diff --git a/INSTALL_MATCHBOX.md b/INSTALL_MATCHBOX.md new file mode 100644 index 0000000..70f6c77 --- /dev/null +++ b/INSTALL_MATCHBOX.md @@ -0,0 +1,142 @@ +# Installing Matchbox Signaling Server + +## Step 1: Install Rust (if not already installed) + +```bash +# Install Rust using rustup (recommended) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Follow the prompts, then restart your shell or run: +source "$HOME/.cargo/env" + +# Verify installation +cargo --version +``` + +## Step 2: Install Matchbox Server + +```bash +# Install matchbox_server +cargo install matchbox_server + +# This will take a few minutes to compile +``` + +## Step 3: Run Matchbox Server + +```bash +# Run on default port (3536) +matchbox_server + +# Or specify a port +matchbox_server --port 3536 + +# Run in background with nohup +nohup matchbox_server --port 3536 > matchbox.log 2>&1 & + +# Or with systemd (recommended for production) +# See systemd service example below +``` + +## Step 4: Configure Godot to Use Matchbox + +Update `network_manager.gd` to connect to your matchbox server. + +## Alternative: Use Pre-built Binary + +If you don't want to install Rust, download a pre-built binary: + +```bash +# Download latest release +wget https://github.com/johanhelsing/matchbox/releases/download/v0.9.0/matchbox_server-x86_64-unknown-linux-gnu.tar.gz + +# Extract +tar -xzf matchbox_server-x86_64-unknown-linux-gnu.tar.gz + +# Make executable +chmod +x matchbox_server + +# Run it +./matchbox_server --port 3536 +``` + +## Systemd Service (Production) + +Create `/etc/systemd/system/matchbox.service`: + +```ini +[Unit] +Description=Matchbox WebRTC Signaling Server +After=network.target + +[Service] +Type=simple +User=oldcan +WorkingDirectory=/home/oldcan +ExecStart=/home/oldcan/.cargo/bin/matchbox_server --port 3536 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Then: + +```bash +# Reload systemd +sudo systemctl daemon-reload + +# Start service +sudo systemctl start matchbox + +# Enable on boot +sudo systemctl enable matchbox + +# Check status +sudo systemctl status matchbox + +# View logs +sudo journalctl -u matchbox -f +``` + +## Testing + +Once running, test the connection: + +```bash +# Check if it's listening +netstat -tulpn | grep 3536 + +# Or +ss -tulpn | grep 3536 +``` + +Your matchbox server will be available at: +- `ws://ruinborn.thefirstboss.com:3536` + +## Firewall Configuration + +Make sure port 3536 is open: + +```bash +# UFW +sudo ufw allow 3536/tcp + +# Or iptables +sudo iptables -A INPUT -p tcp --dport 3536 -j ACCEPT +``` + +## Quick Start Commands + +```bash +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" + +# Install Matchbox +cargo install matchbox_server + +# Run Matchbox +matchbox_server --port 3536 +``` diff --git a/MULTIPLAYER_WEBRTC_GUIDE.md b/MULTIPLAYER_WEBRTC_GUIDE.md new file mode 100644 index 0000000..a9aa4da --- /dev/null +++ b/MULTIPLAYER_WEBRTC_GUIDE.md @@ -0,0 +1,172 @@ +# WebRTC Multiplayer Guide + +## How WebRTC Multiplayer Works + +### Architecture + +Your game now supports two networking modes: + +1. **ENet (Native builds)**: Windows/Linux/Mac players connect via IP:Port +2. **WebRTC (Web builds)**: Browser players connect via Room IDs + +**Important**: ENet and WebRTC are NOT compatible with each other! +- PC (ENet) players can only play with other PC (ENet) players +- Browser (WebRTC) players can only play with other Browser (WebRTC) players + +### Your Server Setup + +- **Matchbox Signaling**: `ws://ruinborn.thefirstboss.com:3536` +- **STUN Server**: `stun:ruinborn.thefirstboss.com:3578` + +## How Players Find Each Other + +### WebRTC/Browser Mode (Room-Based) + +1. **Host creates a game**: + - Clicks "Host Game" + - Game generates a 6-character **Room Code** (e.g., "ABC123") + - Share this code with friends + +2. **Players join**: + - Enter the **Room Code** in the "Join Game" field + - Click "Join" + - Matchbox connects all players in the same room + +### Example Flow + +``` +Host (Browser): +- Clicks "Host" +- Gets Room Code: "XYZ789" +- Tells friend: "Join room XYZ789" + +Player (Browser): +- Types "XYZ789" in join field +- Clicks "Join" +- Connected! +``` + +## Testing Scenarios + +### Scenario 1: Browser to Browser (WebRTC) +✅ **Works!** +``` +1. Open game in Chrome: host.com/game +2. Host creates game → Gets code "ABC123" +3. Open game in Firefox: host.com/game +4. Join with code "ABC123" +5. Both connected via WebRTC! +``` + +### Scenario 2: PC to Browser (Mixed) +❌ **Doesn't Work!** +``` +PC (ENet) ←→ Browser (WebRTC) = INCOMPATIBLE +``` + +### Scenario 3: Phone Browser to PC Browser (WebRTC) +✅ **Works!** +``` +1. PC Browser hosts → code "XYZ456" +2. Phone browser joins "XYZ456" +3. Connected via WebRTC! +``` + +### Scenario 4: PC to PC (ENet) +✅ **Works!** +``` +1. PC1 hosts → Shows IP: 192.168.1.100 +2. PC2 joins → Enters "192.168.1.100" +3. Connected via ENet! +``` + +## Can PC Host and Browser Join? + +**Short Answer**: No, not with the current setup. + +**Long Answer**: ENet (PC) and WebRTC (Browser) use completely different protocols. They can't talk to each other directly. + +**Solution**: If you want cross-platform play, you have two options: + +### Option A: Make PC Use WebRTC Too + +Update PC builds to also use WebRTC when you want to play with browser users: + +```gdscript +# Force WebRTC mode even on native builds +use_webrtc = true # in network_manager.gd +``` + +Then PC and Browser can use the same room codes! + +### Option B: Use a Dedicated Server + +Run a dedicated server that: +- Accepts both ENet and WebRTC connections +- Bridges between them + +This is more complex but allows true cross-platform play. + +## Room Codes + +Room codes are automatically generated as 6-character codes: +- Easy to share (just text it!) +- No IP addresses to remember +- Works through NATs/firewalls + +Example codes: `ABC123`, `XYZ789`, `PLAYER`, `GAME42` + +## Exporting for Web + +1. **Export HTML5**: + - Project → Export → Add → HTML5 (WebAssembly) + - Export the project + +2. **Host on Web Server**: + ```bash + # Your files + python -m http.server 8000 + ``` + +3. **Players Access**: + - `http://yoursite.com/game` + - Works on PC browsers, phone browsers, tablets! + +## Security Notes + +- Room codes are NOT secure (anyone with code can join) +- For production, add: + - Password-protected rooms + - Room capacity limits + - Ban/kick functionality + - Room expiration + +## Troubleshooting + +**"Connection failed" in browser:** +- Check matchbox server is running: `netstat -tulpn | grep 3536` +- Check firewall allows port 3536 +- Check browser console for errors + +**"Can't see other players":** +- Make sure both players entered the SAME room code +- Check both are using browser builds (not mixing PC/browser) + +**"Stuck on connecting":** +- STUN server must be reachable +- Check port 3578 is open +- Try different browsers + +## Current Limitations + +1. PC (ENet) ←/→ Browser (WebRTC) can't play together +2. Need to share room codes manually (no lobby browser yet) +3. No reconnection if disconnected (must rejoin) + +## Next Steps + +To improve multiplayer: +1. Add lobby browser (see active rooms) +2. Add friend codes/invites +3. Add voice chat (WebRTC supports it!) +4. Add dedicated server for PC↔Browser bridge diff --git a/NETWORK_MODE_GUIDE.md b/NETWORK_MODE_GUIDE.md new file mode 100644 index 0000000..40403f8 --- /dev/null +++ b/NETWORK_MODE_GUIDE.md @@ -0,0 +1,166 @@ +# Network Mode Selection Guide + +## New Features + +### 1. **Network Mode Dropdown** (PC Only) + +On PC builds, you'll now see a dropdown in the main menu: +- **ENet (PC/LAN)**: Traditional IP-based networking for PC players +- **WebRTC (Browser/Web)**: Web-compatible networking using room codes + +The dropdown is automatically hidden on web builds (WebRTC is auto-selected). + +### 2. **--webrtc Command Line Flag** + +Force WebRTC mode on PC builds: + +```bash +# Start as host with WebRTC +godot --path . -- --host --webrtc + +# Join with WebRTC +godot --path . -- --join --webrtc --address=ABC123 + +# Example with room code +godot --path . -- --join --webrtc --address=XYZ789 +``` + +## Usage Examples + +### PC to PC (Same LAN) - ENet Mode + +**PC 1 (Host):** +1. Select "ENet (PC/LAN)" in dropdown +2. Click "Host Game" +3. Note your IP (e.g., 192.168.1.100) + +**PC 2 (Join):** +1. Select "ENet (PC/LAN)" in dropdown +2. Enter host IP in "Server Address" +3. Click "Join Game" + +### PC to PC (Internet) - WebRTC Mode + +**PC 1 (Host):** +1. Select "WebRTC (Browser/Web)" in dropdown +2. Click "Host Game" +3. Share the 6-character room code (e.g., "ABC123") + +**PC 2 (Join):** +1. Select "WebRTC (Browser/Web)" in dropdown +2. Enter room code in "Server Address" +3. Click "Join Game" + +### PC to Browser - WebRTC Mode + +**PC (Host):** +1. Select "WebRTC (Browser/Web)" in dropdown +2. Click "Host Game" +3. Share room code + +**Browser (Join):** +1. Enter room code (WebRTC auto-detected) +2. Click "Join Game" + +### Browser to Browser - WebRTC Mode + +**Browser 1 (Host):** +1. Click "Host Game" +2. Share room code + +**Browser 2 (Join):** +1. Enter room code +2. Click "Join Game" + +## Command Line Examples + +### Test with WebRTC + +```bash +# Terminal 1 - Host with WebRTC +godot --path . -- --host --webrtc + +# Terminal 2 - Join with WebRTC (room code from Terminal 1) +godot --path . -- --join --webrtc --address=ABC123 +``` + +### Test with ENet (Default) + +```bash +# Terminal 1 - Host +godot --path . -- --host + +# Terminal 2 - Join +godot --path . -- --join --address=127.0.0.1 +``` + +### Test with Multiple Local Players + +```bash +# Host with 2 local players using WebRTC +godot --path . -- --host --webrtc --players=2 + +# Join with 2 local players +godot --path . -- --join --webrtc --address=ABC123 --players=2 +``` + +## When to Use Each Mode + +### Use **ENet** When: +- Playing on same LAN (local network) +- Both players are on PC (Windows/Linux/Mac) +- You want lower latency +- You have direct network access + +### Use **WebRTC** When: +- Playing over the internet (through NATs/firewalls) +- Playing with browser users +- Playing on mobile browsers +- You want easy room code sharing (no IP addresses) + +## Technical Details + +### ENet +- Protocol: UDP +- Connection: Direct IP:Port +- Latency: Lower (direct connection) +- NAT Traversal: Requires port forwarding +- Compatibility: PC only + +### WebRTC +- Protocol: UDP/DTLS via WebRTC +- Connection: Room codes via signaling server +- Latency: Slightly higher (STUN/relay) +- NAT Traversal: Automatic (STUN/TURN) +- Compatibility: PC, Browser, Mobile + +### Your Servers +- **Matchbox Signaling**: `ws://ruinborn.thefirstboss.com:3536` +- **STUN Server**: `stun:ruinborn.thefirstboss.com:3578` + +## Troubleshooting + +**Dropdown not visible:** +- This is normal on web builds (WebRTC auto-selected) + +**Can't connect in ENet mode:** +- Check firewall (port 21212) +- Verify IP address is correct +- Try WebRTC mode instead + +**Can't connect in WebRTC mode:** +- Verify room code matches exactly +- Check matchbox server is running +- Try ENet mode for LAN play + +**Room code not showing:** +- Only shown in console when hosting with WebRTC +- Will be displayed in UI in future updates + +## Future Improvements + +- [ ] Display room code in UI (not just console) +- [ ] Copy room code to clipboard button +- [ ] Lobby browser (see active rooms) +- [ ] Remember last used network mode +- [ ] Auto-detect best mode for connection diff --git a/WEBRTC_SETUP.md b/WEBRTC_SETUP.md new file mode 100644 index 0000000..d070cb5 --- /dev/null +++ b/WEBRTC_SETUP.md @@ -0,0 +1,133 @@ +# WebRTC Multiplayer Setup + +The game now supports WebRTC multiplayer for WebAssembly (browser) builds! + +## How It Works + +The `NetworkManager` automatically detects the platform: +- **Native builds** (Windows, Linux, Mac): Uses **ENet** (traditional UDP networking) +- **Web builds**: Uses **WebRTC** (peer-to-peer browser networking) + +## Important Note About WebRTC + +WebRTC requires a **signaling mechanism** to establish connections between peers. This is different from ENet which uses direct IP:port connections. + +### Current Implementation Status + +✅ **Done:** +- Auto-detection of web platform +- WebRTCMultiplayerPeer creation for host and client +- Basic connection setup +- **STUN server configured** (`stun:ruinborn.thefirstboss.com:3578`) for NAT traversal + +⚠️ **Requires Additional Setup:** +- **Signaling server** to exchange connection info between peers +- Session Description Protocol (SDP) offer/answer exchange +- ICE candidate exchange + +## Quick Setup Options + +### Option 1: Use a Public Signaling Server (Easiest) + +Use a service like: +- [peerjs-server](https://github.com/peers/peerjs-server) +- [Matchbox](https://github.com/johanhelsing/matchbox) (Godot-specific) +- Roll your own simple WebSocket server + +### Option 2: Manual Connection (For Testing) + +For local testing, you can manually exchange connection strings: +1. Host creates a game +2. Host copies their connection info +3. Joiner pastes host's connection info +4. Joiner generates their own connection info +5. Host pastes joiner's connection info + +### Option 3: Implement Signaling Server (Recommended for Production) + +Create a simple WebSocket signaling server that: +1. Maintains a list of active lobbies +2. Relays SDP offers/answers between peers +3. Relays ICE candidates between peers + +## Example: Using Matchbox (Recommended) + +Matchbox is a simple signaling server specifically for Godot WebRTC: + +```bash +# Install matchbox server +cargo install matchbox_server + +# Run the server +matchbox_server +``` + +Then update the code to connect to your matchbox server when in web mode. + +## Testing WebRTC Locally + +1. **Export for HTML5**: + - Project -> Export -> Add -> HTML5 (WebAssembly) + - Export the project + +2. **Run a local web server**: + ```bash + # Python 3 + python -m http.server 8000 + + # Or use any web server + ``` + +3. **Open in browsers**: + - Host: `http://localhost:8000` + - Client: `http://localhost:8000` (in another browser tab/window) + +4. **Connect**: + - One browser hosts + - Other browser joins + - (Requires signaling server to be running) + +## Alternative: WebSocket Multiplayer + +If WebRTC setup is too complex, you can use WebSocketMultiplayerPeer instead: + +```gdscript +# In network_manager.gd, replace WebRTCMultiplayerPeer with: +var peer = WebSocketMultiplayerPeer.new() + +# For host: +peer.create_server(port) + +# For client: +peer.create_client("ws://your-server:port") +``` + +WebSocket is simpler but requires a dedicated server (can't be P2P like WebRTC). + +## Production Deployment + +For production, you'll need: +1. A signaling server (for WebRTC) or game server (for WebSocket) +2. HTTPS hosting (required for WebRTC in browsers) +3. Proper CORS configuration +4. STUN/TURN servers for NAT traversal (optional but recommended) + +## Current Status + +The game will: +- ✅ Detect web platform automatically +- ✅ Create WebRTC peers +- ⚠️ Require signaling server setup to actually connect + +**To complete the setup**, implement one of the options above based on your needs. + +## Quick Recommendation + +**For Testing**: Use Matchbox (Option 3) +**For Production**: Implement a proper signaling server with lobby/matchmaking + +## Resources + +- [Godot WebRTC Documentation](https://docs.godotengine.org/en/stable/tutorials/networking/webrtc.html) +- [Matchbox Server](https://github.com/johanhelsing/matchbox) +- [WebRTC for Godot Tutorial](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html#webrtc) diff --git a/src/assets/audio/music/Gelhein - Evil.mp3 b/src/assets/audio/music/Gelhein - Evil.mp3 new file mode 100644 index 0000000..222241b Binary files /dev/null and b/src/assets/audio/music/Gelhein - Evil.mp3 differ diff --git a/src/assets/audio/music/Gelhein - Evil.mp3.import b/src/assets/audio/music/Gelhein - Evil.mp3.import new file mode 100644 index 0000000..23cef95 --- /dev/null +++ b/src/assets/audio/music/Gelhein - Evil.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://ba6csajuxujrg" +path="res://.godot/imported/Gelhein - Evil.mp3-bc2ead9945dee5ecfc0f8aff80053da8.mp3str" + +[deps] + +source_file="res://assets/audio/music/Gelhein - Evil.mp3" +dest_files=["res://.godot/imported/Gelhein - Evil.mp3-bc2ead9945dee5ecfc0f8aff80053da8.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/src/export_presets.cfg b/src/export_presets.cfg index 12cf959..3f03279 100644 --- a/src/export_presets.cfg +++ b/src/export_presets.cfg @@ -79,7 +79,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="webrtc/webrtc.gdextension" export_path="../export/www/index.html" patches=PackedStringArray() patch_delta_encoding=false diff --git a/src/project.godot b/src/project.godot index 26a9571..1c7e1b6 100644 --- a/src/project.godot +++ b/src/project.godot @@ -24,12 +24,14 @@ buses/default_bus_layout="uid://psistrevppd1" [autoload] NetworkManager="*res://scripts/network_manager.gd" +LogManager="*res://scripts/log_manager.gd" [display] window/size/viewport_width=1280 window/size/viewport_height=720 window/stretch/mode="canvas_items" +window/stretch/aspect="expand" window/stretch/scale_mode="integer" [editor_plugins] @@ -65,6 +67,7 @@ move_down={ grab={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) ] } throw={ @@ -75,6 +78,7 @@ throw={ attack={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null) ] } diff --git a/src/scenes/ingame_hud.tscn b/src/scenes/ingame_hud.tscn index a5c713f..ad11a22 100644 --- a/src/scenes/ingame_hud.tscn +++ b/src/scenes/ingame_hud.tscn @@ -158,6 +158,27 @@ horizontal_alignment = 1 layout_mode = 2 texture_progress = ExtResource("4_hearts_filled") +[node name="CenterTop" type="MarginContainer" parent="." unique_id=22752256] +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 0.0 +anchor_right = 0.5 +anchor_bottom = 0.0 +offset_left = -150.0 +offset_top = 8.0 +offset_right = 150.0 +offset_bottom = 28.0 +grow_horizontal = 2 +theme = SubResource("Theme_standard_font") + +[node name="LabelDisconnected" type="Label" parent="CenterTop" unique_id=869912310] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "Disconnected - Reconnecting..." +horizontal_alignment = 1 +vertical_alignment = 1 +visible = false + [node name="MobileInput" type="Control" parent="." unique_id=1373461519] layout_mode = 3 anchors_preset = 15 diff --git a/src/scenes/main_menu.tscn b/src/scenes/main_menu.tscn index 6c254e7..dc41447 100644 --- a/src/scenes/main_menu.tscn +++ b/src/scenes/main_menu.tscn @@ -103,6 +103,30 @@ layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "ruinborn.thefirstboss.com" +[node name="RoomFetchStatusContainer" type="VBoxContainer" parent="Control/MainMenu/VBoxContainer" unique_id=1286608618] +visible = false +layout_mode = 2 + +[node name="RoomFetchLoadingContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer/RoomFetchStatusContainer" unique_id=1286608619] +layout_mode = 2 + +[node name="LoadingLabel" type="Label" parent="Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/RoomFetchLoadingContainer" unique_id=1286608620] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Loading rooms..." +vertical_alignment = 1 + +[node name="LoadingSpinner" type="Label" parent="Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/RoomFetchLoadingContainer" unique_id=1286608621] +layout_mode = 2 +text = "⏳" +horizontal_alignment = 1 + +[node name="LastFetchLabel" type="Label" parent="Control/MainMenu/VBoxContainer/RoomFetchStatusContainer" unique_id=1286608622] +layout_mode = 2 +text = "Last fetched: Never" +horizontal_alignment = 1 +autowrap_mode = 2 + [node name="Spacer2" type="Control" parent="Control/MainMenu/VBoxContainer" unique_id=1061067008] custom_minimum_size = Vector2(0, 20) layout_mode = 2 diff --git a/src/scripts/chat_ui.gd b/src/scripts/chat_ui.gd index 972728a..1c99794 100644 --- a/src/scripts/chat_ui.gd +++ b/src/scripts/chat_ui.gd @@ -156,7 +156,13 @@ func send_system_message(message: String): 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 + # Route through game_world to avoid node path issues + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_receive_chat_message", ["System", message]) + else: + # Fallback: try direct RPC if game_world not available + _receive_message.rpc("System", message) else: # Client sends to server (system messages should only come from server) pass @@ -164,6 +170,16 @@ func send_system_message(message: String): # Offline mode - just show locally _add_message("System", message) +func add_local_message(player_name: String, message: String): + # Add a local-only message (not broadcast to other players) + # Used for connection status messages that should only appear locally + _add_message(player_name, message) + +func add_colorful_local_message(player_name: String, message: String): + # Add a local-only message with each character in a different color + # Used for special connection status messages + _add_colorful_message(player_name, message) + func _send_message(message: String): if not network_manager: return @@ -176,7 +192,13 @@ func _send_message(message: String): 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 + # Route through game_world to avoid node path issues + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_receive_chat_message", [player_name, message]) + else: + # Fallback: try direct RPC if game_world not available + _receive_message.rpc(player_name, message) else: # Client sends to server _send_message_to_server.rpc_id(1, player_name, message) @@ -190,8 +212,13 @@ func _send_message_to_server(player_name: String, message: String): 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) + # Then broadcast to all clients via game_world to avoid node path issues + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_receive_chat_message", [player_name, message]) + else: + # Fallback: try direct RPC if game_world not available + _receive_message.rpc(player_name, message) @rpc("authority", "reliable") func _receive_message(player_name: String, message: String): @@ -217,7 +244,7 @@ func _get_player_color(player_name: String) -> Color: func _add_message(player_name: String, message: String): if not message_list: - print("ChatUI: ERROR - message_list is null, cannot add message!") + LogManager.log_error("ChatUI: ERROR - message_list is null, cannot add message!", LogManager.CATEGORY_UI) return # Get current time @@ -271,6 +298,83 @@ func _add_message(player_name: String, message: String): # Update background visibility _update_background_visibility() +func _add_colorful_message(player_name: String, message: String): + # Add a message with each character in a different color + if not message_list: + LogManager.log_error("ChatUI: ERROR - message_list is null, cannot add message!", LogManager.CATEGORY_UI) + 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 for the player name + var player_color = _get_player_color(player_name) + var player_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 + + # Color palette for rainbow effect + var colors = [ + Color.RED, + Color.ORANGE, + Color.YELLOW, + Color.GREEN, + Color.CYAN, + Color.BLUE, + Color(0.5, 0.0, 0.5), # Purple + Color.MAGENTA + ] + + # Format message with colored player name and colorful message text + var formatted_text = "%s [color=%s]%s[/color]: " % [timestamp, player_color_hex, player_name] + + # Add each character with a different color + for i in range(message.length()): + var char_text = message.substr(i, 1) + var color = colors[i % colors.size()] + var color_hex = "#%02x%02x%02x" % [int(color.r * 255), int(color.g * 255), int(color.b * 255)] + formatted_text += "[color=%s]%s[/color]" % [color_hex, char_text] + + 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 @@ -350,7 +454,17 @@ func _process(_delta): _update_message_fades() func _get_player_name() -> String: - # Get local player name + # Get local player name from network_manager.players_info + # This ensures consistency with how player names are generated and used elsewhere + if network_manager and multiplayer.has_multiplayer_peer(): + var my_id = multiplayer.get_unique_id() + var player_info = network_manager.get_player_info(my_id) + if not player_info.is_empty(): + var player_names = player_info.get("player_names", []) + if player_names.size() > 0: + return player_names[0] + + # Fallback: try to get from player node 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") @@ -361,7 +475,7 @@ func _get_player_name() -> String: # 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 + # Final fallback if multiplayer.has_multiplayer_peer(): return "Player%d" % multiplayer.get_unique_id() return "Player" diff --git a/src/scripts/door.gd b/src/scripts/door.gd index 59cbaaf..12dc185 100644 --- a/src/scripts/door.gd +++ b/src/scripts/door.gd @@ -101,6 +101,12 @@ func _ready() -> void: # Call setup after a frame to ensure everything is ready call_deferred("_ready_after_setup") + + # Ensure door_index meta is set if name follows BlockingDoor_ + if name.begins_with("BlockingDoor_") and not has_meta("door_index"): + var index_str = name.substr(13) + if index_str.is_valid_int(): + set_meta("door_index", index_str.to_int()) func _update_door_texture(): # Update door texture based on door type @@ -113,24 +119,24 @@ func _update_door_texture(): var locked_texture = load("res://assets/gfx/door_locked.png") if locked_texture: sprite.texture = locked_texture - print("Door: Set KeyDoor texture to door_locked.png") + LogManager.log("Door: Set KeyDoor texture to door_locked.png", LogManager.CATEGORY_DOOR) else: - push_error("Door: Could not load door_locked.png texture!") + LogManager.log_error("Door: Could not load door_locked.png texture!", LogManager.CATEGORY_DOOR) "GateDoor": var gate_texture = load("res://assets/gfx/door_gate.png") if gate_texture: sprite.texture = gate_texture - print("Door: Set GateDoor texture to door_gate.png") + LogManager.log("Door: Set GateDoor texture to door_gate.png", LogManager.CATEGORY_DOOR) else: - push_error("Door: Could not load door_gate.png texture!") + LogManager.log_error("Door: Could not load door_gate.png texture!", LogManager.CATEGORY_DOOR) "StoneDoor": # Use door_barred.png for stone doors var barred_texture = load("res://assets/gfx/door_barred.png") if barred_texture: sprite.texture = barred_texture - print("Door: Set StoneDoor texture to door_barred.png") + LogManager.log("Door: Set StoneDoor texture to door_barred.png", LogManager.CATEGORY_DOOR) else: - push_error("Door: Could not load door_barred.png texture!") + LogManager.log_error("Door: Could not load door_barred.png texture!", LogManager.CATEGORY_DOOR) # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta: float) -> void: @@ -138,7 +144,7 @@ func _process(delta: float) -> void: if is_opening or is_closing: # Safety check: ensure closed_position is valid before animating if closed_position == Vector2.ZERO: - print("Door: ERROR - closed_position is zero during animation! Resetting...") + LogManager.log_error("Door: ERROR - closed_position is zero during animation! Resetting...", LogManager.CATEGORY_DOOR) closed_position = position - open_offset if is_opening else position is_opening = false is_closing = false @@ -160,7 +166,7 @@ func _process(delta: float) -> void: global_position = position # Also update global position during animation # Debug: log for KeyDoors to verify movement if type == "KeyDoor" and move_timer < 0.1: # Only log once at start of animation - print("Door: KeyDoor opening animation - start: ", start_pos, ", target: ", target_pos, ", offset: ", open_offset, ", direction: ", direction) + LogManager.log("Door: KeyDoor opening animation - start: " + str(start_pos) + ", target: " + str(target_pos) + ", offset: " + str(open_offset) + ", direction: " + str(direction), LogManager.CATEGORY_DOOR) # For KeyDoors: disable collision as soon as opening starts (allow passage immediately) # For StoneDoor/GateDoor: update collision based on position @@ -211,7 +217,8 @@ func _process(delta: float) -> void: global_position = open_position # Also set global position # When moved from closed position (open), collision should be DISABLED set_collision_layer_value(7, false) - print("Door: Opening animation complete - moved to open position: ", open_position, " (closed: ", closed_position, ", offset: ", open_offset, ") - collision DISABLED", " (key_used=", key_used, ")" if type == "KeyDoor" else "") + var key_used_str = " (key_used=" + str(key_used) + ")" if type == "KeyDoor" else "" + LogManager.log("Door: Opening animation complete - moved to open position: " + str(open_position) + " (closed: " + str(closed_position) + ", offset: " + str(open_offset) + ") - collision DISABLED" + key_used_str, LogManager.CATEGORY_DOOR) # CRITICAL: For KeyDoors, ensure key_used is true after animation completes # This prevents the door from being reset to closed in _process() @@ -234,7 +241,7 @@ func _process(delta: float) -> void: global_position = closed_position # Also set global position # When at closed position, collision should be ENABLED set_collision_layer_value(7, true) - print("Door: Closing animation complete - moved to closed position: ", closed_position, " - collision ENABLED") + LogManager.log("Door: Closing animation complete - moved to closed position: " + str(closed_position) + " - collision ENABLED", LogManager.CATEGORY_DOOR) # Spawn smoke puffs when StoneDoor finishes closing (1-3 puffs) if type == "StoneDoor": @@ -285,7 +292,7 @@ func _process(delta: float) -> void: # Snap to closed position if somehow moved (shouldn't happen, but safety check) var distance_to_closed = position.distance_to(closed_position) if distance_to_closed > 1.0: - print("Door: KeyDoor was moved incorrectly! Resetting to closed position.") + LogManager.log("Door: KeyDoor was moved incorrectly! Resetting to closed position.", LogManager.CATEGORY_DOOR) position = closed_position is_closed = true set_collision_layer_value(7, true) @@ -329,9 +336,9 @@ func _open(): global_position = closed_position is_closed = true set_collision_layer_value(7, true) # Collision enabled at closed position - print("Door: KeyDoor _open() called - reset to closed position ", closed_position, " before opening") + LogManager.log("Door: KeyDoor _open() called - reset to closed position " + str(closed_position) + " before opening", LogManager.CATEGORY_DOOR) else: - push_error("Door: KeyDoor _open() called but closed_position is zero!") + LogManager.log_error("Door: KeyDoor _open() called but closed_position is zero!", LogManager.CATEGORY_DOOR) return $SfxOpenKeyDoor.play() else: @@ -341,7 +348,7 @@ func _open(): if is_actually_open: # Door is already open - don't do anything - print("Door: _open() called but door is already open! Position: ", position, ", closed: ", closed_position, ", distance: ", distance_to_closed) + LogManager.log("Door: _open() called but door is already open! Position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) # Ensure door is at open position and collision is disabled var open_pos = closed_position + open_offset position = open_pos @@ -356,9 +363,9 @@ func _open(): global_position = closed_position is_closed = true set_collision_layer_value(7, true) - print("Door: StoneDoor/GateDoor _open() called - ensuring door is at closed position ", closed_position, " before opening") + LogManager.log("Door: StoneDoor/GateDoor _open() called - ensuring door is at closed position " + str(closed_position) + " before opening", LogManager.CATEGORY_DOOR) else: - push_error("Door: StoneDoor/GateDoor _open() called but closed_position is zero!") + LogManager.log_error("Door: StoneDoor/GateDoor _open() called but closed_position is zero!", LogManager.CATEGORY_DOOR) return if type == "GateDoor": $SfxOpenGateDoor.play() @@ -367,16 +374,29 @@ func _open(): # CRITICAL: Store starting position for animation (should be closed_position) animation_start_position = position - print("Door: Starting open animation from ", animation_start_position, " to ", closed_position + open_offset, " (offset: ", open_offset, ")") + LogManager.log("Door: Starting open animation from " + str(animation_start_position) + " to " + str(closed_position + open_offset) + " (offset: " + str(open_offset) + ")", LogManager.CATEGORY_DOOR) is_opening = true is_closing = false move_timer = 0.0 # Sync door opening to clients in multiplayer if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and is_inside_tree(): - _sync_door_open.rpc() - # Also sync puzzle_solved state - _sync_puzzle_solved.rpc(puzzle_solved) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_door_open_by_name", [name]) + # Also sync puzzle_solved state + game_world._rpc_to_ready_peers("_sync_door_puzzle_solved_by_name", [name, puzzle_solved]) + + # Track door state for syncing to new clients + if game_world: + game_world.door_states[name] = { + "is_closed": false, + "puzzle_solved": puzzle_solved, + "key_used": key_used if "key_used" in self else false, + "position": position, + "closed_position": closed_position, + "open_offset": open_offset + } func _close(): # Only close on server/authority in multiplayer, then sync to clients @@ -385,30 +405,30 @@ func _close(): # CRITICAL: KeyDoors should NEVER be closed (they only open with a key and stay open) if type == "KeyDoor": - print("Door: ERROR - _close() called on KeyDoor! KeyDoors should never be closed!") + LogManager.log_error("Door: ERROR - _close() called on KeyDoor! KeyDoors should never be closed!", LogManager.CATEGORY_DOOR) return # Ensure closed_position is valid before closing if closed_position == Vector2.ZERO: # If closed_position wasn't set correctly, use current position closed_position = position - print("Door: WARNING - closed_position was zero, using current position: ", closed_position) + LogManager.log("Door: WARNING - closed_position was zero, using current position: " + str(closed_position), LogManager.CATEGORY_DOOR) # Check both flag and actual position to determine door state var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var is_actually_at_closed = distance_to_closed < 5.0 # Within 5 pixels of closed position - print("Door: _close() called - is_closed: ", is_closed, ", is_actually_at_closed: ", is_actually_at_closed, ", position: ", position, ", closed: ", closed_position, ", distance: ", distance_to_closed) + LogManager.log("Door: _close() called - is_closed: " + str(is_closed) + ", is_actually_at_closed: " + str(is_actually_at_closed) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) # If door is already at closed position (both visually and by flag), don't do anything if is_closed and is_actually_at_closed and not is_opening and not is_closing: - print("Door: Already closed (both flag and position match), not closing again") + LogManager.log("Door: Already closed (both flag and position match), not closing again", LogManager.CATEGORY_DOOR) return # Already closed, don't do anything # CRITICAL: If door is at closed position but flag says open, just fix the state - don't animate if is_actually_at_closed and not is_closed: # Door is visually at closed position but flag says open - fix state only - print("Door: Door is at closed position but flag says open! Fixing state only (no animation)") + LogManager.log("Door: Door is at closed position but flag says open! Fixing state only (no animation)", LogManager.CATEGORY_DOOR) position = closed_position # Ensure exact position is_closed = true set_collision_layer_value(7, true) @@ -424,7 +444,7 @@ func _close(): # If door is significantly away from expected open position, snap to open position first if distance_to_open > 10.0: # Door is very far from expected open position - reset to open position first - print("Door: WARNING - Door is far from expected open position! Resetting to open: ", expected_open_pos, " (was at: ", position, ", distance: ", distance_to_open, ")") + LogManager.log("Door: WARNING - Door is far from expected open position! Resetting to open: " + str(expected_open_pos) + " (was at: " + str(position) + ", distance: " + str(distance_to_open) + ")", LogManager.CATEGORY_DOOR) animation_start_position = expected_open_pos position = expected_open_pos global_position = expected_open_pos @@ -434,7 +454,7 @@ func _close(): # Door is at or near open position - use current position as start animation_start_position = position - print("Door: Starting close animation from ", animation_start_position, " to ", closed_position, " (offset: ", open_offset, ")") + LogManager.log("Door: Starting close animation from " + str(animation_start_position) + " to " + str(closed_position) + " (offset: " + str(open_offset) + ")", LogManager.CATEGORY_DOOR) if type == "GateDoor": $SfxCloseGateDoor.play() else: @@ -446,7 +466,20 @@ func _close(): # Sync door closing to clients in multiplayer if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and is_inside_tree(): - _sync_door_close.rpc() + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_door_close_by_name", [name]) + + # Track door state for syncing to new clients + if game_world: + game_world.door_states[name] = { + "is_closed": true, + "puzzle_solved": puzzle_solved, + "key_used": key_used if "key_used" in self else false, + "position": position, + "closed_position": closed_position, + "open_offset": open_offset + } func _ready_after_setup(): # Called after door is fully set up with room references and positioned @@ -454,7 +487,7 @@ func _ready_after_setup(): # The position set by game_world is the OPEN position (initial state for blocking doors) var open_position = position # Current position is the OPEN position (from tile coordinates) - print("Door: _ready_after_setup() called - type: ", type, ", direction: ", direction, ", is_closed: ", is_closed, ", open_position: ", open_position) + LogManager.log("Door: _ready_after_setup() called - type: " + str(type) + ", direction: " + str(direction) + ", is_closed: " + str(is_closed) + ", open_position: " + str(open_position), LogManager.CATEGORY_DOOR) # CRITICAL: Calculate closed position based on direction # For StoneDoor/GateDoor: They start OPEN, then CLOSE when entering room @@ -491,7 +524,7 @@ func _ready_after_setup(): # This is used when opening from closed position open_offset = -closed_offset # open_offset = (0, -16) means open is 16px up from closed - print("Door: Calculated positions - open: ", open_position, ", closed: ", closed_position, ", closed_offset: ", closed_offset, ", open_offset: ", open_offset) + LogManager.log("Door: Calculated positions - open: " + str(open_position) + ", closed: " + str(closed_position) + ", closed_offset: " + str(closed_offset) + ", open_offset: " + str(open_offset), LogManager.CATEGORY_DOOR) # CRITICAL: KeyDoors should ALWAYS start closed, regardless of is_closed value # KeyDoors should NEVER be moved until opened with a key @@ -523,7 +556,7 @@ func _ready_after_setup(): position = closed_position global_position = closed_position set_collision_layer_value(7, true) # Collision enabled when closed - print("Door: KeyDoor starting CLOSED at position ", position, " (direction: ", direction, "), will open to ", closed_position + open_offset, " - collision ENABLED") + LogManager.log("Door: KeyDoor starting CLOSED at position " + str(position) + " (direction: " + str(direction) + "), will open to " + str(closed_position + open_offset) + " - collision ENABLED", LogManager.CATEGORY_DOOR) # Create key indicator sprite for KeyDoor _create_key_indicator() return # Exit early for KeyDoors @@ -533,46 +566,46 @@ func _ready_after_setup(): global_position = closed_position is_closed = true # Ensure state matches position set_collision_layer_value(7, true) - print("Door: Starting CLOSED at position ", position, " (type: ", type, ", direction: ", direction, ") - collision ENABLED") + LogManager.log("Door: Starting CLOSED at position " + str(position) + " (type: " + str(type) + ", direction: " + str(direction) + ") - collision ENABLED", LogManager.CATEGORY_DOOR) else: # StoneDoor/GateDoor starting OPEN (default for blocking doors) # CRITICAL: Door MUST start at open position (which is where game_world placed it) # Ensure position is EXACTLY at open_position (don't assume game_world set it correctly) if position.distance_to(open_position) > 1.0: # Position doesn't match open_position - force it to open position - print("Door: WARNING - Position doesn't match open_position! Forcing to open: ", open_position, " (was: ", position, ")") + LogManager.log("Door: WARNING - Position doesn't match open_position! Forcing to open: " + str(open_position) + " (was: " + str(position) + ")", LogManager.CATEGORY_DOOR) position = open_position global_position = position # Ensure global_position matches position is_closed = false # CRITICAL: State MUST be false (open) when at open position set_collision_layer_value(7, false) # CRITICAL: Collision MUST be DISABLED when open - print("Door: Starting OPEN at position ", position, " (closed: ", closed_position, ", open: ", open_position, ", open_offset: ", open_offset, ", type: ", type, ", direction: ", direction, ") - collision DISABLED, is_closed: ", is_closed) + LogManager.log("Door: Starting OPEN at position " + str(position) + " (closed: " + str(closed_position) + ", open: " + str(open_position) + ", open_offset: " + str(open_offset) + ", type: " + str(type) + ", direction: " + str(direction) + ") - collision DISABLED, is_closed: " + str(is_closed), LogManager.CATEGORY_DOOR) # CRITICAL: Verify the door is actually at open position after setting it var actual_distance = position.distance_to(closed_position) var expected_distance = 16.0 # Should be 16 pixels away if abs(actual_distance - expected_distance) > 2.0: - push_error("Door: ERROR - Door open/closed distance is wrong! Position: ", position, ", closed: ", closed_position, ", distance: ", actual_distance, " (expected: ", expected_distance, ")") + LogManager.log_error("Door: ERROR - Door open/closed distance is wrong! Position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(actual_distance) + " (expected: " + str(expected_distance) + ")", LogManager.CATEGORY_DOOR) # Force it to correct open position position = open_position global_position = open_position is_closed = false # CRITICAL: Ensure state is false when at open position set_collision_layer_value(7, false) - print("Door: FORCED door to open position: ", position, " (distance to closed: ", position.distance_to(closed_position), ", is_closed: ", is_closed, ")") + LogManager.log("Door: FORCED door to open position: " + str(position) + " (distance to closed: " + str(position.distance_to(closed_position)) + ", is_closed: " + str(is_closed) + ")", LogManager.CATEGORY_DOOR) # FINAL VERIFICATION: Double-check state matches position var distance_to_closed = position.distance_to(closed_position) var should_be_open = distance_to_closed > 8.0 # If more than 8px from closed, should be open if should_be_open and is_closed: - push_error("Door: ERROR - Door is at open position but is_closed is true! Fixing state...") + LogManager.log_error("Door: ERROR - Door is at open position but is_closed is true! Fixing state...", LogManager.CATEGORY_DOOR) is_closed = false set_collision_layer_value(7, false) - print("Door: Fixed state - door is now OPEN (is_closed: ", is_closed, ", collision: ", get_collision_layer_value(7), ")") + LogManager.log("Door: Fixed state - door is now OPEN (is_closed: " + str(is_closed) + ", collision: " + str(get_collision_layer_value(7)) + ")", LogManager.CATEGORY_DOOR) elif not should_be_open and not is_closed: - push_error("Door: ERROR - Door is at closed position but is_closed is false! Fixing state...") + LogManager.log_error("Door: ERROR - Door is at closed position but is_closed is false! Fixing state...", LogManager.CATEGORY_DOOR) is_closed = true set_collision_layer_value(7, true) - print("Door: Fixed state - door is now CLOSED (is_closed: ", is_closed, ", collision: ", get_collision_layer_value(7), ")") + LogManager.log("Door: Fixed state - door is now CLOSED (is_closed: " + str(is_closed) + ", collision: " + str(get_collision_layer_value(7)) + ")", LogManager.CATEGORY_DOOR) # NOTE: Doors are NOT connected via signals to room triggers # Instead, room triggers call door._on_room_entered() directly @@ -634,20 +667,20 @@ func _on_room_entered(body): var distance_to_closed = position.distance_to(closed_position) if closed_position != Vector2.ZERO else 999.0 var is_actually_open = distance_to_closed > 5.0 # If door is more than 5 pixels away from closed_position, it's open - print("Door: _on_room_entered() - type: ", type, ", is_closed: ", is_closed, ", is_actually_open: ", is_actually_open, ", position: ", position, ", closed: ", closed_position, ", distance: ", distance_to_closed) + LogManager.log("Door: _on_room_entered() - type: " + str(type) + ", is_closed: " + str(is_closed) + ", is_actually_open: " + str(is_actually_open) + ", position: " + str(position) + ", closed: " + str(closed_position) + ", distance: " + str(distance_to_closed), LogManager.CATEGORY_DOOR) # CRITICAL: Only close if door is actually open (both flag and position must indicate open) # If door is already closed, don't do anything if is_actually_open and not is_closing and not is_opening: # Door is actually open (position is away from closed position) - close it - print("Door: Closing door on room entry - was at position ", position, " (closed: ", closed_position, ", is_closed: ", is_closed, ", distance: ", distance_to_closed, ")") + LogManager.log("Door: Closing door on room entry - was at position " + str(position) + " (closed: " + str(closed_position) + ", is_closed: " + str(is_closed) + ", distance: " + str(distance_to_closed) + ")", LogManager.CATEGORY_DOOR) # Ensure door is at open position before closing var expected_open_pos = closed_position + open_offset var dist_to_open = position.distance_to(expected_open_pos) if dist_to_open > 5.0: # Door is not at expected open position - reset to open position first - print("Door: WARNING - Door is not at expected open position! Resetting to open: ", expected_open_pos, " (was at: ", position, ")") + LogManager.log("Door: WARNING - Door is not at expected open position! Resetting to open: " + str(expected_open_pos) + " (was at: " + str(position) + ")", LogManager.CATEGORY_DOOR) position = expected_open_pos global_position = expected_open_pos is_closed = false @@ -659,20 +692,20 @@ func _on_room_entered(body): return # Exit early, don't check puzzle state yet elif is_actually_open: # Door is open but animation already in progress - don't interfere - print("Door: Door is open but animation in progress, not closing") + LogManager.log("Door: Door is open but animation in progress, not closing", LogManager.CATEGORY_DOOR) return elif not is_actually_open: # Door is already at closed position - but for StoneDoor/GateDoor, this shouldn't happen on room entry # They should start OPEN and then CLOSE when entering room # If door is at closed position, it might have been closed already - don't do anything - print("Door: WARNING - Door is already at closed position when entering room! This shouldn't happen for StoneDoor/GateDoor that start open.") + LogManager.log("Door: WARNING - Door is already at closed position when entering room! This shouldn't happen for StoneDoor/GateDoor that start open.", LogManager.CATEGORY_DOOR) if closed_position != Vector2.ZERO: # Ensure exact position and state match position = closed_position global_position = closed_position is_closed = true set_collision_layer_value(7, true) # Collision ENABLED when closed - print("Door: Door was already closed - ensuring state is correct, position: ", position, ", closed: ", closed_position) + LogManager.log("Door: Door was already closed - ensuring state is correct, position: " + str(position) + ", closed: " + str(closed_position), LogManager.CATEGORY_DOOR) # Now that door is confirmed closed, check if puzzle is already solved # CRITICAL: Only check puzzle state if door is closed - don't check if puzzle is already solved if not puzzle_solved: @@ -694,7 +727,7 @@ func _check_puzzle_state(): # CRITICAL: Don't check puzzle state while door is animating (closing or opening) # This prevents race conditions where switch triggers before door finishes closing if is_closing or is_opening: - print("Door: Skipping puzzle check - door is animating (is_closing: ", is_closing, ", is_opening: ", is_opening, ")") + LogManager.log("Door: Skipping puzzle check - door is animating (is_closing: " + str(is_closing) + ", is_opening: " + str(is_opening) + ")", LogManager.CATEGORY_DOOR) return # Check door's actual state (position-based check is more reliable than flags) @@ -708,13 +741,15 @@ func _check_puzzle_state(): # This handles race conditions where switch triggers while door is still closing if puzzle_solved and (not is_actually_open or collision_enabled): # Door should be open but isn't (position or collision) - reset puzzle_solved to allow switch to trigger again - print("Door: puzzle_solved is true but door is not actually open (position: ", is_actually_open, ", collision: ", collision_enabled, ") - resetting to allow switch to trigger again") + LogManager.log("Door: puzzle_solved is true but door is not actually open (position: " + str(is_actually_open) + ", collision: " + str(collision_enabled) + ") - resetting to allow switch to trigger again", LogManager.CATEGORY_DOOR) puzzle_solved = false switches_activated = false # Check if all enemies are defeated (enemies in blocking room) if requires_enemies and _are_all_enemies_defeated(): - print("Door: All enemies defeated! Opening door ", name, " (type: ", type, ", room: ", blocking_room.get("x", "?") if blocking_room and not blocking_room.is_empty() else "?", ",", blocking_room.get("y", "?") if blocking_room and not blocking_room.is_empty() else "?", ")") + var room_x_str = str(blocking_room.get("x", "?")) if blocking_room and not blocking_room.is_empty() else "?" + var room_y_str = str(blocking_room.get("y", "?")) if blocking_room and not blocking_room.is_empty() else "?" + LogManager.log("Door: All enemies defeated! Opening door " + str(name) + " (type: " + str(type) + ", room: " + room_x_str + "," + room_y_str + ")", LogManager.CATEGORY_DOOR) enemies_defeated = true puzzle_solved = true if is_actually_closed: @@ -744,7 +779,7 @@ func _check_puzzle_state(): # Not all switches are active if puzzle_solved and has_pillar_switch: # Pillar switch became inactive and door was open - close it and reset puzzle - print("Door: Pillar switch deactivated - closing door ", name) + LogManager.log("Door: Pillar switch deactivated - closing door " + str(name), LogManager.CATEGORY_DOOR) switches_activated = false puzzle_solved = false if not is_actually_closed: @@ -768,9 +803,13 @@ func _are_all_enemies_defeated() -> bool: return false # Find all enemies in the room that were spawned from spawners - var entities_node = get_tree().get_first_node_in_group("game_world") + var game_world = get_tree().get_first_node_in_group("game_world") + var entities_node = null + if game_world: + entities_node = game_world.get_node_or_null("Entities") if not entities_node: - entities_node = get_node("/root/GameWorld/Entities") + # Fallback without throwing if GameWorld isn't ready yet + entities_node = get_node_or_null("/root/GameWorld/Entities") if not entities_node: return false @@ -804,10 +843,14 @@ func _are_all_enemies_defeated() -> bool: if enemy_in_room: room_spawned_enemies.append(child) - print("Door: Found spawned enemy in room: ", child.name, " (spawner: ", child.get_meta("spawner_name") if child.has_meta("spawner_name") else "unknown", ", is_dead: ", child.is_dead if "is_dead" in child else "unknown", ")") + var spawner_name = str(child.get_meta("spawner_name")) if child.has_meta("spawner_name") else "unknown" + var is_dead_str = str(child.is_dead) if "is_dead" in child else "unknown" + LogManager.log("Door: Found spawned enemy in room: " + str(child.name) + " (spawner: " + spawner_name + ", is_dead: " + is_dead_str + ")", LogManager.CATEGORY_DOOR) # Check if all spawned enemies are dead - print("Door: _are_all_enemies_defeated() - Found ", room_spawned_enemies.size(), " spawned enemies in room (", target_room.get("x", "?") if target_room and not target_room.is_empty() else "?", ",", target_room.get("y", "?") if target_room and not target_room.is_empty() else "?", ")") + var target_room_x_str = str(target_room.get("x", "?")) if target_room and not target_room.is_empty() else "?" + var target_room_y_str = str(target_room.get("y", "?")) if target_room and not target_room.is_empty() else "?" + LogManager.log("Door: _are_all_enemies_defeated() - Found " + str(room_spawned_enemies.size()) + " spawned enemies in room (" + target_room_x_str + "," + target_room_y_str + ")", LogManager.CATEGORY_DOOR) # First, check if any enemies in room_spawned_enemies are still alive # If any are alive, puzzle is not solved @@ -820,12 +863,12 @@ func _are_all_enemies_defeated() -> bool: enemy_is_dead = enemy.is_queued_for_deletion() or not enemy.is_inside_tree() if not enemy_is_dead: - print("Door: Enemy ", enemy.name, " is still alive - puzzle not solved yet") + LogManager.log("Door: Enemy " + str(enemy.name) + " is still alive - puzzle not solved yet", LogManager.CATEGORY_DOOR) return false # Enemy is still alive, puzzle not solved # If we have enemies and all are dead, puzzle is solved if room_spawned_enemies.size() > 0: - print("Door: All ", room_spawned_enemies.size(), " spawned enemies are dead! Puzzle solved!") + LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) return true # All enemies found are dead # No spawned enemies found - check if spawners have actually spawned enemies before @@ -836,9 +879,9 @@ func _are_all_enemies_defeated() -> bool: # This catches cases where enemies weren't added to room_spawned_enemies due to position check issues var entities_child = entities_node.get_node_or_null("Entities") if entities_node else null if not entities_child and entities_node: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - entities_child = game_world.get_node_or_null("Entities") + var fallback_game_world = get_tree().get_first_node_in_group("game_world") + if fallback_game_world: + entities_child = fallback_game_world.get_node_or_null("Entities") var tile_size = 16 var room_min_x = target_room.x + 2 @@ -871,7 +914,7 @@ func _are_all_enemies_defeated() -> bool: if enemy_is_alive: # Found an ALIVE enemy in this room - puzzle not solved! - print("Door: Found ALIVE enemy ", child.name, " in room - puzzle not solved yet (enemy still alive)") + LogManager.log("Door: Found ALIVE enemy " + str(child.name) + " in room - puzzle not solved yet (enemy still alive)", LogManager.CATEGORY_DOOR) return false # No alive enemies found in room - now check if spawners have spawned @@ -907,9 +950,9 @@ func _are_all_enemies_defeated() -> bool: if not has_spawned: var entities_child_for_spawner = entities_node.get_node_or_null("Entities") if entities_node else null if not entities_child_for_spawner and entities_node: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - entities_child_for_spawner = game_world.get_node_or_null("Entities") + var fallback_game_world = get_tree().get_first_node_in_group("game_world") + if fallback_game_world: + entities_child_for_spawner = fallback_game_world.get_node_or_null("Entities") if entities_child_for_spawner: for child in entities_child_for_spawner.get_children(): @@ -1002,7 +1045,7 @@ func _are_all_enemies_defeated() -> bool: for spawner_name in unique_spawner_names_that_spawned.keys(): spawners_in_room.append(null) # Placeholder for destroyed spawner spawners_that_have_spawned.append(null) # Count as spawned - print("Door: Spawner ", spawner_name, " was destroyed but spawned enemies that are now all dead - counting as spawned") + LogManager.log("Door: Spawner " + str(spawner_name) + " was destroyed but spawned enemies that are now all dead - counting as spawned", LogManager.CATEGORY_DOOR) else: # Spawners exist - check if any weren't counted as spawned yet for spawner_name in unique_spawner_names_that_spawned.keys(): @@ -1017,7 +1060,7 @@ func _are_all_enemies_defeated() -> bool: var spawner = spawners_in_room[i] if is_instance_valid(spawner) and spawner.name == spawner_name: spawners_that_have_spawned.append(spawner) - print("Door: Found dead enemy from spawner ", spawner_name, " - marking as spawned") + LogManager.log("Door: Found dead enemy from spawner " + str(spawner_name) + " - marking as spawned", LogManager.CATEGORY_DOOR) break @@ -1034,7 +1077,7 @@ func _are_all_enemies_defeated() -> bool: if valid_spawners_count > 0 and valid_spawned_count >= valid_spawners_count: # All spawners in room have spawned at least once, and no enemies found in room # This means all spawned enemies are dead - puzzle solved! - print("Door: No spawned enemies found, but all ", valid_spawners_count, " spawners in room have spawned enemies that are all dead - puzzle solved!") + LogManager.log("Door: No spawned enemies found, but all " + str(valid_spawners_count) + " spawners in room have spawned enemies that are all dead - puzzle solved!", LogManager.CATEGORY_DOOR) return true # Also check: if no spawners found (they were destroyed), but this is a puzzle room (has blocking doors), @@ -1043,13 +1086,13 @@ func _are_all_enemies_defeated() -> bool: if valid_spawners_count == 0 and valid_spawned_count > 0: # Spawners were destroyed, but we found evidence they spawned # Since no enemies found, they must all be dead - puzzle solved! - print("Door: No spawners or enemies found, but found evidence of spawned enemies that are now all dead - puzzle solved!") + LogManager.log("Door: No spawners or enemies found, but found evidence of spawned enemies that are now all dead - puzzle solved!", LogManager.CATEGORY_DOOR) return true if valid_spawners_count > 0: - print("Door: Spawners in room (", valid_spawners_count, ") but only ", valid_spawned_count, " have spawned - puzzle not solved yet") + LogManager.log("Door: Spawners in room (" + str(valid_spawners_count) + ") but only " + str(valid_spawned_count) + " have spawned - puzzle not solved yet", LogManager.CATEGORY_DOOR) else: - print("Door: No spawned enemies found in room - puzzle not solved yet (enemies may not have spawned or already removed)") + LogManager.log("Door: No spawned enemies found in room - puzzle not solved yet (enemies may not have spawned or already removed)", LogManager.CATEGORY_DOOR) return false for enemy in room_spawned_enemies: @@ -1062,13 +1105,13 @@ func _are_all_enemies_defeated() -> bool: enemy_is_dead = enemy.is_queued_for_deletion() or not enemy.is_inside_tree() if not enemy_is_dead: - print("Door: Enemy ", enemy.name, " is still alive (is_dead: ", enemy_is_dead, ", is_queued: ", enemy.is_queued_for_deletion(), ", in_tree: ", enemy.is_inside_tree(), ")") + LogManager.log("Door: Enemy " + str(enemy.name) + " is still alive (is_dead: " + str(enemy_is_dead) + ", is_queued: " + str(enemy.is_queued_for_deletion()) + ", in_tree: " + str(enemy.is_inside_tree()) + ")", LogManager.CATEGORY_DOOR) return false else: # Enemy is no longer valid (removed from scene) - consider it dead - print("Door: Enemy is no longer valid (removed from scene) - counting as dead") + LogManager.log("Door: Enemy is no longer valid (removed from scene) - counting as dead", LogManager.CATEGORY_DOOR) - print("Door: All ", room_spawned_enemies.size(), " spawned enemies are dead! Puzzle solved!") + LogManager.log("Door: All " + str(room_spawned_enemies.size()) + " spawned enemies are dead! Puzzle solved!", LogManager.CATEGORY_DOOR) return true # All enemies are dead func _spawn_smoke_puffs_on_close(): @@ -1135,20 +1178,22 @@ func _are_all_switches_activated() -> bool: # Do NOT use position-based fallback checks - they cause cross-room door triggering! if connected_switches.size() > 0: # Check all connected switches (these are the switches in THIS door's puzzle room) - print("Door: _are_all_switches_activated() - Checking ", connected_switches.size(), " connected switches for door ", name, " (room: ", blocking_room.get("x", "?"), ",", blocking_room.get("y", "?"), ")") + var switch_room_x = str(blocking_room.get("x", "?")) if blocking_room and not blocking_room.is_empty() else "?" + var switch_room_y = str(blocking_room.get("y", "?")) if blocking_room and not blocking_room.is_empty() else "?" + LogManager.log("Door: _are_all_switches_activated() - Checking " + str(connected_switches.size()) + " connected switches for door " + str(name) + " (room: " + switch_room_x + "," + switch_room_y + ")", LogManager.CATEGORY_DOOR) for switch in connected_switches: if not is_instance_valid(switch): continue # is_activated is a variable, not a method if not switch.is_activated: - print("Door: Switch ", switch.name, " is NOT activated") + LogManager.log("Door: Switch " + str(switch.name) + " is NOT activated", LogManager.CATEGORY_DOOR) return false - print("Door: All connected switches are activated!") + LogManager.log("Door: All connected switches are activated!", LogManager.CATEGORY_DOOR) return true # All connected switches are activated # CRITICAL: If no switches are connected, the puzzle is NOT solved! # Switches should ALWAYS be connected when spawned - if they're not, it's an error - print("Door: WARNING - Door ", name, " has no connected switches! Puzzle cannot be solved!") + LogManager.log("Door: WARNING - Door " + str(name) + " has no connected switches! Puzzle cannot be solved!", LogManager.CATEGORY_DOOR) return false # No connected switches means puzzle is NOT solved func _on_key_interaction_area_body_entered(body): @@ -1165,7 +1210,7 @@ func _on_key_interaction_area_body_entered(body): key_used = true _show_key_indicator() _open() - print("KeyDoor opened with key!") + LogManager.log("KeyDoor opened with key!", LogManager.CATEGORY_DOOR) func _show_key_indicator(): # Show key indicator above door @@ -1253,7 +1298,8 @@ func _sync_door_open(): else: $SfxOpenStoneDoor.play() - print("Door: Client received door open RPC for ", name, " - starting open animation", " (key_used=", key_used, ")" if type == "KeyDoor" else "") + var key_used_str = " (key_used=" + str(key_used) + ")" if type == "KeyDoor" else "" + LogManager.log("Door: Client received door open RPC for " + str(name) + " - starting open animation" + key_used_str, LogManager.CATEGORY_DOOR) @rpc("authority", "reliable") func _sync_puzzle_solved(is_solved: bool): @@ -1263,7 +1309,7 @@ func _sync_puzzle_solved(is_solved: bool): if is_solved: enemies_defeated = true switches_activated = true - print("Door: Client received puzzle_solved sync for ", name, " - puzzle_solved: ", is_solved) + LogManager.log("Door: Client received puzzle_solved sync for " + str(name) + " - puzzle_solved: " + str(is_solved), LogManager.CATEGORY_DOOR) @rpc("authority", "reliable") func _sync_door_close(): @@ -1289,4 +1335,4 @@ func _sync_door_close(): is_closing = true move_timer = 0.0 - print("Door: Client received door close RPC for ", name, " - starting close animation") + LogManager.log("Door: Client received door close RPC for " + str(name) + " - starting close animation", LogManager.CATEGORY_DOOR) diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index 55cd6e8..c0f9d98 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -96,7 +96,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - # Calculate target room count based on level # Level 1: 7-8 rooms, then increase by 2-3 rooms per level var target_room_count = 7 + (level - 1) * 2 + rng.randi_range(0, 1) # Level 1: 7-8, Level 2: 9-10, etc. - print("DungeonGenerator: Level ", level, " - Target room count: ", target_room_count) + LogManager.log("DungeonGenerator: Level " + str(level) + " - Target room count: " + str(target_room_count), LogManager.CATEGORY_DUNGEON) # Initialize grid (0 = wall, 1 = floor, 2 = door, 3 = corridor) var grid = [] @@ -137,7 +137,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - attempts -= 1 - print("DungeonGenerator: Generated ", all_rooms.size(), " rooms (target was ", target_room_count, ")") + LogManager.log("DungeonGenerator: Generated " + str(all_rooms.size()) + " rooms (target was " + str(target_room_count) + ")", LogManager.CATEGORY_DUNGEON) # 3. Connect rooms with corridors/doors if all_rooms.size() > 1: @@ -156,7 +156,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - # 6. Mark exit room (farthest REACHABLE room from start) # First find all reachable rooms from start var reachable_rooms = _find_reachable_rooms(all_rooms[start_room_index], all_rooms, all_doors) - print("DungeonGenerator: Found ", reachable_rooms.size(), " reachable rooms from start (out of ", all_rooms.size(), " total)") + LogManager.log("DungeonGenerator: Found " + str(reachable_rooms.size()) + " reachable rooms from start (out of " + str(all_rooms.size()) + " total)", LogManager.CATEGORY_DUNGEON) # CRITICAL: Remove inaccessible rooms (rooms not reachable from start) # Store the start room before filtering (it should always be reachable) @@ -179,13 +179,13 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - filtered_rooms.append(room) else: inaccessible_count += 1 - print("DungeonGenerator: Removing inaccessible room at (", room.x, ", ", room.y, ") - no corridor connection") + LogManager.log("DungeonGenerator: Removing inaccessible room at (" + str(room.x) + ", " + str(room.y) + ") - no corridor connection", LogManager.CATEGORY_DUNGEON) # Update all_rooms to only include reachable rooms all_rooms = filtered_rooms if inaccessible_count > 0: - print("DungeonGenerator: Removed ", inaccessible_count, " inaccessible room(s). Remaining rooms: ", all_rooms.size()) + LogManager.log("DungeonGenerator: Removed " + str(inaccessible_count) + " inaccessible room(s). Remaining rooms: " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON) # Update start_room_index after filtering (find start room in new array using value-based comparison) start_room_index = -1 @@ -197,7 +197,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - break if start_room_index == -1: - push_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!") + LogManager.log_error("DungeonGenerator: ERROR - Start room was removed! This should never happen!", LogManager.CATEGORY_DUNGEON) start_room_index = 0 # Fallback # Also remove doors connected to inaccessible rooms (clean up all_doors) @@ -229,23 +229,23 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - filtered_doors.append(door) else: doors_removed += 1 - print("DungeonGenerator: Removing door - room1 reachable: ", door_room1_reachable, ", room2 reachable: ", door_room2_reachable) + LogManager.log("DungeonGenerator: Removing door - room1 reachable: " + str(door_room1_reachable) + ", room2 reachable: " + str(door_room2_reachable), LogManager.CATEGORY_DUNGEON) all_doors = filtered_doors if doors_removed > 0: - print("DungeonGenerator: Removed ", doors_removed, " door(s) connected to inaccessible rooms. Remaining doors: ", all_doors.size()) + LogManager.log("DungeonGenerator: Removed " + str(doors_removed) + " door(s) connected to inaccessible rooms. Remaining doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) # Find the farthest reachable room (now all rooms are reachable, but find farthest) # Make sure we have at least 2 rooms (start and exit must be different) # exit_room_index is already declared at function level if all_rooms.size() < 2: - push_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have ", all_rooms.size()) + LogManager.log_error("DungeonGenerator: ERROR - Not enough reachable rooms! Need at least 2 (start + exit), but only have " + str(all_rooms.size()), LogManager.CATEGORY_DUNGEON) # Use start room as exit if only one room exists (shouldn't happen, but handle gracefully) if all_rooms.size() == 1: exit_room_index = 0 else: # No rooms at all - this is a critical error - push_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!") + LogManager.log_error("DungeonGenerator: CRITICAL ERROR - No rooms left after filtering!", LogManager.CATEGORY_DUNGEON) return {} # Return empty dungeon else: exit_room_index = _find_farthest_room(all_rooms, start_room_index) @@ -266,7 +266,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - exit_room_index = second_farthest all_rooms[exit_room_index].modifiers.append({"type": "EXIT"}) - print("DungeonGenerator: Selected exit room at index ", exit_room_index, " position: ", all_rooms[exit_room_index].x, ",", all_rooms[exit_room_index].y) + LogManager.log("DungeonGenerator: Selected exit room at index " + str(exit_room_index) + " position: " + str(all_rooms[exit_room_index].x) + "," + str(all_rooms[exit_room_index].y), LogManager.CATEGORY_DUNGEON) # 7. Render walls around rooms _render_room_walls(all_rooms, grid, tile_grid, map_size, rng) @@ -274,12 +274,12 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - # 7.5. Place stairs in exit room BEFORE placing torches (so torches don't overlap stairs) var stairs_data = _place_stairs_in_exit_room(all_rooms[exit_room_index], grid, tile_grid, map_size, all_doors, rng) if stairs_data.is_empty(): - print("DungeonGenerator: ERROR - Failed to place stairs in exit room! Room size: ", all_rooms[exit_room_index].w, "x", all_rooms[exit_room_index].h, " Doors: ", all_doors.size()) + LogManager.log_error("DungeonGenerator: ERROR - Failed to place stairs in exit room! Room size: " + str(all_rooms[exit_room_index].w) + "x" + str(all_rooms[exit_room_index].h) + " Doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) # CRITICAL: Force place stairs - we MUST have an exit! - print("DungeonGenerator: FORCING stairs placement in exit room center") + LogManager.log("DungeonGenerator: FORCING stairs placement in exit room center", LogManager.CATEGORY_DUNGEON) stairs_data = _force_place_stairs(all_rooms[exit_room_index], grid, tile_grid, map_size, all_doors, rng) if stairs_data.is_empty(): - push_error("DungeonGenerator: CRITICAL ERROR - Could not place stairs even with force placement!") + LogManager.log_error("DungeonGenerator: CRITICAL ERROR - Could not place stairs even with force placement!", LogManager.CATEGORY_DUNGEON) # 8. Place torches in rooms (AFTER stairs, so torches don't overlap stairs) var all_torches = [] @@ -309,7 +309,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - break if not already_in_list: rooms_with_spawner_puzzles.append(puzzle_room) - print("DungeonGenerator: Room (", puzzle_room.x, ", ", puzzle_room.y, ") has monster spawner puzzle - will skip pre-spawning enemies") + LogManager.log("DungeonGenerator: Room (" + str(puzzle_room.x) + ", " + str(puzzle_room.y) + ") has monster spawner puzzle - will skip pre-spawning enemies", LogManager.CATEGORY_DUNGEON) # 9. Place enemies in rooms (scaled by level, excluding start and exit rooms, and rooms with spawner puzzles) var all_enemies = [] @@ -323,7 +323,7 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - if spawner_room.x == room.x and spawner_room.y == room.y and \ spawner_room.w == room.w and spawner_room.h == room.h: has_spawner_puzzle = true - print("DungeonGenerator: Skipping pre-spawned enemies for room (", room.x, ", ", room.y, ") - has monster spawner puzzle") + LogManager.log("DungeonGenerator: Skipping pre-spawned enemies for room (" + str(room.x) + ", " + str(room.y) + ") - has monster spawner puzzle", LogManager.CATEGORY_DUNGEON) break if not has_spawner_puzzle: @@ -1317,7 +1317,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A # Choose a random wall to place stairs on (excluding corners) # Make sure stairs don't overlap any doors # Returns stairs data with position and size for Area2D creation - print("DungeonGenerator: Placing stairs in exit room: ", exit_room.x, ",", exit_room.y, " size: ", exit_room.w, "x", exit_room.h, " doors: ", all_doors.size()) + LogManager.log("DungeonGenerator: Placing stairs in exit room: " + str(exit_room.x) + "," + str(exit_room.y) + " size: " + str(exit_room.w) + "x" + str(exit_room.h) + " doors: " + str(all_doors.size()), LogManager.CATEGORY_DUNGEON) var stairs_data: Dictionary = {} var wall_choices = [] @@ -1334,7 +1334,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A for stairs_tile_y in range(stairs_y, stairs_y + stairs_h): if stairs_tile_x >= 0 and stairs_tile_x < map_size.x and stairs_tile_y >= 0 and stairs_tile_y < map_size.y: if grid[stairs_tile_x][stairs_tile_y] == 2: # Grid value 2 = door - print("DungeonGenerator: Stairs tile (", stairs_tile_x, ",", stairs_tile_y, ") is marked as door in grid!") + LogManager.log("DungeonGenerator: Stairs tile (" + str(stairs_tile_x) + "," + str(stairs_tile_y) + ") is marked as door in grid!", LogManager.CATEGORY_DUNGEON) return true # SECOND: Check door dictionary - verify against all known doors @@ -1377,7 +1377,8 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A # Check if this stairs tile matches any door tile for door_tile in door_tiles: if stairs_tile_x == door_tile.x and stairs_tile_y == door_tile.y: - print("DungeonGenerator: Stairs tile (", stairs_tile_x, ",", stairs_tile_y, ") overlaps door tile (", door_tile.x, ",", door_tile.y, ") from door at (", door_x, ",", door_y, ") dir: ", door.dir if "dir" in door else "unknown") + var door_dir_str = str(door.dir) if "dir" in door else "unknown" + LogManager.log("DungeonGenerator: Stairs tile (" + str(stairs_tile_x) + "," + str(stairs_tile_y) + ") overlaps door tile (" + str(door_tile.x) + "," + str(door_tile.y) + ") from door at (" + str(door_x) + "," + str(door_y) + ") dir: " + door_dir_str, LogManager.CATEGORY_DUNGEON) return true return false @@ -1453,7 +1454,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A }) if wall_choices.size() == 0: - print("DungeonGenerator: ERROR - No valid walls for stairs! Exit room too small: ", exit_room.w, "x", exit_room.h) + LogManager.log_error("DungeonGenerator: ERROR - No valid walls for stairs! Exit room too small: " + str(exit_room.w) + "x" + str(exit_room.h), LogManager.CATEGORY_DUNGEON) return {} # No valid walls for stairs # Choose a random wall @@ -1462,7 +1463,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A if wall.dir == "UP" or wall.dir == "DOWN": # Horizontal stairs (3x2) if wall.x_range.size() == 0: - print("DungeonGenerator: ERROR - x_range is empty for ", wall.dir, " stairs") + LogManager.log_error("DungeonGenerator: ERROR - x_range is empty for " + str(wall.dir) + " stairs", LogManager.CATEGORY_DUNGEON) return {} # Try to find a position that doesn't overlap doors @@ -1473,7 +1474,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A valid_positions.append(test_x) if valid_positions.size() == 0: - print("DungeonGenerator: ERROR - No valid position found for ", wall.dir, " stairs (all positions overlap doors)") + LogManager.log_error("DungeonGenerator: ERROR - No valid position found for " + str(wall.dir) + " stairs (all positions overlap doors)", LogManager.CATEGORY_DUNGEON) # Don't allow stairs to overlap doors - this is a critical bug # Instead, try the next wall or return empty to force placement elsewhere return {} # No valid position found - will trigger _force_place_stairs @@ -1493,7 +1494,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A "world_size": Vector2(wall.w * tile_size, wall.h * tile_size) } - print("DungeonGenerator: Placed ", wall.dir, " stairs at tile (", stairs_start_x, ",", wall.y, ") world pos: ", stairs_data.world_pos, " in room (", exit_room.x, ",", exit_room.y, ") size ", exit_room.w, "x", exit_room.h) + LogManager.log("DungeonGenerator: Placed " + str(wall.dir) + " stairs at tile (" + str(stairs_start_x) + "," + str(wall.y) + ") world pos: " + str(stairs_data.world_pos) + " in room (" + str(exit_room.x) + "," + str(exit_room.y) + ") size " + str(exit_room.w) + "x" + str(exit_room.h), LogManager.CATEGORY_DUNGEON) # Mark grid cells as stairs (similar to doors) for dx in range(wall.w): @@ -1524,7 +1525,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A elif wall.dir == "LEFT" or wall.dir == "RIGHT": # Vertical stairs (2x3) if wall.y_range.size() == 0: - print("DungeonGenerator: ERROR - y_range is empty for ", wall.dir, " stairs") + LogManager.log_error("DungeonGenerator: ERROR - y_range is empty for " + str(wall.dir) + " stairs", LogManager.CATEGORY_DUNGEON) return {} # Try to find a position that doesn't overlap doors @@ -1535,7 +1536,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A valid_positions.append(test_y) if valid_positions.size() == 0: - print("DungeonGenerator: ERROR - No valid position found for ", wall.dir, " stairs (all positions overlap doors)") + LogManager.log_error("DungeonGenerator: ERROR - No valid position found for " + str(wall.dir) + " stairs (all positions overlap doors)", LogManager.CATEGORY_DUNGEON) # Don't allow stairs to overlap doors - this is a critical bug # Instead, try the next wall or return empty to force placement elsewhere return {} # No valid position found - will trigger _force_place_stairs @@ -1555,7 +1556,7 @@ func _place_stairs_in_exit_room(exit_room: Dictionary, grid: Array, tile_grid: A "world_size": Vector2(wall.w * tile_size, wall.h * tile_size) } - print("DungeonGenerator: Placed ", wall.dir, " stairs at tile (", wall.x, ",", stairs_start_y, ") world pos: ", stairs_data.world_pos, " in room (", exit_room.x, ",", exit_room.y, ") size ", exit_room.w, "x", exit_room.h) + LogManager.log("DungeonGenerator: Placed " + str(wall.dir) + " stairs at tile (" + str(wall.x) + "," + str(stairs_start_y) + ") world pos: " + str(stairs_data.world_pos) + " in room (" + str(exit_room.x) + "," + str(exit_room.y) + ") size " + str(exit_room.w) + "x" + str(exit_room.h), LogManager.CATEGORY_DUNGEON) # Mark grid cells as stairs for dx in range(wall.w): @@ -1588,7 +1589,7 @@ func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, m # Force place stairs in exit room - used as fallback when normal placement fails # Still tries to avoid door overlaps, but will place stairs even if room is small # Uses same positioning logic as doors: at least 2 tiles from corners - print("DungeonGenerator: Force placing stairs in exit room: ", exit_room.x, ",", exit_room.y, " size: ", exit_room.w, "x", exit_room.h) + LogManager.log("DungeonGenerator: Force placing stairs in exit room: " + str(exit_room.x) + "," + str(exit_room.y) + " size: " + str(exit_room.w) + "x" + str(exit_room.h), LogManager.CATEGORY_DUNGEON) var stairs_data: Dictionary = {} var tile_size = 16 @@ -1747,7 +1748,7 @@ func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, m # If still no valid position found, return empty (don't place stairs that overlap doors!) if not found_position: - print("DungeonGenerator: ERROR - Could not find any position for stairs that doesn't overlap doors!") + LogManager.log_error("DungeonGenerator: ERROR - Could not find any position for stairs that doesn't overlap doors!", LogManager.CATEGORY_DUNGEON) return {} stairs_data = { @@ -1807,7 +1808,7 @@ func _force_place_stairs(exit_room: Dictionary, grid: Array, tile_grid: Array, m # Fallback: use UP stairs tiles tile_grid[x][y] = STAIRS_UP_START + Vector2i(dx, dy) - print("DungeonGenerator: Force placed ", stairs_dir, " stairs at tile (", stairs_data.x, ",", stairs_data.y, ") world pos: ", stairs_data.world_pos) + LogManager.log("DungeonGenerator: Force placed " + str(stairs_dir) + " stairs at tile (" + str(stairs_data.x) + "," + str(stairs_data.y) + ") world pos: " + str(stairs_data.world_pos), LogManager.CATEGORY_DUNGEON) return stairs_data func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size: Vector2i, all_doors: Array, all_enemies: Array, rng: RandomNumberGenerator, room_puzzle_data: Dictionary = {}) -> Array: @@ -1828,13 +1829,13 @@ func _place_interactable_objects_in_room(room: Dictionary, grid: Array, map_size if puzzle_room.x == room.x and puzzle_room.y == room.y and \ puzzle_room.w == room.w and puzzle_room.h == room.h: var puzzle_info = room_puzzle_data[puzzle_room] - print("DungeonGenerator: Checking room (", room.x, ",", room.y, ") - puzzle_room (", puzzle_room.x, ",", puzzle_room.y, ") puzzle_type: ", puzzle_info.type) + LogManager.log("DungeonGenerator: Checking room (" + str(room.x) + "," + str(room.y) + ") - puzzle_room (" + str(puzzle_room.x) + "," + str(puzzle_room.y) + ") puzzle_type: " + str(puzzle_info.type), LogManager.CATEGORY_DUNGEON) if puzzle_info.type == "switch_pillar": has_pillar_switch_puzzle = true - print("DungeonGenerator: Room (", room.x, ",", room.y, ") has pillar switch puzzle - will spawn at least 1 pillar") + LogManager.log("DungeonGenerator: Room (" + str(room.x) + "," + str(room.y) + ") has pillar switch puzzle - will spawn at least 1 pillar", LogManager.CATEGORY_DUNGEON) break else: - print("DungeonGenerator: room_puzzle_data is empty for room (", room.x, ",", room.y, ")") + LogManager.log("DungeonGenerator: room_puzzle_data is empty for room (" + str(room.x) + "," + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) # Calculate room floor area (excluding walls) var floor_w = room.w - 4 # Excluding 2-tile walls on each side @@ -2090,7 +2091,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ # STEP 1: For each room (except start/exit), randomly decide if it has a door-puzzle var puzzle_room_chance = 0.4 # 40% chance per room - print("DungeonGenerator: Assigning puzzles to rooms (", all_rooms.size(), " total rooms, excluding start/exit)") + LogManager.log("DungeonGenerator: Assigning puzzles to rooms (" + str(all_rooms.size()) + " total rooms, excluding start/exit)", LogManager.CATEGORY_DUNGEON) for i in range(all_rooms.size()): if i == start_room_index or i == exit_room_index: continue # Skip start and exit rooms @@ -2098,7 +2099,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ var room = all_rooms[i] if rng.randf() < puzzle_room_chance: - print("DungeonGenerator: Room (", room.x, ", ", room.y, ") selected for puzzle assignment") + LogManager.log("DungeonGenerator: Room (" + str(room.x) + ", " + str(room.y) + ") selected for puzzle assignment", LogManager.CATEGORY_DUNGEON) # This room has a puzzle! # CRITICAL SAFETY CHECK: Never assign puzzles to start or exit rooms # Double-check even though we skip them in the loop @@ -2128,10 +2129,10 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ doors_in_room.append(door) if doors_in_room.size() == 0: - print("DungeonGenerator: Room (", room.x, ", ", room.y, ") has no doors connected - skipping puzzle assignment") + LogManager.log("DungeonGenerator: Room (" + str(room.x) + ", " + str(room.y) + ") has no doors connected - skipping puzzle assignment", LogManager.CATEGORY_DUNGEON) continue # No doors connected to this room, skip - print("DungeonGenerator: Room (", room.x, ", ", room.y, ") has ", doors_in_room.size(), " doors - assigning puzzle") + LogManager.log("DungeonGenerator: Room (" + str(room.x) + ", " + str(room.y) + ") has " + str(doors_in_room.size()) + " doors - assigning puzzle", LogManager.CATEGORY_DUNGEON) # Decide puzzle type: 33% walk switch, 33% pillar switch, 33% enemy spawner (if room is large enough) var can_have_enemies = false @@ -2153,13 +2154,13 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ "type": puzzle_type, "doors": doors_in_room } - print("DungeonGenerator: Stored puzzle data for room (", room.x, ", ", room.y, ") - type: ", puzzle_type, ", doors: ", doors_in_room.size()) + LogManager.log("DungeonGenerator: Stored puzzle data for room (" + str(room.x) + ", " + str(room.y) + ") - type: " + str(puzzle_type) + ", doors: " + str(doors_in_room.size()), LogManager.CATEGORY_DUNGEON) # Mark these doors as assigned for door in doors_in_room: assigned_doors.append(door) - print("DungeonGenerator: Assigned puzzles to ", room_puzzle_data.size(), " rooms") + LogManager.log("DungeonGenerator: Assigned puzzles to " + str(room_puzzle_data.size()) + " rooms", LogManager.CATEGORY_DUNGEON) # STEP 2: Create blocking doors for rooms with puzzles # CRITICAL: Blocking doors should ONLY be placed ON THE DOORS IN THE PUZZLE ROOM @@ -2168,7 +2169,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ for room in room_puzzle_data.keys(): # CRITICAL SAFETY CHECK #1: Verify this room is actually in room_puzzle_data if not room in room_puzzle_data: - push_error("DungeonGenerator: ERROR - Room (", room.x, ", ", room.y, ") is NOT in room_puzzle_data! This should never happen!") + LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") is NOT in room_puzzle_data! This should never happen!", LogManager.CATEGORY_DUNGEON) continue # CRITICAL SAFETY CHECK #2: Never create blocking doors for start or exit rooms @@ -2181,12 +2182,12 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ break if room_index == start_room_index or room_index == exit_room_index: - push_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start/exit room! Skipping.") + LogManager.log_error("DungeonGenerator: ERROR - Attempted to create blocking doors for start/exit room! Skipping.", LogManager.CATEGORY_DUNGEON) continue # CRITICAL SAFETY CHECK #3: Verify this room is actually in all_rooms (sanity check) if room_index == -1: - push_error("DungeonGenerator: ERROR - Room (", room.x, ", ", room.y, ") not found in all_rooms! Skipping.") + LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") not found in all_rooms! Skipping.", LogManager.CATEGORY_DUNGEON) continue var puzzle_info = room_puzzle_data[room] @@ -2194,7 +2195,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ var puzzle_type = puzzle_info.type if doors_in_room.size() == 0: - print("DungeonGenerator: WARNING - Room has puzzle but no doors! Skipping.") + LogManager.log("DungeonGenerator: WARNING - Room has puzzle but no doors! Skipping.", LogManager.CATEGORY_DUNGEON) continue # Randomly choose door type: 50% StoneDoor, 50% GateDoor @@ -2219,9 +2220,9 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ "switch_data": switch_data, "switch_room": room } - print("DungeonGenerator: Created switch puzzle for room (", room.x, ", ", room.y, ") - type: ", switch_type) + LogManager.log("DungeonGenerator: Created switch puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - type: " + str(switch_type), LogManager.CATEGORY_DUNGEON) else: - print("DungeonGenerator: WARNING - Could not place floor switch in puzzle room (", room.x, ", ", room.y, ")! Skipping puzzle.") + LogManager.log("DungeonGenerator: WARNING - Could not place floor switch in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON) elif puzzle_type == "enemy": # Add enemy spawner IN THE PUZZLE ROOM var spawner_positions = [] @@ -2246,13 +2247,13 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ "spawner_data": spawner_data, "spawner_room": room } - print("DungeonGenerator: Created enemy spawner puzzle for room (", room.x, ", ", room.y, ") - spawner at ", spawner_data.position) + LogManager.log("DungeonGenerator: Created enemy spawner puzzle for room (" + str(room.x) + ", " + str(room.y) + ") - spawner at " + str(spawner_data.position), LogManager.CATEGORY_DUNGEON) else: - print("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (", room.x, ", ", room.y, ")! Skipping puzzle.") + LogManager.log("DungeonGenerator: WARNING - Could not place enemy spawner in puzzle room (" + str(room.x) + ", " + str(room.y) + ")! Skipping puzzle.", LogManager.CATEGORY_DUNGEON) # CRITICAL SAFETY CHECK: Only create blocking doors if puzzle element was successfully created if not puzzle_element_created: - push_error("DungeonGenerator: ERROR - Puzzle element was NOT created for room (", room.x, ", ", room.y, ") with puzzle_type: ", puzzle_type, "! Skipping ALL doors in this room.") + LogManager.log_error("DungeonGenerator: ERROR - Puzzle element was NOT created for room (" + str(room.x) + ", " + str(room.y) + ") with puzzle_type: " + str(puzzle_type) + "! Skipping ALL doors in this room.", LogManager.CATEGORY_DUNGEON) # Remove doors from assigned list since we're not creating the puzzle for door in doors_in_room: if door in assigned_doors: @@ -2261,12 +2262,12 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ # CRITICAL: Verify puzzle_element_data is valid before proceeding if puzzle_element_data.is_empty() or not puzzle_element_data.has("type"): - push_error("DungeonGenerator: ERROR - puzzle_element_data is invalid for room (", room.x, ", ", room.y, ")! puzzle_element_created was true but data is empty!") + LogManager.log_error("DungeonGenerator: ERROR - puzzle_element_data is invalid for room (" + str(room.x) + ", " + str(room.y) + ")! puzzle_element_created was true but data is empty!", LogManager.CATEGORY_DUNGEON) continue # Create blocking doors for at least 1 door (minimum), or all doors in the room # For now, create blocking doors for ALL doors in the puzzle room - print("DungeonGenerator: Creating blocking doors for room (", room.x, ", ", room.y, ") with ", doors_in_room.size(), " doors, puzzle type: ", puzzle_type, ", puzzle_element type: ", puzzle_element_data.type) + LogManager.log("DungeonGenerator: Creating blocking doors for room (" + str(room.x) + ", " + str(room.y) + ") with " + str(doors_in_room.size()) + " doors, puzzle type: " + str(puzzle_type) + ", puzzle_element type: " + str(puzzle_element_data.type), LogManager.CATEGORY_DUNGEON) for door in doors_in_room: # Determine direction based on which WALL of the PUZZLE ROOM the door is on var direction = _determine_door_direction_for_puzzle_room(door, room, all_rooms) @@ -2343,7 +2344,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ # Position is the OPEN state position (will move to CLOSED when entering room) # CRITICAL: Verify room is still a valid puzzle room before creating door if not room in room_puzzle_data: - push_error("DungeonGenerator: ERROR - Room (", room.x, ", ", room.y, ") is no longer in room_puzzle_data! Cannot create door.") + LogManager.log_error("DungeonGenerator: ERROR - Room (" + str(room.x) + ", " + str(room.y) + ") is no longer in room_puzzle_data! Cannot create door.", LogManager.CATEGORY_DUNGEON) continue # NOTE: door_room1 is already declared at line 1933 and verified to match puzzle room at line 1935-1940 @@ -2364,7 +2365,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ # Store puzzle room as room1 for blocking doors door_data.original_room1 = room # Puzzle room is always room1 for blocking doors - print("DungeonGenerator: Creating blocking door for puzzle room (", room.x, ", ", room.y, ") - direction: ", direction, ", open_tile: (", open_tile_x, ",", open_tile_y, ")") + LogManager.log("DungeonGenerator: Creating blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open_tile: (" + str(open_tile_x) + "," + str(open_tile_y) + ")", LogManager.CATEGORY_DUNGEON) # CRITICAL: Add puzzle-specific data from the puzzle_element_data created above (shared across all doors in room) # Only add door if puzzle element data is valid @@ -2379,7 +2380,7 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ door_data.switch_type = puzzle_element_data.switch_type door_data.switch_required_weight = puzzle_element_data.switch_weight door_has_valid_puzzle = true - print("DungeonGenerator: Added switch data to door - switch at (", door_data.switch_tile_x, ", ", door_data.switch_tile_y, ")") + LogManager.log("DungeonGenerator: Added switch data to door - switch at (" + str(door_data.switch_tile_x) + ", " + str(door_data.switch_tile_y) + ")", LogManager.CATEGORY_DUNGEON) elif puzzle_element_data.has("type") and puzzle_element_data.type == "enemy": if puzzle_element_data.has("spawner_data") and puzzle_element_data.spawner_data.has("position"): if not "enemy_spawners" in door_data: @@ -2393,11 +2394,11 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ }) door_data.requires_enemies = true door_has_valid_puzzle = true - print("DungeonGenerator: Added enemy spawner data to door - spawner at (", puzzle_element_data.spawner_data.tile_x, ", ", puzzle_element_data.spawner_data.tile_y, ")") + LogManager.log("DungeonGenerator: Added enemy spawner data to door - spawner at (" + str(puzzle_element_data.spawner_data.tile_x) + ", " + str(puzzle_element_data.spawner_data.tile_y) + ")", LogManager.CATEGORY_DUNGEON) # CRITICAL SAFETY CHECK: Only add door to blocking doors if it has valid puzzle element if not door_has_valid_puzzle: - push_error("DungeonGenerator: ERROR - Blocking door for room (", room.x, ", ", room.y, ") has no valid puzzle element! Skipping door. puzzle_type: ", puzzle_type, ", puzzle_element_data: ", puzzle_element_data) + LogManager.log_error("DungeonGenerator: ERROR - Blocking door for room (" + str(room.x) + ", " + str(room.y) + ") has no valid puzzle element! Skipping door. puzzle_type: " + str(puzzle_type) + ", puzzle_element_data: " + str(puzzle_element_data), LogManager.CATEGORY_DUNGEON) continue # Skip this door - don't add it to blocking_doors # FINAL SAFETY CHECK: Verify door has either requires_switch or requires_enemies set @@ -2405,18 +2406,18 @@ func _place_blocking_doors(all_rooms: Array, all_doors: Array, grid: Array, map_ var has_switch = door_data.get("requires_switch", false) == true var has_enemies = door_data.get("requires_enemies", false) == true if not has_switch and not has_enemies: - push_error("DungeonGenerator: ERROR - Blocking door (StoneDoor/GateDoor) has neither requires_switch nor requires_enemies! Door data: ", door_data.keys(), " - SKIPPING DOOR") + LogManager.log_error("DungeonGenerator: ERROR - Blocking door (StoneDoor/GateDoor) has neither requires_switch nor requires_enemies! Door data: " + str(door_data.keys()) + " - SKIPPING DOOR", LogManager.CATEGORY_DUNGEON) continue # Skip this door - it's invalid # FINAL CRITICAL SAFETY CHECK: Verify door's blocking_room matches the puzzle room exactly if door_data.blocking_room.x != room.x or door_data.blocking_room.y != room.y or \ door_data.blocking_room.w != room.w or door_data.blocking_room.h != room.h: - push_error("DungeonGenerator: ERROR - Door blocking_room (", door_data.blocking_room.x, ",", door_data.blocking_room.y, ") doesn't match puzzle room (", room.x, ",", room.y, ")! This door is for wrong room! SKIPPING DOOR") + LogManager.log_error("DungeonGenerator: ERROR - Door blocking_room (" + str(door_data.blocking_room.x) + "," + str(door_data.blocking_room.y) + ") doesn't match puzzle room (" + str(room.x) + "," + str(room.y) + ")! This door is for wrong room! SKIPPING DOOR", LogManager.CATEGORY_DUNGEON) continue # Skip this door - it's for the wrong room # Add door to blocking doors list ONLY if it has valid puzzle element blocking_doors.append(door_data) - print("DungeonGenerator: Created blocking door for puzzle room (", room.x, ", ", room.y, ") - direction: ", direction, ", open tile: (", open_tile_x, ", ", open_tile_y, "), puzzle_type: ", puzzle_type, ", has_switch: ", door_data.get("requires_switch", false), ", has_enemies: ", door_data.get("requires_enemies", false)) + LogManager.log("DungeonGenerator: Created blocking door for puzzle room (" + str(room.x) + ", " + str(room.y) + ") - direction: " + str(direction) + ", open tile: (" + str(open_tile_x) + ", " + str(open_tile_y) + "), puzzle_type: " + str(puzzle_type) + ", has_switch: " + str(door_data.get("requires_switch", false)) + ", has_enemies: " + str(door_data.get("requires_enemies", false)), LogManager.CATEGORY_DUNGEON) # STEP 3: Randomly assign some doors as KeyDoors (except start/exit room doors and already assigned doors) var key_door_chance = 0.2 # 20% chance per door diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index 403a361..0560ec8 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -142,10 +142,9 @@ func _physics_process(delta): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_enemy_position"): # Send via game_world using enemy name/index and position for identification - game_world._sync_enemy_position.rpc(enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val) - else: - # Fallback: try direct rpc (may fail if node path doesn't match) - rpc("_sync_position", position, velocity, position_z, current_direction, anim_frame, "", 0, state_val) + game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val]) + # Removed fallback rpc() call - it causes node path resolution errors + # If game_world is not available, skip sync (will sync next frame) func _ai_behavior(_delta): # Override in subclasses @@ -235,7 +234,7 @@ func _attack_player(player): # Fallback: broadcast if we can't get peer_id player.rpc_take_damage.rpc(damage, global_position) attack_timer = attack_cooldown - print(name, " attacked ", player.name, " (peer: ", player_peer_id, ", server: ", multiplayer.get_unique_id(), ")") + LogManager.log(str(name) + " attacked " + str(player.name) + " (peer: " + str(player_peer_id) + ", server: " + str(multiplayer.get_unique_id()) + ")", LogManager.CATEGORY_ENEMY) func _find_nearest_player() -> Node: var players = get_tree().get_nodes_in_group("player") @@ -306,7 +305,8 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals var dodge_chance = character_stats.dodge_chance if dodge_roll < dodge_chance: _was_dodged = true - print(name, " DODGED the attack! (DEX: ", character_stats.baseStats.dex + character_stats.get_pass("dex"), ", dodge chance: ", dodge_chance * 100.0, "%)") + var dex_total = character_stats.baseStats.dex + character_stats.get_pass("dex") + LogManager.log(str(name) + " DODGED the attack! (DEX: " + str(dex_total) + ", dodge chance: " + str(dodge_chance * 100.0) + "%)", LogManager.CATEGORY_ENEMY) # Show "DODGED" text _show_damage_number(0.0, from_position, false, false, true) # is_dodged = true # Sync dodge visual to clients (pass damage_amount=0.0 and is_dodged=true to indicate dodge) @@ -315,7 +315,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_enemy_damage_visual"): - game_world._sync_enemy_damage_visual.rpc(enemy_name, enemy_index, 0.0, from_position, false, true) + game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, 0.0, from_position, false, true]) return # No damage taken, exit early # If not dodged, apply damage with DEF reduction @@ -328,12 +328,12 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals if character_stats.hp <= 0: character_stats.no_health.emit() var effective_def = character_stats.defense * (0.2 if is_critical else 1.0) - print(name, " took ", actual_damage, " damage (", amount, " base - ", effective_def, " DEF = ", actual_damage, ")! Health: ", current_health, "/", character_stats.maxhp) + LogManager.log(str(name) + " took " + str(actual_damage) + " damage (" + str(amount) + " base - " + str(effective_def) + " DEF = " + str(actual_damage) + ")! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY) else: # Fallback for legacy (shouldn't happen if _initialize_character_stats is called) current_health -= amount actual_damage = amount - print(name, " took ", amount, " damage! Health: ", current_health, " (critical: ", is_critical, ")") + LogManager.log(str(name) + " took " + str(amount) + " damage! Health: " + str(current_health) + " (critical: " + str(is_critical) + ")", LogManager.CATEGORY_ENEMY) # Calculate knockback direction (away from attacker) var knockback_direction = (global_position - from_position).normalized() @@ -357,7 +357,7 @@ func take_damage(amount: float, from_position: Vector2, is_critical: bool = fals var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_enemy_damage_visual"): - game_world._sync_enemy_damage_visual.rpc(enemy_name, enemy_index, actual_damage, from_position, is_critical) + game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, from_position, is_critical]) else: # Fallback: try direct RPC (may fail if node path doesn't match) _sync_damage_visual.rpc(actual_damage, from_position, is_critical) @@ -467,7 +467,7 @@ func _notify_doors_enemy_died(): if door.requires_enemies: # Trigger puzzle state check immediately (doors will verify if all enemies are dead) door.call_deferred("_check_puzzle_state") - print("Enemy: Notified door ", door.name, " to check puzzle state after enemy death") + LogManager.log("Enemy: Notified door " + str(door.name) + " to check puzzle state after enemy death", LogManager.CATEGORY_ENEMY) func _set_animation(_anim_name: String): # Virtual function - override in subclasses that use animation state system @@ -479,17 +479,26 @@ func _die(): return is_dead = true - print(name, " died!") + LogManager.log(str(name) + " died!", LogManager.CATEGORY_ENEMY) + + # Track defeated enemy for syncing to new clients + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 + if enemy_index >= 0: + game_world.defeated_enemies[enemy_index] = true + LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY) # Credit kill and grant EXP to the player who dealt the fatal damage if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: killer_player.character_stats.kills += 1 - print(name, " kill credited to ", killer_player.name, " (total kills: ", killer_player.character_stats.kills, ")") + LogManager.log(str(name) + " kill credited to " + str(killer_player.name) + " (total kills: " + str(killer_player.character_stats.kills) + ")", LogManager.CATEGORY_ENEMY) # Grant EXP to the killer if exp_reward > 0: killer_player.character_stats.add_xp(exp_reward) - print(name, " granted ", exp_reward, " EXP to ", killer_player.name) + LogManager.log(str(name) + " granted " + str(exp_reward) + " EXP to " + str(killer_player.name), LogManager.CATEGORY_ENEMY) # Sync kill update to client if this player belongs to a client # Only sync if we're on the server and the killer is a client's player @@ -499,7 +508,7 @@ func _die(): if killer_peer_id != 0 and killer_peer_id != multiplayer.get_unique_id() and killer_player.has_method("_sync_stats_update"): # Server is updating a client's player stats - sync to the client var coins = killer_player.character_stats.coin if "coin" in killer_player.character_stats else 0 - print(name, " syncing kill stats to client peer_id=", killer_peer_id, " kills=", killer_player.character_stats.kills, " coins=", coins) + LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins), LogManager.CATEGORY_ENEMY) killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins) # Spawn loot immediately (before death animation) @@ -512,7 +521,7 @@ func _die(): var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_enemy_death"): - game_world._sync_enemy_death.rpc(enemy_name, enemy_index) + game_world._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index]) else: # Fallback: try direct RPC (may fail if node path doesn't match) _sync_death.rpc() @@ -528,20 +537,20 @@ func _play_death_animation(): func _spawn_loot(): # Only spawn loot on server/authority if not is_multiplayer_authority(): - print(name, " _spawn_loot() called but not authority, skipping") + LogManager.log(str(name) + " _spawn_loot() called but not authority, skipping", LogManager.CATEGORY_ENEMY) return - print(name, " _spawn_loot() called on authority") + LogManager.log(str(name) + " _spawn_loot() called on authority", LogManager.CATEGORY_ENEMY) # Spawn random loot at enemy position var loot_scene = preload("res://scenes/loot.tscn") if not loot_scene: - print(name, " ERROR: loot_scene is null!") + LogManager.log_error(str(name) + " ERROR: loot_scene is null!", LogManager.CATEGORY_ENEMY) return # Random chance to drop loot (70% chance) var loot_chance = randf() - print(name, " loot chance roll: ", loot_chance, " (need > 0.3)") + LogManager.log(str(name) + " loot chance roll: " + str(loot_chance) + " (need > 0.3)", LogManager.CATEGORY_ENEMY) if loot_chance > 0.3: # Decide what to drop: 30% coin, 30% food, 40% item var drop_roll = randf() @@ -575,7 +584,7 @@ func _spawn_loot(): var entities_node = get_parent() if not entities_node: - print(name, " ERROR: entities_node is null! Cannot spawn loot!") + LogManager.log_error(str(name) + " ERROR: entities_node is null! Cannot spawn loot!", LogManager.CATEGORY_ENEMY) return if drop_item: @@ -583,7 +592,7 @@ func _spawn_loot(): var item = ItemDatabase.get_random_enemy_drop() if item: ItemLootHelper.spawn_item_loot(item, global_position, entities_node, game_world) - print(name, " ✓ dropped item: ", item.item_name, " at ", safe_spawn_pos) + LogManager.log(str(name) + " ✓ dropped item: " + str(item.item_name) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY) else: # Spawn regular loot (coin or food) var loot = loot_scene.instantiate() @@ -595,7 +604,7 @@ func _spawn_loot(): loot.velocity_z = random_velocity_z loot.velocity_set_by_spawner = true loot.is_airborne = true - print(name, " ✓ dropped loot: ", loot_type, " at ", safe_spawn_pos, " (original enemy pos: ", global_position, ")") + LogManager.log(str(name) + " ✓ dropped loot: " + str(loot_type) + " at " + str(safe_spawn_pos) + " (original enemy pos: " + str(global_position) + ")", LogManager.CATEGORY_ENEMY) # Sync loot spawn to all clients (use safe position) if multiplayer.has_multiplayer_peer(): @@ -608,12 +617,12 @@ func _spawn_loot(): # Store loot ID on server loot instance loot.set_meta("loot_id", loot_id) # Sync to clients with ID - game_world._sync_loot_spawn.rpc(safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id) - print(name, " ✓ synced loot spawn to clients") + game_world._rpc_to_ready_peers("_sync_loot_spawn", [safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id]) + LogManager.log(str(name) + " ✓ synced loot spawn to clients", LogManager.CATEGORY_ENEMY) else: - print(name, " ERROR: game_world not found for loot sync!") + LogManager.log_error(str(name) + " ERROR: game_world not found for loot sync!", LogManager.CATEGORY_ENEMY) else: - print(name, " loot chance failed (", loot_chance, " <= 0.3), no loot dropped") + LogManager.log(str(name) + " loot chance failed (" + str(loot_chance) + " <= 0.3), no loot dropped", LogManager.CATEGORY_ENEMY) # This function can be called directly (not just via RPC) when game_world routes the update func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0, frame: int = 0, anim: String = "", frame_num: int = 0, state_value: int = -1): @@ -625,12 +634,12 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0 # Debug: Log when client receives position update (first few times) if not has_meta("position_sync_count"): set_meta("position_sync_count", 0) - print("Enemy ", name, " (client) RECEIVED first position sync! pos=", pos, " authority: ", get_multiplayer_authority(), " is_authority: ", is_multiplayer_authority(), " in_tree: ", is_inside_tree()) + LogManager.log("Enemy " + str(name) + " (client) RECEIVED first position sync! pos=" + str(pos) + " authority: " + str(get_multiplayer_authority()) + " is_authority: " + str(is_multiplayer_authority()) + " in_tree: " + str(is_inside_tree()), LogManager.CATEGORY_ENEMY) var sync_count = get_meta("position_sync_count") + 1 set_meta("position_sync_count", sync_count) if sync_count <= 3: # Log first 3 syncs - print("Enemy ", name, " (client) received position sync #", sync_count, ": pos=", pos) + LogManager.log("Enemy " + str(name) + " (client) received position sync #" + str(sync_count) + ": pos=" + str(pos), LogManager.CATEGORY_ENEMY) # Update position and state position = pos @@ -661,6 +670,10 @@ func _sync_damage_visual(damage_amount: float = 0.0, attacker_position: Vector2 if is_multiplayer_authority(): return # Server ignores its own updates + # Trigger damage animation and state change on client + # This ensures clients play the damage animation (e.g., slime DAMAGE animation) + _on_take_damage() + _flash_damage() # Show damage number on client (even if damage_amount is 0 for dodges/misses) @@ -676,7 +689,7 @@ func _sync_death(): if not is_dead: is_dead = true - print(name, " received death sync, dying on client") + LogManager.log(str(name) + " received death sync, dying on client", LogManager.CATEGORY_ENEMY) # Remove collision layer so they don't collide with players, but still collide with walls # This matches what happens on the server when rats/slimes die @@ -687,7 +700,7 @@ func _sync_death(): _play_death_animation() else: # Already dead, but make sure collision is removed and it's removed from scene - print(name, " received death sync but already dead, ensuring removal") + LogManager.log(str(name) + " received death sync but already dead, ensuring removal", LogManager.CATEGORY_ENEMY) # Remove collision layer if not already removed if get_collision_layer_value(2): diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index aac1aa1..3084d9b 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -200,11 +200,11 @@ func _ready(): # But still deterministic across clients by using a synced random value var random_component = randi() # This will be different each spawn seed_value = hash(str(spawn_position) + str(humanoid_type) + str(random_component)) - print(name, " appearance seed (randomized): ", seed_value, " at spawn position: ", spawn_position, " type: ", humanoid_type) + LogManager.log(str(name) + " appearance seed (randomized): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY) else: # Deterministic based on position and type only seed_value = hash(str(spawn_position) + str(humanoid_type)) - print(name, " appearance seed (deterministic): ", seed_value, " at spawn position: ", spawn_position, " type: ", humanoid_type) + LogManager.log(str(name) + " appearance seed (deterministic): " + str(seed_value) + " at spawn position: " + str(spawn_position) + " type: " + str(humanoid_type), LogManager.CATEGORY_ENEMY) appearance_rng.seed = seed_value # Set up appearance based on type @@ -678,7 +678,7 @@ func _load_type_addons(): sprite_addons.texture = texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 - print(name, " loaded type addon: ", addon_path) + LogManager.log(str(name) + " loaded type addon: " + str(addon_path), LogManager.CATEGORY_ENEMY) HumanoidType.SKELETON: # Can have (but not must) skeleton horns @@ -690,7 +690,7 @@ func _load_type_addons(): sprite_addons.texture = texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 - print(name, " loaded skeleton horns: ", addon_path) + LogManager.log(str(name) + " loaded skeleton horns: " + str(addon_path), LogManager.CATEGORY_ENEMY) HumanoidType.HUMANOID: # Ears are already set by _randomize_appearance, so skip here @@ -706,7 +706,7 @@ func _load_type_addons(): sprite_addons.texture = texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 - print(name, " loaded night elf ears") + LogManager.log(str(name) + " loaded night elf ears", LogManager.CATEGORY_ENEMY) HumanoidType.DEMON: # Can have DemonEars or DemonJaw @@ -718,7 +718,7 @@ func _load_type_addons(): sprite_addons.texture = texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 - print(name, " loaded demon addon: ", addon_path) + LogManager.log(str(name) + " loaded demon addon: " + str(addon_path), LogManager.CATEGORY_ENEMY) func _load_beastkin_addon(): # Load random beastkin addon (low chance, can override type addon) @@ -734,7 +734,7 @@ func _load_beastkin_addon(): sprite_addons.texture = texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 - print(name, " loaded beastkin addon: ", addon_path) + LogManager.log(str(name) + " loaded beastkin addon: " + str(addon_path), LogManager.CATEGORY_ENEMY) func _setup_stats(): # Set stats based on type @@ -796,7 +796,7 @@ func _setup_stats(): charge_multiplier = clamp(charge_multiplier, 0.375, 1.0) # Clamp between 0.375x (2.67x faster) and 1.0x base_attack_charge_time = 0.4 * charge_multiplier - print(name, " stats: DEX=", dex, " attack_cooldown=", attack_cooldown, " charge_time=", base_attack_charge_time) + LogManager.log(str(name) + " stats: DEX=" + str(dex) + " attack_cooldown=" + str(attack_cooldown) + " charge_time=" + str(base_attack_charge_time), LogManager.CATEGORY_ENEMY) # Setup alert indicators if alert_indicator: @@ -874,7 +874,7 @@ func _physics_process(delta): # Send via game_world using enemy name/index and position for identification var enemy_name = name var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 - game_world._sync_enemy_position.rpc(enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1) + game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1]) else: # Fallback: try direct call to _sync_position (not RPC) _sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame) @@ -1188,7 +1188,13 @@ func _perform_attack(): # Sync attack animation to clients first if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): - _sync_attack.rpc(current_direction, attack_direction) + # Use game_world to send RPC instead of rpc() on node instance + # This avoids node path resolution issues when clients haven't spawned yet + var game_world = get_tree().get_first_node_in_group("game_world") + var enemy_name = name + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 + if game_world and game_world.has_method("_sync_enemy_attack"): + game_world._rpc_to_ready_peers("_sync_enemy_attack", [enemy_name, enemy_index, current_direction, attack_direction]) # Delay before spawning sword slash await get_tree().create_timer(0.15).timeout @@ -1207,7 +1213,7 @@ func _perform_attack(): if parent: parent.add_child(projectile) else: - push_error("EnemyHumanoid: ERROR - No parent node to add projectile to!") + LogManager.log_error("EnemyHumanoid: ERROR - No parent node to add projectile to!", LogManager.CATEGORY_ENEMY) projectile.queue_free() func _try_attack_object(obj: Node): @@ -1231,11 +1237,11 @@ func _try_attack_object(obj: Node): # Perform attack - sword projectile will damage the object if it hits # The object will handle damage from sword projectiles (sword_projectile.gd already handles this) _perform_attack() - print(name, " is attacking object ", obj.name, "!") + LogManager.log(str(name) + " is attacking object " + str(obj.name) + "!", LogManager.CATEGORY_ENEMY) -@rpc("authority", "reliable") func _sync_attack(direction: int, attack_dir: Vector2): - # Sync attack to clients + # Sync attack to clients (called by game_world, not via RPC) + # Only process on clients (not authority) if not is_multiplayer_authority(): current_direction = direction as Direction _set_animation("SWORD") @@ -1248,7 +1254,7 @@ func _sync_attack(direction: int, attack_dir: Vector2): projectile.setup(attack_dir, self) var spawn_offset = attack_dir * 10.0 projectile.global_position = global_position + spawn_offset - print(name, " performed synced attack!") + LogManager.log(str(name) + " performed synced attack!", LogManager.CATEGORY_ENEMY) func _set_animation(anim_name: String): if anim_name in ANIMATIONS: diff --git a/src/scripts/enemy_slime.gd b/src/scripts/enemy_slime.gd index 7b2fe5f..a7045b6 100644 --- a/src/scripts/enemy_slime.gd +++ b/src/scripts/enemy_slime.gd @@ -268,6 +268,19 @@ func _update_client_visuals(): # Update visuals on clients based on synced state super._update_client_visuals() + # Map synced state to animation (similar to how bat/rat use state directly) + match state: + SlimeState.IDLE: + _set_animation("IDLE") + SlimeState.MOVING: + _set_animation("MOVE") + SlimeState.JUMPING: + _set_animation("JUMP") + SlimeState.DAMAGED: + _set_animation("DAMAGE") + SlimeState.DYING: + _set_animation("DIE") + # Update animation based on synced state _update_animation(0.0) # Update animation immediately when state changes diff --git a/src/scripts/enemy_spawner.gd b/src/scripts/enemy_spawner.gd index e9988db..7421fa8 100644 --- a/src/scripts/enemy_spawner.gd +++ b/src/scripts/enemy_spawner.gd @@ -126,7 +126,7 @@ func spawn_enemy(): node = node.get_parent() if game_world and game_world.has_method("_sync_smoke_puffs"): - game_world._sync_smoke_puffs.rpc(name, puff_positions) + game_world._rpc_to_ready_peers("_sync_smoke_puffs", [name, puff_positions]) # Wait for smoke puffs to finish animating before spawning enemy # Reduced duration for faster spawning: 4 frames * (1/10.0) seconds per frame = 0.4s, plus move_duration 1.0s, plus fade_duration 0.3s = ~1.7s total @@ -239,7 +239,7 @@ func spawn_enemy(): # Pass humanoid_type if it's a humanoid enemy (for syncing to clients) var sync_humanoid_type = humanoid_type if humanoid_type != null else -1 print(" DEBUG: Calling _sync_enemy_spawn.rpc with name=", name, " pos=", global_position, " scene_index=", scene_index, " humanoid_type=", sync_humanoid_type) - game_world._sync_enemy_spawn.rpc(name, global_position, scene_index, sync_humanoid_type) + game_world._rpc_to_ready_peers("_sync_enemy_spawn", [name, global_position, scene_index, sync_humanoid_type]) print(" Sent RPC to sync enemy spawn to clients: spawner=", name, " pos=", global_position, " scene_index=", scene_index, " humanoid_type=", sync_humanoid_type) else: var has_method_str = str(game_world.has_method("_sync_enemy_spawn")) if game_world else "N/A" diff --git a/src/scripts/floor_switch.gd b/src/scripts/floor_switch.gd index 43a42ea..14763c6 100644 --- a/src/scripts/floor_switch.gd +++ b/src/scripts/floor_switch.gd @@ -186,6 +186,13 @@ func _check_activation(): is_activated = should_activate _update_visual() + # Track activated switch for syncing to new clients + if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and is_activated: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + game_world.activated_switches[name] = true + LogManager.log("FloorSwitch: Tracked activated switch " + name, LogManager.CATEGORY_NETWORK) + # Notify connected doors _notify_doors() diff --git a/src/scripts/game_ui.gd b/src/scripts/game_ui.gd index fbcdf08..981cf6e 100644 --- a/src/scripts/game_ui.gd +++ b/src/scripts/game_ui.gd @@ -9,18 +9,33 @@ extends CanvasLayer @onready var network_mode_container = $Control/MainMenu/VBoxContainer/NetworkModeContainer @onready var local_players_spinbox = $Control/MainMenu/VBoxContainer/LocalPlayersContainer/SpinBox @onready var address_input = $Control/MainMenu/VBoxContainer/AddressContainer/AddressInput +@onready var room_fetch_status_container = $Control/MainMenu/VBoxContainer/RoomFetchStatusContainer +@onready var loading_label = $Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/RoomFetchLoadingContainer/LoadingLabel +@onready var loading_spinner = $Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/RoomFetchLoadingContainer/LoadingSpinner +@onready var last_fetch_label = $Control/MainMenu/VBoxContainer/RoomFetchStatusContainer/LastFetchLabel @onready var network_manager = $"/root/NetworkManager" +var connection_error_label: Label = null +var connection_error_shown: bool = false # Prevent spamming error messages +var is_joining_attempt: bool = false +var last_join_address: String = "" +var room_fetch_timer: Timer = null # Timer for retrying room fetches +var is_auto_joining: bool = false # Track if we're in auto-join mode +var is_hosting: bool = false # Track if we're hosting (don't fetch rooms when hosting) +var room_list_container: VBoxContainer = null # Container for displaying available rooms +var refresh_button: Button = null # Refresh button for manually reloading rooms +var refresh_cooldown_timer: Timer = null # Timer for refresh button cooldown + func _ready(): # Wait for nodes to be ready await get_tree().process_frame - # Debug: Print node paths - print("GameUI _ready() called") - print("Main menu node: ", main_menu) - print("Host button node: ", host_button) - print("Join button node: ", join_button) + # Debug: Print node paths (UI category - disabled by default for network debugging) + LogManager.log("GameUI _ready() called", LogManager.CATEGORY_UI) + LogManager.log("Main menu node: " + str(main_menu), LogManager.CATEGORY_UI) + LogManager.log("Host button node: " + str(host_button), LogManager.CATEGORY_UI) + LogManager.log("Join button node: " + str(join_button), LogManager.CATEGORY_UI) # Verify nodes exist if not host_button: @@ -40,7 +55,7 @@ func _ready(): # On web builds, filter out ENet option (only WebRTC and WebSocket available) if OS.get_name() == "Web": - print("GameUI: Web platform detected, filtering network mode options") + LogManager.log("GameUI: Web platform detected, filtering network mode options", LogManager.CATEGORY_UI) # Remove ENet option (index 0) for web builds network_mode_option.remove_item(0) # Adjust selected index (was 0 for ENet, now 0 is WebRTC) @@ -59,43 +74,52 @@ func _ready(): if network_manager: network_manager.connection_succeeded.connect(_on_connection_succeeded) network_manager.connection_failed.connect(_on_connection_failed) + # Connect to rooms_fetched for displaying rooms (non-auto-join mode) + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_display): + network_manager.rooms_fetched.connect(_on_rooms_fetched_display) else: push_error("NetworkManager not found!") # Check for command-line arguments _check_command_line_args() + + # If WebRTC is selected at startup (not auto-joining and not hosting), fetch rooms + if not is_auto_joining and not is_hosting: + var current_mode = network_manager.network_mode + if current_mode == 1: # WebRTC + _start_room_fetch() func _check_command_line_args(): var args = OS.get_cmdline_args() - print("GameUI: Parsing command-line arguments: ", args) + LogManager.log("GameUI: Parsing command-line arguments: " + str(args), LogManager.CATEGORY_UI) # Parse arguments var should_host = false var should_join = false var should_debug = false var force_webrtc = false - var join_address = "ruinborn.thefirstboss.com" + var join_address = "" # Empty by default - will fetch rooms if WebRTC/WebSocket var local_count = 1 for arg in args: if arg == "--host": should_host = true - print("GameUI: Found --host argument") + LogManager.log("GameUI: Found --host argument", LogManager.CATEGORY_UI) elif arg == "--join": should_join = true - print("GameUI: Found --join argument") + LogManager.log("GameUI: Found --join argument", LogManager.CATEGORY_UI) elif arg == "--websocket" or arg == "--webrtc": force_webrtc = true - print("GameUI: Found --websocket/--webrtc argument (forcing WebSocket mode)") + LogManager.log("GameUI: Found --websocket/--webrtc argument (forcing WebSocket mode)", LogManager.CATEGORY_UI) elif arg == "--room-debug": should_debug = true - print("GameUI: Found --room-debug argument") + LogManager.log("GameUI: Found --room-debug argument", LogManager.CATEGORY_UI) elif arg.begins_with("--address="): join_address = arg.split("=")[1] elif arg.begins_with("--players="): local_count = int(arg.split("=")[1]) - print("GameUI: Parsed flags - should_host: ", should_host, ", should_join: ", should_join, ", force_webrtc: ", force_webrtc, ", should_debug: ", should_debug) + LogManager.log("GameUI: Parsed flags - should_host: " + str(should_host) + ", should_join: " + str(should_join) + ", force_webrtc: " + str(force_webrtc) + ", should_debug: " + str(should_debug), LogManager.CATEGORY_UI) # Force WebRTC mode if --webrtc flag is present if force_webrtc: @@ -106,28 +130,368 @@ func _check_command_line_args(): else: network_mode_option.selected = 1 # WebRTC is second option on native _on_network_mode_changed(network_mode_option.selected) - print("GameUI: WebRTC mode forced via --webrtc flag") + LogManager.log("GameUI: WebRTC mode forced via --webrtc flag", LogManager.CATEGORY_UI) # Set debug flag only if --room-debug is used with --host or --join if should_debug and (should_host or should_join): network_manager.show_room_labels = true - print("Debug mode enabled: room labels will be shown") + LogManager.log("Debug mode enabled: room labels will be shown", LogManager.CATEGORY_UI) else: - print("GameUI: Debug mode NOT enabled - should_debug: ", should_debug, ", should_host: ", should_host, ", should_join: ", should_join) + LogManager.log("GameUI: Debug mode NOT enabled - should_debug: " + str(should_debug) + ", should_host: " + str(should_host) + ", should_join: " + str(should_join), LogManager.CATEGORY_UI) # Auto-start based on arguments if should_host: - print("Auto-hosting due to --host argument") + is_hosting = true # Set flag so we don't fetch rooms + LogManager.log("Auto-hosting due to --host argument", LogManager.CATEGORY_UI) network_manager.set_local_player_count(local_count) if network_manager.host_game(): _start_game() elif should_join: - print("Auto-joining to ", join_address, " due to --join argument") - address_input.text = join_address - network_manager.set_local_player_count(local_count) - if network_manager.join_game(join_address): - # Connection callback will handle starting the game - pass + # Check network mode after it's been set + var current_mode = network_manager.network_mode + if join_address.is_empty() and (current_mode == 1 or current_mode == 2): + # No address provided, and using WebRTC or WebSocket - fetch and auto-join first available room + LogManager.log("Auto-joining: No address provided, fetching available rooms (mode: " + str(current_mode) + ")...", LogManager.CATEGORY_UI) + network_manager.set_local_player_count(local_count) + is_auto_joining = true + # Create timer for retrying room fetches + room_fetch_timer = Timer.new() + room_fetch_timer.name = "RoomFetchTimer" + room_fetch_timer.wait_time = 2.0 # Retry every 2 seconds + room_fetch_timer.timeout.connect(_retry_room_fetch) + room_fetch_timer.autostart = false + add_child(room_fetch_timer) + # Connect to rooms_fetched signal (not one-shot, so we can keep retrying) + if not network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.connect(_on_rooms_fetched_auto_join) + # Show room fetch status UI and start fetching + _show_room_fetch_status() + _start_room_fetch() + elif not join_address.is_empty(): + LogManager.log("Auto-joining to " + join_address + " due to --join argument", LogManager.CATEGORY_UI) + address_input.text = join_address + network_manager.set_local_player_count(local_count) + if network_manager.join_game(join_address): + # Connection callback will handle starting the game + pass + else: + # ENet mode without address - need a default address + var default_address = "127.0.0.1" + LogManager.log("Auto-joining to " + default_address + " due to --join argument (ENet mode, no address provided)", LogManager.CATEGORY_UI) + address_input.text = default_address + network_manager.set_local_player_count(local_count) + if network_manager.join_game(default_address): + # Connection callback will handle starting the game + pass + +func _on_rooms_fetched_display(rooms: Array): + """Display available rooms when fetched (non-auto-join mode)""" + # Only handle if not in auto-join mode (auto-join has its own handler) + if is_auto_joining: + return # Let auto-join handler take care of it + + # Hide loading indicator - request completed + _hide_loading_indicator() + + # Update last fetch time + _update_last_fetch_time() + + # Clear existing room list + _clear_room_list() + + # Display rooms if any are found + if rooms.is_empty(): + LogManager.log("GameUI: No available rooms found", LogManager.CATEGORY_UI) + # Keep room fetch status visible so user can see "Last fetched: ..." even with no rooms + # Add a "No rooms available" message + _add_room_list_header("No available rooms") + else: + LogManager.log("GameUI: Found " + str(rooms.size()) + " available room(s)", LogManager.CATEGORY_UI) + # Add header + _add_room_list_header("Available Rooms:") + # Add each room to the list + for room in rooms: + var room_code = room.get("room", "") + var players = room.get("players", 0) + var level = room.get("level", 1) + _add_room_item(room_code, players, level) + +func _on_rooms_fetched_auto_join(rooms: Array): + """Auto-join the first available room when --join --webrtc is used without address""" + if not is_auto_joining: + return # Not in auto-join mode, ignore + + # Hide loading indicator - request completed + _hide_loading_indicator() + + # Update last fetch time + _update_last_fetch_time() + + if rooms.is_empty(): + LogManager.log("No available rooms found, will retry in 2 seconds...", LogManager.CATEGORY_UI) + # Start timer to retry fetching + if room_fetch_timer: + room_fetch_timer.start() + return + + # Stop retrying - we found rooms! + if room_fetch_timer: + room_fetch_timer.stop() + is_auto_joining = false + + # Hide room fetch status UI + _hide_room_fetch_status() + + # Disconnect from signal since we're done + if network_manager.rooms_fetched.is_connected(_on_rooms_fetched_auto_join): + network_manager.rooms_fetched.disconnect(_on_rooms_fetched_auto_join) + + # Sort rooms by player count (prefer rooms with more players, but not full) + rooms.sort_custom(func(a, b): return a.get("players", 0) < b.get("players", 0)) + + # Try to join the first room + var room = rooms[0] + var room_code = room.get("room", "") + if room_code.is_empty(): + LogManager.log("Room code is empty, cannot join - will retry", LogManager.CATEGORY_UI) + # Restart timer to retry + if room_fetch_timer: + room_fetch_timer.start() + is_auto_joining = true + # Keep showing status UI + return + + # Get local player count from spinbox (same as _on_join_pressed does) + var local_count = int(local_players_spinbox.value) + + LogManager.log("Auto-joining room: " + room_code + " (players: " + str(room.get("players", 0)) + ", level: " + str(room.get("level", 1)) + ")", LogManager.CATEGORY_UI) + address_input.text = room_code + network_manager.set_local_player_count(local_count) + if network_manager.join_game(room_code): + # Connection callback will handle starting the game + pass + +func _retry_room_fetch(): + """Retry fetching available rooms""" + if not is_auto_joining: + if room_fetch_timer: + room_fetch_timer.stop() + return + + LogManager.log("Retrying room fetch...", LogManager.CATEGORY_UI) + _start_room_fetch() + +func _start_room_fetch(): + """Start fetching rooms and show loading indicator""" + # Only fetch if WebRTC mode and not hosting + if network_manager.network_mode != 1: # Not WebRTC + return + + if is_hosting or network_manager.is_hosting: + LogManager.log("GameUI: Skipping room fetch - we are hosting", LogManager.CATEGORY_UI) + return + + # Show the status container and loading indicator + _show_room_fetch_status() + _show_loading_indicator() + + # Check if a request is already in progress before starting a new one + var fetch_result = network_manager.fetch_available_rooms() + if not fetch_result: + # Request failed or is already in progress - hide loading indicator + _hide_loading_indicator() + # Start timer to retry (only in auto-join mode) + if is_auto_joining and room_fetch_timer: + room_fetch_timer.start() + +func _show_room_fetch_status(): + """Show the room fetch status UI""" + if room_fetch_status_container: + room_fetch_status_container.visible = true + if last_fetch_label: + last_fetch_label.text = "Last fetched: Never" + # Create room list container if it doesn't exist + _create_room_list_container() + # Create refresh button if it doesn't exist + _create_refresh_button() + +func _show_loading_indicator(): + """Show the loading indicator""" + if loading_label: + loading_label.text = "Loading rooms..." + if loading_spinner: + loading_spinner.text = "⏳" + +func _hide_loading_indicator(): + """Hide the loading indicator""" + if loading_label: + loading_label.text = "" + if loading_spinner: + loading_spinner.text = "" + +func _hide_room_fetch_status(): + """Hide the room fetch status UI""" + if room_fetch_status_container: + room_fetch_status_container.visible = false + +func _create_refresh_button(): + """Create a refresh button for manually reloading the room list""" + if refresh_button: + return # Already exists + + if not room_fetch_status_container: + return + + # Create HBoxContainer for refresh button (below last fetch label) + var refresh_container = HBoxContainer.new() + refresh_container.name = "RefreshButtonContainer" + refresh_container.alignment = BoxContainer.ALIGNMENT_END + + # Create refresh button + refresh_button = Button.new() + refresh_button.name = "RefreshButton" + refresh_button.text = "Refresh Rooms" + refresh_button.pressed.connect(_on_refresh_button_pressed) + + refresh_container.add_child(refresh_button) + + # Add refresh container after LastFetchLabel (or at the end if LastFetchLabel doesn't exist) + var last_fetch_node = room_fetch_status_container.get_node_or_null("LastFetchLabel") + if last_fetch_node: + var index = last_fetch_node.get_index() + 1 + room_fetch_status_container.add_child(refresh_container) + room_fetch_status_container.move_child(refresh_container, index) + else: + room_fetch_status_container.add_child(refresh_container) + + # Create cooldown timer + refresh_cooldown_timer = Timer.new() + refresh_cooldown_timer.name = "RefreshCooldownTimer" + refresh_cooldown_timer.wait_time = 5.0 + refresh_cooldown_timer.one_shot = true + refresh_cooldown_timer.timeout.connect(_on_refresh_cooldown_finished) + refresh_cooldown_timer.autostart = false + add_child(refresh_cooldown_timer) + +func _on_refresh_button_pressed(): + """Handle refresh button click""" + # Disable button and start cooldown + if refresh_button: + refresh_button.disabled = true + refresh_button.text = "Refreshing..." + + if refresh_cooldown_timer: + refresh_cooldown_timer.start() + + # Fetch rooms + _start_room_fetch() + +func _on_refresh_cooldown_finished(): + """Re-enable refresh button after cooldown""" + if refresh_button: + refresh_button.disabled = false + refresh_button.text = "Refresh Rooms" + +func _update_last_fetch_time(): + """Update the last fetch time label with current datetime""" + if last_fetch_label: + var now = Time.get_datetime_dict_from_system() + var time_str = "%02d:%02d:%02d" % [now.hour, now.minute, now.second] + last_fetch_label.text = "Last fetched: " + time_str + +func _create_room_list_container(): + """Create the container for displaying available rooms""" + if room_list_container: + return # Already exists + + if not room_fetch_status_container: + return + + # Create a ScrollContainer for the room list + var scroll_container = ScrollContainer.new() + scroll_container.name = "RoomListScrollContainer" + scroll_container.custom_minimum_size = Vector2(0, 150) # Set a reasonable height + scroll_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + # Create VBoxContainer inside scroll container + room_list_container = VBoxContainer.new() + room_list_container.name = "RoomListContainer" + room_list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + scroll_container.add_child(room_list_container) + + # Add scroll container to room_fetch_status_container (after LastFetchLabel) + room_fetch_status_container.add_child(scroll_container) + +func _clear_room_list(): + """Clear all room items from the list""" + if room_list_container: + for child in room_list_container.get_children(): + child.queue_free() + +func _add_room_list_header(text: String): + """Add a header label to the room list""" + if not room_list_container: + _create_room_list_container() + if not room_list_container: + return + + var label = Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 14) + room_list_container.add_child(label) + +func _add_room_item(room_code: String, players: int, level: int): + """Add a room item with join button to the list""" + if not room_list_container: + _create_room_list_container() + if not room_list_container: + return + + # Create HBoxContainer for this room + var room_row = HBoxContainer.new() + room_row.name = "RoomRow_" + room_code + + # Room code label + var code_label = Label.new() + code_label.text = room_code + code_label.custom_minimum_size = Vector2(100, 0) + code_label.add_theme_font_size_override("font_size", 12) + room_row.add_child(code_label) + + # Info label (players, level) + var info_label = Label.new() + info_label.text = "Players: %d | Level: %d" % [players, level] + info_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + info_label.add_theme_font_size_override("font_size", 12) + room_row.add_child(info_label) + + # Join button + var room_join_button = Button.new() + room_join_button.text = "Join" + room_join_button.custom_minimum_size = Vector2(80, 0) + # Connect button to join function + room_join_button.pressed.connect(func(): _join_room(room_code)) + room_row.add_child(room_join_button) + + room_list_container.add_child(room_row) + +func _join_room(room_code: String): + """Join a room by setting the address and clicking join""" + if room_code.is_empty(): + return + + # Set the address input + if address_input: + address_input.text = room_code + + # Get local player count + var local_count = int(local_players_spinbox.value) + network_manager.set_local_player_count(local_count) + + # Join the game + if network_manager.join_game(room_code): + LogManager.log("Joining room: " + room_code, LogManager.CATEGORY_UI) func _on_network_mode_changed(index: int): # On web builds, index 0 = WebRTC, index 1 = WebSocket @@ -153,9 +517,17 @@ func _on_network_mode_changed(index: int): address_input.placeholder_text = "Enter Room Code (e.g., ABC123)" var mode_names = ["ENet", "WebRTC", "WebSocket"] - print("GameUI: Network mode changed to: ", mode_names[actual_mode]) + LogManager.log("GameUI: Network mode changed to: " + mode_names[actual_mode], LogManager.CATEGORY_UI) + + # If WebRTC is selected, fetch available rooms (unless we're auto-joining or hosting) + if actual_mode == 1 and not is_auto_joining and not is_hosting: # WebRTC mode + _start_room_fetch() + elif actual_mode != 1: # Not WebRTC mode + # Hide room fetch status if switching away from WebRTC + _hide_room_fetch_status() func _on_host_pressed(): + is_hosting = true # Set flag so we don't fetch rooms var local_count = int(local_players_spinbox.value) network_manager.set_local_player_count(local_count) @@ -171,42 +543,115 @@ func _on_host_pressed(): _start_game() func _on_join_pressed(): + # Reset error state when attempting new connection + connection_error_shown = false + _hide_connection_error() + is_joining_attempt = true var address = address_input.text if address.is_empty(): var mode = network_manager.network_mode if mode == 1 or mode == 2: # WebRTC or WebSocket - print("Error: Please enter a room code") + LogManager.log("Error: Please enter a room code", LogManager.CATEGORY_UI) return - else: # ENet + else: # ENet mode without address - use default address = "127.0.0.1" var local_count = int(local_players_spinbox.value) network_manager.set_local_player_count(local_count) if network_manager.join_game(address): + last_join_address = address var mode = network_manager.network_mode if mode == 1: # WebRTC - print("Joining WebRTC game with room code: ", address) + LogManager.log("Joining WebRTC game with room code: " + address, LogManager.CATEGORY_UI) elif mode == 2: # WebSocket - print("Joining WebSocket game with room code: ", address) + LogManager.log("Joining WebSocket game with room code: " + address, LogManager.CATEGORY_UI) else: # ENet - print("Joining ENet game at ", address, " with ", local_count, " local players") + LogManager.log("Joining ENet game at " + address + " with " + str(local_count) + " local players", LogManager.CATEGORY_UI) func _on_connection_succeeded(): - print("Connection succeeded, starting game") - _start_game() + LogManager.log("GameUI: Connection succeeded signal received, starting game", LogManager.CATEGORY_UI) + is_joining_attempt = false + # Check if node is still valid before starting game + if not is_inside_tree(): + LogManager.log_error("GameUI: Cannot start game - node not in tree", LogManager.CATEGORY_UI) + return + # Use call_deferred to ensure we're in a safe state to change scenes + call_deferred("_start_game") func _on_connection_failed(): - print("Connection failed") - # Show error message - var error_label = Label.new() - error_label.text = "Failed to connect to server" - error_label.modulate = Color.RED - main_menu.add_child(error_label) + LogManager.log("Connection failed", LogManager.CATEGORY_UI) + if connection_error_shown: + # Already shown, don't spam + return + + connection_error_shown = true + var mode = network_manager.network_mode + var mode_name = "ENet" + if mode == 1: + mode_name = "WebRTC" + elif mode == 2: + mode_name = "WebSocket" + + if is_joining_attempt: + var code_hint = (" (" + last_join_address + ")") if not last_join_address.is_empty() else "" + _show_connection_error("Failed to join room" + code_hint + ". Did you enter the correct code?") + # For WebRTC, refresh signaling connection after a failed join + if mode == 1 and network_manager and network_manager.has_method("refresh_webrtc_servers"): + network_manager.refresh_webrtc_servers(last_join_address) + # Show room list UI and refresh rooms after a failed WebRTC join + _show_room_fetch_status() + _show_loading_indicator() + _start_room_fetch() + else: + _show_connection_error("Connection failed (" + mode_name + "). Please try again.") + +func _show_connection_error(message: String): + """Show a temporary error message in the UI""" + if not main_menu: + return + + # Remove existing error label if any + _hide_connection_error() + + # Create error label + connection_error_label = Label.new() + connection_error_label.name = "ConnectionErrorLabel" + connection_error_label.text = message + connection_error_label.modulate = Color.RED + connection_error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + connection_error_label.add_theme_font_size_override("font_size", 14) + + # Add to main menu VBoxContainer (after the title) + var vbox = main_menu.get_node_or_null("VBoxContainer") + if vbox: + # Insert after title (index 0) or at the beginning + vbox.add_child(connection_error_label) + vbox.move_child(connection_error_label, 1) # Move to position 1 (after title) + + # Auto-hide after 5 seconds + await get_tree().create_timer(5.0).timeout + _hide_connection_error() + +func _hide_connection_error(): + """Hide and remove the connection error label""" + if connection_error_label: + connection_error_label.queue_free() + connection_error_label = null func _start_game(): + # Check if node is still in the tree before trying to access get_tree() + if not is_inside_tree(): + LogManager.log_error("GameUI: Cannot change scene - node is not in tree", LogManager.CATEGORY_UI) + return + # Hide menu - main_menu.visible = false + if main_menu: + main_menu.visible = false # Load the game scene - get_tree().change_scene_to_file("res://scenes/game_world.tscn") + var tree = get_tree() + if tree: + tree.change_scene_to_file("res://scenes/game_world.tscn") + else: + LogManager.log_error("GameUI: Cannot change scene - get_tree() is null", LogManager.CATEGORY_UI) diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 889631f..dd486b4 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -27,6 +27,34 @@ var clients_ready: Dictionary = {} # peer_id -> bool # Level complete tracking var level_complete_triggered: bool = false # Prevent multiple level complete triggers +# Track broken interactable objects (object_index -> true) for syncing to new clients +var broken_objects: Dictionary = {} # object_index -> true + +# Track defeated enemies (enemy_index -> true) for syncing to new clients +var defeated_enemies: Dictionary = {} # enemy_index -> true + +# Track opened chests (object_index -> true) for syncing to new clients +var opened_chests: Dictionary = {} # object_index -> true + +# Track activated floor switches (switch_name -> true) for syncing to new clients +var activated_switches: Dictionary = {} # switch_name -> true + +# Track door states (door_name -> state_dict) for syncing to new clients +var door_states: Dictionary = {} # door_name -> {"is_closed": bool, "puzzle_solved": bool, "key_used": bool} + +# Track which clients have GameWorld ready (server only) +var client_gameworld_ready: Dictionary = {} # peer_id -> bool +var client_gameworld_sync_pending: Dictionary = {} # peer_id -> local_count + +# Pending interactable state syncs (client only) +var pending_interactable_states: Dictionary = {} # obj_name -> {pos, vel, z_pos, z_vel, airborne} + +# Pending door state syncs (client only) +var pending_door_states: Dictionary = {} # door_name -> {open: bool, close: bool, puzzle_solved: bool} + +# Pending chest open syncs (client only) +var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, player_peer_id: int} + func _ready(): # Add to group for easy access add_to_group("game_world") @@ -42,28 +70,61 @@ func _ready(): # Create inventory UI _create_inventory_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(), ")") + # Generate dungeon on host only + # Only generate if we're the server (not just "no multiplayer peer") + # This prevents clients from generating their own dungeon before connecting + if multiplayer.is_server(): + LogManager.log("GameWorld: _ready() - Will generate dungeon (is_server: true)", LogManager.CATEGORY_DUNGEON) call_deferred("_generate_dungeon") else: - print("GameWorld: _ready() - Client, will wait for dungeon sync") + # Client or single-player mode + if multiplayer.has_multiplayer_peer(): + LogManager.log("GameWorld: _ready() - Client connected, will wait for dungeon sync", LogManager.CATEGORY_DUNGEON) + else: + # Single-player mode (no multiplayer peer) - generate dungeon + LogManager.log("GameWorld: _ready() - Single-player mode, will generate dungeon", LogManager.CATEGORY_DUNGEON) + call_deferred("_generate_dungeon") # Clients spawn players immediately (they'll be moved when dungeon syncs) - call_deferred("_spawn_all_players") + if multiplayer.has_multiplayer_peer(): + call_deferred("_spawn_all_players") + + # Notify server that GameWorld is ready (client only) + if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): + call_deferred("_send_gameworld_ready") + +func _send_gameworld_ready(): + # Client notifies server that GameWorld is ready to receive RPCs + if not is_inside_tree(): + return + if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): + var peer_id = multiplayer.get_unique_id() + _notify_gameworld_ready.rpc_id(1, peer_id) + +@rpc("any_peer", "reliable") +func _notify_gameworld_ready(peer_id: int): + # Server receives notification that client's GameWorld is ready + if not multiplayer.is_server(): + return + client_gameworld_ready[peer_id] = true + # If we were waiting to sync this client, do it now + if client_gameworld_sync_pending.has(peer_id): + var local_count = client_gameworld_sync_pending[peer_id] + client_gameworld_sync_pending.erase(peer_id) + _send_initial_client_sync(peer_id, local_count) func _spawn_all_players(): - print("GameWorld: Spawning all players. Server: ", multiplayer.is_server()) - print("GameWorld: Players info: ", network_manager.players_info) + LogManager.log("GameWorld: Spawning all players. Server: " + str(multiplayer.is_server()), LogManager.CATEGORY_GAMEPLAY) + LogManager.log("GameWorld: Players info: " + str(network_manager.players_info), LogManager.CATEGORY_GAMEPLAY) # Only spawn on server initially - clients will spawn via RPC if multiplayer.is_server(): for peer_id in network_manager.players_info.keys(): var info = network_manager.players_info[peer_id] - print("GameWorld: Server spawning ", info.local_player_count, " players for peer ", peer_id) + LogManager.log("GameWorld: Server spawning " + str(info.local_player_count) + " players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) player_manager.spawn_players_for_peer(peer_id, info.local_player_count) func _on_player_connected(peer_id: int, player_info: Dictionary): - print("GameWorld: Player connected signal received for peer ", peer_id, " with info: ", player_info) + LogManager.log("GameWorld: Player connected signal received for peer " + str(peer_id) + " with info: " + str(player_info), LogManager.CATEGORY_GAMEPLAY) # Send join message to chat (only on server to avoid duplicates) if multiplayer.is_server(): @@ -76,54 +137,152 @@ func _on_player_connected(peer_id: int, player_info: Dictionary): _reset_server_players_ready_flag() if multiplayer.is_server(): - var host_room = null - # Sync existing dungeon to the new client (if dungeon has been generated) - if not dungeon_data.is_empty(): - print("GameWorld: Syncing existing dungeon to client ", peer_id) - host_room = _get_host_room() - _sync_dungeon.rpc_id(peer_id, dungeon_data, dungeon_seed, current_level, host_room) - - # Update spawn points to use host's current room - host_room = _get_host_room() - if not host_room.is_empty(): - print("GameWorld: Host is in room at ", host_room.x, ", ", host_room.y) - _update_spawn_points(host_room) + # Always spawn new joiners in the original start room + var start_room = dungeon_data.start_room if not dungeon_data.is_empty() and dungeon_data.has("start_room") else {} + if start_room and not start_room.is_empty(): + LogManager.log("GameWorld: Using start room for new joiner spawn (" + str(start_room.x) + ", " + str(start_room.y) + ")", LogManager.CATEGORY_GAMEPLAY) + _update_spawn_points(start_room) else: - print("GameWorld: Could not find host room, using start room") + LogManager.log("GameWorld: Could not find start room, using default spawn points", LogManager.CATEGORY_DUNGEON) _update_spawn_points() # Use start room as fallback # Server spawns locally - print("GameWorld: Server spawning players for peer ", peer_id) + LogManager.log("GameWorld: Server spawning players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) player_manager.spawn_players_for_peer(peer_id, player_info.local_player_count) - # Sync spawn to all clients - _sync_spawn_player.rpc(peer_id, player_info.local_player_count) - # Sync existing enemies (from spawners) to the new client - # Wait a bit after dungeon sync to ensure spawners are spawned first - call_deferred("_sync_existing_enemies_to_client", peer_id) - - # Sync existing chest open states to the new client - # Wait a bit after dungeon sync to ensure objects are spawned first - call_deferred("_sync_existing_chest_states_to_client", peer_id) - - # Note: Dungeon-spawned enemies are already synced via _sync_dungeon RPC - # which includes dungeon_data.enemies and calls _spawn_enemies() on the client. - # So we don't need to sync them again with individual RPCs. - - # Note: Interactable objects are also synced via _sync_dungeon RPC - # which includes dungeon_data.interactable_objects and calls _spawn_interactable_objects() on the client. - # However, chest open states need to be synced separately since they change during gameplay. - - # Sync existing torches to the new client - call_deferred("_sync_existing_torches_to_client", peer_id) + # Wait for client GameWorld to be ready before sending RPCs + if client_gameworld_ready.get(peer_id, false): + _send_initial_client_sync(peer_id, player_info.local_player_count) + else: + client_gameworld_sync_pending[peer_id] = player_info.local_player_count + _schedule_gameworld_ready_check(peer_id, player_info.local_player_count) else: # Clients spawn directly when they receive this signal - print("GameWorld: Client spawning players for peer ", peer_id) + LogManager.log("GameWorld: Client spawning players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) player_manager.spawn_players_for_peer(peer_id, player_info.local_player_count) +func _schedule_gameworld_ready_check(peer_id: int, local_count: int, attempts: int = 0): + # Server waits for client GameWorld to be ready before sending RPCs + if not multiplayer.is_server(): + return + + if client_gameworld_ready.get(peer_id, false): + _send_initial_client_sync(peer_id, local_count) + return + + if attempts >= 50: + LogManager.log("GameWorld: Timed out waiting for client GameWorld ready for peer " + str(peer_id), LogManager.CATEGORY_NETWORK) + return + + get_tree().create_timer(0.2).timeout.connect(func(): + _schedule_gameworld_ready_check(peer_id, local_count, attempts + 1) + ) + +func _send_initial_client_sync(peer_id: int, local_count: int): + # Send initial dungeon and world state to a client (server only) + if not multiplayer.is_server(): + return + + if not client_gameworld_ready.get(peer_id, false): + return + + # Sync existing dungeon to the new client (if dungeon has been generated) + if not dungeon_data.is_empty(): + LogManager.log("GameWorld: Syncing existing dungeon to client " + str(peer_id), LogManager.CATEGORY_GAMEPLAY) + var host_room = _get_host_room() + # Defer the RPC call to ensure the peer is registered in the multiplayer system + call_deferred("_sync_dungeon_to_client", peer_id, dungeon_data, dungeon_seed, current_level, host_room) + + # Sync spawn to all clients - wait a bit for data channel to be ready + # Use call_deferred with a small delay to ensure WebRTC data channel is open + get_tree().create_timer(0.1).timeout.connect(func(): + if is_inside_tree(): + _rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count]) + ) + + # Sync existing enemies (from spawners) to the new client + # Wait a bit after dungeon sync to ensure spawners are spawned first + call_deferred("_sync_existing_enemies_to_client", peer_id) + + # Sync existing chest open states to the new client + # Wait a bit after dungeon sync to ensure objects are spawned first + call_deferred("_sync_existing_chest_states_to_client", peer_id) + + # Sync broken interactable objects to the new client + # Wait a bit after dungeon sync to ensure objects are spawned first + call_deferred("_sync_broken_objects_to_client", peer_id) + + # Note: Dungeon-spawned enemies are already synced via _sync_dungeon RPC + # which includes dungeon_data.enemies and calls _spawn_enemies() on the client. + # So we don't need to sync them again with individual RPCs. + + # Note: Interactable objects are also synced via _sync_dungeon RPC + # which includes dungeon_data.interactable_objects and calls _spawn_interactable_objects() on the client. + # However, chest open states and broken objects need to be synced separately since they change during gameplay. + + # Sync existing torches to the new client + # Wait longer to ensure dungeon is fully spawned + get_tree().create_timer(0.3).timeout.connect(func(): + if is_inside_tree(): + _sync_existing_torches_to_client(peer_id) + ) + + # Sync door states to the new client (wait longer to ensure doors are spawned) + get_tree().create_timer(0.3).timeout.connect(func(): + if is_inside_tree(): + _sync_existing_door_states_to_client(peer_id) + ) + + # Sync defeated enemies to the new client (so they don't spawn) + call_deferred("_sync_defeated_enemies_to_client", peer_id) + + # Sync existing loot on the ground to the new client + get_tree().create_timer(0.3).timeout.connect(func(): + if is_inside_tree(): + _sync_existing_loot_to_client(peer_id) + ) + + # Sync activated floor switches to the new client + get_tree().create_timer(0.3).timeout.connect(func(): + if is_inside_tree(): + _sync_activated_switches_to_client(peer_id) + ) + + # Sync interactable object positions (boxes that were moved/thrown) + get_tree().create_timer(0.4).timeout.connect(func(): + if is_inside_tree(): + _sync_interactable_object_positions_to_client(peer_id) + ) + +func _rpc_to_ready_peers(method: String, args: Array = []): + # Send RPC to all clients whose GameWorld is ready + if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): + return + for peer_id in multiplayer.get_peers(): + if client_gameworld_ready.get(peer_id, false): + callv("rpc_id", [peer_id, method] + args) + +func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): + # Send RPC from a node to all clients whose GameWorld is ready + if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): + return + if not node: + return + for peer_id in multiplayer.get_peers(): + if client_gameworld_ready.get(peer_id, false): + node.callv("rpc_id", [peer_id, method] + args) + func _sync_existing_enemies_to_client(client_peer_id: int): # Find all enemy spawners and sync their spawned enemies to the new client # Spawners are children of the Entities node, not GameWorld directly + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring enemy sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + var spawners = [] var entities_node = get_node_or_null("Entities") if entities_node: @@ -137,7 +296,7 @@ func _sync_existing_enemies_to_client(client_peer_id: int): if child.is_in_group("enemy_spawner") or (child.has_method("get_spawned_enemy_positions") and child.has_method("spawn_enemy_at_position")): spawners.append(child) - print("GameWorld: Syncing existing enemies to client ", client_peer_id, " from ", spawners.size(), " spawners") + LogManager.log("GameWorld: Syncing existing enemies to client " + str(client_peer_id) + " from " + str(spawners.size()) + " spawners", LogManager.CATEGORY_DUNGEON) for spawner in spawners: var enemy_data = spawner.get_spawned_enemy_positions() @@ -170,21 +329,33 @@ func _sync_spawn_player(peer_id: int, local_count: int): func _notify_client_ready(peer_id: int): # Client notifies server that it's ready (all players spawned) if multiplayer.is_server(): - print("GameWorld: Client ", peer_id, " is ready") + LogManager.log("GameWorld: Client " + str(peer_id) + " is ready", LogManager.CATEGORY_GAMEPLAY) clients_ready[peer_id] = true # Store the time when this client became ready var current_time = Time.get_ticks_msec() / 1000.0 clients_ready[str(peer_id) + "_ready_time"] = current_time + + # When a new player finishes loading: + # 1. Position only the new joiner in start room + # 2. Send all current player positions to the new joiner (without moving them) + # 3. Notify all existing players about the new joiner's position + # Wait a bit longer to ensure the client has fully loaded the game scene and spawned all players + if not dungeon_data.is_empty(): + # Delay positioning to ensure client scene is fully loaded + get_tree().create_timer(0.5).timeout.connect(func(): _position_new_joiner_and_sync_positions(peer_id)) + # Notify all players that a client is ready (so server players can check if all are ready) - _client_ready_status_changed.rpc(clients_ready.duplicate()) + _rpc_to_ready_peers("_client_ready_status_changed", [clients_ready.duplicate()]) # Note: We don't reset the flag here - we want server players to check if all are ready now @rpc("authority", "reliable") func _client_ready_status_changed(_ready_status: Dictionary): # Server broadcasts ready status to all clients # This allows server players to know when all clients are ready - # Currently not used on clients, but kept for future use - pass + if multiplayer.is_server(): + return + # Store ready status on clients so they can avoid sending RPCs to peers not ready + clients_ready = _ready_status func _reset_server_players_ready_flag(): # Reset all_clients_ready flag for all server players @@ -271,6 +442,9 @@ func _sync_loot_spawn(spawn_position: Vector2, loot_type: int, initial_velocity: entities_node.add_child(loot) loot.global_position = spawn_position loot.loot_type = loot_type + # Ensure key loot has a deterministic name for any legacy RPCs + if loot_type == 4: # LootType.KEY + loot.name = "KeyLoot_%d_%d" % [int(spawn_position.x), int(spawn_position.y)] # Set initial velocity before _ready() processes loot.velocity = initial_velocity loot.velocity_z = initial_velocity_z @@ -309,6 +483,50 @@ func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initi loot.is_airborne = true print("Client spawned item loot: ", item.item_name, " at ", spawn_position, " authority: ", loot.get_multiplayer_authority()) +@rpc("authority", "reliable") +func _sync_loot_floating_text(loot_type_value: int, text: String, color_value: Color, sprite_frame_value: int, player_peer_id: int): + # Client receives floating text sync from server (routed via GameWorld) + if multiplayer.is_server(): + return + + # Find player by peer ID + var player = null + var players = get_tree().get_nodes_in_group("player") + for p in players: + if p.get_multiplayer_authority() == player_peer_id: + player = p + break + + if not player or not is_instance_valid(player): + return + + # Determine texture and frames based on loot type (0 = COIN, others use items sheet) + var item_texture: Texture2D = null + var sprite_hframes: int = 1 + var sprite_vframes: int = 1 + + if loot_type_value == 0: + item_texture = load("res://assets/gfx/pickups/gold_coin.png") + sprite_hframes = 6 + sprite_vframes = 1 + else: + item_texture = load("res://assets/gfx/pickups/items_n_shit.png") + sprite_hframes = 20 + sprite_vframes = 14 + + _show_loot_floating_text(player, text, color_value, item_texture, sprite_hframes, sprite_vframes, sprite_frame_value) + +func _show_loot_floating_text(player: Node, text: String, color: Color, item_texture: Texture2D, sprite_hframes: int, sprite_vframes: int, sprite_frame: int): + # Create floating text and item graphic above player's head + var floating_text_scene = preload("res://scenes/floating_text.tscn") + if floating_text_scene and player and is_instance_valid(player): + var floating_text = floating_text_scene.instantiate() + var parent = player.get_parent() + if parent: + parent.add_child(floating_text) + floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) + floating_text.setup(text, color, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame) + @rpc("authority", "unreliable") func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int): # Clients receive enemy position updates from server @@ -395,6 +613,36 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou # This is okay, just log it print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) +@rpc("authority", "reliable") +func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2): + # Clients receive enemy attack sync from server + # Find the enemy by name or index + if multiplayer.is_server(): + return # Server ignores this (it's the sender) + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + # Try to find enemy by name first, then by index + var enemy = null + for child in entities_node.get_children(): + if child.is_in_group("enemy"): + if child.name == enemy_name: + enemy = child + break + elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index: + enemy = child + break + + if enemy and enemy.has_method("_sync_attack"): + # Call the enemy's _sync_attack method directly (not via RPC) + enemy._sync_attack(direction, attack_dir) + else: + # Enemy not found - might already be freed or never spawned + # This is okay, just log it + print("GameWorld: Could not find enemy for attack sync: name=", enemy_name, " index=", enemy_index) + @rpc("any_peer", "reliable") func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_peer_id: int): # Server receives item drop request from client @@ -417,7 +665,7 @@ func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_pe # Create item from data var item = Item.new(item_data) if not item: - print("GameWorld: Could not create item from data for drop request") + LogManager.log("GameWorld: Could not create item from data for drop request", LogManager.CATEGORY_DUNGEON) return # Remove item from player's inventory on server (if player found) @@ -446,6 +694,35 @@ func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_pe loot.set_meta("drop_time", Time.get_ticks_msec()) print("GameWorld: Server spawned item loot from client drop request: ", item.item_name) +@rpc("any_peer", "reliable") +func _request_enemy_damage(enemy_name: String, enemy_index: int, damage: float, attacker_position: Vector2, is_critical: bool): + # Server receives enemy damage request from client + # Route to the correct enemy + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + # Try to find enemy by name first, then by index + var enemy = null + for child in entities_node.get_children(): + if child.is_in_group("enemy"): + if child.name == enemy_name: + enemy = child + break + elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index: + enemy = child + break + + if enemy and enemy.has_method("rpc_take_damage"): + # Call the enemy's rpc_take_damage method directly (it will handle authority check) + enemy.rpc_take_damage(damage, attacker_position, is_critical) + else: + # Enemy not found - might already be freed + print("GameWorld: Could not find enemy for damage request: name=", enemy_name, " index=", enemy_index) + @rpc("any_peer", "reliable") func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: int): # Server receives loot pickup request from client @@ -476,6 +753,34 @@ func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: else: print("GameWorld: Could not find loot for pickup request: id=", loot_id, " pos=", loot_position) +@rpc("any_peer", "reliable") +func _request_chest_open_by_name(chest_name: String, player_peer_id: int): + # Server receives chest open request by name (avoids node path RPC errors) + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var chest = entities_node.get_node_or_null(chest_name) + if not chest: + # Fallback: try to find by object_index if name is InteractableObject_X + if chest_name.begins_with("InteractableObject_"): + var index_str = chest_name.substr(20) + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.is_in_group("interactable_object") and child.has_meta("object_index"): + if child.get_meta("object_index") == obj_index: + chest = child + break + + if chest and chest.has_method("_request_chest_open"): + chest._request_chest_open(player_peer_id) + else: + print("GameWorld: Could not find chest for open request: name=", chest_name, " peer_id=", player_peer_id) + @rpc("authority", "reliable") func _sync_player_exit_stairs(player_peer_id: int): # Client receives notification that a player reached exit stairs @@ -561,7 +866,6 @@ func _sync_show_level_number(level: int): @rpc("authority", "reliable") func _sync_loot_remove(loot_id: int, loot_position: Vector2): # Clients receive loot removal sync from server - # Find the loot by ID or position if multiplayer.is_server(): return # Server ignores this (it's the sender) @@ -569,25 +873,29 @@ func _sync_loot_remove(loot_id: int, loot_position: Vector2): if not entities_node: return - # Try to find loot by ID first, then by position - var loot = null + # Try to find and remove all matching loot (by ID, else by position) + var matched_loot: Array = [] for child in entities_node.get_children(): if child.is_in_group("loot") or child.has_method("_sync_remove"): - # Check by ID first - if child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id: - loot = child - break - # Fallback: check by position (within 16 pixels tolerance) - elif child.global_position.distance_to(loot_position) < 16.0: - loot = child - break + if loot_id >= 0 and child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id: + matched_loot.append(child) + elif loot_id < 0 and child.global_position.distance_to(loot_position) < 16.0: + matched_loot.append(child) - if loot and loot.has_method("_sync_remove"): - # Call the loot's _sync_remove method directly (not via RPC) - loot._sync_remove() + if matched_loot.is_empty(): + # Fallback: try position match even when id was provided + for child in entities_node.get_children(): + if child.is_in_group("loot") or child.has_method("_sync_remove"): + if child.global_position.distance_to(loot_position) < 16.0: + matched_loot.append(child) + + if not matched_loot.is_empty(): + for loot in matched_loot: + if loot and loot.has_method("_sync_remove"): + loot._sync_remove() + print("GameWorld: Client removed ", matched_loot.size(), " loot item(s) for id=", loot_id, " pos=", loot_position) else: # Loot not found - might already be freed or never spawned - # This is okay, just log it print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position) func _process(_delta): @@ -624,7 +932,7 @@ func _generate_dungeon(): print("GameWorld: _generate_dungeon() called - is_server: ", multiplayer.is_server(), ", has_peer: ", multiplayer.has_multiplayer_peer()) if not multiplayer.is_server() and multiplayer.has_multiplayer_peer(): - print("GameWorld: Not server, skipping dungeon generation") + LogManager.log("GameWorld: Not server, skipping dungeon generation", LogManager.CATEGORY_DUNGEON) return # Reset level complete flag for new level @@ -650,7 +958,7 @@ func _generate_dungeon(): push_error("ERROR: Dungeon generation returned empty data!") return - print("GameWorld: Dungeon generated with ", dungeon_data.rooms.size(), " rooms") + LogManager.log("GameWorld: Dungeon generated with " + str(dungeon_data.rooms.size()) + " rooms", LogManager.CATEGORY_DUNGEON) print("GameWorld: Start room at ", dungeon_data.start_room.x, ", ", dungeon_data.start_room.y) print("GameWorld: Map size: ", dungeon_data.map_size) @@ -685,8 +993,8 @@ func _generate_dungeon(): # Move any already-spawned players to the correct spawn points _move_all_players_to_start_room() - # Restore players (make visible and restore collision) - _restore_all_players() + # Restore players (make visible and restore collision) after a short delay + _schedule_restore_all_players(0.6) # Update camera immediately to ensure it's looking at the players await get_tree().process_frame # Wait a frame for players to be fully in scene tree @@ -699,7 +1007,7 @@ func _generate_dungeon(): _show_level_number() # Sync to all clients if multiplayer.has_multiplayer_peer(): - _sync_show_level_number.rpc(current_level) + _rpc_to_ready_peers("_sync_show_level_number", [current_level]) # Load HUD after dungeon generation completes (non-blocking) call_deferred("_load_hud") @@ -711,13 +1019,13 @@ func _generate_dungeon(): # Debug: Check if enemies are in dungeon_data before syncing if dungeon_data.has("enemies"): - print("GameWorld: Server syncing dungeon with ", dungeon_data.enemies.size(), " enemies") + LogManager.log("GameWorld: Server syncing dungeon with " + str(dungeon_data.enemies.size()) + " enemies", LogManager.CATEGORY_GAMEPLAY) else: - print("GameWorld: WARNING: Server dungeon_data has NO 'enemies' key before sync!") + LogManager.log("GameWorld: WARNING: Server dungeon_data has NO 'enemies' key before sync!", LogManager.CATEGORY_DUNGEON) - _sync_dungeon.rpc(dungeon_data, dungeon_seed, current_level, host_room) + _rpc_to_ready_peers("_sync_dungeon", [dungeon_data, dungeon_seed, current_level, host_room]) - print("GameWorld: Dungeon generation completed successfully") + LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON) func _render_dungeon(): if dungeon_data.is_empty(): @@ -733,7 +1041,7 @@ func _render_dungeon(): if not dungeon_tilemap_layer: # Create new TileMapLayer - print("GameWorld: Creating new TileMapLayer") + LogManager.log("GameWorld: Creating new TileMapLayer", LogManager.CATEGORY_DUNGEON) dungeon_tilemap_layer = TileMapLayer.new() dungeon_tilemap_layer.name = "DungeonLayer0" @@ -745,13 +1053,13 @@ func _render_dungeon(): move_child(dungeon_tilemap_layer, 0) dungeon_tilemap_layer.position = Vector2.ZERO - print("GameWorld: Created new TileMapLayer and added to scene") + LogManager.log("GameWorld: Created new TileMapLayer and added to scene", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: Using existing TileMapLayer from scene") + LogManager.log("GameWorld: Using existing TileMapLayer from scene", LogManager.CATEGORY_DUNGEON) if not dungeon_tilemap_layer_above: # Create new TileMapLayerAbove - print("GameWorld: Creating new TileMapLayerAbove") + LogManager.log("GameWorld: Creating new TileMapLayerAbove", LogManager.CATEGORY_DUNGEON) dungeon_tilemap_layer_above = TileMapLayer.new() dungeon_tilemap_layer_above.name = "TileMapLayerAbove" @@ -763,19 +1071,19 @@ func _render_dungeon(): move_child(dungeon_tilemap_layer_above, 0) dungeon_tilemap_layer_above.position = Vector2.ZERO - print("GameWorld: Created new TileMapLayerAbove and added to scene") + LogManager.log("GameWorld: Created new TileMapLayerAbove and added to scene", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: Using existing TileMapLayerAbove from scene") + LogManager.log("GameWorld: Using existing TileMapLayerAbove from scene", LogManager.CATEGORY_DUNGEON) # TileMapLayer should work standalone - no TileMap needed - print("GameWorld: TileMapLayer ready for rendering") + LogManager.log("GameWorld: TileMapLayer ready for rendering", LogManager.CATEGORY_DUNGEON) # Render tiles from dungeon_data var tile_grid = dungeon_data.tile_grid var grid = dungeon_data.grid var map_size = dungeon_data.map_size - print("GameWorld: Rendering ", map_size.x, "x", map_size.y, " tiles") + LogManager.log("GameWorld: Rendering " + str(map_size.x) + "x" + str(map_size.y) + " tiles", LogManager.CATEGORY_DUNGEON) var tiles_placed = 0 var above_tiles_placed = 0 @@ -897,9 +1205,9 @@ func _render_dungeon(): dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, BLACK_TILE) above_tiles_placed += 1 - print("GameWorld: Placed ", tiles_placed, " tiles on main layer") - print("GameWorld: Placed ", above_tiles_placed, " tiles on above layer") - print("GameWorld: Dungeon rendered on TileMapLayer") + LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_DUNGEON) + LogManager.log("GameWorld: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON) + LogManager.log("GameWorld: Dungeon rendered on TileMapLayer", LogManager.CATEGORY_DUNGEON) # Create stairs Area2D if stairs data exists _create_stairs_area() @@ -935,7 +1243,7 @@ func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = t var world_x = tile_pos.x * tile_size + tile_size / 2.0 # Center of tile var world_y = tile_pos.y * tile_size + tile_size / 2.0 # Center of tile player_manager.spawn_points.append(Vector2(world_x, world_y)) - print("GameWorld: Updated spawn points with ", free_tiles.size(), " free floor tiles in room") + LogManager.log("GameWorld: Updated spawn points with " + str(free_tiles.size()) + " free floor tiles in room", LogManager.CATEGORY_DUNGEON) else: # Fallback: Create spawn points in a circle around the room center var room_center_x = (room.x + room.w / 2.0) * tile_size @@ -945,7 +1253,7 @@ func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = t var angle = i * PI * 2 / num_spawn_points var offset = Vector2(cos(angle), sin(angle)) * 30 # 30 pixel radius player_manager.spawn_points.append(Vector2(room_center_x, room_center_y) + offset) - print("GameWorld: Updated spawn points in circle around room center (no free tiles found)") + LogManager.log("GameWorld: Updated spawn points in circle around room center (no free tiles found)", LogManager.CATEGORY_DUNGEON) func _find_room_at_position(world_pos: Vector2) -> Dictionary: # Find which room contains the given world position @@ -1062,13 +1370,13 @@ func _find_nearby_safe_spawn_position(world_pos: Vector2, max_distance: float = # Check if this position is safe if _is_safe_spawn_position(check_world_pos): - print("GameWorld: Found safe spawn position at ", check_world_pos, " (original was ", world_pos, ")") + LogManager.log("GameWorld: Found safe spawn position at " + str(check_world_pos) + " (original was " + str(world_pos) + ")", LogManager.CATEGORY_DUNGEON) return check_world_pos search_radius += 1 # If no safe position found, return original (fallback) - print("GameWorld: WARNING: Could not find safe spawn position near ", world_pos, ", using original position") + LogManager.log("GameWorld: WARNING: Could not find safe spawn position near " + str(world_pos) + ", using original position", LogManager.CATEGORY_DUNGEON) return world_pos func _get_host_room() -> Dictionary: @@ -1094,6 +1402,12 @@ func _get_host_room() -> Dictionary: var host_pos = host_players[0].position return _find_room_at_position(host_pos) +func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary): + # Helper function to send dungeon sync RPC to a client + # Called via call_deferred to ensure the peer is registered in the multiplayer system + if multiplayer.is_server(): + _sync_dungeon.rpc_id(client_peer_id, dungeon_data_sync, seed_value, level, host_room) + @rpc("authority", "reliable") func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary = {}): # Clients receive dungeon data from host @@ -1102,14 +1416,30 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h print("GameWorld: dungeon_data_sync keys: ", dungeon_data_sync.keys()) if dungeon_data_sync.has("enemies"): var enemy_count = dungeon_data_sync.enemies.size() if dungeon_data_sync.enemies is Array else 0 - print("GameWorld: dungeon_data_sync has ", enemy_count, " enemies") + LogManager.log("GameWorld: dungeon_data_sync has " + str(enemy_count) + " enemies", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!") + LogManager.log("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!", LogManager.CATEGORY_DUNGEON) + + # Check if we're reconnecting to the same level - skip dungeon reload if so + var skip_reload = false + if network_manager and network_manager.reconnection_level > 0: + if network_manager.reconnection_level == level: + skip_reload = true + LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON) dungeon_data = dungeon_data_sync dungeon_seed = seed_value current_level = level # Update current_level FIRST before showing level number - print("GameWorld: Client updated current_level to ", current_level, " from sync") + LogManager.log("GameWorld: Client updated current_level to " + str(current_level) + " from sync", LogManager.CATEGORY_DUNGEON) + + # Skip dungeon reload if reconnecting to same level + if skip_reload: + # Just update the level number, don't reload the dungeon + LogManager.log("GameWorld: Skipping dungeon reload - same level on reconnection", LogManager.CATEGORY_DUNGEON) + # Clear reconnection state + if network_manager: + network_manager.reconnection_level = 0 + return # Clear previous level on client _clear_level() @@ -1142,12 +1472,12 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h # Update spawn points - use host's room if available, otherwise use start room if not host_room.is_empty(): - print("GameWorld: Using host's room for spawn points") + LogManager.log("GameWorld: Using host's room for spawn points", LogManager.CATEGORY_DUNGEON) _update_spawn_points(host_room) # Move any existing players to spawn near host _move_players_to_host_room(host_room) else: - print("GameWorld: Host room not available, using start room") + LogManager.log("GameWorld: Host room not available, using start room", LogManager.CATEGORY_DUNGEON) _update_spawn_points() # Move all players to start room _move_all_players_to_start_room() @@ -1178,6 +1508,20 @@ func _spawn_torches(): push_error("ERROR: Could not find Entities node!") return + # Remove existing blocking doors first to prevent duplicates/renames + var doors_to_remove = [] + for child in entities_node.get_children(): + if child.is_in_group("blocking_door"): + doors_to_remove.append(child) + + for door_to_remove in doors_to_remove: + if is_instance_valid(door_to_remove): + door_to_remove.queue_free() + + if doors_to_remove.size() > 0: + await get_tree().process_frame + await get_tree().process_frame + # Remove existing torches first for child in entities_node.get_children(): if child.name.begins_with("Torch_"): @@ -1185,7 +1529,7 @@ func _spawn_torches(): # Spawn torches var torches = dungeon_data.torches - print("GameWorld: Spawning ", torches.size(), " torches") + LogManager.log("GameWorld: Spawning " + str(torches.size()) + " torches", LogManager.CATEGORY_DUNGEON) for i in range(torches.size()): var torch_data = torches[i] @@ -1195,7 +1539,7 @@ func _spawn_torches(): torch.global_position = torch_data.position torch.rotation_degrees = torch_data.rotation - print("GameWorld: Spawned ", torches.size(), " torches") + LogManager.log("GameWorld: Spawned " + str(torches.size()) + " torches", LogManager.CATEGORY_DUNGEON) func _spawn_enemies(): # Spawn enemies from dungeon data @@ -1227,7 +1571,7 @@ func _spawn_enemies(): # Spawn enemies if not dungeon_data.has("enemies"): - print("GameWorld: WARNING: dungeon_data has no 'enemies' key!") + LogManager.log("GameWorld: WARNING: dungeon_data has no 'enemies' key!", LogManager.CATEGORY_DUNGEON) return var enemies = dungeon_data.enemies @@ -1235,9 +1579,14 @@ func _spawn_enemies(): print("GameWorld: WARNING: dungeon_data.enemies is not an Array! Type: ", typeof(enemies)) return - print("GameWorld: Spawning ", enemies.size(), " enemies (is_server: ", is_server, ")") + LogManager.log("GameWorld: Spawning " + str(enemies.size()) + " enemies (is_server: " + str(is_server) + ")", LogManager.CATEGORY_DUNGEON) for i in range(enemies.size()): + # Check if this enemy was already defeated (for clients joining mid-game) + if defeated_enemies.has(i): + LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(i), LogManager.CATEGORY_NETWORK) + continue # Don't spawn defeated enemies + var enemy_data = enemies[i] if not enemy_data is Dictionary: push_error("ERROR: Enemy data at index ", i, " is not a Dictionary! Type: ", typeof(enemy_data)) @@ -1306,7 +1655,7 @@ func _spawn_enemies(): # CRITICAL: Re-verify collision_mask AFTER adding to scene (in case scene file overrides it) # This ensures enemies always collide with walls (layer 7 = bit 6 = 64) if enemy.collision_mask != (1 | 2 | 64): - print("GameWorld: WARNING - Enemy ", enemy.name, " collision_mask was ", enemy.collision_mask, ", expected ", (1 | 2 | 64), "! Correcting...") + LogManager.log("GameWorld: WARNING - Enemy " + str(enemy.name) + " collision_mask was " + str(enemy.collision_mask) + ", expected " + str(1 | 2 | 64) + "! Correcting...", LogManager.CATEGORY_DUNGEON) enemy.collision_mask = 1 | 2 | 64 # Verify authority is still set after adding to tree @@ -1321,14 +1670,14 @@ func _spawn_enemies(): push_error("GameWorld: ERROR - Enemy not in tree after add_child!") if is_server: - print("GameWorld: Server spawned enemy: ", enemy.name, " at ", enemy_data.position, " (type: ", enemy_type, ") authority: ", enemy.get_multiplayer_authority(), " in_tree: ", enemy.is_inside_tree(), " is_authority: ", enemy.is_multiplayer_authority(), " index: ", i) + LogManager.log("GameWorld: Server spawned enemy: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + " in_tree: " + str(enemy.is_inside_tree()) + " is_authority: " + str(enemy.is_multiplayer_authority()) + " index: " + str(i), LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: Client spawned enemy: ", enemy.name, " at ", enemy_data.position, " (type: ", enemy_type, ") authority: ", enemy.get_multiplayer_authority(), " is_authority: ", enemy.is_multiplayer_authority(), " in_tree: ", enemy.is_inside_tree(), " index: ", i) + LogManager.log("GameWorld: Client spawned enemy: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + " is_authority: " + str(enemy.is_multiplayer_authority()) + " in_tree: " + str(enemy.is_inside_tree()) + " index: " + str(i), LogManager.CATEGORY_DUNGEON) if is_server: - print("GameWorld: Server spawned ", enemies.size(), " enemies") + LogManager.log("GameWorld: Server spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: Client spawned ", enemies.size(), " enemies") + LogManager.log("GameWorld: Client spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON) func _spawn_interactable_objects(): # Spawn interactable objects from dungeon data @@ -1344,10 +1693,10 @@ func _spawn_interactable_objects(): push_error("ERROR: Could not find Entities node!") return - # Remove existing interactable objects first + # Remove existing interactable objects first (avoid name conflicts / desync) var objects_to_remove = [] for child in entities_node.get_children(): - if child.is_in_group("interactable_object") and child.has_meta("dungeon_spawned"): + if child.is_in_group("interactable_object"): objects_to_remove.append(child) # Remove all old dungeon objects @@ -1356,9 +1705,14 @@ func _spawn_interactable_objects(): if is_instance_valid(obj): obj.queue_free() + # Wait a frame to ensure removals are processed before re-spawning + if objects_to_remove.size() > 0: + await get_tree().process_frame + await get_tree().process_frame + # Spawn objects if not dungeon_data.has("interactable_objects"): - print("GameWorld: WARNING: dungeon_data has no 'interactable_objects' key!") + LogManager.log("GameWorld: WARNING: dungeon_data has no 'interactable_objects' key!", LogManager.CATEGORY_DUNGEON) return var objects = dungeon_data.interactable_objects @@ -1366,7 +1720,7 @@ func _spawn_interactable_objects(): print("GameWorld: WARNING: dungeon_data.interactable_objects is not an Array! Type: ", typeof(objects)) return - print("GameWorld: Spawning ", objects.size(), " interactable objects (is_server: ", is_server, ")") + LogManager.log("GameWorld: Spawning " + str(objects.size()) + " interactable objects (is_server: " + str(is_server) + ")", LogManager.CATEGORY_DUNGEON) var interactable_object_scene = load("res://scenes/interactable_object.tscn") if not interactable_object_scene: @@ -1407,6 +1761,9 @@ func _spawn_interactable_objects(): # Add to scene tree entities_node.add_child(obj) + # Re-apply name and index after add_child to avoid auto-renaming + obj.name = "InteractableObject_%d" % i + obj.set_meta("object_index", i) obj.global_position = object_data.position # Call the setup function to configure the object @@ -1417,8 +1774,27 @@ func _spawn_interactable_objects(): # Add to group for easy access obj.add_to_group("interactable_object") + + # Apply any pending chest open sync that arrived before this chest spawned + if obj.has_method("setup_chest") and pending_chest_opens.has(obj.name): + var chest_state = pending_chest_opens[obj.name] + if obj.has_method("_sync_chest_open"): + obj._sync_chest_open(chest_state.loot_type, chest_state.player_peer_id) + pending_chest_opens.erase(obj.name) + + # Apply pending state sync if it arrived before this object spawned + if pending_interactable_states.has(obj.name): + var state = pending_interactable_states[obj.name] + _apply_interactable_state(obj, state) + pending_interactable_states.erase(obj.name) + + # If this object is already broken (for clients joining mid-game), break it immediately + # Check broken_objects after object is fully spawned + if broken_objects.has(i): + # Use call_deferred to break after object is fully ready + obj.call_deferred("_sync_break") - print("GameWorld: Spawned ", objects.size(), " interactable objects") + LogManager.log("GameWorld: Spawned " + str(objects.size()) + " interactable objects", LogManager.CATEGORY_DUNGEON) func _sync_existing_dungeon_enemies_to_client(client_peer_id: int): # Sync existing dungeon-spawned enemies to newly connected client @@ -1438,6 +1814,25 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): # Client receives dungeon enemy spawn data and spawns it print("GameWorld: Client received RPC to spawn dungeon enemy: type=", enemy_data.type, " pos=", enemy_data.position) + # Find the enemy index from the position in the enemies array + # We need to match the server's enemy index to ensure consistent naming + var enemy_index = -1 + if dungeon_data.has("enemies") and dungeon_data.enemies is Array: + for idx in range(dungeon_data.enemies.size()): + var e_data = dungeon_data.enemies[idx] + if e_data.has("position") and e_data.position.distance_to(enemy_data.position) < 1.0: + enemy_index = idx + break + + # If enemy_index is in enemy_data, use it (more reliable) + if "enemy_index" in enemy_data: + enemy_index = enemy_data.enemy_index + + # Check if this enemy was already defeated (for clients joining mid-game) + if enemy_index >= 0 and defeated_enemies.has(enemy_index): + LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_NETWORK) + return # Don't spawn defeated enemies + if not multiplayer.is_server(): # Convert enemy type to full path if needed (same as _spawn_enemies) var enemy_type = enemy_data.type @@ -1468,19 +1863,9 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): child.queue_free() # Continue to spawn new one else: - print("GameWorld: Enemy already exists at ", enemy_data.position, ", skipping duplicate spawn") + LogManager.log("GameWorld: Enemy already exists at " + str(enemy_data.position) + ", skipping duplicate spawn", LogManager.CATEGORY_DUNGEON) return - # Find the enemy index from the position in the enemies array - # We need to match the server's enemy index to ensure consistent naming - var enemy_index = -1 - if dungeon_data.has("enemies") and dungeon_data.enemies is Array: - for idx in range(dungeon_data.enemies.size()): - var e_data = dungeon_data.enemies[idx] - if e_data.has("position") and e_data.position.distance_to(enemy_data.position) < 1.0: - enemy_index = idx - break - # If we couldn't find the index, use a fallback if enemy_index == -1: enemy_index = entities_node.get_child_count() @@ -1522,7 +1907,7 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): # CRITICAL: Re-verify collision_mask AFTER adding to scene (in case scene file overrides it) # This ensures enemies always collide with walls (layer 7 = bit 6 = 64) if enemy.collision_mask != (1 | 2 | 64): - print("GameWorld: WARNING - Enemy ", enemy.name, " collision_mask was ", enemy.collision_mask, ", expected ", (1 | 2 | 64), "! Correcting...") + LogManager.log("GameWorld: WARNING - Enemy " + str(enemy.name) + " collision_mask was " + str(enemy.collision_mask) + ", expected " + str(1 | 2 | 64) + "! Correcting...", LogManager.CATEGORY_DUNGEON) enemy.collision_mask = 1 | 2 | 64 # Verify authority is still set @@ -1532,7 +1917,7 @@ func _sync_dungeon_enemy_spawn(enemy_data: Dictionary): if auth_after != 1: push_error("GameWorld: ERROR - Enemy authority lost after add_child in RPC! Expected 1, got ", auth_after) - print("GameWorld: Client spawned dungeon enemy via RPC: ", enemy.name, " at ", enemy_data.position, " (type: ", enemy_type, ") authority: ", enemy.get_multiplayer_authority()) + LogManager.log("GameWorld: Client spawned dungeon enemy via RPC: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + ")", LogManager.CATEGORY_DUNGEON) func _sync_existing_chest_states_to_client(client_peer_id: int): # Sync chest open states to new client @@ -1553,6 +1938,54 @@ func _sync_existing_chest_states_to_client(client_peer_id: int): print("GameWorld: Synced ", opened_chest_count, " opened chests to client ", client_peer_id) +func _sync_broken_objects_to_client(client_peer_id: int): + # Sync broken interactable objects to new client + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring broken objects sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + + if broken_objects.is_empty(): + LogManager.log("GameWorld: No broken objects to sync to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + return + + # Convert dictionary to array of indices + var broken_indices = [] + for obj_index in broken_objects.keys(): + broken_indices.append(obj_index) + + _sync_broken_objects.rpc_id(client_peer_id, broken_indices, true) + LogManager.log("GameWorld: Syncing " + str(broken_indices.size()) + " broken objects to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +@rpc("authority", "reliable") +func _sync_broken_objects(broken_indices: Array, silent: bool = false): + # Client receives list of broken object indices + # Check if this node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring broken objects sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + # Store broken objects locally + for obj_index in broken_indices: + broken_objects[obj_index] = true + + # Break the objects that are already spawned + var entities_node = get_node_or_null("Entities") + if entities_node: + for obj_index in broken_indices: + var obj_name = "InteractableObject_%d" % obj_index + var obj = entities_node.get_node_or_null(obj_name) + if obj and obj.has_method("_sync_break"): + # Use call_deferred to ensure object is fully initialized + obj.call_deferred("_sync_break", silent) + LogManager.log("GameWorld: Client breaking object " + obj_name + " (was already broken before joining)", LogManager.CATEGORY_NETWORK) + + LogManager.log("GameWorld: Client received " + str(broken_indices.size()) + " broken object indices", LogManager.CATEGORY_NETWORK) + @rpc("authority", "reliable") func _sync_chest_state(obj_name: String, is_opened: bool): # Client receives chest state sync @@ -1572,6 +2005,14 @@ func _sync_chest_state(obj_name: String, is_opened: bool): func _sync_existing_torches_to_client(client_peer_id: int): # Sync existing torches to newly connected client + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring torch sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + if dungeon_data.is_empty() or not dungeon_data.has("torches"): return @@ -1598,9 +2039,415 @@ func _sync_torch_spawn(torch_position: Vector2, torch_rotation: float): torch.rotation_degrees = torch_rotation print("Client spawned torch at ", torch_position, " with rotation ", torch_rotation) +func _sync_existing_door_states_to_client(client_peer_id: int): + # Sync door states to new client + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var door_count = 0 + for child in entities_node.get_children(): + if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"): + # Get door state + var door_state = { + "is_closed": child.is_closed if "is_closed" in child else true, + "puzzle_solved": child.puzzle_solved if "puzzle_solved" in child else false, + "key_used": child.key_used if "key_used" in child else false, + "position": child.position if "position" in child else Vector2.ZERO, + "closed_position": child.closed_position if "closed_position" in child else Vector2.ZERO, + "open_offset": child.open_offset if "open_offset" in child else Vector2.ZERO + } + _sync_door_state.rpc_id(client_peer_id, child.name, door_state) + door_count += 1 + LogManager.log("GameWorld: Syncing door state for " + child.name + " to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + + LogManager.log("GameWorld: Synced " + str(door_count) + " door states to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +@rpc("authority", "reliable") +func _sync_door_state(door_name: String, door_state: Dictionary): + # Client receives door state sync + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring door state sync RPC for " + door_name, LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if entities_node: + var door = entities_node.get_node_or_null(door_name) + if door: + # Apply door state + if "is_closed" in door_state: + door.is_closed = door_state.is_closed + if "puzzle_solved" in door_state: + door.puzzle_solved = door_state.puzzle_solved + if "key_used" in door_state: + door.key_used = door_state.key_used + + # Set position based on state + if "closed_position" in door_state and "open_offset" in door_state: + if door_state.is_closed: + door.position = door_state.closed_position + door.global_position = door_state.closed_position + door.set_collision_layer_value(7, true) + else: + door.position = door_state.closed_position + door_state.open_offset + door.global_position = door_state.closed_position + door_state.open_offset + door.set_collision_layer_value(7, false) + + LogManager.log("GameWorld: Client received door state sync for " + door_name, LogManager.CATEGORY_NETWORK) + +func _sync_defeated_enemies_to_client(client_peer_id: int): + # Sync defeated enemies to new client (so they don't spawn) + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring defeated enemies sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + + if defeated_enemies.is_empty(): + LogManager.log("GameWorld: No defeated enemies to sync to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + return + + # Convert dictionary to array of indices + var defeated_indices = [] + for enemy_index in defeated_enemies.keys(): + defeated_indices.append(enemy_index) + + _sync_defeated_enemies.rpc_id(client_peer_id, defeated_indices) + LogManager.log("GameWorld: Syncing " + str(defeated_indices.size()) + " defeated enemies to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +@rpc("authority", "reliable") +func _sync_defeated_enemies(defeated_indices: Array): + # Client receives list of defeated enemy indices + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + # Store defeated enemies locally + for enemy_index in defeated_indices: + defeated_enemies[enemy_index] = true + + LogManager.log("GameWorld: Client received " + str(defeated_indices.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK) + +func _sync_existing_loot_to_client(client_peer_id: int): + # Sync existing loot on the ground to new client + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring loot sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var loot_count = 0 + for child in entities_node.get_children(): + if child.is_in_group("loot"): + # Get loot data + var loot_data = { + "position": child.global_position, + "loot_type": child.loot_type if "loot_type" in child else 0, + "loot_id": child.get_meta("loot_id") if child.has_meta("loot_id") else -1 + } + + # For item loot, include item data + if child.loot_type == child.LootType.ITEM and "item" in child and child.item: + var item = child.item + # Use Item.save() method to get all item data as dictionary + var item_data = item.save() + _sync_item_loot_spawn.rpc_id(client_peer_id, loot_data.position, item_data, Vector2.ZERO, 0.0, loot_data.loot_id) + else: + _sync_loot_spawn.rpc_id(client_peer_id, loot_data.position, loot_data.loot_type, Vector2.ZERO, 0.0, loot_data.loot_id) + + loot_count += 1 + + LogManager.log("GameWorld: Synced " + str(loot_count) + " loot items to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +func _sync_activated_switches_to_client(client_peer_id: int): + # Sync activated floor switches to new client + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring switch sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var switch_count = 0 + for child in entities_node.get_children(): + if child.is_in_group("floor_switch"): + if "is_activated" in child and child.is_activated: + _sync_switch_state.rpc_id(client_peer_id, child.name, true) + switch_count += 1 + LogManager.log("GameWorld: Syncing activated switch " + child.name + " to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + + LogManager.log("GameWorld: Synced " + str(switch_count) + " activated switches to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +@rpc("authority", "reliable") +func _sync_switch_state(switch_name: String, is_activated: bool): + # Client receives switch state sync + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if entities_node: + var switch_node = entities_node.get_node_or_null(switch_name) + if switch_node and "is_activated" in switch_node: + switch_node.is_activated = is_activated + if switch_node.has_method("_update_visual"): + switch_node._update_visual() + LogManager.log("GameWorld: Client received switch state sync for " + switch_name + " - activated: " + str(is_activated), LogManager.CATEGORY_NETWORK) + +func _sync_interactable_object_positions_to_client(client_peer_id: int): + # Sync current positions of interactable objects (boxes that were moved/thrown) + # Check if node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring interactable object position sync RPC", LogManager.CATEGORY_NETWORK) + return + + if not multiplayer.is_server(): + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + # Wait for dungeon_data to be available + if dungeon_data.is_empty() or not dungeon_data.has("interactable_objects"): + LogManager.log("GameWorld: Dungeon data not ready for interactable object position sync", LogManager.CATEGORY_NETWORK) + return + + var object_count = 0 + for child in entities_node.get_children(): + if child.is_in_group("interactable_object") and child.has_meta("object_index"): + # Skip broken objects (they're synced separately) + var obj_index = child.get_meta("object_index") + if broken_objects.has(obj_index): + continue + + # Sync all interactable objects (including chests and pillars) + var throw_vel = child.throw_velocity if "throw_velocity" in child else Vector2.ZERO + var z_pos = child.position_z if "position_z" in child else 0.0 + var z_vel = child.velocity_z if "velocity_z" in child else 0.0 + var airborne = child.is_airborne if "is_airborne" in child else false + var obj_name = child.name + _sync_interactable_object_state.rpc_id(client_peer_id, obj_name, child.global_position, throw_vel, z_pos, z_vel, airborne) + object_count += 1 + + LogManager.log("GameWorld: Synced " + str(object_count) + " interactable object positions to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK) + +@rpc("authority", "reliable") +func _sync_interactable_object_state(obj_name: String, pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool): + # Client receives interactable object state sync (uses GameWorld to avoid node path issues) + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var obj = entities_node.get_node_or_null(obj_name) + var state = { + "pos": pos, + "vel": vel, + "z_pos": z_pos, + "z_vel": z_vel, + "airborne": airborne + } + if obj: + _apply_interactable_state(obj, state) + else: + # Store for later if object isn't spawned yet + pending_interactable_states[obj_name] = state + +@rpc("authority", "reliable") +func _sync_player_position_by_name(player_name: String, pos: Vector2): + # Client receives player position sync by name (avoids node path RPC errors) + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var player = entities_node.get_node_or_null(player_name) + if player and player.has_method("_sync_teleport_position"): + player._sync_teleport_position(pos) + +@rpc("authority", "reliable") +func _sync_door_open_by_name(door_name: String): + # Client receives door open sync by name (avoids node path RPC errors) + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var door = entities_node.get_node_or_null(door_name) + if not door: + door = _find_door_fallback(entities_node, door_name) + if door and door.has_method("_sync_door_open"): + door._sync_door_open() + else: + # Store for later if door isn't spawned yet + if not pending_door_states.has(door_name): + pending_door_states[door_name] = {} + pending_door_states[door_name]["open"] = true + +@rpc("authority", "reliable") +func _sync_door_close_by_name(door_name: String): + # Client receives door close sync by name (avoids node path RPC errors) + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var door = entities_node.get_node_or_null(door_name) + if not door: + door = _find_door_fallback(entities_node, door_name) + if door and door.has_method("_sync_door_close"): + door._sync_door_close() + else: + # Store for later if door isn't spawned yet + if not pending_door_states.has(door_name): + pending_door_states[door_name] = {} + pending_door_states[door_name]["close"] = true + +@rpc("authority", "reliable") +func _sync_door_puzzle_solved_by_name(door_name: String, is_solved: bool): + # Client receives door puzzle solved sync by name + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var door = entities_node.get_node_or_null(door_name) + if not door: + door = _find_door_fallback(entities_node, door_name) + if door and door.has_method("_sync_puzzle_solved"): + door._sync_puzzle_solved(is_solved) + else: + # Store for later if door isn't spawned yet + if not pending_door_states.has(door_name): + pending_door_states[door_name] = {} + pending_door_states[door_name]["puzzle_solved"] = is_solved + +func _apply_pending_door_state(door: Node): + if not door or not pending_door_states.has(door.name): + # Try fallback key if name was auto-renamed + if door and door.has_meta("door_index"): + var fallback_key = "BlockingDoor_%d" % door.get_meta("door_index") + if pending_door_states.has(fallback_key): + var fallback_state = pending_door_states[fallback_key] + if fallback_state.has("open") and fallback_state.open and door.has_method("_sync_door_open"): + door._sync_door_open() + if fallback_state.has("close") and fallback_state.close and door.has_method("_sync_door_close"): + door._sync_door_close() + if fallback_state.has("puzzle_solved") and door.has_method("_sync_puzzle_solved"): + door._sync_puzzle_solved(fallback_state.puzzle_solved) + pending_door_states.erase(fallback_key) + return + var state = pending_door_states[door.name] + if state.has("open") and state.open and door.has_method("_sync_door_open"): + door._sync_door_open() + if state.has("close") and state.close and door.has_method("_sync_door_close"): + door._sync_door_close() + if state.has("puzzle_solved") and door.has_method("_sync_puzzle_solved"): + door._sync_puzzle_solved(state.puzzle_solved) + pending_door_states.erase(door.name) + +@rpc("authority", "reliable") +func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_peer_id: int): + # Client receives chest open sync by name (avoids node path RPC errors) + if not is_inside_tree(): + return + + if not multiplayer.is_server(): + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + + var chest = entities_node.get_node_or_null(chest_name) + if not chest and chest_name.begins_with("InteractableObject_"): + # Fallback: try to find by object_index if name doesn't match + var index_str = chest_name.substr(20) + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.is_in_group("interactable_object") and child.has_meta("object_index"): + if child.get_meta("object_index") == obj_index: + chest = child + break + + if chest and chest.has_method("_sync_chest_open"): + chest._sync_chest_open(loot_type_str, player_peer_id) + else: + # Store for later if chest isn't spawned yet + pending_chest_opens[chest_name] = { + "loot_type": loot_type_str, + "player_peer_id": player_peer_id + } + +func _apply_interactable_state(obj: Node, state: Dictionary): + # Apply interactable state to an object (client only) + if not obj: + return + if obj.has_method("_sync_box_state"): + obj._sync_box_state(state.pos, state.vel, state.z_pos, state.z_vel, state.airborne) + +func _find_door_fallback(entities_node: Node, door_name: String) -> Node: + if not entities_node: + return null + if door_name.begins_with("BlockingDoor_"): + var index_str = door_name.substr(13) + if index_str.is_valid_int(): + var door_index = index_str.to_int() + for child in entities_node.get_children(): + if child.is_in_group("blocking_door"): + if child.has_meta("door_index") and child.get_meta("door_index") == door_index: + return child + if child.name.begins_with("BlockingDoor_%d" % door_index): + return child + # Fallback: try any door that starts with the same prefix + for child in entities_node.get_children(): + if child.is_in_group("blocking_door") and child.name.begins_with(door_name): + return child + return null + func _clear_level(): # Clear previous level data - print("GameWorld: Clearing previous level...") + LogManager.log("GameWorld: Clearing previous level...", LogManager.CATEGORY_DUNGEON) + + # Reset level-scoped sync state (server + clients) + broken_objects.clear() + defeated_enemies.clear() + opened_chests.clear() + activated_switches.clear() + door_states.clear() + pending_interactable_states.clear() + pending_door_states.clear() + pending_chest_opens.clear() # Clear tilemap layers - ensure we get references from scene if they exist var environment = get_node_or_null("Environment") @@ -1640,7 +2487,7 @@ func _clear_level(): # Clear dungeon data (but keep it for now until new one is generated) # dungeon_data = {} # Don't clear yet, wait for new generation - print("GameWorld: Previous level cleared") + LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON) func _hide_all_players(): # Hide all players and remove collision before generating new level @@ -1654,7 +2501,7 @@ func _hide_all_players(): player.collision_layer = 0 # Hide player player.visible = false - print("GameWorld: Hid player ", player.name, " and removed collision") + LogManager.log("GameWorld: Hid player " + str(player.name) + " and removed collision", LogManager.CATEGORY_DUNGEON) func _restore_all_players(): # Restore all players (make visible and restore collision) after placement @@ -1670,31 +2517,146 @@ func _restore_all_players(): player.collision_layer = 1 # Make player visible player.visible = true - print("GameWorld: Restored player ", player.name, " (visible and collision restored)") + LogManager.log("GameWorld: Restored player " + str(player.name) + " (visible and collision restored)", LogManager.CATEGORY_DUNGEON) -func _move_all_players_to_start_room(): - # Move all players to the start room of the new level +func _schedule_restore_all_players(delay: float = 0.6): + # Delay restoring player visibility/collision to allow positioning to finish + if not is_inside_tree(): + return + get_tree().create_timer(delay).timeout.connect(func(): + if is_inside_tree(): + _restore_all_players() + ) + +func _position_new_joiner_and_sync_positions(new_peer_id: int): + # Position a new joiner and sync all player positions correctly + # This should ONLY be called when a new client joins + # DO NOT move existing players - only position the new joiner + if not multiplayer.is_server(): + return + if dungeon_data.is_empty() or not dungeon_data.has("start_room"): return var start_room = dungeon_data.start_room _update_spawn_points(start_room) - # Move all players to spawn points + # Find all players, sorted by peer_id and local_index for consistent spawn point assignment var players = get_tree().get_nodes_in_group("player") - var spawn_index = 0 + var sorted_players = [] for player in players: + if player.get("peer_id") != null: + sorted_players.append(player) + sorted_players.sort_custom(func(a, b): + if a.peer_id != b.peer_id: + return a.peer_id < b.peer_id + var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0 + var b_index = b.get("local_player_index") if b.get("local_player_index") != null else 0 + return a_index < b_index + ) + + # Find players belonging to the new joiner + var new_joiner_players = [] + for player in sorted_players: + if player.peer_id == new_peer_id: + new_joiner_players.append(player) + + # Count how many spawn points are already used by existing players (with lower peer_id) + var used_spawn_index = 0 + for player in sorted_players: + if player.peer_id < new_peer_id: + used_spawn_index += 1 + + # Assign spawn points to new joiner's players (starting from the next available spawn point) + var spawn_index = used_spawn_index + for player in new_joiner_players: if spawn_index < player_manager.spawn_points.size(): var new_pos = player_manager.spawn_points[spawn_index] player.global_position = new_pos - print("GameWorld: Moved player ", player.name, " to start room at ", new_pos) + LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) spawn_index += 1 else: # Fallback: place in center of start room var room_center_x = (start_room.x + start_room.w / 2.0) * 16 var room_center_y = (start_room.y + start_room.h / 2.0) * 16 - player.global_position = Vector2(room_center_x, room_center_y) - print("GameWorld: Moved player ", player.name, " to start room center at ", player.global_position) + var fallback_pos = Vector2(room_center_x, room_center_y) + player.global_position = fallback_pos + LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY) + + # Send ALL current player positions to the new joiner (so they see everyone correctly) + # Wait longer to ensure the client has fully loaded the game scene and all player nodes are spawned + await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning + + for player in sorted_players: + if player.is_inside_tree() and is_instance_valid(player): + # Use GameWorld RPC to avoid node path resolution issues + _sync_player_position_by_name.rpc_id(new_peer_id, player.name, player.global_position) + LogManager.log("GameWorld: Sent player " + player.name + " position (" + str(player.global_position) + ") to new joiner " + str(new_peer_id), LogManager.CATEGORY_GAMEPLAY) + + # Notify all existing players about the new joiner's positions (without moving them) + var existing_peers = multiplayer.get_peers() + for existing_peer_id in existing_peers: + if existing_peer_id != new_peer_id: + for player in new_joiner_players: + if player.is_inside_tree() and is_instance_valid(player): + _sync_player_position_by_name.rpc_id(existing_peer_id, player.name, player.global_position) + LogManager.log("GameWorld: Notified existing peer " + str(existing_peer_id) + " about new joiner player " + player.name + " position (" + str(player.global_position) + ")", LogManager.CATEGORY_GAMEPLAY) + +func _move_all_players_to_start_room(): + # Move all players to the start room of the new level + # This should ONLY be called during level generation or level transitions, NOT when a new player joins + if dungeon_data.is_empty() or not dungeon_data.has("start_room"): + return + + var start_room = dungeon_data.start_room + _update_spawn_points(start_room) + + # Move all players to spawn points based on peer_id (deterministic across clients) + var players = get_tree().get_nodes_in_group("player") + + # Sort players by peer_id, then by local_player_index to ensure consistent ordering + var sorted_players = [] + for player in players: + if player.get("peer_id") != null: + sorted_players.append(player) + sorted_players.sort_custom(func(a, b): + if a.peer_id != b.peer_id: + return a.peer_id < b.peer_id + var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0 + var b_index = b.get("local_player_index") if b.get("local_player_index") != null else 0 + return a_index < b_index + ) + + # Assign spawn points sequentially based on sorted order + # IMPORTANT: Only server should move players and sync positions + # Clients should wait for server to sync positions via RPC + if not multiplayer.is_server(): + # Client: Don't move players here - wait for server to sync positions + # The server will call _sync_teleport_position for each player + return + + var spawn_index = 0 + for player in sorted_players: + if spawn_index < player_manager.spawn_points.size(): + var new_pos = player_manager.spawn_points[spawn_index] + player.global_position = new_pos + LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room at " + str(new_pos), LogManager.CATEGORY_GAMEPLAY) + + # Server: Sync position to all clients for ALL players (including server's own) + # Use GameWorld RPC to avoid node path resolution issues + _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, new_pos]) + + spawn_index += 1 + else: + # Fallback: place in center of start room + var room_center_x = (start_room.x + start_room.w / 2.0) * 16 + var room_center_y = (start_room.y + start_room.h / 2.0) * 16 + var fallback_pos = Vector2(room_center_x, room_center_y) + player.global_position = fallback_pos + LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room center at " + str(player.global_position), LogManager.CATEGORY_GAMEPLAY) + + # Server: Sync position to all clients for ALL players (including server's own) + _rpc_to_ready_peers("_sync_player_position_by_name", [player.name, fallback_pos]) func _create_stairs_area(): # Remove existing stairs area if any @@ -1745,10 +2707,10 @@ func _on_player_reached_stairs(player: Node): # Prevent multiple triggers - if already triggered, ignore if level_complete_triggered: - print("GameWorld: Level complete already triggered, ignoring duplicate trigger") + LogManager.log("GameWorld: Level complete already triggered, ignoring duplicate trigger", LogManager.CATEGORY_DUNGEON) return - print("GameWorld: Player ", player.name, " reached stairs!") + LogManager.log("GameWorld: Player " + str(player.name) + " reached stairs!", LogManager.CATEGORY_DUNGEON) # Mark as triggered to prevent re-triggering level_complete_triggered = true @@ -1767,7 +2729,7 @@ func _on_player_reached_stairs(player: Node): # Sync controls disabled and collision removal to clients if multiplayer.has_multiplayer_peer() and player_peer_id > 0: - _sync_player_exit_stairs.rpc(player_peer_id) + _rpc_to_ready_peers("_sync_player_exit_stairs", [player_peer_id]) # Drop any held objects for all players before level completion var entities_node = get_node_or_null("Entities") @@ -1792,7 +2754,7 @@ func _on_player_reached_stairs(player: Node): _show_level_complete_ui(level_time) # Sync to all clients (each client will show their own local player's stats) if multiplayer.has_multiplayer_peer(): - _sync_show_level_complete.rpc(level_time) + _rpc_to_ready_peers("_sync_show_level_complete", [level_time]) # After delay, hide UI and generate new level await get_tree().create_timer(5.0).timeout # Show stats for 5 seconds @@ -1803,7 +2765,7 @@ func _on_player_reached_stairs(player: Node): level_complete_ui.visible = false # Sync hide to all clients if multiplayer.has_multiplayer_peer(): - _sync_hide_level_complete.rpc() + _rpc_to_ready_peers("_sync_hide_level_complete", []) # Clear previous level _clear_level() @@ -1814,7 +2776,12 @@ func _on_player_reached_stairs(player: Node): # Generate next level current_level += 1 - print("GameWorld: Incremented to level ", current_level, " (was level ", current_level - 1, ")") + LogManager.log("GameWorld: Incremented to level " + str(current_level) + " (was level " + str(current_level - 1) + ")", LogManager.CATEGORY_DUNGEON) + + # Update room registry with new level + if network_manager and network_manager.is_hosting and network_manager.room_registry: + var player_count = network_manager.get_all_player_ids().size() + network_manager.room_registry.send_room_update(player_count, current_level) # Generate the new dungeon (this is async but we don't await it - it will complete in background) _generate_dungeon() @@ -1833,25 +2800,21 @@ func _on_player_reached_stairs(player: Node): _show_level_number() # Sync to all clients if multiplayer.has_multiplayer_peer(): - print("GameWorld: Syncing level number ", current_level, " to all clients") - _sync_show_level_number.rpc(current_level) + LogManager.log("GameWorld: Syncing level number " + str(current_level) + " to all clients", LogManager.CATEGORY_DUNGEON) + _rpc_to_ready_peers("_sync_show_level_number", [current_level]) # Restart HUD timer for new level hud = get_node_or_null("IngameHUD") if hud and hud.has_method("start_timer"): hud.start_timer() - # Restore controls and collision for all players (server side) - _restore_player_controls_and_collision() - - # Sync restore to all clients - if multiplayer.has_multiplayer_peer(): - _sync_restore_player_controls.rpc() + # Restore controls and collision for all players after a short delay + _schedule_restore_player_controls(0.6) # Remove black fade overlay (server and clients) _remove_black_fade_overlay() if multiplayer.has_multiplayer_peer(): - _sync_remove_black_fade.rpc() + _rpc_to_ready_peers("_sync_remove_black_fade", []) # Move all players to start room (server side) _move_all_players_to_start_room() @@ -1869,11 +2832,11 @@ func _on_player_reached_stairs(player: Node): # Debug: Verify enemies are in dungeon_data before syncing if dungeon_data.has("enemies"): - print("GameWorld: Server about to sync new level with ", dungeon_data.enemies.size(), " enemies to all clients") + LogManager.log("GameWorld: Server about to sync new level with " + str(dungeon_data.enemies.size()) + " enemies to all clients", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: ERROR: Server dungeon_data has NO 'enemies' key when syncing new level!") + LogManager.log("GameWorld: ERROR: Server dungeon_data has NO 'enemies' key when syncing new level!", LogManager.CATEGORY_DUNGEON) - _sync_dungeon.rpc(dungeon_data, dungeon_seed, current_level, start_room) + _rpc_to_ready_peers("_sync_dungeon", [dungeon_data, dungeon_seed, current_level, start_room]) func _get_local_player_stats() -> Dictionary: # Get stats for the local player (for level complete screen) @@ -2008,7 +2971,7 @@ func _show_black_fade_overlay(): fade_rect.modulate.a = 0.0 # Start transparent var fade_tween = create_tween() fade_tween.tween_property(fade_rect, "modulate:a", 1.0, 0.5) # Fade in over 0.5 seconds - print("GameWorld: Created black fade overlay for player who reached exit") + LogManager.log("GameWorld: Created black fade overlay for player who reached exit", LogManager.CATEGORY_DUNGEON) func _remove_black_fade_overlay(): # Remove black fade overlay when new level starts @@ -2021,7 +2984,7 @@ func _remove_black_fade_overlay(): fade_tween.tween_property(fade_rect, "modulate:a", 0.0, 0.2) # Fade out over 0.2 seconds await fade_tween.finished existing_fade.queue_free() - print("GameWorld: Removed black fade overlay") + LogManager.log("GameWorld: Removed black fade overlay", LogManager.CATEGORY_DUNGEON) func _restore_player_controls_and_collision(): # Restore controls and collision for all players when new level starts @@ -2032,6 +2995,17 @@ func _restore_player_controls_and_collision(): player.set_collision_layer_value(1, true) print("GameWorld: Restored controls and collision for player ", player.name) +func _schedule_restore_player_controls(delay: float = 0.6): + # Delay restoring player controls/collision to allow positioning to finish + if not multiplayer.is_server(): + return + get_tree().create_timer(delay).timeout.connect(func(): + if is_inside_tree(): + _restore_player_controls_and_collision() + if multiplayer.has_multiplayer_peer(): + _rpc_to_ready_peers("_sync_restore_player_controls", []) + ) + func _show_level_complete_ui(level_time: float = 0.0): # Create or show level complete UI var level_complete_ui = get_node_or_null("LevelCompleteUI") @@ -2046,7 +3020,7 @@ func _show_level_complete_ui(level_time: float = 0.0): add_child(level_complete_ui) else: # Scene file exists but failed to load - fall back to programmatic creation - print("GameWorld: Warning - level_complete_ui.tscn exists but failed to load, creating programmatically") + LogManager.log("GameWorld: Warning - level_complete_ui.tscn exists but failed to load, creating programmatically", LogManager.CATEGORY_DUNGEON) level_complete_ui = _create_level_complete_ui_programmatically() else: # Scene file doesn't exist - create UI programmatically (expected behavior) @@ -2082,7 +3056,7 @@ func _show_level_number(): add_child(level_text_ui) else: # Scene file exists but failed to load - fall back to programmatic creation - print("GameWorld: Warning - level_text_ui.tscn exists but failed to load, creating programmatically") + LogManager.log("GameWorld: Warning - level_text_ui.tscn exists but failed to load, creating programmatically", LogManager.CATEGORY_DUNGEON) level_text_ui = _create_level_text_ui_programmatically() else: # Scene file doesn't exist - create UI programmatically (expected behavior) @@ -2092,19 +3066,19 @@ func _show_level_number(): if level_text_ui.has_method("show_level"): # Store the level number in a local variable to ensure we use the correct value var level_to_show = current_level - print("GameWorld: Calling show_level(", level_to_show, ") on LevelTextUI (current_level = ", current_level, ")") + LogManager.log("GameWorld: Calling show_level(" + str(level_to_show) + ") on LevelTextUI (current_level = " + str(current_level) + ")", LogManager.CATEGORY_DUNGEON) # Make sure we pass the current level value explicitly level_text_ui.show_level(level_to_show) else: - print("GameWorld: ERROR - LevelTextUI does not have show_level method!") + LogManager.log("GameWorld: ERROR - LevelTextUI does not have show_level method!", LogManager.CATEGORY_DUNGEON) else: - print("GameWorld: ERROR - Could not create or find LevelTextUI!") + LogManager.log("GameWorld: ERROR - Could not create or find LevelTextUI!", LogManager.CATEGORY_DUNGEON) func _load_hud(): # Check if HUD already exists - only load it once var existing_hud = get_node_or_null("IngameHUD") if existing_hud and is_instance_valid(existing_hud): - print("GameWorld: HUD already exists, skipping load (will just reset timer)") + LogManager.log("GameWorld: HUD already exists, skipping load (will just reset timer)", LogManager.CATEGORY_DUNGEON) # Reset timer for new level if method exists if existing_hud.has_method("reset_level_timer"): existing_hud.reset_level_timer() @@ -2117,7 +3091,7 @@ func _load_hud(): # Check if scene exists if not ResourceLoader.exists(hud_scene_path): - print("GameWorld: HUD scene not found at ", hud_scene_path, " - HUD disabled") + LogManager.log("GameWorld: HUD scene not found at " + str(hud_scene_path) + " - HUD disabled", LogManager.CATEGORY_DUNGEON) return # Try to load the scene @@ -2131,11 +3105,11 @@ func _load_hud(): if hud_scene.has_method("instantiate"): hud = hud_scene.instantiate() else: - print("GameWorld: Warning - HUD scene is not a PackedScene") + LogManager.log("GameWorld: Warning - HUD scene is not a PackedScene", LogManager.CATEGORY_DUNGEON) return if not hud: - print("GameWorld: Warning - Failed to instantiate HUD scene") + LogManager.log("GameWorld: Warning - Failed to instantiate HUD scene", LogManager.CATEGORY_DUNGEON) return # Add to scene tree @@ -2151,7 +3125,7 @@ func _load_hud(): if hud.has_method("reset_level_timer"): hud.reset_level_timer() - print("GameWorld: HUD loaded successfully and added to scene tree") + LogManager.log("GameWorld: HUD loaded successfully and added to scene tree", LogManager.CATEGORY_DUNGEON) print("GameWorld: HUD visible: ", hud.visible, ", layer: ", hud.layer) func _initialize_hud(): @@ -2161,7 +3135,7 @@ func _initialize_hud(): if hud and is_instance_valid(hud) and hud.has_method("reset_level_timer"): hud.reset_level_timer() else: - print("GameWorld: HUD not found or not ready - this is OK if HUD scene is missing") + LogManager.log("GameWorld: HUD not found or not ready - this is OK if HUD scene is missing", LogManager.CATEGORY_DUNGEON) func _create_chat_ui(): # Load chat UI scene @@ -2173,7 +3147,7 @@ func _create_chat_ui(): 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") + LogManager.log("GameWorld: Chat UI scene instantiated and added to scene tree", LogManager.CATEGORY_DUNGEON) else: push_error("GameWorld: Failed to instantiate chat_ui.tscn!") @@ -2188,7 +3162,7 @@ func _create_inventory_ui(): if inventory_ui: inventory_ui.name = "InventoryUI" add_child(inventory_ui) - print("GameWorld: Inventory UI scene instantiated and added to scene tree") + LogManager.log("GameWorld: Inventory UI scene instantiated and added to scene tree", LogManager.CATEGORY_DUNGEON) else: push_error("GameWorld: Failed to instantiate inventory_ui.tscn!") @@ -2229,9 +3203,96 @@ func _send_player_disconnect_message(peer_id: int, player_info: Dictionary): # 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 + var message = "%s disconnected" % player_name chat_ui.send_system_message(message) +@rpc("authority", "reliable") +func _receive_chat_message(player_name: String, message: String): + # Route chat messages through game_world to avoid node path issues + # Find ChatUI and call _receive_message on it + var chat_ui = get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("_receive_message"): + chat_ui._receive_message(player_name, message) + else: + # ChatUI not found yet, log and skip (message will be lost, but that's okay) + LogManager.log("GameWorld: ChatUI not found when trying to receive chat message from " + player_name, LogManager.CATEGORY_NETWORK) + +@rpc("any_peer", "reliable") +func _sync_object_break(obj_name: String): + # Route object break RPC through game_world to avoid node path issues + # Find object by name and call _sync_break on it + # Check if this node is still valid and in tree + if not is_inside_tree(): + LogManager.log("GameWorld: Node not in tree, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK) + return + + var entities_node = get_node_or_null("Entities") + if not entities_node: + LogManager.log("GameWorld: Entities node not found, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK) + return + + # Try to find object by name first + var obj = entities_node.get_node_or_null(obj_name) + + # If not found and name looks like "InteractableObject_X", try extracting index and searching by meta + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + # Search all children for object with matching object_index meta + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + + # If still not found, try to find any interactable object that matches (fallback) + if not obj: + # Last resort: try to find by checking if any interactable has the name or position + # This shouldn't be necessary if naming is consistent, but helps with edge cases + for child in entities_node.get_children(): + if "health" in child and child.has_method("_sync_break"): + # If this object has object_index meta, check if name matches pattern + if child.has_meta("object_index"): + var child_index = child.get_meta("object_index") + var expected_name = "InteractableObject_%d" % child_index + if expected_name == obj_name: + obj = child + break + + if obj and obj.has_method("_sync_break"): + # Break locally (on server or client) + obj._sync_break() + + # On server: track broken objects for syncing to new clients + if multiplayer.is_server(): + var obj_index = -1 + if obj.has_meta("object_index"): + obj_index = obj.get_meta("object_index") + elif obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + obj_index = index_str.to_int() + + if obj_index >= 0: + broken_objects[obj_index] = true + LogManager.log("GameWorld: Tracking broken object " + obj_name + " (index: " + str(obj_index) + ")", LogManager.CATEGORY_NETWORK) + + # If we're the server and this came from a client, broadcast to all other clients + if multiplayer.is_server() and multiplayer.get_remote_sender_id() != 0: + # This is a request from a client - broadcast to all clients + # The client who sent the request already broke it locally, so they'll skip breaking again + # Use call_deferred to ensure we're in a safe state for RPC + call_deferred("_broadcast_object_break", obj_name) + LogManager.log("GameWorld: Server broadcasting box break for " + obj_name + " (requested by client " + str(multiplayer.get_remote_sender_id()) + ")", LogManager.CATEGORY_NETWORK) + else: + # Object not found - might have already been destroyed, which is okay + LogManager.log("GameWorld: Object " + obj_name + " not found when trying to sync break", LogManager.CATEGORY_NETWORK) + +func _broadcast_object_break(obj_name: String): + # Helper to broadcast object break - called deferred to ensure safe RPC state + if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + _rpc_to_ready_peers("_sync_object_break", [obj_name]) + func _create_level_complete_ui_programmatically() -> Node: # Create level complete UI programmatically var canvas_layer = CanvasLayer.new() @@ -2242,7 +3303,7 @@ func _create_level_complete_ui_programmatically() -> Node: # Load standard font (as FontFile) var standard_font = load("res://assets/fonts/standard_font.png") as FontFile if not standard_font: - print("GameWorld: Warning - Could not load standard_font.png as FontFile") + LogManager.log("GameWorld: Warning - Could not load standard_font.png as FontFile", LogManager.CATEGORY_DUNGEON) # Create theme with standard font var theme = Theme.new() @@ -2399,7 +3460,7 @@ func _move_players_to_host_room(host_room: Dictionary): if players_to_move.is_empty(): return - print("GameWorld: Moving ", players_to_move.size(), " local players to host room") + LogManager.log("GameWorld: Moving " + str(players_to_move.size()) + " local players to host room", LogManager.CATEGORY_DUNGEON) # Move each player to a free spawn point var spawn_index = 0 @@ -2429,7 +3490,7 @@ func _spawn_blocking_doors(): push_error("ERROR: Could not find Entities node!") return - print("GameWorld: Spawning ", blocking_doors.size(), " blocking doors") + LogManager.log("GameWorld: Spawning " + str(blocking_doors.size()) + " blocking doors", LogManager.CATEGORY_DUNGEON) # Track pillar placement per room to avoid duplicates var rooms_with_pillars: Dictionary = {} # Key: room string "x,y", Value: true if pillar exists @@ -2442,6 +3503,7 @@ func _spawn_blocking_doors(): var door = door_scene.instantiate() door.name = "BlockingDoor_%d" % i door.add_to_group("blocking_door") + door.set_meta("door_index", i) # Set door properties BEFORE adding to scene (so _ready() has correct values) door.type = door_data.type if "type" in door_data else "StoneDoor" @@ -2453,11 +3515,11 @@ func _spawn_blocking_doors(): if door_data.puzzle_type == "enemy": door.requires_enemies = true door.requires_switch = false - print("GameWorld: Door ", door.name, " requires enemies to open (puzzle_type: enemy)") + LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON) elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]: door.requires_enemies = false door.requires_switch = true - print("GameWorld: Door ", door.name, " requires switch to open (puzzle_type: ", door_data.puzzle_type, ")") + LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON) door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {} door.switch_room = door_data.switch_room if "switch_room" in door_data else {} @@ -2474,7 +3536,7 @@ func _spawn_blocking_doors(): door.queue_free() continue - print("GameWorld: Creating blocking door ", door.name, " (", door_data.type, ") for puzzle room (", door_data.blocking_room.x, ", ", door_data.blocking_room.y, "), puzzle_type: ", door_data.puzzle_type) + LogManager.log("GameWorld: Creating blocking door " + str(door.name) + " (" + str(door_data.type) + ") for puzzle room (" + str(door_data.blocking_room.x) + ", " + str(door_data.blocking_room.y) + "), puzzle_type: " + str(door_data.puzzle_type), LogManager.CATEGORY_DUNGEON) # CRITICAL: Store original door connection info from door_data # For blocking doors: room1 = puzzle room (where door is IN / leads FROM) @@ -2516,7 +3578,7 @@ func _spawn_blocking_doors(): door.queue_free() continue - print("GameWorld: Blocking door ", door.name, " verified - room1 (", door.room1.x, ",", door.room1.y, ") == blocking_room (", door.blocking_room.x, ",", door.blocking_room.y, ") - door is IN puzzle room") + LogManager.log("GameWorld: Blocking door " + str(door.name) + " verified - room1 (" + str(door.room1.x) + "," + str(door.room1.y) + ") == blocking_room (" + str(door.blocking_room.x) + "," + str(door.blocking_room.y) + ") - door is IN puzzle room", LogManager.CATEGORY_DUNGEON) # Set multiplayer authority BEFORE adding to scene if multiplayer.has_multiplayer_peer(): @@ -2527,6 +3589,13 @@ func _spawn_blocking_doors(): # Add to scene (this triggers _ready() which will use the position we just set) entities_node.add_child(door) + # Re-apply name after add_child to avoid auto-renaming + door.name = "BlockingDoor_%d" % i + door.set_meta("door_index", i) + + # Apply any pending door state sync that arrived before this door spawned + if pending_door_states.has(door.name): + _apply_pending_door_state(door) # NOTE: Doors are connected to room triggers automatically by room_trigger._find_room_entities() # No need to manually connect them here @@ -2594,7 +3663,7 @@ func _spawn_blocking_doors(): if pos_match: # Both room AND position match - this is the correct switch existing_switch = existing - print("GameWorld: Found existing switch ", existing.name, " in room (", door_blocking_room.x, ",", door_blocking_room.y, ") at position ", existing.global_position, " matching door room and position") + LogManager.log("GameWorld: Found existing switch " + str(existing.name) + " in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") at position " + str(existing.global_position) + " matching door room and position", LogManager.CATEGORY_DUNGEON) break if existing_switch: @@ -2611,7 +3680,7 @@ func _spawn_blocking_doors(): # Switch already exists in the SAME room - connect door to existing switch door.connected_switches.append(existing_switch) has_puzzle_element = true - print("GameWorld: Connected door ", door.name, " (room: ", door_blocking_room.get("x", "?"), ",", door_blocking_room.get("y", "?"), ") to existing switch ", existing_switch.name, " in SAME room") + LogManager.log("GameWorld: Connected door " + str(door.name) + " (room: " + str(door_blocking_room.get("x", "?")) + "," + str(door_blocking_room.get("y", "?")) + ") to existing switch " + str(existing_switch.name) + " in SAME room", LogManager.CATEGORY_DUNGEON) # If this is a pillar switch, ensure a pillar exists in the room # Check if switch is a pillar switch (check both door_data and existing switch) @@ -2635,19 +3704,16 @@ func _spawn_blocking_doors(): if obj_room.x == door_blocking_room.x and obj_room.y == door_blocking_room.y and \ obj_room.w == door_blocking_room.w and obj_room.h == door_blocking_room.h: pillar_exists_in_room = true - print("GameWorld: Found existing pillar in room (", door_blocking_room.x, ",", door_blocking_room.y, ")") + LogManager.log("GameWorld: Found existing pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON) break - # If no pillar exists, place one - if not pillar_exists_in_room: - print("GameWorld: Pillar switch found but no pillar in room (", door_blocking_room.x, ",", door_blocking_room.y, ") - placing pillar now") - _place_pillar_in_room(door_blocking_room, existing_switch.global_position) - # Mark room as checked after attempting to place pillar - # Note: Even if placement fails, mark as checked to avoid repeated attempts - rooms_with_pillars[room_key] = true + # Pillars should already be in dungeon generation data + # Just mark room as checked (don't place extra pillars - they won't sync) + if pillar_exists_in_room: + LogManager.log("GameWorld: Found existing pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON) else: - # Pillar exists - mark room as checked - rooms_with_pillars[room_key] = true + push_warning("GameWorld: WARNING - Pillar switch found but no pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") - pillar should have been placed during dungeon generation!") + rooms_with_pillars[room_key] = true else: push_error("GameWorld: ERROR - Attempted to connect door ", door.name, " to switch ", existing_switch.name, " but rooms don't match! Door room: (", door_blocking_room.get("x", "?"), ",", door_blocking_room.get("y", "?"), "), Switch room: (", existing_switch_room_final.get("x", "?") if existing_switch_room_final else "?", ",", existing_switch_room_final.get("y", "?") if existing_switch_room_final else "?", ")") # Don't connect - spawn a new switch instead @@ -2692,20 +3758,31 @@ func _spawn_blocking_doors(): # Connect switch to door ONLY if rooms match exactly door.connected_switches.append(switch) has_puzzle_element = true - print("GameWorld: Spawned switch ", switch.name, " in room (", door_blocking_room.x, ",", door_blocking_room.y, ") and connected to door ", door.name, " in SAME room") + LogManager.log("GameWorld: Spawned switch " + str(switch.name) + " in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") and connected to door " + str(door.name) + " in SAME room", LogManager.CATEGORY_DUNGEON) - # If this is a pillar switch, place a pillar in the same room + # If this is a pillar switch, verify pillar exists (should be in dungeon generation data) if switch_type == "pillar": - # Check if we've already placed a pillar for this room var room_key = str(door_blocking_room.x) + "," + str(door_blocking_room.y) if not rooms_with_pillars.has(room_key): - print("GameWorld: Placing pillar for new pillar switch in room (", door_blocking_room.x, ",", door_blocking_room.y, ")") - _place_pillar_in_room(door_blocking_room, switch_pos) - # Mark room as checked after attempting to place pillar - # Note: Even if placement fails, mark as checked to avoid repeated attempts + # Check if pillar exists in the room + var pillar_exists = false + for obj in get_tree().get_nodes_in_group("interactable_object"): + if is_instance_valid(obj): + var obj_type = obj.object_type if "object_type" in obj else "" + if obj_type == "Pillar": + if obj.has_meta("room"): + var obj_room = obj.get_meta("room") + if obj_room and not obj_room.is_empty(): + if obj_room.x == door_blocking_room.x and obj_room.y == door_blocking_room.y and \ + obj_room.w == door_blocking_room.w and obj_room.h == door_blocking_room.h: + pillar_exists = true + LogManager.log("GameWorld: Found existing pillar for new pillar switch in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON) + break + if not pillar_exists: + push_warning("GameWorld: WARNING - New pillar switch spawned but no pillar found in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") - pillar should have been placed during dungeon generation!") rooms_with_pillars[room_key] = true else: - print("GameWorld: Pillar already exists/placed for room (", door_blocking_room.x, ",", door_blocking_room.y, ") - skipping placement") + LogManager.log("GameWorld: Already checked for pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON) else: push_error("GameWorld: ERROR - Switch ", switch.name, " room (", switch_room_check.x, ",", switch_room_check.y, ") doesn't match door blocking_room (", door_blocking_room.x, ",", door_blocking_room.y, ")! NOT connecting! Removing switch.") switch.queue_free() # Remove the switch since it's in wrong room @@ -2724,9 +3801,9 @@ func _spawn_blocking_doors(): # Spawn enemy spawners if this door requires enemies (puzzle_type is "enemy") if "puzzle_type" in door_data and door_data.puzzle_type == "enemy": - print("GameWorld: ===== Door ", door.name, " has puzzle_type 'enemy' - checking for enemy_spawners =====") + LogManager.log("GameWorld: ===== Door " + str(door.name) + " has puzzle_type 'enemy' - checking for enemy_spawners =====", LogManager.CATEGORY_DUNGEON) if "enemy_spawners" in door_data and door_data.enemy_spawners is Array: - print("GameWorld: Door has enemy_spawners array with ", door_data.enemy_spawners.size(), " spawners") + LogManager.log("GameWorld: Door has enemy_spawners array with " + str(door_data.enemy_spawners.size()) + " spawners", LogManager.CATEGORY_DUNGEON) var spawner_created = false for spawner_data in door_data.enemy_spawners: if spawner_data is Dictionary and spawner_data.has("position"): @@ -2766,24 +3843,24 @@ func _spawn_blocking_doors(): push_warning("GameWorld: Reason: door_data.enemy_spawners array is empty!") else: if "puzzle_type" in door_data: - print("GameWorld: Door ", door.name, " has puzzle_type '", door_data.puzzle_type, "' (not 'enemy')") + LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON) # CRITICAL: If door has no puzzle elements (neither switch nor spawner), this is an error # This should never happen if dungeon_generator logic is correct, but add safety check if door_data.type != "KeyDoor" and not has_puzzle_element: push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!") print("GameWorld: Door data keys: ", door_data.keys()) - print("GameWorld: Door puzzle_type: ", door_data.get("puzzle_type", "MISSING")) + LogManager.log("GameWorld: Door puzzle_type: " + str(door_data.get("puzzle_type", "MISSING")), LogManager.CATEGORY_DUNGEON) print("GameWorld: Door has requires_switch: ", door_data.get("requires_switch", false)) print("GameWorld: Door has requires_enemies: ", door_data.get("requires_enemies", false)) print("GameWorld: Door has floor_switch_position: ", "floor_switch_position" in door_data) print("GameWorld: Door has enemy_spawners: ", "enemy_spawners" in door_data) # Remove the door since it's invalid - it was created without puzzle elements door.queue_free() - print("GameWorld: Removed invalid blocking door ", door.name, " - it had no puzzle elements!") + LogManager.log("GameWorld: Removed invalid blocking door " + str(door.name) + " - it had no puzzle elements!", LogManager.CATEGORY_DUNGEON) continue # Skip to next door - print("GameWorld: Spawned ", blocking_doors.size(), " blocking doors") + LogManager.log("GameWorld: Spawned " + str(blocking_doors.size()) + " blocking doors", LogManager.CATEGORY_DUNGEON) func _spawn_floor_switch(i_position: Vector2, required_weight: float, tile_x: int, tile_y: int, switch_type: String = "walk", switch_room: Dictionary = {}) -> Node: # Spawn a floor switch using the scene file @@ -2814,7 +3891,7 @@ func _spawn_floor_switch(i_position: Vector2, required_weight: float, tile_x: in # This ensures switches can be matched to doors in the same room if switch_room and not switch_room.is_empty(): switch.set_meta("switch_room", switch_room) - print("GameWorld: Set switch_room metadata for switch - room (", switch_room.x, ", ", switch_room.y, ")") + LogManager.log("GameWorld: Set switch_room metadata for switch - room (" + str(switch_room.x) + ", " + str(switch_room.y) + ")", LogManager.CATEGORY_DUNGEON) else: push_warning("GameWorld: WARNING - Spawning switch without switch_room metadata! This may cause cross-room connections!") @@ -2833,7 +3910,9 @@ func _spawn_floor_switch(i_position: Vector2, required_weight: float, tile_x: in initial_tile = Vector2i(11, 9) # Walk-on switch inactive dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, initial_tile) - print("GameWorld: Spawned ", switch_type, " floor switch at ", i_position, " tile (", tile_x, ", ", tile_y, "), room: (", switch_room.get("x", "?") if switch_room and not switch_room.is_empty() else "?", ", ", switch_room.get("y", "?") if switch_room and not switch_room.is_empty() else "?", ")") + var room_x_str = str(switch_room.get("x", "?")) if switch_room and not switch_room.is_empty() else "?" + var room_y_str = str(switch_room.get("y", "?")) if switch_room and not switch_room.is_empty() else "?" + LogManager.log("GameWorld: Spawned " + str(switch_type) + " floor switch at " + str(i_position) + " tile (" + str(tile_x) + ", " + str(tile_y) + "), room: (" + room_x_str + ", " + room_y_str + ")", LogManager.CATEGORY_DUNGEON) return switch return null @@ -2890,13 +3969,20 @@ func _spawn_enemy_spawner(i_position: Vector2, room: Dictionary, spawner_data: D if entities_node: entities_node.add_child(spawner) spawner.global_position = i_position - print("GameWorld: ✓✓✓ Successfully spawned enemy spawner '", spawner.name, "' at ", i_position, " for room at (", room.x if room and not room.is_empty() else "unknown", ", ", room.y if room and not room.is_empty() else "unknown", ")") - print("GameWorld: Spawner has room metadata: ", spawner.has_meta("room")) + var room_x_str = str(room.x) if room and not room.is_empty() else "unknown" + var room_y_str = str(room.y) if room and not room.is_empty() else "unknown" + LogManager.log("GameWorld: ✓✓✓ Successfully spawned enemy spawner '" + str(spawner.name) + "' at " + str(i_position) + " for room at (" + room_x_str + ", " + room_y_str + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("GameWorld: Spawner has room metadata: " + str(spawner.has_meta("room")), LogManager.CATEGORY_DUNGEON) if spawner.has_meta("room"): var spawner_room = spawner.get_meta("room") - print("GameWorld: Spawner room metadata: (", spawner_room.x if spawner_room and not spawner_room.is_empty() else "none", ", ", spawner_room.y if spawner_room and not spawner_room.is_empty() else "none", ", ", spawner_room.w if spawner_room and not spawner_room.is_empty() else "none", "x", spawner_room.h if spawner_room and not spawner_room.is_empty() else "none", ")") - print("GameWorld: Spawner in group 'enemy_spawner': ", spawner.is_in_group("enemy_spawner")) - print("GameWorld: Spawner enemy_scenes.size(): ", spawner.enemy_scenes.size() if "enemy_scenes" in spawner else "N/A") + var spawner_room_x = str(spawner_room.x) if spawner_room and not spawner_room.is_empty() else "none" + var spawner_room_y = str(spawner_room.y) if spawner_room and not spawner_room.is_empty() else "none" + var spawner_room_w = str(spawner_room.w) if spawner_room and not spawner_room.is_empty() else "none" + var spawner_room_h = str(spawner_room.h) if spawner_room and not spawner_room.is_empty() else "none" + LogManager.log("GameWorld: Spawner room metadata: (" + spawner_room_x + ", " + spawner_room_y + ", " + spawner_room_w + "x" + spawner_room_h + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("GameWorld: Spawner in group 'enemy_spawner': " + str(spawner.is_in_group("enemy_spawner")), LogManager.CATEGORY_DUNGEON) + var enemy_scenes_size = str(spawner.enemy_scenes.size()) if "enemy_scenes" in spawner else "N/A" + LogManager.log("GameWorld: Spawner enemy_scenes.size(): " + enemy_scenes_size, LogManager.CATEGORY_DUNGEON) return spawner return null @@ -2920,13 +4006,13 @@ func _spawn_room_triggers(): push_error("ERROR: Could not find Entities node!") return - print("GameWorld: Spawning ", rooms.size(), " room triggers") + LogManager.log("GameWorld: Spawning " + str(rooms.size()) + " room triggers", LogManager.CATEGORY_DUNGEON) var triggers_spawned = 0 for i in range(rooms.size()): var room = rooms[i] if not room is Dictionary: - print("GameWorld: WARNING - Room at index ", i, " is not a Dictionary, skipping") + LogManager.log("GameWorld: WARNING - Room at index " + str(i) + " is not a Dictionary, skipping", LogManager.CATEGORY_DUNGEON) continue var trigger = Area2D.new() @@ -2964,12 +4050,16 @@ func _spawn_room_triggers(): # Add to scene entities_node.add_child(trigger) triggers_spawned += 1 - print("GameWorld: Added room trigger ", trigger.name, " for room (", room.x, ", ", room.y, ") - ", triggers_spawned, "/", rooms.size()) + LogManager.log("GameWorld: Added room trigger " + str(trigger.name) + " for room (" + str(room.x) + ", " + str(room.y) + ") - " + str(triggers_spawned) + "/" + str(rooms.size()), LogManager.CATEGORY_DUNGEON) - print("GameWorld: Spawned ", triggers_spawned, " room triggers (out of ", rooms.size(), " rooms)") + LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON) func _place_key_in_room(room: Dictionary): # Place a key in the specified room (as loot) + # Only run on server - keys are not synced via RPC, so clients should not spawn them + if not multiplayer.is_server() and multiplayer.has_multiplayer_peer(): + return + if room.is_empty(): return @@ -3006,6 +4096,11 @@ func _place_key_in_room(room: Dictionary): key_loot.name = "KeyLoot_%d_%d" % [int(key_pos.x), int(key_pos.y)] key_loot.loot_type = key_loot.LootType.KEY + # Assign unique loot ID for syncing/removal + var loot_id = loot_id_counter + loot_id_counter += 1 + key_loot.set_meta("loot_id", loot_id) + # Set multiplayer authority if multiplayer.has_multiplayer_peer(): key_loot.set_multiplayer_authority(1) @@ -3013,100 +4108,11 @@ func _place_key_in_room(room: Dictionary): entities_node.add_child(key_loot) key_loot.global_position = key_pos - print("GameWorld: Placed key in room at ", key_pos) - -func _place_pillar_in_room(room: Dictionary, switch_position: Vector2): - # Place a pillar in the specified room (needed for pillar switches) - if room.is_empty(): - return - - var interactable_object_scene = load("res://scenes/interactable_object.tscn") - if not interactable_object_scene: - push_error("ERROR: Could not load interactable_object scene for pillar!") - return - - var entities_node = get_node_or_null("Entities") - if not entities_node: - push_error("ERROR: Could not find Entities node for pillar placement!") - return - - # Find a valid floor position in the room (away from the switch) - var tile_size = 16 - var valid_positions = [] - - # Room interior floor tiles: from room.x + 2 to room.x + room.w - 3 (excluding 2-tile walls on each side) - # Floor tiles are at: room.x+2, room.x+3, ..., room.x+room.w-3 (last floor tile before right wall) - # CRITICAL: Also exclude last row/column of floor tiles to prevent objects from spawning in walls - # Objects are 16x16, so we need at least 1 tile buffer from walls - # Floor tiles are at: room.x+2, room.x+3, ..., room.x+room.w-3 (last floor tile before wall) - # To exclude last tile, we want: room.x+2, room.x+3, ..., room.x+room.w-4 - var min_x = room.x + 2 - var max_x = room.x + room.w - 4 # Exclude last 2 floor tiles (room.w-3 is last floor, room.w-4 is second-to-last) - var min_y = room.y + 2 - var max_y = room.y + room.h - 4 # Exclude last 2 floor tiles (room.h-3 is last floor, room.h-4 is second-to-last) - - var interior_width = room.w - 4 # Exclude 2-tile walls on each side - var interior_height = room.h - 4 # Exclude 2-tile walls on each side - print("GameWorld: _place_pillar_in_room - Searching for valid positions in room (", room.x, ",", room.y, ",", room.w, "x", room.h, ")") - print("GameWorld: Interior size: ", interior_width, "x", interior_height, ", Checking tiles: x[", min_x, " to ", max_x, "], y[", min_y, " to ", max_y, "]") - print("GameWorld: Switch position: ", switch_position) - - for x in range(min_x, max_x + 1): # +1 because range is exclusive at end - for y in range(min_y, max_y + 1): - if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y: - if dungeon_data.grid[x][y] == 1: # Floor - # CRITICAL: Interactable objects are 16x16 pixels with origin at (0,0) (top-left) - # To center a 16x16 sprite in a 16x16 tile, we need to offset by 8 pixels into the tile - # Tile (x,y) spans from (x*16, y*16) to ((x+1)*16, (y+1)*16) - # Position sprite 8 pixels into the tile from top-left: (x*16 + 8, y*16 + 8) - var world_x = x * tile_size + 8 - var world_y = y * tile_size + 8 - var world_pos = Vector2(world_x, world_y) - - # Ensure pillar is at least 1 tile away from the switch - var distance_to_switch = world_pos.distance_to(switch_position) - if distance_to_switch >= tile_size * 1: # At least 1 tiles away - valid_positions.append(world_pos) - print("GameWorld: Valid position found at (", x, ",", y, ") -> world (", world_x, ",", world_y, "), distance to switch: ", distance_to_switch) - else: - print("GameWorld: Position at (", x, ",", y, ") -> world (", world_x, ",", world_y, ") too close to switch (distance: ", distance_to_switch, " < ", tile_size, ")") - - print("GameWorld: Found ", valid_positions.size(), " valid positions for pillar") - if valid_positions.size() > 0: - # Pick a deterministic random position using dungeon seed - # This ensures server and clients place pillars in the same positions - var rng = RandomNumberGenerator.new() - # Use dungeon seed + room position + switch position as seed for deterministic randomness - var pillar_seed = dungeon_seed + (room.x * 1000) + (room.y * 100) + int(switch_position.x) + int(switch_position.y) - rng.seed = pillar_seed - var pillar_pos = valid_positions[rng.randi() % valid_positions.size()] - - # Spawn pillar interactable object - var pillar = interactable_object_scene.instantiate() - pillar.name = "Pillar_%d_%d" % [int(pillar_pos.x), int(pillar_pos.y)] - pillar.set_meta("dungeon_spawned", true) - pillar.set_meta("room", room) - - # Set multiplayer authority + # Sync key loot spawn to clients (so pickup/removal works by ID) if multiplayer.has_multiplayer_peer(): - pillar.set_multiplayer_authority(1) + _rpc_to_ready_peers("_sync_loot_spawn", [key_pos, key_loot.LootType.KEY, Vector2.ZERO, 0.0, loot_id]) - # Add to scene tree - entities_node.add_child(pillar) - pillar.global_position = pillar_pos - - # Call setup function to configure as pillar - if pillar.has_method("setup_pillar"): - pillar.call("setup_pillar") - else: - push_error("ERROR: Pillar does not have setup_pillar method!") - - # Add to group for easy access - pillar.add_to_group("interactable_object") - - print("GameWorld: Placed pillar in room at ", pillar_pos, " (switch at ", switch_position, ")") - else: - push_warning("GameWorld: Could not find valid position for pillar in room! Room might be too small.") + print("GameWorld: Placed key in room at ", key_pos) func _connect_door_to_room_trigger(door: Node): # Connect a door to its room trigger area diff --git a/src/scripts/ingame_hud.gd b/src/scripts/ingame_hud.gd index 7946eec..5338337 100644 --- a/src/scripts/ingame_hud.gd +++ b/src/scripts/ingame_hud.gd @@ -15,6 +15,7 @@ var texture_progress_bar_boss_hp: TextureProgressBar = null var label_host: Label = null var label_player_count: Label = null var label_room_code: Label = null +var label_disconnected: Label = null var game_world: Node = null var network_manager: Node = null @@ -41,6 +42,7 @@ func _ready(): label_host = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelHost") label_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount") label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode") + label_disconnected = get_node_or_null("CenterTop/LabelDisconnected") # Find network manager network_manager = get_node_or_null("/root/NetworkManager") @@ -48,6 +50,8 @@ func _ready(): # Connect to player connection signals to update player count network_manager.player_connected.connect(_on_player_connected) network_manager.player_disconnected.connect(_on_player_disconnected) + network_manager.connection_failed.connect(_on_connection_failed) + network_manager.connection_succeeded.connect(_on_connection_succeeded) # Debug: Check if nodes were found if not label_time_value: @@ -86,6 +90,21 @@ func _on_player_connected(_peer_id: int, _player_info: Dictionary): func _on_player_disconnected(_peer_id: int, _player_info: Dictionary): _update_host_info() +func _on_connection_failed(): + # Show disconnection message + if label_disconnected: + label_disconnected.visible = true + # Show different message for host vs joiner + if network_manager and network_manager.is_hosting: + label_disconnected.text = "Lost connection to Matchbox server - Retrying..." + else: + label_disconnected.text = "Disconnected - Reconnecting..." + +func _on_connection_succeeded(): + # Hide disconnection message + if label_disconnected: + label_disconnected.visible = false + func _update_host_info(): if not network_manager: return diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 1b38a44..5472306 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -17,6 +17,7 @@ var is_being_held: bool = false var held_by_player = null var is_frozen: bool = false var thrown_by_player = null # Track who threw this box +var is_broken: bool = false # Physics for thrown objects var throw_velocity: Vector2 = Vector2.ZERO @@ -47,6 +48,17 @@ func _ready(): collision_layer = 2 # Layer 2 for objects collision_mask = 1 | 2 | 4 # Collide with players, other objects, and walls + # Ensure deterministic name for network sync + if has_meta("object_index") and not name.begins_with("InteractableObject_"): + name = "InteractableObject_%d" % get_meta("object_index") + elif name.begins_with("InteractableObject_"): + # Ensure meta matches name if it already has a consistent name + var index_str = name.substr(20) + if index_str.is_valid_int(): + var name_index = index_str.to_int() + if not has_meta("object_index") or get_meta("object_index") != name_index: + set_meta("object_index", name_index) + # No gravity in top-down motion_mode = MOTION_MODE_FLOATING @@ -164,9 +176,14 @@ func _handle_air_collision(): # Box breaks (only if destroyable) if is_destroyable: + # Sync break to OTHER clients via RPC BEFORE breaking locally + # Use game_world to route the RPC to avoid node path resolution issues + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [name]) + _break_into_pieces() - if multiplayer.has_multiplayer_peer(): - _sync_break.rpc() return @@ -181,11 +198,14 @@ func _handle_air_collision(): # Hit a player! Break locally and sync to others (only if destroyable) if is_destroyable: - _break_into_pieces() + # Sync break to OTHER clients via RPC BEFORE breaking locally + # Use game_world to route the RPC to avoid node path resolution issues + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [name]) - # Sync break to OTHER clients - if multiplayer.has_multiplayer_peer(): - _sync_break.rpc() + _break_into_pieces() # Damage and knockback player using RPC # Pass the thrower's position for accurate direction @@ -214,24 +234,28 @@ func _handle_air_collision(): # Hit another box! Break both locally (only if destroyable) if is_destroyable: + # Sync break to OTHER clients via RPC BEFORE breaking locally + # Use game_world to route the RPC to avoid node path resolution issues + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [name]) + # Tell the other box to break too + if collider.has_method("can_be_destroyed") and collider.can_be_destroyed(): + game_world._rpc_to_ready_peers("_sync_object_break", [collider.name]) + _break_into_pieces() if collider.has_method("_break_into_pieces") and collider.has_method("can_be_destroyed") and collider.can_be_destroyed(): collider._break_into_pieces() - - # Sync break to OTHER clients - if multiplayer.has_multiplayer_peer(): - _sync_break.rpc() - # Tell the other box to break too - if collider.has_method("_sync_break") and collider.has_method("can_be_destroyed") and collider.can_be_destroyed(): - collider._sync_break.rpc() print(name, " hit another box!") return -func _break_into_pieces(): +func _break_into_pieces(silent: bool = false): # Only break if destroyable - if not is_destroyable: + if not is_destroyable or is_broken: return + is_broken = true var sprite_texture = $Sprite2D.texture var frame_width = sprite_texture.get_width() / $Sprite2D.hframes @@ -254,27 +278,28 @@ func _break_into_pieces(): Rect2(frame_x + frame_width / 2, frame_y + frame_height / 2, frame_width / 2, frame_height / 2) # Bottom-right ] - for i in range(4): - var tp = tileParticleScene.instantiate() as CharacterBody2D - var spr2D = tp.get_node("Sprite2D") as Sprite2D - tp.global_position = global_position + if not silent: + for i in range(4): + var tp = tileParticleScene.instantiate() as CharacterBody2D + var spr2D = tp.get_node("Sprite2D") as Sprite2D + tp.global_position = global_position + + # Set up the sprite's texture and region + spr2D.texture = sprite_texture + spr2D.region_enabled = true + spr2D.region_rect = regions[i] + + # Add some randomness to the velocity + var speed = randf_range(170, 200) + var dir = directions[i] + Vector2(randf_range(-0.2, 0.2), randf_range(-0.2, 0.2)) + tp.velocity = dir * speed + + # Add some rotation + tp.angular_velocity = randf_range(-7, 7) + + get_parent().call_deferred("add_child", tp) - # Set up the sprite's texture and region - spr2D.texture = sprite_texture - spr2D.region_enabled = true - spr2D.region_rect = regions[i] - - # Add some randomness to the velocity - var speed = randf_range(170, 200) - var dir = directions[i] + Vector2(randf_range(-0.2, 0.2), randf_range(-0.2, 0.2)) - tp.velocity = dir * speed - - # Add some rotation - tp.angular_velocity = randf_range(-7, 7) - - get_parent().call_deferred("add_child", tp) - - play_destroy_sound() + play_destroy_sound() self.set_deferred("collision_layer", 0) $Shadow.visible = false $Sprite2DAbove.visible = false @@ -292,18 +317,47 @@ func _break_into_pieces(): ItemLootHelper.spawn_item_loot(item, global_position, entities_node, game_world) print(name, " dropped item: ", item.item_name, " when broken") - if ($SfxShatter.playing): - await $SfxShatter.finished - if ($SfxBreakCrate.playing): - await $SfxBreakCrate.finished + if not silent: + if ($SfxShatter.playing): + await $SfxShatter.finished + if ($SfxBreakCrate.playing): + await $SfxBreakCrate.finished # Remove self queue_free() func can_be_grabbed() -> bool: return is_grabbable and not is_being_held +func _get_configured_object_type() -> String: + # Prefer the configured type from dungeon data if available + var idx = -1 + if name.begins_with("InteractableObject_"): + var index_str = name.substr(20) + if index_str.is_valid_int(): + idx = index_str.to_int() + elif has_meta("object_index"): + idx = get_meta("object_index") + + if idx >= 0: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_data" in game_world and game_world.dungeon_data.has("interactable_objects"): + var objects = game_world.dungeon_data.interactable_objects + if idx < objects.size(): + var obj_data = objects[idx] + if obj_data is Dictionary and obj_data.has("type"): + return obj_data.type + + return object_type + func can_be_lifted() -> bool: # Can be lifted if it's liftable (being held is OK - we're checking if it CAN be lifted) + var resolved_type = object_type + if resolved_type == "": + resolved_type = _get_configured_object_type() + if resolved_type in ["Box", "Pot", "LiftableBarrel"]: + return true + if resolved_type in ["Chest", "Pillar", "PushableBarrel", "PushableHighBox"]: + return false return is_liftable func can_be_thrown() -> bool: @@ -321,8 +375,16 @@ func on_grabbed(by_player): # Client - send request to server if by_player: var player_peer_id = by_player.get_multiplayer_authority() - print("Chest: Client sending RPC to open chest, player_peer_id: ", player_peer_id) - _request_chest_open.rpc_id(1, player_peer_id) + # Use consistent object name based on object_index to avoid NodePath issues + var chest_name = name + if has_meta("object_index"): + chest_name = "InteractableObject_%d" % get_meta("object_index") + print("Chest: Client sending RPC to open chest, player_peer_id: ", player_peer_id, " chest_name: ", chest_name) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_request_chest_open_by_name"): + game_world._request_chest_open_by_name.rpc_id(1, chest_name, player_peer_id) + else: + push_warning("Chest: GameWorld not ready, cannot send chest open request for " + chest_name) else: # Server or single player - open directly _open_chest(by_player) @@ -397,10 +459,10 @@ func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, air is_airborne = airborne @rpc("any_peer", "reliable") -func _sync_break(): +func _sync_break(silent: bool = false): # Sync break to all clients including server (called by whoever breaks the box) - if not is_queued_for_deletion(): - _break_into_pieces() + if not is_queued_for_deletion() and not is_broken: + _break_into_pieces(silent) # Object type setup functions func setup_pot(): @@ -448,7 +510,21 @@ func setup_box(): var box_frames = [7, 26] if sprite: - sprite.frame = box_frames[randi() % box_frames.size()] + # Use deterministic randomness based on dungeon seed and position + # This ensures host and clients get the same box variant + var box_seed = 0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world: + box_seed = game_world.dungeon_seed + # Add position and object_index to seed to make each box unique but deterministic + box_seed += int(global_position.x) * 1000 + int(global_position.y) + if has_meta("object_index"): + box_seed += get_meta("object_index") * 10000 + + var rng = RandomNumberGenerator.new() + rng.seed = box_seed + var index = rng.randi() % box_frames.size() + sprite.frame = box_frames[index] func setup_chest(): object_type = "Chest" @@ -503,7 +579,19 @@ func setup_pushable_high_box(): var bottom_frames = [24, 25] var top_frames = [5, 6] - var index = randi() % bottom_frames.size() + + # Use deterministic randomness based on dungeon seed and position + # This ensures host and clients get the same chest variant + var highbox_seed = 0 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and "dungeon_seed" in game_world: + highbox_seed = game_world.dungeon_seed + # Add position to seed to make each chest unique but deterministic + highbox_seed += int(global_position.x) * 1000 + int(global_position.y) + + var rng = RandomNumberGenerator.new() + rng.seed = highbox_seed + var index = rng.randi() % bottom_frames.size() if sprite: sprite.frame = bottom_frames[index] @@ -519,6 +607,14 @@ func _open_chest(by_player: Node = null): return $SfxOpenChest.play() is_chest_opened = true + + # Track opened chest for syncing to new clients + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and has_meta("object_index"): + var obj_index = get_meta("object_index") + game_world.opened_chests[obj_index] = true + LogManager.log("Chest: Tracked opened chest with index " + str(obj_index), LogManager.CATEGORY_NETWORK) if sprite and chest_opened_frame >= 0: sprite.frame = chest_opened_frame @@ -579,7 +675,12 @@ func _open_chest(by_player: Node = null): # Sync chest opening visual to all clients (item already given on server) if multiplayer.has_multiplayer_peer(): var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 - _sync_chest_open.rpc(selected_loot.type if by_player else "coin", player_peer_id) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_rpc_to_ready_peers"): + var chest_name = name + if has_meta("object_index"): + chest_name = "InteractableObject_%d" % get_meta("object_index") + game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, selected_loot.type if by_player else "coin", player_peer_id]) else: push_error("Chest: ERROR - No valid player to give item to!") diff --git a/src/scripts/item_loot_helper.gd b/src/scripts/item_loot_helper.gd index 96f2704..67c338a 100644 --- a/src/scripts/item_loot_helper.gd +++ b/src/scripts/item_loot_helper.gd @@ -67,7 +67,7 @@ static func spawn_item_loot(item: Item, position: Vector2, entities_node: Node, loot.set_meta("loot_id", loot_id) # Sync item data to clients - game_world._sync_item_loot_spawn.rpc(safe_spawn_pos, item.save(), initial_velocity, random_velocity_z, loot_id) + game_world._rpc_to_ready_peers("_sync_item_loot_spawn", [safe_spawn_pos, item.save(), initial_velocity, random_velocity_z, loot_id]) print("ItemLootHelper: Spawned item loot: ", item.item_name, " at ", safe_spawn_pos) return loot diff --git a/src/scripts/log_manager.gd b/src/scripts/log_manager.gd new file mode 100644 index 0000000..06b0787 --- /dev/null +++ b/src/scripts/log_manager.gd @@ -0,0 +1,80 @@ +extends Node + +# Log Manager - Centralized logging system with category-based filtering +# Categories can be enabled/disabled independently for debugging + +# Log categories +const CATEGORY_NETWORK = "NETWORK" +const CATEGORY_GAMEPLAY = "GAMEPLAY" +const CATEGORY_UI = "UI" +const CATEGORY_DUNGEON = "DUNGEON" +const CATEGORY_ENEMY = "ENEMY" +const CATEGORY_PLAYER = "PLAYER" +const CATEGORY_DOOR = "DOOR" +const CATEGORY_INVENTORY = "INVENTORY" +const CATEGORY_DEFAULT = "DEFAULT" + +# Enable/disable flags for each category +var enabled_categories: Dictionary = { + CATEGORY_NETWORK: true, # Enable network logs for debugging + CATEGORY_GAMEPLAY: false, + CATEGORY_UI: false, + CATEGORY_DUNGEON: false, + CATEGORY_ENEMY: false, + CATEGORY_PLAYER: false, + CATEGORY_DOOR: false, + CATEGORY_INVENTORY: false, + CATEGORY_DEFAULT: false, # Disable default/unclassified logs +} + +# Logging helper to determine host/joiner prefix +func _log_prefix() -> String: + var network_manager = get_node_or_null("/root/NetworkManager") + if network_manager and network_manager.is_hosting: + return "[H] " + else: + return "[J] " + +func log(message: String, category: String = CATEGORY_DEFAULT): + """Log a message if the category is enabled""" + if enabled_categories.get(category, false): + print(_log_prefix() + "[" + category + "] " + message) + +func log_error(message: String, category: String = CATEGORY_DEFAULT): + """Log an error message if the category is enabled""" + if enabled_categories.get(category, false): + push_error(_log_prefix() + "[" + category + "] " + message) + +func set_category_enabled(category: String, enabled: bool): + """Enable or disable a log category""" + enabled_categories[category] = enabled + +func is_category_enabled(category: String) -> bool: + """Check if a category is enabled""" + return enabled_categories.get(category, false) + +func enable_all_categories(): + """Enable all log categories""" + for category in enabled_categories: + enabled_categories[category] = true + +func disable_all_categories(): + """Disable all log categories""" + for category in enabled_categories: + enabled_categories[category] = false + +func enable_network_only(): + """Enable only network-related logs (useful for debugging multiplayer)""" + disable_all_categories() + enabled_categories[CATEGORY_NETWORK] = true + +# Example usage: +# LogManager.log("This is a network message", LogManager.CATEGORY_NETWORK) +# LogManager.log("This is a gameplay message", LogManager.CATEGORY_GAMEPLAY) +# LogManager.set_category_enabled(LogManager.CATEGORY_GAMEPLAY, true) # Enable gameplay logs +# LogManager.enable_network_only() # Disable all except network +# +# NOTE: Many scripts still use print() directly. To suppress those logs, +# you can temporarily redirect print() output or update scripts to use LogManager. +# For now, network-related scripts (NetworkManager, MatchboxClient, RoomRegistry) +# use LogManager, while gameplay scripts still use print() directly. diff --git a/src/scripts/log_manager.gd.uid b/src/scripts/log_manager.gd.uid new file mode 100644 index 0000000..94c4e7b --- /dev/null +++ b/src/scripts/log_manager.gd.uid @@ -0,0 +1 @@ +uid://db2vxtkt6gnvy diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 30a14e1..9cdaa53 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -379,11 +379,10 @@ func _process_pickup_on_server(player: Node): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_remove"): print("Loot: Server syncing removal of loot id=", loot_id, " at ", global_position) - game_world._sync_loot_remove.rpc(loot_id, global_position) + game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) else: - # Fallback: try direct RPC (may fail if node path doesn't match) - print("Loot: Server syncing removal via direct RPC (fallback)") - rpc("_sync_remove") + # If GameWorld isn't ready, skip removal sync to avoid node path RPC errors + print("Loot: GameWorld not ready, skipping removal sync for loot id=", loot_id) match loot_type: LootType.COIN: @@ -395,9 +394,11 @@ func _process_pickup_on_server(player: Node): # Show floating text with item graphic and text var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") _show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, coin_texture, 6, 1, 0) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), coin_value, 0, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority()) self.visible = false @@ -416,9 +417,11 @@ func _process_pickup_on_server(player: Node): # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, int(actual_heal), (8 * 20) + 10, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 10, player.get_multiplayer_authority()) self.visible = false @@ -437,9 +440,11 @@ func _process_pickup_on_server(player: Node): # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, int(actual_heal), (8 * 20) + 11, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 11, player.get_multiplayer_authority()) self.visible = false @@ -458,9 +463,11 @@ func _process_pickup_on_server(player: Node): # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, int(actual_heal), (8 * 20) + 12, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 12, player.get_multiplayer_authority()) self.visible = false @@ -477,9 +484,11 @@ func _process_pickup_on_server(player: Node): # Show floating text with item graphic and text var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, items_texture, 20, 14, (13 * 20) + 10) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, 0, (13 * 20) + 10, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority()) self.visible = false @@ -522,9 +531,11 @@ func _process_pickup_on_server(player: Node): _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, item.spriteFrames.x, item.spriteFrames.y, item.spriteFrame) - # Sync floating text to client + # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - _sync_show_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, 0, item.spriteFrame, player.get_multiplayer_authority()) + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has_method("_sync_loot_floating_text"): + game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority()) self.visible = false diff --git a/src/scripts/matchbox_client.gd b/src/scripts/matchbox_client.gd index 00dde04..ae23b31 100644 --- a/src/scripts/matchbox_client.gd +++ b/src/scripts/matchbox_client.gd @@ -7,7 +7,7 @@ signal peer_joined(peer_id: int) signal peer_left(peer_id: int) signal peer_connected(peer_id: int) signal connection_failed() -signal connection_succeeded() +signal connection_succeeded(was_reconnecting: bool) signal webrtc_ready() # Emitted when WebRTC mesh is set up after Welcome message const MATCHBOX_SERVER = "wss://matchbox.thefirstboss.com" @@ -16,14 +16,34 @@ const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" var websocket: WebSocketPeer = null var webrtc_peer: WebRTCMultiplayerPeer = null var room_name: String = "" -var is_connected: bool = false +var is_network_connected: bool = false var is_hosting: bool = false # Set by NetworkManager to determine if we're host (ID 1) or client (ID 2+) var my_uuid: String = "" # Our UUID assigned by Matchbox -var my_peer_id: int = 0 # Our integer peer ID (1 = host, 2+ = clients) +var my_peer_id: int = 0 # Our integer peer ID (1 = host, 2+ = clients) - assigned by host for clients var peer_uuid_to_id: Dictionary = {} # UUID -> integer peer ID -var peer_id_counter: int = 1 # Counter for assigning integer peer IDs +var peer_id_counter: int = 1 # Counter for assigning integer peer IDs (host only) var peer_connections: Dictionary = {} # peer_id (int) -> WebRTCPeerConnection var pending_offers: Dictionary = {} # peer_id -> offer data +var waiting_for_peer_id: bool = false # Client flag: waiting for host to assign peer ID +var queued_signaling_messages: Array = [] # Queue for signaling messages received before peer ID assignment +var connection_failed_emitted: bool = false # Prevent multiple connection_failed emissions +var retry_count: int = 0 # Number of retry attempts +var max_retries: int = 3 # Maximum retry attempts (for initial connection) +var retry_timer: float = 0.0 # Timer for retry backoff +var retry_delay: float = 5.0 # Initial retry delay in seconds +var is_retrying: bool = false # Whether we're currently retrying +var host_reconnect_timer: float = 0.0 # Timer for host reconnection +var host_reconnect_delay: float = 60.0 # Host reconnection delay: 1 minute +var host_reconnect_count: int = 0 # Number of host reconnection attempts +var max_host_retries: int = 10 # Maximum host reconnection attempts +var is_host_reconnecting: bool = false # Whether host is in reconnection mode + +# Logging - use LogManager for categorized logging +func log_print(message: String): + LogManager.log(message, LogManager.CATEGORY_NETWORK) + +func log_error(message: String): + LogManager.log_error(message, LogManager.CATEGORY_NETWORK) func _ready(): pass @@ -34,7 +54,11 @@ func connect_to_room(room: String, hosting: bool = false) -> bool: room_name = room is_hosting = hosting - print("MatchboxClient: Connecting to room: ", room, " (hosting: ", hosting, ")") + retry_count = 0 + connection_failed_emitted = false + is_retrying = false + retry_timer = 0.0 + log_print("MatchboxClient: Connecting to room: " + room + " (hosting: " + str(hosting) + ")") # Create WebSocket connection to Matchbox websocket = WebSocketPeer.new() @@ -42,12 +66,77 @@ func connect_to_room(room: String, hosting: bool = false) -> bool: var error = websocket.connect_to_url(url) if error != OK: - push_error("MatchboxClient: Failed to connect to Matchbox: " + str(error)) + log_error("MatchboxClient: Failed to connect to Matchbox: " + str(error)) return false - print("MatchboxClient: Connecting to: ", url) + log_print("MatchboxClient: Connecting to: " + url) return true +func _check_and_emit_peer_connected(peer_id: int): + """Check if peer connection is established and emit peer_connected if so""" + var pc = peer_connections.get(peer_id) + if not pc: + return + + # Check connection state - if it's CONNECTED (2) or CONNECTING (1), emit peer_connected + var connection_state = pc.get_connection_state() + var signaling_state = pc.get_signaling_state() + + log_print("MatchboxClient: Checking connection for peer " + str(peer_id) + " (connection: " + str(connection_state) + ", signaling: " + str(signaling_state) + ")") + + # If signaling state is STABLE (0) or connection is CONNECTING/CONNECTED, emit peer_connected + # This allows the joiner to proceed even if the connection isn't fully established yet + # The connection will complete during gameplay + if signaling_state == 0 or connection_state >= 1: # STABLE or CONNECTING/CONNECTED + log_print("MatchboxClient: Connection ready for peer " + str(peer_id) + " - emitting peer_connected") + peer_connected.emit(peer_id) + + # On web, the multiplayer.peer_connected signal might not fire automatically + # So we need to manually check and wait for the peer to be available + if not is_hosting: + # For joiners, check if multiplayer system sees the peer + # If the connection is CONNECTED (2), the peer should be available soon + if connection_state == 2: # CONNECTED + log_print("MatchboxClient: Connection is CONNECTED, checking if peer is available for RPCs") + call_deferred("_check_multiplayer_peer_available", peer_id) + else: + # Connection not ready yet, try again after a short delay + log_print("MatchboxClient: Connection not ready for peer " + str(peer_id) + " - will retry") + get_tree().create_timer(0.5).timeout.connect(func(): _check_and_emit_peer_connected(peer_id)) + +func _check_multiplayer_peer_available(peer_id: int): + """Check if the multiplayer system recognizes the peer and manually trigger connection_succeeded if needed""" + if not webrtc_peer: + return + + # Wait a frame for the multiplayer system to update + await get_tree().process_frame + + # Check if the peer is in the multiplayer peer list + var peers = multiplayer.get_peers() + if peer_id in peers: + log_print("MatchboxClient: Multiplayer system recognizes peer " + str(peer_id)) + # The multiplayer.peer_connected signal should fire automatically + # But if it doesn't, we might need to manually trigger it + # For now, just log - NetworkManager will handle connection_succeeded + else: + log_print("MatchboxClient: Multiplayer system doesn't recognize peer " + str(peer_id) + " yet - will retry") + # Retry after a short delay (max 5 seconds) + var peer_check_retry_count = get_meta("peer_check_retry_count", 0) + if peer_check_retry_count < 10: # Max 10 retries (5 seconds) + set_meta("peer_check_retry_count", peer_check_retry_count + 1) + get_tree().create_timer(0.5).timeout.connect(func(): _check_multiplayer_peer_available(peer_id)) + else: + # After max retries, assume connection is ready even if multiplayer doesn't recognize it + # This can happen on web where the multiplayer system might be delayed + log_print("MatchboxClient: Max retries reached - assuming peer " + str(peer_id) + " is available") + # Manually trigger the peer_connected signal for NetworkManager + # This will cause _on_peer_connected to fire, which will emit connection_succeeded + # We'll do this by directly calling the NetworkManager's handler + var network_manager = get_node_or_null("/root/NetworkManager") + if network_manager and network_manager.has_method("_on_peer_connected"): + network_manager._on_peer_connected(peer_id) + func disconnect_from_room(): if websocket: websocket.close() @@ -60,22 +149,71 @@ func disconnect_from_room(): pc.close() peer_connections.clear() pending_offers.clear() - is_connected = false + queued_signaling_messages.clear() + is_network_connected = false room_name = "" + connection_failed_emitted = false + retry_count = 0 + is_retrying = false + retry_timer = 0.0 + is_host_reconnecting = false + host_reconnect_count = 0 + host_reconnect_timer = 0.0 + waiting_for_peer_id = false + my_peer_id = 0 + peer_uuid_to_id.clear() func _process(_delta): - if not websocket: + # Handle retry timer + if is_retrying: + retry_timer -= _delta + if retry_timer <= 0.0: + # Time to retry + is_retrying = false + _attempt_reconnect() return + if not websocket: + # Handle host reconnection even if websocket is null + if is_hosting and is_host_reconnecting: + host_reconnect_timer -= _delta + if host_reconnect_timer <= 0.0: + # Time to retry host connection + host_reconnect_timer = host_reconnect_delay + _attempt_host_reconnect() + return + + # Handle host reconnection timer (when websocket exists but is closed) + if is_hosting and is_host_reconnecting: + host_reconnect_timer -= _delta + if host_reconnect_timer <= 0.0: + # Time to retry host connection + host_reconnect_timer = host_reconnect_delay + _attempt_host_reconnect() + websocket.poll() + # Poll all peer connections to process WebRTC events + for peer_id in peer_connections: + var pc = peer_connections[peer_id] + if pc: + pc.poll() + var state = websocket.get_ready_state() if state == WebSocketPeer.STATE_OPEN: - if not is_connected: - is_connected = true - connection_succeeded.emit() - print("MatchboxClient: Connected to Matchbox server") + if not is_network_connected: + is_network_connected = true + connection_failed_emitted = false + retry_count = 0 + is_retrying = false + # Store if host was reconnecting before clearing the flag + var was_host_reconnecting = is_host_reconnecting + is_host_reconnecting = false # Clear host reconnection mode on successful connection + host_reconnect_timer = 0.0 + host_reconnect_count = 0 # Reset retry counter on successful connection + connection_succeeded.emit(was_host_reconnecting) # Pass reconnection status to signal + log_print("MatchboxClient: Connected to Matchbox server") # Process incoming messages while websocket.get_available_packet_count() > 0: @@ -83,14 +221,40 @@ func _process(_delta): _handle_message(packet.get_string_from_utf8()) elif state == WebSocketPeer.STATE_CLOSED: - if is_connected: - is_connected = false - connection_failed.emit() - print("MatchboxClient: Disconnected from Matchbox server") + if is_network_connected: + # Was connected, now disconnected + is_network_connected = false + if not connection_failed_emitted: + connection_failed.emit() + connection_failed_emitted = true + log_print("MatchboxClient: Disconnected from Matchbox server") + + # If hosting, start reconnection mode (max 10 retries) + if is_hosting: + is_host_reconnecting = true + host_reconnect_count = 0 # Reset counter when starting reconnection + host_reconnect_timer = host_reconnect_delay + log_print("MatchboxClient: Host disconnected, will retry every " + str(host_reconnect_delay) + " seconds (max " + str(max_host_retries) + " retries)") + else: + # Joiner: attempt quick reconnect with exponential backoff + _handle_connection_failure() else: # Connection failed before being established - print("MatchboxClient: Connection failed (state: CLOSED)") - connection_failed.emit() + if not connection_failed_emitted and not is_retrying and not is_host_reconnecting: + # Only emit once and start retry logic + connection_failed_emitted = true + log_print("MatchboxClient: Connection failed (state: CLOSED)") + + # For hosts, use host reconnection logic (1 minute, max 10 retries) + if is_hosting: + is_host_reconnecting = true + host_reconnect_count = 0 # Reset counter when starting reconnection + host_reconnect_timer = host_reconnect_delay + connection_failed.emit() # Emit signal so NetworkManager can show chat message + log_print("MatchboxClient: Host connection failed, will retry every " + str(host_reconnect_delay) + " seconds (max " + str(max_host_retries) + " retries)") + else: + # For joiners, use joiner retry logic + _handle_connection_failure() elif state == WebSocketPeer.STATE_CONNECTING: # Still connecting, wait for next poll @@ -98,18 +262,18 @@ func _process(_delta): elif state == WebSocketPeer.STATE_CLOSING: # Connection is closing - print("MatchboxClient: Connection closing...") + log_print("MatchboxClient: Connection closing...") func _handle_message(message: String): - print("MatchboxClient: Received message: ", message) + log_print("MatchboxClient: Received message: " + message) var json = JSON.new() var error = json.parse(message) if error != OK: - push_error("MatchboxClient: Failed to parse message: " + message) + log_error("MatchboxClient: Failed to parse message: " + message) return var data = json.data - print("MatchboxClient: Parsed data: ", data) + log_print("MatchboxClient: Parsed data: " + str(data)) # Matchbox protocol uses direct keys: IdAssigned, NewPeer, PeerLeft, Signal if data.has("IdAssigned"): @@ -121,48 +285,68 @@ func _handle_message(message: String): elif data.has("Signal"): _handle_signal_message_dict(data.get("Signal", {})) else: - print("MatchboxClient: Unknown message format: ", data) + log_print("MatchboxClient: Unknown message format: " + str(data)) func _handle_id_assigned(uuid: String): - print("MatchboxClient: IdAssigned message received: ", uuid) + log_print("MatchboxClient: IdAssigned message received: " + uuid) my_uuid = uuid # Assign peer ID based on whether we're hosting or joining - # Host gets ID 1, clients get ID 2, 3, 4, etc. + # Host gets ID 1 immediately, clients wait for host to assign their ID if is_hosting: my_peer_id = 1 peer_uuid_to_id[my_uuid] = my_peer_id peer_id_counter = 2 # Next peer will get ID 2 + + # Host can create mesh immediately + if setup_webrtc_peer(): + webrtc_ready.emit() + log_print("MatchboxClient: WebRTC mesh ready, signal emitted") else: - # Client: we'll assign ourselves a temporary ID, but this needs coordination - # Actually, we can't determine our ID from IdAssigned alone - we need to wait - # For now, assign ID 2 (assumes only one client joins at a time) - # TODO: Proper peer ID coordination needed for multiple simultaneous joins - my_peer_id = 2 # Client gets ID 2 - peer_uuid_to_id[my_uuid] = my_peer_id - peer_id_counter = 3 # Next peer will get ID 3 + # Client: wait for host to assign our peer ID via Signal message + # Don't create mesh yet - we'll create it when we receive our peer ID from the host + waiting_for_peer_id = true + log_print("MatchboxClient: Client waiting for host to assign peer ID...") - print("MatchboxClient: Assigned UUID: ", my_uuid, " -> Peer ID: ", my_peer_id, " (hosting: ", is_hosting, ")") + log_print("MatchboxClient: Assigned UUID: " + my_uuid + " -> Peer ID: " + str(my_peer_id) + " (hosting: " + str(is_hosting) + ")") - # Setup WebRTC mesh now that we know our peer ID - if setup_webrtc_peer(): - webrtc_ready.emit() - print("MatchboxClient: WebRTC mesh ready, signal emitted") + # Note: Matchbox protocol behavior: + # - When you join a room, you get IdAssigned + # - Existing peers in the room should receive NewPeer for you + # - But you don't get NewPeer for existing peers + # - We'll discover existing peers when they send Signal messages + # - The host should always create offers, so when host discovers a joiner via Signal, it creates an offer func _handle_new_peer(uuid: String): if uuid.is_empty(): return - print("MatchboxClient: NewPeer message received: ", uuid) + log_print("MatchboxClient: NewPeer message received: " + uuid) - # Assign sequential integer peer ID - var peer_id = peer_id_counter - peer_uuid_to_id[uuid] = peer_id - peer_id_counter += 1 - - print("MatchboxClient: New peer UUID: ", uuid, " -> Peer ID: ", peer_id) - peer_joined.emit(peer_id) - _create_peer_connection(peer_id) + # Only host assigns peer IDs to new peers + if is_hosting: + # Host assigns sequential integer peer ID + var peer_id = peer_id_counter + peer_uuid_to_id[uuid] = peer_id + peer_id_counter += 1 + + log_print("MatchboxClient: Host assigned peer ID " + str(peer_id) + " to UUID: " + uuid) + + # Send peer ID assignment to the client via Matchbox Signal + _send_peer_id_assignment(uuid, peer_id) + + # Broadcast the new peer's ID to all existing clients so they can discover each other + _broadcast_peer_id_to_all_clients(uuid, peer_id) + + # Also send all existing peer IDs to the new client so they can discover existing peers + _send_all_existing_peers_to_new_client(uuid) + + peer_joined.emit(peer_id) + _create_peer_connection(peer_id) + else: + # Client: we don't assign peer IDs, the host does + # Just store the UUID for now, peer ID will come via Signal message + log_print("MatchboxClient: Client received NewPeer for UUID: " + uuid + " (waiting for host to assign peer ID)") func _handle_peer_left_uuid(uuid: String): if uuid.is_empty(): @@ -172,33 +356,240 @@ func _handle_peer_left_uuid(uuid: String): if peer_id == 0: return - print("MatchboxClient: Peer left UUID: ", uuid, " -> Peer ID: ", peer_id) + log_print("MatchboxClient: Peer left UUID: " + uuid + " -> Peer ID: " + str(peer_id)) peer_uuid_to_id.erase(uuid) peer_left.emit(peer_id) _close_peer_connection(peer_id) func _handle_signal_message_dict(signal_data: Dictionary): - # Matchbox Signal format: {"From": "uuid", "signal": {...}} - # We need to convert UUID to integer peer ID + # Matchbox Signal format when receiving: The server routes messages, so we need to identify the sender + # Log all fields to see what Matchbox actually sends + log_print("MatchboxClient: Received Signal message with keys: " + str(signal_data.keys())) + + # Extract signal data first to check if it's a peer-id-assigned message + # This needs to be checked BEFORE peer discovery/connection logic + var sig = signal_data.get("data", {}) + if sig.is_empty(): + sig = signal_data.get("signal", {}) + + # Handle peer ID assignment FIRST (clients only, before mesh is created) + # This must happen before we try to discover/create peer connections + if not is_hosting and sig.has("type") and sig.get("type") == "peer-id-assigned": + # Client receiving peer ID assignment from host + # Extract host UUID from sender field and store it + var host_uuid = signal_data.get("sender", "") + if not host_uuid.is_empty(): + peer_uuid_to_id[host_uuid] = 1 # Store host UUID -> peer ID 1 mapping + log_print("MatchboxClient: Stored host UUID: " + host_uuid + " -> Peer ID 1") + # Process this message directly - don't need peer_id yet + log_print("MatchboxClient: Client received peer ID assignment from host (processing immediately)") + _handle_signal_message(1, sig) # Use peer_id = 1 (host) as the sender + return + + # Handle peer discovery broadcast from host (clients only) + if not is_hosting and sig.has("type") and sig.get("type") == "peer-discovered": + var discovered_peer_uuid = sig.get("peer_uuid", "") + var discovered_peer_id = sig.get("peer_id", 0) + if not discovered_peer_uuid.is_empty() and discovered_peer_id > 0: + log_print("MatchboxClient: Client received peer discovery broadcast: UUID " + discovered_peer_uuid + " -> Peer ID " + str(discovered_peer_id)) + # Store the peer ID mapping + if not peer_uuid_to_id.has(discovered_peer_uuid): + peer_uuid_to_id[discovered_peer_uuid] = discovered_peer_id + # Create peer connection if we have our own peer ID + if my_peer_id > 0 and not waiting_for_peer_id: + _create_peer_connection(discovered_peer_id) + return # Handled this signal, no further processing + + # Try to get sender UUID - Matchbox might include it in different fields var from_uuid = signal_data.get("From", "") if from_uuid.is_empty(): - return + from_uuid = signal_data.get("sender", "") + if from_uuid.is_empty(): + from_uuid = signal_data.get("peer", "") + + # If still empty, try to determine from context + if from_uuid.is_empty(): + # If we're a client and don't know the sender, assume it's the host (peer ID 1) + if not is_hosting: + # We're a client, so the sender must be the host + var host_uuid = "" + for uuid in peer_uuid_to_id: + if peer_uuid_to_id[uuid] == 1: + host_uuid = uuid + break + if not host_uuid.is_empty(): + from_uuid = host_uuid + log_print("MatchboxClient: Assuming Signal is from host UUID: " + host_uuid) + else: + # Host not discovered yet - this might be the first Signal from host + # We'll discover the host when we process this Signal + log_print("MatchboxClient: Received Signal but host UUID not known yet - will discover from Signal") + # We'll handle this in the discovery logic below + else: + # We're host, so sender must be a client - we'll discover it below + log_print("MatchboxClient: Received Signal as host - will discover sender from Signal") + + # If we still don't have from_uuid, we need to discover it + if from_uuid.is_empty(): + # If we're a client, the sender must be the host + if not is_hosting: + # Client receiving Signal from unknown sender - assume it's from the host + # Check if this is a peer-id-assigned message (which comes from host) + var sig_data = signal_data.get("data", {}) + if sig_data.is_empty(): + sig_data = signal_data.get("signal", {}) + + if sig_data.has("type") and sig_data.get("type") == "peer-id-assigned": + # This is a peer ID assignment from the host - process it directly + log_print("MatchboxClient: Client received peer ID assignment from host") + _handle_signal_message(1, sig_data) # Use peer_id = 1 (host) as sender + return + + # For other signals, assume from host + var client_peer_id = 1 + log_print("MatchboxClient: Client received Signal - assuming from host (peer ID 1)") + if not sig_data.is_empty(): + _handle_signal_message(client_peer_id, sig_data) + return + else: + # We're host, so sender must be a client - we'll discover it + log_print("MatchboxClient: Host received Signal - will discover sender from signal content") + # Process signal and discover sender + var host_sig = signal_data.get("data", {}) + if host_sig.is_empty(): + host_sig = signal_data.get("signal", {}) + if not host_sig.is_empty(): + # For now, assign to next available peer ID + var host_peer_id = peer_id_counter + peer_id_counter += 1 + log_print("MatchboxClient: Host assigning Signal to new peer ID: " + str(host_peer_id)) + _handle_signal_message(host_peer_id, host_sig) + return var peer_id = peer_uuid_to_id.get(from_uuid, 0) if peer_id == 0: - # Unknown peer - might be a new peer, assign ID - peer_id = peer_id_counter - peer_uuid_to_id[from_uuid] = peer_id - peer_id_counter += 1 - print("MatchboxClient: Unknown peer in Signal, assigned ID: ", peer_id) + # Unknown peer - this is an existing peer we haven't seen yet + # Matchbox doesn't send NewPeer for existing peers, so we discover them via Signal messages + # Assign ID based on whether we're host or client + if is_hosting: + # We're host, so this must be a client (peer ID 2+) + peer_id = peer_id_counter + peer_id_counter += 1 + peer_uuid_to_id[from_uuid] = peer_id + log_print("MatchboxClient: Host discovered existing peer via Signal message - UUID: " + from_uuid + " -> Peer ID: " + str(peer_id)) + else: + # We're client - check if we already know the host + # If we know the host and this isn't the host, it must be another client + # But we don't know its peer ID yet - wait for host to broadcast it + var host_uuid = "" + for uuid in peer_uuid_to_id: + if peer_uuid_to_id[uuid] == 1: + host_uuid = uuid + break + + if host_uuid == from_uuid: + # This is the host + peer_id = 1 + peer_uuid_to_id[from_uuid] = peer_id + log_print("MatchboxClient: Client discovered host via Signal message - UUID: " + from_uuid + " -> Peer ID: 1") + elif host_uuid.is_empty() and not from_uuid.is_empty(): + # We don't know the host yet, but this might be the host sending us an offer + # If this is an offer or answer, it's likely from the host (host creates offers to clients) + var sig_type = sig.get("type", "") + if sig_type == "offer" or sig_type == "answer": + # Assume this is from the host + peer_id = 1 + peer_uuid_to_id[from_uuid] = peer_id + log_print("MatchboxClient: Client assuming Signal is from host (offer/answer) - UUID: " + from_uuid + " -> Peer ID: 1") + else: + # This is another client, but we don't know its peer ID yet + # Store the UUID and wait for host to broadcast the peer ID + log_print("MatchboxClient: Client received Signal from unknown peer UUID: " + from_uuid + " (waiting for host to broadcast peer ID)") + # Don't assign a peer ID yet - wait for host broadcast + return + else: + # This is another client, but we don't know its peer ID yet + # Store the UUID and wait for host to broadcast the peer ID + log_print("MatchboxClient: Client received Signal from unknown peer UUID: " + from_uuid + " (waiting for host to broadcast peer ID)") + # Don't assign a peer ID yet - wait for host broadcast + return + + # Create peer connection for this newly discovered peer + # Don't emit peer_joined signal here since this is discovery, not a new join + # But clients must have their peer ID before creating connections + if not is_hosting and (my_peer_id == 0 or waiting_for_peer_id): + log_print("MatchboxClient: Client discovered peer but waiting for peer ID - will create connection after peer ID assignment") + return + _create_peer_connection(peer_id) + + # If we're the host and discovered a client via Signal (not NewPeer), + # we should create an offer (the host always initiates) + # _create_peer_connection already handles this if my_peer_id == 1, + # but let's make sure we handle it here too if needed + if is_hosting and my_peer_id == 1 and peer_id != 1: + # This is discovery of an existing client - ensure we create an offer + # But only if we haven't already created one (check signal type first) + pass # Will be handled by _create_peer_connection or in _handle_signal_message - var sig = signal_data.get("signal", {}) + # Matchbox uses "data" field, not "signal" (already extracted above) if sig.is_empty(): + # Empty signal - this might be a discovery-only message + # If we're host and just discovered a client, create an offer + if is_hosting and my_peer_id == 1 and peer_id != 1: + # We discovered a client but got an empty signal - create offer proactively + call_deferred("_create_offer_for_peer", peer_id) + return + + # If client is waiting for peer ID, queue this signal message + if not is_hosting and waiting_for_peer_id: + log_print("MatchboxClient: Queueing signaling message (waiting for peer ID)") + queued_signaling_messages.append(signal_data) # Queue the entire signal_data dict return _handle_signal_message(peer_id, sig) func _handle_signal_message(peer_id: int, signal_data: Dictionary): + var type = signal_data.get("type", "") + + # Handle peer ID assignment from host (clients only) + if type == "peer-id-assigned" and not is_hosting: + var assigned_peer_id = signal_data.get("peer_id", 0) + if assigned_peer_id > 0: + log_print("MatchboxClient: Received peer ID assignment from host: " + str(assigned_peer_id)) + my_peer_id = assigned_peer_id + peer_uuid_to_id[my_uuid] = my_peer_id + waiting_for_peer_id = false + + # Now create the WebRTC mesh with the assigned peer ID + if setup_webrtc_peer(): + webrtc_ready.emit() + log_print("MatchboxClient: WebRTC mesh ready with assigned peer ID: " + str(my_peer_id)) + + # Create peer connection for host if we discovered it earlier + # Check if we know the host's UUID but don't have a connection yet + for uuid in peer_uuid_to_id: + if peer_uuid_to_id[uuid] == 1 and not peer_connections.has(1): + log_print("MatchboxClient: Creating peer connection for host (peer ID 1) after peer ID assignment") + _create_peer_connection(1) + break + + # Process all queued signaling messages now that we have our peer ID + log_print("MatchboxClient: Processing " + str(queued_signaling_messages.size()) + " queued signaling messages") + for queued_msg in queued_signaling_messages: + _handle_signal_message_dict(queued_msg) + queued_signaling_messages.clear() + + return + + # Handle WebRTC signaling messages (offer, answer, ice-candidate) + # Clients must have their peer ID assigned before processing signaling messages + if not is_hosting and waiting_for_peer_id: + log_print("MatchboxClient: Client received signaling message but waiting for peer ID assignment - queuing for later") + # Find the original signal message dict to queue it + # We need to reconstruct it from the current context + # This is a bit of a hack - we queue at the dict level in _handle_signal_message_dict + return + var peer_conn = peer_connections.get(peer_id) if not peer_conn: # Create connection if it doesn't exist @@ -207,8 +598,6 @@ func _handle_signal_message(peer_id: int, signal_data: Dictionary): if not peer_conn: return - var type = signal_data.get("type", "") - match type: "offer": _handle_offer(peer_id, signal_data) @@ -218,10 +607,18 @@ func _handle_signal_message(peer_id: int, signal_data: Dictionary): _handle_ice_candidate(peer_id, signal_data) func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: + # Note: This function cannot be async because it's called from signal handlers + # We'll use call_deferred for async operations + + # Clients must have their peer ID assigned before creating peer connections + if not is_hosting and (my_peer_id == 0 or waiting_for_peer_id): + log_error("MatchboxClient: Cannot create peer connection - client waiting for peer ID assignment (my_peer_id=" + str(my_peer_id) + ")") + return null + if peer_connections.has(peer_id): return peer_connections[peer_id] - print("MatchboxClient: Creating peer connection for peer ", peer_id) + log_print("MatchboxClient: Creating peer connection for peer " + str(peer_id)) var pc = WebRTCPeerConnection.new() @@ -236,23 +633,75 @@ func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: var error = pc.initialize(config) if error != OK: - push_error("MatchboxClient: Failed to initialize peer connection: " + str(error)) + log_error("MatchboxClient: Failed to initialize peer connection: " + str(error)) return null # Connect signals - pc.session_description_created.connect(_on_session_description_created.bind(peer_id)) - pc.ice_candidate_created.connect(_on_ice_candidate_created.bind(peer_id)) + # Note: session_description_created signal has signature (type: String, sdp: String) + # We need to wrap it in a lambda to pass peer_id + var signal_connected = pc.session_description_created.connect(func(type: String, sdp: String): _on_session_description_created(peer_id, type, sdp)) + if signal_connected != OK: + push_error("MatchboxClient: Failed to connect session_description_created signal: " + str(signal_connected)) + # Note: ice_candidate_created signal has signature (media: String, index: int, name: String) + # We need to wrap it in a lambda to pass peer_id + var ice_connected = pc.ice_candidate_created.connect(func(media: String, index: int, candidate_name: String): _on_ice_candidate_created(peer_id, media, index, candidate_name)) + if ice_connected != OK: + log_error("MatchboxClient: Failed to connect ice_candidate_created signal: " + str(ice_connected)) + log_print("MatchboxClient: Signals connected for peer " + str(peer_id) + " (session: " + str(signal_connected) + ", ice: " + str(ice_connected) + ")") + + # Create a data channel for multiplayer communication + # This is required before creating an offer + var data_channel = pc.create_data_channel("game", { + "ordered": true + }) + if not data_channel: + log_error("MatchboxClient: Failed to create data channel for peer " + str(peer_id)) + return null + + log_print("MatchboxClient: Created data channel for peer " + str(peer_id) + " (channel: " + str(data_channel) + ")") peer_connections[peer_id] = pc - # If we're the host (peer_id 1), create offer for other peers - # Otherwise wait for offer - if my_peer_id == 1 and peer_id != 1: - # We're the host, create offer - pc.create_offer() + # IMPORTANT: Add peer connection to WebRTC mesh BEFORE starting offer/answer exchange + # The peer connection must be in STATE_NEW when added to the mesh + add_peer_to_mesh(peer_id) + + # In a full mesh, the peer with the lower ID creates the offer to the peer with the higher ID + # This ensures we don't have both peers trying to create offers simultaneously + if my_peer_id < peer_id: + # We have a lower peer ID, so we create the offer + # Use call_deferred to wait a frame for DataChannel to be ready + call_deferred("_create_offer_for_peer", peer_id) + # Otherwise (my_peer_id > peer_id), we wait for the other peer to create the offer return pc +func _create_offer_for_peer(peer_id: int): + var pc = peer_connections.get(peer_id) + if not pc: + log_error("MatchboxClient: No peer connection found for peer " + str(peer_id)) + return + + # Ensure we poll the connection to process any pending operations + pc.poll() + + log_print("MatchboxClient: Creating offer for peer " + str(peer_id) + " (my_peer_id: " + str(my_peer_id) + ")") + log_print("MatchboxClient: Peer connection state before create_offer: " + str(pc.get_connection_state())) + + var offer_error = pc.create_offer() + if offer_error != OK: + log_error("MatchboxClient: Failed to create offer: " + str(offer_error)) + else: + log_print("MatchboxClient: Offer creation initiated for peer " + str(peer_id) + " (error code: " + str(offer_error) + ")") + # Poll immediately to process the offer creation + pc.poll() + log_print("MatchboxClient: Peer connection state after create_offer: " + str(pc.get_connection_state())) + + # Signal should be connected from _create_peer_connection + log_print("MatchboxClient: Signal is connected, waiting for callback...") + # Force another poll to ensure the signal fires + call_deferred("_poll_and_check_offer", peer_id) + func _close_peer_connection(peer_id: int): var pc = peer_connections.get(peer_id) if pc: @@ -263,6 +712,7 @@ func _close_peer_connection(peer_id: int): func _handle_offer(peer_id: int, signal_data: Dictionary): var pc = peer_connections.get(peer_id) if not pc: + # This shouldn't happen - _handle_signal_message should create it pc = _create_peer_connection(peer_id) if not pc: return @@ -271,13 +721,18 @@ func _handle_offer(peer_id: int, signal_data: Dictionary): if sdp.is_empty(): return + log_print("MatchboxClient: Received offer from peer " + str(peer_id)) var set_error = pc.set_remote_description("offer", sdp) if set_error != OK: - push_error("MatchboxClient: Failed to set remote offer: " + str(set_error)) + log_error("MatchboxClient: Failed to set remote offer: " + str(set_error)) return - # Create answer - pc.create_answer() + # Don't create a new data channel - the offer already includes one from the host + # The data channel is negotiated as part of the offer/answer exchange + + # Note: In Godot, set_remote_description with type "offer" automatically creates the answer + # No need to call create_answer() - it doesn't exist in WebRTCPeerConnection + log_print("MatchboxClient: Answer will be created automatically by set_remote_description for peer " + str(peer_id)) func _handle_answer(peer_id: int, signal_data: Dictionary): var pc = peer_connections.get(peer_id) @@ -288,12 +743,44 @@ func _handle_answer(peer_id: int, signal_data: Dictionary): if sdp.is_empty(): return - var error = pc.set_remote_description("answer", sdp) - if error != OK: - push_error("MatchboxClient: Failed to set remote answer: " + str(error)) + # Check signaling state before setting remote description + # On web, if the signaling state is already "stable", setting the answer will fail + # We should only set the answer if we're in HAVE_LOCAL_OFFER state + var signaling_state = pc.get_signaling_state() + log_print("MatchboxClient: Answer received for peer " + str(peer_id) + " (signaling state: " + str(signaling_state) + ")") + + # WebRTCPeerConnection.SignalingState constants: + # 0 = STATE_STABLE (offer/answer exchange complete) + # 1 = STATE_HAVE_LOCAL_OFFER (we created offer, waiting for answer) + # 2 = STATE_HAVE_REMOTE_OFFER (we received offer, need to create answer) + # 3 = STATE_HAVE_LOCAL_PRANSWER (provisional answer) + # 4 = STATE_HAVE_REMOTE_PRANSWER (provisional answer received) + # 5 = STATE_CLOSED + + # Only set remote answer if we're in HAVE_LOCAL_OFFER state (1) + # If we're already in STABLE (0), the answer was already set or the connection is established + if signaling_state == 0: # STATE_STABLE + log_print("MatchboxClient: Signaling state is already STABLE for peer " + str(peer_id) + " - answer already set, connection established") + # Connection is already established, emit peer_connected + peer_connected.emit(peer_id) + return + elif signaling_state != 1: # Not HAVE_LOCAL_OFFER + log_print("MatchboxClient: Signaling state is " + str(signaling_state) + " (not HAVE_LOCAL_OFFER) for peer " + str(peer_id) + " - skipping answer") + # Still emit peer_connected in case connection is working + peer_connected.emit(peer_id) return - print("MatchboxClient: Answer received for peer ", peer_id) + # We're in HAVE_LOCAL_OFFER state, safe to set the remote answer + var error = pc.set_remote_description("answer", sdp) + if error != OK: + log_error("MatchboxClient: Failed to set remote answer: " + str(error) + " (signaling state: " + str(signaling_state) + ")") + # Still emit peer_connected - connection might still work + peer_connected.emit(peer_id) + return + + log_print("MatchboxClient: Answer set successfully for peer " + str(peer_id)) + # Note: Peer connection should already be added to mesh in _create_peer_connection() + # Just emit the connected signal for NetworkManager to handle player spawning peer_connected.emit(peer_id) func _handle_ice_candidate(peer_id: int, signal_data: Dictionary): @@ -310,33 +797,154 @@ func _handle_ice_candidate(peer_id: int, signal_data: Dictionary): var error = pc.add_ice_candidate(sdp_mid, sdp_mline_index, candidate) if error != OK: - push_error("MatchboxClient: Failed to add ICE candidate: " + str(error)) + log_error("MatchboxClient: Failed to add ICE candidate: " + str(error)) return func _on_session_description_created(peer_id: int, type: String, sdp: String): + # Matchbox protocol uses "From" with UUID, not "id" with peer ID + # Find the UUID for this peer_id + var target_uuid = "" + for uuid in peer_uuid_to_id: + if peer_uuid_to_id[uuid] == peer_id: + target_uuid = uuid + break + + if target_uuid.is_empty(): + log_error("MatchboxClient: No UUID found for peer ID " + str(peer_id)) + return + var message = { - "type": "Signal", - "id": peer_id, - "signal": { - "type": type, - "sdp": sdp + "Signal": { + "receiver": target_uuid, + "data": { + "type": type, + "sdp": sdp + } + } + } + + # If we're a joiner (not hosting) and we just created an answer, + # emit peer_connected after sending it (deferred to ensure answer is sent first) + if not is_hosting and type == "answer": + log_print("MatchboxClient: Joiner created answer for peer " + str(peer_id) + " - will emit peer_connected after sending") + # Use call_deferred to ensure the answer is sent first, then check connection state + call_deferred("_check_and_emit_peer_connected", peer_id) + log_print("MatchboxClient: Sending Signal message: type=" + type + " from=" + my_uuid + " to=" + target_uuid + " sdp length=" + str(sdp.length())) + _send_message(message) + + # If we're a joiner and just sent an answer, check connection and emit peer_connected + # (This is in addition to the deferred call above, as a backup) + if not is_hosting and type == "answer": + # Also check immediately after sending - the connection might be ready + call_deferred("_check_and_emit_peer_connected", peer_id) + + # If we're a joiner and just sent an answer, check connection and emit peer_connected + # (This is in addition to the deferred call above, as a backup) + if not is_hosting and type == "answer": + # Also check immediately - the connection might be ready + call_deferred("_check_and_emit_peer_connected", peer_id) + +func _on_ice_candidate_created(peer_id: int, media: String, index: int, candidate_name: String): + # Matchbox protocol uses "From" with UUID, not "id" with peer ID + # Find the UUID for this peer_id + var target_uuid = "" + for uuid in peer_uuid_to_id: + if peer_uuid_to_id[uuid] == peer_id: + target_uuid = uuid + break + + if target_uuid.is_empty(): + log_error("MatchboxClient: No UUID found for peer ID " + str(peer_id)) + return + + if my_uuid.is_empty(): + log_error("MatchboxClient: my_uuid is empty, cannot send ICE candidate") + return + + var message = { + "Signal": { + "receiver": target_uuid, + "data": { + "type": "ice-candidate", + "candidate": candidate_name, + "sdpMid": media, + "sdpMLineIndex": index + } } } _send_message(message) -func _on_ice_candidate_created(peer_id: int, media: String, index: int, name: String): +func _send_peer_id_assignment(target_uuid: String, assigned_peer_id: int): + # Host sends peer ID assignment to a client via Matchbox Signal + if not is_hosting: + log_error("MatchboxClient: Only host can send peer ID assignments") + return + + if my_uuid.is_empty(): + log_error("MatchboxClient: my_uuid is empty, cannot send peer ID assignment") + return + var message = { - "type": "Signal", - "id": peer_id, - "signal": { - "type": "ice-candidate", - "candidate": name, - "sdpMid": media, - "sdpMLineIndex": index + "Signal": { + "receiver": target_uuid, + "data": { + "type": "peer-id-assigned", + "peer_id": assigned_peer_id + } } } + log_print("MatchboxClient: Sending peer ID assignment: UUID " + target_uuid + " -> Peer ID " + str(assigned_peer_id)) _send_message(message) +func _broadcast_peer_id_to_all_clients(new_peer_uuid: String, new_peer_id: int): + # Host broadcasts a new peer's ID to all existing clients so they can discover each other + if not is_hosting: + return + + if my_uuid.is_empty(): + return + + # Send to all known clients (all UUIDs except the new peer and ourselves) + for uuid in peer_uuid_to_id: + if uuid != new_peer_uuid and uuid != my_uuid: + var message = { + "Signal": { + "receiver": uuid, + "data": { + "type": "peer-discovered", + "peer_uuid": new_peer_uuid, + "peer_id": new_peer_id + } + } + } + log_print("MatchboxClient: Broadcasting peer discovery: UUID " + new_peer_uuid + " -> Peer ID " + str(new_peer_id) + " to " + uuid) + _send_message(message) + +func _send_all_existing_peers_to_new_client(new_client_uuid: String): + # Host sends all existing peer IDs to a new client so they can discover existing peers + if not is_hosting: + return + + if my_uuid.is_empty(): + return + + # Send all existing peer IDs to the new client (except the new client itself and the host) + for uuid in peer_uuid_to_id: + if uuid != new_client_uuid and uuid != my_uuid: + var existing_peer_id = peer_uuid_to_id[uuid] + var message = { + "Signal": { + "receiver": new_client_uuid, + "data": { + "type": "peer-discovered", + "peer_uuid": uuid, + "peer_id": existing_peer_id + } + } + } + log_print("MatchboxClient: Sending existing peer to new client: UUID " + uuid + " -> Peer ID " + str(existing_peer_id) + " to " + new_client_uuid) + _send_message(message) + func _send_message(message: Dictionary): if not websocket or websocket.get_ready_state() != WebSocketPeer.STATE_OPEN: return @@ -344,11 +952,86 @@ func _send_message(message: Dictionary): var json = JSON.stringify(message) var error = websocket.send_text(json) if error != OK: - push_error("MatchboxClient: Failed to send message: " + str(error)) + log_error("MatchboxClient: Failed to send message: " + str(error)) func get_my_peer_id() -> int: return my_peer_id +func _poll_and_check_offer(peer_id: int): + """Helper function to poll peer connection and check if offer was created""" + var pc = peer_connections.get(peer_id) + if not pc: + return + + # Poll multiple times to ensure async operations complete + log_print("MatchboxClient: Performing additional polls for peer " + str(peer_id) + " offer creation") + for i in range(10): + pc.poll() + # Small delay between polls + await get_tree().create_timer(0.01).timeout + + log_print("MatchboxClient: Finished additional polling for peer " + str(peer_id)) + +func _handle_connection_failure(): + """Handle connection failure and decide whether to retry""" + if retry_count >= max_retries: + # Max retries reached, give up + log_print("MatchboxClient: Max retries (" + str(max_retries) + ") reached, giving up") + connection_failed.emit() + return + + # Calculate exponential backoff delay + var delay = retry_delay * pow(2, retry_count) # 5s, 10s, 20s + retry_count += 1 + retry_timer = delay + is_retrying = true + log_print("MatchboxClient: Will retry connection in " + str(delay) + " seconds (attempt " + str(retry_count) + "/" + str(max_retries) + ")") + +func _attempt_reconnect(): + """Attempt to reconnect to the Matchbox server (for initial connection failures)""" + if room_name.is_empty(): + return + + log_print("MatchboxClient: Retrying connection to room: " + room_name) + connection_failed_emitted = false + + # Create new WebSocket connection + websocket = WebSocketPeer.new() + var url = MATCHBOX_SERVER + "/" + room_name + var error = websocket.connect_to_url(url) + + if error != OK: + log_error("MatchboxClient: Failed to reconnect to Matchbox: " + str(error)) + _handle_connection_failure() + return + +func _attempt_host_reconnect(): + """Attempt to reconnect to the Matchbox server (for host reconnection, max 10 retries)""" + if room_name.is_empty(): + return + + # Check if we've exceeded max retries + if host_reconnect_count >= max_host_retries: + log_print("MatchboxClient: Host max retries (" + str(max_host_retries) + ") reached, giving up") + is_host_reconnecting = false + return + + host_reconnect_count += 1 + log_print("MatchboxClient: Host retrying connection to room: " + room_name + " (attempt " + str(host_reconnect_count) + "/" + str(max_host_retries) + ")") + connection_failed_emitted = false + + # Create new WebSocket connection + websocket = WebSocketPeer.new() + var url = MATCHBOX_SERVER + "/" + room_name + var error = websocket.connect_to_url(url) + + if error != OK: + log_error("MatchboxClient: Host failed to reconnect: " + str(error)) + # Will retry again in 1 minute (if under max retries) + if host_reconnect_count >= max_host_retries: + is_host_reconnecting = false + return + func setup_webrtc_peer() -> bool: if webrtc_peer: # Already set up @@ -361,27 +1044,32 @@ func setup_webrtc_peer() -> bool: var error = webrtc_peer.create_mesh(my_peer_id) if error != OK: - push_error("MatchboxClient: Failed to create WebRTC mesh: " + str(error)) + log_error("MatchboxClient: Failed to create WebRTC mesh: " + str(error)) return false multiplayer.multiplayer_peer = webrtc_peer - print("MatchboxClient: WebRTC mesh created with peer ID: ", my_peer_id) + log_print("MatchboxClient: WebRTC mesh created with peer ID: " + str(my_peer_id)) return true func add_peer_to_mesh(peer_id: int): + # Ensure we have a valid peer ID before creating mesh + if my_peer_id == 0: + log_error("MatchboxClient: Cannot add peer to mesh - my_peer_id is 0") + return + if not webrtc_peer: if not setup_webrtc_peer(): return var pc = peer_connections.get(peer_id) if not pc: - push_error("MatchboxClient: No peer connection for peer ", peer_id) + log_error("MatchboxClient: No peer connection for peer " + str(peer_id)) return var error = webrtc_peer.add_peer(pc, peer_id) if error != OK: - push_error("MatchboxClient: Failed to add peer to mesh: " + str(error)) + log_error("MatchboxClient: Failed to add peer to mesh: " + str(error)) return - print("MatchboxClient: Added peer ", peer_id, " to WebRTC mesh") + log_print("MatchboxClient: Added peer " + str(peer_id) + " to WebRTC mesh") diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index d6d9e07..439eb12 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -10,6 +10,7 @@ signal player_connected(peer_id, player_info) signal player_disconnected(peer_id, player_info) signal connection_failed() signal connection_succeeded() +signal rooms_fetched(rooms: Array) # Forwarded from room_registry const DEFAULT_PORT = 21212 const MAX_PLAYERS = 8 @@ -24,17 +25,32 @@ var network_mode: int = 0 # 0 = ENet, 1 = WebRTC, 2 = WebSocket var room_id = "" # Room ID for Matchbox (WebRTC) or WebSocket server URL var matchbox_client: Node = null # Matchbox client instance const WEBSOCKET_SERVER_URL = "ws://ruinborn.thefirstboss.com:21212" # WebSocket server URL +var room_registry: Node = null # Room registry client for room discovery + +# Reconnection state (for joiners only) +var reconnection_room_id: String = "" # Store room_id for reconnection +var reconnection_level: int = 0 # Store level for reconnection +var reconnection_attempting: bool = false # Track if we're attempting to reconnect +var reconnection_timer: float = 0.0 # Timer for reconnection delay +const RECONNECTION_DELAY: float = 2.0 # Delay before attempting reconnection + +# Logging - use LogManager for categorized logging +func log_print(message: String): + LogManager.log(message, LogManager.CATEGORY_NETWORK) + +func log_error(message: String): + LogManager.log_error(message, LogManager.CATEGORY_NETWORK) func _ready(): # Detect if running in browser - default to WebRTC on web, ENet on native if OS.get_name() == "Web": network_mode = 1 # WebRTC default for web - print("NetworkManager: Detected Web platform, defaulting to WebRTC") - print("NetworkManager: Matchbox server: ", MATCHBOX_SERVER) + log_print("NetworkManager: Detected Web platform, defaulting to WebRTC") + log_print("NetworkManager: Matchbox server: " + MATCHBOX_SERVER) else: network_mode = 0 # ENet default for native - print("NetworkManager: Using ENet by default for native platform") - print("NetworkManager: You can switch network modes in the menu") + log_print("NetworkManager: Using ENet by default for native platform") + log_print("NetworkManager: You can switch network modes in the menu") # Connect multiplayer signals multiplayer.peer_connected.connect(_on_peer_connected) @@ -42,26 +58,29 @@ func _ready(): multiplayer.connected_to_server.connect(_on_connected_to_server) multiplayer.connection_failed.connect(_on_connection_failed) multiplayer.server_disconnected.connect(_on_server_disconnected) + + # Create room registry client + var registry_script = load("res://scripts/room_registry_client.gd") + room_registry = Node.new() + room_registry.set_script(registry_script) + add_child(room_registry) + room_registry.rooms_fetched.connect(_on_rooms_fetched) func set_network_mode(mode: int): # 0 = ENet, 1 = WebRTC, 2 = WebSocket - # WebRTC is only available on web builds - if mode == 1 and OS.get_name() != "Web": - push_error("NetworkManager: WebRTC is not available on native platforms. WebRTC only works in web builds. Falling back to ENet.") - network_mode = 0 - print("NetworkManager: ENet mode enabled (WebRTC not available on native platforms)") - return - + # WebRTC is now available on native platforms with webrtc-native extension network_mode = mode match mode: 0: - print("NetworkManager: ENet mode enabled") + log_print("NetworkManager: ENet mode enabled") 1: - print("NetworkManager: WebRTC mode enabled") - print("NetworkManager: Matchbox server: ", MATCHBOX_SERVER) + log_print("NetworkManager: WebRTC mode enabled") + log_print("NetworkManager: Matchbox server: " + MATCHBOX_SERVER) + if OS.get_name() != "Web": + log_print("NetworkManager: Using webrtc-native extension for native platform") 2: - print("NetworkManager: WebSocket mode enabled") - print("NetworkManager: WebSocket server: ", WEBSOCKET_SERVER_URL) + log_print("NetworkManager: WebSocket mode enabled") + log_print("NetworkManager: WebSocket server: " + WEBSOCKET_SERVER_URL) func force_webrtc_mode(enable: bool): # Legacy function for backwards compatibility @@ -82,9 +101,9 @@ func host_game(port: int = DEFAULT_PORT, matchbox_room: String = "") -> bool: else: room_id = matchbox_room - print("NetworkManager: Creating WebRTC host with room ID: ", room_id) - print("NetworkManager: Share this room code with players!") - print("NetworkManager: Matchbox URL: ", MATCHBOX_SERVER, "/", room_id) + log_print("NetworkManager: Creating WebRTC host with room ID: " + room_id) + log_print("NetworkManager: Share this room code with players!") + log_print("NetworkManager: Matchbox URL: " + MATCHBOX_SERVER + "/" + room_id) # Create Matchbox client var matchbox_script = load("res://scripts/matchbox_client.gd") @@ -106,7 +125,7 @@ func host_game(port: int = DEFAULT_PORT, matchbox_room: String = "") -> bool: # Connect to Matchbox room (pass is_hosting = true) if not matchbox_client.connect_to_room(room_id, true): - push_error("Failed to connect to Matchbox room") + log_error("Failed to connect to Matchbox room") return false # Register the host as a player (peer_id 1) @@ -124,16 +143,16 @@ func host_game(port: int = DEFAULT_PORT, matchbox_room: String = "") -> bool: else: room_id = matchbox_room - print("NetworkManager: Creating WebSocket host with room ID: ", room_id) - print("NetworkManager: Share this room code with players!") - print("NetworkManager: WebSocket URL: ", WEBSOCKET_SERVER_URL, "/", room_id) + log_print("NetworkManager: Creating WebSocket host with room ID: " + room_id) + log_print("NetworkManager: Share this room code with players!") + log_print("NetworkManager: WebSocket URL: " + WEBSOCKET_SERVER_URL + "/" + room_id) peer = WebSocketMultiplayerPeer.new() var url = WEBSOCKET_SERVER_URL + "/" + room_id error = peer.create_server(port) if error != OK: - push_error("Failed to create WebSocket server: " + str(error)) + log_error("Failed to create WebSocket server: " + str(error)) return false multiplayer.multiplayer_peer = peer @@ -183,8 +202,8 @@ func join_game(address: String, port: int = DEFAULT_PORT) -> bool: # 'address' is the room ID for WebRTC room_id = address - print("NetworkManager: Joining WebRTC game with room ID: ", room_id) - print("NetworkManager: Matchbox URL: ", MATCHBOX_SERVER, "/", room_id) + log_print("NetworkManager: Joining WebRTC game with room ID: " + room_id) + log_print("NetworkManager: Matchbox URL: " + MATCHBOX_SERVER + "/" + room_id) # Create Matchbox client var matchbox_script = load("res://scripts/matchbox_client.gd") @@ -204,7 +223,7 @@ func join_game(address: String, port: int = DEFAULT_PORT) -> bool: # Connect to Matchbox room (pass is_hosting = false) if not matchbox_client.connect_to_room(room_id, false): - push_error("Failed to connect to Matchbox room") + log_error("Failed to connect to Matchbox room") return false return true @@ -213,21 +232,21 @@ func join_game(address: String, port: int = DEFAULT_PORT) -> bool: # 'address' is the room ID for WebSocket room_id = address - print("NetworkManager: Joining WebSocket game with room ID: ", room_id) - print("NetworkManager: WebSocket URL: ", WEBSOCKET_SERVER_URL, "/", room_id) + log_print("NetworkManager: Joining WebSocket game with room ID: " + room_id) + log_print("NetworkManager: WebSocket URL: " + WEBSOCKET_SERVER_URL + "/" + room_id) peer = WebSocketMultiplayerPeer.new() var url = WEBSOCKET_SERVER_URL + "/" + room_id error = peer.create_client(url) if error != OK: - push_error("Failed to create WebSocket client: " + str(error)) + log_error("Failed to create WebSocket client: " + str(error)) return false multiplayer.multiplayer_peer = peer is_hosting = false - print("Attempting to connect to WebSocket server: ", url) + log_print("Attempting to connect to WebSocket server: " + url) return true else: # ENet (mode 0) @@ -236,17 +255,60 @@ func join_game(address: String, port: int = DEFAULT_PORT) -> bool: error = peer.create_client(address, port) if error != OK: - push_error("Failed to create ENet client: " + str(error)) + log_error("Failed to create ENet client: " + str(error)) return false multiplayer.multiplayer_peer = peer is_hosting = false - print("Attempting to connect to ", address, ":", port) + log_print("Attempting to connect to " + address + ":" + str(port)) return true +func refresh_webrtc_servers(room_override: String = "") -> bool: + # Recreate Matchbox client to refresh WebRTC signaling connection + if network_mode != 1: + return false + + var target_room = room_override if not room_override.is_empty() else room_id + if target_room.is_empty(): + log_error("NetworkManager: Cannot refresh WebRTC servers - no room ID") + return false + + # Tear down existing client + if matchbox_client: + matchbox_client.disconnect_from_room() + matchbox_client.queue_free() + matchbox_client = null + + # Recreate Matchbox client + var matchbox_script = load("res://scripts/matchbox_client.gd") + matchbox_client = Node.new() + matchbox_client.set_script(matchbox_script) + add_child(matchbox_client) + + matchbox_client.peer_joined.connect(_on_matchbox_peer_joined) + matchbox_client.peer_left.connect(_on_matchbox_peer_left) + matchbox_client.peer_connected.connect(_on_matchbox_peer_connected) + matchbox_client.connection_succeeded.connect(_on_matchbox_connected) + matchbox_client.connection_failed.connect(_on_matchbox_connection_failed) + matchbox_client.webrtc_ready.connect(_on_matchbox_webrtc_ready) + + is_hosting = false + room_id = target_room + log_print("NetworkManager: Refreshing WebRTC servers for room ID: " + room_id) + + if not matchbox_client.connect_to_room(room_id, false): + log_error("NetworkManager: Failed to reconnect to Matchbox room during refresh") + return false + + return true + func disconnect_from_game(): + # Unregister room from registry + if room_registry: + room_registry.unregister_room() + if matchbox_client: matchbox_client.disconnect_from_room() matchbox_client.queue_free() @@ -265,41 +327,118 @@ func set_local_player_count(count: int): func _generate_player_names(count: int, peer_id: int) -> Array: var names = [] for i in range(count): - names.append("Player%d_%d" % [peer_id, i + 1]) + if count == 1: + # Only one player - don't add index suffix + names.append("Player%d" % peer_id) + else: + # Multiple players - add index + names.append("Player%d_%d" % [peer_id, i + 1]) return names # Called when a peer connects to the server func _on_peer_connected(id: int): - print("Peer connected: ", id) + log_print("Peer connected: " + str(id)) + # Peer is now actually available for RPCs - emit player_connected signal + # Make sure player info is registered (should have been done in _on_matchbox_peer_connected) + if not players_info.has(id): + # Fallback: register default player info if not already registered + players_info[id] = { + "local_player_count": 1, # Default, will be updated via RPC + "player_names": _generate_player_names(1, id) + } + + # For joiners, emit connection_succeeded when the first peer (host) connects + # This ensures the connection is actually established and RPCs can be received + # Note: On web, multiplayer.peer_connected might not fire automatically, + # so we also check in _on_matchbox_peer_connected as a fallback + if not is_hosting and id == 1: # Host connected to joiner + log_print("NetworkManager: Joiner - host connected via multiplayer.peer_connected, emitting connection_succeeded") + connection_succeeded.emit() + + # Emit player connected signal (peer is now available for RPCs) + player_connected.emit(id, players_info[id]) + + # Update room registry if hosting (player count changed) + if is_hosting and room_registry and not room_id.is_empty(): + var player_count = get_all_player_ids().size() + var _level = 1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + _level = game_world.current_level + room_registry.send_room_update(player_count) # Called when a peer disconnects func _on_peer_disconnected(id: int): - print("Peer disconnected: ", id) + log_print("Peer disconnected: " + str(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_info) + + # Update room registry if hosting (player count changed) + if is_hosting and room_registry and not room_id.is_empty(): + var player_count = get_all_player_ids().size() + var _level = 1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + _level = game_world.current_level + room_registry.send_room_update(player_count) # Called on client when successfully connected to server func _on_connected_to_server(): - print("Successfully connected to server") + log_print("Successfully connected to server") connection_succeeded.emit() # Send our player info to the server var my_id = multiplayer.get_unique_id() _register_player.rpc_id(1, my_id, local_player_count) + + # Update room registry if hosting (player count changed) + if is_hosting and room_registry and not room_id.is_empty(): + var player_count = get_all_player_ids().size() + var _level = 1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + _level = game_world.current_level + room_registry.send_room_update(player_count) # Called on client when connection fails func _on_connection_failed(): - print("Connection failed") + log_print("Connection failed") multiplayer.multiplayer_peer = null connection_failed.emit() # Called on client when disconnected from server func _on_server_disconnected(): - print("Server disconnected") + log_print("Server disconnected") + + # Store reconnection info if we're a joiner (not host) + if not is_hosting and not room_id.is_empty(): + reconnection_room_id = room_id + # Get current level from game_world + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world and game_world.has("current_level"): + reconnection_level = game_world.current_level + log_print("NetworkManager: Stored reconnection info - room_id: " + reconnection_room_id + ", level: " + str(reconnection_level)) + else: + reconnection_level = 0 # Unknown level + + # Add local-only chat message "Connection lost..." + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", "Connection lost...") + + # Start reconnection attempt + reconnection_attempting = true + reconnection_timer = RECONNECTION_DELAY + log_print("NetworkManager: Will attempt to reconnect in " + str(RECONNECTION_DELAY) + " seconds") + + # Emit connection_failed signal to show UI message + connection_failed.emit() + multiplayer.multiplayer_peer = null players_info.clear() @@ -318,7 +457,8 @@ func _register_player(peer_id: int, local_count: int): print("NetworkManager: Total players_info: ", players_info) # Sync all player info to the new client (so they know about everyone) - _sync_players.rpc_id(peer_id, players_info) + # Defer RPC to ensure the peer is fully registered in the multiplayer system + call_deferred("_sync_players_to_peer", peer_id, players_info) # Notify all clients (including the new one) about the new player _notify_player_joined.rpc(peer_id, players_info[peer_id]) @@ -326,6 +466,11 @@ func _register_player(peer_id: int, local_count: int): # Emit signal on server player_connected.emit(peer_id, players_info[peer_id]) +func _sync_players_to_peer(peer_id: int, all_players_info: Dictionary): + # Helper function to send _sync_players RPC (called via call_deferred) + if multiplayer.is_server(): + _sync_players.rpc_id(peer_id, all_players_info) + # RPC to sync all player info to a newly connected client @rpc("authority", "reliable") func _sync_players(all_players_info: Dictionary): @@ -353,32 +498,58 @@ func get_player_info(peer_id: int) -> Dictionary: return players_info.get(peer_id, {}) # Matchbox callback handlers -func _on_matchbox_connected(): - print("NetworkManager: Connected to Matchbox server") +func _on_matchbox_connected(was_reconnecting: bool = false): + log_print("NetworkManager: Connected to Matchbox server") # WebRTC peer will be set up when Welcome message is received with our peer ID + + # If host was reconnecting, show colorful reconnection message + if is_hosting and was_reconnecting: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_colorful_local_message"): + chat_ui.add_colorful_local_message("System", "Matchbox connection re-established!") func _on_matchbox_webrtc_ready(): - print("NetworkManager: WebRTC mesh is ready") + log_print("NetworkManager: WebRTC mesh is ready") - # Register ourselves if we're not hosting (clients need to register) - if not is_hosting and matchbox_client: - var my_peer_id = matchbox_client.get_my_peer_id() - if my_peer_id > 0 and not players_info.has(my_peer_id): - players_info[my_peer_id] = { - "local_player_count": local_player_count, - "player_names": _generate_player_names(local_player_count, my_peer_id) - } - print("NetworkManager: Registered joining player with peer ID: ", my_peer_id) - - # Emit connection_succeeded signal so the game can start - connection_succeeded.emit() + # Register room with room registry if hosting + if is_hosting and room_registry and not room_id.is_empty(): + var player_count = get_all_player_ids().size() + var level = 1 + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + level = game_world.current_level + room_registry.register_room(room_id, player_count, level) + log_print("NetworkManager: Registered room " + room_id + " with registry") + + # Host can start immediately + connection_succeeded.emit() + else: + # For joiners, wait for peer_connected to be emitted before starting + # This ensures the connection is actually established and RPCs can be received + log_print("NetworkManager: Joiner - waiting for peer connection before starting game") + # connection_succeeded will be emitted in _on_peer_connected when the first peer (host) connects func _on_matchbox_connection_failed(): - print("NetworkManager: Failed to connect to Matchbox server") - connection_failed.emit() + log_print("NetworkManager: Failed to connect to Matchbox server") + + # If hosting, add local chat message and show UI + if is_hosting: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var chat_ui = game_world.get_node_or_null("ChatUI") + if chat_ui and chat_ui.has_method("add_local_message"): + chat_ui.add_local_message("System", "Lost connection to matchbox server...") + + # Emit connection_failed to show UI message + connection_failed.emit() + else: + # For joiners, just emit the signal (they already have their own handling) + connection_failed.emit() func _on_matchbox_peer_joined(peer_id: int): - print("NetworkManager: Matchbox peer joined: ", peer_id) + log_print("NetworkManager: Matchbox peer joined: " + str(peer_id)) # Peer connection will be created by Matchbox client # Once connected, we'll add it to WebRTC mesh @@ -388,23 +559,160 @@ func _on_matchbox_peer_left(peer_id: int): func _on_matchbox_peer_connected(peer_id: int): print("NetworkManager: Matchbox peer connected: ", peer_id) - # Add peer to WebRTC mesh - if matchbox_client: - matchbox_client.add_peer_to_mesh(peer_id) + # Note: Peer connection is already added to WebRTC mesh in _create_peer_connection() + # This signal is emitted after the answer is received, but the peer might not be available for RPCs yet + # We'll emit player_connected from _on_peer_connected() when the peer is actually available - # Register player info + # Register player info (we'll emit player_connected when multiplayer.peer_connected fires) if not players_info.has(peer_id): players_info[peer_id] = { "local_player_count": 1, # Default, will be updated via RPC "player_names": _generate_player_names(1, peer_id) } - # Emit player connected signal - player_connected.emit(peer_id, players_info[peer_id]) + # For joiners, if this is the host (peer_id 1) and connection_succeeded hasn't been emitted yet, + # wait a bit and then emit it as a fallback (in case multiplayer.peer_connected doesn't fire on web) + if not is_hosting and peer_id == 1: + # Check if we've already set up a timer for this peer (prevent multiple timers) + if has_meta("connection_succeeded_timer_set"): + log_print("NetworkManager: Joiner - timer already set for peer " + str(peer_id) + ", skipping") + return + + set_meta("connection_succeeded_timer_set", true) + log_print("NetworkManager: Joiner - host connected via Matchbox, waiting for multiplayer.peer_connected...") + # Store reference to scene tree before timer + var scene_tree = get_tree() + if not scene_tree: + # Node is not in scene tree, emit immediately + log_print("NetworkManager: Joiner - node not in scene tree, emitting connection_succeeded immediately") + connection_succeeded.emit() + return + + # Wait a short time to see if multiplayer.peer_connected fires + # If it doesn't fire within 2 seconds, emit connection_succeeded anyway + var timer = scene_tree.create_timer(2.0) + timer.timeout.connect(func(): + # Check if connection_succeeded was already emitted (by checking if we're in game scene) + if not is_inside_tree(): + log_print("NetworkManager: Joiner - node no longer in tree, skipping fallback emit") + return + + var current_tree = get_tree() + if not current_tree: + log_print("NetworkManager: Joiner - no scene tree, skipping fallback emit") + return + + var game_world = current_tree.get_first_node_in_group("game_world") + if not game_world: + # Still in main menu, so connection_succeeded wasn't emitted + log_print("NetworkManager: Joiner - multiplayer.peer_connected didn't fire, emitting connection_succeeded as fallback") + # Emit signal immediately - NetworkManager is an autoload so it should always be in tree + log_print("NetworkManager: is_inside_tree() = " + str(is_inside_tree()) + ", emitting connection_succeeded signal (fallback)") + connection_succeeded.emit() + log_print("NetworkManager: connection_succeeded signal emitted") + + # Also try to get GameUI and call _start_game directly as a fallback + # Search for GameUI by script (it has game_ui.gd script) + var game_ui = null + var root = current_tree.get_root() + + # Try different possible paths (GameUI is at /root/GameUI) + var possible_paths = [ + "/root/GameUI", + "GameUI", + "Main/GameUI", + "/root/Main/GameUI" + ] + + for path in possible_paths: + var node = root.get_node_or_null(path) + if node and node.get_script() and node.get_script().resource_path.ends_with("game_ui.gd"): + game_ui = node + log_print("NetworkManager: Found GameUI at path: " + path) + break + + # If not found by path, search all nodes recursively + if not game_ui: + game_ui = _find_node_by_script(root, "game_ui.gd") + if game_ui: + log_print("NetworkManager: Found GameUI by recursive script search: " + str(game_ui.get_path())) + + if game_ui and game_ui.has_method("_start_game"): + log_print("NetworkManager: Found GameUI node, calling _start_game directly as fallback") + game_ui.call_deferred("_start_game") + else: + log_print("NetworkManager: GameUI node not found or doesn't have _start_game method") + # Try to change scene directly as last resort + var game_world_scene = load("res://scenes/game_world.tscn") + if game_world_scene: + log_print("NetworkManager: Attempting to change scene directly to game_world.tscn") + current_tree.change_scene_to_packed(game_world_scene) + else: + log_print("NetworkManager: Joiner - already in game scene, connection_succeeded was already emitted") + ) + + # Don't emit player_connected here - wait for multiplayer.peer_connected signal + # which fires when the peer is actually available for RPCs + +func _emit_connection_succeeded_safe(): + """Safely emit connection_succeeded signal - checks if node is still valid""" + if is_inside_tree(): + log_print("NetworkManager: Emitting connection_succeeded signal (fallback)") + connection_succeeded.emit() + else: + log_print("NetworkManager: Cannot emit connection_succeeded - node not in tree") + +func _find_node_by_script(node: Node, script_name: String) -> Node: + """Recursively search for a node with a specific script""" + if node.get_script() and node.get_script().resource_path.ends_with(script_name): + return node + + for child in node.get_children(): + var result = _find_node_by_script(child, script_name) + if result: + return result + + return null func get_room_id() -> String: return room_id +func _process(delta: float): + # Handle reconnection timer + if reconnection_attempting: + reconnection_timer -= delta + if reconnection_timer <= 0.0: + reconnection_attempting = false + _attempt_reconnect() + +func _attempt_reconnect(): + # Only reconnect if we're a joiner and have reconnection info + if is_hosting or reconnection_room_id.is_empty(): + log_print("NetworkManager: Cannot reconnect - is_hosting: " + str(is_hosting) + ", reconnection_room_id: " + reconnection_room_id) + return + + log_print("NetworkManager: Attempting to reconnect to room: " + reconnection_room_id) + + # Attempt to reconnect using the stored room_id + var success = join_game(reconnection_room_id) + if not success: + log_error("NetworkManager: Reconnection attempt failed, will retry") + # Retry after delay + reconnection_attempting = true + reconnection_timer = RECONNECTION_DELAY + +func fetch_available_rooms() -> bool: + """Fetch available rooms from the registry""" + if room_registry: + return room_registry.fetch_available_rooms() + return false + +func _on_rooms_fetched(rooms: Array): + """Callback when rooms are fetched from registry""" + log_print("NetworkManager: Received " + str(rooms.size()) + " available rooms") + # Forward the signal to listeners + rooms_fetched.emit(rooms) + func get_local_ip() -> String: var addresses = IP.get_local_addresses() for addr in addresses: diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 2f457ca..8eac5e6 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -263,8 +263,6 @@ func _ready(): _setup_player_appearance() # Authority is set by player_manager after adding to scene - # Just log it here - print("Player ", name, " ready. Authority: ", get_multiplayer_authority(), " Is local: ", is_local_player) # Hide interaction indicator by default if interaction_indicator: @@ -385,7 +383,6 @@ func _initialize_character_stats(): appearance_rng = RandomNumberGenerator.new() var seed_value = hash(str(peer_id) + "_" + str(local_player_index)) appearance_rng.seed = seed_value - print(name, " appearance/stats seed: ", seed_value, " (peer_id: ", peer_id, ", local_index: ", local_player_index, ")") # Create character stats character_stats = CharacterStats.new() @@ -415,13 +412,7 @@ func _randomize_stats(): character_stats.baseStats.cha = appearance_rng.randi_range(8, 12) character_stats.baseStats.lck = appearance_rng.randi_range(8, 12) - print(name, " randomized stats: STR=", character_stats.baseStats.str, - " DEX=", character_stats.baseStats.dex, - " INT=", character_stats.baseStats.int, - " END=", character_stats.baseStats.end, - " WIS=", character_stats.baseStats.wis, - " CHA=", character_stats.baseStats.cha, - " LCK=", character_stats.baseStats.lck) + # Stats randomized (verbose logging removed) func _setup_player_appearance(): # Randomize appearance - players spawn "bare" (naked, no equipment) @@ -600,10 +591,7 @@ func _apply_appearance_to_sprites(): if sprite_weapon: sprite_weapon.texture = null # Weapons don't use character sprite layers - print(name, " appearance applied: skin=", character_stats.skin, - " hair=", character_stats.hairstyle, - " facial_hair=", character_stats.facial_hair, - " eyes=", character_stats.eyes) + # Appearance applied (verbose logging removed) func _on_character_changed(_char: CharacterStats): # Update appearance when character stats change (e.g., equipment) @@ -619,7 +607,7 @@ func _on_character_changed(_char: CharacterStats): equipment_data[slot_name] = item.save() # Serialize item data else: equipment_data[slot_name] = null - _sync_equipment.rpc(equipment_data) + _rpc_to_ready_peers("_sync_equipment", [equipment_data]) # Sync equipment and inventory to client (when server adds/removes items for a client player) # This ensures joiners see items they pick up and equipment changes @@ -661,6 +649,88 @@ func _is_player(obj) -> bool: # Check if it's a player by looking for player-specific properties return obj.is_in_group("player") or ("is_local_player" in obj and "peer_id" in obj) +# Helper function to get consistent object name for network sync +func _get_object_name_for_sync(obj) -> String: + # For interactable objects, use the consistent name (InteractableObject_X) + if obj.name.begins_with("InteractableObject_"): + return obj.name + if obj.has_meta("object_index"): + var obj_index = obj.get_meta("object_index") + return "InteractableObject_%d" % obj_index + # For players, use their unique name + if _is_player(obj): + return obj.name + # Last resort: use the node name (might be auto-generated like @CharacterBody2D@82) + return obj.name + +func _get_log_prefix() -> String: + if multiplayer.has_multiplayer_peer(): + return "[H] " if multiplayer.is_server() else "[J] " + return "" + +func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool: + var space_state = get_world_2d().direct_space_state + var placed_shape = _get_collision_shape_for(placed_obj) + if not placed_shape: + # Fallback to 16x16 + placed_shape = RectangleShape2D.new() + placed_shape.size = Vector2(16, 16) + + var params = PhysicsShapeQueryParameters2D.new() + params.shape = placed_shape + params.transform = Transform2D(0.0, place_pos) + params.collision_mask = 1 | 2 | 64 # Players, objects, walls + params.exclude = [self, placed_obj] + + var hits = space_state.intersect_shape(params, 8) + return hits.is_empty() + +func _find_closest_place_pos(direction: Vector2, placed_obj: Node) -> Vector2: + var dir = direction.normalized() + if dir.length() < 0.1: + dir = last_movement_direction.normalized() + if dir.length() < 0.1: + dir = Vector2.RIGHT + + var player_extent = _get_collision_extent(self) + var obj_extent = _get_collision_extent(placed_obj) + # Start just outside player + object bounds + var start_dist = max(8.0, player_extent + obj_extent + 1.0) + var max_dist = start_dist + 32.0 + var step = 2.0 + + var best_pos = global_position + dir * max_dist + for d in range(int(start_dist), int(max_dist) + 1, int(step)): + var test_pos = global_position + dir * float(d) + if _can_place_down_at(test_pos, placed_obj): + return test_pos + + return best_pos + +func _get_collision_shape_for(node: Node) -> Shape2D: + if not node: + return null + var shape_node = node.get_node_or_null("CollisionShape2D") + if not shape_node: + shape_node = node.find_child("CollisionShape2D", true, false) + if shape_node and "shape" in shape_node: + return shape_node.shape + return null + +func _get_collision_extent(node: Node) -> float: + var shape = _get_collision_shape_for(node) + if shape is RectangleShape2D: + return max(shape.size.x, shape.size.y) * 0.5 + if shape is CapsuleShape2D: + return shape.radius + shape.height * 0.5 + if shape is CircleShape2D: + return shape.radius + if shape is ConvexPolygonShape2D: + var rect = shape.get_rect() + return max(rect.size.x, rect.size.y) * 0.5 + # Fallback + return 8.0 + func _update_animation(delta): # Update animation frame timing time_since_last_frame += delta @@ -1031,15 +1101,10 @@ func _physics_process(delta): else: print("Player ", name, " (server) - all clients now ready! (no ready times tracked)") - # On server, also wait a bit after setting all_clients_ready to ensure nodes are registered - if not multiplayer.is_server(): - _sync_position.rpc(position, velocity, position_z, is_airborne, current_direction, current_animation) - elif all_clients_ready: - # Wait an additional 0.2 seconds after setting all_clients_ready before sending RPCs - var current_time = Time.get_ticks_msec() / 1000.0 - var time_since_ready = current_time - all_clients_ready_time - if time_since_ready >= 0.2: - _sync_position.rpc(position, velocity, position_z, is_airborne, current_direction, current_animation) + # Sync position to all ready peers (clients and server) + # Only send if node is still valid and in tree + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and is_instance_valid(self): + _rpc_to_ready_peers("_sync_position", [position, velocity, position_z, is_airborne, current_direction, current_animation]) # Always move and slide to maintain horizontal velocity # When airborne, velocity is set by throw and decreases with friction @@ -1396,8 +1461,12 @@ func _handle_interactions(): # Gamepad (X button) attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) - if attack_just_pressed and can_attack and not is_lifting and not is_pushing: - _perform_attack() + if attack_just_pressed and can_attack: + if is_lifting: + # Attack while lifting -> throw immediately (no movement required) + _force_throw_held_object(last_movement_direction) + elif not is_pushing: + _perform_attack() # Note: just_grabbed_this_frame is reset when we block a release, or stays true if we grabbed this frame # This ensures it persists to the next frame to block immediate release @@ -1475,9 +1544,11 @@ func _try_grab(): # Sync initial grab to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_initial_grab.rpc(held_object.get_path(), grab_offset) + # Use consistent object name or index instead of path + var obj_name = _get_object_name_for_sync(held_object) + _rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset]) # Sync the grab state - _sync_grab.rpc(held_object.get_path(), is_lifting, push_axis) + _rpc_to_ready_peers("_sync_grab", [obj_name, is_lifting, push_axis]) print("Grabbed: ", closest_body.name) @@ -1525,7 +1596,8 @@ func _lift_object(): # Sync to network (non-blocking) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_grab.rpc(held_object.get_path(), true, push_axis) + var obj_name = _get_object_name_for_sync(held_object) + _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) print("Lifted: ", held_object.name) $SfxLift.play() @@ -1565,7 +1637,8 @@ func _start_pushing(): # Sync push state to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_grab.rpc(held_object.get_path(), false, push_axis) # false = pushing, not lifting + var obj_name = _get_object_name_for_sync(held_object) + _rpc_to_ready_peers("_sync_grab", [obj_name, false, push_axis]) # false = pushing, not lifting print("Pushing: ", held_object.name, " along axis: ", push_axis, " facing dir: ", push_direction_locked) @@ -1598,7 +1671,8 @@ func _stop_pushing(): # Sync release to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_release.rpc(released_obj.get_path()) + var obj_name = _get_object_name_for_sync(released_obj) + _rpc_to_ready_peers("_sync_release", [obj_name]) # Release the object and re-enable collision completely if _is_box(released_obj): @@ -1660,6 +1734,99 @@ func _throw_object(): is_lifting = false is_pushing = false + # Re-enable collision completely + if _is_box(thrown_obj): + # Box: set position and physics first + thrown_obj.global_position = throw_start_pos + + # Set throw velocity for box (same force as player throw) + if "throw_velocity" in thrown_obj: + thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight + if "is_frozen" in thrown_obj: + thrown_obj.is_frozen = false + + # Make box airborne with same arc as players + if "is_airborne" in thrown_obj: + thrown_obj.is_airborne = true + thrown_obj.position_z = 2.5 + thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale + + # Call on_thrown if available + if thrown_obj.has_method("on_thrown"): + thrown_obj.on_thrown(self, throw_direction * throw_force) + + # ⚡ Delay collision re-enable to prevent self-collision + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(2, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(2, true) + thrown_obj.set_collision_mask_value(7, true) + elif _is_player(thrown_obj): + # Player: set position and physics first + thrown_obj.global_position = throw_start_pos + + # Set horizontal velocity for the arc + thrown_obj.velocity = throw_direction * throw_force * 0.8 # Slightly reduced for arc + + # Make player airborne with Z velocity + if "is_airborne" in thrown_obj: + thrown_obj.is_airborne = true + thrown_obj.position_z = 2.5 # Start slightly off ground + thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale + + if thrown_obj.has_method("set_being_held"): + thrown_obj.set_being_held(false) + + # ⚡ Delay collision re-enable to prevent self-collision + await get_tree().create_timer(0.1).timeout + if thrown_obj and is_instance_valid(thrown_obj): + thrown_obj.set_collision_layer_value(1, true) + thrown_obj.set_collision_mask_value(1, true) + thrown_obj.set_collision_mask_value(7, true) + + if thrown_obj.has_method("on_thrown"): + thrown_obj.on_thrown(self, throw_direction * throw_force) + + # Play throw animation + _set_animation("THROW") + $SfxThrow.play() + + # Sync throw over network + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): + var obj_name = _get_object_name_for_sync(thrown_obj) + _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) + + print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) + +func _force_throw_held_object(direction: Vector2): + if not held_object or not is_lifting: + return + + # Check if object can be thrown + if held_object.has_method("can_be_thrown") and not held_object.can_be_thrown(): + # Can't throw this object, place it down instead + _place_down_object() + return + + var throw_direction = direction.normalized() + if throw_direction.length() < 0.1: + throw_direction = last_movement_direction.normalized() + if throw_direction.length() < 0.1: + throw_direction = Vector2.RIGHT + + # Position object at player's position before throwing + var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front + + # Store reference before clearing + var thrown_obj = held_object + + # Clear state first (important!) + held_object = null + grab_offset = Vector2.ZERO + is_lifting = false + is_pushing = false + # Re-enable collision completely if _is_box(thrown_obj): # Box: set position and physics first @@ -1718,7 +1885,8 @@ func _throw_object(): # Sync throw over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_throw.rpc(thrown_obj.get_path(), throw_start_pos, throw_direction * throw_force, get_path()) + var obj_name = _get_object_name_for_sync(thrown_obj) + _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) @@ -1727,10 +1895,13 @@ func _place_down_object(): return # Place object in front of player based on last movement direction - var place_offset = last_movement_direction * 15 # Scaled down for 1x scale - var place_pos = global_position + place_offset + var place_pos = _find_closest_place_pos(last_movement_direction, held_object) var placed_obj = held_object + if not _can_place_down_at(place_pos, placed_obj): + print("DEBUG: Place down blocked - space not free") + return + # Clear state held_object = null grab_offset = Vector2.ZERO @@ -1775,7 +1946,8 @@ func _place_down_object(): # Sync place down over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_place_down.rpc(placed_obj.get_path(), place_pos) + var obj_name = _get_object_name_for_sync(placed_obj) + _rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos]) print("Placed down ", placed_obj.name, " at ", place_pos) @@ -1893,7 +2065,7 @@ func _perform_attack(): # Sync attack over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_attack.rpc(current_direction, attack_direction) + _rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction]) # Reset attack cooldown (instant if cooldown is 0) if attack_cooldown > 0: @@ -1920,7 +2092,8 @@ func _update_lifted_object(): # Sync held object position over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_held_object_pos.rpc(held_object.get_path(), held_object.global_position) + var obj_name = _get_object_name_for_sync(held_object) + _rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position]) func _update_pushed_object(): if held_object and is_instance_valid(held_object): @@ -1963,10 +2136,10 @@ func _update_pushed_object(): # Account for collision shape offset var shape_offset = held_collision_shape.position if held_collision_shape else Vector2.ZERO query.transform = Transform2D(0, target_pos + shape_offset) - query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) + query.collision_mask = 1 | 2 | 64 # Players, objects, walls query.collide_with_areas = false query.collide_with_bodies = true - query.exclude = [held_object.get_rid()] # Exclude the object itself + query.exclude = [held_object.get_rid(), get_rid()] # Exclude the object and the holder var results = space_state.intersect_shape(query) was_blocked = results.size() > 0 @@ -1976,11 +2149,11 @@ func _update_pushed_object(): # Fallback: use point query var query = PhysicsPointQueryParameters2D.new() query.position = target_pos - query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) + query.collision_mask = 1 | 2 | 64 # Players, objects, walls query.collide_with_areas = false query.collide_with_bodies = true if held_object is CharacterBody2D: - query.exclude = [held_object.get_rid()] + query.exclude = [held_object.get_rid(), get_rid()] var results = space_state.intersect_point(query) was_blocked = results.size() > 0 @@ -2001,11 +2174,40 @@ func _update_pushed_object(): # Sync position over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_held_object_pos.rpc(held_object.get_path(), held_object.global_position) + var obj_name = _get_object_name_for_sync(held_object) + _rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position]) + +# Send RPCs only to peers who are ready to receive them +func _rpc_to_ready_peers(method: String, args: Array = []): + if not multiplayer.has_multiplayer_peer(): + return + + var game_world = get_tree().get_first_node_in_group("game_world") + # Server can use the ready-peer helper for safe fanout + if multiplayer.is_server() and game_world and game_world.has_method("_rpc_node_to_ready_peers"): + game_world._rpc_node_to_ready_peers(self, method, args) + return + + # Clients: only send to peers marked ready by server + if game_world and "clients_ready" in game_world: + for target_peer_id in multiplayer.get_peers(): + # Always allow sending to server (peer 1) + if target_peer_id == 1: + callv("rpc_id", [target_peer_id, method] + args) + continue + if game_world.clients_ready.has(target_peer_id) and game_world.clients_ready[target_peer_id]: + callv("rpc_id", [target_peer_id, method] + args) + else: + # Fallback: send to all peers + callv("rpc", [method] + args) # Network sync @rpc("any_peer", "unreliable") func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bool = false, dir: int = 0, anim: String = "IDLE"): + # Check if node still exists and is valid before processing + if not is_inside_tree() or not is_instance_valid(self): + return + # Only update if we're not the authority (remote player) if not is_multiplayer_authority(): position = pos @@ -2029,6 +2231,10 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bo @rpc("any_peer", "reliable") func _sync_attack(direction: int, attack_dir: Vector2): # Sync attack to other clients + # Check if node still exists and is valid before processing + if not is_inside_tree() or not is_instance_valid(self): + return + if not is_multiplayer_authority(): current_direction = direction as Direction _set_animation("SWORD") @@ -2036,6 +2242,10 @@ func _sync_attack(direction: int, attack_dir: Vector2): # Delay before spawning sword slash await get_tree().create_timer(0.15).timeout + # Check again after delay - node might have been destroyed + if not is_inside_tree() or not is_instance_valid(self): + return + # Spawn sword projectile on client if sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() @@ -2048,11 +2258,36 @@ func _sync_attack(direction: int, attack_dir: Vector2): print(name, " performed synced attack!") @rpc("any_peer", "reliable") -func _sync_throw(obj_path: NodePath, throw_pos: Vector2, force: Vector2, thrower_path: NodePath): +func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String): # Sync throw to all clients (RPC sender already threw on their side) - var obj = get_node_or_null(obj_path) - var thrower = get_node_or_null(thrower_path) - print("_sync_throw received: ", obj_path, " found obj: ", obj != null, " thrower: ", thrower != null, " is_authority: ", is_multiplayer_authority()) + # Check if node is still valid and in tree + if not is_inside_tree(): + return + + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_") and entities_node: + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + + var thrower = null + if entities_node: + thrower = entities_node.get_node_or_null(thrower_name) + print("_sync_throw received: ", obj_name, " found obj: ", obj != null, " thrower: ", thrower != null, " is_authority: ", is_multiplayer_authority()) if obj: obj.global_position = throw_pos @@ -2089,6 +2324,7 @@ func _sync_throw(obj_path: NodePath, throw_pos: Vector2, force: Vector2, thrower obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) + obj.set_collision_mask_value(7, true) elif is_player: print("Syncing player throw on client! pos: ", throw_pos, " force: ", force) # Player: set physics first @@ -2109,12 +2345,36 @@ func _sync_throw(obj_path: NodePath, throw_pos: Vector2, force: Vector2, thrower if obj and is_instance_valid(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) + obj.set_collision_mask_value(7, true) @rpc("any_peer", "reliable") -func _sync_initial_grab(obj_path: NodePath, _offset: Vector2): +func _sync_initial_grab(obj_name: String, _offset: Vector2): # Sync initial grab to other clients + # Check if node is still valid and in tree + if not is_inside_tree(): + return + if not is_multiplayer_authority(): - var obj = get_node_or_null(obj_path) + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + if obj: # Disable collision for grabbed object if _is_box(obj): @@ -2125,13 +2385,36 @@ func _sync_initial_grab(obj_path: NodePath, _offset: Vector2): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) - print("Synced initial grab on client: ", obj_path) + print("Synced initial grab on client: ", obj_name) @rpc("any_peer", "reliable") -func _sync_grab(obj_path: NodePath, is_lift: bool, axis: Vector2 = Vector2.ZERO): +func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): # Sync lift/push state to other clients + # Check if node is still valid and in tree + if not is_inside_tree(): + return + if not is_multiplayer_authority(): - var obj = get_node_or_null(obj_path) + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + if obj: if is_lift: # Lifting - completely disable collision @@ -2174,10 +2457,33 @@ func _sync_grab(obj_path: NodePath, is_lift: bool, axis: Vector2 = Vector2.ZERO) print("Synced grab on client: lift=", is_lift, " axis=", axis) @rpc("any_peer", "reliable") -func _sync_release(obj_path: NodePath): +func _sync_release(obj_name: String): # Sync release to other clients + # Check if node is still valid and in tree + if not is_inside_tree(): + return + if not is_multiplayer_authority(): - var obj = get_node_or_null(obj_path) + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + if obj: # Re-enable collision completely if _is_box(obj): @@ -2198,10 +2504,33 @@ func _sync_release(obj_path: NodePath): obj.set_being_held(false) @rpc("any_peer", "reliable") -func _sync_place_down(obj_path: NodePath, place_pos: Vector2): +func _sync_place_down(obj_name: String, place_pos: Vector2): # Sync placing down to other clients + # Check if node is still valid and in tree + if not is_inside_tree(): + return + if not is_multiplayer_authority(): - var obj = get_node_or_null(obj_path) + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + if obj: obj.global_position = place_pos @@ -2247,10 +2576,33 @@ func _sync_teleport_position(new_pos: Vector2): print(name, " teleported to ", new_pos, " (synced from server, is_authority: ", is_multiplayer_authority(), ")") @rpc("any_peer", "unreliable") -func _sync_held_object_pos(obj_path: NodePath, pos: Vector2): +func _sync_held_object_pos(obj_name: String, pos: Vector2): # Sync held object position to other clients + # Check if node is still valid and in tree + if not is_inside_tree(): + return + if not is_multiplayer_authority(): - var obj = get_node_or_null(obj_path) + # Find object by name (consistent name like InteractableObject_X) + var obj = null + var game_world = get_tree().get_first_node_in_group("game_world") + if not game_world or not game_world.is_inside_tree(): + return # GameWorld not ready yet + + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + obj = entities_node.get_node_or_null(obj_name) + + # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup + if not obj and obj_name.begins_with("InteractableObject_"): + var index_str = obj_name.substr(20) # Skip "InteractableObject_" + if index_str.is_valid_int(): + var obj_index = index_str.to_int() + for child in entities_node.get_children(): + if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: + obj = child + break + if obj: # Don't update position if object is airborne (being thrown) if "is_airborne" in obj and obj.is_airborne: @@ -2313,28 +2665,34 @@ func _break_free_from_holder(): # Sync break free over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_break_free.rpc(being_held_by.get_path(), struggle_direction) + _rpc_to_ready_peers("_sync_break_free", [being_held_by.name, struggle_direction]) struggle_time = 0.0 struggle_direction = Vector2.ZERO being_held_by = null @rpc("any_peer", "reliable") -func _sync_break_free(holder_path: NodePath, direction: Vector2): - var holder = get_node_or_null(holder_path) +func _sync_break_free(holder_name: String, direction: Vector2): + var holder = null + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + holder = entities_node.get_node_or_null(holder_name) + if holder and holder.has_method("_force_place_down"): holder._force_place_down(direction) func _force_place_down(direction: Vector2): # Forced to place down held object in specified direction if held_object and is_lifting: - var place_offset = direction.normalized() * 20 - if place_offset.length() < 0.1: - place_offset = last_movement_direction * 20 - - var place_pos = position + place_offset + var place_pos = _find_closest_place_pos(direction, held_object) var placed_obj = held_object + if not _can_place_down_at(place_pos, placed_obj): + print("DEBUG: Forced place down blocked - space not free") + return + # Clear state held_object = null grab_offset = Vector2.ZERO @@ -2414,9 +2772,17 @@ func take_damage(amount: float, attacker_position: Vector2): _show_damage_number(0.0, attacker_position, false, false, true) # is_dodged = true # Sync dodge visual to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _sync_damage.rpc(0.0, attacker_position, false, false, true) # is_dodged = true + _rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true return # No damage taken, exit early + # If taking damage while holding something, drop/throw immediately + if held_object: + if is_lifting: + var throw_dir = (global_position - attacker_position).normalized() + _force_throw_held_object(throw_dir) + else: + _stop_pushing() + # If not dodged, apply damage with DEF reduction var actual_damage = amount if character_stats: @@ -2466,7 +2832,7 @@ func take_damage(amount: float, attacker_position: Vector2): # Sync damage visual effects to other clients (including damage numbers) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _sync_damage.rpc(actual_damage, attacker_position) + _rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position]) # Check if dead - but wait for damage animation to play first var health = character_stats.hp if character_stats else current_health @@ -2517,7 +2883,8 @@ func _die(): # Sync release to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_release.rpc(released_obj.get_path()) + var obj_name = _get_object_name_for_sync(released_obj) + _rpc_to_ready_peers("_sync_release", [obj_name]) print(name, " released ", released_obj.name, " on death") else: @@ -2548,7 +2915,7 @@ func _die(): # Sync death over network (only authority sends) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_death.rpc() + _rpc_to_ready_peers("_sync_death", []) # Wait for DIE animation to complete (200+200+200+800 = 1400ms = 1.4s) await get_tree().create_timer(1.4).timeout @@ -2586,7 +2953,7 @@ func _die(): # THEN sync to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _force_holder_to_drop.rpc(other_player.get_path()) + _rpc_to_ready_peers("_force_holder_to_drop", [other_player.name]) found_holder = true break @@ -2709,17 +3076,22 @@ func _respawn(): # Sync respawn over network (only authority sends) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): - _sync_respawn.rpc(new_respawn_pos) + _rpc_to_ready_peers("_sync_respawn", [new_respawn_pos]) @rpc("any_peer", "reliable") -func _force_holder_to_drop(holder_path: NodePath): +func _force_holder_to_drop(holder_name: String): # Force a specific player to drop what they're holding - _force_holder_to_drop_local(holder_path) + _force_holder_to_drop_local(holder_name) -func _force_holder_to_drop_local(holder_path: NodePath): +func _force_holder_to_drop_local(holder_name: String): # Local function to clear holder's held object - print("_force_holder_to_drop_local called for holder path: ", holder_path) - var holder = get_node_or_null(holder_path) + print("_force_holder_to_drop_local called for holder: ", holder_name) + var holder = null + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + var entities_node = game_world.get_node_or_null("Entities") + if entities_node: + holder = entities_node.get_node_or_null(holder_name) if holder and is_instance_valid(holder): print(" Found holder: ", holder.name, ", their held_object: ", holder.held_object) if holder.held_object == self: @@ -2863,6 +3235,12 @@ func heal(amount: float): func add_key(amount: int = 1): keys += amount print(name, " picked up ", amount, " key(s)! Total keys: ", keys) + + # Sync key count to owning client (server authoritative) + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + var owner_peer_id = get_multiplayer_authority() + if owner_peer_id != 0 and owner_peer_id != multiplayer.get_unique_id(): + _sync_keys.rpc_id(owner_peer_id, keys) func has_key() -> bool: return keys > 0 @@ -2870,10 +3248,17 @@ func has_key() -> bool: func use_key(): if keys > 0: keys -= 1 - print(name, " used a key! Remaining keys: ", keys) + print(_get_log_prefix(), name, " used a key! Remaining keys: ", keys) return true return false +@rpc("any_peer", "reliable") +func _sync_keys(new_key_count: int): + # Sync key count to client + if not is_inside_tree(): + return + keys = new_key_count + @rpc("authority", "reliable") func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false): # Show damage number (red, using dmg_numbers.png font) above player diff --git a/src/scripts/player_manager.gd b/src/scripts/player_manager.gd index 7a9b692..cfd83c8 100644 --- a/src/scripts/player_manager.gd +++ b/src/scripts/player_manager.gd @@ -81,11 +81,8 @@ func spawn_player(peer_id: int, local_index: int): # Set multiplayer authority AFTER adding to scene if multiplayer.has_multiplayer_peer(): player.set_multiplayer_authority(peer_id) - print("Set authority for player ", unique_id, " to peer ", peer_id) players[unique_id] = player - - print("Spawned player: ", unique_id, " at ", player.position, " (local: ", player.is_local_player, ")") func despawn_players_for_peer(peer_id: int): var to_remove = [] diff --git a/src/scripts/room_registry_client.gd b/src/scripts/room_registry_client.gd new file mode 100644 index 0000000..2aa6840 --- /dev/null +++ b/src/scripts/room_registry_client.gd @@ -0,0 +1,226 @@ +extends Node + +# Room Registry Client - Handles HTTP requests to room discovery server +# Allows hosts to register rooms and joiners to discover available rooms + +# Logging - use LogManager for categorized logging +func log_print(message: String): + LogManager.log(message, LogManager.CATEGORY_NETWORK) + +func log_error(message: String): + LogManager.log_error(message, LogManager.CATEGORY_NETWORK) + +signal rooms_fetched(rooms: Array) +signal room_update_sent(success: bool) + +const ROOM_LIST_URL = "https://ruinborn-rooms.thefirstboss.com/index.php" +const ROOM_UPDATE_URL = "https://ruinborn-rooms.thefirstboss.com/update.php" + +var http_request_list: HTTPRequest = null +var http_request_update: HTTPRequest = null +var update_timer: Timer = null +var current_room: String = "" +var current_level: int = 1 +var pending_update: Dictionary = {} # Store pending update (player_count, level) when request is in progress +var is_update_requesting: bool = false # Flag to track if an update request is in progress +var is_list_requesting: bool = false # Flag to track if a room list request is in progress + +func _ready(): + # Create HTTPRequest nodes for fetching and updating + http_request_list = HTTPRequest.new() + http_request_list.name = "HTTPRequestList" + add_child(http_request_list) + http_request_list.request_completed.connect(_on_room_list_received) + + http_request_update = HTTPRequest.new() + http_request_update.name = "HTTPRequestUpdate" + add_child(http_request_update) + http_request_update.request_completed.connect(_on_room_update_received) + + # Create timer for periodic updates + update_timer = Timer.new() + update_timer.name = "UpdateTimer" + update_timer.wait_time = 15.0 # Update every 15 seconds + update_timer.timeout.connect(_on_update_timer_timeout) + update_timer.autostart = false + add_child(update_timer) + +func fetch_available_rooms() -> bool: + """Fetch list of available rooms from the server""" + # Check if a request is already in progress + if is_list_requesting or http_request_list.get_http_client_status() == HTTPClient.STATUS_REQUESTING: + log_print("RoomRegistry: Room list request already in progress, skipping duplicate request") + return false + + # Mark that we're starting a request + is_list_requesting = true + + log_print("RoomRegistry: Fetching available rooms...") + var error = http_request_list.request(ROOM_LIST_URL) + if error != OK: + log_error("RoomRegistry: Failed to request room list: " + str(error)) + is_list_requesting = false # Reset flag on error + return false + return true + +func register_room(room_code: String, player_count: int, level: int) -> bool: + """Register a room and start sending keepalive updates""" + current_room = room_code + current_level = level + log_print("RoomRegistry: Registering room: " + room_code + " with " + str(player_count) + " players, level " + str(level)) + + # Send initial update + send_room_update(player_count) + + # Start periodic updates + update_timer.start() + + return true + +func send_room_update(player_count: int, level: int = -1) -> bool: + """Send a keepalive update for the current room""" + if current_room.is_empty(): + return false + + # Get level from game_world if not provided + if level < 0: + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + level = game_world.current_level + else: + level = current_level + else: + current_level = level + + # Check if a request is already in progress - if so, queue this update + # HTTPRequest can only handle one request at a time + if is_update_requesting or http_request_update.get_http_client_status() == HTTPClient.STATUS_REQUESTING: + log_print("RoomRegistry: Update request already in progress, queuing update") + pending_update = { + "player_count": player_count, + "level": level + } + return true # Return true to indicate update is queued + + # Clear any pending update since we're sending now + pending_update = {} + + # Mark that we're starting a request + is_update_requesting = true + + var json_data = { + "data": { + "room": current_room, + "players": player_count, + "level": level + } + } + + var json_string = JSON.stringify(json_data) + var headers = ["Content-Type: application/json"] + + log_print("RoomRegistry: Sending room update: " + json_string) + var error = http_request_update.request(ROOM_UPDATE_URL, headers, HTTPClient.METHOD_POST, json_string) + if error != OK: + log_error("RoomRegistry: Failed to send room update: " + str(error)) + is_update_requesting = false # Reset flag on error + return false + return true + +func unregister_room(): + """Stop sending updates and clear room registration""" + log_print("RoomRegistry: Unregistering room: " + current_room) + update_timer.stop() + current_room = "" + current_level = 1 + +func _on_room_list_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray): + """Handle response from room list request""" + # Reset the requesting flag + is_list_requesting = false + + if result != HTTPRequest.RESULT_SUCCESS: + log_error("RoomRegistry: Failed to fetch rooms: HTTP result " + str(result)) + rooms_fetched.emit([]) + return + + if response_code != 200: + log_error("RoomRegistry: HTTP error code: " + str(response_code)) + rooms_fetched.emit([]) + return + + var json = JSON.new() + var parse_error = json.parse(body.get_string_from_utf8()) + if parse_error != OK: + log_error("RoomRegistry: Failed to parse room list JSON: " + str(parse_error)) + rooms_fetched.emit([]) + return + + var data = json.data + if not data.has("status") or data["status"] != "OK": + log_error("RoomRegistry: Server returned error status") + rooms_fetched.emit([]) + return + + var rooms = [] + if data.has("data") and data["data"] is Array: + for room_data in data["data"]: + if room_data.has("room"): + rooms.append({ + "room": room_data["room"], + "players": room_data.get("players", 0), + "level": room_data.get("level", 1) + }) + + log_print("RoomRegistry: Found " + str(rooms.size()) + " available rooms") + rooms_fetched.emit(rooms) + +func _on_room_update_received(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray): + """Handle response from room update request""" + # Reset the requesting flag + is_update_requesting = false + + if result != HTTPRequest.RESULT_SUCCESS: + log_error("RoomRegistry: Failed to send room update: HTTP result " + str(result)) + room_update_sent.emit(false) + # Try to send pending update if any + if not pending_update.is_empty(): + send_room_update(pending_update.get("player_count", 0), pending_update.get("level", -1)) + return + + if response_code != 200: + log_error("RoomRegistry: Room update HTTP error code: " + str(response_code)) + room_update_sent.emit(false) + # Try to send pending update if any + if not pending_update.is_empty(): + send_room_update(pending_update.get("player_count", 0), pending_update.get("level", -1)) + return + + log_print("RoomRegistry: Room update sent successfully") + room_update_sent.emit(true) + + # Send pending update if any + if not pending_update.is_empty(): + var queued_player_count = pending_update.get("player_count", 0) + var queued_level = pending_update.get("level", -1) + pending_update = {} # Clear pending update before sending + send_room_update(queued_player_count, queued_level) + +func _on_update_timer_timeout(): + """Periodic timer callback to send keepalive updates""" + if current_room.is_empty(): + return + + # Get current player count from NetworkManager + var network_manager = get_node("/root/NetworkManager") + if not network_manager: + return + + var player_count = network_manager.get_all_player_ids().size() + var level = current_level + var game_world = get_tree().get_first_node_in_group("game_world") + if game_world: + level = game_world.current_level + current_level = level + + send_room_update(player_count, level) diff --git a/src/scripts/room_registry_client.gd.uid b/src/scripts/room_registry_client.gd.uid new file mode 100644 index 0000000..c906473 --- /dev/null +++ b/src/scripts/room_registry_client.gd.uid @@ -0,0 +1 @@ +uid://b165x5g8k5iwd diff --git a/src/scripts/room_trigger.gd b/src/scripts/room_trigger.gd index 70ab702..d7bf6c3 100644 --- a/src/scripts/room_trigger.gd +++ b/src/scripts/room_trigger.gd @@ -45,9 +45,9 @@ func _on_body_exited(body): func _on_player_entered_room(player: Node): # Handle player entering room - print("Player ", player.name, " entered room at ", room.x, ", ", room.y) - print("RoomTrigger: This trigger is for room (", room.x, ", ", room.y, ", ", room.w, "x", room.h, ")") - print("RoomTrigger: Found ", doors_in_room.size(), " doors in this room") + LogManager.log("Player " + str(player.name) + " entered room at " + str(room.x) + ", " + str(room.y), LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: This trigger is for room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Found " + str(doors_in_room.size()) + " doors in this room", LogManager.CATEGORY_DUNGEON) # Mark room as entered and update debug label room_entered = true @@ -78,10 +78,14 @@ func _on_player_entered_room(player: Node): if not door_in_this_room: # Door is NOT in this room - DO NOT call it! - print("RoomTrigger: ERROR - Door ", door.name, " is NOT in room (", room.x, ", ", room.y, ")!") - print("RoomTrigger: Door room1: (", door_room1.x if door_room1 else "none", ", ", door_room1.y if door_room1 else "none", ")") - print("RoomTrigger: Door blocking_room: (", door_blocking_room.x if door_blocking_room else "none", ", ", door_blocking_room.y if door_blocking_room else "none", ")") - print("RoomTrigger: Removing from this trigger's doors list!") + var door_room1_x = str(door_room1.x) if door_room1 and not door_room1.is_empty() else "none" + var door_room1_y = str(door_room1.y) if door_room1 and not door_room1.is_empty() else "none" + var door_blocking_room_x = str(door_blocking_room.x) if door_blocking_room and not door_blocking_room.is_empty() else "none" + var door_blocking_room_y = str(door_blocking_room.y) if door_blocking_room and not door_blocking_room.is_empty() else "none" + LogManager.log_error("RoomTrigger: ERROR - Door " + str(door.name) + " is NOT in room (" + str(room.x) + ", " + str(room.y) + ")!", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Door room1: (" + door_room1_x + ", " + door_room1_y + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Door blocking_room: (" + door_blocking_room_x + ", " + door_blocking_room_y + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Removing from this trigger's doors list!", LogManager.CATEGORY_DUNGEON) doors_in_room.erase(door) if door.room_trigger_area == self: door.room_trigger_area = null @@ -96,13 +100,13 @@ func _on_player_entered_room(player: Node): door._on_room_entered(player) # Spawn enemies if this room has a spawner - print("RoomTrigger: About to call _spawn_room_enemies()...") + LogManager.log("RoomTrigger: About to call _spawn_room_enemies()...", LogManager.CATEGORY_DUNGEON) _spawn_room_enemies() - print("RoomTrigger: Finished _spawn_room_enemies()") + LogManager.log("RoomTrigger: Finished _spawn_room_enemies()", LogManager.CATEGORY_DUNGEON) func _on_player_exited_room(player: Node): # Handle player leaving room - print("Player ", player.name, " exited room at ", room.x, ", ", room.y) + LogManager.log("Player " + str(player.name) + " exited room at " + str(room.x) + ", " + str(room.y), LogManager.CATEGORY_DUNGEON) func _find_room_entities(): # Find all doors, enemies, and switches that belong to this room @@ -118,13 +122,13 @@ func _find_room_entities(): # CRITICAL: Find doors where room1 == THIS room OR blocking_room == THIS room # Blocking doors are IN the puzzle room (they lead OUT OF this room) # When you enter this room, doors IN this room should close (blocking exits) - print("RoomTrigger: Finding doors for room (", room.x, ", ", room.y, ", ", room.w, "x", room.h, ")") + LogManager.log("RoomTrigger: Finding doors for room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) var _total_blocking_doors = 0 for child in entities_node.get_children(): if child.is_in_group("blocking_door") or child.name.begins_with("BlockingDoor_"): _total_blocking_doors += 1 if not is_instance_valid(child): - print("RoomTrigger: Door ", child.name, " is invalid, skipping") + LogManager.log("RoomTrigger: Door " + str(child.name) + " is invalid, skipping", LogManager.CATEGORY_DUNGEON) continue # Check if door is IN this room (room1 == this room OR blocking_room == this room) @@ -142,7 +146,7 @@ func _find_room_entities(): door_room1.w == room.w and door_room1.h == room.h) if room1_matches: door_in_this_room = true - print("RoomTrigger: Door ", child.name, " room1 matches this room (door IN this room)") + LogManager.log("RoomTrigger: Door " + str(child.name) + " room1 matches this room (door IN this room)", LogManager.CATEGORY_DUNGEON) # For blocking doors, also verify blocking_room matches (it should match room1) if door_in_this_room and is_blocking_door and door_blocking_room and not door_blocking_room.is_empty(): @@ -150,22 +154,24 @@ func _find_room_entities(): door_blocking_room.w == room.w and door_blocking_room.h == room.h) if not blocking_matches: # Blocking door's blocking_room doesn't match - this is an error! - print("RoomTrigger: ERROR - Blocking door ", child.name, " room1 matches but blocking_room (", door_blocking_room.x, ", ", door_blocking_room.y, ") doesn't match this room (", room.x, ", ", room.y, ")!") + LogManager.log_error("RoomTrigger: ERROR - Blocking door " + str(child.name) + " room1 matches but blocking_room (" + str(door_blocking_room.x) + ", " + str(door_blocking_room.y) + ") doesn't match this room (" + str(room.x) + ", " + str(room.y) + ")!", LogManager.CATEGORY_DUNGEON) door_in_this_room = false # Reject this door else: - print("RoomTrigger: Blocking door ", child.name, " blocking_room also matches this room (verified)") + LogManager.log("RoomTrigger: Blocking door " + str(child.name) + " blocking_room also matches this room (verified)", LogManager.CATEGORY_DUNGEON) # For non-blocking doors or if room1 didn't match, check blocking_room as fallback if not door_in_this_room and door_blocking_room and not door_blocking_room.is_empty(): door_in_this_room = (door_blocking_room.x == room.x and door_blocking_room.y == room.y and \ door_blocking_room.w == room.w and door_blocking_room.h == room.h) if door_in_this_room: - print("RoomTrigger: Door ", child.name, " blocking_room matches this room (fallback check)") + LogManager.log("RoomTrigger: Door " + str(child.name) + " blocking_room matches this room (fallback check)", LogManager.CATEGORY_DUNGEON) else: - print("RoomTrigger: Door ", child.name, " blocking_room (", door_blocking_room.x if door_blocking_room else "none", ", ", door_blocking_room.y if door_blocking_room else "none", ") doesn't match this room (", room.x, ", ", room.y, ")") + var blocking_room_x = str(door_blocking_room.x) if door_blocking_room and not door_blocking_room.is_empty() else "none" + var blocking_room_y = str(door_blocking_room.y) if door_blocking_room and not door_blocking_room.is_empty() else "none" + LogManager.log("RoomTrigger: Door " + str(child.name) + " blocking_room (" + blocking_room_x + ", " + blocking_room_y + ") doesn't match this room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) if not door_room1 and not door_blocking_room: - print("RoomTrigger: Door ", child.name, " has no room1 or blocking_room set!") + LogManager.log("RoomTrigger: Door " + str(child.name) + " has no room1 or blocking_room set!", LogManager.CATEGORY_DUNGEON) if door_in_this_room: # This door is IN THIS room (blocks exits from this room) - add it to this trigger @@ -178,11 +184,15 @@ func _find_room_entities(): var this_room_str = "(" + str(room.x) + ", " + str(room.y) + ")" # Debug: Print door's room info to understand why it's matching multiple rooms - print("RoomTrigger: WARNING - Door ", child.name, " already connected to trigger for room ", other_room_str, "!") - print("RoomTrigger: Door room1: (", door_room1.x if door_room1 else "none", ", ", door_room1.y if door_room1 else "none", ")") - print("RoomTrigger: Door blocking_room: (", door_blocking_room.x if door_blocking_room else "none", ", ", door_blocking_room.y if door_blocking_room else "none", ")") - print("RoomTrigger: Current trigger room: ", this_room_str) - print("RoomTrigger: Skipping this door - it belongs to the other trigger!") + var door_room1_x_str = str(door_room1.x) if door_room1 and not door_room1.is_empty() else "none" + var door_room1_y_str = str(door_room1.y) if door_room1 and not door_room1.is_empty() else "none" + var door_blocking_room_x_str = str(door_blocking_room.x) if door_blocking_room and not door_blocking_room.is_empty() else "none" + var door_blocking_room_y_str = str(door_blocking_room.y) if door_blocking_room and not door_blocking_room.is_empty() else "none" + LogManager.log("RoomTrigger: WARNING - Door " + str(child.name) + " already connected to trigger for room " + other_room_str + "!", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Door room1: (" + door_room1_x_str + ", " + door_room1_y_str + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Door blocking_room: (" + door_blocking_room_x_str + ", " + door_blocking_room_y_str + ")", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Current trigger room: " + this_room_str, LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Skipping this door - it belongs to the other trigger!", LogManager.CATEGORY_DUNGEON) # Don't add to this trigger if already connected to another trigger if child in doors_in_room: @@ -193,7 +203,7 @@ func _find_room_entities(): # CRITICAL: Only add if not already in the list (avoid duplicates) if not child in doors_in_room: doors_in_room.append(child) - print("RoomTrigger: Added door ", child.name, " that is IN room (", room.x, ", ", room.y, ") to this trigger") + LogManager.log("RoomTrigger: Added door " + str(child.name) + " that is IN room (" + str(room.x) + ", " + str(room.y) + ") to this trigger", LogManager.CATEGORY_DUNGEON) # Set door's room trigger reference (should be null at this point, but set it anyway) if not child.room_trigger_area: @@ -202,13 +212,13 @@ func _find_room_entities(): # This door is NOT in this room - ensure it's not in this trigger's list # This prevents doors from being accidentally connected to wrong triggers if child in doors_in_room: - print("RoomTrigger: Removing door ", child.name, " from this trigger - it's not in this room (", room.x, ", ", room.y, ")") + LogManager.log("RoomTrigger: Removing door " + str(child.name) + " from this trigger - it's not in this room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) doors_in_room.erase(child) if child.room_trigger_area == self: - print("RoomTrigger: Disconnecting door ", child.name, " from this trigger - wrong room!") + LogManager.log("RoomTrigger: Disconnecting door " + str(child.name) + " from this trigger - wrong room!", LogManager.CATEGORY_DUNGEON) child.room_trigger_area = null - print("RoomTrigger: Found ", doors_in_room.size(), " doors for room (", room.x, ", ", room.y, ")") + LogManager.log("RoomTrigger: Found " + str(doors_in_room.size()) + " doors for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) # Find enemies (only if room has enemies - skip for empty rooms to avoid unnecessary work) for child in entities_node.get_children(): @@ -249,18 +259,18 @@ func _find_room_entities(): switch_tile_y >= room_min_y and switch_tile_y < room_max_y: floor_switches_in_room.append(child) - print("RoomTrigger: Found ", enemies_in_room.size(), " enemies and ", floor_switches_in_room.size(), " switches for room (", room.x, ", ", room.y, ")") + LogManager.log("RoomTrigger: Found " + str(enemies_in_room.size()) + " enemies and " + str(floor_switches_in_room.size()) + " switches for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) # Update debug label after finding all entities (skip if label not created yet) if debug_label: call_deferred("_update_debug_label") func _spawn_room_enemies(): - print("RoomTrigger: ===== _spawn_room_enemies() CALLED for room (", room.x, ", ", room.y, ") =====") + LogManager.log("RoomTrigger: ===== _spawn_room_enemies() CALLED for room (" + str(room.x) + ", " + str(room.y) + ") =====", LogManager.CATEGORY_DUNGEON) # Spawn enemies when player enters room (if room has spawners and not already spawned) if enemies_spawned: - print("RoomTrigger: Already spawned enemies, skipping...") + LogManager.log("RoomTrigger: Already spawned enemies, skipping...", LogManager.CATEGORY_DUNGEON) return # Already spawned enemies # CRITICAL: Remove any existing smoke puffs before spawning new ones @@ -269,42 +279,42 @@ func _spawn_room_enemies(): # Find enemy spawners for this room _find_room_spawners() - print("RoomTrigger: ===== Found ", enemy_spawners.size(), " spawners for room (", room.x, ", ", room.y, ") =====") + LogManager.log("RoomTrigger: ===== Found " + str(enemy_spawners.size()) + " spawners for room (" + str(room.x) + ", " + str(room.y) + ") =====", LogManager.CATEGORY_DUNGEON) # Spawn enemies from all spawners in this room (only once) if enemy_spawners.size() > 0: for spawner in enemy_spawners: if not is_instance_valid(spawner): - print("RoomTrigger: WARNING - Invalid spawner found, skipping") + LogManager.log("RoomTrigger: WARNING - Invalid spawner found, skipping", LogManager.CATEGORY_DUNGEON) continue if not spawner.has_method("spawn_enemy"): - print("RoomTrigger: WARNING - Spawner ", spawner.name, " doesn't have spawn_enemy method!") + LogManager.log("RoomTrigger: WARNING - Spawner " + str(spawner.name) + " doesn't have spawn_enemy method!", LogManager.CATEGORY_DUNGEON) continue # CRITICAL: Verify spawner has enemy scenes set if "enemy_scenes" in spawner: if spawner.enemy_scenes.size() == 0: - print("RoomTrigger: ERROR - Spawner ", spawner.name, " has empty enemy_scenes array! Cannot spawn!") + LogManager.log_error("RoomTrigger: ERROR - Spawner " + str(spawner.name) + " has empty enemy_scenes array! Cannot spawn!", LogManager.CATEGORY_DUNGEON) continue else: - print("RoomTrigger: Spawner ", spawner.name, " has ", spawner.enemy_scenes.size(), " enemy scenes available") + LogManager.log("RoomTrigger: Spawner " + str(spawner.name) + " has " + str(spawner.enemy_scenes.size()) + " enemy scenes available", LogManager.CATEGORY_DUNGEON) else: - print("RoomTrigger: ERROR - Spawner ", spawner.name, " has no enemy_scenes property!") + LogManager.log_error("RoomTrigger: ERROR - Spawner " + str(spawner.name) + " has no enemy_scenes property!", LogManager.CATEGORY_DUNGEON) continue # CRITICAL: Verify spawner is on server (authority) - only server can spawn if multiplayer.has_multiplayer_peer() and not spawner.is_multiplayer_authority(): - print("RoomTrigger: WARNING - Spawner ", spawner.name, " is not multiplayer authority! Cannot spawn on client!") + LogManager.log("RoomTrigger: WARNING - Spawner " + str(spawner.name) + " is not multiplayer authority! Cannot spawn on client!", LogManager.CATEGORY_DUNGEON) continue - print("RoomTrigger: Calling spawn_enemy() on spawner ", spawner.name, " at ", spawner.global_position) + LogManager.log("RoomTrigger: Calling spawn_enemy() on spawner " + str(spawner.name) + " at " + str(spawner.global_position), LogManager.CATEGORY_DUNGEON) # Spawn enemies from this spawner (spawner will handle max_enemies check) # NOTE: spawn_enemy() is async (uses await), so we don't await it here - it will execute asynchronously spawner.spawn_enemy() enemies_spawned = true - print("RoomTrigger: Called spawn_enemy() on ", enemy_spawners.size(), " spawners in room at ", room.x, ", ", room.y) + LogManager.log("RoomTrigger: Called spawn_enemy() on " + str(enemy_spawners.size()) + " spawners in room at " + str(room.x) + ", " + str(room.y), LogManager.CATEGORY_DUNGEON) # Update debug label _update_debug_label() @@ -315,12 +325,15 @@ func _spawn_room_enemies(): _find_room_entities() # Refresh enemy list after spawning completes _update_debug_label() # Update again after enemies spawn else: - print("RoomTrigger: No spawners found for room (", room.x, ", ", room.y, ")") + LogManager.log("RoomTrigger: No spawners found for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) _update_debug_label() func _cleanup_smoke_puffs(): # Remove all existing smoke puffs in the scene before spawning new ones - var entities_node = get_tree().get_first_node_in_group("game_world") + var game_world = get_tree().get_first_node_in_group("game_world") + var entities_node = null + if game_world: + entities_node = game_world.get_node_or_null("Entities") if not entities_node: entities_node = get_node_or_null("/root/GameWorld/Entities") @@ -331,12 +344,12 @@ func _cleanup_smoke_puffs(): var smoke_puffs_removed = 0 for child in entities_node.get_children(): if child.is_in_group("smoke_puff") or child.name.begins_with("SmokePuff"): - print("RoomTrigger: Removing existing smoke puff: ", child.name) + LogManager.log("RoomTrigger: Removing existing smoke puff: " + str(child.name), LogManager.CATEGORY_DUNGEON) child.queue_free() smoke_puffs_removed += 1 if smoke_puffs_removed > 0: - print("RoomTrigger: Cleaned up ", smoke_puffs_removed, " existing smoke puffs before spawning") + LogManager.log("RoomTrigger: Cleaned up " + str(smoke_puffs_removed) + " existing smoke puffs before spawning", LogManager.CATEGORY_DUNGEON) func _find_room_spawners(): # CRITICAL: Clear the list first to avoid accumulating old spawners @@ -345,26 +358,26 @@ func _find_room_spawners(): # Find enemy spawners in this room var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: - print("RoomTrigger: ERROR - No game_world found when searching for spawners!") + LogManager.log_error("RoomTrigger: ERROR - No game_world found when searching for spawners!", LogManager.CATEGORY_DUNGEON) return var entities_node = game_world.get_node_or_null("Entities") if not entities_node: - print("RoomTrigger: ERROR - No Entities node found when searching for spawners!") + LogManager.log_error("RoomTrigger: ERROR - No Entities node found when searching for spawners!", LogManager.CATEGORY_DUNGEON) return - print("RoomTrigger: ===== Searching for spawners in room (", room.x, ", ", room.y, ", ", room.w, "x", room.h, ") =====") - print("RoomTrigger: Entities node has ", entities_node.get_child_count(), " children") + LogManager.log("RoomTrigger: ===== Searching for spawners in room (" + str(room.x) + ", " + str(room.y) + ", " + str(room.w) + "x" + str(room.h) + ") =====", LogManager.CATEGORY_DUNGEON) + LogManager.log("RoomTrigger: Entities node has " + str(entities_node.get_child_count()) + " children", LogManager.CATEGORY_DUNGEON) # Search for spawners (they might be direct children of Entities or in a Spawners node) var found_spawners_count = 0 for child in entities_node.get_children(): if child.name.begins_with("EnemySpawner_") or child.is_in_group("enemy_spawner"): found_spawners_count += 1 - print("RoomTrigger: Checking spawner: ", child.name, " at ", child.global_position) + LogManager.log("RoomTrigger: Checking spawner: " + str(child.name) + " at " + str(child.global_position), LogManager.CATEGORY_DUNGEON) if not is_instance_valid(child): - print("RoomTrigger: Spawner is invalid, skipping") + LogManager.log("RoomTrigger: Spawner is invalid, skipping", LogManager.CATEGORY_DUNGEON) continue var spawner_in_room = false @@ -372,28 +385,32 @@ func _find_room_spawners(): # First check if spawner has room metadata matching this room if child.has_meta("room"): var spawner_room = child.get_meta("room") - print("RoomTrigger: Spawner has room metadata: (", spawner_room.x if spawner_room and not spawner_room.is_empty() else "none", ", ", spawner_room.y if spawner_room and not spawner_room.is_empty() else "none", ")") + var spawner_room_x_str = str(spawner_room.x) if spawner_room and not spawner_room.is_empty() else "none" + var spawner_room_y_str = str(spawner_room.y) if spawner_room and not spawner_room.is_empty() else "none" + LogManager.log("RoomTrigger: Spawner has room metadata: (" + spawner_room_x_str + ", " + spawner_room_y_str + ")", LogManager.CATEGORY_DUNGEON) if spawner_room and not spawner_room.is_empty(): # Compare rooms by position and size if spawner_room.x == room.x and spawner_room.y == room.y and \ spawner_room.w == room.w and spawner_room.h == room.h: spawner_in_room = true - print("RoomTrigger: ✓ Spawner room matches this trigger room!") + LogManager.log("RoomTrigger: ✓ Spawner room matches this trigger room!", LogManager.CATEGORY_DUNGEON) else: - print("RoomTrigger: ✗ Spawner room doesn't match - spawner: (", spawner_room.x, ",", spawner_room.y, ",", spawner_room.w, "x", spawner_room.h, "), trigger: (", room.x, ",", room.y, ",", room.w, "x", room.h, ")") + LogManager.log("RoomTrigger: ✗ Spawner room doesn't match - spawner: (" + str(spawner_room.x) + "," + str(spawner_room.y) + "," + str(spawner_room.w) + "x" + str(spawner_room.h) + "), trigger: (" + str(room.x) + "," + str(room.y) + "," + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) # Also check blocking_room metadata (fallback) if not spawner_in_room and child.has_meta("blocking_room"): var blocking_room = child.get_meta("blocking_room") - print("RoomTrigger: Spawner has blocking_room metadata: (", blocking_room.x if blocking_room and not blocking_room.is_empty() else "none", ", ", blocking_room.y if blocking_room and not blocking_room.is_empty() else "none", ")") + var blocking_room_x_str = str(blocking_room.x) if blocking_room and not blocking_room.is_empty() else "none" + var blocking_room_y_str = str(blocking_room.y) if blocking_room and not blocking_room.is_empty() else "none" + LogManager.log("RoomTrigger: Spawner has blocking_room metadata: (" + blocking_room_x_str + ", " + blocking_room_y_str + ")", LogManager.CATEGORY_DUNGEON) if blocking_room and not blocking_room.is_empty(): # Compare rooms by position and size if blocking_room.x == room.x and blocking_room.y == room.y and \ blocking_room.w == room.w and blocking_room.h == room.h: spawner_in_room = true - print("RoomTrigger: ✓ Spawner blocking_room matches this trigger room!") + LogManager.log("RoomTrigger: ✓ Spawner blocking_room matches this trigger room!", LogManager.CATEGORY_DUNGEON) else: - print("RoomTrigger: ✗ Spawner blocking_room doesn't match - spawner: (", blocking_room.x, ",", blocking_room.y, ",", blocking_room.w, "x", blocking_room.h, "), trigger: (", room.x, ",", room.y, ",", room.w, "x", room.h, ")") + LogManager.log("RoomTrigger: ✗ Spawner blocking_room doesn't match - spawner: (" + str(blocking_room.x) + "," + str(blocking_room.y) + "," + str(blocking_room.w) + "x" + str(blocking_room.h) + "), trigger: (" + str(room.x) + "," + str(room.y) + "," + str(room.w) + "x" + str(room.h) + ")", LogManager.CATEGORY_DUNGEON) # Also check by position (fallback if no room metadata) if not spawner_in_room: @@ -405,22 +422,22 @@ func _find_room_spawners(): var room_min_y = room.y + 2 var room_max_y = room.y + room.h - 2 - print("RoomTrigger: Checking by position: spawner at tile (", spawner_tile_x, ", ", spawner_tile_y, "), room bounds: (", room_min_x, "-", room_max_x, ", ", room_min_y, "-", room_max_y, ")") + LogManager.log("RoomTrigger: Checking by position: spawner at tile (" + str(spawner_tile_x) + ", " + str(spawner_tile_y) + "), room bounds: (" + str(room_min_x) + "-" + str(room_max_x) + ", " + str(room_min_y) + "-" + str(room_max_y) + ")", LogManager.CATEGORY_DUNGEON) if spawner_tile_x >= room_min_x and spawner_tile_x < room_max_x and \ spawner_tile_y >= room_min_y and spawner_tile_y < room_max_y: spawner_in_room = true - print("RoomTrigger: Spawner is within room bounds!") + LogManager.log("RoomTrigger: Spawner is within room bounds!", LogManager.CATEGORY_DUNGEON) if spawner_in_room and not child in enemy_spawners: enemy_spawners.append(child) - print("RoomTrigger: ✓ Added spawner ", child.name, " to this room trigger") + LogManager.log("RoomTrigger: ✓ Added spawner " + str(child.name) + " to this room trigger", LogManager.CATEGORY_DUNGEON) elif spawner_in_room: - print("RoomTrigger: Spawner already in list, skipping") + LogManager.log("RoomTrigger: Spawner already in list, skipping", LogManager.CATEGORY_DUNGEON) else: - print("RoomTrigger: Spawner is NOT in this room, skipping") + LogManager.log("RoomTrigger: Spawner is NOT in this room, skipping", LogManager.CATEGORY_DUNGEON) - print("RoomTrigger: Total spawners found: ", found_spawners_count, ", spawners in this room: ", enemy_spawners.size()) + LogManager.log("RoomTrigger: Total spawners found: " + str(found_spawners_count) + ", spawners in this room: " + str(enemy_spawners.size()), LogManager.CATEGORY_DUNGEON) # Update debug label after finding entities call_deferred("_update_debug_label") @@ -430,13 +447,13 @@ func _create_debug_label(): # Only create if debug mode is enabled var network_manager = get_node_or_null("/root/NetworkManager") if not network_manager: - print("RoomTrigger: NetworkManager not found, skipping debug label") + LogManager.log("RoomTrigger: NetworkManager not found, skipping debug label", LogManager.CATEGORY_DUNGEON) return if not network_manager.show_room_labels: - print("RoomTrigger: Debug mode not enabled (show_room_labels = false), skipping debug label for room (", room.x, ", ", room.y, ")") + LogManager.log("RoomTrigger: Debug mode not enabled (show_room_labels = false), skipping debug label for room (" + str(room.x) + ", " + str(room.y) + ")", LogManager.CATEGORY_DUNGEON) return - print("RoomTrigger: Creating debug label for room (", room.x, ", ", room.y, ") - debug mode enabled") + LogManager.log("RoomTrigger: Creating debug label for room (" + str(room.x) + ", " + str(room.y) + ") - debug mode enabled", LogManager.CATEGORY_DUNGEON) # Skip if label already exists if debug_label and is_instance_valid(debug_label): diff --git a/src/scripts/sword_projectile.gd b/src/scripts/sword_projectile.gd index d5f0377..e78b27b 100644 --- a/src/scripts/sword_projectile.gd +++ b/src/scripts/sword_projectile.gd @@ -123,17 +123,30 @@ func _on_body_entered(body): # Hit successful - play impact sound and deal damage $SfxImpact.play() - var enemy_peer_id = body.get_multiplayer_authority() - if enemy_peer_id != 0: - # If enemy is on the same peer (server), call directly - if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): - body.rpc_take_damage(damage, attacker_pos, is_crit) + + # Use game_world to route damage request instead of direct RPC to avoid node path issues + var game_world = get_tree().get_first_node_in_group("game_world") + var enemy_name = body.name + var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1 + + if game_world and game_world.has_method("_request_enemy_damage"): + if multiplayer.is_server(): + # Server can call directly + game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, is_crit) else: - # Send RPC to enemy's authority (server) - clients can do this! - body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, is_crit) + # Client sends RPC to server + game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit) else: - # Fallback: broadcast if we can't get peer_id - body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) + # Fallback: try direct call (may fail if node path doesn't match) + var enemy_peer_id = body.get_multiplayer_authority() + if enemy_peer_id != 0: + if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): + body.rpc_take_damage(damage, attacker_pos, is_crit) + else: + body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, is_crit) + else: + body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) + # Debug print - handle null player_owner safely var owner_name: String = "none" var is_authority: bool = false @@ -149,16 +162,46 @@ func _on_body_entered(body): # Boxes have health property body.health -= damage if body.health <= 0: - # Break locally first + # Get object identifier - prefer consistent name (InteractableObject_X) or use object_index meta + var obj_name = body.name + var obj_index = -1 + + # Check if object has object_index meta (set when spawned in dungeon) + if body.has_meta("object_index"): + obj_index = body.get_meta("object_index") + # Use consistent naming if we have the index + if obj_index >= 0: + obj_name = "InteractableObject_%d" % obj_index + + # Fallback: if name doesn't start with "InteractableObject_", try to find consistent name + if not obj_name.begins_with("InteractableObject_") and obj_index < 0: + # Try to find object by position or other means + # For now, just use the name as-is and log a warning + print("Sword projectile: Warning - object ", body.name, " doesn't have consistent naming!") + + # Sync break to server (which will broadcast to all clients) + # Use game_world to route the RPC to avoid node path resolution issues + if multiplayer.has_multiplayer_peer(): + var game_world = get_tree().get_first_node_in_group("game_world") + # Only send RPC if game_world exists and is valid + if game_world and is_instance_valid(game_world) and game_world.is_inside_tree() and game_world.has_method("_sync_object_break"): + if multiplayer.is_server(): + # Server: broadcast to ready clients (breaks locally too via _sync_break) + if game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [obj_name]) + print("Sword projectile synced box break to all clients: ", obj_name) + else: + # Client: send request to server (server will break locally and broadcast to all) + # Also send object_index if available for fallback lookup + game_world._sync_object_break.rpc_id(1, obj_name) + print("Sword projectile requested box break on server: ", obj_name, " (index: ", obj_index, ")") + else: + print("Sword projectile: GameWorld not ready, skipping box break sync for ", obj_name) + + # Break locally AFTER syncing (so node still exists for RPC path resolution) if body.has_method("_break_into_pieces"): body._break_into_pieces() print("Sword projectile broke box locally: ", body.name) - - # Sync break to OTHER clients via RPC - if multiplayer.has_multiplayer_peer(): - if body.has_method("_sync_break"): - body._sync_break.rpc() - print("Sword projectile synced box break to other clients") print("Sword projectile hit object: ", body.name) # Push the hit target away slightly (only for non-enemies) diff --git a/src/webrtc/LICENSE.libdatachannel b/src/webrtc/LICENSE.libdatachannel new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/src/webrtc/LICENSE.libdatachannel @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/src/webrtc/LICENSE.libjuice b/src/webrtc/LICENSE.libjuice new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/src/webrtc/LICENSE.libjuice @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/src/webrtc/LICENSE.libsrtp b/src/webrtc/LICENSE.libsrtp new file mode 100644 index 0000000..af0a2ac --- /dev/null +++ b/src/webrtc/LICENSE.libsrtp @@ -0,0 +1,35 @@ +/* + * + * Copyright (c) 2001-2017 Cisco Systems, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * Neither the name of the Cisco Systems, Inc. nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ diff --git a/src/webrtc/LICENSE.mbedtls b/src/webrtc/LICENSE.mbedtls new file mode 100644 index 0000000..776ac77 --- /dev/null +++ b/src/webrtc/LICENSE.mbedtls @@ -0,0 +1,553 @@ +Mbed TLS files are provided under a dual [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) +OR [GPL-2.0-or-later](https://spdx.org/licenses/GPL-2.0-or-later.html) license. +This means that users may choose which of these licenses they take the code +under. + +The full text of each of these licenses is given below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +=============================================================================== + + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/src/webrtc/LICENSE.plog b/src/webrtc/LICENSE.plog new file mode 100644 index 0000000..8a91a0a --- /dev/null +++ b/src/webrtc/LICENSE.plog @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Sergey Podobry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/webrtc/LICENSE.usrsctp b/src/webrtc/LICENSE.usrsctp new file mode 100644 index 0000000..a2d1f98 --- /dev/null +++ b/src/webrtc/LICENSE.usrsctp @@ -0,0 +1,27 @@ +Copyright (c) 2015, Randall Stewart and Michael Tuexen +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of usrsctp nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/webrtc/LICENSE.webrtc-native b/src/webrtc/LICENSE.webrtc-native new file mode 100644 index 0000000..ec92632 --- /dev/null +++ b/src/webrtc/LICENSE.webrtc-native @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Godot Engine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/webrtc/lib/libwebrtc_native.android.template_debug.arm64.so b/src/webrtc/lib/libwebrtc_native.android.template_debug.arm64.so new file mode 100644 index 0000000..f9f2363 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.android.template_debug.arm64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.android.template_debug.x86_64.so b/src/webrtc/lib/libwebrtc_native.android.template_debug.x86_64.so new file mode 100644 index 0000000..224649d Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.android.template_debug.x86_64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.android.template_release.arm64.so b/src/webrtc/lib/libwebrtc_native.android.template_release.arm64.so new file mode 100644 index 0000000..b6e065d Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.android.template_release.arm64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.android.template_release.x86_64.so b/src/webrtc/lib/libwebrtc_native.android.template_release.x86_64.so new file mode 100644 index 0000000..8df24f4 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.android.template_release.x86_64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.ios.template_debug.arm64.dylib b/src/webrtc/lib/libwebrtc_native.ios.template_debug.arm64.dylib new file mode 100644 index 0000000..e4a4fa5 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.ios.template_debug.arm64.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.ios.template_debug.x86_64.simulator.dylib b/src/webrtc/lib/libwebrtc_native.ios.template_debug.x86_64.simulator.dylib new file mode 100644 index 0000000..b106bd1 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.ios.template_debug.x86_64.simulator.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.ios.template_release.arm64.dylib b/src/webrtc/lib/libwebrtc_native.ios.template_release.arm64.dylib new file mode 100644 index 0000000..1ba64d0 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.ios.template_release.arm64.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.ios.template_release.x86_64.simulator.dylib b/src/webrtc/lib/libwebrtc_native.ios.template_release.x86_64.simulator.dylib new file mode 100644 index 0000000..94c4106 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.ios.template_release.x86_64.simulator.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm32.so b/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm32.so new file mode 100644 index 0000000..05659d5 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm32.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm64.so b/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm64.so new file mode 100644 index 0000000..17acf11 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_debug.arm64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_32.so b/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_32.so new file mode 100644 index 0000000..76c5cbc Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_32.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_64.so b/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_64.so new file mode 100644 index 0000000..632eb47 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_debug.x86_64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_release.arm32.so b/src/webrtc/lib/libwebrtc_native.linux.template_release.arm32.so new file mode 100644 index 0000000..25055a0 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_release.arm32.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_release.arm64.so b/src/webrtc/lib/libwebrtc_native.linux.template_release.arm64.so new file mode 100644 index 0000000..260da96 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_release.arm64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_32.so b/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_32.so new file mode 100644 index 0000000..5e110af Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_32.so differ diff --git a/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_64.so b/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_64.so new file mode 100644 index 0000000..7d20909 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.linux.template_release.x86_64.so differ diff --git a/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/Resources/Info.plist b/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/Resources/Info.plist new file mode 100644 index 0000000..f4d8e8a --- /dev/null +++ b/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + libwebrtc_native.macos.template_debug.universal.dylib + CFBundleIdentifier + org.godotengine.webrtc-native + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + libwebrtc_native.macos.template_debug.universal + CFBundleName + webrtc_native + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 11.0 + + diff --git a/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/libwebrtc_native.macos.template_debug.universal.dylib b/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/libwebrtc_native.macos.template_debug.universal.dylib new file mode 100644 index 0000000..cf4b2b8 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.macos.template_debug.universal.framework/libwebrtc_native.macos.template_debug.universal.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/Resources/Info.plist b/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/Resources/Info.plist new file mode 100644 index 0000000..a0219e7 --- /dev/null +++ b/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + libwebrtc_native.macos.template_release.universal.dylib + CFBundleIdentifier + org.godotengine.webrtc-native + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + libwebrtc_native.macos.template_release.universal + CFBundleName + webrtc_native + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 11.0 + + diff --git a/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/libwebrtc_native.macos.template_release.universal.dylib b/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/libwebrtc_native.macos.template_release.universal.dylib new file mode 100644 index 0000000..b999822 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.macos.template_release.universal.framework/libwebrtc_native.macos.template_release.universal.dylib differ diff --git a/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_32.dll b/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_32.dll new file mode 100644 index 0000000..18ab8a2 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_32.dll differ diff --git a/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_64.dll b/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_64.dll new file mode 100644 index 0000000..7b7464a Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.windows.template_debug.x86_64.dll differ diff --git a/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_32.dll b/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_32.dll new file mode 100644 index 0000000..da77b12 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_32.dll differ diff --git a/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_64.dll b/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_64.dll new file mode 100644 index 0000000..6751ad4 Binary files /dev/null and b/src/webrtc/lib/libwebrtc_native.windows.template_release.x86_64.dll differ diff --git a/src/webrtc/lib/~libwebrtc_native.windows.template_debug.x86_64.dll b/src/webrtc/lib/~libwebrtc_native.windows.template_debug.x86_64.dll new file mode 100644 index 0000000..7b7464a Binary files /dev/null and b/src/webrtc/lib/~libwebrtc_native.windows.template_debug.x86_64.dll differ diff --git a/src/webrtc/webrtc.gdextension b/src/webrtc/webrtc.gdextension new file mode 100644 index 0000000..a112463 --- /dev/null +++ b/src/webrtc/webrtc.gdextension @@ -0,0 +1,30 @@ +[configuration] + +entry_symbol = "webrtc_extension_init" +compatibility_minimum = 4.1 + +[libraries] + +linux.debug.x86_64 = "lib/libwebrtc_native.linux.template_debug.x86_64.so" +linux.debug.x86_32 = "lib/libwebrtc_native.linux.template_debug.x86_32.so" +linux.debug.arm64 = "lib/libwebrtc_native.linux.template_debug.arm64.so" +linux.debug.arm32 = "lib/libwebrtc_native.linux.template_debug.arm32.so" +macos.debug = "lib/libwebrtc_native.macos.template_debug.universal.framework" +windows.debug.x86_64 = "lib/libwebrtc_native.windows.template_debug.x86_64.dll" +windows.debug.x86_32 = "lib/libwebrtc_native.windows.template_debug.x86_32.dll" +android.debug.arm64 = "lib/libwebrtc_native.android.template_debug.arm64.so" +android.debug.x86_64 = "lib/libwebrtc_native.android.template_debug.x86_64.so" +ios.debug.arm64 = "lib/libwebrtc_native.ios.template_debug.arm64.dylib" +ios.debug.x86_64 = "lib/libwebrtc_native.ios.template_debug.x86_64.simulator.dylib" + +linux.release.x86_64 = "lib/libwebrtc_native.linux.template_release.x86_64.so" +linux.release.x86_32 = "lib/libwebrtc_native.linux.template_release.x86_32.so" +linux.release.arm64 = "lib/libwebrtc_native.linux.template_release.arm64.so" +linux.release.arm32 = "lib/libwebrtc_native.linux.template_release.arm32.so" +macos.release = "lib/libwebrtc_native.macos.template_release.universal.framework" +windows.release.x86_64 = "lib/libwebrtc_native.windows.template_release.x86_64.dll" +windows.release.x86_32 = "lib/libwebrtc_native.windows.template_release.x86_32.dll" +android.release.arm64 = "lib/libwebrtc_native.android.template_release.arm64.so" +android.release.x86_64 = "lib/libwebrtc_native.android.template_release.x86_64.so" +ios.release.arm64 = "lib/libwebrtc_native.ios.template_release.arm64.dylib" +ios.release.x86_64 = "lib/libwebrtc_native.ios.template_release.x86_64.simulator.dylib" diff --git a/src/webrtc/webrtc.gdextension.uid b/src/webrtc/webrtc.gdextension.uid new file mode 100644 index 0000000..d5e732f --- /dev/null +++ b/src/webrtc/webrtc.gdextension.uid @@ -0,0 +1 @@ +uid://o5h08t2r11hh