straywild/messages/beach_bottle_service.gd

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