Add rare beach message bottles

This commit is contained in:
Alexander Sellite 2026-09-02 12:39:23 -04:00
parent 687568eb38
commit 2b5b0a63ec
38 changed files with 2422 additions and 18 deletions

View file

@ -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")]

View 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

View file

@ -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

View file

@ -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")

View 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)

View file

@ -0,0 +1 @@
uid://dgf6xvsiarx7o

View 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

View 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()

View file

@ -0,0 +1 @@
uid://chta0r77d4i2k

View 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)]

View file

@ -0,0 +1 @@
uid://c2vu7o6y4th61

View 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
)

View file

@ -0,0 +1 @@
uid://c044pmgt5w1un

View 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")]

View 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"

View 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"

View 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,
)

View file

@ -0,0 +1 @@
uid://bsvhqsqeu11pu

View file

@ -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,

View file

@ -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"

View 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()

View file

@ -0,0 +1 @@
uid://dadlfrooej5k0

View 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()

View file

@ -0,0 +1 @@
uid://cjkesykq1lrfg

View file

@ -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

View file

@ -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())

View file

@ -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(

View file

@ -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", {})

View 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

View file

@ -0,0 +1 @@
uid://otelx0xjr05u

View file

@ -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", {})

View file

@ -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))

View file

@ -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
View 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()

View file

@ -0,0 +1 @@
uid://fege70iyongd

View 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"

View file

@ -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:

View file

@ -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