extends Node3D ## Visual twin of one LuneUnit. Reads the authoritative state and drives the existing ## figure animations from real combat events; it never writes back into the simulation. const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")] const HERO_GOLD := Color("f2c96b") var unit: LuneUnit var model: Node3D var skeletal: bool = false var _fill: MeshInstance3D var _bars: Node3D var _ring: MeshInstance3D var _last_attack: int = 0 var _last_ability: int = 0 var _attack_flash: float = 0.0 var _ability_flash: float = 0.0 var _time: float = 0.0 var _last_visual: Vector3 var _height: float = 1.6 static func _flat(color: Color, energy: float = 0.0) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED material.albedo_color = color if energy > 0.0: material.emission_enabled = true material.emission = color material.emission_energy_multiplier = energy return material func bind(source: LuneUnit) -> void: unit = source name = "Unit_%d" % unit.id var id: String = str(unit.def.get("id", "")) model = AssetLibrary.instantiate_fighter(id) if model == null: model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team]) add_child(model) skeletal = model.has_method("update_combat") if model.has_method("get_aabb"): _height = maxf(1.0, model.get_aabb().end.y) var scale_factor: float = float(unit.def.get("view_scale", 1.0)) model.scale = Vector3.ONE * scale_factor _height *= scale_factor _ring = MeshInstance3D.new() var torus := TorusMesh.new() torus.inner_radius = .42 * scale_factor torus.outer_radius = .52 * scale_factor torus.rings = 20 torus.ring_segments = 4 _ring.mesh = torus var ring_color: Color = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team] _ring.material_override = _flat(ring_color, 2.2 if unit.kind in ["hero", "boss", "champion"] else (1.4 if unit.kind != "monster" else .6)) _ring.scale = Vector3(1, .25, 1) _ring.position.y = .05 _ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(_ring) _bars = Node3D.new() _bars.position.y = _height + .25 add_child(_bars) var back := MeshInstance3D.new() var quad := QuadMesh.new() quad.size = Vector2(.9, .1) back.mesh = quad var back_material := _flat(Color(0, 0, 0, .7)) back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA back_material.no_depth_test = true back.material_override = back_material back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _bars.add_child(back) _fill = MeshInstance3D.new() var fill_quad := QuadMesh.new() fill_quad.size = Vector2(.86, .07) fill_quad.center_offset = Vector3(.43, 0, 0) _fill.mesh = fill_quad var fill_material := _flat(TEAM_COLORS[unit.team].lightened(.15)) fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED fill_material.billboard_keep_scale = true fill_material.no_depth_test = true fill_material.render_priority = 1 _fill.material_override = fill_material _fill.position = Vector3(-.43, 0, 0) _fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _bars.add_child(_fill) if unit.kind in ["hero", "champion", "boss"]: # Named units carry a readable tag: heroes with level, champions with role, the boss. var tag := Label3D.new() tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.fixed_size = true tag.pixel_size = .0011 tag.font_size = 30 tag.outline_size = 10 tag.modulate = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team].lightened(.3) tag.position.y = _height + .55 var title: String = str(unit.def.get("display_name", "")) if unit.kind == "hero": title = "%s ยท Stufe %d" % [title, int(unit.def.get("level", 1))] tag.text = title add_child(tag) _last_attack = unit.attack_serial _last_ability = unit.ability_serial position = Vector3(unit.position.x, 0, unit.position.y) rotation.y = unit.facing _last_visual = position func sync(alpha: float, delta: float) -> void: if unit == null: return _time += delta var at: Vector2 = unit.previous_position.lerp(unit.position, clampf(alpha, 0.0, 1.0)) position = Vector3(at.x, 0, at.y) rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0)) var moved: float = position.distance_to(_last_visual) _last_visual = position if unit.attack_serial != _last_attack: _last_attack = unit.attack_serial _attack_flash = .28 if unit.ability_serial != _last_ability: _last_ability = unit.ability_serial _ability_flash = .5 _attack_flash = maxf(0.0, _attack_flash - delta) _ability_flash = maxf(0.0, _ability_flash - delta) if skeletal: model.rotation.y = 0.0 model.update_combat(delta, moved, unit.in_range and unit.alive, unit.attack_progress, unit.hit_count, _ability_flash, unit.hit_flash, unit.alive) elif model.has_method("pose"): model.pose(_time + float(unit.id % 17) * .23, moved > .002, _attack_flash, _ability_flash, unit.hit_flash) var fraction: float = clampf(unit.hp / maxf(1.0, unit.max_hp), 0.0, 1.0) _fill.scale.x = maxf(.001, fraction) _bars.visible = unit.alive and fraction < .999 _ring.visible = unit.alive # Rigid figures have no death clip; skeletal ones stay down in their death pose. if not skeletal: visible = unit.alive class EnemyVisualsFallback: static func make(color: Color) -> Node3D: var body := MeshInstance3D.new() var capsule := CapsuleMesh.new() capsule.radius = .35 capsule.height = 1.4 body.mesh = capsule body.position.y = .7 var material := StandardMaterial3D.new() material.albedo_color = color body.material_override = material var root := Node3D.new() root.add_child(body) return root