# 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 "" % 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")