Compare commits

...

9 Commits

Author SHA1 Message Date
Josh Creek b285d012dc chore(*): Update todos 2026-08-04 14:14:15 +01:00
Josh Creek c7cd740f23 chore(assets): VRAM-compress arena textures, BC7 for the nebula sky
Move the nebula sky, planet surface and particle glow textures to VRAM
compression with mipmaps, so they stop being uploaded uncompressed.

The sky panorama is forced to high quality (BC7). It is a 5 MB image of
smooth nebula gradients seen through the arena walls, and BC1/BC3's 5:6:5
endpoint interpolation bands visibly across exactly that kind of content.
2026-08-04 13:36:43 +01:00
Josh Creek a836a96d13 feat(weather): keep nebula dust clear of the play volume
The dust emitter's box fully encloses the arena, so motes drifted through the
pitch and hazed the surfaces the player is trying to read.

Fade each mote by the Chebyshev distance of its world-space origin outside
the play volume, taken from MODEL_MATRIX's translation column so it is
unaffected by the billboard rewrite of VERTEX. Motes inside the box are fully
hidden and fade in over arena_clear_distance beyond it, so the effect reads as
weather drifting around the station rather than through the match.

Extents default to ArenaBoundary's play volume and are exposed as uniforms.

Also adds the missing .uid sidecar for the shader.
2026-08-04 13:36:43 +01:00
Josh Creek e62d844c1d fix(render): drop SDFGI, add fill lights and enable debanding
SDFGI ran with default cascades in a volume that was almost entirely
transparent, so its low-resolution probes had little solid geometry to
capture and contributed blotching across large flat surfaces. With the
boundary now an opaque deck plus an unshaded additive field, it earns
nothing, and the existing ReflectionProbe and ambient carry the lighting.

Each arena also gains a shadowless fill light opposite its key light so the
deck is not lit from a single direction, and passes its own tint through to
the boundary's containment field so the three arenas read differently.

use_debanding was absent. Glow runs at hdr_scale 2.0 with adjustment_contrast
above 1.0, which amplifies 8-bit quantisation on exactly the kind of smooth
gradients the new shell is made of.

The remaining project.godot churn is Godot's own key reordering on save.
2026-08-04 13:36:30 +01:00
Josh Creek df3e168b31 feat(arena): add elevated-goal arena variants
Each of the three arenas gains an ELEVATED sibling scene that inherits the
base arena and overrides the boundary's goal_mode, the two goal transforms
and the ship spawns, so the goal sits at mid-wall height instead of flush
with the deck. Registered in ArenaRegistry alongside the floor-level arenas,
plus a training_elevated scene for self-play on them.

TrainingMode reads the arena's goal_mode once in _start() and widens the
ball-placement height range to match the goal's real position; on FLOOR
arenas the bound is a no-op, so floor-level training is unchanged. It is read
in _start() rather than _ready() because TrainingMode has no _ready()
override and GameMode._ready() is what discovers the arena first.

Policies trained against floor-level goals are not expected to score on an
elevated one, so the two are kept as separate arenas rather than a variant of
the same entry.
2026-08-04 13:36:19 +01:00
Josh Creek 66b6215fdd feat(goal): recess goals into the hull behind a netted pocket
The goal was a single flat translucent slab. Because the chase camera sits
behind the goal line at kickoff it rendered as a large blue rectangle across
the lower screen, and the hull beside the mouth was translucent field panel,
so the pocket behind it showed straight through the wall and the net appeared
a second time alongside the frame.

The boundary now carries an opaque bulkhead around each mouth, which occludes
properly. On top of that the goal becomes a pocket sunk into the wall: a dark
machined bezel lining the opening, a thin team-coloured emissive rim flush
with the wall face, and netting from a single box viewed inside-out, giving a
five-sided pocket instead of a flat panel across the back.

The net is cut with discard rather than alpha blending so it still writes
depth and sorts against the frame and the containment field like solid
geometry; a blended net would join the transparent queue and sort per object
against the boundary shell, which is the artefact this whole pass removed.

goal.tscn drops from 18 node/sub-resource blocks to 3 — it is now just the
Area3D, its CollisionShape3D and the script. All geometry is built in code
from two loops, and the mouth is measured off the collision shape rather than
restated, so the frame cannot drift from the volume that actually scores.

The sensor, its collision shape and the goal_scored signal are unchanged, and
visuals are skipped under --headless.
2026-08-04 13:36:07 +01:00
Josh Creek 02df09e40d feat(arena): draw the play volume as one non-overlapping surface shell
Every visible surface of the enclosure shared one alpha-blended material and
was drawn as several overlapping layers: floor, ceiling and four walls as
solid BoxMeshes, plus corner curves and fillets generated on top of the box
faces they eased into. Alpha-blended surfaces don't write depth and sort per
object, so the perimeter composited that tint twice and the corners three
times, giving hard-edged trapezoidal patches that re-sorted as the camera
moved. The floor box also overhung the walls by 1 m and showed through them.

Replace all of it with a single generated mesh covering the inner surface
exactly once, every piece cut to meet its neighbours edge-on and only
inward-facing triangles emitted. It carries two surfaces: an opaque hull
(deck, base fillets, goal bulkheads) and the translucent containment field
(walls, corners, ceiling fillets, ceiling).

The field shader is additive and unshaded rather than alpha-blended: additive
cannot double-darken and composites order-independently, so overlap is
structurally invisible, and being unshaded it no longer picks up per-arena
light and GI gradients across a 28x40 m panel. Fresnel replaces the old
StandardMaterial3D rim, which was a lit effect and the wrong tool. The deck
shader draws plating and field markings procedurally from the boundary's own
constants, so markings cannot drift from the collision geometry.

Per-face MeshInstance3D visibility toggling is gone; the field shader fades
facets the camera has crossed outside of per-pixel from one uniform, which is
what allows a single merged mesh.

Also adds ELEVATED goal mode and the ceiling fillets, and skips the mesh build
entirely under --headless, where training spawns instances that never render.

Collision is untouched: 166 collider shapes in FLOOR mode and 158 in ELEVATED,
verified byte-identical to before, so trained policies in Game/bots/ are
unaffected.
2026-08-04 13:35:55 +01:00
Josh Creek 6f5ce488a9 feat: lit particle shader for nebula dust weather effect
Replace NebulaDust's flat unshaded glow material with a custom
ShaderMaterial: a fake per-pixel puff normal on the billboarded quad
feeds a light() override so motes catch the directional light and a
nebula-core color bias, alpha gets a depth-texture soft-particle fade
so motes no longer hard-clip through boundary/decoration geometry,
and a per-particle hash adds subtle sparkle.
2026-08-04 07:42:34 +01:00
Josh Creek 73fb83a0da feat: richer nebula sky and planet surface textures
Extend gen_nebula_sky.py with two more dust-lane layers at different
scales and subtle hue variation within the bright core. Add
gen_planet_surface.py (no prior generator existed) producing latitude
bands, storm vortices, and a lit/unlit terminator baked from the
planet mesh's actual UV convention against arena_02's directional
light, verified in-engine via screenshots.
2026-08-04 07:18:51 +01:00
31 changed files with 1357 additions and 353 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 985 KiB

@@ -3,9 +3,10 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cr3feyv7yldjc"
path="res://.godot/imported/nebula_planet_planet_surface.png-4b68cca75d2d22d25510c7eb277c0b85.ctex"
path.s3tc="res://.godot/imported/nebula_planet_planet_surface.png-4b68cca75d2d22d25510c7eb277c0b85.s3tc.ctex"
metadata={
"vram_texture": false
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "44a2e26010b529339e264deecde957e0"
@@ -14,11 +15,11 @@ generator_parameters={
[deps]
source_file="res://assets/models/nebula_planet_planet_surface.png"
dest_files=["res://.godot/imported/nebula_planet_planet_surface.png-4b68cca75d2d22d25510c7eb277c0b85.ctex"]
dest_files=["res://.godot/imported/nebula_planet_planet_surface.png-4b68cca75d2d22d25510c7eb277c0b85.s3tc.ctex"]
[params]
compress/mode=0
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
@@ -40,4 +41,4 @@ process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
detect_3d/compress_to=0
@@ -3,19 +3,20 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://damajpvo00prm"
path="res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.ctex"
path.s3tc="res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.s3tc.ctex"
metadata={
"vram_texture": false
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/textures/particle_glow.png"
dest_files=["res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.ctex"]
dest_files=["res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.s3tc.ctex"]
[params]
compress/mode=0
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
@@ -23,7 +24,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
@@ -37,4 +38,4 @@ process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
detect_3d/compress_to=0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

+8 -7
View File
@@ -3,27 +3,28 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dc445qysyxwwv"
path="res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.ctex"
path.bptc="res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.bptc.ctex"
metadata={
"vram_texture": false
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/textures/sky_nebula.png"
dest_files=["res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.ctex"]
dest_files=["res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.bptc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/mode=2
compress/high_quality=true
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
@@ -37,4 +38,4 @@ process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
detect_3d/compress_to=0
+1 -59
View File
@@ -1,36 +1,7 @@
[gd_scene load_steps=11 format=3]
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/arena_boundary.gd" id="1_bndry"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.08, 0.09, 0.12, 1)
metallic = 0.3
roughness = 0.6
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_field"]
transparency = 1
albedo_color = Color(0.6, 0.75, 1, 0.035)
roughness = 0.05
rim_enabled = true
rim = 1.0
rim_tint = 0.6
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
material = SubResource("StandardMaterial3D_floor")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_ceiling"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_side_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(1, 13, 38)
[sub_resource type="BoxMesh" id="BoxMesh_end_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(26, 13, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
size = Vector3(28, 1, 40)
@@ -47,51 +18,22 @@ script = ExtResource("1_bndry")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="FloorMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
mesh = SubResource("BoxMesh_floor")
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallPosXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallNegXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallPosZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallNegZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="CeilingShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="CeilingMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_ceiling")
+1 -12
View File
@@ -1,24 +1,13 @@
[gd_scene load_steps=5 format=3 uid="uid://cofdcxo5170rs"]
[gd_scene load_steps=3 format=3 uid="uid://cofdcxo5170rs"]
[ext_resource type="Script" path="res://scripts/goal.gd" id="1_v8ikr"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ptddx"]
transparency = 1
albedo_color = Color(0, 0.368627, 1, 0.537255)
[sub_resource type="BoxMesh" id="BoxMesh_4ngif"]
material = SubResource("StandardMaterial3D_ptddx")
size = Vector3(3.5, 1.5, 0.1)
[sub_resource type="BoxShape3D" id="BoxShape3D_nmd5r"]
size = Vector3(3.5, 1.5, 0.1)
[node name="Goal" type="Area3D"]
script = ExtResource("1_v8ikr")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_4ngif")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_nmd5r")
+15 -11
View File
@@ -18,13 +18,14 @@ config/name="Cosmic Clash"
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
config/version="0.0.1"
run/main_scene="uid://bcq14356s3e2i"
run/main_scene.training="res://scenes/training.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
run/main_scene.training="res://scenes/training.tscn"
[editor_plugins]
[autoload]
enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
GameSettings="*res://scripts/game_settings.gd"
VideoSettings="*res://scripts/video_settings.gd"
[display]
@@ -34,10 +35,17 @@ window/size/mode=2
window/stretch/mode="viewport"
window/stretch/aspect="expand"
[editor_plugins]
enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
[input]
reset_ball={"deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]}
reset_ball={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_forward={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
@@ -113,15 +121,11 @@ roll_right={
[physics]
common/physics_interpolation=true
3d/physics_engine="Jolt Physics"
common/physics_interpolation=true
[rendering]
anti_aliasing/quality/msaa_3d=2
anti_aliasing/quality/screen_space_aa=1
[autoload]
GameSettings="*res://scripts/game_settings.gd"
VideoSettings="*res://scripts/video_settings.gd"
anti_aliasing/quality/use_debanding=true
+8 -1
View File
@@ -33,7 +33,6 @@ sky_material = SubResource("ShaderMaterial_stars")
[sub_resource type="Environment" id="Environment_space"]
background_mode = 2
sky = SubResource("Sky_space")
sdfgi_enabled = true
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
ambient_light_energy = 0.4
@@ -58,11 +57,19 @@ adjustment_saturation = 1.08
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
field_tint = Color(0.5, 0.7, 1, 1)
field_intensity = 0.09
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.55, 0.65, 0.85, 1)
light_energy = 0.4
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_space")
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="1_base"]
[node name="Arena" instance=ExtResource("1_base")]
[node name="Boundary" parent="." index="0"]
goal_mode = 1
[node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
[node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
+18 -13
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=15 format=3]
[gd_scene load_steps=16 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
@@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://assets/models/nebula_debris.glb" id="9_debris"]
[ext_resource type="Texture2D" path="res://assets/textures/particle_glow.png" id="10_dust"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_planet.glb" id="11_planet"]
[ext_resource type="Shader" path="res://shaders/nebula_dust.gdshader" id="12_dust_shader"]
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_nebula"]
panorama = ExtResource("7_nebula")
@@ -18,7 +19,6 @@ sky_material = SubResource("PanoramaSkyMaterial_nebula")
[sub_resource type="Environment" id="Environment_nebula"]
background_mode = 2
sky = SubResource("Sky_nebula")
sdfgi_enabled = true
ambient_light_source = 2
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
ambient_light_energy = 0.45
@@ -39,19 +39,16 @@ adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dust"]
transparency = 1
shading_mode = 0
billboard_mode = 1
vertex_color_use_as_albedo = true
albedo_color = Color(1, 0.75, 0.9, 1)
albedo_texture = ExtResource("10_dust")
emission_enabled = true
emission = Color(1, 0.6, 0.85, 1)
emission_energy_multiplier = 2.5
[sub_resource type="ShaderMaterial" id="ShaderMaterial_dust"]
shader = ExtResource("12_dust_shader")
shader_parameter/albedo_tex = ExtResource("10_dust")
shader_parameter/core_bias_color = Color(1, 0.6, 0.85, 1)
shader_parameter/core_bias_amount = 0.35
shader_parameter/emission_strength = 2.5
shader_parameter/soft_fade_distance = 1.0
[sub_resource type="QuadMesh" id="QuadMesh_dust"]
material = SubResource("StandardMaterial3D_dust")
material = SubResource("ShaderMaterial_dust")
size = Vector2(0.4, 0.4)
[sub_resource type="Gradient" id="Gradient_dust"]
@@ -82,12 +79,20 @@ color_ramp = SubResource("GradientTexture1D_dust")
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
field_tint = Color(0.78, 0.55, 1, 1)
field_intensity = 0.1
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.85, 0.75, 1, 1)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.55, 0.35, 0.65, 1)
light_energy = 0.45
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_nebula")
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="PackedScene" path="res://scenes/arena_02.tscn" id="1_base"]
[node name="Arena" instance=ExtResource("1_base")]
[node name="Boundary" parent="." index="0"]
goal_mode = 1
[node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
[node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
+8 -1
View File
@@ -14,7 +14,6 @@ sky_material = SubResource("ShaderMaterial_asteroid")
[sub_resource type="Environment" id="Environment_asteroid"]
background_mode = 2
sky = SubResource("Sky_asteroid")
sdfgi_enabled = true
ambient_light_source = 2
ambient_light_color = Color(0.6, 0.45, 0.3, 1)
ambient_light_energy = 0.3
@@ -39,12 +38,20 @@ adjustment_saturation = 1.08
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
field_tint = Color(1, 0.72, 0.4, 1)
field_intensity = 0.09
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(1, 0.85, 0.65, 1)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.55, 0.4, 0.3, 1)
light_energy = 0.4
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_asteroid")
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="PackedScene" path="res://scenes/arena_03.tscn" id="1_base"]
[node name="Arena" instance=ExtResource("1_base")]
[node name="Boundary" parent="." index="0"]
goal_mode = 1
[node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18)
[node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18)
[node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5)
[node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5)
+15
View File
@@ -0,0 +1,15 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/training_mode.gd" id="1_tr"]
[ext_resource type="PackedScene" path="res://scenes/arena_01_elevated.tscn" id="2_tr"]
[ext_resource type="Script" path="res://addons/godot_rl_agents/sync.gd" id="3_tr"]
[node name="Training" type="Node3D"]
script = ExtResource("1_tr")
[node name="Arena" parent="." instance=ExtResource("2_tr")]
[node name="Sync" type="Node" parent="."]
script = ExtResource("3_tr")
action_repeat = 8
speed_up = 8.0
+450 -198
View File
@@ -13,18 +13,31 @@ const INNER_HEIGHT := 12.0
# goal and the wall for a ball to ramp across before reaching the sensor.
const GOAL_LINE_Z := INNER_HALF_Z
# FLOOR (default) keeps the goal flush with the floor, as above. ELEVATED
# moves the goal to GOAL_CENTER_Y — see the arena_0X_elevated.tscn scenes,
# which bake that Y directly onto their Goal nodes and override this export.
# There's no ramp: ships fly freely (see ship.gd), so nothing physically
# needs to lead up to an elevated goal.
enum GoalMode { FLOOR, ELEVATED }
@export var goal_mode: GoalMode = GoalMode.FLOOR
const GOAL_CENTER_Y := INNER_HEIGHT / 2.0 # ELEVATED mode goal centre
# Curved transitions, so the ball rolls back into play instead of wedging
# into a 90° pocket and ships can carry speed up the walls: quarter-cylinder
# corner curves spanning the four vertical wall-wall edges, and base fillets
# easing the floor into every wall. Everything is generated in _ready from
# these constants, but collision and visuals deliberately differ:
# corner curves spanning the four vertical wall-wall edges, base fillets
# easing the floor into every wall, and ceiling fillets easing every wall
# into the ceiling. Everything is generated in _ready from these constants,
# but collision and visuals deliberately differ:
# - collision is rings of thick flat boxes tangent to the true arc — a
# concave curve can't be one convex collider, primitives give Jolt clean
# stable contact normals (the wall-contact reward reads them), and 1 m of
# thickness is tunnel-proof at ball speeds;
# - visuals are single smooth ArrayMesh surfaces (one per corner plus one
# for all fillets) — proper curved normals, no seams or double-tinted
# overlaps, one draw call each.
# - visuals are one merged shell (see _build_visual_shell) — proper curved
# normals, and every surface drawn exactly once.
# The base and ceiling fillets are the same skirting mirrored vertically, so
# every function that builds one takes a `rise` of +1 (base, climbing away
# from the floor) or -1 (ceiling, dropping away from it) and serves both.
# The corner curves reach at most the chord plane
# |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS.
const CORNER_RADIUS := 4.0
@@ -32,6 +45,17 @@ const BASE_RADIUS := 2.0
# The end-wall fillets stop short of the goal mouth so floor-level shots
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
const GOAL_MOUTH_HALF_WIDTH := 2.5
# The aperture the visual shell leaves for the goal itself, and the opaque
# bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a
# little clearance, so no sliver of panel shows through the goal frame. The
# surround has to be opaque hull: backed by the translucent field panel
# instead, the goal's recess and net showed straight through the wall beside
# the mouth and read as a second, duplicated net.
const GOAL_APERTURE_HALF_WIDTH := 1.85
const GOAL_APERTURE_HEIGHT := 1.65
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
# tangent, so its height is BASE_RADIUS and needs no constant.
const GOAL_SURROUND_HALF_HEIGHT := 1.6
# Flat collider segments per quarter arc. Max sag from the true curve is
# R * (1 - cos(45° / N)): under 4 cm for both radii, invisible to the ball.
const CORNER_SEGMENTS := 6
@@ -42,27 +66,52 @@ const WRAP_SEGMENTS := 3
# ball can sit at most ~4 cm proud of the drawn surface, which never reads).
const CORNER_VISUAL_ARCS := 16
const FILLET_VISUAL_ARCS := 8
# Path steps for the torus patches carrying a fillet around a corner curve.
const WRAP_VISUAL_ARCS := 8
# Match the boxes in arena_boundary.tscn: 1 m thick surfaces, walls spanning
# y -1..12 (flush with the floor slab's bottom and the ceiling slab's top).
const SURFACE_THICKNESS := 1.0
const WALL_HEIGHT := INNER_HEIGHT + 1.0
const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5
@onready var _wall_pos_x: MeshInstance3D = $WallPosXMesh
@onready var _wall_neg_x: MeshInstance3D = $WallNegXMesh
@onready var _wall_pos_z: MeshInstance3D = $WallPosZMesh
@onready var _wall_neg_z: MeshInstance3D = $WallNegZMesh
@onready var _ceiling: MeshInstance3D = $CeilingMesh
# Where the flat deck/ceiling stops and the fillets take over, and where the
# fillets hand over to the wall panels. Every piece of the shell is cut to
# these so no two surfaces overlap.
const FLAT_HALF_X := INNER_HALF_X - BASE_RADIUS
const FLAT_HALF_Z := INNER_HALF_Z - BASE_RADIUS
const CORNER_CENTRE_X := INNER_HALF_X - CORNER_RADIUS
const CORNER_CENTRE_Z := INNER_HALF_Z - CORNER_RADIUS
const WRAP_RADIUS := CORNER_RADIUS - BASE_RADIUS
# Corner curve visuals, following the same hide-when-the-camera-is-outside
# rule as the walls: {mesh, point (on the 45° tangent plane), outward}.
var _corner_visuals: Array[Dictionary] = []
const BASE_FILLET := 1.0
const CEILING_FILLET := -1.0
const FIELD_SHADER_PATH := "res://shaders/energy_field.gdshader"
const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader"
@export var field_tint := Color(0.45, 0.65, 1.0)
@export var field_intensity := 0.09
@export var team0_tint := Color(0.15, 0.45, 1.0)
@export var team1_tint := Color(1.0, 0.35, 0.25)
# The single merged surface shell and the material whose camera-side fade
# _process() drives. Both stay null in headless runs, which never render.
var _shell: MeshInstance3D
var _field_material: ShaderMaterial
func _ready() -> void:
add_to_group("arena_boundary")
_build_corner_curves()
_build_base_fillets()
_build_corner_colliders()
_build_fillet_colliders(BASE_FILLET)
_build_fillet_colliders(CEILING_FILLET)
# Visuals are pure decoration and training spawns many headless instances
# that never render one, so skip the mesh build there entirely. Collision
# is unaffected either way.
if DisplayServer.get_name() == "headless":
set_process(false)
return
_build_visual_shell()
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
@@ -82,8 +131,8 @@ func get_surface_pull(
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
pull += Vector3(-1, 0, 0) * _falloff(INNER_HALF_X + p.x, wall_range) * wall_strength
# End walls are gated off inside the goal mouth — there is no physical
# wall there (see GOAL_MOUTH_HALF_WIDTH / _build_base_fillets), so a shot
# heading straight for the net doesn't feel a phantom sideways tug.
# wall there (see GOAL_MOUTH_HALF_WIDTH / _fillet_runs), so a shot heading
# straight for the net doesn't feel a phantom sideways tug.
if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH:
pull += Vector3(0, 0, 1) * _falloff(INNER_HALF_Z - p.z, wall_range) * wall_strength
pull += Vector3(0, 0, -1) * _falloff(INNER_HALF_Z + p.z, wall_range) * wall_strength
@@ -97,40 +146,103 @@ func _falloff(dist: float, field_range: float) -> float:
func _process(_delta: float) -> void:
# The translucent field material tints everything behind it, so any face
# the camera has crossed to the outside of is hidden entirely — looking
# into the arena from outside stays clear, while faces seen from inside
# keep their tint. Collision is untouched; only the meshes toggle.
# The field shader fades out any facet the camera has crossed to the
# outside of, so looking into the arena from outside stays clear. It does
# that per-pixel from this one uniform, which is what lets the whole
# enclosure be a single mesh instead of per-face MeshInstance3Ds toggled
# individually. Collision is untouched.
if _field_material == null:
return
var camera := get_viewport().get_camera_3d()
if camera == null:
return # headless (RL/CI) has no camera
var p := to_local(camera.global_position)
_wall_pos_x.visible = p.x < INNER_HALF_X
_wall_neg_x.visible = p.x > -INNER_HALF_X
_wall_pos_z.visible = p.z < INNER_HALF_Z
_wall_neg_z.visible = p.z > -INNER_HALF_Z
_ceiling.visible = p.y < INNER_HEIGHT
for visual in _corner_visuals:
var mesh: MeshInstance3D = visual["mesh"]
var point: Vector3 = visual["point"]
var outward: Vector3 = visual["outward"]
mesh.visible = (p - point).dot(outward) < 0.0
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
# Vertical quarter-cylinder curves across the four wall-wall corners, faced
# with the walls' translucent field material.
func _build_corner_curves() -> void:
var field_material: Material = (_wall_pos_x.mesh as BoxMesh).material
# --- shared layout -----------------------------------------------------------
# Consumed by both the collider rings and the visual shell, so the two can
# never end up describing different geometry.
func _corner_centre(sx: float, sz: float) -> Vector3:
return Vector3(sx * CORNER_CENTRE_X, 0.0, sz * CORNER_CENTRE_Z)
# The surface a fillet eases out of: the floor for the base run, the ceiling
# for the mirrored one.
func _fillet_base_y(rise: float) -> float:
return 0.0 if rise > 0.0 else INNER_HEIGHT
# Profile direction at `angle`, sweeping from facing the surface the fillet
# eases out of (0) round to facing the wall (90°), pointing from the arc axis
# into the fillet material.
func _fillet_dir(wall_out: Vector3, angle: float, rise: float) -> Vector3:
return wall_out * sin(angle) - Vector3.UP * (rise * cos(angle))
# Each run: horizontal unit vector toward its wall, unit direction along the
# wall, centre of its arc axis, run length, and which ends (in +run_dir /
# -run_dir order) are exposed and need a visual cap rather than meeting a
# corner wrap.
#
# In FLOOR mode the base run along each end wall stops short of the goal mouth
# (flat cutoff, capped) so floor-level shots roll flat into the goal sensor
# instead of ramping over it — the goal sits flush with the wall there (see
# GOAL_LINE_Z). Nothing else splits: an elevated goal isn't at floor level, and
# the ceiling has nothing goal-related to keep clear, so both run full width.
func _fillet_runs(rise: float) -> Array[Dictionary]:
var centre_y := _fillet_base_y(rise) + rise * BASE_RADIUS
var split := rise > 0.0 and goal_mode == GoalMode.FLOOR
var side_run := 2.0 * CORNER_CENTRE_Z
var end_run_full := 2.0 * CORNER_CENTRE_X
var end_run_split := CORNER_CENTRE_X - GOAL_MOUTH_HALF_WIDTH
var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run_split / 2.0
var runs: Array[Dictionary] = []
for side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(side, 0, 0),
"run_dir": Vector3(0, 0, 1),
"centre": Vector3(side * FLAT_HALF_X, centre_y, 0),
"length": side_run,
"caps": [false, false],
})
if not split:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(0, centre_y, side * FLAT_HALF_Z),
"length": end_run_full,
"caps": [false, false],
})
continue
for goal_side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(goal_side * end_centre_x, centre_y, side * FLAT_HALF_Z),
"length": end_run_split,
# The end at the corner wrap is unexposed; the end at the goal
# mouth needs a cap. run_dir is always +X, so the mouth-facing
# end is -run_dir when goal_side is +1.
"caps": [goal_side > 0.0, goal_side < 0.0],
})
return runs
# --- collision ---------------------------------------------------------------
# These build the tangent-box collider rings only. Their geometry is what
# trained policies in Game/bots/ were fitted against, so it must not change.
# Vertical quarter-cylinder curves across the four wall-wall corners.
func _build_corner_colliders() -> void:
var arc_step := (PI / 2.0) / CORNER_SEGMENTS
# Wide enough that adjacent tangent segments overlap instead of gapping.
var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var arc_centre := Vector3(
sx * (INNER_HALF_X - CORNER_RADIUS),
0.0,
sz * (INNER_HALF_Z - CORNER_RADIUS)
)
var arc_centre := _corner_centre(sx, sz)
for i in CORNER_SEGMENTS:
# Angle sweeps the quarter arc from facing the ±x wall (0)
# to facing the ±z wall (90°); segments are tangent at
@@ -141,115 +253,37 @@ func _build_corner_curves() -> void:
arc_centre + outward * CORNER_RADIUS + Vector3.UP * WALL_CENTRE_Y,
outward, Vector3.UP, face_width, WALL_HEIGHT
)
_add_corner_visual(sx, sz, arc_centre, field_material)
# One smooth quarter-cylinder surface (floor to ceiling) plus a top cap so
# the curve doesn't read as a hollow tube from above the (hidden) ceiling.
func _add_corner_visual(sx: float, sz: float, arc_centre: Vector3, material: Material) -> void:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS
var top := Vector3.UP * INNER_HEIGHT
var cap_corner := Vector3(sx * INNER_HALF_X, INNER_HEIGHT, sz * INNER_HALF_Z)
for i in CORNER_VISUAL_ARCS:
var dir_a := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step))
var dir_b := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step))
var base_a := arc_centre + dir_a * CORNER_RADIUS
var base_b := arc_centre + dir_b * CORNER_RADIUS
_add_quad(st, base_a, -dir_a, base_b, -dir_b, base_b + top, -dir_b, base_a + top, -dir_a)
_add_cap_tri(st, cap_corner, base_a + top, base_b + top, Vector3.UP)
st.set_material(material)
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = st.commit()
mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mesh_instance)
# Hide when the camera crosses the 45° tangent plane — the deepest point
# of the curve, so it never vanishes while the camera is still in play.
var outward_45 := Vector3(sx, 0.0, sz).normalized()
_corner_visuals.append({
"mesh": mesh_instance,
"point": arc_centre + outward_45 * CORNER_RADIUS,
"outward": outward_45,
})
# Quarter-cylinder fillets easing the floor into each wall, faced with the
# floor material (they read as curved skirting, and like the floor they are
# never hidden — they sit too low to block the view). Torus sections wrap
# the fillet around each corner curve, joining the side- and end-wall runs
# with no exposed end face; the goal-mouth ends stay a simple flat cutoff
# (capped) since the goal now sits flush with the wall there (see
# GOAL_LINE_Z) — there is no floating approach for a ship to hug at speed.
func _build_base_fillets() -> void:
var floor_material: Material = ($FloorMesh.mesh as BoxMesh).material
# Quarter-cylinder fillets easing every wall into the floor (rise +1) or the
# ceiling (rise -1), with torus sections wrapping each corner curve so the
# side- and end-wall runs join with no exposed end face.
func _build_fillet_colliders(rise: float) -> void:
var arc_step := (PI / 2.0) / BASE_SEGMENTS
var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3
# Side-wall fillets run the full span between the corner wraps; end-wall
# fillets run from the corner wraps to the goal mouth.
var side_run := 2.0 * (INNER_HALF_Z - CORNER_RADIUS)
var end_run := (INNER_HALF_X - CORNER_RADIUS) - GOAL_MOUTH_HALF_WIDTH
var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run / 2.0
# Each run: horizontal unit vector toward its wall, unit direction along
# the wall, centre of its arc axis, run length, and which ends (in
# +run_dir / -run_dir order) are exposed and need a cap rather than
# meeting a corner wrap.
var runs: Array[Dictionary] = []
for side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(side, 0, 0),
"run_dir": Vector3(0, 0, 1),
"centre": Vector3(side * (INNER_HALF_X - BASE_RADIUS), BASE_RADIUS, 0),
"length": side_run,
"caps": [false, false],
})
for goal_side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(goal_side * end_centre_x, BASE_RADIUS, side * (INNER_HALF_Z - BASE_RADIUS)),
"length": end_run,
# The end at the corner wrap is unexposed; the end at the
# goal mouth needs a cap. run_dir is always +X, so the
# mouth-facing end is -run_dir when goal_side is +1.
"caps": [goal_side > 0.0, goal_side < 0.0],
})
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for run in runs:
for run in _fillet_runs(rise):
var wall_out: Vector3 = run["wall_out"]
var run_dir: Vector3 = run["run_dir"]
var centre: Vector3 = run["centre"]
var length: float = run["length"]
var caps: Array = run["caps"]
# Profile angle sweeps the quarter arc from facing the floor (0) to
# facing the wall (90°); the direction points from the arc axis into
# the fillet material.
for i in BASE_SEGMENTS:
var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step)
var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step, rise)
_add_curve_collider(centre + outward * BASE_RADIUS, outward, run_dir, face_width, length)
_add_fillet_visual(st, wall_out, run_dir, centre, length, caps[0], caps[1])
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_fillet_corner_wrap(st, sx, sz)
st.set_material(floor_material)
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = st.commit()
mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mesh_instance)
_add_wrap_colliders(sx, sz, rise)
# Torus section carrying the fillet around a corner curve's base: the fillet
# profile swept along the corner arc, meeting the straight runs flush at both
# ends. Sweep position: with u(phi) the horizontal radial direction from the
# corner arc's centre, the surface is
# P(phi, theta) = centre + u * (CORNER_RADIUS - BASE_RADIUS
# + BASE_RADIUS * sin(theta)) + UP * BASE_RADIUS * (1 - cos(theta))
# whose outward (into-material) normal is u * sin(theta) - UP * cos(theta).
func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
var origin := Vector3(sx * (INNER_HALF_X - CORNER_RADIUS), 0.0, sz * (INNER_HALF_Z - CORNER_RADIUS))
var axis_radius := CORNER_RADIUS - BASE_RADIUS
# Torus section carrying a fillet around a corner curve. With u(phi) the
# horizontal radial direction from the corner arc's centre, the surface is
# P(phi, theta) = origin + u * (CORNER_RADIUS - BASE_RADIUS
# + BASE_RADIUS * sin(theta))
# + UP * rise * BASE_RADIUS * (1 - cos(theta))
# whose outward (into-material) normal is
# u * sin(theta) - UP * rise * cos(theta).
# Note `rise` enters the position and the normal with opposite signs.
func _add_wrap_colliders(sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
var path_step := (PI / 2.0) / WRAP_SEGMENTS
var profile_step := (PI / 2.0) / BASE_SEGMENTS
var face_width := 2.0 * BASE_RADIUS * tan(profile_step / 2.0) + 0.3
@@ -259,70 +293,12 @@ func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
var along := Vector3(-sx * sin(phi), 0.0, sz * cos(phi))
for j in BASE_SEGMENTS:
var theta := (j + 0.5) * profile_step
var ring_radius := axis_radius + BASE_RADIUS * sin(theta)
var ring_radius := WRAP_RADIUS + BASE_RADIUS * sin(theta)
var face_centre := origin + u * ring_radius \
+ Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta)))
var outward := u * sin(theta) - Vector3.UP * cos(theta)
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta)))
var outward := u * sin(theta) - Vector3.UP * (rise * cos(theta))
var run_length := ring_radius * 2.0 * tan(path_step / 2.0) + 0.3
_add_curve_collider(face_centre, outward, along, face_width, run_length)
# Smooth visual patch over the same torus.
var v_path := 8
var v_step := (PI / 2.0) / v_path
var p_step := (PI / 2.0) / FILLET_VISUAL_ARCS
for i in v_path:
for j in FILLET_VISUAL_ARCS:
var points: Array[Vector3] = []
var normals: Array[Vector3] = []
for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]:
var u_c := Vector3(sx * cos(corner[0] * v_step), 0.0, sz * sin(corner[0] * v_step))
var theta_c: float = corner[1] * p_step
points.append(origin + u_c * (axis_radius + BASE_RADIUS * sin(theta_c)) \
+ Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta_c))))
normals.append(-(u_c * sin(theta_c) - Vector3.UP * cos(theta_c)))
_add_quad(
st, points[0], normals[0], points[1], normals[1],
points[2], normals[2], points[3], normals[3]
)
# One smooth fillet strip, optionally capped at either end (see caller: an
# end is capped when it stops at the goal mouth rather than meeting a
# corner wrap).
func _add_fillet_visual(
st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float,
cap_start: bool, cap_end: bool
) -> void:
var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS
var end_a := centre - run_dir * (length / 2.0)
var end_b := centre + run_dir * (length / 2.0)
for i in FILLET_VISUAL_ARCS:
var dir_a := _fillet_dir(wall_out, i * arc_step)
var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step)
_add_quad(
st,
end_a + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_b * BASE_RADIUS, -dir_b,
end_a + dir_b * BASE_RADIUS, -dir_b
)
var caps: Array[Array] = []
if cap_start:
caps.append([end_a, -run_dir])
if cap_end:
caps.append([end_b, run_dir])
for cap in caps:
var end_point: Vector3 = cap[0]
var cap_normal: Vector3 = cap[1]
# Fan from the wall-floor corner of the cross-section to the arc.
var cap_corner := end_point + (wall_out + Vector3.DOWN) * BASE_RADIUS
for i in FILLET_VISUAL_ARCS:
var p0 := end_point + _fillet_dir(wall_out, i * arc_step) * BASE_RADIUS
var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step) * BASE_RADIUS
_add_cap_tri(st, cap_corner, p0, p1, cap_normal)
func _fillet_dir(wall_out: Vector3, angle: float) -> Vector3:
return wall_out * sin(angle) + Vector3.DOWN * cos(angle)
# One flat tangent collider segment of a curved surface: a box whose inner
@@ -342,6 +318,282 @@ func _add_curve_collider(
add_child(collision)
# --- visual shell ------------------------------------------------------------
# One mesh covering the inner surface of the play volume exactly once. Every
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces
# overlap — overlapping alpha was what made the enclosure read as patches of
# differing brightness. Only inward-facing triangles are emitted; the outer
# faces and the 1 m overhang the old BoxMeshes carried simply don't exist.
#
# Two surfaces share the mesh: an opaque hull (deck, base fillets and the goal
# surrounds) and the translucent containment field (walls, corners, ceiling
# fillets, ceiling).
func _build_visual_shell() -> void:
var mesh := ArrayMesh.new()
var deck := SurfaceTool.new()
deck.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_flat_panel(deck, 0.0, Vector3.UP)
_add_goal_aprons(deck)
_add_goal_surrounds(deck)
_add_fillet_surface(deck, BASE_FILLET)
deck.commit(mesh)
mesh.surface_set_material(0, _make_deck_material())
_field_material = _make_field_material()
var field := SurfaceTool.new()
field.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_wall_panels(field)
_add_corner_panels(field)
_add_fillet_surface(field, CEILING_FILLET)
_add_flat_panel(field, INNER_HEIGHT, Vector3.DOWN)
field.commit(mesh)
mesh.surface_set_material(1, _field_material)
_shell = MeshInstance3D.new()
_shell.name = "SurfaceShell"
_shell.mesh = mesh
# Ships and the ball still cast onto the deck; the deck shadowing itself
# would only produce acne.
_shell.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_shell)
func _make_deck_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(DECK_SHADER_PATH)
# The markings are drawn from these, so they track the collision geometry.
mat.set_shader_parameter("half_x", INNER_HALF_X)
mat.set_shader_parameter("half_z", INNER_HALF_Z)
mat.set_shader_parameter("goal_line_z", GOAL_LINE_Z)
mat.set_shader_parameter("base_radius", BASE_RADIUS)
mat.set_shader_parameter("team0_color", team0_tint)
mat.set_shader_parameter("team1_color", team1_tint)
mat.set_shader_parameter("seam_color", field_tint)
return mat
func _make_field_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(FIELD_SHADER_PATH)
mat.set_shader_parameter("field_color", field_tint)
mat.set_shader_parameter("field_intensity", field_intensity)
return mat
# The flat deck (or ceiling): a rounded rectangle whose straight edges stop on
# the fillet tangents and whose corners follow the corner wraps' inner arc, so
# it meets the fillets exactly once. Decomposed into a centre span, two end
# spans narrowed to clear the corner arcs, and four quarter-discs — a partition
# with no overlaps.
func _add_flat_panel(st: SurfaceTool, y: float, face: Vector3) -> void:
var origin := Vector3(0, y, 0)
var right := Vector3(1, 0, 0)
var forward := Vector3(0, 0, 1)
_add_plane_quad(st, origin, right, forward, face,
-FLAT_HALF_X, FLAT_HALF_X, -CORNER_CENTRE_Z, CORNER_CENTRE_Z)
for sz in [-1.0, 1.0]:
var z0: float = sz * CORNER_CENTRE_Z
var z1: float = sz * FLAT_HALF_Z
_add_plane_quad(st, origin, right, forward, face,
-CORNER_CENTRE_X, CORNER_CENTRE_X, minf(z0, z1), maxf(z0, z1))
var step := (PI / 2.0) / FILLET_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var centre := _corner_centre(sx, sz) + Vector3.UP * y
for i in FILLET_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * step), 0.0, sz * sin(i * step))
var d1 := Vector3(sx * cos((i + 1) * step), 0.0, sz * sin((i + 1) * step))
_add_cap_tri(st, centre, centre + d0 * WRAP_RADIUS, centre + d1 * WRAP_RADIUS, face)
# In FLOOR mode the end-wall fillet stops short of the goal mouth, so the deck
# has to run flat all the way to the end wall across that gap — otherwise there
# would be a hole in front of each goal.
func _add_goal_aprons(st: SurfaceTool) -> void:
if goal_mode != GoalMode.FLOOR:
return
for sz in [-1.0, 1.0]:
var z0: float = sz * FLAT_HALF_Z
var z1: float = sz * INNER_HALF_Z
_add_plane_quad(st, Vector3.ZERO, Vector3(1, 0, 0), Vector3(0, 0, 1), Vector3.UP,
-GOAL_MOUTH_HALF_WIDTH, GOAL_MOUTH_HALF_WIDTH, minf(z0, z1), maxf(z0, z1))
# Opaque bulkhead around each goal mouth, with the aperture cut out of it. This
# is the solid hull the goal recess is set into: left as translucent field
# panel, the recess and net behind it showed through the wall and the net read
# as duplicated either side of the frame.
func _add_goal_surrounds(st: SurfaceTool) -> void:
var bounds := _goal_surround_bounds()
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
GOAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
# (surround bottom, surround top, aperture bottom, aperture top) on the end
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
# sitting on the deck; in ELEVATED it is a band centred on the raised goal.
func _goal_surround_bounds() -> Vector4:
if goal_mode == GoalMode.FLOOR:
return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT)
var half := GOAL_APERTURE_HEIGHT / 2.0
return Vector4(
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y - half,
GOAL_CENTER_Y + half)
func _add_fillet_surface(st: SurfaceTool, rise: float) -> void:
for run in _fillet_runs(rise):
var caps: Array = run["caps"]
_add_fillet_visual(st, run["wall_out"], run["run_dir"], run["centre"], run["length"],
caps[0], caps[1], rise)
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_wrap_visual(st, sx, sz, rise)
# Wall panels, spanning between the fillets vertically and between the corner
# curves horizontally. Each end wall is cut around the goal surround, which is
# opaque hull carrying the aperture itself (see _add_goal_surrounds). In FLOOR
# mode that surround sits below the panels entirely, so only the ELEVATED one
# punches a hole through them.
func _add_wall_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var elevated := goal_mode == GoalMode.ELEVATED
var bounds := _goal_surround_bounds()
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
Vector3(-side, 0, 0), CORNER_CENTRE_Z, y0, y1, 0.0, 0.0, 0.0)
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), CORNER_CENTRE_X, y0, y1,
GOAL_MOUTH_HALF_WIDTH if elevated else 0.0, bounds.x, bounds.y)
# The corner quarter-cylinders, trimmed to the wall panels' height range so
# they abut the fillet wraps instead of running through them.
func _add_corner_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var arc_centre := _corner_centre(sx, sz)
for i in CORNER_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step))
var d1 := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step))
var b0 := arc_centre + d0 * CORNER_RADIUS
var b1 := arc_centre + d1 * CORNER_RADIUS
_add_quad(st,
b0 + Vector3.UP * y0, -d0, b1 + Vector3.UP * y0, -d1,
b1 + Vector3.UP * y1, -d1, b0 + Vector3.UP * y1, -d0)
# One smooth fillet strip, optionally capped at either end (an end is capped
# when it stops at the goal mouth rather than meeting a corner wrap).
func _add_fillet_visual(
st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float,
cap_start: bool, cap_end: bool, rise: float
) -> void:
var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS
var end_a := centre - run_dir * (length / 2.0)
var end_b := centre + run_dir * (length / 2.0)
for i in FILLET_VISUAL_ARCS:
var dir_a := _fillet_dir(wall_out, i * arc_step, rise)
var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step, rise)
_add_quad(
st,
end_a + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_b * BASE_RADIUS, -dir_b,
end_a + dir_b * BASE_RADIUS, -dir_b
)
var caps: Array[Array] = []
if cap_start:
caps.append([end_a, -run_dir])
if cap_end:
caps.append([end_b, run_dir])
for cap in caps:
var end_point: Vector3 = cap[0]
var cap_normal: Vector3 = cap[1]
# Fan from the wall-floor corner of the cross-section round to the arc.
var cap_corner := end_point + (wall_out + _fillet_dir(wall_out, 0.0, rise)) * BASE_RADIUS
for i in FILLET_VISUAL_ARCS:
var p0 := end_point + _fillet_dir(wall_out, i * arc_step, rise) * BASE_RADIUS
var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step, rise) * BASE_RADIUS
_add_cap_tri(st, cap_corner, p0, p1, cap_normal)
# Smooth torus patch over the same sweep _add_wrap_colliders describes.
func _add_wrap_visual(st: SurfaceTool, sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
var path_step := (PI / 2.0) / WRAP_VISUAL_ARCS
var profile_step := (PI / 2.0) / FILLET_VISUAL_ARCS
for i in WRAP_VISUAL_ARCS:
for j in FILLET_VISUAL_ARCS:
var points: Array[Vector3] = []
var normals: Array[Vector3] = []
for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]:
var phi: float = corner[0] * path_step
var theta: float = corner[1] * profile_step
var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi))
points.append(origin + u * (WRAP_RADIUS + BASE_RADIUS * sin(theta))
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta))))
normals.append(-(u * sin(theta) - Vector3.UP * (rise * cos(theta))))
_add_quad(
st, points[0], normals[0], points[1], normals[1],
points[2], normals[2], points[3], normals[3]
)
# --- meshing primitives ------------------------------------------------------
# A flat rectangle in the plane through `plane_origin` spanned by `right`/`up`,
# with an optional rectangular aperture cut out of it (hole_half_width <= 0 for
# none). Decomposed into the up-to-four quads surrounding the hole so the panel
# stays a single non-overlapping layer.
func _add_aperture_panel(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3, normal: Vector3,
half_width: float, v0: float, v1: float,
hole_half_width: float, hole_v0: float, hole_v1: float
) -> void:
if hole_half_width <= 0.0:
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, v1)
return
var lo: float = clampf(hole_v0, v0, v1)
var hi: float = clampf(hole_v1, v0, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, lo)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, hi, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, -hole_half_width, lo, hi)
_add_plane_quad(st, plane_origin, right, up, normal, hole_half_width, half_width, lo, hi)
# One quad in the plane through `plane_origin` spanned by `right`/`up`, over
# the given parameter ranges. Degenerate spans are skipped so callers can pass
# empty slices (an aperture flush with a panel edge, say) without guarding.
func _add_plane_quad(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3,
normal: Vector3, u0: float, u1: float, v0: float, v1: float
) -> void:
if u1 - u0 <= 0.0001 or v1 - v0 <= 0.0001:
return
_add_quad(st,
plane_origin + right * u0 + up * v0, normal,
plane_origin + right * u1 + up * v0, normal,
plane_origin + right * u1 + up * v1, normal,
plane_origin + right * u0 + up * v1, normal)
# Quad a-b-c-d with per-vertex normals, wound so the front faces the normals
# (Godot front faces wind clockwise when seen from the normal side).
func _add_quad(
+18 -6
View File
@@ -1,14 +1,26 @@
class_name ArenaRegistry
# Single source of truth for available arenas: the Free Play menu dropdown
# lists these, and Match/Spectate pick one at random each session. Training
# is exempt — training.tscn keeps its own fixed arena_01 child.
# lists all of these, and Match/Spectate pick at random each session from
# those with "random" true. Training is exempt — training.tscn/
# training_elevated.tscn each keep their own fixed Arena child.
#
# Elevated-goal variants are Free-Play-only ("random": false) until a
# checkpoint trained on training_elevated.tscn is promoted — the current
# promoted bots (see main_menu.gd's DIFFICULTIES) were trained exclusively
# on floor-level goals and can't be expected to score on an elevated one.
# Flip a variant's "random" flag to true once that training/promotion has
# happened.
const ARENAS := [
{"name": "Starfield", "path": "res://scenes/arena_01.tscn"},
{"name": "Nebula", "path": "res://scenes/arena_02.tscn"},
{"name": "Asteroid Field", "path": "res://scenes/arena_03.tscn"},
{"name": "Starfield (Floor Goals)", "path": "res://scenes/arena_01.tscn", "random": true},
{"name": "Starfield (Elevated Goals)", "path": "res://scenes/arena_01_elevated.tscn", "random": false},
{"name": "Nebula (Floor Goals)", "path": "res://scenes/arena_02.tscn", "random": true},
{"name": "Nebula (Elevated Goals)", "path": "res://scenes/arena_02_elevated.tscn", "random": false},
{"name": "Asteroid Field (Floor Goals)", "path": "res://scenes/arena_03.tscn", "random": true},
{"name": "Asteroid Field (Elevated Goals)", "path": "res://scenes/arena_03_elevated.tscn", "random": false},
]
static func random_path() -> String:
return ARENAS[randi() % ARENAS.size()]["path"]
var candidates := ARENAS.filter(func(arena): return arena["random"])
return candidates[randi() % candidates.size()]["path"]
+102
View File
@@ -10,12 +10,114 @@ extends Area3D
signal goal_scored(team: int)
# Frame tints, indexed by team.
const TEAM_COLORS := [Color(0.2, 0.55, 1.0), Color(1.0, 0.4, 0.28)]
# The pocket is sunk into the end wall, so it can be at most as deep as that
# wall is thick or it pokes out the back of the arena. ArenaBoundary cuts the
# matching aperture in the hull (see its GOAL_APERTURE_* constants).
const POCKET_DEPTH := ArenaBoundary.SURFACE_THICKNESS
# Clearance between the mouth and the pocket shell, so the pocket's side walls
# stay hidden behind the hull rather than showing at the aperture edge.
const POCKET_CLEARANCE := 0.2
# Dark machined lining round the opening, then a thin emissive rim flush with
# the wall face.
const BEZEL_THICKNESS := 0.2
const BEZEL_DEPTH := 0.16
const RIM_THICKNESS := 0.05
const RIM_DEPTH := 0.05
const NET_SHADER_PATH := "res://shaders/goal_net.gdshader"
func _ready():
# Group lets AI controllers and game modes discover goals
add_to_group("goal")
# Everything below _on_body_entered is decoration, and training spawns
# headless arenas that never render it. The sensor is unaffected.
if DisplayServer.get_name() != "headless":
_build_visuals()
func _on_body_entered(body):
if body.is_in_group("ball"):
goal_scored.emit(team)
# --- visuals -----------------------------------------------------------------
# Cosmetic only. The mouth is measured off the sensor's own collision shape, so
# the frame can never drift from the volume that actually scores.
func _build_visuals() -> void:
var mouth := ($CollisionShape3D.shape as BoxShape3D).size
var half := Vector2(mouth.x, mouth.y) / 2.0
var tint: Color = TEAM_COLORS[team % TEAM_COLORS.size()]
# Pocket shell, seen from the inside (hence CULL_FRONT) so it reads as a
# void carved into the hull rather than a box stuck onto it.
var pocket := _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9)
pocket.cull_mode = BaseMaterial3D.CULL_FRONT
_add_box(
Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH),
Vector3(0, 0, POCKET_DEPTH / 2.0), pocket)
# The net is the same trick: a box viewed from inside gives a five-sided
# pocket of netting from one mesh, instead of a flat panel across the back.
_add_box(Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
Vector3(0, 0, POCKET_DEPTH / 2.0), _net_material(tint))
# Bezel lines the opening (inset into the wall); the rim sits flush with
# the wall face and carries the team colour.
_add_frame_ring(half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0,
_surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
_add_frame_ring(half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0, _emissive(tint))
# Four bars around the mouth. The uprights run the full outer height so each
# corner is covered exactly once.
func _add_frame_ring(
half: Vector2, thickness: float, depth: float, z: float, material: Material
) -> void:
for sx in [-1.0, 1.0]:
_add_box(Vector3(thickness, (half.y + thickness) * 2.0, depth),
Vector3(sx * (half.x + thickness / 2.0), 0.0, z), material)
for sy in [-1.0, 1.0]:
_add_box(Vector3(half.x * 2.0, thickness, depth),
Vector3(0.0, sy * (half.y + thickness / 2.0), z), material)
func _add_box(size: Vector3, pos: Vector3, material: Material) -> void:
var mesh := BoxMesh.new()
mesh.size = size
var instance := MeshInstance3D.new()
instance.mesh = mesh
instance.position = pos
instance.material_override = material
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(instance)
func _surface(albedo: Color, metallic: float, roughness: float) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = albedo
mat.metallic = metallic
mat.roughness = roughness
return mat
func _emissive(tint: Color) -> StandardMaterial3D:
var mat := _surface(tint.darkened(0.7), 0.4, 0.3)
mat.emission_enabled = true
mat.emission = tint
# Each arena runs glow at hdr_threshold ~1.0, so keep this near it or the
# frame blooms into a smear.
mat.emission_energy_multiplier = 1.2
return mat
func _net_material(tint: Color) -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(NET_SHADER_PATH)
mat.set_shader_parameter("net_color", tint.lightened(0.45))
return mat
+12 -1
View File
@@ -120,8 +120,16 @@ var _ai_overrides := {}
# arena spawn instead of drifting into the play area as a stray obstacle.
var _inert_ships: Array[Ship] = []
# Whether this run's arena is an ELEVATED-goal variant (see
# ArenaBoundary.GoalMode) — read once _start() runs so _place_ball_near_goal
# can sample a height range matching the goal's real position. Read here
# rather than _ready(): TrainingMode has no _ready() override, and
# GameMode._ready() is what discovers `arena` before calling _start().
var _elevated := false
func _start() -> void:
_elevated = (arena.get_node("Boundary") as ArenaBoundary).goal_mode == ArenaBoundary.GoalMode.ELEVATED
_parse_eval_args()
_parse_curriculum_args()
spawn_ball()
@@ -371,9 +379,12 @@ func _place_ball_random() -> void:
func _place_ball_near_goal() -> void:
var goal := _goal_for_team(0) if randf() < attack_goal_bias else _goal_for_team(1)
var toward_centre := -signf(goal.global_position.z)
# Upper Y bound is a no-op on FLOOR arenas (goal.global_position.y ~0.79,
# so maxf(4.0, ...) stays 4.0); on ELEVATED arenas it widens to sample
# near the goal's real height instead of always landing near the floor.
var position := Vector3(
randf_range(-4.0, 4.0),
randf_range(FIELD_MIN_Y, 4.0),
randf_range(FIELD_MIN_Y, maxf(4.0, goal.global_position.y + 2.0)),
goal.global_position.z + toward_centre * randf_range(3.0, 6.0)
)
var to_goal := (goal.global_position - position).normalized()
+138
View File
@@ -0,0 +1,138 @@
// Opaque hull for the arena: the deck, the fillets easing it into the walls,
// and the bulkheads surrounding each goal mouth — the lit half of
// ArenaBoundary's visual shell.
//
// Everything is drawn procedurally from the vertex's position in the
// boundary's local space, so field markings are resolution-independent and
// need no decals, UVs or texture authoring. All dimensions arrive as uniforms
// set from ArenaBoundary's constants (see _make_deck_material), so the
// markings cannot drift away from the collision geometry they describe.
shader_type spatial;
render_mode cull_back, diffuse_burley, specular_schlick_ggx;
uniform vec3 deck_color : source_color = vec3(0.085, 0.095, 0.122);
// Kept close to deck_color on purpose: per-plate variation is meant to break
// up a 24x36 m plane, not to reintroduce visible tiling.
uniform vec3 panel_color : source_color = vec3(0.104, 0.116, 0.146);
// Stand-in for bounce light. The arena is lit by one dim directional plus low
// ambient, so hull facing away from it — the goal bulkheads, the fillet caps
// either side of each mouth — otherwise crushed to pure black and the whole
// end zone read as a void with a goal floating in it.
uniform float hull_fill : hint_range(0.0, 2.0) = 0.55;
uniform float panel_variation : hint_range(0.0, 1.0) = 0.4;
uniform vec3 line_color : source_color = vec3(0.62, 0.78, 1.0);
uniform vec3 team0_color : source_color = vec3(0.15, 0.45, 1.0);
uniform vec3 team1_color : source_color = vec3(1.0, 0.35, 0.25);
uniform vec3 seam_color : source_color = vec3(0.35, 0.75, 1.0);
// Play-volume dimensions, from ArenaBoundary's constants.
uniform float half_x = 12.0;
uniform float half_z = 18.0;
uniform float goal_line_z = 18.0;
uniform float base_radius = 2.0;
uniform float centre_circle_radius = 4.0;
uniform float goal_area_depth = 6.0;
uniform float goal_area_half_width = 6.0;
uniform float line_width = 0.14;
// Each arena's Environment runs glow at hdr_threshold ~1.0 with hdr_scale 2.0,
// so emission much above 1 blooms into a white smear. These stay under it.
uniform float line_emission : hint_range(0.0, 8.0) = 0.65;
uniform float seam_emission : hint_range(0.0, 8.0) = 0.8;
uniform float seam_width = 0.22;
uniform float end_zone_strength : hint_range(0.0, 1.0) = 0.12;
uniform float panel_size = 4.0;
uniform float panel_line_width : hint_range(0.0, 0.5) = 0.05;
// Deliberately not a high metallic value: this arena is lit by one dim
// directional plus low ambient, and a near-metal deck has almost no diffuse
// response, which crushed the plating to black.
uniform float deck_metallic : hint_range(0.0, 1.0) = 0.3;
uniform float deck_roughness : hint_range(0.0, 1.0) = 0.34;
varying vec3 v_local;
varying vec3 v_local_normal;
void vertex() {
v_local = VERTEX;
v_local_normal = NORMAL;
}
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
// 1 inside a band of width `w` centred on the zero of `d`, screen-space
// antialiased so lines stay crisp at grazing angles and don't shimmer.
float band(float d, float w) {
float aa = fwidth(d) + 0.001;
return 1.0 - smoothstep(w * 0.5, w * 0.5 + aa, abs(d));
}
// Antialiased outline of the axis-aligned box |p| <= half_size.
float rect_outline(vec2 p, vec2 half_size, float w) {
vec2 d = abs(p) - half_size;
return band(max(d.x, d.y), w);
}
// Plate seam mask for one projection plane.
float plate_seam(vec2 p) {
vec2 d = abs(fract(p / panel_size) - 0.5) * panel_size;
float m = min(d.x, d.y);
return 1.0 - smoothstep(panel_line_width, panel_line_width + fwidth(m) + 0.01, m);
}
void fragment() {
vec2 xz = v_local.xz;
vec3 n = normalize(v_local_normal);
float up_facing = clamp(n.y, 0.0, 1.0);
// --- hull plating ----------------------------------------------------
// Projected onto whichever plane each facet most faces, blended by
// |normal|, so plating doesn't smear up the fillets or stripe the vertical
// goal bulkheads the way a flat XZ projection did.
vec3 w = abs(n);
w /= max(w.x + w.y + w.z, 0.001);
float seams = w.x * plate_seam(v_local.zy)
+ w.y * plate_seam(v_local.xz)
+ w.z * plate_seam(v_local.xy);
vec3 albedo = mix(deck_color, panel_color, hash21(floor(xz / panel_size)) * panel_variation);
albedo = mix(albedo, deck_color * 0.55, seams);
float rough = deck_roughness + seams * 0.25;
// --- team-tinted end zones -------------------------------------------
// Team 0 defends +z (see the arenas' GoalTeam0 transforms), team 1 -z.
float t0 = smoothstep(0.0, half_z, xz.y);
float t1 = smoothstep(0.0, half_z, -xz.y);
vec3 zone = team0_color * t0 + team1_color * t1;
// Markings belong on the flat deck only: fade them out as the fillet lifts
// away from the floor, and drop them entirely on anything not facing up so
// they never wrap onto a goal bulkhead.
float flat_deck = (1.0 - smoothstep(0.0, base_radius * 0.4, v_local.y)) * up_facing;
albedo = mix(albedo, albedo + zone * 0.18, end_zone_strength * flat_deck);
// --- field markings ---------------------------------------------------
float marks = band(length(xz) - centre_circle_radius, line_width);
marks = max(marks, band(xz.y, line_width));
for (int i = 0; i < 2; i++) {
float s = i == 0 ? 1.0 : -1.0;
vec2 c = vec2(0.0, s * (goal_line_z - goal_area_depth * 0.5));
marks = max(marks, rect_outline(
xz - c, vec2(goal_area_half_width, goal_area_depth * 0.5), line_width));
}
marks *= flat_deck;
// --- deck/field handover ---------------------------------------------
// A band, not a step: it has to light the top edge of the fillets and the
// matching edge of the goal bulkheads without setting the whole of an
// elevated bulkhead (which sits far above base_radius) glowing.
float seam = 1.0 - smoothstep(0.0, seam_width, abs(v_local.y - base_radius));
ALBEDO = mix(albedo, line_color, marks);
METALLIC = deck_metallic * (1.0 - marks);
ROUGHNESS = clamp(mix(rough, 0.5, marks), 0.05, 1.0);
EMISSION = albedo * hull_fill
+ line_color * marks * line_emission
+ seam_color * seam * seam_emission
+ zone * 0.015 * flat_deck;
}
+1
View File
@@ -0,0 +1 @@
uid://bogr8ydj6q8rp
+83
View File
@@ -0,0 +1,83 @@
// Containment-field surface for the arena walls, ceiling, corner curves and
// ceiling fillets — the translucent half of ArenaBoundary's visual shell.
//
// Additive and unshaded on purpose. The StandardMaterial3D this replaces used
// alpha blending plus rim lighting, which had two problems:
// - alpha blending darkens whatever is behind it, so wherever two boundary
// surfaces overlapped the tint composited twice and the overlap read as a
// hard-edged patch that re-sorted as the camera moved;
// - rim is a *lit* effect, so each arena's two DirectionalLight3Ds and SDFGI
// drove visible gradients across a single 28x40 m panel.
// Additive cannot double-darken and composites order-independently, so the
// field reads uniformly regardless of how the geometry happens to sort.
shader_type spatial;
render_mode blend_add, depth_draw_never, cull_back, unshaded;
uniform vec3 field_color : source_color = vec3(0.45, 0.65, 1.0);
uniform float field_intensity : hint_range(0.0, 1.0) = 0.09;
// Face-on brightness floor, before fresnel and cell detail.
uniform float base_level : hint_range(0.0, 2.0) = 0.5;
// Grazing-angle glow — the "glass" read the old rim param was reaching for.
// Kept gentle: a steep exponent makes a head-on end wall read as a hard-edged
// dark rectangle against the corner curves flanking it.
uniform float fresnel_power : hint_range(0.5, 8.0) = 2.2;
uniform float fresnel_boost : hint_range(0.0, 8.0) = 2.6;
uniform float cell_size = 1.6;
uniform float cell_width : hint_range(0.0, 0.5) = 0.06;
uniform float cell_strength : hint_range(0.0, 4.0) = 1.1;
// Camera position in ArenaBoundary's local space, pushed each frame by
// arena_boundary.gd. Facets the camera has crossed to the outside of fade out
// rather than popping, so looking into the arena from outside stays clear.
// Per-pixel, which is what lets one merged shell replace the old per-face
// MeshInstance3D visibility toggling.
uniform vec3 camera_local_pos = vec3(0.0, 6.0, 0.0);
uniform float cull_fade_distance : hint_range(0.01, 8.0) = 1.25;
varying vec3 v_local_pos;
varying vec3 v_local_normal;
void vertex() {
v_local_pos = VERTEX;
v_local_normal = NORMAL;
}
// Offset-grid nearest-cell offset, used to build a hex lattice.
vec2 hex_cell(vec2 p) {
vec2 r = vec2(1.0, 1.7320508);
vec2 h = r * 0.5;
vec2 a = mod(p, r) - h;
vec2 b = mod(p - h, r) - h;
return dot(a, a) < dot(b, b) ? a : b;
}
float hex_edge(vec2 p) {
vec2 g = abs(hex_cell(p / cell_size));
float d = max(dot(g, normalize(vec2(1.0, 1.7320508))), g.x);
return smoothstep(0.5 - cell_width, 0.5, d);
}
void fragment() {
vec3 n = normalize(v_local_normal);
// Project the hex lattice onto whichever plane the facet most faces,
// blended by |normal| so the curved corner and fillet surfaces cross
// between projections without a seam.
vec3 w = abs(n);
w /= max(w.x + w.y + w.z, 0.001);
float cells =
w.x * hex_edge(v_local_pos.zy) +
w.y * hex_edge(v_local_pos.xz) +
w.z * hex_edge(v_local_pos.xy);
float facing = clamp(dot(normalize(NORMAL), normalize(VIEW)), 0.0, 1.0);
float fresnel = pow(1.0 - facing, fresnel_power);
float level = base_level + fresnel * fresnel_boost + cells * cell_strength;
ALBEDO = field_color * level * field_intensity;
// Signed distance from the camera to this facet's plane along its inward
// normal: positive while the camera is still inside the enclosure, so the
// panel fades out just before back-face culling would drop it.
float side = dot(camera_local_pos - v_local_pos, n);
ALPHA = smoothstep(0.0, cull_fade_distance, side);
}
+1
View File
@@ -0,0 +1 @@
uid://bqe75ud45htst
+55
View File
@@ -0,0 +1,55 @@
// Netting for the goal pocket.
//
// Applied to a box viewed from the inside (cull_front), so one mesh gives a
// five-sided pocket of netting rather than a flat panel across the back. The
// strand pattern is driven from model-space position rather than UV so spacing
// stays physical and continuous across all five faces — a box's UV layout
// would stretch it differently on each.
//
// Cut with discard rather than alpha blending on purpose: an alpha-scissor net
// still writes depth, so it sorts against the pocket, the frame and the
// arena's containment field like ordinary solid geometry. A blended net would
// join the transparent queue and sort per-object against the boundary shell,
// which is the class of artefact this whole pass removed.
shader_type spatial;
render_mode cull_front, diffuse_burley;
uniform vec3 net_color : source_color = vec3(0.65, 0.78, 1.0);
uniform float cell_size = 0.13;
uniform float strand_width = 0.011;
uniform float emission_strength : hint_range(0.0, 4.0) = 0.35;
varying vec3 v_local;
varying vec3 v_local_normal;
void vertex() {
v_local = VERTEX;
v_local_normal = NORMAL;
}
// Distance to the nearest strand centre line of a square lattice.
float strand(vec2 p) {
vec2 d = abs(fract(p / cell_size) - 0.5) * cell_size;
return min(d.x, d.y);
}
void fragment() {
vec3 n = abs(normalize(v_local_normal));
// Project onto whichever plane this face lies in.
float d = n.x > max(n.y, n.z)
? strand(v_local.zy)
: (n.y > n.z ? strand(v_local.xz) : strand(v_local.xy));
if (d > strand_width) {
discard;
}
// Only back faces survive cull_front, and their normals point out of the
// pocket rather than into it, so flip them or the netting lights from
// behind and reads flat black.
if (!FRONT_FACING) {
NORMAL = -NORMAL;
}
ALBEDO = net_color * 0.25;
EMISSION = net_color * emission_strength;
ROUGHNESS = 0.65;
METALLIC = 0.1;
}
+1
View File
@@ -0,0 +1 @@
uid://bbueqcqp62gqo
+68
View File
@@ -0,0 +1,68 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, specular_disabled;
uniform sampler2D albedo_tex : source_color;
uniform sampler2D depth_tex : hint_depth_texture, filter_linear_mipmap;
uniform vec3 core_bias_color : source_color = vec3(1.0, 0.6, 0.85);
uniform float core_bias_amount : hint_range(0.0, 1.0) = 0.35;
uniform float emission_strength : hint_range(0.0, 5.0) = 2.5;
uniform float soft_fade_distance : hint_range(0.0, 5.0) = 1.0;
// Arena play volume (world space) this weather effect must stay clear of —
// see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully
// hidden inside that box and fades in over arena_clear_distance beyond it,
// so it reads as drifting around the station rather than through the pitch.
uniform vec3 arena_half_extents = vec3(12.0, 6.0, 18.0);
uniform vec3 arena_centre = vec3(0.0, 6.0, 0.0);
uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0;
varying float v_flicker;
varying float v_arena_fade;
void vertex() {
// true billboard: strip rotation from the per-instance modelview, keep translation + scale
mat4 mv = MODELVIEW_MATRIX;
mv[0].xyz = vec3(length(mv[0].xyz), 0.0, 0.0);
mv[1].xyz = vec3(0.0, length(mv[1].xyz), 0.0);
mv[2].xyz = vec3(0.0, 0.0, length(mv[2].xyz));
VERTEX = (mv * vec4(VERTEX, 1.0)).xyz;
// INSTANCE_CUSTOM.x is GPUParticles3D's per-particle random seed, baked at spawn
float seed = INSTANCE_CUSTOM.x;
float flicker_hash = fract(sin((seed + floor(TIME * 6.0)) * 127.1) * 43758.5453);
v_flicker = 0.85 + 0.15 * flicker_hash;
// MODEL_MATRIX's translation column is this particle's world-space
// origin (pre-billboard, so unaffected by the VERTEX rewrite above).
// Chebyshev (box) distance outside arena_half_extents, in metres.
vec3 particle_pos = MODEL_MATRIX[3].xyz;
vec3 outside = abs(particle_pos - arena_centre) - arena_half_extents;
float outside_dist = max(max(outside.x, outside.y), outside.z);
v_arena_fade = smoothstep(0.0, arena_clear_distance, outside_dist);
}
void fragment() {
// fake a puff/sphere normal from local UV so a camera-facing billboard can still catch directional light;
// the quad is billboarded to face the camera in vertex(), so this normal is already view-space aligned
vec2 centered = UV * 2.0 - 1.0;
float r2 = dot(centered, centered);
float mask = clamp(1.0 - r2, 0.0, 1.0);
NORMAL = normalize(vec3(centered, sqrt(max(mask, 0.001))));
vec4 tex = texture(albedo_tex, UV);
ALBEDO = tex.rgb * mix(vec3(1.0), core_bias_color, core_bias_amount);
EMISSION = core_bias_color * tex.rgb * emission_strength * 0.15;
ALPHA = tex.a * COLOR.a * mask * v_arena_fade;
float raw_depth = texture(depth_tex, SCREEN_UV).r;
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
vec4 view_pos = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
view_pos.xyz /= view_pos.w;
float scene_depth = -view_pos.z;
float particle_depth = -VERTEX.z;
ALPHA *= clamp((scene_depth - particle_depth) / soft_fade_distance, 0.0, 1.0);
}
void light() {
float ndotl = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
DIFFUSE_LIGHT += LIGHT_COLOR * ndotl * ALBEDO * emission_strength * v_flicker * ATTENUATION;
}
+1
View File
@@ -0,0 +1 @@
uid://b6p22qfcd7qmt
+58 -33
View File
@@ -6,12 +6,67 @@ Deferred work, in rough priority order. The current architecture (ShipAction/Shi
The training pipeline is built — see `TRAINING.md` (self-play PPO via the vendored godot_rl_agents bridge, JSON policy export, in-game GDScript inference, eval ladder). Remaining:
- [x] Promote a first tier: `curric-s6-unmask` copied into `Game/bots/promoted/easy.json` as the shipped "easy" bot (see TRAINING.md's "Promoted bots" section) — `match.tscn`/`spectate.tscn` now default there instead of `run05.json`.
- [ ] Long training runs on the Linux/3090 box to produce actually-good bots; promote further checkpoints into `Game/bots/promoted/` as `medium`/`hard` tiers once they clear `easy.json` in `evaluate.py`.
- [x] Staged curriculum (score → defend → avoid draws → full mechanics) via `train.py`'s `--opponent-mode`/`--draw-penalty`/`--attack-goal-bias`/`--vertical-ramp`/`--pitch-roll-ramp` flags — see TRAINING.md's "Curriculum training" section. `--opponent-mode=frozen` is a single-fixed-model slice of the league idea below, not the full sampled pool.
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
- [x] Main-menu difficulty picker: Easy/Medium/Hard presets in `main_menu.gd` set `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` via `GameSettings`. Raw-checkpoint testing and Spectate mode moved into a `DevSection` hidden outside debug builds. All three tiers currently reuse `promoted/easy.json` with different handicaps until `medium`/`hard` checkpoints are promoted.
## Correctness
Bugs found in an adversarial review. None are gameplay- or physics-affecting, so all are safe to land against the current `Game/bots/` checkpoints.
- [ ] `VideoSettings.apply_to_environment()` (`scripts/video_settings.gd`) compounds on every arena load: `env.glow_intensity *= glow_scale` mutates an `Environment` that is a `[sub_resource]` of the arena scene, and Godot shares sub-resources across instantiations of a cached `PackedScene`. Glow at 50% becomes 25% then 12.5% across repeat entries. Fix by duplicating the Environment in `Arena._ready()` (`scripts/arena.gd`). Regression test: set glow to 50%, enter/leave Free Play three times, confirm it's still 50%.
- [ ] Collapse the four disagreeing team palettes into one source of truth — `ship.gd`, `HUDController.gd` and `goal.gd` each declare `TEAM_COLORS`, `arena_boundary.gd` exports `team0_tint`/`team1_tint`, and `arena_deck.gdshader` defaults to a fifth pair. Three of them disagree, so nose, goal rim, end zone and scoreboard are all different blues.
- [ ] Goal scoring volume (3.5 x 1.5, `objects/goal.tscn`) is smaller than the drawn mouth (3.7 x 1.65, `ArenaBoundary.GOAL_APERTURE_*`) — a ball crossing the visible edge doesn't score. Derive the aperture constants from the goal's collision shape, the way `goal.gd:53` already measures its own visuals.
- [ ] `match_mode.gd`: full time can fire mid-kickoff-countdown, and the stalled coroutine resumes into the dying scene (can re-emit `kickoff_countdown` / unfreeze bodies for a frame). Guard `_run_kickoff_countdown` with a match-over flag.
- [ ] `match_mode.gd` emits `timer_updated` every frame for a value that changes once a second; the HUD re-formats and re-shapes the label each time. Emit only on change, matching `ship.gd`'s threshold-gated telemetry discipline.
- [ ] `HUDController` binds to `get_first_node_in_group("ship")` in a group that always has 2+ members — works only because the player ship happens to spawn first. Have the game mode hand the HUD its target ship.
- [ ] Reuse a member `ShipAction` in `ship.gd` (controllerless path) and `player_ship_controller.gd` instead of allocating one per physics tick; `ai_ship_controller.gd` already does this correctly.
- [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.
## DRY / structure
- [ ] `arena_base.tscn` + inherited themes. `arena_01/02/03.tscn` each restate ~40 identical lines (both lights, the reflection probe, goal transforms, ball/ship spawn markers); only sky, ambient, three glow numbers, tint and decoration differ. The `_elevated` variants already prove the inherited-scene pattern works here — `arena_01_elevated.tscn` is 20 lines to `arena_01.tscn`'s 100. Unblocks cheap new arenas.
- [ ] Hoist bot construction into `GameMode``match_mode._make_opponent_controller` and `spectate_mode._make_bot` are the same function (same existence check, same three fields, same warning + inert fallback).
- [ ] Hoist score-keeping into `GameMode` — both modes declare `score := {0:0, 1:0}`, a `score_changed` signal, and the identical increment/emit/print block.
- [ ] `HudInstrument extends Control` base for the three HUD widgets: each repeats `const SMOOTHING := 12.0` and the same `1.0 - exp(-SMOOTHING * delta)` lerp/redraw `_process`, and `hud_heading_tape.gd` reaches across to a static angle helper parked on `HudAttitudeIndicator`.
- [ ] `MAIN_MENU_SCENE_PATH` is declared in both `game_mode.gd` and `settings_menu.gd`.
- [ ] Comment `main_menu.gd`'s `DIFFICULTIES` to say the tiers deliberately share `easy.json` and differ only by handicap (superseded once real `medium`/`hard` tiers land — see the AI section above).
## Performance
- [ ] Gate `Ship._emit_telemetry_data()` — it runs `get_euler()` + trig per ship per physics tick for every ship, including AI ships nobody displays and `--headless` training where no HUD exists. Gate on having signal connections, and `set_physics_process(false)` when headless (`arena_boundary.gd` already does this correctly for its `_process`).
- [ ] Stop the HUD instruments redrawing once settled: all five `queue_redraw()` every frame forever, re-recording canvas items with `draw_string` glyph work, and `HUD.tscn` sets `process_mode = 3` so it continues while paused.
- [ ] Shared per-team materials instead of `Ship._apply_team_color()` allocating a fresh `StandardMaterial3D` and assigning it as `material_override` (and running at least twice per ship — once from `_ready`, once from the `team` setter). Removes the allocation and lets same-team ships batch.
- [ ] Merge each goal's visuals into one `ArrayMesh` with a hull surface and a net surface — currently 11 `MeshInstance3D`s and 4 materials per goal, ~22 draw calls for a static prop. `arena_boundary.gd:333` already demonstrates the `SurfaceTool` technique in this codebase.
- [ ] Merge the four non-tinted ship meshes (Hull/Canopy/EngineGlowL/R) into one — 6 draw calls per ship down to 3. Irrelevant at 1v1; 36 calls before VFX at 3v3.
- [ ] Cache the camera in `ArenaBoundary._process` instead of a `get_viewport().get_camera_3d()` tree lookup every frame (`ship_camera.gd` already caches the ball this way).
- [ ] Name collision layers in `project.godot` and assign them — nothing configures `collision_layer`/`collision_mask` today, so every body tests against every other.
- [ ] Measure `nebula_dust.gdshader`'s per-fragment depth-texture sample across 500 large soft billboards before adding more particle work.
- [ ] Bake `ArenaBoundary`'s ~160 runtime-generated `CollisionShape3D` nodes into the scene. Costs a load hitch on every arena entry and repeats in every parallel headless training env. **Blocked on the trained-bot decision below**`arena_boundary.gd:235` notes this geometry is what the shipped policies were fitted against, so the bake must be verified byte-identical.
## Presentation / AAA polish
The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour.
- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals.
- [ ] SSAO/SSIL in the arena Environments — cheapest single perceived-quality win available; grounds the ships against the deck and gives the fillets and goal recesses real depth.
- [ ] VFX on anything that moves: `EngineGlowL/R` are static meshes that don't respond to throttle. No thruster plume, turbo flame, ball trail, impact sparks or goal burst. `arena_02` is the only scene with any particles at all.
- [ ] Impact feedback — screen shake, hit-stop, flash, controller rumble on ball contact.
- [ ] Camera feel in `ship_camera.gd` — fixed distance/height/FOV today. Speed-based FOV widening, turbo kick, impact shake.
- [ ] Goal celebration sequence: today it's a `print()` and a label scale-pop. Wants an explosion, team-tinted screen flash, camera cut, slow-mo, title card. `HUDController`'s `ResultOverlay` animation is a reasonable template.
- [ ] Local lighting / LightmapGI bake — two directional lights and low ambient, no local lights anywhere. `arena_deck.gdshader`'s `hull_fill` uniform is explicitly a "stand-in for bounce light", i.e. the shader is compensating for lighting that isn't there. The arena is fully static, so a bake is viable; retire `hull_fill` afterwards.
- [ ] Dress `arena_03` — its `Decoration` node is empty, while `arena_02` has stations, debris, a planet and volumetric dust.
- [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
- [ ] Post-processing beyond glow: DoF, motion blur, vignette, chromatic aberration on turbo.
## Open decision — trained-bot compatibility
Four items collide with the checkpoints in `Game/bots/`. Decide the policy before scheduling any of them; everything in Correctness and DRY above is safe either way.
- [ ] **Per-tick drag** (`ship.gd`): `state.linear_velocity *= drag_coefficient` and `angular_velocity *= 0.9` aren't delta-scaled, and `project.godot` never pins `physics/common/physics_ticks_per_second`. Correct at 60 Hz, silently different at any other rate. Zero-risk option: pin the tick rate to 60 and document the dependency. Correct-but-breaking option: delta-scale it and retrain.
- [ ] **Goal aperture** — cutting a real opening in the end walls so the ball visibly enters the net changes collision geometry the policies were fitted against. Today the walls are solid and the pocket/net are unreachable decoration.
- [ ] **Collider bake** (see Performance above) — safe only if byte-identical to the current generated output.
- [ ] **Beyond 1v1**`ai_ship_controller.gd` takes the first non-self ship as "the opponent" and `ship_observations.gd` has room for exactly one. The observation space is the hardest thing to change later, so decide whether 2v2/3v3 is in scope before more training time is spent.
## Multiplayer (long term)
@@ -19,33 +74,3 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score.
- [ ] C# backend / online servers per README roadmap (not started).
- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this).
## General
- [x] More arenas: `arena_02.tscn` (Nebula) and `arena_03.tscn` (Asteroid Field) added alongside `arena_01.tscn`, all following the same template (goals/spawns/`arena_boundary.tscn` unchanged) and listed in `scripts/arena_registry.gd`. Free Play lets the player pick an arena from the main menu; Match/Spectate each pick one at random per session; Training keeps its own fixed `arena_01.tscn` untouched.
- [x] arena_02 (Nebula) redone RL-style: `arena_boundary.tscn`'s field material is now near-invisible glass (shared by all arenas), the sky is a baked photoreal-style equirect nebula texture (`assets/textures/sky_nebula.png`, procedurally generated offline — no copied astrophotography) instead of a procedural cell-shader, a `NebulaDust` `GPUParticles3D` gives a drifting-motes "weather" effect, and the `Decoration` node holds a station/debris/planet environment (Blender-modeled, `assets/blender_models/nebula_decoration.blend``assets/models/nebula_*.glb`) visible in multiple directions. `arena_01`/`arena_03` still use the older embedded-shader sky — same treatment for those is unstarted follow-up work.
## Visual quality pass (arena_02 critique follow-up)
A subagent ran the game and critiqued arena_02 head-on against Rocket League 2 in Unreal Engine 5: verdict was "a flat-shaded, unlit-looking blockout dressed up with two nice noise textures" — not fundamentally unfixable in Godot (SDFGI/Nanite-tier GI and hero-sculpted geometry aside), but several concrete gaps. Work through these one at a time, in this order (cheapest/highest-impact first). Each item has a ready-to-use prompt — paste it into a fresh session to tackle just that piece. The texture-generation Python scripts used for `sky_nebula.png`/`planet_surface.png` currently only exist in an ephemeral scratchpad, not the repo — the first item that touches them should commit a copy into the repo (e.g. `tools/textures/`) so they're reproducible.
- [x] **Post-processing pipeline** (no models needed — cheapest, highest-impact item on the list). Nothing currently sits on top of the raw render: no bloom/glow, no AA, no color grading, no vignette beyond the floor's baked fade.
Prompt: "In Cosmic Clash (Godot 4.7), the arena Environment resources (start with `Game/scenes/arena_02.tscn`'s `Environment_nebula`, then apply consistently across `arena_01`/`arena_03` too) currently have no post-processing. Enable and tune Glow (bloom) so the nebula core, emissive accent strips on the station model, and bright stars actually bleed light; enable Adjustments (`adjustment_enabled`) for a subtle contrast/saturation grade. Godot's `Environment` has no built-in vignette property — if that's still wanted, it needs a small custom full-screen shader (a `CompositorEffect` or a screen-space `ColorRect` overlay), not a toggle; treat it as a separate, smaller sub-task rather than assuming it's free. Also enable FXAA and/or TAA via `project.godot`'s `rendering/anti_aliasing/quality/*` settings project-wide to fix the aliased hard edges visible on ship geometry. Motion blur and depth-of-field are deliberately out of scope here — Godot has no built-in equivalent, and faking either well needs a custom `CompositorEffect`, which is a much bigger task than this pass. Verify with before/after screenshots via godot-mcp (`run_project` on `free_play.tscn` with the Nebula arena selected) — don't just eyeball the editor, actually run the game."
- [x] **De-duplicate the nebula sky's star sprites**. Every bright star in `sky_nebula.png` is the exact same diffraction-spike stamp at the same size/brightness, just relocated — called out as "the single most amateur-looking tell in the whole scene" once you look for more than a second. Fixed: the generator is now committed at `tools/textures/gen_nebula_sky.py`; the hero-star loop randomizes rotation, arm count (4 or 8), spike length, and brightness per star, and `sky_nebula.png` was regenerated/reimported and confirmed via in-game screenshot.
Prompt: "Commit the nebula sky texture generator (currently only in an ephemeral scratchpad — recreate it if needed: numpy/Pillow script generating a 4096x2048 equirect nebula via layered FFT/domain-warped noise, color-graded, with drawn hero stars) into the repo at `tools/textures/gen_nebula_sky.py`. Fix the hero-star drawing loop so each star's diffraction-spike stamp gets randomized rotation, spike length, and brightness instead of reusing one identical stamp at every location. Regenerate `Game/assets/textures/sky_nebula.png`, reimport, and confirm via an in-game screenshot that the repeated-stamp tell is gone."
- [x] **Real PBR lighting + materials, fresnel glass boundary**: SDFGI enabled and a `ReflectionProbe` added to all three arenas (`arena_01`/`02`/`03.tscn`); `arena_boundary.tscn`'s shared `StandardMaterial3D_field` converted from unshaded to shaded (roughness 0.05, rim-enabled fresnel highlight, alpha still near-invisible face-on). Verified via godot-mcp screenshots (60 FPS / 17ms frame time, no measurable cost) and zero-error headless runs of `free_play`/`match`/`spectate`/`training`.
Prompt: "In Cosmic Clash, enable SDFGI and add reflection probes to the arena scenes (start with `arena_02.tscn`) so surfaces get real bounce lighting/reflections instead of flat ambient. Convert `arena_boundary.tscn`'s `StandardMaterial3D_field` from unshaded to a proper shaded material with a fresnel-based rim highlight (bright at grazing angles, near-invisible face-on) so it reads as glass rather than a tinted overlay — check the performance impact of moving it off unshaded, given it's a large always-visible surface. Verify visually via godot-mcp screenshots, and confirm headless runs (`free_play`/`match`/`spectate`/`training`) still show zero errors."
- [x] **Greeble/detail pass on the station + debris models** (Blender remodel — explicit models to redo). Rebuilt via a new committed generator, `tools/blender/gen_nebula.py` (`nebula_decoration.blend` had no prior script, unlike ship/ball — this brings it in line): the station gets panel-line inset/extrude greeble across three passes and 3 separate emissive window strips (was 1), and debris gets its own `Mat_DebrisRock`/`Mat_DebrisScorch` materials plus per-vertex jitter and impact-crater gouges instead of cloning the station's `Hull_Metal_Dark`. The full normal-map/AO bake was skipped as too fragile to script reliably (per this item's own fallback allowance) — detail is geometry + material-only, no trim texture needed on top.
Prompt: "Using the blender MCP, rebuild `nebula_station` (source in `Game/assets/blender_models/nebula_decoration.blend`) with actual surface detail: greebled panel-line insets via bmesh inset-and-extrude on selected faces (more scriptable and robust than chained boolean cuts — prefer this over booleans for the main detailing pass), and 2-3 emissive window/light strips distinct from the existing single accent strip. A full normal-map/AO bake pipeline (low-poly + high-poly pair, UV unwrap, bake settings) is the ideal AAA-style finish but is fragile to script end-to-end in one pass — attempt it, but if it proves too unreliable, fall back to the inset/extrude geometric detail alone plus a simple tiled trim-sheet-style texture rather than forcing a bake that doesn't work. Give `nebula_debris` chunks a rockier/damaged material distinct from the station's clean hull (darker, rougher, maybe scorch-mark variation) so they read as separate debris rather than clones of the station's material. Re-export both to `Game/assets/models/nebula_station.glb`/`nebula_debris.glb`, re-save the shared `.blend`, and re-verify placement/transforms in `arena_02.tscn` still look right (screenshot check)."
- [ ] **Richer nebula sky + planet surface textures** (extends the existing procedural generation — no new asset types). `sky_nebula.png` is essentially one noise-filter pass over a bright core; `planet_surface.png` is a flat gradient with a single vortex swirl and no bands, craters, or day/night terminator.
Prompt: "Extend the nebula/planet texture generators (see `tools/textures/` once committed, per the star-sprite TODO item above) with more detail layers: for the sky, add a second/third dust-lane layer at a different scale plus subtler color variation within the bright core (real nebulae aren't one flat color); for the planet, add a proper lit/unlit terminator gradient (the side facing the arena's directional light should read brighter), more band variation at different latitudes, and a couple more storm-vortex features so it doesn't read as a single gradient with one twist. Regenerate both textures, reimport, and screenshot-verify."
- [ ] **Particle lighting response for the nebula dust ("weather") effect** (no models — particle material/shader only). The `NebulaDust` `GPUParticles3D` motes are generic soft glow sprites with no lighting interaction, no depth-based fade, and no secondary motion (no sparkle/color shift).
Prompt: "In `arena_02.tscn`'s `NebulaDust` particle system, replace the current unshaded glow-sprite material with a custom shader that fakes lighting response — note the sprites are camera-facing billboards, so they have no fixed world-space normal and true per-pixel PBR lighting won't behave like it would on a solid mesh. A practical approach: derive a fake per-pixel normal from the quad's local UV (as if each sprite were a small sphere/puff, same trick used for 2D lit particle effects) and light that against the directional light + a fixed 'glow color' bias toward the nebula core direction, rather than relying on the mesh's real (camera-facing) normal. Add soft-particle depth fade (compare particle depth to the depth buffer) so motes don't hard-clip through the boundary/decoration geometry, and add subtle per-particle brightness flicker via the color ramp or shader for sparkle. Verify visually via godot-mcp screenshots that the dust reads as lit rather than flat-glowing, from a couple of different camera angles (billboard lighting tricks can look wrong from some angles even when right from others)."
- [x] **Ship + ball visual pass**: `Game/objects/ship.tscn`'s 5 primitives replaced with a Blender-greebled hull/nose/canopy/tailfin/twin nacelles (source `Game/assets/blender_models/ship.blend`, generator `tools/blender/gen_ship.py`); ball fully remodeled as a smooth round icosphere with a crossed emissive accent pattern (`ball.blend`, `gen_ball.py`) rather than just a material tweak, replacing the old flat-shaded `gold_ball`. `Nose`/`TailFin` node names preserved for `_apply_team_color()`; `CollisionShape3D`/`RigidBody3D` physics on both ship and ball untouched (verified). Each part's mesh is extracted to a standalone `.res` (`tools/blender/extract_meshes.gd`) rather than referenced via `glb::ArrayMesh_xxx`, which doesn't reliably resolve across scene files. Verified via in-game screenshots (both team colors, orientation) and headless runs of `free_play`/`training`/`match`.
Prompt: "Using the blender MCP, model an actual low-poly-but-detailed ship hull (greebled fuselage panel lines, a proper cockpit canopy shape, 1-2 engine nacelles with emissive exhaust glow) to replace `Game/objects/ship.tscn`'s current 5 primitive `MeshInstance3D` parts (`Hull`/`Nose`/`Canopy`/`TailFin`/`EngineGlow`), built the same procedural way as the arena decoration (bpy/bmesh primitives + bevels + inset/extrude detail — kept original, no borrowed/asset-store art). Two hard constraints this must respect: (1) **`scripts/ship.gd`'s `_apply_team_color()` recolors two child nodes by exact name, `get_node_or_null(\"Nose\")` and `get_node_or_null(\"TailFin\")`, at runtime per-team** (team 0 blue, team 1 orange, set via `game_mode.gd`'s `ship.team = team`) — either keep two `MeshInstance3D` parts of the new model literally named `Nose` and `TailFin` so this keeps working untouched, or update `_apply_team_color()`'s node-name list in the same change if the new model's parts are named differently; don't let this silently break. (2) **Do not touch `ship.tscn`'s single `CollisionShape3D` (`BoxShape3D`, size `1 x 1 x 4`) or the `RigidBody3D`'s `mass = 5.0`/`inertia = (1,1,1)`/`physics_material_override`** — those are exactly what the trained RL bots and flight feel are tuned against (per CLAUDE.md). Keep the new visual mesh's silhouette reasonably close to that `1 x 1 x 4` box so the ship doesn't visually stick out of or float inside its own collision bounds. Separately, audit the existing gold ball's material (`Game/assets/models/gold_ball.glb`/`.res`, referenced from `Game/objects/ball.tscn`'s `MeshInstance3D` with no scene-level material override, so its look currently comes entirely from whatever's embedded in the imported mesh) and upgrade it to a proper metallic/emissive-accent PBR look if it turns out flat/unshaded once the lighting pass above lands — don't assume it needs work without checking first. Verify both visually (screenshot, including a look at team-color recoloring in Match/Spectate) and via a headless run of `free_play.tscn`/`training.tscn` to confirm physics/RL behavior is unaffected."
+33
View File
@@ -61,6 +61,14 @@ haze = bilinear_sample(fft_field(3.0, seed=21), xx + warp_x * 1.3, yy + warp_y *
ridged = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(1.6, seed=31), xx + warp_x * 0.8, yy + warp_y * 0.8) - 1.0)
dust = np.clip((ridged - 0.72) / 0.28, 0.0, 1.0) ** 1.5
# Two more dust-lane layers at scales straddling the primary one above, so the sky
# reads as several overlapping filament systems instead of one repeated pattern.
ridged_fine = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(1.0, seed=41), xx + warp_x * 1.6, yy + warp_y * 1.6) - 1.0)
dust_fine = np.clip((ridged_fine - 0.78) / 0.22, 0.0, 1.0) ** 1.2 # delicate, sparse filaments
ridged_wide = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(2.2, seed=51), xx + warp_x * 0.4, yy + warp_y * 0.4) - 1.0)
dust_wide = np.clip((ridged_wide - 0.65) / 0.35, 0.0, 1.0) ** 1.5 # broad, soft secondary lane
# Confine the bright nebula body to an off-center round-ish region (like the reference's
# bright core), fading into the darker starfield toward the poles/edges.
cx, cy = W * 0.42, H * 0.46
@@ -104,6 +112,22 @@ def ramp(t):
color = ramp(density)
print("adding core color variation...")
# Real emission nebulae mix H-alpha (magenta/red) and OIII (faint teal/cyan) regions
# rather than being one flat hue at a given brightness -- nudge hue in slow-varying
# patches, confined to the bright body, on top of the brightness-only ramp above.
hue_field = bilinear_sample(fft_field(2.4, seed=61), xx + warp_x * 0.9, yy + warp_y * 0.9)
hue_field = (hue_field - 0.5) * 2.0 # -1..1
warm_tint = np.array([0.25, 0.02, -0.05])
cool_tint = np.array([-0.12, 0.10, 0.06])
hue_variation = np.where(
hue_field[..., None] > 0,
hue_field[..., None] * warm_tint[None, None, :],
-hue_field[..., None] * cool_tint[None, None, :],
)
core_variation_strength = (mask ** 0.8) * 0.22
color = np.clip(color + hue_variation * core_variation_strength[..., None], 0.0, 1.0)
# Blue-violet outer haze wash, additive, strongest away from the bright core.
haze_color = np.array([0.10, 0.11, 0.30])
haze_amount = (haze * 0.5 + 0.5) * (1.0 - mask) * 0.35
@@ -113,6 +137,15 @@ color += haze_amount[..., None] * haze_color[None, None, :]
dust_strength = (dust * mask * 0.85)[..., None]
color *= (1.0 - dust_strength * 0.9)
# Two more dust-lane layers at different scales, weaker than the primary lane. The
# wide lane is allowed to bleed slightly past the core edge (mask relaxed) since real
# dust lanes don't stop precisely at a nebula's bright-body boundary.
dust_fine_strength = (dust_fine * mask * 0.55)[..., None]
color *= (1.0 - dust_fine_strength * 0.55)
dust_wide_strength = (dust_wide * (mask * 0.7 + 0.15) * 0.5)[..., None]
color *= (1.0 - dust_wide_strength * 0.45)
color = np.clip(color, 0.0, 1.0)
print("adding stars...")
+189
View File
@@ -0,0 +1,189 @@
"""Generates Game/assets/textures/planet_surface.png (and the byte-identical
Game/assets/models/nebula_planet_planet_surface.png sidecar that actually feeds
the live nebula_planet.glb material -- Godot's glTF importer extracted the
embedded image there at import time, so the imported scene references that
external file, not the glb's internal binary): a 2048x1024 equirectangular
decorative planet with latitude bands, storm vortices, and a lit/unlit
terminator, via layered FFT/domain-warped noise in the same style as
gen_nebula_sky.py (helpers duplicated here to keep both scripts standalone).
"""
from pathlib import Path
import numpy as np
from PIL import Image
W, H = 2048, 1024
# Direction *toward* arena_02's DirectionalLight3D in world space (its basis Z-column;
# NebulaPlanet has no rotation, so mesh-local axes equal world axes and this can be used
# directly against the UV-derived normal below). A surface texel is lit when its normal
# points roughly toward this direction.
LIGHT_DIR = np.array([0.321394, 0.766044, 0.55667])
# Sphere's exact UV convention (extracted from Game/assets/models/nebula_planet.glb's
# vertex data: +X -> uv(0.5,0.5), -X -> uv(0.0,0.5), +Z -> uv(0.25,0.5),
# -Z -> uv(0.75,0.5), +Y -> uv(*, 0.0), -Y -> uv(*, 1.0)):
# theta = v*pi, phi = 2*pi*(u-0.5)
# x = sin(theta)*cos(phi), y = cos(theta), z = -sin(theta)*sin(phi)
def fft_field(power, seed, remove_dc=True):
r = np.random.default_rng(seed)
white = r.normal(size=(H, W))
F = np.fft.fft2(white)
fy = np.fft.fftfreq(H)[:, None]
fx = np.fft.fftfreq(W)[None, :]
freq = np.sqrt(fx ** 2 + fy ** 2)
freq[0, 0] = 1e-6
filt = 1.0 / (freq ** power)
if remove_dc:
filt[0, 0] = 0.0
field = np.fft.ifft2(F * filt).real
field -= field.min()
field /= (field.max() + 1e-9)
return field
def bilinear_sample(field, xs, ys):
x0 = np.floor(xs).astype(np.int64) % W
x1 = (x0 + 1) % W
y0 = np.clip(np.floor(ys).astype(np.int64), 0, H - 1)
y1 = np.clip(y0 + 1, 0, H - 1)
fx = xs - np.floor(xs)
fy = ys - np.floor(ys)
v00 = field[y0, x0]
v10 = field[y0, x1]
v01 = field[y1, x0]
v11 = field[y1, x1]
return v00 * (1 - fx) * (1 - fy) + v10 * fx * (1 - fy) + v01 * (1 - fx) * fy + v11 * fx * fy
def ramp(t, stops):
out = np.zeros(t.shape + (3,), dtype=np.float64)
for i in range(len(stops) - 1):
t0, c0 = stops[i]
t1, c1 = stops[i + 1]
seg = (t >= t0) & (t <= t1)
if not np.any(seg):
continue
local = (t[seg] - t0) / (t1 - t0)
for ch in range(3):
out[seg, ch] = c0[ch] + (c1[ch] - c0[ch]) * local
return out
yy, xx = np.mgrid[0:H, 0:W].astype(np.float64)
print("generating warp fields...")
warp_x = (fft_field(3.0, seed=101) - 0.5) * 160
warp_y = (fft_field(3.0, seed=102) - 0.5) * 80
print("generating storm vortices...")
# Each vortex spirals nearby coordinates around its center before bands/turbulence
# are sampled through them, so bands visibly swirl into the storm rather than sitting
# as flat stripes underneath a cosmetic overlay.
vortex_defs = [
{"cx": W * 0.50, "cy": H * 0.50, "radius": W * 0.09, "strength": 2.6, "dir": 1},
{"cx": W * 0.20, "cy": H * 0.30, "radius": W * 0.05, "strength": 1.8, "dir": -1},
{"cx": W * 0.78, "cy": H * 0.68, "radius": W * 0.045, "strength": -2.0, "dir": 1},
]
vx, vy = xx.copy(), yy.copy()
falloffs = []
for v in vortex_defs:
dxv = xx - v["cx"]
dxv -= W * np.round(dxv / W) # shortest signed horizontal wrap distance
dyv = yy - v["cy"]
rr = np.sqrt(dxv ** 2 + dyv ** 2)
falloff = np.exp(-((rr / v["radius"]) ** 2))
falloffs.append(falloff)
theta = np.arctan2(dyv, dxv) + v["dir"] * v["strength"] * falloff
vx = np.where(falloff > 0.01, v["cx"] + rr * np.cos(theta), vx)
vy = np.where(falloff > 0.01, v["cy"] + rr * np.sin(theta), vy)
print("generating latitude bands...")
band_count = 8
lat_norm = vy / H
lat_wobble = bilinear_sample(fft_field(2.2, seed=111), vx * 0.5 + warp_x * 1.4, vy + warp_y * 1.4)
warped_lat = lat_norm + (lat_wobble - 0.5) * 0.10
band_id = np.floor(warped_lat * band_count)
band_frac = warped_lat * band_count - band_id
# Wide, soft transition zone (most of each band's width) so bands blend into each
# other like atmospheric flow rather than reading as flat-shaded stripes.
band_edge = np.clip(np.minimum(band_frac, 1 - band_frac) / 0.42, 0.0, 1.0)
band_edge = band_edge * band_edge * (3 - 2 * band_edge) # smoothstep
rng_bands = np.random.default_rng(112)
band_shade = rng_bands.uniform(-1, 1, band_count + 2)
band_shade_field = band_shade[np.clip(band_id.astype(np.int64), 0, band_count + 1)]
turbulence = bilinear_sample(fft_field(1.4, seed=121), vx + warp_x * 0.6, vy + warp_y * 0.6)
value = np.clip(0.5 + band_shade_field * 0.13 * (0.4 + 0.6 * band_edge) + (turbulence - 0.5) * 0.20, 0.0, 1.0)
print("color grading...")
# Palette-matched to the current bake (dark navy/purple -> dusty pink/mauve) and to
# gen_nebula_sky.py's Orion-esque family, for thematic consistency between the two.
stops = [
(0.00, (0.05, 0.03, 0.12)),
(0.30, (0.16, 0.07, 0.24)),
(0.55, (0.38, 0.14, 0.36)),
(0.75, (0.62, 0.28, 0.46)),
(1.00, (0.86, 0.62, 0.72)),
]
color = ramp(value, stops)
print("adding hue variation...")
hue_field = bilinear_sample(fft_field(2.6, seed=131), vx * 0.7 + warp_x, vy * 0.7 + warp_y)
hue_field = (hue_field - 0.5) * 2.0
warm_tint = np.array([0.22, 0.03, -0.04])
cool_tint = np.array([-0.10, 0.08, 0.05])
hue_variation = np.where(
hue_field[..., None] > 0,
hue_field[..., None] * warm_tint[None, None, :],
-hue_field[..., None] * cool_tint[None, None, :],
)
color = np.clip(color + hue_variation * 0.16, 0.0, 1.0)
print("adding storm highlights...")
for v, falloff in zip(vortex_defs, falloffs):
hi = np.clip((falloff - 0.55) / 0.45, 0.0, 1.0) ** 1.3
tint = np.array([0.35, 0.22, 0.18]) * (0.6 if v["strength"] < 0 else 1.0)
color += hi[..., None] * tint[None, None, :]
color = np.clip(color, 0.0, 1.0)
print("baking terminator...")
u = xx / W
v = yy / H
theta = v * np.pi
phi = 2.0 * np.pi * (u - 0.5)
nx = np.sin(theta) * np.cos(phi)
ny = np.cos(theta)
nz = -np.sin(theta) * np.sin(phi)
lit_raw = nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2] # -1..1
# Soft-edged but asymmetric terminator band (favors more of the sphere reading lit,
# since a fully half-dark planet reads badly from most camera angles as a background
# decoration). Smoothstep between two dot-product thresholds.
edge0, edge1 = -0.45, 0.35
t = np.clip((lit_raw - edge0) / (edge1 - edge0), 0.0, 1.0)
lit = t * t * (3.0 - 2.0 * t)
# Pushed harder than physically correct: Planet_Surface's emissiveFactor is flat and
# uniform, which washes out real-time per-pixel lighting almost entirely in-engine, so
# the day/night contrast has to be baked directly into the diffuse texture instead.
color = color * (0.28 + 0.85 * lit)[..., None]
color = color + (lit[..., None] ** 2) * np.array([0.07, 0.03, -0.02])[None, None, :] * 0.6
color = np.clip(color, 0.0, 1.0)
print("saving planet surface...")
root = Path(__file__).resolve().parents[2]
out_paths = [
root / "Game" / "assets" / "textures" / "planet_surface.png",
root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png",
]
img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
for p in out_paths:
img.save(p)
print("saved:", p)
print("done")