rimlike/autoload/clock.gd
megaproxy b9093dd24b Phase 17: Touch UX (PawnDetail+BuildDrawer+WorkMatrix+AlertsLog+Settings)
Three-agent fan-out shipping the major touch UI surfaces. Opus pre-wrote
6 EventBus signals (pawn_selected/deselected, pawn_priority_changed,
alert_added, request_wolf_spawn, day_ended) + Pawn.work_priorities
Dictionary stub before dispatch. Pattern proven across Phases 12-17.

Pawn detail + Settings (Agent A):
- scenes/ui/pawn_detail_panel.gd — right-side CanvasLayer (layer 18),
  ~360px wide, opens on EventBus.pawn_selected. Renders portrait,
  HP/Hunger/Sleep bars with threshold colors, current job, mood +
  sulking, statuses, top 5 mood thoughts, full skill table,
  read-only work-priorities row. Live-refreshes each sim tick.
- scenes/ui/settings_menu.gd — modal CanvasLayer (layer 26), opened
  via Settings button. Auto-pause toggles (Threat/Wanderer/Pawn-Down/
  Modal), audio sliders (stubs for Phase 18), accessibility checkboxes.
  Persists via GameState.apply_settings.
- scenes/world/selection.gd — extended to emit pawn_selected/deselected
  through EventBus on tap.

Build drawer + 12 new Designation tools (Agent B):
- scenes/ui/build_drawer.gd — bottom-sheet CanvasLayer (layer 16) with
  4 tabs (Designate/Build/Stockpile/Cancel) + FAB ⊕ open button.
  Each tab has HFlowContainer of 80×80 buttons with procedural colored
  icons + label. Tap → Designation.set_active_tool + alert + auto-close.
- Designation: added TOOL_CHOP, TOOL_MINE, TOOL_BUILD_CRATE,
  TOOL_BUILD_BED, TOOL_BUILD_TORCH, 5× TOOL_BUILD_WORKBENCH_* variants,
  TOOL_PAINT_STOCKPILE. Plus tool_material override for wall/floor.
- World._on_designation_added: extended dispatch for all 12 new tools;
  added _spawn_workbench() helper for the 5 bench kinds.

Work matrix + Alerts log + Decision refactor + Wolf signal (Agent C):
- scenes/ai/decision.gd: Layer 4 now filters by pawn.work_priorities
  (0=OFF skip, sort by level ascending with provider.priority tiebreak).
  NEEDS_CATEGORIES (rest/eat/sleep) bypass the filter — a pawn can
  never starve from misconfiguration. Audit log prefixes work decisions
  with (pri=N).
- scenes/ui/work_priority_matrix.gd — CanvasLayer (layer 17) bottom-sheet
  grid: rows=pawns × cols=8 work categories. Each cell tap-cycles
  1→2→3→4→0→1, color-coded (red/orange/yellow/blue/gray). Writes back
  to pawn.work_priorities + emits pawn_priority_changed.
- scenes/ui/alerts_log.gd — CanvasLayer (layer 19) ring buffer 50
  entries. Newest first, severity icon (info/warn/danger), Day HH:MM
  timestamp, Go-there camera pan. Listens to alert_added +
  storyteller_event_fired + day_ended.
- EventBus.request_wolf_spawn wired end-to-end: EventCatalog
  _spawn_wolves emits; WolfSpawner._on_request_wolf_spawn force-spawns
  bypassing the darkness/cooldown gates.
- Clock emits EventBus.day_ended(summary) at dusk→night transition.

Top bar buttons added in order: ‖ / 1× / 5× / 12× / Save / Load /
Settings / Build / Work / Log[N]. Plus the ⊕ FAB at bottom-right.

MCP runtime verified all 4 surfaces via screenshot:
- PawnDetailPanel: Bram shows Crafting=8 / Cooking=2 / Manual=0
  matching seed; bars green; Mood: 50; work-priorities readout
- BuildDrawer: 4 tabs visible, Designate tab shows Chop/Mine/Dig grave/
  No roof buttons with procedural icons
- WorkPriorityMatrix: 3 pawns × 8 categories, all '3' (NORMAL default)
  cells in yellow, tap-to-cycle ready
- AlertsLog: 4 entries — red 'Wolf pack approaching!' danger, blue
  'Bram is at the cabin' info, yellow 'Test alert' warn, blue 'Spring
  Awakens' from boot storyteller roll. Go-there button per entry.

Mouse drag-paint works as-is (user noted). Existing
Selection/Designation _unhandled_input handles drag.

Deferred to Phase 17.5 polish:
- Per-pawn/per-job view layers on the matrix
- Stockpile 4×4 chip filter UI (paint creates 1×1 zones today)
- Bill UI for workbenches (programmatic only today)
- 'No stockpile accepts X' / 'Bill blocked' alert emit wiring
- DaySummaryCard visual (signal emits today, no card UI)
- Wanderer recruit UI, resource buff system

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
integration + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:45:35 +01:00

206 lines
7.7 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node
## In-game clock: sim-tick → day, hour, minute, darkness factor.
##
## Design (docs/architecture.md "Time / tick model"):
## - 1 in-game day = TICKS_PER_DAY sim ticks
## 4800 ticks/day = 240 s real at 1× = 48 s at Fast (5×) = 20 s at Ultra (12×)
## - 24 in-game hours per day → TICKS_PER_HOUR = 200
## - TICKS_PER_MINUTE = 200 / 60 = 3 (integer floor; ~3.33 ticks per in-game min)
##
## Time-of-day phases (docs/design.md "Day/Night cycle"):
## Night: 22:0005:00
## Dawn: 05:0007:00 (2-hour smooth ramp, darkness 1.0 → 0.0)
## Day: 07:0019:00
## Dusk: 19:0022:00 (3-hour smooth ramp, darkness 0.0 → 1.0)
##
## darkness_factor() returns 0.0 (full daylight) to 1.0 (deepest night).
##
## Game starts at Day 1, 06:00 (mid-dawn — atmospheric, not full dark).
##
## Save seam: save_dict() / apply_dict() persist _start_offset_ticks so
## the time-of-day survives a session reload.
const TICKS_PER_DAY: int = 4800
const TICKS_PER_HOUR: int = 200
## Integer floor of 200/60 ≈ 3.33. Minute display rounds down — fine for a clock.
const TICKS_PER_MINUTE: int = TICKS_PER_HOUR / 60
const START_HOUR: int = 6
const HOURS_PER_DAY: int = 24
# Phase boundaries (in-game hours, inclusive lower bound).
const DAWN_START_HOUR: int = 5
const DAWN_END_HOUR: int = 7
const DUSK_START_HOUR: int = 19
const DUSK_END_HOUR: int = 22
## Internal: added to Sim.tick so we begin at START_HOUR rather than midnight.
var _start_offset_ticks: int = START_HOUR * TICKS_PER_HOUR
## Fired when the time-phase transitions (Night → Dawn, Dawn → Day, etc.).
signal phase_changed(phase: StringName)
const PHASE_NIGHT: StringName = &"night"
const PHASE_DAWN: StringName = &"dawn"
const PHASE_DAY: StringName = &"day"
const PHASE_DUSK: StringName = &"dusk"
# Phase 12 — Season contracts (Agent A fills in current_season() + day_of_season()).
# Names locked here so Agent B + C can reference them without race conditions.
const SEASON_SPRING: StringName = &"spring"
const SEASON_SUMMER: StringName = &"summer"
const SEASON_AUTUMN: StringName = &"autumn"
const SEASON_WINTER: StringName = &"winter"
const SEASONS: Array[StringName] = [SEASON_SPRING, SEASON_SUMMER, SEASON_AUTUMN, SEASON_WINTER]
const DAYS_PER_SEASON: int = 12
const SEASONS_PER_YEAR: int = 4
const DAYS_PER_YEAR: int = DAYS_PER_SEASON * SEASONS_PER_YEAR # 48
var _last_emitted_phase: StringName = &""
## Mirrors _last_emitted_phase — guards the season_changed emit against repeat fires.
var _last_emitted_season: StringName = &""
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
# ── public API ───────────────────────────────────────────────────────────────
## Current in-game day (1-indexed). Day 1 starts at boot.
func current_day() -> int:
return 1 + (_offset_ticks() / TICKS_PER_DAY)
## Current hour 0..23.
func current_hour() -> int:
return (_offset_ticks() / TICKS_PER_HOUR) % HOURS_PER_DAY
## Current minute 0..59.
func current_minute() -> int:
var ticks_into_hour: int = _offset_ticks() % TICKS_PER_HOUR
return (ticks_into_hour * 60) / TICKS_PER_HOUR
## Time of day as a 0..1 fraction (0.0 = midnight, 0.5 = noon).
func time_of_day_fraction() -> float:
return float(_offset_ticks() % TICKS_PER_DAY) / float(TICKS_PER_DAY)
## Returns 0.0 (full daylight) to 1.0 (deepest night).
##
## Ramp shapes:
## Day [07:0019:00] → 0.0 flat
## Night [22:0005:00] → 1.0 flat
## Dawn [05:0007:00] → 1.0 ramps down to 0.0 linearly over 2 hours
## Dusk [19:0022:00] → 0.0 ramps up to 1.0 linearly over 3 hours
func darkness_factor() -> float:
var h: float = float(current_hour()) + float(current_minute()) / 60.0
# Full daylight band.
if h >= DAWN_END_HOUR and h < DUSK_START_HOUR:
return 0.0
# Full night band — handles the wrap (h >= 22 OR h < 5).
if h >= DUSK_END_HOUR or h < DAWN_START_HOUR:
return 1.0
# Dawn ramp: 1.0 at 05:00 → 0.0 at 07:00
if h < DAWN_END_HOUR:
return 1.0 - (h - DAWN_START_HOUR) / float(DAWN_END_HOUR - DAWN_START_HOUR)
# Dusk ramp: 0.0 at 19:00 → 1.0 at 22:00
return (h - DUSK_START_HOUR) / float(DUSK_END_HOUR - DUSK_START_HOUR)
## Current phase as a StringName constant. Phase transitions emit phase_changed.
func current_phase() -> StringName:
var h: int = current_hour()
if h < DAWN_START_HOUR or h >= DUSK_END_HOUR:
return PHASE_NIGHT
if h < DAWN_END_HOUR:
return PHASE_DAWN
if h < DUSK_START_HOUR:
return PHASE_DAY
return PHASE_DUSK
## "HH:MM" formatted 24-hour time string.
func time_string() -> String:
return "%02d:%02d" % [current_hour(), current_minute()]
# Phase 12 — Season public API (stub; Agent A wires emit + season_changed).
## Day index since game start (0-indexed, ignores 1-indexed display).
func day_index_from_start() -> int:
return _offset_ticks() / TICKS_PER_DAY
## Current season index 0..3 (spring/summer/autumn/winter).
func current_season_index() -> int:
return (day_index_from_start() / DAYS_PER_SEASON) % SEASONS_PER_YEAR
## Current season as a locked StringName constant.
func current_season() -> StringName:
return SEASONS[current_season_index()]
## Day within the current season, 0..DAYS_PER_SEASON-1.
func day_of_season() -> int:
return day_index_from_start() % DAYS_PER_SEASON
## Current year (1-indexed; Year 1 starts at boot).
func current_year() -> int:
return 1 + day_index_from_start() / DAYS_PER_YEAR
# ── internal ─────────────────────────────────────────────────────────────────
func _offset_ticks() -> int:
return _start_offset_ticks + Sim.tick
func _on_sim_tick(_n: int) -> void:
var phase: StringName = current_phase()
if phase != _last_emitted_phase:
var prev_phase: StringName = _last_emitted_phase
_last_emitted_phase = phase
emit_signal("phase_changed", phase)
Audit.log("clock", "phase → %s (day %d, %s)" % [phase, current_day(), time_string()])
# Phase 17 — emit day_ended summary at the dusk→night boundary.
# "Night" begins at DUSK_END_HOUR (22:00). This fires once per in-game day.
# Listeners can use this for end-of-day recap UI, tension logging, etc.
# Defensive: use get() for Storyteller/Weather/World fields that may not be
# fully wired in all test contexts.
if prev_phase == PHASE_DUSK and phase == PHASE_NIGHT:
var summary: Dictionary = {
"day": current_day(),
"weather": Weather.get("current_weather") if Weather != null else &"unknown",
"season": current_season(),
"pawns_alive": World.pawns.size() if World != null else 0,
"tension": Storyteller.get("tension") if Storyteller != null else 0.0,
"wolves_alive": World.wolves.size() if World != null else 0,
}
EventBus.day_ended.emit(summary)
Audit.log("clock", "day_ended: day=%d season=%s pawns=%d tension=%.1f wolves=%d" % [
summary["day"], summary["season"], summary["pawns_alive"],
summary["tension"], summary["wolves_alive"]
])
var season: StringName = current_season()
if season != _last_emitted_season:
_last_emitted_season = season
EventBus.season_changed.emit(season)
Audit.log("clock", "season → %s (day %d/%d of season, year %d)" % [
season, day_of_season() + 1, DAYS_PER_SEASON, current_year()
])
# ── save / load ──────────────────────────────────────────────────────────────
func save_dict() -> Dictionary:
return {"start_offset_ticks": _start_offset_ticks}
func apply_dict(d: Dictionary) -> void:
_start_offset_ticks = int(d.get("start_offset_ticks", START_HOUR * TICKS_PER_HOUR))