diff --git a/autoload/clock.gd b/autoload/clock.gd new file mode 100644 index 0000000..ddd094a --- /dev/null +++ b/autoload/clock.gd @@ -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)) diff --git a/autoload/clock.gd.uid b/autoload/clock.gd.uid new file mode 100644 index 0000000..cd85a24 --- /dev/null +++ b/autoload/clock.gd.uid @@ -0,0 +1 @@ +uid://b0rnjjv1rag7d diff --git a/autoload/strings.gd b/autoload/strings.gd index 6c7024e..467a262 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -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", diff --git a/autoload/world.gd b/autoload/world.gd index 01aa8b8..8450948 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -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. diff --git a/docs/implementation.md b/docs/implementation.md index d4c11ad..37a1c7e 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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. diff --git a/project.godot b/project.godot index c12bdab..72ed2ba 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/ai/thought_catalog.gd b/scenes/ai/thought_catalog.gd index 0da496e..d6b6f41 100644 --- a/scenes/ai/thought_catalog.gd +++ b/scenes/ai/thought_catalog.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). diff --git a/scenes/entities/torch.gd b/scenes/entities/torch.gd new file mode 100644 index 0000000..bf17604 --- /dev/null +++ b/scenes/entities/torch.gd @@ -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) diff --git a/scenes/entities/torch.gd.uid b/scenes/entities/torch.gd.uid new file mode 100644 index 0000000..c40ffcd --- /dev/null +++ b/scenes/entities/torch.gd.uid @@ -0,0 +1 @@ +uid://d0yg2xw7btr1a diff --git a/scenes/entities/torch.tscn b/scenes/entities/torch.tscn new file mode 100644 index 0000000..2ad3442 --- /dev/null +++ b/scenes/entities/torch.tscn @@ -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") diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index f05cb45..9a7c2c6 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -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) diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index 9e0ef28..3ce7e64 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -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() diff --git a/scenes/ui/top_bar.gd b/scenes/ui/top_bar.gd index 478f97a..7ed3efb 100644 --- a/scenes/ui/top_bar.gd +++ b/scenes/ui/top_bar.gd @@ -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 diff --git a/scenes/ui/top_bar.tscn b/scenes/ui/top_bar.tscn index 115a184..eccdebe 100644 --- a/scenes/ui/top_bar.tscn +++ b/scenes/ui/top_bar.tscn @@ -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 diff --git a/scenes/world/world.gd b/scenes/world/world.gd index 8e6d752..7aa4d76 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -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: diff --git a/scenes/world/world.tscn b/scenes/world/world.tscn index d39c503..85f848b 100644 --- a/scenes/world/world.tscn +++ b/scenes/world/world.tscn @@ -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