Add rare beach message bottles
This commit is contained in:
parent
687568eb38
commit
2b5b0a63ec
38 changed files with 2422 additions and 18 deletions
|
|
@ -1,4 +1,4 @@
|
|||
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=42 format=3]
|
||||
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=43 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"]
|
||||
|
|
@ -41,7 +41,8 @@
|
|||
[ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/basic_cube.tres" id="42_basic_cube"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/message_bottle.tres" id="43_message_bottle"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_script")
|
||||
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net"), ExtResource("42_basic_cube")]
|
||||
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net"), ExtResource("42_basic_cube"), ExtResource("43_message_bottle")]
|
||||
|
|
|
|||
17
items/catalog/message_bottle.tres
Normal file
17
items/catalog/message_bottle.tres
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
|
||||
[ext_resource type="Texture2D" path="res://messages/assets/message_in_a_bottle.png" id="2_icon"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_item")
|
||||
item_id = &"message_bottle"
|
||||
display_name = "Message in a Bottle"
|
||||
description = "A salt-worn bottle carrying a message from somewhere far away. Open it to read what is inside."
|
||||
category = 5
|
||||
icon = ExtResource("2_icon")
|
||||
stackable = false
|
||||
max_stack = 1
|
||||
usable = true
|
||||
equippable = false
|
||||
hotbar_allowed = false
|
||||
80
main/main.gd
80
main/main.gd
|
|
@ -141,6 +141,15 @@ const NetworkHomeServiceType = preload(
|
|||
const HomeDecorControllerType = preload(
|
||||
"res://homes/home_decor_controller.gd"
|
||||
)
|
||||
const BottleMessageCatalogType = preload(
|
||||
"res://messages/bottle_message_catalog.gd"
|
||||
)
|
||||
const PlayerBottleMessageStateType = preload(
|
||||
"res://messages/player_bottle_message_state.gd"
|
||||
)
|
||||
const BeachBottleServiceType = preload(
|
||||
"res://messages/beach_bottle_service.gd"
|
||||
)
|
||||
|
||||
const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
||||
const TITLE_MUSIC_PATH: String = (
|
||||
|
|
@ -167,6 +176,7 @@ const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation"
|
|||
@export var main_shop_buyer_profile: FishBuyerProfileType
|
||||
@export var item_catalog: ItemCatalogType
|
||||
@export var gatherable_catalog: GatherableCatalogType
|
||||
@export var bottle_message_catalog: BottleMessageCatalogType
|
||||
@export_category("Title Music")
|
||||
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
|
||||
@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
|
||||
|
|
@ -264,6 +274,10 @@ const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation"
|
|||
@onready var _players_root: Node3D = $Players
|
||||
@onready var _surface_drawings_root: Node3D = $SurfaceDrawings
|
||||
@onready var _world_gatherables_root: Node3D = $WorldGatherables
|
||||
@onready var _bottle_message_state: PlayerBottleMessageStateType = (
|
||||
%PlayerBottleMessageState
|
||||
)
|
||||
@onready var _beach_bottles: BeachBottleServiceType = %BeachBottleService
|
||||
@onready var _home_world: HomeWorldServiceType = %HomeWorldService
|
||||
@onready var _title_background: ColorRect = %TitleBackground
|
||||
|
||||
|
|
@ -809,9 +823,24 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
_player_jobs,
|
||||
_player,
|
||||
_home_state,
|
||||
_bottle_message_state,
|
||||
_network_profile if not dedicated else null,
|
||||
_appearance_store if not dedicated else null,
|
||||
)
|
||||
_beach_bottles.setup(
|
||||
_test_world,
|
||||
_player,
|
||||
_player.bag,
|
||||
_bottle_message_state,
|
||||
bottle_message_catalog,
|
||||
_save_manager,
|
||||
_world_gatherables_root,
|
||||
dedicated,
|
||||
)
|
||||
if not dedicated:
|
||||
_beach_bottles.collection_failed.connect(
|
||||
_on_beach_bottle_collection_failed
|
||||
)
|
||||
_home_world.setup(
|
||||
_test_world,
|
||||
_player_spawn_service,
|
||||
|
|
@ -1028,6 +1057,7 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
_test_world.get_world_environment(),
|
||||
_test_world.get_sun(),
|
||||
)
|
||||
_game_ui.setup_message_bottles(_beach_bottles)
|
||||
_home_decor_controller.setup(
|
||||
_player,
|
||||
_player.hotbar,
|
||||
|
|
@ -1746,6 +1776,13 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
_refresh_home_door_prompt({})
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
# Beach bottles use the same compact interaction prompt as villagers, but
|
||||
# resolve before nearby NPCs while the player is within the tighter bottle
|
||||
# reach. Collection never happens from proximity alone.
|
||||
if _can_interact_with_beach_bottle():
|
||||
_beach_bottles.try_collect_nearby()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
# All shop prompts and interactions resolve through the same nearest-NPC
|
||||
# selection. This prevents overlapping trigger margins from making a fixed
|
||||
# shop priority open (or speak for) someone the player is not facing.
|
||||
|
|
@ -1790,18 +1827,26 @@ func _process(delta: float) -> void:
|
|||
# gives overlapping edge cases an immediate nearest-character handoff.
|
||||
_refresh_shop_npc_speech_state()
|
||||
var active_shop_interaction := _get_active_shop_npc_interaction()
|
||||
var show_shop_prompt := _can_show_active_shop_prompt(
|
||||
active_shop_interaction
|
||||
var home_prompt := _get_home_prompt()
|
||||
var show_bottle_prompt: bool = (
|
||||
home_prompt.is_empty() and _can_interact_with_beach_bottle()
|
||||
)
|
||||
var show_shop_prompt := (
|
||||
not show_bottle_prompt
|
||||
and _can_show_active_shop_prompt(active_shop_interaction)
|
||||
)
|
||||
var shop_prompt_anchor := (
|
||||
active_shop_interaction.get_prompt_anchor_position()
|
||||
if active_shop_interaction != null else Vector3.ZERO
|
||||
_beach_bottles.get_prompt_anchor_position()
|
||||
if show_bottle_prompt
|
||||
else (
|
||||
active_shop_interaction.get_prompt_anchor_position()
|
||||
if active_shop_interaction != null else Vector3.ZERO
|
||||
)
|
||||
)
|
||||
_game_ui.set_shop_prompt_visible(
|
||||
show_shop_prompt,
|
||||
show_bottle_prompt or show_shop_prompt,
|
||||
shop_prompt_anchor,
|
||||
)
|
||||
var home_prompt := _get_home_prompt()
|
||||
_refresh_home_door_prompt(home_prompt)
|
||||
_game_ui.set_rv_storage_button_visible(
|
||||
_can_show_rv_storage_button()
|
||||
|
|
@ -1959,6 +2004,7 @@ func _set_gameplay_active(active: bool) -> void:
|
|||
)
|
||||
_game_ui.set_gameplay_ui_enabled(active)
|
||||
_save_manager.set_autosave_enabled(active)
|
||||
_beach_bottles.set_gameplay_active(active)
|
||||
if active:
|
||||
_player_jobs.begin_progression_session()
|
||||
_refresh_active_hotbar_item()
|
||||
|
|
@ -1997,6 +2043,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void:
|
|||
_dusk_music.play(0.0)
|
||||
|
||||
|
||||
func _on_beach_bottle_collection_failed(message: String) -> void:
|
||||
_fishing_spot.report_external_status(message)
|
||||
|
||||
|
||||
static func _natural_interval_crosses_hour(
|
||||
previous_hour: float,
|
||||
advanced_hours: float,
|
||||
|
|
@ -2643,6 +2693,7 @@ func _apply_world(
|
|||
if _application_initialized:
|
||||
_refresh_active_world_bindings()
|
||||
_network_world_spawns.refresh_world_context()
|
||||
_beach_bottles.refresh_world_context()
|
||||
_network_home.refresh_world_presentations()
|
||||
if _application_initialized and not _dedicated_runtime:
|
||||
_water_recovery.update_world_context(
|
||||
|
|
@ -3055,6 +3106,7 @@ func _on_local_home_space_changed(
|
|||
_rain_puddles.set_suppressed(inside_rv)
|
||||
_surface_drawings_root.visible = not inside_rv
|
||||
_world_gatherables_root.visible = not inside_rv
|
||||
_beach_bottles.set_interior_active(inside_rv)
|
||||
_refresh_active_hotbar_item()
|
||||
_fishing_spot.set_fishing_environment_enabled(not inside_rv)
|
||||
_game_ui.set_rv_interior_camera_mode(
|
||||
|
|
@ -3309,6 +3361,22 @@ func _can_show_shop_prompt() -> bool:
|
|||
)
|
||||
|
||||
|
||||
func _can_interact_with_beach_bottle() -> bool:
|
||||
return (
|
||||
_gameplay_started
|
||||
and not _shop_conversation_camera_restoring
|
||||
and not is_instance_valid(_engaged_shop_npc_interaction)
|
||||
and _beach_bottles != null
|
||||
and _beach_bottles.is_local_player_in_range()
|
||||
and not _game_ui.get_player_storage().visible
|
||||
and not _game_ui.get_rv_upgrade_shop().visible
|
||||
and not _game_ui.get_fishing_shop().visible
|
||||
and not _game_ui.get_decor_shop().visible
|
||||
and not _water_recovery.is_recovery_active()
|
||||
and _fishing_spot.can_open_fishing_shop()
|
||||
)
|
||||
|
||||
|
||||
func _can_show_decor_shop_prompt() -> bool:
|
||||
return (
|
||||
_get_active_shop_npc_interaction() == _decor_shop_interaction
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@
|
|||
[ext_resource type="Script" path="res://homes/home_world_service.gd" id="62_home_world"]
|
||||
[ext_resource type="Script" path="res://network/network_home_service.gd" id="63_network_home"]
|
||||
[ext_resource type="Script" path="res://homes/home_decor_controller.gd" id="64_home_decor_controller"]
|
||||
[ext_resource type="Script" path="res://messages/player_bottle_message_state.gd" id="65_bottle_state"]
|
||||
[ext_resource type="Script" path="res://messages/beach_bottle_service.gd" id="66_beach_bottles"]
|
||||
[ext_resource type="Resource" path="res://messages/catalog/bottle_message_catalog.tres" id="67_bottle_catalog"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_grass_native"]
|
||||
shader = ExtResource("42_menu_pattern")
|
||||
|
|
@ -113,6 +116,7 @@ pelican_buyer_profile = ExtResource("7_pelicans")
|
|||
main_shop_buyer_profile = ExtResource("12_main_shop")
|
||||
item_catalog = ExtResource("11_items")
|
||||
gatherable_catalog = ExtResource("58_gatherable_catalog")
|
||||
bottle_message_catalog = ExtResource("67_bottle_catalog")
|
||||
|
||||
[node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702]
|
||||
unique_name_in_owner = true
|
||||
|
|
@ -303,6 +307,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081)
|
|||
|
||||
[node name="WorldGatherables" type="Node3D" parent="."]
|
||||
|
||||
[node name="BeachBottleService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("66_beach_bottles")
|
||||
|
||||
[node name="HomeWorldService" type="Node3D" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("62_home_world")
|
||||
|
|
@ -323,6 +331,10 @@ script = ExtResource("8_recovery")
|
|||
unique_name_in_owner = true
|
||||
script = ExtResource("9_save")
|
||||
|
||||
[node name="PlayerBottleMessageState" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("65_bottle_state")
|
||||
|
||||
[node name="PlayerHomeState" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("61_home_state")
|
||||
|
|
|
|||
68
messages/beach_bottle_presentation.gd
Normal file
68
messages/beach_bottle_presentation.gd
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
class_name BeachBottlePresentation
|
||||
extends Node3D
|
||||
|
||||
const VISIBLE_BASE_TUCK_PIXELS: float = 1.0
|
||||
const AUTHORED_VISIBLE_BOTTOM_PIXEL: float = 48.0
|
||||
|
||||
@onready var _sprite: Sprite3D = %BottleSprite
|
||||
|
||||
var message_id: StringName
|
||||
var _collecting: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_anchor_sprite_visible_bottom()
|
||||
|
||||
|
||||
func configure(
|
||||
reserved_message_id: StringName,
|
||||
world_position: Vector3,
|
||||
phase: float = 0.0,
|
||||
) -> void:
|
||||
message_id = reserved_message_id
|
||||
global_position = world_position
|
||||
rotation.y = phase
|
||||
|
||||
|
||||
func _anchor_sprite_visible_bottom() -> void:
|
||||
if _sprite == null or _sprite.texture == null:
|
||||
return
|
||||
var texture_height: float = float(_sprite.texture.get_height())
|
||||
if texture_height <= 0.0:
|
||||
return
|
||||
var texture_center_pixel: float = texture_height * 0.5
|
||||
# VRAM-compressed textures do not expose reliable alpha bounds at runtime.
|
||||
# The authored 64px sand sprite ends at pixel 48; sink that visible baseline
|
||||
# one source pixel into the sampled terrain so it cannot visibly hover.
|
||||
_sprite.position.y = maxf(
|
||||
(
|
||||
AUTHORED_VISIBLE_BOTTOM_PIXEL
|
||||
- texture_center_pixel
|
||||
- VISIBLE_BASE_TUCK_PIXELS
|
||||
) * _sprite.pixel_size,
|
||||
0.0,
|
||||
)
|
||||
|
||||
|
||||
func get_collect_position() -> Vector3:
|
||||
return global_position
|
||||
|
||||
|
||||
func play_collected() -> void:
|
||||
if _collecting:
|
||||
return
|
||||
_collecting = true
|
||||
var tween := create_tween()
|
||||
tween.set_parallel(true)
|
||||
tween.set_trans(Tween.TRANS_QUAD)
|
||||
tween.set_ease(Tween.EASE_IN)
|
||||
tween.tween_property(self, "scale", Vector3(0.35, 0.35, 0.35), 0.22)
|
||||
tween.tween_property(self, "position:y", position.y + 0.35, 0.22)
|
||||
if _sprite != null:
|
||||
tween.tween_property(
|
||||
_sprite,
|
||||
"modulate",
|
||||
Color(1.0, 1.0, 1.0, 0.0),
|
||||
0.22,
|
||||
)
|
||||
tween.chain().tween_callback(queue_free)
|
||||
1
messages/beach_bottle_presentation.gd.uid
Normal file
1
messages/beach_bottle_presentation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dgf6xvsiarx7o
|
||||
17
messages/beach_bottle_presentation.tscn
Normal file
17
messages/beach_bottle_presentation.tscn
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://messages/beach_bottle_presentation.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" path="res://messages/assets/message_in_a_bottle_sand.png" id="2_texture"]
|
||||
|
||||
[node name="BeachBottlePresentation" type="Node3D"]
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="BottleSprite" type="Sprite3D" parent="."]
|
||||
unique_name_in_owner = true
|
||||
texture = ExtResource("2_texture")
|
||||
pixel_size = 0.007
|
||||
billboard = 2
|
||||
shaded = false
|
||||
double_sided = true
|
||||
alpha_cut = 1
|
||||
texture_filter = 0
|
||||
619
messages/beach_bottle_service.gd
Normal file
619
messages/beach_bottle_service.gd
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
class_name BeachBottleService
|
||||
extends Node
|
||||
|
||||
const PRESENTATION_SCENE: PackedScene = preload(
|
||||
"res://messages/beach_bottle_presentation.tscn"
|
||||
)
|
||||
const BottleMessageCatalogType = preload(
|
||||
"res://messages/bottle_message_catalog.gd"
|
||||
)
|
||||
const BOTTLE_ITEM_ID: StringName = &"message_bottle"
|
||||
const SAND_MATERIALS: Array[StringName] = [&"sand"]
|
||||
const SPAWN_CHECK_INTERVAL_SECONDS: float = 2.0
|
||||
const MINIMUM_SPAWN_DISTANCE: float = 6.0
|
||||
const MAXIMUM_SPAWN_DISTANCE: float = 22.0
|
||||
const PICKUP_DISTANCE: float = 1.15
|
||||
const PROMPT_HEIGHT: float = 0.72
|
||||
const SURFACE_OFFSET: float = 0.0
|
||||
const MINIMUM_SURFACE_UP_DOT: float = 0.6
|
||||
const MAXIMUM_SHORE_HEIGHT_ABOVE_WATER: float = 1.25
|
||||
const SURFACE_SAMPLE_ATTEMPTS: int = 64
|
||||
|
||||
signal bottle_spawned(message_id: StringName, position: Vector3)
|
||||
signal collection_failed(message: String)
|
||||
|
||||
@export_category("Testing")
|
||||
@export var force_spawn_for_testing: bool = false
|
||||
|
||||
var _world: Node
|
||||
var _player: Node3D
|
||||
var _bag: PlayerBag
|
||||
var _state: Node
|
||||
var _catalog: BottleMessageCatalogType
|
||||
var _save_manager: PlayerSaveManager
|
||||
var _presentation_root: Node3D
|
||||
var _presentation: BeachBottlePresentation
|
||||
var _dedicated: bool = false
|
||||
var _gameplay_active: bool = false
|
||||
var _interior_active: bool = false
|
||||
var _spawn_check_elapsed: float = 0.0
|
||||
var _pickup_failure_notified: bool = false
|
||||
var _pickup_transaction_blocked: bool = false
|
||||
var _surface_triangles: Array[PackedVector3Array] = []
|
||||
var _surface_cumulative_areas := PackedFloat32Array()
|
||||
var _surface_total_area: float = 0.0
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
|
||||
|
||||
## Bottle state remains duck typed at this seam so presentation code does not
|
||||
## own the save schema. Its integration contract is:
|
||||
## get_pending_message_id() -> StringName
|
||||
## is_pending_bottle_picked_up() -> bool
|
||||
## ensure_cooldown_scheduled(now_unix, rng) -> bool
|
||||
## is_bottle_due(now_unix) -> bool
|
||||
## try_assign_due_message(catalog, rng, now_unix) -> BottleMessageData
|
||||
## record_bottle_picked_up(now_unix, rng) -> bool
|
||||
## to_save_data() -> Dictionary
|
||||
## restore_from_save_data(data: Dictionary) -> bool
|
||||
## The reservation and pickup methods must emit the state's changed signal.
|
||||
func setup(
|
||||
world: Node,
|
||||
local_player: Node3D,
|
||||
bag: PlayerBag,
|
||||
bottle_state: Node,
|
||||
bottle_catalog: BottleMessageCatalogType,
|
||||
save_manager: PlayerSaveManager,
|
||||
presentation_root: Node3D = null,
|
||||
dedicated: bool = false,
|
||||
) -> void:
|
||||
_clear_presentation(false)
|
||||
_world = world
|
||||
_player = local_player
|
||||
_bag = bag
|
||||
_state = bottle_state
|
||||
_catalog = bottle_catalog
|
||||
_save_manager = save_manager
|
||||
_presentation_root = presentation_root
|
||||
_dedicated = dedicated
|
||||
_rng.randomize()
|
||||
refresh_world_context()
|
||||
set_process(not _dedicated)
|
||||
|
||||
|
||||
func set_gameplay_active(active: bool) -> void:
|
||||
_gameplay_active = active and not _dedicated
|
||||
_spawn_check_elapsed = SPAWN_CHECK_INTERVAL_SECONDS
|
||||
_pickup_failure_notified = false
|
||||
_pickup_transaction_blocked = false
|
||||
if not _gameplay_active:
|
||||
_clear_presentation(false)
|
||||
else:
|
||||
_repair_unresolvable_pending_message()
|
||||
if _state != null and _state.has_method("observe_wall_clock"):
|
||||
_state.call(
|
||||
"observe_wall_clock",
|
||||
int(Time.get_unix_time_from_system()),
|
||||
)
|
||||
set_process(not _dedicated)
|
||||
|
||||
|
||||
func set_interior_active(active: bool) -> void:
|
||||
_interior_active = active
|
||||
if _presentation != null and is_instance_valid(_presentation):
|
||||
_presentation.visible = not active
|
||||
if active:
|
||||
_spawn_check_elapsed = 0.0
|
||||
|
||||
|
||||
func refresh_world_context() -> void:
|
||||
_clear_presentation(false)
|
||||
_surface_triangles.clear()
|
||||
_surface_cumulative_areas = PackedFloat32Array()
|
||||
_surface_total_area = 0.0
|
||||
_spawn_check_elapsed = SPAWN_CHECK_INTERVAL_SECONDS
|
||||
_cache_shore_surfaces()
|
||||
|
||||
|
||||
func has_active_bottle() -> bool:
|
||||
return _presentation != null and is_instance_valid(_presentation)
|
||||
|
||||
|
||||
func get_active_bottle_position() -> Vector3:
|
||||
return (
|
||||
_presentation.get_collect_position()
|
||||
if has_active_bottle()
|
||||
else Vector3(INF, INF, INF)
|
||||
)
|
||||
|
||||
|
||||
func is_local_player_in_range() -> bool:
|
||||
return (
|
||||
_can_present()
|
||||
and has_active_bottle()
|
||||
and _presentation.get_collect_position().distance_to(
|
||||
_player.global_position
|
||||
) <= PICKUP_DISTANCE
|
||||
)
|
||||
|
||||
|
||||
func get_prompt_anchor_position() -> Vector3:
|
||||
return (
|
||||
_presentation.get_collect_position() + Vector3.UP * PROMPT_HEIGHT
|
||||
if has_active_bottle()
|
||||
else Vector3(INF, INF, INF)
|
||||
)
|
||||
|
||||
|
||||
func try_collect_nearby() -> bool:
|
||||
if (
|
||||
not _can_present()
|
||||
or not has_active_bottle()
|
||||
or _bag == null
|
||||
or _state == null
|
||||
):
|
||||
return false
|
||||
var bottle_position: Vector3 = _presentation.get_collect_position()
|
||||
var player_position: Vector3 = _player.global_position
|
||||
if bottle_position.distance_to(player_position) > PICKUP_DISTANCE:
|
||||
_pickup_failure_notified = false
|
||||
_pickup_transaction_blocked = false
|
||||
return false
|
||||
if _pickup_transaction_blocked:
|
||||
return false
|
||||
var message_id: StringName = _pending_message_id()
|
||||
if message_id.is_empty():
|
||||
_clear_presentation(false)
|
||||
return false
|
||||
if not _bag.can_add_item(BOTTLE_ITEM_ID, 1):
|
||||
if not _pickup_failure_notified:
|
||||
_pickup_failure_notified = true
|
||||
collection_failed.emit("Inventory is full.")
|
||||
return false
|
||||
_pickup_failure_notified = false
|
||||
var bag_snapshot: Array[OwnedItem] = _bag.get_all_items()
|
||||
var state_snapshot: Dictionary = _state_save_snapshot()
|
||||
if not _bag.add_item(BOTTLE_ITEM_ID, 1):
|
||||
_pickup_transaction_blocked = true
|
||||
collection_failed.emit("The bottle could not be picked up.")
|
||||
return false
|
||||
if not _record_pickup(int(Time.get_unix_time_from_system())):
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
_pickup_transaction_blocked = true
|
||||
collection_failed.emit("The bottle could not be picked up.")
|
||||
return false
|
||||
if _save_manager == null or not _save_manager.save_if_dirty():
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
_pickup_transaction_blocked = true
|
||||
collection_failed.emit("The bottle could not be saved.")
|
||||
return false
|
||||
var collected_presentation: BeachBottlePresentation = _presentation
|
||||
_presentation = null
|
||||
collected_presentation.play_collected()
|
||||
return true
|
||||
|
||||
|
||||
func read_held_bottle() -> Dictionary:
|
||||
var failure := {
|
||||
"ok": false,
|
||||
"text": "",
|
||||
"message_id": "",
|
||||
"error": "There is no message in a bottle to read.",
|
||||
}
|
||||
if (
|
||||
_state == null
|
||||
or _catalog == null
|
||||
or _bag == null
|
||||
or not _state.has_method("is_pending_bottle_picked_up")
|
||||
or not bool(_state.call("is_pending_bottle_picked_up"))
|
||||
or not _bag.owns_item(BOTTLE_ITEM_ID)
|
||||
):
|
||||
return failure
|
||||
var message_id: StringName = _pending_message_id()
|
||||
var message: Variant = _catalog.get_message(message_id)
|
||||
if message == null:
|
||||
failure["error"] = "The message inside could not be read."
|
||||
return failure
|
||||
var body: String = str(message.get("body"))
|
||||
if body.strip_edges().is_empty():
|
||||
failure["error"] = "The message inside was blank."
|
||||
return failure
|
||||
var bag_snapshot: Array[OwnedItem] = _bag.get_all_items()
|
||||
var state_snapshot: Dictionary = _state_save_snapshot()
|
||||
if not _bag.remove_item(BOTTLE_ITEM_ID, 1):
|
||||
failure["error"] = "The bottle could not be opened."
|
||||
return failure
|
||||
if (
|
||||
not _state.has_method("consume_pending_message")
|
||||
or StringName(str(_state.call("consume_pending_message"))) != message_id
|
||||
):
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
failure["error"] = "The bottle could not be opened."
|
||||
return failure
|
||||
if _save_manager == null or not _save_manager.save_if_dirty():
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
failure["error"] = "The opened bottle could not be saved."
|
||||
return failure
|
||||
return {
|
||||
"ok": true,
|
||||
"text": body,
|
||||
"message_id": String(message_id),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _can_present():
|
||||
return
|
||||
if has_active_bottle():
|
||||
# Collection is deliberately interaction-driven. Leaving the prompt range
|
||||
# only rearms a failed save/inventory attempt for the next explicit press.
|
||||
if not is_local_player_in_range():
|
||||
_pickup_failure_notified = false
|
||||
_pickup_transaction_blocked = false
|
||||
return
|
||||
if _bag != null and _bag.owns_item(BOTTLE_ITEM_ID):
|
||||
return
|
||||
_spawn_check_elapsed += delta
|
||||
if _spawn_check_elapsed < SPAWN_CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
_spawn_check_elapsed = fmod(
|
||||
_spawn_check_elapsed,
|
||||
SPAWN_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
_try_spawn_due_bottle()
|
||||
|
||||
|
||||
func _try_spawn_due_bottle() -> bool:
|
||||
if not _can_present() or has_active_bottle():
|
||||
return false
|
||||
if _state == null or _catalog == null:
|
||||
return false
|
||||
if _state.has_method("is_pending_bottle_picked_up") and bool(
|
||||
_state.call("is_pending_bottle_picked_up")
|
||||
):
|
||||
return false
|
||||
var now_unix: int = int(Time.get_unix_time_from_system())
|
||||
var message_id: StringName = _pending_message_id()
|
||||
if message_id.is_empty():
|
||||
if force_spawn_for_testing:
|
||||
if not _has_unseen_message():
|
||||
return false
|
||||
else:
|
||||
if not _ensure_cooldown(now_unix):
|
||||
return false
|
||||
if not _state.has_method("is_bottle_due") or not bool(
|
||||
_state.call("is_bottle_due", now_unix)
|
||||
):
|
||||
return false
|
||||
if not force_spawn_for_testing and not _has_unseen_message():
|
||||
return false
|
||||
if _surface_triangles.is_empty():
|
||||
_cache_shore_surfaces()
|
||||
if _surface_triangles.is_empty():
|
||||
return false
|
||||
var spawn_position: Vector3 = _sample_nearby_shore_position(
|
||||
_player.global_position
|
||||
)
|
||||
if not spawn_position.is_finite():
|
||||
return false
|
||||
var state_snapshot: Dictionary = _state_save_snapshot()
|
||||
if message_id.is_empty():
|
||||
message_id = (
|
||||
_assign_unseen_message_for_testing()
|
||||
if force_spawn_for_testing
|
||||
else _assign_due_message(now_unix)
|
||||
)
|
||||
if message_id.is_empty():
|
||||
return false
|
||||
# Persist the reservation before making a collectible visible. A crash or
|
||||
# world rebuild can relocate this one opportunity, but cannot mint another.
|
||||
if _save_manager == null or not _save_manager.save_if_dirty():
|
||||
_restore_state_snapshot(state_snapshot)
|
||||
return false
|
||||
_spawn_presentation(message_id, spawn_position)
|
||||
return true
|
||||
|
||||
|
||||
func _spawn_presentation(
|
||||
message_id: StringName,
|
||||
spawn_position: Vector3,
|
||||
) -> void:
|
||||
var presentation := (
|
||||
PRESENTATION_SCENE.instantiate() as BeachBottlePresentation
|
||||
)
|
||||
if presentation == null:
|
||||
return
|
||||
if _presentation_root != null and is_instance_valid(_presentation_root):
|
||||
_presentation_root.add_child(presentation)
|
||||
else:
|
||||
add_child(presentation)
|
||||
presentation.configure(message_id, spawn_position, _rng.randf_range(0.0, TAU))
|
||||
presentation.visible = not _interior_active
|
||||
_presentation = presentation
|
||||
bottle_spawned.emit(message_id, spawn_position)
|
||||
|
||||
|
||||
func _cache_shore_surfaces() -> void:
|
||||
if (
|
||||
_dedicated
|
||||
or _world == null
|
||||
or not _world.has_method("get_spawn_surface_triangles")
|
||||
):
|
||||
return
|
||||
if _world.has_method("is_world_ready") and not bool(
|
||||
_world.call("is_world_ready")
|
||||
):
|
||||
return
|
||||
var minimum_y: float = -100.0
|
||||
var maximum_y: float = 100.0
|
||||
if _world.has_method("get_saltwater_surface_height"):
|
||||
var water_height: float = float(
|
||||
_world.call("get_saltwater_surface_height")
|
||||
)
|
||||
if is_finite(water_height):
|
||||
minimum_y = water_height + 0.01
|
||||
maximum_y = water_height + MAXIMUM_SHORE_HEIGHT_ABOVE_WATER
|
||||
var values: Variant = _world.call(
|
||||
"get_spawn_surface_triangles",
|
||||
SAND_MATERIALS,
|
||||
minimum_y,
|
||||
MINIMUM_SURFACE_UP_DOT,
|
||||
maximum_y,
|
||||
)
|
||||
if typeof(values) != TYPE_ARRAY:
|
||||
return
|
||||
for value: Variant in values:
|
||||
if typeof(value) != TYPE_PACKED_VECTOR3_ARRAY:
|
||||
continue
|
||||
var triangle := value as PackedVector3Array
|
||||
if triangle.size() != 3:
|
||||
continue
|
||||
var area: float = (
|
||||
(triangle[1] - triangle[0]).cross(
|
||||
triangle[2] - triangle[0]
|
||||
).length() * 0.5
|
||||
)
|
||||
if area <= 0.000001:
|
||||
continue
|
||||
_surface_total_area += area
|
||||
_surface_triangles.append(triangle)
|
||||
_surface_cumulative_areas.append(_surface_total_area)
|
||||
|
||||
|
||||
func _sample_nearby_shore_position(origin: Vector3) -> Vector3:
|
||||
if _surface_triangles.is_empty() or _surface_total_area <= 0.0:
|
||||
return Vector3(INF, INF, INF)
|
||||
var candidates: Array[PackedVector3Array] = []
|
||||
var candidate_areas := PackedFloat32Array()
|
||||
var candidate_total_area: float = 0.0
|
||||
var horizontal_origin := Vector2(origin.x, origin.z)
|
||||
for triangle: PackedVector3Array in _surface_triangles:
|
||||
var center: Vector3 = (triangle[0] + triangle[1] + triangle[2]) / 3.0
|
||||
var center_2d := Vector2(center.x, center.z)
|
||||
var radius: float = 0.0
|
||||
for vertex: Vector3 in triangle:
|
||||
radius = maxf(
|
||||
radius,
|
||||
center_2d.distance_to(Vector2(vertex.x, vertex.z)),
|
||||
)
|
||||
var center_distance: float = horizontal_origin.distance_to(center_2d)
|
||||
if (
|
||||
center_distance - radius > MAXIMUM_SPAWN_DISTANCE
|
||||
or center_distance + radius < MINIMUM_SPAWN_DISTANCE
|
||||
):
|
||||
continue
|
||||
var area: float = (
|
||||
(triangle[1] - triangle[0]).cross(
|
||||
triangle[2] - triangle[0]
|
||||
).length() * 0.5
|
||||
)
|
||||
if area <= 0.000001:
|
||||
continue
|
||||
candidate_total_area += area
|
||||
candidates.append(triangle)
|
||||
candidate_areas.append(candidate_total_area)
|
||||
if candidates.is_empty() or candidate_total_area <= 0.0:
|
||||
return Vector3(INF, INF, INF)
|
||||
for _attempt: int in SURFACE_SAMPLE_ATTEMPTS:
|
||||
var roll: float = _rng.randf() * candidate_total_area
|
||||
var index: int = clampi(
|
||||
candidate_areas.bsearch(roll),
|
||||
0,
|
||||
candidates.size() - 1,
|
||||
)
|
||||
var point: Vector3 = sample_triangle(
|
||||
candidates[index],
|
||||
_rng.randf(),
|
||||
_rng.randf(),
|
||||
)
|
||||
var distance := Vector2(
|
||||
point.x - origin.x,
|
||||
point.z - origin.z,
|
||||
).length()
|
||||
if (
|
||||
distance >= MINIMUM_SPAWN_DISTANCE
|
||||
and distance <= MAXIMUM_SPAWN_DISTANCE
|
||||
):
|
||||
return point + Vector3.UP * SURFACE_OFFSET
|
||||
return Vector3(INF, INF, INF)
|
||||
|
||||
|
||||
static func sample_triangle(
|
||||
triangle: PackedVector3Array,
|
||||
root_roll: float,
|
||||
edge_roll: float,
|
||||
) -> Vector3:
|
||||
if triangle.size() != 3:
|
||||
return Vector3(INF, INF, INF)
|
||||
var root: float = sqrt(clampf(root_roll, 0.0, 1.0))
|
||||
var b: float = root * (1.0 - clampf(edge_roll, 0.0, 1.0))
|
||||
var c: float = root - b
|
||||
return triangle[0] * (1.0 - root) + triangle[1] * b + triangle[2] * c
|
||||
|
||||
|
||||
func _can_present() -> bool:
|
||||
return (
|
||||
not _dedicated
|
||||
and _gameplay_active
|
||||
and not _interior_active
|
||||
and _world != null
|
||||
and _player != null
|
||||
and is_instance_valid(_player)
|
||||
and _bag != null
|
||||
and _state != null
|
||||
)
|
||||
|
||||
|
||||
func _pending_message_id() -> StringName:
|
||||
if _state == null:
|
||||
return StringName()
|
||||
for method_name: StringName in [
|
||||
&"get_pending_message_id",
|
||||
&"get_reserved_message_id",
|
||||
]:
|
||||
if _state.has_method(method_name):
|
||||
return StringName(str(_state.call(method_name)))
|
||||
return StringName()
|
||||
|
||||
|
||||
func _has_unseen_message() -> bool:
|
||||
if (
|
||||
_state == null
|
||||
or _catalog == null
|
||||
or not _state.has_method("get_received_message_ids")
|
||||
):
|
||||
return false
|
||||
var received: Array[StringName] = []
|
||||
var values: Variant = _state.call("get_received_message_ids")
|
||||
if typeof(values) != TYPE_ARRAY:
|
||||
return false
|
||||
for value: Variant in values as Array:
|
||||
if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
received.append(StringName(str(value)))
|
||||
return not _catalog.get_active_unseen_messages(received).is_empty()
|
||||
|
||||
|
||||
func _repair_unresolvable_pending_message() -> void:
|
||||
var message_id: StringName = _pending_message_id()
|
||||
if (
|
||||
message_id.is_empty()
|
||||
or _catalog == null
|
||||
or _catalog.get_message(message_id) != null
|
||||
or _state == null
|
||||
or _bag == null
|
||||
or not _state.has_method("consume_pending_message")
|
||||
):
|
||||
return
|
||||
var bag_snapshot: Array[OwnedItem] = _bag.get_all_items()
|
||||
var state_snapshot: Dictionary = _state_save_snapshot()
|
||||
if _bag.owns_item(BOTTLE_ITEM_ID) and not _bag.remove_item(
|
||||
BOTTLE_ITEM_ID, 1
|
||||
):
|
||||
return
|
||||
if StringName(str(_state.call("consume_pending_message"))) != message_id:
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
return
|
||||
if _save_manager == null or not _save_manager.save_if_dirty():
|
||||
_rollback_pickup(bag_snapshot, state_snapshot)
|
||||
return
|
||||
push_warning(
|
||||
"Cleared a pending message bottle whose catalog entry no longer exists."
|
||||
)
|
||||
|
||||
|
||||
func _ensure_cooldown(now_unix: int) -> bool:
|
||||
if _state == null or not _state.has_method("ensure_cooldown_scheduled"):
|
||||
return false
|
||||
var snapshot: Dictionary = _state_save_snapshot()
|
||||
if not bool(_state.call("ensure_cooldown_scheduled", now_unix, _rng)):
|
||||
return false
|
||||
if _save_manager != null and _save_manager.save_if_dirty():
|
||||
return true
|
||||
_restore_state_snapshot(snapshot)
|
||||
return false
|
||||
|
||||
|
||||
func _assign_due_message(now_unix: int) -> StringName:
|
||||
if (
|
||||
_state == null
|
||||
or _catalog == null
|
||||
or not _state.has_method("try_assign_due_message")
|
||||
):
|
||||
return StringName()
|
||||
var message: Variant = _state.call(
|
||||
"try_assign_due_message",
|
||||
_catalog,
|
||||
_rng,
|
||||
now_unix,
|
||||
)
|
||||
if message == null:
|
||||
return StringName()
|
||||
return StringName(str(message.get("message_id")))
|
||||
|
||||
|
||||
func _assign_unseen_message_for_testing() -> StringName:
|
||||
if (
|
||||
_state == null
|
||||
or _catalog == null
|
||||
or not _state.has_method("assign_unseen_message_for_testing")
|
||||
):
|
||||
return StringName()
|
||||
var message: Variant = _state.call(
|
||||
"assign_unseen_message_for_testing",
|
||||
_catalog,
|
||||
_rng,
|
||||
)
|
||||
if message == null:
|
||||
return StringName()
|
||||
return StringName(str(message.get("message_id")))
|
||||
|
||||
|
||||
func _record_pickup(now_unix: int) -> bool:
|
||||
if _state == null or not _state.has_method("record_bottle_picked_up"):
|
||||
return false
|
||||
var result: Variant = _state.call(
|
||||
"record_bottle_picked_up",
|
||||
now_unix,
|
||||
_rng,
|
||||
)
|
||||
return typeof(result) == TYPE_BOOL and bool(result)
|
||||
|
||||
|
||||
func _state_save_snapshot() -> Dictionary:
|
||||
if _state != null and _state.has_method("to_save_data"):
|
||||
var value: Variant = _state.call("to_save_data")
|
||||
if typeof(value) == TYPE_DICTIONARY:
|
||||
return (value as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _restore_state_snapshot(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
_state != null
|
||||
and not snapshot.is_empty()
|
||||
and _state.has_method("restore_from_save_data")
|
||||
and bool(_state.call("restore_from_save_data", snapshot))
|
||||
)
|
||||
|
||||
|
||||
func _rollback_pickup(
|
||||
bag_snapshot: Array[OwnedItem],
|
||||
state_snapshot: Dictionary,
|
||||
) -> void:
|
||||
if _bag != null:
|
||||
_bag.replace_all_items(bag_snapshot)
|
||||
_restore_state_snapshot(state_snapshot)
|
||||
if _save_manager != null:
|
||||
_save_manager.save_if_dirty()
|
||||
|
||||
|
||||
func _clear_presentation(play_collected: bool) -> void:
|
||||
if _presentation == null or not is_instance_valid(_presentation):
|
||||
_presentation = null
|
||||
return
|
||||
var previous: BeachBottlePresentation = _presentation
|
||||
_presentation = null
|
||||
if play_collected:
|
||||
previous.play_collected()
|
||||
else:
|
||||
previous.queue_free()
|
||||
1
messages/beach_bottle_service.gd.uid
Normal file
1
messages/beach_bottle_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://chta0r77d4i2k
|
||||
61
messages/bottle_message_catalog.gd
Normal file
61
messages/bottle_message_catalog.gd
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
class_name BottleMessageCatalog
|
||||
extends Resource
|
||||
|
||||
const BottleMessageDataType = preload(
|
||||
"res://messages/bottle_message_data.gd"
|
||||
)
|
||||
|
||||
@export var entries: Array[BottleMessageDataType] = []
|
||||
|
||||
|
||||
func get_message(message_id: StringName) -> BottleMessageDataType:
|
||||
if message_id.is_empty():
|
||||
return null
|
||||
for entry: BottleMessageDataType in entries:
|
||||
if (
|
||||
entry != null
|
||||
and entry.is_valid()
|
||||
and entry.message_id == message_id
|
||||
):
|
||||
return entry
|
||||
return null
|
||||
|
||||
|
||||
func get_active_unseen_messages(
|
||||
received_message_ids: Array[StringName],
|
||||
) -> Array[BottleMessageDataType]:
|
||||
var received: Dictionary[StringName, bool] = {}
|
||||
for message_id: StringName in received_message_ids:
|
||||
received[message_id] = true
|
||||
var result: Array[BottleMessageDataType] = []
|
||||
var included: Dictionary[StringName, bool] = {}
|
||||
for entry: BottleMessageDataType in entries:
|
||||
if (
|
||||
entry == null
|
||||
or not entry.active
|
||||
or not entry.is_valid()
|
||||
or received.has(entry.message_id)
|
||||
or included.has(entry.message_id)
|
||||
):
|
||||
continue
|
||||
included[entry.message_id] = true
|
||||
result.append(entry)
|
||||
result.sort_custom(
|
||||
func(first: BottleMessageDataType, second: BottleMessageDataType) -> bool:
|
||||
return String(first.message_id) < String(second.message_id)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
func choose_random_unseen_message(
|
||||
received_message_ids: Array[StringName],
|
||||
rng: RandomNumberGenerator,
|
||||
) -> BottleMessageDataType:
|
||||
if rng == null:
|
||||
return null
|
||||
var candidates: Array[BottleMessageDataType] = get_active_unseen_messages(
|
||||
received_message_ids
|
||||
)
|
||||
if candidates.is_empty():
|
||||
return null
|
||||
return candidates[rng.randi_range(0, candidates.size() - 1)]
|
||||
1
messages/bottle_message_catalog.gd.uid
Normal file
1
messages/bottle_message_catalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c2vu7o6y4th61
|
||||
20
messages/bottle_message_data.gd
Normal file
20
messages/bottle_message_data.gd
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
class_name BottleMessageData
|
||||
extends Resource
|
||||
|
||||
const MAX_MESSAGE_ID_LENGTH: int = 96
|
||||
const MAX_BODY_LENGTH: int = 4096
|
||||
|
||||
@export var message_id: StringName
|
||||
@export_multiline var body: String = ""
|
||||
@export var active: bool = true
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
var normalized_id: String = String(message_id).strip_edges()
|
||||
return (
|
||||
not normalized_id.is_empty()
|
||||
and normalized_id.length() <= MAX_MESSAGE_ID_LENGTH
|
||||
and normalized_id == String(message_id)
|
||||
and not body.strip_edges().is_empty()
|
||||
and body.length() <= MAX_BODY_LENGTH
|
||||
)
|
||||
1
messages/bottle_message_data.gd.uid
Normal file
1
messages/bottle_message_data.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c044pmgt5w1un
|
||||
9
messages/catalog/bottle_message_catalog.tres
Normal file
9
messages/catalog/bottle_message_catalog.tres
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[gd_resource type="Resource" script_class="BottleMessageCatalog" load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://messages/bottle_message_catalog.gd" id="1_catalog"]
|
||||
[ext_resource type="Resource" path="res://messages/catalog/entries/pushing_squares.tres" id="2_pushing_squares"]
|
||||
[ext_resource type="Resource" path="res://messages/catalog/entries/message_2.tres" id="3_message_2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_catalog")
|
||||
entries = [ExtResource("2_pushing_squares"), ExtResource("3_message_2")]
|
||||
8
messages/catalog/entries/message_2.tres
Normal file
8
messages/catalog/entries/message_2.tres
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[gd_resource type="Resource" script_class="BottleMessageData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://messages/bottle_message_data.gd" id="1_message"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_message")
|
||||
message_id = &"message_2"
|
||||
body = "01101001 00100000 01101100 01101111 01110110 01100101 00100000 01111001 01101111 01110101"
|
||||
8
messages/catalog/entries/pushing_squares.tres
Normal file
8
messages/catalog/entries/pushing_squares.tres
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[gd_resource type="Resource" script_class="BottleMessageData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://messages/bottle_message_data.gd" id="1_message"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_message")
|
||||
message_id = &"pushing_squares"
|
||||
body = "it's as simple as pushing squares"
|
||||
332
messages/player_bottle_message_state.gd
Normal file
332
messages/player_bottle_message_state.gd
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
class_name PlayerBottleMessageState
|
||||
extends Node
|
||||
|
||||
signal changed
|
||||
|
||||
const BottleMessageCatalogType = preload(
|
||||
"res://messages/bottle_message_catalog.gd"
|
||||
)
|
||||
const BottleMessageDataType = preload(
|
||||
"res://messages/bottle_message_data.gd"
|
||||
)
|
||||
|
||||
const SAVE_SCHEMA_VERSION: int = 1
|
||||
const MINIMUM_COOLDOWN_SECONDS: int = 3 * 24 * 60 * 60
|
||||
const MAXIMUM_COOLDOWN_SECONDS: int = 7 * 24 * 60 * 60
|
||||
const BACKWARD_CLOCK_TOLERANCE_SECONDS: int = 5 * 60
|
||||
const CLOCK_OBSERVATION_CHECKPOINT_SECONDS: int = 15 * 60
|
||||
const MAX_MESSAGE_ID_LENGTH: int = 96
|
||||
const MAX_RECEIVED_MESSAGE_IDS: int = 4096
|
||||
const MAX_SUPPORTED_UNIX_TIME: int = 32503680000
|
||||
|
||||
var _received_message_ids: Array[StringName] = []
|
||||
var _pending_message_id: StringName
|
||||
var _pending_bottle_picked_up: bool = false
|
||||
var _next_bottle_at_unix: int = 0
|
||||
var _last_observed_unix: int = 0
|
||||
|
||||
|
||||
func get_received_message_ids() -> Array[StringName]:
|
||||
return _received_message_ids.duplicate()
|
||||
|
||||
|
||||
func has_received_message(message_id: StringName) -> bool:
|
||||
return _received_message_ids.has(message_id)
|
||||
|
||||
|
||||
func has_pending_message() -> bool:
|
||||
return not _pending_message_id.is_empty()
|
||||
|
||||
|
||||
func get_pending_message_id() -> StringName:
|
||||
return _pending_message_id
|
||||
|
||||
|
||||
func is_pending_bottle_picked_up() -> bool:
|
||||
return has_pending_message() and _pending_bottle_picked_up
|
||||
|
||||
|
||||
func get_next_bottle_at_unix() -> int:
|
||||
return _next_bottle_at_unix
|
||||
|
||||
|
||||
func get_last_observed_unix() -> int:
|
||||
return _last_observed_unix
|
||||
|
||||
|
||||
func observe_wall_clock(now_unix: int) -> bool:
|
||||
if not _valid_unix_time(now_unix):
|
||||
return false
|
||||
var changed_state: bool = false
|
||||
if _last_observed_unix > 0 and now_unix < _last_observed_unix:
|
||||
var rollback_seconds: int = _last_observed_unix - now_unix
|
||||
if rollback_seconds <= BACKWARD_CLOCK_TOLERANCE_SECONDS:
|
||||
return false
|
||||
if _next_bottle_at_unix > 0:
|
||||
_next_bottle_at_unix = maxi(
|
||||
now_unix,
|
||||
_next_bottle_at_unix - rollback_seconds,
|
||||
)
|
||||
_last_observed_unix = now_unix
|
||||
changed_state = true
|
||||
elif (
|
||||
_last_observed_unix == 0
|
||||
or now_unix - _last_observed_unix
|
||||
>= CLOCK_OBSERVATION_CHECKPOINT_SECONDS
|
||||
):
|
||||
_last_observed_unix = now_unix
|
||||
changed_state = true
|
||||
if changed_state:
|
||||
changed.emit()
|
||||
return changed_state
|
||||
|
||||
|
||||
func ensure_cooldown_scheduled(
|
||||
now_unix: int,
|
||||
rng: RandomNumberGenerator,
|
||||
) -> bool:
|
||||
if rng == null or not _valid_unix_time(now_unix):
|
||||
return false
|
||||
observe_wall_clock(now_unix)
|
||||
if has_pending_message():
|
||||
return _pending_bottle_picked_up and _next_bottle_at_unix > 0
|
||||
if _next_bottle_at_unix > 0:
|
||||
return true
|
||||
_next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng)
|
||||
changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func is_bottle_due(now_unix: int) -> bool:
|
||||
return (
|
||||
_valid_unix_time(now_unix)
|
||||
and not has_pending_message()
|
||||
and _next_bottle_at_unix > 0
|
||||
and now_unix >= _next_bottle_at_unix
|
||||
)
|
||||
|
||||
|
||||
func try_assign_due_message(
|
||||
catalog: BottleMessageCatalogType,
|
||||
rng: RandomNumberGenerator,
|
||||
now_unix: int,
|
||||
) -> BottleMessageDataType:
|
||||
if (
|
||||
catalog == null
|
||||
or rng == null
|
||||
or has_pending_message()
|
||||
or not _valid_unix_time(now_unix)
|
||||
):
|
||||
return null
|
||||
observe_wall_clock(now_unix)
|
||||
if _next_bottle_at_unix == 0:
|
||||
ensure_cooldown_scheduled(now_unix, rng)
|
||||
return null
|
||||
if not is_bottle_due(now_unix):
|
||||
return null
|
||||
return _assign_random_unseen_message(catalog, rng)
|
||||
|
||||
|
||||
func assign_unseen_message_for_testing(
|
||||
catalog: BottleMessageCatalogType,
|
||||
rng: RandomNumberGenerator,
|
||||
) -> BottleMessageDataType:
|
||||
if catalog == null or rng == null or has_pending_message():
|
||||
return null
|
||||
return _assign_random_unseen_message(catalog, rng)
|
||||
|
||||
|
||||
func _assign_random_unseen_message(
|
||||
catalog: BottleMessageCatalogType,
|
||||
rng: RandomNumberGenerator,
|
||||
) -> BottleMessageDataType:
|
||||
var message: BottleMessageDataType = catalog.choose_random_unseen_message(
|
||||
_received_message_ids,
|
||||
rng,
|
||||
)
|
||||
if message == null:
|
||||
return null
|
||||
_pending_message_id = message.message_id
|
||||
_pending_bottle_picked_up = false
|
||||
_received_message_ids.append(message.message_id)
|
||||
_next_bottle_at_unix = 0
|
||||
changed.emit()
|
||||
return message
|
||||
|
||||
|
||||
func record_bottle_picked_up(
|
||||
now_unix: int,
|
||||
rng: RandomNumberGenerator,
|
||||
) -> bool:
|
||||
if (
|
||||
rng == null
|
||||
or not has_pending_message()
|
||||
or _pending_bottle_picked_up
|
||||
or _next_bottle_at_unix != 0
|
||||
or not _valid_unix_time(now_unix)
|
||||
):
|
||||
return false
|
||||
observe_wall_clock(now_unix)
|
||||
_pending_bottle_picked_up = true
|
||||
_next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng)
|
||||
changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func consume_pending_message() -> StringName:
|
||||
var consumed_id: StringName = _pending_message_id
|
||||
if consumed_id.is_empty():
|
||||
return &""
|
||||
_pending_message_id = &""
|
||||
_pending_bottle_picked_up = false
|
||||
changed.emit()
|
||||
return consumed_id
|
||||
|
||||
|
||||
func reset_to_defaults() -> void:
|
||||
_received_message_ids.clear()
|
||||
_pending_message_id = &""
|
||||
_pending_bottle_picked_up = false
|
||||
_next_bottle_at_unix = 0
|
||||
_last_observed_unix = 0
|
||||
changed.emit()
|
||||
|
||||
|
||||
func to_save_data() -> Dictionary:
|
||||
var received: Array[String] = []
|
||||
for message_id: StringName in _received_message_ids:
|
||||
received.append(String(message_id))
|
||||
received.sort()
|
||||
return {
|
||||
"schema_version": SAVE_SCHEMA_VERSION,
|
||||
"received_message_ids": received,
|
||||
"pending_message_id": String(_pending_message_id),
|
||||
"pending_bottle_picked_up": _pending_bottle_picked_up,
|
||||
"next_bottle_at_unix": _next_bottle_at_unix,
|
||||
"last_observed_unix": _last_observed_unix,
|
||||
}
|
||||
|
||||
|
||||
func restore_from_save_data(data: Dictionary) -> bool:
|
||||
var sanitized: Dictionary = sanitize_save_data(data)
|
||||
if sanitized.is_empty():
|
||||
return false
|
||||
_received_message_ids.clear()
|
||||
for value: Variant in sanitized["received_message_ids"] as Array:
|
||||
_received_message_ids.append(StringName(str(value)))
|
||||
_pending_message_id = StringName(str(sanitized["pending_message_id"]))
|
||||
_pending_bottle_picked_up = bool(sanitized["pending_bottle_picked_up"])
|
||||
_next_bottle_at_unix = int(sanitized["next_bottle_at_unix"])
|
||||
_last_observed_unix = int(sanitized["last_observed_unix"])
|
||||
changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
static func default_save_data() -> Dictionary:
|
||||
return {
|
||||
"schema_version": SAVE_SCHEMA_VERSION,
|
||||
"received_message_ids": [],
|
||||
"pending_message_id": "",
|
||||
"pending_bottle_picked_up": false,
|
||||
"next_bottle_at_unix": 0,
|
||||
"last_observed_unix": 0,
|
||||
}
|
||||
|
||||
|
||||
static func sanitize_save_data(data: Dictionary) -> Dictionary:
|
||||
if (
|
||||
not _is_integer_value(data.get("schema_version"))
|
||||
or int(data.get("schema_version", -1)) != SAVE_SCHEMA_VERSION
|
||||
or typeof(data.get("received_message_ids")) != TYPE_ARRAY
|
||||
or typeof(data.get("pending_message_id")) not in [
|
||||
TYPE_STRING, TYPE_STRING_NAME,
|
||||
]
|
||||
or typeof(data.get("pending_bottle_picked_up")) != TYPE_BOOL
|
||||
or not _is_integer_value(data.get("next_bottle_at_unix"))
|
||||
or not _is_integer_value(data.get("last_observed_unix"))
|
||||
):
|
||||
return {}
|
||||
var values: Array = data["received_message_ids"]
|
||||
if values.size() > MAX_RECEIVED_MESSAGE_IDS:
|
||||
return {}
|
||||
var received: Array[String] = []
|
||||
var seen: Dictionary[String, bool] = {}
|
||||
for value: Variant in values:
|
||||
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return {}
|
||||
var message_id: String = str(value)
|
||||
if not _valid_message_id(message_id):
|
||||
return {}
|
||||
if seen.has(message_id):
|
||||
continue
|
||||
seen[message_id] = true
|
||||
received.append(message_id)
|
||||
var pending_message_id: String = str(data["pending_message_id"])
|
||||
var pending_bottle_picked_up: bool = bool(
|
||||
data["pending_bottle_picked_up"]
|
||||
)
|
||||
if (
|
||||
not pending_message_id.is_empty()
|
||||
and not _valid_message_id(pending_message_id)
|
||||
):
|
||||
return {}
|
||||
if not pending_message_id.is_empty() and not seen.has(pending_message_id):
|
||||
if received.size() >= MAX_RECEIVED_MESSAGE_IDS:
|
||||
return {}
|
||||
received.append(pending_message_id)
|
||||
if pending_message_id.is_empty() and pending_bottle_picked_up:
|
||||
return {}
|
||||
var next_bottle_at_unix: int = int(data["next_bottle_at_unix"])
|
||||
var last_observed_unix: int = int(data["last_observed_unix"])
|
||||
if (
|
||||
not _valid_persisted_unix_time(next_bottle_at_unix)
|
||||
or not _valid_persisted_unix_time(last_observed_unix)
|
||||
or (
|
||||
not pending_message_id.is_empty()
|
||||
and pending_bottle_picked_up
|
||||
and next_bottle_at_unix == 0
|
||||
)
|
||||
or (
|
||||
not pending_message_id.is_empty()
|
||||
and not pending_bottle_picked_up
|
||||
and next_bottle_at_unix != 0
|
||||
)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"schema_version": SAVE_SCHEMA_VERSION,
|
||||
"received_message_ids": received,
|
||||
"pending_message_id": pending_message_id,
|
||||
"pending_bottle_picked_up": pending_bottle_picked_up,
|
||||
"next_bottle_at_unix": next_bottle_at_unix,
|
||||
"last_observed_unix": last_observed_unix,
|
||||
}
|
||||
|
||||
|
||||
static func _valid_message_id(message_id: String) -> bool:
|
||||
return (
|
||||
not message_id.is_empty()
|
||||
and message_id == message_id.strip_edges()
|
||||
and message_id.length() <= MAX_MESSAGE_ID_LENGTH
|
||||
)
|
||||
|
||||
|
||||
static func _valid_unix_time(value: int) -> bool:
|
||||
return value > 0 and value <= MAX_SUPPORTED_UNIX_TIME
|
||||
|
||||
|
||||
static func _valid_persisted_unix_time(value: int) -> bool:
|
||||
return value == 0 or _valid_unix_time(value)
|
||||
|
||||
|
||||
static func _is_integer_value(value: Variant) -> bool:
|
||||
return typeof(value) == TYPE_INT or (
|
||||
typeof(value) == TYPE_FLOAT and is_equal_approx(value, roundf(value))
|
||||
)
|
||||
|
||||
|
||||
static func _random_cooldown_seconds(rng: RandomNumberGenerator) -> int:
|
||||
return rng.randi_range(
|
||||
MINIMUM_COOLDOWN_SECONDS,
|
||||
MAXIMUM_COOLDOWN_SECONDS,
|
||||
)
|
||||
1
messages/player_bottle_message_state.gd.uid
Normal file
1
messages/player_bottle_message_state.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bsvhqsqeu11pu
|
||||
|
|
@ -42,11 +42,15 @@ const NetworkProfilePreferencesType = preload(
|
|||
const PlayerAppearanceStoreType = preload(
|
||||
"res://progression/player_appearance_store.gd"
|
||||
)
|
||||
const PlayerBottleMessageStateType = preload(
|
||||
"res://messages/player_bottle_message_state.gd"
|
||||
)
|
||||
|
||||
const SAVE_VERSION: int = 11
|
||||
const SAVE_VERSION: int = 12
|
||||
const LEGACY_SAVE_FILENAME := "player_save.json"
|
||||
const ARCHIVE_EXTENSION := ".nfsave"
|
||||
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
|
||||
const MESSAGE_BOTTLE_ITEM_ID: StringName = &"message_bottle"
|
||||
const MAX_SAFE_BALANCE: int = 1000000000000
|
||||
const DEFAULT_WORLD_SEED: int = 13001
|
||||
const MAX_WORLD_SEED: int = 2147483646
|
||||
|
|
@ -85,6 +89,10 @@ class LoadSnapshot:
|
|||
)
|
||||
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
|
||||
var home_data: Dictionary = PlayerHomeStateType.default_save_data()
|
||||
var bottle_messages_data: Dictionary = (
|
||||
PlayerBottleMessageStateType.default_save_data()
|
||||
)
|
||||
var requires_resave: bool = false
|
||||
var has_character_data: bool = false
|
||||
var character_profile_data: Dictionary = {}
|
||||
var appearance_data: Dictionary = {}
|
||||
|
|
@ -111,6 +119,7 @@ var _player: PlayerType
|
|||
var _home_state: PlayerHomeStateType
|
||||
var _network_profile: NetworkProfilePreferencesType
|
||||
var _appearance_store: PlayerAppearanceStoreType
|
||||
var _bottle_messages: PlayerBottleMessageStateType
|
||||
var _legacy_character_data: Dictionary = {}
|
||||
var _autosave_timer: Timer
|
||||
var _is_configured: bool = false
|
||||
|
|
@ -192,6 +201,7 @@ func setup(
|
|||
jobs: PlayerJobServiceType,
|
||||
player: PlayerType,
|
||||
home_state: PlayerHomeStateType,
|
||||
bottle_messages: PlayerBottleMessageStateType,
|
||||
network_profile: NetworkProfilePreferencesType = null,
|
||||
appearance_store: PlayerAppearanceStoreType = null,
|
||||
) -> void:
|
||||
|
|
@ -212,6 +222,7 @@ func setup(
|
|||
_jobs = jobs
|
||||
_player = player
|
||||
_home_state = home_state
|
||||
_bottle_messages = bottle_messages
|
||||
_network_profile = network_profile
|
||||
_appearance_store = appearance_store
|
||||
capture_legacy_character_fallback()
|
||||
|
|
@ -233,6 +244,7 @@ func setup(
|
|||
and _jobs != null
|
||||
and _player != null
|
||||
and _home_state != null
|
||||
and _bottle_messages != null
|
||||
)
|
||||
if not _is_configured:
|
||||
push_error("PlayerSaveManager setup is missing required references.")
|
||||
|
|
@ -281,6 +293,8 @@ func setup(
|
|||
_player.active_lure_changed.connect(_on_active_tackle_changed)
|
||||
if not _home_state.changed.is_connected(_mark_dirty):
|
||||
_home_state.changed.connect(_mark_dirty)
|
||||
if not _bottle_messages.changed.is_connected(_mark_dirty):
|
||||
_bottle_messages.changed.connect(_mark_dirty)
|
||||
if (
|
||||
_network_profile != null
|
||||
and not _network_profile.profile_changed.is_connected(_mark_dirty)
|
||||
|
|
@ -493,6 +507,11 @@ func load_player_data() -> bool:
|
|||
var home_restored: bool = _home_state.restore_from_save_data(
|
||||
snapshot.home_data
|
||||
)
|
||||
var bottle_messages_restored: bool = (
|
||||
_bottle_messages.restore_from_save_data(
|
||||
snapshot.bottle_messages_data
|
||||
)
|
||||
)
|
||||
var cooler_restored: bool = _cooler_capacity.restore_level(
|
||||
snapshot.cooler_capacity_level
|
||||
)
|
||||
|
|
@ -540,6 +559,7 @@ func load_player_data() -> bool:
|
|||
or not world_weather_restored
|
||||
or not jobs_restored
|
||||
or not home_restored
|
||||
or not bottle_messages_restored
|
||||
or not character_restored
|
||||
):
|
||||
push_error(
|
||||
|
|
@ -547,7 +567,7 @@ func load_player_data() -> bool:
|
|||
"Validated player save could not be restored: "
|
||||
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
|
||||
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
|
||||
+ "time=%s weather=%s jobs=%s home=%s character=%s"
|
||||
+ "time=%s weather=%s jobs=%s home=%s bottles=%s character=%s"
|
||||
)
|
||||
% [
|
||||
inventory_restored,
|
||||
|
|
@ -565,12 +585,13 @@ func load_player_data() -> bool:
|
|||
world_weather_restored,
|
||||
jobs_restored,
|
||||
home_restored,
|
||||
bottle_messages_restored,
|
||||
character_restored,
|
||||
]
|
||||
)
|
||||
return false
|
||||
|
||||
_is_dirty = false
|
||||
_is_dirty = snapshot.requires_resave
|
||||
if read_path == _legacy_save_path():
|
||||
_migrate_legacy_plaintext_save(save_data, read_path)
|
||||
print(
|
||||
|
|
@ -885,8 +906,16 @@ func delete_progression_save() -> bool:
|
|||
|
||||
func set_autosave_enabled(enabled: bool) -> void:
|
||||
_autosave_enabled = enabled
|
||||
if not enabled and _autosave_timer != null:
|
||||
if _autosave_timer == null:
|
||||
return
|
||||
if not enabled:
|
||||
_autosave_timer.stop()
|
||||
elif (
|
||||
_is_dirty
|
||||
and not _automatic_saving_blocked
|
||||
and _autosave_timer.is_stopped()
|
||||
):
|
||||
_autosave_timer.start(maxf(autosave_delay, 0.05))
|
||||
|
||||
|
||||
func save_if_dirty() -> bool:
|
||||
|
|
@ -1074,6 +1103,7 @@ func _build_save_dictionary() -> Dictionary:
|
|||
},
|
||||
"jobs": _jobs.to_save_data(),
|
||||
"home": _home_state.to_save_data(),
|
||||
"message_bottles": _bottle_messages.to_save_data(),
|
||||
}
|
||||
var character: Dictionary = _current_character_data()
|
||||
if _network_profile != null and _appearance_store != null:
|
||||
|
|
@ -1095,6 +1125,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("home")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("message_bottles")) != TYPE_DICTIONARY
|
||||
):
|
||||
return null
|
||||
var wallet_data: Dictionary = save_data["wallet"]
|
||||
|
|
@ -1107,6 +1138,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
var experience_data: Dictionary = save_data["experience"]
|
||||
var jobs_data: Dictionary = save_data["jobs"]
|
||||
var home_data: Dictionary = save_data["home"]
|
||||
var bottle_messages_data: Dictionary = save_data["message_bottles"]
|
||||
var world_data: Dictionary = {}
|
||||
if typeof(save_data.get("world")) == TYPE_DICTIONARY:
|
||||
world_data = save_data["world"]
|
||||
|
|
@ -1206,6 +1238,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
if sanitized_home.is_empty():
|
||||
return null
|
||||
snapshot.home_data = sanitized_home
|
||||
var sanitized_bottle_messages: Dictionary = (
|
||||
PlayerBottleMessageStateType.sanitize_save_data(
|
||||
bottle_messages_data
|
||||
)
|
||||
)
|
||||
if sanitized_bottle_messages.is_empty():
|
||||
return null
|
||||
snapshot.bottle_messages_data = sanitized_bottle_messages
|
||||
if world_data.has("time_hours"):
|
||||
snapshot.world_time_hours = _read_world_time_hours(
|
||||
world_data["time_hours"]
|
||||
|
|
@ -1407,6 +1447,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
)
|
||||
seen_items[item_id] = true
|
||||
snapshot.bag_items.append(owned)
|
||||
_reconcile_bottle_message_ownership(snapshot)
|
||||
|
||||
var seen_unlocked_baits: Dictionary[StringName, bool] = {}
|
||||
if bag_data.has("unlocked_bait_ids"):
|
||||
|
|
@ -1567,6 +1608,8 @@ func _migrate_save(
|
|||
migrated = _migrate_version_9_to_10(migrated)
|
||||
10:
|
||||
migrated = _migrate_version_10_to_11(migrated)
|
||||
11:
|
||||
migrated = _migrate_version_11_to_12(migrated)
|
||||
_:
|
||||
return {}
|
||||
if migrated.is_empty():
|
||||
|
|
@ -1831,6 +1874,15 @@ func _migrate_version_10_to_11(data: Dictionary) -> Dictionary:
|
|||
return migrated
|
||||
|
||||
|
||||
func _migrate_version_11_to_12(data: Dictionary) -> Dictionary:
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
migrated["message_bottles"] = (
|
||||
PlayerBottleMessageStateType.default_save_data()
|
||||
)
|
||||
migrated["save_version"] = 12
|
||||
return migrated
|
||||
|
||||
|
||||
func _mark_dirty() -> void:
|
||||
if (
|
||||
_is_restoring
|
||||
|
|
@ -2101,6 +2153,7 @@ func _restore_defaults() -> void:
|
|||
_world_seed = DEFAULT_WORLD_SEED
|
||||
_jobs.reset_to_defaults()
|
||||
_home_state.reset_to_defaults()
|
||||
_bottle_messages.reset_to_defaults()
|
||||
_is_restoring = false
|
||||
_is_dirty = false
|
||||
|
||||
|
|
@ -2115,6 +2168,38 @@ func _valid_active_tackle(item_id: StringName, bait: bool) -> bool:
|
|||
)
|
||||
|
||||
|
||||
func _reconcile_bottle_message_ownership(snapshot: LoadSnapshot) -> void:
|
||||
var bottle_item_index: int = -1
|
||||
for index: int in snapshot.bag_items.size():
|
||||
if snapshot.bag_items[index].item_id == MESSAGE_BOTTLE_ITEM_ID:
|
||||
bottle_item_index = index
|
||||
break
|
||||
var bottle_data: Dictionary = snapshot.bottle_messages_data.duplicate(true)
|
||||
var pending_id: String = str(bottle_data.get("pending_message_id", ""))
|
||||
var picked_up: bool = bool(
|
||||
bottle_data.get("pending_bottle_picked_up", false)
|
||||
)
|
||||
if pending_id.is_empty() or not picked_up:
|
||||
if bottle_item_index >= 0:
|
||||
snapshot.bag_items.remove_at(bottle_item_index)
|
||||
snapshot.requires_resave = true
|
||||
push_warning(
|
||||
"Removed an orphaned message bottle from saved inventory."
|
||||
)
|
||||
return
|
||||
if bottle_item_index >= 0:
|
||||
return
|
||||
# Preserve the assigned, never-repeat message by putting its missing bottle
|
||||
# back on the beach. The pickup will start a fresh cooldown when recovered.
|
||||
bottle_data["pending_bottle_picked_up"] = false
|
||||
bottle_data["next_bottle_at_unix"] = 0
|
||||
snapshot.bottle_messages_data = bottle_data
|
||||
snapshot.requires_resave = true
|
||||
push_warning(
|
||||
"Recovered a missing inventory message bottle as a beach find."
|
||||
)
|
||||
|
||||
|
||||
func _validated_tackle_id(
|
||||
item_id: StringName,
|
||||
bait: bool,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export straywild_ISOLATED_VALIDATION_STORAGE=1
|
|||
readonly -a QUICK_TESTS=(
|
||||
"scripts/validate_animalese_samples.gd"
|
||||
"tests/android_readiness_validation.gd"
|
||||
"tests/beach_bottle_service_validation.gd"
|
||||
"tests/bottle_message_state_validation.gd"
|
||||
"tests/camera_drag_validation.gd"
|
||||
"tests/character_rig_validation.gd"
|
||||
"tests/network_chat_protocol_validation.gd"
|
||||
|
|
@ -40,6 +42,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/inventory_storage_validation.gd"
|
||||
"tests/keyboard_mouse_mapping_validation.gd"
|
||||
"tests/logbook_validation.gd"
|
||||
"tests/message_bottle_reader_validation.gd"
|
||||
"tests/network_player_animation_protocol_validation.gd"
|
||||
"tests/net_attachment_validation.gd"
|
||||
"tests/on_screen_keyboard_validation.gd"
|
||||
|
|
|
|||
202
tests/beach_bottle_service_validation.gd
Normal file
202
tests/beach_bottle_service_validation.gd
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
extends SceneTree
|
||||
|
||||
const ServiceType = preload("res://messages/beach_bottle_service.gd")
|
||||
const StateType = preload("res://messages/player_bottle_message_state.gd")
|
||||
const Catalog: BottleMessageCatalog = preload(
|
||||
"res://messages/catalog/bottle_message_catalog.tres"
|
||||
)
|
||||
const ItemCatalogResource: ItemCatalog = preload(
|
||||
"res://items/catalog/item_catalog.tres"
|
||||
)
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
extends Node
|
||||
|
||||
func is_world_ready() -> bool:
|
||||
return true
|
||||
|
||||
func get_saltwater_surface_height() -> float:
|
||||
return -0.45
|
||||
|
||||
func get_spawn_surface_triangles(
|
||||
_materials: Array[StringName],
|
||||
_minimum_y: float,
|
||||
_minimum_up_dot: float,
|
||||
_maximum_y: float,
|
||||
) -> Array[PackedVector3Array]:
|
||||
return [PackedVector3Array([
|
||||
Vector3(8.0, 0.0, -4.0),
|
||||
Vector3(16.0, 0.0, -4.0),
|
||||
Vector3(12.0, 0.0, 4.0),
|
||||
])]
|
||||
|
||||
|
||||
class FakeSaveManager:
|
||||
extends PlayerSaveManager
|
||||
|
||||
var allow_save: bool = true
|
||||
var save_count: int = 0
|
||||
|
||||
func save_if_dirty() -> bool:
|
||||
save_count += 1
|
||||
return allow_save
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var root := Node.new()
|
||||
get_root().add_child(root)
|
||||
var world := FakeWorld.new()
|
||||
var player := Node3D.new()
|
||||
var presentation_root := Node3D.new()
|
||||
var bag := PlayerBag.new()
|
||||
var state := StateType.new()
|
||||
var saves := FakeSaveManager.new()
|
||||
var service := ServiceType.new()
|
||||
root.add_child(world)
|
||||
root.add_child(player)
|
||||
root.add_child(presentation_root)
|
||||
root.add_child(bag)
|
||||
root.add_child(state)
|
||||
root.add_child(saves)
|
||||
root.add_child(service)
|
||||
bag.setup(ItemCatalogResource)
|
||||
var now_unix: int = int(Time.get_unix_time_from_system())
|
||||
assert(state.restore_from_save_data({
|
||||
"schema_version": 1,
|
||||
"received_message_ids": [],
|
||||
"pending_message_id": "",
|
||||
"pending_bottle_picked_up": false,
|
||||
"next_bottle_at_unix": now_unix + 300000,
|
||||
"last_observed_unix": now_unix,
|
||||
}))
|
||||
service.setup(
|
||||
world,
|
||||
player,
|
||||
bag,
|
||||
state,
|
||||
Catalog,
|
||||
saves,
|
||||
presentation_root,
|
||||
false,
|
||||
)
|
||||
service.force_spawn_for_testing = true
|
||||
service.set_gameplay_active(true)
|
||||
assert(service.call("_try_spawn_due_bottle"))
|
||||
assert(service.has_active_bottle())
|
||||
assert(state.has_pending_message())
|
||||
assert(not state.is_pending_bottle_picked_up())
|
||||
var presentation := service.get("_presentation") as BeachBottlePresentation
|
||||
var spawned_message: BottleMessageData = Catalog.get_message(
|
||||
presentation.message_id
|
||||
)
|
||||
assert(spawned_message != null)
|
||||
var bottle_sprite := presentation.get_node("%BottleSprite") as Sprite3D
|
||||
assert(bottle_sprite.texture.resource_path.ends_with(
|
||||
"message_in_a_bottle_sand.png"
|
||||
))
|
||||
assert(bottle_sprite.texture.get_size() == Vector2(64.0, 64.0))
|
||||
assert(bottle_sprite.position.y > 0.0)
|
||||
assert(bottle_sprite.offset == Vector2.ZERO)
|
||||
assert(
|
||||
bottle_sprite.billboard == BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
)
|
||||
var bottle_image: Image = bottle_sprite.texture.get_image()
|
||||
if bottle_image.is_compressed():
|
||||
assert(bottle_image.decompress() == OK)
|
||||
var visible_bounds: Rect2i = bottle_image.get_used_rect()
|
||||
assert(
|
||||
visible_bounds.position.y + visible_bounds.size.y
|
||||
== int(presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL)
|
||||
)
|
||||
var texture_height: float = float(bottle_sprite.texture.get_height())
|
||||
var visible_bottom_pixel: float = presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL
|
||||
var expected_sprite_height: float = (
|
||||
visible_bottom_pixel
|
||||
- texture_height * 0.5
|
||||
- presentation.VISIBLE_BASE_TUCK_PIXELS
|
||||
) * bottle_sprite.pixel_size
|
||||
assert(
|
||||
is_equal_approx(bottle_sprite.position.y, expected_sprite_height)
|
||||
)
|
||||
var first_position: Vector3 = service.get_active_bottle_position()
|
||||
assert(first_position.is_finite())
|
||||
assert(is_equal_approx(first_position.y, ServiceType.SURFACE_OFFSET))
|
||||
var visible_base_y: float = (
|
||||
first_position.y
|
||||
+ bottle_sprite.position.y
|
||||
+ (
|
||||
texture_height * 0.5
|
||||
- visible_bottom_pixel
|
||||
) * bottle_sprite.pixel_size
|
||||
)
|
||||
assert(is_equal_approx(visible_base_y, -bottle_sprite.pixel_size))
|
||||
assert(
|
||||
Vector2(first_position.x, first_position.z).length()
|
||||
>= ServiceType.MINIMUM_SPAWN_DISTANCE
|
||||
)
|
||||
assert(
|
||||
Vector2(first_position.x, first_position.z).length()
|
||||
<= ServiceType.MAXIMUM_SPAWN_DISTANCE
|
||||
)
|
||||
player.global_position = first_position
|
||||
assert(service.is_local_player_in_range())
|
||||
assert(service.get_prompt_anchor_position().is_finite())
|
||||
service.call("_process", 1.0)
|
||||
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
|
||||
assert(service.has_active_bottle())
|
||||
saves.allow_save = false
|
||||
assert(not service.try_collect_nearby())
|
||||
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
|
||||
assert(state.has_pending_message())
|
||||
assert(not state.is_pending_bottle_picked_up())
|
||||
assert(service.has_active_bottle())
|
||||
saves.allow_save = true
|
||||
# A failed disk transaction remains blocked until the player leaves pickup
|
||||
# range, preventing a save-conflict failure from being retried every frame.
|
||||
assert(not service.try_collect_nearby())
|
||||
player.global_position = first_position + Vector3.RIGHT * 2.0
|
||||
service.call("_process", 0.1)
|
||||
assert(not service.is_local_player_in_range())
|
||||
player.global_position = first_position
|
||||
assert(service.try_collect_nearby())
|
||||
assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
|
||||
assert(state.is_pending_bottle_picked_up())
|
||||
assert(not service.has_active_bottle())
|
||||
saves.allow_save = false
|
||||
var failed_read: Dictionary = service.read_held_bottle()
|
||||
assert(not bool(failed_read.get("ok", false)))
|
||||
assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
|
||||
assert(state.is_pending_bottle_picked_up())
|
||||
saves.allow_save = true
|
||||
var read_result: Dictionary = service.read_held_bottle()
|
||||
assert(bool(read_result.get("ok", false)))
|
||||
assert(
|
||||
str(read_result.get("text", ""))
|
||||
== spawned_message.body
|
||||
)
|
||||
assert(
|
||||
str(read_result.get("message_id", ""))
|
||||
== String(spawned_message.message_id)
|
||||
)
|
||||
assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID))
|
||||
assert(not state.has_pending_message())
|
||||
var point := ServiceType.sample_triangle(
|
||||
PackedVector3Array([
|
||||
Vector3.ZERO,
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Vector3(0.0, 0.0, 1.0),
|
||||
]),
|
||||
0.0,
|
||||
0.75,
|
||||
)
|
||||
assert(point.is_equal_approx(Vector3.ZERO))
|
||||
assert(not ServiceType.sample_triangle(PackedVector3Array(), 0.5, 0.5).is_finite())
|
||||
print("Beach bottle service validation: PASS")
|
||||
root.queue_free()
|
||||
await process_frame
|
||||
quit()
|
||||
1
tests/beach_bottle_service_validation.gd.uid
Normal file
1
tests/beach_bottle_service_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dadlfrooej5k0
|
||||
187
tests/bottle_message_state_validation.gd
Normal file
187
tests/bottle_message_state_validation.gd
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
extends SceneTree
|
||||
|
||||
const BottleMessageCatalogType = preload(
|
||||
"res://messages/bottle_message_catalog.gd"
|
||||
)
|
||||
const BottleMessageDataType = preload(
|
||||
"res://messages/bottle_message_data.gd"
|
||||
)
|
||||
const PlayerBottleMessageStateType = preload(
|
||||
"res://messages/player_bottle_message_state.gd"
|
||||
)
|
||||
|
||||
const CATALOG_PATH: String = (
|
||||
"res://messages/catalog/bottle_message_catalog.tres"
|
||||
)
|
||||
const BASE_TIME: int = 1788282000
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_validate_shipped_catalog()
|
||||
_validate_player_save_migration()
|
||||
_validate_deterministic_unseen_selection()
|
||||
_validate_cooldown_and_assignment()
|
||||
_validate_save_round_trip_and_unknown_ids()
|
||||
_validate_backward_clock_tolerance()
|
||||
print("Bottle message state validation: PASS")
|
||||
quit()
|
||||
|
||||
|
||||
func _validate_shipped_catalog() -> void:
|
||||
var catalog := load(CATALOG_PATH) as BottleMessageCatalogType
|
||||
assert(catalog != null)
|
||||
var message: BottleMessageDataType = catalog.get_message(
|
||||
&"pushing_squares"
|
||||
)
|
||||
assert(message != null)
|
||||
assert(message.active)
|
||||
assert(message.body == "it's as simple as pushing squares")
|
||||
var message_2: BottleMessageDataType = catalog.get_message(
|
||||
&"message_2"
|
||||
)
|
||||
assert(message_2 != null)
|
||||
assert(message_2.active)
|
||||
assert(
|
||||
message_2.body
|
||||
== "01101001 00100000 01101100 01101111 01110110 01100101 00100000 01111001 01101111 01110101"
|
||||
)
|
||||
|
||||
|
||||
func _validate_player_save_migration() -> void:
|
||||
var manager := PlayerSaveManager.new()
|
||||
root.add_child(manager)
|
||||
var migrated: Dictionary = manager.call(
|
||||
"_migrate_save",
|
||||
{"save_version": 11},
|
||||
11,
|
||||
)
|
||||
assert(int(migrated.get("save_version", -1)) == 12)
|
||||
assert(
|
||||
migrated.get("message_bottles", {})
|
||||
== PlayerBottleMessageStateType.default_save_data()
|
||||
)
|
||||
manager.queue_free()
|
||||
|
||||
|
||||
func _validate_deterministic_unseen_selection() -> void:
|
||||
var catalog := BottleMessageCatalogType.new()
|
||||
for message_id: StringName in [&"third", &"first", &"second"]:
|
||||
var message := BottleMessageDataType.new()
|
||||
message.message_id = message_id
|
||||
message.body = String(message_id)
|
||||
catalog.entries.append(message)
|
||||
var first_rng := RandomNumberGenerator.new()
|
||||
first_rng.seed = 4412
|
||||
var second_rng := RandomNumberGenerator.new()
|
||||
second_rng.seed = 4412
|
||||
var received: Array[StringName] = [&"second"]
|
||||
assert(
|
||||
catalog.choose_random_unseen_message(received, first_rng).message_id
|
||||
== catalog.choose_random_unseen_message(received, second_rng).message_id
|
||||
)
|
||||
catalog.get_message(&"first").active = false
|
||||
assert(
|
||||
catalog.choose_random_unseen_message(received, first_rng).message_id
|
||||
== &"third"
|
||||
)
|
||||
|
||||
|
||||
func _validate_cooldown_and_assignment() -> void:
|
||||
var catalog := load(CATALOG_PATH) as BottleMessageCatalogType
|
||||
var state := PlayerBottleMessageStateType.new()
|
||||
root.add_child(state)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 915
|
||||
assert(state.ensure_cooldown_scheduled(BASE_TIME, rng))
|
||||
var due_at: int = state.get_next_bottle_at_unix()
|
||||
assert(
|
||||
due_at >= BASE_TIME + state.MINIMUM_COOLDOWN_SECONDS
|
||||
and due_at <= BASE_TIME + state.MAXIMUM_COOLDOWN_SECONDS
|
||||
)
|
||||
assert(state.try_assign_due_message(catalog, rng, due_at - 1) == null)
|
||||
var message: BottleMessageDataType = state.try_assign_due_message(
|
||||
catalog, rng, due_at
|
||||
)
|
||||
assert(message != null)
|
||||
assert(state.has_pending_message())
|
||||
assert(not state.is_pending_bottle_picked_up())
|
||||
assert(state.has_received_message(message.message_id))
|
||||
assert(state.get_next_bottle_at_unix() == 0)
|
||||
assert(not state.ensure_cooldown_scheduled(due_at, rng))
|
||||
assert(state.try_assign_due_message(catalog, rng, due_at) == null)
|
||||
assert(state.record_bottle_picked_up(due_at + 10, rng))
|
||||
assert(state.is_pending_bottle_picked_up())
|
||||
assert(
|
||||
state.get_next_bottle_at_unix()
|
||||
>= due_at + 10 + state.MINIMUM_COOLDOWN_SECONDS
|
||||
)
|
||||
assert(not state.record_bottle_picked_up(due_at + 20, rng))
|
||||
assert(state.consume_pending_message() == message.message_id)
|
||||
assert(not state.has_pending_message())
|
||||
var second_due_at: int = state.get_next_bottle_at_unix()
|
||||
var second_message: BottleMessageDataType = state.try_assign_due_message(
|
||||
catalog,
|
||||
rng,
|
||||
second_due_at,
|
||||
)
|
||||
assert(second_message != null)
|
||||
assert(second_message.message_id != message.message_id)
|
||||
assert(state.record_bottle_picked_up(second_due_at + 10, rng))
|
||||
assert(state.consume_pending_message() == second_message.message_id)
|
||||
var exhausted_due_at: int = state.get_next_bottle_at_unix()
|
||||
assert(
|
||||
state.try_assign_due_message(
|
||||
catalog,
|
||||
rng,
|
||||
exhausted_due_at,
|
||||
) == null
|
||||
)
|
||||
state.queue_free()
|
||||
|
||||
|
||||
func _validate_save_round_trip_and_unknown_ids() -> void:
|
||||
var state := PlayerBottleMessageStateType.new()
|
||||
root.add_child(state)
|
||||
var data := {
|
||||
"schema_version": state.SAVE_SCHEMA_VERSION,
|
||||
"received_message_ids": ["retired_message"],
|
||||
"pending_message_id": "pending_from_future_catalog",
|
||||
"pending_bottle_picked_up": true,
|
||||
"next_bottle_at_unix": BASE_TIME + 50,
|
||||
"last_observed_unix": BASE_TIME,
|
||||
}
|
||||
assert(state.restore_from_save_data(data))
|
||||
assert(state.has_received_message(&"retired_message"))
|
||||
assert(state.has_received_message(&"pending_from_future_catalog"))
|
||||
assert(state.get_pending_message_id() == &"pending_from_future_catalog")
|
||||
assert(state.is_pending_bottle_picked_up())
|
||||
var saved: Dictionary = state.to_save_data()
|
||||
assert(
|
||||
"retired_message" in (saved["received_message_ids"] as Array)
|
||||
)
|
||||
assert(
|
||||
"pending_from_future_catalog"
|
||||
in (saved["received_message_ids"] as Array)
|
||||
)
|
||||
state.queue_free()
|
||||
|
||||
|
||||
func _validate_backward_clock_tolerance() -> void:
|
||||
var state := PlayerBottleMessageStateType.new()
|
||||
root.add_child(state)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 122
|
||||
assert(state.ensure_cooldown_scheduled(BASE_TIME, rng))
|
||||
var original_due_at: int = state.get_next_bottle_at_unix()
|
||||
assert(not state.observe_wall_clock(BASE_TIME - 60))
|
||||
assert(state.get_last_observed_unix() == BASE_TIME)
|
||||
assert(state.get_next_bottle_at_unix() == original_due_at)
|
||||
var rollback: int = state.BACKWARD_CLOCK_TOLERANCE_SECONDS + 1
|
||||
assert(state.observe_wall_clock(BASE_TIME - rollback))
|
||||
assert(state.get_last_observed_unix() == BASE_TIME - rollback)
|
||||
assert(state.get_next_bottle_at_unix() == original_due_at - rollback)
|
||||
state.queue_free()
|
||||
1
tests/bottle_message_state_validation.gd.uid
Normal file
1
tests/bottle_message_state_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cjkesykq1lrfg
|
||||
|
|
@ -53,6 +53,12 @@ func _run() -> void:
|
|||
"_decor_shop_interaction"
|
||||
) as DecorShopInteraction
|
||||
var game_ui := main.get("_game_ui") as GameUI
|
||||
var bottle_service := main.get_node(
|
||||
"%BeachBottleService"
|
||||
) as BeachBottleService
|
||||
var bottle_state := main.get_node(
|
||||
"%PlayerBottleMessageState"
|
||||
) as PlayerBottleMessageState
|
||||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
|
|
@ -60,13 +66,74 @@ func _run() -> void:
|
|||
_expect(interaction != null, "shop interaction is unavailable")
|
||||
_expect(decor_interaction != null, "decor interaction is unavailable")
|
||||
_expect(game_ui != null, "game UI is unavailable")
|
||||
_expect(bottle_service != null, "beach bottle service is unavailable")
|
||||
_expect(bottle_state != null, "bottle message state is unavailable")
|
||||
_expect(chat_service != null, "network chat service is unavailable")
|
||||
if (
|
||||
player != null
|
||||
and interaction != null
|
||||
and game_ui != null
|
||||
and bottle_service != null
|
||||
and bottle_state != null
|
||||
and chat_service != null
|
||||
):
|
||||
# A nearby beach bottle owns the same compact interaction glyph as an
|
||||
# unengaged villager. Controller Y must yield its character-call alias and
|
||||
# collect only after the explicit interaction press.
|
||||
var now_unix: int = int(Time.get_unix_time_from_system())
|
||||
bottle_service.call("_clear_presentation", false)
|
||||
_expect(bottle_state.restore_from_save_data({
|
||||
"schema_version": 1,
|
||||
"received_message_ids": ["pushing_squares"],
|
||||
"pending_message_id": "pushing_squares",
|
||||
"pending_bottle_picked_up": false,
|
||||
"next_bottle_at_unix": 0,
|
||||
"last_observed_unix": now_unix,
|
||||
}), "bottle interaction fixture could not be restored")
|
||||
var bottle_position := (
|
||||
interaction.global_position + Vector3(12.0, 0.0, 12.0)
|
||||
)
|
||||
player.global_position = bottle_position
|
||||
bottle_service.call(
|
||||
"_spawn_presentation",
|
||||
&"pushing_squares",
|
||||
bottle_position,
|
||||
)
|
||||
main.call("_process", 0.0)
|
||||
var bottle_prompt := game_ui.get_node("%ShopPrompt") as Control
|
||||
_expect(
|
||||
bottle_service.is_local_player_in_range()
|
||||
and bottle_prompt.visible
|
||||
and bottle_prompt.size == Vector2(30.0, 30.0),
|
||||
"nearby bottle did not use the villager interaction glyph",
|
||||
)
|
||||
var bottle_calls: Array[String] = []
|
||||
var capture_bottle_call := func(
|
||||
_peer_id: int,
|
||||
call_id: String,
|
||||
_pitch_scale: float,
|
||||
) -> void:
|
||||
bottle_calls.append(call_id)
|
||||
chat_service.character_call_received.connect(capture_bottle_call)
|
||||
var bottle_press := InputEventJoypadButton.new()
|
||||
bottle_press.device = 0
|
||||
bottle_press.button_index = JOY_BUTTON_Y
|
||||
bottle_press.pressed = true
|
||||
game_ui.call("_input", bottle_press)
|
||||
_expect(
|
||||
bottle_calls.is_empty(),
|
||||
"bottle interaction Y press played a character call",
|
||||
)
|
||||
main.call("_unhandled_input", bottle_press)
|
||||
await process_frame
|
||||
_expect(
|
||||
player.bag.owns_item(BeachBottleService.BOTTLE_ITEM_ID)
|
||||
and not bottle_service.has_active_bottle()
|
||||
and main.get("_engaged_shop_npc_interaction") == null,
|
||||
"explicit bottle interaction did not collect exactly the bottle",
|
||||
)
|
||||
chat_service.character_call_received.disconnect(capture_bottle_call)
|
||||
|
||||
player.global_position = interaction.global_position
|
||||
for _frame: int in 6:
|
||||
await physics_frame
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func _run() -> void:
|
|||
var hotbar_data: Dictionary = parsed["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["save_version"]) == 11)
|
||||
assert(int(parsed["save_version"]) == 12)
|
||||
assert(not PlayerHomeState.sanitize_save_data(
|
||||
parsed.get("home", {})
|
||||
).is_empty())
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void:
|
|||
version_four,
|
||||
4,
|
||||
)
|
||||
assert(int(migrated.get("save_version", -1)) == 11)
|
||||
assert(int(migrated.get("save_version", -1)) == 12)
|
||||
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
|
||||
assert(
|
||||
is_equal_approx(
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ func _run() -> void:
|
|||
)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
assert(int(save_data.get("save_version", -1)) == 11)
|
||||
assert(int(save_data.get("save_version", -1)) == 12)
|
||||
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
|
||||
assert(not PlayerHomeState.sanitize_save_data(
|
||||
save_data.get("home", {})
|
||||
|
|
|
|||
204
tests/message_bottle_reader_validation.gd
Normal file
204
tests/message_bottle_reader_validation.gd
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
extends SceneTree
|
||||
|
||||
const ReaderScene: PackedScene = preload(
|
||||
"res://ui/message_bottle_reader.tscn"
|
||||
)
|
||||
const PlayerMenuScene: PackedScene = preload("res://ui/player_menu.tscn")
|
||||
const ItemCatalogResource: ItemCatalog = preload(
|
||||
"res://items/catalog/item_catalog.tres"
|
||||
)
|
||||
const BottleCatalog: BottleMessageCatalog = preload(
|
||||
"res://messages/catalog/bottle_message_catalog.tres"
|
||||
)
|
||||
|
||||
|
||||
class FakeSaveManager:
|
||||
extends PlayerSaveManager
|
||||
|
||||
func save_if_dirty() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var prior_button := Button.new()
|
||||
prior_button.text = "inventory slot"
|
||||
root.add_child(prior_button)
|
||||
prior_button.grab_focus()
|
||||
|
||||
var reader := ReaderScene.instantiate() as MessageBottleReader
|
||||
root.add_child(reader)
|
||||
await process_frame
|
||||
assert(not reader.is_open())
|
||||
assert(reader.mouse_filter == Control.MOUSE_FILTER_STOP)
|
||||
assert(reader.find_children("*", "ColorRect", true, false).is_empty())
|
||||
assert(reader.find_child("Title", true, false) == null)
|
||||
var parchment := reader.get_node("%ParchmentSurface") as Control
|
||||
var parchment_art := reader.get_node("%ParchmentArt") as TextureRect
|
||||
var fallback_panel := reader.get_node("%FallbackPanel") as Control
|
||||
assert(parchment_art.texture != null)
|
||||
assert(parchment_art.texture.resource_path.ends_with("parchment.png"))
|
||||
assert(parchment_art.texture.get_size() == Vector2(256.0, 192.0))
|
||||
assert(parchment_art.visible)
|
||||
assert(not fallback_panel.visible)
|
||||
assert(is_equal_approx(
|
||||
parchment.custom_minimum_size.x / parchment.custom_minimum_size.y,
|
||||
4.0 / 3.0,
|
||||
))
|
||||
|
||||
var observed := {"closed_count": 0}
|
||||
reader.closed.connect(
|
||||
func() -> void: observed["closed_count"] += 1
|
||||
)
|
||||
reader.present("it's as simple as pushing squares")
|
||||
await process_frame
|
||||
assert(reader.is_open())
|
||||
var message := reader.get_node("%MessageLabel") as Label
|
||||
var close_button := reader.get_node("%CloseButton") as Button
|
||||
assert(message.text == "it's as simple as pushing squares")
|
||||
assert(root.gui_get_focus_owner() == close_button)
|
||||
|
||||
reader.present("a replacement message")
|
||||
await process_frame
|
||||
assert(message.text == "a replacement message")
|
||||
assert(reader.consume_escape())
|
||||
await process_frame
|
||||
assert(not reader.is_open())
|
||||
assert(int(observed["closed_count"]) == 1)
|
||||
assert(root.gui_get_focus_owner() == prior_button)
|
||||
assert(not reader.consume_escape())
|
||||
|
||||
root.size = Vector2i(480, 270)
|
||||
reader.present("small viewport")
|
||||
await process_frame
|
||||
assert(parchment.size.x <= 448.0)
|
||||
assert(parchment.size.y <= 238.0)
|
||||
assert(absf(parchment.size.x / parchment.size.y - 4.0 / 3.0) < 0.01)
|
||||
reader.queue_free()
|
||||
prior_button.queue_free()
|
||||
await process_frame
|
||||
|
||||
await _validate_player_menu_controller_open_and_close()
|
||||
|
||||
print("Message bottle reader validation: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _validate_player_menu_controller_open_and_close() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var host := Node.new()
|
||||
root.add_child(host)
|
||||
var menu := PlayerMenuScene.instantiate() as PlayerMenu
|
||||
var bag := PlayerBag.new()
|
||||
var catches := FishInventory.new()
|
||||
var capacity := PlayerCoolerCapacity.new()
|
||||
var layout := PlayerInventoryLayout.new()
|
||||
var hotbar := PlayerHotbar.new()
|
||||
var state := PlayerBottleMessageState.new()
|
||||
var saves := FakeSaveManager.new()
|
||||
var service := BeachBottleService.new()
|
||||
for node: Node in [
|
||||
menu,
|
||||
bag,
|
||||
catches,
|
||||
capacity,
|
||||
layout,
|
||||
hotbar,
|
||||
state,
|
||||
saves,
|
||||
service,
|
||||
]:
|
||||
host.add_child(node)
|
||||
await process_frame
|
||||
|
||||
bag.setup(ItemCatalogResource)
|
||||
var bottle_item: ItemData = ItemCatalogResource.get_item_by_id(&"message_bottle")
|
||||
assert(bottle_item != null)
|
||||
assert(bottle_item.icon != null)
|
||||
assert(bottle_item.icon.resource_path.ends_with("message_in_a_bottle.png"))
|
||||
assert(bottle_item.icon.get_size() == Vector2(64.0, 64.0))
|
||||
layout.setup(bag, catches, ItemCatalogResource, capacity)
|
||||
bag.set_inventory_layout(layout)
|
||||
catches.set_inventory_layout(layout)
|
||||
hotbar.setup(bag, ItemCatalogResource, catches, layout)
|
||||
assert(bag.add_item(&"message_bottle"))
|
||||
var now_unix: int = int(Time.get_unix_time_from_system())
|
||||
assert(state.restore_from_save_data({
|
||||
"schema_version": 1,
|
||||
"received_message_ids": ["pushing_squares"],
|
||||
"pending_message_id": "pushing_squares",
|
||||
"pending_bottle_picked_up": true,
|
||||
"next_bottle_at_unix": now_unix + 300000,
|
||||
"last_observed_unix": now_unix,
|
||||
}))
|
||||
service.set("_bag", bag)
|
||||
service.set("_state", state)
|
||||
service.set("_catalog", BottleCatalog)
|
||||
service.set("_save_manager", saves)
|
||||
menu.set("_bag", bag)
|
||||
menu.set("_inventory", catches)
|
||||
menu.set("_hotbar", hotbar)
|
||||
menu.set("_inventory_layout", layout)
|
||||
menu.set("_item_catalog", ItemCatalogResource)
|
||||
menu.setup_message_bottles(service)
|
||||
var inventory_grid := menu.get("_general_inventory_grid") as GeneralInventoryGrid
|
||||
inventory_grid.setup(
|
||||
layout,
|
||||
bag,
|
||||
catches,
|
||||
hotbar,
|
||||
ItemCatalogResource,
|
||||
PlayerInventoryLayout.InventoryContainer.INVENTORY,
|
||||
)
|
||||
menu.visible = true
|
||||
menu.call("_show_section_immediate", PlayerMenu.Section.BAG)
|
||||
menu.call("_set_shell_interactive", true)
|
||||
menu.set(
|
||||
"_controller_ownership",
|
||||
PlayerMenu.ControllerOwnership.ITEM_LIST,
|
||||
)
|
||||
menu.call("_apply_inventory_controller_zone_focus_modes")
|
||||
var bottle_slot: GeneralInventorySlot
|
||||
for slot: GeneralInventorySlot in inventory_grid.get_slots():
|
||||
if slot.entry_identity == &"message_bottle":
|
||||
bottle_slot = slot
|
||||
break
|
||||
assert(bottle_slot != null)
|
||||
bottle_slot.grab_focus()
|
||||
|
||||
var accept := InputEventJoypadButton.new()
|
||||
accept.button_index = JOY_BUTTON_A
|
||||
accept.pressed = true
|
||||
assert(bool(menu.call("_handle_controller_ownership_input", accept)))
|
||||
accept.pressed = false
|
||||
assert(bool(menu.call("_handle_controller_ownership_input", accept)))
|
||||
await process_frame
|
||||
var menu_reader := menu.get_node("%MessageBottleReader") as MessageBottleReader
|
||||
assert(menu_reader.is_open())
|
||||
assert(
|
||||
(menu_reader.get_node("%MessageLabel") as Label).text
|
||||
== "it's as simple as pushing squares"
|
||||
)
|
||||
assert(not bag.owns_item(&"message_bottle"))
|
||||
assert(not state.has_pending_message())
|
||||
assert(
|
||||
(menu.get_node("%InventoryTab") as Button).focus_mode
|
||||
== Control.FOCUS_NONE
|
||||
)
|
||||
|
||||
var cancel := InputEventJoypadButton.new()
|
||||
cancel.button_index = JOY_BUTTON_B
|
||||
cancel.pressed = true
|
||||
menu.call("_input", cancel)
|
||||
await process_frame
|
||||
assert(not menu_reader.is_open())
|
||||
assert(
|
||||
(menu.get_node("%InventoryTab") as Button).focus_mode
|
||||
== Control.FOCUS_ALL
|
||||
)
|
||||
host.queue_free()
|
||||
await process_frame
|
||||
1
tests/message_bottle_reader_validation.gd.uid
Normal file
1
tests/message_bottle_reader_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://otelx0xjr05u
|
||||
|
|
@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
|
|||
version_five,
|
||||
5,
|
||||
)
|
||||
assert(int(migrated.get("save_version", -1)) == 11)
|
||||
assert(int(migrated.get("save_version", -1)) == 12)
|
||||
var experience_data: Dictionary = migrated.get("experience", {})
|
||||
assert(int(experience_data.get("total_experience", -1)) == 0)
|
||||
var world_data: Dictionary = migrated.get("world", {})
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func _run() -> void:
|
|||
_assert_opaque(save_path)
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
assert(int((decoded["data"] as Dictionary)["save_version"]) == 11)
|
||||
assert(int((decoded["data"] as Dictionary)["save_version"]) == 12)
|
||||
var saved_character: Dictionary = (
|
||||
(decoded["data"] as Dictionary).get("character", {})
|
||||
)
|
||||
|
|
@ -89,6 +89,39 @@ func _run() -> void:
|
|||
(saved_character["appearance"] as Dictionary)
|
||||
== first_appearance
|
||||
)
|
||||
# Cross-model recovery must not merely repair runtime state. Mark the loaded
|
||||
# save dirty so the next save removes the orphan permanently on disk.
|
||||
var orphaned_bottle_save: Dictionary = (
|
||||
(decoded["data"] as Dictionary).duplicate(true)
|
||||
)
|
||||
var orphaned_bag: Dictionary = (
|
||||
orphaned_bottle_save["bag"] as Dictionary
|
||||
)
|
||||
(orphaned_bag["items"] as Array).append({
|
||||
"item_id": "message_bottle",
|
||||
"quantity": 1,
|
||||
"storage_slot": -1,
|
||||
})
|
||||
var orphan_write: Dictionary = save_manager.call(
|
||||
"_write_current_save_data",
|
||||
orphaned_bottle_save,
|
||||
save_manager.get("_expected_hash"),
|
||||
)
|
||||
assert(bool(orphan_write.get("ok", false)))
|
||||
assert(save_manager.load_player_data())
|
||||
assert(not player.bag.owns_item(&"message_bottle"))
|
||||
assert(save_manager.is_dirty())
|
||||
assert(save_manager.save_now())
|
||||
var repaired_bottle_save: Dictionary = (
|
||||
ProgressionSaveCodec.read_local_save(save_path)["data"]
|
||||
)
|
||||
for value: Variant in (
|
||||
repaired_bottle_save["bag"]["items"] as Array
|
||||
):
|
||||
assert(
|
||||
StringName(str((value as Dictionary).get("item_id", "")))
|
||||
!= &"message_bottle"
|
||||
)
|
||||
# Starting another save must feel like a new character, then loading this
|
||||
# archive must restore only this slot's name, voice, and appearance.
|
||||
assert(save_manager.initialize_new_game(97531))
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ const PlayerExperienceType = preload(
|
|||
const UIReferencePresentationType = preload(
|
||||
"res://ui/ui_reference_presentation.gd"
|
||||
)
|
||||
const BeachBottleServiceType = preload(
|
||||
"res://messages/beach_bottle_service.gd"
|
||||
)
|
||||
const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload(
|
||||
"res://ui/components/bubble_menu/bubble_menu_profile.tres"
|
||||
)
|
||||
|
|
@ -221,6 +224,7 @@ var _shop_interaction: ShopInteractionType
|
|||
var _decor_shop_interaction: DecorShopInteractionType
|
||||
var _rv_upgrade_shop_interaction: RVUpgradeInteractionType
|
||||
var _storage_interaction: PlayerStorageInteractionType
|
||||
var _beach_bottle_service: BeachBottleServiceType
|
||||
var _surface_drawing: NetworkSurfaceDrawingService
|
||||
var _surface_drawing_hotbar_selected: bool = false
|
||||
var _experience: PlayerExperienceType
|
||||
|
|
@ -593,6 +597,11 @@ func setup(
|
|||
)
|
||||
|
||||
|
||||
func setup_message_bottles(service: BeachBottleServiceType) -> void:
|
||||
_beach_bottle_service = service
|
||||
_player_menu.setup_message_bottles(service)
|
||||
|
||||
|
||||
func set_world_interactions(
|
||||
shop_interaction: ShopInteractionType,
|
||||
storage_interaction: PlayerStorageInteractionType,
|
||||
|
|
@ -840,6 +849,10 @@ func _character_call_yields_to_world_interaction(
|
|||
_storage_interaction != null
|
||||
and _storage_interaction.is_local_player_in_range()
|
||||
)
|
||||
or (
|
||||
_beach_bottle_service != null
|
||||
and _beach_bottle_service.is_local_player_in_range()
|
||||
)
|
||||
)
|
||||
and _fishing_spot != null
|
||||
and _fishing_spot.can_open_fishing_shop()
|
||||
|
|
|
|||
119
ui/message_bottle_reader.gd
Normal file
119
ui/message_bottle_reader.gd
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
class_name MessageBottleReader
|
||||
extends Control
|
||||
|
||||
signal closed
|
||||
|
||||
const MAX_PARCHMENT_SIZE := Vector2(640.0, 480.0)
|
||||
const FALLBACK_PARCHMENT_ASPECT: float = 4.0 / 3.0
|
||||
const VIEWPORT_MARGIN := Vector2(32.0, 32.0)
|
||||
|
||||
@export var parchment_texture: Texture2D:
|
||||
set(value):
|
||||
parchment_texture = value
|
||||
_apply_parchment_texture()
|
||||
|
||||
@onready var _parchment_surface: Control = %ParchmentSurface
|
||||
@onready var _fallback_panel: Control = %FallbackPanel
|
||||
@onready var _parchment_art: TextureRect = %ParchmentArt
|
||||
@onready var _message_label: Label = %MessageLabel
|
||||
@onready var _close_button: Button = %CloseButton
|
||||
|
||||
var _previous_focus: WeakRef
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_close_button.pressed.connect(dismiss)
|
||||
_close_button.focus_neighbor_left = _close_button.get_path_to(_close_button)
|
||||
_close_button.focus_neighbor_right = _close_button.get_path_to(_close_button)
|
||||
_close_button.focus_neighbor_top = _close_button.get_path_to(_close_button)
|
||||
_close_button.focus_neighbor_bottom = _close_button.get_path_to(_close_button)
|
||||
_apply_parchment_texture()
|
||||
_resize_parchment()
|
||||
hide()
|
||||
|
||||
|
||||
func present(message_text: String) -> void:
|
||||
if not is_node_ready():
|
||||
call_deferred("present", message_text)
|
||||
return
|
||||
if not is_open():
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
_previous_focus = weakref(focus_owner) if focus_owner != null else null
|
||||
_message_label.text = message_text
|
||||
show()
|
||||
_close_button.call_deferred("grab_focus")
|
||||
|
||||
|
||||
func dismiss() -> void:
|
||||
if not is_open():
|
||||
return
|
||||
hide()
|
||||
_restore_previous_focus.call_deferred()
|
||||
closed.emit()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return visible
|
||||
|
||||
|
||||
func consume_escape() -> bool:
|
||||
if not is_open():
|
||||
return false
|
||||
dismiss()
|
||||
return true
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not is_open() or not event.is_action_pressed(&"ui_cancel"):
|
||||
return
|
||||
if consume_escape():
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED and is_node_ready():
|
||||
_resize_parchment()
|
||||
|
||||
|
||||
func _resize_parchment() -> void:
|
||||
var available_size: Vector2 = (
|
||||
get_viewport_rect().size - VIEWPORT_MARGIN
|
||||
).max(Vector2.ONE)
|
||||
var target_size: Vector2 = MAX_PARCHMENT_SIZE.min(available_size)
|
||||
var aspect: float = FALLBACK_PARCHMENT_ASPECT
|
||||
if parchment_texture != null:
|
||||
var texture_size: Vector2 = parchment_texture.get_size()
|
||||
if texture_size.x > 0.0 and texture_size.y > 0.0:
|
||||
aspect = texture_size.x / texture_size.y
|
||||
if target_size.x / target_size.y > aspect:
|
||||
target_size.x = target_size.y * aspect
|
||||
else:
|
||||
target_size.y = target_size.x / aspect
|
||||
_parchment_surface.custom_minimum_size = target_size.floor()
|
||||
|
||||
|
||||
func _apply_parchment_texture() -> void:
|
||||
if _parchment_art == null:
|
||||
return
|
||||
_parchment_art.texture = parchment_texture
|
||||
_parchment_art.visible = parchment_texture != null
|
||||
if _fallback_panel != null:
|
||||
_fallback_panel.visible = parchment_texture == null
|
||||
_resize_parchment()
|
||||
|
||||
|
||||
func _restore_previous_focus() -> void:
|
||||
if _previous_focus == null:
|
||||
get_viewport().gui_release_focus()
|
||||
return
|
||||
var previous: Control = _previous_focus.get_ref() as Control
|
||||
_previous_focus = null
|
||||
if (
|
||||
is_instance_valid(previous)
|
||||
and previous.is_inside_tree()
|
||||
and previous.is_visible_in_tree()
|
||||
and previous.focus_mode != Control.FOCUS_NONE
|
||||
):
|
||||
previous.grab_focus()
|
||||
else:
|
||||
get_viewport().gui_release_focus()
|
||||
1
ui/message_bottle_reader.gd.uid
Normal file
1
ui/message_bottle_reader.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://fege70iyongd
|
||||
172
ui/message_bottle_reader.tscn
Normal file
172
ui/message_bottle_reader.tscn
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
[gd_scene load_steps=8 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/message_bottle_reader.gd" id="1_reader"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
[ext_resource type="Texture2D" path="res://messages/assets/parchment.png" id="3_parchment"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_parchment"]
|
||||
content_margin_left = 0.0
|
||||
content_margin_top = 0.0
|
||||
content_margin_right = 0.0
|
||||
content_margin_bottom = 0.0
|
||||
bg_color = Color(0.88, 0.76, 0.49, 1)
|
||||
border_width_left = 3
|
||||
border_width_top = 3
|
||||
border_width_right = 3
|
||||
border_width_bottom = 3
|
||||
border_color = Color(0.29, 0.19, 0.09, 1)
|
||||
corner_radius_top_left = 18
|
||||
corner_radius_top_right = 18
|
||||
corner_radius_bottom_right = 18
|
||||
corner_radius_bottom_left = 18
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_button_normal"]
|
||||
content_margin_left = 22.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 22.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.29, 0.19, 0.09, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_button_hover"]
|
||||
content_margin_left = 22.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 22.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.42, 0.28, 0.12, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.98, 0.9, 0.66, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBox_button_pressed"]
|
||||
content_margin_left = 22.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 22.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.2, 0.13, 0.06, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="MessageBottleReader" type="Control"]
|
||||
visible = false
|
||||
process_mode = 3
|
||||
z_index = 250
|
||||
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_reader")
|
||||
parchment_texture = ExtResource("3_parchment")
|
||||
|
||||
[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
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ParchmentSurface" type="Control" parent="Center"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(680, 420)
|
||||
layout_mode = 2
|
||||
mouse_filter = 1
|
||||
|
||||
[node name="FallbackPanel" type="PanelContainer" parent="Center/ParchmentSurface"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_override_styles/panel = SubResource("StyleBox_parchment")
|
||||
|
||||
[node name="ParchmentArt" type="TextureRect" parent="Center/ParchmentSurface"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
texture_filter = 1
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="ContentMargin" type="MarginContainer" parent="Center/ParchmentSurface"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/margin_left = 70
|
||||
theme_override_constants/margin_top = 44
|
||||
theme_override_constants/margin_right = 70
|
||||
theme_override_constants/margin_bottom = 44
|
||||
|
||||
[node name="Layout" type="VBoxContainer" parent="Center/ParchmentSurface/ContentMargin"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="MessageScroll" type="ScrollContainer" parent="Center/ParchmentSurface/ContentMargin/Layout"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 0
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="MessageLabel" type="Label" parent="Center/ParchmentSurface/ContentMargin/Layout/MessageScroll"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 140)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.22, 0.13, 0.055, 1)
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "it's as simple as pushing squares"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="ButtonCenter" type="CenterContainer" parent="Center/ParchmentSurface/ContentMargin/Layout"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="CloseButton" type="Button" parent="Center/ParchmentSurface/ContentMargin/Layout/ButtonCenter"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 44)
|
||||
layout_mode = 2
|
||||
focus_mode = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.98, 0.9, 0.66, 1)
|
||||
theme_override_colors/font_hover_color = Color(1, 0.95, 0.8, 1)
|
||||
theme_override_colors/font_pressed_color = Color(1, 0.95, 0.8, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_styles/normal = SubResource("StyleBox_button_normal")
|
||||
theme_override_styles/hover = SubResource("StyleBox_button_hover")
|
||||
theme_override_styles/focus = SubResource("StyleBox_button_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBox_button_pressed")
|
||||
text = "close"
|
||||
|
|
@ -81,6 +81,12 @@ const GeneralInventoryGridType = preload(
|
|||
const GeneralInventorySlotType = preload(
|
||||
"res://ui/components/general_inventory_slot.gd"
|
||||
)
|
||||
const MessageBottleReaderType = preload(
|
||||
"res://ui/message_bottle_reader.gd"
|
||||
)
|
||||
const BeachBottleServiceType = preload(
|
||||
"res://messages/beach_bottle_service.gd"
|
||||
)
|
||||
const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
|
||||
const NAVIGATION_PRESENTATION_SCALE: float = 0.60
|
||||
const NAVIGATION_REFERENCE_SIZE := Vector2(840.0, 100.0)
|
||||
|
|
@ -92,6 +98,7 @@ const NAVIGATION_CANONICAL_POSITION := Vector2(
|
|||
const NAVIGATION_SELECTED_SCALE: float = 1.02
|
||||
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
|
||||
const PELICAN_BUYER_ID: StringName = &"pelicans"
|
||||
const MESSAGE_BOTTLE_ITEM_ID: StringName = &"message_bottle"
|
||||
|
||||
signal menu_visibility_changed(is_open: bool)
|
||||
signal inventory_hotbar_context_changed(show_hotbar: bool)
|
||||
|
|
@ -259,6 +266,9 @@ const LIGHT_COOLER_WATER_COLOR := Color(0.037, 0.27, 0.375, 1.0)
|
|||
@onready var _players_tab: BubbleButtonType = %PlayersTab
|
||||
@onready var _mail_unread_badge: Label = %MailUnreadBadge
|
||||
@onready var _close_button: BubbleButtonType = %CloseButton
|
||||
@onready var _message_bottle_reader: MessageBottleReaderType = (
|
||||
%MessageBottleReader
|
||||
)
|
||||
@onready var _cooler_scroll: ScrollContainer = %CoolerScroll
|
||||
@onready var _cooler_host: Control = %CoolerHost
|
||||
@onready var _sale_confirmation: PanelContainer = %SaleConfirmation
|
||||
|
|
@ -293,6 +303,7 @@ var _fishing_spot: FishingSpotType
|
|||
var _bag: PlayerBagType
|
||||
var _hotbar: PlayerHotbarType
|
||||
var _item_catalog: ItemCatalogType
|
||||
var _message_bottle_service: BeachBottleServiceType
|
||||
var _inventory_layout: PlayerInventoryLayout
|
||||
var _cooler_capacity: PlayerCoolerCapacityType
|
||||
var _current_section: Section = Section.BAG
|
||||
|
|
@ -426,6 +437,7 @@ func _ready() -> void:
|
|||
_sell_bubble.pressed.connect(_on_sell_pressed)
|
||||
_confirm_sale_button.pressed.connect(_on_confirm_sale_pressed)
|
||||
_cancel_sale_button.pressed.connect(_close_sale_confirmation)
|
||||
_message_bottle_reader.closed.connect(_on_message_bottle_reader_closed)
|
||||
_configure_sale_confirmation_focus()
|
||||
_cooler_sort_option.add_item("catch order", SortMode.CATCH_ORDER)
|
||||
_cooler_sort_option.add_item("name", SortMode.NAME)
|
||||
|
|
@ -664,6 +676,10 @@ func setup_controller_mapping(
|
|||
_the_net_page.setup_controller_mapping(_controller_mapping_manager)
|
||||
|
||||
|
||||
func setup_message_bottles(service: BeachBottleServiceType) -> void:
|
||||
_message_bottle_service = service
|
||||
|
||||
|
||||
func set_profile_preview_world_pixel_size(pixel_size: int) -> void:
|
||||
_profile_page.set_world_pixel_size(pixel_size)
|
||||
|
||||
|
|
@ -677,6 +693,23 @@ func allows_global_controller_scroll() -> bool:
|
|||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.echo:
|
||||
return
|
||||
if visible and _message_bottle_reader.is_open():
|
||||
var bottle_cancel: bool = event.is_action_pressed(&"ui_cancel")
|
||||
var bottle_button := event as InputEventJoypadButton
|
||||
if (
|
||||
bottle_button != null
|
||||
and bottle_button.pressed
|
||||
and _event_matches_controller_role(
|
||||
event,
|
||||
ControllerMappingManagerType.ROLE_B,
|
||||
JOY_BUTTON_B,
|
||||
)
|
||||
):
|
||||
bottle_cancel = true
|
||||
if bottle_cancel:
|
||||
_message_bottle_reader.dismiss()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if _handle_controller_ownership_input(event):
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
|
@ -1520,6 +1553,8 @@ func consume_escape() -> bool:
|
|||
func _consume_player_menu_back() -> bool:
|
||||
if _transitioning or _page_transitioning:
|
||||
return true
|
||||
if _message_bottle_reader.consume_escape():
|
||||
return true
|
||||
if _sale_confirmation.visible:
|
||||
_close_sale_confirmation()
|
||||
return true
|
||||
|
|
@ -3377,6 +3412,7 @@ func _finish_close(
|
|||
_page_transitioning = false
|
||||
_bag_drag_active = false
|
||||
_cancel_active_item_move()
|
||||
_message_bottle_reader.dismiss()
|
||||
_set_inventory_notepad_visible(Section.BAG, false)
|
||||
_set_inventory_notepad_visible(Section.TACKLE_BOX, false)
|
||||
_hide_inventory_context_tooltip()
|
||||
|
|
@ -3871,6 +3907,13 @@ func _on_general_inventory_slot_activated(
|
|||
or _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS
|
||||
):
|
||||
return
|
||||
if (
|
||||
_inventory_move_identity.is_empty()
|
||||
and slot.entry_kind == PlayerInventoryLayout.EntryKind.ITEM
|
||||
and slot.entry_identity == MESSAGE_BOTTLE_ITEM_ID
|
||||
):
|
||||
_open_message_bottle(slot)
|
||||
return
|
||||
if _inventory_move_identity.is_empty():
|
||||
if slot.entry_identity.is_empty():
|
||||
return
|
||||
|
|
@ -3914,6 +3957,29 @@ func _on_general_inventory_slot_activated(
|
|||
)
|
||||
|
||||
|
||||
func _open_message_bottle(slot: GeneralInventorySlotType) -> void:
|
||||
_cancel_active_item_move()
|
||||
_hide_inventory_context_tooltip()
|
||||
if _message_bottle_service == null:
|
||||
_show_inventory_context_tooltip(slot, "The message cannot be opened yet.")
|
||||
return
|
||||
var result: Dictionary = _message_bottle_service.read_held_bottle()
|
||||
if not bool(result.get("ok", false)):
|
||||
_show_inventory_context_tooltip(
|
||||
slot,
|
||||
str(result.get("error", "The message could not be opened.")),
|
||||
)
|
||||
return
|
||||
_set_shell_interactive(false)
|
||||
_message_bottle_reader.present(str(result.get("text", "")))
|
||||
|
||||
|
||||
func _on_message_bottle_reader_closed() -> void:
|
||||
if not visible or _transitioning or _page_transitioning:
|
||||
return
|
||||
_set_shell_interactive(true)
|
||||
|
||||
|
||||
func _open_general_inventory_notepad(
|
||||
slot: GeneralInventorySlotType,
|
||||
) -> void:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=30 format=3]
|
||||
[gd_scene load_steps=31 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/online_simple.png" id="30_online_simple"]
|
||||
[ext_resource type="Texture2D" path="res://ui/icons/tab_menu/close.png" id="31_close"]
|
||||
[ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="32_currency"]
|
||||
[ext_resource type="PackedScene" path="res://ui/message_bottle_reader.tscn" id="33_bottle_reader"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_cooler_water"]
|
||||
shader = ExtResource("14_water")
|
||||
|
|
@ -1105,3 +1106,6 @@ motion_period = 5.3
|
|||
motion_phase = 4.2
|
||||
deformation_amplitude = 0.01
|
||||
deformation_period = 6.2
|
||||
|
||||
[node name="MessageBottleReader" parent="." instance=ExtResource("33_bottle_reader")]
|
||||
unique_name_in_owner = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue