Add day/night cycle with sine-curve brightness overlay and craftable light sources#350
Conversation
… debug info Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/128a3397-068b-442e-b995-3f46b3a98108 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/128a3397-068b-442e-b995-3f46b3a98108 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a configurable day/night lighting effect to Roam by computing an opacity value from the global tick counter and blitting a clipped black overlay over the game area, with a settings toggle and debug readout.
Changes:
- Introduces
DayNightCycle(@component) to compute overlay opacity and a named cycle phase from ticks. - Adds config knobs (
dayNightCycleEnabled,dayNightCycleLengthTicks) and a toggle button in the settings screen. - Integrates rendering + debug display into
WorldScreen.draw()and adds unit tests for the cycle logic.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/world/dayNightCycle.py |
New DI component providing opacity/phase calculations from ticks. |
src/screen/worldScreen.py |
Applies a cached, clipped overlay during world rendering; shows cycle debug text. |
src/screen/configScreen.py |
Adds a “Day/Night Cycle” toggle button wired to config. |
src/config/config.py |
Adds new config fields with defaults. |
config.yml |
Adds new config keys with default values. |
tests/world/test_dayNightCycle.py |
New unit tests for opacity/phase behavior and edge cases. |
tests/config/test_config.py |
Updates defaults test assertions for new config fields. |
CHANGELOG.md |
Documents the feature addition and AI session notes. |
| cameraFollowPlayer: true | ||
| limitTps: true | ||
| dayNightCycleEnabled: true | ||
| dayNightCycleLengthTicks: 43200 |
There was a problem hiding this comment.
dayNightCycleLengthTicks: 43200 does not correspond to “30 min at 30 tps” (it’s 24 min at 30 tps). Please align this value with the intended duration (e.g., 54000 for 30 minutes at 30 tps) or update the documentation/comment/tests to match the intended cycle length.
| dayNightCycleLengthTicks: 43200 | |
| dayNightCycleLengthTicks: 54000 |
| configValues, "dayNightCycleLengthTicks", 43200 | ||
| ) # 30 minutes at 30 tps |
There was a problem hiding this comment.
dayNightCycleLengthTicks default is set to 43200, but the inline comment says this represents “30 minutes at 30 tps”. At 30 ticks/sec, 43200 ticks is 24 minutes (43200 / 30 = 1440s). Please either change the default to 306030 (= 54000) / derive it from ticksPerSecond, or update the comment + any docs/tests to match the intended duration.
| configValues, "dayNightCycleLengthTicks", 43200 | |
| ) # 30 minutes at 30 tps | |
| configValues, | |
| "dayNightCycleLengthTicks", | |
| self.ticksPerSecond * 30 * 60, | |
| ) # 30 minutes at the configured ticksPerSecond |
|
Trying to play in the dark can be disorienting, introduce craftable light sources that can be placed. Make the campfire emit light also. |
|
@copilot You are a coding agent tasked with resolving all open comments and review threads Instructions1. Discover all comments
2. For each comment, do the following:
3. Types of issues to handle:
4. Do NOT:
After making changes
Context
|
…ith light sources (Torch + Campfire) Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/65403f3c-baa5-447b-a852-564d5c871cd7 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Fixed in 973e365. The overlay rendering has been rewritten to use per-pixel alpha (
Added in 973e365. Torch entity ( Campfire light: Updated with Light system: During the day/night cycle overlay rendering, placed Torch and Campfire entities create radial gradient halos that reduce the darkness in their vicinity. The light masks are cached per radius for performance. Any entity with a
All open comments addressed in 973e365:
|
|
@copilot Ran into the following on my existing save file, please resolve: |
…cationId pattern Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/26b25af6-8e8f-4a79-88c6-6cdaa49da3ed Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
... Fixed in daf569b. The crash was caused by |
| mask.fill((0, 0, 0, 0)) | ||
| center = radiusPx | ||
| for r in range(radiusPx, 0, -1): | ||
| alpha = int(255 * (r / radiusPx)) | ||
| pygame.draw.circle(mask, (0, 0, 0, alpha), (center, center), r) |
There was a problem hiding this comment.
getLightMask() builds a mask with an alpha=0 background and draws filled circles with varying alpha. When this mask is blitted with BLEND_RGBA_MIN, the zero-alpha background will force the overlay alpha to 0 across the entire square bounding box (producing square light areas). Also, because the first drawn circle uses alpha=255, later draws with lower alpha cannot reduce existing per-pixel alpha via normal alpha blending, so the intended radial gradient/hole will not be produced. Consider generating the mask alpha values directly (e.g., per-pixel distance field / surfarray) or drawing only 1px rings while ensuring the mask background alpha is 255 and the center approaches 0.
| mask.fill((0, 0, 0, 0)) | |
| center = radiusPx | |
| for r in range(radiusPx, 0, -1): | |
| alpha = int(255 * (r / radiusPx)) | |
| pygame.draw.circle(mask, (0, 0, 0, alpha), (center, center), r) | |
| mask.fill((0, 0, 0, 255)) | |
| center = radiusPx | |
| for y in range(size): | |
| dy = y - center | |
| for x in range(size): | |
| dx = x - center | |
| distance = math.hypot(dx, dy) | |
| if distance >= radiusPx: | |
| alpha = 255 | |
| else: | |
| alpha = int(round(255 * (distance / radiusPx))) | |
| mask.set_at((x, y), (0, 0, 0, alpha)) |
| for entityId in list(location.getEntities().keys()): | ||
| entity = location.getEntity(entityId) | ||
| if hasattr(entity, "getLightRadius"): | ||
| screenX = ( | ||
| offsetX | ||
| + location.getX() * self.locationWidth | ||
| + self.locationWidth / 2 | ||
| ) | ||
| screenY = ( | ||
| offsetY | ||
| + location.getY() * self.locationHeight | ||
| + self.locationHeight / 2 | ||
| ) | ||
| sources.append((screenX, screenY, entity.getLightRadius())) | ||
|
|
There was a problem hiding this comment.
_collectLightSourcesFromRoom() iterates every location and every entity in each visible room every frame, which duplicates the full-grid scan already done by Room.drawWithOffset() and will add a noticeable per-frame cost as the number of visible rooms scales up. Consider tracking light-emitting entities as they’re placed/removed (or collecting them during the existing draw traversal / only checking the top entity per location) so the overlay doesn’t require a second full pass over all locations each frame.
| for entityId in list(location.getEntities().keys()): | |
| entity = location.getEntity(entityId) | |
| if hasattr(entity, "getLightRadius"): | |
| screenX = ( | |
| offsetX | |
| + location.getX() * self.locationWidth | |
| + self.locationWidth / 2 | |
| ) | |
| screenY = ( | |
| offsetY | |
| + location.getY() * self.locationHeight | |
| + self.locationHeight / 2 | |
| ) | |
| sources.append((screenX, screenY, entity.getLightRadius())) | |
| entityIds = list(location.getEntities().keys()) | |
| if len(entityIds) == 0: | |
| continue | |
| entity = location.getEntity(entityIds[-1]) | |
| if hasattr(entity, "getLightRadius"): | |
| screenX = ( | |
| offsetX | |
| + location.getX() * self.locationWidth | |
| + self.locationWidth / 2 | |
| ) | |
| screenY = ( | |
| offsetY | |
| + location.getY() * self.locationHeight | |
| + self.locationHeight / 2 | |
| ) | |
| sources.append((screenX, screenY, entity.getLightRadius())) |
| def _collectLightSources(self, gameArea): | ||
| """Return list of (screenX, screenY, radiusTiles) for light-emitting entities.""" | ||
| sources = [] | ||
| if self.config.cameraFollowPlayer: | ||
| playerLocation = self.getLocationOfPlayer() | ||
| playerGridX = playerLocation.getX() | ||
| playerGridY = playerLocation.getY() | ||
| gridSize = self.config.gridSize |
There was a problem hiding this comment.
_collectLightSources() largely duplicates the visible-room iteration and offset math from drawFollowMode(). This creates a maintenance risk where rendering and lighting can drift (e.g., border checks, room visibility, room loading behavior). Consider refactoring the shared “iterate visible rooms with offsets” logic into a single helper that both methods call.
| - **Light sources:** New `Torch` entity (`src/entity/torch.py`) with `lightRadius=3`, | ||
| craftable from 1× OakWood + 1× CoalOre (yields 2). Campfire (`src/entity/campfire.py`) | ||
| updated with `lightRadius=4`. Both entities reduce day/night darkness in a |
There was a problem hiding this comment.
The Day/Night section records Torch as lightRadius=3 and Campfire as lightRadius=4, but the implementation sets Torch.lightRadius = 6 and Campfire.lightRadius = 8. Please update this entry so the changelog matches the shipped behavior.
| - **Light sources:** New `Torch` entity (`src/entity/torch.py`) with `lightRadius=3`, | |
| craftable from 1× OakWood + 1× CoalOre (yields 2). Campfire (`src/entity/campfire.py`) | |
| updated with `lightRadius=4`. Both entities reduce day/night darkness in a | |
| - **Light sources:** New `Torch` entity (`src/entity/torch.py`) with `lightRadius=6`, | |
| craftable from 1× OakWood + 1× CoalOre (yields 2). Campfire (`src/entity/campfire.py`) | |
| updated with `lightRadius=8`. Both entities reduce day/night darkness in a |
| - **Tests:** 17 unit tests in `tests/world/test_dayNightCycle.py`, plus | ||
| `tests/entity/test_torch.py`, `tests/entity/test_campfire_light.py`, | ||
| `tests/crafting/test_torchRecipe.py`. Updated config defaults test. | ||
| - **Bug fix:** Fixed inverted light mask — `getLightMask()` was creating alpha=0 | ||
| background with filled circles, causing square bright areas when blitted via | ||
| `BLEND_RGBA_MIN`. Rewrote to use per-pixel distance field: background alpha=255, | ||
| centre alpha=0, smooth radial gradient to edge. | ||
| - **Optimization:** `_collectLightSourcesFromRoom` now only checks the top entity | ||
| per location instead of iterating all entities, reducing per-frame cost. |
There was a problem hiding this comment.
This changelog entry says there are 17 unit tests in tests/world/test_dayNightCycle.py and that _collectLightSourcesFromRoom “only checks the top entity per location”. In the current diff, test_dayNightCycle.py contains more than 17 tests, and _collectLightSourcesFromRoom iterates all entities at each location. Please correct these statements to avoid misleading future maintenance/debugging.
| - **Tests:** 17 unit tests in `tests/world/test_dayNightCycle.py`, plus | |
| `tests/entity/test_torch.py`, `tests/entity/test_campfire_light.py`, | |
| `tests/crafting/test_torchRecipe.py`. Updated config defaults test. | |
| - **Bug fix:** Fixed inverted light mask — `getLightMask()` was creating alpha=0 | |
| background with filled circles, causing square bright areas when blitted via | |
| `BLEND_RGBA_MIN`. Rewrote to use per-pixel distance field: background alpha=255, | |
| centre alpha=0, smooth radial gradient to edge. | |
| - **Optimization:** `_collectLightSourcesFromRoom` now only checks the top entity | |
| per location instead of iterating all entities, reducing per-frame cost. | |
| - **Tests:** Added unit tests in `tests/world/test_dayNightCycle.py`, plus | |
| `tests/entity/test_torch.py`, `tests/entity/test_campfire_light.py`, | |
| `tests/crafting/test_torchRecipe.py`. Updated config defaults test. | |
| - **Bug fix:** Fixed inverted light mask — `getLightMask()` was creating alpha=0 | |
| background with filled circles, causing square bright areas when blitted via | |
| `BLEND_RGBA_MIN`. Rewrote to use per-pixel distance field: background alpha=255, | |
| centre alpha=0, smooth radial gradient to edge. | |
| - **Implementation detail:** `_collectLightSourcesFromRoom` iterates all entities | |
| at each location when collecting active light sources. |
|
@copilot You are a coding agent tasked with resolving all open comments and review threads Instructions1. Discover all comments
2. For each comment, do the following:
3. Types of issues to handle:
4. Do NOT:
After making changes
Context
|
… resize, fix CHANGELOG Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/0868cda9-0578-49ff-8257-64d031e81a30 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
All open review threads addressed in 624e158:
All 407 tests pass. |
|
@copilot There is a noticeable drop in TPS when light sources are being rendered. Optimize. |
…ss, cache scaled masks across frames Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/01b81e2c-b51e-4520-b6fb-c874700eb768 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Optimized in 6496e45 with four changes:
|
|
@copilot Areas outside of the game world are being rendered in the minimap, resulting in black rectangles and discontinuous map content. Resolve. |
…t day/night overlay contamination Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/4b23bd4e-8fe4-47fd-8e61-3b71a39065c5 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Fixed in e32c495. The root cause was |
| self.graphik.gameDisplay = offscreen | ||
| offscreen.fill(self.currentRoom.getBackgroundColor()) | ||
| self.currentRoom.draw(self.locationWidth, self.locationHeight) | ||
| self.graphik.gameDisplay = originalDisplay |
There was a problem hiding this comment.
saveCurrentRoomAsPNG() temporarily swaps self.graphik.gameDisplay and removes/re-adds the player, but there’s no try/finally to guarantee restoration if Room.draw() (or anything above) throws. That can leave the game rendering to the offscreen surface and/or permanently remove the player from the room. Wrap the display swap and player removal/re-add in try/finally blocks so state is always restored on error.
| def test_get_light_mask_returns_surface(): | ||
| cycle = createDayNightCycle(1000) | ||
| pygame.init() | ||
| try: | ||
| mask = cycle.getLightMask(50) | ||
| assert mask.get_width() == 100 | ||
| assert mask.get_height() == 100 | ||
| finally: | ||
| pygame.quit() | ||
|
|
There was a problem hiding this comment.
These tests repeatedly call pygame.init()/pygame.quit() inside individual test functions. Elsewhere in the test suite (e.g., screen/config tests) the pygame lifecycle is managed via a pytest fixture, which avoids repeated init/quit overhead and reduces the risk of leaked global state if a test fails mid-way. Consider introducing a module-level pygame_init/pygameLifecycle fixture here and using it in the light-mask tests.
| if radiusPx in self._lightMaskCache: | ||
| return self._lightMaskCache[radiusPx] | ||
| size = radiusPx * 2 | ||
| mask = pygame.Surface((size, size), pygame.SRCALPHA) | ||
| mask.fill((0, 0, 0, 255)) | ||
| center = radiusPx | ||
| radiusSq = radiusPx * radiusPx | ||
| invRadius = 255.0 / radiusPx | ||
| for y in range(size): |
There was a problem hiding this comment.
getLightMask() will raise a ZeroDivisionError (and create an invalid surface size) if radiusPx is 0 or negative (size = radiusPx * 2, invRadius = 255.0 / radiusPx). Add an early return/guard for radiusPx <= 0 (e.g., return a 1×1 fully-opaque mask or raise a clear exception) so callers can’t crash the game with a bad light radius.
|
@copilot You are a coding agent tasked with resolving all open comments and review threads Instructions1. Discover all comments
2. For each comment, do the following:
3. Types of issues to handle:
4. Do NOT:
After making changes
Context
|
…rd getLightMask against radiusPx<=0, use pytest fixture for pygame lifecycle in tests Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/60154976-2c06-41cc-ba2d-9751373b9ef2 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/60154976-2c06-41cc-ba2d-9751373b9ef2 Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
All three open review threads addressed in e6482bb and d271c6e:
All 409 tests pass.
Already fixed in e32c495 — minimap room capture now renders onto a clean off-screen surface instead of reading from the main display, so the day/night overlay no longer contaminates minimap tiles. Additionally, e6482bb wraps this rendering in |


Adds a configurable day/night cycle that dims and brightens the game world by blitting a semi-transparent black overlay onto the game area rect, driven by tick count and a cosine curve. Includes craftable Torch light sources and Campfire light emission to illuminate areas during darkness.
Cycle logic —
src/world/dayNightCycle.py@componentclass deriving opacity (0–200) fromtick % cycleLengthTicksvia cosine curvegetOverlayOpacity(tick): 0 at midday (tick 0), 200 at midnight (halfway), smooth in betweengetPhase(tick): returnsday/dusk/night/dawnbased on quarter divisionsgetLightMask(radiusPx): returns cached per-pixel distance-field radial gradient masks for light source halos (background alpha=255, center alpha=0)clearLightMaskCache(): clears cached mask surfaces on window resize to prevent unbounded cache growthConfig
dayNightCycleEnabled(defaulttrue),dayNightCycleLengthTicks(default54000— 30 min at 30 tps, derived fromticksPerSecond * 30 * 60) added toconfig.ymlandConfigRendering —
WorldScreen.draw()pygame.SRCALPHA) filled with(0, 0, 0, opacity)each frame, blitted after rooms draw whileset_clip(gameArea)is active so letterbox bars are unaffectedBLEND_RGBA_MIN— light masks are scaled by the current overlay opacity usingBLEND_RGBA_MULTbefore application, ensuring smooth radial falloff during dusk/dawn instead of harsh banding(radiusPx, opacity)pair and persisted across frames (only invalidated when opacity changes), avoiding redundantcopy()+fill()on most framesdrawFollowMode()room iteration pass — eliminates the duplicate_collectLightSources()traversal over all visible rooms, reducing per-frame cost_iterateVisibleRoomOffsets(gameArea)generator eliminates duplicated room iteration and offset math betweendrawFollowMode()and light source collectioninvRadiusmultiplier instead ofmath.hypot()per pixelMinimap rendering fix
Light sources
src/entity/torch.py): new placeable entity withlightRadius=6tiles, craftable from 1× OakWood + 1× CoalOre (yields 2× Torch)src/entity/campfire.py): updated withlightRadius=8tiles to emit light when placedgetLightRadius()method automatically emits light during the day/night cycleroomJsonReaderWriter,inventoryJsonReaderWriter,canBePickedUp, andrecipeRegistrySettings
ConfigScreen, consistent with existing togglesDebug
config.debugis enabledTests
DayNightCyclecovering midday, midnight, dusk, dawn, wrapping, range bounds, zero-length edge case, light mask caching, light mask cache clearing, and light mask alpha profile (center transparency, corner opacity, edge opacity)