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 ## 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 const NOT_ONLINE := "Hunting and fishing aren't on servers yet (they come with the animals)" func _not_online() -> bool: notify.emit(NOT_ONLINE) return false 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:". 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) 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 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 _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(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 _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) 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 _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) 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) 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") 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 _online(): return _not_online() 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: if _online(): return _not_online() 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: if _online(): return _not_online() 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: if _online(): return _not_online() 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 _online(): return _not_online() 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 # --- fishing ---------------------------------------------------------------------- ## Casts the held rod from `origin` along `dir`. func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool: if _online(): return _not_online() 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))) 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 _not_online() var b: Node3D = world.bobber if b == null or not is_instance_valid(b): return false var caught := "" if b.state == "biting": caught = b.fish world.end_fishing() 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()) 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() 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.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 _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", "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 if _online(): if container != null: _ask("transfer", [WorldNet.nid(container), slot, from == player.inventory]) return from.quick_move(slot, to)