rimlike/scenes/entities/tree.gd
megaproxy 5a6ec53b12 Tree growth: dedicated stage atlas + tuned WildGrowth rate
Sub-mature stages (0/1/2) now use FG_Tree_Stages.png (a 128×32 atlas
with 8 progressively-larger tree cells from the bundle's "Crops with
Stages 03" pack). Stage 0 = tiny sprout (col 0), Stage 1 = small
leaf (col 1), Stage 2 = small tree (col 3). Stage 3 (Mature) keeps
the existing 64×80 seasonal canopy atlases.

Visually distinct progression replaces the previous scale-down-the-
mature-texture placeholder + procedural sapling dots.

WildGrowth pacing tuned: INTERVAL 1200 → 3000, PROBABILITY 0.30 →
0.12, LIMIT 60 → 80. Previous values flooded the map with saplings
within ~30 seconds of 12× play. New rate gives a slow but visible
regrowth over a season at default speed.

_draw simplified: removed procedural sapling fallback (atlas handles
all stages now). Pending-plant ghosts get the alpha tint via
sprite.modulate.
2026-05-16 16:42:38 +01:00

342 lines
14 KiB
GDScript
Raw 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:
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
)