commit 536cbff829032b442c8f1d1575d4f0dbee532e2c Author: Kroonk Date: Sat May 23 00:38:59 2026 +0200 Initial Brogether prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27ca5a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.tools/ +recovered/ +dist/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ca295c --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Brogether + +Brogether ist ein experimentelles Brotato-Mod-Projekt mit dem Ziel, echten Online-Multiplayer fuer 2 Spieler vorzubereiten. Brotato selbst kann lokal im Coop gespielt werden und ueber Steam Remote Play geteilt werden; Brogether erforscht stattdessen eine native Steam-Lobby- und P2P-Basis. + +Der aktuelle Stand ist bewusst ein technischer Prototyp. Es ist noch keine fertige Multiplayer-Mod, aber die wichtigsten Grundlagen sind vorbereitet: Mod-Laden, Steam-Lobby-Erstellung, Debug-Logging, lokale Pakettests und erste Hooks in den Brotato-Run. + +## Was aktuell funktioniert + +- Die Mod wird von Brotatos Godot Mod Loader geladen. +- Steam wird ueber den im Spiel vorhandenen Steam-Singleton angesprochen. +- Eine oeffentliche Dev-Steam-Lobby kann erstellt werden. +- Basis-Pakete fuer Handshake, Ping und Pong sind implementiert. +- Ein lokaler Loopback-Test simuliert Paketverkehr ohne zweiten Steam-Client. +- Brotatos Run-Szene wird per Script-Extension beobachtet. +- Run-Start, Spieler-Spawn und einfache Player-Snapshots werden geloggt bzw. fuer P2P-Versand vorbereitet. + +## Was noch nicht fertig ist + +- Ein echter zweiter Steam-Client wurde noch nicht getestet. +- Remote-Spieler werden noch nicht voll spielbar synchronisiert. +- Shop-, Item-, Gegner-, Wave- und RNG-Synchronisation fehlen noch. +- Die Snapshot-Anwendung auf einen zweiten lokalen Player ist nur experimentell vorbereitet. + +## Projektstruktur + +```text +src/mods-unpacked/Brogether-Brogether/ + manifest.json Mod-Metadaten fuer Godot Mod Loader + mod_main.gd Steam-Lobby, P2P-Pakete, Debug-Overlay, Logging + extensions/main_ext.gd Hook in Brotatos Run-Szene + +scripts/ + package.ps1 Baut das Mod-ZIP + install-local.ps1 Installiert das ZIP fuer lokale Brotato-Tests + +docs/ + setup.md Setup- und Testnotizen + brotato-multiplayer-modding-research.md +``` + +Nicht eingecheckt werden `.tools/`, `recovered/` und `dist/`. Diese Ordner enthalten lokale Tools, recovered Brotato-Dateien und gebaute ZIPs. + +## Lokaler Test + +Brotato laedt in der Steam-Version nur abonnierte Workshop-Ordner. Das Install-Skript legt das gebaute Brogether-ZIP deshalb fuer lokale Tests neben eine bereits abonnierte Brotato-Workshop-Mod. + +```powershell +.\scripts\install-local.ps1 +``` + +Danach Brotato komplett neu starten. + +## Debug-Tasten + +```text +F6 Steam-Lobby erstellen +F7 Lobby suchen / joinen +F9 Lobby verlassen +F10 Ping senden +F11 Lokalen Loopback-Test starten +``` + +Brogether schreibt ein eigenes Log nach: + +```text +C:\Users\tommi\AppData\Roaming\Brotato\brogether.log +``` + +## Naechster sinnvoller Test + +Sobald ein zweiter Steam-Client verfuegbar ist: + +1. Beide Clients mit derselben Brogether-Version starten. +2. Host drueckt `F6`. +3. Client drueckt `F7`. +4. Mit `F10` Ping/Pong testen. +5. Einen Run starten und pruefen, ob `run_started`, `players_spawned` und Snapshot-Pakete auf beiden Seiten im Log erscheinen. + +Wenn das stabil laeuft, ist der naechste Entwicklungsschritt die echte Remote-Spielersteuerung auf Basis der Snapshot- und Input-Pakete. diff --git a/docs/brotato-multiplayer-modding-research.md b/docs/brotato-multiplayer-modding-research.md new file mode 100644 index 0000000..2df947d --- /dev/null +++ b/docs/brotato-multiplayer-modding-research.md @@ -0,0 +1,87 @@ +# Brotato Multiplayer Mod Research + +Stand: 2026-05-22 + +## Kurzfazit + +Brotato-Mods laufen ueber den Godot Mod Loader und werden als ZIPs ueber Steam Workshop verteilt. Fuer eine echte Online-Multiplayer-Mod ist das Workshop-Paket nur der Verteilungskanal; die eigentliche Schwierigkeit ist die Netzwerksynchronisation des Spiels. + +Es gibt bereits eine Workshop-Mod namens "Brotatogether - Networked Multiplayer with Steam Lobbies". Sie nutzt Steam-basierte Lobbies, ist aber laut Workshop-Kommentaren und Beschreibung vor allem bei 3-4 Spielern und aktuellen DLC-/Patch-Staenden fehleranfaellig. Das ist trotzdem die wichtigste Referenz, weil sie beweist, dass Steam-Lobbies aus einer Brotato-Mod heraus grundsaetzlich machbar sind. + +## Was man braucht + +- Brotato auf Steam, weil die Steam-Version Workshop und ModLoader enthaelt. +- GodotSteam passend zur Brotato-Godot-Version. Der aktuelle Brotato-Modding-Guide verweist auf GodotSteam fuer Godot 3.6; aeltere Notizen nennen Godot 3.5. Die genaue Version sollte beim Decompile ueber GDRETools/Export-Log verifiziert werden. +- GDRETools, um das Brotato-Projekt lokal fuer Modding/Analyse zu recovern. +- Godot-Kenntnisse, insbesondere GDScript, Szenenbaum, Autoloads/Singletons und Resource-Dateien. +- Godot Mod Loader Wissen: `mods-unpacked`, `manifest.json`, `mod_main.gd`, Script Extensions, Hooks, Abhaengigkeiten. +- Steam-/GodotSteam-Netzwerkkenntnisse: Steam Matchmaking/Lobbies plus P2P/Networking Messages oder Sockets. +- Mindestens zwei Steam-Accounts/Installationen oder Testmaschinen fuer echte Netzwerk-Tests. + +## Mod-Struktur + +Entwicklung: + +```text +res:// +└── mods-unpacked + └── Brogether-Brogether + ├── manifest.json + ├── mod_main.gd + ├── extensions/ + └── translations/ +``` + +Workshop/Distribution: + +```text +Brogether-Brogether-0.1.0.zip/ +├── .import/ +└── mods-unpacked/ + └── Brogether-Brogether/ + ├── manifest.json + ├── mod_main.gd + └── extensions/ +``` + +## Multiplayer-Architektur + +Empfohlener erster Ansatz: Host-authoritative P2P ueber Steam. + +- Ein Spieler erstellt eine Steam Lobby. +- Andere Spieler joinen ueber Steam Lobby/Friends. +- Der Host ist die einzige Wahrheit fuer Run-State, Gegner, Drops, Shop-Entscheidungen und RNG. +- Clients senden Inputs/Entscheidungen an den Host. +- Host sendet regelmaessige Snapshots oder Events zurueck. + +Warum nicht sofort voll deterministisch/lockstep: Brotato hat viele zufalls- und framegetriebene Systeme. Ein kompletter Lockstep-Port waere wahrscheinlich spröde, solange wir nicht exakt wissen, welche Systeme deterministisch laufen. + +## MVP-Vorschlag + +1. Mod-Skeleton laedt in Brotato und zeigt einen Multiplayer-Button oder Debug-Hotkeys. +2. Steam-Lobby erstellen/joinen. +3. Text-/Ping-Nachrichten zwischen zwei Clients senden. +4. Run-Start zwischen zwei Clients synchronisieren. +5. Nur Spielerposition/Input synchronisieren, Gegner und Spielwelt bleiben host-controlled. +6. Shop-Phase synchronisieren. +7. Erst danach 3-4 Spieler, Reconnects, Crash Recovery und DLC-Kompatibilitaet. + +## Hauptrisiken + +- Brotato ist nicht fuer echten Online-Multiplayer gebaut; viele Systeme muessen per Hook/Extension kontrolliert werden. +- Steam Workshop erlaubt die Verteilung, gibt aber keine Multiplayer-API. Netzwerk laeuft ueber GodotSteam/Steamworks. +- Updates/DLC koennen Script-Extensions brechen. +- Save- und Cloud-Daten koennen bei instabilen Mods leiden; Tests sollten mit Backup-Saves laufen. +- 3-4 Spieler sind deutlich schwerer als 2 Spieler, weil Bandbreite, Shop-Sync, Disconnects und Edge Cases stark wachsen. + +## Wichtige Quellen + +- Brotato Wiki, Modding: https://brotato.wiki.spellsandguns.com/Modding +- Steam Community, Brotato Modding Guide: https://steamcommunity.com/sharedfiles/filedetails/?id=2931079751 +- Godot Mod Loader Wiki: https://wiki.godotmodding.com/ +- Godot Mod Loader, Mod Structure: https://wiki.godotmodding.com/guides/modding/mod_structure/ +- Godot Mod Loader, Mod Files: https://wiki.godotmodding.com/guides/modding/mod_files/ +- Godot Mod Loader, Workshop Uploader: https://wiki.godotmodding.com/guides/modding/tools/workshop_uploader/ +- Steamworks Networking Docs: https://partner.steamgames.com/doc/features/multiplayer/networking +- Brotatogether GitHub: https://github.com/boardengineer/Brotatogether +- Brotatogether Workshop: https://steamcommunity.com/workshop/filedetails/?id=2985539742 diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..1f10504 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,81 @@ +# Brogether Setup + +Stand: 2026-05-22 + +## Aktueller Zustand + +- Brotato Steam wurde lokal gefunden unter `C:\Program Files (x86)\Steam\steamapps\common\Brotato`. +- Die recovered Scripts melden Brotato `1.1.15.3` und ModLoader `6.3.0`. +- GDRETools wurde nach `.tools/gdretools` geladen. +- Godot Mod Loader v6.3.0 wurde nach `.tools/godot-mod-loader` geladen. +- Brotato- und DLC-Scripts wurden nach `recovered/` recovered. +- Das erste Mod-Skeleton liegt unter `src/mods-unpacked/Brogether-Brogether`. + +## Lokalen Test-Build installieren + +```powershell +.\scripts\install-local.ps1 +``` + +Das erzeugt `dist/Brogether-Brogether-0.1.0.zip` und kopiert es fuer lokale Tests in einen bereits abonnierten Brotato-Workshop-Ordner: + +```text +C:\Program Files (x86)\Steam\steamapps\workshop\content\1942280\ +``` + +Brotato laedt in der Steam-Version nur Workshop-Ordner, deren ID Steam als abonniert meldet. Darum wird das lokale ZIP fuer den ersten Test neben eine vorhandene abonnierte Workshop-Mod gelegt. Spaeter ersetzen wir das durch einen eigenen privaten/unlisted Brogether-Workshop-Eintrag. + +Danach Brotato ueber Steam starten. Die Mod sollte im Mod-Menue auftauchen. + +## Debug-Hotkeys im Spiel + +- `F6`: oeffentliche Dev-Steam-Lobby erstellen +- `F7`: Brogether-Lobby suchen und beitreten +- `F9`: Lobby verlassen +- `F10`: Ping-Paket an den anderen Spieler senden +- `F11`: lokalen Loopback-Test ohne zweiten Client ausfuehren + +Die Ausgabe landet im Godot/Brotato-Log. Das erste Ziel ist, auf zwei Steam-Clients folgende Meldungen zu sehen: + +- `Steam ready` +- `created lobby` +- `joined lobby` +- `packet from ... handshake` +- `packet from ... ping` +- `packet from ... pong` + +Ohne zweiten Steam-Client kann `F11` die Paketlogik lokal simulieren. Das ersetzt keinen echten Steam-P2P-Test, prueft aber, ob Handshake-, Ping- und Pong-Verarbeitung in Brogether funktionieren. + +Zusaetzlich schreibt Brogether ein eigenes Debug-Log nach: + +```text +C:\Users\tommi\AppData\Roaming\Brotato\brogether.log +``` + +## Naechster technischer Schritt + +Wenn der P2P-Handshake zwischen zwei Steam-Clients funktioniert, erweitern wir die Mod um: + +1. Hook in die Run-Erstellung. Erledigt: `extensions/main_ext.gd` meldet Run-Start. +2. Host/Client-Rollenmodell. Grundlegend vorhanden ueber Steam-Lobby-Owner. +3. Minimalen Player-Snapshot-Sync. Grundlegend vorhanden: Position, Leben, Dead-State alle 250 ms. +4. Erst danach Gegner-, Wave- und Shop-Sync. + +## Aktueller Multiplayer-Stand + +Brogether hat jetzt eine Script-Extension fuer `res://main.gd`. Beim Start einer Wave/Run schreibt sie: + +- `run scene ready` +- `players spawned` +- periodische `host_snapshot`/`client_snapshot` Pakete, wenn eine Lobby mit Peer existiert +- `run scene exiting` + +Die Snapshots enthalten derzeit nur das sichere Minimum: Spielerindex, Position, Leben, Max-Leben und Dead-State. Das ist noch kein spielbarer Remote-Player, aber die Transport- und Hook-Struktur ist damit vorbereitet. + +Es gibt intern eine experimentelle Funktion zum Vorbereiten lokaler 2-Spieler-Coop-Slots. Sie ist bewusst nicht mehr an einen Hotkey gebunden, weil sie Brotatos lokale Coop-UI aktiviert und im normalen Menue stoeren kann. Wenn ein 2-Spieler-Run existiert, versucht Brogether eingehende Remote-Snapshots auf den zweiten lokalen Player anzuwenden. + +## Editor-Hinweis + +Fuer den ersten In-Game-Test ist noch kein Godot-Editor noetig: die Mod wird als ZIP gebaut und direkt von Brotato geladen. + +Fuer spaetere Hook-Arbeit ist ein GodotSteam-Editor sinnvoll. Die aktuellen GodotSteam-3.x-Releases liefern auf GitHub/Codeberg vor allem Templates; wenn wir den Editor brauchen, sollten wir gezielt eine zu Brotatos recovered Engine-Version passende GodotSteam-Binary verwenden oder selbst bauen. Vanilla-Godot ist fuer das recovered Brotato-Projekt problematisch, weil der `Steam`-Singleton fehlt. diff --git a/scripts/install-local.ps1 b/scripts/install-local.ps1 new file mode 100644 index 0000000..9e6bb05 --- /dev/null +++ b/scripts/install-local.ps1 @@ -0,0 +1,41 @@ +param( + [string]$BrotatoDir = "C:\Program Files (x86)\Steam\steamapps\common\Brotato", + [string]$WorkshopItemId = "", + [string]$Version = "0.1.0" +) + +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$packageScript = Join-Path $PSScriptRoot "package.ps1" +$zipPath = Join-Path $root "dist\Brogether-Brogether-$Version.zip" +$legacyModsDir = Join-Path $BrotatoDir "mods" +$steamAppsDir = Split-Path -Parent (Split-Path -Parent $BrotatoDir) +$workshopContentDir = Join-Path $steamAppsDir "workshop\content\1942280" + +& $packageScript -Version $Version + +if (-not (Test-Path $workshopContentDir)) { + throw "Workshop content folder not found: $workshopContentDir" +} + +if ([string]::IsNullOrWhiteSpace($WorkshopItemId)) { + $workshopItem = Get-ChildItem -Path $workshopContentDir -Directory | + Where-Object { $_.Name -match '^\d+$' } | + Select-Object -First 1 + + if ($null -eq $workshopItem) { + throw "No subscribed Brotato workshop folder found in $workshopContentDir. Subscribe to any Brotato workshop mod once, then rerun this script." + } +} else { + $workshopItem = Get-Item -LiteralPath (Join-Path $workshopContentDir $WorkshopItemId) -ErrorAction Stop +} + +Get-ChildItem -Path $workshopContentDir -Recurse -Filter "Brogether-Brogether-*.zip" -ErrorAction SilentlyContinue | Remove-Item -Force +Get-ChildItem -Path $workshopContentDir -Recurse -Filter "PotatoConnect-PotatoConnect-*.zip" -ErrorAction SilentlyContinue | Remove-Item -Force +Get-ChildItem -Path $legacyModsDir -Filter "Brogether-Brogether-*.zip" -ErrorAction SilentlyContinue | Remove-Item -Force +Get-ChildItem -Path $legacyModsDir -Filter "PotatoConnect-PotatoConnect-*.zip" -ErrorAction SilentlyContinue | Remove-Item -Force +Copy-Item -LiteralPath $zipPath -Destination (Join-Path $workshopItem.FullName (Split-Path -Leaf $zipPath)) -Force + +Write-Host "Installed to workshop item folder $($workshopItem.FullName)" +Write-Host "Start Brotato from Steam, then use F6 on host and F7 on client." diff --git a/scripts/package.ps1 b/scripts/package.ps1 new file mode 100644 index 0000000..0ef08be --- /dev/null +++ b/scripts/package.ps1 @@ -0,0 +1,40 @@ +param( + [string]$Version = "0.1.0" +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$root = Split-Path -Parent $PSScriptRoot +$source = Join-Path $root "src" +$dist = Join-Path $root "dist" +$zipPath = Join-Path $dist "Brogether-Brogether-$Version.zip" + +if (-not (Test-Path $source)) { + throw "Missing source folder: $source" +} + +New-Item -ItemType Directory -Force -Path $dist | Out-Null +if (Test-Path $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} + +$archive = [System.IO.Compression.ZipFile]::Open($zipPath, [System.IO.Compression.ZipArchiveMode]::Create) +try { + $sourceRoot = (Resolve-Path $source).Path.TrimEnd('\', '/') + Get-ChildItem -Path $sourceRoot -Recurse -File | ForEach-Object { + $relativePath = $_.FullName.Substring($sourceRoot.Length + 1).Replace('\', '/') + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, + $_.FullName, + $relativePath, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } +} +finally { + $archive.Dispose() +} + +Write-Host "Created $zipPath" diff --git a/src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd b/src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd new file mode 100644 index 0000000..a68d9dc --- /dev/null +++ b/src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd @@ -0,0 +1,44 @@ +extends "res://main.gd" + +var _brogether_snapshot_elapsed := 0.0 + + +func _ready() -> void: + ._ready() + var brogether = _get_brogether() + if brogether != null: + brogether.on_run_scene_ready(self) + + +func _physics_process(delta: float) -> void: + ._physics_process(delta) + _brogether_snapshot_elapsed += delta + if _brogether_snapshot_elapsed < 0.25: + return + _brogether_snapshot_elapsed = 0.0 + var brogether = _get_brogether() + if brogether != null: + brogether.on_run_snapshot_tick(_players) + + +func _on_EntitySpawner_players_spawned(players: Array) -> void: + ._on_EntitySpawner_players_spawned(players) + var brogether = _get_brogether() + if brogether != null: + brogether.on_run_players_spawned(players) + + +func _exit_tree() -> void: + var brogether = _get_brogether() + if brogether != null: + brogether.on_run_scene_exiting() + + +func _get_brogether(): + var root := get_tree().get_root() + if root == null: + return null + var mod_loader := root.get_node_or_null("ModLoader") + if mod_loader == null: + return null + return mod_loader.get_node_or_null("Brogether-Brogether") diff --git a/src/mods-unpacked/Brogether-Brogether/manifest.json b/src/mods-unpacked/Brogether-Brogether/manifest.json new file mode 100644 index 0000000..b8b35ad --- /dev/null +++ b/src/mods-unpacked/Brogether-Brogether/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Brogether", + "namespace": "Brogether", + "version_number": "0.1.0", + "description": "Experimental 2-player online multiplayer foundation for Brotato using Steam lobbies and P2P packets.", + "website_url": "https://example.com", + "dependencies": [], + "extra": { + "godot": { + "authors": ["tommi"], + "compatible_mod_loader_version": ["6.3.0"], + "compatible_game_version": ["1.1.15.3"], + "godot_mod": true, + "incompatibilities": [], + "tags": ["Utilities", "New Mechanics"] + } + } +} diff --git a/src/mods-unpacked/Brogether-Brogether/mod_main.gd b/src/mods-unpacked/Brogether-Brogether/mod_main.gd new file mode 100644 index 0000000..800c806 --- /dev/null +++ b/src/mods-unpacked/Brogether-Brogether/mod_main.gd @@ -0,0 +1,472 @@ +extends Node + +const MOD_ID := "Brogether-Brogether" +const LOG_PATH := "user://brogether.log" + +var steam = null +var lobby_id := 0 +var lobby_members := [] +var steam_id := 0 +var is_host := false +var packet_sequence := 0 +var status_layer: CanvasLayer = null +var status_label: Label = null +var status_time_left := 0.0 +var current_run_id := "" +var current_run_active := false +var current_players := [] +var snapshot_sequence := 0 + + +func _init() -> void: + ModLoaderMod.install_script_extension("res://mods-unpacked/Brogether-Brogether/extensions/main_ext.gd") + +func _ready() -> void: + name = MOD_ID + pause_mode = Node.PAUSE_MODE_PROCESS + set_process(true) + set_process_input(true) + _clear_log() + _create_status_overlay() + _init_steam() + _log("loaded. F6=create lobby, F7=find/join lobby, F9=leave, F10=ping, F11=loopback test") + + +func _process(_delta: float) -> void: + if status_time_left > 0.0: + status_time_left -= _delta + if status_time_left <= 0.0 and status_label != null: + status_label.visible = false + if steam == null: + return + if steam.has_method("run_callbacks"): + steam.call("run_callbacks") + if lobby_id != 0: + _read_all_p2p_packets() + + +func _input(event: InputEvent) -> void: + if not event is InputEventKey or not event.pressed or event.echo: + return + match event.scancode: + KEY_F6: + create_lobby() + get_tree().set_input_as_handled() + KEY_F7: + find_lobbies() + get_tree().set_input_as_handled() + KEY_F9: + leave_lobby() + get_tree().set_input_as_handled() + KEY_F10: + send_ping() + get_tree().set_input_as_handled() + KEY_F11: + run_loopback_test() + get_tree().set_input_as_handled() + + +func create_lobby() -> void: + if steam == null: + _log("cannot create lobby: Steam singleton missing") + return + if lobby_id != 0: + _log("already in lobby %s" % lobby_id) + return + var lobby_type := 2 + _log("creating public dev lobby") + steam.call("createLobby", lobby_type, 2) + + +func find_lobbies() -> void: + if steam == null: + _log("cannot find lobbies: Steam singleton missing") + return + if steam.has_method("addRequestLobbyListStringFilter"): + steam.call("addRequestLobbyListStringFilter", "brogether", "1", 0) + if steam.has_method("addRequestLobbyListDistanceFilter"): + steam.call("addRequestLobbyListDistanceFilter", 2) + _log("requesting lobby list") + steam.call("requestLobbyList") + + +func join_lobby(target_lobby_id: int) -> void: + if steam == null: + return + if lobby_id != 0: + leave_lobby() + _log("joining lobby %s" % target_lobby_id) + steam.call("joinLobby", target_lobby_id) + + +func leave_lobby() -> void: + if steam == null or lobby_id == 0: + return + _log("leaving lobby %s" % lobby_id) + if steam.has_method("leaveLobby"): + steam.call("leaveLobby", lobby_id) + lobby_id = 0 + lobby_members.clear() + is_host = false + + +func send_ping() -> void: + if lobby_id == 0: + _log("not in a lobby yet") + return + _send_p2p_packet(0, { + "type": "ping", + "sequence": packet_sequence, + "from": steam_id, + "time": OS.get_ticks_msec() + }) + packet_sequence += 1 + + +func run_loopback_test() -> void: + var fake_remote_id := 48000000000000001 + _log("starting local loopback test") + _handle_packet(fake_remote_id, { + "type": "handshake", + "from": fake_remote_id + }, true) + _handle_packet(fake_remote_id, { + "type": "ping", + "sequence": packet_sequence, + "from": fake_remote_id, + "time": OS.get_ticks_msec() + }, true) + packet_sequence += 1 + _log("local loopback test finished") + + +func on_run_scene_ready(main_node: Node) -> void: + current_run_active = true + current_run_id = "%s-%s-%s" % [steam_id, OS.get_unix_time(), RunData.current_wave] + var run_state := _build_run_state() + _log("run scene ready: %s" % run_state) + _send_p2p_packet(0, { + "type": "run_started", + "run_id": current_run_id, + "from": steam_id, + "state": run_state + }) + + +func on_run_players_spawned(players: Array) -> void: + current_players = players + _log("players spawned: %s" % _build_players_snapshot(players)) + _send_p2p_packet(0, { + "type": "players_spawned", + "run_id": current_run_id, + "from": steam_id, + "players": _build_players_snapshot(players) + }) + + +func on_run_snapshot_tick(players: Array) -> void: + if not current_run_active: + return + var snapshot := _build_players_snapshot(players) + if snapshot.empty(): + return + var packet := { + "type": "host_snapshot" if is_host else "client_snapshot", + "run_id": current_run_id, + "sequence": snapshot_sequence, + "from": steam_id, + "wave": RunData.current_wave, + "players": snapshot + } + snapshot_sequence += 1 + _send_p2p_packet(0, packet) + + +func on_run_scene_exiting() -> void: + if current_run_active: + _log("run scene exiting: %s" % current_run_id) + current_run_active = false + current_run_id = "" + current_players.clear() + snapshot_sequence = 0 + + +func prepare_online_coop_slots() -> void: + _log("preparing experimental 2-player online coop slots; this intentionally enters Brotato local coop UI") + RunData.play_mode = RunData.PlayMode.COOP + RunData.set_coop_run(true) + RunData.set_player_count(2) + CoopService.clear_coop_players() + CoopService._add_player(CoopService.KEYBOARD_REMAPPED_DEVICE_ID, CoopService.PlayerType.KEYBOARD_AND_MOUSE) + CoopService._add_player(CoopService.FIRST_DEBUG_DEVICE_ID, CoopService.PlayerType.KEYBOARD_AND_MOUSE) + _log("coop slots ready: RunData players=%s, CoopService players=%s" % [RunData.get_player_count(), CoopService.connected_players]) + + +func _init_steam() -> void: + if not Engine.has_singleton("Steam"): + _log("Steam singleton not available") + return + steam = Engine.get_singleton("Steam") + if steam.has_method("getSteamID"): + steam_id = int(steam.call("getSteamID")) + _connect_steam_signal("lobby_created", "_on_lobby_created") + _connect_steam_signal("lobby_joined", "_on_lobby_joined") + _connect_steam_signal("lobby_match_list", "_on_lobby_match_list") + _connect_steam_signal("lobby_chat_update", "_on_lobby_chat_update") + _connect_steam_signal("p2p_session_request", "_on_p2p_session_request") + _connect_steam_signal("p2p_session_connect_fail", "_on_p2p_session_connect_fail") + if steam.has_method("allowP2PPacketRelay"): + steam.call("allowP2PPacketRelay", true) + _log("Steam ready as %s" % steam_id) + + +func _connect_steam_signal(signal_name: String, method_name: String) -> void: + if steam == null: + return + if steam.has_signal(signal_name) and not steam.is_connected(signal_name, self, method_name): + steam.connect(signal_name, self, method_name) + + +func _on_lobby_created(connect: int, created_lobby_id: int) -> void: + if connect != 1: + _log("lobby creation failed: %s" % connect) + return + lobby_id = created_lobby_id + is_host = true + _log("created lobby %s" % lobby_id) + steam.call("setLobbyJoinable", lobby_id, true) + steam.call("setLobbyData", lobby_id, "name", "Brogether") + steam.call("setLobbyData", lobby_id, "brogether", "1") + steam.call("setLobbyData", lobby_id, "version", "0.1.0") + _refresh_lobby_members() + + +func _on_lobby_joined(joined_lobby_id: int, _permissions: int, _locked: bool, response: int) -> void: + var success := response == 1 + if not success: + _log("join failed for lobby %s: %s" % [joined_lobby_id, response]) + return + lobby_id = joined_lobby_id + _refresh_lobby_members() + is_host = steam.has_method("getLobbyOwner") and int(steam.call("getLobbyOwner", lobby_id)) == steam_id + _log("joined lobby %s as %s with %s member(s)" % [lobby_id, "host" if is_host else "client", lobby_members.size()]) + _send_p2p_packet(0, {"type": "handshake", "from": steam_id}) + + +func _on_lobby_match_list(lobbies: Array) -> void: + _log("found %s lobby candidate(s)" % lobbies.size()) + for candidate in lobbies: + var candidate_id := int(candidate) + if steam.has_method("getLobbyData") and steam.call("getLobbyData", candidate_id, "brogether") == "1": + join_lobby(candidate_id) + return + _log("no Brogether lobby found") + + +func _on_lobby_chat_update(this_lobby_id: int, _changed_id: int, _making_change_id: int, _chat_state: int) -> void: + if this_lobby_id != lobby_id: + return + _refresh_lobby_members() + _send_p2p_packet(0, {"type": "handshake", "from": steam_id}) + + +func _on_p2p_session_request(remote_id: int) -> void: + _log("accepting P2P session from %s" % remote_id) + steam.call("acceptP2PSessionWithUser", remote_id) + _send_p2p_packet(remote_id, {"type": "handshake_ack", "from": steam_id}) + + +func _on_p2p_session_connect_fail(remote_id: int, session_error: int) -> void: + _log("P2P session failed with %s: error %s" % [remote_id, session_error]) + + +func _refresh_lobby_members() -> void: + lobby_members.clear() + if lobby_id == 0 or steam == null or not steam.has_method("getNumLobbyMembers"): + return + var member_count := int(steam.call("getNumLobbyMembers", lobby_id)) + for index in range(member_count): + var member_id := int(steam.call("getLobbyMemberByIndex", lobby_id, index)) + var persona := str(member_id) + if steam.has_method("getFriendPersonaName"): + persona = steam.call("getFriendPersonaName", member_id) + lobby_members.append({"steam_id": member_id, "name": persona}) + _log("members: %s" % lobby_members) + + +func _read_all_p2p_packets(read_count: int = 0) -> void: + if read_count >= 32 or steam == null or not steam.has_method("getAvailableP2PPacketSize"): + return + var packet_size := int(steam.call("getAvailableP2PPacketSize", 0)) + if packet_size <= 0: + return + _read_p2p_packet(packet_size) + _read_all_p2p_packets(read_count + 1) + + +func _read_p2p_packet(packet_size: int) -> void: + var packet: Dictionary = steam.call("readP2PPacket", packet_size, 0) + if packet.empty() or not packet.has("data"): + _log("read empty packet") + return + var sender := int(packet.get("remote_steam_id", 0)) + var data = bytes2var(packet["data"]) + _handle_packet(sender, data) + + +func _handle_packet(sender: int, data, is_loopback: bool = false) -> void: + _log("packet from %s: %s" % [sender, data]) + if typeof(data) != TYPE_DICTIONARY: + return + match data.get("type", ""): + "handshake": + _log("handshake received from %s" % sender) + "handshake_ack": + _log("handshake acknowledged by %s" % sender) + "ping": + _log("ping received from %s, sending pong" % sender) + if is_loopback: + _handle_packet(sender, { + "type": "pong", + "sequence": data.get("sequence", -1), + "from": steam_id, + "time": OS.get_ticks_msec() + }, true) + return + _send_p2p_packet(sender, { + "type": "pong", + "sequence": data.get("sequence", -1), + "from": steam_id, + "time": OS.get_ticks_msec() + }) + "pong": + _log("pong received from %s for sequence %s" % [sender, data.get("sequence", -1)]) + "run_started": + _log("remote run started from %s: %s" % [sender, data.get("state", {})]) + "players_spawned": + _log("remote players spawned from %s: %s" % [sender, data.get("players", [])]) + "host_snapshot": + _log("host snapshot %s from %s: %s player(s)" % [data.get("sequence", -1), sender, data.get("players", []).size()]) + _apply_remote_snapshot(sender, data) + "client_snapshot": + _log("client snapshot %s from %s: %s player(s)" % [data.get("sequence", -1), sender, data.get("players", []).size()]) + _apply_remote_snapshot(sender, data) + _: + _log("unknown packet type from %s" % sender) + + +func _send_p2p_packet(target: int, packet_data: Dictionary) -> void: + if steam == null or lobby_id == 0: + return + var send_type := 2 + var payload := PoolByteArray() + payload.append_array(var2bytes(packet_data)) + if target == 0: + for member in lobby_members: + var member_id := int(member["steam_id"]) + if member_id != steam_id: + steam.call("sendP2PPacket", member_id, payload, send_type, 0) + else: + steam.call("sendP2PPacket", target, payload, send_type, 0) + + +func _log(message: String) -> void: + var line := "[Brogether] %s" % message + print(line) + _show_status(message) + var file := File.new() + var error := file.open(LOG_PATH, File.READ_WRITE) + if error != OK: + error = file.open(LOG_PATH, File.WRITE) + if error == OK: + file.seek_end() + file.store_line(line) + file.close() + + +func _clear_log() -> void: + var file := File.new() + if file.open(LOG_PATH, File.WRITE) == OK: + file.store_line("[Brogether] log started") + file.close() + + +func _create_status_overlay() -> void: + status_layer = CanvasLayer.new() + status_layer.name = "BrogetherStatusLayer" + status_layer.pause_mode = Node.PAUSE_MODE_PROCESS + add_child(status_layer) + + status_label = Label.new() + status_label.name = "BrogetherStatusLabel" + status_label.anchor_left = 0.0 + status_label.anchor_top = 0.0 + status_label.margin_left = 16 + status_label.margin_top = 16 + status_label.rect_min_size = Vector2(620, 32) + status_label.add_color_override("font_color", Color(0.25, 1.0, 0.65, 1.0)) + status_label.add_color_override("font_color_shadow", Color(0, 0, 0, 0.85)) + status_label.add_constant_override("shadow_offset_x", 2) + status_label.add_constant_override("shadow_offset_y", 2) + status_label.visible = false + status_layer.add_child(status_label) + + +func _show_status(message: String) -> void: + if status_label == null: + return + status_label.text = "Brogether: " + message + status_label.visible = true + status_time_left = 5.0 + + +func _build_run_state() -> Dictionary: + return { + "run_id": current_run_id, + "wave": RunData.current_wave, + "zone": RunData.current_zone, + "player_count": RunData.get_player_count(), + "is_coop_run": RunData.is_coop_run, + "is_host": is_host, + "lobby_id": str(lobby_id) + } + + +func _build_players_snapshot(players: Array) -> Array: + var snapshot := [] + for player in players: + if not is_instance_valid(player): + continue + var health := -1 + var max_health := -1 + if "current_stats" in player and player.current_stats != null: + health = int(player.current_stats.health) + if "max_stats" in player and player.max_stats != null: + max_health = int(player.max_stats.health) + snapshot.append({ + "player_index": int(player.player_index) if "player_index" in player else snapshot.size(), + "x": float(player.global_position.x), + "y": float(player.global_position.y), + "health": health, + "max_health": max_health, + "dead": bool(player.dead) if "dead" in player else false + }) + return snapshot + + +func _apply_remote_snapshot(sender: int, data: Dictionary) -> void: + if current_players.size() < 2: + _log("remote snapshot from %s ignored: no second local player exists" % sender) + return + var players: Array = data.get("players", []) + if players.empty(): + return + var remote_player: Dictionary = players[0] + var target_index: int = 1 + if target_index >= current_players.size() or not is_instance_valid(current_players[target_index]): + return + var target = current_players[target_index] + target.global_position = Vector2(float(remote_player.get("x", target.global_position.x)), float(remote_player.get("y", target.global_position.y))) + if "current_stats" in target and target.current_stats != null and remote_player.has("health"): + target.current_stats.health = int(remote_player.health) + _log("applied remote snapshot from %s to local player %s" % [sender, target_index])