update
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
server/.venv/
|
||||||
|
server/__pycache__/
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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
36
Makefile
Normal 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
|
||||||
50
README.md
50
README.md
@@ -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
33
docker-compose.yml
Normal 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
|
||||||
@@ -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()):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
186
scripts/menu.gd
186
scripts/menu.gd
@@ -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.")
|
||||||
|
|||||||
188
scripts/net.gd
188
scripts/net.gd
@@ -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
78
server/matchmaker.py
Normal 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
2
server/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
Reference in New Issue
Block a user