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]) 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:". 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 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)