mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 13:52:05 +00:00
chore(multiplayer): Phase 0 refactors + graphics/perf settings groundwork
Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/ arena refactors, sim constants, background FPS handling) plus a first pass at exposing graphics/performance settings (presets, resolution scaling, vsync, FPS cap, perf overlay) and a GPU profiling harness for the real-hardware follow-up in task 0.15b.
This commit is contained in:
@@ -66,7 +66,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
|
|||||||
|
|
||||||
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
|
||||||
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
|
||||||
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
|
||||||
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
|
||||||
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
|
||||||
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ collision_mask = 13
|
|||||||
mass = 3
|
mass = 3
|
||||||
physics_material_override = SubResource("PhysicsMaterial_ball")
|
physics_material_override = SubResource("PhysicsMaterial_ball")
|
||||||
continuous_cd = true
|
continuous_cd = true
|
||||||
|
can_sleep = false
|
||||||
inertia = Vector3(3, 3, 3)
|
inertia = Vector3(3, 3, 3)
|
||||||
gravity_scale = 0.8
|
gravity_scale = 0.8
|
||||||
linear_damp = 0.1
|
linear_damp = 0.1
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ collision_mask = 7
|
|||||||
mass = 5.0
|
mass = 5.0
|
||||||
physics_material_override = SubResource("PhysicsMaterial_ship")
|
physics_material_override = SubResource("PhysicsMaterial_ship")
|
||||||
inertia = Vector3(7, 1, 7)
|
inertia = Vector3(7, 1, 7)
|
||||||
|
can_sleep = false
|
||||||
|
continuous_cd = true
|
||||||
script = ExtResource("1_efag7")
|
script = ExtResource("1_efag7")
|
||||||
|
|
||||||
[node name="Nose" type="MeshInstance3D" parent="."]
|
[node name="Visual" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Nose" type="MeshInstance3D" parent="Visual"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
|
||||||
mesh = ExtResource("3_nose")
|
mesh = ExtResource("3_nose")
|
||||||
|
|
||||||
[node name="TailFin" type="MeshInstance3D" parent="."]
|
[node name="TailFin" type="MeshInstance3D" parent="Visual"]
|
||||||
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
|
||||||
mesh = ExtResource("5_talfin")
|
mesh = ExtResource("5_talfin")
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,49 @@ run/main_scene.training="res://scenes/training.tscn"
|
|||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
GameSettings="*res://scripts/game_settings.gd"
|
GameSettings="*res://scripts/game_settings.gd"
|
||||||
VideoSettings="*res://scripts/video_settings.gd"
|
VideoSettings="*res://scripts/video_settings.gd"
|
||||||
|
BackgroundFPS="*res://scripts/background_fps.gd"
|
||||||
|
PerfOverlay="*res://scripts/perf_overlay.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
window/size/viewport_height=1080
|
window/size/viewport_height=1080
|
||||||
window/size/mode=2
|
window/size/mode=2
|
||||||
|
; Task 0.17c: kept fixed at "viewport" + 1080p rather than moved to
|
||||||
|
; "disabled", deliberately. A player on a 1440p/4K display cannot render
|
||||||
|
; native this way, and a 1080p player cannot render lower than 1080p through
|
||||||
|
; window scaling alone — but task 0.17b's Viewport.scaling_3d_scale already
|
||||||
|
; covers "render lower than the window" independently of stretch mode (it
|
||||||
|
; scales the 3D viewport's own internal resolution before this blit, not the
|
||||||
|
; window itself), and task 0.15b found an unexplained ~6% non-uniform width
|
||||||
|
; scaling on this project's one tested (Mac/Retina) machine — see
|
||||||
|
; multiplayer-todo.md §5.5.1 — that needs understanding before stretch mode
|
||||||
|
; is touched, not blindly carried into a resolution-dependent change.
|
||||||
window/stretch/mode="viewport"
|
window/stretch/mode="viewport"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
; Task 0.17: default matches VideoSettings.gd's VsyncMode.ADAPTIVE default —
|
||||||
|
; VideoSettings.apply_vsync() overwrites this at runtime via DisplayServer as
|
||||||
|
; soon as the autoload initializes, so this is only what's in effect for the
|
||||||
|
; brief pre-autoload window and if VideoSettings ever fails to load.
|
||||||
|
window/vsync/vsync_mode=2
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
@@ -119,6 +152,12 @@ roll_right={
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggle_perf_overlay={
|
||||||
|
"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[layer_names]
|
[layer_names]
|
||||||
|
|
||||||
3d_physics/layer_1/name="Ships"
|
3d_physics/layer_1/name="Ships"
|
||||||
@@ -130,9 +169,13 @@ roll_right={
|
|||||||
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
common/physics_interpolation=true
|
common/physics_interpolation=true
|
||||||
|
common/physics_jitter_fix=0.0
|
||||||
|
|
||||||
[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
|
anti_aliasing/quality/use_debanding=true
|
||||||
|
lights_and_shadows/positional_shadow/atlas_size=2048
|
||||||
|
lights_and_shadows/directional_shadow/size=2048
|
||||||
|
lights_and_shadows/soft_shadow_filter_quality=2
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
[node name="Match" type="Node3D"]
|
[node name="Match" type="Node3D"]
|
||||||
script = ExtResource("1_m")
|
script = ExtResource("1_m")
|
||||||
bot_model_path = "res://bots/promoted/easy.json"
|
bot_model_path = "res://bots/promoted/easy.json"
|
||||||
|
team_size = 3
|
||||||
|
|
||||||
[node name="HUD" parent="." instance=ExtResource("3_m")]
|
[node name="HUD" parent="." instance=ExtResource("3_m")]
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ horizontal_alignment = 1
|
|||||||
custom_minimum_size = Vector2(0, 14)
|
custom_minimum_size = Vector2(0, 14)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Graphics preset"
|
||||||
|
|
||||||
|
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
@@ -49,6 +64,33 @@ custom_minimum_size = Vector2(0, 40)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Resolution scale"
|
||||||
|
|
||||||
|
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 24)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 4
|
||||||
|
min_value = 0.5
|
||||||
|
max_value = 1.0
|
||||||
|
step = 0.05
|
||||||
|
value = 1.0
|
||||||
|
|
||||||
|
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(48, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "100%"
|
||||||
|
horizontal_alignment = 2
|
||||||
|
|
||||||
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
@@ -103,6 +145,51 @@ layout_mode = 2
|
|||||||
text = "100%"
|
text = "100%"
|
||||||
horizontal_alignment = 2
|
horizontal_alignment = 2
|
||||||
|
|
||||||
|
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "VSync"
|
||||||
|
|
||||||
|
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "FPS cap"
|
||||||
|
|
||||||
|
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(0, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||||
|
custom_minimum_size = Vector2(110, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Current"
|
||||||
|
|
||||||
|
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "0 fps"
|
||||||
|
|
||||||
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
|
||||||
custom_minimum_size = Vector2(0, 14)
|
custom_minimum_size = Vector2(0, 14)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -112,7 +199,11 @@ custom_minimum_size = Vector2(0, 56)
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "Back"
|
text = "Back"
|
||||||
|
|
||||||
|
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
|
||||||
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
|
||||||
|
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
|
||||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
|
||||||
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
|
||||||
|
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
|
||||||
|
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
class_name AIShipController
|
class_name AIShipController
|
||||||
extends ShipController
|
extends ShipController
|
||||||
|
|
||||||
|
# Emitted if a cached teammate/opponent reference is found freed and dropped
|
||||||
|
# from the roster (see _decide). No despawn path exists anywhere in this
|
||||||
|
# codebase today — rosters are fixed at match start — so this never fires in
|
||||||
|
# practice; it's cheap insurance against ShipObservations.build() crashing on
|
||||||
|
# a stale reference if that ever changes.
|
||||||
|
signal roster_changed
|
||||||
|
|
||||||
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
|
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
|
||||||
# same canonical observation as training (ShipObservations) and runs the
|
# same canonical observation as training (ShipObservations) and runs the
|
||||||
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
|
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
|
||||||
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
|
|||||||
func _ready():
|
func _ready():
|
||||||
if not model_path.is_empty():
|
if not model_path.is_empty():
|
||||||
load_policy(model_path)
|
load_policy(model_path)
|
||||||
|
# Stagger the first decision across [1, reaction_ticks] so bots sharing a
|
||||||
|
# reaction cadence don't all run policy inference on the same physics
|
||||||
|
# tick — six bots landing together is a ~2.4 ms spike in a 16.7 ms budget
|
||||||
|
# (policy_network.gd's forward pass). The phase offset this establishes
|
||||||
|
# persists across subsequent decisions since each one re-arms the same
|
||||||
|
# period from wherever _ticks_until_decision currently sits.
|
||||||
|
_ticks_until_decision = randi_range(1, maxi(reaction_ticks, 1))
|
||||||
|
|
||||||
|
|
||||||
# League training swaps a frozen opponent's policy between episodes without
|
# League training swaps a frozen opponent's policy between episodes without
|
||||||
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
|
|||||||
|
|
||||||
|
|
||||||
func _decide() -> void:
|
func _decide() -> void:
|
||||||
|
if _teammates.any(func(s): return not is_instance_valid(s)) \
|
||||||
|
or _opponents.any(func(s): return not is_instance_valid(s)):
|
||||||
|
_teammates = _teammates.filter(is_instance_valid)
|
||||||
|
_opponents = _opponents.filter(is_instance_valid)
|
||||||
|
roster_changed.emit()
|
||||||
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
|
||||||
var out := _policy.forward(obs)
|
var out := _policy.forward(obs)
|
||||||
# See ShipActionCodec for the decode — the single source of truth shared
|
# See ShipActionCodec for the decode — the single source of truth shared
|
||||||
|
|||||||
+48
-12
@@ -19,23 +19,59 @@ extends Node3D
|
|||||||
@export var glow_hdr_threshold := 1.0
|
@export var glow_hdr_threshold := 1.0
|
||||||
|
|
||||||
|
|
||||||
|
var _env: Environment
|
||||||
|
# Lights authored with shadow_enabled = true (the DirectionalLight3D + 4
|
||||||
|
# PitchLights omnis) — captured once, before gating ever touches them. Every
|
||||||
|
# call after the first re-applies VideoSettings.shadows_enabled to exactly
|
||||||
|
# these lights, so the set can't self-poison (if it were re-derived from
|
||||||
|
# current state, a light this same code just turned off would look
|
||||||
|
# indistinguishable from FillLight, which is authored off on purpose and must
|
||||||
|
# never be turned on by the preset ladder).
|
||||||
|
var _shadow_capable_lights: Array[Light3D] = []
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
add_to_group("arena")
|
add_to_group("arena")
|
||||||
|
# A headless server never renders, so duplicating and configuring a full
|
||||||
|
# Environment (glow/SSAO/SSIL/SDFGI) for it is pure waste — mirrors the
|
||||||
|
# same guard at ship.gd and arena_boundary.gd.
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||||
if world_env and world_env.environment:
|
if world_env and world_env.environment:
|
||||||
var env := world_env.environment.duplicate(true) as Environment
|
_env = world_env.environment.duplicate(true) as Environment
|
||||||
world_env.environment = env
|
world_env.environment = _env
|
||||||
if sky_material:
|
if sky_material:
|
||||||
if not env.sky:
|
if not _env.sky:
|
||||||
env.sky = Sky.new()
|
_env.sky = Sky.new()
|
||||||
env.sky.sky_material = sky_material
|
_env.sky.sky_material = sky_material
|
||||||
env.ambient_light_color = ambient_light_color
|
_env.ambient_light_color = ambient_light_color
|
||||||
env.ambient_light_energy = ambient_light_energy
|
_env.ambient_light_energy = ambient_light_energy
|
||||||
env.glow_intensity = glow_intensity
|
_env.glow_intensity = glow_intensity
|
||||||
env.glow_strength = glow_strength
|
_env.glow_strength = glow_strength
|
||||||
env.glow_bloom = glow_bloom
|
_env.glow_bloom = glow_bloom
|
||||||
env.glow_hdr_threshold = glow_hdr_threshold
|
_env.glow_hdr_threshold = glow_hdr_threshold
|
||||||
VideoSettings.apply_to_environment(env)
|
for light in find_children("*", "Light3D", true, false):
|
||||||
|
if (light as Light3D).shadow_enabled:
|
||||||
|
_shadow_capable_lights.append(light)
|
||||||
|
_apply_video_settings()
|
||||||
|
# Task 0.17: a preset change from the settings menu must take effect
|
||||||
|
# on the arena that's already loaded, not just the next one — this is
|
||||||
|
# the "settings persist and apply without a restart" acceptance bar.
|
||||||
|
VideoSettings.settings_changed.connect(_apply_video_settings)
|
||||||
|
|
||||||
|
|
||||||
|
# Re-run on every VideoSettings.settings_changed (preset or individual
|
||||||
|
# toggle) as well as once at load. Shadow gating lives here rather than in
|
||||||
|
# VideoSettings.apply_to_environment() because it targets Light3D nodes in
|
||||||
|
# this arena's own tree, not the Environment resource.
|
||||||
|
func _apply_video_settings() -> void:
|
||||||
|
if not is_instance_valid(_env):
|
||||||
|
return
|
||||||
|
VideoSettings.apply_to_environment(_env)
|
||||||
|
for light in _shadow_capable_lights:
|
||||||
|
if is_instance_valid(light):
|
||||||
|
light.shadow_enabled = VideoSettings.shadows_enabled
|
||||||
|
|
||||||
|
|
||||||
func get_ball_spawn() -> Transform3D:
|
func get_ball_spawn() -> Transform3D:
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ var _field_material: ShaderMaterial
|
|||||||
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
|
||||||
# pattern so the viewport lookup isn't repeated every frame.
|
# pattern so the viewport lookup isn't repeated every frame.
|
||||||
var _camera: Camera3D
|
var _camera: Camera3D
|
||||||
|
var _last_camera_local_pos := Vector3.INF
|
||||||
|
# Below this, the shader's per-pixel facing test can't produce a visibly
|
||||||
|
# different result — skip the to_local()/set_shader_parameter() call.
|
||||||
|
const CAMERA_UNIFORM_UPDATE_THRESHOLD := 0.05
|
||||||
|
|
||||||
# Group every generated collider is tagged with. A CollisionShape3D only
|
# Group every generated collider is tagged with. A CollisionShape3D only
|
||||||
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
|
||||||
@@ -184,6 +188,14 @@ func get_surface_pull(
|
|||||||
global_pos: Vector3, wall_strength: float, wall_range: float,
|
global_pos: Vector3, wall_strength: float, wall_range: float,
|
||||||
ceiling_strength: float, ceiling_range: float
|
ceiling_strength: float, ceiling_range: float
|
||||||
) -> Vector3:
|
) -> Vector3:
|
||||||
|
# Early-out: every dynamic body pays to_local() plus five _falloff calls
|
||||||
|
# every tick even mid-arena, where every term is exactly zero. Compared
|
||||||
|
# directly against global_pos, matching the same identity-transform
|
||||||
|
# assumption GameMode._is_escaped already makes against these constants.
|
||||||
|
if absf(global_pos.x) < INNER_HALF_X - wall_range \
|
||||||
|
and absf(global_pos.z) < INNER_HALF_Z - wall_range \
|
||||||
|
and global_pos.y < INNER_HEIGHT - ceiling_range:
|
||||||
|
return Vector3.ZERO
|
||||||
var p := to_local(global_pos)
|
var p := to_local(global_pos)
|
||||||
var pull := Vector3.ZERO
|
var pull := Vector3.ZERO
|
||||||
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
|
||||||
@@ -214,7 +226,11 @@ func _process(_delta: float) -> void:
|
|||||||
var camera := _get_camera()
|
var camera := _get_camera()
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return # headless (RL/CI) has no camera
|
return # headless (RL/CI) has no camera
|
||||||
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
|
var local_pos := to_local(camera.global_position)
|
||||||
|
if local_pos.distance_to(_last_camera_local_pos) < CAMERA_UNIFORM_UPDATE_THRESHOLD:
|
||||||
|
return
|
||||||
|
_last_camera_local_pos = local_pos
|
||||||
|
_field_material.set_shader_parameter("camera_local_pos", local_pos)
|
||||||
|
|
||||||
|
|
||||||
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
# Caches the viewport's active camera; a plain is_instance_valid revalidation
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Autoload: drops Engine.max_fps while the window is unfocused, so an idle
|
||||||
|
# background window doesn't keep rendering at whatever uncapped rate the
|
||||||
|
# hardware can hit. Independent of — and complementary to — the per-menu
|
||||||
|
# refresh-rate cap in main_menu.gd/settings_menu.gd, which only covers menu
|
||||||
|
# screens; this covers every scene, including gameplay.
|
||||||
|
|
||||||
|
const BACKGROUND_FPS := 30
|
||||||
|
|
||||||
|
# 0 means "uncapped"; also what we restore to if focus is lost before any
|
||||||
|
# menu/gameplay scene has had a chance to set its own cap.
|
||||||
|
var _foreground_max_fps := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
match what:
|
||||||
|
NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||||
|
_foreground_max_fps = Engine.max_fps
|
||||||
|
Engine.max_fps = BACKGROUND_FPS
|
||||||
|
NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||||
|
Engine.max_fps = _foreground_max_fps
|
||||||
+33
-1
@@ -19,6 +19,30 @@ const MAX_SPEED := 32.0
|
|||||||
var _boundary: ArenaBoundary
|
var _boundary: ArenaBoundary
|
||||||
var _trail: GPUParticles3D
|
var _trail: GPUParticles3D
|
||||||
|
|
||||||
|
var _pending_teleport: Transform3D
|
||||||
|
var _has_pending_teleport := false
|
||||||
|
|
||||||
|
|
||||||
|
# Queues an authoritative teleport, applied at the top of the next
|
||||||
|
# _integrate_forces — the only Jolt-safe place to write state.transform
|
||||||
|
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
|
||||||
|
# physics step via set_deferred("global_transform", ...).
|
||||||
|
func queue_teleport(to: Transform3D) -> void:
|
||||||
|
_pending_teleport = to
|
||||||
|
_has_pending_teleport = true
|
||||||
|
|
||||||
|
|
||||||
|
# -1 = use the real linear_velocity (default; see _physics_process below).
|
||||||
|
# A frozen remote ball (Phase 4) holds zero velocity — Godot/Jolt zeroes and
|
||||||
|
# ignores velocity writes on frozen bodies — so the trail needs a
|
||||||
|
# presentation-only speed fed in from outside instead of reading physics
|
||||||
|
# state that will never reflect the ball's true remote motion.
|
||||||
|
var _visual_speed_override: float = -1.0
|
||||||
|
|
||||||
|
|
||||||
|
func set_visual_speed(speed: float) -> void:
|
||||||
|
_visual_speed_override = speed
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_boundary = get_tree().get_first_node_in_group("arena_boundary")
|
_boundary = get_tree().get_first_node_in_group("arena_boundary")
|
||||||
@@ -32,7 +56,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
if _trail:
|
if _trail:
|
||||||
var speed_ratio := clampf(linear_velocity.length() / MAX_SPEED, 0.0, 1.0)
|
var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length()
|
||||||
|
var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0)
|
||||||
_trail.emitting = speed_ratio > 0.12
|
_trail.emitting = speed_ratio > 0.12
|
||||||
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
|
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
|
||||||
|
|
||||||
@@ -68,6 +93,13 @@ func _build_trail() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||||
|
if _has_pending_teleport:
|
||||||
|
_has_pending_teleport = false
|
||||||
|
state.transform = _pending_teleport
|
||||||
|
state.linear_velocity = Vector3.ZERO
|
||||||
|
state.angular_velocity = Vector3.ZERO
|
||||||
|
reset_physics_interpolation()
|
||||||
|
|
||||||
if _boundary:
|
if _boundary:
|
||||||
var pull := _boundary.get_surface_pull(
|
var pull := _boundary.get_surface_pull(
|
||||||
global_position, wall_pull_strength, wall_pull_range,
|
global_position, wall_pull_strength, wall_pull_range,
|
||||||
|
|||||||
+80
-69
@@ -17,16 +17,10 @@ var hud: HUDController
|
|||||||
var ball: RigidBody3D
|
var ball: RigidBody3D
|
||||||
var ships: Array[Ship] = []
|
var ships: Array[Ship] = []
|
||||||
var _ship_spawn_transforms := {}
|
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 _camera_rig: ShipCameraRig
|
||||||
var _goal_slowmo_active := false
|
|
||||||
var _time_scale_before_goal := 1.0
|
|
||||||
var _goal_in_progress := false
|
var _goal_in_progress := false
|
||||||
|
|
||||||
const GOAL_CELEBRATION_SECONDS := 1.6
|
const GOAL_CELEBRATION_SECONDS := 1.6
|
||||||
const GOAL_SLOWMO_SCALE := 0.22
|
|
||||||
|
|
||||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||||
# references this or emits a score_changed signal, and HUDController relies
|
# references this or emits a score_changed signal, and HUDController relies
|
||||||
@@ -38,6 +32,18 @@ var score := {0: 0, 1: 0}
|
|||||||
func _ready():
|
func _ready():
|
||||||
# Group lets the HUD discover the game mode for timer/score signals
|
# Group lets the HUD discover the game mode for timer/score signals
|
||||||
add_to_group("game")
|
add_to_group("game")
|
||||||
|
# At the default 8, a client hitching to ~20 fps runs up to 8 physics
|
||||||
|
# ticks in one rendered frame — and each of those ticks costs roughly as
|
||||||
|
# much as the frame that caused the hitch, so the client can spiral
|
||||||
|
# further behind instead of recovering. 4 trades a lower worst-case
|
||||||
|
# catch-up rate for bounded per-frame cost.
|
||||||
|
Engine.max_physics_steps_per_frame = 4
|
||||||
|
# A fresh RandomNumberGenerator defaults to a fixed internal state (unlike
|
||||||
|
# the global randf_range, which Godot auto-randomizes at startup), so an
|
||||||
|
# explicit randomize() is required unless a seed was set for reproducible
|
||||||
|
# kickoffs (see kickoff_rng_seed above).
|
||||||
|
if kickoff_rng_seed == 0:
|
||||||
|
_kickoff_rng.randomize()
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child is Arena:
|
if child is Arena:
|
||||||
arena = child
|
arena = child
|
||||||
@@ -51,6 +57,7 @@ func _ready():
|
|||||||
if not arena:
|
if not arena:
|
||||||
push_error("GameMode: scene has no Arena child")
|
push_error("GameMode: scene has no Arena child")
|
||||||
return
|
return
|
||||||
|
if _owns_goal_logic():
|
||||||
for goal in arena.get_goals():
|
for goal in arena.get_goals():
|
||||||
goal.goal_scored.connect(_handle_goal_scored)
|
goal.goal_scored.connect(_handle_goal_scored)
|
||||||
_start()
|
_start()
|
||||||
@@ -68,6 +75,31 @@ func _start() -> void:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: whether this mode decides goals from its own local Goal sensors.
|
||||||
|
# True for every mode today. A future networked client mode overrides this
|
||||||
|
# false — it must learn a goal happened from an authoritative server message,
|
||||||
|
# not from an interpolated remote ball wandering through its local Goal
|
||||||
|
# Area3D, which would score client-side against no one.
|
||||||
|
func _owns_goal_logic() -> bool:
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: whether this mode simulates and enforces its own world (escaped-
|
||||||
|
# body respawn runs locally in _physics_process). True for every mode today.
|
||||||
|
# A future networked client mode overrides this false — the server is
|
||||||
|
# authoritative for body positions, and a client respawning a body itself
|
||||||
|
# would fight that authority.
|
||||||
|
func _owns_world_simulation() -> bool:
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual: how long the goal cinematic holds before resuming play. Subclasses
|
||||||
|
# that want a different cadence override this instead of touching
|
||||||
|
# GOAL_CELEBRATION_SECONDS directly.
|
||||||
|
func _goal_pause_seconds() -> float:
|
||||||
|
return GOAL_CELEBRATION_SECONDS
|
||||||
|
|
||||||
|
|
||||||
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
|
||||||
func _on_goal_scored(_conceding_team: int) -> void:
|
func _on_goal_scored(_conceding_team: int) -> void:
|
||||||
pass
|
pass
|
||||||
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
|
|||||||
_goal_in_progress = true
|
_goal_in_progress = true
|
||||||
_on_goal_registered(conceding_team)
|
_on_goal_registered(conceding_team)
|
||||||
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
await _play_goal_celebration(1 - conceding_team, conceding_team)
|
||||||
|
# A scene change (Esc, match end) queued during the celebration removes
|
||||||
|
# this node from the tree before the await chain finishes; resuming past
|
||||||
|
# that point would touch arena/hud state that is mid-teardown.
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
await _on_goal_scored(conceding_team)
|
await _on_goal_scored(conceding_team)
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
_goal_in_progress = false
|
_goal_in_progress = false
|
||||||
|
|
||||||
|
|
||||||
@@ -97,34 +136,23 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
|
|||||||
# real-time presentation delay between episodes.
|
# real-time presentation delay between episodes.
|
||||||
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
|
||||||
return
|
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
|
var goal_position := Vector3.ZERO
|
||||||
for goal in arena.get_goals():
|
for goal in arena.get_goals():
|
||||||
if goal.team == conceding_team:
|
if goal.team == conceding_team:
|
||||||
goal_position = goal.global_position
|
goal_position = goal.global_position
|
||||||
break
|
break
|
||||||
|
# The cinematic camera cut itself (hard FOV change, cut to a fixed angle)
|
||||||
|
# carries the "moment" that Engine.time_scale slow-mo used to sell —
|
||||||
|
# world simulation speed is never touched, so this behaves identically
|
||||||
|
# for a future networked client watching a shared server sim.
|
||||||
_camera_rig.begin_goal_cut(goal_position)
|
_camera_rig.begin_goal_cut(goal_position)
|
||||||
if hud:
|
if hud:
|
||||||
hud.show_goal_celebration(scoring_team)
|
hud.show_goal_celebration(scoring_team)
|
||||||
await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout
|
await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout
|
||||||
if is_instance_valid(_camera_rig):
|
if is_instance_valid(_camera_rig):
|
||||||
_camera_rig.end_goal_cut()
|
_camera_rig.end_goal_cut()
|
||||||
if hud and is_instance_valid(hud):
|
if hud and is_instance_valid(hud):
|
||||||
hud.hide_goal_celebration()
|
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:
|
func spawn_ball() -> RigidBody3D:
|
||||||
@@ -136,7 +164,7 @@ func spawn_ball() -> RigidBody3D:
|
|||||||
|
|
||||||
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
|
||||||
var ship: Ship = ship_scene.instantiate()
|
var ship: Ship = ship_scene.instantiate()
|
||||||
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
|
ship.name = "Ship_T%d_S%d" % [team, spawn_index]
|
||||||
add_child(ship)
|
add_child(ship)
|
||||||
var spawns := arena.get_ship_spawns(team)
|
var spawns := arena.get_ship_spawns(team)
|
||||||
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
|
||||||
@@ -155,7 +183,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
|||||||
add_child(rig)
|
add_child(rig)
|
||||||
_camera_rig = rig
|
_camera_rig = rig
|
||||||
rig.target = target
|
rig.target = target
|
||||||
rig.impact_feedback.connect(_on_player_impact)
|
|
||||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
# Also wires the scene's static HUD (if any) to the same ship, rather
|
||||||
# than letting it guess via the "ship" group.
|
# than letting it guess via the "ship" group.
|
||||||
if hud:
|
if hud:
|
||||||
@@ -163,42 +190,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
|||||||
return rig
|
return rig
|
||||||
|
|
||||||
|
|
||||||
func _on_player_impact(intensity: float) -> void:
|
|
||||||
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
|
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
||||||
# apply their own GameSettings-override logic first, which differs between
|
# apply their own GameSettings-override logic first, which differs between
|
||||||
# modes (Match lets GameSettings override all three fields, Spectate only
|
# modes (Match lets GameSettings override all three fields, Spectate only
|
||||||
@@ -233,6 +224,16 @@ func _record_goal(scoring_team: int) -> void:
|
|||||||
const KICKOFF_POSITION_JITTER := 0.3
|
const KICKOFF_POSITION_JITTER := 0.3
|
||||||
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
|
||||||
|
|
||||||
|
# Owned rather than global `randf_range`, so a fixed seed makes kickoffs
|
||||||
|
# exactly reproducible (replay logs, deterministic tests) without disturbing
|
||||||
|
# any other system's random stream.
|
||||||
|
@export var kickoff_rng_seed: int = 0:
|
||||||
|
set(value):
|
||||||
|
kickoff_rng_seed = value
|
||||||
|
if value != 0:
|
||||||
|
_kickoff_rng.seed = value
|
||||||
|
var _kickoff_rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
|
|
||||||
func reset_ball() -> void:
|
func reset_ball() -> void:
|
||||||
if is_instance_valid(ball):
|
if is_instance_valid(ball):
|
||||||
@@ -243,24 +244,33 @@ func reset_ships() -> void:
|
|||||||
for ship in ships:
|
for ship in ships:
|
||||||
if is_instance_valid(ship):
|
if is_instance_valid(ship):
|
||||||
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
|
||||||
|
if is_instance_valid(_camera_rig):
|
||||||
|
# _reset_body's queue_teleport defers the actual transform write to
|
||||||
|
# the ship's next _integrate_forces (task 0.15) — snapping the camera
|
||||||
|
# now would read the pre-teleport position. Wait one physics tick so
|
||||||
|
# the teleport has already landed; without this the camera would also
|
||||||
|
# smoothly chase the teleported ship across the arena instead of
|
||||||
|
# cutting with it.
|
||||||
|
await get_tree().physics_frame
|
||||||
|
if is_instance_valid(_camera_rig):
|
||||||
|
_camera_rig.snap_to_target()
|
||||||
|
|
||||||
|
|
||||||
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
|
||||||
var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
|
var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter))
|
||||||
var basis := to.basis
|
var basis := to.basis
|
||||||
if yaw_jitter > 0.0:
|
if yaw_jitter > 0.0:
|
||||||
basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter))
|
basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter))
|
||||||
return Transform3D(basis, to.origin + offset)
|
return Transform3D(basis, to.origin + offset)
|
||||||
|
|
||||||
|
|
||||||
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||||
# Deferred: a RigidBody3D transform can't be set mid-physics-step
|
# Queued and applied inside the body's own _integrate_forces — the only
|
||||||
body.set_deferred("global_transform", to)
|
# Jolt-safe place to write state.transform — instead of racing the
|
||||||
body.set_deferred("linear_velocity", Vector3.ZERO)
|
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
|
||||||
body.set_deferred("angular_velocity", Vector3.ZERO)
|
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
|
||||||
# A kickoff reset is a teleport: without this, physics interpolation
|
# so a statically-typed call here won't resolve.
|
||||||
# smears the body across the arena for a frame
|
body.call("queue_teleport", to)
|
||||||
body.call_deferred("reset_physics_interpolation")
|
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event):
|
func _unhandled_input(event):
|
||||||
@@ -282,6 +292,7 @@ const ESCAPE_MARGIN := 15.0
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if _owns_world_simulation():
|
||||||
_respawn_escaped_bodies()
|
_respawn_escaped_bodies()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func _process(delta: float) -> void:
|
|||||||
_pitch = new_pitch
|
_pitch = new_pitch
|
||||||
_roll = new_roll
|
_roll = new_roll
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func _process(delta: float) -> void:
|
|||||||
var changed := absf(new_value - _value) > max_value * 0.001
|
var changed := absf(new_value - _value) > max_value * 0.001
|
||||||
_value = new_value
|
_value = new_value
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func _process(delta: float) -> void:
|
|||||||
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
|
||||||
_heading = new_heading
|
_heading = new_heading
|
||||||
if changed:
|
if changed:
|
||||||
queue_redraw()
|
_throttled_redraw(delta)
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ extends Control
|
|||||||
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
||||||
|
|
||||||
const SMOOTHING := 12.0
|
const SMOOTHING := 12.0
|
||||||
|
# _draw does real work (text shaping, building point arrays); nobody can
|
||||||
|
# perceive an instrument repainting faster than this, so redraws are paced
|
||||||
|
# to it independently of the render frame rate — value smoothing itself
|
||||||
|
# still runs every _process call, only the (expensive) repaint is throttled.
|
||||||
|
const REDRAW_INTERVAL := 1.0 / 60.0
|
||||||
|
|
||||||
|
var _time_since_redraw := 0.0
|
||||||
|
|
||||||
|
|
||||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||||
@@ -23,3 +30,12 @@ static func angle_delta_deg(from: float, to: float) -> float:
|
|||||||
|
|
||||||
func _smoothing_weight(delta: float) -> float:
|
func _smoothing_weight(delta: float) -> float:
|
||||||
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||||
|
|
||||||
|
|
||||||
|
# Call instead of queue_redraw() directly once a subclass's _process has
|
||||||
|
# decided the smoothed value moved enough to warrant a repaint.
|
||||||
|
func _throttled_redraw(delta: float) -> void:
|
||||||
|
_time_since_redraw += delta
|
||||||
|
if _time_since_redraw >= REDRAW_INTERVAL:
|
||||||
|
_time_since_redraw = 0.0
|
||||||
|
queue_redraw()
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const DIFFICULTIES := [
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# An idle menu has no reason to render past the display's own refresh
|
||||||
|
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
|
||||||
|
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||||
|
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
||||||
_populate_difficulty_dropdown()
|
_populate_difficulty_dropdown()
|
||||||
_populate_arena_dropdown()
|
_populate_arena_dropdown()
|
||||||
dev_section.visible = OS.is_debug_build()
|
dev_section.visible = OS.is_debug_build()
|
||||||
@@ -115,10 +119,19 @@ func _selected_path(dropdown: OptionButton) -> String:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# The menu's own refresh-rate fps cap (see _ready) is a menu-only concern;
|
||||||
|
# gameplay scenes respect the player's own VideoSettings fps cap instead
|
||||||
|
# (task 0.17), which only actually caps anything when vsync is Disabled and a
|
||||||
|
# divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0.
|
||||||
|
func _leave_to_gameplay(scene_path: String) -> void:
|
||||||
|
VideoSettings.apply_fps_cap()
|
||||||
|
get_tree().change_scene_to_file(scene_path)
|
||||||
|
|
||||||
|
|
||||||
func _on_free_play_pressed() -> void:
|
func _on_free_play_pressed() -> void:
|
||||||
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
||||||
GameSettings.selected_arena_path = chosen["path"]
|
GameSettings.selected_arena_path = chosen["path"]
|
||||||
get_tree().change_scene_to_file("res://scenes/free_play.tscn")
|
_leave_to_gameplay("res://scenes/free_play.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_match_pressed() -> void:
|
func _on_match_pressed() -> void:
|
||||||
@@ -134,7 +147,7 @@ func _on_match_pressed() -> void:
|
|||||||
GameSettings.selected_bot_path = override_path
|
GameSettings.selected_bot_path = override_path
|
||||||
GameSettings.selected_bot_reaction_ticks = -1
|
GameSettings.selected_bot_reaction_ticks = -1
|
||||||
GameSettings.selected_bot_action_noise = -1.0
|
GameSettings.selected_bot_action_noise = -1.0
|
||||||
get_tree().change_scene_to_file("res://scenes/match.tscn")
|
_leave_to_gameplay("res://scenes/match.tscn")
|
||||||
|
|
||||||
|
|
||||||
func _on_settings_pressed() -> void:
|
func _on_settings_pressed() -> void:
|
||||||
@@ -144,4 +157,4 @@ func _on_settings_pressed() -> void:
|
|||||||
func _on_spectate_pressed() -> void:
|
func _on_spectate_pressed() -> void:
|
||||||
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
||||||
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
||||||
get_tree().change_scene_to_file("res://scenes/spectate.tscn")
|
_leave_to_gameplay("res://scenes/spectate.tscn")
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
# Autoload: toggleable frame-time/bottleneck overlay (F3 by default — see
|
||||||
|
# toggle_perf_overlay in project.godot's [input]). Read-only against
|
||||||
|
# Performance monitors; never touches rendering or gameplay state. Exists so
|
||||||
|
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
|
||||||
|
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
|
||||||
|
# GPU-bound. See multiplayer-todo.md task 0.20.
|
||||||
|
|
||||||
|
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
|
||||||
|
# history itself being a rate-dependent quantity.
|
||||||
|
const HISTORY_SIZE := 120
|
||||||
|
|
||||||
|
var _label: Label
|
||||||
|
var _frame_times_ms: PackedFloat32Array = PackedFloat32Array()
|
||||||
|
var _history_index := 0
|
||||||
|
var _history_filled := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# A headless server never renders and has no input to toggle this with.
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
set_process(false)
|
||||||
|
return
|
||||||
|
layer = 100
|
||||||
|
_label = Label.new()
|
||||||
|
_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||||
|
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
||||||
|
_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||||
|
_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||||
|
_label.position = Vector2(12, 12)
|
||||||
|
_label.visible = false
|
||||||
|
add_child(_label)
|
||||||
|
_frame_times_ms.resize(HISTORY_SIZE)
|
||||||
|
_frame_times_ms.fill(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("toggle_perf_overlay") and _label:
|
||||||
|
_label.visible = not _label.visible
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not _label or not _label.visible:
|
||||||
|
return
|
||||||
|
var frame_ms := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||||
|
var physics_ms := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||||
|
_frame_times_ms[_history_index] = frame_ms
|
||||||
|
_history_index = (_history_index + 1) % HISTORY_SIZE
|
||||||
|
_history_filled = mini(_history_filled + 1, HISTORY_SIZE)
|
||||||
|
|
||||||
|
var sorted := _frame_times_ms.slice(0, _history_filled)
|
||||||
|
sorted.sort()
|
||||||
|
var p50 := sorted[sorted.size() / 2]
|
||||||
|
var p99 := sorted[int(sorted.size() * 0.99)]
|
||||||
|
|
||||||
|
_label.text = "FPS %d (p50 %.2fms p99 %.2fms)\nprocess %.2fms physics %.2fms\ndraw calls %d" % [
|
||||||
|
Performance.get_monitor(Performance.TIME_FPS),
|
||||||
|
p50, p99, frame_ms, physics_ms,
|
||||||
|
Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
|
||||||
|
]
|
||||||
@@ -1,26 +1,90 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
# Settings screen: a small set of player-facing video knobs on top of
|
# Settings screen: player-facing video knobs on top of VideoSettings (the
|
||||||
# VideoSettings (the autoload holding + persisting them). Anti-aliasing
|
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
|
||||||
# applies immediately since it's a Viewport-wide setting; glow/brightness
|
# scale apply immediately since they're Viewport- or DisplayServer-wide;
|
||||||
# apply the next time an arena loads (see arena.gd), since they scale each
|
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
|
||||||
# arena's own tuned Environment values rather than something viewport-wide.
|
# an Environment, or instantly to an already-loaded one via
|
||||||
|
# VideoSettings.settings_changed (see arena.gd) — task 0.17.
|
||||||
|
|
||||||
const AA_OPTIONS := [
|
const AA_OPTIONS := [
|
||||||
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
|
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
|
||||||
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
|
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
|
||||||
|
{"name": "MSAA 2x", "mode": VideoSettings.AAMode.MSAA_2X},
|
||||||
{"name": "MSAA 4x", "mode": VideoSettings.AAMode.MSAA},
|
{"name": "MSAA 4x", "mode": VideoSettings.AAMode.MSAA},
|
||||||
{"name": "MSAA 4x + FXAA", "mode": VideoSettings.AAMode.MSAA_FXAA},
|
{"name": "MSAA 4x + FXAA", "mode": VideoSettings.AAMode.MSAA_FXAA},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PRESET_OPTIONS := [
|
||||||
|
{"name": "Low", "preset": VideoSettings.Preset.LOW},
|
||||||
|
{"name": "Medium", "preset": VideoSettings.Preset.MEDIUM},
|
||||||
|
{"name": "High", "preset": VideoSettings.Preset.HIGH},
|
||||||
|
{"name": "Custom", "preset": VideoSettings.Preset.CUSTOM},
|
||||||
|
]
|
||||||
|
|
||||||
|
const VSYNC_OPTIONS := [
|
||||||
|
{"name": "Disabled", "mode": VideoSettings.VsyncMode.DISABLED},
|
||||||
|
{"name": "Enabled", "mode": VideoSettings.VsyncMode.ENABLED},
|
||||||
|
{"name": "Adaptive", "mode": VideoSettings.VsyncMode.ADAPTIVE},
|
||||||
|
]
|
||||||
|
|
||||||
|
@onready var preset_dropdown: OptionButton = %PresetDropdown
|
||||||
@onready var aa_dropdown: OptionButton = %AADropdown
|
@onready var aa_dropdown: OptionButton = %AADropdown
|
||||||
@onready var glow_slider: HSlider = %GlowSlider
|
@onready var glow_slider: HSlider = %GlowSlider
|
||||||
@onready var glow_value_label: Label = %GlowValueLabel
|
@onready var glow_value_label: Label = %GlowValueLabel
|
||||||
@onready var brightness_slider: HSlider = %BrightnessSlider
|
@onready var brightness_slider: HSlider = %BrightnessSlider
|
||||||
@onready var brightness_value_label: Label = %BrightnessValueLabel
|
@onready var brightness_value_label: Label = %BrightnessValueLabel
|
||||||
|
@onready var resolution_slider: HSlider = %ResolutionSlider
|
||||||
|
@onready var resolution_value_label: Label = %ResolutionValueLabel
|
||||||
|
@onready var vsync_dropdown: OptionButton = %VsyncDropdown
|
||||||
|
@onready var fps_cap_dropdown: OptionButton = %FpsCapDropdown
|
||||||
|
@onready var fps_readout_label: Label = %FpsReadoutLabel
|
||||||
|
|
||||||
|
# fps_cap_dropdown item index -> VideoSettings divisor (0 = uncapped). Built
|
||||||
|
# in _ready() from the live refresh rate so the menu never hardcodes a
|
||||||
|
# specific display's numbers.
|
||||||
|
var _fps_cap_divisors: Array[int] = []
|
||||||
|
var _populating := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# An idle settings screen has no reason to render past the display's own
|
||||||
|
# refresh rate; _on_back_pressed only returns to another capped menu, so
|
||||||
|
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
|
||||||
|
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
||||||
|
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
||||||
|
|
||||||
|
_populating = true
|
||||||
|
_populate_preset_dropdown()
|
||||||
|
_populate_aa_dropdown()
|
||||||
|
_populate_vsync_dropdown()
|
||||||
|
_populate_fps_cap_dropdown(refresh_rate)
|
||||||
|
_populating = false
|
||||||
|
|
||||||
|
glow_slider.value = VideoSettings.glow_scale
|
||||||
|
brightness_slider.value = VideoSettings.brightness
|
||||||
|
resolution_slider.value = VideoSettings.resolution_scale
|
||||||
|
_update_glow_label()
|
||||||
|
_update_brightness_label()
|
||||||
|
_update_resolution_label()
|
||||||
|
_update_fps_cap_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
fps_readout_label.text = "%d fps" % Performance.get_monitor(Performance.TIME_FPS)
|
||||||
|
|
||||||
|
|
||||||
|
func _populate_preset_dropdown() -> void:
|
||||||
|
preset_dropdown.clear()
|
||||||
|
var selected := 0
|
||||||
|
for i in PRESET_OPTIONS.size():
|
||||||
|
preset_dropdown.add_item(PRESET_OPTIONS[i]["name"])
|
||||||
|
if PRESET_OPTIONS[i]["preset"] == VideoSettings.preset:
|
||||||
|
selected = i
|
||||||
|
preset_dropdown.select(selected)
|
||||||
|
|
||||||
|
|
||||||
|
func _populate_aa_dropdown() -> void:
|
||||||
aa_dropdown.clear()
|
aa_dropdown.clear()
|
||||||
var selected := 0
|
var selected := 0
|
||||||
for i in AA_OPTIONS.size():
|
for i in AA_OPTIONS.size():
|
||||||
@@ -29,10 +93,39 @@ func _ready() -> void:
|
|||||||
selected = i
|
selected = i
|
||||||
aa_dropdown.select(selected)
|
aa_dropdown.select(selected)
|
||||||
|
|
||||||
glow_slider.value = VideoSettings.glow_scale
|
|
||||||
brightness_slider.value = VideoSettings.brightness
|
func _populate_vsync_dropdown() -> void:
|
||||||
_update_glow_label()
|
vsync_dropdown.clear()
|
||||||
_update_brightness_label()
|
var selected := 0
|
||||||
|
for i in VSYNC_OPTIONS.size():
|
||||||
|
vsync_dropdown.add_item(VSYNC_OPTIONS[i]["name"])
|
||||||
|
if VSYNC_OPTIONS[i]["mode"] == VideoSettings.vsync_mode:
|
||||||
|
selected = i
|
||||||
|
vsync_dropdown.select(selected)
|
||||||
|
|
||||||
|
|
||||||
|
# Options derive from the live refresh rate rather than a fixed list, per
|
||||||
|
# task 0.17's "fps cap derived from screen_get_refresh_rate() divisors".
|
||||||
|
# A -1 (or otherwise non-positive) query falls back to "Uncapped" only,
|
||||||
|
# rather than presenting cap choices the engine can't compute a value for.
|
||||||
|
func _populate_fps_cap_dropdown(refresh_rate: float) -> void:
|
||||||
|
fps_cap_dropdown.clear()
|
||||||
|
_fps_cap_divisors.clear()
|
||||||
|
fps_cap_dropdown.add_item("Uncapped")
|
||||||
|
_fps_cap_divisors.append(0)
|
||||||
|
if refresh_rate > 0.0:
|
||||||
|
for divisor in [1, 2, 3, 4]:
|
||||||
|
var hz := refresh_rate / float(divisor)
|
||||||
|
fps_cap_dropdown.add_item("%d fps (refresh / %d)" % [roundi(hz), divisor])
|
||||||
|
_fps_cap_divisors.append(divisor)
|
||||||
|
var selected := _fps_cap_divisors.find(VideoSettings.fps_cap_divisor)
|
||||||
|
fps_cap_dropdown.select(maxi(selected, 0))
|
||||||
|
|
||||||
|
|
||||||
|
# The fps cap dropdown only means anything with vsync Disabled — vsync
|
||||||
|
# itself already caps to (a multiple of) the refresh rate otherwise.
|
||||||
|
func _update_fps_cap_enabled() -> void:
|
||||||
|
fps_cap_dropdown.disabled = VideoSettings.vsync_mode != VideoSettings.VsyncMode.DISABLED
|
||||||
|
|
||||||
|
|
||||||
func _update_glow_label() -> void:
|
func _update_glow_label() -> void:
|
||||||
@@ -43,9 +136,26 @@ func _update_brightness_label() -> void:
|
|||||||
brightness_value_label.text = "%d%%" % roundi(brightness_slider.value * 100.0)
|
brightness_value_label.text = "%d%%" % roundi(brightness_slider.value * 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_resolution_label() -> void:
|
||||||
|
resolution_value_label.text = "%d%%" % roundi(resolution_slider.value * 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_preset_dropdown_item_selected(index: int) -> void:
|
||||||
|
VideoSettings.apply_preset(PRESET_OPTIONS[index]["preset"])
|
||||||
|
# The bundle may have changed AA/resolution scale underneath the other
|
||||||
|
# controls — resync them without re-triggering their own "user changed
|
||||||
|
# this by hand" mark_custom() path.
|
||||||
|
_populating = true
|
||||||
|
_populate_aa_dropdown()
|
||||||
|
resolution_slider.value = VideoSettings.resolution_scale
|
||||||
|
_populating = false
|
||||||
|
_update_resolution_label()
|
||||||
|
|
||||||
|
|
||||||
func _on_aa_dropdown_item_selected(index: int) -> void:
|
func _on_aa_dropdown_item_selected(index: int) -> void:
|
||||||
VideoSettings.aa_mode = AA_OPTIONS[index]["mode"]
|
VideoSettings.aa_mode = AA_OPTIONS[index]["mode"]
|
||||||
VideoSettings.apply_aa()
|
VideoSettings.apply_aa()
|
||||||
|
_mark_custom_if_user_driven()
|
||||||
|
|
||||||
|
|
||||||
func _on_glow_slider_value_changed(value: float) -> void:
|
func _on_glow_slider_value_changed(value: float) -> void:
|
||||||
@@ -58,6 +168,40 @@ func _on_brightness_slider_value_changed(value: float) -> void:
|
|||||||
_update_brightness_label()
|
_update_brightness_label()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_resolution_slider_value_changed(value: float) -> void:
|
||||||
|
VideoSettings.resolution_scale = value
|
||||||
|
VideoSettings.apply_resolution_scale()
|
||||||
|
_update_resolution_label()
|
||||||
|
_mark_custom_if_user_driven()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_vsync_dropdown_item_selected(index: int) -> void:
|
||||||
|
VideoSettings.vsync_mode = VSYNC_OPTIONS[index]["mode"]
|
||||||
|
VideoSettings.apply_vsync()
|
||||||
|
_update_fps_cap_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_fps_cap_dropdown_item_selected(index: int) -> void:
|
||||||
|
VideoSettings.fps_cap_divisor = _fps_cap_divisors[index]
|
||||||
|
VideoSettings.apply_fps_cap()
|
||||||
|
|
||||||
|
|
||||||
|
# Preset-gated fields (AA, resolution scale — glow/shadows/SDFGI/SSIL/SSAO
|
||||||
|
# have no direct control in this menu yet) flip the preset dropdown to
|
||||||
|
# Custom when the player overrides them by hand, so the dropdown never shows
|
||||||
|
# a preset name next to settings that preset doesn't actually produce. Not
|
||||||
|
# called while _populating (initial load) or while apply_preset() itself is
|
||||||
|
# writing these same fields (VideoSettings.mark_custom() no-ops there too,
|
||||||
|
# but skipping the redundant dropdown repopulation here is cheap).
|
||||||
|
func _mark_custom_if_user_driven() -> void:
|
||||||
|
if _populating:
|
||||||
|
return
|
||||||
|
VideoSettings.mark_custom()
|
||||||
|
_populating = true
|
||||||
|
_populate_preset_dropdown()
|
||||||
|
_populating = false
|
||||||
|
|
||||||
|
|
||||||
func _on_back_pressed() -> void:
|
func _on_back_pressed() -> void:
|
||||||
VideoSettings.save()
|
VideoSettings.save()
|
||||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||||
|
|||||||
+72
-7
@@ -1,5 +1,6 @@
|
|||||||
class_name Ship
|
class_name Ship
|
||||||
extends RigidBody3D
|
extends RigidBody3D
|
||||||
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||||
|
|
||||||
# Physics-driven spaceship. All movement is force/torque-based, applied in
|
# Physics-driven spaceship. All movement is force/torque-based, applied in
|
||||||
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
|
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
|
||||||
@@ -44,7 +45,8 @@ extends RigidBody3D
|
|||||||
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
|
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
|
||||||
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
|
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
|
||||||
# _apply_team_color() retints them per-team and must keep addressing them by
|
# _apply_team_color() retints them per-team and must keep addressing them by
|
||||||
# name). Verified via get_surface_count()/surface_get_material() before
|
# name, under $Visual — see that function). Verified via
|
||||||
|
# get_surface_count()/surface_get_material() before
|
||||||
# writing this: hull and canopy are each a single surface with their own
|
# writing this: hull and canopy are each a single surface with their own
|
||||||
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
|
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
|
||||||
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
|
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
|
||||||
@@ -105,6 +107,43 @@ var _current_action: ShipAction = ShipAction.new()
|
|||||||
var _inert_action: ShipAction = ShipAction.new()
|
var _inert_action: ShipAction = ShipAction.new()
|
||||||
var _boundary: ArenaBoundary
|
var _boundary: ArenaBoundary
|
||||||
|
|
||||||
|
var _pending_teleport: Transform3D
|
||||||
|
var _has_pending_teleport := false
|
||||||
|
|
||||||
|
|
||||||
|
# Queues an authoritative teleport, applied at the top of the next
|
||||||
|
# _integrate_forces — the only Jolt-safe place to write state.transform
|
||||||
|
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
|
||||||
|
# physics step via set_deferred("global_transform", ...).
|
||||||
|
func queue_teleport(to: Transform3D) -> void:
|
||||||
|
_pending_teleport = to
|
||||||
|
_has_pending_teleport = true
|
||||||
|
|
||||||
|
|
||||||
|
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
|
||||||
|
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
|
||||||
|
# hook in _integrate_forces below is a no-op today.
|
||||||
|
# Velocity delta from a soft correction, consumed once then cleared —
|
||||||
|
# applied in full immediately (invisible to the player, and it's the
|
||||||
|
# *cause* of future position error, so blending it just prolongs
|
||||||
|
# divergence).
|
||||||
|
var net_vel_correction := Vector3.ZERO
|
||||||
|
# Rendered offset between the body and $Visual while a soft correction
|
||||||
|
# decays away, so a position correction moves the collider in full without
|
||||||
|
# visibly teleporting the mesh. Same decay convention as drag/righting
|
||||||
|
# torque (_tick_scaled) above.
|
||||||
|
var net_visual_offset := Vector3.ZERO
|
||||||
|
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||||
|
|
||||||
|
|
||||||
|
# Feeds thrust_z/turbo into the movement VFX for a ship with no local
|
||||||
|
# controller driving _integrate_forces (a frozen remote ship never calls
|
||||||
|
# get_action(), so _update_movement_vfx's engine glow/flame would otherwise
|
||||||
|
# read a stale or zeroed action and show dead engines).
|
||||||
|
func set_visual_action(thrust_z: float, turbo: bool) -> void:
|
||||||
|
_current_action.thrust.z = thrust_z
|
||||||
|
_current_action.turbo = turbo
|
||||||
|
|
||||||
# Instrument signals for efficient data distribution
|
# Instrument signals for efficient data distribution
|
||||||
signal speed_changed(speed: float)
|
signal speed_changed(speed: float)
|
||||||
signal attitude_changed(pitch: float, roll: float, yaw: float)
|
signal attitude_changed(pitch: float, roll: float, yaw: float)
|
||||||
@@ -135,6 +174,13 @@ var _engine_cores: Array[MeshInstance3D] = []
|
|||||||
var _engine_flames: Array[MeshInstance3D] = []
|
var _engine_flames: Array[MeshInstance3D] = []
|
||||||
var _engine_lights: Array[OmniLight3D] = []
|
var _engine_lights: Array[OmniLight3D] = []
|
||||||
|
|
||||||
|
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
|
||||||
|
# TailFin) parents under this instead of the RigidBody3D directly, so a
|
||||||
|
# future prediction correction (task 0.14) can offset the visual without
|
||||||
|
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
|
||||||
|
# and the controller child correctly stay on the body itself.
|
||||||
|
@onready var visual: Node3D = $Visual
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Add ship to group for instrument discovery
|
# Add ship to group for instrument discovery
|
||||||
@@ -177,7 +223,7 @@ func _apply_team_color() -> void:
|
|||||||
return
|
return
|
||||||
var accent := _get_team_material(team)
|
var accent := _get_team_material(team)
|
||||||
for mesh_name in ["Nose", "TailFin"]:
|
for mesh_name in ["Nose", "TailFin"]:
|
||||||
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
|
var mesh := get_node_or_null("Visual/" + mesh_name) as MeshInstance3D
|
||||||
if mesh:
|
if mesh:
|
||||||
mesh.material_override = accent
|
mesh.material_override = accent
|
||||||
|
|
||||||
@@ -205,7 +251,7 @@ func _build_merged_hull() -> void:
|
|||||||
var instance := MeshInstance3D.new()
|
var instance := MeshInstance3D.new()
|
||||||
instance.name = "MergedHull"
|
instance.name = "MergedHull"
|
||||||
instance.mesh = mesh
|
instance.mesh = mesh
|
||||||
add_child(instance)
|
visual.add_child(instance)
|
||||||
|
|
||||||
|
|
||||||
# Attach the node that drives this ship (player, AI, or network). Replaces
|
# Attach the node that drives this ship (player, AI, or network). Replaces
|
||||||
@@ -238,7 +284,7 @@ func _build_movement_vfx() -> void:
|
|||||||
core.position = engine_pos
|
core.position = engine_pos
|
||||||
core.mesh = core_mesh
|
core.mesh = core_mesh
|
||||||
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
add_child(core)
|
visual.add_child(core)
|
||||||
_engine_cores.append(core)
|
_engine_cores.append(core)
|
||||||
|
|
||||||
# A single conventional orange flame replaces the layered particle plume
|
# A single conventional orange flame replaces the layered particle plume
|
||||||
@@ -265,7 +311,7 @@ func _build_movement_vfx() -> void:
|
|||||||
flame.mesh = flame_mesh
|
flame.mesh = flame_mesh
|
||||||
flame.visible = false
|
flame.visible = false
|
||||||
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
add_child(flame)
|
visual.add_child(flame)
|
||||||
_engine_flames.append(flame)
|
_engine_flames.append(flame)
|
||||||
|
|
||||||
var light := OmniLight3D.new()
|
var light := OmniLight3D.new()
|
||||||
@@ -275,7 +321,7 @@ func _build_movement_vfx() -> void:
|
|||||||
light.omni_range = 3.5
|
light.omni_range = 3.5
|
||||||
light.omni_attenuation = 2.0
|
light.omni_attenuation = 2.0
|
||||||
light.shadow_enabled = false
|
light.shadow_enabled = false
|
||||||
add_child(light)
|
visual.add_child(light)
|
||||||
_engine_lights.append(light)
|
_engine_lights.append(light)
|
||||||
|
|
||||||
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
|
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
|
||||||
@@ -343,6 +389,25 @@ func _has_telemetry_listeners() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func _integrate_forces(state):
|
func _integrate_forces(state):
|
||||||
|
if _has_pending_teleport:
|
||||||
|
_has_pending_teleport = false
|
||||||
|
state.transform = _pending_teleport
|
||||||
|
state.linear_velocity = Vector3.ZERO
|
||||||
|
state.angular_velocity = Vector3.ZERO
|
||||||
|
reset_physics_interpolation()
|
||||||
|
|
||||||
|
# --- Netcode correction hook (Phase 4) --- guarded: both fields default
|
||||||
|
# to Vector3.ZERO and nothing writes them yet, so neither branch runs
|
||||||
|
# today.
|
||||||
|
if net_vel_correction != Vector3.ZERO:
|
||||||
|
state.linear_velocity += net_vel_correction
|
||||||
|
net_vel_correction = Vector3.ZERO
|
||||||
|
if net_visual_offset != Vector3.ZERO:
|
||||||
|
net_visual_offset *= _tick_scaled(NET_VISUAL_OFFSET_DECAY, state.step)
|
||||||
|
if net_visual_offset.length_squared() < 0.0001:
|
||||||
|
net_visual_offset = Vector3.ZERO
|
||||||
|
visual.position = net_visual_offset
|
||||||
|
|
||||||
# One action per physics tick, pulled from the controller (deterministic)
|
# One action per physics tick, pulled from the controller (deterministic)
|
||||||
_current_action = controller.get_action() if controller else _inert_action
|
_current_action = controller.get_action() if controller else _inert_action
|
||||||
|
|
||||||
@@ -448,7 +513,7 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
|
|||||||
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
|
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
|
||||||
# decays at the same rate per second regardless of physics_ticks_per_second.
|
# decays at the same rate per second regardless of physics_ticks_per_second.
|
||||||
func _tick_scaled(k: float, step: float) -> float:
|
func _tick_scaled(k: float, step: float) -> float:
|
||||||
return pow(k, step * 60.0)
|
return pow(k, step * SimConstants.TICK_HZ)
|
||||||
|
|
||||||
|
|
||||||
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
|
||||||
|
|||||||
@@ -9,3 +9,15 @@ extends RefCounted
|
|||||||
var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back
|
var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back
|
||||||
var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll
|
var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll
|
||||||
var turbo := false
|
var turbo := false
|
||||||
|
|
||||||
|
|
||||||
|
# Returns a distinct ShipAction with equal fields. Callers that hold onto an
|
||||||
|
# action past the tick it was returned in (input history, prediction ring)
|
||||||
|
# must copy() it — get_action() implementations are free to return a reused
|
||||||
|
# instance, and player_ship_controller.gd's does.
|
||||||
|
func copy() -> ShipAction:
|
||||||
|
var c := ShipAction.new()
|
||||||
|
c.thrust = thrust
|
||||||
|
c.rotation = rotation
|
||||||
|
c.turbo = turbo
|
||||||
|
return c
|
||||||
|
|||||||
+111
-8
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
|
|||||||
@export_group("Impact Shake")
|
@export_group("Impact Shake")
|
||||||
@export var max_shake_offset := 0.32
|
@export var max_shake_offset := 0.32
|
||||||
@export var shake_decay := 8.0
|
@export var shake_decay := 8.0
|
||||||
|
@export_group("Impact Punch")
|
||||||
|
# Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd):
|
||||||
|
# a camera-only FOV kick + PostFX flash that decays over real time, so it
|
||||||
|
# works identically for hit-stop and goal moments without touching global
|
||||||
|
# simulation speed — which a networked client could never do to a shared sim.
|
||||||
|
@export var punch_fov_kick := 9.0
|
||||||
|
@export var punch_vignette_kick := 0.28
|
||||||
|
@export var punch_chroma_kick := 0.012
|
||||||
|
@export var punch_decay := 5.0
|
||||||
|
|
||||||
var target: Ship:
|
var target: Ship:
|
||||||
set(value):
|
set(value):
|
||||||
@@ -33,6 +42,16 @@ var target: Ship:
|
|||||||
target.ball_contact.disconnect(_on_target_ball_contact)
|
target.ball_contact.disconnect(_on_target_ball_contact)
|
||||||
target = value
|
target = value
|
||||||
_connect_target()
|
_connect_target()
|
||||||
|
# Priming: a freshly assigned target's Visual has no interpolation
|
||||||
|
# history yet (or is about to be reparented mid-spawn), and the rig
|
||||||
|
# itself would otherwise lerp in from wherever it was previously
|
||||||
|
# (world origin on first spawn, the old target on a Spectate switch)
|
||||||
|
# over camera_smoothing seconds. Both read as a visible swoop/smear;
|
||||||
|
# neither is a real camera move.
|
||||||
|
if is_instance_valid(target) and is_instance_valid(target.visual):
|
||||||
|
target.visual.reset_physics_interpolation()
|
||||||
|
if is_inside_tree():
|
||||||
|
snap_to_target()
|
||||||
var ball_cam_enabled := true
|
var ball_cam_enabled := true
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Camera3D
|
@onready var camera: Camera3D = $Camera3D
|
||||||
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
|
|||||||
var _shake_noise := FastNoiseLite.new()
|
var _shake_noise := FastNoiseLite.new()
|
||||||
var _shake_time := 0.0
|
var _shake_time := 0.0
|
||||||
var _last_shake_offset := Vector3.ZERO
|
var _last_shake_offset := Vector3.ZERO
|
||||||
|
var _punch_strength := 0.0
|
||||||
var _goal_cut_active := false
|
var _goal_cut_active := false
|
||||||
var _goal_cut_position := Vector3.ZERO
|
var _goal_cut_position := Vector3.ZERO
|
||||||
var _goal_cut_look_at := Vector3.ZERO
|
var _goal_cut_look_at := Vector3.ZERO
|
||||||
|
|
||||||
|
const SHAKE_UPDATE_HZ := 60.0
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Group lets the HUD discover the rig for the camera-mode instrument
|
# Group lets the HUD discover the rig for the camera-mode instrument
|
||||||
add_to_group("ship_camera")
|
add_to_group("ship_camera")
|
||||||
|
# The rig moves itself every rendered frame in _process now (task 0.16),
|
||||||
|
# not on the physics tick — Godot's built-in physics interpolation would
|
||||||
|
# otherwise smooth between _physics_process-era transforms this rig no
|
||||||
|
# longer writes, fighting the manual smoothing below.
|
||||||
|
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||||||
camera.fov = base_fov
|
camera.fov = base_fov
|
||||||
_effective_distance = camera_distance
|
_effective_distance = camera_distance
|
||||||
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
@@ -83,7 +110,7 @@ func _input(event):
|
|||||||
camera_mode_changed.emit(ball_cam_enabled)
|
camera_mode_changed.emit(ball_cam_enabled)
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta):
|
func _process(delta):
|
||||||
# Camera position smoothing operates on the unshaken chase position. Remove
|
# Camera position smoothing operates on the unshaken chase position. Remove
|
||||||
# last frame's presentation-only offset first so shake never accumulates or
|
# last frame's presentation-only offset first so shake never accumulates or
|
||||||
# exceeds its configured amplitude during a low-time-scale hit-stop.
|
# exceeds its configured amplitude during a low-time-scale hit-stop.
|
||||||
@@ -93,6 +120,10 @@ func _physics_process(delta):
|
|||||||
return
|
return
|
||||||
if _goal_cut_active:
|
if _goal_cut_active:
|
||||||
_smooth_look_at(_goal_cut_look_at, delta)
|
_smooth_look_at(_goal_cut_look_at, delta)
|
||||||
|
# The cinematic cut freezes camera position, but leftover shake from
|
||||||
|
# an impact right before the goal must still bleed off in real time —
|
||||||
|
# otherwise it resumes at full pre-cut magnitude when play returns.
|
||||||
|
_decay_shake(delta)
|
||||||
return
|
return
|
||||||
_update_speed_feel(delta)
|
_update_speed_feel(delta)
|
||||||
var ball := _get_ball()
|
var ball := _get_ball()
|
||||||
@@ -100,6 +131,7 @@ func _physics_process(delta):
|
|||||||
_update_ball_cam(delta, ball)
|
_update_ball_cam(delta, ball)
|
||||||
else:
|
else:
|
||||||
_update_ship_cam(delta)
|
_update_ship_cam(delta)
|
||||||
|
_apply_punch(delta)
|
||||||
|
|
||||||
|
|
||||||
func _get_ball() -> Node3D:
|
func _get_ball() -> Node3D:
|
||||||
@@ -112,7 +144,14 @@ func _update_ball_cam(delta, ball: Node3D):
|
|||||||
# Ball cam keeps the ship between the camera and the ball: the camera sits
|
# Ball cam keeps the ship between the camera and the ball: the camera sits
|
||||||
# on the ball→ship line (horizontal component only), looking at the ball,
|
# on the ball→ship line (horizontal component only), looking at the ball,
|
||||||
# so the ship stays low-centre in frame and the ball stays centred.
|
# so the ship stays low-centre in frame and the ball stays centred.
|
||||||
var ship_pos: Vector3 = target.global_transform.origin
|
# Reads target.visual, not target itself: once prediction correction
|
||||||
|
# (task 0.14) lands, the body can be offset from what's on screen — the
|
||||||
|
# camera must always frame what the player sees, not the collider.
|
||||||
|
# get_global_transform_interpolated() (not global_transform) because this
|
||||||
|
# now runs in _process: the physics body only moves once per 60Hz tick,
|
||||||
|
# so sampling its raw transform every rendered frame at 240fps would
|
||||||
|
# repeat the same value 4 times in a row and read as stutter.
|
||||||
|
var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().origin
|
||||||
var ball_pos: Vector3 = ball.global_transform.origin
|
var ball_pos: Vector3 = ball.global_transform.origin
|
||||||
|
|
||||||
# Smooth the orbit direction in angle space rather than lerping the camera
|
# Smooth the orbit direction in angle space rather than lerping the camera
|
||||||
@@ -145,9 +184,12 @@ func _update_ball_cam(delta, ball: Node3D):
|
|||||||
|
|
||||||
|
|
||||||
func _update_ship_cam(delta):
|
func _update_ship_cam(delta):
|
||||||
# In ship cam, camera follows and looks in the same direction as the ship
|
# In ship cam, camera follows and looks in the same direction as the ship.
|
||||||
var ship_pos: Vector3 = target.global_transform.origin
|
# Reads target.visual, not target itself, via the interpolated transform —
|
||||||
var ship_forward: Vector3 = -target.global_transform.basis.z
|
# see _update_ball_cam.
|
||||||
|
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||||
|
var ship_pos: Vector3 = visual_xform.origin
|
||||||
|
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||||
|
|
||||||
# Position camera behind and above the ship
|
# Position camera behind and above the ship
|
||||||
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||||
@@ -189,6 +231,7 @@ func _update_speed_feel(delta: float) -> void:
|
|||||||
|
|
||||||
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
|
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
|
||||||
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
|
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
|
||||||
|
_punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0))
|
||||||
for device in Input.get_connected_joypads():
|
for device in Input.get_connected_joypads():
|
||||||
Input.start_joy_vibration(
|
Input.start_joy_vibration(
|
||||||
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
|
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
|
||||||
@@ -201,17 +244,77 @@ func _apply_shake(delta: float) -> void:
|
|||||||
if _shake_strength <= 0.001:
|
if _shake_strength <= 0.001:
|
||||||
_shake_strength = 0.0
|
_shake_strength = 0.0
|
||||||
return
|
return
|
||||||
_shake_time += delta * 60.0
|
_shake_time += delta
|
||||||
|
# Quantized to a fixed 60Hz cadence rather than sampled once per rendered
|
||||||
|
# frame. The noise domain's total distance travelled per second is the
|
||||||
|
# same either way, but that's not what "reads the same" means here: at
|
||||||
|
# 60fps, consecutive samples are frequency (2.5) domain-units apart —
|
||||||
|
# far enough that FastNoiseLite's simplex correlation has decayed, so it
|
||||||
|
# reads as sharp, uncorrelated jitter. At 240fps the same per-second
|
||||||
|
# distance is split across 4x the samples, so consecutive samples are
|
||||||
|
# ~4x closer together and highly correlated — a completely different,
|
||||||
|
# much gentler wobble. Freezing the domain input to whole 60Hz ticks
|
||||||
|
# makes every render frame within one tick reuse the exact same sample,
|
||||||
|
# so the perceived shake texture is identical at any frame rate.
|
||||||
|
var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ)
|
||||||
var amplitude := max_shake_offset * _shake_strength * _shake_strength
|
var amplitude := max_shake_offset * _shake_strength * _shake_strength
|
||||||
var noise := Vector2(
|
var noise := Vector2(
|
||||||
_shake_noise.get_noise_1d(_shake_time),
|
_shake_noise.get_noise_1d(tick),
|
||||||
_shake_noise.get_noise_1d(_shake_time + 100.0)
|
_shake_noise.get_noise_1d(tick + 100.0)
|
||||||
).limit_length(1.0) * amplitude
|
).limit_length(1.0) * amplitude
|
||||||
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
|
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
|
||||||
camera.global_position += _last_shake_offset
|
camera.global_position += _last_shake_offset
|
||||||
|
_decay_shake(delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _decay_shake(delta: float) -> void:
|
||||||
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
||||||
|
|
||||||
|
|
||||||
|
# Additive FOV kick + PostFX flash on top of _update_speed_feel's base
|
||||||
|
# values, decaying over real time. Replaces the weight that Engine.time_scale
|
||||||
|
# hit-stop used to sell on ball impact — see the Impact Punch export group.
|
||||||
|
func _apply_punch(delta: float) -> void:
|
||||||
|
if _punch_strength <= 0.001:
|
||||||
|
_punch_strength = 0.0
|
||||||
|
return
|
||||||
|
camera.fov += punch_fov_kick * _punch_strength
|
||||||
|
var chroma: float = post_material.get_shader_parameter("chromatic_aberration")
|
||||||
|
var vignette: float = post_material.get_shader_parameter("vignette_strength")
|
||||||
|
post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength)
|
||||||
|
post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength)
|
||||||
|
_punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta)
|
||||||
|
|
||||||
|
|
||||||
|
# Places the camera at its resting chase position instantly, bypassing
|
||||||
|
# camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the
|
||||||
|
# thing being framed just teleported (kickoff, target reassignment) — without
|
||||||
|
# this the rig would lerp smoothly across the whole arena over
|
||||||
|
# camera_smoothing seconds, which reads as an unintended camera move rather
|
||||||
|
# than a reset.
|
||||||
|
func snap_to_target() -> void:
|
||||||
|
if not is_instance_valid(target) or not is_instance_valid(camera):
|
||||||
|
return
|
||||||
|
_shake_strength = 0.0
|
||||||
|
camera.global_position -= _last_shake_offset
|
||||||
|
_last_shake_offset = Vector3.ZERO
|
||||||
|
var visual_xform := target.visual.get_global_transform_interpolated()
|
||||||
|
var ship_pos: Vector3 = visual_xform.origin
|
||||||
|
var ball := _get_ball()
|
||||||
|
if ball_cam_enabled and ball:
|
||||||
|
var ball_pos: Vector3 = ball.global_transform.origin
|
||||||
|
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
|
||||||
|
_orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK
|
||||||
|
camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
|
||||||
|
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||||
|
camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP)
|
||||||
|
else:
|
||||||
|
var ship_forward: Vector3 = -visual_xform.basis.z
|
||||||
|
camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
|
||||||
|
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
|
||||||
|
camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP)
|
||||||
|
|
||||||
|
|
||||||
func begin_goal_cut(goal_position: Vector3) -> void:
|
func begin_goal_cut(goal_position: Vector3) -> void:
|
||||||
_goal_cut_active = true
|
_goal_cut_active = true
|
||||||
# The hard cut replaces the chase camera's presentation offset entirely;
|
# The hard cut replaces the chase camera's presentation offset entirely;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class_name SimConstants
|
||||||
|
|
||||||
|
# Single source of truth for the physics tick rate. Every script-side timing
|
||||||
|
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
|
||||||
|
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
|
||||||
|
# instead of restating the literal, so changing it changes every derived
|
||||||
|
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
|
||||||
|
# simulation needs to be a config change plus a retrain, not a protocol
|
||||||
|
# rewrite hunting down bare 60s.
|
||||||
|
#
|
||||||
|
# NOT wired to project.godot's physics/common/physics_ticks_per_second — an
|
||||||
|
# engine setting, not a script constant, so it must still be changed by hand
|
||||||
|
# to match (currently unset, engine default 60 — see task 0.18).
|
||||||
|
const TICK_HZ := 60
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
class_name TrainingMode
|
class_name TrainingMode
|
||||||
extends GameMode
|
extends GameMode
|
||||||
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||||
|
|
||||||
# Headless self-play training mode: two RL-driven ships, no HUD, no camera.
|
# Headless self-play training mode: two RL-driven ships, no HUD, no camera.
|
||||||
# The scene also contains the godot_rl_agents Sync node, which speaks TCP to
|
# The scene also contains the godot_rl_agents Sync node, which speaks TCP to
|
||||||
@@ -120,8 +121,9 @@ const MAX_RANDOM_SHIP_SPEED := 8.0
|
|||||||
# marker spacing, which uses the same margin for the same reason.
|
# marker spacing, which uses the same margin for the same reason.
|
||||||
const MIN_SHIP_SEPARATION := 4.5
|
const MIN_SHIP_SEPARATION := 4.5
|
||||||
|
|
||||||
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
|
# Sim runs at SimConstants.TICK_HZ physics ticks per sim-second regardless
|
||||||
const TICKS_PER_SIM_SECOND := 60.0
|
# of speedup.
|
||||||
|
const TICKS_PER_SIM_SECOND := float(SimConstants.TICK_HZ)
|
||||||
|
|
||||||
var _agents: Array[ShipAIController] = []
|
var _agents: Array[ShipAIController] = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,164 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
# Autoload: persisted player-facing video preferences (AA, glow, brightness).
|
# Autoload: persisted player-facing video preferences (preset, AA, glow,
|
||||||
# AA is a Viewport-wide setting applied immediately via apply_aa(). Glow and
|
# brightness, vsync, fps cap, resolution scale). AA/vsync/fps-cap/resolution
|
||||||
|
# scale are Viewport- or DisplayServer-wide and applied immediately. Glow and
|
||||||
# brightness instead scale each arena's own tuned Environment values (see
|
# brightness instead scale each arena's own tuned Environment values (see
|
||||||
# arena.gd's _ready(), which calls apply_to_environment() once per arena
|
# arena.gd's _ready(), which calls apply_to_environment() once per arena
|
||||||
# load) rather than overwriting them outright, so the per-arena bloom tuning
|
# load) rather than overwriting them outright, so the per-arena bloom tuning
|
||||||
# in arena_01/02/03.tscn survives underneath the user's preference.
|
# in arena_01/02/03.tscn survives underneath the user's preference.
|
||||||
|
|
||||||
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA }
|
signal settings_changed # Arenas re-apply preset-gated Environment/light state live.
|
||||||
|
|
||||||
|
# MSAA_2X appended at the end, not inserted, so existing user://settings.cfg
|
||||||
|
# files (which store this as a bare integer ordinal) keep meaning the same
|
||||||
|
# thing after this rung was added — see task 0.19.
|
||||||
|
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA, MSAA_2X }
|
||||||
|
|
||||||
|
# Ordinals are persisted the same way as AAMode above — append only.
|
||||||
|
enum Preset { LOW, MEDIUM, HIGH, CUSTOM }
|
||||||
|
enum VsyncMode { DISABLED, ENABLED, ADAPTIVE }
|
||||||
|
|
||||||
const SETTINGS_PATH := "user://settings.cfg"
|
const SETTINGS_PATH := "user://settings.cfg"
|
||||||
|
|
||||||
var aa_mode: AAMode = AAMode.MSAA_FXAA
|
# preset -> bundle applied to the individual fields below. CUSTOM has no
|
||||||
|
# bundle: selecting it just stops future preset changes from overwriting
|
||||||
|
# whatever the individual fields currently hold. Task 0.15b's measured
|
||||||
|
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
|
||||||
|
# against each other, so each rung is "meaningfully fewer full-screen passes
|
||||||
|
# than the one above it" rather than a precisely tuned ladder.
|
||||||
|
const PRESET_BUNDLES := {
|
||||||
|
Preset.LOW: {
|
||||||
|
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": false,
|
||||||
|
"shadows_enabled": false, "glow_enabled": false, "aa_mode": AAMode.OFF,
|
||||||
|
"resolution_scale": 0.8,
|
||||||
|
},
|
||||||
|
Preset.MEDIUM: {
|
||||||
|
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": true,
|
||||||
|
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
|
||||||
|
"resolution_scale": 1.0,
|
||||||
|
},
|
||||||
|
Preset.HIGH: {
|
||||||
|
"sdfgi_enabled": true, "ssil_enabled": true, "ssao_enabled": true,
|
||||||
|
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
|
||||||
|
"resolution_scale": 1.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var preset: Preset = Preset.HIGH
|
||||||
|
var sdfgi_enabled: bool = true
|
||||||
|
var ssil_enabled: bool = true
|
||||||
|
var ssao_enabled: bool = true
|
||||||
|
var shadows_enabled: bool = true
|
||||||
|
var glow_enabled: bool = true
|
||||||
|
|
||||||
|
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
|
||||||
|
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
|
||||||
|
var aa_mode: AAMode = AAMode.FXAA
|
||||||
var glow_scale: float = 1.0
|
var glow_scale: float = 1.0
|
||||||
var brightness: float = 1.0
|
var brightness: float = 1.0
|
||||||
|
|
||||||
|
# 0.17b: Viewport.scaling_3d_scale, 0.5-1.0. Distinct from window stretch
|
||||||
|
# (0.17c) — this scales the 3D viewport's own internal render resolution
|
||||||
|
# before the fixed-1080p blit, so it works regardless of the stretch
|
||||||
|
# decision. FSR2 rather than bilinear: a fixed-1080p target already discards
|
||||||
|
# native resolution (see 0.17c), so FSR2's per-pixel sharpening recovers more
|
||||||
|
# of that loss than a plain bilinear upscale at the same internal scale.
|
||||||
|
var resolution_scale: float = 1.0
|
||||||
|
var fsr_sharpness: float = 0.2
|
||||||
|
|
||||||
|
var vsync_mode: VsyncMode = VsyncMode.ADAPTIVE
|
||||||
|
# 0 = uncapped; otherwise divides DisplayServer.screen_get_refresh_rate() at
|
||||||
|
# apply time (not stored as a raw fps number) so the same preference re-derives
|
||||||
|
# correctly if the game later runs on a different-refresh-rate display. Only
|
||||||
|
# takes effect when vsync_mode == DISABLED — vsync itself already caps to the
|
||||||
|
# refresh rate (or an unpredictable multiple of it, for ADAPTIVE) otherwise.
|
||||||
|
var fps_cap_divisor: int = 0
|
||||||
|
|
||||||
|
var _applying_preset := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_load()
|
_load()
|
||||||
|
# A headless server never renders; applying any of this to its root
|
||||||
|
# viewport or window is pure waste (mirrors the same guard at ship.gd and
|
||||||
|
# arena_boundary.gd).
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
apply_aa()
|
apply_aa()
|
||||||
|
apply_resolution_scale()
|
||||||
|
apply_vsync()
|
||||||
|
|
||||||
|
|
||||||
func _load() -> void:
|
func _load() -> void:
|
||||||
var cfg := ConfigFile.new()
|
var cfg := ConfigFile.new()
|
||||||
if cfg.load(SETTINGS_PATH) != OK:
|
if cfg.load(SETTINGS_PATH) != OK:
|
||||||
return
|
return
|
||||||
|
preset = cfg.get_value("video", "preset", preset) as Preset
|
||||||
|
sdfgi_enabled = cfg.get_value("video", "sdfgi_enabled", sdfgi_enabled)
|
||||||
|
ssil_enabled = cfg.get_value("video", "ssil_enabled", ssil_enabled)
|
||||||
|
ssao_enabled = cfg.get_value("video", "ssao_enabled", ssao_enabled)
|
||||||
|
shadows_enabled = cfg.get_value("video", "shadows_enabled", shadows_enabled)
|
||||||
|
glow_enabled = cfg.get_value("video", "glow_enabled", glow_enabled)
|
||||||
aa_mode = cfg.get_value("video", "aa_mode", aa_mode) as AAMode
|
aa_mode = cfg.get_value("video", "aa_mode", aa_mode) as AAMode
|
||||||
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
|
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
|
||||||
brightness = cfg.get_value("video", "brightness", brightness)
|
brightness = cfg.get_value("video", "brightness", brightness)
|
||||||
|
resolution_scale = cfg.get_value("video", "resolution_scale", resolution_scale)
|
||||||
|
fsr_sharpness = cfg.get_value("video", "fsr_sharpness", fsr_sharpness)
|
||||||
|
vsync_mode = cfg.get_value("video", "vsync_mode", vsync_mode) as VsyncMode
|
||||||
|
fps_cap_divisor = cfg.get_value("video", "fps_cap_divisor", fps_cap_divisor)
|
||||||
|
|
||||||
|
|
||||||
func save() -> void:
|
func save() -> void:
|
||||||
var cfg := ConfigFile.new()
|
var cfg := ConfigFile.new()
|
||||||
|
cfg.set_value("video", "preset", preset)
|
||||||
|
cfg.set_value("video", "sdfgi_enabled", sdfgi_enabled)
|
||||||
|
cfg.set_value("video", "ssil_enabled", ssil_enabled)
|
||||||
|
cfg.set_value("video", "ssao_enabled", ssao_enabled)
|
||||||
|
cfg.set_value("video", "shadows_enabled", shadows_enabled)
|
||||||
|
cfg.set_value("video", "glow_enabled", glow_enabled)
|
||||||
cfg.set_value("video", "aa_mode", aa_mode)
|
cfg.set_value("video", "aa_mode", aa_mode)
|
||||||
cfg.set_value("video", "glow_scale", glow_scale)
|
cfg.set_value("video", "glow_scale", glow_scale)
|
||||||
cfg.set_value("video", "brightness", brightness)
|
cfg.set_value("video", "brightness", brightness)
|
||||||
|
cfg.set_value("video", "resolution_scale", resolution_scale)
|
||||||
|
cfg.set_value("video", "fsr_sharpness", fsr_sharpness)
|
||||||
|
cfg.set_value("video", "vsync_mode", vsync_mode)
|
||||||
|
cfg.set_value("video", "fps_cap_divisor", fps_cap_divisor)
|
||||||
cfg.save(SETTINGS_PATH)
|
cfg.save(SETTINGS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# Pushes a preset's bundle into the individual fields and applies everything
|
||||||
|
# live. CUSTOM is a no-op bundle-wise — it only matters as a marker so
|
||||||
|
# set_custom_field() below knows not to silently revert to Custom itself.
|
||||||
|
func apply_preset(new_preset: Preset) -> void:
|
||||||
|
preset = new_preset
|
||||||
|
if PRESET_BUNDLES.has(new_preset):
|
||||||
|
var bundle: Dictionary = PRESET_BUNDLES[new_preset]
|
||||||
|
_applying_preset = true
|
||||||
|
sdfgi_enabled = bundle["sdfgi_enabled"]
|
||||||
|
ssil_enabled = bundle["ssil_enabled"]
|
||||||
|
ssao_enabled = bundle["ssao_enabled"]
|
||||||
|
shadows_enabled = bundle["shadows_enabled"]
|
||||||
|
glow_enabled = bundle["glow_enabled"]
|
||||||
|
aa_mode = bundle["aa_mode"]
|
||||||
|
resolution_scale = bundle["resolution_scale"]
|
||||||
|
_applying_preset = false
|
||||||
|
apply_aa()
|
||||||
|
apply_resolution_scale()
|
||||||
|
settings_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
# Called by the settings menu whenever the player edits an individual
|
||||||
|
# preset-gated field directly (not via the preset dropdown) — flips to
|
||||||
|
# Custom so the dropdown reflects reality instead of silently lying about
|
||||||
|
# which preset is "selected". No-ops during apply_preset's own writes above.
|
||||||
|
func mark_custom() -> void:
|
||||||
|
if not _applying_preset:
|
||||||
|
preset = Preset.CUSTOM
|
||||||
|
|
||||||
|
|
||||||
func apply_aa() -> void:
|
func apply_aa() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
var viewport := get_tree().root
|
var viewport := get_tree().root
|
||||||
match aa_mode:
|
match aa_mode:
|
||||||
AAMode.OFF:
|
AAMode.OFF:
|
||||||
@@ -53,13 +173,67 @@ func apply_aa() -> void:
|
|||||||
AAMode.MSAA_FXAA:
|
AAMode.MSAA_FXAA:
|
||||||
viewport.msaa_3d = Viewport.MSAA_4X
|
viewport.msaa_3d = Viewport.MSAA_4X
|
||||||
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
|
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
|
||||||
|
AAMode.MSAA_2X:
|
||||||
|
viewport.msaa_3d = Viewport.MSAA_2X
|
||||||
|
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
||||||
|
|
||||||
|
|
||||||
# Called once by each arena's _ready() to fold the user's glow/brightness
|
func apply_resolution_scale() -> void:
|
||||||
# preference into that arena's own baked Environment tuning.
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
var viewport := get_tree().root
|
||||||
|
if resolution_scale >= 0.999:
|
||||||
|
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
|
||||||
|
viewport.scaling_3d_scale = 1.0
|
||||||
|
else:
|
||||||
|
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_FSR2
|
||||||
|
viewport.scaling_3d_scale = clampf(resolution_scale, 0.5, 1.0)
|
||||||
|
viewport.fsr_sharpness = fsr_sharpness
|
||||||
|
|
||||||
|
|
||||||
|
func apply_vsync() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
match vsync_mode:
|
||||||
|
VsyncMode.DISABLED:
|
||||||
|
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
|
||||||
|
VsyncMode.ENABLED:
|
||||||
|
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
|
||||||
|
VsyncMode.ADAPTIVE:
|
||||||
|
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE)
|
||||||
|
apply_fps_cap()
|
||||||
|
|
||||||
|
|
||||||
|
# Public (not just called from apply_vsync) because gameplay-scene transitions
|
||||||
|
# (main_menu.gd's _leave_to_gameplay) need to apply the player's chosen cap
|
||||||
|
# rather than hardcoding an uncapped 0 — the menu's own refresh-rate cap is a
|
||||||
|
# separate, menu-only concern (settings_menu.gd/main_menu.gd _ready()).
|
||||||
|
func apply_fps_cap() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
if vsync_mode != VsyncMode.DISABLED or fps_cap_divisor <= 0:
|
||||||
|
Engine.max_fps = 0
|
||||||
|
return
|
||||||
|
var refresh := DisplayServer.screen_get_refresh_rate()
|
||||||
|
# refresh_rate query returning -1 (or 0, unlikely but not contractually
|
||||||
|
# excluded) falls back to uncapped rather than dividing by a negative
|
||||||
|
# number into a nonsense cap.
|
||||||
|
if refresh <= 0.0:
|
||||||
|
Engine.max_fps = 0
|
||||||
|
return
|
||||||
|
Engine.max_fps = maxi(1, roundi(refresh / float(fps_cap_divisor)))
|
||||||
|
|
||||||
|
|
||||||
|
# Called once by each arena's _ready() (and again on settings_changed, so an
|
||||||
|
# already-loaded arena updates live) to fold the user's glow/brightness
|
||||||
|
# preference into that arena's own baked Environment tuning, and to gate the
|
||||||
|
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
|
||||||
func apply_to_environment(env: Environment) -> void:
|
func apply_to_environment(env: Environment) -> void:
|
||||||
if env == null:
|
if env == null:
|
||||||
return
|
return
|
||||||
env.glow_enabled = glow_scale > 0.0
|
env.glow_enabled = glow_enabled and glow_scale > 0.0
|
||||||
env.glow_intensity *= glow_scale
|
env.glow_intensity *= glow_scale
|
||||||
env.adjustment_brightness *= brightness
|
env.adjustment_brightness *= brightness
|
||||||
|
env.sdfgi_enabled = sdfgi_enabled
|
||||||
|
env.ssil_enabled = ssil_enabled
|
||||||
|
env.ssao_enabled = ssao_enabled
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# One-off GPU frame-time profiling harness for task 0.15b's real-hardware
|
||||||
|
# follow-up (multiplayer-todo.md §5.5.1) — the automated Mac passes gave
|
||||||
|
# inconsistent, sometimes implausible numbers (stale-process contention,
|
||||||
|
# and Apple Silicon's tile-based GPU architecture is a poor stand-in for the
|
||||||
|
# target reference hardware). Run this directly on a machine with a real
|
||||||
|
# discrete desktop GPU instead:
|
||||||
|
#
|
||||||
|
# godot --path Game res://tools/gpu_profile_harness.tscn
|
||||||
|
#
|
||||||
|
# On a Linux box with no attached physical display, wrap it in a virtual
|
||||||
|
# framebuffer so it still gets a real windowing/rendering context (NOT
|
||||||
|
# --headless — that uses a dummy renderer with no GPU rendering at all,
|
||||||
|
# see CLAUDE.md's "Headless smoke test" note):
|
||||||
|
#
|
||||||
|
# xvfb-run -a --server-args="-screen 0 1920x1080x24" \
|
||||||
|
# godot --path Game res://tools/gpu_profile_harness.tscn
|
||||||
|
#
|
||||||
|
# Prints a report to stdout and also writes it to
|
||||||
|
# user://gpu_profile_report.txt — on Linux that's typically
|
||||||
|
# ~/.local/share/godot/app_userdata/Cosmic Clash/gpu_profile_report.txt; the
|
||||||
|
# exact resolved path is printed at the end of the run, so just paste that
|
||||||
|
# back. Quits itself automatically when done (~90 seconds total).
|
||||||
|
|
||||||
|
const SAMPLE_SECONDS := 4.0
|
||||||
|
const SETTLE_SECONDS := 1.5
|
||||||
|
|
||||||
|
var _match: Node
|
||||||
|
var _env: Environment
|
||||||
|
var _shadow_lights: Array[Light3D] = []
|
||||||
|
var _postfx: CanvasItem
|
||||||
|
var _viewport: Viewport
|
||||||
|
var _report_lines: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var adapter := RenderingServer.get_video_adapter_name()
|
||||||
|
var vendor := RenderingServer.get_video_adapter_vendor()
|
||||||
|
_log("GPU adapter: %s (%s)" % [adapter, vendor])
|
||||||
|
if not ("NVIDIA" in adapter.to_upper() or "NVIDIA" in vendor.to_upper()):
|
||||||
|
_log("WARNING: this doesn't look like a real NVIDIA GPU context.")
|
||||||
|
_log(" If this is llvmpipe/softpipe/Mesa software rendering, every")
|
||||||
|
_log(" number below is meaningless for GPU profiling purposes —")
|
||||||
|
_log(" check `glxinfo | grep -i renderer` and your Xorg/Xvfb/driver")
|
||||||
|
_log(" setup before trusting this report.")
|
||||||
|
|
||||||
|
var match_scene := load("res://scenes/match.tscn") as PackedScene
|
||||||
|
_match = match_scene.instantiate()
|
||||||
|
# 3v3 = 6 ships, matching the scenario multiplayer-todo.md §5.5 measures.
|
||||||
|
_match.team_size = 3
|
||||||
|
# Direct-scene-run fallback path (see match_mode.gd:_make_opponent_controller)
|
||||||
|
# — gives every AI ship a real trained policy so thruster VFX/movement
|
||||||
|
# load matches actual play, not six stationary hulls.
|
||||||
|
_match.bot_model_path = "res://bots/promoted/medium.json"
|
||||||
|
add_child(_match)
|
||||||
|
|
||||||
|
_log("Waiting for kickoff and bots to start moving...")
|
||||||
|
await get_tree().create_timer(5.0).timeout
|
||||||
|
|
||||||
|
_viewport = get_tree().root
|
||||||
|
var world_env := _match.arena.get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||||
|
if not world_env or not world_env.environment:
|
||||||
|
_log("ERROR: no WorldEnvironment found on the spawned arena — aborting.")
|
||||||
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
_env = world_env.environment
|
||||||
|
for light in _match.arena.find_children("*", "Light3D", true, false):
|
||||||
|
if (light as Light3D).shadow_enabled:
|
||||||
|
_shadow_lights.append(light)
|
||||||
|
var rig := get_tree().get_first_node_in_group("ship_camera")
|
||||||
|
_postfx = rig.get_node_or_null("PostProcess/PostFX") if rig else null
|
||||||
|
if not _postfx:
|
||||||
|
_log("WARNING: PostFX node not found — that pass won't be profiled.")
|
||||||
|
|
||||||
|
await _run_all_configs()
|
||||||
|
|
||||||
|
var report := "\n".join(_report_lines)
|
||||||
|
var f := FileAccess.open("user://gpu_profile_report.txt", FileAccess.WRITE)
|
||||||
|
if f:
|
||||||
|
f.store_string(report)
|
||||||
|
f.close()
|
||||||
|
_log("")
|
||||||
|
_log("Report written to: %s" % ProjectSettings.globalize_path("user://gpu_profile_report.txt"))
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
|
func _log(s: String) -> void:
|
||||||
|
_report_lines.append(s)
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
|
||||||
|
func _run_all_configs() -> void:
|
||||||
|
var base_sdfgi := _env.sdfgi_enabled
|
||||||
|
var base_ssil := _env.ssil_enabled
|
||||||
|
var base_ssao := _env.ssao_enabled
|
||||||
|
var base_glow := _env.glow_enabled
|
||||||
|
var base_msaa := _viewport.msaa_3d
|
||||||
|
var base_aa := _viewport.screen_space_aa
|
||||||
|
var base_postfx_visible: bool = _postfx.visible if _postfx else true
|
||||||
|
|
||||||
|
await _measure("baseline_all_on")
|
||||||
|
|
||||||
|
_env.sdfgi_enabled = false
|
||||||
|
_env.ssil_enabled = false
|
||||||
|
_env.ssao_enabled = false
|
||||||
|
_env.glow_enabled = false
|
||||||
|
for l in _shadow_lights:
|
||||||
|
l.shadow_enabled = false
|
||||||
|
_viewport.msaa_3d = Viewport.MSAA_DISABLED
|
||||||
|
_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
||||||
|
if _postfx:
|
||||||
|
_postfx.visible = false
|
||||||
|
await _measure("all_off_floor")
|
||||||
|
|
||||||
|
_restore_baseline(base_sdfgi, base_ssil, base_ssao, base_glow, base_msaa, base_aa, base_postfx_visible)
|
||||||
|
|
||||||
|
_env.sdfgi_enabled = false
|
||||||
|
await _measure("sdfgi_off")
|
||||||
|
_env.sdfgi_enabled = base_sdfgi
|
||||||
|
|
||||||
|
_env.ssil_enabled = false
|
||||||
|
await _measure("ssil_off")
|
||||||
|
_env.ssil_enabled = base_ssil
|
||||||
|
|
||||||
|
_env.ssao_enabled = false
|
||||||
|
await _measure("ssao_off")
|
||||||
|
_env.ssao_enabled = base_ssao
|
||||||
|
|
||||||
|
_env.glow_enabled = false
|
||||||
|
await _measure("glow_off")
|
||||||
|
_env.glow_enabled = base_glow
|
||||||
|
|
||||||
|
for l in _shadow_lights:
|
||||||
|
l.shadow_enabled = false
|
||||||
|
await _measure("shadows_off_all_%d_lights" % _shadow_lights.size())
|
||||||
|
for l in _shadow_lights:
|
||||||
|
l.shadow_enabled = true
|
||||||
|
|
||||||
|
_viewport.msaa_3d = Viewport.MSAA_DISABLED
|
||||||
|
await _measure("msaa_off")
|
||||||
|
_viewport.msaa_3d = base_msaa
|
||||||
|
|
||||||
|
_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
||||||
|
await _measure("fxaa_off")
|
||||||
|
_viewport.screen_space_aa = base_aa
|
||||||
|
|
||||||
|
if _postfx:
|
||||||
|
_postfx.visible = false
|
||||||
|
await _measure("postfx_off")
|
||||||
|
_postfx.visible = base_postfx_visible
|
||||||
|
|
||||||
|
|
||||||
|
func _restore_baseline(sdfgi: bool, ssil: bool, ssao: bool, glow: bool, msaa: Viewport.MSAA, aa: Viewport.ScreenSpaceAA, postfx_visible: bool) -> void:
|
||||||
|
_env.sdfgi_enabled = sdfgi
|
||||||
|
_env.ssil_enabled = ssil
|
||||||
|
_env.ssao_enabled = ssao
|
||||||
|
_env.glow_enabled = glow
|
||||||
|
for l in _shadow_lights:
|
||||||
|
l.shadow_enabled = true
|
||||||
|
_viewport.msaa_3d = msaa
|
||||||
|
_viewport.screen_space_aa = aa
|
||||||
|
if _postfx:
|
||||||
|
_postfx.visible = postfx_visible
|
||||||
|
|
||||||
|
|
||||||
|
# Raw get_process_delta_time() per rendered frame, not Performance.TIME_FPS —
|
||||||
|
# TIME_FPS is itself a smoothed/rounded value, which would understate exactly
|
||||||
|
# the p99 variance this is trying to measure.
|
||||||
|
func _measure(label: String) -> void:
|
||||||
|
await get_tree().create_timer(SETTLE_SECONDS).timeout
|
||||||
|
var samples: PackedFloat32Array = []
|
||||||
|
var elapsed := 0.0
|
||||||
|
while elapsed < SAMPLE_SECONDS:
|
||||||
|
await get_tree().process_frame
|
||||||
|
var dt := get_process_delta_time()
|
||||||
|
samples.append(dt * 1000.0)
|
||||||
|
elapsed += dt
|
||||||
|
samples.sort()
|
||||||
|
var p50 := samples[samples.size() / 2]
|
||||||
|
var p99 := samples[mini(int(samples.size() * 0.99), samples.size() - 1)]
|
||||||
|
_log("%-28s p50=%6.2fms p99=%6.2fms fps(p50)=%6.1f n=%d" % [label, p50, p99, 1000.0 / p50, samples.size()])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tools/gpu_profile_harness.gd" id="1_harness"]
|
||||||
|
|
||||||
|
[node name="GpuProfileHarness" type="Node"]
|
||||||
|
script = ExtResource("1_harness")
|
||||||
@@ -19,7 +19,10 @@ The largest gap between this and a AAA-feeling product is presentation, not code
|
|||||||
|
|
||||||
## Multiplayer (long term)
|
## Multiplayer (long term)
|
||||||
|
|
||||||
- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation.
|
Planned in **[`multiplayer-todo.md`](multiplayer-todo.md)** — architecture decisions (server-authoritative dedicated servers, client-side prediction, ENet then Steam), wire format, latency budget, and an eight-phase task breakdown. Nothing implemented yet.
|
||||||
- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score.
|
|
||||||
- [ ] C# backend / online servers per README roadmap (not started).
|
Phase 0 of that plan is a set of non-networked refactors that land independently and are verifiable in single-player today; start there. It now also carries the **graphics/performance work** — the project has never been profiled, and `video_settings.gd` exposes only AA, glow and brightness while SDFGI, SSIL, SSAO and five shadow-casting lights are on by default and unreachable (see §5.5 there).
|
||||||
- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this).
|
|
||||||
|
**Tasks 0.1–0.15, 0.18–0.25, 0.27, 0.29 are done** (see the Phase 0 table in `multiplayer-todo.md` for what each one actually changed — several deviated from the original plan for concrete GDScript/Godot reasons recorded inline). Remaining, all blocked on **0.15b (profile, on reference hardware, in the live editor — not done)**: 0.16 (camera to `_process`), 0.17/0.17b/0.17c/0.17d (graphics presets, vsync, resolution scaling), **0.26 (bake the arena GI to retire SDFGI — the largest frame-time win available, costs no image quality since the arena is fully static)**, and 0.28 (physics separate-thread prototype, flagged as the riskiest task in the phase). These need a human at the editor with real hardware to profile and eyeball, not further code changes.
|
||||||
|
|
||||||
|
- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). Unrelated to online play.
|
||||||
|
|||||||
+1007
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user