From 938e871bf153027880927ebf0abd07c1d21f92e1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 12 May 2026 14:34:24 +0100 Subject: [PATCH] =?UTF-8?q?Add=20BigRock:=202=C3=972=20mineable=20boulder?= =?UTF-8?q?=20with=20full=20mining/path/save=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new entity for multi-tile rock formations. Same duck-typed contract as single-tile Rock so MineProvider scans both transparently via World.rocks. Differences from Rock: • Occupies a 2×2 footprint anchored at origin_tile (top-left). • Renders a single 32×32 Sprite2D drawn from the FG_Grasslands_Spring 2×2 cluster sprites at (22, 3) brown and (30, 3) gray. • Blocks pathfinding on all four footprint tiles — pawns route around it. • MineProvider asks `rock.approach_tile_for(pawn.tile)` for the walk destination, so the pawn stands beside the boulder instead of trying to path into the blocked footprint. Rock returns its own tile (walkable); BigRock picks the nearest walkable perimeter neighbour. • Mining takes 480 ticks (4× Rock) and drops 4 stone, one per footprint tile. All init work happens in setup() rather than _ready(): the calling pattern is `add_child(big); big.setup(origin)`, and _ready fires inside add_child with origin_tile still at its zero default — anything reading origin_tile from _ready would stamp the pathfinder at the wrong tile. Wired through SaveSystem: factory preload, spawn-priority tier 0 (same as Rock — static structures spawn before pawns), and a `&"big_rock"` factory. World seed adds two demo boulders near the small-rock cluster (65, 58) + (56, 64) so the visual contrast is on-screen from boot. Co-Authored-By: Claude Opus 4.7 (1M context) --- autoload/save_system.gd | 11 ++ scenes/ai/mine_provider.gd | 14 +- scenes/entities/big_rock.gd | 240 ++++++++++++++++++++++++++++++++++ scenes/entities/big_rock.tscn | 6 + scenes/entities/rock.gd | 9 ++ scenes/world/world.gd | 16 ++- 6 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 scenes/entities/big_rock.gd create mode 100644 scenes/entities/big_rock.tscn diff --git a/autoload/save_system.gd b/autoload/save_system.gd index 89601f2..115d730 100644 --- a/autoload/save_system.gd +++ b/autoload/save_system.gd @@ -28,6 +28,7 @@ const SAVE_VERSION: int = 2 const _PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn") const _TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn") const _ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn") +const _BIG_ROCK_SCENE: PackedScene = preload("res://scenes/entities/big_rock.tscn") const _ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") const _WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn") const _FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn") @@ -314,6 +315,7 @@ func _collect_entities() -> Array: const _SPAWN_PRIORITY: Dictionary = { &"tree": 0, &"rock": 0, + &"big_rock": 0, &"wall": 0, &"floor": 0, &"door": 1, @@ -348,6 +350,7 @@ func _sort_by_spawn_priority(entity_dicts: Array) -> Array: func _register_factories() -> void: _factories[&"tree"] = _spawn_tree _factories[&"rock"] = _spawn_rock + _factories[&"big_rock"] = _spawn_big_rock _factories[&"item"] = _spawn_item _factories[&"wall"] = _spawn_wall _factories[&"floor"] = _spawn_floor @@ -387,6 +390,14 @@ func _spawn_rock(world_scene: Node, d: Dictionary) -> void: ent.queue_redraw() +func _spawn_big_rock(world_scene: Node, d: Dictionary) -> void: + var ent = _BIG_ROCK_SCENE.instantiate() + world_scene.add_child(ent) + ent.setup(Vector2i(int(d.get("origin_x", 0)), int(d.get("origin_y", 0)))) + ent.mine_progress = int(d.get("mine_progress", 0)) + ent.queue_redraw() + + func _spawn_item(world_scene: Node, d: Dictionary) -> void: var ent = _ITEM_SCENE.instantiate() world_scene.add_child(ent) diff --git a/scenes/ai/mine_provider.gd b/scenes/ai/mine_provider.gd index 7ea767b..6209c1e 100644 --- a/scenes/ai/mine_provider.gd +++ b/scenes/ai/mine_provider.gd @@ -15,9 +15,11 @@ class_name MineProvider extends WorkProvider ## pathfinder). ## ## Duck-typing note: Rock is referenced without class_name (class may not be -## registered yet when this provider loads). We rely only on: -## rock.tile: Vector2i +## registered yet when this provider loads). World.rocks may also hold BigRock +## instances; both expose the same interface so we just iterate the array: +## rock.tile: Vector2i — for distance scoring ## rock.is_mineable() -> bool +## rock.approach_tile_for(pawn_tile) -> Vector2i — walk destination ## rock.get_path() -> NodePath @@ -48,6 +50,12 @@ func find_best_for(pawn) -> Job: var j := Job.new() j.label = "Mine rock at %s" % best.tile j.target_node = best - j.toils.append(Toil.walk_to(best.tile)) + # Ask the entity where the pawn should stand. Single rocks return their own + # tile (walkable). BigRocks return a perimeter tile so the pawn doesn't try + # to pathfind into the blocked 2×2 footprint. + var walk_tile: Vector2i = ( + best.approach_tile_for(pawn.tile) if best.has_method("approach_tile_for") else best.tile + ) + j.toils.append(Toil.walk_to(walk_tile)) j.toils.append(Toil.interact(best.get_path(), &"on_mine_tick")) return j diff --git a/scenes/entities/big_rock.gd b/scenes/entities/big_rock.gd new file mode 100644 index 0000000..ea0748b --- /dev/null +++ b/scenes/entities/big_rock.gd @@ -0,0 +1,240 @@ +## BigRock — 2×2 boulder formation. Same mining contract as Rock but occupies +## four tiles, drops four stone, and blocks pathfinding on its footprint so +## pawns route around it. +## +## Duck-typed to Rock's public interface (tile, is_mineable, on_mine_tick, +## approach_tile_for) so MineProvider can scan World.rocks without checking +## the entity's concrete type. +## +## Position semantics (different from single Rock): +## • origin_tile is the TOP-LEFT of the 2×2 footprint (player-friendly anchor). +## • The Sprite2D is 32×32 and is centred at the intersection of the four +## tiles, so its visual centre sits at (origin_tile + (1, 1)) * 16 px. +## • For MineProvider distance, `tile` aliases to origin_tile (cheap, stable). +## • For the pawn's walk destination, approach_tile_for(pawn_tile) returns +## the closest walkable perimeter neighbour, so the pawn stands BESIDE the +## boulder instead of trying to path onto a blocked tile. + +class_name BigRock extends Node2D + +const TILE_SIZE_PX: int = 16 + +## Sim ticks to mine a big rock. Roughly 4× a small rock (120 ticks) since the +## footprint represents four tiles' worth of stone. +const MINE_TICKS: int = 480 +## Stone Items dropped on a successful mine (one per footprint tile). +const STONE_DROPS_ON_MINE: int = 4 + +## Footprint dimensions. Locked at 2×2 for the first BigRock pass; if larger +## boulders ever ship, generalise here and the perimeter math in approach_tile_for. +const FOOTPRINT_W: int = 2 +const FOOTPRINT_H: int = 2 + +# Preloaded scene for spawned stone items. +const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") + +## ElvGames Grasslands tileset — 2×2 cluster sprites starting at these +## top-left coords. Visually confirmed against /tmp/rocks_labeled_grid.png +## in the 2026-05-12 visual pass. +const _ROCK_TEX: Texture2D = preload("res://art/tiles/FG_Grasslands_Spring.png") +const _BIG_ROCK_ATLAS_COORDS: Array[Vector2i] = [ + Vector2i(22, 3), # brown 2×2 boulder + Vector2i(30, 3), # gray 2×2 boulder +] + + +# ── state ───────────────────────────────────────────────────────────────────── + +## Top-left tile of the 2×2 footprint. +var origin_tile: Vector2i = Vector2i.ZERO + +## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS. +var mine_progress: int = 0 + + +# ── lifecycle ───────────────────────────────────────────────────────────────── + +## All init work happens inside setup() (not _ready) because callers +## always do `add_child(node); node.setup(origin)` — _ready fires inside +## add_child with origin_tile still at its zero default, so anything that +## reads origin_tile from _ready would stamp the pathfinder / position at +## the wrong tile. setup() is the single authoritative entry point. +func _ready() -> void: + pass + + +func _exit_tree() -> void: + World.unregister_rock(self) + _set_footprint_walkable(true) + + +# ── public API ──────────────────────────────────────────────────────────────── + +## One-shot initialiser. Builds the sprite, positions the node, marks the four +## footprint tiles unwalkable, and registers with World.rocks. Call after +## add_child(). +func setup(p_origin: Vector2i) -> void: + origin_tile = p_origin + mine_progress = 0 + position = Vector2( + (origin_tile.x + 1) * TILE_SIZE_PX, + (origin_tile.y + 1) * TILE_SIZE_PX, + ) + _build_sprite() + _set_footprint_walkable(false) + World.register_rock(self) + queue_redraw() + Audit.log("big_rock", "spawned 2×2 at %s" % origin_tile) + + +## Alias used by MineProvider's distance calc (`rock.tile`). Returns the +## top-left so the manhattan distance is stable; refining to the centre would +## require fractional tiles. +var tile: Vector2i: + get: + return origin_tile + + +## True when the boulder hasn't been fully mined yet. +func is_mineable() -> bool: + return mine_progress < MINE_TICKS + + +## Returns the perimeter tile closest to `pawn_tile` that the pawn can stand +## on while mining. Mirrors Rock.approach_tile_for so MineProvider can ask the +## entity for its walk destination without knowing which kind of rock it is. +## Falls back to the origin tile when no perimeter is walkable — the pathfinder +## will then return an empty path and the job runner cancels cleanly. +func approach_tile_for(pawn_tile: Vector2i) -> Vector2i: + var perimeter := _perimeter_tiles() + var best: Vector2i = origin_tile + var best_d: int = 0x7fffffff + var found_any: bool = false + for t in perimeter: + if World.pathfinder == null: + continue + if not World.pathfinder.is_walkable(t): + continue + var d: int = abs(t.x - pawn_tile.x) + abs(t.y - pawn_tile.y) + if d < best_d: + best_d = d + best = t + found_any = true + if not found_any: + return origin_tile + return best + + +## Called by the INTERACT toil in JobRunner once per sim tick while the pawn +## works this boulder. Advances mine_progress and triggers mined() when done. +func on_mine_tick() -> void: + if not is_mineable(): + return + mine_progress += 1 + queue_redraw() + if mine_progress >= MINE_TICKS: + mined() + + +## Drop four stone Items (one per footprint tile) and free this node. Called +## by on_mine_tick() automatically; can also be called for scripted removal. +func mined() -> void: + for ft in footprint_tiles(): + var item: Item = ITEM_SCENE.instantiate() + get_parent().add_child(item) + item.setup(Item.TYPE_STONE, 1, ft) + Audit.log("big_rock", "mined 2×2 at %s; %d stone drops" % [origin_tile, STONE_DROPS_ON_MINE]) + queue_free() + + +## The four tiles this boulder occupies (origin + the three south/east neighbours). +func footprint_tiles() -> Array[Vector2i]: + return [ + origin_tile, + origin_tile + Vector2i(1, 0), + origin_tile + Vector2i(0, 1), + origin_tile + Vector2i(1, 1), + ] + + +# ── save / load ─────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "class_id": &"big_rock", + "origin_x": origin_tile.x, + "origin_y": origin_tile.y, + "mine_progress": mine_progress, + } + + +static func from_dict(d: Dictionary) -> Dictionary: + return { + "origin_x": int(d.get("origin_x", 0)), + "origin_y": int(d.get("origin_y", 0)), + "mine_progress": int(d.get("mine_progress", 0)), + } + + +# ── render ──────────────────────────────────────────────────────────────────── + +func _draw() -> void: + # Sprite child draws the boulder body. _draw renders only the mine-progress + # crack overlay so the player sees mining damage. + if mine_progress > 0: + var ratio := float(mine_progress) / float(MINE_TICKS) + var crack_len := ratio * 12.0 + draw_line( + Vector2(-4.0, -2.0), + Vector2(-4.0 + crack_len, 4.0), + Color(0.15, 0.12, 0.10, 0.85), + 1.5 + ) + + +# ── internal ────────────────────────────────────────────────────────────────── + +## Build a single 32×32 Sprite2D from a 2×2 region of FG_Grasslands_Spring. +## Variant chosen deterministically from origin_tile so the same boulder renders +## the same colour across boots and load/save. +func _build_sprite() -> void: + var sprite := Sprite2D.new() + sprite.name = "Sprite" + sprite.texture = _ROCK_TEX + sprite.region_enabled = true + var coord_idx: int = (origin_tile.x * 31 + origin_tile.y * 17) % _BIG_ROCK_ATLAS_COORDS.size() + var coord: Vector2i = _BIG_ROCK_ATLAS_COORDS[coord_idx] + sprite.region_rect = Rect2( + coord.x * TILE_SIZE_PX, + coord.y * TILE_SIZE_PX, + TILE_SIZE_PX * FOOTPRINT_W, + TILE_SIZE_PX * FOOTPRINT_H, + ) + sprite.centered = true + sprite.offset = Vector2.ZERO + add_child(sprite) + + +## Mark or unmark the four footprint tiles in the pathfinder. Called from +## _ready / _exit_tree so the boulder appears as an obstacle while it exists. +func _set_footprint_walkable(walkable: bool) -> void: + if World.pathfinder == null: + return + for ft in footprint_tiles(): + World.pathfinder.set_cell_walkable(ft, walkable) + + +## The eight perimeter tiles around the 2×2 footprint (cardinal + diagonals +## of the bounding rect). Used by approach_tile_for to find a stand-and-mine +## tile near the pawn. +func _perimeter_tiles() -> Array[Vector2i]: + var out: Array[Vector2i] = [] + # Top + bottom rows. + for dx in range(-1, FOOTPRINT_W + 1): + out.append(origin_tile + Vector2i(dx, -1)) + out.append(origin_tile + Vector2i(dx, FOOTPRINT_H)) + # Left + right columns (skip the corners — already in the top/bottom rows). + for dy in range(0, FOOTPRINT_H): + out.append(origin_tile + Vector2i(-1, dy)) + out.append(origin_tile + Vector2i(FOOTPRINT_W, dy)) + return out diff --git a/scenes/entities/big_rock.tscn b/scenes/entities/big_rock.tscn new file mode 100644 index 0000000..9a61bd8 --- /dev/null +++ b/scenes/entities/big_rock.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://big_rock_entity"] + +[ext_resource type="Script" path="res://scenes/entities/big_rock.gd" id="1_big_rock"] + +[node name="BigRock" type="Node2D"] +script = ExtResource("1_big_rock") diff --git a/scenes/entities/rock.gd b/scenes/entities/rock.gd index 2722ced..59b82d7 100644 --- a/scenes/entities/rock.gd +++ b/scenes/entities/rock.gd @@ -91,6 +91,15 @@ func is_mineable() -> bool: return mine_progress < MINE_TICKS +## Walk destination for a pawn approaching this rock. Small rocks are walkable +## (Phase 4 simplification), so the pawn stands on the rock tile while mining. +## BigRock overrides this to return a perimeter neighbour because its footprint +## is blocked. MineProvider always asks the entity for this rather than reading +## `.tile` directly, so single Rock and BigRock plug into the same walk toil. +func approach_tile_for(_pawn_tile: Vector2i) -> Vector2i: + return tile + + ## Called by the INTERACT toil in JobRunner once per sim tick while the pawn ## works this rock. Advances mine_progress and triggers mined() when complete. func on_mine_tick() -> void: diff --git a/scenes/world/world.gd b/scenes/world/world.gd index 80589b5..c4fa98b 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -30,6 +30,7 @@ const EVENT_CATALOG_SCRIPT: Script = preload("res://scenes/storyteller/event_cat const PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn") const TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn") const ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn") +const BIG_ROCK_SCENE: PackedScene = preload("res://scenes/entities/big_rock.tscn") const STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn") const WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn") const FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn") @@ -66,6 +67,13 @@ const SAMPLE_ROCKS: Array[Vector2i] = [ Vector2i(60, 60), Vector2i(62, 60), Vector2i(63, 62), Vector2i(58, 62), ] +# 2×2 boulder formations (top-left anchor tiles). Two near the small-rock +# cluster so the player sees the size contrast on first scroll. +const SAMPLE_BIG_ROCKS: Array[Vector2i] = [ + Vector2i(65, 58), # brown/gray (deterministic per-tile) + Vector2i(56, 64), +] + # HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks). const HAUL_SWEEP_INTERVAL_TICKS: int = 100 @@ -409,7 +417,13 @@ func _spawn_sample_harvestables() -> void: var rock = ROCK_SCENE.instantiate() add_child(rock) rock.setup(r_tile) - Audit.log("world", "spawned %d trees + %d rocks" % [SAMPLE_TREES.size(), SAMPLE_ROCKS.size()]) + for br_origin in SAMPLE_BIG_ROCKS: + var big = BIG_ROCK_SCENE.instantiate() + add_child(big) + big.setup(br_origin) + Audit.log("world", "spawned %d trees + %d rocks + %d big rocks" % [ + SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size() + ]) func _seed_phase5_demo_buildings() -> void: