first commit

This commit is contained in:
Team3
2026-09-05 23:19:46 +02:00
commit e299f9d088
28 changed files with 2622 additions and 0 deletions

1
.godot/.gdignore Normal file
View File

@@ -0,0 +1 @@

View 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<><>::

View File

@@ -0,0 +1,6 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
animation_groups_folded=[]

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

View 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

Binary file not shown.

119
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
uid://wkuby2u01vbb

82
scripts/boosters.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://c11yljsc2s3x4

206
scripts/die.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://cfrejcvw78gen

716
scripts/game.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://ciff82aqhoplv

507
scripts/hud.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://cdqtjolck2gno

38
scripts/main.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://bcurjfvrdwb51

237
scripts/menu.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://dxm710y2k5alf

136
scripts/net.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://d23xmuufgcx5o

323
scripts/table.gd Normal file
View 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
View File

@@ -0,0 +1 @@
uid://bnmt2celsowb6