netfishing/ui/chat_ui.gd

482 lines
14 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name ChatUI
extends Control
const INPUT_OWNER: StringName = &"chat"
const RECENT_SECONDS: float = 8.0
const SPEECH_SECONDS: float = 6.0
const DRAFT_SAVE_DELAY: float = 0.4
const IDLE_ALPHA: float = 0.58
const PANEL_POSITION := Vector2(30, 376)
const PANEL_SIZE := Vector2(390, 250)
var _service: NetworkChatService
var _session: NetworkSession
var _spawn: PlayerSpawnService
var _player: Player
var _fishing_spot: FishingSpot
var _settings: PlayerSettingsManager
var _history: Label
var _entry: LineEdit
var _status: Label
var _panel: PanelContainer
var _collapse_button: Button
var _unread_indicator: Label
var _hint: Label
var _speech_layer: Control
var _speech: Dictionary[int, Dictionary] = {}
var _draft_save_timer: Timer
var _opacity_tween: Tween
var _opened: bool = false
var _available: bool = false
var _collapsed: bool = false
var _panel_hovered: bool = false
var _collapsed_has_unread: bool = false
var _last_message_time: float = -INF
var _target_alpha: float = -1.0
var _send_pending: bool = false
var _pending_send_body: String = ""
var _prior_movement: bool = true
var _prior_camera: bool = true
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui()
set_process(true)
func setup(
service: NetworkChatService,
session: NetworkSession,
spawn: PlayerSpawnService,
player: Player,
fishing_spot: FishingSpot,
settings: PlayerSettingsManager,
) -> void:
_service = service
_session = session
_spawn = spawn
_player = player
_fishing_spot = fishing_spot
_settings = settings
_service.message_received.connect(_on_message)
_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)
_entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length()
_set_collapsed(_settings.current_settings.chat_collapsed, false)
_refresh_history()
func open_chat() -> void:
if (
_opened or not _available or _service == null
or not _session.is_gameplay_session_active()
):
return
_set_collapsed(false)
_opened = true
_prior_movement = _player.is_movement_enabled()
_prior_camera = _player.is_camera_input_enabled()
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
# Clear the last authoritative input immediately. Merely disabling local
# prediction would let a remote host continue the last held movement.
_session.submit_neutral_local_movement()
_entry.show()
_entry.grab_focus()
_hint.hide()
_update_panel_opacity(true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func set_available(value: bool) -> void:
_available = value
if not value:
_send_pending = false
_pending_send_body = ""
_entry.editable = true
close_chat()
_flush_draft()
_refresh_visibility()
func close_chat() -> void:
if not _opened:
return
_opened = false
_entry.release_focus()
_entry.hide()
_player.set_movement_enabled(_prior_movement)
_player.set_camera_input_enabled(_prior_camera)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
_flush_draft()
_refresh_visibility()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode in [KEY_ENTER, KEY_KP_ENTER]:
if _opened:
_send()
elif (
_available
and get_viewport().gui_get_focus_owner() is not LineEdit
):
open_chat()
get_viewport().set_input_as_handled()
elif event.keycode == KEY_T and not _opened and _available:
open_chat()
get_viewport().set_input_as_handled()
elif event.keycode == KEY_ESCAPE and _opened:
close_chat()
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
_refresh_visibility()
_update_panel_opacity()
_update_speech()
func _exit_tree() -> void:
_flush_draft()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.position = PANEL_POSITION
_panel.size = PANEL_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _chat_panel_style())
_panel.mouse_entered.connect(func() -> void:
_panel_hovered = true
_update_panel_opacity(true)
)
_panel.mouse_exited.connect(func() -> void:
_panel_hovered = false
_update_panel_opacity(true)
)
add_child(_panel)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 6)
_panel.add_child(box)
_history = Label.new()
_history.custom_minimum_size = Vector2(360, 158)
_history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_history.mouse_filter = Control.MOUSE_FILTER_IGNORE
_history.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
_history.add_theme_color_override("font_color", UtilityPageStyle.LIGHT_TEXT)
box.add_child(_history)
_status = Label.new()
_status.custom_minimum_size = Vector2(360, 20)
_status.mouse_filter = Control.MOUSE_FILTER_IGNORE
_status.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
_status.add_theme_color_override("font_color", Color("ffd6a1"))
box.add_child(_status)
_entry = LineEdit.new()
_entry.placeholder_text = "type a message…"
_entry.max_length = NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
_entry.text_submitted.connect(func(_value: String) -> void: _send())
_entry.text_changed.connect(_on_draft_changed)
UtilityPageStyle.apply_line_edit(_entry)
box.add_child(_entry)
_entry.hide()
_collapse_button = Button.new()
_collapse_button.position = Vector2(2, PANEL_POSITION.y + 101)
_collapse_button.size = Vector2(34, 48)
_collapse_button.tooltip_text = "Collapse chat"
_collapse_button.pressed.connect(func() -> void:
_set_collapsed(not _collapsed)
)
_collapse_button.focus_entered.connect(func() -> void:
_update_panel_opacity(true)
)
UtilityPageStyle.apply_button(_collapse_button)
add_child(_collapse_button)
_unread_indicator = Label.new()
_unread_indicator.text = ""
_unread_indicator.position = Vector2(8, PANEL_POSITION.y + 86)
_unread_indicator.size = Vector2(22, 20)
_unread_indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_unread_indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
_unread_indicator.add_theme_font_override(
"font", UtilityPageStyle.TuffyFont
)
_unread_indicator.add_theme_font_size_override("font_size", 20)
_unread_indicator.add_theme_color_override("font_color", Color("ffe08a"))
add_child(_unread_indicator)
_hint = Label.new()
_hint.text = "Press Enter to chat"
_hint.position = Vector2(30, 655)
_hint.size = Vector2(260, 28)
_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hint.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
_hint.add_theme_color_override(
"font_color", Color(0.94, 0.91, 0.80, 0.72)
)
_hint.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
_hint.add_theme_constant_override("shadow_offset_x", 1)
_hint.add_theme_constant_override("shadow_offset_y", 2)
add_child(_hint)
_speech_layer = Control.new()
_speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_speech_layer)
_draft_save_timer = Timer.new()
_draft_save_timer.one_shot = true
_draft_save_timer.wait_time = DRAFT_SAVE_DELAY
_draft_save_timer.timeout.connect(_flush_draft)
add_child(_draft_save_timer)
_panel.hide()
_collapse_button.hide()
_unread_indicator.hide()
_hint.hide()
func _chat_panel_style() -> StyleBoxFlat:
var style := UtilityPageStyle.button_style(Color(0.025, 0.13, 0.19, 0.94))
style.border_color = Color(0.75, 0.84, 0.74, 0.66)
style.set_corner_radius_all(10)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 10
style.content_margin_bottom = 10
return style
func _speech_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(UtilityPageStyle.PAPER_ALT, 0.96)
style.border_color = Color(UtilityPageStyle.BORDER, 0.88)
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 6
style.content_margin_bottom = 6
style.shadow_color = Color(0, 0, 0, 0.32)
style.shadow_size = 4
style.shadow_offset = Vector2(2, 3)
return style
func _send() -> void:
if _send_pending:
return
var body := _entry.text
_send_pending = true
_pending_send_body = NetworkChatProtocol.sanitize_body(body)
_entry.editable = false
if not _service.send_local_message(body):
_send_pending = false
_pending_send_body = ""
_entry.editable = true
elif _send_pending:
_status.text = "Sending…"
func _on_local_message_confirmed(message: Dictionary) -> void:
if (
not _send_pending
or str(message.get("body", "")) != _pending_send_body
):
return
_send_pending = false
_pending_send_body = ""
_entry.editable = true
_entry.clear()
_status.text = ""
_flush_draft()
close_chat()
func _on_message(message: Dictionary) -> void:
_last_message_time = Time.get_ticks_msec() / 1000.0
_refresh_history()
if _collapsed:
_collapsed_has_unread = true
_unread_indicator.show()
_update_panel_opacity(true)
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
return
var peer_id: int = message["sender_peer_id"]
_on_peer_removed(peer_id)
var bubble := PanelContainer.new()
bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
bubble.custom_minimum_size = Vector2(270, 0)
bubble.size = Vector2(270, 72)
bubble.add_theme_stylebox_override("panel", _speech_panel_style())
var label := Label.new()
label.text = str(message["body"]).left(120)
label.custom_minimum_size = Vector2(246, 46)
label.size = Vector2(246, 62)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
label.add_theme_color_override("font_color", UtilityPageStyle.INK)
bubble.add_child(label)
_speech_layer.add_child(bubble)
_speech[peer_id] = {
"bubble": bubble,
"expires": Time.get_ticks_msec() / 1000.0 + SPEECH_SECONDS,
"fingerprint": str(message.get("sender_fingerprint", "")),
}
func _on_history(messages: Array) -> void:
for peer_id: int in _speech.keys():
var state: Dictionary = _speech[peer_id]
if (
messages.is_empty()
or _service.is_sender_filtered(
str(state.get("fingerprint", ""))
)
):
_on_peer_removed(peer_id)
_refresh_history()
func _on_rejected(message: String) -> void:
_send_pending = false
_pending_send_body = ""
_entry.editable = true
_status.text = message
if not _opened:
open_chat()
func _refresh_history() -> void:
if _service == null:
return
var lines: Array[String] = []
var messages := _service.get_history()
var start := maxi(0, messages.size() - 8)
for message: Dictionary in messages.slice(start):
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.text = "\n".join(lines)
func _refresh_visibility() -> void:
if not _available:
_panel.hide()
_collapse_button.hide()
_unread_indicator.hide()
_hint.hide()
return
_collapse_button.show()
_panel.visible = not _collapsed
_unread_indicator.visible = _collapsed and _collapsed_has_unread
_hint.visible = not _opened
func _set_collapsed(value: bool, persist: bool = true) -> void:
_collapsed = value
_collapse_button.text = "" if _collapsed else ""
_collapse_button.tooltip_text = (
"Expand chat" if _collapsed else "Collapse chat"
)
if not _collapsed:
_collapsed_has_unread = false
_unread_indicator.hide()
if persist and _settings != null:
_settings.update_chat_preferences(_entry.text, _collapsed)
_draft_save_timer.start()
_refresh_visibility()
func _update_panel_opacity(immediate_recheck: bool = false) -> void:
if _panel == null or _collapsed or not _available:
return
var recent := (
Time.get_ticks_msec() / 1000.0 - _last_message_time < RECENT_SECONDS
)
var focus_owner := get_viewport().gui_get_focus_owner()
var wants_full := (
_opened or recent or _panel_hovered
or focus_owner == _collapse_button
or focus_owner == _entry
)
var desired := 1.0 if wants_full else IDLE_ALPHA
if not immediate_recheck and is_equal_approx(desired, _target_alpha):
return
_target_alpha = desired
if _opacity_tween != null and _opacity_tween.is_valid():
_opacity_tween.kill()
_opacity_tween = create_tween()
_opacity_tween.tween_property(
_panel, "modulate:a", desired, 0.18
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
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 bubble == null or now >= float(state.get("expires", 0.0)):
if bubble != null:
bubble.queue_free()
_speech.erase(peer_id)
continue
var avatar := _spawn.get_avatar(peer_id)
var camera := _player.get_gameplay_camera()
if avatar == null or camera == null:
bubble.hide()
continue
var world_position := avatar.get_chat_anchor_position()
if camera.is_position_behind(world_position):
bubble.hide()
continue
var screen_position := camera.unproject_position(world_position)
var viewport_size := get_viewport_rect().size
if (
screen_position.x < -80.0
or screen_position.x > viewport_size.x + 80.0
or screen_position.y < -80.0
or screen_position.y > viewport_size.y + 80.0
):
bubble.hide()
continue
var desired := screen_position - Vector2(
bubble.size.x * 0.5, bubble.size.y + 8.0
)
bubble.position = Vector2(
clampf(desired.x, 8.0, viewport_size.x - bubble.size.x - 8.0),
clampf(desired.y, 8.0, viewport_size.y - bubble.size.y - 8.0),
)
bubble.show()
func _on_draft_changed(_value: String) -> void:
if _settings == null:
return
_settings.update_chat_preferences(_entry.text, _collapsed)
_draft_save_timer.start()
func _flush_draft() -> void:
if _settings == null or _entry == null:
return
_settings.update_chat_preferences(_entry.text, _collapsed)
_settings.save_chat_preferences()
func _on_peer_removed(peer_id: int) -> void:
var state: Dictionary = _speech.get(peer_id, {})
var bubble := state.get("bubble") as PanelContainer
if bubble != null:
bubble.queue_free()
_speech.erase(peer_id)