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.
256 lines
9.6 KiB
GDScript
256 lines
9.6 KiB
GDScript
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
|
||
)
|