commit e299f9d08811f86d39c7f655241c8953455ee195 Author: Team3 Date: Sat Sep 5 23:19:46 2026 +0200 first commit diff --git a/.godot/.gdignore b/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 new file mode 100644 index 0000000..8c6f89f --- /dev/null +++ b/.godot/editor/filesystem_cache10 @@ -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<><>:: diff --git a/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg new file mode 100644 index 0000000..9e8467b --- /dev/null +++ b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg @@ -0,0 +1,6 @@ +[folding] + +node_unfolds=[] +resource_unfolds=[] +nodes_folded=[] +animation_groups_folded=[] diff --git a/.godot/editor/project_metadata.cfg b/.godot/editor/project_metadata.cfg new file mode 100644 index 0000000..1276ebb --- /dev/null +++ b/.godot/editor/project_metadata.cfg @@ -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"] diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg new file mode 100644 index 0000000..246d72a --- /dev/null +++ b/.godot/global_script_class_cache.cfg @@ -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" +}] diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin new file mode 100644 index 0000000..a94f2e3 Binary files /dev/null and b/.godot/uid_cache.bin differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a31d8f --- /dev/null +++ b/README.md @@ -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. diff --git a/anforderungen.md b/anforderungen.md new file mode 100644 index 0000000..7692c0b --- /dev/null +++ b/anforderungen.md @@ -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 +- \ No newline at end of file diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..51eaa30 --- /dev/null +++ b/project.godot @@ -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 diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..d9dce77 --- /dev/null +++ b/scenes/main.tscn @@ -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") diff --git a/scripts/ai.gd b/scripts/ai.gd new file mode 100644 index 0000000..40de244 --- /dev/null +++ b/scripts/ai.gd @@ -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 diff --git a/scripts/ai.gd.uid b/scripts/ai.gd.uid new file mode 100644 index 0000000..16d8702 --- /dev/null +++ b/scripts/ai.gd.uid @@ -0,0 +1 @@ +uid://wkuby2u01vbb diff --git a/scripts/boosters.gd b/scripts/boosters.gd new file mode 100644 index 0000000..dc97bae --- /dev/null +++ b/scripts/boosters.gd @@ -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 diff --git a/scripts/boosters.gd.uid b/scripts/boosters.gd.uid new file mode 100644 index 0000000..0b073a1 --- /dev/null +++ b/scripts/boosters.gd.uid @@ -0,0 +1 @@ +uid://c11yljsc2s3x4 diff --git a/scripts/die.gd b/scripts/die.gd new file mode 100644 index 0000000..07cce35 --- /dev/null +++ b/scripts/die.gd @@ -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 diff --git a/scripts/die.gd.uid b/scripts/die.gd.uid new file mode 100644 index 0000000..043779a --- /dev/null +++ b/scripts/die.gd.uid @@ -0,0 +1 @@ +uid://cfrejcvw78gen diff --git a/scripts/game.gd b/scripts/game.gd new file mode 100644 index 0000000..7cdf3cf --- /dev/null +++ b/scripts/game.gd @@ -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) diff --git a/scripts/game.gd.uid b/scripts/game.gd.uid new file mode 100644 index 0000000..aa40b72 --- /dev/null +++ b/scripts/game.gd.uid @@ -0,0 +1 @@ +uid://ciff82aqhoplv diff --git a/scripts/hud.gd b/scripts/hud.gd new file mode 100644 index 0000000..095d2df --- /dev/null +++ b/scripts/hud.gd @@ -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 diff --git a/scripts/hud.gd.uid b/scripts/hud.gd.uid new file mode 100644 index 0000000..90b86fc --- /dev/null +++ b/scripts/hud.gd.uid @@ -0,0 +1 @@ +uid://cdqtjolck2gno diff --git a/scripts/main.gd b/scripts/main.gd new file mode 100644 index 0000000..e926cac --- /dev/null +++ b/scripts/main.gd @@ -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") diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid new file mode 100644 index 0000000..fd0b439 --- /dev/null +++ b/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://bcurjfvrdwb51 diff --git a/scripts/menu.gd b/scripts/menu.gd new file mode 100644 index 0000000..a5bd5f6 --- /dev/null +++ b/scripts/menu.gd @@ -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) diff --git a/scripts/menu.gd.uid b/scripts/menu.gd.uid new file mode 100644 index 0000000..8ff4837 --- /dev/null +++ b/scripts/menu.gd.uid @@ -0,0 +1 @@ +uid://dxm710y2k5alf diff --git a/scripts/net.gd b/scripts/net.gd new file mode 100644 index 0000000..6af5aad --- /dev/null +++ b/scripts/net.gd @@ -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) diff --git a/scripts/net.gd.uid b/scripts/net.gd.uid new file mode 100644 index 0000000..c447cf3 --- /dev/null +++ b/scripts/net.gd.uid @@ -0,0 +1 @@ +uid://d23xmuufgcx5o diff --git a/scripts/table.gd b/scripts/table.gd new file mode 100644 index 0000000..b0a1c0e --- /dev/null +++ b/scripts/table.gd @@ -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 diff --git a/scripts/table.gd.uid b/scripts/table.gd.uid new file mode 100644 index 0000000..698190b --- /dev/null +++ b/scripts/table.gd.uid @@ -0,0 +1 @@ +uid://bnmt2celsowb6