extends Node3D class_name PlayerJetVFX ## Four restrained anime-thrust accents: two shoulder nozzles and two foot ## nozzles. The geometry is deliberately graphic instead of particle-heavy so ## the character remains 90% silhouette/animation and only 10% visible tech. const DOUBLE_JUMP_TIME := 0.40 const FLAME_LENGTH := 0.28 const SHOULDER_SOCKETS := [ Vector3(-0.27, 1.34, 0.16), Vector3(0.27, 1.34, 0.16), ] const FOOT_SOCKETS := [ Vector3(-0.16, 0.14, 0.08), Vector3(0.16, 0.14, 0.08), ] var _flames: Array[Node3D] = [] var _burst_left := 0.0 var _dash_active := false var _dash_tail := 0.0 var _render_enabled := true var _exhaust_direction := Vector3.DOWN var _time := 0.0 var _visual_model: Node3D = null func _ready() -> void: for socket in SHOULDER_SOCKETS + FOOT_SOCKETS: _flames.append(_make_flame(socket)) visible = false func _make_flame(socket: Vector3) -> Node3D: var flame := Node3D.new() flame.name = "JetFlame%02d" % _flames.size() flame.position = socket flame.set_meta("jet_flame", true) add_child(flame) var outer := MeshInstance3D.new() outer.name = "OuterFlame" outer.mesh = _cone_mesh(0.045, FLAME_LENGTH) outer.material_override = _flame_material( Color(1.0, 0.24, 0.05, 0.72), Color(1.0, 0.12, 0.02), 3.2) outer.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF outer.position.y = FLAME_LENGTH * 0.5 flame.add_child(outer) var inner := MeshInstance3D.new() inner.name = "HotCore" inner.mesh = _cone_mesh(0.016, FLAME_LENGTH * 0.72) inner.material_override = _flame_material( Color(0.82, 0.96, 1.0, 0.86), Color(0.36, 0.82, 1.0), 3.2) inner.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF inner.position.y = FLAME_LENGTH * 0.36 flame.add_child(inner) return flame func _cone_mesh(radius: float, height: float) -> CylinderMesh: var mesh := CylinderMesh.new() mesh.bottom_radius = radius mesh.top_radius = 0.002 mesh.height = height mesh.radial_segments = 8 mesh.rings = 1 return mesh func _flame_material(albedo: Color, emission: Color, energy: float) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.albedo_color = albedo mat.emission_enabled = true mat.emission = emission mat.emission_energy_multiplier = energy return mat func set_render_enabled(enabled: bool) -> void: _render_enabled = enabled if not enabled: visible = false func set_visual_model(model: Node3D) -> void: _visual_model = model func burst_double_jump() -> void: _burst_left = DOUBLE_JUMP_TIME func set_dash(active: bool, movement_local: Vector2) -> void: if _dash_active and not active: _dash_tail = 0.14 _dash_active = active if not active: return var move := Vector3(movement_local.x, 0.0, -movement_local.y) if move.length_squared() < 0.01: move = Vector3.FORWARD # Exhaust opposes travel and dips slightly, which makes the force direction # readable without turning the flame into a large sci-fi effect. _exhaust_direction = (-move.normalized() + Vector3.DOWN * 0.20).normalized() func get_flame_count() -> int: return _flames.size() func get_exhaust_direction() -> Vector3: return _exhaust_direction func _process(delta: float) -> void: _time += delta _update_sockets() _burst_left = maxf(0.0, _burst_left - delta) _dash_tail = maxf(0.0, _dash_tail - delta) var dash_visible := _dash_active or _dash_tail > 0.0 var active := dash_visible or _burst_left > 0.0 visible = _render_enabled and active if not visible: return if _burst_left > 0.0 and not dash_visible: # The double-jump jets push mostly down, with just enough rear cant to # keep the four flames separated in the silhouette. _exhaust_direction = Vector3(0.0, -1.0, 0.16).normalized() var pulse := 0.90 + sin(_time * 54.0) * 0.08 + sin(_time * 31.0) * 0.04 if _burst_left > 0.0 and not _dash_active: pulse *= clampf(_burst_left / 0.08, 0.15, 1.0) for i in _flames.size(): var flame := _flames[i] # A tiny outward fan keeps all four graphic wedges legible from the # chase camera, even when the centerline points almost straight at it. var side: float = [0.13, -0.13, 0.06, -0.06][i] var nozzle_dir := (_exhaust_direction + Vector3(side, 0.0, 0.0)).normalized() flame.quaternion = Quaternion(Vector3.UP, nozzle_dir) var stagger := 1.0 + sin(_time * 43.0 + float(i) * 1.7) * 0.05 flame.scale = Vector3(1.0, pulse * stagger, 1.0) func _update_sockets() -> void: if not is_instance_valid(_visual_model) \ or not _visual_model.has_method("get_jet_socket_world_positions"): return var sockets: Array = _visual_model.get_jet_socket_world_positions() if sockets.size() != _flames.size(): return for i in _flames.size(): _flames[i].position = to_local(sockets[i])