mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 20:53:43 +00:00
6320b982a8
Godot's ConfigFile writer does not round-trip comments in project.godot. An observed rewrite deleted both `;` blocks outright and spliced the three-line `#` block above run/main_scene.dedicated_server onto the setting's own line, leaving it commented out — which would send dedicated builds to the interactive main menu instead of server_boot.tscn, with nothing failing until someone noticed a server process rendering a menu. Move the explanations into the code that owns the settings (server_boot.gd for the dedicated-server override, video_settings.gd for stretch mode and vsync) so they cannot be destroyed by a rewrite, and leave project.godot holding only assignments plus Godot's own regenerated header. Add tests/cases/test_project_settings.gd as the backstop: the feature-override assertions read project.godot as text and reject a line that has been folded into a comment, since ProjectSettings resolves `key.<feature>` overrides at load time and never exposes the suffixed key. Verified by reproducing the exact corruption, which fails the test, and it also covers the Jolt physics engine, the required autoloads, and that no test-hook autoload is ever shipped registered.
265 lines
11 KiB
GDScript
265 lines
11 KiB
GDScript
extends Node
|
|
|
|
# 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.
|
|
|
|
# The two display project settings this autoload sits on top of are documented
|
|
# here rather than in project.godot, because Godot's ConfigFile writer does not
|
|
# round-trip comments — it drops `;` blocks outright and can splice `#` blocks
|
|
# into the following line, silently commenting the setting out. Anything in
|
|
# project.godot that needs an explanation must therefore be explained from the
|
|
# code that owns it. `tests/cases/test_project_settings.gd` guards the settings
|
|
# themselves against exactly that corruption.
|
|
#
|
|
# `window/stretch/mode="viewport"` + 1080p (task 0.17c): kept fixed 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
|
|
# (resolution_scale below) already covers "render lower than the window"
|
|
# independently of stretch mode, since it scales the 3D viewport's own internal
|
|
# resolution before this blit rather than the window itself. Task 0.15b also
|
|
# found an unexplained ~6% non-uniform width scaling on this project's one
|
|
# tested (Mac/Retina) machine — see multiplayer-todo.md §5.5.1 — which needs
|
|
# understanding before stretch mode is touched, not blindly carrying into a
|
|
# resolution-dependent change.
|
|
#
|
|
# `window/vsync/vsync_mode=2` (task 0.17): matches VsyncMode.ADAPTIVE below.
|
|
# apply_vsync() overwrites it at runtime via DisplayServer as soon as this
|
|
# autoload initializes, so the project setting is only in effect for the brief
|
|
# pre-autoload window, and as a fallback if VideoSettings ever fails to load.
|
|
|
|
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"
|
|
|
|
# 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()
|
|
# 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:
|
|
viewport.msaa_3d = Viewport.MSAA_DISABLED
|
|
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
|
AAMode.FXAA:
|
|
viewport.msaa_3d = Viewport.MSAA_DISABLED
|
|
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
|
|
AAMode.MSAA:
|
|
viewport.msaa_3d = Viewport.MSAA_4X
|
|
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
|
|
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
|
|
|
|
|
|
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_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
|