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:
Jared Miller 2026-02-14 16:05:05 -05:00
parent 9594e23011
commit 4c4d947ce2
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 300 additions and 17 deletions

View file

@ -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
View 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

View file

@ -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
View 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