diff --git a/autoload/save_system.gd b/autoload/save_system.gd index b5dfe76..6d134ce 100644 --- a/autoload/save_system.gd +++ b/autoload/save_system.gd @@ -29,6 +29,7 @@ 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 _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") @@ -276,6 +277,7 @@ func _collect_entities() -> Array: var registries: Array = [ World.trees, World.rocks, + World.big_rock_nodes, World.items, World.build_queue, # ghost walls / floors / doors / grave_slots World.doors, @@ -316,6 +318,7 @@ const _SPAWN_PRIORITY: Dictionary = { &"tree": 0, &"rock": 0, &"big_rock": 0, + &"big_rock_node": 0, &"wall": 0, &"floor": 0, &"door": 1, @@ -351,6 +354,7 @@ func _register_factories() -> void: _factories[&"tree"] = _spawn_tree _factories[&"rock"] = _spawn_rock _factories[&"big_rock"] = _spawn_big_rock + _factories[&"big_rock_node"] = _spawn_big_rock_node _factories[&"item"] = _spawn_item _factories[&"wall"] = _spawn_wall _factories[&"floor"] = _spawn_floor @@ -377,8 +381,18 @@ func _register_factories() -> void: func _spawn_tree(world_scene: Node, d: Dictionary) -> void: var ent = _TREE_SCENE.instantiate() world_scene.add_child(ent) - ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) + # Pass growth_stage to setup() so _refresh_sprite() picks the right visual. + # Default 3 (STAGE_MATURE) so pre-growth-system saves load as mature trees. + var gs: int = int(d.get("growth_stage", 3)) + ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), gs) ent.chop_progress = int(d.get("chop_progress", 0)) + ent.growth_progress = int(d.get("growth_progress", 0)) + ent.chop_designated = bool(d.get("chop_designated", false)) + ent.pending_plant = bool(d.get("pending_plant", false)) + ent._plant_progress = int(d.get("plant_progress", 0)) + # Re-register as build site if planting is still in progress. + if ent.pending_plant: + World.register_build_site(ent) ent.queue_redraw() @@ -398,6 +412,14 @@ func _spawn_big_rock(world_scene: Node, d: Dictionary) -> void: ent.queue_redraw() +func _spawn_big_rock_node(world_scene: Node, d: Dictionary) -> void: + var ent = _BIG_ROCK_NODE_SCENE.instantiate() + world_scene.add_child(ent) + ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) + ent.is_quarry_site = bool(d.get("is_quarry_site", false)) + 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/autoload/strings.gd b/autoload/strings.gd index d49660d..a96fe0e 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -194,8 +194,15 @@ const TABLE: Dictionary = { &"tool.workbench_millstone": "Millstone", &"tool.workbench_hearth": "Hearth", &"tool.workbench_cremation_pyre": "Cremation Pyre", + &"tool.paint_quarry": "Build quarry", &"tool.stockpile_general": "Stockpile", &"tool.graveyard": "Graveyard", + &"tool.plant_tree": "Plant tree", + # Tree growth stage names (shown in inspect tooltip). + &"tree.stage.sapling": "Sapling", + &"tree.stage.young": "Young tree", + &"tree.stage.growing": "Growing tree", + &"tree.stage.mature": "Mature tree", &"ui.bill.mode_forever": "Forever", &"ui.bill.mode_count": "Do X times", &"ui.bill.mode_until_n": "Do until X", diff --git a/autoload/world.gd b/autoload/world.gd index ffa2b99..c85dc3c 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -19,6 +19,7 @@ var work_providers: Array = [] # from their _ready/_exit_tree. Phase 16 will add stable IDs and persistence wiring. var trees: Array = [] # Array of Tree var rocks: Array = [] # Array of Rock +var big_rock_nodes: Array = [] # Array of BigRockNode (permanent stone outcrops) var items: Array = [] # Array of Item (on-floor stacks) var stockpiles: Array = [] # Array of StorageDestination (StockpileZone for now; containers Phase 5) @@ -207,6 +208,24 @@ func unregister_rock(r) -> void: rocks.erase(r) +func register_big_rock_node(n) -> void: + if not big_rock_nodes.has(n): + big_rock_nodes.append(n) + + +func unregister_big_rock_node(n) -> void: + big_rock_nodes.erase(n) + + +## Returns the BigRockNode whose 2×2 footprint covers `tile`, or null. +## Used by `paint_quarry` designation to validate the build site. +func big_rock_node_at_tile(tile: Vector2i): + for n in big_rock_nodes: + if n.is_at(tile): + return n + return null + + func register_item(it) -> void: if items.has(it): return diff --git a/scenes/ai/crafting_provider.gd b/scenes/ai/crafting_provider.gd index dcab18d..fc59865 100644 --- a/scenes/ai/crafting_provider.gd +++ b/scenes/ai/crafting_provider.gd @@ -69,14 +69,24 @@ func find_best_for(pawn) -> Job: _emit_bill_blocked(b.recipe.label, &"skill_too_low", wb) continue - # Confirm a qualifying ingredient exists on the floor. - var src = _find_ingredient_item(b.recipe.ingredient_type) - if src == null: - _emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb) - continue + # If ingredient_count is 0, no ingredient is required; proceed directly. + # Otherwise, confirm a qualifying ingredient exists on the floor. + var src = null + if b.recipe.ingredient_count > 0: + src = _find_ingredient_item(b.recipe.ingredient_type) + if src == null: + _emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb) + continue + + # Score: total Manhattan travel distance. + # If no ingredient (count==0), distance is just pawn → workbench. + # Otherwise, distance is pawn → ingredient → workbench. + var d: int + if b.recipe.ingredient_count > 0: + d = _manhattan(pawn.tile, src.tile) + _manhattan(src.tile, wb.tile) + else: + d = _manhattan(pawn.tile, wb.tile) - # Score: total Manhattan travel distance pawn → ingredient → workbench. - var d: int = _manhattan(pawn.tile, src.tile) + _manhattan(src.tile, wb.tile) if d < best_dist: best_dist = d best_wb = wb @@ -87,16 +97,22 @@ func find_best_for(pawn) -> Job: if best_wb == null: return null - # Re-resolve the source item in case multiple bills tied on the same item. - var src_item = _find_ingredient_item(best_bill.recipe.ingredient_type) - if src_item == null: - return null + var src_item = null + # If ingredient_count > 0, re-resolve the source item in case multiple bills tied on the same item. + if best_bill.recipe.ingredient_count > 0: + src_item = _find_ingredient_item(best_bill.recipe.ingredient_type) + if src_item == null: + return null var j := Job.new() j.label = "Craft %s at %s" % [best_bill.recipe.label, best_wb.get("label_text") if best_wb.get("label_text") != null else "workbench"] j.target_node = best_wb - j.toils.append(Toil.walk_to(src_item.tile)) - j.toils.append(Toil.pickup()) + + # Only add ingredient-haul toils if ingredient is required. + if best_bill.recipe.ingredient_count > 0: + j.toils.append(Toil.walk_to(src_item.tile)) + j.toils.append(Toil.pickup()) + j.toils.append(Toil.walk_to(best_wb.tile)) j.toils.append(Toil.craft_at(best_wb.get_path(), best_bill_index)) return j diff --git a/scenes/ai/recipe.gd b/scenes/ai/recipe.gd index 768fa6a..5f7ebc8 100644 --- a/scenes/ai/recipe.gd +++ b/scenes/ai/recipe.gd @@ -19,6 +19,9 @@ var id: StringName = &"" ## Item type consumed by this recipe (single-ingredient for Phase 6). var ingredient_type: StringName = &"" +## Count of ingredient_type required by this recipe. 0 = no ingredient (work only). +var ingredient_count: int = 0 + ## Phase 14 — optional secondary ingredient. Empty string = no secondary. ## CraftingProvider Phase 14 follow-up: enforce pickup of ingredient2 before ## assigning a pawn to this bill (currently stub — only ingredient_type enforced). @@ -59,6 +62,7 @@ func to_dict() -> Dictionary: return { "id": String(id), "ingredient_type": String(ingredient_type), + "ingredient_count": ingredient_count, "ingredient2_type": String(ingredient2_type), "ingredient2_count": ingredient2_count, "output_type": String(output_type), @@ -73,6 +77,7 @@ static func from_dict(d: Dictionary) -> Recipe: var r := Recipe.new() r.id = StringName(d.get("id", "")) r.ingredient_type = StringName(d.get("ingredient_type", "")) + r.ingredient_count = int(d.get("ingredient_count", 0)) r.ingredient2_type = StringName(d.get("ingredient2_type", "")) r.ingredient2_count = int(d.get("ingredient2_count", 0)) r.output_type = StringName(d.get("output_type", "")) diff --git a/scenes/ai/recipe_catalog.gd b/scenes/ai/recipe_catalog.gd index c8f9e22..24d84e3 100644 --- a/scenes/ai/recipe_catalog.gd +++ b/scenes/ai/recipe_catalog.gd @@ -111,6 +111,21 @@ static func cremate_corpse() -> Recipe: return r +## Quarry workbench — no input, produces 1 stone per work cycle. Used by the +## QuarryWorkbench placed on a BigRockNode for renewable stone supply. +static func quarry_stone() -> Recipe: + var r := Recipe.new() + r.id = &"quarry_stone" + r.label = "Quarry stone" + r.ingredient_type = &"" # no input + r.ingredient_count = 0 + r.output_type = Item.TYPE_STONE + r.work_ticks = 300 + r.required_skill = &"manual_labor" + r.skill_threshold = 0 + return r + + ## Returns one fresh instance of every recipe in the catalog. Used by UI ## recipe-pickers to enumerate available bills; callers filter by ## `recipe.required_skill` against the workbench's `accepted_skill`. @@ -122,4 +137,5 @@ static func all() -> Array[Recipe]: bread(), meal_from_vegetables(), cremate_corpse(), + quarry_stone(), ] diff --git a/scenes/entities/big_rock_node.gd b/scenes/entities/big_rock_node.gd new file mode 100644 index 0000000..3ff6da3 --- /dev/null +++ b/scenes/entities/big_rock_node.gd @@ -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) diff --git a/scenes/entities/big_rock_node.gd.uid b/scenes/entities/big_rock_node.gd.uid new file mode 100644 index 0000000..af363c5 --- /dev/null +++ b/scenes/entities/big_rock_node.gd.uid @@ -0,0 +1 @@ +uid://bhn0lknhgn1od diff --git a/scenes/entities/big_rock_node.tscn b/scenes/entities/big_rock_node.tscn new file mode 100644 index 0000000..f93f19f --- /dev/null +++ b/scenes/entities/big_rock_node.tscn @@ -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") diff --git a/scenes/entities/quarry_workbench.gd b/scenes/entities/quarry_workbench.gd new file mode 100644 index 0000000..91ef0f7 --- /dev/null +++ b/scenes/entities/quarry_workbench.gd @@ -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) diff --git a/scenes/entities/quarry_workbench.gd.uid b/scenes/entities/quarry_workbench.gd.uid new file mode 100644 index 0000000..a3ef967 --- /dev/null +++ b/scenes/entities/quarry_workbench.gd.uid @@ -0,0 +1 @@ +uid://bpbcwr4dh1736 diff --git a/scenes/entities/quarry_workbench.tscn b/scenes/entities/quarry_workbench.tscn new file mode 100644 index 0000000..ec28b7a --- /dev/null +++ b/scenes/entities/quarry_workbench.tscn @@ -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") diff --git a/scenes/entities/tree.gd b/scenes/entities/tree.gd index 50b2a28..a77d505 100644 --- a/scenes/entities/tree.gd +++ b/scenes/entities/tree.gd @@ -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( diff --git a/scenes/ui/build_drawer.gd b/scenes/ui/build_drawer.gd index 07b9ae0..ea85b1b 100644 --- a/scenes/ui/build_drawer.gd +++ b/scenes/ui/build_drawer.gd @@ -180,10 +180,11 @@ func _build_designate_tab() -> Control: var flow := _make_flow_grid() box.add_child(flow) - _add_tool_btn(flow, Strings.t(&"tool.chop"), &"chop", func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop"))) - _add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine"))) - _add_tool_btn(flow, Strings.t(&"tool.dig_grave"),&"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave"))) - _add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof"))) + _add_tool_btn(flow, Strings.t(&"tool.chop"), &"chop", func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop"))) + _add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine"))) + _add_tool_btn(flow, Strings.t(&"tool.dig_grave"), &"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave"))) + _add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof"))) + _add_tool_btn(flow, Strings.t(&"tool.plant_tree"), &"plant_tree", func() -> void: _activate(&"plant_tree", &"", Strings.t(&"tool.plant_tree"))) return box @@ -237,6 +238,12 @@ func _build_build_tab() -> Control: &"build_workbench_cremation_pyre", func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre"))) + # Quarry — must be painted on a stone outcrop (BigRockNode); world.gd + # rejects placements on plain ground. + _add_tool_btn(flow, Strings.t(&"tool.paint_quarry"), + &"paint_quarry", + func() -> void: _activate(&"paint_quarry", &"", Strings.t(&"tool.paint_quarry"))) + return box diff --git a/scenes/ui/build_drawer_thumb.gd b/scenes/ui/build_drawer_thumb.gd index 22ec4d5..d6c07d9 100644 --- a/scenes/ui/build_drawer_thumb.gd +++ b/scenes/ui/build_drawer_thumb.gd @@ -332,6 +332,46 @@ func _draw() -> void: draw_line(Vector2(c.x, c.y + 6), Vector2(c.x, c.y - 6), arrow, 2.0) draw_line(Vector2(c.x, c.y - 6), Vector2(c.x - 4, c.y - 2), arrow, 2.0) draw_line(Vector2(c.x, c.y - 6), Vector2(c.x + 4, c.y - 2), arrow, 2.0) + &"paint_quarry": + # Stone block on wood frame + small pile of cut stones beside. + var frame := Color(0.42, 0.26, 0.12) + var frame_top := Color(0.55, 0.36, 0.18) + var stone := Color(0.65, 0.63, 0.60) + var stone_hi := Color(0.82, 0.80, 0.76) + var stone_dark := Color(0.32, 0.30, 0.28) + var chisel_steel := Color(0.78, 0.80, 0.85) + # Wood frame base. + draw_rect(Rect2(c.x - 14, c.y + 6, 28, 8), frame) + draw_rect(Rect2(c.x - 14, c.y + 3, 28, 3), frame_top) + # Large stone block on top. + draw_rect(Rect2(c.x - 10, c.y - 8, 14, 11), stone) + draw_rect(Rect2(c.x - 10, c.y - 8, 14, 3), stone_hi) + # Chisel marks (3 short dark lines on the stone face). + draw_line(Vector2(c.x - 6, c.y - 3), Vector2(c.x - 4, c.y - 3), stone_dark, 1.0) + draw_line(Vector2(c.x - 2, c.y - 3), Vector2(c.x, c.y - 3), stone_dark, 1.0) + draw_line(Vector2(c.x + 2, c.y - 3), Vector2(c.x - 0, c.y - 3), stone_dark, 1.0) + # Pile of cut stones beside the block. + draw_rect(Rect2(c.x + 6, c.y, 4, 3), stone) + draw_rect(Rect2(c.x + 8, c.y - 3, 3, 3), stone_hi) + # Chisel tool on top of block. + draw_rect(Rect2(c.x - 4, c.y - 12, 2, 4), chisel_steel) + draw_rect(Rect2(c.x - 5, c.y - 13, 4, 2), frame_top) + &"plant_tree": + # Green sapling sprout rising from dirt — signals manual tree planting. + var dirt := Color(0.45, 0.32, 0.18) + var dirt_hi := Color(0.62, 0.46, 0.28) + var stem := Color(0.35, 0.22, 0.10) + var leaf_a := Color(0.30, 0.65, 0.20) + var leaf_b := Color(0.25, 0.55, 0.18) + # Dirt base rectangle. + draw_rect(Rect2(c.x - 14, c.y + 2, 28, 14), dirt) + draw_line(Vector2(c.x - 14, c.y + 5), Vector2(c.x + 14, c.y + 5), dirt_hi, 1.0) + # Stem. + draw_line(Vector2(c.x, c.y + 2), Vector2(c.x, c.y - 8), stem, 2.0) + # Three leaf dots at top of stem. + draw_circle(Vector2(c.x, c.y - 10), 4.0, leaf_a) + draw_circle(Vector2(c.x - 5, c.y - 6), 3.0, leaf_b) + draw_circle(Vector2(c.x + 5, c.y - 5), 3.0, leaf_b) _: # Unknown tool — small grey placeholder. draw_rect(Rect2(c.x - 12, c.y - 12, 24, 24), Color(0.50, 0.50, 0.50)) diff --git a/scenes/ui/build_drawer_thumb.gd.uid b/scenes/ui/build_drawer_thumb.gd.uid new file mode 100644 index 0000000..6d94197 --- /dev/null +++ b/scenes/ui/build_drawer_thumb.gd.uid @@ -0,0 +1 @@ +uid://w802akkpbc6l diff --git a/scenes/ui/inspect_tooltip.gd b/scenes/ui/inspect_tooltip.gd index c3a716d..da793cd 100644 --- a/scenes/ui/inspect_tooltip.gd +++ b/scenes/ui/inspect_tooltip.gd @@ -264,12 +264,36 @@ func _describe_wolf(w) -> String: func _describe_tree(t) -> String: + # Growth stage label. + var stage: int = int(t.get("growth_stage")) if "growth_stage" in t else 3 + var stage_key_map: Array[StringName] = [ + &"tree.stage.sapling", + &"tree.stage.young", + &"tree.stage.growing", + &"tree.stage.mature", + ] + var stage_label: String = Strings.t(stage_key_map[clamp(stage, 0, 3)]) + + # Pending-plant ghost indicator. + var pending: bool = bool(t.get("pending_plant")) if "pending_plant" in t else false + if pending: + return "[b]%s[/b]\n[color=#aaa]awaiting pawn[/color]" % stage_label + + # Growth progress for sub-mature trees. + var is_mature: bool = (stage >= 3) + if not is_mature: + var progress: int = int(t.get("growth_progress")) if "growth_progress" in t else 0 + var stage_ticks: int = int(t.get("STAGE_TICKS")) if "STAGE_TICKS" in t else 1 + var pct_grow: int = int(100.0 * float(progress) / float(max(stage_ticks, 1))) + return "[b]%s[/b]\nGrowing %d%%" % [stage_label, pct_grow] + + # Mature tree — show chop progress if any. var pct: int = int(100.0 * float(t.chop_progress) / float(t.CHOP_TICKS)) var designated: bool = bool(t.get("chop_designated")) var tag := " · [color=#fc6]marked[/color]" if designated else "" if pct > 0: - return "[b]Tree[/b]\nChop %d%%%s" % [pct, tag] - return "[b]Tree[/b]%s" % tag + return "[b]%s[/b]\nChop %d%%%s" % [stage_label, pct, tag] + return "[b]%s[/b]%s" % [stage_label, tag] func _describe_rock(r) -> String: diff --git a/scenes/ui/medieval_theme.gd.uid b/scenes/ui/medieval_theme.gd.uid new file mode 100644 index 0000000..cb1f529 --- /dev/null +++ b/scenes/ui/medieval_theme.gd.uid @@ -0,0 +1 @@ +uid://c26o807ldrrrx diff --git a/scenes/world/designation.gd b/scenes/world/designation.gd index 5958268..b22ba5d 100644 --- a/scenes/world/designation.gd +++ b/scenes/world/designation.gd @@ -40,6 +40,10 @@ const TOOL_BUILD_WORKBENCH_HEARTH: StringName = &"build_workbench_hearth" const TOOL_BUILD_WORKBENCH_CREMATION_PYRE: StringName = &"build_workbench_cremation_pyre" # Phase 17 — Stockpile tab. const TOOL_PAINT_STOCKPILE: StringName = &"paint_stockpile" +# Tree planting — ghost sapling that ConstructionProvider will fulfil. +const TOOL_PLANT_TREE: StringName = &"plant_tree" +# Quarry — must paint on a BigRockNode tile; spawns a QuarryWorkbench ghost. +const TOOL_PAINT_QUARRY: StringName = &"paint_quarry" # ── tool → material override ───────────────────────────────────────────────── # For build_wall and build_floor the tool is shared but the material differs. @@ -73,6 +77,8 @@ const _ATLAS_BY_TOOL: Dictionary = { &"build_workbench_hearth": Vector2i(1, 0), &"build_workbench_cremation_pyre":Vector2i(3, 0), &"paint_stockpile": Vector2i(0, 0), + &"plant_tree": Vector2i(0, 0), # grass ghost — tinted green + &"paint_quarry": Vector2i(2, 0), # stone-grey ghost } # Placeholder source ID — mirrors World.PLACEHOLDER_SOURCE_ID. @@ -120,6 +126,8 @@ func set_active_tool(tool: StringName) -> void: TOOL_BUILD_WORKBENCH_MILLSTONE, TOOL_BUILD_WORKBENCH_HEARTH, TOOL_BUILD_WORKBENCH_CREMATION_PYRE, TOOL_PAINT_STOCKPILE, + TOOL_PLANT_TREE, + TOOL_PAINT_QUARRY, ], "Designation.set_active_tool: unknown tool '%s'" % tool ) diff --git a/scenes/world/world.gd b/scenes/world/world.gd index cb40443..d861ba9 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -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.