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)
|
||||
1
scripts/core/actions.gd.uid
Normal file
1
scripts/core/actions.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://nwo7dhy67nkv
|
||||
167
scripts/core/inventory.gd
Normal file
167
scripts/core/inventory.gd
Normal file
@@ -0,0 +1,167 @@
|
||||
class_name Inventory
|
||||
extends RefCounted
|
||||
## Slot-based inventory with stacking. Backs the player, chests and (later)
|
||||
## grave bags, so moving items is the same operation everywhere.
|
||||
##
|
||||
## Pure data: no nodes, no UI. Each slot is either null or {"id": String,
|
||||
## "count": int}. UI widgets render it and listen to `changed`; mutations from
|
||||
## gameplay go through Actions (see docs/SURVIVAL_DESIGN.md §3).
|
||||
|
||||
signal changed
|
||||
|
||||
var slots: Array = []
|
||||
|
||||
func _init(size: int = 40) -> void:
|
||||
slots.resize(size)
|
||||
|
||||
func size() -> int:
|
||||
return slots.size()
|
||||
|
||||
func get_slot(i: int):
|
||||
return slots[i]
|
||||
|
||||
func set_slot(i: int, stack) -> void:
|
||||
slots[i] = _norm(stack)
|
||||
changed.emit()
|
||||
|
||||
## Looked up at runtime rather than via the ItemDB global, so this plain data
|
||||
## class also compiles in contexts that load it before autoloads exist (tests,
|
||||
## a future headless server tool).
|
||||
static func _stack_max(id: String) -> int:
|
||||
var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
|
||||
return db.stack_size(id) if db != null else 50
|
||||
|
||||
static func _norm(stack):
|
||||
if stack == null or int(stack.count) <= 0:
|
||||
return null
|
||||
return {"id": String(stack.id), "count": int(stack.count)}
|
||||
|
||||
func count(id: String) -> int:
|
||||
var n := 0
|
||||
for s in slots:
|
||||
if s != null and s.id == id:
|
||||
n += s.count
|
||||
return n
|
||||
|
||||
## How many of `id` would fit right now.
|
||||
func space_for(id: String) -> int:
|
||||
var cap := _stack_max(id)
|
||||
var room := 0
|
||||
for s in slots:
|
||||
if s == null:
|
||||
room += cap
|
||||
elif s.id == id:
|
||||
room += cap - s.count
|
||||
return room
|
||||
|
||||
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
|
||||
## leftover that didn't fit (0 = everything fitted).
|
||||
func add(id: String, n: int) -> int:
|
||||
var cap := _stack_max(id)
|
||||
for s in slots:
|
||||
if n <= 0:
|
||||
break
|
||||
if s != null and s.id == id and s.count < cap:
|
||||
var take := mini(cap - s.count, n)
|
||||
s.count += take
|
||||
n -= take
|
||||
for i in range(slots.size()):
|
||||
if n <= 0:
|
||||
break
|
||||
if slots[i] == null:
|
||||
var take := mini(cap, n)
|
||||
slots[i] = {"id": id, "count": take}
|
||||
n -= take
|
||||
changed.emit()
|
||||
return n
|
||||
|
||||
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
|
||||
## items longest). Returns false and changes nothing if there aren't enough.
|
||||
func remove(id: String, n: int) -> bool:
|
||||
if count(id) < n:
|
||||
return false
|
||||
for i in range(slots.size() - 1, -1, -1):
|
||||
if n <= 0:
|
||||
break
|
||||
var s = slots[i]
|
||||
if s != null and s.id == id:
|
||||
var take := mini(s.count, n)
|
||||
s.count -= take
|
||||
n -= take
|
||||
if s.count <= 0:
|
||||
slots[i] = null
|
||||
changed.emit()
|
||||
return true
|
||||
|
||||
## Removes `n` from a specific slot. Returns how many were removed.
|
||||
func take_from(i: int, n: int) -> int:
|
||||
var s = slots[i]
|
||||
if s == null:
|
||||
return 0
|
||||
var take := mini(s.count, n)
|
||||
s.count -= take
|
||||
if s.count <= 0:
|
||||
slots[i] = null
|
||||
changed.emit()
|
||||
return take
|
||||
|
||||
func has_all(inputs: Dictionary) -> bool:
|
||||
for id in inputs:
|
||||
if count(id) < int(inputs[id]):
|
||||
return false
|
||||
return true
|
||||
|
||||
## Moves the stack in slot `i` into `other` (stacking where possible). Whatever
|
||||
## doesn't fit stays put. Used for shift-click quick-move.
|
||||
func quick_move(i: int, other: Inventory) -> void:
|
||||
var s = slots[i]
|
||||
if s == null:
|
||||
return
|
||||
var left := other.add(s.id, s.count)
|
||||
slots[i] = null if left <= 0 else {"id": s.id, "count": left}
|
||||
changed.emit()
|
||||
|
||||
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
|
||||
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
|
||||
## pick up half, or put down one.
|
||||
func click(i: int, cursor, right: bool):
|
||||
var s = slots[i]
|
||||
var result = cursor
|
||||
if cursor == null:
|
||||
if s == null:
|
||||
return null
|
||||
if right and s.count > 1:
|
||||
var half := int(ceil(s.count / 2.0))
|
||||
s.count -= half
|
||||
result = {"id": s.id, "count": half}
|
||||
else:
|
||||
result = s
|
||||
slots[i] = null
|
||||
elif s == null:
|
||||
if right:
|
||||
slots[i] = {"id": cursor.id, "count": 1}
|
||||
result = _norm({"id": cursor.id, "count": cursor.count - 1})
|
||||
else:
|
||||
slots[i] = cursor
|
||||
result = null
|
||||
elif s.id == cursor.id:
|
||||
var cap := _stack_max(s.id)
|
||||
var move: int = 1 if right else cursor.count
|
||||
move = mini(move, cap - s.count)
|
||||
s.count += move
|
||||
result = _norm({"id": cursor.id, "count": cursor.count - move})
|
||||
else:
|
||||
slots[i] = cursor
|
||||
result = s
|
||||
changed.emit()
|
||||
return result
|
||||
|
||||
## Serialisable snapshot (for saves and, later, network replication).
|
||||
func to_data() -> Array:
|
||||
return slots.duplicate(true)
|
||||
|
||||
func load_data(data: Array) -> void:
|
||||
slots.resize(data.size())
|
||||
for i in range(data.size()):
|
||||
slots[i] = _norm(data[i]) if data[i] != null else null
|
||||
changed.emit()
|
||||
1
scripts/core/inventory.gd.uid
Normal file
1
scripts/core/inventory.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b6q13eerjpee2
|
||||
135
scripts/core/item_db.gd
Normal file
135
scripts/core/item_db.gd
Normal file
@@ -0,0 +1,135 @@
|
||||
extends Node
|
||||
## Autoload "ItemDB": item and recipe definitions loaded from data/*.json, plus
|
||||
## procedurally rendered inventory icons.
|
||||
##
|
||||
## Definitions use the same shape as the addon SDK's define_item /
|
||||
## define_recipe (docs/ADDON_SDK.md), so addon content will later be
|
||||
## registered through `define_item()` / `define_recipe()` like the base game.
|
||||
|
||||
signal icons_ready
|
||||
|
||||
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
|
||||
const ICON_SIZE := 96
|
||||
|
||||
var items: Dictionary = {} # id -> definition Dictionary
|
||||
var recipes: Dictionary = {} # id -> recipe Dictionary (insertion order kept)
|
||||
var icons: Dictionary = {} # id -> Texture2D
|
||||
var _item_mat: StandardMaterial3D
|
||||
|
||||
func _ready() -> void:
|
||||
_load("res://data/items.json", "items", define_item)
|
||||
_load("res://data/recipes.json", "recipes", define_recipe)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
_render_icons.call_deferred()
|
||||
|
||||
func _load(path: String, key: String, define: Callable) -> void:
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
var data = JSON.parse_string(text)
|
||||
if typeof(data) != TYPE_DICTIONARY or not data.has(key):
|
||||
push_error("ItemDB: could not parse %s" % path)
|
||||
return
|
||||
for d in data[key]:
|
||||
define.call(d)
|
||||
|
||||
func define_item(d: Dictionary) -> void:
|
||||
for f in ["id", "name", "category", "stack"]:
|
||||
if not d.has(f):
|
||||
push_error("ItemDB: item missing '%s': %s" % [f, d])
|
||||
return
|
||||
d.stack = int(d.stack)
|
||||
items[d.id] = d
|
||||
|
||||
func define_recipe(r: Dictionary) -> void:
|
||||
if not items.has(r.get("output", "")):
|
||||
push_error("ItemDB: recipe '%s' outputs unknown item" % r.get("id", "?"))
|
||||
return
|
||||
for id in r.inputs:
|
||||
if not items.has(id):
|
||||
push_error("ItemDB: recipe '%s' uses unknown item '%s'" % [r.id, id])
|
||||
return
|
||||
r.count = int(r.get("count", 1))
|
||||
r.station = String(r.get("station", ""))
|
||||
recipes[r.id] = r
|
||||
|
||||
func get_item(id: String) -> Dictionary:
|
||||
return items.get(id, {})
|
||||
|
||||
func item_name(id: String) -> String:
|
||||
return items.get(id, {}).get("name", id)
|
||||
|
||||
func stack_size(id: String) -> int:
|
||||
return int(items.get(id, {}).get("stack", 1))
|
||||
|
||||
func mesh_for(id: String) -> ArrayMesh:
|
||||
return ItemMeshes.build(items.get(id, {}).get("mesh", id))
|
||||
|
||||
## Shared material for item meshes (vertex colours, authored as sRGB).
|
||||
func item_material() -> StandardMaterial3D:
|
||||
if _item_mat == null:
|
||||
_item_mat = StandardMaterial3D.new()
|
||||
_item_mat.vertex_color_use_as_albedo = true
|
||||
_item_mat.vertex_color_is_srgb = true
|
||||
_item_mat.roughness = 0.85
|
||||
return _item_mat
|
||||
|
||||
func icon(id: String) -> Texture2D:
|
||||
return icons.get(id, null)
|
||||
|
||||
# --- icon renderer ----------------------------------------------------------
|
||||
|
||||
## Renders every item mesh once in an offscreen viewport and keeps the images.
|
||||
func _render_icons() -> void:
|
||||
var vp := SubViewport.new()
|
||||
vp.size = Vector2i(ICON_SIZE, ICON_SIZE)
|
||||
vp.transparent_bg = true
|
||||
vp.own_world_3d = true
|
||||
vp.msaa_3d = Viewport.MSAA_4X
|
||||
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
add_child(vp)
|
||||
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_CLEAR_COLOR
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.75, 0.78, 0.85)
|
||||
env.ambient_light_energy = 0.7
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
vp.add_child(we)
|
||||
|
||||
var key := DirectionalLight3D.new()
|
||||
key.light_energy = 1.6
|
||||
key.rotation = Vector3(deg_to_rad(-50), deg_to_rad(-35), 0)
|
||||
vp.add_child(key)
|
||||
var fill := DirectionalLight3D.new()
|
||||
fill.light_energy = 0.5
|
||||
fill.light_color = Color(0.7, 0.8, 1.0)
|
||||
fill.rotation = Vector3(deg_to_rad(-20), deg_to_rad(140), 0)
|
||||
vp.add_child(fill)
|
||||
|
||||
var cam := Camera3D.new()
|
||||
cam.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
vp.add_child(cam)
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.material_override = item_material()
|
||||
vp.add_child(mi)
|
||||
|
||||
for id in items:
|
||||
var mesh := mesh_for(id)
|
||||
mi.mesh = mesh
|
||||
# Tools are long and thin: tilt them diagonally across the icon.
|
||||
var cat: String = items[id].category
|
||||
mi.rotation = Vector3(0, 0, deg_to_rad(-45)) if cat in ["tool", "weapon"] else Vector3(0, deg_to_rad(30), 0)
|
||||
var aabb := mi.transform * mesh.get_aabb()
|
||||
var c := aabb.get_center()
|
||||
var extent := maxf(aabb.size.length(), 0.05)
|
||||
cam.size = extent * 0.95
|
||||
cam.position = c + Vector3(0, 0.55, 1).normalized() * extent * 2.0
|
||||
cam.look_at(c, Vector3.UP)
|
||||
cam.near = 0.01
|
||||
cam.far = extent * 5.0
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
icons[id] = ImageTexture.create_from_image(vp.get_texture().get_image())
|
||||
vp.queue_free()
|
||||
icons_ready.emit()
|
||||
1
scripts/core/item_db.gd.uid
Normal file
1
scripts/core/item_db.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bv6di5sbrx4fj
|
||||
110
scripts/core/saves.gd
Normal file
110
scripts/core/saves.gd
Normal file
@@ -0,0 +1,110 @@
|
||||
extends Node
|
||||
## Autoload "Saves": world slots on disk.
|
||||
##
|
||||
## Each world is a folder under `root_dir` (user://worlds/<id>/) holding:
|
||||
## meta.json name, seed, created, last played, play time (read by the title screen)
|
||||
## world.json the game state, written by world.gd's collect_save()
|
||||
##
|
||||
## The world itself is regenerated from its seed, so a save only stores what
|
||||
## the player changed: their own state, placed pieces, chest contents, harvested
|
||||
## resources and collected pickups. That keeps saves small, and it's the same
|
||||
## data a dedicated server will persist in M2.
|
||||
##
|
||||
## Writes are atomic (temp file + rename) and the previous world.json is kept
|
||||
## as world.json.bak, so a crash mid-save can't destroy a world.
|
||||
|
||||
const VERSION := 1
|
||||
|
||||
var root_dir := "user://worlds"
|
||||
var current_id := "" ## world being played; "" = unsaved session (title screen, tests)
|
||||
|
||||
func has_world() -> bool:
|
||||
return not current_id.is_empty()
|
||||
|
||||
func world_seed() -> int:
|
||||
return int(read_meta(current_id).get("seed", 1337))
|
||||
|
||||
func _dir(id: String) -> String:
|
||||
return root_dir.path_join(id)
|
||||
|
||||
func list_worlds() -> Array:
|
||||
var out: Array = []
|
||||
var d := DirAccess.open(root_dir)
|
||||
if d == null:
|
||||
return out
|
||||
for id in d.get_directories():
|
||||
var m := read_meta(id)
|
||||
if not m.is_empty():
|
||||
m["id"] = id
|
||||
out.append(m)
|
||||
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
|
||||
return out
|
||||
|
||||
func create_world(world_name: String, seed: int) -> String:
|
||||
DirAccess.make_dir_recursive_absolute(root_dir)
|
||||
var base := world_name.to_lower().replace(" ", "_").validate_filename()
|
||||
if base.is_empty():
|
||||
base = "world"
|
||||
var id := base
|
||||
var n := 2
|
||||
while DirAccess.dir_exists_absolute(_dir(id)):
|
||||
id = "%s_%d" % [base, n]
|
||||
n += 1
|
||||
DirAccess.make_dir_recursive_absolute(_dir(id))
|
||||
var now := Time.get_unix_time_from_system()
|
||||
_write_json(_dir(id).path_join("meta.json"), {
|
||||
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
||||
"play_time": 0.0, "version": VERSION,
|
||||
})
|
||||
return id
|
||||
|
||||
func delete_world(id: String) -> void:
|
||||
var dir := _dir(id)
|
||||
var d := DirAccess.open(dir)
|
||||
if d == null:
|
||||
return
|
||||
for f in d.get_files():
|
||||
d.remove(f)
|
||||
DirAccess.remove_absolute(dir)
|
||||
if current_id == id:
|
||||
current_id = ""
|
||||
|
||||
func read_meta(id: String) -> Dictionary:
|
||||
return _read_json(_dir(id).path_join("meta.json"))
|
||||
|
||||
func load_state(id: String) -> Dictionary:
|
||||
var s := _read_json(_dir(id).path_join("world.json"))
|
||||
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")):
|
||||
push_warning("Saves: world.json unreadable, using backup")
|
||||
s = _read_json(_dir(id).path_join("world.json.bak"))
|
||||
return s
|
||||
|
||||
## Writes the state for `id` and updates its meta. Returns true on success.
|
||||
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool:
|
||||
var path := _dir(id).path_join("world.json")
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.copy_absolute(path, path + ".bak")
|
||||
state["version"] = VERSION
|
||||
if not _write_json(path, state):
|
||||
return false
|
||||
var m := read_meta(id)
|
||||
m["last_played"] = Time.get_unix_time_from_system()
|
||||
m["play_time"] = float(m.get("play_time", 0.0)) + session_seconds
|
||||
_write_json(_dir(id).path_join("meta.json"), m)
|
||||
return true
|
||||
|
||||
func _write_json(path: String, data: Dictionary) -> bool:
|
||||
var tmp := path + ".tmp"
|
||||
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("Saves: can't write %s (%s)" % [tmp, error_string(FileAccess.get_open_error())])
|
||||
return false
|
||||
f.store_string(JSON.stringify(data, "\t"))
|
||||
f.close()
|
||||
return DirAccess.rename_absolute(tmp, path) == OK
|
||||
|
||||
func _read_json(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
return data if typeof(data) == TYPE_DICTIONARY else {}
|
||||
1
scripts/core/saves.gd.uid
Normal file
1
scripts/core/saves.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c5tln66oif2hb
|
||||
Reference in New Issue
Block a user