diff --git a/ROADMAP.md b/ROADMAP.md index 838a7c8..8b3f47c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,9 +72,9 @@ Property names and file references are included so each item is easy to find. ## Gameplay - [x] World saving/loading — `WorldStorage` stores immutable worldgen metadata and deterministic sparse final block edits in ZSTD region files; `VoxelWorld` hydrates edits on the main thread before worker snapshots and evicts unloaded disk-backed edit buckets. Continue Last World restores the most recent save, and autosave/exit/new-world flows flush dirty regions -- [ ] World management UI — Continue Last World is available, but a browsable world list with rename/duplicate/delete/manual-backup controls and an autosave-interval setting remains -- [x] Versioned save format and migration — metadata and region files carry independent magic/version/layout fields; unsupported future or malformed data is rejected, writes use temp/backup replacement, and corrupt primaries recover without discarding the only valid backup. The clean-region cache is bounded to 16 entries and dirty regions are never evicted -- [x] Persist time, weather, inventory, and player state — save state includes player position/flying, inventory, selected slot, spawn, day/night time, moon phase, and weather +- [x] World management UI — the Play hub lists every saved world and supports create/load, rename, independent duplication, deletion with confirmation, and timestamped manual backups under `user://world_backups`; Gameplay settings provide Off/30 s/1/2/5/10 min autosave intervals while leave/quit saves remain unconditional +- [x] Versioned save format and migration — metadata and region files carry independent magic/version headers, metadata freezes chunk/world/region layout, the region v1 reader migrates legacy edit records into the current v2 palette/RLE representation on rewrite, and gameplay state now has an explicit schema revision with a v0 migration. Unsupported future or malformed data is rejected, writes use temp/backup replacement, corrupt primaries recover without discarding the only valid backup, and an unreadable region rejects edits to that region without blocking unrelated regions or session state. The clean-region cache is bounded to 16 entries and dirty regions are never evicted +- [x] Persist time, weather, inventory, and player state — versioned save state includes validated player position/flying/spawn, inventory, selected slot, day/night time, moon phase, weather target and rain-transition progress; missing legacy inventory retains defaults and restored weather grading/audio is applied immediately - [ ] Survival layer — health/hunger/damage with respawn and damage sources (fall, drowning, lava/fire); `Player` has no health, falling below `FALL_RESET_Y` just teleports - [ ] Tools, hardness, and durability — per-block hardness/break time and tool tiers in `BlockRegistry.BLOCK_DEFS`, tool stacks with durability; mining is instant today (`Player._break_target()` -> `VoxelWorld.break_block()`) - [ ] Item inventory and crafting — stack grid with drag/drop beyond the fixed `Main.HOTBAR`, crafting recipes/table, furnaces, and chests; `Main.inventory` is only an id->count dictionary rendered by `InventoryOverlay` diff --git a/autoload/game_config.gd b/autoload/game_config.gd index a1bc125..7363d38 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -20,6 +20,7 @@ const DEFAULT_SETTINGS := { "input_bindings": {}, "ui_scale": 1.0, "text_scale": 1.0, + "autosave_interval": 30, } # Interface scale. UI scale drives the window's canvas-item content scale, so @@ -48,6 +49,8 @@ const DYNAMIC_RESOLUTION_UP_MARGIN := 0.85 const LOD_MODE_FULL := 0 const LOD_MODE_BALANCED := 1 const LOD_MODE_NAMES := ["Full Detail", "Balanced LOD"] +const AUTOSAVE_INTERVAL_VALUES := [0, 30, 60, 120, 300, 600] +const AUTOSAVE_INTERVAL_NAMES := ["Off", "30 Seconds", "1 Minute", "2 Minutes", "5 Minutes", "10 Minutes"] # Rebindable input actions in menu order. The InputMap's non-`ui_*` actions are # the source of truth: anything missing from this table still gets a row with a @@ -228,6 +231,9 @@ func set_setting(key: String, value: Variant) -> void: apply_frame_pacing() elif key == "ui_scale" or key == "text_scale": apply_ui_scale() + elif key == "autosave_interval": + settings[key] = nearest_option(value, AUTOSAVE_INTERVAL_VALUES) + save_settings() ## Applies the interface scale. The window's canvas-item content scale resizes @@ -484,6 +490,11 @@ func get_mouse_sensitivity() -> float: return float(settings.get("mouse_sensitivity", DEFAULT_SETTINGS["mouse_sensitivity"])) +func get_autosave_interval() -> float: + return float(nearest_option(settings.get("autosave_interval", DEFAULT_SETTINGS["autosave_interval"]), + AUTOSAVE_INTERVAL_VALUES)) + + func is_fullscreen() -> bool: return bool(settings.get("fullscreen", DEFAULT_SETTINGS["fullscreen"])) @@ -531,6 +542,7 @@ func load_settings() -> void: settings["dynamic_resolution_target"] = nearest_option(settings.get("dynamic_resolution_target", DEFAULT_SETTINGS["dynamic_resolution_target"]), DYNAMIC_RESOLUTION_TARGET_VALUES) settings["ui_scale"] = nearest_option(settings.get("ui_scale", DEFAULT_SETTINGS["ui_scale"]), UI_SCALE_VALUES) settings["text_scale"] = nearest_option(settings.get("text_scale", DEFAULT_SETTINGS["text_scale"]), TEXT_SCALE_VALUES) + settings["autosave_interval"] = nearest_option(settings.get("autosave_interval", DEFAULT_SETTINGS["autosave_interval"]), AUTOSAVE_INTERVAL_VALUES) _sanitize_input_bindings() apply_window_mode() apply_frame_pacing() diff --git a/game/main.gd b/game/main.gd index a713835..8a4f529 100644 --- a/game/main.gd +++ b/game/main.gd @@ -22,7 +22,8 @@ const UNDERWATER_DEEP_COLOR := Color(0.02, 0.08, 0.16) const CAVE_FADE_SECONDS := 0.65 const DYNAMIC_RESOLUTION_INTERVAL := 0.5 const DYNAMIC_RESOLUTION_SMOOTHING := 0.2 -const AUTOSAVE_INTERVAL := 30.0 +const SESSION_STATE_VERSION := 1 +const AUTOSAVE_RETRY_SECONDS := 30.0 @onready var world: VoxelWorld = $World @onready var player: Player = $Player @@ -70,6 +71,7 @@ var _dynamic_resolution_timer := 0.0 var _dynamic_resolution_active := false var _world_storage: WorldStorage var _autosave_time := 0.0 +var _storage_warning_shown := false func _ready() -> void: @@ -86,7 +88,9 @@ func _ready() -> void: _inventory_overlay.opened.connect(_on_inventory_opened) _inventory_overlay.closed.connect(_on_inventory_closed) _inventory_overlay.time_selected.connect(_on_inventory_time_selected) - var resumed := not saved_state.is_empty() and player.restore_persistent_state(saved_state.get("player", {})) + var player_state: Variant = saved_state.get("player", {}) + var resumed := typeof(player_state) == TYPE_DICTIONARY \ + and player.restore_persistent_state(player_state) if resumed: world.setup_player(player, true) # Recover saves written after the player had already entered unloaded or @@ -95,7 +99,6 @@ func _ready() -> void: if not world.is_player_volume_clear(player.global_position): var recovered_spawn := world.find_safe_spawn(player.global_position) player.global_position = recovered_spawn - player.spawn_position = recovered_spawn player.velocity = Vector3.ZERO else: var spawn := world.get_spawn_position() @@ -228,9 +231,12 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: if not get_tree().paused: _autosave_time += delta - if _autosave_time >= AUTOSAVE_INTERVAL: - _autosave_time = 0.0 - _flush_world_save() + var autosave_interval := GameConfig.get_autosave_interval() + if autosave_interval > 0.0 and _autosave_time >= autosave_interval: + if _flush_world_save(): + _autosave_time = 0.0 + else: + _autosave_time = maxf(autosave_interval - minf(AUTOSAVE_RETRY_SECONDS, autosave_interval), 0.0) if status_time > 0.0: status_time -= delta if status_time <= 0.0: @@ -339,14 +345,27 @@ func _prepare_world_storage() -> Dictionary: GameConfig.clear_active_world() return {} GameConfig.activate_world(metadata) - return (metadata.get("state", {}) as Dictionary).duplicate(true) + return _migrate_session_state(metadata.get("state", {})) + + +func _migrate_session_state(value: Variant) -> Dictionary: + if typeof(value) != TYPE_DICTIONARY: + return {} + var state: Dictionary = value.duplicate(true) + var version := int(state.get("state_version", 0)) + if version > SESSION_STATE_VERSION: + push_warning("Save uses unsupported session state version %d" % version) + return {} + # Version 0 saves used the same fields but did not carry an explicit tag. + state["state_version"] = SESSION_STATE_VERSION + return state func _restore_session_state(state: Dictionary) -> void: if state.is_empty(): return - var saved_inventory: Variant = state.get("inventory", []) - if typeof(saved_inventory) == TYPE_ARRAY: + var saved_inventory: Variant = state.get("inventory", null) + if state.has("inventory") and typeof(saved_inventory) == TYPE_ARRAY: var restored_inventory: Dictionary = {} for entry in saved_inventory: if typeof(entry) != TYPE_ARRAY or entry.size() != 2: @@ -363,6 +382,8 @@ func _restore_session_state(state: Dictionary) -> void: var weather_state: Variant = state.get("weather", {}) if typeof(weather_state) == TYPE_DICTIONARY: _weather.restore_persistent_state(weather_state) + _day_night.set_time(_day_night.time_hours) + _on_weather_changed(_weather.state) func _build_persistent_state() -> Dictionary: @@ -374,6 +395,7 @@ func _build_persistent_state() -> Dictionary: for item_id in item_ids: inventory_rows.append([item_id, int(inventory[item_id])]) return { + "state_version": SESSION_STATE_VERSION, "player": player.persistent_state(), "inventory": inventory_rows, "selected_slot": selected_slot, @@ -382,17 +404,22 @@ func _build_persistent_state() -> Dictionary: } -func _flush_world_save() -> void: +func _flush_world_save() -> bool: if _world_storage == null or player == null: - return + return false var save_error := world.flush_edit_store() if save_error == OK: save_error = _world_storage.flush(_build_persistent_state()) if save_error == OK: GameConfig.active_world_metadata = _world_storage.metadata.duplicate(true) + if not _storage_warning_shown and _world_storage.unreadable_region_count() > 0: + _storage_warning_shown = true + set_status("A damaged world region is read-only; edits there cannot be saved") + return true else: push_warning("World save failed with error %d" % save_error) set_status("Save failed - progress remains in memory") + return false func _apply_graphics() -> void: diff --git a/player/player.gd b/player/player.gd index e00771f..d46ade8 100644 --- a/player/player.gd +++ b/player/player.gd @@ -96,6 +96,8 @@ func restore_persistent_state(state: Dictionary) -> bool: var restored := Vector3(float(position_value[0]), float(position_value[1]), float(position_value[2])) if not is_finite(restored.x) or not is_finite(restored.y) or not is_finite(restored.z): return false + if restored.y < 0.0: + return false global_position = restored rotation.y = float(state.get("yaw", 0.0)) if head != null: @@ -105,7 +107,8 @@ func restore_persistent_state(state: Dictionary) -> bool: var spawn_value: Variant = state.get("spawn", []) if typeof(spawn_value) == TYPE_ARRAY and spawn_value.size() == 3: var restored_spawn := Vector3(float(spawn_value[0]), float(spawn_value[1]), float(spawn_value[2])) - if is_finite(restored_spawn.x) and is_finite(restored_spawn.y) and is_finite(restored_spawn.z): + if is_finite(restored_spawn.x) and is_finite(restored_spawn.y) and is_finite(restored_spawn.z) \ + and restored_spawn.y >= 0.0: spawn_position = restored_spawn else: spawn_position = restored diff --git a/tools/player_target_verify.gd b/tools/player_target_verify.gd index ca28e85..aefaafa 100644 --- a/tools/player_target_verify.gd +++ b/tools/player_target_verify.gd @@ -91,6 +91,14 @@ func _ready() -> void: add_child(flight_player) flight_player.set_physics_process(false) flight_player.world = world + if not flight_player.restore_persistent_state({ + "position": [15.75, 210.0, 8.0], + "spawn": [15.75, 205.0, 8.0], + "flying": true, + }) or not is_equal_approx(flight_player.global_position.y, 210.0) \ + or not is_equal_approx(flight_player.spawn_position.y, 205.0): + push_error("player_target_verify: legitimate above-ceiling flight state was rejected") + failed = true flight_player.flying = true flight_player.global_position = Vector3(15.75, 70.0, 8.0) flight_player.velocity = Vector3(10.0, -10.0, 0.0) diff --git a/tools/ui_flow_verify.gd b/tools/ui_flow_verify.gd index 8139216..437d6bc 100644 --- a/tools/ui_flow_verify.gd +++ b/tools/ui_flow_verify.gd @@ -77,6 +77,8 @@ func _run() -> void: "incompatible world incorrectly enabled loading") _expect(not play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteButton").disabled, "incompatible world did not enable deletion") + _expect(play._rename_button.disabled and play._duplicate_button.disabled and play._backup_button.disabled, + "incompatible world enabled management operations") # Disconnect the menu's scene-changing handler while verifying the panel's # public signal in isolation. @@ -86,6 +88,27 @@ func _run() -> void: var beta_card: Button = play._cards.get("beta") if beta_card != null: beta_card.pressed.emit() + _expect(not play._rename_button.disabled and not play._duplicate_button.disabled and not play._backup_button.disabled, + "compatible world did not enable management operations") + play._rename_button.pressed.emit() + await process_frame + _expect(play._editing_rename and root.gui_get_focus_owner() == play._rename_field, + "rename did not open its editor and focus the name field") + play._rename_field.text = "Renamed Shore" + play._rename_confirm_button.pressed.emit() + await process_frame + _expect(String((WorldStorage.new(_library_root).open_world("beta")).get("name", "")) == "Renamed Shore", + "world rename did not persist through the management UI") + play._select_world("beta") + play._duplicate_button.pressed.emit() + await process_frame + var copy_id: String = play._selected_world_id + _expect(not copy_id.is_empty() and copy_id != "beta" and play._cards.size() == 4, + "world duplication did not refresh and select the new copy") + if not copy_id.is_empty() and copy_id != "beta": + _expect(WorldStorage.delete_world(copy_id, _library_root), "could not clean up duplicated UI fixture") + play._refresh_load_view() + play._select_world("beta") play.get_node("Center/Panel/Box/LoadBox/LoadFooter/LoadButton").pressed.emit() _expect(loaded_ids == ["beta"], "selected compatible world did not emit load_requested") @@ -149,7 +172,14 @@ func _run() -> void: _expect(settings.visible, "Settings did not open the hub") var display: Node = settings.get_node("DisplayCategory") var graphics: Node = settings.get_node("GraphicsCategory") + var gameplay: Node = settings.get_node("GameplayCategory") var advanced: Node = settings.get_node("GraphicsPanel") + var gameplay_rows: Array = gameplay._category_definition()["rows"] + var has_autosave := false + for row in gameplay_rows: + if String(row.get("key", "")) == "autosave_interval": + has_autosave = true + _expect(has_autosave, "Gameplay settings omitted the autosave interval") display.open_panel() await process_frame _expect(display.visible and settings.visible, "display category did not open over the hub") diff --git a/tools/weather_verify.gd b/tools/weather_verify.gd index 49abe1c..4f75a07 100644 --- a/tools/weather_verify.gd +++ b/tools/weather_verify.gd @@ -17,6 +17,7 @@ func _initialize() -> void: func _verify() -> void: _check_biome_classification() _check_particles() + _check_persistence() _check_lightning_schedule() _check_lightning_flash() _check_wind_wiring() @@ -76,6 +77,20 @@ func _check_particles() -> void: weather.free() +func _check_persistence() -> void: + var source := WeatherSystemScript.new() + source.state = WeatherSystemScript.State.RAIN + source.rain_amount = 0.42 + var restored := WeatherSystemScript.new() + restored.restore_persistent_state(source.persistent_state()) + _expect(restored.state == WeatherSystemScript.State.RAIN, + "weather target state did not round-trip") + _expect(is_equal_approx(restored.rain_amount, 0.42), + "weather transition progress did not round-trip") + source.free() + restored.free() + + func _check_lightning_schedule() -> void: var weather := WeatherSystemScript.new() weather.name = "LightningVerify" diff --git a/tools/world_storage_verify.gd b/tools/world_storage_verify.gd index 212fb09..074da69 100644 --- a/tools/world_storage_verify.gd +++ b/tools/world_storage_verify.gd @@ -9,6 +9,7 @@ func _initialize() -> void: _verify_round_trip_and_regions() _verify_future_metadata_rejection() _verify_library_listing_and_delete() + _verify_world_management() _verify_v2_compression_round_trip_and_corruption() _verify_generation_edit_priority() if _failures.is_empty(): @@ -144,6 +145,15 @@ func _verify_round_trip_and_regions() -> void: var rejected := WorldStorage.new(_root) rejected.open_world("verify-world") _expect(rejected.load_chunk_edits(chunk_a).is_empty(), "future region version was not rejected") + var unreadable_bytes := FileAccess.get_file_as_bytes(negative_region_path) + _expect(rejected.unreadable_region_count() == 1, + "unreadable region was not exposed for one-time player feedback") + rejected.stage_chunk_edits(chunk_a, edits_a) + rejected.stage_chunk_edits(chunk_c, edits_c) + _expect(rejected.flush_dirty_regions() == OK, + "an unreadable region blocked unrelated region saves") + _expect(FileAccess.get_file_as_bytes(negative_region_path) == unreadable_bytes, + "an unreadable region was overwritten after staging an edit") # Metadata also recovers from a syntactically valid but structurally corrupt primary. var metadata_path := _root + "/verify-world/metadata.json" @@ -200,6 +210,45 @@ func _verify_library_listing_and_delete() -> void: "deleting the last-played world left a stale pointer") +func _verify_world_management() -> void: + var storage := WorldStorage.new(_root) + var metadata := storage.create_world({"seed": 2468}, {"inventory": [[1, 7]]}, "manage-world") + _expect(not metadata.is_empty(), "could not create world-management fixture") + storage.stage_chunk_edits(Vector2i.ZERO, {Vector3i(1, 2, 3): BlockRegistry.BLOCK_STONE}) + _expect(storage.flush(metadata.get("state", {})) == OK, "could not flush world-management fixture") + _expect(WorldStorage.rename_world("manage-world", "Managed World", _root), "world rename failed") + var renamed := WorldStorage.new(_root).open_world("manage-world") + _expect(String(renamed.get("name", "")) == "Managed World", "renamed title did not persist") + _expect(not WorldStorage.rename_world("manage-world", "", _root), "empty world name was accepted") + var maximum_name := "W".repeat(64) + _expect(WorldStorage.rename_world("manage-world", maximum_name, _root), + "maximum-length world name was rejected") + _expect(WorldStorage.new(_root).create_world({"seed": 1}, {}, "manage-world").is_empty(), + "world creation overwrote an existing requested id") + + var duplicated := WorldStorage.duplicate_world("manage-world", _root) + var duplicate_id := String(duplicated.get("id", "")) + _expect(not duplicate_id.is_empty() and duplicate_id != "manage-world", "world duplication did not create a new id") + _expect(String(WorldStorage.latest_world_metadata(_root).get("id", "")) == "manage-world", + "management operation changed Continue Last World to an unplayed duplicate") + var duplicate_storage := WorldStorage.new(_root) + _expect(not duplicate_storage.open_world(duplicate_id).is_empty(), "duplicated world could not be opened") + _expect(duplicate_storage.load_chunk_edits(Vector2i.ZERO) == { + Vector3i(1, 2, 3): BlockRegistry.BLOCK_STONE, + }, "duplicated world lost region edits") + _expect(String(duplicated.get("name", "")) == "%s Copy" % maximum_name.substr(0, 59), + "duplicated world name did not stay within the rename limit") + + var backup_root := _root + "_backups" + var backup_path := WorldStorage.backup_world("manage-world", _root, backup_root) + _expect(not backup_path.is_empty() and FileAccess.file_exists(backup_path + "/metadata.json"), + "manual world backup did not copy metadata") + _expect(FileAccess.file_exists(backup_path + "/regions/r.0.0.rcregion"), + "manual world backup did not copy region data") + _expect(WorldStorage.new(_root).open_world("../manage-world").is_empty(), + "world opening accepted a traversal id") + + func _verify_v2_compression_round_trip_and_corruption() -> void: var storage := WorldStorage.new(_root) var config := WorldGenConfig.new({"seed": 13579}).to_dictionary() diff --git a/ui/play_panel.gd b/ui/play_panel.gd index b4a3d1d..f0cf3c5 100644 --- a/ui/play_panel.gd +++ b/ui/play_panel.gd @@ -4,7 +4,7 @@ extends Control ## Play hub. The landing offers Continue / New World / Load World; New World ## reveals the creation form (world type, seed, clipboard import, and the ## Advanced world-gen screen), Load World lists every saved world with -## metadata and supports select-to-load plus delete-with-confirmation. +## metadata and supports loading, renaming, duplication, backup, and deletion. ## `ui_cancel` unwinds one layer at a time: delete confirmation -> load list ## or create form -> landing -> closed. The world-gen screen handles its own ## cancel first because it sits above this panel in the tree. @@ -74,10 +74,21 @@ var _continue_world_id := "" var _cards: Dictionary = {} var _world_names: Dictionary = {} var _landing_return: Control = null +var _manage_row: HBoxContainer +var _rename_button: Button +var _duplicate_button: Button +var _backup_button: Button +var _rename_row: HBoxContainer +var _rename_field: LineEdit +var _rename_cancel_button: Button +var _rename_confirm_button: Button +var _operation_status: Label +var _editing_rename := false func _ready() -> void: UITheme.apply(self) + _build_management_controls() _style_static() _load_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED for type_name in WORLD_TYPES: @@ -117,12 +128,15 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() if _confirming_delete: _cancel_delete() + elif _editing_rename: + _cancel_rename() elif _view == View.LANDING: close_panel() else: _back_to_landing(_new_world_button if _view == View.CREATE else _load_world_button) return - if _view == View.LOAD and not _confirming_delete and not _selected_world_id.is_empty() \ + if _view == View.LOAD and not _confirming_delete and not _editing_rename \ + and not _selected_world_id.is_empty() \ and event is InputEventKey and event.pressed and not event.echo: if (event as InputEventKey).keycode == KEY_DELETE: get_viewport().set_input_as_handled() @@ -133,6 +147,7 @@ func open_panel() -> void: _landing_return = null _confirming_delete = false _confirming_delete_all = false + _editing_rename = false visible = true _show_view(View.LANDING) Motion.dim_in(_dim) @@ -144,6 +159,7 @@ func close_panel() -> void: return _confirming_delete = false _confirming_delete_all = false + _editing_rename = false visible = false closed.emit() @@ -247,7 +263,10 @@ func _refresh_load_view() -> void: _selected_world_id = "" _confirming_delete = false _confirming_delete_all = false + _editing_rename = false _confirm_row.visible = false + _rename_row.visible = false + _manage_row.visible = true _load_footer.visible = true _confirm_delete_button.text = "Delete" var worlds := WorldStorage.list_world_summaries(_library_root) @@ -270,6 +289,8 @@ func _refresh_load_view() -> void: else "No saved worlds to delete" _load_button.disabled = true _load_button.tooltip_text = "Select a world to load" + _set_management_enabled(false) + _operation_status.text = "" _apply_view_metrics() if has_worlds: (_cards.values()[0] as Button).grab_focus() @@ -403,7 +424,7 @@ func _style_card(card: Button, selected: bool) -> void: func _on_card_activated(id: String) -> void: - if _confirming_delete: + if _confirming_delete or _editing_rename: return if id == _selected_world_id: _load_selected() @@ -412,7 +433,7 @@ func _on_card_activated(id: String) -> void: func _on_card_gui_input(event: InputEvent, id: String) -> void: - if _confirming_delete: + if _confirming_delete or _editing_rename: return if event is InputEventMouseButton and (event as InputEventMouseButton).double_click: _select_world(id) @@ -430,10 +451,11 @@ func _select_world(id: String) -> void: _load_button.disabled = not compatible _load_button.tooltip_text = "Load the selected world" if compatible \ else "The selected world was saved by a newer version" + _set_management_enabled(compatible) func _load_selected() -> void: - if _confirming_delete or _selected_world_id.is_empty(): + if _confirming_delete or _editing_rename or _selected_world_id.is_empty(): return var card := _cards.get(_selected_world_id) as Button if card == null or not bool(card.get_meta("compatible", true)): @@ -442,20 +464,21 @@ func _load_selected() -> void: func _begin_delete_confirmation() -> void: - if _confirming_delete or _selected_world_id.is_empty(): + if _confirming_delete or _editing_rename or _selected_world_id.is_empty(): return _confirming_delete = true _confirming_delete_all = false var world_name := String(_world_names.get(_selected_world_id, "this world")) _confirm_label.text = "Delete \"%s\"? Its blocks and progress are removed permanently." % world_name _load_footer.visible = false + _manage_row.visible = false _confirm_row.visible = true # The safe choice takes focus first; the destructive one is one Tab away. _cancel_delete_button.grab_focus() func _begin_delete_all_confirmation() -> void: - if _confirming_delete: + if _confirming_delete or _editing_rename: return var world_count := WorldStorage.list_world_summaries(_library_root).size() if world_count == 0: @@ -468,6 +491,7 @@ func _begin_delete_all_confirmation() -> void: ] _confirm_delete_button.text = "Delete All" _load_footer.visible = false + _manage_row.visible = false _confirm_row.visible = true _cancel_delete_button.grab_focus() @@ -479,6 +503,7 @@ func _cancel_delete() -> void: _confirming_delete = false _confirming_delete_all = false _confirm_row.visible = false + _manage_row.visible = true _load_footer.visible = true _confirm_delete_button.text = "Delete" focus_target.grab_focus() @@ -494,6 +519,7 @@ func _confirm_delete() -> void: _confirming_delete = false _confirming_delete_all = false _confirm_row.visible = false + _manage_row.visible = true _load_footer.visible = true _confirm_delete_button.text = "Delete" if WorldStorage.delete_world(id, _library_root) and GameConfig.active_world_id == id: @@ -513,11 +539,75 @@ func _confirm_delete_all() -> void: _confirming_delete = false _confirming_delete_all = false _confirm_row.visible = false + _manage_row.visible = true _load_footer.visible = true _confirm_delete_button.text = "Delete" _refresh_load_view() +func _begin_rename() -> void: + if _confirming_delete or _editing_rename or _selected_world_id.is_empty(): + return + _editing_rename = true + _rename_field.text = String(_world_names.get(_selected_world_id, "")) + _rename_field.select_all() + _manage_row.visible = false + _load_footer.visible = false + _rename_row.visible = true + _rename_field.grab_focus() + + +func _cancel_rename() -> void: + if not _editing_rename: + return + _editing_rename = false + _rename_row.visible = false + _manage_row.visible = true + _load_footer.visible = true + _rename_button.grab_focus() + + +func _confirm_rename() -> void: + if not _editing_rename: + return + var id := _selected_world_id + var name := _rename_field.text.strip_edges() + if not WorldStorage.rename_world(id, name, _library_root): + _set_operation_status("Rename failed. Use 1-64 visible characters.", true) + _rename_field.grab_focus() + return + if GameConfig.active_world_id == id: + GameConfig.active_world_metadata["name"] = name + _editing_rename = false + _refresh_load_view() + _select_world(id) + _set_operation_status("Renamed to %s." % name) + + +func _duplicate_selected() -> void: + if _confirming_delete or _editing_rename or _selected_world_id.is_empty(): + return + var duplicated := WorldStorage.duplicate_world(_selected_world_id, _library_root) + if duplicated.is_empty(): + _set_operation_status("Could not duplicate this world.", true) + return + var copy_id := String(duplicated.get("id", "")) + _refresh_load_view() + _select_world(copy_id) + _set_operation_status("Created %s." % _display_name(duplicated)) + + +func _backup_selected() -> void: + if _confirming_delete or _editing_rename or _selected_world_id.is_empty(): + return + var backup_path := WorldStorage.backup_world(_selected_world_id, _library_root) + if backup_path.is_empty(): + _set_operation_status("Could not back up this world.", true) + else: + _set_operation_status("Backup saved to %s." % backup_path) + _backup_button.grab_focus() + + # ------------------------------------------------------------- create view -- func _on_import() -> void: @@ -582,6 +672,11 @@ func _style_static() -> void: UITheme.style_button_ghost(_cancel_delete_button) UITheme.style_button_primary(_confirm_delete_button) UITheme.style_button_ghost(_empty_create_button) + UITheme.style_button_ghost(_rename_button) + UITheme.style_button_ghost(_duplicate_button) + UITheme.style_button_ghost(_backup_button) + UITheme.style_button_ghost(_rename_cancel_button) + UITheme.style_button_primary(_rename_confirm_button) _landing_hint.add_theme_color_override("font_color", UITheme.MUTED) UITheme.apply_font_size(_landing_hint, 13) _confirm_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -597,6 +692,70 @@ func _style_static() -> void: _empty_body.add_theme_color_override("font_color", UITheme.MUTED) UITheme.apply_font_size(_empty_body, 13) _empty_state.custom_minimum_size = Vector2(0.0, 240.0) + _operation_status.add_theme_color_override("font_color", UITheme.CYAN) + UITheme.apply_font_size(_operation_status, 13) + + +func _build_management_controls() -> void: + _manage_row = HBoxContainer.new() + _manage_row.name = "ManageRow" + _manage_row.alignment = BoxContainer.ALIGNMENT_END + _manage_row.add_theme_constant_override("separation", 10) + _rename_button = Button.new() + _rename_button.text = "Rename..." + _duplicate_button = Button.new() + _duplicate_button.text = "Duplicate" + _backup_button = Button.new() + _backup_button.text = "Back Up" + for button in [_rename_button, _duplicate_button, _backup_button]: + _manage_row.add_child(button) + _load_box.add_child(_manage_row) + _load_box.move_child(_manage_row, _confirm_row.get_index()) + _rename_button.pressed.connect(_begin_rename) + _duplicate_button.pressed.connect(_duplicate_selected) + _backup_button.pressed.connect(_backup_selected) + + _rename_row = HBoxContainer.new() + _rename_row.name = "RenameRow" + _rename_row.visible = false + _rename_row.add_theme_constant_override("separation", 10) + _rename_field = LineEdit.new() + _rename_field.max_length = 64 + _rename_field.placeholder_text = "World name" + _rename_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _rename_cancel_button = Button.new() + _rename_cancel_button.text = "Cancel" + _rename_confirm_button = Button.new() + _rename_confirm_button.text = "Rename" + _rename_row.add_child(_rename_field) + _rename_row.add_child(_rename_cancel_button) + _rename_row.add_child(_rename_confirm_button) + _load_box.add_child(_rename_row) + _load_box.move_child(_rename_row, _confirm_row.get_index()) + _rename_cancel_button.pressed.connect(_cancel_rename) + _rename_confirm_button.pressed.connect(_confirm_rename) + _rename_field.text_submitted.connect(func(_text: String) -> void: _confirm_rename()) + + _operation_status = Label.new() + _operation_status.name = "OperationStatus" + _operation_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _load_box.add_child(_operation_status) + _load_box.move_child(_operation_status, _load_footer.get_index()) + + +func _set_management_enabled(enabled: bool) -> void: + _rename_button.disabled = not enabled + _duplicate_button.disabled = not enabled + _backup_button.disabled = not enabled + var hint := "Select a compatible world first" + _rename_button.tooltip_text = "Rename the selected world" if enabled else hint + _duplicate_button.tooltip_text = "Create an independent copy" if enabled else hint + _backup_button.tooltip_text = "Copy the save to user://world_backups" if enabled else hint + + +func _set_operation_status(text: String, failed: bool = false) -> void: + _operation_status.text = text + _operation_status.add_theme_color_override("font_color", UITheme.WARN if failed else UITheme.CYAN) func _style_row_label(label: Label, text: String) -> void: diff --git a/ui/settings_category_panel.gd b/ui/settings_category_panel.gd index b2ab6ab..b924a4a 100644 --- a/ui/settings_category_panel.gd +++ b/ui/settings_category_panel.gd @@ -104,6 +104,7 @@ func _category_definition() -> Dictionary: "advanced": false, "rows": [ {"type": "slider", "label": "Mouse Sensitivity", "key": "mouse_sensitivity", "min": 0.0005, "max": 0.005, "step": 0.0001, "format": "%.2fx", "scale": 1000.0}, + {"type": "option", "label": "Autosave", "key": "autosave_interval", "options": GameConfig.AUTOSAVE_INTERVAL_NAMES, "values": GameConfig.AUTOSAVE_INTERVAL_VALUES, "tooltip": "How often active gameplay is saved. Worlds are always saved when leaving or quitting."}, ], } _: diff --git a/world/weather_system.gd b/world/weather_system.gd index 312d2dd..aa5dfe7 100644 --- a/world/weather_system.gd +++ b/world/weather_system.gd @@ -94,12 +94,13 @@ func set_state(new_state: State) -> void: func persistent_state() -> Dictionary: - return {"state": int(state)} + return {"state": int(state), "rain_amount": rain_amount} func restore_persistent_state(value: Dictionary) -> void: set_state(clampi(int(value.get("state", State.SUNNY)), State.SUNNY, State.RAIN) as State) - rain_amount = 1.0 if state == State.RAIN else 0.0 + var target := 1.0 if state == State.RAIN else 0.0 + rain_amount = clampf(float(value.get("rain_amount", target)), 0.0, 1.0) if _day_night != null: _day_night.set_weather_dim(rain_amount) diff --git a/world/world_storage.gd b/world/world_storage.gd index db6f838..96cb7fe 100644 --- a/world/world_storage.gd +++ b/world/world_storage.gd @@ -16,6 +16,7 @@ const MAX_REGION_EDITS := 2_000_000 const MAX_PALETTE_ENTRIES := 256 const MAX_CACHED_REGIONS := 16 const DEFAULT_ROOT := "user://worlds" +const DEFAULT_BACKUP_ROOT := "user://world_backups" const LAST_WORLD_FILE := "last_world.txt" var root_path := DEFAULT_ROOT @@ -29,22 +30,29 @@ var _region_access: Dictionary = {} var _access_tick := 0 var _regions_loaded_from_backup: Dictionary = {} var _metadata_loaded_from_backup := false +var _unreadable_regions: Dictionary = {} func _init(p_root_path: String = DEFAULT_ROOT) -> void: root_path = p_root_path.trim_suffix("/") -func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requested_id: String = "") -> Dictionary: +func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requested_id: String = "", + activate: bool = true) -> Dictionary: _reset_cache() + metadata = {} var normalized_config := WorldGenConfig.new(world_config).to_dictionary() world_id = _safe_id(requested_id) + if not requested_id.is_empty() and world_id != requested_id: + return {} if world_id.is_empty(): world_id = "%d-%d-%d" % [ int(Time.get_unix_time_from_system()), abs(int(normalized_config.get("seed", 0))), Time.get_ticks_usec(), ] + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(_world_path())): + return {} var now := int(Time.get_unix_time_from_system()) metadata = { "magic": METADATA_MAGIC, @@ -68,17 +76,19 @@ func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requ if _write_metadata() != OK: metadata = {} return {} - _write_last_world_id() + if activate: + _write_last_world_id() return metadata.duplicate(true) func open_world(p_world_id: String) -> Dictionary: _reset_cache() world_id = _safe_id(p_world_id) - if world_id.is_empty(): + if world_id.is_empty() or world_id != p_world_id: return {} metadata = _read_metadata_file(_metadata_path()) - if metadata.is_empty() or not _validate_metadata(metadata): + if metadata.is_empty() or not _validate_metadata(metadata) \ + or String(metadata.get("id", "")) != world_id: metadata = {} return {} _write_last_world_id() @@ -96,6 +106,8 @@ func load_chunk_edits(chunk_pos: Vector2i) -> Dictionary: func stage_chunk_edits(chunk_pos: Vector2i, edits: Dictionary) -> void: var region_pos := _chunk_region(chunk_pos) _load_region(region_pos) + if _unreadable_regions.has(region_pos): + return var region: Dictionary = _regions.get(region_pos, {}) if edits.is_empty(): region.erase(chunk_pos) @@ -136,6 +148,10 @@ func has_dirty_regions() -> bool: return not _dirty_regions.is_empty() +func unreadable_region_count() -> int: + return _unreadable_regions.size() + + static func latest_world_metadata(p_root_path: String = DEFAULT_ROOT) -> Dictionary: var root := p_root_path.trim_suffix("/") var last_path := root + "/" + LAST_WORLD_FILE @@ -193,8 +209,72 @@ static func delete_world(p_world_id: String, p_root_path: String = DEFAULT_ROOT) return true +static func rename_world(p_world_id: String, new_name: String, p_root_path: String = DEFAULT_ROOT) -> bool: + var name := new_name.strip_edges() + if name.is_empty() or name.length() > 64: + return false + var storage := WorldStorage.new(p_root_path) + if storage._read_world_for_management(p_world_id).is_empty(): + return false + storage.metadata["name"] = name + return storage._write_metadata() == OK + + +static func duplicate_world(p_world_id: String, p_root_path: String = DEFAULT_ROOT) -> Dictionary: + var source := WorldStorage.new(p_root_path) + var source_metadata := source._read_world_for_management(p_world_id) + if source_metadata.is_empty(): + return {} + var copy := WorldStorage.new(p_root_path) + var created := copy.create_world( + source_metadata.get("worldgen", {}), source_metadata.get("state", {}), "", false) + if created.is_empty(): + return {} + var source_name := String(source_metadata.get("name", "World")) + copy.metadata["name"] = "%s Copy" % source_name.substr(0, 59) + var source_regions := p_root_path.trim_suffix("/") + "/" + p_world_id + "/regions" + var destination_regions := p_root_path.trim_suffix("/") + "/" + copy.world_id + "/regions" + if not _copy_directory_contents(source_regions, destination_regions) or copy._write_metadata() != OK: + _remove_directory_tree(ProjectSettings.globalize_path(copy._world_path())) + return {} + return copy.metadata.duplicate(true) + + +static func backup_world(p_world_id: String, p_root_path: String = DEFAULT_ROOT, + p_backup_root: String = DEFAULT_BACKUP_ROOT) -> String: + var source := WorldStorage.new(p_root_path) + if source._read_world_for_management(p_world_id).is_empty(): + return "" + var backup_root := p_backup_root.trim_suffix("/") + var backup_id := "%s-%d-%d" % [p_world_id, int(Time.get_unix_time_from_system()), Time.get_ticks_usec()] + var destination := backup_root + "/" + backup_id + if DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(destination)) != OK: + return "" + var source_path := p_root_path.trim_suffix("/") + "/" + p_world_id + if not _copy_directory_contents(source_path, destination): + _remove_directory_tree(ProjectSettings.globalize_path(destination)) + return "" + return destination + + +func _read_world_for_management(p_world_id: String) -> Dictionary: + _reset_cache() + world_id = _safe_id(p_world_id) + if world_id.is_empty() or world_id != p_world_id: + return {} + metadata = _read_metadata_file(_metadata_path()) + if metadata.is_empty() or String(metadata.get("id", "")) != world_id: + metadata = {} + return metadata.duplicate(true) + + static func _read_world_summary(root: String, id: String) -> Dictionary: var path := root + "/" + id + "/metadata.json" + var storage := WorldStorage.new(root) + storage.world_id = id + var validated := storage._read_metadata_file(path) + if not validated.is_empty() and String(validated.get("id", "")) == id: + return _summary_from_metadata(validated, id) for candidate in [path, path + ".bak"]: if not FileAccess.file_exists(candidate): continue @@ -251,6 +331,33 @@ static func _remove_directory_tree(path: String) -> bool: return DirAccess.remove_absolute(path) == OK +static func _copy_directory_contents(source_path: String, destination_path: String) -> bool: + var source_abs := ProjectSettings.globalize_path(source_path) + var destination_abs := ProjectSettings.globalize_path(destination_path) + var source := DirAccess.open(source_abs) + if source == null: + return false + var make_error := DirAccess.make_dir_recursive_absolute(destination_abs) + if make_error != OK and make_error != ERR_ALREADY_EXISTS: + return false + source.list_dir_begin() + var entry := source.get_next() + while not entry.is_empty(): + if entry != "." and entry != ".." and not entry.ends_with(".tmp"): + var source_entry := source_abs.path_join(entry) + var destination_entry := destination_abs.path_join(entry) + if source.current_is_dir(): + if not _copy_directory_contents(source_entry, destination_entry): + source.list_dir_end() + return false + elif DirAccess.copy_absolute(source_entry, destination_entry) != OK: + source.list_dir_end() + return false + entry = source.get_next() + source.list_dir_end() + return true + + func _load_region(region_pos: Vector2i) -> void: if _loaded_regions.has(region_pos): _touch_region(region_pos) @@ -261,7 +368,14 @@ func _load_region(region_pos: Vector2i) -> void: loaded = _read_region_file(_region_backup_path(region_pos)) if bool(loaded.get("valid", false)): _regions_loaded_from_backup[region_pos] = true - _regions[region_pos] = loaded.get("region", {}) if bool(loaded.get("valid", false)) else {} + var valid := bool(loaded.get("valid", false)) + _regions[region_pos] = loaded.get("region", {}) if valid else {} + if not valid and (FileAccess.file_exists(_region_path(region_pos)) \ + or FileAccess.file_exists(_region_backup_path(region_pos))): + var first_warning := not _unreadable_regions.has(region_pos) + _unreadable_regions[region_pos] = true + if first_warning: + push_warning("World region %s is corrupt or from an unsupported version; edits to this region are blocked to preserve it." % region_pos) _touch_region(region_pos) _evict_clean_regions() @@ -547,6 +661,7 @@ func _reset_cache() -> void: _region_access.clear() _regions_loaded_from_backup.clear() _metadata_loaded_from_backup = false + _unreadable_regions.clear() _access_tick = 0