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 wirklich an, im Mehrspieler läuft die Runde weiter – sonst würde ein geöffnetes
Menü alle anderen am Tisch blockieren. Menü alle anderen am Tisch blockieren.
Die Bildaufteilung: Tisch mittig, aktuelles Gebot oben mittig, Würfel aller Die Bildaufteilung: Tisch mittig, darauf die letzte Wette als so viele Würfel
Spieler oben rechts, Verlauf unten links, Ansage unten rechts. 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 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 stehen deshalb deine eigenen Würfel offen, gegnerische verdeckt – bis auf die
per *Spähen* aufgedeckten und beim Aufdecken am Rundenende. 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 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 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 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 * **`renderer/rendering_method="mobile"`** – auf integrierten GPUs kostet
Forward+ hier mehr als das Doppelte: 22 statt 47 fps bei 30 Würfeln. 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 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. 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 raise_.is_empty():
if must_bid: if must_bid:
# Notgebot: minimal erhöhen, auch wenn unwahrscheinlich # Notgebot: minimal erhöhen, auch wenn unwahrscheinlich
var c: int = bid.count + maxi(1, min_raise) var c: int = int(bid.count) + maxi(1, min_raise)
if c > total_dice: 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": "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": "challenge"}
return {"type": "bid", "count": raise_.count, "face": raise_.face} 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 var t := global_transform
t.basis = Basis(wb, wa, wc) * Basis(b, a, c).transposed() t.basis = Basis(wb, wa, wc) * Basis(b, a, c).transposed()
global_transform = t place(t)
if not freeze: if not freeze:
sleeping = false sleeping = false
linear_velocity = Vector3.ZERO linear_velocity = Vector3.ZERO
angular_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: func set_kinematic(on: bool) -> void:
freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = on freeze = on

View File

@@ -4,6 +4,7 @@ extends Node3D
const START_DICE := 5 const START_DICE := 5
const BOOST_TIMEOUT := 30.0 const BOOST_TIMEOUT := 30.0
const BID_TIMEOUT := 30.0 const BID_TIMEOUT := 30.0
const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses
var table: GameTable var table: GameTable
var hud: CanvasLayer var hud: CanvasLayer
@@ -18,11 +19,13 @@ var my_values: Array = [] ## Client-Sicht (-1 = vernebelt)
var bid: Dictionary = {} var bid: Dictionary = {}
var turn := 0 var turn := 0
var starter := 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 peeked: Dictionary = {} ## viewer -> {target_seat: [idx, value]}
var min_raise: Dictionary = {} ## seat -> int var min_raise: Dictionary = {} ## seat -> int
var must_bid: Dictionary = {} ## seat -> bool var must_bid: Dictionary = {} ## seat -> bool
var chosen_boost: Dictionary = {} 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 log_lines: Array = []
var phase := "lobby" var phase := "lobby"
var banner := "" var banner := ""
@@ -33,6 +36,8 @@ var _stream_acc := 0.0
var _last_phase := "" var _last_phase := ""
var deadline := 0.0 ## Sekunden-Zeitstempel, 0 = keine Frist var deadline := 0.0 ## Sekunden-Zeitstempel, 0 = keine Frist
var _pause_left := 0.0 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: func _ready() -> void:
@@ -108,17 +113,25 @@ func _refresh_labels() -> void:
func _host_loop() -> void: func _host_loop() -> void:
_log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE]) _log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE])
starter = 0 starter = 0
while _alive_count() > 1: while _alive() and _alive_count() > 1:
await _phase_roll() 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() await _phase_boost()
if not _alive():
return
var result: Dictionary = await _phase_bid() var result: Dictionary = await _phase_bid()
if not _alive():
return
await _phase_reveal(result) await _phase_reveal(result)
phase = "over" phase = "over"
var winner := "" var winner := ""
for s in seats: for s in seats:
if s.alive: if s.alive:
winner = s.name winner = s.name
banner = "%s gewinnt!" % winner banner = "%s gewinnt" % winner
_log(banner) _log(banner)
_push_state() _push_state()
@@ -156,6 +169,18 @@ func _total_dice() -> int:
return n 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: func _now() -> float:
return Time.get_ticks_msec() / 1000.0 return Time.get_ticks_msec() / 1000.0
@@ -163,8 +188,10 @@ func _now() -> float:
func _phase_roll() -> void: func _phase_roll() -> void:
phase = "roll" phase = "roll"
deadline = 0.0 deadline = 0.0
reveal_total = 0
bid = {} bid = {}
fogged.clear() fogged.clear()
last_bids.clear()
peeked.clear() peeked.clear()
min_raise.clear() min_raise.clear()
must_bid.clear() must_bid.clear()
@@ -183,6 +210,8 @@ func _phase_roll() -> void:
table.throw_dice(i, rng) table.throw_dice(i, rng)
await _await_settled(_alive_seats()) await _await_settled(_alive_seats())
_stream = false _stream = false
if not _alive():
return
for i in _alive_seats(): for i in _alive_seats():
host_values[i] = [] 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 step := 1.0 / float(Engine.physics_ticks_per_second)
var t := 0.0 var t := 0.0
while t < timeout: while t < timeout:
if not _alive():
return
await get_tree().physics_frame await get_tree().physics_frame
if not _alive():
return
if get_tree().paused:
continue
t += step t += step
if t < 0.7: if t < 0.7:
continue continue
@@ -218,7 +253,7 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
func _phase_boost() -> void: func _phase_boost() -> void:
phase = "boost" phase = "boost"
_answered.clear() _answered.clear()
var offers := {} offers.clear()
for i in _alive_seats(): for i in _alive_seats():
offers[i] = BoosterDB.draw_offer(rng) offers[i] = BoosterDB.draw_offer(rng)
if seats[i].ai: if seats[i].ai:
@@ -231,12 +266,16 @@ func _phase_boost() -> void:
_push_state() _push_state()
await _wait_until(_all_boosts_in, BOOST_TIMEOUT) await _wait_until(_all_boosts_in, BOOST_TIMEOUT)
if not _alive():
return
for i in _alive_seats(): for i in _alive_seats():
if not chosen_boost.has(i): if not chosen_boost.has(i):
var fallback: String = offers[i][rng.randi_range(0, offers[i].size() - 1)] var fallback: String = offers[i][rng.randi_range(0, offers[i].size() - 1)]
chosen_boost[i] = {"id": fallback, "target": 0} chosen_boost[i] = {"id": fallback, "target": 0}
await _apply_boosters() await _apply_boosters()
if not _alive():
return
_snapshot_all() _snapshot_all()
_push_state() _push_state()
@@ -290,6 +329,8 @@ func _apply_boosters() -> void:
_throw_subset(seat, rerolls[seat]) _throw_subset(seat, rerolls[seat])
await _await_settled(rerolls.keys()) await _await_settled(rerolls.keys())
_stream = false _stream = false
if not _alive():
return
for seat: int in rerolls: for seat: int in rerolls:
var dl: Array = table.dice_of(seat) var dl: Array = table.dice_of(seat)
for k: int in rerolls[seat]: for k: int in rerolls[seat]:
@@ -327,7 +368,11 @@ func _apply_boosters() -> void:
"fog": "fog":
for o in order: for o in order:
if o != seat and host_values[o].size() > 0: 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: func _merge(a: Array, b: Array) -> Array:
@@ -343,7 +388,7 @@ func _set_die(seat: int, idx: int, value: int) -> void:
return return
host_values[seat][idx] = value host_values[seat][idx] = value
var dl: Array = table.dice_of(seat) 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) dl[idx].snap_to_value(value, rng)
@@ -353,14 +398,16 @@ func _throw_subset(seat: int, idxs: Array) -> void:
for k: int in idxs: for k: int in idxs:
if k >= dl.size(): if k >= dl.size():
continue 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) dl[k].throw_from(zone.global_transform * off, rng)
func _phase_bid() -> Dictionary: func _phase_bid() -> Dictionary:
phase = "bid" phase = "bid"
turn = starter if seats[starter].alive else _next_alive(starter) turn = starter if seats[starter].alive else _next_alive(starter)
while true: while _alive():
_answered.clear() _answered.clear()
deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT
_push_state() _push_state()
@@ -392,16 +439,38 @@ func _phase_bid() -> Dictionary:
## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis. ## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
func _apply_action(seat: int, a: Dictionary) -> Dictionary: func _apply_action(seat: int, a: Dictionary) -> Dictionary:
if a.type == "bid" and not _valid_bid(seat, int(a.count), int(a.face)): 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": if a.type == "challenge":
_log("%s fechtet an!" % seats[seat].name) _log("%s fechtet an!" % seats[seat].name)
return {"challenger": seat, "bid": bid.duplicate()} return {"challenger": seat, "bid": bid.duplicate()}
bid = {"seat": seat, "count": int(a.count), "face": int(a.face)} bid = {"seat": seat, "count": int(a.count), "face": int(a.face)}
last_bids[seat] = {"count": bid.count, "face": bid.face}
must_bid.erase(seat) must_bid.erase(seat)
_log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face]) _log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face])
return {} 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: func _valid_bid(seat: int, count: int, face: int) -> bool:
if face < 1 or face > 6 or count < 1 or count > _total_dice(): if face < 1 or face > 6 or count < 1 or count > _total_dice():
return false return false
@@ -426,18 +495,26 @@ func _phase_reveal(result: Dictionary) -> void:
if v == b.face: if v == b.face:
total += 1 total += 1
reveal_total = total
var loser := challenger if total >= b.count else int(b.seat) var loser := challenger if total >= b.count else int(b.seat)
banner = "%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [ _log("%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [
b.count, b.face, total, seats[loser].name] b.count, b.face, total, seats[loser].name])
_log(banner) banner = "%s verliert" % seats[loser].name
for i in _alive_seats(): for i in _alive_seats():
table.arrange_and_show(i, host_values[i], rng) table.arrange_and_show(i, host_values[i], rng)
table.set_cup_down(i, false) table.set_cup_down(i, false)
if not multiplayer.get_peers().is_empty(): # Gezielt an die Mitspieler am Tisch: ein Peer, der nur in der Lobby sitzt,
_reveal.rpc(_all_values(), banner) # 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() _push_state()
await _sleep(4.5) await _sleep(REVEAL_PAUSE)
if not _alive():
return
seats[loser].count -= 1 seats[loser].count -= 1
if seats[loser].count <= 0: if seats[loser].count <= 0:
@@ -461,18 +538,28 @@ func _all_values() -> Array:
return out return out
## process_always = false: eine offene Pause hält den Timer wirklich an.
func _sleep(sec: float) -> void: 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: func _wait_until(cond: Callable, timeout: float) -> bool:
var t := 0.0 var t := 0.0
while t < timeout: while t < timeout:
if not _alive():
return false
if cond.call(): if cond.call():
return true return true
await get_tree().process_frame 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() t += get_process_delta_time()
return cond.call() return _alive() and cond.call()
func _log(line: String) -> void: func _log(line: String) -> void:
@@ -484,7 +571,7 @@ func _log(line: String) -> void:
# ---------------------------------------------------------------- Sync # ---------------------------------------------------------------- Sync
func _physics_process(delta: float) -> void: 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 return
_stream_acc += delta _stream_acc += delta
if _stream_acc < 1.0 / 30.0: if _stream_acc < 1.0 / 30.0:
@@ -492,7 +579,7 @@ func _physics_process(delta: float) -> void:
_stream_acc = 0.0 _stream_acc = 0.0
for i in seats.size(): for i in seats.size():
var peer: int = seats[i].peer var peer: int = seats[i].peer
if peer <= 1: if not _peer_live(peer):
continue continue
_dice_stream.rpc_id(peer, _pack(i)) _dice_stream.rpc_id(peer, _pack(i))
@@ -500,7 +587,7 @@ func _physics_process(delta: float) -> void:
func _snapshot_all() -> void: func _snapshot_all() -> void:
for i in seats.size(): for i in seats.size():
var peer: int = seats[i].peer var peer: int = seats[i].peer
if peer > 1: if _peer_live(peer):
_dice_snapshot.rpc_id(peer, _pack(i)) _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])), 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])) Vector3(data[o], data[o + 1], data[o + 2]))
dl[i].set_kinematic(true) dl[i].set_kinematic(true)
dl[i].global_transform = t dl[i].place(t)
@rpc("authority", "unreliable_ordered") @rpc("authority", "unreliable_ordered")
@@ -536,9 +623,11 @@ func _dice_snapshot(data: PackedFloat32Array) -> void:
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _reveal(all_values: Array, text: String) -> void: 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(): for i in seats.size():
if i == local_seat:
continue
if i < all_values.size() and not all_values[i].is_empty(): if i < all_values.size() and not all_values[i].is_empty():
table.arrange_and_show(i, all_values[i], rng) table.arrange_and_show(i, all_values[i], rng)
table.set_cup_down(i, false) table.set_cup_down(i, false)
@@ -547,14 +636,34 @@ func _reveal(all_values: Array, text: String) -> void:
func _push_state() -> void: func _push_state() -> void:
if not _alive():
return
_refresh_labels() _refresh_labels()
for i in seats.size(): for i in seats.size():
var peer: int = seats[i].peer var peer: int = seats[i].peer
var st := _state_for(i)
if peer == multiplayer.get_unique_id(): if peer == multiplayer.get_unique_id():
_apply_state(st) _apply_state(_state_for(i))
elif peer > 1: elif _peer_live(peer):
_state.rpc_id(peer, st) _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: func _state_for(seat: int) -> Dictionary:
@@ -565,18 +674,22 @@ func _state_for(seat: int) -> Dictionary:
elif phase == "reveal": elif phase == "reveal":
vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel
else: else:
var fg: Array = fogged.get(seat, [])
for k in host_values[seat].size(): 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 = [] var pub: Array = []
for s in seats: for s in seats:
pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai}) pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai})
return { return {
"phase": phase, "turn": turn, "bid": bid, "seats": pub, "values": vis, "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), "peek": peeked.get(seat, {}), "min_raise": min_raise.get(seat, 1),
"must_bid": must_bid.get(seat, false), "total": _total_dice(), "must_bid": must_bid.get(seat, false), "total": _total_dice(),
"time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()), "time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()),
"open": _all_values() if phase == "reveal" else [], "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", ""), "boost": chosen_boost.get(seat, {}).get("id", ""),
} }
@@ -608,6 +721,13 @@ func _apply_state(st: Dictionary) -> void:
table.set_dice_visible(local_seat, true) table.set_dice_visible(local_seat, true)
_refresh_labels() _refresh_labels()
_apply_fog_to_table() _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) hud.update_state(st, local_seat)
@@ -659,7 +779,13 @@ func _on_pause_changed(on: bool) -> void:
func _on_quit() -> void: func _on_quit() -> void:
if _quitting:
return
_quitting = true
get_tree().paused = false get_tree().paused = false
# Sofort ausblenden, damit das Verlassen unmittelbar wirkt.
table.visible = false
hud.visible = false
Net.leave() Net.leave()
get_tree().change_scene_to_file("res://scenes/main.tscn") 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: 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(): for i in seats.size():
if seats[i].peer == peer: if seats[i].peer == peer:
return i return i
@@ -697,15 +825,21 @@ func _do_bid(seat: int, count: int, face: int) -> void:
func _do_challenge(seat: int) -> void: func _do_challenge(seat: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat): if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
return 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 return
_answered[seat] = _apply_action(seat, {"type": "challenge"}) _answered[seat] = _apply_action(seat, {"type": "challenge"})
func _do_boost(seat: int, id: String, target: int) -> void: 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 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 return
chosen_boost[seat] = {"id": id, "target": target} chosen_boost[seat] = {"id": id, "target": target}
_answered[seat] = true _answered[seat] = true

View File

@@ -9,16 +9,31 @@ signal pause_changed(paused: bool)
const ACCENT := Color(1.0, 0.82, 0.32) const ACCENT := Color(1.0, 0.82, 0.32)
const DIM := Color(0.62, 0.64, 0.70) 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 root: Control
var players_box: VBoxContainer var players_box: VBoxContainer
var bid_row: HBoxContainer var bid_box: VBoxContainer
var hint_label: Label var bid_name: Label
var bid_row: HFlowContainer
var boosts_box: VBoxContainer
var banner_label: Label 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 face_buttons: Array[Button] = []
var bid_btn: Button var bid_btn: Button
var challenge_btn: Button var challenge_btn: Button
@@ -53,13 +68,16 @@ class DieIcon extends Control:
var value := 0 var value := 0
var _box := StyleBoxFlat.new() 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 value = v
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
if px > 0.0: if px > 0.0:
custom_minimum_size = Vector2(px, px) custom_minimum_size = Vector2(px, px)
# sonst zieht der Container das Symbol in die Höhe und es wird unrund # sonst zieht der Container das Symbol in die Höhe und es wird unrund
size_flags_vertical = Control.SIZE_SHRINK_CENTER 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.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.set_corner_radius_all(4)
_box.border_color = Color(0, 0, 0, 0.35) _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: func _build() -> void:
# --- Spielerliste oben rechts # --- Spielerliste oben rechts
var pl := _panel() 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.grow_horizontal = Control.GROW_DIRECTION_BEGIN
pl.custom_minimum_size = Vector2(PLAYERS_W, 0)
root.add_child(pl) root.add_child(pl)
players_box = VBoxContainer.new() 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) pl.add_child(players_box)
# --- Aktuelles Gebot oben mittig # --- Aktive Booster oben links, Breite richtet sich nach dem Inhalt
var bp := _panel() var bo := _panel()
_slot(0, 0, 1, 0, 300, 12, -300, 120).add_child(bp) _place(bo, 0, 0, 0, 0, 16, 16, 16, 16)
var bv := VBoxContainer.new() root.add_child(bo)
bp.add_child(bv) boosts_box = VBoxContainer.new()
bid_row = HBoxContainer.new() boosts_box.add_theme_constant_override("separation", 3)
bid_row.alignment = BoxContainer.ALIGNMENT_CENTER bo.add_child(boosts_box)
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 # --- Letzte Wette in der Tischmitte, als so viele Würfel wie angesagt
var banner_slot := _slot(0, 0, 1, 0, 140, 126, -140, 210) 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 = _label("", 28, ACCENT)
banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
banner_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
banner_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART banner_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
banner_label.custom_minimum_size = Vector2(1000, 0) banner_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
banner_slot.add_child(banner_label) var edge := 240.0
_place(banner_label, 0, 0, 1, 0, edge, 20, -edge, 150)
root.add_child(banner_label)
# --- Verlauf unten links # --- Ansage: kompakte Leiste unten in der Mitte
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() action_panel = _panel()
_place(action_panel, 1, 1, 1, 1, -296, -282, -16, -16) _slot(0, 1, 1, 1, 16, -110, -16, -16).add_child(action_panel)
action_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN var av := HBoxContainer.new()
root.add_child(action_panel) av.add_theme_constant_override("separation", 16)
var av := VBoxContainer.new()
av.add_theme_constant_override("separation", 6)
action_panel.add_child(av) action_panel.add_child(av)
var head := HBoxContainer.new() # Gegenstück zur Zeitanzeige am rechten Ende, damit die Leiste symmetrisch
av.add_child(head) # bleibt, auch wenn dort gerade keine Zeit läuft.
var head_title := _label("Deine Ansage", 18, ACCENT) var spacer := Control.new()
head_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.custom_minimum_size = Vector2(TIMER_W, 0)
head.add_child(head_title) spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
timer_bid = _label("", 18, DIM) av.add_child(spacer)
head.add_child(timer_bid)
var row := HBoxContainer.new() av.add_child(_label("Anzahl", 15))
av.add_child(row) minus_btn = Button.new()
var cl := _label("Anzahl", 15) minus_btn.text = "−"
cl.custom_minimum_size = Vector2(80, 0) minus_btn.custom_minimum_size = Vector2(42, 42)
row.add_child(cl) minus_btn.pressed.connect(_step_count.bind(-1))
count_spin = SpinBox.new() av.add_child(minus_btn)
count_spin.min_value = 1 count_label = _label("1", 22)
count_spin.max_value = 30 count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
count_spin.value = 1 count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
count_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL count_label.custom_minimum_size = Vector2(38, 0)
row.add_child(count_spin) 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() var frow := HBoxContainer.new()
frow.add_theme_constant_override("separation", 4) frow.add_theme_constant_override("separation", 4)
av.add_child(frow) 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): for f in range(1, 7):
var b := Button.new() var b := Button.new()
b.toggle_mode = true 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)) b.pressed.connect(_on_face.bind(f))
var ic := DieIcon.new(f) var ic := DieIcon.new(f)
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -230,16 +262,22 @@ func _build() -> void:
bid_btn = Button.new() bid_btn = Button.new()
bid_btn.text = "Ansagen" 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) bid_btn.pressed.connect(_on_bid)
av.add_child(bid_btn) av.add_child(bid_btn)
challenge_btn = Button.new() challenge_btn = Button.new()
challenge_btn.text = "Anfechten" 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) challenge_btn.pressed.connect(_on_challenge)
av.add_child(challenge_btn) 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 # --- Booster-Overlay
boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0) boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0)
boost_slot.visible = false boost_slot.visible = false
@@ -303,8 +341,58 @@ func _on_quit() -> void:
quit_pressed.emit() 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: func _on_bid() -> void:
bid_pressed.emit(int(count_spin.value), _face) bid_pressed.emit(_count, _face)
func _on_challenge() -> void: func _on_challenge() -> void:
@@ -326,6 +414,14 @@ func _set_paused(on: bool) -> void:
pause_changed.emit(on) 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: func _on_face(f: int) -> void:
_face = f _face = f
for i in face_buttons.size(): for i in face_buttons.size():
@@ -338,64 +434,96 @@ func update_state(st: Dictionary, my_seat: int) -> void:
_my_seat = my_seat _my_seat = my_seat
_my_values = st.values _my_values = st.values
_phase = st.phase _phase = st.phase
_actual = int(st.get("actual", 0))
var left := float(st.get("time_left", 0.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 _deadline = 0.0 if left <= 0.0 else Time.get_ticks_msec() / 1000.0 + left
for c in players_box.get_children(): _clear(players_box)
c.queue_free()
for i in st.seats.size(): for i in st.seats.size():
var s: Dictionary = st.seats[i] var s: Dictionary = st.seats[i]
var turn: bool = st.phase == "bid" and i == st.turn var turn: bool = st.phase == "bid" and i == st.turn
var col := ACCENT if turn else (Color.WHITE if s.alive else DIM) var col := ACCENT if turn else (Color.WHITE if s.alive else DIM)
var row := HBoxContainer.new() var entry := VBoxContainer.new()
row.add_theme_constant_override("separation", 4) entry.add_theme_constant_override("separation", 2)
players_box.add_child(row) players_box.add_child(entry)
var nm := _label(("▸ " if turn else " ") + s.name + (" (KI)" if s.ai else ""), 15, col) var nm := _label(("▸ " if turn else "") + _short(String(s.name)) + (" (KI)" if s.ai else ""), 14, 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.clip_text = true
nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(nm) entry.add_child(nm)
if not s.alive: if not s.alive:
row.add_child(_label("ausgeschieden", 14, DIM)) entry.add_child(_label(" ausgeschieden", 13, DIM))
continue 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): 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(): _clear(bid_row)
c.queue_free()
if st.bid.is_empty(): 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: else:
bid_row.add_child(_label("%s: %d ×" % [st.seats[st.bid.seat].name, st.bid.count], 26, ACCENT)) bid_name.text = String(st.seats[st.bid.seat].name)
bid_row.add_child(DieIcon.new(int(st.bid.face), 30.0)) 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 = [] _clear(boosts_box)
hints.append("Würfel im Spiel: %d" % st.total) var boosts: Array = st.get("boosts", [])
if st.boost != "": var any := false
hints.append("Dein Booster: %s" % BoosterDB.get_booster(st.boost).get("name", "")) 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: 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: if st.must_bid:
hints.append("Maulkorb: du musst bieten") boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
hint_label.text = " | ".join(hints) 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 banner_label.text = st.banner
var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive 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 _my_turn = my_turn
action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45) action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45)
bid_btn.disabled = not my_turn bid_btn.disabled = not my_turn
challenge_btn.disabled = not my_turn or st.bid.is_empty() or st.must_bid _count_max = maxi(1, int(st.total))
count_spin.max_value = 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 var low := 1
if not st.bid.is_empty(): if _bid_count > 0:
low = int(st.bid.count) + (int(st.min_raise) if int(st.min_raise) > 1 else 0) low = _bid_count + (_min_raise if _min_raise > 1 else 0)
count_spin.min_value = clampi(low, 1, int(count_spin.max_value)) _count_min = clampi(low, 1, _count_max)
if count_spin.value < count_spin.min_value: _set_count(_count)
count_spin.value = count_spin.min_value if kam_dran:
_lowest_bid()
if st.phase != "boost": if st.phase != "boost":
boost_slot.visible = false 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 ## Eigene Würfel sind bekannt, gegnerische nicht - ausser sie wurden gespäht
## oder es wird gerade aufgedeckt. ## 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: func _seat_dice(st: Dictionary, seat: int, my_seat: int) -> Array:
if seat == my_seat: if seat == my_seat:
return st.values return st.values
@@ -451,8 +595,7 @@ func show_booster_offer(ids: Array, values: Array) -> void:
_pending_boost = "" _pending_boost = ""
target_row.visible = false target_row.visible = false
boost_title.text = "Wähle einen Booster" boost_title.text = "Wähle einen Booster"
for c in boost_cards.get_children(): _clear(boost_cards)
c.queue_free()
for id: String in ids: for id: String in ids:
var b: Dictionary = BoosterDB.get_booster(id) var b: Dictionary = BoosterDB.get_booster(id)
var card := _panel(Color(0.10, 0.12, 0.17, 0.98)) var card := _panel(Color(0.10, 0.12, 0.17, 0.98))
@@ -482,10 +625,8 @@ func _pick(id: String) -> void:
return return
_pending_boost = id _pending_boost = id
boost_title.text = "%s - welchen Würfel?" % BoosterDB.get_booster(id).name boost_title.text = "%s - welchen Würfel?" % BoosterDB.get_booster(id).name
for c in boost_cards.get_children(): _clear(boost_cards)
c.queue_free() _clear(target_row)
for c in target_row.get_children():
c.queue_free()
for i in _my_values.size(): for i in _my_values.size():
var b := Button.new() var b := Button.new()
b.custom_minimum_size = Vector2(56, 56) 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: func _start_game(cfg: Dictionary) -> void:
if game != null: if game != null:
return return
if menu != null:
menu.queue_free() menu.queue_free()
menu = null menu = null
game = Node3D.new() game = Node3D.new()

View File

@@ -6,12 +6,12 @@ const BOT_NAMES_MAX := 7
var screens: Dictionary = {} var screens: Dictionary = {}
# Einzelspieler # Einzelspieler
var solo_bots: SpinBox var solo_bots: NumberRow
# Mehrspieler # Mehrspieler
var name_edit: LineEdit var name_edit: LineEdit
var ip_edit: LineEdit var ip_edit: LineEdit
var multi_bots: SpinBox var multi_bots: NumberRow
var lobby_list: Label var lobby_list: Label
var status: Label var status: Label
var host_btn: Button var host_btn: Button
@@ -94,9 +94,46 @@ func _button(text: String, parent: Control, action: Callable) -> Button:
return b 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: func _title(box: VBoxContainer) -> void:
box.add_child(_text("LIAR'S DICE 3D", 40)) 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()) box.add_child(HSeparator.new())
@@ -113,12 +150,9 @@ func _build_single() -> Control:
var box := _box(400) var box := _box(400)
_title(box) _title(box)
box.add_child(_text("Einzelspieler", 22, Color(1.0, 0.82, 0.32))) box.add_child(_text("Einzelspieler", 22, Color(1.0, 0.82, 0.32)))
solo_bots = SpinBox.new() box.add_child(_text("Bots", 15))
solo_bots.min_value = 1 solo_bots = NumberRow.new(1, BOT_NAMES_MAX, 3)
solo_bots.max_value = BOT_NAMES_MAX box.add_child(solo_bots)
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()) box.add_child(HSeparator.new())
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8) row.add_theme_constant_override("separation", 8)
@@ -142,11 +176,9 @@ func _build_multi() -> Control:
ip_edit.text = "127.0.0.1" ip_edit.text = "127.0.0.1"
box.add_child(_row("Server-IP", ip_edit)) box.add_child(_row("Server-IP", ip_edit))
multi_bots = SpinBox.new() box.add_child(_text("Bots (nur Host)", 15))
multi_bots.min_value = 0 multi_bots = NumberRow.new(0, BOT_NAMES_MAX, 1)
multi_bots.max_value = BOT_NAMES_MAX box.add_child(multi_bots)
multi_bots.value = 1
box.add_child(_row("Bots (nur Host)", multi_bots))
var brow := HBoxContainer.new() var brow := HBoxContainer.new()
brow.add_theme_constant_override("separation", 8) brow.add_theme_constant_override("separation", 8)
@@ -174,18 +206,26 @@ func _build_multi() -> Control:
# ------------------------------------------------------------ Aktionen # ------------------------------------------------------------ Aktionen
func _on_solo_start() -> void: 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: func _on_host() -> void:
status.text = "" 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 status.text = "Server läuft auf Port %d. Warte auf Mitspieler." % Net.PORT
_refresh()
func _on_join() -> void: func _on_join() -> void:
status.text = "Verbinde..." 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: func _on_leave() -> void:
@@ -202,7 +242,7 @@ func _on_back() -> void:
func _on_start() -> void: func _on_start() -> void:
var humans: int = Net.lobby.size() 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: if humans + ai < 2:
status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)." status.text = "Mindestens 2 Spieler nötig (Mitspieler oder Bots)."
return return
@@ -227,7 +267,11 @@ func _refresh() -> void:
join_btn.disabled = connected join_btn.disabled = connected
leave_btn.disabled = not connected leave_btn.disabled = not connected
start_btn.disabled = not (connected and Net.is_host) 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(): if Net.lobby.is_empty():
lobby_list.text = "- nicht verbunden -" lobby_list.text = "- nicht verbunden -"
else: else:

View File

@@ -3,14 +3,14 @@ class_name GameTable
## Baut den 3D-Tisch mit Würfelzonen, Bechern, Licht und Kamera. ## Baut den 3D-Tisch mit Würfelzonen, Bechern, Licht und Kamera.
const TABLE_H := 0.95 const TABLE_H := 0.95
const ZONE_W := 1.5 ## Breite der Würfelzone (tangential) const ZONE_RAD := 0.68 ## Radius der runden Würfelzone
const ZONE_D := 0.95 ## Tiefe der Würfelzone (radial) const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylinder
const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt 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 MIN_ZONE_R := 1.15
const TILT_DEG := 15.0 ## Neigung der Kamera gegen die Senkrechte const TILT_DEG := 15.0 ## Neigung der Kamera gegen die Senkrechte
const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige 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. ## Tisch und Zonenradius wachsen mit der Spielerzahl.
var table_r := 2.0 var table_r := 2.0
@@ -23,6 +23,10 @@ var zones: Array[Node3D] = []
var cups: Array[Node3D] = [] var cups: Array[Node3D] = []
var labels: Array[Label3D] = [] var labels: Array[Label3D] = []
var cup_tweens: Array = [] 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 dice: Array = [] ## seat -> Array[Die]
var camera: Camera3D var camera: Camera3D
@@ -32,7 +36,7 @@ func build(count: int, local_index: int) -> void:
seat_count = count seat_count = count
local_seat = local_index local_seat = local_index
zone_r = maxf(MIN_ZONE_R, count * SEAT_ARC / TAU) 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() _build_room()
seat_dir.clear() seat_dir.clear()
for i in count: for i in count:
@@ -131,40 +135,46 @@ func _build_seat(i: int) -> void:
add_child(zone) add_child(zone)
zones.append(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() var walls := StaticBody3D.new()
zone.add_child(walls) zone.add_child(walls)
# lokale x = radial (Tiefe), lokale z = tangential (Breite) var seg_w := TAU * ZONE_RAD / WALL_SEGS * 1.25 # Überlappung, damit keine Lücke bleibt
var specs := [ for k in WALL_SEGS:
[Vector3(ZONE_D * 0.5, WALL_H * 0.5, 0), Vector3(0.12, WALL_H, ZONE_W)], var ang := TAU * k / WALL_SEGS
[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 c := CollisionShape3D.new()
var b := BoxShape3D.new() var b := BoxShape3D.new()
b.size = s[1] b.size = Vector3(0.12, WALL_H, seg_w)
c.shape = b 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) 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 # Filzfläche
var felt := MeshInstance3D.new() var felt := MeshInstance3D.new()
var fm := BoxMesh.new() var fm := CylinderMesh.new()
fm.size = Vector3(ZONE_D, 0.01, ZONE_W) fm.top_radius = ZONE_RAD
fm.bottom_radius = ZONE_RAD
fm.height = 0.012
fm.radial_segments = 32
felt.mesh = fm felt.mesh = fm
felt.position = Vector3(0, 0.006, 0) felt.position = Vector3(0, 0.006, 0)
felt.material_override = _mat(Color(0.07, 0.20, 0.13), 0.95) felt.material_override = _mat(Color(0.07, 0.20, 0.13), 0.95)
zone.add_child(felt) 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 # Becher
var cup := Node3D.new() var cup := Node3D.new()
cup.position = Vector3(0, 0.02, 0) cup.position = Vector3(0, 0.02, 0)
@@ -196,6 +206,42 @@ func _build_seat(i: int) -> void:
add_child(lbl) add_child(lbl)
labels.append(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 ## Fast senkrechte Aufsicht mit leichter Neigung, damit die Würfel noch
## plastisch wirken. Der eigene Platz liegt unten im Bild. ## 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 target := dir * shift + Vector3(0, TABLE_H, 0)
var pos := target + (Vector3.UP * cos(tilt) + dir * sin(tilt)) * dist var pos := target + (Vector3.UP * cos(tilt) + dir * sin(tilt)) * dist
camera.look_at_from_position(pos, target, Vector3.UP) 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: 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 arr: Array = dice[seat]
var n := arr.size() var n := arr.size()
for i in n: for i in n:
# eigene Spur je Würfel, damit sie nicht übereinander fallen # gleichmässig auf einem Kreis verteilt, damit nichts übereinander fällt
var lane := ((float(i) + 0.5) / n * 2.0 - 1.0) * ZONE_W * 0.34 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( 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), 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) 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: func _layout_idle(seat: int) -> void:
var zone: Node3D = zones[seat] var zone: Node3D = zones[seat]
var arr: Array = dice[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(): 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 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].set_kinematic(true)
arr[i].global_transform = t arr[i].place(t)
func arrange_and_show(seat: int, values: Array, rng: RandomNumberGenerator) -> void: 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: func set_label(seat: int, text: String, col: Color) -> void:
labels[seat].text = text labels[seat].text = text
labels[seat].modulate = col labels[seat].modulate = col
_layout_seat(seat)