## 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 ## True once a player has painted a mine designation on any footprint tile. ## MineProvider ignores undesignated boulders (Rimworld parity). var mine_designated: bool = false # ── 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: # Apply mine buff (veins_of_iron): multiply total stone drops with stochastic rounding, # then distribute one item per footprint tile (first N tiles get 1 each; extras stack # on the origin tile for simple overflow handling). var total_drops: int = Storyteller.multiply_drops(STONE_DROPS_ON_MINE, Storyteller.get_buff_multiplier(&"mine")) var tiles := footprint_tiles() for i in total_drops: var drop_tile: Vector2i = tiles[mini(i, tiles.size() - 1)] var item: Item = ITEM_SCENE.instantiate() get_parent().add_child(item) item.setup(Item.TYPE_STONE, 1, drop_tile) Audit.log("big_rock", "mined 2×2 at %s; %d stone drops (buff mult=%.2f)" % [origin_tile, total_drops, Storyteller.get_buff_multiplier(&"mine")]) if Audio != null: Audio.play_sfx(&"mine_tick") # BigRocks span 2×2 — clear the designation stamp from every footprint tile. for ft in footprint_tiles(): World.clear_designation_at(ft) 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, "mine_designated": mine_designated, } 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)), "mine_designated": bool(d.get("mine_designated", false)), } # ── 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