This commit is contained in:
team3
2026-09-07 18:07:53 +02:00
parent 2a9ab4c7da
commit 778498d512
9 changed files with 245 additions and 390 deletions

2
.gitignore vendored
View File

@@ -1,3 +1 @@
dist/
server/.venv/
server/__pycache__/

View File

@@ -1,24 +1,30 @@
FROM python:3.12-slim
# Stage 1: Godot holen. Die Version steht hier, damit auf dem Server dieselbe
# läuft wie beim Entwickeln.
FROM debian:bookworm-slim AS godot
ARG GODOT_VERSION=4.7.2
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip \
&& curl -fsSL -o /tmp/godot.zip \
"https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
&& unzip -q /tmp/godot.zip -d /tmp \
&& mv "/tmp/Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \
&& chmod +x /usr/local/bin/godot \
&& rm -rf /tmp/godot.zip /var/lib/apt/lists/*
# Stage 2: der Spielserver. Headless braucht kein X11 und kein OpenGL, Godot
# lädt die Bibliotheken erst bei Bedarf nach.
FROM debian:bookworm-slim
WORKDIR /app
COPY server/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Godot legt `make godot` in den Build-Kontext. Damit passt die Version auf
# dem Server exakt zu der, mit der das Spiel entwickelt wurde.
COPY dist/godot /usr/local/bin/godot
COPY --from=godot /usr/local/bin/godot /usr/local/bin/godot
COPY project.godot ./spiel/
COPY scenes ./spiel/scenes
COPY scripts ./spiel/scripts
COPY server/matchmaker.py ./
# Einmal beim Bauen importieren, sonst tut das der erste Raum - und der
# Spieler wartet.
RUN godot --headless --path /app/spiel --import
ENV GODOT=/usr/local/bin/godot
ENV SPIEL_PFAD=/app/spiel
EXPOSE 8000
CMD ["uvicorn", "matchmaker:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["godot", "--headless", "--path", "/app/spiel", "--", "server", "8000"]

View File

@@ -1,31 +1,20 @@
.PHONY: dev server matchmaker godot prod logs down
.PHONY: dev server prod logs down
GODOT ?= $(HOME)/bin/godot
# ── Entwickeln ───────────────────────────────────────────────────────────────
# Spiel gegen den lokalen Matchmaker starten. Ohne die Variable zeigt es auf
# pirate-dice.marha.de.
dev:
PIRATE_DICE_SERVER=http://127.0.0.1:8020 $(GODOT) --path .
# Matchmaker lokal, legt Räume als eigene Prozesse an
matchmaker:
cd server && python3 -m venv .venv 2>/dev/null || true
server/.venv/bin/pip install -q -r server/requirements.txt
GODOT=$(GODOT) SPIEL_PFAD=$(PWD) SPIEL_HOST=127.0.0.1 \
server/.venv/bin/uvicorn --app-dir server matchmaker:app --port 8020
# Einzelnen Raum von Hand aufmachen, ohne Matchmaker
# Spielserver lokal. Ein Prozess trägt alle Räume.
server:
$(GODOT) --headless --path . -- server 8910
$(GODOT) --headless --path . -- server 8000
# ── Produktion (auf dem Server ausführen) ────────────────────────────────────
# Das Binary kommt aus dem Build-Kontext, damit die Version stimmt.
godot:
mkdir -p dist
cp $(GODOT) dist/godot
# Spiel gegen den lokalen Server. Ohne die Variable zeigt es auf
# wss://pirate-dice.marha.de.
dev:
PIRATE_DICE_SERVER=ws://127.0.0.1:8000 $(GODOT) --path .
prod: godot
# ── Produktion ───────────────────────────────────────────────────────────────
# Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst.
prod:
docker compose up -d --build
@echo "läuft: https://pirate-dice.marha.de"

152
README.md
View File

@@ -22,146 +22,56 @@ Das Startmenü hat drei Punkte:
## Mehrspieler: wie es zusammenhängt
Die Partie läuft nicht mehr beim Host, sondern auf `pirate-dice.marha.de`:
Die Partie läuft nicht beim Host, sondern auf `pirate-dice.marha.de`:
Spieler ──HTTPS──> Matchmaker ──startet──> Raum (godot --headless, UDP-Port)
Spieler ──UDP────> Raum
Spieler ──wss://443──> ein Serverprozess ──> Raum MQG4 (SubViewport + World3D)
└──> Raum 4YPV (SubViewport + World3D)
* Der **Matchmaker** (`server/matchmaker.py`) vergibt Codes wie `K7QM` und
startet je Raum einen eigenen headless Godot-Prozess auf einem Port aus
8911–8940. `POST /api/raum` legt an, `GET /api/raum/<code>` schlägt nach.
* Ein **Raum** ist Peer 1 und damit die Autorität, sitzt aber nicht mit am
Tisch (`local_seat` bleibt −1, kein HUD). Ohne Spieler beendet er sich nach
zwei Minuten von selbst, dann ist der Code wieder frei.
* Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste
nach. Nur er darf starten (`Net.request_start`).
* **Ein Prozess, ein Port.** Der Server trägt alle Räume gleichzeitig und
spricht WebSocket. Traefik reicht das auf 443 durch, es muss kein Port
freigegeben werden.
* **Ein Raum** ist ein Knoten unter `/root/Main/Raeume/<CODE>`. Darin ein
SubViewport mit `own_world_3d` – nur so hat jede Partie ihren eigenen
Physikraum, sonst lägen alle Würfel im selben.
* Die Knotennamen sind auf Server und Client gleich. Daran hängt das Ziel
jedes RPC, deshalb dürfen sie nicht auseinanderlaufen.
* Broadcasts (`rpc`) würden alle Räume treffen. Deshalb geht alles gezielt per
`rpc_id` an die Peers des eigenen Raums.
* Der Server sitzt nicht mit am Tisch: `local_seat` bleibt −1, kein HUD.
* Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste nach.
Nur er stellt die Bots ein und startet.
* Der letzte macht das Licht aus: verlässt der letzte Spieler den Raum, wird er
samt Code sofort abgeräumt.
Gemessen je Raum: 51 MB Speicher zusätzlich, ~1 % eines Kerns im Leerlauf,
kurze Spitzen beim Würfelwurf.
Gemessen mit sechs gleichzeitigen Partien zu acht Plätzen: **3,7 MB je Raum**,
im Leerlauf unter 1 % eines Kerns, kurze Spitzen beim Würfelwurf.
## Lokal entwickeln
make matchmaker # Matchmaker auf :8020, legt Räume als Prozesse an
make dev # Spiel, zeigt auf den lokalen Matchmaker
make server # Spielserver auf :8000, trägt alle Räume
make dev # Spiel, zeigt auf den lokalen Server
Ohne `PIRATE_DICE_SERVER` zeigt das Spiel auf `pirate-dice.marha.de`. Einen
einzelnen Raum ohne Matchmaker gibt es mit `make server` (Port 8910).
Ohne `PIRATE_DICE_SERVER` zeigt das Spiel auf `wss://pirate-dice.marha.de`.
## Auf dem Server
make prod # kopiert das Godot-Binary, baut, startet
git pull
make prod # baut und startet, holt Godot selbst
make logs
make down
Die HTTP-Schnittstelle läuft hinter Traefik auf `pirate-dice.marha.de`. Die
Raum-Ports 8911–8940/UDP gehen direkt nach aussen – Traefik kann kein UDP.
Mehr ist nicht nötig. Der Docker-Build lädt Godot 4.7.2 herunter (Version steht
im `Dockerfile`), importiert das Projekt einmal vor und startet den Server.
`restart: unless-stopped` bringt ihn nach einem Neustart von selbst wieder hoch.
Das Spiel startet im Vollbild, **F11** schaltet um. **Esc** öffnet ein Menü mit
*Fortsetzen* und *Verlassen*; im Einzelspieler hält es das Spiel samt Zeitlimit
wirklich an, im Mehrspieler läuft die Runde weiter – sonst würde ein geöffnetes
Menü alle anderen am Tisch blockieren.
Die Bildaufteilung: Tisch mittig, darauf die letzte Wette als so viele Würfel
wie angesagt; aktive Booster oben links, Würfel aller Spieler oben rechts
(Name mit den Würfeln darunter) – beide Panels nur so breit wie ihr Inhalt –,
und die eigene Ansage als schmale Leiste unten in der Mitte.
Augenzahlen werden überall als gezeichnete Würfel dargestellt (`DieIcon` in
`hud.gd`), unbekannte Werte als Würfel mit Fragezeichen. Beim Aufdecken färbt
sich die Wette in der Tischmitte ein: grün die Würfel, die es wirklich gab,
rot die fehlenden. Am Tisch trägt jeder Becher ein Schild mit Namen und der letzten Ansage dieses
Spielers; Unterlage und Würfelbilder dafür werden beim Start einmal als Textur
gezeichnet. Im Panel oben rechts
stehen deshalb deine eigenen Würfel offen, gegnerische verdeckt – bis auf die
per *Spähen* aufgedeckten und beim Aufdecken am Rundenende.
Jeder Platz hat eine runde Würfelzone mit umlaufender Bande. Die runde Form
ist nicht nur Optik: in den Ecken der früheren rechteckigen Zone verkanteten
sich Würfel, im Kreis liegt kein Wurf mehr schief (Messung: 2,0 % schiefe
Würfel und 1,3 % Nachhelfer vorher, 0,4 % und 0 % nachher). Beim Aufdecken
legen sich die Würfel als Bogen auf einen Kreis um die Zonenmitte, damit sie
sich nicht überdecken und das Namensschild frei bleibt.
Der Tisch wächst mit der Spielerzahl, damit die Würfelzonen auch bei acht
Plätzen nicht aneinanderstoßen. Die Kamera schaut mit 15° Neigung fast senkrecht
von oben darauf; der eigene Platz liegt unten im Bild. `_frame_camera` in
`table.gd` rechnet den Abstand so aus, dass der Tisch genau das freie Band
zwischen den HUD-Feldern ausfüllt (`HUD_TOP`/`HUD_BOTTOM`) – bei jeder
Spielerzahl und jedem Fensterformat, auch nach dem Ändern der Fenstergröße.
## Physik und Bildrate
Zwei Engine-Vorgaben sind bewusst gesetzt, beide in `project.godot`:
* **`3d/physics_engine="Jolt Physics"`** – GodotPhysics3D hat Kontakt-Toleranzen,
die auf meterhohe Objekte ausgelegt sind (`contact_max_separation` 0.05 bei
0.075 Würfel-Halbkante). Würfel dieser Größe sanken dadurch in die Tischplatte
ein und rollten endlos weiter, sodass jeder Wurf in den Settle-Timeout lief.
Mit Jolt liegt die Wurfphase bei rund 0,9 s statt 7 s.
* **`renderer/rendering_method="mobile"`** – auf integrierten GPUs kostet
Forward+ hier mehr als das Doppelte: 22 statt 47 fps bei 30 Würfeln.
Ein liegender Würfel wird mit `FREEZE_MODE_STATIC` eingefroren, nicht
kinematisch. Ein kinematischer Körper meldet seine Lage weiter an den Knoten
zurück: setzt ein Booster wie *Glücksgriff* die Augenzahl über
`snap_to_value()`, überschreibt der Physikserver die neue Lage im nächsten
Schritt wieder mit der alten, und der Würfel zeigt den falschen Wert.
`Die.place()` zieht die Lage zusätzlich sofort im Physikserver nach.
Ein Würfel ist ein einziges Renderobjekt: Körper und Augen stecken in einem
gemeinsamen Mesh mit Vertexfarben, statt 21 Augen als eigene Meshes zu hängen.
Weil Kamera und Deckenlicht beide fast senkrecht stehen, liegt der Glanzpunkt
genau auf der Würfeloberseite. Mit glänzendem Material und starkem Spot klippte
die weiße Fläche bei 255 und riss die schwarzen Augen mit hoch – gemessen lag
kein einziger Punkt-Pixel unter Luminanz 150. Deshalb ist das Würfelmaterial
matt (`roughness` 0.9, `metallic_specular` 0.0) und der Spot schwächer
zugunsten von mehr Umgebungslicht; der Kontrast Auge zu Körper liegt damit bei
rund 11:1.
## Regeln
Klassisches Liar's Dice ohne Joker. Jeder startet mit 5 Würfeln und sieht nur
die eigenen.
1. **Würfeln** – alle Würfel fallen physikalisch in den eigenen Becher.
2. **Boost wählen** – jeder bekommt 3 zufällige Booster angeboten und wählt
einen; er wirkt nur für die laufende Runde.
3. **Ansagen** – reihum wird ein Gebot "Anzahl x Augenzahl" über alle Würfel
am Tisch gemacht. Jedes Gebot muss höher sein: mehr Würfel, oder gleich
viele bei höherer Augenzahl.
4. **Anfechten** – statt zu erhöhen kann man das Vorgebot anzweifeln. Alle
Becher werden gehoben und gezählt: Stimmt das Gebot, verliert der
Anfechter einen Würfel, sonst der Bieter.
Für Boosterwahl und Ansage hat man jeweils **30 Sekunden**; die Restzeit steht
im Booster-Fenster beziehungsweise neben „Deine Ansage" und wird unter zehn
Sekunden rot. Läuft die Zeit ab, entscheidet der Host automatisch – beim Booster
zufällig, beim Gebot mit der Bot-Logik. Ohne dieses Limit würde ein abwesender
oder getrennter Mitspieler den ganzen Tisch dauerhaft blockieren.
Wer keine Würfel mehr hat, scheidet aus. Der Letzte gewinnt.
## Booster
| Booster | Art | Wirkung |
|---|---|---|
| Neuwurf | Buff | Alle eigenen Würfel neu werfen |
| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen |
| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 |
| Zwilling | Buff | Ein anderer eigener Würfel nimmt den Wert des gewählten an |
| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt |
| Rütteln | Debuff | Jeder Gegner wirft einen zufälligen Würfel neu |
| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 |
| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr |
| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen |
| Maulkorb | Debuff | Der nächste Spieler muss bieten und darf nicht anfechten |
Es muss kein Port freigegeben werden: alles läuft als WebSocket über Traefik
auf 443.
## Aufbau
| Datei | Inhalt |
|---|---|
| `scripts/net.gd` | Autoload: ENet-Host/Client, Lobby, Spielstart |
| `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart |
| `scripts/boosters.gd` | Autoload: Booster-Katalog |
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |

View File

@@ -5,22 +5,17 @@ services:
container_name: pirate-dice
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
# Der Server spricht WebSocket, kein HTTP - also nur schauen, ob der
# Port jemanden zu Hause hat.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
start_interval: 2s
environment:
SPIEL_HOST: pirate-dice.marha.de
PORT_VON: 8911
PORT_BIS: 8940
# Das Spiel spricht UDP. Traefik macht nur HTTP, also müssen die
# Raum-Ports direkt nach aussen.
ports:
- "8911-8940:8911-8940/udp"
networks:
- web
# Keine Portfreigabe nötig: alles läuft als WebSocket über Traefik auf 443.
labels:
- "traefik.enable=true"
- "traefik.http.routers.piratedice.rule=Host(`pirate-dice.marha.de`)"

View File

@@ -1,15 +1,24 @@
extends Node
## Wechselt zwischen Menü und laufender Partie.
## Wechselt zwischen Menü und laufenden Partien.
##
## Mit `-- server <port>` läuft dieselbe Datei als Raum auf dem Spielserver:
## kein Menü, kein Bild, nur die Spiellogik.
## Mit `-- server <port>` läuft dieselbe Datei als Spielserver: kein Menü, kein
## Bild, dafür beliebig viele Räume nebeneinander.
##
## Ein Raum steckt in einem SubViewport mit eigener World3D. Nur so hat jede
## Partie ihren eigenen Physikraum. Die Knotennamen sind auf Server und Client
## gleich - daran hängt das RPC-Ziel.
var menu: CanvasLayer
var game: Node3D
var raeume: Node
func _ready() -> void:
Net.game_started.connect(_start_game)
raeume = Node.new()
raeume.name = "Raeume"
add_child(raeume)
Net.game_started.connect(_raum_bauen)
Net.room_closed.connect(_raum_abbauen)
Net.server_closed.connect(_back_to_menu)
var args := OS.get_cmdline_user_args()
@@ -31,19 +40,40 @@ func _unhandled_key_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _start_game(cfg: Dictionary) -> void:
if game != null:
func _raum_bauen(code: String, cfg: Dictionary) -> void:
if raeume.has_node(code):
return
if menu != null:
menu.queue_free()
menu = null
game = Node3D.new()
var raum := Node.new()
raum.name = code
raeume.add_child(raum)
var behaelter := SubViewportContainer.new()
behaelter.name = "Container"
behaelter.stretch = true
behaelter.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
raum.add_child(behaelter)
var sicht := SubViewport.new()
sicht.name = "Viewport"
sicht.own_world_3d = true
behaelter.add_child(sicht)
var game := Node3D.new()
game.set_script(preload("res://scripts/game.gd"))
game.name = "Game" # fester Pfad, sonst findet das RPC-Routing den Knoten nicht
add_child(game)
game.name = "Game"
sicht.add_child(game)
game.setup(cfg)
func _raum_abbauen(code: String) -> void:
var raum := raeume.get_node_or_null(NodePath(code))
if raum != null:
raum.queue_free()
func _back_to_menu() -> void:
if game != null:
get_tree().change_scene_to_file("res://scenes/main.tscn")

View File

@@ -1,61 +1,66 @@
extends Node
## Netzwerk: Räume liegen auf dem Spielserver, Beitritt läuft über einen Code.
## Netzwerk: ein Server, viele Räume, Beitritt per Code.
##
## Der Server ist eine eigene headless Instanz und sitzt nicht mit am Tisch.
## Er ist Peer 1 und damit die Autorität. Ein Spieler im Raum ist Eigentümer
## und darf die Partie starten.
## Der Server hält alle Räume in einem einzigen Prozess und lauscht auf einem
## einzigen WebSocket-Port. Damit braucht es weder einen Portbereich noch einen
## Prozess je Partie. Ein Raum ist ein Knoten unter /root/Main/Raeume; sein
## SubViewport bringt einen eigenen Physikraum mit, sonst würden die Würfel
## aller Partien im selben Raum liegen.
##
## Die Knotenpfade müssen auf Server und Client gleich heißen - daran hängt
## das Ziel jedes RPC.
signal lobby_changed
signal game_started(config: Dictionary)
signal game_started(code: String, cfg: Dictionary)
signal room_closed(code: String)
signal connection_failed(reason: String)
signal server_closed
signal room_opened(code: String)
const PORT := 8910
const MAX_HUMANS := 8
const STANDARD_SERVER := "https://pirate-dice.marha.de"
## Ohne Spieler schliesst sich ein Raum von selbst, sonst stehen Leichen herum
const LEERLAUF := 120.0
const STANDARD_SERVER := "wss://pirate-dice.marha.de"
## Ohne I, O, 0 und 1: die verwechselt man beim Vorlesen
const ALPHABET := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
const CODE_LAENGE := 4
const SOLO := "SOLO"
# ── Client ───────────────────────────────────────────────────────────────────
var player_name := "Spieler"
## peer_id -> Name. Nur Menschen; der Server steht nicht drin.
var lobby: Dictionary = {}
var owner_id := 0
var is_owner := false
var room_code := ""
var in_game := false
## true in der headless Instanz auf dem Server
var dedicated := false
var _leer_seit := 0.0
# ── Server ───────────────────────────────────────────────────────────────────
var dedicated := false
## code -> {lobby: {peer: name}, owner: int, in_game: bool}
var raeume: Dictionary = {}
## peer -> code
var peer_raum: Dictionary = {}
var _wunsch: Dictionary = {}
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
_rng.randomize()
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connect_fail)
multiplayer.server_disconnected.connect(_on_server_disconnected)
## Zum Entwickeln auf einen lokalen Matchmaker zeigen lassen:
## PIRATE_DICE_SERVER=http://127.0.0.1:8020 godot
## Zum Entwickeln auf den lokalen Server zeigen lassen:
## PIRATE_DICE_SERVER=ws://127.0.0.1:8000 godot
func server_url() -> String:
var eigen := OS.get_environment("PIRATE_DICE_SERVER")
return eigen if eigen != "" else STANDARD_SERVER
# ---------------------------------------------------------------- Räume
# ── Verbinden ────────────────────────────────────────────────────────────────
## Neuen Raum anlegen lassen und gleich betreten.
func create_room(pname: String) -> void:
var raum := await _api(HTTPClient.METHOD_POST, "/api/raum")
if raum.is_empty():
connection_failed.emit("Spielserver nicht erreichbar.")
return
room_code = String(raum.get("code", ""))
room_opened.emit(room_code)
join_game(String(raum.get("host", "")), pname, int(raum.get("port", PORT)))
_verbinden({"art": "erstellen"}, pname)
func join_room(code: String, pname: String) -> void:
@@ -63,52 +68,29 @@ func join_room(code: String, pname: String) -> void:
if code == "":
connection_failed.emit("Kein Code eingegeben.")
return
var raum := await _api(HTTPClient.METHOD_GET, "/api/raum/" + code)
if raum.is_empty():
connection_failed.emit("Den Code %s gibt es nicht." % code)
_verbinden({"art": "beitreten", "code": code}, pname)
## Der Wunsch wartet, bis die Verbindung steht - vorher kann er nicht raus.
func _verbinden(wunsch: Dictionary, pname: String) -> void:
player_name = pname
_wunsch = wunsch
var peer := WebSocketMultiplayerPeer.new()
if peer.create_client(server_url()) != OK:
connection_failed.emit("Server nicht erreichbar.")
return
room_code = code
join_game(String(raum.get("host", "")), pname, int(raum.get("port", PORT)))
multiplayer.multiplayer_peer = peer
func _api(method: int, pfad: String) -> Dictionary:
var req := HTTPRequest.new()
add_child(req)
req.timeout = 8.0
if req.request(server_url() + pfad, PackedStringArray(), method) != OK:
req.queue_free()
return {}
var antwort: Array = await req.request_completed
req.queue_free()
if int(antwort[1]) != 200:
return {}
var daten: Variant = JSON.parse_string(
(antwort[3] as PackedByteArray).get_string_from_utf8())
return daten if daten is Dictionary else {}
# ---------------------------------------------------------------- Verbindung
## Nur die headless Instanz auf dem Server ruft das auf.
func serve(port: int) -> bool:
var peer := ENetMultiplayerPeer.new()
if peer.create_server(port, MAX_HUMANS) != OK:
var peer := WebSocketMultiplayerPeer.new()
if peer.create_server(port) != OK:
push_error("Port %d ist belegt." % port)
return false
multiplayer.multiplayer_peer = peer
dedicated = true
_leer_seit = _jetzt()
print("Raum offen auf Port %d." % port)
return true
func join_game(host: String, pname: String, port: int = PORT) -> bool:
player_name = pname
var peer := ENetMultiplayerPeer.new()
if peer.create_client(host, port) != OK:
connection_failed.emit("Verbindung zu %s:%d fehlgeschlagen." % [host, port])
return false
multiplayer.multiplayer_peer = peer
print("Server lauscht auf Port %d." % port)
return true
@@ -126,49 +108,21 @@ func leave() -> void:
func is_online() -> bool:
var peer := multiplayer.multiplayer_peer
return peer is ENetMultiplayerPeer \
return peer != null \
and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
## Ein leerer Raum kostet sonst dauerhaft Speicher auf dem Server.
func _process(_delta: float) -> void:
if not dedicated:
return
if lobby.is_empty():
if _jetzt() - _leer_seit > LEERLAUF:
print("Niemand mehr im Raum, wird geschlossen.")
get_tree().quit()
else:
_leer_seit = _jetzt()
func _jetzt() -> float:
return Time.get_ticks_msec() / 1000.0
func _on_peer_connected(id: int) -> void:
if multiplayer.is_server():
_push_lobby.rpc_id(id, lobby, owner_id)
func _on_peer_disconnected(id: int) -> void:
if not multiplayer.is_server():
return
lobby.erase(id)
# Geht der Eigentümer, rückt der nächste nach - sonst startet niemand mehr
if id == owner_id:
owner_id = 0 if lobby.is_empty() else int(lobby.keys()[0])
_push_lobby.rpc(lobby, owner_id)
lobby_changed.emit()
func _on_connected() -> void:
_register.rpc_id(1, player_name)
if _wunsch.get("art", "") == "erstellen":
_req_create.rpc_id(1, player_name)
else:
_req_join.rpc_id(1, String(_wunsch.get("code", "")), player_name)
_wunsch = {}
func _on_connect_fail() -> void:
multiplayer.multiplayer_peer = null
connection_failed.emit("Raum nicht erreichbar.")
connection_failed.emit("Server nicht erreichbar.")
func _on_server_disconnected() -> void:
@@ -180,31 +134,79 @@ func _on_server_disconnected() -> void:
server_closed.emit()
# ── Räume (nur Server) ───────────────────────────────────────────────────────
func _neuer_code() -> String:
while true:
var code := ""
for i in CODE_LAENGE:
code += ALPHABET[_rng.randi_range(0, ALPHABET.length() - 1)]
if not raeume.has(code):
return code
return ""
@rpc("any_peer", "reliable")
func _register(pname: String) -> void:
func _req_create(pname: String) -> void:
if not multiplayer.is_server():
return
var id := multiplayer.get_remote_sender_id()
if in_game:
_abweisen(id, "In dem Raum läuft schon eine Partie.")
return
if lobby.size() >= MAX_HUMANS:
_abweisen(id, "Der Raum ist voll.")
return
lobby[id] = pname.substr(0, 16)
if owner_id == 0:
owner_id = id
_push_lobby.rpc(lobby, owner_id)
lobby_changed.emit()
var code := _neuer_code()
raeume[code] = {"lobby": {id: pname.substr(0, 16)}, "owner": id, "in_game": false}
peer_raum[id] = code
print("Raum %s aufgemacht (%d offen)." % [code, raeume.size()])
_lobby_senden(code)
## Erst die Begründung schicken, dann trennen - andersherum kommt sie nie an.
func _abweisen(id: int, grund: String) -> void:
_abgewiesen.rpc_id(id, grund)
await get_tree().create_timer(0.5).timeout
var peer := multiplayer.multiplayer_peer
if peer is ENetMultiplayerPeer:
peer.disconnect_peer(id)
@rpc("any_peer", "reliable")
func _req_join(code: String, pname: String) -> void:
if not multiplayer.is_server():
return
var id := multiplayer.get_remote_sender_id()
code = code.strip_edges().to_upper()
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty():
_abgewiesen.rpc_id(id, "Den Code %s gibt es nicht." % code)
return
if raum.in_game:
_abgewiesen.rpc_id(id, "In dem Raum läuft schon eine Partie.")
return
if raum.lobby.size() >= MAX_HUMANS:
_abgewiesen.rpc_id(id, "Der Raum ist voll.")
return
raum.lobby[id] = pname.substr(0, 16)
peer_raum[id] = code
_lobby_senden(code)
## Ein Broadcast würde alle Räume treffen, also gezielt an die eigenen Peers.
func _lobby_senden(code: String) -> void:
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty():
return
for peer: int in raum.lobby:
_push_lobby.rpc_id(peer, code, raum.lobby, raum.owner)
func _on_peer_disconnected(id: int) -> void:
if not multiplayer.is_server():
return
var code: String = peer_raum.get(id, "")
peer_raum.erase(id)
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty():
return
raum.lobby.erase(id)
# Der letzte macht das Licht aus: leerer Raum, weg damit.
if raum.lobby.is_empty():
raeume.erase(code)
room_closed.emit(code)
print("Raum %s zu (%d offen)." % [code, raeume.size()])
return
# Geht der Host, rückt der nächste nach - sonst startet niemand mehr
if id == raum.owner:
raum.owner = int(raum.lobby.keys()[0])
_lobby_senden(code)
@rpc("authority", "reliable")
@@ -214,27 +216,30 @@ func _abgewiesen(grund: String) -> void:
@rpc("authority", "reliable")
func _push_lobby(data: Dictionary, besitzer: int) -> void:
func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
room_code = code
lobby = data
owner_id = besitzer
is_owner = besitzer == multiplayer.get_unique_id()
lobby_changed.emit()
# ---------------------------------------------------------------- Start
# ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
func start_singleplayer(pname: String, ai_count: int) -> void:
player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO
lobby = {1: pname}
owner_id = 1
is_owner = true
in_game = true
lobby_changed.emit()
_begin({"peers": [1], "names": lobby.duplicate(), "ai": ai_count})
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count})
## Der Eigentümer bittet den Server um den Start.
## Der Host bittet den Server um den Start.
func request_start(ai_count: int) -> void:
_req_start.rpc_id(1, ai_count)
@@ -243,22 +248,24 @@ func request_start(ai_count: int) -> void:
func _req_start(ai_count: int) -> void:
if not multiplayer.is_server():
return
if multiplayer.get_remote_sender_id() != owner_id:
var id := multiplayer.get_remote_sender_id()
var code: String = peer_raum.get(id, "")
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty() or raum.in_game or id != raum.owner:
return
start_game(ai_count)
func start_game(ai_count: int) -> void:
if not multiplayer.is_server() or in_game:
return
var order: Array = lobby.keys()
var order: Array = raum.lobby.keys()
order.sort()
if order.size() + ai_count < 2:
return
_begin.rpc({"peers": order, "names": lobby.duplicate(), "ai": ai_count})
raum.in_game = true
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count}
for peer: int in order:
_begin.rpc_id(peer, code, cfg)
game_started.emit(code, cfg)
@rpc("authority", "call_local", "reliable")
func _begin(cfg: Dictionary) -> void:
@rpc("authority", "reliable")
func _begin(code: String, cfg: Dictionary) -> void:
in_game = true
game_started.emit(cfg)
room_code = code
game_started.emit(code, cfg)

View File

@@ -1,78 +0,0 @@
"""Räume verwalten: Code vergeben, headless Spielserver starten, aufräumen.
Ein Raum ist ein eigener Godot-Prozess auf einem festen UDP-Port. Der Code
zeigt nur auf diesen Port. Läuft der Prozess nicht mehr, ist der Code frei.
"""
import os
import random
import subprocess
from pathlib import Path
from fastapi import FastAPI, HTTPException
# Ohne I, O, 0 und 1: die verwechselt man beim Vorlesen
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LAENGE = 4
GODOT = os.getenv("GODOT", "godot")
SPIEL_PFAD = Path(os.getenv("SPIEL_PFAD", "/app/spiel"))
# Was die Clients als Adresse bekommen
SPIEL_HOST = os.getenv("SPIEL_HOST", "pirate-dice.marha.de")
PORT_VON = int(os.getenv("PORT_VON", "8911"))
PORT_BIS = int(os.getenv("PORT_BIS", "8940"))
app = FastAPI(title="Pirate Dice Matchmaker")
## code -> {"port": int, "proc": Popen}
raeume: dict = {}
def aufraeumen() -> None:
"""Beendete Räume vergessen. Godot beendet sich selbst, wenn es leer ist."""
for code in [c for c, r in raeume.items() if r["proc"].poll() is not None]:
del raeume[code]
def freier_port() -> int:
belegt = {r["port"] for r in raeume.values()}
for port in range(PORT_VON, PORT_BIS + 1):
if port not in belegt:
return port
raise HTTPException(status_code=503, detail="Alle Räume belegt")
def neuer_code() -> str:
while True:
code = "".join(random.choice(ALPHABET) for _ in range(CODE_LAENGE))
if code not in raeume:
return code
@app.get("/api/health")
def health():
aufraeumen()
return {"ok": True, "raeume": len(raeume)}
@app.post("/api/raum")
def raum_anlegen():
aufraeumen()
port = freier_port()
code = neuer_code()
proc = subprocess.Popen(
[GODOT, "--headless", "--path", str(SPIEL_PFAD), "--", "server", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
raeume[code] = {"port": port, "proc": proc}
return {"code": code, "host": SPIEL_HOST, "port": port}
@app.get("/api/raum/{code}")
def raum_suchen(code: str):
aufraeumen()
raum = raeume.get(code.strip().upper())
if not raum:
raise HTTPException(status_code=404, detail="Code unbekannt")
return {"code": code.upper(), "host": SPIEL_HOST, "port": raum["port"]}

View File

@@ -1,2 +0,0 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0