first commit

This commit is contained in:
team3
2026-08-29 11:42:30 +02:00
commit 9410d18ea5
47 changed files with 4382 additions and 0 deletions

422
scripts/ui/BoardView3D.gd Normal file
View File

@@ -0,0 +1,422 @@
class_name BoardView3D
extends Node3D
## 3D-Arena: 7x8 Hexfelder als Zylinder-Primitive, Einheiten als Box/Kegel/Kugel.
## Reine Darstellung, die Spiellogik liegt in Combat und PlayerState.
signal hex_clicked(hex: Vector2i, button: int)
const COLS := Combat.ARENA_COLS
const ROWS := Combat.ARENA_ROWS
const TILE := 1.0
const TILE_H := 0.18
const MOVE_LERP := 8.0
const BENCH_GAP := 1.62 ## Abstand der Bankplätze
const BENCH_OFFSET := 1.95 ## Abstand der Bank hinter der letzten Reihe
var game: Game = null
var camera: Camera3D = null
var selected_slot := {} ## {"kind": "board"/"bench", ...}
var hover_slot := {}
var drag_active := false
var drag_avatar_id := -1
var drag_world := Vector3.ZERO
## Was das 2D-Overlay über die Einheiten zeichnen soll.
var draw_units: Array = []
var _tiles := {} ## Vector2i -> MeshInstance3D
var _tile_mats := {} ## Vector2i -> StandardMaterial3D
var _bench_tiles := {} ## int -> MeshInstance3D
var _bench_mats := {}
var _bench_z := 0.0
var _units := {} ## key -> Node3D
var _origin := Vector3.ZERO
var _tile_mesh: CylinderMesh
var _ring_mesh: TorusMesh
func _ready() -> void:
_tile_mesh = CylinderMesh.new()
_tile_mesh.radial_segments = 6
_tile_mesh.rings = 1
_tile_mesh.top_radius = TILE * 0.92
_tile_mesh.bottom_radius = TILE * 0.92
_tile_mesh.height = TILE_H
_ring_mesh = TorusMesh.new()
_ring_mesh.inner_radius = 0.50
_ring_mesh.outer_radius = 0.66
_ring_mesh.ring_segments = 8
_ring_mesh.rings = 24
_build_tiles()
_build_bench()
set_process(true)
# --- Geometrie -------------------------------------------------------------
func _build_tiles() -> void:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for r in ROWS:
for c in COLS:
var p := Hex.to_pixel(Vector2i(c, r), TILE)
lo = Vector2(minf(lo.x, p.x), minf(lo.y, p.y))
hi = Vector2(maxf(hi.x, p.x), maxf(hi.y, p.y))
var mid := (lo + hi) * 0.5
_origin = Vector3(mid.x, 0.0, mid.y)
for r in ROWS:
for c in COLS:
var h := Vector2i(c, r)
var mi := MeshInstance3D.new()
mi.mesh = _tile_mesh
mi.position = world_pos(h)
var m := StandardMaterial3D.new()
m.roughness = 0.9
m.metallic = 0.0
mi.material_override = m
add_child(mi)
_tiles[h] = mi
_tile_mats[h] = m
func _build_bench() -> void:
var max_z := -INF
for key in _tiles:
max_z = maxf(max_z, world_pos(key).z)
_bench_z = max_z + TILE * BENCH_OFFSET
var pad := CylinderMesh.new()
pad.radial_segments = 6
pad.rings = 1
pad.top_radius = 0.62
pad.bottom_radius = 0.62
pad.height = 0.12
for i in Data.BENCH_SLOTS:
var mi := MeshInstance3D.new()
mi.mesh = pad
mi.position = bench_pos(i)
var m := StandardMaterial3D.new()
m.roughness = 0.9
mi.material_override = m
add_child(mi)
_bench_tiles[i] = mi
_bench_mats[i] = m
func bench_pos(index: int) -> Vector3:
var half := float(Data.BENCH_SLOTS - 1) * 0.5
return Vector3((float(index) - half) * BENCH_GAP, 0.0, _bench_z)
## Feld oder Bankplatz unter einem Weltpunkt.
func slot_at_world(p: Vector3) -> Dictionary:
var flat := Vector2(p.x, p.z)
var best := {}
var best_d := TILE * 0.98
for key in _tiles:
var w := world_pos(key)
var d := flat.distance_to(Vector2(w.x, w.z))
if d < best_d:
best_d = d
best = {"kind": "board", "hex": to_board(key), "arena": key}
for i in Data.BENCH_SLOTS:
var b := bench_pos(i)
var d2 := flat.distance_to(Vector2(b.x, b.z))
if d2 < best_d:
best_d = d2
best = {"kind": "bench", "index": i}
return best
## Bildschirmposition eines Feldes oder Bankplatzes (für Tests und Anzeige).
func slot_screen(slot: Dictionary) -> Vector2:
if camera == null:
return Vector2.ZERO
return camera.unproject_position(to_global(slot_world(slot)) + Vector3(0.0, TILE_H * 0.5, 0.0))
func slot_world(slot: Dictionary) -> Vector3:
if slot.is_empty():
return Vector3.ZERO
if slot["kind"] == "bench":
return bench_pos(slot["index"])
return world_pos(from_board(slot["hex"]))
func world_pos(hex: Vector2i) -> Vector3:
var p := Hex.to_pixel(hex, TILE)
return Vector3(p.x, 0.0, p.y) - _origin
## Nächstes Hexfeld zu einem Weltpunkt, oder (-1,-1).
func hex_at_world(p: Vector3) -> Vector2i:
var best := Vector2i(-1, -1)
var best_d := TILE * 0.98
for key in _tiles:
var h: Vector2i = key
var w := world_pos(h)
var d := Vector2(p.x, p.z).distance_to(Vector2(w.x, w.z))
if d < best_d:
best_d = d
best = h
return best
## Bildschirmposition eines Feldes (für Overlay und Tests).
func screen_pos(hex: Vector2i, height: float = 0.0) -> Vector2:
if camera == null:
return Vector2.ZERO
return camera.unproject_position(world_pos(hex) + Vector3(0.0, height, 0.0))
## Acht Eckpunkte des Spielfeldes, oben mit Platz für hohe Einheiten.
func corner_points() -> Array:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for key in _tiles:
var w := world_pos(key)
lo = Vector2(minf(lo.x, w.x), minf(lo.y, w.z))
hi = Vector2(maxf(hi.x, w.x), maxf(hi.y, w.z))
for i in Data.BENCH_SLOTS:
var b := bench_pos(i)
lo = Vector2(minf(lo.x, b.x), minf(lo.y, b.z))
hi = Vector2(maxf(hi.x, b.x), maxf(hi.y, b.z))
lo -= Vector2(TILE, TILE)
hi += Vector2(TILE, TILE)
var out := []
for y in [0.0, 1.5]:
out.append(Vector3(lo.x, y, lo.y))
out.append(Vector3(hi.x, y, lo.y))
out.append(Vector3(lo.x, y, hi.y))
out.append(Vector3(hi.x, y, hi.y))
return out
static func to_board(h: Vector2i) -> Vector2i:
return Vector2i(h.x, h.y - 4)
static func from_board(h: Vector2i) -> Vector2i:
return Vector2i(h.x, h.y + 4)
func is_own_half(h: Vector2i) -> bool:
return h.y >= 4 and h.y < ROWS and h.x >= 0 and h.x < COLS
# --- Bildaufbau ------------------------------------------------------------
func _process(delta: float) -> void:
if game == null:
return
_update_tiles()
_update_units(delta)
func _update_tiles() -> void:
for key in _tiles:
var h: Vector2i = key
var own: bool = h.y >= 4
var slot := {"kind": "board", "hex": to_board(h)}
var col := Color(0.20, 0.23, 0.31) if own else Color(0.28, 0.18, 0.20)
var emit := 0.0
if own and _is_hover(slot):
col = Color(0.42, 0.60, 0.82)
emit = 0.40
elif own and not drag_active and _same_slot(slot, selected_slot):
col = Color(0.35, 0.52, 0.75)
emit = 0.30
elif own and drag_active:
col = Color(0.24, 0.42, 0.29)
emit = 0.16
_paint(_tile_mats[h], col, emit)
for i in Data.BENCH_SLOTS:
var bslot := {"kind": "bench", "index": i}
var bcol := Color(0.24, 0.24, 0.30)
var bemit := 0.0
if _is_hover(bslot):
bcol = Color(0.48, 0.56, 0.72)
bemit = 0.40
elif not drag_active and _same_slot(bslot, selected_slot):
bcol = Color(0.38, 0.44, 0.60)
bemit = 0.28
elif drag_active:
bcol = Color(0.26, 0.40, 0.32)
bemit = 0.16
_paint(_bench_mats[i], bcol, bemit)
func _paint(m: StandardMaterial3D, col: Color, emit: float) -> void:
m.albedo_color = col
m.emission_enabled = emit > 0.0
m.emission = col
m.emission_energy_multiplier = emit
func _is_hover(slot: Dictionary) -> bool:
return game.phase == Game.Phase.PLANNING and _same_slot(slot, hover_slot)
static func _same_slot(a: Dictionary, b: Dictionary) -> bool:
if a.is_empty() or b.is_empty() or a["kind"] != b["kind"]:
return false
if a["kind"] == "bench":
return a["index"] == b["index"]
return a["hex"] == b["hex"]
func _update_units(delta: float) -> void:
var wanted := {}
draw_units.clear()
if game.phase == Game.Phase.COMBAT and game.player_combat != null:
var my_team := game.human_team_in_combat()
for u in game.player_combat.units:
if not u.alive:
continue
var key := "c%d" % u.get_instance_id()
wanted[key] = {
"avatar": u.avatar,
"world": world_pos(_mirror(u.pos, my_team)),
"hp": u.hp / u.max_hp,
"mana": u.mana / u.max_mana,
"side": 0 if u.team == my_team else 1,
"cast": u.cast_flash > 0.0,
"hit": u.hit_flash > 0.0,
}
else:
for bh in game.human.board:
var a: Avatar = game.human.board[bh]
wanted["a%d" % a.id] = {
"avatar": a, "world": world_pos(from_board(bh)), "hp": 1.0, "mana": 0.0,
"side": 0, "cast": false, "hit": false,
}
# Die Bank ist immer sichtbar, auch während des Kampfes.
for i in game.human.bench.size():
var ba = game.human.bench[i]
if ba == null:
continue
wanted["a%d" % ba.id] = {
"avatar": ba, "world": bench_pos(i), "hp": 1.0, "mana": 0.0,
"side": 0, "cast": false, "hit": false,
}
# Der gezogene Avatar folgt dem Zeiger.
var drag_key := ""
if drag_active and drag_avatar_id >= 0:
drag_key = "a%d" % drag_avatar_id
if wanted.has(drag_key):
wanted[drag_key]["world"] = drag_world
wanted[drag_key]["dragged"] = true
for key in _units.keys():
if not wanted.has(key):
_units[key].queue_free()
_units.erase(key)
for key in wanted:
var d: Dictionary = wanted[key]
var node: Node3D = _units.get(key)
if node == null:
node = _make_unit(d["avatar"], d["side"])
node.position = d["world"]
add_child(node)
_units[key] = node
var target: Vector3 = d["world"]
if d.get("dragged", false):
# Beim Ziehen ohne Glättung, sonst schleift der Avatar hinterher
# und sieht nicht angehoben aus.
node.position = target
else:
node.position = node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0))
var body: MeshInstance3D = node.get_meta("body")
var mat: StandardMaterial3D = body.material_override
var base: Color = d["avatar"].color()
mat.albedo_color = base.lightened(0.5) if d["hit"] else base
var dragged: bool = d.get("dragged", false)
mat.emission_energy_multiplier = 1.6 if d["cast"] else (0.45 if dragged else 0.12)
node.scale = Vector3.ONE * (1.12 if (d["cast"] or dragged) else 1.0)
draw_units.append({
"pos": node.position, "avatar": d["avatar"], "hp": d["hp"],
"mana": d["mana"], "side": d["side"], "cast": d["cast"],
})
## Knoten eines Avatars, oder null. Wird von den Tests genutzt.
func unit_node(avatar_id: int) -> Node3D:
return _units.get("a%d" % avatar_id)
func _mirror(p: Vector2i, my_team: int) -> Vector2i:
if my_team == 0:
return p
return Vector2i(COLS - 1 - p.x, ROWS - 1 - p.y)
func _make_unit(a: Avatar, side: int) -> Node3D:
var root := Node3D.new()
var body := MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.albedo_color = a.color()
mat.roughness = 0.45
mat.metallic = 0.15
mat.emission_enabled = true
mat.emission = a.color()
mat.emission_energy_multiplier = 0.12
body.material_override = mat
match a.role:
Data.Role.TANK:
var box := BoxMesh.new()
box.size = Vector3(0.78, 0.78, 0.78)
body.mesh = box
body.position.y = 0.48
Data.Role.DPS:
var cone := CylinderMesh.new()
cone.top_radius = 0.0
cone.bottom_radius = 0.46
cone.height = 1.05
cone.radial_segments = 12
body.mesh = cone
body.position.y = 0.62
_:
var sph := SphereMesh.new()
sph.radius = 0.42
sph.height = 0.84
sph.radial_segments = 16
sph.rings = 10
body.mesh = sph
body.position.y = 0.55
root.add_child(body)
root.set_meta("body", body)
# Bodenring: weiss = eigenes Team, rot = Gegner.
var ring := MeshInstance3D.new()
ring.mesh = _ring_mesh
ring.position.y = 0.10
var rm := StandardMaterial3D.new()
rm.albedo_color = Color(0.95, 0.95, 1.0) if side == 0 else Color(0.92, 0.32, 0.32)
rm.emission_enabled = true
rm.emission = rm.albedo_color
rm.emission_energy_multiplier = 0.5
ring.material_override = rm
root.add_child(ring)
# Zweit- und Drittelement als schmalere Ringe darüber.
for i in range(1, a.elements.size()):
var er := MeshInstance3D.new()
var em := TorusMesh.new()
em.inner_radius = 0.30 + 0.10 * float(i)
em.outer_radius = em.inner_radius + 0.07
em.ring_segments = 6
em.rings = 20
er.mesh = em
er.position.y = 0.18 + 0.16 * float(i)
var m2 := StandardMaterial3D.new()
m2.albedo_color = Data.ELEMENT_COLORS[a.elements[i]]
m2.emission_enabled = true
m2.emission = m2.albedo_color
m2.emission_energy_multiplier = 0.6
er.material_override = m2
root.add_child(er)
return root

View File

@@ -0,0 +1 @@
uid://cyxqqwolqywtm

53
scripts/ui/HudOverlay.gd Normal file
View File

@@ -0,0 +1,53 @@
class_name HudOverlay
extends Control
## Zeichnet Lebens- und Manabalken, Rolle und Sterne über die 3D-Einheiten.
var board: BoardView3D = null
var _font: Font
func _ready() -> void:
_font = ThemeDB.fallback_font
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
func _process(_delta: float) -> void:
queue_redraw()
func _draw() -> void:
if board == null or board.camera == null:
return
var cam := board.camera
for d in board.draw_units:
var world: Vector3 = d["pos"]
if cam.is_position_behind(world):
continue
var head := cam.unproject_position(world + Vector3(0.0, 1.35, 0.0))
var foot := cam.unproject_position(world + Vector3(0.0, 0.05, 0.0))
var a: Avatar = d["avatar"]
var side: int = d["side"]
# Balkenbreite nach Entfernung, damit nahe Einheiten größer wirken.
var bw: float = clampf(absf(foot.y - head.y) * 0.85, 26.0, 70.0)
var bx := head.x - bw * 0.5
var by := head.y - 4.0
draw_rect(Rect2(bx - 1.0, by - 1.0, bw + 2.0, 8.0), Color(0.05, 0.05, 0.07, 0.85))
draw_rect(Rect2(bx, by, bw * clampf(d["hp"], 0.0, 1.0), 6.0),
Color(0.35, 0.85, 0.42) if side == 0 else Color(0.92, 0.38, 0.38))
if d["mana"] > 0.0:
draw_rect(Rect2(bx, by + 7.0, bw, 4.0), Color(0.05, 0.05, 0.07, 0.85))
draw_rect(Rect2(bx, by + 7.0, bw * clampf(d["mana"], 0.0, 1.0), 4.0),
Color(0.35, 0.62, 0.95))
# Sterne
for s in a.star:
draw_circle(Vector2(bx + bw * 0.5 - 7.0 + 7.0 * float(s), by - 7.0), 2.6,
Color(1.0, 0.85, 0.25))
# Rolle
draw_string(_font, Vector2(bx + bw + 4.0, by + 7.0), Data.ROLE_SHORT[a.role],
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.9, 0.9, 0.95, 0.9))
if d["cast"]:
draw_arc(foot, bw * 0.55, 0.0, TAU, 24, Color(1.0, 0.95, 0.5, 0.9), 2.5)

View File

@@ -0,0 +1 @@
uid://bhbduae6n3bp5

843
scripts/ui/Main.gd Normal file
View File

@@ -0,0 +1,843 @@
extends Node3D
## Spielszene: 3D-Arena plus Bedienoberfläche.
## Alles wird im Code aus primitiven Meshes und Controls gebaut.
const TOP_H := 46.0
const LEFT_W := 290.0 ## links: Elemente, Einheit, Rundenlog
const RIGHT_W := 210.0 ## rechts: Spielerliste
const BOTTOM_H := 138.0
const TEXT_W := 244.0
const BG := Color(0.09, 0.10, 0.13)
const PANEL_BG := Color(0.14, 0.15, 0.19)
const SLOT_BG := Color(0.20, 0.21, 0.26)
const CAM_FOV := 44.0
const CAM_PITCH := 52.0 ## Grad über der Waagerechten
var game := Game.new()
var board: BoardView3D
var camera: Camera3D
var ui_layer: CanvasLayer
var ui_root: Control
var hud: HudOverlay
var combat_speed := 1.0
# Kopfzeile
var lbl_round: Label
var lbl_gold: Label
var lbl_hp: Label
var lbl_slots: Label
var lbl_streak: Label
var lbl_phase: Label
var btn_ready: Button
var btn_speed: Button
# Panels
var players_box: VBoxContainer
var traits_box: VBoxContainer
var info_box: VBoxContainer
var log_box: VBoxContainer
var shop_row: HBoxContainer
var lbl_fodder: Label
var btn_reroll: Button
var lbl_hint: Label
# Auswahl und Ziehen
var sel_slot := {}
var drag_src := {}
var drag_avatar: Avatar = null
var drag_moved := false
# Element-Auswahl
var picker: PopupPanel
var picker_grid: GridContainer
var picker_title: Label
var picker_sub: Label
var picker_cb: Callable
var overlay: Control
func _ready() -> void:
_build_world()
ui_layer = CanvasLayer.new()
add_child(ui_layer)
hud = HudOverlay.new()
hud.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
hud.board = board
ui_layer.add_child(hud)
ui_root = Control.new()
ui_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ui_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui_layer.add_child(ui_root)
_build_top()
_build_left()
_build_right()
_build_bottom()
_build_picker()
game.start()
board.game = game
_refresh()
set_process(true)
func _process(delta: float) -> void:
if game.phase == Game.Phase.COMBAT:
var before := game.phase
game.update_combat(delta * combat_speed)
_refresh_header()
if game.phase != before:
_refresh()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_on_mouse_move(event.position)
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_begin_drag(event.position)
else:
_end_drag(event.position)
return
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_on_right_click(event.position)
return
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
KEY_D:
_on_reroll()
KEY_SPACE, KEY_F:
_on_ready()
KEY_ESCAPE:
_cancel_drag()
sel_slot = {}
_refresh()
func _on_mouse_move(pos: Vector2) -> void:
var slot := _pick_slot(pos)
board.hover_slot = slot
if drag_avatar != null:
board.drag_world = _drag_point(pos, slot)
drag_moved = true
## Hebt einen Avatar an, wenn unter dem Zeiger einer liegt.
func _begin_drag(pos: Vector2) -> void:
if game.phase != Game.Phase.PLANNING:
return
var slot := _pick_slot(pos)
var a: Avatar = _avatar_at(slot)
if a == null:
sel_slot = {}
_refresh()
return
drag_src = slot
drag_avatar = a
drag_moved = false
board.drag_active = true
board.drag_avatar_id = a.id
board.drag_world = _drag_point(pos, slot)
# Während des Ziehens zeigt das Info-Panel den Avatar in der Hand.
sel_slot = slot
_refresh()
func _end_drag(pos: Vector2) -> void:
if drag_avatar == null:
return
var dst := _pick_slot(pos)
var src := drag_src
_cancel_drag()
if dst.is_empty() or BoardView3D._same_slot(src, dst):
# Kein Weg zurückgelegt: das war eine Auswahl.
sel_slot = src
_refresh()
return
if dst["kind"] == "board" and not board.is_own_half(BoardView3D.from_board(dst["hex"])):
_refresh()
return
if not game.human.move_slot(src, dst, game.round_no):
if dst["kind"] == "board":
_hint("Kaderplätze voll (%d)." % game.human.team_slots(game.round_no))
else:
sel_slot = dst
_refresh()
func _cancel_drag() -> void:
drag_avatar = null
drag_src = {}
board.drag_active = false
board.drag_avatar_id = -1
func _on_right_click(pos: Vector2) -> void:
if game.phase != Game.Phase.PLANNING:
return
_cancel_drag()
var slot := _pick_slot(pos)
if slot.is_empty():
return
if slot["kind"] == "bench":
game.human.sell_bench(slot["index"], game.pool)
else:
game.human.unplace(slot["hex"])
sel_slot = {}
_refresh()
func _avatar_at(slot: Dictionary):
if slot.is_empty():
return null
if slot["kind"] == "bench":
return game.human.bench[slot["index"]]
return game.human.board.get(slot["hex"])
# --- Aufbau ----------------------------------------------------------------
func _add_panel(pos: Vector2, sz: Vector2) -> VBoxContainer:
var p := UiKit.panel(PANEL_BG)
p.position = pos
p.size = sz
ui_root.add_child(p)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
p.add_child(v)
return v
func _build_top() -> void:
var bar := ColorRect.new()
bar.color = PANEL_BG
bar.position = Vector2.ZERO
bar.size = Vector2(1600, TOP_H)
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui_root.add_child(bar)
var h := HBoxContainer.new()
h.position = Vector2(14, 9)
h.size = Vector2(1570, 30)
h.add_theme_constant_override("separation", 22)
ui_root.add_child(h)
lbl_round = UiKit.label("Runde 1", 18)
lbl_phase = UiKit.label("Planung", 16, Color(0.7, 0.85, 1.0))
lbl_hp = UiKit.label("Leben 100", 16, Color(0.5, 0.9, 0.55))
lbl_gold = UiKit.label("Gold 4", 16, Color(1.0, 0.85, 0.35))
lbl_slots = UiKit.label("Kader 0/3", 16)
lbl_streak = UiKit.label("Serie 0", 16, Color(0.85, 0.7, 1.0))
for l in [lbl_round, lbl_phase, lbl_hp, lbl_gold, lbl_slots, lbl_streak]:
h.add_child(l)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(360, 0)
h.add_child(spacer)
btn_speed = UiKit.button("Tempo 1x", Color(0.25, 0.28, 0.36))
btn_speed.custom_minimum_size = Vector2(96, 28)
btn_speed.pressed.connect(_on_speed)
h.add_child(btn_speed)
btn_ready = UiKit.button("Kampf starten [Leer]", Color(0.24, 0.52, 0.34))
btn_ready.custom_minimum_size = Vector2(190, 28)
btn_ready.pressed.connect(_on_ready)
h.add_child(btn_ready)
## Linke Spalte: Elemente, gewählte Einheit und Rundenlog, scrollbar.
func _build_left() -> void:
var w := LEFT_W - 16.0
var h := 900.0 - TOP_H - BOTTOM_H - 20.0
var p := UiKit.panel(PANEL_BG)
p.position = Vector2(8, TOP_H + 8)
p.size = Vector2(w, h)
p.clip_contents = true
ui_root.add_child(p)
var sc := ScrollContainer.new()
sc.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
p.add_child(sc)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sc.add_child(col)
traits_box = VBoxContainer.new()
info_box = VBoxContainer.new()
log_box = VBoxContainer.new()
for b in [traits_box, info_box, log_box]:
b.add_theme_constant_override("separation", 3)
b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
col.add_child(b)
col.add_child(HSeparator.new())
func _build_world() -> void:
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = BG
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.44, 0.48, 0.62)
env.ambient_light_energy = 0.9
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-58.0, -35.0, 0.0)
sun.light_energy = 1.15
sun.shadow_enabled = true
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-22.0, 140.0, 0.0)
fill.light_energy = 0.4
fill.light_color = Color(0.72, 0.80, 1.0)
add_child(fill)
board = BoardView3D.new()
add_child(board)
camera = Camera3D.new()
camera.fov = CAM_FOV
camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP)
add_child(camera)
board.camera = camera
get_viewport().size_changed.connect(_fit_camera)
_fit_camera()
## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt.
## Links, rechts und unten liegen Bedienpanels darüber.
func _fit_camera() -> void:
var vp := Vector2(get_viewport().get_visible_rect().size)
var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0,
vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0)
if target.size.x <= 0.0 or target.size.y <= 0.0:
return
var pts := board.corner_points()
var pitch := deg_to_rad(CAM_PITCH)
var dir := Vector3(0.0, sin(pitch), cos(pitch))
var dist := 20.0
var center := Vector3.ZERO
for i in 8:
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
var r := _project_rect(pts)
if r.size.x <= 0.0 or r.size.y <= 0.0:
return
dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y)
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
r = _project_rect(pts)
var err := r.get_center() - target.get_center()
var o := camera.unproject_position(center)
var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x
var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y
if absf(kx) < 0.001 or absf(kz) < 0.001:
break
center += Vector3(err.x / kx, 0.0, err.y / kz)
if err.length() < 1.0 and absf(1.0 - maxf(r.size.x / target.size.x,
r.size.y / target.size.y)) < 0.01:
break
camera.look_at_from_position(center + dir * dist, center, Vector3.UP)
func _project_rect(points: Array) -> Rect2:
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for p in points:
if camera.is_position_behind(p):
return Rect2()
var q := camera.unproject_position(p)
lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
return Rect2(lo, hi - lo)
## Punkt auf der Feldebene unter einer Bildschirmposition, oder null.
func _ground_point(screen_pos: Vector2):
if camera == null:
return null
var from := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
if absf(dir.y) < 0.0001:
return null
var t := (BoardView3D.TILE_H * 0.5 - from.y) / dir.y
if t < 0.0:
return null
return board.to_local(from + dir * t)
## Position des gezogenen Avatars: auf Feldhöhe, damit er nicht schwebt.
func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3:
var hit = _ground_point(screen_pos)
if hit == null:
return board.slot_world(fallback)
return Vector3(hit.x, 0.0, hit.z)
## Feld oder Bankplatz unter einer Bildschirmposition.
func _pick_slot(screen_pos: Vector2) -> Dictionary:
var hit = _ground_point(screen_pos)
if hit == null:
return {}
return board.slot_at_world(hit)
## Rechte Spalte: die acht Spieler mit Leben und Serie.
func _build_right() -> void:
players_box = _add_panel(Vector2(1600.0 - RIGHT_W + 8.0, TOP_H + 8),
Vector2(RIGHT_W - 16, 900 - TOP_H - BOTTOM_H - 20))
players_box.add_child(UiKit.label("Spieler", 16, Color(0.75, 0.8, 0.95)))
func _build_bottom() -> void:
var y := 900.0 - BOTTOM_H
var p := UiKit.panel(PANEL_BG)
p.position = Vector2(8, y)
p.size = Vector2(1584, BOTTOM_H - 10)
ui_root.add_child(p)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 6)
p.add_child(v)
var head := HBoxContainer.new()
head.add_theme_constant_override("separation", 16)
v.add_child(head)
head.add_child(UiKit.label("Shop", 13, Color(0.7, 0.72, 0.8)))
btn_reroll = UiKit.button("Neu würfeln %d Gold [D]" % Data.REROLL_COST, Color(0.30, 0.32, 0.44))
btn_reroll.custom_minimum_size = Vector2(200, 26)
btn_reroll.pressed.connect(_on_reroll)
head.add_child(btn_reroll)
lbl_fodder = UiKit.label("", 13, Color(0.7, 0.72, 0.8))
head.add_child(lbl_fodder)
lbl_hint = UiKit.label("", 13, Color(0.95, 0.85, 0.45))
head.add_child(lbl_hint)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
head.add_child(spacer)
head.add_child(UiKit.label(
"Avatare zwischen Bank und Feld ziehen · Rechtsklick: verkaufen bzw. zurück",
13, Color(0.6, 0.62, 0.7)))
shop_row = HBoxContainer.new()
shop_row.add_theme_constant_override("separation", 8)
v.add_child(shop_row)
func _build_picker() -> void:
picker = PopupPanel.new()
picker.add_theme_stylebox_override("panel", UiKit.flat(Color(0.16, 0.17, 0.22)))
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
picker.add_child(v)
picker_title = UiKit.label("Element wählen", 16)
v.add_child(picker_title)
picker_sub = UiKit.label("", 13, Color(0.75, 0.8, 0.95))
v.add_child(picker_sub)
picker_grid = GridContainer.new()
picker_grid.columns = 3
picker_grid.add_theme_constant_override("h_separation", 6)
picker_grid.add_theme_constant_override("v_separation", 6)
v.add_child(picker_grid)
ui_root.add_child(picker)
## Element-Auswahl. Mit `previews` steht neben jedem Knopf, was sich ändert.
func _open_picker(title: String, options: Array, cb: Callable, previews: Array = [],
subtitle: String = "") -> void:
picker_title.text = title
picker_sub.text = subtitle
picker_sub.visible = subtitle != ""
for c in picker_grid.get_children():
c.queue_free()
picker_cb = cb
var counts := game.human.trait_counts()
var with_preview := not previews.is_empty()
picker_grid.columns = 2 if with_preview else 3
for i in options.size():
var e: int = options[i]
var col: Color = Data.ELEMENT_COLORS[e]
var b := UiKit.button("%s\n(%d im Team)" % [Data.ELEMENT_NAMES[e], counts.get(e, 0)],
col, UiKit.ink(col))
b.custom_minimum_size = Vector2(120, 52 if with_preview else 46)
b.pressed.connect(func():
picker.hide()
picker_cb.call(e))
picker_grid.add_child(b)
if with_preview:
var l := UiKit.label(previews[i], 12, Color(0.85, 0.88, 0.95))
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
picker_grid.add_child(l)
picker.reset_size()
picker.popup_centered()
# --- Aktionen --------------------------------------------------------------
func _on_ready() -> void:
if game.phase == Game.Phase.PLANNING:
_cancel_drag()
sel_slot = {}
game.start_combat()
_refresh()
elif game.phase == Game.Phase.COMBAT:
game.skip_combat()
_refresh()
func _on_speed() -> void:
combat_speed = 1.0 if combat_speed >= 4.0 else combat_speed * 2.0
btn_speed.text = "Tempo %dx" % int(combat_speed)
func _on_reroll() -> void:
if game.phase != Game.Phase.PLANNING:
return
game.human.reroll(game.pool, game.rand)
_refresh()
func _on_buy(index: int) -> void:
if game.phase != Game.Phase.PLANNING:
return
var p := game.human
if index < 0 or index >= p.shop.size():
return
var entry: ShopEntry = p.shop[index]
if entry.sold:
return
if p.free_bench_slot() < 0:
_hint("Bank ist voll. Erst etwas verkaufen.")
return
if p.gold < entry.price():
return
if entry.element >= 0:
if not p.buy(index, game.pool, entry.element):
_hint("Kauf nicht möglich (Vorrat leer).")
_refresh()
return
# Neutraler Avatar: Spezialisierung schon beim Kauf wählen.
var free: Array[int] = []
for e in Data.ELEMENT_COUNT:
if game.pool.element_left[e] > 0:
free.append(e)
if free.is_empty():
_hint("Kein Element mehr im Vorrat.")
return
_open_picker("Spezialisierung wählen", free, func(e):
if not game.human.buy(index, game.pool, e):
_hint("Kauf nicht möglich (Vorrat leer).")
_refresh())
func _on_upgrade() -> void:
var p := game.human
var hex := selected_board_hex()
if hex.x < 0 or not p.can_upgrade(hex):
return
var a: Avatar = p.board[hex]
var options := p.available_new_elements(hex)
var previews := []
for e in options:
previews.append("+ " + _element_effect(e) + " (halbe Stärke)")
_open_picker("Zweite/dritte Spezialisierung", options, func(e):
game.human.upgrade(hex, e, game.pool)
_refresh(), previews,
"Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1))
func _star_effect(role: int, star: int) -> String:
match role:
Data.Role.TANK:
if star == 2:
return "Schild und spottet 3 Gegner"
return "Schild, Spott und 25% Reflexion"
Data.Role.DPS:
if star == 2:
return "Ziel plus Splash auf Nachbarn"
return "Ziel plus Fläche im Radius 2"
_:
if star == 2:
return "heilt 3 Verbündete"
return "heilt alle und schildet sie"
func _element_effect(element: int) -> String:
match element:
Data.Element.FEUER: return "Brand"
Data.Element.WASSER: return "Mana und stärkere Heilung"
Data.Element.WIND: return "Tempo-Schub"
Data.Element.BLITZ: return "Kettenblitz"
Data.Element.ERDE: return "Betäubung"
_: return "Verlangsamung"
## Gewähltes Board-Feld, oder (-1,-1) wenn nichts oder ein Bankplatz gewählt ist.
func selected_board_hex() -> Vector2i:
if sel_slot.is_empty() or sel_slot["kind"] != "board":
return Vector2i(-1, -1)
return sel_slot["hex"]
func _hint(text: String) -> void:
lbl_hint.text = text
get_tree().create_timer(2.5).timeout.connect(func():
if is_instance_valid(lbl_hint) and lbl_hint.text == text:
lbl_hint.text = "")
# --- Anzeige ---------------------------------------------------------------
func _refresh() -> void:
_refresh_header()
_refresh_players()
_refresh_traits()
_refresh_info()
_refresh_log()
_refresh_fodder()
_refresh_shop()
board.selected_slot = sel_slot
if game.phase == Game.Phase.GAMEOVER:
_show_gameover()
func _refresh_header() -> void:
var p := game.human
lbl_round.text = "Runde %d" % game.round_no
lbl_hp.text = "Leben %d" % p.hp
var interest := Data.interest(p.gold)
lbl_gold.text = "Gold %d (+%d Zins)" % [p.gold, interest] if interest > 0 else "Gold %d" % p.gold
lbl_slots.text = "Kader %d/%d" % [p.board.size(), p.team_slots(game.round_no)]
var s := p.streak
lbl_streak.text = "Serie %s%d (+%d Gold)" % ["+" if s > 0 else "", s, Data.streak_gold(s)]
match game.phase:
Game.Phase.PLANNING:
lbl_phase.text = "Planung"
btn_ready.text = "Kampf starten [Leer]"
btn_ready.disabled = false
Game.Phase.COMBAT:
var opp := game.human_opponent()
lbl_phase.text = "Kampf gegen %s" % (opp.pname if opp else "?")
btn_ready.text = "Kampf überspringen"
btn_ready.disabled = false
_:
lbl_phase.text = "Spiel vorbei"
btn_ready.disabled = true
## Label für das rechte Panel: bricht um und bleibt in der Panelbreite.
func _wrapped(text: String, size: int, col: Color) -> Label:
var l := UiKit.label(text, size, col)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(TEXT_W, 0)
l.size_flags_horizontal = Control.SIZE_FILL
return l
func _clear(box: Node, keep: int = 0) -> void:
var kids := box.get_children()
for i in range(keep, kids.size()):
box.remove_child(kids[i])
kids[i].queue_free()
func _refresh_players() -> void:
_clear(players_box, 1)
for p in game.ranking():
var row := VBoxContainer.new()
row.add_theme_constant_override("separation", 1)
var top := HBoxContainer.new()
var col := Color(0.9, 0.9, 0.95) if p.alive else Color(0.5, 0.5, 0.55)
if p == game.human:
col = Color(0.6, 0.9, 1.0)
var nm := UiKit.label(p.pname, 14, col)
nm.custom_minimum_size = Vector2(88, 0)
top.add_child(nm)
top.add_child(UiKit.label("%d" % p.hp, 14, col))
if p.streak != 0:
top.add_child(UiKit.label(" Serie %s%d" % ["+" if p.streak > 0 else "", p.streak], 12,
Color(0.85, 0.7, 1.0)))
row.add_child(top)
var bar := ColorRect.new()
bar.color = Color(0.12, 0.13, 0.16)
bar.custom_minimum_size = Vector2(RIGHT_W - 32, 6)
var fill := ColorRect.new()
fill.color = Color(0.35, 0.8, 0.45) if p.alive else Color(0.4, 0.4, 0.45)
fill.size = Vector2((RIGHT_W - 32) * (float(p.hp) / float(Data.START_HP)), 6)
fill.position = Vector2.ZERO
bar.add_child(fill)
row.add_child(bar)
players_box.add_child(row)
var opp := game.human_opponent()
if opp != null and game.phase == Game.Phase.COMBAT:
players_box.add_child(UiKit.label("\nGegner: " + opp.pname, 13, Color(1.0, 0.6, 0.6)))
func _refresh_traits() -> void:
_clear(traits_box)
traits_box.add_child(UiKit.label("Elemente im Team", 15, Color(0.75, 0.8, 0.95)))
var counts := game.human.trait_counts()
var any := false
for e in Data.ELEMENT_COUNT:
var c: int = counts.get(e, 0)
if c == 0:
continue
any = true
var tier := Data.trait_tier(e, c)
var col: Color = Data.ELEMENT_COLORS[e]
var row := HBoxContainer.new()
var dot := ColorRect.new()
dot.color = col if tier > 0 else col.darkened(0.55)
dot.custom_minimum_size = Vector2(12, 12)
row.add_child(dot)
var txt := " %s %d" % [Data.ELEMENT_NAMES[e], c]
if tier > 0:
txt += " - " + Data.ELEMENT_TRAITS[e]["desc"][tier - 1]
else:
txt += " (ab 2 aktiv)"
var el := UiKit.label(txt, 13, Color(0.9, 0.9, 0.95) if tier > 0 else Color(0.55, 0.55, 0.6))
el.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
el.custom_minimum_size = Vector2(TEXT_W - 20.0, 0)
row.add_child(el)
traits_box.add_child(row)
if not any:
traits_box.add_child(_wrapped("Noch nichts aufgestellt.", 13, Color(0.55, 0.55, 0.6)))
traits_box.add_child(UiKit.label("\nKombinationen", 15, Color(0.75, 0.8, 0.95)))
var active := game.human.active_combos()
var keys := []
for c in active:
keys.append(c["key"])
for c in Data.COMBO_TRAITS:
var on: bool = keys.has(c["key"])
var t := "%s (%s+%s): %s" % [c["name"], Data.ELEMENT_NAMES[c["a"]],
Data.ELEMENT_NAMES[c["b"]], c["desc"]]
traits_box.add_child(_wrapped(t, 12, Color(0.6, 0.95, 0.7) if on else Color(0.45, 0.45, 0.5)))
func _refresh_info() -> void:
_clear(info_box)
info_box.add_child(UiKit.label("Einheit", 15, Color(0.75, 0.8, 0.95)))
var p := game.human
var a: Avatar = _selected_avatar()
if a == null:
info_box.add_child(_wrapped("Einheit auf dem Feld oder der Bank anklicken.", 13,
Color(0.55, 0.55, 0.6)))
return
var s := a.base_stats()
info_box.add_child(_wrapped("%s %d-Star" % [a.display_name(), a.star], 14, a.color()))
info_box.add_child(_wrapped("Leben %d Angriff %d Rüstung %d%%\nTempo %.2f Reichweite %d"
% [int(s["hp"]), int(s["atk"]), int(s["armor"] * 100.0), s["aspd"], s["range"]], 12,
Color(0.9, 0.9, 0.95)))
info_box.add_child(_wrapped(a.ability_text(), 12, Color(0.8, 0.85, 1.0)))
var hex := selected_board_hex()
if hex.x < 0:
info_box.add_child(_wrapped("Zum Aufwerten aufs Feld ziehen.", 12,
Color(0.85, 0.85, 0.9)))
return
var cost := a.upgrade_cost()
if cost.is_empty():
info_box.add_child(_wrapped("Maximale Stufe.", 12, Color(1.0, 0.85, 0.35)))
return
var missing := p.upgrade_missing(hex)
var need := []
for e in cost:
need.append("%dx %s" % [cost[e], Data.ELEMENT_NAMES[e]])
info_box.add_child(_wrapped("Aufwerten braucht: " + ", ".join(need), 12,
Color(0.85, 0.85, 0.9)))
if missing.is_empty():
var b := UiKit.button("Aufwerten", Color(0.28, 0.55, 0.36))
b.custom_minimum_size = Vector2(140, 26)
b.pressed.connect(_on_upgrade)
info_box.add_child(b)
else:
var miss := []
for e in missing:
miss.append("%dx %s" % [missing[e], Data.ELEMENT_NAMES[e]])
info_box.add_child(_wrapped("Es fehlt: " + ", ".join(miss), 12, Color(0.95, 0.6, 0.5)))
func _selected_avatar():
return _avatar_at(sel_slot)
func _refresh_log() -> void:
_clear(log_box)
log_box.add_child(UiKit.label("Letzte Runde", 15, Color(0.75, 0.8, 0.95)))
if game.last_round_log.is_empty():
log_box.add_child(UiKit.label("-", 12, Color(0.55, 0.55, 0.6)))
for line in game.last_round_log:
log_box.add_child(_wrapped(line, 12, Color(0.8, 0.8, 0.86)))
func _refresh_fodder() -> void:
var fodder := game.human.fodder_counts()
var parts := []
for e in fodder:
parts.append("%dx %s" % [fodder[e], Data.ELEMENT_NAMES[e]])
lbl_fodder.text = "Futter auf der Bank: " + (", ".join(parts) if parts else "-")
func _refresh_shop() -> void:
_clear(shop_row)
var p := game.human
for i in p.shop.size():
var e: ShopEntry = p.shop[i]
var col := e.color()
var txt := "verkauft"
var b: Button
if e.sold:
b = UiKit.button(txt, SLOT_BG.darkened(0.3))
b.disabled = true
else:
var note: String = "Element wählen" if e.element < 0 \
else "noch %d im Vorrat" % game.pool.element_left[e.element]
b = UiKit.button("%s\n%d Gold\n%s" % [e.label(), e.price(), note], col, UiKit.ink(col))
b.disabled = p.gold < e.price() or game.phase != Game.Phase.PLANNING
b.pressed.connect(_on_buy.bind(i))
b.custom_minimum_size = Vector2(196, 74)
shop_row.add_child(b)
var mine := []
for el in game.human.shop_elements(game.pool):
mine.append("%s %d" % [Data.ELEMENT_NAMES[el], game.pool.element_left[el]])
shop_row.add_child(UiKit.label(" Dein Shop zeigt Neutral\n und deine Elemente:\n %s"
% [", ".join(mine) if mine else "noch keine aufgestellt"], 12,
Color(0.7, 0.72, 0.8)))
func _show_gameover() -> void:
if overlay != null:
return
overlay = ColorRect.new()
overlay.color = Color(0, 0, 0, 0.72)
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ui_root.add_child(overlay)
var v := VBoxContainer.new()
v.position = Vector2(620, 320)
v.add_theme_constant_override("separation", 10)
overlay.add_child(v)
var alive := game.alive_players()
var win: String = alive[0].pname if not alive.is_empty() else "-"
v.add_child(UiKit.label("Spiel vorbei", 32))
v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35)))
var place := game.human.placement if game.human.placement > 0 else 1
v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18,
Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6)))
var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36))
b.custom_minimum_size = Vector2(180, 36)
b.pressed.connect(func():
overlay.queue_free()
overlay = null
game = Game.new()
game.start()
board.game = game
_cancel_drag()
sel_slot = {}
_refresh())
v.add_child(b)

1
scripts/ui/Main.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dgtlalmkli5w4

56
scripts/ui/UiKit.gd Normal file
View File

@@ -0,0 +1,56 @@
class_name UiKit
extends RefCounted
## Kleine Helfer für die primitiven UI-Bausteine.
static func flat(bg: Color, border: Color = Color(0, 0, 0, 0), radius: int = 6) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.corner_radius_top_left = radius
sb.corner_radius_top_right = radius
sb.corner_radius_bottom_left = radius
sb.corner_radius_bottom_right = radius
if border.a > 0.0:
sb.border_color = border
sb.set_border_width_all(2)
sb.content_margin_left = 6
sb.content_margin_right = 6
sb.content_margin_top = 4
sb.content_margin_bottom = 4
return sb
static func panel(bg: Color) -> PanelContainer:
var p := PanelContainer.new()
p.add_theme_stylebox_override("panel", flat(bg))
return p
static func label(text: String, size: int = 14, col: Color = Color(0.9, 0.9, 0.95)) -> 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
static func button(text: String, bg: Color, fg: Color = Color(1, 1, 1)) -> Button:
var b := Button.new()
b.text = text
b.add_theme_stylebox_override("normal", flat(bg))
b.add_theme_stylebox_override("hover", flat(bg.lightened(0.12)))
b.add_theme_stylebox_override("pressed", flat(bg.darkened(0.15)))
b.add_theme_stylebox_override("disabled", flat(bg.darkened(0.45)))
b.add_theme_color_override("font_color", fg)
b.add_theme_color_override("font_hover_color", fg)
b.add_theme_color_override("font_disabled_color", Color(fg, 0.45))
b.add_theme_font_size_override("font_size", 14)
b.clip_text = true
# Ohne das würde der zuletzt geklickte Knopf die Leertaste abfangen.
b.focus_mode = Control.FOCUS_NONE
return b
## Lesbare Textfarbe zu einem Hintergrund.
static func ink(bg: Color) -> Color:
return Color(0.06, 0.06, 0.08) if bg.get_luminance() > 0.5 else Color(0.96, 0.96, 1.0)

1
scripts/ui/UiKit.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dguo4u0xtal7d