Files
client/spacewar/menu/main_menu.gd
T
anekdotin 2f6607af8c Add engine flames, damage numbers, nameplates, music, and combat/collision fixes
Feature work (CLAUDE.md items 17-18):
- Race roster art rework (Apex/ISN/ORC) plus per-ship engine-flame sprites
  that swap in on thrust
- Floating damage-number VFX, per-ship nameplates, energy bar rework
- Background music manager and per-race ship SFX

Bugfixes/tuning from live testing:
- No friendly fire: bullets stop on a teammate's hull but deal no damage
  (bullet.gd), decided by race via PlayerRegistry
- Ships no longer physically block each other (own collision layer,
  ship.tscn/bullet.tscn) -- fixes ramming/parking trolling and the stuck-on-
  corpse bug after a kill
- Explosions ghost any ship's sprite passing through instead of blocking it
  (effects/explosion.gd's GhostArea)
- Fixed two "flushing physics queries" crashes from the above: explosion
  spawn and death/respawn collision toggling are now deferred
  (ship_movement.gd)
- Dead ships stop blocking bullets during their respawn delay
- Bots fly a mixed roster (1 Tank + 2 Gunner + rest Fighter per team)
  instead of always the Fighter (bot_manager.gd)
- Camera zoom knob, larger/longer-lived damage numbers and nameplate text,
  red (not yellow) enemy name color for better contrast

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:55:18 -04:00

409 lines
12 KiB
GDScript

extends Control
# HL2-style menu: no big mode tiles, just a plain vertical text nav over a
# space backdrop. Quick Play always queues casual — ranked is gone until the
# matchmaking/MMR work in overview/task.md lands.
var _status_lbl: Label
var _name_lbl: Label
var _rank_lbl: Label
var _profile_overlay: Control
var _profile_input: LineEdit
var _profile_hint_lbl: Label
var _connecting: bool = false
var _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
const RANK_DATA := [
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
{"label": "PILOT", "color": Color(0.78, 0.78, 0.82)},
{"label": "ACE", "color": Color(1.0, 0.82, 0.22)},
{"label": "COMMANDER", "color": Color(0.35, 0.88, 1.0)},
{"label": "ADMIRAL", "color": Color(0.62, 0.28, 1.0)},
{"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)},
]
const CURRENT_RANK := 0 # placeholder until backend exists
const CURRENT_LEVEL := 1 # placeholder until backend exists
const NAV_ITEMS := ["QUICK PLAY", "SERVER SELECT", "OPTIONS", "PROFILE", "QUIT"]
const NAV_LEFT := 64.0
const NAV_WIDTH := 360.0
const NAV_TOP := 250.0
const NAV_BTN_H := 52.0
const NAV_GAP := 4.0
func _ready() -> void:
_build_ui()
MusicManager.play_menu_music()
NetworkManager.connection_succeeded.connect(_on_connected)
NetworkManager.connection_failed.connect(_on_connect_failed)
MatchmakingClient.match_found.connect(_on_match_found)
MatchmakingClient.search_failed.connect(_on_search_failed)
func _build_ui() -> void:
_add_bg()
_add_logo()
_add_nav_menu()
_add_profile_badge()
_add_status_label()
_add_version_label()
_add_profile_overlay()
_refresh_profile_badge()
func _add_bg() -> void:
var bg := TextureRect.new()
bg.texture = load("res://assets/images/background/skybox/2.png")
bg.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
bg.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(bg)
var ov := ColorRect.new()
ov.color = Color(0.0, 0.0, 0.0, 0.55)
ov.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(ov)
func _add_logo() -> void:
var title := Label.new()
title.text = "S P A C E W A R"
title.anchor_left = 0.0
title.anchor_top = 0.0
title.anchor_right = 0.0
title.anchor_bottom = 0.0
title.offset_left = 60.0
title.offset_top = 56.0
title.offset_right = 700.0
title.offset_bottom = 118.0
title.add_theme_font_size_override("font_size", 44)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
add_child(title)
var sub := Label.new()
sub.text = "FIGHT FOR THE GALAXY"
sub.anchor_left = 0.0
sub.anchor_top = 0.0
sub.anchor_right = 0.0
sub.anchor_bottom = 0.0
sub.offset_left = 64.0
sub.offset_top = 116.0
sub.offset_right = 500.0
sub.offset_bottom = 138.0
sub.add_theme_font_size_override("font_size", 13)
sub.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78, 0.85))
add_child(sub)
func _add_nav_menu() -> void:
for i in NAV_ITEMS.size():
var label_text: String = NAV_ITEMS[i]
var stub := label_text == "OPTIONS"
var btn := _make_nav_btn(label_text, stub)
btn.anchor_left = 0.0
btn.anchor_top = 0.0
btn.anchor_right = 0.0
btn.anchor_bottom = 0.0
btn.offset_left = NAV_LEFT
btn.offset_right = NAV_LEFT + NAV_WIDTH
btn.offset_top = NAV_TOP + i * (NAV_BTN_H + NAV_GAP)
btn.offset_bottom = btn.offset_top + NAV_BTN_H
add_child(btn)
match label_text:
"QUICK PLAY":
btn.pressed.connect(_on_quick_play)
"SERVER SELECT":
btn.pressed.connect(_on_server_select)
"OPTIONS":
btn.pressed.connect(_on_options)
"PROFILE":
btn.pressed.connect(_on_profile)
"QUIT":
btn.pressed.connect(_on_quit)
func _make_nav_btn(label_text: String, stub: bool) -> Button:
var btn := Button.new()
btn.text = label_text
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.add_theme_font_size_override("font_size", 22)
var sn := StyleBoxEmpty.new()
sn.content_margin_left = 20.0
btn.add_theme_stylebox_override("normal", sn)
if stub:
btn.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48, 0.8))
btn.add_theme_stylebox_override("hover", sn)
btn.add_theme_stylebox_override("pressed", sn)
else:
btn.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 1.0))
btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0))
var sh := StyleBoxFlat.new()
sh.bg_color = Color(1.0, 1.0, 1.0, 0.06)
sh.content_margin_left = 20.0
sh.border_width_left = 3
sh.border_color = Color(1.0, 1.0, 1.0, 0.9)
btn.add_theme_stylebox_override("hover", sh)
btn.add_theme_stylebox_override("pressed", sh)
return btn
func _add_profile_badge() -> void:
_name_lbl = Label.new()
_name_lbl.anchor_left = 1.0
_name_lbl.anchor_top = 0.0
_name_lbl.anchor_right = 1.0
_name_lbl.anchor_bottom = 0.0
_name_lbl.offset_left = -340.0
_name_lbl.offset_top = 28.0
_name_lbl.offset_right = -24.0
_name_lbl.offset_bottom = 50.0
_name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_name_lbl.add_theme_font_size_override("font_size", 17)
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
add_child(_name_lbl)
_rank_lbl = Label.new()
_rank_lbl.anchor_left = 1.0
_rank_lbl.anchor_top = 0.0
_rank_lbl.anchor_right = 1.0
_rank_lbl.anchor_bottom = 0.0
_rank_lbl.offset_left = -340.0
_rank_lbl.offset_top = 50.0
_rank_lbl.offset_right = -24.0
_rank_lbl.offset_bottom = 68.0
_rank_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_rank_lbl.add_theme_font_size_override("font_size", 12)
add_child(_rank_lbl)
func _refresh_profile_badge() -> void:
if GameConfig.player_name.strip_edges().is_empty():
_name_lbl.text = "GUEST"
_name_lbl.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78))
_rank_lbl.text = "SET A CALLSIGN IN PROFILE"
_rank_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
else:
_name_lbl.text = GameConfig.player_name.to_upper()
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
var rank: Dictionary = RANK_DATA[CURRENT_RANK]
_rank_lbl.text = "%s • LEVEL %d" % [rank.label, CURRENT_LEVEL]
_rank_lbl.add_theme_color_override("font_color", rank.color)
func _add_status_label() -> void:
_status_lbl = Label.new()
_status_lbl.text = ""
_status_lbl.anchor_left = 0.0
_status_lbl.anchor_top = 1.0
_status_lbl.anchor_right = 0.0
_status_lbl.anchor_bottom = 1.0
_status_lbl.offset_left = NAV_LEFT
_status_lbl.offset_right = NAV_LEFT + 460.0
_status_lbl.offset_top = -64.0
_status_lbl.offset_bottom = -36.0
_status_lbl.add_theme_font_size_override("font_size", 13)
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
add_child(_status_lbl)
func _add_version_label() -> void:
var lbl := Label.new()
lbl.text = "v0.1-dev"
lbl.anchor_left = 0.0
lbl.anchor_top = 1.0
lbl.anchor_right = 0.0
lbl.anchor_bottom = 1.0
lbl.offset_left = NAV_LEFT
lbl.offset_top = -30.0
lbl.offset_right = NAV_LEFT + 100.0
lbl.offset_bottom = -10.0
lbl.add_theme_font_size_override("font_size", 11)
lbl.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48))
add_child(lbl)
func _add_profile_overlay() -> void:
_profile_overlay = Control.new()
_profile_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_profile_overlay.visible = false
add_child(_profile_overlay)
var dim := ColorRect.new()
dim.color = Color(0.0, 0.0, 0.0, 0.6)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
_profile_overlay.add_child(dim)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.offset_left = -200
panel.offset_top = -120
panel.offset_right = 200
panel.offset_bottom = 120
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.05, 0.05, 0.07, 0.97)
ps.set_corner_radius_all(6)
ps.set_border_width_all(1)
ps.border_color = Color(1.0, 1.0, 1.0, 0.25)
panel.add_theme_stylebox_override("panel", ps)
_profile_overlay.add_child(panel)
var vb := VBoxContainer.new()
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vb.offset_left = 26
vb.offset_top = 22
vb.offset_right = -26
vb.offset_bottom = -22
vb.add_theme_constant_override("separation", 10)
panel.add_child(vb)
var title := Label.new()
title.text = "PROFILE"
title.add_theme_font_size_override("font_size", 20)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
vb.add_child(title)
vb.add_child(HSeparator.new())
_profile_hint_lbl = Label.new()
_profile_hint_lbl.text = ""
_profile_hint_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD
_profile_hint_lbl.add_theme_font_size_override("font_size", 12)
_profile_hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
_profile_hint_lbl.visible = false
vb.add_child(_profile_hint_lbl)
var lbl := Label.new()
lbl.text = "CALLSIGN"
lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", Color(0.65, 0.68, 0.75))
vb.add_child(lbl)
_profile_input = LineEdit.new()
_profile_input.placeholder_text = "Enter your callsign..."
_profile_input.custom_minimum_size = Vector2(0, 40)
_profile_input.add_theme_font_size_override("font_size", 15)
_profile_input.text_submitted.connect(func(_t): _on_profile_save())
vb.add_child(_profile_input)
var gap := Control.new()
gap.custom_minimum_size = Vector2(0, 4)
gap.size_flags_vertical = Control.SIZE_EXPAND_FILL
vb.add_child(gap)
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 10)
vb.add_child(btn_row)
var cancel_btn := Button.new()
cancel_btn.text = "CANCEL"
cancel_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
cancel_btn.custom_minimum_size = Vector2(0, 42)
cancel_btn.pressed.connect(_on_profile_cancel)
btn_row.add_child(cancel_btn)
var save_btn := Button.new()
save_btn.text = "SAVE"
save_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
save_btn.custom_minimum_size = Vector2(0, 42)
save_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
var sn := StyleBoxFlat.new()
sn.bg_color = Color(1.0, 1.0, 1.0, 0.12)
sn.set_corner_radius_all(4)
var sh := StyleBoxFlat.new()
sh.bg_color = Color(1.0, 1.0, 1.0, 0.22)
sh.set_corner_radius_all(4)
save_btn.add_theme_stylebox_override("normal", sn)
save_btn.add_theme_stylebox_override("hover", sh)
save_btn.pressed.connect(_on_profile_save)
btn_row.add_child(save_btn)
func _open_profile(pending_quick_play: bool) -> void:
_pending_quick_play = pending_quick_play
_profile_input.text = GameConfig.player_name
_profile_hint_lbl.visible = pending_quick_play
_profile_hint_lbl.text = "Enter a callsign to play."
_profile_overlay.visible = true
_profile_input.grab_focus()
func _on_profile_save() -> void:
var new_name := _profile_input.text.strip_edges()
if new_name.is_empty():
return
GameConfig.player_name = new_name
_refresh_profile_badge()
_profile_overlay.visible = false
if _pending_quick_play:
_pending_quick_play = false
_start_quick_play()
func _on_profile_cancel() -> void:
_pending_quick_play = false
_profile_overlay.visible = false
func _on_quick_play() -> void:
if _connecting:
return
if GameConfig.player_name.strip_edges().is_empty():
_open_profile(true)
return
_start_quick_play()
func _start_quick_play() -> void:
_connecting = true
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
_status_lbl.text = "Searching for a match..."
MatchmakingClient.start_matchmaking(GameConfig.player_name, "casual")
func _on_server_select() -> void:
get_tree().change_scene_to_file("res://menu/server_select.tscn")
func _on_options() -> void:
_status_lbl.add_theme_color_override("font_color", Color(0.5, 0.54, 0.62))
_status_lbl.text = "Options — coming soon."
func _on_profile() -> void:
_open_profile(false)
func _on_quit() -> void:
get_tree().quit()
func _on_match_found(server_ip: String, server_port: int) -> void:
_status_lbl.text = "Match found — connecting..."
NetworkManager.join_server(server_ip, server_port)
func _on_connected() -> void:
get_tree().change_scene_to_file("res://world/world.tscn")
func _on_connect_failed() -> void:
_connecting = false
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_status_lbl.text = "Connection failed — is the game server reachable?"
func _on_search_failed(reason: String) -> void:
_connecting = false
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_status_lbl.text = "Matchmaking failed: %s" % reason