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