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 ## Phase 7 ships wheat and potato. Phase 17 expands (berry, hop) per design.md. const KIND_WHEAT: StringName = &"wheat" const KIND_POTATO: StringName = &"potato" ## 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 } @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 # 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 const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") # ── lifecycle ───────────────────────────────────────────────────────────────── func _ready() -> void: position = _tile_to_world(tile) 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) 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 it: Item = ITEM_SCENE.instantiate() get_parent().add_child(it) it.setup(item_type, 1, tile) stage = Stage.TILLED stage_progress = 0 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 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 stage_progress += 1 if stage_progress >= STAGE_TICKS: stage_progress = 0 stage = (int(stage) + 1) as Stage 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, } ## 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)), } # ── render ──────────────────────────────────────────────────────────────────── func _draw() -> void: # Tilled-soil base: a small dark-earth square. 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) if stage == Stage.TILLED: return # Bare soil — no plant drawn. # stage_idx: 0 = SOWN, 4 = READY var stage_idx := int(stage) - int(Stage.SOWN) var height: float = lerp(2.0, 12.0, float(stage_idx) / float(STAGE_COUNT)) var plant_color := _plant_color_for(crop_kind) # Stem draw_rect(Rect2(Vector2(-2.0, 5.0 - height), Vector2(4.0, height)), plant_color) # Foliage circle grows in from GROWING_2 onward if stage_idx >= 2: draw_circle(Vector2(0.0, 5.0 - height), 3.0 + float(stage_idx), plant_color) # Ready accent — grain head or potato cap if stage == Stage.READY: draw_circle(Vector2(0.0, 5.0 - height), 2.0, _ready_accent_for(crop_kind)) # ── helpers ─────────────────────────────────────────────────────────────────── func _harvest_output_for(kind: StringName) -> StringName: match kind: KIND_WHEAT: return Item.TYPE_GRAIN KIND_POTATO: return Item.TYPE_VEGETABLE _: return Item.TYPE_VEGETABLE # fallback func _plant_color_for(kind: StringName) -> Color: match kind: KIND_WHEAT: return Color(0.50, 0.65, 0.20) # bright green sprout KIND_POTATO: return Color(0.30, 0.55, 0.20) # darker green _: return Color(0.40, 0.60, 0.20) func _ready_accent_for(kind: StringName) -> Color: match kind: KIND_WHEAT: return Color(0.95, 0.85, 0.20) # golden grain head KIND_POTATO: return Color(0.95, 0.60, 0.30) # orange potato cap _: return Color(1.0, 0.4, 0.4) 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 )