P1 iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair

This commit is contained in:
Paul Bailey
2026-11-05 00:31:07 +00:00
parent ab1f417c32
commit 24c280d885
24 changed files with 1334 additions and 43 deletions

20
scripts/building/forge.gd Normal file
View File

@@ -0,0 +1,20 @@
extends "res://scripts/building/piece.gd"
## Forge: an anvil by a stone hearth. Unlocks "forge" recipes (iron tools,
## nails, a lantern) within 6 m, and repairs iron tools. E opens crafting.
func _build() -> void:
add_to_group("station:forge")
if DisplayServer.get_name() == "headless":
return
var l := OmniLight3D.new()
l.light_color = Color(1.0, 0.5, 0.2)
l.omni_range = 4.0
l.light_energy = 0.8
l.position = Vector3(-0.55, 0.9, 0.2)
add_child(l)
func interact_prompt(_player: Node) -> String:
return "Forge [E] Craft"
func interact(player: Node) -> void:
player.open_inventory(null)

View File

@@ -0,0 +1,125 @@
extends "res://scripts/building/piece.gd"
## A charcoal kiln or a smelter: E opens it like a chest. Put what it works
## on inside and it turns it into something else over time, on its own,
## while you're away (docs/PROGRESSION_DESIGN.md §2). It glows while it works.
##
## - Kiln: 2 wood -> 1 charcoal, 20 s.
## - Smelter: 1 iron ore + 1 charcoal -> 1 iron bar, 30 s; or 3 iron
## nuggets + 1 charcoal -> 1 iron bar.
##
## The output goes into the same slots, for you to take out. On a server it
## runs there (step() is skipped on players' copies) and the contents reach
## everyone like a chest's. Progress is saved.
const SLOTS := 6
const RECIPES := {
"charcoal_kiln": [{"inputs": {"wood": 2}, "output": "charcoal", "seconds": 20.0}],
"smelter": [
{"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
{"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
],
}
var inventory := Inventory.new(SLOTS)
var progress := 0.0 ## seconds into the current batch
var _light: OmniLight3D
var _glow: MeshInstance3D
var _t := 0.0
func _build() -> void:
add_to_group("processor")
inventory.changed.connect(_on_changed)
if DisplayServer.get_name() == "headless":
return
_light = OmniLight3D.new()
_light.light_color = Color(1.0, 0.55, 0.25)
_light.omni_range = 5.0
_light.light_energy = 0.0
_light.position = Vector3(0, 0.6, 0.5)
add_child(_light)
# The fire showing through the mouth.
_glow = MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(0.36, 0.26)
_glow.mesh = q
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = Color(1.0, 0.45, 0.12)
m.emission_enabled = true
m.emission = Color(1.0, 0.45, 0.12)
m.emission_energy_multiplier = 3.0
_glow.material_override = m
_glow.position = Vector3(0, 0.3 if item_id == "smelter" else 0.25, 0.58 if item_id == "smelter" else 0.72)
_glow.visible = false
add_child(_glow)
## The recipe it would work on now, or {} (nothing to do, or no room for
## what it makes).
func current() -> Dictionary:
for r in RECIPES.get(item_id, []):
if inventory.has_all(r.inputs) and inventory.space_for(r.output) > 0:
return r
return {}
func working() -> bool:
return not current().is_empty()
func _process(delta: float) -> void:
if WorldNet.is_client():
_show(working()) # (the server does the work; we just show it)
return
step(delta)
## Works for `secs` (several batches if there's time and stuff for them).
func step(secs: float) -> void:
var r := current()
if r.is_empty():
progress = 0.0
_show(false)
return
progress += secs
while not r.is_empty() and progress >= float(r.seconds):
progress -= float(r.seconds)
for id in r.inputs:
inventory.remove(id, int(r.inputs[id]))
inventory.add(r.output, 1)
r = current()
if r.is_empty():
progress = 0.0
_show(not r.is_empty())
func _show(on: bool) -> void:
if _light == null:
return
_t += get_process_delta_time()
_glow.visible = on
_light.light_energy = (1.2 + 0.25 * sin(_t * 9.0) * sin(_t * 3.1)) if on else 0.0
func _on_changed() -> void:
if current().is_empty():
progress = 0.0
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty it first"
return ""
func interact_prompt(_player: Node) -> String:
var r := current()
var what := "charcoal" if item_id == "charcoal_kiln" else "an iron bar"
if r.is_empty():
var needs := "put in wood" if item_id == "charcoal_kiln" else "put in iron ore (or 3 nuggets) and charcoal"
return "%s (idle: %s) [E] Open" % [display_name(), needs]
if WorldNet.is_client():
return "%s (making %s) [E] Open" % [display_name(), what]
return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)]
func interact(player: Node) -> void:
player.open_inventory(self)
func save_extra() -> Dictionary:
return {"progress": progress}
func load_extra(d: Dictionary) -> void:
progress = float(d.get("progress", 0.0))

View File

@@ -81,6 +81,81 @@ func tell_all(text: String) -> void:
else:
notify.emit(text)
# --- wear and repair (P1, docs/PROGRESSION_DESIGN.md §2) ---------------------------
## One use of the tool or weapon in the player's hand: it wears, and breaks
## when it runs out. Things without "durability" never wear.
func use_held(player: Node) -> void:
var slot: int = player.selected
var s = player.inventory.get_slot(slot)
if s == null:
return
var full := ItemDB.max_durability(s.id)
if full <= 0:
return
var left := ItemDB.durability(s) - 1
if left <= 0:
player.inventory.set_slot(slot, null)
tell(player, "Your %s broke" % ItemDB.item_name(s.id).to_lower())
return
var st: Dictionary = s.duplicate()
st.dur = left
player.inventory.set_slot(slot, st)
if left == int(full * 0.1) or left == 5:
tell(player, "Your %s is nearly worn out (repair it at a %s)" % [ItemDB.item_name(s.id).to_lower(),
String(ItemDB.get_item(s.id).get("repair", {}).get("station", "workbench"))])
## What repairing the item in `slot` costs, and where: {"inputs", "station"},
## or {} if it can't be repaired or doesn't need it.
func repair_cost(player: Node, slot: int) -> Dictionary:
var s = player.inventory.get_slot(slot)
if s == null:
return {}
var r: Dictionary = ItemDB.get_item(s.id).get("repair", {})
if r.is_empty() or ItemDB.durability(s) >= ItemDB.max_durability(s.id):
return {}
return r
func can_repair(player: Node, slot: int) -> bool:
var r := repair_cost(player, slot)
return not r.is_empty() and player.inventory.has_all(r.inputs) and station_near(player, String(r.get("station", "")))
## Brings the item in `slot` back to as new, for a little of what it's made of.
func repair(player: Node, slot: int) -> bool:
if _online():
return _ask("repair", [slot])
var r := repair_cost(player, slot)
if r.is_empty():
return false
if not station_near(player, String(r.get("station", ""))):
notify.emit("Needs a %s nearby" % r.station)
return false
if not player.inventory.has_all(r.inputs):
notify.emit("Missing materials")
return false
var s: Dictionary = player.inventory.get_slot(slot).duplicate()
for id in r.inputs:
player.inventory.remove(id, int(r.inputs[id]))
s.erase("dur")
# (Removing materials can't move the tool: it isn't one of them.)
player.inventory.set_slot(slot, s)
notify.emit("Repaired your %s" % ItemDB.item_name(s.id).to_lower())
return true
## The arrows a bow shoots: the first kind in its "ammo" list you have.
func ammo_for(player: Node, bow: Dictionary) -> String:
var kinds: Array = bow.ammo if bow.ammo is Array else [bow.ammo]
for k in kinds:
if player.inventory.count(String(k)) > 0:
return String(k)
return ""
func ammo_count(player: Node, bow: Dictionary) -> int:
var n := 0
for k in (bow.ammo if bow.ammo is Array else [bow.ammo]):
n += player.inventory.count(String(k))
return n
## Online, a creature is known by its net id (creature_net.gd).
static func _cid(c: Node) -> int:
return int(c.get_meta("cid", 0))
@@ -140,7 +215,17 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
if _online():
return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
var id: String = pickup_node.item_id
if _give(player, id, pickup_node.amount) <= 0:
var extra: Dictionary = pickup_node.get("extra") if pickup_node.get("extra") != null else {}
if not extra.is_empty():
var st := extra.duplicate()
st.id = id
st.count = pickup_node.amount
if player.inventory.add_stack(st) > 0:
notify.emit("Inventory full")
return false
item_gained.emit(id, pickup_node.amount)
notify.emit("+%d %s" % [pickup_node.amount, ItemDB.item_name(id)])
elif _give(player, id, pickup_node.amount) <= 0:
return false
pickup_node.collected()
return true
@@ -164,6 +249,7 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
for id in drops:
_give(player, id, int(drops[id]))
use_held(player)
return true
func can_place(player: Node3D, slot: int) -> bool:
@@ -283,6 +369,7 @@ func melee(player: Node3D, target: Node3D) -> bool:
notify.emit("Too tired")
return false
target.take_hit(m.damage, player.global_position)
use_held(player)
if target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower())
return true
@@ -316,6 +403,7 @@ func hit_player(player: Node3D, peer: int) -> bool:
return false
player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8)
target.survival.damage(m.damage, player.global_position, String(player.display))
use_held(player)
notify.emit("Hit %s" % String(target.display))
return true
@@ -342,9 +430,16 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world
notify.emit("Too tired")
return false
var id: String = s.id
var left := ItemDB.durability(s) - 1
player.inventory.take_from(slot, 1)
world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
var pr: Node3D = world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
float(w.throw.damage), player)
if ItemDB.max_durability(id) > 0:
if left <= 0:
pr.breaks = true # (its last throw: it breaks where it lands)
tell(player, "Your %s will break" % ItemDB.item_name(id).to_lower())
else:
pr.extra = {"dur": left}
return true
## Starts drawing the held bow: needs arrows and a little stamina.
@@ -354,13 +449,13 @@ func can_draw(player: Node3D) -> bool:
# (The server checks arrows and spends the stamina when you shoot.)
if bow.is_empty():
return false
if player.inventory.count(bow.ammo) <= 0:
if ammo_count(player, bow) <= 0:
notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)")
return false
return true
if bow.is_empty():
return false
if player.inventory.count(bow.ammo) <= 0:
if ammo_count(player, bow) <= 0:
notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)")
return false
if not player.survival.use_stamina(float(bow.stamina)):
@@ -375,12 +470,15 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
if not player.inventory.remove(bow.ammo, 1):
var ammo := ammo_for(player, bow)
if ammo.is_empty() or not player.inventory.remove(ammo, 1):
return false
power = clampf(power, 0.0, 1.0)
var spd: float = lerpf(float(bow.speed[0]), float(bow.speed[1]), power)
var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power)
world.launch(bow.ammo, origin, dir.normalized() * spd, dmg, player)
dmg *= float(ItemDB.get_item(ammo).get("ammo", {}).get("damage_mult", 1.0))
world.launch(ammo, origin, dir.normalized() * spd, dmg, player)
use_held(player)
return true
func butcher(player: Node3D, carcass: Node3D) -> bool:
@@ -417,6 +515,7 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) ->
var d := dir.normalized()
d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level
world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0)))
use_held(player)
return true
## Reels in. With a fish biting you land it; otherwise the line just comes in.

View File

@@ -31,10 +31,27 @@ 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.
static func _norm(stack):
if stack == null or int(stack.count) <= 0:
return null
return {"id": String(stack.id), "count": int(stack.count)}
var out := {"id": String(stack.id), "count": int(stack.count)}
if stack.has("dur"):
out.dur = int(stack.dur)
return out
## Adds a whole stack, keeping what's special about it (a worn tool's "dur").
## 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))
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
@@ -117,8 +134,8 @@ func quick_move(i: int, other: Inventory) -> void:
var s = slots[i]
if s == null:
return
var left := other.add(s.id, s.count)
slots[i] = null if left <= 0 else {"id": s.id, "count": left}
var left := other.add_stack(s)
slots[i] = null if left <= 0 else _norm({"id": s.id, "count": left, "dur": s.dur} if s.has("dur") else {"id": s.id, "count": left})
changed.emit()
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
@@ -138,7 +155,7 @@ func click(i: int, cursor, right: bool):
result = s
slots[i] = null
elif s == null:
if right:
if right and cursor.count > 1:
slots[i] = {"id": cursor.id, "count": 1}
result = _norm({"id": cursor.id, "count": cursor.count - 1})
else:

View File

@@ -109,6 +109,16 @@ func item_name(id: String) -> String:
func stack_size(id: String) -> int:
return int(items.get(id, {}).get("stack", 1))
## How many uses a tool or weapon has when new (0 = it never wears out).
func max_durability(id: String) -> int:
return int(items.get(id, {}).get("durability", 0))
## Uses left on a stack (its max if it's never been used, or it can't wear).
func durability(stack) -> int:
if stack == null:
return 0
return int(stack.get("dur", max_durability(stack.id)))
func mesh_for(id: String) -> ArrayMesh:
return ItemMeshes.build(items.get(id, {}).get("mesh", id))

View File

@@ -24,11 +24,11 @@ func setup(id: String, w: Node3D, from: Vector3, vel: Vector3, dmg: float, who:
damage = dmg
shooter = who
_start_speed = maxf(vel.length(), 0.1)
gravity = 9.8 if id == "arrow" else 12.0
gravity = 9.8 if ItemDB.get_item(id).has("ammo") else 12.0
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for(id)
mi.material_override = ItemDB.item_material()
if id == "spear":
if ItemDB.get_item(id).get("weapon", {}).has("throw"):
# The spear mesh runs along +Y; turn it to fly point-first along -Z.
mi.transform = Transform3D(Basis(Vector3.RIGHT, -PI * 0.5), Vector3(0, 0, 0.35))
add_child(mi)
@@ -83,26 +83,28 @@ func _on_hit(hit: Dictionary) -> void:
return
if c != null and (c as Node).is_in_group("creature"):
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "")
if item_id == "spear":
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if ItemDB.get_item(item_id).has("ammo") else "")
if ItemDB.get_item(item_id).get("weapon", {}).has("throw"):
var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0)
_drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at))
else:
queue_free()
return
# Stick into whatever it hit, point first, slightly buried.
var at: Vector3 = hit.position - dir * (0.12 if item_id == "arrow" else 0.25)
var at: Vector3 = hit.position - dir * (0.12 if ItemDB.get_item(item_id).has("ammo") else 0.25)
var b := Basis.looking_at(dir, Vector3.UP if absf(dir.y) < 0.99 else Vector3.RIGHT)
if item_id == "spear":
if ItemDB.get_item(item_id).get("weapon", {}).has("throw"):
b = b * Basis(Vector3.RIGHT, -PI * 0.5) # mesh +Y along the flight
_drop(Transform3D(b, at))
func _drop(xf: Transform3D) -> void:
if not visual:
world.drop_item(item_id, 1, xf)
if not visual and not breaks:
world.drop_item(item_id, 1, xf, extra)
queue_free()
## Online, our game shows the server's arrows and spears in flight
## (creature_net.gd). They hit nothing: the server's copy does that, and
## anything that lands comes from the server as a dropped item.
var visual := false
var extra: Dictionary = {} ## carried onto the item where it lands (a spear's wear)
var breaks := false ## its last throw: nothing is left where it lands

View File

@@ -76,6 +76,20 @@ static func build(key: String) -> ArrayMesh:
"grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true)
"fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
"iron_ore": m = _ore()
"clay": m = _pebble(67, CLAY, Vector3(0.12, 0.07, 0.1), 0.12)
"charcoal": m = _charcoal()
"iron_bar": m = _iron_bar()
"nails": m = _nails()
"iron_axe": m = _iron_axe()
"iron_pickaxe": m = _iron_pickaxe()
"iron_spear": m = _iron_spear()
"iron_arrow": m = _arrow(IRON_BRIGHT)
"lantern": m = _lantern()
"charcoal_kiln": m = _kiln()
"smelter": m = _smelter()
"forge": m = _forge()
"berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false)
_:
@@ -474,11 +488,11 @@ static func _bow() -> ArrayMesh:
return P._commit(st)
## Arrow along -Z (its flight direction), tip at the front.
static func _arrow() -> ArrayMesh:
static func _arrow(tip: Color = FLINT) -> ArrayMesh:
var st := P._begin()
var r := _rng(137)
P._cylinder(st, Vector3(0, 0, 0.35), Vector3(0, 0, -0.33), 0.008, 0.008, 4, HANDLE, r, 0.0)
P._cylinder(st, Vector3(0, 0, -0.32), Vector3(0, 0, -0.4), 0.018, 0.002, 4, FLINT, r, 0.0)
P._cylinder(st, Vector3(0, 0, -0.32), Vector3(0, 0, -0.4), 0.018, 0.002, 4, tip, r, 0.0)
for a in [0.0, TAU / 3.0, TAU * 2.0 / 3.0]:
var d := Vector3(cos(a), sin(a), 0) * 0.03
P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(-d.y, d.x, 0), FLETCH)
@@ -487,6 +501,133 @@ static func _arrow() -> ArrayMesh:
const WOLF_FUR := Color(0.5, 0.5, 0.52)
# --- iron (P1) ------------------------------------------------------------------
const RUST := Color(0.5, 0.26, 0.14)
const CLAY := Color(0.62, 0.6, 0.55)
const CLAY_FIRED := Color(0.66, 0.42, 0.3)
const CHARCOAL := Color(0.1, 0.09, 0.09)
const IRON_BRIGHT := Color(0.55, 0.56, 0.6)
static func _ore() -> ArrayMesh:
var st := P._begin()
var r := _rng(71)
var n := FastNoiseLite.new()
n.seed = 71
n.frequency = 1.4
P._blob(st, Vector3(0, 0.07, 0), Vector3(0.13, 0.09, 0.11), n, 0.35, STONE, r, 1)
for i in range(4):
var a := TAU * i / 4.0 + 0.4
P._blob(st, Vector3(cos(a) * 0.08, 0.1, sin(a) * 0.07), Vector3(0.05, 0.035, 0.04), n, 0.4, RUST, r, 0)
return P._commit(st)
static func _charcoal() -> ArrayMesh:
var st := P._begin()
var r := _rng(73)
for i in range(4):
var c := Vector3(r.randf_range(-0.07, 0.07), 0.03, r.randf_range(-0.06, 0.06))
_box(st, c, Vector3(0.1, 0.05, 0.06) * r.randf_range(0.8, 1.2), P._mul(CHARCOAL, r.randf_range(0.8, 1.6)), Basis(Vector3.UP, r.randf() * TAU))
return P._commit(st)
static func _iron_bar() -> ArrayMesh:
var st := P._begin()
_box(st, Vector3(0, 0.04, 0), Vector3(0.3, 0.07, 0.1), IRON_BRIGHT)
_box(st, Vector3(0, 0.085, 0), Vector3(0.26, 0.02, 0.07), P._mul(IRON_BRIGHT, 1.15))
return P._commit(st)
static func _nails() -> ArrayMesh:
var st := P._begin()
var r := _rng(79)
for i in range(6):
var a := r.randf() * TAU
var c := Vector3(r.randf_range(-0.06, 0.06), 0.012, r.randf_range(-0.06, 0.06))
var d := Vector3(cos(a), 0, sin(a)) * 0.07
P._cylinder(st, c - d, c + d, 0.006, 0.002, 4, IRON, r)
P._cylinder(st, c - d, c - d * 0.85, 0.013, 0.013, 5, IRON, r)
return P._commit(st)
static func _iron_axe() -> ArrayMesh:
var st := P._begin()
var r := _rng(83)
_handle(st, 0.58, r)
_box(st, Vector3(0.03, 0.5, 0), Vector3(0.1, 0.08, 0.04), IRON)
_box(st, Vector3(0.11, 0.5, 0), Vector3(0.07, 0.15, 0.025), IRON_BRIGHT)
return P._commit(st)
static func _iron_pickaxe() -> ArrayMesh:
var st := P._begin()
var r := _rng(89)
_handle(st, 0.62, r)
_box(st, Vector3(0, 0.57, 0), Vector3(0.06, 0.07, 0.06), IRON)
P._cylinder(st, Vector3(-0.02, 0.58, 0), Vector3(0.25, 0.51, 0), 0.03, 0.004, 4, IRON_BRIGHT, r, -1.0, IRON)
P._cylinder(st, Vector3(0.02, 0.58, 0), Vector3(-0.22, 0.52, 0), 0.03, 0.004, 4, IRON_BRIGHT, r, -1.0, IRON)
return P._commit(st)
static func _iron_spear() -> ArrayMesh:
var st := P._begin()
var r := _rng(97)
P._cylinder(st, Vector3(0, -0.55, 0), Vector3(0, 0.75, 0), 0.022, 0.02, 6, HANDLE, r, -1.0, HANDLE)
_box(st, Vector3(0, 0.77, 0), Vector3(0.04, 0.05, 0.04), IRON)
P._cylinder(st, Vector3(0, 0.79, 0), Vector3(0, 1.02, 0), 0.03, 0.002, 4, IRON_BRIGHT, r)
return P._commit(st)
static func _lantern() -> ArrayMesh:
var st := P._begin()
var r := _rng(101)
# Held by the ring at the top, the light hanging below the hand.
_box(st, Vector3(0, 0.05, 0), Vector3(0.14, 0.02, 0.14), IRON)
_box(st, Vector3(0, 0.3, 0), Vector3(0.16, 0.03, 0.16), IRON)
for sx in [-0.065, 0.065]:
for sz in [-0.065, 0.065]:
_box(st, Vector3(sx, 0.175, sz), Vector3(0.015, 0.24, 0.015), IRON)
_box(st, Vector3(0, 0.17, 0), Vector3(0.11, 0.2, 0.11), Color(1.0, 0.85, 0.5))
P._cylinder(st, Vector3(0, 0.315, 0), Vector3(0, 0.4, 0), 0.04, 0.01, 5, IRON, r)
return P._commit(st)
## A low clay dome with a dark mouth at the front (+Z), and a smoke hole.
static func _kiln() -> ArrayMesh:
var st := P._begin()
var r := _rng(103)
var n := FastNoiseLite.new()
n.seed = 103
n.frequency = 1.2
P._blob(st, Vector3(0, 0.45, 0), Vector3(0.75, 0.55, 0.75), n, 0.08, CLAY_FIRED, r, 1)
_box(st, Vector3(0, 0.25, 0.68), Vector3(0.42, 0.34, 0.12), ASH)
P._cylinder(st, Vector3(0, 0.95, 0), Vector3(0, 1.12, 0), 0.12, 0.1, 6, P._mul(CLAY_FIRED, 0.85), r, -1.0, ASH)
for i in range(8):
var a := TAU * i / 8.0
P._blob(st, Vector3(cos(a) * 0.78, 0.08, sin(a) * 0.78), Vector3(0.13, 0.1, 0.12), n, 0.3, STONE, r, 1)
return P._commit(st)
## A stone base with a tall clay shaft, the hot mouth low at the front (+Z).
static func _smelter() -> ArrayMesh:
var st := P._begin()
var r := _rng(107)
var n := FastNoiseLite.new()
n.seed = 107
n.frequency = 1.5
for i in range(10):
var a := TAU * i / 10.0
P._blob(st, Vector3(cos(a) * 0.5, 0.12, sin(a) * 0.5), Vector3(0.16, 0.14, 0.15), n, 0.3, P._mul(STONE, r.randf_range(0.8, 1.05)), r, 1)
P._cylinder(st, Vector3(0, 0.1, 0), Vector3(0, 1.7, 0), 0.5, 0.26, 8, CLAY_FIRED, r, -1.0, ASH)
_box(st, Vector3(0, 0.3, 0.46), Vector3(0.36, 0.28, 0.14), ASH)
_box(st, Vector3(0.38, 0.12, 0.42), Vector3(0.28, 0.06, 0.14), STONE, Basis(Vector3.UP, 0.5))
return P._commit(st)
## A stone hearth (left) and an anvil on a stump (right).
static func _forge() -> ArrayMesh:
var st := P._begin()
var r := _rng(109)
_box(st, Vector3(-0.55, 0.38, 0), Vector3(0.95, 0.76, 0.95), P._mul(STONE, 0.9))
_box(st, Vector3(-0.55, 0.8, 0), Vector3(0.75, 0.08, 0.75), ASH)
_box(st, Vector3(-0.55, 0.86, 0.05), Vector3(0.4, 0.06, 0.4), Color(0.9, 0.35, 0.1))
P._cylinder(st, Vector3(0.55, 0.0, 0), Vector3(0.55, 0.55, 0), 0.24, 0.22, 7, P.BARK, r, -1.0, PLANK)
_box(st, Vector3(0.55, 0.62, 0), Vector3(0.5, 0.14, 0.2), IRON)
_box(st, Vector3(0.55, 0.72, 0), Vector3(0.62, 0.06, 0.24), IRON_BRIGHT)
P._cylinder(st, Vector3(0.84, 0.72, 0), Vector3(1.0, 0.71, 0), 0.07, 0.01, 5, IRON_BRIGHT, r)
_box(st, Vector3(0.45, 0.8, 0.0), Vector3(0.24, 0.04, 0.05), HANDLE, Basis(Vector3.UP, 0.6))
return P._commit(st)
static func _pelt(col: Color) -> ArrayMesh:
var st := P._begin()
var r := _rng(149)

View File

@@ -205,7 +205,7 @@ func _on_died(peer: int) -> void:
if not p.worn.is_empty() and p.inventory.add(p.worn, 1) == 0:
p.worn = ""
if p.cursor != null:
p.inventory.add(p.cursor.id, p.cursor.count)
p.inventory.add_stack(p.cursor)
p.cursor = null
var has_items := false
for st in p.inventory.slots:
@@ -225,7 +225,8 @@ func _on_died(peer: int) -> void:
var off := Vector3(rng.randf_range(-1.2, 1.2), 0.0, rng.randf_range(-1.2, 1.2))
var at: Vector3 = pos + off
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at))
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
{"dur": int(st.dur)} if st.has("dur") else {})
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
# ("keep": you keep everything.)
# They stay down on the death screen for a few seconds, then ask to respawn.
@@ -357,9 +358,10 @@ func _run(p: Node3D, what: String, a: Array) -> void:
# The inventory screen closed: what's on the cursor goes back in
# your bag, or at your feet if it's full.
if p.cursor != null:
var left: int = p.inventory.add(p.cursor.id, p.cursor.count)
var left: int = p.inventory.add_stack(p.cursor)
if left > 0:
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)))
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
{"dur": int(p.cursor.dur)} if p.cursor.has("dur") else {})
p.cursor = null
"debug_give":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
@@ -410,6 +412,14 @@ func _run(p: Node3D, what: String, a: Array) -> void:
Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w)
"reel":
Actions.reel(p, w)
"repair":
var slot := _i(a, 0)
if slot >= 0 and slot < p.inventory.size():
Actions.repair(p, slot)
"debug_process":
if cheats:
for m in w.get_tree().get_nodes_in_group("processor"):
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
"debug_spawn":
if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite():
w.creatures.spawn(_s(a, 0), _v(a, 1))

View File

@@ -175,7 +175,7 @@ func _physics_process(delta: float) -> void:
elif def.has("wear"):
_set_prompt("[RMB] Wear %s" % def.name)
elif def.has("bow"):
var n: int = player.inventory.count(def.bow.ammo)
var n: int = Actions.ammo_count(player, def.bow)
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
elif def.get("weapon", {}).has("throw"):
_set_prompt("[LMB] Thrust [RMB] Throw")

View File

@@ -154,9 +154,11 @@ func close() -> void:
PlayerNet.ask("close") # the server puts the cursor back in our bag
_cursor = null
elif _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count)
var left: int = player.inventory.add_stack(_cursor)
if left > 0 and container != null and is_instance_valid(container):
left = container.inventory.add(_cursor.id, left)
var rest: Dictionary = _cursor.duplicate()
rest.count = left
left = container.inventory.add_stack(rest)
_cursor = null
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
container.inventory.changed.disconnect(_refresh)
@@ -253,16 +255,75 @@ func _rebuild_recipes() -> void:
for c in _recipe_list.get_children():
c.queue_free()
var stations: Array[String] = []
for st in ["workbench", "campfire"]:
for st in ["workbench", "campfire", "forge"]:
if Actions.station_near(player, st):
stations.append(st)
_station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)")
_rebuild_tabs()
if category == "repair":
for slot in _worn_slots():
_recipe_list.add_child(_repair_row(slot))
return
for rid in ItemDB.recipes:
var r: Dictionary = ItemDB.recipes[rid]
if r.category == category:
_recipe_list.add_child(_recipe_row(r))
## Slots holding a tool or weapon that's been used.
func _worn_slots() -> Array:
var out: Array = []
for i in range(player.inventory.size()):
if not Actions.repair_cost(player, i).is_empty():
out.append(i)
return out
func _repair_row(slot: int) -> Control:
var s = player.inventory.get_slot(slot)
var cost: Dictionary = Actions.repair_cost(player, slot)
var ok := Actions.can_repair(player, slot)
var row := PanelContainer.new()
row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if ok else UITheme.SLOT_BORDER, 1, 5, 6))
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
row.add_child(h)
var icon := TextureRect.new()
icon.texture = ItemDB.icon(s.id)
icon.custom_minimum_size = Vector2(44, 44)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
h.add_child(icon)
var v := VBoxContainer.new()
v.size_flags_horizontal = Control.SIZE_EXPAND_FILL
v.add_child(UITheme.label("%s (%d/%d)" % [ItemDB.item_name(s.id), ItemDB.durability(s), ItemDB.max_durability(s.id)], 16,
UITheme.TEXT if ok else UITheme.TEXT_DIM))
var req := RichTextLabel.new()
req.bbcode_enabled = true
req.fit_content = true
req.scroll_active = false
req.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
req.add_theme_font_size_override("normal_font_size", 13)
req.custom_minimum_size = Vector2(170, 0)
var parts: Array[String] = []
for id in cost.inputs:
var need := int(cost.inputs[id])
var have: int = player.inventory.count(id)
parts.append("[color=#%s]%s %d/%d[/color]" % [(UITheme.OK if have >= need else UITheme.BAD).to_html(false), ItemDB.item_name(id), have, need])
var st := String(cost.get("station", ""))
if not st.is_empty():
parts.append("[color=#%s]@ %s[/color]" % [(UITheme.OK if Actions.station_near(player, st) else UITheme.BAD).to_html(false), st])
req.text = " ".join(parts)
v.add_child(req)
h.add_child(v)
var b := Button.new()
b.text = "Repair"
b.disabled = not ok
b.focus_mode = Control.FOCUS_NONE
b.pressed.connect(func():
Actions.repair(player, slot)
_refresh())
h.add_child(b)
return row
var _tabs: HFlowContainer
var category := "tools" ## the tab showing
var _picked := false ## chose a tab since opening (don't jump about)
@@ -279,14 +340,19 @@ func _rebuild_tabs() -> void:
count[r.category] = int(count.get(r.category, 0)) + 1
if Actions.can_craft(player, rid):
ready[r.category] = int(ready.get(r.category, 0)) + 1
# Repair: shown once something you carry is worn.
var worn := _worn_slots()
if not worn.is_empty():
count["repair"] = worn.size()
ready["repair"] = worn.filter(func(i): return Actions.can_repair(player, i)).size()
if not count.has(category):
category = count.keys()[0] if not count.is_empty() else "tools"
for cat in ItemDB.CATEGORIES:
for cat in ItemDB.CATEGORIES.keys() + ["repair"]:
if not count.has(cat):
continue
var b := Button.new()
var n := int(ready.get(cat, 0))
b.text = ItemDB.CATEGORIES[cat] + (" (%d)" % n if n > 0 else "")
b.text = String(ItemDB.CATEGORIES.get(cat, "Repair")) + (" (%d)" % n if n > 0 else "")
b.toggle_mode = true
b.button_pressed = cat == category
b.focus_mode = Control.FOCUS_NONE

View File

@@ -13,6 +13,8 @@ var _key: Label
var _style: StyleBoxFlat
var _selected_style: StyleBoxFlat
var _stack = null
var _wear: ColorRect
var _wear_fill: ColorRect
func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
index = i
@@ -44,17 +46,44 @@ func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
_key.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_key)
_wear = ColorRect.new()
_wear.color = Color(0, 0, 0, 0.6)
_wear.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_wear.offset_left = 6
_wear.offset_right = -6
_wear.offset_top = -9
_wear.offset_bottom = -5
_wear.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wear.visible = false
add_child(_wear)
_wear_fill = ColorRect.new()
_wear_fill.anchor_bottom = 1.0
_wear_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wear.add_child(_wear_fill)
func set_stack(stack) -> void:
_stack = stack
if stack == null:
_icon.texture = null
_count.text = ""
tooltip_text = ""
_wear.visible = false
return
_icon.texture = ItemDB.icon(stack.id)
_count.text = str(stack.count) if stack.count > 1 else ""
var def := ItemDB.get_item(stack.id)
tooltip_text = "%s\n%s" % [def.get("name", stack.id), def.get("description", "")]
# Wear: a bar along the bottom once a tool or weapon has been used.
var full := ItemDB.max_durability(stack.id)
var left := ItemDB.durability(stack)
if full > 0 and left < full:
var k := float(left) / full
_wear.visible = true
_wear_fill.anchor_right = k
_wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35))
tooltip_text += "\n%d of %d uses left" % [left, full]
else:
_wear.visible = false
func set_selected(on: bool) -> void:
add_theme_stylebox_override("panel", _selected_style if on else _style)

View File

@@ -35,6 +35,7 @@ func interact(player: Node) -> void:
signal taken_now(pickup: Node)
var dropped := false ## dropped by the player (arrows, spears): saved, never regrows
var extra: Dictionary = {} ## kept when it's picked up (a worn spear's "dur")
## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
## Dropped items just go.

View File

@@ -478,6 +478,19 @@ func _scatter_rocks() -> void:
_bind_nodes(pending, _flush(groups, meshes, true, "Rocks"))
## 0..1: how close (x, z) is to the foot of the mountain rim, where scree
## and iron collect. 1 within 6 m of the edge of the walkable area, 0 from
## 30 m in.
func _near_rim(x: float, z: float) -> float:
if not terrain.inside_boundary(x, z, 6.0):
return 1.0
if terrain.inside_boundary(x, z, 30.0):
return 0.0
for m in [24.0, 18.0, 12.0]: # (inside at margin m = at least m metres from the edge)
if terrain.inside_boundary(x, z, m):
return 1.0 - (m - 6.0) / 24.0
return 1.0
## Places one rock. Passing `pending` makes it a mineable boulder (if it's
## inside the walkable area); otherwise it just gets plain collision.
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
@@ -493,6 +506,12 @@ func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h:
shape.points = pts
if pending != null and terrain.inside_boundary(p.x, p.y, 0.0):
var node := _resource_node("rock", Transform3D(b, pos))
# Rust-streaked boulders with iron ore in them: mostly near the
# rim, a few anywhere. (From the position, so it's the same every
# time, without disturbing the scatter's random numbers.)
var roll := float(absi(hash(Vector2i(int(p.x * 10.0), int(p.y * 10.0)))) % 1000) / 1000.0
if roll < 0.18 + _near_rim(p.x, p.y) * 0.6:
node.make_iron(hulls[vi])
var cs := CollisionShape3D.new()
cs.shape = shape
cs.basis = b.orthonormalized()
@@ -591,8 +610,10 @@ func _scatter_pickups() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 6
# Order matters: new kinds go at the END so existing saves keep their ids.
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45}
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
"iron_nugget": 70, "clay": 60}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
"iron_nugget": 0.25, "clay": 0.3}
for id: String in want:
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
@@ -625,8 +646,17 @@ func _scatter_pickups() -> void:
# Meadows, and thickest along the lake shore.
var wet := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 3.0, h)
chance = 0.05 + (1.0 - _forest(x, z)) * 0.45 + wet * 0.6
"iron_nugget":
# In the scree at the foot of the mountains (and on rocky slopes).
chance = 0.02 + _near_rim(x, z) * 0.9 + (1.0 - n.y) * 1.5
"clay":
# The lake shore only.
var lake: Vector2 = terrain.lake_center
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
chance = shore * 0.95 if by_lake else 0.0
# A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay"]:
chance += 0.5
if keep > chance:
continue

View File

@@ -36,8 +36,50 @@ var _orig_shapes: Array = [] ## [CollisionShape3D, shape, position] befor
signal depleted_now(node: Node)
func display_name() -> String:
if is_iron:
return "Rusty boulder (iron ore)"
return "Tree" if kind == "tree" else "Boulder"
# --- rusty boulders (iron ore) ---------------------------------------------------
var is_iron := false
var ore_vis: Node3D ## the rust streaks, following the boulder's instance
static var _patch_mesh: ArrayMesh
## Turns this boulder into a rusty one: iron ore when it breaks, and red-brown
## streaks on its surface (`hull` = the rock mesh's points, unscaled).
func make_iron(hull: PackedVector3Array) -> void:
is_iron = true
drops_final = {"stone": [2, 4], "iron_ore": [2, 4]}
chip_color = Color(0.55, 0.3, 0.18)
if DisplayServer.get_name() == "headless":
return
if _patch_mesh == null:
var n := FastNoiseLite.new()
n.seed = 5
n.frequency = 2.0
var st := ProcMesh._begin()
ProcMesh._blob(st, Vector3.ZERO, Vector3(0.13, 0.1, 0.018), n, 0.25, Color(0.48, 0.24, 0.12), RandomNumberGenerator.new(), 0)
_patch_mesh = ProcMesh._commit(st)
ore_vis = Node3D.new()
ore_vis.top_level = true
add_child(ore_vis)
# Streaks on the upper surface, spread out.
var up: Array = []
for q: Vector3 in hull:
if q.y > 0.15:
up.append(q)
up.sort_custom(func(a, b): return a.x + a.z * 0.37 < b.x + b.z * 0.37)
var mat := ItemDB.item_material()
for i in range(mini(6, up.size())):
var q: Vector3 = up[int(float(i) / 6.0 * up.size())]
var mi := MeshInstance3D.new()
mi.mesh = _patch_mesh
mi.material_override = mat
mi.transform = Transform3D(Basis.looking_at(-q.normalized(), Vector3.UP if absf(q.normalized().y) < 0.95 else Vector3.RIGHT), q * 0.97)
ore_vis.add_child(mi)
ore_vis.global_transform = base_xf
func interact_prompt(player: Node) -> String:
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
var verb := "Chop" if kind == "tree" else "Mine"
@@ -101,6 +143,8 @@ func _rock_xf() -> Transform3D:
func _set_instance(xf: Transform3D) -> void:
if mmi != null and index >= 0:
mmi.multimesh.set_instance_transform(index, xf)
if ore_vis != null:
ore_vis.global_transform = xf # (shrinks, shakes, hides and regrows with it)
## Restores saved damage when a world loads. hp <= 0 = already felled/mined
## (no animation, no drops).

View File

@@ -24,6 +24,9 @@ const PIECES := {
"build": preload("res://scripts/building/build_piece.gd"),
"door": preload("res://scripts/building/door.gd"),
"bed": preload("res://scripts/building/bed.gd"),
"kiln": preload("res://scripts/building/processor.gd"),
"smelter": preload("res://scripts/building/processor.gd"),
"forge": preload("res://scripts/building/forge.gd"),
}
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const UITheme = preload("res://scripts/ui/theme.gd")
@@ -311,6 +314,8 @@ func skip_time(secs: float) -> void:
day_night.apply(true)
weather.snap()
props.skip_time(secs)
for m in get_tree().get_nodes_in_group("processor"):
m.step(secs) # (kilns and smelters work through the night)
for f in get_tree().get_nodes_in_group("heat"):
if f.has_method("burn"):
f.burn(secs)
@@ -537,6 +542,8 @@ func piece_entry(piece: Node3D) -> Dictionary:
func dropped_entry(d: Node3D) -> Dictionary:
var e := {"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}
if not d.extra.is_empty():
e["extra"] = d.extra
if d.has_meta("nid"):
e["nid"] = int(d.get_meta("nid"))
return e
@@ -570,7 +577,10 @@ func add_piece_entry(e: Dictionary) -> Node3D:
func add_dropped_entry(e: Dictionary) -> Node3D:
if ItemDB.get_item(e.get("item", "")).is_empty():
return null
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf))
var ex: Dictionary = {}
if e.get("extra") is Dictionary and e.extra.has("dur"):
ex = {"dur": int(e.extra.dur)}
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex)
if e.has("nid"):
d.set_meta("nid", int(e.nid))
return d
@@ -772,17 +782,20 @@ func _on_net_left(_reason: String) -> void:
## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a
## tree, a thrown spear, meat that didn't fit). Saved with the world.
## Online only the server drops things (our game never does: returns null).
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
## `extra` is what's special about it (a worn tool's "dur"), kept when it's
## picked up again.
func drop_item(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
if net_sync != null:
push_warning("[world] drop_item(%s) on a client: only the server drops items" % id)
return null
return _make_dropped(id, count, xf)
return _make_dropped(id, count, xf, extra)
func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
func _make_dropped(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
pk.amount = count
pk.dropped = true
pk.extra = extra.duplicate()
_dropped.add_child(pk)
pk.global_transform = xf
if server_mode and not _applying: