619 lines
18 KiB
GDScript
619 lines
18 KiB
GDScript
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()
|