first commit
This commit is contained in:
1
.godot/.gdignore
Normal file
1
.godot/.gdignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
15
.godot/editor/filesystem_cache10
Normal file
15
.godot/editor/filesystem_cache10
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
78098b89c88a74ebe8c20117b8754620
|
||||||
|
::res://::1788615103
|
||||||
|
anforderungen.md::TextFile::-1::1788614966::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scenes/::1788615090
|
||||||
|
main.tscn::PackedScene::-1::1788615090::0::1::::<><><>0<>0<><>::res://scripts/main.gd
|
||||||
|
::res://scripts/::1788615090
|
||||||
|
ai.gd::GDScript::1566015766439830167::1788614917::0::1::::DiceAI<>RefCounted<><>0<>0<><>::
|
||||||
|
boosters.gd::GDScript::6652902475784270619::1788614722::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
die.gd::GDScript::5159139086042829365::1788614770::0::1::::Die<>RigidBody3D<><>0<>0<><>::
|
||||||
|
game.gd::GDScript::5345037940290796967::1788615059::0::1::::<>Node3D<><>0<>0<><>::
|
||||||
|
hud.gd::GDScript::5017608389798015960::1788615059::0::1::::<>CanvasLayer<><>0<>0<><>::
|
||||||
|
main.gd::GDScript::2569134482907456858::1788615090::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
menu.gd::GDScript::8800368713613178347::1788615090::0::1::::<>CanvasLayer<><>0<>0<><>::
|
||||||
|
net.gd::GDScript::9113582408378731254::1788614741::0::1::::<>Node<><>0<>0<><>::
|
||||||
|
table.gd::GDScript::3324850135084571785::1788614825::0::1::::GameTable<>Node3D<><>0<>0<><>::
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
node_unfolds=[]
|
||||||
|
resource_unfolds=[]
|
||||||
|
nodes_folded=[]
|
||||||
|
animation_groups_folded=[]
|
||||||
11
.godot/editor/project_metadata.cfg
Normal file
11
.godot/editor/project_metadata.cfg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[game_view]
|
||||||
|
|
||||||
|
select_mode=0
|
||||||
|
|
||||||
|
[editor_metadata]
|
||||||
|
|
||||||
|
executable_path="/home/arbeit/.local/bin/godot"
|
||||||
|
|
||||||
|
[recent_files]
|
||||||
|
|
||||||
|
scenes=["res://scenes/main.tscn"]
|
||||||
25
.godot/global_script_class_cache.cfg
Normal file
25
.godot/global_script_class_cache.cfg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
list=[{
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"DiceAI",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/ai.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RigidBody3D",
|
||||||
|
"class": &"Die",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/die.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"GameTable",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/table.gd"
|
||||||
|
}]
|
||||||
BIN
.godot/uid_cache.bin
Normal file
BIN
.godot/uid_cache.bin
Normal file
Binary file not shown.
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Liar's Dice 3D
|
||||||
|
|
||||||
|
Würfel-Bluffspiel in Godot 4.7 mit echter 3D-Physik, Host/Client-Multiplayer,
|
||||||
|
KI-Bots und einem Booster-System aus Buffs und Debuffs.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
godot --path . # oder das Projekt im Godot-Editor öffnen
|
||||||
|
|
||||||
|
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).
|
||||||
|
* **Beenden** – schließt das Spiel.
|
||||||
|
|
||||||
|
Zum Testen auf einem Rechner: eine Instanz hostet, weitere Instanzen verbinden
|
||||||
|
sich auf `127.0.0.1`.
|
||||||
|
|
||||||
|
Das Spiel startet im Vollbild, **F11** schaltet um. **Esc** öffnet ein Menü mit
|
||||||
|
*Fortsetzen* und *Verlassen*; im Einzelspieler hält es das Spiel samt Zeitlimit
|
||||||
|
wirklich an, im Mehrspieler läuft die Runde weiter – sonst würde ein geöffnetes
|
||||||
|
Menü alle anderen am Tisch blockieren.
|
||||||
|
|
||||||
|
Die Bildaufteilung: Tisch mittig, aktuelles Gebot oben mittig, Würfel aller
|
||||||
|
Spieler oben rechts, Verlauf unten links, Ansage unten rechts.
|
||||||
|
|
||||||
|
Augenzahlen werden überall als gezeichnete Würfel dargestellt (`DieIcon` in
|
||||||
|
`hud.gd`), unbekannte Werte als Würfel mit Fragezeichen. Im Panel oben rechts
|
||||||
|
stehen deshalb deine eigenen Würfel offen, gegnerische verdeckt – bis auf die
|
||||||
|
per *Spähen* aufgedeckten und beim Aufdecken am Rundenende.
|
||||||
|
|
||||||
|
Der Tisch wächst mit der Spielerzahl, damit die Würfelzonen auch bei acht
|
||||||
|
Plätzen nicht aneinanderstoßen. Die Kamera schaut mit 15° Neigung fast senkrecht
|
||||||
|
von oben darauf; der eigene Platz liegt unten im Bild. `_frame_camera` in
|
||||||
|
`table.gd` rechnet den Abstand so aus, dass der Tisch genau das freie Band
|
||||||
|
zwischen den HUD-Feldern ausfüllt (`HUD_TOP`/`HUD_BOTTOM`) – bei jeder
|
||||||
|
Spielerzahl und jedem Fensterformat, auch nach dem Ändern der Fenstergröße.
|
||||||
|
|
||||||
|
## Physik und Bildrate
|
||||||
|
|
||||||
|
Zwei Engine-Vorgaben sind bewusst gesetzt, beide in `project.godot`:
|
||||||
|
|
||||||
|
* **`3d/physics_engine="Jolt Physics"`** – GodotPhysics3D hat Kontakt-Toleranzen,
|
||||||
|
die auf meterhohe Objekte ausgelegt sind (`contact_max_separation` 0.05 bei
|
||||||
|
0.075 Würfel-Halbkante). Würfel dieser Größe sanken dadurch in die Tischplatte
|
||||||
|
ein und rollten endlos weiter, sodass jeder Wurf in den Settle-Timeout lief.
|
||||||
|
Mit Jolt liegt die Wurfphase bei rund 0,9 s statt 7 s.
|
||||||
|
* **`renderer/rendering_method="mobile"`** – auf integrierten GPUs kostet
|
||||||
|
Forward+ hier mehr als das Doppelte: 22 statt 47 fps bei 30 Würfeln.
|
||||||
|
|
||||||
|
Ein Würfel ist ein einziges Renderobjekt: Körper und Augen stecken in einem
|
||||||
|
gemeinsamen Mesh mit Vertexfarben, statt 21 Augen als eigene Meshes zu hängen.
|
||||||
|
|
||||||
|
Weil Kamera und Deckenlicht beide fast senkrecht stehen, liegt der Glanzpunkt
|
||||||
|
genau auf der Würfeloberseite. Mit glänzendem Material und starkem Spot klippte
|
||||||
|
die weiße Fläche bei 255 und riss die schwarzen Augen mit hoch – gemessen lag
|
||||||
|
kein einziger Punkt-Pixel unter Luminanz 150. Deshalb ist das Würfelmaterial
|
||||||
|
matt (`roughness` 0.9, `metallic_specular` 0.0) und der Spot schwächer
|
||||||
|
zugunsten von mehr Umgebungslicht; der Kontrast Auge zu Körper liegt damit bei
|
||||||
|
rund 11:1.
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
Klassisches Liar's Dice ohne Joker. Jeder startet mit 5 Würfeln und sieht nur
|
||||||
|
die eigenen.
|
||||||
|
|
||||||
|
1. **Würfeln** – alle Würfel fallen physikalisch in den eigenen Becher.
|
||||||
|
2. **Boost wählen** – jeder bekommt 3 zufällige Booster angeboten und wählt
|
||||||
|
einen; er wirkt nur für die laufende Runde.
|
||||||
|
3. **Ansagen** – reihum wird ein Gebot "Anzahl x Augenzahl" über alle Würfel
|
||||||
|
am Tisch gemacht. Jedes Gebot muss höher sein: mehr Würfel, oder gleich
|
||||||
|
viele bei höherer Augenzahl.
|
||||||
|
4. **Anfechten** – statt zu erhöhen kann man das Vorgebot anzweifeln. Alle
|
||||||
|
Becher werden gehoben und gezählt: Stimmt das Gebot, verliert der
|
||||||
|
Anfechter einen Würfel, sonst der Bieter.
|
||||||
|
|
||||||
|
Für Boosterwahl und Ansage hat man jeweils **30 Sekunden**; die Restzeit steht
|
||||||
|
im Booster-Fenster beziehungsweise neben „Deine Ansage" und wird unter zehn
|
||||||
|
Sekunden rot. Läuft die Zeit ab, entscheidet der Host automatisch – beim Booster
|
||||||
|
zufällig, beim Gebot mit der Bot-Logik. Ohne dieses Limit würde ein abwesender
|
||||||
|
oder getrennter Mitspieler den ganzen Tisch dauerhaft blockieren.
|
||||||
|
|
||||||
|
Wer keine Würfel mehr hat, scheidet aus. Der Letzte gewinnt.
|
||||||
|
|
||||||
|
## Booster
|
||||||
|
|
||||||
|
| Booster | Art | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Neuwurf | Buff | Alle eigenen Würfel neu werfen |
|
||||||
|
| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen |
|
||||||
|
| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 |
|
||||||
|
| Zwilling | Buff | Ein anderer eigener Würfel nimmt den Wert des gewählten an |
|
||||||
|
| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt |
|
||||||
|
| Rütteln | Debuff | Jeder Gegner wirft einen zufälligen Würfel neu |
|
||||||
|
| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 |
|
||||||
|
| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr |
|
||||||
|
| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen |
|
||||||
|
| Maulkorb | Debuff | Der nächste Spieler muss bieten und darf nicht anfechten |
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/net.gd` | Autoload: ENet-Host/Client, Lobby, Spielstart |
|
||||||
|
| `scripts/boosters.gd` | Autoload: Booster-Katalog |
|
||||||
|
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
|
||||||
|
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
|
||||||
|
| `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage |
|
||||||
|
| `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) |
|
||||||
|
| `scripts/hud.gd` | Spiel-UI |
|
||||||
|
| `scripts/menu.gd` | Startmenü und Lobby |
|
||||||
|
|
||||||
|
Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten
|
||||||
|
nur ihre eigenen Würfel als Transform-Stream, gegnerische Werte erst beim
|
||||||
|
Aufdecken – Mitlesen fremder Würfel ist damit nicht möglich.
|
||||||
13
anforderungen.md
Normal file
13
anforderungen.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Kern
|
||||||
|
- Würfelspiel wie Lair's dice, Klassisch
|
||||||
|
|
||||||
|
Loops
|
||||||
|
- Lüge prüfen -> Würfeln -> Würfelzahl nennen
|
||||||
|
- Boost wählen -> Würfel verbessern
|
||||||
|
|
||||||
|
Funktionen
|
||||||
|
- Multiplayer: Host / Client
|
||||||
|
- KI: Bot
|
||||||
|
- Würfeln: Würfeln, Akzeptieren / Ablehnen, 3D-Physik
|
||||||
|
- Bootser: Buff / Debuff
|
||||||
|
-
|
||||||
33
project.godot
Normal file
33
project.godot
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
; Engine configuration file.
|
||||||
|
config_version=5
|
||||||
|
|
||||||
|
[application]
|
||||||
|
|
||||||
|
config/name="Liar's Dice 3D"
|
||||||
|
run/main_scene="res://scenes/main.tscn"
|
||||||
|
config/features=PackedStringArray("4.7", "Mobile")
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
BoosterDB="*res://scripts/boosters.gd"
|
||||||
|
Net="*res://scripts/net.gd"
|
||||||
|
|
||||||
|
[display]
|
||||||
|
|
||||||
|
window/size/viewport_width=1280
|
||||||
|
window/size/viewport_height=720
|
||||||
|
window/size/mode=3
|
||||||
|
window/stretch/mode="canvas_items"
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
; Jolt statt GodotPhysics3D: dessen Kontakt-Toleranzen sind auf meterhohe
|
||||||
|
; Objekte ausgelegt und lassen Würfel dieser Größe einsinken und endlos rollen.
|
||||||
|
3d/physics_engine="Jolt Physics"
|
||||||
|
3d/default_gravity=18.0
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
renderer/rendering_method="mobile"
|
||||||
|
renderer/rendering_method.mobile="mobile"
|
||||||
|
anti_aliasing/quality/msaa_3d=2
|
||||||
6
scenes/main.tscn
Normal file
6
scenes/main.tscn
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||||
|
|
||||||
|
[node name="Main" type="Node"]
|
||||||
|
script = ExtResource("1_main")
|
||||||
139
scripts/ai.gd
Normal file
139
scripts/ai.gd
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
extends RefCounted
|
||||||
|
class_name DiceAI
|
||||||
|
## Entscheidungslogik für KI-Spieler.
|
||||||
|
|
||||||
|
|
||||||
|
static func _pmf(n: int, k: int, p: float) -> float:
|
||||||
|
var c := 1.0
|
||||||
|
for i in k:
|
||||||
|
c = c * float(n - i) / float(i + 1)
|
||||||
|
return c * pow(p, k) * pow(1.0 - p, n - k)
|
||||||
|
|
||||||
|
|
||||||
|
## P(X >= k) für X ~ Bin(n, p)
|
||||||
|
static func tail(n: int, k: int, p: float) -> float:
|
||||||
|
if k <= 0:
|
||||||
|
return 1.0
|
||||||
|
if k > n:
|
||||||
|
return 0.0
|
||||||
|
var s := 0.0
|
||||||
|
for i in range(k, n + 1):
|
||||||
|
s += _pmf(n, i, p)
|
||||||
|
return clampf(s, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
static func _own(values: Array, face: int) -> int:
|
||||||
|
var c := 0
|
||||||
|
for v in values:
|
||||||
|
if v == face:
|
||||||
|
c += 1
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
## Wahrscheinlichkeit, dass ein Gebot (count, face) hält.
|
||||||
|
static func chance(values: Array, unknown: int, count: int, face: int) -> float:
|
||||||
|
return tail(unknown, count - _own(values, face), 1.0 / 6.0)
|
||||||
|
|
||||||
|
|
||||||
|
static func choose_action(values: Array, total_dice: int, bid: Dictionary,
|
||||||
|
min_raise: int, must_bid: bool, rng: RandomNumberGenerator) -> Dictionary:
|
||||||
|
var known: Array = []
|
||||||
|
for v in values:
|
||||||
|
if v > 0:
|
||||||
|
known.append(v)
|
||||||
|
var unknown := total_dice - known.size()
|
||||||
|
|
||||||
|
if bid.is_empty():
|
||||||
|
var op := _opening(known, unknown, rng)
|
||||||
|
return {"type": "bid", "count": op.count, "face": op.face}
|
||||||
|
|
||||||
|
var hold := chance(known, unknown, bid.count, bid.face)
|
||||||
|
var raise_ = _best_raise(known, unknown, total_dice, bid, min_raise)
|
||||||
|
|
||||||
|
if not must_bid:
|
||||||
|
var doubt := 0.30 + rng.randf_range(-0.07, 0.07)
|
||||||
|
if hold < doubt and (raise_.is_empty() or raise_.p < 0.32):
|
||||||
|
return {"type": "challenge"}
|
||||||
|
if raise_.is_empty():
|
||||||
|
if must_bid:
|
||||||
|
# Notgebot: minimal erhöhen, auch wenn unwahrscheinlich
|
||||||
|
var c: int = bid.count + maxi(1, min_raise)
|
||||||
|
if c > total_dice:
|
||||||
|
return {"type": "challenge"} if not must_bid else {"type": "bid", "count": total_dice, "face": bid.face}
|
||||||
|
return {"type": "bid", "count": c, "face": bid.face}
|
||||||
|
return {"type": "challenge"}
|
||||||
|
return {"type": "bid", "count": raise_.count, "face": raise_.face}
|
||||||
|
|
||||||
|
|
||||||
|
static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) -> Dictionary:
|
||||||
|
var best_face := rng.randi_range(1, 6)
|
||||||
|
var best := -1
|
||||||
|
for f in range(1, 7):
|
||||||
|
var c := _own(known, f)
|
||||||
|
if c > best:
|
||||||
|
best = c
|
||||||
|
best_face = f
|
||||||
|
var count := maxi(1, best + int(round(unknown / 6.0)))
|
||||||
|
return {"count": count, "face": best_face}
|
||||||
|
|
||||||
|
|
||||||
|
static func _best_raise(known: Array, unknown: int, total: int, bid: Dictionary, min_raise: int) -> Dictionary:
|
||||||
|
var best := {}
|
||||||
|
for face in range(1, 7):
|
||||||
|
for count in range(1, total + 1):
|
||||||
|
if not _is_higher(count, face, bid, min_raise):
|
||||||
|
continue
|
||||||
|
var p := chance(known, unknown, count, face)
|
||||||
|
if p < 0.42:
|
||||||
|
continue
|
||||||
|
if best.is_empty() or count < best.count or (count == best.count and p > best.p):
|
||||||
|
best = {"count": count, "face": face, "p": p}
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) -> bool:
|
||||||
|
if count >= bid.count + min_raise:
|
||||||
|
return true
|
||||||
|
if min_raise > 1:
|
||||||
|
return false
|
||||||
|
return count == bid.count and face > bid.face
|
||||||
|
|
||||||
|
|
||||||
|
static func choose_booster(offer: Array, values: Array, dice_left: int, rng: RandomNumberGenerator) -> Dictionary:
|
||||||
|
var scores: Array = []
|
||||||
|
for id in offer:
|
||||||
|
var b: Dictionary = BoosterDB.get_booster(id)
|
||||||
|
var s := rng.randf()
|
||||||
|
if b.kind == BoosterDB.Kind.BUFF:
|
||||||
|
s += 0.35
|
||||||
|
if dice_left <= 2 and b.kind == BoosterDB.Kind.BUFF:
|
||||||
|
s += 0.35
|
||||||
|
scores.append(s)
|
||||||
|
var pick := 0
|
||||||
|
for i in scores.size():
|
||||||
|
if scores[i] > scores[pick]:
|
||||||
|
pick = i
|
||||||
|
var id: String = offer[pick]
|
||||||
|
return {"id": id, "target": _target_for(id, values)}
|
||||||
|
|
||||||
|
|
||||||
|
static func _target_for(id: String, values: Array) -> int:
|
||||||
|
if values.is_empty():
|
||||||
|
return 0
|
||||||
|
if id == "twin":
|
||||||
|
# Würfel mit dem häufigsten Wert
|
||||||
|
var counts := {}
|
||||||
|
for v in values:
|
||||||
|
counts[v] = counts.get(v, 0) + 1
|
||||||
|
var best_v: int = values[0]
|
||||||
|
for v: int in counts:
|
||||||
|
if counts[v] > counts.get(best_v, 0):
|
||||||
|
best_v = v
|
||||||
|
return values.find(best_v)
|
||||||
|
# sonst: schwächster Würfel
|
||||||
|
var idx := 0
|
||||||
|
for i in values.size():
|
||||||
|
if values[i] < values[idx]:
|
||||||
|
idx = i
|
||||||
|
return idx
|
||||||
1
scripts/ai.gd.uid
Normal file
1
scripts/ai.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://wkuby2u01vbb
|
||||||
82
scripts/boosters.gd
Normal file
82
scripts/boosters.gd
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
extends Node
|
||||||
|
## Katalog aller Booster. Jede Runde bekommt jeder Spieler 3 davon
|
||||||
|
## zufällig angeboten und wählt genau einen aus.
|
||||||
|
|
||||||
|
enum Kind { BUFF, DEBUFF }
|
||||||
|
|
||||||
|
const ALL := [
|
||||||
|
{
|
||||||
|
"id": "reroll_all", "kind": Kind.BUFF, "target": false,
|
||||||
|
"name": "Neuwurf",
|
||||||
|
"desc": "Wirf alle deine Würfel noch einmal neu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reroll_one", "kind": Kind.BUFF, "target": true,
|
||||||
|
"name": "Feinschliff",
|
||||||
|
"desc": "Wirf einen selbst gewählten Würfel neu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "set_six", "kind": Kind.BUFF, "target": true,
|
||||||
|
"name": "Glücksgriff",
|
||||||
|
"desc": "Ein gewählter Würfel wird zur 6.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "twin", "kind": Kind.BUFF, "target": true,
|
||||||
|
"name": "Zwilling",
|
||||||
|
"desc": "Ein anderer deiner Würfel nimmt den Wert des gewählten an.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "peek", "kind": Kind.BUFF, "target": false,
|
||||||
|
"name": "Spähen",
|
||||||
|
"desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "enemy_reroll", "kind": Kind.DEBUFF, "target": false,
|
||||||
|
"name": "Rütteln",
|
||||||
|
"desc": "Jeder Gegner muss einen zufälligen Würfel neu werfen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "enemy_lower", "kind": Kind.DEBUFF, "target": false,
|
||||||
|
"name": "Absenken",
|
||||||
|
"desc": "Bei jedem Gegner sinkt ein zufälliger Würfel um 1 (1 bleibt 1).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fog", "kind": Kind.DEBUFF, "target": false,
|
||||||
|
"name": "Nebel",
|
||||||
|
"desc": "Jeder Gegner verliert die Sicht auf einen seiner eigenen Würfel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raise_min", "kind": Kind.DEBUFF, "target": false,
|
||||||
|
"name": "Störfeuer",
|
||||||
|
"desc": "Alle Gegner müssen diese Runde um mindestens 2 erhöhen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gag", "kind": Kind.DEBUFF, "target": false,
|
||||||
|
"name": "Maulkorb",
|
||||||
|
"desc": "Der nächste Spieler muss bieten und darf nicht anfechten.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func get_booster(id: String) -> Dictionary:
|
||||||
|
for b in ALL:
|
||||||
|
if b.id == id:
|
||||||
|
return b
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func needs_target(id: String) -> bool:
|
||||||
|
var b := get_booster(id)
|
||||||
|
return b.get("target", false)
|
||||||
|
|
||||||
|
|
||||||
|
func draw_offer(rng: RandomNumberGenerator, amount: int = 3) -> Array:
|
||||||
|
var pool := ALL.duplicate()
|
||||||
|
var out: Array = []
|
||||||
|
for i in amount:
|
||||||
|
if pool.is_empty():
|
||||||
|
break
|
||||||
|
var k := rng.randi_range(0, pool.size() - 1)
|
||||||
|
out.append(pool[k].id)
|
||||||
|
pool.remove_at(k)
|
||||||
|
return out
|
||||||
1
scripts/boosters.gd.uid
Normal file
1
scripts/boosters.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c11yljsc2s3x4
|
||||||
206
scripts/die.gd
Normal file
206
scripts/die.gd
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
extends RigidBody3D
|
||||||
|
class_name Die
|
||||||
|
## Ein physikalischer Würfel mit abgerundeten Kanten.
|
||||||
|
## Der Wert wird aus der Ruhelage abgelesen.
|
||||||
|
|
||||||
|
const SIZE := 0.15
|
||||||
|
const HALF := SIZE * 0.5
|
||||||
|
const CORNER := SIZE * 0.12 ## Rundungsradius der Kanten und Ecken
|
||||||
|
const SEGMENTS := 6 ## Unterteilung je Würfelseite
|
||||||
|
const PIP_R := SIZE * 0.11 ## Radius eines Auges
|
||||||
|
const PIP_SEGS := 8
|
||||||
|
|
||||||
|
const COL_BODY := Color(0.94, 0.92, 0.86)
|
||||||
|
const COL_PIP := Color(0.03, 0.03, 0.04)
|
||||||
|
|
||||||
|
## Lokale Achse -> aufgedruckter Wert (gegenüberliegende Seiten ergeben 7)
|
||||||
|
const FACE_AXES: Array[Vector3] = [
|
||||||
|
Vector3.RIGHT, Vector3.LEFT, Vector3.UP, Vector3.DOWN, Vector3.BACK, Vector3.FORWARD
|
||||||
|
]
|
||||||
|
const FACE_VALUES: Array[int] = [1, 6, 2, 5, 3, 4]
|
||||||
|
|
||||||
|
const PIPS := {
|
||||||
|
1: [Vector2(0, 0)],
|
||||||
|
2: [Vector2(-1, -1), Vector2(1, 1)],
|
||||||
|
3: [Vector2(-1, -1), Vector2(0, 0), Vector2(1, 1)],
|
||||||
|
4: [Vector2(-1, -1), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 1)],
|
||||||
|
5: [Vector2(-1, -1), Vector2(-1, 1), Vector2(0, 0), Vector2(1, -1), Vector2(1, 1)],
|
||||||
|
6: [Vector2(-1, -1), Vector2(-1, 0), Vector2(-1, 1),
|
||||||
|
Vector2(1, -1), Vector2(1, 0), Vector2(1, 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Körper und Augen stecken in einem gemeinsamen Mesh mit Vertexfarben,
|
||||||
|
# damit ein Würfel nur ein einziges Renderobjekt ist.
|
||||||
|
static var _mesh: ArrayMesh
|
||||||
|
static var _mat: StandardMaterial3D
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
mass = 0.03
|
||||||
|
contact_monitor = false
|
||||||
|
can_sleep = true
|
||||||
|
linear_damp = 0.25
|
||||||
|
angular_damp = 0.35
|
||||||
|
|
||||||
|
var pm := PhysicsMaterial.new()
|
||||||
|
pm.friction = 0.6
|
||||||
|
pm.bounce = 0.25
|
||||||
|
physics_material_override = pm
|
||||||
|
|
||||||
|
var shape := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3.ONE * SIZE
|
||||||
|
shape.shape = box
|
||||||
|
add_child(shape)
|
||||||
|
|
||||||
|
_build_visuals()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Aufbau
|
||||||
|
|
||||||
|
static func _face_tangents(n: Vector3) -> Array:
|
||||||
|
var u := Vector3.UP if absf(n.y) < 0.9 else Vector3.RIGHT
|
||||||
|
u = (u - n * u.dot(n)).normalized()
|
||||||
|
return [u, n.cross(u)]
|
||||||
|
|
||||||
|
|
||||||
|
## Schiebt einen Punkt der Würfeloberfläche auf die abgerundete Form
|
||||||
|
## (Minkowski-Summe aus kleinerem Würfel und Kugel).
|
||||||
|
static func _round_point(p: Vector3) -> Vector3:
|
||||||
|
var inner := HALF - CORNER
|
||||||
|
var c := Vector3(clampf(p.x, -inner, inner), clampf(p.y, -inner, inner),
|
||||||
|
clampf(p.z, -inner, inner))
|
||||||
|
var d := p - c
|
||||||
|
if d.length_squared() < 1e-9:
|
||||||
|
return p
|
||||||
|
return c + d.normalized() * CORNER
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_mesh() -> ArrayMesh:
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
for f in FACE_AXES.size():
|
||||||
|
var n: Vector3 = FACE_AXES[f]
|
||||||
|
var t := _face_tangents(n)
|
||||||
|
st.set_color(COL_BODY)
|
||||||
|
for i in SEGMENTS:
|
||||||
|
for j in SEGMENTS:
|
||||||
|
var quad: Array[Vector3] = []
|
||||||
|
for c: Vector2 in [Vector2(i, j), Vector2(i + 1, j),
|
||||||
|
Vector2(i + 1, j + 1), Vector2(i, j + 1)]:
|
||||||
|
var u := (c.x / SEGMENTS) * 2.0 - 1.0
|
||||||
|
var v := (c.y / SEGMENTS) * 2.0 - 1.0
|
||||||
|
quad.append(_round_point(n * HALF + t[0] * u * HALF + t[1] * v * HALF))
|
||||||
|
for k in [0, 2, 1, 0, 3, 2]:
|
||||||
|
st.add_vertex(quad[k])
|
||||||
|
_add_pips(st, n, t, FACE_VALUES[f])
|
||||||
|
st.index()
|
||||||
|
st.generate_normals()
|
||||||
|
return st.commit()
|
||||||
|
|
||||||
|
|
||||||
|
## Augen als flache Scheiben knapp über der Seitenfläche, gleiche Surface.
|
||||||
|
static func _add_pips(st: SurfaceTool, n: Vector3, t: Array, value: int) -> void:
|
||||||
|
st.set_color(COL_PIP)
|
||||||
|
var spread := HALF * 0.50
|
||||||
|
var lift: Vector3 = n * (HALF + 0.0012)
|
||||||
|
for p: Vector2 in PIPS[value]:
|
||||||
|
var mid: Vector3 = lift + t[0] * p.x * spread + t[1] * p.y * spread
|
||||||
|
var ring: Array[Vector3] = []
|
||||||
|
for k in PIP_SEGS:
|
||||||
|
var a := TAU * k / PIP_SEGS
|
||||||
|
ring.append(mid + (t[0] * cos(a) + t[1] * sin(a)) * PIP_R)
|
||||||
|
for k in PIP_SEGS:
|
||||||
|
st.add_vertex(mid)
|
||||||
|
st.add_vertex(ring[(k + 1) % PIP_SEGS])
|
||||||
|
st.add_vertex(ring[k])
|
||||||
|
|
||||||
|
|
||||||
|
func _build_visuals() -> void:
|
||||||
|
if _mesh == null:
|
||||||
|
_mesh = _build_mesh()
|
||||||
|
_mat = StandardMaterial3D.new()
|
||||||
|
_mat.vertex_color_use_as_albedo = true
|
||||||
|
# Kamera und Deckenlicht stehen beide senkrecht: bei glatter Oberfläche
|
||||||
|
# liegt der Glanzpunkt genau auf der Fläche und hellt die Augen auf.
|
||||||
|
_mat.roughness = 0.9
|
||||||
|
_mat.metallic_specular = 0.0
|
||||||
|
var body := MeshInstance3D.new()
|
||||||
|
body.mesh = _mesh
|
||||||
|
body.material_override = _mat
|
||||||
|
add_child(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Zustand
|
||||||
|
|
||||||
|
## Wert der aktuell nach oben zeigenden Seite.
|
||||||
|
func top_value() -> int:
|
||||||
|
var best := -2.0
|
||||||
|
var val := 1
|
||||||
|
for i in FACE_AXES.size():
|
||||||
|
var d: float = (global_transform.basis * FACE_AXES[i]).y
|
||||||
|
if d > best:
|
||||||
|
best = d
|
||||||
|
val = FACE_VALUES[i]
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
## Wie sauber der Würfel liegt (1.0 = perfekt flach).
|
||||||
|
func flatness() -> float:
|
||||||
|
var best := -2.0
|
||||||
|
for a in FACE_AXES:
|
||||||
|
best = maxf(best, (global_transform.basis * a).y)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
func is_settled() -> bool:
|
||||||
|
return sleeping or (linear_velocity.length() < 0.06 and angular_velocity.length() < 0.15)
|
||||||
|
|
||||||
|
|
||||||
|
func throw_from(origin: Vector3, rng: RandomNumberGenerator) -> void:
|
||||||
|
freeze = false
|
||||||
|
sleeping = false
|
||||||
|
var t := Transform3D()
|
||||||
|
t.basis = Basis(Vector3(rng.randf(), rng.randf(), rng.randf()).normalized(), rng.randf() * TAU)
|
||||||
|
t.origin = origin
|
||||||
|
global_transform = t
|
||||||
|
linear_velocity = Vector3(rng.randf_range(-1.5, 1.5), rng.randf_range(-0.3, 0.0),
|
||||||
|
rng.randf_range(-1.5, 1.5))
|
||||||
|
angular_velocity = Vector3(rng.randf_range(-24, 24), rng.randf_range(-24, 24),
|
||||||
|
rng.randf_range(-24, 24))
|
||||||
|
|
||||||
|
|
||||||
|
func nudge(rng: RandomNumberGenerator) -> void:
|
||||||
|
sleeping = false
|
||||||
|
apply_central_impulse(Vector3(rng.randf_range(-0.04, 0.04), 0.06, rng.randf_range(-0.04, 0.04)))
|
||||||
|
apply_torque_impulse(Vector3(rng.randf_range(-0.008, 0.008), rng.randf_range(-0.008, 0.008),
|
||||||
|
rng.randf_range(-0.008, 0.008)))
|
||||||
|
|
||||||
|
|
||||||
|
## Dreht den Würfel so, dass `value` oben liegt (für Booster-Effekte).
|
||||||
|
func snap_to_value(value: int, rng: RandomNumberGenerator) -> void:
|
||||||
|
var idx := FACE_VALUES.find(value)
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
var a: Vector3 = FACE_AXES[idx]
|
||||||
|
var b := Vector3.RIGHT if absf(a.x) < 0.9 else Vector3.UP
|
||||||
|
b = (b - a * b.dot(a)).normalized()
|
||||||
|
var c := a.cross(b)
|
||||||
|
|
||||||
|
var yaw := rng.randf() * TAU
|
||||||
|
var wb := Vector3(cos(yaw), 0.0, sin(yaw))
|
||||||
|
var wa := Vector3.UP
|
||||||
|
var wc := wa.cross(wb)
|
||||||
|
|
||||||
|
var t := global_transform
|
||||||
|
t.basis = Basis(wb, wa, wc) * Basis(b, a, c).transposed()
|
||||||
|
global_transform = t
|
||||||
|
if not freeze:
|
||||||
|
sleeping = false
|
||||||
|
linear_velocity = Vector3.ZERO
|
||||||
|
angular_velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func set_kinematic(on: bool) -> void:
|
||||||
|
freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||||
|
freeze = on
|
||||||
1
scripts/die.gd.uid
Normal file
1
scripts/die.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cfrejcvw78gen
|
||||||
716
scripts/game.gd
Normal file
716
scripts/game.gd
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Host-autoritative Spiellogik + Client-Ansicht.
|
||||||
|
|
||||||
|
const START_DICE := 5
|
||||||
|
const BOOST_TIMEOUT := 30.0
|
||||||
|
const BID_TIMEOUT := 30.0
|
||||||
|
|
||||||
|
var table: GameTable
|
||||||
|
var hud: CanvasLayer
|
||||||
|
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
var seats: Array = [] ## [{peer, name, ai, count, alive}]
|
||||||
|
var local_seat := 0
|
||||||
|
var host_values: Array = [] ## nur Host: seat -> Array[int]
|
||||||
|
var my_values: Array = [] ## Client-Sicht (-1 = vernebelt)
|
||||||
|
|
||||||
|
# Rundenzustand (nur Host)
|
||||||
|
var bid: Dictionary = {}
|
||||||
|
var turn := 0
|
||||||
|
var starter := 0
|
||||||
|
var fogged: Dictionary = {} ## seat -> int index
|
||||||
|
var peeked: Dictionary = {} ## viewer -> {target_seat: [idx, value]}
|
||||||
|
var min_raise: Dictionary = {} ## seat -> int
|
||||||
|
var must_bid: Dictionary = {} ## seat -> bool
|
||||||
|
var chosen_boost: Dictionary = {}
|
||||||
|
var log_lines: Array = []
|
||||||
|
var phase := "lobby"
|
||||||
|
var banner := ""
|
||||||
|
|
||||||
|
var _answered: Dictionary = {}
|
||||||
|
var _stream := false
|
||||||
|
var _stream_acc := 0.0
|
||||||
|
var _last_phase := ""
|
||||||
|
var deadline := 0.0 ## Sekunden-Zeitstempel, 0 = keine Frist
|
||||||
|
var _pause_left := 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
rng.randomize()
|
||||||
|
|
||||||
|
|
||||||
|
func setup(cfg: Dictionary) -> void:
|
||||||
|
seats.clear()
|
||||||
|
var peers: Array = cfg.peers
|
||||||
|
for p in peers:
|
||||||
|
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
|
||||||
|
"count": START_DICE, "alive": true})
|
||||||
|
for i in int(cfg.ai):
|
||||||
|
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
|
||||||
|
"count": START_DICE, "alive": true})
|
||||||
|
|
||||||
|
var me := multiplayer.get_unique_id()
|
||||||
|
for i in seats.size():
|
||||||
|
if seats[i].peer == me:
|
||||||
|
local_seat = i
|
||||||
|
|
||||||
|
table = GameTable.new()
|
||||||
|
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)
|
||||||
|
|
||||||
|
for i in seats.size():
|
||||||
|
table.set_dice_count(i, START_DICE)
|
||||||
|
table.set_dice_visible(i, false)
|
||||||
|
table.set_cup_down(i, i != local_seat)
|
||||||
|
host_values.append([])
|
||||||
|
|
||||||
|
_refresh_labels()
|
||||||
|
if multiplayer.is_server():
|
||||||
|
multiplayer.peer_disconnected.connect(_on_peer_lost)
|
||||||
|
_host_loop()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_peer_lost(peer: int) -> void:
|
||||||
|
var seat := _seat_of(peer)
|
||||||
|
if seat < 0 or seats[seat].ai:
|
||||||
|
return
|
||||||
|
seats[seat].ai = true
|
||||||
|
seats[seat].peer = 0
|
||||||
|
_log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name)
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_labels() -> void:
|
||||||
|
for i in seats.size():
|
||||||
|
var s = seats[i]
|
||||||
|
# Die Würfelzahl steht im Panel oben rechts, hier reicht der Name.
|
||||||
|
# Lange Namen werden gekürzt, damit sie auf den Becher passen.
|
||||||
|
var short: String = s.name if s.name.length() <= 9 else s.name.substr(0, 8) + "…"
|
||||||
|
var txt: String = short if s.alive else "%s · raus" % short
|
||||||
|
var col := Color(1, 1, 1)
|
||||||
|
if not s.alive:
|
||||||
|
col = Color(0.5, 0.5, 0.55)
|
||||||
|
elif phase == "bid" and i == turn:
|
||||||
|
col = Color(1.0, 0.85, 0.3)
|
||||||
|
table.set_label(i, txt, col)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Host-Ablauf
|
||||||
|
|
||||||
|
func _host_loop() -> void:
|
||||||
|
_log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE])
|
||||||
|
starter = 0
|
||||||
|
while _alive_count() > 1:
|
||||||
|
await _phase_roll()
|
||||||
|
await _phase_boost()
|
||||||
|
var result: Dictionary = await _phase_bid()
|
||||||
|
await _phase_reveal(result)
|
||||||
|
phase = "over"
|
||||||
|
var winner := ""
|
||||||
|
for s in seats:
|
||||||
|
if s.alive:
|
||||||
|
winner = s.name
|
||||||
|
banner = "%s gewinnt!" % winner
|
||||||
|
_log(banner)
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _alive_count() -> int:
|
||||||
|
var n := 0
|
||||||
|
for s in seats:
|
||||||
|
if s.alive:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
func _alive_seats() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for i in seats.size():
|
||||||
|
if seats[i].alive:
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _next_alive(from: int) -> int:
|
||||||
|
var i := from
|
||||||
|
for _n in seats.size():
|
||||||
|
i = (i + 1) % seats.size()
|
||||||
|
if seats[i].alive:
|
||||||
|
return i
|
||||||
|
return from
|
||||||
|
|
||||||
|
|
||||||
|
func _total_dice() -> int:
|
||||||
|
var n := 0
|
||||||
|
for s in seats:
|
||||||
|
if s.alive:
|
||||||
|
n += s.count
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
func _now() -> float:
|
||||||
|
return Time.get_ticks_msec() / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
func _phase_roll() -> void:
|
||||||
|
phase = "roll"
|
||||||
|
deadline = 0.0
|
||||||
|
bid = {}
|
||||||
|
fogged.clear()
|
||||||
|
peeked.clear()
|
||||||
|
min_raise.clear()
|
||||||
|
must_bid.clear()
|
||||||
|
chosen_boost.clear()
|
||||||
|
banner = ""
|
||||||
|
_log("--- Neue Runde ---")
|
||||||
|
|
||||||
|
for i in _alive_seats():
|
||||||
|
table.set_dice_count(i, seats[i].count)
|
||||||
|
table.set_dice_visible(i, i == local_seat)
|
||||||
|
table.set_cup_down(i, i != local_seat)
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
_stream = true
|
||||||
|
for i in _alive_seats():
|
||||||
|
table.throw_dice(i, rng)
|
||||||
|
await _await_settled(_alive_seats())
|
||||||
|
_stream = false
|
||||||
|
|
||||||
|
for i in _alive_seats():
|
||||||
|
host_values[i] = []
|
||||||
|
for d in table.dice_of(i):
|
||||||
|
host_values[i].append(d.top_value())
|
||||||
|
table.freeze_dice(i)
|
||||||
|
_snapshot_all()
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _await_settled(list: Array, timeout := 7.0) -> void:
|
||||||
|
var step := 1.0 / float(Engine.physics_ticks_per_second)
|
||||||
|
var t := 0.0
|
||||||
|
while t < timeout:
|
||||||
|
await get_tree().physics_frame
|
||||||
|
t += step
|
||||||
|
if t < 0.7:
|
||||||
|
continue
|
||||||
|
var ok := true
|
||||||
|
for s in list:
|
||||||
|
for d in table.dice_of(s):
|
||||||
|
if d.freeze:
|
||||||
|
continue
|
||||||
|
if not d.is_settled():
|
||||||
|
ok = false
|
||||||
|
elif d.flatness() < 0.86:
|
||||||
|
d.nudge(rng)
|
||||||
|
ok = false
|
||||||
|
if ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _phase_boost() -> void:
|
||||||
|
phase = "boost"
|
||||||
|
_answered.clear()
|
||||||
|
var offers := {}
|
||||||
|
for i in _alive_seats():
|
||||||
|
offers[i] = BoosterDB.draw_offer(rng)
|
||||||
|
if seats[i].ai:
|
||||||
|
var pick := DiceAI.choose_booster(offers[i], host_values[i], seats[i].count, rng)
|
||||||
|
chosen_boost[i] = pick
|
||||||
|
_answered[i] = true
|
||||||
|
else:
|
||||||
|
_send_offer(i, offers[i])
|
||||||
|
deadline = _now() + BOOST_TIMEOUT
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
await _wait_until(_all_boosts_in, BOOST_TIMEOUT)
|
||||||
|
|
||||||
|
for i in _alive_seats():
|
||||||
|
if not chosen_boost.has(i):
|
||||||
|
var fallback: String = offers[i][rng.randi_range(0, offers[i].size() - 1)]
|
||||||
|
chosen_boost[i] = {"id": fallback, "target": 0}
|
||||||
|
await _apply_boosters()
|
||||||
|
_snapshot_all()
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _all_boosts_in() -> bool:
|
||||||
|
for i in _alive_seats():
|
||||||
|
if not _answered.has(i) and not seats[i].ai:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _send_offer(seat: int, ids: Array) -> void:
|
||||||
|
var peer: int = seats[seat].peer
|
||||||
|
if peer == multiplayer.get_unique_id():
|
||||||
|
hud.show_booster_offer(ids, my_values)
|
||||||
|
else:
|
||||||
|
_offer.rpc_id(peer, ids)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_boosters() -> void:
|
||||||
|
var rerolls := {} ## seat -> Array[int]
|
||||||
|
var order := _alive_seats()
|
||||||
|
|
||||||
|
for seat in order:
|
||||||
|
var pick: Dictionary = chosen_boost[seat]
|
||||||
|
var id: String = pick.id
|
||||||
|
var target: int = clampi(int(pick.get("target", 0)), 0, maxi(0, host_values[seat].size() - 1))
|
||||||
|
var b: Dictionary = BoosterDB.get_booster(id)
|
||||||
|
_log("%s nutzt %s." % [seats[seat].name, b.name])
|
||||||
|
|
||||||
|
match id:
|
||||||
|
"reroll_all":
|
||||||
|
rerolls[seat] = range(host_values[seat].size())
|
||||||
|
"reroll_one":
|
||||||
|
rerolls[seat] = _merge(rerolls.get(seat, []), [target])
|
||||||
|
"enemy_reroll":
|
||||||
|
for o in order:
|
||||||
|
if o != seat and host_values[o].size() > 0:
|
||||||
|
var k := rng.randi_range(0, host_values[o].size() - 1)
|
||||||
|
rerolls[o] = _merge(rerolls.get(o, []), [k])
|
||||||
|
"gag":
|
||||||
|
must_bid[_next_alive(seat)] = true
|
||||||
|
"raise_min":
|
||||||
|
for o in order:
|
||||||
|
if o != seat:
|
||||||
|
min_raise[o] = 2
|
||||||
|
|
||||||
|
if not rerolls.is_empty():
|
||||||
|
_stream = true
|
||||||
|
for seat: int in rerolls:
|
||||||
|
_throw_subset(seat, rerolls[seat])
|
||||||
|
await _await_settled(rerolls.keys())
|
||||||
|
_stream = false
|
||||||
|
for seat: int in rerolls:
|
||||||
|
var dl: Array = table.dice_of(seat)
|
||||||
|
for k: int in rerolls[seat]:
|
||||||
|
if k < dl.size():
|
||||||
|
host_values[seat][k] = dl[k].top_value()
|
||||||
|
table.freeze_dice(seat)
|
||||||
|
|
||||||
|
for seat in order:
|
||||||
|
var pick: Dictionary = chosen_boost[seat]
|
||||||
|
var id: String = pick.id
|
||||||
|
var vals: Array = host_values[seat]
|
||||||
|
var target: int = clampi(int(pick.get("target", 0)), 0, maxi(0, vals.size() - 1))
|
||||||
|
match id:
|
||||||
|
"set_six":
|
||||||
|
_set_die(seat, target, 6)
|
||||||
|
"twin":
|
||||||
|
if vals.size() > 1:
|
||||||
|
var others: Array = []
|
||||||
|
for k in vals.size():
|
||||||
|
if k != target:
|
||||||
|
others.append(k)
|
||||||
|
_set_die(seat, others[rng.randi_range(0, others.size() - 1)], vals[target])
|
||||||
|
"enemy_lower":
|
||||||
|
for o in order:
|
||||||
|
if o != seat and host_values[o].size() > 0:
|
||||||
|
var k := rng.randi_range(0, host_values[o].size() - 1)
|
||||||
|
_set_die(o, k, maxi(1, host_values[o][k] - 1))
|
||||||
|
"peek":
|
||||||
|
var info := {}
|
||||||
|
for o in order:
|
||||||
|
if o != seat and host_values[o].size() > 0:
|
||||||
|
var k := rng.randi_range(0, host_values[o].size() - 1)
|
||||||
|
info[o] = [k, host_values[o][k]]
|
||||||
|
peeked[seat] = info
|
||||||
|
"fog":
|
||||||
|
for o in order:
|
||||||
|
if o != seat and host_values[o].size() > 0:
|
||||||
|
fogged[o] = rng.randi_range(0, host_values[o].size() - 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _merge(a: Array, b: Array) -> Array:
|
||||||
|
var out := a.duplicate()
|
||||||
|
for v in b:
|
||||||
|
if not out.has(v):
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _set_die(seat: int, idx: int, value: int) -> void:
|
||||||
|
if idx < 0 or idx >= host_values[seat].size():
|
||||||
|
return
|
||||||
|
host_values[seat][idx] = value
|
||||||
|
var dl: Array = table.dice_of(seat)
|
||||||
|
if idx < dl.size():
|
||||||
|
dl[idx].snap_to_value(value, rng)
|
||||||
|
|
||||||
|
|
||||||
|
func _throw_subset(seat: int, idxs: Array) -> void:
|
||||||
|
var dl: Array = table.dice_of(seat)
|
||||||
|
var zone: Node3D = table.zones[seat]
|
||||||
|
for k: int in idxs:
|
||||||
|
if k >= dl.size():
|
||||||
|
continue
|
||||||
|
var off := Vector3(rng.randf_range(-0.1, 0.1), 0.42, rng.randf_range(-0.22, 0.22))
|
||||||
|
dl[k].throw_from(zone.global_transform * off, rng)
|
||||||
|
|
||||||
|
|
||||||
|
func _phase_bid() -> Dictionary:
|
||||||
|
phase = "bid"
|
||||||
|
turn = starter if seats[starter].alive else _next_alive(starter)
|
||||||
|
while true:
|
||||||
|
_answered.clear()
|
||||||
|
deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT
|
||||||
|
_push_state()
|
||||||
|
if seats[turn].ai:
|
||||||
|
await _sleep(1.1)
|
||||||
|
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
|
||||||
|
min_raise.get(turn, 1), must_bid.get(turn, false), rng)
|
||||||
|
var res := _apply_action(turn, a)
|
||||||
|
if not res.is_empty():
|
||||||
|
return res
|
||||||
|
else:
|
||||||
|
await _wait_until(func() -> bool: return _answered.has(turn) or seats[turn].ai, BID_TIMEOUT)
|
||||||
|
if _answered.has(turn):
|
||||||
|
var res: Dictionary = _answered[turn]
|
||||||
|
if not res.is_empty():
|
||||||
|
return res
|
||||||
|
else:
|
||||||
|
# Zeit abgelaufen oder getrennt: automatisch entscheiden
|
||||||
|
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
|
||||||
|
min_raise.get(turn, 1), must_bid.get(turn, false), rng)
|
||||||
|
_log("%s wird automatisch gespielt." % seats[turn].name)
|
||||||
|
var res := _apply_action(turn, a)
|
||||||
|
if not res.is_empty():
|
||||||
|
return res
|
||||||
|
turn = _next_alive(turn)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
|
||||||
|
func _apply_action(seat: int, a: Dictionary) -> Dictionary:
|
||||||
|
if a.type == "bid" and not _valid_bid(seat, int(a.count), int(a.face)):
|
||||||
|
a = {"type": "challenge"} if not bid.is_empty() else {"type": "bid", "count": 1, "face": 1}
|
||||||
|
if a.type == "challenge":
|
||||||
|
_log("%s fechtet an!" % seats[seat].name)
|
||||||
|
return {"challenger": seat, "bid": bid.duplicate()}
|
||||||
|
bid = {"seat": seat, "count": int(a.count), "face": int(a.face)}
|
||||||
|
must_bid.erase(seat)
|
||||||
|
_log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face])
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func _valid_bid(seat: int, count: int, face: int) -> bool:
|
||||||
|
if face < 1 or face > 6 or count < 1 or count > _total_dice():
|
||||||
|
return false
|
||||||
|
if bid.is_empty():
|
||||||
|
return true
|
||||||
|
var mr: int = min_raise.get(seat, 1)
|
||||||
|
if count >= bid.count + mr:
|
||||||
|
return true
|
||||||
|
if mr > 1:
|
||||||
|
return false
|
||||||
|
return count == bid.count and face > bid.face
|
||||||
|
|
||||||
|
|
||||||
|
func _phase_reveal(result: Dictionary) -> void:
|
||||||
|
phase = "reveal"
|
||||||
|
deadline = 0.0
|
||||||
|
var b: Dictionary = result.bid
|
||||||
|
var challenger: int = result.challenger
|
||||||
|
var total := 0
|
||||||
|
for i in _alive_seats():
|
||||||
|
for v in host_values[i]:
|
||||||
|
if v == b.face:
|
||||||
|
total += 1
|
||||||
|
|
||||||
|
var loser := challenger if total >= b.count else int(b.seat)
|
||||||
|
banner = "%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [
|
||||||
|
b.count, b.face, total, seats[loser].name]
|
||||||
|
_log(banner)
|
||||||
|
|
||||||
|
for i in _alive_seats():
|
||||||
|
table.arrange_and_show(i, host_values[i], rng)
|
||||||
|
table.set_cup_down(i, false)
|
||||||
|
if not multiplayer.get_peers().is_empty():
|
||||||
|
_reveal.rpc(_all_values(), banner)
|
||||||
|
_push_state()
|
||||||
|
await _sleep(4.5)
|
||||||
|
|
||||||
|
seats[loser].count -= 1
|
||||||
|
if seats[loser].count <= 0:
|
||||||
|
seats[loser].alive = false
|
||||||
|
seats[loser].count = 0
|
||||||
|
host_values[loser] = []
|
||||||
|
table.set_dice_count(loser, 0)
|
||||||
|
_log("%s scheidet aus." % seats[loser].name)
|
||||||
|
starter = loser if seats[loser].alive else _next_alive(loser)
|
||||||
|
for i in seats.size():
|
||||||
|
table.set_dice_visible(i, false)
|
||||||
|
table.set_cup_down(i, true)
|
||||||
|
banner = ""
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _all_values() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for i in seats.size():
|
||||||
|
out.append(host_values[i] if seats[i].alive else [])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _sleep(sec: float) -> void:
|
||||||
|
await get_tree().create_timer(sec).timeout
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_until(cond: Callable, timeout: float) -> bool:
|
||||||
|
var t := 0.0
|
||||||
|
while t < timeout:
|
||||||
|
if cond.call():
|
||||||
|
return true
|
||||||
|
await get_tree().process_frame
|
||||||
|
t += get_process_delta_time()
|
||||||
|
return cond.call()
|
||||||
|
|
||||||
|
|
||||||
|
func _log(line: String) -> void:
|
||||||
|
log_lines.append(line)
|
||||||
|
if log_lines.size() > 8:
|
||||||
|
log_lines.pop_front()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Sync
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
if not multiplayer.is_server() or not _stream:
|
||||||
|
return
|
||||||
|
_stream_acc += delta
|
||||||
|
if _stream_acc < 1.0 / 30.0:
|
||||||
|
return
|
||||||
|
_stream_acc = 0.0
|
||||||
|
for i in seats.size():
|
||||||
|
var peer: int = seats[i].peer
|
||||||
|
if peer <= 1:
|
||||||
|
continue
|
||||||
|
_dice_stream.rpc_id(peer, _pack(i))
|
||||||
|
|
||||||
|
|
||||||
|
func _snapshot_all() -> void:
|
||||||
|
for i in seats.size():
|
||||||
|
var peer: int = seats[i].peer
|
||||||
|
if peer > 1:
|
||||||
|
_dice_snapshot.rpc_id(peer, _pack(i))
|
||||||
|
|
||||||
|
|
||||||
|
func _pack(seat: int) -> PackedFloat32Array:
|
||||||
|
var out := PackedFloat32Array()
|
||||||
|
for d in table.dice_of(seat):
|
||||||
|
var t: Transform3D = d.global_transform
|
||||||
|
var q := t.basis.get_rotation_quaternion()
|
||||||
|
out.append_array([t.origin.x, t.origin.y, t.origin.z, q.x, q.y, q.z, q.w])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _unpack(data: PackedFloat32Array) -> void:
|
||||||
|
var dl: Array = table.dice_of(local_seat)
|
||||||
|
var n := mini(dl.size(), int(data.size() / 7))
|
||||||
|
for i in n:
|
||||||
|
var o := i * 7
|
||||||
|
var t := Transform3D(Basis(Quaternion(data[o + 3], data[o + 4], data[o + 5], data[o + 6])),
|
||||||
|
Vector3(data[o], data[o + 1], data[o + 2]))
|
||||||
|
dl[i].set_kinematic(true)
|
||||||
|
dl[i].global_transform = t
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "unreliable_ordered")
|
||||||
|
func _dice_stream(data: PackedFloat32Array) -> void:
|
||||||
|
_unpack(data)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _dice_snapshot(data: PackedFloat32Array) -> void:
|
||||||
|
_unpack(data)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _reveal(all_values: Array, text: String) -> void:
|
||||||
|
for i in seats.size():
|
||||||
|
if i == local_seat:
|
||||||
|
continue
|
||||||
|
if i < all_values.size() and not all_values[i].is_empty():
|
||||||
|
table.arrange_and_show(i, all_values[i], rng)
|
||||||
|
table.set_cup_down(i, false)
|
||||||
|
banner = text
|
||||||
|
hud.set_banner(text)
|
||||||
|
|
||||||
|
|
||||||
|
func _push_state() -> void:
|
||||||
|
_refresh_labels()
|
||||||
|
for i in seats.size():
|
||||||
|
var peer: int = seats[i].peer
|
||||||
|
var st := _state_for(i)
|
||||||
|
if peer == multiplayer.get_unique_id():
|
||||||
|
_apply_state(st)
|
||||||
|
elif peer > 1:
|
||||||
|
_state.rpc_id(peer, st)
|
||||||
|
|
||||||
|
|
||||||
|
func _state_for(seat: int) -> Dictionary:
|
||||||
|
var vis: Array = []
|
||||||
|
if phase == "roll":
|
||||||
|
for _k in seats[seat].count: # während des Wurfs steht der Wert noch nicht fest
|
||||||
|
vis.append(-1)
|
||||||
|
elif phase == "reveal":
|
||||||
|
vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel
|
||||||
|
else:
|
||||||
|
for k in host_values[seat].size():
|
||||||
|
vis.append(-1 if fogged.get(seat, -1) == k else host_values[seat][k])
|
||||||
|
var pub: Array = []
|
||||||
|
for s in seats:
|
||||||
|
pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai})
|
||||||
|
return {
|
||||||
|
"phase": phase, "turn": turn, "bid": bid, "seats": pub, "values": vis,
|
||||||
|
"log": log_lines.duplicate(), "banner": banner,
|
||||||
|
"peek": peeked.get(seat, {}), "min_raise": min_raise.get(seat, 1),
|
||||||
|
"must_bid": must_bid.get(seat, false), "total": _total_dice(),
|
||||||
|
"time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()),
|
||||||
|
"open": _all_values() if phase == "reveal" else [],
|
||||||
|
"boost": chosen_boost.get(seat, {}).get("id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _state(st: Dictionary) -> void:
|
||||||
|
_apply_state(st)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_state(st: Dictionary) -> void:
|
||||||
|
phase = st.phase
|
||||||
|
turn = st.turn
|
||||||
|
bid = st.bid
|
||||||
|
my_values = st.values
|
||||||
|
banner = st.banner
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
for i in mini(seats.size(), st.seats.size()):
|
||||||
|
seats[i].alive = st.seats[i].alive
|
||||||
|
if seats[i].count != st.seats[i].count:
|
||||||
|
seats[i].count = st.seats[i].count
|
||||||
|
table.set_dice_count(i, seats[i].count)
|
||||||
|
if phase != _last_phase:
|
||||||
|
_last_phase = phase
|
||||||
|
if phase == "roll":
|
||||||
|
for i in seats.size():
|
||||||
|
if i != local_seat:
|
||||||
|
table.set_dice_visible(i, false)
|
||||||
|
table.set_cup_down(i, true)
|
||||||
|
table.set_dice_visible(local_seat, true)
|
||||||
|
_refresh_labels()
|
||||||
|
_apply_fog_to_table()
|
||||||
|
hud.update_state(st, local_seat)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Aktionen
|
||||||
|
|
||||||
|
## 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():
|
||||||
|
return
|
||||||
|
var dl: Array = table.dice_of(local_seat)
|
||||||
|
for i in mini(dl.size(), my_values.size()):
|
||||||
|
dl[i].visible = my_values[i] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
func _on_bid_pressed(count: int, face: int) -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_do_bid(local_seat, count, face)
|
||||||
|
else:
|
||||||
|
_req_bid.rpc_id(1, count, face)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_challenge_pressed() -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_do_challenge(local_seat)
|
||||||
|
else:
|
||||||
|
_req_challenge.rpc_id(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_booster_chosen(id: String, target: int) -> void:
|
||||||
|
if multiplayer.is_server():
|
||||||
|
_do_boost(local_seat, id, target)
|
||||||
|
else:
|
||||||
|
_req_boost.rpc_id(1, id, target)
|
||||||
|
|
||||||
|
|
||||||
|
## Im Einzelspieler hält das Pausenmenü das Spiel wirklich an. Sobald ein
|
||||||
|
## zweiter Mensch am Tisch sitzt, läuft die Runde weiter - sonst würde ein
|
||||||
|
## geöffnetes Menü alle anderen blockieren.
|
||||||
|
func _on_pause_changed(on: bool) -> void:
|
||||||
|
if not multiplayer.get_peers().is_empty():
|
||||||
|
return
|
||||||
|
if on:
|
||||||
|
_pause_left = 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now())
|
||||||
|
get_tree().paused = on
|
||||||
|
if not on and _pause_left > 0.0:
|
||||||
|
deadline = _now() + _pause_left
|
||||||
|
_pause_left = 0.0
|
||||||
|
_push_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quit() -> void:
|
||||||
|
get_tree().paused = false
|
||||||
|
Net.leave()
|
||||||
|
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _req_bid(count: int, face: int) -> void:
|
||||||
|
_do_bid(_seat_of(multiplayer.get_remote_sender_id()), count, face)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _req_challenge() -> void:
|
||||||
|
_do_challenge(_seat_of(multiplayer.get_remote_sender_id()))
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _req_boost(id: String, target: int) -> void:
|
||||||
|
_do_boost(_seat_of(multiplayer.get_remote_sender_id()), id, target)
|
||||||
|
|
||||||
|
|
||||||
|
func _seat_of(peer: int) -> int:
|
||||||
|
for i in seats.size():
|
||||||
|
if seats[i].peer == peer:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
func _do_bid(seat: int, count: int, face: int) -> void:
|
||||||
|
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
|
||||||
|
return
|
||||||
|
if not _valid_bid(seat, count, face):
|
||||||
|
return
|
||||||
|
_answered[seat] = _apply_action(seat, {"type": "bid", "count": count, "face": face})
|
||||||
|
|
||||||
|
|
||||||
|
func _do_challenge(seat: int) -> void:
|
||||||
|
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
|
||||||
|
return
|
||||||
|
if bid.is_empty() or must_bid.get(seat, false):
|
||||||
|
return
|
||||||
|
_answered[seat] = _apply_action(seat, {"type": "challenge"})
|
||||||
|
|
||||||
|
|
||||||
|
func _do_boost(seat: int, id: String, target: int) -> void:
|
||||||
|
if seat < 0 or phase != "boost" or chosen_boost.has(seat):
|
||||||
|
return
|
||||||
|
if BoosterDB.get_booster(id).is_empty():
|
||||||
|
return
|
||||||
|
chosen_boost[seat] = {"id": id, "target": target}
|
||||||
|
_answered[seat] = true
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _offer(ids: Array) -> void:
|
||||||
|
hud.show_booster_offer(ids, my_values)
|
||||||
1
scripts/game.gd.uid
Normal file
1
scripts/game.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ciff82aqhoplv
|
||||||
507
scripts/hud.gd
Normal file
507
scripts/hud.gd
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## Komplettes Spiel-UI (programmatisch aufgebaut).
|
||||||
|
|
||||||
|
signal bid_pressed(count: int, face: int)
|
||||||
|
signal challenge_pressed
|
||||||
|
signal booster_chosen(id: String, target: int)
|
||||||
|
signal quit_pressed
|
||||||
|
signal pause_changed(paused: bool)
|
||||||
|
|
||||||
|
const ACCENT := Color(1.0, 0.82, 0.32)
|
||||||
|
const DIM := Color(0.62, 0.64, 0.70)
|
||||||
|
const PLAYERS_W := 300.0 ## feste Breite der Übersicht oben rechts
|
||||||
|
|
||||||
|
var root: Control
|
||||||
|
var players_box: VBoxContainer
|
||||||
|
var bid_row: HBoxContainer
|
||||||
|
var hint_label: Label
|
||||||
|
var banner_label: Label
|
||||||
|
var log_label: Label
|
||||||
|
|
||||||
|
var count_spin: SpinBox
|
||||||
|
var face_buttons: Array[Button] = []
|
||||||
|
var bid_btn: Button
|
||||||
|
var challenge_btn: Button
|
||||||
|
var action_panel: PanelContainer
|
||||||
|
|
||||||
|
var boost_slot: CenterContainer
|
||||||
|
var pause_slot: Control
|
||||||
|
var pause_hint: Label
|
||||||
|
var boost_panel: PanelContainer
|
||||||
|
var boost_cards: HBoxContainer
|
||||||
|
var boost_title: Label
|
||||||
|
var timer_bid: Label
|
||||||
|
var timer_boost: Label
|
||||||
|
var target_row: HBoxContainer
|
||||||
|
|
||||||
|
var _face := 1
|
||||||
|
|
||||||
|
|
||||||
|
## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem
|
||||||
|
## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen.
|
||||||
|
class DieIcon extends Control:
|
||||||
|
const PIPS := {
|
||||||
|
1: [Vector2(0, 0)],
|
||||||
|
2: [Vector2(-1, -1), Vector2(1, 1)],
|
||||||
|
3: [Vector2(-1, -1), Vector2(0, 0), Vector2(1, 1)],
|
||||||
|
4: [Vector2(-1, -1), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 1)],
|
||||||
|
5: [Vector2(-1, -1), Vector2(-1, 1), Vector2(0, 0), Vector2(1, -1), Vector2(1, 1)],
|
||||||
|
6: [Vector2(-1, -1), Vector2(-1, 0), Vector2(-1, 1),
|
||||||
|
Vector2(1, -1), Vector2(1, 0), Vector2(1, 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
var value := 0
|
||||||
|
var _box := StyleBoxFlat.new()
|
||||||
|
|
||||||
|
func _init(v: int, px: float = 0.0) -> void:
|
||||||
|
value = v
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
if px > 0.0:
|
||||||
|
custom_minimum_size = Vector2(px, px)
|
||||||
|
# sonst zieht der Container das Symbol in die Höhe und es wird unrund
|
||||||
|
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
_box.bg_color = Color(0.93, 0.92, 0.88) if v > 0 else Color(0.26, 0.28, 0.34)
|
||||||
|
_box.set_corner_radius_all(4)
|
||||||
|
_box.border_color = Color(0, 0, 0, 0.35)
|
||||||
|
_box.set_border_width_all(1)
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
# Immer im grössten Quadrat zeichnen, das in die Fläche passt.
|
||||||
|
var s := minf(size.x, size.y)
|
||||||
|
var mid := size * 0.5
|
||||||
|
draw_style_box(_box, Rect2(mid - Vector2(s, s) * 0.5, Vector2(s, s)))
|
||||||
|
if value <= 0:
|
||||||
|
var f := get_theme_default_font()
|
||||||
|
var fs := int(s * 0.66)
|
||||||
|
var w := f.get_string_size("?", HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
|
||||||
|
draw_string(f, Vector2(mid.x - w * 0.5, mid.y + fs * 0.35), "?",
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(0.85, 0.88, 0.94))
|
||||||
|
return
|
||||||
|
for p: Vector2 in PIPS[value]:
|
||||||
|
draw_circle(mid + p * (s * 0.24), s * 0.105, Color(0.09, 0.08, 0.1))
|
||||||
|
var _deadline := 0.0 ## lokaler Zeitstempel, 0 = keine Frist
|
||||||
|
var _phase := ""
|
||||||
|
var _my_turn := false
|
||||||
|
var _pending_boost := ""
|
||||||
|
var _my_values: Array = []
|
||||||
|
var _my_seat := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 1
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
root = Control.new()
|
||||||
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(root)
|
||||||
|
_build()
|
||||||
|
|
||||||
|
|
||||||
|
func _panel(col := Color(0.06, 0.07, 0.10, 0.86)) -> PanelContainer:
|
||||||
|
var p := PanelContainer.new()
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = col
|
||||||
|
sb.set_corner_radius_all(8)
|
||||||
|
sb.set_content_margin_all(10)
|
||||||
|
sb.border_color = Color(1, 1, 1, 0.10)
|
||||||
|
sb.set_border_width_all(1)
|
||||||
|
p.add_theme_stylebox_override("panel", sb)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
func _label(text: String, size: int, col := Color.WHITE) -> Label:
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = text
|
||||||
|
l.add_theme_font_size_override("font_size", size)
|
||||||
|
l.add_theme_color_override("font_color", col)
|
||||||
|
return l
|
||||||
|
|
||||||
|
|
||||||
|
func _place(c: Control, al: float, at: float, ar: float, ab: float,
|
||||||
|
ol: float, ot: float, orr: float, ob: float) -> void:
|
||||||
|
c.anchor_left = al
|
||||||
|
c.anchor_top = at
|
||||||
|
c.anchor_right = ar
|
||||||
|
c.anchor_bottom = ab
|
||||||
|
c.offset_left = ol
|
||||||
|
c.offset_top = ot
|
||||||
|
c.offset_right = orr
|
||||||
|
c.offset_bottom = ob
|
||||||
|
|
||||||
|
|
||||||
|
func _slot(al: float, at: float, ar: float, ab: float,
|
||||||
|
ol: float, ot: float, orr: float, ob: float) -> CenterContainer:
|
||||||
|
var cc := CenterContainer.new()
|
||||||
|
cc.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_place(cc, al, at, ar, ab, ol, ot, orr, ob)
|
||||||
|
root.add_child(cc)
|
||||||
|
return cc
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
# --- Spielerliste oben rechts
|
||||||
|
var pl := _panel()
|
||||||
|
_place(pl, 1, 0, 1, 0, -16 - PLAYERS_W, 16, -16, 16)
|
||||||
|
pl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
pl.custom_minimum_size = Vector2(PLAYERS_W, 0)
|
||||||
|
root.add_child(pl)
|
||||||
|
players_box = VBoxContainer.new()
|
||||||
|
players_box.add_theme_constant_override("separation", 4)
|
||||||
|
pl.add_child(players_box)
|
||||||
|
|
||||||
|
# --- Aktuelles Gebot oben mittig
|
||||||
|
var bp := _panel()
|
||||||
|
_slot(0, 0, 1, 0, 300, 12, -300, 120).add_child(bp)
|
||||||
|
var bv := VBoxContainer.new()
|
||||||
|
bp.add_child(bv)
|
||||||
|
bid_row = HBoxContainer.new()
|
||||||
|
bid_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
bid_row.add_theme_constant_override("separation", 8)
|
||||||
|
bv.add_child(bid_row)
|
||||||
|
hint_label = _label("", 14, DIM)
|
||||||
|
hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
bv.add_child(hint_label)
|
||||||
|
|
||||||
|
# --- Banner in der Bildmitte
|
||||||
|
var banner_slot := _slot(0, 0, 1, 0, 140, 126, -140, 210)
|
||||||
|
banner_label = _label("", 28, ACCENT)
|
||||||
|
banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
banner_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
banner_label.custom_minimum_size = Vector2(1000, 0)
|
||||||
|
banner_slot.add_child(banner_label)
|
||||||
|
|
||||||
|
# --- Verlauf unten links
|
||||||
|
var lp := _panel()
|
||||||
|
_place(lp, 0, 1, 0, 1, 16, -252, 376, -16)
|
||||||
|
root.add_child(lp)
|
||||||
|
log_label = _label("", 13, Color(0.82, 0.84, 0.9))
|
||||||
|
log_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
||||||
|
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
lp.add_child(log_label)
|
||||||
|
|
||||||
|
# --- Aktionen unten rechts
|
||||||
|
action_panel = _panel()
|
||||||
|
_place(action_panel, 1, 1, 1, 1, -296, -282, -16, -16)
|
||||||
|
action_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
root.add_child(action_panel)
|
||||||
|
var av := VBoxContainer.new()
|
||||||
|
av.add_theme_constant_override("separation", 6)
|
||||||
|
action_panel.add_child(av)
|
||||||
|
|
||||||
|
var head := HBoxContainer.new()
|
||||||
|
av.add_child(head)
|
||||||
|
var head_title := _label("Deine Ansage", 18, ACCENT)
|
||||||
|
head_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
head.add_child(head_title)
|
||||||
|
timer_bid = _label("", 18, DIM)
|
||||||
|
head.add_child(timer_bid)
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
av.add_child(row)
|
||||||
|
var cl := _label("Anzahl", 15)
|
||||||
|
cl.custom_minimum_size = Vector2(80, 0)
|
||||||
|
row.add_child(cl)
|
||||||
|
count_spin = SpinBox.new()
|
||||||
|
count_spin.min_value = 1
|
||||||
|
count_spin.max_value = 30
|
||||||
|
count_spin.value = 1
|
||||||
|
count_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.add_child(count_spin)
|
||||||
|
|
||||||
|
av.add_child(_label("Augenzahl", 15))
|
||||||
|
var frow := HBoxContainer.new()
|
||||||
|
frow.add_theme_constant_override("separation", 4)
|
||||||
|
av.add_child(frow)
|
||||||
|
for f in range(1, 7):
|
||||||
|
var b := Button.new()
|
||||||
|
b.toggle_mode = true
|
||||||
|
b.custom_minimum_size = Vector2(40, 40)
|
||||||
|
b.pressed.connect(_on_face.bind(f))
|
||||||
|
var ic := DieIcon.new(f)
|
||||||
|
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
ic.offset_left = 7
|
||||||
|
ic.offset_top = 7
|
||||||
|
ic.offset_right = -7
|
||||||
|
ic.offset_bottom = -7
|
||||||
|
b.add_child(ic)
|
||||||
|
frow.add_child(b)
|
||||||
|
face_buttons.append(b)
|
||||||
|
face_buttons[0].button_pressed = true
|
||||||
|
|
||||||
|
bid_btn = Button.new()
|
||||||
|
bid_btn.text = "Ansagen"
|
||||||
|
bid_btn.custom_minimum_size = Vector2(0, 36)
|
||||||
|
bid_btn.pressed.connect(_on_bid)
|
||||||
|
av.add_child(bid_btn)
|
||||||
|
|
||||||
|
challenge_btn = Button.new()
|
||||||
|
challenge_btn.text = "Anfechten"
|
||||||
|
challenge_btn.custom_minimum_size = Vector2(0, 36)
|
||||||
|
challenge_btn.pressed.connect(_on_challenge)
|
||||||
|
av.add_child(challenge_btn)
|
||||||
|
|
||||||
|
# --- Booster-Overlay
|
||||||
|
boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0)
|
||||||
|
boost_slot.visible = false
|
||||||
|
boost_panel = _panel(Color(0.05, 0.06, 0.09, 0.97))
|
||||||
|
boost_slot.add_child(boost_panel)
|
||||||
|
var bvv := VBoxContainer.new()
|
||||||
|
bvv.add_theme_constant_override("separation", 12)
|
||||||
|
boost_panel.add_child(bvv)
|
||||||
|
boost_title = _label("Wähle einen Booster", 24, ACCENT)
|
||||||
|
boost_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
bvv.add_child(boost_title)
|
||||||
|
timer_boost = _label("", 16, DIM)
|
||||||
|
timer_boost.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
bvv.add_child(timer_boost)
|
||||||
|
boost_cards = HBoxContainer.new()
|
||||||
|
boost_cards.add_theme_constant_override("separation", 12)
|
||||||
|
bvv.add_child(boost_cards)
|
||||||
|
target_row = HBoxContainer.new()
|
||||||
|
target_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
target_row.add_theme_constant_override("separation", 6)
|
||||||
|
target_row.visible = false
|
||||||
|
bvv.add_child(target_row)
|
||||||
|
|
||||||
|
# --- Pausenmenü (Esc)
|
||||||
|
pause_slot = Control.new()
|
||||||
|
pause_slot.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
pause_slot.visible = false
|
||||||
|
root.add_child(pause_slot)
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
dim.color = Color(0, 0, 0, 0.55)
|
||||||
|
pause_slot.add_child(dim)
|
||||||
|
var pc := CenterContainer.new()
|
||||||
|
pc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
pause_slot.add_child(pc)
|
||||||
|
var pp := _panel(Color(0.05, 0.06, 0.09, 0.98))
|
||||||
|
pc.add_child(pp)
|
||||||
|
var pv := VBoxContainer.new()
|
||||||
|
pv.custom_minimum_size = Vector2(280, 0)
|
||||||
|
pv.add_theme_constant_override("separation", 10)
|
||||||
|
pp.add_child(pv)
|
||||||
|
var pt := _label("Pause", 26, ACCENT)
|
||||||
|
pt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
pv.add_child(pt)
|
||||||
|
pause_hint = _label("", 13, DIM)
|
||||||
|
pause_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
pv.add_child(pause_hint)
|
||||||
|
var resume := Button.new()
|
||||||
|
resume.text = "Fortsetzen"
|
||||||
|
resume.custom_minimum_size = Vector2(0, 42)
|
||||||
|
resume.pressed.connect(_set_paused.bind(false))
|
||||||
|
pv.add_child(resume)
|
||||||
|
var leave := Button.new()
|
||||||
|
leave.text = "Verlassen"
|
||||||
|
leave.custom_minimum_size = Vector2(0, 42)
|
||||||
|
leave.pressed.connect(_on_quit)
|
||||||
|
pv.add_child(leave)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quit() -> void:
|
||||||
|
quit_pressed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_bid() -> void:
|
||||||
|
bid_pressed.emit(int(count_spin.value), _face)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_challenge() -> void:
|
||||||
|
challenge_pressed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo \
|
||||||
|
and event.keycode == KEY_ESCAPE:
|
||||||
|
_set_paused(not pause_slot.visible)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_paused(on: bool) -> void:
|
||||||
|
pause_slot.visible = on
|
||||||
|
if on:
|
||||||
|
pause_hint.text = "Die Runde läuft weiter." if multiplayer.get_peers().size() > 0 \
|
||||||
|
else "Das Spiel ist angehalten."
|
||||||
|
pause_changed.emit(on)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_face(f: int) -> void:
|
||||||
|
_face = f
|
||||||
|
for i in face_buttons.size():
|
||||||
|
face_buttons[i].button_pressed = (i + 1 == f)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Updates
|
||||||
|
|
||||||
|
func update_state(st: Dictionary, my_seat: int) -> void:
|
||||||
|
_my_seat = my_seat
|
||||||
|
_my_values = st.values
|
||||||
|
_phase = st.phase
|
||||||
|
var left := float(st.get("time_left", 0.0))
|
||||||
|
_deadline = 0.0 if left <= 0.0 else Time.get_ticks_msec() / 1000.0 + left
|
||||||
|
|
||||||
|
for c in players_box.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
for i in st.seats.size():
|
||||||
|
var s: Dictionary = st.seats[i]
|
||||||
|
var turn: bool = st.phase == "bid" and i == st.turn
|
||||||
|
var col := ACCENT if turn else (Color.WHITE if s.alive else DIM)
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 4)
|
||||||
|
players_box.add_child(row)
|
||||||
|
var nm := _label(("▸ " if turn else " ") + s.name + (" (KI)" if s.ai else ""), 15, col)
|
||||||
|
# Der Name teilt sich den Rest der festen Panelbreite und wird
|
||||||
|
# notfalls mit Auslassungspunkten gekürzt.
|
||||||
|
nm.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
nm.clip_text = true
|
||||||
|
nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
row.add_child(nm)
|
||||||
|
if not s.alive:
|
||||||
|
row.add_child(_label("ausgeschieden", 14, DIM))
|
||||||
|
continue
|
||||||
|
for v: int in _seat_dice(st, i, my_seat):
|
||||||
|
row.add_child(DieIcon.new(v, 22.0))
|
||||||
|
|
||||||
|
for c in bid_row.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
if st.bid.is_empty():
|
||||||
|
bid_row.add_child(_label("Noch kein Gebot", 26, ACCENT))
|
||||||
|
else:
|
||||||
|
bid_row.add_child(_label("%s: %d ×" % [st.seats[st.bid.seat].name, st.bid.count], 26, ACCENT))
|
||||||
|
bid_row.add_child(DieIcon.new(int(st.bid.face), 30.0))
|
||||||
|
|
||||||
|
var hints: Array = []
|
||||||
|
hints.append("Würfel im Spiel: %d" % st.total)
|
||||||
|
if st.boost != "":
|
||||||
|
hints.append("Dein Booster: %s" % BoosterDB.get_booster(st.boost).get("name", ""))
|
||||||
|
if int(st.min_raise) > 1:
|
||||||
|
hints.append("Störfeuer: du musst um mind. %d erhöhen" % st.min_raise)
|
||||||
|
if st.must_bid:
|
||||||
|
hints.append("Maulkorb: du musst bieten")
|
||||||
|
hint_label.text = " | ".join(hints)
|
||||||
|
|
||||||
|
log_label.text = "\n".join(st.log)
|
||||||
|
banner_label.text = st.banner
|
||||||
|
|
||||||
|
var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive
|
||||||
|
_my_turn = my_turn
|
||||||
|
action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45)
|
||||||
|
bid_btn.disabled = not my_turn
|
||||||
|
challenge_btn.disabled = not my_turn or st.bid.is_empty() or st.must_bid
|
||||||
|
count_spin.max_value = maxi(1, int(st.total))
|
||||||
|
var low := 1
|
||||||
|
if not st.bid.is_empty():
|
||||||
|
low = int(st.bid.count) + (int(st.min_raise) if int(st.min_raise) > 1 else 0)
|
||||||
|
count_spin.min_value = clampi(low, 1, int(count_spin.max_value))
|
||||||
|
if count_spin.value < count_spin.min_value:
|
||||||
|
count_spin.value = count_spin.min_value
|
||||||
|
|
||||||
|
if st.phase != "boost":
|
||||||
|
boost_slot.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
## Läuft die Restzeit herunter, solange eine Eingabe von dir erwartet wird.
|
||||||
|
## Nach Ablauf entscheidet der Host automatisch.
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if pause_slot != null and pause_slot.visible:
|
||||||
|
return
|
||||||
|
var wants_boost := boost_slot != null and boost_slot.visible
|
||||||
|
var wants_bid := _my_turn and _phase == "bid"
|
||||||
|
if _deadline <= 0.0 or not (wants_boost or wants_bid):
|
||||||
|
timer_bid.text = ""
|
||||||
|
timer_boost.text = ""
|
||||||
|
return
|
||||||
|
var left := maxf(0.0, _deadline - Time.get_ticks_msec() / 1000.0)
|
||||||
|
var text := "%d s" % ceili(left)
|
||||||
|
var col := Color(0.95, 0.45, 0.4) if left <= 10.0 else DIM
|
||||||
|
timer_bid.text = text if wants_bid else ""
|
||||||
|
timer_bid.add_theme_color_override("font_color", col)
|
||||||
|
timer_boost.text = "Noch %s - danach wird zufällig gewählt" % text if wants_boost else ""
|
||||||
|
timer_boost.add_theme_color_override("font_color", col)
|
||||||
|
|
||||||
|
|
||||||
|
## Eigene Würfel sind bekannt, gegnerische nicht - ausser sie wurden gespäht
|
||||||
|
## oder es wird gerade aufgedeckt.
|
||||||
|
func _seat_dice(st: Dictionary, seat: int, my_seat: int) -> Array:
|
||||||
|
if seat == my_seat:
|
||||||
|
return st.values
|
||||||
|
var open: Array = st.get("open", [])
|
||||||
|
if seat < open.size() and not open[seat].is_empty():
|
||||||
|
return open[seat]
|
||||||
|
var out: Array = []
|
||||||
|
for _k in int(st.seats[seat].count):
|
||||||
|
out.append(-1)
|
||||||
|
var peek: Dictionary = st.peek
|
||||||
|
if peek.has(seat):
|
||||||
|
var idx := int(peek[seat][0])
|
||||||
|
if idx >= 0 and idx < out.size():
|
||||||
|
out[idx] = int(peek[seat][1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func set_banner(text: String) -> void:
|
||||||
|
banner_label.text = text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Booster
|
||||||
|
|
||||||
|
func show_booster_offer(ids: Array, values: Array) -> void:
|
||||||
|
_my_values = values
|
||||||
|
_pending_boost = ""
|
||||||
|
target_row.visible = false
|
||||||
|
boost_title.text = "Wähle einen Booster"
|
||||||
|
for c in boost_cards.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
for id: String in ids:
|
||||||
|
var b: Dictionary = BoosterDB.get_booster(id)
|
||||||
|
var card := _panel(Color(0.10, 0.12, 0.17, 0.98))
|
||||||
|
card.custom_minimum_size = Vector2(210, 170)
|
||||||
|
var v := VBoxContainer.new()
|
||||||
|
v.add_theme_constant_override("separation", 6)
|
||||||
|
card.add_child(v)
|
||||||
|
var kind_col := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
|
||||||
|
v.add_child(_label("BUFF" if b.kind == BoosterDB.Kind.BUFF else "DEBUFF", 12, kind_col))
|
||||||
|
v.add_child(_label(b.name, 20, ACCENT))
|
||||||
|
var d := _label(b.desc, 14, Color(0.8, 0.82, 0.88))
|
||||||
|
d.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
d.custom_minimum_size = Vector2(190, 60)
|
||||||
|
v.add_child(d)
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = "Wählen"
|
||||||
|
btn.pressed.connect(_pick.bind(id))
|
||||||
|
v.add_child(btn)
|
||||||
|
boost_cards.add_child(card)
|
||||||
|
boost_slot.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _pick(id: String) -> void:
|
||||||
|
if not BoosterDB.needs_target(id) or _my_values.is_empty():
|
||||||
|
booster_chosen.emit(id, 0)
|
||||||
|
boost_slot.visible = false
|
||||||
|
return
|
||||||
|
_pending_boost = id
|
||||||
|
boost_title.text = "%s - welchen Würfel?" % BoosterDB.get_booster(id).name
|
||||||
|
for c in boost_cards.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
for c in target_row.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
for i in _my_values.size():
|
||||||
|
var b := Button.new()
|
||||||
|
b.custom_minimum_size = Vector2(56, 56)
|
||||||
|
b.pressed.connect(_pick_target.bind(i))
|
||||||
|
var ic := DieIcon.new(int(_my_values[i]))
|
||||||
|
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
ic.offset_left = 10
|
||||||
|
ic.offset_top = 10
|
||||||
|
ic.offset_right = -10
|
||||||
|
ic.offset_bottom = -10
|
||||||
|
b.add_child(ic)
|
||||||
|
target_row.add_child(b)
|
||||||
|
target_row.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _pick_target(idx: int) -> void:
|
||||||
|
booster_chosen.emit(_pending_boost, idx)
|
||||||
|
boost_slot.visible = false
|
||||||
|
target_row.visible = false
|
||||||
1
scripts/hud.gd.uid
Normal file
1
scripts/hud.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cdqtjolck2gno
|
||||||
38
scripts/main.gd
Normal file
38
scripts/main.gd
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
extends Node
|
||||||
|
## Wechselt zwischen Menü und laufender Partie.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
## Das Spiel startet im Vollbild; F11 schaltet um.
|
||||||
|
func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventKey and event.pressed and event.keycode == KEY_F11:
|
||||||
|
var full := DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN
|
||||||
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED if full
|
||||||
|
else DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _start_game(cfg: Dictionary) -> void:
|
||||||
|
if game != null:
|
||||||
|
return
|
||||||
|
menu.queue_free()
|
||||||
|
menu = null
|
||||||
|
game = Node3D.new()
|
||||||
|
game.set_script(preload("res://scripts/game.gd"))
|
||||||
|
game.name = "Game" # fester Pfad, sonst findet das RPC-Routing den Knoten nicht
|
||||||
|
add_child(game)
|
||||||
|
game.setup(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
func _back_to_menu() -> void:
|
||||||
|
if game != null:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
||||||
1
scripts/main.gd.uid
Normal file
1
scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bcurjfvrdwb51
|
||||||
237
scripts/menu.gd
Normal file
237
scripts/menu.gd
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## Startmenü: Einzelspieler, Mehrspieler (Host/Client mit Lobby), Beenden.
|
||||||
|
|
||||||
|
const BOT_NAMES_MAX := 7
|
||||||
|
|
||||||
|
var screens: Dictionary = {}
|
||||||
|
|
||||||
|
# Einzelspieler
|
||||||
|
var solo_bots: SpinBox
|
||||||
|
|
||||||
|
# Mehrspieler
|
||||||
|
var name_edit: LineEdit
|
||||||
|
var ip_edit: LineEdit
|
||||||
|
var multi_bots: SpinBox
|
||||||
|
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:
|
||||||
|
var root := Control.new()
|
||||||
|
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(root)
|
||||||
|
|
||||||
|
var bg := ColorRect.new()
|
||||||
|
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
bg.color = Color(0.05, 0.06, 0.09)
|
||||||
|
root.add_child(bg)
|
||||||
|
|
||||||
|
screens["main"] = _screen(root, _build_main())
|
||||||
|
screens["single"] = _screen(root, _build_single())
|
||||||
|
screens["multi"] = _screen(root, _build_multi())
|
||||||
|
|
||||||
|
Net.lobby_changed.connect(_refresh)
|
||||||
|
Net.connection_failed.connect(_on_failed)
|
||||||
|
Net.server_closed.connect(_on_closed)
|
||||||
|
_show("main")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Aufbau
|
||||||
|
|
||||||
|
func _screen(root: Control, content: Control) -> CenterContainer:
|
||||||
|
var cc := CenterContainer.new()
|
||||||
|
cc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
cc.add_child(content)
|
||||||
|
root.add_child(cc)
|
||||||
|
return cc
|
||||||
|
|
||||||
|
|
||||||
|
func _show(which: String) -> void:
|
||||||
|
for key: String in screens:
|
||||||
|
screens[key].visible = key == which
|
||||||
|
if which == "multi":
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _box(width: int) -> VBoxContainer:
|
||||||
|
var v := VBoxContainer.new()
|
||||||
|
v.custom_minimum_size = Vector2(width, 0)
|
||||||
|
v.add_theme_constant_override("separation", 10)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
func _text(t: String, size: int, col := Color.WHITE) -> Label:
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = t
|
||||||
|
l.add_theme_font_size_override("font_size", size)
|
||||||
|
l.add_theme_color_override("font_color", col)
|
||||||
|
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
return l
|
||||||
|
|
||||||
|
|
||||||
|
func _row(caption: String, ctrl: Control) -> HBoxContainer:
|
||||||
|
var h := HBoxContainer.new()
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = caption
|
||||||
|
l.custom_minimum_size = Vector2(150, 0)
|
||||||
|
h.add_child(l)
|
||||||
|
ctrl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
h.add_child(ctrl)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
func _button(text: String, parent: Control, action: Callable) -> Button:
|
||||||
|
var b := Button.new()
|
||||||
|
b.text = text
|
||||||
|
b.custom_minimum_size = Vector2(0, 42)
|
||||||
|
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
b.pressed.connect(action)
|
||||||
|
parent.add_child(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
func _title(box: VBoxContainer) -> void:
|
||||||
|
box.add_child(_text("LIAR'S DICE 3D", 40))
|
||||||
|
box.add_child(_text("Würfeln - Bluffen - Boostern", 16, Color(0.6, 0.63, 0.7)))
|
||||||
|
box.add_child(HSeparator.new())
|
||||||
|
|
||||||
|
|
||||||
|
func _build_main() -> Control:
|
||||||
|
var box := _box(360)
|
||||||
|
_title(box)
|
||||||
|
_button("Einzelspieler", box, _show.bind("single"))
|
||||||
|
_button("Mehrspieler", box, _show.bind("multi"))
|
||||||
|
_button("Beenden", box, func() -> void: get_tree().quit())
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
func _build_single() -> Control:
|
||||||
|
var box := _box(400)
|
||||||
|
_title(box)
|
||||||
|
box.add_child(_text("Einzelspieler", 22, Color(1.0, 0.82, 0.32)))
|
||||||
|
solo_bots = SpinBox.new()
|
||||||
|
solo_bots.min_value = 1
|
||||||
|
solo_bots.max_value = BOT_NAMES_MAX
|
||||||
|
solo_bots.value = 3
|
||||||
|
box.add_child(_row("Bots", solo_bots))
|
||||||
|
box.add_child(_text("Ohne Netzwerk - du spielst allein gegen die Bots.", 13, Color(0.55, 0.58, 0.65)))
|
||||||
|
box.add_child(HSeparator.new())
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
box.add_child(row)
|
||||||
|
_button("Starten", row, _on_solo_start)
|
||||||
|
_button("Zurück", row, _show.bind("main"))
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
func _build_multi() -> Control:
|
||||||
|
var box := _box(440)
|
||||||
|
_title(box)
|
||||||
|
box.add_child(_text("Mehrspieler", 22, Color(1.0, 0.82, 0.32)))
|
||||||
|
|
||||||
|
name_edit = LineEdit.new()
|
||||||
|
name_edit.text = "Spieler"
|
||||||
|
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))
|
||||||
|
|
||||||
|
multi_bots = SpinBox.new()
|
||||||
|
multi_bots.min_value = 0
|
||||||
|
multi_bots.max_value = BOT_NAMES_MAX
|
||||||
|
multi_bots.value = 1
|
||||||
|
box.add_child(_row("Bots (nur Host)", 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)
|
||||||
|
|
||||||
|
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))
|
||||||
|
box.add_child(lobby_list)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
_button("Zurück", box, _on_back)
|
||||||
|
status = _text("", 14, Color(0.95, 0.55, 0.5))
|
||||||
|
box.add_child(status)
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Aktionen
|
||||||
|
|
||||||
|
func _on_solo_start() -> void:
|
||||||
|
Net.start_singleplayer("Spieler", int(solo_bots.value))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_host() -> void:
|
||||||
|
status.text = ""
|
||||||
|
if Net.host_game(name_edit.text.strip_edges()):
|
||||||
|
status.text = "Server läuft auf Port %d. Warte auf Mitspieler." % Net.PORT
|
||||||
|
|
||||||
|
|
||||||
|
func _on_join() -> void:
|
||||||
|
status.text = "Verbinde..."
|
||||||
|
Net.join_game(ip_edit.text.strip_edges(), name_edit.text.strip_edges())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_leave() -> void:
|
||||||
|
Net.leave()
|
||||||
|
status.text = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _on_back() -> void:
|
||||||
|
if Net.is_online():
|
||||||
|
Net.leave()
|
||||||
|
status.text = ""
|
||||||
|
_show("main")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_start() -> void:
|
||||||
|
var humans: int = Net.lobby.size()
|
||||||
|
var ai: int = mini(int(multi_bots.value), Net.MAX_HUMANS - humans)
|
||||||
|
if humans + ai < 2:
|
||||||
|
status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)."
|
||||||
|
return
|
||||||
|
Net.start_game(ai)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_failed(msg: String) -> void:
|
||||||
|
status.text = msg
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_closed() -> void:
|
||||||
|
status.text = "Server hat die Verbindung beendet."
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh() -> void:
|
||||||
|
if status == 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)
|
||||||
|
multi_bots.editable = not connected or Net.is_host
|
||||||
|
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)
|
||||||
1
scripts/menu.gd.uid
Normal file
1
scripts/menu.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dxm710y2k5alf
|
||||||
136
scripts/net.gd
Normal file
136
scripts/net.gd
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
extends Node
|
||||||
|
## Netzwerk-Verwaltung: Host/Client via ENet, Lobby und Spielstart.
|
||||||
|
|
||||||
|
signal lobby_changed
|
||||||
|
signal game_started(config: Dictionary)
|
||||||
|
signal connection_failed(reason: String)
|
||||||
|
signal server_closed
|
||||||
|
|
||||||
|
const PORT := 8910
|
||||||
|
const MAX_HUMANS := 8
|
||||||
|
|
||||||
|
var player_name := "Spieler"
|
||||||
|
## peer_id -> name (enthält auch den Host mit id 1)
|
||||||
|
var lobby: Dictionary = {}
|
||||||
|
var is_host := false
|
||||||
|
var in_game := false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||||
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||||
|
multiplayer.connected_to_server.connect(_on_connected)
|
||||||
|
multiplayer.connection_failed.connect(_on_connect_fail)
|
||||||
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||||
|
|
||||||
|
|
||||||
|
func host_game(pname: String, port: int = PORT) -> bool:
|
||||||
|
player_name = pname
|
||||||
|
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)
|
||||||
|
return false
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
is_host = true
|
||||||
|
lobby = {1: pname}
|
||||||
|
lobby_changed.emit()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func join_game(ip: 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])
|
||||||
|
return false
|
||||||
|
multiplayer.multiplayer_peer = peer
|
||||||
|
is_host = false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func leave() -> void:
|
||||||
|
if multiplayer.multiplayer_peer != null:
|
||||||
|
multiplayer.multiplayer_peer.close()
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
lobby.clear()
|
||||||
|
is_host = false
|
||||||
|
in_game = false
|
||||||
|
lobby_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func is_online() -> bool:
|
||||||
|
var peer := multiplayer.multiplayer_peer
|
||||||
|
return peer is ENetMultiplayerPeer \
|
||||||
|
and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
|
||||||
|
|
||||||
|
|
||||||
|
func _on_peer_connected(id: int) -> void:
|
||||||
|
if is_host:
|
||||||
|
_push_lobby.rpc_id(id, lobby)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_peer_disconnected(id: int) -> void:
|
||||||
|
if is_host:
|
||||||
|
lobby.erase(id)
|
||||||
|
_push_lobby.rpc(lobby)
|
||||||
|
lobby_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connected() -> void:
|
||||||
|
_register.rpc_id(1, player_name)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_connect_fail() -> void:
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
connection_failed.emit("Server nicht erreichbar.")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_server_disconnected() -> void:
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
lobby.clear()
|
||||||
|
in_game = false
|
||||||
|
server_closed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "reliable")
|
||||||
|
func _register(pname: String) -> void:
|
||||||
|
if not is_host:
|
||||||
|
return
|
||||||
|
var id := multiplayer.get_remote_sender_id()
|
||||||
|
lobby[id] = pname.substr(0, 16)
|
||||||
|
_push_lobby.rpc(lobby)
|
||||||
|
lobby_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "reliable")
|
||||||
|
func _push_lobby(data: Dictionary) -> void:
|
||||||
|
lobby = data
|
||||||
|
lobby_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## 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}
|
||||||
|
lobby_changed.emit()
|
||||||
|
_begin({"peers": [1], "names": lobby.duplicate(), "ai": ai_count})
|
||||||
|
|
||||||
|
|
||||||
|
## Nur der Host ruft das auf.
|
||||||
|
func start_game(ai_count: int) -> void:
|
||||||
|
if not is_host:
|
||||||
|
return
|
||||||
|
var order: Array = lobby.keys()
|
||||||
|
order.sort()
|
||||||
|
var cfg := {"peers": order, "names": lobby.duplicate(), "ai": ai_count}
|
||||||
|
_begin.rpc(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _begin(cfg: Dictionary) -> void:
|
||||||
|
in_game = true
|
||||||
|
game_started.emit(cfg)
|
||||||
1
scripts/net.gd.uid
Normal file
1
scripts/net.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d23xmuufgcx5o
|
||||||
323
scripts/table.gd
Normal file
323
scripts/table.gd
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
extends Node3D
|
||||||
|
class_name GameTable
|
||||||
|
## Baut den 3D-Tisch mit Würfelzonen, Bechern, Licht und Kamera.
|
||||||
|
|
||||||
|
const TABLE_H := 0.95
|
||||||
|
const ZONE_W := 1.5 ## Breite der Würfelzone (tangential)
|
||||||
|
const ZONE_D := 0.95 ## Tiefe der Würfelzone (radial)
|
||||||
|
const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt
|
||||||
|
const SEAT_ARC := ZONE_W + 0.85 ## Bogenlänge, die ein Platz am Tischrand braucht
|
||||||
|
const MIN_ZONE_R := 1.15
|
||||||
|
const TILT_DEG := 15.0 ## Neigung der Kamera gegen die Senkrechte
|
||||||
|
const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige
|
||||||
|
const HUD_BOTTOM := 0.07 ## Bildanteil unten als Rand
|
||||||
|
|
||||||
|
## Tisch und Zonenradius wachsen mit der Spielerzahl.
|
||||||
|
var table_r := 2.0
|
||||||
|
var zone_r := 1.2
|
||||||
|
|
||||||
|
var seat_count := 0
|
||||||
|
var local_seat := 0
|
||||||
|
var seat_dir: Array[Vector3] = []
|
||||||
|
var zones: Array[Node3D] = []
|
||||||
|
var cups: Array[Node3D] = []
|
||||||
|
var labels: Array[Label3D] = []
|
||||||
|
var cup_tweens: Array = []
|
||||||
|
var dice: Array = [] ## seat -> Array[Die]
|
||||||
|
|
||||||
|
var camera: Camera3D
|
||||||
|
|
||||||
|
|
||||||
|
func build(count: int, local_index: int) -> void:
|
||||||
|
seat_count = count
|
||||||
|
local_seat = local_index
|
||||||
|
zone_r = maxf(MIN_ZONE_R, count * SEAT_ARC / TAU)
|
||||||
|
table_r = zone_r + ZONE_D * 0.5 + 0.45
|
||||||
|
_build_room()
|
||||||
|
seat_dir.clear()
|
||||||
|
for i in count:
|
||||||
|
var ang := -PI * 0.5 + float(i - local_index) * TAU / float(count)
|
||||||
|
seat_dir.append(Vector3(cos(ang), 0.0, sin(ang)))
|
||||||
|
for i in count:
|
||||||
|
_build_seat(i)
|
||||||
|
dice.append([])
|
||||||
|
_build_camera()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_room() -> void:
|
||||||
|
var env := WorldEnvironment.new()
|
||||||
|
var e := Environment.new()
|
||||||
|
e.background_mode = Environment.BG_COLOR
|
||||||
|
e.background_color = Color(0.04, 0.05, 0.07)
|
||||||
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
e.ambient_light_color = Color(0.35, 0.38, 0.45)
|
||||||
|
e.ambient_light_energy = 1.1
|
||||||
|
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||||
|
e.fog_enabled = true
|
||||||
|
e.fog_light_color = Color(0.05, 0.06, 0.09)
|
||||||
|
e.fog_density = 0.02
|
||||||
|
env.environment = e
|
||||||
|
add_child(env)
|
||||||
|
|
||||||
|
var lamp := SpotLight3D.new()
|
||||||
|
lamp.position = Vector3(0, TABLE_H + table_r * 1.45, 0)
|
||||||
|
lamp.rotation_degrees = Vector3(-90, 0, 0)
|
||||||
|
lamp.light_energy = 2.5
|
||||||
|
lamp.light_color = Color(1.0, 0.94, 0.82)
|
||||||
|
lamp.spot_range = table_r * 4.2
|
||||||
|
lamp.spot_angle = 60.0
|
||||||
|
lamp.spot_attenuation = 0.6
|
||||||
|
lamp.shadow_enabled = true
|
||||||
|
add_child(lamp)
|
||||||
|
|
||||||
|
var fill := DirectionalLight3D.new()
|
||||||
|
fill.rotation_degrees = Vector3(-55, 35, 0)
|
||||||
|
fill.light_energy = 0.25
|
||||||
|
add_child(fill)
|
||||||
|
|
||||||
|
# Boden
|
||||||
|
var floor_body := StaticBody3D.new()
|
||||||
|
var floor_col := CollisionShape3D.new()
|
||||||
|
var fs := BoxShape3D.new()
|
||||||
|
fs.size = Vector3(24, 0.4, 24)
|
||||||
|
floor_col.shape = fs
|
||||||
|
floor_col.position = Vector3(0, -0.2, 0)
|
||||||
|
floor_body.add_child(floor_col)
|
||||||
|
var floor_mi := MeshInstance3D.new()
|
||||||
|
var fpm := PlaneMesh.new()
|
||||||
|
fpm.size = Vector2(24, 24)
|
||||||
|
floor_mi.mesh = fpm
|
||||||
|
floor_mi.material_override = _mat(Color(0.08, 0.07, 0.09), 0.9)
|
||||||
|
floor_body.add_child(floor_mi)
|
||||||
|
add_child(floor_body)
|
||||||
|
|
||||||
|
# Tischplatte
|
||||||
|
var table := StaticBody3D.new()
|
||||||
|
var tcol := CollisionShape3D.new()
|
||||||
|
var tcs := CylinderShape3D.new()
|
||||||
|
tcs.radius = table_r
|
||||||
|
tcs.height = 0.12
|
||||||
|
tcol.shape = tcs
|
||||||
|
tcol.position = Vector3(0, TABLE_H - 0.06, 0)
|
||||||
|
table.add_child(tcol)
|
||||||
|
var tmi := MeshInstance3D.new()
|
||||||
|
var tcm := CylinderMesh.new()
|
||||||
|
tcm.top_radius = table_r
|
||||||
|
tcm.bottom_radius = table_r
|
||||||
|
tcm.height = 0.12
|
||||||
|
tmi.mesh = tcm
|
||||||
|
tmi.position = Vector3(0, TABLE_H - 0.06, 0)
|
||||||
|
tmi.material_override = _mat(Color(0.10, 0.28, 0.17), 0.85)
|
||||||
|
table.add_child(tmi)
|
||||||
|
add_child(table)
|
||||||
|
|
||||||
|
# Tischbein
|
||||||
|
var leg := MeshInstance3D.new()
|
||||||
|
var lcm := CylinderMesh.new()
|
||||||
|
lcm.top_radius = table_r * 0.09
|
||||||
|
lcm.bottom_radius = table_r * 0.22
|
||||||
|
lcm.height = TABLE_H - 0.12
|
||||||
|
leg.mesh = lcm
|
||||||
|
leg.position = Vector3(0, (TABLE_H - 0.12) * 0.5, 0)
|
||||||
|
leg.material_override = _mat(Color(0.16, 0.11, 0.07), 0.7)
|
||||||
|
add_child(leg)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_seat(i: int) -> void:
|
||||||
|
var dir: Vector3 = seat_dir[i]
|
||||||
|
var zone := Node3D.new()
|
||||||
|
zone.position = dir * zone_r + Vector3(0, TABLE_H, 0)
|
||||||
|
zone.rotation.y = -atan2(dir.z, dir.x)
|
||||||
|
add_child(zone)
|
||||||
|
zones.append(zone)
|
||||||
|
|
||||||
|
var walls := StaticBody3D.new()
|
||||||
|
zone.add_child(walls)
|
||||||
|
# lokale x = radial (Tiefe), lokale z = tangential (Breite)
|
||||||
|
var specs := [
|
||||||
|
[Vector3(ZONE_D * 0.5, WALL_H * 0.5, 0), Vector3(0.12, WALL_H, ZONE_W)],
|
||||||
|
[Vector3(-ZONE_D * 0.5, WALL_H * 0.5, 0), Vector3(0.12, WALL_H, ZONE_W)],
|
||||||
|
[Vector3(0, WALL_H * 0.5, ZONE_W * 0.5), Vector3(ZONE_D, WALL_H, 0.12)],
|
||||||
|
[Vector3(0, WALL_H * 0.5, -ZONE_W * 0.5), Vector3(ZONE_D, WALL_H, 0.12)],
|
||||||
|
]
|
||||||
|
var rim_mat := _mat(Color(0.22, 0.15, 0.09), 0.6)
|
||||||
|
for s in specs:
|
||||||
|
var c := CollisionShape3D.new()
|
||||||
|
var b := BoxShape3D.new()
|
||||||
|
b.size = s[1]
|
||||||
|
c.shape = b
|
||||||
|
c.position = s[0]
|
||||||
|
walls.add_child(c)
|
||||||
|
var m := MeshInstance3D.new()
|
||||||
|
var bm := BoxMesh.new()
|
||||||
|
bm.size = Vector3(s[1].x, 0.05, s[1].z)
|
||||||
|
m.mesh = bm
|
||||||
|
m.position = Vector3(s[0].x, 0.025, s[0].z)
|
||||||
|
m.material_override = rim_mat
|
||||||
|
walls.add_child(m)
|
||||||
|
|
||||||
|
# Filzfläche
|
||||||
|
var felt := MeshInstance3D.new()
|
||||||
|
var fm := BoxMesh.new()
|
||||||
|
fm.size = Vector3(ZONE_D, 0.01, ZONE_W)
|
||||||
|
felt.mesh = fm
|
||||||
|
felt.position = Vector3(0, 0.006, 0)
|
||||||
|
felt.material_override = _mat(Color(0.07, 0.20, 0.13), 0.95)
|
||||||
|
zone.add_child(felt)
|
||||||
|
|
||||||
|
# Becher
|
||||||
|
var cup := Node3D.new()
|
||||||
|
cup.position = Vector3(0, 0.02, 0)
|
||||||
|
zone.add_child(cup)
|
||||||
|
var cup_mi := MeshInstance3D.new()
|
||||||
|
var cm := CylinderMesh.new()
|
||||||
|
cm.top_radius = 0.33
|
||||||
|
cm.bottom_radius = 0.42
|
||||||
|
cm.height = 0.44
|
||||||
|
cup_mi.mesh = cm
|
||||||
|
cup_mi.position = Vector3(0, 0.22, 0)
|
||||||
|
cup_mi.material_override = _mat(Color(0.35, 0.12, 0.10), 0.5)
|
||||||
|
cup.add_child(cup_mi)
|
||||||
|
cups.append(cup)
|
||||||
|
cup_tweens.append(null)
|
||||||
|
|
||||||
|
# Namensschild
|
||||||
|
var lbl := Label3D.new()
|
||||||
|
lbl.position = dir * zone_r + Vector3(0, TABLE_H + 0.52, 0) # oben auf dem Becher
|
||||||
|
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
# Keine feste Bildschirmgrösse: so schrumpft der Name mit dem Becher, wenn
|
||||||
|
# der Tisch bei mehr Spielern wächst und die Kamera zurückweicht.
|
||||||
|
lbl.font_size = 64
|
||||||
|
lbl.pixel_size = 0.0023
|
||||||
|
lbl.outline_size = 12
|
||||||
|
lbl.no_depth_test = true
|
||||||
|
lbl.text = ""
|
||||||
|
lbl.visible = i != local_seat # den eigenen Namen kennt man
|
||||||
|
add_child(lbl)
|
||||||
|
labels.append(lbl)
|
||||||
|
|
||||||
|
|
||||||
|
## Fast senkrechte Aufsicht mit leichter Neigung, damit die Würfel noch
|
||||||
|
## plastisch wirken. Der eigene Platz liegt unten im Bild.
|
||||||
|
func _build_camera() -> void:
|
||||||
|
camera = Camera3D.new()
|
||||||
|
camera.fov = 58.0
|
||||||
|
camera.current = true
|
||||||
|
add_child(camera)
|
||||||
|
_frame_camera()
|
||||||
|
get_viewport().size_changed.connect(_frame_camera)
|
||||||
|
|
||||||
|
|
||||||
|
func _frame_camera() -> void:
|
||||||
|
var dir: Vector3 = seat_dir[local_seat]
|
||||||
|
var fov_v := deg_to_rad(camera.fov)
|
||||||
|
var tilt := deg_to_rad(TILT_DEG)
|
||||||
|
|
||||||
|
var vp := get_viewport().get_visible_rect().size
|
||||||
|
var aspect := 16.0 / 9.0 if vp.y <= 0.0 else vp.x / vp.y
|
||||||
|
|
||||||
|
# Der Tisch füllt das freie Band zwischen den HUD-Feldern aus. Geneigt
|
||||||
|
# betrachtet wird er zur Ellipse: senkrecht kürzer, waagerecht unverändert.
|
||||||
|
var band := 1.0 - HUD_TOP - HUD_BOTTOM
|
||||||
|
var half_v := tan(fov_v * 0.5)
|
||||||
|
var dist := (table_r * cos(tilt)) / (band * half_v)
|
||||||
|
# Waagerecht ist normalerweise reichlich Platz, in schmalen Fenstern nicht.
|
||||||
|
dist = maxf(dist, table_r / (0.98 * tan(fov_v * 0.5) * aspect))
|
||||||
|
# Das freie Band liegt nicht mittig, der Tisch rückt entsprechend nach oben.
|
||||||
|
var shift := (HUD_BOTTOM - HUD_TOP) * half_v * dist
|
||||||
|
|
||||||
|
var target := dir * shift + Vector3(0, TABLE_H, 0)
|
||||||
|
var pos := target + (Vector3.UP * cos(tilt) + dir * sin(tilt)) * dist
|
||||||
|
camera.look_at_from_position(pos, target, Vector3.UP)
|
||||||
|
|
||||||
|
|
||||||
|
func _mat(col: Color, rough: float) -> StandardMaterial3D:
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.albedo_color = col
|
||||||
|
m.roughness = rough
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
## --- Würfel ---
|
||||||
|
|
||||||
|
func set_dice_count(seat: int, count: int) -> void:
|
||||||
|
var arr: Array = dice[seat]
|
||||||
|
while arr.size() > count:
|
||||||
|
var d = arr.pop_back()
|
||||||
|
d.queue_free()
|
||||||
|
while arr.size() < count:
|
||||||
|
var d := Die.new()
|
||||||
|
add_child(d)
|
||||||
|
d.global_position = zones[seat].global_position + Vector3(0, 0.4, 0)
|
||||||
|
arr.append(d)
|
||||||
|
_layout_idle(seat)
|
||||||
|
|
||||||
|
|
||||||
|
func dice_of(seat: int) -> Array:
|
||||||
|
return dice[seat]
|
||||||
|
|
||||||
|
|
||||||
|
func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
|
||||||
|
var zone: Node3D = zones[seat]
|
||||||
|
var arr: Array = dice[seat]
|
||||||
|
var n := arr.size()
|
||||||
|
for i in n:
|
||||||
|
# eigene Spur je Würfel, damit sie nicht übereinander fallen
|
||||||
|
var lane := ((float(i) + 0.5) / n * 2.0 - 1.0) * ZONE_W * 0.34
|
||||||
|
var off := Vector3(
|
||||||
|
rng.randf_range(-ZONE_D * 0.14, ZONE_D * 0.14),
|
||||||
|
0.48 + rng.randf_range(0.0, 0.10),
|
||||||
|
lane + rng.randf_range(-0.03, 0.03)
|
||||||
|
)
|
||||||
|
arr[i].throw_from(zone.global_transform * off, rng)
|
||||||
|
|
||||||
|
|
||||||
|
## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal).
|
||||||
|
func _layout_idle(seat: int) -> void:
|
||||||
|
var zone: Node3D = zones[seat]
|
||||||
|
var arr: Array = dice[seat]
|
||||||
|
for i in arr.size():
|
||||||
|
var x := (float(i) - (arr.size() - 1) * 0.5) * 0.24
|
||||||
|
var t := zone.global_transform
|
||||||
|
t.origin = zone.global_transform * Vector3(0.0, Die.HALF + 0.01, x)
|
||||||
|
arr[i].set_kinematic(true)
|
||||||
|
arr[i].global_transform = t
|
||||||
|
|
||||||
|
|
||||||
|
func arrange_and_show(seat: int, values: Array, rng: RandomNumberGenerator) -> void:
|
||||||
|
_layout_idle(seat)
|
||||||
|
var arr: Array = dice[seat]
|
||||||
|
for i in mini(arr.size(), values.size()):
|
||||||
|
arr[i].snap_to_value(values[i], rng)
|
||||||
|
set_dice_visible(seat, true)
|
||||||
|
|
||||||
|
|
||||||
|
## Friert alle Würfel ein, damit gesetzte Werte liegen bleiben.
|
||||||
|
func freeze_dice(seat: int) -> void:
|
||||||
|
for d in dice[seat]:
|
||||||
|
d.set_kinematic(true)
|
||||||
|
|
||||||
|
|
||||||
|
func set_dice_visible(seat: int, on: bool) -> void:
|
||||||
|
for d in dice[seat]:
|
||||||
|
d.visible = on
|
||||||
|
|
||||||
|
|
||||||
|
## Becher senken (Würfel verdeckt) oder anheben und ausblenden (Würfel frei).
|
||||||
|
func set_cup_down(seat: int, down: bool) -> void:
|
||||||
|
var cup: Node3D = cups[seat]
|
||||||
|
if cup_tweens[seat] != null and cup_tweens[seat].is_valid():
|
||||||
|
cup_tweens[seat].kill()
|
||||||
|
var tw := create_tween()
|
||||||
|
cup_tweens[seat] = tw
|
||||||
|
if down:
|
||||||
|
cup.visible = true
|
||||||
|
tw.tween_property(cup, "position:y", 0.02, 0.35).set_trans(Tween.TRANS_CUBIC)
|
||||||
|
else:
|
||||||
|
tw.tween_property(cup, "position:y", 1.1, 0.35).set_trans(Tween.TRANS_CUBIC)
|
||||||
|
tw.tween_callback(func() -> void: cup.visible = false)
|
||||||
|
|
||||||
|
|
||||||
|
func set_label(seat: int, text: String, col: Color) -> void:
|
||||||
|
labels[seat].text = text
|
||||||
|
labels[seat].modulate = col
|
||||||
1
scripts/table.gd.uid
Normal file
1
scripts/table.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bnmt2celsowb6
|
||||||
Reference in New Issue
Block a user