Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das HUD im Unity-Projekt (../luneraria-unity). - HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse, Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails, Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben - Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie - Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715; verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern - Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md - HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
9.5 KiB
GDScript
234 lines
9.5 KiB
GDScript
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
|
||
## G3: wide second ring in the owner's shape under a deployed boss (ownership marker).
|
||
var _owner_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
|
||
# G3: team shape as well as colour – round ring for Blau, diamond for Rot, hexagon for the
|
||
# neutral grotto boss (the HUD and minimap use the same shapes).
|
||
torus.rings = [20, 4, 6][clampi(unit.team, 0, 2)]
|
||
torus.ring_segments = 4
|
||
_ring.mesh = torus
|
||
if unit.team == 1:
|
||
_ring.rotation.y = PI * .25
|
||
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)
|
||
if unit.kind == "boss" and unit.team <= 1:
|
||
_owner_ring = MeshInstance3D.new()
|
||
var outer := TorusMesh.new()
|
||
outer.inner_radius = 1.0 * scale_factor
|
||
outer.outer_radius = 1.12 * scale_factor
|
||
outer.rings = 20 if unit.team == 0 else 4
|
||
outer.ring_segments = 4
|
||
_owner_ring.mesh = outer
|
||
_owner_ring.material_override = _flat(TEAM_COLORS[unit.team], 3.0)
|
||
_owner_ring.scale = Vector3(1, .2, 1)
|
||
_owner_ring.position.y = .07
|
||
_owner_ring.rotation.y = PI * .25 if unit.team == 1 else 0.0
|
||
_owner_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||
add_child(_owner_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))]
|
||
elif unit.kind == "boss" and unit.team <= 1:
|
||
# G3: a deployed boss says whom it fights for; its crystals alone are close to Blau's armies.
|
||
title = "%s · für %s" % [title.to_upper(), "Blau" if unit.team == 0 else "Rot"]
|
||
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 _owner_ring != null:
|
||
_owner_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
|