Files
elements-autochess/scripts/ui/BoardView3D.gd
2026-08-29 11:42:54 +02:00

423 lines
12 KiB
GDScript

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