This commit is contained in:
team3
2026-09-07 17:38:17 +02:00
parent 6478559252
commit 2a9ab4c7da
11 changed files with 521 additions and 125 deletions

3
.gitignore vendored Normal file
View File

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

24
Dockerfile Normal file
View File

@@ -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"]

36
Makefile Normal file
View File

@@ -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

View File

@@ -1,6 +1,6 @@
# Liar's Dice 3D # 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. KI-Bots und einem Booster-System aus Buffs und Debuffs.
## Starten ## Starten
@@ -11,14 +11,50 @@ Das Startmenü hat drei Punkte:
* **Einzelspieler** – nur die Bot-Anzahl (1–7) einstellen und starten. Es wird * **Einzelspieler** – nur die Bot-Anzahl (1–7) einstellen und starten. Es wird
kein Netzwerk geöffnet. kein Netzwerk geöffnet.
* **Mehrspieler** – Name, Server-IP und Bot-Anzahl. **Spiel hosten** öffnet * **Mehrspieler** – nur den Namen eintragen, dann **Hosten** oder
einen Server auf Port 8910, **Beitreten** verbindet sich mit der eingetragenen **Beitreten**. Hosten führt direkt in die Lobby; dort steht der Code.
IP. Der Host startet die Partie, sobald mindestens 2 Teilnehmer zusammen Beitreten fragt zuerst nach dem Code. Niemand braucht eine IP oder eine
sind (Menschen und Bots zusammengezählt, maximal 8). 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. * **Beenden** – schließt das Spiel.
Zum Testen auf einem Rechner: eine Instanz hostet, weitere Instanzen verbinden ## Mehrspieler: wie es zusammenhängt
sich auf `127.0.0.1`.
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/<code>` schlägt nach.
* Ein **Raum** ist Peer 1 und damit die Autorität, sitzt aber nicht mit am
Tisch (`local_seat` bleibt −1, kein HUD). Ohne Spieler beendet er sich nach
zwei Minuten von selbst, dann ist der Code wieder frei.
* Der **Host** ist der erste Spieler im Raum. Geht er, rückt der nächste
nach. Nur er darf starten (`Net.request_start`).
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 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 *Fortsetzen* und *Verlassen*; im Einzelspieler hält es das Spiel samt Zeitlimit

33
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -11,7 +11,7 @@ var hud: CanvasLayer
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}] 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 host_values: Array = [] ## nur Host: seat -> Array[int]
var my_values: Array = [] ## Client-Sicht (-1 = vernebelt) 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, seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_DICE, "alive": true}) "count": START_DICE, "alive": true})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
var me := multiplayer.get_unique_id() var me := multiplayer.get_unique_id()
local_seat = -1
for i in seats.size(): for i in seats.size():
if seats[i].peer == me: if seats[i].peer == me:
local_seat = i local_seat = i
@@ -63,6 +65,8 @@ func setup(cfg: Dictionary) -> void:
add_child(table) add_child(table)
table.build(seats.size(), local_seat) table.build(seats.size(), local_seat)
# Ohne Platz am Tisch gibt es nichts anzuzeigen und nichts zu bedienen.
if local_seat >= 0:
hud = preload("res://scripts/hud.gd").new() hud = preload("res://scripts/hud.gd").new()
add_child(hud) add_child(hud)
hud.bid_pressed.connect(_on_bid_pressed) hud.bid_pressed.connect(_on_bid_pressed)
@@ -735,7 +739,7 @@ func _apply_state(st: Dictionary) -> void:
## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken. ## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken.
func _apply_fog_to_table() -> void: 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 return
var dl: Array = table.dice_of(local_seat) var dl: Array = table.dice_of(local_seat)
for i in mini(dl.size(), my_values.size()): for i in mini(dl.size(), my_values.size()):

View File

@@ -1,16 +1,26 @@
extends Node extends Node
## Wechselt zwischen Menü und laufender Partie. ## Wechselt zwischen Menü und laufender Partie.
##
## Mit `-- server <port>` läuft dieselbe Datei als Raum auf dem Spielserver:
## kein Menü, kein Bild, nur die Spiellogik.
var menu: CanvasLayer var menu: CanvasLayer
var game: Node3D var game: Node3D
func _ready() -> void: func _ready() -> void:
menu = preload("res://scripts/menu.gd").new()
add_child(menu)
Net.game_started.connect(_start_game) Net.game_started.connect(_start_game)
Net.server_closed.connect(_back_to_menu) 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. ## Das Spiel startet im Vollbild; F11 schaltet um.
func _unhandled_key_input(event: InputEvent) -> void: func _unhandled_key_input(event: InputEvent) -> void:

View File

@@ -1,23 +1,25 @@
extends CanvasLayer 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 const BOT_NAMES_MAX := 7
var screens: Dictionary = {} var screens: Dictionary = {}
var status_labels: Dictionary = {}
var current := "main"
# Einzelspieler # Einzelspieler
var solo_bots: NumberRow var solo_bots: NumberRow
# Mehrspieler # Mehrspieler
var name_edit: LineEdit var name_edit: LineEdit
var ip_edit: LineEdit var code_edit: LineEdit
var multi_bots: NumberRow
# Lobby
var lobby_bots: NumberRow
var code_label: Label
var lobby_list: Label var lobby_list: Label
var status: Label
var host_btn: Button
var join_btn: Button
var start_btn: Button var start_btn: Button
var leave_btn: Button
func _ready() -> void: func _ready() -> void:
@@ -33,6 +35,8 @@ func _ready() -> void:
screens["main"] = _screen(root, _build_main()) screens["main"] = _screen(root, _build_main())
screens["single"] = _screen(root, _build_single()) screens["single"] = _screen(root, _build_single())
screens["multi"] = _screen(root, _build_multi()) screens["multi"] = _screen(root, _build_multi())
screens["join"] = _screen(root, _build_join())
screens["lobby"] = _screen(root, _build_lobby())
Net.lobby_changed.connect(_refresh) Net.lobby_changed.connect(_refresh)
Net.connection_failed.connect(_on_failed) Net.connection_failed.connect(_on_failed)
@@ -51,12 +55,20 @@ func _screen(root: Control, content: Control) -> CenterContainer:
func _show(which: String) -> void: func _show(which: String) -> void:
current = which
for key: String in screens: for key: String in screens:
screens[key].visible = key == which screens[key].visible = key == which
if which == "multi": _sagen("")
if which == "lobby":
_refresh() _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: func _box(width: int) -> VBoxContainer:
var v := VBoxContainer.new() var v := VBoxContainer.new()
v.custom_minimum_size = Vector2(width, 0) v.custom_minimum_size = Vector2(width, 0)
@@ -94,6 +106,13 @@ func _button(text: String, parent: Control, action: Callable) -> Button:
return b 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 ## Eine Reihe anklickbarer Zahlen - bei so wenigen Werten schneller als eine
## Spinbox mit ihren winzigen Pfeilen. ## Spinbox mit ihren winzigen Pfeilen.
class NumberRow extends HBoxContainer: class NumberRow extends HBoxContainer:
@@ -120,17 +139,14 @@ class NumberRow extends HBoxContainer:
for i in _buttons.size(): for i in _buttons.size():
_buttons[i].button_pressed = _lo + i == n _buttons[i].button_pressed = _lo + i == n
## Werte oberhalb von `hi` sperren (im Mehrspieler sind die Plätze begrenzt). ## Obergrenze und Bedienbarkeit in einem Zug - getrennt würde der eine
func set_limit(hi: int) -> void: ## Aufruf den anderen wieder aufheben.
func anpassen(hi: int, on: bool) -> void:
for i in _buttons.size(): for i in _buttons.size():
_buttons[i].disabled = _lo + i > hi _buttons[i].disabled = not on or _lo + i > hi
if value > hi: if value > hi:
pick(maxi(_lo, hi)) pick(maxi(_lo, hi))
func set_enabled(on: bool) -> void:
for b in _buttons:
b.disabled = not on
func _title(box: VBoxContainer) -> void: func _title(box: VBoxContainer) -> void:
box.add_child(_text("LIAR'S DICE 3D", 40)) box.add_child(_text("LIAR'S DICE 3D", 40))
@@ -162,6 +178,7 @@ func _build_single() -> Control:
return box return box
## Erster Schritt: nur der Name, dann hosten oder beitreten.
func _build_multi() -> Control: func _build_multi() -> Control:
var box := _box(440) var box := _box(440)
_title(box) _title(box)
@@ -172,34 +189,68 @@ func _build_multi() -> Control:
name_edit.placeholder_text = "Dein Name" name_edit.placeholder_text = "Dein Name"
box.add_child(_row("Name", name_edit)) 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() var brow := HBoxContainer.new()
brow.add_theme_constant_override("separation", 8) brow.add_theme_constant_override("separation", 8)
box.add_child(brow) box.add_child(brow)
host_btn = _button("Spiel hosten", brow, _on_host) _button("Hosten", brow, _on_host)
join_btn = _button("Beitreten", brow, _on_join) _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(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(lobby_list)
box.add_child(HSeparator.new())
var arow := HBoxContainer.new() box.add_child(_text("Bots", 15))
arow.add_theme_constant_override("separation", 8) lobby_bots = NumberRow.new(0, BOT_NAMES_MAX, 1)
box.add_child(arow) box.add_child(lobby_bots)
start_btn = _button("Spiel starten", arow, _on_start)
leave_btn = _button("Trennen", arow, _on_leave)
_button("Zurück", box, _on_back) var row := HBoxContainer.new()
status = _text("", 14, Color(0.95, 0.55, 0.5)) row.add_theme_constant_override("separation", 8)
box.add_child(status) box.add_child(row)
start_btn = _button("Spiel starten", row, _on_start)
_button("Verlassen", row, _on_leave)
_status_row(box, "lobby")
return box return box
@@ -216,66 +267,57 @@ func _spielername() -> String:
func _on_host() -> void: func _on_host() -> void:
status.text = "" _sagen("Raum wird angelegt ...")
if Net.host_game(_spielername()): Net.create_room(_spielername())
status.text = "Server läuft auf Port %d. Warte auf Mitspieler." % Net.PORT
_refresh()
func _on_join() -> void: func _on_join() -> void:
status.text = "Verbinde..." _sagen("Verbinde ...")
Net.join_game(ip_edit.text.strip_edges(), _spielername()) Net.join_room(code_edit.text, _spielername())
_refresh()
func _on_leave() -> void: func _on_leave() -> void:
Net.leave() Net.leave()
status.text = "" _show("multi")
func _on_back() -> void:
if Net.is_online():
Net.leave()
status.text = ""
_show("main")
func _on_start() -> void: func _on_start() -> void:
var humans: int = Net.lobby.size() 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: if humans + ai < 2:
status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)." _sagen("Mindestens 2 Spieler nötig (Mitspieler oder Bots).")
return return
Net.start_game(ai) Net.request_start(ai)
func _on_failed(msg: String) -> void: func _on_failed(msg: String) -> void:
status.text = msg _sagen(msg)
_refresh()
func _on_closed() -> void: func _on_closed() -> void:
status.text = "Server hat die Verbindung beendet." _show("multi")
_refresh() _sagen("Der Raum wurde geschlossen.")
## Sobald der Server uns kennt, sind wir in der Lobby.
func _refresh() -> void: func _refresh() -> void:
if status == null: if lobby_list == null:
return return
var connected := Net.is_online() var drin := Net.is_online() and Net.lobby.has(multiplayer.get_unique_id())
host_btn.disabled = connected if drin and current != "lobby":
join_btn.disabled = connected _show("lobby")
leave_btn.disabled = not connected elif not drin and current == "lobby":
start_btn.disabled = not (connected and Net.is_host) _show("multi")
if not connected or not Net.is_host: if current != "lobby":
multi_bots.set_enabled(not connected) return
else:
multi_bots.set_enabled(true) code_label.text = Net.room_code
multi_bots.set_limit(Net.MAX_HUMANS - Net.lobby.size()) start_btn.disabled = not Net.is_owner
if Net.lobby.is_empty(): lobby_bots.anpassen(Net.MAX_HUMANS - Net.lobby.size(), Net.is_owner)
lobby_list.text = "- nicht verbunden -"
else:
var names: Array = [] var names: Array = []
for id: int in Net.lobby: for id: int in Net.lobby:
names.append("%s%s" % [Net.lobby[id], " (Host)" if id == 1 else ""]) names.append("%s%s" % [Net.lobby[id], " (Host)" if id == Net.owner_id else ""])
lobby_list.text = "\n".join(names) lobby_list.text = "\n".join(names)
if not Net.is_owner:
_sagen("Der Host startet die Partie.")

View File

@@ -1,19 +1,33 @@
extends Node 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 lobby_changed
signal game_started(config: Dictionary) signal game_started(config: Dictionary)
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 PORT := 8910
const MAX_HUMANS := 8 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" 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 lobby: Dictionary = {}
var is_host := false var owner_id := 0
var is_owner := false
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
func _ready() -> void: func _ready() -> void:
@@ -24,29 +38,77 @@ func _ready() -> void:
multiplayer.server_disconnected.connect(_on_server_disconnected) multiplayer.server_disconnected.connect(_on_server_disconnected)
func host_game(pname: String, port: int = PORT) -> bool: ## Zum Entwickeln auf einen lokalen Matchmaker zeigen lassen:
player_name = pname ## 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 peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, MAX_HUMANS - 1) if peer.create_server(port, MAX_HUMANS) != OK:
if err != OK: push_error("Port %d ist belegt." % port)
connection_failed.emit("Server konnte nicht gestartet werden (Port %d belegt?)" % port)
return false return false
multiplayer.multiplayer_peer = peer multiplayer.multiplayer_peer = peer
is_host = true dedicated = true
lobby = {1: pname} _leer_seit = _jetzt()
lobby_changed.emit() print("Raum offen auf Port %d." % port)
return true 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 player_name = pname
var peer := ENetMultiplayerPeer.new() var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port) if peer.create_client(host, port) != OK:
if err != OK: connection_failed.emit("Verbindung zu %s:%d fehlgeschlagen." % [host, port])
connection_failed.emit("Verbindung zu %s:%d fehlgeschlagen" % [ip, port])
return false return false
multiplayer.multiplayer_peer = peer multiplayer.multiplayer_peer = peer
is_host = false
return true return true
@@ -55,7 +117,9 @@ func leave() -> void:
multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
lobby.clear() lobby.clear()
is_host = false owner_id = 0
is_owner = false
room_code = ""
in_game = false in_game = false
lobby_changed.emit() lobby_changed.emit()
@@ -66,15 +130,35 @@ func is_online() -> bool:
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: func _on_peer_connected(id: int) -> void:
if is_host: if multiplayer.is_server():
_push_lobby.rpc_id(id, lobby) _push_lobby.rpc_id(id, lobby, owner_id)
func _on_peer_disconnected(id: int) -> void: func _on_peer_disconnected(id: int) -> void:
if is_host: if not multiplayer.is_server():
return
lobby.erase(id) lobby.erase(id)
_push_lobby.rpc(lobby) # 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() lobby_changed.emit()
@@ -84,50 +168,94 @@ func _on_connected() -> void:
func _on_connect_fail() -> void: func _on_connect_fail() -> void:
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
connection_failed.emit("Server nicht erreichbar.") connection_failed.emit("Raum nicht erreichbar.")
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
lobby.clear() lobby.clear()
owner_id = 0
is_owner = false
in_game = false in_game = false
server_closed.emit() server_closed.emit()
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func _register(pname: String) -> void: func _register(pname: String) -> void:
if not is_host: if not multiplayer.is_server():
return return
var id := multiplayer.get_remote_sender_id() 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) 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() 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") @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 lobby = data
owner_id = besitzer
is_owner = besitzer == multiplayer.get_unique_id()
lobby_changed.emit() lobby_changed.emit()
# ---------------------------------------------------------------- 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()
is_host = true
lobby = {1: pname} lobby = {1: pname}
owner_id = 1
is_owner = true
lobby_changed.emit() lobby_changed.emit()
_begin({"peers": [1], "names": lobby.duplicate(), "ai": ai_count}) _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: func start_game(ai_count: int) -> void:
if not is_host: if not multiplayer.is_server() or in_game:
return return
var order: Array = lobby.keys() var order: Array = lobby.keys()
order.sort() order.sort()
var cfg := {"peers": order, "names": lobby.duplicate(), "ai": ai_count} if order.size() + ai_count < 2:
_begin.rpc(cfg) return
_begin.rpc({"peers": order, "names": lobby.duplicate(), "ai": ai_count})
@rpc("authority", "call_local", "reliable") @rpc("authority", "call_local", "reliable")

78
server/matchmaker.py Normal file
View File

@@ -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"]}

2
server/requirements.txt Normal file
View File

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