diff --git a/fish/fish_experience.gd b/fish/fish_experience.gd new file mode 100644 index 0000000..592042f --- /dev/null +++ b/fish/fish_experience.gd @@ -0,0 +1,73 @@ +class_name FishExperience +extends RefCounted + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") + +const RARITY_BASE_EXPERIENCE: Array[int] = [10, 18, 32, 55, 90] +const QUALITY_MULTIPLIERS: Array[float] = [1.0, 1.15, 1.4, 1.8, 2.5] +const MAXIMUM_WEIGHT_BONUS: float = 0.25 +const FIRST_SPECIES_BONUS: int = 25 +const FIRST_QUALITY_BONUS: int = 15 +const SPECIES_MASTERY_BONUS: int = 100 + + +static func calculate_for_collection( + fish_catch: FishCatchType, + collection_log: CollectionLogType, +) -> int: + if fish_catch == null or collection_log == null: + return 0 + return calculate_catch_experience( + fish_catch, + collection_log.has_discovered(fish_catch.fish_id), + collection_log.get_quality_mask(fish_catch.fish_id), + ) + + +static func calculate_catch_experience( + fish_catch: FishCatchType, + was_species_discovered: bool, + previous_quality_mask: int, +) -> int: + if fish_catch == null or not fish_catch.is_valid(): + return 0 + var rarity: int = int(fish_catch.fish.rarity) + if rarity < 0 or rarity >= RARITY_BASE_EXPERIENCE.size(): + return 0 + if not FishQualityType.is_valid(fish_catch.quality): + return 0 + var weight_percentile: float = _get_weight_percentile(fish_catch) + var weight_multiplier: float = ( + 1.0 + MAXIMUM_WEIGHT_BONUS * weight_percentile * weight_percentile + ) + var catch_experience: int = roundi( + float(RARITY_BASE_EXPERIENCE[rarity]) + * QUALITY_MULTIPLIERS[fish_catch.quality] + * weight_multiplier + ) + var quality_bit: int = FishQualityType.bit_for(fish_catch.quality) + if not was_species_discovered: + catch_experience += FIRST_SPECIES_BONUS + if (previous_quality_mask & quality_bit) == 0: + catch_experience += FIRST_QUALITY_BONUS + var next_quality_mask: int = previous_quality_mask | quality_bit + if ( + previous_quality_mask != FishQualityType.ALL_TIERS_MASK + and next_quality_mask == FishQualityType.ALL_TIERS_MASK + ): + catch_experience += SPECIES_MASTERY_BONUS + return maxi(catch_experience, 0) + + +static func _get_weight_percentile(fish_catch: FishCatchType) -> float: + var minimum_weight: float = fish_catch.fish.get_minimum_weight() + var maximum_weight: float = fish_catch.fish.get_maximum_weight() + if maximum_weight <= minimum_weight: + return 0.0 + return clampf( + inverse_lerp(minimum_weight, maximum_weight, fish_catch.weight_lb), + 0.0, + 1.0, + ) diff --git a/fish/fish_experience.gd.uid b/fish/fish_experience.gd.uid new file mode 100644 index 0000000..aafa145 --- /dev/null +++ b/fish/fish_experience.gd.uid @@ -0,0 +1 @@ +uid://d05mwat7tju1b diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 1b4e577..c694461 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -9,6 +9,10 @@ const CatchControllerType = preload("res://fishing/catch_controller.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemDataType = preload("res://items/item_data.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") @@ -101,9 +105,9 @@ const BITE_QUICK_MAX_SECONDS: float = 30.0 const BITE_TYPICAL_MAX_SECONDS: float = 90.0 const BITE_LONG_MAX_SECONDS: float = 180.0 const BITE_MAX_SECONDS: float = 240.0 -const BITE_QUICK_PROBABILITY: float = 0.15 -const BITE_TYPICAL_PROBABILITY: float = 0.55 -const BITE_LONG_PROBABILITY: float = 0.25 +const BITE_QUICK_PROBABILITY: float = 0.25 +const BITE_TYPICAL_PROBABILITY: float = 0.65 +const BITE_LONG_PROBABILITY: float = 0.08 const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 @@ -126,6 +130,7 @@ var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_player: PlayerType var _local_inventory: FishInventoryType var _local_collection_log: CollectionLogType +var _local_experience: PlayerExperienceType var _local_bag: PlayerBagType var _local_hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType @@ -186,6 +191,7 @@ func setup( local_player: PlayerType, local_inventory: FishInventoryType, local_collection_log: CollectionLogType, + local_experience: PlayerExperienceType, local_bag: PlayerBagType, local_hotbar: PlayerHotbarType, item_catalog: ItemCatalogType, @@ -201,6 +207,7 @@ func setup( _local_player = local_player _local_inventory = local_inventory _local_collection_log = local_collection_log + _local_experience = local_experience _local_bag = local_bag _local_hotbar = local_hotbar _item_catalog = item_catalog @@ -382,11 +389,7 @@ func _secure_showcase_catch_for_recovery() -> void: and _local_inventory != null and _local_collection_log != null ): - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_quality_discovered( - _pending_catch.fish_id, - _pending_catch.quality, - ) + _store_catch_progression(_pending_catch) _pending_catch = null _showcase_ready = false _put_away_press_armed = false @@ -406,11 +409,7 @@ func _exit_tree() -> void: and _local_collection_log != null and is_instance_valid(_local_collection_log) ): - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_quality_discovered( - _pending_catch.fish_id, - _pending_catch.quality, - ) + _store_catch_progression(_pending_catch) _pending_catch = null if _active_player != null and is_instance_valid(_active_player): _active_player.end_catch_showcase(Callable(), true) @@ -1061,11 +1060,7 @@ func _put_away_catch() -> void: or _pending_catch == null ): return - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_quality_discovered( - _pending_catch.fish_id, - _pending_catch.quality, - ) + _store_catch_progression(_pending_catch) _pending_catch = null _showcase_ready = false _showcase_outcome_completed = false @@ -1081,6 +1076,29 @@ func _put_away_catch() -> void: ) +func _store_catch_progression(fish_catch: FishCatchType) -> void: + if ( + fish_catch == null + or _local_inventory == null + or _local_collection_log == null + or _local_experience == null + or _local_inventory.contains_catch_id(fish_catch.catch_id) + ): + return + var experience_award: int = ( + FishExperienceType.calculate_for_collection( + fish_catch, + _local_collection_log, + ) + ) + _local_inventory.add_catch(fish_catch) + _local_collection_log.mark_quality_discovered( + fish_catch.fish_id, + fish_catch.quality, + ) + _local_experience.award_experience(experience_award) + + func _finish_showcase_put_away( restore_generation: int, cooldown_message: String, diff --git a/main/main.gd b/main/main.gd index a08a53e..7a074a3 100644 --- a/main/main.gd +++ b/main/main.gd @@ -357,6 +357,8 @@ func _initialize_after_data_root() -> void: _player.fishing_upgrades, _player.cooler_capacity, _player.art_unlocks, + _player.experience, + _world_time, ) _save_manager.set_autosave_enabled(false) _asset_reservations.setup( @@ -411,6 +413,7 @@ func _initialize_after_data_root() -> void: _player.inventory, _player.collection_log, _player.cooler_capacity, + _player.experience, _save_manager, item_catalog, fish_catalog, @@ -451,6 +454,7 @@ func _initialize_after_data_root() -> void: _player, _player.inventory, _player.collection_log, + _player.experience, _player.bag, _player.hotbar, item_catalog, @@ -467,6 +471,7 @@ func _initialize_after_data_root() -> void: _player, _player.inventory, _player.collection_log, + _player.experience, _player.wallet, _player.fish_sale_service, pelican_buyer_profile, @@ -1154,6 +1159,11 @@ func _on_return_to_title_requested() -> void: if _quit_in_progress: return var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + if not _save_manager.save_world_time_checkpoint(): + pause_menu.report_network_error( + "Could not save progression before returning to title." + ) + return pause_menu.close_for_title_transition() _network_session.disconnect_session("Returned to title.") _set_gameplay_active(false) @@ -1176,7 +1186,7 @@ func _on_title_join_game_requested(endpoint: String) -> void: func _on_pause_join_game_requested(endpoint: String) -> void: if _quit_in_progress or not _gameplay_started: return - if not _save_manager.save_if_dirty(): + if not _save_manager.save_world_time_checkpoint(): _game_ui.get_pause_menu().report_network_error( "Could not save progression before leaving this session." ) @@ -1409,7 +1419,7 @@ func _on_quit_requested() -> void: _quit_in_progress = true _settings_manager.save_if_dirty() if _gameplay_started: - _save_manager.save_if_dirty() + _save_manager.save_world_time_checkpoint() _network_session.disconnect_session("Application closing.") _title_music_requested = false _replace_title_music_transition() diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 0df4355..be214e2 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -7,6 +7,10 @@ const FishPoolType = preload("res://fish/fish_pool.gd") const FishSelectorType = preload("res://fish/fish_selector.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") const CollectionLogType = preload("res://collection/collection_log.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const RemotePresentationType = preload( "res://fishing/remote_fishing_presentation.gd" ) @@ -29,6 +33,7 @@ var _fishing_spot: FishingSpot var _local_inventory: FishInventory var _local_collection: CollectionLog var _local_capacity: PlayerCoolerCapacity +var _local_experience: PlayerExperienceType var _save_manager: PlayerSaveManager var _item_catalog: ItemCatalog var _fish_catalog: FishPoolType @@ -51,6 +56,7 @@ func setup( local_inventory: FishInventory, local_collection: CollectionLog, local_capacity: PlayerCoolerCapacity, + local_experience: PlayerExperienceType, save_manager: PlayerSaveManager, item_catalog: ItemCatalog, fish_catalog: FishPoolType, @@ -62,6 +68,7 @@ func setup( _local_inventory = local_inventory _local_collection = local_collection _local_capacity = local_capacity + _local_experience = local_experience _save_manager = save_manager _item_catalog = item_catalog _fish_catalog = fish_catalog @@ -760,11 +767,18 @@ func _apply_target_outcome(data: Dictionary) -> void: ): return if not already_owned: + var experience_award: int = ( + FishExperienceType.calculate_for_collection( + fish_catch, + _local_collection, + ) + ) _local_inventory.add_catch(fish_catch) _local_collection.mark_quality_discovered( fish_id, fish_catch.quality, ) + _local_experience.award_experience(experience_award) if not _save_manager.save_if_dirty(): return _result_ledgers[result_id] = true diff --git a/network/network_world_time_service.gd b/network/network_world_time_service.gd index ad50255..91b4060 100644 --- a/network/network_world_time_service.gd +++ b/network/network_world_time_service.gd @@ -39,12 +39,16 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_time.begin_session() + _world_time.set_persistence_tracking_enabled(true) + _world_time.begin_session( + _world_time.get_persistent_time_hours() + ) return if state == NetworkSession.State.JOINED_CLIENT: _active_session_id = _session.get_session_id() _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_time.set_persistence_tracking_enabled(false) if _session.supports_server_capability( NetworkProtocol.WORLD_TIME_CAPABILITY ): @@ -62,6 +66,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_time.set_persistence_tracking_enabled(false) _world_time.end_session() diff --git a/player/player.gd b/player/player.gd index 7b8a947..2f0f134 100644 --- a/player/player.gd +++ b/player/player.gd @@ -21,6 +21,9 @@ const PlayerCoolerCapacityType = preload( const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const FishingRodAttachmentScene = preload( "res://player/fishing_rod_attachment.tscn" ) @@ -109,6 +112,7 @@ class ShowcaseCameraSnapshot: @onready var item_effects: PlayerItemEffectsType = %ItemEffects @onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity @onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks +@onready var experience: PlayerExperienceType = %Experience @onready var _cast_origin: Marker3D = %CastOrigin @onready var _catch_display: Node3D = %CatchDisplay @onready var _catch_sprite: Sprite3D = %CatchSprite diff --git a/player/player.tscn b/player/player.tscn index 9606811..2f7c0fb 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=17 format=3] +[gd_scene load_steps=18 format=3] [ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] @@ -14,6 +14,7 @@ [ext_resource type="Material" path="res://player/materials/player_blob_shadow.tres" id="12_blob_shadow"] [ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="13_character"] [ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"] +[ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -104,6 +105,10 @@ script = ExtResource("10_capacity") unique_name_in_owner = true script = ExtResource("14_art_unlocks") +[node name="Experience" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("15_experience") + [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"] unique_name_in_owner = true position = Vector3(0, 1.45, -1.15) diff --git a/progression/player_experience.gd b/progression/player_experience.gd new file mode 100644 index 0000000..ec5ba20 --- /dev/null +++ b/progression/player_experience.gd @@ -0,0 +1,130 @@ +class_name PlayerExperience +extends Node + +signal experience_changed(total_experience: int, level: int) +signal experience_awarded( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, +) + +const MAX_TOTAL_EXPERIENCE: int = 1000000000000 +const MAX_LEVEL: int = 100000 + +var _total_experience: int = 0 + + +func get_total_experience() -> int: + return _total_experience + + +func get_level() -> int: + return level_for_total_experience(_total_experience) + + +func get_experience_in_level() -> int: + return _total_experience - total_experience_for_level(get_level()) + + +func get_experience_for_next_level() -> int: + return experience_required_for_next_level(get_level()) + + +func get_level_progress() -> float: + return progress_for_total_experience(_total_experience) + + +func award_experience(amount: int) -> bool: + if amount <= 0 or _total_experience >= MAX_TOTAL_EXPERIENCE: + return false + var previous_total: int = _total_experience + var previous_level: int = level_for_total_experience(previous_total) + _total_experience = mini( + _total_experience + amount, + MAX_TOTAL_EXPERIENCE, + ) + var awarded_amount: int = _total_experience - previous_total + if awarded_amount <= 0: + return false + var new_level: int = level_for_total_experience(_total_experience) + experience_changed.emit(_total_experience, new_level) + experience_awarded.emit( + awarded_amount, + previous_total, + _total_experience, + previous_level, + new_level, + ) + return true + + +func restore_total_experience(total_experience: int) -> bool: + if total_experience < 0 or total_experience > MAX_TOTAL_EXPERIENCE: + return false + var changed: bool = _total_experience != total_experience + _total_experience = total_experience + if changed: + experience_changed.emit(_total_experience, get_level()) + return true + + +func reset_to_defaults() -> void: + restore_total_experience(0) + + +func to_save_data() -> Dictionary: + return {"total_experience": _total_experience} + + +static func experience_required_for_next_level(level: int) -> int: + var safe_level: int = maxi(level, 1) + var level_index: int = safe_level - 1 + return 100 + level_index * 25 + level_index * level_index * 5 + + +static func total_experience_for_level(level: int) -> int: + var completed_levels: int = clampi(level - 1, 0, MAX_LEVEL - 1) + if completed_levels == 0: + return 0 + var linear_sum: int = floori( + float(completed_levels) * float(completed_levels - 1) / 2.0 + ) + var square_sum: int = floori( + float(completed_levels - 1) + * float(completed_levels) + * float(2 * completed_levels - 1) + / 6.0 + ) + return completed_levels * 100 + linear_sum * 25 + square_sum * 5 + + +static func level_for_total_experience(total_experience: int) -> int: + var safe_total: int = clampi( + total_experience, + 0, + MAX_TOTAL_EXPERIENCE, + ) + var low: int = 1 + var high: int = MAX_LEVEL + while low < high: + var middle: int = low + floori(float(high - low + 1) / 2.0) + if total_experience_for_level(middle) <= safe_total: + low = middle + else: + high = middle - 1 + return low + + +static func progress_for_total_experience(total_experience: int) -> float: + var level: int = level_for_total_experience(total_experience) + var level_start: int = total_experience_for_level(level) + var required: int = experience_required_for_next_level(level) + if required <= 0: + return 0.0 + return clampf( + float(total_experience - level_start) / float(required), + 0.0, + 1.0, + ) diff --git a/progression/player_experience.gd.uid b/progression/player_experience.gd.uid new file mode 100644 index 0000000..9b49089 --- /dev/null +++ b/progression/player_experience.gd.uid @@ -0,0 +1 @@ +uid://eml3k7a43e1d diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index e8486c6..72f7676 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -21,8 +21,12 @@ const PlayerCoolerCapacityType = preload( const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const WorldTimeServiceType = preload("res://world/world_time_service.gd") -const SAVE_VERSION: int = 5 +const SAVE_VERSION: int = 6 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 @@ -42,6 +46,8 @@ class LoadSnapshot: var barrier_power_level: int = 0 var cooler_capacity_level: int = 0 var art_unlock_mask: int = 0 + var total_experience: int = 0 + var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -56,6 +62,8 @@ var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _cooler_capacity: PlayerCoolerCapacityType var _art_unlocks: PlayerArtUnlocksType +var _experience: PlayerExperienceType +var _world_time: WorldTimeServiceType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -98,6 +106,8 @@ func setup( fishing_upgrades: PlayerFishingUpgradesType, cooler_capacity: PlayerCoolerCapacityType, art_unlocks: PlayerArtUnlocksType, + experience: PlayerExperienceType, + world_time: WorldTimeServiceType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -109,6 +119,8 @@ func setup( _fishing_upgrades = fishing_upgrades _cooler_capacity = cooler_capacity _art_unlocks = art_unlocks + _experience = experience + _world_time = world_time _is_configured = ( _inventory != null and _collection_log != null @@ -120,6 +132,8 @@ func setup( and _fishing_upgrades != null and _cooler_capacity != null and _art_unlocks != null + and _experience != null + and _world_time != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -154,6 +168,10 @@ func setup( ) if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed): _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed) + if not _experience.experience_changed.is_connected( + _on_experience_changed + ): + _experience.experience_changed.connect(_on_experience_changed) func load_player_data() -> bool: @@ -234,6 +252,12 @@ func load_player_data() -> bool: var art_restored: bool = _art_unlocks.restore_mask( snapshot.art_unlock_mask ) + var experience_restored: bool = _experience.restore_total_experience( + snapshot.total_experience + ) + var world_time_restored: bool = ( + _world_time.restore_persistent_time_hours(snapshot.world_time_hours) + ) _is_restoring = false if ( not inventory_restored @@ -244,6 +268,8 @@ func load_player_data() -> bool: or not upgrades_restored or not cooler_restored or not art_restored + or not experience_restored + or not world_time_restored ): push_error("Validated player save could not be restored.") return false @@ -337,6 +363,17 @@ func save_if_dirty() -> bool: return not _is_dirty or save_now() +func save_world_time_checkpoint() -> bool: + if ( + not _is_configured + or _automatic_saving_blocked + or not _autosave_enabled + ): + return false + _is_dirty = true + return save_now() + + func is_dirty() -> bool: return _is_dirty @@ -465,6 +502,10 @@ func _build_save_dictionary() -> Dictionary: "upgrades": _fishing_upgrades.to_save_data(), "cooler": _cooler_capacity.to_save_data(), "art": _art_unlocks.to_save_data(), + "experience": _experience.to_save_data(), + "world": { + "time_hours": _world_time.get_persistent_time_hours(), + }, } @@ -475,6 +516,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("bag")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY + or typeof(save_data.get("experience")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] @@ -482,6 +524,10 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var inventory_data: Dictionary = save_data["inventory"] var bag_data: Dictionary = save_data["bag"] var hotbar_data: Dictionary = save_data["hotbar"] + var experience_data: Dictionary = save_data["experience"] + var world_data: Dictionary = {} + if typeof(save_data.get("world")) == TYPE_DICTIONARY: + world_data = save_data["world"] var upgrades_data: Dictionary = {} var cooler_data: Dictionary = {} var art_data: Dictionary = {} @@ -507,6 +553,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(bag_data.get("items")) != TYPE_ARRAY or typeof(hotbar_data.get("slots")) != TYPE_ARRAY or not hotbar_data.has("selected_slot") + or not experience_data.has("total_experience") ): return null @@ -525,6 +572,19 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var snapshot := LoadSnapshot.new() snapshot.wallet_balance = balance + snapshot.total_experience = _read_integer( + experience_data["total_experience"], + -1, + PlayerExperienceType.MAX_TOTAL_EXPERIENCE, + ) + if snapshot.total_experience < 0: + return null + if world_data.has("time_hours"): + snapshot.world_time_hours = _read_world_time_hours( + world_data["time_hours"] + ) + if snapshot.world_time_hours < 0.0: + return null var discovered_values: Array = collection_data["discovered_fish_ids"] var seen_discoveries: Dictionary[StringName, bool] = {} for value: Variant in discovered_values: @@ -744,6 +804,8 @@ func _migrate_save( migrated = _migrate_version_3_to_4(migrated) 4: migrated = _migrate_version_4_to_5(migrated) + 5: + migrated = _migrate_version_5_to_6(migrated) _: return {} if migrated.is_empty(): @@ -843,6 +905,16 @@ func _migrate_version_4_to_5(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_5_to_6(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["save_version"] = 6 + migrated["experience"] = {"total_experience": 0} + migrated["world"] = { + "time_hours": WorldTimeServiceType.DEFAULT_START_HOUR, + } + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -888,6 +960,10 @@ func _on_art_unlocks_changed(_unlock_mask: int) -> void: _mark_dirty() +func _on_experience_changed(_total_experience: int, _level: int) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() @@ -949,6 +1025,10 @@ func _restore_defaults() -> void: _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() _art_unlocks.reset_to_defaults() + _experience.reset_to_defaults() + _world_time.restore_persistent_time_hours( + WorldTimeServiceType.DEFAULT_START_HOUR + ) _is_restoring = false _is_dirty = false @@ -974,6 +1054,19 @@ func _read_integer( return invalid_value +func _read_world_time_hours(value: Variant) -> float: + if typeof(value) not in [TYPE_FLOAT, TYPE_INT]: + return -1.0 + var time_hours: float = float(value) + if ( + not is_finite(time_hours) + or time_hours < 0.0 + or time_hours >= WorldTimeServiceType.HOURS_PER_DAY + ): + return -1.0 + return time_hours + + func _read_upgrade_level( value: Variant, maximum_level: int, diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 86cfe03..bd1df0f 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -30,6 +30,9 @@ func _run() -> void: await process_frame var player := main.get("_player") as Player + var world_time := main.get_node("%WorldTimeService") as WorldTimeService + world_time.synchronize_time(19.75) + assert(player.experience.award_experience(125)) var fish_catalog := main.get("fish_catalog") as FishPool var service := main.get_node( "%NetworkFishShowcaseService" @@ -100,7 +103,17 @@ func _run() -> void: var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int((parsed as Dictionary)["save_version"]) == 5) + assert(int((parsed as Dictionary)["save_version"]) == 6) + assert( + int((parsed as Dictionary)["experience"]["total_experience"]) + == 125 + ) + assert( + is_equal_approx( + float((parsed as Dictionary)["world"]["time_hours"]), + 19.75, + ) + ) var saved_catches: Array = (parsed as Dictionary)["inventory"]["catches"] assert(int((saved_catches[0] as Dictionary)["quality"]) == fish_catch.quality) var saved_masks: Dictionary = ( @@ -112,7 +125,12 @@ func _run() -> void: ) assert(player.hotbar.clear_slot(1)) + assert(player.experience.restore_total_experience(0)) + world_time.synchronize_time(8.0) assert(save_manager.load_player_data()) + assert(player.experience.get_total_experience() == 125) + assert(is_equal_approx(world_time.get_time_hours(), 19.75)) + assert(player.experience.get_level() == 2) assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) assert(player.hotbar.get_selected_slot() == 1) assert( diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 8cd1564..e4f3288 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -242,7 +242,14 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 5) + assert(int(migrated.get("save_version", -1)) == 6) + assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) + assert( + is_equal_approx( + float((migrated["world"] as Dictionary)["time_hours"]), + 8.0, + ) + ) var catches: Array = migrated["inventory"]["catches"] assert(int((catches[0] as Dictionary)["quality"]) == 0) var collection: Dictionary = migrated["collection"] diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 3c0ad87..c178aac 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -239,18 +239,29 @@ func _validate_bite_wait_distribution() -> void: var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType root.add_child(fishing_spot) await process_frame + var bite_rng := fishing_spot.get("_bite_rng") as RandomNumberGenerator + bite_rng.seed = 84219 var quick_count: int = 0 var typical_or_long_count: int = 0 + var very_long_count: int = 0 + var total_wait_seconds: float = 0.0 for _sample_index: int in 10000: var wait_seconds: float = fishing_spot.roll_bite_wait_time() assert(wait_seconds >= 10.0) assert(wait_seconds <= 240.0) + total_wait_seconds += wait_seconds if wait_seconds < 30.0: quick_count += 1 else: typical_or_long_count += 1 - assert(quick_count > 0) + if wait_seconds >= 180.0: + very_long_count += 1 + var average_wait_seconds: float = total_wait_seconds / 10000.0 + assert(quick_count >= 2300 and quick_count <= 2700) assert(typical_or_long_count > quick_count) + assert(very_long_count >= 100 and very_long_count <= 300) + assert(average_wait_seconds >= 57.0) + assert(average_wait_seconds <= 61.0) fishing_spot.queue_free() await process_frame diff --git a/tests/player_experience_ui_validation.gd b/tests/player_experience_ui_validation.gd new file mode 100644 index 0000000..93c23eb --- /dev/null +++ b/tests/player_experience_ui_validation.gd @@ -0,0 +1,47 @@ +extends SceneTree + +const GameUIScene: PackedScene = preload("res://ui/game_ui.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var game_ui := GameUIScene.instantiate() as GameUI + root.add_child(game_ui) + await process_frame + game_ui.set("_gameplay_ui_enabled", true) + game_ui.call( + "_on_experience_awarded", + 50, + 0, + 50, + 1, + 1, + ) + var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer + var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer + var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label + var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label + assert(panel != null and bubble != null) + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) + await process_frame + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false) + await process_frame + await process_frame + assert(panel.visible and bubble.visible) + assert(award_label.text == "+50 xp") + assert(bubble_label.text == "+50 xp!") + await create_timer(1.6).timeout + var progress := game_ui.get_node("%ExperienceProgress") as ProgressBar + var level_label := game_ui.get_node("%ExperienceLevelLabel") as Label + assert(is_equal_approx(progress.value, 50.0)) + assert(level_label.text == "level 1") + await create_timer(1.2).timeout + assert(not panel.visible and not bubble.visible) + game_ui.queue_free() + print("Player experience UI validation: PASS") + quit() diff --git a/tests/player_experience_ui_validation.gd.uid b/tests/player_experience_ui_validation.gd.uid new file mode 100644 index 0000000..b2f976b --- /dev/null +++ b/tests/player_experience_ui_validation.gd.uid @@ -0,0 +1 @@ +uid://c03tvq1ydhdqj diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd new file mode 100644 index 0000000..e7b8c18 --- /dev/null +++ b/tests/player_experience_validation.gd @@ -0,0 +1,178 @@ +extends SceneTree + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_level_curve() + _validate_catch_awards() + _validate_player_experience_state() + _validate_save_migration() + print("Player experience validation: PASS") + quit() + + +func _validate_level_curve() -> void: + assert(PlayerExperienceType.experience_required_for_next_level(1) == 100) + assert(PlayerExperienceType.experience_required_for_next_level(2) == 130) + assert(PlayerExperienceType.experience_required_for_next_level(3) == 170) + assert(PlayerExperienceType.total_experience_for_level(1) == 0) + assert(PlayerExperienceType.total_experience_for_level(2) == 100) + assert(PlayerExperienceType.total_experience_for_level(3) == 230) + assert(PlayerExperienceType.total_experience_for_level(4) == 400) + assert(PlayerExperienceType.level_for_total_experience(99) == 1) + assert(PlayerExperienceType.level_for_total_experience(100) == 2) + assert(PlayerExperienceType.level_for_total_experience(399) == 3) + assert(PlayerExperienceType.level_for_total_experience(400) == 4) + assert( + is_equal_approx( + PlayerExperienceType.progress_for_total_experience(50), + 0.5, + ) + ) + + +func _validate_catch_awards() -> void: + var minimum_boring: FishCatch = _make_catch( + FishQualityType.Tier.BORING, + false, + ) + assert( + FishExperienceType.calculate_catch_experience( + minimum_boring, + false, + 0, + ) == 50 + ) + var maximum_shiny: FishCatch = _make_catch( + FishQualityType.Tier.SHINY, + true, + ) + var boring_mask: int = FishQualityType.bit_for( + FishQualityType.Tier.BORING + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + boring_mask, + ) == 46 + ) + var almost_mastered: int = ( + FishQualityType.ALL_TIERS_MASK + & ~FishQualityType.bit_for(FishQualityType.Tier.SHINY) + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + almost_mastered, + ) == 146 + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + FishQualityType.ALL_TIERS_MASK, + ) == 31 + ) + + +func _validate_player_experience_state() -> void: + var experience := PlayerExperienceType.new() + root.add_child(experience) + var awards: Array[Array] = [] + experience.experience_awarded.connect( + func( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, + ) -> void: + awards.append([ + amount, + previous_total, + new_total, + previous_level, + new_level, + ]) + ) + assert(experience.get_level() == 1) + assert(experience.award_experience(125)) + assert(experience.get_total_experience() == 125) + assert(experience.get_level() == 2) + assert(experience.get_experience_in_level() == 25) + assert(awards.size() == 1) + assert(awards[0] == [125, 0, 125, 1, 2]) + assert(experience.restore_total_experience(400)) + assert(experience.get_level() == 4) + assert(not experience.restore_total_experience(-1)) + experience.queue_free() + + +func _validate_save_migration() -> void: + var manager := PlayerSaveManager.new() + root.add_child(manager) + var version_five: Dictionary = { + "save_version": 5, + "wallet": {"balance": 0}, + "collection": { + "discovered_fish_ids": [], + "discovered_quality_masks": {}, + }, + "inventory": {"next_catch_sequence": 1, "catches": []}, + "bag": {"items": []}, + "hotbar": {"selected_slot": 0, "slots": []}, + "upgrades": {"reel_speed_level": 0, "barrier_power_level": 0}, + "cooler": {"capacity_level": 0}, + "art": {"unlock_mask": 0}, + } + var migrated: Dictionary = manager.call( + "_migrate_save", + version_five, + 5, + ) + assert(int(migrated.get("save_version", -1)) == 6) + var experience_data: Dictionary = migrated.get("experience", {}) + assert(int(experience_data.get("total_experience", -1)) == 0) + var world_data: Dictionary = migrated.get("world", {}) + assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0)) + manager.queue_free() + + +func _make_catch(quality: int, maximum_weight: bool) -> FishCatch: + var fish: FishData = Catalog.get_fish_by_id(&"bluegill") + assert(fish != null) + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.catch_id = StringName("bluegill:xp-%d-%s" % [ + quality, + "max" if maximum_weight else "min", + ]) + fish_catch.catch_sequence = 1 + fish_catch.weight_lb = ( + fish.get_maximum_weight() + if maximum_weight + else fish.get_minimum_weight() + ) + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = quality + fish_catch.sale_value = FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + quality, + ) + return fish_catch diff --git a/tests/player_experience_validation.gd.uid b/tests/player_experience_validation.gd.uid new file mode 100644 index 0000000..1afe50c --- /dev/null +++ b/tests/player_experience_validation.gd.uid @@ -0,0 +1 @@ +uid://djkr2iaa5taks diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 66f7dad..6aa89aa 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -32,6 +32,9 @@ func _run_host() -> void: ) assert(session.start_private_host(TEST_PORT)) world_time.synchronize_time(INITIAL_HOST_TIME) + assert(is_equal_approx( + world_time.get_persistent_time_hours(), INITIAL_HOST_TIME + )) world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.RAINY, 300.0 ) @@ -56,6 +59,9 @@ func _run_host() -> void: await create_timer(1.0).timeout world_time.synchronize_time(UPDATED_HOST_TIME) + assert(is_equal_approx( + world_time.get_persistent_time_hours(), UPDATED_HOST_TIME + )) world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.FOGGY, 300.0 ) @@ -98,6 +104,7 @@ func _run_client() -> void: assert(session.supports_server_capability( NetworkProtocol.WORLD_WEATHER_CAPABILITY )) + assert(world_time.restore_persistent_time_hours(15.25)) var initial_deadline: int = Time.get_ticks_msec() + 8000 while ( @@ -145,6 +152,7 @@ func _run_client() -> void: world_time.get_time_hours(), UPDATED_HOST_TIME ) <= TIME_TOLERANCE_HOURS) assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) + assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25)) assert(clock_label.text == world_time.get_clock_text()) var fog_deadline: int = Time.get_ticks_msec() + 8000 while ( diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index 666831e..1908951 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -25,6 +25,7 @@ func _initialize() -> void: func _run() -> void: _validate_clock_boundaries_and_duration() + _validate_persistent_host_clock() _validate_fishing_availability() _validate_fishing_spot_context() _validate_network_snapshot_bounds() @@ -78,6 +79,24 @@ func _validate_clock_boundaries_and_duration() -> void: clock.queue_free() +func _validate_persistent_host_clock() -> void: + var clock := WorldTimeServiceType.new() + root.add_child(clock) + assert(clock.restore_persistent_time_hours(18.75)) + clock.set_persistence_tracking_enabled(true) + clock.begin_session(clock.get_persistent_time_hours()) + assert(is_equal_approx(clock.get_time_hours(), 18.75)) + clock.synchronize_time(19.25) + assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) + clock.set_persistence_tracking_enabled(false) + clock.synchronize_time(6.5) + assert(is_equal_approx(clock.get_time_hours(), 6.5)) + assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) + assert(not clock.restore_persistent_time_hours(-1.0)) + assert(not clock.restore_persistent_time_hours(24.0)) + clock.queue_free() + + func _validate_fishing_availability() -> void: var day_context := FishingContextType.new() day_context.location_tags = [&"starter_pond"] diff --git a/ui/game_ui.gd b/ui/game_ui.gd index a43d021..0bcaa53 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -44,6 +44,12 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const UIReferencePresentationType = preload( + "res://ui/ui_reference_presentation.gd" +) signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested @@ -63,6 +69,13 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _barrier_health: Label = %BarrierHealth @onready var _showcase_details: Label = %ShowcaseDetails @onready var _fishing_panel: PanelContainer = %FishingPanel +@onready var _experience_panel: PanelContainer = %ExperienceProgressPanel +@onready var _experience_level_label: Label = %ExperienceLevelLabel +@onready var _experience_award_label: Label = %ExperienceAwardLabel +@onready var _experience_progress: ProgressBar = %ExperienceProgress +@onready var _experience_bubble: PanelContainer = %ExperienceBubble +@onready var _experience_bubble_label: Label = %ExperienceBubbleLabel +@onready var _canonical_stage: Control = %CanonicalStage @onready var _player_menu: PlayerMenuType = %PlayerMenu @onready var _screen_fade: ScreenFade = %ScreenFade @onready var _title_screen: TitleScreenType = %TitleScreen @@ -96,6 +109,11 @@ var _item_effects: PlayerItemEffectsType var _main_shop_buyer: FishBuyerProfileType var _shop_interaction: ShopInteractionType var _surface_drawing: NetworkSurfaceDrawingService +var _experience: PlayerExperienceType +var _experience_award_queue: Array[Dictionary] = [] +var _experience_animation_active: bool = false +var _experience_animation_generation: int = 0 +var _experience_panel_rest_y: float = 18.0 func _ready() -> void: @@ -124,6 +142,7 @@ func setup( player: PlayerType, inventory: FishInventoryType, collection_log: CollectionLogType, + experience: PlayerExperienceType, wallet: PlayerWalletType, sale_service: FishSaleServiceType, default_buyer: FishBuyerProfileType, @@ -156,6 +175,14 @@ func setup( _player = player _fishing_spot = fishing_spot _item_effects = item_effects + _experience = experience + if ( + _experience != null + and not _experience.experience_awarded.is_connected( + _on_experience_awarded + ) + ): + _experience.experience_awarded.connect(_on_experience_awarded) _chat_ui.setup( network_chat_service, network_session, spawn_service, player, fishing_spot, settings_manager, world_time, world_weather @@ -304,6 +331,7 @@ func setup_data_and_identity( func _process(_delta: float) -> void: + _update_experience_bubble_position() if _item_effects == null or not _gameplay_ui_enabled: _effect_status.hide() return @@ -372,6 +400,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled(true) _refresh_fishing_panel_visibility() + call_deferred("_start_next_experience_animation") func set_system_menu_open(is_open: bool) -> void: @@ -578,6 +607,7 @@ func _on_showcase_changed( _showcase_details.text = "" _showcase_details.visible = false _set_fishing_status("") + call_deferred("_start_next_experience_animation") return _catch_track.visible = false _barrier_prompt_panel.visible = false @@ -596,6 +626,197 @@ func _on_showcase_changed( _refresh_fishing_panel_visibility() +func _on_experience_awarded( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, +) -> void: + if amount <= 0: + return + _experience_award_queue.append({ + "amount": amount, + "previous_total": previous_total, + "new_total": new_total, + "previous_level": previous_level, + "new_level": new_level, + }) + + +func _start_next_experience_animation() -> void: + if ( + _experience_animation_active + or _showcase_active + or _experience_award_queue.is_empty() + or not _gameplay_ui_enabled + ): + return + var award: Dictionary = _experience_award_queue.pop_front() + _experience_animation_active = true + _experience_animation_generation += 1 + var generation: int = _experience_animation_generation + _play_experience_animation(award, generation) + + +func _play_experience_animation( + award: Dictionary, + generation: int, +) -> void: + var amount: int = int(award.get("amount", 0)) + var previous_total: int = int(award.get("previous_total", 0)) + var new_total: int = int(award.get("new_total", previous_total)) + _experience_award_label.text = "+%d xp" % amount + _experience_bubble_label.text = "+%d xp!" % amount + _update_experience_progress(previous_total) + _experience_panel.position.y = -_experience_panel.size.y - 8.0 + _experience_panel.modulate.a = 0.0 + _experience_panel.show() + _experience_bubble.modulate.a = 0.0 + _experience_bubble.scale = Vector2(0.72, 0.72) + _experience_bubble.pivot_offset = _experience_bubble.size * 0.5 + _experience_bubble.show() + + var entry_tween: Tween = create_tween() + entry_tween.set_parallel(true) + entry_tween.tween_property( + _experience_panel, + "position:y", + _experience_panel_rest_y, + 0.26, + ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + entry_tween.tween_property( + _experience_panel, + "modulate:a", + 1.0, + 0.18, + ) + entry_tween.tween_property( + _experience_bubble, + "modulate:a", + 1.0, + 0.15, + ) + entry_tween.tween_property( + _experience_bubble, + "scale", + Vector2.ONE, + 0.28, + ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + await entry_tween.finished + if generation != _experience_animation_generation: + return + + var fill_duration: float = clampf( + 0.8 + float(amount) * 0.006, + 0.9, + 1.65, + ) + var fill_tween: Tween = create_tween() + fill_tween.tween_method( + Callable(self, "_set_experience_animation_progress").bind( + previous_total, + new_total, + ), + 0.0, + 1.0, + fill_duration, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + await fill_tween.finished + if generation != _experience_animation_generation: + return + await get_tree().create_timer(0.75).timeout + if generation != _experience_animation_generation: + return + + var exit_tween: Tween = create_tween() + exit_tween.set_parallel(true) + exit_tween.tween_property( + _experience_panel, + "modulate:a", + 0.0, + 0.24, + ) + exit_tween.tween_property( + _experience_bubble, + "modulate:a", + 0.0, + 0.2, + ) + exit_tween.tween_property( + _experience_bubble, + "scale", + Vector2(0.82, 0.82), + 0.24, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + await exit_tween.finished + if generation != _experience_animation_generation: + return + _experience_panel.hide() + _experience_bubble.hide() + _experience_animation_active = false + call_deferred("_start_next_experience_animation") + + +func _set_experience_animation_progress( + progress: float, + previous_total: int, + new_total: int, +) -> void: + var displayed_total: int = roundi(lerpf( + float(previous_total), + float(new_total), + clampf(progress, 0.0, 1.0), + )) + _update_experience_progress(displayed_total) + + +func _update_experience_progress(total_experience: int) -> void: + var level: int = PlayerExperienceType.level_for_total_experience( + total_experience + ) + _experience_level_label.text = "level %d" % level + _experience_progress.value = ( + PlayerExperienceType.progress_for_total_experience(total_experience) + * 100.0 + ) + + +func _update_experience_bubble_position() -> void: + if not _experience_animation_active or _player == null: + return + var camera: Camera3D = _player.get_gameplay_camera() + var anchor_position: Vector3 = _player.get_chat_anchor_position() + if camera == null or camera.is_position_behind(anchor_position): + _experience_bubble.hide() + return + _experience_bubble.show() + var window_size := Vector2(get_window().size) + var output_scale: float = UIReferencePresentationType.get_scale( + window_size + ) + var stage_position: Vector2 = ( + camera.unproject_position(anchor_position) / output_scale + - _canonical_stage.position + ) + var desired: Vector2 = stage_position - Vector2( + _experience_bubble.size.x * 0.5, + _experience_bubble.size.y + 10.0, + ) + _experience_bubble.position = Vector2( + clampf( + desired.x, + 8.0, + _canonical_stage.size.x - _experience_bubble.size.x - 8.0, + ), + clampf( + desired.y, + 8.0, + _canonical_stage.size.y - _experience_bubble.size.y - 8.0, + ), + ) + + func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open if is_open and _surface_drawing != null: diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 4289771..3d59a55 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=3] +[gd_scene load_steps=19 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"] @@ -35,6 +35,27 @@ corner_radius_bottom_left = 12 [sub_resource type="StyleBoxFlat" id="StyleBox_transparent"] bg_color = Color(0, 0, 0, 0) +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_panel"] +bg_color = Color(0.051, 0.173, 0.227, 0.97) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_background"] +bg_color = Color(0.025, 0.102, 0.137, 1) +corner_radius_top_left = 7 +corner_radius_top_right = 7 +corner_radius_bottom_right = 7 +corner_radius_bottom_left = 7 + +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_fill"] +bg_color = Color(1, 0.82, 0.4, 1) +corner_radius_top_left = 7 +corner_radius_top_right = 7 +corner_radius_bottom_right = 7 +corner_radius_bottom_left = 7 + [node name="GameUI" type="CanvasLayer"] script = ExtResource("1_ui") @@ -59,6 +80,91 @@ grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 +[node name="ExperienceProgressPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 63 +anchors_preset = 10 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -240.0 +offset_top = 18.0 +offset_right = 240.0 +offset_bottom = 82.0 +grow_horizontal = 2 +mouse_filter = 2 +theme = ExtResource("3_theme") +theme_override_styles/panel = SubResource("StyleBox_experience_panel") + +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 7 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 9 + +[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Header" type="HBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +layout_mode = 2 + +[node name="ExperienceLevelLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +text = "level 1" +theme_override_colors/font_color = Color(0.95, 0.98, 1, 1) +theme_override_font_sizes/font_size = 16 + +[node name="Spacer" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="ExperienceAwardLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +text = "+0 xp" +theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) +theme_override_font_sizes/font_size = 16 + +[node name="ExperienceProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +mouse_filter = 2 +theme_override_styles/background = SubResource("StyleBox_experience_background") +theme_override_styles/fill = SubResource("StyleBox_experience_fill") +value = 0.0 +show_percentage = false + +[node name="ExperienceBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 63 +offset_right = 122.0 +offset_bottom = 46.0 +mouse_filter = 2 +theme = ExtResource("3_theme") +theme_override_styles/panel = SubResource("StyleBox_experience_panel") + +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble"] +layout_mode = 2 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 5 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 5 + +[node name="ExperienceBubbleLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble/Margin"] +unique_name_in_owner = true +layout_mode = 2 +text = "+0 xp!" +horizontal_alignment = 1 +vertical_alignment = 1 +theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) +theme_override_font_sizes/font_size = 22 + [node name="FishingPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] unique_name_in_owner = true visible = false diff --git a/world/world_time_service.gd b/world/world_time_service.gd index aef3054..378fcd1 100644 --- a/world/world_time_service.gd +++ b/world/world_time_service.gd @@ -24,6 +24,8 @@ const DUSK_END_HOUR: float = NIGHT_START_HOUR + TRANSITION_HALF_HOURS const DEFAULT_START_HOUR: float = DAY_START_HOUR var _time_hours: float = DEFAULT_START_HOUR +var _persistent_time_hours: float = DEFAULT_START_HOUR +var _persistence_tracking_enabled: bool = false var _running: bool = false var _phase: Phase = Phase.DAWN @@ -63,6 +65,31 @@ func synchronize_time(authoritative_time_hours: float) -> void: _set_time_hours(authoritative_time_hours, true) +func set_persistence_tracking_enabled(enabled: bool) -> void: + if _persistence_tracking_enabled == enabled: + return + if _persistence_tracking_enabled: + _persistent_time_hours = _time_hours + _persistence_tracking_enabled = enabled + + +func restore_persistent_time_hours(time_hours: float) -> bool: + if ( + not is_finite(time_hours) + or time_hours < 0.0 + or time_hours >= HOURS_PER_DAY + ): + return false + _persistent_time_hours = time_hours + if _persistence_tracking_enabled: + _set_time_hours(_persistent_time_hours, true) + return true + + +func get_persistent_time_hours() -> float: + return _persistent_time_hours + + func get_time_hours() -> float: return _time_hours @@ -116,6 +143,8 @@ func _set_time_hours(time_hours: float, force_emit: bool) -> void: var time_was_changed: bool = not is_equal_approx(normalized, _time_hours) _time_hours = normalized _phase = next_phase + if _persistence_tracking_enabled: + _persistent_time_hours = normalized if phase_was_changed: phase_changed.emit(_phase) if force_emit or time_was_changed or phase_was_changed: