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:
Josh Creek
2026-08-19 22:37:17 +01:00
parent 88591e031f
commit 04691aaa48
29 changed files with 2212 additions and 134 deletions
+1 -1
View File
@@ -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.
- **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.
- **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.
+1
View File
@@ -15,6 +15,7 @@ collision_mask = 13
mass = 3
physics_material_override = SubResource("PhysicsMaterial_ball")
continuous_cd = true
can_sleep = false
inertia = Vector3(3, 3, 3)
gravity_scale = 0.8
linear_damp = 0.1
+6 -2
View File
@@ -17,13 +17,17 @@ collision_mask = 7
mass = 5.0
physics_material_override = SubResource("PhysicsMaterial_ship")
inertia = Vector3(7, 1, 7)
can_sleep = false
continuous_cd = true
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)
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)
mesh = ExtResource("5_talfin")
+43
View File
@@ -24,16 +24,49 @@ run/main_scene.training="res://scenes/training.tscn"
[autoload]
GameSettings="*res://scripts/game_settings.gd"
VideoSettings="*res://scripts/video_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
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/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]
@@ -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]
3d_physics/layer_1/name="Ships"
@@ -130,9 +169,13 @@ roll_right={
3d/physics_engine="Jolt Physics"
common/physics_interpolation=true
common/physics_jitter_fix=0.0
[rendering]
anti_aliasing/quality/msaa_3d=2
anti_aliasing/quality/screen_space_aa=1
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
+1 -1
View File
@@ -6,6 +6,6 @@
[node name="Match" type="Node3D"]
script = ExtResource("1_m")
bot_model_path = "res://bots/promoted/easy.json"
team_size = 3
[node name="HUD" parent="." instance=ExtResource("3_m")]
+91
View File
@@ -34,6 +34,21 @@ horizontal_alignment = 1
custom_minimum_size = Vector2(0, 14)
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"]
layout_mode = 2
theme_override_constants/separation = 10
@@ -49,6 +64,33 @@ custom_minimum_size = Vector2(0, 40)
layout_mode = 2
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"]
layout_mode = 2
theme_override_constants/separation = 10
@@ -103,6 +145,51 @@ layout_mode = 2
text = "100%"
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"]
custom_minimum_size = Vector2(0, 14)
layout_mode = 2
@@ -112,7 +199,11 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
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="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/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"]
+19
View File
@@ -1,6 +1,13 @@
class_name AIShipController
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
# same canonical observation as training (ShipObservations) and runs the
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
func _ready():
if not model_path.is_empty():
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
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
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 out := _policy.forward(obs)
# See ShipActionCodec for the decode — the single source of truth shared
+48 -12
View File
@@ -19,23 +19,59 @@ extends Node3D
@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():
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
if world_env and world_env.environment:
var env := world_env.environment.duplicate(true) as Environment
world_env.environment = env
_env = world_env.environment.duplicate(true) as Environment
world_env.environment = _env
if sky_material:
if not env.sky:
env.sky = Sky.new()
env.sky.sky_material = sky_material
env.ambient_light_color = ambient_light_color
env.ambient_light_energy = ambient_light_energy
env.glow_intensity = glow_intensity
env.glow_strength = glow_strength
env.glow_bloom = glow_bloom
env.glow_hdr_threshold = glow_hdr_threshold
VideoSettings.apply_to_environment(env)
if not _env.sky:
_env.sky = Sky.new()
_env.sky.sky_material = sky_material
_env.ambient_light_color = ambient_light_color
_env.ambient_light_energy = ambient_light_energy
_env.glow_intensity = glow_intensity
_env.glow_strength = glow_strength
_env.glow_bloom = glow_bloom
_env.glow_hdr_threshold = glow_hdr_threshold
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:
+17 -1
View File
@@ -117,6 +117,10 @@ var _field_material: ShaderMaterial
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
# pattern so the viewport lookup isn't repeated every frame.
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
# 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,
ceiling_strength: float, ceiling_range: float
) -> 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 pull := Vector3.ZERO
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()
if camera == null:
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
+22
View File
@@ -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
View File
@@ -19,6 +19,30 @@ const MAX_SPEED := 32.0
var _boundary: ArenaBoundary
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:
_boundary = get_tree().get_first_node_in_group("arena_boundary")
@@ -32,7 +56,8 @@ func _ready() -> void:
func _physics_process(_delta: float) -> void:
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.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
@@ -68,6 +93,13 @@ func _build_trail() -> 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:
var pull := _boundary.get_surface_pull(
global_position, wall_pull_strength, wall_pull_range,
+83 -72
View File
@@ -17,16 +17,10 @@ var hud: HUDController
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
var _hit_stop_generation := 0
var _hit_stop_active := false
var _time_scale_before_hit_stop := 1.0
var _camera_rig: ShipCameraRig
var _goal_slowmo_active := false
var _time_scale_before_goal := 1.0
var _goal_in_progress := false
const GOAL_CELEBRATION_SECONDS := 1.6
const GOAL_SLOWMO_SCALE := 0.22
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
@@ -38,6 +32,18 @@ var score := {0: 0, 1: 0}
func _ready():
# Group lets the HUD discover the game mode for timer/score signals
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():
if child is Arena:
arena = child
@@ -51,8 +57,9 @@ func _ready():
if not arena:
push_error("GameMode: scene has no Arena child")
return
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
if _owns_goal_logic():
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
_start()
@@ -68,6 +75,31 @@ func _start() -> void:
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`.
func _on_goal_scored(_conceding_team: int) -> void:
pass
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
_goal_in_progress = true
_on_goal_registered(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)
if not is_inside_tree():
return
_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.
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
return
_restore_hit_stop()
_goal_slowmo_active = true
_time_scale_before_goal = Engine.time_scale
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
var goal_position := Vector3.ZERO
for goal in arena.get_goals():
if goal.team == conceding_team:
goal_position = goal.global_position
break
# 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)
if hud:
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):
_camera_rig.end_goal_cut()
if hud and is_instance_valid(hud):
hud.hide_goal_celebration()
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
# time scale, but restore any hit-stop that was already queued this frame.
_restore_hit_stop()
_restore_goal_slowmo()
func _restore_goal_slowmo() -> void:
if not _goal_slowmo_active:
return
Engine.time_scale = _time_scale_before_goal
_goal_slowmo_active = false
func spawn_ball() -> RigidBody3D:
@@ -136,7 +164,7 @@ func spawn_ball() -> RigidBody3D:
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
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)
var spawns := arena.get_ship_spawns(team)
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)
_camera_rig = rig
rig.target = target
rig.impact_feedback.connect(_on_player_impact)
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
if hud:
@@ -163,42 +190,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
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
# apply their own GameSettings-override logic first, which differs between
# 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_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:
if is_instance_valid(ball):
@@ -243,24 +244,33 @@ func reset_ships() -> void:
for ship in ships:
if is_instance_valid(ship):
_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:
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
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)
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
# Deferred: a RigidBody3D transform can't be set mid-physics-step
body.set_deferred("global_transform", to)
body.set_deferred("linear_velocity", Vector3.ZERO)
body.set_deferred("angular_velocity", Vector3.ZERO)
# A kickoff reset is a teleport: without this, physics interpolation
# smears the body across the arena for a frame
body.call_deferred("reset_physics_interpolation")
# Queued and applied inside the body's own _integrate_forces — the only
# Jolt-safe place to write state.transform — instead of racing the
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
# so a statically-typed call here won't resolve.
body.call("queue_teleport", to)
func _unhandled_input(event):
@@ -282,7 +292,8 @@ const ESCAPE_MARGIN := 15.0
func _physics_process(_delta: float) -> void:
_respawn_escaped_bodies()
if _owns_world_simulation():
_respawn_escaped_bodies()
func _respawn_escaped_bodies() -> void:
+1 -1
View File
@@ -38,7 +38,7 @@ func _process(delta: float) -> void:
_pitch = new_pitch
_roll = new_roll
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+1 -1
View File
@@ -33,7 +33,7 @@ func _process(delta: float) -> void:
var changed := absf(new_value - _value) > max_value * 0.001
_value = new_value
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+1 -1
View File
@@ -35,7 +35,7 @@ func _process(delta: float) -> void:
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
_heading = new_heading
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+16
View File
@@ -7,6 +7,13 @@ extends Control
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
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:
@@ -23,3 +30,12 @@ static func angle_delta_deg(from: float, to: float) -> float:
func _smoothing_weight(delta: float) -> float:
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
# 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()
+16 -3
View File
@@ -34,6 +34,10 @@ const DIFFICULTIES := [
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_arena_dropdown()
dev_section.visible = OS.is_debug_build()
@@ -115,10 +119,19 @@ func _selected_path(dropdown: OptionButton) -> String:
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:
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
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:
@@ -134,7 +147,7 @@ func _on_match_pressed() -> void:
GameSettings.selected_bot_path = override_path
GameSettings.selected_bot_reaction_ticks = -1
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:
@@ -144,4 +157,4 @@ func _on_settings_pressed() -> void:
func _on_spectate_pressed() -> void:
GameSettings.spectate_bot_a_path = _selected_path(bot_a_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")
+62
View File
@@ -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),
]
+153 -9
View File
@@ -1,26 +1,90 @@
extends Control
# Settings screen: a small set of player-facing video knobs on top of
# VideoSettings (the autoload holding + persisting them). Anti-aliasing
# applies immediately since it's a Viewport-wide setting; glow/brightness
# apply the next time an arena loads (see arena.gd), since they scale each
# arena's own tuned Environment values rather than something viewport-wide.
# Settings screen: player-facing video knobs on top of VideoSettings (the
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
# scale apply immediately since they're Viewport- or DisplayServer-wide;
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
# an Environment, or instantly to an already-loaded one via
# VideoSettings.settings_changed (see arena.gd) — task 0.17.
const AA_OPTIONS := [
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
{"name": "MSAA 2x", "mode": VideoSettings.AAMode.MSAA_2X},
{"name": "MSAA 4x", "mode": VideoSettings.AAMode.MSAA},
{"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 glow_slider: HSlider = %GlowSlider
@onready var glow_value_label: Label = %GlowValueLabel
@onready var brightness_slider: HSlider = %BrightnessSlider
@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:
# 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()
var selected := 0
for i in AA_OPTIONS.size():
@@ -29,10 +93,39 @@ func _ready() -> void:
selected = i
aa_dropdown.select(selected)
glow_slider.value = VideoSettings.glow_scale
brightness_slider.value = VideoSettings.brightness
_update_glow_label()
_update_brightness_label()
func _populate_vsync_dropdown() -> void:
vsync_dropdown.clear()
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:
@@ -43,9 +136,26 @@ func _update_brightness_label() -> void:
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:
VideoSettings.aa_mode = AA_OPTIONS[index]["mode"]
VideoSettings.apply_aa()
_mark_custom_if_user_driven()
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()
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:
VideoSettings.save()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+72 -7
View File
@@ -1,5 +1,6 @@
class_name Ship
extends RigidBody3D
const SimConstants = preload("res://scripts/sim_constants.gd")
# Physics-driven spaceship. All movement is force/torque-based, applied in
# _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
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
# _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
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
# 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 _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
signal speed_changed(speed: 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_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():
# Add ship to group for instrument discovery
@@ -177,7 +223,7 @@ func _apply_team_color() -> void:
return
var accent := _get_team_material(team)
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:
mesh.material_override = accent
@@ -205,7 +251,7 @@ func _build_merged_hull() -> void:
var instance := MeshInstance3D.new()
instance.name = "MergedHull"
instance.mesh = mesh
add_child(instance)
visual.add_child(instance)
# 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.mesh = core_mesh
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(core)
visual.add_child(core)
_engine_cores.append(core)
# A single conventional orange flame replaces the layered particle plume
@@ -265,7 +311,7 @@ func _build_movement_vfx() -> void:
flame.mesh = flame_mesh
flame.visible = false
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(flame)
visual.add_child(flame)
_engine_flames.append(flame)
var light := OmniLight3D.new()
@@ -275,7 +321,7 @@ func _build_movement_vfx() -> void:
light.omni_range = 3.5
light.omni_attenuation = 2.0
light.shadow_enabled = false
add_child(light)
visual.add_child(light)
_engine_lights.append(light)
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
@@ -343,6 +389,25 @@ func _has_telemetry_listeners() -> bool:
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)
_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)`
# decays at the same rate per second regardless of physics_ticks_per_second.
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):
+12
View File
@@ -9,3 +9,15 @@ extends RefCounted
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 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
View File
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
@export_group("Impact Shake")
@export var max_shake_offset := 0.32
@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:
set(value):
@@ -33,6 +42,16 @@ var target: Ship:
target.ball_contact.disconnect(_on_target_ball_contact)
target = value
_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
@onready var camera: Camera3D = $Camera3D
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
var _shake_noise := FastNoiseLite.new()
var _shake_time := 0.0
var _last_shake_offset := Vector3.ZERO
var _punch_strength := 0.0
var _goal_cut_active := false
var _goal_cut_position := Vector3.ZERO
var _goal_cut_look_at := Vector3.ZERO
const SHAKE_UPDATE_HZ := 60.0
func _ready():
# Group lets the HUD discover the rig for the camera-mode instrument
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
_effective_distance = camera_distance
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
@@ -83,7 +110,7 @@ func _input(event):
camera_mode_changed.emit(ball_cam_enabled)
func _physics_process(delta):
func _process(delta):
# Camera position smoothing operates on the unshaken chase position. Remove
# last frame's presentation-only offset first so shake never accumulates or
# exceeds its configured amplitude during a low-time-scale hit-stop.
@@ -93,6 +120,10 @@ func _physics_process(delta):
return
if _goal_cut_active:
_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
_update_speed_feel(delta)
var ball := _get_ball()
@@ -100,6 +131,7 @@ func _physics_process(delta):
_update_ball_cam(delta, ball)
else:
_update_ship_cam(delta)
_apply_punch(delta)
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
# 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.
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
# 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):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos: Vector3 = target.global_transform.origin
var ship_forward: Vector3 = -target.global_transform.basis.z
# In ship cam, camera follows and looks in the same direction as the ship.
# Reads target.visual, not target itself, via the interpolated transform —
# 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
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:
_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():
Input.start_joy_vibration(
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:
_shake_strength = 0.0
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 noise := Vector2(
_shake_noise.get_noise_1d(_shake_time),
_shake_noise.get_noise_1d(_shake_time + 100.0)
_shake_noise.get_noise_1d(tick),
_shake_noise.get_noise_1d(tick + 100.0)
).limit_length(1.0) * amplitude
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
camera.global_position += _last_shake_offset
_decay_shake(delta)
func _decay_shake(delta: float) -> void:
_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:
_goal_cut_active = true
# The hard cut replaces the chase camera's presentation offset entirely;
+14
View File
@@ -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
+4 -2
View File
@@ -1,5 +1,6 @@
class_name TrainingMode
extends GameMode
const SimConstants = preload("res://scripts/sim_constants.gd")
# 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
@@ -120,8 +121,9 @@ const MAX_RANDOM_SHIP_SPEED := 8.0
# marker spacing, which uses the same margin for the same reason.
const MIN_SHIP_SEPARATION := 4.5
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
const TICKS_PER_SIM_SECOND := 60.0
# Sim runs at SimConstants.TICK_HZ physics ticks per sim-second regardless
# of speedup.
const TICKS_PER_SIM_SECOND := float(SimConstants.TICK_HZ)
var _agents: Array[ShipAIController] = []
+182 -8
View File
@@ -1,44 +1,164 @@
extends Node
# Autoload: persisted player-facing video preferences (AA, glow, brightness).
# AA is a Viewport-wide setting applied immediately via apply_aa(). Glow and
# Autoload: persisted player-facing video preferences (preset, AA, glow,
# 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
# arena.gd's _ready(), which calls apply_to_environment() once per arena
# load) rather than overwriting them outright, so the per-arena bloom tuning
# 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"
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 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:
_load()
apply_aa()
# 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_resolution_scale()
apply_vsync()
func _load() -> void:
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
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
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
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:
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", "glow_scale", glow_scale)
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)
# 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:
if DisplayServer.get_name() == "headless":
return
var viewport := get_tree().root
match aa_mode:
AAMode.OFF:
@@ -53,13 +173,67 @@ func apply_aa() -> void:
AAMode.MSAA_FXAA:
viewport.msaa_3d = Viewport.MSAA_4X
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
# preference into that arena's own baked Environment tuning.
func apply_resolution_scale() -> void:
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:
if env == null:
return
env.glow_enabled = glow_scale > 0.0
env.glow_enabled = glow_enabled and glow_scale > 0.0
env.glow_intensity *= glow_scale
env.adjustment_brightness *= brightness
env.sdfgi_enabled = sdfgi_enabled
env.ssil_enabled = ssil_enabled
env.ssao_enabled = ssao_enabled
+182
View File
@@ -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()])
+6
View File
@@ -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")
+7 -4
View File
@@ -19,7 +19,10 @@ The largest gap between this and a AAA-feeling product is presentation, not code
## Multiplayer (long term)
- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation.
- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score.
- [ ] C# backend / online servers per README roadmap (not started).
- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this).
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.
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).
**Tasks 0.10.15, 0.180.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
View File
File diff suppressed because it is too large Load Diff