Add visibility system for time and weather effects
Night, fog, and storms now reduce viewport size. Night reduces by 6 width and 2 height (21x11 -> 15x9). Thick fog reduces by 8 width and 4 height. Storm reduces by 4 width and 2 height. Effects stack but clamp to minimum 7x5. Dawn and dusk subtly dim by 2 width.
This commit is contained in:
parent
9594e23011
commit
4c4d947ce2
4 changed files with 300 additions and 17 deletions
|
|
@ -17,6 +17,7 @@ from mudlib.render.room import (
|
||||||
)
|
)
|
||||||
from mudlib.seasons import get_season
|
from mudlib.seasons import get_season
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.visibility import get_visibility
|
||||||
from mudlib.weather import (
|
from mudlib.weather import (
|
||||||
WeatherCondition,
|
WeatherCondition,
|
||||||
get_current_weather,
|
get_current_weather,
|
||||||
|
|
@ -90,16 +91,38 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compute environment state once
|
||||||
|
hour = None
|
||||||
|
day = None
|
||||||
|
weather = None
|
||||||
|
season = None
|
||||||
|
try:
|
||||||
|
hour = get_game_hour()
|
||||||
|
day = get_game_day()
|
||||||
|
weather = get_current_weather()
|
||||||
|
season = get_season(day)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use hour/weather for visibility
|
||||||
|
if hour is not None and weather is not None:
|
||||||
|
effective_width, effective_height = get_visibility(
|
||||||
|
hour, weather, VIEWPORT_WIDTH, VIEWPORT_HEIGHT
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_width = VIEWPORT_WIDTH
|
||||||
|
effective_height = VIEWPORT_HEIGHT
|
||||||
|
|
||||||
# Get the viewport from the zone
|
# Get the viewport from the zone
|
||||||
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
viewport = zone.get_viewport(player.x, player.y, effective_width, effective_height)
|
||||||
|
|
||||||
# Calculate center position
|
# Calculate center position
|
||||||
center_x = VIEWPORT_WIDTH // 2
|
center_x = effective_width // 2
|
||||||
center_y = VIEWPORT_HEIGHT // 2
|
center_y = effective_height // 2
|
||||||
|
|
||||||
# Get nearby entities (players and mobs) from the zone
|
# Get nearby entities (players and mobs) from the zone
|
||||||
# Viewport half-diagonal distance for range
|
# Viewport half-diagonal distance for range
|
||||||
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
|
viewport_range = effective_width // 2 + effective_height // 2
|
||||||
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
||||||
|
|
||||||
# Build a list of (relative_x, relative_y) for other entities
|
# Build a list of (relative_x, relative_y) for other entities
|
||||||
|
|
@ -129,13 +152,13 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
rel_y = dy + center_y
|
rel_y = dy + center_y
|
||||||
|
|
||||||
# Check if within viewport bounds
|
# Check if within viewport bounds
|
||||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
if 0 <= rel_x < effective_width and 0 <= rel_y < effective_height:
|
||||||
entity_positions.append((rel_x, rel_y))
|
entity_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
# Build the output with ANSI coloring
|
# Build the output with ANSI coloring
|
||||||
# priority: player @ > other players * > mobs * > effects > terrain
|
# priority: player @ > other players * > mobs * > effects > terrain
|
||||||
half_width = VIEWPORT_WIDTH // 2
|
half_width = effective_width // 2
|
||||||
half_height = VIEWPORT_HEIGHT // 2
|
half_height = effective_height // 2
|
||||||
|
|
||||||
output_lines = []
|
output_lines = []
|
||||||
for y, row in enumerate(viewport):
|
for y, row in enumerate(viewport):
|
||||||
|
|
@ -169,13 +192,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
output.append(render_where(zone.description))
|
output.append(render_where(zone.description))
|
||||||
|
|
||||||
# Atmosphere line (between Where and viewport)
|
# Atmosphere line (between Where and viewport)
|
||||||
try:
|
if hour is not None and weather is not None and season is not None:
|
||||||
hour = get_game_hour()
|
|
||||||
day = get_game_day()
|
|
||||||
weather = get_current_weather()
|
|
||||||
season = get_season(day)
|
|
||||||
|
|
||||||
# Get weather description (empty string for clear)
|
|
||||||
if weather.condition == WeatherCondition.clear:
|
if weather.condition == WeatherCondition.clear:
|
||||||
weather_desc = ""
|
weather_desc = ""
|
||||||
else:
|
else:
|
||||||
|
|
@ -183,9 +200,6 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
|
|
||||||
atmosphere = render_atmosphere(hour, weather_desc, season)
|
atmosphere = render_atmosphere(hour, weather_desc, season)
|
||||||
output.append(atmosphere)
|
output.append(atmosphere)
|
||||||
except RuntimeError:
|
|
||||||
# Game time not initialized, skip atmosphere
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Viewport
|
# Viewport
|
||||||
output.append("\r\n".join(output_lines))
|
output.append("\r\n".join(output_lines))
|
||||||
|
|
|
||||||
54
src/mudlib/visibility.py
Normal file
54
src/mudlib/visibility.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Visibility calculations based on time and weather."""
|
||||||
|
|
||||||
|
from mudlib.weather import WeatherCondition, WeatherState
|
||||||
|
|
||||||
|
|
||||||
|
def get_visibility(
|
||||||
|
hour: int,
|
||||||
|
weather: WeatherState,
|
||||||
|
base_width: int = 21,
|
||||||
|
base_height: int = 11,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Calculate effective viewport dimensions.
|
||||||
|
|
||||||
|
Night, fog, and storms reduce visibility. Multiple effects stack
|
||||||
|
but never reduce below minimum (7x5).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (effective_width, effective_height)
|
||||||
|
"""
|
||||||
|
width = base_width
|
||||||
|
height = base_height
|
||||||
|
|
||||||
|
# Time-based reductions
|
||||||
|
if hour >= 20 or hour <= 4:
|
||||||
|
# Night (hours 20-4)
|
||||||
|
width -= 6
|
||||||
|
height -= 2
|
||||||
|
elif 5 <= hour <= 6:
|
||||||
|
# Dawn (hours 5-6)
|
||||||
|
width -= 2
|
||||||
|
elif 18 <= hour <= 19:
|
||||||
|
# Dusk (hours 18-19)
|
||||||
|
width -= 2
|
||||||
|
|
||||||
|
# Weather-based reductions
|
||||||
|
if weather.condition == WeatherCondition.fog:
|
||||||
|
if weather.intensity >= 0.7:
|
||||||
|
# Thick fog
|
||||||
|
width -= 8
|
||||||
|
height -= 4
|
||||||
|
elif weather.intensity >= 0.4:
|
||||||
|
# Moderate fog
|
||||||
|
width -= 4
|
||||||
|
height -= 2
|
||||||
|
elif weather.condition == WeatherCondition.storm:
|
||||||
|
# Storm
|
||||||
|
width -= 4
|
||||||
|
height -= 2
|
||||||
|
|
||||||
|
# Clamp to minimum
|
||||||
|
width = max(7, width)
|
||||||
|
height = max(5, height)
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
|
@ -337,3 +337,83 @@ async def test_look_atmosphere_between_where_and_viewport():
|
||||||
bracket_pos = output.find("[")
|
bracket_pos = output.find("[")
|
||||||
|
|
||||||
assert where_pos < bracket_pos < location_pos
|
assert where_pos < bracket_pos < location_pos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_night_reduces_viewport():
|
||||||
|
"""Look at night should show smaller viewport than day."""
|
||||||
|
|
||||||
|
# Set time to noon (hour 12) - epoch was 12 hours ago
|
||||||
|
# elapsed_game_hours = (time.time() - epoch) / 60 / 1.0 = 12
|
||||||
|
# so epoch = time.time() - 12 * 60
|
||||||
|
init_game_time(epoch=time.time() - 12 * 60, real_minutes_per_game_hour=1.0)
|
||||||
|
init_weather(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
|
||||||
|
)
|
||||||
|
player.location = zone
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
day_output = get_output(player)
|
||||||
|
|
||||||
|
# Find viewport section (between atmosphere and Location:)
|
||||||
|
day_lines = day_output.split("\r\n")
|
||||||
|
# Viewport is after atmosphere (containing '[') and before Location:
|
||||||
|
viewport_start_idx = None
|
||||||
|
viewport_end_idx = None
|
||||||
|
for i, line in enumerate(day_lines):
|
||||||
|
if "[" in line and viewport_start_idx is None:
|
||||||
|
viewport_start_idx = i + 1
|
||||||
|
if line.startswith("Location:"):
|
||||||
|
viewport_end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
assert viewport_start_idx is not None
|
||||||
|
assert viewport_end_idx is not None
|
||||||
|
day_viewport_height = viewport_end_idx - viewport_start_idx
|
||||||
|
|
||||||
|
# Reset writer
|
||||||
|
mock_writer.reset_mock()
|
||||||
|
|
||||||
|
# Set time to night (hour 22) - epoch was 22 hours ago
|
||||||
|
init_game_time(epoch=time.time() - 22 * 60, real_minutes_per_game_hour=1.0)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
night_output = get_output(player)
|
||||||
|
|
||||||
|
# Find viewport section
|
||||||
|
night_lines = night_output.split("\r\n")
|
||||||
|
viewport_start_idx = None
|
||||||
|
viewport_end_idx = None
|
||||||
|
for i, line in enumerate(night_lines):
|
||||||
|
if "[" in line and viewport_start_idx is None:
|
||||||
|
viewport_start_idx = i + 1
|
||||||
|
if line.startswith("Location:"):
|
||||||
|
viewport_end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
assert viewport_start_idx is not None
|
||||||
|
assert viewport_end_idx is not None
|
||||||
|
night_viewport_height = viewport_end_idx - viewport_start_idx
|
||||||
|
|
||||||
|
# Night should have fewer viewport lines (9 vs 11)
|
||||||
|
assert night_viewport_height < day_viewport_height
|
||||||
|
assert day_viewport_height == 11 # full viewport at noon
|
||||||
|
assert night_viewport_height == 9 # reduced viewport at night
|
||||||
|
|
|
||||||
135
tests/test_visibility.py
Normal file
135
tests/test_visibility.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Tests for visibility calculations."""
|
||||||
|
|
||||||
|
from mudlib.visibility import get_visibility
|
||||||
|
from mudlib.weather import WeatherCondition, WeatherState
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_day_full_visibility():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_reduces_visibility():
|
||||||
|
hour = 22 # night (20-4)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # 21 - 6
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_thick_fog_during_day():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.8)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 13 # 21 - 8
|
||||||
|
assert height == 7 # 11 - 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_plus_thick_fog_clamps_to_minimum():
|
||||||
|
hour = 22 # night
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.9)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
# Night: -6 width, -2 height (21x11 -> 15x9)
|
||||||
|
# Thick fog: -8 width, -4 height (15x9 -> 7x5)
|
||||||
|
# Should clamp to minimum 7x5
|
||||||
|
assert width == 7
|
||||||
|
assert height == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_storm_reduces_visibility():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.storm, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 17 # 21 - 4
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dawn_subtle_dimming():
|
||||||
|
hour = 5 # dawn (5-6)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 19 # 21 - 2
|
||||||
|
assert height == 11 # 11 - 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dusk_subtle_dimming():
|
||||||
|
hour = 18 # dusk (18-19)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 19 # 21 - 2
|
||||||
|
assert height == 11 # 11 - 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moderate_fog():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 17 # 21 - 4
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_light_fog_no_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.3)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21 # no reduction for light fog
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudy_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.cloudy, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_rain_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.rain, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_snow_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.snow, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_base_dimensions():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
|
||||||
|
assert width == 31
|
||||||
|
assert height == 21
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_custom_base():
|
||||||
|
hour = 22 # night
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
|
||||||
|
assert width == 25 # 31 - 6
|
||||||
|
assert height == 19 # 21 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_midnight_is_night():
|
||||||
|
hour = 0 # midnight
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # reduced for night
|
||||||
|
assert height == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_early_morning_is_night():
|
||||||
|
hour = 3 # early morning
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # reduced for night
|
||||||
|
assert height == 9
|
||||||
Loading…
Reference in a new issue