diff --git a/game/composition/vertical_slice/sunlit_shallows_world.tscn b/game/composition/vertical_slice/sunlit_shallows_world.tscn new file mode 100644 index 0000000..bd932a3 --- /dev/null +++ b/game/composition/vertical_slice/sunlit_shallows_world.tscn @@ -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 diff --git a/game/presentation/cockpit/cockpit_detailing.gd b/game/presentation/cockpit/cockpit_detailing.gd index acc8115..926ae36 100644 --- a/game/presentation/cockpit/cockpit_detailing.gd +++ b/game/presentation/cockpit/cockpit_detailing.gd @@ -108,6 +108,10 @@ func practical_light_count() -> int: 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: return _analog_gauges != null and _analog_gauges.get_parent() == get_parent().get_node("Dashboard") diff --git a/game/presentation/cockpit/cockpit_practical_lighting.gd b/game/presentation/cockpit/cockpit_practical_lighting.gd index 3d2d5d8..609ad34 100644 --- a/game/presentation/cockpit/cockpit_practical_lighting.gd +++ b/game/presentation/cockpit/cockpit_practical_lighting.gd @@ -9,6 +9,10 @@ const CockpitMeshFactory = preload( "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") ## 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 _warm_lights: Array[OmniLight3D] = [] var _emergency_light: OmniLight3D +var _porthole_fill: OmniLight3D func _init(materials: CockpitMaterialPalette) -> void: @@ -103,7 +108,20 @@ func _add_fill_light() -> void: porthole_fill.light_energy = 1.0 porthole_fill.omni_range = 1.1 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) + _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: diff --git a/game/presentation/cockpit/cockpit_pressure_bulkhead.gd b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd index d9d3479..34029d3 100644 --- a/game/presentation/cockpit/cockpit_pressure_bulkhead.gd +++ b/game/presentation/cockpit/cockpit_pressure_bulkhead.gd @@ -4,6 +4,18 @@ extends Node3D ## 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`. +## 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] = [ "LeftBulkhead", "RightBulkhead", @@ -26,6 +38,21 @@ func _ready() -> void: push_error("Pressure hull or porthole model is missing a node") return _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: diff --git a/game/world/sites/sunlit_shallows_caustic_floor.gdshader b/game/world/sites/sunlit_shallows_caustic_floor.gdshader new file mode 100644 index 0000000..ec2613b --- /dev/null +++ b/game/world/sites/sunlit_shallows_caustic_floor.gdshader @@ -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; +} diff --git a/game/world/sites/sunlit_shallows_environment.gd b/game/world/sites/sunlit_shallows_environment.gd new file mode 100644 index 0000000..2234248 --- /dev/null +++ b/game/world/sites/sunlit_shallows_environment.gd @@ -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 diff --git a/game/world/sites/sunlit_shallows_environment.tscn b/game/world/sites/sunlit_shallows_environment.tscn new file mode 100644 index 0000000..a34ac31 --- /dev/null +++ b/game/world/sites/sunlit_shallows_environment.tscn @@ -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 diff --git a/game/world/sites/sunlit_shallows_surface_ceiling.gdshader b/game/world/sites/sunlit_shallows_surface_ceiling.gdshader new file mode 100644 index 0000000..21451c9 --- /dev/null +++ b/game/world/sites/sunlit_shallows_surface_ceiling.gdshader @@ -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); +} diff --git a/game/world/sites/sunlit_shallows_surface_occluder.gdshader b/game/world/sites/sunlit_shallows_surface_occluder.gdshader new file mode 100644 index 0000000..d86f308 --- /dev/null +++ b/game/world/sites/sunlit_shallows_surface_occluder.gdshader @@ -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; +} diff --git a/project.godot b/project.godot index 9c631e7..dec6187 100644 --- a/project.godot +++ b/project.godot @@ -23,4 +23,6 @@ window/stretch/mode="canvas_items" [rendering] +environment/volumetric_fog/volume_size=128 +environment/volumetric_fog/volume_depth=128 textures/default_filters/use_nearest_mipmap_filter=false diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index 6f2c38d..6c8177c 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -108,7 +108,7 @@ if [ -d game ]; then input_violations="$( rg -n '\bInput\.' game --glob '*.gd' \ - | rg -v '^game/infrastructure/input/' \ + | rg -v '^game[/\\]infrastructure[/\\]input[/\\]' \ || true )" fail_with_matches "direct Input access outside infrastructure/input" "$input_violations" diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index c14fb7e..c8984ae 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -113,6 +113,16 @@ check_godot_log \ "production foundation integration" \ "$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' "$GODOT_EXECUTABLE" --headless \ --log-file "$QUALITY_TEMP_DIRECTORY/poc_b_integration.log" \ diff --git a/tests/integration/run_sunlit_shallows_environment.gd b/tests/integration/run_sunlit_shallows_environment.gd new file mode 100644 index 0000000..607122d --- /dev/null +++ b/tests/integration/run_sunlit_shallows_environment.gd @@ -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) diff --git a/tests/integration/run_vertical_slice_foundation.gd b/tests/integration/run_vertical_slice_foundation.gd index 07fc0ea..f537333 100644 --- a/tests/integration/run_vertical_slice_foundation.gd +++ b/tests/integration/run_vertical_slice_foundation.gd @@ -30,7 +30,7 @@ const ProductionInputAdapter = preload( "res://game/infrastructure/input/production_input_adapter.gd" ) -const TEST_COUNT := 91 +const TEST_COUNT := 94 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(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.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(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")