extends CanvasLayer ## Komplettes Spiel-UI (programmatisch aufgebaut). signal bid_pressed(count: int, face: int) signal challenge_pressed signal exact_pressed signal rematch_pressed signal booster_chosen(id: String, target: int) signal quit_pressed signal pause_changed(paused: bool) const ACCENT := Color(1.0, 0.82, 0.32) const DIM := Color(0.62, 0.64, 0.70) ## 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(150.0, 108.0) const TIMER_W := 52.0 ## Breite der Zeitanzeige in der Ansage-Leiste const INFO_W := 280.0 ## Textbreite im Erklärfeld beim Überfahren const GRUEN := Color(0.55, 0.84, 0.55) ## überzähliger 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_box: VBoxContainer var bid_name: Label var bid_row: HFlowContainer var boosts_box: VBoxContainer var banner_label: Label 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 _actual := 0 ## beim Aufdecken: tatsächlich gefundene Würfel var face_buttons: Array[Button] = [] var bid_btn: Button var challenge_btn: Button var exact_btn: Button var verlauf_box: VBoxContainer var _verlauf_sig := "" ## Abbild des Gebotsverlaufs, gegen unnötigen Neuaufbau var action_panel: PanelContainer var hand_panel: PanelContainer var hand_box: HBoxContainer var boost_slot: CenterContainer var pause_slot: Control var over_slot: Control var over_title: Label var over_again: Button var pause_hint: Label var boost_panel: PanelContainer var boost_cards: HBoxContainer var boost_title: Label var turn_label: Label var info_panel: PanelContainer var info_name: Label var info_desc: Label var info_stark: Label var wurf_slot: CenterContainer 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 var _face := 1 ## Gezeichnete Münze. Bilder als Dateien gibt es im Projekt nicht; alles wird ## zur Laufzeit gemalt, so wie die Würfel auch. Vorn das Sinnbild der Wirkung ## (Wappen), hinten die glatte Zahlseite. class MuenzIcon extends Control: const RAND := Color(0.40, 0.29, 0.11) ## Prägekante const FLAECHE := Color(0.78, 0.60, 0.26) ## Messing const GLANZ := Color(0.91, 0.75, 0.39) ## Lichtkante const GRAVUR := Color(0.19, 0.13, 0.04) ## eingeschlagenes Zeichen var id := "" var farbe := GRAVUR ## Zeichenfarbe, von den Zeichenhilfen benutzt var kind_col := Color.WHITE var seite := 0 ## 0 = Wappen (Sinnbild), 1 = Zahl func _init(muenze: String, col: Color, px: float) -> void: id = muenze kind_col = col mouse_filter = Control.MOUSE_FILTER_IGNORE custom_minimum_size = Vector2(px, px) size_flags_vertical = Control.SIZE_SHRINK_CENTER pivot_offset = Vector2(px, px) * 0.5 func _draw() -> void: var s := minf(size.x, size.y) var m := size * 0.5 var r := s * 0.5 draw_circle(m, r, RAND) draw_circle(m - Vector2(r, r) * 0.05, r * 0.95, GLANZ) draw_circle(m, r * 0.89, FLAECHE) # Der Farbring sagt, ob die Münze dir hilft oder den anderen schadet. draw_arc(m, r * 0.79, 0, TAU, 40, kind_col, maxf(1.0, s * 0.05)) if seite == 1: draw_arc(m, r * 0.52, 0, TAU, 32, GRAVUR, maxf(1.0, s * 0.04)) draw_circle(m, s * 0.09, GRAVUR) return _zeichen(m, s * 0.74) func _zeichen(m: Vector2, s: float) -> void: match id: "peek": _auge(m, s) "count": _striche(m, s) "set_face": _schiefer_wuerfel(m, s * 0.50) "gain_die": _wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4) _plus(m + Vector2(s * 0.26, -s * 0.20), s * 0.20) "bid_up": _pfeil(m, s * 0.52, true) "gag": var r := Rect2(m - Vector2(s * 0.28, s * 0.16), Vector2(s * 0.56, s * 0.32)) draw_rect(r, farbe, false, s * 0.07) draw_line(m + Vector2(-s * 0.28, s * 0.22), m + Vector2(s * 0.28, -s * 0.22), farbe, s * 0.08) _: draw_circle(m, s * 0.26, farbe) ## Vier Striche und einer quer - eine Strichliste, das Zeichen fürs Zählen. func _striche(mitte: Vector2, s: float) -> void: var w := maxf(1.5, s * 0.07) for i in 4: var x: float = mitte.x + (float(i) - 1.5) * s * 0.16 draw_line(Vector2(x, mitte.y - s * 0.26), Vector2(x, mitte.y + s * 0.26), farbe, w) draw_line(mitte + Vector2(-s * 0.32, s * 0.20), mitte + Vector2(s * 0.32, -s * 0.20), farbe, w) ## Ein gekippter Würfel mit einem Auge - der wird gerade umgedreht. func _schiefer_wuerfel(mitte: Vector2, kante: float) -> void: var h := kante * 0.5 var ecken := PackedVector2Array() for e: Vector2 in [Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h)]: ecken.append(mitte + e.rotated(0.35)) draw_colored_polygon(ecken, farbe) draw_circle(mitte, kante * 0.11, FLAECHE) func _wuerfel(mitte: Vector2, kante: float, augen: int) -> void: var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante)) draw_rect(r, farbe, true) var punkt := kante * 0.10 var weg := kante * 0.26 for p: Vector2 in DieIcon.PIPS[augen]: draw_circle(mitte + p * weg, punkt, FLAECHE) func _plus(mitte: Vector2, r: float) -> void: draw_rect(Rect2(mitte - Vector2(r, r * 0.28), Vector2(r * 2, r * 0.56)), farbe, true) draw_rect(Rect2(mitte - Vector2(r * 0.28, r), Vector2(r * 0.56, r * 2)), farbe, true) ## Der Schaft sitzt hinter der Spitze, sonst überlagern sich beide zu ## einem Klecks. func _pfeil(mitte: Vector2, h: float, hoch: bool) -> void: var d := -1.0 if hoch else 1.0 var schaft_y: float = mitte.y - d * h * 0.48 var oben: float = minf(mitte.y, schaft_y) draw_rect(Rect2(Vector2(mitte.x - h * 0.13, oben), Vector2(h * 0.26, absf(mitte.y - schaft_y))), farbe, true) draw_colored_polygon([mitte + Vector2(0, d * h * 0.50), mitte + Vector2(-h * 0.34, d * h * 0.02), mitte + Vector2(h * 0.34, d * h * 0.02)], farbe) func _auge(mitte: Vector2, s: float) -> void: var punkte := PackedVector2Array() for i in 17: var t := -1.0 + 2.0 * i / 16.0 punkte.append(mitte + Vector2(t * s * 0.34, -sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20)) for i in 17: var t := 1.0 - 2.0 * i / 16.0 punkte.append(mitte + Vector2(t * s * 0.34, sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20)) draw_polyline(punkte, farbe, s * 0.05, true) draw_circle(mitte, s * 0.11, farbe) ## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem ## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen. class DieIcon extends Control: const PIPS := { 1: [Vector2(0, 0)], 2: [Vector2(-1, -1), Vector2(1, 1)], 3: [Vector2(-1, -1), Vector2(0, 0), Vector2(1, 1)], 4: [Vector2(-1, -1), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 1)], 5: [Vector2(-1, -1), Vector2(-1, 1), Vector2(0, 0), Vector2(1, -1), Vector2(1, 1)], 6: [Vector2(-1, -1), Vector2(-1, 0), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 0), Vector2(1, 1)], } var value := 0 var _box := StyleBoxFlat.new() func _init(v: int, px: float = 0.0, bg := Color.TRANSPARENT) -> void: value = v mouse_filter = Control.MOUSE_FILTER_IGNORE if px > 0.0: custom_minimum_size = Vector2(px, px) # sonst zieht der Container das Symbol in die Höhe und es wird unrund size_flags_vertical = Control.SIZE_SHRINK_CENTER if bg.a > 0.0: _box.bg_color = bg else: _box.bg_color = Color(0.93, 0.92, 0.88) if v > 0 else Color(0.26, 0.28, 0.34) _box.set_corner_radius_all(4) _box.border_color = Color(0, 0, 0, 0.35) _box.set_border_width_all(1) func _draw() -> void: # Immer im grössten Quadrat zeichnen, das in die Fläche passt. var s := minf(size.x, size.y) var mid := size * 0.5 draw_style_box(_box, Rect2(mid - Vector2(s, s) * 0.5, Vector2(s, s))) if value <= 0: var f := get_theme_default_font() var fs := int(s * 0.66) var w := f.get_string_size("?", HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x draw_string(f, Vector2(mid.x - w * 0.5, mid.y + fs * 0.35), "?", HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(0.85, 0.88, 0.94)) return for p: Vector2 in PIPS[value]: draw_circle(mid + p * (s * 0.24), s * 0.105, Color(0.09, 0.08, 0.1)) var _deadline := 0.0 ## lokaler Zeitstempel, 0 = keine Frist var _phase := "" var _my_turn := false var _pending_boost := "" var _letzter_wurf := 0 ## Nummer des zuletzt gezeigten Münzwurfs var _info_ctrl: Control ## worauf die Maus gerade liegt var _zug_box := StyleBoxFlat.new() ## Hinterlegung der Zeile, die dran ist var _leer_box := StyleBoxEmpty.new() var _hand_sig := "" ## Abbild des Vorrats, gegen unnötigen Neuaufbau var _mitte_sig := "" ## dasselbe für die Liste der gelegten Münzen var _wurf_tween: Tween var _my_values: Array = [] var _my_seat := 0 func _ready() -> void: _zug_box.bg_color = Color(0.30, 0.24, 0.07, 0.85) _zug_box.set_corner_radius_all(6) _zug_box.border_color = ACCENT _zug_box.set_border_width_all(1) _zug_box.set_content_margin_all(5) _leer_box.set_content_margin_all(5) layer = 1 process_mode = Node.PROCESS_MODE_ALWAYS root = Control.new() root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(root) _build() func _panel(col := Color(0.06, 0.07, 0.10, 0.86)) -> PanelContainer: var p := PanelContainer.new() var sb := StyleBoxFlat.new() sb.bg_color = col sb.set_corner_radius_all(8) sb.set_content_margin_all(10) sb.border_color = Color(1, 1, 1, 0.10) sb.set_border_width_all(1) p.add_theme_stylebox_override("panel", sb) return p func _label(text: String, size: int, col := Color.WHITE) -> Label: var l := Label.new() l.text = text l.add_theme_font_size_override("font_size", size) l.add_theme_color_override("font_color", col) return l func _place(c: Control, al: float, at: float, ar: float, ab: float, ol: float, ot: float, orr: float, ob: float) -> void: c.anchor_left = al c.anchor_top = at c.anchor_right = ar c.anchor_bottom = ab c.offset_left = ol c.offset_top = ot c.offset_right = orr c.offset_bottom = ob func _slot(al: float, at: float, ar: float, ab: float, ol: float, ot: float, orr: float, ob: float) -> CenterContainer: var cc := CenterContainer.new() cc.mouse_filter = Control.MOUSE_FILTER_IGNORE _place(cc, al, at, ar, ab, ol, ot, orr, ob) root.add_child(cc) return cc func _build() -> void: # --- Spielerliste oben rechts var pl := _panel() _place(pl, 1, 0, 1, 0, -16, 16, -16, 16) pl.grow_horizontal = Control.GROW_DIRECTION_BEGIN root.add_child(pl) var rechts := VBoxContainer.new() rechts.add_theme_constant_override("separation", 10) pl.add_child(rechts) players_box = VBoxContainer.new() players_box.add_theme_constant_override("separation", 6) rechts.add_child(players_box) # Der Gebotsverlauf der Runde. Bluffs liest man daran, wer wie schnell # hochgegangen ist - nicht am letzten Gebot allein. verlauf_box = VBoxContainer.new() verlauf_box.add_theme_constant_override("separation", 2) rechts.add_child(verlauf_box) # --- 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) # --- 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("", 21, 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.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) # --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt turn_label = _label("", 19, ACCENT) turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE _place(turn_label, 0, 1, 1, 1, 16, -146, -16, -114) root.add_child(turn_label) # --- Ansage: kompakte Leiste unten in der Mitte action_panel = _panel() _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) # 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) 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) 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(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) ic.offset_left = 7 ic.offset_top = 7 ic.offset_right = -7 ic.offset_bottom = -7 b.add_child(ic) frow.add_child(b) face_buttons.append(b) face_buttons[0].button_pressed = true bid_btn = Button.new() bid_btn.text = "Ansagen" 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(120, 42) challenge_btn.pressed.connect(_on_challenge) av.add_child(challenge_btn) exact_btn = Button.new() exact_btn.text = "Genau" exact_btn.tooltip_text = "Die Ansage trifft exakt. Stimmt es, verlieren alle anderen einen Würfel." exact_btn.custom_minimum_size = Vector2(96, 42) exact_btn.pressed.connect(_on_exact) av.add_child(exact_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) # --- Münzvorrat unten links, über der Ansageleiste hand_panel = _panel() _place(hand_panel, 0, 1, 0, 1, 16, -300, 16, -124) root.add_child(hand_panel) var hv := VBoxContainer.new() hv.add_theme_constant_override("separation", 4) hand_panel.add_child(hv) hv.add_child(_label("Münzen", 13, DIM)) hand_box = HBoxContainer.new() hand_box.add_theme_constant_override("separation", 6) hv.add_child(hand_box) hand_panel.visible = false # --- Booster-Overlay # --- 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 wurf_label = _label("", 16, ACCENT) wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE 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. info_panel = _panel(Color(0.05, 0.06, 0.09, 0.97)) info_panel.visible = false info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(info_panel) var iv := VBoxContainer.new() iv.add_theme_constant_override("separation", 4) info_panel.add_child(iv) info_name = _label("", 16, ACCENT) iv.add_child(info_name) info_desc = _label("", 14, Color(0.82, 0.84, 0.90)) info_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info_desc.custom_minimum_size = Vector2(INFO_W, 0) iv.add_child(info_desc) info_stark = _label("", 14, ACCENT) info_stark.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info_stark.custom_minimum_size = Vector2(INFO_W, 0) iv.add_child(info_stark) boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0) boost_slot.visible = false boost_panel = _panel(Color(0.05, 0.06, 0.09, 0.97)) boost_slot.add_child(boost_panel) var bvv := VBoxContainer.new() bvv.add_theme_constant_override("separation", 12) boost_panel.add_child(bvv) boost_title = _label("Wähle eine Münze", 24, ACCENT) boost_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER bvv.add_child(boost_title) timer_boost = _label("", 16, DIM) timer_boost.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER bvv.add_child(timer_boost) boost_cards = HBoxContainer.new() boost_cards.add_theme_constant_override("separation", 12) bvv.add_child(boost_cards) target_row = HBoxContainer.new() target_row.alignment = BoxContainer.ALIGNMENT_CENTER target_row.add_theme_constant_override("separation", 6) target_row.visible = false bvv.add_child(target_row) # --- Pausenmenü (Esc) pause_slot = Control.new() pause_slot.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) pause_slot.visible = false root.add_child(pause_slot) var dim := ColorRect.new() dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) dim.color = Color(0, 0, 0, 0.55) pause_slot.add_child(dim) var pc := CenterContainer.new() pc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) pause_slot.add_child(pc) var pp := _panel(Color(0.05, 0.06, 0.09, 0.98)) pc.add_child(pp) var pv := VBoxContainer.new() pv.custom_minimum_size = Vector2(280, 0) pv.add_theme_constant_override("separation", 10) pp.add_child(pv) var pt := _label("Pause", 26, ACCENT) pt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER pv.add_child(pt) pause_hint = _label("", 13, DIM) pause_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER pv.add_child(pause_hint) var resume := Button.new() resume.text = "Fortsetzen" resume.custom_minimum_size = Vector2(0, 42) resume.pressed.connect(_set_paused.bind(false)) pv.add_child(resume) var leave := Button.new() leave.text = "Verlassen" leave.custom_minimum_size = Vector2(0, 42) leave.pressed.connect(_on_quit) pv.add_child(leave) # --- Spielende: wer gewonnen hat, und wie es weitergeht over_slot = Control.new() over_slot.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) over_slot.visible = false root.add_child(over_slot) var oc := CenterContainer.new() oc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) over_slot.add_child(oc) var op := _panel(Color(0.05, 0.06, 0.09, 0.98)) oc.add_child(op) var ov := VBoxContainer.new() ov.custom_minimum_size = Vector2(300, 0) ov.add_theme_constant_override("separation", 10) op.add_child(ov) over_title = _label("", 26, ACCENT) over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ov.add_child(over_title) over_again = Button.new() over_again.text = "Nochmal" over_again.custom_minimum_size = Vector2(0, 42) over_again.pressed.connect(func() -> void: rematch_pressed.emit()) ov.add_child(over_again) var menue := Button.new() menue.text = "Zum Menü" menue.custom_minimum_size = Vector2(0, 42) menue.pressed.connect(_on_quit) ov.add_child(menue) 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: return true 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 _bid_face < 6: # Gleiche Anzahl reicht, wenn die Augenzahl steigt. _set_count(_bid_count) _on_face(_bid_face + 1) else: _set_count(_bid_count + 1) _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(_count, _face) func _on_challenge() -> void: challenge_pressed.emit() func _on_exact() -> void: exact_pressed.emit() func _unhandled_key_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo \ and event.keycode == KEY_ESCAPE: _set_paused(not pause_slot.visible) get_viewport().set_input_as_handled() func _set_paused(on: bool) -> void: pause_slot.visible = on if on: pause_hint.text = "Die Runde läuft weiter." if multiplayer.get_peers().size() > 0 \ else "Das Spiel ist angehalten." 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(): face_buttons[i].button_pressed = (i + 1 == f) # ---------------------------------------------------------------- Updates 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 _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) # Wer dran ist, bekommt eine hinterlegte Zeile. Ein Pfeil und etwas # Farbe gehen in der Liste unter. var zeile := PanelContainer.new() zeile.add_theme_stylebox_override("panel", _zug_box if turn else _leer_box) players_box.add_child(zeile) var entry := VBoxContainer.new() entry.add_theme_constant_override("separation", 2) zeile.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 entry.add_child(nm) if not s.alive: 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, 21.0)) _clear(bid_row) if st.bid.is_empty(): bid_name.text = "Kein Gebot" bid_name.add_theme_color_override("font_color", Color(1, 1, 1, 0.5)) else: bid_name.text = String(st.seats[st.bid.seat].name) bid_name.add_theme_color_override("font_color", ACCENT) # Beim Aufdecken bleibt die Ansage stehen, wie sie war: die angesagten # Würfel normal, die fehlenden davon rot. Gab es mehr als angesagt, # kommen die überzähligen grün dahinter. var faerben: bool = st.phase == "reveal" and _actual >= 0 var angesagt := int(st.bid.count) var gezeigt := maxi(angesagt, _actual) if faerben else angesagt var px := _fit_dice(gezeigt) for i in gezeigt: var bg := Color.TRANSPARENT if faerben: if i >= angesagt: bg = GRUEN elif i >= _actual: bg = ROT bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg)) _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 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 over_slot.visible = true 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 if st.phase == "bid" and int(st.turn) < st.seats.size(): var wer := "%s ist dran" % _short(String(st.seats[st.turn].name)) if my_turn: turn_label.text = "Du bist dran" elif not st.seats[my_seat].alive: turn_label.text = "Du bist raus · " + wer else: turn_label.text = wer turn_label.add_theme_color_override("font_color", ACCENT if my_turn else DIM) else: turn_label.text = "" action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45) bid_btn.disabled = not my_turn _count_max = maxi(1, int(st.total)) _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 + 1 <= _count_max or _bid_face < 6 challenge_btn.disabled = not my_turn or st.bid.is_empty() \ or (st.must_bid and gebot_moeglich) exact_btn.disabled = challenge_btn.disabled _count_min = clampi(maxi(1, _bid_count), 1, _count_max) _set_count(_count) if kam_dran: _lowest_bid() if st.phase != "boost": boost_slot.visible = false ## Läuft die Restzeit herunter, solange eine Eingabe von dir erwartet wird. ## Nach Ablauf entscheidet der Host automatisch. func _process(_delta: float) -> void: if pause_slot != null and pause_slot.visible: return var wants_boost := boost_slot != null and boost_slot.visible var wants_bid := _my_turn and _phase == "bid" if _deadline <= 0.0 or not (wants_boost or wants_bid): timer_bid.text = "" timer_boost.text = "" return var left := maxf(0.0, _deadline - Time.get_ticks_msec() / 1000.0) var text := "%d s" % ceili(left) var col := Color(0.95, 0.45, 0.4) if left <= 10.0 else DIM timer_bid.text = text if wants_bid else "" timer_bid.add_theme_color_override("font_color", col) # Läuft die Ansagezeit ab, während die Würfelauswahl offen steht, wird # automatisch angesagt - die Münze bleibt dann ungeworfen liegen. timer_boost.text = "Noch %s bis zur Ansage" % text if wants_boost else "" timer_boost.add_theme_color_override("font_color", col) ## 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 var open: Array = st.get("open", []) if seat < open.size() and not open[seat].is_empty(): return open[seat] var out: Array = [] for _k in int(st.seats[seat].count): out.append(-1) var peek: Dictionary = st.peek for paar: Array in peek.get(seat, []): var idx := int(paar[0]) if idx >= 0 and idx < out.size(): out[idx] = int(paar[1]) return out func set_banner(text: String) -> void: banner_label.text = text # ---------------------------------------------------------------- Booster ## Der Vorrat liegt offen unten links. Ein Klick wirft die Münze sofort; nur ## Münzen mit eigenem Ziel fragen noch nach dem Würfel. Die Wirkung steht im ## Tooltip - auf eine Münze passt nur das Sinnbild. func _zeichne_hand(st: Dictionary) -> void: if hand_box == null: return var muenzen: Array = st.get("hand", []) # Wer raus ist, schaut nur noch zu. Münzen, die er nie werfen kann, # stünden nur klickbar im Weg. var dabei: bool = _my_seat < st.seats.size() and st.seats[_my_seat].alive hand_panel.visible = dabei and not muenzen.is_empty() # Geworfen wird nur am eigenen Zug. Was eine Ansage braucht, wartet auf eine. var spielbar: bool = st.phase == "bid" and int(st.turn) == _my_seat var gebot: bool = not st.bid.is_empty() # Jeder Zustand kommt mehrmals an. Ein Neuaufbau würde die Münze unter dem # Zeiger wegreissen und die Erklärung mitnehmen. var sig := "%s|%s|%s" % [muenzen, spielbar, gebot] if sig == _hand_sig: return _hand_sig = sig _clear(hand_box) for id: String in muenzen: var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): continue var col := _kind_col(b) var btn := Button.new() btn.custom_minimum_size = Vector2(104, 120) btn.disabled = not spielbar or (BoosterDB.needs_bid(id) and not gebot) # Der gesperrte Knopf dunkelt nur seinen Rand ab; Münze und Name darauf # blieben sonst so hell wie bei einer werfbaren. btn.modulate = Color(1, 1, 1, 1.0 if not btn.disabled else 0.4) # Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber # auch dann kommen, wenn gerade nicht geworfen werden darf. btn.mouse_filter = Control.MOUSE_FILTER_STOP btn.pressed.connect(_pick.bind(id)) hand_box.add_child(btn) _info_an(btn, id, false, hand_panel) var v := VBoxContainer.new() v.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) v.offset_top = 8 v.offset_bottom = -6 v.mouse_filter = Control.MOUSE_FILTER_IGNORE v.add_theme_constant_override("separation", 4) btn.add_child(v) var ic := MuenzIcon.new(id, col, 72.0) ic.size_flags_horizontal = Control.SIZE_SHRINK_CENTER v.add_child(ic) var name_l := _label(String(b.name), 14, col) name_l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name_l.mouse_filter = Control.MOUSE_FILTER_IGNORE v.add_child(name_l) ## Der Topf: was diese Runde geworfen wurde und wem es gehört, wenn er ## das Aufdecken gewinnt. Darunter, was gerade auf einem selbst liegt. ## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat. func _zeichne_mitte(st: Dictionary, my_seat: int) -> void: var boosts: Array = st.get("boosts", []) # Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau. var neu := {} for m in boosts: if int(m.get("nr", 0)) > int(neu.get("nr", 0)): neu = m _pruefe_wurf(neu, st) var zaehlung: Dictionary = st.get("zaehlung", {}) var sig := "%s|%s|%s" % [boosts, zaehlung, st.must_bid] if sig == _mitte_sig: return _mitte_sig = sig _clear(boosts_box) boosts_box.add_child(_label("Topf" if not boosts.is_empty() else "Topf: leer", 13, DIM)) for m in boosts: var id := String(m.get("id", "")) var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): continue var wer := int(m.get("seat", 0)) var line := HBoxContainer.new() line.add_theme_constant_override("separation", 5) # Der Container muss die Maus annehmen, sonst gibt es kein Überfahren. line.mouse_filter = Control.MOUSE_FILTER_STOP boosts_box.add_child(line) _info_an(line, id, true) line.add_child(MuenzIcon.new(id, _kind_col(b), 22.0)) line.add_child(_label(String(b.get("name", "")), 14, _kind_col(b))) if bool(m.get("stark", false)): line.add_child(_label("· Wappen", 14, ACCENT)) if wer < st.seats.size(): line.add_child(_label("von " + _short(String(st.seats[wer].name)), 13, ACCENT if wer == my_seat else DIM)) # Ergebnis vom Zählen: so oft liegt die Augenzahl am Tisch. for f in zaehlung: var line := HBoxContainer.new() line.add_theme_constant_override("separation", 5) boosts_box.add_child(line) var von := int(zaehlung[f][0]) var bis := int(zaehlung[f][1]) var text := "Am Tisch: %d ×" % von if von == bis else "Am Tisch: %d–%d ×" % [von, bis] line.add_child(_label(text, 14, ACCENT)) line.add_child(DieIcon.new(int(f), 18.0)) if st.must_bid: boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT)) ## Die letzten Ansagen der Runde, die jüngste unten und hervorgehoben. func _zeichne_verlauf(st: Dictionary, my_seat: int) -> void: var verlauf: Array = st.get("verlauf", []) var sig := str(verlauf) if sig == _verlauf_sig: return _verlauf_sig = sig _clear(verlauf_box) if verlauf.is_empty(): return verlauf_box.add_child(_label("Verlauf", 13, DIM)) var ab := maxi(0, verlauf.size() - 6) for i in range(ab, verlauf.size()): var e: Dictionary = verlauf[i] var wer := int(e.get("seat", 0)) if wer >= st.seats.size(): continue var letzte: bool = i == verlauf.size() - 1 var col := ACCENT if letzte else (Color(0.85, 0.87, 0.92) if wer != my_seat else Color(0.95, 0.85, 0.55)) var line := HBoxContainer.new() line.add_theme_constant_override("separation", 5) verlauf_box.add_child(line) var nm := _label(_short(String(st.seats[wer].name)), 13, col) nm.custom_minimum_size = Vector2(64, 0) line.add_child(nm) line.add_child(_label("%d ×" % int(e.get("count", 0)), 13, col)) line.add_child(DieIcon.new(int(e.get("face", 1)), 16.0)) if bool(e.get("erhoeht", false)): line.add_child(_label("erhöht", 12, DIM)) ## Erklärung zu einer Münze, sobald die Maus daraufliegt. `unter` legt sie ## unter das Feld statt darüber - oben links wäre sonst kein Platz. `anker` ## ist, woran sie sich ausrichtet: beim Vorrat das ganze Feld, sonst läge sie ## halb über dessen Überschrift. func _info_an(ctrl: Control, id: String, unter: bool, anker: Control = null) -> void: var an: Control = anker if anker != null else ctrl ctrl.mouse_entered.connect(_info_zeigen.bind(ctrl, id, unter, an)) ctrl.mouse_exited.connect(_info_weg.bind(ctrl)) func _info_zeigen(ctrl: Control, id: String, unter: bool, anker: Control) -> void: var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): return _info_ctrl = ctrl info_panel.visible = false info_name.text = String(b.name) info_desc.text = String(b.desc) info_stark.text = "Wappen: %s" % b.get("stark", "") # Die Höhe steht erst nach dem Umbruch fest, deshalb ein Bild später # setzen. Bis dahin bleibt das Feld unsichtbar, sonst springt es. await get_tree().process_frame if _info_ctrl != ctrl or not is_instance_valid(ctrl) or not is_instance_valid(anker): return info_panel.reset_size() var gr := info_panel.size var r := anker.get_global_rect() var y: float = r.position.y + r.size.y + 8.0 if unter else r.position.y - gr.y - 8.0 info_panel.position = Vector2( clampf(r.position.x, 8.0, maxf(8.0, root.size.x - gr.x - 8.0)), clampf(y, 8.0, maxf(8.0, root.size.y - gr.y - 8.0))) info_panel.visible = true func _info_weg(ctrl: Control) -> void: if _info_ctrl != ctrl: return _info_ctrl = null info_panel.visible = false func _kind_col(b: Dictionary) -> Color: return Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5) ## Jeder Wurf hat eine laufende Nummer. Steigt sie, ist eine neue Münze ## gefallen und wird gezeigt. Zu Rundenbeginn ist die Mitte leer, dann faengt ## die Zählung wieder bei null an. func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void: var nr := int(neu.get("nr", 0)) if nr == 0: _letzter_wurf = 0 return if nr <= _letzter_wurf: return _letzter_wurf = nr var seat := int(neu.get("seat", 0)) if seat >= st.seats.size(): return _zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)), _short(String(st.seats[seat].name))) ## 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_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_interval(2.6) _wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false) ## 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 _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 return _pending_boost = id boost_title.text = "%s - welchen Würfel?" % BoosterDB.get_booster(id).name _clear(boost_cards) _clear(target_row) boost_slot.visible = true for i in _my_values.size(): var b := Button.new() b.custom_minimum_size = Vector2(56, 56) b.pressed.connect(_pick_target.bind(i)) var ic := DieIcon.new(int(_my_values[i])) ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ic.offset_left = 10 ic.offset_top = 10 ic.offset_right = -10 ic.offset_bottom = -10 b.add_child(ic) target_row.add_child(b) target_row.visible = true func _pick_target(idx: int) -> void: booster_chosen.emit(_pending_boost, idx) boost_slot.visible = false target_row.visible = false