## 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) ## Growth-stage atlas — 128×32, 8 columns × 1 row of 16×32 cells, left-to-right ## progressively larger trees. Used for sub-mature stages so the player sees ## a proper sapling → small tree silhouette change instead of a scaled-down ## mature canopy. Stage MATURE keeps using _TREE_TEXES above (the full 64×80 ## canopy). const _STAGE_TEX: Texture2D = preload("res://art/sprites/FG_Tree_Stages.png") const _STAGE_CELL_W: int = 16 const _STAGE_CELL_H: int = 32 ## Atlas column to use per growth_stage. Stage 3 (MATURE) is unused here. const _STAGE_COLS: Array[int] = [0, 1, 3, -1] # ── 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. ## Stages 0-2 use _STAGE_TEX (a dedicated growth-stage atlas with progressively ## larger trees per cell). Stage 3 (Mature) uses the seasonal full-canopy ## atlases. 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() var sprite := Sprite2D.new() sprite.name = "Sprite" sprite.region_enabled = true sprite.centered = true sprite.z_index = 0 if growth_stage < STAGE_MATURE: # Sub-mature: 16×32 cell from _STAGE_TEX. Bottom edge at tile bottom # (+8); sprite half-height 16 → centre offset y = 8 - 16 = -8. var col: int = _STAGE_COLS[growth_stage] sprite.texture = _STAGE_TEX sprite.region_rect = Rect2(col * _STAGE_CELL_W, 0, _STAGE_CELL_W, _STAGE_CELL_H) sprite.offset = Vector2(0, -8) else: # Mature: full 64×80 seasonal canopy. Bottom at +8 → centre at -32. var hash_seed: int = tile.x * 31 + tile.y * 17 var silhouette: int = hash_seed % _TREE_SILHOUETTES var season: int = ((hash_seed / _TREE_SILHOUETTES) + tile.x * 7 + tile.y * 11) % _TREE_TEXES.size() sprite.texture = _TREE_TEXES[season] sprite.region_rect = Rect2(silhouette * _TREE_VARIANT_W, 0, _TREE_VARIANT_W, _TREE_VARIANT_H) sprite.offset = Vector2(0, -32) 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: # Ghost tint for pending-plant saplings — apply via modulate on the sprite # child instead of drawing extra overlay shapes here. if pending_plant and growth_stage == STAGE_SAPLING: var s := get_node_or_null("Sprite") if s != null: s.modulate = Color(1, 1, 1, 0.55) # 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 )