mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 23:12:01 +00:00
Compare commits
9 Commits
3257f5cbcc
...
b285d012dc
| Author | SHA1 | Date | |
|---|---|---|---|
| b285d012dc | |||
| c7cd740f23 | |||
| a836a96d13 | |||
| e62d844c1d | |||
| df3e168b31 | |||
| 66b6215fdd | |||
| 02df09e40d | |||
| 6f5ce488a9 | |||
| 73fb83a0da |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 985 KiB |
@@ -3,9 +3,10 @@
|
|||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://cr3feyv7yldjc"
|
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={
|
metadata={
|
||||||
"vram_texture": false
|
"imported_formats": ["s3tc_bptc"],
|
||||||
|
"vram_texture": true
|
||||||
}
|
}
|
||||||
generator_parameters={
|
generator_parameters={
|
||||||
"md5": "44a2e26010b529339e264deecde957e0"
|
"md5": "44a2e26010b529339e264deecde957e0"
|
||||||
@@ -14,11 +15,11 @@ generator_parameters={
|
|||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/models/nebula_planet_planet_surface.png"
|
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]
|
[params]
|
||||||
|
|
||||||
compress/mode=0
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
compress/uastc_level=0
|
compress/uastc_level=0
|
||||||
@@ -40,4 +41,4 @@ process/normal_map_invert_y=false
|
|||||||
process/hdr_as_srgb=false
|
process/hdr_as_srgb=false
|
||||||
process/hdr_clamp_exposure=false
|
process/hdr_clamp_exposure=false
|
||||||
process/size_limit=0
|
process/size_limit=0
|
||||||
detect_3d/compress_to=1
|
detect_3d/compress_to=0
|
||||||
|
|||||||
@@ -3,19 +3,20 @@
|
|||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://damajpvo00prm"
|
uid="uid://damajpvo00prm"
|
||||||
path="res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.ctex"
|
path.s3tc="res://.godot/imported/particle_glow.png-e6c07ae4c700896e29ebe96adf51fa5c.s3tc.ctex"
|
||||||
metadata={
|
metadata={
|
||||||
"vram_texture": false
|
"imported_formats": ["s3tc_bptc"],
|
||||||
|
"vram_texture": true
|
||||||
}
|
}
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/textures/particle_glow.png"
|
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]
|
[params]
|
||||||
|
|
||||||
compress/mode=0
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=false
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
compress/uastc_level=0
|
compress/uastc_level=0
|
||||||
@@ -23,7 +24,7 @@ compress/rdo_quality_loss=0.0
|
|||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
mipmaps/generate=false
|
mipmaps/generate=true
|
||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
@@ -37,4 +38,4 @@ process/normal_map_invert_y=false
|
|||||||
process/hdr_as_srgb=false
|
process/hdr_as_srgb=false
|
||||||
process/hdr_clamp_exposure=false
|
process/hdr_clamp_exposure=false
|
||||||
process/size_limit=0
|
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 |
@@ -3,27 +3,28 @@
|
|||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://dc445qysyxwwv"
|
uid="uid://dc445qysyxwwv"
|
||||||
path="res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.ctex"
|
path.bptc="res://.godot/imported/sky_nebula.png-70f974ab520aaa5206eef67a936b662e.bptc.ctex"
|
||||||
metadata={
|
metadata={
|
||||||
"vram_texture": false
|
"imported_formats": ["s3tc_bptc"],
|
||||||
|
"vram_texture": true
|
||||||
}
|
}
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/textures/sky_nebula.png"
|
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]
|
[params]
|
||||||
|
|
||||||
compress/mode=0
|
compress/mode=2
|
||||||
compress/high_quality=false
|
compress/high_quality=true
|
||||||
compress/lossy_quality=0.7
|
compress/lossy_quality=0.7
|
||||||
compress/uastc_level=0
|
compress/uastc_level=0
|
||||||
compress/rdo_quality_loss=0.0
|
compress/rdo_quality_loss=0.0
|
||||||
compress/hdr_compression=1
|
compress/hdr_compression=1
|
||||||
compress/normal_map=0
|
compress/normal_map=0
|
||||||
compress/channel_pack=0
|
compress/channel_pack=0
|
||||||
mipmaps/generate=false
|
mipmaps/generate=true
|
||||||
mipmaps/limit=-1
|
mipmaps/limit=-1
|
||||||
roughness/mode=0
|
roughness/mode=0
|
||||||
roughness/src_normal=""
|
roughness/src_normal=""
|
||||||
@@ -37,4 +38,4 @@ process/normal_map_invert_y=false
|
|||||||
process/hdr_as_srgb=false
|
process/hdr_as_srgb=false
|
||||||
process/hdr_clamp_exposure=false
|
process/hdr_clamp_exposure=false
|
||||||
process/size_limit=0
|
process/size_limit=0
|
||||||
detect_3d/compress_to=1
|
detect_3d/compress_to=0
|
||||||
|
|||||||
@@ -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"]
|
[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"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
|
||||||
size = Vector3(28, 1, 40)
|
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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
|
||||||
shape = SubResource("BoxShape3D_slab")
|
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="."]
|
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
|
||||||
shape = SubResource("BoxShape3D_side_wall")
|
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="."]
|
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
|
||||||
shape = SubResource("BoxShape3D_side_wall")
|
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="."]
|
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
|
||||||
shape = SubResource("BoxShape3D_end_wall")
|
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="."]
|
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
|
||||||
shape = SubResource("BoxShape3D_end_wall")
|
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="."]
|
[node name="CeilingShape" type="CollisionShape3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
|
||||||
shape = SubResource("BoxShape3D_slab")
|
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
@@ -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"]
|
[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"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_nmd5r"]
|
||||||
size = Vector3(3.5, 1.5, 0.1)
|
size = Vector3(3.5, 1.5, 0.1)
|
||||||
|
|
||||||
[node name="Goal" type="Area3D"]
|
[node name="Goal" type="Area3D"]
|
||||||
script = ExtResource("1_v8ikr")
|
script = ExtResource("1_v8ikr")
|
||||||
|
|
||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
|
||||||
mesh = SubResource("BoxMesh_4ngif")
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
shape = SubResource("BoxShape3D_nmd5r")
|
shape = SubResource("BoxShape3D_nmd5r")
|
||||||
|
|
||||||
|
|||||||
+15
-11
@@ -18,13 +18,14 @@ config/name="Cosmic Clash"
|
|||||||
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
|
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
|
||||||
config/version="0.0.1"
|
config/version="0.0.1"
|
||||||
run/main_scene="uid://bcq14356s3e2i"
|
run/main_scene="uid://bcq14356s3e2i"
|
||||||
run/main_scene.training="res://scenes/training.tscn"
|
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
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]
|
[display]
|
||||||
|
|
||||||
@@ -34,10 +35,17 @@ window/size/mode=2
|
|||||||
window/stretch/mode="viewport"
|
window/stretch/mode="viewport"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
|
[editor_plugins]
|
||||||
|
|
||||||
|
enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
|
||||||
|
|
||||||
[input]
|
[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={
|
move_forward={
|
||||||
"deadzone": 0.2,
|
"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)
|
"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]
|
[physics]
|
||||||
|
|
||||||
common/physics_interpolation=true
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
|
common/physics_interpolation=true
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
anti_aliasing/quality/msaa_3d=2
|
anti_aliasing/quality/msaa_3d=2
|
||||||
anti_aliasing/quality/screen_space_aa=1
|
anti_aliasing/quality/screen_space_aa=1
|
||||||
|
anti_aliasing/quality/use_debanding=true
|
||||||
[autoload]
|
|
||||||
|
|
||||||
GameSettings="*res://scripts/game_settings.gd"
|
|
||||||
VideoSettings="*res://scripts/video_settings.gd"
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ sky_material = SubResource("ShaderMaterial_stars")
|
|||||||
[sub_resource type="Environment" id="Environment_space"]
|
[sub_resource type="Environment" id="Environment_space"]
|
||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_space")
|
sky = SubResource("Sky_space")
|
||||||
sdfgi_enabled = true
|
|
||||||
ambient_light_source = 2
|
ambient_light_source = 2
|
||||||
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
|
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
|
||||||
ambient_light_energy = 0.4
|
ambient_light_energy = 0.4
|
||||||
@@ -58,11 +57,19 @@ adjustment_saturation = 1.08
|
|||||||
script = ExtResource("1_iywne")
|
script = ExtResource("1_iywne")
|
||||||
|
|
||||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
[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="."]
|
[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)
|
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
|
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="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_space")
|
environment = SubResource("Environment_space")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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="Script" path="res://scripts/arena.gd" id="1_iywne"]
|
||||||
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
|
[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="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="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="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"]
|
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_nebula"]
|
||||||
panorama = ExtResource("7_nebula")
|
panorama = ExtResource("7_nebula")
|
||||||
@@ -18,7 +19,6 @@ sky_material = SubResource("PanoramaSkyMaterial_nebula")
|
|||||||
[sub_resource type="Environment" id="Environment_nebula"]
|
[sub_resource type="Environment" id="Environment_nebula"]
|
||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_nebula")
|
sky = SubResource("Sky_nebula")
|
||||||
sdfgi_enabled = true
|
|
||||||
ambient_light_source = 2
|
ambient_light_source = 2
|
||||||
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
|
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
|
||||||
ambient_light_energy = 0.45
|
ambient_light_energy = 0.45
|
||||||
@@ -39,19 +39,16 @@ adjustment_brightness = 1.0
|
|||||||
adjustment_contrast = 1.05
|
adjustment_contrast = 1.05
|
||||||
adjustment_saturation = 1.08
|
adjustment_saturation = 1.08
|
||||||
|
|
||||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dust"]
|
[sub_resource type="ShaderMaterial" id="ShaderMaterial_dust"]
|
||||||
transparency = 1
|
shader = ExtResource("12_dust_shader")
|
||||||
shading_mode = 0
|
shader_parameter/albedo_tex = ExtResource("10_dust")
|
||||||
billboard_mode = 1
|
shader_parameter/core_bias_color = Color(1, 0.6, 0.85, 1)
|
||||||
vertex_color_use_as_albedo = true
|
shader_parameter/core_bias_amount = 0.35
|
||||||
albedo_color = Color(1, 0.75, 0.9, 1)
|
shader_parameter/emission_strength = 2.5
|
||||||
albedo_texture = ExtResource("10_dust")
|
shader_parameter/soft_fade_distance = 1.0
|
||||||
emission_enabled = true
|
|
||||||
emission = Color(1, 0.6, 0.85, 1)
|
|
||||||
emission_energy_multiplier = 2.5
|
|
||||||
|
|
||||||
[sub_resource type="QuadMesh" id="QuadMesh_dust"]
|
[sub_resource type="QuadMesh" id="QuadMesh_dust"]
|
||||||
material = SubResource("StandardMaterial3D_dust")
|
material = SubResource("ShaderMaterial_dust")
|
||||||
size = Vector2(0.4, 0.4)
|
size = Vector2(0.4, 0.4)
|
||||||
|
|
||||||
[sub_resource type="Gradient" id="Gradient_dust"]
|
[sub_resource type="Gradient" id="Gradient_dust"]
|
||||||
@@ -82,12 +79,20 @@ color_ramp = SubResource("GradientTexture1D_dust")
|
|||||||
script = ExtResource("1_iywne")
|
script = ExtResource("1_iywne")
|
||||||
|
|
||||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
[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="."]
|
[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)
|
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)
|
light_color = Color(0.85, 0.75, 1, 1)
|
||||||
shadow_enabled = true
|
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="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_nebula")
|
environment = SubResource("Environment_nebula")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,7 +14,6 @@ sky_material = SubResource("ShaderMaterial_asteroid")
|
|||||||
[sub_resource type="Environment" id="Environment_asteroid"]
|
[sub_resource type="Environment" id="Environment_asteroid"]
|
||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_asteroid")
|
sky = SubResource("Sky_asteroid")
|
||||||
sdfgi_enabled = true
|
|
||||||
ambient_light_source = 2
|
ambient_light_source = 2
|
||||||
ambient_light_color = Color(0.6, 0.45, 0.3, 1)
|
ambient_light_color = Color(0.6, 0.45, 0.3, 1)
|
||||||
ambient_light_energy = 0.3
|
ambient_light_energy = 0.3
|
||||||
@@ -39,12 +38,20 @@ adjustment_saturation = 1.08
|
|||||||
script = ExtResource("1_iywne")
|
script = ExtResource("1_iywne")
|
||||||
|
|
||||||
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
|
[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="."]
|
[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)
|
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)
|
light_color = Color(1, 0.85, 0.65, 1)
|
||||||
shadow_enabled = true
|
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="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_asteroid")
|
environment = SubResource("Environment_asteroid")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -13,18 +13,31 @@ const INNER_HEIGHT := 12.0
|
|||||||
# goal and the wall for a ball to ramp across before reaching the sensor.
|
# goal and the wall for a ball to ramp across before reaching the sensor.
|
||||||
const GOAL_LINE_Z := INNER_HALF_Z
|
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
|
# 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
|
# 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
|
# corner curves spanning the four vertical wall-wall edges, base fillets
|
||||||
# easing the floor into every wall. Everything is generated in _ready from
|
# easing the floor into every wall, and ceiling fillets easing every wall
|
||||||
# these constants, but collision and visuals deliberately differ:
|
# 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
|
# - 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
|
# 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
|
# stable contact normals (the wall-contact reward reads them), and 1 m of
|
||||||
# thickness is tunnel-proof at ball speeds;
|
# thickness is tunnel-proof at ball speeds;
|
||||||
# - visuals are single smooth ArrayMesh surfaces (one per corner plus one
|
# - visuals are one merged shell (see _build_visual_shell) — proper curved
|
||||||
# for all fillets) — proper curved normals, no seams or double-tinted
|
# normals, and every surface drawn exactly once.
|
||||||
# overlaps, one draw call each.
|
# 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
|
# The corner curves reach at most the chord plane
|
||||||
# |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS.
|
# |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS.
|
||||||
const CORNER_RADIUS := 4.0
|
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
|
# 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.
|
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
|
||||||
const GOAL_MOUTH_HALF_WIDTH := 2.5
|
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
|
# 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.
|
# R * (1 - cos(45° / N)): under 4 cm for both radii, invisible to the ball.
|
||||||
const CORNER_SEGMENTS := 6
|
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).
|
# ball can sit at most ~4 cm proud of the drawn surface, which never reads).
|
||||||
const CORNER_VISUAL_ARCS := 16
|
const CORNER_VISUAL_ARCS := 16
|
||||||
const FILLET_VISUAL_ARCS := 8
|
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
|
# 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).
|
# y -1..12 (flush with the floor slab's bottom and the ceiling slab's top).
|
||||||
const SURFACE_THICKNESS := 1.0
|
const SURFACE_THICKNESS := 1.0
|
||||||
const WALL_HEIGHT := INNER_HEIGHT + 1.0
|
const WALL_HEIGHT := INNER_HEIGHT + 1.0
|
||||||
const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5
|
const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5
|
||||||
|
|
||||||
@onready var _wall_pos_x: MeshInstance3D = $WallPosXMesh
|
# Where the flat deck/ceiling stops and the fillets take over, and where the
|
||||||
@onready var _wall_neg_x: MeshInstance3D = $WallNegXMesh
|
# fillets hand over to the wall panels. Every piece of the shell is cut to
|
||||||
@onready var _wall_pos_z: MeshInstance3D = $WallPosZMesh
|
# these so no two surfaces overlap.
|
||||||
@onready var _wall_neg_z: MeshInstance3D = $WallNegZMesh
|
const FLAT_HALF_X := INNER_HALF_X - BASE_RADIUS
|
||||||
@onready var _ceiling: MeshInstance3D = $CeilingMesh
|
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
|
const BASE_FILLET := 1.0
|
||||||
# rule as the walls: {mesh, point (on the 45° tangent plane), outward}.
|
const CEILING_FILLET := -1.0
|
||||||
var _corner_visuals: Array[Dictionary] = []
|
|
||||||
|
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:
|
func _ready() -> void:
|
||||||
add_to_group("arena_boundary")
|
add_to_group("arena_boundary")
|
||||||
_build_corner_curves()
|
_build_corner_colliders()
|
||||||
_build_base_fillets()
|
_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,
|
# 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
|
||||||
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
|
# 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
|
# wall there (see GOAL_MOUTH_HALF_WIDTH / _fillet_runs), so a shot heading
|
||||||
# heading straight for the net doesn't feel a phantom sideways tug.
|
# straight for the net doesn't feel a phantom sideways tug.
|
||||||
if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH:
|
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
|
||||||
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:
|
func _process(_delta: float) -> void:
|
||||||
# The translucent field material tints everything behind it, so any face
|
# The field shader fades out any facet the camera has crossed to the
|
||||||
# the camera has crossed to the outside of is hidden entirely — looking
|
# outside of, so looking into the arena from outside stays clear. It does
|
||||||
# into the arena from outside stays clear, while faces seen from inside
|
# that per-pixel from this one uniform, which is what lets the whole
|
||||||
# keep their tint. Collision is untouched; only the meshes toggle.
|
# 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()
|
var camera := get_viewport().get_camera_3d()
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return # headless (RL/CI) has no camera
|
return # headless (RL/CI) has no camera
|
||||||
var p := to_local(camera.global_position)
|
_field_material.set_shader_parameter("camera_local_pos", 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
|
|
||||||
|
|
||||||
|
|
||||||
# Vertical quarter-cylinder curves across the four wall-wall corners, faced
|
# --- shared layout -----------------------------------------------------------
|
||||||
# with the walls' translucent field material.
|
# Consumed by both the collider rings and the visual shell, so the two can
|
||||||
func _build_corner_curves() -> void:
|
# never end up describing different geometry.
|
||||||
var field_material: Material = (_wall_pos_x.mesh as BoxMesh).material
|
|
||||||
|
|
||||||
|
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
|
var arc_step := (PI / 2.0) / CORNER_SEGMENTS
|
||||||
# Wide enough that adjacent tangent segments overlap instead of gapping.
|
# Wide enough that adjacent tangent segments overlap instead of gapping.
|
||||||
var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4
|
var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4
|
||||||
for sx in [-1.0, 1.0]:
|
for sx in [-1.0, 1.0]:
|
||||||
for sz in [-1.0, 1.0]:
|
for sz in [-1.0, 1.0]:
|
||||||
var arc_centre := Vector3(
|
var arc_centre := _corner_centre(sx, sz)
|
||||||
sx * (INNER_HALF_X - CORNER_RADIUS),
|
|
||||||
0.0,
|
|
||||||
sz * (INNER_HALF_Z - CORNER_RADIUS)
|
|
||||||
)
|
|
||||||
for i in CORNER_SEGMENTS:
|
for i in CORNER_SEGMENTS:
|
||||||
# Angle sweeps the quarter arc from facing the ±x wall (0)
|
# Angle sweeps the quarter arc from facing the ±x wall (0)
|
||||||
# to facing the ±z wall (90°); segments are tangent at
|
# 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,
|
arc_centre + outward * CORNER_RADIUS + Vector3.UP * WALL_CENTRE_Y,
|
||||||
outward, Vector3.UP, face_width, WALL_HEIGHT
|
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
|
# Quarter-cylinder fillets easing every wall into the floor (rise +1) or the
|
||||||
# the curve doesn't read as a hollow tube from above the (hidden) ceiling.
|
# ceiling (rise -1), with torus sections wrapping each corner curve so the
|
||||||
func _add_corner_visual(sx: float, sz: float, arc_centre: Vector3, material: Material) -> void:
|
# side- and end-wall runs join with no exposed end face.
|
||||||
var st := SurfaceTool.new()
|
func _build_fillet_colliders(rise: float) -> void:
|
||||||
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
|
|
||||||
var arc_step := (PI / 2.0) / BASE_SEGMENTS
|
var arc_step := (PI / 2.0) / BASE_SEGMENTS
|
||||||
var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3
|
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
|
for run in _fillet_runs(rise):
|
||||||
# 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:
|
|
||||||
var wall_out: Vector3 = run["wall_out"]
|
var wall_out: Vector3 = run["wall_out"]
|
||||||
var run_dir: Vector3 = run["run_dir"]
|
var run_dir: Vector3 = run["run_dir"]
|
||||||
var centre: Vector3 = run["centre"]
|
var centre: Vector3 = run["centre"]
|
||||||
var length: float = run["length"]
|
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:
|
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_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 sx in [-1.0, 1.0]:
|
||||||
for sz in [-1.0, 1.0]:
|
for sz in [-1.0, 1.0]:
|
||||||
_add_fillet_corner_wrap(st, sx, sz)
|
_add_wrap_colliders(sx, sz, rise)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Torus section carrying the fillet around a corner curve's base: the fillet
|
# Torus section carrying a fillet around a corner curve. With u(phi) the
|
||||||
# profile swept along the corner arc, meeting the straight runs flush at both
|
# horizontal radial direction from the corner arc's centre, the surface is
|
||||||
# ends. Sweep position: with u(phi) the horizontal radial direction from the
|
# P(phi, theta) = origin + u * (CORNER_RADIUS - BASE_RADIUS
|
||||||
# corner arc's centre, the surface is
|
# + BASE_RADIUS * sin(theta))
|
||||||
# P(phi, theta) = centre + u * (CORNER_RADIUS - BASE_RADIUS
|
# + UP * rise * BASE_RADIUS * (1 - cos(theta))
|
||||||
# + BASE_RADIUS * sin(theta)) + UP * BASE_RADIUS * (1 - cos(theta))
|
# whose outward (into-material) normal is
|
||||||
# whose outward (into-material) normal is u * sin(theta) - UP * cos(theta).
|
# u * sin(theta) - UP * rise * cos(theta).
|
||||||
func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
|
# Note `rise` enters the position and the normal with opposite signs.
|
||||||
var origin := Vector3(sx * (INNER_HALF_X - CORNER_RADIUS), 0.0, sz * (INNER_HALF_Z - CORNER_RADIUS))
|
func _add_wrap_colliders(sx: float, sz: float, rise: float) -> void:
|
||||||
var axis_radius := CORNER_RADIUS - BASE_RADIUS
|
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
|
||||||
var path_step := (PI / 2.0) / WRAP_SEGMENTS
|
var path_step := (PI / 2.0) / WRAP_SEGMENTS
|
||||||
var profile_step := (PI / 2.0) / BASE_SEGMENTS
|
var profile_step := (PI / 2.0) / BASE_SEGMENTS
|
||||||
var face_width := 2.0 * BASE_RADIUS * tan(profile_step / 2.0) + 0.3
|
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))
|
var along := Vector3(-sx * sin(phi), 0.0, sz * cos(phi))
|
||||||
for j in BASE_SEGMENTS:
|
for j in BASE_SEGMENTS:
|
||||||
var theta := (j + 0.5) * profile_step
|
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 \
|
var face_centre := origin + u * ring_radius \
|
||||||
+ Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta)))
|
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta)))
|
||||||
var outward := u * sin(theta) - Vector3.UP * 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
|
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)
|
_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
|
# One flat tangent collider segment of a curved surface: a box whose inner
|
||||||
@@ -342,6 +318,282 @@ func _add_curve_collider(
|
|||||||
add_child(collision)
|
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
|
# 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).
|
# (Godot front faces wind clockwise when seen from the normal side).
|
||||||
func _add_quad(
|
func _add_quad(
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
class_name ArenaRegistry
|
class_name ArenaRegistry
|
||||||
|
|
||||||
# Single source of truth for available arenas: the Free Play menu dropdown
|
# Single source of truth for available arenas: the Free Play menu dropdown
|
||||||
# lists these, and Match/Spectate pick one at random each session. Training
|
# lists all of these, and Match/Spectate pick at random each session from
|
||||||
# is exempt — training.tscn keeps its own fixed arena_01 child.
|
# 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 := [
|
const ARENAS := [
|
||||||
{"name": "Starfield", "path": "res://scenes/arena_01.tscn"},
|
{"name": "Starfield (Floor Goals)", "path": "res://scenes/arena_01.tscn", "random": true},
|
||||||
{"name": "Nebula", "path": "res://scenes/arena_02.tscn"},
|
{"name": "Starfield (Elevated Goals)", "path": "res://scenes/arena_01_elevated.tscn", "random": false},
|
||||||
{"name": "Asteroid Field", "path": "res://scenes/arena_03.tscn"},
|
{"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:
|
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"]
|
||||||
|
|||||||
@@ -10,12 +10,114 @@ extends Area3D
|
|||||||
|
|
||||||
signal goal_scored(team: int)
|
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():
|
func _ready():
|
||||||
# Group lets AI controllers and game modes discover goals
|
# Group lets AI controllers and game modes discover goals
|
||||||
add_to_group("goal")
|
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):
|
func _on_body_entered(body):
|
||||||
if body.is_in_group("ball"):
|
if body.is_in_group("ball"):
|
||||||
goal_scored.emit(team)
|
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
|
||||||
|
|||||||
@@ -120,8 +120,16 @@ var _ai_overrides := {}
|
|||||||
# arena spawn instead of drifting into the play area as a stray obstacle.
|
# arena spawn instead of drifting into the play area as a stray obstacle.
|
||||||
var _inert_ships: Array[Ship] = []
|
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:
|
func _start() -> void:
|
||||||
|
_elevated = (arena.get_node("Boundary") as ArenaBoundary).goal_mode == ArenaBoundary.GoalMode.ELEVATED
|
||||||
_parse_eval_args()
|
_parse_eval_args()
|
||||||
_parse_curriculum_args()
|
_parse_curriculum_args()
|
||||||
spawn_ball()
|
spawn_ball()
|
||||||
@@ -371,9 +379,12 @@ func _place_ball_random() -> void:
|
|||||||
func _place_ball_near_goal() -> void:
|
func _place_ball_near_goal() -> void:
|
||||||
var goal := _goal_for_team(0) if randf() < attack_goal_bias else _goal_for_team(1)
|
var goal := _goal_for_team(0) if randf() < attack_goal_bias else _goal_for_team(1)
|
||||||
var toward_centre := -signf(goal.global_position.z)
|
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(
|
var position := Vector3(
|
||||||
randf_range(-4.0, 4.0),
|
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)
|
goal.global_position.z + toward_centre * randf_range(3.0, 6.0)
|
||||||
)
|
)
|
||||||
var to_goal := (goal.global_position - position).normalized()
|
var to_goal := (goal.global_position - position).normalized()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bogr8ydj6q8rp
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bqe75ud45htst
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bbueqcqp62gqo
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b6p22qfcd7qmt
|
||||||
@@ -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:
|
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`.
|
- [ ] 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.
|
- [ ] 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).
|
- [ ] 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)
|
## 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.
|
- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score.
|
||||||
- [ ] C# backend / online servers per README roadmap (not started).
|
- [ ] 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).
|
- [ ] 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."
|
|
||||||
|
|||||||
@@ -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)
|
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
|
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
|
# 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.
|
# bright core), fading into the darker starfield toward the poles/edges.
|
||||||
cx, cy = W * 0.42, H * 0.46
|
cx, cy = W * 0.42, H * 0.46
|
||||||
@@ -104,6 +112,22 @@ def ramp(t):
|
|||||||
|
|
||||||
color = ramp(density)
|
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.
|
# Blue-violet outer haze wash, additive, strongest away from the bright core.
|
||||||
haze_color = np.array([0.10, 0.11, 0.30])
|
haze_color = np.array([0.10, 0.11, 0.30])
|
||||||
haze_amount = (haze * 0.5 + 0.5) * (1.0 - mask) * 0.35
|
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]
|
dust_strength = (dust * mask * 0.85)[..., None]
|
||||||
color *= (1.0 - dust_strength * 0.9)
|
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)
|
color = np.clip(color, 0.0, 1.0)
|
||||||
|
|
||||||
print("adding stars...")
|
print("adding stars...")
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user