完善客户端功能并加入40250一致性审计
This commit is contained in:
@@ -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
@@ -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,不是连击段号
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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优先于Down,Left优先于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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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).msa,COMBO_ATTACK_N -> combo_0N.msa),
|
||||
# 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 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
@@ -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 EquipmentSlot:index, 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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user