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") const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") ## G2: every boss unit (grotto and deployed) shows the Schlundkrone; its veins and crystals ## take the colour of the team it fights for. const BOSS_MODEL := "schlundkrone" var unit: LuneUnit ## Ground the figure stands on (lane decks, terrain, grotto terrace); flat without it. var terrain: LuneTerrain ## Optional world height (adds plaza and dais steps); preferred over the bare terrain. var ground: Callable 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 ## Boss look (G2): dressed LN_* surfaces, vein energy that follows sleep, fight and death. var _boss_surfaces: Array[GeometryInstance3D] = [] var _tag: Label3D var _boss_energy: float = 1.0 var _sent_energy: float = -1.0 var _sent_flash: float = -1.0 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", "")) if unit.kind == "boss" and AssetLibrary.has_model(BOSS_MODEL): id = BOSS_MODEL model = AssetLibrary.instantiate_fighter(id) if model == null: model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team]) add_child(model) if id == BOSS_MODEL: _boss_surfaces = Materials.dress(model) for surface: GeometryInstance3D in _boss_surfaces: surface.set_instance_shader_parameter("tint", Materials.team_color(unit.team, "crystal")) surface.set_instance_shader_parameter("seed", 1.7 + unit.team) _boss_energy = .45 if unit.dormant else 1.0 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 # Fixed screen size under the perspective game camera (about 18 px at 1080p). tag.pixel_size = .00034 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) _tag = tag _last_attack = unit.attack_serial _last_ability = unit.ability_serial position = Vector3(unit.position.x, _ground(unit.position), 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, _ground(at), at.y) rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0)) var moved: float = Vector2(position.x - _last_visual.x, position.z - _last_visual.z).length() _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) if not _boss_surfaces.is_empty(): _sync_boss(delta) 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 if _tag != null: # Fallen skeletal figures stay in their death pose, but without their name. _tag.visible = unit.alive # Rigid figures have no death clip; skeletal ones stay down in their death pose. if not skeletal: visible = unit.alive ## Veins sleep while the grotto boss is dormant, burn while it fights, flash on hits and go ## out over its death clip. func _sync_boss(delta: float) -> void: var goal: float = 0.0 if not unit.alive else (.45 if unit.dormant else 1.0) _boss_energy = move_toward(_boss_energy, goal, delta * (.6 if not unit.alive else 1.5)) var flash: float = clampf(unit.hit_flash * 2.2, 0.0, .45) if unit.alive else 0.0 if absf(_boss_energy - _sent_energy) > .01: _sent_energy = _boss_energy for surface: GeometryInstance3D in _boss_surfaces: surface.set_instance_shader_parameter("energy", _boss_energy) if absf(flash - _sent_flash) > .02: _sent_flash = flash for surface: GeometryInstance3D in _boss_surfaces: surface.set_instance_shader_parameter("damage", flash) ## Where tower bolts and effects hit this figure (upper body, not the feet). func aim_point() -> Vector3: return global_position + Vector3(0, _height * .55, 0) ## Relative ground footprint for lock-on markers (bigger figures get bigger rings). func footprint() -> float: return clampf(float(unit.def.get("view_scale", 1.0)) if unit != null else 1.0, .8, 2.2) func _ground(at: Vector2) -> float: if ground.is_valid(): return float(ground.call(at)) + .03 return terrain.walk_height(at) + .03 if terrain != null else 0.0 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