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