Add multiplayer item use and session chat

This commit is contained in:
Alexander Sellite 2026-07-29 19:26:46 -04:00
parent 5e04b70609
commit 5dda74c9e4
27 changed files with 1361 additions and 31 deletions

241
ui/chat_ui.gd Normal file
View file

@ -0,0 +1,241 @@
class_name ChatUI
extends Control
const INPUT_OWNER: StringName = &"chat"
const COMPACT_SECONDS: float = 8.0
const SPEECH_SECONDS: float = 6.0
var _service: NetworkChatService
var _session: NetworkSession
var _spawn: PlayerSpawnService
var _player: Player
var _fishing_spot: FishingSpot
var _history: Label
var _entry: LineEdit
var _chat_button: Button
var _panel: PanelContainer
var _speech_layer: Control
var _speech: Dictionary[int, Dictionary] = {}
var _opened: bool = false
var _available: bool = false
var _last_message_time: float = -INF
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()
func setup(
service: NetworkChatService,
session: NetworkSession,
spawn: PlayerSpawnService,
player: Player,
fishing_spot: FishingSpot,
) -> void:
_service = service
_session = session
_spawn = spawn
_player = player
_fishing_spot = fishing_spot
_service.message_received.connect(_on_message)
_service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected)
_session.peer_removed.connect(_on_peer_removed)
_refresh_history()
func open_chat() -> void:
if (
_opened or not _available or _service == null
or not _session.is_gameplay_session_active()
):
return
_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)
_panel.show()
_entry.show()
_entry.grab_focus()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func set_available(value: bool) -> void:
_available = value
_chat_button.visible = value
if not value:
close_chat()
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)
_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_ESCAPE and _opened:
close_chat()
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
_refresh_visibility()
var now := Time.get_ticks_msec() / 1000.0
for peer_id: int in _speech.keys():
var state: Dictionary = _speech[peer_id]
var label := state.get("label") as Label
if label == null or now >= float(state.get("expires", 0.0)):
if label != null:
label.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:
label.hide()
continue
var world_position := avatar.get_body_center_position() + Vector3.UP * 1.2
if camera.is_position_behind(world_position):
label.hide()
continue
var screen_position := camera.unproject_position(world_position)
label.position = screen_position - Vector2(label.size.x * 0.5, 42.0)
label.show()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.position = Vector2(18, 390)
_panel.size = Vector2(390, 240)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
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, 150)
_history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_history.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(_history)
_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.hide()
box.add_child(_entry)
_chat_button = Button.new()
_chat_button.text = "chat"
_chat_button.position = Vector2(18, 570)
_chat_button.size = Vector2(76, 54)
_chat_button.pressed.connect(open_chat)
add_child(_chat_button)
_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)
_panel.hide()
func _send() -> void:
var body := _entry.text
if _service.send_local_message(body):
_entry.clear()
close_chat()
func _on_message(message: Dictionary) -> void:
_last_message_time = Time.get_ticks_msec() / 1000.0
_refresh_history()
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
return
var peer_id: int = message["sender_peer_id"]
_on_peer_removed(peer_id)
var label := Label.new()
label.text = "%s: %s" % [
str(message["sender_display_name"]), str(message["body"]).left(120),
]
label.custom_minimum_size = Vector2(80, 34)
label.size = Vector2(280, 70)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_speech_layer.add_child(label)
_speech[peer_id] = {
"label": label,
"expires": Time.get_ticks_msec() / 1000.0 + SPEECH_SECONDS,
}
func _on_history(_messages: Array[Dictionary]) -> void:
if _messages.is_empty():
for peer_id: int in _speech.keys():
_on_peer_removed(peer_id)
_refresh_history()
func _on_rejected(message: String) -> void:
_entry.placeholder_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()
return
if _opened:
_panel.show()
return
var recent := (
Time.get_ticks_msec() / 1000.0 - _last_message_time
< COMPACT_SECONDS
)
_panel.visible = recent and _service != null and not _service.get_history().is_empty()
func _on_peer_removed(peer_id: int) -> void:
var state: Dictionary = _speech.get(peer_id, {})
var label := state.get("label") as Label
if label != null:
label.queue_free()
_speech.erase(peer_id)

1
ui/chat_ui.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://canbrvh1cqkie

View file

@ -31,6 +31,7 @@ const PlayerItemEffectsType = preload(
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const ChatUIType = preload("res://ui/chat_ui.gd")
signal pixelation_settings_visibility_changed(is_visible: bool)
signal crisp_reset_focus_requested
@ -53,6 +54,7 @@ signal interactive_pointer_ui_changed(is_open: bool)
@onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _shop_prompt: PanelContainer = %ShopPrompt
@onready var _effect_status: Label = %EffectStatus
@onready var _chat_ui: ChatUIType = %ChatUI
@onready var _title_settings_panel: SettingsPanelType = (
$UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel
)
@ -70,6 +72,9 @@ var _item_effects: PlayerItemEffectsType
func _ready() -> void:
_pause_menu.chat_requested.connect(
func() -> void: call_deferred("_open_chat_from_pause")
)
_title_settings_panel.panel_visibility_changed.connect(
_on_settings_visibility_changed
)
@ -84,6 +89,10 @@ func _ready() -> void:
)
func _open_chat_from_pause() -> void:
_chat_ui.open_chat()
func setup(
player: PlayerType,
inventory: FishInventoryType,
@ -104,9 +113,22 @@ func setup(
network_session: NetworkSessionType,
network_sale_service: NetworkSaleService,
network_shop_service: NetworkShopService,
network_chat_service: NetworkChatService,
network_profile: NetworkProfilePreferences,
spawn_service: PlayerSpawnService,
) -> void:
_fishing_spot = fishing_spot
_item_effects = item_effects
_chat_ui.setup(
network_chat_service, network_session, spawn_service, player,
fishing_spot
)
_title_settings_panel.setup_network_profile(
network_profile, network_session
)
_pause_settings_panel.setup_network_profile(
network_profile, network_session
)
fishing_spot.status_changed.connect(_on_fishing_status_changed)
fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
fishing_spot.showcase_changed.connect(_on_showcase_changed)
@ -202,6 +224,7 @@ func get_pause_menu() -> PauseMenuType:
func set_gameplay_ui_enabled(enabled: bool) -> void:
_gameplay_ui_enabled = enabled
_refresh_chat_availability()
if not enabled:
close_player_menu_for_session_end()
_fishing_shop.close_for_session_end()
@ -217,6 +240,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
func set_system_menu_open(is_open: bool) -> void:
_system_menu_open = is_open
_refresh_chat_availability()
_hotbar_ui.visible = (
_gameplay_ui_enabled and not is_open and not _shop_open
)
@ -444,6 +468,7 @@ func _on_showcase_changed(
func _on_player_menu_visibility_changed(is_open: bool) -> void:
_player_menu_open = is_open
_refresh_chat_availability()
_hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled and not is_open
)
@ -456,6 +481,7 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void:
func _on_shop_visibility_changed(is_open: bool) -> void:
_shop_open = is_open
_refresh_chat_availability()
_hotbar_ui.visible = (
_gameplay_ui_enabled and not is_open and not _system_menu_open
)
@ -474,3 +500,14 @@ func _emit_interactive_pointer_ui_changed() -> void:
interactive_pointer_ui_changed.emit(
_system_menu_open or _player_menu_open or _shop_open
)
func _refresh_chat_availability() -> void:
if _chat_ui == null:
return
_chat_ui.set_available(
_gameplay_ui_enabled
and not _system_menu_open
and not _player_menu_open
and not _shop_open
)

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=13 format=3]
[gd_scene load_steps=14 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"]
[ext_resource type="PackedScene" path="res://ui/hotbar.tscn" id="7_hotbar"]
[ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"]
[ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"]
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.055, 0.105, 0.125, 1)
@ -185,6 +186,17 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
[node name="ChatUI" type="Control" parent="UIRoot"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("9_chat")
[node name="ScreenFade" type="ColorRect" parent="UIRoot"]
unique_name_in_owner = true
visible = false

View file

@ -28,6 +28,7 @@ signal reset_progress_requested
signal quit_requested
signal menu_visibility_changed(is_open: bool)
signal join_game_requested(endpoint: String)
signal chat_requested
enum ConfirmationAction {
NONE,
@ -87,6 +88,7 @@ func _ready() -> void:
_save_button.pressed.connect(_save_now)
%SettingsButton.pressed.connect(_open_settings)
%JoinGameButton.pressed.connect(_open_join_game)
%ChatButton.pressed.connect(_request_chat)
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
%ResetProgressButton.pressed.connect(_confirm_reset_progress)
%QuitButton.pressed.connect(_request_quit)
@ -105,6 +107,13 @@ func _ready() -> void:
call_deferred("_update_responsive_pause_stage")
func _request_chat() -> void:
if not visible or _action_in_progress or _root_transition_active:
return
close_menu(CloseReason.USER_RETURN, true)
chat_requested.emit()
func setup(
player: PlayerType,
save_manager: SaveManagerType,

View file

@ -77,8 +77,8 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("4_page")
page_id = &"pause"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
initial_focus_path = NodePath("BubbleCluster/ResumeButton")
back_focus_path = NodePath("BubbleCluster/ResumeButton")
maximum_layout_size = Vector2(720, 520)
@ -135,6 +135,18 @@ minimum_font_size = 16
maximum_font_size = 23
motion_phase = 2.05
[node name="ChatButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
text = "chat"
neutral_size = Vector2(112, 106)
desktop_anchor = Vector2(620, 255)
compact_anchor = Vector2(595, 270)
compact_minimum_size = Vector2(92, 88)
minimum_font_size = 15
maximum_font_size = 20
motion_phase = 2.3
[node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)

View file

@ -38,6 +38,7 @@ enum PresentationMode {
@onready var _controls_page: SettingsBubblePage = %ControlsPage
@onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage
@onready var _feedback: Label = %SettingsFeedback
@onready var _display_name_edit: LineEdit = %DisplayNameEdit
@onready var _world_value: BubbleButton = %WorldValue
@onready var _ui_value: BubbleButton = %UIValue
@ -75,6 +76,8 @@ var _auto_click_interval_value: float = 0.20
var _mouse_sensitivity: float = 0.005
var _controller_sensitivity: float = 2.5
var _invert_camera_y: bool = false
var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession
func _ready() -> void:
@ -98,6 +101,10 @@ func _ready() -> void:
%DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input)
%ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input)
%AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input)
_display_name_edit.text_submitted.connect(
func(_value: String) -> void: _apply_display_name()
)
_display_name_edit.focus_exited.connect(_apply_display_name)
for index: int in _world_options.size():
_world_options[index].pressed.connect(
_set_world_pixelation.bind(index + 1)
@ -143,6 +150,33 @@ func _ready() -> void:
call_deferred("_refresh_panel_size")
func setup_network_profile(
profile: NetworkProfilePreferences,
session: NetworkSession,
) -> void:
_network_profile = profile
_network_session = session
if _network_profile != null:
_display_name_edit.text = _network_profile.display_name
func _apply_display_name() -> void:
if _network_profile == null:
return
var value := _display_name_edit.text.strip_edges()
var accepted := (
_network_session.update_local_display_name(value)
if _network_session != null
else _network_profile.set_display_name(value)
)
if accepted:
_display_name_edit.text = _network_profile.display_name
_feedback.text = "display name saved."
else:
_display_name_edit.text = _network_profile.display_name
_feedback.text = "display name must be 124 plain-text characters."
func open_panel(
settings_manager: SettingsManagerType,
presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
@ -158,6 +192,8 @@ func open_panel(
)
_settings_manager = settings_manager
_load_controls()
if _network_profile != null:
_display_name_edit.text = _network_profile.display_name
_feedback.text = ""
show()
_page_stack.clear()

View file

@ -144,6 +144,31 @@ minimum_font_size = 14
maximum_font_size = 17
motion_phase = 4.8
[node name="DisplayNamePanel" type="PanelContainer" parent="RootPage"]
layout_mode = 0
offset_left = 318.0
offset_top = 430.0
offset_right = 618.0
offset_bottom = 510.0
[node name="VBox" type="VBoxContainer" parent="RootPage/DisplayNamePanel"]
layout_mode = 2
[node name="Label" type="Label" parent="RootPage/DisplayNamePanel/VBox"]
layout_mode = 2
text = "display name"
[node name="DisplayNameEdit" type="LineEdit" parent="RootPage/DisplayNamePanel/VBox"]
unique_name_in_owner = true
layout_mode = 2
max_length = 24
placeholder_text = "Player"
[node name="Help" type="Label" parent="RootPage/DisplayNamePanel/VBox"]
layout_mode = 2
text = "Shown to other players in multiplayer."
theme_override_font_sizes/font_size = 12
[node name="DisplayPage" type="Control" parent="."]
unique_name_in_owner = true
visible = false