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_SPEC.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_SPEC.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-next.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_SPEC.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