saumwacht-godot/scripts/luneraria/ui/components/hero_detail.gd
Martin Stickelbroeck 1b540f2a55 G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity
Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:59:55 +02:00

347 lines
17 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.

class_name LuneHeroDetail
extends VBoxContainer
## Hero window content. Without a hero: the three heroes with portrait, role, real stats and
## price to recruit one. With a hero: portrait, level and experience, lane order, and tabs for
## values and ability (from the rules' definitions, including level, items and talents), the
## three item slots with the item shop, and the two talent tiers. Only shows data; every
## change is a command through the game.
signal command_requested(command: Dictionary)
const TAB_VALUES: int = 0
const TAB_ITEMS: int = 1
const TAB_TALENTS: int = 2
var tab: int = TAB_VALUES
var _model: LuneHudModel
var _signature: String = ""
var item_cards: Dictionary = {}
var recruit_buttons: Dictionary = {}
var lane_buttons: Dictionary = {}
var tabs: LuneTabBar
func _init() -> void:
add_theme_constant_override("separation", 10)
size_flags_vertical = Control.SIZE_EXPAND_FILL
func show_tab(index: int) -> void:
tab = index
_signature = ""
if _model != null:
apply(_model)
func title_text(model: LuneHudModel) -> String:
var hero: Dictionary = model.own_hero()
if hero.is_empty():
return "HELD ANWERBEN"
return "HELD · %s" % str(LuneContent.HEROES[hero.hero_id].name).to_upper()
func apply(model: LuneHudModel) -> void:
_model = model
var hero: Dictionary = model.own_hero()
var signature: String = "%s|%d|%s|%s|%d|%s|%d" % [str(hero), model.own_gold(), model.planning(), model.locked(), tab, model.state.grotto_available(), model.state.round_number]
if signature == _signature:
return
_signature = signature
for child: Node in get_children():
child.free()
item_cards.clear()
recruit_buttons.clear()
lane_buttons.clear()
if hero.is_empty():
_recruit(model)
else:
_hero(model, hero)
func _can_act(model: LuneHudModel) -> bool:
return model.planning() and not model.locked()
func _why_not(model: LuneHudModel) -> String:
if not model.planning():
return "Nur in der Planung."
if model.locked():
return "Bereit gemeldet – die Planung ist verbindlich."
return ""
# ---------------------------------------------------------------- recruiting
func _recruit(model: LuneHudModel) -> void:
LuneUiTheme.label("Jeder Spieler führt einen Helden. Er kämpft auf seiner Lane mit, sammelt Erfahrung aus Gefechten in seiner Nähe und aus jeder Runde, trägt drei Items und wählt ab Stufe 3 und 6 je ein Talent.", "HudMuted", self).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
row.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(row)
var gold: int = model.own_gold()
for hero_id: String in LuneContent.HEROES:
var spec: Dictionary = LuneContent.HEROES[hero_id]
var panel := PanelContainer.new()
panel.theme_type_variation = "HudPanelTight"
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(panel)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 5)
panel.add_child(column)
var portrait := LunePortraitRect.new()
portrait.custom_minimum_size = Vector2(0, 170)
column.add_child(portrait)
portrait.show_model(str(spec.model), true)
LuneUiTheme.label(str(spec.name), "HudTitle", column)
LuneUiTheme.label(str(spec.title), "HudMuted", column)
var role := HBoxContainer.new()
role.add_theme_constant_override("separation", 5)
column.add_child(role)
var definition: Dictionary = model.hero_definition({"hero_id": hero_id, "xp": 0, "items": [], "talents": []})
LuneGlyph.make(LuneStats.role_glyph(str(definition.role)), LuneUiTheme.BRONZE_LIGHT, 15.0, role)
LuneUiTheme.label(str(spec.role), "HudText", role)
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 6)
column.add_child(grid)
var rows: Array = LuneStats.rows(definition)
for entry: Array in rows.slice(0, 5):
LuneGlyph.make(str(entry[0]), LuneUiTheme.MUTED, 13.0, grid)
LuneUiTheme.label(str(entry[1]), "HudMuted", grid)
LuneUiTheme.label(str(entry[2]), "HudText", grid)
var summary := LuneUiTheme.label(str(spec.summary), "HudText", column)
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
summary.size_flags_vertical = Control.SIZE_EXPAND_FILL
var cost: int = int(spec.cost)
var buy := LuneUiTheme.button("ANWERBEN · %d G" % cost, "HudPrimary", column)
var why: String = _why_not(model)
if why.is_empty() and gold < cost:
why = "Es fehlen %d Gold." % (cost - gold)
buy.disabled = not why.is_empty()
buy.pressed.connect(func() -> void: command_requested.emit({"type": "hero_buy", "hero_id": hero_id}))
recruit_buttons[hero_id] = buy
if not why.is_empty():
LuneUiTheme.label(why, "HudMuted", column).add_theme_color_override("font_color", LuneUiTheme.DANGER if gold < cost else LuneUiTheme.MUTED)
LuneUiTheme.label("Vorläufige Rolle des vorhandenen Modells.", "HudMuted", column)
# ---------------------------------------------------------------- hero
func _hero(model: LuneHudModel, hero: Dictionary) -> void:
var spec: Dictionary = LuneContent.HEROES[hero.hero_id]
var definition: Dictionary = model.hero_definition(hero)
var can_act: bool = _can_act(model)
var body := HBoxContainer.new()
body.add_theme_constant_override("separation", 18)
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(body)
var left := VBoxContainer.new()
left.add_theme_constant_override("separation", 6)
left.custom_minimum_size.x = 290
body.add_child(left)
var portrait := LunePortraitRect.new()
portrait.custom_minimum_size = Vector2(290, 230)
left.add_child(portrait)
portrait.show_model(str(spec.model), true)
LuneUiTheme.label(str(spec.title), "HudMuted", left)
var role := HBoxContainer.new()
role.add_theme_constant_override("separation", 6)
left.add_child(role)
LuneGlyph.make(LuneStats.role_glyph(str(definition.role)), LuneUiTheme.BRONZE_LIGHT, 16.0, role)
LuneUiTheme.label(str(spec.role), "HudText", role)
var xp: int = int(hero.xp)
var level: int = LuneContent.level_for(xp)
var level_row := HBoxContainer.new()
level_row.add_theme_constant_override("separation", 6)
left.add_child(level_row)
LuneGlyph.make("star", LuneUiTheme.HERO, 16.0, level_row)
LuneUiTheme.label("STUFE %d / %d" % [level, LuneContent.LEVEL_XP.size()], "HudValue", level_row)
var bar := LuneHpBar.new()
bar.custom_minimum_size = Vector2(0, 8)
bar.fill = LuneUiTheme.BRONZE_LIGHT
left.add_child(bar)
var top: bool = level >= LuneContent.LEVEL_XP.size()
var low: int = LuneContent.LEVEL_XP[level - 1]
var high: int = LuneContent.LEVEL_XP[mini(level, LuneContent.LEVEL_XP.size() - 1)]
bar.fraction = 1.0 if top else float(xp - low) / maxf(1.0, float(high - low))
LuneUiTheme.label(("%d / %d EP · noch %d bis Stufe %d" % [xp, high, high - xp, level + 1]) if not top else "%d EP · Höchststufe" % xp, "HudMuted", left)
LuneUiTheme.label("EINSATZ", "HudCaps", left)
var lanes := HBoxContainer.new()
lanes.add_theme_constant_override("separation", 4)
left.add_child(lanes)
for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]:
var title: String = "GROTTE" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane].to_upper()
var button := LuneUiTheme.button(title, "HudToggle", lanes)
button.toggle_mode = true
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.set_pressed_no_signal(str(hero.lane) == lane)
button.disabled = not can_act or (lane == LuneMatch.GROTTO and not model.state.grotto_available())
var uid: String = str(hero.uid)
button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane}))
lane_buttons[lane] = button
if str(hero.lane) == LuneMatch.GROTTO:
var warning := LuneUiTheme.label("Der Held fehlt seiner Lane in der nächsten Kampfphase.", "HudText", left)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
warning.add_theme_color_override("font_color", LuneUiTheme.WARN)
if bool(hero.get("fresh", false)) and model.planning():
var refund: int = int(hero.invested)
for item_id: String in hero.items:
refund += int(LuneContent.ITEMS[item_id].cost)
var cancel := LuneUiTheme.button("Held zurückgeben +%d" % refund, "HudButton", left)
cancel.disabled = not can_act
cancel.pressed.connect(func() -> void: command_requested.emit({"type": "hero_cancel"}))
var right := VBoxContainer.new()
right.add_theme_constant_override("separation", 8)
right.size_flags_horizontal = Control.SIZE_EXPAND_FILL
body.add_child(right)
tabs = LuneTabBar.new()
right.add_child(tabs)
tabs.add_tab("WERTE & FÄHIGKEIT")
tabs.add_tab("ITEMS %d/%d" % [(hero.items as Array).size(), LuneContent.ITEM_SLOTS])
var open_tier: int = (hero.talents as Array).size()
var talent_ready: bool = open_tier < LuneContent.TALENT_LEVELS.size() and level >= LuneContent.TALENT_LEVELS[open_tier]
tabs.add_tab("TALENTE" + (" ★" if talent_ready else ""))
tabs.select(tab)
tabs.tab_changed.connect(show_tab)
var page := VBoxContainer.new()
page.add_theme_constant_override("separation", 8)
page.size_flags_vertical = Control.SIZE_EXPAND_FILL
right.add_child(page)
match tab:
TAB_ITEMS:
_items(model, hero, level, page)
TAB_TALENTS:
_talents(model, hero, level, page)
_:
_values(hero, definition, spec, page)
func _values(hero: Dictionary, definition: Dictionary, spec: Dictionary, page: VBoxContainer) -> void:
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 3)
page.add_child(grid)
var rows: Array = LuneStats.rows(definition, true)
rows.append(["sword", "Schaden/Sekunde", "≈ %s (ohne Fähigkeit)" % LuneUiTheme.decimal(LuneStats.dps(definition))])
for entry: Array in rows:
LuneGlyph.make(str(entry[0]), LuneUiTheme.MUTED, 15.0, grid)
LuneUiTheme.label(str(entry[1]), "HudMuted", grid)
LuneUiTheme.label(str(entry[2]), "HudText", grid)
var level: int = int(definition.level)
var base: Dictionary = spec.def
var growth := LuneUiTheme.label("Grundwerte %s Leben, %s Schaden; je Stufe +%d %% Leben und +%d %% Schaden (jetzt Stufe %d)." % [
LuneUiTheme.number(base.max_hp), LuneUiTheme.decimal(base.damage), int(LuneContent.HP_PER_LEVEL * 100.0), int(LuneContent.DAMAGE_PER_LEVEL * 100.0), level], "HudMuted", page)
growth.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
LuneUiTheme.label("FÄHIGKEIT", "HudCaps", page)
var ability := LuneUiTheme.label(str(DataCatalog.get_unit(str(spec.model)).get("ability_description", "")), "HudText", page)
ability.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var chosen: PackedStringArray = []
for tier: int in range((hero.talents as Array).size()):
for option: Dictionary in LuneContent.TALENTS[hero.hero_id][tier]:
if option.id == hero.talents[tier]:
chosen.append("%s: %s" % [option.name, option.text])
if not chosen.is_empty():
LuneUiTheme.label("TALENTE", "HudCaps", page)
var talents := LuneUiTheme.label("\n".join(chosen), "HudText", page)
talents.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var xp := LuneUiTheme.label("Erfahrung: Tode von Gegnern in %d m (Monster 12 + Welle, Champion 30 + 2 × Welle, Gebäude %d, Boss %d) und je Kampf %d + %d × Welle." % [
int(LuneContent.XP_RADIUS), LuneContent.BUILDING_XP, LuneContent.BOSS_XP, LuneContent.XP_PARTICIPATION, LuneContent.XP_PER_ROUND], "HudMuted", page)
xp.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
func _items(model: LuneHudModel, hero: Dictionary, level: int, page: VBoxContainer) -> void:
var can_act: bool = _can_act(model)
var items: Array = hero.items
var slots := HBoxContainer.new()
slots.add_theme_constant_override("separation", 10)
page.add_child(slots)
for index: int in range(LuneContent.ITEM_SLOTS):
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 3)
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slots.add_child(box)
var slot := LuneItemSlot.new(true)
slot.custom_minimum_size = Vector2(46, 46)
slot.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
box.add_child(slot)
if index < items.size():
var item_id: String = items[index]
var item: Dictionary = LuneContent.ITEMS[item_id]
slot.set_item(item_id, LuneItemSlot.State.EQUIPPED)
LuneTooltip.attach(slot, func() -> Dictionary: return {"title": str(item.name), "glyph": item_id, "glyph_color": LuneItemSlot.color_of(item_id), "text": str(item.text)})
LuneUiTheme.label(str(item.name), "HudText", box)
var fresh: bool = (hero.get("fresh_items", []) as Array).has(item_id)
var refund: int = int(item.cost) if fresh else floori(float(item.cost) * LuneContent.ITEM_REFUND)
var sell := LuneUiTheme.button("Verkaufen +%d" % refund, "HudButton", box)
sell.disabled = not can_act
sell.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
sell.pressed.connect(func() -> void: command_requested.emit({"type": "item_sell", "item_id": item_id}))
else:
slot.set_item("", LuneItemSlot.State.EMPTY)
LuneUiTheme.label("Platz %d frei" % (index + 1), "HudMuted", box)
LuneUiTheme.label("ITEMS · einzigartig je Held, bleiben dauerhaft, Rückgabe %d %%" % int(LuneContent.ITEM_REFUND * 100.0), "HudCaps", page)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 8)
grid.add_theme_constant_override("v_separation", 8)
page.add_child(grid)
var gold: int = model.own_gold()
for item_id: String in LuneContent.ITEMS:
var item: Dictionary = LuneContent.ITEMS[item_id]
var card := LuneItemSlot.new(false)
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(card)
var state: int = LuneItemSlot.State.AVAILABLE
if items.has(item_id):
state = LuneItemSlot.State.UNIQUE
elif not can_act:
state = LuneItemSlot.State.LOCKED
elif level < int(item.get("min_level", 1)):
state = LuneItemSlot.State.INCOMPATIBLE
elif items.size() >= LuneContent.ITEM_SLOTS:
state = LuneItemSlot.State.FULL
elif gold < int(item.cost):
state = LuneItemSlot.State.UNAFFORDABLE
card.set_item(item_id, state)
item_cards[item_id] = card
var uid: String = str(hero.uid)
card.pressed.connect(func() -> void:
if card.state == LuneItemSlot.State.AVAILABLE:
command_requested.emit({"type": "item_buy", "item_id": item_id, "uid": uid}))
var reasons: Dictionary = {LuneItemSlot.State.UNIQUE: "Dein Held trägt es bereits; Items sind einzigartig.",
LuneItemSlot.State.LOCKED: _why_not(model), LuneItemSlot.State.INCOMPATIBLE: "Erfordert Heldenstufe %d." % int(item.get("min_level", 1)),
LuneItemSlot.State.FULL: "Alle %d Plätze belegt; verkaufe zuerst ein Item." % LuneContent.ITEM_SLOTS,
LuneItemSlot.State.UNAFFORDABLE: "Es fehlen %d Gold." % (int(item.cost) - gold), LuneItemSlot.State.AVAILABLE: "Klick: kaufen und ausrüsten."}
var why: String = str(reasons.get(state, ""))
LuneTooltip.attach(card, func() -> Dictionary: return {"title": "%s · %d Gold" % [item.name, int(item.cost)], "glyph": item_id,
"glyph_color": LuneItemSlot.color_of(item_id), "text": str(item.text), "note": why})
func _talents(model: LuneHudModel, hero: Dictionary, level: int, page: VBoxContainer) -> void:
var can_act: bool = _can_act(model)
var talents: Array = hero.talents
for tier: int in range(LuneContent.TALENT_LEVELS.size()):
var required: int = LuneContent.TALENT_LEVELS[tier]
var state_text: String = "gewählt" if tier < talents.size() else ("jetzt wählbar" if level >= required and tier == talents.size() else "ab Stufe %d" % required)
LuneUiTheme.label("TALENT %d · STUFE %d · %s" % [tier + 1, required, state_text.to_upper()], "HudCaps", page)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
page.add_child(row)
var options: Array = LuneContent.TALENTS[hero.hero_id][tier]
for choice: int in range(options.size()):
var option: Dictionary = options[choice]
var card := Button.new()
card.theme_type_variation = "HudCard"
card.toggle_mode = true
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.custom_minimum_size = Vector2(0, 96)
card.focus_mode = Control.FOCUS_ALL
row.add_child(card)
var column := VBoxContainer.new()
column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
column.offset_left = 10
column.offset_right = -10
column.offset_top = 8
column.offset_bottom = -8
column.mouse_filter = Control.MOUSE_FILTER_IGNORE
card.add_child(column)
LuneUiTheme.label(str(option.name), "HudValue", column)
var text := LuneUiTheme.label(str(option.text), "HudMuted", column)
text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var chosen: bool = tier < talents.size() and talents[tier] == option.id
card.set_pressed_no_signal(chosen)
card.disabled = not (can_act and tier == talents.size() and level >= required) and not chosen
var command: Dictionary = {"type": "talent", "tier": tier, "choice": choice}
card.pressed.connect(func() -> void:
card.set_pressed_no_signal(chosen)
if not chosen:
command_requested.emit(command))
LuneUiTheme.label("Die Wahl ist endgültig; jede Stufe bietet zwei Richtungen (Einzelziel oder Fläche, Schutz oder Druck).", "HudMuted", page).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART