straywild/world/rain_puddle_presentation.gd

645 lines
17 KiB
GDScript

class_name RainPuddlePresentation
extends Node3D
## Client-only rain puddles. These are deliberately visual-only: they have no
## collision, water-region, fishing, persistence, or networking components.
const PUDDLE_SURFACE_MATERIALS: Array[StringName] = [
&"grass_lite",
&"grass",
&"sand",
]
const TERRAIN_COLLISION_MASK: int = 1
const MAX_PUDDLES: int = 14
const SURFACE_CELL_SIZE: float = 8.0
const SURFACE_SEARCH_CELL_RADIUS: int = 3
const SURFACE_CLEARANCE_ABOVE_WATER: float = 0.08
const MINIMUM_SURFACE_UP_DOT: float = 0.985
const MINIMUM_PLAYER_DISTANCE: float = 2.5
const MAXIMUM_PLAYER_DISTANCE: float = 17.0
const RELEASE_DISTANCE: float = 23.0
# Puddle radius is intentionally generous: the presentation should read from
# the normal gameplay camera rather than as tiny ground specks.
const MINIMUM_PUDDLE_RADIUS: float = 0.78
const MAXIMUM_PUDDLE_RADIUS: float = 2.76
const MINIMUM_PUDDLE_SPACING: float = 4.35
const MAXIMUM_PLAYER_HEIGHT_OFFSET: float = 1.0
const SPAWN_INTERVAL_SECONDS: float = 0.18
const FADE_IN_ALPHA_PER_SECOND: float = 0.85
const FADE_OUT_ALPHA_PER_SECOND: float = 0.055
const SPLASH_COOLDOWN_SECONDS: float = 0.34
const SPLASH_COLOR := Color(0.42, 0.76, 0.80, 0.92)
const PUDDLE_COLOR := Color(0.055, 0.18, 0.225, 0.42)
class PuddleState:
var visual: MeshInstance3D
var material: StandardMaterial3D
var center: Vector3 = Vector3.ZERO
var radii: Vector2 = Vector2.ONE
var rotation_y: float = 0.0
var alpha: float = 0.0
var active: bool = false
var player_was_inside: bool = false
var splash_cooldown: float = 0.0
var _test_world: TestWorld
var _weather_service: WorldWeatherService
var _local_player: Player
var _light_performance_profile: bool = false
var _is_active: bool = false
var _suppressed: bool = false
var _surface_cache_ready: bool = false
var _surface_triangles: Array[PackedVector3Array] = []
var _surface_triangle_indices_by_cell: Dictionary = {}
var _puddles: Array[PuddleState] = []
var _random := RandomNumberGenerator.new()
var _spawn_accumulator: float = 0.0
var _player_was_grounded: bool = false
var _visual_root: Node3D
var _splash_root: Node3D
var _puddle_mesh: CylinderMesh
func _ready() -> void:
set_process(false)
func _exit_tree() -> void:
if (
_weather_service != null
and _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.disconnect(_on_weather_changed)
func configure(
test_world: TestWorld,
weather_service: WorldWeatherService,
local_player: Player,
light_performance_profile: bool,
) -> void:
if (
_weather_service != null
and _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.disconnect(_on_weather_changed)
_test_world = test_world
_weather_service = weather_service
_local_player = local_player
_light_performance_profile = light_performance_profile
_random.seed = _seed_for_world()
if (
_weather_service != null
and not _weather_service.weather_changed.is_connected(
_on_weather_changed
)
):
_weather_service.weather_changed.connect(_on_weather_changed)
if not _light_performance_profile:
_prepare_visual_pool()
refresh_world()
_refresh_processing()
func set_active(active: bool) -> void:
if _is_active == active:
return
_is_active = active
if not _is_active:
_clear_puddles()
_refresh_processing()
func set_suppressed(suppressed: bool) -> void:
if _suppressed == suppressed:
return
_suppressed = suppressed
if _suppressed:
_clear_puddles()
_refresh_processing()
func refresh_world() -> void:
_surface_cache_ready = false
_surface_triangles.clear()
_surface_triangle_indices_by_cell.clear()
_spawn_accumulator = 0.0
_player_was_grounded = false
_random.seed = _seed_for_world()
_clear_puddles()
_refresh_processing()
func is_light_performance_profile() -> bool:
return _light_performance_profile
func get_active_puddle_count() -> int:
var count: int = 0
for puddle: PuddleState in _puddles:
if puddle.active and puddle.alpha > 0.0:
count += 1
return count
func _process(delta: float) -> void:
if not _can_present_puddles():
return
var player_position: Vector3 = _local_player.global_position
var is_raining: bool = _weather_service.is_raining()
if is_raining:
_ensure_surface_cache()
_release_distant_puddles(player_position)
_fade_puddles_in(delta)
_spawn_accumulator += delta
while _spawn_accumulator >= SPAWN_INTERVAL_SECONDS:
_spawn_accumulator -= SPAWN_INTERVAL_SECONDS
if not _spawn_puddle_near(player_position):
break
else:
_fade_puddles_out(delta)
_update_player_splashes(player_position, delta)
_refresh_processing()
func _on_weather_changed(
_weather: WorldWeatherService.Weather,
_seconds_remaining: float,
) -> void:
if _weather_service != null and _weather_service.is_raining():
_spawn_accumulator = SPAWN_INTERVAL_SECONDS
_refresh_processing()
func _can_present_puddles() -> bool:
return (
not _light_performance_profile
and _is_active
and not _suppressed
and _test_world != null
and _test_world.is_world_ready()
and _weather_service != null
and _local_player != null
and _local_player.is_local_control_enabled()
)
func _refresh_processing() -> void:
var should_process: bool = (
_can_present_puddles()
and (
_weather_service.is_raining()
or _has_active_puddles()
)
)
set_process(should_process)
func _has_active_puddles() -> bool:
for puddle: PuddleState in _puddles:
if puddle.active:
return true
return false
func _prepare_visual_pool() -> void:
if _visual_root != null:
return
_visual_root = Node3D.new()
_visual_root.name = "PuddleVisuals"
add_child(_visual_root)
_splash_root = Node3D.new()
_splash_root.name = "PuddleSplashes"
add_child(_splash_root)
_puddle_mesh = CylinderMesh.new()
_puddle_mesh.top_radius = 1.0
_puddle_mesh.bottom_radius = 1.0
_puddle_mesh.height = 0.012
_puddle_mesh.radial_segments = 12
_puddle_mesh.rings = 1
for _index: int in MAX_PUDDLES:
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.roughness = 0.18
material.metallic = 0.0
var visual := MeshInstance3D.new()
visual.name = "RainPuddle"
visual.mesh = _puddle_mesh
visual.material_override = material
visual.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
visual.visible = false
_visual_root.add_child(visual)
var puddle := PuddleState.new()
puddle.visual = visual
puddle.material = material
_puddles.append(puddle)
func _ensure_surface_cache() -> void:
if _surface_cache_ready or _test_world == null:
return
_surface_cache_ready = true
var saltwater_height: float = _test_world.get_saltwater_surface_height()
if is_nan(saltwater_height):
return
var candidate_triangles: Array[PackedVector3Array] = (
_test_world.get_spawn_surface_triangles(
PUDDLE_SURFACE_MATERIALS,
saltwater_height + SURFACE_CLEARANCE_ABOVE_WATER,
MINIMUM_SURFACE_UP_DOT,
)
)
for triangle: PackedVector3Array in candidate_triangles:
if not is_puddle_surface_eligible(triangle, saltwater_height):
continue
var triangle_index: int = _surface_triangles.size()
_surface_triangles.append(triangle)
var center: Vector3 = (
triangle[0] + triangle[1] + triangle[2]
) / 3.0
var cell: Vector2i = _surface_cell_for(center)
var triangle_indices: Array = (
_surface_triangle_indices_by_cell.get(cell, []) as Array
)
triangle_indices.append(triangle_index)
_surface_triangle_indices_by_cell[cell] = triangle_indices
func _spawn_puddle_near(player_position: Vector3) -> bool:
if _active_puddle_count() >= MAX_PUDDLES:
return false
var puddle: PuddleState = _first_inactive_puddle()
if puddle == null:
return false
for _attempt: int in 24:
var triangle_index: int = _random_nearby_triangle_index(
player_position
)
if triangle_index < 0 or triangle_index >= _surface_triangles.size():
return false
var triangle: PackedVector3Array = _surface_triangles[triangle_index]
var position: Vector3 = _random_point_in_triangle(triangle)
var horizontal_distance: float = Vector2(
position.x,
position.z,
).distance_to(Vector2(player_position.x, player_position.z))
if (
horizontal_distance < MINIMUM_PLAYER_DISTANCE
or horizontal_distance > MAXIMUM_PLAYER_DISTANCE
or _is_candidate_blocked(position)
or _is_near_existing_puddle(position)
):
continue
var maximum_radius: float = minf(
MAXIMUM_PUDDLE_RADIUS,
_minimum_triangle_edge_distance(position, triangle) * 0.65,
)
if maximum_radius < MINIMUM_PUDDLE_RADIUS:
continue
var radius_x: float = _random.randf_range(
MINIMUM_PUDDLE_RADIUS,
maximum_radius,
)
var radius_z: float = radius_x * _random.randf_range(0.58, 0.86)
_activate_puddle(
puddle,
position,
Vector2(radius_x, radius_z),
_random.randf_range(0.0, TAU),
)
return true
return false
func _active_puddle_count() -> int:
var count: int = 0
for puddle: PuddleState in _puddles:
if puddle.active:
count += 1
return count
func _first_inactive_puddle() -> PuddleState:
for puddle: PuddleState in _puddles:
if not puddle.active:
return puddle
return null
func _random_nearby_triangle_index(player_position: Vector3) -> int:
var base_cell: Vector2i = _surface_cell_for(player_position)
for _attempt: int in 16:
var cell := base_cell + Vector2i(
_random.randi_range(
-SURFACE_SEARCH_CELL_RADIUS,
SURFACE_SEARCH_CELL_RADIUS,
),
_random.randi_range(
-SURFACE_SEARCH_CELL_RADIUS,
SURFACE_SEARCH_CELL_RADIUS,
),
)
var triangle_indices: Array = (
_surface_triangle_indices_by_cell.get(cell, []) as Array
)
if not triangle_indices.is_empty():
return int(triangle_indices[_random.randi_range(
0,
triangle_indices.size() - 1,
)])
return -1
func _activate_puddle(
puddle: PuddleState,
center: Vector3,
radii: Vector2,
rotation_y: float,
) -> void:
puddle.center = center
puddle.radii = radii
puddle.rotation_y = rotation_y
puddle.alpha = 0.0
puddle.active = true
puddle.player_was_inside = false
puddle.splash_cooldown = 0.0
puddle.visual.global_position = center + Vector3.UP * 0.012
puddle.visual.rotation = Vector3(0.0, rotation_y, 0.0)
puddle.visual.scale = Vector3(radii.x, 1.0, radii.y)
_apply_puddle_alpha(puddle)
puddle.visual.visible = true
func _fade_puddles_in(delta: float) -> void:
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
puddle.alpha = minf(
1.0,
puddle.alpha + FADE_IN_ALPHA_PER_SECOND * delta,
)
_apply_puddle_alpha(puddle)
func _fade_puddles_out(delta: float) -> void:
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
puddle.alpha = maxf(
0.0,
puddle.alpha - FADE_OUT_ALPHA_PER_SECOND * delta,
)
if puddle.alpha <= 0.0:
_deactivate_puddle(puddle)
continue
_apply_puddle_alpha(puddle)
func _release_distant_puddles(player_position: Vector3) -> void:
for puddle: PuddleState in _puddles:
if (
puddle.active
and Vector2(puddle.center.x, puddle.center.z).distance_to(
Vector2(player_position.x, player_position.z)
) > RELEASE_DISTANCE
):
_deactivate_puddle(puddle)
func _deactivate_puddle(puddle: PuddleState) -> void:
puddle.active = false
puddle.alpha = 0.0
puddle.player_was_inside = false
puddle.splash_cooldown = 0.0
if puddle.visual != null:
puddle.visual.visible = false
func _clear_puddles() -> void:
for puddle: PuddleState in _puddles:
_deactivate_puddle(puddle)
if _splash_root == null:
return
for splash: Node in _splash_root.get_children():
splash.queue_free()
func _apply_puddle_alpha(puddle: PuddleState) -> void:
if puddle.material == null:
return
var color := PUDDLE_COLOR
color.a *= puddle.alpha
puddle.material.albedo_color = color
func _is_candidate_blocked(position: Vector3) -> bool:
if not is_inside_tree():
return false
var world: World3D = get_world_3d()
if world == null:
return false
var query := PhysicsRayQueryParameters3D.create(
position + Vector3.UP * 0.05,
position + Vector3.UP * 1.75,
TERRAIN_COLLISION_MASK,
)
query.collide_with_areas = false
query.collide_with_bodies = true
return not world.direct_space_state.intersect_ray(query).is_empty()
func _is_near_existing_puddle(position: Vector3) -> bool:
var horizontal_position := Vector2(position.x, position.z)
for puddle: PuddleState in _puddles:
if not puddle.active:
continue
var minimum_distance: float = (
MINIMUM_PUDDLE_SPACING + maxf(puddle.radii.x, puddle.radii.y)
)
if horizontal_position.distance_to(
Vector2(puddle.center.x, puddle.center.z)
) < minimum_distance:
return true
return false
func _update_player_splashes(
player_position: Vector3,
delta: float,
) -> void:
var grounded: bool = _local_player.is_on_floor()
for puddle: PuddleState in _puddles:
if not puddle.active or puddle.alpha <= 0.08:
continue
puddle.splash_cooldown = maxf(
0.0,
puddle.splash_cooldown - delta,
)
var is_inside: bool = is_point_inside_puddle(
player_position,
puddle.center,
puddle.radii,
puddle.rotation_y,
)
var should_splash: bool = (
is_inside
and puddle.splash_cooldown <= 0.0
and (
not puddle.player_was_inside
or (not _player_was_grounded and grounded)
)
)
if should_splash:
_emit_splash(player_position, puddle.center.y)
puddle.splash_cooldown = SPLASH_COOLDOWN_SECONDS
puddle.player_was_inside = is_inside
_player_was_grounded = grounded
func _emit_splash(player_position: Vector3, surface_height: float) -> void:
if _splash_root == null or not _splash_root.visible:
return
var particles := CPUParticles3D.new()
particles.name = "RainPuddleSplash"
particles.global_position = Vector3(
player_position.x,
surface_height + 0.04,
player_position.z,
)
particles.amount = 7
particles.lifetime = 0.38
particles.one_shot = true
particles.local_coords = true
particles.explosiveness = 1.0
particles.direction = Vector3.UP
particles.spread = 48.0
particles.gravity = Vector3(0.0, -3.2, 0.0)
particles.initial_velocity_min = 0.65
particles.initial_velocity_max = 1.15
particles.scale_amount_min = 0.7
particles.scale_amount_max = 1.0
var material := StandardMaterial3D.new()
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = SPLASH_COLOR
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.roughness = 1.0
var droplet_mesh := BoxMesh.new()
droplet_mesh.size = Vector3(0.028, 0.04, 0.028)
droplet_mesh.material = material
particles.mesh = droplet_mesh
_splash_root.add_child(particles)
particles.emitting = true
var cleanup := create_tween()
cleanup.tween_interval(0.5)
cleanup.tween_callback(particles.queue_free)
func _seed_for_world() -> int:
if _test_world == null:
return 1
return max(_test_world.get_generation_seed(), 1) ^ 0x5261696e
func _surface_cell_for(position: Vector3) -> Vector2i:
return Vector2i(
floori(position.x / SURFACE_CELL_SIZE),
floori(position.z / SURFACE_CELL_SIZE),
)
func _random_point_in_triangle(triangle: PackedVector3Array) -> Vector3:
var first: float = _random.randf()
var second: float = _random.randf()
if first + second > 1.0:
first = 1.0 - first
second = 1.0 - second
return triangle[0] + (triangle[1] - triangle[0]) * first + (
triangle[2] - triangle[0]
) * second
func _minimum_triangle_edge_distance(
position: Vector3,
triangle: PackedVector3Array,
) -> float:
var horizontal_position := Vector2(position.x, position.z)
return minf(
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[0].x, triangle[0].z),
Vector2(triangle[1].x, triangle[1].z),
),
minf(
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[1].x, triangle[1].z),
Vector2(triangle[2].x, triangle[2].z),
),
_horizontal_distance_to_segment(
horizontal_position,
Vector2(triangle[2].x, triangle[2].z),
Vector2(triangle[0].x, triangle[0].z),
),
),
)
static func is_puddle_surface_eligible(
triangle: PackedVector3Array,
saltwater_height: float,
) -> bool:
if triangle.size() != 3 or is_nan(saltwater_height):
return false
for vertex: Vector3 in triangle:
if vertex.y <= saltwater_height + SURFACE_CLEARANCE_ABOVE_WATER:
return false
var cross: Vector3 = (triangle[1] - triangle[0]).cross(
triangle[2] - triangle[0]
)
return (
cross.length_squared() > 0.0000001
and absf(cross.normalized().dot(Vector3.UP)) >= MINIMUM_SURFACE_UP_DOT
)
static func is_point_inside_puddle(
world_position: Vector3,
center: Vector3,
radii: Vector2,
rotation_y: float = 0.0,
) -> bool:
if radii.x <= 0.0 or radii.y <= 0.0:
return false
if absf(world_position.y - center.y) > MAXIMUM_PLAYER_HEIGHT_OFFSET:
return false
var offset := Vector2(
world_position.x - center.x,
world_position.z - center.z,
).rotated(-rotation_y)
offset = Vector2(offset.x / radii.x, offset.y / radii.y)
return offset.length_squared() <= 1.0
static func _horizontal_distance_to_segment(
point: Vector2,
start: Vector2,
end: Vector2,
) -> float:
var segment: Vector2 = end - start
var segment_length_squared: float = segment.length_squared()
if segment_length_squared <= 0.0000001:
return point.distance_to(start)
var ratio: float = clampf(
(point - start).dot(segment) / segment_length_squared,
0.0,
1.0,
)
return point.distance_to(start + segment * ratio)