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>
167 lines
5.9 KiB
GDScript
167 lines
5.9 KiB
GDScript
extends Node3D
|
||
## Imported, weighted character. The battle clock owns playback, including pause/2×.
|
||
var skeleton: Skeleton3D
|
||
var player: AnimationPlayer
|
||
var bounds: AABB
|
||
var clips: Dictionary = {}
|
||
var attack_cycle: Array[String] = ["attack_a", "attack_b"]
|
||
## Ground covered by one walk cycle (metres); big figures such as the Schlundkrone step wider.
|
||
var stride: float = .52
|
||
var current_clip: String = ""
|
||
var dead: bool = false
|
||
var _clock: float = 0.0
|
||
var _walk_clock: float = 0.0
|
||
var _event_left: float = 0.0
|
||
var _last_hits: int = 0
|
||
var _had_attack: bool = false
|
||
var _last_ability: float = 0.0
|
||
var _last_hit: float = 0.0
|
||
var _moving: bool = false
|
||
var _preview_time: float = 0.0
|
||
var _ability_time: float = -1.0
|
||
var _hit_time: float = -1.0
|
||
|
||
func setup(source: Node3D, box: AABB) -> void:
|
||
bounds = box
|
||
add_child(source)
|
||
for node: Node in source.find_children("*", "Skeleton3D", true, false):
|
||
skeleton = node as Skeleton3D
|
||
for node: Node in source.find_children("*", "AnimationPlayer", true, false):
|
||
player = node as AnimationPlayer
|
||
if player != null:
|
||
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
|
||
player.playback_default_blend_time = .12
|
||
for key: String in player.get_animation_list():
|
||
var short_name: String = key.get_slice("/",key.get_slice_count("/")-1)
|
||
clips[short_name] = key
|
||
|
||
func get_aabb() -> AABB:
|
||
return bounds
|
||
|
||
func clip_length(clip: String) -> float:
|
||
return player.get_animation(clips[clip]).length if clips.has(clip) else 0.0
|
||
|
||
func _sample(clip: String, at: float, delta: float = 0.0, blend: float = .12) -> void:
|
||
if player == null or not clips.has(clip) or not is_inside_tree():
|
||
return
|
||
if current_clip != clip:
|
||
player.play(clips[clip], blend)
|
||
current_clip = clip
|
||
player.advance(delta)
|
||
player.seek(clampf(at,0,clip_length(clip)),true)
|
||
|
||
func preview(clip: String, at: float) -> void:
|
||
_sample(clip, at, 0.0, 0.0)
|
||
|
||
func pose(time: float, moving: bool, attack: float, ability: float, hit: float) -> void:
|
||
# Legacy preview entry point, retained for model galleries and callers.
|
||
var dt: float = clampf(time-_preview_time,0,.1)
|
||
_preview_time = time
|
||
if attack > 0:
|
||
_sample("attack_a",.90+(.28-attack),dt)
|
||
elif ability > 0:
|
||
_sample("ability",fmod(time,clip_length("ability")),dt)
|
||
elif hit > 0:
|
||
_sample("hit",.18-hit,dt)
|
||
else:
|
||
var clip: String = "walk" if moving else "idle"
|
||
_sample(clip,fmod(time,clip_length(clip)),dt)
|
||
|
||
func update_combat(delta: float, distance: float, in_range: bool, progress: float, hits: int, ability: float, hit: float, living: bool) -> void:
|
||
if not living:
|
||
if not dead:
|
||
dead = true
|
||
_clock = 0.0
|
||
_clock += delta
|
||
_sample("death",_clock,delta,.06)
|
||
return
|
||
_clock += delta
|
||
var moving: bool = delta > 0 and distance / delta > .04
|
||
# Direct damage stays in AttackComponent. Pose reaches contact exactly on its event.
|
||
if in_range:
|
||
if hits > _last_hits:
|
||
_had_attack = true
|
||
_last_hits = hits
|
||
var recovering: bool = _had_attack and progress < .30
|
||
var index: int = maxi(0,hits-1) if recovering else hits
|
||
var clip: String = attack_cycle[index % attack_cycle.size()]
|
||
var at: float = .90+.35*progress/.30 if recovering else .90*maxf(0,progress-(.30 if _had_attack else 0.0))/(.70 if _had_attack else 1.0)
|
||
_sample(clip,at,delta,.055)
|
||
_event_left = 0
|
||
else:
|
||
_had_attack = false
|
||
_last_hits = hits
|
||
if ability > _last_ability + .05:
|
||
_event_left = clip_length("ability")
|
||
_clock = 0.0
|
||
_sample("ability",0,delta)
|
||
elif hit > _last_hit + .05 and _event_left <= 0.0:
|
||
_event_left = clip_length("hit")
|
||
_clock = 0.0
|
||
_sample("hit",0,delta,.045)
|
||
if _event_left > 0:
|
||
_event_left -= delta
|
||
_sample(current_clip,_clock,delta)
|
||
elif moving:
|
||
if not _moving:
|
||
_clock = 0.0
|
||
# One stride travels `stride` metres. Separation and slow effects affect cadence.
|
||
_walk_clock += distance / stride * clip_length("walk")
|
||
if _clock < clip_length("start"):
|
||
_sample("start",_clock,delta)
|
||
else:
|
||
_sample("walk",fmod(_walk_clock,clip_length("walk")),delta)
|
||
else:
|
||
if _moving:
|
||
_clock = 0.0
|
||
if current_clip in ["walk","start","stop"] and _clock < clip_length("stop"):
|
||
_sample("stop",_clock,delta)
|
||
else:
|
||
_sample("idle",fmod(_clock,clip_length("idle")),delta)
|
||
if ability > _last_ability + .05:
|
||
_ability_time = 0.0
|
||
if hit > _last_hit + .05:
|
||
_hit_time = 0.0
|
||
if _ability_time >= 0:
|
||
_ability_time += delta
|
||
if in_range:
|
||
_overlay("ability",_ability_time,.45,["Head","Forearm.L","Forearm.R"])
|
||
if _ability_time >= clip_length("ability"):
|
||
_ability_time = -1.0
|
||
if _hit_time >= 0:
|
||
_hit_time += delta
|
||
if in_range:
|
||
_overlay("hit",_hit_time,.65,["Spine","Head"])
|
||
if _hit_time >= clip_length("hit"):
|
||
_hit_time = -1.0
|
||
_last_ability = ability
|
||
_last_hit = hit
|
||
_moving = moving
|
||
|
||
## Plays a one-off clip (e.g. the boss roar) as an event outside of attacks; ignored when
|
||
## the figure has no such clip or is dead. Purely visual.
|
||
func play_event(clip: String) -> void:
|
||
if dead or not clips.has(clip):
|
||
return
|
||
_event_left = clip_length(clip)
|
||
_clock = 0.0
|
||
_sample(clip, 0.0, 0.0)
|
||
|
||
func _overlay(clip: String, at: float, weight: float, bones: Array) -> void:
|
||
# Only selected joints react while the primary swing keeps its contact time.
|
||
if not is_inside_tree() or not clips.has(clip):
|
||
return
|
||
var animation: Animation = player.get_animation(clips[clip])
|
||
for track: int in range(animation.get_track_count()):
|
||
if animation.track_get_type(track) != Animation.TYPE_ROTATION_3D:
|
||
continue
|
||
var path: NodePath = animation.track_get_path(track)
|
||
if path.get_subname_count() == 0 or not str(path.get_subname(0)) in bones:
|
||
continue
|
||
var index: int = skeleton.find_bone(path.get_subname(0))
|
||
if index < 0:
|
||
continue
|
||
var rest: Quaternion = skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
|
||
var value: Quaternion = animation.rotation_track_interpolate(track,minf(at,animation.length))
|
||
var offset: Quaternion = Quaternion.IDENTITY.slerp(rest.inverse()*value,weight)
|
||
skeleton.set_bone_pose_rotation(index,skeleton.get_bone_pose_rotation(index)*offset)
|