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

@ -25,9 +25,21 @@ const SPEECH_BUBBLE_WIDTH: float = 320.0
const SPEECH_POINTER_HALF_WIDTH: float = 12.0
const SPEECH_POINTER_HEIGHT: float = 14.0
const SPEECH_POINTER_OVERLAP: float = 3.0
const NAMEPLATE_WIDTH: float = 300.0
const NAMEPLATE_NAME_HEIGHT: float = 24.0
const NAMEPLATE_TITLE_TOP: float = 21.0
const NAMEPLATE_TITLE_HEIGHT: float = 18.0
const NAMEPLATE_HEIGHT: float = (
NAMEPLATE_TITLE_TOP + NAMEPLATE_TITLE_HEIGHT
)
const NAMEPLATE_NAME_FONT_SIZE: int = 20
const NAMEPLATE_TITLE_FONT_SIZE: int = 14
const NAMEPLATE_HEAD_GAP: float = 3.0
const NAMEPLATE_SPEECH_GAP: float = 5.0
const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0
const ANIMALESE_SILENT_DISTANCE: float = 24.0
const ANIMALESE_SILENT_VOLUME_DB: float = -80.0
const ROLEPLAY_FONT_SLANT: float = -0.18
const MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000
const MOBILE_COMPACT_WIDTH: float = 620.0
const MOBILE_EXPANDED_WIDTH: float = 820.0
@ -107,6 +119,7 @@ var _player: Player
var _fishing_spot: FishingSpot
var _settings: PlayerSettingsManager
var _history: RichTextLabel
var _roleplay_font: FontVariation
var _entry: LineEdit
var _status: Label
var _panel: PanelContainer
@ -114,6 +127,7 @@ var _collapse_button: Button
var _height_button: Button
var _unread_indicator: Label
var _hint: Label
var _nameplate_layer: Control
var _speech_layer: Control
var _animalese_voice: AnimaleseVoiceType
var _clock_panel: PanelContainer
@ -125,6 +139,7 @@ var _calendar_balance: CurrencyAmount
var _weather_icon: WeatherIconType
var _status_effect_column: VBoxContainer
var _status_effect_icons: Dictionary[StringName, TextureRect] = {}
var _nameplates: Dictionary[int, Dictionary] = {}
var _speech: Dictionary[int, Dictionary] = {}
var _draft_save_timer: Timer
var _opacity_tween: Tween
@ -132,6 +147,7 @@ var _height_tween: Tween
var _opened: bool = false
var _available: bool = false
var _hud_hidden: bool = false
var _nameplates_hud_hidden: bool = false
var _world_speech_visible: bool = true
var _presentation_state := PresentationState.COMPACT
var _visible_state_before_collapse := PresentationState.COMPACT
@ -247,7 +263,12 @@ func setup(
_service.local_message_confirmed.connect(_on_local_message_confirmed)
_service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected)
_session.peer_removed.connect(_on_peer_removed)
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
_session.peer_title_changed.connect(_on_peer_title_changed)
_session.peer_removed.connect(_on_session_peer_removed)
_spawn.avatar_spawned.connect(_on_avatar_spawned)
_spawn.avatar_removed.connect(_on_avatar_removed)
_entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length()
_set_presentation_state(
@ -258,6 +279,7 @@ func setup(
false,
)
_refresh_history()
_reconcile_nameplates()
func _rebuild_status_effect_icons() -> void:
@ -370,6 +392,7 @@ func open_command_chat() -> void:
func set_available(value: bool) -> void:
_available = value
_refresh_nameplate_layer_visibility()
if not value:
_clear_suspended_chat_state()
_send_pending = false
@ -601,6 +624,7 @@ func _process(_delta: float) -> void:
_refresh_visibility()
_update_status_effect_icons()
_update_panel_opacity()
_update_nameplates()
_update_speech()
@ -612,6 +636,11 @@ func _exit_tree() -> void:
func _build_ui() -> void:
_roleplay_font = FontVariation.new()
_roleplay_font.base_font = UtilityPageStyle.TuffyFont
var roleplay_transform := Transform2D.IDENTITY
roleplay_transform.y.x = ROLEPLAY_FONT_SLANT
_roleplay_font.variation_transform = roleplay_transform
_clock_panel = PanelContainer.new()
_clock_panel.name = "WorldClockPanel"
_clock_panel.size = CLOCK_SIZE
@ -720,7 +749,9 @@ func _build_ui() -> void:
_history.scroll_following = true
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_history.mouse_filter = Control.MOUSE_FILTER_STOP
_history.bbcode_enabled = false
_history.add_theme_font_override("normal_font", UtilityPageStyle.TuffyFont)
_history.add_theme_font_override("italics_font", _roleplay_font)
_history.add_theme_font_size_override("normal_font_size", HISTORY_FONT_SIZE)
_history.add_theme_color_override("default_color", UtilityPageStyle.LIGHT_TEXT)
_history.add_theme_stylebox_override("normal", _borderless_style(Color.TRANSPARENT))
@ -846,7 +877,16 @@ func _build_ui() -> void:
_hint.add_theme_constant_override("shadow_offset_x", 1)
_hint.add_theme_constant_override("shadow_offset_y", 2)
add_child(_hint)
_nameplate_layer = Control.new()
_nameplate_layer.name = "PlayerNameplateLayer"
_nameplate_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_nameplate_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate_layer.visible = (
_available and _world_speech_visible and not _nameplates_hud_hidden
)
add_child(_nameplate_layer)
_speech_layer = Control.new()
_speech_layer.name = "PlayerSpeechLayer"
_speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_speech_layer)
@ -1193,10 +1233,15 @@ func _on_message(message: Dictionary) -> void:
_update_panel_opacity(true)
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
return
var body: String = str(message["body"])
# Roleplay actions belong only in chat history. They deliberately do not
# create world speech, animalese, or a talking animation.
if NetworkChatProtocol.is_roleplay_body(body):
return
var peer_id: int = message["sender_peer_id"]
_show_speech_bubble(
peer_id,
str(message["body"]),
body,
str(message.get("sender_fingerprint", "")),
VoiceProfilesType.sanitized_id(str(message.get(
"voice_id", VoiceProfilesType.DEFAULT_ID
@ -1221,6 +1266,8 @@ func _show_speech_bubble(
requested_sample_set_id: String = "",
) -> void:
_on_peer_removed(peer_id)
if not _speaker_shares_local_space(peer_id):
return
var bubble := PanelContainer.new()
bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
bubble.custom_minimum_size = Vector2(SPEECH_BUBBLE_WIDTH, 0.0)
@ -1342,16 +1389,27 @@ func _on_rejected(message: String) -> void:
func _refresh_history() -> void:
if _service == null:
return
var lines: Array[String] = []
var messages := _service.get_history()
for message: Dictionary in messages:
_history.clear()
for message_index: int in messages.size():
var message: Dictionary = messages[message_index]
if message_index > 0:
_history.add_text("\n")
if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM:
lines.append("%s" % str(message["body"]))
else:
lines.append("%s: %s" % [
str(message["sender_display_name"]), str(message["body"]),
_history.add_text("%s" % str(message["body"]))
continue
var body: String = str(message["body"])
if NetworkChatProtocol.is_roleplay_body(body):
_history.push_italics()
_history.add_text("%s %s" % [
str(message["sender_display_name"]),
NetworkChatProtocol.roleplay_action(body),
])
_history.text = "\n".join(lines)
_history.pop()
continue
_history.add_text("%s: %s" % [
str(message["sender_display_name"]), body,
])
_scroll_history_to_bottom.call_deferred()
@ -1465,6 +1523,8 @@ func is_mobile_mode() -> bool:
func set_hud_hidden(is_hidden: bool) -> void:
_hud_hidden = is_hidden
_nameplates_hud_hidden = is_hidden
_refresh_nameplate_layer_visibility()
_refresh_visibility()
@ -1472,8 +1532,14 @@ func is_hud_hidden() -> bool:
return _hud_hidden
func set_world_nameplates_hud_hidden(is_hidden: bool) -> void:
_nameplates_hud_hidden = is_hidden
_refresh_nameplate_layer_visibility()
func set_world_speech_visible(should_be_visible: bool) -> void:
_world_speech_visible = should_be_visible
_refresh_nameplate_layer_visibility()
if _speech_layer != null:
_speech_layer.visible = should_be_visible
@ -1482,6 +1548,14 @@ func is_world_speech_visible() -> bool:
return _world_speech_visible
func _refresh_nameplate_layer_visibility() -> void:
if _nameplate_layer == null:
return
_nameplate_layer.visible = (
_available and _world_speech_visible and not _nameplates_hud_hidden
)
func _refresh_handle_labels(state: PresentationState) -> void:
var collapsed := state == PresentationState.COLLAPSED
var height_state := _visible_state_before_collapse if collapsed else state
@ -1538,11 +1612,217 @@ func _update_panel_opacity(immediate_recheck: bool = false) -> void:
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _update_nameplates() -> void:
_reconcile_nameplates()
if _spawn == null or _player == null:
_hide_all_nameplates()
return
var camera := _player.get_active_gameplay_camera()
if camera == null:
_hide_all_nameplates()
return
var viewport_size := get_viewport_rect().size
for peer_id: int in _nameplates.keys():
var state: Dictionary = _nameplates[peer_id]
var plate := state.get("control") as Control
var avatar := _spawn.get_avatar(peer_id)
if plate == null:
continue
if (
avatar == null
or not _available
or _nameplates_hud_hidden
or not _world_speech_visible
or not _speaker_shares_local_space(peer_id)
or not _avatar_presentation_visible(peer_id, avatar)
):
plate.hide()
continue
var world_position := avatar.get_nameplate_anchor_position()
if (
camera.is_position_behind(world_position)
or _is_speech_world_occluded(camera, avatar, world_position)
):
plate.hide()
continue
var screen_position := (
camera.unproject_position(world_position) / _output_scale
)
if (
screen_position.x < -NAMEPLATE_WIDTH * 0.5
or screen_position.x > viewport_size.x + NAMEPLATE_WIDTH * 0.5
or screen_position.y < -NAMEPLATE_HEIGHT
or screen_position.y > viewport_size.y + NAMEPLATE_HEIGHT
):
plate.hide()
continue
plate.position = Vector2(
screen_position.x - NAMEPLATE_WIDTH * 0.5,
screen_position.y - NAMEPLATE_HEIGHT - NAMEPLATE_HEAD_GAP,
)
plate.show()
func _reconcile_nameplates() -> void:
if _spawn == null or _session == null or _nameplate_layer == null:
return
var live_peer_ids: Dictionary[int, bool] = {}
for peer_id: int in _spawn.get_peer_ids():
if (
_spawn.get_avatar(peer_id) == null
or _session.get_peer_record(peer_id) == null
):
continue
live_peer_ids[peer_id] = true
_ensure_nameplate(peer_id)
for peer_id: int in _nameplates.keys():
if not live_peer_ids.has(peer_id):
_remove_nameplate(peer_id)
func _ensure_nameplate(peer_id: int) -> void:
if _nameplates.has(peer_id):
_refresh_nameplate_text(peer_id)
return
var plate := Control.new()
plate.name = "PlayerNameplate_%d" % peer_id
plate.size = Vector2(NAMEPLATE_WIDTH, NAMEPLATE_HEIGHT)
plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
plate.hide()
var name_label := _create_nameplate_label(
"PlayerName",
NAMEPLATE_NAME_FONT_SIZE,
0.0,
NAMEPLATE_NAME_HEIGHT,
)
var title_label := _create_nameplate_label(
"PlayerTitle",
NAMEPLATE_TITLE_FONT_SIZE,
NAMEPLATE_TITLE_TOP,
NAMEPLATE_TITLE_HEIGHT,
)
plate.add_child(name_label)
plate.add_child(title_label)
_nameplate_layer.add_child(plate)
_nameplates[peer_id] = {
"control": plate,
"name": name_label,
"title": title_label,
}
_refresh_nameplate_text(peer_id)
func _create_nameplate_label(
node_name: String,
font_size: int,
vertical_position: float,
height: float,
) -> Label:
var label := Label.new()
label.name = node_name
label.position = Vector2(0.0, vertical_position)
label.size = Vector2(NAMEPLATE_WIDTH, height)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", Color.WHITE)
label.add_theme_color_override("font_outline_color", Color.TRANSPARENT)
label.add_theme_color_override("font_shadow_color", Color.TRANSPARENT)
label.add_theme_constant_override("outline_size", 0)
label.add_theme_constant_override("shadow_offset_x", 0)
label.add_theme_constant_override("shadow_offset_y", 0)
return label
func _refresh_nameplate_text(peer_id: int) -> void:
if _session == null or not _nameplates.has(peer_id):
return
var record := _session.get_peer_record(peer_id)
if record == null:
return
var state: Dictionary = _nameplates[peer_id]
var name_label := state.get("name") as Label
var title_label := state.get("title") as Label
if name_label != null:
name_label.text = (
record.display_name
if not record.display_name.is_empty()
else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
if title_label != null:
title_label.text = (
record.title
if not record.title.is_empty()
else NetworkProfilePreferences.DEFAULT_TITLE
)
func _remove_nameplate(peer_id: int) -> void:
var state: Dictionary = _nameplates.get(peer_id, {})
var plate := state.get("control") as Control
if plate != null:
plate.queue_free()
_nameplates.erase(peer_id)
func _hide_all_nameplates() -> void:
for state: Dictionary in _nameplates.values():
var plate := state.get("control") as Control
if plate != null:
plate.hide()
func _avatar_presentation_visible(peer_id: int, avatar: Player) -> bool:
if _session == null or peer_id == _session.get_local_peer_id():
return true
return avatar.is_remote_presentation_visible()
func _nameplate_speech_clearance(peer_id: int) -> float:
var state: Dictionary = _nameplates.get(peer_id, {})
var plate := state.get("control") as Control
if plate == null or not plate.is_visible_in_tree():
return 0.0
return NAMEPLATE_HEIGHT + NAMEPLATE_HEAD_GAP + NAMEPLATE_SPEECH_GAP
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
_ensure_nameplate(peer_id)
func _on_peer_display_name_changed(peer_id: int, _display_name: String) -> void:
_ensure_nameplate(peer_id)
func _on_peer_title_changed(peer_id: int, _title: String) -> void:
_ensure_nameplate(peer_id)
func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
_ensure_nameplate(peer_id)
func _on_avatar_removed(peer_id: int) -> void:
_remove_nameplate(peer_id)
func _on_session_peer_removed(peer_id: int) -> void:
_on_peer_removed(peer_id)
_remove_nameplate(peer_id)
func _update_speech() -> void:
var now := Time.get_ticks_msec() / 1000.0
for peer_id: int in _speech.keys():
var state: Dictionary = _speech[peer_id]
var bubble := state.get("bubble") as PanelContainer
if not _speaker_shares_local_space(peer_id):
_on_peer_removed(peer_id)
continue
if bubble == null or now >= float(state.get("expires", 0.0)):
if bubble != null:
bubble.queue_free()
@ -1553,10 +1833,14 @@ func _update_speech() -> void:
continue
var avatar := _spawn.get_avatar(peer_id)
var camera := _player.get_active_gameplay_camera()
if avatar == null or camera == null:
if (
avatar == null
or camera == null
or not _avatar_presentation_visible(peer_id, avatar)
):
bubble.hide()
continue
var world_position := avatar.get_chat_anchor_position()
var world_position := avatar.get_nameplate_anchor_position()
if camera.is_position_behind(world_position):
bubble.hide()
continue
@ -1566,6 +1850,7 @@ func _update_speech() -> void:
var screen_position := (
camera.unproject_position(world_position) / _output_scale
)
screen_position.y -= _nameplate_speech_clearance(peer_id)
var viewport_size := get_viewport_rect().size
if (
screen_position.x < -80.0
@ -1631,10 +1916,27 @@ func _is_speech_world_occluded(
if collider is Player:
excluded.append(collider.get_rid())
continue
if (
collider != null
and bool(collider.get_meta(
HomeWorldService.INTERIOR_SHELL_COLLISION_META, false
))
):
excluded.append(collider.get_rid())
continue
return true
return false
func _speaker_shares_local_space(peer_id: int) -> bool:
if _session == null:
return true
var local_peer_id: int = _session.get_local_peer_id()
if local_peer_id <= 0:
return true
return _session.peers_share_space(local_peer_id, peer_id)
func _on_draft_changed(_value: String) -> void:
if _settings == null:
return