@tool extends Node3D ## Kestrel EC-01 — analog 0..1 clip driver + socket plumes. ## Attach to the Kestrel child (inherited hull scene), NOT the CharacterBody3D. ## Sockets: Vfx_RCS_* and Vfx_Abort_* must be Node3D (not GPUParticles). ## Ship axes: nose +Y, starboard +X, dorsal +Z. @export_range(0.0, 1.0) var gear_deploy := 1.0 : set = set_gear_deploy @export_range(0.0, 1.0) var hatch_crew_open := 0.0 : set = set_hatch_crew_open @export_range(0.0, 1.0) var hatch_ladder_deploy := 0.0 : set = set_hatch_ladder_deploy @export_range(0.0, 1.0) var hatch_dock_open := 0.0 : set = set_hatch_dock_open @export_range(0.0, 1.0) var instruments_demo := 0.4 : set = set_instruments_demo @export_enum("Off", "Nose", "Tail", "Starboard", "Port", "Dorsal", "Ventral") var rcs_direction := 0 @export var att_pitch := 0.0 @export var att_yaw := 0.0 @export var att_roll := 0.0 @export var abort_burn := false @export var cabin_dim := false : set = set_cabin_dim @export var clip_speed := 1.35 var _rcs: Array[GPUParticles3D] = [] var _abort: Array[GPUParticles3D] = [] var _cabin_ready := false var _cabin_lights: Array[Light3D] = [] var _cabin_rest: Array[Dictionary] = [] var _cabin_mats: Array[Dictionary] = [] var _clip_pos: Dictionary = {} var _clip_tgt: Dictionary = {} const RCS_THRUST := [ Vector3.ZERO, Vector3(0, 1, 0), Vector3(0, -1, 0), Vector3(1, 0, 0), Vector3(-1, 0, 0), Vector3(0, 0, 1), Vector3(0, 0, -1), ] func _ready() -> void: _clip_tgt = { "gear_deploy": gear_deploy, "hatch_crew_open": hatch_crew_open, "hatch_ladder_deploy": hatch_ladder_deploy, "hatch_dock_open": hatch_dock_open, "instruments_demo": instruments_demo, } _clip_pos = _clip_tgt.duplicate() for clip in _clip_tgt.keys(): _apply_clip(clip, float(_clip_pos[clip])) _arm_all("Vfx_RCS_*", 18, 0.09, 14.0, 22.0, 2.2, Color(0.82, 0.93, 1.0), 1.4, _rcs, false) _arm_all("Vfx_Abort_*", 36, 0.16, 28.0, 46.0, 1.6, Color(1.0, 0.58, 0.22), 5.0, _abort, true) _collect_cabin() set_process(true) print("Kestrel driver ready rcs=", _rcs.size(), " abort=", _abort.size()) func _process(delta: float) -> void: _tick_clips(delta) func _physics_process(_delta: float) -> void: var cmd := Vector3(att_pitch, att_roll, -att_yaw) var xlat: Vector3 = RCS_THRUST[clampi(rcs_direction, 0, RCS_THRUST.size() - 1)] var origin := global_position var ship := global_transform.basis var turning := cmd.length() > 0.15 for p in _rcs: if not is_instance_valid(p): continue var exhaust: Vector3 = (ship.inverse() * p.global_transform.basis.y).normalized() var fire := false if turning: var local_pos: Vector3 = ship.inverse() * (p.global_position - origin) if local_pos.length() < 0.05: local_pos = -exhaust var torque: Vector3 = local_pos.cross(-exhaust) fire = torque.dot(cmd) > 0.12 if not fire and xlat != Vector3.ZERO: fire = exhaust.dot(-xlat) > 0.55 p.emitting = fire for p in _abort: if is_instance_valid(p): p.emitting = abort_burn func set_cabin_dim(v: bool) -> void: cabin_dim = v if _cabin_ready: _apply_cabin() func set_gear_deploy(v: float) -> void: gear_deploy = v _clip_tgt["gear_deploy"] = clampf(v, 0.0, 1.0) func set_hatch_crew_open(v: float) -> void: hatch_crew_open = v _clip_tgt["hatch_crew_open"] = clampf(v, 0.0, 1.0) func set_hatch_ladder_deploy(v: float) -> void: hatch_ladder_deploy = v _clip_tgt["hatch_ladder_deploy"] = clampf(v, 0.0, 1.0) func set_hatch_dock_open(v: float) -> void: hatch_dock_open = v _clip_tgt["hatch_dock_open"] = clampf(v, 0.0, 1.0) func set_instruments_demo(v: float) -> void: instruments_demo = v _clip_tgt["instruments_demo"] = clampf(v, 0.0, 1.0) func _tick_clips(delta: float) -> void: for clip in _clip_tgt.keys(): var tgt := float(_clip_tgt[clip]) var pos := float(_clip_pos.get(clip, tgt)) if absf(pos - tgt) < 0.001: if pos != tgt: _clip_pos[clip] = tgt _apply_clip(clip, tgt) continue pos = move_toward(pos, tgt, clip_speed * delta) _clip_pos[clip] = pos _apply_clip(clip, pos) func _apply_clip(clip: StringName, t: float) -> void: var ap := _ap() if ap == null or not ap.has_animation(clip): return var len := ap.get_animation(clip).length ap.play(clip) ap.seek(clampf(t, 0.0, 1.0) * len, true) ap.pause() func _ap() -> AnimationPlayer: var ap := get_node_or_null("AnimationPlayer") as AnimationPlayer if ap == null: ap = find_child("AnimationPlayer", true, false) as AnimationPlayer return ap func _seek(clip: StringName, t: float) -> void: _apply_clip(clip, t) func _collect_cabin() -> void: _cabin_lights.clear() _cabin_rest.clear() _cabin_mats.clear() for n in find_children("*", "Light3D", true, false): var nm := String(n.name) if nm.begins_with("Nav"): continue if not (nm.begins_with("PointLight_") or nm == "CabinFill" or nm == "PanelLight" or nm.begins_with("CabinLamp")): var p := n.get_parent() var cabin := false while p: if String(p.name) == "CabinLights": cabin = true break p = p.get_parent() if not cabin: continue _cabin_lights.append(n) _cabin_rest.append({"energy": n.light_energy, "color": n.light_color}) for n in find_children("*", "MeshInstance3D", true, false): if not String(n.name).begins_with("LightStrip"): continue var mesh := n as MeshInstance3D var mat := mesh.get_active_material(0) if mat == null or not (mat is StandardMaterial3D): continue var copy := (mat as StandardMaterial3D).duplicate() as StandardMaterial3D mesh.set_surface_override_material(0, copy) _cabin_mats.append({ "mat": copy, "energy": copy.emission_energy_multiplier, "color": copy.emission, "on": copy.emission_enabled }) _cabin_ready = true _apply_cabin() func _apply_cabin() -> void: for i in _cabin_lights.size(): var lamp := _cabin_lights[i] var rest: Dictionary = _cabin_rest[i] lamp.light_color = rest.color lamp.light_energy = float(rest.energy) * (0.16 if cabin_dim else 1.0) for row in _cabin_mats: var mat := row.mat as StandardMaterial3D mat.emission_enabled = true if cabin_dim else bool(row.on) mat.emission = row.color mat.emission_energy_multiplier = float(row.energy) * (0.18 if cabin_dim else 1.0) func _arm_all(pattern: String, amount: int, life: float, vmin: float, vmax: float, spread: float, color: Color, reach: float, into: Array[GPUParticles3D], streak: bool) -> void: for n in find_children(pattern, "Node3D", true, false): if String(n.name) == "Plume": continue if n is GPUParticles3D: into.append(n as GPUParticles3D) continue var authored := _find_authored_plume(n) if authored: into.append(authored) continue var p := _make_plume(n, amount, life, vmin, vmax, spread, color, reach, streak) if p: into.append(p) func _find_authored_plume(socket: Node3D) -> GPUParticles3D: if socket is GPUParticles3D: return socket as GPUParticles3D for child in socket.get_children(): if child is GPUParticles3D and String(child.name) != "Plume": return child as GPUParticles3D var ours := socket.get_node_or_null("Plume") if ours is GPUParticles3D: return ours as GPUParticles3D return null func _make_plume(socket: Node3D, amount: int, life: float, vmin: float, vmax: float, spread: float, color: Color, reach: float, streak: bool) -> GPUParticles3D: var existing := socket.get_node_or_null("Plume") if existing is GPUParticles3D: return existing as GPUParticles3D if existing: existing.free() var p := GPUParticles3D.new() p.name = "Plume" p.amount = amount p.lifetime = life p.local_coords = true p.visibility_aabb = AABB(Vector3(-reach, 0, -reach), Vector3(reach * 2.0, reach * 4.0, reach * 2.0)) var proc := ParticleProcessMaterial.new() proc.direction = Vector3(0, 1, 0) proc.spread = spread proc.initial_velocity_min = vmin proc.initial_velocity_max = vmax proc.gravity = Vector3.ZERO proc.scale_min = 0.55 if streak else 0.04 proc.scale_max = 0.95 if streak else 0.09 proc.color = color if streak: proc.particle_flag_align_y = true p.process_material = proc var draw := StandardMaterial3D.new() draw.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA draw.blend_mode = BaseMaterial3D.BLEND_MODE_ADD draw.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED draw.billboard_mode = BaseMaterial3D.BILLBOARD_DISABLED if streak else BaseMaterial3D.BILLBOARD_PARTICLES draw.albedo_color = color p.material_override = draw var quad := QuadMesh.new() quad.size = Vector2(0.07, 0.55) if streak else Vector2(0.035, 0.035) p.draw_pass_1 = quad p.emitting = false var dir := socket.position if dir.length() < 0.001: dir = Vector3.UP dir = (socket.quaternion.inverse() * dir).normalized() var y := dir var up := Vector3.FORWARD if absf(y.dot(Vector3.UP)) > 0.9 else Vector3.UP var x := up.cross(y).normalized() var z := x.cross(y).normalized() p.basis = Basis(x, y, z) socket.add_child(p) return p