744 lines
28 KiB
GDScript
744 lines
28 KiB
GDScript
extends Node
|
|
## Autoload "Actions": the intent layer.
|
|
##
|
|
## UI and input never change game state directly. They call these functions,
|
|
## which validate and then apply. In M2 (multiplayer) these become the RPCs a
|
|
## client sends to the authoritative server, so every rule lives here and
|
|
## nothing is trusted from the caller. See docs/SURVIVAL_DESIGN.md §3.
|
|
##
|
|
## Also registers the input actions (so project.godot stays readable).
|
|
|
|
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
|
signal item_gained(id: String, n: int)
|
|
## What players did (docs/TUTORIAL_DESIGN.md §3): the tutorial's Guide (and
|
|
## later hints and addons) listen to these. Emitted where the game decides
|
|
## (single-player, or on a server for each player's character).
|
|
signal crafted(player: Node, recipe_id: String, output: String, count: int)
|
|
signal harvested(player: Node, node: Node, felled: bool)
|
|
signal placed(player: Node, item_id: String, piece: Node)
|
|
signal ate(player: Node, item_id: String)
|
|
signal killed(player: Node, kind: String)
|
|
signal butchered(player: Node, kind: String)
|
|
signal caught(player: Node, fish: String)
|
|
signal slept(player: Node)
|
|
|
|
const STATION_RANGE := 6.0
|
|
const REACH := 3.2
|
|
const FULL_HUNGER := 98.0 ## can't eat above this
|
|
|
|
func _ready() -> void:
|
|
_bind("interact", [KEY_E])
|
|
_bind("inventory", [KEY_TAB, KEY_I])
|
|
_bind("rotate_piece", [KEY_R])
|
|
_bind("dismantle", [KEY_X])
|
|
for i in range(8):
|
|
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
|
|
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
|
|
_bind_mouse("use_item", MOUSE_BUTTON_RIGHT)
|
|
|
|
func _bind(action: String, keys: Array) -> void:
|
|
if not InputMap.has_action(action):
|
|
InputMap.add_action(action)
|
|
for k in keys:
|
|
var ev := InputEventKey.new()
|
|
ev.physical_keycode = k
|
|
InputMap.action_add_event(action, ev)
|
|
|
|
func _bind_mouse(action: String, button: MouseButton) -> void:
|
|
if not InputMap.has_action(action):
|
|
InputMap.add_action(action)
|
|
var ev := InputEventMouseButton.new()
|
|
ev.button_index = button
|
|
InputMap.action_add_event(action, ev)
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
|
|
func _give(player: Node, id: String, n: int) -> int:
|
|
var inv: Inventory = player.inventory
|
|
var left := inv.add(id, n)
|
|
var got := n - left
|
|
if got > 0:
|
|
item_gained.emit(id, got)
|
|
notify.emit("+%d %s" % [got, ItemDB.item_name(id)])
|
|
if left > 0:
|
|
notify.emit("Inventory full")
|
|
return got
|
|
|
|
## Playing on a server: changes to the island go to it (world_net.gd), and
|
|
## come back to everyone, us included.
|
|
func _online() -> bool:
|
|
return WorldNet.is_client()
|
|
|
|
## Online, the server decides (player_net.gd): every intent is sent there as
|
|
## a request, and the server runs this same function on our character there.
|
|
## Offline nothing changes: the game decides.
|
|
func _ask(what: String, args: Array = []) -> bool:
|
|
PlayerNet.ask(what, args)
|
|
return true
|
|
|
|
## A message for one player: on screen in single-player; on a server, to
|
|
## that player's game (player_net.gd). For things that happen later than the
|
|
## request that caused them (a fish biting, an arrow landing a kill).
|
|
func tell(player: Node, text: String) -> void:
|
|
if Net.is_server() and player != null and player.get("peer") != null:
|
|
PlayerNet.tell(int(player.peer), text)
|
|
else:
|
|
notify.emit(text)
|
|
|
|
## A message for everyone (the wolves howling).
|
|
func tell_all(text: String) -> void:
|
|
if Net.is_server():
|
|
PlayerNet.tell_all(text)
|
|
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))
|
|
|
|
func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool:
|
|
return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra
|
|
|
|
## Station nodes register themselves in group "station:<name>".
|
|
func station_near(player: Node3D, station: String) -> bool:
|
|
if station.is_empty():
|
|
return true
|
|
for s in player.get_tree().get_nodes_in_group("station:" + station):
|
|
if s.has_method("station_active") and not s.station_active():
|
|
continue # e.g. a campfire that has gone out
|
|
if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE:
|
|
return true
|
|
return false
|
|
|
|
# --- intents ----------------------------------------------------------------
|
|
|
|
func can_craft(player: Node, recipe_id: String) -> bool:
|
|
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
|
|
if r.is_empty():
|
|
return false
|
|
var inv: Inventory = player.inventory
|
|
return inv.has_all(r.inputs) and station_near(player, r.station)
|
|
|
|
func craft(player: Node, recipe_id: String) -> bool:
|
|
if _online():
|
|
return _ask("craft", [recipe_id])
|
|
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
|
|
if r.is_empty():
|
|
return false
|
|
var inv: Inventory = player.inventory
|
|
if not station_near(player, r.station):
|
|
notify.emit("Needs a %s nearby" % r.station)
|
|
return false
|
|
if not inv.has_all(r.inputs):
|
|
notify.emit("Missing materials")
|
|
return false
|
|
# Check the output fits *after* the inputs are consumed.
|
|
var trial := Inventory.new(inv.size())
|
|
trial.load_data(inv.to_data())
|
|
for id in r.inputs:
|
|
trial.remove(id, int(r.inputs[id]))
|
|
if trial.space_for(r.output) < r.count:
|
|
notify.emit("Inventory full")
|
|
return false
|
|
for id in r.inputs:
|
|
inv.remove(id, int(r.inputs[id]))
|
|
_give(player, r.output, r.count)
|
|
crafted.emit(player, recipe_id, String(r.output), int(r.count))
|
|
Sfx.at("craft", player.global_position)
|
|
return true
|
|
|
|
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
|
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
|
|
return false
|
|
if _online():
|
|
return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
|
|
var id: String = pickup_node.item_id
|
|
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
|
|
Sfx.at("pickup", pickup_node.global_position)
|
|
pickup_node.collected()
|
|
return true
|
|
|
|
## One swing at a resource node with whatever the player is holding.
|
|
func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
|
if not is_instance_valid(node) or node.depleted:
|
|
return false
|
|
if _online():
|
|
return _ask("hit", [String(node.uid), hit_point])
|
|
if player.global_position.distance_to(hit_point) > REACH + 1.0:
|
|
return false
|
|
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
|
var tool: Dictionary = held.get("tool", {})
|
|
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
|
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
|
return false
|
|
if not player.survival.use_stamina(float(tool.get("stamina", 6.0))): # (the antler pickaxe is lighter)
|
|
notify.emit("Too tired")
|
|
return false
|
|
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)
|
|
Sfx.at(harvest_sound(String(node.tool_kind), bool(node.depleted)), hit_point)
|
|
harvested.emit(player, node, bool(node.depleted))
|
|
return true
|
|
|
|
func can_place(player: Node3D, slot: int) -> bool:
|
|
var s = player.inventory.get_slot(slot)
|
|
return s != null and ItemDB.get_item(s.id).has("place")
|
|
|
|
## Places the item in `slot` at `xf`. `xf` comes from the client's ghost
|
|
## preview, so it's re-validated here.
|
|
func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
|
if not can_place(player, slot):
|
|
return false
|
|
if _online():
|
|
return _ask("place", [slot, xf])
|
|
if player.global_position.distance_to(xf.origin) > 8.0:
|
|
return false
|
|
var id: String = player.inventory.get_slot(slot).id
|
|
var def: Dictionary = ItemDB.get_item(id)
|
|
if not world.placement_valid(def, xf):
|
|
notify.emit("Can't place that here")
|
|
return false
|
|
player.inventory.take_from(slot, 1)
|
|
var piece: Node3D = world.spawn_piece(def, xf)
|
|
Sfx.at(place_sound(id), xf.origin)
|
|
placed.emit(player, id, piece)
|
|
return true
|
|
|
|
## Takes a placed piece back into your inventory. Building pieces that lose
|
|
## their support collapse, and come back to you too.
|
|
func dismantle(player: Node3D, piece: Node3D) -> bool:
|
|
if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion():
|
|
return false
|
|
if _online():
|
|
return _ask("dismantle", [WorldNet.nid(piece)])
|
|
if player.global_position.distance_to(piece.global_position) > 8.0:
|
|
return false
|
|
if piece.item_id == "grave":
|
|
notify.emit("Take your things out and it will crumble")
|
|
return false
|
|
var why: String = piece.dismantle_block_reason()
|
|
if not why.is_empty():
|
|
notify.emit(why)
|
|
return false
|
|
if player.inventory.space_for(piece.item_id) < 1:
|
|
notify.emit("Inventory full")
|
|
return false
|
|
_give(player, piece.item_id, 1)
|
|
Sfx.at("dismantle", piece.global_position)
|
|
piece.get_parent().get_parent().remove_piece(piece, player)
|
|
return true
|
|
|
|
func toggle_door(player: Node3D, door: Node3D) -> bool:
|
|
if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
|
|
return false
|
|
if _online():
|
|
return _ask("door", [WorldNet.nid(door)])
|
|
door.set_open(not door.open)
|
|
Sfx.at("door_open" if door.open else "door_close", door.global_position)
|
|
return true
|
|
|
|
## E on the barrow's coffin (barrow.gd): take what's in it. Opening it wakes
|
|
## the guards. What doesn't fit goes on the floor by you.
|
|
func loot_barrow(player: Node3D, barrow: Node) -> bool:
|
|
if barrow == null or not barrow.found or not _in_reach(player, barrow._coffin, 0.5):
|
|
return false
|
|
if _online():
|
|
return _ask("barrow_loot", [])
|
|
var got: Dictionary = barrow.loot(player)
|
|
if got.is_empty():
|
|
return false
|
|
for id in got:
|
|
var left: int = player.inventory.add(id, int(got[id]))
|
|
if left > 0:
|
|
barrow.world.drop_item(id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.3, 0)))
|
|
item_gained.emit(id, int(got[id]))
|
|
tell(player, "+%d %s" % [int(got[id]), ItemDB.item_name(id)])
|
|
return true
|
|
|
|
## E on the standing stones' altar (standing_stones.gd, P5): set barrow key
|
|
## pieces in it, or (all three in, at night) call the Old Stag.
|
|
func use_stones(player: Node3D, stones: Node) -> bool:
|
|
if stones == null or not stones.found or not _in_reach(player, stones.altar(), 0.5):
|
|
return false
|
|
if _online():
|
|
return _ask("stones", [])
|
|
return not stones.use(player).is_empty()
|
|
|
|
## E on a door an alpha broke: mend it (two wood), shut.
|
|
func repair_door(player: Node3D, door: Node3D) -> bool:
|
|
if not is_instance_valid(door) or not door.get("broken") or not _in_reach(player, door, 1.0):
|
|
return false
|
|
if _online():
|
|
return _ask("door_repair", [WorldNet.nid(door)])
|
|
for id in door.REPAIR_COST:
|
|
if player.inventory.count(id) < int(door.REPAIR_COST[id]):
|
|
tell(player, "Need %d %s to mend the door" % [int(door.REPAIR_COST[id]), ItemDB.item_name(id).to_lower()])
|
|
return false
|
|
for id in door.REPAIR_COST:
|
|
player.inventory.remove(id, int(door.REPAIR_COST[id]))
|
|
door.set_broken(false)
|
|
tell(player, "Door mended")
|
|
return true
|
|
|
|
## E on a bed: claim it as your spawn point, or (if it's already yours) sleep.
|
|
func use_bed(player: Node3D, bed: Node3D) -> bool:
|
|
if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0):
|
|
return false
|
|
if _online():
|
|
return _ask("bed", [WorldNet.nid(bed)])
|
|
var world: Node = bed.get_parent().get_parent()
|
|
# On a server each player has their own bed (server_player.gd).
|
|
var mine: bool = player.claims_bed(bed) if player.has_method("claims_bed") else bed.is_spawn()
|
|
if not mine:
|
|
if player.has_method("claim_bed"):
|
|
player.claim_bed(bed)
|
|
else:
|
|
world.set_bed_spawn(bed)
|
|
notify.emit("Spawn point set")
|
|
Sfx.for_player(player, "ui_good")
|
|
return true
|
|
var why: String = world.sleep_block_reason(bed)
|
|
if not why.is_empty():
|
|
notify.emit(why)
|
|
return false
|
|
world.sleep(player)
|
|
slept.emit(player)
|
|
return true
|
|
|
|
# --- hunting and combat ---------------------------------------------------------
|
|
|
|
## Bare hands (or berries, a torch, a stone...) can't hurt an animal: you
|
|
## need a weapon, or at least an axe or pickaxe.
|
|
const NO_WEAPON := {"damage": 0.0, "reach": 2.0, "stamina": 0.0, "cooldown": 0.45, "armed": false}
|
|
const TOOL_AS_WEAPON := 6.0 ## axes and pickaxes are poor weapons
|
|
const NEED_WEAPON := "You need a weapon: a club, spear, bow or axe (Tab to craft)"
|
|
|
|
## Melee stats for what's in the player's hand. "armed" is false when it
|
|
## can't hurt an animal.
|
|
func melee_stats(player: Node) -> Dictionary:
|
|
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
|
var w: Dictionary = held.get("weapon", {})
|
|
if float(w.get("damage", 0)) > 0.0:
|
|
return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)),
|
|
"stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true,
|
|
"blunt": bool(w.get("blunt", false))}
|
|
if held.has("tool"):
|
|
return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true}
|
|
return NO_WEAPON
|
|
|
|
## One melee blow at an animal.
|
|
func melee(player: Node3D, target: Node3D) -> bool:
|
|
if _online():
|
|
if not is_instance_valid(target) or not target.is_in_group("creature"):
|
|
return false
|
|
if not melee_stats(player).armed:
|
|
notify.emit(NEED_WEAPON)
|
|
return false
|
|
return _ask("melee", [_cid(target)])
|
|
if not is_instance_valid(target) or not target.is_in_group("creature"):
|
|
return false
|
|
var m := melee_stats(player)
|
|
if not m.armed:
|
|
notify.emit(NEED_WEAPON)
|
|
return false
|
|
if player.global_position.distance_to(target.global_position) > m.reach + 1.8:
|
|
return false
|
|
if not player.survival.use_stamina(m.stamina):
|
|
notify.emit("Too tired")
|
|
return false
|
|
# Blunt weapons (the club) crack bone: +50% on skeletons.
|
|
var bone := bool(target.def.get("bone", false)) if target.get("def") != null else false
|
|
if target.has_method("credit"):
|
|
target.credit(player)
|
|
target.take_hit(m.damage * (1.5 if bone and m.get("blunt", false) else 1.0), player.global_position)
|
|
use_held(player)
|
|
if target.is_dead():
|
|
notify.emit("Killed a %s" % target.display_name().to_lower())
|
|
killed.emit(player, String(target.kind))
|
|
return true
|
|
|
|
## A melee blow at another player (online, with PvP on). Online only.
|
|
func hit_player(player: Node3D, peer: int) -> bool:
|
|
if _online():
|
|
if not melee_stats(player).armed:
|
|
notify.emit(NEED_WEAPON)
|
|
return false
|
|
return _ask("hit_player", [peer])
|
|
if not Net.is_server() or not PlayerNet.proxies.has(peer) or player.get("peer") == peer:
|
|
return false
|
|
var target: Node3D = PlayerNet.proxies[peer]
|
|
if not preload("res://scripts/net/rules.gd").pvp():
|
|
notify.emit("PvP is off on this server")
|
|
return false
|
|
if target.survival.dead:
|
|
return false
|
|
var m := melee_stats(player)
|
|
if not m.armed:
|
|
notify.emit(NEED_WEAPON)
|
|
return false
|
|
# (Positions are where the server last heard you both were: a little slack.)
|
|
if player.global_position.distance_to(target.global_position) > m.reach + 2.5:
|
|
return false
|
|
if Time.get_ticks_msec() < int(player.melee_ready_at):
|
|
return false
|
|
if not player.survival.use_stamina(m.stamina):
|
|
notify.emit("Too tired")
|
|
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))
|
|
Sfx.at("hit_flesh", target.global_position)
|
|
use_held(player)
|
|
notify.emit("Hit %s" % String(target.display))
|
|
return true
|
|
|
|
## An arrow or thrown spear reaching an animal (projectile.gd).
|
|
func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void:
|
|
if not is_instance_valid(target) or not target.is_in_group("creature"):
|
|
return
|
|
var was_alive: bool = not target.is_dead()
|
|
if shooter != null and target.has_method("credit"):
|
|
target.credit(shooter) # (the Old Stag remembers everyone who hurt it)
|
|
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
|
|
if was_alive and target.is_dead():
|
|
tell(shooter, "Killed a %s" % target.display_name().to_lower())
|
|
killed.emit(shooter, String(target.kind))
|
|
|
|
## Throws the spear in `slot` from `origin` along `dir`.
|
|
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
|
if _online():
|
|
return _ask("throw", [slot, dir])
|
|
var s = player.inventory.get_slot(slot)
|
|
if s == null:
|
|
return false
|
|
var w: Dictionary = ItemDB.get_item(s.id).get("weapon", {})
|
|
if not w.has("throw") or player.global_position.distance_to(origin) > 2.5:
|
|
return false
|
|
if not player.survival.use_stamina(float(w.throw.get("stamina", 12))):
|
|
notify.emit("Too tired")
|
|
return false
|
|
var id: String = s.id
|
|
var left := ItemDB.durability(s) - 1
|
|
player.inventory.take_from(slot, 1)
|
|
Sfx.at("throw", origin)
|
|
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.
|
|
func can_draw(player: Node3D) -> bool:
|
|
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
|
|
if _online():
|
|
# (The server checks arrows and spends the stamina when you shoot.)
|
|
if bow.is_empty():
|
|
return false
|
|
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 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)):
|
|
notify.emit("Too tired")
|
|
return false
|
|
return true
|
|
|
|
## Looses an arrow. `power` 0..1 is how far the bow was drawn.
|
|
func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool:
|
|
if _online():
|
|
return _ask("shoot", [dir, power])
|
|
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
|
|
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)
|
|
dmg *= float(ItemDB.get_item(ammo).get("ammo", {}).get("damage_mult", 1.0))
|
|
world.launch(ammo, origin, dir.normalized() * spd, dmg, player)
|
|
Sfx.at("bow", origin)
|
|
use_held(player)
|
|
return true
|
|
|
|
func butcher(player: Node3D, carcass: Node3D) -> bool:
|
|
if _online():
|
|
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass"):
|
|
return false
|
|
return _ask("butcher", [_cid(carcass)])
|
|
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
|
|
return false
|
|
var drops: Dictionary = carcass.butcher_drops()
|
|
carcass.remove_from_group("carcass")
|
|
butchered.emit(player, String(carcass.kind))
|
|
Sfx.at("butcher", carcass.global_position)
|
|
for id in drops:
|
|
var got := _give(player, id, int(drops[id]))
|
|
if got < int(drops[id]):
|
|
# Inventory full: the rest drops on the ground.
|
|
carcass.world.drop_item(id, int(drops[id]) - got, Transform3D(Basis(), carcass.global_position + Vector3(0, 0.1, 0)))
|
|
carcass.queue_free()
|
|
return true
|
|
|
|
# --- fishing ----------------------------------------------------------------------
|
|
|
|
## Casts the held rod from `origin` along `dir`.
|
|
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
|
if _online():
|
|
if ItemDB.get_item(player.held_item_id()).get("fishing", {}).is_empty():
|
|
return false
|
|
return _ask("cast", [dir])
|
|
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
|
|
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
|
|
return false
|
|
if not player.survival.use_stamina(float(rod.get("stamina", 4))):
|
|
notify.emit("Too tired")
|
|
return false
|
|
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)))
|
|
Sfx.at("cast", origin)
|
|
use_held(player)
|
|
return true
|
|
|
|
## Reels in. With a fish biting you land it; otherwise the line just comes in.
|
|
func reel(player: Node3D, world: Node3D) -> bool:
|
|
if _online():
|
|
return _ask("reel")
|
|
var b: Node3D = world.bobber_of(player)
|
|
if b == null or not is_instance_valid(b):
|
|
return false
|
|
var caught := ""
|
|
if b.state == "biting":
|
|
caught = b.fish
|
|
Sfx.at("reel", player.global_position)
|
|
if not caught.is_empty():
|
|
Sfx.at("catch", b.global_position)
|
|
world.end_fishing(player)
|
|
if caught.is_empty():
|
|
return false
|
|
if _give(player, caught, 1) == 0:
|
|
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
|
|
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
|
|
Sfx.for_player(player, "ui_catch")
|
|
emit_signal("caught", player, caught) # (the local `caught` hides the signal's name)
|
|
return true
|
|
|
|
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
|
|
func wear(player: Node3D, slot: int) -> bool:
|
|
if _online():
|
|
return _ask("wear", [slot])
|
|
var s = player.inventory.get_slot(slot)
|
|
if s == null or not ItemDB.get_item(s.id).has("wear"):
|
|
return false
|
|
var id: String = s.id
|
|
player.inventory.take_from(slot, 1)
|
|
if not player.worn.is_empty():
|
|
_give(player, player.worn, 1)
|
|
player.worn = id
|
|
player.worn_changed.emit()
|
|
Sfx.at("wear", player.global_position)
|
|
notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower())
|
|
return true
|
|
|
|
func take_off(player: Node3D) -> bool:
|
|
if player.worn.is_empty():
|
|
return false
|
|
if _online():
|
|
return _ask("take_off")
|
|
if player.inventory.space_for(player.worn) < 1:
|
|
notify.emit("Inventory full")
|
|
return false
|
|
_give(player, player.worn, 1)
|
|
player.worn = ""
|
|
player.worn_changed.emit()
|
|
return true
|
|
|
|
func forage(player: Node3D, bush: Node3D) -> bool:
|
|
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
|
return false
|
|
if _online():
|
|
return _ask("forage", [String(bush.uid)])
|
|
var got: Dictionary = bush.harvest()
|
|
for id in got:
|
|
_give(player, id, int(got[id]))
|
|
return not got.is_empty()
|
|
|
|
## Eats one of the food in `slot`.
|
|
func eat(player: Node3D, slot: int) -> bool:
|
|
if _online():
|
|
return _ask("eat", [slot])
|
|
var s = player.inventory.get_slot(slot)
|
|
if s == null:
|
|
return false
|
|
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
|
if food.is_empty():
|
|
return false
|
|
if player.survival.sick > 0.0:
|
|
tell(player, "You feel too sick to eat")
|
|
return false
|
|
if player.survival.hunger >= FULL_HUNGER:
|
|
notify.emit("You're full")
|
|
return false
|
|
player.inventory.take_from(slot, 1)
|
|
var eaten := String(s.id)
|
|
player.survival.eat(food)
|
|
Sfx.at("eat", player.global_position)
|
|
notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower())
|
|
ate.emit(player, eaten)
|
|
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
|
|
if food_rng.randf() < float(food.get("risk", 0.0)):
|
|
player.survival.poison()
|
|
tell(player, "Your stomach turns: food poisoning. Cook your meat!")
|
|
return true
|
|
|
|
## Rolls for food poisoning (tests seed it).
|
|
var food_rng := RandomNumberGenerator.new()
|
|
|
|
|
|
## Feeds a campfire from the player's inventory: wood first, then sticks.
|
|
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
|
if not is_instance_valid(fire) or not _in_reach(player, fire):
|
|
return false
|
|
if _online():
|
|
return _ask("fuel", [WorldNet.nid(fire)])
|
|
if fire.fuel >= fire.MAX_FUEL - 1.0:
|
|
notify.emit("The fire is fully stoked")
|
|
return false
|
|
for id in ["wood", "bear_fat", "stick", "fiber"]:
|
|
if player.inventory.count(id) > 0:
|
|
player.inventory.remove(id, 1)
|
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
|
Sfx.at("fuel", fire.global_position)
|
|
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
|
return true
|
|
notify.emit("Nothing to burn (wood or sticks)")
|
|
return false
|
|
|
|
## Moves between two inventories (quick-move / shift-click). The player must
|
|
## be next to the container.
|
|
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
|
if container != null and not _in_reach(player, container, 1.0):
|
|
return
|
|
if _online():
|
|
if container != null:
|
|
_ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
|
|
return
|
|
from.quick_move(slot, to)
|
|
|
|
# --- sounds (sfx.gd) --------------------------------------------------------------------
|
|
|
|
## The sound of a tool on a tree, a boulder or a bush; heavier when it falls.
|
|
static func harvest_sound(tool_kind: String, felled: bool) -> String:
|
|
match tool_kind:
|
|
"axe":
|
|
return "tree_fall" if felled else "chop"
|
|
"pickaxe":
|
|
return "rock_break" if felled else "mine"
|
|
return "rustle"
|
|
|
|
## A piece going down: wood, stone, or something soft.
|
|
static func place_sound(id: String) -> String:
|
|
if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]:
|
|
return "place_stone"
|
|
if id.contains("rug") or id.contains("bed") or id == "smoking_rack":
|
|
return "place_soft"
|
|
return "place_wood"
|