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 ## Brett eines anderen Spielers, das in der Planung oben gezeigt wird. var scout_player: PlayerState = null var selected_slot := {} ## {"kind": "board"/"bench", ...} var hover_slot := {} ## Reichweite der gewählten Einheit: Mitte (Arena-Feld), Radius, Farbe. Radius 0 = aus. var range_center := Vector2i(-1, -1) var range_radius := 0 var range_color := Color(1.0, 0.7, 0.3) 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 _dying := {} ## key -> Restzeit der Sterbe-Animation const DEATH_TIME := 0.45 var _shots: Array = [] ## fliegende Geschosse {"node", "from", "to", "t", "dur"} var shots_total := 0 ## Zähler aller Geschosse, für Tests var _effects: Array = [] ## Fähigkeitsformen {"node", "t", "dur", "grow"} var effects_total := 0 var _ring_fx: TorusMesh var _disc_fx: CylinderMesh var _shot_mesh: SphereMesh var _slash_mesh: BoxMesh 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 _shot_mesh = SphereMesh.new() _shot_mesh.radius = 0.11 _shot_mesh.height = 0.22 _shot_mesh.radial_segments = 8 _shot_mesh.rings = 4 _slash_mesh = BoxMesh.new() _slash_mesh.size = Vector3(0.12, 0.12, 0.7) _ring_fx = TorusMesh.new() _ring_fx.inner_radius = 0.55 _ring_fx.outer_radius = 0.7 _ring_fx.ring_segments = 8 _ring_fx.rings = 28 _disc_fx = CylinderMesh.new() _disc_fx.top_radius = 0.9 _disc_fx.bottom_radius = 0.9 _disc_fx.height = 0.06 _disc_fx.radial_segments = 6 _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) _update_shots(delta) _update_effects(delta) ## Sichtbare Fähigkeit: Tank = wachsender Schildring, Schütze = Fläche am Ziel, ## Heiler = aufsteigender Heilring. Farbe nach Element. func spawn_effect(world: Vector3, target: Vector3, role: int, col: Color) -> void: var mi := MeshInstance3D.new() var m := StandardMaterial3D.new() m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA m.albedo_color = Color(col, 0.75) m.emission_enabled = true m.emission = col m.emission_energy_multiplier = 1.4 mi.material_override = m var fx := {"node": mi, "t": 0.0, "dur": 0.45, "rise": 0.0, "grow": 1.0} match role: Data.Role.TANK: mi.mesh = _ring_fx mi.position = world + Vector3(0.0, 0.3, 0.0) fx["grow"] = 1.6 Data.Role.DPS: mi.mesh = _disc_fx mi.position = target + Vector3(0.0, 0.12, 0.0) mi.scale = Vector3(0.3, 1.0, 0.3) fx["grow"] = 1.4 fx["dur"] = 0.35 _: mi.mesh = _ring_fx mi.position = world + Vector3(0.0, 0.1, 0.0) fx["rise"] = 1.2 fx["grow"] = 1.2 fx["dur"] = 0.6 add_child(mi) effects_total += 1 _effects.append(fx) func _update_effects(delta: float) -> void: var keep := [] for fx in _effects: fx["t"] += delta var f: float = fx["t"] / fx["dur"] var node: MeshInstance3D = fx["node"] if f >= 1.0: node.queue_free() continue var g: float = lerpf(1.0, fx["grow"], f) if fx["grow"] >= 1.0 else 1.0 node.scale = Vector3(g, 1.0, g) if node.mesh == _disc_fx else Vector3(g, g, g) node.position.y += fx["rise"] * delta var m: StandardMaterial3D = node.material_override m.albedo_color.a = 0.75 * (1.0 - f) keep.append(fx) _effects = keep ## Sichtbarer Angriff: Fernkampf schickt eine Kugel, Nahkampf einen kurzen Hieb. func spawn_attack(from: Vector3, to: Vector3, ranged: bool, col: Color) -> void: var mi := MeshInstance3D.new() mi.mesh = _shot_mesh if ranged else _slash_mesh var m := StandardMaterial3D.new() m.albedo_color = col m.emission_enabled = true m.emission = col m.emission_energy_multiplier = 1.8 mi.material_override = m var a := from + Vector3(0.0, 0.7, 0.0) var b := to + Vector3(0.0, 0.6, 0.0) if not ranged: # Der Hieb startet vor dem Angreifer und endet am Ziel. a = a.lerp(b, 0.35) mi.position = a if (b - a).length() > 0.01: mi.look_at_from_position(a, b, Vector3.UP) add_child(mi) shots_total += 1 _shots.append({"node": mi, "from": a, "to": b, "t": 0.0, "dur": 0.2 if ranged else 0.09}) func _update_shots(delta: float) -> void: var keep := [] for sh in _shots: sh["t"] += delta var f: float = sh["t"] / sh["dur"] var node: MeshInstance3D = sh["node"] if f >= 1.0: node.queue_free() continue var pos: Vector3 = sh["from"].lerp(sh["to"], f) pos.y += sin(f * PI) * 0.35 node.position = pos keep.append(sh) _shots = keep 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 var in_range := range_radius > 0 and range_center.x >= 0 \ and Hex.distance(h, range_center) <= range_radius and h != range_center if in_range and not drag_active: col = col.lerp(range_color, 0.45) emit = 0.22 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, "burn": u.burn_timer > 0.0, "slow": u.slow_timer > 0.0 and u.slow > 0.0, "stun": u.stun_timer > 0.0, "shield": clampf(u.shield / maxf(1.0, u.max_hp), 0.0, 1.0), "face": world_pos(_mirror(u.target.pos, my_team)) if (u.target != null and u.target.alive) else null, } 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, } if scout_player != null and scout_player.alive: for bh in scout_player.board: var sa: Avatar = scout_player.board[bh] wanted["s%d" % sa.id] = { "avatar": sa, "world": world_pos(Combat._arena_pos(bh, 1)), "hp": 1.0, "mana": 0.0, "side": 1, "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 wanted.has(key): continue var node: Node3D = _units[key] if not _dying.has(key): # Im Kampf schrumpft die Einheit weg, in der Planung verschwindet sie sofort. if game.phase == Game.Phase.COMBAT and key.begins_with("c"): _dying[key] = DEATH_TIME else: node.queue_free() _units.erase(key) continue _dying[key] -= delta var t: float = maxf(0.0, _dying[key] / DEATH_TIME) node.scale = Vector3(t, t * 0.6, t) node.position.y = -0.4 * (1.0 - t) if _dying[key] <= 0.0: node.queue_free() _units.erase(key) _dying.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: var flat_from := Vector3(node.position.x, 0.0, node.position.z) var flat_to := Vector3(target.x, 0.0, target.z) var moving := flat_from.distance_to(flat_to) > 0.05 var next := node.position.lerp(target, clampf(delta * MOVE_LERP, 0.0, 1.0)) if moving: # Blick in Laufrichtung, kleines Wippen wie ein Schritt. var dirv := flat_to - flat_from node.rotation.y = lerp_angle(node.rotation.y, atan2(dirv.x, dirv.z), clampf(delta * 10.0, 0.0, 1.0)) var body: MeshInstance3D = node.get_meta("body") body.position.y = body.get_meta("base_y", body.position.y) + absf(sin(Time.get_ticks_msec() / 90.0)) * 0.12 elif d.get("face", null) != null: var fd: Vector3 = d["face"] - flat_to if fd.length() > 0.05: node.rotation.y = lerp_angle(node.rotation.y, atan2(fd.x, fd.z), clampf(delta * 6.0, 0.0, 1.0)) var body2: MeshInstance3D = node.get_meta("body") body2.position.y = body2.get_meta("base_y", body2.position.y) node.position = next var body: MeshInstance3D = node.get_meta("body") var mat: StandardMaterial3D = body.material_override var base: Color = d["avatar"].color() var col := base if d.get("slow", false): col = col.lerp(Color(0.55, 0.8, 1.0), 0.45) if d.get("stun", false): col = col.lerp(Color(1.0, 0.95, 0.4), 0.5) if d["hit"]: col = col.lightened(0.5) mat.albedo_color = col var dragged: bool = d.get("dragged", false) var emit := 0.12 if d["cast"]: emit = 1.6 elif d.get("burn", false): emit = 0.9 mat.emission = Color(1.0, 0.45, 0.15) elif dragged: emit = 0.45 else: mat.emission = base mat.emission_energy_multiplier = emit var sc := 1.12 if (d["cast"] or dragged) else 1.0 if d.get("stun", false): node.scale = Vector3(sc * 1.08, sc * 0.8, sc * 1.08) else: node.scale = Vector3.ONE * sc 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. ## Findet auch Kampfeinheiten, die unter einem anderen Schlüssel liegen. func unit_node(avatar_id: int) -> Node3D: var direct = _units.get("a%d" % avatar_id) if direct != null: return direct for n in _units.values(): if n.get_meta("avatar_id", -1) == avatar_id: return n return null ## Für das Overlay: Arena-Feld so spiegeln, wie das Brett gezeichnet wird. func mirror_for_view(p: Vector2i, my_team: int) -> Vector2i: return _mirror(p, my_team) func _mirror(p: Vector2i, my_team: int) -> Vector2i: if my_team == 0: return p return Vector2i(COLS - 1 - p.x, ROWS - 1 - p.y) ## Kleines Kennzeichen des Hauptelements auf oder neben dem Körper. func _add_ornament(root: Node3D, a: Avatar, body_y: float) -> void: var e := a.primary_element() if e < 0: return var col: Color = Data.ELEMENT_COLORS[e] var m := StandardMaterial3D.new() m.albedo_color = col.lightened(0.15) m.emission_enabled = true m.emission = col m.emission_energy_multiplier = 0.5 var top := body_y + 0.5 match e: Data.Element.FEUER: # Flamme: kleiner Kegel oben. var c := CylinderMesh.new() c.top_radius = 0.0 c.bottom_radius = 0.16 c.height = 0.38 c.radial_segments = 8 _orn(root, c, m, Vector3(0.0, top + 0.18, 0.0)) Data.Element.WASSER: # Tropfen: flache Kugel oben. var sp := SphereMesh.new() sp.radius = 0.16 sp.height = 0.22 _orn(root, sp, m, Vector3(0.0, top + 0.12, 0.0)) Data.Element.WIND: # Finne: dünne, hohe Platte. var b := BoxMesh.new() b.size = Vector3(0.05, 0.42, 0.28) _orn(root, b, m, Vector3(0.0, top + 0.2, 0.0)) Data.Element.BLITZ: # Zacke: Prisma oben. var pr := PrismMesh.new() pr.size = Vector3(0.28, 0.4, 0.12) _orn(root, pr, m, Vector3(0.0, top + 0.2, 0.0)) Data.Element.ERDE: # Schultern: zwei Würfel seitlich. for sx in [-1.0, 1.0]: var bx := BoxMesh.new() bx.size = Vector3(0.22, 0.22, 0.22) _orn(root, bx, m, Vector3(sx * 0.5, body_y + 0.1, 0.0)) Data.Element.EIS: # Kristall: umgedrehtes Prisma oben. var cr := PrismMesh.new() cr.size = Vector3(0.22, 0.42, 0.22) var n := _orn(root, cr, m, Vector3(0.0, top + 0.2, 0.0)) n.rotation_degrees = Vector3(180.0, 45.0, 0.0) func _orn(root: Node3D, mesh: Mesh, mat: Material, pos: Vector3) -> MeshInstance3D: var mi := MeshInstance3D.new() mi.mesh = mesh mi.material_override = mat mi.position = pos root.add_child(mi) return mi 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 body.set_meta("base_y", body.position.y) root.add_child(body) root.set_meta("body", body) root.set_meta("avatar_id", a.id) _add_ornament(root, a, body.position.y) # 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