This commit is contained in:
Team3
2026-09-07 04:29:23 +02:00
parent e299f9d088
commit 6478559252
8 changed files with 708 additions and 197 deletions

View File

@@ -25,14 +25,27 @@ Das Spiel startet im Vollbild, **F11** schaltet um. **Esc** öffnet ein Menü mi
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.
Die Bildaufteilung: Tisch mittig, darauf die letzte Wette als so viele Würfel
wie angesagt; aktive Booster oben links, Würfel aller Spieler oben rechts
(Name mit den Würfeln darunter) – beide Panels nur so breit wie ihr Inhalt –,
und die eigene Ansage als schmale Leiste unten in der Mitte.
Augenzahlen werden überall als gezeichnete Würfel dargestellt (`DieIcon` in
`hud.gd`), unbekannte Werte als Würfel mit Fragezeichen. Im Panel oben rechts
`hud.gd`), unbekannte Werte als Würfel mit Fragezeichen. Beim Aufdecken färbt
sich die Wette in der Tischmitte ein: grün die Würfel, die es wirklich gab,
rot die fehlenden. Am Tisch trägt jeder Becher ein Schild mit Namen und der letzten Ansage dieses
Spielers; Unterlage und Würfelbilder dafür werden beim Start einmal als Textur
gezeichnet. 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.
Jeder Platz hat eine runde Würfelzone mit umlaufender Bande. Die runde Form
ist nicht nur Optik: in den Ecken der früheren rechteckigen Zone verkanteten
sich Würfel, im Kreis liegt kein Wurf mehr schief (Messung: 2,0 % schiefe
Würfel und 1,3 % Nachhelfer vorher, 0,4 % und 0 % nachher). Beim Aufdecken
legen sich die Würfel als Bogen auf einen Kreis um die Zonenmitte, damit sie
sich nicht überdecken und das Namensschild frei bleibt.
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
@@ -52,6 +65,13 @@ Zwei Engine-Vorgaben sind bewusst gesetzt, beide in `project.godot`:
* **`renderer/rendering_method="mobile"`** – auf integrierten GPUs kostet
Forward+ hier mehr als das Doppelte: 22 statt 47 fps bei 30 Würfeln.
Ein liegender Würfel wird mit `FREEZE_MODE_STATIC` eingefroren, nicht
kinematisch. Ein kinematischer Körper meldet seine Lage weiter an den Knoten
zurück: setzt ein Booster wie *Glücksgriff* die Augenzahl über
`snap_to_value()`, überschreibt der Physikserver die neue Lage im nächsten
Schritt wieder mit der alten, und der Würfel zeigt den falschen Wert.
`Die.place()` zieht die Lage zusätzlich sofort im Physikserver nach.
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.

View File

@@ -57,10 +57,12 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary,
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}
var c: int = int(bid.count) + maxi(1, min_raise)
if c <= total_dice:
return {"type": "bid", "count": c, "face": bid.face}
if min_raise <= 1 and int(bid.face) < 6:
return {"type": "bid", "count": int(bid.count), "face": int(bid.face) + 1}
# Es gibt kein gültiges Gebot mehr - dann bleibt nur die Anfechtung.
return {"type": "challenge"}
return {"type": "bid", "count": raise_.count, "face": raise_.face}

View File

@@ -194,13 +194,21 @@ func snap_to_value(value: int, rng: RandomNumberGenerator) -> void:
var t := global_transform
t.basis = Basis(wb, wa, wc) * Basis(b, a, c).transposed()
global_transform = t
place(t)
if not freeze:
sleeping = false
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
## Setzt die Lage und schiebt sie sofort in den Physikserver nach. Ohne das
## schreibt der Server im nächsten Schritt noch einmal die alte Lage zurück -
## der Würfel zeigt dann einen Frame lang die falsche Augenzahl.
func place(t: Transform3D) -> void:
global_transform = t
PhysicsServer3D.body_set_state(get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, t)
func set_kinematic(on: bool) -> void:
freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = on

View File

@@ -4,6 +4,7 @@ extends Node3D
const START_DICE := 5
const BOOST_TIMEOUT := 30.0
const BID_TIMEOUT := 30.0
const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses
var table: GameTable
var hud: CanvasLayer
@@ -18,11 +19,13 @@ var my_values: Array = [] ## Client-Sicht (-1 = vernebelt)
var bid: Dictionary = {}
var turn := 0
var starter := 0
var fogged: Dictionary = {} ## seat -> int index
var fogged: Dictionary = {} ## seat -> Array[int] verdeckter Würfel
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 offers: Dictionary = {} ## seat -> die drei angebotenen Booster
var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = []
var phase := "lobby"
var banner := ""
@@ -33,6 +36,8 @@ var _stream_acc := 0.0
var _last_phase := ""
var deadline := 0.0 ## Sekunden-Zeitstempel, 0 = keine Frist
var _pause_left := 0.0
var _quitting := false ## beim Verlassen: laufende Spielschleife sauber abbrechen
var reveal_total := 0 ## beim Aufdecken: wie viele der angesagten Augenzahl wirklich da waren
func _ready() -> void:
@@ -108,17 +113,25 @@ func _refresh_labels() -> void:
func _host_loop() -> void:
_log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE])
starter = 0
while _alive_count() > 1:
while _alive() and _alive_count() > 1:
await _phase_roll()
# Zwischen den Phasen prüfen: wer während des Wurfs verlässt, darf nicht
# noch in die Boosterphase laufen - dort ist das Netzwerk schon weg.
if not _alive():
return
await _phase_boost()
if not _alive():
return
var result: Dictionary = await _phase_bid()
if not _alive():
return
await _phase_reveal(result)
phase = "over"
var winner := ""
for s in seats:
if s.alive:
winner = s.name
banner = "%s gewinnt!" % winner
banner = "%s gewinnt" % winner
_log(banner)
_push_state()
@@ -156,6 +169,18 @@ func _total_dice() -> int:
return n
## Solange falsch, darf die Spielschleife nicht weiterlaufen - beim Verlassen
## ist der Knoten sonst schon aus dem Baum und jeder Zugriff schlägt fehl.
func _alive() -> bool:
return not _quitting and is_inside_tree()
## Ein gerade getrennter Peer steht noch in `seats`, bis das Signal ankommt -
## ein RPC dorthin wäre ein Fehler in der Konsole.
func _peer_live(peer: int) -> bool:
return peer > 1 and multiplayer.get_peers().has(peer)
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
@@ -163,8 +188,10 @@ func _now() -> float:
func _phase_roll() -> void:
phase = "roll"
deadline = 0.0
reveal_total = 0
bid = {}
fogged.clear()
last_bids.clear()
peeked.clear()
min_raise.clear()
must_bid.clear()
@@ -183,6 +210,8 @@ func _phase_roll() -> void:
table.throw_dice(i, rng)
await _await_settled(_alive_seats())
_stream = false
if not _alive():
return
for i in _alive_seats():
host_values[i] = []
@@ -197,7 +226,13 @@ 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:
if not _alive():
return
await get_tree().physics_frame
if not _alive():
return
if get_tree().paused:
continue
t += step
if t < 0.7:
continue
@@ -218,7 +253,7 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
func _phase_boost() -> void:
phase = "boost"
_answered.clear()
var offers := {}
offers.clear()
for i in _alive_seats():
offers[i] = BoosterDB.draw_offer(rng)
if seats[i].ai:
@@ -231,12 +266,16 @@ func _phase_boost() -> void:
_push_state()
await _wait_until(_all_boosts_in, BOOST_TIMEOUT)
if not _alive():
return
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()
if not _alive():
return
_snapshot_all()
_push_state()
@@ -290,6 +329,8 @@ func _apply_boosters() -> void:
_throw_subset(seat, rerolls[seat])
await _await_settled(rerolls.keys())
_stream = false
if not _alive():
return
for seat: int in rerolls:
var dl: Array = table.dice_of(seat)
for k: int in rerolls[seat]:
@@ -327,7 +368,11 @@ func _apply_boosters() -> void:
"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)
var k := rng.randi_range(0, host_values[o].size() - 1)
var liste: Array = fogged.get(o, [])
if not liste.has(k):
liste.append(k)
fogged[o] = liste
func _merge(a: Array, b: Array) -> Array:
@@ -343,7 +388,7 @@ func _set_die(seat: int, idx: int, value: int) -> void:
return
host_values[seat][idx] = value
var dl: Array = table.dice_of(seat)
if idx < dl.size():
if idx < dl.size() and dl[idx].is_inside_tree():
dl[idx].snap_to_value(value, rng)
@@ -353,14 +398,16 @@ func _throw_subset(seat: int, idxs: Array) -> void:
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))
var ang := rng.randf() * TAU
var rad := GameTable.ZONE_RAD * rng.randf_range(0.25, 0.45)
var off := Vector3(cos(ang) * rad, 0.42, sin(ang) * rad)
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:
while _alive():
_answered.clear()
deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT
_push_state()
@@ -392,16 +439,38 @@ func _phase_bid() -> Dictionary:
## 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}
# Wer einen Maulkorb hat, darf sich nicht über ein ungültiges Gebot in
# die Anfechtung retten - dann wird auf das kleinste gültige korrigiert.
var low := _lowest_legal_bid(seat)
if not low.is_empty() and (bid.is_empty() or must_bid.get(seat, false)):
a = {"type": "bid", "count": low.count, "face": low.face}
elif not bid.is_empty():
a = {"type": "challenge"}
else:
a = {"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)}
last_bids[seat] = {"count": bid.count, "face": bid.face}
must_bid.erase(seat)
_log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face])
return {}
## Kleinstes Gebot, das dieser Platz noch ansagen darf ({} = gar keins mehr).
func _lowest_legal_bid(seat: int) -> Dictionary:
if bid.is_empty():
return {"count": 1, "face": 1}
var mr: int = min_raise.get(seat, 1)
if mr == 1 and int(bid.face) < 6:
return {"count": int(bid.count), "face": int(bid.face) + 1}
var c: int = int(bid.count) + mr
if c <= _total_dice():
return {"count": c, "face": 1}
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
@@ -426,18 +495,26 @@ func _phase_reveal(result: Dictionary) -> void:
if v == b.face:
total += 1
reveal_total = total
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)
_log("%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [
b.count, b.face, total, seats[loser].name])
banner = "%s verliert" % seats[loser].name
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)
# Gezielt an die Mitspieler am Tisch: ein Peer, der nur in der Lobby sitzt,
# hat keinen Spielknoten und würde das Paket ins Leere laufen lassen.
var values := _all_values()
for i in seats.size():
var peer: int = seats[i].peer
if _peer_live(peer):
_reveal.rpc_id(peer, values, banner)
_push_state()
await _sleep(4.5)
await _sleep(REVEAL_PAUSE)
if not _alive():
return
seats[loser].count -= 1
if seats[loser].count <= 0:
@@ -461,18 +538,28 @@ func _all_values() -> Array:
return out
## process_always = false: eine offene Pause hält den Timer wirklich an.
func _sleep(sec: float) -> void:
await get_tree().create_timer(sec).timeout
if not _alive():
return
await get_tree().create_timer(sec, false).timeout
func _wait_until(cond: Callable, timeout: float) -> bool:
var t := 0.0
while t < timeout:
if not _alive():
return false
if cond.call():
return true
await get_tree().process_frame
if not _alive():
return false
# Im Einzelspieler hält das Pausenmenü den Baum an - dann darf auch die
# Frist nicht weiterlaufen, sonst ist der Zug nach dem Fortsetzen weg.
if not get_tree().paused:
t += get_process_delta_time()
return cond.call()
return _alive() and cond.call()
func _log(line: String) -> void:
@@ -484,7 +571,7 @@ func _log(line: String) -> void:
# ---------------------------------------------------------------- Sync
func _physics_process(delta: float) -> void:
if not multiplayer.is_server() or not _stream:
if not _alive() or not _stream or not multiplayer.is_server():
return
_stream_acc += delta
if _stream_acc < 1.0 / 30.0:
@@ -492,7 +579,7 @@ func _physics_process(delta: float) -> void:
_stream_acc = 0.0
for i in seats.size():
var peer: int = seats[i].peer
if peer <= 1:
if not _peer_live(peer):
continue
_dice_stream.rpc_id(peer, _pack(i))
@@ -500,7 +587,7 @@ func _physics_process(delta: float) -> void:
func _snapshot_all() -> void:
for i in seats.size():
var peer: int = seats[i].peer
if peer > 1:
if _peer_live(peer):
_dice_snapshot.rpc_id(peer, _pack(i))
@@ -521,7 +608,7 @@ func _unpack(data: PackedFloat32Array) -> void:
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
dl[i].place(t)
@rpc("authority", "unreliable_ordered")
@@ -536,9 +623,11 @@ func _dice_snapshot(data: PackedFloat32Array) -> void:
@rpc("authority", "reliable")
func _reveal(all_values: Array, text: String) -> void:
# Auch die eigenen Würfel in den Bogen legen - sonst liegen sie als einzige
# noch verstreut da, wo die Physik sie fallen liess. Dafür die Werte des
# Hosts nehmen: my_values ist hier noch vernebelt, ein vernebelter Würfel
# würde sonst mit beliebiger Seite nach oben liegen bleiben.
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)
@@ -547,14 +636,34 @@ func _reveal(all_values: Array, text: String) -> void:
func _push_state() -> void:
if not _alive():
return
_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)
_apply_state(_state_for(i))
elif _peer_live(peer):
_state.rpc_id(peer, _state_for(i))
## Erst nach der Wahl offenlegen, sonst verrät die Anzeige während der
## Boosterphase, wer schon gewählt hat.
func _round_boosters() -> Array:
var out: Array = []
if phase == "boost" or phase == "roll":
return out
for i in seats.size():
out.append(String(chosen_boost.get(i, {}).get("id", "")))
return out
## Was jeder Platz in dieser Runde zuletzt angesagt hat.
func _seat_bids() -> Array:
var out: Array = []
for i in seats.size():
out.append(last_bids.get(i, {}))
return out
func _state_for(seat: int) -> Dictionary:
@@ -565,18 +674,22 @@ func _state_for(seat: int) -> Dictionary:
elif phase == "reveal":
vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel
else:
var fg: Array = fogged.get(seat, [])
for k in host_values[seat].size():
vis.append(-1 if fogged.get(seat, -1) == k else host_values[seat][k])
vis.append(-1 if fg.has(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,
"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 [],
"boosts": _round_boosters(),
"actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(),
"boost": chosen_boost.get(seat, {}).get("id", ""),
}
@@ -608,6 +721,13 @@ func _apply_state(st: Dictionary) -> void:
table.set_dice_visible(local_seat, true)
_refresh_labels()
_apply_fog_to_table()
var bids: Array = st.get("bids", [])
for i in mini(seats.size(), bids.size()):
var b: Dictionary = bids[i]
if b.is_empty():
table.set_bid(i, 0, 0)
else:
table.set_bid(i, int(b.count), int(b.face))
hud.update_state(st, local_seat)
@@ -659,7 +779,13 @@ func _on_pause_changed(on: bool) -> void:
func _on_quit() -> void:
if _quitting:
return
_quitting = true
get_tree().paused = false
# Sofort ausblenden, damit das Verlassen unmittelbar wirkt.
table.visible = false
hud.visible = false
Net.leave()
get_tree().change_scene_to_file("res://scenes/main.tscn")
@@ -680,6 +806,8 @@ func _req_boost(id: String, target: int) -> void:
func _seat_of(peer: int) -> int:
if peer <= 0: # 0 steht für einen KI-Platz, das ist nie ein Absender
return -1
for i in seats.size():
if seats[i].peer == peer:
return i
@@ -697,15 +825,21 @@ func _do_bid(seat: int, count: int, face: int) -> void:
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):
if bid.is_empty():
return
# Der Maulkorb sperrt die Anfechtung nur, solange es überhaupt noch ein
# gültiges Gebot gibt - sonst bliebe der Platz ohne jede Handlung.
if must_bid.get(seat, false) and not _lowest_legal_bid(seat).is_empty():
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):
if seat < 0 or phase != "boost" or chosen_boost.has(seat) or not seats[seat].alive:
return
if BoosterDB.get_booster(id).is_empty():
# Nur aus dem eigenen Angebot: sonst könnte ein veränderter Client sich
# jede Runde den stärksten Booster aus dem Katalog nehmen.
if not (offers.get(seat, []) as Array).has(id):
return
chosen_boost[seat] = {"id": id, "target": target}
_answered[seat] = true

View File

@@ -9,16 +9,31 @@ 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
## Freie Fläche in der Tischmitte zwischen den Plätzen. Die Würfel der letzten
## Wette werden so gross gewählt, dass sie dort hineinpassen.
const BID_BOX := Vector2(200.0, 150.0)
const TIMER_W := 52.0 ## Breite der Zeitanzeige in der Ansage-Leiste
const GRUEN := Color(0.55, 0.84, 0.55) ## vorhandener Würfel beim Aufdecken
const ROT := Color(0.94, 0.55, 0.52) ## fehlender Würfel beim Aufdecken
var root: Control
var players_box: VBoxContainer
var bid_row: HBoxContainer
var hint_label: Label
var bid_box: VBoxContainer
var bid_name: Label
var bid_row: HFlowContainer
var boosts_box: VBoxContainer
var banner_label: Label
var log_label: Label
var count_spin: SpinBox
var count_label: Label
var minus_btn: Button
var plus_btn: Button
var _count := 1
var _count_min := 1
var _count_max := 30
var _bid_count := 0 ## aktuelles Gebot, 0 = noch keins
var _bid_face := 0
var _min_raise := 1
var _actual := 0 ## beim Aufdecken: tatsächlich gefundene Würfel
var face_buttons: Array[Button] = []
var bid_btn: Button
var challenge_btn: Button
@@ -53,13 +68,16 @@ class DieIcon extends Control:
var value := 0
var _box := StyleBoxFlat.new()
func _init(v: int, px: float = 0.0) -> void:
func _init(v: int, px: float = 0.0, bg := Color.TRANSPARENT) -> 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
if bg.a > 0.0:
_box.bg_color = bg
else:
_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)
@@ -141,81 +159,95 @@ func _slot(al: float, at: float, ar: float, ab: float,
func _build() -> void:
# --- Spielerliste oben rechts
var pl := _panel()
_place(pl, 1, 0, 1, 0, -16 - PLAYERS_W, 16, -16, 16)
_place(pl, 1, 0, 1, 0, -16, 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)
players_box.add_theme_constant_override("separation", 6)
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)
# --- Aktive Booster oben links, Breite richtet sich nach dem Inhalt
var bo := _panel()
_place(bo, 0, 0, 0, 0, 16, 16, 16, 16)
root.add_child(bo)
boosts_box = VBoxContainer.new()
boosts_box.add_theme_constant_override("separation", 3)
bo.add_child(boosts_box)
# --- Banner in der Bildmitte
var banner_slot := _slot(0, 0, 1, 0, 140, 126, -140, 210)
# --- Letzte Wette in der Tischmitte, als so viele Würfel wie angesagt
var bid_slot := _slot(0, 0, 1, 1, 0, 0, 0, 0)
bid_box = VBoxContainer.new()
bid_box.add_theme_constant_override("separation", 8)
bid_slot.add_child(bid_box)
bid_name = _label("", 30, ACCENT)
bid_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# Auf die freie Mitte begrenzen, sonst läuft der Text über die Nachbarplätze.
bid_name.custom_minimum_size = Vector2(BID_BOX.x, 0)
bid_name.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
bid_box.add_child(bid_name)
bid_row = HFlowContainer.new()
bid_row.alignment = FlowContainer.ALIGNMENT_CENTER
bid_row.custom_minimum_size = Vector2(BID_BOX.x, 0)
bid_row.add_theme_constant_override("h_separation", 5)
bid_row.add_theme_constant_override("v_separation", 5)
bid_box.add_child(bid_row)
# --- Banner oben, im freien Band zwischen den beiden Seitenpanels
banner_label = _label("", 28, ACCENT)
banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
banner_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
banner_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
banner_label.custom_minimum_size = Vector2(1000, 0)
banner_slot.add_child(banner_label)
banner_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
var edge := 240.0
_place(banner_label, 0, 0, 1, 0, edge, 20, -edge, 150)
root.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
# --- Ansage: kompakte Leiste unten in der Mitte
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)
_slot(0, 1, 1, 1, 16, -110, -16, -16).add_child(action_panel)
var av := HBoxContainer.new()
av.add_theme_constant_override("separation", 16)
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)
# Gegenstück zur Zeitanzeige am rechten Ende, damit die Leiste symmetrisch
# bleibt, auch wenn dort gerade keine Zeit läuft.
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(TIMER_W, 0)
spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
av.add_child(spacer)
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("Anzahl", 15))
minus_btn = Button.new()
minus_btn.text = "−"
minus_btn.custom_minimum_size = Vector2(42, 42)
minus_btn.pressed.connect(_step_count.bind(-1))
av.add_child(minus_btn)
count_label = _label("1", 22)
count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
count_label.custom_minimum_size = Vector2(38, 0)
av.add_child(count_label)
plus_btn = Button.new()
plus_btn.text = "+"
plus_btn.custom_minimum_size = Vector2(42, 42)
plus_btn.pressed.connect(_step_count.bind(1))
av.add_child(plus_btn)
av.add_child(_label("Augenzahl", 15))
var frow := HBoxContainer.new()
frow.add_theme_constant_override("separation", 4)
av.add_child(frow)
# Die Auswahl hebt sich sonst kaum vom unbenutzten Knopf ab.
var gewaehlt := StyleBoxFlat.new()
gewaehlt.bg_color = Color(0.32, 0.26, 0.08)
gewaehlt.set_corner_radius_all(6)
gewaehlt.border_color = ACCENT
gewaehlt.set_border_width_all(2)
for f in range(1, 7):
var b := Button.new()
b.toggle_mode = true
b.custom_minimum_size = Vector2(40, 40)
b.custom_minimum_size = Vector2(42, 42)
b.add_theme_stylebox_override("pressed", gewaehlt)
b.add_theme_stylebox_override("hover_pressed", gewaehlt)
b.pressed.connect(_on_face.bind(f))
var ic := DieIcon.new(f)
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -230,16 +262,22 @@ func _build() -> void:
bid_btn = Button.new()
bid_btn.text = "Ansagen"
bid_btn.custom_minimum_size = Vector2(0, 36)
bid_btn.custom_minimum_size = Vector2(120, 42)
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.custom_minimum_size = Vector2(120, 42)
challenge_btn.pressed.connect(_on_challenge)
av.add_child(challenge_btn)
timer_bid = _label("", 18, DIM)
timer_bid.custom_minimum_size = Vector2(TIMER_W, 0)
timer_bid.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
timer_bid.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
av.add_child(timer_bid)
# --- Booster-Overlay
boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0)
boost_slot.visible = false
@@ -303,8 +341,58 @@ func _on_quit() -> void:
quit_pressed.emit()
## Ein Gebot muss höher sein als das vorige. Bei gleicher Anzahl geht das nur
## über eine höhere Augenzahl - die kleineren werden deshalb ausgeblendet.
func _face_allowed(f: int) -> bool:
if _bid_count <= 0 or _count >= _bid_count + _min_raise:
return true
if _min_raise > 1:
return false
return _count == _bid_count and f > _bid_face
func _refresh_faces() -> void:
var first_ok := 0
for i in face_buttons.size():
var ok := _my_turn and _face_allowed(i + 1)
face_buttons[i].disabled = not ok
face_buttons[i].modulate = Color(1, 1, 1, 1.0 if ok else 0.3)
if ok and first_ok == 0:
first_ok = i + 1
# Steht die gewählte Augenzahl nicht mehr zur Wahl, auf die kleinste mögliche
if _my_turn and first_ok > 0 and not _face_allowed(_face):
_on_face(first_ok)
## Beim Drankommen auf das kleinstmögliche Gebot stellen - von dort aus ist es
## am wenigsten Klickarbeit bis zur gewünschten Ansage.
func _lowest_bid() -> void:
if _bid_count <= 0:
_set_count(1)
_on_face(1)
elif _min_raise == 1 and _bid_face < 6:
# Gleiche Anzahl reicht, wenn die Augenzahl steigt.
_set_count(_bid_count)
_on_face(_bid_face + 1)
else:
_set_count(_bid_count + _min_raise)
_on_face(1)
func _step_count(delta: int) -> void:
_set_count(_count + delta)
func _set_count(v: int) -> void:
_count = clampi(v, _count_min, _count_max)
count_label.text = str(_count)
minus_btn.disabled = not _my_turn or _count <= _count_min
plus_btn.disabled = not _my_turn or _count >= _count_max
_refresh_faces()
func _on_bid() -> void:
bid_pressed.emit(int(count_spin.value), _face)
bid_pressed.emit(_count, _face)
func _on_challenge() -> void:
@@ -326,6 +414,14 @@ func _set_paused(on: bool) -> void:
pause_changed.emit(on)
## queue_free() nimmt den Knoten erst am Frameende aus dem Baum - bis dahin
## lägen alte und neue Einträge nebeneinander im Container.
func _clear(box: Node) -> void:
for c in box.get_children():
box.remove_child(c)
c.queue_free()
func _on_face(f: int) -> void:
_face = f
for i in face_buttons.size():
@@ -338,64 +434,96 @@ func update_state(st: Dictionary, my_seat: int) -> void:
_my_seat = my_seat
_my_values = st.values
_phase = st.phase
_actual = int(st.get("actual", 0))
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()
_clear(players_box)
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
var entry := VBoxContainer.new()
entry.add_theme_constant_override("separation", 2)
players_box.add_child(entry)
var nm := _label(("▸ " if turn else "") + _short(String(s.name)) + (" (KI)" if s.ai else ""), 14, col)
nm.clip_text = true
nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(nm)
entry.add_child(nm)
if not s.alive:
row.add_child(_label("ausgeschieden", 14, DIM))
entry.add_child(_label(" ausgeschieden", 13, DIM))
continue
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
entry.add_child(row)
for v: int in _seat_dice(st, i, my_seat):
row.add_child(DieIcon.new(v, 22.0))
row.add_child(DieIcon.new(v, 21.0))
for c in bid_row.get_children():
c.queue_free()
_clear(bid_row)
if st.bid.is_empty():
bid_row.add_child(_label("Noch kein Gebot", 26, ACCENT))
bid_name.text = "Kein Gebot"
bid_name.add_theme_color_override("font_color", Color(1, 1, 1, 0.5))
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))
bid_name.text = String(st.seats[st.bid.seat].name)
bid_name.add_theme_color_override("font_color", ACCENT)
var px := _fit_dice(int(st.bid.count))
# Beim Aufdecken zeigen die Farben, wie viele der angesagten Würfel es
# wirklich gab: grün die vorhandenen, rot die fehlenden.
var faerben: bool = st.phase == "reveal"
for i in int(st.bid.count):
var bg := Color.TRANSPARENT
if faerben:
bg = GRUEN if i < _actual else ROT
bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg))
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", ""))
_clear(boosts_box)
var boosts: Array = st.get("boosts", [])
var any := false
for i in mini(boosts.size(), st.seats.size()):
if String(boosts[i]) == "":
continue
any = true
var b: Dictionary = BoosterDB.get_booster(String(boosts[i]))
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
boosts_box.add_child(line)
line.add_child(_label(_short(String(st.seats[i].name)) + ":", 14,
ACCENT if i == my_seat else Color(0.78, 0.80, 0.86)))
var kind := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
line.add_child(_label(String(b.get("name", "")), 14, kind))
if int(st.min_raise) > 1:
hints.append("Störfeuer: du musst um mind. %d erhöhen" % st.min_raise)
boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT))
any = true
if st.must_bid:
hints.append("Maulkorb: du musst bieten")
hint_label.text = " | ".join(hints)
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
any = true
if not any:
boosts_box.add_child(_label("Keine Booster aktiv", 14, DIM))
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
var kam_dran := my_turn and not _my_turn
_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))
_count_max = maxi(1, int(st.total))
_min_raise = maxi(1, int(st.min_raise))
_bid_count = 0 if st.bid.is_empty() else int(st.bid.count)
_bid_face = 0 if st.bid.is_empty() else int(st.bid.face)
# Ein Maulkorb sperrt das Anfechten - ausser es gäbe gar kein gültiges
# Gebot mehr, dann bliebe sonst kein einziger Knopf übrig.
var gebot_moeglich: bool = _bid_count + _min_raise <= _count_max \
or (_min_raise == 1 and _bid_face < 6)
challenge_btn.disabled = not my_turn or st.bid.is_empty() \
or (st.must_bid and gebot_moeglich)
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 _bid_count > 0:
low = _bid_count + (_min_raise if _min_raise > 1 else 0)
_count_min = clampi(low, 1, _count_max)
_set_count(_count)
if kam_dran:
_lowest_bid()
if st.phase != "boost":
boost_slot.visible = false
@@ -423,6 +551,22 @@ func _process(_delta: float) -> void:
## Eigene Würfel sind bekannt, gegnerische nicht - ausser sie wurden gespäht
## oder es wird gerade aufgedeckt.
## Lange Namen kürzen, damit die Panels nicht in die Breite laufen.
func _short(name: String) -> String:
return name if name.length() <= 12 else name.substr(0, 11) + "…"
## Grösste Würfelgrösse, bei der `n` Würfel noch in die freie Mitte passen.
func _fit_dice(n: int) -> float:
const GAP := 4.0
for px: float in [34.0, 30.0, 26.0, 22.0, 18.0, 15.0, 12.0]:
var per_row := maxi(1, int((BID_BOX.x + GAP) / (px + GAP)))
var rows := ceili(float(n) / per_row)
if rows * (px + GAP) - GAP <= BID_BOX.y:
return px
return 12.0
func _seat_dice(st: Dictionary, seat: int, my_seat: int) -> Array:
if seat == my_seat:
return st.values
@@ -451,8 +595,7 @@ func show_booster_offer(ids: Array, values: Array) -> void:
_pending_boost = ""
target_row.visible = false
boost_title.text = "Wähle einen Booster"
for c in boost_cards.get_children():
c.queue_free()
_clear(boost_cards)
for id: String in ids:
var b: Dictionary = BoosterDB.get_booster(id)
var card := _panel(Color(0.10, 0.12, 0.17, 0.98))
@@ -482,10 +625,8 @@ func _pick(id: String) -> void:
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()
_clear(boost_cards)
_clear(target_row)
for i in _my_values.size():
var b := Button.new()
b.custom_minimum_size = Vector2(56, 56)

View File

@@ -24,6 +24,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _start_game(cfg: Dictionary) -> void:
if game != null:
return
if menu != null:
menu.queue_free()
menu = null
game = Node3D.new()

View File

@@ -6,12 +6,12 @@ const BOT_NAMES_MAX := 7
var screens: Dictionary = {}
# Einzelspieler
var solo_bots: SpinBox
var solo_bots: NumberRow
# Mehrspieler
var name_edit: LineEdit
var ip_edit: LineEdit
var multi_bots: SpinBox
var multi_bots: NumberRow
var lobby_list: Label
var status: Label
var host_btn: Button
@@ -94,9 +94,46 @@ func _button(text: String, parent: Control, action: Callable) -> Button:
return b
## Eine Reihe anklickbarer Zahlen - bei so wenigen Werten schneller als eine
## Spinbox mit ihren winzigen Pfeilen.
class NumberRow extends HBoxContainer:
var value := 0
var _buttons: Array[Button] = []
var _lo := 0
func _init(lo: int, hi: int, start: int) -> void:
_lo = lo
alignment = BoxContainer.ALIGNMENT_CENTER
add_theme_constant_override("separation", 6)
for n in range(lo, hi + 1):
var b := Button.new()
b.text = str(n)
b.toggle_mode = true
b.custom_minimum_size = Vector2(40, 38)
b.pressed.connect(pick.bind(n))
add_child(b)
_buttons.append(b)
pick(start)
func pick(n: int) -> void:
value = n
for i in _buttons.size():
_buttons[i].button_pressed = _lo + i == n
## Werte oberhalb von `hi` sperren (im Mehrspieler sind die Plätze begrenzt).
func set_limit(hi: int) -> void:
for i in _buttons.size():
_buttons[i].disabled = _lo + i > hi
if value > hi:
pick(maxi(_lo, hi))
func set_enabled(on: bool) -> void:
for b in _buttons:
b.disabled = not on
func _title(box: VBoxContainer) -> void:
box.add_child(_text("LIAR'S DICE 3D", 40))
box.add_child(_text("Würfeln - Bluffen - Boostern", 16, Color(0.6, 0.63, 0.7)))
box.add_child(HSeparator.new())
@@ -113,12 +150,9 @@ 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(_text("Bots", 15))
solo_bots = NumberRow.new(1, BOT_NAMES_MAX, 3)
box.add_child(solo_bots)
box.add_child(HSeparator.new())
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
@@ -142,11 +176,9 @@ func _build_multi() -> Control:
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))
box.add_child(_text("Bots (nur Host)", 15))
multi_bots = NumberRow.new(0, BOT_NAMES_MAX, 1)
box.add_child(multi_bots)
var brow := HBoxContainer.new()
brow.add_theme_constant_override("separation", 8)
@@ -174,18 +206,26 @@ func _build_multi() -> Control:
# ------------------------------------------------------------ Aktionen
func _on_solo_start() -> void:
Net.start_singleplayer("Spieler", int(solo_bots.value))
Net.start_singleplayer("Spieler", solo_bots.value)
## Ein leeres Namensfeld ergäbe einen namenlosen Platz am Tisch.
func _spielername() -> String:
var n := name_edit.text.strip_edges()
return n if n != "" else "Spieler"
func _on_host() -> void:
status.text = ""
if Net.host_game(name_edit.text.strip_edges()):
if Net.host_game(_spielername()):
status.text = "Server läuft auf Port %d. Warte auf Mitspieler." % Net.PORT
_refresh()
func _on_join() -> void:
status.text = "Verbinde..."
Net.join_game(ip_edit.text.strip_edges(), name_edit.text.strip_edges())
Net.join_game(ip_edit.text.strip_edges(), _spielername())
_refresh()
func _on_leave() -> void:
@@ -202,7 +242,7 @@ func _on_back() -> void:
func _on_start() -> void:
var humans: int = Net.lobby.size()
var ai: int = mini(int(multi_bots.value), Net.MAX_HUMANS - humans)
var ai: int = mini(multi_bots.value, Net.MAX_HUMANS - humans)
if humans + ai < 2:
status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)."
return
@@ -227,7 +267,11 @@ func _refresh() -> void:
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 not connected or not Net.is_host:
multi_bots.set_enabled(not connected)
else:
multi_bots.set_enabled(true)
multi_bots.set_limit(Net.MAX_HUMANS - Net.lobby.size())
if Net.lobby.is_empty():
lobby_list.text = "- nicht verbunden -"
else:

View File

@@ -3,14 +3,14 @@ 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 ZONE_RAD := 0.68 ## Radius der runden Würfelzone
const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylinder
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 SEAT_ARC := ZONE_RAD * 2.0 + 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
const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste
## Tisch und Zonenradius wachsen mit der Spielerzahl.
var table_r := 2.0
@@ -23,6 +23,10 @@ var zones: Array[Node3D] = []
var cups: Array[Node3D] = []
var labels: Array[Label3D] = []
var cup_tweens: Array = []
var bid_counts: Array[Label3D] = []
var bid_icons: Array[Sprite3D] = []
var _bid_anchor: Array[Vector3] = []
var plates: Array[Sprite3D] = []
var dice: Array = [] ## seat -> Array[Die]
var camera: Camera3D
@@ -32,7 +36,7 @@ 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
table_r = zone_r + ZONE_RAD + 0.45
_build_room()
seat_dir.clear()
for i in count:
@@ -131,40 +135,46 @@ func _build_seat(i: int) -> void:
add_child(zone)
zones.append(zone)
# Runde Bande aus Segmenten - ein hohler Zylinder ist als Kollisionsform
# nicht vorgesehen. Nur Kollision, kein Mesh: der sichtbare Rand ist ein Torus.
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 seg_w := TAU * ZONE_RAD / WALL_SEGS * 1.25 # Überlappung, damit keine Lücke bleibt
for k in WALL_SEGS:
var ang := TAU * k / WALL_SEGS
var c := CollisionShape3D.new()
var b := BoxShape3D.new()
b.size = s[1]
b.size = Vector3(0.12, WALL_H, seg_w)
c.shape = b
c.position = s[0]
c.position = Vector3(cos(ang), 0.0, sin(ang)) * (ZONE_RAD + 0.06) \
+ Vector3(0, WALL_H * 0.5, 0)
c.rotation.y = -ang
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)
var fm := CylinderMesh.new()
fm.top_radius = ZONE_RAD
fm.bottom_radius = ZONE_RAD
fm.height = 0.012
fm.radial_segments = 32
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)
# Sichtbarer Rand
var rim := MeshInstance3D.new()
var tm := TorusMesh.new()
tm.inner_radius = ZONE_RAD
tm.outer_radius = ZONE_RAD + 0.07
tm.rings = 32
tm.ring_segments = 8
rim.mesh = tm
rim.position = Vector3(0, 0.02, 0)
rim.material_override = _mat(Color(0.22, 0.15, 0.09), 0.6)
zone.add_child(rim)
# Becher
var cup := Node3D.new()
cup.position = Vector3(0, 0.02, 0)
@@ -196,6 +206,42 @@ func _build_seat(i: int) -> void:
add_child(lbl)
labels.append(lbl)
# Unterlage hinter Name und Ansage
_build_plate_texture()
var pl := Sprite3D.new()
pl.texture = _plate_tex
pl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
pl.no_depth_test = true
pl.pixel_size = 1.0 / 128.0 # Grundgrösse 1 x 1 Welteinheit
pl.render_priority = -1 # immer hinter Schrift und Würfelbild
pl.visible = i != local_seat
add_child(pl)
plates.append(pl)
# Ansagezeile darunter: Anzahl als Text, Augenzahl als Würfelbild
_build_face_textures()
var unten := lbl.position + Vector3(0, -0.001, 0)
var cnt := Label3D.new()
cnt.billboard = BaseMaterial3D.BILLBOARD_ENABLED
cnt.font_size = 64
cnt.pixel_size = 0.0019
cnt.outline_size = 12
cnt.no_depth_test = true
cnt.modulate = Color(1.0, 0.88, 0.52)
cnt.visible = false
add_child(cnt)
bid_counts.append(cnt)
var ic := Sprite3D.new()
ic.billboard = BaseMaterial3D.BILLBOARD_ENABLED
ic.no_depth_test = true
ic.pixel_size = 0.0016
ic.texture = _face_tex[0]
ic.visible = false
add_child(ic)
bid_icons.append(ic)
_bid_anchor.append(unten)
## Fast senkrechte Aufsicht mit leichter Neigung, damit die Würfel noch
## plastisch wirken. Der eigene Platz liegt unten im Bild.
@@ -229,6 +275,113 @@ func _frame_camera() -> void:
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)
_place_bid_lines()
## Setzt Name, Ansage und Unterlage eines Platzes. Alles wird entlang der
## Bildachsen ausgerichtet, sonst stünde bei der Aufsicht eines hinter dem
## anderen statt darunter.
func _layout_seat(i: int) -> void:
if camera == null or i >= plates.size():
return
var rechts: Vector3 = camera.global_transform.basis.x
var hoch: Vector3 = camera.global_transform.basis.y
var p: Vector3 = _bid_anchor[i]
var zeigt_ansage: bool = bid_icons[i].visible
var name_w := _label_width(labels[i])
labels[i].position = p + hoch * (0.10 if zeigt_ansage else 0.0)
var icon_w: float = 96.0 * bid_icons[i].pixel_size
var cnt_w := _label_width(bid_counts[i])
var zeile_w := cnt_w + 0.03 + icon_w
var mitte: Vector3 = p - hoch * 0.10
bid_counts[i].position = mitte - rechts * (zeile_w - cnt_w) * 0.5
bid_icons[i].position = mitte + rechts * (zeile_w - icon_w) * 0.5
var breite := maxf(name_w, zeile_w if zeigt_ansage else 0.0) + 0.14
var hoehe := 0.40 if zeigt_ansage else 0.22
plates[i].scale = Vector3(breite, hoehe, 1.0)
plates[i].position = p
plates[i].visible = labels[i].visible or zeigt_ansage
func _label_width(l: Label3D) -> float:
if l.text == "":
return 0.0
var f := ThemeDB.fallback_font
return f.get_string_size(l.text, HORIZONTAL_ALIGNMENT_LEFT, -1, l.font_size).x * l.pixel_size
func _place_bid_lines() -> void:
for i in plates.size():
_layout_seat(i)
## Würfelbilder für die Ansagen am Tisch. Einmal gezeichnet, von allen genutzt.
static var _face_tex: Array[ImageTexture] = []
static var _plate_tex: ImageTexture
## Fast deckende, abgerundete Unterlage. Halbtransparent ginge sie auf dem
## dunkelroten Becher unter.
static func _build_plate_texture() -> void:
if _plate_tex != null:
return
const S := 128
var img := Image.create(S, S, false, Image.FORMAT_RGBA8)
img.fill(Color(0, 0, 0, 0))
var r := S * 0.22
for y in S:
for x in S:
var dx := maxf(maxf(r - x, x - (S - r)), 0.0)
var dy := maxf(maxf(r - y, y - (S - r)), 0.0)
if Vector2(dx, dy).length() <= r:
img.set_pixel(x, y, Color(0.04, 0.05, 0.07, 0.90))
_plate_tex = ImageTexture.create_from_image(img)
static func _build_face_textures() -> void:
if not _face_tex.is_empty():
return
const S := 192 # doppelt gezeichnet und verkleinert = weiche Kanten
const OUT := 96
for v in range(1, 7):
var img := Image.create(S, S, false, Image.FORMAT_RGBA8)
img.fill(Color(0, 0, 0, 0))
var r := S * 0.18
for y in S:
for x in S:
var dx := maxf(maxf(r - x, x - (S - r)), 0.0)
var dy := maxf(maxf(r - y, y - (S - r)), 0.0)
if Vector2(dx, dy).length() <= r:
img.set_pixel(x, y, Color(0.95, 0.94, 0.90))
var mid := S * 0.5
var spread := S * 0.24
var pr := S * 0.105
for p: Vector2 in Die.PIPS[v]:
var cx := mid + p.x * spread
var cy := mid + p.y * spread
for y in S:
for x in S:
if Vector2(x + 0.5 - cx, y + 0.5 - cy).length() <= pr:
img.set_pixel(x, y, Color(0.09, 0.08, 0.1))
img.resize(OUT, OUT, Image.INTERPOLATE_LANCZOS)
_face_tex.append(ImageTexture.create_from_image(img))
## Zeigt unter dem Namen, was der Platz zuletzt angesagt hat (0 = nichts).
func set_bid(seat: int, count: int, face: int) -> void:
if seat >= bid_counts.size():
return
# Am eigenen Platz steht kein Name, dort läge die Zeile in den Würfeln.
var zeige := seat != local_seat and count > 0 and face >= 1 and face <= 6
bid_counts[seat].visible = zeige
bid_icons[seat].visible = zeige
if zeige:
bid_counts[seat].text = "%d ×" % count
bid_icons[seat].texture = _face_tex[face - 1]
_layout_seat(seat)
func _mat(col: Color, rough: float) -> StandardMaterial3D:
@@ -262,12 +415,13 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
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
# gleichmässig auf einem Kreis verteilt, damit nichts übereinander fällt
var ang := TAU * i / maxi(n, 1) + rng.randf_range(-0.15, 0.15)
var rad := ZONE_RAD * rng.randf_range(0.30, 0.46)
var off := Vector3(
rng.randf_range(-ZONE_D * 0.14, ZONE_D * 0.14),
cos(ang) * rad,
0.48 + rng.randf_range(0.0, 0.10),
lane + rng.randf_range(-0.03, 0.03)
sin(ang) * rad
)
arr[i].throw_from(zone.global_transform * off, rng)
@@ -276,12 +430,18 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
func _layout_idle(seat: int) -> void:
var zone: Node3D = zones[seat]
var arr: Array = dice[seat]
# Bogen auf einem Kreis um die Zonenmitte - passt zur runden Bande.
# Der Schrittwinkel folgt aus dem gewünschten Abstand: Diagonale eines
# Würfels plus Luft, sonst überlappen die Bilder beim Aufdecken.
const BOGEN_R := 0.48 # etwas weiter aussen, damit das Namensschild frei bleibt
var schritt: float = (Die.SIZE * 1.7) / BOGEN_R
for i in arr.size():
var x := (float(i) - (arr.size() - 1) * 0.5) * 0.24
var ang := PI + (float(i) - (arr.size() - 1) * 0.5) * schritt
var t := zone.global_transform
t.origin = zone.global_transform * Vector3(0.0, Die.HALF + 0.01, x)
t.origin = zone.global_transform * Vector3(
cos(ang) * BOGEN_R, Die.HALF + 0.01, sin(ang) * BOGEN_R)
arr[i].set_kinematic(true)
arr[i].global_transform = t
arr[i].place(t)
func arrange_and_show(seat: int, values: Array, rng: RandomNumberGenerator) -> void:
@@ -321,3 +481,4 @@ func set_cup_down(seat: int, down: bool) -> void:
func set_label(seat: int, text: String, col: Color) -> void:
labels[seat].text = text
labels[seat].modulate = col
_layout_seat(seat)