diff --git a/.gitignore b/.gitignore index b87434d..849ddff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ dist/ -server/.venv/ -server/__pycache__/ diff --git a/Dockerfile b/Dockerfile index 6ac96cd..c227f0c 100644 --- a/Dockerfile +++ b/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 -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"] diff --git a/Makefile b/Makefile index f408c1c..29bec6d 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index 0263af0..a1f9701 100644 --- a/README.md +++ b/README.md @@ -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/` 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/`. 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 | diff --git a/docker-compose.yml b/docker-compose.yml index c875f01..a7d745f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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`)" diff --git a/scripts/main.gd b/scripts/main.gd index 63887ad..b42e592 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -1,15 +1,24 @@ extends Node -## Wechselt zwischen Menü und laufender Partie. +## Wechselt zwischen Menü und laufenden Partien. ## -## Mit `-- server ` läuft dieselbe Datei als Raum auf dem Spielserver: -## kein Menü, kein Bild, nur die Spiellogik. +## Mit `-- server ` 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") + get_tree().change_scene_to_file("res://scenes/main.tscn") diff --git a/scripts/net.gd b/scripts/net.gd index d13a942..71679b6 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -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) diff --git a/server/matchmaker.py b/server/matchmaker.py deleted file mode 100644 index 68ef198..0000000 --- a/server/matchmaker.py +++ /dev/null @@ -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"]} diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 3fb50f0..0000000 --- a/server/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -fastapi==0.115.6 -uvicorn[standard]==0.34.0