Files
Cinderwild/scripts/core/inventory.gd

292 lines
8.7 KiB
GDScript

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
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
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()