Files
mtgodot-poc/project/char_status_ui_test.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

283 lines
12 KiB
GDScript
Raw 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.
# char_status_ui_test —— P11 角色状态窗 headless 自检(假 client + 真 uiscript)。
# godot --headless --path project --script char_status_ui_test.gd
# 校验 characterwindow.py 装载、RefreshStatus 数值绑定(逐字对照 uicharacter.py)、
# /stat 加点命令、页签切换。缺 uiscript 资产时优雅跳过。
extends SceneTree
const UiManager = preload("res://ui/ui_manager.gd")
const CharStatusUI = preload("res://ui/char_status_ui.gd")
class FakeClient extends Node:
signal points_changed(points: Dictionary)
signal entity_main_set(vid: int)
var pts: Array = []
var says: Array = []
var _main_vid := 7
func _init() -> void:
pts.resize(255)
pts.fill(0)
pts[1] = 42 # LEVEL
pts[3] = 123456 # EXP
pts[4] = 200000 # NEXT_EXP
pts[5] = 850 # HP
pts[6] = 900 # MAX_HP
pts[7] = 120 # SP
pts[8] = 150 # MAX_SP
pts[12] = 33 # ST
pts[13] = 27 # HT
pts[14] = 18 # DX
pts[15] = 12 # IQ
pts[16] = 250 # ATT_POWER
pts[17] = 145 # ATT_SPEED
pts[18] = 40 # EVADE_RATE
pts[19] = 165 # MOV_SPEED
pts[20] = 88 # DEF_GRADE
pts[21] = 100 # CASTING_SPEED
pts[22] = 55 # MAGIC_ATT_GRADE
pts[23] = 44 # MAGIC_DEF_GRADE
pts[26] = 5 # STAT (남은 포인트)
pts[29] = 210 # MIN_ATK
pts[30] = 210 # MAX_ATK
pts[95] = 15 # ATT_GRADE_BONUS
func get_main_vid() -> int: return _main_vid
func get_points() -> Dictionary:
return {
"points": pts, "hp": pts[5], "max_hp": pts[6], "sp": pts[7], "max_sp": pts[8],
"level": pts[1], "exp": pts[3], "next_exp": pts[4], "gold": 0,
}
func get_entity(vid: int) -> Dictionary:
if vid != _main_vid:
return {}
return {"name": "TestHero", "guild": 9, "race": 5} # race 5 -> job 1 (assassin)
func get_guild_name(gid: int) -> String:
return "銀河聯盟" if gid == 9 else ""
func say(type: int, text: String) -> bool:
says.append([type, text]); return true
signal skills_changed
signal skill_group_changed(group: int)
var skills_list: Array = [
{"id": 31, "level": 10, "master": 0},
{"id": 121, "level": 5, "master": 0},
]
func get_skills() -> Array: return skills_list
func get_skill_group() -> int: return 1
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: char_status_ui_test (layout + RefreshStatus + /stat + tabs)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _txt(cs: Node, nm: String) -> String:
var n: Control = cs._node(nm)
return String(n.text) if n and ("text" in n) else "<missing:%s>" % nm
func _run() -> void:
var assets := AssetRoot.path()
if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")):
print(" (skip: no characterwindow.py — assets checkout missing)")
return
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready()
var fc := FakeClient.new()
get_root().add_child(fc)
var cs: Node = CharStatusUI.new()
get_root().add_child(cs)
cs.setup(ui, fc, assets)
cs.open()
_ck(cs.is_open(), "character window opened")
if not cs.is_open():
return
# --- RefreshStatus 数值绑定 ---
_ck(_txt(cs, "Level_Value") == "42", "Level_Value = 42 (got %s)" % _txt(cs, "Level_Value"))
_ck(_txt(cs, "Exp_Value") == "123456", "Exp_Value = 123456 (got %s)" % _txt(cs, "Exp_Value"))
_ck(_txt(cs, "RestExp_Value") == "76544", "RestExp_Value = next-exp (got %s)" % _txt(cs, "RestExp_Value"))
_ck(_txt(cs, "HP_Value") == "850/900", "HP_Value = 850/900 (got %s)" % _txt(cs, "HP_Value"))
_ck(_txt(cs, "SP_Value") == "120/150", "SP_Value = 120/150 (got %s)" % _txt(cs, "SP_Value"))
_ck(_txt(cs, "STR_Value") == "33", "STR_Value = 33 (got %s)" % _txt(cs, "STR_Value"))
_ck(_txt(cs, "DEX_Value") == "18", "DEX_Value = 18 (got %s)" % _txt(cs, "DEX_Value"))
_ck(_txt(cs, "HTH_Value") == "27", "HTH_Value = 27 (got %s)" % _txt(cs, "HTH_Value"))
_ck(_txt(cs, "INT_Value") == "12", "INT_Value = 12 (got %s)" % _txt(cs, "INT_Value"))
# ATT: min==max==210, bonus = 95(15) + 91(0) -> 225
_ck(_txt(cs, "ATT_Value") == "225", "ATT_Value = 210+15 (got %s)" % _txt(cs, "ATT_Value"))
_ck(_txt(cs, "DEF_Value") == "88", "DEF_Value = 88 (got %s)" % _txt(cs, "DEF_Value"))
_ck(_txt(cs, "MATT_Value") == "55", "MATT_Value = 55 (no magic-wep) (got %s)" % _txt(cs, "MATT_Value"))
_ck(_txt(cs, "MDEF_Value") == "44", "MDEF_Value = 44 (got %s)" % _txt(cs, "MDEF_Value"))
_ck(_txt(cs, "ASPD_Value") == "145", "ASPD_Value = 145 (got %s)" % _txt(cs, "ASPD_Value"))
_ck(_txt(cs, "MSPD_Value") == "165", "MSPD_Value = 165 (got %s)" % _txt(cs, "MSPD_Value"))
_ck(_txt(cs, "CSPD_Value") == "100", "CSPD_Value = 100 (got %s)" % _txt(cs, "CSPD_Value"))
_ck(_txt(cs, "ER_Value") == "40", "ER_Value = 40 (got %s)" % _txt(cs, "ER_Value"))
_ck(_txt(cs, "Status_Plus_Value") == "5", "Status_Plus_Value = 5 (got %s)" % _txt(cs, "Status_Plus_Value"))
# --- identity ---
_ck(_txt(cs, "Character_Name") == "TestHero", "Character_Name = TestHero (got %s)" % _txt(cs, "Character_Name"))
_ck(_txt(cs, "Guild_Name") == "銀河聯盟", "Guild_Name resolved (got %s)" % _txt(cs, "Guild_Name"))
# --- +/- 按钮:STAT>0 时可见,命令与 uicharacter.statusPlusCommandDict 一致 ---
var hth_plus: Control = cs._node("HTH_Plus")
_ck(hth_plus != null and hth_plus.visible, "HTH_Plus visible while STAT>0")
if hth_plus is BaseButton:
hth_plus.pressed.emit()
_ck(fc.says.size() == 1 and fc.says[0][0] == 0 and fc.says[0][1] == "/stat ht",
"HTH_Plus -> say(0, '/stat ht') (got %s)" % str(fc.says))
var int_minus: Control = cs._node("INT_Minus")
if int_minus is BaseButton:
int_minus.pressed.emit()
_ck(fc.says.size() == 2 and fc.says[1][1] == "/stat- iq",
"INT_Minus -> say(0, '/stat- iq') (got %s)" % str(fc.says))
# --- STAT == 0 -> 加点按钮隐藏 + 刷新走 points_changed ---
fc.pts[26] = 0
fc.points_changed.emit(fc.get_points())
_ck(_txt(cs, "Status_Plus_Value") == "0", "Status_Plus_Value = 0 after change")
var hth_plus2: Control = cs._node("HTH_Plus")
_ck(hth_plus2 != null and not hth_plus2.visible, "HTH_Plus hidden while STAT==0")
# --- 页签切换(uicharacter.SetState ---
var char_page: Control = cs._node("Character_Page")
var skill_page: Control = cs._node("Skill_Page")
_ck(char_page != null and char_page.visible, "STATUS page visible by default")
cs._set_state("SKILL")
_ck(char_page != null and not char_page.visible, "Character_Page hidden after SetState(SKILL)")
_ck(skill_page != null and skill_page.visible, "Skill_Page visible after SetState(SKILL)")
cs._set_state("STATUS")
_ck(char_page.visible, "Character_Page visible again after SetState(STATUS)")
# --- Skill & Emoticon 拖拽到快捷栏 (40250 机制自检) ---
var mouse_ctl := preload("res://ui/mouse_controller.gd").new()
get_root().add_child(mouse_ctl)
mouse_ctl.setup(ui)
cs.item_mouse = mouse_ctl
var qb := preload("res://ui/quickbar.gd").new()
get_root().add_child(qb)
qb.item_mouse = mouse_ctl
qb.setup(fc, cs.skill_table, ui, func(): return null, assets)
cs.open("SKILL")
_ck(cs.is_open(), "SKILL tab open")
var active_slot: Control = cs._node("Skill_Active_Slot")
var slot1: Control = active_slot.get_node_or_null("slot_1") if active_slot else null
var slot1_btn: TextureButton = slot1.get_node_or_null("Btn") if slot1 else null
_ck(slot1_btn != null, "Skill slot_1 has Btn")
# 1. 模拟左键点击主动技能 (id 31) -> 成功 attach_skill 到鼠标
if slot1_btn:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = true
slot1_btn.gui_input.emit(ev)
_ck(mouse_ctl.is_attached(), "mouse attached skill on left click")
var att := mouse_ctl.attached()
_ck(att.get("source") == "skill" and att.get("skill_id") == 31, "attached payload is skill 31")
# 2. 模拟拖拽与容差判定:鼠标在快捷栏槽位 0 边缘(例如向外偏移 6 像素)依然能准确吸附落入槽位 0
var slot0_ctrl: Control = qb._slots[0]["btn"]
var drop_pos: Vector2 = slot0_ctrl.get_global_rect().position + Vector2(-6, -6)
_ck(mouse_ctl._drop_at(drop_pos), "generous hitbox allows drop within tolerance")
var st0 := qb.mobile_slot_state(0)
_ck(st0.get("kind") == "skill" and int(st0.get("id")) == 31, "quickbar slot 0 has skill 31 (got %s)" % str(st0))
# 2b. 40250 点击拾取模式:点击技能后松开(未发生拖动),技能仍然附着在鼠标上
var ev_down := InputEventMouseButton.new()
ev_down.button_index = MOUSE_BUTTON_LEFT
ev_down.pressed = true
slot1_btn.gui_input.emit(ev_down)
var ev_up := InputEventMouseButton.new()
ev_up.button_index = MOUSE_BUTTON_LEFT
ev_up.pressed = false
mouse_ctl._input(ev_up)
_ck(mouse_ctl.is_attached(), "mouse still attached after stationary click release (40250 pickup mode)")
# 移到槽位 3 点击放置
var slot3_ctrl: Control = qb._slots[3]["btn"]
var ev_click_slot := InputEventMouseButton.new()
ev_click_slot.button_index = MOUSE_BUTTON_LEFT
ev_click_slot.pressed = true
ev_click_slot.position = slot3_ctrl.get_global_rect().get_center()
mouse_ctl._input(ev_click_slot)
_ck(not mouse_ctl.is_attached(), "clicking quickslot 3 drops attached skill")
var st3 := qb.mobile_slot_state(3)
_ck(st3.get("kind") == "skill" and int(st3.get("id")) == 31, "quickbar slot 3 has skill 31 (got %s)" % str(st3))
mouse_ctl.cancel()
# 3. 被动技能(如 Leadership 121slot 103)不可拖拽 / 附着
var etc_slot: Control = cs._node("Skill_ETC_Slot")
var slot103: Control = etc_slot.get_node_or_null("slot_103") if etc_slot else null
var slot103_btn: TextureButton = slot103.get_node_or_null("Btn") if slot103 else null
if slot103_btn:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = true
slot103_btn.gui_input.emit(ev)
_ck(not mouse_ctl.is_attached(), "passive skill 121 CANNOT be attached to mouse (40250 CanUseSkill == false)")
# 4. Ctrl + 左键主动技能 -> 自动填入首个空快捷栏槽位 (40250 RequestAddToEmptyLocalQuickSlot)
if slot1_btn:
var ev_ctrl := InputEventMouseButton.new()
ev_ctrl.button_index = MOUSE_BUTTON_LEFT
ev_ctrl.pressed = true
ev_ctrl.ctrl_pressed = true
slot1_btn.gui_input.emit(ev_ctrl)
var st1 := qb.mobile_slot_state(1)
_ck(st1.get("kind") == "skill" and int(st1.get("id")) == 31, "Ctrl+Click auto assigns skill to quickslot 1 (got %s)" % str(st1))
# 5. 表情拖拽:切换到 EMOTICON,点击动作 1 附着到鼠标,放入快捷栏槽位 2
cs.open("EMOTICON")
var solo_slot: Control = cs._node("SoloEmotionSlot")
var emote1: Control = solo_slot.get_node_or_null("slot_1") if solo_slot else null
var emote1_btn: TextureButton = emote1.get_node_or_null("Btn") if emote1 else null
_ck(emote1_btn != null, "Emote slot_1 has Btn")
if emote1_btn:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = true
emote1_btn.gui_input.emit(ev)
_ck(mouse_ctl.is_attached(), "mouse attached emote on left click")
var att_e := mouse_ctl.attached()
_ck(att_e.get("source") == "emotion" and att_e.get("emote_id") == 1, "attached payload is emote 1")
_ck(qb._drop_mouse_payload(att_e, 2), "drop emote onto quickbar slot 2 succeeds")
var st2 := qb.mobile_slot_state(2)
_ck(st2.get("kind") == "emote" and int(st2.get("id")) == 1, "quickbar slot 2 has emote 1 (got %s)" % str(st2))
mouse_ctl.cancel()
# --- TitleBar close button ---
var tb: Control = cs._node("Character_TitleBar")
var close_btn: Button = tb.find_child("CloseButton", true, false) if tb else null
_ck(close_btn != null, "Character_TitleBar has CloseButton")
if close_btn:
close_btn.pressed.emit()
_ck(not cs.is_open(), "CloseButton on TitleBar closes window")
cs.open()
cs.close()
_ck(not cs.is_open(), "character window closed")
# 移动端使用独立横屏视图,不把 PC 固定坐标页面缩小后直接复用。
ui.set_mobile_mode(true)
cs.set_mobile_mode(true)
cs.open("STATUS")
_ck(cs.is_open(), "mobile character window opened")
_ck(cs._mobile_root.size == Vector2(760, 340), "mobile character base size is landscape")
_ck(cs._mobile_status_page.visible and not cs._mobile_emote_page.visible,
"mobile character opens on status/equipment page")
_ck(cs._mobile_stats.has("STR") and cs._mobile_stats["STR"]["value"].text == "33",
"mobile character binds STR")
cs.open("EMOTICON")
_ck(not cs._mobile_status_page.visible and cs._mobile_emote_page.visible,
"mobile character switches to emote page")
cs.close()
_ck(not cs.is_open(), "mobile character window closed")