Phase 1 — 80² world, 6-layer TileMap, camera rig, tick loop, speed UI

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>
This commit is contained in:
megaproxy 2026-05-10 20:37:02 +01:00
parent 18fb784e76
commit 836dfdd716
23 changed files with 579 additions and 39 deletions

View file

@ -1,20 +1,18 @@
extends Node2D
## Phase 0 smoke-test scene root.
## Bootstrap. Mounts the world view + UI overlay.
##
## Verifies the autoload graph is alive and the i18n table resolves a key.
## Once Phase 1 lands the world view, this becomes the bootstrap that loads
## the right scene based on game state (new game / continue / settings).
@onready var hello_label: Label = $HelloLabel
## Once we add menus / continue-game / new-game flows this will branch
## on game state. For Phase 1 it just instances the World and TopBar,
## which are children placed in main.tscn.
func _ready() -> void:
Audit.log("main", "Phase 0 smoke test online.")
# Verify autoloads are alive.
Audit.log("main", "Phase 1 — world view + speed UI online.")
# Autoloads — keep these asserts; cheap and catch a renamed-autoload
# regression instantly.
assert(World != null, "World autoload missing")
assert(Sim != null, "Sim autoload missing")
assert(GameState != null, "GameState autoload missing")
assert(EventBus != null, "EventBus autoload missing")
assert(Strings != null, "Strings autoload missing")
assert(SaveSystem != null, "SaveSystem autoload missing")
hello_label.text = Strings.t(&"smoke.hello")

View file

@ -1,15 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://rimlike_main"]
[gd_scene load_steps=4 format=3 uid="uid://rimlike_main"]
[ext_resource type="Script" path="res://scenes/main/main.gd" id="1_main"]
[ext_resource type="PackedScene" uid="uid://rimlike_world" path="res://scenes/world/world.tscn" id="2_world"]
[ext_resource type="PackedScene" uid="uid://top_bar" path="res://scenes/ui/top_bar.tscn" id="3_top_bar"]
[node name="Main" type="Node2D"]
script = ExtResource("1_main")
[node name="Camera2D" type="Camera2D" parent="."]
[node name="World" parent="." instance=ExtResource("2_world")]
[node name="HelloLabel" type="Label" parent="."]
offset_left = 32.0
offset_top = 32.0
offset_right = 800.0
offset_bottom = 96.0
text = "(boot)"
[node name="TopBar" parent="." instance=ExtResource("3_top_bar")]

69
scenes/ui/top_bar.gd Normal file
View file

@ -0,0 +1,69 @@
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
# Maps Speed enum value → the corresponding Button node.
var _speed_buttons: Dictionary = {}
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)"
_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)
# 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 _apply_highlight(speed: Sim.Speed) -> void:
for s: int in _speed_buttons:
_speed_buttons[s].modulate = ACTIVE_MODULATE if s == speed else IDLE_MODULATE

1
scenes/ui/top_bar.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bap8avymp6dj5

47
scenes/ui/top_bar.tscn Normal file
View file

@ -0,0 +1,47 @@
[gd_scene load_steps=2 format=3 uid="uid://top_bar"]
[ext_resource type="Script" path="res://scenes/ui/top_bar.gd" id="1_topbar"]
[node name="TopBar" type="CanvasLayer"]
layer = 10
script = ExtResource("1_topbar")
[node name="Anchor" type="Control" parent="."]
anchor_right = 1.0
anchor_bottom = 0.0
offset_bottom = 48.0
grow_horizontal = 2
[node name="ButtonRow" type="HBoxContainer" parent="Anchor"]
offset_left = 8.0
offset_top = 8.0
offset_right = 300.0
offset_bottom = 40.0
[node name="PauseBtn" type="Button" parent="Anchor/ButtonRow"]
focus_mode = 0
text = "‖"
[node name="NormalBtn" type="Button" parent="Anchor/ButtonRow"]
focus_mode = 0
text = "1×"
[node name="FastBtn" type="Button" parent="Anchor/ButtonRow"]
focus_mode = 0
text = "5×"
[node name="UltraBtn" type="Button" parent="Anchor/ButtonRow"]
focus_mode = 0
text = "12×"
[node name="TickLabel" type="Label" parent="Anchor"]
anchor_left = 1.0
anchor_right = 1.0
anchor_bottom = 0.0
offset_left = -120.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = 40.0
grow_horizontal = 0
text = "(boot)"
horizontal_alignment = 2

114
scenes/world/camera_rig.gd Normal file
View file

@ -0,0 +1,114 @@
extends Camera2D
## World-view camera rig — pinch-zoom, drag-pan, double-tap-centre.
## All input handled via _unhandled_input so CanvasLayer UI overlays swallow first.
## Call set_world_bounds(rect) once from the world scene after map generation.
const MIN_ZOOM: float = 0.5 # strategic / whole-map-ish
const MAX_ZOOM: float = 4.0 # close / sprite-readable
const BOUNDS_BLEED_PX: int = 32 # 2 tiles of bleed at map edge
const DOUBLE_TAP_MS: int = 300 # window for double-tap detection
const DOUBLE_TAP_DIST_PX: float = 16.0 # 1 tile radius
var target_zoom: float = 1.0
var _dragging: bool = false # desktop left-mouse drag tracking
var _last_tap_time_ms: int = 0
var _last_tap_world_pos: Vector2 = Vector2.ZERO
var _centre_tween: Tween = null
func _ready() -> void:
position_smoothing_enabled = false
# Start at the mid-range of the zoom band
target_zoom = 1.0
zoom = Vector2(target_zoom, target_zoom)
func _process(delta: float) -> void:
# Lerp zoom toward target each render frame.
# Plain 0.15 factor is correct at 60 fps; frame-rate independent form used for safety.
var t: float = 1.0 - pow(1.0 - 0.15, delta * 60.0)
zoom = zoom.lerp(Vector2(target_zoom, target_zoom), t)
func _unhandled_input(event: InputEvent) -> void:
# --- Pinch-zoom via magnify gesture (trackpad + native touch pinch) ---
if event is InputEventMagnifyGesture:
target_zoom = clampf(target_zoom * event.factor, MIN_ZOOM, MAX_ZOOM)
return
# --- Scroll-wheel zoom (desktop) ---
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
target_zoom = clampf(target_zoom * 1.1, MIN_ZOOM, MAX_ZOOM)
return
if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
target_zoom = clampf(target_zoom / 1.1, MIN_ZOOM, MAX_ZOOM)
return
# --- Desktop left-mouse: drag-pan tracking + double-tap detection ---
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_dragging = true
_check_double_tap(event.position)
else:
_dragging = false
return
# --- Touch drag-pan ---
if event is InputEventScreenDrag:
_apply_pan(event.relative)
return
# --- Desktop mouse-motion drag-pan (only while left mouse held) ---
if event is InputEventMouseMotion and _dragging:
_apply_pan(event.relative)
return
# --- Touch press: double-tap detection ---
if event is InputEventScreenTouch and event.pressed:
_check_double_tap(event.position)
func _apply_pan(relative: Vector2) -> void:
# zoom.x > 1 means more zoomed-in; each screen pixel = fewer world units.
position -= relative / zoom.x
func _check_double_tap(screen_pos: Vector2) -> void:
var now_ms: int = Time.get_ticks_msec()
var world_pos: Vector2 = get_canvas_transform().affine_inverse() * screen_pos
var dt: int = now_ms - _last_tap_time_ms
var dist: float = world_pos.distance_to(_last_tap_world_pos)
if dt < DOUBLE_TAP_MS and dist < DOUBLE_TAP_DIST_PX:
_centre_on(world_pos)
# Reset so a triple-tap doesn't re-trigger immediately
_last_tap_time_ms = 0
else:
_last_tap_time_ms = now_ms
_last_tap_world_pos = world_pos
func _centre_on(world_pos: Vector2) -> void:
# Kill any in-progress centre tween before starting a new one
if _centre_tween != null and _centre_tween.is_valid():
_centre_tween.kill()
_centre_tween = create_tween()
_centre_tween.set_ease(Tween.EASE_OUT)
_centre_tween.set_trans(Tween.TRANS_QUAD)
_centre_tween.tween_property(self, "position", world_pos, 0.2)
## Public API — call once from the world scene after map bounds are known.
func set_world_bounds(rect: Rect2) -> void:
limit_left = int(rect.position.x) - BOUNDS_BLEED_PX
limit_top = int(rect.position.y) - BOUNDS_BLEED_PX
limit_right = int(rect.end.x) + BOUNDS_BLEED_PX
limit_bottom = int(rect.end.y) + BOUNDS_BLEED_PX
Audit.log("camera", "bounds set: %s (bleed %d)" % [rect, BOUNDS_BLEED_PX])

View file

@ -0,0 +1 @@
uid://bcyu4gcjd3837

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://rimlike_camera_rig"]
[ext_resource type="Script" path="res://scenes/world/camera_rig.gd" id="1_camera_rig"]
[node name="CameraRig" type="Camera2D"]
script = ExtResource("1_camera_rig")

112
scenes/world/world.gd Normal file
View file

@ -0,0 +1,112 @@
extends Node2D
## Phase 1 world view. 80×80 TileMap with 6 layers, placeholder tiles.
##
## Real ElvGames art lands in Phase 5+ (wood walls custom-authored on
## FG_Houses, stone walls autotiled from FG_Fortress per the 2026-05-10
## audit lock). Phase 1 is just "render an 80² map" and exists to prove
## the layer pipeline + camera UX + speed loop end-to-end.
##
## TileMap layer indices follow docs/architecture.md:
## 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof · 5 Fog
const MAP_SIZE_TILES: Vector2i = Vector2i(80, 80)
const TILE_SIZE_PX: int = 16
# Atlas coords inside the placeholder tileset (one source, source_id = 0).
# Real assets in Phase 5 will use multiple atlas sources.
const TILE_GRASS: Vector2i = Vector2i(0, 0)
const TILE_DIRT: Vector2i = Vector2i(1, 0)
const TILE_STONE: Vector2i = Vector2i(2, 0)
const TILE_STONE_DARK: Vector2i = Vector2i(3, 0)
const PLACEHOLDER_SOURCE_ID: int = 0
@onready var terrain_layer: TileMapLayer = $Terrain
@onready var floor_layer: TileMapLayer = $Floor
@onready var wall_layer: TileMapLayer = $Wall
@onready var designation_layer: TileMapLayer = $Designation
@onready var roof_layer: TileMapLayer = $Roof
@onready var fog_layer: TileMapLayer = $Fog
func _ready() -> void:
Audit.log("world", "Phase 1 — building %d×%d world view." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
var tileset := _build_placeholder_tileset()
for layer in [terrain_layer, floor_layer, wall_layer, designation_layer, roof_layer, fog_layer]:
layer.tile_set = tileset
_paint_terrain()
_paint_sample_walls()
_apply_camera_bounds()
func world_bounds_px() -> Rect2:
return Rect2(Vector2.ZERO, Vector2(MAP_SIZE_TILES * TILE_SIZE_PX))
func _build_placeholder_tileset() -> TileSet:
# Four 16×16 placeholder tiles laid out as a 4×1 atlas. No PNG dependency
# — atlas built at runtime from a programmatic Image. Real ElvGames art
# replaces this when wood/stone wall variants are imported in Phase 5.
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
var atlas_w := TILE_SIZE_PX * 4
var img := Image.create(atlas_w, TILE_SIZE_PX, false, Image.FORMAT_RGBA8)
var palette: Array[Color] = [
Color(0.45, 0.65, 0.30), # grass
Color(0.55, 0.45, 0.30), # dirt
Color(0.60, 0.60, 0.55), # stone
Color(0.30, 0.30, 0.32), # stone dark
]
for i in palette.size():
var base: Color = palette[i]
var border: Color = base.darkened(0.15)
for px in TILE_SIZE_PX:
for py in TILE_SIZE_PX:
var on_border := (
px == 0 or px == TILE_SIZE_PX - 1
or py == 0 or py == TILE_SIZE_PX - 1
)
img.set_pixel(i * TILE_SIZE_PX + px, py, border if on_border else base)
var tex := ImageTexture.create_from_image(img)
var src := TileSetAtlasSource.new()
src.texture = tex
src.texture_region_size = Vector2i(TILE_SIZE_PX, TILE_SIZE_PX)
for i in palette.size():
src.create_tile(Vector2i(i, 0))
ts.add_source(src, PLACEHOLDER_SOURCE_ID)
return ts
func _paint_terrain() -> void:
# Solid grass for now. Phase 4+ introduces ore veins, trees-as-entities,
# water, etc. — this fill is a baseline.
for x in MAP_SIZE_TILES.x:
for y in MAP_SIZE_TILES.y:
terrain_layer.set_cell(Vector2i(x, y), PLACEHOLDER_SOURCE_ID, TILE_GRASS)
func _paint_sample_walls() -> void:
# An 8×8 stone ring near the map centre as a visual landmark. Proves the
# wall layer renders on top of terrain and gives the camera something to
# pan toward in the demo. Phase 5 deletes this and stands up real player-
# built walls.
var origin := Vector2i(36, 36)
var size: int = 8
for i in size:
wall_layer.set_cell(origin + Vector2i(i, 0), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(i, size - 1), PLACEHOLDER_SOURCE_ID, TILE_STONE)
wall_layer.set_cell(origin + Vector2i(0, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
wall_layer.set_cell(origin + Vector2i(size - 1, i), PLACEHOLDER_SOURCE_ID, TILE_STONE_DARK)
func _apply_camera_bounds() -> void:
var cam := get_node_or_null("CameraRig")
if cam == null:
Audit.log("world", "no CameraRig child yet — bounds set later when camera lands.")
return
if not cam.has_method("set_world_bounds"):
Audit.log("world", "CameraRig present but missing set_world_bounds() — skipping.")
return
cam.set_world_bounds(world_bounds_px())

View file

@ -0,0 +1 @@
uid://bl0mqje5rcsu8

30
scenes/world/world.tscn Normal file
View file

@ -0,0 +1,30 @@
[gd_scene load_steps=3 format=3 uid="uid://rimlike_world"]
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
[node name="World" type="Node2D"]
script = ExtResource("1_world")
[node name="Terrain" type="TileMapLayer" parent="."]
z_index = 0
[node name="Floor" type="TileMapLayer" parent="."]
z_index = 1
[node name="Wall" type="TileMapLayer" parent="."]
z_index = 2
[node name="Designation" type="TileMapLayer" parent="."]
z_index = 3
[node name="Roof" type="TileMapLayer" parent="."]
z_index = 4
visible = false
[node name="Fog" type="TileMapLayer" parent="."]
z_index = 5
visible = false
[node name="CameraRig" parent="." instance=ExtResource("2_camera")]
position = Vector2(640, 640)