rimlike/scenes/entities/tree.gd
megaproxy 2afca16299 wire buff consumers, sick penalty, multi-count cremation
A: Storyteller.multiply_drops() stochastic-rounding helper drives
crop_growth, harvest_yield, chop, mine consumption sites. sleep_decay
multiplied in Pawn sleep tick.

B: Pawn._sick_speed_penalty() (0% healthy → 75% severity 3, clamped to
25% min speed). JobRunner._work_speed_mult coin-flips per-tick progress
on INTERACT/BUILD/CRAFT toils. Sleep/eat/treat unaffected.

C: CraftingProvider builds N deposit trips for ingredient2_count > 1.
JobRunner._tick_craft validates+consumes the full count from buffer.
Cremation now actually requires and consumes 5 wood.

crop._stage_accum round-trips through save/load to preserve buff-
accumulated fractional growth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:17:29 +01:00

346 lines
14 KiB
GDScript
Raw Permalink 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)
## 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:
# Apply chop buff (lumberjacks_luck): multiply total wood drops with stochastic rounding.
var total_drops: int = Storyteller.multiply_drops(WOOD_DROPS_ON_FELL, Storyteller.get_buff_multiplier(&"chop"))
var drop_tiles := _pick_drop_tiles_count(total_drops)
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 (buff mult=%.2f)" % [tile, drops_count, Storyteller.get_buff_multiplier(&"chop")])
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 `count` tile positions for wood drops.
## Prefers the tree's own tile then walkable 4-neighbours; fills any remaining
## slots with the tree tile if neighbours are scarce.
## Previously took an implicit count of WOOD_DROPS_ON_FELL; now accepts an
## explicit count so the chop buff can request more drops.
func _pick_drop_tiles_count(count: int) -> 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() >= count:
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 land there if boxed in).
while chosen.size() < count:
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
)