class_name Arena extends Node3D # A reusable stadium: terrain, lighting, environment, two team goals, and # spawn markers. An arena holds no rules and no state — game modes query it # for spawn transforms and goals, then spawn ships/ball themselves. # Per-arena Environment tuning. These live as exports (rather than baked # into each arena's Environment sub-resource) because Godot's scene # inheritance can only override whole top-level node properties, not # properties nested inside a sub-resource — so each arena_0N.tscn overrides # these on the Arena root instead, and _ready() applies them here. @export var sky_material: Material @export var ambient_light_color := Color(0.55, 0.6, 0.75, 1) @export var ambient_light_energy := 0.4 @export var glow_intensity := 0.9 @export var glow_strength := 1.2 @export var glow_bloom := 0.05 @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: _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 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: return $BallSpawn.global_transform func get_ship_spawns(team: int) -> Array[Transform3D]: var spawns: Array[Transform3D] = [] var container := get_node_or_null("SpawnsTeam%d" % team) if container: for child in container.get_children(): if child is Marker3D: spawns.append(child.global_transform) return spawns func get_goals() -> Array[Goal]: var goals: Array[Goal] = [] for child in get_children(): if child is Goal: goals.append(child) return goals