Initial Brogether prototype
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.tools/
|
||||||
|
recovered/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
79
README.md
Normal file
79
README.md
Normal 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.
|
||||||
87
docs/brotato-multiplayer-modding-research.md
Normal file
87
docs/brotato-multiplayer-modding-research.md
Normal 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
81
docs/setup.md
Normal 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
41
scripts/install-local.ps1
Normal 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
40
scripts/package.ps1
Normal 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"
|
||||||
44
src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd
Normal file
44
src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd
Normal 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")
|
||||||
18
src/mods-unpacked/Brogether-Brogether/manifest.json
Normal file
18
src/mods-unpacked/Brogether-Brogether/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
472
src/mods-unpacked/Brogether-Brogether/mod_main.gd
Normal file
472
src/mods-unpacked/Brogether-Brogether/mod_main.gd
Normal 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])
|
||||||
Reference in New Issue
Block a user