From 0d050b08cc3e42667dbb363055716d2a6f2e5215 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 25 Jul 2026 18:20:49 -0400 Subject: [PATCH] Add saves, settings, and multiplayer-safe menus --- collection/collection_log.gd | 21 + economy/player_wallet.gd | 12 + fish/fish_catch.gd | 146 ++++++- fish/fish_pool.gd | 9 + fishing/catch_controller.gd | 9 + fishing/fishing_spot.gd | 95 ++++- inventory/fish_inventory.gd | 38 ++ main/main.gd | 139 ++++++ main/main.tscn | 12 +- player/player.gd | 18 +- save/player_save_inspection.gd | 26 ++ save/player_save_inspection.gd.uid | 1 + save/player_save_manager.gd | 539 ++++++++++++++++++++++++ save/player_save_manager.gd.uid | 1 + settings/player_settings.gd | 39 ++ settings/player_settings.gd.uid | 1 + settings/player_settings_manager.gd | 175 ++++++++ settings/player_settings_manager.gd.uid | 1 + ui/game_ui.gd | 44 +- ui/game_ui.tscn | 10 +- ui/pause_menu.gd | 305 ++++++++++++++ ui/pause_menu.gd.uid | 1 + ui/pause_menu.tscn | 173 ++++++++ ui/player_menu.gd | 64 ++- ui/player_menu.tscn | 1 - ui/settings_panel.gd | 80 ++++ ui/settings_panel.gd.uid | 1 + ui/settings_panel.tscn | 136 ++++++ ui/title_screen.gd | 232 ++++++++++ ui/title_screen.gd.uid | 1 + ui/title_screen.tscn | 172 ++++++++ world/water_recovery_controller.gd | 15 +- 32 files changed, 2468 insertions(+), 49 deletions(-) create mode 100644 save/player_save_inspection.gd create mode 100644 save/player_save_inspection.gd.uid create mode 100644 save/player_save_manager.gd create mode 100644 save/player_save_manager.gd.uid create mode 100644 settings/player_settings.gd create mode 100644 settings/player_settings.gd.uid create mode 100644 settings/player_settings_manager.gd create mode 100644 settings/player_settings_manager.gd.uid create mode 100644 ui/pause_menu.gd create mode 100644 ui/pause_menu.gd.uid create mode 100644 ui/pause_menu.tscn create mode 100644 ui/settings_panel.gd create mode 100644 ui/settings_panel.gd.uid create mode 100644 ui/settings_panel.tscn create mode 100644 ui/title_screen.gd create mode 100644 ui/title_screen.gd.uid create mode 100644 ui/title_screen.tscn diff --git a/collection/collection_log.gd b/collection/collection_log.gd index c068a6e..ad0ab56 100644 --- a/collection/collection_log.gd +++ b/collection/collection_log.gd @@ -2,6 +2,7 @@ class_name CollectionLog extends Node signal fish_discovered(fish_id: StringName) +signal collection_changed var _discovered: Dictionary[StringName, bool] = {} @@ -15,3 +16,23 @@ func mark_discovered(fish_id: StringName) -> void: return _discovered[fish_id] = true fish_discovered.emit(fish_id) + collection_changed.emit() + + +func get_discovered_ids() -> Array[StringName]: + var discovered_ids: Array[StringName] = [] + for fish_id: StringName in _discovered: + discovered_ids.append(fish_id) + discovered_ids.sort() + return discovered_ids + + +func replace_discovered_ids(fish_ids: Array[StringName]) -> bool: + var replacement: Dictionary[StringName, bool] = {} + for fish_id: StringName in fish_ids: + if fish_id.is_empty(): + return false + replacement[fish_id] = true + _discovered = replacement + collection_changed.emit() + return true diff --git a/economy/player_wallet.gd b/economy/player_wallet.gd index 7297bd3..bac8855 100644 --- a/economy/player_wallet.gd +++ b/economy/player_wallet.gd @@ -14,6 +14,18 @@ func get_balance() -> int: return current_balance +func restore_balance(balance: int) -> bool: + if balance < 0: + return false + var previous_balance: int = current_balance + current_balance = balance + balance_changed.emit( + current_balance, + current_balance - previous_balance + ) + return true + + func can_afford(amount: int) -> bool: return amount >= 0 and current_balance >= amount diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd index c2ed60b..318e2bb 100644 --- a/fish/fish_catch.gd +++ b/fish/fish_catch.gd @@ -3,6 +3,11 @@ extends Resource const FishDataType = preload("res://fish/fish_data.gd") +const MAX_SAFE_WEIGHT_LB: float = 1000000.0 +const MAX_SAFE_DISPLAY_SCALE: float = 10000.0 +const MAX_SAFE_SALE_VALUE: int = 1000000000 +const MAX_SAFE_SEQUENCE: int = 2147483647 + @export var fish: FishDataType @export var fish_id: StringName @export var catch_id: StringName @@ -16,9 +21,16 @@ const FishDataType = preload("res://fish/fish_data.gd") func ensure_identity() -> void: if not catch_id.is_empty(): return + var random_bytes: PackedByteArray = Crypto.new().generate_random_bytes(16) + var identity_suffix: String = random_bytes.hex_encode() + if identity_suffix.is_empty(): + identity_suffix = "%d:%d:%d" % [ + int(Time.get_unix_time_from_system()), + Time.get_ticks_usec(), + get_instance_id(), + ] catch_id = StringName( - "%s:%d:%d" - % [fish_id, Time.get_ticks_usec(), get_instance_id()] + "%s:%s" % [fish_id, identity_suffix] ) @@ -28,8 +40,134 @@ func is_valid() -> bool: and not fish_id.is_empty() and not catch_id.is_empty() and fish.id == fish_id - and weight_lb >= fish.get_minimum_weight() - and weight_lb <= fish.get_maximum_weight() + and catch_sequence >= 0 + and catch_sequence < MAX_SAFE_SEQUENCE + and is_finite(weight_lb) + and weight_lb > 0.0 + and weight_lb <= MAX_SAFE_WEIGHT_LB + and is_finite(display_scale) and display_scale > 0.0 + and display_scale <= MAX_SAFE_DISPLAY_SCALE and sale_value >= 0 + and sale_value <= MAX_SAFE_SALE_VALUE ) + + +func to_save_dict() -> Dictionary: + return { + "catch_id": String(catch_id), + "catch_sequence": catch_sequence, + "fish_id": String(fish_id), + "weight_lb": weight_lb, + "display_scale": display_scale, + "sale_value": sale_value, + "is_favorited": is_favorited, + } + + +static func from_save_dict( + data: Dictionary, + resolved_fish: FishDataType, +) -> FishCatch: + if resolved_fish == null: + return null + for required_key: String in [ + "catch_id", + "catch_sequence", + "fish_id", + "weight_lb", + "display_scale", + "sale_value", + "is_favorited", + ]: + if not data.has(required_key): + return null + if ( + typeof(data["catch_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(data["fish_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] + ): + return null + + var loaded_catch_id: StringName = StringName(str(data["catch_id"])) + var loaded_fish_id: StringName = StringName(str(data["fish_id"])) + var loaded_sequence: int = _read_safe_integer( + data["catch_sequence"], + -1, + MAX_SAFE_SEQUENCE + ) + var loaded_sale_value: int = _read_safe_integer( + data["sale_value"], + -1, + MAX_SAFE_SALE_VALUE + ) + var loaded_weight: float = _read_safe_float( + data["weight_lb"], + 0.0, + MAX_SAFE_WEIGHT_LB + ) + var loaded_scale: float = _read_safe_float( + data["display_scale"], + 0.0, + MAX_SAFE_DISPLAY_SCALE + ) + var loaded_favorite: Variant = data["is_favorited"] + if ( + loaded_catch_id.is_empty() + or loaded_fish_id != resolved_fish.id + or loaded_sequence <= 0 + or loaded_sequence >= MAX_SAFE_SEQUENCE + or loaded_sale_value < 0 + or loaded_weight <= 0.0 + or loaded_scale <= 0.0 + or typeof(loaded_favorite) != TYPE_BOOL + ): + return null + + var fish_catch := FishCatch.new() + fish_catch.fish = resolved_fish + fish_catch.fish_id = loaded_fish_id + fish_catch.catch_id = loaded_catch_id + fish_catch.catch_sequence = loaded_sequence + fish_catch.weight_lb = loaded_weight + fish_catch.display_scale = loaded_scale + fish_catch.sale_value = loaded_sale_value + fish_catch.is_favorited = bool(loaded_favorite) + return fish_catch if fish_catch.is_valid() else null + + +static func _read_safe_integer( + value: Variant, + invalid_value: int, + maximum_value: int, +) -> int: + if typeof(value) == TYPE_INT: + var integer_value: int = int(value) + if integer_value >= 0 and integer_value <= maximum_value: + return integer_value + elif typeof(value) == TYPE_FLOAT: + var float_value: float = float(value) + if ( + is_finite(float_value) + and float_value >= 0.0 + and float_value <= float(maximum_value) + and is_equal_approx(float_value, round(float_value)) + ): + return int(round(float_value)) + return invalid_value + + +static func _read_safe_float( + value: Variant, + invalid_value: float, + maximum_value: float, +) -> float: + if typeof(value) not in [TYPE_INT, TYPE_FLOAT]: + return invalid_value + var float_value: float = float(value) + if ( + not is_finite(float_value) + or float_value <= 0.0 + or float_value > maximum_value + ): + return invalid_value + return float_value diff --git a/fish/fish_pool.gd b/fish/fish_pool.gd index 4021986..89c9998 100644 --- a/fish/fish_pool.gd +++ b/fish/fish_pool.gd @@ -4,3 +4,12 @@ extends Resource const FishDataType = preload("res://fish/fish_data.gd") @export var candidates: Array[FishDataType] = [] + + +func get_fish_by_id(fish_id: StringName) -> FishDataType: + if fish_id.is_empty(): + return null + for fish: FishDataType in candidates: + if fish != null and fish.id == fish_id: + return fish + return null diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 29ab699..7043508 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -68,6 +68,15 @@ var _auto_click_accumulator: float = 0.0 var _rng: RandomNumberGenerator = RandomNumberGenerator.new() +func configure_accessibility( + enabled: bool, + interval: float, +) -> void: + auto_click_enabled = enabled + auto_click_interval = clampf(interval, 0.10, 0.50) + _auto_click_accumulator = 0.0 + + func _process(delta: float) -> void: match state: CatchState.REELING: diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 5a6d3c9..f49bb77 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -79,6 +79,8 @@ enum FishingState { var state: FishingState = FishingState.READY var _external_input_blocked: bool = false +var _gameplay_input_enabled: bool = false +var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_player: PlayerType var _local_inventory: FishInventoryType var _local_collection_log: CollectionLogType @@ -126,10 +128,76 @@ func setup( func can_open_player_menu() -> bool: - return not _external_input_blocked and state in [ + return ( + _gameplay_input_enabled + and not _external_input_blocked + and _local_menu_input_owners.is_empty() + and state in [ FishingState.READY, FishingState.WAITING_FOR_BITE, - ] + ] + ) + + +func can_open_system_menu() -> bool: + return ( + _gameplay_input_enabled + and not _external_input_blocked + and _local_menu_input_owners.is_empty() + and state in [ + FishingState.READY, + FishingState.WAITING_FOR_BITE, + ] + ) + + +func handle_escape_action() -> bool: + if not _gameplay_input_enabled or _external_input_blocked: + return false + if state == FishingState.SHOWING_CATCH: + _put_away_catch() + return true + if ( + _active_player != null + and state in [ + FishingState.AIMING_CAST, + FishingState.CASTING, + FishingState.FIGHTING, + ] + ): + _cancel_attempt() + return true + return false + + +func set_local_menu_input_suppressed( + owner: StringName, + suppressed: bool, +) -> void: + if owner.is_empty(): + return + if suppressed: + _local_menu_input_owners[owner] = true + else: + _local_menu_input_owners.erase(owner) + if suppressed and state == FishingState.WAITING_FOR_BITE: + _withdrawal_input_held = false + _presentation.set_line_mode( + FishingPresentationType.LineMode.SLACK + ) + + +func set_gameplay_input_enabled(enabled: bool) -> void: + _gameplay_input_enabled = enabled + if not enabled: + _local_menu_input_owners.clear() + + +func configure_accessibility_auto_click( + enabled: bool, + interval: float, +) -> void: + _catch_controller.configure_accessibility(enabled, interval) func begin_water_recovery() -> void: @@ -217,29 +285,12 @@ func _process(delta: float) -> void: func _unhandled_input(event: InputEvent) -> void: - if _external_input_blocked: - return if ( - event.is_action_pressed("ui_cancel") - and state == FishingState.SHOWING_CATCH + not _gameplay_input_enabled + or _external_input_blocked + or not _local_menu_input_owners.is_empty() ): - _put_away_catch() - get_viewport().set_input_as_handled() return - if ( - event.is_action_pressed("ui_cancel") - and _active_player != null - and state in [ - FishingState.AIMING_CAST, - FishingState.CASTING, - FishingState.WAITING_FOR_BITE, - FishingState.FIGHTING, - ] - ): - _cancel_attempt() - get_viewport().set_input_as_handled() - return - if _local_player == null or not _local_player.is_local_control_enabled(): return if not event.is_action("fish_primary"): diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd index ac1ae6e..7304246 100644 --- a/inventory/fish_inventory.gd +++ b/inventory/fish_inventory.gd @@ -37,6 +37,44 @@ func get_all_catches() -> Array[FishCatchType]: return _catches.duplicate() +func get_next_catch_sequence() -> int: + return _next_catch_sequence + + +func replace_all_catches( + catches: Array[FishCatchType], + requested_next_sequence: int, +) -> bool: + var validated: Array[FishCatchType] = [] + var seen_ids: Dictionary[StringName, bool] = {} + var seen_sequences: Dictionary[int, bool] = {} + var maximum_sequence: int = 0 + for fish_catch: FishCatchType in catches: + if ( + fish_catch == null + or not fish_catch.is_valid() + or fish_catch.catch_sequence <= 0 + or seen_ids.has(fish_catch.catch_id) + or seen_sequences.has(fish_catch.catch_sequence) + ): + return false + seen_ids[fish_catch.catch_id] = true + seen_sequences[fish_catch.catch_sequence] = true + maximum_sequence = maxi( + maximum_sequence, + fish_catch.catch_sequence + ) + validated.append(fish_catch) + + _catches = validated + _next_catch_sequence = maxi( + maxi(requested_next_sequence, 1), + maximum_sequence + 1 + ) + catches_changed.emit() + return true + + func get_catch_by_id(catch_id: StringName) -> FishCatchType: if catch_id.is_empty(): return null diff --git a/main/main.gd b/main/main.gd index cef837d..ee28cd3 100644 --- a/main/main.gd +++ b/main/main.gd @@ -9,6 +9,15 @@ const TestWorldType = preload("res://world/test_world.gd") const WaterRecoveryControllerType = preload( "res://world/water_recovery_controller.gd" ) +const PlayerSaveManagerType = preload( + "res://save/player_save_manager.gd" +) +const PlayerSettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) +const PlayerSettingsType = preload("res://settings/player_settings.gd") +const TitleScreenType = preload("res://ui/title_screen.gd") +const PauseMenuType = preload("res://ui/pause_menu.gd") @export var fish_catalog: FishPoolType @export var pelican_buyer_profile: FishBuyerProfileType @@ -18,6 +27,10 @@ const WaterRecoveryControllerType = preload( @onready var _fishing_spot: FishingSpotType = %FishingSpot @onready var _game_ui: GameUIType = %GameUI @onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery +@onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager +@onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager + +var _gameplay_started: bool = false func _ready() -> void: @@ -25,6 +38,13 @@ func _ready() -> void: _player.inventory, _player.wallet ) + _save_manager.setup( + _player.inventory, + _player.collection_log, + _player.wallet, + fish_catalog + ) + _save_manager.set_autosave_enabled(false) _fishing_spot.setup( _player, _player.inventory, @@ -48,3 +68,122 @@ func _ready() -> void: _test_world.get_player_water_trigger(), _test_world.get_safe_respawn_points() ) + if not _settings_manager.settings_changed.is_connected( + _apply_runtime_settings + ): + _settings_manager.settings_changed.connect(_apply_runtime_settings) + _settings_manager.load_settings() + _apply_runtime_settings(_settings_manager.current_settings) + _set_gameplay_active(false) + var title_screen: TitleScreenType = _game_ui.get_title_screen() + title_screen.gameplay_requested.connect(_on_gameplay_requested) + title_screen.quit_requested.connect(_on_quit_requested) + title_screen.setup(_save_manager, _settings_manager) + var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + pause_menu.setup( + _player, + _save_manager, + _settings_manager, + _fishing_spot + ) + pause_menu.return_to_title_requested.connect( + _on_return_to_title_requested + ) + pause_menu.reset_progress_requested.connect( + _on_reset_progress_requested + ) + pause_menu.quit_requested.connect(_on_quit_requested) + _water_recovery.recovery_starting.connect( + _on_water_recovery_starting + ) + + +func _input(event: InputEvent) -> void: + if ( + not event.is_action_pressed("ui_cancel") + or (event is InputEventKey and event.echo) + ): + return + var title_screen: TitleScreenType = _game_ui.get_title_screen() + if title_screen.visible or not _gameplay_started: + return + var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + if pause_menu.handle_escape(): + get_viewport().set_input_as_handled() + return + if _game_ui.consume_player_menu_escape(): + get_viewport().set_input_as_handled() + return + if _fishing_spot.handle_escape_action(): + get_viewport().set_input_as_handled() + return + if ( + not _water_recovery.is_recovery_active() + and _fishing_spot.can_open_system_menu() + ): + _game_ui.close_player_menu_for_game_menu() + pause_menu.open_menu() + get_viewport().set_input_as_handled() + + +func _apply_runtime_settings(settings: PlayerSettingsType) -> void: + if settings == null: + return + _player.apply_camera_settings( + settings.mouse_camera_sensitivity, + settings.controller_camera_sensitivity, + settings.invert_camera_y + ) + _fishing_spot.configure_accessibility_auto_click( + settings.auto_click_enabled, + settings.auto_click_interval + ) + + +func _set_gameplay_active(active: bool) -> void: + _gameplay_started = active + _player.set_movement_enabled(active) + _player.set_camera_input_enabled(active) + _fishing_spot.set_gameplay_input_enabled(active) + _water_recovery.set_recovery_enabled(active) + _game_ui.set_gameplay_ui_enabled(active) + _save_manager.set_autosave_enabled(active) + + +func _on_gameplay_requested() -> void: + if _gameplay_started: + return + _game_ui.get_title_screen().hide() + _set_gameplay_active(true) + + +func _on_return_to_title_requested() -> void: + var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + pause_menu.close_for_title_transition() + _set_gameplay_active(false) + _game_ui.get_title_screen().reopen() + + +func _on_reset_progress_requested() -> void: + var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + if not _save_manager.delete_progression_save(): + pause_menu.report_reset_failure() + return + if not _save_manager.initialize_new_game(): + pause_menu.report_reset_failure() + return + pause_menu.close_for_title_transition() + _set_gameplay_active(false) + _game_ui.get_title_screen().reopen() + + +func _on_water_recovery_starting() -> void: + _game_ui.close_player_menu_for_water_recovery() + _game_ui.get_pause_menu().close_for_water_recovery() + + +func _on_quit_requested() -> void: + _settings_manager.save_if_dirty() + if _gameplay_started: + _save_manager.save_if_dirty() + get_tree().quit() diff --git a/main/main.tscn b/main/main.tscn index 27ea42b..8784651 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -8,6 +8,8 @@ [ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="6_pool"] [ext_resource type="Resource" path="res://economy/buyers/pelicans.tres" id="7_pelicans"] [ext_resource type="Script" path="res://world/water_recovery_controller.gd" id="8_recovery"] +[ext_resource type="Script" path="res://save/player_save_manager.gd" id="9_save"] +[ext_resource type="Script" path="res://settings/player_settings_manager.gd" id="10_settings"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -30,5 +32,13 @@ position = Vector3(0, 1.02, -18) unique_name_in_owner = true script = ExtResource("8_recovery") +[node name="PlayerSaveManager" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("9_save") + +[node name="PlayerSettingsManager" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("10_settings") + [node name="GameUI" parent="." instance=ExtResource("5_ui")] unique_name_in_owner = true diff --git a/player/player.gd b/player/player.gd index 50f6e06..4f04f2d 100644 --- a/player/player.gd +++ b/player/player.gd @@ -50,6 +50,7 @@ class ShowcaseCameraSnapshot: @export var mouse_sensitivity: float = 0.005 @export var controller_camera_speed: float = 2.5 @export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2 +@export var invert_camera_y: bool = false @export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0 @export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0 @export var minimum_zoom: float = 2.0 @@ -217,8 +218,9 @@ func _get_current_speed() -> float: func _rotate_camera(delta_rotation: Vector2) -> void: _camera_yaw.rotation.y -= delta_rotation.x + var vertical_direction: float = -1.0 if invert_camera_y else 1.0 _camera_pitch.rotation.x = clampf( - _camera_pitch.rotation.x - delta_rotation.y, + _camera_pitch.rotation.x - delta_rotation.y * vertical_direction, deg_to_rad(minimum_pitch_degrees), deg_to_rad(maximum_pitch_degrees) ) @@ -257,6 +259,20 @@ func set_camera_input_enabled(enabled: bool) -> void: _camera_dragging = false +func apply_camera_settings( + new_mouse_sensitivity: float, + new_controller_sensitivity: float, + invert_vertical: bool, +) -> void: + mouse_sensitivity = clampf(new_mouse_sensitivity, 0.001, 0.012) + controller_camera_speed = clampf( + new_controller_sensitivity, + 0.5, + 5.0 + ) + invert_camera_y = invert_vertical + + func is_camera_input_enabled() -> bool: return _camera_input_enabled diff --git a/save/player_save_inspection.gd b/save/player_save_inspection.gd new file mode 100644 index 0000000..be5fd24 --- /dev/null +++ b/save/player_save_inspection.gd @@ -0,0 +1,26 @@ +class_name PlayerSaveInspection +extends RefCounted + +enum Status { + MISSING, + VALID_SUPPORTED, + MALFORMED, + UNSUPPORTED_VERSION, + IO_ERROR, +} + +var status: Status = Status.MISSING +var detected_version: int = -1 +var catch_count: int = 0 +var wallet_balance: int = 0 +var discovered_species_count: int = 0 +var has_primary_file: bool = false +var message: String = "" + + +func can_continue() -> bool: + return status == Status.VALID_SUPPORTED + + +func can_delete() -> bool: + return has_primary_file diff --git a/save/player_save_inspection.gd.uid b/save/player_save_inspection.gd.uid new file mode 100644 index 0000000..5334741 --- /dev/null +++ b/save/player_save_inspection.gd.uid @@ -0,0 +1 @@ +uid://ds46yeg8hex65 diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd new file mode 100644 index 0000000..f0a2a53 --- /dev/null +++ b/save/player_save_manager.gd @@ -0,0 +1,539 @@ +class_name PlayerSaveManager +extends Node + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") +const PlayerWalletType = preload("res://economy/player_wallet.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") +const SaveInspectionType = preload("res://save/player_save_inspection.gd") + +const SAVE_VERSION: int = 1 +const SAVE_PATH: String = "user://player_save.json" +const TEMP_PATH: String = "user://player_save.json.tmp" +const BACKUP_PATH: String = "user://player_save.json.backup" +const MAX_SAFE_BALANCE: int = 1000000000000 + +class LoadSnapshot: + extends RefCounted + + var catches: Array[FishCatchType] = [] + var discovered_ids: Array[StringName] = [] + var wallet_balance: int = 0 + var next_catch_sequence: int = 1 + + +@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 + +var _inventory: FishInventoryType +var _collection_log: CollectionLogType +var _wallet: PlayerWalletType +var _catalog: FishPoolType +var _autosave_timer: Timer +var _is_configured: bool = false +var _is_restoring: bool = false +var _is_dirty: bool = false +var _automatic_saving_blocked: bool = false +var _autosave_enabled: bool = false + + +func _ready() -> void: + _autosave_timer = Timer.new() + _autosave_timer.one_shot = true + _autosave_timer.timeout.connect(_on_autosave_timeout) + add_child(_autosave_timer) + + +func setup( + inventory: FishInventoryType, + collection_log: CollectionLogType, + wallet: PlayerWalletType, + catalog: FishPoolType, +) -> void: + _inventory = inventory + _collection_log = collection_log + _wallet = wallet + _catalog = catalog + _is_configured = ( + _inventory != null + and _collection_log != null + and _wallet != null + and _catalog != null + ) + if not _is_configured: + push_error("PlayerSaveManager setup is missing required references.") + return + if not _inventory.catches_changed.is_connected(_mark_dirty): + _inventory.catches_changed.connect(_mark_dirty) + if not _collection_log.fish_discovered.is_connected( + _on_fish_discovered + ): + _collection_log.fish_discovered.connect(_on_fish_discovered) + if not _wallet.balance_changed.is_connected(_on_balance_changed): + _wallet.balance_changed.connect(_on_balance_changed) + + +func load_player_data() -> bool: + if not _is_configured: + return false + _automatic_saving_blocked = false + _recover_interrupted_write() + if not FileAccess.file_exists(SAVE_PATH): + _is_dirty = false + return true + + var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ) + if save_file == null: + _handle_corrupt_save("Unable to open player save.") + return false + var json_text: String = save_file.get_as_text() + save_file.close() + + var json := JSON.new() + var parse_error: Error = json.parse(json_text) + if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: + _handle_corrupt_save("Player save contains malformed JSON.") + return false + var save_data: Dictionary = json.data + var version: int = _read_integer(save_data.get("save_version"), -1) + if version > SAVE_VERSION: + _automatic_saving_blocked = true + push_warning( + ( + "Player save version %d is newer than supported " + + "version %d; the file was left untouched." + ) + % [version, SAVE_VERSION] + ) + _restore_defaults() + return false + if version != SAVE_VERSION: + save_data = _migrate_save(save_data, version) + if save_data.is_empty(): + _handle_corrupt_save( + "Player save version %d is unsupported." % version + ) + return false + + var snapshot: LoadSnapshot = _build_load_snapshot(save_data) + if snapshot == null: + _handle_corrupt_save("Player save failed structural validation.") + return false + + _is_restoring = true + var inventory_restored: bool = _inventory.replace_all_catches( + snapshot.catches, + snapshot.next_catch_sequence + ) + var collection_restored: bool = ( + _collection_log.replace_discovered_ids(snapshot.discovered_ids) + ) + var wallet_restored: bool = _wallet.restore_balance( + snapshot.wallet_balance + ) + _is_restoring = false + if ( + not inventory_restored + or not collection_restored + or not wallet_restored + ): + push_error("Validated player save could not be restored.") + return false + + _is_dirty = false + print( + "Loaded player save version %d with %d catches." + % [SAVE_VERSION, snapshot.catches.size()] + ) + return true + + +func inspect_save() -> SaveInspectionType: + var result := SaveInspectionType.new() + _recover_interrupted_write() + result.has_primary_file = FileAccess.file_exists(SAVE_PATH) + if not result.has_primary_file: + result.status = SaveInspectionType.Status.MISSING + result.message = "No save found." + return result + var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ) + if save_file == null: + result.status = SaveInspectionType.Status.IO_ERROR + result.message = "The save could not be read." + return result + var json := JSON.new() + var parse_error: Error = json.parse(save_file.get_as_text()) + save_file.close() + if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: + result.status = SaveInspectionType.Status.MALFORMED + result.message = "The save is corrupt and was preserved." + return result + var save_data: Dictionary = json.data + result.detected_version = _read_integer(save_data.get("save_version"), -1) + if result.detected_version > SAVE_VERSION: + result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION + result.message = "This save belongs to a newer game version." + return result + if result.detected_version != SAVE_VERSION: + result.status = SaveInspectionType.Status.MALFORMED + result.message = "The save version is unsupported." + return result + var snapshot: LoadSnapshot = _build_load_snapshot(save_data) + if snapshot == null: + result.status = SaveInspectionType.Status.MALFORMED + result.message = "The save is structurally invalid and was preserved." + return result + result.status = SaveInspectionType.Status.VALID_SUPPORTED + result.catch_count = snapshot.catches.size() + result.wallet_balance = snapshot.wallet_balance + result.discovered_species_count = snapshot.discovered_ids.size() + result.message = "Save ready." + return result + + +func initialize_new_game() -> bool: + if not _is_configured: + return false + _automatic_saving_blocked = false + _restore_defaults() + return true + + +func delete_progression_save() -> bool: + if FileAccess.file_exists(SAVE_PATH) and not _remove_if_present(SAVE_PATH): + return false + for path: String in [TEMP_PATH, BACKUP_PATH]: + if FileAccess.file_exists(path) and not _remove_if_present(path): + push_warning("Unable to remove stale player-save auxiliary file.") + if _autosave_timer != null: + _autosave_timer.stop() + _is_dirty = false + _automatic_saving_blocked = false + return true + + +func set_autosave_enabled(enabled: bool) -> void: + _autosave_enabled = enabled + if not enabled and _autosave_timer != null: + _autosave_timer.stop() + + +func save_if_dirty() -> bool: + return not _is_dirty or save_now() + + +func is_dirty() -> bool: + return _is_dirty + + +func cancel_pending_autosave() -> void: + if _autosave_timer != null: + _autosave_timer.stop() + _is_dirty = false + + +func save_now() -> bool: + if ( + not _is_configured + or _automatic_saving_blocked + or not _autosave_enabled + ): + return false + var save_data: Dictionary = _build_save_dictionary() + if save_data.is_empty(): + return false + var json_text: String = JSON.stringify(save_data, "\t") + if json_text.is_empty(): + return false + + var temp_file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) + if temp_file == null: + push_warning("Unable to open temporary player save for writing.") + return false + temp_file.store_string(json_text) + temp_file.flush() + var write_error: Error = temp_file.get_error() + temp_file.close() + if write_error != OK: + _remove_if_present(TEMP_PATH) + push_warning("Unable to finish writing temporary player save.") + return false + + var had_primary: bool = FileAccess.file_exists(SAVE_PATH) + _remove_if_present(BACKUP_PATH) + if had_primary and not _rename_file(SAVE_PATH, BACKUP_PATH): + _remove_if_present(TEMP_PATH) + push_warning("Unable to preserve the previous player save.") + return false + if not _rename_file(TEMP_PATH, SAVE_PATH): + if had_primary: + _rename_file(BACKUP_PATH, SAVE_PATH) + push_warning("Unable to promote the temporary player save.") + return false + _remove_if_present(BACKUP_PATH) + _is_dirty = false + return true + + +func delete_save_for_debug() -> bool: + return delete_progression_save() + + +func _exit_tree() -> void: + if _is_dirty and _autosave_enabled and not _automatic_saving_blocked: + save_now() + + +func _build_save_dictionary() -> Dictionary: + var serialized_catches: Array[Dictionary] = [] + for fish_catch: FishCatchType in _inventory.get_all_catches(): + if fish_catch == null or not fish_catch.is_valid(): + push_warning( + "Player save aborted because inventory contains an " + + "invalid runtime catch." + ) + return {} + serialized_catches.append(fish_catch.to_save_dict()) + + var discovered_strings: Array[String] = [] + for fish_id: StringName in _collection_log.get_discovered_ids(): + if fish_id.is_empty(): + return {} + discovered_strings.append(String(fish_id)) + if ( + _wallet.get_balance() < 0 + or _wallet.get_balance() > MAX_SAFE_BALANCE + or _inventory.get_next_catch_sequence() < 1 + or ( + _inventory.get_next_catch_sequence() + > FishCatchType.MAX_SAFE_SEQUENCE + ) + ): + return {} + + return { + "save_version": SAVE_VERSION, + "wallet": { + "balance": _wallet.get_balance(), + }, + "collection": { + "discovered_fish_ids": discovered_strings, + }, + "inventory": { + "next_catch_sequence": _inventory.get_next_catch_sequence(), + "catches": serialized_catches, + }, + } + + +func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: + if ( + typeof(save_data.get("wallet")) != TYPE_DICTIONARY + or typeof(save_data.get("collection")) != TYPE_DICTIONARY + or typeof(save_data.get("inventory")) != TYPE_DICTIONARY + ): + return null + var wallet_data: Dictionary = save_data["wallet"] + var collection_data: Dictionary = save_data["collection"] + var inventory_data: Dictionary = save_data["inventory"] + if ( + not wallet_data.has("balance") + or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY + or typeof(inventory_data.get("catches")) != TYPE_ARRAY + or not inventory_data.has("next_catch_sequence") + ): + return null + + var balance: int = _read_integer( + wallet_data["balance"], + -1, + MAX_SAFE_BALANCE + ) + var requested_next_sequence: int = _read_integer( + inventory_data["next_catch_sequence"], + -1, + FishCatchType.MAX_SAFE_SEQUENCE + ) + if balance < 0 or requested_next_sequence < 1: + return null + + var snapshot := LoadSnapshot.new() + snapshot.wallet_balance = balance + var discovered_values: Array = collection_data["discovered_fish_ids"] + var seen_discoveries: Dictionary[StringName, bool] = {} + for value: Variant in discovered_values: + if typeof(value) != TYPE_STRING and typeof(value) != TYPE_STRING_NAME: + continue + var fish_id: StringName = StringName(str(value)) + if fish_id.is_empty() or seen_discoveries.has(fish_id): + continue + seen_discoveries[fish_id] = true + snapshot.discovered_ids.append(fish_id) + + var seen_ids: Dictionary[StringName, bool] = {} + var seen_sequences: Dictionary[int, bool] = {} + var maximum_sequence: int = 0 + var catch_values: Array = inventory_data["catches"] + for catch_index: int in range(catch_values.size()): + var catch_value: Variant = catch_values[catch_index] + if typeof(catch_value) != TYPE_DICTIONARY: + push_warning( + "Skipped invalid saved catch at index %d." % catch_index + ) + continue + var catch_data: Dictionary = catch_value + var fish_id: StringName = StringName(str(catch_data.get("fish_id", ""))) + var fish_data = _catalog.get_fish_by_id(fish_id) + if fish_data == null: + push_warning( + "Skipped saved catch with missing fish ID '%s'." + % String(fish_id) + ) + continue + var fish_catch: FishCatchType = FishCatchType.from_save_dict( + catch_data, + fish_data + ) + if fish_catch == null: + push_warning( + "Skipped structurally invalid saved catch at index %d." + % catch_index + ) + continue + if seen_ids.has(fish_catch.catch_id): + push_warning( + "Skipped duplicate saved catch ID '%s'." + % String(fish_catch.catch_id) + ) + continue + if seen_sequences.has(fish_catch.catch_sequence): + push_warning( + "Skipped duplicate saved catch sequence %d." + % fish_catch.catch_sequence + ) + continue + seen_ids[fish_catch.catch_id] = true + seen_sequences[fish_catch.catch_sequence] = true + maximum_sequence = maxi( + maximum_sequence, + fish_catch.catch_sequence + ) + snapshot.catches.append(fish_catch) + + snapshot.next_catch_sequence = maxi( + requested_next_sequence, + maximum_sequence + 1 + ) + return snapshot + + +func _migrate_save( + data: Dictionary, + from_version: int, +) -> Dictionary: + if from_version == SAVE_VERSION: + return data + return {} + + +func _mark_dirty() -> void: + if ( + _is_restoring + or _automatic_saving_blocked + or not _autosave_enabled + ): + return + _is_dirty = true + if _autosave_timer != null and _autosave_timer.is_stopped(): + _autosave_timer.start(maxf(autosave_delay, 0.05)) + + +func _on_fish_discovered(_fish_id: StringName) -> void: + _mark_dirty() + + +func _on_balance_changed(_new_balance: int, _delta: int) -> void: + _mark_dirty() + + +func _on_autosave_timeout() -> void: + if _is_dirty: + save_now() + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(SAVE_PATH): + _remove_if_present(TEMP_PATH) + _remove_if_present(BACKUP_PATH) + return + if FileAccess.file_exists(BACKUP_PATH): + _rename_file(BACKUP_PATH, SAVE_PATH) + _remove_if_present(TEMP_PATH) + + +func _handle_corrupt_save(message: String) -> void: + push_warning(message) + _restore_defaults() + if not FileAccess.file_exists(SAVE_PATH): + return + var timestamp: int = int(Time.get_unix_time_from_system()) + var corrupt_path: String = ( + "%s.corrupt-%d" % [SAVE_PATH, timestamp] + ) + if FileAccess.file_exists(corrupt_path): + corrupt_path += "-%d" % Time.get_ticks_usec() + if not _rename_file(SAVE_PATH, corrupt_path): + _automatic_saving_blocked = true + push_warning( + "Corrupt player save was left in place; automatic saving is " + + "disabled for this run." + ) + + +func _restore_defaults() -> void: + if not _is_configured: + return + _is_restoring = true + var empty_catches: Array[FishCatchType] = [] + var empty_discoveries: Array[StringName] = [] + _inventory.replace_all_catches(empty_catches, 1) + _collection_log.replace_discovered_ids(empty_discoveries) + _wallet.restore_balance(0) + _is_restoring = false + _is_dirty = false + + +func _read_integer( + value: Variant, + invalid_value: int, + maximum_value: int = 2147483647, +) -> int: + if typeof(value) == TYPE_INT: + var integer_value: int = int(value) + if integer_value >= 0 and integer_value <= maximum_value: + return integer_value + elif typeof(value) == TYPE_FLOAT: + var float_value: float = float(value) + if ( + is_finite(float_value) + and float_value >= 0.0 + and float_value <= float(maximum_value) + and is_equal_approx(float_value, round(float_value)) + ): + return int(round(float_value)) + return invalid_value + + +func _rename_file(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute( + ProjectSettings.globalize_path(from_path), + ProjectSettings.globalize_path(to_path) + ) == OK + + +func _remove_if_present(path: String) -> bool: + if not FileAccess.file_exists(path): + return true + return DirAccess.remove_absolute( + ProjectSettings.globalize_path(path) + ) == OK diff --git a/save/player_save_manager.gd.uid b/save/player_save_manager.gd.uid new file mode 100644 index 0000000..2769326 --- /dev/null +++ b/save/player_save_manager.gd.uid @@ -0,0 +1 @@ +uid://dfr5m2x77d8ed diff --git a/settings/player_settings.gd b/settings/player_settings.gd new file mode 100644 index 0000000..3c5581a --- /dev/null +++ b/settings/player_settings.gd @@ -0,0 +1,39 @@ +class_name PlayerSettings +extends Resource + +const MIN_AUTO_CLICK_INTERVAL: float = 0.10 +const MAX_AUTO_CLICK_INTERVAL: float = 0.50 +const MIN_MOUSE_SENSITIVITY: float = 0.001 +const MAX_MOUSE_SENSITIVITY: float = 0.012 +const MIN_CONTROLLER_SENSITIVITY: float = 0.5 +const MAX_CONTROLLER_SENSITIVITY: float = 5.0 + +@export var auto_click_enabled: bool = false +@export_range(0.10, 0.50, 0.01) var auto_click_interval: float = 0.20 +@export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005 +@export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5 +@export var invert_camera_y: bool = false + + +func is_valid() -> bool: + return ( + is_finite(auto_click_interval) + and auto_click_interval >= MIN_AUTO_CLICK_INTERVAL + and auto_click_interval <= MAX_AUTO_CLICK_INTERVAL + and is_finite(mouse_camera_sensitivity) + and mouse_camera_sensitivity >= MIN_MOUSE_SENSITIVITY + and mouse_camera_sensitivity <= MAX_MOUSE_SENSITIVITY + and is_finite(controller_camera_sensitivity) + and controller_camera_sensitivity >= MIN_CONTROLLER_SENSITIVITY + and controller_camera_sensitivity <= MAX_CONTROLLER_SENSITIVITY + ) + + +func copy() -> PlayerSettings: + var result := PlayerSettings.new() + result.auto_click_enabled = auto_click_enabled + result.auto_click_interval = auto_click_interval + result.mouse_camera_sensitivity = mouse_camera_sensitivity + result.controller_camera_sensitivity = controller_camera_sensitivity + result.invert_camera_y = invert_camera_y + return result diff --git a/settings/player_settings.gd.uid b/settings/player_settings.gd.uid new file mode 100644 index 0000000..6bcbb81 --- /dev/null +++ b/settings/player_settings.gd.uid @@ -0,0 +1 @@ +uid://ck1b2emy2fhrk diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd new file mode 100644 index 0000000..47db648 --- /dev/null +++ b/settings/player_settings_manager.gd @@ -0,0 +1,175 @@ +class_name PlayerSettingsManager +extends Node + +const SETTINGS_VERSION: int = 1 +const SETTINGS_PATH: String = "user://player_settings.json" +const TEMP_PATH: String = "user://player_settings.json.tmp" +const BACKUP_PATH: String = "user://player_settings.json.backup" + +signal settings_changed(settings: PlayerSettings) + +var current_settings: PlayerSettings = PlayerSettings.new() +var _is_dirty: bool = false + + +func load_settings() -> bool: + _recover_interrupted_write() + if not FileAccess.file_exists(SETTINGS_PATH): + current_settings = PlayerSettings.new() + emit_signal("settings_changed", current_settings) + return true + var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) + if file == null: + push_warning("Unable to open player settings; using defaults.") + current_settings = PlayerSettings.new() + emit_signal("settings_changed", current_settings) + return false + var json := JSON.new() + var error: Error = json.parse(file.get_as_text()) + file.close() + if error != OK or typeof(json.data) != TYPE_DICTIONARY: + return _use_defaults_after_corruption("Player settings JSON is malformed.") + var data: Dictionary = json.data + if _read_integer(data.get("settings_version"), -1) != SETTINGS_VERSION: + return _use_defaults_after_corruption("Player settings version is unsupported.") + if ( + typeof(data.get("accessibility")) != TYPE_DICTIONARY + or typeof(data.get("camera")) != TYPE_DICTIONARY + ): + return _use_defaults_after_corruption("Player settings structure is invalid.") + var accessibility: Dictionary = data["accessibility"] + var camera: Dictionary = data["camera"] + if ( + typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL + or typeof(camera.get("invert_vertical")) != TYPE_BOOL + ): + return _use_defaults_after_corruption("Player settings values are invalid.") + var loaded := PlayerSettings.new() + loaded.auto_click_enabled = accessibility["auto_click_enabled"] + loaded.auto_click_interval = _read_float( + accessibility.get("auto_click_interval"), + -1.0 + ) + loaded.mouse_camera_sensitivity = _read_float( + camera.get("mouse_sensitivity"), + -1.0 + ) + loaded.controller_camera_sensitivity = _read_float( + camera.get("controller_sensitivity"), + -1.0 + ) + loaded.invert_camera_y = camera["invert_vertical"] + if not loaded.is_valid(): + return _use_defaults_after_corruption("Player settings values are invalid.") + current_settings = loaded + _is_dirty = false + emit_signal("settings_changed", current_settings) + return true + + +func apply_settings(settings: PlayerSettings) -> bool: + if settings == null or not settings.is_valid(): + return false + var previous: PlayerSettings = current_settings + current_settings = settings.copy() + _is_dirty = true + if not save_now(): + current_settings = previous + return false + emit_signal("settings_changed", current_settings) + return true + + +func save_now() -> bool: + if current_settings == null or not current_settings.is_valid(): + return false + var data: Dictionary = { + "settings_version": SETTINGS_VERSION, + "accessibility": { + "auto_click_enabled": current_settings.auto_click_enabled, + "auto_click_interval": current_settings.auto_click_interval, + }, + "camera": { + "mouse_sensitivity": current_settings.mouse_camera_sensitivity, + "controller_sensitivity": current_settings.controller_camera_sensitivity, + "invert_vertical": current_settings.invert_camera_y, + }, + } + var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(data, "\t")) + file.flush() + var write_error: Error = file.get_error() + file.close() + if write_error != OK: + _remove_if_present(TEMP_PATH) + return false + var had_primary: bool = FileAccess.file_exists(SETTINGS_PATH) + _remove_if_present(BACKUP_PATH) + if had_primary and not _rename_file(SETTINGS_PATH, BACKUP_PATH): + _remove_if_present(TEMP_PATH) + return false + if not _rename_file(TEMP_PATH, SETTINGS_PATH): + if had_primary: + _rename_file(BACKUP_PATH, SETTINGS_PATH) + return false + _remove_if_present(BACKUP_PATH) + _is_dirty = false + return true + + +func save_if_dirty() -> bool: + return not _is_dirty or save_now() + + +func _exit_tree() -> void: + save_if_dirty() + + +func _use_defaults_after_corruption(message: String) -> bool: + push_warning(message) + current_settings = PlayerSettings.new() + _is_dirty = false + emit_signal("settings_changed", current_settings) + return false + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(SETTINGS_PATH): + _remove_if_present(TEMP_PATH) + _remove_if_present(BACKUP_PATH) + return + if FileAccess.file_exists(BACKUP_PATH): + _rename_file(BACKUP_PATH, SETTINGS_PATH) + _remove_if_present(TEMP_PATH) + + +func _read_integer(value: Variant, fallback: int) -> int: + if typeof(value) == TYPE_INT: + return int(value) + if typeof(value) == TYPE_FLOAT and is_equal_approx(value, round(value)): + return int(round(value)) + return fallback + + +func _read_float(value: Variant, fallback: float) -> float: + if typeof(value) not in [TYPE_FLOAT, TYPE_INT]: + return fallback + var result: float = float(value) + return result if is_finite(result) else fallback + + +func _rename_file(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute( + ProjectSettings.globalize_path(from_path), + ProjectSettings.globalize_path(to_path) + ) == OK + + +func _remove_if_present(path: String) -> bool: + if not FileAccess.file_exists(path): + return true + return DirAccess.remove_absolute( + ProjectSettings.globalize_path(path) + ) == OK diff --git a/settings/player_settings_manager.gd.uid b/settings/player_settings_manager.gd.uid new file mode 100644 index 0000000..d911be7 --- /dev/null +++ b/settings/player_settings_manager.gd.uid @@ -0,0 +1 @@ +uid://dwv3nvfke5xtu diff --git a/ui/game_ui.gd b/ui/game_ui.gd index b8e1275..e8d1d70 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -10,6 +10,8 @@ const FishingSpotType = preload("res://fishing/fishing_spot.gd") const PlayerMenuType = preload("res://ui/player_menu.gd") const PlayerType = preload("res://player/player.gd") const PlayerWalletType = preload("res://economy/player_wallet.gd") +const TitleScreenType = preload("res://ui/title_screen.gd") +const PauseMenuType = preload("res://ui/pause_menu.gd") @onready var _status_label: Label = %StatusLabel @onready var _catch_track: Control = %CatchTrack @@ -22,9 +24,12 @@ const PlayerWalletType = preload("res://economy/player_wallet.gd") @onready var _fishing_panel: PanelContainer = %FishingPanel @onready var _player_menu: PlayerMenuType = %PlayerMenu @onready var _screen_fade: ScreenFade = %ScreenFade +@onready var _title_screen: TitleScreenType = %TitleScreen +@onready var _pause_menu: PauseMenuType = %PauseMenu var _showcase_active: bool = false var _player_menu_open: bool = false +var _gameplay_ui_enabled: bool = false func setup( @@ -59,10 +64,43 @@ func close_player_menu() -> void: _player_menu.close_menu() +func close_player_menu_for_water_recovery() -> void: + _player_menu.close_for_water_recovery() + + +func close_player_menu_for_game_menu() -> void: + _player_menu.close_for_game_menu() + + +func close_player_menu_for_session_end() -> void: + _player_menu.close_for_session_end() + + +func consume_player_menu_escape() -> bool: + return _player_menu.consume_escape() + + +func get_pause_menu() -> PauseMenuType: + return _pause_menu + + +func set_gameplay_ui_enabled(enabled: bool) -> void: + _gameplay_ui_enabled = enabled + if not enabled: + close_player_menu_for_session_end() + _fishing_panel.visible = false + else: + _refresh_fishing_panel_visibility() + + func get_screen_fade() -> ScreenFade: return _screen_fade +func get_title_screen() -> TitleScreenType: + return _title_screen + + func _on_fishing_status_changed(status: String) -> void: if _showcase_active: return @@ -84,7 +122,11 @@ func _refresh_fishing_panel_visibility() -> void: or _barrier_summary.visible or _barrier_health.visible ) - _fishing_panel.visible = has_content and not _player_menu_open + _fishing_panel.visible = ( + _gameplay_ui_enabled + and has_content + and not _player_menu_open + ) func _on_catch_display_changed( diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 94dd4ef..7a344c6 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,9 +1,11 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="3_theme"] [ext_resource type="Script" path="res://ui/screen_fade.gd" id="4_fade"] +[ext_resource type="PackedScene" path="res://ui/title_screen.tscn" id="5_title"] +[ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.055, 0.105, 0.125, 1) @@ -138,3 +140,9 @@ grow_vertical = 2 mouse_filter = 0 color = Color(0, 0, 0, 1) script = ExtResource("4_fade") + +[node name="TitleScreen" parent="." instance=ExtResource("5_title")] +unique_name_in_owner = true + +[node name="PauseMenu" parent="." instance=ExtResource("6_pause")] +unique_name_in_owner = true diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd new file mode 100644 index 0000000..a579002 --- /dev/null +++ b/ui/pause_menu.gd @@ -0,0 +1,305 @@ +class_name PauseMenu +extends Control + +const INPUT_OWNER: StringName = &"game_menu" +const PlayerType = preload("res://player/player.gd") +const SaveManagerType = preload("res://save/player_save_manager.gd") +const SettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) +const SettingsPanelType = preload("res://ui/settings_panel.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") + +signal return_to_title_requested +signal reset_progress_requested +signal quit_requested + +enum ConfirmationAction { + NONE, + RETURN_TO_TITLE, + RESET_PROGRESS, + QUIT_ANYWAY, +} + +enum CloseReason { + USER_RETURN, + BITE_STARTED, + WATER_RECOVERY, + RETURN_TO_TITLE, + RESET_PROGRESS, + QUIT, + TEARDOWN, +} + +@onready var _root_panel: PanelContainer = %RootPanel +@onready var _settings_panel: SettingsPanelType = %SettingsPanel +@onready var _confirmation_panel: PanelContainer = %ConfirmationPanel +@onready var _confirmation_title: Label = %ConfirmationTitle +@onready var _confirmation_text: Label = %ConfirmationText +@onready var _confirm_button: Button = %ConfirmButton +@onready var _cancel_button: Button = %CancelConfirmButton +@onready var _feedback: Label = %FeedbackLabel +@onready var _save_button: Button = %SaveButton + +var _player: PlayerType +var _save_manager: SaveManagerType +var _settings_manager: SettingsManagerType +var _fishing_spot: FishingSpotType +var _prior_movement_enabled: bool = true +var _prior_camera_enabled: bool = true +var _control_snapshot_stored: bool = false +var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE +var _action_in_progress: bool = false + + +func _ready() -> void: + %ResumeButton.pressed.connect(resume) + _save_button.pressed.connect(_save_now) + %SettingsButton.pressed.connect(_open_settings) + %ReturnToTitleButton.pressed.connect(_confirm_return_to_title) + %ResetProgressButton.pressed.connect(_confirm_reset_progress) + %QuitButton.pressed.connect(_request_quit) + _confirm_button.pressed.connect(_accept_confirmation) + _cancel_button.pressed.connect(_close_confirmation) + _settings_panel.applied.connect(_on_settings_applied) + _settings_panel.closed.connect(_on_settings_closed) + + +func setup( + player: PlayerType, + save_manager: SaveManagerType, + settings_manager: SettingsManagerType, + fishing_spot: FishingSpotType, +) -> void: + _player = player + _save_manager = save_manager + _settings_manager = settings_manager + _fishing_spot = fishing_spot + if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): + _fishing_spot.bite_activated.connect(_on_bite_activated) + + +func open_menu() -> void: + if visible or _player == null: + return + _prior_movement_enabled = _player.is_movement_enabled() + _prior_camera_enabled = _player.is_camera_input_enabled() + _control_snapshot_stored = true + _player.set_movement_enabled(false) + _player.set_camera_input_enabled(false) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) + _feedback.text = "" + _root_panel.show() + _settings_panel.hide() + _confirmation_panel.hide() + _confirmation_action = ConfirmationAction.NONE + show() + %ResumeButton.grab_focus() + + +func resume() -> void: + if not visible or _action_in_progress: + return + close_menu(CloseReason.USER_RETURN) + + +func close_for_title_transition() -> void: + close_menu(CloseReason.RETURN_TO_TITLE, false) + + +func close_for_water_recovery() -> void: + close_menu(CloseReason.WATER_RECOVERY) + + +func close_menu( + _reason: CloseReason, + restore_controls: bool = true, +) -> void: + if not visible: + return + if _settings_panel.visible: + _settings_panel.close_panel() + hide() + _root_panel.show() + _settings_panel.hide() + _confirmation_panel.hide() + _confirmation_action = ConfirmationAction.NONE + _action_in_progress = false + if _fishing_spot != null and is_instance_valid(_fishing_spot): + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) + get_viewport().gui_release_focus() + if restore_controls: + _restore_controls() + else: + _control_snapshot_stored = false + + +func handle_escape() -> bool: + if not visible: + return false + if _confirmation_panel.visible: + _close_confirmation() + elif _settings_panel.visible: + _settings_panel.close_panel() + else: + resume() + return true + + +func _save_now() -> void: + if _action_in_progress: + return + _action_in_progress = true + _save_button.disabled = true + if _save_manager.save_now(): + _feedback.text = "Game saved." + else: + _feedback.text = "Save failed. Previous save was preserved." + _action_in_progress = false + call_deferred("_reenable_save_button") + + +func _reenable_save_button() -> void: + _save_button.disabled = false + + +func _open_settings() -> void: + if _action_in_progress or _confirmation_panel.visible: + return + _root_panel.hide() + _settings_panel.open_panel(_settings_manager) + + +func _on_settings_applied() -> void: + _root_panel.show() + _feedback.text = "Settings saved." + %SettingsButton.grab_focus() + + +func _on_settings_closed() -> void: + _root_panel.show() + %SettingsButton.grab_focus() + + +func _confirm_return_to_title() -> void: + _open_confirmation( + ConfirmationAction.RETURN_TO_TITLE, + "Return to Title", + "Unsaved progress will be saved first.", + "Save and Return", + false + ) + + +func _confirm_reset_progress() -> void: + _open_confirmation( + ConfirmationAction.RESET_PROGRESS, + "Reset all progression?", + ( + "This permanently deletes your fish, discoveries, " + + "favorites, and wallet balance.\n\n" + + "Your settings will be preserved." + ), + "DELETE ALL PROGRESS", + true + ) + + +func _request_quit() -> void: + if _action_in_progress: + return + _action_in_progress = true + var settings_saved: bool = _settings_manager.save_if_dirty() + var progression_saved: bool = _save_manager.save_if_dirty() + _action_in_progress = false + if settings_saved and progression_saved: + quit_requested.emit() + return + _open_confirmation( + ConfirmationAction.QUIT_ANYWAY, + "Save failed", + "Some progress or settings could not be saved. Quit anyway?", + "Quit Anyway", + true + ) + + +func _open_confirmation( + action: ConfirmationAction, + title: String, + message: String, + confirm_text: String, + dangerous: bool, +) -> void: + if _action_in_progress: + return + _confirmation_action = action + _confirmation_title.text = title + _confirmation_text.text = message + _confirm_button.text = confirm_text + _confirm_button.theme_type_variation = ( + &"DangerButton" if dangerous else StringName() + ) + _root_panel.hide() + _confirmation_panel.show() + if dangerous: + _cancel_button.grab_focus() + else: + _confirm_button.grab_focus() + + +func _close_confirmation() -> void: + _confirmation_action = ConfirmationAction.NONE + _confirmation_panel.hide() + _root_panel.show() + %ResumeButton.grab_focus() + + +func _accept_confirmation() -> void: + if _action_in_progress: + return + var action: ConfirmationAction = _confirmation_action + _confirmation_action = ConfirmationAction.NONE + _confirmation_panel.hide() + _action_in_progress = true + match action: + ConfirmationAction.RETURN_TO_TITLE: + if _save_manager.save_now(): + return_to_title_requested.emit() + else: + _feedback.text = "Save failed. Previous save was preserved." + _root_panel.show() + ConfirmationAction.RESET_PROGRESS: + reset_progress_requested.emit() + ConfirmationAction.QUIT_ANYWAY: + quit_requested.emit() + _: + _root_panel.show() + _action_in_progress = false + + +func report_reset_failure() -> void: + _action_in_progress = false + _root_panel.show() + _confirmation_panel.hide() + _feedback.text = "Reset failed. Your progression was preserved." + %ResumeButton.grab_focus() + + +func _restore_controls() -> void: + if not _control_snapshot_stored: + return + if _player != null and is_instance_valid(_player): + _player.set_movement_enabled(_prior_movement_enabled) + _player.set_camera_input_enabled(_prior_camera_enabled) + _control_snapshot_stored = false + + +func _on_bite_activated() -> void: + if visible: + close_menu(CloseReason.BITE_STARTED) + + +func _exit_tree() -> void: + if visible: + close_menu(CloseReason.TEARDOWN) diff --git a/ui/pause_menu.gd.uid b/ui/pause_menu.gd.uid new file mode 100644 index 0000000..700d452 --- /dev/null +++ b/ui/pause_menu.gd.uid @@ -0,0 +1 @@ +uid://djp0uuwevg2ht diff --git a/ui/pause_menu.tscn b/ui/pause_menu.tscn new file mode 100644 index 0000000..5ffe0da --- /dev/null +++ b/ui/pause_menu.tscn @@ -0,0 +1,173 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://ui/pause_menu.gd" id="1_script"] +[ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="2_settings"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="3_theme"] + +[node name="PauseMenu" type="Control"] +unique_name_in_owner = true +visible = false +z_index = 190 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +theme = ExtResource("3_theme") +script = ExtResource("1_script") + +[node name="DimBackground" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +color = Color(0.02, 0.05, 0.07, 0.78) + +[node name="RootCenter" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="RootPanel" type="PanelContainer" parent="RootCenter"] +unique_name_in_owner = true +custom_minimum_size = Vector2(420, 0) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="RootCenter/RootPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 32 +theme_override_constants/margin_top = 26 +theme_override_constants/margin_right = 32 +theme_override_constants/margin_bottom = 26 + +[node name="Content" type="VBoxContainer" parent="RootCenter/RootPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 11 + +[node name="Title" type="Label" parent="RootCenter/RootPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Game Menu" +horizontal_alignment = 1 + +[node name="ResumeButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +text = "Return to Game" + +[node name="SaveButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +text = "Save Now" + +[node name="SettingsButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +text = "Settings" + +[node name="ReturnToTitleButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +text = "Return to Title" + +[node name="ResetProgressButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "Reset Progress" + +[node name="QuitButton" type="Button" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +text = "Quit" + +[node name="FeedbackLabel" type="Label" parent="RootCenter/RootPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="SettingsCenter" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="SettingsPanel" parent="SettingsCenter" instance=ExtResource("2_settings")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="ConfirmationCenter" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ConfirmationPanel" type="PanelContainer" parent="ConfirmationCenter"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(520, 0) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="ConfirmationCenter/ConfirmationPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 24 + +[node name="Content" type="VBoxContainer" parent="ConfirmationCenter/ConfirmationPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 15 + +[node name="ConfirmationTitle" type="Label" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.937, 0.357, 0.384, 1) +theme_override_font_sizes/font_size = 24 +text = "Confirm" +horizontal_alignment = 1 + +[node name="ConfirmationText" type="Label" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Are you sure?" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="Buttons" type="HBoxContainer" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="ConfirmButton" type="Button" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 43) +layout_mode = 2 +text = "Confirm" + +[node name="CancelConfirmButton" type="Button" parent="ConfirmationCenter/ConfirmationPanel/Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(130, 43) +layout_mode = 2 +text = "Cancel" diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 7932886..d7bd5f6 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -1,6 +1,7 @@ class_name PlayerMenu extends Control +const INPUT_OWNER: StringName = &"player_menu" const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") @@ -26,6 +27,15 @@ enum SortMode { RARITY, } +enum CloseReason { + USER, + BITE_STARTED, + WATER_RECOVERY, + GAME_MENU, + SESSION_END, + TEARDOWN, +} + @onready var _inventory_tab: Button = %InventoryTab @onready var _logbook_tab: Button = %LogbookTab @onready var _close_button: Button = %CloseButton @@ -128,20 +138,10 @@ func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return if visible: - if ( - event.is_action_pressed("ui_cancel") - and _sale_confirmation.visible - ): - _close_sale_confirmation() - get_viewport().set_input_as_handled() - return - if ( - event.is_action_pressed("open_backpack") - or event.is_action_pressed("ui_cancel") - ): + if event.is_action_pressed("open_backpack"): close_menu() get_viewport().set_input_as_handled() - return + return if ( event.is_action_pressed("open_backpack") and _fishing_spot != null @@ -151,6 +151,16 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func consume_escape() -> bool: + if not visible: + return false + if _sale_confirmation.visible: + _close_sale_confirmation() + else: + close_menu() + return true + + func open_menu() -> void: if ( visible @@ -165,6 +175,7 @@ func open_menu() -> void: _control_snapshot_stored = true _player.set_movement_enabled(false) _player.set_camera_input_enabled(false) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) visible = true _refresh_all() _show_section(_current_section) @@ -172,22 +183,41 @@ func open_menu() -> void: menu_visibility_changed.emit(true) -func close_menu() -> void: +func close_menu( + _reason: CloseReason = CloseReason.USER, + restore_controls: bool = true, +) -> void: if not visible: return _close_sale_confirmation() var closing_generation: int = _menu_generation visible = false get_viewport().gui_release_focus() - _restore_player_controls(closing_generation) + if _fishing_spot != null and is_instance_valid(_fishing_spot): + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) + if restore_controls: + _restore_player_controls(closing_generation) + else: + _control_snapshot_stored = false _menu_generation += 1 menu_visibility_changed.emit(false) +func close_for_water_recovery() -> void: + close_menu(CloseReason.WATER_RECOVERY) + + +func close_for_game_menu() -> void: + close_menu(CloseReason.GAME_MENU) + + +func close_for_session_end() -> void: + close_menu(CloseReason.SESSION_END, false) + + func _exit_tree() -> void: if visible: - visible = false - _restore_player_controls(_menu_generation) + close_menu(CloseReason.TEARDOWN, false) _selected_catch_id = StringName() _menu_generation += 1 @@ -206,7 +236,7 @@ func _restore_player_controls(generation: int) -> void: func _on_bite_activated() -> void: if visible: - close_menu() + close_menu(CloseReason.BITE_STARTED) func _show_section(section: Section) -> void: diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index fb2f660..10db017 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -4,7 +4,6 @@ [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [node name="PlayerMenu" type="Control"] -process_mode = 3 visible = false layout_mode = 3 anchors_preset = 15 diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd new file mode 100644 index 0000000..a6b1553 --- /dev/null +++ b/ui/settings_panel.gd @@ -0,0 +1,80 @@ +class_name SettingsPanel +extends PanelContainer + +const SettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) + +signal applied +signal closed + +@onready var _auto_click_toggle: CheckButton = %AutoClickToggle +@onready var _auto_click_interval: HSlider = %AutoClickInterval +@onready var _auto_click_interval_value: Label = %AutoClickIntervalValue +@onready var _mouse_sensitivity: HSlider = %MouseSensitivity +@onready var _mouse_sensitivity_value: Label = %MouseSensitivityValue +@onready var _controller_sensitivity: HSlider = %ControllerSensitivity +@onready var _controller_sensitivity_value: Label = %ControllerSensitivityValue +@onready var _invert_y_toggle: CheckButton = %InvertYToggle +@onready var _feedback: Label = %SettingsFeedback + +var _settings_manager: SettingsManagerType + + +func _ready() -> void: + %ApplySettingsButton.pressed.connect(_apply_settings) + %CancelSettingsButton.pressed.connect(close_panel) + _auto_click_interval.value_changed.connect(_refresh_value_labels) + _mouse_sensitivity.value_changed.connect(_refresh_value_labels) + _controller_sensitivity.value_changed.connect(_refresh_value_labels) + + +func open_panel(settings_manager: SettingsManagerType) -> void: + _settings_manager = settings_manager + _load_controls() + _feedback.text = "" + show() + _auto_click_toggle.grab_focus() + + +func close_panel() -> void: + if not visible: + return + hide() + _load_controls() + closed.emit() + + +func _apply_settings() -> void: + if _settings_manager == null: + _feedback.text = "Settings are unavailable." + return + var edited := PlayerSettings.new() + edited.auto_click_enabled = _auto_click_toggle.button_pressed + edited.auto_click_interval = float(_auto_click_interval.value) + edited.mouse_camera_sensitivity = float(_mouse_sensitivity.value) + edited.controller_camera_sensitivity = float(_controller_sensitivity.value) + edited.invert_camera_y = _invert_y_toggle.button_pressed + if _settings_manager.apply_settings(edited): + hide() + applied.emit() + else: + _feedback.text = "Failed to save settings." + + +func _load_controls() -> void: + if _settings_manager == null or not is_node_ready(): + return + var settings: PlayerSettings = _settings_manager.current_settings + _auto_click_toggle.button_pressed = settings.auto_click_enabled + _auto_click_interval.value = settings.auto_click_interval + _mouse_sensitivity.value = settings.mouse_camera_sensitivity + _controller_sensitivity.value = settings.controller_camera_sensitivity + _invert_y_toggle.button_pressed = settings.invert_camera_y + _refresh_value_labels(0.0) + + +func _refresh_value_labels(_unused: float) -> void: + _auto_click_interval_value.text = "%.2f s" % _auto_click_interval.value + _mouse_sensitivity_value.text = "%.4f" % _mouse_sensitivity.value + _controller_sensitivity_value.text = "%.1f" % _controller_sensitivity.value diff --git a/ui/settings_panel.gd.uid b/ui/settings_panel.gd.uid new file mode 100644 index 0000000..dd49c76 --- /dev/null +++ b/ui/settings_panel.gd.uid @@ -0,0 +1 @@ +uid://cuoxpo2o3usey diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn new file mode 100644 index 0000000..a2f75a0 --- /dev/null +++ b/ui/settings_panel.tscn @@ -0,0 +1,136 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/settings_panel.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] + +[node name="SettingsPanel" type="PanelContainer"] +visible = false +custom_minimum_size = Vector2(600, 490) +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 24 + +[node name="Content" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Heading" type="Label" parent="Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 26 +text = "Settings" +horizontal_alignment = 1 + +[node name="AutoClickToggle" type="CheckButton" parent="Margin/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Accessibility auto-click" + +[node name="AutoClickHelp" type="Label" parent="Margin/Content"] +layout_mode = 2 +text = "Lower intervals click barriers faster while held." + +[node name="AutoClickRow" type="HBoxContainer" parent="Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Label" type="Label" parent="Margin/Content/AutoClickRow"] +custom_minimum_size = Vector2(190, 0) +layout_mode = 2 +text = "Auto-click interval" + +[node name="AutoClickInterval" type="HSlider" parent="Margin/Content/AutoClickRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.1 +max_value = 0.5 +step = 0.01 +value = 0.2 + +[node name="AutoClickIntervalValue" type="Label" parent="Margin/Content/AutoClickRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(62, 0) +layout_mode = 2 +text = "0.20 s" + +[node name="MouseRow" type="HBoxContainer" parent="Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Label" type="Label" parent="Margin/Content/MouseRow"] +custom_minimum_size = Vector2(190, 0) +layout_mode = 2 +text = "Mouse camera sensitivity" + +[node name="MouseSensitivity" type="HSlider" parent="Margin/Content/MouseRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.001 +max_value = 0.012 +step = 0.0005 +value = 0.005 + +[node name="MouseSensitivityValue" type="Label" parent="Margin/Content/MouseRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(62, 0) +layout_mode = 2 +text = "0.0050" + +[node name="ControllerRow" type="HBoxContainer" parent="Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Label" type="Label" parent="Margin/Content/ControllerRow"] +custom_minimum_size = Vector2(190, 0) +layout_mode = 2 +text = "Controller camera sensitivity" + +[node name="ControllerSensitivity" type="HSlider" parent="Margin/Content/ControllerRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.5 +max_value = 5.0 +step = 0.1 +value = 2.5 + +[node name="ControllerSensitivityValue" type="Label" parent="Margin/Content/ControllerRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(62, 0) +layout_mode = 2 +text = "2.5" + +[node name="InvertYToggle" type="CheckButton" parent="Margin/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Invert vertical camera" + +[node name="SettingsFeedback" type="Label" parent="Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +horizontal_alignment = 1 + +[node name="Buttons" type="HBoxContainer" parent="Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 2 + +[node name="ApplySettingsButton" type="Button" parent="Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 42) +layout_mode = 2 +text = "Apply" + +[node name="CancelSettingsButton" type="Button" parent="Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 42) +layout_mode = 2 +text = "Back" diff --git a/ui/title_screen.gd b/ui/title_screen.gd new file mode 100644 index 0000000..2ce5720 --- /dev/null +++ b/ui/title_screen.gd @@ -0,0 +1,232 @@ +class_name TitleScreen +extends Control + +const SaveInspectionType = preload("res://save/player_save_inspection.gd") +const SaveManagerType = preload("res://save/player_save_manager.gd") +const SettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) +const SettingsPanelType = preload("res://ui/settings_panel.gd") + +signal gameplay_requested +signal quit_requested + +enum ConfirmationAction { + NONE, + NEW_GAME, + DELETE_SAVE, +} + +@onready var _continue_button: Button = %ContinueButton +@onready var _new_game_button: Button = %NewGameButton +@onready var _settings_button: Button = %SettingsButton +@onready var _delete_button: Button = %DeleteSaveButton +@onready var _feedback_label: Label = %FeedbackLabel +@onready var _confirmation_panel: PanelContainer = %ConfirmationPanel +@onready var _confirmation_text: Label = %ConfirmationText +@onready var _confirm_button: Button = %ConfirmButton +@onready var _settings_panel: SettingsPanelType = %SettingsPanel + +var _save_manager: SaveManagerType +var _settings_manager: SettingsManagerType +var _inspection: SaveInspectionType +var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE +var _action_in_progress: bool = false + + +func _ready() -> void: + _continue_button.pressed.connect(_on_continue_pressed) + _new_game_button.pressed.connect(_on_new_game_pressed) + _settings_button.pressed.connect(_open_settings) + _delete_button.pressed.connect(_on_delete_pressed) + %QuitButton.pressed.connect(_on_quit_pressed) + _confirm_button.pressed.connect(_on_confirmation_accepted) + %CancelConfirmButton.pressed.connect(_close_confirmation) + _settings_panel.applied.connect(_on_settings_applied) + _settings_panel.closed.connect(_on_settings_closed) + + +func setup( + save_manager: SaveManagerType, + settings_manager: SettingsManagerType, +) -> void: + _save_manager = save_manager + _settings_manager = settings_manager + _refresh_save_inspection() + show() + call_deferred("_focus_initial_button") + + +func reopen() -> void: + _close_confirmation() + _settings_panel.hide() + _refresh_save_inspection() + show() + call_deferred("_focus_initial_button") + + +func _input(event: InputEvent) -> void: + if not visible or not event.is_action_pressed("ui_cancel"): + return + if _confirmation_panel.visible: + _close_confirmation() + elif _settings_panel.visible: + _close_settings() + else: + return + get_viewport().set_input_as_handled() + + +func _on_continue_pressed() -> void: + if ( + _action_in_progress + or _confirmation_panel.visible + or _settings_panel.visible + or _inspection == null + or not _inspection.can_continue() + ): + return + _action_in_progress = true + if _save_manager.load_player_data(): + _feedback_label.text = "Save loaded." + gameplay_requested.emit() + else: + _refresh_save_inspection() + _feedback_label.text = "Failed to load save. The original was preserved." + _action_in_progress = false + + +func _on_new_game_pressed() -> void: + if ( + _action_in_progress + or _confirmation_panel.visible + or _settings_panel.visible + or _inspection == null + ): + return + if _inspection.status == SaveInspectionType.Status.MISSING: + if _save_manager.initialize_new_game(): + gameplay_requested.emit() + return + if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION: + _feedback_label.text = ( + "This save is from a newer game version. " + + "Use Delete Save before starting over." + ) + return + if _inspection.status == SaveInspectionType.Status.IO_ERROR: + _feedback_label.text = "The existing save cannot be accessed safely." + return + _open_confirmation( + ConfirmationAction.NEW_GAME, + "Start a new game? Existing progression will be deleted.", + "Start New Game" + ) + + +func _on_delete_pressed() -> void: + if ( + _action_in_progress + or _confirmation_panel.visible + or _settings_panel.visible + or _inspection == null + or not _inspection.can_delete() + ): + return + _open_confirmation( + ConfirmationAction.DELETE_SAVE, + "Delete your saved progression? This cannot be undone.", + "Delete" + ) + + +func _on_confirmation_accepted() -> void: + if _action_in_progress: + return + var action: ConfirmationAction = _confirmation_action + _close_confirmation() + _action_in_progress = true + if not _save_manager.delete_progression_save(): + _feedback_label.text = "Failed to delete saved progression." + _action_in_progress = false + _refresh_save_inspection() + return + _save_manager.initialize_new_game() + _refresh_save_inspection() + if action == ConfirmationAction.NEW_GAME: + gameplay_requested.emit() + else: + _feedback_label.text = "Saved progression deleted." + _action_in_progress = false + + +func _open_confirmation( + action: ConfirmationAction, + message: String, + confirm_text: String, +) -> void: + _confirmation_action = action + _confirmation_text.text = message + _confirm_button.text = confirm_text + _confirmation_panel.visible = true + _confirm_button.grab_focus() + + +func _close_confirmation() -> void: + _confirmation_action = ConfirmationAction.NONE + _confirmation_panel.visible = false + _focus_initial_button() + + +func _open_settings() -> void: + if _confirmation_panel.visible or _action_in_progress: + return + _feedback_label.text = "" + _settings_panel.open_panel(_settings_manager) + + +func _close_settings() -> void: + _settings_panel.close_panel() + + +func _on_settings_applied() -> void: + _feedback_label.text = "Settings saved." + _settings_button.grab_focus() + + +func _on_settings_closed() -> void: + _settings_button.grab_focus() + + +func _refresh_save_inspection() -> void: + _inspection = _save_manager.inspect_save() + _continue_button.disabled = not _inspection.can_continue() + _delete_button.disabled = not _inspection.can_delete() + _feedback_label.text = _inspection.message + if _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED: + _feedback_label.text = ( + "%d fish • $%d • %d discovered" + % [ + _inspection.catch_count, + _inspection.wallet_balance, + _inspection.discovered_species_count, + ] + ) + + +func _focus_initial_button() -> void: + if not is_node_ready() or not visible: + return + if not _continue_button.disabled: + _continue_button.grab_focus() + else: + _new_game_button.grab_focus() + + +func _on_quit_pressed() -> void: + if ( + not _action_in_progress + and not _confirmation_panel.visible + and not _settings_panel.visible + ): + quit_requested.emit() diff --git a/ui/title_screen.gd.uid b/ui/title_screen.gd.uid new file mode 100644 index 0000000..327d231 --- /dev/null +++ b/ui/title_screen.gd.uid @@ -0,0 +1 @@ +uid://6xhmtogx3c66 diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn new file mode 100644 index 0000000..4815567 --- /dev/null +++ b/ui/title_screen.tscn @@ -0,0 +1,172 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] +[ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="3_settings"] + +[node name="TitleScreen" type="Control"] +unique_name_in_owner = true +z_index = 200 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.047, 0.102, 0.145, 1) + +[node name="Center" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="MainPanel" type="PanelContainer" parent="Center"] +custom_minimum_size = Vector2(440, 0) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="Center/MainPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 36 +theme_override_constants/margin_top = 30 +theme_override_constants/margin_right = 36 +theme_override_constants/margin_bottom = 30 + +[node name="Content" type="VBoxContainer" parent="Center/MainPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="Center/MainPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 36 +text = "NETFISHING" +horizontal_alignment = 1 + +[node name="Subtitle" type="Label" parent="Center/MainPanel/Margin/Content"] +layout_mode = 2 +text = "A cozy social fishing game" +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="Center/MainPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 12) +layout_mode = 2 + +[node name="ContinueButton" type="Button" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "Continue" + +[node name="NewGameButton" type="Button" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "New Game" + +[node name="SettingsButton" type="Button" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "Settings" + +[node name="DeleteSaveButton" type="Button" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "Delete Save" + +[node name="QuitButton" type="Button" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "Quit" + +[node name="FeedbackLabel" type="Label" parent="Center/MainPanel/Margin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +text = "" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="ConfirmationPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +z_index = 210 +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -230.0 +offset_top = -95.0 +offset_right = 230.0 +offset_bottom = 95.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="ConfirmationPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 20 + +[node name="Content" type="VBoxContainer" parent="ConfirmationPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="ConfirmationText" type="Label" parent="ConfirmationPanel/Margin/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Confirm?" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="Buttons" type="HBoxContainer" parent="ConfirmationPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="ConfirmButton" type="Button" parent="ConfirmationPanel/Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 42) +layout_mode = 2 +text = "Confirm" + +[node name="CancelConfirmButton" type="Button" parent="ConfirmationPanel/Margin/Content/Buttons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 42) +layout_mode = 2 +text = "Cancel" + +[node name="SettingsPanel" parent="." instance=ExtResource("3_settings")] +unique_name_in_owner = true +z_index = 210 +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -300.0 +offset_top = -245.0 +offset_right = 300.0 +offset_bottom = 245.0 +grow_horizontal = 2 +grow_vertical = 2 diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index fe0d37a..7a137b7 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -1,6 +1,8 @@ class_name WaterRecoveryController extends Node +signal recovery_starting + enum RecoveryState { IDLE, BOBBING, @@ -28,6 +30,7 @@ var _bob_elapsed: float = 0.0 var _prior_movement_enabled: bool = true var _prior_camera_input_enabled: bool = true var _generation: int = 0 +var _recovery_enabled: bool = false func setup( @@ -68,6 +71,14 @@ func _process(delta: float) -> void: _screen_fade.fade_to_black(_generation) +func set_recovery_enabled(enabled: bool) -> void: + _recovery_enabled = enabled + + +func is_recovery_active() -> bool: + return state != RecoveryState.IDLE + + func _exit_tree() -> void: _generation += 1 if _screen_fade != null and is_instance_valid(_screen_fade): @@ -84,14 +95,16 @@ func _on_recovery_requested( surface_height: float, ) -> void: if ( + not _recovery_enabled + or state != RecoveryState.IDLE or triggered_player != _player or not is_instance_valid(triggered_player) ): return + recovery_starting.emit() _generation += 1 _entry_position = _player.global_position - _game_ui.close_player_menu() _fishing_spot.begin_water_recovery() _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_input_enabled = _player.is_camera_input_enabled()