a94bccc57b
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>
532 lines
21 KiB
GDScript
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
|