完善客户端功能并加入40250一致性审计

This commit is contained in:
shen
2026-09-21 16:38:58 -07:00
parent 3eb001a1fd
commit 1522a8a1a1
22 changed files with 876 additions and 105 deletions
+2 -2
View File
@@ -361,8 +361,8 @@ func setup(m2client: Node, assets_root: String,
quickbar.skill_cast_started.connect(_on_skill_cast_started)
if quickbar.has_signal("skill_activated"):
quickbar.skill_activated.connect(func(sid: int):
if skill_fx and player:
skill_fx.spawn_skill(sid, _skill_master(sid), player)
# 40250 的本地技能视觉来自当前等级 .msa 的 MotionEventData。
# 这里不再按技能名猜测并整套预播,否则会和动作帧特效重复且位置错误。
# Bow skills send CG_SHOOT from the .msa FLY frame, matching
# CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列,
# 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6
+37 -18
View File
@@ -319,6 +319,18 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, au
_observer_mode = bool(client.is_observer_mode())
if pc and "skip_actor_collision" in pc:
pc.skip_actor_collision = _observer_mode
# GameScene 的资源加载会让首个 entity_main_set 早于 NetPlay.setup 到达;补读当前
# 主角,和 NetWorld.catch_up 一样避免丢失进场状态(动作模式、本地主角 VID、目标)。
if client.has_method("get_main_vid"):
var current_main := int(client.get_main_vid())
if current_main > 0:
_on_main_set(current_main)
# setup 通常发生在网络已完成首轮 POINTS 同步之后。先建立经验/等级基线,
# 否则进入游戏后的第一次击杀只会把 _last_exp 从 -1 初始化,表现为没有经验反馈。
if client.has_method("get_points"):
var initial_points: Dictionary = client.get_points()
if not initial_points.is_empty():
_on_points(initial_points)
func _on_observer_mode(enabled: bool) -> void:
_observer_mode = enabled
@@ -610,11 +622,23 @@ func _combo_vec() -> PackedInt32Array:
return cls.get(key, PackedInt32Array())
func _motion_data() -> Dictionary:
var pv_anim: Object = player_view.get("anim") if player_view else null
var view := _combat_view()
var pv_anim: Object = view.get("anim") if view and "anim" in view else null
if pv_anim and pv_anim.has_method("get_motion_data"):
return pv_anim.get_motion_data()
return {}
func _combat_view() -> Node:
if is_instance_valid(player_view):
return player_view
# GameScene 会在占位角色替换成 PlayerView 后注入 player_view;替换同帧发生攻击时,
# pc.player 已经是权威本地模型,因此用它兜底,避免只发包却没有攻击动作。
if pc and "player" in pc and is_instance_valid(pc.player):
var candidate: Node = pc.player
if candidate.has_method("play_attack_motion") or candidate.has_method("set_anim_state"):
return candidate
return null
# --- helpers --------------------------------------------------------------
func _vid_of(node: Object) -> int:
@@ -908,20 +932,11 @@ func _on_points(p: Dictionary) -> void:
func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
# 1. 40250 官方经验聚能光球飞行与吸收
if is_instance_valid(net_world) and net_world.has_method("spawn_exp_fly"):
var spawn_pos := _last_dead_mob_pos
var player_node: Node3D = pc.player if (pc and is_instance_valid(pc.player)) else null
if spawn_pos == Vector3.INF and is_instance_valid(player_node):
# 若无死亡怪物坐标记录,则在玩家正前方 2.5 米处生成光球回吸
var p_pos := player_node.global_position if player_node.is_inside_tree() else player_node.position
var forward := -player_node.global_transform.basis.z if player_node.is_inside_tree() else Vector3(0, 0, -1)
spawn_pos = p_pos + forward * 2.5
if spawn_pos != Vector3.INF:
net_world.spawn_exp_fly(spawn_pos, player_node, 3)
_last_dead_mob_pos = Vector3.INF
# 40250 的经验球由服务端 `from->CreateFly(FLY_EXP, to)` 下发 GC_CREATE_FLY
# NetWorld._on_fly 负责唯一一次播放。这里不能再合成一组,否则正常收包时会出现双份。
_last_dead_mob_pos = Vector3.INF
# 2. 40250 吸收音效与任务栏反馈
# 经验数值反馈音效;飞行和吸收闪光由权威 GC_CREATE_FLY 驱动。
if audio and audio.has_method("play_ui"):
audio.play_ui("money.wav")
@@ -2004,10 +2019,14 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段
# 动作,命中窗 / 连击时间才读得到这一段的 .msa。
if player_view and player_view.has_method("play_attack_motion"):
player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor)
elif player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if is_combo else "attack")
var view := _combat_view()
var motion_played := false
if view and view.has_method("play_attack_motion"):
# 旧视图/测试桩的方法没有返回值;只有新版 PlayerView 明确返回 true 时才认为
# 已成功绑定 .msa,其余情况继续走 set_anim_state 兼容兜底。
motion_played = view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) == true
if not motion_played and view and view.has_method("set_anim_state"):
view.set_anim_state("combo" if is_combo else "attack")
_begin_hit_windows()
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
+4 -1
View File
@@ -1207,7 +1207,10 @@ func _on_dead(vid: int) -> void:
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
var a: Vector3 = _entity_pos(start_vid)
var b: Vector3 = _entity_pos(end_vid)
var target_node: Node3D = _by_vid.get(end_vid, null)
# 本地主角按 40250 的单实例规则不在 _by_vid 中;经验球的 end_vid 正是主角,
# 必须指向 _local_node 才会追踪胸口并触发 at_target/吸收效果。
var target_node: Node3D = _local_node if end_vid == _local_vid and is_instance_valid(_local_node) \
else _by_vid.get(end_vid, null)
var shooter: Node3D = _by_vid.get(start_vid, null)
if b == Vector3.INF and shooter:
var target: Dictionary = {}
+64 -22
View File
@@ -65,6 +65,13 @@ var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切
# CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞
# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。
var skip_actor_collision := false
# 40250 CPythonPlayer::NEW_SetSingleDirKeyState:方向由进入游戏后的按下/松开事件
# 维护,不能每帧查询系统全局键盘状态。否则加载场景时仍按着的键会被继承,表现为
# 刚进入游戏角色就自动移动。
var _key_up := false
var _key_down := false
var _key_left := false
var _key_right := false
var _last_wasd := Vector2.ZERO
var _mobile_axis := Vector2.ZERO
var _mobile_active := false
@@ -79,13 +86,21 @@ var _dst_rot := 0.0
var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点
var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效
var active := true # 进游戏装配/加载中置假,防止过早响应点地
var active := true: # 进游戏装配/加载中置假,防止过早响应点地
set(value):
if active == value:
return
active = value
# 切换输入阶段相当于 40250 game.Close/Open 重建按键状态。只有在游戏窗
# 激活后新收到的 KeyDown 才能驱动角色。
_clear_direction_keys()
# 40250 CInstanceBase::NEW_Stop() / CPythonPlayer::NEW_Stop():停止点地移动与方向输入,重置预约
func stop() -> void:
_is_going = false
_reserved_ground = null
_reserved_delay_time = 0.0
_last_wasd = Vector2.ZERO
_clear_direction_keys()
_mobile_active = false
_mobile_axis = Vector2.ZERO
_mobile_ui_touches.clear()
@@ -97,6 +112,39 @@ func stop() -> void:
_dst_pos = player.position
anim_state.emit("wait")
func _clear_direction_keys() -> void:
_key_up = false
_key_down = false
_key_left = false
_key_right = false
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
# KeyUp 可能在窗口失焦期间丢失;参考客户端失去游戏窗口输入后停止移动。
_clear_direction_keys()
_last_wasd = Vector2.ZERO
func _input(e: InputEvent) -> void:
if not (e is InputEventKey) or e.echo:
return
var key := e as InputEventKey
# 松开事件必须始终接收,即使此时 UI 正在拦截游戏输入,否则会留下粘键。
# KeyDown 则在 _unhandled_input 中接收,让聊天输入框有机会先消费 WASD。
if not key.pressed:
_set_direction_key_state(key, false)
func _set_direction_key_state(e: InputEventKey, pressed: bool) -> void:
var logical := e.keycode
var physical := e.physical_keycode
if logical in [KEY_W, KEY_UP] or physical in [KEY_W, KEY_UP]:
_key_up = pressed
elif logical in [KEY_S, KEY_DOWN] or physical in [KEY_S, KEY_DOWN]:
_key_down = pressed
elif logical in [KEY_A, KEY_LEFT] or physical in [KEY_A, KEY_LEFT]:
_key_left = pressed
elif logical in [KEY_D, KEY_RIGHT] or physical in [KEY_D, KEY_RIGHT]:
_key_right = pressed
# CInstanceBase::__IsSyncing()Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。
func is_going() -> bool:
return _is_going
@@ -184,7 +232,11 @@ func _goto(dst_flat: Vector3) -> bool:
func _unhandled_input(e: InputEvent) -> void:
if not active:
return
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
if e is InputEventKey:
var key := e as InputEventKey
if key.pressed and not key.echo:
_set_direction_key_state(key, true)
elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_click(e.position)
elif e is InputEventScreenTouch:
_on_touch(e)
@@ -326,26 +378,17 @@ func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
return null
func _wasd() -> Vector2:
if ui_manager and ui_manager.has_method("blocks_game_input"):
var dummy := InputEventKey.new()
dummy.keycode = KEY_W
if ui_manager.blocks_game_input(dummy):
return Vector2.ZERO
var is_up := Input.is_key_pressed(KEY_W) or Input.is_physical_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP)
var is_down := Input.is_key_pressed(KEY_S) or Input.is_physical_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN)
var is_left := Input.is_key_pressed(KEY_A) or Input.is_physical_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT)
var is_right := Input.is_key_pressed(KEY_D) or Input.is_physical_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT)
if not (is_up or is_down or is_left or is_right):
if not (_key_up or _key_down or _key_left or _key_right):
return Vector2.ZERO
var d := Vector2.ZERO
# 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于DownLeft优先于Right
if is_up:
if _key_up:
d.y = -1.0
elif is_down:
elif _key_down:
d.y = 1.0
if is_left:
if _key_left:
d.x = -1.0
elif is_right:
elif _key_right:
d.x = 1.0
return d.normalized()
@@ -396,18 +439,17 @@ func _actor_blocked(next_pos: Vector3) -> bool:
func _turn_toward(target_yaw: float, dt: float) -> void:
if player == null:
return
player.rotation.x = 0.0
player.rotation.z = 0.0
var cur_yaw: float = wrapf(player.rotation.y, -PI, PI)
var diff: float = wrapf(target_yaw - cur_yaw, -PI, PI)
var spd := rotation_speed_deg
if absf(diff) <= deg_to_rad(45.0):
spd *= 5.0 / 12.0
var max_step := deg_to_rad(spd) * dt
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
player.rotation.y = wrapf(cur_yaw + signf(diff) * max_step, -PI, PI)
var next_yaw := target_yaw if max_step <= 0.0 or absf(diff) <= max_step \
else wrapf(cur_yaw + signf(diff) * max_step, -PI, PI)
# 只允许直立偏航。逐项修改 Euler 分量在 W/S 180° 快切和 ±PI 边界附近可能
# 被引擎重新分解为 X/Z = PI,看起来就是角色翻跟头。
player.rotation = Vector3(0.0, wrapf(next_yaw, -PI, PI), 0.0)
func _process(dt: float) -> void:
if player == null or not active:
+11 -5
View File
@@ -88,13 +88,19 @@ func _init() -> void:
var player_node := Node3D.new()
player_node.position = Vector3(4, 0, 0)
mount.add_child(player_node)
nw._by_vid[1] = player_node
nw.set_local_node(player_node)
nw.set_local_vid(1)
# 触发 40250 官方 _on_fly(0, 100, 1)
nw._on_fly(FlyObject.FLY_EXP, 100, 1)
if mgr.active_count() != 3:
printerr("FAIL: _on_fly(FLY_EXP) 应发射 3 颗经验聚能光球,实际: ", mgr.active_count())
fails += 1
for orb in mgr._instances:
if not orb._is_object or orb._target_obj != player_node:
printerr("FAIL: 经验球没有锁定本地主角节点,无法产生吸收效果")
fails += 1
break
# 清空飞行物
for i in range(50):
@@ -113,7 +119,7 @@ func _init() -> void:
if mgr.active_count() == 0:
break
# 5. 验证 net_play 经验变动时触发经验获取流程
# 5. 验证 net_play 经验变动不重复合成服务端已经下发的经验球
var np := NetPlay.new()
mount.add_child(np)
var pc := PlayerCtl.new()
@@ -126,11 +132,11 @@ func _init() -> void:
printerr("FAIL: 初始点数同步不应触发获取经验!")
fails += 1
# 杀死怪物获得 500 经验
# 杀死怪物获得 500 经验:视觉只由上面的 GC_CREATE_FLY 路径生成。
np._last_dead_mob_pos = Vector3(3, 0, 0)
np._on_points({"exp": 1500, "next_exp": 5000, "level": 10})
if mgr.active_count() != 3:
printerr("FAIL: 经验增加时未能通过 net_world 生成经验光球实际数量: ", mgr.active_count())
if mgr.active_count() != 0:
printerr("FAIL: POINT_EXP 不应重复生成经验光球实际数量: ", mgr.active_count())
fails += 1
mount.queue_free()
+28
View File
@@ -34,6 +34,34 @@ func _run() -> void:
pc.player = player
pc.rotation_speed_deg = 1200.0
# -------------------------------------------------------------------------
# Test 0: 40250 按键事件状态,不继承进入游戏前的系统按键
# -------------------------------------------------------------------------
print("\n--- Test 0: 进入游戏按键状态与 W/S 优先级 ---")
pc.stop()
_ck(pc._wasd() == Vector2.ZERO, "fresh/stop state cannot auto-move on game entry")
var w_down := InputEventKey.new()
w_down.keycode = KEY_W
w_down.pressed = true
pc.active = false
pc._unhandled_input(w_down)
pc.active = true
_ck(pc._wasd() == Vector2.ZERO, "W pressed during loading is not inherited by the game phase")
pc._unhandled_input(w_down)
_ck(pc._wasd() == Vector2(0, -1), "W KeyDown starts forward movement")
var s_down := InputEventKey.new()
s_down.keycode = KEY_S
s_down.pressed = true
pc._unhandled_input(s_down)
_ck(pc._wasd() == Vector2(0, -1), "W keeps priority while W and S overlap")
var w_up := InputEventKey.new()
w_up.keycode = KEY_W
w_up.pressed = false
pc._input(w_up)
_ck(pc._wasd() == Vector2(0, 1), "releasing W switches cleanly to held S")
pc.stop()
_ck(pc._wasd() == Vector2.ZERO, "stop clears all latched direction keys")
# -------------------------------------------------------------------------
# Test 1: 边界跨越平滑度与无翻跟头(X/Z 严格为 0)
# -------------------------------------------------------------------------
+4 -3
View File
@@ -259,9 +259,9 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio })
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msaCOMBO_ATTACK_N -> combo_0N.msa),
# 模式目录缺该段时退回 general 的 attack(_1).msaGENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool:
if anim == null or motion_dir == "":
return
return false
var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
var names: Array = ["attack", "attack_1"]
if index > MOTION_NORMAL_ATTACK:
@@ -270,8 +270,9 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
if msa == "":
msa = _pick_msa(motion_dir, ["attack", "attack_1"])
if msa == "":
return
return false
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
return true
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
+102 -54
View File
@@ -1,24 +1,35 @@
# ViewEquipmentUI — GC_VIEW_EQUIP 的装备查看窗
# 服务器推送某个角色的 11 个传统可见装备位后自动刷新并置顶。
# ViewEquipmentUI — GC_VIEW_EQUIP 的 40250 EquipmentDialog 复刻
#
# 参考:40250/root/uiequipmentdialog.py + uiscript/equipmentdialog.py。
# 原版不是文字列表:固定 180x230 的装备槽底图承载 11 个装备位;鼠标进入
# 一个非空槽位时,由同一 ItemToolTip.AddItemData 显示属性面板,离开立即隐藏。
extends Node
const WEAR_NAMES := ["身体", "头部", "鞋子", "护腕", "武器", "项链", "耳环", "盾牌", "饰品 1", "饰品 2", "箭矢"]
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# 40250 EquipmentSlotindex, x, y, width, height(相对 equipment_bg)。
const SLOT_RECTS := {
0: Rect2(39, 37, 32, 64), 1: Rect2(39, 2, 32, 32),
2: Rect2(39, 145, 32, 32), 3: Rect2(75, 67, 32, 32),
4: Rect2(3, 3, 32, 96), 5: Rect2(114, 67, 32, 32),
6: Rect2(114, 35, 32, 32), 7: Rect2(2, 145, 32, 32),
8: Rect2(75, 145, 32, 32), 9: Rect2(114, 2, 32, 32),
10: Rect2(75, 35, 32, 32),
}
const EQUIPMENT_BG := "d:/ymir work/ui/equipment_bg_without_ring.tga"
var client: Node
var ui: CanvasLayer
var proto: Node
var item_list: RefCounted
var _tooltip_builder: RefCounted
var _window: Control
var _slots: Dictionary = {} # wear -> Panel
func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void:
client = m2client
ui = manager
proto = proto_node
item_list = il
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path() if ClassDB.class_exists("AssetRoot") else "", "en", proto)
if client.has_signal("view_equipment"):
client.view_equipment.connect(show_for)
@@ -29,6 +40,7 @@ func show_for(vid: int) -> void:
if equipment.is_empty():
return
if is_instance_valid(_window):
_hide_item_tooltip()
ui.close(_window)
_window = _build_window(vid, equipment)
ui.open(_window)
@@ -36,75 +48,111 @@ func show_for(vid: int) -> void:
func _build_window(vid: int, equipment: Array) -> Control:
var win := Panel.new()
win.name = "ViewEquipment"
win.position = Vector2(1280, 180)
win.size = Vector2(280, 390)
win.position = Vector2(870, 300)
win.size = Vector2(180, 230)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.05, 0.06, 0.09, 0.94)
bg.border_color = Color(0.65, 0.5, 0.2, 0.9)
bg.set_border_width_all(1)
bg.set_corner_radius_all(5)
win.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.position = Vector2(12, 8)
title.size = Vector2(220, 26)
title.position = Vector2(8, 4)
title.size = Vector2(142, 26)
title.text = "%s 的装备" % _character_name(vid)
title.add_theme_font_size_override("font_size", 16)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 13)
win.add_child(title)
var close := Button.new()
close.text = "×"
close.position = Vector2(238, 4)
close.size = Vector2(34, 30)
close.position = Vector2(150, 3)
close.size = Vector2(25, 25)
close.pressed.connect(func():
if is_instance_valid(_window):
_hide_item_tooltip()
ui.close(_window)
_window = null)
win.add_child(close)
var list := VBoxContainer.new()
list.position = Vector2(12, 42)
list.size = Vector2(256, 336)
list.add_theme_constant_override("separation", 3)
win.add_child(list)
for d in equipment:
var wear := int(d.get("wear", 0))
var line := Label.new()
line.text = "%s: %s" % [_wear_name(wear), _item_name(int(d.get("vnum", 0)), int(d.get("count", 0)))]
line.tooltip_text = _item_tooltip(d)
line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
list.add_child(line)
var equipment_bg := TextureRect.new()
equipment_bg.name = "EquipmentBaseImage"
equipment_bg.position = Vector2(15, 35)
equipment_bg.size = Vector2(150, 182)
equipment_bg.texture = UiAssets.load_tex(AssetRoot.path(), EQUIPMENT_BG)
equipment_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
equipment_bg.stretch_mode = TextureRect.STRETCH_SCALE
equipment_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
win.add_child(equipment_bg)
_slots.clear()
var by_wear := {}
for d: Dictionary in equipment:
by_wear[int(d.get("wear", -1))] = d
for wear in SLOT_RECTS:
var slot := _make_slot(int(wear), SLOT_RECTS[wear], by_wear.get(wear, {}))
equipment_bg.add_child(slot)
_slots[int(wear)] = slot
return win
func _make_slot(wear: int, rect: Rect2, data: Dictionary) -> Panel:
var slot := Panel.new()
slot.name = "slot_%d" % wear
slot.position = rect.position + Vector2(3, 3) # 40250 EquipmentSlot 的偏移。
slot.size = rect.size
slot.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
slot.set_meta("item", data.duplicate(true))
var style := StyleBoxFlat.new()
style.bg_color = Color(0.03, 0.04, 0.06, 0.35)
style.border_color = Color(0.52, 0.44, 0.25, 0.70)
style.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", style)
var vnum := int(data.get("vnum", 0))
if vnum > 0:
var icon := _item_icon(vnum)
if icon:
var image := TextureRect.new()
image.texture = icon
image.set_anchors_preset(Control.PRESET_FULL_RECT)
image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(image)
var count := int(data.get("count", 1))
if count > 1:
var count_label := Label.new()
count_label.text = str(count)
count_label.position = Vector2(1, rect.size.y - 15)
count_label.add_theme_font_size_override("font_size", 10)
count_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(count_label)
slot.mouse_entered.connect(func(): _on_slot_over_in(wear))
slot.mouse_exited.connect(_hide_item_tooltip)
return slot
func _on_slot_over_in(wear: int) -> void:
var slot: Panel = _slots.get(wear, null)
if slot == null:
return
var data: Dictionary = slot.get_meta("item", {})
var vnum := int(data.get("vnum", 0))
if vnum <= 0:
return
# 对应 EquipmentDialog.OverInItem:一个共享 ItemToolTip,先清旧内容再 AddItemData。
var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
if ui and ui.has_method("show_item_tooltip"):
ui.show_item_tooltip(vnum, int(data.get("count", 1)), proto_data, data, {}, AssetRoot.path(), proto, item_list)
func _hide_item_tooltip() -> void:
if ui and ui.has_method("hide_item_tooltip"):
ui.hide_item_tooltip()
func _item_icon(vnum: int) -> Texture2D:
var path: String = item_list.icon(vnum) if item_list and item_list.has(vnum) else "icon/item/%05d.tga" % ((vnum / 10) * 10)
return UiAssets.load_tex(AssetRoot.path(), path)
func _character_name(vid: int) -> String:
if client.has_method("get_entity"):
var entity: Dictionary = client.get_entity(vid)
if not entity.is_empty() and not String(entity.get("name", "")).is_empty():
return String(entity["name"])
return "角色 #%d" % vid
func _wear_name(wear: int) -> String:
return WEAR_NAMES[wear] if wear >= 0 and wear < WEAR_NAMES.size() else "装备 %d" % wear
func _item_name(vnum: int, count: int) -> String:
if vnum == 0:
return "(空)"
var name := "物品 #%d" % vnum
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
name = String(pd.get("locale_name", pd.get("name", name)))
elif item_list and item_list.has(vnum):
name = item_list.type_of(vnum)
return "%s x%d" % [name, count] if count > 1 else name
func _item_tooltip(d: Dictionary) -> String:
var vnum := int(d.get("vnum", 0))
if _tooltip_builder and proto and proto.has_method("item"):
return _tooltip_builder.format(vnum, int(d.get("count", 1)), proto.item(vnum), d)
var attrs: Array = d.get("attrs", [])
if attrs.is_empty():
return ""
var lines := []
for attr in attrs:
lines.append("属性 %d: %+d" % [int(attr.get("type", 0)), int(attr.get("value", 0))])
return "\n".join(lines)
+65
View File
@@ -0,0 +1,65 @@
# 40250 EquipmentDialog:槽位布局与统一属性面板回归测试。
extends SceneTree
const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd")
const UiManager = preload("res://ui/ui_manager.gd")
class MockClient extends Node:
var equipment := []
func get_view_equipment(_vid: int) -> Array: return equipment
class MockProto extends Node:
func item(vnum: int) -> Dictionary:
if vnum != 19:
return {}
return {
"vnum": 19, "locale_name": "战斗剑 +9", "type": 1,
"values": [0, 0, 0, 10, 20, 0],
}
var failures := 0
func _check(ok: bool, message: String) -> void:
if ok:
print("PASS: " + message)
else:
failures += 1
printerr("FAIL: " + message)
func _init() -> void:
call_deferred("_run")
func _run() -> void:
var ui := UiManager.new()
get_root().add_child(ui)
var client := MockClient.new()
client.equipment.resize(11)
for wear in 11:
client.equipment[wear] = {"wear": wear, "vnum": 0}
client.equipment[4] = {"wear": 4, "vnum": 19, "count": 1,
"sockets": [0, 0, 0], "attrs": [{"type": 5, "value": 12}]}
var view := ViewEquipmentUI.new()
get_root().add_child(view)
view.setup(client, ui, MockProto.new())
view.show_for(1001)
await process_frame
var dialog: Control = ui.find_child("ViewEquipment", true, false)
_check(dialog != null and dialog.size == Vector2(180, 230), "uses the 40250 180x230 dialog")
_check(view._slots.size() == 11, "creates all 11 legacy EquipmentSlot positions")
var weapon: Panel = view._slots.get(4)
_check(weapon != null and weapon.position == Vector2(6, 6) and weapon.size == Vector2(32, 96),
"weapon slot matches 40250 index 4 rectangle")
_check(weapon.tooltip_text.is_empty(), "slot has no native Godot tooltip panel")
view._on_slot_over_in(4)
await process_frame
var tooltip: Control = ui.get_item_tooltip_view(AssetRoot.path(), view.proto, null)
_check(tooltip.visible, "hover opens the shared 40250 ItemTooltipView")
_check(ui.find_children("ItemTooltipView", "", true, false).size() == 1,
"only one item attribute panel exists")
view._hide_item_tooltip()
_check(not tooltip.visible, "leaving the slot hides the shared attribute panel")
if failures == 0:
print("ALL TESTS PASSED")
quit(failures)