diff --git a/player/player.gd b/player/player.gd index b71add7..717b5e8 100644 --- a/player/player.gd +++ b/player/player.gd @@ -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: diff --git a/player/player.tscn b/player/player.tscn index b0ad70c..43ab900 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -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) diff --git a/player/sprint_dust_trail.gd b/player/sprint_dust_trail.gd new file mode 100644 index 0000000..124b6ae --- /dev/null +++ b/player/sprint_dust_trail.gd @@ -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) diff --git a/player/sprint_dust_trail.gd.uid b/player/sprint_dust_trail.gd.uid new file mode 100644 index 0000000..c40239c --- /dev/null +++ b/player/sprint_dust_trail.gd.uid @@ -0,0 +1 @@ +uid://o2gi5oqunsp8 diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 7f5ea20..2d81b0d 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -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