rimlike/scenes/entities/big_rock.gd
megaproxy 938e871bf1 Add BigRock: 2×2 mineable boulder with full mining/path/save support
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) <noreply@anthropic.com>
2026-05-12 14:34:24 +01:00

240 lines
8.9 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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