Integrate per-save identity and interaction UI
This commit is contained in:
parent
ac82a28f3b
commit
4fda6e97f6
58 changed files with 3471 additions and 461 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue