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
138
autoload/clock.gd
Normal file
138
autoload/clock.gd
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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:00–05:00
|
||||
## Dawn: 05:00–07:00 (2-hour smooth ramp, darkness 1.0 → 0.0)
|
||||
## Day: 07:00–19:00
|
||||
## Dusk: 19:00–22: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"
|
||||
|
||||
var _last_emitted_phase: 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:00–19:00] → 0.0 flat
|
||||
## Night [22:00–05:00] → 1.0 flat
|
||||
## Dawn [05:00–07:00] → 1.0 ramps down to 0.0 linearly over 2 hours
|
||||
## Dusk [19:00–22: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()]
|
||||
|
||||
|
||||
# ── 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:
|
||||
_last_emitted_phase = phase
|
||||
emit_signal("phase_changed", phase)
|
||||
Audit.log("clock", "phase → %s (day %d, %s)" % [phase, current_day(), time_string()])
|
||||
|
||||
|
||||
# ── 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))
|
||||
1
autoload/clock.gd.uid
Normal file
1
autoload/clock.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b0rnjjv1rag7d
|
||||
|
|
@ -19,6 +19,8 @@ const TABLE: Dictionary = {
|
|||
&"speed.ultra": "12×",
|
||||
# HUD
|
||||
&"hud.tick": "Tick: {n}",
|
||||
# Phase 11 — in-game clock display ("{d}" = day, "{t}" = "HH:MM")
|
||||
&"clock.format": "Day {d}, {t}",
|
||||
# Pawn state labels
|
||||
&"pawn.state.idle": "idle",
|
||||
&"pawn.state.walking": "walking",
|
||||
|
|
@ -41,6 +43,8 @@ const TABLE: Dictionary = {
|
|||
# Phase 7 — pawn hunger states
|
||||
&"pawn.state.eating": "eating",
|
||||
&"pawn.state.hungry": "hungry",
|
||||
# Phase 11 — mood thoughts (player-visible in pawn-detail, Phase 17)
|
||||
&"thought.in_darkness": "In darkness",
|
||||
# Phase 6 — quality tier labels
|
||||
&"quality.shoddy": "Shoddy",
|
||||
&"quality.normal": "Normal",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ var crops: Array = []
|
|||
# Storyteller also reads beds.size() for the "First Beds" state predicate.
|
||||
var beds: Array = []
|
||||
|
||||
# Phase 11 — light-source entities (Torch + Hearth workbench). Entities call
|
||||
# register_light_source() in _ready and unregister_light_source() in _exit_tree.
|
||||
# is_tile_lit() is queried by the "in darkness" thought and any future
|
||||
# darkness-rendering shader bridge. All entries expose the duck-type interface:
|
||||
# is_on() → bool | get_light_tile() → Vector2i | get_light_radius() → int
|
||||
var light_sources: Array = []
|
||||
|
||||
# Phase 4 — hauling dirty set. Keys are Items, value is unused (we just use .keys()).
|
||||
# An Item is added when it spawns (Tree.fell, Rock.mined, workbench drop, ...)
|
||||
# and removed when it lands at its highest-priority valid destination.
|
||||
|
|
@ -193,6 +200,33 @@ func unregister_bed(b) -> void:
|
|||
beds.erase(b)
|
||||
|
||||
|
||||
# ── Phase 11: light-source registry ────────────────────────────────────────
|
||||
|
||||
func register_light_source(ls) -> void:
|
||||
if not light_sources.has(ls):
|
||||
light_sources.append(ls)
|
||||
|
||||
|
||||
func unregister_light_source(ls) -> void:
|
||||
light_sources.erase(ls)
|
||||
|
||||
|
||||
## Returns true if `tile` is within get_light_radius() of any is_on() light
|
||||
## source. Uses Manhattan distance (no wall-occlusion in Phase 11; Phase 13
|
||||
## may add BFS-based occlusion through the room/roof system).
|
||||
##
|
||||
## Called by the "in darkness" Thought trigger on each pawn sim tick.
|
||||
## O(light_sources) per call; trivial at our scale (< 50 sources in MVP).
|
||||
func is_tile_lit(p_tile: Vector2i) -> bool:
|
||||
for ls in light_sources:
|
||||
if not ls.is_on():
|
||||
continue
|
||||
var d: int = abs(ls.get_light_tile().x - p_tile.x) + abs(ls.get_light_tile().y - p_tile.y)
|
||||
if d <= ls.get_light_radius():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# Called by Wall.on_build_tick() when construction completes.
|
||||
# Stamps the data-only Wall TileMap layer so room/roof/save logic sees the
|
||||
# wall. World scene exposes wall_layer via a getter set during _ready.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
|
|||
| ✅ done — Recipe + Bill data, Workbench entity (Carpenter / Smelter via label_text), CraftingProvider, KIND_CRAFT toil, 5-tier Quality system, Pawn skills, wall-trap fix | **Phase 6 — Production: workbenches, recipes, bills, quality** |
|
||||
| ✅ done — Crop entity (6-stage state machine), PlantProvider (harvest), Hunger need + EatProvider w/ food-priority ladder, Hearth/Millstone via label_text, grain/flour/bread/meal types | **Phase 7 — Plants, cooking, hunger** |
|
||||
| ✅ done — Bed entity (quality-tinted, claim/release), Sleep need + SleepProvider + KIND_SLEEP toil, Thought registry + mood compute + Sulking soft-break, Decision Layer-1 interrupt | **Phase 8 — Sleep, mood, thoughts** |
|
||||
| ✅ done (out of order — taken before Phase 9 for the atmospheric win) — Clock autoload + dawn/day/dusk/night phases + darkness_factor ramp, CanvasModulate global tint, Torch entity + PointLight2D + procedural radial gradient, Hearth opts-in as light source, in_darkness thought | **Phase 11 — Day/night + Lighting** |
|
||||
| ⏳ next | **Phase 9 — Status effects + Medicine** |
|
||||
|
||||
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ Audit="*res://autoload/audit.gd"
|
|||
GameState="*res://autoload/game_state.gd"
|
||||
World="*res://autoload/world.gd"
|
||||
Sim="*res://autoload/sim.gd"
|
||||
Clock="*res://autoload/clock.gd"
|
||||
SaveSystem="*res://autoload/save_system.gd"
|
||||
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
|
||||
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,19 @@ static func slept_on_floor() -> Thought:
|
|||
return t
|
||||
|
||||
|
||||
## Mood penalty while a pawn is in an unlit tile at night.
|
||||
## modifier=-3, max_stacks=1, PERSISTENT.
|
||||
## Phase 17 polish may split into "outdoor dark" / "cave dark" tiers.
|
||||
static func in_darkness() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"in_darkness"
|
||||
t.label = "In darkness"
|
||||
t.modifier = -3
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Small mood boost after eating a cooked meal or bread.
|
||||
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
|
||||
## Stacks up to 3 (multiple good meals compound, but cap at 3).
|
||||
|
|
|
|||
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)
|
||||
1
scenes/entities/torch.gd.uid
Normal file
1
scenes/entities/torch.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d0yg2xw7btr1a
|
||||
8
scenes/entities/torch.tscn
Normal file
8
scenes/entities/torch.tscn
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[gd_scene load_steps=2 format=3 uid="uid://torch_entity"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/entities/torch.gd" id="1_torch"]
|
||||
|
||||
[node name="Torch" type="Node2D"]
|
||||
y_sort_enabled = true
|
||||
z_index = 0
|
||||
script = ExtResource("1_torch")
|
||||
|
|
@ -31,6 +31,17 @@ const TILE_SIZE_PX: int = 16
|
|||
## Sim ticks to build a workbench (90 ticks ≈ 4.5 sim seconds at 1×).
|
||||
const BUILD_TICKS: int = 90
|
||||
|
||||
# ── Phase 11: light-source support ───────────────────────────────────────────
|
||||
## Workbenches whose label_text is in this list emit light when completed.
|
||||
## Currently only the Hearth (open-fire cooking) qualifies.
|
||||
const LIGHT_EMITTING_LABELS: Array[String] = ["Hearth"]
|
||||
|
||||
## Sim-side Manhattan-distance light radius for the Hearth (tiles). Max 8.
|
||||
const HEARTH_LIGHT_RADIUS: int = 5
|
||||
|
||||
## Pixel size of the procedural radial gradient used for PointLight2D.
|
||||
const LIGHT_TEXTURE_SIZE: int = 64
|
||||
|
||||
# ── exports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
## Tile position of this workbench in world-tile coordinates.
|
||||
|
|
@ -68,6 +79,11 @@ var current_bill = null
|
|||
## JobRunner reads this to decide whether the recipe's work_ticks are done.
|
||||
var current_work_progress: int = 0
|
||||
|
||||
## PointLight2D child for workbenches that emit light (Hearth). null for all
|
||||
## others. Built in _ready() when label_text is in LIGHT_EMITTING_LABELS;
|
||||
## enabled only after _complete() fires.
|
||||
var _light: PointLight2D = null
|
||||
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -78,11 +94,20 @@ func _ready() -> void:
|
|||
tile.y * TILE_SIZE_PX + TILE_SIZE_PX
|
||||
)
|
||||
World.register_workbench(self)
|
||||
# Phase 11: light-emitting workbenches register with the light-source registry.
|
||||
# All workbenches register; non-emitters return false from is_on() so
|
||||
# World.is_tile_lit() skips them at zero cost.
|
||||
World.register_light_source(self)
|
||||
if label_text in LIGHT_EMITTING_LABELS:
|
||||
_light = _build_point_light_2d()
|
||||
add_child(_light)
|
||||
_light.enabled = false # dark until built
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
World.unregister_workbench(self)
|
||||
World.unregister_light_source(self)
|
||||
|
||||
|
||||
## One-shot initialiser. Call after add_child() so _ready() has fired.
|
||||
|
|
@ -180,6 +205,29 @@ func on_craft_interrupted() -> void:
|
|||
current_work_progress = 0
|
||||
|
||||
|
||||
# ── Phase 11: light-source duck-type interface ────────────────────────────────
|
||||
## Shared by Torch. World.is_tile_lit() and the "in darkness" thought call these.
|
||||
|
||||
## True when this workbench emits light and has finished construction.
|
||||
## Non-emitting workbenches (Carpenter, Smelter, Millstone) always return false.
|
||||
func is_on() -> bool:
|
||||
return _completed and label_text in LIGHT_EMITTING_LABELS
|
||||
|
||||
|
||||
## The tile this light source occupies (for Manhattan-distance calculation).
|
||||
func get_light_tile() -> Vector2i:
|
||||
return tile
|
||||
|
||||
|
||||
## The sim-side Manhattan-distance radius of this light source.
|
||||
## Returns 0 for non-emitting workbenches; World.is_tile_lit() still calls this
|
||||
## but d <= 0 is never true for a non-adjacent tile so it's a no-op in practice.
|
||||
func get_light_radius() -> int:
|
||||
if label_text in LIGHT_EMITTING_LABELS:
|
||||
return HEARTH_LIGHT_RADIUS
|
||||
return 0
|
||||
|
||||
|
||||
# ── save / load ───────────────────────────────────────────────────────────────
|
||||
|
||||
## Serialise workbench state for World save (wired in Phase 16).
|
||||
|
|
@ -352,5 +400,43 @@ func _draw_generic(alpha: float) -> void:
|
|||
|
||||
func _complete() -> void:
|
||||
_completed = true
|
||||
# Phase 11: enable PointLight2D for light-emitting workbenches on completion.
|
||||
if _light != null:
|
||||
_light.enabled = is_on()
|
||||
queue_redraw()
|
||||
Audit.log("workbench", "%s built at %s" % [label_text, tile])
|
||||
|
||||
|
||||
# ── Phase 11: internal light helpers ─────────────────────────────────────────
|
||||
|
||||
## Construct and return the PointLight2D that provides Godot-side visual lighting.
|
||||
## Mirrors Torch._build_point_light_2d(). Duplicated here to avoid a cross-file
|
||||
## dependency; the ~20 lines of duplication is the lesser cost.
|
||||
func _build_point_light_2d() -> PointLight2D:
|
||||
var p := PointLight2D.new()
|
||||
p.texture = _build_radial_light_texture(LIGHT_TEXTURE_SIZE)
|
||||
# Scale so the texture radius covers HEARTH_LIGHT_RADIUS tiles in world-pixels.
|
||||
p.texture_scale = float(HEARTH_LIGHT_RADIUS) * float(TILE_SIZE_PX) / float(LIGHT_TEXTURE_SIZE) * 2.0
|
||||
p.color = Color(1.0, 0.80, 0.50, 1.0) # warm hearthfire tint, slightly redder than torch
|
||||
p.energy = 1.0
|
||||
# Offset upward so the light originates from the flame area, not the tile base.
|
||||
p.position = Vector2(0.0, -10.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.
|
||||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -286,6 +286,13 @@ func _process_thoughts() -> void:
|
|||
# 2. Sync PERSISTENT thoughts.
|
||||
_sync_persistent_thought(&"hungry", is_hungry(), ThoughtCatalog.hungry())
|
||||
_sync_persistent_thought(&"tired", is_tired(), ThoughtCatalog.tired())
|
||||
# Phase 11 — in_darkness fires when past dusk/before dawn AND the pawn's
|
||||
# tile is unlit. architecture.md "LightingSystem": is_lit = light_map > 0.2;
|
||||
# darkness_factor > 0.3 spans dusk-mid through dawn-mid.
|
||||
# Phase 13 may add wall-occlusion via room BFS; for now radius-8 falloff only.
|
||||
var _dark_time := Clock.darkness_factor() > 0.3
|
||||
var _lit := World.is_tile_lit(tile)
|
||||
_sync_persistent_thought(&"in_darkness", _dark_time and not _lit, ThoughtCatalog.in_darkness())
|
||||
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
|
||||
if dirty:
|
||||
_recompute_mood()
|
||||
|
|
|
|||
|
|
@ -9,22 +9,27 @@ extends CanvasLayer
|
|||
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 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)"
|
||||
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,
|
||||
|
|
@ -40,6 +45,7 @@ func _ready() -> void:
|
|||
|
||||
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)
|
||||
|
|
@ -64,6 +70,13 @@ 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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ text = "5×"
|
|||
focus_mode = 0
|
||||
text = "12×"
|
||||
|
||||
[node name="ClockLabel" type="Label" parent="Anchor"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.0
|
||||
offset_left = -80.0
|
||||
offset_top = 8.0
|
||||
offset_right = 80.0
|
||||
offset_bottom = 40.0
|
||||
grow_horizontal = 2
|
||||
text = "Day 1, 06:00"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TickLabel" type="Label" parent="Anchor"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.ts
|
|||
const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn")
|
||||
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
|
||||
const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn")
|
||||
const TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn")
|
||||
|
||||
# 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table.
|
||||
const SAMPLE_PAWNS: Array[Dictionary] = [
|
||||
|
|
@ -51,6 +52,12 @@ const SAMPLE_ROCKS: Array[Vector2i] = [
|
|||
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
|
||||
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
|
||||
|
||||
# Phase 11 — global darkness tint. Day = white, night = deep cool blue.
|
||||
# Driven by Clock.darkness_factor() (0..1) each sim tick.
|
||||
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
|
||||
const DAY_TINT: Color = Color(1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
@onready var dark_overlay: CanvasModulate = $DarkOverlay
|
||||
@onready var terrain_layer: TileMapLayer = $Terrain
|
||||
@onready var floor_layer: TileMapLayer = $Floor
|
||||
@onready var wall_layer: TileMapLayer = $Wall
|
||||
|
|
@ -395,6 +402,19 @@ func _seed_phase5_demo_buildings() -> void:
|
|||
bed.on_build_tick()
|
||||
Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size())
|
||||
|
||||
# Phase 11 demo — 2 torches inside cabin (north-east + south-west corners
|
||||
# of interior) so the indoor area stays lit at night. Combined with the
|
||||
# Hearth's light radius=5, the cabin interior should be mostly bright
|
||||
# while the outdoors goes deep-blue tinted.
|
||||
var torch_tiles: Array[Vector2i] = [Vector2i(46, 26), Vector2i(49, 26)]
|
||||
for tt in torch_tiles:
|
||||
var torch: Torch = TORCH_SCENE.instantiate()
|
||||
add_child(torch)
|
||||
torch.setup(tt)
|
||||
while torch.is_buildable():
|
||||
torch.on_build_tick()
|
||||
Audit.log("world", "phase 11 demo: %d torches pre-built inside cabin" % torch_tiles.size())
|
||||
|
||||
|
||||
func _spawn_sample_stockpiles() -> void:
|
||||
# Two zones for the Phase 4 acceptance demo:
|
||||
|
|
@ -463,11 +483,20 @@ func _on_designation_cleared(cell: Vector2i) -> void:
|
|||
# ── periodic re-flow (the "wood floats up" cascade) ─────────────────────────
|
||||
|
||||
func _on_sim_tick_world_sweep(tick_n: int) -> void:
|
||||
_update_dark_overlay()
|
||||
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
|
||||
return
|
||||
hauling_provider.sweep_for_better_destinations()
|
||||
|
||||
|
||||
# Phase 11 — interpolate CanvasModulate between DAY_TINT and NIGHT_TINT based
|
||||
# on Clock.darkness_factor() (0 = full day, 1 = full night).
|
||||
# Called every sim tick; Color.lerp is a handful of float ops — negligible cost.
|
||||
func _update_dark_overlay() -> void:
|
||||
var f := Clock.darkness_factor()
|
||||
dark_overlay.color = DAY_TINT.lerp(NIGHT_TINT, f)
|
||||
|
||||
|
||||
# ── spike: AStarGrid2D query timing at 80² ──────────────────────────────────
|
||||
|
||||
func _run_pathfinder_spike() -> void:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
y_sort_enabled = true
|
||||
script = ExtResource("1_world")
|
||||
|
||||
[node name="DarkOverlay" type="CanvasModulate" parent="."]
|
||||
color = Color(1, 1, 1, 1)
|
||||
|
||||
[node name="Terrain" type="TileMapLayer" parent="."]
|
||||
z_index = 0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue