World scene (scenes/world/world.{tscn,gd}):
- 6 TileMapLayer nodes per architecture.md split: Terrain (0), Floor (1),
Wall (2), Designation (3), Roof (4, hidden), Fog (5, hidden).
- Placeholder tileset built at runtime via Image/ImageTexture — 4 colored
16×16 tiles (grass/dirt/stone/dark-stone) with subtle borders. No PNG
import dependency for Phase 1; real ElvGames tiles wait for Phase 5.
- Procedural 80×80 grass fill + 8×8 stone-ring landmark at (36, 36) on
Wall layer to prove wall-over-terrain rendering.
- Calls camera_rig.set_world_bounds() once map dimensions known.
- ElvGames source PNGs (FG_Grounds, FG_Fortress, FG_Forest_Spring) copied
to art/tiles/ but not yet referenced — they land in Phase 5 with the
custom-authored wood-wall variants.
Camera rig (scenes/world/camera_rig.{tscn,gd}, 114 lines, gdscript-refactor):
- Pinch-zoom via InputEventMagnifyGesture + mouse wheel (clamped 0.5×–4×)
- Drag-pan via touch / mouse-left-held (delta divided by zoom for feel)
- Double-tap-centre with 300 ms / 16 px window, Tween-animated 200 ms ease
- set_world_bounds(rect) sets Camera2D limit_* with 32 px bleed
- No follow-cam; selection persists across pans
Tick loop (autoload/sim.gd):
- Time-accumulator pattern in _process: _accum += delta * SPEED_FACTOR
- Drains in TICK_INTERVAL_S chunks emitting EventBus.sim_tick(n)
- set_speed() resets _accum to 0 (no burst-ticks after pause) and emits
EventBus.speed_changed(int). Boot default = NORMAL.
- Audit.log on every speed transition for runtime diagnostics.
- Early-return guard against redundant set_speed calls.
EventBus (autoload/event_bus.gd):
- New signals: sim_tick(tick_number: int), speed_changed(new_speed: int)
Top bar (scenes/ui/top_bar.{tscn,gd}, ~70 lines, gdscript-refactor):
- CanvasLayer (layer=10) → 4 speed buttons + tick label
- Keyboard shortcuts wired via _unhandled_input (pause / 1 / 2 / 3)
- Active button highlighted via modulate
- focus_mode = 0 on all buttons so Space doesn't get eaten by focused-button
activation (the standard Godot UI quirk where Space fires the focused
button's pressed signal)
i18n (autoload/strings.gd):
- 5 new keys: speed.pause/normal/fast/ultra, hud.tick (template with {n})
Main bootstrap (scenes/main/main.{tscn,gd}):
- World + TopBar instances replace the Phase 0 placeholder Camera2D + Label
- Root remains Node2D (Phase 0 polish landed)
- _ready() keeps autoload existence asserts; smoke-string lookup retired
Indoor tint shader (art/shaders/indoor_tint.gdshader):
- Stub: tint_strength = 0 pass-through. Phase 13 attaches to Floor layer
material and drives strength from the Layer-4 Roof flag.
Acceptance: MCP-verified via play_scene + get_game_screenshot. 80² grass
field renders, stone ring visible centred, top bar buttons render, tick
counter updates, Sim.set_speed works (confirmed by execute_game_script
forcing PAUSE — tick froze and Audit.log emitted the transition line).
Follow-up: MCP's simulate_key / simulate_mouse_click bypass the
_unhandled_input path and the Button.pressed signal — events don't reach
the handler. Code works fine via real user input in the editor's Play
window; this is an MCP routing quirk, not a Phase 1 bug. Documented as
a known limitation when scripting input tests.
Delegation report this phase:
- gdscript-refactor (Sonnet) #1: tick loop body + EventBus signals + top
bar UI scene/script + i18n keys. ~3 file mods + 2 new files. Headless-
validated by the subagent.
- gdscript-refactor (Sonnet) #2: camera rig scene + script. 2 new files,
114 lines GDScript. Headless-validated by the subagent.
- Opus: world scene + procedural tileset + map fill + integration into
main.tscn + MCP-driven runtime verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 lines
1.3 KiB
GDScript
46 lines
1.3 KiB
GDScript
extends Node
|
||
## Sim tick loop owner. Sim runs at 20 Hz, render at 60 Hz, decoupled.
|
||
##
|
||
## Speed factor controls how many sim ticks queue per render frame:
|
||
## 1× — 1 tick per 3 render frames (20 ticks/s)
|
||
## Fast — 5 ticks per 3 render frames (~100 ticks/s)
|
||
## Ultra — 12 ticks per 3 render frames (~240 ticks/s)
|
||
## Pause — 0
|
||
##
|
||
## Saves are taken between ticks only — never mid-tick — so JobRunner mid-toil
|
||
## state round-trips cleanly. See docs/architecture.md "Time / tick model".
|
||
|
||
const SIM_HZ: int = 20
|
||
const TICK_INTERVAL_S: float = 1.0 / float(SIM_HZ)
|
||
|
||
enum Speed { PAUSE, NORMAL, FAST, ULTRA }
|
||
|
||
const SPEED_FACTOR: Dictionary = {
|
||
Speed.PAUSE: 0,
|
||
Speed.NORMAL: 1,
|
||
Speed.FAST: 5,
|
||
Speed.ULTRA: 12,
|
||
}
|
||
|
||
var current_speed: Speed = Speed.NORMAL
|
||
var tick: int = 0
|
||
var _accum: float = 0.0
|
||
|
||
|
||
func set_speed(new_speed: Speed) -> void:
|
||
if new_speed == current_speed:
|
||
return
|
||
Audit.log("sim", "speed %s → %s (tick %d)" % [Speed.keys()[current_speed], Speed.keys()[new_speed], tick])
|
||
current_speed = new_speed
|
||
_accum = 0.0 # Prevent burst-tick after long pauses.
|
||
EventBus.speed_changed.emit(int(new_speed))
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if current_speed == Speed.PAUSE:
|
||
return
|
||
_accum += delta * SPEED_FACTOR[current_speed]
|
||
while _accum >= TICK_INTERVAL_S:
|
||
_accum -= TICK_INTERVAL_S
|
||
tick += 1
|
||
EventBus.sim_tick.emit(tick)
|