Refine sprint and landing dust effects

This commit is contained in:
Alexander Sellite 2026-08-13 14:23:11 -04:00
parent c239ca6bd5
commit 7d38bc962b
5 changed files with 350 additions and 31 deletions

View file

@ -34,6 +34,7 @@ const FishingRodAttachmentScene = preload(
const HeldItemAttachmentScene = preload(
"res://player/held_item_attachment.tscn"
)
const SprintDustTrailType = preload("res://player/sprint_dust_trail.gd")
signal retract_visual_finished
@ -90,6 +91,7 @@ const BLINK_DURATION_SECONDS: float = 0.11
const CHARACTER_CALL_MOUTH_ID: String = "open_ah"
const CHARACTER_CALL_MOUTH_DURATION_SECONDS: float = 0.16
const BASE_REEL_SPEED: float = 0.16
const LANDING_DUST_MIN_FALL_SPEED: float = 2.5
# The target Android handheld exposes its physical right trigger through
# Godot's left-trigger axis. Keep the role named here so the platform mapping
# remains isolated from camera behavior.
@ -424,8 +426,11 @@ var _fishing_rod: Node3D
var _fishing_rod_tip: Marker3D
var _active_item_is_rod: bool = false
var _controller_mapping_manager: ControllerMappingManagerType
var _sprint_dust_landing_ready: bool = false
var _sprint_dust_airborne: bool = false
var _sprint_dust_fall_speed: float = 0.0
@onready var _sprint_dust: CPUParticles3D = %SprintDust
@onready var _sprint_dust: SprintDustTrailType = %SprintDust
func _ready() -> void:
@ -608,7 +613,7 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void:
_update_blink(delta)
_update_character_animation()
_update_sprint_dust()
_update_sprint_dust(delta)
# The hand bone supplies the attachment position, but its animated wrist
# rotation should not turn the flat fish/catch artwork edge-on. Keep each
# display aligned with the character's facing while it follows the hand.
@ -694,24 +699,54 @@ func _schedule_next_blink() -> void:
)
func _update_sprint_dust() -> void:
func _update_sprint_dust(delta: float) -> void:
if _sprint_dust == null:
return
var horizontal_speed_squared: float = (
velocity.x * velocity.x + velocity.z * velocity.z
)
var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z)
var grounded: bool = is_on_floor()
if _water_recovery_active:
_sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0
elif not _sprint_dust_landing_ready:
# Establish a real ground contact before arming landing effects. This
# prevents the player's initial placement from looking like a fall.
_sprint_dust_landing_ready = grounded
elif not grounded:
_sprint_dust_airborne = true
_sprint_dust_fall_speed = maxf(
_sprint_dust_fall_speed,
maxf(-velocity.y, 0.0),
)
elif _sprint_dust_airborne:
if _sprint_dust_fall_speed >= LANDING_DUST_MIN_FALL_SPEED:
var landing_facing: Vector3 = horizontal_velocity.normalized()
if landing_facing.is_zero_approx() and _visuals != null:
landing_facing = -_visuals.global_basis.z
_sprint_dust.emit_landing_burst(
global_position,
landing_facing,
)
_sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0
var horizontal_speed_squared: float = horizontal_velocity.length_squared()
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
_sprint_dust.emitting = (
is_on_floor()
var should_emit: bool = (
grounded
and not _sitting
and not _water_recovery_active
and horizontal_speed_squared
> running_threshold * running_threshold
)
_sprint_dust.update_trail(
delta,
global_position,
horizontal_velocity,
should_emit,
)
func _get_controller_camera_stick() -> Vector2:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=21 format=3]
[gd_scene load_steps=22 format=3]
[ext_resource type="Script" path="res://player/player.gd" id="1_script"]
[ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"]
@ -15,6 +15,7 @@
[ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="13_character"]
[ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"]
[ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"]
[ext_resource type="Script" path="res://player/sprint_dust_trail.gd" id="16_sprint_dust"]
[sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45
@ -25,7 +26,7 @@ material = ExtResource("12_blob_shadow")
size = Vector2(1.15, 0.72)
[sub_resource type="Shader" id="SprintDustShader"]
code = "shader_type spatial;\nrender_mode blend_mix, depth_draw_never, cull_disabled, unshaded, shadows_disabled;\n\nuniform vec4 dust_color : source_color = vec4(0.47, 0.39, 0.27, 0.42);\nvarying float particle_phase;\n\nvoid vertex() {\n\tparticle_phase = INSTANCE_CUSTOM.y;\n\tMODELVIEW_MATRIX = VIEW_MATRIX * mat4(\n\t\tINV_VIEW_MATRIX[0],\n\t\tINV_VIEW_MATRIX[1],\n\t\tINV_VIEW_MATRIX[2],\n\t\tMODEL_MATRIX[3]\n\t);\n}\n\nvoid fragment() {\n\tvec2 centered_uv = UV * 2.0 - 1.0;\n\tfloat angle = atan(centered_uv.y, centered_uv.x);\n\tfloat edge_wobble = sin(angle * 5.0 + particle_phase * 8.0) * 0.07;\n\tfloat radius = length(centered_uv);\n\tfloat soft_shape = 1.0 - smoothstep(0.48 + edge_wobble, 1.0 + edge_wobble, radius);\n\tfloat fade_in = smoothstep(0.0, 0.12, particle_phase);\n\tfloat fade_out = 1.0 - smoothstep(0.38, 1.0, particle_phase);\n\tALBEDO = dust_color.rgb;\n\tALPHA = soft_shape * dust_color.a * fade_in * fade_out;\n}\n"
code = "shader_type spatial;\nrender_mode blend_mix, depth_draw_never, cull_disabled, unshaded, shadows_disabled;\n\nuniform vec3 dust_color : source_color = vec3(0.61, 0.57, 0.53);\nvarying flat float puff_phase;\nvarying flat float puff_alpha;\n\nvoid vertex() {\n\tpuff_phase = INSTANCE_CUSTOM.x;\n\tpuff_alpha = INSTANCE_CUSTOM.w;\n\tVERTEX.xy *= INSTANCE_CUSTOM.yz;\n\tfloat puff_rotation = puff_phase * 6.28318530718;\n\tfloat rotation_sine = sin(puff_rotation);\n\tfloat rotation_cosine = cos(puff_rotation);\n\tVERTEX.xy = mat2(\n\t\tvec2(rotation_cosine, -rotation_sine),\n\t\tvec2(rotation_sine, rotation_cosine)\n\t) * VERTEX.xy;\n\tMODELVIEW_MATRIX = VIEW_MATRIX * mat4(\n\t\tINV_VIEW_MATRIX[0],\n\t\tINV_VIEW_MATRIX[1],\n\t\tINV_VIEW_MATRIX[2],\n\t\tMODEL_MATRIX[3]\n\t);\n}\n\nvoid fragment() {\n\tvec2 centered_uv = UV * 2.0 - 1.0;\n\tfloat angle = atan(centered_uv.y, centered_uv.x);\n\tfloat edge_wobble =\n\t\tsin(angle * 5.0 + puff_phase * 8.0) * 0.07\n\t\t+ sin(angle * 3.0 - puff_phase * 5.0) * 0.025;\n\tfloat radius = length(centered_uv);\n\tif (radius > 0.82 + edge_wobble) {\n\t\tdiscard;\n\t}\n\tALBEDO = dust_color;\n\tALPHA = puff_alpha;\n}\n"
[sub_resource type="ShaderMaterial" id="SprintDustMaterial"]
render_priority = 1
@ -33,7 +34,7 @@ shader = SubResource("SprintDustShader")
[sub_resource type="QuadMesh" id="SprintDustMesh"]
material = SubResource("SprintDustMaterial")
size = Vector2(0.38, 0.28)
size = Vector2(1, 1)
[node name="Player" type="CharacterBody3D"]
collision_layer = 2
@ -66,27 +67,11 @@ rotation_degrees = Vector3(-90, 0, 0)
cast_shadow = 0
mesh = SubResource("GroundShadowMesh")
[node name="SprintDust" type="CPUParticles3D" parent="."]
[node name="SprintDust" type="MultiMeshInstance3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 0.08, 0)
emitting = false
amount = 16
lifetime = 0.65
randomness = 0.55
visibility_aabb = AABB(-4, -2, -4, 8, 5, 8)
local_coords = false
emission_shape = 3
emission_box_extents = Vector3(0.24, 0.03, 0.16)
direction = Vector3(0, 1, 0)
spread = 65.0
gravity = Vector3(0, -0.8, 0)
initial_velocity_min = 0.35
initial_velocity_max = 0.8
angular_velocity_min = -90.0
angular_velocity_max = 90.0
scale_amount_min = 0.35
scale_amount_max = 0.72
mesh = SubResource("SprintDustMesh")
cast_shadow = 0
script = ExtResource("16_sprint_dust")
puff_mesh = SubResource("SprintDustMesh")
[node name="CharacterRig" parent="Visuals" instance=ExtResource("13_character")]
rotation_degrees = Vector3(0, 180, 0)

249
player/sprint_dust_trail.gd Normal file
View file

@ -0,0 +1,249 @@
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)

View file

@ -0,0 +1 @@
uid://o2gi5oqunsp8

View file

@ -27,6 +27,55 @@ func _run() -> void:
await physics_frame
var player := main.get("_player") as Player
var sprint_dust := player.get_node("%SprintDust") as SprintDustTrail
assert(sprint_dust != null)
assert(sprint_dust.get_active_puff_count() == 0)
sprint_dust.update_trail(
0.016,
Vector3.ZERO,
Vector3(4.5, 0.0, 0.0),
true,
)
assert(sprint_dust.get_active_puff_count() == 0)
sprint_dust.update_trail(
0.016,
Vector3(0.5, 0.0, 0.0),
Vector3(4.5, 0.0, 0.0),
true,
)
assert(sprint_dust.get_active_puff_count() == 1)
var planted_puff_position: Vector3 = (
sprint_dust.get_first_active_puff_position()
)
sprint_dust.update_trail(
0.08,
Vector3(0.75, 0.0, 0.0),
Vector3(4.5, 0.0, 0.0),
true,
)
assert(
sprint_dust.get_first_active_puff_position()
== planted_puff_position
)
sprint_dust.update_trail(
0.08,
Vector3(0.75, 0.0, 0.0),
Vector3.ZERO,
false,
)
assert(sprint_dust.get_active_puff_count() == 1)
sprint_dust.clear_trail()
assert(sprint_dust.get_active_puff_count() == 0)
sprint_dust.emit_landing_burst(Vector3.ZERO, Vector3.FORWARD)
assert(sprint_dust.get_active_puff_count() == 5)
sprint_dust.update_trail(
0.08,
Vector3.ZERO,
Vector3.ZERO,
false,
)
assert(sprint_dust.get_active_puff_count() == 5)
sprint_dust.clear_trail()
var service := main.get_node(
"%NetworkSurfaceDrawingService"
) as NetworkSurfaceDrawingService