rimlike/scenes/entities/tree.gd
megaproxy d98d2c2425 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.
2026-05-16 16:36:16 +01:00

341 lines
14 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.

## Tree entity — choppable by a pawn with a Chop job. Drops wood Item nodes
## 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.
class_name HarvestableTree extends Node2D
## NOTE: class_name is HarvestableTree because Godot 4 ships a built-in `Tree`
## Control node — using "Tree" would shadow that. Filename / scene name stay
## as `tree` because the game-side concept is still just "tree".
const TILE_SIZE_PX: int = 16
## Sim ticks to fell a tree at 1× speed (80 ticks = ~4 sim seconds at 20 Hz).
const CHOP_TICKS: int = 80
## Number of separate wood Item nodes dropped on fell.
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
## 0..CHOP_TICKS. Advanced by on_chop_tick(); tree is felled when equal to CHOP_TICKS.
var chop_progress: int = 0
## True once a player has painted a chop designation on this tree. ChopProvider
## 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")
## ElvGames Grasslands tree pack — 4 silhouettes laid out left-to-right (64×80
## each). Trunk base sits in the bottom ~10 rows; we anchor the sprite centre
## 32 px above tile origin so the trunk bottom lands at the tile's bottom edge
## and the canopy rises into the cells above.
##
## Three season palettes (Spring / Summer / Fall) give 12 visual variants from
## the same silhouette set. Winter is omitted — snowy trees look out of place
## in the current biome. When a season-cycle system lands later, swap the
## active texture by season globally instead of per-tree.
const _TREE_TEXES: Array[Texture2D] = [
preload("res://art/sprites/FG_Tree_Spring.png"),
preload("res://art/sprites/FG_Tree_Summer.png"),
preload("res://art/sprites/FG_Tree_Fall.png"),
]
const _TREE_VARIANT_W: int = 64
const _TREE_VARIANT_H: int = 80
const _TREE_SILHOUETTES: int = 4 # silhouettes per atlas (columns)
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
position = _tile_to_world(tile)
_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)
## 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
var silhouette: int = hash_seed % _TREE_SILHOUETTES
# Independent hash mix for season so neighbouring tiles don't all match.
var season: int = ((hash_seed / _TREE_SILHOUETTES) + tile.x * 7 + tile.y * 11) % _TREE_TEXES.size()
sprite.texture = _TREE_TEXES[season]
sprite.region_enabled = true
sprite.region_rect = Rect2(silhouette * _TREE_VARIANT_W, 0, _TREE_VARIANT_W, _TREE_VARIANT_H)
sprite.centered = true
# Lift the sprite up so its bottom edge sits at the tile's bottom row.
# 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:
World.unregister_tree(self)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() already fired.
## 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 (stage=%d)" % [tile, growth_stage])
## 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 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
## works this tree. Advances chop_progress and fells the tree when complete.
func on_chop_tick() -> void:
if not is_choppable():
return
chop_progress += 1
queue_redraw()
if chop_progress >= CHOP_TICKS:
fell()
## Drop wood Items and free this node. Called by on_chop_tick() automatically,
## but also accessible for scripted felling (debug, storyteller events).
func fell() -> void:
var drop_tiles := _pick_drop_tiles()
var drops_count := 0
for drop_tile in drop_tiles:
var item: Item = ITEM_SCENE.instantiate()
get_parent().add_child(item)
item.setup(Item.TYPE_WOOD, STACK_SIZE_PER_DROP, drop_tile)
drops_count += 1
Audit.log("tree", "felled at %s; %d wood drops" % [tile, drops_count])
if Audio != null:
Audio.play_sfx(&"tree_fell")
World.clear_designation_at(tile)
queue_free()
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"tree",
"tile_x": tile.x,
"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,
}
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"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:
# 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 and growth_stage == STAGE_MATURE:
var ratio := float(chop_progress) / float(CHOP_TICKS)
var notch_depth := ratio * 3.0
draw_line(
Vector2(-2.0, 2.0 + notch_depth),
Vector2(2.0, 2.0),
Color(0.15, 0.08, 0.02, 0.9),
1.5
)
# ── helpers ───────────────────────────────────────────────────────────────────
## Returns up to WOOD_DROPS_ON_FELL tile positions for wood drops.
## Prefers the tree's own tile then walkable 4-neighbours; falls back to the
## tree tile for any remaining drops when neighbours are scarce.
func _pick_drop_tiles() -> Array[Vector2i]:
var chosen: Array[Vector2i] = []
# First drop always goes on the tree's tile itself.
chosen.append(tile)
# Remaining drops prefer walkable neighbours.
var offsets: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
for offset in offsets:
if chosen.size() >= WOOD_DROPS_ON_FELL:
break
var candidate: Vector2i = tile + offset
if World.pathfinder != null and World.pathfinder.is_walkable(candidate):
chosen.append(candidate)
# Fill any remaining slots with the tree tile (all 3 land there if boxed in).
while chosen.size() < WOOD_DROPS_ON_FELL:
chosen.append(tile)
return chosen
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)