Files
joywayer ce4ce2b392 docs(shop): 记录属性子面板行容量上限,供后续扩容前置说明
商店「属性」子面板行坐标 y = 220 + i*34,关闭按钮占 y=[420,460];按当前坐标常量
算出 250+34i <= 420 ⟺ i <= 5,即最多 6 行(i=0..5)不与关闭按钮重叠,第 7 行
(i=6)落在 y=424 正压在按钮上。路线图 E3-① 延后的 7 个属性一旦全部上架会超出
此容量。本轮不实现滚动/布局改造(超出本次合并范围),仅加注释记录数字,供后续
实现者在动那批属性之前先规划布局。
2026-08-03 12:51:30 +08:00

958 lines
33 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## CombatS2 — S2 核心循环场景
## 目标:波次→商店→升级→下一波,循环可无限重复
## 验收:P-S2-01 ~ P-S2-07
extends Node2D
## 玩家节点
var _player_node: Node2D = null
## 世界渲染(MultiMeshInstance2D,§4.4 渲染优化)
var _bullet_mm: MultiMesh = null
var _enemy_mm: MultiMesh = null
var _enemy_bullet_mm: MultiMesh = null
## UI 层引用
var _hud_layer: CanvasLayer = null
var _shop_layer: CanvasLayer = null
var _shop_warn: Label = null # 防呆警告:Deck 无 ACTION 时提示
## HUD 标签
var _lbl_wave: Label = null
var _lbl_hp: Label = null
var _lbl_gold: Label = null
var _lbl_xp: Label = null
var _lbl_kills: Label = null
var _lbl_status: Label = null
var _lbl_deck: Label = null
var _lbl_dps: Label = null
var _lbl_charge: Label = null
var _lbl_statuses: Label = null
var _mana_bar: ProgressBar = null
var _mana_lbl: Label = null
var _dps_poll_timer: float = 0.0
## 商店
var _shop_panel: Panel = null
var _shop_btns: Array = [] # Array[Button]
var _reroll_btn: Button = null
var _close_btn: Button = null
var _core_btn: Button = null
var _inv_btn: Button = null
var _lang_btn: Button = null
## 属性购买(货架 C):独立子面板,由商店面板上的入口按钮打开
## (商店面板自身已无空间容纳四行属性——面板 y=200~540,既有控件已占到 y≈522,
## 仅剩约 18px,故不内联在商店面板里,改开独立弹窗,参照 _setup_settings_ui 的结构)
var _attr_layer: CanvasLayer = null
var _attr_rows: Array = [] # [{id: String, label: Label, button: Button}]
## 背包(插槽编辑)
var _inv_layer: CanvasLayer = null
var _inv_root: Control = null
var _inv_sel_kind: String = "" # "" / "slot" / "bench"
var _inv_sel_idx: int = -1
## 结算 / 死亡屏
var _settle_layer: CanvasLayer = null
var _settle_root: Control = null
## 暂停菜单
var _pause_layer: CanvasLayer = null
var _is_paused: bool = false
## 设置面板
var _settings_layer: CanvasLayer = null
var _settings_root: Control = null
var _clear_confirm: bool = false # 清除数据二次确认
## CombatManager
var _cm: Node = null # CombatManager 实例
func _ready() -> void:
# 暂停时仍需响应 ESC 与暂停菜单按钮(游戏世界由 autoload 的 PAUSABLE _physics_process 冻结)
process_mode = Node.PROCESS_MODE_ALWAYS
SceneManager.fade_in_first() # 单独测试时淡入;经 goto 进入时自动跳过
_setup_player()
_setup_world_view()
_setup_hud()
_setup_shop_ui()
_setup_attr_shop_ui()
_setup_inventory_ui()
_setup_settlement_ui()
_setup_settings_ui()
_setup_pause_menu()
_setup_combat_manager()
_subscribe_events()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
if _inv_layer and _inv_layer.visible:
_close_inventory()
elif _settings_layer and _settings_layer.visible:
_close_settings()
elif _attr_layer and _attr_layer.visible:
_close_attr_shop()
elif _settle_layer and _settle_layer.visible:
pass # 结算屏:ESC 无效,必须按按钮
elif _shop_layer and _shop_layer.visible:
pass # 商店中不暂停
else:
_toggle_pause()
func _process(delta: float) -> void:
# 世界渲染同步(SoA → MultiMesh
BulletManager.sync_multimesh(_bullet_mm)
EnemyBulletManager.sync_multimesh(_enemy_bullet_mm)
EnemyManager.sync_multimesh(_enemy_mm)
_refresh_hud()
_dps_poll_timer -= delta
if _dps_poll_timer <= 0.0:
_dps_poll_timer = 0.5
if _lbl_dps:
_lbl_dps.text = tr("UI_DPS") % DpsTracker.get_dps()
# 无敌帧闪烁反馈:8Hz 方波(相位来自系统时钟,避免累加状态)
if _player_node:
if PlayerStats.is_invulnerable():
var on: bool = (Time.get_ticks_msec() / 62) % 2 == 0 # ≈8Hz62ms 半周期)
_player_node.modulate.a = 1.0 if on else 0.35
elif _player_node.modulate.a != 1.0:
_player_node.modulate.a = 1.0 # 窗口结束确保恢复不透明
# ── 初始化 ─────────────────────────────────────────────────
func _setup_player() -> void:
var p := Node2D.new()
p.name = "Player"
add_child(p)
_player_node = p
PlayerManager.set_player_node(p)
## 世界视图:竞技场网格背景 + 玩家标记 + 跟随相机 + 子弹/敌人 MultiMesh 渲染器
func _setup_world_view() -> void:
# 竞技场网格地面(程序化 shader,世界静止,z 最底)
var arena := Polygon2D.new()
var ext: float = 5000.0
arena.polygon = PackedVector2Array([
Vector2(-ext, -ext), Vector2(ext, -ext), Vector2(ext, ext), Vector2(-ext, ext)])
arena.uv = PackedVector2Array([
Vector2(0, 0), Vector2(1, 0), Vector2(1, 1), Vector2(0, 1)])
var grid_mat := ShaderMaterial.new()
grid_mat.shader = load("res://shaders/arena_grid.gdshader")
arena.material = grid_mat
arena.z_index = -100
add_child(arena)
# 玩家可视标记(青色发光菱形 + 光晕环)
var glow := Polygon2D.new()
glow.polygon = PackedVector2Array([
Vector2(0, -22), Vector2(22, 0), Vector2(0, 22), Vector2(-22, 0)])
glow.color = Color(0.35, 0.9, 1.0, 0.22)
_player_node.add_child(glow)
var marker := Polygon2D.new()
marker.polygon = PackedVector2Array([
Vector2(0, -13), Vector2(13, 0), Vector2(0, 13), Vector2(-13, 0)])
marker.color = Color(0.55, 0.95, 1.0)
_player_node.add_child(marker)
var core := Polygon2D.new()
core.polygon = PackedVector2Array([
Vector2(0, -6), Vector2(6, 0), Vector2(0, 6), Vector2(-6, 0)])
core.color = Color(1.0, 1.0, 1.0)
_player_node.add_child(core)
# 跟随相机
var cam := Camera2D.new()
cam.enabled = true
_player_node.add_child(cam)
var quad := QuadMesh.new()
quad.size = Vector2(1.0, 1.0) # 单位四边形,transform 缩放即像素尺寸
# 敌人渲染器(per-instance 颜色)
_enemy_mm = MultiMesh.new()
_enemy_mm.transform_format = MultiMesh.TRANSFORM_2D
_enemy_mm.use_colors = true
_enemy_mm.mesh = quad
_enemy_mm.instance_count = EnemyManager.MAX_ENEMIES
_enemy_mm.visible_instance_count = 0
var enemy_mmi := MultiMeshInstance2D.new()
enemy_mmi.multimesh = _enemy_mm
# 程序化美术占位:方块 → 软边实体圆 + 高光
var enemy_mat := ShaderMaterial.new()
enemy_mat.shader = load("res://shaders/enemy_circle.gdshader")
enemy_mmi.material = enemy_mat
add_child(enemy_mmi)
# 子弹渲染器(统一淡黄色,modulate)
_bullet_mm = MultiMesh.new()
_bullet_mm.transform_format = MultiMesh.TRANSFORM_2D
_bullet_mm.mesh = quad
_bullet_mm.instance_count = BulletManager.MAX_BULLETS
_bullet_mm.visible_instance_count = 0
var bullet_mmi := MultiMeshInstance2D.new()
bullet_mmi.multimesh = _bullet_mm
bullet_mmi.modulate = Color(1.0, 0.95, 0.45)
# 程序化美术占位:方块 → 加法发光圆(弹幕质感)
var bullet_mat := ShaderMaterial.new()
bullet_mat.shader = load("res://shaders/bullet_glow.gdshader")
bullet_mmi.material = bullet_mat
add_child(bullet_mmi)
# 敌方子弹渲染器(红粉色 modulate,区分玩家黄弹)
_enemy_bullet_mm = MultiMesh.new()
_enemy_bullet_mm.transform_format = MultiMesh.TRANSFORM_2D
_enemy_bullet_mm.mesh = quad
_enemy_bullet_mm.instance_count = EnemyBulletManager.MAX_ENEMY_BULLETS
_enemy_bullet_mm.visible_instance_count = 0
var eb_mmi := MultiMeshInstance2D.new()
eb_mmi.multimesh = _enemy_bullet_mm
eb_mmi.modulate = Color(1.0, 0.35, 0.5)
var eb_mat := ShaderMaterial.new()
eb_mat.shader = load("res://shaders/bullet_glow.gdshader")
eb_mmi.material = eb_mat
add_child(eb_mmi)
func _setup_combat_manager() -> void:
_cm = Node.new()
_cm.set_script(load("res://scripts/domain/combat/combat_manager.gd"))
add_child(_cm)
_cm._on_shop_closed_cb = Callable(self, "_on_shop_ready")
# 根据 SceneManager 传入的启动模式决定新游戏还是继续
if SceneManager.start_mode == "continue":
call_deferred("_deferred_resume")
else:
call_deferred("_deferred_start")
func _deferred_start() -> void:
_cm.start_game()
func _deferred_resume() -> void:
_cm.resume_game()
func _subscribe_events() -> void:
EventBus.subscribe(EventID.GAME_STATE_CHANGED, _on_game_state_changed)
EventBus.subscribe(EventID.GAME_OVER, _on_game_over)
EventBus.subscribe(EventID.SPELL_DEPTH_EXCEEDED, _on_spell_depth_exceeded)
EventBus.subscribe(EventID.CHARGE_STATE_CHANGED, _on_charge_state_changed)
EventBus.subscribe(EventID.CHARGE_FIRED, _on_charge_fired)
EventBus.subscribe(EventID.BOSS_PHASE_CHANGED, _on_boss_phase_changed)
PlayerStats.stats_changed.connect(_on_stats_changed)
func _setup_hud() -> void:
_hud_layer = CanvasLayer.new()
_hud_layer.name = "HUD"
add_child(_hud_layer)
var bg := ColorRect.new()
bg.color = Color(0, 0, 0, 0.5)
bg.size = Vector2(380, 190)
bg.position = Vector2(10, 10)
_hud_layer.add_child(bg)
var make_label := func(pos: Vector2, font_size: int = 16) -> Label:
var lbl := Label.new()
lbl.position = pos
lbl.add_theme_font_size_override("font_size", font_size)
_hud_layer.add_child(lbl)
return lbl
_lbl_wave = make_label.call(Vector2(20, 18), 18)
_lbl_hp = make_label.call(Vector2(20, 42))
_lbl_gold = make_label.call(Vector2(20, 62))
_lbl_xp = make_label.call(Vector2(20, 82))
_lbl_kills = make_label.call(Vector2(20, 102))
_lbl_dps = make_label.call(Vector2(200, 42))
_lbl_charge = make_label.call(Vector2(200, 62))
_lbl_deck = make_label.call(Vector2(20, 122), 13)
_lbl_statuses = make_label.call(Vector2(20, 142), 13)
_mana_bar = ProgressBar.new()
_mana_bar.position = Vector2(200, 86)
_mana_bar.custom_minimum_size = Vector2(170, 16)
_mana_bar.size = Vector2(170, 16)
_mana_bar.min_value = 0.0
_mana_bar.show_percentage = false
var mana_fill := StyleBoxFlat.new()
mana_fill.bg_color = Color(0.3, 0.55, 1.0)
_mana_bar.add_theme_stylebox_override("fill", mana_fill)
var mana_bg := StyleBoxFlat.new()
mana_bg.bg_color = Color(0.12, 0.12, 0.22)
_mana_bar.add_theme_stylebox_override("background", mana_bg)
_hud_layer.add_child(_mana_bar)
_mana_lbl = Label.new()
_mana_lbl.position = Vector2(200, 86)
_mana_lbl.size = Vector2(170, 16)
_mana_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_mana_lbl.add_theme_font_size_override("font_size", 11)
_hud_layer.add_child(_mana_lbl)
_lbl_status = make_label.call(Vector2(20, 620), 20)
_lbl_status.modulate = Color.YELLOW
func _setup_shop_ui() -> void:
_shop_layer = CanvasLayer.new()
_shop_layer.name = "ShopLayer"
_shop_layer.visible = false
add_child(_shop_layer)
var bg := ColorRect.new()
bg.color = Color(0.08, 0.08, 0.2, 0.92)
bg.size = Vector2(600, 340)
bg.position = Vector2(200, 200)
_shop_layer.add_child(bg)
var title := Label.new()
title.text = tr("SHOP_TITLE")
title.position = Vector2(220, 215)
title.add_theme_font_size_override("font_size", 20)
title.modulate = Color.GOLD
_shop_layer.add_child(title)
# 3 张法术卡按鈕
for i in 3:
var btn := Button.new()
btn.position = Vector2(220 + i * 195, 255)
btn.size = Vector2(180, 130)
btn.connect("pressed", _on_shop_slot_pressed.bind(i))
_shop_layer.add_child(btn)
_shop_btns.append(btn)
_reroll_btn = Button.new()
_reroll_btn.text = tr("SHOP_REROLL") % ShopManager.REROLL_BASE_COST
_reroll_btn.position = Vector2(220, 400)
_reroll_btn.size = Vector2(130, 38)
_reroll_btn.connect("pressed", _on_reroll_pressed)
_shop_layer.add_child(_reroll_btn)
# 法杖(Core)切换按钮:循环 wand_basic→fast→memory→matrix→circuit
_core_btn = Button.new()
_core_btn.position = Vector2(360, 400)
_core_btn.size = Vector2(180, 38)
_core_btn.connect("pressed", _on_switch_core_pressed)
_shop_layer.add_child(_core_btn)
# 背包(插槽编辑)按钮
_inv_btn = Button.new()
_inv_btn.text = tr("SHOP_INV_BTN")
_inv_btn.position = Vector2(220, 445)
_inv_btn.size = Vector2(130, 34)
_inv_btn.connect("pressed", _open_inventory)
_shop_layer.add_child(_inv_btn)
# 语言切换(循环 zh_CN→zh_TW→en→ja
_lang_btn = Button.new()
_lang_btn.position = Vector2(360, 445)
_lang_btn.size = Vector2(120, 34)
_lang_btn.connect("pressed", _on_lang_pressed)
_shop_layer.add_child(_lang_btn)
# 设置面板入口
var settings_btn := Button.new()
settings_btn.text = tr("SHOP_SETTINGS_BTN")
settings_btn.position = Vector2(490, 445)
settings_btn.size = Vector2(120, 34)
settings_btn.connect("pressed", _open_settings)
_shop_layer.add_child(settings_btn)
# 属性购买入口(货架 C):面板 y=445 行内剩余空白(settings_btn 右边到面板边缘 x=610~800),
# 不改动本函数内任何既有控件的坐标;点击打开独立子面板 _attr_layer(见 _setup_attr_shop_ui
var attr_btn := Button.new()
attr_btn.text = tr("SHOP_ATTR_BTN")
attr_btn.position = Vector2(630, 445)
attr_btn.size = Vector2(150, 34)
attr_btn.connect("pressed", _open_attr_shop)
_shop_layer.add_child(attr_btn)
_close_btn = Button.new()
_close_btn.text = tr("SHOP_NEXT_WAVE")
_close_btn.position = Vector2(550, 400)
_close_btn.size = Vector2(140, 38)
_close_btn.connect("pressed", _on_close_shop_pressed)
_shop_layer.add_child(_close_btn)
_shop_warn = Label.new()
_shop_warn.position = Vector2(250, 502) # 商店按钮行下方空白区,避免遮挡背包/语言/设置按钮
_shop_warn.add_theme_color_override("font_color", Color(1.0, 0.45, 0.35))
_shop_warn.visible = false
_shop_layer.add_child(_shop_warn)
# ── 属性购买(货架 C)子面板 ────────────────────────────────
## 完全数据驱动:行数与内容全部来自 ShopManager.get_sellable_attrs()/get_attr_def()
## 本函数不含任何属性 id 的硬编码分支;新增可售属性只需改 data/attributes.json 的 shop 段
func _setup_attr_shop_ui() -> void:
_attr_layer = CanvasLayer.new()
_attr_layer.name = "AttrShopLayer"
_attr_layer.visible = false
add_child(_attr_layer)
var bg := ColorRect.new()
bg.color = Color(0.06, 0.06, 0.12, 0.97)
bg.size = Vector2(560, 320)
bg.position = Vector2(300, 160)
_attr_layer.add_child(bg)
var title := Label.new()
title.text = tr("SHOP_ATTR_TITLE")
title.position = Vector2(324, 178)
title.add_theme_font_size_override("font_size", 20)
title.modulate = Color.GOLD
_attr_layer.add_child(title)
_attr_rows.clear()
var ids: Array[String] = ShopManager.get_sellable_attrs()
# 行容量上限(当前坐标常量下):行 i 占 y=[220+34i, 250+34i]label/button 高 30),
# 关闭按钮占 y=[420, 460](见下方 close.position/size)。250+34i <= 420 ⟺ i <= 5
# 即最多 6 行(i=0..5)不与关闭按钮重叠;第 7 行(i=6)落在 y=424,正压在按钮上。
# 路线图 E3-② 延后的 7 个属性一旦全部上架会超出此容量,需要先做布局/滚动改造——
# 本轮评审 Finding 4 仅要求记录容量,不在此实现滚动,故不改动下方坐标。
for i in ids.size():
var id: String = ids[i]
var y: float = 220.0 + float(i) * 34.0
var lbl := Label.new()
lbl.position = Vector2(324, y)
lbl.size = Vector2(300, 30)
_attr_layer.add_child(lbl)
var btn := Button.new()
btn.position = Vector2(640, y)
btn.size = Vector2(180, 30)
btn.connect("pressed", _on_buy_attr_pressed.bind(id))
_attr_layer.add_child(btn)
_attr_rows.append({"id": id, "label": lbl, "button": btn})
var close := Button.new()
close.text = tr("SHOP_ATTR_CLOSE")
close.position = Vector2(700, 420)
close.size = Vector2(120, 40)
close.connect("pressed", _close_attr_shop)
_attr_layer.add_child(close)
func _open_attr_shop() -> void:
_attr_layer.visible = true
_refresh_shop_ui()
func _close_attr_shop() -> void:
_attr_layer.visible = false
func _on_buy_attr_pressed(id: String) -> void:
if ShopManager.buy_attribute(id):
_refresh_shop_ui()
# ── HUD 刷新 ────────────────────────────────────────────────
func _refresh_hud() -> void:
if _lbl_wave == null:
return
_lbl_wave.text = tr("UI_WAVE") % WaveManager.current_wave
_lbl_hp.text = tr("UI_HP") % [PlayerStats.hp, PlayerStats.hp_max]
_lbl_gold.text = tr("UI_GOLD") % PlayerStats.gold
_lbl_xp.text = tr("UI_XP") % [PlayerStats.xp, PlayerStats.xp_to_next, PlayerStats.level]
_lbl_kills.text = tr("UI_KILLS") % [PlayerManager.kill_count, EnemyManager.get_active_count()]
if _cm:
_lbl_deck.text = "[%s] %s" % [_cm.get_core_display(), str(_cm.get_deck_spell_ids())]
_lbl_statuses.text = tr("UI_STATUSES") % [StatusManager.get_active_count(), VFXManager.get_active_count()]
if _mana_bar:
_mana_bar.max_value = PlayerStats.mana_max
_mana_bar.value = PlayerStats.mana
var frac: float = PlayerStats.mana / maxf(1.0, PlayerStats.mana_max)
var fill := _mana_bar.get_theme_stylebox("fill") as StyleBoxFlat
if fill:
if frac < 0.2:
var a: float = 0.6 + 0.4 * absf(sin(float(Time.get_ticks_msec()) / 150.0))
fill.bg_color = Color(0.8, 0.2, 0.2, a)
else:
fill.bg_color = Color(0.3, 0.55, 1.0)
var h: float = clampf(PlayerStats.mana_heat, 0.0, 0.7)
_mana_bar.modulate = Color(1.0, 1.0 - h, 1.0 - h)
if _mana_lbl:
_mana_lbl.text = tr("UI_MANA") % [int(PlayerStats.mana), int(PlayerStats.mana_max)]
func _refresh_shop_ui() -> void:
for i in _shop_btns.size():
var btn: Button = _shop_btns[i]
var spell: SpellNode = ShopManager.current_slots[i] if i < ShopManager.current_slots.size() else null
if spell:
var cost: int = int(spell.meta.get("shop_cost", 20))
btn.text = "%s\n%s\n%dG" % [spell.display_name, spell.description, cost]
btn.disabled = PlayerStats.gold < cost
else:
btn.text = tr("SHOP_SOLD")
btn.disabled = true
var reroll_cost: int = ShopManager.get_reroll_cost()
_reroll_btn.text = tr("SHOP_REROLL") % reroll_cost
_reroll_btn.disabled = PlayerStats.gold < reroll_cost
if _core_btn and _cm:
_core_btn.text = tr("SHOP_CORE_BTN") % _cm.get_core_display()
if _lang_btn:
_lang_btn.text = "🌐 " + Locale.get_locale()
for row in _attr_rows:
var id: String = row["id"]
var reason: String = ShopManager.can_buy_attribute(id)
var price: int = ShopManager.get_attr_price(id)
var n: int = ShopManager.get_attr_purchases(id)
var disp: String = String(ShopManager.get_attr_def(id).get("display_name", id))
row["label"].text = tr("SHOP_ATTR_ROW") % [disp, "%.2f" % PlayerStats.get_attr_value(id), n]
row["button"].text = (tr("SHOP_ATTR_BUY") % price) if reason.is_empty() else reason
row["button"].disabled = not reason.is_empty()
# ── 事件 ─────────────────────────────────────────────────
func _on_game_state_changed(payload: Dictionary) -> void:
var to_state: String = str(payload.get("to", ""))
_lbl_status.text = "[状态: %s]" % to_state
func _on_stats_changed() -> void:
if _shop_layer and _shop_layer.visible:
_refresh_shop_ui()
func _on_shop_ready(_wave: int) -> void:
if _shop_warn:
_shop_warn.visible = false
_shop_layer.visible = true
_refresh_shop_ui()
func _on_shop_slot_pressed(slot_idx: int) -> void:
var spell: SpellNode = ShopManager.buy_spell(slot_idx)
if spell:
_cm.install_spell(spell)
_lbl_status.text = tr("STATUS_PURCHASED") % spell.display_name
_refresh_shop_ui()
func _on_reroll_pressed() -> void:
if ShopManager.reroll():
_refresh_shop_ui()
func _on_switch_core_pressed() -> void:
_cm.switch_core()
_lbl_status.text = tr("STATUS_WAND_SWITCH") % _cm.get_core_display()
_refresh_shop_ui()
func _on_lang_pressed() -> void:
SettingsManager.set_locale(Locale.cycle_locale()) # 切换并持久化
_refresh_shop_ui() # 立即刷新商店文本(HUD 由 _process 持续刷新)
# ── 暂停菜单 ────────────────────────────────────────────────
func _setup_pause_menu() -> void:
_pause_layer = CanvasLayer.new()
_pause_layer.name = "PauseLayer"
_pause_layer.layer = 20
_pause_layer.visible = false
add_child(_pause_layer)
# 半透明遮罩
var dim := ColorRect.new()
dim.color = Color(0.0, 0.0, 0.0, 0.65)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
_pause_layer.add_child(dim)
# 面板
var panel := ColorRect.new()
panel.color = Color(0.06, 0.06, 0.14, 0.98)
panel.size = Vector2(380, 340)
panel.position = Vector2(386, 154)
_pause_layer.add_child(panel)
# 标题
var title := Label.new()
title.text = tr("PAUSE_TITLE")
title.position = Vector2(480, 174)
title.add_theme_font_size_override("font_size", 30)
title.modulate = Color.GOLD
_pause_layer.add_child(title)
# 按钮
var btns = [
[tr("PAUSE_RESUME"), func(): _toggle_pause()],
[tr("PAUSE_SETTINGS"), func(): _toggle_pause(); _open_settings()],
[tr("PAUSE_QUIT_MENU"), func(): get_tree().paused = false; SceneManager.goto_main_menu()],
[tr("PAUSE_QUIT_GAME"), func(): get_tree().quit()],
]
var y: float = 240.0
for bd in btns:
var b := Button.new()
b.text = bd[0]
b.position = Vector2(436, y)
b.size = Vector2(280, 46)
b.add_theme_font_size_override("font_size", 18)
b.connect("pressed", bd[1])
_pause_layer.add_child(b)
y += 58.0
func _toggle_pause() -> void:
if _settle_layer and _settle_layer.visible:
return
_is_paused = not _is_paused
_pause_layer.visible = _is_paused
get_tree().paused = _is_paused
# ── 设置面板(难度 / 语言 / 音量 / 清除数据 ST-03)──────────────
func _setup_settings_ui() -> void:
_settings_layer = CanvasLayer.new()
_settings_layer.name = "SettingsLayer"
_settings_layer.visible = false
add_child(_settings_layer)
var bg := ColorRect.new()
bg.color = Color(0.06, 0.06, 0.12, 0.97)
bg.size = Vector2(560, 440)
bg.position = Vector2(300, 110)
_settings_layer.add_child(bg)
_settings_root = Control.new()
_settings_layer.add_child(_settings_root)
func _open_settings() -> void:
_clear_confirm = false
_settings_layer.visible = true
_rebuild_settings()
func _close_settings() -> void:
_settings_layer.visible = false
func _rebuild_settings() -> void:
for c in _settings_root.get_children():
c.queue_free()
var ox: float = 300.0
var oy: float = 110.0
var title := Label.new()
title.text = tr("SETTINGS_TITLE")
title.position = Vector2(ox + 24, oy + 18)
title.add_theme_font_size_override("font_size", 26)
title.modulate = Color.GOLD
_settings_root.add_child(title)
# 难度循环
var diff_btn := Button.new()
diff_btn.text = tr("SETTINGS_DIFFICULTY") % tr(SettingsManager.difficulty_name())
diff_btn.position = Vector2(ox + 24, oy + 70)
diff_btn.size = Vector2(360, 40)
diff_btn.connect("pressed", _on_diff_cycle)
_settings_root.add_child(diff_btn)
# 语言循环
var lang_btn := Button.new()
lang_btn.text = tr("SETTINGS_LANGUAGE") % Locale.get_locale()
lang_btn.position = Vector2(ox + 24, oy + 120)
lang_btn.size = Vector2(360, 40)
lang_btn.connect("pressed", _on_settings_lang)
_settings_root.add_child(lang_btn)
# 音量
var vol_lbl := Label.new()
vol_lbl.text = tr("SETTINGS_VOLUME") % int(SettingsManager.master_volume * 100.0)
vol_lbl.position = Vector2(ox + 24, oy + 175)
vol_lbl.add_theme_font_size_override("font_size", 16)
_settings_root.add_child(vol_lbl)
var slider := HSlider.new()
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.05
slider.value = SettingsManager.master_volume
slider.position = Vector2(ox + 24, oy + 200)
slider.size = Vector2(360, 24)
slider.connect("value_changed", _on_volume_changed)
_settings_root.add_child(slider)
# 清除所有本地数据(ST-03,二次确认)
var clear_btn := Button.new()
clear_btn.text = (tr("SETTINGS_CLEAR_CONFIRM") if _clear_confirm else tr("SETTINGS_CLEAR_DATA"))
clear_btn.position = Vector2(ox + 24, oy + 250)
clear_btn.size = Vector2(360, 40)
clear_btn.modulate = (Color(1.0, 0.4, 0.4) if _clear_confirm else Color.WHITE)
clear_btn.connect("pressed", _on_clear_data)
_settings_root.add_child(clear_btn)
var close := Button.new()
close.text = tr("SETTINGS_CLOSE")
close.position = Vector2(ox + 410, oy + 360)
close.size = Vector2(120, 40)
close.connect("pressed", _close_settings)
_settings_root.add_child(close)
func _on_diff_cycle() -> void:
SettingsManager.cycle_difficulty()
_rebuild_settings()
func _on_settings_lang() -> void:
SettingsManager.set_locale(Locale.cycle_locale())
_rebuild_settings()
_refresh_shop_ui()
func _on_volume_changed(v: float) -> void:
SettingsManager.set_master_volume(v)
_rebuild_settings()
func _on_clear_data() -> void:
if not _clear_confirm:
_clear_confirm = true # 首次点击进入确认态
_rebuild_settings()
return
SettingsManager.clear_all_local_data()
_clear_confirm = false
_close_settings()
_lbl_status.text = tr("SETTINGS_CLEARED")
_refresh_shop_ui()
# ── 背包 / 插槽编辑 UI ────────────────────────────────────────
func _setup_inventory_ui() -> void:
_inv_layer = CanvasLayer.new()
_inv_layer.name = "InventoryLayer"
_inv_layer.visible = false
add_child(_inv_layer)
var bg := ColorRect.new()
bg.color = Color(0.05, 0.06, 0.12, 0.96)
bg.size = Vector2(720, 520)
bg.position = Vector2(150, 90)
_inv_layer.add_child(bg)
_inv_root = Control.new() # 动态内容容器(每次刷新清空重建)
_inv_layer.add_child(_inv_root)
func _open_inventory() -> void:
_inv_sel_kind = ""
_inv_sel_idx = -1
_inv_layer.visible = true
_rebuild_inventory()
func _close_inventory() -> void:
_inv_layer.visible = false
func _spell_short_name(sid: String) -> String:
if sid == "":
return tr("INV_EMPTY")
var sp: SpellNode = SpellRegistry.get_spell(sid)
return sp.display_name if sp else sid
func _rebuild_inventory() -> void:
for c in _inv_root.get_children():
c.queue_free()
var ox: float = 150.0
var oy: float = 90.0
# 标题
var title := Label.new()
title.text = tr("INV_TITLE") % [
_cm.get_core_display(), _cm.get_slot_count(), _topology_name()]
title.position = Vector2(ox + 20, oy + 12)
title.add_theme_font_size_override("font_size", 19)
title.modulate = Color.GOLD
_inv_root.add_child(title)
var tip := Label.new()
tip.text = tr("INV_TIP")
tip.position = Vector2(ox + 20, oy + 42)
tip.add_theme_font_size_override("font_size", 12)
tip.modulate = Color(0.8, 0.85, 1.0)
_inv_root.add_child(tip)
# 插槽网格(拓扑布局)
var deck: Array = _cm.get_padded_deck()
var rows: int = 1
var cols: int = deck.size()
if _cm._equipped_core and _cm._equipped_core.topology == CoreDefinition.Topology.MATRIX:
rows = max(1, _cm._equipped_core.grid_rows)
cols = max(1, _cm._equipped_core.grid_cols)
var bw: float = 150.0
var bh: float = 56.0
var gx: float = ox + 24
var gy: float = oy + 80
for idx in deck.size():
var r: int = idx / cols
var col: int = idx % cols
var btn := Button.new()
btn.position = Vector2(gx + col * (bw + 8), gy + r * (bh + 8))
btn.size = Vector2(bw, bh)
var sid: String = String(deck[idx])
var rowtag: String = ""
if rows > 1:
rowtag = (tr("INV_ROW_A") if r == 0 else tr("INV_ROW_B"))
btn.text = "[%d] %s%s" % [idx, rowtag, _spell_short_name(sid)]
if sid == "":
btn.modulate = Color(0.6, 0.6, 0.6)
if _inv_sel_kind == "slot" and _inv_sel_idx == idx:
btn.modulate = Color(0.4, 1.0, 0.5) # 选中高亮
btn.connect("pressed", _on_inv_slot_pressed.bind(idx))
_inv_root.add_child(btn)
# 备牌区
var bench: Array = _cm.get_bench()
var bench_lbl := Label.new()
bench_lbl.text = tr("INV_BENCH") % bench.size()
bench_lbl.position = Vector2(gx, gy + rows * (bh + 8) + 16)
bench_lbl.add_theme_font_size_override("font_size", 14)
_inv_root.add_child(bench_lbl)
for bi in bench.size():
var bbtn := Button.new()
bbtn.position = Vector2(gx + bi * (bw + 8), gy + rows * (bh + 8) + 42)
bbtn.size = Vector2(bw, bh)
bbtn.text = _spell_short_name(String(bench[bi]))
bbtn.modulate = Color(0.85, 0.8, 1.0)
if _inv_sel_kind == "bench" and _inv_sel_idx == bi:
bbtn.modulate = Color(0.4, 1.0, 0.5)
bbtn.connect("pressed", _on_inv_bench_pressed.bind(bi))
_inv_root.add_child(bbtn)
# 关闭按钮
var close := Button.new()
close.text = tr("INV_CLOSE")
close.position = Vector2(ox + 560, oy + 470)
close.size = Vector2(130, 36)
close.connect("pressed", _close_inventory)
_inv_root.add_child(close)
func _topology_name() -> String:
if _cm._equipped_core == null:
return "LINEAR"
match _cm._equipped_core.topology:
CoreDefinition.Topology.MATRIX: return "MATRIX"
CoreDefinition.Topology.CIRCUIT: return "CIRCUIT"
_: return "LINEAR"
func _on_inv_slot_pressed(idx: int) -> void:
if _inv_sel_kind == "":
_inv_sel_kind = "slot"; _inv_sel_idx = idx
elif _inv_sel_kind == "slot":
if _inv_sel_idx == idx:
_cm.slot_to_bench(idx) # 点自身 → 移入备牌
else:
_cm.swap_slots(_inv_sel_idx, idx) # 交换
_inv_sel_kind = ""; _inv_sel_idx = -1
elif _inv_sel_kind == "bench":
_cm.bench_to_slot(_inv_sel_idx, idx) # 备牌 → 插槽
_inv_sel_kind = ""; _inv_sel_idx = -1
_rebuild_inventory()
func _on_inv_bench_pressed(bi: int) -> void:
if _inv_sel_kind == "bench" and _inv_sel_idx == bi:
_inv_sel_kind = ""; _inv_sel_idx = -1 # 取消选择
else:
_inv_sel_kind = "bench"; _inv_sel_idx = bi
_rebuild_inventory()
func _on_close_shop_pressed() -> void:
# 防呆:Deck 无 ACTION 法术则法术塔无法开火,阻止进入战斗并提示
if not _cm.has_castable_action():
_shop_warn.text = tr("SHOP_NEED_ACTION")
_shop_warn.visible = true
return
_shop_layer.visible = false
_cm.close_shop_and_next_wave()
func _on_charge_state_changed(payload: Dictionary) -> void:
var prog: float = float(payload.get("progress", 0.0))
if _lbl_charge:
_lbl_charge.text = tr("UI_CHARGE") % int(prog * 100.0)
func _on_charge_fired(_payload: Dictionary) -> void:
if _lbl_charge:
_lbl_charge.text = tr("UI_CHARGE_DONE")
_lbl_status.text = tr("STATUS_CHARGE_FIRED")
func _on_boss_phase_changed(payload: Dictionary) -> void:
if _lbl_status:
_lbl_status.text = tr("STATUS_BOSS_PHASE") % int(payload.get("phase", 0))
func _on_spell_depth_exceeded(_payload: Dictionary) -> void:
_lbl_status.text = tr("STATUS_DEPTH") % SpellEvaluator.MAX_TRIGGER_DEPTH
_lbl_status.modulate = Color.YELLOW
# 3 秒后自动清除
var t := Timer.new()
add_child(t)
t.wait_time = 3.0
t.one_shot = true
t.timeout.connect(func(): _lbl_status.text = ""; _lbl_status.modulate = Color.YELLOW)
t.start()
func _on_game_over(payload: Dictionary) -> void:
_shop_layer.visible = false
_inv_layer.visible = false
if _pause_layer:
_pause_layer.visible = false
_is_paused = false
get_tree().paused = false
_lbl_status.text = ""
_rebuild_settlement(payload)
_settle_layer.visible = true
# P-S6-04:保存本局结算截图到 user://runs/(死亡屏,非黑屏)
call_deferred("_save_run_screenshot")
func _restart_game() -> void:
_settle_layer.visible = false
_lbl_status.text = ""
PlayerManager.kill_count = 0
_cm.start_game()
func _return_to_menu() -> void:
SceneManager.goto_main_menu()
# ── 结算 / 死亡屏 + 本地排行榜 ────────────────────────────────
func _setup_settlement_ui() -> void:
_settle_layer = CanvasLayer.new()
_settle_layer.name = "SettlementLayer"
_settle_layer.visible = false
add_child(_settle_layer)
var bg := ColorRect.new()
bg.color = Color(0.04, 0.04, 0.09, 0.97)
bg.size = Vector2(1152, 648)
_settle_layer.add_child(bg)
_settle_root = Control.new()
_settle_layer.add_child(_settle_root)
func _fmt_time(sec: int) -> String:
return "%d:%02d" % [sec / 60, sec % 60]
func _rebuild_settlement(payload: Dictionary) -> void:
for c in _settle_root.get_children():
c.queue_free()
var wave: int = int(payload.get("wave", 1))
var kills: int = int(payload.get("kills", 0))
var elapsed: int = int(payload.get("elapsed_sec", 0))
var score: int = int(payload.get("score", 0))
var title := Label.new()
title.text = tr("SETTLE_TITLE")
title.position = Vector2(440, 60)
title.add_theme_font_size_override("font_size", 40)
title.modulate = Color(1.0, 0.35, 0.35)
_settle_root.add_child(title)
var stats := Label.new()
stats.text = tr("SETTLE_STATS") % [wave, kills, _fmt_time(elapsed), score]
stats.position = Vector2(400, 140)
stats.add_theme_font_size_override("font_size", 22)
_settle_root.add_child(stats)
# 本地排行榜(前 10
var board_title := Label.new()
board_title.text = tr("SETTLE_BOARD")
board_title.position = Vector2(400, 230)
board_title.add_theme_font_size_override("font_size", 18)
board_title.modulate = Color.GOLD
_settle_root.add_child(board_title)
var top: Array = EndlessRecords.get_top(10)
var my_score: int = score
for i in top.size():
var rec: Dictionary = top[i]
var row := Label.new()
var is_me: bool = int(rec.get("score", -1)) == my_score
row.text = tr("SETTLE_BOARD_ROW") % [
i + 1, int(rec.get("wave", 0)), _fmt_time(int(rec.get("elapsed", 0))),
int(rec.get("score", 0)), (tr("SETTLE_THIS_RUN") if is_me else "")]
row.position = Vector2(420, 265 + i * 26)
row.add_theme_font_size_override("font_size", 15)
if is_me:
row.modulate = Color(0.4, 1.0, 0.5)
_settle_root.add_child(row)
var restart := Button.new()
restart.text = tr("SETTLE_RESTART")
restart.position = Vector2(380, 560)
restart.size = Vector2(180, 46)
restart.connect("pressed", _restart_game)
_settle_root.add_child(restart)
var menu_btn := Button.new()
menu_btn.text = tr("SETTLE_RETURN_MENU")
menu_btn.position = Vector2(580, 560)
menu_btn.size = Vector2(180, 46)
menu_btn.connect("pressed", _return_to_menu)
_settle_root.add_child(menu_btn)
func _save_run_screenshot() -> void:
var img := get_viewport().get_texture().get_image()
if img == null:
return
DirAccess.make_dir_recursive_absolute("user://runs")
var path: String = "user://runs/run_%d.png" % Time.get_ticks_msec()
img.save_png(path)