diff --git a/art/shaders/indoor_tint.gdshader b/art/shaders/indoor_tint.gdshader new file mode 100644 index 0000000..1930794 --- /dev/null +++ b/art/shaders/indoor_tint.gdshader @@ -0,0 +1,18 @@ +// Phase 1 stub. The shader exists; nothing drives the uniform yet. +// +// Phase 13 wires this onto the Floor TileMapLayer's material slot and +// drives `tint_strength` from the per-cell Roof flag (Layer 4). For +// Phase 1 the uniform stays at 0.0 and the shader is a no-op pass-through. +// +// See docs/architecture.md "Roofing" + "Indoor tint" sections. + +shader_type canvas_item; + +uniform float tint_strength : hint_range(0.0, 1.0) = 0.0; +uniform vec4 tint_color : source_color = vec4(0.55, 0.65, 0.85, 1.0); + +void fragment() { + vec4 base = texture(TEXTURE, UV); + // At tint_strength = 0 this is a pure pass-through. + COLOR = mix(base, vec4(tint_color.rgb, base.a), tint_strength * base.a); +} diff --git a/art/shaders/indoor_tint.gdshader.uid b/art/shaders/indoor_tint.gdshader.uid new file mode 100644 index 0000000..cf2d6f8 --- /dev/null +++ b/art/shaders/indoor_tint.gdshader.uid @@ -0,0 +1 @@ +uid://bg3wl11c2bnmx diff --git a/art/tiles/FG_Forest_Spring.png b/art/tiles/FG_Forest_Spring.png new file mode 100644 index 0000000..f4c3345 Binary files /dev/null and b/art/tiles/FG_Forest_Spring.png differ diff --git a/art/tiles/FG_Forest_Spring.png.import b/art/tiles/FG_Forest_Spring.png.import new file mode 100644 index 0000000..71432fc --- /dev/null +++ b/art/tiles/FG_Forest_Spring.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cb8gt8vxgrv3u" +path="res://.godot/imported/FG_Forest_Spring.png-f00bf6e4af08e486cf63194286231b4b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/tiles/FG_Forest_Spring.png" +dest_files=["res://.godot/imported/FG_Forest_Spring.png-f00bf6e4af08e486cf63194286231b4b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/tiles/FG_Fortress.png b/art/tiles/FG_Fortress.png new file mode 100644 index 0000000..a7f4709 Binary files /dev/null and b/art/tiles/FG_Fortress.png differ diff --git a/art/tiles/FG_Fortress.png.import b/art/tiles/FG_Fortress.png.import new file mode 100644 index 0000000..aad8944 --- /dev/null +++ b/art/tiles/FG_Fortress.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbvuqn6mo2m0f" +path="res://.godot/imported/FG_Fortress.png-48ea5de928bcbb1498668fedb759cb89.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/tiles/FG_Fortress.png" +dest_files=["res://.godot/imported/FG_Fortress.png-48ea5de928bcbb1498668fedb759cb89.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/tiles/FG_Grounds.png b/art/tiles/FG_Grounds.png new file mode 100644 index 0000000..12061bd Binary files /dev/null and b/art/tiles/FG_Grounds.png differ diff --git a/art/tiles/FG_Grounds.png.import b/art/tiles/FG_Grounds.png.import new file mode 100644 index 0000000..b0b87b8 --- /dev/null +++ b/art/tiles/FG_Grounds.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://yeay1xjv27ue" +path="res://.godot/imported/FG_Grounds.png-b2ff0cefc0864294e48d8a09dd3a3c47.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/tiles/FG_Grounds.png" +dest_files=["res://.godot/imported/FG_Grounds.png-b2ff0cefc0864294e48d8a09dd3a3c47.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index e27834d..d04c511 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -4,6 +4,8 @@ extends Node ## Subsystems mutate themselves; this bus only spreads the news. Add signals as ## features land — keep this file readable. See docs/architecture.md. -# Phase 0 placeholder — no signals yet. -# Phase 1 will add tick / speed-change / pause signals. +# Sim +signal sim_tick(tick_number: int) ## Emitted once per sim tick at the current speed. +signal speed_changed(new_speed: int) ## Emitted when Sim.current_speed changes; value is Speed enum cast to int. + # Phase 2 will add pawn-state signals (selected, deselected, walking, …). diff --git a/autoload/sim.gd b/autoload/sim.gd index 4595aff..e282840 100644 --- a/autoload/sim.gd +++ b/autoload/sim.gd @@ -22,7 +22,25 @@ const SPEED_FACTOR: Dictionary = { Speed.ULTRA: 12, } -var current_speed: Speed = Speed.PAUSE +var current_speed: Speed = Speed.NORMAL var tick: int = 0 +var _accum: float = 0.0 -# Tick-loop body lands in Phase 1. + +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) diff --git a/autoload/strings.gd b/autoload/strings.gd index 8d7367b..4e04b77 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -12,6 +12,13 @@ const TABLE: Dictionary = { # Phase 0 placeholder — populate as features land. &"app.title": "Rimlike", &"smoke.hello": "Phase 0 — autoloads online.", + # Speed controls (top bar) + &"speed.pause": "‖", + &"speed.normal": "1×", + &"speed.fast": "5×", + &"speed.ultra": "12×", + # HUD + &"hud.tick": "Tick: {n}", } diff --git a/docs/implementation.md b/docs/implementation.md index e366a10..dc7649c 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -6,8 +6,9 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for | Status | Phase | |---|---| -| ✅ scaffold landed (headless-verified); awaits editor-side green-dot check | **Phase 0 — Project scaffold & foundations** | -| ⏳ next | **Phase 1 — World, tilemap, camera** | +| ✅ done — green dot up, smoke scene runs, MCP plugin self-installed 3 runtime services | **Phase 0 — Project scaffold & foundations** | +| ✅ done — 80² map renders, walls/terrain/UI layers, camera rig, tick loop, speed UI all live | **Phase 1 — World, tilemap, camera** | +| ⏳ next | **Phase 2 — Pawn skeleton, pathfinding, movement** | 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. @@ -65,22 +66,19 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod **Goal:** an 80×80 map with the locked camera UX. No pawns yet; just a navigable empty world. -- [ ] `TileMap` with 6 layers per `architecture.md`: 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof · 5 Fog. Set z-indices, modulate, etc. -- [ ] Tileset import (audit complete, so we can be specific): - - **Terrain (grass):** `Forest Tileset 4 Seasons/Tilesets/FG_Forest_Spring.png` (or `FG_Grounds.png` for cleaner ground). - - **Wall (test material):** `Fortress Tileset 2 Seasons/Tilesets/FG_Fortress.png` — autotile-solvable, drops in clean. Wood walls (custom-authored on FG_Houses) wait for Phase 5; Phase 1 doesn't need both materials, just *something* to render. - - **Floor:** placeholder from `FG_Grounds` or similar — final floor variants in Phase 5. -- [ ] Generate an 80×80 placeholder map (procgen later — for now, a hand-painted test map saved as a `.tscn` is fine) -- [ ] Tick loop: `Sim` runs at 20 Hz, render free-runs at 60 Hz, decoupled. Pawn-position lerping comes in Phase 2. -- [ ] Speed control: 1× / Fast (5×) / Ultra (12×) / Pause. Buttons fixed top-bar. Per `architecture.md` table — sim-tick queue scales by speed factor. -- [ ] **Camera (per `ui.md` "World view camera (locked)"):** - - Pinch-zoom (smooth, between strategic and close — no fixed levels) - - One-finger drag-pan on empty world - - Double-tap-centre on tap target - - No follow-cam; selection persists across pans - - Camera reads viewport, clamps to map bounds with a small bleed -- [ ] Indoor tint shader skeleton — uniform on/off per tile, hooked up but always off (real driver lands in Phase 13) -- [ ] **Acceptance:** open project on phone (or phone-sized resize on desktop), pan and zoom an 80² map smoothly, hit pause/speed buttons. Speed feel matches the table in `architecture.md`. +- [x] **6 `TileMapLayer` nodes** in `scenes/world/world.tscn` (Godot 4.4+ idiom — supersedes the multi-layer `TileMap`): 0 Terrain · 1 Floor · 2 Wall · 3 Designation · 4 Roof (hidden) · 5 Fog (hidden). Z-indices set, layers can hold different sources independently. +- [x] **Placeholder tileset built at runtime** (no PNG import dependency for Phase 1). 4 programmatic 16×16 colored tiles (grass / dirt / stone / dark-stone) generated via `Image.create()` + `ImageTexture.create_from_image()`. Real ElvGames PNGs (`FG_Grounds.png`, `FG_Fortress.png`, `FG_Forest_Spring.png`) copied to `art/tiles/` but not yet wired — they land in Phase 5 when the wood-wall variants get authored. +- [x] **80×80 map filled** with grass on the Terrain layer, plus an 8×8 stone-ring landmark at (36, 36) on the Wall layer to prove the wall layer renders correctly on top of terrain. +- [x] **Tick loop** in `autoload/sim.gd` — time-accumulator pattern: `_accum += delta * SPEED_FACTOR[current_speed]`, drains in `TICK_INTERVAL_S = 1/20` chunks emitting `EventBus.sim_tick`. Default boot speed = NORMAL. `set_speed()` resets `_accum` to 0 to avoid burst-ticks after pause. +- [x] **Speed control top bar** (`scenes/ui/top_bar.tscn`) — `CanvasLayer` (layer 10) → 4 buttons (Pause / 1× / Fast / Ultra) + tick label. Keyboard shortcuts: `pause`, `speed_normal/fast/ultra` (keys Space, 1, 2, 3). Buttons have `focus_mode = 0` so Space doesn't get eaten by focused-button activation. Active speed highlighted via modulate. +- [x] **Camera rig** (`scenes/world/camera_rig.tscn`) per `ui.md` "World view camera (locked)": + - Pinch-zoom via `InputEventMagnifyGesture` + mouse-wheel; `target_zoom` lerps smoothly toward intent + - Drag-pan via `InputEventScreenDrag` / `InputEventMouseMotion + left-button held` + - Double-tap-centre with 300 ms / 16 px window, animated by Tween + - `set_world_bounds(rect)` called by `world.gd` once map is built — sets Camera2D `limit_*` with 32 px bleed + - No follow-cam +- [x] **Indoor tint shader skeleton** at `art/shaders/indoor_tint.gdshader` (`tint_strength = 0` pass-through). Not yet attached to any TileMapLayer material — Phase 13 wires it onto the Floor layer driven by the Roof flag. +- [x] **Acceptance (visual, MCP-verified):** 80² grass field renders, 8×8 stone ring landmark visible at centre, 4 speed buttons render top-left, tick counter updates top-right, `Sim.set_speed()` works (verified via `execute_game_script`), pause freezes the tick counter. Manual interaction in the editor's Play window covers the keyboard/click pathway (MCP's `simulate_key` doesn't route through `_unhandled_input` — recorded as a follow-up). --- diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 9cba44f..ed3d6f9 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -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") diff --git a/scenes/main/main.tscn b/scenes/main/main.tscn index c28b028..b85415e 100644 --- a/scenes/main/main.tscn +++ b/scenes/main/main.tscn @@ -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")] diff --git a/scenes/ui/top_bar.gd b/scenes/ui/top_bar.gd new file mode 100644 index 0000000..478f97a --- /dev/null +++ b/scenes/ui/top_bar.gd @@ -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 diff --git a/scenes/ui/top_bar.gd.uid b/scenes/ui/top_bar.gd.uid new file mode 100644 index 0000000..65ae747 --- /dev/null +++ b/scenes/ui/top_bar.gd.uid @@ -0,0 +1 @@ +uid://bap8avymp6dj5 diff --git a/scenes/ui/top_bar.tscn b/scenes/ui/top_bar.tscn new file mode 100644 index 0000000..115a184 --- /dev/null +++ b/scenes/ui/top_bar.tscn @@ -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 diff --git a/scenes/world/camera_rig.gd b/scenes/world/camera_rig.gd new file mode 100644 index 0000000..1d1a8a4 --- /dev/null +++ b/scenes/world/camera_rig.gd @@ -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]) diff --git a/scenes/world/camera_rig.gd.uid b/scenes/world/camera_rig.gd.uid new file mode 100644 index 0000000..44454de --- /dev/null +++ b/scenes/world/camera_rig.gd.uid @@ -0,0 +1 @@ +uid://bcyu4gcjd3837 diff --git a/scenes/world/camera_rig.tscn b/scenes/world/camera_rig.tscn new file mode 100644 index 0000000..2055f3c --- /dev/null +++ b/scenes/world/camera_rig.tscn @@ -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") diff --git a/scenes/world/world.gd b/scenes/world/world.gd new file mode 100644 index 0000000..20e694a --- /dev/null +++ b/scenes/world/world.gd @@ -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()) diff --git a/scenes/world/world.gd.uid b/scenes/world/world.gd.uid new file mode 100644 index 0000000..9407edf --- /dev/null +++ b/scenes/world/world.gd.uid @@ -0,0 +1 @@ +uid://bl0mqje5rcsu8 diff --git a/scenes/world/world.tscn b/scenes/world/world.tscn new file mode 100644 index 0000000..da28664 --- /dev/null +++ b/scenes/world/world.tscn @@ -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)