## 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") ## ElvGames Grasslands tree pack — 4 variants laid out left-to-right. ## Each variant is 64×80 px; trunk base sits in the bottom ~10 rows. We anchor ## the sprite center 32 px above tile origin so the trunk bottom lands at the ## tile's bottom edge and the canopy rises into the cells above. const _TREE_TEX: Texture2D = preload("res://art/sprites/FG_Tree_Spring.png") const _TREE_VARIANT_W: int = 64 const _TREE_VARIANT_H: int = 80 const _TREE_VARIANT_COUNT: int = 4 # ── lifecycle ───────────────────────────────────────────────────────────────── func _ready() -> void: position = _tile_to_world(tile) _build_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 4 ElvGames tree variants. ## 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: var sprite := Sprite2D.new() sprite.name = "Sprite" sprite.texture = _TREE_TEX sprite.region_enabled = true var variant: int = (tile.x * 31 + tile.y * 17) % _TREE_VARIANT_COUNT sprite.region_rect = Rect2(variant * _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) # Render behind pawns/items that are at higher z_index; trees live at z=0. sprite.z_index = 0 add_child(sprite) 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: # Canopy + trunk now come from the Sprite2D child (see _build_sprite). # This _draw renders only the chop-progress notch overlaid on the trunk. 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 )