From 6478559252cd24b3e274522f3f2bbe7fe7a1a938 Mon Sep 17 00:00:00 2001 From: Team3 Date: Mon, 7 Sep 2026 04:29:23 +0200 Subject: [PATCH] update --- README.md | 26 +++- scripts/ai.gd | 10 +- scripts/die.gd | 12 +- scripts/game.gd | 200 ++++++++++++++++++++++----- scripts/hud.gd | 345 +++++++++++++++++++++++++++++++++-------------- scripts/main.gd | 5 +- scripts/menu.gd | 82 ++++++++--- scripts/table.gd | 225 ++++++++++++++++++++++++++----- 8 files changed, 708 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index 4a31d8f..a9572b2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/ai.gd b/scripts/ai.gd index 40de244..3bfd36b 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -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} - return {"type": "bid", "count": c, "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} diff --git a/scripts/die.gd b/scripts/die.gd index 07cce35..bdc2f93 100644 --- a/scripts/die.gd +++ b/scripts/die.gd @@ -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 diff --git a/scripts/game.gd b/scripts/game.gd index 7cdf3cf..5cbda2c 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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 - t += get_process_delta_time() - return cond.call() + 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 _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 diff --git a/scripts/hud.gd b/scripts/hud.gd index 095d2df..329507f 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -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,14 +68,17 @@ 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 - _box.bg_color = Color(0.93, 0.92, 0.88) if v > 0 else Color(0.26, 0.28, 0.34) + 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) _box.set_border_width_all(1) @@ -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) diff --git a/scripts/main.gd b/scripts/main.gd index e926cac..f293c4b 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -24,8 +24,9 @@ func _unhandled_key_input(event: InputEvent) -> void: func _start_game(cfg: Dictionary) -> void: if game != null: return - menu.queue_free() - menu = null + if menu != null: + menu.queue_free() + menu = null game = Node3D.new() game.set_script(preload("res://scripts/game.gd")) game.name = "Game" # fester Pfad, sonst findet das RPC-Routing den Knoten nicht diff --git a/scripts/menu.gd b/scripts/menu.gd index a5bd5f6..26ed1fa 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -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: diff --git a/scripts/table.gd b/scripts/table.gd index b0a1c0e..887c27d 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -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)