## Tree entity — choppable by a pawn with a Chop job. Drops wood Item nodes ## when felled. ## ## 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. ## ## 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 # ── 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 # Preloaded scene for spawned wood items. const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") # ── lifecycle ───────────────────────────────────────────────────────────────── func _ready() -> void: position = _tile_to_world(tile) World.register_tree(self) func _exit_tree() -> void: World.unregister_tree(self) # ── public API ──────────────────────────────────────────────────────────────── ## One-shot initialiser. Call after add_child() so _ready() already fired. func setup(start_tile: Vector2i) -> void: tile = start_tile chop_progress = 0 position = _tile_to_world(tile) queue_redraw() Audit.log("tree", "spawned at %s" % tile) ## True when the tree hasn't been fully chopped yet. func is_choppable() -> bool: return chop_progress < CHOP_TICKS ## 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]) queue_free() # ── save / load ─────────────────────────────────────────────────────────────── func to_dict() -> Dictionary: return { "class_id": &"tree", "tile_x": tile.x, "tile_y": tile.y, "chop_progress": chop_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)), } # ── render ──────────────────────────────────────────────────────────────────── func _draw() -> void: # Brown trunk: small filled rect at centre-bottom (~4 wide × 6 tall). var trunk_color := Color(0.45, 0.28, 0.12) draw_rect(Rect2(Vector2(-2.0, 1.0), Vector2(4.0, 6.0)), trunk_color) # Green canopy: large filled circle centered near the top. var canopy_color := Color(0.22, 0.60, 0.18) draw_circle(Vector2(0.0, -3.0), 7.0, canopy_color) # Canopy outline. draw_arc(Vector2(0.0, -3.0), 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.4), 1.0) # Chop-progress wedge: a dark angled line on the trunk when partially chopped. if chop_progress > 0: 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 )