class_name CremationPyre extends Workbench ## Phase 14 — Cremation pyre furniture. ## ## Subclasses Workbench to reuse the build + bill machinery. The pyre uses a ## special `cremate_corpse` recipe that consumes a Corpse entity (hauled as ## Item.TYPE_CORPSE by the crafting toil's pickup-ingredient step) and 5 wood, ## producing 1 ash item dropped adjacent on completion. ## ## Variant appearance: label_text = "Pyre" triggers the _draw_pyre() branch ## (overrides _draw() in Workbench). accepted_skill = "manual_labor" (any ## laborer can cremate; no Crafting threshold needed). ## ## EventBus.corpse_cremated(corpse, pyre) is emitted on craft completion. ## The Corpse node is queue_free'd after the signal fires. ## ## Stub gap (documented): ## The existing single-ingredient Recipe supports one ingredient_type. ## cremate_corpse needs 1 corpse + 5 wood. This is expressed via ## ingredient2_type / ingredient2_count extended fields on Recipe ## (added in Phase 14 — see recipe.gd). CraftingProvider's ingredient ## pickup step must be updated to check ingredient2 before assigning a ## pawn; until that update lands, the pyre will work as a single-ingredient ## recipe (corpse-only) and the 5-wood secondary requirement is NOT enforced ## at runtime. This is the documented Phase 14 Agent C stub gap. ## ## World registration: shares Workbench.register_workbench / ## unregister_workbench (called by the parent's _ready / _exit_tree). ## Override label so Workbench._complete() logs "Pyre built at …" ## and _draw() dispatches to _draw_pyre(). func _init() -> void: label_text = "Pyre" accepted_skill = &"manual_labor" func _ready() -> void: label_text = "Pyre" accepted_skill = &"manual_labor" super._ready() # Auto-populate a default FOREVER bill for the cremate_corpse recipe so # the pyre is immediately usable once built. var b := Bill.new() b.recipe = RecipeCatalog.cremate_corpse() b.mode = Bill.Mode.FOREVER bills.append(b) Audit.log("pyre", "CremationPyre ready at %s — bill added" % tile) # ── craft-cycle hook override ───────────────────────────────────────────────── ## Called by JobRunner._tick_craft when a cremate_corpse craft completes. ## Drops an ash item adjacent to the pyre, emits corpse_cremated, and frees ## the Corpse entity that was consumed as an ingredient. ## ## The `pawn` argument is the pawn who completed the craft; used only for ## the "cremated_friend" thought signal path in EventBus listeners. ## ## NOTE: The standard Workbench.on_craft_complete() is called by JobRunner ## before this override runs (it clears current_bill / current_work_progress). ## We override at the pyre level to inject the ash-drop + signal + corpse-free ## after the base logic. JobRunner calls on_craft_complete() directly, so we ## shadow it here. func on_craft_complete() -> void: # Base Workbench cleanup (clears current_bill, resets work progress). super.on_craft_complete() # Find the corpse that was hauled as ingredient. Prefer the transient # reference set by JobRunner (reliable regardless of corpse tile position); # fall back to proximity scan only if the field is somehow unset. var consumed_corpse = _last_consumed_ingredient if _last_consumed_ingredient != null \ and is_instance_valid(_last_consumed_ingredient) \ else _find_consumed_corpse() # Drop 1 ash item on an adjacent walkable tile (same pattern as # Tree.fell() / Workbench output drops elsewhere in Phase 6). var drop_tile := _pick_adjacent_drop_tile() if drop_tile != Vector2i(-1, -1): var ash := load("res://scenes/entities/item.tscn").instantiate() get_parent().add_child(ash) ash.setup(Item.TYPE_ASH, 1, drop_tile) Audit.log("pyre", "Pyre at %s: cremation complete — ash dropped at %s" % [tile, drop_tile]) else: Audit.log("pyre", "Pyre at %s: cremation complete — no adjacent tile for ash drop" % tile) # Emit and clean up corpse. if consumed_corpse != null: Audit.log("pyre", "Pyre at %s: cremated '%s'" % [tile, consumed_corpse.deceased_name]) EventBus.corpse_cremated.emit(consumed_corpse, self) consumed_corpse.queue_free() else: # Defensive: corpse may have already been freed by decay or another # system during the crafting window. Audit.log("pyre", "Pyre at %s: cremation complete but no corpse found (already freed?)" % tile) # ── render ───────────────────────────────────────────────────────────────────── ## Override Workbench._draw() to dispatch to _draw_pyre() for the "Pyre" label. func _draw() -> void: var alpha: float = 1.0 if is_completed() else 0.4 _draw_pyre(alpha) func _draw_pyre(alpha: float) -> void: # Simple log-pile silhouette: dark-brown stone base with orange ember # and ash-grey smoke wisps, suggesting an outdoor funeral pyre. var base_top := Color(0.30, 0.22, 0.12, alpha) # charred wood / dirt var base_front := Color(0.22, 0.15, 0.08, alpha) var ember := Color(0.95, 0.45, 0.10, alpha) var ash_grey := Color(0.70, 0.68, 0.65, alpha * 0.7) var outline := Color(0.12, 0.08, 0.04, 0.7 * alpha) # Top face — dark charred surface. draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), base_top) # Front face — very dark wood body. draw_rect(Rect2(Vector2(-8.0, -11.0), Vector2(16.0, 11.0)), base_front) # Ember glow — wide orange strip across the front, suggesting hot coals. draw_rect(Rect2(Vector2(-5.0, -4.0), Vector2(10.0, 3.0)), ember) # Smoke wisps — three thin vertical rects rising above the top face. draw_rect(Rect2(Vector2(-3.0, -18.0), Vector2(1.0, 3.0)), ash_grey) draw_rect(Rect2(Vector2(0.5, -19.0), Vector2(1.0, 4.0)), ash_grey) draw_rect(Rect2(Vector2(3.0, -17.0), Vector2(1.0, 2.0)), ash_grey) # Horizon line. draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), base_top, 1.0) # Tile outline. draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0) # ── helpers ─────────────────────────────────────────────────────────────────── ## Scan World.corpses for the nearest corpse within 2 tiles (the crafting toil ## would have moved it to the pyre tile or an adjacent tile). Returns null if ## none found. func _find_consumed_corpse(): var best = null var best_d: int = 3 # max search radius for c in World.corpses: var d: int = abs(c.tile.x - tile.x) + abs(c.tile.y - tile.y) if d < best_d: best_d = d best = c return best ## Return a walkable adjacent tile for the ash drop. Prefers the 4 cardinal ## neighbours in a fixed priority order; falls back to the pyre's own tile if ## all neighbours are blocked. func _pick_adjacent_drop_tile() -> Vector2i: var candidates: Array[Vector2i] = [ tile + Vector2i(1, 0), tile + Vector2i(-1, 0), tile + Vector2i(0, 1), tile + Vector2i(0, -1), ] for c in candidates: if World.pathfinder != null and World.pathfinder.is_walkable(c): return c return tile # fallback: drop on the pyre tile itself