Add character calls, settings controls, and gameplay fixes

This commit is contained in:
Alexander Sellite 2026-08-09 11:22:35 -04:00
parent 7b2898c11a
commit 40696b6bd9
33 changed files with 1120 additions and 78 deletions

View file

@ -4,17 +4,37 @@ extends Node
const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
0.985,
1.055,
1.0,
0.975,
1.04,
]
const VoiceProfilesType = preload(
"res://player/animalese_voice_profiles.gd"
)
signal message_received(message: Dictionary)
signal local_message_confirmed(message: Dictionary)
signal send_rejected(message: String)
signal history_replaced(messages: Array[Dictionary])
signal character_call_received(
peer_id: int,
call_id: String,
pitch_scale: float,
)
var _session: NetworkSession
var _history: Array[Dictionary] = []
var _seen_messages: Dictionary[String, bool] = {}
var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {}
var _last_call_msec: Dictionary[int, int] = {}
var _call_variant_indices: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
@ -79,6 +99,134 @@ func send_local_message(body: String) -> bool:
return true
func send_local_character_call(call_id: String) -> bool:
if (
_session == null
or not _session.is_gameplay_session_active()
or not VoiceProfilesType.is_valid_call(call_id)
or (
not _session.is_host()
and not _session.supports_server_capability(
NetworkChatProtocol.CAPABILITY
)
)
):
return false
var request := {
"request_id": _new_id("character_call"),
"session_id": _session.get_session_id(),
"call_id": call_id,
"sender_fingerprint": _session.get_local_identity_fingerprint(),
}
request["sender_signature"] = _session.sign_local_action(
"character_call", _character_call_signature_fields(request)
)
if _session.is_host():
_handle_character_call(_session.get_local_peer_id(), request)
else:
submit_character_call.rpc_id(1, request)
return true
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_character_call(data: Dictionary) -> void:
var sender_id := multiplayer.get_remote_sender_id()
if _session.is_host() and _session.is_authenticated_peer(sender_id):
_handle_character_call(sender_id, data)
func _handle_character_call(peer_id: int, data: Dictionary) -> void:
if (
typeof(data.get("request_id")) != TYPE_STRING
or str(data["request_id"]).is_empty()
or str(data["request_id"]).length() > 64
or typeof(data.get("session_id")) != TYPE_STRING
or str(data["session_id"]) != _session.get_session_id()
or typeof(data.get("call_id")) != TYPE_STRING
or not VoiceProfilesType.is_valid_call(str(data["call_id"]))
or typeof(data.get("sender_fingerprint")) != TYPE_STRING
or typeof(data.get("sender_signature")) != TYPE_PACKED_BYTE_ARRAY
):
return
var record := _session.get_peer_record(peer_id)
if (
record == null
or record.identity_fingerprint != str(data["sender_fingerprint"])
or not _session.verify_peer_action(
peer_id,
"character_call",
_character_call_signature_fields(data),
data["sender_signature"],
)
or not _consume_character_call_rate(peer_id)
):
return
var pitch_scale: float = _next_character_call_pitch(peer_id)
_apply_character_call(peer_id, str(data["call_id"]), pitch_scale)
receive_character_call.rpc(
peer_id,
str(data["call_id"]),
pitch_scale,
)
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func receive_character_call(
peer_id: int,
call_id: String,
pitch_scale: float,
) -> void:
_apply_character_call(peer_id, call_id, pitch_scale)
func _apply_character_call(
peer_id: int,
call_id: String,
pitch_scale: float,
) -> void:
if (
not VoiceProfilesType.is_valid_call(call_id)
or pitch_scale < 0.9
or pitch_scale > 1.1
or _session == null
or not _session.is_gameplay_session_active()
):
return
var record := _session.get_peer_record(peer_id)
if record == null or is_sender_filtered(record.identity_fingerprint):
return
character_call_received.emit(peer_id, call_id, pitch_scale)
func _next_character_call_pitch(peer_id: int) -> float:
var variant_index: int = _call_variant_indices.get(peer_id, 0)
var pitch_scale: float = CALL_PITCH_VARIANTS[
variant_index % CALL_PITCH_VARIANTS.size()
]
_call_variant_indices[peer_id] = variant_index + 1
return pitch_scale
func _consume_character_call_rate(peer_id: int) -> bool:
var now_msec: int = Time.get_ticks_msec()
var last_msec: int = _last_call_msec.get(
peer_id, now_msec - CALL_COOLDOWN_MILLISECONDS
)
if now_msec - last_msec < CALL_COOLDOWN_MILLISECONDS:
return false
_last_call_msec[peer_id] = now_msec
return true
func _character_call_signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
str(data.get("call_id", "")),
]
func broadcast_system_message(body: String) -> bool:
if _session == null or not _session.is_host():
return false
@ -272,6 +420,8 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
func _on_peer_removed(peer_id: int) -> void:
_request_ledgers.erase(peer_id)
_rate_times.erase(peer_id)
_last_call_msec.erase(peer_id)
_call_variant_indices.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
@ -329,6 +479,8 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_seen_messages.clear()
_request_ledgers.clear()
_rate_times.clear()
_last_call_msec.clear()
_call_variant_indices.clear()
_peer_names.clear()
_sequence = 0
history_replaced.emit([])

View file

@ -9,6 +9,7 @@ var profile_id: String = ""
var display_name: String = "Player"
var voice_id: String = VoiceProfilesType.DEFAULT_ID
var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
var created_at_unix: int = 0
var _profile_path := ""
var _expected_hash := ""
@ -55,37 +56,43 @@ func load_or_create() -> bool:
display_name = "Player"
voice_id = VoiceProfilesType.DEFAULT_ID
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
call_id = VoiceProfilesType.DEFAULT_CALL_ID
created_at_unix = int(Time.get_unix_time_from_system())
return _save_atomic()
func set_display_name(value: String) -> bool:
return set_profile_identity(value, voice_id, speech_speed_id)
return set_profile_identity(value, voice_id, speech_speed_id, call_id)
func set_profile_identity(
value: String,
selected_voice_id: String,
selected_speech_speed_id: String,
selected_call_id: String,
) -> bool:
var clean_name: String = value.strip_edges()
if (
not is_valid_display_name(clean_name)
or not VoiceProfilesType.is_valid(selected_voice_id)
or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
or not VoiceProfilesType.is_valid_call(selected_call_id)
):
return false
var previous_name: String = display_name
var previous_voice_id: String = voice_id
var previous_speech_speed_id: String = speech_speed_id
var previous_call_id: String = call_id
display_name = clean_name
voice_id = selected_voice_id
speech_speed_id = selected_speech_speed_id
call_id = selected_call_id
if _save_atomic():
return true
display_name = previous_name
voice_id = previous_voice_id
speech_speed_id = previous_speech_speed_id
call_id = previous_call_id
return false
@ -140,6 +147,9 @@ func _load_existing() -> bool:
speech_speed_id = VoiceProfilesType.sanitized_speed_id(
str(data.get("speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID))
)
call_id = VoiceProfilesType.sanitized_call_id(
str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
)
created_at_unix = int(data["created_at_unix"])
return true
@ -151,6 +161,7 @@ func _save_atomic() -> bool:
"display_name": display_name,
"voice_id": voice_id,
"speech_speed_id": speech_speed_id,
"call_id": call_id,
"created_at_unix": created_at_unix,
}
var result := PortableFileGuard.write_guarded(

View file

@ -24,6 +24,7 @@ var _spawn_service: PlayerSpawnService
var _pending_apply: Dictionary[String, Dictionary] = {}
var _pending_voice_ids: Dictionary[String, String] = {}
var _pending_speech_speed_ids: Dictionary[String, String] = {}
var _pending_call_ids: Dictionary[String, String] = {}
var _host_pending_apply: Dictionary[String, Dictionary] = {}
var _latest_check_id: String = ""
var _latest_check_name: String = ""
@ -65,6 +66,10 @@ func get_persisted_speech_speed_id() -> String:
return _preferences.speech_speed_id
func get_persisted_call_id() -> String:
return _preferences.call_id
func get_identity_fingerprint() -> String:
return (
_session.get_local_identity_fingerprint()
@ -104,6 +109,7 @@ func apply_profile(
use_anyway: bool,
voice_id: String = VoiceProfilesType.DEFAULT_ID,
speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID,
call_id: String = VoiceProfilesType.DEFAULT_CALL_ID,
) -> bool:
var clean_name := display_name.strip_edges()
if (
@ -111,6 +117,7 @@ func apply_profile(
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
or not VoiceProfilesType.is_valid(voice_id)
or not VoiceProfilesType.is_valid_speed(speech_speed_id)
or not VoiceProfilesType.is_valid_call(call_id)
):
apply_finished.emit(false, "Check the player name and appearance choices.")
return false
@ -129,6 +136,7 @@ func apply_profile(
_pending_apply[request_id] = request
_pending_voice_ids[request_id] = voice_id
_pending_speech_speed_ids[request_id] = speech_speed_id
_pending_call_ids[request_id] = call_id
if _session == null or not _session.is_gameplay_session_active():
_apply_local_result(request_id, true, "", false, PackedStringArray())
elif _session.is_host():
@ -287,12 +295,14 @@ func _apply_local_result(
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
conflict_result.emit(request_id, conflict, suggestions)
apply_finished.emit(false, message)
return
var previous_name := _preferences.display_name
var previous_voice_id := _preferences.voice_id
var previous_speech_speed_id := _preferences.speech_speed_id
var previous_call_id := _preferences.call_id
var requested_voice_id: String = _pending_voice_ids.get(
request_id,
VoiceProfilesType.DEFAULT_ID,
@ -301,14 +311,20 @@ func _apply_local_result(
request_id,
VoiceProfilesType.DEFAULT_SPEED_ID,
)
var requested_call_id: String = _pending_call_ids.get(
request_id,
VoiceProfilesType.DEFAULT_CALL_ID,
)
if not _preferences.set_profile_identity(
str(request["display_name"]),
requested_voice_id,
requested_speech_speed_id,
requested_call_id,
):
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
if not _appearance_store.save_snapshot(request["appearance"]):
@ -316,15 +332,18 @@ func _apply_local_result(
previous_name,
previous_voice_id,
previous_speech_speed_id,
previous_call_id,
)
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
if _session != null and _session.is_gameplay_session_active():
if _session.is_host():
_session.apply_canonical_profile(
@ -451,6 +470,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_pending_apply.clear()
_pending_voice_ids.clear()
_pending_speech_speed_ids.clear()
_pending_call_ids.clear()
_host_pending_apply.clear()
_latest_check_id = ""
_latest_check_name = ""

View file

@ -8,7 +8,7 @@ const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 30.0
const SNAPSHOT_INTERVAL: float = 1.0 / 20.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
signal state_changed(state: State)
signal status_message_changed(message: String)
@ -574,9 +574,13 @@ func apply_canonical_profile(
var appearance_changed := _registry.update_appearance(peer_id, appearance)
if name_changed:
peer_display_name_changed.emit(peer_id, display_name)
if name_changed and appearance_changed:
if appearance_changed:
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_appearance_snapshot(appearance)
if name_changed or appearance_changed:
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
return name_changed and appearance_changed
return name_changed or appearance_changed
@rpc("any_peer", "call_remote", "reliable", 0)
@ -1018,6 +1022,14 @@ func submit_client_hello(data: Dictionary) -> void:
_spawn_service.get_spawn_transform_for_index(spawn_index)
)
_spawn_service.spawn_remote_player(sender_id, spawn_transform, true)
var spawned_avatar: Player = _spawn_service.get_avatar(sender_id)
if spawned_avatar != null:
spawned_avatar.apply_appearance_snapshot(submitted_appearance)
peer_profile_changed.emit(
sender_id,
display_name,
submitted_appearance.duplicate(true),
)
receive_server_hello.rpc_id(
sender_id,
NetworkProtocol.make_server_hello(