rimlike/scenes/world/crate.gd
megaproxy f67c12c51f Clear designation tile-highlight when jobs complete
Each entity completion handler (wall/floor/door/bed/torch/workbench/crate
/tree/rock/big_rock/grave_slot) now calls World.clear_designation_at(tile)
so the orange/blue/etc. highlight overlay disappears with the job.
BigRock iterates its footprint to clear all four tiles.

World.designation_ctl is set during the scene boot wire-up; the helper
no-ops when the controller is absent (e.g. headless tests).
2026-05-15 19:31:55 +01:00

272 lines
10 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 Crate extends StorageDestination
## Furniture container entity: holds up to CAPACITY item stacks and can be
## filtered like a StockpileZone. Built via Build → Furniture → Crate.
##
## Lifecycle:
## - Ghost phase: placed but not yet built (build_progress < BUILD_TICKS).
## accepts() returns false; visual is 40% alpha.
## - Completed phase: _completed == true; accepts items up to CAPACITY.
##
## StorageDestination interface:
## accepts() — filter + capacity gate; false while ghost.
## find_drop_position()— returns crate's own tile when room exists,
## Vector2i(-1, -1) when full or ghost.
## covers_tile() — single-tile container; only the crate's own tile.
##
## BuildJob interface (mirrors Wall.on_build_tick pattern):
## is_buildable() — true while still a ghost.
## on_build_tick() — increments build_progress; completes at BUILD_TICKS.
## is_completed() — true once built.
##
## register_item() is called by JobRunner._tick_deposit (Opus integration
## follow-up) after the deposit physically lands on the crate tile.
##
## See docs/architecture.md "Container" and Phase 5 implementation plan.
## Maximum item stacks this crate can hold.
const CAPACITY: int = 4
## Number of sim ticks a pawn must spend building to complete the crate.
const BUILD_TICKS: int = 60
## Pixel size of one tile — must match World.TILE_SIZE_PX.
const TILE_SIZE_PX: int = 16
# ── visual constants ──────────────────────────────────────────────────────────
## Body dimensions in pixels (centred on the tile).
const _BODY_W: int = 12
const _BODY_H: int = 10
## Crate colours.
const _COLOR_BODY: Color = Color(0.45, 0.30, 0.15, 1.0)
const _COLOR_OUTLINE: Color = Color(0.25, 0.18, 0.08, 1.0)
const _COLOR_SLAT: Color = Color(0.30, 0.20, 0.10, 1.0)
const _COLOR_FILL_DOT: Color = Color(1.0, 1.0, 1.0, 0.9)
## Ghost alpha multiplier when not yet built.
const _GHOST_ALPHA: float = 0.40
# ── exports ───────────────────────────────────────────────────────────────────
## Tile position of this crate in world-tile coordinates.
@export var tile: Vector2i = Vector2i.ZERO
## Player-facing label (inspect UI, Phase 17).
@export var label_text: String = "Crate"
# ── state ─────────────────────────────────────────────────────────────────────
## Sim ticks of construction work applied so far.
var build_progress: int = 0
## True once build_progress >= BUILD_TICKS.
var _completed: bool = false
## Live item nodes currently stored in this crate; capped at CAPACITY.
## Populated by register_item() (called from JobRunner._tick_deposit).
var _contents: Array = []
# ── lifecycle ─────────────────────────────────────────────────────────────────
func _ready() -> void:
# Inherits StorageDestination defaults for priority / accepted_types.
# Crates default to NORMAL priority and wildcard (accepts all types).
priority = StorageDestination.Priority.NORMAL
accepted_types = []
# Register with the World stockpile pool so HaulingProvider sees us.
World.register_stockpile(self)
World.register_build_site(self)
queue_redraw()
func _exit_tree() -> void:
World.unregister_stockpile(self)
World.unregister_build_site(self)
## One-shot initialiser called by the spawning / placement code.
## Sets tile and snaps pixel position to the tile centre.
func setup(p_tile: Vector2i) -> void:
tile = p_tile
position = Vector2(
tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
tile.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)
# ── StorageDestination interface ──────────────────────────────────────────────
## Returns true if this crate can accept `item` right now.
## False while still a ghost, false if the filter rejects the type,
## false if all CAPACITY slots are taken.
func accepts(item) -> bool:
if not _completed:
return false
if not _filter_accepts(item):
return false
return _contents.size() < CAPACITY
## Returns the crate's own tile when it can accept `item`, otherwise (-1,-1).
## All items stack into the crate's single tile — there is no 2D region.
func find_drop_position(item) -> Vector2i:
if accepts(item):
return tile
return Vector2i(-1, -1)
## Returns true only when `p_tile` is exactly the crate's own tile.
func covers_tile(p_tile: Vector2i) -> bool:
return p_tile == tile
# ── BuildJob interface ────────────────────────────────────────────────────────
## True while the crate has not yet been fully built.
func is_buildable() -> bool:
return not _completed
## Returns the player-visible name for build-order and inspect UI.
func label() -> String:
return label_text
## Called once per sim tick while a Construction pawn is working on this crate.
## Advances build_progress; completes the crate once BUILD_TICKS is reached.
func on_build_tick() -> void:
if _completed:
return
build_progress += 1
if build_progress >= BUILD_TICKS:
_completed = true
emit_signal("contents_changed")
World.clear_designation_at(tile)
Audit.log("crate", "built at %s (capacity %d)" % [tile, CAPACITY])
queue_redraw()
## True once the crate has been fully built.
func is_completed() -> bool:
return _completed
# ── inventory hooks ───────────────────────────────────────────────────────────
## Called from JobRunner._tick_deposit (Opus integration) after the item
## physically lands on the crate tile.
## Defensive: skips duplicates and over-capacity inserts (HaulingProvider may
## race ahead of capacity checks in edge cases).
func register_item(item) -> void:
if not _completed:
return
if _contents.has(item) or _contents.size() >= CAPACITY:
return
_contents.append(item)
emit_signal("contents_changed")
## Called when an item is removed from the crate (picked up by a pawn or via
## the Empty operation in Phase 17 inspect UI).
func unregister_item(item) -> void:
_contents.erase(item)
emit_signal("contents_changed")
# ── save / load ───────────────────────────────────────────────────────────────
## Serialise crate state for World save (Phase 16 will wire this).
func to_dict() -> Dictionary:
return {
"class_id": &"crate",
"tile_x": tile.x,
"tile_y": tile.y,
"label_text": label_text,
"build_progress": build_progress,
"completed": _completed,
"priority": int(priority),
"accepted_types": accepted_types.map(func(t): return String(t)),
}
## Restore from a dict produced by to_dict().
## Item content refs are reconnected by World.load_crates() after all items
## are spawned (Phase 16); _contents starts empty here.
func from_dict(d: Dictionary) -> void:
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
label_text = d.get("label_text", "Crate")
build_progress = int(d.get("build_progress", 0))
_completed = bool(d.get("completed", false))
priority = d.get("priority", StorageDestination.Priority.NORMAL) as StorageDestination.Priority
var raw_types: Array = d.get("accepted_types", [])
accepted_types.clear()
for s in raw_types:
accepted_types.append(StringName(s))
setup(tile)
# ── render ─────────────────────────────────────────────────────────────────────
## Procedural crate graphic; no PNG dependency.
##
## Completed crate:
## Brown 12×10 body with a darker 1-px outline, two horizontal slat bands.
## Four 2×2 fill-indicator dots in the top-right corner — white dots equal
## to _contents.size() are drawn; remaining dots are drawn at low alpha.
##
## Ghost (not yet built):
## Same shapes at GHOST_ALPHA overall alpha.
func _draw() -> void:
var alpha_scale: float = _GHOST_ALPHA if not _completed else 1.0
var body_color := Color(_COLOR_BODY.r, _COLOR_BODY.g, _COLOR_BODY.b, _COLOR_BODY.a * alpha_scale)
var outline_col := Color(_COLOR_OUTLINE.r, _COLOR_OUTLINE.g, _COLOR_OUTLINE.b, _COLOR_OUTLINE.a * alpha_scale)
var slat_color := Color(_COLOR_SLAT.r, _COLOR_SLAT.g, _COLOR_SLAT.b, _COLOR_SLAT.a * alpha_scale)
# Half-extents for centering.
var hw: int = _BODY_W / 2 # 6
var hh: int = _BODY_H / 2 # 5
# Body fill.
var body_rect := Rect2(Vector2(-hw, -hh), Vector2(_BODY_W, _BODY_H))
draw_rect(body_rect, body_color, true)
# Outline (1 px wide; draw_rect with false = border).
draw_rect(body_rect, outline_col, false, 1.0)
# Two horizontal slat bands — at ⅓ and ⅔ of the body height.
# Each band is 1 px tall, inset 1 px from the sides.
var slat_x_start: float = -hw + 1.0
var slat_width: float = float(_BODY_W - 2)
var slat_y1: float = -hh + float(_BODY_H) / 3.0 - 0.5
var slat_y2: float = -hh + float(_BODY_H) * 2.0 / 3.0 - 0.5
draw_line(
Vector2(slat_x_start, slat_y1),
Vector2(slat_x_start + slat_width, slat_y1),
slat_color, 1.0
)
draw_line(
Vector2(slat_x_start, slat_y2),
Vector2(slat_x_start + slat_width, slat_y2),
slat_color, 1.0
)
# Fill-level indicator: 4 dots (2×2 px each) in the top-right corner,
# arranged in a 2×2 grid. Dots up to _contents.size() are bright white;
# the rest are dim (10% alpha).
var dot_size: float = 2.0
var dot_gap: float = 1.0
var dot_origin := Vector2(float(hw) - 2.0 * dot_size - dot_gap - 1.0, float(-hh) + 1.0)
for i in range(CAPACITY):
var col_idx: int = i % 2
var row_idx: int = i / 2
var dot_x: float = dot_origin.x + col_idx * (dot_size + dot_gap)
var dot_y: float = dot_origin.y + row_idx * (dot_size + dot_gap)
var dot_rect := Rect2(Vector2(dot_x, dot_y), Vector2(dot_size, dot_size))
var fill_alpha: float = (1.0 if i < _contents.size() else 0.15) * alpha_scale
var dot_col := Color(_COLOR_FILL_DOT.r, _COLOR_FILL_DOT.g, _COLOR_FILL_DOT.b, fill_alpha)
draw_rect(dot_rect, dot_col, true)