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

@ -388,6 +388,17 @@ class ShowcaseCameraSnapshot:
var camera_dragging: bool
class ConversationCameraSnapshot:
extends RefCounted
var yaw_transform: Transform3D
var yaw_rotation: Vector3
var pitch_rotation: Vector3
var spring_length: float
var target_zoom: float
var camera_dragging: bool
@export_category("Movement")
@export var walk_speed: float = 2.25
@export var sprint_speed: float = 4.5
@ -446,6 +457,11 @@ var _rv_previous_camera_environment: Environment
@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55
@export_range(1.0, 40.0, 0.5) var free_camera_speed: float = 10.0
@export_range(1.0, 10.0, 0.5) var free_camera_sprint_multiplier: float = 3.0
@export_category("Conversation Camera")
@export_range(0.05, 1.0, 0.01) var conversation_camera_transition_seconds: float = 0.42
@export_range(-45.0, 20.0, 1.0) var conversation_camera_pitch_degrees: float = -9.0
@export_range(1.5, 8.0, 0.1, "suffix:m") var conversation_camera_distance: float = 3.65
@export_range(0.5, 2.5, 0.05, "suffix:m") var conversation_camera_target_height: float = 1.3
@onready var _visuals: Node3D = %Visuals
@onready var _character_rig: Node3D = get_node_or_null(
@ -521,6 +537,9 @@ var _showcase_restore_tween: Tween
var _showcase_camera_tween: Tween
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
var _showcase_restore_generation: int = 0
var _conversation_camera_tween: Tween
var _conversation_camera_snapshot: ConversationCameraSnapshot
var _conversation_camera_generation: int = 0
var _network_peer_id: int = 0
var _network_authoritative_simulation: bool = false
var _network_interpolation_enabled: bool = false
@ -1070,7 +1089,18 @@ func _simulate_movement_physics(delta: float) -> void:
if jump_requested:
_set_sitting(false, local_control_enabled)
else:
velocity = Vector3.ZERO
# Keep the body participating in physics while seated. In particular,
# move_and_slide() refreshes Godot's floor state, so a stale grounded
# flag from prediction or a delayed packet cannot trap this early-return
# path forever after the capsule has moved into the air.
velocity.x = 0.0
velocity.z = 0.0
if not is_on_floor():
velocity.y -= _gravity * fall_gravity_multiplier * delta
move_and_slide()
if not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
_network_jump_pending = false
return
if _water_recovery_active:
@ -1726,6 +1756,10 @@ func toggle_sitting() -> void:
return
if not _can_begin_sitting():
_sit_after_landing = true
# Queue and repeat the intent until an owner snapshot acknowledges it.
# Previously this branch returned before marking a local sitting intent,
# so a delayed authoritative audit could race the deferred sit.
_set_sitting(false, local_control_enabled)
return
_set_sitting(should_sit, local_control_enabled)
@ -1750,7 +1784,11 @@ func _set_sitting(
return
_sitting = should_sit
if _sitting:
velocity = Vector3.ZERO
# Sitting stops horizontal locomotion, but vertical motion remains owned by
# physics. Clearing Y here allowed a delayed seated state to repeatedly
# cancel gravity in a high-latency session.
velocity.x = 0.0
velocity.z = 0.0
_character_animation_name = &""
_update_character_animation()
@ -1803,6 +1841,24 @@ func apply_authoritative_network_sitting_state(should_sit: bool) -> void:
_set_sitting(should_sit)
func _apply_local_prediction_sitting_state(should_sit: bool) -> void:
# Owner audits can arrive several physics frames behind the locally predicted
# jump. Treat an authoritative seated bit as an intent until this capsule is
# actually grounded; applying it directly zeros vertical velocity and lets a
# stream of high-latency snapshots hold the local player in mid-air.
if _local_network_jump_intent_pending:
_sit_after_landing = false
_set_sitting(false)
return
if should_sit and not _can_begin_sitting():
_sit_after_landing = true
_set_sitting(false)
return
if should_sit:
_sit_after_landing = false
_set_sitting(should_sit)
func _input(event: InputEvent) -> void:
# Camera dragging must observe the complete mouse gesture before GUI controls
# get a chance to consume one part of it. Do not claim the event here: the
@ -2048,6 +2104,162 @@ func get_active_gameplay_camera() -> Camera3D:
return _camera
func begin_shop_conversation_camera(npc_world_position: Vector3) -> bool:
if (
not local_control_enabled
or _camera_yaw == null
or _camera_pitch == null
or _spring_arm == null
):
return false
if _free_camera_active:
_set_free_camera_active(false)
_kill_conversation_camera_tween()
if _conversation_camera_snapshot == null:
_conversation_camera_snapshot = ConversationCameraSnapshot.new()
_conversation_camera_snapshot.yaw_transform = _camera_yaw.transform
_conversation_camera_snapshot.yaw_rotation = _camera_yaw.rotation
_conversation_camera_snapshot.pitch_rotation = _camera_pitch.rotation
_conversation_camera_snapshot.spring_length = _spring_arm.spring_length
_conversation_camera_snapshot.target_zoom = _target_zoom
_conversation_camera_snapshot.camera_dragging = _camera_dragging
_set_camera_dragging(false)
var pair_direction := npc_world_position - global_position
pair_direction.y = 0.0
if pair_direction.length_squared() <= 0.000001:
pair_direction = Vector3.RIGHT
else:
pair_direction = pair_direction.normalized()
# Frame the midpoint, with the camera's right axis running from the player
# toward the NPC. This naturally places the player left and shopkeeper right.
var target_world_yaw := atan2(-pair_direction.z, pair_direction.x)
var target_local_yaw := target_world_yaw - global_rotation.y
var shortest_target_yaw := (
_camera_yaw.rotation.y
+ wrapf(target_local_yaw - _camera_yaw.rotation.y, -PI, PI)
)
var pair_midpoint := (global_position + npc_world_position) * 0.5
pair_midpoint.y = global_position.y + conversation_camera_target_height
var target_position := to_local(pair_midpoint)
var duration := maxf(conversation_camera_transition_seconds, 0.05)
_conversation_camera_tween = create_tween()
_conversation_camera_tween.set_parallel(true)
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
_conversation_camera_tween.tween_property(
_camera_yaw, "rotation:y", shortest_target_yaw, duration
)
_conversation_camera_tween.tween_property(
_camera_yaw, "position", target_position, duration
)
_conversation_camera_tween.tween_property(
_camera_pitch,
"rotation:x",
deg_to_rad(conversation_camera_pitch_degrees),
duration,
)
_conversation_camera_tween.tween_property(
_spring_arm,
"spring_length",
maxf(conversation_camera_distance, 0.1),
duration,
)
return true
func restore_shop_conversation_camera(
immediate: bool = false,
restored_callback: Callable = Callable(),
) -> void:
_kill_conversation_camera_tween()
if _conversation_camera_snapshot == null:
if restored_callback.is_valid():
restored_callback.call()
return
if immediate:
_complete_conversation_camera_restore(restored_callback)
return
var yaw_target := _conversation_camera_snapshot.yaw_rotation
yaw_target.y = (
_camera_yaw.rotation.y
+ wrapf(yaw_target.y - _camera_yaw.rotation.y, -PI, PI)
)
var generation := _conversation_camera_generation
var duration := maxf(conversation_camera_transition_seconds, 0.05)
_conversation_camera_tween = create_tween()
_conversation_camera_tween.set_parallel(true)
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
_conversation_camera_tween.tween_property(
_camera_yaw, "rotation", yaw_target, duration
)
_conversation_camera_tween.tween_property(
_camera_yaw,
"position",
_conversation_camera_snapshot.yaw_transform.origin,
duration,
)
_conversation_camera_tween.tween_property(
_camera_pitch,
"rotation",
_conversation_camera_snapshot.pitch_rotation,
duration,
)
_conversation_camera_tween.tween_property(
_spring_arm,
"spring_length",
_conversation_camera_snapshot.spring_length,
duration,
)
_conversation_camera_tween.finished.connect(
_on_conversation_camera_restore_finished.bind(
generation, restored_callback
)
)
func is_shop_conversation_camera_active() -> bool:
return _conversation_camera_snapshot != null
func _kill_conversation_camera_tween() -> void:
if (
_conversation_camera_tween != null
and _conversation_camera_tween.is_valid()
):
_conversation_camera_tween.kill()
_conversation_camera_tween = null
_conversation_camera_generation += 1
func _on_conversation_camera_restore_finished(
generation: int,
restored_callback: Callable,
) -> void:
if generation != _conversation_camera_generation:
return
_conversation_camera_tween = null
_complete_conversation_camera_restore(restored_callback)
func _complete_conversation_camera_restore(
restored_callback: Callable,
) -> void:
if _conversation_camera_snapshot != null:
_camera_yaw.transform = _conversation_camera_snapshot.yaw_transform
_camera_pitch.rotation = _conversation_camera_snapshot.pitch_rotation
_spring_arm.spring_length = _conversation_camera_snapshot.spring_length
_target_zoom = _conversation_camera_snapshot.target_zoom
_set_camera_dragging(
_conversation_camera_snapshot.camera_dragging
and Input.is_action_pressed("camera_drag")
)
_conversation_camera_snapshot = null
if restored_callback.is_valid():
restored_callback.call()
func _set_target_zoom(value: float) -> void:
_target_zoom = clampf(
value,
@ -2526,7 +2738,7 @@ func apply_local_prediction_correction(
):
_clear_local_network_jump_intent()
if not _sitting_intent_pending:
_set_sitting(bool(parsed["sitting"]))
_apply_local_prediction_sitting_state(bool(parsed["sitting"]))
var authoritative_position: Vector3 = parsed["position"]
var transit_seconds: float = resolve_local_prediction_transit_seconds(
acknowledged_input,
@ -3322,9 +3534,23 @@ func get_body_center_position() -> Vector3:
func get_chat_anchor_position() -> Vector3:
return get_nameplate_anchor_position()
func get_nameplate_anchor_position() -> Vector3:
# Character head meshes keep stable authored bounds while skeletal animation
# plays. Restricting the anchor to them prevents rods, nets, held catches,
# and showcase props from making overhead UI bounce during an action.
var highest_y := -INF
for node: Node in _visuals.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := node as MeshInstance3D
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
) as Skeleton3D
for head_name: String in ["head_round", "head_pointy"]:
var mesh_instance := (
skeleton.get_node_or_null(head_name) as MeshInstance3D
if skeleton != null
else null
)
if mesh_instance == null or not mesh_instance.is_visible_in_tree():
continue
var bounds := mesh_instance.get_aabb()