extends CanvasLayer ## Komplettes Spiel-UI (programmatisch aufgebaut). signal bid_pressed(count: int, face: int) signal challenge_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) const PLAYERS_W := 300.0 ## feste Breite der Übersicht oben rechts var root: Control var players_box: VBoxContainer var bid_row: HBoxContainer var hint_label: Label var banner_label: Label var log_label: Label var count_spin: SpinBox var face_buttons: Array[Button] = [] var bid_btn: Button var challenge_btn: Button var action_panel: PanelContainer var boost_slot: CenterContainer var pause_slot: Control var pause_hint: Label var boost_panel: PanelContainer var boost_cards: HBoxContainer var boost_title: Label var timer_bid: Label var timer_boost: Label var target_row: HBoxContainer var _face := 1 ## 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) -> 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) _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 _my_values: Array = [] var _my_seat := 0 func _ready() -> void: 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 - PLAYERS_W, 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) 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) # --- Banner in der Bildmitte var banner_slot := _slot(0, 0, 1, 0, 140, 126, -140, 210) banner_label = _label("", 28, ACCENT) banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER banner_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART banner_label.custom_minimum_size = Vector2(1000, 0) banner_slot.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 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) 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) 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("Augenzahl", 15)) var frow := HBoxContainer.new() frow.add_theme_constant_override("separation", 4) av.add_child(frow) for f in range(1, 7): var b := Button.new() b.toggle_mode = true b.custom_minimum_size = Vector2(40, 40) 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(0, 36) 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.pressed.connect(_on_challenge) av.add_child(challenge_btn) # --- Booster-Overlay 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 einen Booster", 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) func _on_quit() -> void: quit_pressed.emit() func _on_bid() -> void: bid_pressed.emit(int(count_spin.value), _face) func _on_challenge() -> void: challenge_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) 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 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() 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 nm.clip_text = true nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS row.add_child(nm) if not s.alive: row.add_child(_label("ausgeschieden", 14, DIM)) continue for v: int in _seat_dice(st, i, my_seat): row.add_child(DieIcon.new(v, 22.0)) for c in bid_row.get_children(): c.queue_free() if st.bid.is_empty(): bid_row.add_child(_label("Noch kein Gebot", 26, ACCENT)) 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)) 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", "")) if int(st.min_raise) > 1: hints.append("Störfeuer: du musst um mind. %d erhöhen" % st.min_raise) if st.must_bid: hints.append("Maulkorb: du musst bieten") hint_label.text = " | ".join(hints) 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 _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)) 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 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) timer_boost.text = "Noch %s - danach wird zufällig gewählt" % 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. 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 if peek.has(seat): var idx := int(peek[seat][0]) if idx >= 0 and idx < out.size(): out[idx] = int(peek[seat][1]) return out func set_banner(text: String) -> void: banner_label.text = text # ---------------------------------------------------------------- Booster func show_booster_offer(ids: Array, values: Array) -> void: _my_values = values _pending_boost = "" target_row.visible = false boost_title.text = "Wähle einen Booster" for c in boost_cards.get_children(): c.queue_free() for id: String in ids: var b: Dictionary = BoosterDB.get_booster(id) var card := _panel(Color(0.10, 0.12, 0.17, 0.98)) card.custom_minimum_size = Vector2(210, 170) var v := VBoxContainer.new() v.add_theme_constant_override("separation", 6) card.add_child(v) var kind_col := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5) v.add_child(_label("BUFF" if b.kind == BoosterDB.Kind.BUFF else "DEBUFF", 12, kind_col)) v.add_child(_label(b.name, 20, ACCENT)) var d := _label(b.desc, 14, Color(0.8, 0.82, 0.88)) d.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART d.custom_minimum_size = Vector2(190, 60) v.add_child(d) var btn := Button.new() btn.text = "Wählen" btn.pressed.connect(_pick.bind(id)) v.add_child(btn) boost_cards.add_child(card) boost_slot.visible = true func _pick(id: String) -> void: 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 for c in boost_cards.get_children(): c.queue_free() for c in target_row.get_children(): c.queue_free() 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