This commit is contained in:
team3
2026-09-16 13:21:56 +02:00
parent 7979d5c336
commit 3be22351a3
25 changed files with 734 additions and 195 deletions

View File

@@ -34,6 +34,7 @@ var _muenze_laeuft := false ## eine Münze wird gerade aufgelöst
var _wurf_nr := 0 ## zählt die Würfe übers Spiel, damit das HUD neue erkennt
var _klang_phase := "" ## letzte Phase, für die ein Klang lief
var _gezaehlt_gezeigt := false ## Hervorhebung beim Aufdecken lief schon
var _topf_ziel := -1 ## wer den Topf gerade genommen hat, für den Flug der Münzen
var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = []
var phase := "lobby"
@@ -56,13 +57,20 @@ func _ready() -> void:
func setup(cfg: Dictionary) -> void:
DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear()
var peers: Array = cfg.peers
for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": START_DICE, "alive": true})
for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_DICE, "alive": true})
if cfg.has("seats"):
# Rückkehrer: die laufende Aufstellung, Platz für Platz, in der
# Reihenfolge des Servers - daran hängen alle Indizes im Zustand.
for s in cfg.seats:
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 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})
for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
"count": START_DICE, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
var me := multiplayer.get_unique_id()
@@ -93,7 +101,7 @@ func setup(cfg: Dictionary) -> void:
# Die Startmünze zieht nur der Host. Beim Client kommt sie mit dem
# ersten Zustand, sonst läge dort kurz eine andere.
hand[i] = [BoosterDB.zieh(rng)] if multiplayer.is_server() else []
table.set_dice_count(i, START_DICE)
table.set_dice_count(i, seats[i].count)
table.set_dice_visible(i, false)
table.set_cup_down(i, i != local_seat)
host_values.append([])
@@ -110,10 +118,44 @@ func _on_peer_lost(peer: int) -> void:
return
seats[seat].ai = true
seats[seat].peer = 0
seats[seat].verwaist = true
_log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name)
_push_state()
## Nur Server: ein Rückkehrer mit diesem Namen bekommt seinen Platz zurück.
## Gibt die Aufstellung für seinen Client zurück, oder {} wenn es keinen
## verwaisten Platz mit dem Namen gibt.
func wiedereinstieg(peer: int, pname: String) -> Dictionary:
for i in seats.size():
var s = seats[i]
if not s.verwaist or String(s.name).to_lower() != pname.to_lower():
continue
s.peer = peer
s.ai = false
s.verwaist = false
_log("%s ist zurück und übernimmt wieder." % s.name)
var aufstellung: Array = []
for t in seats:
aufstellung.append({"peer": t.peer, "name": t.name, "ai": t.ai,
"count": t.count, "alive": t.alive})
_nachliefern(i)
return {"seats": aufstellung, "stufe": DiceAI.stufe}
return {}
## Dem Rückkehrer Zustand und Würfellage schicken - kurz verzögert, damit
## sein Spielknoten steht, bevor das erste Paket dort ankommt.
func _nachliefern(seat: int) -> void:
await _sleep(0.6)
if not _alive():
return
_refresh_labels()
_push_state()
if _peer_live(seats[seat].peer):
_dice_snapshot.rpc_id(seats[seat].peer, _pack(seat))
func _refresh_labels() -> void:
for i in seats.size():
var s = seats[i]
@@ -214,6 +256,7 @@ func _phase_roll() -> void:
gezaehlt.clear()
verlauf.clear()
must_bid.clear()
_topf_ziel = -1
banner = ""
_log("--- Neue Runde ---")
@@ -670,6 +713,7 @@ func _topf_an(winner: int) -> void:
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
@@ -832,6 +876,8 @@ func _state_for(seat: int) -> Dictionary:
"actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(),
"verlauf": verlauf.duplicate(true),
"topf_an": _topf_ziel,
"log": log_lines.duplicate(),
"hand": (hand.get(seat, []) as Array).duplicate(),
}
@@ -865,7 +911,7 @@ func _apply_state(st: Dictionary) -> void:
table.set_cup_down(i, seats[i].alive)
table.set_dice_visible(local_seat, true)
_refresh_labels()
table.set_topf(st.get("boosts", []))
table.set_topf(st.get("boosts", []), int(st.get("topf_an", -1)))
if phase == "reveal" and int(st.get("actual", -1)) >= 0 and not _gezaehlt_gezeigt:
_gezaehlt_gezeigt = true
if not bid.is_empty():

View File

@@ -61,8 +61,13 @@ var info_name: Label
var info_desc: Label
var info_stark: Label
var wurf_slot: CenterContainer
var wurf_icon: MuenzIcon
var wurf_label: Label
var log_box: VBoxContainer
var _log_sig := ""
## Auf dem Handy gibt es kein Überfahren: der erste Tipp zeigt die Erklärung,
## der zweite wirft. Am Rechner bleibt alles beim Klick.
var _touch := DisplayServer.is_touchscreen_available()
var _tipp_id := "" ## Münze, deren Erklärung nach dem ersten Tipp offen ist
var timer_bid: Label
var timer_boost: Label
var target_row: HBoxContainer
@@ -456,21 +461,25 @@ func _build() -> void:
hand_panel.visible = false
# --- Booster-Overlay
# --- Geworfene Münze, links unter der Liste der gelegten Münzen. Die
# Tischmitte ist besetzt: dort stehen die Becher und das Gebot.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 300)
# --- Wurfansage, links unter dem Topf. Die Münze selbst fällt in 3D auf
# den Tisch; hier steht nur, wer was geworfen hat und wie es fiel.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200)
wurf_slot.visible = false
var wv := VBoxContainer.new()
wv.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_theme_constant_override("separation", 6)
wurf_slot.add_child(wv)
wurf_icon = MuenzIcon.new("", ACCENT, 104.0)
wurf_icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
wv.add_child(wurf_icon)
wurf_label = _label("", 16, ACCENT)
wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_child(wurf_label)
wurf_slot.add_child(wurf_label)
# --- Protokoll unten rechts: was der Topf tut, wer geht, wer zurückkommt.
var lp := _panel()
# Am unteren Rand verankert und nach oben wachsend - so gross wie der Text.
_place(lp, 1, 1, 1, 1, -376, -124, -16, -124)
lp.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lp.grow_vertical = Control.GROW_DIRECTION_BEGIN
root.add_child(lp)
log_box = VBoxContainer.new()
log_box.add_theme_constant_override("separation", 2)
lp.add_child(log_box)
# --- Erklärung beim Überfahren. Ein eigenes Feld statt Tooltip: das kommt
# sofort und verschwindet nicht, wenn die Runde weiterläuft.
@@ -733,6 +742,7 @@ func update_state(st: Dictionary, my_seat: int) -> void:
_zeichne_hand(st)
_zeichne_mitte(st, my_seat)
_zeichne_verlauf(st, my_seat)
_zeichne_log(st)
# Am Ende steht der Sieger im Endbildschirm; das Banner darüber wäre doppelt.
banner_label.text = "" if st.phase == "over" else st.banner
@@ -1045,54 +1055,48 @@ func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void:
_short(String(st.seats[seat].name)))
## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Drehung
## ist nur die Ansicht dazu und endet auf der richtigen Seite.
## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Münze
## fällt in 3D auf den Tisch; hier steht der Text dazu, kurz und dann weg.
func _zeig_wurf(id: String, stark: bool, wer: String) -> void:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
return
wurf_icon.id = id
wurf_icon.kind_col = _kind_col(b)
wurf_icon.seite = 0
wurf_icon.scale = Vector2.ONE
wurf_icon.queue_redraw()
wurf_label.text = "%s wirft\n%s" % [wer, b.name]
wurf_label.add_theme_color_override("font_color", DIM)
wurf_label.text = "%s wirft %s\n%s" % [wer, b.name,
"Wappen - " + String(b.get("stark", "")) if stark else "Zahl - Grundwirkung"]
wurf_label.add_theme_color_override("font_color", ACCENT if stark else DIM)
wurf_slot.visible = true
Klang.muenze()
if _wurf_tween != null and _wurf_tween.is_valid():
_wurf_tween.kill()
_wurf_tween = create_tween()
_wurf_tween.tween_method(_dreh, 0.0, TAU * 2.5, 0.8)
_wurf_tween.tween_callback(_wurf_ende.bind(stark, b))
_wurf_tween.tween_interval(1.6)
_wurf_tween.tween_interval(2.6)
_wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false)
## Die Münze wird waagerecht gestaucht; bei negativer Breite sieht man die
## Rückseite. Das ist eine Drehung, ohne dass etwas in 3D stehen muss.
func _dreh(winkel: float) -> void:
var c := cos(winkel)
wurf_icon.scale.x = maxf(0.06, absf(c))
var seite := 0 if c >= 0.0 else 1
if wurf_icon.seite != seite:
wurf_icon.seite = seite
wurf_icon.queue_redraw()
## Die letzten Zeilen des Spielprotokolls.
func _zeichne_log(st: Dictionary) -> void:
var zeilen: Array = st.get("log", [])
var sig := str(zeilen)
if sig == _log_sig:
return
_log_sig = sig
_clear(log_box)
var ab := maxi(0, zeilen.size() - 5)
for i in range(ab, zeilen.size()):
var l := _label(String(zeilen[i]), 13, ACCENT if i == zeilen.size() - 1 else DIM)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(340, 0)
log_box.add_child(l)
func _wurf_ende(stark: bool, b: Dictionary) -> void:
wurf_icon.scale.x = 1.0
wurf_icon.seite = 0 if stark else 1
wurf_icon.queue_redraw()
if stark:
wurf_label.text = "%s\nWappen - verstärkt" % b.name
wurf_label.add_theme_color_override("font_color", ACCENT)
else:
wurf_label.text = "%s\nZahl - Grundwirkung" % b.name
wurf_label.add_theme_color_override("font_color", DIM)
func _pick(id: String) -> void:
# Handy: erster Tipp öffnet nur die Erklärung (die kommt über den
# nachgeahmten Mauszeiger von selbst), der zweite wirft wirklich.
if _touch and _tipp_id != id:
_tipp_id = id
return
_tipp_id = ""
if not BoosterDB.needs_target(id) or _my_values.is_empty():
booster_chosen.emit(id, 0)
boost_slot.visible = false

View File

@@ -19,6 +19,7 @@ func _ready() -> void:
Net.game_started.connect(_raum_bauen)
Net.room_closed.connect(_raum_abbauen)
Net.rejoin_requested.connect(_wiedereinstieg)
Net.server_closed.connect(_back_to_menu)
var args := OS.get_cmdline_user_args()
@@ -88,6 +89,20 @@ func _raum_bauen(code: String, cfg: Dictionary) -> void:
game.setup(cfg)
## Nur Server: ein Rückkehrer klopft an. Gibt es seinen verwaisten Platz
## noch, übernimmt er ihn wieder vom Bot.
func _wiedereinstieg(code: String, peer: int, pname: String) -> void:
var game := raeume.get_node_or_null(NodePath(code + "/Container/Viewport/Game"))
if game == null:
Net.abweisen(peer, "In dem Raum läuft schon eine Partie.")
return
var cfg: Dictionary = game.wiedereinstieg(peer, pname)
if cfg.is_empty():
Net.abweisen(peer, "In dem Raum läuft schon eine Partie, und kein Platz heißt %s." % pname)
return
Net.rejoin_annehmen(code, peer, pname, cfg)
func _raum_abbauen(code: String) -> void:
var raum := raeume.get_node_or_null(NodePath(code))
if raum != null:

View File

@@ -13,6 +13,9 @@ extends Node
signal lobby_changed
signal game_started(code: String, cfg: Dictionary)
signal room_closed(code: String)
## Nur Server: jemand will in eine laufende Partie zurück. Der Raum prüft,
## ob es einen verwaisten Platz mit dem Namen gibt.
signal rejoin_requested(code: String, peer: int, pname: String)
signal connection_failed(reason: String)
signal server_closed
@@ -172,7 +175,9 @@ func _req_join(code: String, pname: String) -> void:
_abgewiesen.rpc_id(id, "Den Code %s gibt es nicht." % code)
return
if raum.in_game:
_abgewiesen.rpc_id(id, "In dem Raum läuft schon eine Partie.")
# Kein Neuling, aber vielleicht ein Rückkehrer: dessen Platz spielt
# gerade ein Bot weiter. Der Raum entscheidet.
rejoin_requested.emit(code, id, pname.substr(0, 16))
return
if raum.lobby.size() >= MAX_HUMANS:
_abgewiesen.rpc_id(id, "Der Raum ist voll.")
@@ -182,6 +187,23 @@ func _req_join(code: String, pname: String) -> void:
_lobby_senden(code)
## Rückkehrer aufnehmen: in die Lobby eintragen und mit der laufenden
## Aufstellung starten lassen. `cfg` beschreibt alle Plätze, nicht nur Peers.
func rejoin_annehmen(code: String, peer: int, pname: String, cfg: Dictionary) -> void:
var raum: Dictionary = raeume.get(code, {})
if raum.is_empty():
return
raum.lobby[peer] = pname
peer_raum[peer] = code
raum.bestaetigt.erase(peer)
_begin.rpc_id(peer, code, cfg)
print("%s kehrt in Raum %s zurück." % [pname, code])
func abweisen(peer: int, grund: String) -> void:
_abgewiesen.rpc_id(peer, grund)
## Ein Broadcast würde alle Räume treffen, also gezielt an die eigenen Peers.
func _lobby_senden(code: String) -> void:
var raum: Dictionary = raeume.get(code, {})

View File

@@ -476,10 +476,12 @@ func freeze_dice(seat: int) -> void:
## Der Topf liegt als Häufchen Messing in der Tischmitte. Jede Münze trägt
## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner.
## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner -
## dorthin fliegt sie dann auch (`ziel`), sonst verschwindet sie einfach.
## Lage und Drehung kommen aus der Nummer, damit Host und Clients dasselbe
## Häufchen sehen.
func set_topf(liste: Array) -> void:
## Häufchen sehen. Wappen-Münzen tragen einen geprägten Ring und leuchten
## stärker: so sieht man den Wurfausgang auf dem Tisch, nicht nur im Text.
func set_topf(liste: Array, ziel := -1) -> void:
var bleibt := {}
for m in liste:
var nr := int(m.get("nr", 0))
@@ -490,21 +492,43 @@ func set_topf(liste: Array) -> void:
r.seed = nr * 7919
var ang := r.randf() * TAU
var rad := r.randf_range(0.30, 0.55)
var ziel := Vector3(cos(ang) * rad, TABLE_H + 0.006, sin(ang) * rad)
var wappen: bool = m.get("stark", false)
var lage := Vector3(cos(ang) * rad, TABLE_H + 0.007, sin(ang) * rad)
var mi := MeshInstance3D.new()
mi.mesh = _muenz_form()
mi.material_override = _muenz_stoff()
mi.position = ziel + Vector3(0, 0.35, 0)
mi.material_override = _muenz_stoff(wappen)
mi.position = lage + Vector3(0, 0.45, 0)
mi.rotation.y = r.randf() * TAU
add_child(mi)
if wappen:
var ring := MeshInstance3D.new()
ring.mesh = _praegung()
ring.material_override = _praegung_stoff()
ring.position = Vector3(0, 0.008, 0)
mi.add_child(ring)
topf_muenzen[nr] = mi
# Fällt aus der Hand auf den Tisch.
create_tween().tween_property(mi, "position", ziel, 0.35) \
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
# Fällt drehend aus der Hand und bleibt flach liegen.
var tw := create_tween().set_parallel(true)
tw.tween_property(mi, "position", lage, 0.55).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tw.tween_property(mi, "rotation:x", TAU * 3.0, 0.55).from(0.0)
for nr in topf_muenzen.keys():
if not bleibt.has(nr):
topf_muenzen[nr].queue_free()
topf_muenzen.erase(nr)
if bleibt.has(nr):
continue
var mi: MeshInstance3D = topf_muenzen[nr]
topf_muenzen.erase(nr)
if ziel < 0 or ziel >= zones.size():
mi.queue_free()
continue
# Zum Gewinner fliegen, dann weg.
var hin: Vector3 = zones[ziel].global_position + Vector3(0, 0.25, 0)
var tw := create_tween()
tw.tween_property(mi, "global_position", hin, 0.6).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
tw.tween_callback(mi.queue_free)
static var _muenz_mat_wappen: StandardMaterial3D
static var _praeg_mesh: TorusMesh
static var _praeg_mat: StandardMaterial3D
static func _muenz_form() -> CylinderMesh:
@@ -519,7 +543,7 @@ static func _muenz_form() -> CylinderMesh:
return _muenz_mesh
static func _muenz_stoff() -> StandardMaterial3D:
static func _muenz_stoff(wappen: bool) -> StandardMaterial3D:
if _muenz_mat == null:
_muenz_mat = StandardMaterial3D.new()
_muenz_mat.albedo_color = Color(0.80, 0.62, 0.26)
@@ -528,7 +552,27 @@ static func _muenz_stoff() -> StandardMaterial3D:
_muenz_mat.emission_enabled = true
_muenz_mat.emission = Color(0.55, 0.40, 0.12)
_muenz_mat.emission_energy_multiplier = 0.35
return _muenz_mat
_muenz_mat_wappen = _muenz_mat.duplicate()
_muenz_mat_wappen.emission_energy_multiplier = 0.9
return _muenz_mat_wappen if wappen else _muenz_mat
static func _praegung() -> TorusMesh:
if _praeg_mesh == null:
_praeg_mesh = TorusMesh.new()
_praeg_mesh.inner_radius = 0.035
_praeg_mesh.outer_radius = 0.065
_praeg_mesh.rings = 20
_praeg_mesh.ring_segments = 6
return _praeg_mesh
static func _praegung_stoff() -> StandardMaterial3D:
if _praeg_mat == null:
_praeg_mat = StandardMaterial3D.new()
_praeg_mat.albedo_color = Color(0.20, 0.14, 0.05)
_praeg_mat.roughness = 0.8
return _praeg_mat
## Beim Zählen leuchten die passenden Würfel nacheinander auf, mit einem