Phase 11 — Day/night cycle + Lighting (taken before Phase 9 per recommendation)
Three gdscript-refactor agents in parallel; Opus integrated and verified
the day-night transition + torch lighting via MCP runtime + screenshot.
Clock autoload (Agent A, autoload/clock.gd, ~138 lines):
- TICKS_PER_DAY = 4800 → 4 min/day at 1× / 48 s at Fast / 20 s at Ultra
- TICKS_PER_HOUR = 200 (so 60 min × ~3 ticks per minute)
- 4-phase day: night → dawn (5–7) → day (7–19) → dusk (19–22) → night
- darkness_factor() returns 0..1 with linear ramps across dawn/dusk
- phase_changed signal fires on phase transitions
- save_dict / apply_dict for save round-trip
- Boots at Day 1, 06:00 (mid-dawn for atmospheric start)
- Registered in project.godot autoload list (Opus)
Top-bar clock UI (Agent A):
- ClockLabel added to top_bar.tscn (center-anchored at ±80 px)
- _on_clock_refresh in top_bar.gd; early-out string compare to skip text
assignments when unchanged (cheap per-tick)
Torch entity + lights registry (Agent B, scenes/entities/torch.{gd,tscn} +
workbench.gd + world.gd, ~210 lines):
- class Torch: buildable furniture, BUILD_TICKS=30, LIGHT_RADIUS=6
- Procedural radial gradient texture (64×64) generated at runtime with
smoothstep falloff → no PNG dependency
- PointLight2D child with the gradient texture, warm fire tint, energy 1.2
- is_on / get_light_tile / get_light_radius duck-typed interface; same
shape exposed by Workbench when label_text='Hearth' (HEARTH_LIGHT_RADIUS=5)
- World.light_sources registry + register/unregister + is_tile_lit(tile)
(Manhattan distance, no occlusion — Phase 13 may add wall-occlusion)
CanvasModulate darkness + in_darkness thought (Agent C, ~30 lines mod +
new factory):
- DarkOverlay CanvasModulate node added to world.tscn (first child of
World root so it tints all sibling layers + entities)
- world.gd._update_dark_overlay lerps DAY_TINT (white) ↔ NIGHT_TINT
(0.20, 0.22, 0.40 deep cool blue) by Clock.darkness_factor() each tick
- ThoughtCatalog.in_darkness(): persistent, -3 mood, fires when
darkness > 0.3 AND World.is_tile_lit(pawn.tile) is false
- Pawn._process_thoughts syncs in_darkness alongside hungry/tired
Opus integration:
- project.godot: Clock autoload registered
- world.tscn: DarkOverlay CanvasModulate node, plus the agent additions
- Demo seed: 2 torches inside cabin at (46, 26) + (49, 26), pre-built
- MCP-driven runtime test verified day→night transition + lighting
effects:
- Noon: world bright green, torches barely visible (over-bright at noon
is minor polish — Phase 17 may scale torch energy by darkness)
- Midnight: world deep blue/green tinted, torches cast yellow halos,
Hearth ember glows orange, cabin interior warmly lit, exterior dark
- top_bar clock label updates each sim tick (early-out on no-change)
Phase 11 followups for later phases:
- Torch energy should scale with darkness — visible halos at noon are
silly. Phase 17 will likely tie PointLight2D.energy to clamp(darkness,
0.2, 1.0) so they're invisible at midday
- Wall-occlusion for light_map — Phase 13's room-detection BFS could
treat completed wall tiles as occluders so light doesn't bleed through
- 'In darkness' thought currently treats ALL unlit cells as darkness;
Phase 13's roof flag could differentiate 'indoors-dark' (different
thought) from 'outdoors-dark'
- Light source visibility through CanvasModulate works correctly thanks
to PointLight2D's additive blend mode
Acceptance — MCP-verified via play_scene + get_game_screenshot:
- ✅ Day → Dusk → Night cycle visible (Clock.current_phase emits events)
- ✅ CanvasModulate tints world deep blue at night
- ✅ Torches cast visible yellow halos via PointLight2D additive blend
- ✅ Hearth opts-in as a light source via label_text='Hearth' check
- ✅ Top-bar clock shows 'Day N, HH:MM' format and updates each tick
- ✅ in_darkness thought wires through _process_thoughts (would fire if
a pawn were standing in an unlit night tile — demo didn't capture this
specifically but the code path is verified)
Delegation report this phase:
- Agent A: Clock autoload + 4-phase day cycle + top-bar UI extension
- Agent B: Torch entity + PointLight2D + procedural radial texture +
Workbench Hearth opt-in + World.light_sources registry
- Agent C: CanvasModulate world.tscn node + day/night colour lerp +
in_darkness ThoughtCatalog entry + Pawn persistent thought sync
- Opus: Clock autoload registration in project.godot + 2 torches in
demo seed + MCP runtime verification at midnight vs noon
~75% of Phase 11 GDScript was subagent-authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43e52ffe75
commit
a1e5b38dd6
16 changed files with 606 additions and 10 deletions
245
scenes/entities/torch.gd
Normal file
245
scenes/entities/torch.gd
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
class_name Torch extends Node2D
|
||||
## Torch furniture entity — buildable wall-hung or floor-standing light source.
|
||||
##
|
||||
## Rendered as a small stick + wrapped head + flame using _draw() in the same
|
||||
## 3/4-perspective convention as Wall / Bed / Workbench. Ghost state (40% alpha)
|
||||
## while construction is in progress; solid + lit once _completed.
|
||||
##
|
||||
## Light model (docs/architecture.md "LightingSystem"):
|
||||
## Emits a PointLight2D with a procedural radial-gradient texture (soft
|
||||
## falloff). The Godot visual light is additive with CanvasModulate for
|
||||
## night-time darkness. The sim-side light radius (LIGHT_RADIUS tiles,
|
||||
## Manhattan distance) is registered with World.light_sources so that
|
||||
## is_tile_lit() can answer "in darkness" thought queries cheaply.
|
||||
##
|
||||
## Light-source duck-type interface (shared with Hearth / Workbench):
|
||||
## is_on() → bool
|
||||
## get_light_tile() → Vector2i
|
||||
## get_light_radius() → int
|
||||
##
|
||||
## Build model (same BuildJob interface as Wall / Bed / Workbench):
|
||||
## BUILD_TICKS ticks via the standard BuildJob toil; PointLight2D enabled
|
||||
## only after _complete().
|
||||
##
|
||||
## Save/load: to_dict / from_dict capture tile, label_text, build_progress,
|
||||
## _completed, _is_on. Phase 16 wires these into the full save layer.
|
||||
##
|
||||
## World registration: World.register_light_source / World.unregister_light_source
|
||||
## called from _ready / _exit_tree.
|
||||
|
||||
const TILE_SIZE_PX: int = 16
|
||||
|
||||
## Sim ticks to build a torch (30 ticks ≈ 1.5 sim seconds at 1×).
|
||||
const BUILD_TICKS: int = 30
|
||||
|
||||
## Sim-side light radius in tiles (Manhattan). Max 8 per architecture.md.
|
||||
const LIGHT_RADIUS: int = 6
|
||||
|
||||
## Pixel size of the procedural radial gradient used for the PointLight2D.
|
||||
## Larger values give a smoother falloff; 64 is sufficient for a 6-tile radius.
|
||||
const LIGHT_TEXTURE_SIZE: int = 64
|
||||
|
||||
# ── exports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
## Tile position of this torch in world-tile coordinates.
|
||||
@export var tile: Vector2i = Vector2i.ZERO
|
||||
|
||||
## Player-visible label. Drives Audit logs and job descriptions.
|
||||
@export var label_text: String = "Torch"
|
||||
|
||||
# ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
## Ticks of construction work applied so far. 0..BUILD_TICKS.
|
||||
var build_progress: int = 0
|
||||
|
||||
## True once build_progress >= BUILD_TICKS.
|
||||
var _completed: bool = false
|
||||
|
||||
## Whether the torch is emitting light. Always true for Phase 11;
|
||||
## Phase 17 may add fuel consumption or a player on/off toggle.
|
||||
var _is_on: bool = true
|
||||
|
||||
## PointLight2D child node for Godot's visual lighting pipeline.
|
||||
## Built in _ready(); enabled only once _complete() fires.
|
||||
var _light: PointLight2D = null
|
||||
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func _ready() -> void:
|
||||
# Bottom-anchor so Y-sort occludes pawns correctly (same pivot as Wall/Bed).
|
||||
position = Vector2(
|
||||
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||||
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
|
||||
)
|
||||
World.register_light_source(self)
|
||||
_light = _build_point_light_2d()
|
||||
add_child(_light)
|
||||
_light.enabled = false # dark until built
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
World.unregister_light_source(self)
|
||||
|
||||
|
||||
## One-shot initialiser. Call after add_child() so _ready() has already fired.
|
||||
func setup(p_tile: Vector2i) -> void:
|
||||
tile = p_tile
|
||||
position = Vector2(
|
||||
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||||
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
|
||||
)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
# ── BuildJob interface (matches Wall / Bed / Workbench shape) ─────────────────
|
||||
|
||||
## True while the torch still needs construction work.
|
||||
func is_buildable() -> bool:
|
||||
return not _completed
|
||||
|
||||
|
||||
## Human-readable label for job descriptions and Audit logs.
|
||||
func label() -> String:
|
||||
return label_text
|
||||
|
||||
|
||||
## Called by the BUILD toil in JobRunner once per sim tick while the pawn works.
|
||||
## Advances build_progress and completes the torch at BUILD_TICKS.
|
||||
func on_build_tick() -> void:
|
||||
if _completed:
|
||||
return
|
||||
build_progress += 1
|
||||
queue_redraw()
|
||||
if build_progress >= BUILD_TICKS:
|
||||
_complete()
|
||||
|
||||
|
||||
## True once the torch has been fully built.
|
||||
func is_completed() -> bool:
|
||||
return _completed
|
||||
|
||||
|
||||
## Torches are walkable — pawns step around them visually but the tile remains
|
||||
## passable for pathfinding purposes.
|
||||
func blocks_pathing_when_complete() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# ── light-source duck-type interface ──────────────────────────────────────────
|
||||
## Used by World.is_tile_lit() and the "in darkness" thought.
|
||||
## Hearth / Workbench exposes the same three functions.
|
||||
|
||||
## True when the torch is built and switched on.
|
||||
func is_on() -> bool:
|
||||
return _completed and _is_on
|
||||
|
||||
|
||||
## The tile this light source occupies (for distance calculations).
|
||||
func get_light_tile() -> Vector2i:
|
||||
return tile
|
||||
|
||||
|
||||
## The sim-side Manhattan-distance radius of this light source.
|
||||
func get_light_radius() -> int:
|
||||
return LIGHT_RADIUS
|
||||
|
||||
|
||||
## Toggle the torch on/off. Phase 17 may call this from a fuel-depletion system
|
||||
## or player action; safe to call any time including before _complete().
|
||||
func set_on(value: bool) -> void:
|
||||
_is_on = value
|
||||
if _light != null:
|
||||
_light.enabled = _completed and _is_on
|
||||
Audit.log("light", "torch at %s set_on → %s" % [tile, value])
|
||||
|
||||
|
||||
# ── save / load ───────────────────────────────────────────────────────────────
|
||||
|
||||
## Serialise all persistent state for World save (wired in Phase 16).
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"tile_x": tile.x,
|
||||
"tile_y": tile.y,
|
||||
"label_text": label_text,
|
||||
"build_progress": build_progress,
|
||||
"completed": _completed,
|
||||
"is_on": _is_on,
|
||||
}
|
||||
|
||||
|
||||
## Restore from a dict produced by to_dict().
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
label_text = str(d.get("label_text", "Torch"))
|
||||
build_progress = int(d.get("build_progress", 0))
|
||||
_completed = bool(d.get("completed", false))
|
||||
_is_on = bool(d.get("is_on", true))
|
||||
# _light is rebuilt in _ready() — no need to restore the node.
|
||||
setup(tile)
|
||||
|
||||
|
||||
# ── render ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func _draw() -> void:
|
||||
# 3/4-perspective torch — fits within the tile (16×16 local box).
|
||||
# Origin (0,0) = tile bottom-centre. Tile spans local Y: -16 to 0.
|
||||
# Ghost (not yet built) draws at 0.4 alpha.
|
||||
var alpha: float = 1.0 if _completed else 0.4
|
||||
|
||||
# Stick — narrow vertical brown rect, centred horizontally.
|
||||
draw_rect(Rect2(Vector2(-1.0, -9.0), Vector2(2.0, 9.0)), Color(0.35, 0.22, 0.10, alpha))
|
||||
|
||||
# Wrapped head — slightly wider, darker, at the top of the stick.
|
||||
draw_rect(Rect2(Vector2(-2.0, -13.0), Vector2(4.0, 4.0)), Color(0.20, 0.14, 0.06, alpha))
|
||||
|
||||
# Flame — only when built and on; two overlapping circles for depth.
|
||||
if _completed and _is_on:
|
||||
draw_circle(Vector2(0.0, -15.0), 2.5, Color(1.0, 0.65, 0.10, alpha))
|
||||
draw_circle(Vector2(0.0, -16.0), 1.5, Color(1.0, 0.90, 0.40, alpha))
|
||||
|
||||
|
||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
## Construct and return the PointLight2D that provides Godot-side visual
|
||||
## lighting. The light is positioned at the flame, not the tile base.
|
||||
func _build_point_light_2d() -> PointLight2D:
|
||||
var p := PointLight2D.new()
|
||||
p.texture = _build_radial_light_texture(LIGHT_TEXTURE_SIZE)
|
||||
# Scale the texture so its radius in world-pixels matches LIGHT_RADIUS tiles.
|
||||
p.texture_scale = float(LIGHT_RADIUS) * float(TILE_SIZE_PX) / float(LIGHT_TEXTURE_SIZE) * 2.0
|
||||
p.color = Color(1.0, 0.85, 0.55, 1.0) # warm fire tint
|
||||
p.energy = 1.2
|
||||
# Offset upward so the light originates from the flame position.
|
||||
p.position = Vector2(0.0, -15.0)
|
||||
return p
|
||||
|
||||
|
||||
## Build a soft radial gradient Image and return it as an ImageTexture.
|
||||
## White centre, fades to transparent at the edge via smoothstep falloff.
|
||||
## Called once per torch in _ready(); result is ~4 KB of VRAM at 64×64 RGBA8.
|
||||
static func _build_radial_light_texture(size: int) -> Texture2D:
|
||||
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
|
||||
var cx: float = float(size) / 2.0
|
||||
var cy: float = float(size) / 2.0
|
||||
var max_r: float = float(size) / 2.0
|
||||
for x in size:
|
||||
for y in size:
|
||||
var dx: float = float(x) - cx
|
||||
var dy: float = float(y) - cy
|
||||
var d: float = sqrt(dx * dx + dy * dy)
|
||||
var t: float = clampf(1.0 - d / max_r, 0.0, 1.0)
|
||||
# Smoothstep so the edge is soft rather than a hard circle cutoff.
|
||||
var a: float = t * t * (3.0 - 2.0 * t)
|
||||
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, a))
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## Called when build_progress reaches BUILD_TICKS.
|
||||
func _complete() -> void:
|
||||
_completed = true
|
||||
if _light != null:
|
||||
_light.enabled = _is_on
|
||||
queue_redraw()
|
||||
Audit.log("torch", "built at %s" % tile)
|
||||
Loading…
Add table
Add a link
Reference in a new issue