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 ORIGINAL_PANEL_WIDTH: float = 390.0 const PANEL_WIDTH: float = ORIGINAL_PANEL_WIDTH * 0.85 const COMPACT_HEIGHT: float = 250.0 const BOTTOM_MARGIN: float = 94.0 const MIN_TOP_MARGIN: float = 24.0 const MIN_HISTORY_HEIGHT: float = 92.0 const HISTORY_FONT_SIZE: int = 17 const INPUT_FONT_SIZE: int = 23 const HINT_FONT_SIZE: int = 12 const HISTORY_INPUT_GAP: int = 8 const HANDLE_SIZE := Vector2(34, 42) const HANDLE_GAP: float = 6.0 const COLLAPSED_REVEAL_WIDTH: float = 8.0 const HINT_EDGE_MARGIN: float = 4.0 const CLOCK_SIZE := Vector2(116.0, 34.0) const CLOCK_EDGE_MARGIN: float = 10.0 const CLOCK_PANEL_GAP: float = 8.0 const WEATHER_ICON_SIZE := Vector2(34.0, 34.0) const WEATHER_ICON_GAP: float = 6.0 const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" ) const WeatherIconType = preload("res://ui/weather_icon.gd") enum PresentationState { COLLAPSED, COMPACT, EXPANDED, } signal text_entry_ownership_changed(active: bool) var _service: NetworkChatService var _session: NetworkSession var _spawn: PlayerSpawnService var _player: Player var _fishing_spot: FishingSpot var _settings: PlayerSettingsManager var _history: RichTextLabel var _entry: LineEdit var _status: Label var _panel: PanelContainer var _collapse_button: Button var _height_button: Button var _unread_indicator: Label var _hint: Label var _speech_layer: Control var _clock_panel: PanelContainer var _clock_label: Label var _weather_icon: WeatherIconType var _speech: Dictionary[int, Dictionary] = {} var _draft_save_timer: Timer var _opacity_tween: Tween var _height_tween: Tween var _opened: bool = false var _available: bool = false var _presentation_state := PresentationState.COMPACT var _visible_state_before_collapse := PresentationState.COMPACT 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 var _output_scale: float = 1.0 var _dock_right: bool = false var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE _build_ui() resized.connect(_on_viewport_resized) set_process(true) func setup( service: NetworkChatService, session: NetworkSession, spawn: PlayerSpawnService, player: Player, fishing_spot: FishingSpot, settings: PlayerSettingsManager, world_time: WorldTimeServiceType, world_weather: WorldWeatherServiceType, ) -> void: _service = service _session = session _spawn = spawn _player = player _fishing_spot = fishing_spot _settings = settings _world_time = world_time _world_weather = world_weather if ( _world_time != null and not _world_time.time_changed.is_connected( _on_world_time_changed ) ): _world_time.time_changed.connect(_on_world_time_changed) _on_world_time_changed( _world_time.get_time_hours(), _world_time.get_phase() ) if ( _world_weather != null and not _world_weather.weather_changed.is_connected( _on_world_weather_changed ) ): _world_weather.weather_changed.connect(_on_world_weather_changed) _on_world_weather_changed( _world_weather.get_weather(), _world_weather.get_seconds_remaining(), ) set_dock_right(_settings.current_settings.chat_dock_right) _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_presentation_state( PresentationState.COLLAPSED if _settings.current_settings.chat_collapsed else PresentationState.COMPACT, false, false, ) _refresh_history() func open_chat() -> void: if ( _opened or not _available or _service == null or not _session.is_gameplay_session_active() ): return if _presentation_state == PresentationState.COLLAPSED: _set_presentation_state(_visible_state_before_collapse, true, true) _opened = true text_entry_ownership_changed.emit(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 text_entry_ownership_changed.emit(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: _clock_panel = PanelContainer.new() _clock_panel.name = "WorldClockPanel" _clock_panel.size = CLOCK_SIZE _clock_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _clock_panel.add_theme_stylebox_override( "panel", _clock_panel_style() ) add_child(_clock_panel) _clock_label = Label.new() _clock_label.name = "WorldClockLabel" _clock_label.text = "8:00 am" _clock_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _clock_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _clock_label.mouse_filter = Control.MOUSE_FILTER_IGNORE _clock_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont) _clock_label.add_theme_font_size_override("font_size", 18) _clock_label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) _clock_panel.add_child(_clock_label) _weather_icon = WeatherIconType.new() _weather_icon.name = "WorldWeatherIcon" _weather_icon.size = WEATHER_ICON_SIZE _weather_icon.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_weather_icon) _panel = PanelContainer.new() _panel.name = "ChatPanel" _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", HISTORY_INPUT_GAP) _panel.add_child(box) _history = RichTextLabel.new() _history.name = "ChatHistory" _history.custom_minimum_size = Vector2(0, MIN_HISTORY_HEIGHT) _history.size_flags_vertical = Control.SIZE_EXPAND_FILL _history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _history.fit_content = false _history.scroll_active = true _history.scroll_following = false _history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM _history.mouse_filter = Control.MOUSE_FILTER_STOP _history.add_theme_font_override("normal_font", UtilityPageStyle.TuffyFont) _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)) box.add_child(_history) _status = Label.new() _status.custom_minimum_size = Vector2(0, 20) _status.hide() _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.name = "ChatEntry" _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_ocean_line_edit(_entry) _entry.add_theme_font_size_override("font_size", INPUT_FONT_SIZE) box.add_child(_entry) _entry.hide() _collapse_button = Button.new() _collapse_button.name = "ChatCollapseButton" _collapse_button.size = HANDLE_SIZE _collapse_button.mouse_filter = Control.MOUSE_FILTER_STOP _collapse_button.focus_mode = Control.FOCUS_ALL _collapse_button.z_index = 3 _collapse_button.tooltip_text = "Collapse chat" _collapse_button.pressed.connect(func() -> void: if _presentation_state == PresentationState.COLLAPSED: _set_presentation_state( _visible_state_before_collapse, true, true, ) else: if _opened: close_chat() _set_presentation_state(PresentationState.COLLAPSED, true, true) ) _collapse_button.focus_entered.connect(func() -> void: _update_panel_opacity(true) ) _collapse_button.mouse_entered.connect(_on_exterior_handle_entered) _collapse_button.mouse_exited.connect(_on_exterior_handle_exited) _apply_flat_chat_button(_collapse_button) add_child(_collapse_button) _height_button = Button.new() _height_button.name = "ChatHeightButton" _height_button.size = HANDLE_SIZE _height_button.mouse_filter = Control.MOUSE_FILTER_STOP _height_button.focus_mode = Control.FOCUS_ALL _height_button.z_index = 3 _height_button.pressed.connect(func() -> void: if _presentation_state == PresentationState.COLLAPSED: _visible_state_before_collapse = ( PresentationState.COMPACT if _visible_state_before_collapse == PresentationState.EXPANDED else PresentationState.EXPANDED ) _refresh_handle_labels(PresentationState.COLLAPSED) _layout_presentation(true) return _set_presentation_state( PresentationState.COMPACT if _presentation_state == PresentationState.EXPANDED else PresentationState.EXPANDED, true, true, ) ) _height_button.focus_entered.connect(func() -> void: _update_panel_opacity(true) ) _height_button.mouse_entered.connect(_on_exterior_handle_entered) _height_button.mouse_exited.connect(_on_exterior_handle_exited) _apply_flat_chat_button(_height_button) add_child(_height_button) _unread_indicator = Label.new() _unread_indicator.name = "ChatUnreadIndicator" _unread_indicator.text = "•" _unread_indicator.size = Vector2(22, 20) _unread_indicator.z_index = 4 _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.name = "ChatHint" _hint.text = "Press Enter to chat" _hint.size = Vector2(180, 18) _hint.mouse_filter = Control.MOUSE_FILTER_IGNORE _hint.add_theme_font_override("font", UtilityPageStyle.TuffyFont) _hint.add_theme_font_size_override("font_size", HINT_FONT_SIZE) _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() _clock_panel.hide() _weather_icon.hide() _collapse_button.hide() _height_button.hide() _unread_indicator.hide() _hint.hide() _layout_presentation(false) func _chat_panel_style() -> StyleBoxFlat: var style := _borderless_style(Color(0.025, 0.13, 0.19, 0.94)) style.corner_radius_top_left = 10 if _dock_right else 0 style.corner_radius_bottom_left = 10 if _dock_right else 0 style.corner_radius_top_right = 0 if _dock_right else 10 style.corner_radius_bottom_right = 0 if _dock_right else 10 style.content_margin_left = 12 style.content_margin_right = 12 style.content_margin_top = 10 style.content_margin_bottom = 10 return style func _clock_panel_style() -> StyleBoxFlat: var style := _borderless_style(Color(0.025, 0.13, 0.19, 0.94)) style.set_corner_radius_all(12) style.content_margin_left = 10 style.content_margin_right = 10 style.content_margin_top = 4 style.content_margin_bottom = 4 return style func _borderless_style(color: Color) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.set_border_width_all(0) return style func _flat_chat_button_style(color: Color) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.set_border_width_all(0) style.set_corner_radius_all(0) style.anti_aliasing = false style.shadow_size = 0 style.shadow_color = Color.TRANSPARENT style.shadow_offset = Vector2.ZERO style.content_margin_left = 0 style.content_margin_right = 0 style.content_margin_top = 0 style.content_margin_bottom = 0 return style func _apply_flat_chat_button(button: Button) -> void: button.add_theme_font_override("font", UtilityPageStyle.TuffyFont) button.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) button.add_theme_color_override("font_hover_color", Color.WHITE) button.add_theme_color_override("font_pressed_color", Color.WHITE) button.add_theme_color_override("font_focus_color", Color.WHITE) button.add_theme_color_override( "font_disabled_color", Color(UtilityPageStyle.OCEAN_TEXT_SECONDARY, 0.52), ) button.add_theme_stylebox_override( "normal", _flat_chat_button_style(UtilityPageStyle.OCEAN_BUTTON) ) button.add_theme_stylebox_override( "hover", _flat_chat_button_style(UtilityPageStyle.OCEAN_BUTTON_HOVER) ) button.add_theme_stylebox_override( "pressed", _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED) ) button.add_theme_stylebox_override( "hover_pressed", _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED), ) button.add_theme_stylebox_override( "focus", _flat_chat_button_style(UtilityPageStyle.OCEAN_SELECTED) ) button.add_theme_stylebox_override( "disabled", _flat_chat_button_style(UtilityPageStyle.OCEAN_DISABLED) ) 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: _set_status("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() _set_status("") _flush_draft() close_chat() func _on_message(message: Dictionary) -> void: _last_message_time = Time.get_ticks_msec() / 1000.0 _refresh_history() if _presentation_state == PresentationState.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 _set_status(message) if not _opened: open_chat() func _refresh_history() -> void: if _service == null: return var scroll_state := _capture_history_scroll() var lines: Array[String] = [] var messages := _service.get_history() for message: Dictionary in messages: 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) _restore_history_scroll.call_deferred(scroll_state) func _refresh_visibility() -> void: if not _available: _panel.hide() _clock_panel.hide() _weather_icon.hide() _collapse_button.hide() _height_button.hide() _unread_indicator.hide() _hint.hide() return _clock_panel.show() _weather_icon.show() _collapse_button.show() var collapsed := _presentation_state == PresentationState.COLLAPSED _panel.show() _height_button.show() _unread_indicator.visible = collapsed and _collapsed_has_unread _hint.visible = not _opened func _set_presentation_state( state: PresentationState, persist: bool = true, animate: bool = false, ) -> void: var previous_state := _presentation_state if ( state == PresentationState.COLLAPSED and previous_state != PresentationState.COLLAPSED ): _visible_state_before_collapse = previous_state _refresh_handle_labels(state) if state == _presentation_state and not animate: _layout_presentation(false) _refresh_visibility() return _presentation_state = state var collapsed := state == PresentationState.COLLAPSED if not collapsed: _collapsed_has_unread = false _unread_indicator.hide() _layout_presentation(animate) if persist and _settings != null: _settings.update_chat_preferences(_entry.text, collapsed) _draft_save_timer.start() _refresh_visibility() func set_output_scale(output_scale: float) -> void: _output_scale = maxf(output_scale, 0.001) func set_dock_right(should_dock_right: bool) -> void: _dock_right = should_dock_right if not is_node_ready() or _panel == null: return _panel.add_theme_stylebox_override("panel", _chat_panel_style()) _refresh_handle_labels(_presentation_state) _layout_presentation(false) func is_docked_right() -> bool: return _dock_right func _refresh_handle_labels(state: PresentationState) -> void: var collapsed := state == PresentationState.COLLAPSED var height_state := _visible_state_before_collapse if collapsed else state if _dock_right: _collapse_button.text = "<" if collapsed else ">" else: _collapse_button.text = ">" if collapsed else "<" _collapse_button.tooltip_text = ( "Show chat" if collapsed else "Hide chat" ) _height_button.text = "v" if height_state == PresentationState.EXPANDED else "^" _height_button.tooltip_text = ( "Compact chat" if height_state == PresentationState.EXPANDED else "Expand chat history" ) func _update_panel_opacity(immediate_recheck: bool = false) -> void: if _panel == null or not _available: return if _presentation_state == PresentationState.COLLAPSED: if _height_tween == null or not _height_tween.is_running(): _panel.modulate.a = IDLE_ALPHA _collapse_button.modulate.a = ( 1.0 if _collapsed_has_unread else 0.82 ) _height_button.modulate.a = 0.82 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 == _height_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.set_parallel(true) for control: Control in [_panel, _collapse_button, _height_button]: _opacity_tween.tween_property( control, "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) / _output_scale ) 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, _presentation_state == PresentationState.COLLAPSED, ) _draft_save_timer.start() func _flush_draft() -> void: if _settings == null or _entry == null: return _settings.update_chat_preferences( _entry.text, _presentation_state == PresentationState.COLLAPSED, ) _settings.save_chat_preferences() func _set_status(text: String) -> void: _status.text = text _status.visible = not text.is_empty() func _layout_presentation(animate: bool) -> void: if _panel == null: return var viewport_width := size.x if viewport_width <= 0.0: viewport_width = 1280.0 var viewport_height := size.y if viewport_height <= 0.0: viewport_height = 720.0 var bottom_margin := minf( BOTTOM_MARGIN, maxf(MIN_TOP_MARGIN, (viewport_height - MIN_HISTORY_HEIGHT) * 0.25), ) var bottom := viewport_height - bottom_margin var height := COMPACT_HEIGHT var layout_state := ( _visible_state_before_collapse if _presentation_state == PresentationState.COLLAPSED else _presentation_state ) if layout_state == PresentationState.EXPANDED: height = maxf(MIN_HISTORY_HEIGHT, viewport_height - 2.0 * bottom_margin) height = minf(height, maxf(MIN_HISTORY_HEIGHT, bottom - MIN_TOP_MARGIN)) var collapsed := _presentation_state == PresentationState.COLLAPSED var panel_x: float if _dock_right: panel_x = ( viewport_width - COLLAPSED_REVEAL_WIDTH if collapsed else viewport_width - PANEL_WIDTH ) else: panel_x = -(PANEL_WIDTH - COLLAPSED_REVEAL_WIDTH) if collapsed else 0.0 var target_position := Vector2(panel_x, bottom - height) var target_size := Vector2(PANEL_WIDTH, height) var clock_x: float = ( viewport_width - CLOCK_SIZE.x - CLOCK_EDGE_MARGIN if _dock_right else CLOCK_EDGE_MARGIN ) var clock_position := Vector2( clock_x, maxf( CLOCK_EDGE_MARGIN, target_position.y - CLOCK_SIZE.y - CLOCK_PANEL_GAP, ), ) var weather_icon_x: float = ( clock_position.x - WEATHER_ICON_GAP - WEATHER_ICON_SIZE.x if _dock_right else clock_position.x + CLOCK_SIZE.x + WEATHER_ICON_GAP ) var weather_icon_position := Vector2( weather_icon_x, clock_position.y ) var handle_stack_height := HANDLE_SIZE.y * 2.0 + HANDLE_GAP var handle_stack_top := ( bottom - COMPACT_HEIGHT * 0.5 - handle_stack_height * 0.5 ) var handle_x: float if _dock_right: handle_x = panel_x - HANDLE_SIZE.x else: handle_x = COLLAPSED_REVEAL_WIDTH if collapsed else PANEL_WIDTH var collapse_position := Vector2( handle_x, handle_stack_top + HANDLE_SIZE.y + HANDLE_GAP, ) var height_position := Vector2(handle_x, handle_stack_top) var unread_offset_x := -18.0 if _dock_right else 6.0 var unread_position := collapse_position + Vector2(unread_offset_x, -18.0) var hint_position := Vector2( ( viewport_width - _hint.size.x - HINT_EDGE_MARGIN if _dock_right else HINT_EDGE_MARGIN ), viewport_height - _hint.size.y - HINT_EDGE_MARGIN, ) if _height_tween != null and _height_tween.is_valid(): _height_tween.kill() if not animate: _panel.position = target_position _panel.size = target_size _clock_panel.position = clock_position _clock_panel.size = CLOCK_SIZE _weather_icon.position = weather_icon_position _weather_icon.size = WEATHER_ICON_SIZE _collapse_button.position = collapse_position _height_button.position = height_position _unread_indicator.position = unread_position _hint.position = hint_position return var scroll_state := _capture_history_scroll() _height_tween = create_tween().set_parallel(true) _height_tween.tween_property( _panel, "position", target_position, UIMotion.CHAT_RESIZE_DURATION ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _panel, "size", target_size, UIMotion.CHAT_RESIZE_DURATION ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _clock_panel, "position", clock_position, UIMotion.CHAT_RESIZE_DURATION, ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _weather_icon, "position", weather_icon_position, UIMotion.CHAT_RESIZE_DURATION, ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _collapse_button, "position", collapse_position, UIMotion.CHAT_RESIZE_DURATION, ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _height_button, "position", height_position, UIMotion.CHAT_RESIZE_DURATION, ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.tween_property( _unread_indicator, "position", unread_position, UIMotion.CHAT_RESIZE_DURATION, ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _height_tween.finished.connect(func() -> void: _restore_history_scroll(scroll_state) ) _hint.position = hint_position func _capture_history_scroll() -> Dictionary: if _history == null: return {"pinned": true, "value": 0.0} var scroll := _history.get_v_scroll_bar() if scroll == null: return {"pinned": true, "value": 0.0} var bottom := maxf(scroll.min_value, scroll.max_value - scroll.page) return { "pinned": scroll.value >= bottom - 2.0, "value": scroll.value, } func _restore_history_scroll(state: Dictionary) -> void: if _history == null: return var scroll := _history.get_v_scroll_bar() if scroll == null: return if bool(state.get("pinned", true)): scroll.value = maxf(scroll.min_value, scroll.max_value - scroll.page) else: scroll.value = clampf( float(state.get("value", 0.0)), scroll.min_value, maxf(scroll.min_value, scroll.max_value - scroll.page), ) func _on_viewport_resized() -> void: _layout_presentation(false) func _on_world_time_changed( _time_hours: float, _phase: WorldTimeService.Phase, ) -> void: if _clock_label != null and _world_time != null: _clock_label.text = _world_time.get_clock_text() func _on_world_weather_changed( weather: WorldWeatherService.Weather, _seconds_remaining: float, ) -> void: if _weather_icon != null: _weather_icon.set_weather(weather) func _on_exterior_handle_entered() -> void: _panel_hovered = true _update_panel_opacity(true) func _on_exterior_handle_exited() -> void: _panel_hovered = false _update_panel_opacity(true) 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)