rimlike/scenes/entities/crop.gd
megaproxy c93f889ff1 Crop sprites — atlas art + four growable kinds
Replaces the procedural stem-and-circle draw with an ElvGames atlas-backed
Sprite2D. Crops now pick a per-kind 64×32 (or 80×32) sheet from
art/sprites/crops/ and slice cols 0..3 across the SOWN..READY stage range
(TILLED keeps the bare dirt rect). The plant sprite is anchored so its
bottom edge sits at the tile bottom, matching the tree convention.

Four kinds wired in: wheat, potato, corn, strawberry. The boot demo's
crop patch now plants one column per kind so all four show up in the
spring start state. Harvest outputs map: wheat/corn → grain,
potato/strawberry → vegetable.

Tooltip already capitalises crop_kind so 'Corn' / 'Strawberry' show
through with no UI change.
2026-05-15 19:39:57 +01:00

256 lines
9.6 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
# 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 it: Item = ITEM_SCENE.instantiate()
get_parent().add_child(it)
it.setup(item_type, 1, tile)
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
stage_progress += 1
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,
}
## 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 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
)