This commit is contained in:
team3
2026-09-16 15:04:02 +02:00
parent 3be22351a3
commit b9d6b115c0
23 changed files with 795 additions and 425 deletions

View File

@@ -55,10 +55,14 @@ static var stufe := 1
const STUFEN := [
{"bluff": 0.15, "genau": 0.35, "zweifel": 0.40, "sicher": 0.30},
{"bluff": 0.40, "genau": 0.22, "zweifel": 0.30, "sicher": 0.42},
{"bluff": 0.50, "genau": 0.18, "zweifel": 0.24, "sicher": 0.50},
# Gemessen: bei zweifel 0.24 / genau 0.18 dauerte eine Partie doppelt so
# lang wie auf normal - viele Züge je Runde, viele nullsummige Genau-Treffer.
{"bluff": 0.50, "genau": 0.26, "zweifel": 0.28, "sicher": 0.48},
]
## Wie oft er ein sicheres Gebot einen Schritt höher setzt, als nötig wäre.
const FRECH := 0.12
## Wie oft er eine gute Genau-Gelegenheit auch wirklich nutzt.
const GENAU_LUST := 0.35
static func _regler() -> Dictionary:
@@ -85,7 +89,10 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary,
if not must_bid:
# Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die
# Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu.
if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6:
# Aber nur ab und zu: Genau nimmt keinen Würfel vom Tisch. Ein Bot,
# der es bei jeder Gelegenheit ruft, zieht die Partie ins Endlose.
if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6 \
and rng.randf() < GENAU_LUST:
return {"type": "exact"}
var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07)
if hold < doubt and (raise_.is_empty() or raise_.p < 0.32):

104
scripts/aufdecken.gd Normal file
View File

@@ -0,0 +1,104 @@
extends RefCounted
## Das Aufdecken in drei Etappen: Becher hoch, zählen, Urteil. Dazu Genau
## und die Vergabe des Topfs. Arbeitet auf dem Spiel `g`.
var g: Node
func _init(spiel: Node) -> void:
g = spiel
func _phase_reveal(result: Dictionary) -> void:
g.phase = "reveal"
g.deadline = 0.0
var b: Dictionary = result.bid
var rufer: int = result.challenger
var genau: bool = result.get("exact", false)
var total: int = g._count_face(int(b.face))
# 1. Becher hoch. Noch kein Urteil - erst schauen.
g.reveal_total = -1
g.banner = "Genau?" if genau else "Aufdecken"
for i in g._alive_seats():
g.table.arrange_and_show(i, g.host_values[i], g.rng)
g.table.set_cup_down(i, false)
# 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 g.seats.size():
var peer: int = g.seats[i].peer
if g._peer_live(peer):
g._reveal.rpc_id(peer, values, g.banner)
g._push_state()
await g._sleep(g.ZEIT_BECHER)
if not g._alive():
return
# 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein.
g.reveal_total = total
g.banner = "%d gefunden, %d angesagt" % [total, b.count]
g._push_state()
await g._sleep(g.ZEIT_ZAEHLEN)
if not g._alive():
return
# 3. Urteil. Bei "Genau" trifft es alle anderen - oder nur den Rufer.
var verlierer: Array = []
var winner: int
var bonus := -1
if genau and total == int(b.count):
# Fester Schwung von zwei, egal wie viele am Tisch sitzen: der Ansager
# verliert einen, der Rufer bekommt einen. Mehr als eine Anfechtung
# bringt, weniger als "alle anderen" - das kippte bei vielen Spielern.
verlierer = [int(b.seat)]
winner = rufer
bonus = rufer
g.banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
g.seats[int(b.seat)].name, g.seats[rufer].name]
elif genau:
verlierer = [rufer]
winner = int(b.seat)
g.banner = "Daneben - %s verliert" % g.seats[rufer].name
else:
var loser := rufer if total >= int(b.count) else int(b.seat)
verlierer = [loser]
winner = int(b.seat) if loser == rufer else rufer
g.banner = "%s verliert" % g.seats[loser].name
g._log("%d x %d angesagt - tatsächlich %d. %s" % [b.count, b.face, total, g.banner])
g._topf_an(winner)
g._push_state()
await g._sleep(g.ZEIT_URTEIL)
if not g._alive():
return
for l: int in verlierer:
g.seats[l].count -= 1
if g.seats[l].count <= 0:
g.seats[l].alive = false
g.seats[l].count = 0
g.host_values[l] = []
g.table.set_dice_count(l, 0)
g._log("%s scheidet aus." % g.seats[l].name)
else:
g._zieh_muenze(l)
if bonus >= 0 and g.seats[bonus].alive:
g.seats[bonus].count += 1
g.host_values[bonus].append(g.rng.randi_range(1, 6))
g.table.set_dice_count(bonus, g.seats[bonus].count)
# Nach einem Treffer beginnt der Rufer, sonst der Verlierer.
var naechster: int = winner if genau and total == int(b.count) else verlierer[0]
g.starter = naechster if g.seats[naechster].alive else g._next_alive(naechster)
# Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel.
for i in g.seats.size():
g.table.set_dice_visible(i, false)
g.table.set_cup_down(i, g.seats[i].alive)
g.banner = ""
g._push_state()
func _all_values() -> Array:
var out: Array = []
for i in g.seats.size():
out.append(g.host_values[i] if g.seats[i].alive else [])
return out

View File

@@ -13,6 +13,11 @@ const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist,
var table: GameTable
var hud: CanvasLayer
## Alle Wartezeiten des Ablaufs (Bot-Pausen, Aufdecken, Münzwurf) laufen
## durch `_sleep` und lassen sich hiermit stauchen. Im Spiel 1.0; die Tests
## setzen 0, sonst dauert eine Bot-Partie Minuten statt Sekunden. Kein
## class_name dafür: den Klassencache baut nur der Editor, headless fehlt er.
var tempo := 1.0
var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}]
@@ -64,13 +69,14 @@ func setup(cfg: Dictionary) -> void:
seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai),
"count": int(s.count), "alive": bool(s.alive), "verwaist": false})
else:
var dice := int(cfg.get("dice", START_DICE)) # Tests spielen mit weniger
var peers: Array = cfg.peers
for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": START_DICE, "alive": true, "verwaist": false})
"count": dice, "alive": true, "verwaist": false})
for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_DICE, "alive": true, "verwaist": false})
"count": dice, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
var me := multiplayer.get_unique_id()
@@ -299,6 +305,10 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
continue
var ok := true
for s in list:
# Verirrte Würfel zurückholen, sonst wartet man hier vergebens.
if table.fange_verirrte(s):
ok = false
continue
for d in table.dice_of(s):
if d.freeze:
continue
@@ -311,136 +321,45 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
return
# ---------------------------------------------------------------- Münzen
# ---------------------------------------------------------------- Münzen und Aufdecken
# Ausgelagert nach muenzen.gd und aufdecken.gd. Die Hüllen hier bleiben, weil
# Tests und Aufrufer den Weg über das Spiel kennen.
var muenzen := preload("res://scripts/muenzen.gd").new(self)
var aufdecken := preload("res://scripts/aufdecken.gd").new(self)
## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub.
func _zieh_muenze(seat: int) -> void:
var muenzen: Array = hand.get(seat, [])
if muenzen.size() >= BoosterDB.HANDLIMIT:
_log("%s hat schon drei Münzen und bekommt keine." % seats[seat].name)
return
var id := BoosterDB.zieh(rng)
muenzen.append(id)
hand[seat] = muenzen
_log("%s bekommt %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
muenzen._zieh_muenze(seat)
## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor
## man ansagt - so ist der Zug eine Entscheidung und nichts kommt sich in die
## Quere. Wappen verstärkt, Zahl lässt es bei der Grundwirkung.
func _do_play(seat: int, id: String, target: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
return
if _muenze_laeuft:
return
if BoosterDB.needs_bid(id) and bid.is_empty():
return
var muenzen: Array = hand.get(seat, [])
if not muenzen.has(id):
return
muenzen.erase(id)
hand[seat] = muenzen
_muenze_laeuft = true
var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE
_wurf_nr += 1
topf.append({"seat": seat, "id": id, "stark": stark, "nr": _wurf_nr})
while topf.size() > BoosterDB.TOPF_MAX:
var weg: Dictionary = topf.pop_front()
_log("%s fällt vom Tisch - der Topf ist voll." % BoosterDB.get_booster(String(weg.id)).name)
_log("%s wirft %s - %s." % [seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"])
# Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die
# Folge vor der Ursache.
_push_state()
await _sleep(WURF_ZEIT)
if _alive():
await _wirkung(seat, id, target, stark)
_muenze_laeuft = false
if not _alive():
return
_snapshot_all()
_push_state()
await muenzen._do_play(seat, id, target)
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = host_values[seat]
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id:
"peek":
var info := {}
for o in _alive_seats():
if o != seat:
info[o] = _zufalls_paare(o, 2 if stark else 1)
peeked[seat] = info
"count":
# Zahl: ein Fenster von drei, in dem die Wahrheit irgendwo liegt.
# Genau wüsste man sonst, ob die Ansage hält - kein Bluff mehr.
var n := _count_face(int(bid.face))
var von: int = n if stark else maxi(0, n - rng.randi_range(0, 2))
gezaehlt[seat] = {int(bid.face): [von, von if stark else von + 2]}
"set_face":
for k: int in _ziel_und_zufall(seat, ziel, stark):
_set_die(seat, k, int(bid.face))
"gain_die":
seats[seat].count += 1
table.set_dice_count(seat, seats[seat].count)
host_values[seat].append(1)
var neu: int = host_values[seat].size() - 1
await _neu_werfen(seat, [neu])
if stark:
_set_die(seat, neu, 6)
"bid_up":
bid.count = int(bid.count) + (2 if stark else 1)
last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face}
verlauf.append({"seat": int(bid.seat), "count": bid.count, "face": bid.face, "erhoeht": true})
"gag":
if stark:
for o in _alive_seats():
if o != seat:
must_bid[o] = true
else:
must_bid[_next_alive(seat)] = true
await muenzen._wirkung(seat, id, target, stark)
func _count_face(face: int) -> int:
var n := 0
for i in _alive_seats():
for v in host_values[i]:
if v == face:
n += 1
return n
return muenzen._count_face(face)
## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen.
func _zufalls_idx(seat: int, n: int) -> Array:
var frei: Array = range(host_values[seat].size())
var out: Array = []
while out.size() < n and not frei.is_empty():
out.append(frei.pop_at(rng.randi_range(0, frei.size() - 1)))
return out
func _bot_muenze(seat: int, a: Dictionary) -> bool:
return await muenzen._bot_muenze(seat, a)
func _zufalls_paare(seat: int, n: int) -> Array:
var out: Array = []
for k: int in _zufalls_idx(seat, n):
out.append([k, host_values[seat][k]])
return out
func _topf_an(winner: int) -> void:
muenzen._topf_an(winner)
## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger.
func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array:
var out: Array = [ziel]
if not stark:
return out
var frei: Array = []
for k in host_values[seat].size():
if k != ziel:
frei.append(k)
if not frei.is_empty():
out.append(frei[rng.randi_range(0, frei.size() - 1)])
return out
func _phase_reveal(result: Dictionary) -> void:
await aufdecken._phase_reveal(result)
func _all_values() -> Array:
return aufdecken._all_values()
## Würfel neu werfen und die Augenzahl danach übernehmen.
@@ -518,61 +437,6 @@ func _phase_bid() -> Dictionary:
return {}
## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die
## geplante Handlung. Gibt zurück, ob geworfen wurde.
func _bot_muenze(seat: int, a: Dictionary) -> bool:
var muenzen: Array = hand.get(seat, [])
if muenzen.is_empty():
return false
var vals: Array = host_values[seat]
var unknown: int = _total_dice() - vals.size()
var will_bieten: bool = a.type == "bid"
# Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt?
var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0
var liegt: float = DiceAI.chance(vals, unknown, int(bid.count), int(bid.face)) if not bid.is_empty() else 0.0
var wahl := ""
var ziel := 0
# Hinten liegen: Nachschub ist der einzige Weg zurück.
if muenzen.has("gain_die") and vals.size() <= _kleinste_hand():
wahl = "gain_die"
# Vor dem Anfechten lieber noch nachsehen, wenn es knapp ist.
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("count"):
wahl = "count"
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("peek"):
wahl = "peek"
# Zudrücken: die Ansage des Gegners höher schrauben, dann anfechten.
elif not will_bieten and muenzen.has("bid_up"):
wahl = "bid_up"
# Ein Bluff steht an: dem Nächsten den Mund verbieten.
elif will_bieten and eigenes < 0.35 and muenzen.has("gag"):
wahl = "gag"
# Auf derselben Augenzahl erhöhen: einen Würfel dazu drehen.
elif will_bieten and not bid.is_empty() and int(a.face) == int(bid.face) \
and muenzen.has("set_face") and vals.count(int(bid.face)) < vals.size():
wahl = "set_face"
ziel = DiceAI.target_for(vals, int(bid.face))
# Voller Vorrat: irgendwas Spielbares raus, sonst wird nie geworfen.
elif muenzen.size() >= BoosterDB.HANDLIMIT:
for id: String in muenzen:
if not BoosterDB.needs_bid(id) or not bid.is_empty():
wahl = id
break
if wahl == "":
return false
await _do_play(seat, wahl, ziel)
await _sleep(0.6)
return true
## Die kleinste Würfelzahl am Tisch - wer da liegt, ist hinten.
func _kleinste_hand() -> int:
var k := 99
for i in _alive_seats():
k = mini(k, seats[i].count)
return k
## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
func _apply_action(seat: int, a: Dictionary) -> Dictionary:
if a.type == "exact":
@@ -616,124 +480,11 @@ func _valid_bid(seat: int, count: int, face: int) -> bool:
return count == bid.count and face > bid.face
func _phase_reveal(result: Dictionary) -> void:
phase = "reveal"
deadline = 0.0
var b: Dictionary = result.bid
var rufer: int = result.challenger
var genau: bool = result.get("exact", false)
var total := _count_face(int(b.face))
# 1. Becher hoch. Noch kein Urteil - erst schauen.
reveal_total = -1
banner = "Genau?" if genau else "Aufdecken"
for i in _alive_seats():
table.arrange_and_show(i, host_values[i], rng)
table.set_cup_down(i, false)
# 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(ZEIT_BECHER)
if not _alive():
return
# 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein.
reveal_total = total
banner = "%d gefunden, %d angesagt" % [total, b.count]
_push_state()
await _sleep(ZEIT_ZAEHLEN)
if not _alive():
return
# 3. Urteil. Bei "Genau" trifft es alle anderen - oder nur den Rufer.
var verlierer: Array = []
var winner: int
var bonus := -1
if genau and total == int(b.count):
# Fester Schwung von zwei, egal wie viele am Tisch sitzen: der Ansager
# verliert einen, der Rufer bekommt einen. Mehr als eine Anfechtung
# bringt, weniger als "alle anderen" - das kippte bei vielen Spielern.
verlierer = [int(b.seat)]
winner = rufer
bonus = rufer
banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
seats[int(b.seat)].name, seats[rufer].name]
elif genau:
verlierer = [rufer]
winner = int(b.seat)
banner = "Daneben - %s verliert" % seats[rufer].name
else:
var loser := rufer if total >= int(b.count) else int(b.seat)
verlierer = [loser]
winner = int(b.seat) if loser == rufer else rufer
banner = "%s verliert" % seats[loser].name
_log("%d x %d angesagt - tatsächlich %d. %s" % [b.count, b.face, total, banner])
_topf_an(winner)
_push_state()
await _sleep(ZEIT_URTEIL)
if not _alive():
return
for l: int in verlierer:
seats[l].count -= 1
if seats[l].count <= 0:
seats[l].alive = false
seats[l].count = 0
host_values[l] = []
table.set_dice_count(l, 0)
_log("%s scheidet aus." % seats[l].name)
else:
_zieh_muenze(l)
if bonus >= 0 and seats[bonus].alive:
seats[bonus].count += 1
host_values[bonus].append(rng.randi_range(1, 6))
table.set_dice_count(bonus, seats[bonus].count)
# Nach einem Treffer beginnt der Rufer, sonst der Verlierer.
var naechster: int = winner if genau and total == int(b.count) else verlierer[0]
starter = naechster if seats[naechster].alive else _next_alive(naechster)
# Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel.
for i in seats.size():
table.set_dice_visible(i, false)
table.set_cup_down(i, seats[i].alive)
banner = ""
_push_state()
## Wer das Aufdecken gewinnt, nimmt aus dem Topf, so viel in den Vorrat
## passt. Der Rest bleibt liegen und wächst in die nächste Runde - so ist
## ein voller Vorrat kein Grund, den Topf zu verschenken.
func _topf_an(winner: int) -> void:
if topf.is_empty():
return
var h: Array = hand.get(winner, [])
var genommen := 0
_topf_ziel = winner
while not topf.is_empty() and h.size() < BoosterDB.HANDLIMIT:
h.append(String(topf.pop_front().id))
genommen += 1
hand[winner] = h
_log("%s nimmt %d Münze(n) aus dem Topf, %d bleiben." % [
seats[winner].name, genommen, topf.size()])
func _all_values() -> Array:
var out: Array = []
for i in seats.size():
out.append(host_values[i] if seats[i].alive else [])
return out
## process_always = false: eine offene Pause hält den Timer wirklich an.
func _sleep(sec: float) -> void:
if not _alive():
return
await get_tree().create_timer(sec, false).timeout
await get_tree().create_timer(sec * tempo, false).timeout
func _wait_until(cond: Callable, timeout: float) -> bool:
@@ -983,11 +734,15 @@ func _on_pause_changed(on: bool) -> void:
_push_state()
## Noch eine Solopartie mit denselben Bots. Der Wunsch wandert über Net ins
## neu geladene Menü, das dann sofort startet.
## Noch eine Partie mit denselben Bots. Solo wandert der Wunsch über Net ins
## neu geladene Menü, das dann sofort startet. Im Netz bittet der Raumbesitzer
## den Server, den Raum neu aufzusetzen.
func _on_rematch() -> void:
if _quitting:
return
if Net.room_code != Net.SOLO:
Net.request_rematch()
return
var bots := 0
for s in seats:
if s.ai:

View File

@@ -748,9 +748,9 @@ func update_state(st: Dictionary, my_seat: int) -> void:
banner_label.text = "" if st.phase == "over" else st.banner
if st.phase == "over" and not over_slot.visible:
over_title.text = String(st.banner)
# Nochmal gibt es nur solo. Im Netz müsste der Server den Raum neu
# aufsetzen; bis dahin geht es zurück ins Menü.
over_again.visible = Net.room_code == Net.SOLO
# Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server
# setzt den Raum dann für alle neu auf.
over_again.visible = Net.room_code == Net.SOLO or Net.is_owner
over_slot.visible = true
var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive

View File

@@ -49,7 +49,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _raum_bauen(code: String, cfg: Dictionary) -> void:
if raeume.has_node(code):
return
# Ein Nochmal ersetzt die alte Partie; alles andere ist ein Doppel.
if not cfg.get("rematch", false):
return
_raum_abbauen(code)
if menu != null:
menu.queue_free()
menu = null
@@ -103,9 +106,12 @@ func _wiedereinstieg(code: String, peer: int, pname: String) -> void:
Net.rejoin_annehmen(code, peer, pname, cfg)
## Sofort aus dem Baum, nicht erst am Frameende: ein Nochmal baut den Raum
## unter demselben Namen gleich wieder auf.
func _raum_abbauen(code: String) -> void:
var raum := raeume.get_node_or_null(NodePath(code))
if raum != null:
raeume.remove_child(raum)
raum.queue_free()

View File

@@ -10,6 +10,7 @@ var current := "main"
# Einzelspieler
var solo_bots: NumberRow
var zurueck_btn: Button ## "Zurück in Raum XY" nach einem Abbruch
var solo_stufe: OptionButton
var lobby_stufe: OptionButton
@@ -62,6 +63,10 @@ func _show(which: String) -> void:
for key: String in screens:
screens[key].visible = key == which
_sagen("")
if which == "main" and zurueck_btn != null:
zurueck_btn.visible = not Net.letzter_raum.is_empty()
if zurueck_btn.visible:
zurueck_btn.text = "Zurück in Raum %s" % Net.letzter_raum.code
if which == "lobby":
_refresh()
@@ -88,6 +93,12 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label:
return l
func _on_wieder_rein() -> void:
_show("join")
_sagen("Verbinde mit Raum %s …" % Net.letzter_raum.get("code", ""))
Net.wieder_rein()
## Leicht, normal, schwer - stellt Bluff-Quote, Genau-Schwelle und Vorsicht
## der Bots ein.
func _stufen_wahl() -> OptionButton:
@@ -170,6 +181,9 @@ func _title(box: VBoxContainer) -> void:
func _build_main() -> Control:
var box := _box(360)
_title(box)
# Nach einem Verbindungsabbruch: derselbe Name, derselbe Code, ein Klick.
zurueck_btn = _button("", box, _on_wieder_rein)
zurueck_btn.visible = false
_button("Einzelspieler", box, _show.bind("single"))
_button("Mehrspieler", box, _show.bind("multi"))
_button("Regeln", box, _show.bind("regeln"))
@@ -191,6 +205,8 @@ func _build_regeln() -> Control:
["Topf", "Geworfene Münzen liegen in der Mitte. Wer das Aufdecken gewinnt, nimmt sie. Mehr als vier passen nicht in den Vorrat; der Rest bleibt liegen. Im Topf liegen höchstens sechs - die älteste fällt vom Tisch."],
["Ende", "Wer keine Würfel mehr hat, schaut zu. Der Letzte gewinnt."],
]
for b in BoosterDB.ALL:
absaetze.append([String(b.name), "%s Wappen: %s" % [b.desc, b.stark]])
for a: Array in absaetze:
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 12)
@@ -206,7 +222,13 @@ func _build_regeln() -> Control:
h.add_child(t)
box.add_child(h)
_button("Zurück", box, _show.bind("main"))
return box
# Mit den Münzen passt die Seite nicht mehr auf 720 Zeilen: scrollen.
var sc := ScrollContainer.new()
sc.custom_minimum_size = Vector2(720, 560)
sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sc.add_child(box)
return sc
func _build_single() -> Control:

212
scripts/muenzen.gd Normal file
View File

@@ -0,0 +1,212 @@
extends RefCounted
## Münzen: ziehen, werfen, Wirkung, Bot-Absicht, Topf. Arbeitet auf dem Spiel
## `g` - Zustand und Ablauf bleiben dort, hier steht nur, was Münzen tun.
## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er.
var g: Node
func _init(spiel: Node) -> void:
g = spiel
## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub.
func _zieh_muenze(seat: int) -> void:
var muenzen: Array = g.hand.get(seat, [])
if muenzen.size() >= BoosterDB.HANDLIMIT:
g._log("%s hat schon drei Münzen und bekommt keine." % g.seats[seat].name)
return
var id: String = BoosterDB.zieh(g.rng)
muenzen.append(id)
g.hand[seat] = muenzen
g._log("%s bekommt %s." % [g.seats[seat].name, BoosterDB.get_booster(id).name])
## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor
## man ansagt - so ist der Zug eine Entscheidung und nichts kommt sich in die
## Quere. Wappen verstärkt, Zahl lässt es bei der Grundwirkung.
func _do_play(seat: int, id: String, target: int) -> void:
if seat < 0 or g.phase != "bid" or seat != g.turn or g._answered.has(seat):
return
if g._muenze_laeuft:
return
if BoosterDB.needs_bid(id) and g.bid.is_empty():
return
var muenzen: Array = g.hand.get(seat, [])
if not muenzen.has(id):
return
muenzen.erase(id)
g.hand[seat] = muenzen
g._muenze_laeuft = true
var stark: bool = g.rng.randf() < BoosterDB.WAPPEN_CHANCE
g._wurf_nr += 1
g.topf.append({"seat": seat, "id": id, "stark": stark, "nr": g._wurf_nr})
while g.topf.size() > BoosterDB.TOPF_MAX:
var weg: Dictionary = g.topf.pop_front()
g._log("%s fällt vom Tisch - der Topf ist voll." % BoosterDB.get_booster(String(weg.id)).name)
g._log("%s wirft %s - %s." % [g.seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"])
# Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die
# Folge vor der Ursache.
g._push_state()
await g._sleep(g.WURF_ZEIT)
if g._alive():
await _wirkung(seat, id, target, stark)
g._muenze_laeuft = false
if not g._alive():
return
g._snapshot_all()
g._push_state()
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = g.host_values[seat]
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id:
"peek":
var info := {}
for o in g._alive_seats():
if o != seat:
info[o] = _zufalls_paare(o, 2 if stark else 1)
g.peeked[seat] = info
"count":
# Zahl: ein Fenster von drei, in dem die Wahrheit irgendwo liegt.
# Genau wüsste man sonst, ob die Ansage hält - kein Bluff mehr.
var n: int = _count_face(int(g.bid.face))
var von: int = n if stark else maxi(0, n - g.rng.randi_range(0, 2))
g.gezaehlt[seat] = {int(g.bid.face): [von, von if stark else von + 2]}
"set_face":
for k: int in _ziel_und_zufall(seat, ziel, stark):
g._set_die(seat, k, int(g.bid.face))
"gain_die":
g.seats[seat].count += 1
g.table.set_dice_count(seat, g.seats[seat].count)
g.host_values[seat].append(1)
var neu: int = g.host_values[seat].size() - 1
await g._neu_werfen(seat, [neu])
if stark:
g._set_die(seat, neu, 6)
"bid_up":
g.bid.count = int(g.bid.count) + (2 if stark else 1)
g.last_bids[int(g.bid.seat)] = {"count": g.bid.count, "face": g.bid.face}
g.verlauf.append({"seat": int(g.bid.seat), "count": g.bid.count, "face": g.bid.face, "erhoeht": true})
"gag":
if stark:
for o in g._alive_seats():
if o != seat:
g.must_bid[o] = true
else:
g.must_bid[g._next_alive(seat)] = true
func _count_face(face: int) -> int:
var n := 0
for i in g._alive_seats():
for v in g.host_values[i]:
if v == face:
n += 1
return n
## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen.
func _zufalls_idx(seat: int, n: int) -> Array:
var frei: Array = range(g.host_values[seat].size())
var out: Array = []
while out.size() < n and not frei.is_empty():
out.append(frei.pop_at(g.rng.randi_range(0, frei.size() - 1)))
return out
func _zufalls_paare(seat: int, n: int) -> Array:
var out: Array = []
for k: int in _zufalls_idx(seat, n):
out.append([k, g.host_values[seat][k]])
return out
## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger.
func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array:
var out: Array = [ziel]
if not stark:
return out
var frei: Array = []
for k in g.host_values[seat].size():
if k != ziel:
frei.append(k)
if not frei.is_empty():
out.append(frei[g.rng.randi_range(0, frei.size() - 1)])
return out
## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die
## geplante Handlung. Gibt zurück, ob geworfen wurde.
func _bot_muenze(seat: int, a: Dictionary) -> bool:
var muenzen: Array = g.hand.get(seat, [])
if muenzen.is_empty():
return false
var vals: Array = g.host_values[seat]
var unknown: int = g._total_dice() - vals.size()
var will_bieten: bool = a.type == "bid"
# Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt?
var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0
var liegt: float = DiceAI.chance(vals, unknown, int(g.bid.count), int(g.bid.face)) if not g.bid.is_empty() else 0.0
var wahl := ""
var ziel := 0
# Wirklich hinten liegen: Nachschub ist der Weg zurück. Bei Gleichstand
# nicht - sonst wirft ihn zu Beginn jeder, und der Tisch wird nie leerer.
if muenzen.has("gain_die") and vals.size() < _groesste_hand():
wahl = "gain_die"
# Vor dem Anfechten lieber noch nachsehen, wenn es knapp ist.
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("count"):
wahl = "count"
elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("peek"):
wahl = "peek"
# Zudrücken: die Ansage des Gegners höher schrauben, dann anfechten.
elif not will_bieten and muenzen.has("bid_up"):
wahl = "bid_up"
# Ein Bluff steht an: dem Nächsten den Mund verbieten.
elif will_bieten and eigenes < 0.35 and muenzen.has("gag"):
wahl = "gag"
# Auf derselben Augenzahl erhöhen: einen Würfel dazu drehen.
elif will_bieten and not g.bid.is_empty() and int(a.face) == int(g.bid.face) \
and muenzen.has("set_face") and vals.count(int(g.bid.face)) < vals.size():
wahl = "set_face"
ziel = DiceAI.target_for(vals, int(g.bid.face))
# Voller Vorrat: irgendwas Spielbares raus, sonst wird nie geworfen.
elif muenzen.size() >= BoosterDB.HANDLIMIT:
for id: String in muenzen:
if not BoosterDB.needs_bid(id) or not g.bid.is_empty():
wahl = id
break
if wahl == "":
return false
await _do_play(seat, wahl, ziel)
await g._sleep(0.6)
return true
## Die grösste Würfelzahl am Tisch - wer weniger hat, liegt hinten.
func _groesste_hand() -> int:
var k := 0
for i in g._alive_seats():
k = maxi(k, g.seats[i].count)
return k
## Wer das Aufdecken gewinnt, nimmt aus dem Topf, so viel in den Vorrat
## passt. Der Rest bleibt liegen und wächst in die nächste Runde - so ist
## ein voller Vorrat kein Grund, den Topf zu verschenken.
func _topf_an(winner: int) -> void:
if g.topf.is_empty():
return
var h: Array = g.hand.get(winner, [])
var genommen := 0
g._topf_ziel = winner
while not g.topf.is_empty() and h.size() < BoosterDB.HANDLIMIT:
h.append(String(g.topf.pop_front().id))
genommen += 1
g.hand[winner] = h
g._log("%s nimmt %d Münze(n) aus dem Topf, %d bleiben." % [
g.seats[winner].name, genommen, g.topf.size()])

View File

@@ -35,6 +35,9 @@ var room_code := ""
var in_game := false
## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein.
var rematch: Dictionary = {}
## Raum und Name nach einem Verbindungsabbruch - für "Zurück in Raum XY".
## Ein bewusstes Verlassen löscht es.
var letzter_raum: Dictionary = {}
# ── Server ───────────────────────────────────────────────────────────────────
var dedicated := false
@@ -100,6 +103,7 @@ func serve(port: int) -> bool:
func leave() -> void:
letzter_raum = {}
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
@@ -131,6 +135,8 @@ func _on_connect_fail() -> void:
func _on_server_disconnected() -> void:
if in_game and room_code != "" and room_code != SOLO:
letzter_raum = {"code": room_code, "name": player_name}
multiplayer.multiplayer_peer = null
lobby.clear()
owner_id = 0
@@ -252,7 +258,7 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
# ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void:
func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) -> void:
player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO
@@ -262,16 +268,51 @@ func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void:
in_game = true
lobby_changed.emit()
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count,
"stufe": stufe})
"stufe": stufe, "dice": dice})
## Der Host bittet den Server um den Start.
func request_start(ai_count: int, stufe := 1) -> void:
_req_start.rpc_id(1, ai_count, stufe)
## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie.
func wieder_rein() -> void:
var r := letzter_raum
letzter_raum = {}
join_room(String(r.code), String(r.name))
## Der Raumbesitzer will nach dem Ende noch eine Partie mit derselben Runde.
func request_rematch() -> void:
_req_rematch.rpc_id(1)
@rpc("any_peer", "reliable")
func _req_start(ai_count: int, stufe: int) -> void:
func _req_rematch() -> void:
if not multiplayer.is_server():
return
var id := multiplayer.get_remote_sender_id()
var code: String = peer_raum.get(id, "")
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty() or not raum.in_game or id != raum.owner:
return
var order: Array = raum.lobby.keys()
order.sort()
if order.size() + int(raum.get("ai", 0)) < 2:
return
raum.bestaetigt = {}
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": int(raum.get("ai", 0)),
"stufe": int(raum.get("stufe", 1)), "dice": int(raum.get("dice", 5)), "rematch": true}
room_closed.emit(code) # alter Spielknoten weg, auf dem Server
_start_zustellen(code, cfg)
game_started.emit(code, cfg)
print("Raum %s spielt noch einmal." % code)
## Der Host bittet den Server um den Start.
## `dice` unter 5 nutzen nur die Tests, für kurze Partien.
func request_start(ai_count: int, stufe := 1, dice := 5) -> void:
_req_start.rpc_id(1, ai_count, stufe, dice)
@rpc("any_peer", "reliable")
func _req_start(ai_count: int, stufe: int, dice: int) -> void:
if not multiplayer.is_server():
return
var id := multiplayer.get_remote_sender_id()
@@ -284,8 +325,11 @@ func _req_start(ai_count: int, stufe: int) -> void:
if order.size() + ai_count < 2:
return
raum.in_game = true
raum.ai = ai_count
raum.stufe = clampi(stufe, 0, 2)
raum.dice = clampi(dice, 1, 5)
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count,
"stufe": clampi(stufe, 0, 2)}
"stufe": clampi(stufe, 0, 2), "dice": clampi(dice, 1, 5)}
_start_zustellen(code, cfg)
game_started.emit(code, cfg)
@@ -323,7 +367,7 @@ func _start_erhalten() -> void:
@rpc("authority", "reliable")
func _begin(code: String, cfg: Dictionary) -> void:
_start_erhalten.rpc_id(1)
if in_game and room_code == code:
if in_game and room_code == code and not cfg.get("rematch", false):
return # Nachzügler-Paket, die Partie läuft hier schon
in_game = true
room_code = code

View File

@@ -442,6 +442,27 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
arr[i].throw_from(zone.global_transform * off, rng)
## Fangnetz: ein Würfel, der über die Bande oder durch den Filz gefallen ist,
## fiele sonst ewig - und das Würfeln käme nie zur Ruhe. Er kommt zurück in
## seine Zone und fällt dort noch einmal. Gibt zurück, ob einer gefangen wurde.
func fange_verirrte(seat: int) -> bool:
var zone: Node3D = zones[seat]
var gefangen := false
for d in dice[seat]:
if d.global_position.y > TABLE_H - 0.3 and d.global_position.y < TABLE_H + 3.0:
continue
var ang := randf() * TAU
var off := Vector3(cos(ang), 0.0, sin(ang)) * ZONE_RAD * 0.3 + Vector3(0, 0.35, 0)
d.freeze = false
d.sleeping = false
d.linear_velocity = Vector3.ZERO
d.angular_velocity = Vector3.ZERO
var t := Transform3D(Basis(), zone.global_transform * off)
d.place(t)
gefangen = true
return gefangen
## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von`
## bleiben die davorliegenden unangetastet.
func _layout_idle(seat: int, von := 0) -> void: