Files
client/spacewar/menu/team_select.gd
T
anekdotin a94bccc57b Add capital ships, and settings screen with smooth-motion fixes
Capital ships & support art:
- New Flagship capital ships (world/flagship.gd) spawn a small
  point-defense formation at each team's spawn marker, independently
  targeting the nearest enemy in range with a slow unmissable-looking
  missile and a faster bullet stream -- keeps a team from parking on
  the enemy spawn and farming respawns.
- Per-race/role bullet sprites for turret fire (assets/images/effects/
  bullets/), flagship art for both factions, minimap now draws
  flagship blips, bot_ai awareness tuned to notice them.

Settings screen (items 20-21 in CLAUDE.md) -- was a stub, now five
real sections shared between the main menu and the in-game pause menu:
- Diagnosed and fixed a "movement looks blurry/laggy" report down to
  three independent causes: physics-tick vs. display-refresh judder
  (fixed via Godot's built-in physics interpolation, plus a
  frame-rate-cap + VSync dropdown so each player can match their own
  monitor), missing mipmaps on every minified ship texture (real GPU
  sampling shimmer, unrelated to frame timing), and a periodic hitch
  from the matchmaking heartbeat spinning up a new HTTPRequest thread
  every 8 seconds instead of reusing one.
- Nameplates get their own manual per-frame interpolation, since
  Godot's physics interpolation only covers Node2D/Node3D, not the
  Control-based Label they're built from.
- Audio: Master/Music/SFX volume sliders backed by a real bus layout
  (default_bus_layout.tres) -- the project had no volume control at
  all before this.
- Window mode (Fullscreen/Exclusive Fullscreen/Windowed), a
  colorblind-friendly enemy-color toggle (also fixes the minimap's own
  separate, inconsistent yellow enemy color), and keyboard rebinding
  for every action with a live capture UI.
- New GameConfig.settings_focused flag (same pattern as chat_focused/
  team_select_focused) so a key-rebind capture can't also move the
  ship or toggle the pause menu underneath the panel.

Spawn intro: a ship's first-ever appearance now eases in from a
random direction (fast off the start, decelerating into its landing
spot) instead of popping into place -- purely a cosmetic sprite
offset, so collision/camera/networking are untouched and every peer
(including bots) sees the same warp-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:02:31 -04:00

532 lines
21 KiB
GDScript

extends CanvasLayer
class_name TeamSelect
# Movement speed factor by role, shared across both factions so e.g. every
# faction's Fighter moves at the same speed as every other faction's.
const SPEED_BY_ROLE := {
"Fighter": 1.3,
"Gunner": 1.15,
"Tank": 0.7,
}
const RACES := [
{
"id": 1, "name": "INNER SPHERE NAVY", "sub": "Gunmetal-gray navy of Earth, Mars, and Mercury — disciplined, frontal, built to hold a line",
"color": Color(0.80, 0.32, 0.32),
"flagship_path": "res://assets/images/ships/isn/flagship_colossus.png", "flagship_scale": 1.0,
"ships": [
{
"name": "PATRIOT", "role": "Fighter",
"desc": "A single, hard-hitting high-caliber explosive shell. Standard-issue and dependable — a clean, punishing hit if you land it.",
"path": "res://assets/images/ships/isn/patriot.png", "scale": 0.504, "speed_factor": SPEED_BY_ROLE.Fighter,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/patriot_flame.png",
},
{
"name": "BARRAGE", "role": "Gunner",
"desc": "A relentless quad-barrel spray of fast, low-damage light bullets. Built for sustained suppressing fire rather than any one killing blow.",
"path": "res://assets/images/ships/isn/barrage.png", "scale": 0.516, "speed_factor": SPEED_BY_ROLE.Gunner,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/barrage_flame.png",
},
{
"name": "BEHEMOTH", "role": "Tank",
"desc": "Heavy tracking torpedoes plus massive physical deck-guns. The navy's flagship-killer — slow to turn, but nothing stands in its way for long.",
"path": "res://assets/images/ships/isn/behemoth.png", "scale": 0.331, "speed_factor": SPEED_BY_ROLE.Tank,
"sound_path": "res://assets/sound/ships/isn/isn_1.wav",
"flame_path": "res://assets/images/ships/isn/behemoth_flame.png",
},
],
},
{
"id": 2, "name": "OUTER RIM COLLECTIVE", "sub": "Gritty, jury-rigged mining rebels — kinetic weapons scavenged and welded onto anything that flies",
"color": Color(0.82, 0.52, 0.24),
"flagship_path": "res://assets/images/ships/orc/flagship_rust_titan.png", "flagship_scale": 1.0,
"ships": [
{
"name": "RAIL-JACK", "role": "Fighter",
"desc": "A single, heavy magnetic railgun slug. Repurposed mining hardware — one slug, but it hits like a dropped asteroid.",
"path": "res://assets/images/ships/orc/rail_jack.png", "scale": 0.492, "speed_factor": SPEED_BY_ROLE.Fighter,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/rail_jack_flame.png",
},
{
"name": "SCRAP-SPITTER", "role": "Gunner",
"desc": "A rotary scrap-cannon spraying rapid, low-damage metal shrapnel. Loud, dirty, and built to keep the pressure on.",
"path": "res://assets/images/ships/orc/scrap_spitter.png", "scale": 0.462, "speed_factor": SPEED_BY_ROLE.Gunner,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/scrap_spitter_flame.png",
},
{
"name": "IRON-CLAD", "role": "Tank",
"desc": "Unguided explosive rockets plus continuous vulcan machine-gun fire. Bolted-together and brutal — built to grind a fight down, not finesse it.",
"path": "res://assets/images/ships/orc/iron_clad.png", "scale": 0.495, "speed_factor": SPEED_BY_ROLE.Tank,
"sound_path": "res://assets/sound/ships/orc/orc_1.wav",
"flame_path": "res://assets/images/ships/orc/iron_clad_flame.png",
},
],
},
]
# Row layout constants — ROW_X1/ROW_X2_MARGIN are left/right margins (not
# absolute x-coordinates), so rows stretch to fill the actual window width;
# stretch mode is disabled (project.godot), so nothing scales automatically.
const ROW_X1 := 120.0
const ROW_X2_MARGIN := 120.0
const ROW_H := 115.0
const ROW_GAP := 6.0
const ROW_START := 175.0
const ROW_IMG := 95.0
var _root: Control
var _content: Control
var _loading_lbl: Label
var _offered: Array = []
var _chosen_race: Dictionary = {}
# False until the player's first-ever pick (pre-spawn, mandatory — no cancel
# possible). True afterward, once reopen() below can show this screen again
# for a live swap, at which point ESC should be able to back out of it
# without forcing a re-pick.
var _reopenable: bool = false
# race_id -> {btn, count_lbl, hint_lbl, roster_lbl, color}, populated by
# _show_race_selection() and cleared the moment that screen isn't showing —
# _refresh_race_boxes() no-ops while empty, so a loadout_updated/player_removed
# signal arriving on the ship-selection screen (or while closed) is harmless.
var _race_boxes: Dictionary = {}
func _ready() -> void:
layer = 20
_build_root()
GameConfig.team_select_focused = true
# Keeps the race-selection screen's live counts/rosters/TEAM FULL state
# in sync as other players join, leave, or swap — both here (pre-spawn)
# and when reopened mid-match from the pause menu.
PlayerRegistry.loadout_updated.connect(_on_registry_changed)
PlayerRegistry.player_removed.connect(_on_registry_player_removed)
# The 2 offered races must be the same for every player in the match, not
# independently randomized per client — the server decides once (see
# World.decide_offered_races()) and every client either asks for it
# (real remote peers) or reads it directly (the server's own instance,
# no round-trip needed since it's the same process).
var world := get_node("/root/World")
if multiplayer.is_server():
offer_races(world.decide_offered_races())
else:
world.request_offered_races.rpc_id(1)
func _input(event: InputEvent) -> void:
if _reopenable and _root.visible and event.is_action_pressed("toggle_pause"):
_close()
get_viewport().set_input_as_handled()
# Called by PauseMenu's SELECT TEAM button so an already-playing player can
# swap race/ship mid-match. Reuses the match's already-decided _offered pair
# instead of re-requesting it — the server decides that once for everyone
# (World.decide_offered_races()) and it never changes mid-match.
func reopen() -> void:
_root.visible = true
GameConfig.team_select_focused = true
_show_race_selection()
func _close() -> void:
_root.visible = false
GameConfig.team_select_focused = false
_race_boxes.clear()
func _on_registry_changed(_peer_id: int) -> void:
_refresh_race_boxes()
func _on_registry_player_removed(_peer_id: int, _info: Dictionary) -> void:
_refresh_race_boxes()
# Recomputes each offered race's human headcount/roster and whether it's
# blocked as "TEAM FULL". Bots don't count — they're padding, not players
# choosing a side, so they'd distort the balance check for no reason. Only
# ever blocks the strictly larger side, and never a race the local player is
# already on (re-picking your own current race shouldn't lock you out of it).
func _refresh_race_boxes() -> void:
if _race_boxes.is_empty():
return
var counts: Dictionary = {}
var rosters: Dictionary = {}
for race in _offered:
counts[race.id] = 0
rosters[race.id] = []
for peer_id in PlayerRegistry.players:
if peer_id < 0:
continue
var info: Dictionary = PlayerRegistry.players[peer_id]
var race_id: int = info.get("race", 0)
if not counts.has(race_id):
continue
counts[race_id] += 1
rosters[race_id].append(String(info.get("name", "?")))
var my_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
var diff: int = counts[_offered[0].id] - counts[_offered[1].id]
const MAX_ROSTER_SHOWN := 14
for race in _offered:
var box: Dictionary = _race_boxes[race.id]
var count: int = counts[race.id]
box.count_lbl.text = "%d PLAYER%s" % [count, "" if count == 1 else "S"]
var names: Array = rosters[race.id]
if names.is_empty():
box.roster_lbl.text = "—"
elif names.size() > MAX_ROSTER_SHOWN:
box.roster_lbl.text = "\n".join(names.slice(0, MAX_ROSTER_SHOWN)) + \
"\n+%d more" % (names.size() - MAX_ROSTER_SHOWN)
else:
box.roster_lbl.text = "\n".join(names)
# Only one side can ever be "larger" at a time with exactly 2 offered
# races, so there's always at least one joinable side.
# Explicit `bool` types below (not `:=`): `race` comes from the
# untyped `_offered` Array, so `race.id == ...` resolves to Variant,
# and this project treats inferring a var's type from Variant as an
# error (same class of issue as the clamp() gotcha elsewhere).
var is_larger: bool = (race.id == _offered[0].id and diff > 2) or \
(race.id == _offered[1].id and diff < -2)
var is_full: bool = is_larger and race.id != my_race
box.btn.disabled = is_full
box.hint_lbl.text = "TEAM FULL" if is_full else "CLICK TO SELECT"
var col: Color = box.color
box.hint_lbl.add_theme_color_override("font_color",
Color(0.95, 0.35, 0.35) if is_full else Color(col.r * 0.5, col.g * 0.5, col.b * 0.5))
# Called once this client knows which 2 races (by id) the match is offering.
func offer_races(race_ids: Array) -> void:
if _loading_lbl:
_loading_lbl.queue_free()
_loading_lbl = null
_offered = race_ids.map(func(id): return _race_by_id(id))
_show_race_selection()
func _race_by_id(id: int) -> Dictionary:
for race in RACES:
if race.id == id:
return race
return RACES[0]
func _build_root() -> void:
_root = Control.new()
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(_root)
var overlay := ColorRect.new()
overlay.color = Color(0.0, 0.0, 0.0, 0.65)
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
_root.add_child(overlay)
_loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75),
0, 380, 0, 420, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
func _swap_content() -> Control:
if _content:
_content.queue_free()
_content = Control.new()
_content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(_content)
return _content
# ── Race selection ───────────────────────────────────────────────────────────
func _show_race_selection() -> void:
var c := _swap_content()
_label(c, "CHOOSE YOUR FACTION", 32, Color(0.42, 0.87, 1.0),
0, 105, 0, 160, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
_label(c, "This match: %s vs %s" % [_offered[0].name, _offered[1].name],
13, Color(0.45, 0.58, 0.72),
0, 170, 0, 200, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
# Both race buttons are a fixed 440px wide with a 100px gap, centered on
# the actual screen width via anchor 0.5 — not a hardcoded 1280px design
# width, since stretch mode is disabled (see project.godot).
const BTN_W := 440.0
const HALF_GAP := 50.0
_race_boxes.clear()
_build_race_box(c, _offered[0], -(BTN_W + HALF_GAP), -HALF_GAP)
_build_race_box(c, _offered[1], HALF_GAP, BTN_W + HALF_GAP)
_refresh_race_boxes()
# Builds one race's button + the player roster below it, and registers both
# in _race_boxes under the race's id so _refresh_race_boxes() can update them
# live as PlayerRegistry changes.
func _build_race_box(c: Control, race: Dictionary, x1: float, x2: float) -> void:
var built := _make_race_btn(race)
_place(built.btn, x1, 225, x2, 540, 0.5, 0.5)
built.btn.pressed.connect(_on_race_chosen.bind(race))
c.add_child(built.btn)
var roster_lbl := _label(c, "", 12, Color(0.55, 0.65, 0.78),
x1, 550, x2, 760, HORIZONTAL_ALIGNMENT_CENTER, 0.5, 0.5)
roster_lbl.vertical_alignment = VERTICAL_ALIGNMENT_TOP
roster_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_race_boxes[race.id] = {
"btn": built.btn, "count_lbl": built.count_lbl, "hint_lbl": built.hint_lbl,
"roster_lbl": roster_lbl, "color": race.color,
}
func _on_race_chosen(race: Dictionary) -> void:
_chosen_race = race
_race_boxes.clear()
_show_ship_selection()
# ── Ship selection ───────────────────────────────────────────────────────────
func _show_ship_selection() -> void:
var c := _swap_content()
_label(c, "SELECT YOUR SHIP", 30, Color(0.42, 0.87, 1.0),
0, 85, 0, 130, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
_label(c, "Playing as — " + _chosen_race.name, 14, _chosen_race.color,
0, 135, 0, 165, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
var ships: Array = _chosen_race.ships
for i in ships.size():
var y := ROW_START + i * (ROW_H + ROW_GAP)
var btn := _make_ship_row(ships[i], _chosen_race.color)
# ROW_X1 margin from the left edge, ROW_X2_MARGIN margin from the
# actual right edge (anchor_right=1.0) — the row stretches to fill
# whatever width is available instead of stopping at a hardcoded
# 1280px design width.
_place(btn, ROW_X1, y, -ROW_X2_MARGIN, y + ROW_H, 0.0, 1.0)
btn.pressed.connect(_on_ship_chosen.bind(ships[i]))
c.add_child(btn)
func _on_ship_chosen(ship: Dictionary) -> void:
PlayerRegistry.submit_local_loadout(
GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor, ship.role,
ship.get("flame_path", ""), ship.get("sound_path", "")
)
# Only the mandatory pre-spawn pick should end the menu music — a
# mid-match reopen (pause menu SELECT TEAM) leaves it alone since it's
# already stopped by this point (see _reopenable).
if not _reopenable:
MusicManager.stop_menu_music()
_reopenable = true
_close()
# ── Button builders ──────────────────────────────────────────────────────────
func _make_race_btn(race: Dictionary) -> Dictionary:
var btn := Button.new()
btn.text = ""
var col: Color = race.color
var sn := _flat(Color(col.r * 0.14, col.g * 0.14, col.b * 0.14, 0.96), 12)
sn.set_border_width_all(2)
sn.border_color = Color(col.r * 0.38, col.g * 0.38, col.b * 0.38)
var sh := _flat(Color(col.r * 0.24, col.g * 0.24, col.b * 0.24, 0.96), 12)
sh.set_border_width_all(2)
sh.border_color = col
var sd := _flat(Color(0.08, 0.08, 0.09, 0.9), 12)
sd.set_border_width_all(2)
sd.border_color = Color(0.4, 0.15, 0.15)
btn.add_theme_stylebox_override("normal", sn)
btn.add_theme_stylebox_override("hover", sh)
btn.add_theme_stylebox_override("pressed", sh)
btn.add_theme_stylebox_override("disabled", sd)
var vb := VBoxContainer.new()
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vb.offset_left = 20; vb.offset_top = 24; vb.offset_right = -20; vb.offset_bottom = -24
vb.alignment = BoxContainer.ALIGNMENT_CENTER
vb.add_theme_constant_override("separation", 16)
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(vb)
var name_lbl := Label.new()
name_lbl.text = race.name
name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_lbl.add_theme_font_size_override("font_size", 28)
name_lbl.add_theme_color_override("font_color", col)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(name_lbl)
var sep := HSeparator.new()
sep.add_theme_color_override("color", Color(col.r * 0.4, col.g * 0.4, col.b * 0.4))
sep.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(sep)
var sub_lbl := Label.new()
sub_lbl.text = race.sub
sub_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sub_lbl.add_theme_font_size_override("font_size", 15)
sub_lbl.add_theme_color_override("font_color", Color(0.60, 0.70, 0.82))
sub_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(sub_lbl)
var count_lbl := Label.new()
count_lbl.text = "0 PLAYERS"
count_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
count_lbl.add_theme_font_size_override("font_size", 16)
count_lbl.add_theme_color_override("font_color", col)
count_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(count_lbl)
var hint := Label.new()
hint.text = "CLICK TO SELECT"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_font_size_override("font_size", 11)
hint.add_theme_color_override("font_color", Color(col.r * 0.5, col.g * 0.5, col.b * 0.5))
hint.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(hint)
return {"btn": btn, "count_lbl": count_lbl, "hint_lbl": hint}
func _make_ship_row(ship: Dictionary, accent: Color) -> Button:
var btn := Button.new()
btn.text = ""
btn.clip_contents = true
var sn := _flat(Color(0.06, 0.09, 0.17, 0.95), 8)
sn.set_border_width_all(1)
sn.border_color = Color(0.18, 0.28, 0.48, 0.6)
var sh := _flat(Color(0.10, 0.16, 0.28, 0.97), 8)
sh.set_border_width_all(2)
sh.border_color = accent
btn.add_theme_stylebox_override("normal", sn)
btn.add_theme_stylebox_override("hover", sh)
btn.add_theme_stylebox_override("pressed", sh)
var hb := HBoxContainer.new()
hb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
hb.offset_left = 10; hb.offset_top = 8; hb.offset_right = -16; hb.offset_bottom = -8
hb.add_theme_constant_override("separation", 16)
hb.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(hb)
var img_box := Control.new()
img_box.custom_minimum_size = Vector2(ROW_IMG, ROW_IMG)
img_box.size_flags_vertical = Control.SIZE_SHRINK_CENTER
img_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
hb.add_child(img_box)
var tex := TextureRect.new()
tex.texture = load(ship.path)
tex.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tex.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tex.mouse_filter = Control.MOUSE_FILTER_IGNORE
img_box.add_child(tex)
var vb := VBoxContainer.new()
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vb.size_flags_vertical = Control.SIZE_EXPAND_FILL
vb.alignment = BoxContainer.ALIGNMENT_CENTER
vb.add_theme_constant_override("separation", 3)
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
hb.add_child(vb)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(header)
var name_lbl := Label.new()
name_lbl.text = ship.name
name_lbl.add_theme_font_size_override("font_size", 18)
name_lbl.add_theme_color_override("font_color", Color(0.92, 0.95, 1.0))
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(name_lbl)
var role_lbl := Label.new()
role_lbl.text = ship.role.to_upper()
role_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
role_lbl.add_theme_font_size_override("font_size", 13)
role_lbl.add_theme_color_override("font_color", accent)
role_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(role_lbl)
var desc_lbl := Label.new()
desc_lbl.text = ship.desc
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
desc_lbl.clip_text = true
desc_lbl.add_theme_font_size_override("font_size", 12)
desc_lbl.add_theme_color_override("font_color", Color(0.62, 0.70, 0.80))
desc_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(desc_lbl)
return btn
# ── Helpers ──────────────────────────────────────────────────────────────────
func _label(parent: Control, text: String, font_size: int, color: Color,
x1: float, y1: float, x2: float, y2: float,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT,
anchor_left: float = 0.0, anchor_right: float = 0.0) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = align
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", font_size)
lbl.add_theme_color_override("font_color", color)
_place(lbl, x1, y1, x2, y2, anchor_left, anchor_right)
parent.add_child(lbl)
return lbl
# anchor_left/anchor_right default to 0.0 (pixels measured from the left
# edge), matching the design-resolution layout this screen was originally
# built at. Pass 1.0 for anchor_right to make x2 a margin from the actual
# right edge instead (stretches with window width), or 0.5 for both to
# center a fixed-size element regardless of window width — see call sites
# below for which each element needs (stretch mode is disabled, so nothing
# scales automatically the way it used to under canvas_items stretch).
#
# Anchors and offsets are set as plain property assignments, NOT via
# sequential set_anchor_and_offset() calls — that method's default
# push_opposite_anchor=true drags the opposite side's anchor/offset along
# whenever the two calls momentarily disagree (e.g. left set to 0.5 while
# right is still its 0.0 default), corrupting layout for any non-0.0 anchor.
# Found via main_menu.gd's play buttons rendering flush against the left
# edge instead of centered.
func _place(node: Control, x1: float, y1: float, x2: float, y2: float,
anchor_left: float = 0.0, anchor_right: float = 0.0) -> void:
node.anchor_left = anchor_left
node.anchor_right = anchor_right
node.anchor_top = 0.0
node.anchor_bottom = 0.0
node.offset_left = x1
node.offset_top = y1
node.offset_right = x2
node.offset_bottom = y2
func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = col
s.set_corner_radius_all(radius)
return s