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

@ -32,6 +32,8 @@ 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 BIG_ROCK_NODE_SCENE: PackedScene = preload("res://scenes/entities/big_rock_node.tscn")
const QUARRY_WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/quarry_workbench.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")
@ -60,10 +62,15 @@ const SAMPLE_PAWNS: Array[Dictionary] = [
]
# Phase 4 — sample harvestables. Trees clustered east, rocks south-east.
# Mix of 8 mature + 4 saplings so players see growth in action from day 1.
const SAMPLE_TREES: Array[Vector2i] = [
Vector2i(58, 30), Vector2i(60, 31), Vector2i(62, 30),
Vector2i(61, 33), Vector2i(63, 34), Vector2i(59, 35),
Vector2i(57, 28), Vector2i(64, 32), # 2 more mature
Vector2i(56, 36), Vector2i(65, 29), # 2 more mature
]
# The first 4 in SAMPLE_TREES_SAPLING are planted as saplings (stage 0).
const SAMPLE_TREES_SAPLING_COUNT: int = 4
const SAMPLE_ROCKS: Array[Vector2i] = [
Vector2i(60, 60), Vector2i(62, 60), Vector2i(63, 62), Vector2i(58, 62),
]
@ -75,9 +82,28 @@ const SAMPLE_BIG_ROCKS: Array[Vector2i] = [
Vector2i(56, 64),
]
# Permanent stone outcrops (BigRockNode). Scattered far from the cabin at
# (44, 22)..(51, 28) so the player has to scout / plan transport routes.
# Each is a 2×2 footprint that never depletes; player paints `paint_quarry`
# to build a QuarryWorkbench on it.
const SAMPLE_BIG_ROCK_NODES: Array[Vector2i] = [
Vector2i(12, 30), # west, near map edge
Vector2i(68, 12), # north-east corner area
Vector2i(70, 60), # south-east corner
]
# HaulingProvider re-flow cadence — every 5 sim seconds at 1× (100 ticks).
const HAUL_SWEEP_INTERVAL_TICKS: int = 100
# WildGrowth — spontaneous sapling spawning on eligible grass tiles.
# 1200 ticks = 1 in-game hour at 20 Hz (20 ticks/s × 60 s/min = 1200 ticks/min,
# but 1 in-game minute = 20 ticks at 1× so 1 hour = 1200 ticks at 1×).
const WILD_GROWTH_INTERVAL: int = 1200
const WILD_GROWTH_SPAWN_PROBABILITY: float = 0.30
const MAP_TREE_LIMIT: int = 60
# Rejection-sample attempts before giving up for this tick.
const WILD_GROWTH_MAX_ATTEMPTS: int = 10
# Phase 11 — global darkness tint. Day = white, night = deep cool blue.
# Driven by Clock.darkness_factor() (0..1) each sim tick.
const NIGHT_TINT: Color = Color(0.20, 0.22, 0.40, 1.0)
@ -419,11 +445,15 @@ func _spawn_sample_harvestables() -> void:
# Boot seed auto-designates so the production-chain demo runs end-to-end
# without requiring a player to paint chop/mine first. Real player-painted
# trees / rocks still gate on chop_designated / mine_designated (Rimworld parity).
for t_tile in SAMPLE_TREES:
for i in SAMPLE_TREES.size():
var tree = TREE_SCENE.instantiate()
add_child(tree)
tree.setup(t_tile)
tree.chop_designated = true
# First SAMPLE_TREES_SAPLING_COUNT trees spawn as saplings (stage 0)
# so the player can observe growth from day 1. The rest are mature.
var stage: int = HarvestableTree.STAGE_SAPLING if i < SAMPLE_TREES_SAPLING_COUNT else HarvestableTree.STAGE_MATURE
tree.setup(SAMPLE_TREES[i], stage)
if stage == HarvestableTree.STAGE_MATURE:
tree.chop_designated = true
for r_tile in SAMPLE_ROCKS:
var rock = ROCK_SCENE.instantiate()
add_child(rock)
@ -434,8 +464,13 @@ func _spawn_sample_harvestables() -> void:
add_child(big)
big.setup(br_origin)
big.mine_designated = true
Audit.log("world", "spawned %d trees + %d rocks + %d big rocks" % [
SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size()
# Permanent stone outcrops (never deplete; Quarry workbench built on them).
for node_origin in SAMPLE_BIG_ROCK_NODES:
var node = BIG_ROCK_NODE_SCENE.instantiate()
add_child(node)
node.setup(node_origin)
Audit.log("world", "spawned %d trees + %d rocks + %d big rocks + %d stone outcrops" % [
SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size(), SAMPLE_BIG_ROCK_NODES.size()
])
@ -747,6 +782,38 @@ func _on_designation_added(cell: Vector2i, tool: StringName) -> void:
sz.accepted_types = [] as Array[StringName] # wildcard
sz.queue_redraw()
entity = sz
# Quarry — must be placed on a BigRockNode tile. Spawns a
# QuarryWorkbench ghost (auto-FOREVER bill on completion).
&"paint_quarry":
var node = World.big_rock_node_at_tile(cell)
if node == null:
Audit.log("world", "paint_quarry: %s not on a stone outcrop — rejected" % cell)
return
# Refuse if this node already has a quarry built/queued.
for ws in World.workbenches:
if "label_text" in ws and ws.label_text == "Quarry" and node.is_at(ws.tile):
Audit.log("world", "paint_quarry: outcrop at %s already has a quarry" % cell)
return
var quarry = QUARRY_WORKBENCH_SCENE.instantiate()
add_child(quarry)
quarry.setup(cell)
entity = quarry
# Tree planting — spawn a ghost sapling with pending_plant=true so
# ConstructionProvider can queue a build job (1 wood, 30 ticks of work).
# The ghost renders as a translucent sprout until the pawn completes it.
&"plant_tree":
# Reject if tile is already occupied by a tree.
for existing_t in World.trees:
if is_instance_valid(existing_t) and existing_t.tile == cell:
Audit.log("world", "plant_tree: tile %s already has a tree — skipped" % cell)
return
var pt = TREE_SCENE.instantiate()
add_child(pt)
pt.setup(cell, HarvestableTree.STAGE_SAPLING)
pt.pending_plant = true
# Register as a build site so ConstructionProvider can assign a pawn.
World.register_build_site(pt)
entity = pt
_:
Audit.log("world", "unknown designation tool: %s" % tool)
return
@ -839,11 +906,88 @@ func _on_designation_cleared(cell: Vector2i) -> void:
func _on_sim_tick_world_sweep(tick_n: int) -> void:
_update_dark_overlay()
# Tree growth — tick every registered tree; mature + pending-plant trees are
# no-ops inside on_sim_tick(), so iterating all is safe.
for tree in World.trees:
if is_instance_valid(tree):
tree.on_sim_tick()
# WildGrowth — attempt to plant a new sapling once per WILD_GROWTH_INTERVAL.
if tick_n % WILD_GROWTH_INTERVAL == 0:
_try_wild_growth()
if tick_n % HAUL_SWEEP_INTERVAL_TICKS != 0:
return
hauling_provider.sweep_for_better_destinations()
## Attempt to spawn one wild sapling on a random eligible grass tile.
## Eligibility: walkable + grass terrain + no entity overlap + < 2 tree neighbours.
## Gives up after WILD_GROWTH_MAX_ATTEMPTS rejected tries to avoid lag spikes.
func _try_wild_growth() -> void:
if World.trees.size() >= MAP_TREE_LIMIT:
return
if randf() >= WILD_GROWTH_SPAWN_PROBABILITY:
return
var rng := RandomNumberGenerator.new()
rng.seed = Sim.tick + 9973 # stable within a tick, different each call cycle
for _attempt in WILD_GROWTH_MAX_ATTEMPTS:
var candidate := Vector2i(
rng.randi_range(0, MAP_SIZE_TILES.x - 1),
rng.randi_range(0, MAP_SIZE_TILES.y - 1)
)
if not _wild_growth_tile_eligible(candidate):
continue
var tree = TREE_SCENE.instantiate()
add_child(tree)
tree.setup(candidate, HarvestableTree.STAGE_SAPLING)
Audit.log("world", "wild growth: sapling at %s (total %d)" % [candidate, World.trees.size()])
return
## Returns true if `tile` is a valid WildGrowth spawn location.
## Checks: walkable, grass terrain (source 0, atlas (0,0)), no entity at tile,
## and fewer than 2 trees among the 4 cardinal neighbours.
func _wild_growth_tile_eligible(tile: Vector2i) -> bool:
# Bounds check (pathfinder bounds == map bounds).
if pathfinder == null:
return false
if not pathfinder.is_walkable(tile):
return false
# Grass terrain only — atlas (0,0) on source 0 = TILE_GRASS.
if terrain_layer == null:
return false
var src_id: int = terrain_layer.get_cell_source_id(tile)
var atlas: Vector2i = terrain_layer.get_cell_atlas_coords(tile)
if src_id != PLACEHOLDER_SOURCE_ID or atlas != TILE_GRASS:
return false
# No existing tree, rock, or item at this tile.
for t in World.trees:
if is_instance_valid(t) and t.tile == tile:
return false
for r in World.rocks:
if is_instance_valid(r):
if r.has_method("footprint_tiles"):
if tile in r.footprint_tiles():
return false
elif r.tile == tile:
return false
for it in World.items:
if is_instance_valid(it) and it.tile == tile:
return false
# No clumping: reject if 2+ cardinal neighbours already have a tree.
var neighbour_trees: int = 0
var offsets: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
for offset in offsets:
var nb: Vector2i = tile + offset
for t in World.trees:
if is_instance_valid(t) and t.tile == nb:
neighbour_trees += 1
break
return neighbour_trees < 2
# Phase 11 — interpolate CanvasModulate between DAY_TINT and NIGHT_TINT based
# on Clock.darkness_factor() (0 = full day, 1 = full night).
# Called every sim tick; Color.lerp is a handful of float ops — negligible cost.