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:
parent
18fb784e76
commit
836dfdd716
23 changed files with 579 additions and 39 deletions
114
scenes/world/camera_rig.gd
Normal file
114
scenes/world/camera_rig.gd
Normal 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])
|
||||
1
scenes/world/camera_rig.gd.uid
Normal file
1
scenes/world/camera_rig.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bcyu4gcjd3837
|
||||
6
scenes/world/camera_rig.tscn
Normal file
6
scenes/world/camera_rig.tscn
Normal 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
112
scenes/world/world.gd
Normal 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())
|
||||
1
scenes/world/world.gd.uid
Normal file
1
scenes/world/world.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bl0mqje5rcsu8
|
||||
30
scenes/world/world.tscn
Normal file
30
scenes/world/world.tscn
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue