- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
283 lines
12 KiB
GDScript
283 lines
12 KiB
GDScript
# 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 121,slot 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")
|