305 lines
9.3 KiB
GDScript
305 lines
9.3 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
|
|
## 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()
|