Files
pirate-dice/scripts/hud.gd
2026-09-05 23:19:46 +02:00

508 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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