diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b87434d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +server/.venv/ +server/__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ac96cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-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 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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f408c1c --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: dev server matchmaker godot 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 +server: + $(GODOT) --headless --path . -- server 8910 + +# ── 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 + +prod: godot + docker compose up -d --build + @echo "läuft: https://pirate-dice.marha.de" + +logs: + docker compose logs -f + +down: + docker compose down diff --git a/README.md b/README.md index a9572b2..0263af0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Liar's Dice 3D -Würfel-Bluffspiel in Godot 4.7 mit echter 3D-Physik, Host/Client-Multiplayer, +Würfel-Bluffspiel in Godot 4.7 mit echter 3D-Physik, Mehrspieler über Raum-Codes, KI-Bots und einem Booster-System aus Buffs und Debuffs. ## Starten @@ -11,14 +11,50 @@ Das Startmenü hat drei Punkte: * **Einzelspieler** – nur die Bot-Anzahl (1–7) einstellen und starten. Es wird kein Netzwerk geöffnet. -* **Mehrspieler** – Name, Server-IP und Bot-Anzahl. **Spiel hosten** öffnet - einen Server auf Port 8910, **Beitreten** verbindet sich mit der eingetragenen - IP. Der Host startet die Partie, sobald mindestens 2 Teilnehmer zusammen - sind (Menschen und Bots zusammengezählt, maximal 8). +* **Mehrspieler** – nur den Namen eintragen, dann **Hosten** oder + **Beitreten**. Hosten führt direkt in die Lobby; dort steht der Code. + Beitreten fragt zuerst nach dem Code. Niemand braucht eine IP oder eine + Portfreigabe. +* **Lobby** – zeigt den Code, wer schon da ist und die Bot-Anzahl. Nur der + Host stellt die Bots ein und startet, sobald mindestens 2 Teilnehmer + zusammen sind (Menschen und Bots zusammengezählt, maximal 8). * **Beenden** – schließt das Spiel. -Zum Testen auf einem Rechner: eine Instanz hostet, weitere Instanzen verbinden -sich auf `127.0.0.1`. +## Mehrspieler: wie es zusammenhängt + +Die Partie läuft nicht mehr beim Host, sondern auf `pirate-dice.marha.de`: + + Spieler ──HTTPS──> Matchmaker ──startet──> Raum (godot --headless, UDP-Port) + Spieler ──UDP────> Raum + +* 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`). + +Gemessen je Raum: 51 MB Speicher zusätzlich, ~1 % eines Kerns im Leerlauf, +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 + +Ohne `PIRATE_DICE_SERVER` zeigt das Spiel auf `pirate-dice.marha.de`. Einen +einzelnen Raum ohne Matchmaker gibt es mit `make server` (Port 8910). + +## Auf dem Server + + make prod # kopiert das Godot-Binary, baut, startet + 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. 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c875f01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + pirate-dice: + build: + context: . + 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)"] + 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 + labels: + - "traefik.enable=true" + - "traefik.http.routers.piratedice.rule=Host(`pirate-dice.marha.de`)" + - "traefik.http.routers.piratedice.entrypoints=websecure" + - "traefik.http.routers.piratedice.tls.certresolver=letsencrypt" + - "traefik.http.services.piratedice.loadbalancer.server.port=8000" + +networks: + web: + external: true diff --git a/scripts/game.gd b/scripts/game.gd index 5cbda2c..4168bd9 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -11,7 +11,7 @@ var hud: CanvasLayer var rng := RandomNumberGenerator.new() var seats: Array = [] ## [{peer, name, ai, count, alive}] -var local_seat := 0 +var local_seat := 0 ## -1 auf dem Spielserver: der sitzt nicht mit var host_values: Array = [] ## nur Host: seat -> Array[int] var my_values: Array = [] ## Client-Sicht (-1 = vernebelt) @@ -54,7 +54,9 @@ func setup(cfg: Dictionary) -> void: seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, "count": START_DICE, "alive": true}) + # Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz. var me := multiplayer.get_unique_id() + local_seat = -1 for i in seats.size(): if seats[i].peer == me: local_seat = i @@ -63,13 +65,15 @@ func setup(cfg: Dictionary) -> void: add_child(table) table.build(seats.size(), local_seat) - hud = preload("res://scripts/hud.gd").new() - add_child(hud) - hud.bid_pressed.connect(_on_bid_pressed) - hud.challenge_pressed.connect(_on_challenge_pressed) - hud.booster_chosen.connect(_on_booster_chosen) - hud.quit_pressed.connect(_on_quit) - hud.pause_changed.connect(_on_pause_changed) + # Ohne Platz am Tisch gibt es nichts anzuzeigen und nichts zu bedienen. + if local_seat >= 0: + hud = preload("res://scripts/hud.gd").new() + add_child(hud) + hud.bid_pressed.connect(_on_bid_pressed) + hud.challenge_pressed.connect(_on_challenge_pressed) + hud.booster_chosen.connect(_on_booster_chosen) + hud.quit_pressed.connect(_on_quit) + hud.pause_changed.connect(_on_pause_changed) for i in seats.size(): table.set_dice_count(i, START_DICE) @@ -735,7 +739,7 @@ func _apply_state(st: Dictionary) -> void: ## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken. func _apply_fog_to_table() -> void: - if phase == "roll" or local_seat >= table.dice.size(): + if phase == "roll" or local_seat < 0 or local_seat >= table.dice.size(): return var dl: Array = table.dice_of(local_seat) for i in mini(dl.size(), my_values.size()): diff --git a/scripts/main.gd b/scripts/main.gd index f293c4b..63887ad 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -1,16 +1,26 @@ extends Node ## Wechselt zwischen Menü und laufender Partie. +## +## Mit `-- server ` läuft dieselbe Datei als Raum auf dem Spielserver: +## kein Menü, kein Bild, nur die Spiellogik. var menu: CanvasLayer var game: Node3D func _ready() -> void: - menu = preload("res://scripts/menu.gd").new() - add_child(menu) Net.game_started.connect(_start_game) Net.server_closed.connect(_back_to_menu) + var args := OS.get_cmdline_user_args() + if args.size() >= 2 and args[0] == "server": + if not Net.serve(int(args[1])): + get_tree().quit(1) + return + + menu = preload("res://scripts/menu.gd").new() + add_child(menu) + ## Das Spiel startet im Vollbild; F11 schaltet um. func _unhandled_key_input(event: InputEvent) -> void: diff --git a/scripts/menu.gd b/scripts/menu.gd index 26ed1fa..1b356c4 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -1,23 +1,25 @@ extends CanvasLayer -## Startmenü: Einzelspieler, Mehrspieler (Host/Client mit Lobby), Beenden. +## Startmenü: Einzelspieler, Mehrspieler (hosten oder per Code beitreten), +## Lobby und Beenden. const BOT_NAMES_MAX := 7 var screens: Dictionary = {} +var status_labels: Dictionary = {} +var current := "main" # Einzelspieler var solo_bots: NumberRow # Mehrspieler var name_edit: LineEdit -var ip_edit: LineEdit -var multi_bots: NumberRow +var code_edit: LineEdit + +# Lobby +var lobby_bots: NumberRow +var code_label: Label var lobby_list: Label -var status: Label -var host_btn: Button -var join_btn: Button var start_btn: Button -var leave_btn: Button func _ready() -> void: @@ -33,6 +35,8 @@ func _ready() -> void: screens["main"] = _screen(root, _build_main()) screens["single"] = _screen(root, _build_single()) screens["multi"] = _screen(root, _build_multi()) + screens["join"] = _screen(root, _build_join()) + screens["lobby"] = _screen(root, _build_lobby()) Net.lobby_changed.connect(_refresh) Net.connection_failed.connect(_on_failed) @@ -51,12 +55,20 @@ func _screen(root: Control, content: Control) -> CenterContainer: func _show(which: String) -> void: + current = which for key: String in screens: screens[key].visible = key == which - if which == "multi": + _sagen("") + if which == "lobby": _refresh() +## Meldungen gehören auf den Bildschirm, auf dem man gerade steht. +func _sagen(text: String) -> void: + for key: String in status_labels: + status_labels[key].text = text if key == current else "" + + func _box(width: int) -> VBoxContainer: var v := VBoxContainer.new() v.custom_minimum_size = Vector2(width, 0) @@ -94,6 +106,13 @@ func _button(text: String, parent: Control, action: Callable) -> Button: return b +## Eine Zeile für Meldungen, unten auf jedem Bildschirm. +func _status_row(box: VBoxContainer, key: String) -> void: + var l := _text("", 14, Color(0.95, 0.55, 0.5)) + box.add_child(l) + status_labels[key] = l + + ## Eine Reihe anklickbarer Zahlen - bei so wenigen Werten schneller als eine ## Spinbox mit ihren winzigen Pfeilen. class NumberRow extends HBoxContainer: @@ -120,17 +139,14 @@ class NumberRow extends HBoxContainer: for i in _buttons.size(): _buttons[i].button_pressed = _lo + i == n - ## Werte oberhalb von `hi` sperren (im Mehrspieler sind die Plätze begrenzt). - func set_limit(hi: int) -> void: + ## Obergrenze und Bedienbarkeit in einem Zug - getrennt würde der eine + ## Aufruf den anderen wieder aufheben. + func anpassen(hi: int, on: bool) -> void: for i in _buttons.size(): - _buttons[i].disabled = _lo + i > hi + _buttons[i].disabled = not on or _lo + i > hi if value > hi: pick(maxi(_lo, hi)) - func set_enabled(on: bool) -> void: - for b in _buttons: - b.disabled = not on - func _title(box: VBoxContainer) -> void: box.add_child(_text("LIAR'S DICE 3D", 40)) @@ -162,6 +178,7 @@ func _build_single() -> Control: return box +## Erster Schritt: nur der Name, dann hosten oder beitreten. func _build_multi() -> Control: var box := _box(440) _title(box) @@ -172,34 +189,68 @@ func _build_multi() -> Control: name_edit.placeholder_text = "Dein Name" box.add_child(_row("Name", name_edit)) - ip_edit = LineEdit.new() - ip_edit.text = "127.0.0.1" - box.add_child(_row("Server-IP", ip_edit)) - - box.add_child(_text("Bots (nur Host)", 15)) - multi_bots = NumberRow.new(0, BOT_NAMES_MAX, 1) - box.add_child(multi_bots) - var brow := HBoxContainer.new() brow.add_theme_constant_override("separation", 8) box.add_child(brow) - host_btn = _button("Spiel hosten", brow, _on_host) - join_btn = _button("Beitreten", brow, _on_join) + _button("Hosten", brow, _on_host) + _button("Beitreten", brow, _show.bind("join")) + _button("Zurück", box, _show.bind("main")) + _status_row(box, "multi") + return box + + +## Zweiter Schritt beim Beitreten: der Code vom Host. +func _build_join() -> Control: + var box := _box(440) + _title(box) + box.add_child(_text("Beitreten", 22, Color(1.0, 0.82, 0.32))) + box.add_child(_text("Den Code bekommst du vom Host.", 15, + Color(0.65, 0.67, 0.72))) + + code_edit = LineEdit.new() + code_edit.placeholder_text = "z. B. K7QM" + code_edit.max_length = 6 + code_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER + code_edit.add_theme_font_size_override("font_size", 24) + code_edit.text_submitted.connect(func(_t: String) -> void: _on_join()) + box.add_child(code_edit) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + box.add_child(row) + _button("Beitreten", row, _on_join) + _button("Zurück", row, _show.bind("multi")) + _status_row(box, "join") + return box + + +## Dritter Schritt: warten, bis alle da sind. +func _build_lobby() -> Control: + var box := _box(440) + _title(box) + box.add_child(_text("Lobby", 22, Color(1.0, 0.82, 0.32))) + + code_label = _text("", 34, Color(1.0, 0.82, 0.32)) + box.add_child(code_label) + box.add_child(_text("Diesen Code an die Mitspieler geben.", 14, + Color(0.65, 0.67, 0.72))) box.add_child(HSeparator.new()) - box.add_child(_text("Lobby", 18, Color(1.0, 0.82, 0.32))) - lobby_list = _text("-", 15, Color(0.85, 0.87, 0.92)) + + lobby_list = _text("-", 16, Color(0.85, 0.87, 0.92)) box.add_child(lobby_list) + box.add_child(HSeparator.new()) - var arow := HBoxContainer.new() - arow.add_theme_constant_override("separation", 8) - box.add_child(arow) - start_btn = _button("Spiel starten", arow, _on_start) - leave_btn = _button("Trennen", arow, _on_leave) + box.add_child(_text("Bots", 15)) + lobby_bots = NumberRow.new(0, BOT_NAMES_MAX, 1) + box.add_child(lobby_bots) - _button("Zurück", box, _on_back) - status = _text("", 14, Color(0.95, 0.55, 0.5)) - box.add_child(status) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + box.add_child(row) + start_btn = _button("Spiel starten", row, _on_start) + _button("Verlassen", row, _on_leave) + _status_row(box, "lobby") return box @@ -216,66 +267,57 @@ func _spielername() -> String: func _on_host() -> void: - status.text = "" - if Net.host_game(_spielername()): - status.text = "Server läuft auf Port %d. Warte auf Mitspieler." % Net.PORT - _refresh() + _sagen("Raum wird angelegt ...") + Net.create_room(_spielername()) func _on_join() -> void: - status.text = "Verbinde..." - Net.join_game(ip_edit.text.strip_edges(), _spielername()) - _refresh() + _sagen("Verbinde ...") + Net.join_room(code_edit.text, _spielername()) func _on_leave() -> void: Net.leave() - status.text = "" - - -func _on_back() -> void: - if Net.is_online(): - Net.leave() - status.text = "" - _show("main") + _show("multi") func _on_start() -> void: var humans: int = Net.lobby.size() - var ai: int = mini(multi_bots.value, Net.MAX_HUMANS - humans) + var ai: int = mini(lobby_bots.value, Net.MAX_HUMANS - humans) if humans + ai < 2: - status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)." + _sagen("Mindestens 2 Spieler nötig (Mitspieler oder Bots).") return - Net.start_game(ai) + Net.request_start(ai) func _on_failed(msg: String) -> void: - status.text = msg - _refresh() + _sagen(msg) func _on_closed() -> void: - status.text = "Server hat die Verbindung beendet." - _refresh() + _show("multi") + _sagen("Der Raum wurde geschlossen.") +## Sobald der Server uns kennt, sind wir in der Lobby. func _refresh() -> void: - if status == null: + if lobby_list == null: return - var connected := Net.is_online() - host_btn.disabled = connected - join_btn.disabled = connected - leave_btn.disabled = not connected - start_btn.disabled = not (connected and Net.is_host) - if not connected or not Net.is_host: - multi_bots.set_enabled(not connected) - else: - multi_bots.set_enabled(true) - multi_bots.set_limit(Net.MAX_HUMANS - Net.lobby.size()) - if Net.lobby.is_empty(): - lobby_list.text = "- nicht verbunden -" - else: - var names: Array = [] - for id: int in Net.lobby: - names.append("%s%s" % [Net.lobby[id], " (Host)" if id == 1 else ""]) - lobby_list.text = "\n".join(names) + var drin := Net.is_online() and Net.lobby.has(multiplayer.get_unique_id()) + if drin and current != "lobby": + _show("lobby") + elif not drin and current == "lobby": + _show("multi") + if current != "lobby": + return + + code_label.text = Net.room_code + start_btn.disabled = not Net.is_owner + lobby_bots.anpassen(Net.MAX_HUMANS - Net.lobby.size(), Net.is_owner) + + var names: Array = [] + for id: int in Net.lobby: + names.append("%s%s" % [Net.lobby[id], " (Host)" if id == Net.owner_id else ""]) + lobby_list.text = "\n".join(names) + if not Net.is_owner: + _sagen("Der Host startet die Partie.") diff --git a/scripts/net.gd b/scripts/net.gd index 6af5aad..d13a942 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -1,19 +1,33 @@ extends Node -## Netzwerk-Verwaltung: Host/Client via ENet, Lobby und Spielstart. +## Netzwerk: Räume liegen auf dem Spielserver, Beitritt läuft über einen 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. signal lobby_changed signal game_started(config: Dictionary) 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 var player_name := "Spieler" -## peer_id -> name (enthält auch den Host mit id 1) +## peer_id -> Name. Nur Menschen; der Server steht nicht drin. var lobby: Dictionary = {} -var is_host := false +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 func _ready() -> void: @@ -24,29 +38,77 @@ func _ready() -> void: multiplayer.server_disconnected.connect(_on_server_disconnected) -func host_game(pname: String, port: int = PORT) -> bool: - player_name = pname +## Zum Entwickeln auf einen lokalen Matchmaker zeigen lassen: +## PIRATE_DICE_SERVER=http://127.0.0.1:8020 godot +func server_url() -> String: + var eigen := OS.get_environment("PIRATE_DICE_SERVER") + return eigen if eigen != "" else STANDARD_SERVER + + +# ---------------------------------------------------------------- Räume + +## 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))) + + +func join_room(code: String, pname: String) -> void: + code = code.strip_edges().to_upper() + 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) + return + room_code = code + 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. +func serve(port: int) -> bool: var peer := ENetMultiplayerPeer.new() - var err := peer.create_server(port, MAX_HUMANS - 1) - if err != OK: - connection_failed.emit("Server konnte nicht gestartet werden (Port %d belegt?)" % port) + if peer.create_server(port, MAX_HUMANS) != OK: + push_error("Port %d ist belegt." % port) return false multiplayer.multiplayer_peer = peer - is_host = true - lobby = {1: pname} - lobby_changed.emit() + dedicated = true + _leer_seit = _jetzt() + print("Raum offen auf Port %d." % port) return true -func join_game(ip: String, pname: String, port: int = PORT) -> bool: +func join_game(host: String, pname: String, port: int = PORT) -> bool: player_name = pname var peer := ENetMultiplayerPeer.new() - var err := peer.create_client(ip, port) - if err != OK: - connection_failed.emit("Verbindung zu %s:%d fehlgeschlagen" % [ip, port]) + if peer.create_client(host, port) != OK: + connection_failed.emit("Verbindung zu %s:%d fehlgeschlagen." % [host, port]) return false multiplayer.multiplayer_peer = peer - is_host = false return true @@ -55,7 +117,9 @@ func leave() -> void: multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer = null lobby.clear() - is_host = false + owner_id = 0 + is_owner = false + room_code = "" in_game = false lobby_changed.emit() @@ -66,16 +130,36 @@ func is_online() -> bool: 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 is_host: - _push_lobby.rpc_id(id, lobby) + if multiplayer.is_server(): + _push_lobby.rpc_id(id, lobby, owner_id) func _on_peer_disconnected(id: int) -> void: - if is_host: - lobby.erase(id) - _push_lobby.rpc(lobby) - lobby_changed.emit() + 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: @@ -84,50 +168,94 @@ func _on_connected() -> void: func _on_connect_fail() -> void: multiplayer.multiplayer_peer = null - connection_failed.emit("Server nicht erreichbar.") + connection_failed.emit("Raum nicht erreichbar.") func _on_server_disconnected() -> void: multiplayer.multiplayer_peer = null lobby.clear() + owner_id = 0 + is_owner = false in_game = false server_closed.emit() @rpc("any_peer", "reliable") func _register(pname: String) -> void: - if not is_host: + 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) - _push_lobby.rpc(lobby) + 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. +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("authority", "reliable") -func _push_lobby(data: Dictionary) -> void: +func _abgewiesen(grund: String) -> void: + connection_failed.emit(grund) + leave() + + +@rpc("authority", "reliable") +func _push_lobby(data: Dictionary, besitzer: int) -> void: lobby = data + owner_id = besitzer + is_owner = besitzer == multiplayer.get_unique_id() lobby_changed.emit() +# ---------------------------------------------------------------- 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() - is_host = true lobby = {1: pname} + owner_id = 1 + is_owner = true lobby_changed.emit() _begin({"peers": [1], "names": lobby.duplicate(), "ai": ai_count}) -## Nur der Host ruft das auf. +## Der Eigentümer bittet den Server um den Start. +func request_start(ai_count: int) -> void: + _req_start.rpc_id(1, ai_count) + + +@rpc("any_peer", "reliable") +func _req_start(ai_count: int) -> void: + if not multiplayer.is_server(): + return + if multiplayer.get_remote_sender_id() != owner_id: + return + start_game(ai_count) + + func start_game(ai_count: int) -> void: - if not is_host: + if not multiplayer.is_server() or in_game: return var order: Array = lobby.keys() order.sort() - var cfg := {"peers": order, "names": lobby.duplicate(), "ai": ai_count} - _begin.rpc(cfg) + if order.size() + ai_count < 2: + return + _begin.rpc({"peers": order, "names": lobby.duplicate(), "ai": ai_count}) @rpc("authority", "call_local", "reliable") diff --git a/server/matchmaker.py b/server/matchmaker.py new file mode 100644 index 0000000..68ef198 --- /dev/null +++ b/server/matchmaker.py @@ -0,0 +1,78 @@ +"""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 new file mode 100644 index 0000000..3fb50f0 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0