Initial Brogether prototype

This commit is contained in:
Kroonk
2026-05-23 00:38:59 +02:00
commit 536cbff829
9 changed files with 866 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.tools/
recovered/
dist/
*.log

79
README.md Normal file
View File

@@ -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.

View File

@@ -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

81
docs/setup.md Normal file
View File

@@ -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\<workshop-id>
```
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.

41
scripts/install-local.ps1 Normal file
View File

@@ -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."

40
scripts/package.ps1 Normal file
View File

@@ -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"

View File

@@ -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")

View File

@@ -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"]
}
}
}

View File

@@ -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])