Compare commits

...

6 Commits

Author SHA1 Message Date
Josh Creek 8fa40769a8 feat(rendering): add cinematic post processing 2026-08-07 22:54:01 +01:00
Josh Creek 0a7eb0b742 feat(ui): theme HUD with Orbitron typography 2026-08-07 22:48:56 +01:00
Josh Creek 8bd65290fc feat(goals): add cinematic celebration sequence 2026-08-07 22:45:33 +01:00
Josh Creek 28eb45338a feat(feedback): add responsive camera and impacts 2026-08-07 22:37:49 +01:00
Josh Creek 78ef0cb1f8 feat(vfx): animate movement and scoring effects 2026-08-07 22:31:17 +01:00
Josh Creek 8d4078989c feat(arena): add grounded lighting and asteroid dressing 2026-08-07 22:26:02 +01:00
24 changed files with 930 additions and 38 deletions
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Orbitron Project Authors (https://github.com/theleagueof/orbitron), with Reserved Font Name: "Orbitron"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bhbpb712ouagv"
path="res://.godot/imported/Orbitron-Medium.ttf-0d3cd88166f12a3096066c2eef570744.fontdata"
[deps]
source_file="res://assets/fonts/Orbitron-Medium.ttf"
dest_files=["res://.godot/imported/Orbitron-Medium.ttf-0d3cd88166f12a3096066c2eef570744.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+51 -1
View File
@@ -1,9 +1,10 @@
[gd_scene load_steps=9 format=3 uid="uid://c8kak2l3m4n5"]
[gd_scene load_steps=10 format=3 uid="uid://c8kak2l3m4n5"]
[ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"]
[ext_resource type="Script" path="res://scripts/hud_attitude_indicator.gd" id="2_adi"]
[ext_resource type="Script" path="res://scripts/hud_heading_tape.gd" id="3_tape"]
[ext_resource type="Script" path="res://scripts/hud_gauge.gd" id="4_gauge"]
[ext_resource type="Theme" path="res://themes/hud_theme.tres" id="5_theme"]
[sub_resource type="LabelSettings" id="LabelSettings_hud"]
font_size = 32
@@ -50,6 +51,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("5_theme")
[node name="ScoreboardPanel" type="PanelContainer" parent="Control"]
layout_mode = 1
@@ -118,6 +120,54 @@ label_settings = SubResource("LabelSettings_kickoff")
horizontal_alignment = 1
vertical_alignment = 1
[node name="ImpactFlash" type="ColorRect" parent="Control"]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.72, 0.9, 1, 0)
[node name="GoalCelebration" type="Control" parent="Control"]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Tint" type="ColorRect" parent="Control/GoalCelebration"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Center" type="CenterContainer" parent="Control/GoalCelebration"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="GoalTitle" type="Label" parent="Control/GoalCelebration/Center"]
layout_mode = 2
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8)
theme_override_constants/shadow_offset_x = 5
theme_override_constants/shadow_offset_y = 5
theme_override_font_sizes/font_size = 82
text = "GOAL!"
horizontal_alignment = 1
[node name="ResultOverlay" type="Control" parent="Control"]
visible = false
layout_mode = 1
+31 -1
View File
@@ -1,7 +1,10 @@
[gd_scene load_steps=4 format=3]
[gd_scene load_steps=7 format=3]
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
[ext_resource type="Shader" path="res://shaders/sky_asteroid_field.gdshader" id="7_asteroid"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_station.glb" id="8_station"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_debris.glb" id="9_debris"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_planet.glb" id="10_planet"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_asteroid"]
shader = ExtResource("7_asteroid")
@@ -22,3 +25,30 @@ light_color = Color(1, 0.85, 0.65, 1)
light_color = Color(0.55, 0.4, 0.3, 1)
[node name="Decoration" type="Node3D" parent="."]
[node name="MiningStationA" parent="Decoration" instance=ExtResource("8_station")]
transform = Transform3D(2.1, 0, 0, 0, 2.1, 0, 0, 0, 2.1, 44, 8, -12)
rotation = Vector3(0.08, -0.72, 0.02)
[node name="MiningStationB" parent="Decoration" instance=ExtResource("8_station")]
transform = Transform3D(1.45, 0, 0, 0, 1.45, 0, 0, 0, 1.45, -48, 15, 22)
rotation = Vector3(-0.06, 1.2, 0.08)
[node name="AsteroidClusterA" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(2.4, 0, 0, 0, 2.4, 0, 0, 0, 2.4, -35, 12, -25)
rotation = Vector3(0.4, 0.9, -0.2)
[node name="AsteroidClusterB" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(1.7, 0, 0, 0, 1.7, 0, 0, 0, 1.7, 38, 20, 28)
rotation = Vector3(-0.25, 2.1, 0.35)
[node name="AsteroidClusterC" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(1.2, 0, 0, 0, 1.2, 0, 0, 0, 1.2, 18, 7, -48)
rotation = Vector3(0.7, -0.5, 0.1)
[node name="AsteroidClusterD" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -20, 25, 50)
rotation = Vector3(-0.3, -1.1, -0.2)
[node name="DistantPlanet" parent="Decoration" instance=ExtResource("10_planet")]
transform = Transform3D(24, 0, 0, 0, 24, 0, 0, 0, 24, 95, 48, -145)
+55
View File
@@ -31,6 +31,23 @@ adjustment_enabled = true
adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
ssao_enabled = true
ssao_radius = 2.5
ssao_intensity = 2.0
ssao_power = 1.5
ssao_detail = 0.75
ssao_horizon = 0.06
ssao_sharpness = 0.98
ssao_light_affect = 0.15
ssil_enabled = true
ssil_radius = 4.0
ssil_intensity = 1.25
ssil_sharpness = 0.98
ssil_normal_rejection = 1.0
sdfgi_enabled = true
sdfgi_use_occlusion = true
sdfgi_bounce_feedback = 0.5
sdfgi_read_sky_light = true
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
@@ -49,6 +66,44 @@ light_color = Color(0.55, 0.65, 0.85, 1)
light_energy = 0.4
shadow_enabled = false
[node name="PitchLights" type="Node3D" parent="."]
[node name="Team0GoalLight" type="OmniLight3D" parent="PitchLights"]
position = Vector3(0, 4.5, 22)
light_color = Color(0.48, 0.18, 0.88, 1)
light_energy = 5.0
light_bake_mode = 1
omni_range = 16.0
omni_attenuation = 1.35
shadow_enabled = true
[node name="Team1GoalLight" type="OmniLight3D" parent="PitchLights"]
position = Vector3(0, 4.5, -22)
light_color = Color(0.24, 0.86, 0.42, 1)
light_energy = 5.0
light_bake_mode = 1
omni_range = 16.0
omni_attenuation = 1.35
shadow_enabled = true
[node name="MidfieldLightA" type="OmniLight3D" parent="PitchLights"]
position = Vector3(-10, 7, 0)
light_color = Color(0.38, 0.62, 1, 1)
light_energy = 3.5
light_bake_mode = 1
omni_range = 15.0
omni_attenuation = 1.5
shadow_enabled = true
[node name="MidfieldLightB" type="OmniLight3D" parent="PitchLights"]
position = Vector3(10, 7, 0)
light_color = Color(0.38, 0.62, 1, 1)
light_energy = 3.5
light_bake_mode = 1
omni_range = 15.0
omni_attenuation = 1.5
shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_base")
+27 -1
View File
@@ -1,9 +1,35 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/ship_camera.gd" id="1_rig"]
[ext_resource type="Shader" path="res://shaders/post_process.gdshader" id="2_post"]
[sub_resource type="CameraAttributesPractical" id="CameraAttributes_gameplay"]
dof_blur_far_enabled = true
dof_blur_far_distance = 24.0
dof_blur_far_transition = 14.0
dof_blur_near_enabled = true
dof_blur_near_distance = 1.25
dof_blur_near_transition = 0.75
dof_blur_amount = 0.08
[sub_resource type="ShaderMaterial" id="ShaderMaterial_post"]
shader = ExtResource("2_post")
[node name="ShipCameraRig" type="Node3D"]
script = ExtResource("1_rig")
[node name="Camera3D" type="Camera3D" parent="."]
current = true
attributes = SubResource("CameraAttributes_gameplay")
[node name="PostProcess" type="CanvasLayer" parent="."]
layer = 0
[node name="PostFX" type="ColorRect" parent="PostProcess"]
material = SubResource("ShaderMaterial_post")
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
+51
View File
@@ -18,6 +18,10 @@ class_name HUDController
@onready var result_panel = get_node_or_null("Control/ResultOverlay/Center/ResultPanel")
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/ResultLabel")
@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/FinalScoreLabel")
@onready var impact_flash = get_node_or_null("Control/ImpactFlash")
@onready var goal_celebration = get_node_or_null("Control/GoalCelebration")
@onready var goal_tint = get_node_or_null("Control/GoalCelebration/Tint")
@onready var goal_title = get_node_or_null("Control/GoalCelebration/Center/GoalTitle")
@onready var heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
@@ -28,6 +32,8 @@ class_name HUDController
var ship: Node
var _last_score := {0: 0, 1: 0}
var _impact_flash_tween: Tween
var _goal_tween: Tween
func _ready():
# Wait one frame to ensure ship is ready and added to group
@@ -170,6 +176,51 @@ func _flash_score_label(label: Label) -> void:
tween.tween_property(label, "scale", Vector2(1.4, 1.4), 0.1)
tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.2)
func flash_impact(intensity: float) -> void:
if not impact_flash or not is_instance_valid(impact_flash):
return
if _impact_flash_tween and _impact_flash_tween.is_valid():
_impact_flash_tween.kill()
impact_flash.color = Color(0.72, 0.9, 1.0, lerpf(0.06, 0.24, intensity))
impact_flash.visible = true
_impact_flash_tween = create_tween()
_impact_flash_tween.tween_property(impact_flash, "color:a", 0.0, lerpf(0.08, 0.18, intensity))
_impact_flash_tween.tween_callback(func(): impact_flash.visible = false)
func show_goal_celebration(scoring_team: int) -> void:
if not goal_celebration or not goal_title or not goal_tint:
return
if _goal_tween and _goal_tween.is_valid():
_goal_tween.kill()
var tint: Color = TeamColors.TEAM_COLORS[scoring_team]
goal_tint.color = Color(tint.r, tint.g, tint.b, 0.34)
goal_title.text = "%s GOAL!" % TeamColors.TEAM_NAMES[scoring_team].to_upper()
goal_title.add_theme_color_override("font_color", tint.lightened(0.35))
goal_title.scale = Vector2(0.55, 0.55)
goal_title.modulate.a = 0.0
goal_celebration.visible = true
await get_tree().process_frame
if not is_instance_valid(goal_title):
return
goal_title.pivot_offset = goal_title.size / 2.0
_goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true)
_goal_tween.tween_property(goal_title, "scale", Vector2.ONE, 0.32).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_goal_tween.tween_property(goal_title, "modulate:a", 1.0, 0.16)
_goal_tween.tween_property(goal_tint, "color:a", 0.12, 0.7).set_delay(0.12)
func hide_goal_celebration() -> void:
if not goal_celebration or not is_instance_valid(goal_celebration):
return
if _goal_tween and _goal_tween.is_valid():
_goal_tween.kill()
_goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true)
_goal_tween.tween_property(goal_title, "modulate:a", 0.0, 0.16)
_goal_tween.tween_property(goal_tint, "color:a", 0.0, 0.2)
_goal_tween.chain().tween_callback(func(): goal_celebration.visible = false)
func _on_overtime_started():
if timer_label and is_instance_valid(timer_label):
timer_label.text = "OT"
+44
View File
@@ -17,10 +17,54 @@ extends RigidBody3D
const MAX_SPEED := 32.0
var _boundary: ArenaBoundary
var _trail: GPUParticles3D
func _ready() -> void:
_boundary = get_tree().get_first_node_in_group("arena_boundary")
if DisplayServer.get_name() == "headless":
# _integrate_forces remains active; only the render-side trail updater is
# disabled across the many parallel RL environments.
set_physics_process(false)
else:
_build_trail()
func _physics_process(_delta: float) -> void:
if _trail:
var speed_ratio := clampf(linear_velocity.length() / MAX_SPEED, 0.0, 1.0)
_trail.emitting = speed_ratio > 0.12
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
func _build_trail() -> void:
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mat.albedo_color = Color(0.55, 0.85, 1.0, 0.42)
mat.emission_enabled = true
mat.emission = Color(0.35, 0.72, 1.0)
mat.emission_energy_multiplier = 1.8
var quad := QuadMesh.new()
quad.size = Vector2(0.22, 0.22)
quad.material = mat
var process := ParticleProcessMaterial.new()
process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
process.emission_sphere_radius = 0.35
process.gravity = Vector3.ZERO
process.scale_min = 0.35
process.scale_max = 1.0
_trail = GPUParticles3D.new()
_trail.name = "BallTrail"
_trail.amount = 48
_trail.lifetime = 0.48
_trail.local_coords = false
_trail.process_material = process
_trail.draw_pass_1 = quad
_trail.visibility_aabb = AABB(Vector3(-18, -18, -18), Vector3(36, 36, 36))
_trail.emitting = false
add_child(_trail)
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
+98 -6
View File
@@ -17,6 +17,16 @@ var hud: HUDController
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
var _hit_stop_generation := 0
var _hit_stop_active := false
var _time_scale_before_hit_stop := 1.0
var _camera_rig: ShipCameraRig
var _goal_slowmo_active := false
var _time_scale_before_goal := 1.0
var _goal_in_progress := false
const GOAL_CELEBRATION_SECONDS := 1.6
const GOAL_SLOWMO_SCALE := 0.22
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
@@ -63,16 +73,58 @@ func _on_goal_scored(_conceding_team: int) -> void:
pass
# Virtual: immediate, non-presentational consequences at sensor time. Modes
# with a score/clock override this so a last-second goal is authoritative
# before the cinematic; reset/kickoff remains in _on_goal_scored afterwards.
func _on_goal_registered(_conceding_team: int) -> void:
pass
# Debounce: a fast ball can re-trigger the goal area before the deferred
# reset teleports it away, which would double-count the goal.
var _goal_cooldown := false
func _handle_goal_scored(conceding_team: int) -> void:
if _goal_cooldown:
if _goal_in_progress:
return
_goal_cooldown = true
get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false)
_on_goal_scored(conceding_team)
_goal_in_progress = true
_on_goal_registered(conceding_team)
await _play_goal_celebration(1 - conceding_team, conceding_team)
await _on_goal_scored(conceding_team)
_goal_in_progress = false
func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
# Training/headless modes never spawn a camera or HUD and must not pay a
# real-time presentation delay between episodes.
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
return
_restore_hit_stop()
_goal_slowmo_active = true
_time_scale_before_goal = Engine.time_scale
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
var goal_position := Vector3.ZERO
for goal in arena.get_goals():
if goal.team == conceding_team:
goal_position = goal.global_position
break
_camera_rig.begin_goal_cut(goal_position)
if hud:
hud.show_goal_celebration(scoring_team)
await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout
if is_instance_valid(_camera_rig):
_camera_rig.end_goal_cut()
if hud and is_instance_valid(hud):
hud.hide_goal_celebration()
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
# time scale, but restore any hit-stop that was already queued this frame.
_restore_hit_stop()
_restore_goal_slowmo()
func _restore_goal_slowmo() -> void:
if not _goal_slowmo_active:
return
Engine.time_scale = _time_scale_before_goal
_goal_slowmo_active = false
func spawn_ball() -> RigidBody3D:
@@ -101,7 +153,9 @@ func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = nu
func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
_camera_rig = rig
rig.target = target
rig.impact_feedback.connect(_on_player_impact)
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
if hud:
@@ -109,6 +163,44 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
return rig
func _on_player_impact(intensity: float) -> void:
if hud:
hud.flash_impact(intensity)
if not _goal_slowmo_active:
_run_hit_stop(intensity)
func _run_hit_stop(intensity: float) -> void:
if _goal_slowmo_active:
return
_hit_stop_generation += 1
var generation := _hit_stop_generation
if not _hit_stop_active:
_time_scale_before_hit_stop = Engine.time_scale
_hit_stop_active = true
Engine.time_scale = minf(
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
)
await get_tree().create_timer(
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
).timeout
if generation == _hit_stop_generation:
_restore_hit_stop()
func _restore_hit_stop() -> void:
if not _hit_stop_active:
return
Engine.time_scale = _time_scale_before_hit_stop
_hit_stop_active = false
func _exit_tree() -> void:
# A scene change during the unscaled timer must never strand global time.
_restore_hit_stop()
_restore_goal_slowmo()
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
# apply their own GameSettings-override logic first, which differs between
# modes (Match lets GameSettings override all three fields, Spectate only
+42
View File
@@ -26,6 +26,8 @@ const RIM_DEPTH := 0.05
const NET_SHADER_PATH := "res://shaders/goal_net.gdshader"
var _goal_burst: GPUParticles3D
func _ready():
# Group lets AI controllers and game modes discover goals
@@ -34,10 +36,13 @@ func _ready():
# headless arenas that never render it. The sensor is unaffected.
if DisplayServer.get_name() != "headless":
_build_visuals()
_build_goal_burst()
func _on_body_entered(body):
if body.is_in_group("ball"):
if _goal_burst:
_goal_burst.restart()
goal_scored.emit(team)
@@ -103,6 +108,43 @@ func _build_visuals() -> void:
add_child(visuals)
func _build_goal_burst() -> void:
var tint: Color = TeamColors.TEAM_COLORS[team % TeamColors.TEAM_COLORS.size()]
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mat.albedo_color = Color(tint.r, tint.g, tint.b, 0.9)
mat.emission_enabled = true
mat.emission = tint
mat.emission_energy_multiplier = 3.0
var quad := QuadMesh.new()
quad.size = Vector2(0.38, 0.8)
quad.material = mat
var process := ParticleProcessMaterial.new()
process.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
process.emission_box_extents = Vector3(1.7, 0.7, 0.15)
process.direction = Vector3(0, 0, -1)
process.spread = 48.0
process.initial_velocity_min = 8.0
process.initial_velocity_max = 18.0
process.gravity = Vector3(0, -2.0, 0)
process.scale_min = 0.35
process.scale_max = 1.2
_goal_burst = GPUParticles3D.new()
_goal_burst.name = "GoalBurst"
_goal_burst.position = Vector3(0, 0, -0.15)
_goal_burst.amount = 140
_goal_burst.lifetime = 1.1
_goal_burst.one_shot = true
_goal_burst.explosiveness = 0.92
_goal_burst.process_material = process
_goal_burst.draw_pass_1 = quad
_goal_burst.visibility_aabb = AABB(Vector3(-12, -8, -20), Vector3(24, 16, 24))
_goal_burst.emitting = false
add_child(_goal_burst)
# Four bars around the mouth. The uprights run the full outer height so each
# corner is covered exactly once.
func _add_frame_ring(
+1 -1
View File
@@ -73,7 +73,7 @@ func _draw() -> void:
draw_line(Vector2(-half_chord, horizon_y), Vector2(half_chord, horizon_y), LINE_COLOR, 2.0)
# Pitch ladder: rungs every 10 degrees, labelled, scrolling with pitch.
var font := ThemeDB.fallback_font
var font := hud_font()
for ladder_deg: int in [-30, -20, -10, 10, 20, 30]:
var y := horizon_y - ladder_deg * px_per_deg
if absf(y) > radius - 12.0:
+1 -1
View File
@@ -37,7 +37,7 @@ func _process(delta: float) -> void:
func _draw() -> void:
var font := ThemeDB.fallback_font
var font := hud_font()
var fraction := clampf(_value / max_value, 0.0, 1.0)
var reading := value_format % _value
+1 -1
View File
@@ -39,7 +39,7 @@ func _process(delta: float) -> void:
func _draw() -> void:
var font := ThemeDB.fallback_font
var font := hud_font()
var center_x := size.x / 2.0
var px_per_deg := size.x / VISIBLE_RANGE
+8
View File
@@ -23,3 +23,11 @@ static func angle_delta_deg(from: float, to: float) -> float:
func _smoothing_weight(delta: float) -> float:
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
# Procedural draw calls do not automatically consume a Control's theme font;
# resolve it explicitly so every instrument uses the same packaged typeface
# as ordinary Labels instead of ThemeDB's debug-looking fallback.
func hud_font() -> Font:
var font := get_theme_default_font()
return font if font else ThemeDB.fallback_font
+21 -3
View File
@@ -37,6 +37,8 @@ var match_timer: Timer
var _in_overtime := false
var _match_over := false
var _last_timer_remaining := -1
var _pending_scoring_team := -1
var _match_time_after_goal := -1.0
func _get_arena_scene_path() -> String:
@@ -79,16 +81,32 @@ func _process(_delta):
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
func _on_goal_scored(conceding_team: int) -> void:
func _on_goal_registered(conceding_team: int) -> void:
if _match_over:
return
var scoring_team := 1 - conceding_team
_record_goal(scoring_team)
_pending_scoring_team = 1 - conceding_team
_record_goal(_pending_scoring_team)
score_changed.emit(score.duplicate())
# Commit the score and freeze regulation time at sensor entry. The clock
# resumes after the cinematic, so a last-second goal cannot be discarded or
# reclassified as overtime while presentation is still running.
if match_timer and match_timer.time_left > 0.0:
_match_time_after_goal = match_timer.time_left
match_timer.stop()
func _on_goal_scored(_conceding_team: int) -> void:
if _match_over or _pending_scoring_team < 0:
return
var scoring_team := _pending_scoring_team
_pending_scoring_team = -1
if _in_overtime:
# Golden goal: the first score after a draw ends the match outright.
_end_match(scoring_team)
return
if match_timer and _match_time_after_goal > 0.0:
match_timer.start(_match_time_after_goal)
_match_time_after_goal = -1.0
await _run_kickoff_countdown()
+146
View File
@@ -97,6 +97,7 @@ signal altitude_changed(altitude: float)
signal thrust_changed(thrust_percent: float)
signal angular_velocity_changed(angular_speed: float)
signal heading_changed(heading_degrees: float)
signal ball_contact(intensity: float, world_position: Vector3)
# Performance optimization - track last emitted values to avoid unnecessary signals
var _last_speed: float = -1.0
@@ -115,6 +116,12 @@ const ANGULAR_THRESHOLD = 0.01 # rad/s
const ATTITUDE_THRESHOLD = 1.0 # degrees
const THRUST_THRESHOLD = 1.0 # percent
var _engine_cores: Array[MeshInstance3D] = []
var _engine_plumes: Array[GPUParticles3D] = []
var _turbo_flames: Array[GPUParticles3D] = []
var _engine_lights: Array[OmniLight3D] = []
var _impact_sparks: GPUParticles3D
func _ready():
# Add ship to group for instrument discovery
@@ -148,6 +155,8 @@ func _ready():
set_physics_process(false)
else:
_build_merged_hull()
_build_movement_vfx()
body_entered.connect(_on_body_entered)
func _apply_team_color() -> void:
@@ -197,10 +206,147 @@ func set_controller(new_controller: ShipController) -> void:
func _physics_process(_delta):
_update_movement_vfx()
if _has_telemetry_listeners():
_emit_telemetry_data()
func _build_movement_vfx() -> void:
for x in [-0.42, 0.42]:
var engine_pos := Vector3(x, -0.05, 1.42)
var core_mat := _vfx_material(Color(0.35, 0.85, 1.0, 1.0), 1.0)
var core_mesh := SphereMesh.new()
core_mesh.radius = 0.11
core_mesh.height = 0.22
core_mesh.material = core_mat
var core := MeshInstance3D.new()
core.name = "EngineCoreL" if x < 0.0 else "EngineCoreR"
core.position = engine_pos
core.mesh = core_mesh
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(core)
_engine_cores.append(core)
var plume := _make_particles(
"EnginePlumeL" if x < 0.0 else "EnginePlumeR",
Color(0.3, 0.82, 1.0, 0.72), Vector2(0.16, 0.42), 36, 0.24,
Vector3(0, 0, 1), 12.0, 2.0, 5.0
)
plume.position = engine_pos + Vector3(0, 0, 0.08)
add_child(plume)
_engine_plumes.append(plume)
var turbo := _make_particles(
"TurboFlameL" if x < 0.0 else "TurboFlameR",
Color(0.78, 0.34, 1.0, 0.9), Vector2(0.22, 0.72), 48, 0.18,
Vector3(0, 0, 1), 7.0, 6.0, 10.0
)
turbo.position = engine_pos + Vector3(0, 0, 0.12)
add_child(turbo)
_turbo_flames.append(turbo)
var light := OmniLight3D.new()
light.name = "EngineLightL" if x < 0.0 else "EngineLightR"
light.position = engine_pos
light.light_color = Color(0.3, 0.75, 1.0)
light.omni_range = 3.5
light.omni_attenuation = 2.0
light.shadow_enabled = false
add_child(light)
_engine_lights.append(light)
_impact_sparks = _make_particles(
"ImpactSparks", Color(1.0, 0.72, 0.22, 1.0), Vector2(0.09, 0.3),
28, 0.32, Vector3.UP, 180.0, 5.0, 12.0
)
_impact_sparks.one_shot = true
_impact_sparks.explosiveness = 1.0
_impact_sparks.local_coords = false
_impact_sparks.emitting = false
add_child(_impact_sparks)
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = Color(color.r, color.g, color.b)
mat.emission_energy_multiplier = energy
return mat
func _make_particles(
particle_name: String, color: Color, size: Vector2, amount: int,
lifetime: float, direction: Vector3, spread: float,
velocity_min: float, velocity_max: float
) -> GPUParticles3D:
var quad := QuadMesh.new()
quad.size = size
quad.material = _vfx_material(color, 2.2)
var process := ParticleProcessMaterial.new()
process.direction = direction
process.spread = spread
process.initial_velocity_min = velocity_min
process.initial_velocity_max = velocity_max
process.gravity = Vector3.ZERO
process.scale_min = 0.35
process.scale_max = 1.0
var particles := GPUParticles3D.new()
particles.name = particle_name
particles.amount = amount
particles.lifetime = lifetime
particles.local_coords = true
particles.process_material = process
particles.draw_pass_1 = quad
particles.visibility_aabb = AABB(Vector3(-3, -3, -1), Vector3(6, 6, 12))
particles.emitting = false
return particles
func _update_movement_vfx() -> void:
if _engine_plumes.is_empty():
return
# These are the two rear main engines, so lateral/vertical maneuvering jets
# must not make them flare. Reverse thrust also comes from separate attitude
# jets conceptually; only positive Z drives this rear-facing plume.
var thrust := clampf(maxf(_current_action.thrust.z, 0.0), 0.0, 1.0)
var turbo := _current_action.turbo and thrust > 0.05
for i in _engine_plumes.size():
_engine_plumes[i].emitting = thrust > 0.02
_engine_plumes[i].amount_ratio = 0.25 + thrust * 0.75
_engine_plumes[i].speed_scale = 0.65 + thrust * 0.75
_turbo_flames[i].emitting = turbo
_engine_lights[i].light_energy = 0.35 + thrust * 2.1 + (2.3 if turbo else 0.0)
var core_mat := _engine_cores[i].mesh.material as StandardMaterial3D
core_mat.emission_energy_multiplier = 0.65 + thrust * 2.0 + (2.0 if turbo else 0.0)
_engine_cores[i].scale = Vector3.ONE * (0.8 + thrust * 0.3 + (0.25 if turbo else 0.0))
func get_speed_ratio() -> float:
return clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0)
func is_turbo_active() -> bool:
return _current_action.turbo and _current_action.thrust.z > 0.05
func _on_body_entered(body: Node) -> void:
if not body is Ball:
return
var relative_speed := (linear_velocity - (body as Ball).linear_velocity).length()
var intensity := clampf(inverse_lerp(3.0, 24.0, relative_speed), 0.12, 1.0)
var contact_pos := (global_position + (body as Ball).global_position) * 0.5
if _impact_sparks:
_impact_sparks.position = to_local(contact_pos)
_impact_sparks.amount_ratio = lerpf(0.25, 1.0, intensity)
_impact_sparks.restart()
ball_contact.emit(intensity, contact_pos)
# Covers a second/AI ship with no HUD watching it - headless mode is already
# handled by disabling _physics_process entirely in _ready.
func _has_telemetry_listeners() -> bool:
+134 -3
View File
@@ -7,6 +7,7 @@ extends Node3D
# themselves are camera-free (a headless/AI ship never needs one).
signal camera_mode_changed(is_ball_cam: bool)
signal impact_feedback(intensity: float)
@export var camera_distance := 8.0
@export var camera_height := 3.0
@@ -14,22 +15,66 @@ signal camera_mode_changed(is_ball_cam: bool)
@export var orbit_smoothing := 6.0 # How fast the camera swings around the ship in ball cam
@export var look_smoothing := 20.0 # How fast the camera re-centres its look target
@export var min_camera_height := 1.0 # Keeps the camera from dipping through the arena floor
@export_group("Speed Feel")
@export var base_fov := 70.0
@export var speed_fov_add := 11.0
@export var turbo_fov_kick := 7.0
@export var turbo_distance_kick := 0.9
@export var feel_smoothing := 7.0
@export_group("Impact Shake")
@export var max_shake_offset := 0.32
@export var shake_decay := 8.0
var target: Ship
var target: Ship:
set(value):
if target == value:
return
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.disconnect(_on_target_ball_contact)
target = value
_connect_target()
var ball_cam_enabled := true
@onready var camera: Camera3D = $Camera3D
@onready var post_material: ShaderMaterial = $PostProcess/PostFX.material as ShaderMaterial
var _ball: Node3D
# Smoothed horizontal direction from ball to ship; the ball-cam orbits along
# this so the camera swings smoothly around the ship instead of chasing a
# raw position (which whips when ball and ship are close together).
var _orbit_dir := Vector3.BACK
var _turbo_blend := 0.0
var _speed_blend := 0.0
var _effective_distance := 8.0
var _shake_strength := 0.0
var _shake_noise := FastNoiseLite.new()
var _shake_time := 0.0
var _last_shake_offset := Vector3.ZERO
var _goal_cut_active := false
var _goal_cut_position := Vector3.ZERO
var _goal_cut_look_at := Vector3.ZERO
func _ready():
# Group lets the HUD discover the rig for the camera-mode instrument
add_to_group("ship_camera")
camera.fov = base_fov
_effective_distance = camera_distance
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_shake_noise.frequency = 2.5
_connect_target()
func _connect_target() -> void:
if not is_inside_tree() or not is_instance_valid(target):
return
if not target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.connect(_on_target_ball_contact)
func _exit_tree() -> void:
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
target.ball_contact.disconnect(_on_target_ball_contact)
func _input(event):
@@ -39,8 +84,17 @@ func _input(event):
func _physics_process(delta):
# Camera position smoothing operates on the unshaken chase position. Remove
# last frame's presentation-only offset first so shake never accumulates or
# exceeds its configured amplitude during a low-time-scale hit-stop.
camera.global_position -= _last_shake_offset
_last_shake_offset = Vector3.ZERO
if not is_instance_valid(target):
return
if _goal_cut_active:
_smooth_look_at(_goal_cut_look_at, delta)
return
_update_speed_feel(delta)
var ball := _get_ball()
if ball_cam_enabled and ball:
_update_ball_cam(delta, ball)
@@ -77,11 +131,12 @@ func _update_ball_cam(delta, ball: Node3D):
var swing := _orbit_dir.signed_angle_to(flat.normalized(), Vector3.UP)
_orbit_dir = _orbit_dir.rotated(Vector3.UP, swing * t).normalized()
var camera_target_pos := ship_pos + _orbit_dir * camera_distance + Vector3.UP * camera_height
var camera_target_pos := ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height)
var pos_t := 1.0 - exp(-camera_smoothing * delta)
camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t)
_apply_shake(delta)
# Look slightly above the ball's centre; look smoothing is faster than
# position smoothing so the ball never drifts out of frame while the
@@ -95,11 +150,12 @@ func _update_ship_cam(delta):
var ship_forward: Vector3 = -target.global_transform.basis.z
# Position camera behind and above the ship
var camera_target_pos := ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height)
var pos_t := 1.0 - exp(-camera_smoothing * delta)
camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t)
_apply_shake(delta)
# Look ahead of the ship
_smooth_look_at(ship_pos + ship_forward * 10.0, delta)
@@ -115,3 +171,78 @@ func _smooth_look_at(point: Vector3, delta: float) -> void:
var look_basis := Basis.looking_at(dir, Vector3.UP)
var look_t := 1.0 - exp(-look_smoothing * delta)
camera.global_basis = camera.global_basis.slerp(look_basis, look_t).orthonormalized()
func _update_speed_feel(delta: float) -> void:
var feel_t := 1.0 - exp(-feel_smoothing * delta)
var turbo_target := 1.0 if target.is_turbo_active() else 0.0
_turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t)
_speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t)
var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick
camera.fov = lerpf(camera.fov, target_fov, feel_t)
_effective_distance = lerpf(
_effective_distance, camera_distance + _turbo_blend * turbo_distance_kick, feel_t
)
post_material.set_shader_parameter(
"motion_blur_amount", _speed_blend * _speed_blend * 0.006 + _turbo_blend * 0.006
)
post_material.set_shader_parameter("chromatic_aberration", _turbo_blend * 0.007)
post_material.set_shader_parameter("vignette_strength", 0.22 + _turbo_blend * 0.16)
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
for device in Input.get_connected_joypads():
Input.start_joy_vibration(
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
lerpf(0.08, 0.2, intensity)
)
impact_feedback.emit(intensity)
func _apply_shake(delta: float) -> void:
if _shake_strength <= 0.001:
_shake_strength = 0.0
return
_shake_time += delta * 60.0
var amplitude := max_shake_offset * _shake_strength * _shake_strength
var noise := Vector2(
_shake_noise.get_noise_1d(_shake_time),
_shake_noise.get_noise_1d(_shake_time + 100.0)
).limit_length(1.0) * amplitude
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
camera.global_position += _last_shake_offset
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
func begin_goal_cut(goal_position: Vector3) -> void:
_goal_cut_active = true
# The hard cut replaces the chase camera's presentation offset entirely;
# don't let the next physics frame subtract a pre-cut shake sample from the
# new cinematic position.
camera.global_position -= _last_shake_offset
_last_shake_offset = Vector3.ZERO
var inward := Vector3(-goal_position.x, 0.0, -goal_position.z)
if inward.length_squared() < 0.01:
inward = Vector3.BACK
else:
inward = inward.normalized()
_goal_cut_position = goal_position + inward * 10.5 + Vector3.UP * 5.5
_goal_cut_look_at = goal_position + Vector3.UP * 0.8
camera.global_position = _goal_cut_position
camera.fov = 58.0
camera.look_at(_goal_cut_look_at, Vector3.UP)
# A hard, stationary cinematic camera must not inherit the scoring frame's
# ship-speed smear or turbo chroma. Keep only a deliberate cinematic edge.
post_material.set_shader_parameter("motion_blur_amount", 0.0)
post_material.set_shader_parameter("chromatic_aberration", 0.0)
post_material.set_shader_parameter("vignette_strength", 0.3)
func end_goal_cut() -> void:
_goal_cut_active = false
_speed_blend = 0.0
_turbo_blend = 0.0
post_material.set_shader_parameter("motion_blur_amount", 0.0)
post_material.set_shader_parameter("chromatic_aberration", 0.0)
post_material.set_shader_parameter("vignette_strength", 0.22)
+4 -1
View File
@@ -31,10 +31,13 @@ func _start() -> void:
spawn_camera_rig(ship_a)
func _on_goal_scored(conceding_team: int) -> void:
func _on_goal_registered(conceding_team: int) -> void:
var scoring_team := 1 - conceding_team
_record_goal(scoring_team)
score_changed.emit(score.duplicate())
func _on_goal_scored(_conceding_team: int) -> void:
reset_ball()
reset_ships()
+4 -10
View File
@@ -14,11 +14,6 @@ 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);
// Must track TeamColors.TEAM_COLORS in scripts/team_colors.gd — shaders can't
@@ -47,9 +42,9 @@ 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.
// Deliberately not a high metallic value: the local pitch lights and SDFGI
// need enough diffuse response to reveal the plating instead of turning it
// into a dark mirror at grazing angles.
uniform float deck_metallic : hint_range(0.0, 1.0) = 0.3;
uniform float deck_roughness : hint_range(0.0, 1.0) = 0.34;
@@ -135,8 +130,7 @@ void fragment() {
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
EMISSION = line_color * marks * line_emission
+ seam_color * seam * seam_emission
+ zone * 0.015 * flat_deck;
}
+43
View File
@@ -0,0 +1,43 @@
shader_type canvas_item;
render_mode unshaded;
uniform sampler2D screen_texture : hint_screen_texture, filter_linear_mipmap;
uniform float motion_blur_amount : hint_range(0.0, 0.03) = 0.0;
uniform float chromatic_aberration : hint_range(0.0, 0.02) = 0.0;
uniform float vignette_strength : hint_range(0.0, 1.0) = 0.22;
vec4 motion_sample(vec2 uv) {
// A restrained centre-directed velocity smear. The camera script scales it
// with ship speed/turbo; mipmapped screen reads soften the outer samples.
if (motion_blur_amount < 0.0001) {
return textureLod(screen_texture, uv, 0.0);
}
vec2 step_uv = (uv - vec2(0.5)) * motion_blur_amount;
vec4 color = textureLod(screen_texture, uv, 0.0) * 0.36;
color += textureLod(screen_texture, uv - step_uv * 0.35, 0.5) * 0.25;
color += textureLod(screen_texture, uv - step_uv * 0.7, 1.0) * 0.21;
color += textureLod(screen_texture, uv - step_uv, 1.5) * 0.18;
return color;
}
void fragment() {
vec2 radial = UV - vec2(0.5);
vec2 chroma = radial * chromatic_aberration;
vec4 center = motion_sample(UV);
vec3 color = center.rgb;
// The branch is driven by one uniform for the whole draw, so non-turbo
// frames avoid eight redundant screen taps without causing warp divergence.
if (chromatic_aberration > 0.0001) {
color.r = motion_sample(UV + chroma).r;
color.b = motion_sample(UV - chroma).b;
}
// Aspect-correct vignette, always subtle and tightened slightly on turbo.
vec2 aspect_radial = radial;
aspect_radial.x *= SCREEN_PIXEL_SIZE.y / SCREEN_PIXEL_SIZE.x;
float vignette = smoothstep(0.38, 0.78, length(aspect_radial));
color *= 1.0 - vignette * vignette_strength;
COLOR = vec4(color, 1.0);
}
+1
View File
@@ -0,0 +1 @@
uid://cx1lpjodunjhx
+29
View File
@@ -0,0 +1,29 @@
[gd_resource type="Theme" load_steps=3 format=3]
[ext_resource type="FontFile" path="res://assets/fonts/Orbitron-Medium.ttf" id="1_font"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
bg_color = Color(0.035, 0.05, 0.085, 0.76)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.7, 1, 0.38)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
content_margin_left = 12.0
content_margin_top = 8.0
content_margin_right = 12.0
content_margin_bottom = 8.0
[resource]
default_font = ExtResource("1_font")
default_font_size = 14
Label/colors/font_color = Color(0.86, 0.94, 1, 1)
Label/colors/font_shadow_color = Color(0, 0, 0, 0.72)
Label/constants/outline_size = 1
Label/constants/shadow_offset_x = 2
Label/constants/shadow_offset_y = 2
PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel")
+9 -9
View File
@@ -15,15 +15,15 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
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.
- [x] 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.
- [x] VFX on anything that moves: throttle-reactive engine cores and plumes, distinct turbo flames, a speed-scaled ball trail, contact sparks and team-tinted goal bursts.
- [x] Impact feedback — intensity-scaled screen shake, unscaled hit-stop, HUD flash and controller rumble on ball contact.
- [x] Camera feel in `ship_camera.gd`speed-based FOV widening, turbo kick-back and decaying impact shake.
- [x] Goal celebration sequence: goal burst, team-tinted flash/title card, dedicated camera cut and slow-motion beat after authoritative score registration and before reset/kickoff.
- [x] Local lighting / dynamic GI — four local pitch lights plus SDFGI provide bounce and contact lighting for the runtime-generated arena shell; the shader's `hull_fill` stand-in is retired. (The shell is generated in `_ready`, so it cannot participate in an editor LightmapGI bake.)
- [x] Dress `arena_03` — its asteroid field now has mining stations, debris clusters and a distant planet.
- [x] Custom font + a real `Theme` resource for the HUD. Packaged Orbitron typography, shared label styling and themed procedural-instrument text replace `ThemeDB.fallback_font`.
- [x] Post-processing beyond glow: camera DoF plus speed-scaled motion blur, a cinematic vignette and turbo-driven chromatic aberration.
## Multiplayer (long term)