mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
refactor(*): DRY up arena scenes, game modes, and HUD instruments
Arenas inherit from a new arena_base.tscn instead of restating ~40 shared lines each; only sky/ambient/glow/tint/decoration vary, exposed via new Arena exports since nested Environment properties aren't overridable through scene inheritance. Bot construction and score-keeping move onto GameMode, shared by match and spectate modes while preserving their differing GameSettings-override behavior and the HUD's score-row duck-typing. HUD instruments share a HudInstrument base for the smoothing-weight calc and angle-lerp helper. Also dedupes MAIN_MENU_SCENE_PATH into ScenePaths and documents why DIFFICULTIES tiers share one checkpoint.
This commit is contained in:
+25
-1
@@ -5,12 +5,36 @@ extends Node3D
|
||||
# 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
|
||||
|
||||
|
||||
func _ready():
|
||||
add_to_group("arena")
|
||||
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
|
||||
if world_env and world_env.environment:
|
||||
VideoSettings.apply_to_environment(world_env.environment)
|
||||
var env := world_env.environment
|
||||
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)
|
||||
|
||||
|
||||
func get_ball_spawn() -> Transform3D:
|
||||
|
||||
@@ -11,13 +11,18 @@ extends Node3D
|
||||
@export var ball_scene: PackedScene = preload("res://objects/ball.tscn")
|
||||
|
||||
const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn")
|
||||
const MAIN_MENU_SCENE_PATH = "res://scenes/main_menu.tscn"
|
||||
|
||||
var arena: Arena
|
||||
var ball: RigidBody3D
|
||||
var ships: Array[Ship] = []
|
||||
var _ship_spawn_transforms := {}
|
||||
|
||||
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||
# references this or emits a score_changed signal, and HUDController relies
|
||||
# on has_signal("score_changed") to decide whether to show a score row — so
|
||||
# that signal stays declared per-subclass, not here.
|
||||
var score := {0: 0, 1: 0}
|
||||
|
||||
|
||||
func _ready():
|
||||
# Group lets the HUD discover the game mode for timer/score signals
|
||||
@@ -97,6 +102,28 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||
return rig
|
||||
|
||||
|
||||
# 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
|
||||
# the path) — builds a trained-policy controller, or an inert placeholder if
|
||||
# the path is empty or missing.
|
||||
func _build_opponent(model_path: String, reaction_ticks: int, action_noise: float, label: String = "GameMode") -> ShipController:
|
||||
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = model_path
|
||||
bot.reaction_ticks = reaction_ticks
|
||||
bot.action_noise = action_noise
|
||||
return bot
|
||||
if not model_path.is_empty():
|
||||
push_warning("%s: bot model not found at %s, spawning inert opponent" % [label, model_path])
|
||||
return ShipController.new() # inert placeholder
|
||||
|
||||
|
||||
func _record_goal(scoring_team: int) -> void:
|
||||
score[scoring_team] += 1
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
|
||||
|
||||
# Tiny per-reset randomization, well below anything a player would notice as
|
||||
# "not a real kickoff" — just enough that two ships running the identical
|
||||
# deterministic AI policy (action_noise = 0) don't start every kickoff from a
|
||||
@@ -141,4 +168,4 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudAttitudeIndicator
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Aircraft-style attitude indicator (artificial horizon): the sky/ground disc
|
||||
# rolls and the pitch ladder scrolls behind a fixed ship symbol, so pitch and
|
||||
@@ -14,8 +14,6 @@ const RING_COLOR := Color(0.5, 0.85, 1.0, 0.7)
|
||||
|
||||
# Instrument scale: degrees of pitch visible from centre to rim.
|
||||
const PITCH_RANGE := 45.0
|
||||
# Display smoothing rate (per second); high = snappy, low = floaty.
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
var _target_pitch := 0.0
|
||||
var _target_roll := 0.0
|
||||
@@ -29,16 +27,12 @@ func set_attitude(pitch_deg: float, roll_deg: float) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
var t := _smoothing_weight(delta)
|
||||
_pitch = lerpf(_pitch, _target_pitch, t)
|
||||
_roll = lerp_angle_deg(_roll, _target_roll, t)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||
return rad_to_deg(lerp_angle(deg_to_rad(from), deg_to_rad(to), weight))
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var center := size / 2.0
|
||||
var radius := minf(size.x, size.y) / 2.0 - 10.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudGauge
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Generic bar gauge (vertical or horizontal) with a short label and numeric
|
||||
# readout — used for speed, altitude, and thrust. A dumb display fed by
|
||||
@@ -9,8 +9,6 @@ const BG_COLOR := Color(0.05, 0.07, 0.11, 0.55)
|
||||
const FRAME_COLOR := Color(0.5, 0.85, 1.0, 0.7)
|
||||
const TEXT_COLOR := Color(0.85, 0.92, 1.0, 0.9)
|
||||
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
@export var label := "SPD"
|
||||
@export var max_value := 100.0
|
||||
@export var horizontal := false
|
||||
@@ -27,7 +25,7 @@ func set_value(value: float) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
var t := _smoothing_weight(delta)
|
||||
_value = lerpf(_value, _target, t)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name HudHeadingTape
|
||||
extends Control
|
||||
extends HudInstrument
|
||||
|
||||
# Aircraft-style heading tape: a strip of compass ticks slides under a fixed
|
||||
# centre marker, with cardinal letters so the player always knows which way
|
||||
@@ -11,7 +11,6 @@ const MARKER_COLOR := Color(1.0, 0.8, 0.2)
|
||||
|
||||
# Degrees of heading visible across the full tape width.
|
||||
const VISIBLE_RANGE := 90.0
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
const CARDINALS := {0: "N", 45: "NE", 90: "E", 135: "SE", 180: "S", 225: "SW", 270: "W", 315: "NW"}
|
||||
|
||||
@@ -28,8 +27,8 @@ func set_heading(heading_deg: float) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
_heading = fmod(HudAttitudeIndicator.lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
|
||||
var t := _smoothing_weight(delta)
|
||||
_heading = fmod(lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name HudInstrument
|
||||
extends Control
|
||||
|
||||
# Shared base for the HUD's procedural instruments (heading tape, attitude
|
||||
# indicator, gauges): the exponential-smoothing weight calc and the
|
||||
# angle-aware lerp helper they all use. Each instrument still owns its own
|
||||
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
|
||||
|
||||
const SMOOTHING := 12.0
|
||||
|
||||
|
||||
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
|
||||
return rad_to_deg(lerp_angle(deg_to_rad(from), deg_to_rad(to), weight))
|
||||
|
||||
|
||||
func _smoothing_weight(delta: float) -> float:
|
||||
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfjncr0nnakun
|
||||
@@ -11,6 +11,10 @@ extends Control
|
||||
|
||||
const BOTS_DIR := "res://bots"
|
||||
|
||||
# All three tiers deliberately point at the one trained checkpoint
|
||||
# (easy.json) and differ only by reaction_ticks/action_noise handicap, not by
|
||||
# model skill. Superseded once real medium/hard checkpoints are promoted into
|
||||
# res://bots/promoted/ — see the "AI opponent" section of TODO.md.
|
||||
const DIFFICULTIES := [
|
||||
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 24, "action_noise": 0.35},
|
||||
{"name": "Medium", "model": "res://bots/promoted/easy.json", "reaction_ticks": 14, "action_noise": 0.15},
|
||||
|
||||
@@ -27,7 +27,6 @@ const KICKOFF_COUNTDOWN_SECONDS := 3
|
||||
@export_range(1, 60) var bot_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
|
||||
|
||||
var score := {0: 0, 1: 0}
|
||||
var match_timer: Timer
|
||||
var _in_overtime := false
|
||||
|
||||
@@ -56,15 +55,9 @@ func _make_opponent_controller() -> ShipController:
|
||||
# The main menu's dropdown selection (GameSettings autoload) wins over the
|
||||
# scene's export, which stays as the fallback for direct-scene runs.
|
||||
var path := bot_model_path if GameSettings.selected_bot_path.is_empty() else GameSettings.selected_bot_path
|
||||
if not path.is_empty() and FileAccess.file_exists(path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = path
|
||||
bot.reaction_ticks = bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
|
||||
bot.action_noise = bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
|
||||
return bot
|
||||
if not path.is_empty():
|
||||
push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path)
|
||||
return ShipController.new() # inert placeholder
|
||||
var reaction_ticks := bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
|
||||
var action_noise := bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
|
||||
return _build_opponent(path, reaction_ticks, action_noise, "MatchMode")
|
||||
|
||||
|
||||
func _process(_delta):
|
||||
@@ -75,9 +68,8 @@ func _process(_delta):
|
||||
|
||||
func _on_goal_scored(conceding_team: int) -> void:
|
||||
var scoring_team := 1 - conceding_team
|
||||
score[scoring_team] += 1
|
||||
_record_goal(scoring_team)
|
||||
score_changed.emit(score.duplicate())
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
if _in_overtime:
|
||||
# Golden goal: the first score after a draw ends the match outright.
|
||||
_end_match(scoring_team)
|
||||
@@ -136,4 +128,4 @@ func _end_match(winning_team: int) -> void:
|
||||
await get_tree().create_timer(RESULT_SCREEN_SECONDS).timeout
|
||||
# Unpause before leaving, or the menu arrives paused and unresponsive.
|
||||
get_tree().paused = false
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class_name ScenePaths
|
||||
|
||||
const MAIN_MENU := "res://scenes/main_menu.tscn"
|
||||
@@ -0,0 +1 @@
|
||||
uid://5msygpcnvgfd
|
||||
@@ -6,8 +6,6 @@ extends Control
|
||||
# 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.
|
||||
|
||||
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
|
||||
|
||||
const AA_OPTIONS := [
|
||||
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
|
||||
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
|
||||
@@ -62,7 +60,7 @@ func _on_brightness_slider_value_changed(value: float) -> void:
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
VideoSettings.save()
|
||||
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
@@ -16,9 +16,6 @@ signal score_changed(score: Dictionary)
|
||||
@export_range(1, 60) var bot_b_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_b_action_noise: float = 0.0
|
||||
|
||||
var score := {0: 0, 1: 0}
|
||||
|
||||
|
||||
func _get_arena_scene_path() -> String:
|
||||
return ArenaRegistry.random_path()
|
||||
|
||||
@@ -29,28 +26,15 @@ func _start() -> void:
|
||||
# scene's exports, which stay as the fallback for direct-scene runs.
|
||||
var path_a := bot_a_model_path if GameSettings.spectate_bot_a_path.is_empty() else GameSettings.spectate_bot_a_path
|
||||
var path_b := bot_b_model_path if GameSettings.spectate_bot_b_path.is_empty() else GameSettings.spectate_bot_b_path
|
||||
var ship_a := spawn_ship(0, 0, _make_bot(path_a, bot_a_reaction_ticks, bot_a_action_noise))
|
||||
spawn_ship(1, 0, _make_bot(path_b, bot_b_reaction_ticks, bot_b_action_noise))
|
||||
var ship_a := spawn_ship(0, 0, _build_opponent(path_a, bot_a_reaction_ticks, bot_a_action_noise, "SpectateMode"))
|
||||
spawn_ship(1, 0, _build_opponent(path_b, bot_b_reaction_ticks, bot_b_action_noise, "SpectateMode"))
|
||||
spawn_camera_rig(ship_a)
|
||||
|
||||
|
||||
func _make_bot(model_path: String, reaction_ticks: int, action_noise: float) -> ShipController:
|
||||
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = model_path
|
||||
bot.reaction_ticks = reaction_ticks
|
||||
bot.action_noise = action_noise
|
||||
return bot
|
||||
if not model_path.is_empty():
|
||||
push_warning("SpectateMode: bot model not found at %s, spawning inert ship" % model_path)
|
||||
return ShipController.new() # inert placeholder
|
||||
|
||||
|
||||
func _on_goal_scored(conceding_team: int) -> void:
|
||||
var scoring_team := 1 - conceding_team
|
||||
score[scoring_team] += 1
|
||||
_record_goal(scoring_team)
|
||||
score_changed.emit(score.duplicate())
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user