add a chat window

This commit is contained in:
2026-01-11 14:07:53 +01:00
parent 26de2ca6e0
commit 7b030cb3af
6 changed files with 496 additions and 3 deletions

353
src/scripts/chat_ui.gd Normal file
View File

@@ -0,0 +1,353 @@
extends CanvasLayer
# Chat UI - Handles in-game chat with network sync
const MAX_MESSAGES = 100 # Maximum messages to keep in history
const MESSAGE_FADE_TIME = 5.0 # Seconds before message starts fading
const MESSAGE_FADE_DURATION = 2.0 # Duration of fade out
const RECENT_MESSAGE_TIME = 7.0 # Show background if message is this recent
# Player colors for chat names
var player_colors = [
Color.RED,
Color.GREEN,
Color.BLUE,
Color.ORANGE,
Color(0.5, 0.0, 0.5) # Purple
]
var chat_open: bool = false
var messages: Array = [] # Array of {timestamp: String, player_name: String, message: String, time: float}
var network_manager: Node = null
# UI Nodes
var chat_container: Control = null
var message_scroll: ScrollContainer = null
var message_list: VBoxContainer = null
var chat_input: LineEdit = null
var background: ColorRect = null
# Metropolis font
var metropolis_font: FontFile = null
func _ready():
network_manager = get_node_or_null("/root/NetworkManager")
# Load Metropolis font
var font_path = "res://assets/fonts/Metropolis/TrueType/Metropolis-Regular.ttf"
if ResourceLoader.exists(font_path):
metropolis_font = load(font_path)
# Get UI nodes from scene
chat_container = get_node_or_null("ChatContainer")
background = get_node_or_null("ChatContainer/BottomLeft/Background")
message_scroll = get_node_or_null("ChatContainer/BottomLeft/VBox/MessageScroll")
message_list = get_node_or_null("ChatContainer/BottomLeft/VBox/MessageScroll/MessageList")
chat_input = get_node_or_null("ChatContainer/BottomLeft/VBox/ChatInput")
# Apply Metropolis font to chat input if available
if chat_input and metropolis_font:
chat_input.add_theme_font_override("font", metropolis_font)
chat_input.add_theme_font_size_override("font_size", 14)
# Connect input signals
if chat_input:
chat_input.text_submitted.connect(_on_chat_input_submitted)
chat_input.focus_exited.connect(_on_chat_input_focus_exited)
visible = true # Chat UI is always visible (background and messages fade)
# Hide scrollbar by default (will show when chat is opened)
if message_scroll:
var scroll_bar = message_scroll.get_v_scroll_bar()
if scroll_bar:
scroll_bar.visible = false
func _input(event):
# Toggle chat with ENTER key
if event is InputEventKey and event.pressed and event.keycode == KEY_ENTER:
if not chat_open:
_open_chat()
else:
_send_and_close_chat()
get_viewport().set_input_as_handled()
func _open_chat():
chat_open = true
chat_input.visible = true
chat_input.text = ""
chat_input.grab_focus()
background.visible = true
# Show scrollbar when chat is open
if message_scroll:
var scroll_bar = message_scroll.get_v_scroll_bar()
if scroll_bar:
scroll_bar.visible = true
# Make all messages fully visible when chat is opened
_show_all_messages()
# Lock player controls
_lock_player_controls(true)
# Scroll to bottom
call_deferred("_scroll_to_bottom")
func _send_and_close_chat():
var message_text = chat_input.text.strip_edges()
chat_input.text = ""
chat_open = false
chat_input.visible = false
chat_input.release_focus()
# Hide scrollbar when chat is closed
if message_scroll:
var scroll_bar = message_scroll.get_v_scroll_bar()
if scroll_bar:
scroll_bar.visible = false
# Unlock player controls
_lock_player_controls(false)
# Send message if not empty
if message_text.length() > 0:
_send_message(message_text)
# Update background visibility
_update_background_visibility()
func _on_chat_input_submitted(text: String):
_send_and_close_chat()
func _on_chat_input_focus_exited():
# Don't close chat on focus loss - only close when ENTER is pressed
pass
func send_system_message(message: String):
# Send a system message (appears as "System" instead of player name)
# System messages are always sent from server
if multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
# Server broadcasts to all clients and also shows locally
_add_message("System", message) # Show locally first
_receive_message.rpc("System", message) # Broadcast to clients
else:
# Client sends to server (system messages should only come from server)
pass
else:
# Offline mode - just show locally
_add_message("System", message)
func _send_message(message: String):
if not network_manager:
return
# Get player name
var player_name = _get_player_name()
# Send to server (or broadcast if we're the server)
if multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
# Server broadcasts to all clients and also shows locally
_add_message(player_name, message) # Show locally first
_receive_message.rpc(player_name, message) # Broadcast to clients
else:
# Client sends to server
_send_message_to_server.rpc_id(1, player_name, message)
else:
# Offline mode - just show locally
_add_message(player_name, message)
@rpc("any_peer", "reliable")
func _send_message_to_server(player_name: String, message: String):
# Server receives message from client and broadcasts to all
if multiplayer.is_server():
# Show message on server first
_add_message(player_name, message)
# Then broadcast to all clients
_receive_message.rpc(player_name, message)
@rpc("authority", "reliable")
func _receive_message(player_name: String, message: String):
# All clients (and server) receive the message
_add_message(player_name, message)
func _get_player_color(player_name: String) -> Color:
# System messages use gray color
if player_name == "System":
return Color.GRAY
# Extract peer_id from player name (format: "PlayerX_Y" or "PlayerX")
var peer_id = 0
if player_name.begins_with("Player"):
var parts = player_name.substr(6).split("_") # Remove "Player" prefix
if parts.size() > 0:
var peer_str = parts[0]
if peer_str.is_valid_int():
peer_id = peer_str.to_int()
# Use peer_id to consistently assign colors
return player_colors[peer_id % player_colors.size()]
func _add_message(player_name: String, message: String):
if not message_list:
print("ChatUI: ERROR - message_list is null, cannot add message!")
return
# Get current time
var time_dict = Time.get_time_dict_from_system()
var timestamp = "%02d:%02d:%02d" % [time_dict.hour, time_dict.minute, time_dict.second]
var current_time = Time.get_ticks_msec() / 1000.0
# Add to messages array
messages.append({
"timestamp": timestamp,
"player_name": player_name,
"message": message,
"time": current_time
})
# Limit message history
if messages.size() > MAX_MESSAGES:
messages.pop_front()
# Get player color
var player_color = _get_player_color(player_name)
var color_hex = "#%02x%02x%02x" % [int(player_color.r * 255), int(player_color.g * 255), int(player_color.b * 255)]
# Create message label using RichTextLabel for colored text
var message_label = RichTextLabel.new()
message_label.name = "Message_%d" % messages.size()
message_label.bbcode_enabled = true
message_label.scroll_active = false
message_label.fit_content = true
message_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
message_label.clip_contents = false
# Format message with colored player name using BBCode
var formatted_text = "%s [color=%s]%s[/color]: %s" % [timestamp, color_hex, player_name, message]
message_label.text = formatted_text
# Apply Metropolis font if available
if metropolis_font:
message_label.add_theme_font_override("normal_font", metropolis_font)
message_label.add_theme_font_size_override("normal_font_size", 12)
message_label.modulate.a = 1.0
# Add to message list (at the end, so newest messages appear closest to input)
# Messages flow upwards - oldest at top, newest at bottom
message_list.add_child(message_label)
# Scroll to bottom
call_deferred("_scroll_to_bottom")
# Update background visibility
_update_background_visibility()
func _scroll_to_bottom():
if message_scroll:
await get_tree().process_frame # Wait for layout to update
var scroll_bar = message_scroll.get_v_scroll_bar()
if scroll_bar:
message_scroll.scroll_vertical = int(scroll_bar.max_value)
func _update_background_visibility():
if not background:
return
var current_time = Time.get_ticks_msec() / 1000.0
var has_recent_message = false
# Check if there are any recent messages
for msg in messages:
var age = current_time - msg.time
if age < RECENT_MESSAGE_TIME:
has_recent_message = true
break
# Show background if chat is open OR if there are recent messages
background.visible = chat_open or has_recent_message
# Update message fade (only when chat is closed)
if not chat_open:
_update_message_fades()
func _show_all_messages():
# Make all messages fully visible (used when chat is opened)
if not message_list:
return
var message_labels = []
for child in message_list.get_children():
if child is RichTextLabel:
message_labels.append(child)
for label in message_labels:
label.modulate.a = 1.0
func _update_message_fades():
if not message_list:
return
# Don't fade messages when chat is open - they should all be visible
if chat_open:
return
var current_time = Time.get_ticks_msec() / 1000.0
# Update fade for each message label
# Messages are in chronological order (oldest first, newest last)
var message_labels = []
for child in message_list.get_children():
if child is RichTextLabel:
message_labels.append(child)
# Match labels to messages (same order - oldest first, newest last)
for i in range(min(messages.size(), message_labels.size())):
var msg = messages[i]
var label = message_labels[i]
var age = current_time - msg.time
if age > MESSAGE_FADE_TIME:
# Start fading
var fade_progress = (age - MESSAGE_FADE_TIME) / MESSAGE_FADE_DURATION
label.modulate.a = max(0.0, 1.0 - fade_progress)
else:
# Fully visible
label.modulate.a = 1.0
func _process(_delta):
# Update message fades and background visibility
if not chat_open:
_update_background_visibility()
_update_message_fades()
func _get_player_name() -> String:
# Get local player name
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var player_manager = game_world.get_node_or_null("PlayerManager")
if player_manager:
var local_players = player_manager.get_local_players()
if local_players.size() > 0:
var player = local_players[0]
# peer_id and local_player_index are always defined in player.gd
return "Player%d_%d" % [player.peer_id, player.local_player_index + 1]
# Fallback
if multiplayer.has_multiplayer_peer():
return "Player%d" % multiplayer.get_unique_id()
return "Player"
func _lock_player_controls(lock: bool):
# Lock/unlock controls for all local players
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var player_manager = game_world.get_node_or_null("PlayerManager")
if player_manager:
var local_players = player_manager.get_local_players()
for player in local_players:
# controls_disabled is a property in player.gd, so we can set it directly
player.controls_disabled = lock