rimlike/scenes/ui/top_bar.gd
megaproxy a1e5b38dd6 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>
2026-05-11 15:54:15 +01:00

82 lines
2.9 KiB
GDScript

extends CanvasLayer
## Top-bar HUD: speed/pause buttons and tick counter.
##
## Buttons call Sim.set_speed(); active button is yellow-tinted.
## Tick label updates on every EventBus.sim_tick signal.
## Keyboard shortcuts (pause / speed_normal / speed_fast / speed_ultra) are
## handled here so the bar is the single owner of speed-input logic.
const ACTIVE_MODULATE := Color(1.2, 1.2, 0.8)
const IDLE_MODULATE := Color.WHITE
@onready var pause_btn : Button = $Anchor/ButtonRow/PauseBtn
@onready var normal_btn : Button = $Anchor/ButtonRow/NormalBtn
@onready var fast_btn : Button = $Anchor/ButtonRow/FastBtn
@onready var ultra_btn : Button = $Anchor/ButtonRow/UltraBtn
@onready var tick_label : Label = $Anchor/TickLabel
@onready var clock_label : Label = $Anchor/ClockLabel
# Maps Speed enum value → the corresponding Button node.
var _speed_buttons: Dictionary = {}
# Early-out cache: only set clock_label.text when the string changes.
var _last_clock_text: String = ""
func _ready() -> void:
pause_btn.text = Strings.t(&"speed.pause")
normal_btn.text = Strings.t(&"speed.normal")
fast_btn.text = Strings.t(&"speed.fast")
ultra_btn.text = Strings.t(&"speed.ultra")
tick_label.text = "(boot)"
clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"})
_speed_buttons = {
Sim.Speed.PAUSE: pause_btn,
Sim.Speed.NORMAL: normal_btn,
Sim.Speed.FAST: fast_btn,
Sim.Speed.ULTRA: ultra_btn,
}
pause_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.PAUSE))
normal_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.NORMAL))
fast_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.FAST))
ultra_btn.pressed.connect(func() -> void: Sim.set_speed(Sim.Speed.ULTRA))
EventBus.speed_changed.connect(_on_speed_changed)
EventBus.sim_tick.connect(_on_sim_tick)
EventBus.sim_tick.connect(_on_clock_refresh)
# Reflect the initial speed state without emitting a signal.
_apply_highlight(Sim.current_speed)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("pause"):
Sim.set_speed(Sim.Speed.PAUSE)
elif event.is_action_pressed("speed_normal"):
Sim.set_speed(Sim.Speed.NORMAL)
elif event.is_action_pressed("speed_fast"):
Sim.set_speed(Sim.Speed.FAST)
elif event.is_action_pressed("speed_ultra"):
Sim.set_speed(Sim.Speed.ULTRA)
func _on_speed_changed(new_speed: int) -> void:
_apply_highlight(new_speed as Sim.Speed)
func _on_sim_tick(tick_number: int) -> void:
tick_label.text = Strings.t(&"hud.tick").format({"n": tick_number})
func _on_clock_refresh(_n: int) -> void:
var t: String = Strings.t(&"clock.format").format({"d": Clock.current_day(), "t": Clock.time_string()})
if t != _last_clock_text:
_last_clock_text = t
clock_label.text = t
func _apply_highlight(speed: Sim.Speed) -> void:
for s: int in _speed_buttons:
_speed_buttons[s].modulate = ACTIVE_MODULATE if s == speed else IDLE_MODULATE