rimlike/scenes/entities/crop.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

275 lines
11 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.

class_name Crop extends Node2D
## Crop entity — a farm plant tile that grows through stages and is harvested by a pawn.
##
## Growth model (docs/implementation.md Phase 7):
## SOWN → GROWING_1 → GROWING_2 → GROWING_3 → READY, each stage taking STAGE_TICKS sim ticks.
## At 20 Hz, 200 ticks ≈ 10 sim seconds ≈ 2 in-game minutes at Fast speed.
## "No growth indoors" rule (docs/design.md) lands in Phase 13 when the Roof flag
## system is fully wired; for now crops always grow.
##
## A PlantProvider creates a Job whose INTERACT toil calls on_harvest_tick() or
## on_sow_tick() once per sim tick via JobRunner. Both are single-tick completions.
## The INTERACT toil finishes when is_harvestable() / is_sowable() returns false.
##
## World registration (World.register_crop / World.unregister_crop) is called here.
const TILE_SIZE_PX: int = 16
## Available crop kinds. Each maps to an ElvGames 64×32 sprite sheet
## (4 stages × 16w × 32h, plant anchored to bottom row).
const KIND_WHEAT: StringName = &"wheat"
const KIND_POTATO: StringName = &"potato"
const KIND_CORN: StringName = &"corn"
const KIND_STRAWBERRY: StringName = &"strawberry"
## Sim ticks per growth stage. 200 ticks × 4 stages = 800 total.
## At 20 Hz × 5× speed = 100 ticks/sec → 8 real seconds per stage, 32 seconds full grow.
const STAGE_TICKS: int = 200
const STAGE_COUNT: int = 4
enum Stage { TILLED, SOWN, GROWING_1, GROWING_2, GROWING_3, READY }
## Per-kind sprite atlas. Strawberry / Corn are 80×32 (5 stages) — we still slice
## the first 4 cols, which gives the same progression (the 5th col is a
## post-harvest regrow frame we don't use).
const _CROP_TEXTURES: Dictionary = {
KIND_WHEAT: preload("res://art/sprites/crops/FG_Crops_Wheat.png"),
KIND_POTATO: preload("res://art/sprites/crops/FG_Crops_Potato.png"),
KIND_CORN: preload("res://art/sprites/crops/FG_Crops_Corns.png"),
KIND_STRAWBERRY: preload("res://art/sprites/crops/FG_Crops_Strawberry.png"),
}
## Width / height of one stage cell in pixels.
const _STAGE_W: int = 16
const _STAGE_H: int = 32
@export var crop_kind: StringName = KIND_WHEAT
@export var tile: Vector2i = Vector2i.ZERO
var stage: Stage = Stage.SOWN
## Progress within the current growth stage; 0..STAGE_TICKS.
var stage_progress: int = 0
## Float accumulator for sub-tick growth when crop_growth buff is active.
## Carries fractional progress between ticks so the buff has the correct
## expected-value effect even though stage_progress is stored as int.
var _stage_accum: float = 0.0
# Phase 13 — "no growth indoors" rule. True once we've logged the first
# indoor detection for this crop instance so we don't flood the audit log.
var _logged_indoor: bool = false
# Child Sprite2D — created in _ready, region_rect updated whenever stage flips.
var _sprite: Sprite2D = null
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
position = _tile_to_world(tile)
_build_sprite()
World.register_crop(self)
EventBus.sim_tick.connect(_on_sim_tick)
queue_redraw()
func _exit_tree() -> void:
World.unregister_crop(self)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Call after add_child() so _ready() has already fired.
func setup(p_tile: Vector2i, p_kind: StringName, p_stage: Stage = Stage.SOWN) -> void:
tile = p_tile
crop_kind = p_kind
stage = p_stage
stage_progress = 0
position = _tile_to_world(tile)
if _sprite != null:
_sprite.texture = _texture_for(crop_kind)
_refresh_sprite_region()
queue_redraw()
Audit.log("crop", "spawned %s at %s (stage=%s)" % [crop_kind, tile, Stage.keys()[stage]])
## True when this crop can be harvested by a pawn.
func is_harvestable() -> bool:
return stage == Stage.READY
## True when this crop can be sown by a pawn (bare tilled soil, no plant yet).
func is_sowable() -> bool:
return stage == Stage.TILLED
## Called by the INTERACT toil in JobRunner once per sim tick while a pawn harvests.
## Single-tick harvest: drops an output Item and resets to TILLED (re-sowable).
func on_harvest_tick() -> void:
if not is_harvestable():
return
var item_type := _harvest_output_for(crop_kind)
var base_qty: int = 1
var drop_qty: int = Storyteller.multiply_drops(base_qty, Storyteller.get_buff_multiplier(&"harvest_yield"))
var it: Item = ITEM_SCENE.instantiate()
get_parent().add_child(it)
it.setup(item_type, drop_qty, tile)
# Carry the crop_kind through as a visual subtype so wheat / corn /
# potato / strawberry each render distinctly, while item_type stays
# generic (TYPE_GRAIN / TYPE_VEGETABLE) for stockpile filter purposes.
it.subtype = crop_kind
it.queue_redraw()
stage = Stage.TILLED
stage_progress = 0
_refresh_sprite_region()
Audit.log("crop", "harvested %s at %s%s" % [crop_kind, tile, item_type])
queue_redraw()
## Called by the INTERACT toil in JobRunner once per sim tick while a pawn sows.
## Single-tick sow: transitions TILLED → SOWN so growth begins on the next sim tick.
func on_sow_tick() -> void:
if not is_sowable():
return
stage = Stage.SOWN
stage_progress = 0
_refresh_sprite_region()
Audit.log("crop", "sown %s at %s" % [crop_kind, tile])
queue_redraw()
# ── growth ────────────────────────────────────────────────────────────────────
func _on_sim_tick(_n: int) -> void:
if stage == Stage.READY or stage == Stage.TILLED:
return
# Phase 13 — crops don't grow indoors (no sunlight under a roof).
# World.is_indoor() returns false while RoomDetector has not yet fired, so
# outdoor crops planted during boot are unaffected.
if World.is_indoor(tile):
if not _logged_indoor:
Audit.log("crop", "%s at %s won't grow (indoor)" % [crop_kind, tile])
_logged_indoor = true
return
# Crop has moved outdoors or was never indoors — reset the log flag so a
# future re-roofing produces another audit line.
_logged_indoor = false
# Apply crop_growth buff: accumulate fractional progress each tick so the
# expected-value growth rate exactly matches the multiplier.
var growth_mult: float = Storyteller.get_buff_multiplier(&"crop_growth")
_stage_accum += growth_mult
var whole: int = int(floor(_stage_accum))
_stage_accum -= float(whole)
stage_progress += whole
if stage_progress >= STAGE_TICKS:
stage_progress = 0
stage = (int(stage) + 1) as Stage
_refresh_sprite_region()
queue_redraw()
if stage == Stage.READY:
Audit.log("crop", "%s ready at %s" % [crop_kind, tile])
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"crop",
"tile_x": tile.x,
"tile_y": tile.y,
"crop_kind": String(crop_kind),
"stage": int(stage),
"stage_progress": stage_progress,
"stage_accum": _stage_accum,
}
## Returns a plain Dictionary spec for World to instantiate from.
## Crops cannot reconstruct themselves standalone — they need a parent in the
## scene tree. World adds the node, then calls setup() from the returned dict.
static func from_dict(d: Dictionary) -> Dictionary:
return {
"tile_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)),
"crop_kind": StringName(d.get("crop_kind", "wheat")),
"stage": int(d.get("stage", Stage.SOWN)),
"stage_progress": int(d.get("stage_progress", 0)),
"stage_accum": float(d.get("stage_accum", 0.0)),
}
# ── render ────────────────────────────────────────────────────────────────────
func _draw() -> void:
# Tilled-soil base draws under the plant sprite. Stays visible at every stage
# so the player sees the patch as cultivated.
var soil_color := Color(0.32, 0.20, 0.10)
var soil_dark := Color(0.22, 0.14, 0.06)
draw_rect(Rect2(Vector2(-7.0, -7.0), Vector2(14.0, 14.0)), soil_color)
draw_rect(Rect2(Vector2(-7.0, -7.0), Vector2(14.0, 14.0)), soil_dark, false, 1.0)
# ── sprite helpers ────────────────────────────────────────────────────────────
func _build_sprite() -> void:
_sprite = Sprite2D.new()
_sprite.name = "Sprite"
_sprite.texture = _texture_for(crop_kind)
_sprite.region_enabled = true
_sprite.centered = true
# 32-tall sprite anchored so its bottom edge sits at the tile's bottom row
# (local +8 from tile centre). Sprite half-height = 16 → offset.y = 8 - 16 = -8.
_sprite.offset = Vector2(0, -8)
# Draw the plant above the soil rect but below pawns/items.
_sprite.z_index = 0
add_child(_sprite)
_refresh_sprite_region()
func _refresh_sprite_region() -> void:
if _sprite == null:
return
var idx := _sprite_stage_index(stage)
if idx < 0:
_sprite.visible = false
return
_sprite.visible = true
_sprite.region_rect = Rect2(idx * _STAGE_W, 0, _STAGE_W, _STAGE_H)
## Map game-stage to one of the 4 sprite columns. TILLED has no plant frame.
## SOWN..GROWING_2 step through cols 0..2; GROWING_3 and READY both land on
## col 3 (mature). The harvest designation overlay is what cues the player
## that READY is ready — sprite alone doesn't need a fifth frame.
func _sprite_stage_index(s: Stage) -> int:
match s:
Stage.TILLED: return -1
Stage.SOWN: return 0
Stage.GROWING_1: return 1
Stage.GROWING_2: return 2
Stage.GROWING_3: return 3
Stage.READY: return 3
_: return 0
# ── helpers ───────────────────────────────────────────────────────────────────
func _texture_for(kind: StringName) -> Texture2D:
return _CROP_TEXTURES.get(kind, _CROP_TEXTURES[KIND_WHEAT])
func _harvest_output_for(kind: StringName) -> StringName:
match kind:
KIND_WHEAT: return Item.TYPE_GRAIN
KIND_POTATO: return Item.TYPE_VEGETABLE
KIND_CORN: return Item.TYPE_GRAIN
KIND_STRAWBERRY: return Item.TYPE_VEGETABLE
_: return Item.TYPE_VEGETABLE
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
)