Add sunlit shallows site with localized sun shafts #2

Merged
chindit merged 1 commits from olix21/feat/sunlit-shallows AGit into main 2026-10-07 12:21:45 +02:00
14 changed files with 666 additions and 2 deletions

No files matched your search

@@ -0,0 +1,28 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="PackedScene" path="res://game/world/sites/sunlit_shallows_environment.tscn" id="1_environment"]
[ext_resource type="PackedScene" path="res://game/world/submarine/submarine_actor.tscn" id="2_submarine"]
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_camera_rig.tscn" id="3_camera"]
[ext_resource type="PackedScene" path="res://game/presentation/cockpit/cockpit_interior.tscn" id="4_cockpit"]
[node name="SunlitShallowsWorld" type="Node3D"]
[node name="Environment" parent="." instance=ExtResource("1_environment")]
[node name="SubmarineActor" parent="." instance=ExtResource("2_submarine")]
unique_name_in_owner = true
position = Vector3(0, -30, 0)
[node name="CockpitInterior" parent="SubmarineActor" instance=ExtResource("4_cockpit")]
unique_name_in_owner = true
[node name="CockpitCameraRig" parent="SubmarineActor" instance=ExtResource("3_camera")]
unique_name_in_owner = true
[node name="LeftProjector" parent="SubmarineActor" index="0"]
light_energy = 3.0
light_volumetric_fog_energy = 0.15
[node name="RightProjector" parent="SubmarineActor" index="1"]
light_energy = 3.0
light_volumetric_fog_energy = 0.15
@@ -108,6 +108,10 @@ func practical_light_count() -> int:
return 0 if _practical_lighting == null else _practical_lighting.practical_light_count() return 0 if _practical_lighting == null else _practical_lighting.practical_light_count()
func fill_light_ignores_porthole_glass() -> bool:
return _practical_lighting != null and _practical_lighting.fill_light_ignores_porthole_glass()
func gauges_are_dashboard_mounted() -> bool: func gauges_are_dashboard_mounted() -> bool:
return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard") return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard")
@@ -9,6 +9,10 @@ const CockpitMeshFactory = preload(
"res://game/presentation/cockpit/cockpit_mesh_factory.gd" "res://game/presentation/cockpit/cockpit_mesh_factory.gd"
) )
const CockpitPressureBulkhead = preload(
"res://game/presentation/cockpit/cockpit_pressure_bulkhead.gd"
)
const CAGE_LAMP_SCENE = preload("res://game/presentation/cockpit/assets/models/cage_lamp.glb") const CAGE_LAMP_SCENE = preload("res://game/presentation/cockpit/assets/models/cage_lamp.glb")
## Flange centre of the right-hand lamp, on the flat end cap of the pressure ## Flange centre of the right-hand lamp, on the flat end cap of the pressure
@@ -34,6 +38,7 @@ const WARM_LAMP_SHADOW_BIAS := 0.03
var _materials: CockpitMaterialPalette var _materials: CockpitMaterialPalette
var _warm_lights: Array[OmniLight3D] = [] var _warm_lights: Array[OmniLight3D] = []
var _emergency_light: OmniLight3D var _emergency_light: OmniLight3D
var _porthole_fill: OmniLight3D
func _init(materials: CockpitMaterialPalette) -> void: func _init(materials: CockpitMaterialPalette) -> void:
@@ -103,7 +108,20 @@ func _add_fill_light() -> void:
porthole_fill.light_energy = 1.0 porthole_fill.light_energy = 1.0
porthole_fill.omni_range = 1.1 porthole_fill.omni_range = 1.1
porthole_fill.shadow_enabled = false porthole_fill.shadow_enabled = false
# 30 cm behind the glass, it would mirror itself as a hard blue point.
porthole_fill.light_cull_mask &= ~CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
add_child(porthole_fill) add_child(porthole_fill)
_porthole_fill = porthole_fill
func fill_light_ignores_porthole_glass() -> bool:
return (
_porthole_fill != null
and (
_porthole_fill.light_cull_mask
& CockpitPressureBulkhead.PORTHOLE_GLASS_RENDER_LAYER_BIT
) == 0
)
func _add_emergency_light() -> void: func _add_emergency_light() -> void:
@@ -4,6 +4,18 @@ extends Node3D
## Imported pressure hull (end cap, knuckle and barrel in one shell) and the ## Imported pressure hull (end cap, knuckle and barrel in one shell) and the
## porthole bolted into it. Both models are instanced by `cockpit_interior.tscn`. ## porthole bolted into it. Both models are instanced by `cockpit_interior.tscn`.
## The porthole glass lives alone on render layer 2 so interior lights that
## sit right behind it (the cool porthole fill) can leave it out of their cull
## mask instead of mirroring themselves as a hard specular point.
const PORTHOLE_GLASS_RENDER_LAYER_BIT := 1 << 1
## Imported glass is near-mirror smooth; a little roughness turns the remaining
## lamp reflections into soft smears rather than pinpoints.
const PORTHOLE_GLASS_ROUGHNESS := 0.45
const CockpitMeshFactory = preload(
"res://game/presentation/cockpit/cockpit_mesh_factory.gd"
)
const WHITEBOX_NAMES: Array[String] = [ const WHITEBOX_NAMES: Array[String] = [
"LeftBulkhead", "LeftBulkhead",
"RightBulkhead", "RightBulkhead",
@@ -26,6 +38,21 @@ func _ready() -> void:
push_error("Pressure hull or porthole model is missing a node") push_error("Pressure hull or porthole model is missing a node")
return return
_porthole_glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _porthole_glass.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_porthole_glass.layers = PORTHOLE_GLASS_RENDER_LAYER_BIT
var glass_material := CockpitMeshFactory.own_surface_material(_porthole_glass)
if glass_material != null:
glass_material.roughness = PORTHOLE_GLASS_ROUGHNESS
func glass_is_on_dedicated_layer() -> bool:
return _porthole_glass != null and _porthole_glass.layers == PORTHOLE_GLASS_RENDER_LAYER_BIT
func glass_roughness() -> float:
if _porthole_glass == null:
return 0.0
var material := _porthole_glass.get_active_material(0) as BaseMaterial3D
return 0.0 if material == null else material.roughness
func panel_count() -> int: func panel_count() -> int:
@@ -0,0 +1,28 @@
// Flat sandy floor with slowly drifting caustic lines. The pattern is purely
// presentational and time-driven by the shader clock; no gameplay reads it.
shader_type spatial;
uniform vec3 sand_color : source_color = vec3(0.34, 0.32, 0.25);
uniform vec3 caustic_tint : source_color = vec3(0.55, 0.82, 0.92);
uniform sampler2D caustic_noise : repeat_enable, filter_linear_mipmap;
uniform float caustic_scale = 0.055;
uniform float caustic_speed = 0.018;
uniform float caustic_strength : hint_range(0.0, 2.0) = 0.25;
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 drift_a = TIME * caustic_speed * vec2(1.0, 0.6);
vec2 drift_b = TIME * caustic_speed * vec2(-0.7, 1.0);
float layer_a = texture(caustic_noise, world_xz * caustic_scale + drift_a).r;
float layer_b = texture(caustic_noise, world_xz * caustic_scale * 1.35 + drift_b).r;
float caustic = pow(clamp(min(layer_a, layer_b) * 1.9, 0.0, 1.0), 4.0);
ALBEDO = sand_color;
ROUGHNESS = 0.95;
METALLIC = 0.0;
EMISSION = caustic_tint * caustic * caustic_strength;
}
@@ -0,0 +1,161 @@
class_name SunlitShallowsEnvironment
extends Node3D
## Visual test site: flat floor, calm water, fixed daytime sun.
## The hour never changes, so the sun transform is a constant and the only
## motion is the slow ripple drift inside the two shaders.
const SURFACE_LEVEL_M := 0.0
const FLOOR_DEPTH_M := -40.0
## Air above the calm surface holds no scattering medium: a subtractive
## FogVolume cancels the global volumetric fog there so the view never shows
## an unshadowed glow above the water.
const AIR_VOLUME_HEIGHT_M := 80.0
const VOLUMETRIC_FOG_DENSITY := 0.035
const SITE_EXTENT_M := 240.0
## Sun sits ahead of the observer (world -Z) and high: shafts travel down
## toward the submarine, as in the cockpit visual target.
const SUN_ROTATION_DEGREES := Vector3(-44.0, 162.0, 0.0)
const NOISE_SEED := 7
## Calm water: ripple drift stays well under 0.05 texture units per second.
const SURFACE_DRIFT_SPEED := 0.012
## Sunlight only breaks through a pool of surface a few tens of metres ahead of
## the submarine (world -Z), so the shafts land in front of the porthole.
const SHAFT_POOL_CENTER_XZ_M := Vector2(-14.0, -31.0)
const SHAFT_POOL_RADIUS_M := 22.0
const SHAFT_POOL_EDGE_M := 12.0
## Coarser ripples give a few broken oblique shafts rather than a dense fan.
const SURFACE_RIPPLE_SCALE := 0.025
## The underside of the surface is drawn dark, lit only inside the pool.
const SURFACE_CEILING_LIT_SCALE := 0.3
const SURFACE_OCCLUDER_SHADER := preload(
"res://game/world/sites/sunlit_shallows_surface_occluder.gdshader"
)
const CAUSTIC_FLOOR_SHADER := preload(
"res://game/world/sites/sunlit_shallows_caustic_floor.gdshader"
)
const SURFACE_CEILING_SHADER := preload(
"res://game/world/sites/sunlit_shallows_surface_ceiling.gdshader"
)
func _ready() -> void:
_fix_sun()
_add_flat_floor()
_add_surface_occluder()
_add_surface_ceiling()
_add_air_fog_cut()
func _fix_sun() -> void:
var sun := get_node_or_null("Sun") as DirectionalLight3D
if sun == null:
return
sun.rotation_degrees = SUN_ROTATION_DEGREES
func _add_flat_floor() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
plane.subdivide_width = 1
plane.subdivide_depth = 1
var material := ShaderMaterial.new()
material.shader = CAUSTIC_FLOOR_SHADER
material.set_shader_parameter("caustic_noise", _make_ripple_noise(0.007))
var body := StaticBody3D.new()
body.name = "FlatFloor"
body.position = Vector3(0.0, FLOOR_DEPTH_M, 0.0)
body.set_meta("sonar_material", 1)
body.set_meta("sonar_reflectivity", 0.6)
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = "Mesh"
mesh_instance.mesh = plane
mesh_instance.material_override = material
body.add_child(mesh_instance)
var collision := CollisionShape3D.new()
collision.name = "Collision"
var shape := BoxShape3D.new()
shape.size = Vector3(SITE_EXTENT_M, 1.0, SITE_EXTENT_M)
collision.shape = shape
collision.position = Vector3(0.0, -0.5, 0.0)
body.add_child(collision)
add_child(body)
func _add_surface_occluder() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M, SITE_EXTENT_M)
var material := ShaderMaterial.new()
material.shader = SURFACE_OCCLUDER_SHADER
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
material.set_shader_parameter("ripple_scale", SURFACE_RIPPLE_SCALE)
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
material.set_shader_parameter("pool_edge", SHAFT_POOL_EDGE_M)
var occluder := MeshInstance3D.new()
occluder.name = "CalmSurfaceOccluder"
occluder.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
occluder.mesh = plane
occluder.material_override = material
occluder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
add_child(occluder)
func _add_surface_ceiling() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(SITE_EXTENT_M * 1.5, SITE_EXTENT_M * 1.5)
var material := ShaderMaterial.new()
material.shader = SURFACE_CEILING_SHADER
material.set_shader_parameter("ripple_noise", _make_ripple_noise(0.009))
material.set_shader_parameter("drift_speed", SURFACE_DRIFT_SPEED)
material.set_shader_parameter("pool_center_xz", SHAFT_POOL_CENTER_XZ_M)
material.set_shader_parameter("pool_radius", SHAFT_POOL_RADIUS_M)
material.set_shader_parameter("lit_scale", SURFACE_CEILING_LIT_SCALE)
var ceiling := MeshInstance3D.new()
ceiling.name = "SurfaceCeiling"
ceiling.position = Vector3(0.0, SURFACE_LEVEL_M, 0.0)
ceiling.mesh = plane
ceiling.material_override = material
ceiling.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(ceiling)
func _add_air_fog_cut() -> void:
var air := FogVolume.new()
air.name = "AirFogCut"
air.shape = RenderingServer.FOG_VOLUME_SHAPE_BOX
air.size = Vector3(SITE_EXTENT_M * 1.5, AIR_VOLUME_HEIGHT_M, SITE_EXTENT_M * 1.5)
air.position = Vector3(0.0, SURFACE_LEVEL_M + AIR_VOLUME_HEIGHT_M * 0.5, 0.0)
var material := FogMaterial.new()
material.density = -VOLUMETRIC_FOG_DENSITY
material.edge_fade = 0.0
air.material = material
add_child(air)
func _make_ripple_noise(frequency: float) -> NoiseTexture2D:
var noise := FastNoiseLite.new()
noise.seed = NOISE_SEED
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.frequency = frequency
noise.fractal_octaves = 3
noise.fractal_lacunarity = 2.1
noise.fractal_gain = 0.45
var texture := NoiseTexture2D.new()
texture.width = 512
texture.height = 512
texture.seamless = true
texture.generate_mipmaps = true
texture.noise = noise
return texture
@@ -0,0 +1,64 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://game/world/sites/sunlit_shallows_environment.gd" id="1_environment"]
[sub_resource type="Environment" id="EnvironmentSunlitShallows"]
background_mode = 1
background_color = Color(0.005, 0.03, 0.06, 1)
ambient_light_source = 3
ambient_light_color = Color(0.14, 0.32, 0.42, 1)
ambient_light_energy = 0.22
tonemap_mode = 2
tonemap_exposure = 0.75
glow_enabled = true
glow_levels/1 = 0.6
glow_levels/2 = 1.0
glow_levels/3 = 0.8
glow_levels/4 = 0.3
glow_intensity = 0.35
glow_blend_mode = 0
glow_hdr_threshold = 1.6
fog_enabled = true
fog_light_color = Color(0.04, 0.16, 0.24, 1)
fog_light_energy = 0.12
fog_sun_scatter = 0.0
fog_density = 0.003
fog_aerial_perspective = 0.6
fog_sky_affect = 1.0
volumetric_fog_enabled = true
volumetric_fog_density = 0.035
volumetric_fog_albedo = Color(0.26, 0.52, 0.7, 1)
volumetric_fog_emission_energy = 0.0
volumetric_fog_anisotropy = 0.55
volumetric_fog_length = 150.0
volumetric_fog_detail_spread = 1.6
volumetric_fog_ambient_inject = 0.015
volumetric_fog_sky_affect = 1.0
volumetric_fog_temporal_reprojection_enabled = true
volumetric_fog_temporal_reprojection_amount = 0.92
[node name="SunlitShallowsEnvironment" type="Node3D"]
script = ExtResource("1_environment")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("EnvironmentSunlitShallows")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation_degrees = Vector3(-44, 162, 0)
light_color = Color(0.86, 0.95, 1, 1)
light_energy = 1.0
light_volumetric_fog_energy = 1.4
shadow_enabled = true
shadow_bias = 0.04
shadow_normal_bias = 1.5
shadow_blur = 2.5
directional_shadow_mode = 1
directional_shadow_split_1 = 0.2
directional_shadow_blend_splits = true
directional_shadow_max_distance = 160.0
[node name="ObserverCamera" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.9962, 0.0872, 0, -0.0872, 0.9962, 0, -30, 0)
fov = 72.0
near = 0.1
far = 400.0
@@ -0,0 +1,32 @@
// Underside of the calm surface as seen from below: dark everywhere, lit with
// drifting crests only inside the pool where the occluder lets the sun through.
// Purely visual; shadows and shafts come from the occluder mesh.
shader_type spatial;
render_mode unshaded, cull_disabled;
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
uniform float pool_radius = 22.0;
uniform float ripple_scale = 0.03;
uniform float drift_speed = 0.012;
uniform float lit_scale : hint_range(0.0, 1.0) = 0.3;
uniform vec3 dark_color : source_color = vec3(0.01, 0.05, 0.09);
uniform vec3 lit_color : source_color = vec3(0.5, 0.85, 1.0);
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 slow_drift = vec2(TIME * drift_speed);
vec2 cross_drift = vec2(-TIME * drift_speed * 0.7);
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
float crest = smoothstep(0.45, 0.7, broad * 0.65 + fine * 0.35);
float pool = 1.0 - smoothstep(
pool_radius * 0.6, pool_radius * 1.6, distance(world_xz, pool_center_xz)
);
ALBEDO = mix(dark_color, lit_color * (0.35 + crest) * lit_scale, pool);
}
@@ -0,0 +1,39 @@
// Calm-water surface seen only through its shadow: the mesh is invisible
// (SHADOW_ONLY) and the alpha-scissor cut-outs let the fixed daytime sun carve
// light shafts into the volumetric fog below.
shader_type spatial;
render_mode cull_disabled, unshaded, depth_prepass_alpha;
uniform sampler2D ripple_noise : repeat_enable, filter_linear_mipmap;
uniform float ripple_scale = 0.025;
uniform float drift_speed = 0.012;
uniform float coverage : hint_range(0.0, 1.0) = 0.30;
// World-space pool where the surface lets sunlight through. Outside it the
// surface is fully opaque, so shafts only form in that patch of water.
uniform vec2 pool_center_xz = vec2(-14.0, -31.0);
uniform float pool_radius = 22.0;
uniform float pool_edge = 12.0;
varying vec2 world_xz;
void vertex() {
world_xz = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
void fragment() {
vec2 slow_drift = TIME * drift_speed * vec2(1.0, 0.35);
vec2 cross_drift = TIME * drift_speed * vec2(-0.4, 0.9);
float broad = texture(ripple_noise, world_xz * ripple_scale + slow_drift).r;
float fine = texture(ripple_noise, world_xz * ripple_scale * 1.9 + cross_drift).r;
float raw_ripple = broad * 0.65 + fine * 0.35;
// Inside the pool the crests are sharpened so the gaps cut crisp shafts.
float pool_ripple = smoothstep(0.35, 0.65, raw_ripple);
// Outside the pool only the deepest troughs open: a dim dapple, not darkness.
float dim_ripple = smoothstep(0.16, 0.30, raw_ripple);
float pool = 1.0 - smoothstep(
pool_radius - pool_edge, pool_radius, distance(world_xz, pool_center_xz)
);
ALBEDO = vec3(0.0);
ALPHA = mix(dim_ripple, pool_ripple, pool);
ALPHA_SCISSOR_THRESHOLD = coverage;
}
+2
View File
@@ -23,4 +23,6 @@ window/stretch/mode="canvas_items"
[rendering] [rendering]
environment/volumetric_fog/volume_size=128
environment/volumetric_fog/volume_depth=128
textures/default_filters/use_nearest_mipmap_filter=false textures/default_filters/use_nearest_mipmap_filter=false
+1 -1
View File
@@ -108,7 +108,7 @@ if [ -d game ]; then
input_violations="$( input_violations="$(
rg -n '\bInput\.' game --glob '*.gd' \ rg -n '\bInput\.' game --glob '*.gd' \
| rg -v '^game/infrastructure/input/' \ | rg -v '^game[/\\]infrastructure[/\\]input[/\\]' \
|| true || true
)" )"
fail_with_matches "direct Input access outside infrastructure/input" "$input_violations" fail_with_matches "direct Input access outside infrastructure/input" "$input_violations"
+10
View File
@@ -113,6 +113,16 @@ check_godot_log \
"production foundation integration" \ "production foundation integration" \
"$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log" "$QUALITY_TEMP_DIRECTORY/foundation-integration-console.log"
printf 'Quality gate: sunlit shallows environment integration\n'
"$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration.log" \
--path . \
--script tests/integration/run_sunlit_shallows_environment.gd \
2>&1 | tee "$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
check_godot_log \
"sunlit shallows environment integration" \
"$QUALITY_TEMP_DIRECTORY/sunlit-shallows-integration-console.log"
printf 'Quality gate: POC B integration\n' printf 'Quality gate: POC B integration\n'
"$GODOT_EXECUTABLE" --headless \ "$GODOT_EXECUTABLE" --headless \
--log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \ --log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \
@@ -0,0 +1,248 @@
extends SceneTree
## Checks the sunlit shallows visual site keeps its three promises:
## a flat floor, a calm shadow-only water surface, and a daytime sun whose
## hour never changes.
const SCENE_PATH := "res://game/world/sites/sunlit_shallows_environment.tscn"
const WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/sunlit_shallows_world.tscn"
)
var _failures: Array[String] = []
var _checks := 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene := load(SCENE_PATH) as PackedScene
_expect(scene != null, "scene loads")
if scene == null:
_finish()
return
var site := scene.instantiate() as SunlitShallowsEnvironment
_expect(site != null, "scene root is SunlitShallowsEnvironment")
if site == null:
_finish()
return
root.add_child(site)
await process_frame
_test_fixed_daytime_sun(site)
_test_flat_floor(site)
_test_calm_surface_occluder(site)
_test_scattering_environment(site)
_test_air_fog_cut(site)
_test_surface_ceiling(site)
site.queue_free()
await process_frame
_test_composed_world()
_finish()
func _test_composed_world() -> void:
var world_scene := load(WORLD_SCENE_PATH) as PackedScene
_expect(world_scene != null, "composed world scene loads")
if world_scene == null:
return
var world := world_scene.instantiate() as Node3D
_expect(
world.get_node_or_null("Environment") is SunlitShallowsEnvironment,
"composed world uses the sunlit shallows environment"
)
var submarine := world.get_node_or_null("SubmarineActor") as Node3D
_expect(submarine != null, "composed world has a submarine")
if submarine != null:
_expect(
submarine.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M
and submarine.position.y > SunlitShallowsEnvironment.FLOOR_DEPTH_M,
"submarine starts between surface and floor"
)
_expect(
submarine.get_node_or_null("CockpitInterior") != null
and submarine.get_node_or_null("CockpitCameraRig") != null,
"submarine carries cockpit and camera rig"
)
for projector_name: String in ["LeftProjector", "RightProjector"]:
var projector := submarine.get_node_or_null(projector_name) as SpotLight3D
_expect(projector != null, "%s exists" % projector_name)
if projector != null:
_expect(
projector.light_volumetric_fog_energy < 1.0,
"%s does not wash out the sun shafts in the fog" % projector_name
)
world.free()
func _test_fixed_daytime_sun(site: SunlitShallowsEnvironment) -> void:
var sun := site.get_node_or_null("Sun") as DirectionalLight3D
_expect(sun != null, "sun is a DirectionalLight3D")
if sun == null:
return
_expect(
sun.rotation_degrees.is_equal_approx(
SunlitShallowsEnvironment.SUN_ROTATION_DEGREES
),
"sun hour is the fixed constant"
)
var sun_direction := -sun.global_transform.basis.z
_expect(sun_direction.y < -0.6, "sun shines downward from above the porthole (daytime)")
_expect(
sun_direction.z > 0.15,
"sun shines toward the observer (rays come from ahead, world -Z)"
)
_expect(sun.shadow_enabled, "sun casts shadows (needed for light shafts)")
_expect(
sun.light_volumetric_fog_energy >= 1.0,
"sun injects energy into the volumetric fog"
)
func _test_flat_floor(site: SunlitShallowsEnvironment) -> void:
var floor_mesh := site.get_node_or_null("FlatFloor/Mesh") as MeshInstance3D
_expect(floor_mesh != null, "floor mesh exists")
if floor_mesh == null:
return
var vertices := floor_mesh.mesh.get_faces()
var flat := true
for vertex: Vector3 in vertices:
if not is_zero_approx(vertex.y):
flat = false
break
_expect(flat, "floor mesh is flat")
_expect(
is_equal_approx(
floor_mesh.global_position.y,
SunlitShallowsEnvironment.FLOOR_DEPTH_M
),
"floor sits at the site depth"
)
_expect(
floor_mesh.material_override is ShaderMaterial,
"floor uses the caustic shader material"
)
var collision := site.get_node_or_null("FlatFloor/Collision") as CollisionShape3D
_expect(collision != null and collision.shape is BoxShape3D, "floor has a box collider")
func _test_calm_surface_occluder(site: SunlitShallowsEnvironment) -> void:
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
_expect(occluder != null, "surface occluder exists")
if occluder == null:
return
_expect(
occluder.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY,
"surface occluder is shadow-only"
)
_expect(
is_equal_approx(
occluder.global_position.y,
SunlitShallowsEnvironment.SURFACE_LEVEL_M
),
"surface occluder sits at water level"
)
var material := occluder.material_override as ShaderMaterial
_expect(material != null, "surface occluder uses a shader material")
if material == null:
return
var drift: Variant = material.get_shader_parameter("drift_speed")
_expect(
drift is float and drift > 0.0 and drift < 0.05,
"surface drift is slow (calm water)"
)
_expect(
material.get_shader_parameter("ripple_noise") is NoiseTexture2D,
"surface ripple noise is assigned"
)
var pool_center: Variant = material.get_shader_parameter("pool_center_xz")
_expect(
pool_center is Vector2 and pool_center.y < -20.0 and pool_center.y > -120.0,
"light pool sits a few tens of metres ahead (world -Z)"
)
var pool_radius: Variant = material.get_shader_parameter("pool_radius")
_expect(
pool_radius is float and pool_radius > 5.0 and pool_radius < 60.0,
"light pool radius is local, not the whole surface"
)
func _test_scattering_environment(site: SunlitShallowsEnvironment) -> void:
var world_environment := site.get_node_or_null("WorldEnvironment") as WorldEnvironment
_expect(world_environment != null, "world environment exists")
if world_environment == null:
return
var environment := world_environment.environment
_expect(environment.volumetric_fog_enabled, "volumetric fog is enabled")
_expect(environment.volumetric_fog_anisotropy > 0.5, "fog scatters forward")
_expect(environment.fog_enabled, "depth fog is enabled")
func _test_surface_ceiling(site: SunlitShallowsEnvironment) -> void:
var ceiling := site.get_node_or_null("SurfaceCeiling") as MeshInstance3D
_expect(ceiling != null, "visible surface ceiling exists")
if ceiling == null:
return
_expect(
is_equal_approx(ceiling.global_position.y, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
"surface ceiling sits at water level"
)
_expect(
ceiling.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF,
"surface ceiling leaves shadowing to the occluder"
)
var ceiling_material := ceiling.material_override as ShaderMaterial
var occluder := site.get_node_or_null("CalmSurfaceOccluder") as MeshInstance3D
var occluder_material: ShaderMaterial = null if occluder == null else occluder.material_override
_expect(
ceiling_material != null
and occluder_material != null
and ceiling_material.get_shader_parameter("pool_center_xz")
== occluder_material.get_shader_parameter("pool_center_xz")
and ceiling_material.get_shader_parameter("pool_radius")
== occluder_material.get_shader_parameter("pool_radius"),
"surface ceiling lights the same pool the occluder opens"
)
func _test_air_fog_cut(site: SunlitShallowsEnvironment) -> void:
var air := site.get_node_or_null("AirFogCut") as FogVolume
_expect(air != null, "air fog cut exists")
if air == null:
return
var bottom := air.global_position.y - air.size.y * 0.5
_expect(
is_equal_approx(bottom, SunlitShallowsEnvironment.SURFACE_LEVEL_M),
"air fog cut starts at the water surface"
)
var material := air.material as FogMaterial
_expect(material != null and material.density < 0.0, "air fog cut subtracts fog")
var world_environment := site.get_node("WorldEnvironment") as WorldEnvironment
_expect(
material != null and is_equal_approx(
-material.density,
world_environment.environment.volumetric_fog_density
),
"air fog cut cancels the global volumetric density exactly"
)
func _expect(condition: bool, label: String) -> void:
_checks += 1
if not condition:
_failures.append(label)
func _finish() -> void:
if _failures.is_empty():
print("Sunlit shallows environment: %d passed, 0 failed." % _checks)
quit(0)
else:
for failure: String in _failures:
push_error("sunlit shallows environment: %s" % failure)
print("Sunlit shallows environment: %d failed." % _failures.size())
quit(1)
@@ -30,7 +30,7 @@ const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd" "res://game/infrastructure/input/production_input_adapter.gd"
) )
const TEST_COUNT := 91 const TEST_COUNT := 94
var _failures: Array[String] = [] var _failures: Array[String] = []
@@ -186,6 +186,9 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect(detailing.practical_light_count() == 2, "cockpit uses two caged practical lights") _expect(detailing.practical_light_count() == 2, "cockpit uses two caged practical lights")
_expect(pressure_bulkhead.panel_count() == 1, "porthole bulkhead uses one continuous curved shell") _expect(pressure_bulkhead.panel_count() == 1, "porthole bulkhead uses one continuous curved shell")
_expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden") _expect(pressure_bulkhead.rectangular_whitebox_is_hidden(), "porthole rectangular whitebox is hidden")
_expect(pressure_bulkhead.glass_is_on_dedicated_layer(), "porthole glass sits alone on its render layer")
_expect(pressure_bulkhead.glass_roughness() >= 0.4, "porthole glass is rough enough to smear lamp reflections")
_expect(detailing.fill_light_ignores_porthole_glass(), "porthole fill light does not reflect in the glass")
_expect(analog_gauges.gauge_count() == 4, "dashboard exposes four primary analog instruments") _expect(analog_gauges.gauge_count() == 4, "dashboard exposes four primary analog instruments")
_expect(alert_box.indicator_count() == 3, "alert box exposes three pilot lights") _expect(alert_box.indicator_count() == 3, "alert box exposes three pilot lights")
_expect(alert_box.active_indicator_count() == 1, "exactly one pilot light reflects the alert state") _expect(alert_box.active_indicator_count() == 1, "exactly one pilot light reflects the alert state")