extends Node3D ## Kestrel EC-01 plume driver. Godot 4.7. ## Attach to the Kestrel child. Leave escape_capsule.gd on EscapeCapsule. ## Nose +Y, starboard +X, dorsal +Z. The four abort motors are the drive plume. ## ## This script does not move the hull. escape_capsule.gd owns velocity. ## listen_keys is off so walking the player does not light jets or shove the craft. ## Capsule sets abort_burn, att_pitch/yaw/roll, rcs_direction, hatches, cabin_dim. @export var listen_keys := false @export_range(0.0, 1.0) var gear_deploy := 1.0 @export_range(0.0, 1.0) var hatch_crew_open := 0.0 @export_range(0.0, 1.0) var hatch_ladder_deploy := 0.0 @export_range(0.0, 1.0) var hatch_dock_open := 0.0 @export_range(0.0, 1.0) var instruments_demo := 0.4 @export var cabin_lights := true @export var cabin_dim := false @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 ## Tick this in the inspector (or Remote tab while running) to self-test every jet. @export var debug_fire := false const _RIBBON := "shader_type spatial; render_mode blend_add, unshaded, cull_disabled, depth_draw_never, fog_disabled; uniform vec4 root_color : source_color = vec4(1.0, 1.0, 1.0, 0.95); uniform vec4 mid_color : source_color = vec4(0.47, 0.71, 1.0, 0.35); uniform vec4 tip_color : source_color = vec4(0.16, 0.31, 1.0, 0.0); uniform float opacity : hint_range(0.0, 2.0, 0.001) = 0.0; void fragment() { float v = UV.y; float edge = abs(UV.x - 0.5) * 2.0; float side = 1.0 - smoothstep(0.36, 1.0, edge); vec3 col = mix(root_color.rgb, mid_color.rgb, smoothstep(0.0, 0.45, v)); col = mix(col, tip_color.rgb, smoothstep(0.35, 1.0, v)); float a = mix(root_color.a, mid_color.a, smoothstep(0.05, 0.45, v)); a = mix(a, tip_color.a, smoothstep(0.45, 1.0, v)); ALBEDO = col; ALPHA = clamp(a * side * opacity, 0.0, 1.0); }" var _player: AnimationPlayer var _shader: Shader var _rcs: Array[Node3D] = [] var _aborts: Array[Dictionary] = [] var _abort_hold := 0.0 var _time := 0.0 var _prev := {} var _clip_cache := {} var _warm_frames := 12 var _cabin_ready := false var _applied_dim := false var _applied_lights := true var _cabin_lights_n: Array[Light3D] = [] var _cabin_rest: Array[Dictionary] = [] var _cabin_mats: Array[Dictionary] = [] const _DIRS: Array[Vector3] = [ 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: _shader = Shader.new() _shader.code = _RIBBON _player = get_node_or_null("AnimationPlayer") as AnimationPlayer if _player == null: _player = find_child("AnimationPlayer", true, false) as AnimationPlayer _arm() _collect_cabin() _apply_clips() print("Kestrel driver ready: rcs=", _rcs.size(), " abort=", _aborts.size(), " cabin_lights=", _cabin_lights_n.size(), " strips=", _cabin_mats.size(), " anim_player=", _player != null) if _rcs.is_empty(): push_warning("Kestrel driver: no Vfx_RCS_* nodes found under " + str(get_path())) if _aborts.is_empty(): push_warning("Kestrel driver: no Vfx_Abort_* nodes found under " + str(get_path())) if _player == null: push_warning("Kestrel driver: no AnimationPlayer found (gear/hatches will not move)") func _physics_process(delta: float) -> void: _time += delta if listen_keys: _read_keys() if debug_fire: abort_burn = true rcs_direction = 1 + int(_time) % 6 _apply_clips() if _warm_frames > 0: _warm_frames -= 1 # FIX: the capsule sets cabin_dim as a plain variable, so nothing re-applied it. if cabin_dim != _applied_dim or cabin_lights != _applied_lights: _apply_cabin() _burn(_wish(), delta) func _read_keys() -> void: if _pressed(KEY_G): gear_deploy = 0.0 if gear_deploy > 0.5 else 1.0 if _pressed(KEY_C): hatch_crew_open = 0.0 if hatch_crew_open > 0.5 else 1.0 if _pressed(KEY_L): hatch_ladder_deploy = 0.0 if hatch_ladder_deploy > 0.5 else 1.0 if _pressed(KEY_H): hatch_dock_open = 0.0 if hatch_dock_open > 0.5 else 1.0 if _pressed(KEY_I): instruments_demo = 0.15 if instruments_demo > 0.5 else 1.0 if _pressed(KEY_K): cabin_lights = not cabin_lights abort_burn = Input.is_physical_key_pressed(KEY_SPACE) func _pressed(key: Key) -> bool: var down := Input.is_physical_key_pressed(key) var was: bool = _prev.get(key, false) _prev[key] = down return down and not was func _wish() -> Vector3: var local := _DIRS[clampi(rcs_direction, 0, _DIRS.size() - 1)] if listen_keys: var pad := Vector3.ZERO if Input.is_physical_key_pressed(KEY_W): pad.y += 1.0 if Input.is_physical_key_pressed(KEY_S): pad.y -= 1.0 if Input.is_physical_key_pressed(KEY_D): pad.x += 1.0 if Input.is_physical_key_pressed(KEY_A): pad.x -= 1.0 if Input.is_physical_key_pressed(KEY_E): pad.z += 1.0 if Input.is_physical_key_pressed(KEY_Q): pad.z -= 1.0 if pad.length_squared() > 0.0: local = pad.normalized() if local == Vector3.ZERO: return Vector3.ZERO return (global_transform.basis * local).normalized() func _apply_clips() -> void: _seek("gear_deploy", gear_deploy) _seek("hatch_crew_open", hatch_crew_open) _seek("hatch_ladder_deploy", hatch_ladder_deploy) _seek("hatch_dock_open", hatch_dock_open) _seek("instruments_demo", instruments_demo) ## FIX: only re-seek when the value actually changed. The old version called ## play()+seek()+pause() on five clips every physics frame. func _clip_name(clip: StringName) -> StringName: if _player.has_animation(clip): return clip for a in _player.get_animation_list(): if String(a).ends_with("/" + String(clip)): return a return &"" ## Re-seeks only when the value changed, but keeps re-applying for the first few ## frames so the pose sticks after the AnimationPlayer finishes initializing. func _seek(clip: StringName, t: float) -> void: if _player == null: return var clip_id := _clip_name(clip) if clip_id == &"": return t = clampf(t, 0.0, 1.0) if _warm_frames <= 0 and _clip_cache.has(clip) and is_equal_approx(float(_clip_cache[clip]), t): return _clip_cache[clip] = t var clip_len := _player.get_animation(clip_id).length _player.play(clip_id) _player.seek(t * clip_len, true) _player.pause() func _collect_cabin() -> void: _cabin_lights_n.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 var keep := nm.begins_with("PointLight_") or nm == "CabinFill" or nm == "PanelLight" or nm.begins_with("CabinLamp") if not keep: var p := n.get_parent() while p: if String(p.name) == "CabinLights": keep = true break p = p.get_parent() if not keep: continue _cabin_lights_n.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: if not _cabin_ready: return _applied_dim = cabin_dim _applied_lights = cabin_lights var dim := cabin_dim or not cabin_lights for i in _cabin_lights_n.size(): var lamp := _cabin_lights_n[i] var rest: Dictionary = _cabin_rest[i] lamp.light_color = rest.color lamp.light_energy = float(rest.energy) * (0.08 if dim else 1.0) for row in _cabin_mats: var mat := row.mat as StandardMaterial3D mat.emission_enabled = true if dim else bool(row.on) mat.emission = row.color mat.emission_energy_multiplier = float(row.energy) * (0.05 if dim else 1.0) func _arm() -> void: var i := 0 for n in find_children("*", "Node3D", true, false): var nm := String(n.name) if nm.begins_with("Vfx_RCS_"): _rcs.append(_spear(n, 1.2, 0.09, Color(0.863, 0.937, 1.0, 0.95), Color(0.41, 0.67, 1.0, 0.35), Color(0.25, 0.45, 0.8, 0.0), 0.82, 2, float(i))) i += 1 elif nm.begins_with("Vfx_Abort_"): _aborts.append(_make_abort(n)) func _burn(wish: Vector3, delta: float) -> void: var ship := global_transform.basis var ship_inv := ship.inverse() # FIX: capsule yaws about +Z by +att_yaw (rotate_object_local(FORWARD, -yaw) == +Z, +yaw), # so the commanded angular velocity is (pitch, roll, +yaw). The old -att_yaw fired the # opposite-side jets for yaw. var cmd := Vector3(att_pitch, att_roll, att_yaw) var turning := cmd.length() > 0.15 var origin := global_position for ribbon in _rcs: var want := 0.0 var exhaust: Vector3 = (ship_inv * ribbon.global_transform.basis.y).normalized() if turning: var local_pos: Vector3 = ship_inv * (ribbon.global_position - origin) if local_pos.length() < 0.05: local_pos = -exhaust var torque: Vector3 = local_pos.cross(-exhaust) if torque.dot(cmd) > 0.12: want = 1.0 if want < 0.5 and wish != Vector3.ZERO: if exhaust.dot(-(ship_inv * wish)) > 0.7: want = 1.0 var burn := float(ribbon.get_meta("burn")) burn = _smooth(burn, want, delta, 0.08 if want > burn else 0.14) ribbon.set_meta("burn", burn) _set_spear(ribbon, burn, _time) if abort_burn: _abort_hold = minf(1.0, _abort_hold + delta / 0.22) else: _abort_hold = maxf(0.0, _abort_hold - delta / 0.28) var on := _abort_hold > 0.03 var flick := 0.86 + 0.14 * sin(_time * 41.0) var sheath_scale := 0.9 + 0.1 * sin(_time * 19.0) for a in _aborts: var core := a.core as Node3D var sheath := a.sheath as Node3D _set_opacity(core, _abort_hold * 0.95 * flick if on else 0.0) _set_opacity(sheath, _abort_hold * 0.82 * flick if on else 0.0) sheath.scale = Vector3(1, sheath_scale, 1) var sparks := a.sparks as GPUParticles3D var sheath_sparks := a.sheath_sparks as GPUParticles3D sparks.emitting = on sheath_sparks.emitting = on var proc := sparks.process_material as ParticleProcessMaterial var proc2 := sheath_sparks.process_material as ParticleProcessMaterial if proc: proc.color = Color(1, 1, 1, _abort_hold * 0.55) if proc2: proc2.color = Color(1, 1, 1, _abort_hold * 0.55) var flash := a.flash as MeshInstance3D flash.visible = on var fmat := flash.material_override as StandardMaterial3D if fmat: var tint := fmat.albedo_color tint.a = _abort_hold * 0.45 fmat.albedo_color = tint flash.scale = Vector3.ONE * (0.06 + _abort_hold * 0.04) var light := a.light as SpotLight3D light.light_energy = _abort_hold * 8.0 if on else 0.0 ## Exhaust direction in the socket's parent space. The model puts each Vfx_ socket's ## translation along its exhaust direction (RCS = +Y, Abort = -Y), so position is the aim. func _exhaust(socket: Node3D) -> Vector3: var dir := socket.position if dir.length() < 0.001: dir = Vector3.UP return (socket.quaternion.inverse() * dir).normalized() func _basis_y(dir: Vector3) -> Basis: var y := dir.normalized() if y.length() < 0.001: y = Vector3.UP 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() return Basis(x, y, z) func _ribbon_mat(root: Color, mid: Color, tip: Color) -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = _shader mat.set_shader_parameter("root_color", root) mat.set_shader_parameter("mid_color", mid) mat.set_shader_parameter("tip_color", tip) mat.set_shader_parameter("opacity", 0.0) return mat func _sheet(length: float, width: float) -> ArrayMesh: var half := width * 0.5 var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) st.set_uv(Vector2(0, 0)); st.add_vertex(Vector3(-half, 0, 0)) st.set_uv(Vector2(1, 0)); st.add_vertex(Vector3(half, 0, 0)) st.set_uv(Vector2(1, 1)); st.add_vertex(Vector3(half, length, 0)) st.set_uv(Vector2(0, 1)); st.add_vertex(Vector3(-half, length, 0)) st.add_index(0); st.add_index(1); st.add_index(2) st.add_index(0); st.add_index(2); st.add_index(3) return st.commit() func _taper(length: float, w0: float, w1: float) -> ArrayMesh: var rows := 11 var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) for i in rows: var t := float(i) / float(rows - 1) var y := t * length var w := (w0 + (w1 - w0) * pow(t, 0.7)) * 0.5 st.set_uv(Vector2(0, t)); st.add_vertex(Vector3(-w, y, 0)) st.set_uv(Vector2(1, t)); st.add_vertex(Vector3(w, y, 0)) for i in rows - 1: var a := i * 2 st.add_index(a); st.add_index(a + 1); st.add_index(a + 2) st.add_index(a + 1); st.add_index(a + 3); st.add_index(a + 2) return st.commit() func _crossed(socket: Node3D, mesh: ArrayMesh, mat: ShaderMaterial, planes: int, node_name: String, length: float, opacity: float, flick_seed: float) -> Node3D: var g := Node3D.new() g.name = node_name g.set_meta("mat", mat) g.set_meta("len", length) g.set_meta("opacity", opacity) g.set_meta("seed", flick_seed) g.set_meta("burn", 0.0) for i in planes: var m := MeshInstance3D.new() m.mesh = mesh m.material_override = mat m.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF m.extra_cull_margin = 8.0 m.rotation.y = (float(i) / float(planes)) * PI g.add_child(m) g.basis = _basis_y(_exhaust(socket)) g.visible = false socket.add_child(g) return g func _spear(socket: Node3D, length: float, width: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, flick_seed: float) -> Node3D: return _crossed(socket, _sheet(length, width), _ribbon_mat(root, mid, tip), planes, "Spear", length, opacity, flick_seed) func _taper_ribbon(socket: Node3D, length: float, w0: float, w1: float, root: Color, mid: Color, tip: Color, opacity: float, planes: int, node_name: String) -> Node3D: return _crossed(socket, _taper(length, w0, w1), _ribbon_mat(root, mid, tip), planes, node_name, length, opacity, 1.0) func _set_opacity(ribbon: Node3D, opacity: float) -> void: var mat := ribbon.get_meta("mat") as ShaderMaterial var shown := opacity > 0.02 ribbon.visible = shown mat.set_shader_parameter("opacity", opacity if shown else 0.0) func _set_spear(ribbon: Node3D, amount: float, time: float) -> void: var a := maxf(amount, 0.0) if a < 0.02: _set_opacity(ribbon, 0.0) return var flick_seed := float(ribbon.get_meta("seed")) var flick := 0.82 + 0.18 * sin(time * 37.0 + flick_seed * 2.1) ribbon.scale = Vector3.ONE _set_opacity(ribbon, float(ribbon.get_meta("opacity")) * minf(a, 1.0) * flick) func _smooth(current: float, target: float, delta: float, seconds: float) -> float: var k := 1.0 - exp(-delta / maxf(0.05, seconds)) return current + (target - current) * k func _soft_disc() -> Texture2D: var img := Image.create(64, 64, false, Image.FORMAT_RGBA8) for y in 64: for x in 64: var d := Vector2(x - 31.5, y - 31.5).length() / 32.0 var t := clampf(d, 0.0, 1.0) var s := t * t * (3.0 - 2.0 * t) var a := (1.0 - s) * (1.0 - s) img.set_pixel(x, y, Color(1, 1, 1, a)) return ImageTexture.create_from_image(img) func _disc_mat() -> StandardMaterial3D: 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_PARTICLES draw.vertex_color_use_as_albedo = true draw.albedo_texture = _soft_disc() draw.albedo_color = Color.WHITE draw.cull_mode = BaseMaterial3D.CULL_DISABLED draw.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED return draw func _abort_ramp() -> GradientTexture1D: var g := Gradient.new() g.offsets = PackedFloat32Array([0.0, 0.15, 0.45, 1.0]) g.colors = PackedColorArray([ Color(1.0, 0.97, 0.92, 1.0), Color(1.0, 0.46, 0.12, 0.9), Color(0.55, 0.16, 0.05, 0.45), Color(0.25, 0.05, 0.01, 0.0), ]) var tex := GradientTexture1D.new() tex.gradient = g return tex func _sparks(socket: Node3D, node_name: String, amount: int, life: float, vmin: float, vmax: float, spread_deg: float, size: float, reach: float) -> GPUParticles3D: var p := GPUParticles3D.new() p.name = node_name p.amount = amount p.lifetime = life p.explosiveness = 0.0 p.local_coords = true p.visibility_aabb = AABB(Vector3(-reach, 0, -reach), Vector3(reach * 2.0, reach * 3.0, reach * 2.0)) var proc := ParticleProcessMaterial.new() proc.direction = Vector3(0, 1, 0) proc.spread = spread_deg proc.initial_velocity_min = vmin proc.initial_velocity_max = vmax proc.gravity = Vector3.ZERO proc.scale_min = 0.85 proc.scale_max = 1.15 proc.color = Color(1, 1, 1, 0.55) proc.color_ramp = _abort_ramp() p.process_material = proc p.material_override = _disc_mat() var quad := QuadMesh.new() quad.size = Vector2(size, size) p.draw_pass_1 = quad p.emitting = false p.basis = _basis_y(_exhaust(socket)) socket.add_child(p) return p func _abort_flash(socket: Node3D) -> MeshInstance3D: var aim := _basis_y(_exhaust(socket)) var mat := StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED mat.albedo_texture = _soft_disc() mat.albedo_color = Color(1.0, 0.945, 0.867, 0.0) mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED var m := MeshInstance3D.new() m.name = "AbortFlash" var quad := QuadMesh.new() quad.size = Vector2(1, 1) m.mesh = quad m.material_override = mat m.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF m.position = aim.y * 0.02 m.scale = Vector3.ONE * 0.08 m.visible = false socket.add_child(m) return m func _spot(socket: Node3D) -> SpotLight3D: var aim := _basis_y(_exhaust(socket)) var light := SpotLight3D.new() light.name = "ExhaustLight" light.light_color = Color(1.0, 0.416, 0.157) light.light_energy = 0.0 light.spot_range = 6.5 light.spot_angle = rad_to_deg(0.5) light.spot_attenuation = 1.8 light.shadow_enabled = false light.position = aim.y * 0.08 var z := -(aim.y) var up := aim.x var x := up.cross(z).normalized() var y := z.cross(x).normalized() light.basis = Basis(x, y, z) socket.add_child(light) return light func _make_abort(socket: Node3D) -> Dictionary: var core := _taper_ribbon(socket, 2.1, 0.06, 0.20, Color(1.0, 0.97, 0.92, 1.0), Color(1.0, 0.45, 0.12, 0.72), Color(0.40, 0.08, 0.02, 0.0), 0.95, 3, "AbortCore") var sheath := _taper_ribbon(socket, 5.2, 0.10, 0.78, Color(1.0, 0.55, 0.18, 1.0), Color(1.0, 0.28, 0.06, 0.55), Color(0.35, 0.06, 0.01, 0.0), 0.82, 3, "AbortSheath") return { "core": core, "sheath": sheath, "sparks": _sparks(socket, "AbortCoreSparks", 10, 0.15, 18.0, 28.0, 1.6, 0.05, 6.0), "sheath_sparks": _sparks(socket, "AbortSheathSparks", 18, 0.26, 14.0, 22.0, 4.5, 0.09, 8.0), "flash": _abort_flash(socket), "light": _spot(socket), }