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
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/<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
*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 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()):

View File

@@ -1,16 +1,26 @@
extends Node
## 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 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:

View File

@@ -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.")

View File

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

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