Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm, HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller (Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch. Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.8 KiB
GDScript
134 lines
4.8 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")]
|
|
|
|
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
|
|
_ring.material_override = _flat(TEAM_COLORS[unit.team], 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)
|
|
_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
|