Renewable resources: tree growth + WildGrowth + Quarry on BigRockNode

Trees: 4 growth stages (Sapling→Young→Growing→Mature), only Mature
yields wood. WildGrowth ticker fires every in-game hour; rejection-
samples grass tiles and plants a sapling with ~30% probability (capped
at MAP_TREE_LIMIT=60). New `paint_plant_tree` designation lets the
player manually plant — ghost sapling registered as a build_site that
ConstructionProvider fulfils. Stage round-trips through save/load.
Initial seed mixes 4 saplings + 6 mature so growth is visible day 1.

Quarry: new BigRockNode entity (2×2 permanent stone outcrop, never
depletes). 3 nodes seeded far from cabin. New QuarryWorkbench
(extends Workbench, auto-FOREVER `quarry_stone` bill, recipe drops
1 stone per 300 work-ticks). New `paint_quarry` designation only
accepts BigRockNode tiles. CraftingProvider now supports recipes
with `ingredient_count == 0` — skips ingredient-fetch and goes
straight to walk+craft toils. Recipe gains `ingredient_count` field
(defaults 0). Save/load layering: big_rock_node spawns at priority 0
(same as rock/tree), quarry_workbench at priority 2 (after the node).

UI: Plant tree + Build quarry buttons added to Build drawer.
build_drawer_thumb gains `plant_tree` (sapling sprout in dirt) and
`paint_quarry` (stone block + chisel + cut-stone pile) shapes.
inspect_tooltip recognises BigRockNode + shows tree growth stage on
hover.

Delegation: gdscript-refactor (Sonnet ×2) for trees full impl +
quarry skeleton; quick-edit (Haiku) for CraftingProvider no-ingredient
plumbing + TopBar polish; integration handled on Opus.
This commit is contained in:
megaproxy 2026-05-16 16:36:16 +01:00
parent 296894ff7a
commit d98d2c2425
20 changed files with 716 additions and 38 deletions

View file

@ -0,0 +1,140 @@
class_name BigRockNode extends Node2D
## Permanent stone outcrop — a 2×2 immovable boulder that never depletes.
##
## BigRockNode marks its footprint impassable in the pathfinder and registers
## itself with World so the designation system can locate it. A QuarryWorkbench
## can be built on one of its tiles; external code sets is_quarry_site = true
## and quarry_workbench once construction completes.
##
## Draw convention: position is stamped at the TOP-LEFT pixel corner of the
## 2×2 footprint (tile * TILE_SIZE_PX). The visual centre of the 32×32 area
## sits at local (16, 16), so all _draw_* helpers are centred there.
##
## Save/load: to_dict / from_dict round-trip tile + is_quarry_site.
## The quarry_workbench reference is an entity pointer reconstructed by the
## save layer (SaveSystem wires it after both entities are spawned).
const TILE_SIZE_PX: int = 16
## Top-left tile of the 2×2 footprint.
var tile: Vector2i = Vector2i.ZERO
## Footprint size in tiles (always 2×2; declared as a var so external code can
## read it uniformly without special-casing BigRockNode vs hypothetical future nodes).
var footprint: Vector2i = Vector2i(2, 2)
## True once a Quarry workbench has been completed on this outcrop.
## Flipped by external code (designation / world.gd) — this file only declares it.
var is_quarry_site: bool = false
## The QuarryWorkbench that sits on this node after construction.
## null until assigned by the designation completion handler.
var quarry_workbench = null
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Position at top-left pixel corner so footprint_tiles() aligns with world coords.
position = Vector2(tile.x * TILE_SIZE_PX, tile.y * TILE_SIZE_PX)
# Block pathfinding on every tile in the footprint.
for t in footprint_tiles():
if World.pathfinder != null:
World.pathfinder.set_cell_walkable(t, false)
World.register_big_rock_node(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_big_rock_node(self)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() has fired.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = Vector2(tile.x * TILE_SIZE_PX, tile.y * TILE_SIZE_PX)
queue_redraw()
Audit.log("big_rock_node", "spawned at tile %s" % tile)
## Returns the four tiles covered by this node's 2×2 footprint.
## Used by designation, save/load, and inspection code.
func footprint_tiles() -> Array[Vector2i]:
return [
tile,
tile + Vector2i(1, 0),
tile + Vector2i(0, 1),
tile + Vector2i(1, 1),
]
## True when tile_to_check falls inside the 2×2 footprint.
func is_at(tile_to_check: Vector2i) -> bool:
return (
tile_to_check.x >= tile.x and tile_to_check.x < tile.x + footprint.x
and tile_to_check.y >= tile.y and tile_to_check.y < tile.y + footprint.y
)
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"big_rock_node",
"tile_x": tile.x,
"tile_y": tile.y,
"is_quarry_site": is_quarry_site,
}
## Restore from a dict produced by to_dict(). quarry_workbench is reconnected
## by the save layer after both entities are spawned.
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"is_quarry_site": bool(d.get("is_quarry_site", false)),
}
# ── render ────────────────────────────────────────────────────────────────────
## Draw a procedural pile of grey rocks centred at local (16, 16) —
## the geometric centre of the 32×32 footprint area.
## Three layers create depth: large base blob → medium mid rock → small cap.
func _draw() -> void:
var cx: float = 16.0
var cy: float = 16.0
# Colour palette — warm grey tones suggesting weathered granite.
var base_fill := Color(0.60, 0.58, 0.55) # large base ellipse
var mid_fill := Color(0.45, 0.44, 0.42) # medium middle rock
var top_fill := Color(0.55, 0.53, 0.50) # small perched cap
var outline_col := Color(0.20, 0.18, 0.16, 0.70) # subtle dark rim
# Bottom: large flattened blob — widest at the base to read as a ground-hugging mass.
var base_w: float = 24.0
var base_h: float = 16.0
var base_rect := Rect2(Vector2(cx - base_w / 2.0, cy - base_h / 2.0 + 2.0), Vector2(base_w, base_h))
draw_rect(base_rect, base_fill)
# Dark outline arc around the base blob.
draw_rect(base_rect, outline_col, false, 1.0)
# Middle: slightly elevated, narrower rock sitting on top of the base.
var mid_w: float = 16.0
var mid_h: float = 12.0
var mid_oy: float = -4.0 # shift up from centre
var mid_rect := Rect2(Vector2(cx - mid_w / 2.0, cy - mid_h / 2.0 + mid_oy), Vector2(mid_w, mid_h))
draw_rect(mid_rect, mid_fill)
draw_rect(mid_rect, outline_col, false, 1.0)
# Top: small cap perched on the very top.
var top_w: float = 8.0
var top_h: float = 6.0
var top_oy: float = -10.0 # above the mid rock
var top_rect := Rect2(Vector2(cx - top_w / 2.0, cy - top_h / 2.0 + top_oy), Vector2(top_w, top_h))
draw_rect(top_rect, top_fill)
draw_rect(top_rect, outline_col, false, 1.0)

View file

@ -0,0 +1 @@
uid://bhn0lknhgn1od

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://big_rock_node_entity"]
[ext_resource type="Script" path="res://scenes/entities/big_rock_node.gd" id="1_big_rock_node"]
[node name="BigRockNode" type="Node2D"]
script = ExtResource("1_big_rock_node")

View file

@ -0,0 +1,80 @@
class_name QuarryWorkbench extends Workbench
## Quarry workbench — built on a BigRockNode tile, produces stone indefinitely.
##
## Subclasses Workbench to reuse the build + bill machinery. A default
## FOREVER bill with the quarry_stone recipe is auto-added in _ready() so the
## workbench begins dripping stone as soon as construction completes.
##
## No-ingredient recipe: quarry_stone uses ingredient_count = 0. CraftingProvider
## must handle the zero-ingredient path before this workbench produces anything
## (see plan_quarry_bigrock.md Step 1 — handled separately).
##
## Variant appearance: overrides _draw() unconditionally so that the quarry
## silhouette is always shown regardless of label_text (mirrors CremationPyre).
## accepted_skill = manual_labor — any labourer can work the quarry.
##
## World registration: inherited from Workbench._ready / _exit_tree.
func _init() -> void:
label_text = "Quarry"
accepted_skill = &"manual_labor"
func _ready() -> void:
label_text = "Quarry"
accepted_skill = &"manual_labor"
super._ready()
# Auto-populate a default FOREVER bill so the bench is immediately usable
# once construction completes. Mirrors CremationPyre's bill wiring.
var b := Bill.new()
b.recipe = RecipeCatalog.quarry_stone()
b.mode = Bill.Mode.FOREVER
bills.append(b)
Audit.log("quarry", "QuarryWorkbench ready at %s — bill added" % tile)
# ── render ────────────────────────────────────────────────────────────────────
## Override Workbench._draw() to always dispatch to _draw_quarry().
## Alpha is 0.4 while under construction, 1.0 once complete (same as all benches).
func _draw() -> void:
var alpha: float = 1.0 if is_completed() else 0.4
_draw_quarry(alpha)
## Procedural quarry appearance: a wooden frame base with a large stone block
## on top, chisel-mark details, and a small pile of freshly cut stones to the
## right side. Local coords follow Workbench convention — (0, 0) is the
## bottom-right of the tile; the bench draws UP into negative-y space.
func _draw_quarry(alpha: float) -> void:
var frame_top := Color(0.55, 0.36, 0.18, alpha) # light wood top face
var frame_front := Color(0.42, 0.26, 0.12, alpha) # darker wood front
var frame_edge := Color(0.25, 0.14, 0.06, alpha) # wood grain seam
var stone_top := Color(0.60, 0.58, 0.55, alpha) # stone block top face
var stone_front := Color(0.44, 0.43, 0.41, alpha) # stone block front
var chisel_mark := Color(0.28, 0.27, 0.25, alpha) # cut line on stone
var pile_light := Color(0.62, 0.61, 0.59, alpha) # loose stone pile
var pile_dark := Color(0.38, 0.37, 0.35, alpha) # pile shadow
var outline := Color(0.20, 0.18, 0.16, 0.70 * alpha)
# Wooden frame base — front face + top lip.
draw_rect(Rect2(Vector2(-8.0, -7.0), Vector2(16.0, 7.0)), frame_front)
draw_rect(Rect2(Vector2(-8.0, -10.0), Vector2(16.0, 3.0)), frame_top)
draw_line(Vector2(-8.0, -7.0), Vector2(8.0, -7.0), frame_edge, 1.0)
# Stone block sitting on the frame — occupies most of the upper half.
draw_rect(Rect2(Vector2(-7.0, -15.0), Vector2(11.0, 5.0)), stone_top)
draw_rect(Rect2(Vector2(-7.0, -10.5), Vector2(11.0, 0.5)), stone_front) # thin visible front edge
# Chisel marks on the stone top — short diagonal cuts suggesting work in progress.
draw_line(Vector2(-4.0, -13.0), Vector2(-2.0, -11.5), chisel_mark, 1.0)
draw_line(Vector2(-1.0, -14.0), Vector2( 1.0, -12.5), chisel_mark, 1.0)
draw_line(Vector2( 2.0, -13.0), Vector2( 4.0, -11.5), chisel_mark, 1.0)
# Small pile of cut stones on the right — two rounded rects stacked.
draw_rect(Rect2(Vector2(5.0, -9.0), Vector2(4.0, 3.0)), pile_dark)
draw_rect(Rect2(Vector2(5.0, -11.0), Vector2(3.0, 2.0)), pile_light)
# Tile outline.
draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0)

View file

@ -0,0 +1 @@
uid://bpbcwr4dh1736

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://quarry_workbench_entity"]
[ext_resource type="Script" path="res://scenes/entities/quarry_workbench.gd" id="1_quarry"]
[node name="QuarryWorkbench" type="Node2D"]
script = ExtResource("1_quarry")

View file

@ -1,10 +1,15 @@
## Tree entity — choppable by a pawn with a Chop job. Drops wood Item nodes
## when felled.
## when felled. Trees also grow through four stages (Sapling → Young → Growing
## → Mature); only Mature trees can be chopped.
##
## Chopping model (docs/implementation.md Phase 4):
## A ChopProvider creates a Job whose INTERACT toil calls on_chop_tick() once
## per sim tick via JobRunner. After CHOP_TICKS ticks the tree is felled.
##
## Growth model: on_sim_tick() is called once per sim tick by world.gd's sweep.
## After STAGE_TICKS ticks at each sub-mature stage the tree advances one stage
## and _refresh_sprite() updates the visual.
##
## World registration (World.register_tree / World.unregister_tree) is called
## here but the methods land in World during Opus integration.
@ -22,6 +27,19 @@ const WOOD_DROPS_ON_FELL: int = 3
## Stack size per dropped Item (Phase 4 simplicity: 3 items of stack 1 each).
const STACK_SIZE_PER_DROP: int = 1
# ── growth stage constants ─────────────────────────────────────────────────────
## Growth stage indices.
const STAGE_SAPLING: int = 0
const STAGE_YOUNG: int = 1
const STAGE_GROWING: int = 2
const STAGE_MATURE: int = 3
## Sim ticks spent in each sub-mature stage before advancing.
## 5 in-game hours per stage at 20 Hz = 1200 ticks/hour × 5 = 6000 ticks.
## At default 5× speed that is ~5 min real time per stage, ~15 min seed → mature.
const STAGE_TICKS: int = 6000
# ── state ─────────────────────────────────────────────────────────────────────
var tile: Vector2i = Vector2i.ZERO
@ -31,6 +49,17 @@ var chop_progress: int = 0
## ignores undesignated trees (Rimworld parity — pawns don't auto-chop).
var chop_designated: bool = false
## Current growth stage (STAGE_SAPLING..STAGE_MATURE). Default MATURE so
## existing seed trees load at full size with no visual regression.
var growth_stage: int = STAGE_MATURE
## Ticks elapsed within the current sub-mature stage.
var growth_progress: int = 0
## Set to true on a ghost tree spawned by the plant_tree designation. The
## ConstructionProvider will issue a build job; on completion this flag clears
## and the tree grows normally.
var pending_plant: bool = false
# Preloaded scene for spawned wood items.
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
@ -57,18 +86,27 @@ const _TREE_SILHOUETTES: int = 4 # silhouettes per atlas (columns)
func _ready() -> void:
position = _tile_to_world(tile)
_build_sprite()
_refresh_sprite()
# Y-sort so the canopy draws behind walls/pawns that are visually south of
# the trunk base. Position.y is the trunk-base row.
y_sort_enabled = true
World.register_tree(self)
## Adds a Sprite2D child painted with one of the 12 ElvGames tree variants
## (4 silhouettes × 3 season palettes). Variant chosen deterministically
## from the tile coord so the same tile always gets the same tree silhouette
## across boots and load/save.
func _build_sprite() -> void:
## Rebuild the Sprite2D child to match the current growth_stage.
## Sapling (stage 0): no Sprite2D — rendered procedurally in _draw().
## Young (1) → scale 0.35, Growing (2) → 0.65, Mature (3) → 1.0.
## Any existing "Sprite" child is removed first so re-calls don't stack.
func _refresh_sprite() -> void:
var old := get_node_or_null("Sprite")
if old != null:
old.queue_free()
if growth_stage == STAGE_SAPLING:
# No Sprite2D for saplings — all rendering done in _draw().
queue_redraw()
return
var scale_map: Array[float] = [1.0, 0.35, 0.65, 1.0] # indexed by stage
var sprite_scale: float = scale_map[growth_stage]
var sprite := Sprite2D.new()
sprite.name = "Sprite"
var hash_seed: int = tile.x * 31 + tile.y * 17
@ -83,9 +121,18 @@ func _build_sprite() -> void:
# Sprite center is at offset.y; sprite half-height is _TREE_VARIANT_H/2 = 40.
# We want bottom edge at +8 (tile bottom) → center at 8 - 40 = -32.
sprite.offset = Vector2(0, -32)
sprite.scale = Vector2(sprite_scale, sprite_scale)
# Render behind pawns/items that are at higher z_index; trees live at z=0.
sprite.z_index = 0
add_child(sprite)
queue_redraw()
## Adds a Sprite2D child painted with one of the 12 ElvGames tree variants
## (4 silhouettes × 3 season palettes). Kept for call-site compatibility but
## now delegates to _refresh_sprite(). New code should call _refresh_sprite().
func _build_sprite() -> void:
_refresh_sprite()
func _exit_tree() -> void:
@ -95,17 +142,81 @@ func _exit_tree() -> void:
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() already fired.
func setup(start_tile: Vector2i) -> void:
## start_stage defaults to STAGE_MATURE for backward compatibility.
func setup(start_tile: Vector2i, start_stage: int = STAGE_MATURE) -> void:
tile = start_tile
chop_progress = 0
growth_stage = start_stage
growth_progress = 0
position = _tile_to_world(tile)
_refresh_sprite()
queue_redraw()
Audit.log("tree", "spawned at %s" % tile)
Audit.log("tree", "spawned at %s (stage=%d)" % [tile, growth_stage])
## True when the tree hasn't been fully chopped yet.
## True when the tree is mature, unChopped, and not a pending-plant ghost.
## Only mature trees yield wood — saplings/young/growing cannot be felled.
func is_choppable() -> bool:
return chop_progress < CHOP_TICKS
return chop_progress < CHOP_TICKS and growth_stage == STAGE_MATURE and not pending_plant
## Called by world.gd's sim-tick sweep once per sim tick.
## Advances growth_progress and promotes the stage on threshold. No-op for
## mature trees and for pending-plant ghosts (ghost must be built first).
func on_sim_tick() -> void:
if growth_stage >= STAGE_MATURE or pending_plant:
return
growth_progress += 1
if growth_progress >= STAGE_TICKS:
growth_stage += 1
growth_progress = 0
_refresh_sprite()
Audit.log("tree", "grew to stage %d at %s" % [growth_stage, tile])
# ── pending-plant / build-site duck-type API ──────────────────────────────────
# ConstructionProvider requires is_buildable() / on_build_tick() / label()
# on every entity in World.build_queue. A pending_plant tree satisfies this
# interface so the provider can assign a pawn to "build" (plant) it.
## Ticks of pawn work needed to complete a manual planting job.
const PLANT_TICKS: int = 30
## Progress counter within the planting job (0..PLANT_TICKS).
var _plant_progress: int = 0
## True while the tree is a pending-plant ghost awaiting pawn work.
func is_buildable() -> bool:
return pending_plant and _plant_progress < PLANT_TICKS
## Human-readable label for the ConstructionProvider job entry.
func label() -> String:
return "Plant tree"
## Called by JobRunner's BUILD toil once per sim tick while a pawn works this
## site. After PLANT_TICKS the ghost becomes a real sapling.
func on_build_tick() -> void:
if not is_buildable():
return
_plant_progress += 1
queue_redraw()
if _plant_progress >= PLANT_TICKS:
_complete_plant()
## Finish the planting job: clear the pending flag, register as a real sapling,
## remove from World.build_queue, and clear the designation highlight.
func _complete_plant() -> void:
pending_plant = false
_plant_progress = 0
World.unregister_build_site(self)
World.clear_designation_at(tile)
_refresh_sprite()
queue_redraw()
Audit.log("tree", "planted at %s — sapling begins growing" % tile)
## Called by the INTERACT toil in JobRunner once per sim tick while the pawn
@ -145,6 +256,10 @@ func to_dict() -> Dictionary:
"tile_y": tile.y,
"chop_progress": chop_progress,
"chop_designated": chop_designated,
"growth_stage": growth_stage,
"growth_progress": growth_progress,
"pending_plant": pending_plant,
"plant_progress": _plant_progress,
}
@ -154,15 +269,34 @@ static func from_dict(d: Dictionary) -> Dictionary:
"tile_y": int(d.get("tile_y", 0)),
"chop_progress": int(d.get("chop_progress", 0)),
"chop_designated": bool(d.get("chop_designated", false)),
# Default to STAGE_MATURE (3) so pre-growth-system saves load as mature trees.
"growth_stage": int(d.get("growth_stage", 3)),
"growth_progress": int(d.get("growth_progress", 0)),
"pending_plant": bool(d.get("pending_plant", false)),
"plant_progress": int(d.get("plant_progress", 0)),
}
# ── render ────────────────────────────────────────────────────────────────────
func _draw() -> void:
# Canopy + trunk now come from the Sprite2D child (see _build_sprite).
# Sapling stage: draw a procedural sprout — no atlas sprite available.
# Three small green leaf-dots clustered above a thin brown stem.
if growth_stage == STAGE_SAPLING:
# Ghost tint for pending-plant saplings so the player can tell it's
# waiting for a pawn to build it.
var alpha := 0.55 if pending_plant else 1.0
# Stem
draw_line(Vector2(0.0, 6.0), Vector2(0.0, 0.0), Color(0.35, 0.22, 0.10, alpha), 1.5)
# Three leaf dots
draw_circle(Vector2(0.0, -2.0), 2.5, Color(0.30, 0.65, 0.20, alpha))
draw_circle(Vector2(-3.0, 1.0), 1.8, Color(0.25, 0.58, 0.18, alpha))
draw_circle(Vector2(3.0, 0.5), 1.8, Color(0.28, 0.62, 0.19, alpha))
return
# Mature / growing stages: canopy + trunk come from the Sprite2D child.
# This _draw renders only the chop-progress notch overlaid on the trunk.
if chop_progress > 0:
if chop_progress > 0 and growth_stage == STAGE_MATURE:
var ratio := float(chop_progress) / float(CHOP_TICKS)
var notch_depth := ratio * 3.0
draw_line(