M1a: survival core, pause menu, title screen and save/load
Design: docs/SURVIVAL_DESIGN.md (core loop, systems, architecture, phases). Survival core (M1a): - Items/recipes as data (data/*.json) in the addon SDK's define_item / define_recipe shape; procedural item meshes and offscreen-rendered icons - Inventory class (40 slots, hotbar = row 1) shared by player and chests; click / right-click split / shift-click quick-move - Actions autoload: the intent layer (craft, pickup, harvest_hit, place, transfer) that validates every change; becomes the RPC surface in M2 - Gathering: ground pickups (sticks, stones, flint); trees chopped with an axe fall and leave stumps; boulders mined with a pickaxe shrink per hit - Crafting screen with station checks (workbench); placement mode with green/red ghost, rotation and overlap/slope validation - Pieces: campfire (light, flames, embers), workbench, chest (16 slots) - HUD: hotbar, held tool view model with swing, prompts, toasts Menus and saving: - ESC pause menu: resume, controls, fullscreen, mouse sensitivity, quit - Title screen over a flyover of the island: Continue / New World (name + seed) / Load World (with delete) / Quit - Saves autoload: one folder per world under user://worlds, atomic writes with a .bak; stores only player changes (player, pieces, chest contents, harvested resources, taken pickups) and regenerates the rest from the seed - Autosave every 2 min, Save & Quit to title/desktop, save on window close Fixes: UI screens now fill the viewport (were pinned top-left); title backdrop no longer captures the mouse. Tests: test_survival_core (31 checks), test_save_load (round trip + slots), test_terrain_edges now checks 8 seeds for deep pits / unbanked water.
This commit is contained in:
149
scripts/core/actions.gd
Normal file
149
scripts/core/actions.gd
Normal file
@@ -0,0 +1,149 @@
|
||||
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
|
||||
|
||||
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("cancel", 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 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("Need a %s" % ("stone " + node.tool_kind))
|
||||
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
|
||||
|
||||
## 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)
|
||||
Reference in New Issue
Block a user