update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1 @@
|
|||||||
dist/
|
dist/
|
||||||
server/.venv/
|
|
||||||
server/__pycache__/
|
|
||||||
|
|||||||
30
Dockerfile
30
Dockerfile
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY server/requirements.txt ./
|
COPY --from=godot /usr/local/bin/godot /usr/local/bin/godot
|
||||||
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 project.godot ./spiel/
|
COPY project.godot ./spiel/
|
||||||
COPY scenes ./spiel/scenes
|
COPY scenes ./spiel/scenes
|
||||||
COPY scripts ./spiel/scripts
|
COPY scripts ./spiel/scripts
|
||||||
COPY server/matchmaker.py ./
|
|
||||||
|
|
||||||
# Einmal beim Bauen importieren, sonst tut das der erste Raum - und der
|
# Einmal beim Bauen importieren, sonst tut das der erste Raum - und der
|
||||||
# Spieler wartet.
|
# Spieler wartet.
|
||||||
RUN godot --headless --path /app/spiel --import
|
RUN godot --headless --path /app/spiel --import
|
||||||
|
|
||||||
ENV GODOT=/usr/local/bin/godot
|
|
||||||
ENV SPIEL_PFAD=/app/spiel
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "matchmaker:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["godot", "--headless", "--path", "/app/spiel", "--", "server", "8000"]
|
||||||
|
|||||||
31
Makefile
31
Makefile
@@ -1,31 +1,20 @@
|
|||||||
.PHONY: dev server matchmaker godot prod logs down
|
.PHONY: dev server prod logs down
|
||||||
|
|
||||||
GODOT ?= $(HOME)/bin/godot
|
GODOT ?= $(HOME)/bin/godot
|
||||||
|
|
||||||
# ── Entwickeln ───────────────────────────────────────────────────────────────
|
# ── Entwickeln ───────────────────────────────────────────────────────────────
|
||||||
# Spiel gegen den lokalen Matchmaker starten. Ohne die Variable zeigt es auf
|
# Spielserver lokal. Ein Prozess trägt alle Räume.
|
||||||
# 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
|
|
||||||
server:
|
server:
|
||||||
$(GODOT) --headless --path . -- server 8910
|
$(GODOT) --headless --path . -- server 8000
|
||||||
|
|
||||||
# ── Produktion (auf dem Server ausführen) ────────────────────────────────────
|
# Spiel gegen den lokalen Server. Ohne die Variable zeigt es auf
|
||||||
# Das Binary kommt aus dem Build-Kontext, damit die Version stimmt.
|
# wss://pirate-dice.marha.de.
|
||||||
godot:
|
dev:
|
||||||
mkdir -p dist
|
PIRATE_DICE_SERVER=ws://127.0.0.1:8000 $(GODOT) --path .
|
||||||
cp $(GODOT) dist/godot
|
|
||||||
|
|
||||||
prod: godot
|
# ── Produktion ───────────────────────────────────────────────────────────────
|
||||||
|
# Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst.
|
||||||
|
prod:
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@echo "läuft: https://pirate-dice.marha.de"
|
@echo "läuft: https://pirate-dice.marha.de"
|
||||||
|
|
||||||
|
|||||||
152
README.md
152
README.md
@@ -22,146 +22,56 @@ Das Startmenü hat drei Punkte:
|
|||||||
|
|
||||||
## Mehrspieler: wie es zusammenhängt
|
## 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 ──wss://443──> ein Serverprozess ──> Raum MQG4 (SubViewport + World3D)
|
||||||
Spieler ──UDP────> Raum
|
└──> Raum 4YPV (SubViewport + World3D)
|
||||||
|
|
||||||
* Der **Matchmaker** (`server/matchmaker.py`) vergibt Codes wie `K7QM` und
|
* **Ein Prozess, ein Port.** Der Server trägt alle Räume gleichzeitig und
|
||||||
startet je Raum einen eigenen headless Godot-Prozess auf einem Port aus
|
spricht WebSocket. Traefik reicht das auf 443 durch, es muss kein Port
|
||||||
8911–8940. `POST /api/raum` legt an, `GET /api/raum/<code>` schlägt nach.
|
freigegeben werden.
|
||||||
* Ein **Raum** ist Peer 1 und damit die Autorität, sitzt aber nicht mit am
|
* **Ein Raum** ist ein Knoten unter `/root/Main/Raeume/<CODE>`. Darin ein
|
||||||
Tisch (`local_seat` bleibt −1, kein HUD). Ohne Spieler beendet er sich nach
|
SubViewport mit `own_world_3d` – nur so hat jede Partie ihren eigenen
|
||||||
zwei Minuten von selbst, dann ist der Code wieder frei.
|
Physikraum, sonst lägen alle Würfel im selben.
|
||||||
* Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste
|
* Die Knotennamen sind auf Server und Client gleich. Daran hängt das Ziel
|
||||||
nach. Nur er darf starten (`Net.request_start`).
|
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,
|
Gemessen mit sechs gleichzeitigen Partien zu acht Plätzen: **3,7 MB je Raum**,
|
||||||
kurze Spitzen beim Würfelwurf.
|
im Leerlauf unter 1 % eines Kerns, kurze Spitzen beim Würfelwurf.
|
||||||
|
|
||||||
## Lokal entwickeln
|
## Lokal entwickeln
|
||||||
|
|
||||||
make matchmaker # Matchmaker auf :8020, legt Räume als Prozesse an
|
make server # Spielserver auf :8000, trägt alle Räume
|
||||||
make dev # Spiel, zeigt auf den lokalen Matchmaker
|
make dev # Spiel, zeigt auf den lokalen Server
|
||||||
|
|
||||||
Ohne `PIRATE_DICE_SERVER` zeigt das Spiel auf `pirate-dice.marha.de`. Einen
|
Ohne `PIRATE_DICE_SERVER` zeigt das Spiel auf `wss://pirate-dice.marha.de`.
|
||||||
einzelnen Raum ohne Matchmaker gibt es mit `make server` (Port 8910).
|
|
||||||
|
|
||||||
## Auf dem Server
|
## Auf dem Server
|
||||||
|
|
||||||
make prod # kopiert das Godot-Binary, baut, startet
|
git pull
|
||||||
|
make prod # baut und startet, holt Godot selbst
|
||||||
make logs
|
make logs
|
||||||
make down
|
make down
|
||||||
|
|
||||||
Die HTTP-Schnittstelle läuft hinter Traefik auf `pirate-dice.marha.de`. Die
|
Mehr ist nicht nötig. Der Docker-Build lädt Godot 4.7.2 herunter (Version steht
|
||||||
Raum-Ports 8911–8940/UDP gehen direkt nach aussen – Traefik kann kein UDP.
|
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
|
Es muss kein Port freigegeben werden: alles läuft als WebSocket über Traefik
|
||||||
*Fortsetzen* und *Verlassen*; im Einzelspieler hält es das Spiel samt Zeitlimit
|
auf 443.
|
||||||
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 |
|
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
| Datei | Inhalt |
|
| 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/boosters.gd` | Autoload: Booster-Katalog |
|
||||||
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
|
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
|
||||||
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
|
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
|
||||||
|
|||||||
@@ -5,22 +5,17 @@ services:
|
|||||||
container_name: pirate-dice
|
container_name: pirate-dice
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
start_interval: 2s
|
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:
|
networks:
|
||||||
- web
|
- web
|
||||||
|
# Keine Portfreigabe nötig: alles läuft als WebSocket über Traefik auf 443.
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.piratedice.rule=Host(`pirate-dice.marha.de`)"
|
- "traefik.http.routers.piratedice.rule=Host(`pirate-dice.marha.de`)"
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
extends Node
|
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:
|
## Mit `-- server <port>` läuft dieselbe Datei als Spielserver: kein Menü, kein
|
||||||
## kein Menü, kein Bild, nur die Spiellogik.
|
## 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 menu: CanvasLayer
|
||||||
var game: Node3D
|
var raeume: Node
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
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)
|
Net.server_closed.connect(_back_to_menu)
|
||||||
|
|
||||||
var args := OS.get_cmdline_user_args()
|
var args := OS.get_cmdline_user_args()
|
||||||
@@ -31,19 +40,40 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
func _start_game(cfg: Dictionary) -> void:
|
func _raum_bauen(code: String, cfg: Dictionary) -> void:
|
||||||
if game != null:
|
if raeume.has_node(code):
|
||||||
return
|
return
|
||||||
if menu != null:
|
if menu != null:
|
||||||
menu.queue_free()
|
menu.queue_free()
|
||||||
menu = null
|
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.set_script(preload("res://scripts/game.gd"))
|
||||||
game.name = "Game" # fester Pfad, sonst findet das RPC-Routing den Knoten nicht
|
game.name = "Game"
|
||||||
add_child(game)
|
sicht.add_child(game)
|
||||||
game.setup(cfg)
|
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:
|
func _back_to_menu() -> void:
|
||||||
if game != null:
|
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
||||||
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
|
||||||
|
|||||||
273
scripts/net.gd
273
scripts/net.gd
@@ -1,61 +1,66 @@
|
|||||||
extends Node
|
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.
|
## Der Server hält alle Räume in einem einzigen Prozess und lauscht auf einem
|
||||||
## Er ist Peer 1 und damit die Autorität. Ein Spieler im Raum ist Eigentümer
|
## einzigen WebSocket-Port. Damit braucht es weder einen Portbereich noch einen
|
||||||
## und darf die Partie starten.
|
## 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 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 connection_failed(reason: String)
|
||||||
signal server_closed
|
signal server_closed
|
||||||
signal room_opened(code: String)
|
|
||||||
|
|
||||||
const PORT := 8910
|
|
||||||
const MAX_HUMANS := 8
|
const MAX_HUMANS := 8
|
||||||
const STANDARD_SERVER := "https://pirate-dice.marha.de"
|
const STANDARD_SERVER := "wss://pirate-dice.marha.de"
|
||||||
## Ohne Spieler schliesst sich ein Raum von selbst, sonst stehen Leichen herum
|
## Ohne I, O, 0 und 1: die verwechselt man beim Vorlesen
|
||||||
const LEERLAUF := 120.0
|
const ALPHABET := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
const CODE_LAENGE := 4
|
||||||
|
const SOLO := "SOLO"
|
||||||
|
|
||||||
|
# ── Client ───────────────────────────────────────────────────────────────────
|
||||||
var player_name := "Spieler"
|
var player_name := "Spieler"
|
||||||
## peer_id -> Name. Nur Menschen; der Server steht nicht drin.
|
|
||||||
var lobby: Dictionary = {}
|
var lobby: Dictionary = {}
|
||||||
var owner_id := 0
|
var owner_id := 0
|
||||||
var is_owner := false
|
var is_owner := false
|
||||||
var room_code := ""
|
var room_code := ""
|
||||||
var in_game := false
|
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:
|
func _ready() -> void:
|
||||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
_rng.randomize()
|
||||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||||
multiplayer.connected_to_server.connect(_on_connected)
|
multiplayer.connected_to_server.connect(_on_connected)
|
||||||
multiplayer.connection_failed.connect(_on_connect_fail)
|
multiplayer.connection_failed.connect(_on_connect_fail)
|
||||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||||
|
|
||||||
|
|
||||||
## Zum Entwickeln auf einen lokalen Matchmaker zeigen lassen:
|
## Zum Entwickeln auf den lokalen Server zeigen lassen:
|
||||||
## PIRATE_DICE_SERVER=http://127.0.0.1:8020 godot
|
## PIRATE_DICE_SERVER=ws://127.0.0.1:8000 godot
|
||||||
func server_url() -> String:
|
func server_url() -> String:
|
||||||
var eigen := OS.get_environment("PIRATE_DICE_SERVER")
|
var eigen := OS.get_environment("PIRATE_DICE_SERVER")
|
||||||
return eigen if eigen != "" else STANDARD_SERVER
|
return eigen if eigen != "" else STANDARD_SERVER
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Räume
|
# ── Verbinden ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
## Neuen Raum anlegen lassen und gleich betreten.
|
|
||||||
func create_room(pname: String) -> void:
|
func create_room(pname: String) -> void:
|
||||||
var raum := await _api(HTTPClient.METHOD_POST, "/api/raum")
|
_verbinden({"art": "erstellen"}, pname)
|
||||||
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)))
|
|
||||||
|
|
||||||
|
|
||||||
func join_room(code: String, pname: String) -> void:
|
func join_room(code: String, pname: String) -> void:
|
||||||
@@ -63,52 +68,29 @@ func join_room(code: String, pname: String) -> void:
|
|||||||
if code == "":
|
if code == "":
|
||||||
connection_failed.emit("Kein Code eingegeben.")
|
connection_failed.emit("Kein Code eingegeben.")
|
||||||
return
|
return
|
||||||
var raum := await _api(HTTPClient.METHOD_GET, "/api/raum/" + code)
|
_verbinden({"art": "beitreten", "code": code}, pname)
|
||||||
if raum.is_empty():
|
|
||||||
connection_failed.emit("Den Code %s gibt es nicht." % code)
|
|
||||||
|
## 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
|
return
|
||||||
room_code = code
|
multiplayer.multiplayer_peer = peer
|
||||||
join_game(String(raum.get("host", "")), pname, int(raum.get("port", PORT)))
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
## Nur die headless Instanz auf dem Server ruft das auf.
|
||||||
func serve(port: int) -> bool:
|
func serve(port: int) -> bool:
|
||||||
var peer := ENetMultiplayerPeer.new()
|
var peer := WebSocketMultiplayerPeer.new()
|
||||||
if peer.create_server(port, MAX_HUMANS) != OK:
|
if peer.create_server(port) != OK:
|
||||||
push_error("Port %d ist belegt." % port)
|
push_error("Port %d ist belegt." % port)
|
||||||
return false
|
return false
|
||||||
multiplayer.multiplayer_peer = peer
|
multiplayer.multiplayer_peer = peer
|
||||||
dedicated = true
|
dedicated = true
|
||||||
_leer_seit = _jetzt()
|
print("Server lauscht auf Port %d." % port)
|
||||||
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
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
@@ -126,49 +108,21 @@ func leave() -> void:
|
|||||||
|
|
||||||
func is_online() -> bool:
|
func is_online() -> bool:
|
||||||
var peer := multiplayer.multiplayer_peer
|
var peer := multiplayer.multiplayer_peer
|
||||||
return peer is ENetMultiplayerPeer \
|
return peer != null \
|
||||||
and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
|
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:
|
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:
|
func _on_connect_fail() -> void:
|
||||||
multiplayer.multiplayer_peer = null
|
multiplayer.multiplayer_peer = null
|
||||||
connection_failed.emit("Raum nicht erreichbar.")
|
connection_failed.emit("Server nicht erreichbar.")
|
||||||
|
|
||||||
|
|
||||||
func _on_server_disconnected() -> void:
|
func _on_server_disconnected() -> void:
|
||||||
@@ -180,31 +134,79 @@ func _on_server_disconnected() -> void:
|
|||||||
server_closed.emit()
|
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")
|
@rpc("any_peer", "reliable")
|
||||||
func _register(pname: String) -> void:
|
func _req_create(pname: String) -> void:
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
var id := multiplayer.get_remote_sender_id()
|
var id := multiplayer.get_remote_sender_id()
|
||||||
if in_game:
|
var code := _neuer_code()
|
||||||
_abweisen(id, "In dem Raum läuft schon eine Partie.")
|
raeume[code] = {"lobby": {id: pname.substr(0, 16)}, "owner": id, "in_game": false}
|
||||||
return
|
peer_raum[id] = code
|
||||||
if lobby.size() >= MAX_HUMANS:
|
print("Raum %s aufgemacht (%d offen)." % [code, raeume.size()])
|
||||||
_abweisen(id, "Der Raum ist voll.")
|
_lobby_senden(code)
|
||||||
return
|
|
||||||
lobby[id] = pname.substr(0, 16)
|
|
||||||
if owner_id == 0:
|
|
||||||
owner_id = id
|
|
||||||
_push_lobby.rpc(lobby, owner_id)
|
|
||||||
lobby_changed.emit()
|
|
||||||
|
|
||||||
|
|
||||||
## Erst die Begründung schicken, dann trennen - andersherum kommt sie nie an.
|
@rpc("any_peer", "reliable")
|
||||||
func _abweisen(id: int, grund: String) -> void:
|
func _req_join(code: String, pname: String) -> void:
|
||||||
_abgewiesen.rpc_id(id, grund)
|
if not multiplayer.is_server():
|
||||||
await get_tree().create_timer(0.5).timeout
|
return
|
||||||
var peer := multiplayer.multiplayer_peer
|
var id := multiplayer.get_remote_sender_id()
|
||||||
if peer is ENetMultiplayerPeer:
|
code = code.strip_edges().to_upper()
|
||||||
peer.disconnect_peer(id)
|
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")
|
@rpc("authority", "reliable")
|
||||||
@@ -214,27 +216,30 @@ func _abgewiesen(grund: String) -> void:
|
|||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
@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
|
lobby = data
|
||||||
owner_id = besitzer
|
owner_id = besitzer
|
||||||
is_owner = besitzer == multiplayer.get_unique_id()
|
is_owner = besitzer == multiplayer.get_unique_id()
|
||||||
lobby_changed.emit()
|
lobby_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Start
|
# ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
|
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
|
||||||
func start_singleplayer(pname: String, ai_count: int) -> void:
|
func start_singleplayer(pname: String, ai_count: int) -> void:
|
||||||
player_name = pname
|
player_name = pname
|
||||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||||
|
room_code = SOLO
|
||||||
lobby = {1: pname}
|
lobby = {1: pname}
|
||||||
owner_id = 1
|
owner_id = 1
|
||||||
is_owner = true
|
is_owner = true
|
||||||
|
in_game = true
|
||||||
lobby_changed.emit()
|
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:
|
func request_start(ai_count: int) -> void:
|
||||||
_req_start.rpc_id(1, ai_count)
|
_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:
|
func _req_start(ai_count: int) -> void:
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
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
|
return
|
||||||
start_game(ai_count)
|
var order: Array = raum.lobby.keys()
|
||||||
|
|
||||||
|
|
||||||
func start_game(ai_count: int) -> void:
|
|
||||||
if not multiplayer.is_server() or in_game:
|
|
||||||
return
|
|
||||||
var order: Array = lobby.keys()
|
|
||||||
order.sort()
|
order.sort()
|
||||||
if order.size() + ai_count < 2:
|
if order.size() + ai_count < 2:
|
||||||
return
|
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")
|
@rpc("authority", "reliable")
|
||||||
func _begin(cfg: Dictionary) -> void:
|
func _begin(code: String, cfg: Dictionary) -> void:
|
||||||
in_game = true
|
in_game = true
|
||||||
game_started.emit(cfg)
|
room_code = code
|
||||||
|
game_started.emit(code, cfg)
|
||||||
|
|||||||
@@ -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"]}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fastapi==0.115.6
|
|
||||||
uvicorn[standard]==0.34.0
|
|
||||||
Reference in New Issue
Block a user