From 2e28afebcecaaa902731c7cfde226d8c1f24166a Mon Sep 17 00:00:00 2001 From: bee Date: Tue, 7 Jul 2026 08:17:45 +0200 Subject: [PATCH] init --- .editorconfig | 4 + .gitattributes | 2 + .gitignore | 3 + main.tscn | 117 +++++++++++ materials/stylized_water.tres | 57 ++++++ node.tscn | 3 + project.godot | 56 ++++++ scripts/boat.gd | 51 +++++ scripts/boat.gd.uid | 1 + scripts/camera_rig.gd | 31 +++ scripts/camera_rig.gd.uid | 1 + scripts/floating_body.gd | 68 +++++++ scripts/floating_body.gd.uid | 1 + scripts/ocean.gd | 125 ++++++++++++ scripts/ocean.gd.uid | 1 + scripts/stats_label.gd | 27 +++ scripts/stats_label.gd.uid | 1 + scripts/world_manager.gd | 47 +++++ scripts/world_manager.gd.uid | 1 + shaders/ocean.gdshader | 363 ++++++++++++++++++++++++++++++++++ shaders/ocean.gdshader.uid | 1 + 21 files changed, 961 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 main.tscn create mode 100644 materials/stylized_water.tres create mode 100644 node.tscn create mode 100644 project.godot create mode 100644 scripts/boat.gd create mode 100644 scripts/boat.gd.uid create mode 100644 scripts/camera_rig.gd create mode 100644 scripts/camera_rig.gd.uid create mode 100644 scripts/floating_body.gd create mode 100644 scripts/floating_body.gd.uid create mode 100644 scripts/ocean.gd create mode 100644 scripts/ocean.gd.uid create mode 100644 scripts/stats_label.gd create mode 100644 scripts/stats_label.gd.uid create mode 100644 scripts/world_manager.gd create mode 100644 scripts/world_manager.gd.uid create mode 100644 shaders/ocean.gdshader create mode 100644 shaders/ocean.gdshader.uid diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/main.tscn b/main.tscn new file mode 100644 index 0000000..f764e1d --- /dev/null +++ b/main.tscn @@ -0,0 +1,117 @@ +[gd_scene format=3 uid="uid://jven27ktracb"] + +[ext_resource type="Material" uid="uid://6p5bg1nyso6d" path="res://materials/stylized_water.tres" id="1_water_material"] +[ext_resource type="Script" uid="uid://co5xetl8amg4j" path="res://scripts/ocean.gd" id="2_ocean_script"] +[ext_resource type="Script" uid="uid://c0n1x050jvspr" path="res://scripts/boat.gd" id="3_1bvp3"] +[ext_resource type="Script" uid="uid://46jiwugeccqg" path="res://scripts/camera_rig.gd" id="4_1bvp3"] +[ext_resource type="Script" uid="uid://b500yf6hema7c" path="res://scripts/stats_label.gd" id="5_lquwl"] +[ext_resource type="Script" uid="uid://dpohf1tg5e2gs" path="res://scripts/world_manager.gd" id="6_7mycd"] + +[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_1"] +sky_horizon_color = Color(0.66, 0.67, 0.68, 1) +ground_horizon_color = Color(0.66, 0.67, 0.68, 1) + +[sub_resource type="Sky" id="Sky_1"] +sky_material = SubResource("SkyMaterial_1") + +[sub_resource type="Environment" id="Environment_1"] +background_mode = 2 +sky = SubResource("Sky_1") +tonemap_mode = 2 + +[sub_resource type="PlaneMesh" id="PlaneMesh_ocean"] +size = Vector2(200, 200) +subdivide_width = 200 +subdivide_depth = 200 + +[sub_resource type="BoxMesh" id="BoxMesh_seabed"] +size = Vector3(200, 1, 200) + +[sub_resource type="StandardMaterial3D" id="Material_seabed"] +albedo_color = Color(0.76, 0.7, 0.5, 1) + +[sub_resource type="BoxShape3D" id="BoxShape_seabed"] +size = Vector3(200, 1, 200) + +[sub_resource type="BoxMesh" id="BoxMesh_crate"] + +[sub_resource type="StandardMaterial3D" id="Material_crate"] +albedo_color = Color(0.72, 0.53, 0.32, 1) +roughness = 0.9 + +[sub_resource type="BoxShape3D" id="BoxShape_crate"] + +[node name="Main" type="Node3D" unique_id=1242452333] + +[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=431656791] +environment = SubResource("Environment_1") + +[node name="Sun" type="DirectionalLight3D" parent="." unique_id=144458330] +transform = Transform3D(1, 0, 0, 0, 0.642788, 0.766044, 0, -0.766044, 0.642788, 0, 20, 0) +shadow_enabled = true + +[node name="Ocean" type="MeshInstance3D" parent="." unique_id=312504233 groups=["follow_focus", "ocean"]] +material_override = ExtResource("1_water_material") +mesh = SubResource("PlaneMesh_ocean") +script = ExtResource("2_ocean_script") +sea_height = 1.0 +sea_choppy = 8.0 +metadata/follow_step = 1.0 + +[node name="Seabed" type="StaticBody3D" parent="." unique_id=1919476025 groups=["follow_focus"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -4.5, 0) + +[node name="Mesh" type="MeshInstance3D" parent="Seabed" unique_id=1209469388] +mesh = SubResource("BoxMesh_seabed") +surface_material_override/0 = SubResource("Material_seabed") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Seabed" unique_id=111957760] +shape = SubResource("BoxShape_seabed") + +[node name="Crate" type="RigidBody3D" parent="." unique_id=1409256205 groups=["shift_with_origin"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3, 0) +mass = 5.0 +script = ExtResource("3_1bvp3") +buoyancy = 1.5 +full_force_depth = 0.8 +probe_extents = Vector3(0.35, 0, 2.25) + +[node name="Mesh" type="MeshInstance3D" parent="Crate" unique_id=1406951740] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 5, 0, 0, 0) +mesh = SubResource("BoxMesh_crate") +surface_material_override/0 = SubResource("Material_crate") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Crate" unique_id=1190807384] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 5, 0, 0, 0) +shape = SubResource("BoxShape_crate") + +[node name="CameraRig" type="Node3D" parent="." unique_id=2139913829 node_paths=PackedStringArray("target", "cam") groups=["shift_with_origin"]] +transform = Transform3D(1, 0, 0, 0, 0.9396926, 0.34202012, 0, -0.34202012, 0.9396926, 0, 0, 0) +script = ExtResource("4_1bvp3") +target = NodePath("../Crate") +cam = NodePath("PlayerCamera") + +[node name="PlayerCamera" type="Camera3D" parent="CameraRig" unique_id=553919400] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) + +[node name="CanvasLayer" type="CanvasLayer" parent="." unique_id=1275537335] + +[node name="StatsLabel" type="Label" parent="CanvasLayer" unique_id=15552030 node_paths=PackedStringArray("boat", "world")] +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -76.0 +offset_top = -52.0 +grow_horizontal = 0 +grow_vertical = 0 +script = ExtResource("5_lquwl") +boat = NodePath("../../Crate") +world = NodePath("../../WorldManager") + +[node name="WorldManager" type="Node" parent="." unique_id=1879897241 node_paths=PackedStringArray("focus", "ocean")] +script = ExtResource("6_7mycd") +focus = NodePath("../Crate") +ocean = NodePath("../Ocean") +shift_threshold = 50.0 diff --git a/materials/stylized_water.tres b/materials/stylized_water.tres new file mode 100644 index 0000000..bf5dd27 --- /dev/null +++ b/materials/stylized_water.tres @@ -0,0 +1,57 @@ +[gd_resource type="ShaderMaterial" format=3 uid="uid://6p5bg1nyso6d"] + +[ext_resource type="Shader" uid="uid://cst73i45poweu" path="res://shaders/ocean.gdshader" id="1_water_shader"] + +[sub_resource type="Gradient" id="Gradient_caustics"] +colors = PackedColorArray(1, 1, 1, 1, 0, 0, 0, 1) + +[sub_resource type="FastNoiseLite" id="FastNoiseLite_caustics"] +noise_type = 2 +frequency = 0.05 +cellular_return_type = 4 + +[sub_resource type="NoiseTexture2D" id="NoiseTexture2D_caustics"] +noise = SubResource("FastNoiseLite_caustics") +color_ramp = SubResource("Gradient_caustics") +seamless = true + +[resource] +render_priority = 0 +shader = ExtResource("1_water_shader") +shader_parameter/max_depth = 10.0 +shader_parameter/underwater_fog_color = Color(0, 0.05, 0.1, 1) +shader_parameter/fade_start_depth = 0.5 +shader_parameter/wave_time = 0.0 +shader_parameter/sea_height = 1.3 +shader_parameter/sea_choppy = 4.0 +shader_parameter/sea_freq = 0.08 +shader_parameter/ITER_GEOMETRY = 3 +shader_parameter/ITER_FRAGMENT = 5 +shader_parameter/refraction_strength = 1.0 +shader_parameter/refraction_distance_fade = 5.0 +shader_parameter/normal_epsilon = 0.01 +shader_parameter/normal_smoothness_dist = 50.0 +shader_parameter/base_tint_color = Color(0.439, 0.973, 1, 1) +shader_parameter/deep_color = Color(0, 0.341, 0.29, 1) +shader_parameter/water_absorption = Color(0.3, 0.06, 0.02, 1) +shader_parameter/roughness = 0.125 +shader_parameter/metallic = 0.0 +shader_parameter/specular = 0.5 +shader_parameter/caustics_texture = SubResource("NoiseTexture2D_caustics") +shader_parameter/caustics_scale = 0.2 +shader_parameter/caustics_speed = 0.1 +shader_parameter/caustics_intensity = 0.8 +shader_parameter/caustics_depth_fade = 0.7 +shader_parameter/foam_color = Color(1, 1, 1, 1) +shader_parameter/foam_depth_start = 0.8 +shader_parameter/foam_depth_end = 0.0 +shader_parameter/foam_noise_scale = 2.0 +shader_parameter/foam_noise_speed = 1.0 +shader_parameter/foam_cutoff = 0.7 +shader_parameter/foam_crest_threshold = 0.7 +shader_parameter/foam_crest_amount = 2.0 +shader_parameter/foam_edge_color = Color(0, 0, 0.05, 1) +shader_parameter/foam_edge_offset = Vector2(0.02, 0.02) +shader_parameter/voronoi_scale = 9.0 +shader_parameter/voronoi_strength = 0.8 +shader_parameter/wave_offset = Vector2(0, 0) diff --git a/node.tscn b/node.tscn new file mode 100644 index 0000000..75b9d54 --- /dev/null +++ b/node.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://buq17wtyky42j"] + +[node name="Node" type="Node" unique_id=2000830144] diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..fc1285f --- /dev/null +++ b/project.godot @@ -0,0 +1,56 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="boatgame" +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.7", "Forward Plus") + +[display] + +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[input] + +throttle_increase={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) +] +} +throttle_decrease={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null) +] +} +rudder_left={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +] +} +rudder_right={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +] +} +throttle_stop={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null) +] +} + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" diff --git a/scripts/boat.gd b/scripts/boat.gd new file mode 100644 index 0000000..47219b5 --- /dev/null +++ b/scripts/boat.gd @@ -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) diff --git a/scripts/boat.gd.uid b/scripts/boat.gd.uid new file mode 100644 index 0000000..764e7fa --- /dev/null +++ b/scripts/boat.gd.uid @@ -0,0 +1 @@ +uid://c0n1x050jvspr diff --git a/scripts/camera_rig.gd b/scripts/camera_rig.gd new file mode 100644 index 0000000..bbadf17 --- /dev/null +++ b/scripts/camera_rig.gd @@ -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) diff --git a/scripts/camera_rig.gd.uid b/scripts/camera_rig.gd.uid new file mode 100644 index 0000000..693da36 --- /dev/null +++ b/scripts/camera_rig.gd.uid @@ -0,0 +1 @@ +uid://46jiwugeccqg diff --git a/scripts/floating_body.gd b/scripts/floating_body.gd new file mode 100644 index 0000000..b5e7f54 --- /dev/null +++ b/scripts/floating_body.gd @@ -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) diff --git a/scripts/floating_body.gd.uid b/scripts/floating_body.gd.uid new file mode 100644 index 0000000..27cab0b --- /dev/null +++ b/scripts/floating_body.gd.uid @@ -0,0 +1 @@ +uid://bsvmv1v1r4h2n diff --git a/scripts/ocean.gd b/scripts/ocean.gd new file mode 100644 index 0000000..3745959 --- /dev/null +++ b/scripts/ocean.gd @@ -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) diff --git a/scripts/ocean.gd.uid b/scripts/ocean.gd.uid new file mode 100644 index 0000000..eb0fb5b --- /dev/null +++ b/scripts/ocean.gd.uid @@ -0,0 +1 @@ +uid://co5xetl8amg4j diff --git a/scripts/stats_label.gd b/scripts/stats_label.gd new file mode 100644 index 0000000..d9a1027 --- /dev/null +++ b/scripts/stats_label.gd @@ -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, + ] diff --git a/scripts/stats_label.gd.uid b/scripts/stats_label.gd.uid new file mode 100644 index 0000000..fa6be03 --- /dev/null +++ b/scripts/stats_label.gd.uid @@ -0,0 +1 @@ +uid://b500yf6hema7c diff --git a/scripts/world_manager.gd b/scripts/world_manager.gd new file mode 100644 index 0000000..bcebabb --- /dev/null +++ b/scripts/world_manager.gd @@ -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 diff --git a/scripts/world_manager.gd.uid b/scripts/world_manager.gd.uid new file mode 100644 index 0000000..0bc390c --- /dev/null +++ b/scripts/world_manager.gd.uid @@ -0,0 +1 @@ +uid://dpohf1tg5e2gs diff --git a/shaders/ocean.gdshader b/shaders/ocean.gdshader new file mode 100644 index 0000000..2488812 --- /dev/null +++ b/shaders/ocean.gdshader @@ -0,0 +1,363 @@ +// Stylized 3D water shader, assembled from the gameidea.org tutorial: +// https://gameidea.org/2026/02/01/creating-a-stylized-3d-water-shader/ +// Seascape-style FBM waves + depth fog, Beer-Lambert absorption, refraction, +// caustics and voronoi foam. +// +// Adaptation for this project: wave displacement uses the `wave_time` uniform +// (driven by ocean.gd) instead of TIME, so the CPU-side get_wave_height() +// used for buoyancy stays in sync with the rendered surface. Purely cosmetic +// animation (foam, caustics) still uses TIME. +shader_type spatial; +render_mode depth_draw_always; + +uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap; +uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap; + +uniform float max_depth : hint_range(0.1, 50.0, 0.1) = 10.0; +uniform vec3 underwater_fog_color : source_color = vec3(0.0, 0.05, 0.1); +uniform float fade_start_depth : hint_range(0.0, 50.0, 0.1) = 0.5; + +// wave parameters (pushed from ocean.gd -- change them there, not here, +// or buoyancy physics will desync from the rendered surface). +// wave_time is accumulated phase (delta * sea_speed integrated on the CPU), +// so speed changes mid-game never make the surface jump. +uniform float wave_time = 0.0; +uniform float sea_height : hint_range(0.0, 5.0) = 1.3; +uniform float sea_choppy : hint_range(0.0, 10.0) = 4.0; +uniform float sea_freq : hint_range(0.0, 0.5) = 0.08; +// iterations +uniform int ITER_GEOMETRY = 3; +uniform int ITER_FRAGMENT = 5; +const mat2 octave_m = mat2(vec2(1.6, 1.2), vec2(-1.2, 1.6)); + +// refraction settings +uniform float refraction_strength : hint_range(0.0, 2.0, 0.01) = 1.0; +uniform float refraction_distance_fade : hint_range(0.0, 100.0, 1.0) = 5.0; +// for smoothness part +uniform float normal_epsilon : hint_range(0.001, 1.0) = 0.01; +uniform float normal_smoothness_dist : hint_range(0.0, 1000.0) = 50.0; + +uniform vec3 base_tint_color : source_color = vec3(0.439, 0.973, 1.0); +uniform vec3 deep_color : source_color = vec3(0.0, 0.341, 0.29); +// beer-lambert absorption +uniform vec3 water_absorption : source_color = vec3(0.3, 0.06, 0.02); + +uniform float roughness : hint_range(0.0, 1.0) = 0.125; +uniform float metallic : hint_range(0.0, 1.0) = 0.0; +uniform float specular : hint_range(0.0, 1.0) = 0.5; + +// caustics uniforms +uniform sampler2D caustics_texture : filter_linear_mipmap, repeat_enable; +uniform float caustics_scale : hint_range(0.1, 10.0, 0.1) = 2.0; +uniform float caustics_speed : hint_range(0.0, 1.0, 0.01) = 0.1; +uniform float caustics_intensity : hint_range(0.0, 2.0, 0.1) = 0.8; +uniform float caustics_depth_fade : hint_range(0.0, 1.0, 0.01) = 0.7; + +// foam uniforms +uniform vec3 foam_color : source_color = vec3(1.0, 1.0, 1.0); +uniform float foam_depth_start : hint_range(0.0, 5.0, 0.01) = 0.8; +uniform float foam_depth_end : hint_range(0.0, 5.0, 0.01) = 0.0; +uniform float foam_noise_scale : hint_range(0.0, 10.0, 0.01) = 2.0; +uniform float foam_noise_speed : hint_range(0.0, 2.0, 0.01) = 1.0; +uniform float foam_cutoff : hint_range(0.0, 1.0, 0.01) = 0.7; +uniform float foam_crest_threshold : hint_range(0.0, 1.0, 0.01) = 0.7; +uniform float foam_crest_amount : hint_range(0.0, 10.0, 0.1) = 2.0; + +uniform vec3 foam_edge_color : source_color = vec3(0.0, 0.0, 0.05); +uniform vec2 foam_edge_offset = vec2(0.02, 0.02); +uniform float voronoi_scale : hint_range(0.0, 50.0) = 9.0; +uniform float voronoi_strength : hint_range(0.0, 8.0) = 0.8; + +uniform vec2 wave_offset = vec2(0.0); + +// --------------------------------------------------------------------------- +// wave functions +// --------------------------------------------------------------------------- +float hash12(vec2 p) { + uvec2 q = uvec2(ivec2(p)) * uvec2(1597334677u, 3812015801u); + uint n = (q.x ^ q.y) * 1597334677u; + return float(n) * (1.0 / 4294967295.0); +} + +float noise(in vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return -1.0 + 2.0 * mix(mix(hash12(i + vec2(0.0, 0.0)), hash12(i + vec2(1.0, 0.0)), u.x), + mix(hash12(i + vec2(0.0, 1.0)), hash12(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float sea_octave(vec2 uv, float choppy) { + uv += noise(uv); + vec2 wv = 1.0 - abs(sin(uv)); + vec2 swv = abs(cos(uv)); + wv = mix(wv, swv, wv); + return pow(1.0 - pow(wv.x * wv.y, 0.65), choppy); +} + +float map(vec3 p, float time) { + float freq = sea_freq; + float amp = sea_height; + float choppy = sea_choppy; + vec2 uv = p.xz; + uv.x *= 0.75; + float h = 0.0; + for (int i = 0; i < ITER_GEOMETRY; i++) { + float d = sea_octave((uv + time) * freq, choppy); + d += sea_octave((uv - time) * freq, choppy); + h += d * amp; + uv *= octave_m; + freq *= 1.9; + amp *= 0.22; + choppy = mix(choppy, 1.0, 0.2); + } + return p.y - h; +} + +float map_detailed(vec3 p, float time) { + float freq = sea_freq; + float amp = sea_height; + float choppy = sea_choppy; + vec2 uv = p.xz; + uv.x *= 0.75; + float h = 0.0; + for (int i = 0; i < ITER_FRAGMENT; i++) { + float d = sea_octave((uv + time) * freq, choppy); + d += sea_octave((uv - time) * freq, choppy); + h += d * amp; + uv *= octave_m; + freq *= 1.9; + amp *= 0.22; + choppy = mix(choppy, 1.0, 0.2); + } + return p.y - h; +} + +vec3 get_normal_detailed(vec3 p, float eps, float time) { + vec3 n; + n.y = map_detailed(p, time); + n.x = map_detailed(vec3(p.x + eps, p.y, p.z), time) - n.y; + n.z = map_detailed(vec3(p.x, p.y, p.z + eps), time) - n.y; + n.y = eps; + return normalize(n); +} + +// --------------------------------------------------------------------------- +// foam noise functions +// --------------------------------------------------------------------------- +float hash13(vec3 p) { + uvec3 q = uvec3(ivec3(p)) * uvec3(1597334677u, 3812015801u, 2798796415u); + uint n = (q.x ^ q.y ^ q.z) * 1597334677u; + return float(n) * (1.0 / 4294967295.0); +} + +float hash3d(vec3 p) { return hash13(p); } + +vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +// voronoi cell noise: distance to nearest animated point per grid cell, +// ideal for simulating foam clumps and bubbles +float voronoi(vec2 uv) { + vec2 n = floor(uv); + vec2 f = fract(uv); + float m_dist = 1.0; + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = hash22(n + g); + // animate the point within the cell + o = 0.5 + 0.5 * sin(TIME * foam_noise_speed + 6.2831 * o); + vec2 r = g - f + o; + float d = dot(r, r); + m_dist = min(m_dist, d); + } + } + return m_dist; +} + +float noise3d(in vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(hash3d(i + vec3(0.0, 0.0, 0.0)), hash3d(i + vec3(1.0, 0.0, 0.0)), f.x), + mix(hash3d(i + vec3(0.0, 1.0, 0.0)), hash3d(i + vec3(1.0, 1.0, 0.0)), f.x), f.y), + mix( + mix(hash3d(i + vec3(0.0, 0.0, 1.0)), hash3d(i + vec3(1.0, 0.0, 1.0)), f.x), + mix(hash3d(i + vec3(0.0, 1.0, 1.0)), hash3d(i + vec3(1.0, 1.0, 1.0)), f.x), f.y), f.z); +} + +float fbm_voronoi(vec2 uv) { + float v = 0.0; + float a = 0.5; + vec2 shift = vec2(100.0); + mat2 rot = mat2(vec2(cos(0.5), sin(0.5)), vec2(-sin(0.5), cos(0.5))); + for (int i = 0; i < 3; i++) { + float val = 1.0 - voronoi(uv); + val = pow(val, 2.0); // sharpen bubbles + v += a * val; + uv = rot * uv * 2.0 + shift; + a *= 0.5; + } + return v; +} + +// GET LINEAR DEPTH FROM DEPTH TEXTURE +float get_linear_depth(sampler2D d_tex, vec2 uv, mat4 inv_proj) { + float depth = texture(d_tex, uv).x; + vec3 ndc = vec3(uv * 2.0 - 1.0, depth); +#if CURRENT_RENDERER == RENDERER_COMPATIBILITY + ndc.z = depth * 2.0 - 1.0; +#endif + vec4 view = inv_proj * vec4(ndc, 1.0); + return -view.z / view.w; +} + +varying vec3 world_vert; +varying float wave_height; +varying vec3 vertex_normal_world; + +void vertex() { + vec3 world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_pos.xz += wave_offset; // ← add + wave_height = -map(world_pos, wave_time); + VERTEX.y = wave_height; + world_vert = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_vert.xz += wave_offset; // ← add + float vertex_eps = 0.1; + float h_center = wave_height; + float h_x = -map(world_pos + vec3(vertex_eps, 0.0, 0.0), wave_time); + float h_z = -map(world_pos + vec3(0.0, 0.0, vertex_eps), wave_time); + vec3 n_geom = normalize(vec3(h_center - h_x, vertex_eps, h_center - h_z)); + vertex_normal_world = n_geom; +} + +void fragment() { + // linear depth of the water surface itself + float water_linear_depth = -VERTEX.z; + // linear depth of what's behind the water + float bg_linear_depth = get_linear_depth(DEPTH_TEXTURE, SCREEN_UV, INV_PROJECTION_MATRIX); + float thickness = max(0.0, bg_linear_depth - water_linear_depth); + + // normals & distance smoothing + float dist_to_cam = length(VERTEX); + float lod_epsilon = max(normal_epsilon, dist_to_cam * 0.005); + vec3 detailed_normal = get_normal_detailed(world_vert, lod_epsilon, wave_time); + float smooth_factor = clamp((dist_to_cam - normal_smoothness_dist) / 200.0, 0.0, 1.0); + vec3 final_normal = mix(detailed_normal, vertex_normal_world, smooth_factor); + NORMAL = (VIEW_MATRIX * vec4(final_normal, 0.0)).xyz; + + // refraction offset calculation + vec3 view_vertex_normal = (VIEW_MATRIX * vec4(vertex_normal_world, 0.0)).xyz; + vec2 normal_offset = NORMAL.xy - view_vertex_normal.xy; + float ref_dist_factor = clamp(refraction_distance_fade / max(0.1, dist_to_cam), 0.0, 1.0); + float ref_depth_mask = smoothstep(0.0, max_depth, thickness); + vec2 refraction_offset = normal_offset * refraction_strength * ref_dist_factor * ref_depth_mask * 0.05; + + vec2 distorted_uv = SCREEN_UV + refraction_offset; + + // fix ghosting for pixels that are on boundary so they dont make water look weird + float distorted_bg_depth = get_linear_depth(DEPTH_TEXTURE, distorted_uv, INV_PROJECTION_MATRIX); + if (distorted_bg_depth < water_linear_depth - 0.001) { + distorted_uv = SCREEN_UV; + distorted_bg_depth = bg_linear_depth; + } + + vec3 screen_color = texture(SCREEN_TEXTURE, distorted_uv).rgb; + thickness = max(0.0, distorted_bg_depth - water_linear_depth); + + // caustics + vec4 bg_ndc = vec4(distorted_uv * 2.0 - 1.0, texture(DEPTH_TEXTURE, distorted_uv).x, 1.0); +#if CURRENT_RENDERER == RENDERER_COMPATIBILITY + bg_ndc.z = bg_ndc.z * 2.0 - 1.0; +#endif + vec4 bg_world = INV_VIEW_MATRIX * INV_PROJECTION_MATRIX * bg_ndc; + vec3 bg_world_pos = bg_world.xyz / bg_world.w; + if (thickness > 0.0) { + vec2 caustics_uv1 = bg_world_pos.xz * caustics_scale; + caustics_uv1.x += TIME * caustics_speed; + vec2 caustics_uv2 = bg_world_pos.xz * caustics_scale * 0.7; + caustics_uv2.y -= TIME * caustics_speed * 0.8; + float caustics_sample1 = texture(caustics_texture, caustics_uv1).r; + float caustics_sample2 = texture(caustics_texture, caustics_uv2).r; + float caustics_value = caustics_sample1 * caustics_sample2; + float caustics_fade = 1.0 - clamp(thickness * caustics_depth_fade, 0.0, 1.0); + screen_color += caustics_value * caustics_intensity * caustics_fade; + } + + // underwater fog ratio + float fade_range = max(0.001, max_depth - fade_start_depth); + float depth_ratio = clamp((thickness - fade_start_depth) / fade_range, 0.0, 1.0); + + // beer-lambert absorption + vec3 transmittance = exp(-thickness * water_absorption); + vec3 water_volume_color = mix(base_tint_color, deep_color, depth_ratio); + vec3 apparent_seabed_color = screen_color * water_volume_color; // screen_color = seabed color + ALBEDO = mix(underwater_fog_color, apparent_seabed_color, transmittance); + + // foam calculation + float foam_mask_primary = 0.0; + float foam_mask_shadow = 0.0; + + // density factor: 1.0 at shore/crests, 0.0 at deep water + float depth_foam_factor = smoothstep(foam_depth_start, foam_depth_end, thickness); + float wave_crest_factor = smoothstep(foam_crest_threshold, foam_crest_threshold - 0.1, final_normal.y); + wave_crest_factor *= foam_crest_amount; + float foam_level = clamp(depth_foam_factor + wave_crest_factor, 0.0, 1.0); + + // domain warping for swirling, flowing foam motion + vec2 flow_uv = world_vert.xz * 0.5 + TIME * 0.05 * foam_noise_speed; + vec2 warp = vec2( + noise(flow_uv), + noise(flow_uv + vec2(5.2, 1.3)) + ) * 0.5; + + if (distorted_bg_depth > water_linear_depth) { + vec2 foam_uv = world_vert.xz * foam_noise_scale + warp; + + // emergence noise creates pulsing/clumping so foam doesn't appear everywhere at once + float emergence_noise = noise3d(vec3(world_vert.xz * 0.5, TIME * 0.2 * foam_noise_speed)); + emergence_noise = smoothstep(0.0, 1.0, emergence_noise * 0.5 + 0.5); + + // near shores and crests, emergence is forced to full strength + float effective_emergence = mix(emergence_noise, 1.0, foam_level); + + // main foam sample + float foam_noise_val = fbm_voronoi(foam_uv); + foam_noise_val *= effective_emergence; + + // shadow sample at slight offset, faking thickness and separation from the water + vec2 foam_uv_shadow = (world_vert.xz + foam_edge_offset) * foam_noise_scale + warp; + float foam_noise_shadow_val = fbm_voronoi(foam_uv_shadow); + foam_noise_shadow_val *= effective_emergence; + + // clumping/mask generation + float combined_noise = foam_noise_val + foam_level; + float combined_shadow = foam_noise_shadow_val + foam_level; + + foam_mask_primary = smoothstep(foam_cutoff + 0.5, foam_cutoff + 0.6, combined_noise); + float shadow_shape = smoothstep(foam_cutoff + 0.5, foam_cutoff + 0.6, combined_shadow); + foam_mask_shadow = clamp(shadow_shape - foam_mask_primary, 0.0, 1.0); + } + + // voronoi bubble texture inside the foam + float voronoi_val = 1.0 - sqrt(voronoi(world_vert.xz * voronoi_scale + warp)); + voronoi_val = smoothstep(0.2, 0.8, voronoi_val); + + float bubble_alpha = 1.0 - clamp((1.0 - voronoi_val) * voronoi_strength, 0.0, 1.0); + + // apply foam + ALBEDO = mix(ALBEDO, foam_edge_color, foam_mask_shadow); + ALBEDO = mix(ALBEDO, foam_color, foam_mask_primary * bubble_alpha); + + // make foam rougher + ROUGHNESS = mix(roughness, 0.8, (foam_mask_primary * bubble_alpha) + foam_mask_shadow); + + SPECULAR = specular; + METALLIC = metallic; +} diff --git a/shaders/ocean.gdshader.uid b/shaders/ocean.gdshader.uid new file mode 100644 index 0000000..93b9a72 --- /dev/null +++ b/shaders/ocean.gdshader.uid @@ -0,0 +1 @@ +uid://cst73i45poweu