Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone, Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik. Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion (Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen, Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation. Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe, dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein, Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade ohne dithernde Schatten. Keine Regeländerung. Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73, Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38, verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463. 21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung, Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten). Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64 und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11 ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
16 KiB
GDScript
377 lines
16 KiB
GDScript
extends Node3D
|
|
## One lane or base tower (G2). Reads the authoritative building HP and, during combat, the
|
|
## tower's LuneUnit (target, attack progress, attack serial) and turns them into visuals:
|
|
## four damage states, a short destruction sequence into a lasting ruin, the charging crystal
|
|
## and the launch of the tower bolt (flown by LuneTowerFx). Nothing here writes back into the
|
|
## simulation; the rules have applied the damage by the time a bolt lands.
|
|
|
|
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
|
|
const Fx = preload("res://scripts/luneraria/view/lune_fx.gd")
|
|
enum State { INTACT, LIGHT, HEAVY, DESTROYING, RUIN }
|
|
const ASSETS: Dictionary = {
|
|
"outer": {"path": "res://assets/luneraria/g2/grenzwacht.glb", "top": 6.3, "crystal": 5.2, "light_range": 7.0},
|
|
"inner": {"path": "res://assets/luneraria/g2/bastion.glb", "top": 7.4, "crystal": 6.2, "light_range": 8.0},
|
|
"base": {"path": "res://assets/luneraria/g2/kernwacht.glb", "top": 8.6, "crystal": 5.25, "light_range": 9.0},
|
|
}
|
|
## Hit point fractions where the look changes (the same bands as the Ancient).
|
|
const LIGHT_BELOW: float = .605
|
|
const HEAVY_BELOW: float = .305
|
|
const DESTROY_SECONDS: float = 1.7
|
|
## Occlusion fade (G2): dithering starts this far above the foot; half width of the box that
|
|
## can hide figures behind the tower.
|
|
const FADE_FLOOR: float = 2.0
|
|
const OCCLUDER_RADIUS: Dictionary = {"outer": 1.35, "inner": 1.7, "base": 1.8}
|
|
## A bolt leaves when the attack cycle has this much left, so it lands on the rules' hit.
|
|
const LAUNCH_AT: float = .74
|
|
static var _scenes: Dictionary = {}
|
|
|
|
var building_id: String = ""
|
|
var team: int = 0
|
|
var tier: String = "outer"
|
|
var state: int = State.INTACT
|
|
var fraction: float = 1.0
|
|
## Occlusion fade the world asks for (0 opaque .. 1 mostly dithered away above fade_floor).
|
|
var fade_target: float = 0.0
|
|
var seed_value: float = 0.0
|
|
var crystal: Node3D
|
|
var ring: Node3D
|
|
var shards: Array[Node3D] = []
|
|
var light: OmniLight3D
|
|
var _parts: Dictionary = {}
|
|
var _instances: Array = []
|
|
var _structure: Array = []
|
|
var _crystals: Array = []
|
|
var _cloth: Array = []
|
|
var _ruin: Array = []
|
|
var _sent: Dictionary = {}
|
|
var _sparks: GPUParticles3D
|
|
var _smoke: GPUParticles3D
|
|
var _dust: GPUParticles3D
|
|
var _burst: GPUParticles3D
|
|
var _bar: Node3D
|
|
var _bar_fill: MeshInstance3D
|
|
var _rest: Dictionary = {}
|
|
var _time: float = 0.0
|
|
var _destroy_time: float = -1.0
|
|
var _dust_fired: bool = false
|
|
var _ruin_smoke_left: float = 0.0
|
|
var _hit_kick: float = 0.0
|
|
var _charge: float = 0.0
|
|
var _fade: float = 0.0
|
|
var _crystal_rest := Vector3.ZERO
|
|
var _last_serial: int = -1
|
|
var _launched_for: int = -1
|
|
var _upper_axis := Vector3.RIGHT
|
|
var _energy_boost: float = 0.0
|
|
|
|
func setup(definition: Dictionary, facing: Vector2) -> void:
|
|
building_id = str(definition.id)
|
|
team = int(definition.team)
|
|
tier = str(definition.tier)
|
|
name = building_id
|
|
seed_value = float(absi(hash(building_id)) % 997) / 97.0
|
|
var spec: Dictionary = ASSETS[tier]
|
|
if not _scenes.has(tier):
|
|
_scenes[tier] = load(spec.path)
|
|
var model: Node3D = (_scenes[tier] as PackedScene).instantiate()
|
|
model.name = "Model"
|
|
add_child(model)
|
|
# Local +z of the model faces the lane (base towers: away from their Ancient).
|
|
model.rotation.y = atan2(facing.x, facing.y)
|
|
for child: Node in model.get_children():
|
|
_parts[str(child.name)] = child
|
|
_rest[str(child.name)] = (child as Node3D).transform
|
|
_instances = Materials.dress(model)
|
|
Materials.apply_team(_instances, team, seed_value)
|
|
crystal = _parts.get("Crystal")
|
|
ring = _parts.get("Ring")
|
|
for index: int in range(4):
|
|
if _parts.has("Shard_%d" % index):
|
|
shards.append(_parts["Shard_%d" % index])
|
|
if crystal != null:
|
|
_crystal_rest = crystal.position
|
|
for key: String in _parts:
|
|
var list: Array = _collect(_parts[key])
|
|
if key == "Crystal" or key.begins_with("Shard"):
|
|
_crystals.append_array(list)
|
|
elif key.begins_with("Pennant") or key.begins_with("Banner"):
|
|
_cloth.append_array(list)
|
|
elif key == "Ruin":
|
|
_ruin.append_array(list)
|
|
else:
|
|
_structure.append_array(list)
|
|
Materials.apply_rune_tint(_structure + _ruin, team)
|
|
# Glowing crystals, thin rings, shards and cloth cast no sun shadow (saves shadow-pass draws;
|
|
# their shadows were only a few pixels from the game camera).
|
|
for instance: GeometryInstance3D in _crystals + _cloth:
|
|
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
if ring != null:
|
|
for instance: GeometryInstance3D in _collect(ring):
|
|
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
# The pennant belongs to the upper part, so it falls with it.
|
|
if _parts.has("Upper") and _parts.has("Pennant"):
|
|
var cloth: Node3D = _parts["Pennant"]
|
|
var upper: Node3D = _parts["Upper"]
|
|
var local: Transform3D = upper.transform.affine_inverse() * cloth.transform
|
|
cloth.get_parent().remove_child(cloth)
|
|
upper.add_child(cloth)
|
|
cloth.transform = local
|
|
# The upper part tips sideways relative to the lane.
|
|
_upper_axis = Vector3(facing.x, 0, facing.y).normalized().rotated(Vector3.UP, PI * .5 + (seed_value - 5.0) * .08)
|
|
_set_visible("Upper_Damaged", false)
|
|
_set_visible("Debris", false)
|
|
_set_visible("Ruin", false)
|
|
light = OmniLight3D.new()
|
|
light.name = "TowerLight"
|
|
light.position = Vector3(0, float(spec.crystal), 0)
|
|
light.light_color = Materials.team_color(team, "light")
|
|
light.omni_range = float(spec.light_range)
|
|
light.omni_attenuation = 1.4
|
|
light.light_energy = 1.3
|
|
add_child(light)
|
|
_sparks = Fx.sparks(Materials.team_color(team, "light"), 16, 1.0, 1.5)
|
|
_sparks.position = Vector3(0, float(spec.crystal) * .55, 0)
|
|
add_child(_sparks)
|
|
_smoke = Fx.smoke(8, .7, .24, 1.2)
|
|
_smoke.position = Vector3(0, float(spec.crystal) * .6, 0)
|
|
add_child(_smoke)
|
|
_dust = Fx.dust_burst(34 if tier != "base" else 44, 1.5, 1.7)
|
|
add_child(_dust)
|
|
_burst = Fx.shard_burst(Materials.team_color(team, "crystal"), 26, 4.5)
|
|
add_child(_burst)
|
|
_build_bar(float(spec.top) + .6)
|
|
# The occlusion fade only thins out what stands above head height of the figures around.
|
|
Materials.set_param(_instances, "fade_floor", global_position.y + FADE_FLOOR)
|
|
|
|
func _collect(node: Node) -> Array:
|
|
var result: Array = []
|
|
if node is GeometryInstance3D:
|
|
result.append(node)
|
|
for child: Node in node.get_children():
|
|
result.append_array(_collect(child))
|
|
return result
|
|
|
|
func _set_visible(key: String, value: bool) -> void:
|
|
if _parts.has(key):
|
|
(_parts[key] as Node3D).visible = value
|
|
|
|
## Sets an instance parameter on a group only when it really changes (saves server calls).
|
|
func _param(group: Array, group_key: String, parameter: String, value: float, epsilon: float = .01) -> void:
|
|
var key: String = group_key + ":" + parameter
|
|
if _sent.has(key) and absf(float(_sent[key]) - value) < epsilon:
|
|
return
|
|
_sent[key] = value
|
|
Materials.set_param(group, parameter, value)
|
|
|
|
func _build_bar(height: float) -> void:
|
|
_bar = Node3D.new()
|
|
_bar.name = "HealthBar"
|
|
_bar.position.y = height
|
|
add_child(_bar)
|
|
var width: float = 2.2 if tier != "base" else 2.6
|
|
var back := MeshInstance3D.new()
|
|
var quad := QuadMesh.new()
|
|
quad.size = Vector2(width + .08, .2)
|
|
back.mesh = quad
|
|
var back_material := StandardMaterial3D.new()
|
|
back_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
back_material.albedo_color = Color(.03, .03, .05, .75)
|
|
back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
back_material.no_depth_test = true
|
|
back.material_override = back_material
|
|
back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_bar.add_child(back)
|
|
_bar_fill = MeshInstance3D.new()
|
|
var fill := QuadMesh.new()
|
|
fill.size = Vector2(width, .13)
|
|
fill.center_offset = Vector3(width * .5, 0, 0)
|
|
_bar_fill.mesh = fill
|
|
var fill_material := StandardMaterial3D.new()
|
|
fill_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
fill_material.albedo_color = Materials.team_color(team, "light").lightened(.1)
|
|
fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
fill_material.billboard_keep_scale = true
|
|
fill_material.no_depth_test = true
|
|
fill_material.render_priority = 1
|
|
_bar_fill.material_override = fill_material
|
|
_bar_fill.position.x = -width * .5
|
|
_bar_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_bar.add_child(_bar_fill)
|
|
_bar.visible = false
|
|
|
|
## World-space point the bolt leaves from (crystal tip).
|
|
func muzzle() -> Vector3:
|
|
if crystal != null and crystal.is_inside_tree():
|
|
return crystal.global_position + Vector3(0, .35, 0)
|
|
return global_position + Vector3(0, float(ASSETS[tier].crystal), 0)
|
|
|
|
func is_standing() -> bool:
|
|
return state != State.DESTROYING and state != State.RUIN
|
|
|
|
## World box of the part that can hide figures (above the fade floor); empty for ruins.
|
|
func occluder_box() -> AABB:
|
|
if not is_standing():
|
|
return AABB()
|
|
var r: float = float(OCCLUDER_RADIUS[tier])
|
|
var top: float = float(ASSETS[tier].top)
|
|
return AABB(global_position + Vector3(-r, FADE_FLOOR, -r), Vector3(r * 2.0, top - FADE_FLOOR, r * 2.0))
|
|
|
|
func current_fade() -> float:
|
|
return _fade
|
|
|
|
## Authoritative HP -> visual state. `animate` is false while the view is (re)built, so a
|
|
## tower that is already down appears as a ruin without replaying its collapse.
|
|
func set_health(value: float, max_value: float, animate: bool = true) -> void:
|
|
var previous: float = fraction
|
|
fraction = clampf(value / maxf(1.0, max_value), 0.0, 1.0)
|
|
if fraction < previous - .0005 and value > 0.0:
|
|
_hit_kick = 1.0
|
|
var next: int = State.INTACT
|
|
if value <= 0.0:
|
|
if state == State.RUIN or state == State.DESTROYING:
|
|
next = state
|
|
else:
|
|
next = State.DESTROYING if animate else State.RUIN
|
|
elif fraction < HEAVY_BELOW:
|
|
next = State.HEAVY
|
|
elif fraction < LIGHT_BELOW:
|
|
next = State.LIGHT
|
|
if next != state:
|
|
_enter(next)
|
|
_bar.visible = value > 0.0 and fraction < .999
|
|
_bar_fill.scale.x = maxf(.001, fraction)
|
|
|
|
func _enter(next: int) -> void:
|
|
var from: int = state
|
|
state = next
|
|
var damaged_top: bool = next == State.HEAVY or (next == State.DESTROYING and from == State.HEAVY)
|
|
_set_visible("Upper", next != State.RUIN and not damaged_top)
|
|
_set_visible("Upper_Damaged", next != State.RUIN and damaged_top)
|
|
_set_visible("Debris", next == State.HEAVY or next == State.DESTROYING)
|
|
_set_visible("Lower", next != State.RUIN)
|
|
_set_visible("Ruin", next == State.RUIN)
|
|
for key: String in _parts:
|
|
if key == "Crystal" or key == "Ring" or key.begins_with("Shard") or key.begins_with("Banner"):
|
|
(_parts[key] as Node3D).visible = next != State.RUIN
|
|
_sparks.emitting = next == State.HEAVY
|
|
_smoke.emitting = next == State.LIGHT or next == State.HEAVY
|
|
_smoke.amount_ratio = .35 if next == State.LIGHT else 1.0
|
|
match next:
|
|
State.DESTROYING:
|
|
_destroy_time = 0.0
|
|
_dust_fired = false
|
|
Fx.fire(_burst, muzzle())
|
|
State.RUIN:
|
|
_destroy_time = -1.0
|
|
_ruin_smoke_left = 18.0 if from == State.DESTROYING else 0.0
|
|
_smoke.emitting = _ruin_smoke_left > 0.0
|
|
_smoke.amount_ratio = .6
|
|
light.visible = false
|
|
_bar.visible = false
|
|
Materials.set_param(_ruin, "energy", .14)
|
|
Materials.set_param(_ruin, "flicker", .6)
|
|
Materials.set_param(_ruin, "damage", .8)
|
|
Materials.set_param(_ruin, "scorch", .7)
|
|
for part: String in ["Upper", "Upper_Damaged"]:
|
|
if _parts.has(part):
|
|
(_parts[part] as Node3D).transform = _rest[part]
|
|
if crystal != null:
|
|
crystal.scale = Vector3.ONE
|
|
var damage: float = {State.INTACT: 0.0, State.LIGHT: .42, State.HEAVY: .82}.get(next, .9)
|
|
_param(_structure, "structure", "damage", damage)
|
|
_param(_structure, "structure", "scorch", {State.INTACT: 0.0, State.LIGHT: .22}.get(next, .55))
|
|
_param(_cloth, "cloth", "tatter", {State.INTACT: 0.0, State.LIGHT: .35, State.HEAVY: .7}.get(next, .8))
|
|
|
|
## Combat hook with the tower's own LuneUnit (null outside combat) and its target's view.
|
|
## Returns a bolt request [from, target_view, flight_seconds] when a shot leaves this frame.
|
|
func update_attack(unit: LuneUnit, target_view: Node3D, interval: float) -> Array:
|
|
if unit == null or not unit.alive or not is_standing():
|
|
_charge = move_toward(_charge, 0.0, .08)
|
|
return []
|
|
var request: Array = []
|
|
var aiming: bool = unit.in_range and unit.target != null and unit.target.alive
|
|
_charge = unit.attack_progress if aiming else move_toward(_charge, 0.0, .1)
|
|
var cycle: int = unit.attack_serial
|
|
if aiming and target_view != null and unit.attack_progress >= LAUNCH_AT and _launched_for != cycle:
|
|
_launched_for = cycle
|
|
request = [muzzle(), target_view, maxf(.06, (1.0 - unit.attack_progress) * interval)]
|
|
elif cycle != _last_serial and _last_serial >= 0 and _launched_for != cycle - 1 and target_view != null:
|
|
# A hit without a predicted launch (retarget, big time step): a quick bolt now.
|
|
request = [muzzle(), target_view, .08]
|
|
if cycle != _last_serial and _last_serial >= 0:
|
|
_energy_boost = 1.0
|
|
_last_serial = cycle
|
|
return request
|
|
|
|
func charge() -> float:
|
|
return _charge
|
|
|
|
func process_view(delta: float, sim_delta: float) -> void:
|
|
_time += delta
|
|
var calm: float = {State.INTACT: 1.0, State.LIGHT: .85, State.HEAVY: .6}.get(state, 0.0)
|
|
_energy_boost = maxf(0.0, _energy_boost - sim_delta * 4.0)
|
|
if crystal != null and crystal.visible and state != State.DESTROYING:
|
|
var bob: float = sin(_time * 1.3 + seed_value) * .05 * calm
|
|
if state == State.HEAVY:
|
|
bob += sin(_time * 17.0) * .012
|
|
crystal.position = _crystal_rest + Vector3(0, bob, 0)
|
|
crystal.rotation.y += delta * (.35 + _charge * 2.6)
|
|
if ring != null and ring.visible:
|
|
ring.rotation.y += delta * (.5 + _charge * 1.5) * (1.0 if state != State.HEAVY else .55)
|
|
ring.rotation.x = sin(_time * .7 + seed_value) * .08 + (.22 if state == State.HEAVY else 0.0)
|
|
for index: int in range(shards.size()):
|
|
var a: float = _time * .6 + TAU * index / shards.size()
|
|
shards[index].position = _crystal_rest + Vector3(cos(a) * 1.02, .12 + sin(_time * 1.1 + index) * .08, sin(a) * 1.02)
|
|
shards[index].rotation.y = a
|
|
var flicker: float = {State.INTACT: 0.0, State.LIGHT: .18, State.HEAVY: .6}.get(state, .8)
|
|
if state != State.DESTROYING and state != State.RUIN:
|
|
_param(_crystals, "crystal", "energy", calm * (1.0 + _charge * .9 + _energy_boost * 1.6), .02)
|
|
_param(_crystals, "crystal", "flicker", flicker)
|
|
_param(_crystals, "crystal", "fracture", 0.0 if state == State.INTACT else (.4 if state == State.LIGHT else .9))
|
|
_param(_structure, "structure", "energy", .35 + calm * .65 + _charge * .5, .03)
|
|
_param(_structure, "structure", "flicker", flicker)
|
|
if light.visible and state != State.DESTROYING:
|
|
var wobble: float = 1.0
|
|
if flicker > 0.0:
|
|
wobble = lerpf(1.0, .45 + .55 * absf(sin(_time * 23.0 + seed_value) * sin(_time * 7.3)), flicker)
|
|
light.light_energy = (.55 + 1.0 * calm) * wobble * (1.0 + _charge * .5 + _energy_boost * 1.2)
|
|
# A hit on the building: a short shudder of the upper part.
|
|
if _hit_kick > 0.0 and is_standing():
|
|
_hit_kick = maxf(0.0, _hit_kick - delta * 5.0)
|
|
var part: String = "Upper_Damaged" if state == State.HEAVY else "Upper"
|
|
if _parts.has(part):
|
|
(_parts[part] as Node3D).transform = (_rest[part] as Transform3D).translated_local(Vector3(sin(_time * 60.0) * .03 * _hit_kick, 0, 0))
|
|
if state == State.DESTROYING:
|
|
_destroy_step(delta)
|
|
elif state == State.RUIN and _ruin_smoke_left > 0.0:
|
|
_ruin_smoke_left -= delta
|
|
if _ruin_smoke_left <= 0.0:
|
|
_smoke.emitting = false
|
|
_fade = move_toward(_fade, fade_target, delta * 3.0)
|
|
_param(_instances, "all", "fade", _fade, .02)
|
|
|
|
## Collapse: the crystal overloads and fails, the upper part tips and sinks behind a dust
|
|
## burst, then the ruin replaces the tower. Short and bounded; the rules finished long ago.
|
|
func _destroy_step(delta: float) -> void:
|
|
_destroy_time += delta
|
|
var t: float = _destroy_time
|
|
var part: String = "Upper_Damaged" if _parts.has("Upper_Damaged") and (_parts["Upper_Damaged"] as Node3D).visible else "Upper"
|
|
if _parts.has(part):
|
|
var rest: Transform3D = _rest[part]
|
|
var fall: float = smoothstep(.15, 1.05, t)
|
|
var tilt: float = fall * fall * .95
|
|
var drop: float = fall * fall * 2.4
|
|
(_parts[part] as Node3D).transform = Transform3D(Basis(_upper_axis, tilt) * rest.basis,
|
|
rest.origin + Vector3(0, -drop, 0) + _upper_axis.cross(Vector3.UP) * fall * .6)
|
|
if crystal != null:
|
|
var overload: float = clampf(1.0 - t / .35, 0.0, 1.0)
|
|
Materials.set_param(_crystals, "energy", 3.0 * overload + .05)
|
|
Materials.set_param(_crystals, "flicker", 1.0)
|
|
crystal.scale = Vector3.ONE * maxf(.01, 1.0 + overload * .25 - smoothstep(.25, .5, t))
|
|
light.light_energy = maxf(0.0, 4.0 * (1.0 - t / .5))
|
|
if t >= .45 and not _dust_fired:
|
|
_dust_fired = true
|
|
Fx.fire(_dust, global_position + Vector3(0, .3, 0))
|
|
if t >= DESTROY_SECONDS * .62:
|
|
_enter(State.RUIN)
|