class_name Inventory extends RefCounted ## Slot-based inventory with stacking. Backs the player, chests and (later) ## grave bags, so moving items is the same operation everywhere. ## ## Pure data: no nodes, no UI. Each slot is either null or {"id": String, ## "count": int}. UI widgets render it and listen to `changed`; mutations from ## gameplay go through Actions (see docs/SURVIVAL_DESIGN.md §3). signal changed var slots: Array = [] func _init(size: int = 40) -> void: slots.resize(size) func size() -> int: return slots.size() func get_slot(i: int): return slots[i] func set_slot(i: int, stack) -> void: slots[i] = _norm(stack) changed.emit() ## Looked up at runtime rather than via the ItemDB global, so this plain data ## class also compiles in contexts that load it before autoloads exist (tests, ## a future headless server tool). static func _stack_max(id: String) -> int: var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB") return db.stack_size(id) if db != null else 50 ## A stack can carry "dur": uses left on a tool or weapon that wears out ## (ItemDB "durability"). No "dur" means as new. ## Food that spoils (ItemDB "keeps", in days) carries "rot": the world time ## (seconds since day 1, 00:00) at which it goes rotten. It's stamped when ## the food first goes into an inventory; two stacks that merge share the ## average. world.gd turns rotten stacks into rotten food (spoil()). static func _norm(stack): if stack == null or int(stack.count) <= 0: return null var out := {"id": String(stack.id), "count": int(stack.count)} if stack.has("dur"): out.dur = int(stack.dur) if stack.has("rot"): out.rot = float(stack.rot) return out ## What's special about a stack, for when it's dropped on the ground (and ## kept when it's picked up again): a tool's wear, food's freshness. static func extra_of(stack: Dictionary) -> Dictionary: var out := {} if stack.has("dur"): out.dur = int(stack.dur) if stack.has("rot"): out.rot = float(stack.rot) return out # --- spoilage (P4, docs/PROGRESSION_DESIGN.md §5) --------------------------------- ## The world clock (seconds since day 1, 00:00) and how long a day is, kept ## up to date by world.gd. `rot_speed`: 1 normal, 0.5 slow, 0 off (the ## world's or server's "rot" setting). static var clock := 0.0 static var day_seconds := 1200.0 static var rot_speed := 1.0 ## How many times longer food keeps in here (a clay jar: 3), and the clock at ## the last spoil() (the time since is what's slowed). var keep_mult := 1.0 var _last_clock := -1.0 const ROTTEN := "rotten_food" static func _db() -> Node: return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB") ## Days `id` keeps before it rots (0: it doesn't). static func keeps_days(id: String) -> float: var db := _db() return float(db.get_item(id).get("keeps", 0.0)) if db != null else 0.0 ## How long `id` keeps, in world seconds, at the current rot speed (0: forever). static func keeps_for(id: String) -> float: var d := keeps_days(id) if d <= 0.0 or rot_speed <= 0.0: return 0.0 return d * day_seconds / rot_speed ## The "rot" stamp for `id` made right now (-1: it doesn't spoil). static func fresh_rot(id: String) -> float: var k := keeps_for(id) # (Whole seconds, so a stamp comes back from a JSON save exactly as it went in.) return roundf(clock + k) if k > 0.0 else -1.0 ## 0..1: how fresh a stack is (1 = just made or doesn't spoil). static func freshness(stack) -> float: if stack == null or not stack.has("rot"): return 1.0 var k := keeps_for(String(stack.id)) if k <= 0.0: return 1.0 return clampf((float(stack.rot) - clock) / k, 0.0, 1.0) ## In-game hours until a stack rots (INF: never). static func hours_left(stack) -> float: if stack == null or not stack.has("rot") or rot_speed <= 0.0: return INF return maxf(float(stack.rot) - clock, 0.0) / day_seconds * 24.0 ## Stamps food that has no "rot" yet, and turns food past its time into ## rotten food. Returns how many items went rotten. func spoil() -> int: if rot_speed <= 0.0: return 0 var rotted := 0 var touched := false # A clay jar (keep_mult 3): food in it ages at a third of the rate, so its # "rot" stamp moves back by two thirds of the time that's passed. if keep_mult > 1.0: if _last_clock >= 0.0 and clock > _last_clock: var later := (clock - _last_clock) * (1.0 - 1.0 / keep_mult) for s in slots: if s != null and s.has("rot") and float(s.rot) > _last_clock: s.rot = float(s.rot) + later _last_clock = clock for i in range(slots.size()): var s = slots[i] if s == null or keeps_days(String(s.id)) <= 0.0: continue if not s.has("rot"): s.rot = fresh_rot(String(s.id)) touched = true elif clock >= float(s.rot): rotted += int(s.count) slots[i] = {"id": ROTTEN, "count": int(s.count)} touched = true if touched: changed.emit() return rotted ## Adds a whole stack, keeping what's special about it (a worn tool's "dur", ## food's "rot"). Returns how many didn't fit. func add_stack(stack: Dictionary) -> int: if not stack.has("dur"): return add(String(stack.id), int(stack.count), float(stack.get("rot", -1.0))) for i in range(slots.size()): if slots[i] == null: slots[i] = _norm(stack) changed.emit() return 0 return int(stack.count) func count(id: String) -> int: var n := 0 for s in slots: if s != null and s.id == id: n += s.count return n ## How many of `id` would fit right now. func space_for(id: String) -> int: var cap := _stack_max(id) var room := 0 for s in slots: if s == null: room += cap elif s.id == id: room += cap - s.count return room ## Adds up to `n` of `id`, topping up existing stacks first. Returns the ## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot" ## stamp, or `rot` if it's coming from somewhere with one of its own. func add(id: String, n: int, rot: float = -1.0) -> int: var cap := _stack_max(id) var r := rot if rot >= 0.0 else fresh_rot(id) for s in slots: if n <= 0: break if s != null and s.id == id and s.count < cap: var take := mini(cap - s.count, n) if r >= 0.0: s.rot = _mix_rot(s, r, take) s.count += take n -= take for i in range(slots.size()): if n <= 0: break if slots[i] == null: var take := mini(cap, n) slots[i] = {"id": id, "count": take} if r >= 0.0: slots[i].rot = r n -= take changed.emit() return n ## The "rot" of stack `s` once `n` more with `rot` are added to it: the ## average, so mixing fresh and old food gives you food in between. static func _mix_rot(s: Dictionary, rot: float, n: int) -> float: if rot < 0.0: return float(s.get("rot", -1.0)) var have := float(s.get("rot", rot)) return roundf((have * int(s.count) + rot * n) / float(int(s.count) + n)) ## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its ## items longest). Returns false and changes nothing if there aren't enough. func remove(id: String, n: int) -> bool: if count(id) < n: return false for i in range(slots.size() - 1, -1, -1): if n <= 0: break var s = slots[i] if s != null and s.id == id: var take := mini(s.count, n) s.count -= take n -= take if s.count <= 0: slots[i] = null changed.emit() return true ## Removes `n` from a specific slot. Returns how many were removed. func take_from(i: int, n: int) -> int: var s = slots[i] if s == null: return 0 var take := mini(s.count, n) s.count -= take if s.count <= 0: slots[i] = null changed.emit() return take func has_all(inputs: Dictionary) -> bool: for id in inputs: if count(id) < int(inputs[id]): return false return true ## Moves the stack in slot `i` into `other` (stacking where possible). Whatever ## doesn't fit stays put. Used for shift-click quick-move. func quick_move(i: int, other: Inventory) -> void: var s = slots[i] if s == null: return var left := other.add_stack(s) slots[i] = null if left <= 0 else _part(s, left) changed.emit() ## `n` of stack `s`, with its wear or freshness. static func _part(s: Dictionary, n: int): var out := extra_of(s) out.id = s.id out.count = n return _norm(out) ## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new ## cursor stack. Left click: pick up / put down / merge / swap. Right click: ## pick up half, or put down one. func click(i: int, cursor, right: bool): var s = slots[i] var result = cursor if cursor == null: if s == null: return null if right and s.count > 1: var half := int(ceil(s.count / 2.0)) s.count -= half result = _part(s, half) else: result = s slots[i] = null elif s == null: if right and cursor.count > 1: slots[i] = _part(cursor, 1) result = _part(cursor, cursor.count - 1) else: slots[i] = cursor result = null elif s.id == cursor.id and not s.has("dur") and not cursor.has("dur"): var cap := _stack_max(s.id) var move: int = 1 if right else cursor.count move = mini(move, cap - s.count) if move > 0 and cursor.has("rot"): s.rot = _mix_rot(s, float(cursor.rot), move) s.count += move result = _part(cursor, cursor.count - move) else: slots[i] = cursor result = s changed.emit() return result ## Serialisable snapshot (for saves and, later, network replication). func to_data() -> Array: return slots.duplicate(true) func load_data(data: Array) -> void: slots.resize(data.size()) for i in range(data.size()): slots[i] = _norm(data[i]) if data[i] != null else null changed.emit()