Author SHA1 Message Date
Codex 2d56842c90 Wire Hebat Allah dive into the main game 2026-10-07 16:20:14 +02:00
OlivierandClaude Fable 5.1 b72cdd5042 Add Hebat Allah wreck to the sunlit shallows site
First wreck in the production world layer. A WreckActor (StaticBody3D)
instantiates a two-LOD glTF visual, builds a trimesh collision from a
separate lighter glTF, and carries the sonar metadata (metal, 0.9
reflectivity) so the bathymetry rays echo it. The sunlit shallows site
places the MV Hebat Allah 62 m ahead of the submarine on the floor.

The assets derive from a CC BY 4.0 photogrammetry scan by Holger Buss
(Sketchfab). The raw 274 MB scan stays outside the repo; the committed
GLBs come from a reproducible Blender pipeline under scripts/assets/:
skirt cut, 44 m rescale, merge by distance (the scan is a triangle soup),
fragment removal, 0.4 m voxel remesh, decimation to 58k/14.5k/2.9k
triangles, smart UVs and a 2K albedo rebaked from a luminance-rebuilt
texture (the scan's red channel is dead).

docs/ASSET_LICENSES.md starts the licence registry required by the design
plan and records the attribution owed in the credits. The site suite
gains twelve assertions covering sonar metadata, collision, LOD handover
and triangle budgets. Godot extracts the embedded albedo next to the glb
on import; those PNGs are ignored like the cockpit ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:11:30 +02:00
chindit e53bf8dbb4 Merge pull request 'Add sunlit shallows site with localized sun shafts' (#2) from olix21/feat/sunlit-shallows into main
Quality Gate / Quality Gate (push) Successful in 5m26s
Reviewed-on: #2
2026-10-07 12:21:42 +02:00
OlivierandClaude Fable 5.1 f0072a3e11 Add sunlit shallows site with localized sun shafts
Quality Gate / Quality Gate (pull_request) Successful in 6m55s
New visual test site under game/world/sites: flat sandy floor at
-40 m, calm surface at 0 m, fixed daytime sun ahead of and above
the submarine. Light shafts come from a shadow-only surface
occluder whose alpha-scissor ripples open only inside a pool a few
tens of metres ahead; a subtractive FogVolume cancels the global
volumetric fog above the water, and an unshaded ceiling mesh shows
the lit underside of the surface inside that pool. A composed
world (sunlit_shallows_world.tscn) mounts the submarine, cockpit
and camera rig in it, with the projectors kept out of the fog.

Cockpit: the porthole glass moves to its own render layer with a
rougher material and the porthole fill light excludes that layer,
so interior lamps no longer mirror as hard specular points.

Quality gate: new integration suite for the site (42 checks), three
glass assertions in the foundation suite, and the Input-access
architecture rule now matches both path separators so the gate runs
on Windows. project.godot raises the volumetric fog froxel grid to
128 so the shafts stay readable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:18:56 +02:00
23 changed files with 1228 additions and 10 deletions

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ builds/
# Textures que Godot extrait des .glb a l import : regenerees, non versionnees
game/presentation/cockpit/assets/models/*.png
game/world/wrecks/assets/**/*.png
+49
View File
@@ -0,0 +1,49 @@
# Registre des licences de ressources externes
Registre exigé par `GAME_DESIGN_PLAN.md` §19 : chaque ressource tierce (modèle,
texture, son, police) a une entrée ici avant d'entrer dans le dépôt ou dans une
build. Une ressource dont la licence interdit la diffusion commerciale est
marquée `À REMPLACER AVANT COMMERCIALISATION` et doit sortir du projet avant
toute vente, précommande, financement participatif ou page store payante.
Statuts possibles : `OK commercial`, `À VÉRIFIER`, `À REMPLACER AVANT
COMMERCIALISATION`, `RETIRÉ`.
## Modèles 3D
### MV Hebat Allah — `OK commercial` (crédit obligatoire)
- Source : https://sketchfab.com/3d-models/shipwreck-mv-hebat-allah-3a5306f3961146729d054ee42bc063e5
- Auteur : Holger Buss (`holger.buss`), Sketchfab.
- Licence : **CC BY 4.0**. Usage commercial et dérivés autorisés à condition de
créditer l'auteur. Crédit à inscrire dans l'écran des crédits :
« MV Hebat Allah shipwreck scan by Holger Buss (sketchfab.com), CC BY 4.0,
modified ».
- Contenu d'origine : scan photogrammétrique d'un caboteur de 44 m coulé
volontairement en mer Rouge (2004). 2 375 184 triangles, 109 morceaux, une
texture 8K. Fichier brut conservé hors dépôt dans
`C:\Users\olivi\Assets\hebat_allah\raw\`, jamais committé.
- Dérivés committés (`game/world/wrecks/assets/hebat_allah/`) : coque remise à
l'échelle sur 44 m, jupe de sédiment coupée à 3 m, soupe de triangles fusionnée
par distance, fragments isolés retirés, surface refermée par remesh voxel
0,4 m, LOD0 58 k triangles et LOD1 14,5 k avec UV refaits, albédo rebaké en 2K
puis neutralisé (le canal rouge du scan est inexploitable, la teinte est
reconstruite depuis la luminance), collision trimesh séparée de 2,9 k
triangles. Pipeline reproductible : `scripts/assets/hebat_allah_prepare_albedo.py`
puis `scripts/assets/hebat_allah_blender_pipeline.py`.
- Usage dans le projet : kit « acier du XXe siècle », site des hauts-fonds
ensoleillés (`game/world/sites/sunlit_shallows_environment.tscn`).
## Modèles du cockpit — `À VÉRIFIER` (polices seulement)
- `game/presentation/cockpit/assets/models/*.glb` : générés par scripts Blender
écrits pour le projet, aucune ressource tierce (voir
`docs/briefs/cockpit-modeles-integration.md`). Modèles : `OK commercial`.
- Polices rendues en texture dans les cadrans et étiquettes : Arial et Impact,
polices système Windows. Point de licence à vérifier avant diffusion ; prévoir
une police libre (SIL OFL) en remplacement si nécessaire.
## Images de référence
- `docs/visual_targets/cockpit-objective-v1.jpg` : générée pour le projet,
référence interne non distribuée dans les builds. Pas de contrainte.
@@ -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
@@ -51,8 +51,8 @@ const ConfirmationDialogScreen = preload(
const RebindResult = preload("res://game/application/input/rebind_result.gd")
const MAIN_MENU_SCENE = preload("res://game/presentation/main_menu/main_menu_screen.tscn")
const VEHICLE_TEST_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/vehicle_test_world.tscn"
const DIVE_WORLD_SCENE_PATH := (
"res://game/composition/vertical_slice/sunlit_shallows_world.tscn"
)
const COCKPIT_HUD_SCENE = preload("res://game/presentation/cockpit/cockpit_hud.tscn")
const SONAR_DISPLAY_SCENE = preload(
@@ -66,7 +66,7 @@ const CONFIRMATION_SCENE = preload(
"res://game/presentation/confirmation/confirmation_dialog.tscn"
)
const DIVE_SESSION_ID := &"vertical_slice_vehicle_test"
const DIVE_SESSION_ID := &"sunlit_shallows_dive"
const CONFIRM_ABANDON := &"abandon_dive"
const CONFIRM_MAIN_MENU := &"return_main_menu"
const CONFIRM_QUIT := &"quit_game"
@@ -105,7 +105,7 @@ func _ready() -> void:
_show_main_menu()
func _start_vehicle_test() -> void:
func _start_dive() -> void:
var rules = DiveRulesResolver.new().resolve(
DiveDifficultyProfile.standard(),
DiveUpgradeEffects.new(),
@@ -117,7 +117,7 @@ func _start_vehicle_test() -> void:
if not session.is_ready():
return
var world_scene := load(VEHICLE_TEST_WORLD_SCENE_PATH) as PackedScene
var world_scene := load(DIVE_WORLD_SCENE_PATH) as PackedScene
if world_scene == null:
return
var world := world_scene.instantiate() as Node3D
@@ -457,7 +457,7 @@ func _show_main_menu() -> void:
_replace_active_world(null)
var screen := MAIN_MENU_SCENE.instantiate() as MainMenuScreen
_replace_active_screen(screen)
screen.new_game_requested.connect(_start_vehicle_test)
screen.new_game_requested.connect(_start_dive)
screen.quit_requested.connect(_quit_game)
@@ -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")
@@ -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:
@@ -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:
@@ -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,69 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://game/world/sites/sunlit_shallows_environment.gd" id="1_environment"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/hebat_allah_wreck.tscn" id="2_wreck"]
[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
[node name="HebatAllahWreck" parent="." instance=ExtResource("2_wreck")]
position = Vector3(-8, -41, -62)
rotation_degrees = Vector3(0, 55, 3)
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://game/world/wrecks/wreck_actor.gd" id="1_wreck_actor"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb" id="2_visual"]
[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb" id="3_collision"]
[node name="HebatAllahWreck" type="StaticBody3D"]
script = ExtResource("1_wreck_actor")
visual_scene = ExtResource("2_visual")
collision_scene = ExtResource("3_collision")
lod_switch_distance_m = 45.0
sonar_reflectivity = 0.9
+65
View File
@@ -0,0 +1,65 @@
class_name WreckActor
extends StaticBody3D
## A wreck resting on a site: two visual levels of detail from one glTF and a
## separate, much lighter trimesh collision. The sonar reads the body's
## metadata, so this node is the one owner of how a wreck echoes.
const LOD0_NODE_NAME := "WreckLOD0"
const LOD1_NODE_NAME := "WreckLOD1"
const COLLISION_NODE_NAME := "WreckCollision"
const COLLISION_SHAPE_NAME := "Collision"
const VISUAL_NAME := "Visual"
## Hysteresis around the LOD handover so a hovering submarine never flickers.
const LOD_MARGIN_M := 4.0
@export var visual_scene: PackedScene
@export var collision_scene: PackedScene
## Beyond this distance the coarse LOD is drawn instead of the detailed one.
@export_range(10.0, 400.0, 1.0) var lod_switch_distance_m := 45.0
@export_range(0.0, 1.0, 0.01) var sonar_reflectivity := 0.9
func _ready() -> void:
set_meta("sonar_material", SonarMaterial.Value.METAL)
set_meta("sonar_reflectivity", sonar_reflectivity)
_build_visual()
_build_collision()
func _build_visual() -> void:
if visual_scene == null:
push_error("WreckActor %s has no visual scene" % name)
return
var visual := visual_scene.instantiate() as Node3D
visual.name = VISUAL_NAME
add_child(visual)
var lod0 := visual.find_child(LOD0_NODE_NAME, true, false) as MeshInstance3D
var lod1 := visual.find_child(LOD1_NODE_NAME, true, false) as MeshInstance3D
if lod0 == null or lod1 == null:
push_error("WreckActor %s visual scene lacks %s or %s" % [
name, LOD0_NODE_NAME, LOD1_NODE_NAME
])
return
lod0.visibility_range_end = lod_switch_distance_m
lod0.visibility_range_end_margin = LOD_MARGIN_M
lod1.visibility_range_begin = lod_switch_distance_m
lod1.visibility_range_begin_margin = LOD_MARGIN_M
func _build_collision() -> void:
if collision_scene == null:
push_error("WreckActor %s has no collision scene" % name)
return
var source := collision_scene.instantiate() as Node3D
var collision_mesh := source.find_child(COLLISION_NODE_NAME, true, false) as MeshInstance3D
if collision_mesh == null or collision_mesh.mesh == null:
push_error("WreckActor %s collision scene lacks %s" % [name, COLLISION_NODE_NAME])
source.free()
return
var shape := CollisionShape3D.new()
shape.name = COLLISION_SHAPE_NAME
shape.shape = collision_mesh.mesh.create_trimesh_shape()
shape.transform = collision_mesh.transform
source.free()
add_child(shape)
+2
View File
@@ -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
@@ -0,0 +1,272 @@
"""Blender pipeline turning the raw MV Hebat Allah photogrammetry scan into the
game-ready wreck assets committed under game/world/wrecks/assets/hebat_allah/.
Run headless with Blender 5.2:
WRECK_RAW_DIR=<dir with wreck_raw_joined.blend and albedo_2k_balanced.png>
WRECK_OUT_DIR=<output dir>
WRECK_VOXEL=0.4
blender -b --python scripts/assets/hebat_allah_blender_pipeline.py
Steps: apply transforms, cut the sediment skirt 3 m around the hull, scale the
hull to its real 44 m, merge the triangle soup by distance, drop isolated
fragments, voxel remesh to a closed surface, decimate LOD0, smart-project UVs,
bake the albedo from the scan, derive LOD1 and a UV-less collision mesh, export
two GLBs. Licence: see docs/ASSET_LICENSES.md (CC BY 4.0, Holger Buss).
"""
import bpy, bmesh, math, json, os, time
from mathutils import Vector, Matrix
T0 = time.time()
RAW = os.environ.get("WRECK_RAW_DIR", r"C:\Users\olivi\Assets\hebat_allah\raw")
VOXEL_TAG = os.environ.get("WRECK_VOXEL", "0.4")
VOXEL = float(VOXEL_TAG)
OUT = os.environ.get("WRECK_OUT_DIR", r"C:\Users\olivi\Assets\hebat_allah\out\v" + VOXEL_TAG)
os.makedirs(OUT, exist_ok=True)
CHK = os.path.join(OUT, "check")
os.makedirs(CHK, exist_ok=True)
log = {}
def mark(k):
log[k] = round(time.time() - T0, 1)
print("STEP", k, log[k], flush=True)
def activate(o):
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.wm.open_mainfile(filepath=RAW + r"\wreck_raw_joined.blend")
src = bpy.data.objects["wreck_raw"]
activate(src)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
co = [v.co for v in src.data.vertices if v.co.z > 3.0]
fx = (min(c.x for c in co), max(c.x for c in co))
fy = (min(c.y for c in co), max(c.y for c in co))
MARGIN = 3.0
bpy.ops.object.mode_set(mode='EDIT')
for pno, pco in [((1, 0, 0), (fx[1] + MARGIN, 0, 0)), ((-1, 0, 0), (fx[0] - MARGIN, 0, 0)),
((0, 1, 0), (0, fy[1] + MARGIN, 0)), ((0, -1, 0), (0, fy[0] - MARGIN, 0))]:
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.bisect(plane_co=pco, plane_no=pno, clear_outer=True, clear_inner=False)
bpy.ops.object.mode_set(mode='OBJECT')
cx, cy = (fx[0] + fx[1]) / 2, (fy[0] + fy[1]) / 2
zmin = min(v.co.z for v in src.data.vertices)
SCALE = 44.0 / (fx[1] - fx[0])
src.data.transform(Matrix.Scale(SCALE, 4) @ Matrix.Rotation(math.radians(90), 4, 'Z') @ Matrix.Translation((-cx, -cy, -zmin)))
src.data.update()
mark("cut_scaled")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
src.data.calc_loop_triangles()
log["verts_after_merge"] = len(src.data.vertices)
mark("merged")
print("DBG", log, flush=True)
def drop_small_components(obj, min_faces):
import numpy as np
me = obj.data
nv = len(me.vertices)
ev = np.empty(len(me.edges) * 2, dtype=np.int64)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
label = np.arange(nv, dtype=np.int64)
for _ in range(10000):
m = np.minimum(label[ev[:, 0]], label[ev[:, 1]])
new = label.copy()
np.minimum.at(new, ev[:, 0], m)
np.minimum.at(new, ev[:, 1], m)
for _ in range(4):
new = new[new]
if np.array_equal(new, label):
break
label = new
nf = len(me.polygons)
loop_start = np.empty(nf, dtype=np.int64)
me.polygons.foreach_get("loop_start", loop_start)
loop_vert = np.empty(len(me.loops), dtype=np.int64)
me.loops.foreach_get("vertex_index", loop_vert)
flab = label[loop_vert[loop_start]]
counts = np.bincount(flab, minlength=nv)
small = counts[flab] < min_faces
removed = int(np.unique(flab[small]).size)
kept = int(np.unique(flab[~small]).size)
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
geom = [bm.faces[int(i)] for i in np.nonzero(small)[0]]
if geom:
bmesh.ops.delete(bm, geom=geom, context='FACES')
bm.to_mesh(me)
bm.free()
me.update()
return removed, kept
log["frag_removed_kept"] = drop_small_components(src, 400)
mark("fragments")
print("DBG", log, flush=True)
src.data.calc_loop_triangles()
log["tris_after_clean"] = len(src.data.loop_triangles)
mat_src = bpy.data.materials.new("src_mat")
mat_src.use_nodes = True
nt = mat_src.node_tree
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.load(RAW + r"\albedo_2k_balanced.png")
nt.links.new(tex.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"])
src.data.materials.clear()
src.data.materials.append(mat_src)
rm = src.copy()
rm.data = src.data.copy()
rm.name = "remeshed"
bpy.context.scene.collection.objects.link(rm)
activate(rm)
rm.data.remesh_voxel_size = VOXEL
rm.data.remesh_voxel_adaptivity = 0.0
rm.data.use_remesh_fix_poles = False
rm.data.use_remesh_preserve_volume = True
bpy.ops.object.voxel_remesh()
rm.data.calc_loop_triangles()
log["tris_after_remesh"] = len(rm.data.loop_triangles)
mark("remesh")
print("DBG", log, flush=True)
log["remesh_frag_removed_kept"] = drop_small_components(rm, 300)
rm.data.calc_loop_triangles()
n_rm = len(rm.data.loop_triangles)
lod0 = rm
lod0.name = "WreckLOD0"
lod0.data.name = "WreckLOD0"
m = lod0.modifiers.new("dec", 'DECIMATE')
m.ratio = min(1.0, 60000 / n_rm)
m.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier="dec")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.003, correct_aspect=True, scale_to_bounds=False)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.shade_smooth()
mark("lod0_uv")
baked = bpy.data.images.new("wreck_hebat_allah_albedo", 2048, 2048)
baked.generated_color = (0.3, 0.3, 0.3, 1)
mat = bpy.data.materials.new("wreck_hebat_allah")
mat.use_nodes = True
nt = mat.node_tree
tn = nt.nodes.new("ShaderNodeTexImage")
tn.image = baked
nt.links.new(tn.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"])
nt.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85
nt.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.0
nt.nodes.active = tn
lod0.data.materials.clear()
lod0.data.materials.append(mat)
sc = bpy.context.scene
sc.render.engine = 'CYCLES'
sc.cycles.device = 'CPU'
sc.cycles.samples = 4
sc.render.bake.use_selected_to_active = True
sc.render.bake.cage_extrusion = 0.6
sc.render.bake.max_ray_distance = 1.6
sc.render.bake.margin = 8
sc.render.bake.use_pass_direct = False
sc.render.bake.use_pass_indirect = False
sc.render.bake.use_pass_color = True
bpy.ops.object.select_all(action='DESELECT')
src.select_set(True)
lod0.select_set(True)
bpy.context.view_layer.objects.active = lod0
bpy.ops.object.bake(type='DIFFUSE')
baked.filepath_raw = OUT + r"\hebat_allah_albedo_2k.png"
baked.file_format = 'PNG'
baked.save()
mark("bake")
def derive(name, ratio, keep_mat):
o = lod0.copy()
o.data = lod0.data.copy()
o.name = name
o.data.name = name
bpy.context.scene.collection.objects.link(o)
activate(o)
if not keep_mat:
while o.data.uv_layers:
o.data.uv_layers.remove(o.data.uv_layers[0])
m = o.modifiers.new("dec", 'DECIMATE')
m.ratio = ratio
m.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier="dec")
if not keep_mat:
o.data.materials.clear()
o.data.calc_loop_triangles()
return o, len(o.data.loop_triangles)
lod1, t1 = derive("WreckLOD1", 0.25, True)
col, tc = derive("WreckCollision", 0.05, False)
lod0.data.calc_loop_triangles()
t0 = len(lod0.data.loop_triangles)
bpy.data.objects.remove(src)
def export(objs, path):
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=True, export_apply=True,
export_yup=True, export_image_format='AUTO', export_materials='EXPORT')
export([lod0, lod1], OUT + r"\hebat_allah_visual.glb")
export([col], OUT + r"\hebat_allah_collision.glb")
bpy.ops.wm.save_as_mainfile(filepath=OUT + r"\hebat_allah_work.blend")
mark("export")
sc.render.engine = 'BLENDER_WORKBENCH'
sc.display.shading.light = 'STUDIO'
sc.display.shading.color_type = 'TEXTURE'
sc.render.resolution_x = 1600
sc.render.resolution_y = 900
sc.world = bpy.data.worlds.new("W")
sc.world.color = (0.08, 0.1, 0.12)
cam = bpy.data.cameras.new("C")
cam.lens = 35
cob = bpy.data.objects.new("Cam", cam)
sc.collection.objects.link(cob)
sc.camera = cob
def look(loc, target):
cob.location = loc
cob.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
def shot(name, vis):
for o in (lod0, lod1, col):
o.hide_render = o is not vis
sc.render.filepath = os.path.join(CHK, name + ".png")
bpy.ops.render.render(write_still=True)
look((-30, -45, 18), (0, 0, 5))
shot("lod0_bow_quarter", lod0)
shot("lod1_bow_quarter", lod1)
shot("collision_bow_quarter", col)
look((28, 40, 14), (0, 0, 5))
shot("lod0_stern_quarter", lod0)
look((-14, -6, 3.5), (0, -8, 4))
shot("lod0_from_12m", lod0)
look((0, 0, 70), (0, 0, 0))
shot("lod0_top", lod0)
log.update({"scale": SCALE, "lod0_tris": t0, "lod1_tris": t1, "collision_tris": tc,
"dims": [round(v, 2) for v in lod0.dimensions],
"sizes_MB": {f: round(os.path.getsize(os.path.join(OUT, f)) / 1e6, 1) for f in os.listdir(OUT)}})
print("RESULT" + json.dumps(log), flush=True)
@@ -0,0 +1,42 @@
"""Rebuild a neutral 2K albedo from the raw MV Hebat Allah scan texture.
The underwater scan has a dead red channel (mean 17/255) and a strong blue
cast, so a white balance only produces magenta noise. This script keeps the
luminance of the two live channels, stretches it, and tints it with a rust /
algae palette driven by the green-to-blue ratio. The game's fog adds the water
colour at runtime.
python scripts/assets/hebat_allah_prepare_albedo.py <albedo_8k.png> <out_dir>
Writes albedo_2k_balanced.png and albedo_1k_balanced.png into <out_dir>.
Requires Pillow and numpy. Licence: see docs/ASSET_LICENSES.md.
"""
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def balance(source: Path, out_dir: Path) -> None:
image = Image.open(source).convert("RGB").resize((2048, 2048), Image.LANCZOS)
rgb = np.asarray(image).astype(np.float32) / 255.0
luminance = 0.55 * rgb[..., 1] + 0.45 * rgb[..., 2]
low, high = np.percentile(luminance, 1), np.percentile(luminance, 99)
luminance = np.clip((luminance - low) / (high - low), 0.0, 1.0)
luminance = luminance ** 0.9 * 0.75 + 0.08
algae_ratio = np.clip((rgb[..., 1] - rgb[..., 2] * 0.68) * 3.0, 0.0, 1.0)
rust = np.array([0.62, 0.52, 0.44])
algae = np.array([0.50, 0.58, 0.46])
tint = rust[None, None, :] * (1.0 - algae_ratio[..., None]) + algae[None, None, :] * algae_ratio[..., None]
out = np.clip(luminance[..., None] * tint / 0.55, 0.0, 1.0)
result = Image.fromarray((out * 255).astype(np.uint8))
out_dir.mkdir(parents=True, exist_ok=True)
result.save(out_dir / "albedo_2k_balanced.png")
result.resize((1024, 1024), Image.LANCZOS).save(out_dir / "albedo_1k_balanced.png")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
balance(Path(sys.argv[1]), Path(sys.argv[2]))
+1 -1
View File
@@ -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"
+10
View File
@@ -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" \
@@ -0,0 +1,313 @@
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)
_test_wreck(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 _test_wreck(site: SunlitShallowsEnvironment) -> void:
var wreck := site.get_node_or_null("HebatAllahWreck") as WreckActor
_expect(wreck != null, "site places the Hebat Allah wreck")
if wreck == null:
return
_expect(
int(wreck.get_meta("sonar_material", SonarMaterial.Value.UNKNOWN))
== SonarMaterial.Value.METAL,
"wreck echoes as metal on the sonar"
)
_expect((wreck.collision_layer & 1) == 1, "wreck sits on the sonar collision layer")
_expect(
wreck.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M
and absf(wreck.position.y - SunlitShallowsEnvironment.FLOOR_DEPTH_M) < 3.0,
"wreck rests on the site floor"
)
var collision := wreck.get_node_or_null(WreckActor.COLLISION_SHAPE_NAME) as CollisionShape3D
_expect(
collision != null and collision.shape is ConcavePolygonShape3D,
"wreck has a trimesh collision separate from its visuals"
)
if collision != null and collision.shape is ConcavePolygonShape3D:
var faces := (collision.shape as ConcavePolygonShape3D).get_faces()
_expect(faces.size() >= 3 * 500, "wreck collision mesh is not empty")
_expect(faces.size() <= 3 * 6000, "wreck collision mesh stays light")
var lod0 := wreck.find_child(WreckActor.LOD0_NODE_NAME, true, false) as MeshInstance3D
var lod1 := wreck.find_child(WreckActor.LOD1_NODE_NAME, true, false) as MeshInstance3D
_expect(lod0 != null and lod1 != null, "wreck carries two visual LODs")
if lod0 == null or lod1 == null:
return
_expect(
lod0.visibility_range_end > 0.0
and is_equal_approx(lod1.visibility_range_begin, lod0.visibility_range_end),
"wreck LODs hand over at one distance"
)
var lod0_triangles := _triangle_count(lod0.mesh)
var lod1_triangles := _triangle_count(lod1.mesh)
_expect(
lod0_triangles > 0 and lod0_triangles <= 120000,
"wreck LOD0 stays within the triangle budget"
)
_expect(lod1_triangles > 0 and lod1_triangles < lod0_triangles / 2, "wreck LOD1 is far lighter")
_expect(
lod0.mesh.surface_get_material(0) != null
or lod0.get_surface_override_material(0) != null,
"wreck LOD0 carries a material"
)
func _triangle_count(mesh: Mesh) -> int:
if mesh == null:
return 0
var triangles := 0
for surface in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(surface)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.is_empty():
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
triangles += vertices.size() / 3
else:
triangles += indices.size() / 3
return triangles
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)
@@ -29,8 +29,14 @@ const ControlsSettingsScreen = preload(
const ProductionInputAdapter = preload(
"res://game/infrastructure/input/production_input_adapter.gd"
)
const SunlitShallowsEnvironment = preload(
"res://game/world/sites/sunlit_shallows_environment.gd"
)
const WreckActor = preload("res://game/world/wrecks/wreck_actor.gd")
const SonarService = preload("res://game/application/sonar/sonar_service.gd")
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const TEST_COUNT := 91
const TEST_COUNT := 99
var _failures: Array[String] = []
@@ -120,7 +126,32 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var hud := composition.active_screen() as CockpitHud
var sonar_service = composition.sonar_service()
var sonar_display = composition.sonar_display()
_expect(composition.active_world() != null, "vehicle test world is assembled")
var active_world := composition.active_world()
var site: SunlitShallowsEnvironment = null
var wreck: WreckActor = null
var floor_body: StaticBody3D = null
if active_world != null:
site = active_world.get_node_or_null("Environment") as SunlitShallowsEnvironment
if site != null:
wreck = site.get_node_or_null("HebatAllahWreck") as WreckActor
floor_body = site.get_node_or_null("FlatFloor") as StaticBody3D
_expect(active_world != null and active_world.name == "SunlitShallowsWorld", "new game loads sunlit shallows world")
_expect(site != null, "sunlit environment is active")
_expect(
floor_body != null and floor_body.get_node_or_null("Collision") is CollisionShape3D,
"active dive has a physical seabed"
)
_expect(
wreck != null and wreck.get_node_or_null("Collision") is CollisionShape3D,
"active dive contains the collidable Hebat Allah wreck"
)
_expect(
actor != null
and wreck != null
and (wreck.global_position - actor.global_position).dot(-actor.global_basis.z) > 0.0
and actor.global_position.distance_to(wreck.global_position) < SonarService.MAXIMUM_RANGE_M,
"wreck starts ahead of the submarine within sonar range"
)
_expect(actor != null, "production submarine actor is assembled")
_expect(camera != null, "cockpit camera is attached to the submarine")
_expect(instruments != null, "physical cockpit instruments are assembled")
@@ -186,6 +217,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")
@@ -219,7 +253,11 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
var first_scan = sonar_service.current_scan()
_expect(first_scan != null, "manual sonar command publishes a typed scan")
_expect(first_scan.samples.observed_count() > 1000, "reference acquisition captures dense bathymetry")
_expect(first_scan.contacts.size() >= 1, "metal calibration target becomes a distinct contact")
var has_wreck_contact := false
for contact in first_scan.contacts:
if contact.material == SonarMaterial.Value.METAL:
has_wreck_contact = true
_expect(has_wreck_contact, "manual sonar sees the metal wreck in the active world")
_expect("RELEVÉ EN COURS" in sonar_display.status_text(), "sonar display reports progressive reveal")
_expect(not sonar_display.idle_prompt_visible(), "sonar data replaces the idle instruction")
for frame in 18:
@@ -240,12 +278,20 @@ func _test_vehicle_slice(composition: VerticalSliceRoot) -> void:
_expect(not sonar_display.is_expanded(), "expanded sonar returns to cockpit view")
var start_position: Vector3 = actor.global_position
var start_wreck_distance: float = (
actor.global_position.distance_to(wreck.global_position) if wreck != null else INF
)
var starting_battery: float = composition.latest_update().snapshot.battery.remaining_kwh
Input.action_press(ProductionInputAdapter.ACTION_FORWARD)
for frame in 35:
await physics_frame
Input.action_release(ProductionInputAdapter.ACTION_FORWARD)
_expect(actor.global_position.distance_to(start_position) > 0.1, "keyboard propulsion moves the submarine")
_expect(
wreck != null
and actor.global_position.distance_to(wreck.global_position) < start_wreck_distance - 0.1,
"forward propulsion closes the distance to the wreck"
)
_expect(actor.velocity.length() > 0.1, "propulsion builds inertial velocity")
_expect(
composition.latest_update().snapshot.battery.remaining_kwh < starting_battery,