- Rabbits, deer and boars with procedural meshes and animated legs, a state machine (idle/wander/flee/charge/dead) and heightmap steering that avoids water, cliffs, the world edge, trees and buildings - Spawner keeps a habitat-based budget around the player (6/4/3) - Melee needs a weapon or tool (club, spear, axe, pickaxe); box sweep hits - Wooden spear (thrust or throw) and crude bow with draw-scaled arrows; projectiles stick in the world as saved pickups - Carcasses: E to butcher for meat, hide and embedded arrows - Boars charge and gore when hit; hurt flash and a brief healing pause - Cooked meat gives Well fed (faster stamina and healing); buffs saved - Hide cloak: wear for warmth, saved, goes in the grave on death - Feathers as meadow pickups (appended, existing save ids unchanged) - New test_creatures_combat suite; run_all.sh gets a per-suite time limit
369 lines
14 KiB
GDScript
369 lines
14 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)
|
|
|
|
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
|
|
|
|
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:
|
|
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)
|
|
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
|
|
var id: String = pickup_node.item_id
|
|
if _give(player, id, pickup_node.amount) <= 0:
|
|
return false
|
|
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 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(6.0):
|
|
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]))
|
|
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 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)
|
|
world.spawn_piece(def, xf)
|
|
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 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)
|
|
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
|
|
door.set_open(not door.open)
|
|
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
|
|
var world: Node = bed.get_parent().get_parent()
|
|
if not bed.is_spawn():
|
|
world.set_bed_spawn(bed)
|
|
notify.emit("Spawn point set")
|
|
return true
|
|
var why: String = world.sleep_block_reason(bed)
|
|
if not why.is_empty():
|
|
notify.emit(why)
|
|
return false
|
|
world.sleep(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}
|
|
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 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
|
|
target.take_hit(m.damage, player.global_position)
|
|
if target.is_dead():
|
|
notify.emit("Killed a %s" % target.display_name().to_lower())
|
|
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()
|
|
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
|
|
if was_alive and target.is_dead():
|
|
notify.emit("Killed a %s" % target.display_name().to_lower())
|
|
|
|
## Throws the spear in `slot` from `origin` along `dir`.
|
|
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
|
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
|
|
player.inventory.take_from(slot, 1)
|
|
world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
|
|
float(w.throw.damage), player)
|
|
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 bow.is_empty():
|
|
return false
|
|
if player.inventory.count(bow.ammo) <= 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:
|
|
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):
|
|
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)
|
|
return true
|
|
|
|
func butcher(player: Node3D, carcass: Node3D) -> bool:
|
|
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")
|
|
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
|
|
|
|
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
|
|
func wear(player: Node3D, slot: int) -> bool:
|
|
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()
|
|
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 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
|
|
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:
|
|
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.hunger >= FULL_HUNGER:
|
|
notify.emit("You're full")
|
|
return false
|
|
player.inventory.take_from(slot, 1)
|
|
player.survival.eat(food)
|
|
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
|
return true
|
|
|
|
|
|
## 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 fire.fuel >= fire.MAX_FUEL - 1.0:
|
|
notify.emit("The fire is fully stoked")
|
|
return false
|
|
for id in ["wood", "stick", "fiber"]:
|
|
if player.inventory.count(id) > 0:
|
|
player.inventory.remove(id, 1)
|
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
|
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
|
|
from.quick_move(slot, to)
|