Integrate per-save identity and interaction UI

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:50 -04:00
parent ac82a28f3b
commit 4fda6e97f6
58 changed files with 3471 additions and 461 deletions

View file

@ -49,12 +49,16 @@ func _run() -> void:
var player := main.get("_player") as Player
var interaction := main.get("_shop_interaction") as FishingShopInteraction
var decor_interaction := main.get(
"_decor_shop_interaction"
) as DecorShopInteraction
var game_ui := main.get("_game_ui") as GameUI
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
_expect(player != null, "local player is unavailable")
_expect(interaction != null, "shop interaction is unavailable")
_expect(decor_interaction != null, "decor interaction is unavailable")
_expect(game_ui != null, "game UI is unavailable")
_expect(chat_service != null, "network chat service is unavailable")
if (
@ -70,6 +74,136 @@ func _run() -> void:
interaction.is_local_player_in_range(),
"local player did not enter the shop interaction range",
)
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var shop_message := game_ui.get_node("%ShopPromptMessage") as Label
_expect(
not bool(game_ui.get("_shop_npc_dialogue_active"))
and not shop_message.visible,
"shopkeeper spoke before the player initiated conversation",
)
_expect(
shop_prompt.size == Vector2(30.0, 30.0),
"nearby shop prompt was not reduced to the compact interaction glyph",
)
var fixed_area_transform := interaction.global_transform
var shopkeeper := interaction.get_node("../Shopkeeper") as WorldCharacterDisplay
var shopkeeper_start := shopkeeper.position
var camera_yaw := player.get_node("CameraYaw") as Node3D
var camera_pitch := player.get_node("CameraYaw/CameraPitch") as Node3D
var spring_arm := player.get_node(
"CameraYaw/CameraPitch/SpringArm3D"
) as SpringArm3D
var initial_camera_yaw_transform := camera_yaw.transform
var initial_camera_pitch := camera_pitch.rotation
var initial_spring_length := spring_arm.spring_length
# Route planning treats the player as a live obstacle. Move the validation
# avatar clear before forcing an ambient patrol leg.
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
interaction.call("_refresh_local_player_proximity")
interaction.call("_choose_roaming_destination")
var roaming_destination: Vector3 = interaction.get(
"_roaming_destination"
)
var safe_route: Dictionary = interaction.call(
"_resolve_safe_route_destination",
roaming_destination,
)
_expect(
not safe_route.is_empty(),
"shopkeeper accepted a destination that failed its terrain route probe",
)
var initial_route_offset := roaming_destination - shopkeeper.position
shopkeeper.rotation.y = (
atan2(initial_route_offset.x, initial_route_offset.z) + PI
)
interaction.call("_process", 0.05)
_expect(
shopkeeper.position.is_equal_approx(shopkeeper_start)
and not shopkeeper.is_locomotion_walking(),
"shopkeeper translated before finishing its turn-in-place",
)
# A patrol leg deliberately starts by turning in place. Advance in small
# slices until the body is aligned and translation begins.
for _turn_frame: int in 20:
interaction.call("_process", 0.05)
if shopkeeper.position.distance_to(shopkeeper_start) > 0.001:
break
_expect(
interaction.global_transform.is_equal_approx(fixed_area_transform),
"ambient shopkeeper roaming moved the authoritative interaction area",
)
_expect(
shopkeeper.position.distance_to(shopkeeper_start) > 0.001
and shopkeeper.is_locomotion_walking(),
"shopkeeper did not walk toward an ambient roaming destination",
)
var roaming_direction := (
shopkeeper.position - shopkeeper_start
).normalized()
var roaming_origin: Vector3 = interaction.get("_roaming_origin")
_expect(
roaming_destination.distance_to(shopkeeper_start)
>= interaction.minimum_route_distance
and roaming_destination.distance_to(shopkeeper_start)
<= interaction.maximum_route_distance + 0.001
and roaming_destination.distance_to(roaming_origin)
<= interaction.roaming_radius + 0.001
and shopkeeper.basis.z.normalized().dot(roaming_direction) > 0.95,
(
"shopkeeper patrol leg was unsafe, too short, or did not face "
+ "its travel direction"
),
)
# Ambient visuals may roam well beyond their original compact trigger, but
# the trigger itself must remain fixed for multiplayer authority. Local UI
# eligibility follows the moving character, while the host validates a
# conservative authored-origin envelope that covers the full patrol.
var captured_roaming_origin: Vector3 = interaction.get(
"_roaming_origin"
)
interaction.roaming_enabled = false
shopkeeper.position = captured_roaming_origin + Vector3(2.6, 0.0, 0.0)
interaction.setup_local_player(player)
_expect(
(interaction.get("_roaming_origin") as Vector3).is_equal_approx(
captured_roaming_origin
),
"rebinding the player recaptured a wandered position as the patrol origin",
)
player.global_position = shopkeeper.global_position + Vector3(1.4, 0.0, 0.0)
interaction.call("_refresh_local_player_proximity")
_expect(
interaction.is_local_player_in_range()
and interaction.is_avatar_in_range(player),
"moving-shopkeeper reach was not covered locally and authoritatively",
)
player.global_position = interaction.call("_get_roaming_origin_global")
interaction.call("_refresh_local_player_proximity")
_expect(
not interaction.is_local_player_in_range()
and interaction.is_avatar_in_range(player),
"local proximity still followed the fixed authored Area3D",
)
var authority_radius := interaction.get_authority_interaction_radius()
player.global_position = (
interaction.call("_get_roaming_origin_global")
+ Vector3(authority_radius + 0.1, 0.0, 0.0)
)
_expect(
not interaction.is_avatar_in_range(player),
"shop authority accepted a player beyond the fixed patrol envelope",
)
shopkeeper.position = shopkeeper_start
interaction.roaming_enabled = true
player.global_position = (
shopkeeper.global_position + Vector3(0.0, 0.0, -1.0)
)
interaction.call("_refresh_local_player_proximity")
_expect(
interaction.global_transform.is_equal_approx(fixed_area_transform),
"proximity validation moved the authoritative interaction area",
)
var character_calls: Array[String] = []
var capture_call := func(
_peer_id: int,
@ -94,12 +228,57 @@ func _run() -> void:
main.call("_unhandled_input", press)
await process_frame
_expect(
game_ui.get_fishing_shop().visible,
"Y did not open the shop while the player was in range",
not game_ui.get_fishing_shop().visible
and main.get("_engaged_shop_npc_interaction") == interaction
and interaction.is_engaged(),
"first Y press did not enter shopkeeper dialogue without opening",
)
_expect(
bool(game_ui.get("_shop_npc_dialogue_active"))
and shop_message.visible
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
"engaged shopkeeper dialogue was not presented",
)
_expect(
not bool(player.call("_is_movement_input_enabled"))
and not bool(player.call("_is_camera_input_enabled"))
and player.is_shop_conversation_camera_active(),
"shopkeeper dialogue did not lock movement and hold its camera frame",
)
_expect(
character_calls.is_empty(),
"Y played a character call instead of opening the shop",
"Y played a character call instead of starting shopkeeper dialogue",
)
await create_timer(0.5).timeout
var player_to_shopkeeper := (
shopkeeper.global_position - player.global_position
)
player_to_shopkeeper.y = 0.0
player_to_shopkeeper = player_to_shopkeeper.normalized()
_expect(
camera_yaw.global_basis.x.normalized().dot(player_to_shopkeeper)
> 0.9
and spring_arm.spring_length < initial_spring_length,
"conversation camera did not frame player-left and NPC-right",
)
_expect(
shopkeeper.global_basis.z.normalized().dot(
(player.global_position - shopkeeper.global_position).normalized()
) > 0.9
and not shopkeeper.is_locomotion_walking(),
"engaged shopkeeper did not stop and face the player",
)
main.call("_input", press)
await process_frame
_expect(
game_ui.get_fishing_shop().visible
and bool(main.get("_shop_conversation_shop_opened")),
"second Y press did not dismiss dialogue and open the shop",
)
_expect(
main.get("_engaged_shop_npc_interaction") == interaction
and not bool(player.call("_is_camera_input_enabled")),
"shop opening released the conversation frame before close",
)
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
for _frame: int in 6:
@ -108,6 +287,12 @@ func _run() -> void:
if not game_ui.get_fishing_shop().visible:
break
await process_frame
for _frame: int in 40:
if not player.is_shop_conversation_camera_active():
break
await process_frame
if player.is_shop_conversation_camera_active():
await create_timer(0.55).timeout
_expect(
not interaction.is_local_player_in_range(),
"local player did not leave the shop interaction range",
@ -116,12 +301,219 @@ func _run() -> void:
not game_ui.get_fishing_shop().visible,
"shop did not close after leaving its interaction range",
)
_expect(
main.get("_engaged_shop_npc_interaction") == null
and bool(player.call("_is_movement_input_enabled"))
and bool(player.call("_is_camera_input_enabled")),
(
"shop close did not restore conversation controls and camera "
+ "(engaged=%s movement=%s camera=%s restoring=%s)"
) % [
main.get("_engaged_shop_npc_interaction"),
player.call("_is_movement_input_enabled"),
player.call("_is_camera_input_enabled"),
main.get("_shop_conversation_camera_restoring"),
],
)
_expect(
camera_yaw.transform.is_equal_approx(
initial_camera_yaw_transform
)
and camera_pitch.rotation.is_equal_approx(initial_camera_pitch)
and is_equal_approx(spring_arm.spring_length, initial_spring_length),
"conversation camera did not restore its exact pre-dialogue frame",
)
game_ui.call("_input", press)
await process_frame
_expect(
character_calls.size() == 1,
"Y did not play a character call away from an interaction",
)
# Deliberately overlap two shop margins to validate the transition case.
# Whichever character the player is actually closest to must own both the
# dialogue/prompt and the interaction button, regardless of shop type.
if decor_interaction != null:
var fishing_root := interaction.get_parent() as Node3D
var decor_root := decor_interaction.get_parent() as Node3D
var original_decor_transform := decor_root.global_transform
interaction.roaming_enabled = false
decor_interaction.roaming_enabled = false
(interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
(decor_interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
decor_root.global_position = (
fishing_root.global_position + Vector3(1.1, 0.0, 0.0)
)
player.global_position = decor_root.global_position
for _frame: int in 6:
await physics_frame
_expect(
interaction.is_local_player_in_range()
and decor_interaction.is_local_player_in_range(),
"overlapping shop transition margins were not established",
)
_expect(
main.call("_get_active_shop_npc_interaction")
== decor_interaction,
"nearest decor NPC did not own the overlapping interaction",
)
_expect(
int(game_ui.get("_shop_npc_source_id"))
== decor_interaction.get_instance_id()
and shop_message.text
== DecorShopInteraction.DECOR_DIALOGUE_TEXT,
"decor dialogue did not follow the active NPC handoff",
)
player.global_position = (
fishing_root.global_position + Vector3(0.57, 0.0, 0.0)
)
for _frame: int in 3:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== decor_interaction,
"shop handoff hysteresis did not stabilize a near-tie",
)
player.global_position = (
fishing_root.global_position + Vector3(0.45, 0.0, 0.0)
)
for _frame: int in 3:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== interaction,
"shop ownership did not switch after a clear distance lead",
)
_expect(
int(game_ui.get("_shop_npc_source_id"))
== interaction.get_instance_id()
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
"fishing dialogue did not replace the prior NPC cleanly",
)
player.global_position = decor_root.global_position
for _frame: int in 3:
await physics_frame
game_ui.call("_input", press)
main.call("_unhandled_input", press)
await process_frame
_expect(
not game_ui.get_decor_shop().visible
and main.get("_engaged_shop_npc_interaction")
== decor_interaction,
"first Y did not engage the nearest decor NPC in an overlap",
)
main.call("_input", press)
await process_frame
_expect(
game_ui.get_decor_shop().visible
and main.get("_engaged_shop_npc_interaction")
== decor_interaction,
"second Y did not open the engaged decor shop",
)
_expect(
character_calls.size() == 1,
"Y emitted a character call instead of opening the decor shop",
)
game_ui.get_decor_shop().close_shop()
for _frame: int in 40:
if not player.is_shop_conversation_camera_active():
break
await process_frame
if player.is_shop_conversation_camera_active():
await create_timer(0.55).timeout
player.global_position = fishing_root.global_position
for _frame: int in 6:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== interaction,
"shop ownership did not hand back to the nearer fishing NPC",
)
decor_root.global_transform = original_decor_transform
# Idle visuals can still begin a frame overlapped (for example after two
# independently planned routes converge). They must separate without
# moving either authoritative Area3D, deadlocking on their reservations,
# or waiting for one NPC to already be in the walking state.
var fishing_visual := interaction.get_node(
"../Shopkeeper"
) as WorldCharacterDisplay
var decor_visual := decor_interaction.get_node(
"../Shopkeeper"
) as WorldCharacterDisplay
var fishing_visual_transform := fishing_visual.global_transform
var decor_visual_transform := decor_visual.global_transform
var fishing_area_transform := interaction.global_transform
var decor_area_transform := decor_interaction.global_transform
player.global_position = fishing_visual.global_position + Vector3(20.0, 0.0, 0.0)
interaction.roaming_enabled = true
decor_interaction.roaming_enabled = true
interaction.call("_schedule_idle")
decor_interaction.call("_schedule_idle")
var crossing_center := fishing_visual.global_position
decor_visual.global_position = (
crossing_center + Vector3(1.0, 0.0, -1.0)
)
decor_interaction.set(
"_roaming_destination",
decor_visual.get_parent_node_3d().to_local(
crossing_center + Vector3(1.0, 0.0, 1.0)
),
)
decor_interaction.set("_roaming_walking", true)
_expect(
bool(interaction.call(
"_route_conflicts_with_reserved_corridors",
crossing_center,
crossing_center + Vector3(2.0, 0.0, 0.0),
)),
"crossing shopkeeper route corridors were not reserved",
)
decor_interaction.call("_schedule_idle")
var fishing_priority: String = interaction.call(
"_traffic_priority_key"
)
var decor_priority: String = decor_interaction.call(
"_traffic_priority_key"
)
var blocked_outward_direction := (
Vector3.RIGHT
if fishing_priority < decor_priority
else Vector3.LEFT
)
fishing_visual.global_position = (
interaction.call("_get_roaming_origin_global")
+ blocked_outward_direction * interaction.roaming_radius
)
decor_visual.global_position = fishing_visual.global_position
var overlapped_position := fishing_visual.global_position
for _separation_frame: int in 80:
interaction.call("_process", 0.05)
decor_interaction.call("_process", 0.05)
var separation := fishing_visual.global_position - decor_visual.global_position
separation.y = 0.0
if separation.length() >= interaction.other_shopkeeper_clearance:
break
var resolved_separation := (
fishing_visual.global_position - decor_visual.global_position
)
resolved_separation.y = 0.0
_expect(
resolved_separation.length()
>= interaction.other_shopkeeper_clearance - 0.02
and fishing_visual.global_position.distance_to(overlapped_position)
> 0.1,
"idle-overlapped shopkeepers did not separate and resume safely",
)
_expect(
interaction.global_transform.is_equal_approx(fishing_area_transform)
and decor_interaction.global_transform.is_equal_approx(
decor_area_transform
),
"live shopkeeper separation moved an authoritative Area3D",
)
fishing_visual.global_transform = fishing_visual_transform
decor_visual.global_transform = decor_visual_transform
chat_service.character_call_received.disconnect(capture_call)
var session := main.get_node("%NetworkSession") as NetworkSession