class_name SprintDustTrail extends MultiMeshInstance3D const PUFF_COUNT: int = 12 const PUFF_SPACING: float = 0.46 const PUFF_LIFETIME: float = 0.62 const PUFF_GROW_TIME: float = 0.16 const PUFF_FADE_TIME: float = 0.22 const PUFF_BACK_OFFSET: float = 0.14 const PUFF_SIDE_OFFSET: float = 0.11 const PUFF_HEIGHT: float = 0.08 const PUFF_MIN_SCALE: float = 0.22 @export var puff_mesh: Mesh var _puff_positions: Array[Vector3] = [] var _puff_ages := PackedFloat32Array() var _puff_widths := PackedFloat32Array() var _puff_heights := PackedFloat32Array() var _puff_phases := PackedFloat32Array() var _next_puff: int = 0 var _spawn_serial: int = 0 var _foot_side: float = -1.0 var _tracking_run: bool = false var _previous_source_position: Vector3 var _distance_since_puff: float = 0.0 func _ready() -> void: top_level = true global_transform = Transform3D.IDENTITY _initialize_pool() func update_trail( delta: float, source_position: Vector3, movement_velocity: Vector3, is_running: bool, ) -> void: _update_existing_puffs(delta) custom_aabb = AABB( source_position - Vector3(5.0, 2.0, 5.0), Vector3(10.0, 5.0, 10.0), ) if not is_running: _tracking_run = false _distance_since_puff = 0.0 return var flat_source := Vector3( source_position.x, source_position.y, source_position.z, ) if not _tracking_run: _tracking_run = true _previous_source_position = flat_source _distance_since_puff = 0.0 return var segment := flat_source - _previous_source_position segment.y = 0.0 var remaining_distance: float = segment.length() if remaining_distance <= 0.0001: _previous_source_position = flat_source return var segment_direction: Vector3 = segment / remaining_distance var movement_direction := Vector3( movement_velocity.x, 0.0, movement_velocity.z, ).normalized() if movement_direction.is_zero_approx(): movement_direction = segment_direction var cursor: Vector3 = _previous_source_position var distance_to_next: float = PUFF_SPACING - _distance_since_puff while remaining_distance >= distance_to_next: cursor += segment_direction * distance_to_next _spawn_puff(cursor, movement_direction) remaining_distance -= distance_to_next _distance_since_puff = 0.0 distance_to_next = PUFF_SPACING _distance_since_puff += remaining_distance _previous_source_position = flat_source func clear_trail() -> void: _tracking_run = false _distance_since_puff = 0.0 for index: int in PUFF_COUNT: _puff_ages[index] = PUFF_LIFETIME _write_inactive_puff(index) func emit_landing_burst( source_position: Vector3, facing_direction: Vector3, ) -> void: var direction := Vector3( facing_direction.x, 0.0, facing_direction.z, ).normalized() if direction.is_zero_approx(): direction = Vector3.FORWARD var first_direction: Vector3 = -direction for puff_index: int in 5: var spread_direction := first_direction.rotated( Vector3.UP, TAU * float(puff_index) / 5.0, ) var radius: float = lerpf( 0.38, 0.46, _stable_variation(_spawn_serial + puff_index + 81), ) _spawn_puff_at( source_position + spread_direction * radius + Vector3.UP * PUFF_HEIGHT, 0.95, ) func get_active_puff_count() -> int: var active_count: int = 0 for age: float in _puff_ages: if age < PUFF_LIFETIME: active_count += 1 return active_count func get_first_active_puff_position() -> Vector3: for index: int in PUFF_COUNT: if _puff_ages[index] < PUFF_LIFETIME: return _puff_positions[index] return Vector3.INF func _initialize_pool() -> void: var pool := MultiMesh.new() pool.transform_format = MultiMesh.TRANSFORM_3D pool.use_custom_data = true pool.instance_count = PUFF_COUNT pool.mesh = puff_mesh multimesh = pool _puff_positions.resize(PUFF_COUNT) _puff_ages.resize(PUFF_COUNT) _puff_widths.resize(PUFF_COUNT) _puff_heights.resize(PUFF_COUNT) _puff_phases.resize(PUFF_COUNT) clear_trail() func _spawn_puff(source_position: Vector3, direction: Vector3) -> void: var lateral := Vector3(-direction.z, 0.0, direction.x) var position := ( source_position - direction * PUFF_BACK_OFFSET + lateral * PUFF_SIDE_OFFSET * _foot_side + Vector3.UP * PUFF_HEIGHT ) _spawn_puff_at(position) _foot_side *= -1.0 func _spawn_puff_at( position: Vector3, size_multiplier: float = 1.0, ) -> void: var variation: float = _stable_variation(_spawn_serial) var second_variation: float = _stable_variation(_spawn_serial + 37) var index: int = _next_puff _puff_positions[index] = position _puff_ages[index] = 0.0 _puff_widths[index] = ( lerpf(0.34, 0.43, variation) * size_multiplier ) _puff_heights[index] = ( lerpf(0.23, 0.30, second_variation) * size_multiplier ) _puff_phases[index] = variation _write_puff(index, PUFF_MIN_SCALE, 1.0) _next_puff = (_next_puff + 1) % PUFF_COUNT _spawn_serial += 1 func _update_existing_puffs(delta: float) -> void: if multimesh == null: return for index: int in PUFF_COUNT: var age: float = _puff_ages[index] if age >= PUFF_LIFETIME: continue age += delta _puff_ages[index] = age if age >= PUFF_LIFETIME: _write_inactive_puff(index) continue var visual_scale: float = 1.0 if age < PUFF_GROW_TIME: var grow_weight: float = smoothstep( 0.0, 1.0, age / PUFF_GROW_TIME, ) visual_scale = lerpf(PUFF_MIN_SCALE, 1.0, grow_weight) var visual_alpha: float = 1.0 if age > PUFF_LIFETIME - PUFF_FADE_TIME: var fade_weight: float = smoothstep( 0.0, 1.0, (age - (PUFF_LIFETIME - PUFF_FADE_TIME)) / PUFF_FADE_TIME, ) visual_alpha = 1.0 - fade_weight _write_puff(index, visual_scale, visual_alpha) func _write_puff( index: int, visual_scale: float, visual_alpha: float, ) -> void: var puff_transform := Transform3D.IDENTITY puff_transform.origin = _puff_positions[index] multimesh.set_instance_transform(index, puff_transform) multimesh.set_instance_custom_data( index, Color( _puff_phases[index], _puff_widths[index] * visual_scale, _puff_heights[index] * visual_scale, visual_alpha, ), ) func _write_inactive_puff(index: int) -> void: var inactive_transform := Transform3D.IDENTITY inactive_transform.origin = _puff_positions[index] multimesh.set_instance_transform(index, inactive_transform) multimesh.set_instance_custom_data(index, Color(0.0, 0.0, 0.0, 0.0)) func _stable_variation(serial: int) -> float: return fposmod(sin(float(serial + 1) * 12.9898) * 43758.5453, 1.0)