mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +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.
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 _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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
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] = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
- [ ] `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.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