diff --git a/README.md b/README.md index cfb7895..947b288 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A Godot 4.6 multiplayer cooperative game supporting both local and online multiplayer with physics-based interactions. +Start with arguments: +--host or --join # instantly hosts or joins a game +--room-debug # shows information above each room what types of puzzles it has, and if player entered the room or not... + ## Features ### Multiplayer Support diff --git a/src/scenes/chat_ui.tscn b/src/scenes/chat_ui.tscn new file mode 100644 index 0000000..642e936 --- /dev/null +++ b/src/scenes/chat_ui.tscn @@ -0,0 +1,66 @@ +[gd_scene format=3 uid="uid://chatui1234567"] + +[ext_resource type="Script" path="res://scripts/chat_ui.gd" id="1_chat_ui"] +[ext_resource type="FontFile" path="res://assets/fonts/Metropolis/TrueType/Metropolis-Regular.ttf" id="2_metropolis"] + +[sub_resource type="Theme" id="Theme_metropolis"] +default_font = ExtResource("2_metropolis") +default_font_size = 12 + +[node name="ChatUI" type="CanvasLayer" unique_id=2000000000] +layer = 200 +script = ExtResource("1_chat_ui") + +[node name="ChatContainer" type="Control" parent="." unique_id=3000000000] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 + +[node name="BottomLeft" type="MarginContainer" parent="ChatContainer" unique_id=4000000000] +layout_mode = 1 +anchors_preset = 1 +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +offset_left = 10.0 +offset_top = -200.0 +offset_right = 400.0 +offset_bottom = -10.0 +grow_horizontal = 0 +grow_vertical = 0 + +[node name="Background" type="ColorRect" parent="ChatContainer/BottomLeft" unique_id=5000000000] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.5) +mouse_filter = 1 +visible = false + +[node name="VBox" type="VBoxContainer" parent="ChatContainer/BottomLeft" unique_id=6000000000] +layout_mode = 2 + +[node name="MessageScroll" type="ScrollContainer" parent="ChatContainer/BottomLeft/VBox" unique_id=7000000000] +layout_mode = 2 +size_flags_vertical = 3 +custom_minimum_size = Vector2(380, 150) + +[node name="MessageList" type="VBoxContainer" parent="ChatContainer/BottomLeft/VBox/MessageScroll" unique_id=8000000000] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="ChatInput" type="LineEdit" parent="ChatContainer/BottomLeft/VBox" unique_id=10000000000] +layout_mode = 2 +size_flags_horizontal = 3 +theme = SubResource("Theme_metropolis") +placeholder_text = "Type a message..." +visible = false diff --git a/src/scripts/chat_ui.gd b/src/scripts/chat_ui.gd new file mode 100644 index 0000000..526c6da --- /dev/null +++ b/src/scripts/chat_ui.gd @@ -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 diff --git a/src/scripts/chat_ui.gd.uid b/src/scripts/chat_ui.gd.uid new file mode 100644 index 0000000..71afc9b --- /dev/null +++ b/src/scripts/chat_ui.gd.uid @@ -0,0 +1 @@ +uid://dxglerf7e7p5j diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index c7a4c77..361833b 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -36,6 +36,9 @@ func _ready(): network_manager.player_connected.connect(_on_player_connected) network_manager.player_disconnected.connect(_on_player_disconnected) + # Create chat UI + _create_chat_ui() + # Generate dungeon on host if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): print("GameWorld: _ready() - Will generate dungeon (is_server: ", multiplayer.is_server(), ", has_peer: ", multiplayer.has_multiplayer_peer(), ")") @@ -59,6 +62,10 @@ func _spawn_all_players(): func _on_player_connected(peer_id: int, player_info: Dictionary): print("GameWorld: Player connected signal received for peer ", peer_id, " with info: ", player_info) + # Send join message to chat (only on server to avoid duplicates) + if multiplayer.is_server(): + _send_player_join_message(peer_id, player_info) + # Reset ready status for this peer (they need to notify again after spawning) if multiplayer.is_server(): clients_ready[peer_id] = false @@ -139,8 +146,13 @@ func _sync_existing_enemies_to_client(client_peer_id: int): _sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type) print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type) -func _on_player_disconnected(peer_id: int): +func _on_player_disconnected(peer_id: int, player_info: Dictionary): print("GameWorld: Player disconnected - ", peer_id) + + # Send disconnect message to chat (only on server to avoid duplicates) + if multiplayer.is_server(): + _send_player_disconnect_message(peer_id, player_info) + player_manager.despawn_players_for_peer(peer_id) @rpc("authority", "reliable") @@ -2030,6 +2042,60 @@ func _initialize_hud(): else: print("GameWorld: HUD not found or not ready - this is OK if HUD scene is missing") +func _create_chat_ui(): + # Load chat UI scene + var chat_ui_scene = load("res://scenes/chat_ui.tscn") + if not chat_ui_scene: + push_error("GameWorld: Could not load chat_ui.tscn scene!") + return + + var chat_ui = chat_ui_scene.instantiate() + if chat_ui: + add_child(chat_ui) + print("GameWorld: Chat UI scene instantiated and added to scene tree") + else: + push_error("GameWorld: Failed to instantiate chat_ui.tscn!") + +func _send_player_join_message(peer_id: int, player_info: Dictionary): + # Send a chat message when a player joins + # Only send from server to avoid duplicate messages + if not multiplayer.is_server(): + return + + # Get player name (use first player name from the player_info) + var player_names = player_info.get("player_names", []) + var player_name = "" + if player_names.size() > 0: + player_name = player_names[0] + else: + player_name = "Player%d" % peer_id + + # Get chat UI + var chat_ui = get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("send_system_message"): + var message = "%s joined the game" % player_name + chat_ui.send_system_message(message) + +func _send_player_disconnect_message(peer_id: int, player_info: Dictionary): + # Send a chat message when a player disconnects + # Only send from server to avoid duplicate messages + if not multiplayer.is_server(): + return + + # Get player name from player_info + var player_name = "" + var player_names = player_info.get("player_names", []) + if player_names.size() > 0: + player_name = player_names[0] + else: + player_name = "Player%d" % peer_id + + # Get chat UI + var chat_ui = get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("send_system_message"): + var message = "%s left/disconnected" % player_name + chat_ui.send_system_message(message) + func _create_level_complete_ui_programmatically() -> Node: # Create level complete UI programmatically var canvas_layer = CanvasLayer.new() diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 3c2b162..15db158 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -4,7 +4,7 @@ extends Node # Supports both hosting and joining games signal player_connected(peer_id, player_info) -signal player_disconnected(peer_id) +signal player_disconnected(peer_id, player_info) signal connection_failed() signal connection_succeeded() @@ -82,9 +82,12 @@ func _on_peer_connected(id: int): # Called when a peer disconnects func _on_peer_disconnected(id: int): print("Peer disconnected: ", id) + # Get player_info before erasing it + var player_info = {} if players_info.has(id): + player_info = players_info[id] players_info.erase(id) - player_disconnected.emit(id) + player_disconnected.emit(id, player_info) # Called on client when successfully connected to server func _on_connected_to_server():