This commit is contained in:
bee
2026-07-07 08:17:45 +02:00
commit 2e28afebce
21 changed files with 961 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
class_name Boat
extends FloatingBody
## All force values are per-kilogram (they get multiplied by mass), so the
## boat handles the same if you change its mass later.
@export var engine_power := 12.0 # forward thrust, m/s² at full throttle
@export var reverse_ratio := 0.4 # reverse is weaker, like a real prop
@export var throttle_response := 1.5 # how fast the engine spools up/down
@export var rudder_strength := 2.5 # turning torque at speed
@export var keel_grip := 3.0 # resistance to sliding sideways
var desired_throttle := 0.0
var throttle := 0.0
func _physics_process(delta: float) -> void:
super(delta)
if Input.is_action_pressed("throttle_increase"):
desired_throttle = move_toward(desired_throttle, 1, 0.005)
elif Input.is_action_pressed("throttle_decrease"):
desired_throttle = move_toward(desired_throttle, -1, 0.006)
elif Input.is_action_pressed("throttle_stop"):
desired_throttle = 0.0
throttle = move_toward(throttle, desired_throttle, throttle_response * delta)
# boat's forward direction, flattened onto the water plane
var forward := -global_basis.z
forward.y = 0.0
forward = forward.normalized()
# --- engine ---
var power := engine_power * (reverse_ratio if throttle < 0.0 else 1.0)
apply_central_force(forward * throttle * power * mass * submerged_ratio)
# --- rudder: only bites when water flows past it ---
var steer_input := Input.get_axis("rudder_right", "rudder_left")
var forward_speed := linear_velocity.dot(forward)
var rudder_bite := clampf(forward_speed / 3.0, -1.0, 1.0)
apply_torque(Vector3.UP * steer_input * rudder_strength * rudder_bite * mass * submerged_ratio)
if submerged_ratio <= 0.0:
return # airborne: no thrust, no rudder, no keelffffff
# --- keel: kill sideways sliding ---
var right := global_basis.x
right.y = 0.0
right = right.normalized()
var lateral_speed := linear_velocity.dot(right)
apply_central_force(-right * lateral_speed * keel_grip * mass * submerged_ratio)
+1
View File
@@ -0,0 +1 @@
uid://c0n1x050jvspr
+31
View File
@@ -0,0 +1,31 @@
extends Node3D
@export var target: Node3D
@export var sensitivity := 0.005
@export var follow_speed := 5.0
@export var cam: Camera3D
var _dragging := false
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
match event.button_index:
MOUSE_BUTTON_LEFT:
_dragging = event.pressed
MOUSE_BUTTON_WHEEL_UP:
cam.position.z = maxf(cam.position.z - 1.0, 3.0)
MOUSE_BUTTON_WHEEL_DOWN:
cam.position.z = minf(cam.position.z + 1.0, 30.0)
elif event is InputEventMouseMotion and _dragging:
rotation.y -= event.relative.x * sensitivity
rotation.x = clampf(
rotation.x - event.relative.y * sensitivity,
deg_to_rad(-80.0),
deg_to_rad(-10.0)
)
func _process(delta: float) -> void:
if target:
global_position = global_position.lerp(target.global_position, follow_speed * delta)
+1
View File
@@ -0,0 +1 @@
uid://46jiwugeccqg
+68
View File
@@ -0,0 +1,68 @@
class_name FloatingBody
extends RigidBody3D
## Buoyant rigid body. Add Marker3D children as float probes (e.g. the four
## corners of a hull); each probe pushes up in proportion to how deep it is
## below the ocean surface, which makes the body bob and tilt with the waves.
## Upward acceleration as a multiple of gravity when fully submerged.
## > 1.0 floats, < 1.0 sinks. Higher rides higher in the water.
@export var buoyancy := 2.0
## Probe depth (in meters) at which buoyant force reaches full strength.
@export var full_force_depth := 0.4
## Linear drag applied while in the water.
@export var water_drag := 1.5
## Rotational drag applied while in the water.
@export var water_angular_drag := 1.0
## Probes generated across the hull footprint if no Marker3D children exist.
@export var probe_extents := Vector3(0.35, 0.0, 0.9) # half-width, height, half-length
@export var probe_grid := Vector2i(2, 4) # columns (x), rows (z)
@export var probe_damping := 4.0
var _probe_offsets: Array[Vector3] = [] # local space
var _ocean: Ocean
var _probes: Array[Marker3D] = []
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var submerged_ratio := 0.0
func _ready() -> void:
_ocean = get_tree().get_first_node_in_group("ocean") as Ocean
# manual markers take priority if present...
for child in get_children():
if child is Marker3D:
_probe_offsets.append(child.position)
# ...otherwise generate a grid over the hull footprint
if _probe_offsets.is_empty():
for col in probe_grid.x:
for row in probe_grid.y:
_probe_offsets.append(Vector3(
lerpf(-probe_extents.x, probe_extents.x, col / float(probe_grid.x - 1)),
probe_extents.y,
lerpf(-probe_extents.z, probe_extents.z, row / float(probe_grid.y - 1))))
func _physics_process(_delta: float) -> void:
if _ocean == null or _probe_offsets.is_empty():
return
submerged_ratio = 0.0
for offset in _probe_offsets:
var pos := global_transform * offset
var depth := _ocean.get_wave_height(pos) - pos.y
if depth > 0.0:
var factor := clampf(depth / full_force_depth, 0.0, 1.0)
submerged_ratio += factor / _probe_offsets.size()
var force := Vector3.UP * _gravity * mass * buoyancy * factor / _probe_offsets.size()
apply_force(force, pos - global_position)
var r := pos - global_position
# velocity of this specific point on the hull (body motion + rotation)
var point_velocity := linear_velocity + angular_velocity.cross(r)
var damp := Vector3.UP * -point_velocity.y * probe_damping * factor * mass / _probe_offsets.size()
apply_force(force + damp, r)
if submerged_ratio > 0.0:
apply_central_force(-linear_velocity * water_drag * mass * submerged_ratio)
apply_torque(-angular_velocity * water_angular_drag * mass * submerged_ratio)
+1
View File
@@ -0,0 +1 @@
uid://bsvmv1v1r4h2n
+125
View File
@@ -0,0 +1,125 @@
class_name Ocean
extends MeshInstance3D
## Drives the stylized water shader and mirrors its wave math on the CPU so
## physics (buoyancy probes) can query the exact rendered surface height.
## These exports are pushed to the shader as uniforms in _ready(), and the
## shader's `wave_time` is driven from here every frame — the shader itself
## never reads TIME for displacement, so the two can't drift apart.
## All of these are safe to change from game logic at any time (directly or
## via a Tween); the setters forward them to the shader. Abrupt jumps in
## height/choppy/freq visibly snap the surface, so tween those for things
## like a storm rolling in.
@export var sea_height := 0.1:
set(value):
sea_height = value
_push("sea_height", value)
@export var sea_choppy := 4.0:
set(value):
sea_choppy = value
_push("sea_choppy", value)
## Phase advance per second. Not a shader uniform: speed is integrated into
## _phase on the CPU, so changing it mid-game is always smooth.
@export var sea_speed := 1.5
@export var sea_freq := 0.08:
set(value):
sea_freq = value
_push("sea_freq", value)
@export var iter_geometry := 3:
set(value):
iter_geometry = value
_push("ITER_GEOMETRY", value)
## Node the ocean mesh stays centered under (camera rig or boat).
## Half-size of the rendered ocean square, in meters.
@export var render_distance := 100.0:
set(value):
render_distance = value
if is_node_ready():
_rebuild_mesh()
## World-space distance between mesh vertices; smaller = more wave detail.
@export var vertex_spacing := 1.0
var _phase := 0.0
var _wave_offset := Vector2.ZERO
func _ready() -> void:
_push("sea_height", sea_height)
_push("sea_choppy", sea_choppy)
_push("sea_freq", sea_freq)
_push("ITER_GEOMETRY", iter_geometry)
_rebuild_mesh()
func _process(delta: float) -> void:
_phase += delta * sea_speed
_push("wave_time", _phase)
func _push(param: String, value: Variant) -> void:
# Setters can fire during scene load before material_override is assigned;
# _ready() re-pushes everything once the node is complete.
var mat := material_override as ShaderMaterial
if mat:
mat.set_shader_parameter(param, value)
## Water surface height (world Y) at the given world position. Mirrors the
## shader's map() at ITER_GEOMETRY iterations; the shader outputs an absolute
## world-space height, independent of this node's own Y.
func get_wave_height(world_pos: Vector3) -> float:
var uv := Vector2((world_pos.x + _wave_offset.x) * 0.75, world_pos.z + _wave_offset.y)
var freq := sea_freq
var amp := sea_height
var choppy := sea_choppy
var h := 0.0
var ts := _phase
for i in iter_geometry:
var d := _sea_octave((uv + Vector2(ts, ts)) * freq, choppy)
d += _sea_octave((uv - Vector2(ts, ts)) * freq, choppy)
h += d * amp
# uv *= octave_m, with octave_m = mat2(vec2(1.6, 1.2), vec2(-1.2, 1.6))
uv = Vector2(1.6 * uv.x + 1.2 * uv.y, -1.2 * uv.x + 1.6 * uv.y)
freq *= 1.9
amp *= 0.22
choppy = lerpf(choppy, 1.0, 0.2)
return h
static func _sea_octave(uv: Vector2, choppy: float) -> float:
var n := _noise(uv)
uv += Vector2(n, n)
var wv := Vector2(1.0 - absf(sin(uv.x)), 1.0 - absf(sin(uv.y)))
var swv := Vector2(absf(cos(uv.x)), absf(cos(uv.y)))
wv = Vector2(lerpf(wv.x, swv.x, wv.x), lerpf(wv.y, swv.y, wv.y))
return pow(1.0 - pow(wv.x * wv.y, 0.65), choppy)
static func _noise(p: Vector2) -> float:
var i := p.floor()
var f := p - i
var u := f * f * (Vector2(3.0, 3.0) - 2.0 * f)
return -1.0 + 2.0 * lerpf(
lerpf(_hash12(i), _hash12(i + Vector2(1, 0)), u.x),
lerpf(_hash12(i + Vector2(0, 1)), _hash12(i + Vector2(1, 1)), u.x), u.y)
# Mirrors the shader's hash12(); the & 0xFFFFFFFF masks emulate 32-bit
# unsigned wraparound on GDScript's 64-bit ints.
static func _hash12(p: Vector2) -> float:
var qx := (int(p.x) * 1597334677) & 0xFFFFFFFF
var qy := (int(p.y) * 3812015801) & 0xFFFFFFFF
var n := ((qx ^ qy) * 1597334677) & 0xFFFFFFFF
return float(n) / 4294967295.0
func _rebuild_mesh() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(render_distance, render_distance) * 2.0
plane.subdivide_width = int(render_distance * 2.0 / vertex_spacing) - 1
plane.subdivide_depth = plane.subdivide_width
mesh = plane
func shift_origin(shift: Vector3) -> void:
_wave_offset += Vector2(shift.x, shift.z)
_push("wave_offset", _wave_offset)
+1
View File
@@ -0,0 +1 @@
uid://co5xetl8amg4j
+27
View File
@@ -0,0 +1,27 @@
extends Label
@export var boat: Boat # drag the Crate into this slot in the inspector
@export var world: WorldManager
func _process(_delta: float) -> void:
if boat == null:
text = "no boat"
return
var forward := -boat.global_basis.z
forward.y = 0.0
var speed := boat.linear_velocity.dot(forward.normalized())
var pos := world.true_position(boat)
text = "Throttle: %+.0f %%
Speed: %.1f m/s
Submerged: %.0f %%
Position: (%.0f, %.0f)
" % [
boat.throttle * 100.0,
speed,
boat.submerged_ratio * 100.0,
boat.global_position.x,
boat.global_position.z,
]
+1
View File
@@ -0,0 +1 @@
uid://b500yf6hema7c
+47
View File
@@ -0,0 +1,47 @@
class_name WorldManager
extends Node
## Owns "where is the player and what stays centered on them":
## recenters scenery in the follow_focus group, and shifts the origin
## (teleporting the shift_with_origin group) before float precision degrades.
@export var focus: Node3D # the boat
@export var ocean: Ocean
## Distance from origin that triggers an origin shift.
@export var shift_threshold := 2048.0
## Default recenter step; nodes can override with a follow_step metadata.
## Keep it a multiple of the ocean's vertex_spacing.
@export var follow_step := 16.0
## True world position of the current local origin (grows over a long voyage).
var origin_offset := Vector3.ZERO
func true_position(node: Node3D) -> Vector3:
return node.global_position + origin_offset
func _physics_process(_delta: float) -> void:
_maybe_shift_origin()
_recenter_followers()
func _maybe_shift_origin() -> void:
var p := focus.global_position
if Vector2(p.x, p.z).length() < shift_threshold:
return
var shift := Vector3(snappedf(p.x, follow_step), 0.0, snappedf(p.z, follow_step))
origin_offset += shift
for node in get_tree().get_nodes_in_group("shift_with_origin"):
node.global_position -= shift
ocean.shift_origin(shift)
func _recenter_followers() -> void:
for node in get_tree().get_nodes_in_group("follow_focus"):
var step: float = node.get_meta("follow_step", follow_step)
var new_x := snappedf(focus.global_position.x, step)
var new_z := snappedf(focus.global_position.z, step)
if new_x != node.global_position.x or new_z != node.global_position.z:
node.global_position.x = new_x
node.global_position.z = new_z
+1
View File
@@ -0,0 +1 @@
uid://dpohf1tg5e2gs