feat: add Nebula and Asteroid Field arenas

Introduce arena_02 (Nebula) and arena_03 (Asteroid Field) alongside
arena_01, listed in a new ArenaRegistry (scripts/arena_registry.gd) as
the single source of truth for available arenas. Free Play lets the
player pick an arena from the main menu; Match/Spectate each pick one
at random per session; Training keeps its own fixed arena_01.

Nebula gets an RL-style visual treatment: a near-invisible glass
boundary material shared by all arenas, a baked equirect nebula sky
texture, a drifting-dust particle system, and a Blender-modeled
station/debris/planet decoration set. GameMode gains a
_get_arena_scene_path() hook so modes can instantiate their arena in
code instead of hardcoding it in the scene.
This commit is contained in:
Josh Creek
2026-08-03 19:09:16 +01:00
parent 1af3e0410d
commit 171cd4a840
35 changed files with 683 additions and 12 deletions
+14
View File
@@ -0,0 +1,14 @@
class_name ArenaRegistry
# Single source of truth for available arenas: the Free Play menu dropdown
# lists these, and Match/Spectate pick one at random each session. Training
# is exempt — training.tscn keeps its own fixed arena_01 child.
const ARENAS := [
{"name": "Starfield", "path": "res://scenes/arena_01.tscn"},
{"name": "Nebula", "path": "res://scenes/arena_02.tscn"},
{"name": "Asteroid Field", "path": "res://scenes/arena_03.tscn"},
]
static func random_path() -> String:
return ARENAS[randi() % ARENAS.size()]["path"]
+1
View File
@@ -0,0 +1 @@
uid://orkh5g5828cv
+6
View File
@@ -4,6 +4,12 @@ extends GameMode
# Rocket League's free play. R resets the ball, Esc returns to the menu.
func _get_arena_scene_path() -> String:
if not GameSettings.selected_arena_path.is_empty():
return GameSettings.selected_arena_path
return ArenaRegistry.ARENAS[0]["path"]
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
+12
View File
@@ -26,6 +26,11 @@ func _ready():
if child is Arena:
arena = child
break
if not arena:
var path := _get_arena_scene_path()
if not path.is_empty():
arena = (load(path) as PackedScene).instantiate()
add_child(arena)
if not arena:
push_error("GameMode: scene has no Arena child")
return
@@ -34,6 +39,13 @@ func _ready():
_start()
# Virtual: subclasses whose scene has no fixed Arena child override this to
# pick which arena scene to instantiate in code (see ArenaRegistry). Modes
# that keep a fixed Arena child (training.tscn) never call this.
func _get_arena_scene_path() -> String:
return ""
# Virtual: subclasses spawn their ball/ships/camera here.
func _start() -> void:
pass
+4
View File
@@ -21,3 +21,7 @@ var dev_bot_override_path: String = ""
# Spectate (bot vs bot) policies chosen in the main menu; empty = mode export.
var spectate_bot_a_path: String = ""
var spectate_bot_b_path: String = ""
# Arena chosen in the main menu for Free Play; empty = ArenaRegistry's default.
# Match/Spectate ignore this and pick a random arena each session instead.
var selected_arena_path: String = ""
+17
View File
@@ -18,6 +18,7 @@ const DIFFICULTIES := [
]
@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown
@onready var arena_dropdown: OptionButton = %ArenaDropdown
@onready var dev_section: Control = %DevSection
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
@onready var bot_a_dropdown: OptionButton = %BotADropdown
@@ -26,6 +27,7 @@ const DIFFICULTIES := [
func _ready() -> void:
_populate_difficulty_dropdown()
_populate_arena_dropdown()
dev_section.visible = OS.is_debug_build()
if dev_section.visible:
var bots := _list_bots()
@@ -47,6 +49,19 @@ func _populate_difficulty_dropdown() -> void:
difficulty_dropdown.select(selected)
# Free Play only — Match/Spectate pick a random arena in code instead.
func _populate_arena_dropdown() -> void:
arena_dropdown.clear()
for tier in ArenaRegistry.ARENAS:
arena_dropdown.add_item(tier["name"])
var selected := 0
for i in ArenaRegistry.ARENAS.size():
if ArenaRegistry.ARENAS[i]["path"] == GameSettings.selected_arena_path:
selected = i
break
arena_dropdown.select(selected)
# Lists bot checkpoints as paths relative to BOTS_DIR, including the
# top-level curriculum checkpoints and anything under promoted/.
func _list_bots() -> Array[String]:
@@ -93,6 +108,8 @@ func _selected_path(dropdown: OptionButton) -> String:
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")
+4
View File
@@ -32,6 +32,10 @@ var match_timer: Timer
var _in_overtime := false
func _get_arena_scene_path() -> String:
return ArenaRegistry.random_path()
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
+4
View File
@@ -19,6 +19,10 @@ signal score_changed(score: Dictionary)
var score := {0: 0, 1: 0}
func _get_arena_scene_path() -> String:
return ArenaRegistry.random_path()
func _start() -> void:
spawn_ball()
# The main menu's dropdown selections (GameSettings autoload) win over the