Files
mtgodot-poc/docs/CLIENT-GAP-FIX.md
T

10148 lines
789 KiB
Markdown
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.
# CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22
> 本文是 [`CLIENT-GAP.md`](CLIENT-GAP.md) §「ClientVS22 实现细节审计(2026-09-02)」的**逐条修改说明**。
> 编号与该节 110 完全对应。
>
> **原则**:每条只写 `40250/ClientVS22` 里实际存在的行为、常量和结构,附源码 `文件:行`;
> 不设计新机制、不做“更好”的替代方案。`ClientVS22/source/` 本身没有完整的 Python UI
> 脚本;凡引用 `POC/assets/root/*.py` 的地方,表示迁移资产 / 历史脚本依据,不冒充
> `ClientVS22/source/` 证据。凡本文没有从参考源码读出的细节,标注为
> **[待读参考]** 并给出应读的参考文件,而不是臆造。
>
> 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`
> `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。
>
> 创建 / 修订日期:2026-09-04
---
## 实施进度(2026-09-04
当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理
第一批;并行波 A 已有多项实现合入。当前仍有真实链路、平台和 1:1 表现验收项,不能把“代码回归
通过”误记为全部完成。
已完成并已编译验证:
- [x] **增量 128 —— W3→M2 §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义修正**
- `net_world.gd` 消费 `guild_marks_ready` / `guild_mark_updated`,为已经生成的角色动态创建、
更新和移除 `GuildMark` `Sprite3D`,并按 `mark_x_offset` / `guild_name_x_offset` 与公会名排布;
会徽会跟随聊天气泡造成的名字尾标上移。`guild_mark_tail_test.gd` 覆盖延迟下载、纹理替换、
水平排布和公会移除。
- `quest_dialog.gd` 保持 `[DONE]` 解析无副作用,`done_event` 改为只在用户点击关闭按钮时触发;
`quest_test.gd` 覆盖解析阶段不触发、点击后触发和 `script_answer(255)`
- CTest 22/22、Godot 74/74 离线回归通过;macOS headless 证书读取噪声不计为代码失败,真实
服务器、真机和正式资源包仍需外部验收。
- [x] **增量 127 —— W3→M2 §8.8 / §5.4 / §2.1 四项本地缺口收口**
- `net_world.gd` 移除旧 `Sprite3D HpBar`,由 `CanvasLayer + Control``ScreenHpBar`
`Camera3D.unproject_position()` 每帧投影;无最大血量、死亡、镜头后方、屏幕外和换图时隐藏 / 清理。
`screen_hp_bar.gd` / `screen_hp_bar_test.gd` 覆盖血量同步与投影边界。
- `entity_rules.gd` / `net_play.gd` 增加 `KIND_BUILDING=5``KIND_WOODEN_DOOR=6`;支持显式
`kind``building``mob_proto bType=4`,以及 13000 / 30111..30119 木门 race。目标框和
攻击分类不再把建筑 / 木门误当 PC;`entity_rules_test.gd` / `netplay_test.gd` 覆盖。
- `ground_items.gd` 的掉落名 / owner 行按显式颜色、`item_grade` / `quality` / `rarity`
品阶字段取色,owner 行继承物品颜色,不再固定纯黄色;`p2b_test.gd` 覆盖显式品阶色。
- `chat_ui.gd` 新增独立 `WhisperDialog`LCONTROL 点同阵营其他 PC 后可发送、取消或 ESC 关闭,
不污染公共聊天输入;`game_scene.gd` / `chat_test.gd` 完成接线与回归。
- `eterngrn_polish_test.gd` 的 Audio stub 补齐 MilesLib 接口,`p10_test.gd` 改用受限环境可写的
`/tmp` fixture;最终 C++/CTest 21/21、Godot 脚本 71/71 通过。Godot macOS headless 的
系统证书读取噪声和受限 TCP bind skip 不计作代码失败;真实服务器、真机和正式资源包仍需外部验收。
- [x] `GC_LOGIN_SUCCESS3/4` 的角色槽保存 `lAddr/wPort``M2Client.enter_game(index)` 已接入
Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送
`CG_CHARACTER_SELECT`
- [x] `select_char()`、登录包、`CG_ENTERGAME` 均透传 `send_fixed()` 结果;`CG_ENTERGAME` 成功后
执行即时 flush。`m_entergame_sent` 只在发送成功后置位。
- [x] 未知 header 不再扫描 Loading 缓冲寻找伪边界;保留参考端唯一的 `header == 0` 填充跳过,错误会
记录最近两个 header 并断开。
- [x] ClassicParser 对已知但未实现的集成包不再静默成功,而是记录阶段 / header 并触发失败链路。
- [x] 已建立游戏连接后的断线现在发 `disconnected``AppFlow` 无条件回 LOGIN,匹配
`OnRemoteDisconnect() -> SetLoginPhase()` 的最小语义。
- [x] 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序;
`net_classic_stream_test``net_classic_session_test``netbridge_test``p10_test` 已通过。
`net_classic_session_test` 内的 localhost 双监听目标测试因当前受限环境禁止 bind 而自动 skip,
未将 skip 计作真实 TCP / 真服验收。
- [x] Classic `GC_WARP` 透传 `lX/lY/lAddr/wPort`,不再用 `addr==0` 判断同服;保留登录票据和已选
角色槽,通过同一个 `ClassicSession` 重连目标 `lAddr:wPort`,并在重连前清理 `EntityStore`
地图实体、掉落物、目标、PVP / 决斗、技能 / affect / 交互状态。
- [x] 新增 `M2Client.world_reset``GameScene` / `NetWorld` / `GroundItems` 清理旧表现节点,
重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。
- [x] 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建
相关 GDScriptC++ 扩展和 `gamescene_test` / `p9_test` / `p2b_test` 已通过。
- [x] **增量 49 —— W0 接口冻结批次**(Phase 1 前置)。为 4 个并行工作流一次性落地共享文件骨架:
- `entity_store.h/.cpp``Entity` 新增 `empire / affect_flags / owner_vid / state_flags`
`mut_spawn_full(const Entity&)`(§2.3 两包合并的单次落地,保留在途插值状态)、
`mut_ownership(vid, owner_vid)`(§2.5)、`mut_map_bgm(name, volume)` +
`take_bgm_dirty()/bgm_name()/bgm_volume()`(§9.1`volume<0` = 服务器未指定)、
`drain_dirty()`(§2.1/§2.5 裸字段更新去重队列);`mut_char_info()``e.empire = empire`
取代 `(void)empire;``reset_for_map_change()` 一并清 `m_dirty`
- `m2_client.cpp`:新增 `bgm_changed(name, volume)` 信号;classic 与 m2dev 两条 pump 回路
都消费 `drain_dirty()``entity_info``take_bgm_dirty()``bgm_changed`
`entity_dict()``empire / affect_flags / owner_vid / state_flags` 键。
- `classic_parser.cpp/.h``GC_MAIN_CHARACTER3_BGM` / `GC_MAIN_CHARACTER4_BGM_VOL`
`bgm_name` / `bgm_volume` 从「读出即丢弃」改为路由到 `mut_map_bgm()`;新增
`std::unordered_map<uint32_t, Entity> m_pending_actor` 成员(W1 在 §2.2 填充合并逻辑)。
- `project/bgm_director.gd`(新):`M2Client.bgm_changed``Audio.play_bgm` / `stop_bgm`
把地图 BGM 消费点从 `net_world.gd` 拆出,避免 W2(可见性)与 W4(音频)抢改同一文件;
`game_scene.gd` 装配 `_bgm_director``bind(client, _audio)`
- `game_scene.gd` §8.6 / §4.8 键位收敛:数字键 1–4 → `quickbar.activate(0..3)`
F1F4 → `quickbar.activate(4..7)`(原为翻页)、Ctrl/Cmd+1..9 → `_emote(n)` 统一入口
(表情包发送与本地表情动作由 W3 在 §8.6 补全,当前调用 `client.send_emoticon` 若存在)。
- 测试:`cmake --build build` 通过;`ctest` 16/16 通过;`netbridge_test``gamescene_test`
`p9_test``netplay_test``system_menu_ui_test``skill_test``combat_fx_test`
`skill_fx_test``player_motion_test` 全绿。无 skip。
- 未做(留给 Phase 1):BGM 名 → 曲目 / 路径解析与音量真正应用到混音(W4 §9.1)、
`m_pending_actor` 合并逻辑(W1 §2.2)、§8.6 剩余逐行键位与 LSHIFT 翻页(W3)。
- [x] **增量 51 —— W0 §9.3 生命周期单一属主**:生产入口 `client_main.gd → AppFlow` 创建并持有
唯一 `AppLifecycle`,登录 / 选人 / 游戏切屏不重复创建;`GameScene` 只暴露其 `Audio` 给 AppFlow
绑定;`M2Client` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` 监听,只保留
`suspend()/resume()`;旧的 `login.gd` 兼容启动器也保持自己的生命周期节点贯穿登录到游戏,且不再
在进场时销毁。新增 `app_flow_lifecycle_test.gd`,并扩展 `gamescene_test.gd` 断言无第二个节点。
- [x] **增量 52 —— W0/W2 §3.9 公共网络 API 参数边界**:新增
`extension/src/net/net_bounds.h`,统一封装 `u8/u16/u32/i32`、快捷栏 36 槽、交易 12 槽、Cube 24 槽、
私人商店 39 项、龙魂精炼 15 格等协议边界;`M2Client` 在转换为 wire 类型前拒绝负数、溢出、错误
Variant 类型和超出数组 / 文本容量的输入。覆盖连接端口、角色创建、移动 / 同步 / 战斗 / 技能、聊天、
社交、公会、商店 / 交易 / 仓库 / 私人商店 / Cube、精炼、物品操作和会徽连接参数。新增
`net_bounds_test`C++ 扩展构建与 CTest 18/18 通过。
- [x] **增量 53 —— W3/W4/W5 §4.8 §6.3 §8.6 §10**:快捷栏按参考端改为服务器 36 槽、客户端
4 页 × 8 格,类型严格使用 `INVENTORY=1/SKILL=2/EMOTION=3`;表情槽接入 `send_emoticon()`
补齐 LSHIFT 翻页、F1F4、Ctrl/Command+数字、空格连续攻击、Alt 显示名字、截图、帮助 / 坐骑 /
角色窗口 / 小地图 / 聊天 / 相机等入口。`.mse` 粒子读取位置、发射形状、方向 / 速度和首个
`TextureFiles` 的实际 `.dds``UiAssets` + 原生 DDS 解码)。Mac 打包脚本复制 `assets` / `bgm`
`Contents/Resources`,支持 `MT_MAC_ARCHES=universal``lipo` 门禁和嵌套扩展先签名,发布凭据
改为环境变量。`fx_test``input_key_test``skill_test``netplay_test``netbridge_test`
`gamescene_test` 全绿;完整资源导出、当前机器双架构产物、正式证书 / notarization、真服 / 真机
仍未验收。
- [x] **增量 54 —— W3 §4.2 §4.9 / 鼠标物品态、光标和窗口焦点**:新增
`project/ui/mouse_controller.gd` 全局拖放层和 `cursor_manager.gd` 光标状态层;背包物品可投放到
背包格 / 快捷栏,释放到无效区域、右键、ESC、失焦取消;`UiManager` 在窗口打开时拦截不应穿透到
世界的键盘事件;`PlayerController``GroundItems``NetWorld` 接入 PICK / TALK / ATTACK 等
首版状态。修正装备窗口拖放的 wire 坐标,并补齐普通 J、Ctrl+Q 和反引号拾取入口。新增
`mouse_controller_test.gd`;相关 Godot 回归全绿。复杂窗口逐项 mousemodule 语义、真实 `.sub`
光标资源、CJK IME 和真服 / 真机仍未验收。
- [x] **增量 55 —— W4 §9.4 / MilesLib 音频子集**`audio.gd` 已实现参考端的
`SetPosition/SetDirection/Update` 监听器接口、普通 / 环境 / 角色 3D 播放、200/1000 坐标缩放、
参考音量曲线、角色声音 5000cm / 0.3 秒限频、实例停止、全停和音乐限幅淡出;`.msa` 本地动作声音
已接入角色声道。新增 `audio_miles_test.gd`,覆盖常量、换算和限频规则并通过。
- [x] **增量 56 —— W3 §4.2/§4.4/§4.5/§4.6/§4.7 / 复杂窗口物品态**:仓库增加 5×9 网格和分页,
支持背包↔仓库指定格、仓库内移动和仓库↔背包;商城增加分页网格并支持商城→背包;交易增加 12 个
指定投放格;NPC 商店增加 BUY/SELL 模式、出售投放区和 BUY/SELL 光标联动;私人商店的 40 格已
接入全局物品态。新增 `complex_item_drop_test.gd`,覆盖仓库 / 商城 / 交易 / 商店落点及购买模式
拒绝出售投放;代码回归通过。金钱附着、stack split、anti-flag / 售价确认、真实 `.sub` 资源、
完整交易事务和真服 / 真机仍未验收。
- [x] **增量 57 —— W3 §4.2/§4.5/§4.6/§4.7 + W1 item 实例字段**:全局物品 payload 保留实例
`anti_flags``flags``sockets``attrs`;普通背包拖放携带整组数量,Shift+左键堆叠物品支持先选
附着数量,`MouseController` 增加 `ITEM_MONEY=-1` 独立金币态。交易按参考端补齐服务端 accept 镜像、
`USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限和已放金币不可编辑;
NPC 商店补齐实例 `ANTIFLAG_SELL``IsValuableItem` / `Is1GoldItem` 售价公式、pick/drop/money/
loginfail 音效和首次确认框;私人商店补齐 `ANTIFLAG_GIVE/MYSHOP`。新增 `proto_item_layout_test`
`shop_rules_test`,库存 / 鼠标 / 交易 / 摆摊 / 复杂窗口回归通过。参考端 safebox money 发送函数已核实
主动 `return false`,因此不实现伪 `/safebox_money` 命令;完整交易事务、真实 `.sub` 资源、真服 / 真机
仍待验收。
- [x] **增量 68 —— W4 §7.5 / `.msenv` 格式与静态云层**:按 `MapUtil.cpp` 补齐参考缺省值、
Fog `IsDensity`、Filter、SkyBox `BTextureRenderMode`、六面天空文件名和 `Reserved`
`environment_builder.cpp` 通过 `AssetResolver` 将真实云层贴图接入
`ProceduralSkyMaterial.sky_cover`,并把 Godot 暂无等价渲染载体的参数写入
`WorldEnvironment` 元数据;修正 macOS 环境路径强制小写问题。`formats_map_test`(内联 + 真实 A1)、
`environment_test``world_probe` 和受影响回归通过;在该批次完成时,六面天空、双方向光分层、
云层 UV / 高度动画、滤色实际合成、风参数、lens flare、昼夜天气联动和真机视觉仍待实现 / 验收,
后续增量 69/70/71/72 已分别补上双光层、texture-mode 六面天空首版、参考风缺省和纹理天空云层组合首版。
- [x] **增量 69 —— W4 §7.5 / 背景光与角色光分层**:按
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264` 保留背景 / 角色两套方向光;
`Sun` 使用 layer 1,仅照地形 / 水面 / 建筑 / 树,新增 `CharacterLight` 使用 layer 2,仅照
`Metin2Model`、装备和 LOD。换图卸载同时清理两盏灯;`environment_test``player_motion_test`
和 C++ 构建通过。SpeedTree 自身风参数、真实阴影和真机视觉仍待验收。
- [x] **增量 70 —— W4 §7.5 / texture-mode 六面天空**:按
`40250/ClientVS22/source/EterLib/SkyBox.cpp:483-542` 对齐 front/back/left/right/top/bottom
的面顺序和 UV`environment_builder.cpp` 使用 Godot `sky` shader 加载真实天空面贴图,空面
回退 `.msenv` 渐变。新增 `skybox_test.gd`,以真实 CapeDragonHead 环境验证五面资源、空 bottom
面和 shader 安装;`cmake --build``ctest``environment_test``skybox_test` 通过。纹理天空
与云层同时存在时的叠加、云层动画、滤色、风、lens flare、昼夜联动和真机视觉仍待验收。
- [x] **增量 71 —— W4 §7.5 / SpeedTree 风缺省**:按
`40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42` 确认 `Environment_Load` 不读取风 token
参考缺省为 `fWindStrength=0.2``fWindRandom=0`;树 proxy 不再误用 `1.0`
`WorldEnvironment` 保存对应元数据,`environment_test` 增加断言。SpeedTree 真实几何、森林
shader 常量和随机风语义仍待实现 / 验收。
- [x] **增量 72 —— W4 §7.5 / texture-mode 天空与云层组合**:按
`40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557,869-927` 将有限水平云面接入 Godot
`sky` shader`CloudScale` / `CloudHeight` 做厘米到米的平面投影,`CloudTextureScale` /
`CloudSpeed` 做 UV 缩放和 `TIME` 秒级滚动,并复现 `MODULATEINVALPHA_ADDCOLOR` +
`ONE/INVSRCCOLOR` 的颜色合成。真实 DawnMistWood 的五面天空 + 云层资源回归通过;云层
渐变过渡、滤色、SpeedTree 真实几何 / 风语义、lens flare、昼夜联动和真机视觉仍待实现。
- [x] **增量 100 —— W4 §6.3 / EffectLib emitter cycle 生命周期首版**:按
`40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` 对齐缺省
`CycleLength=0.05`,按 `ParticleSystemInstance.cpp:280-300` 接入有限 cycle / LoopCount 的
发射停止;重播时复位 CPU 时间门控,并移除把 cycle 长度错误写入粒子寿命的逻辑。`fx_test`
覆盖 cycle、LoopCount、独立粒子寿命和 OUTER fallback。OUTER / INNER 的精确径向速度、每周期
emission residue / 时间曲线重置、随机起始帧和每粒子随机语义仍待实现。
- [x] **增量 101 —— W3→M2 §5.2 / Quest 日志五行窗口、滚动和任务行事件**:按
`assets/root/uicharacter.py:651-703,1339-1346` 对齐 `QUEST_MAX_NUM=5`、滚动起始索引和
`event.QuestButtonClick(-2147483648 + questIndex)``quest_log.gd` 保存过滤后的任务快照,
超过 5 条显示滚动条,按默认 `season1/icon/scroll_open.tga` 或服务端 icon 渲染,补齐任务行点击
`quest_selected``quest_test.gd` 新增 7 条任务、首尾 5 行滚动和事件参数回归,测试通过。
世界箭头 / 屏幕边缘指示见增量 108,信件按钮条多列定位见增量 109;任务日志窗体 `_list` 自身
多列 / 分页、完整 `.sub` 图标视觉和真服任务生命周期仍待实现。
- [x] **增量 102 —— W3→M2 §5.3 / 钓鱼输入状态机首版**:按
`40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24`
`PythonPlayerInput.cpp:419-425,506-549``Packet.h` 对齐钓鱼攻击键、方向 / 地面点击取消及
`CG_FISHING` 方向参数;`net_play.gd` 在钓鱼模式下阻断普通攻击,使用 `MapCoord`
yaw→server heading`M2Client::fishing()` 再按参考 wire 量化为 `rot / 5`。收到
`GC_FISHING` START/REACT/STOP/SUCCESS/FAIL/FISH 时维护客户端意图状态。
`player_controller.gd` 接入方向键边沿与地面点击取消,保留 `NEW_CancelFishing` 的 500ms
节流;`quickbar.gd` 对特殊技能 123 接入 `activate_fishing()``netplay_test`
`player_move_test``skill_test``gamescene_test``combat_fx_test` 已通过。
`GetFishingRot` 水面像素扫描、wrong-place 文案、鱼获物品落袋以及副本 / 坐骑 / 观战者
完整玩法仍待实现。
- [x] **增量 103 —— W3→M2 §5.3 / GetFishingRot 水面扫描首版**:按
`40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108`
`InstanceBase.cpp:375-377``PRTerrainLib/Terrain.h:70-72` 接入真实 `ATTRIBUTE_WATER`
查询。新增 `Metin2World.get_fishing_rotation()` / `can_fishing_position()`,在角色前方
600cm 对当前 heading 左右 `0..180°` 每 10° 扫描并返回选中的服务端 heading;`NetWorld`
桥接地图层给 `NetPlay`,没有地图 provider 时保留协议 fallback。扩展重新编译通过,
`fishing_water_test.gd` 加载 A1 全 20 区块并找到真实水面回归通过。wrong-place locale
文案、鱼获 item proto / inventory 落袋和副本 / 坐骑 / 观战者完整玩法仍待实现。
- [x] **增量 99 —— W3→M2 §4.1 修改 5:远端 PC 真模型工厂与 `awPart[4]` 换装首版**
`game_scene.gd``ch_type==0` 远端 PC 创建 `RemotePlayerView`,复用 `PlayerView` 的 race /
GR2 / MSA 装载;新增远端专用 `EquipModel.setup_remote()`,只消费
`awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()``net_world.gd` 改为四字段
`set_armor→set_weapon→set_head→set_hair`,并以真实 warrior 资源回归。当前仍待所有职业 / race、
变身字段、真实远端装备视觉和真服多角色验收。
- [x] **增量 73 —— W2 §3.7 / 受击状态统一**:删除 `net_play.gd` 里参考端不存在的
`_hitstun_until` 本地硬直计时器;「能否动作」统一走
`_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`
三个函数(对齐 `InstanceBase.cpp:1247``__IsSyncing` / `InstanceBaseBattle.cpp:355` /
`:310`)。C++ `Entity` 增加 `knock_down``entity_dict` 暴露、`mut_knockdown`
落位),本地由受击 `.msa` 播放生命周期驱动(`player_view.is_in_hit_reaction()`),
动作结束清零。`combat_fx_test` 断言改写,C++ 19/19 + 相关 GDScript 回归全绿。
远端实体 `knock_down` 填充留待后续增量(仅影响远端受击表现,不涉及输入闸门)。
- [x] **增量 74 —— W2 §3.3 / 普攻节奏硬编码**`formats/msa.{h,cpp}` 新增
`Group ComboInputData` + `Group AttackingData` 解析(对齐
`EterGrnLib CGrannyMotion::LoadMotionData``CRaceMotionData::TComboInputData /
TAttackingData`);`metin2_anim` 导出并绑定 `get_motion_data()``net_play.gd`
删除 `ATTACK_RANGE`,改为 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐
`InstanceBaseBattle.cpp:294 NEW_IsClickableDistanceDestInstance`),普攻节奏改为
`_current_attack_period()` = `(.msa next_combo 或 DEFAULT_ATTACK_PERIOD) /
(bAttackSpeed/100)`,收 info 包时不再把 `attack_period` 写死。弓箭 `__GetBowRange()`
留待 §3.5。C++ 19/19 + `combat_fx_test` / `netplay_test` 等 GDScript 回归全绿。
- [x] **增量 75 —— W2 §3.4 / 目标预留状态机**`net_play.gd` 新增
`enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` +
预留字段,`_process` 每帧跑 `_update_auto_attack()`(照抄 `PythonPlayer.cpp:207
__Update_AutoAttack` 的目标存在 / 死亡 / 骑乘 / charge-skill / 睡眠分支)→
`_refresh_reserved()` 分派。`_reserve_process_click_actor()` 严格 10 步对齐
`PythonPlayerInput.cpp:877`(出距离 `pc.walk_to` 靠近、不可攻击走 `click_npc`
`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经新 `signal cannot_act(code)`
第 10 步按 §3.3 节奏 gate `_do_attack_swing`)。安全区 / 弓箭 / 骑乘 / dash 等服务端
未下发的判定位先用实体键 + 本地占位接线(同 §3.7 `knock_down`)。CLICK_ITEM /
CLICK_POSITION / USE_SKILL 分派留桩。`netplay_test` 新增 5b 段;C++ 19/19 +
其余 GDScript 回归全绿。
- [x] **增量 76 —— W2 §3.1 / 本地移动 Src/Dst 模型**`player_controller.gd` 去掉
`_move_target` / `_has_target` 的直线改坐标,改为 `_src_pos` / `_dst_pos` / `_is_going` /
`_dst_rot` 模型,`walk_to()``_goto()` 对齐 `CInstanceBase::NEW_Goto()`:太近
`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门(`frozen` / `locked` / `moving_skill`
`_can_translate()`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s 重试
(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。
`_process` 每帧朝 `_dst_pos` 推进、抵达清 `_is_going`WASD 分支置 `_is_going = false`
新增 `project/player_move_test.gd`5 组);C++ 19/19 + 其余 GDScript 回归全绿。
未做:Actor 间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似)、坐骑
`MODE_HORSE*`(依赖 §6.4 + §3.2)。
- [x] **增量 77 —— W2 §3.2 / 远端同步命令队列(C++ 核心)**`Entity` 新增
`std::deque<StateCmd> state_queue` + `mov_after_*` / `skip_collision``mut_move()`
改为入队 `StateCmd`(≈ `PushTCPState`)后 `drain_state_queue()` 释放到期项,m2dev
`apply()` 与 classic parser 都走 `mut_move(...,arg,time)`,删掉旧 `start_move()` 直改
坐标路径。新增 `process_states()` + `apply_state_cmd()` 1:1 实现 `StateProcess`
switch`FUNC_WAIT` 1.0 阈值、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 阈值 + `m_kMovAfterFunc`
到达后动作、`skip_collision`);释放门按 `m_server_frame_ms`(新 `set_server_frame_ms`
为 0 时立即释放 = 旧行为)+ `__CanProcessNetworkStatePacket`dead / knock_down hold
队列)。`tick()` 每帧先 `process_states()`。新增 `extension/tests/net_state_queue_test.cpp`
`net_entity_test``FUNC_ATTACK` 断言按新语义改写;C++ 20/20 + GDScript 回归全绿。
留给增量 78net_play.gd 上行 6 事件回调 + 节流常量、net_world.gd 去掉自有 lerp、
`GC_HANDSHAKE` `lDelta` 接真实服务器帧钟。
- [x] **增量 78 —— W2 §3.2 / 上行 6 事件回调 + net_world 去 lerp**`net_play.gd` 上行
`PythonPlayerEventHandler.cpp` 拆成 6 个回调——统一出口 `_send_state(func,arg,pos)`
(≈ `SendCharacterStatePacket`);`OnMove` 立即发 `FUNC_MOVE` 并重置 moving/waiting 两个
节流窗,`OnMoving` 300 ms 节流,`OnWaiting`(新 `_tick_on_waiting()``_process` 每帧)
100 ms 节流且位移 ≥ 1 cm 才发 `FUNC_WAIT``OnStop` 不节流收尾 `FUNC_WAIT``OnAttack`
`CG_ATTACK` 外额外 `_send_state(FUNC_COMBO, motion, pos)`,新增公共入口
`on_use_skill(motion,arg)``FUNC_SKILL|motion`(调用点属 §3.8)。删旧
`SEND_INTERVAL/SEND_DIST/_last_sent_t` 单一节流。`net_world.gd` `_process`
`n.position = _grounded(e.pos)` 直接跟随,去掉自有 `follow_lerp`——位置插值全交 C++
`EntityStore::tick()`,只朝向仍 GDScript 平滑。`netplay_test` 加第 11 段、`netbridge_test`
的 near-move 断言改写;C++ 20/20 + 7 个 GDScript 回归全绿。留给增量 79:⑤ 从
`GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟(`ClassicStream` 已存时间基,待暴露访问器)。
- [x] **增量 79 —— W2 §3.2 / 真实服务器毫秒帧钟(part C,收官)**`ClassicStream` 暴露
`server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)——`HDR_HANDSHAKE` 处理里已存的
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`,帧钟即
`now_ms() - m_client_time_base + m_server_time_base`;首个握手落地前返回 0。
`ClassicSession::process()``m_world.tick()``set_server_frame_ms(m_stream.server_frame_ms())`
于是 classic 后端的 `drain_state_queue()` 按服务器时间放行队列而非即时释放;m2dev 后端无
握手 `lDelta` 对应物,保持 0 = 即时释放,无回归。`drain_state_queue` 加防呆阀
`STATE_QUEUE_MAX_WAIT_MS = 1000`——`chk_time` 领先帧钟超过 1 s 视为异帧钟 `dwTime`,立即
放行以免远端 Actor 永久冻结(参考端无此上限,属有意偏离,已在 §3.2 记录)。
`net_classic_stream_test` 加帧钟段(握手前 0、握手后 ≈ `hs.time+lDelta`、单调)、
`net_state_queue_test` 加第 6 段(防呆阀边界);C++ 20/20 + 7 个 GDScript 回归全绿。
§3.2 至此完成,真实服务器 `dwTime` 时基仍待联机核验。
- [x] **增量 80 —— W2 §3.5 / PC 连击段表解析(part A**:参考端 PC 的连击段表 **不在**
`.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)——它由 `playersettingmodule.py`
`chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用在 `CRaceData` 上建起来
`PythonCharacterManagerModule.cpp``CRaceData::ReserveComboAttack` / `RegisterComboAttack`
`RaceData.cpp`)。新增 `formats/combo_table.{h,cpp}``fmt::PlayerComboTables`):按
`def __LoadGame<Class>Ex` 分四个职业块,解析那批调用为
`map<MAKE_COMBO_KEY(motionMode,comboType), vector<段号>>`(段号 = `NAME_COMBO_ATTACK_*` =
14..21)。照 `CRaceData` 语义:`Reserve` 建长度 `count` 的 0 向量(`map::insert`,重复 key
不覆盖)、`Register` 按下标写(key 未 Reserve 或下标越界则跳过)。认得 `chr.MOTION_MODE_*`
= `CRaceMotionData::EMode`)、`chr.MOTION_COMBO_ATTACK_N`= 13+N)、`chr.MOTION_NORMAL_ATTACK`
= 13)、`COMBO_TYPE_N` / `COMBO_INDEX_N`= N1)、裸整数。新增 `formats_combo_table_test`
(合成片段断言 + `M2_ASSETS` 存在时对真实 `playersettingmodule.py` 断言 warrior/sura 1H、
shaman FAN);C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 81:`player_controller.gd` 连击
状态机(`_cur_combo_index` / `_is_pre_input` / `_is_next_pre_input`)改用此表 + `.msa`
`ComboInputData` 三时间,替掉当前 `(_combo + 1) % 3` 硬编码;之后命中窗(`HitDataContainer`
+ 发包挂动作事件。
- [x] **增量 81 —— W2 §3.5 / PC 连击状态机(part B**`net_play.gd` 新增
`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo`
逐行对齐 `CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` /
`__OnEndCombo` / `__ClearCombo``REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`),
替掉 `(_combo + 1) % 3``_combo_index==0` 直接推进;否则按 `.msa` `ComboInputData` 三时间
`pre_input_time` / `direct_input_time` / `input_limit_time`)判 `_is_pre_input` 挂起 / 直接推进 /
弓模式 `_is_next_pre_input`;无 combo 输入组用 `duration*0.9` 兜底;`_combo_process()` 每帧触发
挂起输入、动作回 Wait 时 `_clear_combo()`;段号越界不推进;末段仅骑乘复位到 1。段号来自
新静态绑定 `Metin2AnimPlayer.parse_combo_tables(py_path)``extension/src/metin2_anim.{h,cpp}`
增量 80 的 `fmt::parse_player_combo_tables_file`),按 `race & 3` 选职业块、
`combo_motion_mode<<16|type` 取段序列;缺段表退化为单段普攻。`_emit_swing` 保持增量 78 的两包
`CG_ATTACK{bType=0}` + `CG_CHARACTER_MOVE{FUNC_COMBO, 段号}`)。`game_scene.gd` 装配时
`net_play.set_asset_root(_assets)``combat_fx_test.gd``FakeAnim` + 连击状态机全路径断言。
C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 82+:`.msa` `HitDataContainer` 多命中窗导出、
`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION`
武器种类 → `combo_motion_mode` 映射。
- [x] **增量 82 —— W2 §3.5 / `.msa` 多命中窗导出(part C,修改 1)**`formats/msa.{h,cpp}`
`Group AttackingData``GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法
解析成 `std::vector<Msa::HitWindow>`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`
`HitDataCount`= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` +
`Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每窗带
`start_time`/`end_time`/`bone`/`weapon_length` + `List HitPosition` 采样
`{time, last_pos, pos}`7 浮点一组);`attacking_type` 兼容 `AttackType`/`AttackingType`
两种键,新增 `motion_type` / `hit_limit_count``attack_start_time`/`attack_end_time` 保留为
第一个窗起止(旧调用方兼容)。`formats/textscript.cpp` 同时补 `List Name { ... }` 块解析
(原来 `List` 会把 `{` 吞进普通行、还串掉其后的 `Group``palbang.msa`
`MotionEventData` 就丢了)。`extension/src/metin2_anim.cpp get_motion_data()` 新增
`hit_windows`(数组:`{start_time,end_time,bone,weapon_length,samples:[{time,last_pos:Vector3,pos:Vector3}]}`
+ `motion_type` / `hit_limit_count``formats/tests/msa_test.cpp` 加内联单窗 / 新式双窗合成
断言 + `M2_ASSETS` 时对真实 `warrior/general/attack`17 采样)、`twohand_sword/combo_05`
(武器长 150 / 37 采样)、`assassin/dualhand_sword/combo_01`R Hand + L Hand 双窗)、
`warrior/skill/palbang``AttackingData``MotionEventData` 3 事件)断言;`CMakeLists.txt`
`formats.msa_loop_data``M2_ASSETS` 环境透传。C++ 21/21 + 7 个 GDScript 回归全绿。
留给增量 83+:修改 4`THitData` 窗口命中判定 + `OnHit``CG_ATTACK` + `FlushVictimList` 帧末
`CG_SYNC_POSITION` ≤16)、武器种类 → `combo_motion_mode` 映射。
- [x] **增量 83 —— W2 §3.5 / 命中窗几何判定 → `OnHit`(修改 4,§3.5 收官)**`net_play.gd`
新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` / `motion_type` /
`hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、`_attack_process(dt)`
(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + `ActorInstanceCollisionDetection.cpp:333
__NormalAttackProcess`):每帧动作时间窗 `[t-dt, t]` 与某 `hit_window [start,end]` 相交时,
遍历 `client.get_entities()` 里可攻击 / 未死 / 双方不在安全区的实体,`net_world.node_for(vid)`
取世界位;`_hit_deduped()``m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标一次、其余按
`invisible_time` 冷却)+ `_register_hit()`(每窗命中上限:NORMAL/COMBO 16、SKILL
`hit_limit_count`)→ 命中发 `client.attack(_swing_skill, vid)``OnHit``SendAttackPacket`
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush``_emit_swing()` 改为**只有拿不到
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK``_flush_victim_list()`(对齐
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`
几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。
`combat_fx_test.gd``FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
`combo_motion_mode` 映射。
- [x] **增量 84 —— W2 §3.5 / 武器种类 → `combo_motion_mode`(修改 6**`net_play.gd` 补全
`MOTION_MODE_*``CRaceMotionData::EMode` 全 17 项)+ `WEAPON_SUB_*``CItemData::EWeaponSubTypes`
+ `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射 `static motion_mode_for(item_type, weapon_sub,
mounted, poly, dress)` —— 1:1 复刻 `CInstanceBase::RefreshState``InstanceBase.cpp:2851`)里
`SetMotionMode` 分支:Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、
钓竿→骑乘 HORSE / 否则 FISHING、骑乘按 subtype→HORSE_{ONEHAND,TWOHAND,DUALHAND,FAN,BELL,BOW}
或 HORSE、否则按 subtype→{ONEHAND,TWOHAND,DUALHAND}_SWORD / BOW / FAN / BELL、ARROW 及其余→
GENERAL。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`(为 0 回退 `get_equipment()`
`WEAR_WEAPON` 槽),`_refresh_motion_mode()``proto.item(vnum)``type`/`sub_type` +
实体 `mount_vnum`/`polymorph``set_combo_motion_mode()`(换模式会 `_clear_combo()`)。
接线:`_on_entity_info`(主角设定 / `GC_CHARACTER_UPDATE` parts 变)末尾、新 `_on_inv_changed_mode`
`inventory_changed` window==EQUIPMENT)、`mount_changed` 信号(vid==主角)。`combat_fx_test.gd`
`FakeProto` + 15 段 `motion_mode_for` 映射断言 + 2 段端到端(`parts[WEAPON]`=匕首→
DUALHAND_SWORD 且清连击、上马→HORSE_DUALHAND_SWORD)。C++ 21/21 + 7 个 GDScript 回归全绿
`equip_model_test` 亦 PASS)。留给增量 85+:弓箭 `__GetBowRange()`(§3.5 命中距离)、
§3.6 箭矢 FLY 事件。
- [x] **增量 85 —— W2 §3.5 / 弓箭 `__GetBowRange()`(可点击 / 攻击距离)**`net_play.gd`
`_clickable_distance()` 逐分支对齐 `CInstanceBase::NEW_IsClickableDistanceDestInstance`
`InstanceBaseBattle.cpp:294`):默认 150 cm → `_is_bow_mode()``combo_motion_mode`
`MODE_BOW` / `MODE_HORSE_BOW`,对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)时
改用 `_bow_range_cm()` → 目标 `IsNPC()` 覆盖为 500 → 目标 `IsResource()` 覆盖为 100(后写的
`if` 覆盖前面的)。新增 `_bow_range_cm()` = `BOW_RANGE_BASE_CM``2500-100`+ `_bow_distance_bonus`
—— 1:1 复刻 `CInstanceBase::__GetBowRange``InstanceBase.cpp:694``2500.0f - 100.0f` 再加主角
`GetStatus(POINT_BOW_DISTANCE)`,参考端按 cm 直加不 ×100)。`_bow_distance_bonus`
`_on_points()``points["bow_distance"]` 填充;`wire.h``POINT_BOW_DISTANCE = 34`
`M2Client::get_points()``d["bow_distance"] = p.v[POINT_BOW_DISTANCE]``combat_fx_test.gd`
§3.5 段加 8 段断言(非 bow=150 / bow 基础=2400 / bow 目标=__GetBowRange / NPC 覆盖=500 /
采集物覆盖=100 / HORSE_BOW 也算 bow / 加成叠加 / `_on_points``bow_distance`)。
C++ 21/21 + 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 86:§3.6 箭矢
`.msa` `MOTION_EVENT_TYPE_FLY``CG_FLY_TARGETING` / `CG_SHOOT`,替掉 `game_scene.gd` 约 1.5 s
定时器兜底(`PythonPlayerEventHandler.cpp:235`)。
- [x] **增量 86 —— W2 §3.6 / 弓箭 FLY 事件(`OnSetFlyTarget` / `OnShoot`**:拆掉
`game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底,换成
`_pending_shots: Array[int]`FIFO,上限 `MAX_PENDING_SHOTS = 8`,满则丢最旧)——
1:1 对齐 `CNormalBowAttack_FlyEventHandler_AutoClear`:每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)`
压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event``type 6`
`pop_front()` 弹一个发 `client.shoot()`pop 即自清 = AutoClear,队列天然支持连射多箭在途)。
对齐 `CActorInstance::ProcessMotionEventFly``ActorInstanceMotionEvent.cpp:290`
`m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)`)。`OnSetFlyTarget``ActorInstanceMotion.cpp:372`
`__SetMotion``__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`
`_is_bow_mode()` 时先 `_send_fly_target()` —— 取 `_target_vid`(回退 `_vid_reserved`)在
`net_world` 里的节点,按 `MapCoord.to_server_cm()` 换 server-XY 后 `client.add_fly_targeting(vid, x, y)`
(对齐 `SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition())`
`OnGetFlyTargetPosition` = 目标包围球中心世界坐标,其平面分量与脚点一致,
`CG_FLY_TARGETING` 只带平面坐标,`ActorInstanceFly.cpp:4`)——再 `bow_shot_fired.emit(_swing_skill)`
`game_scene` 入队。新增 `net_play` 信号 `bow_shot_fired(skill)``_send_fly_target()`
`game_scene._queue_shot()` + `net_play.bow_shot_fired.connect(_queue_shot)` 接线(弓技能路径
`quickbar.skill_activated``is_ranged` 分支同样改走 `_queue_shot``use_skill` 里 C++ 已发
fly-targeting 不重复)。`combat_fx_test.gd``FakeClient.add_fly_targeting/shoot` + 6 段
§3.6 断言(非弓模式无 FLY 事件 / 弓挥击起手发 `CG_FLY_TARGETING(vid)` / 坐标 = 目标 server-XY /
`bow_shot_fired(0)` / 无目标不发);`eterngrn_polish_test.gd` FLY 段改为 FIFO 连射断言
(连压 77/78 → 依次 pop → 队空不再发)。C++ 21/21 + 9 个 GDScript 回归全绿。留给后续:
飞行物实体本身的生成 / 命中判定(`CFlyingManager` / `FlyingInstance`)、`__CanShot` 弓箭
合法性、真服 `CG_FLY_TARGETING` z 分量。
- [x] **增量 87 —— W2 §3.8 / TSkillData 判定位解析(修改 2**POC 无 C++ skill proto
`extension/src/proto` 只有 item/mob),技能数据全在 GDScript 的 `project/ui/skill_table.gd`
里读 `locale/locale/<lang>/skilldesc.txt` TSV。故在该文件按 `RegisterSkillDesc`
`REF/UserInterface/PythonSkill.cpp:280`)的做法,把第 11 列 attributes / 第 12 列 weapon
`|` 分隔记号串折成 `attr_bits` / `need_weapon_bits` 两个位掩码(`_fold_bits()` 静态方法,
记号名 1:1 抄 `PythonSkill.h:92-128``SKILL_ATTRIBUTE_*` / `SKILL_NEED_WEAPON_*`
未知记号忽略 = 参考端“map 查不到就跳过”;`DOUBLE_SWORD` 复用 `DAGGER` 位;weapon 位 =
`1 << CItemData::WEAPON_*``GameLib/ItemData.h:63-75`)。`load_file` 时连同 `skill_type`
JOB 列经 `m_SkillTypeIndexMap``SKILL_TYPE_{NONE,ACTIVE,SUPPORT,GUILD,HORSE}`
`PythonSkill.cpp:909-918`)一起预存进 entry。新增 / 改写谓词逐条对齐 `CPythonSkill`
`is_can_use_skill``IsCanUseSkill` = 非 PASSIVE`PythonSkill.cpp:1407`)、`is_standing`
`is_need_target``is_need_corpse``is_auto_search_target``can_change_direction`
`is_horse_skill``is_moving_skill``is_melee``is_charge_skill``is_use_hp`
`is_time_increase``is_need_empty_bottle``is_need_poison_bottle``can_use_for_me`
`is_only_for_alliance``can_use_if_not_enough``is_only_for_guild_war``is_fan_range`
`is_circle_range``has_weapon_limitation``can_use_weapon_type(id, weapon_type)`
`CanUseWeaponType` = 无限制恒真,否则 `dwNeedWeapon & (1<<type)``PythonSkill.cpp:1143`)、
`is_need_bow``skill_type_of` / `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle`
/ `is_ranged` / `can_level_up` 改为查位(等价,`is_ranged` 现含 ARROW 位)。`skill_test.gd`
加 34 条 §3.8 断言(skill 1/16/18/46/63/106/109/137/138/151/152 的位面 + `_fold_bits`
大小写/分隔宽容 + `DOUBLE_SWORD` 位)。C++ 21/21 + 9 个 GDScript 回归全绿。留给增量 88+:
`player_skill.gd` 移植 (a)(b)(c)(d) 三层校验、`M2Client::use_skill()` 带符号 + 范围校验(§3.9)。
- [x] **增量 88 —— W2 §3.8 / `player_skill.gd` 技能三层合法性校验(修改 1)**:新建
`project/player_skill.gd``extends RefCounted`,纯校验器,不发包不播动作),逐段移植
`REF/UserInterface/PythonPlayerSkill.cpp``ClickSkillSlot`(a):106 /
`__CanUseSkill`(b):439 / `__CheckSkillUsable`(c):166 / `__UseSkill`(d):460 —— 每个
`return` 分支对应一个返回码,`code``OnCannotUseSkill` 原字符串,另加静默码集
`PASSIVE` / `GUILD_ROUTED` / `TOGGLE_OFF` / `SPECIAL_SKILL` / `PRIVATE_SHOP` / `BAD_SLOT`
/ `NO_SKILL_DATA` / `CANNOT_ACT` …)对齐参考端“静默 return”。命名常量 `SKILL_MAX_NUM=255`
`Packet.h:2041`/ `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` /
`ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` / `SKILL_INDEX_FISHING=123` /
`SKILL_INDEX_COMBO=122``PythonPlayerModule.cpp:6-7`)。TSkillData 判定全走增量 87 的
`skill_table.gd` 谓词;`__CheckRestSkillCoolTime` = 本地槽 `cd_end``m_sysIsCoolTime` 关则不查),
`__CheckDashAffect` 分支 1:1 保留,`__HasItem``client.get_inventory()``__HasEnoughArrow`
`net_play._arrow_count` 占位,`__CheckShortMana`/`__CheckShortLife` 暂为桩(POC 无
skilldesc 的 NeedSP/NeedHP 公式解析)。目标解析 `_resolve_target``__UseSkill:520-650`
判联盟 / 安全区 / 可攻击 / 尸体 / 需要目标并回传 `target_vid`;距离 / `__ReserveUseSkill` /
扇形·圆形多目标归 §3.4/§3.9。接线:`net_play.gd` +`skill_context()` 快照
observer / mounting / in_safe / fishing / using_skill / can_act / dash / weapon_sub_type /
arrow_count);`quickbar.gd` +`net_play` 引用 + `_skill_gate``PlayerSkill.new()`+
`skill_rejected(id, code)` 信号,`activate()` skill 分支先 `click_skill_slot()`ok 才发
`use_skill`/`cast_skill``TOGGLE_OFF` 只发 `use_skill` 关开关技,其余非静默码发
`skill_rejected``game_scene.gd` 把它转 `net_play.cannot_act` 走同一条文案通道。新
`project/player_skill_test.gd` 逐分支断言((a) BAD_SLOT / NO_SKILL_DATA / GUILD_ROUTED /
PASSIVE / TOGGLE_OFF(b) OBSERVER / CANNOT_ACT / NO_RIDING_SKILL / 骑乘术 lv20
(c) NOT_HORSE_SKILL / HAVE_TO_RIDE / IN_SAFE / REMOVE_FISHING_ROD / NOT_YET_LEARN /
NOT_MATCHABLE_WEAPON / EMPTY_ARROW / WAIT_COOLTIME / dash+charge 跳冷却;
(d) PRIVATE_SHOP / ALREADY_CASTING / SPECIAL_SKILL / 目标类型矩阵 / 结盟技回落自身);
`skill_test.gd` FakeClient `get_entity``kind`。C++ 21/21 + 12 个 GDScript 回归全绿。
留给增量 89+:修改 3`M2Client::use_skill()` 带符号 + 范围校验,§3.9)、`__CheckShortMana` /
`__CheckShortLife` 落地(依赖 `.msk` NeedSP/NeedHP 公式解析)、`__ChangeTargetToPickedInstance`
拾取回退、扇形/圆形多目标 fly-targeting。
- [x] **增量 89 —— W2 §3.8 修改 3 / `use_skill` 边界收紧 + 短蓝短血校验落地**:①
`extension/src/net/net_bounds.h` 新增 `SKILL_MAX_NUM=255``Packet.h:2041`+
`skill_index(v) = 0 < v < 255` 谓词;`extension/src/net/m2_client.cpp::use_skill(int
skill_id, int target_vid)`(形参增量 52 起即带符号)的 `skill_id` 护栏从
`skill_id <= 0 || !bounds::u32()` 收紧成 `!mtnet::bounds::skill_index(skill_id)`,对齐
`ClickSkillSlot``slot >= SKILL_MAX_NUM` 门;`target_vid` 保留 `>= 0 && bounds::u32`
`cast_skill()``motion_idx ∈ [0,0x7f]` 不动(7 位 wire 位段)。`net_bounds_test.cpp`
+4 断言(`SKILL_MAX_NUM==255`、端点 1/254、越界 0/255/-1、拒大值)。② `project/ui/skill_table.gd`
`_load_cooltime` 更名 `_load_msk`,加解析 `.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]`
`LocaleService_GetSkillPower` INTERNATIONAL 表,`REF/EterLib/Locale.cpp:126`
`SKILL_POWER_NUM=50``level >= 50 → 0`+ `_skill_point(level)` + `need_sp(id, level)`
(≈ `GetNeedSP``PythonSkill.cpp:1426``Expression.parse(formula, ["SkillPoint"])` 求值。
POC 约定:无 `NeedSPFormula` 返回 `-1`(参考返回 0),避免误挡缺字段技能)+ `need_hp()`
(照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖,HP 需求也读 NeedSP 公式);
`cooldown_of``SkillPoint` 也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`
`PythonPlayer.cpp:1037`),`max_level` 仍解析留给其它调用方。③ `project/player_skill.gd`
新增 `SKILL_INDEX_SUMMON=131``PythonPlayerModule.cpp:15`+ 上下文字段 `cur_sp` / `cur_hp`
`-1` = 未知不判),把 `_check_short_mana` / `_check_short_life` 从桩改实装:
`_check_short_mana` 逐条移植 `__CheckShortMana``PythonPlayerSkill.cpp:320`summon `131`
豁免、非开关技 `need_sp == 0` → 坏数据拦、`can_use_if_not_enough``cur_sp <= 0` 拦、
否则 `need_sp != -1 && need_sp > cur_sp` 拦),`_check_short_life` 移植 `__CheckShortLife`
`:354``!is_use_hp() → false` 放行、`need_hp > cur_hp` 拦;shipped `skilldesc.txt`
无 USE_HP 技能故实为放行)。二者只在 `check_skill_usable` 非冲刺 `else` 支路命中,
分别转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`。④ `project/net_play.gd::skill_context()`
`client.get_points()``hp` / `sp``cur_hp` / `cur_sp`(缺则 `-1`)。⑤
`player_skill_test.gd` +“(c) §3.8 修改 3”段(`_skill_point(20)==0.5``_skill_point(0)/(50)==0`
`need_sp(1,5)==52``need_hp==need_sp`、SP 不足→`NOT_ENOUGH_SP`、SP 充足→ok、SP 未知(-1)→ok、
无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 `NOT_ENOUGH_HP`),`_reset`
`cur_hp=-1` / `cur_sp=-1`。C++ 21/21 + 13 个 GDScript 回归全绿。§3.8 三层校验(修改 1/2/3)
至此全部落地。留给增量 90+:§3.10 技能升级门(audit 10)、`__ChangeTargetToPickedInstance`
拾取回退、扇形/圆形多目标 fly-targeting。
- [x] **增量 90 —— W2 §3.10 技能升级门(加号按钮门控 + `skill_up` 边界收紧)**:①
`extension/src/net/wire.h` `EPointTypes``POINT_SUB_SKILL=27` / `POINT_SKILL=28` /
`POINT_HORSE_SKILL=113``UserInterface/Packet.h``uicharacter.py` `skillPageStatDict`);
`m2_client.cpp::get_points()` 新增 `skill_active` / `skill_support` / `skill_horse` 三键
(镜像增量 85 的 `bow_distance`)。② `m2_client.cpp::skill_up(int skill_id)` 护栏
`skill_id <= 0 || skill_id >= POINT_COUNT``!mtnet::bounds::skill_index(skill_id)`
= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。③ `project/ui/skill_table.gd`
`load_file``RegisterSkillDesc``LEVEL_LIMIT`(列 26/ `MAX_LEVEL`(列 27)解析
`.msk` `maxlevel` 优先;shipped 22 列数据两列都缺,逻辑为兼容第三方数据);新增
`max_level_of(id)`= `SSkillData::GetMaxLevel()``PythonSkill.cpp:1392`,缺值默认 20 =
构造默认 `byMaxLevel`:1462)、`skill_level_limit(id)`= `skillGetSkillLevelLimit`
`PythonSkill.cpp:1741` = `byLevelLimit`;本 40250 数据恒 0)、`can_level_up_skill(id, level)`
= `skillCanLevelUpSkill``PythonSkill.cpp:1833`;本 build 里前置技能 / 前置属性字段
`isRequirement` / `RequireStatDataVector` 从不被填充(构造默认,:1471)→ 1:1 化简为
`level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`)。④ `project/ui/skill_ui.gd`
`refresh()` 里逐行移植 `uicharacter.py`
`RefreshSkillPlusButtonList`:984/ `__RefreshSkillPlusButton`:937/
`CanShowPlusButton`:925)——`_can_use_skill_now()`= `client.get_skill_group() != 0`
对应 `__CanUseSkillNow` :1270)、`_stat_point_for(page)``SUPPORT``skill_support` /
`HORSE``skill_horse` / 其余→`skill_active`,三来源分开取)、`_can_show_plus(id, level,
grade, page, …)`(技能组门 → 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐 → `stat_point<=0`
隐 → `skillGrade!=0` 隐 → 马术页 `plv >= skill_level_limit(id)``level < 20` 才显 →
主动页 `table.can_level_up_skill(id, level)`)。按钮仍在 `_rebuild()` 为非被动 / 非
`CANNOT_LEVEL_UP` 技能创建,`refresh()` 只切 `visible`= `HideAllSlotButton()` +
`ShowSlotButton()`)。常量 `HIDE_SUPPORT_SKILL_POINT``uicharacter.py:21`/
`RIDING_SKILL_MAX_LEVEL=20``__RefreshSkillPlusButton` HORSE 分支硬编码)。升级结果仍
`GC_SKILL_LEVEL``skills_changed` 驱动 `refresh()`,不本地伪造。⑤ `skill_test.gd`
FakeClient 补 `get_skill_group()` / `get_points()`;扩 `§3.10` 段(`max_level_of(1)==30` /
`(121)==20``skill_level_limit(1)==0``can_level_up_skill` 未满级 / 满级 / 超级 /
`CANNOT_LEVEL_UP` / 未知 id、加号显隐:主动页可见 / `skillGrade!=0` 隐 / `skill_group==0`
隐 / 该页点数 0 隐 / 辅助页整页隐 / 马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。
C++ 21/21 + 13 个 GDScript 回归全绿。留给增量 91+:`__ChangeTargetToPickedInstance`
拾取回退、扇形/圆形多目标 fly-targeting、§3.6 飞行物实体(`CFlyingManager` / `FlyingInstance`)。
- [x] **增量 91 —— W2 §3.4 其余三种预约模式 + `__ChangeTargetToPickedInstance` + 扇形/圆形补 fly-targeting**
`project/net_play.gd` `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection`
`PythonPlayerInputMouse.cpp:287`)补齐自增量 75 留桩的三个 case:`_reserve_process_click_item()`
MODE_CLICK_ITEM:290——按 IID 查 `client.get_ground_items()` 世界坐标,`< 20cm`
`CLICK_ITEM_PICKUP_CM` 到手 → `_send_state(FUNC_WAIT)` + `pickup_item` + 解冻 + 清预约,
否则 `walk_to`;物品消失 → 清预约)、`_reserve_process_click_position()`MODE_CLICK_POSITION
:320——`_can_translate()` 未锁且 `_reserved_delay <= 0``_process` 逐帧递减 ≈
`NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` + 清预约)、
`_reserve_process_use_skill()`MODE_USE_SKILL:340——`dist_cm`(含 `_is_huge_race`
`-200cm` `HUGE_RACE_DIST_FIX_CM` 修正)`< _skill_range_reserved``set_target` +
`use_skill_hook(slot)` 成功才清预约,否则 `walk_to`;目标丢失 → 清预约)。② 预约入口
`reserve_click_item(iid)` / `reserve_click_ground(pos)``_reserved_delay = 0.1`/
`reserve_use_skill(vid, slot, range_cm)`= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`
`range > 100` 先减 10 `RESERVE_SKILL_RANGE_TRIM_CM`/ `pick_ground_item(iid)`= `__OnPressItem`——
清预约 + auto-attack,在 `CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。
`_clear_reserved()` 同时复位 `_ppos_reserved` / `_skill_range_reserved`。③ `_on_pick()`
`__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode == USE_SKILL && _dash_active`
时只改 `_vid_reserved` 不选中;战斗目标改走 `_change_target_to_picked_instance(vid)`=
`__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定
PC/怪,否则 `set_target(0)` + `clear_target`NPC/石头/warp 的交互仍由 `_on_pick` 先分流到
`click_npc`)。`_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用
`proto.mob(race)``size >= MOBSIZE_HUGE(3)``rank >= BOSS(4)` 近似,查不到 → false
(文档化的 seam)。④ 扇形/圆形补 fly-targeting`PythonPlayerSkill.cpp:680-746`
`if (dwTargetMaxCount>0 …)` 块):`send_fly_targeting(target_vid, range_cm, max_count, shape)`——
主目标的 `CG_FLY_TARGETING` 已由 `M2Client::use_skill` 发过,这里只补附加目标——
`_instances_in_fan_range()`= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`
先按圆形距离取,再按随距离收窄的半角 `fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN`
过滤,常量 `FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`/
`_instances_in_circle_range()`(:243,无方位过滤)内的其它可攻击敌人经
`client.add_fly_targeting(vid, sx, sy)`= `SendAddFlyTargetingPacket`)补齐;名额不足时
`_random_pos_in_fan_range()`= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10°
`FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→
`add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。
`project/ui/skill_table.gd``_load_msk``.msk` `TargetCountFormula` / `Range` 解析
`CMotionSkillData``strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)`
= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint`
代入后 `floor`)、`target_range(id)`= `__GetSkillTargetRange` 的基础项 `PythonPlayerSkill.cpp:391`
主角弓距加成留 `net_play` 叠加)、`fly_shape(id)``FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0
对齐 `NetPlay.FlyShape`)。⑥ `project/ui/quickbar.gd``activate()``client.use_skill`
返回后,若 `target_vid != 0` 且技能为 `FAN_RANGE` / `CIRCLE_RANGE` → 调
`net_play.send_fly_targeting(target_vid, target_range, target_count(sid, lvl), fly_shape(sid))`
新增 `_skill_level(sid)`(扫 `client.get_skills()`)、`activate_reserved(global_slot)`
`use_skill_hook` 回调,MODE_USE_SKILL 进射程后执行槽位施法)。⑦ `game_scene.gd`
`net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。⑧ 测试:
`netplay_test.gd` FakeClient 补 `get_entities` / `pickup_item` / `add_fly_targeting` /
`get_ground_items` / `get_skills`,新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、
物品消失→清;CLICK_POSITION 延时门 + `walk_to`USE_SKILL `range>100→-10`、射程外`walk_to`
射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 /
怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、
`max_count<=1` / `SINGLE` → 0 附加);`skill_test.gd` 补 skill 47gwangyeokFAN + Range 2500 +
`target_count(lv1)=2` / `(lv20)=5`/ skill 91bipabuFAN + Range 1800 + 无公式→0/
skill 1`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿(`eterngrn_polish_test`
`motion_event: sound` 仍是既有异步竞态,退出码 0)。留给增量 92+:§3.6 飞行物实体
`CFlyingManager` / `FlyingInstance`)生成 / 命中、`__CanShot` 弓箭合法性。
- [x] **增量 92 —— W2 §3.6 飞行物实体 `CFlyingManager` / `CFlyingInstance` 生成 / 命中 + `__CanShot` 弓箭合法性**
① 新增 `project/fly_object.gd``FlyData` / `FlyInstance` / `FlyManager`,对齐
`GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.{h,cpp}`)。
`FlyData` 字段取 `CFlyingData::__Initialize()` 默认值(米制换算):`init_vel` /
`cone_angle` / `roll_angle` / `gravity` / `accel` / `flat_range`(=`m_fRange`) /
`bomb_range`(=`m_fBombRange`) / `pierce_count` / `spreading` / `maintain_parallel` /
`is_homing` / `homing_start_time` / `homing_max_angle` / `hit_on_background`
`FlyInstance.create(d, start, target, can_attack)``CFlyingInstance::Create` +
`__SetDataPointer` + `__SetTargetDirection``target``Node3D`(对象)/ `Vector3`
(位置)/ `Dictionary{vid,pos,node}` 分派;`dir = normalize(target_pos - pos)`
`vel = dir * init_vel``accel``Quaternion((0,-1,0), dir)` 旋到航向。
`FlyInstance.update(dt)` 逐帧对齐 `CFlyingInstance::Update`homing`is_homing &&
_elapsed > homing_start_time && 对象目标``_adjust_homing()` 单步限幅
`deg_to_rad(homing_max_angle)`)→ `last_pos = pos``vel += accel*dt`
`vel.y += gravity*dt`seam:参考端重力作用于 `.z`=上,本 POC 映射到 `-Y`=下)→
`movement = vel*dt``move_dist = |movement|``remain_range -= move_dist`
`pos += movement``remain_range < 0``out_of_range` + `_explode(false)`(不 `__Bomb`
返 false → 对象目标线段判定 `_seg_point_sq_dist(pos, last_pos, target_pos) < bomb_range²`
`_seg_point_sq_dist` 1:1 移植 `GrpMath.h:80` `square_distance_between_linesegment_and_point`
`target_hitted = true``can_attack``shoot_damage` 信号(≈ `pVictim->OnShootDamage()`);
`at_target``pierce > 0``pierce-- + bomb` 继续飞,否则 `_explode()` 返 false →
位置目标同线段判定 → `at_position` + `_explode()``hit_on_background &&
world.sample_height(x, z) > pos.y`(≈ `GetTerrainHeight(x,-y) > z`)→ `at_background` +
`_explode()``_explode(bomb=true)``if not alive: return; alive = false; if bomb: bomb`
seam:怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素判定未移植(客户端仅做命中"时机",
伤害由服务端裁决);`.fly` 资源加载器缺失 → `net_world._on_fly` 用合成箭参数
`init_vel = 45``flat_range = max(dist*1.5, 30)``bomb_range = 0.6`)。
`project/net_world.gd``setup()``FlyManager` 子节点并 `setup(parent, world)`
`exploded` → 新增 `signal fly_exploded(world_pos, cause)`;重写 `_on_fly(type, start_vid,
end_vid)`——原 tween「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false,
FlyData, type)``GC_CREATE_FLY` 在本 40250 客户端无包处理器,仅 `NetStream.cpp` 头串索引 70
实际驱动路径是 POC 自有的 `fly_cue`——保留 shooter 的 `fly_target_queue` meta 消费)。
`project/net_play.gd`:新增 `_can_shot(target_vid := -1) -> String``__CanShot`
本发布源码树中无定义——防御式重建,参考 `IFlyEventHandler::OnNoTarget/OnNoArrow`
`FlyHandler.h``NEW_Attack` `InstanceBaseBattle.cpp:355` 的 IsDead/IsStun/IsKnockDown/
IsUsingSkill 门、`__GetBowRange` `InstanceBase.cpp:694``EMPTY_ARROW` 文案):
非弓模式 → `"NOT_BOW_MODE"``_arrow_count == 0``"EMPTY_ARROW"``-1` =未跟踪/无限,放行);
无目标(`_target_vid` / `_vid_reserved` 均 ≤ 0)→ `"NO_TARGET"`;目标实体已知且不可攻击 /
已死 → `"NO_TARGET"``te.is_empty()` 不拒——headless 容差,与 `_send_fly_target`「有节点即可」一致);
目标世界距离 `* CM > _bow_range_cm()``"OUT_OF_RANGE"`;否则 `""``_emit_swing` 弓分支加门:
`_can_shot()` 非空 → `_emit_cannot(why)`(走 `OnNoArrow` / `OnNoTarget` 文案),不发
`CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作照常(seam:参考端整挥击中止)。
④ 新增 `project/fly_test.gd`(第 14 个 GDScript 回归):直线命中对象目标 / 超程不 `__Bomb` /
`can_attack``shoot_damage(vid)` / 穿透后存活续飞再超程 / 位置目标 / 触地 /
`_can_shot` 六分支(非弓 / 无箭 / 无目标 / 在程 OK / 超弓程 / 死目标)/ `_seg_point_sq_dist`
三段(垂足在段内 / 越 p1 / 越 p2)。C++ 21/21 + 14 个 GDScript 回归全绿。
留给增量 93+:§3.1 Actor-Actor 碰撞(`TestActorCollision` / `BlockMovement`+ 坐骑
`MODE_HORSE*`(依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。
- [x] **增量 93 —— W2 §3.1 收尾 / Actor-Actor 碰撞 + 坐骑转向速度**
`project/player_controller.gd` 新增 `_actor_blocked(next_pos)`——`CActorInstance::TestActorCollision`
`ActorInstanceCollisionDetection.cpp`+ `CInstanceBase::CheckAdvancing`
`InstanceBaseBattle.cpp:469`)的近似:距离门 `ACTOR_COLLIDE_MAX_DIST_M = 8.0 m`
(≈ `LengthSq(victim - self) > 800²` 直接不检)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`
自身 + 对方相加 = `combined`;参考用 race 碰撞球,POC 无骨骼碰撞数据 → 固定半径近似)→
「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` 末尾
`dist(mainPos, victimPos) <= dist(mainLastPos, victimPos)``return TRUE`;远离时放行 →
能从重叠里走出来)。候选=`pickables`(每个已生成的远端 NPC / 怪 / 他人节点),
`dead` meta 为真跳过(≈ `rVictim.IsDead()`)。
`_process` 平移分支改为逐轴门控:`block_x = _actor_blocked(仅动 X) or _blocked(...)`
`block_z` 同理——单个 Actor 时逐轴放行=滑移(≈ `AdjustDynamicCollisionMovement`,该函数不在
发布源码树),两轴都挡=停在上一帧位(≈ `uCollisionCount==2` / 调整后仍撞 → `BlockMovement()`
`__InitializeMovement`);随后既有 `disp` 判定把顶人磨蹭切回 `_is_going = false` + `wait`
`skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`
§3.2 网络状态包驱动的移动整段跳过)——`net_play` 在观战开关(`_on_observer_mode` + `setup`
`_observer_mode` 置位。
④ 坐骑转向速度:`player_controller` 新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG =
1200``c_fDefaultRotationSpeed`+ `_turn_toward(target_yaw, dt)`——`SetAdvancingRotation`
的逐帧限幅收敛(移动方向不受限,仅朝向变慢),替换原来两处 `player.rotation.y = atan2(...)`
瞬转。`net_play._refresh_motion_mode()``mounted` 时把 `rotation_speed_deg` 切到
`ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse`
`SetRotationSpeed(c_fDefaultHorseRotationSpeed)``InstanceBase.cpp:954`),下马恢复 1200。
坐骑动作模式(`MODE_HORSE*`)已由增量 84 的 `motion_mode_for` + `mount_changed``_refresh_motion_mode`
覆盖,本增量不重复。
`project/player_move_test.gd` 扩 3 组(原 5 组 → 第 6/6b/6c/7):正前方 Actor 挡下且
`is_going` 归零、`dead` Actor 不挡、`skip_actor_collision` 直穿、坐骑单帧转向 ≤ 300 deg/s
且仍朝 Dst 平移;并在开头 `await process_frame`(对齐 fly_test,避免 `!is_inside_tree()`+
清第 5 组遗留预约点。C++ 21/21 + 14 个 GDScript 回归全绿。
seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`);`AdjustDynamicCollisionMovement`
的精确滑移向量、门 / Actor 复合的 `uCollisionCount==2` 判定用逐轴近似代替;`CanSkipCollision`
目前只由观战驱动,§3.2 网络状态包路径的 `EnableSkipCollision` 尚未接到本地预测玩家。
留给增量 94+:§3.2 / §3.5 收尾(多为待真实服务器);转 M2(W3 玩法)。
- [x] **增量 94 —— W3→M2 §5.4 PVP / PK / 决斗可攻击判据(`IsAttackableInstance` 1:1**
① 新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`)——
`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`
`REF/UserInterface/InstanceBase.cpp:2147`):主角 + 观战 → false;自打自 → false
Stonevictim.IsPC → true/ PC / Enemyvictim.IsPC || IsBuilding → true/ Poly
victim.IsPC || IsEnemy → true)四分支。PC 分支内:victim.IsStone → truevictim.IsPC 时
决斗三态(`DUEL_CANNOTATTACK` → false`DUEL_START``__FindDUELKey` ? true : false)、
`PK_MODE_GUILD` && 同公会 → false、`victim.IsKiller()` && !同队 → true、
`PK_MODE_PROTECT != GetPKMode()``PK_MODE_FREE`victim 非 PROTECT && !同队 → true/
`PK_MODE_GUILD`victim 非 PROTECT && !同队 && 公会不同 → true)、`IsSameEmpire(victim)`
`IsPVPInstance` → true / `PK_MODE_REVENGE` && !同队 && `IsConflictAlignmentInstance` → true、
否则(异帝国)→ true;随后 victim.IsEnemy → true、victim.IsWoodenDoor → true。
② 判据 helper 全部 1:1`alignment_grade`:502 的 12000/8000/4000/1000/0/-4000/-8000/-12000
九档)、`alignment_type`:524grade 0-3 白 / 4 中 / 5-8 黑)、`is_conflict_alignment_instance`
:2116victim PROTECT → false;白 / 中对黑 → true;黑对非黑 → true)、`_is_same_empire`
:470victim.empire==0 || GM || 同 empire)、`_is_pvp_instance``InstanceBaseEffect.cpp:505`
`GetDuelMode()` || `__FindPVPKey` || `__FindGVGKey`)、`_find_duel_key`:495)、`_is_same_party`
`PythonPlayer.cpp:1445`)、`_is_wooden_door`:2390vnum 13000 / 30111..30119)。
`_is_pc``CActorInstance::IsPC``ActorInstance.cpp:282`)只看 `TYPE_PC`(变身玩家仍是 PC
`IsPoly` 另经 race 越界判真);`_is_enemy``TYPE_ENEMY`;独立 `TYPE_POLY` 才走 Poly 分支。
`net_play.gd``_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼
`self_e``_main_entity()` 快照 + `kind`/ `victim_e`+ `kind`/ `_attack_ctx()`,委托
`EntityRules.is_attackable_instance``_main_entity()` 为空(headless)退化到旧近似。
新增 `_attack_ctx()``client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
`get_party()` 装配 ctxobserver / main_vid / pk_mode / duel_mode / duel_opponents /
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
`_can_shot``_hit_geometry`)无签名变化。
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical25 组断言);`combat_fx_test` /
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type``_entity_kind` 归 PC
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
C++ 21/21 + 15 个 GDScript 回归全绿。
seam`__FindPVPKey``GC_PVP` 关系表 setter/ `__FindGVGKey`(公会战对局 setter/
`IsGameMaster` 标记 / `IsBuilding`·`IsWall` 的 race 白名单(源码未随发布)/ m2dev 后端的
`IsKiller` 状态位 / `_entity_kind` 无法分类时怪被当和平 PC(生产 mob_proto 恒加载);
名字颜色 §8.8、`IsInSafe``ATTRIBUTE_BANPK` 地形属性仍未接。
留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt。
- [x] **增量 95 —— W3→M2 §5.1 Quest EventManager`CPythonEventManager` 1:1**
`project/ui/quest_dialog.gd` 逐条重写脚本解析:`EVENT_TYPE_MAP`42 条,键即 tag+
`enum { EVT_* }`(顺序照抄 `REF/UserInterface/PythonEventManager.h:35``EEventType`+
`_get_script_event_index()``GetScriptEventIndex` `PythonEventManager.cpp:1093`——前导 `/` 剥离为
`EVENT_POSITION_END`,未知命令 `ok=false` → 跳过、不进 switch)。
`class EventSet``SEventSet` `PythonEventManager.h:98`,字段名 1:1`ix/iy/iWidth/iyLocal/
isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`+ `init_event_set()``__InitEventSet` `:72`
常量 `c_lNormal_Waiting_Time=10`/`iRestrictedCharacterCount=30`/`c_fLine_Temp=16`/
`BOX_VISIBLE_LINE_COUNT=5`)、`insert_line()``__InsertLine` `:976`,推行 + `iyLocal += 16`)、
`clear_line()``ClearLine` `:953`)、`body_text()`
`_process_event_set(es, cmd)` = `ProcessEventSet()` `:377``switch``case` 对拷:
`DELAY`/`COLOR`/`COLOR256` 的 START vs `/END` 分支、`LETTER`/`ITEM`/`MOB`
`iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE`
`iAdjustLine += 2``WAIT``iyLocal=0; isLock=true`)、`CONFIRM_WAIT``isConfirmWait=true` +
`insert_line(center)`)、`END_CONFIRM_WAIT`(清 `isConfirmWait`)、`FADE_*`/`WHITE_*`
`isWaitFlag=true`)、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**(引 `:562/566`)、`DUNGEON_RESULT`
9 参、`QUESTBUTTON`/`QUESTBUTTON_CLOSE``SET_CAMERA``_camera_setting` 九参 = `:48`
`GetCameraSettingFromArgList`/`BLEND_CAMERA`+`blendtime`/`RESTORE_CAMERA`
`_tokenize()` 拆混排文本 / `[tag ...]`;裸网络文本折成合成 `LETTER``is_raw=true` → 跳过 30 字
断行,reference 脚本只有显式 `[LETTER]`)。arg 提取 `_tag_kv()``key(v)`/`key("v")`/`key;v`+
`_arg_s/_arg_i/_arg_f`(keyword 优先、位置回退,兼容旧测试的 `value;255``value(1)` 写法)。
`parse_script()` 返回字典在原键之外新增 `insert_images / dungeon_result / adjust_line /
waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`;首命令
`RUN_CINEMA` → 记 `cinema_file` + 发 `cinema_requested` 信号。
⑥ 自检 `project/quest_event_test.gd`(新增,规范测试 #1623 组断言);既有
`quest_test.gd` / `select_item_ui_test.gd` 仍绿。C++ 21/21 + 18 个 GDScript 回归全绿。
seam:逐帧 `UpdateEventSet` `:312` 的时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 /
`isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip`/`EndEventProcess``iConfirmEndTime`
`timeGetTime``.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`NEXT` vs `DONE` 按钮文案区分 +
`DoneEvent()` 回调、`camera_event_requested` 与 §7.6 相机模式对接——一次性跑完 token,
`isLock`/`isWaitFlag` 仅作状态保留并透出。
留给增量 96+:§5.2 任务日志、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口。
- [x] **增量 96 —— W3→M2 §4.1 wear 槽位版图 + costume/ring/belt 发包坐标(`GameType.h` 1:1**
`project/ui/inventory_ui.gd._to_wire()``REF/UserInterface/GameType.h:40-112` 分流——
`c_Equipment_Start+0..10``EWearPositions` BODY..SHIELD)走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell
`ENABLE_NEW_EQUIPMENT_SYSTEM``c_Costume_Slot_Start(90+19)` / `c_Costume_Slot_Hair(90+20)` /
`c_Equipment_Ring1(90+21)` / `c_Equipment_Ring2(90+22)` / `c_Equipment_Belt(90+23)` 在客户端
没有独立窗,继续用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。这修掉了 `inventory_test.gd`
`_to_wire(113) == [1, 113]` 一直红的断言(旧实现返回 `[2, 23]`)。
② 新增常量 `WEAR_MAX_NUM=11` / `WEAR_COSTUME_BODY=19` / `WEAR_COSTUME_HAIR=20` / `WEAR_RING1=21` /
`WEAR_RING2=22` / `WEAR_BELT=23`(原值保持)/ `NEW_EQUIP_WEAR_START=19` / `WINDOW_EQUIPMENT=2`
全部 1:1 自 `GameType.h``c_Equipment_*``ItemData.h``EWearPositions`
`_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 +
16 格腰带背包」(`EWearPositions` 中 11..18 无位置);`refresh()``_cells.get(EQUIP_BASE+i)`
对空槽自动跳过,无需改。头注释同步为 `_to_wire()` 的分流说明。
`inventory_test.gd` 增 7 条 wire 坐标断言(`_to_wire(90)→[2,0]``(100)→[2,10]`
`(109)→[1,109]``(113)→[1,113]`)。`inventory_ui_test.gd``move_to(5,91) → [1,5,2,1,1]`
锁定基础 wear 仍走 window=2)保持绿。C++ 21/21`net.loopback_flow` 一次 localhost bind 抖动,
重跑即过)+ 55 个 GDScript 回归全绿。
seam:修改 2`antiflag→wearable_flag→limit` 装备校验顺序 + `CANNOT_EQUIP_*` 文案)——
40250 客户端本身不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag`/`GetLimit`
仲裁在服务器 `char_item.cpp`),按 §3.9 `__CanShot` 方式防御性重建、tie-break 顺序留待接服务器;
修改 4(穿 costume 隐藏本体 armor / 穿头盔隐藏发型,`InstanceBase.cpp` `ChangeArmor`/`SetHair`);
修改 5(远端角色 `armor/weapon/hair` 三字段接换模,`net_world.gd` 调用序已在 §2.5 对齐)。
留给增量 97+:§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
§4.2 拖放鼠标物品态。
- [x] **增量 97 —— W3→M2 §4.1 修改 2`CanEquipNow` 前置门(antiflag→wearable_flag→limit 1:1**
① 新增 `project/equip_rules.gd``extends RefCounted` 纯校验器),枚举 1:1 自
`REF/GameLib/ItemData.h`——`EItemAntiFlag`classic 排列:`FEMALE=1<<0``SHAMAN=1<<5`
帝国位 `EMPIRE_A/B/R = 1<<9..1<<11`)、`EItemWearableFlag``BODY=1<<0``ARROW=1<<9`)、
`EWearPositions`0..10+ `GameType.h` 新装备槽 19..23、`ELimitTypes``LIMIT_LEVEL=1`
`LIMIT_CON=5``PCBANG=6``REAL_TIME=7`)、`EItemType``WEAPON=1`/`ARMOR=2`/`COSTUME=28`/
`RING=33`/`BELT=34`+ `EArmorSubTypes`/`ECostumeSubTypes`
`can_equip(item, wear, ctx) -> {ok, code, need}`,语义顺序对齐服务器 `char_item.cpp::CanEquipNow`
目标槽解析(`wear<0``default_wear_for()` 按 type/sub)→ anti-flag 性别(`ANTI_SEX`)→
anti-flag 职业(`ANTI_JOB``JOB_ANTIFLAG[race%4]`)→ anti-flag 帝国(`ANTI_EMPIRE`
`EMPIRE_ANTIFLAG[GC_EMPIRE 1/2/3]`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按
`wear_flags & wear_to_wearable(wear)``NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`
`ctx.level < v``LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判,`< 0` 跳过)。
`SEX_BY_RACE`/`job_of_race`/`sex_of_race` 表 1:1 自 `MAIN_RACE_*`
`inventory_ui.gd``use()` 对「常规背包格 + 可穿戴物品」跑前置门,拦下则 `_set_hint()` +
`signal equip_rejected(code, need)` + 返回 false(不发 `use_item`);新增公开
`can_equip_item(vnum, wear)``_equip_ctx()`(从 `get_points().level` + `get_entities()` 主角
`race`/`empire`/`level` 组装;`st/dx/ht/iq` 未由 M2Client 按名暴露 → 传 `-1` = seam)、
`_equip_reject_hint()``ANTI_SEX`/`ANTI_JOB`/`ANTI_EMPIRE`/`NOT_WEARABLE_HERE`/`LIMIT_*` → 中文文案)。
④ 新增 canonical 测试 #17 `project/equip_rules_test.gd`33 断言:race 表、`default_wear_for`
各 type/sub、`slot_accepts`、anti-flag 性别/职业/帝国、`NOT_WEARABLE_HERE``LIMIT_LEVEL/STR/CON`
含 need 值 + 属性 `-1` 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 `NO_ITEM`);
`inventory_ui_test.gd``FakeProto.item()``sub_type/anti_flags/wear_flags/limits`
`iv.use(5)` 仍过门。C++ 21/21 + 56 个 GDScript 回归全绿。
seam:精确 tie-break 顺序、已穿同槽替换、`LIMIT_PCBANG`/`LIMIT_REAL_TIME*`、属性 4 项按名读取,
均留待接服务器 `char_item.cpp` 比对。
留给增量 98+:§4.1 修改 4(部位隐藏 / 遮挡)、修改 5(远端换装接字段)——均为 render 侧;
§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
- [x] **增量 98 —— W3→M2 §4.1 修改 4:变身 / 时装部位遮挡(`InstanceBase.cpp` 1:1+ 修改 5 字段序确认**
① 新增 `project/part_hiding.gd``extends RefCounted` 纯函数),常量 1:1 自
`REF/UserInterface/InstanceBase*.cpp``POLY_SHAPE=0``IsPoly()``SetShape(0)`)、
`ANIMAL_WEAR_SHAPES=[100..103]``__IsShapeAnimalWear``SetWeapon`(:2747) / `SetParts`
返回 false)、`WEDDING_DRESS_SHAPE=201``IsWearingDress``m_eShape==201`)、
`BOUQUET_VNUMS=[50201..50204]``c_iBouquets``__IsChangableWeapon`:901 除捧花外一律 false)。
函数:`is_animal_wear` / `is_wearing_dress` / `is_bouquet` / `is_changable_weapon`
(婚纱→仅捧花可换)/ `weapon_hidden`(变身 ∨ 动物时装 ∨ 婚纱且非捧花)/ `hair_hidden`
(变身 ∨ 动物时装;婚纱不影响头发)/ `body_shape`(变身→0,动物时装 100-103 保留用于出时装体)/
`effective_weapon`(被隐藏→0)。
`equip_model.gd``refresh()` 刷新顺序改为对齐 `ChangeArmor`(身体 → 头发 → 武器 → 盾),
解出身体 shape`armor_shape_of` = `__ArmorVnumToShape``values[3]`/`=vnum`)后过
`PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2` + `shield_gr2` + `hair_gr2`;婚纱非捧花隐藏
武器 + 盾、头发保留;脱下时 `head_key``-1` 复位触发发型重挂(`_last_head_vnum` 哨兵改 `-2`)。
新增 `_is_poly()` seam(40250 实体表暂无变身字段,恒 false)。
`net_world._apply_field_updates()`:确认 armor/weapon/hair 字段序已 1:1 于
`NetworkActorManager.cpp:473``ChangeArmor→ChangeWeapon→ChangeHair→…`,增量 46 落地),
补注变身 / 时装遮挡统一走模型侧 `part_hiding.gd`
④ 新增 canonical 测试 #18 `project/part_hiding_test.gd`45 断言:animal-wear 全集、
dress / bouquet 判定、`is_changable_weapon` 婚纱门、`weapon_hidden` / `hair_hidden` /
`body_shape` / `effective_weapon` 各分支);`equip_model_test.gd` 补动物时装遮挡武器/盾/发、
脱时装恢复、婚纱遮武器保发、婚纱换捧花恢复武器 7 段。C++ 21/21 + 57 个 GDScript 回归全绿。
seam:变身(`_is_poly` 恒 false,等 `GC_CHARACTER_UPDATE` 带变身 race);远端 PC 真模型工厂已由
增量 99 首版落地,但所有职业 / race 的资源可用性、精确体型 / 变身与真服换装仍未验收。
留给增量 108+:§5.3 fishing 鱼获 / `GC_ITEM_*` 事务、DungeonBlock `.mdatr` collision / 副本入口结算、mount UI、observer 自由视角 / 生命周期、§4.2 拖放鼠标物品态、
§4.3 tooltip 特殊分支 + 装备对比;§5.2 的五行 / 滚动 / 任务行事件已由增量 101 落地。
- [x] **增量 104 —— W3→M2 §5.3 / 钓鱼反馈消息与 item proto 分支**:按
`40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860`
对齐 `GC_FISHING`:SUCCESS 只负责动画,不直接写“鱼钩命中”;FAIL / info=0 / fish-vs-nonfish
分别走 `FISHING_FAILURE` / `FISHING_UNKNOWN` / `FISHING_NOTIFY1/2` / `FISHING_SUCCESS1/2`
unknown item proto 静默忽略。`ChatUI` 加载 `locale_game.txt``game_scene.gd` 注入 item proto
和 wrong-place 回调,`chat_test.gd``gamescene_test.gd` 通过。真正背包数量仍由独立
`GC_ITEM_*` 包驱动,本批次不伪造落袋。
- [x] **增量 105 —— W3→M2 §5.3 / 坐骑攻击等级门**:按
`40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216``SHORSE::GetLevel` 映射、
`InstanceBaseBattle.cpp:623-628``CanAttackHorseLevel``PythonPlayerInput.cpp:737-764`
`__CanAttack`,接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门;
自动攻击改用实体 `mount_vnum` 判定。`netplay_test.gd` PASS。坐骑模型 / `MODE_HORSE*`
动作 / 移速和真服坐骑数据仍待验收。
- [x] **增量 106 —— W3→M2 §5.3 / 观战者小地图插值**:按
`40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180` 对齐
`CPythonMiniMap::AddObserver/MoveObserver/RemoveObserver`,为 `GC_OBSERVER_ADD/REMOVE/MOVE`
建立独立轨迹并以 1 秒线性插值;初始 `get_observers()` 快照可播种,`world_reset` 清空轨迹。
`p9_test.gd` 覆盖 ADD / MOVE / REMOVE / reset 并 PASS。观战自由视角和真服完整生命周期仍待验收。
- [x] **增量 107 —— W3→M2 §5.3 / 副本目的地状态与 HUD 罗盘**:按
`40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901`
`PythonPlayer.cpp:147-167,1255-1285` 接入 `GC_DUNGEON` 的目的地分支;保存全局厘米坐标,
收包立即发出目标提示,按参考端 Manhattan `<10000cm` 清除目标,未到达时每 20 秒再次提示,
并在 `world_reset` 清理。`TIME_ATTACK_START` 保持参考端空分支,不添加本地计时器。
`project/dungeon_state.gd``project/hud.gd``project/game_scene.gd`
`project/dungeon_state_test.gd` 已接线,副本状态和场景回归 PASSDungeonBlock `.mdatr`
碰撞、真实副本入口 / 结算窗口和真服验收仍待完成。
仍未完成:
- [ ] 真实 TCP E2E 证明两次游戏连接,以及第二次连接确实命中 `GC_LOGIN_SUCCESS4` 的槽地址;
- [ ] 移除 `MT_CLASSIC_ENTER_DELAY` 定时发送,改为 Loading/游戏窗口完成初始化后的明确发送点;
- [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景
节点无残留;受限环境下 localhost bind 子测试自动 skip。
- [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。
- [ ] locale 代码页转码(CP949 / CP1252)及真实 40250 服务端对 `0xf1` / 可执行文件名分支的接受情况;
代码路径已接线,但仍属待真实验收。
- [ ] AppLifecycle 的真实 Android/iOS 前后台、内存告警和连接断开重连验收;离线单一属主回归已通过。
本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 40250 服务端证据的项目仍保持“未验证”。
## 并行开发与进度同步协议
本细则按工作流并行执行,不按 §0–§10 的编号逐条串行实现。章节号用于定位差距,工作流用于分配
代码边界;依赖关系通过公共接口和集成门禁处理。
### 工作流边界
| 工作流 | 负责条目 | 主要修改范围 | 不得越界修改 |
|---|---|---|---|
| W0 公共基础 / 集成 | §0、共享会话、实体接口、场景生命周期、文档 | `extension/src/net/m2_client.*``entity_store.*``classic_session.*``project/game_scene.gd`、两份差距文档 | 其他工作流不得各自复制网络核心或场景 reset;接口改动必须回 W0 |
| W1 协议 / 账号 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | `extension/src/net/classic/*``extension/tests/net_classic_*`、登录 / 选人专属脚本 | 不直接重写 W0 的公共信号和 EntityStore 生命周期 |
| W2 实体 / 世界 / 战斗 | §2–§3、移动、动作、技能、目标和换图后的实体表现 | `project/net_world.gd``net_play.gd``player_controller.gd``project/world/*`、动画 / 战斗扩展和测试 | 不把 UI 状态写入协议层;共享 reset 只通过 W0 接口接入 |
| W3 玩法 UI / 输入 | §4–§5、§8、背包、任务、社交、IME、键位 | `project/ui/*``hud.gd``login.gd``charselect.gd`、UI 测试和本地化资源 | 不修改 Classic parser;缺少真服时使用 fixture,并注明未验证 |
| W4 渲染 / 资产 / 音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM、生命周期表现 | `extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*``project/fx/*`、音频 / 渲染专属脚本和测试 | 不把渲染 fallback 塞进网络状态机;场景钩子由 W0 提供 |
| W5 打包 / 发布 / 验收 | §10、Mac App 自包含资源、架构、签名、发布配置和验收矩阵 | `project/export_presets.cfg`、打包 / 发布脚本、`extension/CMakeLists.txt`、发布验收文档 | 不以“能启动”代替资源、架构、签名和真实功能验收 |
### 执行波次
1. **G0 公共基础冻结**W0 维护 `enter_game``warp``world_reset`、实体快照、UI 信号和
测试 fixture 的契约,并在本文登记影响文件。
2. **并行波 A**:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug
构建完成自己的回归,不等待其他工作流的全部功能。
3. **G1 接口集成**:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突
只在这一层解决,不在各工作流中反复复制修复。
4. **并行波 B**:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实
服务端、双账号和 Mac App 验收按矩阵分别记录。
5. **G2 发布门禁**:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全,
对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的 `skip` 只能记为未验证。
### 每批次必须同步文档
每个开发批次都要在**开始、完成、阻塞、重新验证**时更新本文和 `CLIENT-GAP.md`。代码提交而没有
对应文档记录时,不得把条目标成完成。
批次记录至少包含:
- `W0W5` 工作流、§ 条目、状态(⬜ 未开始 / 🟡 开发中 / ✅ 完成 / ⚠️ 阻塞 / 🔎 待真实验收);
- 参考文件与行号、修改文件、接口 / 数据结构变化;
- 自动化测试命令和结果,包含被 `skip` 的测试及原因;
- 真服 / 双账号 / Mac App 验收证据,或明确列出尚缺证据;
- 风险、依赖、文件冲突和下一批次。
同步职责固定为:`CLIENT-GAP.md` 更新总览表与变更记录,本文更新对应条目的实现细节、测试证据和
实施进度;W0 集成维护者统一合并文档,避免多个工作流覆盖彼此的状态。每次合并后应检查两份文档的
状态、数字、当前路径和“已完成 / 未验证”表述一致。
---
## 0. 先决:参考端的整体骨架
改任何一条之前,必须先认这套骨架,否则单条修改会互相打架。
### 0.1 连接与阶段(唯一真相)
参考客户端**只有两条 TCP**`CAccountConnector`(认证)与 `CPythonNetworkStream`(游戏),
且后者**在一次登录里会连接两次**。
```
1) CAccountConnector::Connect(gameAddr, gamePort, authAddr, authPort) REF/UserInterface/AccountConnector.cpp:37
└ 记住 game 地址,连的是 auth 地址
2) auth 上发 CG_LOGIN / CG_LOGIN3 → GC_AUTH_SUCCESS{bResult, dwLoginKey}
└ __AuthState_RecvAuthSuccess() REF/UserInterface/AccountConnector.cpp:451
bResult==0 → Python OnLoginFailure("BESAMEKEY")
bResult!=0 → rkNet.SetLoginKey(dwLoginKey); rkNet.Connect(gameAddr, gamePort);
随后 AccountConnector 立刻 Disconnect()
3) 游戏连接 #1HandShake → Login → Select
Login 阶段:m_dwLoginKey != 0 → SendLoginPacketNew() = TPacketCGLogin2{header=109,
login_key, name[ID_MAX_NUM+1], adwClientKey[4]} REF/.../PythonNetworkStreamPhaseLogin.cpp:274
Select 阶段:GC_LOGIN_SUCCESS3/4 填 m_akSimplePlayerInfo[4](含每槽 lAddr / wPort
4) 点“开始”:introselect.py:754 `net.DirectEnter(chrSlot)`
→ CPythonNetworkStream::ConnectGameServer(iChrSlot) REF/.../PythonNetworkStream.cpp:469
__DirectEnterMode_Set(iChrSlot)
CNetworkStream::Connect((DWORD)slot.lAddr, slot.wPort) ← **用角色槽里的地址,不是配置地址**
5) 游戏连接 #2DirectEnterMode):
HandShake:不通知 Python REF/.../PythonNetworkStreamPhaseHandShake.cpp:114
Login :同样 SendLoginPacketNew,不通知 Python,不清角色数据
Select **直接** PyCall SetLoadingPhase() REF/.../PythonNetworkStreamPhaseSelect.cpp:28
Loading __DirectEnterMode_Initialize() REF/.../PythonNetworkStreamPhaseLoading.cpp:190
```
阶段切换只由 `GC_PHASE`(0xfd) 驱动,`TPacketGCPhase.phase ∈ {PHASE_CLOSE, PHASE_HANDSHAKE,
PHASE_LOGIN, PHASE_SELECT, PHASE_LOADING, PHASE_GAME, PHASE_DEAD}`
`REF/UserInterface/Packet.h:1124`,处理见 `PythonNetworkStream.cpp:591 RecvPhasePacket`)。
### 0.2 实体两层模型(唯一真相)
参考端**数据层**与**表现层**是分开的,这是 §2 全部条目的根因:
| 层 | 参考类 | 生命周期 |
|---|---|---|
| 数据 | `CNetworkActorManager::m_kNetActorDict``std::map<VID, SNetworkActorData>` | 只由 `GC_CHARACTER_ADD/DEL` 增删,**不随可见性变化** |
| 表现 | `CPythonCharacterManager``CInstanceBase` | 按距离 `CHAR_STAGE_VIEW_BOUND = 200*100` cm 懒创建 / 淡出删除 |
- 每帧 `CNetworkActorManager::__OLD_Update()``REF/UserInterface/NetworkActorManager.cpp:157`
遍历 `m_kNetActorDict`,插值位置;发现「有数据但没实例」且 `__IsVisibleActor()` 为真时才建实例。
- `CPythonCharacterManager::Update()``REF/.../PythonCharacterManager.cpp:182`)把
`距离 > CHAR_STAGE_VIEW_BOUND + 10` 的实例 `__DeleteBlendOutInstance` 掉,**数据仍在**。
- `__IsVisibleActor()``NetworkActorManager.cpp:295`):主角 VID / `AFFECT_SHOW_ALWAYS` /
距离内 / `IsWall(race)` 四者任一为真。
### 0.3 PC/NPC 的两包合一(§2.2 的根因)
`GC_CHARACTER_ADD``TYPE_PC` / `TYPE_NPC` **不立刻创建**,而是暂存到
`static SNetworkActorData s_kNetActorData`;等 `GC_CHAR_ADDITIONAL_INFO` 同 VID 到达后合并再
`AppendActor``REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:83``:140`):
- `RecvCharacterAppendPacket` 显式把 `alignment / pkMode / guild / empire / armor / weapon /
hair / mount / level` **全部置 0**,它们只能来自 additional info。
- 非 PC/NPC(怪 / 石 / 门 / 器物)立即 `AppendActor`,名字取
`CPythonNonPlayer::GetName(race)`。
- `IsInvisibleRace(20025 / 20038 / 20039)` → 直接丢弃(`PhaseGameActor.cpp:66`)。
- 若 additional info 的 VID 与暂存不符:`TraceError` 并丢弃,**不创建实体**。
---
## 1. P0:连接、协议和生命周期
### 1.1 Classic 直连分支(第一批已接入,仍待 E2E)
**参考**:不存在“直连 vs Auth”的二选一。参考端固定是 §0.1 的四步,其中
“进入游戏”这一步**必须**用角色槽自带的 `lAddr` / `wPort` 重新连一次
`REF/UserInterface/PythonNetworkStream.cpp:469`),并置 `__DirectEnterMode`。
**当前(第一批已修改)**:登录入口仍由 `M2Client::connect_to_server()``POC/extension/src/net/m2_client.cpp`
走 Auth → Game;收到角色列表后,`M2Client.enter_game(index)` 会调用
`ClassicSession::connect_direct_enter(index)`,断开第一条游戏连接并按角色槽 `lAddr/wPort` 建立第二条
游戏连接。新连接的 LOGIN 使用登录 key,SELECT 阶段自动发送 `CG_CHARACTER_SELECT`。
**修改**
1. `classic_session.h/.cpp`:把两个 `connect()` 重命名为语义化的两段,并新增第三段(**待完成的
清理项**;当前先保留原入口以减少无关改动):
- `connect_auth(auth_host, auth_port, id, pw)` —— 现有带 auth 的重载,**只做认证**;
收到 `GC_AUTH_SUCCESS` 后保存 `m_auth_login_key`,随即**关闭 auth 流**。
- `connect_game(game_host, game_port)` —— 用 `m_auth_login_key` 走 `CG_LOGIN2`
(已有 `connect_with_login_key`,改为公开主路径)。
- `connect_direct_enter(slot_addr, slot_port, slot_index)` —— 新增。置
`m_direct_enter = true; m_direct_enter_slot = slot_index;`,再 `m_stream.connect()`。
2. `ClassicParser``GC_LOGIN_SUCCESS3/4` 解析时把每槽的 `lAddr`(LONG, 小端 4 字节 IP) 与
`wPort` 存进 `SimplePlayer`(当前只存了展示字段)。IP 还原按参考
`GetAccountCharacterSlotDataz`(`PythonNetworkStream.cpp:420`)
`ip[i] = (lAddr >> (8*i)) & 0xff`,格式化为 `"%d.%d.%d.%d"`。
3. ✅ `M2Client::enter_game(index)` 已接入:调用
`classic_sess->connect_direct_enter(index)`,即**断开当前连接、按角色槽地址重连**;
`app_flow.gd`、`charselect.gd`、`login.gd` 已优先使用该入口,旧 fake client 仍可回退到
`select_character()`。
4. `ClassicSession::pump()` 在 `m_direct_enter == true` 时:
- `Stage::Connecting`HandShake)不发 `phase_changed` 给选人 UI
- `Stage::CharSelect` 一进入就自动推进到 `Stage::Loading`(等价
`PythonNetworkStreamPhaseSelect.cpp:28` 的 `SetLoadingPhase`),
当前仍保留 `set_auto_entergame_delay(1500)` 的兼容性定时发送,**这一步尚未完成**;
后续应改为 Loading/游戏窗口完成初始化后的明确发送点;
- `Stage::Loading` 入口清 `m_direct_enter`(等价 `PhaseLoading.cpp:190`)。
5. `MT_CLASSIC_ENTER_DELAY` 环境变量连同 `m2_client.cpp` 的兼容性注入仍未删除——参考端
`CG_ENTERGAME` 的时机是 Python `game.py:207 net.SendEnterGamePacket()`,即
**游戏窗口 Open() 的最后一步**,不是定时器。
**已完成的离线验收**:角色槽 `lAddr/wPort` 能被解析并保存;`M2Client.enter_game` 已注册;
DirectEnter 的状态推进和第二次连接入口已接入。**待验收**:`net_e2e` 观察两次 TCP 连接,且第二次
目标地址来自 `GC_LOGIN_SUCCESS4` 槽位数据。
---
### 1.2 游戏中断线回登录(第一批已接入,仍待 E2E)
**参考**`CNetworkStream` 收到远端关闭 → `OnRemoteDisconnect()`
`REF/EterLib/NetStream.cpp:332/343/351`);`CPythonNetworkStream` 的覆盖实现
`REF/UserInterface/PythonNetworkStreamEvent.cpp:4`**只有一行**
```cpp
void CPythonNetworkStream::OnRemoteDisconnect()
{
PyCallClassMemberFunc(m_poHandler, "SetLoginPhase", Py_BuildValue("()"));
}
```
即:**任何阶段断线一律回登录阶段**。参考端没有“重连遮罩 + 倒计时 + 自动重连”。
主动登出也走同一条:`AbsoluteExitGame()` = `OnRemoteDisconnect(); Disconnect();`
`PythonNetworkStream.cpp:227`)。
**当前(第一批已修改)**`pump_classic()` 在已经建立过游戏连接后发
`disconnected(reason)`,认证 / 建立游戏连接前的失败仍发 `login_failed`
`AppFlow._on_disconnected` 无条件回 LOGIN。`ReconnectUI` 已从 `app_flow.gd` 主流程移除,文件仍保留
供旧测试 / 独立增强使用。
**修改**
1. ✅ `m2_client.cpp` `pump_classic()` 已按“游戏连接是否曾进入 Online”区分——认证失败 /
游戏连接尚未建立 → `login_failed`;已经建立过游戏连接的后续断开 → **`disconnected(reason)`**。
`ClassicSession::was_online_lost()` 提供该判定。
2. ✅ `app_flow.gd` 的 `_on_disconnected()` 已改为**无条件** `_goto_login()`,并附
`_set_status(reason)` —— 与 `SetLoginPhase()` 1:1。
3. **`reconnect_ui.gd` 的去留是产品选择,不是必然修复**。
`AUTO_DELAY=5.0` / `MAX_TRIES=5` 是本工程自造,参考端不存在,所以
**它不能计入 1:1 完成度**;但保留与否不影响本条的缺陷修复——
第 1、2 步修好信号链路后:
- 走严格 1:1 → 删除 `reconnect_ui.gd` 及其在 `app_flow.gd` 的挂载;
- 保留自动重连 → 另立需求,并在 `CLIENT-GAP.md` 标为「超出参考范围的增强」。
第 1、2 步已落地;剩余是产品是否删除独立的 `reconnect_ui.gd`,以及真实断线验收。
4. `M2Client::logout()` / ESC 菜单的 `/logout`:按 `ExitGame()`(`PythonNetworkStream.cpp:204`)
的两分支——`__IsNotPing()` 恒为 `false``PythonNetworkStream.cpp:243`),
所以实际总是走 `SendChatPacket("/phase_select")` / `"/logout"`
**不是**本地断开。现有 `system_menu_ui.gd` 已符合,保持。
---
### 1.3 跨服或换图的首版生命周期(已实现,仍待真服)
**参考**:换图/跨服的清理分布在三处,必须全部照做。
- `SetLoadingPhase()``REF/.../PythonNetworkStreamPhaseLoading.cpp:180`):
```cpp
CPythonPlayer::Instance().Clear();
CFlyingManager::Instance().DeleteAllInstances();
CEffectManager::Instance().DeleteAllInstances();
__DirectEnterMode_Initialize();
```
- `CNetworkActorManager::SetMainActorVID(vid)``NetworkActorManager.cpp:139`):
`m_dwMainVID=vid; m_lMainPosX=m_lMainPosY=0; m_kNetActorDict.clear();`
—— 由 `RecvMainCharacter*` 调用(`PhaseLoading.cpp:203` 等),即**每次进图清空整张实体表**。
- 主角 `AppendActor` 时(`NetworkActorManager.cpp:409`)若不是上下马导致的重建:
`__RemoveDynamicActors(); __RemoveAllGroundItems();`
`__RemoveDynamicActors` 只删 `IsPC() || IsNPC() || IsEnemy()`,保留门/器物)。
- Python 侧 `game.py:221 GameWindow.Close()``snd.FadeOutMusic` → `chat.Close()` →
`snd.StopAllSound()` → `grp.InitScreenEffect()` → `chr.Destroy()` → `textTail.Clear()` →
`quest.Clear()` → `background.Destroy()`,顺序不可换。
- BGM 重选:`game.py:194`
```python
netFieldMusicFileName = net.GetFieldMusicFileName()
if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName)
elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
```
**当前(增量 47**Classic `GC_WARP` 已透传包内坐标和 `lAddr:wPort``M2Client.pump_classic()`
先调用 `EntityStore::reset_for_map_change()`,发出 `world_reset`,再通过同一个
`ClassicSession::connect_warp()` 保留 login key / 已选角色槽并重连目标端点。Classic 的 `WarpCue`
不再以 `addr==0` 判定同服;m2dev 后端仍保留自己的旧协议语义,两者不混用。
Godot 侧 `GameScene` 已将 `world_reset` 映射为:清理 `NetWorld` / `GroundItems` 节点和 PVP / 决斗、
目标、交互窗;收到 Warp 坐标后按服务器坐标重新解析地图、重建 `Metin2World`、HUD 小地图、
Atlas、环境和玩家定位。背包、任务、好友等持久账号数据不在该清理中删除。
**修改**
1. ✅ `EntityStore::reset_for_map_change()` 清空实体、地面物、PVP / 决斗、目标、技能 / affect、
观察者 / 地图 marker、交互状态和旧事件队列;保留背包、任务、好友、公会等持久数据。
2. ✅ `net_world.gd` / `ui/ground_items.gd` 增加清理入口;`M2Client.world_reset` 由 `GameScene`
关闭临时窗口、清除表现节点并停止当前 BGM。
3. ✅ `ClassicSession::connect_warp(host, port)` 保留 login key 与选人槽,在同一 session 上
重置连接状态并进入 DirectEnter`M2Client` 对 Classic 的每个 Warp 都按包内 `lAddr:wPort`
连接,失败进入既有断线链路。
4. ✅ `GameScene._reload_map_for_warp()` 根据 Warp 坐标重选地图,重建 `Metin2World`、HUD 小地图、
Atlas 和环境引用,并重新贴地定位玩家。
5. ⬜ 仍需按 `game.py:194` 的 `GetFieldMusicFileName()` / `musicInfo.fieldMusic` 实现地图 BGM
自动选择;还需补飞行物 / EffectLib 的逐实例清理、真实服务换图矩阵和受控画面验收。
---
### 1.4 选人和进入游戏发送结果(第一批已修复)
**参考**:每个发送函数都是 `if (!Send(...)) { Tracen(...); return false; } return SendSequence();`
`REF/.../PythonNetworkStreamPhaseSelect.cpp:161 SendSelectCharacterPacket`、
`:177 SendDestroyCharacterPacket`、`:194 SendCreateCharacterPacket`、
`:217 SendChangeNamePacket``PhaseLoading.cpp:344 SendEnterGame`)。
`SendEnterGame` 还额外要求 `SendSequence()` 成功后 `__SendInternalBuffer()`。
**当前(第一批已修改)**`ClassicSession::select_char()` / `send_select_char()`、登录包和
`enter_game()` 已透传 `send_fixed()` 结果;`enter_game()` 发送成功后再执行
`flush_internal()`,并且只在发送成功后置 `m_entergame_sent`。`M2Client` 和选人相关 GDScript
也优先调用新的 `enter_game(index)`,旧测试 fake client 保留 `select_character()` 回退。
**修改**
1. ✅ `classic_session.cpp` 的选人路径 → `return m_stream.send_fixed(&p, sizeof(p));`
2. ✅ `classic_session.cpp` 的进场路径 →
```cpp
if (!m_stream.send_fixed(&p, sizeof(p))) return false;
m_entergame_sent = true;
return m_stream.flush_internal(); // = __SendInternalBuffer()
```
`m_entergame_sent` 只在**发送成功后**置位。
3. ✅ 登录 / 认证发送路径也已检查 `send_fixed()` 返回值;其它玩法发送函数原本已透传。
4. ✅ `M2Client.enter_game()` / `select_character()` 透传失败结果。GDScript 当前保留按钮状态,
但失败提示 / 可重试文案仍是后续 UI 细化项。
---
### 1.5 客户端版本包是硬编码的
**参考**`SendClientVersionPacket()``REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155`
```cpp
std::string filename;
GetExcutedFileName(filename); // 真实可执行文件全路径
filename = CFileNameHelper::NoPath(filename); // 只留文件名
CFileNameHelper::ChangeDosPath(filename);
if (LocaleService_IsEUROPE() && !LocaleService_IsYMIR()) {
TPacketCGClientVersion2 { header = HEADER_CG_CLIENT_VERSION2 (0xf1),
filename[33], timestamp[33] = "1215955205" }
} else {
TPacketCGClientVersion { header = HEADER_CG_CLIENT_VERSION (0xfd),
filename[33], timestamp[33] = __TIMESTAMP__ }
}
return SendSequence();
```
**这份 ClientVS22 实际走哪一支是确定的**:`REF/UserInterface/Locale_inc.h:1` 定义
`LOCALE_SERVICE_SINGAPORE``Locale.cpp:186` 的
`#if defined(LOCALE_SERVICE_EUROPE) || … || defined(LOCALE_SERVICE_SINGAPORE) || …`
把 `_LSS_SERVICE_NAME` 设为 `LSS_EUROPE`,于是
`LocaleService_IsEUROPE() == true`、`LocaleService_IsYMIR() == false`。
**参考客户端发的是 `HEADER_CG_CLIENT_VERSION2 = 0xf1`timestamp = `"1215955205"`。**
调用点是 4 个 `RecvMainCharacter*` 的**最后一行**`PhaseLoading.cpp:214/240/267/294`)。
**当前**`ClassicSession::send_client_version()``classic_session.cpp:335`)发
`HDR_CG_CLIENT_VERSION`(0xfd) + `"metin2.bin"` + `"1215955205"` —— **header 走错分支**
0xfd 对应的 timestamp 应该是 `__TIMESTAMP__`,而 `"1215955205"` 只属于 0xf1 分支)。
**修改**
1. ✅ `wire_classic.h` 补 `HDR_CG_CLIENT_VERSION2 = 0xF1` 与
`struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; };`
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局);
尺寸表补 `case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);`。
2. ✅ `send_client_version()` 按 locale 分支:
- `m_locale_is_europe`(默认 true)→ 发 `CGClientVersion2{0xf1, filename, "1215955205"}`
header `HDR_CG_CLIENT_VERSION2`
- 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。
本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。
3. ✅ `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)`
`M2Client` 已传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。
填充经 §1.10 的 `mtnet::to_wire(name, sizeof(p.filename) - 1)`。
4. ✅ `MT_BUILD_TIMESTAMP` 由 `extension/CMakeLists.txt` 的
`string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")` +
`target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP=...)` 注入,格式对齐 C 的
`__TIMESTAMP__``"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用;
`.cpp` 内有 `#ifndef MT_BUILD_TIMESTAMP #define ... __TIMESTAMP__` 兜底。
非 1:1`%d` 是零补位,`__TIMESTAMP__` 的日是空格补位——按本节“近似即可”接受。
5. ✅ 发送时机:`on_packet()` 在四个 `GC_MAIN_CHARACTER*` 变体解析成功后立刻发,
`return send_fixed()` 的返回值;`m_version_sent` 只在发送成功后置位,删除独立触发点。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`wire_classic.h`enum + struct + 尺寸表)、`classic_session.{h,cpp}`
`set_executable_name` / `set_locale_is_europe` setter、`m_executable_name` /
`m_locale_is_europe` 成员、`send_client_version()` locale 分支重写)、
`extension/CMakeLists.txt``MT_BUILD_TIMESTAMP` 定义)。
- 测试:`net_classic_session_test` 的版本断言改为 `CGClientVersion2` / `0xf1` /
`filename=="metin2.bin"` / `timestamp=="1215955205"` / 尾字节 `SEQUENCE_TABLE[2]`
`ctest` `net.classic_session` 通过。
- 验收:未验证(无真实 40250 服务端确认 0xf1 被接受)。
### 1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径
**参考**:均存在于 `REF/UserInterface/``HackShield.cpp`、`WiseLogicXTrap.cpp`、
`NProtectGameGuard.cpp`、`ServerStateChecker.cpp`,以及
`CAccountConnector::SendNEWCIBNPasspodAnswerPacket``AccountConnector.cpp:66`,
`HEADER_CG_NEWCIBN_PASSPOD_ANSWER`)。
`CLIENT-GAP.md` 的「不做清单」已把反作弊标注为 ❌。本条**不改代码**,只补文档:
**修改**
1. 在 `CLIENT-GAP.md` 的不做清单里把 HackShield / XTrap / NProtect / Passpod 显式列入
(目前只写了 `CG_HACK` 与桌面外壳)。
2. `ServerStateChecker`(服务器列表状态查询,UDP)**不属于反作弊**,参考端用于登录页显示
频道负载。若目标服务端开着它,需要单独立项;本文不在此展开。
3. `ClassicParser` 对这些 header 的处理按 §1.8 的规则(记日志 + 清接收缓冲),
不静默吞。
---
### 1.7 未知包 Loading 重同步(第一批已修复)
**参考**`CPythonNetworkStream::CheckPacket()``REF/UserInterface/PythonNetworkStream.cpp:533`
```cpp
if (!s_packetHeaderMap.Get(header, &PacketType)) {
TraceError("Unknown packet header: %d, last: %d %d", header, g_iLastPacket[0], g_iLastPacket[1]);
ClearRecvBuffer();
PostQuitMessage(0); // ← 直接结束进程
return false;
}
```
另有一条**唯一合法的重同步**`header == 0` 时连续 `Recv` 吞掉零字节(填充),
直到读到非零 header 为止(`PythonNetworkStream.cpp:505-524`,在 `CheckPacket` `:498` 内)。
参考端**没有**“扫描后续字节找 GC_CHARACTER_ADD 边界”这种启发式。
**当前(第一批已修改)**`classic_stream.cpp` 已删除 Loading 阶段的启发式边界扫描;未知
header 记录最近两个已识别 header,清空接收缓冲并断开。`header == 0` 的填充跳过仍在尺寸表
查询前执行。
**修改**
1. ✅ 删除 `classic_stream.cpp` 的整段启发式恢复逻辑。
2. ✅ 未知 header 的处理已改为移植后的最小安全语义:
```cpp
log_error("Unknown packet header: %u, last: %u %u", header, m_last_pkt[0], m_last_pkt[1]);
m_recv.clear();
m_last_error = "unknown GC header " + std::to_string(header);
disconnect(); // 等价 PostQuitMessage(0) 在本工程里的最小语义
return;
```
并新增 `uint8_t m_last_pkt[2]`,在每次成功识别 header 后按
`m_last_pkt[0]=m_last_pkt[1]; m_last_pkt[1]=header;` 更新(`PythonNetworkStream.cpp:575`)。
**落地限定**`PostQuitMessage(0)` 是 Windows 的「结束进程」,移植时**不要字面照搬**——
映射为「明确的协议错误 + 断线」即可(走 §1.2 的 `disconnected` → 回登录页)。
要保住的语义只有两条:**不做启发式重同步**、**不静默继续**。
3. ✅ `header == 0` 的填充跳过循环位于尺寸表查询之前,与参考同序。
4. ✅ `disconnect()` 触发 §1.2 的 `disconnected` → 回登录页;这对应 Godot 移植中
`PostQuitMessage(0)` 的安全映射。真实 TCP 断线场景仍待 E2E 验收。
---
### 1.8 已知但没有业务处理的包(第一批已修复静默失败)
**参考**`CPythonNetworkStream::RecvErrorPacket(header)``PythonNetworkStream.cpp:582`
```cpp
TraceError("Phase %s does not handle this header (header: %d, last: %d, %d)",
m_strPhase.c_str(), header, g_iLastPacket[0], g_iLastPacket[1]);
ClearRecvBuffer();
return true;
```
六个阶段函数的 `switch` **落空即调它**`PhaseOffline.cpp:21`、`PhaseHandShake.cpp:94`、
`PhaseLogin.cpp:90`、`PhaseSelect.cpp:142`、`PhaseLoading.cpp:166`、`PhaseGame.cpp:673`)。
注意语义是**丢弃整个接收缓冲**(不是丢一个包),并返回 true 让循环继续。
**当前(第一批已修改)**`ClassicParser` 的 `default:` 分支会记录阶段名、header,并返回
`false``ClassicSession` 将错误传给 `ClassicStream`,后者清空接收缓冲并断开,避免形成
“连接不断但功能静默失效”。可选的反作弊 / Passpod / Matrix 等集成包也走同一条显式失败路径。
**修改**
1. ✅ `ClassicParser` 增加成员 `m_phase_name``"Offline"/"HandShake"/"Login"/"Select"/"Loading"/"Game"`),
由 `ClassicSession` 在阶段切换时写入。
2. ✅ `default:` 分支改为:
```cpp
default:
log_error("Phase %s does not handle this header (header: %u, last: %u, %u)",
m_phase_name, header, m_last_pkt[0], m_last_pkt[1]);
m_unhandled_headers.push_back(header); // 供测试断言
return false; // 让 stream 执行 ClearRecvBuffer
```
3. ✅ `classic_stream.cpp` 在 parser 返回 false 时通过断开路径清空 `m_recv`,并触发显式错误
回调;与参考的 `ClearRecvBuffer()` 相比,本移植选择“清空 + 断开”以保持错误不可继续。
4. **待完成**:按阶段分表,把现在“一个大 switch 处理所有 header”拆成 6 个阶段表,与参考的
`OffLinePhase/HandShakePhase/LoginPhase/SelectPhase/LoadingPhase/GamePhase` 一一对应。
这样“Select 阶段收到 GC_MOVE”才会像参考一样报错,而不是被当成合法包消费。
5. **待完成**:新增 `classic_phase_table_test.gd`,对每个阶段喂一个不属于该阶段的合法 header,
断言 `unhandled_headers` 记录且缓冲被清;当前已有 C++ 回归覆盖未知包、未处理包和不重同步。
---
### 1.9 封包覆盖仍停留在结构和尺寸层
**参考**`REF/EterLib/NetStream.cpp:520-620` 的 `CMainPacketHeaderMap` 是尺寸表;
真正的“完成”判据是每个 header 在**对应阶段函数**里有 case,且 case 里
① 完整 `Recv` ② 写业务状态 ③ 通知 Python 窗口 ④ 需要时回包。
**修改**(流程性,不是单点改代码)
1. 从 `REF/EterLib/NetStream.cpp:520` 起的 header 名表导出全量清单,生成
`POC/docs/PACKET-MATRIX.md`,每行五列:`header / 尺寸 / 所属阶段 / 参考处理函数 / POC 实现位置`。
2. 逐行核对上文 ①②③④ 四项,只有四项齐备才标 ✅。
3. 动态长度包(`PacketType.isDynamicSizePacket`)另标一列,核对
`TDynamicSizePacketHeader{header, size}` 的 `Peek(size)` 完整性校验(`PythonNetworkStream.cpp:540`)。
4. 该矩阵替代 `CLIENT-GAP.md` 现在的“缺失 header 计数”。
---
### 1.10 文本编码和固定长度字段没有统一策略
**参考常量**`REF/UserInterface/StdAfx.h:43`、`Packet.h:380`):
| 常量 | 值 | 说明 |
|---|---|---|
| `CHARACTER_NAME_MAX_LEN` | 24 | 角色名字节数;结构里一律 `char[25]` |
| `ID_MAX_NUM` | 30 | 账号;`char[31]` |
| `PASS_MAX_NUM` | 16 | 密码;`char[17]` |
| `CHAT_MAX_NUM` | 128 | 聊天 |
| `SHOP_SIGN_MAX_LEN` | 32 | 私店招牌 |
| `GUILD_NAME_MAX_LEN` | 12 | 公会名 |
| `PRIVATE_CODE_LENGTH` | 8 | 删角色私密码 |
| `QUEST_INPUT_STRING_MAX_NUM` | 64 | 任务输入 |
参考的写法一律是 `strncpy(dst, src, sizeof(dst)-1)`(见
`SendCreateCharacterPacket`/`SendChangeNamePacket`/`SendDirectEnterPacket`),
且 `SendLoginPacket` 额外显式补 `name[ID_MAX_NUM]='\0'`。
参考是**单字节本地编码**`REF/UserInterface/Locale.cpp` + `EterLocale`),不是 UTF-8。
**修改**
1. ✅ 新建 `extension/src/net/text_codec.h`mtnet 侧,不依赖 godot-cpp):
- `std::string to_wire(std::string_view utf8, size_t cap)`**按字节**截断到 `cap`
且不在 UTF-8 多字节序列中间截断(截到最后一个完整码点)。
- `std::string from_wire_str(const char *bytes, size_t cap)`:反向,遇 `\0` 停,
并裁掉末尾悬空的部分序列。
- `godot::String` 重载用 `#if __has_include(<godot_cpp/variant/string.hpp>)`
`MT_TEXT_CODEC_HAS_GODOT`)守卫,保持 `net_classic_wire_test`(不链 mtnet)可编译。
- **非 1:1follow-up**:目前 wire 字节即 UTF-8,未做 locale 代码页(CP949 / CP1252
转换;ASCII 可往返,非 ASCII 名对单字节编码服务端会呈现 mojibake。头注释已登记此缺口。
2. ✅ `wire_classic.h` `#include "../text_codec.h"``classic_session.cpp` 里所有定长名字 /
招牌 / 公会名 / 好友名 / 私密文本填充改为 `mtnet::to_wire(src, cap)` +
`memcpy(dst, w.data(), w.size())`,共 8 处:`create_character` / `change_name` /
`send_whisper` / `send_private_shop` / `send_guild_grade_name` / `send_guild_comment` /
`send_guild_answer_make` / `send_friend_add|remove`。`GC_CHAR_ADDITIONAL_INFO` 的名字
读取改用 `mtnet::from_wire_str(p.name, sizeof(p.name))`。
保留 `strncpy`:登录 id / 密码(`m_id` / `m_pw`,非 wire 定长名字段)、`send_chat` /
whisper 的动态文本 body。**其余 parser 名字读取(非 ADDITIONAL_INFO)仍走原 `strnlen`**
作为 follow-up。
3. ⬜ **不在本批次**`charselect.gd` / `guild_creation_ui.gd` 的名字长度校验统一为
`<= 24` 字节属 GDScriptW3 / Phase 2);本批次只交付 C++ 侧编码。已在下方登记。
4. ✅ 回归:新增 `extension/tests/net_text_codec_test.cpp``ctest` `net.text_codec`),
覆盖 CJK 名恰好 24 字节(接受)、25/27 字节(截断到 24,绝不 25/26)、
多字节边界截断(切进 3 字节序列中间丢整字)、ASCII 往返、`from_wire_str` 遇 NUL 停 /
悬空前导字节裁除 / 无终止符满字段整读。`extension/CMakeLists.txt` 注册。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/text_codec.h`(新)、`extension/tests/net_text_codec_test.cpp`(新)、
`wire_classic.h`include)、`classic_session.cpp`8 处 to_wire 路由)、
`classic_parser.cpp`ADDITIONAL_INFO from_wire_str)、`extension/CMakeLists.txt`(测试注册)。
- 测试:`ctest` `net.text_codec` 通过(新增,套件 16 → 17);`net.classic_session` 通过。
- 验收:未验证(无真实服务端确认非 ASCII 名编码);locale 代码页转换为 follow-up。
---
### 1.11 登录错误、超时、会话失效和服务器列表状态没有完整映射
**参考**
- `GC_LOGIN_FAILURE` → `TPacketGCLoginFailure.szStatus`(字符串),
直接透给 Python `OnLoginFailure(szStatus)``REF/.../PythonNetworkStreamPhaseLogin.cpp:222`)。
文案映射在 `POC/assets/root/intrologin.py:390 OnLoginFailure()` 里(本仓已有该脚本)。
- `GC_AUTH_SUCCESS.bResult == 0` → `OnLoginFailure("BESAMEKEY")`
`REF/UserInterface/AccountConnector.cpp:460`)。
- 连接失败 → `CPythonNetworkStream::OnConnectFailure()``PhaseLogin.cpp:211`):
`__DirectEnterMode_IsSet()` 为真则 `ClosePhase()`,否则
`PyCall(PHASE_WINDOW_LOGIN, "OnConnectFailure")`。
- 阶段专用:`RecvErrorPacket` 的文案带 `m_strPhase`(见 §1.8)。
**修改**
1. `ClassicParser` 保留 `login_failure()` 的**原始 szStatus 字符串**(不要翻译、不要拼接前缀)。
2. `M2Client` 的 `login_failed` 信号第二参数新增 `code: String`= szStatus),
现有第一参数保留为可读描述。
3. `login.gd` 增加 `OnLoginFailure` 文案表,逐条抄 `POC/assets/root/intrologin.py:390` 起的
`localeInfo` 键;未命中的 code 原样显示(与参考一致)。
4. `GC_AUTH_SUCCESS.bResult == 0` 走同一条,code 固定 `"BESAMEKEY"`。
5. 连接失败:`direct_enter` 期间 → 触发 `ClosePhase` 等价(回登录页,不弹错误框);
否则 → `login.gd` 的 `OnConnectFailure()`。
---
## 2. P0/P1:实体、世界和状态一致性
> 本节全部条目的前提是 §0.2 的**两层模型**。先做 2.1 的重构,其余条目才有落点。
### 2.1 未知 VID 的更新包会直接丢失
**参考**:参考端**也**丢弃未知 VID 的更新,但丢的是「不在 `m_kNetActorDict` 里的 VID」,
而不是「没有场景节点的 VID」:
```cpp
CNetworkActorManager::UpdateActor / MoveActor / SyncActor / SetActorOwner
auto f = m_kNetActorDict.find(vid);
if (f == end) { TraceError("... NOT EXIST VID"); return; } // 仅 _DEBUG 下报错
SNetworkActorData& d = f->second;
CInstanceBase* inst = __FindActor(d); // 可见则**按需创建**实例
if (inst) { ...改实例... }
...无论实例在不在,都更新 d...
```
`REF/UserInterface/NetworkActorManager.cpp:456 / 508 / 536 / 553`
关键点有三:
1. **数据表更新与实例存在与否解耦**——`UpdateActor` 末尾无条件写回 `rkNetActorData` 的
`affect / guild / movSpd / atkSpd / armor / weapon / hair / alignment / pkMode`。
2. `__FindActor()``NetworkActorManager.cpp:189`)在实例缺失且**可见**时当场创建。
3. `MoveActor` 用 `__FindActor(d, dstX, dstY)` 的重载,按**目标点**判可见(`:172`)。
**当前**`EntityStore::mut_move/mut_set_position/mut_change_speed/mut_dead/mut_stun` 等在
`m_ents.find(vid) == end` 时直接 `return``POC/extension/src/net/entity_store.cpp:123` 等),
而 `m_ents` 同时充当数据表和「场景应有节点」的清单——两层被合并了。
**修改**
1. `EntityStore` 保持为**数据层**= `m_kNetActorDict`),并明确:条目只由
`GC_CHARACTER_ADD`(含 additional info 合流)创建、由 `GC_CHARACTER_DEL` 删除、
由 `mut_set_main_vid()`(§1.3)整表清空。**任何其它包都不创建、也不删除条目。**
2. 可见性下沉到 `net_world.gd`= `CPythonCharacterManager`):
- 每帧对 `EntityStore` 的全部条目做 `__IsVisibleActor` 判定
(主角 / `AFFECT_SHOW_ALWAYS` / 距离 < `CHAR_STAGE_VIEW_BOUND` / `IsWall(race)`);
- 可见但无节点 → 建节点;节点距离 > `CHAR_STAGE_VIEW_BOUND + 10` → 淡出删除节点。
- 常量:`const CHAR_STAGE_VIEW_BOUND := 200 * 100 # cm``REF/.../PythonCharacterManager.cpp:13`)。
3. 所有 `mut_*` 保持「未知 VID 直接 return + 记一条 debug 日志」,与参考一致——
**不要**引入 pending / 重放机制,参考端没有。
4. `EntityStore` 新增 `drain_dirty()``net_world.gd` 只对**已有节点**的脏实体做刷新,
其余只更新数据。
**验收**`net_entity_test` 补一条——先喂 200 m 外的 `GC_CHARACTER_ADD`,再喂
`GC_CHARACTER_UPDATE`,断言 `EntityStore` 里字段已更新且没有场景节点;把主角挪近后
断言节点出现且带更新后的装备。
**状态:✅ 完成(2026-09-02 / W2 / 增量 50**
- **改动**
- `extension/src/net/entity_store.cpp`:条目仅由 `mut_spawn` / `mut_spawn_full` /
`mut_spawn_main` 创建、由 `mut_despawn` / 主 VID 清表删除。`mut_move` /
`mut_set_position` / `mut_change_speed` / `mut_dead` / `mut_stun` / `mut_char_update` /
`mut_char_info` / `mut_mount` / `mut_shop_sign` / `mut_affect_flags` 等对未知 VID 一律
`m_ents.find == end → dbg_ignored() + return`,无 pending / 重放。新增匿名命名空间
helper `dbg_ignored(what, vid)`(仅 `MT_NET_TRACE` 环境变量下打 stderr)。
- `project/net_world.gd`:新增 `_update_visibility()`,每帧 `_process` 首行调用,对
`client.get_entities()` 全表逐条判 `__IsVisibleActor`(主角 / `is_main` /
`AFFECT_SHOW_ALWAYS`(bit 7) / `IsWall(race)` / 平面距离 `< CHAR_STAGE_VIEW_BOUND`):
可见且无节点 → `_on_spawn(e)` 建节点;有节点且距离 `> CHAR_STAGE_VIEW_BOUND + 10` →
`_fade_and_free(vid)`(保留数据行,节点 0.3s 缩放淡出后 `queue_free``_fading` 集合
防止淡出途中重建)。常量 `const CHAR_STAGE_VIEW_BOUND := 200 * 100`cmpx==cm)。
`_on_spawn` 对信号路径新增 `_is_visible_entity(d)` 门禁;`drain_dirty` → `entity_info`
仅刷新已存在节点。**留白**:数据层不可查询时(fake client 无 `get_entities` /
`_main_vid == 0` / 缺 `pos_cm`)门禁 fail-open,信号驱动的 spawn 不受可见性限制——
与参考端「信号即 `AppendActor`」有意偏差,仅在真实 `M2Client` 上启用逐帧管理。
- **测试**`ctest` 16/16(含 `net.entity_store` 新增 §2.1 断言:远端 `GC_CHARACTER_ADD`
填充数据行 + 入队 Spawn;`UPDATE` 改写已有行;未知 VID 的 `mut_char_update` /
`mut_move` / `mut_mount` / `mut_char_info` / `mut_shop_sign` 既不建行也不缓存)。
新增 `project/net_world_vis_test.gd`:近实体 spawn 即建节点、远实体 spawn 不建节点、
主角走近后逐帧管理补建一次、走远后淡出且数据行保留、`AFFECT_SHOW_ALWAYS` 与
`race 14202` 常驻、走回后淡出结束再重建——全过。
- **验收**:未验证(无真实 40250 服务端;真服端到端可见性 / VID 复用 / affect 表现待接线后验收)。
---
### 2.2 `GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存
**参考**:见 §0.3。参考端**根本不会**出现「additional info 早于 add」——它用
`static SNetworkActorData s_kNetActorData` 做单槽暂存,`GC_CHARACTER_ADD` 只写暂存槽,
`GC_CHAR_ADDITIONAL_INFO` 才 `AppendActor`。VID 不匹配一律 `TraceError` 丢弃:
```cpp
// REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140
SNetworkActorData kNetActorData = s_kNetActorData;
if (IsInvisibleRace(kNetActorData.m_dwRace)) return true;
if (kNetActorData.m_dwVID == chrInfoPacket.dwVID) { ...合并 9 个字段...; __RecvCharacterAppendPacket(&kNetActorData); }
else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error", ...);
```
**当前**`EntityStore::mut_char_info()``POC/extension/src/net/entity_store.cpp:458`
用 `touch(vid, created)` 可以凭空建实体,且新建时不推 `Spawn` change;若后续 Spawn 正常到达,
`NetWorld` 仍会创建节点,但若 Spawn 丢失或顺序异常,实体会长期只有数据没有表现层节点。
**修改**
1. ✅ `ClassicParser` 已有成员 `std::unordered_map<uint32_t, mtnet::Entity> m_pending_actor;`
(W0 增量 49 冻结,等价 `s_kNetActorData`,此处按多槽 map 而非单槽实现);
新增 `pending_actor_count()` 供测试断言。
2. ✅ `GC_CHARACTER_ADD` 处理改为:
- `is_invisible_race(race)`20025 / 20038 / 20039)→ `return true`(丢弃,不断连接);
- `type == CHRTYPE_PC || type == CHRTYPE_NPC` → **只**把裸 `Entity`vid / race / ch_type /
坐标 / 角度 / 速度 / state_flags / affect_flags)写入 `m_pending_actor[vid]`,不调 `m_world`
- 否则 → 名字取 `npclist.txt` 的 `race → code`(新 `npc_names.h`,等价
`CPythonNonPlayer::GetName`;取不到为空串),立即 `m_world.mut_spawn(...)`
`alignment / pk_mode / guild / empire / parts / mount / level` 全部为 0`mut_spawn` 默认)。
3. ✅ `GC_CHAR_ADDITIONAL_INFO` 处理改为:
- `m_pending_actor` 无 `pkt.dwVID` 条目 → 写 `m_last_error` + `m_unhandled_headers`
`return true`**丢弃**,不建实体;
- 命中 → 用 pending `Entity` + additional 的 9 个字段(name / guild / level / alignment /
pk_mode / empire / parts[armor/weapon/hair] / mount_vnum)合成一次
`m_world.mut_spawn_full(e)`(W0 新 API,一次写全字段并推 `Spawn`),随后 `erase` 该条目。
4. ⬜ **W2 依赖(本批次不改 `entity_store.*`**`mut_char_info()` 的 `touch()` 建实体路径、
`mut_shop_sign()` 的凭空建 Entity 由 W2 按 §2.2 步骤 4–5 处理;本批次的合并逻辑对当前 W2
代码即可工作(`mut_spawn_full` 已就位)。
5. ⬜ 同上,`mut_shop_sign()` → 独立 `ShopSign` 表现状态属 W2。
6. ✅ **跨文件已接线**`M2Client` 在 `MT_ASSETS` 配置时调用
`parser().set_npclist_path(<MT_ASSETS>/root/npclist.txt)`;未配置时 `npc_names.h` 回落
`$M2_ASSETS` 环境变量,再回落空串。后续仍可把资源根从 Godot `AssetRoot` 显式传入,
以覆盖打包场景中不使用环境变量的路径。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/classic/npc_names.h`(新,`load_npclist` / `default_npclist_path`
mtnet-safe)、`classic_parser.{h,cpp}``set_npclist_path` / `pending_actor_count` /
`npc_name_for` 惰性加载、`GC_CHARACTER_ADD` 与 `GC_CHAR_ADDITIONAL_INFO` 处理重写)。
`GC_CHARACTER_ADD2` 处理**未动**(超出本条范围,登记为 follow-up)。
- 测试:`net_classic_session_test` 的 NPC 块重写为“先 `GC_CHARACTER_ADD`(NPC) 只暂存
`world().size()` 不变、`pending_actor_count()==1`)→ 再 `GC_CHAR_ADDITIONAL_INFO` 合并落地
(暂存坐标 / 速度保留、9 字段合入、`pending_actor_count()==0`)→ 怪物立即 spawn →
隐身 race 丢弃 → 无 pending 的 additional-info 为 no-op”。`ctest` `net.classic_session` 通过。
- 验收:未验证(两包合并需真实 40250 服务端 `GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 序列)。
**步骤 4-5 状态:✅ 完成(2026-09-02 / W2 / 增量 50**——`mut_char_info()` 改为
`m_ents.find == end → dbg_ignored() + return`,命中则更新字段并无条件 `push {Info, vid}`
`mut_shop_sign()` 同样去掉裸 `Entity` 创建,未知 VID 直接 return。步骤 1-3`m_pending_actor`
两包合并、`GC_CHARACTER_ADD` 分流、`IsInvisibleRace`)属 W1 的 `ClassicParser` 范畴,未在本批次触碰。
---
### 2.3 VID 重用时旧状态可能污染新实体
**参考**`CNetworkActorManager::AppendActor``NetworkActorManager.cpp:409`
```cpp
SNetworkActorData& rkNetActorData = m_kNetActorDict[c_rkNetActorData.m_dwVID];
rkNetActorData = c_rkNetActorData; // operator= → __copy__ 覆盖**全部** 23 个字段
```
`SNetworkActorData::__copy__``NetworkActorManager.cpp:53`)逐个赋值
`m_bType, m_dwVID, m_dwStateFlags, m_dwEmpireID, m_dwRace, m_dwMovSpd, m_dwAtkSpd, m_fRot,
m_lCurX/Y, m_lSrcX/Y, m_lDstX/Y, m_kAffectFlags, m_dwServerSrcTime, m_dwClientSrcTime,
m_dwDuration, m_dwArmor, m_dwWeapon, m_dwHair, m_dwOwnerVID, m_sAlignment, m_byPKMode,
m_dwMountVnum, m_dwGuildID, m_dwLevel, m_stName`。
表现层同样重建:`__AppendCharacterManagerActor``:327`)在已有实例时先
`rkChrMgr.DeleteInstance(dwVID)` 再 `CreateInstance(kCreateData)`——**整个实例重建**
只有上/下马时才把旧坐标覆盖进 `kCreateData``:342`)。
**当前**`EntityStore::mut_spawn()``entity_store.cpp:75`)用 `touch()` 后只覆盖
`race/ch_type/name/x/y/z/angle/moving_speed/attack_speed/moving`。
**修改**
1. `mut_spawn_full()`(§2.2 新增)实现为:
```cpp
Entity e{}; // 值初始化 —— 等价 SNetworkActorData 默认构造 + operator=
e.vid = vid; ...逐字段赋值全部字段...
m_ents[vid] = e; // 整条替换,不做 touch/局部覆盖
m_changes.push_back({ChangeKind::Spawn, vid});
```
即 `dead / stunned / mount_vnum / guild / pk_mode / alignment / parts / shop_sign /
affect / func / walk_mode / fly_target_*` 全部回默认值。
2. `net_world.gd` 收到 `Spawn` 且节点已存在 → **销毁旧节点重建**(对齐
`DeleteInstance` + `CreateInstance`);例外:上下马导致的重建保留旧世界坐标
`kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;`,注意 Y 取反)。
3. `mut_spawn()` 旧签名保留给 `GC_MAIN_CHARACTER` 之外的老调用点,但内部改为委托
`mut_spawn_full()`,避免两套覆盖规则。
**状态:✅ 完成(2026-09-02 / W2 / 增量 50**
- **改动**
- `extension/src/net/entity_store.cpp``mut_spawn_full(const Entity& src)` 实现为
`Entity e = src; e.vid = src.vid; m_ents[src.vid] = e;`(整行替换,VID 复用时不保留
任何在途移动状态——对齐 `SNetworkActorData::__copy__` 从 src 取 Src/Dst/Duration),
`src.is_main` 时设 `m_main_vid`,无条件 `push {Spawn, vid}`。`mut_spawn(...)` 保留原
签名,内部构造 `Entity e{}` 填 vid/race/ch_type/name/x/y/z/angle/speeds/`moving=false`
后委托 `mut_spawn_full(e)`。
- `project/net_world.gd``_on_spawn` 收到已有节点的 `vid` → `queue_free` 旧节点后重建
(对齐 `DeleteInstance` + `CreateInstance`);例外:上/下马驱动的重建
`(old_mount == 0) != (new_mount == 0)`)用 `existing.global_position` 保留旧世界坐标。
节点存 `mount_vnum` meta 供该判定。
- **测试**`ctest` 16/16`net.entity_store` 现有 VID-复用 / loading-reset 断言不回归);
`netbridge_test` / `gamescene_test` / `netplay_test` 全绿。
- **验收**:未验证(VID 复用需真服)。
---
### 2.4 点数组可能残留旧值
**参考**`__RecvPlayerPoints()``REF/.../PythonNetworkStreamPhaseLoading.cpp:325`
```cpp
for (DWORD i = 0; i < POINT_MAX_NUM; ++i)
CPythonPlayer::Instance().SetStatus(i, PointsPacket.points[i]);
```
`TPacketGCPoints.points` 是**定长 `POINT_MAX_NUM` 数组**,全量覆盖,不存在“部分长度”。
**当前**`EntityStore::mut_set_points(pts, n)``entity_store.cpp:133`)只 `memcpy` 前 `n` 个;
当前 Classic 固定长度包路径传入 `POINT_MAX_NUM`,所以该路径本身是全量覆盖,残留风险只存在于
未来或其他调用者传入较短长度时。
**修改**
1. `wire_classic.h` 确认 `GCPoints.points` 为 `int32_t[POINT_MAX_NUM]`,加 `static_assert`。
2. 若 `mut_set_points()` 继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须
传入并校验 `POINT_MAX_NUM`,部分更新不得被误称为参考端的 `GC_POINTS` 全量包;必要时在
部分更新前清零尾部,避免未来调用者读到旧值。
3. `GC_POINT_CHANGE`(单点更新)保持只改一个下标——参考同样是单点
`PhaseSelect.cpp:129` 在 Select 阶段直接 `Recv` 丢弃,Game 阶段才处理)。
**状态:✅ 完成(2026-09-02 / W2 / 增量 50**
- **改动**`extension/src/net/entity_store.cpp`
- `mut_set_points(pts, n)`(全量快照):`if (!pts || n <= 0) return;``n > POINT_MAX_NUM`
255)时截断为 `POINT_MAX_NUM``memcpy` 之后 `for (i = n; i < POINT_MAX_NUM; ++i)
m_points.v[i] = 0` 清尾,杜绝短包留下旧值。
- `mut_set_point(type, value)`(单点):`if (type >= POINT_MAX_NUM) { dbg_ignored(...);
return; }` 越界拒绝。
- **W1 跟进项**`wire_classic.h:606-607` 已有
`struct GCPoints { uint8_t header; int32_t points[POINT_MAX_NUM]; }` +
`static_assert(sizeof(GCPoints) == 1 + 255 * 4)`,本条要求的编译期断言已满足;运行期钳制
仍在 `EntityStore` 侧独立加了一层。
- **测试**`ctest` 16/16`net.entity_store` 点数断言不回归)。
- **验收**:未验证(需真服 `GC_POINTS` / `GC_POINT_CHANGE` 流量)。
---
### 2.5 实体数据字段不完整
**参考**`SNetworkActorData``REF/UserInterface/NetworkActorManager.h:5`)的完整字段表见 §2.3。
`CInstanceBase::SCreateData` 又比它多出 `m_isMain`。参考端各字段的**唯一来源**
| 字段 | 来源包 | 参考行 |
|---|---|---|
| `m_bType`(CHRTYPE) / `m_dwRace` / `m_dwStateFlags` / `m_fRot` / `m_dwMovSpd` / `m_dwAtkSpd` / 位置 / `m_kAffectFlags`(64 bit, 两段 32) | `GC_CHARACTER_ADD` | `PhaseGameActor.cpp:83` |
| `m_stName` / `m_dwGuildID` / `m_dwLevel` / `m_sAlignment` / `m_byPKMode` / `m_dwEmpireID` / `armor,weapon,hair`(`awPart[CHR_EQUIPPART_*]`) / `m_dwMountVnum` | `GC_CHAR_ADDITIONAL_INFO` | `PhaseGameActor.cpp:140` |
| 同上 9 项的后续变更 | `GC_CHARACTER_UPDATE[2]` | `PhaseGameActor.cpp:211/236` |
| `m_dwOwnerVID` | `GC_OWNERSHIP` | `PhaseGameActor.cpp:375` |
`UpdateActor` 应用到实例的调用序(`NetworkActorManager.cpp:470`,顺序即参考顺序):
`ChangeArmor → ChangeWeapon → ChangeHair → ChangeGuild → SetAffectFlagContainer →
SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags`。
`MountHorse/DismountHorse` 在参考里是**注释掉的**,坐骑变化只经 `AppendActor` 重建。)
**当前**`mut_char_info()``entity_store.cpp:477`)显式 `(void)empire;` 丢弃 empire
名字颜色 / 称号 / 会徽 / target highlight / attackable / weapon mode / motion mode /
affect 图标与剩余时间没有统一状态。
**修改**
1. `Entity` 结构补齐到与 `SNetworkActorData` 一一对应:
新增 `uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags;`
`state_flags` 已有 `func/position` 的话合并成一个字段,按 `bStateFlag` 原值保存)。
2. `mut_char_info()` 写入 `e.empire = empire;`,删除 `(void)empire;`。
3. `affect_flags` 按参考的两段拷贝:`CopyData(0, 4, &dwAffectFlag[0])` +
`CopyData(32, 4, &dwAffectFlag[1])` → `uint64_t = lo | (hi << 32)`。
`AFFECT_SHOW_ALWAYS` 的位号取 `REF/UserInterface/InstanceBase.h` 的 `AFFECT_*` 枚举
**[待读参考:`InstanceBase.h` 的 `enum EAffect`]**。
4. `net_world.gd` 的实体刷新按上表的**参考调用序**执行,不要重排。
5. 名字颜色 / 称号 / PK 颜色 / 目标框 属表现层,见 §8.8,数据源就是本节的
`empire / guild / alignment / pk_mode / level`。
**状态:✅ 完成(2026-09-02 / W2 / 增量 50**——affect 位拷贝 + 应用序落地;
`empire` 字段由 §增量 49(W0)补齐,本批次不再重复。
- **改动**
- `extension/src/net/entity_store.{h,cpp}`:新增
`void mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi)`——
`v = (uint64_t)lo | ((uint64_t)hi << 32)`,命中且值变化时写 `affect_flags` 并
`m_dirty.push_back(vid)`(去重刷新);未知 VID `dbg_ignored + return`。
`mut_char_update()` 的 `guild / alignment / pk_mode` 变化现在也置 `changed` 并推
`{Info, vid}`(原先漏推)。
- `project/net_world.gd`:新增 `_apply_field_updates(n, d)`,严格按
`NetworkActorManager.cpp:470` 调用序:ChangeArmor → ChangeWeapon → ChangeHair →
ChangeGuild → SetAffectFlagContainer → SetMoveSpeed → SetAttackSpeed → SetAlignment →
SetPKMode → SetStateFlags;每个 setter 用 `has_method` 守卫,占位胶囊与不建模该字段
的视图自动跳过。`_on_spawn` / `_on_info` 末尾各调一次。
`const AFFECT_SHOW_ALWAYS := 1 << 7`、`const WALL_RACES := [14201,14202,14203,14204]`。
- **待读参考落定**40250 `ClientVS22` 树的 `AffectFlagContainer.h` 未逐一拼出枚举成员;
`AFFECT_SHOW_ALWAYS` = bit 7 取自 m2dev 姊妹源 `InstanceBase.h` 的 `enum EAffect`
序号 7,已在 `net_world.gd` 注释标注为本地常量。
- **W1 跟进项**`GCCharacterAdd/Add2/Update` 都带 `uint32_t affect_flag[2]`,但
`mut_char_update` 无 affect 形参——W1 需在 `classic_parser.cpp` 的
`GC_CHARACTER_ADD` / `ADD2` / `GC_CHARACTER_UPDATE` 之后调
`mut_affect_flags(vid, affect_flag[0], affect_flag[1])`。
- **测试**`ctest` 16/16`netbridge_test` / `gamescene_test` / `combat_fx_test` /
`player_motion_test` 全绿;新增 `net_world_vis_test` 覆盖 `AFFECT_SHOW_ALWAYS` 常驻。
- **验收**:未验证(换装 / affect 表现需真服)。
---
### 2.6 一次性事件没有可靠的生命周期缓存
**参考**:参考端**不缓存**。`CPythonNetworkStream` 收到包立刻
`PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], ...)`;窗口指针由 Python 在
`SetPhaseWindow(ePhaseWnd, poPhaseWnd)``PythonNetworkStream.h:133`)注册,
`PyCallClassMemberFunc` 对空指针是安全 no-op。真正的保证是**顺序**:
`GC_PHASE(PHASE_GAME)` 之前 Python 已经 `game.py:137 GameWindow.Open()` 完成装配,
而 `SendEnterGamePacket()` 是 `Open()` 的倒数第二步(`game.py:207`)——
**服务端在收到 `CG_ENTERGAME` 之前不会推游戏事件**。
**当前**`M2Client` 的 drain queue 在 `game_scene` 装配完成前可能被消费。
**修改**
1. **不要**加事件缓存。改为对齐顺序:
`CG_ENTERGAME` 的发送点从 `ClassicSession` 的定时器(§1.1 已删)挪到
`game_scene.gd` 完成全部窗口装配后显式调用 `client.enter_game()`
即 1:1 对齐 `game.py:207`。
2. `game_scene.gd` 的装配顺序照抄 `game.py:137 Open()`
相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 → `SetFocus/Show/ShowCursor`
→ `net.SendEnterGamePacket()` → `StartGame()`。
3. `M2Client` 的 drain 接口保持「不缓存」,但在 `enter_game()` 之前**不启动** world 分派,
避免登录洪流里的包被提前消费。
---
### 2.7 GC_WARP 永远重连,换图必须清理并重建世界
**参考**`CPythonNetworkStream::RecvWarpPacket()``REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:923`
整体只有三步:接收 `{bHeader, lX, lY, lAddr, wPort}`、设置
`__DirectEnterMode_Set(m_dwSelectedCharacterIndex)`、调用
`CNetworkStream::Connect((DWORD)lAddr, wPort)`。
结论:
- `GC_WARP` 在参考端**永远是重连**,不存在用 `addr == 0` 判断“同服只移动玩家”的分支。
- `lX / lY` 在 `RecvWarpPacket()` 这一步不直接消费,但重连后的
`GC_MAIN_CHARACTER*` / `PhaseLoading.cpp:211 LoadData(lX, lY)` 仍会用它们定位角色,必须
透传,不能标成无用字段。
- `CNetworkStream::Connect()``REF/EterLib/NetStream.cpp:401`)先 `Clear()`,会清理 socket、
收发缓冲和 sequence;新连接再按 DirectEnter 流程 HandShake → Login → Select → Loading。
**当前首版(已实现,仍待真实服务端)**:
- `classic_parser.cpp` 将 Classic `GC_WARP` 标记为 `reconnect=true``WarpCue::same_server()`
不再作为 Classic 的判据,m2dev 旧语义仍可保留在其独立协议路径。
- `M2Client` 在收到 Warp 后发出 `world_reset`,保存登录 key / 已选角色槽,清空实体、掉落、
目标、PVP / 决斗、技能 / affect / 交互等地图局部状态,再由 `ClassicSession::connect_warp()`
按 `lAddr:wPort` 建立第二次游戏连接。
- Godot `GameScene` / `NetWorld` / `GroundItems` 清理旧节点并重建地图、HUD、Atlas、环境和
玩家位置;背包、任务、好友等跨地图数据保留。
**剩余修改与验收**
1. 保持 `lX / lY / lAddr / wPort` 的 wire、信号和重连路径一致;注释统一写“本步不消费”,
不写“坐标无用”。
2. 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的 `lAddr:wPort`、发送 DirectEnter / Select
收到目标 `GC_MAIN_CHARACTER*`,并确认旧场景节点、旧掉落和旧实体没有残留。
3. 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前
只覆盖基础场景和 UI。
4. 回归:`net_classic_session_test` 覆盖 endpoint / selected slot 保留,`net_entity_test` 覆盖
loading reset`gamescene_test` / `p9_test` / `p2b_test` 覆盖场景重建;localhost bind 被
`skip` 时只能记为“未验证”,不能替代真实 E2E。
---
## 3. P1:移动、战斗和技能
### 3.1 本地移动是直线位置修改
**参考**:本地移动**不是**直接改坐标,而是「设 Src/Dst + 起走路动作 + 每帧推进 + 碰撞回退」。
- 点地移动 `CPythonPlayer::__OnPressGround``REF/UserInterface/PythonPlayerInput.cpp:419`):
```
__ClearReservedAction(); __ClearAutoAttackTargetActorID();
if (NEW_CancelFishing()) return;
if (!__IsMovableGroundDistance(...)) return; // 距离 < MOVABLE_GROUND_DISTANCE 不动
if (!rkInstMain.NEW_MoveToDestPixelPositionDirection(p)) __ReserveClickGround(p);
```
`__ReserveClickGround` 会设 `m_fReservedDelayTime = 0.1f``PythonPlayerInput.cpp:834`)。
- `CInstanceBase::NEW_Goto()``REF/UserInterface/InstanceBaseMovement.cpp:251`)的**前置门**依序:
`__IsSyncing()` → false`IsUsingMovingSkill()` → 只转向;`isLock()` → false
`!NEW_CanMoveToDestPixelPosition(dst)` → 停走并返回 true。
通过后:`NEW_SetSrcPixelPosition(cur) / NEW_SetDstPixelPosition(dst) /
NEW_SetDstPixelPositionZ(src.z) / m_fDstRot / m_isGoing=TRUE / StartWalking()`。
- 方向键移动 `NEW_MoveToDirection``InstanceBaseMovement.cpp:293`)同样有
`__IsSyncing / IsUsingMovingSkill / isLock` 三门,且 `m_isGoing = FALSE`。
- 玩家层 `CPythonPlayer::NEW_MoveToDirection``PythonPlayerInput.cpp:445`)额外:
`IsOpenPrivateShop()` → 直接返回;`isLock() && !IsUsingMovingSkill()` → 返回。
- 碰撞:`CInstanceBase::CheckAdvancing()``InstanceBaseBattle.cpp:469`)——
地形 `ATTRIBUTE_BLOCK` 检测(`InstanceBaseBattle.cpp:560`+ 与其它 `CInstanceBase` 的
`TestActorCollision`,命中即 `BlockMovement()`。`CanSkipCollision()` 为真时整段跳过
(网络状态包驱动的移动会 `__EnableSkipCollision()`,见 §3.2)。
**当前**`player_controller.gd:216` 直接改 X/Z,只做地形阻挡。
**修改**
1. `player_controller.gd` 重构为「Src/Dst + `is_going` + `walk_mode`」模型,一一对应上面的
`NEW_Goto` / `NEW_MoveToDirection`,并保留三个前置门(`syncing / moving_skill / lock`)。
2. 新增 `MOVABLE_GROUND_DISTANCE`(参考默认值由 `SetMovableGroundDistance` 从 Python 设置
**[待读参考:`POC/assets/root/game.py` / `playersettingmodule.py` 中的
`player.SetMovableGroundDistance` 调用]**)。
3. `__ReserveClickGround` 的 0.1 s 延迟、`NEW_IsEmptyReservedDelayTime` 的递减逻辑
`PythonPlayerInput.cpp:626`)照搬。
4. Actor 之间的碰撞:用 Godot 的 Area3D 近似 `TestActorCollision`;命中 → `BlockMovement()`
(= 停在上一帧位置,不做滑移)。
5. 坐骑:`IsMountingHorse()` 时移动速度与动作模式按 §6.4 的 `MODE_HORSE*` 走。
**状态:✅ 完成(2026-09-02 / W2 / 增量 76Actor-Actor 碰撞 + 坐骑转向速度 2026-09-03 / 增量 93**
- `player_controller.gd` 重构为 Src/Dst 模型:`_src_pos` / `_dst_pos` / `_is_going` /
`_dst_rot``walk_to()` → `_goto(dst_flat)` 一一对应 `CInstanceBase::NEW_Goto()`——
太近(`flat.length() < MOVABLE_GROUND_DISTANCE_M`,默认 1.0 m)直接返回 true 不动
(≈ `__IsMovableGroundDistance`);否则设 Src=当前位、Dst=点、`_dst_rot=atan2(x,z)`、
`_is_going=true`。`_process` 每帧朝 `_dst_pos` 推进,抵达即 `_is_going=false`。
- 三个前置门用 `_can_translate()` = `not frozen and not locked and not moving_skill`
统一(≈ `__IsSyncing` / `isLock` / `IsUsingMovingSkill`):门未过时 `_goto` 把点存进
`_reserved_ground` 并置 `_reserved_delay_time = RESERVED_GROUND_DELAY`(0.1 s)
照搬 `__ReserveClickGround` + `NEW_IsEmptyReservedDelayTime` 递减重试。
- `moving_skill` 为真时 `_goto` 不平移、`_process` 只 `player.rotation.y = atan2(wish.x, wish.z)`
(≈ `IsUsingMovingSkill()` → 只转向)。WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`
的 `m_isGoing = FALSE`)。
- 回归:新增 `project/player_move_test.gd`5 组:`_goto` 远点设 Src/Dst/is_going/dst_rot、
太近不动、每帧推进抵达清 is_going、frozen 门 → 预约 + 0.1 s 后解冻重试、`moving_skill`
只转向)。`netplay_test` / `combat_fx_test` / `gamescene_test` / C++ 19/19 全绿。
- 增量 77 已补上 §3.2 的命令队列(`skip_collision` 标志已就位)。
- **增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**`player_controller._actor_blocked(next_pos)`
实现 `CActorInstance::TestActorCollision``ActorInstanceCollisionDetection.cpp`+
`CInstanceBase::CheckAdvancing``InstanceBaseBattle.cpp:469`)近似——距离门 8 m
(≈ `LengthSq > 800²`)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,固定半径近似真实
race 碰撞球)→「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision`
末尾 `dist(cur) <= dist(last)` 才 `return TRUE`,远离放行)。候选=`pickables``dead` meta
跳过(≈ `rVictim.IsDead()`)。`_process` 平移改逐轴门控(单 Actor 逐轴放行=滑移 ≈
`AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧 ≈ `BlockMovement` / `__InitializeMovement`)。
`skip_actor_collision`(≈ `CanSkipCollision` / `__EnableSkipCollision`)由 `net_play` 按观战态置位。
坐骑:`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛朝向,`_refresh_motion_mode()` 在
`mounted` 时切 `ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`
`CInstanceBase::MountHorse` → `SetRotationSpeed``InstanceBase.cpp:954`),下马恢复 1200。
`MODE_HORSE*` 动作模式沿用增量 84。`player_move_test.gd` 5 组 → 8 组。C++ 21/21 + 14 GDScript 全绿。
- **仍未做(留后续)**:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`);
`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替;
`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家;`MOVABLE_GROUND_DISTANCE`
参考默认值仍是估计(1.0 m),待读 `game.py` 侧 `SetMovableGroundDistance` 调用校准;
路径寻路 / 坡度处理。
---
### 3.2 同步是距离阈值加 lerp
**参考**:远端 Actor 的位置**完全由一条命令队列**驱动,而不是 lerp。
- 入队:`CInstanceBase::PushTCPState(dwCmdTime, kPPosDst, fRot, eFunc, uArg)`
由 `CNetworkActorManager::MoveActor``NetworkActorManager.cpp:520`)与
`__AppendCharacterManagerActor``:410`,用于补发正在移动中的 actor)调用。
- 出队:`CInstanceBase::StateProcess()``REF/UserInterface/InstanceBase.cpp:1278`
```
while (队非空):
if (ELTimer_GetServerFrameMSec() < front.m_dwChkTime) return; // 按服务器帧时间释放
pop;
if (!__CanProcessNetworkStatePacket()) return; // Dead / KnockDown / 不可取消的技能中
if (!__IsEnableTCPProcess(eFunc)) return; // 表情中 / m_bEnableTCPState==false
switch (eFunc) { ... }
```
- 各 `eFunc` 的阈值是**硬阈值**,不是 lerp:
| eFunc | 条件 | 行为 |
|---|---|---|
| `FUNC_WAIT` | `fDirLen > 1.0f` | 走过去(Src/Dst + StartWalking`m_kMovAfterFunc = WAIT` |
| `FUNC_WAIT` | 否则 | `SCRIPT_SetPixelPosition(dst)` + `SetAdvancingRotation` + `SetRotation`(瞬移对齐) |
| `FUNC_MOVE` | 无条件 | Src/Dst + StartWalking`m_kMovAfterFunc = MOVE` |
| `FUNC_COMBO` | `fDirLen >= 50.0f` | 先走过去,到达后 `RunComboAttack(rot, uArg)` |
| `FUNC_COMBO` | 否则 | 瞬移对齐 + `RunComboAttack(rot, uArg)` |
| `FUNC_ATTACK` | `fDirLen >= 50.0f` | 先走过去,到达后普攻 |
| `FUNC_ATTACK` | 否则 | 瞬移对齐 + `BlendRotation` + `RunNormalAttack(rot)` |
| `FUNC_MOB_SKILL` | `fDirLen >= 50.0f` / 否则 | 同上,动作换 mob skill |
所有「走过去」分支都会 `__EnableSkipCollision()`。
- 数据层的插值另有一份,只用于可见性判定:`SNetworkActorData::UpdatePosition()`
`NetworkActorManager.cpp:8`)按 `(now - m_dwClientSrcTime) / m_dwDuration` 线性插值,
超时钳到 Dst。
- 本地上行的节流(`REF/UserInterface/PythonPlayerEventHandler.cpp`):
| 事件 | 节流 | 发送 |
|---|---|---|
| `OnMove` (:76) | 重置两个计时器(wait +100 ms / moving +300 ms | `FUNC_MOVE, 0` |
| `OnMoving`(:62) | 300 ms | `FUNC_MOVE, 0` |
| `OnWaiting`(:38)| 100 ms **且**位移 ≥ 1.0 | `FUNC_WAIT, 0` |
| `OnStop` (:88) | 无 | `FUNC_WAIT, 0` |
| `OnAttack`(:102)| 无 | `FUNC_COMBO, wMotionIndex` |
| `OnUseSkill`(:120)| 无 | `FUNC_SKILL\|uMotSkill, uArg` |
- 上行包 `SendCharacterStatePacket``PythonNetworkStreamPhaseGame.cpp:1111`):
`TPacketCGMove{ header=HEADER_CG_CHARACTER_MOVE, bFunc, bArg, bRot = rot/5.0f,
lX, lY, dwTime = ELTimer_GetServerMSec() }`;发送前 `__CanActMainInstance()` 为假直接返回 true
角度先归一到 `[0,360)`;坐标最后经 `__LocalPositionToGlobalPosition`。
**当前**`net_world.gd` 按距离阈值 snap/lerp`net_play.gd` 按固定时间/距离发送。
**修改**
1. `EntityStore` 新增每实体的 `std::deque<StateCmd>``{chk_time, dst_x, dst_y, rot, func, arg}`),
`mut_move()` 改为 `push_tcp_state()`,不再直接改坐标。
2. 新增 `EntityStore::process_states(server_frame_ms)`1:1 实现上表的 switch
(含 `1.0f` / `50.0f` 两个阈值与 `m_kMovAfterFunc` 的「到达后动作」)。
3. `EntityStore::update(now)` 里保留 `SNetworkActorData::UpdatePosition()` 的线性插值,
但**只**用于 §2.1 的可见性判定,不驱动节点位置。
4. `net_play.gd` 的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0),
删除现有的「固定时间或距离」逻辑。
5. `ELTimer_GetServerMSec()` 的等价:`GC_HANDSHAKE` 的 `lDelta` 已用于时钟对齐
`REF/.../PythonNetworkStreamPhaseHandShake.cpp:124`),确认 `M2Client` 的
`server_time_ms()` 与之一致后统一使用,不要用本地 `ticks_msec`。
6. **不要**引入预测回滚 / 抖动缓冲——参考端没有。
**状态:🟡 部分完成(2026-09-02 / W2 / 增量 77 —— C++ 命令队列核心)**
- `Entity` 新增 `std::deque<StateCmd> state_queue``{chk_time, dst_x, dst_y, rot,
func, arg, dur_ms}`+ `mov_after_func` / `mov_after_arg` / `mov_after_rot` /
`skip_collision`(对应 `m_kMovAfterFunc` / `__EnableSkipCollision`)。
- `mut_move()` 不再直接改坐标:入队 `StateCmd`(≈ `CInstanceBase::PushTCPState`)后
立即 `drain_state_queue()` 释放已到期项。m2dev `apply()` 的 `GC_MOVE` 与 classic
parser 都改走 `mut_move(vid, angle, func, tx, ty, dur, arg, time)`,删掉旧的
`start_move()` 直改坐标路径。
- 新增 `process_states()` + 私有 `drain_state_queue` / `apply_state_cmd` /
`begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`
1:1 实现上表 switch`FUNC_WAIT` 的 `1.0` 阈值(走过去 vs 瞬移对齐)、
`FUNC_COMBO/ATTACK/MOB_SKILL` 的 `50.0` 阈值(先走再动作 vs 瞬移+动作),
「走过去」分支置 `skip_collision=true` 并把 combat 动作 + 动作索引 latch 到
`mov_after_*``tick()` 里走到 Dst 后按 latch 把 `func` 切到 COMBO/ATTACK/MOB_SKILL
再清 latch。
- 释放门:`drain_state_queue` 先按服务器帧钟 `m_server_frame_ms`(新增
`set_server_frame_ms` / `server_frame_ms`)比 `chk_time`——帧钟为 `0`(尚未对齐)时
一律立即释放,等价旧行为;再过 `__CanProcessNetworkStatePacket``dead` / `knock_down`
时 hold 队列不丢包)。走路时长优先用服务器 `dur_ms`(≈ `SNetworkActorData::UpdatePosition`
的插值窗口),缺省才按 `dist / moving_speed` 推。
- `tick()` 每帧先 `process_states()` 再推进插值。
- 回归:新增 `extension/tests/net_state_queue_test.cpp`(帧钟释放门、帧钟 0 立即释放、
`FUNC_WAIT` 1.0 阈值、`FUNC_COMBO` 50.0 阈值 + 到达后动作、knock_down / dead hold 门、
多条到期命令一次排空按序);`net_entity_test` 的 `FUNC_ATTACK` 断言按新语义改写
(近距 <50 瞬移+动作、远距 ≥50 先走后打)。C++ 20/20(含新 `net.state_queue`)、
`netplay_test` / `combat_fx_test` / `gamescene_test` / `netbridge_test` / `p2b_test` /
`player_move_test` 回归全绿。
- **留给增量 78**:④ `net_play.gd` 上行改 6 事件回调 + 节流常量(100 / 300 ms、位移 1.0);
③ `net_world.gd` 停掉自己那份 lerp,C++ 插值只喂 §2.1 可见性;⑤ 从 `GC_HANDSHAKE`
`lDelta` 接真实服务器毫秒帧钟并 `set_server_frame_ms()`(现为 0 = 立即释放);
`is_enable_tcp_process` 的表情锁位(实体行暂无该状态);GC_SYNC_POSITION 纠偏是否清队列。
**状态:🟡 部分完成(2026-09-02 / W2 / 增量 78 —— 上行 6 回调 + net_world 去 lerp**
- **④ `net_play.gd` 上行改 6 事件回调**(对齐 `PythonPlayerEventHandler.cpp` 的
`OnMove` / `OnMoving` / `OnWaiting` / `OnStop` / `OnAttack` / `OnUseSkill`):
- 新增统一出口 `_send_state(func, arg, world_pos)` → `client.move(func, arg,
heading, cm.x, cm.y)`(≈ `SendCharacterStatePacket` / `TPacketCGMove`rot 的
`/5.0` 与坐标 `__LocalPositionToGlobalPosition` 仍由 C++ `client.move()` 负责),
并记 `_last_sent_pos`。
- `_on_local_moved``pc.moved` 每帧):`_was_moving` 为假 → **OnMove** 立即发
`FUNC_MOVE`,并把 `_last_moving_sent_t` / `_last_waiting_sent_t` 都推到「刚发过」
= 重置 moving/waiting 两个节流窗);否则 → **OnMoving**`SEND_MOVING_INTERVAL`
300 ms 节流后发 `FUNC_MOVE`。
- `_on_anim_state("wait")` 且 `_was_moving` → **OnStop**:不节流,落一包 `FUNC_WAIT`。
- 新增 `_tick_on_waiting()``_process` 每帧调):站立时 `SEND_WAITING_INTERVAL`
100 ms 节流且相对 `_last_sent_pos` 位移 ≥ `SEND_WAITING_MIN_DISP_CM`(1 cm) 才发
`FUNC_WAIT` —— 吸收本地被击退 / 碰撞推挤后的漂移。
- `_do_attack_swing`:除既有 `client.attack()``CG_ATTACK`)外,**OnAttack** 额外
`_send_state(FUNC_COMBO, attack_motion + _combo, pos)`,让远端对齐攻击者位置 / 朝向;
并置 `_was_moving = false`。
- 新增公共入口 `on_use_skill(motion_skill, arg)` → **OnUseSkill**
`_send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pos)`;调用点(技能合法性 /
CD / charge)属 §3.8 的 `player_skill.gd`,这里只留 1:1 上行出口(做法同 §3.4 预留桩)。
- 删除旧的 `SEND_INTERVAL` / `SEND_DIST` / `_last_sent_t` 单一节流。
- **③ `net_world.gd` 去掉自有 lerp**`_process` 里 `n.position = _grounded(e.pos)`
直接跟随——位置插值现在完全在 C++ `EntityStore::tick()`(命令队列 +
`SNetworkActorData::UpdatePosition` 式线性插值)里做,再叠第二层 `follow_lerp` 会把
C++ 的到达时机拖慢、和 `.msa` 动作事件脱节。`follow_lerp` / `snap_dist` 字段保留只为
兼容外部读取,`_process` 不再引用。朝向仍在 GDScript 侧 `lerp_angle` 平滑(非位置)。
- 回归:`netplay_test` 新增第 11 段(OnMove 立即发 + 重置节流窗 / OnMoving 300 ms 节流 /
OnStop 收尾 / `on_use_skill` → `FUNC_SKILL|motion`+arg),观战段基线改为「相对计数不变」;
`netbridge_test` 的「near move → lerp closes in」改为「node follows e.pos directly」。
C++ 20/20 不变;`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` /
`p2b_test` / `combat_fx_test` / `player_motion_test` 全绿。
- **留给增量 79**:⑤ 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟。`ClassicStream`
已在 `HDR_HANDSHAKE` 存了 `m_server_time_base = hs.time + delta` / `m_client_time_base
= now_ms()``classic_stream.cpp:256`),需暴露 `server_frame_ms()` 访问器,穿过
`classic_session` → `classic_parser`(持 `m_world`)或在 `m2_client` `w.tick()` 前算好
`now - m_client_time_base + m_server_time_base` 调 `w.set_server_frame_ms()`m2dev
后端另有一路。接上前 `m_server_frame_ms == 0` 直通 = 增量 76 时序,无回归。
`is_enable_tcp_process` 表情锁、`GC_SYNC_POSITION` 是否清队列同样留给 §3.x。
**状态:✅ 完成(2026-09-03 / W2 / 增量 79 —— 真实服务器毫秒帧钟,§3.2 收官)**
- **⑤ `ClassicStream::server_frame_ms()`**`classic_stream.h:107` / `.cpp:48`,≈
`ELTimer_GetServerFrameMSec()`):`HDR_HANDSHAKE` 处理里早已存的
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`
`classic_stream.cpp:266-267`),帧钟 = `now_ms() - m_client_time_base +
m_server_time_base`;首个握手落地前 `m_client_time_base == 0`,返回 `0`。
另加 `has_server_clock()`。时间同步模式下每个 resync 握手都会刷新时间基,帧钟保持对齐。
- **穿线**`ClassicSession::process()` 在 `m_world.tick()` 前
`m_world.set_server_frame_ms(m_stream.server_frame_ms())``classic_session.cpp:307`),
于是 classic 后端 `drain_state_queue()` 按服务器时间放行队列。m2dev 后端无握手 `lDelta`
对应物,`m_server_frame_ms` 恒 `0` = 即时释放 = 增量 76 时序,无回归。
- **防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000`**`entity_store.h`):参考端 `StateProcess`
没有上限;我们的帧钟尚未联机核验,若服务器 `dwTime` 落在我们不共享的时基里,「等到
`frame >= chk_time`」会让远端 Actor 永久冻结。故 `chk_time` 领先帧钟超过 1 s 视为异常,
立即放行。**有意偏离参考,已在此记录。**
- 回归:`net_classic_stream_test` 加帧钟段(握手前 `0` + `!has_server_clock()`、握手后
≈ `hs.time + lDelta`、连续读单调不减);`net_state_queue_test` 加第 6 段(`chk_time`
正好等于 `frame + cap` 仍 gated、超一毫秒立即放行)。C++ 20/20、7 个 GDScript 回归全绿。
- **§3.2 至此完成**。仍待联机核验:真实服务器 `GC_MOVE` `dwTime` 的时基是否与握手帧钟一致
(若一致,`STATE_QUEUE_MAX_WAIT_MS` 阀几乎不会触发);`is_enable_tcp_process` 表情锁位、
`GC_SYNC_POSITION` 纠偏是否清队列,随对应 §3.x 再处理。
---
### 3.3 普攻节奏硬编码
**参考**:攻击节奏完全来自 `.msa` 动作数据,没有任何硬编码间隔。
- 攻击距离:`CInstanceBase::NEW_IsClickableDistanceDestInstance`
`REF/UserInterface/InstanceBaseBattle.cpp:294`
```
fDistance = 150.0f;
if (IsBowMode()) fDistance = __GetBowRange();
if (dst.IsNPC()) fDistance = 500.0f;
if (dst.IsResource()) fDistance = 100.0f;
```
对像素坐标的通用判定 `NEW_IsClickableDistanceDestPixelPosition` 固定 `150.0f``:283`)。
- 攻击间隔:由动作时长决定。`CActorInstance::InputComboAttackCommand`
`REF/GameLib/ActorInstanceBattle.cpp:157`)用
`m_pkCurRaceMotionData->GetComboInputStartTime() / GetNextComboTime() / GetComboInputEndTime()`
`CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime}`
`REF/GameLib/RaceMotionData.h:177`);无 combo 输入数据时退化为
`fElapsedTime > GetMotionDuration() * 0.9f`。
- 攻速:`SetAttackSpeed(m_dwAtkSpd)` 影响动作播放速率
**[待读参考:`REF/UserInterface/InstanceBase.cpp` 的 `CInstanceBase::SetAttackSpeed`]**。
**当前**`POC/project/net_play.gd:25` 一组固定常量。
**修改**
1. 距离常量搬到 `net_play.gd``CLICK_DIST_DEFAULT=150.0`、`CLICK_DIST_NPC=500.0`、
`CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价。
已读参考:`CInstanceBase::__GetBowRange``REF/UserInterface/InstanceBase.cpp:694`=
`2500.0f - 100.0f`,主角再加 `IAbstractPlayer::GetStatus(POINT_BOW_DISTANCE)``Packet.h:1550`
索引 34,注释单位 meter,但参考端按 cm 直加不 ×100);`IsBowMode()` =
`GetMotionMode()` 为 `MODE_BOW` / `MODE_HORSE_BOW``REF/GameLib/ActorInstance.cpp:258`)。
—— **增量 85 落地**(见 §3.5 状态块 / 下方)。
2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见
`metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end /
motion_duration`。
3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。
**状态:✅ 完成(2026-09-02 / W2 / 增量 74**
- `formats/msa.{h,cpp}``parse_msa` / `parse_msa_file` 新增 `combat_data()`,读取
`Group ComboInputData``PreInputTime` / `DirectInputTime` / `InputLimitTime` /
`LinkTime` → `TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime, fComboLinkTime}`
和 `Group AttackingData``AttackingType` / `HittingType` / `AttackingStartTime` /
`AttackingEndTime` / `StiffenTime` / `InvisibleTime` / `ExternalForce`)。缺组时
`has_combo_input` / `has_attacking_data` 保持 `false`。`formats/tests/msa_test.cpp`
用真实 `onehand_sword/combo_01.msa` 的字段值断言。
- `extension/src/metin2_anim.{h,cpp}`:新增并绑定 `get_motion_data()`,导出
`duration` / `next_combo``has_combo_input && DirectInputTime>0 ? DirectInputTime :
duration*0.9`/ `attack_start_time` / `attack_end_time` / `stiffen_time` /
`invisible_time` / `external_force` 等。
- `net_play.gd`:删除 `ATTACK_RANGE` 常量,改为 `CLICK_DIST_DEFAULT_CM=150` /
`CLICK_DIST_NPC_CM=500` / `CLICK_DIST_RESOURCE_CM=100``_clickable_distance(te)`
按 `_entity_kind` 选,单位 cm→m)。`_apply_attack_speed()` 只把 `bAttackSpeed/100`
夹到 `[0.25,3]` 写入 `_atk_speed_factor`(缩放播放速率),不再在收 info 包时把
`attack_period` 直接写死。攻击命中点用 `attack_period = _current_attack_period()`
= `(next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`。
—— **弓箭 `__GetBowRange()` 已在增量 85 接入**`_clickable_distance()` 里 `_is_bow_mode()`
时改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`
`_on_points()` 读 `points["bow_distance"]` = `POINT_BOW_DISTANCE`=34),NPC / 采集物分支覆盖之。
详见 §3.5 状态块。
- 回归:C++ 19/19`formats.msa_loop_data` 覆盖扩展断言);`combat_fx_test` /
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
`p2b_test` 全绿。
---
### 3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑
**参考**:两段。
**(a) `CPythonPlayer::__Update_AutoAttack()`**`REF/UserInterface/PythonPlayer.cpp:207`),每帧:
```
if (0 == m_dwAutoAttackTargetVID) return;
if (!NEW_GetMainActorPtr()) return;
if (__IsUsingChargeSkill()) return; // 弹射/冲锋中跳过
victim = NEW_FindActorPtr(m_dwAutoAttackTargetVID);
if (!victim) __ClearAutoAttackTargetActorID();
else if (victim->IsDead()) __ClearAutoAttackTargetActorID();
else if (main->IsMountingHorse() && !main->CanAttackHorseLevel())
__ClearAutoAttackTargetActorID();
else if (main->IsAttackableInstance(*victim))
{ if (main->IsSleep()) { /* skip */ } else __ReserveClickActor(m_dwAutoAttackTargetVID); }
```
`__IsUsingChargeSkill()``PythonPlayer.cpp:182`):`__CheckDashAffect` 为真,或
`MODE_USE_SKILL` 且预约技能是 `IsChargeSkill()`。
**(b) `CPythonPlayer::__ReserveProcess_ClickActor()`**`PythonPlayerInput.cpp:877`),
每帧在 `MODE_CLICK_ACTOR` 下跑,**顺序不可改**
```
1 main / reserved 任一为空 → __ClearReservedAction(); return
2 !NEW_IsClickableDistanceDestInstance → NEW_MoveToDestInstanceDirection(); return (继续靠近)
3 !IsAttackableInstance → NEW_Stop(); __SendClickActorPacket(); 清预约; return (NPC 对话)
4 reserved->IsDead() → 清预约; return
5 main->IsInSafe() → OnCannotAttack(..., "IN_SAFE"); NEW_Stop(); 清预约; return
6 reserved->IsInSafe() → OnCannotAttack(..., "DEST_IN_SAFE"); NEW_Stop(); 清预约; return
7 __CheckDashAffect(main) → return (保持预约)
8 main->IsBowMode() && !__HasEnoughArrow() → OnCannotShot(..., "EMPTY_ARROW"); NEW_Stop(); 清预约; return
9 reserved VID != GetTargetVID() → SetTarget(reserved VID)
10 NEW_AttackToDestInstanceDirection(reserved); __ClearReservedAction();
```
**(c) 预约模式枚举**`PythonPlayerInput.cpp:820-856`):
`MODE_NONE / MODE_CLICK_ITEM / MODE_CLICK_ACTOR / MODE_CLICK_POSITION / MODE_USE_SKILL`
每帧在 `NEW_RefreshMouseWalkingDirection()``PythonPlayerInputMouse.cpp:287`)里分派;
末尾还依次处理 `m_isSmtMov / m_isDirMov / m_isDirKey / m_isAtkKey`。
**当前**:只有「测距后发攻击包」。
**修改**
1. `player_controller.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}`
与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved /
_reserved_delay`,逐字对齐 `PythonPlayerInput.cpp:820-865`。
2. 每帧执行 `_refresh_reserved()`,四个 case 分别照抄
`PythonPlayerInputMouse.cpp:288-370``MODE_CLICK_ITEM`(按 IID 查掉落物世界坐标,
`< 20.0f` `CLICK_ITEM_PICKUP_CM` 到手 → `FUNC_WAIT` + `pickup_item` + 解冻 + 清预约,
否则 `walk_to`;物品消失 → 清预约)、`MODE_CLICK_POSITION``_can_translate()` 未锁且
`_reserved_delay <= 0`(≈ `NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` +
清预约)、`MODE_USE_SKILL``dist_cm` 含 `IS_HUGE_RACE` 的 `-200cm` 修正,`<
_skill_range_reserved` → `set_target` + `use_skill_hook(slot)` 成功才清预约,否则
`walk_to`;目标丢失 → 清预约)。`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用
`proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似(seam)。
3. `_reserve_process_click_actor()` 严格按 (b) 的 10 步实现。
4. `_update_auto_attack()` 按 (a) 实现;`m_dwAutoAttackTargetVID` 只在
`__OnPressActor(isAuto=true)` 且 `IsAttackableInstance` 时设置
`PythonPlayerInput.cpp:306` `__OnPressActor`)。
5. `OnCannotAttack` / `OnCannotShot` / `OnCannotUseSkill` 的字符串码
`IN_SAFE / DEST_IN_SAFE / EMPTY_ARROW / NEED_TARGET / NOT_HORSE_SKILL / HAVE_TO_RIDE /
NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD`)走 `game.py` 同名回调的
`localeInfo` 文案表。
**状态:✅ 完成(2026-09-03 / W2 / 增量 91**(增量 75 完成 CLICK_ACTOR 全量;增量 91 补齐
CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派 + `__ChangeTargetToPickedInstance` 拾取回退 +
扇形/圆形补 fly-targeting
- `net_play.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION,
USE_SKILL}` 与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved /
_skill_range_reserved / _reserved_delay / _auto_attack_vid / _last_cannot`
(对齐 `PythonPlayerInput.cpp:820-865`)。`_process` 改为每帧
`_update_auto_attack()` → `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection`
的模式分派)。
- `_update_auto_attack()` 逐条照抄 `__Update_AutoAttack()`PythonPlayer.cpp:207):
无目标 / `!_can_process_network_state()`(≈ `!NEW_GetMainActorPtr` / `__IsSyncing`/
`_dash_active || (_using_skill && _charge_skill)``__IsUsingChargeSkill`/ 目标不存在 /
死亡 / 骑乘且 `!CanAttackHorseLevel` 各自处理,可攻击且非睡眠时无条件
`_reserve_click_actor()`(节奏不在这里 gate)。
- `_reserve_process_click_actor()` 严格 10 步(`PythonPlayerInput.cpp:877`,顺序不可改):
1 空引用清预约 → 2 出可点击距离 `pc.walk_to()` 继续靠近 → 3 不可攻击 `client.click_npc()` +
清预约(NPC 对话)→ 4 目标死 → 5 `main.in_safe` → `OnCannotAttack("IN_SAFE")` →
6 `reserved.in_safe` → `"DEST_IN_SAFE"` → 7 `_dash_active` 保持预约 →
8 弓箭模式且 `_arrow_count==0` → `OnCannotShot("EMPTY_ARROW")` → 9 未选中则 `set_target` →
10 `_attack_cd<=0` 才 `_do_attack_swing()`(面向 + combo + `CG_ATTACK` + 动作 + §3.3 节奏刷新),
然后清预约、下帧重新预约。
- `OnCannot*` 走新 `signal cannot_act(code)` + `_last_cannot`HUD 接 `localeInfo`)。
- 服务端尚未下发的判定位(`in_safe` / `bow_mode` / `mounting` / `sleeping` / dash affect /
charge skill / 箭矢数)先用实体字典键 + 本地占位变量(`_arrow_count=-1` / `_dash_active` /
`_charge_skill`)接线,真值填充与 §5.4 PVP / §3.8 技能 / 弓箭 `__GetBowRange` 一起补,
做法同 §3.7 的 `knock_down`。
- 回归:`netplay_test` 新增 5b 段(出距离→`walk_to` 且不发包、进距离→攻击且清预约、
`IN_SAFE`→`cannot_act` 且不发包、目标死→auto-attack 清零);C++ 19/19、`combat_fx_test` /
`netbridge_test` / `gamescene_test` / `player_motion_test` / `p2b_test` / `input_key_test`
全绿。
**增量 91 补充**`net_play.gd` / `ui/skill_table.gd` / `ui/quickbar.gd` / `game_scene.gd`):
- `_refresh_reserved()` 补齐自增量 75 留桩的三个 case,逐字对齐 `NEW_RefreshMouseWalkingDirection`
`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`:290)、
`_reserve_process_click_position()`:320`_reserved_delay` 由 `_process` 逐帧递减 ≈
`NEW_IsEmptyReservedDelayTime`)、`_reserve_process_use_skill()`:340`_is_huge_race`
的 `HUGE_RACE_DIST_FIX_CM` `-200cm` 修正)。`_clear_reserved()` 同时复位 `_ppos_reserved` /
`_skill_range_reserved`。
- 预约入口:`reserve_click_item(iid)` / `reserve_click_ground(pos)``_reserved_delay=0.1`/
`reserve_use_skill(vid, slot, range_cm)`= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`
`range > 100` 先减 `RESERVE_SKILL_RANGE_TRIM_CM(10)`/ `pick_ground_item(iid)`= `__OnPressItem`——
清预约 + auto-attack 归零,`CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。
- `_on_pick()` 里 `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode==USE_SKILL &&
_dash_active` 时只改 `_vid_reserved`;战斗目标改走 `_change_target_to_picked_instance(vid)`
= `__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定
PC/怪,否则 `set_target(0)` + `clear_target`NPC/石头/warp 仍由 `_on_pick` 先分流到 `click_npc`)。
- `_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用 `proto.mob(race)` 的
`size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似,查不到 → false(文档化 seam)。
- 扇形/圆形补 fly-targeting`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块):
`send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING` 已由
`M2Client::use_skill` 发过,这里只补附加目标——`_instances_in_fan_range()`=
`NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角
`(MAX-MIN) - RPM*min(dist,1000) + MIN``FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`/
`_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid,
sx, sy)` 补齐;名额不足时 `_random_pos_in_fan_range()`= `NEW_GetRandomPositionInFanRange`
:124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)
→ `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。
- `ui/skill_table.gd``_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析
`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)`
= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint` 代入
后 `floor`)、`target_range(id)`= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`
弓距加成留 `net_play` 叠加)、`fly_shape(id)``FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0)。
- `ui/quickbar.gd``activate()` 里 `client.use_skill` 返回后,若 `target_vid != 0` 且技能为
`FAN_RANGE` / `CIRCLE_RANGE` → 调 `net_play.send_fly_targeting(...)`;新增 `_skill_level(sid)`、
`activate_reserved(global_slot)``use_skill_hook` 回调)。`game_scene.gd` 接
`net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。
- 回归:`netplay_test` 新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、
物品消失→清;CLICK_POSITION 延时门 + `walk_to`USE_SKILL `range>100→-10`、射程外`walk_to`、
射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 /
怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、
`max_count<=1` / `SINGLE` → 0);`skill_test` 补 skill 47gwangyeokFAN + Range 2500 +
`target_count(lv1)=2` / `(lv20)=5`/ skill 91bipabuFAN + Range 1800 + 无公式→0/
skill 1`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿。
---
### 3.5 攻击动作、命中时机和网络包没有严格绑定
**参考**:三条链完全由动作数据驱动。
**(a) 连击索引**`REF/GameLib/ActorInstanceBattle.cpp:157 / 213 / 253`):
```
InputComboAttackCommand(rot):
m_fAtkDirRot = rot;
if (m_isPreInput) return false;
if (0 == m_dwcurComboIndex) { __RunNextCombo(); return true; }
if (motion->IsComboInputTimeData()):
t = GetAttackingElapsedTime()
if (t > GetComboInputEndTime()) { if (IsBowMode()) m_isNextPreInput = TRUE; return false; }
if (t > GetNextComboTime()) { __RunNextCombo(); return true; }
if (t > GetComboInputStartTime()) { m_isPreInput = TRUE; return false; }
else:
if (GetAttackingElapsedTime() > GetMotionDuration()*0.9f) { __RunNextCombo(); return true; }
```
`__RunNextCombo()``:253`):`++m_dwcurComboIndex`,用
`m_pkCurRaceData->GetComboDataPointer(m_wcurMotionMode, __GetCurrentComboType(), &pComboData)`
取 `ComboIndexVector[m_dwcurComboIndex-1]` 作为动作号,`ComboAttack(idx, m_fAtkDirRot, 0.1f)`。
连击打满时 `__OnEndCombo()`:骑马下 `m_dwcurComboIndex = 1`,否则**不复位**(靠动作回 Wait 复位)。
`__ClearCombo()``:305`)才把索引清零。
**(b) 命中帧**`ActorInstanceBattle.cpp:355 / 386` + `RaceMotionData`):
`isValidAttacking()` 遍历 `TMotionAttackData::HitDataContainer` 的
`THitData{fAttackStartTime, fAttackEndTime}`,落在窗口内才算命中窗口;
`CanCheckAttacking()` = `isAttacking()`(普攻 / 连击 / splash 三者之一)。
**(c) 发包**`REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit`):
```
OnHit(uSkill, victimActor, isSendPacket):
CPythonPlayer::SetTarget(victimVID, FALSE);
if (isSendPacket) rkStream.SendAttackPacket(uSkill, victimVID); // CG_ATTACK{bType, dwVictimVID}
if (!victim.IsPushing()) return;
if (IS_HUGE_RACE(victim.GetRace())) return;
CPythonCharacterManager::AdjustCollisionWithOtherObjects(&victim);
victim.TEMP_Push(lastX, lastY); 记录到 m_kVctkVictim
```
`FlushVictimList()``:194`)把本帧所有被击退者打成一个 `TPacketCGSyncPosition`
(上限 `SYNC_POSITION_COUNT_LIMIT = 16`)。
`CInstanceBase::AttackProcess()``InstanceBaseBattle.cpp:413`)在 `CanCheckAttacking()` 为真时
遍历全部实例做 `CheckAttacking()`,后者先查 `IsInSafe()` 双方,再
`AttackingProcess(victimActor)` 做真正的几何判定。
**当前**`net_play.gd` 的 combo 只在 0/1/2 循环,命中/发包用定时器。
**修改**
1. ~~`metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与
combo 输入三时间~~ —— **完成(增量 82)**:`formats/msa.{h,cpp}` 把 `Group AttackingData`
按 `GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法解析成
`std::vector<Msa::HitWindow>`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`
无 `HitDataCount`= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` +
`Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每个窗带
`start_time` / `end_time` / `bone` / `weapon_length` + `List HitPosition` 采样
`{time, last_pos, pos}` × n7 个浮点一组)。`textscript.cpp` 同时补上
`List Name { ... }` 块解析(原来 `List` 会把花括号吞进普通行、还会串掉其后的
`Group`)。`metin2_anim.get_motion_data()` 新增 `hit_windows` 数组 + `motion_type` /
`hit_limit_count``attack_start_time` / `attack_end_time` 保留为第一个窗的起止(兼容旧调用)。
`msa_test` 加内联单窗 / 新式双窗合成断言 + `M2_ASSETS` 时对真实
`warrior/general/attack`17 采样)、`twohand_sword/combo_05`(150 武器长 / 37 采样)、
`assassin/dualhand_sword/combo_01`R Hand + L Hand 双窗)、`warrior/skill/palbang`
`AttackingData` 后还有 `MotionEventData`,List 修复后 3 个事件都解析到)断言。
2. ~~`RaceData` 的 combo 表需要从 `.msm` 解析出来~~ —— **更正(增量 80)**:PC 连击段表
**不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)。真正来源是
`assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` /
`RegisterComboAttackNew` 调用(绑定 → `CRaceData::ReserveComboAttack` /
`RegisterComboAttack``REF/GameLib/RaceData.cpp:405`)。已由 **`formats/combo_table.{h,cpp}`**
`fmt::PlayerComboTables`)解析:按 `def __LoadGame<Class>Ex` 分 4 个职业块,
每块一张 `map<MAKE_COMBO_KEY(motionMode,comboType), vector<段号>>`,语义对齐
`CRaceData`Reserve 用 `map::insert` 不覆盖、Register key 未登记或下标越界即跳过)。
`formats_combo_table_test` 覆盖合成片段 + 真实 `playersettingmodule.py`。
3. ~~`player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`),
删除 0/1/2 循环。~~ —— **完成(增量 81)**:`net_play.gd` 的 `_do_attack_swing` /
`_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐
`CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` /
`__OnEndCombo` / `__ClearCombo``REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`):
`_combo_index==0` → 直接推进;否则按 `.msa` `ComboInputData` 三时间
`pre_input_time`=InputStartTime、`direct_input_time`=NextComboTime、`input_limit_time`=InputEndTime
判 `_is_pre_input` 挂起 / 直接推进 / 弓模式置 `_is_next_pre_input`;无 combo 输入组时用
`duration*0.9` 兜底;`_combo_process()` 每帧触发挂起输入、动作回 Wait 时 `_clear_combo()`
段表越界(`__RunNextCombo` 的 OOB 分支)不推进;末段 `__OnEndCombo` 仅骑乘复位到 1。
段号来自增量 80 的 `Metin2AnimPlayer.parse_combo_tables()`(新静态绑定,读
`root/playersettingmodule.py`),按 `race & 3` 选职业块、按 `combo_motion_mode<<16|type` 取段序列
`combo_motion_mode` 默认 GENERAL,武器→mode 映射留给增量 82+)。缺段表(headless / 缺资源)
退化为单段普攻。`game_scene.gd` 装配时 `net_play.set_asset_root(_assets)`。
`combat_fx_test.gd` 加 `FakeAnim`(可控 `.msa`+ 连击状态机全路径断言。
4. ~~命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`
`FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条)~~ —— **完成(增量 83)**:
`net_play.gd` 新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` /
`motion_type` / `hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、
`_attack_process(dt)`(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` +
`ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):每帧动作时间窗
`[t-dt, t]` 与某个 `hit_window [start,end]` 相交时,遍历 `client.get_entities()` 里可攻击、
未死、双方不在安全区的实体,`net_world.node_for(vid)` 取世界位做几何判定;`_hit_deduped()`
查重(`m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标只一次,其余按 `invisible_time` 冷却)、
`_register_hit()` 校验每窗命中上限(NORMAL/COMBO 16、SKILL `hit_limit_count`);命中 →
`client.attack(_swing_skill, vid)`= `OnHit` → `SendAttackPacket``_swing_skill` 普攻 0),
被击退目标(`knock_down` / `stunned`)记入 `_victim_flush`。`_emit_swing()` 因此改为
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
`_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。
几何判定是 **近似**`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
`±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` +
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
6. ~~`combo_motion_mode` 恒 `MOTION_MODE_GENERAL`,未按装备武器种类切 motion set~~ —— **完成
(增量 84**`net_play.gd` 补全 `MOTION_MODE_*``CRaceMotionData::EMode` 全 17 项)+
`WEAPON_SUB_*``CItemData::EWeaponSubTypes`+ `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射
`static motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻
`CInstanceBase::RefreshState``REF/UserInterface/InstanceBase.cpp:2851`)的 `SetMotionMode`
Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、钓竿→骑乘 HORSE / 否则
FISHING、骑乘按 subtype→`HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `HORSE_{FAN,BELL,BOW}` /
`HORSE`、否则按 subtype→`{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `BOW` / `FAN` / `BELL`、
`ARROW` 及其余→`GENERAL`。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`0 回退
`get_equipment()[WEAR_WEAPON]`),`_refresh_motion_mode()` 读 `proto.item(vnum)` 的
`type`/`sub_type` + 实体 `mount_vnum`/`polymorph` → `set_combo_motion_mode()`(换模式
`_clear_combo()`)。接线在 `_on_entity_info` 末尾、`_on_inv_changed_mode``inventory_changed`
window==EQUIPMENT)、`mount_changed` 信号。`combat_fx_test.gd` 加 `FakeProto` + 15 段映射断言
+ 2 段端到端。
7. ~~弓箭模式 `_clickable_distance()` 缺 `__GetBowRange()` 分支~~ —— **完成(增量 85)**:
`net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance`
`InstanceBaseBattle.cpp:294`):默认 150 → `_is_bow_mode()``MODE_BOW` / `MODE_HORSE_BOW`
对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)改用 `_bow_range_cm()` → NPC 500 →
采集物 100(后写 `if` 覆盖前面)。`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) +
`_bow_distance_bonus`1:1 复刻 `CInstanceBase::__GetBowRange``InstanceBase.cpp:694`);
`_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]` 填充,`wire.h` 补
`POINT_BOW_DISTANCE=34`、`M2Client::get_points()` 增 `d["bow_distance"]`。`combat_fx_test.gd`
§3.5 段加 8 段断言。
**状态:🟡 部分完成(2026-09-03 / W2 / 增量 85 —— §3.5 修改 1-7 全落地)**
—— 修改 1 完成(增量 82`formats/msa.{h,cpp}` `Msa::HitWindow` 多命中窗 + `textscript.cpp`
`List` 块解析 + `get_motion_data().hit_windows` + `msa_test` 合成 / 真实资源断言);
修改 2 完成(增量 80`formats/combo_table.{h,cpp}` + `formats_combo_table_test`);
修改 3 完成(增量 81`net_play.gd` 连击状态机对齐参考端五函数 + `Metin2AnimPlayer.parse_combo_tables()`
静态绑定 + `game_scene` 装配 + `combat_fx_test` 全路径断言);修改 4 完成(增量 83:
`net_play.gd` `_begin_hit_windows` / `_attack_process` / `_hit_deduped` / `_register_hit` /
`_hit_geometry` / `_flush_victim_list` —— 命中窗几何判定 → `OnHit`→`CG_ATTACK`、帧末
`FlushVictimList`→`CG_SYNC_POSITION`≤16,几何判定用 reach+正面弧近似留桩);修改 5 已在增量 78 落地;
修改 6 完成(增量 84`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85`_clickable_distance()` 弓箭
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
---
### 3.6 ~~弓箭发射使用固定 1.5 秒定时器~~ —— 已修复(增量 86 / 增量 92)
**状态:✅ 完成(2026-09-03 / W2 / 增量 86;飞行物实体 + `__CanShot` 增量 92**
`game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底已换成
`_pending_shots: Array[int]`(FIFO,上限 8,满则丢最旧):每次弓挥击 / 弓技能起手
`_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event`
的 `type 6``pop_front()` 弹一个发 `client.shoot()`pop 即自清 = `_AutoClear`,队列天然
支持连射多箭在途),对齐 `CActorInstance::ProcessMotionEventFly`
`ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget``ActorInstanceMotion.cpp:372`
`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`
`_is_bow_mode()` 时先 `_send_fly_target()`(取目标节点 → `MapCoord.to_server_cm()` →
`client.add_fly_targeting(vid, x, y)`),再 `bow_shot_fired.emit(_swing_skill)` 让
`game_scene` 入队。
**增量 92 补充**`project/fly_object.gd` 新增 + `net_world.gd` / `net_play.gd`):
飞行物实体本身已落地——`fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐
`REF/GameLib/FlyingObjectManager.{h,cpp}``CreateFlyingInstanceFlyTarget` / `Update` 的
`while` 迭代 + `erase` / `CreateIndexedFly` 的 NORMAL / FIRE_CRACKER / AUTO_FIRE 分支)+
`FlyingInstance.{h,cpp}``Create` → `__SetDataPointer` → `__SetTargetDirection``Update`
的 `vel += accel*dt` → `remain_range -= |movement|` → 线段-点 `< bomb_range²` 命中 →
`OnShootDamage` / pierce / `__Explode` / `__Bomb``hit_on_background` 的
`GetTerrainHeight` 判定)+ `FlyingData.cpp` `__Initialize()` 默认值。线段判定
`_seg_point_sq_dist` 1:1 移植 `REF/EterLib/GrpMath.h:80`。`net_world._on_fly` 把原
tween「发光球」换成 `FlyManager.spawn(...)` 真实弹道 + `signal fly_exploded`。
`net_play._can_shot()` 防御式重建 `__CanShot`(非弓 / 无箭 `EMPTY_ARROW` / 无目标
`NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走
`OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`。
seam`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70),
实际驱动走 POC 自有 `fly_cue``__CanShot` 在发布源码树中无定义(防御式重建);
`.fly` 资源加载器缺失 → 合成箭参数;怪物球体碰撞 / `CullingMgr` / 建筑体素判定未移植
(客户端仅做命中时机,伤害服务端裁决);重力参考端作用于 `.z`=上,本 POC 映射到 `-Y`。
仍待:真服 `CG_FLY_TARGETING` 的 z 分量、`.fly` 数据文件解析。
**参考**:由 `.msa` 的 `MOTION_EVENT_TYPE_FLY` 事件驱动,
经 `IFlyEventHandler` 两个回调(`REF/UserInterface/PythonPlayerEventHandler.cpp:235`):
```
CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget()
→ SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING
CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex)
→ SendShootPacket(dwSkillIndex) // CG_SHOOT
```
handler 由 `__OnPressActor` 通过
`rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&main, &victim)` 取得并传给
`NEW_AttackToDestInstanceDirection``PythonPlayerInput.cpp:358`)。
`MOTION_EVENT_TYPE_FLY` 是 `CRaceMotionData::EMotionEventType` 的第 6 项
`REF/GameLib/RaceMotionData.h:168`)。
**当前(增量 86 前)**`game_scene.gd` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。
**修改**
1. ~~`.msa` 的 motion event 分派新增 `FLY` 分支~~ —— **完成(增量 86)**:`type 6` 分支
`pop_front()` `_pending_shots` → `client.shoot(skill)``OnShoot`);`OnSetFlyTarget` 提前到
`net_play._emit_swing` 里 `_send_fly_target()` → `client.add_fly_targeting(vid, x, y)`。
2. ~~删除定时器与单槽 `pending skill`;改为每次攻击一个 handler 实例(`_AutoClear`),支持并发~~
—— **完成(增量 86)**:`_pending_shots: Array[int]` FIFO(上限 8)替掉 `_pending_shoot_skill`
+ `create_timer(1.5)``pop_front()` 即自清,连射多箭各自一发。
3. ~~`SendFlyTargetingPacket` 坐标取 `OnGetFlyTargetPosition()`~~ —— **完成(增量 86**
`OnGetFlyTargetPosition``REF/GameLib/ActorInstanceFly.cpp:4`= 目标 `m_v3Center`
(包围球中心)经 `GetTransform()` 变换到世界坐标;其平面分量与角色脚点一致,而
`CG_FLY_TARGETING` 只带平面坐标,故 `_send_fly_target()` 直接取目标节点 `global_position`
经 `MapCoord.to_server_cm()` 换 server-XY。
---
### 3.7 受击状态分裂
**参考**:**没有**「客户端硬直」这种本地状态。可用于门控的只有三个,且全部来自
`CActorInstance`
- `IsDead()` / `IsKnockDown()` / `IsStun()`
- `__CanProcessNetworkStatePacket()``REF/UserInterface/InstanceBase.cpp:1247`)只查
`IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`
- `NEW_Attack()``InstanceBaseBattle.cpp:355`)与 `NEW_UseSkill()``:310`)的门也只有
`IsDead / IsStun / IsKnockDown``NEW_Attack` 多一个 `IsUsingSkill`)。
`Stun` 来自服务端 affect`InstanceBaseEffect.cpp:932 case AFFECT_STUN`),
`KnockDown` 来自被击退动作,**都不是本地计时器**。
**当前**`EntityStore.stunned` 与 `NetPlay._hitstun_until` 两套并存。
**修改**
1. **删除 `net_play.gd` 的 `_hitstun_until` 及其全部引用**——参考端不存在这个概念。
2. `Entity` 增加 `knock_down: bool`,由受击动作(`.msa` 的 knockdown 动作播放中)置位,
动作结束清零;`dead` / `stunned` 保持由服务端包驱动。
3. 所有「能否动作」的判定统一走三个函数:
`_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`
实现分别对齐 `InstanceBase.cpp:1247`、`InstanceBaseBattle.cpp:355`、`:310`。
**状态:✅ 完成(2026-09-02 / W2 / 增量 73**
- `net_play.gd`:删除 `const HITSTUN` 与 `var _hitstun_until` 及其全部引用;
新增 `var _knock_down`(击退动作播放中,对齐 `CActorInstance::IsPushing` /
`InterceptOnceMotion(NAME_DAMAGE_FLYING)`)和 `var _using_skill`(§3.8 占位)。
- 三个闸门函数落地:`_can_process_network_state()` = `not(dead or stunned or
knock_down)`(≈ `__IsSyncing`);`_can_attack()` / `_can_use_skill()` =
前者且 `not _using_skill`。`is_stunned()` 保留为兼容 shim = `not
_can_process_network_state()`。`_process` 的 `pc.frozen` 与攻击闸门改走这些函数。
- `_on_damage`:非闪避命中只 `set_anim_state("damage")` + 抖屏 + 置
`_knock_down`,不再写任何计时器;击退动作播完由 `_process` 轮询
`player_view.is_in_hit_reaction()` 清零。
- `player_view.gd``_on_playback_finished` 增加 `_state == "damage"` 分支
(一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`。
- C++ `Entity` 增加 `knock_down``entity_dict` 暴露 `"knock_down"`
`mut_knockdown(vid,on)` 落位并标脏);本地玩家由 `net_play._knock_down`
驱动,**远端实体的 `knock_down` 填充仍待接线**(后续增量,仅影响远端受击
表现,不涉及输入闸门)。
- 回归:`combat_fx_test.gd` 断言改写(命中→`_knock_down`、服务端 `stunned`
标志独立驱动、闪避/他人受击不置位);C++ 19/19、`combat_fx_test` /
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
全绿。
---
### 3.8 技能缺少统一合法性检查
**状态:✅ 完成(2026-09-03 / W2 / 增量 87+88+89 —— 修改 1/2/3 全落地)**
—— 修改 2 完成(增量 87`project/ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc
第 11/12 列折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`,一众 `IsXxx()` 谓词
逐条对齐 `CPythonSkill``skill_test.gd` +34 断言)。修改 1 完成(增量 88:新建
`project/player_skill.gd` —— `RefCounted` 纯校验器,逐段移植 `ClickSkillSlot`(a) /
`__CanUseSkill`(b) / `__CheckSkillUsable`(c) / `__UseSkill`(d),命名常量
`SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / 骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996` /
钓鱼 `123` / 连击 `122`TSkillData 判定全走增量 87 谓词;`net_play.skill_context()` 供运行期
上下文,`quickbar.activate()` skill 分支先过 `click_skill_slot()` 再发 `use_skill`/`cast_skill`
挡下发 `skill_rejected(id, code)` → `game_scene` 转 `cannot_act` 走同一条 `OnCannotUseSkill`
文案通道;新 `player_skill_test.gd` 逐分支断言)。POC 无 C++ skill proto,技能数据只在
GDScript,故“在 `POC/extension/src/proto` 侧补解析”改为在 `skill_table.gd` 内落地(架构一致)。
修改 3 完成(增量 89):① `net_bounds.h` 加 `SKILL_MAX_NUM=255` + `skill_index(v)=0<v<255`
谓词,`M2Client::use_skill()` 的 `skill_id` 形参从 `>0 && bounds::u32` 收紧成
`bounds::skill_index()`(带符号形参 + 发包前范围校验,对齐 `ClickSkillSlot` 的
`slot >= SKILL_MAX_NUM` 门),`cast_skill()` 保留 `motion_idx ∈ [0,0x7f]`7 位 wire 位段),
`net_bounds_test.cpp` +4 断言。② `skill_table.gd` `_load_cooltime` 更名 `_load_msk`,加解析
`.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]``LocaleService_GetSkillPower` INTERNATIONAL
表,`Locale.cpp:126`+ `_skill_point(level)` + `need_sp(id,level)`(≈ `GetNeedSP`
`PythonSkill.cpp:1426`POC 约定:无公式返回 `-1`,参考返回 0,避免误挡)+ `need_hp()`
(照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖);`cooldown_of` 的 `SkillPoint`
也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`)。③ `player_skill.gd`
落地 `_check_short_mana``__CheckShortMana:320`summon `131` 豁免、非开关技 `iNeedSP==0`
→ 坏数据拦、`CanUseIfNotEnough` → `icurSP<=0` 拦、否则 `iNeedSP > icurSP` 拦)和
`_check_short_life``__CheckShortLife:354``!IsUseHPSkill()` 放行、`dwNeedHP > curHP` 拦),
新增上下文字段 `cur_sp` / `cur_hp``net_play.skill_context()` 读 `M2Client.get_points()`
`-1` = 未知不拦);`player_skill_test.gd` +“(c) §3.8 修改 3” HP/SP 段。
**参考**:三层,缺一不可。
**(a) `CPythonPlayer::ClickSkillSlot(dwSlotIndex)`**`REF/UserInterface/PythonPlayerSkill.cpp:108`
```
slot >= SKILL_MAX_NUM → return
!GetSkillData(inst.dwIndex, &data) → return
SKILL_TYPE_GUILD == data->byType → UseGuildSkill(slot); return
!data->IsCanUseSkill() → return // 被动技能挡在这
data->IsStandingSkill():
data->IsToggleSkill() && IsSkillActive(slot):
main->IsUsingSkill() → return
SendUseSkillPacket(inst.dwIndex); return // 关闭 toggle
__UseSkill(slot); return
m_dwcurSkillSlotIndex == slot → __UseSkill(slot); return
!__IsRightButtonSkillMode() → __UseSkill(slot)
else → m_dwcurSkillSlotIndex = slot; ChangeCurrentSkill(slot)
```
**(b) `CPythonPlayer::__CanUseSkill()`**`PythonPlayerSkill.cpp:439`
```
!NEW_GetMainActorPtr() → false
IsObserverMode() → false
IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20 → false // 骑乘术硬编码
return main->CanUseSkill();
```
**(c) `CPythonPlayer::__CheckSkillUsable(slot)`**`PythonPlayerSkill.cpp:166`
```
IsMountingHorse() && !data->IsHorseSkill() → OnCannotUseSkill("NOT_HORSE_SKILL"); false
data->IsHorseSkill() && !IsMountingHorse() → OnCannotUseSkill("HAVE_TO_RIDE"); false
data->IsAttackSkill() && main->IsInSafe() → OnCannotUseSkill("IN_SAFE"); false
!data->IsCanUseSkill() → false
data->IsNeedEmptyBottle() && !__HasItem(27995) → OnCannotUseSkill("NEED_EMPTY_BOTTLE"); false
data->IsNeedPoisonBottle() && !__HasItem(27996) → OnCannotUseSkill("NEED_POISON_BOTTLE"); false
main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING_ROD"); false
```
**(d) `__UseSkill(slot)`**`PythonPlayerSkill.cpp:460`)额外:
`IsOpenPrivateShop()` → true(静默);`__CheckSpecialSkill(idx)` → true
`data->IsToggleSkill() && IsSkillActive(slot)` → 发 `SendUseSkillPacket(idx, slot)` 并返回 false
`main->IsUsingSkill()` → false
需要目标时(`IsNeedTarget() || CanChangeDirection() || IsAutoSearchTarget()`
按 `IsNeedCorpse()` 取死/活目标,取不到就 `__ChangeTargetToPickedInstance()` 再取一次。
**当前**:只查 `skill id > 0``target_vid` 等参数被无符号化。
**修改**
1. ~~在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植;
`SKILL_MAX_NUM`、`SKILL_TYPE_GUILD`、骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996`
均按参考写成命名常量。~~
—— **完成(增量 88)**:`project/player_skill.gd``extends RefCounted`)。
`click_skill_slot(id, is_active)` / `can_use_skill()` / `check_skill_usable(id)` /
`use_skill(id, is_active)` 逐段对齐 `PythonPlayerSkill.cpp:106/439/166/460`,返回
`{ ok, code, target_vid }``code` 用 `OnCannotUseSkill` 原字符串
`NOT_HORSE_SKILL` / `HAVE_TO_RIDE` / `IN_SAFE` / `NEED_EMPTY_BOTTLE` /
`NEED_POISON_BOTTLE` / `REMOVE_FISHING_ROD` / `NOT_YET_LEARN` /
`NOT_MATCHABLE_WEAPON` / `EMPTY_ARROW` / `WAIT_COOLTIME` / `NEED_TARGET` /
`CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` /
`CANNOT_ATTACK` / `ONLY_FOR_CORPSE`),加静默码集(`PASSIVE` / `GUILD_ROUTED` /
`TOGGLE_OFF` / `SPECIAL_SKILL` / `PRIVATE_SHOP` / `BAD_SLOT` …)对齐参考端“静默 return”。
命名常量 `SKILL_MAX_NUM=255``Packet.h:2041`/ `SKILL_TYPE_GUILD=3` /
`RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` /
`SKILL_INDEX_FISHING=123` / `SKILL_INDEX_COMBO=122``PythonPlayerModule.cpp:6-7`)。
`__CheckRestSkillCoolTime` = 本地槽 `cd_end``m_sysIsCoolTime` 关则不查);
`__CheckDashAffect` 分支(冲刺 affect 下非蓄力技才查冷却)1:1 保留;
`__HasItem` 扫 `client.get_inventory()``__HasEnoughArrow` 用 `net_play._arrow_count` 占位;
`__CheckShortMana` / `__CheckShortLife` 于增量 89 落地(见修改 3)。
目标解析(`_resolve_target`)按 `__UseSkill:520-650` 判联盟 / 安全区 / 可攻击 / 尸体 /
需要目标并回传 `target_vid`;距离 / `__ReserveUseSkill` / 扇形·圆形多目标
`SendAddFlyTargetingPacket` 归 §3.4/§3.9,此处不判。接线见状态段。
2. ~~`CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill /
IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget /
CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`
来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析~~
—— **完成(增量 87**POC 无 C++ skill proto,技能数据只在 GDScript
`project/ui/skill_table.gd`。按 `RegisterSkillDesc``PythonSkill.cpp:280`)把 skilldesc
第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits`
位掩码(记号名 1:1 抄 `PythonSkill.h:92-128`),`load_file` 时连同 `skill_type`
JOB 列 → `SKILL_TYPE_*`)预存。上列全部谓词 + `can_use_weapon_type` / `is_need_bow` /
`is_only_for_guild_war` / `can_use_for_me` 等按 `CPythonSkill` 逐条实现;`skill_test.gd`
+34 断言。(参考:`PythonSkill.h` 位枚举、`PythonSkill.cpp:280/909/1143/1407`、
`GameLib/ItemData.h:63-75`。)
3. ~~`M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。~~
—— **完成(增量 89)**:`M2Client::use_skill(int skill_id, int target_vid)` 形参本就
带符号(增量 52),本次把 `skill_id` 的护栏从 `skill_id <= 0 || !bounds::u32()` 收紧成
`!mtnet::bounds::skill_index(skill_id)``net_bounds.h` 新增 `SKILL_MAX_NUM=255` 常量
+ `skill_index(v) = 0 < v < 255` 谓词,对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM`
门 / `aSkill[]` 索引上限),`target_vid` 保留 `>= 0 && bounds::u32``cast_skill()` 的
`motion_idx ∈ [0,0x7f]` 保持不动(7 位 wire 位段)。`net_bounds_test.cpp` +4 断言。
顺带把 `__CheckShortMana` / `__CheckShortLife` 从桩改为实装:`skill_table.gd` 解析
`.msk` `NeedSPFormula``need_sp` / `need_hp` + `LocaleService_GetSkillPower` 功率表
`_skill_point`),`player_skill.gd` 按 `PythonPlayerSkill.cpp:320/354` 逐条移植;HP/SP
由 `net_play.skill_context()` 读 `get_points()` 供入,未知(`-1`)不判。
---
### 3.9 公共网络 API 缺少参数边界检查
**参考**:参考端在**发送前**就有断言/裁剪,而不是靠服务端拒绝:
- `SendCharacterStatePacket``assert(lX >= 0 && lX < 204800)``PhaseGame.cpp:1132`);
角度先归一到 `[0,360)``bRot = rot/5.0f`。
- `OnAttack``assert(wMotionIndex < 255)``PythonPlayerEventHandler.cpp:104`)。
- `FlushVictimList``SYNC_POSITION_COUNT_LIMIT = 16``:196`)。
- `AddQuickSlot / DeleteQuickSlot / MoveQuickSlot``< 0 || >= QUICKSLOT_MAX_NUM(36)` 直接返回
`PythonPlayer.cpp:843/852/861`)。
- `RequestUseLocalQuickSlot``>= QUICKSLOT_MAX_COUNT_PER_LINE(8)` 返回(`:815`)。
- `RequestDeleteGlobalQuickSlot``>= QUICKSLOT_MAX_COUNT(32)` 返回(`:800`)。
- `GetAccountCharacterSlotData*``>= PLAYER_PER_ACCOUNT4(4)` 返回 0`PythonNetworkStream.cpp:378`)。
- `__RecvPlayerCreateSuccessPacket`:槽位越界 `TraceError` 并**吞掉**`PhaseSelect.cpp:239`)。
**修改**
1. ✅ 新建 `POC/extension/src/net/net_bounds.h`,只收纳已从参考 wire / `GameType.h` 核实的公共
协议边界:`u8/u16/u32/i32`、`QUICKSLOT_MAX_NUM=36`、`SHOP_HOST_ITEM_MAX_NUM=40`、
`PRIVATE_SHOP_ITEM_MAX_NUM=39`、`EXCHANGE_ITEM_MAX_NUM=12`、`CUBE_ITEM_SLOT_COUNT=24`、
`POINT_MAX_NUM=255`、`DS_REFINE_WINDOW_MAX_NUM=15`。未读到的 EAffect / 地图规则没有凭经验塞入
这个头文件。
2. ✅ `M2Client` 的公共入口在所有窄类型转换前检查边界:坐标和 VID、动态文本包长度、物品位置、
快捷栏 / 交易 / 私人商店 / Cube 槽位、数量、金币、公会 / 会徽 ID 与端口;私人商店数组超限和
Classic 原先的静默 39 项截断改为返回 `false`。私人商店字典要求整数字段,避免 Godot Variant
隐式转换后再截断。
3. ✅ 按参考语义保留必要特殊值:`set_target(0)` 清目标、`shop_sell(count=0)` 选择旧版单件包、
`move_item(count=0)` 保留整堆语义;没有用“把负数改成 1”掩盖调用方错误。
4. ✅ 回归:新增 `extension/tests/net_bounds_test.cpp` / CTest `net.bounds`,覆盖每个共享边界的
端点与越界值;`cmake --build build/extension -j2` 通过,`ctest --test-dir build
--output-on-failure` 为 18/18。相关 Godot 离线回归(`netbridge_test`、`p8_test`、
`shop_cube_mall_test`、`guild_refine_test`、`dragon_soul_test`、`guild_mark_test`、
`guild_war_skill_test`、`p9_test` 及完整脚本集)通过;Godot 测试按串行、独立日志运行。
5. ⬜ 仍待真实 40250 服务端验证每种业务的服务端合法性、角色 / 物品语义和地图坐标限制;本批次只
保证客户端不会产生越界 wire 参数,不把本地范围检查冒充服务端规则。
---
### 3.10 技能升级传输已对齐但状态门控不完整
**状态:✅ 完成(2026-09-03 / W2 / 增量 90** —— 加号按钮门控按 `uicharacter.py`
的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`
逐行落地:`_can_use_skill_now()`(技能组门)+ 三个技能点来源分开取(`skill_active` /
`skill_support` / `skill_horse`C++ `get_points()` 新增键)+ `skillGrade==0` +
马术页 `skillLevel < 20` + 主动页 `skill_table.can_level_up_skill(id, level)` +
辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐藏。`skill_table` 新增 `max_level_of` /
`skill_level_limit` / `can_level_up_skill`。`M2Client::skill_up()` 入参收紧为
`mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果仍由 `GC_SKILL_LEVEL`
回包经 `skills_changed` 驱动,不本地伪造。
**证据边界与订正**:本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓与该客户端配套的
迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此
断定当前必须改传技能 vnum。`uicharacter.py:1022 OnPressedSlotButton()`(本仓
`POC/assets/root/uicharacter.py`):
```python
srcSlotIndex = self.__RealSkillSlotToSourceSlot(slotNumber)
skillIndex = player.GetSkillIndex(srcSlotIndex)
curLevel = player.GetSkillLevel(srcSlotIndex) # 本版本算了但未使用
maxLevel = skill.GetSkillMaxLevel(skillIndex) # 本版本算了但未使用
net.SendChatPacket("/skillup " + str(skillIndex))
```
所以 `CLIENT-GAP.md` §3.10 的「应改成非命令路径」不成立,主文档已改为记录门控和状态流程差距。
**同时**:本文早前版本曾断言「当前实现应把参数从技能槽改为技能 vnum」——
该结论**未经证明,撤回**。`POC/project/ui/skill_ui.gd:145` 传给
`client.skill_up(sid)` 的 `sid` 来自 `table.for_category()`,本来就是技能表 ID/索引,
与参考端 `player.GetSkillIndex(srcSlotIndex)` 的产物同类。
**真正的差距是「加号按钮门控」完全缺失。** 迁移资产中的参考脚本把全部条件放在按钮
**显示**阶段(`uicharacter.py`),点下去就无条件发包;C++ 侧可核实的技能上限检查见
`PythonSkill.cpp`
```python
# RefreshSkillPlusButtonList() :984
if self.isLoaded == 0: return
self.RefreshSkillPlusPointLabel()
if not self.__CanUseSkillNow(): return # 死亡/变身/骑乘等状态门(:1270)
curSelectedSkillGroup == PAGE_HORSE ? __RefreshSkillPlusButton("HORSE")
: __RefreshSkillPlusButton("ACTIVE")
__RefreshSkillPlusButton("SUPPORT")
# __RefreshSkillPlusButton(name) :937
if HIDE_SUPPORT_SKILL_POINT and name == "SUPPORT": return
slotWindow.HideAllSlotButton()
slotStatType = self.skillPageStatDict[name] # ACTIVE→SKILL_ACTIVE, SUPPORT→SKILL_SUPPORT, HORSE→SKILL_HORSE
if 0 == slotStatType: return
statPoint = player.GetStatus(slotStatType)
if statPoint > 0: # ← 技能点校验
for 每个槽:
skillIndex = player.GetSkillIndex(slot); skillGrade = player.GetSkillGrade(slot)
skillLevel = player.GetSkillLevel(slot)
if skillIndex == 0: continue
if skillGrade != 0: continue # ← 已升阶的技能不给加号
if name == "HORSE":
if player.GetStatus(player.LEVEL) >= skill.GetSkillLevelLimit(skillIndex): # ← 等级前置
if skillLevel < 20: # ← 马术上限 20
slotWindow.ShowSlotButton(__GetETCSkillRealSlotIndex(slot))
else:
if name == "SUPPORT" and SHOW_LIMIT_SUPPORT_SKILL_LIST:
if skillIndex not in SHOW_LIMIT_SUPPORT_SKILL_LIST: continue # ← 辅助技能白名单
if self.CanShowPlusButton(skillIndex, skillLevel, statPoint):
slotWindow.ShowSlotButton(slot)
# CanShowPlusButton(skillIndex, skillLevel, curStatPoint) :925
if 0 == skillIndex: return False
if not skill.CanLevelUpSkill(skillIndex, skillLevel): return False
return True
```
**当前**~~`POC/project/ui/skill_ui.gd:141` 只有
`if table.can_level_up(id) and not table.is_passive(id)`,缺技能点校验、skillGrade、
等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。~~
—— **增量 90 全部落地**(见下方逐条)。
**修改**
1. **完成(增量 90**`skill_ui.gd` `refresh()` 里按三段逐行实现加号显隐——
`_can_use_skill_now()`= `client.get_skill_group() != 0`,对应 `__CanUseSkillNow` :1270)→
`_stat_point_for(page)`(技能点来源)→ `_can_show_plus(id, level, grade, page, …)`
(对应 `__RefreshSkillPlusButton` 每槽体 + `CanShowPlusButton`)。按钮仍在 `_rebuild()`
为非被动 / 非 `CANNOT_LEVEL_UP` 的技能创建,`refresh()` 只切 `visible`= `HideAllSlotButton()`
+ `ShowSlotButton()`)。
2. **完成(增量 90)**:三个技能点来源分开取。`wire.h` 加 `POINT_SUB_SKILL=27` /
`POINT_SKILL=28` / `POINT_HORSE_SKILL=113``M2Client::get_points()` 新增
`skill_active` / `skill_support` / `skill_horse` 三个键(镜像 `bow_distance` 的做法);
`skill_ui._stat_point_for()` 按页取对应键,不合并成一个 `POINT_STAT`。
3. **完成(增量 90**`skill_table.gd` 加
`max_level_of(id)`= `SSkillData::GetMaxLevel()``.msk` `maxlevel` > skilldesc `MAX_LEVEL`
列 > 构造默认 20`PythonSkill.cpp:1392/1462`)、
`skill_level_limit(id)`= `skillGetSkillLevelLimit``PythonSkill.cpp:1741` = `byLevelLimit`
**本 40250 数据里 skilldesc 无 `LEVEL_LIMIT` 列、`.msk` 无 `levellimit` 记号 → 恒 0**
马术页等级前置门因而恒真)、
`can_level_up_skill(id, level)`= `skillCanLevelUpSkill``PythonSkill.cpp:1833`。
**本 build 里 `isRequirement` / `strRequireSkillName` / `RequireStatDataVector` 从不被填充
(构造默认,:1471)→ 1:1 化简为 `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`**)。
`RegisterSkillDesc` 的 `LEVEL_LIMIT`(列 26/ `MAX_LEVEL`(列 27)解析已在 `load_file`
补上,兼容带这两列的第三方数据。
4. **完成(增量 90**`M2Client::skill_up(int skill_id)``m2_client.cpp:961`)发包不变
`/skillup <skillIndex>`),入参守卫由 `skill_id >= POINT_COUNT` 收紧为
`!mtnet::bounds::skill_index(skill_id)`= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。
5. **保持(增量 90)**:升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包经
`skills_changed` / `skill_group_changed` 驱动 `refresh()`,失败提示走 `GC_CHAT` 的
服务端文案——不本地伪造成功。
**测试**`skill_test.gd` 扩 `§3.10` 段——`max_level_of` / `skill_level_limit` /
`can_level_up_skill`(未满级 / 满级 / 超级 / `CANNOT_LEVEL_UP` / 未知 id+ 加号显隐
(主动页可见、`skillGrade!=0` 隐、`skill_group==0` 隐、该页点数 0 隐、辅助页整页隐、
马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。`net.bounds` C++ 用例覆盖
`skill_index` 谓词。全 21 C++ + 13 GDScript 回归通过。
---
## 4. P1:物品、装备、商店和社交
### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 96 + 97 + 98 + 99 —— 修改 1proto 位标志导出,此前 §3.5+ 修改 3costume/ring/belt 槽位版图与发包坐标,增量 96:`inventory_ui.gd._to_wire()` 按 `GameType.h` 把 wear 0..10 走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell、costume/ring/beltwear ≥ 19)留在 `WINDOW_INVENTORY(1)` 全局 cell 109..113;扩展面板只补 5 个真实新装备位)+ 修改 2(`CanEquipNow` 前置门,增量 97:新增 `equip_rules.gd` 纯校验器,`antiflag(性别/职业/帝国)→wearable_flag→limit` 顺序 1:1 自 `ItemData.h`,接入 `inventory_ui.gd.use()` + `equip_rejected` 信号 + 中文文案;canonical 测试 #17 `equip_rules_test.gd`)+ 修改 4(变身 / 时装部位遮挡,增量 98:新增 `part_hiding.gd` 纯函数 1:1 自 `InstanceBase.cpp` `IsPoly`/`__IsShapeAnimalWear`(100-103)/`IsWearingDress`(201)/`__IsChangableWeapon`(捧花 50201-50204),接入 `equip_model.gd.refresh()`——刷新序改对齐 `ChangeArmor`(身体→头发→武器→盾)、动物时装隐藏武器+盾+发、婚纱非捧花隐藏武器;canonical 测试 #18 `part_hiding_test.gd`)+ 修改 5(增量 99:远端 PC 通过 `game_scene.gd` 的 `RemotePlayerView` 创建真实 race 模型,远端 `EquipModel` 只消费四字段 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()``net_world.gd` 按四字段调用 `set_armor→set_weapon→set_head→set_hair``remote_player_test.gd` 覆盖真实 warrior 资源和字段序)已落地。变身字段(`_is_poly` 恒 false)、所有职业 / race 资源、精确体型 / 远端真服换装、精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME` / 属性 4 项按名读取仍留待验收 / 接服务器 `char_item.cpp` 比对)**
**参考**`ClientVS22` 的编译开关(`REF/UserInterface/Locale_inc.h`,本份为
`LOCALE_SERVICE_SINGAPORE`)打开了 `ENABLE_COSTUME_SYSTEM` / `ENABLE_ENERGY_SYSTEM` /
`ENABLE_DRAGON_SOUL_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM`。对应的**槽位版图**
`REF/UserInterface/GameType.h:40-112`):
```
c_Inventory_Page_Size = 5*9 = 45 c_Inventory_Page_Count = 2
c_ItemSlot_Count = 90 c_Equipment_Start = 90 c_Equipment_Count = 12
+0 Body +1 Head +2 Shoes +3 Wrist +4 Weapon +5 Neck
+6 Ear +7 Unique1 +8 Unique2 +9 Arrow +10 Shield
+19 Costume_Body +20 Costume_Hair (ENABLE_COSTUME_SYSTEM)
+21 Ring1 +22 Ring2 +23 Belt (ENABLE_NEW_EQUIPMENT_SYSTEM)
c_Wear_Max = 32
c_DragonSoul_Equip_Start = 90 + 32 = 1226 槽 × DS_DECK_MAX_NUM(2)
c_Belt_Inventory_Width/Height = 4/4 → 16 格
```
本工程的「24 个 wear 位置」= `c_Equipment_Start + 0 .. +23`,**与参考一致**,无需改数据层。
**装备规则**来自 `CItemData``REF/GameLib/ItemData.h`):
- `EWearPositions``:227`):`WEAR_BODY..WEAR_SHIELD``WEAR_MAX_NUM = 11`(基础 11 位)。
- `EItemWearableFlag``:241`):`WEARABLE_BODY(1<<0) … WEARABLE_ARROW(1<<9)`——
「这件装备能穿在哪些位置」的位掩码。
- `EItemAntiFlags``:188`):`FEMALE/MALE/WARRIOR/ASSASSIN/SURA/SHAMAN`(职业性别限制)、
`GET/DROP/SELL/SAVE/GIVE/PKDROP/STACK/MYSHOP`、`EMPIRE_A/B/R`。
- `EItemFlag``:207`):`REFINEABLE/STACKABLE/COUNT_PER_1GOLD/RARE/UNIQUE/IRREMOVABLE/
CONFIRM_WHEN_USE/QUEST_USE/APPLICABLE` 等。
- `EItemTypes``:24`):0..34,其中 `ITEM_TYPE_COSTUME = 28`、`ITEM_TYPE_RING = 33`、
`ITEM_TYPE_BELT = 34`、`ITEM_TYPE_ROD = 13`、`ITEM_TYPE_METIN = 10`。
- 武器子类 `EWeaponSubTypes``:64`):`WEAPON_SWORD/DAGGER/BOW/TWO_HANDED/BELL/FAN/ARROW`。
**换模**`CInstanceBase::ChangeArmor/ChangeWeapon/ChangeHair`,最终经
`__GetRaceType` + `RefreshState`(§6.4)决定 motion mode。远端角色的换装同样走
`CNetworkActorManager::UpdateActor`(§2.5)。
**修改**
1. ~~`proto` 层导出上述四组位标志(当前只用了 `specular`),落到
`POC/extension/src/proto/item_proto.h` 的 `ItemProto`。~~ **增量 96 已完成(此前 §3.5)**:
`proto_node.cpp` 已导出 `d["type"]` / `d["sub_type"]` / `d["anti_flags"]` / `d["wear_flags"]` /
`d["limits"]``[{type, value}] × 2`),装备校验数据层齐备,无需 C++ 重编。
2. ~~装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 →
`limit`(等级/属性) → 发 `CG_ITEM_USE`。~~ **增量 97 已完成**:客户端 40250 本身不做
`CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / `GetLimit`,仲裁在服务器
`char_item.cpp`),故按 §3.9 `__CanShot` 方式在 GDScript 侧防御性重建——
新增 `project/equip_rules.gd``extends RefCounted`),枚举 1:1 自 `REF/GameLib/ItemData.h`
`EItemAntiFlag` classic 排列、`EItemWearableFlag`、`EWearPositions` + `GameType.h` 19..23、
`ELimitTypes`、`EItemType` + `EArmorSubTypes`/`ECostumeSubTypes`)。`can_equip(item, wear, ctx)
-> {ok, code, need}`:目标槽解析 → anti-flag 性别(`ANTI_SEX`)→ 职业(`ANTI_JOB``race%4`)→
帝国(`ANTI_EMPIRE``GC_EMPIRE 1/2/3`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按
`wear_flags & wear_to_wearable(wear)``NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`
`ctx.level < v` → `need=v``LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判)。
`inventory_ui.gd.use()` 对「常规背包格 + 可穿戴物品」跑此门,拦下则 `_set_hint()` + 新
`signal equip_rejected(code, need)` + 不发 `use_item`;新增公开 `can_equip_item()`、`_equip_ctx()`
`get_points().level` + `get_entities()` 主角 `race/empire/level``st/dx/ht/iq` 未由 M2Client
按名暴露 → 传 `-1` = seam)、`_equip_reject_hint()` 中文文案(对齐 `localeInfo` `CANNOT_EQUIP_*` 语义)。
canonical 测试 #17 `equip_rules_test.gd`33 断言)。**seam**:精确 tie-break 顺序、已穿同槽替换、
`LIMIT_PCBANG` / `LIMIT_REAL_TIME*`、属性 4 项按名读取,留待接服务器 `char_item.cpp` 比对。
3. ~~costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` /
`equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。~~ **增量 96 已完成**:
`inventory_ui.gd._to_wire()` 按 `GameType.h` 分流——`c_Equipment_Start+0..10`BODY..SHIELD
走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell`ENABLE_NEW_EQUIPMENT_SYSTEM` 的
`c_Costume_Slot_Start(90+19)` / `c_Equipment_Ring1(90+21)` / `c_Equipment_Belt(90+23)`
在客户端无独立窗,仍用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。扩展面板
`_add_extended_cells` 由「11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 + 16 格腰带背包」。
新增常量 `WEAR_COSTUME_BODY/HAIR`、`WEAR_RING1/RING2`、`WEAR_BELT`、`NEW_EQUIP_WEAR_START`、
`WINDOW_EQUIPMENT` 全部 1:1 自 `GameType.h`。`inventory_test.gd` 增 7 条 wire 断言
`_to_wire(90)→[2,0]`、`(100)→[2,10]`、`(109..113)→[1,109..113]`)。
4. ~~部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按
`REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现。~~
**增量 98 已完成**:穿头盔覆盖发型槽、costume(parts[ARMOR]) 压过本体 armor 在增量 96/97
已由 `equip_model.gd` 的 `_eff()` + `_apply_head_or_hair()` 落地;本次补齐**变身 / 动物时装 /
婚纱的整件遮挡**。新增 `project/part_hiding.gd``extends RefCounted` 纯函数),常量 1:1 自
`REF/UserInterface/InstanceBase*.cpp``POLY_SHAPE=0``IsPoly()`→`SetShape(0)``SetWeapon`/
`SetParts` 提前 return)、`ANIMAL_WEAR_SHAPES=[100,101,102,103]``__IsShapeAnimalWear`(:2834)
→ `SetWeapon`(:2747)/`SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201``IsWearingDress`)、
`BOUQUET_VNUMS=[50201..50204]``c_iBouquets``__IsChangableWeapon`(:901) 除捧花外 false)。
`equip_model.gd.refresh()`:刷新序改为对齐 `ChangeArmor``SetRace→SetArmor→SetHair→SetWeapon`
= 身体 → 头发 → 武器 → 盾;解出身体 shape(`armor_shape_of` = `__ArmorVnumToShape` 的
`values[3]`/`=vnum`)后过 `PartHiding.hair_hidden` / `weapon_hidden` / `effective_weapon`
动物·怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`;婚纱非捧花隐藏武器 + 盾、头发保留。
新增 `_is_poly()` **seam**40250 实体表暂无变身字段 `poly_race`/`polymorph`,恒 `false`
等 `GC_CHARACTER_UPDATE` 带变身 race)。canonical 测试 #18 `part_hiding_test.gd`45 断言)
+ `equip_model_test.gd` 补 7 段遮挡断言。
5. ~~远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把
`armor/weapon/hair` 三个字段接到换模。~~ **增量 99 首版完成**:参考端
`Packet.h:1282-1289` 定义 `CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 / HAIR=3 / NUM=4`
`game_scene.gd._make_entity_model()` 对 `ch_type==0` 创建 `RemotePlayerView`,复用
`PlayerView.build()` 的 race / GR2 / MSA 装载;`ui/equip_model.gd.setup_remote()` 进入远端模式,
只读取 `awPart[4]`,并提供 `set_armor/set_weapon/set_head/set_hair`,不访问本地
`get_equipment()`。`net_world._apply_field_updates()` 现在按四字段调用
`set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])`,对齐
`NetworkActorManager.cpp:473` 的更新序;`remote_player_test.gd` 用真实 warrior 资源覆盖建模、换装和
字段序。**仍为 seam**:没有对应 `poly_race` 的实体字段,`_is_poly()` 仍恒 false;所有职业 / race
资源、精确体型 / 变身、远端真实多角色换装和真服视觉尚未验收。
---
### 4.2 物品拖放不是原版鼠标物品态
**参考**:全局单例 `mouseModule.mouseController``POC/assets/root/mousemodule.py`
`class CMouseController`),是所有窗口之间拖放的**唯一中介**:
```
AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0) mousemodule.py:171
Type ∈ ESlotTypeGameType.h:140:
SLOT_TYPE_INVENTORY / SHOP / EXCHANGE_OWNER / EXCHANGE_TARGET / QUICK_SLOT /
SAFEBOX / PRIVATE_SHOP / MALL / DRAGON_SOUL_INVENTORY → 取物品图标
SLOT_TYPE_SKILL / SLOT_TYPE_EMOTION → 取技能 / 表情图标
SLOT_TYPE_QUICK_SLOT → 再取快捷栏里登记的真实类型递归解析
AttachMoney(owner, type, count) :264 IsAttachedMoney() :251
DeattachObject() :284 isAttached() :315
GetAttachedOwner/Type/SlotNumber/ItemIndex/ItemCount :318-343
```
落点窗口的两个事件与参考里到处出现的模式一致(以 `uisafebox.py:411` 为样板):
```
SelectEmptySlot(pos): if not isAttached(): return
按 GetAttachedType() 分支 → 发对应移动/存取包 → DeattachObject()
SelectItemSlot(pos): 已有物品的格子;未持物时 AttachObject(self, <本窗类型>, pos, itemID)
并 snd.PlaySound("sound/ui/pick.wav")
```
`SlotTypeToInvenType(bSlotType)``REF/UserInterface/GameType.cpp:106` + 表 `:90`)把
slot type 映射到 `EWindows`:只有 `SLOT_TYPE_INVENTORY→INVENTORY`、
`SLOT_TYPE_DRAGON_SOUL_INVENTORY→DRAGON_SOUL_INVENTORY`,其余一律 `RESERVED_WINDOW`。
落点代码里 `if RESERVED_WINDOW == attachedInvenType: return` 是标准防御
`uisafebox.py:437`)。
音效固定:拿起 `sound/ui/pick.wav`、放下 `sound/ui/drop.wav`、金钱 `sound/ui/money.wav`、
非法 `sound/ui/loginfail.wav`。
**当前(增量 57/60**`mouse_controller.gd` 提供全局持有态和鼠标跟随预览;`inventory_ui.gd` 注册
背包 / 装备 / 腰带格,`quickbar.gd` 注册快捷栏格,safebox 注册 5×9 分页格,exchange 注册 12 个
交易落点,shop 注册出售落点,mall 注册商城分页格,private_shop 的 40 格已接入全局投放。背包物品
可按明确目标格调用现有 checkin / checkout / move / exchange / sell wire APIpayload 还保留物品实例的
`anti_flags`、`flags`、`sockets`、`attrs`,普通拖放保留整组数量,Shift+左键堆叠物品可先选择附着数量,
金币以 `ITEM_MONEY=-1` 独立态附着。无效区域、右键、ESC、失焦会取消且不发包。交易已接入服务端
accept 镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限和已放金币不可编辑;NPC 商店已接入
`ANTIFLAG_SELL`、`IsValuableItem` / `Is1GoldItem` 售价确认;私人商店已接入 `ANTIFLAG_GIVE/MYSHOP`。
dragon_soul 仍按当前 40250 范围禁用;完整交易事务和真实 `.sub` 光标资源尚未完成。
**已完成 / 剩余**
1. ✅ 已完成全局 item attach / payload / cancel / drop 控制器、鼠标跟随图标和数量文字;payload
保留 `window/cell/vnum/count/source` 以及实例 `anti_flags/flags/sockets/attrs`,并增加
`ITEM_MONEY=-1` 的独立金币持有态。
2. 已完成 inventory → inventory / equipment / belt 以及 inventory → quickbar 的首版投放;
装备目标使用参考的 `window=2, wear cell` 坐标。
3. 已完成 `UiManager.blocks_game_input()` 的键盘焦点屏蔽和失焦清理;光标管理器提供
`NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM` 状态,暂时用系统光标作为无 `.sub`
资源时的可运行 fallback。
4. 已完成 safebox / mall / shop / exchange / private_shop 的首版明确落点:仓库支持 5×9 页签、仓库内
move 和背包↔仓库;商城支持分页和商城→背包;交易支持背包→12 个指定交易格;商店支持背包拖到
出售目标;私人商店支持背包→40 格价格弹窗。`complex_item_drop_test.gd` 覆盖这些投放入口。
5. ✅ `shop_ui.gd` 已增加 BUY/SELL toggle、出售目标可见性和 BUY/SELL 光标状态;购买模式不会接收
出售投放,出售模式才按数量调用 `shop_sell`。
6. ✅ 已完成 stack split、pick/drop/money/loginfail 音效、交易的 accept / 距离 / anti-give / 金币边界、
NPC 商店的 anti-sell / `IsValuableItem` / `Is1GoldItem` 售价确认,以及私人商店的 anti-give / anti-myshop。
剩余是完整交易事务、全部真实 `.sub` 光标资源和 dragon_soul(当前范围禁用)。
---
### 4.3 物品 tooltip 只覆盖基础信息
**参考**`POC/assets/root/uitooltip.py`(2718 行)是完整实现,本仓已有该脚本。
入口按来源分:`SetInventoryItem / SetShopItem / SetExchangeOwnerItem /
SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem /
SetSkill / SetQuickSlotItem`。
**当前(增量 58/60**:新增 `project/ui/item_tooltip.gd`,由 `item_proto` 的 236 字节记录提供
`limits[2]`、`applies[3]`、`values[6]`、`sockets[3]`、`refined_vnum`、`refine_set`、
`alter_to_magic_pct`、`specular`、`gain_socket_pct`;由物品实例提供真实 `sockets[3]` / `attrs[7]`
和数量;由 `locale/locale/<lang>/itemdesc.txt` 提供描述。背包、装备查看、NPC 商店、仓库、商城、
交易、私人商店都已接入同一个提示构造器,当前按参考的武器 / 防具数值、限制、基础属性、实例附加
属性、魂石槽和精炼关联生成原生 tooltip。
**修改**
1. ✅ 已完成 tooltip 的数据路径和基础内容生成;没有把 Python UI 类机械搬入 Godot
`item_tooltip.gd` 保留 `uitooltip.py` 的数据顺序和关键公式,并使用 Godot 原生 tooltip 承担
定位 / 换行。
- 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary /
GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等);
- 属性行(attribute)、魔石孔(socket`ITEM_SOCKET_MAX_NUM = 3`;龙魂石的 subtype 槽位数另为 6)、
`ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、
精炼等级、使用时限(`GetItemDuration`)逐条对齐。
2. ✅ `proto` 层已补齐固定记录字段(`value[6] / limit[2] / apply[3] / flags / anti_flags /
wear_flags / socket[3] / refine` 等),`proto_item_layout_test` 锁定 236 字节偏移。
3. 装备对比:已仔细搜索 40250 `ClientVS22` 与本仓 `assets/root/uitooltip.py`,未发现
`__AppendEquipmentCompare` 或等价的客户端 tooltip 比较方法;不能把它写成参考端已有语义。
当前应作为本工程新增功能单独定义规则,暂不宣称 1:1。
4. 增量 60 已加入鱼竿 / 镐等级经验、鱼长度、彩票、混合物品、唯一物品剩余时间和龙魂阶段 /
强化的基础分支;增量 62 又按技能书、遗忘书、变身道具的 `sockets[0]` 语义生成技能名 /
怪物名标题,并按限制类型 7/8/9 生成实时剩余时间;对应离线断言已通过。剩余为 CJK 文案 /
字体切换、其他特殊分支、完整计时来源语义。装备对比在参考源码中没有找到对应方法,若要保留
应另立产品需求,不能列为 1:1 必做项。
---
### 4.4 Safebox 是列表和自动找空位
**参考**
- 容量:`CPythonSafeBox``REF/UserInterface/PythonSafeBox.h:8`
`SAFEBOX_SLOT_X_COUNT = 5`、`SAFEBOX_SLOT_Y_COUNT = 9`、`SAFEBOX_PAGE_SIZE = 45`。
- 分页:`uisafebox.py:362 SetTableSize(size)`
```python
pageCount = max(1, size // safebox.SAFEBOX_SLOT_Y_COUNT)
pageCount = min(3, pageCount) # 最多 3 页
size = safebox.SAFEBOX_SLOT_Y_COUNT
self.wndItem.ArrangeSlot(0, SAFEBOX_SLOT_X_COUNT, size, 32, 32, 0, 0)
wnd_height = 130 + 32 * size
```
全局槽号 `__LocalPosToGlobalPos(local) = curPageIndex * SAFEBOX_PAGE_SIZE + local``:357`)。
- 协议(`REF/UserInterface/Packet.h:98`):
`CG_SAFEBOX_CHECKIN = 70`、`CG_SAFEBOX_CHECKOUT = 71`、`CG_SAFEBOX_ITEM_MOVE = 77`
`GC_SAFEBOX_MONEY_CHANGE = 84`、`GC_SAFEBOX_SET = 85`、`GC_SAFEBOX_DEL = 86`、
`GC_SAFEBOX_WRONG_PASSWORD = 87`、`GC_SAFEBOX_SIZE = 88`。
发送函数:`SendSafeBoxCheckinPacket(TItemPos inventoryPos, BYTE safeBoxPos)`
`REF/.../PythonNetworkStreamPhaseGameItem.cpp:29`)——**目标格由调用方指定**
`SendSafeBoxCheckoutPacket(BYTE safeBoxPos, TItemPos inventoryPos)``:43`)、
`SendSafeBoxItemMovePacket(BYTE src, BYTE dst, BYTE count)``:57`)。
`SendSafeBoxMoneyPacket` 在参考里是 `assert(!"…사용하지 않는 함수")`——**已废弃**
金钱走聊天命令(见下)。
- 落点分支(`uisafebox.py:411 SelectEmptySlot`):
```
attachedType == SLOT_TYPE_SAFEBOX → SendSafeboxItemMovePacket(src, dst, 0) # 仓库内移动
否则 attachedInvenType = SlotTypeToInvenType(attachedType)
RESERVED_WINDOW → return
ITEM_MONEY → SendSafeboxSaveMoneyPacket(count) + sound/ui/money.wav
其它 → SendSafeboxCheckinPacket(attachedInvenType, attachedSlotPos, selectedSlotPos)
最后 DeattachObject()
```
`SelectItemSlot``:451`):持物时只处理金钱;未持物时先看光标——
`app.SELL` → `SAFEBOX_SELL_DISABLE_SAFEITEM``app.BUY` → `SHOP_BUY_INFO`
否则 `AttachObject(self, SLOT_TYPE_SAFEBOX, pos, itemID)` + `sound/ui/pick.wav`。
- 关闭:`Close()` = `net.SendChatPacket("/safebox_close")``uisafebox.py:400`)。
- 密码 / 改密:`dlgChangePassword``uisafebox.py`),错误由 `GC_SAFEBOX_WRONG_PASSWORD` 驱动。
**当前(增量 56**`safebox_ui.gd` 已增加参考端 5×9(45 格)分页网格,按 `size` 显示最多实际页数,
`local + page*45` 映射全局槽位;背包 / 装备拖入指定仓库格走 `safebox_checkin`,仓库物品拖到另一
空格走 `safebox_move`,仓库物品可拖回背包指定格走 `safebox_checkout`。旧 `deposit()` 仍保留给右键
兼容入口,因此自动找空位不是新的鼠标落点语义。
**已完成 / 剩余**
1. ✅ `safebox_ui.gd` 已实现 5×9 网格 + 按 `size` 分页,`local + page*45` 映射全局槽位。
2. ✅ 鼠标存取使用**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)` /
`safebox_checkout(safe_pos, inven_window, inven_pos)`;旧 `deposit()` 仅作为右键兼容入口保留自动
找空位,不参与鼠标落点。
3. ✅ `M2Client::safebox_move(from_cell, to_cell, count)` 已存在并由仓库内物品态调用,覆盖
`CG_SAFEBOX_ITEM_MOVE(77)`。
4. 金钱不能按当前参考端实现成 `/safebox_money``PythonNetworkStreamPhaseGameItem.cpp:14-27`
的 `SendSafeBoxMoneyPacket()` 主动 `return false`,包发送代码被注释;本次不伪造聊天命令。
关闭固定用 `client.say(0, "/safebox_close")`。若产品需要仓库存钱,必须另立服务端 / 客户端协议。
5. ✅ 已接入 `mouse_controller`:背包 / 装备→仓库、仓库→仓库、仓库→背包三类首版落点均按
空格 / 已有物品分支处理。
6. ✅ 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的提示已接上;全局 `pick/drop/money/loginfail` 音效和
BUY/SELL cursor 联动已在增量 57 接入。仓库金钱存取保持未实现,原因见第 4 项,不以假命令冒充完成。
---
### 4.5 交易缺少事务状态
**参考**
- 数据:`CPythonExchange``REF/UserInterface/PythonExchange.h:9`
`EXCHANGE_ITEM_MAX_NUM = 12``TExchangeData{ name[25], item_vnum[12], item_count[12],
item_metin[12][ITEM_SOCKET_SLOT_MAX_NUM], item_attr[12][ITEM_ATTRIBUTE_SLOT_MAX_NUM],
accept, elk }`self / target 各一份。
- UI`POC/assets/root/uiexchange.py`):
- `AcceptExchange()``net.SendExchangeAcceptPacket()` 然后 `self.AcceptButton.Disable()`。
- `Refresh()``GetAcceptFromSelf()` 为真 → `OwnerAcceptLight.Down()`
**为假 → `AcceptButton.Enable(); AcceptButton.SetUp(); OwnerAcceptLight.SetUp()`**
—— 即「对方/自己改动导致服务端清 accept」时按钮自动恢复可点,这就是
“接受后修改自动取消接受”的 1:1 实现,客户端**不本地判定**,只跟随服务端的 accept 位。
- `OpenPickMoneyDialog()``if exchange.GetElkFromSelf() > 0:` →
`EXCHANGE_CANT_EDIT_MONEY`,不弹窗(金钱一旦放入不可改)。`SetMax(7)` = 7 位数上限。
- `SelectOwnerEmptySlot()`:持金钱 → `SendExchangeElkAddPacket(amount)`
持物品且 `attachedSlotType ∈ {SLOT_TYPE_INVENTORY, SLOT_TYPE_DRAGON_SOUL_INVENTORY}` →
`item.SelectItem(itemID)``item.IsAntiFlag(item.ANTIFLAG_GIVE)` → 提示
`EXCHANGE_CANNOT_GIVE` 并 `DeattachObject()` 返回;否则
`net.SendExchangeItemAddPacket(attachedInvenType, SrcSlotNumber, DstSlotNumber)`。
- `OnUpdate()``USE_EXCHANGE_LIMIT_RANGE = 1000`——自开窗坐标起
`abs(dx) > 1000 or abs(dy) > 1000` → `net.SendExchangeExitPacket()`。
- 标题栏关闭按钮直接绑 `net.SendExchangeExitPacket`。
**当前(增量 57/60**`exchange_ui.gd` 已接入 12 个指定交易格、服务端 accept 位镜像、Accept 按钮
禁用 / 恢复、`USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限、已放
金币不可编辑,并支持背包 / 装备和 `ITEM_MONEY=-1` 拖放。增量 60 已让 m2dev / Classic 的
`GC_EXCHANGE` 解析、`EntityStore::ExchangeSlot` 和 `M2Client.get_exchange()` 保留 3 个 socket
与 7 个 attribute;真实双账号事务仍未完成。
**修改**
1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:112 格 × 2 + 金钱 + 两个 accept 灯 +
`AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。
2. ✅ `EntityStore` 的交易状态已补齐每个 `ExchangeSlot` 的 3 个 socket / 7 个 attribute,并由
m2dev / Classic 两条收包路径写入、由 `M2Client.get_exchange()` 暴露给 tooltip。
3. accept 语义:**只跟随服务端**。发送 `CG_EXCHANGE(ACCEPT)` 后立刻禁用按钮;
收到 `GC_EXCHANGE` 且 `accept == 0` 时重新启用——不写本地「修改则取消」逻辑。
4. `OnUpdate` 的 1000 距离退出、`ANTIFLAG_GIVE` 拒绝、金钱 7 位上限与
`EXCHANGE_CANT_EDIT_MONEY` 全部照抄。
5. 交易中的背包锁定:参考端**没有**——不要加。
---
### 4.6 NPC 商店仍是基础列表
**参考**`POC/assets/root/uishop.py`
- 槽位:`shop.SHOP_SLOT_COUNT`;多货架的全局槽号
`GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos``:226`
本工程增量 34 已按此实现,保持)。
- 买卖是**光标模式**,不是按钮直接买:
```python
OnBuy(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_BUY_INFO); app.SetCursor(app.BUY); btnSell.SetUp()
OnSell(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_SELL_INFO); app.SetCursor(app.SELL); btnBuy.SetUp()
CancelShopping(): btnBuy.SetUp(); btnSell.SetUp(); app.SetCursor(app.NORMAL)
```
- 卖出 `SellAttachedItem()``:279`):
```
shop.IsPrivateShop() → DeattachObject(); return
attachedSlotType 必须是 SLOT_TYPE_INVENTORY,否则 sound/ui/loginfail.wav
item.IsAntiFlag(item.ANTIFLAG_SELL) → PopupDialog(SHOP_CANNOT_SELL_ITEM); return
player.IsValuableItem(pos):
itemPrice = item.GetISellItemPrice()
if item.Is1GoldItem(): itemPrice = attachedCount // itemPrice // 5
else: itemPrice = itemPrice * max(1, attachedCount) // 5
QuestionDialog(DO_YOU_SELL_ITEM(name, count, price)) → OnSellItem(pos, count, itemtype)
否则直接 OnSellItem(...)
OnSellItem: net.SendShopSellPacketNew(slotPos, count, itemtype); sound/ui/money.wav
```
注意**卖价 = 单价 × 数量 ÷ 5**(整数除),`Is1GoldItem` 走另一条公式。
- 关闭:`OnPressEscapeKey``:250`)→ `self.Close()` 并 `return True`。
**当前(增量 59**`shop_ui.gd` 已按 `shopdialog.py` 改为 5×8、40 格货位;空位也保留为不可用
按钮,已放置货物显示名称 / 堆叠数并接入统一 tooltip,货架切换仍按
`tabIdx * SHOP_SLOT_COUNT + slot` 计算全局槽号。BUY 模式点击货位进入购买确认,确认后固定调用
`shop_buy(global_pos, 1)`;SELL 模式仍隐藏 / 拒绝出售目标以外的购买动作。出售模式显示投放区并切换
`CursorManager.BUY/SELL`,背包物品拖到目标会按当前数量调用 `shop_sell`。实例 / 原型
`ANTIFLAG_SELL`、`IsValuableItem`、普通 / `Is1GoldItem` 整数售价公式和 UI 音效已完成。
**已完成 / 剩余**
1. ✅ 已增加 `shop_ui.gd` 的“拖动物品到此出售”目标,按当前数量调用 `shop_sell`。
2. ✅ 已增加 BUY / SELL toggle、模式切换时的出售目标显隐和 `CursorManager.BUY/SELL` 状态;买入
按钮在 SELL 模式下先切回 BUY,不误发购买包。
3. ✅ `shop_ui.gd` 已提供 5×8 货位网格、多货架全局槽号、空位禁用、tooltip 和 BUY 点击确认;
确认购买固定发送数量 1,与参考 `SendShopBuyPacket` 的包体语义一致。当前仍使用 Godot 控件,
未加载真实 `.sub` 槽位 / 按钮贴图与图标。
4. ✅ 卖出流程已补齐:实例 / 原型 `ANTIFLAG_SELL` 拦截、`IsValuableItem` 首次确认、普通与
`Is1GoldItem` 价格公式(均按整数除 5)和 `shop_sell(slot, count)`;取消或非法投放播放失败音效。
5. ✅ 买入:点非空格子 → 购买确认 → `shop_buy(GetIndexFromSlotPos(pos), 1)`;空格子不发送。
6. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。
7. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。
---
### 4.7 私人商店是兼容实现
**参考**`POC/assets/root/uiprivateshopbuilder.py``itemStock` 模型)+
`PRIVATE_SHOP_ITEM_MAX_NUM`= `TPacketCGMyShop::bCount` 上限)。
本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 +
`display_pos` / 撤下 / 开设 / 撤收)与参考一致。
**当前 / 剩余差距**
1. ✅ 拖放已接入 §4.2 的全局 `mouse_controller`;40 个货位注册为落点,背包 payload 会弹价格窗并
写入 `itemStock[display_pos]`,保留原有点选候选面板作为触屏 / 无鼠标 fallback。
2. ✅ 开店前检查:已按参考在 `uiprivateshopbuilder.py` 的物品投放路径检查
`ITEM_ANTIFLAG_GIVE = 1<<13` 与 `item.IsAntiFlag(item.ANTIFLAG_MYSHOP)`(后者
`ITEM_ANTIFLAG_MYSHOP = 1<<16``REF/GameLib/ItemData.h:204`);实例字段和原型字段取并集,
拦截时不发开店包。
3. 其他玩家查看:`GC_SHOP` 的 `SHOP_SUBHEADER_GC_START_EX` 已实现;招牌走
`GC_SHOP_SIGN`(已实现),但需按 §8.2 换成真 `.sub` 资源渲染。
4. 撤下回滚:参考端**没有**本地回滚——撤下即发包,状态由 `GC_SHOP` 回包驱动。
现有实现若做了本地乐观更新,删掉。
---
### 4.8 快捷栏来源类型不完整
**参考**
- 容量(`REF/UserInterface/Packet.h:392`):
`QUICKSLOT_MAX_LINE = 4`、`QUICKSLOT_MAX_COUNT_PER_LINE = 8`、
`QUICKSLOT_MAX_COUNT = 32`、`QUICKSLOT_MAX_NUM = 36`(**服务器保存 36,客户端只显示 32**)。
- 类型:`TQuickSlot{ BYTE Type; BYTE Position; }``REF/UserInterface/GameType.h:307`),
其中 **`Type` 直接就是 `ESlotType`**`GameType.h:140`)——
`CPythonPlayer::RequestUseLocalQuickSlot``REF/UserInterface/PythonPlayer.cpp:813`
拿 `GetLocalQuickSlotData()` 的原值直接 switch
```
SLOT_TYPE_INVENTORY (1) → SendItemUsePacket(TItemPos(INVENTORY, pos))
SLOT_TYPE_SKILL (2) → ClickSkillSlot(pos) // 走 §3.8 的完整链
SLOT_TYPE_EMOTION (3) → BINARY_ActEmotion(pos)
```
**参考端快捷栏没有「命令」类型**,`CLIENT-GAP.md` 里提到的“缺命令”不成立。
- **⚠ 当前实现的类型编号与参考端不兼容**:
`POC/extension/src/net/m2_client.cpp:878` 是
```cpp
d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion
```
而参考端 `3 == SLOT_TYPE_EMOTION`、`4 == SLOT_TYPE_SHOP`(快捷栏不可执行)。
也就是说**当前把服务端发来的 EMOTION(3) 当成了 command,把不可执行的 SHOP(4) 当成了
emotion**——服务端下发的表情快捷栏会被误判,`quickslot_add()`
`m2_client.cpp:897` 的 `type < 1 || type > 4`)也会把错误类型发回服务端。
- 边界(`PythonPlayer.cpp:800/815/843/852/861`):
`RequestDeleteGlobalQuickSlot >= QUICKSLOT_MAX_COUNT(32) → return`
`RequestUseLocalQuickSlot >= QUICKSLOT_MAX_COUNT_PER_LINE(8) → return`
`AddQuickSlot / DeleteQuickSlot / MoveQuickSlot` 越界 `QUICKSLOT_MAX_NUM(36)` → return。
`MoveQuickSlot(src, dst)` 是 `std::swap`,不是覆盖。
- 键位(`POC/assets/root/game.py:509`):
```python
def __PressQuickSlot(self, localSlotIndex):
if app.IsRTL():
if 0 <= localSlotIndex < 4: player.RequestUseLocalQuickSlot(3 - localSlotIndex)
else: player.RequestUseLocalQuickSlot(11 - localSlotIndex)
else: player.RequestUseLocalQuickSlot(localSlotIndex)
```
数字键 14 走 `pressNumber`(默认 = `__PressQuickSlot`),
按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage``game.py:506`)。
F1F4 固定映射到 `__PressQuickSlot(4..7)``game.py:318`)。
**当前(增量 53**`quickbar.gd` 已显示参考端的 4 页 × 8 格;服务器状态仍保留 36 槽,末 4 槽
不映射到本地显示。`M2Client.entity_dict()` / `quickslot_add()` 与 UI 使用同一套类型:1 道具、
2 技能、3 表情;4(商店)恢复时不执行。技能走 `use_skill()` + `cast_skill()`,表情走
`send_emoticon()`。
**已完成(增量 53**
1. `quickbar.gd` 已改为 4 页 × 8 格本地显示,保留服务器 36 槽状态;数字 1–4 激活 0–3,F1–F4
激活 4–7,LSHIFT 按住时数字 1–4 选择页。
2. `M2Client` / `quickbar.gd` 已统一 `INVENTORY=1 / SKILL=2 / EMOTION=3``quickslot_add()` 只
接受 13;技能保留完整 `use_skill()` + `cast_skill()` 链;4(商店)不执行。
3. Ctrl/Command+19 已调用 `send_emoticon()`;12 个迁移资产表情 token 和本地特效播放已接通。
4. 技能拖放、清除、同页 swap 和服务器回包恢复已回归;普通物品拖入快捷栏仍属于 §4.2 的未完成项。
5. 冷却仍由本地技能表预测并接受 `skill_cooldown_end` 解锁;死亡 / 变身 / 换武器后的完整刷新仍需
真实回包验收。
---
### 4.9 组队状态是近似定义
**参考**`PARTY_AFFECT_SLOT_MAX_NUM = 7``REF/UserInterface/Packet.h:404`)。
角色枚举与 Leadership 门槛在本工程增量 43 已按 40250 服务端 `CParty::Update` 对齐
NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / HASTE 6 /
DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。
**剩余差距与修改**
1. `affects[7]` 的**槽位语义**目前是“暂定映射”。需要从 40250 服务端
`game/src/party.cpp` 的 `CParty::ComputeRolePoint / Update` 读出每个下标的含义
**[待读参考:服务端源码,不在 ClientVS22 内]**;在拿到之前,
UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。
2. 队伍技能冷却:参考客户端**不本地计时**,冷却由服务端拒绝 + `GC_CHAT` 提示体现。
若当前有本地冷却,删除。
3. 队长权限动态变化:以 `get_party()` 里本地 VID 那条的 `leader` 位为唯一判据(已实现),
收到 `GC_PARTY_*` 后立即重算按钮可用性。
4. 离线 / 死亡 / 距离状态:来自 `GC_PARTY_UPDATE` 的字段,接到 `PartyMemberInfoBoard` 的
对应显示位。
---
### 4.10 好友、情侣、公会只有首版状态展示
**参考**`POC/assets/root/uimessenger.py`(739 行,分组 / 上线离线 / 右键菜单)、
`uiwhisper.py`(私聊窗生命周期)、`uiguild.py`。
公会名长度 `GUILD_NAME_MAX_LEN = 12`、等级名 `GUILD_GRADE_NAME_MAX_LEN = 8`、
等级数 `GUILD_GRADE_COUNT = 15`、公告 `GULID_COMMENT_MAX_LEN = 50`
`REF/UserInterface/Packet.h:408`)。
**修改**
1. 按 §8 的窗口迁移样板逐窗移植 `uimessenger.py` / `uiwhisper.py` / `uiguild.py`
不自行设计交互。
2. 公会相关按 `CLIENT-GAP.md` 的当前范围说明**暂缓**,但常量先落到 `limits.h`。
3. 情侣:已有 `GC_LOVER_INFO` / `GC_LOVE_POINT_UPDATE` 与 `love_ui`
完整交互按 `uiwhisper.py` / `uicharacter.py` 里的情侣分支移植。
---
## 5. P1:任务、NPC 和世界玩法
### 5.1 Quest EventManager 只支持少量标签
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 95 —— EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 + SEventSet 状态字段名 1:1;逐帧 `UpdateEventSet` 时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 / `isConfirmWait` 倒计时刷新)、`Skip`/`EndEventProcess`、`iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)仍为 seam**
**参考**:标签表在 `CPythonEventManager::CPythonEventManager()`
`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 42 个**`EEventType` 枚举 42 项,
`CLEAR` 与 `CLEAR_TEXT` 是两个独立枚举但都走 `ClearLine`):
```
LETTER COLOR COLOR256 DELAY ENTER WAIT CLEAR QUESTION NEXT DONE
LEFTIMAGE TOPIMAGE BGIMAGE IMAGE INSERT_IMAGE TITLE_IMAGE
ADDMAPSIGNAL CLEARMAPSIGNAL SETMSGPOS ADJMSGPOS SETCMAPPOS
QUESTBUTTON QUESTBUTTON_CLOSE
SLEEP SET_CAMERA BLEND_CAMERA RESTORE_CAMERA
FADE_OUT FADE_IN WHITE_OUT WHITE_IN
CLEAR_TEXT TEXT_HORIZONTAL_ALIGN_CENTER
RUN_CINEMA DUNGEON_RESULT ITEM MOB WINDOW_SIZE
INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM
```
分派在 `ProcessEventSet()``PythonEventManager.cpp:377`),每个 case 的语义
**逐条照抄**,行号为 `PythonEventManager.cpp`):
| 标签 | 行 | 行为 |
|---|---|---|
| `LETTER` | 399 | 追加 `value` 到当前行;着色 `CurrentColor``iCurrentLetter += len`;超 `iRestrictedCharacterCount` 换行;`lLastDelayTime = lWaitingTime` |
| `DELAY` | 416 | start`lWaitingTime = atoi(value)`end:恢复 `c_lNormal_Waiting_Time` |
| `COLOR` | 425 | start`r/g/b` 取 `atof`a=1end:全 1 |
| `COLOR256` | 444 | 同上但 `/255.0f` |
| `ENTER` | 463 | `__InsertLine` |
| `WAIT` | 469 | `iyLocal = 0; isLock = true` |
| `NEXT` | 477 | `MakeNextButton(BUTTON_TYPE_NEXT)``iAdjustLine += 2` |
| `DONE` | 484 | `MakeNextButton(BUTTON_TYPE_DONE)` + Python `DoneEvent()``iAdjustLine += 2` |
| `CLEAR` / `CLEAR_TEXT` | 492 / 667 | `ClearLine` |
| `QUESTION` | 498 | `MakeQuestion(argList)` |
| `LEFTIMAGE`/`TOPIMAGE`/`BGIMAGE` | 504/510/516 | Python `OnLeftImage/OnTopImage/OnBackgroundImage(src)` |
| `IMAGE` | 522 | Python `OnImage(x, y, src)` |
| `INSERT_IMAGE` | 532 | 有 `image_name` → `OnInsertImage(file, title, title, desc, index, total)`;否则 `OnInsertItemIcon(image_type, idx, title, desc, index, total)``iAdjustLine += 2` |
| `ADDMAPSIGNAL` | 554 | `MiniMap.AddSignalPoint(x,y)` + `OpenAtlasWindow()` |
| `CLEARMAPSIGNAL` | 564 | `MiniMap.ClearAllSignalPoint()` |
| `SETCMAPPOS` | 612 | `MiniMap.SetAtlasCenterPosition(x, y)` |
| `SETMSGPOS`/`ADJMSGPOS` | 604/608 | **空实现** |
| `QUESTBUTTON` | 579 | `icon_name` 为空 → `RecvQuest(idx, name)`;否则 `BINARY_RecvQuest(idx, name, icon_type, icon_name)` |
| `QUESTBUTTON_CLOSE` | 571 | `BINARY_ClearQuest(idx)` |
| `SLEEP` | 617 | `lLastDelayTime = atoi(value)` |
| `SET_CAMERA` | 620 | `rApp.SetEventCamera(设置)` |
| `BLEND_CAMERA` | 627 | `rApp.BlendEventCamera(设置, blendtime)` |
| `RESTORE_CAMERA` | 637 | `rApp.SetDefaultCamera()` |
| `FADE_OUT/FADE_IN/WHITE_OUT/WHITE_IN` | 642/649/655/661 | Python `FadeOut/FadeIn/WhiteOut/WhiteIn(speed)`,并 `isWaitFlag = true` |
| `TEXT_HORIZONTAL_ALIGN_CENTER` | 672 | `isTextCenterMode = true` + 当前行居中 |
| `TITLE_IMAGE` | 679 | Python `OnTitleImage(src)` |
| `DUNGEON_RESULT` | 684 | `ShowDungeonResult(killstone_count, killmob_count, find_hidden, hidden_total, use_potion, is_revived, killallmob, total_time, bonus_exp)` |
| `ITEM` | 709 | 用 `CItemManager` 取物品名追加,颜色固定 `(1.0, 0.2, 0.2)` |
| `MOB` | 728 | 用 `CPythonNonPlayer` 取怪物名追加(无着色) |
| `WINDOW_SIZE` | 748 | Python `OnSize(width, height)` |
| `INPUT` | 755 | `__InsertLine` + Python `OnInput()` |
| `CONFIRM_WAIT` | 761 | `isConfirmWait = TRUE; pConfirmTimeTextLine = 当前行; iConfirmEndTime = now/1000 + timeout;` `__InsertLine(TRUE)` + `MakeNextButton(BUTTON_TYPE_CANCEL)` |
| `END_CONFIRM_WAIT` | 771 | 遍历所有 event set,清 `isConfirmWait` |
| `SELECT_ITEM` | 790 | Python 侧打开选魔石窗(本工程已实现,保持) |
| `RUN_CINEMA` | 169 | **在注册阶段**特判:脚本首条是它时改为 `RegisterEventSet(value)` 加载另一个文件 |
时间推进(`UpdateEventSet``:312`):
`lLastDelayTime` 按帧 elapsed 递减,`<=0` 就 `ProcessEventSet` 一条;
`isWaitFlag` 为真时整体暂停;`isConfirmWait` 时每帧刷新倒计时文本
`m_strLeftTimeString`(默认 `"남은 시간 : %d초"`,可由 `SetLeftTimeString` 改)。
**当前(增量 95**`POC/project/ui/quest_dialog.gd` 已按 `CPythonEventManager` 逐条重写脚本解析:
- `EVENT_TYPE_MAP`42 条,键即 tag+ `enum { EVT_* }`(枚举顺序照抄 `PythonEventManager.h:35`
`EEventType`+ `_get_script_event_index()``GetScriptEventIndex` `:1093`——前导 `/` 剥离为
`EVENT_POSITION_END`,未知命令返回 `ok=false`、跳过、不进 switch)。
- `class EventSet``SEventSet` `PythonEventManager.h:98`——字段名 1:1`ix/iy/iWidth/iyLocal/
isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`+ `init_event_set()``__InitEventSet` `:72`
的常量:`c_lNormal_Waiting_Time=10`、`iRestrictedCharacterCount=30`、`c_fLine_Temp=16`、
`BOX_VISIBLE_LINE_COUNT=5`、颜色 `(1,1,1,1)`)、`insert_line()``__InsertLine` `:976`:推行 +
`iyLocal += 16`)、`clear_line()``ClearLine` `:953`)、`body_text()`。
- `_process_event_set(es, cmd)`——上表 `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷,
含 `DELAY`/`COLOR`/`COLOR256` 的 `EVENT_POSITION_START` vs `END` 分支、`LETTER`/`ITEM`/`MOB`
的 `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE`
的 `iAdjustLine += 2`、`WAIT` 的 `iyLocal=0; isLock=true`、`CONFIRM_WAIT` 的
`isConfirmWait=true` + `insert_line(center)`、`END_CONFIRM_WAIT` 清 `isConfirmWait`、
`FADE_*`/`WHITE_*` 的 `isWaitFlag=true`、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**。
- `_tokenize()` 把混排文本 / `[tag ...]` 拆成命令流,裸网络文本折成合成 `LETTER``is_raw=true`
跳过 30 字断行——reference 脚本只有显式 `[LETTER]`,逐字符断裸文本会破坏服务器句子)。
- arg 提取 `_tag_kv()``key(val)` / `key("val")` / `key;val`+ `_arg_s/_arg_i/_arg_f`
(keyword 优先,位置回退,兼容旧测试的 `value;255 value;128` 与 `value(1) value(2)` 写法)。
- `parse_script()` 返回字典在原有键之外新增:`insert_images / dungeon_result / adjust_line /
waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`(供回归断言)。
- 首命令 `RUN_CINEMA` → 记 `cinema_file` 并发 `cinema_requested` 信号(`.msc` 文件加载是 seam)。
- 回归 `POC/project/quest_event_test.gd`(新增,规范测试 #1623 组断言:EventTypeMap 完整性 /
`/` 前缀 / DELAY START·END / 断行 / ENTER·CLEAR·CLEAR_TEXT / QUESTION·nAnswer /
NEXT·DONE·adjust_line / WAIT·isLock / COLOR·COLOR256 / INPUT·SELECT_ITEM / CONFIRM_WAIT·
END_CONFIRM_WAIT / DUNGEON_RESULT 9 int / INSERT_IMAGE 两路 / IMAGE 家族 / map signal /
QUESTBUTTON·CLOSE / camera SET·BLEND·RESTORE / FADE·WHITE·wait_flag / WINDOW_SIZE /
ITEM·MOB / SETMSGPOS·ADJMSGPOS no-op / 未知 tag 跳过 / RUN_CINEMA 首命令·非首命令),
既有 `quest_test.gd` / `select_item_ui_test.gd` 仍绿。
**修改**
1. ✅ `quest_dialog.gd` 已接入常用标签的解析元数据、颜色 / 居中 / 尺寸、ITEM/MOB 占位名、
CONFIRM_WAIT 取消入口、任务信件 / 小地图 side effect,并在图片路径可解析时创建实际图片节点;
路径不存在时安全跳过,不伪造错误资源。
2. ~~剩余工作是把 `parse_script` 升级为完整的 `EventTypeMap`38 项)+ 上表的 `ProcessEventSet`
分派,逐条实现。~~ **增量 95 完成**:`EVENT_TYPE_MAP`42 项)+ `_get_script_event_index` +
`_process_event_set` 已逐 case 对拷上表;裸文本折成合成 `LETTER`。
3. ~~引入 `EventSet` 状态~~ **增量 95 完成**`class EventSet` 字段名与 `SEventSet` 1:1
`lWaitingTime / lLastDelayTime / iCurrentLetter / iRestrictedCharacterCount / iAdjustLine /
iyLocal / isLock / isWaitFlag / isConfirmWait / iConfirmEndTime / isTextCenterMode /
CurrentColor` 等),`init_event_set()` 复位。**seam**:一次性把 token 跑完,`isLock` /
`isWaitFlag` / `lLastDelayTime` 仅作状态保留并透出,不做逐帧 `UpdateEventSet` 时间推进。
4. ✅ `SETMSGPOS` / `ADJMSGPOS` 已实现为**空**`_process_event_set` 的 `EVT_SET_MESSAGE_POSITION,
EVT_ADJUST_MESSAGE_POSITION: pass`,注释引 `PythonEventManager.cpp:562/566`)。
5. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮
按 `PythonEventManager.cpp` 的对应函数移植
`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。
→ **增量 95 落地**`_make_question``nAnswer` = arg 数、逐 arg 入 `choices`)、`NEXT/DONE →
has_next + iAdjustLine += 2`、`CONFIRM_WAIT` 的 CANCEL 由 `_fill_buttons(confirm_wait)` 出、
`insert_line`/`clear_line` 已按 `:976`/`:953` 移植。**未接**`MakeNextButton` 的 `NEXT` vs
`DONE` 按钮文案区分(现均为「继续」)、`DoneEvent()` 回调。
6. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。
→ **增量 95 落地一半**:`_process_event_set` 已解析 `SET_CAMERA``_camera_setting` 九参
`x/y/z/up/view/cross/distance/rot/pitch``GetCameraSettingFromArgList` `:48`)、`BLEND_CAMERA`
+`blendtime`)、`RESTORE_CAMERA` 并经 `camera_event_requested` 信号发出。**未接**:信号消费端
与 §7.6 的相机模式对接(仍为 game_scene 层 TODO)。
---
### 5.2 任务日志不是完整 Quest 系统
**参考**`REF/UserInterface/PythonQuest.h` + `POC/assets/root/uiquest.py`926 行)。
任务条目由 `EVENT_TYPE_QUEST_BUTTON` 的 `BINARY_RecvQuest(idx, name, icon_type, icon_name)`
(§5.1)与 `GC_QUEST_INFO` 共同驱动;关闭走 `BINARY_ClearQuest(idx)`。
**当前(增量 101**`quest_log.gd` 已按参考端的 `QUEST_MAX_NUM=5` 保持任务快照,固定渲染
5 行;任务数超过 5 时显示滚动条,并将滚动位置离散映射到 `questShowingStartIndex`。每行显示
默认 `season1/icon/scroll_open.tga`(无 icon 路径)或服务端 icon、标题、counter 和 clockclock
按参考的分钟 / 秒格式显示,并在窗口打开时按秒递减。点击任务行发送
`script_button(-2147483648 + questIndex)`,并发出 `quest_selected`。另有独立 QuestButton 条带,
接收 `QUESTBUTTON/QUESTBUTTON_CLOSE`、点击发送 `script_button(index)`,并在对话打开时隐藏。
`quest_dialog.gd` 已识别常用 EventManager 标签,把颜色、居中、尺寸、ITEM/MOB、确认等待、图片资源
和地图信号暴露给 UI。
**已完成 / 剩余**
1. ✅ 任务日志已从纯文字列表升级为 5 行窗口:标题 + 默认 / 服务端 icon + counter + 倒计时;
资源缺失时安全回退,不伪造图标;任务数量、滚动起始索引和可见行可由回归测试检查。
2. ✅ 已补齐参考端任务行选择:滚动条按 `questCount - QUEST_MAX_NUM` 映射起始行,点击行按
`-2147483648 + questIndex` 进入 QuestButton 事件路径。
3. ✅ 已移植独立 QuestButton 的接收 / 替换 / 删除 / 点击发送和对话抑制;已将
`EVENT_TYPE_ADD_MAP_SIGNAL` / `CPythonMiniMap::AddSignalPoint`、清除和 Atlas center adjust 接到
现有 minimap / Atlas。**增量 108**:小地图任务信标越出半径时按 `m2dev PythonMiniMap.cpp:441-462`
的 `atan2 + radius*cos/sin` 夹到圆周并改画指向目标的方向箭头(`minimap.signal_marker_geometry` +
`_draw_signal_arrow`,留边常量 `SIGNAL_RIM_INSET=9.0` 对齐 `c_fMiniMapWindowRadius` 的 `- 9.0f`),
即世界箭头 / 屏幕边缘指示首版。**增量 109**:quest 信件按钮条按 `assets/root/interfacemodule.py:1357-1386`
的 `__ArrangeQuestButton` 1:1 多列定位——`yPos = 170*H//600`、`yCount = (H-330)//63`、第 i 个
`= (xPos + (i//yCount)*100, yPos + (i%yCount)*63)``xPos` 组队窗显示时由 20 移到 130;`_quest_button_order`
复刻 `questButtonList.insert(0, btn)`(最新在前、同 index 提前),`_button_list` 改绝对定位 Control
(不再 VBox 单列 sort by index),组队窗可见性经 `set_party_probe` 在 `_process` 轮询。剩余是 Atlas
同一信标夹边、任务日志窗体 `_list` 自身多列 / 分页和完整 `.sub` 图标 / Flash 视觉。
4. EventManager 的 38 项逐事件推进、立绘、淡入淡出、相机、电影和真实 `.sub` 资源仍按 §5.1
单独推进,不能以本批次的标签识别当作完整迁移。
---
### 5.3 钓鱼、副本、坐骑和观战者只有部分协议入口
**参考要点**
- 钓鱼:`CPythonPlayer::NEW_Fishing()``REF/UserInterface/PythonPlayerInput.cpp:526`
```
if (pkInstMain->GetFishingRot(&irot)) SendFishingPacket(irot);
else PyCall(gameWindow, "OnFishingWrongPlace")
```
攻击键在钓鱼模式下改为钓鱼:`SetAttackKeyState``PythonPlayerInputKeyboard.cpp:6`
`if (isPress && main->IsFishingMode()) { NEW_Fishing(); return; }`。
方向键会 `NEW_CancelFishing()``PythonPlayerInputKeyboard.cpp:24`)。
- 坐骑:`CInstanceBase::MountHorse` → `SetMotionMode(MODE_HORSE)``InstanceBase.cpp:953`);
攻击限制 `CanAttackHorseLevel()``InstanceBaseBattle.cpp:623`=
`IsMountingHorse() && m_kHorse.CanAttack()`
骑马手部模式下禁止徒手攻击:`NEW_Attack()``PythonPlayerInput.cpp:613`
`if (main->IsMountingHorse() && main->IsHandMode()) return;`
动作模式表见 §6.4。
- 副本:`CDungeonBlock``REF/GameLib/DungeonBlock.h`+ `PROPERTY_TYPE_DUNGEON_BLOCK`
(§7.1);结算走 `EVENT_TYPE_DUNGEON_RESULT`(§5.1)。
- 观战:`CPythonPlayer::IsObserverMode()`——它在 `__CanUseSkill()``PythonPlayerSkill.cpp:445`
与 `IsAttackableInstance()``InstanceBase.cpp:2151`)里都是**第一道门**。
**当前状态:🟡 部分完成(2026-09-03 / 增量 107**
增量 102 已完成钓鱼模式攻击键发送 `CG_FISHING`、方向键和地面点击取消、500ms 取消节流、
特殊技能 123 快捷栏入口,以及 `GC_FISHING` 五类事件的客户端意图状态维护;增量 103 又接入
`GetFishingRot` 等价的真实 `ATTRIBUTE_WATER` 水面扫描和选中 heading;增量 104 又按
`RecvFishing` 分支接入 wrong-place / failure / unknown / notify / success 的 locale 消息与
item proto 类型判定;增量 105 接入普通 / 新坐骑的攻击等级门和自动攻击的 `mount_vnum` 判定;
增量 106 接入观战者小地图 ADD / MOVE / REMOVE 一秒插值和 `world_reset` 清理;增量 107 按
`GC_DUNGEON DESTINATION_POSITION` 接入副本目的地状态、全局厘米坐标转换、目标到达判据和
HUD 距离 / 朝向提示,并在 `world_reset` 时清理。`TIME_ATTACK_START` 保持参考端空分支,不添加
本地计时器。具体文件和测试见本文件 C.5 增量 102 / 103 / 104 / 105 / 106 / 107 记录。
**剩余修改**
1. 钓鱼剩余:接入真实 `GC_ITEM_*` 物品更新 / inventory 事务并验证 item proto 与服务端库存;当前
消息分支已完成,水面扫描依赖已加载区块,地图流式切换后的 provider 覆盖仍需真实场景验收。
2. 坐骑剩余:攻击等级门已由增量 105 接入;仍需补真实坐骑模型、`MODE_HORSE*` 动作 / 骑乘动画、
移速和真服坐骑数据验收。
3. 副本剩余:读取真实 `.mde` / `.mdatr` collision 的 DungeonBlock,补真实入口、结算窗口和
`EVENT_TYPE_DUNGEON_RESULT` 的 9 参数字段;目的地消费 / 到达 / HUD 罗盘首版已由增量 107 接入,
参考端 `TIME_ATTACK_START` 本身没有本地计时逻辑。
4. 观战剩余:小地图插值和 `world_reset` 清理已由增量 106 接入;仍需补自由视角、真实服务器
`GC_OBSERVER_ADD/REMOVE/MOVE` 完整生命周期和换图时序验收。
---
### 5.4 PVP、PK 和决斗没有完整规则
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 94 —— `IsAttackableInstance` 1:1;增量 110 —— 名字颜色 `GetNameColorIndex` 1:1,见 §8.8 / C.5 增量 110`ATTRIBUTE_BANPK`、PVP/GVG/DUEL key 的 packet setter 仍为 seam**
**参考**`CInstanceBase::IsAttackableInstance(victim)`
`REF/UserInterface/InstanceBase.cpp:2147`)是**唯一**判据,完整逻辑:
```
主角且 IsObserverMode() → false
自己打自己 → false
IsStone(): victim.IsPC() → true
IsPC():
victim.IsStone() → true
victim.IsPC():
GetDuelMode():
DUEL_CANNOTATTACK → false
DUEL_START → __FindDUELKey(self, victim) ? true : false
PK_MODE_GUILD == GetPKMode() && 同公会 → false
victim.IsKiller() && !同队 → true
PK_MODE_PROTECT != GetPKMode():
PK_MODE_FREE : victim 非 PROTECT && !同队 → true
PK_MODE_GUILD : victim 非 PROTECT && !同队 && 公会不同 → true
IsSameEmpire(victim):
IsPVPInstance(victim) → true
PK_MODE_REVENGE && !同队 && IsConflictAlignmentInstance → true
else (不同帝国) → true
victim.IsEnemy() → true
victim.IsWoodenDoor() → true
IsEnemy(): victim.IsPC() || victim.IsBuilding() → true
IsPoly(): victim.IsPC() || victim.IsEnemy() → true
否则 false
```
`EPKModes``REF/UserInterface/Packet.h:1273`):
`PK_MODE_PEACE=0, PK_MODE_REVENGE=1, PK_MODE_FREE=2, PK_MODE_PROTECT=3, PK_MODE_GUILD=4,
PK_MODE_MAX_NUM=5`。
**注意**`uigameoption.py` 的 PK 单选按钮映射是
`peace→/pkmode 0`、`revenge→/pkmode 1`、`guild→/pkmode 4`、`free→/pkmode 2`
(本工程增量 38 已实现,与上面的枚举一致,保持)。
名字颜色枚举 `ENameColor``REF/UserInterface/InstanceBase.h:227`):
`NAMECOLOR_MOB / NPC / PC(+EMPIRE_NUM) / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY /
WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。
**修改**
1. ~~在 `player_controller.gd`(或 `entity_rules.gd`**逐行**实现
`is_attackable_instance(self_vid, victim_vid)`,不做简化。~~
**增量 94 完成**:新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`),
`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `InstanceBase.cpp:2147` ——
Stone / PC / Enemy / Poly 四分支、决斗三态(`DUEL_CANNOTATTACK`→false、`DUEL_START`→
`__FindDUELKey`)、`PK_MODE_GUILD` 同公会提前放行、`victim.IsKiller()` && !同队、
`PK_MODE_PROTECT != GetPKMode()` 下 `PK_MODE_FREE`victim 非 PROTECT && !同队)/
`PK_MODE_GUILD`(再加公会不同)、`IsSameEmpire` → `IsPVPInstance` / `PK_MODE_REVENGE`
&& !同队 && `IsConflictAlignmentInstance`、异帝国直接 true、`victim.IsEnemy()` /
`victim.IsWoodenDoor()`。附 `alignment_grade`:502)、`alignment_type`:524)、
`is_conflict_alignment_instance`:2116)、`_is_same_empire`:470)、`_is_pvp_instance`
`InstanceBaseEffect.cpp:505`)、`_find_duel_key`:495)、`_is_same_party`
`PythonPlayer.cpp:1445`)、`_is_wooden_door`:2390vnum 13000 / 30111..30119)。
`_is_pc` 依 `CActorInstance::IsPC``ActorInstance.cpp:282`)只看 `TYPE_PC`,变身玩家仍是 PC
`_is_enemy` 只 `TYPE_ENEMY`;独立 `TYPE_POLY` 实例才走 `else if (IsPoly())` 分支。
`net_play.gd` 的 `_is_attackable(e)` 改为拼 `self_e``_main_entity()` + `kind`/ `victim_e`
+ `kind`/ `_attack_ctx()``get_duel` / `get_pvp_relations` / `get_guild_wars` / `get_party`),
委托 `EntityRules.is_attackable_instance``_main_entity()` 为空(headless)退化到旧的
「PC / 怪可打」近似。自检 `project/entity_rules_test.gd`(第 15 个 canonical25 组断言,
覆盖 grade/type 边界、观战门、自打自、四类别、帝国 same/diff/0、决斗三态、PK 五模式、
killer、同队抑制、PVP-pair、GVG-pair、木门、冲突善恶、变身)。
2. 依赖判据补齐:`IsKiller()``state_flags & ADD_CHARACTER_STATE_KILLER`1<<3 位,
classic 后端 `classic_parser.cpp` 已接;m2dev 后端为 **seam**)、`IsSameEmpire()`
empire 字段,§2.5 已补;`IsGameMaster()` 无 GM 标记 → **seam**,恒 false)、
`IsSamePartyMember()``get_party()` 的 VID 集合,已接)、`GetDuelMode()` + `__FindDUELKey`
`get_duel().opponents`,已接)。**仍为 seam**`__FindPVPKey``get_pvp_relations()` 有值则用,
其 setter`GC_PVP` 未接)、`__FindGVGKey``get_guild_wars()` 对表,公会战对局 setter 未接)、
`IsBuilding` / `IsWall` 的 race 白名单(源码未随发布)、`_entity_kind` 无法分类
(无 `ch_type` 且 mob_proto 查不到 race)时怪会被当成和平 PC → 不可攻击
(生产环境 mob_proto 恒加载,仅影响裸测试夹具——增量 94 已给相关夹具补 `ch_type`)。
3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。
**增量 110 完成名字颜色**:`project/name_color.gd` 逐行照抄 `CInstanceBase::GetNameColorIndex`
`REF/UserInterface/InstanceBaseEffect.cpp:525`+ `SetEmpireNameMode`:52+ `GetIndexedNameColor`
:75),复用本节 `entity_rules.gd` 的 `_is_pc / _is_enemy / _is_poly / _is_killer / _is_same_empire /
_find_duel_key / _pair_in` 与 `DUEL_*` 常量;ctx 与 `_attack_ctx()` 同构。`net_world.gd` 接进头顶
`Label3D.modulate`。**增量 111 完成称号色**:`project/text_tail.gd` 照抄 `GetTitleColor`
:609+ `RefreshTextTail`:664+ `UpdateTextTailLevel`:654),复用 `entity_rules.alignment_grade`
`net_world.gd` 接进 `TitleTag` / `LevelTag` / `GuildTag` 子标签。**增量 112 完成聊天 / 信息尾标**:
`project/chat_tail.gd` 照抄 `CPythonTextTail::RegisterChatTail` / `RegisterInfoTail`
`PythonTextTail.cpp:581/:624`+ `TextTail_GetLivingTime`:28+ `RecvChatPacket` 派发
`PythonNetworkStreamPhaseGame.cpp:1301/:1321/:1372`);`net_world.gd` 每 vid 一条 `"bubble"`
`Label3D`、白 / 淡红按 chat/info、5s 硬删。**增量 113 完成名字「是否显示」**:
`project/name_show.gd` 照抄 `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`
`PythonSystem.cpp:327/:334`+ `game.py::__IsShowName`:1149+ `CPythonTextTail::ShowAllTextTail`
的 `fDistanceFromPlayer < 3500``PythonTextTail.cpp:422`+ `ShowCharacterTextTail` 资格判定
:440+ `UpdateAllTextTail` 的 chat `bNameFlag` 强显(:74);`net_world._name_shown()` 组装
`_always_show_name`uigameoption radio/ `_alt_held`LALT/ `_target_vid`
`net_play.target_changed`,即 `targetBoard.GetTargetVID()`/ chat `bNameFlag` 四路。
**目标框已让选中目标强显名字**;可攻击光标仍 **未接**;悬停 `chr.Pick()` 强显 = seam ⑩。
**增量 114 完成头顶伤害飘字**:`project/damage_effect.gd` 照抄 `CInstanceBase::AddDamageEffect`
`InstanceBaseEffect.cpp:87``IsShowDamage()` 门 + `m_DamageQueue`+ `ProcessDamage`
:100,每帧 `pop_front` 一条 + `DODGE|BLOCK → MISS` / `self "damage_"` 交替 / `nontarget_` 不渲染
/ `target "target_"` 分类 + 数字逐位低位在前)+ `CPythonSystem::IsShowDamage` / `SetShowDamageFlag`
`PythonSystem.cpp:337/:342`);`net_world._on_damage` 改为按此入队、`_process_damage_queue()`
每帧 pop 一条渲染 billboard `Label3D``bSelf` 用 `_main_vid`、`bTarget` 用 `_target_vid`
`game_option_ui` 的「伤害数字」radio 经 `display_option_changed` → `net_world.set_show_damage`。
seam ⑪:单块 `Label3D` 代替 `damagevalue/<type><digit>.dds` 逐位特效 / 相机右向像素铺排、
暴击无独立视觉(对齐该 build 参考端)、全局单队列每帧一条 vs 参考每实例一队列。
**增量 115 完成目标框收包分派**:`project/target_board.gd` 照抄 `CPythonNetworkStream::RecvTargetPacket`
`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树 + `CInstanceBase::CanViewTargetHP`
`InstanceBase.cpp:2293` `IsStone() || IsWoodenDoor() || IsEnemy()`+ `game.py` 的
`SetHPTargetBoard`:829,换 VID 先 `ResetTargetBoard` + `SetEnemyVID`/ `CloseTargetBoardIfDifferent`
:837/ `CloseTargetBoard`:841/ `SetPCTargetBoard` 的 `LCONTROL` 悄悄话捷径(:804);
`classify(target_exists, is_dead, is_pc, is_building, can_view_hp)` → `Action { CLOSE,
CLOSE_IF_DIFFERENT, SET_HP, NONE }`(实例缺失 → CLOSE、已死 → NONE 不动框、PC|Building →
CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)。`net_play._on_target_info(vid, hp%)` 由
「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、
`can_view = KIND_STONE 或 KIND_MONSTER`、SET_HP 时换 VID 先 `_target_vid = vid` +
`target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`。seam ⑫:poc 无 building /
wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类缺);`target_info` 由 C++
`m2_client.cpp:3129/3633` 从 EntityStore 抽出,不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词
`whisper_on_ctrl_click` 已备、`net_play` 暂不接(无 whisper dialog 钩子)~~ 增量 122 已接线
`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` →
`game_scene` → `chat.start_whisper`);seam ⑫ 余留:`is_building` 恒 false(门实体缺)、
poc 悄悄话 = 聊天输入预填非独立 whisper dialog 窗口。
**增量 116 完成 `OnRender` Picking 块分派 + 悬停角色强显名字**:`project/pick_show.gd` 照抄
`game.py GameWindow.OnRender()` 的「Picking」块(:1519)—— `wndMgr.IsPickedWindow` 门(:1533)→
`chr.Pick()` 命中角色 `ShowCharacterTextTail`:1535/:1537)、`targetBoard.GetTargetVID()`
`ShowCharacterTextTail`:1539)、`__IsShowName()` 分叉(非总显名字 → `item.Pick()` :1543、
总显名字 → `ShowAllTextTail` + `textTail.Pick()` :1552/:1554)、末尾 `SelectItemName`:1560);
`forced_name_vids(is_picked_window, pick_char_vid, target_vid)` 收集 `[hover, target]` 去重。
poc 侧仿增量 113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`
`net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点可见性)由 `player_controller`
`_update_hover_cursor()` 的射线悬停拾取喂入(`hover_entity_changed` 信号,压 UI / 悬停地面物品
→ emit 0),`game_scene` 连 `pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字
尾标 y = chat 气泡 y 17px`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail`
屏幕空间去重叠排布仍缺(本增量只收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced
靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效 `IsPickedWindow` 门。
**增量 117 收口 seam ⑨(名字尾标随聊天气泡上移 17px)**:`project/name_tail_layout.gd` 照抄
`CPythonTextTail::UpdateShowingTextTail``PythonTextTail.cpp:88`)—— 角色名字尾标投影完屏幕
坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` TRUE
:112,= `RegisterChatTail` 而非 `RegisterInfoTail`)→ `pTextTail->y = pChatTail->y - 17.0f`
:114)。`name_follows_bubble(has_chat_tail, chat_name_flag)` / `name_tail_screen_y`(参考端
字面 `bubble_y 17`/ `name_tail_world_y`poc 3D:屏幕 y 向下 17px → 世界 y 向上
`bubble_world_y + 17*pixel_size`)。poc 侧 `net_world._reposition_name_tag(n)` 由
`_apply_chat_tail` / `_expire_chat_tails` 调用,冒「聊天」气泡时名字 `Label3D` 局部 y 顶到
「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」、气泡消失或只是「信息」尾标(`bNameFlag` FALSE)时
回 `NAME_TAG_DEFAULT_Y` 2.1。
**增量 119 收口 seam ⑦(子标签随名字尾标纵向位移)**:`CPythonTextTail::ArrangeTextTail`
`PythonTextTail.cpp:151`)里逐条角色名字尾标(:211)的公会名(:228,`pTextTail->y -
c_fyGuildNamePosition` 15px 上方)/ 称号(:258,同 `pTextTail->y`/ 等级(:283,同 y
三个子标签屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 `UpdateShowingTextTail :114`
顶过 17。`name_tail_layout.gd` 增 `name_tail_lift_delta`(名字尾标相对默认 y 的净位移,
follows 时非 0/ `subtag_world_y``default_subtag_y + lift_delta`);`net_world`
把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为 `SUBTAG_DEFAULT_Y` 常量
2.34 / 1.86 / 1.74),`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签,
气泡过期 / 「信息」尾标时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版
(公会名上方 15px、称号 / 等级同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个
并存 `Label3D`(非参考单尾标链表)。
**增量 120 收口 seam ⑩(item 尾标去重叠 + `isIn`**`CPythonTextTail::ArrangeTextTail`
`PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它
每条比 `isIn`:489-507,标准 AABB 含边界),命中就把「自己」顶到对方框底下
`pCompareTextTail->y + pCompareTextTail->yEnd + yTemp``yTemp = 5`),内层从头重跑、
`LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd`
`boxes_overlap``isIn`/ `box_*` 四边(`-w/2-2` / `w/2+2` / `-2` / `h``PythonTextTail.cpp:810-813`
/ `arrange_item_tails(tails) -> Array`(外层逐条 / 内层比对 / 命中顶到框底下 + `PUSH_GAP` 5 /
从头重跑 / `MAX_ITERS` 20 封顶)。poc 侧 `project/ui/ground_items.gd._arrange_tags` 每帧把所有
掉落名条世界锚点 `cam.unproject_position` 到屏幕、按 `文本字符数 * 7px` 估宽、单行 15px 估高
组框,跑 `arrange_item_tails`,把被下推的屏幕 Δy 按 `Label3D.pixel_size` 折回世界局部 y
`TAG_BASE_Y Δpx*pixel_size`),可见名条 < 2 直接回默认;`game_scene` 把相机 getter 传进
`ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs 参考 `GetTextSize` 精确像素~~
(增量 123 已换真实字体度量 `TextMetrics.label_width`);~~单行高仍按 `TAG_LINE_H_PX` 15px 估~~
(增量 124 已换 `TextMetrics.label_height` = `GetTextSize` 的 h,见下);
只对掉落 item 名条做(与参考 `m_ItemTextTailList` / `m_CharacterTextTailList` 分表一致)。
**增量 121 深化 seam ⑦(子标签逐像素水平排版)**:`CPythonTextTail::ArrangeTextTail` 的
`m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态
:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)),称号 `x - iNameWidth/2 - fxAdd`
(:258,居中对齐)、等级有称号时再 `- iTitleWidth`:282,右对齐)否则 `x - iNameWidth/2 - 4`
:312)、名字本身 `x + fxAdd`(:320);公会名的水平锚点参考端仅在「有公会徽记 `pMarkInstance`」
时(:220)才改写成 `x + markW/2 + 1.5`:228)。`name_tail_layout.gd`(第 28 个 canonical)加
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
`subtag_world_x`(屏幕像素 → billboard 本地 x,按 `Label3D.pixel_size` 折世界)。`net_world`
新增 `_layout_subtags_x(n)`(在 `_apply_text_tail_to` 尾部):名字 / `TitleTag` / `LevelTag` 各设
`.position.x``GuildTag.position.x = 0`(poc 无徽记 → 参考端 :220 此路不改写);文本宽按
`字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`;增量 123 已换真实字体度量)。与增量 117/119 的纵向
位移解耦(前者写 `.position.y`、本增量写 `.position.x`)。剩余 seam ⑦:无公会徽记 mark 实体、
~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
**增量 123 收口 seam ⑦ / ⑩(文本宽换真实字体度量 `GetTextSize`**`ArrangeTextTail` 里
`iNameWidth` / `iTitleWidth` / `iLevelWidth``PythonTextTail.cpp:225/233/240/267/297`)与掉落
名条框 `xSize`:733/:748/:809)参考端全部来自 `CGraphicTextInstance::GetTextSize(&w, &h)`。
新增第 30 个 canonical `project/text_metrics.gd``text_size(font, text, font_size)` =
`Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(空 / null / 非法字号 →
`Vector2.ZERO`)、`width_of(font, text, font_size, fallback_char_px=7.0)`(量得正宽用精确像素,
否则回退 `len * fallback_char_px`)、`label_width(label: Label3D, fallback_char_px=7.0)`
`label.font ?? ThemeDB.fallback_font` + `label.font_size``null` → 0)。`net_world._layout_subtags_x`
的 `name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX`
换成 `TextMetrics.label_width(label, *_CHAR_W_PX)``SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX = 7.0`
常量保留作无字体(headless / 未配主题)兜底。`name_tail_layout.gd` / `text_tail_arrange.gd`
收宽度作入参、排布公式(增量 119/120/121)不动。测试 `text_metrics_test.gd`,回归 70 全 PASS。
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:名条单行高
仍按 15px 估、只对掉落 item 名条做。
**增量 124 收口 seam ⑦ / ⑩ 末尾(单行文本高换真实字体度量 `GetTextSize` 的 h**:去重叠
用的 item 尾标框 `yEnd = ySize``PythonTextTail.cpp:751/:812`),`ySize` 与 `xSize` 同样来自
`CGraphicTextInstance::GetTextSize(&xSize, &ySize)`:749/:809)—— 单行文本的 `ySize` ≈ 字体
行高(ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。`text_metrics.gd`
增 `const FALLBACK_LINE_H_PX := 15.0`(=旧 `ground_items.TAG_LINE_H_PX`)、`height_of(font,
text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素;否则回退)、
`label_height(label: Label3D, fallback_line_px=15.0)``label.font ?? ThemeDB.fallback_font` +
`label.font_size``null` → 0)。`ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX`
换成 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))``TAG_LINE_H_PX = 15.0` 常量保留
作无字体兜底。`text_tail_arrange.gd``box_end_y(h) = h`/ `name_tail_layout.gd` 收框高作入参、
排布公式不动。测试扩 `text_metrics_test.gd`「5. height_of / label_height」段,回归 70 全 PASS。
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;~~剩余 seam ⑩:掉落名条
owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`:733-736poc 仍按单行
`"name (owner)"` 处理)~~ 增量 125 已把去重叠框度量 1:1、只对掉落 item 名条做。
**增量 125 收口 seam ⑩ 末尾(owner 后缀框度量:owner 独立度量 + `yEnd += yOwnerSize + 4`**
参考 `CPythonTextTail::SetItemTextTailOwner``PythonTextTail.cpp:706-737`)—— 带 owner 的
掉落名条,owner 后缀挂成独立 `CGraphicTextInstance`(文本 = `c_szName + strOwnership`
`IDS_POSSESSIVE_MORPHENE` 为空取字面 `"'s"`;黄 1,1,0 / CENTER),与名字实例分开
`GetTextSize(&xOwnerSize, &yOwnerSize)`,据此 `yEnd += float(yOwnerSize + 4)`:735)、
`xStart = fMIN(xStart, -xOwnerSize/2-1)`:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`:737);
owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个
canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`
`owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0),
name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX(name/2+2, owner/2+1)`
框 x 两边对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical
加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` →
字符数 * `fallback_char_px` / `fallback_line_px`,空串 → 0)。`ground_items._on_added` 存
`item_name` / `item_owner` meta`_arrange_tags` 把「名字子串」「owner + `'s` 子串」分别
度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是单个合并 `"name (owner)"` billboard
`Label3D`(seam ⑩ 记录在案的折中)。测试扩 `text_tail_arrange_test.gd`「5. item_box_with_owner」
段 / `text_metrics_test.gd`「6. string_*_like」段 / `p2b_test.gd``FakeClient.add_ground`
加可选 owner 入参 + 带 owner 掉落被顶 `(15+15+4+5)*0.005` 段),回归 70 全 PASS(无新增
测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接
`ApplicationStringTable` 的本地化语素~~ 增量 126 已收口(见下),只对掉落 item 名条做。
**增量 126 收口 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
接本地化语素)**:参考 `CPythonTextTail::SetItemTextTailOwner``PythonTextTail.cpp:712-745`
+ `ArrangeTextTail`:191-207)—— owner 非空时新建 `pOwnerTextInstance`:715)、文本
= `c_szName + strOwnership`:723`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)
== "" ? "'s" : ...`:722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`:727)、
`SetColor(1.0f, 1.0f, 0.0f)` 黄(:729);排布 `pOwnerTextInstance->SetPosition(x, y, z)`:195
而名字实例 `pTextInstance->SetPosition(x, y + 15.0f, z)`:200);owner 为空时
`CGraphicTextInstance::Delete(pOwnerTextInstance)`:743)。`ground_items`drop 节点下加第二个
`Label3D "owner_tag"``horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate =
Color(1,1,0)` / billboard / `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),
`_apply_owner_tag(node, owner)` 建 / `remove_child + queue_free` 删;名字 `tag` 只放
`_name_for(vnum)``_tag_text` 随之删除,不再拼 `"name (owner)"`)。新增
`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)`:有 `owner_tag` 时
`owner_tag.position.y = TAG_BASE_Y - d_px*px`、`tag.position.y = TAG_BASE_Y - (d_px + 15)*px`
无则 `tag.position.y = TAG_BASE_Y - d_px*px``_arrange_tags` 的三处落点——behind-camera /
单条 / 多条排布——都改走它)。`setup()` 加可选 `possessive := ""``_possessive = possessive
if possessive != "" else OWNER_POSSESSIVE`1:1 对齐 :722 的 `== "" ? "'s" :` 语义),
`game_scene` 用 `EterLocale` 的 `POSSESSIVE_MORPHENE` 键解析后注入(poc 的 `locale/*.txt`
无此键 → 回落 `"'s"`,与 40250 西文 `UserInterface.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"`
一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner``yEnd += ownerH + 4`)不变。
测试扩 `p2b_test.gd`drop_302 名字实例文本 `"掉落28"`、`owner_tag` 文本 `"Alice's"` /
`HORIZONTAL_ALIGNMENT_CENTER` / `Color(1,1,0)` / 落基点,名字实例下移 `15 * pixel_size`
无 owner 的 drop_301 不建 `owner_tag`drop_401 用 `setup(possessive="@")` → `owner_tag`
文本 `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余 seam ⑩:owner 行颜色语义固定黄
(参考名字实例取 `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、
去重叠只对掉落 item 名条做。
4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性
`ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]** —— **未接**。
---
## 6. P1/P2:模型、动画和特效
### 6.1 本地角色先显示胶囊占位体
**参考**:参考端**没有占位体**。加载期由 Python 的加载幕布盖住:
`PHASE_WINDOW_LOAD` 的 `LoadData(lX, lY)``REF/.../PythonNetworkStreamPhaseLoading.cpp:211`
在 `introloading.py` 里显示加载画面;真正的主角实例由
`GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 走 §0.3 的合流创建;
`GameWindow.Open()``game.py:137`)在 `PHASE_GAME` 才 `Show()`。
**当前**`game_scene.gd:136` 先建 Capsule`:811` 再换真模型。
**修改**
1. 删除 `game_scene.gd:136` 与 `:811` 的 Capsule 占位路径。
2. 主角节点只在收到 `Spawn(main_vid)`(合流后)时创建一次,位置/朝向直接取包内值。
3. 在此之前保持 `introloading` 等价的加载幕布(`POC/project/ui` 下已有 `_loading` 层)覆盖,
与 `GameWindow.Open()` 的 `Show()` 时机对齐(§2.6 第 2 步)。
4. 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。
---
### 6.2 SpeedTree 仍是程序化代理
**参考**`REF/SpeedTreeLib/``SpeedTreeWrapper.{h,cpp}` / `SpeedTreeForest*.cpp` /
`SpeedTreeMaterial.h` / `SpeedGrass*`),核心 API
```
LoadTree(pszSptFile, pbBlock, uiBlockSize, nSeed=1, fSize=-1, fSizeVariance=-1) // .spt
RenderBranches() / RenderFronds() / RenderLeaves() / RenderBillboards() // 四类几何
GetLeafMaterial() / GetLeafLightingAdjustment()
SetWindStrength(float)
```
地图侧:`CArea::__SetObjectInstance_SetTree` → `prt::PROPERTY_TYPE_TREE`
属性 `TPropertyTree{strName, strFileName, fSize, fVariance}``REF/GameLib/MapType.h:47`);
风强度来自 `TEnvironmentData.fWindStrength / fWindRandom``MapType.h:154`)。
**当前**`POC/extension/src/tree_placeholder.cpp:190` 只嗅探贴图 + proxy 几何。
**修改**
1. 这是**独立的大工作量项**:需要实现 `.spt` 解析器(SpeedTree 5 二进制),产出
branches / fronds / leaves / billboards 四组几何与各自材质。
2. 在 `.spt` 解析落地之前,`tree_placeholder.cpp` 应至少:
- 读 `TPropertyTree` 的 `fSize` / `fVariance` 并按 `nSeed` 做确定性缩放(当前缺);
- 把风强度接到 `TEnvironmentData.fWindStrength / fWindRandom``.msenv`,§7.5)。
3. 在 `PARITY-GAP.md:77` 标注为「参考模块未移植」,不要按截图反推参数。
---
### 6.3 EffectLib 的 `.mde` 已接入,仍是渲染子集
**参考**`CEffectData``REF/EffectLib/EffectData.h:9`)一个 `.mse` 由**三类元素 + 声音**组成:
```
TParticleVector m_ParticleVector; // CParticleSystemData —— 粒子系统
TMeshVector m_MeshVector; // CEffectMeshScript —— .mde 网格特效
TLightVector m_LightVector; // CLightData —— SimpleLight
NSound::TSoundInstanceVector m_SoundInstanceVector;
float m_fBoundingSphereRadius; D3DXVECTOR3 m_v3BoundingSpherePosition;
```
- `CLightData``REF/EffectLib/SimpleLightData.h`):
`m_fMaxRange / m_fDuration / m_cAmbient / m_cDiffuse / m_bLoopFlag /
m_fAttenuation0 / m_fAttenuation1 / m_fAttenuation2``GetRange(fTime, &range)` 是时间曲线。
- `CParticleProperty``REF/EffectLib/ParticleProperty.h:12`):
`ERotationType{NONE, TIME_EVENT, CW, CCW, RANDOM_DIRECTION}`、
`ETextureAnimationType{NONE, CW, CCW, RANDOM_FRAME, RANDOM_DIRECTION}`
多贴图(`InsertTexture`+ `GetTextureAnimationFrameCount()`。
- `CEmitterProperty``REF/EffectLib/EmitterProperty.h:9`):
`EmitterShape{POINT, ELLIPSE, SQUARE, SPHERE}`、
`EmitterAdvancedType{FREE, OUTER, INNER}`、`isEmitFromEdge()`、
`GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount`
以及一组时间曲线 `GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)`。
**当前(增量 100**Particle 已映射到 `GPUParticles3D`,首个 `.dds` 通过 `UiAssets` + 原生
DDS 解码加载;`POC/project/fx/mde.gd` 按 `EffectMesh.cpp` 读取 `EffectData` v1 / `MDEData002`
v2,展开索引三角形、翻转 UV、厘米转米;`effect_player.gd` 已构建 `ArrayMesh`,播放几何帧,
并接入 `.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;增量 65
又按 `SimpleLightData.cpp` / `SimpleLightInstance.cpp` 接入 Light 组的范围曲线、生命周期、循环
和位置更新,并为粒子多贴图接入帧延迟 / 顺逆序、旋转类型 `NONE/TIME_EVENT/CW/CCW`。增量 66
再按 `EffectElementBase.cpp:5-60` 接入直接段和二次 Bezier 位置曲线,让粒子、网格和 Light
共用同一边界钳制 / 插值逻辑;同时接入 `EmitterEmitFromEdgeFlag` 的球体边缘发射映射,并保存
`EmitterAdvancedType` 元数据。增量 100 对齐缺省 `CycleLength=0.05`,将有限 cycle / LoopCount
转为 CPU 发射停止门控,重播时复位粒子时钟,并保持独立的粒子寿命;OUTER / INNER 当前使用明确
标记的 `spread=180` 宽方向 fallback,不能当成参考端按粒子位置计算的精确径向速度。
**修改**
1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把
`effect_player.gd:134` 的程序化渐变换掉。
2. `.mde` 网格特效的基础读取和帧播放已完成;后续仍需把 `CEffectMeshScript` 的全部纹理帧 / 活跃
状态语义逐项对齐,并补真实资源视觉验收。当前实现见 `POC/project/fx/mde.gd`、
`POC/project/fx/effect_player.gd`。
3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`
`range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。
增量 65 已完成这一可离线验证子集;仍需真实效果资源逐项校准和完整节点销毁 / 重建验收。
4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 /
emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。
增量 65 已完成基础多贴图帧延迟 / 顺逆序和旋转类型;增量 66 已完成 MovingType 直接 /
Bezier 段、球体 edge 映射和 advanced type 元数据;增量 100 已补齐缺省 cycle、有限 LoopCount
发射停止、重播复位和粒子寿命隔离;随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 精确
径向速度、每周期 emission residue / 时间曲线重置仍待补齐。
5. 骨骼挂点:`.mse` 挂到角色骨骼由 `CActorInstance::AttachEffect`
**[待读参考:`REF/GameLib/ActorInstanceAttach.cpp`]** 驱动,接到本工程的 bone attach。
---
### 6.4 GR2 运行时不是完整 ActorInstance
**参考**
- 动作模式枚举 `CRaceMotionData::EMotionMode``REF/GameLib/RaceMotionData.h:33`):
```
MODE_RESERVED, MODE_GENERAL, MODE_ONEHAND_SWORD, MODE_TWOHAND_SWORD,
MODE_DUALHAND_SWORD, MODE_BOW, MODE_FAN, MODE_BELL, MODE_FISHING,
MODE_HORSE, MODE_HORSE_ONEHAND_SWORD, MODE_HORSE_TWOHAND_SWORD,
MODE_HORSE_DUALHAND_SWORD, MODE_HORSE_BOW, MODE_HORSE_FAN, MODE_HORSE_BELL,
MODE_WEDDING_DRESS, MODE_MAX_NUM
```
- 选择逻辑 `CInstanceBase::RefreshState``REF/UserInterface/InstanceBase.cpp:2851`),
**顺序即优先级**
```
IsPoly() → MODE_GENERAL
IsWearingDress() → MODE_WEDDING_DRESS
IsHoldingPickAxe() → 骑马 ? MODE_HORSE : MODE_GENERAL
ITEM_TYPE_ROD → 骑马 ? MODE_HORSE : MODE_FISHING
骑马: WEAPON_SWORD→HORSE_ONEHAND_SWORD, TWO_HANDED→HORSE_TWOHAND_SWORD,
DAGGER→HORSE_DUALHAND_SWORD, FAN→HORSE_FAN, BELL→HORSE_BELL,
BOW→HORSE_BOW, default→MODE_HORSE
徒步: WEAPON_SWORD→ONEHAND_SWORD, TWO_HANDED→TWOHAND_SWORD,
DAGGER→DUALHAND_SWORD, BOW→MODE_BOW, FAN→MODE_FAN, BELL→MODE_BELL,
WEAPON_ARROW/default→MODE_GENERAL
最后: isLoop ? InterceptLoopMotion(idx) : InterceptOnceMotion(idx); RefreshActorInstance();
```
`SetMotionMode(m)``REF/GameLib/ActorInstanceMotion.cpp:247`):非法值回落 `MODE_GENERAL`。
- 动作查表:`m_pkCurRaceData->GetMotionKey(m_wcurMotionMode, dwMotion, &dwMotionKey)`
`ActorInstanceMotion.cpp:306/345/404`)——**动作号必须带 motion mode 一起查**。
- 技能动作:`CInstanceBase::NEW_UseSkill(uSkill, uMot, uMotLoopCount, isMovingSkill)`
`InstanceBaseBattle.cpp:310`
```
IsDead/IsStun/IsKnockDown → false
isMovingSkill ? (未走则 StartWalking, m_isGoing=TRUE) : (在走则 EndWalking, m_isGoing=FALSE)
SetAdvancingRotation(GetTargetRotation())
InterceptOnceMotion(CRaceMotionData::NAME_SKILL + uMot, 0.1f, uSkill, 1.0f)
__OnUseSkill(uMot, uMotLoopCount, isMovingSkill)
uMotLoopCount > 0 → SetMotionLoopCount(uMotLoopCount)
```
- 动作事件类型 `EMotionEventType``RaceMotionData.h:160`):
`NONE, EFFECT, SCREEN_WAVING, SCREEN_FLASHING, SPECIAL_ATTACKING, SOUND, FLY,
CHARACTER_SHOW, CHARACTER_HIDE, WARP, EFFECT_TO_TARGET`。
- 动作数据接口(`RaceMotionData.h:216`):
`GetMotionDuration / IsAccumulationMotion / GetAccumulationPosition /
IsComboInputTimeData / GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime /
isAttackingMotion / GetMotionAttackDataPointer / HasSplashMotionEvent /
IsCancelEnableSkill / IsLoopMotion / GetLoopStartTime / GetLoopEndTime /
GetMotionEventDataCount / GetEventType / GetEventStartTime / GetSoundInstanceVectorPointer`。
**当前**:无 motion mode 概念;loop / interrupt / accumulation / 动作保留未实现。
**修改**
1. `metin2_model` / `metin2_anim` 引入 `motion_mode``.msm` 解析出
「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表
`CRaceData::TMotionModeDataMap` / `TMotionVectorMap``REF/GameLib/RaceData.h:50`)。
2. 实现 `refresh_state()`:**逐行**照抄上面的优先级链,输入是
`is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting`。
3. 播放接口对齐三种:`InterceptLoopMotion` / `InterceptOnceMotion` / `SetLoopMotion`
带 `fBlendTime`(技能固定 `0.1f`)与 `fSpeedRatio`。
4. `accumulation motion`(位移由动作数据驱动)与 `SetMotionLoopCount` 补上。
5. 击退 / 倒地 / 起身 / 死亡动作:按 `NAME_*` 动作名枚举(`RaceMotionData.h:40-160`)接入。
6. 武器轨迹 `CWeaponTrace``REF/GameLib/WeaponTrace.{h,cpp}`)单列一项。
---
### 6.5 武器和特效骨骼挂点不完整
**参考**`CRaceData::EParts``REF/GameLib/RaceData.h:16`)只有 5 个:
```
PART_MAIN, PART_WEAPON, PART_HEAD, PART_WEAPON_LEFT, PART_HAIR, PART_MAX_NUM
```
挂点名表 `TAttachingBoneNameMap``RaceData.h:35`)与
`TModelData{ NRaceData::TAttachingDataVector AttachingDataVector; }``:63`)来自 `.msm`。
挂载实现在 `REF/GameLib/ActorInstanceAttach.cpp`。
**修改**
1. `PART_WEAPON_LEFT` 是参考里明确存在的部件——补左手武器 / 双持挂点
(本工程当前只有右手 + 部分盾牌)。
2. 挂点骨骼名从 `.msm` 的 `TAttachingBoneNameMap` 读,不再硬编码
(本工程当前硬编码 `equip_right_hand` 等)。
3. 坐骑挂点、骨骼特效(`EFFECT_TO_TARGET`)按 `ActorInstanceAttach.cpp` 移植。
4. 装备切换时清理旧挂点:参考在 `ChangeWeapon` 里 detach 后再 attach
**[待读参考:`InstanceBase.cpp` 的 `ChangeWeapon` / `__AttachWeapon`]**。
---
### 6.6 材质是固定功能近似
**参考**:材质开关来自 `.gr2` 的 `MaterialInfo`(本工程增量 30 已接 `alpha_blend / two_sided`);
多贴图 stage / color operation 由 `CGraphicThing` 的渲染路径决定
**[待读参考:`REF/EterGrnLib/Thing.cpp` 与 `Material.cpp` 的 stage 设置]**。
球面高光来自 `item_proto` 的 `bSpecular`(本工程已接 `specular/100`)。
**修改**
1. `m2_material.h:39` 的 `spec_power` 默认 0 保持——参考端确实由装备数据驱动;
缺的是**远端角色**也要按其 `armor/weapon` 的 proto 取 specular(当前只有本地主角)。
2. alpha-test ref 值、贴图 stage 数、color op 三项在 `EterGrnLib` 的渲染端读出后再定,
目前不要猜数值。
3. 透明排序:参考按 `CArea` 的 `m_ThingCloneInstaceVector` 与角色分批渲染;
Godot 侧对应 `render_priority` + `transparency` 的固定分层,先在文档记录映射再实现。
---
## 7. P1/P2:地图、环境和相机
### 7.1 静态地图只完整接入 Building / DungeonBlock
**参考**`CArea``REF/GameLib/Area.{h,cpp}`)的对象只有 **5 种**
`prt::PROPERTY_TYPE_*``Area.cpp:398-446`):
| 类型 | 属性结构(`REF/GameLib/MapType.h` | 运行时 | 参考行 |
|---|---|---|---|
| `TREE` | `TPropertyTree{strName, strFileName, fSize, fVariance}` (:47) | `CSpeedTreeWrapper` | `Area.cpp:590` |
| `BUILDING` | `TPropertyBuilding{strName, strFileName, strAttributeDataFileName, isShadowFlag}` (:56) | `CGraphicThingInstance` + 属性/碰撞/高度 | `Area.cpp:615` |
| `EFFECT` | `TPropertyEffect{strName, strFileName}` (:64) | `CEffectManager` 实例 | `Area.cpp:531` |
| `AMBIENCE` | `TPropertyAmbience{strName, strPlayType, fPlayInterval, fPlayIntervalVariation, fMaxVolumeAreaPercentage, AmbienceSoundVector}` (:85) | `TAmbienceInstance` | `Area.cpp:661` |
| `DUNGEON_BLOCK` | `TPropertyDungeonBlock{strName, strFileName, strAttributeDataFileName}` (:96) | `CDungeonBlock` | `Area.cpp:695` |
对象静态数据 `CArea::TObjectData``Area.h:27`):
`Position(D3DXVECTOR3) / dwCRC / abyPortalID[PORTAL_ID_MAX_NUM] /
m_fYaw / m_fPitch / m_fRoll / m_fHeightBias / dwRange / fMaxVolumeAreaPercentage`。
旋转步进常量(`Area.h:13`):`ROTATION_STEP_COUNT = 24`、`ROTATION_STEP_AMOUNT = 15`
`YAW/PITCH` 同。
Ambience 的三种播放型(`Area.cpp:678`):
`"ONCE" → UpdateOnceSound`、`"STEP" → UpdateStepSound`、`"LOOP" → UpdateLoopSound`
位置 `z = Position.z + m_fHeightBias`,作用半径 `dwRange`。
`CArea::Refresh()``Area.cpp:383`)把对象分桶到
`m_TreeCloneInstaceVector / m_ThingCloneInstaceVector / m_AniThingCloneInstanceVector /
m_ShadowThingCloneInstaceVector / m_AmbienceCloneInstanceVector /
m_DungeonBlockCloneInstanceVector``IsMotionThing()` 的 building 会额外进动画桶并
`SetMotion(0)``isShadowFlag` 决定是否进阴影桶。
**当前**`POC/extension/src/metin2_world.cpp:367` 只处理 Building / DungeonBlock。
**修改**
1. 补 `PROPERTY_TYPE_EFFECT`:按 `TPropertyEffect.strFileName` 注册 `.mse` 并在
对象位置创建常驻特效实例(旋转按 `Yaw/Pitch/Roll` 的 `D3DXMatrixRotationYawPitchRoll`)。
2. 补 `PROPERTY_TYPE_AMBIENCE`:三种播放型 + `dwRange` + `fMaxVolumeAreaPercentage`
接 §9.4 的 ambience 声道。
3. Building 补 `isShadowFlag` 与 `IsMotionThing() → SetMotion(0)` 两条分桶规则。
4. `m_fHeightBias` 目前是否参与位置计算需核对;`abyPortalID` / portal / area 关系
**[待读参考:`REF/GameLib/Area.cpp` 中 `abyPortalID` 的消费点]**。
5. 对象 LOD 与透明排序按 `Refresh()` 的分桶来做,不要自定义分层。
---
### 7.2 Pack 资源读取没有统一
**参考**`CEterPackManager``REF/EterPack/`)是唯一入口,
连 `CAccountConnector::__AuthState_RecvAuthSuccess``AccountConnector.cpp:465`)都要
`CEterPackManager::instance().DecryptPackIV(dwPanamaKey)`——**pack 的解密 IV 来自登录密钥**。
**当前**`metin2_world.cpp:380`、`terrain_splat.cpp:133` 等仍直接
`AssetResolver::resolve()`,绕过 `AssetSource`/`PackMount`。
**修改**
1. 全量替换:`grep -rn "AssetResolver::resolve" POC/extension/src` 逐个改为
`AssetSource::open()``asset_io.cpp` 已经把 gr2/dds/map/proto 路由到 `FileAccess`
把 world / terrain / tree / water / static object 补齐。
2. `AssetResolver` 的目录扫描(Phase 2b)改为 `AssetSource` 的统一枚举接口。
3. 新增 CI 门禁:`MT_ASSETS_PACK_ONLY=1` 时禁用散文件回退,跑一遍现有全部 GDScript 测试。
4. `DecryptPackIV(loginKey ^ clientKey[0..3])` 这条参考行为若目标服务端使用,需要在
`ClassicSession` 收到 `GC_AUTH_SUCCESS` 后同样调用——当前未实现,先记录。
---
### 7.3 地形 splat 仍非完整固定功能复现
**参考**`REF/GameLib/MapOutdoor*.cpp``MapOutdoorRenderSTP.cpp` = 单贴图通道、
`MapOutdoorRenderHTP.cpp` = 高贴图通道、`MapOutdoorIndexBuffer.cpp` = patch 索引、
`MapOutdoorQuadtree.cpp` = LOD/剔除、`TerrainPatch.{h,cpp}`、`TerrainQuadtree.{h,cpp}`)。
Section/Cell 常量(`REF/GameLib/MapType.h:216`):
```
c_Section_xAttributeCellSize = 50 c_Section_yAttributeCellSize = 50
c_Section_xCellSize = 100 c_Section_yCellSize = 100
```
**修改**
1. 本条是**渲染端逐项对照**,应在 `PARITY-GAP.md` 建立子清单,每项列
「参考文件:函数 / 当前实现 / 差异」,包括:
alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、
patch 碰撞、baked shadow mask 叠加。
2. 在读完 `MapOutdoorRenderSTP.cpp` / `MapOutdoorRenderHTP.cpp` 之前不要调数值——
本文档不给具体参数。
3. 先落地的是**常量对齐**:把上面 4 个 Section/Cell 常量核对进
`POC/extension/src/terrain_splat.cpp`。
---
### 7.4 水面是序列纹理加近似深度
**参考**`REF/GameLib/MapOutdoorWater.cpp`。
**修改**
1. 同 §7.3:先读 `MapOutdoorWater.cpp` 建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 /
blend·filter·wrap / 透明排序 / 水深标定),再改实现。
2. `PARITY-GAP.md` 里「浅水透明度未校准」的标记保留,直到参考端的水深公式读出为止。
---
### 7.5 `.msenv` 已完成解析与静态云层,完整环境渲染仍缺
**参考**`TEnvironmentData``REF/GameLib/MapType.h:126`)的**完整字段表**
```
// 光照
BOOL bDirLightsEnable[2]; // ENV_DIRLIGHT_BACKGROUND, ENV_DIRLIGHT_CHARACTER
D3DLIGHT8 DirLights[2]; // 背景光 / 角色光 分开!
D3DMATERIAL8 Material;
// 雾
BOOL bFogEnable; BOOL bDensityFog;
float m_fFogNearDistance; float m_fFogFarDistance;
D3DXCOLOR FogColor;
// 滤色
BOOL bFilteringEnable; D3DXCOLOR FilteringColor;
BYTE byFilteringAlphaSrc; BYTE byFilteringAlphaDest;
// 风
float fWindStrength; float fWindRandom;
// 天空盒
D3DXVECTOR3 v3SkyBoxScale; BOOL bSkyBoxTextureRenderMode;
BYTE bySkyBoxGradientLevelUpper; BYTE bySkyBoxGradientLevelLower;
std::string strSkyBoxFaceFileName[6]; // front/back/left/right/top/bottom
std::vector<TGradientColor> SkyBoxGradientColorVector;
// 云
D3DXVECTOR2 v2CloudScale; float fCloudHeight;
D3DXVECTOR2 v2CloudTextureScale; D3DXVECTOR2 v2CloudSpeed;
std::string strCloudTextureFileName; TGradientColor CloudGradientColor;
// 镜头光晕
BOOL bLensFlareEnable; D3DXCOLOR LensFlareBrightnessColor; float fLensFlareMaxBrightness;
BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFlareSize;
```
**关键差异**:参考端**背景光和角色光是两盏独立方向光**(`ENV_DIRLIGHT_BACKGROUND` /
`ENV_DIRLIGHT_CHARACTER`);本工程已在增量 69 用两个 `DirectionalLight3D` 和 layer/cull mask
分开作用于静态场景与角色。剩余差异集中在环境动态和 Godot 无直接等价的后处理。
**当前(增量 72**`formats/environment.*` 已按 `MapUtil.cpp:1-205` 读取参考缺省值、
`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`
`environment_builder.cpp` 通过 `AssetResolver` 解析实际环境资源,并把真实云层贴图接入渐变天空的
`ProceduralSkyMaterial.sky_cover`,或接入 texture-mode sky shader 的有限云面;真实六面天空由
`sky` shader 按参考面顺序和 UV 渲染,空面回退渐变。双方向光已按 layer 1/2 分层,原始云层
scale / height / texture scale / speed 已映射到 shader,滤色 blend 因子、lens flare 等仍保存在
`WorldEnvironment` 元数据;同时
修正了 macOS 大小写敏感环境文件路径;树 proxy 使用参考风缺省 `0.2/0.0`,但尚未等价实现
SpeedTree 森林级风常量。`formats_map_test`(内联 + 真实 A1)、`environment_test`、`skybox_test`
和 `world_probe` 已通过。
**修改**
1. `formats/environment.*` 负责逐字段解析参考格式并保存原始值;当前已覆盖上表中的参考缺省值、
`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`。注意参考
`MapUtil.cpp` 的 `Environment_Load` 没有读取风参数 token`fWindStrength=.2`、`fWindRandom=0`
是缺省值,不能凭经验添加不存在的键;`environment_builder.cpp` 目前只落实 Godot 能等价承载的
基础环境、静态云层、texture-mode 六面天空和云层组合首版,其余值通过元数据保留。
2. **已完成(增量 69)**:两盏方向光分别作用于「地形 / 水面 / 建筑 / 树」与「角色 / 装备 /
LOD」层——Godot 侧用 `Sun` / `CharacterLight` 两个 `DirectionalLight3D` + `cull_mask`
静态几何固定 layer 1`Metin2Model` 几何固定 layer 2,换图时清理 `CharacterLight`。
3. **已完成(增量 70**`bTextureRenderMode` 时,六面贴图按
`SkyBox.cpp` 的 front/back/left/right/top/bottom 顺序接入 `ShaderMaterial`;缺少的面使用
渐变回退,避免黑面。当前 `CapeDragonHead.msenv` 的五个非空面已由 `skybox_test.gd` 验证。
4. **已完成首版(增量 68 / 72**:无 texture mode 的环境用
`strCloudTextureFileName` 接入 `ProceduralSkyMaterial.sky_cover`texture mode 的环境用 sky
shader 接入有限云面,并按参考实现映射 `v2CloudScale`、`fCloudHeight`、
`v2CloudTextureScale`、`v2CloudSpeed` 和 `CloudGradientColor` 的颜色合成。真实 DawnMistWood
已验证五面天空、云层、UV 滚动参数和 shader 安装。云层渐变过渡、滤色、树 proxy 之外的
SpeedTree 森林级 shader 常量 / `fWindRandom` 语义仍待实现;不能把首版 shader 映射宣称为
参考端完整视觉等价。
5. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness /
bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。
6. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)原始值已解析并保存在元数据;仍需补上
Godot 端全屏后处理。注意参考端该版本 `CScreenFilter::SetEnable` 实际把 enable 置为 FALSE
因此必须先按 `MapOutdoor.cpp` 的真实调用语义验收,不能仅凭 `Filter.Enable` 打开效果。
7. 昼夜插值:参考按 `TEnvironmentDataMap``MapType.h:187``DWORD → TEnvironmentData*`
在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。
8. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。
---
### 7.6 相机不是固定基线机位
**参考**
- 默认机位(`POC/assets/root/game.py:58``GameWindow.Open()` 时
`app.SetCamera(cameraDistance, cameraPitch, cameraRotation, cameraHeight)`):
```python
cameraDistance = 1550.0 cameraPitch = 27.0
cameraRotation = 0.0 cameraHeight = 100.0
```
关窗时 `(cameraDistance, cameraPitch, cameraRotation, cameraHeight) = app.GetCamera()`
存回全局(`game.py:225`)——即**跨进出图保持**。
- 距离范围(`REF/UserInterface/CameraProcedure.cpp:10`):
```cpp
float CCamera::CAMERA_MIN_DISTANCE = 200.0f;
float CCamera::CAMERA_MAX_DISTANCE = 2500.0f;
```
- 相机模式(`REF/UserInterface/PythonApplicationCamera.cpp:23/48/57`):
`CAMERA_MODE_NORMAL`(跟随主角 + `GetTargetHeight()`)、
`CAMERA_MODE_STAND`(固定点)、`CAMERA_MODE_BLEND`(线性插值 zoom/pitch/rotation
`BlendValueByLinear(elapsed, blendTime, from, to)`)。
`SetDefaultCamera()``:295`)回 NORMAL 并恢复 `m_DefaultCameraSetting`。
- 地形碰撞(`CameraProcedure.cpp:13 ProcessTerrainCollision`):
两条射线 `m_kTargetToCameraBottomRay` 与 `m_kCameraBottomToTerrainRay`
命中即 `SetCameraState(CAMERA_STATE_CANTGODOWN)` 并把 eye 抬到
`碰撞点 + 2.0f * m_fTerrainCollisionRadius * m_v3Up`(只在抬高时才 `SetEye`)。
未命中则 `SetCameraState(CAMERA_STATE_NORMAL)`。
- 建筑碰撞(`:125 ProcessBuildingCollision`):
`fMoveAmountSmall = 2.0f` / `fMoveAmountLarge = 4.0f`,用
`CameraCollisionChecker` + `CollisionDynamicSphere` 逐对象判定。
- 键位(`POC/assets/root/game.py:340`):
`Q/E → RotateCamera(NEGATIVE/POSITIVE)`、`R → ZoomCamera(NEGATIVE)`、
`F → ZoomCamera(POSITIVE)`Ctrl+F 是喂马)、`T/G → PitchCamera(NEGATIVE/POSITIVE)`
松键一律 `CAMERA_STOP`;小键盘 4/6/8/2 + PGUP/PGDN 是 Movie 相机。
**当前**`POC/project/game_camera.gd` 用脚本内 FOV/yaw/pitch/距离 + AABB 射线。
**修改**
1. 默认值改为 `distance 1550 / pitch 27 / rotation 0 / height 100`
夹在 `[CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]`。
(§8.4 的「远/近视角」两个 radio 也要落在这个范围内。)
2. 实现三种模式 `NORMAL / STAND / BLEND``BLEND` 用线性插值三参数;
`SetDefaultCamera()` 恢复默认设置——这三者是 §5.1 的
`SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA` 标签的落点。
3. 地形碰撞按上面两条射线 + `2 * m_fTerrainCollisionRadius` 的抬高规则重写,
替换现有 AABB 射线。
4. 建筑碰撞用 `2.0f / 4.0f` 两个位移量的球体检测。
5. 相机参数跨进出图保持(`game.py:225` 的全局回写语义)。
6. 键位按上表补齐 Q/E/R/F/T/G 与松键停止。
---
## 8. P1/P2UI、输入和本地化
### 8.1 uiscript 可解析不等于控件语义完成
**参考**:控件实现在 `REF/EterPythonLib/``PythonWindow*.cpp`、`PythonGraphic*.cpp`
与 `POC/assets/root/ui.py`(本仓已有)。`ui.py` 是权威的控件语义定义。
**修改**
1. 逐个补齐控件,顺序按使用频度:
`ani_image` / `mark` / `grid_table` / `list` / `scroll` / `slotbar` / `thinboard` /
`expanded_image`(nine-patch) / `titlebar` / `radio_button` / `toggle_button`。
2. 每个控件的属性名、默认值、事件名以 `ui.py` 的类定义为准,不自行命名。
3. `SetTop / OnTop / focus / modal / layer` 的语义取 `ui.py` 的
`Window.SetTop / wndMgr` 相关调用。
4. nine-patch`ui.py` 的 `ExpandedImageBox` + `.sub` 资源的 rect 定义。
---
### 8.2 大量窗口是手工 Panel
**参考**`Interface.MakeInterface()``POC/assets/root/interfacemodule.py`)逐个
`LoadDialog()`,每个窗口用 `ui.PythonScriptLoader().LoadScriptFile(self, "UIScript/xxx.py")`。
**修改**
1. 沿用本工程已验证的迁移样板(`char_status_ui.gd` / `select_item_ui.gd` /
`system_option_ui.gd` / `private_shop_ui.gd` / `party_ui.gd` / `system_menu_ui.gd` /
`game_option_ui.gd`):`UiScript` + `UiBuild` 装真 uiscript + 逐字绑定同名 `ui*.py`。
2. 待迁清单(按 `interfacemodule.py` 的 `MakeInterface` 顺序):
`exchangedialog.py`(§4.5)、`safeboxwindow.py`(§4.4)、`shopdialog.py`(§4.6)、
`guilddialog.py`、`questdialog.py`(§5.2)、`messengerwindow.py`、`chatwindow.py`、
`helpwindow.py`(§8.3)、`inventorywindow.py`、`equipmentdialog.py`、
`tooltip`(§4.3,非 uiscript)。
3. 每迁一个窗口配一个 `*_ui_test.gd`,覆盖「装载 + 关键绑定 + 原聊天命令/封包」。
4. 分辨率缩放与 `.sub` 资源、字体描边按 `ui.py` 的实现,不自定义。
---
### 8.3 帮助窗口未实现
**状态**:🟡 开发中(首版落地,真服 / 视觉未验收)—— 批次 2026-09-02 / W3。
**参考**`assets/root/uihelp.py``HelpWindow`,单页版 `ENABLE_HELP_MULTIPAGE = 0` →
`LoadDialogSinglePage()` 装 `UIScript/HelpWindow.py`+ uiscript
`assets/uiscript/uiscript/helpwindow.py`19 条 `help_XX` 浮动文本 + `taskbar_help_*`
贴纸标签 + `close_button`,文本取自 `uiScriptLocale.HELP_*`)。
入口:`game.py __PressHKey()` —— 非 Ctrl 时 `self.interface.OpenHelpWindow()`
Ctrl+H 是 `/user_horse_ride`)。
> 注:本仓 `uihelp.py` 并非“136 行含键位/说明行”的版本;帮助行文本实际在
> `helpwindow.py` uiscript 里以 `uiScriptLocale.HELP_*` 引用,逐字值在
> `locale/locale/<lang>/locale_interface.txt`。故“逐字对齐 `uihelp.py`”按“逐字对齐
> `helpwindow.py` + `uiScriptLocale.HELP_*`(英文表)”落实。
**当前**`system_menu_ui.gd` 的 `help_button` 已由占位提示改为打开 `help_ui.gd`。
**改动**
- 新增 `project/ui/help_ui.gd``ui.open_script("helpwindow.py")`UiScript → UiBuild
非模态开窗;`_relabel()` 把 `helpwindow.py` 解析出的 `<HELP_*>` / `<CLOSE>` 占位
`UiManager` 未接 locale 回调时产生)用本工程 `Locale``en`)表 + 逐字英文兜底补回,
覆盖原脚本重名的两个 `help_02`(右键 / 中键相机说明);`open/close/toggle/is_open`、
`close_button` → `close`、ESC 由 `UiManager` 窗口栈统一关顶层。
- `project/ui/system_menu_ui.gd``help_button` → `close()` + `open_help()`;新增
`const HelpUI`、可注入的 `help_ui` 成员、`_ensure_help()`(懒建,复用菜单的
`ui/client/assets_root`)、`open_help()`、`toggle_help()``setup()` 追加可选第 6 参
`help_win`(旧 5 参调用不受影响)。
- `project/game_scene.gd``_unhandled_input` 加**单条** `elif` —— `KEY_H` 且
`not ctrl_pressed and not meta_pressed` → `system_menu_ui.toggle_help()``Ctrl+H`
未占用,仍留给 `/user_horse_ride`。
- 接口 / 数据结构变化:仅 `SystemMenuUI.setup()` 追加可选尾参;无网络 / EntityStore 改动。
**测试**
- 新增 `project/help_ui_test.gd`headless SceneTree):helpwindow.py 装载、19 条帮助行
逐字绑定且非 `<KEY>` 占位、任务栏标签、`close_button` 文本 = `Close`、`rows()` 便捷器、
`close_button` 关窗、`open/toggle`、ESC 栈顶、`SystemMenuUI.open_help()` 懒建并打开。
- `godot --headless --path project --script res://help_ui_test.gd` → `PASS`exit 0)。
- 回归:`system_menu_ui_test`、`gamescene_test`、`ui_test` 全 `PASS`;附加
`system_option_ui_test`、`char_status_ui_test` 亦 `PASS`。
- skip:本 worktree 无 `build/`,未跑 C++ `ctest`(本条无 C++ 改动)。
**验收**
- 真服 / 双账号 / Mac App:未验证(§8.3 为纯本地 UI,无服务端依赖)。
- 视觉:`helpwindow.py` 的 `.sub` / `help_stick.tga` 贴图未逐像素比对;字体描边
`"outline": 1`)按 `ui.py` 默认,未单独标定。
---
### 8.4 系统设置部分只是保存数值
**参考**`POC/assets/root/uisystemoption.py`(本工程增量 36 已按其绑定)。
其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。
**修改**
1. **CPU/GPU tiling**:参考行为是切换渲染路径,Godot 渲染器没有等价物。
保留控件但明确标注为「参考功能无 Godot 对应」,并在 `CLIENT-GAP.md` 记为
❌(超出移植范围),不要伪造行为。
2. **BGM 更换按钮**:参考对应 `uiselectmusic.py`(本仓已有)——
按 §8.2 迁移该窗口后启用按钮,接 `musicInfo.SaveLastPlayFieldMusic` /
`LoadLastPlayFieldMusic``POC/assets/root/musicinfo.py`)与
`game.py:2217 __PlayMusic(flag, filename)`。
3. **名字颜色 / 伤害数字 / 总是显示名字**:接到 §8.8 的 TextTail 渲染,
开关键名沿用现有 `user://system_option.cfg [gameopt]`。名字颜色本身在增量 110
已按 `GetNameColorIndex` 1:1 落地(`name_color.gd`);`constinfo.SET_CHRNAME_COLOR_INDEX`
对应的运行期开关是 `net_world.set_empire_name_mode(bool)`(默认关),尚未挂到选项 UI。
**增量 113 已完成「总是显示名字」1:1**:`name_show.gd``CPythonSystem::IsAlwaysShowName` /
`SetAlwaysShowNameFlag` 的 `iFlag == 1` 语义 + `game.py::__IsShowName` = `IsAlwaysShowName()
or ShowNameFlag(LALT)` + `CPythonTextTail::ShowAllTextTail` 的 `fDistanceFromPlayer < 3500.0f`
平面距裁剪 + `ShowCharacterTextTail` 的 `isShow / !IsGuildWall / CanPickInstance` 资格判定 +
`UpdateAllTextTail` 的 chat 尾标 `bNameFlag` 强显)。`game_option_ui.gd` 的
`always_show_name` radio 现经新信号 `display_option_changed` 驱动
`net_world.set_always_show_name()``net_play.target_changed` 驱动
`net_world.set_target_vid()`(选中目标始终强显名字)。
**增量 114 已接「伤害数字」radio** → `net_world.set_show_damage()`(见 §8.8)。
**增量 118 已接「名字色」radio(`name_color_normal` / `name_color_empire`** →
`game_scene._on_display_option("name_color", v)` → `net_world.set_empire_name_mode(v == 1)`
`uigameoption.__SetNameColorMode` → `constInfo.SET_CHRNAME_COLOR_INDEX` →
`chrmgr.SetEmpireNameMode`;算法本体增量 110 已 1:1),开局用
`display_value("name_color") == 1` 回填。
**增量 118 同时接「看他国玩家目标框」radio(`target_board_view` / `target_board_no_view`** →
`net_play.set_view_other_empire_target(v == 1)`;见 §5.4 / §8.8。§8.4 三个显示 radio
+ LALT 至此全部接线完毕。见 C.5 增量 113 / 114 / 118、§8.6、§8.8。
4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是
`TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)——
改为按环境数据的近远距离设置,浓/中/淡对应三档缩放
**[待读参考:`uisystemoption.py` 的 `__SetFogLevel` 实参]**。
---
### 8.5 鼠标物品态和光标状态缺失
**参考**:光标枚举 `CPythonApplication::ECursorShapes`
`REF/UserInterface/PythonApplication.h:63`):
```
NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, BUY, SELL,
CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, COUNT
```
资源(`REF/UserInterface/Cursors/`Python 侧映射见 `mousemodule.py:100`):
```
NORMAL→cursor.sub ATTACK/TARGET→cursor_attack.sub TALK→cursor_talk.sub
CANT_GO→cursor_no.sub PICK→cursor_pick.sub DOOR→cursor_door.sub
CHAIR/MAGIC→cursor_chair.sub BUY→cursor_buy.sub SELL→cursor_sell.sub
CAMERA_ROTATE→cursor_camera_rotate.sub
HSIZE→cursor_hsize.sub VSIZE→cursor_vsize.sub HVSIZE→cursor_hvsize.sub
```
软件光标开关:`systemSetting.IsSoftwareCursor()``mousemodule.py:98`)。
`SetCursorNum` / `SetCursor` 的调用点:商店 BUY/SELL(§4.6)、
NPC 悬停 TALK、可攻击目标 ATTACK 等。
**当前(增量 56**:新增 `cursor_manager.gd`,使用参考端的状态名,并把世界实体 / 地面物品
的首版悬停状态接入 `PlayerController``UiManager` 的窗口键盘焦点屏蔽已接入。当前没有
`cursor.sub` 等资源时使用系统光标,不伪称为视觉 1:1;商店 BUY / SELL 模式已切换对应状态。
**剩余**:不可达 CANT_GO、DOOR / CHAIR / MAGIC / camera resize 等完整状态,
以及 `.sub` 光标贴图和软件光标开关仍需逐项实现 / 验收。
---
### 8.6 键位配置不完整
**参考补充**`ClientVS22/source/` 不含完整 Python 键位脚本;下表来自本仓迁移资产
`POC/assets/root/game.py:309-408`,只能作为历史脚本行为依据,仍需用当前目标构建和实际
输入验收确认。逐条:
```
数字 1..9 → __PressNumKey(n)
Ctrl+1..9 → chrmgr.SetEmoticon(-1, n-1) + net.SendEmoticon(n-1)
否则 1..4 → pressNumber(n-1) (§4.8
F1..F4 → __PressQuickSlot(4..7)
LALT 按/放 → ShowName() / HideName()
LCONTROL → ShowMouseImage() / HideMouseImage()
SYSRQ → SaveScreen()
SPACE 按/放 → StartAttack() / EndAttack()
方向键/WASD → MoveUp/Down/Left/Right,松键 StopUp/Down/Left/Right
D 键:Ctrl+D → 龙魂 deck 1;否则 MoveRight
Q / E → RotateCamera(NEGATIVE / POSITIVE),松键 CAMERA_STOP
Ctrl+Q → 切换所有 QuestButton 显示
R → ZoomCamera(NEGATIVE),松键 STOP
F → ZoomCamera(POSITIVE)Ctrl+F → "/user_horse_feed"
T → PitchCamera(NEGATIVE),松键 STOP
G → 若 ShowNameFlag 则 ToggleGuildWindow(),否则 PitchCamera(POSITIVE)
Ctrl+G → "/ride";松键 CAMERA_STOP
NUMPAD 9/4/6/8/2, PGUP, PGDN → Movie 相机
GRAVE / Z → PickUpItem()
C → Ctrl+C → 龙魂 deck 0;否则 ToggleCharacterWindow("STATUS")
V → ToggleCharacterWindow("SKILL")
N → ToggleCharacterWindow("QUEST")
B → Ctrl+B → "/user_horse_back";否则 ToggleCharacterWindow("EMOTICON")
H → Ctrl+H → "/user_horse_ride";否则 OpenHelpWindow() (§8.3
J → Ctrl+J → 骑乘中 "/unmount",否则在背包里找 71114/71116/71118/71120 使用
I → ToggleInventoryWindow()
O → ToggleDragonSoulWindowWithNoInfo()
M → interface.PressMKey()
ADD/SUBTRACT→ MiniMapScaleUp / MiniMapScaleDown
L → ToggleChatLogWindow()
COMMA → ShowConsole()
LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode() (§4.8
```
**当前(增量 53**`game_scene.gd` 已覆盖上表的主入口:数字 1–4 / F1–F4 快捷栏、
Ctrl/Command+数字表情、LSHIFT 翻页、空格按住攻击、Alt 名字显示、截图、帮助 / 坐骑 / 角色窗口 /
小地图 / 聊天 / 相机入口,并将按住 / 松开动作分别处理。macOS 上物理 F1–F12 是否直接产生
`KEY_F1``KEY_F12` 仍受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,这是平台验收条件,
不应写成 ClientVS22 的额外键位功能。
**已完成 / 剩余**
1. `game_scene.gd` 已按表补齐可映射入口,并通过 `input_key_test.gd``SYSRQ` 保存到用户截图目录,
空格按住 / 松开驱动连续攻击,LALT 控制世界名字牌,Ctrl/Command 组合走独立分支。
**增量 113**LALT`ShowName()` / `HideName()`)现只写 `net_world` 的 `_alt_held`,与
uigameoption「总是显示名字」选项(`_always_show_name`)一起按 `game.py::__IsShowName()` =
`IsAlwaysShowName() or ShowNameFlag` 决定名字是否显示,再叠 `ShowAllTextTail` 的 3500 平面距
裁剪与 `ShowCharacterTextTail` 资格判定(`name_show.gd`,见 §8.4 / §8.8 / C.5 增量 113)。
`ShowName()` 的另两步(`playerGauge.EnableShowAlways()` / `SetQuickPage(+1)`)属血条 / 快捷栏
UI,未接。
2. 表情快捷栏调用 `M2Client.send_emoticon()`,C++ 发参考 token 并发出本地播放信号;本地特效由
`game_scene.gd` 按迁移资产注册顺序播放。
3. 未完成的是 §4.2 的多格占用 / 全窗口精确 mousemodule 规则、真实 CJK IME 候选 / composition、以及
macOS F1–F12 的物理键设置验收;键位重绑定 / 冲突检测和独立相机灵敏度持久化不是参考端能力。
---
### 8.7 CJK 输入不等价
**参考**`REF/UserInterface/PythonIME.{h,cpp}`,接口:
```
Create(HWND) / MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete
OnTab / OnReturn / OnEscape / OnWM_CHAR(wParam, lParam) / OnUpdate / OnChangeCodePage
OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd
```
即:候选窗(candidate list)、读音窗(reading window)、代码页切换(`OnChangeCodePage`
与 §1.10 的 `LocaleService_GetCodePage()` 联动)都由客户端自绘。
**修改**
**定位**macOS / Godot **可以用原生 IME 取代 Windows IMM32 实现**(不必自绘候选窗),
但**必须保持参考端的输入行为**——不能因为换了实现就默认等价。以下逐项验证后才算完成:
1. **组合文本(composition)**:未确认的组合串要显示在输入位置,且**不计入**已提交文本;
对应 `OnWM_CHAR` / `OnUpdate`。
2. **候选词与候选窗**:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标,
不被游戏窗口遮挡;对应 `OnOpenCandidateList` / `OnCloseCandidateList` /
`OnOpenReadingWnd` / `OnCloseReadingWnd`。
3. **焦点切换**:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致;
窗口失焦 / 切窗时不残留候选窗。
4. **光标与编辑**`MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete`
六个操作在含多字节字符时按**字符**移动,不能按字节。
5. **长度限制**:见 §1.10——上限是**目标字段的字节数**(角色名 24、聊天 128、
公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。
6. **代码页**`OnChangeCodePage` 对应 `LocaleService_GetCodePage()`
locale 切换后输入与显示都要跟着换(§1.10)。
7. **`OnTab / OnReturn / OnEscape`**:聊天框 Tab 切换频道、Return 发送、Escape 关闭——
按 `uichat.py` 的绑定;这三个键在**候选窗打开时**必须优先给 IME,不能被窗口吃掉。
上述任一项不满足,本条不得标 ✅;`CLIENT-GAP.md` 里也不应写成「平台差异,等价即可」。
---
### 8.8 聊天和头顶信息不完整
**参考**
- 聊天:`REF/UserInterface/PythonChat.{h,cpp}` + `POC/assets/root/uichat.py`
`CHAT_MAX_NUM = 128``Packet.h:383`)。
`InsultChecker``REF/UserInterface/InsultChecker.cpp`)做脏词过滤。
- 头顶信息:`REF/UserInterface/PythonTextTail.{h,cpp}`——名字 / 称号 / 公会名 /
伤害数字 / 聊天气泡都在这里。
- 名字颜色:`ENameColor``REF/UserInterface/InstanceBase.h:227`,完整枚举见 §5.4)——
按 `MOB / NPC / PC+empire / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP /
WAYPOINT` 分类,`EMPIRE_NUM` 决定每类里帝国的偏移。
- 帝国常量:`EMPIRE_NUM = 4``REF/UserInterface/InstanceBase.h:217` 的匿名枚举
`EMPIRE_NONE/A/B/C`)。
- **增量 1102026-09-03**`CInstanceBase::GetNameColorIndex` / `GetNameColor` /
`SetEmpireNameMode` / `GetIndexedNameColor``REF/UserInterface/InstanceBaseEffect.cpp:52/75/520/525`
已 1:1 迁到 `project/name_color.gd`,并接进 `net_world.gd` 的头顶 `Label3D.modulate`
spawn / add-info / GC_PVP / duel 单发刷新 + `_process` ~2Hz 整表兜底)。sole baseline
40250 `source/` 本身含 `GetNameColorIndex`ISO-8859 编码,需 `grep -a`),字节级等同
m2dev 完整版 —— **无 seam**。见 C.5 增量 110。
- **增量 1112026-09-03**:头顶「公会名 / 称号 / 等级」子标签 1:1。
`CInstanceBase::GetTitleColor``InstanceBaseEffect.cpp:609``uGrade >= TITLE_NUM(9)` 白,
否则 `g_akD3DXClrTitle[uGrade]`)、`RefreshTextTail`:664`TITLE_NONE(4)` 时 `DetachTitle`
否则按 `g_TitleNameMap[grade]` `AttachTitle(name, GetTitleColor())`)、`AttachTextTail`:621
`fTextTailHeight = IsMountingHorse() ? 110 : 10`,末尾 `if (m_dwLevel) UpdateTextTailLevel`)、
`UpdateTextTailLevel`:654`s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1)``"Lv %d"`)、
`CPythonTextTail::RegisterCharacterTextTail``PythonTextTail.cpp:509``dwGuildID != 0` 才建公会名
标签,`GetGuildName` 失败 → `"Noname"``c_TextTail_Guild_Name_Color = 0xFFEFD3FF`)、
`EnablePKTitle`:938`AttachTitle` / `AttachLevel` 均 `if (!bPKTitleEnable) return`
`game.py:155` 用 `constInfo.PVPMODE_ENABLE == 1` 初始化)全部迁到 `project/text_tail.gd`(纯静态)。
称号色表来自 `introloading.py:287 TITLE_COLOR_DICT` + `colorInfo.py:29-37`,称号名来自
`localeInfo.py:215 TITLE_NAME_LIST == (PVP_LEVEL0..PVP_LEVEL8,)`。40250 `source/` 自带
`InstanceBaseEffect.cpp` 的 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` /
`RegisterTitleName` / `RegisterTitleColor`:609/:664/:654/:1107/:1131)—— **无 seam**。
见 C.5 增量 111。
- **增量 1122026-09-03**:头顶「聊天 / 信息」尾标 1:1。
`CPythonTextTail::RegisterChatTail``PythonTextTail.cpp:581``m_ChatTailMap` 已有该 vid →
改文本 + `Color = c_TextTail_Chat_Color(白)` + `LivingTime = now + TextTail_GetLivingTime()` +
`bNameFlag = TRUE`,否则新建,高度 = 角色高 + 10)、`RegisterInfoTail`:624,同上但
`Color = c_TextTail_Info_Color = D3DXCOLOR(1, .785, .785, 1)`、`bNameFlag = FALSE`chat 与
info 共用 `m_ChatTailMap`,同 vid 互相覆盖)、`TextTail_GetLivingTime` / `SetLivingTime`
:26/:28/:34`gs_TextTail_LivingTime = 5000``assert(> 1000)`)、`UpdateAllTextTail` 的
`LivingTime < now` 硬删(:328,无淡出)、`IsChatTextTail`:684)全部迁到 `project/chat_tail.gd`
(纯静态)。派发规则照抄 `PythonNetworkStreamPhaseGame.cpp::RecvChatPacket``CHAT_TYPE_COMMAND`
提前 `return`:1301);`TALKING/PARTY/GUILD/SHOUT/WHISPER` 的 `line = strchr(buf,':') ? p+2 : buf`
去掉 `"名字: "` 前缀(:1321),`INFO/NOTICE/BIG_NOTICE` 原样(:1363);`if (CHAT_TYPE_SHOUT !=
type) RegisterChatTail(vid, line)`:1372)—— 即除 `SHOUT` / `COMMAND` 外带 VID 的聊天都进
「聊天尾标」,`RegisterInfoTail` 仅 `game.py:930/936` 技能 / 射击错误显式调。40250 `source/`
自带 `RegisterChatTail` / `RegisterInfoTail` / `RecvChatPacket` —— **无 seam**(气泡与名字尾标
同屏排布 / `bNameFlag` 名字下移 17px = seam ⑨,表现层近似)。见 C.5 增量 112。
- **增量 1132026-09-03**:头顶名字「是否显示」1:1(§8.4 / §8.6)。
`CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag``PythonSystem.cpp:327/:334`
`bAlwaysShowName = iFlag == 1`,默认 `DEFAULT_VALUE_ALWAYS_SHOW_NAME = true`,配置行
`ALWAYS_VIEW_NAME` = `atoi == 1`)、`game.py::__IsShowName`:1149`IsAlwaysShowName() or
ShowNameFlag``ShowNameFlag` 由 LALT 按 / 抬 `ShowName()` / `HideName()` 翻转,:323/:406/:1143)、
`CPythonTextTail::ShowAllTextTail``PythonTextTail.cpp:422``m_CharacterTextTailMap` 里
`fDistanceFromPlayer < 3500.0f` 才 `ShowCharacterTextTail`)、`ShowCharacterTextTail`:440
不在图 / 已入列 / `!pOwner->isShow()` / 无实例 / `IsGuildWall()` → return`CanPickInstance()`
才入列)、`UpdateAllTextTail` 的 chat 尾标 `bNameFlag` → `ShowCharacterTextTail`:74,不受
3500 限制)、`game.py OnRender` 的 `chr.Pick()` 命中 + `targetBoard.GetTargetVID()` 两条强显
:1533/:1535)全部迁到 `project/name_show.gd`(纯静态)。40250 `source/` 自带
`PythonSystem` / `PythonTextTail` 全部逻辑 —— 悬停 `chr.Pick()` 强显 / `ArrangeTextTail`
屏幕空间防重叠 / `CanPickInstance()` 完整语义 = seam ⑩(net_world 每实体一块 billboard
`Label3D` 逐帧 set `.visible` 的近似)。见 C.5 增量 113。
- **增量 1142026-09-03**:头顶伤害飘字 1:1(§8.8 / §5.4)。
`CInstanceBase::AddDamageEffect``InstanceBaseEffect.cpp:87``if (CPythonSystem::IsShowDamage())`
才 `m_DamageQueue.push_back({bSelf, bTarget, damage, flag})`)、`ProcessDamage`:100
`InstanceBase.cpp:1910` 每帧调,`m_DamageQueue` 每帧 `pop_front` 一条:`(flag & DODGE) ||
(flag & BLOCK)` → `bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS` + `return``CRITICAL`
/ `POISON` 分支参考端 `CreateEffect` 均注释掉 → 落普通数字;`bSelf` → `"damage_"` +
`EFFECT_DAMAGE_SELFDAMAGE` / `SELFDAMAGE2` 按 `m_bDamageEffectType` 交替;`bTarget == false`
→ `"nontarget_"` + `return`(不渲染);否则 `"target_"``while (damage > 0)` 逐位
`damage % 10` / `damage /= 10``index 0..7``> 7` `TraceError` break),每位一张
`damagevalue/<type><digit>.dds` 特效按 `FONT_WIDTH(30) * index` 相机右向铺开)、
`TPacketGCDamageInfo``Packet.h:1943``header / dwVID / flag / int damage`)经
`RecvDamageInfoPacket``PythonNetworkStreamPhaseGame.cpp:2401``bSelf = 目标是主实例`、
`bTarget = 目标是 m_pInstTarget`、`damage >= 0` 才 `AddDamageEffect`)、`CPythonSystem::IsShowDamage`
/ `SetShowDamageFlag``PythonSystem.cpp:337/:342``bShowDamage = iFlag == 1`,默认 `true`
配置行 `SHOW_DAMAGE` = `atoi == 1`)全部迁到 `project/damage_effect.gd`(纯静态)。40250 `source/`
自带 `AddDamageEffect` / `ProcessDamage` / `RecvDamageInfoPacket` / `PythonSystem` —— `damagevalue`
dds 逐位特效 / 相机右向像素铺排 / self `SELFDAMAGE`⇄`SELFDAMAGE2` 双资源 / 每实例独立队列
= seam ⑪(net_world 用一块 billboard `Label3D` 写 `str(amount)`、self 退化为水平抖动符号交替、
暴击不再放大 / 变色(对齐该 build)、全局单队列每帧一条)。见 C.5 增量 114。
- **增量 1152026-09-03**:目标框 `RecvTargetPacket` 收包分派 1:1(§5.4 / §8.8)。
`CPythonNetworkStream::RecvTargetPacket``PythonNetworkStreamPhaseGame.cpp:2424`)的判定树:
`pInstPlayer && pInstTarget` 都在且 `!IsDead()` 时 —— `IsPC() || IsBuilding()` →
`CloseTargetBoardIfDifferent(dwVID)`:2440)、否则 `CanViewTargetHP(*pInstTarget)` →
`SetHPTargetBoard(dwVID, bHPPercent)`:2442)、否则 `CloseTargetBoard()`:2444);
`m_pInstTarget = pInstTarget` 仅在该 `!IsDead()` 块内(:2447);实例缺失 → `CloseTargetBoard()`
(:2453)、目标已死透 → 整块跳过(不动框)。`CInstanceBase::CanViewTargetHP`
`InstanceBase.cpp:2293`)= `IsStone() || IsWoodenDoor() || IsEnemy()`。`TPacketGCTarget`
`Packet.h:1937``header / dwVID / bHPPercent`)。`game.py` 侧 `SetHPTargetBoard`:829
`vid != GetTargetVID()` → `ResetTargetBoard()` + `SetEnemyVID(vid)`,再 `SetHP` + `Show`)、
`CloseTargetBoardIfDifferent`:837`vid != GetTargetVID()` 才 `Close()`)、`CloseTargetBoard`
:841)、`SetPCTargetBoard`:804,按住 `LCONTROL` 且 `IsSameEmpire` 且非本人且非 building →
`OpenWhisperDialog`)全部迁到 `project/target_board.gd`(纯静态)。40250 `source/` 自带
`RecvTargetPacket` / `CanViewTargetHP` —— poc 无 building / wooden-door / stone-door 实体类型
`is_building` 恒 false、`can_view` 的 `is_wooden_door` 恒 false)、`LCONTROL` 悄悄话捷径未接
= seam ⑫。见 C.5 增量 115。
- **增量 1162026-09-03**`OnRender` Picking 块分派 + 悬停角色强显名字 1:1(§8.8 / §5.4)。
`game.py GameWindow.OnRender()` 的「Picking」块(:1519):`wndMgr.IsPickedWindow(self.hWnd)` 门
:1533)内 —— `chr.Pick()` 命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`:1535/:1537)、
`targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`:1539)、`not __IsShowName()`
时 `item.Pick()` → `ShowItemTextTail`:1543/:1546);门外 `__IsShowName()` 时 `ShowAllTextTail()`
+ `PickingItemIndex = textTail.Pick(x, y)`:1552/:1554);末尾 `UpdateShowingTextTail` +
`ArrangeTextTail`:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`:1560)。全部迁到
`project/pick_show.gd`(纯静态第 27 个 canonical):`NO_PICK := -1`、`char_pick_active`、
`forced_name_vids(is_picked_window, pick_char_vid, target_vid)`(收 `[hover, target]` 去重)、
`should_pick_item` / `should_show_all_names` / `should_pick_text_tail` / `should_select_item_name`。
poc 侧接:`net_world._hover_vid` + `set_hover_vid` + `_name_shown()` forced 第 5 路,
`player_controller.hover_entity_changed` 信号(`_update_hover_cursor()` 射线拾取,压 UI / 悬停
地面物品 → 0),`game_scene` 连线。40250 `source/` 自带 `OnRender` —— 名字尾标 y = chat 气泡
y 17px`PythonTextTail.cpp:112-114`)未接 = seam ⑨;`ArrangeTextTail` 屏幕空间去重叠排布
仍缺 = seam ⑩(本增量只收口「悬停强显名字」一小块)。见 C.5 增量 116。
- **增量 1172026-09-04**seam ⑨ 收口 —— 头顶名字尾标随聊天气泡上移 17px 1:1(§8.8)。
`CPythonTextTail::UpdateShowingTextTail``PythonTextTail.cpp:88`)在角色名字尾标投影完
屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag`
为 TRUE:112,= `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)
→ `pTextTail->y = pChatTail->y - 17.0f`:114,屏幕 y 向下减 17px = 名字上移一行贴气泡
上沿)。名字与气泡默认同世界高(`GetHeight()+10`:519/:581),全靠这条 17px 分开。
迁到 `project/name_tail_layout.gd`(纯静态第 28 个 canonical):`NAME_OFFSET_ABOVE_BUBBLE_PX
:= 17.0`、`name_follows_bubble(has_chat_tail, chat_name_flag)`、`name_tail_screen_y`(参考端
字面 `bubble_y 17`)、`name_tail_world_y`poc 3D:屏幕 y 向下的 −17px → 世界 y 向上的
`bubble_world_y + 17*pixel_size`)。poc 侧接:`net_world._reposition_name_tag(n)` 由
`_apply_chat_tail` / `_expire_chat_tails` 调用,读 `_chat_tails[vid].name_flag` 决定名字
`Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`2.1)与「气泡 2.35 + 17*pixel_size」之间切换。
剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D` 节点、子标签不随动 = seam ⑦ 遗留。见 C.5 增量 117。
- **增量 1182026-09-04)**:目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线 1:1(§5.4 / §8.8 / §8.4)。
`root/uitarget.py TargetBoard.Open(vid, name)`:189-193)在整个 `RecvTargetPacket` 判定之前
先过滤:`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid):
self.Hide(); return``constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认,
`uigameoption.py` 的 `target_board_view` / `target_board_no_view` radio →
`constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)`)。迁到
`project/target_board.gd``hidden_as_other_empire(view_other_empire, same_empire)` =
`not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— 忠于参考;empire 0 的
怪 / NPC / 石头在 `EntityRules._is_same_empire` 里算「同阵营」故永不被挡)。poc 侧接:
`net_play._view_other_empire_target`(默认 true+ `set_view_other_empire_target(on)` +
`_on_target_info` 在 `TargetBoard.classify(...)` 之前先 `if exists and
TargetBoard.hidden_as_other_empire(...): _clear_target(); return``same_empire` 由
`EntityRules._is_same_empire(_main_entity(), e)` 求。同批把 §8.4「名字色」radio
`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` →
`net_world.set_empire_name_mode(v == 1)`(帝国名色调色板算法增量 110 已 1:1),并修
`game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。
40250 `source/` 自带 `TargetBoard.Open` —— poc 无独立「他国 PC 名字框」窗体,本增量只是
在 `_on_target_info` 分派前加同一道阵营门(seam ⑫:building / wooden-door 实体类型、
`LCONTROL` 悄悄话捷径仍未做)。见 C.5 增量 118。
- **增量 1192026-09-04**seam ⑦ 收口 —— 公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1(§8.8)。
`CPythonTextTail::ArrangeTextTail``PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把
公会名(:228`pTextTail->y - c_fyGuildNamePosition``c_fyGuildNamePosition = 15.0f` :21/
称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个子标签的屏幕 y 全部锚在
`pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 17(增量 117)。
迁到 `project/name_tail_layout.gd``GUILD_NAME_ABOVE_NAME_PX := 15.0`、
`name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(=
`name_tail_world_y(...) - default_world_y`,follows 时为净上移量、否则 0)、
`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=二者相加,三子标签整体平移)。
poc 侧接:`net_world` 把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为
`SUBTAG_DEFAULT_Y`2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按
同一 `lift` 平移三个子标签;气泡过期 / 「信息」尾标(`bNameFlag` FALSE)时回默认。
剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级
同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D`(非单尾标链表)。见 C.5 增量 119。
- **增量 1202026-09-04**seam ⑩ 收口 —— `ArrangeTextTail` item 尾标去重叠 + `isIn` 1:1(§8.8)。
`CPythonTextTail::ArrangeTextTail``PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块
(:160-190):逐条掉落名条与其它每条比 `isIn`:489-507`[x+xStart,x+xEnd]×[y+yStart,y+yEnd]`
的 AABB 含边界 `<=`/`>=`),命中就把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd
+ yTemp``yTemp = 5`),内层从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical
`project/text_tail_arrange.gd``extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、
`box_start_x/end_x/start_y/end_y``-w/2-2` / `w/2+2` / `-2` / `h``PythonTextTail.cpp:810-813`)、
`boxes_overlap(a,b)``isIn`)、`arrange_item_tails(tails:Array)->Array`(外层逐条 i、内层逐条 j
跳过自身、命中 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP` 后 j 归零 limit++、`limit >= MAX_ITERS`
break`pCompare` 用已排布后的 `ys[j]`)。poc 侧接:`project/ui/ground_items.gd` 加
`_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / `TAG_LINE_H_PX 15`
`_process` 尾部 `_arrange_tags()` 把所有掉落名条世界锚点 `cam.unproject_position` 到屏幕、
按字符数估框、跑 `arrange_item_tails`、`position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`
可见名条 < 2 直接回默认;`game_scene` 把 `func() -> Camera3D: return cam` 传进
`ground_items.setup` 第 6 参。剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考
`GetTextSize` 精确像素;只对掉落 item 名条做(角色名字尾标分表不参与)。见 C.5 增量 120。
- **增量 1212026-09-04**seam ⑦ 水平深化 —— 角色子标签逐像素水平排版 1:1(§8.8)。
`CPythonTextTail::ArrangeTextTail` 的 `m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`
非 EUROPE 分支:`fxAdd` 三态(:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)),
称号 `x - iNameWidth/2 - fxAdd`:258)、等级有称号时再 `- iTitleWidth`:282)否则
`x - iNameWidth/2 - 4`:312)、名字本身 `x + fxAdd`(:320);公会名水平锚点仅在有公会徽记
`pMarkInstance`:220)时改写成 `x + markW/2 + 1.5`:228)。`name_tail_layout.gd` 加
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
`subtag_world_x`。`net_world._layout_subtags_x``_apply_text_tail_to` 尾部)设名字 /
`TitleTag` / `LevelTag` 的 `.position.x``GuildTag` 留 0,poc 无徽记),文本宽 `字符数 * 7px`
估(`SUBTAG_CHAR_W_PX`);与增量 117/119 纵向位移解耦(`.position.y` vs `.position.x`)。
剩余 seam ⑦:无公会徽记 mark 实体、字符数估宽 vs `GetTextSize`、三子标签竖排 + x 偏移的
折衷(≠ 参考同一屏幕行)。见 C.5 增量 121。
- **增量 1222026-09-04**seam ⑫ 收口 —— 目标框点击 `LCONTROL` 悄悄话捷径接 whisper 1:1(§5.4 / §8.8)。
`assets/root/game.py CGameWindow::SetPCTargetBoard`:804-817):`self.targetBoard.Open(vid, name)`
之后,`if app.IsPressed(app.DIK_LCONTROL)`:807)且 `player.IsSameEmpire(vid)`:809)且
非本人(:812 `IsMainCharacterIndex`)且 `chr.INSTANCE_TYPE_BUILDING != GetInstanceType(vid)`
:814)→ `self.interface.OpenWhisperDialog(name)`:817)。谓词
`target_board.whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building)`
增量 118 已备好没接,本增量纯接线:`net_play` 加 `signal whisper_requested(name)` +
`var _lcontrol_down` + `set_lcontrol_down(down)``_on_pick` 在 `_entity_kind(e) == KIND_PC`
分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 `whisper_on_ctrl_click(_lcontrol_down,
_is_same_empire(_main_entity(), e), vid == _main_vid, false)` 真则 `whisper_requested.emit(
_entity_name(e))``game_scene._unhandled_input` 加 `KEY_CTRL` 按下 / 松开 →
`net_play.set_lcontrol_down(e.pressed)``chat` 就绪后 `net_play.whisper_requested` →
`chat.start_whisper`poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`)。
剩余 seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 实参恒 false
:814 分支永不触发)、无独立 whisper dialog 窗口。见 C.5 增量 122。
- **增量 1232026-09-04**seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条文本宽换真实字体度量
`GetTextSize` 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 里 `iNameWidth` / `iTitleWidth` /
`iLevelWidth``PythonTextTail.cpp:225/233/240/267/297`)与掉落名条框 `xSize`:733/:748/:809
参考端全部取自 `CGraphicTextInstance::GetTextSize(&w, &h)`——逐字形排版度量,非「字符数 * 定宽」。
新增纯静态 `project/text_metrics.gd`(第 30 个 canonical):`text_size(font, text, font_size)`
= `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(退化输入 → `ZERO`)、
`width_of(...)`(量得正宽用精确像素,无字体回退 `len * 7.0`)、`label_width(label: Label3D)`
`label.font ?? ThemeDB.fallback_font` + `label.font_size`)。`net_world._layout_subtags_x` 的
`name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX`
换成 `TextMetrics.label_width(label, *_CHAR_W_PX)``*_CHAR_W_PX = 7.0` 常量保留作无字体兜底。
`name_tail_layout.gd` / `text_tail_arrange.gd` 收宽度作入参、排布公式(增量 119/120/121)不动。
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行);
剩余 seam ⑩:名条单行高仍按 `TAG_LINE_H_PX` 15px 估、只对掉落 item 名条做。见 C.5 增量 123。
- **增量 1242026-09-04**seam ⑦ / ⑩ 末尾 —— 掉落名条去重叠框的「单行文本高」换真实字体
度量 `GetTextSize` 的 h 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 的 item 尾标去重叠
用 `pTextTail->yEnd`,而 `yEnd = ySize``PythonTextTail.cpp:751/:812`),`ySize` 与增量 123
换过的 `xSize` 同出 `CGraphicTextInstance::GetTextSize(&xSize, &ySize)`:749/:809)。
`project/text_metrics.gd`(仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0`
+ `height_of(font, text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素
`text_size(...).y`;否则回退)+ `label_height(label: Label3D, fallback_line_px=15.0)`
`label.font ?? ThemeDB.fallback_font` + `label.font_size``null` → 0)。
`ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX` 换成
`maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))``TAG_LINE_H_PX = 15.0` 保留作
无字体兜底。`text_tail_arrange.gd``box_end_y(h) = h`/ `name_tail_layout.gd` 收框高作
入参、排布公式不动。测试扩 `text_metrics_test.gd`+「5. height_of / label_height」段),
回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。剩余 seam ⑦:无公会徽记 mark 实体、
三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance
(参考端 `yEnd += yOwnerSize + 4`)、只对掉落 item 名条做。见 C.5 增量 124。
- **增量 1252026-09-04**seam ⑩ 末尾 —— 带 owner 的掉落名条去重叠框按参考
`CPythonTextTail::SetItemTextTailOwner``PythonTextTail.cpp:706-737`)「owner 独立度量 +
`yEnd += yOwnerSize + 4`」1:1(§8.8)。参考端 owner 后缀是与名字分开的
`CGraphicTextInstance`(文本 = `c_szName + strOwnership``IDS_POSSESSIVE_MORPHENE` 为空取
字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`:735)、
`xStart = fMIN(xStart, -xOwnerSize/2-1)`:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`:737)。
`text_tail_arrange.gd`(扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h,
owner_w:=0.0, owner_h:=0.0)`:无 owner → `{name_w, name_h}`(对齐参考 else 分支);有 owner →
`{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的
`fMAX`,框 x 对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical
加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串,`null` /
空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta`_arrange_tags` 把
「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是
单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩
`text_tail_arrange_test.gd`+「5. item_box_with_owner」段)/ `text_metrics_test.gd`
+「6. string_*_like」段)/ `p2b_test.gd``FakeClient.add_ground` 加可选 owner 入参 +
带 owner 掉落被顶 `(34+5)*0.005` 段),回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。
剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接本地化语素、
只对掉落 item 名条做。见 C.5 增量 125。
- **增量 1262026-09-04**seam ⑩ 末尾 —— 带 owner 的掉落名条按参考
`CPythonTextTail::SetItemTextTailOwner``PythonTextTail.cpp:712-745`)把 owner 后缀拆成
**一个独立的 CENTER 黄字(`1,1,0`text instance**、并按 `ArrangeTextTail`:191-207)把名字
实例下移 15px(§8.8)。参考端 owner 非空时新建 `pOwnerTextInstance`:715)、文本
= `c_szName + strOwnership`:723`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)`
为空取字面 `"'s"`:722)、`SetHorizonalAlign(CENTER)`:727)、`SetColor(1,1,0)`:729);
排布时 `pOwnerTextInstance->SetPosition(x, y, z)`:195)、`pTextInstance->SetPosition(x,
y + 15.0f, z)`:200);owner 为空时 `Delete(pOwnerTextInstance)`:743)。`ground_items`
drop 节点下加第二个 `Label3D "owner_tag"`CENTER / `modulate = Color(1,1,0)` / billboard /
`no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag(node,
owner)` 建 / 删;名字 `tag` 只放 `_name_for(vnum)`(不再拼 `"name (owner)"`);新增
`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)` —— 有 owner 时
`owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = TAG_BASE_Y - (d_px + 15)*px`,无 owner 时
沿用 `tag.y = TAG_BASE_Y - d_px*px``setup()` 加可选 `possessive := ""``_possessive =
possessive if possessive != "" else OWNER_POSSESSIVE`1:1 对齐 :722 的 `== "" ? "'s" :` 语义),
`game_scene` 从 EterLocale `POSSESSIVE_MORPHENE` 键解析后注入(poc txt 无此键 → 回落 `"'s"`
与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` 一致)。去重叠框度量仍是增量 125 的
`item_box_with_owner``yEnd += ownerH + 4`)不变。`_tag_text` 删除(不再合并渲染)。测试扩
`p2b_test.gd`drop_302 名字实例文本 = `"掉落28"`、`owner_tag` 文本 `"Alice's"` / CENTER /
黄字 / 落基点、名字实例下移 15px、无 owner 的 drop_301 不建 `owner_tag`drop_401 用
`setup(possessive="@")` → `owner_tag` 文本 `"Bob@"`),回归 `project/*_test.gd` 70 全 PASS
(无新增测试文件)。剩余 seam ⑩:owner 行的品阶/颜色语义仍是固定黄(参考名字实例取
`pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、去重叠只对掉落
item 名条做。见 C.5 增量 126。
**修改**
1. 按 §8.2 迁移 `uichat.py`notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。
2. 新建 `text_tail.gd` / `chat_tail.gd` / `name_show.gd` 移植 `PythonTextTail`:名字 / 称号 /
公会名 / 伤害数字 / 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。
**增量 113 已落地「总是显示名字」显示开关**(`name_show.gd` 纯静态 `IsAlwaysShowName` /
`__IsShowName` / `ShowAllTextTail` 3500 距离裁剪 / `ShowCharacterTextTail` 资格判定;
`net_world.gd` 把原 `_names_visible` 拆成 `_always_show_name`uigameoption radio 经
`game_option_ui` 新信号 `display_option_changed` 驱动)+ `_alt_held`LALT+ `_target_vid`
`net_play.target_changed` 驱动,选中目标强显)+ chat 尾标 `bNameFlag` 强显;名字色 /
伤害数字两个 radio 仍未接)。
**增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` /
`LivingTime` 5s 硬删 / 前缀剥离 / SHOUT·COMMAND 排除,`net_world.gd` `"bubble"` `Label3D`
每 vid 一条 + `register_info_tail()`);伤害数字 / 屏幕空间血条仍待做。
**增量 111 已落地称号 / 等级 / 公会名子标签**(`text_tail.gd` 纯静态 `GetTitleColor` /
`RefreshTextTail` / `UpdateTextTailLevel` / `RegisterCharacterTextTail` 公会名分支 /
`EnablePKTitle``net_world.gd` 的 `GuildTag` / `TitleTag` / `LevelTag` 三个 `Label3D`
子节点 + `_apply_text_tail` 事件驱动 + ~2Hz 兜底;`game_scene.gd` 注入 `title_name_resolver`
→ `Locale.t("PVP_LEVEL%d")`)。
**增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` 纯静态 `RegisterChatTail` /
`RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` / `line_for_tail` 前缀剥离 /
`wants_chat_tail` SHOUT·COMMAND 排除;`net_world.gd` 每 vid 一条 `"bubble"` `Label3D`
子节点、白 / 淡红按 chat/info、`LivingTime` 到点 `_expire_chat_tails` 硬删、新增
`register_info_tail(vid, text)` 公开方法对齐 `RegisterInfoTail``chat` 派发从
`t in [0,3,4,6]` 换成 `ChatTail.wants_chat_tail(t)`=除 SHOUT/COMMAND 全进、并去 `"名字: "`
前缀;`game_scene._player_bubble` 寿命对齐 5s)。伤害数字 / 屏幕空间血条整合仍待做。
billboard 3D 竖排 vs 参考屏幕空间像素排版 = seam ⑦;`title_name_resolver` 未注入时
回退裸 `PVP_LEVEL%d` key = seam ⑧;气泡与名字 `Label3D` 各自 billboard 并存(名字不强制
顶出、不随气泡下移 17px= seam ⑨(**增量 113** 已让 chat 尾标 `bNameFlag` 强制显示名字,
仅剩「下移 17px」未做)。
**增量 113 已落地名字「是否显示」**(`name_show.gd` + `net_world._name_shown()`
`__IsShowName()``_always_show_name or _alt_held`、`ShowAllTextTail` 的
`fDistanceFromPlayer < 3500` 平面距裁剪、`ShowCharacterTextTail` 的 `isShow / !IsGuildWall /
CanPickInstance` 资格、`target VID` 与 chat `bNameFlag` 强显绕过 3500`_process` ~2Hz 兜底
随移动刷新,LALT / 选项 / 选中 / 尾标切换即时刷新)。悬停 `chr.Pick()` 强显未接、
`ArrangeTextTail` 屏幕空间防重叠未做、`CanPickInstance()` 近似为「非死透」= seam ⑩。
**增量 114 已落地头顶伤害飘字**(`damage_effect.gd` 纯静态:`AddDamageEffect`
`InstanceBaseEffect.cpp:87``IsShowDamage()` 门 → `m_DamageQueue`)、`ProcessDamage`
:100,每帧 `pop_front` 一条:`DODGE|BLOCK` → `MISS`/`TARGETMISS` return、`CRITICAL`/`POISON`
分支参考端注释掉走普通数字、`bSelf` → `"damage_"` + `SELFDAMAGE`⇄`SELFDAMAGE2` 交替、
`!bTarget` → `"nontarget_"` return 不渲染、否则 `"target_"`、`while(damage>0)` 逐位 `%10`/`/10`
index 0..7、每位 `damagevalue/<type><digit>.dds` 按 `FONT_WIDTH*index` 相机右向铺开)、
`RecvDamageInfoPacket``PythonNetworkStreamPhaseGame.cpp:2401``bSelf`/`bTarget`/`damage>=0`)、
`IsShowDamage`/`SetShowDamageFlag``PythonSystem.cpp:337/:342`,默认 `true`)。
`net_world._on_damage` 照 `AddDamageEffect``damage_number` 信号仍无条件发=本移植扩展;
飘字本体 `_show_damage` 门后入 `_damage_queue`)、`_process_damage_queue()` 每帧 `_process`
里 pop 一条照 `ProcessDamage` 分类,一块 billboard `Label3D` 写 `str(amount)`/`"MISS"`
`net_world.set_show_damage(on)` ← `game_scene._on_display_option("show_damage")` ←
`game_option_ui.display_option_changed`uigameoption「伤害数字」radio),初值
`display_value("show_damage") == 1` 回填;`clear_for_map_change` 清 `_damage_queue`。
seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排;self
`SELFDAMAGE`⇄`SELFDAMAGE2` 退化成落字水平抖动符号交替(±0.3);暴击不再放大 / 变色
(对齐该 40250 build 本就无独立暴击视觉);`_damage_queue` 全局单条 vs 参考每实例一条。
**增量 115 已落地目标框收包分派**(`target_board.gd` 纯静态:`Action { CLOSE,
CLOSE_IF_DIFFERENT, SET_HP, NONE }`、`can_view_target_hp`(石头 / 木门 / 敌对任一)、
`classify(target_exists, is_dead, is_pc, is_building, can_view_hp)` `RecvTargetPacket` 判定树
(缺失 → CLOSE、已死 → NONE、PC|Building → CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)、
`updates_current_target``m_pInstTarget` 写入条件)、`needs_reset` / `should_close_if_different`
`!= GetTargetVID()`)、`whisper_on_ctrl_click`、`clamp_hp_pct`)。`net_play._on_target_info`
由「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、
`can_view = KIND_STONE 或 KIND_MONSTER`、CLOSE / CLOSE_IF_DIFFERENT → `_clear_target()`、
SET_HP → 换 VID 先 `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字,
clamp_hp_pct(hp%))`、NONE → 不动。seam ⑫:poc 无 building / wooden-door 实体(`is_building`
恒 false、木门分类缺);`target_info` 由 C++ `m2_client.cpp:3129/3633` 从 EntityStore 抽出,
不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词已备、`net_play` 暂不接~~ 增量 122 已接线
`_on_pick` KIND_PC 分支 → `whisper_requested` → `game_scene` → `chat.start_whisper`)。
**增量 116 已落地 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` 纯静态第 27
个 canonical`NO_PICK := -1`、`char_pick_active(is_picked_window)`、`forced_name_vids(
is_picked_window, pick_char_vid, target_vid)` `IsPickedWindow` 门内收 `chr.Pick()` 命中角色
:1537+ `targetBoard.GetTargetVID()`:1539),去重、顺序 `[hover, target]`、`should_pick_item(
is_picked_window, is_show_name)` `and not`:1543)、`should_show_all_names` / `should_pick_text_tail`
`is_show_name`:1552/:1554)、`should_select_item_name` `!= NO_PICK`:1560))。poc 侧仿增量
113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`
`net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点名字可见性)由 `player_controller`
`_update_hover_cursor()` 三出口喂(压 UI → 0、悬停地面物品 → 0、射线命中 pickable →
`best.get_meta("vid")`、无命中 → 0),`hover_entity_changed` 信号去抖,`game_scene` 连
`pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字尾标 y = chat 气泡 y 17px
`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺(本增量只
收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced,靠 `player_controller` 压 UI / 悬停
物品时置 0 部分等效 `IsPickedWindow` 门。
**增量 117 已落地 seam ⑨ 收口(名字尾标随聊天气泡上移 17px)**(`name_tail_layout.gd`
纯静态第 28 个 canonical`NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0`、`name_follows_bubble(
has_chat_tail, chat_name_flag)` `and``UpdateShowingTextTail` :110 命中 && :112
`bNameFlag`)、`name_tail_screen_y(default_y, bubble_y, follows)` 参考端字面 `bubble_y 17`
:114)、`name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)` poc 3D
适配(屏幕 y 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`))。poc 侧
`net_world._reposition_name_tag(n)` 由 `_apply_chat_tail`(冒气泡)/ `_expire_chat_tails`
(气泡过期)调用,读 `_chat_tails[vid].name_flag``RegisterChatTail` TRUE / `RegisterInfoTail`
FALSE)决定名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`2.1)与「`BUBBLE_TAG_Y` 2.35 +
17*pixel_size」之间切换。换算按名字 `Label3D` 的 `pixel_size`0.006),与参考的字体无关
常量 17 对齐。
**增量 119 已落地 seam ⑦ 收口(子标签随名字尾标纵向位移)**:`ArrangeTextTail`
`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y
c_fyGuildNamePosition` 15px/ 称号(:258,同 `pTextTail->y`/ 等级(:283,同 y)三个
子标签屏幕 y 全锚在 `pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 17。
`name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta(
default_world_y, bubble_world_y, follows, px_size)`(= `name_tail_world_y(...)
default_world_y`)、`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=相加)。
poc 侧 `net_world` 把子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y`2.34 / 1.86 / 1.74)常量,
`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签,气泡过期 / 「信息」尾标
时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似。剩余 seam ⑨:
气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)。
**增量 120 已落地 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn`**
`PythonTextTail.cpp:153` 的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它每条比
`isIn`:489-507,AABB 含边界),命中把「自己」顶到 `pCompareTextTail->y + yEnd + 5`,内层
从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical `text_tail_arrange.gd`
`PUSH_GAP` 5 / `MAX_ITERS` 20、`box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`、`boxes_overlap``isIn`、
`arrange_item_tails(tails)->Array`)。poc 侧 `ground_items._arrange_tags` 每帧把掉落名条锚点
`cam.unproject_position` 到屏幕、按字符数估框、跑 `arrange_item_tails`、把被下推屏幕 Δy 按
`Label3D.pixel_size` 折回局部 y`TAG_BASE_Y Δpx*pixel_size`),可见名条 < 2 回默认;
`game_scene` 把相机 getter 传进 `ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs
参考 `GetTextSize` 精确像素~~(增量 123 已换真实字体度量);~~单行高仍按 15px 估~~(增量 124
已换 `TextMetrics.label_height` = `GetTextSize` 的 h);只对掉落 item 名条(角色名字尾标分表不参与)。
**增量 121 已落地 seam ⑦ 水平深化(子标签逐像素水平排版)**:`ArrangeTextTail` 的
`m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态
:236 `0` / :242 `8` / :288 `4`),称号 `x iNameWidth/2 fxAdd`:258)、等级有称号时
再 ` iTitleWidth`:282)否则 `x iNameWidth/2 4`:312)、名字本身 `x + fxAdd`:320);
公会名水平锚点仅在有公会徽记 `pMarkInstance`:220)时改写。`name_tail_layout.gd` 加
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
`subtag_world_x`(屏幕像素 × `pixel_size` → billboard 本地 x)。poc 侧 `net_world._layout_subtags_x`
`_apply_text_tail_to` 尾部)设名字 / `TitleTag` / `LevelTag` 的 `.position.x``GuildTag` 留 0
(poc 无徽记 → 参考端 :220 此路不改写),文本宽 `字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`
增量 123 已换真实字体度量);与增量 117/119 的纵向位移解耦(`.position.y` vs `.position.x`)。
剩余 seam ⑦:无公会徽记 mark 实体、~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、
三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
**增量 124 已落地 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量)**:
`ArrangeTextTail` 的 item 尾标去重叠顶到 `pCompareTextTail->y + yEnd + 5``yEnd = ySize`
`PythonTextTail.cpp:751/:812`),`ySize` 与增量 123 换过的 `xSize` 同出
`CGraphicTextInstance::GetTextSize(&xSize, &ySize)`:749/:809)。`text_metrics.gd`
(仍第 30 个 canonical)加 `const FALLBACK_LINE_H_PX := 15.0` + `height_of(...)`(空串 → 0
量得正高用 `text_size(...).y`;否则回退)+ `label_height(label: Label3D)`
`label.font ?? ThemeDB.fallback_font``null` → 0)。`ground_items._arrange_tags` 的框高 `h`
由硬编码 `TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`
`TAG_LINE_H_PX = 15.0` 留作无字体兜底;`text_tail_arrange.gd``box_end_y(h) = h`)不动。
~~剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`
:733-736~~ 增量 125 已按参考把去重叠框度量 1:1;只对掉落 item 名条做。
**增量 125 已落地 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd += yOwnerSize + 4`**
参考 `CPythonTextTail::SetItemTextTailOwner``PythonTextTail.cpp:706-737`)—— owner 后缀是
与名字分开的 `CGraphicTextInstance`(文本 = `c_szName + strOwnership``IDS_POSSESSIVE_MORPHENE`
为空取字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`:735)、
`xStart = fMIN(xStart, -xOwnerSize/2-1)`:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`:737);
owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个
canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`
`owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0),
name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX`,框 x 对称故
`box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical)加
`string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` /
空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta`_arrange_tags`
把「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框;
渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩
`text_tail_arrange_test.gd`「5. item_box_with_owner」段 / `text_metrics_test.gd`
「6. string_*_like」段 / `p2b_test.gd``FakeClient.add_ground` 加可选 owner 入参),
回归 70 全 PASS(无新增测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、
`strOwnership` 未接本地化语素~~ 增量 126 已收口(见下);只对掉落 item 名条做。
**增量 126 已落地 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
接本地化语素)**:参考 `SetItemTextTailOwner``PythonTextTail.cpp:712-745`+ `ArrangeTextTail`
:191-207)—— owner 非空 → 新建 `pOwnerTextInstance`:715)、文本 = `c_szName + strOwnership`
:723`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...`
:722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`:727)、`SetColor(1,1,0)` 黄(:729);
排布时 owner 落 `(x, y)`:195)、名字实例落 `(x, y + 15.0f)`:200);owner 为空 →
`Delete(pOwnerTextInstance)`:743)。`ground_items`drop 节点下第二个 `Label3D "owner_tag"`
`horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate = Color(1,1,0)` / billboard
/ `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag` 建 /
删;名字 `tag` 只放 `_name_for(vnum)``_tag_text` 删)。新增 `const OWNER_NAME_GAP_PX := 15.0`
+ `_place_tail(n, tag, d_px)`:有 owner → `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y =
TAG_BASE_Y - (d_px + 15)*px`;无 → `tag.y = TAG_BASE_Y - d_px*px`。`setup()` 加可选
`possessive := ""``_possessive = possessive if possessive != "" else OWNER_POSSESSIVE`
1:1 对齐 :722 的 `== "" ? "'s" :` 语义),`game_scene` 从 `EterLocale` 的 `POSSESSIVE_MORPHENE`
键解析后注入(poc 无此键 → 回落 `"'s"`,与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE
"'s"` 一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner``yEnd += ownerH + 4`)不变。
测试扩 `p2b_test.gd`drop_302 名字实例 `"掉落28"`、`owner_tag` `"Alice's"` / CENTER / 黄 /
落基点、名字下移 `15 * pixel_size`;无 owner 的 drop_301 不建 `owner_tag`drop_401
`setup(possessive="@")` → `owner_tag` `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余
seam ⑩:owner 行颜色语义固定黄(参考名字实例取 `pInsertTextTail->Color` 品阶色、poc 名字
走 `_process` 远近着色)、去重叠只对掉落 item 名条做。
3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的
`ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。
**增量 110 已完成名字色本身**(`name_color.gd` + `net_world.gd` 接线,见本节参考的增量 110 条);
**增量 111 已完成称号色 `GetTitleColor` + 公会名 + 等级标签**(见本节参考的增量 111 条);
**增量 112 已完成聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail`**`chat_tail.gd` +
`net_world.gd` `"bubble"` 子节点,见本节参考的增量 112 条);
**增量 113 已完成名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail`
3500 裁剪**`name_show.gd` + `net_world._name_shown()`,见本节参考的增量 113 条);
**增量 114 已完成头顶伤害飘字 `AddDamageEffect` / `ProcessDamage` 分类 + `IsShowDamage` 门**
`damage_effect.gd` + `net_world._on_damage` / `_process_damage_queue` / `set_show_damage`
见本节参考的增量 114 条;seam ⑪);
**增量 115 已完成目标框 `RecvTargetPacket` 收包分派 + `CanViewTargetHP`**`target_board.gd`
+ `net_play._on_target_info` 按判定树分派 —— PC / building 目标不再显血条框、NPC / warp
不弹框、石头 / 敌对怪 `SetHPTargetBoard`、换 VID 先 Reset,见本节参考的增量 115 条;seam ⑫);
**增量 116 已完成 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` +
`net_world._hover_vid` / `set_hover_vid` / `_name_shown()` forced 第 5 路 +
`player_controller.hover_entity_changed` + `game_scene` 连线,见本节参考的增量 116 条;
seam ⑨ / ⑩);
**增量 117 已完成 seam ⑨ 收口(名字尾标随聊天气泡上移 17px,`PythonTextTail.cpp:112-114`**
`name_tail_layout.gd` + `net_world._reposition_name_tag` 由 `_apply_chat_tail` /
`_expire_chat_tails` 调用 —— 冒「聊天」气泡时名字 `Label3D` 局部 y 顶到「气泡 y +
17*pixel_size」、气泡消失或只是「信息」尾标时回默认 2.1,见本节参考的增量 117 条);
**增量 118 已完成目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`**
`target_board.hidden_as_other_empire(view_other_empire, same_empire)` +
`net_play._on_target_info` 在 `classify` 前先判 —— game-option 关「看他国目标框」且目标
异阵营(`EntityRules._is_same_empire` 为假,empire 0 的怪 / NPC / 石头恒同阵营不受影响)
→ `_clear_target()` 直接 return`net_play.set_view_other_empire_target()` 由 §8.4 radio
驱动,见本节参考的增量 118 条);
**增量 119 已完成 seam ⑦ 收口(子标签随名字尾标纵向位移,`ArrangeTextTail` :211/:228/:258/:283**
`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` +
`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一
`lift` 平移 `GuildTag`/`TitleTag`/`LevelTag`,气泡过期 / 「信息」尾标时回默认,见本节参考的增量 119 条);
**增量 120 已完成 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn``PythonTextTail.cpp:160-190` / :489-507**
(第 29 个 canonical `text_tail_arrange.gd``boxes_overlap``isIn` + `arrange_item_tails`
逐条 / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`
`ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y +
`game_scene` 传相机 getter,见本节参考的增量 120 条);
**增量 121 已完成 seam ⑦ 水平深化(角色子标签逐像素水平排版,`ArrangeTextTail` :211-320 非 EUROPE 分支)**
`name_tail_layout.gd` 加 `name_x_shift`fxAdd 8/4/0/ `title_x_offset``nameW/2 8`/
`level_x_offset`(有称号再 ` titleW`/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`
`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的
`.position.x``GuildTag` 留 0(poc 无公会徽记),文本宽 `字符数 * 7px` 估,与增量 117/119
纵向位移解耦,见本节参考的增量 121 条);
**增量 122 已完成 seam ⑫ 收口(目标框点击 `LCONTROL` 悄悄话捷径,`game.py SetPCTargetBoard :804-817`**
`net_play` 加 `signal whisper_requested(name)` / `var _lcontrol_down` / `set_lcontrol_down(down)`
`_on_pick` 在 `KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调
`target_board.whisper_on_ctrl_click(_lcontrol_down, _is_same_empire(_main_entity(), e),
vid == _main_vid, false)` 真则发 `whisper_requested``game_scene._unhandled_input` 加 `KEY_CTRL`
按下 / 松开 → `set_lcontrol_down(e.pressed)``net_play.whisper_requested` → `chat.start_whisper`
poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`);`is_building` 实参恒 false(门实体缺,
:814 分支永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 余留,见本节参考的增量 122 条);
**增量 123 已完成 seam ⑦ / ⑩ 收口(头顶子标签 / 掉落名条文本宽换真实字体度量 `GetTextSize` 1:1**
(新增纯静态 `text_metrics.gd` 第 30 个 canonical`text_size` = `Font.get_string_size(text,
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`、`width_of`(无字体回退 `len * 7.0`)、`label_width`
`label.font ?? ThemeDB.fallback_font`);`net_world._layout_subtags_x` 的 `name_w` / `title_w`、
`ground_items._arrange_tags` 的框宽 `w` 由 `字符数 * *_CHAR_W_PX` 换 `TextMetrics.label_width`
`*_CHAR_W_PX = 7.0` 留作无字体兜底;`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动,
见本节参考的增量 123 条);
**增量 124 已完成 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量 `GetTextSize`
的 h 1:1**`text_metrics.gd` 仍第 30 个 canonical、扩非新增:加 `const FALLBACK_LINE_H_PX
:= 15.0` + `height_of`(空串 → 0;量得正高用 `text_size(...).y`;否则回退)+ `label_height`
`label.font ?? ThemeDB.fallback_font`);`ground_items._arrange_tags` 的框高 `h` 由硬编码
`TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))``TAG_LINE_H_PX
= 15.0` 留作无字体兜底;`text_tail_arrange.gd``box_end_y(h) = h`/ `name_tail_layout.gd`
排布公式不动,测试扩 `text_metrics_test.gd`,回归 70 全 PASS,见本节参考的增量 124 条);
**增量 125 已完成 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd +=
yOwnerSize + 4` 1:1**`text_tail_arrange.gd` 扩第 29 个 canonical:加纯静态
`item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)` —— 无 owner → 纯名字框,
有 owner → `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}``box_end_x(w)` 恰等于参考
`SetItemTextTailOwner` :737 的 `fMAX(name/2+2, owner/2+1)`);`text_metrics.gd` 扩第 30 个
canonical:加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串);
`ground_items._on_added` 存 `item_name` / `item_owner` meta、`_arrange_tags` 把名字子串与
owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"`
`Label3D`seam ⑩ 折中);测试扩 `text_tail_arrange_test.gd` / `text_metrics_test.gd` /
`p2b_test.gd`,回归 70 全 PASS,见本节参考的增量 125 条);
**增量 126 已完成 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
接本地化语素)**`ground_items`drop 节点下第二个 `Label3D "owner_tag"`CENTER /
`modulate = Color(1,1,0)` / 文本 = `owner + _possessive``_apply_owner_tag` 建 / 删),
名字 `tag` 只放名字(`_tag_text` 删);`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail`
有 owner → owner 落 `TAG_BASE_Y - d_px*px`、名字落 `TAG_BASE_Y - (d_px + 15)*px`(参考
`ArrangeTextTail` :195 / :200);`setup()` 加可选 `possessive``== "" ? OWNER_POSSESSIVE`
对齐 `SetItemTextTailOwner` :722),`game_scene` 从 `EterLocale` `POSSESSIVE_MORPHENE` 键注入
(无则回落 `"'s"`);去重叠框度量仍是增量 125 的 `item_box_with_owner`;测试扩 `p2b_test.gd`
回归 70 全 PASS,见本节参考的增量 126 条);
`text_tail.gd` 的屏幕空间血条整合已由增量 127 收口;掉落 owner 行品阶色也已由增量 127
接入。当前仍待:三子标签并到名字同一屏幕行 / 接公会徽记 mark(seam ⑦ 末尾)、名字与气泡
合成单尾标链表(seam ⑨),以及真实服务器 / 真机验收。建筑 / 木门分类和独立 whisper dialog
也已由增量 127 接入,不再列为未实现。
4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。
---
### 8.9 任务日志导航和完整图标视觉仍不完整
增量 101 已在 §5.2 补齐默认 / 服务端 icon、5 行窗口、滚动起始索引、倒计时和任务行事件;
增量 108 已补齐小地图任务信标的世界箭头 / 屏幕边缘指示(越界夹圆周 + 方向箭头,见 §5.2 条 3);
增量 109 已按 `interfacemodule.__ArrangeQuestButton` 补齐信件按钮条多列定位 + 最新在前 + 组队窗
`xPos` 移位(见 §5.2 条 3、C.5 增量 109)。当前仍缺任务日志窗体 `_list` 自身的多列 / 分页、
真实 `.sub` 状态图 / Flash 视觉和服务端 `iStartTime` 生命周期。
不得再把 `project/ui/quest_log.gd` 描述成纯文字占位列表。
---
## 9. P1/P2:音频和应用生命周期
### 9.1 地图 BGM 字段被解析但没有消费
**参考**
- 解析与存储(`REF/UserInterface/PythonNetworkStreamPhaseLoading.cpp`):
```
RecvMainCharacter3_BGM() :244 → __SetFieldMusicFileName(mainChrPacket.szBGMName) // :261
RecvMainCharacter4_BGM_VOL() :271 → __SetFieldMusicFileInfo(szBGMName, fBGMVol) // :288
```
```cpp
// PythonNetworkStreamPhaseLoading.cpp:299
static std::string gs_fieldMusic_fileName;
static float gs_fieldMusic_volume = 1.0f / 5.0f * 0.1f; // = 0.02f 默认值
void __SetFieldMusicFileName(const char* n) { gs_fieldMusic_fileName = n; }
void __SetFieldMusicFileInfo(const char* n, float vol) { gs_fieldMusic_fileName = n; gs_fieldMusic_volume = vol; }
const char* GetFieldMusicFileName() { return gs_fieldMusic_fileName.c_str(); }
float GetFieldMusicVolume() { return gs_fieldMusic_volume; }
```
- 消费(`POC/assets/root/game.py:190``GameWindow.Open()` 内):
```python
snd.SetMusicVolume(systemSetting.GetMusicVolume())
snd.SetSoundVolume(systemSetting.GetSoundVolume())
netFieldMusicFileName = net.GetFieldMusicFileName()
if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName)
elif musicInfo.fieldMusic != "": snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
```
关窗(`game.py:225`):`if musicInfo.fieldMusic != "": snd.FadeOutMusic("BGM/" + musicInfo.fieldMusic)`。
- 默认曲目(`POC/assets/root/musicinfo.py`):
```python
METIN2THEMA = "M2BG.mp3"
loginMusic = "login_window.mp3"
createMusic = "characterselect.mp3"
selectMusic = "characterselect.mp3"
fieldMusic = METIN2THEMA
SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf"
```
**当前**`POC/extension/src/net/classic/classic_parser.cpp:173` 丢弃 BGM 名与音量。
**修改**
1. `ClassicParser` 保存 `field_music_name` / `field_music_volume`,默认音量
`1.0f/5.0f*0.1f = 0.02f``M2Client` 暴露 `get_field_music_name()` / `get_field_music_volume()`。
2. `game_scene.gd` 进图时按 `game.py:190` 的三行逻辑选 BGM(服务端名优先,
否则 `musicInfo.fieldMusic`),并先 `SetMusicVolume/SetSoundVolume`。
3. `musicinfo.gd` 落 `METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic`
五个常量与 `save_last_play_field_music / load_last_play_field_music`
(文件路径 `user://BGM/lastplay.inf`)。
4. 换图(§1.3)走同一条选曲逻辑。
**状态**:✅ 完成(曲目解析 + 音量在 Godot 侧落地;C++ 侧 BGM 名/音量路由与保留由增量 49 完成)。
真服验收待 40250 服务端。
**改动**2026-09-02 / W4
- `project/bgm_director.gd``_on_bgm_changed` / 新增 `_on_world_reset` → `_select()`
`_select()` 实现 `game.py:190` 三行逻辑——服务端 `current_track` 非空即播,为空回落
`MusicInfo.fieldMusic``METIN2THEMA = "M2BG.mp3"`);音量 `current_volume >= 0` 用服务端值,
否则 `DEFAULT_FIELD_MUSIC_VOLUME = 1.0/5.0*0.1 = 0.02`;先 `master_bgm = vol` +
`set_music_volume(vol)` 再 `play_bgm(track)`(淡入前置音量);`bind()` 连
`M2Client.world_reset` 做换图重选。声音(SFX)音量参考取自系统设置,不随 BGM 包下发,
故不改 `master_sfx`。
- `project/musicinfo.gd`(新):常量 `METIN2THEMA / loginMusic / createMusic / selectMusic /
fieldMusic`;静态 `save_last_play_field_music(name)` / `load_last_play_field_music()`
持久化到 `user://BGM/lastplay.inf`。
**测试**`godot --headless --path project --script res://bgm_test.gd` → PASSexit 0)。
覆盖:服务端名+音量、名+音量未指定(默认 0.02)、名为空回落 `fieldMusic`、`world_reset`
重跑选曲、`lastplay.inf` 往返。`gamescene_test` / `netplay_test` / `p9_test` 回归全绿。无 C++ 改动。
**验收**:未验证(无真实 40250 服务端;地图 BGM 名 / 音量需服务端 `GC_MAIN_CHARACTER3/4` 数据)。
---
### 9.2 后台恢复后可能没有 BGM
**参考**:参考端是 Windows 桌面客户端,没有 Android/iOS 的前后台切换语义——
本条**没有 1:1 参考**。最接近的参考行为是
`CSoundManager::SaveVolume() / RestoreVolume()``REF/MilesLib/SoundManager.h:31`),
它保存/恢复 `m_fBackupMusicVolume` / `m_fBackupSoundVolume`。
**修改**(按参考的 Save/Restore 语义,而不是 stop/replay
1. `POC/project/app_lifecycle.gd:67` 的 `_enter_background()` 把
`_bound_audio.stop_bgm()` 改为 `_bound_audio.save_volume()` + 静音
(等价 `SaveVolume()` 后 `SetMusicVolume(0)`)。
2. `_exit_background()``app_lifecycle.gd:75`)加 `_bound_audio.restore_volume()`。
3. `Audio` 增加 `save_volume() / restore_volume()`,字段名对齐
`m_fBackupMusicVolume / m_fBackupSoundVolume`。
4. 这样 `_bgm_name` 不再被清空,曲目继续播放,恢复后音量还原。
**状态**:✅ 完成。
**改动**2026-09-02 / W4
- `project/audio.gd`:新增 `_backup_music_volume` / `_backup_sound_volume`(对齐参考
`m_fBackupMusicVolume / m_fBackupSoundVolume`+ `_volume_saved` 守卫;`save_volume()`
存 `master_bgm/master_sfx``restore_volume()` 还原并把音量重新贴到当前 BGM 声道;
新增 `set_music_volume(v)`(立即把 `master_bgm` 应用到正在播放的声道,无淡入)。
- `project/app_lifecycle.gd``_enter_background()` 把 `_bound_audio.stop_bgm()` 换成
`save_volume()` + `set_music_volume(0.0)`(等价 `SaveVolume()` 后 `SetMusicVolume(0)`);
`_exit_background()` 加 `restore_volume()`。`_bgm_name` / `_bgm[]` 全程不动。
**测试**`bgm_test.gd` 的 `_test_background_save_restore`——`_enter_background()` 后
`master_bgm == 0`、备份为 `0.6/0.9`、`_bgm_name` 不变;`_exit_background()` 后
`master_bgm/master_sfx` 还原为 `0.6/0.9`、`_bgm_name` 仍不变、备份标志清除。PASS(exit 0)。
**验收**:未验证(需真机 Android/iOS 前后台切换;本条无 1:1 桌面参考)。
---
### 9.3 生命周期所有权重复
**状态**:✅ 代码与离线回归完成(2026-09-02 / W0 / 增量 51);🔎 真机通知和断线重连仍待验收。
**参考**:生命周期协调责任在 `CPythonApplication``REF/UserInterface/PythonApplication.cpp`
+ `PythonApplicationEvent.cpp`),网络流只是被它驱动的子系统,不应再独立消费同一组应用通知。
**已实现**
1. `project/app_flow.gd:56-62` 在生产入口 `client_main.gd` 下创建并持有唯一 `AppLifecycle`,从
登录、选人到游戏都不随界面切换销毁;`_goto_login()` 会先解除旧场景音频绑定,
`_goto_game()` 在场景装配完成后绑定当前 `Audio``app_flow.gd:143-181`)。
2. `project/game_scene.gd:181-184` 不再创建 `AppLifecycle`,只提供 `lifecycle_audio()`:892-895
让 AppFlow 绑定当前场景音频;因此切屏不会累积暂停 / 恢复监听器。
3. `extension/src/net/m2_client.cpp:2620-2623` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED`
入口,`M2Client` 仅保留可被协调者调用的 `suspend()/resume()`;头文件同步移除声明。
4. 兼容启动器 `project/login.gd:33-37,142-152` 不属于生产 `AppFlow` 路径,但仍保持自己的单一
生命周期节点贯穿登录到游戏,进入游戏时重新绑定 `M2Client + Audio`,不再错误销毁节点。
**测试**
- `godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log --script app_flow_lifecycle_test.gd`
→ PASS:AppFlow 只有一个生命周期节点,登录构建后仍存在,客户端由同一 AppFlow 持有。
- `godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log --script gamescene_test.gd`
→ PASSGameScene 不创建第二个节点且暴露场景音频。
- 两个 Godot 进程不能并行共用默认 `user://logs`;首次并行运行触发共享日志 / 图形上下文冲突,
已改为带独立 `--log-file` 的串行回归并通过。该失败不是代码断言失败,后续测试保持串行。
**验收**:未验证 Android/iOS 真机前后台、内存告警和后台约 30 秒后 TCP 被系统回收时的重连;
这些属于平台门禁,不在 headless 回归中伪标为完成。
**下一步**:由 W5 / 平台验收补真机证据;若需要产品级自动重连策略,应另立增强项,不把它混入
ClientVS22 的“断线回登录”1:1 判据。
---
### 9.4 MilesLib 事件模型没有迁移
**参考**`CSoundManager``REF/MilesLib/SoundManager.h`)的完整接口:
```
// 3D 监听者
SetPosition(x,y,z) SetDirection(xDir,yDir,zDir, xUp,yUp,zUp) Update()
// 缩放与音量
float m_fSoundScale = 200.0f; // SoundManager.cpp:20
GetSoundScale / SetSoundScale / SetAmbienceSoundScale
SetSoundVolume / SetSoundVolumeRatio / SetSoundVolumeGrade(int)
SetMusicVolume / SetMusicVolumeRatio / SetMusicVolumeGrade(int)
SaveVolume / RestoreVolume / GetSoundVolume / GetMusicVolume
// 音量换算(SoundManager.cpp:245/250
__ConvertGradeVolumeToApplyVolume(grade) = __ConvertRatioVolumeToApplyVolume(grade / 5.0f)
__ConvertRatioVolumeToApplyVolume(r) = (r < 0.1f) ? r : pow(10.0f, -1.0f + r)
// 播放
PlaySound2D(file)
PlaySound3D(x,y,z,file, playCount=1) StopSound3D(index)
PlayAmbienceSound3D(x,y,z,file, playCount=1)
PlayCharacterSound3D(x,y,z,file, bCheckFrequency=FALSE)
SetSoundVolume3D(index, volume) StopAllSound3D()
// 音乐(MUSIC_INSTANCE_MAX_NUM 个通道)
PlayMusic(file)
FadeInMusic(file, volumeSpeed = 0.016f)
FadeOutMusic(file, volumeSpeed = 0.016f)
FadeLimitOutMusic(file, limitVolume, volumeSpeed = 0.016f)
FadeOutAllMusic() FadeAll()
EMusicState { OFF, PLAY, FADE_IN, FADE_OUT, FADE_LIMIT_OUT }
// 动作声音节点(.msa 的 sound 事件)
UpdateSoundData(curFrame, soundDataVector)
UpdateSoundData(x,y,z, curFrame, soundDataVector)
UpdateSoundInstance(x,y,z, curFrame, soundInstanceVector, bCheckFrequency=FALSE)
UpdateSoundInstance(curFrame, soundInstanceVector)
```
3D 定位(`SoundManager.cpp:349` / `:415`):
```cpp
pInstance->SetPosition((fx - m_fxPosition) / m_fSoundScale,
(fy - m_fyPosition) / m_fSoundScale,
(fz - m_fzPosition) / m_fSoundScale);
```
即**世界坐标差 ÷ 200** 才是音频引擎坐标。
角色声音去重(`PlayCharacterSound3D``SoundManager.cpp:380`,仅当 `bCheckFrequency`):
```
static float s_fLimitDistance = 5000*5000; // 距离平方上限
dx² + dy² > s_fLimitDistance → 不播
同一文件距上次播放 < 0.3f 秒 → 不播
否则记录 m_PlaySoundHistoryMap[file] = now
```
**当前(增量 55/63/67**:已实现 MilesLib 可复用子集:BGM crossfade、UI 音效池、普通 / 环境 /
角色 3D 声道、监听器位置 / 方向、200/1000 缩放、参考音量曲线、角色 5000cm / 0.3 秒限频、3D
实例 stop / 全停和音乐限幅淡出;`.msa` 本地动作声音以及 `.mss` 动作声音时间表已接入角色声道。
**已实现**
1. `POC/project/audio.gd` 已按上表补齐可复用 API 与常量:
`SOUND_SCALE = 200.0`、`FADE_SPEED = 0.016`、`CHAR_SOUND_MIN_INTERVAL = 0.3`、
`CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`;另有 `AMBIENCE_SOUND_SCALE = 1000.0`。
2. `SetPosition/SetDirection/Update` 已由 `set_listener/update_listener` 提供,3D 音效位置按
`(world - listener) / scale` 计算,活动实例会在监听器移动时重新基准。
3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现
`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio
系统设置现有滑条继续保存线性应用值,另提供 `set_sound_volume_ratio/grade`、
`set_music_volume_ratio/grade` 供协议入口使用。
4. 三类声道已分开:普通 3D`play_sound_3d`)、环境(`play_ambience_sound_3d`
有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。
5. `game_scene.gd` 的 `.msa` sound 事件已走 `play_character_sound_3d(..., check_frequency=true)`
对齐角色动作声音的频率保护。
6. 已提供 `set_sound_volume_3d`、`stop_sound_3d`、`stop_all_sound_3d`,音乐支持
`fade_limit_out_music` 与 `fade_out_all_music`。
7. 增量 63 已按 `REF/MilesLib/Type.cpp:17-74,118-136` 实现 `.mss`:解析
`SoundDataCount` / `SoundData%02d` 的时间和虚拟声音路径,按 `DWORD(time / (1/60))` 转为声音
实例;`sound/` 前缀会映射到实际 patch 的 `sound` 根。`load_mss_for_motion()` 根据 `.msa`
的 `ymir work` 相对路径找到配对脚本,`update_sound_instances_3d()` 在动作帧进入时按参考
`UpdateSoundInstance` 触发 `PlayCharacterSound3D`。PlayerView / MobView 都已接入。
**仍未完成**
8. `UpdateSoundData/UpdateSoundInstance` 的 `.mss` / sound-node 数据结构仍需继续补齐:当前已覆盖
`.mss` 文件读取、实例帧换算和动作帧播放,但还没有把 EffectInstance 的完整生命周期 / 节点
停止语义全部接到 Godot。
9. 增量 6872 已按 `REF/GameLib/MapUtil.cpp:1-205`、`REF/GameLib/MapOutdoor.cpp:246-323` 和
`REF/EterLib/SkyBox.cpp:403-542` 补齐 `.msenv` 解析字段与参考缺省值、背景 / 角色双方向光
分层、texture-mode 六面天空,以及真实云层贴图到 `ProceduralSkyMaterial.sky_cover` / sky shader
`CloudScale` / `CloudHeight` / `CloudTextureScale` / `CloudSpeed` 已映射到 texture-mode 云面,
原始参数已通过 `WorldEnvironment` 元数据保留。仍需完成云层渐变过渡、滤色实际合成、SpeedTree
完整风语义、lens flare、昼夜插值,以及武器 / 技能 / 怪物 / NPC 所有声音事件。
10. 增量 67 已按 `REF/GameLib/Area.cpp:882,1316-1394` 与
`REF/GameLib/MapType.cpp:195-260` 接入地图环境声:C++ 世界层解析每个已加载区块的
`AreaAmbienceData`(兼容大小写文件名),通过 `.pra` CRC 提供位置、范围、最大音量区域比例、
播放类型、间隔和声音向量;该阶段独立于对象渲染和 splat 开关,并在区块卸载时移除来源。
`audio.gd` 对齐参考的严格 `< range` 判定、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 / 随机
变化和离开区域停止;世界米坐标先还原为厘米再应用 Miles 的 3D 缩放。`ambience_test.gd`
使用真实 OutdoorA3 区块和 `warp_test.mp3` 通过。
---
## 10. 发布和验证问题
> 本节没有 `ClientVS22` 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。
> 因此这里只写**可执行的动作**,不假装有 1:1 依据。
### 10.1 Mac App 不是自包含资源包
**当前(增量 53**`build-macos-client.sh` 已改为把 `assets` / `bgm` 用 `ditto` 复制到
`Contents/Resources/`,并检查 `assets/asset_index.txt``MT_ASSETS` 只作为开发覆盖路径。
代码路径已完成,但尚未执行完整 2.2G 资源导出和跨机器启动验收。
**剩余验收**:把 `.app` 拷到另一台机器(或暂时移开工作区资源)后启动并进图;确认包内所有
资源读取都没有回退到工作区。
### 10.2 主程序和 GDExtension 架构不一致
**当前(增量 53**`build.sh` / `build-macos-client.sh` 支持 `MT_MAC_ARCHES=arm64`、
`x86_64` 或 `universal`universal 模式传入 `CMAKE_OSX_ARCHITECTURES=arm64;x86_64`,打包脚本
用 `lipo -info` 拒绝单架构扩展。
**剩余验收**:当前已检查的 `project/bin/libmtgodot.macos.template_debug.dylib` 仍为 arm64
需在可用的双架构工具链和完整导出模板下重建,并分别在 Apple Silicon / Intel Mac 启动。
### 10.3 当前是 Ad-hoc 签名
**当前(增量 53)**:脚本支持 `CODESIGN_IDENTITY`,按 Contents 内嵌扩展先签、App 后签,
并传入 hardened runtime 选项;默认身份仍为 `-`(Ad-hoc),脚本未伪称正式发布。
**剩余验收**:配置真实 Developer ID 后执行签名、公证和装订,并用
`spctl -a -vvv -t install <app>` 验证;当前没有证书、notarization 或 stapled ticket 证据。
### 10.4 发布构建仍包含 Demo 默认账号密码
**当前(增量 53**`app_flow.gd`、`login.gd`、`charselect.gd` 已移除硬编码账号密码;
账号 / 密码从 `MT_ACCOUNT` / `MT_PASSWORD` 读取,自动登录还要求 `MT_AUTOLOGIN=1`。
**剩余验收**release 预设的 PCK / 导出包加入字符串扫描门禁,确认不包含旧 Demo 密码。
### 10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策
**参考侧的事实**(用于做决策,不是要求实现):
- `REF/UserInterface/PythonApplicationLogo.cpp`、`MovieMan.cpp`、
`PythonApplicationWebPage.cpp` + `REF/CWebBrowser/`、`REF/Discord/`
`ENABLE_DISCORD_RPC` 在 `REF/UserInterface/Locale_inc.h:6` 是**注释掉的**)、
`CheckLatestFiles.cpp`(补丁器)。
- 反作弊四件套见 §1.6,已在不做清单。
**修改**
1. 在 `CLIENT-GAP.md` 的不做清单里逐项定性:
Logo / Movie / Web / Discord(参考默认关闭)→ ❌;
补丁器(`CheckLatestFiles`)→ 单独立项或 ❌。
2. 决策写进 `CLIENT-ROADMAP.md`,避免每轮审计重复出现。
### 10.6 缺少真实验收门禁
**修改**:建立 `POC/docs/ACCEPTANCE.md`,每项写「前置条件 / 步骤 / 通过判据」:
1. 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。
2. 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。
3. 断线重连:拔网线 → 回登录页(§1.2)。
4. 纯 pack 运行:`MT_ASSETS_PACK_ONLY=1`(§7.2)。
5. Intel Mac(§10.2)。
6. 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约
(见 `PARITY-GAP.md §0`,该门禁已存在,保持)。
7. 移动端真机性能。
---
## 附录 A:本次审计对 `CLIENT-GAP.md` 的订正
对照参考源码后,`CLIENT-GAP.md`2026-09-02 审计节)有以下条目需要订正或加限定。
**订正分三档**:❶ 参考端行为与原文不符 · ❷ 原文方向对但表述需加限定 ·
❸ 原文低估了差距(当前实现另有问题)。
| 原条目 | 档 | 订正后的说法 | 依据 |
|---|---|---|---|
| 协议覆盖审计 | ❶+❸ | `Packet.h` 是按 To Server / From Server 分段的全局 enum,不是 `namespace CG/GC`。主文档此前把 m2dev `wire.h` 与默认 Classic 混算,并把历史统计写成当前结论;应按 `MT_PROTOCOL=classic` 与 `MT_PROTOCOL=m2dev` 分开。当前默认 Classic 的参考编号是 `CG_CHARACTER_CREATE=4`、`CG_MYSHOP=55`、`CG_WARP=65`、`CG_FISHING=82`、`CG_DUNGEON=110`,而不是 `0x0201`、`0x0802`、`0x0B01` 等 m2dev 编号。 | `REF/UserInterface/Packet.h:25`、`POC/extension/src/net/classic/wire_classic.h:104`、`POC/project/client_main.gd:11` |
| `CG_TEXT` | ❶ | `CG_TEXT=64 (0x40)`,不是 `0x0011`。它存在于 40250 Server / Classic wire,但不在 ClientVS22 客户端普通 To Server 命名项中,不应计入 ClientVS22 客户端 header 总数。 | `40250/Server/.../packet.h:49`、`REF/UserInterface/Packet.h:88-93` |
| 拍卖行 | ❶ | 默认构建未见完整 UI / handler,但 `__AUCTION__` 条件下仍有 `CG_AUCTION_CMD=205` 和 GC 列表包 `=206`;当前 40250 默认服务配置关闭该宏,因此当前不做。不能写成全源码 / 协议 / 资产零命中。 | `REF/UserInterface/Packet.h:158-160,354-356`、`40250/Server/.../service.h:8` |
| `CG_HACK` | ❶ | 参考编号是 `105 (0x69)`,当前 Classic 也使用该值;不做决定可以保留,但编号必须更正。 | `REF/UserInterface/Packet.h:134-136`、`POC/extension/src/net/classic/wire_classic.h:162` |
| 物品 header 变体 | ❷ | `GC_ITEM_DEL/SET` 的 20/21 映射受 `GAIDEN` 条件编译影响;当前 40250 Server 使用 `DEL=20、SET=21`,文档应明确目标构建变体,不能概括为无条件的 ClientVS22 语义。 | `REF/UserInterface/Packet.h:191-197`、`40250/Server/.../packet.h:150-153` |
| `GC_ITEM_DROP` | ❷ | ClientVS22 保留 `GC_ITEM_DROP=23` 声明但没有注册 / 消费,实际掉落流程使用 `GC_ITEM_GROUND_ADD=26`。应标记为“保留声明 / 未消费”,不能宣称两个包逐字节等价。 | `REF/UserInterface/Packet.h:199-202`、`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:332` |
| §1.2 | ❷ | 参考端确实**没有**重连遮罩 / 倒计时,`OnRemoteDisconnect()` 只调 `SetLoginPhase()`。但「删掉 `reconnect_ui.gd`」只是**严格 1:1 时的选择,不是必然修复**;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 `disconnected``AppFlow` 无条件回 LOGIN;真实 TCP 断线仍待验收。 | `PythonNetworkStreamEvent.cpp:4` |
| §2.7 | ❷ | `RecvWarpPacket()` 确实**无条件**按 `lAddr / wPort` 重连,`same_server()` 不是参考端逻辑。但应表述为「**`RecvWarpPacket()` 内不直接使用 `lX/lY`**」——这两个坐标在重连后的 `GC_MAIN_CHARACTER*` 阶段仍参与角色加载定位,不是「完全没用」。 | `PythonNetworkStreamPhaseGame.cpp:923` |
| §3.10 | ❶+❸ | 本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓迁移资产中的 `uicharacter.py` 仍显示参考脚本通过 `net.SendChatPacket("/skillup " + str(skillIndex))` 发包,因此“应改成非命令路径”不成立,也不能仅据此断定当前应传 skill vnum。当前明确差距是 C++ 技能合法性 / 等级上限逻辑和 UI 加号按钮门控、失败提示、升级后状态刷新不完整。 | `POC/assets/root/uicharacter.py:1022`、`REF/UserInterface/PythonSkill.cpp:1833` |
| §4.8 | ✅(增量 53/54 已修复首版) | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」;`quickbar.gd` / `M2Client` 现已统一 1/2/34`SLOT_TYPE_SHOP`)不执行。客户端显示 4×8,服务器保留 36 槽;技能、表情、F1–F4 / 数字键和 inventory → quickslot 投放已接通。仍未完成复杂窗口之间的完整物品态、stack split 和真实服务端验收。键位表仍来自迁移资产,不冒充 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309`、`POC/project/ui/quickbar.gd`、`POC/project/ui/mouse_controller.gd` |
| §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` |
| §8.6 | ✅(增量 53/54 已接通主入口) | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;`project/game_scene.gd` 已接通表中可映射的按下 / 松开入口,并有 `input_key_test.gd`;窗口键盘焦点屏蔽、PICK / TALK / ATTACK 首版光标和物品拖放已接入。macOS 物理 F1–F12 是否直接产生功能键事件仍受系统功能键设置 / Fn 键影响,应作为平台验收条件;复杂窗口 mousemodule 分支和 CJK IME 仍是独立差距。 | `POC/assets/root/game.py:309-408`、`POC/project/game_scene.gd`、`POC/project/input_key_test.gd`、`POC/project/ui/mouse_controller.gd` |
| §1.5 | ✅ | **属实,保留**。参考这份 `ClientVS22``Locale_inc.h` = `LOCALE_SERVICE_SINGAPORE` → `LSS_EUROPE`)发 `HEADER_CG_CLIENT_VERSION2 = 0xf1` + `"1215955205"`;当前实现发 `0xfd` 却用了 0xf1 分支的 timestamp。另外当前 `filename` 固定 `"metin2.bin"`,参考端用**实际可执行文件名**(`GetExcutedFileName` + `NoPath`)。 | `PythonNetworkStreamPhaseGame.cpp:4155`、`Locale.cpp:186` |
**§1.7 / §1.8 的订正属实并保留**,但补一条落地限定:
参考端对未知 header 是 `ClearRecvBuffer()` + `PostQuitMessage(0)`,对阶段内未处理 header 是
`TraceError` + `ClearRecvBuffer()`。移植到 Godot 时**不必字面调用 `PostQuitMessage(0)`**——
应映射为**明确的协议错误 + 断线处理**(走 §1.2 的 `disconnected` → 回登录页),
关键是保住「不做启发式重同步、不静默吞包」这两条语义。
## 附录 B:本文标注 `[待读参考]` 的条目
以下细节本次没有读到参考源码,实现前必须先读,**不得按经验补写**:
| 条目 | 应读文件 |
|---|---|
| §2.5 affect 位号 | `REF/UserInterface/InstanceBase.h` 的 `enum EAffect` |
| §3.1 `MOVABLE_GROUND_DISTANCE` 默认值 | `POC/assets/root/game.py` / `playersettingmodule.py` |
| §3.3 攻速对动作速率的影响、`__GetBowRange` | `REF/UserInterface/InstanceBase.cpp` / `InstanceBaseBattle.cpp` |
| §3.6 `OnGetFlyTargetPosition` | `REF/GameLib/ActorInstanceFly.cpp` |
| §3.8 `TSkillData` 字段与加载 | `REF/UserInterface/PythonSkill.{h,cpp}` |
| §3.10 `CanLevelUpSkill` / `GetSkillLevelLimit` | `REF/UserInterface/PythonSkill.cpp` |
| §4.1 换装遮挡规则 | `REF/UserInterface/InstanceBase.cpp` 的 `ChangeArmor/SetArmor` |
| §4.3 装备对比 | `POC/assets/root/uitooltip.py` |
| §4.4 仓库金钱包/命令 | `REF/UserInterface/PythonNetworkStreamModule.cpp` |
| §4.9 `affects[7]` 槽位语义 | 40250 **服务端** `game/src/party.cpp` |
| §5.4 `ATTRIBUTE_BANPK` 等地形属性 | `REF/GameLib/TerrainImpl``MapType.h` / `TerrainPatch.h` |
| §6.3 SimpleLight / TexAni 完整语义 | `REF/EffectLib/SimpleLightData.{h,cpp}`、`SimpleLightInstance.cpp`、`ParticleProperty.{h,cpp}`、`ParticleSystemInstance.cpp` |
| §6.3 骨骼挂特效 | `REF/GameLib/ActorInstanceAttach.cpp` |
| §6.5 `ChangeWeapon` 的 detach | `REF/UserInterface/InstanceBase.cpp` |
| §6.6 材质 stage / alpha-test ref | `REF/EterGrnLib/Thing.cpp`、`Material.cpp` |
| §7.1 `abyPortalID` 消费点、`m_fHeightBias` | `REF/GameLib/Area.cpp` |
| §7.3 地形渲染逐项 | `REF/GameLib/MapOutdoorRenderSTP.cpp` / `RenderHTP.cpp` / `TerrainPatch.cpp` |
| §7.4 水面逐项 | `REF/GameLib/MapOutdoorWater.cpp` |
| §7.5 昼夜插值 / `TEnvironmentDataMap` 键 | `REF/GameLib/MapOutdoor.cpp` |
| §8.4 雾等级实参 | `POC/assets/root/uisystemoption.py` |
| §8.8 `EMPIRE_NUM` | `REF/UserInterface/GameType.h` / `Packet.h` |
| §9.4 `.mss` 解析 | `REF/MilesLib/SoundData.cpp` |
---
## 附录 C:并行实施计划、依赖门禁与进度看板
本附录取代旧版“按 1–12 步串行实施”的顺序。条目仍按依赖关系分组,但同一波次内的工作流可以
并行;只有公共接口和集成门禁需要排队。具体的文件主维护者、状态定义和同步要求见本文
《并行开发与进度同步协议》以及 `CLIENT-GAP.md` 顶部的同名章节。
### C.1 公共门禁
| 门禁 | 目标 | 通过条件 | 当前状态 |
|---|---|---|---|
| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结、增量 51 生命周期属主、增量 52 参数边界已落地;真实双连接、真机通知和部分平台行为仍待验收 |
| G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 1W1/W2/W3/W44 个并行工作流提交后执行 |
| G2 真实 / 发布验收 | 验证参考链路与可交付 Mac App | 40250 真实连接 / 换图、适用的双账号玩法、资源包、架构、签名和验收记录齐全 | ⬜ 未开始;受限环境 `skip` 不算通过 |
### C.2 可并行工作流
| 工作流 | 对应条目 | 本批次可独立交付 | 依赖 | 集成时必须提供 |
|---|---|---|---|---|
| W1 协议与账号会话 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | wire 尺寸 / parser、错误处理、loopback、账号阶段测试 | G0 的会话接口;真实服务端不阻塞本地开发 | 包字节证据、阶段转换、unknown / unhandled 处理、真实验收缺口 |
| W2 实体、世界与战斗 | §2–§3、移动、动作、技能、目标、换图 | EntityStore / NetWorld / PlayerController、动作 fixture、战斗与实体测试 | G0 的实体快照和 `world_reset` | 节点生命周期、坐标 / 状态语义、受影响测试和性能风险 |
| W3 玩法 UI、输入与本地化 | §4–§5、§8、背包、任务、社交、IME、键位 | 独立 UI、输入映射、CJK / 键位测试、fixture 驱动回归 | M2Client 公开信号;无真服时可用 fixture | 对应窗口状态、发送包、平台差异和视觉未验收项 |
| W4 渲染、资产与音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM | 单项渲染 / 资产解析、效果和音频生命周期测试 | G0 的场景 / 地图钩子;可用最小资源 | 参考参数、资产路径、帧 / 内存风险、地图切换清理证据 |
| W5 打包、平台与验收 | §10、Mac App、架构、签名、发布矩阵 | debug / release 打包、资源自包含检查、签名和启动检查 | 可与 W1–W4 并行;G2 消费集成产物 | 包路径、架构、签名、运行日志和失败原因 |
### C.3 当前进度初始化
| 工作流 | 当前状态 | 已有证据 | 下一批次 |
|---|---|---|---|
| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结 + 增量 51 单一 `AppLifecycle` + 增量 52 `net_bounds` 已落地,当前 C++ `ctest` 15/15 + 生命周期 / 场景回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1;真机生命周期验收 |
| W1 协议 / 账号会话 | 🟡 开发中(增量 50:§1.5 / §1.10 / §2.2 已落地,待真实验收) | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;§1.5 locale 分支 + `CGClientVersion2`(0xf1);§1.10 `text_codec.h` + `to_wire` 路由 + `net_text_codec_test`(套件 16→17);§2.2 `GC_CHARACTER_ADD` 隐身丢弃 / PC·NPC 暂存 / 其余按 `npclist.txt` 命名立即 spawn`GC_CHAR_ADDITIONAL_INFO` 无 pending 丢弃、命中则 `mut_spawn_full` 合并;G1 已接线 `set_executable_name` / `set_npclist_path` / `mut_affect_flags` | locale 代码页转码(CP949/CP1252)、真实双连接、进场延迟 |
| W2 实体 / 世界 / 战斗 | 🟡 开发中(Phase 1 批次已交) | 增量 50:§2.1 两层模型(`EntityStore` 纯数据层 + `net_world._update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3`mut_spawn`→`mut_spawn_full` 整行替换 + 节点重建 + 上下马保坐标)、§2.4(点数快照清尾 / 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序刷新)已落地;`ctest` 16/16 + 7 项 GDScript 回归 + 新增 `net_world_vis_test` 全绿 | 远服端到端可见性 / VID 复用 / affect 表现验收;`state_flags` 表现层枚举 |
| W3 玩法 UI / 输入 | 🟡 已有首版 | 增量 53 已收敛快捷栏 36/4×8、14 / F1F4、LSHIFT 翻页、Ctrl/Command+数字表情、空格攻击、Alt 名字、截图及帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天 / 相机入口;增量 54 增加全局物品拖放、背包 / 快捷栏投放目标、PICK / TALK / ATTACK 光标和窗口键盘焦点屏蔽;增量 56 增加仓库 / 商城 / 交易 / 商店 / 私人商店的首版明确投放目标和商店 BUY/SELL 模式;增量 57 增加物品实例元数据、整组拖放、stack split、`ITEM_MONEY=-1` 金币态、交易 accept / 距离 / anti-give / 金币边界、NPC 商店 anti-sell / 售价确认、私人商店 anti-give / anti-myshop;增量 58 增加 236 字节 `item_proto` 固定字段和统一基础 tooltip,已接入背包 / 装备查看 / 商店 / 仓库 / 商城 / 交易 / 私人商店;增量 59 将 NPC 商店改为参考端 5×8 / 40 格货位和 BUY 点击确认;增量 60 补齐交易 sockets/attrs、任务日志 icon/clock 和常用事件标签;增量 61 接入 QuestButton 条带、任务小地图信号、Atlas center adjust 和对话图片资源加载;增量 62 接入技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间;相关 C++ / Godot 回归通过;§8.3 帮助窗已落地 | CJK IME、窗口视觉 / `.sub` 光标资源、tooltip 的完整本地化 / 其他特殊分支、完整交易事务、复杂窗口完整 mousemodule 规则、38 项 EventManager 逐事件表现和真服未验收 |
| W4 渲染 / 资产 / 音频 | 🟡 开发中 | 增量 50:§9.1 / §9.2;增量 51AppFlow 单一生命周期;增量 53EffectLib `.mse` 首个 `.dds`、位置、发射形状、方向 / 速度;增量 55:MilesLib 监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色声道、角色限频、stop / 限幅淡出;增量 63:`.mss` 解析、60 FPS 实例换算、`sound/` 虚拟路径和 PlayerView / MobView 动作帧播放;增量 64`.mde` v1/v2 解析、几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65`.mse` Light 组、范围 / 生命周期曲线、粒子多贴图帧延迟 / 顺逆序和旋转类型;增量 66:MovingType 直接 / 二次 Bezier 位置曲线、粒子 / 网格 / Light 共用位置和球体 edge 映射;增量 67:`AreaAmbienceData` + `.pra` 独立环境声源、LOOP / ONCE / STEP、距离音量和区块卸载停止;增量 68:`.msenv` 参考字段 / 缺省值 / `Reserved` / 云层贴图解析接入,macOS 路径大小写修正;增量 69:`Sun` / `CharacterLight` 的静态 / 角色 layer 1/2 分层;增量 70texture-mode 六面天空 shader 和真实 CapeDragonHead 回归;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` 与场景回归全绿 | TexAni 随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree 真实几何 / 精确风常量、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare / 昼夜联动、完整声音节点生命周期、真实声音资源和真机验收 |
| W5 打包 / 发布 / 验收 | 🟡 开发中 | 增量 53 已实现 App 内自包含资源复制、universal 架构参数 / `lipo` 门禁、嵌套签名顺序和环境变量凭据 | 完整 2.2G 导出、当前机器双架构构建、正式证书 / notarization、跨机启动和 Mac App 验收 |
### C.4 每批次回写模板
后续开发不得只在提交说明中记录进度;每个批次完成后,将下面的信息同时回写到
`CLIENT-GAP.md` 的变更记录 / 总览表和本文对应工作流 / 条目:
```text
批次:YYYY-MM-DD / W? / §x.x
状态:🟡 开发中 | ✅ 完成 | ⚠️ 阻塞 | 🔎 待真实验收
参考:ClientVS22/source/...:line
改动:文件列表;接口 / 数据结构变化
测试:命令、结果;skip 项及原因
验收:真服 / 双账号 / Mac App 证据,或明确“未验证”
风险与依赖:...
下一批次:...
```
同步责任固定为:工作流提交者提供事实和证据,W0 集成维护者统一合并两份文档;任何状态从
“开发中 / 待验收”改为“完成”前,必须同时满足代码回归和文档回写。
### C.5 批次记录
```text
批次:2026-09-02 / W4 / §7.5`.msenv` 解析、静态云层与 macOS 路径)
状态:🔎 待真实验收(格式解析、真实 A1 地图加载和静态云层回归完成;完整环境渲染 / 真机未验)
参考:40250/ClientVS22/source/GameLib/MapUtil.cpp:1-205(环境字段、缺省值与加载规则);
40250/ClientVS22/source/GameLib/MapType.cpp:126-187`TEnvironmentData` 字段布局);
40250/ClientVS22/source/GameLib/MapOutdoor.cpp(环境应用与屏幕滤色行为)
改动:
formats/environment.h/.cpp —— 增加并解析 `Reserved`、Fog `IsDensity`、Filter、
SkyBox `BTextureRenderMode`、六面天空文件名和参考缺省值;
extension/src/environment_builder.h/.cpp —— 通过 `AssetResolver` 读取真实云层贴图,
接入 `ProceduralSkyMaterial.sky_cover`,其余暂不能等价渲染的原始参数写入元数据;
extension/src/metin2_world.cpp —— 保留 resolver 返回的真实大小写路径,修复 macOS 大小写敏感加载风险;
project/environment_test.gd —— 增加真实 A1 `.msenv`、云层材质和元数据断言;
project/world_probe.gd —— 按当前地图 patch 数递归统计地形节点,修正旧测试的区块粒度假设。
测试:`cmake --build build/extension -j2``ctest --test-dir build/extension --output-on-failure -j1`
`formats_map_test`(内联 + 真实 A1)、`environment_test`、`world_probe`、`audio_miles_test`、
`fx_test`、`gamescene_test`、`quest_test`、`p8_test` 均通过;`fx_test` 保留既有 1 条 ObjectDB
leak warning,不影响断言结果。
验收:未验证——六面天空实际渲染、云层 UV / 高度动画、滤色实际合成、双方向光分层、风参数、
lens flare、昼夜天气联动、完整资源包和真机表现。
风险与依赖:Godot 当前只直接承载基础环境和静态 sky cover;参考端的滤色接口在该版本实现中
实际禁用,不能仅凭字段值宣称滤色已经生效;风参数在 `Environment_Load` 中没有 token 读取,
只能以参考缺省值保留;动态天气仍依赖后续读取 `MapOutdoor.cpp` 的环境选择 / 插值流程。
下一批次:W4 继续云层动画、双方向光分层、滤色 / lens flare 与 SpeedTreeW5 执行完整 Mac App
导出与 G1/G2;每批代码回归后立即回写本记录及 W4 看板。
批次:2026-09-02 / W4 / §7.5(双方向光分层)
状态:🔎 待真实验收(代码、离线环境 / 角色回归完成;真机视觉与 SpeedTree 真实光照未验)
参考:`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264`(角色光传给 SpeedTree;
`40250/ClientVS22/source/GameLib/MapManager.cpp:239-247`(背景光 enable
改动:
`extension/src/environment_builder.h/.cpp` —— 增加 `CharacterLight`,背景 / 角色 enable、颜色、
方向和元数据分别设置;`Sun` 使用 layer 1`CharacterLight` 使用 layer 2
`extension/src/metin2_world.cpp` —— 地形、水面、建筑、树代理固定 layer 1,卸载地图清理
`Sun` / `CharacterLight`
`extension/src/metin2_model.cpp` —— 角色本体、装备和 LOD 网格固定 layer 2
`project/environment_test.gd`、`project/player_motion_test.gd` —— 增加两盏灯和角色网格层断言。
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、
`environment_test.gd`、`player_motion_test.gd` 均通过(当前 CTest 15/15)。
验收:未验证——真实 SpeedTree 光照、阴影质量、真机视觉和完整天气联动。
风险与依赖:Godot 的 light cull mask 只能保证场景层隔离;SpeedTree 仍是 proxy,未把参考端
`CSpeedTreeForest::SetLight` 的 shader 常量完整迁移。
下一批次:W4 继续纹理天空、云层动态、滤色 / lens flare / 风参数;W5 继续 Mac App G2。
批次:2026-09-02 / W4 / §7.5texture-mode 六面天空)
状态:🔎 待真实验收(真实资源和 shader 编译回归完成;纹理天空 + 云层组合、真机视觉未验)
参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:403-429,483-542`(六面顶点 / UV);
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`texture mode 设置与 Refresh
改动:
`extension/src/environment_builder.cpp` —— 增加 Godot `sky` shader,按 M2 Z-up 到 Godot
x,y,-z 轴变换映射 front/back/left/right/top/bottom;通过 `AssetResolver` + 原生 DDS 解码
加载面贴图,空面以 `.msenv` 渐变回退;记录 `msenv_sky_face_count` 和
`msenv_sky_texture_mode_applied` 元数据;
`project/skybox_test.gd` —— 加载真实 `metin2_patch_dragon_rock/metin2_map_capedragonhead`
并断言五面贴图、空 bottom 面和 shader 安装。
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、
`environment_test.gd`、`skybox_test.gd` 均通过;首次回归发现 `sky` 的 `COLOR` 必须为 `vec3`
已修正并重新通过;无 skip。
验收:未验证——纹理天空与云层同时存在时的叠加、云层 UV / 高度动画、滤色 / lens flare / 风、
昼夜切换和真机视觉。
风险与依赖:Godot `Sky` 是无限天空,不直接消费参考的 `SkyBoxScale`texture mode 且同时配置
CloudTexture 时当前保留 ProceduralSkyMaterial 路径,避免丢云层,但六面 + 云层叠加仍是后续项。
下一批次:W4 继续云层 shader / 动画和剩余环境后处理;W5 继续完整 Mac App 导出与 G2。
批次:2026-09-02 / W3 + W1 / §4.2 §4.5 §4.6 §4.7(物品实例字段、金钱态与边界规则)
状态:🔎 待真实验收(代码 + 离线回归完成;完整事务、资源、真服 / 真机未验)
参考:REF/GameLib/ItemData.h:196-204`ANTIFLAG_SELL/GIVE/MYSHOP`)、:212`COUNT_PER_1GOLD`);
REF/UserInterface/PythonPlayerModule.cpp:1098-1103`Is1GoldItem`)、:1329-1366`IsValuableItem`);
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:455-482(出售包);
POC/assets/root/uiexchange.pyaccept / 7 位金币 / 距离 / give);
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:14-27safebox money 发送函数主动返回 false
改动:
extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析并暴露 item_proto 的 `anti_flags` / `flags`
project/ui/mouse_controller.gd —— 保留实例元数据,增加 `ITEM_MONEY=-1` 独立金币持有态;
project/ui/inventory_ui.gd —— 普通拖放保留整组数量,Shift+左键堆叠物品先弹数量框,金币槽附着;
project/ui/exchange_ui.gd —— accept 服务端镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限、
已放金币不可编辑和金币投放;
project/ui/shop_ui.gd —— 实例 / 原型 `ANTIFLAG_SELL`、`IsValuableItem` 首次确认、
普通 / `Is1GoldItem` 售价公式和失败音效;
project/ui/private_shop_ui.gd —— `ANTIFLAG_GIVE/MYSHOP` 拦截;
extension/tests/proto_item_layout_test.cpp、project/shop_rules_test.gd —— 新增布局 / 出售规则回归。
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`
`inventory_ui_test.gd`、`mouse_controller_test.gd`、`p8_test.gd`、`shop_rules_test.gd`、
`private_shop_ui_test.gd`、`complex_item_drop_test.gd` 均通过。
验收:未验证——完整交易事务 / 双账号回滚、真实 `.sub` 资源、40250 真服和真机输入;仓库金钱
不实现伪 `/safebox_money`,因为参考发送函数本身返回 false。
风险与依赖:payload 的 socket / attribute 数据仍需由网络状态层完整承载;真实服务端回包是最终状态,
离线规则测试不能替代真服业务验收;dragon_soul 按当前范围禁用。
下一批次:W1/W3 补全 tooltip 的鱼竿 / 镐 / 龙魂等特殊分支,并处理 CJK 文案 / 字体;W4 继续 `.mss`、
`.mde` 和真实资源;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W3 / §4.6NPC 商店 5×8 货位与 BUY 点击确认)
状态:🔎 待真实验收(代码 + 离线回归完成;真实 `.sub` / 图标 / 非金币货币 / 真服未验)
参考:POC/assets/root/uishop.py:36-45、:226-227、:350-3975×8 刷新、全局槽号、BUY 点击);
POC/assets/uiscript/uiscript/shopdialog.py:47-62ItemSlot 5×8;
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:401-429(购买包固定 count=1
改动:
project/ui/shop_ui.gd —— 用 5×8 / 40 格按钮网格替换纵向商品行;按货架计算全局 pos;空位禁用;
非空货位 tooltip;BUY 点击购买确认,确认固定 `shop_buy(pos, 1)`;保留 SELL 投放和已有出售规则;
project/p8_test.gd —— 改为验证 40 格、购买确认和多货架槽位 5 / 43。
测试:`p8_test.gd` 通过;`git diff --check` 通过。
验收:未验证——真实 `.sub` 槽位 / 按钮贴图、物品图标、非金币 `coin_type`、40250 真服和真机。
风险与依赖:购买数量必须保持 1,不能把库存 count 当作购买数量;真实 UI 资源接入前不宣称视觉 1:1。
下一批次:W3 继续本地化 / 特殊 tooltipW4 继续 `.mss` / `.mde` / SpeedTreeW5 执行完整 Mac App
导出与 G1/G2。
```
```text
批次:2026-09-02 / W1 + W3 / §4.2 §4.3item_proto 固定字段与统一基础 tooltip)
状态:🔎 待真实验收(代码 + 离线回归完成;特殊物品分支、资源视觉、真服 / 真机未验)
参考:REF/GameLib/ItemData.h:13-72236 字节 TItemTable、limit/apply/value/socket/refine 字段);
POC/assets/root/uitooltip.py:970-1393AddItemData 顺序与物品类型分支);
POC/assets/locale/locale/en/itemdesc.txt(描述数据)
改动:
extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析 / 暴露 limits[2]、applies[3]、
sockets[3]、refined_vnum、refine_set、alter_to_magic_pct、specular、gain_socket_pct
project/ui/item_tooltip.gd —— 统一 item_proto + itemdesc + 实例 attrs/sockets 的基础提示,
对齐武器攻击 / 魔攻、防具防御公式、限制、applies、属性、魂石孔和精炼关联;
project/ui/inventory_ui.gd、view_equipment_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、
exchange_ui.gd、private_shop_ui.gd —— 接入同一个提示构造器;
extension/tests/proto_item_layout_test.cpp、project/item_tooltip_test.gd —— 锁定布局和提示内容。
测试:`cmake --build build/extension -j2`C++ CTest 全套(首次 `net.classic_encstream` 瞬时失败,
定向重跑通过,需在集成前再重跑全套);`inventory_ui_test.gd`、`item_tooltip_test.gd` 通过。
验收:未验证——鱼竿 / 镐经验、龙魂计时、技能书 / 变身特殊分支、装备对比、CJK / 真 `.sub`、
40250 真服和真机。
风险与依赖:参考端普通 item socket 固定为 3 个,龙魂 subtype 槽位数为 6,不能混用;装备对比
在参考源码中没有找到对应方法,后续若需要应单独定义产品规则;真实服务端实例字段优先。
下一批次:W3 继续特殊 tooltip / 本地化,W4 继续 `.mss` / `.mde` / SpeedTreeW5 执行完整 Mac App
导出与 G1/G2。
```
```text
批次:2026-09-02 / W3 / §4.2 §4.4 §4.5 §4.6 §4.7(复杂窗口物品态与商店模式)
状态:🔎 待真实验收(代码 + 离线回归完成;完整原版 UI / 资源 / 真服 / 真机未验)
参考:POC/assets/root/mousemodule.py:171-343(全局物品态);POC/assets/root/uisafebox.py:357-451
(仓库 45 格分页与 SelectEmptySlot / SelectItemSlot);POC/assets/root/uishop.py:226-291
(BUY/SELL 光标模式与出售规则);REF/UserInterface/PythonApplication.h:63(光标状态)
改动:
project/ui/safebox_ui.gd —— 5×9 网格、按 size 分页、指定格 checkin/checkout、仓库内 move
project/ui/mall_ui.gd —— 45 格分页网格和商城→背包投放;
project/ui/exchange_ui.gd —— 12 个指定交易格的背包 / 装备投放;
project/ui/shop_ui.gd —— 出售投放目标、BUY/SELL toggle、模式显隐、BUY/SELL 光标联动;
project/ui/inventory_ui.gd、quickbar.gd、game_scene.gd —— 复杂窗口投放装配和来源窗口回传;
project/complex_item_drop_test.gd(新)—— 仓库 / 商城 / 交易 / 商店落点与购买模式保护回归。
测试:`complex_item_drop_test.gd`、`p8_test.gd`、`private_shop_ui_test.gd`、`inventory_ui_test.gd`
均 exit 0 并输出 PASSC++ 既有 CTest 18/18 保持通过。
验收:未验证——`shopdialog.py` / `privateshopbuilder.py` 的完整 `.sub` 视觉、金钱附着、stack split、
anti-flag / `IsValuableItem` 售价确认、交易事务回滚、完整 pick/drop 音效、40250 真服和真机输入体验。
风险与依赖:当前落点调用已有 wire API,最终状态仍由服务端回包决定;`deposit()` 等旧右键入口保留为
兼容路径,不代表原版自动找空位是鼠标落点语义;dragon_soul 按当前 40250 范围继续禁用。
下一批次:W3 补物品金钱态、stack split、anti-flag / 售价确认和交易状态;W4 继续 `.mss` / EffectLib
W5 执行完整 Mac App 导出与 G1/G2 验收。
```
```text
批次:2026-09-02 / W4 / §9.4MilesLib 音频子集)
状态:🔎 待真实验收(代码 + 离线回归完成;`.mss` / 完整声音节点 / 真服 / 真机未验)
参考:REF/MilesLib/SoundManager.cpp:20-21200/1000 缩放)、:72-89(监听器更新)、
:245-250ratio/grade 音量换算)、:349-420(普通 / 环境 / 角色 3D 播放与 5000cm、0.3 秒限频)、
:510-545(限幅淡出 / 全部淡出);REF/MilesLib/SoundManager.h:23-52(公开 API
改动:
project/audio.gd —— 新增 `set_listener/update_listener`、`SOUND_SCALE=200`、环境音 `1000`、
参考音量曲线、普通 / 环境 / 角色 3D 播放、活动实例位置重基准、play_count、单实例 / 全部停止、
角色声音距离与频率保护、音乐限幅淡出;
project/game_scene.gd —— 每帧更新 Miles listener`.msa` 本地动作 sound 事件走角色声道;
project/ui/quickbar.gd —— assign 返回投放成功状态,失败时不清除拖放语义;
project/audio_miles_test.gd(新)—— 常量、换算、缩放 setter、5000cm / 0.3 秒规则回归。
测试:`godot --headless --path project --script audio_miles_test.gd` → PASS(exit 0)。
音频测试使用无资产纯规则 fixture,不将音频文件缺失误报为资源播放通过。
验收:未验证——`.mss` 解析、`.msenv` 环境参数 / 天气联动、完整 `UpdateSoundData/UpdateSoundInstance` 数据、
武器 / 技能 / 怪物 / NPC 事件,真实 40250 服务端和真机空间听感。
风险与依赖:当前系统设置滑条继续写入 0..1 的应用音量;ratio / grade API 已提供但尚未由全部设置入口
切换。Godot 3D 音频实例使用统一空间根节点,具体 unit_size / attenuation 仍需受控参考帧和真机校准。
下一批次:W4 读取 `SoundData.cpp` 完成 `.mss` / sound-nodeW3 补复杂窗口 mousemodule / CJK IME
W5 继续完整 Mac App 导出和发布验收。
```
```text
批次:2026-09-02 / W3 / §4.2 §4.9(全局物品拖放、光标状态、窗口焦点)
状态:🔎 待真实验收(代码 + 离线回归完成;复杂窗口 / 真实资源 / 真服 / 真机未验)
参考:POC/assets/root/mousemodule.py:171-343AttachObject / DeattachObject / payload);
REF/UserInterface/PythonApplication.h:63、POC/assets/root/mousemodule.py:100(光标枚举 / 映射);
REF/UserInterface/GameType.cpp:90-106slot type → inventory window
改动:
project/ui/mouse_controller.gd(新)——全局 item payload、鼠标跟随预览、有效目标投放、无效区域 /
右键 / ESC / 失焦取消;
project/ui/cursor_manager.gd(新)——NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM 状态;
project/ui/inventory_ui.gd —— inventory / equipment / belt target 注册、全局拖放投放、装备 wire
坐标修正、兼容缺少 belt API 的测试桩;
project/ui/quickbar.gd —— inventory item → quickslot 投放;
project/ui/ui_manager.gd ——窗口打开时拦截不应穿透到 PlayerController / GameCamera 的键盘事件;
project/player_controller.gd、ground_items.gd、net_world.gd、game_scene.gd ——悬停光标、实体点选
注册、地面物品 PICK 和 UI 装配;quest_log.gd —— Ctrl+Q 状态入口;
project/mouse_controller_test.gd、input_key_test.gd —— attach / drop / cancel 与 Ctrl+Q 回归
测试:`mouse_controller_test.gd`、`inventory_ui_test.gd`、`input_key_test.gd`、`gamescene_test.gd`、
`ui_test.gd`、`system_menu_ui_test.gd` 均 exit 0 并输出 PASS`git diff --check` 通过。
验收:未验证——safebox / mall / shop / exchange / private_shop 的完整 SelectEmptySlot /
SelectItemSlot 分支、金钱附着 / stack split、真实 `.sub` 光标贴图、CJK IME、40250 真服和真机体验。
风险与依赖:当前光标没有 `.sub` 资源时使用 Godot 系统光标;Ctrl+Q 的迁移层保留按钮可见状态,
QuestButton 已有独立条带和接口;仍需原版滚动 / 多列定位、资源视觉和服务端回包验收。
下一批次:W3 继续接入复杂物品窗口的 mousemodule 分支 / 音效与 CJK IMEW4 MilesLibW5
完整 App 导出与发布验收。
```
```text
批次:2026-09-02 / W3-W5 / §4.8 §6.3 §8.6 §10(快捷栏、输入、EffectLib、Mac 打包)
状态:🔎 待真实验收(代码 + 离线回归完成;真实服务 / 完整资源包 / 正式签名未验)
参考:POC/assets/root/game.py:309-431、509(键位 / 快捷栏);REF/UserInterface/GameType.h:140,307
ESlotType / TQuickSlot);REF/UserInterface/Packet.h:39236/32 槽);
REF/EffectLib/EmitterProperty.h:12-15、ParticleSystemInstance.cpp:106-181、
ParticleSystemData.cpp:282-287(发射器 / TextureFiles);§10 为本工程发布要求
改动:
project/ui/quickbar.gd —— 服务器 36 槽、客户端 4×8、本地槽类型 1/2/3、表情槽和服务器恢复
extension/src/net/m2_client.h/.cpp —— send_emoticon()、12 个参考 token、表情信号及快捷栏类型校正
project/game_scene.gd、game_camera.gd、net_play.gd、net_world.gd、ui/chat_ui.gd —— 参考键位的
按下 / 松开、连续攻击、相机、名字牌、截图和窗口入口
project/input_key_test.gd、skill_test.gd、netplay_test.gd、netbridge_test.gd —— 离线回归覆盖
project/fx/effect_player.gd —— `.mse` 位置 / 形状 / 方向 / 速度及首个 TextureFiles → DDS
build.sh、build-macos-client.sh —— CMAKE_OSX_ARCHITECTURES、App 内复制 assets/bgm、lipo 门禁、签名顺序
project/app_flow.gd、login.gd、charselect.gd —— 去除硬编码账号密码,自动登录改为显式环境变量
测试:`fx_test.gd`、`input_key_test.gd`、`skill_test.gd`、`netplay_test.gd`、`netbridge_test.gd`、
`gamescene_test.gd` 均 exit 0 并输出 PASS`bash -n build.sh build-macos-client.sh`、`git diff --check` 通过;
C++ 扩展已有构建 / CTest 18/18 证据。Godot 回归串行执行,fx_test 仅有 1 个 ObjectDB leak warning
不影响返回码。
验收:未验证——当前机器已有扩展仍是 arm64;完整 2.2G Mac App 尚未导出;没有 Developer ID /
notarization / stapled ticket;没有跨机资源隔离和 40250 真服 / macOS F1F12 实机证据。
风险与依赖:SimpleLight / TexAni 的完整随机与每粒子语义、MovingType / 完整时间轴、普通物品鼠标态、CJK IME 和 MilesLib 声音
事件模型仍未实现;universal 构建需要完整工具链 / 模板;`CODESIGN_IDENTITY` 不配置时仍为 ad-hoc。
下一批次:W3 §4.2 物品态 / 光标与输入焦点;W4 `.mde` / MilesLibW5 完整导出后执行 G1/G2
真实服务端到位后补 DirectEnter / Warp / 玩法矩阵。
```
```text
批次:2026-09-02 / W0-W2 / §3.9(公共网络 API 参数边界)
状态:🔎 待真实验收(代码 + 离线回归完成;业务语义和真服未验)
参考:REF/UserInterface/PhaseGame.cpp:1132(地图坐标断言);REF/UserInterface/PythonPlayer.cpp:800/815/843/852/861
(快捷栏边界);REF/UserInterface/PythonPlayerEventHandler.cpp:196/104(同步 16、动作索引);
REF/UserInterface/GameType.h36/40/12 等数组容量);40250/ClientVS22/source 的 Classic wire 定长字段
改动:
extension/src/net/net_bounds.h(新)——窄整数、快捷栏 / 商店 / 私人商店 / 交易 / Cube / 龙魂 / 点数组边界
extension/src/net/m2_client.cpp —— 连接端口、角色创建、聊天 / 技能 / 社交 / 商店 / 仓库 / 私人商店 /
Cube / 公会 / 精炼 / 物品操作在 static_cast 前统一拒绝溢出和非法 Variant;移除静默截断 / 负数归一化
extension/tests/net_bounds_test.cpp(新)、extension/CMakeLists.txt —— CTest net.bounds
测试:cmake --build build/extension -j2 通过;ctest --test-dir build --output-on-failure → 18/18
Godot 离线回归按独立 --log-file 串行执行,受影响脚本均 exit 0 且输出 PASS。无新增 skip。
验收:未验证——无真实 40250 服务端的完整业务包回执;Godot 测试中的 macOS 证书探测 / 资源释放提示
不影响脚本返回码,但不计作真实平台验收。
风险与依赖:net_bounds 只表达已核实 wire / 数组容量,不替代技能冷却、目标类型、背包规则和服务端权限;
`M2Client` 的字段值仍需在真服对照服务端拒绝 / 接受行为。
下一批次:W3 §8.6 剩余键位 / 表情与物品态,或 W4 §6.3 EffectLib,继续按文件主维护者与 G1 门禁同步。
```
```text
批次:2026-09-02 / W0 / §2.1 §2.3 §2.5 §8.6 §9.1(接口冻结,Phase 1 前置)
状态:🟡 开发中(骨架落地;语义补全在 Phase 1 各工作流)
参考:POC/assets/root/game.py:309-431(键位);ClientVS22 GC_MAIN_CHARACTER3/4BGM
改动:
extension/src/net/entity_store.h/.cpp —— Entity +empire/affect_flags/owner_vid/state_flags
+mut_spawn_full()/mut_ownership()/mut_map_bgm()/drain_dirty()/take_bgm_dirty()/
bgm_name()/bgm_volume()mut_char_info 落 empirereset_for_map_change 清 m_dirty
extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/
take_bgm_dirtyentity_dict() 补 4 键
extension/src/net/classic/classic_parser.h/.cpp —— BGM 名/音量路由到 mut_map_bgm
+m_pending_actor 成员
project/bgm_director.gd(新)、project/game_scene.gd —— 装配 BgmDirector;§8.6 键位入口
测试:cmake --build build 通过;ctest 16/16netbridge_test、gamescene_test、p9_test、
netplay_test、system_menu_ui_test、skill_test、combat_fx_test、skill_fx_test、
player_motion_test 全绿;无 skip
验收:未验证(无真实 40250 服务端;BGM / 两包合并需服务端数据)
风险与依赖:mut_spawn_full 目前无调用方(W2 §2.3 接入);m_pending_actor 未使用
W1 §2.2 接入);bgm_director 仅透传曲目名,未解析地图→曲目(W4 §9.1)
下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、
W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并
```
```text
批次:2026-09-02 / W1 / §1.5 §1.10 §2.2
状态:🔎 待真实验收(代码 + 本地回归齐全;无 40250 真实服务端)
参考:REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155SendClientVersionPacket);
REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140(两包合并 / IsInvisibleRace);
REF/UserInterface/StdAfx.h:43、Packet.h:380(定长文本常量)
改动:
extension/src/net/text_codec.h(新)—— to_wire(string_view,cap) / from_wire_str(char*,cap)
UTF-8 边界安全字节截断;godot::String 重载用 __has_include 守卫
extension/src/net/classic/npc_names.h(新)—— load_npclist() 解析 root/npclist.txt(vnum→code)
default_npclist_path() 走 $MT_ASSETS/$M2_ASSETSmtnet-safestd::ifstream
extension/src/net/classic/wire_classic.h —— +HDR_CG_CLIENT_VERSION2=0xF1、
struct CGClientVersion2(static_assert 67)、尺寸表分支;#include text_codec.h
CHRTYPE_* enum + is_invisible_race()W0 冻结项,已在增量 49 落地则保留)
extension/src/net/classic/classic_session.{h,cpp} —— set_executable_name/set_locale_is_europe
setter + m_executable_name/m_locale_is_europe 成员;send_client_version() locale 分支
(默认 0xf1 + "1215955205",非 EUROPE 走 0xfd + MT_BUILD_TIMESTAMP);8 处定长文本
填充路由到 to_wire()
extension/src/net/classic/classic_parser.{h,cpp} —— set_npclist_path/pending_actor_count/
npc_name_for 惰性加载;GC_CHARACTER_ADD(隐身丢弃 / PC·NPC 暂存 m_pending_actor /
其余 npclist 命名 + mut_spawn);GC_CHAR_ADDITIONAL_INFO(无 pending → m_last_error +
drop;命中 → 合并 9 字段 + mut_spawn_full + erase);ADDITIONAL_INFO 名字读 from_wire_str
extension/CMakeLists.txt —— string(TIMESTAMP)→MT_BUILD_TIMESTAMP 定义;net.text_codec 测试注册
extension/tests/net_text_codec_test.cpp(新)、net_classic_session_test.cpp(版本断言 +
两包合并断言重写)
接口/数据结构变化:ClassicSession +2 setter +2 成员;ClassicParser +2 public 方法
+1 private 方法 +3 私有成员(m_npclist_path / m_npc_names / m_npc_names_loaded);
wire +CGClientVersion2。未改 entity_store.* / m2_client.* / project/*。
测试:cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j 通过;
cd build && ctest --output-on-failure → 100% tests passed, 17/17
(新增 net.text_codecnet.classic_session 及既有 16 项全绿);
godot --headless --path project --script res://netbridge_test.gd → PASSexit 0);
godot --headless --path project --script res://p10_test.gd → PASSexit 0)。
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
以填充 project/.godot/global_script_class_cache.cfgworktree 内 .godot 为空且 gitignore),
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
风险与依赖:
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibakefollow-up
- MT_BUILD_TIMESTAMP 的日为零补位(%d),__TIMESTAMP__ 为空格补位——按 §1.5“近似即可”
- 跨文件待接线(W0/M2Client):set_executable_name(OS::get_executable_path().get_file())、
parser().set_npclist_path(AssetRoot::path("root/npclist.txt"));均已留 TODO(W0)
- §2.2 步骤 45entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
合并逻辑对当前 W2 代码可工作
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlenGC_CHARACTER_ADD2 处理未动(follow-up
- GDScript 名字长度校验统一(charselect.gd / guild_creation_ui.gd)属 Phase 2 / W3
下一批次:Phase 2 —— W0 执行 G1 集成,接线 set_executable_name / set_npclist_path
合并 §2.2 步骤 4–5,统一 GDScript 名字校验;真实 40250 服务端到位后做 §1.5/§1.10/§2.2 真服验收
```
```text
批次:2026-09-02 / W2 / §2.1 §2.3 §2.4 §2.5(含 §2.2 步骤 4-5 清理)
状态:✅ 完成(代码 + 回归);🔎 真服端到端待验收
参考:REF/UserInterface/NetworkActorManager.cpp:53/409/456/470
REF/.../PythonCharacterManager.cpp:13/182REF/.../PhaseLoading.cpp:325
m2dev src/UserInterface/InstanceBase.h enum EAffectbit 7 = AFFECT_SHOW_ALWAYS
改动:
extension/src/net/entity_store.cpp —— 全部 mut_* 未知 VID → dbg_ignored()+return(无
pending/重放);条目仅 mut_spawn/mut_spawn_full/mut_spawn_main 建、mut_despawn/主 VID
清表删;mut_spawn_full() 整行替换 m_ents[vid]=e 并无条件 push {Spawn}mut_spawn()
委托 mut_spawn_full()mut_set_points() 短包清尾 + n>255 截断、空指针/n<=0 早退;
mut_set_point() type>=255 拒绝;mut_char_info()/mut_shop_sign()/mut_mount() 去掉裸
Entity 创建,find-or-returnmut_char_update() guild/alignment/pk_mode 变化也推 {Info}
+mut_affect_flags(vid,lo,hi)v=lo|(hi<<32),变化则 m_dirty);+匿名 dbg_ignored 助手
extension/src/net/entity_store.h —— +mut_affect_flags 声明
extension/tests/net_entity_test.cpp —— +§2.1 数据层断言块(远端 ADD 填行+入队 Spawn、
UPDATE 改写、未知 VID 五个 mut_* 既不建行也不缓存)
project/net_world.gd —— +CHAR_STAGE_VIEW_BOUND/AFFECT_SHOW_ALWAYS/WALL_RACES 常量;
+_update_visibility()(每帧 __IsVisibleActor 判定,建/淡出节点,_fading 防抖);
+_is_visible_entity/_planar_cm/_show_always/_is_wall/_fade_and_free
+_apply_field_updates()NetworkActorManager.cpp:470 调用序,has_method 守卫);
_on_spawn 支持已有节点销毁重建 + 上下马保留 global_position_on_info 末尾刷字段;
clear_for_map_change/_on_despawn 清 _fading
project/net_world_vis_test.gd(新)—— 两层可见性 headless 自检
测试:./build.sh Debug 退出 0cd build && ctest --output-on-failure → 16/16 全过
net.entity_store 含新断言);godot --headless --script 逐个:net_world_vis_test、
netbridge_test、gamescene_test、netplay_test、p9_test、combat_fx_test、player_motion_test
全绿;无 skip。(注:干净 worktree 需先跑一次 `godot --headless --editor --quit` 生成
global_script_class_cache 才能解析 MapCoord/AssetRoot 全局类)
验收:未验证(无真实 40250 服务端)
风险与依赖:
- W1 需在 classic_parser.cpp 的 GC_CHARACTER_ADD/ADD2/UPDATE 之后调
mut_affect_flags(vid, affect_flag[0], affect_flag[1])——mut_char_update 无 affect 形参
- §2.2 步骤 1-3m_pending_actor 两包合并、IsInvisibleRace 分流)属 W1,未触碰
- net_world.gd 可见性门禁对无 pos_cm 的 fake client fail-open:信号驱动 spawn 不受
距离限制,且逐帧管理会主动建节点——与参考「信号即 AppendActor」的有意偏差
- AFFECT_SHOW_ALWAYS=1<<7 源自 m2dev InstanceBase.h40250 树 AffectFlagContainer.h
未逐一拼出成员);WALL_RACES=[14201..14204] 源自 InstanceBase.cpp IsWall
- m2dev apply() 内联 touch() 路径(GC_CHARACTER_ADD 等旧协议分支)不在 §2.2 范围,未改
下一批次:W1 接线 mut_affect_flags + §2.2 两包合并;Phase 2 由 W0 执行 G1 集成
```
```text
批次:2026-09-02 / W3 / §8.3(帮助窗)
状态:🟡 开发中(首版落地;真服 / 视觉未验收)
参考:assets/root/uihelp.pyHelpWindow.LoadDialogSinglePage);
assets/uiscript/uiscript/helpwindow.pyhelp_01..help_19 + taskbar_help_* + close_button);
assets/locale/locale/en/locale_interface.txtHELP_* / CLOSE 逐字);
game.py __PressHKeyH 非 Ctrl → interface.OpenHelpWindow()Ctrl+H = /user_horse_ride
改动:
project/ui/help_ui.gd(新)—— ui.open_script("helpwindow.py") 非模态;_relabel() 用
Locale(en) + 英文兜底把 <HELP_*>/<CLOSE> 占位补回(含重名的两个 help_02);
open/close/toggle/is_open、close_button→close、ESC 走 UiManager 窗口栈、rows() 便捷器
project/ui/system_menu_ui.gd —— help_button 由占位提示改为 close()+open_help()
+const HelpUI、+help_ui 成员、+_ensure_help()/open_help()/toggle_help()
setup() 追加可选第 6 参 help_win(旧 5 参调用不受影响)
project/game_scene.gd —— _unhandled_input 加单条 elifKEY_H 且非 ctrl/meta →
system_menu_ui.toggle_help()Ctrl+H 未占用)
接口变化:仅 SystemMenuUI.setup() 追加可选尾参;无 net / EntityStore 改动
测试:godot --headless --path project --script res://help_ui_test.gd → PASSexit 0);
system_menu_ui_test / gamescene_test / ui_test 全 PASS;附加 system_option_ui_test /
char_status_ui_test 亦 PASS
skip:本 worktree 无 build/,未跑 C++ ctest(本批次无 C++ 改动)
验收:真服 / 双账号 / Mac App 未验证(§8.3 纯本地 UI,无服务端依赖);
helpwindow.py 的 .sub / help_stick.tga 贴图与字体描边未逐像素比对
风险与依赖:依赖 W0 冻结的 UiManager/UiScript/UiBuild 与 Locale;未改共享文件,
game_scene.gd 仅加 1 条 elifW3 许可范围)。1:1 偏差:本仓 uihelp.py 非“136 行含
键位行”版本,逐字源实际为 helpwindow.py + uiScriptLocale.HELP_*(已在 §8.3 注明);
帮助窗按非模态实现(原版 Lock()),ESC/关闭行为等价
下一批次:Phase 2 由 W0 执行 G1 集成;§8.6 剩余逐行键位与表情包仍在 W3 范围
```
```text
批次:2026-09-02 / W4 / §9.1 §9.2 §9.3-doc
状态:✅ 完成(§9.1 §9.2 代码 + 回归);§9.3 设计已确认,落地推迟到 Phase 2
参考:POC/assets/root/game.py:190/225(选曲 + 先置音量);POC/assets/root/musicinfo.py
(常量 + Save/LoadLastPlayFieldMusic);PythonNetworkStreamPhaseLoading.cpp:299
gs_fieldMusic_volume = 1/5*0.1);REF/MilesLib/SoundManager.h:31SaveVolume/RestoreVolume
改动:
project/bgm_director.gd —— _select() 补齐地图→曲目解析(服务端名优先 / 回落
MusicInfo.fieldMusic / 默认音量 0.02 / 淡入前置音量);bind() 连 M2Client.world_reset
换图重选
project/musicinfo.gd(新)—— METIN2THEMA/loginMusic/createMusic/selectMusic/fieldMusic
五常量 + save/load_last_play_field_music → user://BGM/lastplay.inf
project/audio.gd —— +save_volume()/restore_volume()(字段 _backup_music_volume/
_backup_sound_volume 对齐 m_fBackupMusicVolume/m_fBackupSoundVolume+ set_music_volume()
project/app_lifecycle.gd —— _enter_background 用 save_volume()+set_music_volume(0) 取代
stop_bgm()_exit_background 加 restore_volume()_bgm_name 不清空
project/bgm_test.gd(新)—— §9.1 选曲 4 例 + lastplay 往返 + §9.2 后台→前台存恢复
测试:godot --headless --path project --script res://bgm_test.gd → PASS(exit 0)
gamescene_test / netplay_test / p9_test 回归全绿;无 build/ 目录,无 C++ 改动(ctest 不涉及)
验收:未验证(无真实 40250 服务端;前后台切换需真机 Android/iOS
风险与依赖:§9.3 需 W0 在 Phase 2 把 AppLifecycle 属主收敛到 AppFlow、M2Client 去掉
NOTIFICATION_* —— 本批次按约束未改 game_scene.gd/app_flow.gd/m2_client.*
set_music_volume 只作用于当前 _bgm[_bgm_cur] 声道
下一批次:W0 已完成 §9.3 代码落地;下一批次由 W5 / 平台验收补真机证据,W4 继续 MilesLib
3D 声音模型;G1 集成回归保持串行并使用独立 Godot 日志文件
```
```text
批次:2026-09-02 / W0 / §9.3(生命周期单一属主)
状态:✅ 完成(代码 + 离线回归);🔎 真机 / 真服断线重连待验收
参考:REF/UserInterface/PythonApplication.cpp + PythonApplicationEvent.cpp(应用层统一驱动);
REF/EterLib/NetStream.cpp:332/343/351、REF/UserInterface/PythonNetworkStreamEvent.cpp:4
改动:
project/app_flow.gd —— 生产入口创建唯一 AppLifecycle;登录阶段解绑旧 GameScene 音频,
游戏场景 setup 完成后绑定当前 Audio
project/game_scene.gd —— 删除本地 AppLifecycle 创建,新增 lifecycle_audio() 作为绑定接口
extension/src/net/m2_client.h/.cpp —— 删除 NOTIFICATION_APPLICATION_PAUSED/RESUMED
监听,保留 suspend()/resume() 供 AppLifecycle 驱动
project/login.gd —— 兼容启动器保留单一生命周期节点贯穿登录→游戏,不再进场销毁
project/app_flow_lifecycle_test.gd(新)、project/gamescene_test.gd —— 单一属主 / 无重复节点回归
测试:
godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log
--script app_flow_lifecycle_test.gd → PASS
godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log
--script gamescene_test.gd → PASS
两个 Godot 进程并行时曾因共用 user://logs / 图形上下文触发 exit 134;改用独立日志文件串行
执行后通过。后续 Godot 回归不得并行启动。
验收:未验证 Android/iOS 前后台通知、内存告警、后台 TCP 回收后的重连;无真实 40250 服务端。
风险与依赖:AppFlow 是 `client_main.tscn` 的生产路径;`login.gd` 是旧兼容启动器,保留其自身
单一协调者不等同生产路径重复。若后续删除兼容启动器,应同步移除其专属生命周期代码。
下一批次:W5 真机 / Mac App 验收;W4 补 MilesLib 3D listener、距离衰减和播放模型。
```
```text
批次:2026-09-02 / W1 + W3 / §4.5 §5.1 §5.2(交易实例字段与任务显示 / 标签解析)
状态:🔎 待真实验收(本批范围代码 + 离线回归完成;完整事务、完整 EventManager 和真实资源未验)
参考:REF/UserInterface/Packet.h / PythonNetworkStreamPhaseGameItem.cppGC_EXCHANGE 的 3 sockets
+ 7 attrs);REF/UserInterface/PythonEventManager.cpp:377-79038 项标签分派);
POC/assets/root/uicharacter.py:657-723(任务 icon / counter / clock 展示)
改动:
extension/src/net/entity_store.h/.cpp —— ExchangeSlot 增加 sockets[3] / attrs[7]m2dev
GC_EXCHANGE ITEM_ADD 和 `mut_exchange_item()` 复制完整实例状态;
extension/src/net/classic/classic_parser.cpp —— classic GC_EXCHANGE 将 ItemAttr3 转为统一
ItemAttr 后写入交易状态;
extension/src/net/m2_client.cpp —— get_exchange() 输出 sockets / attrs,供 GDScript tooltip 使用;
project/ui/quest_log.gd —— 读取 icon,资源存在时显示;clock 按参考分钟 / 秒格式倒计时,资源缺失安全回退;
project/ui/quest_dialog.gd —— 识别 COLOR/COLOR256、ITEM/MOB、WINDOW_SIZE、居中、CONFIRM_WAIT、
IMAGE/TITLE_IMAGE 元数据和 DELAY/WAITgame_scene.gd 传入 proto / assets_root
project/ui/friend_ui.gd —— 确认框答复后主动隐藏,避免连续模态框残留;
extension/tests/net_entity_test.cpp、net_classic_session_test.cpp、project/quest_test.gd ——
交易 sockets/attrs、任务标签、任务时钟回归。
测试:`cmake --build build/extension -j2` 通过;`ctest --test-dir build/extension -R
'net.entity|net.classic_session' --output-on-failure` 2/2 通过;`p8_test.gd`、`item_tooltip_test.gd`、
`inventory_ui_test.gd`、`private_shop_ui_test.gd`、`complex_item_drop_test.gd`、`shop_rules_test.gd`、
`mouse_controller_test.gd`、`quest_test.gd` 均 exit 0 并输出 PASS;修复私店测试中的 GDScript 类型推断错误。
验收:未验证——完整交易双账号回滚 / 背包锁定、EventManager 逐事件等待 / 立绘 / 地图信号 / 相机 / 电影、
真实 `.sub` 图标、CJK IME、40250 真服和真机。
风险与依赖:交易实例数组现在可用于提示,但最终物品状态仍以服务端回包为准;任务网络 cue 仍是完整文本,
不能把标签识别当作 38 项 EventSet 迁移;任务 icon 沿用可选资源加载,找不到资源时不伪造图片。
下一批次:W3 继续 EventManager 逐事件状态 / QuestButton / minimap signal 或 CJK 输入;W2 继续交易事务状态;
W4 继续 `.mss` / `.mde` / SpeedTreeW5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W3 / §5.1 §5.2 §9.5(任务信件、任务地图信号与对话图片)
状态:🔎 待真实验收(代码 + 离线回归完成;完整 EventSet、资源视觉、真服 / 真机未验)
参考:REF/UserInterface/PythonEventManager.cpp:554-614、:571-602(地图信号、QuestButton、Atlas center;
REF/UserInterface/PythonEventManager.cpp:504-532、:679(对话图片回调);
POC/assets/root/uiquest.py:1290-1409(任务信件接收、删除、点击、抑制和布局)
改动:
project/ui/quest_dialog.gd —— 增加 `QUESTBUTTON/QUESTBUTTON_CLOSE`、
`ADDMAPSIGNAL/CLEARMAPSIGNAL/SETCMAPPOS`、相机 / 淡入淡出事件信号;对话图片在资源存在时
实际创建 `IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE/TITLE_IMAGE` 节点,空脚本也会先发 side effect
project/ui/quest_log.gd —— 新增独立 QuestButton 条带,支持接收 / 替换 / 删除 / 点击
`script_button(index)`、highlight 图标类型、对话打开抑制和资源 / item 图标解析;
project/ui/minimap.gd —— 保存 / 清除 / 绘制 EventManager 任务信号点;
project/ui/atlas_ui.gd —— 接入 `SetCenterPositionAdjust` 的 screen-space 偏移;
project/game_scene.gd —— 连接任务对话、QuestLog、Minimap 和 Atlas 的公开信号;
project/quest_test.gd —— 增加 QuestButton、地图信号、相机 / fade 元数据、条带点击回归。
测试:`quest_test.gd`、`p9_test.gd`、`gamescene_test.gd` 均串行运行并输出 PASS;三项脚本
`--check-only` 通过;此前并发 Godot 检查曾触发共享 MoltenVK / user://logs 崩溃,后续统一串行。
验收:未验证——完整 38 项 EventSet 逐事件等待 / 分页、立绘 / 电影 / 相机实际表现、真实 `.sub`
资源、40250 真服和真机输入 / 视觉。
风险与依赖:QuestButton 的服务端回包仍是最终状态;地图信号按参考接口保存为绝对服务器厘米,
`SETCMAPPOS` 按参考解释为 Atlas screen-space center adjust,不当作角色传送;缺失图片只安全跳过。
下一批次:W3 继续 EventSet 时间轴 / fade / camera / CJK IMEW2 继续完整交易事务;W4 继续
`.mss` / `.mde` / SpeedTreeW5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W3 / §4.3tooltip 特殊物品分支与实时限时)
状态:🔎 待真实验收(代码 + 离线回归完成;完整本地化、其他特殊分支、真实资源和真服 / 真机未验)
参考:POC/assets/root/uitooltip.py 的技能书、遗忘书、变身道具标题分支;40250
ClientVS22/GameLib/ItemData.h 的 `limits[2]` / `sockets[3]` 固定布局;本地
`locale/locale/<lang>/skilldesc.txt` 与 mob 原型名称表。
改动:
project/ui/item_tooltip.gd —— `setup()` 接收 mob 原型源并加载技能描述名;根据技能书 / 遗忘书
的 `sockets[0]` 生成技能名标题,根据变身道具 `sockets[0]` 生成怪物名标题;根据限制类型
7/8/9 与实例 socket 结束时间 / 持续时间生成剩余分钟 / 秒提示。
project/ui/inventory_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、exchange_ui.gd、
view_equipment_ui.gd、private_shop_ui.gd —— 统一把 mob 原型源传入 tooltip。
project/item_tooltip_test.gd —— 增加技能书、变身道具、实时限时三类断言。
测试:`item_tooltip.gd --check-only` 通过;`item_tooltip_test.gd` 输出 PASS;此前物品窗口、商店、
交易、拖放回归保持通过。
验收:未验证——CJK 文案 / 字体切换、完整技能描述、其他物品特殊分支、真实 `.sub` / 音频资源、
40250 真服实例时间字段和 Mac 真机显示。
风险与依赖:当前只把参考端已确认的技能 / 怪物名称映射接入;未发现的特殊分支不凭经验扩展。
限时提示依赖实例 socket 或显式 remaining 字段,最终过期状态仍以服务端物品回包为准。
下一批次:W4 继续 `.mss` 声音脚本解析;W3 继续 CJK IME / 其他 EventManagerW2 继续完整交易事务;
W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W4 / §9.4.mss 声音脚本与动作帧播放)
状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni / .msenv / 完整节点生命周期 / 真实资源和真机未验)
参考:40250/ClientVS22/source/MilesLib/Type.cpp:17-74LoadSoundInformationPiece)、:118-136
DataToInstance);GameLib/RaceMotionData.cpp:459-562.msa → sound/<tail>.mss 配对);
MilesLib/SoundManager.cpp:148-172UpdateSoundData / UpdateSoundInstance)。
改动:
project/audio.gd —— 解析 `SoundDataCount` / `SoundData%02d`,按参考转换 60 FPS frame;补齐
`sound/` 虚拟前缀到 patch sound 根的查找;新增 `load_mss_for_motion()` 和
`update_sound_instances_3d()`。
project/ui/player_view.gd、project/ui/mob_view.gd —— 每次动作进入新帧时消费配对 `.mss` 实例,
通过角色声音入口触发,并在动作切换 / 循环时重置帧游标。
project/game_scene.gd —— 将 Audio 绑定到本地 PlayerView 和远端 MobView。
project/audio_miles_test.gd —— 增加真实 `.mss` 记录、虚拟路径、60 FPS 换算和 `.msa` 配对断言;
project/player_motion_test.gd、project/mob_view_test.gd —— 增加玩家 / 怪物动作配对断言。
测试:`audio.gd`、`ui/player_view.gd`、`ui/mob_view.gd` `--check-only` 通过;
`audio_miles_test.gd`、`player_motion_test.gd`、`mob_view_test.gd` 串行运行并输出 PASS。
验收:未验证——效果实例完整声音生命周期、`.msenv` 环境参数 / 天气联动、所有武器 / 技能 / 怪物 / NPC
资源逐项听感、40250 真服实体动作和 Mac 真机空间听感。
风险与依赖:`.mss` 只描述触发时刻和虚拟路径,音频解码 / 3D 衰减仍由 Godot 音频层承担;
缺失声音资源会安全跳过,不把文件存在误报为可听感通过。Godot 回归继续严格串行。
下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`W3 继续 CJK IME / 完整 EventSet
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W4 / §7.5SpeedTree 风缺省)
状态:🔎 待真实验收(参考缺省与 proxy 回归完成;真实 SpeedTree 视觉未验)
参考:`40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42``fWindStrength=0.2`、`fWindRandom=0`);
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:257-264`(角色光与风强度传给 SpeedTreeForest
改动:
`extension/src/tree_placeholder.cpp` —— 将叶片 proxy 的 `wind_strength` 从错误的 `1.0` 修正为
参考缺省 `0.2`,并注明 `Environment_Load` 不读取风 token
`extension/src/environment_builder.cpp` —— 将 `msenv_wind_strength=0.2`、
`msenv_wind_random=0.0` 写入 `WorldEnvironment` 元数据;
`project/environment_test.gd` —— 增加两个缺省值断言。
测试:`cmake --build build/extension -j2`、`environment_test.gd`、`skybox_test.gd` 均通过。
验收:未验证——真实 SpeedTree 几何、森林级风 shader、随机风语义和真机视觉。
风险与依赖:当前 `.spt` 仍是程序化 proxy;不能把 proxy 的确定性摆动等同于
`CSpeedTreeForest::SetWindStrength` 的完整实现。
下一批次:W4 继续纹理天空 + 云层叠加 / 云层动画;W5 继续 Mac App G2。
```
```text
批次:2026-09-02 / W4 / §7.5texture-mode 天空与云层组合)
状态:🔎 待真实验收(真实 DawnMistWood 资源、shader 参数和 shader 编译回归完成;真机视觉未验)
参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557`(云面四边形与 UV;
`40250/ClientVS22/source/EterLib/SkyBox.cpp:869-927`(云面高度、纹理矩阵、滚动和颜色 / blend);
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`SkyBox 参数应用)
改动:
`extension/src/environment_builder.cpp` —— texture mode 不再因存在 CloudTexture 而回退,
sky shader 增加有限水平云面:CloudScale / CloudHeight 从参考厘米换算为 Godot 米,
CloudTextureScale / CloudSpeed 映射到 UV 缩放和 `TIME` 秒级滚动;按参考
`MODULATEINVALPHA_ADDCOLOR` + `ONE/INVSRCCOLOR` 合成云色;渐变天空继续使用 sky cover;
新增 `msenv_sky_cloud_applied` 元数据。
`project/skybox_test.gd` —— 增加真实 `metin2_map_dawnmistwood`,断言五面天空、CloudTexture、
2000m 云面、0.001 秒级速度和 shader 组合。
测试:`git diff --check`、`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure -j1`、`environment_test.gd`、`skybox_test.gd` 通过;
skybox_test 串行加载真实 CapeDragonHead + DawnMistWood,未出现 shader 编译错误。
验收:未验证——云层渐变过渡、滤色实际合成、SpeedTree 完整风语义、lens flare、昼夜天气联动、
完整资源包和真机视觉。
风险与依赖:Godot sky shader 是对参考独立云面 pass 的等价投影近似;当前只应用 CloudColor
首个颜色,完整 `StartTransition` / `SetCloudColor` 时间过渡仍待迁移;`SkyBoxScale` 与 D3D
透视裁剪也没有 1:1 的 Godot sky 参数。
下一批次:W4 继续云层颜色过渡、滤色 / lens flare、SpeedTree 真实几何;W5 继续 Mac App G2。
```
```text
批次:2026-09-02 / W4 / §6.3EffectElement MovingType 与发射边缘)
状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 advanced / cycle、完整时间轴、真机视觉未验)
参考:40250/ClientVS22/source/EffectLib/EffectElementBase.cpp:5-60(位置关键帧边界、直接插值、二次
Bezier);EmitterProperty.h/.cppPOINT / ELLIPSE / SQUARE / SPHERE、FREE / OUTER / INNER、edge;
ParticleSystemInstance.cpp:106-181,286-296(发射边缘、advanced 速度和 cycle 生命周期)。
改动:
project/fx/effect_player.gd —— 新增 `_position_at()`,按参考在关键帧前后钳制、直接段线性插值、
Bezier 段使用 `p0 + controlPoint` 控制点;粒子、Mesh 和 Light 节点按同一曲线更新;球体
`EmitterEmitFromEdgeFlag` 映射为 Godot `SPHERE_SURFACE`,并保存 advanced / edge 元数据。
project/fx_test.gd —— 增加直接段中点、二次 Bezier 中点、球体 edge 和 advanced 元数据断言。
测试:`effect_player.gd --check-only``fx_test.gd` 串行运行并输出 PASS;C++ 扩展构建通过。
验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 速度、完整 emitter cycle /
loop、所有时间曲线和 Actor 骨骼挂点;真实 40250 效果逐项视觉、真服和 Mac 真机未验。
风险与依赖:当前 edge 只对球体映射为 Godot 有对应的 surface shapeELLIPSE / SQUARE 的精确
边缘采样、advanced 速度和 per-particle custom data 仍需继续读取参考实现,不以元数据保存冒充完成。
下一批次:W4 继续 emitter advanced / cycle、TexAni 随机 / 每粒子语义或 `.msenv`W3 继续 CJK IME /
完整 EventSet;W2 继续交易事务;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W4 / §9.4AreaAmbienceData 与 Miles 环境声)
状态:🔎 待真实验收(代码 + 离线 / 真实地图回归完成;`.msenv` 环境参数联动、完整资源听感、真服 / 真机未验)
参考:40250/ClientVS22/source/GameLib/Area.cpp:882,1316-1394(环境声文件加载、严格范围判断、
LOOP / ONCE / STEP 生命周期与距离音量);MapType.cpp:195-260Ambience `.pra` 字段)。
改动:
extension/src/metin2_world.cpp/.h —— C++ 世界层解析每个已加载区块的 `AreaAmbienceData`,由
`.pra` CRC 提供 position / range / max volume / play type / interval / sound vector;与对象
渲染和 splat 开关解耦,支持 `areaambiencedata.txt` / `AreaAmbienceData.txt`,区块卸载时清理来源。
project/audio.gd —— 对齐参考的 `< range`、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 + 随机变化、
离开区域停止;Miles 3D 相对坐标先从世界米换回厘米再除以缩放。
project/game_scene.gd —— 每帧消费世界层 `get_ambience_sources()`project/ambience_test.gd ——
使用真实 OutdoorA3/003003 和 `warp_test.mp3` 验证来源、`.pra` 字段和路径。
测试:`cmake --build build/extension -j2` 通过;`ambience_test.gd`、`audio_miles_test.gd` 串行运行
并输出 PASS`formats_map_test`(含真实 A1 地图)通过。
验收:未验证——`.msenv` 的云层 / 滤色 / 风 / lens flare / 双方向光 / 昼夜天气联动、全部环境声音资源、
长时间循环与切图听感、
40250 真服和 Mac 真机空间听感。
风险与依赖:环境声来源现在随区块加载 / 卸载变化,Audio 以 key 做实例生命周期;资源缺失会安全
跳过,不能把来源发现成功等同于音频解码和真实听感通过。
下一批次:W4 继续 `.msenv` / 完整声音节点生命周期或 emitter advancedW3 继续 CJK IME / 完整
EventSet;W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W4 / §6.3SimpleLight / TexAni 基础子集)
状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 MovingType / 时间轴、
Direct3D 材质 stage、SpeedTree、`.msenv`、真实资源逐项视觉和真机未验)
参考:40250/ClientVS22/source/EffectLib/SimpleLightData.cpp(默认值、字段加载、GetRange 曲线);
SimpleLightInstance.cpp(生命周期、loop / loop count、范围 / 颜色 / 位置更新);
ParticleProperty.h / ParticleSystemInstance.cpp(旋转类型和 TexAni 基础语义)。
改动:
project/fx/mse.gd —— 暴露 Light 组及 `MaxRange`、ambient/diffuse、attenuation、duration、loop /
loop count、`TimeEventRange`。
project/fx/effect_player.gd —— 将 Light 组构建为 `OmniLight3D`,按范围曲线和生命周期更新;粒子
多贴图支持 `TexAniDelay`、顺 / 逆序,旋转类型区分 `NONE/TIME_EVENT/CW/CCW`。
project/fx_test.gd —— 增加 Light 解析、范围曲线、开始时间和生命周期可见性断言。
测试:`fx_test.gd` 通过;此前 `.mde`、`.mss`、玩家 / 怪物动作和场景回归保持通过;`git diff --check`
通过。
验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、完整 emitter advanced / cycle、MovingType /
时间轴、真实 40250 效果资源逐项视觉和 Mac 真机。
风险与依赖:Godot `OmniLight3D` 的衰减和 Direct3D 固定功能材质不是逐像素等价;未将近似结果宣称为
1:1。随机和每粒子语义需要独立粒子数据或 shader / custom data 支持。
下一批次:W4 继续 MovingType / 完整时间轴或 `.msenv`W3 继续 CJK IME / 完整 EventSet
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W4 / §6.3EffectMesh .mde 网格与帧播放)
状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni 完整帧语义、MovingType / 骨骼挂点、
真实资源逐项视觉和真机未验)
参考:40250/ClientVS22/source/EffectLib/EffectMesh.cppEffectData / MDEData002 二进制布局、
索引顶点展开、UV.y 翻转、DiffuseMap / IFL 贴图);EffectMeshInstance.cppMeshFrameController、
MeshElement 的 billboard / blending / visibility / alpha)。
改动:
project/fx/mde.gd —— 新增 v1/v2 读取、数量 / 文件边界校验、每帧 visibility / 几何数组、索引
TriangleList 展开、UV 翻转和厘米到米换算。
project/fx/mse.gd —— 暴露 MeshAnimationLoopEnable / LoopCount。
project/fx/effect_player.gd —— 去除 `.mde` box 占位,创建真实 ArrayMesh,播放几何帧,接入
`.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;修复真实
`.mse` 触发的 Gradient 单点移除错误。
project/fx_test.gd —— 使用 `geompung.mse` + `geompung.mde` 锁定 v1 的 3 个 geometry、2 帧、
展开顶点 / UV 和非占位 MeshInstance3D。
测试:`fx_test.gd` 通过;`git diff --check` 通过。
验收:未验证——SimpleLight、TexAni 的完整参考帧控制、MovingType / 完整时间曲线、骨骼挂点、
真实效果资源逐项视觉、40250 真服和 Mac 真机渲染。
风险与依赖:`.mde` 参考端将索引数据展开为 `TPTVertex`,当前保持同一渲染顺序;Godot 的材质 /
billboard / blend 是等价近似而非 Direct3D stage 逐像素复制。缺失或损坏的可选贴图安全回退,
不把回退资源当作 1:1 视觉通过。
下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`W3 继续 CJK IME / 完整 EventSet
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
```
```text
批次:2026-09-02 / W2 / §3.7(受击状态统一 / 删除客户端硬直)
状态:✅ 完成(离线)——参考语义移植 + C++ 19/19 + 相关 GDScript 回归全绿;
远端实体 knock_down 填充与真实 40250 受击时序仍待后续增量 / 真服验收。
参考:REF/UserInterface/InstanceBase.cpp:1247`__CanProcessNetworkStatePacket` 只查
`IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`);
REF/UserInterface/InstanceBaseBattle.cpp:355`NEW_Attack` 门:`IsDead / IsStun /
IsKnockDown / IsUsingSkill`)、:310`NEW_UseSkill` 门);
REF/UserInterface/InstanceBaseEffect.cpp:932`case AFFECT_STUN` → `SetStun`);
40250/ClientVS22/source/GameLib/ActorInstanceSync.cpp:43-71`__IsSyncing` =
`IsDead() || IsStun() || IsPushing()``InterceptOnceMotion(NAME_DAMAGE_FLYING)`
作击退动作,`IsPushing()` = 物理 blending 中)。
改动:
extension/src/net/entity_store.h —— `Entity` 增加 `bool knock_down`;声明
`mut_knockdown(uint32_t vid, bool on)`
extension/src/net/entity_store.cpp —— `mut_knockdown` 落位(值变才标脏 `ChangeKind::Info`);
extension/src/net/m2_client.cpp —— `entity_dict` 暴露 `d["knock_down"]`
project/net_play.gd —— 删除 `const HITSTUN` / `var _hitstun_until` 及全部引用;
新增 `var _knock_down` / `var _using_skill`;新增
`_main_entity()` / `_can_process_network_state()` / `_can_attack()` /
`_can_use_skill()``is_stunned()` 改为兼容 shim`_on_damage` 只置
`_knock_down` + 播 `damage` + 抖屏;`_process` 轮询
`player_view.is_in_hit_reaction()` 清 `_knock_down``pc.frozen` 与攻击闸门
改走新函数;
project/ui/player_view.gd —— `_on_playback_finished` 增加 `_state == "damage"`
分支(一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`
project/combat_fx_test.gd —— 断言改写:命中→`_knock_down` 置位且不能行动;
服务端 `stunned` 标志独立驱动闸门;击退动作结束 / 闪避 / 他人受击不置位。
测试:`cmake --build build -j4``ctest --test-dir build --output-on-failure` → 19/19
`godot --headless --path project --script combat_fx_test.gd` → PASS
`netplay_test` / `netbridge_test` / `gamescene_test` / `input_key_test` /
`p2b_test` / `player_motion_test` → PASS。
验收:未验证——远端实体 `knock_down` 的接线(net_world 从 motion 8/9 驱动、
从 wait/walk/run/dead 清零)留待后续增量;真实 40250 服务端的受击 / 击退 / stun
时序与 `AFFECT_STUN` 清除路径仍需真服证据。
风险与依赖:本地击退时长依赖 `player_view` 的 `damage.msa` 能正常发
`playback_finished`(与既有 `__motion` 分支同一机制);`stunned` 目前只有
`mut_stun` 置位、无独立清除包,属另一处已知缺口(§3.7 范围外)。
下一批次:W2 §3.3 点击距离常量 / §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型;
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
```
```text
批次:2026-09-02 / W2 / §3.3(普攻节奏硬编码 → 动作数据驱动)
状态:✅ 完成(离线)——参考字段移植 + C++ 19/19 + 相关 GDScript 回归全绿;
弓箭 __GetBowRange() 与 §3.5 命中窗接入仍待后续增量 / 真服验收。
参考:40250/ClientVS22/source/EterGrnLibCGrannyMotion::LoadMotionData →
CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime,
fComboLinkTime} / TAttackingData{...});
REF/UserInterface/InstanceBaseBattle.cpp:294`NEW_IsClickableDistanceDestInstance`
默认 150 / IsBowMode → __GetBowRange / IsNPC → 500 / IsResource → 100,单位 cm);
REF/GameLib/ActorInstanceBattle.cpp:157`InputComboAttackCommand` 用
GetComboInputStartTime/GetNextComboTime/GetComboInputEndTime;无 combo 数据退化为
`fElapsedTime > GetMotionDuration() * 0.9f`);`CInstanceBase::SetAttackSpeed`
bAttackSpeed/100 只缩放动作播放速率)。
改动:
formats/msa.h / msa.cpp —— `struct Msa` 增加 `has_combo_input` +
`combo_pre_input_time / combo_direct_input_time / combo_input_limit_time /
combo_link_time`、`has_attacking_data` + `attacking_type / hitting_type /
attack_start_time / attack_end_time / stiffen_time / invisible_time /
external_force`;新增 `combat_data()` 读取 `Group ComboInputData` /
`Group AttackingData``parse_msa` 与 `parse_msa_file` 均调用;
formats/tests/msa_test.cpp —— 无战斗组时标志为 false;再解析真实
`onehand_sword/combo_01.msa` 形状,逐字段断言值;
extension/src/metin2_anim.h / metin2_anim.cpp —— 新增并绑定 `get_motion_data()`
导出 `duration / next_combohas_combo_input && DirectInputTime>0 ?
DirectInputTime : duration*0.9/ attacking_type / hitting_type /
attack_start_time / attack_end_time / stiffen_time / invisible_time /
external_force` 等;
project/net_play.gd —— 删除 `const ATTACK_RANGE`,新增
`CLICK_DIST_DEFAULT_CM=150 / CLICK_DIST_NPC_CM=500 / CLICK_DIST_RESOURCE_CM=100`
与 `_clickable_distance(te)`(按 `_entity_kind` 选,cm→m);`_apply_attack_speed()`
只把 `bAttackSpeed/100` 夹到 `[0.25,3]` 写 `_atk_speed_factor`,不再写死
`attack_period`;新增 `_current_attack_period()` =
`(get_motion_data().next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`
命中点用它刷新 `attack_period` / `_attack_cd``_process` 距离判定改用
`_clickable_distance(te)`
project/combat_fx_test.gd —— 移除已删除的 `np.ATTACK_RANGE` 引用,改断言
无动作数据时节奏回退 `DEFAULT_ATTACK_PERIOD`、攻速 150 → factor 1.5 且节奏按其缩放;
project/netplay_test.gd —— `attack_speed:150` 改断言 `_atk_speed_factor==1.5` 与
`_current_attack_period()==DEFAULT_ATTACK_PERIOD/1.5`(不再断言收包即写死 0.4)。
测试:`cmake --build build -j4``ctest --test-dir build --output-on-failure` → 19/19
`formats.msa_loop_data` 覆盖扩展断言);
`godot --headless --path project --script combat_fx_test.gd` → PASS
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
`p2b_test` → PASS。
验收:未验证——弓箭 `__GetBowRange()` 等价、`.msa` `AttackingData` /
`List HitPosition` 驱动的命中帧 / 硬直 / 无敌帧(§3.5)、真实 40250 服务端的
普攻节奏与攻速时序。
风险与依赖:headless 无 `player_view` 时 `get_motion_data()` 取不到,节奏退化为
`DEFAULT_ATTACK_PERIOD`(0.6s)——与参考端「无 combo 数据 → duration*0.9」是同类兜底;
`.msa` 的 combo 组是否随每个连击段切换 clip 而更新,取决于 `player_view.anim`
的 `anim_path` 是否切到对应 `combo_0N.msa`,该切换属 §3.5 范围。
下一批次:W2 §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型 / §3.2 命令队列同步;
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
```
```text
批次:2026-09-02 / W2 / §3.4(自动攻击目标预留状态机)
状态:✅ 完成(离线,CLICK_ACTOR 全量)——参考 10 步逐条移植 + `netplay_test` 5b 段 +
C++ 19/19 + 其余 GDScript 回归全绿;CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派留桩,
安全区 / 弓箭 / 骑乘 / dash 判定位待服务端下发或后续增量。
参考:REF/UserInterface/PythonPlayer.cpp:207`__Update_AutoAttack`)、:182
`__IsUsingChargeSkill`);REF/UserInterface/PythonPlayerInput.cpp:820-865(预约模式枚举 /
字段)、:877`__ReserveProcess_ClickActor` 10 步)、:306`__OnPressActor`);
REF/UserInterface/PythonPlayerInputMouse.cpp:287`NEW_RefreshMouseWalkingDirection`
模式分派);OnCannotAttack / OnCannotShot 字符串码经 game.py 同名回调 → localeInfo。
改动:
project/net_play.gd ——
· 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}`
与 `_reserved_mode / _vid_reserved / _iid_reserved / _ppos_reserved /
_skill_slot_reserved / _skill_range_reserved / _reserved_delay / _auto_attack_vid /
_last_cannot`;服务端未下发字段的本地占位 `_arrow_count(-1) / _dash_active /
_charge_skill`;新增 `signal cannot_act(code)`
· `_process` 重写:帧序 knockdown 清闸 → `pc.frozen` → 环境守卫(observer / 未在游戏 /
无 pc.player 时清预约)→ `_attack_cd -= dt` / `_reserved_delay -= dt` →
`_update_auto_attack()` → `_refresh_reserved()`
· `_update_auto_attack()``!_attack_key_down || _target_vid==0` 清 auto-attack
`!_can_process_network_state()` / `_dash_active || (_using_skill && _charge_skill)`
直接返回;目标不存在 / 死亡 / 骑乘且 `!_can_attack_horse_level()` → `_clear_auto_attack()`
可攻击且非睡眠且 `_reserved_mode==NONE && _can_attack()` → `_reserve_click_actor()`
· `_reserve_process_click_actor()` 10 步:① 空引用 / 无节点 → `_clear_reserved()`
② `dist > _clickable_distance(rv)` → `pc.walk_to(tnode)` ③ `!_is_attackable` →
`pc.frozen=false` + `client.click_npc()` + 清预约 ④ 目标死 ⑤ `main.in_safe` →
`_emit_cannot("IN_SAFE")` ⑥ `rv.in_safe` → `"DEST_IN_SAFE"` ⑦ `_dash_active` 保持预约
⑧ `main.bow_mode && _arrow_count==0` → `_emit_cannot("EMPTY_ARROW")` ⑨
`_target_vid != _vid_reserved` → `client.set_target` + `target_changed` + HUD 刷新
⑩ `_attack_cd<=0` 才 `_do_attack_swing()`,然后 `_clear_reserved()`
· `_do_attack_swing()`:面向目标 + combo 窗口递增 + `client.attack(motion+combo, vid)` +
`player_view.set_anim_state("combo"/"attack")` + `attack_period = _current_attack_period()`
(§3.3);
· `_is_attackable(e)` = 非死亡且 `_entity_kind in [0,2]`PC / MONSTER;帝国 / 决斗归 §5.4)。
project/netplay_test.gd —— `FakePC` 加 `walk_to()` / `walk_calls` / `frozen`;新增 5b 段:
出可点击距离 → `walk_to` 且不发攻击、预约保持 CLICK_ACTOR;进距离 → 攻击且预约清空;
`fc.ents[1000].in_safe=true` → `cannot_act("IN_SAFE")` 且不发包;目标死 → `_auto_attack_vid==0`。
测试:`ctest --test-dir build` → 19/19
`godot --headless --path project --script netplay_test.gd` → PASS
`combat_fx_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
`p2b_test` / `input_key_test` → PASS。
验收:未验证——CLICK_ITEM`< 20.0f` 拾取阈值 + `_reserved_delay`/ CLICK_POSITION /
USE_SKILL`IS_HUGE_RACE` 200 修正 / charge skill)分派;安全区 / 弓箭模式 / 箭矢数 /
骑乘等级 / dash affect 的真实服务端来源;`OnCannot*` 文案接 HUD;真实 40250 自动攻击时序。
风险与依赖:本工程用「按住攻击键 + 有目标」当 `__OnPressActor(isAuto)`;参考端 auto-attack
不依赖按键,接入真实输入映射时需把 `_attack_key_down` 换成真正的 auto-attack 目标设置点。
判定位占位读实体字典键(`in_safe` / `bow_mode` / `mounting` / `sleeping`),服务端未填时
恒为安全默认(可打、不在安全区、非骑乘),与 §3.7 `knock_down` 同套做法。
下一批次:W2 §3.1 Src-Dst 移动模型 / §3.2 命令队列同步;之后 §3.5 攻击动作-命中-封包绑定、
§3.6 箭矢 FLY、§3.8 技能合法性。
```
```text
批次:2026-09-02 / W2 / §3.1(本地移动 Src/Dst 模型)
状态:✅ 完成(离线,本地移动切片)——`NEW_Goto` 前置门 + Src/Dst + `__ReserveClickGround`
0.1 s 重试逐条移植 + 新增 `player_move_test.gd`5 组)+ C++ 19/19 + 其余 GDScript 回归全绿;
Actor 间碰撞与坐骑 `MODE_HORSE*` 留后续增量。
参考:REF/UserInterface/InstanceBaseMovement.cpp:251`CInstanceBase::NEW_Goto`)、:293
`NEW_MoveToDirection`);REF/UserInterface/PythonPlayerInput.cpp:419`__OnPressGround`)、
:445`NEW_MoveToDirection`)、:626`NEW_IsEmptyReservedDelayTime`)、:834
`__ReserveClickGround` 的 `m_fReservedDelayTime = 0.1f`);
REF/UserInterface/InstanceBaseBattle.cpp:469`CheckAdvancing`)、:560`ATTRIBUTE_BLOCK`)。
改动:
project/player_controller.gd ——
· 删除 `var _move_target` / `var _has_target` 的直线改坐标;
· 新增 `const MOVABLE_GROUND_DISTANCE_M := 1.0` / `const RESERVED_GROUND_DELAY := 0.1`、
`var locked` / `var moving_skill`、`_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot` /
`_reserved_ground` / `_reserved_delay_time``is_going()` / `_can_translate()`
= `not frozen and not locked and not moving_skill`);
· `_goto(dst_flat)` 对齐 `NEW_Goto``flat.length() < MOVABLE_GROUND_DISTANCE_M` → 返回
true 不动(≈ `__IsMovableGroundDistance`);`!_can_translate()` → 存 `_reserved_ground`
+ `_reserved_delay_time = RESERVED_GROUND_DELAY` 返回 false(≈ `__ReserveClickGround`);
否则 `_src_pos = 当前` / `_dst_pos = 点` / `_dst_rot = atan2(flat.x, flat.z)` /
`_is_going = true``walk_to()` → `_goto()``_on_click` 点地分支 → `_goto(hit)`
· `_process`:新增 `_reserved_delay_time` 递减,归 0 且 `_can_translate()` → 重跑
`_goto(_reserved_ground)`(≈ `NEW_IsEmptyReservedDelayTime` 递减重试);`frozen or locked`
时发 `wait` 且**保留** `_is_going`(解冻后继续走);WASD 分支置 `_is_going = false`
(≈ `NEW_MoveToDirection` 的 `m_isGoing = FALSE`);点走分支用 `_is_going` / `_dst_pos`
平移段包一层 `if moving_skill: 只 rotation.y = atan2(wish.x, wish.z) else: <平移>`
平移到位 `_is_going = false`。
project/player_move_test.gd(新增)—— headless `SceneTree`:① `_goto(远)` 设
Src/Dst/is_going/dst_rot;② `< 1 m` 太近不动;③ 每帧推进抵达清 `is_going`
④ `frozen` → `_goto` 返回 false + 预约,解冻后 0.1 s delay 递减自动重试;
⑤ `moving_skill` → `_goto` 不平移。
测试:`ctest --test-dir build` → 19/19(本增量无 C++ 改动);
`godot --headless --path project --script player_move_test.gd` → PASS
`netplay_test` / `combat_fx_test` / `gamescene_test` / `eterngrn_polish_test`exit 0
既有无关 `motion_event: sound` FAIL/ `player_motion_test` / `p2b_test` /
`netbridge_test` / `mouse_controller_test` → PASS。
验收:未验证——Actor 间 `TestActorCollision` / `BlockMovement`(需远端 Actor 碰撞体)、
坐骑 `MODE_HORSE*` 移速与动作模式(依赖 §6.4 + §3.2 命令队列)、`MOVABLE_GROUND_DISTANCE`
参考默认值(现取 1.0 m,待读 `game.py` 侧 `player.SetMovableGroundDistance`)、
网络状态包驱动移动的 `__EnableSkipCollision()`(§3.2)。
风险与依赖:`_can_translate()` 把 `__IsSyncing`Dead/Stun/Pushing)近似成 `frozen`
真实同步态位待 §3.2 命令队列接入后细化。碰撞仍只有地形阻挡(沿用 §3.1「当前」实现),
Actor 叠站问题留到有碰撞体时解决。
下一批次:W2 §3.2 命令队列同步(`EntityStore` 状态队列 + 硬阈值,不引入预测回滚 / 抖动缓冲);
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
```
```text
批次:2026-09-02 / W2 / §3.2(远端同步命令队列 —— C++ 核心,part A)
状态:🟡 部分完成——`EntityStore` 命令队列 + 硬阈值 switch + 释放门已就位;net_play
上行 6 回调、net_world 去自有 lerp、真实服务器帧钟留给增量 78。
参考:REF/UserInterface/InstanceBase.cpp:1278`CInstanceBase::StateProcess`)——
`while` 队列非空:`ELTimer_GetServerFrameMSec() < front.m_dwChkTime` → returnpop
`!__CanProcessNetworkStatePacket()`Dead / KnockDown / 不可取消技能)→ return
`!__IsEnableTCPProcess(eFunc)`(表情锁)→ return`switch (eFunc)`
`FUNC_WAIT` dirLen>1.0 → 走 + `m_kMovAfterFunc=WAIT``FUNC_WAIT` else →
`SCRIPT_SetPixelPosition(dst)` 贴齐;`FUNC_MOVE` → 恒走(after MOVE);
`FUNC_COMBO/ATTACK/MOB_SKILL` dirLen>=50.0 → 走到位再动作,else 贴齐 + 动作;
所有「走过去」分支 `__EnableSkipCollision()`。
REF/UserInterface/NetworkActorManager.cpp:8`SNetworkActorData::UpdatePosition`)——
`(now - m_dwClientSrcTime)/m_dwDuration` 线性插值,仅供可见性。
改动:
extension/src/net/entity_store.h ——
· `#include <deque>`FUNC_* 之后新增 `struct StateCmd { uint32_t chk_time;
float dst_x,dst_y,rot; uint8_t func,arg; uint32_t dur_ms; }`
· `Entity` 新增 `std::deque<StateCmd> state_queue` / `mov_after_func` /
`mov_after_arg` / `mov_after_rot` / `bool skip_collision`
· 新增 `set_server_frame_ms()` / `server_frame_ms()` / `process_states()`
`m_server_frame_ms = 0` 私有成员;`mut_move()` 签名加 `arg=0, chk_time=0`
· 删 `start_move()` 声明;加 `drain_state_queue` / `apply_state_cmd` /
`begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`。
extension/src/net/entity_store.cpp ——
· `#include <cmath>`;删 `start_move()` 定义;
· `mut_move()` 改为 `state_queue.push_back({...})` + `drain_state_queue(e)` +
`ChangeKind::Move`
· `drain_state_queue``while``m_server_frame_ms != 0 && m_server_frame_ms <
front.chk_time` → break`!can_process_network_state(e)` → break
`!is_enable_tcp_process(e, front.func)` → breakpop + `apply_state_cmd`
· `apply_state_cmd``FUNC_WAIT` dir_len>1.0 → `begin_state_walk(WAIT)`else
贴齐 + `func=WAIT` + `skip_collision=false``FUNC_MOVE` → `begin_state_walk(MOVE)`
`FUNC_COMBO/ATTACK/MOB_SKILL` dir_len>=50.0 → `begin_state_walk(c.func)`else
贴齐 + `func=c.func`default 贴齐 + `func=c.func`
· `begin_state_walk``angle/moving/func=FUNC_MOVE/sx,sy/tx,ty/move_start_ms/
move_dur_ms` + `mov_after_*` + `skip_collision=true`walk 时长优先 `c.dur_ms`
为 0 时 `dist / (moving_speed>0?moving_speed:150) * 1000`min 1);
· `can_process_network_state` = `!e.dead && !e.knock_down`
`is_enable_tcp_process` = `true`(表情锁待增量 78);
· `process_states()` 遍历 `m_ents` → `drain_state_queue`
· `tick()`:插值循环前先 `process_states()``t>=1.0f` 到达分支加
`e.skip_collision=false` + `mov_after_func` 为 COMBO/ATTACK/MOB_SKILL 时
`func=mov_after_func; angle=mov_after_rot`,否则 `func=FUNC_WAIT`;清 `mov_after_func`
· m2dev `apply()` 的 GC_MOVE 分支:`mut_move(vid, rot*360/256, func, x, y,
duration, arg, time)`。
extension/src/net/classic/classic_parser.cpp —— HDR_GC_MOVE`mut_move(vid,
rot*5.0f, func, x, y, duration, arg, time)`。
extension/tests/net_entity_test.cpp —— 旧 FUNC_ATTACK 贴齐单测拆成「近」(dir_len
30<50 → 贴齐 + `func==FUNC_ATTACK`+「远」(dir_len 170>=50 → 先 `func==FUNC_MOVE`
走,`tick()` 后到达 Dst + `func==FUNC_ATTACK`)。
extension/tests/net_state_queue_test.cpp(新增,5 组)——① 服务器帧钟释放门(未来
chk_time 挂队;帧钟追上才释放;帧钟 0 立即释放);② FUNC_WAIT 1.0 阈值(贴齐 vs 走 +
after WAIT + skip_collision);③ FUNC_COMBO 50.0 阈值 + 到达后动作 + arg 锁存;
④ `__CanProcessNetworkStatePacket` 门(knock_down / dead 挂队,恢复即释放;未知 vid 不崩);
⑤ 多条到期命令按序一次性 drain,末条胜出。
extension/CMakeLists.txt —— 加 `net_state_queue_test` → `add_test(net.state_queue)`。
测试:`ctest --test-dir build` → 20/20(新增 `net.state_queue`);
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
`combat_fx_test` → PASS`eterngrn_polish_test` exit 0(既有无关 `motion_event: sound` FAIL)。
验收:未验证——真实 40250 `GC_HANDSHAKE` `lDelta` 帧钟对齐;`GC_SYNC_POSITION` 是否应
清队列;`__IsEnableTCPProcess` 表情锁;net_play 上行节流表(100/300 ms + disp≥1.0)与
net_world 去 lerp(都在增量 78)。
风险与依赖:`m_server_frame_ms == 0` 时释放门直通 = 退回增量 76 的即时时序,接真实帧钟
(增量 78)前无回归但也无抖动吸收。`is_enable_tcp_process` 目前恒 `true`,表情锁
`FUNC_EMOTION` 期间拒 TCP 状态)待 §3.x 表情切片。Actor 碰撞近似仍未做,`skip_collision`
标志已就位但 `tick()` 尚未据此跳过碰撞体(无碰撞体,暂无影响)。
下一批次:W2 §3.2 part B(增量 78)—— net_play.gd 上行 6 事件回调 + 节流常量、
net_world.gd 去掉自有 lerpC++ 插值只喂 §2.1 可见性)、从 `GC_HANDSHAKE` `lDelta`
接 `set_server_frame_ms()`;之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
```
```text
批次:2026-09-02 / W2 / §3.2 part B(上行 6 事件回调 + net_world 去 lerp
状态:🟡 部分完成——上行 6 回调 + 节流表 + net_world 去自有 lerp 落地;真实服务器
毫秒帧钟(`GC_HANDSHAKE` `lDelta` → `set_server_frame_ms()`)留给增量 79。
参考:REF/UserInterface/PythonPlayerEventHandler.cpp —— OnMove(:76) 重置 wait+100ms /
moving+300ms 后发 FUNC_MOVE,0OnMoving(:62) 300ms 节流 → FUNC_MOVE,0OnWaiting(:38)
100ms 且 disp≥1.0 → FUNC_WAIT,0OnStop(:88) 不节流 → FUNC_WAIT,0OnAttack(:102)
不节流 → FUNC_COMBO,wMotionIndexOnUseSkill(:120) 不节流 → FUNC_SKILL|uMotSkill,uArg。
REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:1111`SendCharacterStatePacket`)——
`TPacketCGMove{ bFunc, bArg, bRot=rot/5.0f, lX, lY, dwTime }`,角度归一 [0,360)。
REF/UserInterface/NetworkActorManager.cpp:8`SNetworkActorData::UpdatePosition`)——
位置线性插值只供可见性;节点位置改由 C++ 命令队列(增量 77)驱动。
改动:
project/net_play.gd ——
· 删 `const SEND_INTERVAL` / `const SEND_DIST` / `var _last_sent_t`;新增
`const FUNC_COMBO=3` / `const FUNC_SKILL=0x80` / `SEND_MOVING_INTERVAL=0.3` /
`SEND_WAITING_INTERVAL=0.1` / `SEND_WAITING_MIN_DISP_CM=1.0``_last_sent_pos`
改存世界坐标,新增 `_last_moving_sent_t` / `_last_waiting_sent_t`。
· 新增 `_send_state(func, arg, world_pos)` 统一出口 → `client.move(func, arg,
heading, cm.x, cm.y)` + 记 `_last_sent_pos`。
· `_on_local_moved``!_was_moving` → OnMove(立即 `_send_state(FUNC_MOVE,0)` +
`_last_moving_sent_t = _last_waiting_sent_t = now`);否则 OnMoving`now -
_last_moving_sent_t < SEND_MOVING_INTERVAL` 则 return,否则发 `FUNC_MOVE`)。
· `_on_anim_state`"wait" 且 `_was_moving` → OnStop `_send_state(FUNC_WAIT,0)`。
· 新增 `_tick_on_waiting()``_process` 尾部调):`!_was_moving` 时 100ms 节流 +
`_server_xy(player) 距 _server_xy(_last_sent_pos) >= 1.0` 才 `_send_state(FUNC_WAIT,0)`。
· `_do_attack_swing``client.attack()` 后加 `_was_moving = false` +
`_send_state(FUNC_COMBO, attack_motion + _combo, player.pos)`OnAttack)。
· 新增公共 `on_use_skill(motion_skill, arg)` → `_send_state(FUNC_SKILL |
(motion_skill & 0x7F), arg, player.pos)`OnUseSkill;调用点属 §3.8 `player_skill.gd`)。
project/net_world.gd ——
· `_process``n.position = _grounded(e.get("pos", n.position))` 直接跟随,删掉
`if dist > snap_dist ... else n.position.lerp(want, follow_lerp*dt)` 的自有二次平滑。
· `follow_lerp` / `snap_dist` 字段保留(外部读取 / 旧测试兼容),`_process` 不再引用。
project/netplay_test.gd —— 观战段基线改「相对计数不变」(`move_n` / `target_n`);
新增第 11 段:OnMove 立即发 + 重置节流窗、OnMoving <300ms 节流 / 过期后放行、
OnStop → FUNC_WAIT、`on_use_skill(5,2)` → `move[0] == [FUNC_SKILL|5, 2, ...]`。
project/netbridge_test.gd —— 「near move → lerp closes in」改为「move → node follows
e.pos directly」(`distance < 0.01`)。
测试:`ctest --test-dir build` → 20/20(无 C++ 改动);
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
`combat_fx_test` / `player_motion_test` → PASS。
验收:未验证——真实 40250 上行时序 / 服务端对 6 类 func 的接收;`OnWaiting` 的 `disp≥1.0`
单位(现按服务器 cm);`OnAttack` 额外状态包与 `CG_ATTACK` 的先后;`on_use_skill` 的
真实调用点(§3.8)。
风险与依赖:位置插值全交 C++ `EntityStore::tick()` 后,`net_world` 只跟随 `e.pos`——
若 C++ 帧钟(增量 79)未接,命令仍即时释放,观感等同增量 76 + 命令队列排序,无抖动吸收。
`_tick_on_waiting` 用本地 `Time.get_ticks_msec()`,接真实服务器帧钟前节流窗口是客户端时钟。
下一批次:W2 §3.2 part C(增量 79)—— 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟
`ClassicStream::m_server_time_base` / `m_client_time_base` 已就位,需暴露访问器并穿到
`EntityStore::set_server_frame_ms()`);之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、
§3.8 技能合法性。
```
```text
批次:2026-09-03 / W2 / §3.2 part C(真实服务器毫秒帧钟,§3.2 收官)
状态:✅ 完成
参考:REF/UserInterface/EterLib/Timer.cpp`ELTimer_GetServerFrameMSec()`)——
客户端帧钟 = 本地毫秒 - 握手时的本地基准 + 握手带来的服务器基准。
REF/.../PythonNetworkStreamPhaseHandShake.cpp:124`lDelta` 时钟对齐)。
改动:
extension/src/net/classic/classic_stream.h ——
· public 加 `uint32_t server_frame_ms() const;` + `bool has_server_clock() const
{ return m_client_time_base != 0; }`(附 §3.2 注释)。成员 `m_server_time_base` /
`m_client_time_base` 已存在(`:142-143`)。
extension/src/net/classic/classic_stream.cpp ——
· `server_frame_ms()` 定义:`m_client_time_base == 0` 返回 0,否则
`now_ms() - m_client_time_base + m_server_time_base``now_ms()` 为本文件匿名
命名空间里的 steady_clock 毫秒助手)。`HDR_HANDSHAKE` 处理里的
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`
`:266-267`)无改动。
extension/src/net/classic/classic_session.cpp ——
· `process()` 在 `m_world.tick()` 前加
`m_world.set_server_frame_ms(m_stream.server_frame_ms());``:307`)。
extension/src/net/entity_store.h ——
· `StateCmd` 后加 `static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;`
(防呆阀,附注释说明是对参考端的有意偏离)。
extension/src/net/entity_store.cpp ——
· `drain_state_queue` 帧钟门改为:`m_server_frame_ms != 0 && chk_time >
m_server_frame_ms && chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS`
才 break;超过 cap 的领先量当异帧钟 `dwTime`,立即放行。
extension/tests/net_classic_stream_test.cpp ——
· 新增「server frame clock (§3.2)」段:握手前 `server_frame_ms()==0` +
`!has_server_clock()`;喂一个 `hs.time=500000, delta=20` 的握手后
`has_server_clock()` 且帧钟 ∈ [500020, 505020);连读两次单调不减。
extension/tests/net_state_queue_test.cpp ——
· 新增第 6 段:`set_server_frame_ms(1000)``chk_time == 1000+cap` 仍 gated
另一实体 `chk_time == 1000+cap+1` 立即放行(防呆阀边界)。
测试:`ctest --test-dir build` → 20/20`net.classic_stream` / `net.state_queue` 覆盖新逻辑);
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
`combat_fx_test` / `player_motion_test` → PASS。
验收:未验证——真实 40250 `GC_MOVE` `dwTime` 是否与握手帧钟同一时基(若同,`STATE_QUEUE_
MAX_WAIT_MS` 阀几乎不触发;若不同,阀保证远端 Actor 不会永久冻结)。m2dev 后端仍无
帧钟来源,`m_server_frame_ms == 0` 即时释放 = 增量 76 时序,无回归。
风险与依赖:帧钟阀阈值 1 s 为经验值,联机核验后可收紧或去除。时间同步模式下每个
resync 握手都会刷新 `m_server_time_base` / `m_client_time_base`,帧钟持续对齐。
下一批次:§3.2 收官。W2 继续 §3.5 攻击动作-命中-封包绑定(`.msa` `AttackingData` /
`HitPosition`)、§3.6 箭矢 FLY 事件、§3.8 技能合法性(`player_skill.gd`,也是
`net_play.on_use_skill` 的真实调用点)、§3.10 技能升级 gating。
```
```text
批次:2026-09-03 / W2 / §3.5 part APC 连击段表解析)
状态:🟡 部分完成(增量 80
参考:
REF/GameLib/RaceData.h:9 —— MAKE_COMBO_KEY(motionMode, comboType)
= (DWORD(mode)<<16) | DWORD(type)
REF/GameLib/RaceData.cpp:405-475 —— CRaceData::ReserveComboAttackmap::insert
重复 key 不覆盖;ComboIndexVector.resize(count) 清零)
/ RegisterComboAttackkey 未登记 return
index >= size AssertLog+return;否则 vec[index]=motion
REF/UserInterface/PythonCharacterManagerModule.cpp:260-330 ——
chrmgr.ReserveComboAttackNew / RegisterComboAttackNew 绑定
assets/root/playersettingmodule.py —— 四个 def __LoadGame<Class>Ex(race, path)
Warrior@480 / Assassin@663 / Sura@876 / Shaman@1001),
函数体与性别无关 → 连击段表按职业收。
结论:PC 连击段表**不在** `.msm` 里(`.msm` 只有 BaseModelFileName + HairData)。
改动:
formats/combo_table.h(新)——
· enum ComboMotionModeCRaceMotionData::EMode0..16
· COMBO_NAME_NORMAL_ATTACK=13 / COMBO_NAME_COMBO_ATTACK_1=14 / _8=21
· make_combo_key / combo_key_mode / combo_key_type(对齐 RaceData.h
· enum ComboClass { WARRIOR=0, ASSASSIN=1, SURA=2, SHAMAN=3, COUNT=4 }
· struct ComboTable { map<uint32_t, vector<uint16_t>> combos;
get(mode,type) -> vec* 或 nullptr= GetComboDataPointer 语义); empty(); key_count(); }
· struct PlayerComboTables { ComboTable per_class[4]; klass(c) 越界 -> 空表 }
· parse_player_combo_tables(py_text, out, err) / _file(path, out, err)
formats/combo_table.cpp(新)——
· 逐行扫描;`def ` 行切块(只有四个 __LoadGame<Class>Ex 命中,其它 def 复位 cur_class=-1
· resolve_tokenchr.MOTION_MODE_*(静态 17 名表)、chr.MOTION_COMBO_ATTACK_N(→13+N)、
chr.MOTION_NORMAL_ATTACK(→13)、COMBO_TYPE_N / COMBO_INDEX_N(→N-1)、裸整数
· ReserveComboAttackNew(要求 3 实参)→ combos.emplace(key, vector(count,0))emplace 不覆盖)
· RegisterComboAttackNew(要求 4 实参)→ 找 key,未登记 continueidx 越界 continue
否则 vec[idx]=motion
formats/CMakeLists.txt ——
· xr_formats 源表加 combo_table.cpp
· 加 formats_combo_table_test+ M2_ASSETS 时透传做 live 断言)
formats/tests/combo_table_test.cpp(新)——
· 合成片段:warrior GENERAL/1H type1-2(含越界 Register + 重复 Reserve 各一),
assassin DUALHAND type3(只设 idx5),非 Ex 的 def __LoadGameNPC 里一条 stray Reserve
· 断言:key 编码、段号向量逐格、size 不被重复 Reserve/越界 Register 改动、
type3 未 Reserve→nullptr、职业隔离、块外调用不落任何职业(总 key_count==4)、
klass(-1)/klass(99) 空表不崩
· liveM2_ASSETS):真实 playersettingmodule.py → warrior 1H type1==[14,15,16,17]、
sura 1H type3 size 6 且末段==17、shaman FAN type1==[14,15,16,17]
测试:`ctest --test-dir build` → 21/21(新 `formats.combo_table` 含 live 断言 `M2_ASSETS` OK);
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
`combat_fx_test` / `player_motion_test` → PASS7/7)。
验收:解析器纯离线,合成 + 真实资源双覆盖,无需联机。
下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出,`msa.h` 目前只有
单个 `AttackingData` 窗)+ 修改 4`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` +
`FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16+ 武器种类 → `combo_motion_mode` 映射。
```
```text
批次:2026-09-03 / W2 / §3.5 part BPC 连击状态机)
状态:🟡 部分完成(增量 81
参考:
REF/GameLib/ActorInstanceBattle.cpp:157 —— InputComboAttackCommandm_isPreInput 时忽略;
m_dwcurComboIndex==0 → __RunNextCombo;否则按
GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime
判 pre-input 挂起 / 直接推进 / 弓模式 m_isNextPreInput
无 combo 输入组 → GetMotionDuration()*0.9f 兜底
REF/GameLib/ActorInstanceBattle.cpp:213 —— ComboProcessindex!=0 时 null-guard→__ClearCombo
m_isPreInput && t>GetNextComboTime → __RunNextCombo
index==0 时清 m_isPreInput!IsUsingSkill && m_isNextPreInput
→ __RunNextCombo
REF/GameLib/ActorInstanceBattle.cpp:256 —— __RunNextCombo++indexarrIdx=wComboIndex-1
GetComboDataPointer FALSE→returnOOB→TraceError+return
ComboAttack(vec[arrIdx]); index==size → __OnEndCombo
REF/GameLib/ActorInstanceBattle.cpp:297/308 —— __OnEndCombo(骑乘 index=1,否则不复位)/
__ClearComboindex=0isPreInput=FALSEpkCurRaceMotionData=NULL
REF/GameLib/ActorInstance.cpp:164 —— __GetCurrentComboType:弓 / GENERAL(手) / 骑乘 → 0
增量 80 的 formats/combo_table.{h,cpp} + assets/root/playersettingmodule.py
改动:
extension/src/metin2_anim.h / .cpp ——
· 新静态绑定 Metin2AnimPlayer.parse_combo_tables(py_path) -> { class:int ->
{ key:int -> PackedInt32Array(段号) } },转发 fmt::parse_player_combo_tables_file
· 新静态绑定 Metin2AnimPlayer.make_combo_key(motion_mode, combo_type) -> int
· `.msa` ComboInputData 三时间 / AttackingData 已在 get_motion_data() 暴露,无需改
project/net_play.gd ——
· 删 COMBO_WINDOW / _combo / _last_attack_t / attack_motion
· 加 MOTION_MODE_*GENERAL=1 / BOW=5 / HORSE 9..15 / HORSE_BOW=13)、
NAME_NORMAL_ATTACK=13 / NAME_COMBO_ATTACK_1=14
combo_class / combo_motion_mode / _combo_type / _combo_index / _is_pre_input /
_is_next_pre_input / _combo_started_t / _combo_tables
· set_asset_root(root) → parse_combo_tables(root/root/playersettingmodule.py)class_exists 守卫)
· set_combo_typeclamp 0..2/ set_combo_motion_mode(变更即 _clear_combo/
_is_bow_mode / _current_combo_type(弓/GENERAL/骑乘 → 0/ _combo_vec / _motion_data
· _on_entity_infoentity.race & 3 → combo_class
· _do_attack_swing / _run_next_combo / _combo_process / _on_end_combo / _clear_combo
逐行对齐参考端五函数;段表空 → _emit_swing(NAME_NORMAL_ATTACK)
· _emit_swingclient.attack(0, vid)CG_ATTACK bType=技能号,普攻 0+
_send_state(FUNC_COMBO, 段号)(这一包才带 14..21)——两个不同时刻的两个包(增量 78)
· _process_update_auto_attack 前插 _combo_process()
project/game_scene.gd —— net_play 装配后 `if _assets != "": net_play.set_asset_root(_assets)`
project/combat_fx_test.gd —— 加 FakeAnim(可控 get_motion_data+ FakeView.anim
FakeClient.move 记录到 fc.moves;重写 combo 段:4 段合成表跑
首次输入→段1 / start 前忽略 / [start,next) 挂 pre-input / _combo_process 触发→段2 /
过 NextComboTime 直接推进→段3→段4 / 末段越界不推进 / 动作回 Wait→__ClearCombo→0 /
空段表→单段 NAME_NORMAL_ATTACK;断言 CG_ATTACK bType==0 且 FUNC_COMBO 带段号
测试:`ctest --test-dir build` → 21/21`netbridge_test` / `gamescene_test` / `netplay_test` /
`player_move_test` / `p2b_test` / `combat_fx_test` / `player_motion_test` → PASS7/7)。
验收:纯离线;连击推进 / pre-input / 弓 next-pre-input / 末段 / 清空全路径由 combat_fx_test 覆盖。
未验证:真实服务器对 CG_CHARACTER_MOVE{FUNC_COMBO} 段号的接受(联机 E2E)。
下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出)+ 修改 4`THitData`
窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16
复用 `M2Client::sync_positions()`+ 武器种类 → `combo_motion_mode` 映射。
```
```text
批次:2026-09-03 / W2 / §3.5 part C.msa 多命中窗导出,修改 1)
状态:🟡 部分完成(增量 82
参考:
REF/GameLib/GameType.h:71-90 —— NRaceData::SHitData { fAttackStartTime; fAttackEndTime;
fWeaponLength; strBoneName; THitTimePositionMap mapHitPosition }
THitDataContainer = vector<THitData>
SMotionAttackData : SAttackData { iMotionType; THitDataContainer }
REF/GameLib/GameType.cpp:42-118 —— THitData::Loadattackingstarttime / attackingendtime /
attackingbone / weaponlength / GetTokenVector("hitposition")
每 7 个浮点 = time + v3LastPosition + v3Position);
LoadMotionAttackData:无 hitdatacount → 组本身即单窗;
有 hitdatacount N → SetChildNode(i) 逐个读 Group HitData0i
真实 .msaassets/PC/ymir work/pc/warrior/general/attack.msa(老式内联单窗)、
.../twohand_sword/combo_05.msaHitDataCount 1)、
.../assassin/dualhand_sword/combo_01.msaHitDataCount 2R Hand + L Hand)、
.../warrior/skill/palbang.msaAttackingData 后还有 MotionEventData
改动:
formats/textscript.cpp ——
· parse_body 把 `List Name { ... }` 与 `Group Name { ... }` 一样当子节点
(List 体是一串裸值,收进子节点 lines[0])。原来 `List` 落入「普通行」分支,会把
`{` 也 push 进行、并且吃不到配对的 `}`,导致其后的 `Group` 被串掉
palbang.msa 的 MotionEventData 整个丢失)。
formats/msa.h ——
· 删单窗字段留兼容壳:attack_start_time / attack_end_time 现在 = hit_windows[0] 起止
· 新 struct HitSample { float time; float last_pos[3]; float pos[3]; }
· 新 struct HitWindow { float start_time,end_time; string bone_name; float weapon_length;
vector<HitSample> samples; }
· Msa 加 vector<HitWindow> hit_windows; int motion_type; int hit_limit_count;
attacking_type 现在读 AttackType||AttackingType
formats/msa.cpp ——
· hit_positions()List HitPosition 的 lines[0] 按 7 个一组切 HitSample
· hit_window():从一个节点读 AttackingStartTime/EndTime/AttackingBone/WeaponLength + 采样
· combat_data()AttackingData 组内找名字前缀 "HitData" 的子组 → 各一窗;
没有则组本身即单窗;填 attack_start_time/end_time = 首窗起止
extension/src/metin2_anim.cpp get_motion_data() ——
· 新增 hit_windows 数组(每项 {start_time,end_time,bone,weapon_length,
samples:[{time,last_pos:Vector3,pos:Vector3}]}+ motion_type + hit_limit_count
formats/tests/msa_test.cpp ——
· 合成:内联单窗(2 采样,校验 bone / 起止 / last_pos / pos)、
新式 HitDataCount 2HitData00 有采样 / HitData01 无采样 / motion_type / hit_limit_count
· liveM2_ASSETS):general/attack1 窗 17 采样)、twohand_sword/combo_05
1 窗 wlen 150 / 37 采样)、assassin/dualhand_sword/combo_012 窗 R Hand+L Hand 各 13 采样)、
skill/palbang1 窗 wlen 130 / hit_limit 18 + events 3 —— 验证 List 修复没串掉 MotionEventData
formats/CMakeLists.txt —— formats.msa_loop_data 加 M2_ASSETS 环境透传
测试:`ctest --test-dir build` → 21/21`formats.msa_loop_data` 含 live 断言,`M2_ASSETS` OK);
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
`combat_fx_test` / `player_motion_test` → PASS7/7)。
验收:纯离线,合成 + 4 个真实 PC .msa(含真正的双命中窗)双覆盖。
未接:net_play.gd 还没消费 hit_windows(命中判定 = 修改 4,增量 83)。
下一批次(增量 83):§3.5 修改 4 —— `net_play.gd` 每帧按动作时间落在某个 `hit_window`
[start,end] 内时对附近实体做几何判定(`AttackingBone` 世界位 + `weapon_length` +
`hit_windows[].samples` 扫掠球),`OnHit` → `client.attack(uSkill, victimVID)`(去重按
`hit_limit_count` / `invisible_time` 冷却),帧末把被击退者打包 `M2Client::sync_positions()`
(≤16)。之后武器种类 → `combo_motion_mode` 映射(增量 84)。
```
```text
批次:2026-09-03 / W2 / §3.5 修改 4(命中窗几何判定 → OnHit,§3.5 收官)
状态:🟡 部分完成(增量 83)——§3.5 修改 1-5 全落地;几何判定为近似留桩
参考:
REF/UserInterface/InstanceBaseBattle.cpp:413 CInstanceBase::AttackProcess
—— CanCheckAttacking() 为真时遍历全实例做 CheckAttacking()(先查双方 IsInSafe
再 AttackingProcess);命中记 m_dwLastDmgActorVID
REF/GameLib/ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess
—— 距离粗筛(HUGE 500² / 普通 300²);遍历 pad->HitDataContainer 各 THitData
m_HitDataMap[&hitData] 查重(MOTION_TYPE_COMBO 或 cooldown 未到即 continue);
mapHitPosition 取 (motiontime-elapsed, motiontime] 的采样构造动态球(c_fAttackRadius=20),
按 GetRotation() 旋到世界;vs victim m_DefendingPointInstanceList 的动态圆柱;
命中数上限:NORMAL/COMBO 16、SKILL pad->iHitLimitCount
命中 → __ProcessDataAttackSuccess → OnHituSkill = m_kCurMotNode.uSkill
REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit
—— SetTarget(vid, FALSE); if(isSendPacket) SendAttackPacket(uSkill, vid);
if(!victim.IsPushing()) return; ... TEMP_Push + m_kVctkVictim.push_back
REF/UserInterface/PythonPlayerEventHandler.cpp:194 FlushVictimList
—— m_kVctkVictim 非空 → 一个 TPacketCGSyncPosition + 每个 elementSYNC_POSITION_COUNT_LIMIT=16
REF/GameLib/GameType.h:52 NRaceData::EMotionType { NONE=0, NORMAL=1, COMBO=2, SKILL=3 }
改动:
project/net_play.gd ——
· 新常量 MOTION_TYPE_{NONE,NORMAL,COMBO,SKILL}、HIT_ATTACK_RADIUS_CM=20、
HIT_VICTIM_RADIUS_CM=45、HIT_FRONT_COS=0.5、SYNC_POSITION_LIMIT=16
· 新状态 _swing_start_t / _swing_skill / _hit_windows / _hit_motion_type /
_hit_limit_count / _hit_invisible_time / _hit_dedup{wi:{vid:cooldown}} / _victim_flush[]
· _begin_hit_windows():挥击起手从 get_motion_data() 缓存 hit_windows / motion_type /
hit_limit_count / invisible_time,清 _hit_dedup,置 _swing_start_t = _now()
· _emit_swing():不再无条件 client.attack;调 _begin_hit_windows() 后只有
_hit_windows 为空(headless / 缺资源)才立即 client.attack(_swing_skill, _vid_reserved)
· _attack_process(dt)t=_now()-_swing_start_tt-dt > 所有窗 end → 收起 _hit_windows
主角 in_safe → 跳过;遍历 client.get_entities() 可攻击 / 未死 / 双方非安全区实体,
net_world.node_for(vid) 取世界位;窗 [ws,we] 与 [t-dt,t] 相交 → _hit_deduped() /
_hit_geometry() / _register_hit() → client.attack(_swing_skill, vid)
被击退目标(knock_down/stunned)→ _victim_flush.append({vid,x,y}
· _hit_deduped(wi,vid):已命中过 && (COMBO || cooldown 未到) → true
· _register_hit(wi,vid):记 cooldown=_now()+invisible_time;非首次且 m.size() > cap
NORMAL/COMBO 16、else hit_limit_count)→ false(本帧跳过)
· _hit_geometry()reach = max(WeaponLength, 窗内 (t-dt,t] 采样最大 hypot(pos.x,pos.y))
缺则 CLICK_DIST_DEFAULT_CM;命中 = 目标水平距 ≤ (reach+20+45)cm 且在正面 ±60° 弧内
· _clear_combo():附带清 _hit_windows / _hit_dedup
· _process_combo_process() 后插 _attack_process(dt);帧末 _flush_victim_list()
· _flush_victim_list()_victim_flush 非空 → client.sync_positions(_victim_flush.slice(0,16))
→ clearslice 返回新数组:兼做 16 上限截断 + 不把马上 clear() 的引用传给下游)
project/combat_fx_test.gd ——
· FakeClient 加 get_entities()ents.values()/ sync_positions()(记 synced
· 新 class FakeNetWorld { node_for(vid) }
· combo 段 + mod-4 段各加一次 `await process_frame`SceneTree 脚本里 add_child 到 root
的节点要等一帧才 is_inside_tree(),几何判定读 global_position 故需入树
· 新 6 段:有命中窗 → 挥击不发 CG_ATTACK;推进到窗内 → OnHit client.attack([0,2000]) +
帧末 CG_SYNC_POSITIONCOMBO 同窗同目标只一次;目标转身后 → 出正面弧不命中;
动作越过所有窗 → _hit_windows 收起
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS7/7)。
验收:纯离线。几何判定是 reach + 正面弧近似(非动态圆柱扫掠)——真实版需骨骼矩阵 +
defending sphere 数据,与 §3.1 actor 间碰撞一起留桩。未验证:真实服务端对
CG_ATTACK / CG_SYNC_POSITION 时序的接受(联机 E2E)。
下一批次(增量 84):武器种类 → `combo_motion_mode` 映射(当前 net_play.gd
`combo_motion_mode` 恒 MOTION_MODE_GENERAL;应按装备武器 subtype 映射到
GENERAL / BOW / DUAL / 骑乘等 CRaceMotionData::EMode)。之后 §3.6 箭矢 FLY 事件。
```
```text
批次:2026-09-03 / W2 / §3.5 修改 6(武器种类 → combo_motion_mode
状态:🟡 部分完成(增量 84)——§3.5 修改 1-6 全落地;弓箭 __GetBowRange() 留给增量 85+
参考:
REF/UserInterface/InstanceBase.cpp:2851 CInstanceBase::RefreshState
—— dwPartItemID = GetPartItemID(PART_WEAPON)byItemType = GetType()
bySubType = GetWeaponType()= m_ItemTable.bSubType
IsPoly() → MODE_GENERAL
IsWearingDress() → MODE_WEDDING_DRESS
IsHoldingPickAxe() → 骑乘 MODE_HORSE / 否则 MODE_GENERAL
ITEM_TYPE_ROD → 骑乘 MODE_HORSE / 否则 MODE_FISHING
m_kHorse.IsMounting() → switch(bySubType) SWORD/TWO_HANDED/DAGGER/FAN/BELL/BOW
→ MODE_HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_HORSE_{FAN,BELL,BOW}
/ default MODE_HORSE
else → switch(bySubType) SWORD/TWO_HANDED/DAGGER/BOW/FAN/BELL
→ MODE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_BOW / MODE_FAN / MODE_BELL
/ ARROW,default MODE_GENERAL
REF/GameLib/RaceMotionData.h:30 CRaceMotionData::EMode
—— RESERVED=0, GENERAL=1, ONEHAND_SWORD=2, TWOHAND_SWORD=3, DUALHAND_SWORD=4,
BOW=5, FAN=6, BELL=7, FISHING=8, HORSE=9, HORSE_ONEHAND=10, HORSE_TWOHAND=11,
HORSE_DUALHAND=12, HORSE_BOW=13, HORSE_FAN=14, HORSE_BELL=15, WEDDING_DRESS=16
REF/GameLib/ItemData.h:22 EItemTypeWEAPON=1, ROD=13, PICK=24
REF/GameLib/ItemData.h:63 EWeaponSubTypes
—— SWORD=0, DAGGER=1, BOW=2, TWO_HANDED=3, BELL=4, FAN=5, ARROW=6
改动:
project/net_play.gd ——
· 补全 MOTION_MODE_*EMode 全 17 项)+ ITEM_TYPE_WEAPON/ROD/PICK +
WEAPON_SUB_{SWORD,DAGGER,BOW,TWO_HANDED,BELL,FAN,ARROW} + WEAR_WEAPON=4 / PART_WEAPON=1
· static motion_mode_for(item_type, weapon_sub, mounted, poly, dress):逐分支复刻
RefreshState 的 SetMotionMode(顺序 Poly → 婚纱 → 镐 → 钓竿 → 骑乘 switch → else switch
· _equipped_weapon_vnum():主角 parts[WEAPON] 非 0 优先,否则 get_equipment()[WEAR_WEAPON].vnum
· _refresh_motion_mode()mounted = mount_vnum!=0 || mountingpoly = polymorph||poly_vnum
dress = wedding_dressvnum>0 && proto → proto.item(vnum).{type,sub_type}
→ set_combo_motion_mode(motion_mode_for(...))(换模式内部 _clear_combo()
· _on_inv_changed_mode(window,_cell)window==2(EQUIPMENT) → _refresh_motion_mode()
· setup():连 inventory_changed → _on_inv_changed_mode;连 mount_changed → (v==主角) _refresh_motion_mode
· _on_entity_info() 末尾调 _refresh_motion_mode()(主角设定 / GC_CHARACTER_UPDATE parts 变)
project/combat_fx_test.gd ——
· 新 class FakeProto { item(vnum) -> {type,sub_type} }
· 15 段 motion_mode_for 断言(sword/two-handed/dagger/bow/fan/bell/arrow、rod±骑乘、
骑乘 sword/bow/arrow、poly、婚纱覆盖)
· 2 段端到端:proto 桩 + fc.ents[1000].parts=[0,19,0,0] → _refresh_motion_mode()
→ combo_motion_mode == DUALHAND_SWORD 且 _combo_index 归零;再 mount_vnum=20101
→ HORSE_DUALHAND_SWORD
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS7/7),
`equip_model_test` 亦 PASS。
验收:纯离线。映射表 1:1 对齐 RefreshState;换武器 / 上下马 / 换装 / 时装 parts 变都能触发。
未接:弓箭 `__GetBowRange()`(弓模式命中 / 点击距离,§3.5 / §3.6 一起做);婚纱 dress 标志
本工程实体暂无(读 e.wedding_dress,缺则 false)。
下一批次(增量 85):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动
`OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉固定 1.5s 定时器;
连带弓模式 `__GetBowRange()` 命中 / 点击距离。
```
```text
批次:2026-09-03 / W2 / §3.5 修改 7(弓箭 __GetBowRange() 可点击 / 攻击距离)
状态:✅ 完成(增量 85)——§3.5 修改 1-7 全落地;§3.6 箭矢 FLY 事件留给增量 86
(增量 84 记录里「下一批次」原写「§3.6 + 连带弓模式距离」,这里把弓模式距离单独切成增量 85,
更小更好回归;FLY 事件顺延增量 86。)
参考:
REF/UserInterface/InstanceBaseBattle.cpp:294 CInstanceBase::NEW_IsClickableDistanceDestInstance
fDistance = 150.0f;
if (IsBowMode()) fDistance = __GetBowRange();
if (dst.IsNPC()) fDistance = 500.0f;
if (dst.IsResource()) fDistance = 100.0f;
return m_GraphicThingInstance.IsClickableDistanceDestInstance(dst..., fDistance);
—— 后写的 if 覆盖前面:NPC / 采集物距离优先于弓箭距离。
REF/UserInterface/InstanceBase.cpp:694 CInstanceBase::__GetBowRange
float fRange = 2500.0f - 100.0f;
if (__IsMainInstance())
fRange += float(IAbstractPlayer::GetSingleton().GetStatus(POINT_BOW_DISTANCE));
return fRange;
—— 注意参考端把 POINT_BOW_DISTANCE 按 cm 直加,不 ×100PythonPlayerSkill.cpp:393
另一处技能射程才 ×100);本工程照抄不 ×100。
REF/GameLib/ActorInstance.cpp:258 CActorInstance::IsBowMode
return GetMotionMode()==MODE_BOW || GetMotionMode()==MODE_HORSE_BOW;
REF/UserInterface/Packet.h:1550 EPointTypesPOINT_BOW_DISTANCE = 34
改动:
extension/src/net/wire.h —— enum 补 POINT_BOW_DISTANCE = 34
extension/src/net/m2_client.cpp —— get_points() 增 d["bow_distance"] = p.v[POINT_BOW_DISTANCE]
project/net_play.gd ——
· 常量 BOW_RANGE_BASE_CM := 2500.0 - 100.0(保留字面式,对齐参考)
· 成员 _bow_distance_bonus := 0.0GetStatus(POINT_BOW_DISTANCE)
· _bow_range_cm() -> BOW_RANGE_BASE_CM + _bow_distance_bonus= __GetBowRange
· _clickable_distance(te)cm=150 → _is_bow_mode() 则 cm=_bow_range_cm() →
match _entity_kindNPC 500 / 采集物 100(覆盖弓箭距离);末尾 /CM 转米
· _on_points(p)_bow_distance_bonus = float(p.get("bow_distance", 0))
_is_bow_mode() 增量 84 已有:combo_motion_mode ∈ {MODE_BOW, MODE_HORSE_BOW}
project/combat_fx_test.gd —— §3.5 段加 8 段断言:
非 bow 玩家/怪 reach=150 / bow 基础 _bow_range_cm()=2400 / bow 目标 reach=__GetBowRange /
NPC 覆盖=500 / 采集物覆盖=100 / HORSE_BOW 也算 bow / +300 加成叠加 / _on_points 读 bow_distance
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS7/7),
`equip_model_test` 亦 PASS。
验收:纯离线。距离分支 + __GetBowRange 公式 1:1 对齐参考端。未接:POINT_BOW_DISTANCE 的真服
下发路径未联机核验(`mut_set_point` 已能把任意 type 存进 v[]get_points 已透出);几何判定
仍是 reach + 正面弧近似(随 §3.1 actor 碰撞升级)。
下一批次(增量 86):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动
`OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉 `game_scene.gd` 约 1.5s
定时器兜底(`PythonPlayerEventHandler.cpp:235`)。
```
```text
批次:2026-09-03 / W2 / §3.6 弓箭 FLY 事件(OnSetFlyTarget / OnShoot
状态:✅ 完成(增量 86)——§3.6 修改 1-3 全落地
参考:
REF/UserInterface/PythonPlayerEventHandler.cpp:235
CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget()
→ SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING
CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex)
→ SendShootPacket(dwSkillIndex) // CG_SHOOT
REF/GameLib/ActorInstanceMotion.cpp:372 CActorInstance::__SetMotion 尾
if (m_pFlyEventHandler && __IsNeedFlyTargetMotion()) m_pFlyEventHandler->OnSetFlyTarget();
—— OnSetFlyTarget 在弓动作「设定」时触发一次,不是 .msa motion event。
REF/GameLib/ActorInstanceMotionEvent.cpp:255 CActorInstance::ProcessMotionEventFly
if (iType != MOTION_EVENT_TYPE_FLY) return;
if (m_kFlyTarget.IsValidTarget()) {
CFlyingManager::CreateFlyingInstanceFlyTarget(...); // 飞行物(本工程未做)
if (m_pFlyEventHandler) m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill);
}
—— OnShoot 在 .msa 的 FLY 帧触发,uSkill = 本次挥击技能号(普攻 0)。
REF/GameLib/ActorInstanceFly.cpp:4 CActorInstance::OnGetFlyTargetPosition
v3Center = m_v3Center;m_fRadius<=0 先 BuildBoundingSphere
D3DXVec3TransformCoord(&v3Center, &v3Center, &GetTransform()); return v3Center;
—— 目标包围球中心的世界坐标;平面分量与脚点一致,CG_FLY_TARGETING 只带平面坐标。
REF/UserInterface/PythonPlayerInput.cpp:358 __OnPressActor
rkInstMain.NEW_AttackToDestInstanceDirection(rkInstVictim,
rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&rkInstMain, &rkInstVictim));
改动:
project/net_play.gd ——
· 新信号 bow_shot_fired(skill:int)
· _emit_swing() 头部:_is_bow_mode() 时 _send_fly_target() + bow_shot_fired.emit(_swing_skill)
· _send_fly_target()vid = _target_vid ?: _vid_reservednet_world.node_for(vid) →
MapCoord.to_server_cm(global_position) → client.add_fly_targeting(vid, x, y)
project/game_scene.gd ——
· 删 _pending_shoot_skill:int + get_tree().create_timer(1.5) 兜底
· 新 _pending_shots:Array[int] + const MAX_PENDING_SHOTS := 8 + _queue_shot(skill)(满丢最旧)
· quickbar.skill_activated 的 is_ranged 分支改 _queue_shot(sid)
· net_play.bow_shot_fired.connect(_queue_shot)
· _on_local_motion_event() type 6not _pending_shots.is_empty() → client.shoot(_pending_shots.pop_front())
project/combat_fx_test.gd —— FakeClient 加 add_fly_targeting/shoot + fly_targets/shots
§3.6 段 6 断言(非弓无 FLY / 弓挥击发 CG_FLY_TARGETING(vid) / 坐标=目标 server-XY /
bow_shot_fired(0) / 无目标不发)
project/eterngrn_polish_test.gd —— FLY 段改 FIFO 连射断言(连压 77/78 → 依次 pop → 队空不发)
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);9 个 GDScript 回归 → PASS
combat_fx / eterngrn_polish / gamescene / netplay / player_move / player_motion /
netbridge / p2b / equip_model)。
验收:纯离线。OnSetFlyTarget(动作起手)/ OnShoot.msa FLY 帧)两个时刻分开,FIFO = AutoClear +
并发多发,1:1 对齐参考端。未接:飞行物实体本身(CFlyingManager / FlyingInstance 的生成、
轨迹、命中 OnShootDamage)、__CanShot 弓箭合法性(PythonPlayerInput.cpp:343)、真服
CG_FLY_TARGETING 的 z 分量(当前只发平面坐标)。
下一批次(增量 87):§3.8 技能合法性 —— player_skill.gd 三层校验
ClickSkillSlot 冷却 / MP / 前置,PythonPlayerSkill.cpp:108)。
```
```text
批次:2026-09-03 / W2 / §3.8 TSkillData 判定位解析(修改 2
状态:🟡 部分完成(增量 87)——§3.8 修改 2 落地,修改 1/3 待续
参考:
REF/UserInterface/PythonSkill.h:92-115 enum { SKILL_ATTRIBUTE_NEED_TARGET=(1<<0) … SEARCH_TARGET=(1<<23) }
REF/UserInterface/PythonSkill.h:120-128 enum { SKILL_NEED_WEAPON_SWORD=(1<<CItemData::WEAPON_SWORD) … }
REF/GameLib/ItemData.h:63-75 enum EWeaponSubTypes { WEAPON_SWORD=0,DAGGER,BOW,TWO_HANDED,BELL,FAN,ARROW, NUM_TYPES=7, NONE=8 }
REF/UserInterface/PythonSkill.cpp:280 RegisterSkillDesc —— SplitLineByTabATTRIBUTE 列 SplitLine("|")
每记号查 m_SkillAttributeIndexMap → dwSkillAttribute |= bitWEAPON 列同理 → dwNeedWeapon;查不到就跳过
REF/UserInterface/PythonSkill.cpp:909-943 m_SkillTypeIndexMapnone/active/support/guild/horse + 职业名→ACTIVE
/ m_SkillAttributeIndexMap(记号名→位)/ m_SkillNeedWeaponIndexMapSWORD…EMPTY_HANDDOUBLE_SWORD→DAGGER 位)
REF/UserInterface/PythonSkill.cpp:1143 CanUseWeaponType(t)!(attr & WEAPON_LIMITATION) → TRUEelse dwNeedWeapon & (1<<t)
REF/UserInterface/PythonSkill.cpp:1407 IsCanUseSkill()attr & PASSIVE → falseelse true
POC 无 C++ skill protoextension/src/proto 只有 item/mob;技能数据全在 GDScript
project/ui/skill_table.gd 读 locale/locale/<lang>/skilldesc.txt TSV。故本修改在该文件内落地。)
改动:
project/ui/skill_table.gd ——
· const ATTR{}24 个 SKILL_ATTRIBUTE_* 记号名→位)、WEAPON_* 序号常量、
const NEED_WEAPON{}9 个记号名→1<<WEAPON_*DOUBLE_SWORD 复用 DAGGER)、
SKILL_TYPE_* 常量 + _SKILL_TYPE_MAP{}
· static _fold_bits(tokens, name_to_bit)`|`/`,`/空格 分隔、大小写宽容、未知记号忽略 → 位掩码
· load_file()entry 增 attr_bits = _fold_bits(第11列, ATTR)、
need_weapon_bits = _fold_bits(第12列, NEED_WEAPON)、skill_type = _SKILL_TYPE_MAP[job.lower()]
· 谓词(逐条对齐 PythonSkill.cpp):attr_bits / need_weapon_bits / _has_attr、
is_can_use_skill / is_standing / is_need_target / is_need_corpse / is_auto_search_target /
can_change_direction / is_horse_skill / is_moving_skill / is_melee / is_charge_skill /
is_use_hp / is_time_increase / is_need_empty_bottle / is_need_poison_bottle / can_use_for_me /
is_only_for_alliance / can_use_if_not_enough / is_only_for_guild_war / is_fan_range /
is_circle_range / has_weapon_limitation / can_use_weapon_type(id,t) / is_need_bow /
skill_type_of / is_guild_skill;旧 is_attack/is_passive/is_toggle/is_ranged/can_level_up 改查位
project/skill_test.gd —— §3.8 段 +34 断言(skill 1/16/18/46/63/106/109/137/138/151/152
的位面判定 + can_use_weapon_type 有/无 WEAPON_LIMITATION 两路 + _fold_bits 分隔/大小写宽容
+ DOUBLE_SWORD 复用 DAGGER 位)
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);9 个 GDScript 回归 → PASS
skill / skill_fx / guild_war_skill / gamescene / netplay / player_move / combat_fx /
eterngrn_polish + skill_test 扩展)。
验收:纯离线。位折叠 + 谓词 1:1 对齐 CPythonSkill。架构差异:参考端说的
“extension/src/proto 侧补解析”改在 skill_table.gd 内(POC 技能数据只在 GDScript)。
未接:修改 1player_skill.gd 三层校验移植)、修改 3M2Client::use_skill() 带符号 + 范围校验)。
下一批次(增量 88):§3.8 修改 1 —— 新建 project/player_skill.gd,逐行移植
ClickSkillSlot (a) / __CanUseSkill (b) / __CheckSkillUsable (c) / __UseSkill (d)
PythonPlayerSkill.cpp:108/439/166/460),命名常量 SKILL_MAX_NUM / SKILL_TYPE_GUILD /
骑乘术 109 / 空瓶 27995 / 毒瓶 27996,接进技能施放路径。
```
```text
批次:2026-09-03 / W2 / §3.8 修改 1player_skill.gd 三层合法性校验)
状态:🟡 部分完成(增量 88)——§3.8 修改 1 落地,修改 3 待续
参考:
REF/UserInterface/PythonPlayerSkill.cpp:106 ClickSkillSlot(slot) —— 层 (a) 入口分派
REF/UserInterface/PythonPlayerSkill.cpp:439 __CanUseSkill() —— 层 (b) 全局可用性
(硬编码骑乘术 vnum 109IsMountingHorse() && GetSkillGrade(109)<1 && GetSkillLevel(109)<20 → false
REF/UserInterface/PythonPlayerSkill.cpp:166 __CheckSkillUsable(slot) —— 层 (c) 逐项前置
顺序:骑乘⇄马术(NOT_HORSE_SKILL / HAVE_TO_RIDE)→ 安全区攻击技(IN_SAFE)→ !IsCanUseSkill()(被动)
→ 空瓶 27995NEED_EMPTY_BOTTLE)→ 毒瓶 27996NEED_POISON_BOTTLE)→ IsFishingMode()REMOVE_FISHING_ROD
→ m_sysIsLevelLimit && iLevel<=0NOT_YET_LEARN)→ !CanUseWeaponType()NOT_MATCHABLE_WEAPON
→ !IsHorseSkill(): __CheckShortArrow + IsNeedBow && !__HasEnoughArrowEMPTY_ARROW
→ dash 分支:__CheckDashAffect && !IsChargeSkill && __CheckRestSkillCoolTime → WAIT_COOLTIME
否则 __CheckRestSkillCoolTime → WAIT_COOLTIME、__CheckShortLife → NOT_ENOUGH_HP、__CheckShortMana → NOT_ENOUGH_SP
REF/UserInterface/PythonPlayerSkill.cpp:460 __UseSkill(slot) —— 层 (d) 施放
IsOpenPrivateShop() → 静默 return true__CheckSpecialSkill(idx) → true(钓鱼 123 / 连击 122);
IsToggleSkill() && IsSkillActive(slot) → SendUseSkillPacket + return falseIsUsingSkill() → false
目标解析(NEED_TARGET || CanChangeDirection || IsAutoSearchTarget:520-650 —— 同盟 / 安全区 / 可攻击 / 尸体分支
→ CANNOT_USE_SELF / ONLY_FOR_ALLIANCE / CANNOT_ATTACK_ENEMY_IN_SAFE_AREA / CANNOT_ATTACK / ONLY_FOR_CORPSE / NEED_TARGET
REF/UserInterface/PythonPlayerModule.cpp:6-7 c_iSkillIndex_Combo=122 / c_iSkillIndex_Fishing=123
REF/UserInterface/Packet.h:2041 SKILL_MAX_NUM=255
改动:
project/player_skill.gd(新建)—— extends RefCounted,纯校验器(不发包、不驱动动画):
· 返回 {"ok": bool, "code": String, "target_vid": int}
· 命名常量 SKILL_MAX_NUM=255 / SKILL_TYPE_GUILD=3 / RIDING_SKILL_INDEX=109 /
RIDING_SKILL_MASTER_LEVEL=20 / ITEM_EMPTY_BOTTLE=27995 / ITEM_POISON_BOTTLE=27996 /
SKILL_INDEX_COMBO=122 / SKILL_INDEX_FISHING=123
· SILENT_CODES[]13 个无文案码:空串 / OK / PASSIVE / BAD_SLOT / NO_SKILL_DATA / GUILD_ROUTED /
TOGGLE_OFF / SPECIAL_SKILL / PRIVATE_SHOP / RESERVED / NO_RIDING_SKILL / CANNOT_ACT / ALREADY_CASTING
+ static is_silent_code(code)
· setup(m2client, skill_table);运行期状态尽量走 clientget_main_vid / get_entity /
get_skills / get_target / get_inventory),其余经公有 context 字段由调用方每次刷新
· click_skill_slot() 层 (a) / can_use_skill() 层 (b) / check_skill_usable() 层 (c) /
use_skill() 层 (d) / _resolve_target() 目标矩阵 / _check_special_skill()
· __CheckShortMana / __CheckShortLife 打桩恒 false —— POC 无 .msk NeedSP/NeedHP 公式解析
skill_table 只解析 CoolTimeFormula / MaxLevel),留给增量 89
project/net_play.gd —— +skill_context():观察 / 骑乘 / 安全区 / 钓鱼 / 施法中 / 可行动 /
dash / weapon_sub_type(经 _equipped_weapon_vnum + proto.item().sub_type,非武器→-1/ arrow_count 快照
project/ui/quickbar.gd —— preload player_skill.gdsignal skill_rejected(skill_id, code)
var net_play / _skill_gatesetup() 内实例化 gateactivate() 技能分支施放前跑三层,
TOGGLE_OFF → 走 use_skill 关闭 + skill_activated;非静默码 → skill_rejected.emit + return
gate 返回 target_vid 非 0 → 覆盖 target_vid
project/game_scene.gd —— quickbar.net_play = net_playskill_rejected → net_play.cannot_act.emit(code)
HUD 走同一条 OnCannotUseSkill 文案通道)
project/skill_test.gd —— FakeClient get_entity 返回 {"race":0,"kind":2}(选中目标判可攻击)+ get_inventory()
project/player_skill_test.gd(新建)—— extends SceneTree,读真 skilldesc.txt
覆盖层 (a)(b)(c)(d) 每条分支 + 目标矩阵 + is_silent_code
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);12 个 GDScript 回归 → PASS
skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
netbridge / p2b / equip_model / eterngrn_polish + player_skill_test 新建)。
验收:纯离线。三层顺序 1:1 对齐 PythonPlayerSkill.cpp。架构差异:校验器读 client 运行期状态、
其余状态经 net_play.skill_context() 注入,保持解耦可单测。
未接:__CheckShortMana / __CheckShortLife(需 .msk NeedSP/NeedHP 公式解析)、
修改 3M2Client::use_skill() 带符号 + 范围校验,§3.9)。
下一批次(增量 89):§3.8 修改 3 —— M2Client::use_skill() 及相关公有 API 形参带符号 + 范围校验;
同时补 __CheckShortMana / __CheckShortLife.msk NeedSP/NeedHP 公式解析)。
```
```text
批次:2026-09-03 / W2 / §3.8 修改 3use_skill 边界收紧 + 短蓝短血校验落地)
状态:✅ 完成(增量 89)——§3.8 三层校验(修改 1/2/3)全部落地
参考:
REF/UserInterface/PythonPlayerSkill.cpp:108 ClickSkillSlot —— slot >= SKILL_MAX_NUM → return
REF/UserInterface/Packet.h:2041 SKILL_MAX_NUM = 255
REF/UserInterface/PythonPlayerSkill.cpp:320 __CheckShortMana(fCur)
· c_iSkillIndex_Summon(131) 豁免 → false
· iNeedSP = GetNeedSP(fcurEfficientPercentage)icurSP = GetStatus(POINT_SP)
· !IsToggleSkill() && iNeedSP == 0 → chat "strange game data"; return true(坏数据拦截)
· CanUseIfNotEnough() → icurSP <= 0 → return true
· 否则 -1 != iNeedSP && iNeedSP > icurSP → return truedefault false
REF/UserInterface/PythonPlayerSkill.cpp:354 __CheckShortLife(fCur)
· !IsUseHPSkill() → return false(放行)
· dwNeedHP = GetNeedSP(fcurEfficientPercentage)HP 需求也读 NeedSP 公式,参考端怪癖)
· dwNeedHP <= GetStatus(POINT_HP) → false;否则 true
REF/GameLib/PythonSkill.cpp:1426 GetNeedSP(float fSkillPoint) —— strNeedSPFormula 空 → 0;否则 poly 求值
REF/GameLib/PythonSkill.cpp:1250 ProcessFormula —— 变量 "SkillPoint" / "k" 绑定传入 float
REF/UserInterface/PythonPlayer.cpp:1037 fcurEfficientPercentage = LocaleService_GetSkillPower(lv)/100
REF/EterLib/Locale.cpp:126 LocaleService_GetSkillPower —— SKILL_POWER_NUM=50lv>=50 → 0
INTERNATIONAL 表 {0,5,6,8,10,12, 14,16,18,20,22, 24,26,28,30,32, 34,36,38,40,50,
52,54,56,58,60, 63,66,69,72,82, 85,88,91,94,98, 102,106,110,115,125, 125}
REF/UserInterface/PythonPlayerModule.cpp:15 c_iSkillIndex_Summon = 131
改动:
extension/src/net/net_bounds.h —— +SKILL_MAX_NUM=255(注释引 Packet.h:2041 / PythonPlayerSkill.cpp:108
+inline constexpr bool skill_index(int64_t v) { return v > 0 && v < SKILL_MAX_NUM; }
extension/src/net/m2_client.cpp —— use_skill(int skill_id, int target_vid)(形参增量 52 起即带符号)
护栏:skill_id <= 0 || !bounds::u32(skill_id) → !mtnet::bounds::skill_index(skill_id)
target_vid 仍 >= 0 && bounds::u32cast_skill() 的 motion_idx ∈ [0,0x7f] 不动)
extension/tests/net_bounds_test.cpp —— +4 CHECKSKILL_MAX_NUM==255 / 端点 1、254 /
越界 0、255、-1 / 拒 0x7fffffff+1
project/ui/skill_table.gd ——
· _load_cooltime → _load_msk(调用点 line 152),+解析 .msk NeedSPFormula → entry["need_sp_formula"]
· +const _SKILL_POWER[]Locale.cpp INTERNATIONAL 表,41 项)
· +_skill_point(level): level<0 || level>=50 → 0.0;否则 _SKILL_POWER[min(level,40)]/100.0
· +need_sp(id, level): 无 need_sp_formula → -1POC 约定,参考返回 0,避免误挡);
否则 Expression.parse(formula, ["SkillPoint"]).execute([_skill_point(level)]) → int
· +need_hp(id, level) → 直接 return need_sp(id, level)(照抄 __CheckShortLife 复用 GetNeedSP
· cooldown_of 的 SkillPoint 改走 _skill_point(level)(原为 level/max_level);max_level 仍解析
project/player_skill.gd ——
· +const SKILL_INDEX_SUMMON = 131
· +var cur_sp := -1 / var cur_hp := -1-1 = 未知不判)
· _check_short_mana 实装:table 无 need_sp / summon 131 / cur_sp<0 → false
!is_toggle && need_sp==0 → truecan_use_if_not_enough → cur_sp<=0;否则 need_sp!=-1 && need_sp>cur_sp
· _check_short_life 实装:!is_use_hp / cur_hp<0 / need_hp<0 → false;否则 need_hp > cur_hp
(命中点:check_skill_usable 非 dash else 支路,既有 :270-273 已转 NOT_ENOUGH_HP / NOT_ENOUGH_SP
project/net_play.gd —— skill_context()~line 618+cur_hp / cur_sp(读 client.get_points() 的 hp / sp,缺则 -1
project/player_skill_test.gd —— +“(c) §3.8 修改 3”段(_skill_point(20)==0.5、_skill_point(0)/(50)==0、
need_sp(1,5)==52、need_hp==need_sp、SP 不足→NOT_ENOUGH_SP、SP 充足→ok、SP 未知(-1)→ok、
无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 NOT_ENOUGH_HP);_reset 补 cur_hp=-1 / cur_sp=-1
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);`ctest -R net.bounds` → 1/1
13 个 GDScript 回归 → PASSplayer_skill / skill / skill_fx / combat_fx / gamescene / netplay /
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。
eterngrn_polish_test 的 stderr "FAIL: motion_event: sound → play_at" 为该测试既有 async 竞态
_init 未 await _run),非本批次触及,仍 exit 0 + PASS。probe 脚本确认 need_sp(1,5)=52 /
_skill_point(20)=0.5 / formula="40 + (100 * SkillPoint)"。
验收:纯离线。use_skill 上限对齐 ClickSkillSlot 的 SKILL_MAX_NUM 门。__CheckShortMana /
__CheckShortLife 顺序 1:1 对齐 PythonPlayerSkill.cpp:320/354。架构差异:need_sp 无公式返回 -1
(参考 0),HP/SP 由 net_play.skill_context() 经 client.get_points() 注入、-1 不判。
shipped skilldesc.txt 无 USE_HP 技能,故 _check_short_life 对现有技能实为放行(与参考等价)。
下一批次(增量 90):§3.10 技能升级门(CLIENT-GAP.md audit 10)—— 训练 / 读书 / 大师书
可升级判定 + M2Client 升级请求参数校验。
```
```text
批次:2026-09-03 / W2 / §3.10 技能升级门(加号按钮门控 + skill_up 边界收紧)
状态:✅ 完成(增量 90
参考:
POC/assets/root/uicharacter.py:984 RefreshSkillPlusButtonList()
· isLoaded==0 → return; RefreshSkillPlusPointLabel()
· not __CanUseSkillNow() → return(技能组门)
· curSelectedSkillGroup==PAGE_HORSE ? __RefreshSkillPlusButton("HORSE") : __RefreshSkillPlusButton("ACTIVE")
· __RefreshSkillPlusButton("SUPPORT")
POC/assets/root/uicharacter.py:1270 __CanUseSkillNow() —— 0 == net.GetMainActorSkillGroup() → Falseelse True
POC/assets/root/uicharacter.py:937 __RefreshSkillPlusButton(name)
· HIDE_SUPPORT_SKILL_POINT and name=="SUPPORT" → return(本资源 HIDE_SUPPORT_SKILL_POINT=True, :21
· HideAllSlotButton(); slotStatType = skillPageStatDict[name]SUPPORT→SKILL_SUPPORT 27 /
ACTIVE→SKILL_ACTIVE 28 / HORSE→SKILL_HORSE 113:225-228; 0==slotStatType → return
· statPoint = player.GetStatus(slotStatType); if statPoint > 0: for 每槽:
skillIndex/skillGrade/skillLevel; skillIndex==0 → continue; skillGrade!=0 → continue
name=="HORSE": GetStatus(LEVEL) >= skill.GetSkillLevelLimit(idx) → (skillLevel<20 → ShowSlotButton)
else: (SUPPORT 白名单 SHOW_LIMIT_SUPPORT_SKILL_LIST=[] 空 → 无限制) CanShowPlusButton(idx,lv,pt) → ShowSlotButton
POC/assets/root/uicharacter.py:925 CanShowPlusButton(idx, lv, pt) —— idx==0 → False;
not skill.CanLevelUpSkill(idx, lv) → False; else True
REF/UserInterface/PythonSkill.cpp:1833 skillCanLevelUpSkill(idx, lv)
· lv >= GetMaxLevel() → FALSE
· isRequirement → 前置技能 / 前置属性检查(本 build 里 isRequirement 恒 FALSE:1471,构造默认,
strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 全程无人填充 → 分支永不进入)
· dwSkillAttribute & SKILL_ATTRIBUTE_CANNOT_LEVEL_UP (1<<18) → FALSE
· else TRUE ⇒ 本 build 化简为 lv < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)
REF/UserInterface/PythonSkill.cpp:1741 skillGetSkillLevelLimit(idx) = c_pSkillData->byLevelLimit
· byLevelLimit 来源:skilldesc LEVEL_LIMIT 列(DESC_TOKEN_TYPE=26)或 .msk levellimit 记号
· 本 40250shipped skilldesc.txt 仅 22 列(无 26 列)、无 .msk 带 levellimit → 恒 0 → 马术页等级前置门恒真
REF/UserInterface/PythonSkill.cpp:1392 SSkillData::GetMaxLevel() = byMaxLevel
REF/UserInterface/PythonSkill.cpp:1462 SSkillData 构造:byMaxLevel = 20(缺 .msk maxlevel / skilldesc MAX_LEVEL 时的默认)
REF/UserInterface/PythonSkill.cpp:280 RegisterSkillDesc —— LEVEL_LIMIT=col26 / MAX_LEVEL=col27 仅在 TokenVector.size() 够时解析
REF/UserInterface/Packet.h:1540-1541,1641 POINT_SUB_SKILL=27 / POINT_SKILL=28 / POINT_HORSE_SKILL=113
改动:
extension/src/net/wire.h —— EPointTypes +POINT_SUB_SKILL=27 / +POINT_SKILL=28 / +POINT_HORSE_SKILL=113
extension/src/net/m2_client.cpp ——
· get_points() +d["skill_active"]=p.v[POINT_SKILL] / +d["skill_support"]=p.v[POINT_SUB_SKILL]
/ +d["skill_horse"]=p.v[POINT_HORSE_SKILL](镜像增量 85 的 bow_distance
· skill_up(int skill_id) 护栏 skill_id<=0 || skill_id>=bounds::POINT_COUNT
→ !mtnet::bounds::skill_index(skill_id)= 0<v<255,与 use_skill §3.9 同一谓词)
project/ui/skill_table.gd ——
· load_file +解析 skilldesc LEVEL_LIMITt[26]/ MAX_LEVELt[27]),.msk maxlevel 优先;
shipped 22 列数据两列都缺,逻辑为兼容第三方数据
· +max_level_of(id) → entry.get("max_level", 20)
· +skill_level_limit(id) → entry.get("level_limit", 0)(本 build 恒 0
· +can_level_up_skill(id, level) → has(id) && level < max_level_of(id) && !_has_attr("CANNOT_LEVEL_UP")
project/ui/skill_ui.gd ——
· +const HIDE_SUPPORT_SKILL_POINT=trueuicharacter.py:21/ +const RIDING_SKILL_MAX_LEVEL=20
· refresh() 重写:算 page / can_use_now / stat_point / plv,对每个已建按钮切 up.visible
· +_can_use_skill_now() → client.has_method("get_skill_group") ? get_skill_group()!=0 : true
· +_stat_point_for(page) → get_points() 的 skill_support / skill_horse / skill_active(按页),无 get_points → 1
· +_player_level() → get_points().get("level", 1)
· +_can_show_plus(id, level, grade, page, can_use_now, stat_point, plv)
!can_use_now → false; SUPPORT && HIDE_SUPPORT_SKILL_POINT → false; stat_point<=0 → false;
grade!=0 → false; HORSE: plv >= skill_level_limit(id) ? level < 20 : false; else can_level_up_skill(id, level)
· 按钮创建仍在 _rebuild() 门于 can_level_up(id) && !is_passive(id)(静态属性),refresh() 只切 visible
project/skill_test.gd ——
· FakeClient +skill_group=1 / +points={level:30, skill_active:5, skill_support:3, skill_horse:2}
/ +get_skill_group() / +get_points()
· +§3.10 段:max_level_of(1)==30samyeon.msk/ (121)==20(构造默认)、skill_level_limit(1)==0、
can_level_up_skill(1,5/29)==true、(1,30/31)==false、(125,1)==falseCANNOT_LEVEL_UP)、(9999,1)==false
主动页 skill 1 加号可见、skill 16 skillGrade!=0 隐、skill_group==0 隐、skill_active==0 隐、
辅助页整页隐、马术页 skill_horse>0 && level<20 可见 / skill_horse==0 隐
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/2113 个 GDScript 回归 → PASS
player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的
stderr "FAIL: motion_event" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。加号显隐三段(RefreshSkillPlusButtonList / __RefreshSkillPlusButton / CanShowPlusButton
逐行对齐 uicharacter.py。CanLevelUpSkill 在本 40250 build 因前置字段无人填充化简为
「未满级 && !CANNOT_LEVEL_UP」,GetSkillLevelLimit 恒 0(无源数据列/记号)——两者均为
数据事实而非删减。skill_up 发包(/skillup <idx>)不变,升级结果由 GC_SKILL_LEVEL 经
skills_changed 驱动 refresh(),无本地伪造。
下一批次(增量 91):§3.4 __ChangeTargetToPickedInstance 拾取回退 + 扇形/圆形多目标
fly-targeting;随后 §3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中。
```
```text
批次:2026-09-03 / W2 / §3.4 其余三种预约模式 + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting
状态:✅ 完成(增量 91
参考:
REF/UserInterface/PythonPlayerInputMouse.cpp:283 NEW_RefreshMouseWalkingDirection()
· MODE_CLICK_ITEM (:290)GetGroundItemPosition(iid, &pos)
· 找不到 → __ClearReservedAction(); return
· GetDistance(main, pos) < 20.0f → SendCharacterStatePacket(pos, FUNC_WAIT);
SendClickItemPacket(iid); NEW_Stop(); __ClearReservedAction()
· else NEW_MoveToDestPixelPositionDirection(pos)
· MODE_CLICK_POSITION (:320)if (!isLock()) if (NEW_IsEmptyReservedDelayTime(elapsed))
{ NEW_MoveToDestPixelPositionDirection(m_kPPosReserved); __ClearReservedAction(); }
· MODE_USE_SKILL (:340)pkInstReserved = NEW_FindActorPtr(m_dwVIDReserved)
· NULL → __ClearReservedAction()
· fDistance = GetDistance(main, reserved); if (IS_HUGE_RACE(race)) fDistance -= 200.0f
· fDistance < float(m_dwSkillRangeReserved) → { SetTarget(vid); if (__UseSkill(slot)) __ClearReservedAction(); }
· else NEW_MoveToDestInstanceDirection(reserved)
REF/UserInterface/PythonPlayerInput.cpp:836 __ReserveClickGround(x,y,z) —— MODE_CLICK_POSITION;
m_kPPosReserved=(x,y,z); m_fReservedDelayTime=0.1f
REF/UserInterface/PythonPlayerInput.cpp:854 __ReserveUseSkill(slot,vid,range) —— MODE_USE_SKILL;
m_dwSkillSlotReserved=slot; m_dwVIDReserved=vid; m_dwSkillRangeReserved=range;
if (m_dwSkillRangeReserved > 100) m_dwSkillRangeReserved -= 10
REF/UserInterface/PythonPlayerInput.cpp:324 __OnPressActor —— 预约技能 + __CheckDashAffect 生效时
只 m_dwVIDReserved=vid、不 SetTarget / 不施法
REF/UserInterface/PythonPlayerInput.cpp:186 __ChangeTargetToPickedInstance(vid) → SetTarget(vid)
:130 SetTarget —— IsTargetableInstance(PC/怪) 才选中,否则 ClearTarget()
REF/UserInterface/PythonPlayerSkill.cpp:680-746 __UseSkill 内 if (dwTargetMaxCount>0 && pkInstTarget) 块
· dwTargetMaxCount = GetTargetCount(fEfficientPct); dwRange = __GetSkillTargetRange()
· dwTargetCount=1; FAN→NEW_GetInstanceVectorInFanRange / CIRCLE→NEW_GetInstanceVectorInCircleRange
· for eachcount>=max breaktarget!=each && !each->IsDead() →
AddFlyTargetInstance(each); SendAddFlyTargetingPacket(each->VID, each->OnGetFlyTargetPosition()); count++
· while (count<max)NEW_GetRandomPositionInFanRange(target, &pos); pos.y=-pos.y;
AddFlyTargetPosition(pos); SendAddFlyTargetingPacket(0, pos); count++
· __ClearReservedAction()
REF/GameLib/InstanceBaseBattle.cpp:192 NEW_GetInstanceVectorInFanRange —— HALF_FAN_ROT_MIN=20 /
MAX=40 / MIN_DISTANCE=1000 / RPM=0.02per charfHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN
bearing(main→each) ∈ [bearing(main→target) ± fHalfFanRot] 才计入;按距离升序
:243 NEW_GetInstanceVectorInCircleRange —— 同上但无方位过滤
:124 NEW_GetRandomPositionInFanRange —— fRot = frandom(fDstDirRot-10, fDstDirRot+10)
rotate (0, 8000, 0) by -fRot around Zdst = curPixelPos(main) + rotated
REF/UserInterface/PythonSkill.cpp:1361 GetTargetCount(fSkillPoint) —— 空公式 → 0
else ProcessFormula(strTargetCountFormula, fSkillPoint)(变量名 SkillPoint
REF/UserInterface/PythonPlayerSkill.cpp:391 __GetSkillTargetRange —— GetTargetRange() +
GetStatus(POINT_BOW_DISTANCE)*100(基础项 = .msk Range,弓距加成留调用方叠加)
改动:
project/net_play.gd ——
· +const HUGE_RACE_SIZE=3 / HUGE_RACE_RANK=4 / CLICK_ITEM_PICKUP_CM=20 / HUGE_RACE_DIST_FIX_CM=200 /
RESERVE_SKILL_RANGE_TRIM_CM=10 / FAN_HALF_ROT_MIN=20 / FAN_HALF_ROT_MAX=40 /
FAN_HALF_ROT_MIN_DISTANCE=1000 / FAN_RANDOM_JITTER_DEG=10 / FAN_RANDOM_REACH_CM=8000
+enum FlyShape {SINGLE, FAN, CIRCLE}
· _refresh_reserved() 由「只 CLICK_ACTOR」改为 4-case matchCLICK_ITEM →
_reserve_process_click_item()_ground_item_world_pos(iid) 消失→_clear_reserved()
global_position.distance_to(pos) < 20/CM → _send_state(FUNC_WAIT,0,pos)+client.pickup_item(iid)+
pc.frozen=false+_clear_reserved()else pc.walk_to(pos));CLICK_POSITION →
_reserve_process_click_position()pc._can_translate()==false → return_reserved_delay>0 → return
pc.walk_to(_ppos_reserved)+_clear_reserved());USE_SKILL → _reserve_process_use_skill()
client.get_entity(vid) 空 / net_world.node_for(vid) 空 → _clear_reserved()
dist_cm = global_position.distance_to(tnode)*CM_is_huge_race(race) → dist_cm -= 200
dist_cm < _skill_range_reserved → _target_vid!=vid 时 set_target+emit+_refresh_target_hud()
再 _invoke_reserved_skill() 成功才 _clear_reserved()else pc.walk_to(tnode)
· _clear_reserved() 追加复位 _ppos_reserved=ZERO / _skill_range_reserved=0.0
· +use_skill_hook := Callable() / _invoke_reserved_skill()slot<0 → falsehook.is_valid() →
bool(hook.call(slot))else false
· +reserve_click_item(iid) / reserve_click_ground(pos)_reserved_delay=0.1/
reserve_use_skill(vid, slot, range_cm)range_cm>100 → -10/ pick_ground_item(iid)(清预约 +
_auto_attack_vid=0distance*CM < CLICK_DIST_RESOURCE_CM → _send_state(FUNC_WAIT)+pickup_item+
解冻;else reserve_click_item(iid)
· +_ground_item_world_pos(iid)(扫 client.get_ground_items()MapCoord.to_world(p)
· +_is_huge_race(race)race<=0 / 无 proto.mob → falsem=proto.mob(race);空→false
int(m.size)>=3 or int(m.rank)>=4
· +_change_target_to_picked_instance(vid) → boole 空 / dead / _entity_kind ∉ [0,2] →
_target_vid=0 + client.set_target(0) + target_changed.emit(0) + hud.clear_target() + false
else _target_vid=vid + client.set_target(vid) + emit + _refresh_target_hud() + true
· +_bearing_deg(from, to) = rad_to_deg(atan2(d.x, -d.z)) / +_dir_from_bearing(deg) =
Vector3(sin(r), 0, -cos(r)) / +_entity_world_pos(vid)node_for → global_position
else 实体 pos 经 MapCoord.to_worldelse null
· +_instances_in_circle_range(range_cm)(扫 get_entities(),跳 vid 0 / _main_vid / 非 _is_attackable
dist_m <= range_cm/CM,按 dist_m 升序 → [{vid, dist_m, pos}]
· +_instances_in_fan_range(range_cm, target_vid)(从圆形取,tp=_entity_world_pos(target_vid) 空→[]
dst_rot=deg_to_rad(_bearing_deg(main, tp))rpm=(40-20)/1000per cand
each_dist_cm=min(dist_m*CM, 1000)half_fan=(40-20)-rpm*each_dist_cm+20
absf(angle_difference(dst_rot, deg_to_rad(_bearing_deg(main, cand.pos)))) <= deg_to_rad(half_fan)
· +_random_pos_in_fan_range(target_vid)base_bearing = _bearing_deg(main, tp) 或 pc 朝向;
rot = base_bearing + randf_range(-10, 10)return main + _dir_from_bearing(rot) * (8000/CM)
· +send_fly_targeting(target_vid, range_cm, max_count, shape) → int(无 add_fly_targeting /
max_count<=1 / target_vid==0 / shape==SINGLE / 无 pc.player → 0
reach_cm = range_cm + _bow_distance_bonus*100cands = FAN ? _instances_in_fan_range : _instances_in_circle_range
count=1 extras=0loopcount>=max break,跳 vid==target_vid / dead
client.add_fly_targeting(vid, int(sc.x), int(sc.y)) with sc=_server_xy(pos)count++ extras++
while count<maxclient.add_fly_targeting(0, _server_xy(_random_pos_in_fan_range(target_vid)))
count++ extras++return extras
· _on_pick() 重写:vid==0 / ==_main_vid → return_reserved_mode==USE_SKILL && _dash_active →
_vid_reserved=vid; return_entity_kind(e) ∈ [1,3,4] → client.click_npc(vid); return
_attack_cd=0; _change_target_to_picked_instance(vid)
project/ui/skill_table.gd ——
· _load_msk 追加解析 .msk TargetCountFormula → entry.target_count_formula、Range →
entry.target_rangemax(0, int(...))
· +target_count(id, level)formula 空 → 0Expression.parse(formula, ["SkillPoint"])
execute([_skill_point(level)]);失败 / 非数 → 0int(floor(value))
· +target_range(id) → int(entry.get("target_range", 0))
· +fly_shape(id)is_fan_range → 1is_circle_range → 2else 0,对齐 NetPlay.FlyShape
project/ui/quickbar.gd ——
· activate() 里 client.cast_skill 成功后、skill_activated.emit 前:target_vid!=0 && net_play &&
table && net_play.has_method("send_fly_targeting") && (is_fan_range || is_circle_range) →
net_play.send_fly_targeting(target_vid, float(table.target_range(id)),
table.target_count(id, _skill_level(id)), table.fly_shape(id))
· +activate_reserved(global_slot) → bool(越界 → false;换算 locals.kind!="skill" / s.id==0 /
_now()<s.cd_end → falseactivate(local)return true
· +_skill_level(sid)(扫 client.get_skills(),匹配 id → level;否则 0
project/game_scene.gd ——
· quickbar.setup(...) 后:if net_play: net_play.use_skill_hook =
func(slot: int) -> bool: return quickbar.activate_reserved(slot)
project/netplay_test.gd ——
· FakeClient +ground_items=[] / +skills=[] / calls +{"pickup": [], "fly": []}
+get_entities() / pickup_item(vid)calls.pickup.append/ add_fly_targeting(vid,x,y)calls.fly.append/
get_ground_items() / get_skills()
· +「5c」段:CLICK_ITEM 远→walk_to / 到手→pickup(777)+FUNC_WAIT+cleared / 物品消失→cleared
CLICK_POSITION 延时门后 walk_to((9,0,-2))+clearedUSE_SKILL range>100 trim→790.0 /
射程外→walk_to / 射程内→use_skill_hook([5])+set_target(4100)+cleared / 目标丢失→cleared
_change_target_to_picked_instance(NPC 5000)→_target_vid==0 & set_target==[0]
_change_target_to_picked_instance(怪 4100)→选中;send_fly_targeting FAN4200 主目标 (0,0,-10)、
4201 (2,0,-10) & 4202 (-2,0,-10) 入扇、4203 (0,0,12) 背后排除;max 3 → 2 extras
主目标不重发,两侧翼入选);CIRCLE(4203 计入,max 4 → 3 extras);
FAN 名额不足 max 6 → 5 extras 含 ≥1 个 vid=0 随机落点;max_count<=1 → 0SINGLE → 0。
fly 测试前把实体 2000 & 4100 的 node+pos 移到 (9999,0,9999)(不 free,测试 #7-#11 仍引用),
4200-4203 经 mk lambda 造(race:101,测试内无 proto → _entity_kind 返 0 → 可攻击/可选中)
project/skill_test.gd ——
· +§3.4 段(仅在有真实 skilldesc 资源时跑):is_fan_range(47) / fly_shape(47)==1 /
target_range(47)==2500 / target_count(47,1)==2 / target_count(47,20)==5 /
is_fan_range(91) / target_range(91)==1800 / target_count(91,20)==0bipabu 无公式)/
fly_shape(1)==0 / target_count(1,10)==0
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);13 个 GDScript 回归 → PASS
player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的
stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。三种预约模式逐行对齐 NEW_RefreshMouseWalkingDirection(含 CLICK_ITEM 的 20cm
拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 IS_HUGE_RACE 200cm 修正)。
扇形/圆形补目标:主目标 CG_FLY_TARGETING + CG_USE_SKILL 仍由 M2Client::use_skill 发,
send_fly_targeting 只补附加目标(CG_ADD_FLY_TARGETING,含 vid=0 随机落点),几何逐行对齐
NEW_GetInstanceVectorInFanRange / InCircleRange / GetRandomPositionInFanRange。
已文档化 seam:① IS_HUGE_RACE vnum 白名单未随源码发布 → proto.mob(race) size>=3 or rank>=4 近似;
② __GetSkillTargetRange 与 __GetBowRange 在原版就一个 ×100 一个不 ×100,两处各自照抄;
③ .msk Range token 用 _ascii_field 首个字节匹配,对含 AttackRange/SplashRange 前置的 .msk 会误配
shipped gwangyeok/bipabu 已 tr 验证只有一处 case-sensitive Range,安全);
④ use_skill_hook 目前无 live producerreserve_use_skill 无人调——为技能 click-cast 预留),
game_scene 把 hook 接到 quickbar.activate_reserved,测试直接注入 lambda
activate_reserved 在 ready 槽位调 activate() 即返 true(对 `if (__UseSkill(slot))` 的保真差距)。
下一批次(增量 92):§3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中 + __CanShot 弓箭合法性。
```
```text
批次:2026-09-03 / W2 / §3.6 飞行物实体 CFlyingManager / CFlyingInstance 生成 / 命中 + __CanShot 弓箭合法性
状态:✅ 完成(增量 92
参考:
REF/GameLib/FlyingObjectManager.h —— enum EIndexFlyType { NORMAL=0, FIRE_CRACKER=1, AUTO_FIRE=2 }
REF/GameLib/FlyingObjectManager.cpp
· CreateFlyingInstanceFlyTarget(dwID, v3Start, cr_FlyTarget, canAttack) —— CRC 查 CFlyingData*
CFlyingInstance::New()->Create(...)push m_kLst_pkFlyInstID = m_IDCounter++
· Update() —— while 迭代:if (!pkFlyInst->Update()) { CFlyingInstance::Delete(); i = erase(i); } else ++i
· CreateIndexedFly(dwIndex, pStartActor, pEndActor) —— switch byType
NORMAL → start=posStart 像素坐标,target=pEndActorcanAttack=false
FIRE_CRACKER → fRot=fmod(GetRotation()-90+360,360)+frandom(-30,30)fDist=frandom(2000,5000)
fxRand=fDist*cos(rad(fRot))fyRand=fDist*sin(rad(fRot))fzRand=frandom(1000,2500)
start z+200target=posStart+(fxRand,fyRand,fzRand)
AUTO_FIRE → start z+100target=pEndActor
· DeleteAllInstances() —— PythonNetworkStreamPhaseLoading.cpp:187 调
REF/GameLib/FlyingInstance.cpp
· Create(pData, startPos, target, canAttack) —— m_FlyTarget=target; m_canAttack=canAttack;
__SetDataPointer(pData, startPos); __SetTargetDirection(m_FlyTarget)
· __SetDataPointer —— m_v3Position=startPos; m_bAlive=true; m_fStartTime=now;
quat RotationYawPitchRoll(rad(m_fRollAngle-90), 0, rad(m_fConeAngle));
opt m_bSpreading 随机扩散; m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0);
m_v3Accel=pData->m_v3Accel; m_fRemainRange=pData->m_fRange; m_iPierceCount=pData->m_iPierceCount
· __SetTargetDirection —— v3TargetPos=target.GetFlyTargetPosition();
if (m_bMaintainParallel) v3TargetPos.z += 50; dir=normalize(v3TargetPos - m_v3Position);
__SetTargetNormalizedDirection —— SafeRotationNormalizedArc((0,-1,0), dir) 旋转 velocity + accel
· Update() 每帧:
if (!m_bAlive) return false;
homingm_bIsHoming && m_fHomingStartTime+m_fStartTime < now && IsObject → AdjustDirectionForHoming
v3LastPosition=m_v3Position; m_v3Velocity += m_v3Accel*dt; m_v3Velocity.z += m_fGravity*dt;
v3Movement=m_v3Velocity*dt; _fMoveDistance=length(v3Movement);
fCollisionSphereRadius=max(_fMoveDistance*2, m_fCollisionSphereRadius);
m_fRemainRange -= _fMoveDistance; m_v3Position += v3Movement;
if (m_fRemainRange<0) → OnExplodingOutOfRange() + __Explode(false) + return false
对象目标 && !m_bTargetHittedsquare_distance_between_linesegment_and_point(
m_v3Position, v3LastPosition, targetPos) < m_fBombRange² →
m_bTargetHitted=TRUE; if (m_canAttack) pVictim->OnShootDamage();
OnExplodingAtTarget(m_dwSkillIndex); if (m_iPierceCount) { m_iPierceCount--; __Bomb(); } else { __Explode(); return false; }
位置目标:同线段判定 → __Explode(); return false
怪物(m_bHitOnAnotherMonster):球 r=10 CullingMgr.ForInRangem_HittedObjectSet 去重 → OnExplodingAtAnotherTarget
背景(m_bHitOnBackground):GetTerrainHeight(x,-y) > z → OnExplodingAtBackground(); __Explode(); return false + 球 r=1 建筑判定
return true
· __Explode(bBomb=true) —— if (!m_bAlive) return; m_bAlive=false; if (bBomb) __Bomb();
· __Bomb() —— if (!m_pData->m_dwBombEffectID) return; 在 m_v3Position 生成 bomb effect
REF/GameLib/FlyingData.cpp __Initialize() —— m_fInitVel=200 / m_fConeAngle=0 / m_fRollAngle=0 /
m_fRange=500 / m_v3Accel=(0,0,0) / m_fHomingMaxAngle=3 / m_fHomingStartTime=0 / m_fGravity=0 /
m_bSpreading=false / m_bMaintainParallel=false / m_bHitOnBackground=false / m_bHitOnAnotherMonster=false /
m_iPierceCount=0 / m_fCollisionSphereRadius=0 / m_fBombRange=10
REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(p1,p2,x) —— v=p2-p1; l=|v|²;
w=x-p1; d=dot(w,v); if (d<=0) return |w|²; if (d>=l) return |x-p2|²; return |cross(w,v)|²/l
REF/GameLib/FlyTarget.h —— EType { TYPE_NONE, TYPE_OBJECT, TYPE_POSITION }
REF/GameLib/ActorInstanceFly.cpp OnGetFlyTargetPosition —— 包围球心 m_v3Center 变换到世界
REF/GameLib/FlyHandler.h —— IFlyEventHandler::OnNoTarget() / OnNoArrow()__CanShot 失败走的回调)
REF/GameLib/InstanceBaseBattle.cpp:355 NEW_Attack —— IsDead / IsStun / IsKnockDown / IsUsingSkill 门
REF/UserInterface/InstanceBase.cpp:694 __GetBowRange —— 弓模式命中 / 点击距离
★ GC_CREATE_FLY 在本 40250 客户端 EterLib/NetStream.cpp 仅头串索引 70,无包处理器
PythonNetworkStreamPhaseGame*.cpp 无 handler)——实际驱动走 POC 自有 fly_cue
★ grep -rn "CanShot|__CanShot|CanShoot" 全树无命中——__CanShot 在发布源码树中无定义,防御式重建
改动:
project/fly_object.gd(新)——
· const INDEX_FLY_TYPE_NORMAL=0 / FIRE_CRACKER=1 / AUTO_FIRE=2
· class FlyData extends RefCounted —— 米制换算的 __Initialize() 默认值:init_vel=2.0 /
cone_angle=0 / roll_angle=0 / gravity=0 / accel=ZERO / flat_range=5.0(=m_fRange) /
bomb_range=0.1(=m_fBombRange) / pierce_count=0 / spreading=false / maintain_parallel=false /
is_homing=false / homing_start_time=0 / homing_max_angle=0 / hit_on_background=false
· class FlyInstance extends RefCounted ——
· target_position() —— 对象目标每帧刷 _target_pos = _target_obj.global_position
maintain_parallel → +0.5 y= v3TargetPos.z+=50
· create(d, start_world, target, attack) —— pos/last_pos/alive/_elapsed=0/target_hitted=false/
pierce=d.pierce_count/remain_range=d.flat_rangetarget 按 Node3D(对象)/ Vector3(位置)/
Dictionary{vid,pos,node} 分派;dir=(target_position()-pos).normalized()(回退 (0,0,-1));
vel=dir*d.init_velaccel=d.accel,非零则 accel=Quaternion((0,-1,0),dir).normalized()*accel
· _adjust_homing() —— vel 朝目标 dir 单步转向,限幅 deg_to_rad(homing_max_angle)>=180 全转),
经 vdir.slerp(tdir,t)*speed
· update(dt) -> bool —— if not alive: return false_elapsed+=dt
homing if is_homing and _elapsed>homing_start_time and _is_objectlast_pos=pos
vel+=accel*dtvel.y+=gravity*dtseam:参考端 .z=上,POC 映射 -Y=下);
movement=vel*dtmove_dist=movement.length()remain_range-=move_distpos+=movement
remain_range<0 → _emit("out_of_range")+_explode(false)+return false
br2=bomb_range²;对象分支(_is_object, not target_hitted):
_seg_point_sq_dist(pos,last_pos,target_position())<br2 → target_hitted=true
can_attack → _emit("shoot_damage")_emit("at_target")
pierce>0 → pierce-=1;_emit("bomb") else _explode();return false → return true
位置分支:_seg_point_sq_dist(...)<br2 → _emit("at_position")+_explode()+return false
背景:hit_on_background and _world.sample_height(pos.x,pos.z)>pos.y → _emit("at_background")+_explode()+return false
return true
· _explode(bomb=true) —— if not alive: return; alive=false; if bomb: _emit("bomb")
· _emit(ev) —— handler.call(ev, pos, target_vid) if valid
· static _seg_point_sq_dist(p1,p2,x) -> float —— 1:1 移植 GrpMath.h:80
· class FlyManagerthe Nodeextends Node)—— parent:Node3D / world:Object / manual_step:=false /
_instances / _visuals / _id_counter=1signal shoot_damage(target_vid) / exploded(world_pos, cause)
· setup(mount, w=null) —— parent=mount; world=w
· spawn(start_world, target, can_attack, data=null, skill_index=0) -> FlyInstance ——
null 则建 FlyData;建 FlyInstanceinst.id=_id_counter++
inst.handler = func(ev,wp,vid): _on_instance_event(inst,ev,wp,vid)inst.create(...)
append _instancesparent!=null 则建 SphereMesh(r=0.08) 发光球 _visuals[inst]
· active_count() / step(dt)while 迭代 update → 更新 visual.global_position → 不 keep 则
queue_free + erase + remove_atelse ++i/ _process(dt)not manual_step → step(dt)
· _on_instance_event —— "shoot_damage"→shoot_damage.emit(vid)
"out_of_range"/"at_target"/"at_position"/"at_background"/"at_another"→exploded.emit(wp,ev) +
_spawn_flash(wp) if parent and ev!="out_of_range""bomb"→_spawn_flash if parent
· _spawn_flash(wp) —— MeshInstance3Dtween scale→ONE*6.0(0.25s) ∥ albedo_color:a→0.0,链 queue_free
project/net_world.gd ——
· +const FlyObject = preload("res://fly_object.gd")
· +signal fly_exploded(world_pos: Vector3, cause: String)(§3.6 命中 / 超程 / 触地)
· +var _fly_mgr: Node
· setup() 尾:_fly_mgr = FlyObject.new(); name="FlyManager"; add_child; _fly_mgr.setup(parent, world);
_fly_mgr.exploded.connect(func(wp,cause): fly_exploded.emit(wp,cause))
· _on_fly(_type, start_vid, end_vid) 重写 —— 保留 a/b 端点 + shooter fly_target_queue meta 消费
(现也据此设 target_node = _by_vid.get(target_vid) / null);原 tween「发光球」换成:
_fly_mgr.parent=parent; _fly_mgr.world=worldstart_w=a+(0,1,0); goal_w=b+(0,1,0)
FlyData dinit_vel=45.0 / flat_range=max(start_w.distance_to(goal_w)*1.5, 30.0) / bomb_range=0.6 / pierce_count=0
tgt = target_node if valid else goal_w_fly_mgr.spawn(start_w, tgt, false, d, _type)
project/net_play.gd ——
· +func _can_shot(target_vid := -1) -> String(紧跟 _is_bow_mode(),含 seam 注释块)——
not _is_bow_mode() → "NOT_BOW_MODE"_arrow_count==0 → "EMPTY_ARROW"-1 = 未跟踪/无限,放行);
tv = target_vid>=0 ? target_vid : _target_vidtv<=0 → tv=_vid_reservedtv<=0 → "NO_TARGET"
te = client.get_entity(tv)not te.is_empty() and not _is_attackable(te) → "NO_TARGET"
te.is_empty() 不拒——headless 容差,与 _send_fly_target「有节点即可」一致);
tp/mp = _entity_world_pos(tv)/_entity_world_pos(_main_vid)
两者 Vector3 and tp.distance_to(mp)*CM > _bow_range_cm() → "OUT_OF_RANGE"else ""
· _emit_swing 弓分支加门 —— 原 `if _is_bow_mode(): _send_fly_target(); bow_shot_fired.emit(_swing_skill)`
改为:why=_can_shot()why!="" → _emit_cannot(why)(走 OnNoArrow / OnNoTarget 文案,
不发 CG_FLY_TARGETING、不排 OnShoot);else _send_fly_target() + bow_shot_fired.emit(_swing_skill)
(seam:其余起手动作照常,参考端整挥击中止)
project/fly_test.gd(新,第 14 个 GDScript 回归)——
· extends SceneTree_init() → await _run() → PASS/quit(0) or quit(1)
· _run() 首行 await process_frameSceneTree._init 早于 root 就绪);_mk_mgr()manual_step=true);
_node_at(p)root.add_child + global_position=p
· _test_hit_object(直线命中对象目标,at_target 一次,实例移除)
· _test_out_of_rangeremain_range<0 → out_of_range,无 shoot_damage,不 __Bomb
· _test_can_attack_damageDictionary{vid,node} + can_attack → shoot_damage(4242) 恰一次)
· _test_piercepierce_count=1 → 命中后存活续飞 → 最终 out_of_rangecauses==["at_target","out_of_range"]
· _test_position_targetVector3 目标 → at_position
· _test_hit_background_FakeWorld.sample_height=0.9 + gravity=-20 → at_background
· _test_can_shot_FakeClient + NetPlayGENERAL→NOT_BOW_MODE / BOW+0 箭→EMPTY_ARROW /
BOW+箭+无目标→NO_TARGET / _arrow_count=-1 + 目标(5,0,0)→"" / 目标(400,0,0)→OUT_OF_RANGE /
死目标→NO_TARGET
· _test_seg_point_dist_seg_point_sq_dist:垂足在段内→9.0 / 越 p1→16.0 / 越 p2→25.0
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);14 个 GDScript 回归 → PASS
player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill / fly)。eterngrn_polish_test 的
stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。CFlyingInstance::Update 的逐帧管线(homing → vel+=accel*dt → remain_range-=|movement| →
线段-点 < bomb_range² 命中 → OnShootDamage / pierce / __Explode / __Bomb)逐行对齐;
_seg_point_sq_dist 1:1 移植 GrpMath.h:80FlyData 默认值取 FlyingData.cpp __Initialize()。
已文档化 seam
① GC_CREATE_FLY 在本 40250 客户端无包处理器(仅 NetStream.cpp 头串索引 70)——实际驱动走 POC 自有
fly_cueM2Client 发 fly_cue / fly_targetingnet_world._on_fly 消费);
② __CanShot 在发布源码树中无定义(grep 全树无命中)——据 OnNoTarget / OnNoArrow / NEW_Attack 的
IsDead 门 / __GetBowRange 防御式重建;_can_shot 不因实体数据缺失(te.is_empty())而拒,
只在确知不可攻击 / 已死时拒——headless 容差,保 combat_fx_test 的 §3.6 段(_target_vid=2000
但无 fc.ents[2000])仍过;
③ _emit_swing 弓分支为「跳过 fly-targeting / OnShoot」门,不中止整挥(状态包 + 动画照常)——
参考端 __CanShot 失败中止整个攻击;
④ 重力:参考端作用于 m_v3Velocity.z(=上),本 POC 映射到 vel.y(= -Y =下);
⑤ 怪物球体碰撞 / CullingMgr.ForInRange / 建筑体素 r=1 判定未移植——客户端飞行物只做命中「时机」,
伤害服务端裁决;
⑥ 无 .fly 数据文件加载器——net_world._on_fly 用合成箭参数(init_vel=45 / flat_range=max(dist*1.5,30) /
bomb_range=0.6);FIRE_CRACKER / AUTO_FIRE 的 CreateIndexedFly 分支已在 fly_object.gd 常量层预留,
驱动侧(GC_CREATE_FLY byType)本 build 无来源,未接线。
下一批次(增量 93):§3.1 Actor-Actor 碰撞(TestActorCollision / BlockMovement+ 坐骑 MODE_HORSE*
(依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。
```
### C.5 批次记录 · 增量 93W2 / §3.1 收尾:Actor-Actor 碰撞 + 坐骑转向速度)
```text
批次:增量 93 —— W2 §3.1 收尾(M1
状态:完成(纯离线;C++ 21/21 + 14 GDScript 回归全绿)
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/sourceISO-8859-1):
GameLib/ActorInstanceCollisionDetection.cpp —— CActorInstance::TestActorCollision(距离门 800cm²、
body sphere vs body sphere、末尾「dist(cur) <= dist(last) 才 return TRUE」=趋近判定)、
BlockMovement()(→ __InitializeMovement,坐骑时转发 m_pkHorse)、CanSkipCollision() /
EnableSkipCollision() / DisableSkipCollision()m_canSkipCollision)、__TestObjectCollision
m_canSkipCollision || 零移动 → FALSE)。
UserInterface/InstanceBaseBattle.cpp:469 CheckAdvancing —— CanSkipCollision → 不检;
逐个 CInstanceBaseusing-skill 时只撞 DoorTestActorCollision 命中 → uCollisionCount++
==2 → BlockMovement + return;否则 AdjustDynamicCollisionMovement(滑移,函数不在发布树)→
再测仍撞 → BlockMovement,否则 NEW_MoveToDestPixelPositionDirection 重推;末尾地形
ATTRIBUTE_BLOCK 分段 + next-pos 检测 → BlockMovement。
UserInterface/InstanceBase.cpp:17-18 c_fDefaultRotationSpeed=1200 / c_fDefaultHorseRotationSpeed=300
:951 MountHorse(eRace) → SetMotionMode(MODE_HORSE) + SetRotationSpeed(c_fDefaultHorseRotationSpeed)
+ m_GraphicThingInstance.Stop():964 DismountHorse() → m_kHorse.Destroy()
:3018 __InitializeRotationSpeed → SetRotationSpeed(c_fDefaultRotationSpeed)。
IsMountingHorse() = m_kHorse.IsMounting()InstanceBase.cpp:945)。
seamTestActorCollision / BlockMovement / CanSkipCollision 的「调用方」(CPythonCharacterManager
的逐帧 Deform / Update 循环)不在发布源码树;AdjustDynamicCollisionMovement 亦不在。
改动:
project/player_controller.gd ——
· +const ACTOR_BODY_RADIUS_M := 0.55(自身 + 对方相加 = combined 判定阈值;固定半径近似
真实 race 碰撞球)、ACTOR_COLLIDE_MAX_DIST_M := 8.0(≈ LengthSq > 800² 距离门)、
ROT_SPEED_DEFAULT_DEG := 1200.0、ROT_SPEED_HORSE_DEG := 300.0
· +var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG、+var skip_actor_collision := false
· +func _actor_blocked(next_pos) -> bool —— skip_actor_collision → false;遍历 pickables
跳过 !is_instance_valid / dead meta(≈ rVictim.IsDead());d_now = XZ 距离,> 8m 跳过;
d_next = XZ(op, next_pos)d_next < combined && d_next <= d_now + 1e-4 → true(趋近才挡)
· +func _turn_toward(target_yaw, dt) —— max_step = deg_to_rad(rotation_speed_deg)*dt
diff = wrapf(target_yaw - rot.y, -PI, PI)|diff| <= max_step → 直接到位,否则 += sign*max_step
· _process 平移分支:np = pos + wish*want_speed*dt 之后,
block_x = _actor_blocked(仅动 X) or _blocked(np.x, z)block_z 同理(逐轴门控=
单 Actor 滑移 ≈ AdjustDynamicCollisionMovement,两轴都挡=停在上一帧 ≈ BlockMovement);
not block_x → pos.x = np.xnot block_z → pos.z = np.z;既有 disp 判定继续把顶人磨蹭
切回 _is_going=false + wait
· moving_skill 分支 + 正常分支的 `player.rotation.y = atan2(wish.x, wish.z)` 两处 →
`_turn_toward(atan2(wish.x, wish.z), dt)`
project/net_play.gd ——
· _refresh_motion_mode() 尾:if pc and "rotation_speed_deg" in pc →
pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG
(对齐 MountHorse → SetRotationSpeedmounted 沿用既有 mount_vnum/mounting 判定)
· setup()is_observer_mode 之后)+ _on_observer_mode(enabled)if pc and
"skip_actor_collision" in pc → pc.skip_actor_collision = _observer_mode / enabled
(观战=自由穿行 ≈ CanSkipCollision
project/player_move_test.gd ——
· _run() 首行 +await process_frame(对齐 fly_test,避免 !is_inside_tree()
· 第 5 组后清 _reserved_ground / _reserved_delay_time(免遗留预约点改向后续组)
· +第 6 组:正前方 2m 放 blocker(入 pickables)→ _goto(10,0,0) → 停在 x∈(0.3,1.5)、is_going 归零
· +第 6b 组:blocker.dead meta = true → 直穿到 (10,0,0)(≈ rVictim.IsDead()
· +第 6c 组:skip_actor_collision = true → 直穿
· +第 7 组:rotation_speed_deg = 300 → 单个 50ms 步内 |rot.y| <= deg_to_rad(300)*0.05
且 pos.z 已 < -0.05(转向变慢但仍朝 Dst 平移)
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
14 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay /
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill /
fly)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async
竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。TestActorCollision 的距离门 + body 球重叠 + 趋近判定逐条对齐;BlockMovement
=停在上一帧(__InitializeMovement 语义);坐骑转向速度 300 / 1200 取 InstanceBase.cpp:17-18
常量;MODE_HORSE* 动作模式沿用增量 84。
已文档化 seam
① body sphere 半径用固定 0.55m 近似——真实值需 race 碰撞数据(.col / CollisionData.h);
② AdjustDynamicCollisionMovement 精确滑移向量不在发布树——用 _process 既有逐轴门控近似
(单 Actor 逐轴放行=滑移,两轴都挡=硬停);门 / Actor 复合的 uCollisionCount==2 亦以此近似;
③ CanSkipCollision 目前只由观战驱动——§3.2 网络状态包路径的 EnableSkipCollision 尚未接到
本地预测玩家(本地走客户端预测,服务器只 lerp 纠正);
④ TestActorCollision / CheckAdvancing 的逐帧「调用方」不在发布源码树——_process 内联近似;
⑤ MOVABLE_GROUND_DISTANCE 参考默认值仍是估计(1.0m),待读 game.py 侧 SetMovableGroundDistance。
下一批次(增量 94):§3.2 / §3.5 收尾(多为待真实服务器);随后转 M2(W3 玩法:§5.1 quest 标签、
§5.3 fishing/dungeon/mount/observer UI、§5.4 PVP/PK/duel、§4.1 costume/belt、§4.2 拖放鼠标物品态)。
```
### C.5 批次记录 · 增量 94W3→M2 / §5.4 PVP·PK·决斗可攻击判据 `IsAttackableInstance` 1:1
```text
批次:增量 94 —— W3→M2 §5.4M2 起点;纯 GDScript,无 C++ 改动)
状态:完成(纯离线;C++ 21/21 + 15 GDScript 回归全绿)
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/sourceISO-8859-1):
UserInterface/InstanceBase.cpp:2147 CInstanceBase::IsAttackableInstance —— 唯一判据,逐行照抄。
结构:__IsMainInstance() && IsObserverMode() → falseGetVirtualID()==victim → false
if IsStone() { victim.IsPC() → true }
else if IsPC() {
victim.IsStone() → true;
if victim.IsPC() {
switch GetDuelMode(): DUEL_CANNOTATTACK → false; DUEL_START → __FindDUELKey ? true : false;
if PK_MODE_GUILD==GetPKMode() && GetGuildID()==victim.GetGuildID() → false;
if victim.IsKiller() && !IsSamePartyMember(self,victim) → true;
if PK_MODE_PROTECT != GetPKMode() {
if PK_MODE_FREE==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 → true }
if PK_MODE_GUILD==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 && 公会不同 → true }
}
if IsSameEmpire(victim) {
if IsPVPInstance(victim) → true;
if PK_MODE_REVENGE==GetPKMode() && !同队 && IsConflictAlignmentInstance(victim) → true;
} else return true;
}
if victim.IsEnemy() → true;
if victim.IsWoodenDoor() → true;
}
else if IsEnemy() { victim.IsPC() → true; victim.IsBuilding() → true }
else if IsPoly() { victim.IsPC() → true; victim.IsEnemy() → true }
return false;
InstanceBase.cpp:502 GetAlignmentGrade>=12000→0 / >=8000→1 / >=4000→2 / >=1000→3 / >=0→4 /
>-4000→5 / >-8000→6 / >-12000→7 / else 8
InstanceBase.cpp:524 GetAlignmentTypegrade 0-3→WHITE / 4→NORMAL / 5-8→DARK
InstanceBase.cpp:470 IsSameEmpirevictim.empire==0 → TRUEIsGameMaster()(任一)→ TRUE
同 empire → TRUE;否则 FALSE
InstanceBase.cpp:2116 IsConflictAlignmentInstancevictim.GetPKMode()==PROTECT → false
self 类型 NORMAL/WHITEvictim==DARK → trueself==DARKself!=victim → true;否则 false
InstanceBase.cpp:2390 IsWoodenDoorIsDoor() && (vnum==13000 || 30111<=vnum<=30119)
InstanceBaseEffect.cpp:495 __FindDUELKey / :505 IsPVPInstanceGetDuelMode() → true
否则 __FindPVPKey(vidSrc,vidDst) || __FindGVGKey(guildSrc,guildDst)
PythonPlayer.cpp:1445 IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b)
GameLib/ActorInstance.cpp:270 IsPolyTYPE_POLY || (TYPE_PC && race>=MAIN_RACE_MAX_NUM))/
:282 IsPC==TYPE_PC/ :298 IsEnemy==TYPE_ENEMY/ :306 IsStone==TYPE_STONE)——
m_eActorType 单值,变身玩家仍是 TYPE_PC(故 IsAttackableInstance 的 else-if 链里 IsPC() 先命中,
IsPoly() 分支只服务真正的 TYPE_POLY 实例)。
Packet.h:1273 EPKModesPEACE=0/REVENGE=1/FREE=2/PROTECT=3/GUILD=4);
Packet.h:1264 ADD_CHARACTER_STATE_KILLER=1<<3InstanceBase.cpp:2086 SetStateFlags 从位取 killer。
seam__FindPVPKey / __FindGVGKey 的 key 集合 setterGC_PVP / 公会战对局包)、IsGameMaster 标记、
IsBuilding·IsWall 的 race 白名单——均不在发布源码树 / 本 POC 未下发。
改动:
project/entity_rules.gd(新建,纯静态 extends RefCounted)——
· const PK_MODE_* / ALIGNMENT_TYPE_* / DUEL_* / STATE_KILLER=1<<3 / STATE_PARTY=1<<4 /
KIND_PC=0..KIND_WARP=4(对齐 net_play._entity_kind 语义)
· alignment_grade(a) / alignment_type(a) —— :502 / :524 逐行
· _kind / _is_polypoly / polymorph / poly_vnum 任一)/ _is_pc==KIND_PC/ _is_stone /
_is_enemy==KIND_MONSTER/ _is_wooden_doorrace 13000 或 30111..30119/ _is_buildingseam
· _is_killerstate_flags & STATE_KILLER/ _is_same_partya,b 皆在 ctx.party_vids/
_is_same_empire:470/ _find_duel_keyvictim 或 self 在 ctx.duel_opponents/
_pair_in(无序对匹配)/ _is_pvp_instanceduel_mode!=0 || pvp_pairs || 双方公会非 0 且 gvg_pairs/
is_conflict_alignment_instance:2116/ _pk_mode_of
· is_attackable_instance(self_e, victim_e, ctx) -> bool —— :2147 逐行
project/net_play.gd ——
· +const EntityRules = preload("res://entity_rules.gd")CM 常量后)
· _is_attackable(e):桩「dead? false : _entity_kind(e) in [0,2]」→ 拼 self_e_main_entity() 快照 +
kind_entity_kind/ victim_e+ kind/ _attack_ctx(self_e),委托 EntityRules.is_attackable_instance
保留 dead 提前返回(便利,参考端此函数不查死亡,另在各调用点查);_main_entity() 空 → 退化
_entity_kind(e) in [KIND_PC, KIND_MONSTER]
· +_attack_ctx(self_e) -> Dictionary —— get_duel()active/cannot_attack → duel_modeopponents)、
get_pvp_relations()mode!=0 的 src/dst_vid → pvp_pairs)、get_guild_wars()src/dst → gvg_pairs)、
get_party()vid → party_vids);键:observer / main_vid / pk_mode / duel_mode / duel_opponents /
pvp_pairs / gvg_pairs / party_vids
· 5 处调用点(363 / 758 / 872__ReserveProcess_ClickActor 第 3 步 / 1096 / 1351)签名不变
project/entity_rules_test.gd(新建,第 15 个 canonicalextends SceneTree25 组断言)——
grade/type 边界、观战门、自打自、PC/怪/NPC/石头、同帝国 peace+empire 0)、异帝国、
PK_FREE(普通 / victim PROTECT / 同队)、PK_GUILD(同公会提前 false / 不同公会 true)、killer(±同队)、
决斗 CANNOTATTACK / START(±对手名单)、PVP-pair、GVG-pair(±双方公会)、REVENGE(±冲突善恶)、
石头 self / victim、木门 13000 / 30115 / 非木门 13001、is_conflict_alignmentvictim PROTECT / 黑对中 /
黑对黑)、变身玩家(仍走 PC 分支)、TYPE_POLY self(走 Poly 分支)
project/{combat_fx_test,netplay_test,fly_test}.gd ——
怪物夹具(ents[2000] / ents[4100] / send_fly_targeting 的 mk 闭包 / fly_test ents[900-902])补
"ch_type": 2 —— 此前无 ch_type → _entity_kind 归 PC0),旧桩按 [0,2] 也「可打」;1:1 规则下
和平同帝国 PC 不可攻击 → 夹具须明确怪类别(真实服务器 GC_CHARACTER_ADD 带 type,或 m2dev 走
mob_proto 查 race,生产环境二者恒具备)
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
15 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay /
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill /
fly / entity_rules)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为
既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。IsAttackableInstance 的四分支 + 决斗三态 + PK 五模式 + 帝国 / PVP / GVG / 善恶 /
killer / 同队全部逐行;判据 helperalignment_grade/type、IsSameEmpire、IsConflictAlignmentInstance、
IsPVPInstance、__FindDUELKey、IsSamePartyMember、IsWoodenDoor1:1IsPC / IsEnemy / IsPoly 依
ActorInstance.cpp 的 m_eActorType 单值语义(变身玩家仍是 PC)。
已文档化 seam
① __FindPVPKey 的 key 集合 setterGC_PVP 关系表未接(get_pvp_relations() 有值则消费);
② __FindGVGKey 的公会战对局 setter 未接(get_guild_wars() 对表消费,来源 GUILD_GC_WAR);
③ IsGameMaster 无 GM 标记 → _is_same_empire 里恒 false
④ IsBuilding / IsWall 的 race 白名单源码未随发布 → _is_building 读显式 building 字段;
⑤ m2dev 后端的 IsKiller 状态位(state_flags & 1<<3)——classic 后端 classic_parser.cpp 已接;
⑥ _entity_kind 无法分类(无 ch_type 且 mob_proto 查不到 race)时怪被当和平 PC → 不可攻击
(生产 mob_proto 恒加载,仅裸测试夹具受影响,本批次已补 ch_type);
⑦ 名字颜色 / 可攻击光标(ENameColor,§8.8)、IsInSafe 的 ATTRIBUTE_BANPK 地形属性 —— 未接。
下一批次(增量 95):§5.1 quest 38 tagquest_dialog EventTypeMap + ProcessEventSet 分发)、
§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。
```
### C.5 批次记录 · 增量 95W3→M2 / §5.1 Quest EventManager `CPythonEventManager` 1:1
```text
批次:增量 95 —— W3→M2 §5.1M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(纯离线;C++ 21/21 + 18 GDScript 回归全绿)
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/sourceISO-8859-1):
UserInterface/PythonEventManager.h —— :35 EEventType42 项枚举)、:98 SEventSet、
常量 c_lNormal_Waiting_Time=10 / c_fLine_Temp=16 / BOX_VISIBLE_LINE_COUNT=5 /
EVENT_POSITION_START=0 / EVENT_POSITION_END=1。
UserInterface/PythonEventManager.cpp —— :1148 ctor 填 EventTypeMap42 条 std::map<string,int>
CLEAR 与 CLEAR_TEXT 是两个独立枚举但都走 ClearLine);:1093 GetScriptEventIndex(前导 `/` →
EVENT_POSITION_END + 剥离,未知名字 → 解析错误 / 跳过);:377 ProcessEventSet(大 switch
逐命令弹出);:312 UpdateEventSet(逐帧 lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait
倒计时刷新 m_strLeftTimeString);:72 __InitEventSet:976 __InsertLine:953 ClearLine
:884 MakeQuestion:878 MakeNextButton:48 GetCameraSettingFromArgList:142
RegisterEventSetFromString(首命令 RUN_CINEMA 特例:整脚本换成事件文件);:562/:566 SETMSGPOS /
ADJMSGPOS case 为**空实现**。
改动:
project/ui/quest_dialog.gd —— 由旧的单遍手写 parse_script 重写为 EventTypeMap + 状态机 + 分派:
· const EVENT_TYPE_MAP42 键,tag 字符串 → EVT_*+ enum { EVT_LETTER .. EVT_SELECT_ITEM }
(顺序 = EEventType+ const EVENT_POSITION_START/END、C_NORMAL_WAITING_TIME=10、
C_LINE_TEMP=16、BOX_VISIBLE_LINE_COUNT=5
· _get_script_event_index(raw_name) -> {ok, position, type} —— 前导 `/` → position=END + 剥离;
大写查表;未知 → ok=false / type=-1(调用方 return,不进 match
· class EventSet extends RefCounted —— SEventSet 字段名 1:1ix/iy/iWidth/iyLocal/isLock/
lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
DiffuseColor/lWaitingTime/iRestrictedCharacterCount=30/iVisibleStartLine/iVisibleLineCount/
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer+ POC 累加器(lines/color_open/choices/
has_next/has_input/has_select_item/window_size/images/insert_images/title_image/
quest_buttons/quest_button_closes/map_signals/clear_map_signals/atlas_center/camera_events/
fade_events/dungeon_result/cinema_file/end_confirm);方法 init_event_set()__InitEventSet)、
insert_line()__InsertLine:推行 + iyLocal += C_LINE_TEMP + 清 strCurrentLine/iCurrentLetter)、
clear_line()ClearLine)、body_text()
· _process_event_set(es, cmd) = ProcessEventSet 的 switch 逐 case 对拷:literal → 合成 LETTER
(is_raw=true)DELAY / COLOR / COLOR256 的 START vs `/END`LETTER / ITEM / MOB 的
iCurrentLetter >= iRestrictedCharacterCount → insert_line 断行(裸文本 is_raw 跳过断行);
NEXT / DONE / INSERT_IMAGE 的 iAdjustLine += 2WAITiyLocal=0; isLock=true);
CLEAR / CLEAR_TEXT → clear_lineQUESTION → MakeQuestionchoices + nAnswer);
LEFTIMAGE / TOPIMAGE / BGIMAGE / IMAGE / TITLE_IMAGE / INSERT_IMAGEADDMAPSIGNAL /
CLEARMAPSIGNAL / SETCMAPPOSSETMSGPOS / ADJMSGPOS → pass(引 :562/566 空实现);SLEEP →
lLastDelayTimeSET_CAMERA_camera_setting 九参 = :48/ BLEND_CAMERA+blendtime/
RESTORE_CAMERAFADE_OUT / FADE_IN / WHITE_OUT / WHITE_IN → fade_events + isWaitFlag=true
TEXT_HORIZONTAL_ALIGN_CENTER → isTextCenterModeDUNGEON_RESULT → 9 named int
QUESTBUTTON / QUESTBUTTON_CLOSEWINDOW_SIZEw>0 && h>0);INPUTinsert_line + has_input);
CONFIRM_WAITisConfirmWait=true + iConfirmEndTime + insert_line(center));END_CONFIRM_WAIT
(清 isConfirmWait + end_confirm);SELECT_ITEMRUN_CINEMA → passProcessEventSet 无此 case
· _tokenize(src) —— 拆混排文本 / `[tag ...]`;裸文本 → {literal}tag → {name(大写、保留前导 `/`,
pos, tok(括号内原文)}
· arg 提取:_tag_kv(tok)key(v) / key("v") / key;v,括号深度 + 去引号,重复键取末次)+
_arg_s / _arg_f / _arg_ikeyword 优先、位置回退——兼容旧测试 value;255 / value(1) 写法)
· parse_script(src) -> Dictionary —— 建 EventSet → init_event_set() → _tokenize → 首命令
RUN_CINEMA 特例(仅当 cmds[0] 即 RUN_CINEMA、无前导文本 → es.cinema_file)→
for cmd: _process_event_set;返回字典=旧契约全键不变,新增
insert_images / dungeon_result / adjust_line / waiting_time / is_locked / wait_flag /
answer_count / cinema_file / end_confirm
· +signal cinema_requested(filename)_emit_side_effects 在 fade_events 循环后透出
project/quest_event_test.gd —— 新建,第 16 个 canonicalextends SceneTree),23 组断言:
EventTypeMap 42 tag 完整性 + 无多余;_get_script_event_index 的 `/` → END / 未知 → ok=false /
大小写不敏感;DELAY START→waiting_time=40 / `/DELAY`→10;显式 [LETTER] ≥30 字断 2 行 vs
裸长文本仍 1 行;ENTER→行 / CLEAR·CLEAR_TEXT→重置;QUESTION→3 choices + answer_count=3
NEXT→adjust_line=2 / NEXT+DONE→4WAIT→is_lockedCOLOR 命名 r/g/b + COLOR256 位置
value;255 → [color=#00ff00] / [color=#ff8000]INPUT→has_input / SELECT_ITEM→has_select_item
CONFIRM_WAIT→confirm_wait + confirm_timeout=30 / +END_CONFIRM_WAIT→清 + end_confirm
DUNGEON_RESULT→9 named intINSERT_IMAGE 带 image_nameis_item_icon=false / adjust_line=2vs
不带(is_item_icon=true / idx=19);IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE→4 项 + panel + TITLE_IMAGE
ADDMAPSIGNAL/SETCMAPPOS/CLEARMAPSIGNALQUESTBUTTON keyed + QUESTBUTTON_CLOSE idx
camera SET(setting.distance=50)/BLEND(blendtime=1500)/RESTORE3 事件);FADE_OUT/WHITE_IN →
fade_events + wait_flagWINDOW_SIZE keyed + TEXT_CENTERITEM/MOB → "怪物 #77"/"物品 #19"
SETMSGPOS/ADJMSGPOS → no-op;未知 tag → 跳过;RUN_CINEMA 首命令 → cinema_file="intro.msc" /
非首命令 → cinema_file="" + body 保留
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
18 个 GDScript 回归(16 canonical,含新增 quest_event_test+ quest_test + select_item_ui_test
全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async 竞态,
非本批次触及,仍 exit 0 + PASS。
验收:纯离线。EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 +
SEventSet 状态字段名 1:1。裸网络文本合成 LETTER(is_raw) → 跳过 iRestrictedCharacterCount
30 字断行(server 权威句子不逐字符断),显式 [LETTER]/[ITEM]/[MOB] 仍断行(对齐参考)。
已文档化 seam
① 逐帧 UpdateEventSet :312 的时间推进(lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait
倒计时刷新 m_strLeftTimeString)—— 一次性跑完 tokenisLock / isWaitFlag 仅作状态保留并透出;
② Skip / EndEventProcess / iConfirmEndTime 的 timeGetTime —— 帧驱动,未接;
③ .msc 事件文件加载(RegisterEventSetFromString :142 的 RUN_CINEMA 换文件)—— 只记
cinema_file + 发 cinema_requested,文件读取 / 脚本替换未接;
④ MakeNextButton NEXT vs DONE 的按钮文案区分 + DoneEvent() 回调 —— 均归 has_next
⑤ camera_event_requested / fade_event_requested 消费端与 §7.6 相机模式、game_scene 的
cinema_requested 接线 —— 信号已透出,consumer 未接;
⑥ 物品名颜色 (1.0,0.2,0.2)reference ITEM_NAME)—— 以纯文本落地,着色为 cosmetic seam。
下一批次(增量 96):§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。
```
### C.5 批次记录 · 增量 96W3→M2 / §4.1 wear 槽位版图 + costume/ring/belt 发包坐标)
```text
批次:增量 96 —— W3→M2 §4.1M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(纯离线;C++ 21/21 + 55 GDScript 回归全绿)
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/sourceISO-8859-1grep 需 -a):
UserInterface/GameType.h:40-112 —— 槽位版图:
c_ItemSlot_Count=90 / c_Equipment_Start=90 / c_Equipment_Count=12
c_Equipment_Body..Shield = 90+0..90+10EWearPositions BODY..SHIELDWEAR_MAX_NUM=11
#ifdef ENABLE_COSTUME_SYSTEM c_Costume_Slot_Start = 90+19Body=109 / Hair=110
#ifdef ENABLE_NEW_EQUIPMENT_SYSTEM c_New_Equipment_Start = 90+21
c_Equipment_Ring1=111 / c_Equipment_Ring2=112 / c_Equipment_Belt=113
c_Wear_Max=32 / c_DragonSoul_Equip_Start = 90+32 = 122 / c_Belt_Inventory 4x4=16
—— 109..113 全在 INVENTORY 全局 cell 命名空间(window=1),客户端无独立 costume/ring/belt 窗。
GameLib/ItemData.h —— EWearPositions:226)、EItemWearableFlag:244WEARABLE_BODY 1<<0 …
WEARABLE_ARROW 1<<9)、ELimitTypes:160LIMIT_LEVEL/STR/DEX/INT/CONITEM_LIMIT_MAX_NUM=2)。
GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 仅取表,
不做 CanEquipNow(仲裁在服务器 char_item.cpp)。
UserInterface/PythonItemModule.cpp:185/207 —— itemIsWearableFlag / itemGetLimit 是 client
暴露给 uiscript 的全部装备判据。
extension/src/net/wire.h —— WINDOW_INVENTORY=1 / WINDOW_EQUIPMENT=2 / BELT_INVENTORY_SLOT_START
= 90+32+6*2+6*3 = 152 / BELT_INVENTORY_MAX_NUM=16。
改动:
project/ui/inventory_ui.gd
· 新增常量:WEAR_MAX_NUM=11 / WEAR_COSTUME_BODY=19 / WEAR_COSTUME_HAIR=20 / WEAR_RING1=21 /
WEAR_RING2=22 / WEAR_BELT=23(值不变)/ NEW_EQUIP_WEAR_START=WEAR_COSTUME_BODY /
WINDOW_EQUIPMENT=2 —— 全部 1:1 自 GameType.h / ItemData.h
· _to_wire(ui_idx)BELT_BASE(200)+ → [1, 152+n](不变);EQUIP_BASE(90)+ 拆两路——
wear = ui_idx-90wear >= 19 → [WINDOW_INVENTORY, 90+wear](全局 cell 109..113);
wear 0..10 → [WINDOW_EQUIPMENT, wear](零基 wear cell,不变);其余 → [1, ui_idx]
—— 修掉 _to_wire(113) 旧返回 [2, 23],现 [1, 113]
· _add_extended_cells:扩展装备格由 _add_grid(…, first=11, count=WEAR_COUNT=24, …) 收窄为
_add_grid("时装 / 戒指 / 腰带", first=WEAR_COSTUME_BODY, count=WEAR_BELT-WEAR_COSTUME_BODY+1=5, …)
—— EWearPositions 11..18 无位置,不再生成死格;腰带背包 16 格不变
· 头注释重写为 _to_wire 分流说明;WEAR_COUNT 保留为 get_equipment() 数组上限注释
project/inventory_test.gd —— 加 7 条 wire 断言:_to_wire(90)==[2,0] / (100)==[2,10] /
(109)==[1,109] / (110)==[1,110] / (111)==[1,111] / (112)==[1,112](原有 (113)==[1,113] 转绿)
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21net.loopback_flow 首跑 localhost bind
抖动,--rerun-failed 即过;无 C++ 改动);55 个 GDScript 回归全 PASS(含 inventory_test /
inventory_ui_test)。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有
async 竞态,非本批次触及,仍 exit 0 + PASS。
验收:纯离线。wear 槽位版图与 GameType.h 逐值对齐;costume/ring/belt 发包坐标回到 INVENTORY
全局 cell(客户端事实:这三类无独立窗);基础 11 wear 仍走 WINDOW_EQUIPMENTinventory_ui_test
的 move_to(5,91) → [1,5,2,1,1] 锁定)。
已文档化 seam
① §4.1 修改 2 —— antiflag(性别/职业/帝国) → wearable_flag 匹配目标槽 → limit(等级/属性) →
CG_ITEM_USE 的装备校验链 + CANNOT_EQUIP_* 文案:40250 客户端不做此前置门,需按 §3.9
__CanShot 方式在 GDScript 防御性重建,精确 tie-break 顺序留待接服务器比对;
② §4.1 修改 4 —— 部位隐藏 / 遮挡(穿 costume 隐藏本体 armor、穿头盔隐藏发型),
InstanceBase.cpp 的 ChangeArmor / SetHair / __IsShapeAnimalWearrender 侧未接;
③ §4.1 修改 5 —— 远端角色 armor/weapon/hair 三字段接换模(net_world.gd 调用序已在 §2.5 对齐);
④ 扩展装备面板的图标 / tooltip / 拖放目标高亮沿用现有背包格逻辑,未做 costume/ring 专属外观。
下一批次(增量 97):§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
§4.2 拖放鼠标物品态。
```
### C.5 批次记录 · 增量 97W3→M2 / §4.1 修改 2 —— CanEquipNow 前置门)
```text
批次:增量 97 —— W3→M2 §4.1 修改 2M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 §4.1 修改 2 完成(antiflag→wearable_flag→limit 装备校验链在客户端防御性重建);
§4.1 修改 4/5(render 侧部位隐藏 + 远端换装)仍为 seam,留待增量 98+。
参考:
REF/GameLib/ItemData.h
· EItemAntiFlagclassic 排列):FEMALE=1<<0 / MALE=1<<1 / WARRIOR=1<<2 / ASSASSIN=1<<3 /
SURA=1<<4 / SHAMAN=1<<5 / … / EMPIRE_A=1<<9 / EMPIRE_B=1<<10 / EMPIRE_R=1<<11
· EItemWearableFlagWEARABLE_BODY=1<<0 … WEARABLE_UNIQUE=1<<7 / SHIELD=1<<8 / ARROW=1<<9
· EWearPositionsWEAR_BODY=0 … WEAR_SHIELD=10 / WEAR_MAX_NUM=11
· ELimitTypesLIMIT_NONE=0 / LIMIT_LEVEL=1 / LIMIT_STR=2 / LIMIT_DEX=3 / LIMIT_INT=4 /
LIMIT_CON=5 / LIMIT_PCBANG=6 / LIMIT_REAL_TIME=7
· EItemTypeITEM_TYPE_WEAPON=1 / ARMOR=2 / COSTUME=28 / RING=33 / BELT=34
· EArmorSubTypesARMOR_BODY=0 / HEAD=1 / SHIELD=2 / WRIST=3 / FOOTS=4 / NECK=5 / EAR=6
· ECostumeSubTypesCOSTUME_BODY=0 / COSTUME_HAIR=1
REF/GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 均为纯表 getter
客户端无 CanEquipNow(仲裁在服务器 char_item.cpp)。
REF/UserInterface/PythonItemModule.cpp:185/207 —— uiscript 只拿到 itemIsWearableFlag / itemGetLimit。
REF/UserInterface/GameType.h:40-112 —— 新装备槽 c_Costume_Slot_Body=90+19 / Hair=90+20 /
Ring1=90+21 / Ring2=90+22 / Belt=90+23。
common length.h MAIN_RACE_* —— job = race%4race 0..7 性别表见 SEX_BY_RACE。
改动:
project/equip_rules.gd(新增,extends RefCounted 纯校验器)
· 全部枚举常量 1:1 自 ItemData.h / GameType.h(见上「参考」)
· sex_of_race(race) / job_of_race(race)=race%4 —— 表 1:1 自 MAIN_RACE_*
· default_wear_for(item)type/sub → 默认 wear 槽(WEAPON→4 / COSTUME→19|20 / RING→21 /
BELT→23 / ARMOR→match sub);不可穿戴 → -1。is_equippable() = default_wear_for >= 0
· wear_to_wearable(wear)wear 槽 → 需要的 WEARABLE_* 位;新槽 19..23 → 0
· slot_accepts(item, wear):新槽(19/20 costume、21/22 ring、23 belt)按 item type
旧 11 槽按 (wear_flags & wear_to_wearable(wear)) != 0
· can_equip(item, wear, ctx) -> {ok, code, need}
item 空 → NO_ITEM
target = wear>=0 ? wear : default_wear_for(item)target<0 → NOT_EQUIPPABLE
anti-flag 性别(sex==FEMALE & ANTIFLAG_FEMALE / sex==MALE & ANTIFLAG_MALE)→ ANTI_SEX
anti-flag 职业(anti & JOB_ANTIFLAG[job])→ ANTI_JOB
anti-flag 帝国(EMPIRE_ANTIFLAG[ctx.empire] 命中)→ ANTI_EMPIRE
not slot_accepts(item, target) → NOT_WEARABLE_HERE
limits 逐条:value<=0 跳过;LIMIT_LEVELctx.level<v → LIMIT_LEVEL(need=v)
LIMIT_STR/DEX/INT/CON_stat(ctx,key) >= 0 且 < v → 对应 code(need=v)
PCBANG / REAL_TIME* → pass(服务器判,seam
全过 → {ok:true, code:"", need:0}
· ctx{race, sex, job, empire, level, st, dx, ht, iq}sex/job 缺省由 race 推;
属性 st/dx/ht/iq 缺省或 <0 = 不判(M2Client 未按名暴露 → seam
project/ui/inventory_ui.gd
· const EquipRules = preload("res://equip_rules.gd")
· signal equip_rejected(code: String, need: int)
· use(ui_idx) 重写:ui_idx < EQUIP_BASE(常规背包格)且 proto.item(vnum) 可穿戴时,
跑 EquipRules.can_equip(pi, -1, _equip_ctx())not ok → _set_hint(_equip_reject_hint(verdict))
+ equip_rejected.emit(code, need) + return false(不发 use_item);否则维持 _to_wire + use_item
· 新增 can_equip_item(vnum, wear=-1) -> Dictionary(公开查询)
· 新增 _equip_ctx()get_points().level + get_entities() 主角 race/empire/levelst/dx/ht/iq = -1
· 新增 _equip_reject_hint(verdict)ANTI_SEX/ANTI_JOB/ANTI_EMPIRE/NOT_WEARABLE_HERE/LIMIT_* → 中文
project/equip_rules_test.gd(新增,canonical #17)—— 33 断言:race 表、default_wear_for 各
type/sub、slot_accepts、anti-flag 性别/职业/帝国、NOT_WEARABLE_HERE、LIMIT_LEVEL/STR/CON
含 need + 属性 -1 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 NO_ITEM
project/inventory_ui_test.gd —— FakeProto.item() 补 sub_type=0 / anti_flags=0 /
wear_flags=1<<4 / limits=[] 让 iv.use(5)vnum 19 武器)仍过新前置门
测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21
(无 C++ 改动);56 个 GDScript 回归全 PASS(新增 equip_rules_testinventory_test /
inventory_ui_test 保持绿)。
验收:纯离线。装备校验链枚举与 ItemData.h 逐值对齐;语义顺序(antiflag→wearable→limit)对齐
服务器 char_item.cpp::CanEquipNow;背包右键穿戴被前置门拦下时不发 use_item 并 emit
equip_rejected。
已文档化 seam
① 精确 tie-break 顺序 / 已穿同槽替换 / LIMIT_PCBANG / LIMIT_REAL_TIME* —— 留待接服务器比对;
② 属性 4 项(st/dx/ht/iqM2Client 未按名暴露 → _equip_ctx() 传 -1(对应 limit 项被跳过);
③ §4.1 修改 4(部位隐藏 / 遮挡)+ 修改 5(远端换装接字段)—— render 侧,留待增量 98+。
下一批次(增量 98):§4.1 修改 4/5(render 侧)、§5.2 任务日志窗口、
§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
```
### C.5 批次记录 · 增量 98W3→M2 / §4.1 修改 4 —— 变身 / 时装部位遮挡 + 修改 5 字段序确认)
```text
批次:增量 98 —— W3→M2 §4.1 修改 4(部位遮挡)+ 修改 5(远端换装字段序)(M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 §4.1 修改 4 完成(变身 / 动物时装 / 婚纱的整件遮挡在渲染侧 1:1 重建);
修改 5 字段序已确认 1:1(增量 46 落地),远端 PC 换模链要等真模型工厂(增量 99+);
变身态本身是 seam(40250 实体表无变身字段,_is_poly 恒 false)。
参考:
REF/UserInterface/InstanceBase.cppISO-8859-1grep -a
· IsPoly() → SetShape(0)SetWeapon / SetParts 提前 return
—— 变身:身体 shape 0、无武器、无头发 / 挂件
· __IsShapeAnimalWear():2834= GetShape() ∈ {100,101,102,103}
—— SetWeapon:2747/ SetParts 返回 false:穿动物 / 怪物时装时武器 + 头发被抑制
· IsWearingDress() = m_eShape == 201(婚纱)
· __IsChangableWeapon(iWeaponID):901)—— 婚纱状态除 c_iBouquets 外一律 false
c_iBouquets = {50201, 50202, 50203, 50204}(原表 0 结尾)
· __ArmorVnumToShape(iVnum, *pdwShape):281)—— 默认 *pdwShape = iVnum
iVnum ∉ {0,1} 且 USE_ARMOR_SPECULAR 时 *pdwShape = pItemData->GetValue(3)SHAPE_VALUE_SLOT_INDEX=3
· ChangeArmor:2780)—— SetRace(rebuild) → SetArmor → SetHair → SetWeapon → SetRotation →
__AttachHorseSaddle → RefreshState(NAME_WAIT,TRUE) → OnChangeShape()
REF/UserInterface/NetworkActorManager.cpp:457 UpdateActor —— 远端换装序(:473-482):
ChangeArmor(m_dwArmor) → ChangeWeapon(m_dwWeapon) → ChangeHair(m_dwHair) → ChangeGuild →
SetAffectFlagContainer → SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags
extension/src/net/entity_store.h:57 —— ch_type0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP
m2_client.cpp:2429 暴露为 d["ch_type"]);entity dict 无 poly_race / polymorph 字段。
改动:
project/part_hiding.gd(新增,extends RefCounted,纯函数无状态)
· POLY_SHAPE = 0
· ANIMAL_WEAR_SHAPES = [100, 101, 102, 103] # __IsShapeAnimalWear
· WEDDING_DRESS_SHAPE = 201 # IsWearingDress
· BOUQUET_VNUMS = [50201, 50202, 50203, 50204] # c_iBouquets
· is_animal_wear(shape) / is_wearing_dress(shape) / is_bouquet(vnum)
· is_changable_weapon(weapon_vnum, shape):婚纱 → 仅捧花;否则 true
· weapon_hidden(shape, is_poly, weapon_vnum=0)is_poly animal_wear (dress ∧ ¬bouquet)
· hair_hidden(shape, is_poly)is_poly animal_wear(婚纱不影响头发)
· body_shape(shape, is_poly)is_poly → 0;否则原样(动物时装 100-103 保留,用来出时装体)
· effective_weapon(weapon_vnum, shape, is_poly)weapon_hidden → 0;否则原值
project/ui/equip_model.gd
· const PartHiding = preload("res://part_hiding.gd")
· refresh() 重排:身体 → 头 / 发 → 武器 → 盾(对齐 ChangeArmor 的 SetArmor→SetHair→SetWeapon
· 解出 raw_shape = armor_shape_of.call(body_vnum)= __ArmorVnumToShapeproto values[3] / =vnum
· is_poly = _is_poly()(新增,seam 恒 false
· hair_off = PartHiding.hair_hidden(raw_shape, is_poly):真 → 清 hair_gr2 / hair_skin
head_key 用 -1_last_head_vnum 哨兵从 -1 改 -2(-1 现在是「发型被遮挡」的有效态);
恢复时 _last_hair_part = -1 强制重挂
· wpn_eff = PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) → _resolve_weapon
· 盾:sh_eff = PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) ? 0 : sh_vnum
· _is_poly()(新增):return false —— 40250 实体表暂无变身字段,seam
project/net_world.gd
· _apply_field_updates() 头注补 §4.1 修改 5armor/weapon/hair 字段序已 1:1 于
NetworkActorManager.cpp:473;变身 / 时装遮挡统一走模型侧 part_hiding.gd
当前 seam —— 远端 PC 是占位胶囊(_make_entity_model 对 ch_type==0 返回 null),
set_armor/weapon/hair 是 no-op
project/part_hiding_test.gd(新增,canonical #18)—— 45 断言:
animal-wear 全集(100-103 真 / 0,5,99,104,201 假)、dress 判定、bouquet 判定、
is_changable_weapon(婚纱+捧花 / 婚纱+剑 / 普通 shape / 动物 shape 不被此门拦)、
weapon_hidden 各分支、hair_hidden(婚纱不隐藏头发)、body_shape(变身→0 / 动物时装保留)、
effective_weapon 各分支
project/equip_model_test.gd —— 补 7 段:动物时装(armor_shape_of 桩返回 101)遮挡
weapon_gr2 / shield_gr2 / hair_gr2、脱时装武器重解析、婚纱(桩返回 201)遮武器保头发、
婚纱换捧花(vnum 50201_last_weapon_vnum 恢复为 50201
测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21
(无 C++ 改动);57 个 GDScript 回归全 PASS(新增 part_hiding_testequip_model_test /
equip_rules_test / inventory_ui_test 保持绿)。
验收:纯离线。part_hiding.gd 常量与 InstanceBase.cpp 逐值对齐;equip_model.refresh() 刷新序
对齐 ChangeArmor;动物 / 怪物时装隐藏武器+盾+发、婚纱非捧花隐藏武器+盾且头发保留、
脱下后恢复。
已文档化 seam
① 变身态 —— m2_client.cpp entity dict 无 poly_race / polymorph_is_poly() 恒 false
等 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带变身 race 后接 client.get_entity(vid).poly_race
② 远端 PC 的真模型工厂不属于本批次,已在增量 99 首版落地;本批次当时仍只确认字段序,
未把“字段已到位”误记为“远端模型已完成”。
下一批次(增量 99):远端 PC 真模型工厂(PlayerView + 只认 parts[] 的远端 EquipModel)、
§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
```
### C.5 批次记录 · 增量 99W3→M2 / §4.1 修改 5 —— 远端 PC 真模型工厂与 awPart[4]
```text
批次:增量 99 —— W3→M2 §4.1 修改 5M2 玩法 + 渲染接线;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(远端 PC 首版真模型与四字段换装链已落地;所有职业 / race、变身和真服视觉仍待验收)。
参考:
REF/UserInterface/Packet.h:1282-1289 —— CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 /
HAIR=3 / CHR_EQUIPPART_NUM=4
REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:113-115,160-162,193-195 ——
角色附加信息和更新包携带 awPart 数组;
REF/UserInterface/NetworkActorManager.cpp:473-482 —— ChangeArmor → ChangeWeapon → ChangeHair
→ ChangeGuild → affects / move speed / attack speed / alignment / PK / state flags。
改动:
project/ui/remote_player_view.gd(新增)—— 复用 player_view.gd 的 race / GR2 / MSA 建模,
创建远端专用 EquipModel,只消费四槽 awPart[ARMOR..HAIR],提供四个部位 setter
project/ui/equip_model.gd —— 新增 setup_remote() / _remote_parts,远端模式不读取
client.get_equipment(),四个远端部位变更后刷新 PartHiding / 真实模型;
project/game_scene.gd —— 默认实体工厂对 ch_type==0 创建 RemotePlayerView,按 race 缓存资源
可用性,失败时仍安全回退占位胶囊;怪 / NPC 继续走 MobView
project/net_world.gd —— _apply_field_updates() 从旧的三字段假设改为
set_armor(parts[0]) → set_weapon(parts[1]) → set_head(parts[2]) → set_hair(parts[3])
project/remote_player_test.gd(新增)—— 真实 warrior 资源建模、四槽换装及 NetWorld 字段序回归。
测试:
/opt/homebrew/bin/godot --headless --path project --script remote_player_test.gd → PASS
gamescene_test.gd、equip_model_test.gd、net_world_vis_test.gd、netplay_test.gd、
player_motion_test.gd → 全部 PASS。
验收:已验证真实 warrior 的基础模型 / 发型 / 武器加载,远端四槽数据不依赖本地装备快照,
以及字段调用顺序;未验证其他职业 / race、远端真实服务器洪流、poly_race / 变身、精确体型与
服装资源视觉、跨地图重建和真机性能。
风险与依赖:40250 当前 entity dict 没有 poly_race / polymorphEquipModel._is_poly() 仍是
false;部分 race 若缺少可用 GR2/MSA 会按工厂失败回退占位,不应把回退视为模型支持。
下一批次:W3 继续 §5.2 任务日志 / §5.3 fishing、dungeon、mount、observer UIW4 继续 EffectLib
精确 OUTER / INNER 与 advancedW5 继续 Mac App G1/G2。
```
### C.5 批次记录 · 增量 100W4 / §6.3 EffectLib emitter cycle 生命周期首版)
```text
批次:增量 100 —— W4 §6.3EffectLibGDScript,无 C++ 改动)
状态:🟡 部分完成(2026-09-03`fx_test` 通过;精确 OUTER / INNER 与每粒子语义仍为 seam
参考:`40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60`
(缺省 `CycleLength=0.05`、`CycleLoopEnable=false`、`LoopCount=0`);
`40250/ClientVS22/source/EffectLib/ParticleSystemInstance.cpp:280-300`
cycle 边界、LoopCount 递减和停止发射);
`ParticleSystemInstance.cpp:153-209`OUTER / INNER 按粒子半径计算速度)。
改动:
`project/fx/effect_player.gd` —— 区分缺省 cycle 与显式零值;保存 cycle / loop / loop count 元数据;
有限 cycle 到期停止 GPU 发射,重播复位 CPU 时钟;`_longest_life()` 将发射窗口与粒子寿命分开,
删除原来用 cycle 覆盖 `GPUParticles3D.lifetime` 的错误;OUTER / INNER 保留 source mode 并使用
明确记录的 `ParticleProcessMaterial.spread=180` 宽方向 fallback。
`project/fx_test.gd` —— 增加 cycle、LoopCount、独立粒子寿命和 OUTER fallback 断言。
测试:`/opt/homebrew/bin/godot --headless --path project --script fx_test.gd` 通过;
输出 `PASS: fx_test`。本批次没有 C++ 改动,因此未把 CTest 结果冒充为本批次证据。
验收:已验证缺省值、有限 cycle / LoopCount 停止、重播状态复位和粒子寿命不被 cycle 覆盖;
未验证真实 40250 特效视觉、每周期 emission residue / 时间曲线重置、OUTER / INNER 精确径向速度、
随机起始帧 / 每粒子随机方向与帧语义、骨骼挂点和真机视觉。
风险与依赖:Godot `ParticleProcessMaterial` 没有按每粒子初始位置求径向速度的属性;`spread=180`
只是显式 fallback,不能称为 FREE / OUTER / INNER 的 1:1 实现。后续需要独立 particle shader
或 CPU 粒子实例路径,并同时保留参考的 emission residue、时间曲线和纹理随机语义。
下一批次:W4 继续 EffectLib 精确 advanced / TexAniW3→M2 继续
fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态与 §4.3 tooltipW5 继续 Mac App G1/G2。
```
### C.5 批次记录 · 增量 101W3→M2 / §5.2 Quest 日志窗口)
```text
批次:增量 101 —— W3→M2 §5.2M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(离线 `quest_test` 通过;真实 Quest 生命周期、导航表现和真服资源仍未验)
参考:`assets/root/uicharacter.py:651-703,1339-1346`(任务行选择、5 行窗口、clock 与滚动);
`40250/ClientVS22/source/UserInterface/PythonQuest.h/.cpp``QUEST_MAX_NUM=5`、默认 icon、
quest index / clock 数据;源码路径按当前 40250 checkout 记录)
改动:
`project/ui/quest_log.gd` —— 新增 `QUEST_MAX_NUM=5`、任务快照、可见行 / 起始索引、滚动条和
离散滚动比例;无 icon 时使用参考默认 `season1/icon/scroll_open.tga`;任务行点击发送
`script_button(-2147483648 + questIndex)` 并发出 `quest_selected`;保留独立 QuestButton
条带的原有 `script_button(index)` 路径;
`project/quest_test.gd` —— 新增 7 条任务,覆盖只显示 5 行、滚动到最后 5 行、QuestButton
负基址参数和空 icon 回退路径。
测试:`/opt/homebrew/bin/godot --headless --path project --script quest_test.gd`PASS。
验收:未验证——真实服务端 RegisterQuest / SetQuestClockValue 的 start time、任务完成 / 删除
生命周期、真实 icon / `.sub` 视觉、世界箭头、屏幕边缘指示、多列定位和真机布局。
风险与依赖:当前 M2Client 任务字典没有把 `iStartTime` 单独透出,因此 clock 仍以现有服务端
`clock_value` 快照按窗口打开后递减;要完全对齐参考端需先把 server start time / refresh 语义
接入数据层。多列定位和导航依赖后续 minimap / world marker 工作流。
下一批次:W3→M2 继续 §5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态和 §4.3
tooltipW4 继续 EffectLib 精确 advanced / TexAniW5 继续 Mac App G1/G2。
```
### C.5 批次记录 · 增量 102(W3→M2 / §5.3 钓鱼输入状态机)
```text
批次:增量 102 —— W3→M2 §5.3M2 玩法;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(钓鱼输入 / 取消 / 水面扫描首版已落地;鱼获落袋、坐骑 / 副本 / 观战者仍未完成)
参考:
40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24
—— 按下攻击键且处于 IsFishingMode 时进入 NEW_Fishing;方向键进入 NEW_CancelFishing
40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:419-425,506-549
—— 地面点击先清预约 / 自动攻击,再取消钓鱼;取消间隔 500ms;NEW_Fishing 发送方向或失败回调;
40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108
—— GetFishingRot 仍是未迁移的水面角度扫描依据;
40250/ClientVS22/source/UserInterface/Packet.h
—— CG_FISHING / GC_FISHING 协议常量与事件。
改动:
project/net_play.gd —— 增加 fishing_feedback、钓鱼意图状态、五类事件状态转移;
钓鱼模式攻击键改发 CG_FISHING,不设置普通攻击键;方向由 MapCoord yaw 转服务端 heading
activate_fishing() 处理特殊技能 123on_ground_click() 清 reserved / auto attack 后取消,
cancel_fishing() 按 500ms 节流,保留 can_fishing_position 可选 seam。
project/player_controller.gd —— 增加方向键边沿 / 地面点击取消回调;WASD 长按只在边沿取消一次,
地面点击回调消费后不再继续移动。
project/game_scene.gd —— 装配上述两个取消入口,并把 fishing_feedback 接回 cannot_act 通道。
project/ui/quickbar.gd —— SPECIAL_SKILL + SKILL_INDEX_FISHING(123) 进入 activate_fishing()。
测试:
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd
/opt/homebrew/bin/godot --headless --path project --script player_move_test.gd
/opt/homebrew/bin/godot --headless --path project --script skill_test.gd
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd
/opt/homebrew/bin/godot --headless --path project --script combat_fx_test.gd
以上均 PASS。
验收:
已验证钓鱼模式不会误发普通攻击;yaw=0 按 MapCoord 发送 270°;START 后可取消;
连续取消被 500ms 节流;方向键边沿、地面点击的顺序和特殊技能 123 入口均有回归覆盖。
风险与依赖:
Metin2World 已提供 GetFishingRot 等价的 ATTRIBUTE_WATER 扫描;扫描仅覆盖已加载区块,
无地图 provider 的协议-only 场景仍保留 fallback,流式换图后的 provider 覆盖需真实验收。
GC_FISHING(FISH) 仍沿用已有鱼获 / 失败日志路径,没有 item proto 校验、背包落袋或鱼获 UI。
坐骑 m_kHorse.CanAttack、真实模型 / MODE_HORSE* 动作 / 移速,副本 DungeonBlock / 结算窗,
观战自由视角和完整 ADD/REMOVE/MOVE 生命周期仍是独立缺口。
下一批次:
增量 104 优先实现 wrong-place locale 文案和鱼获 item proto / inventory 事务。坐骑、副本、
观战者继续拆成互不写同一文件的独立批次。
```
### C.5 批次记录 · 增量 103W3→M2 / §5.3 GetFishingRot 水面扫描)
```text
批次:增量 103 —— W3→M2 §5.3(地图数据 + 钓鱼玩法;C++ 扩展与 GDScript 桥接)
状态:🟡 部分完成(GetFishingRot 水面扫描首版已落地;wrong-place 文案、鱼获事务和其他世界玩法仍未完成)
参考:
40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108
—— GetFishingRot 以 600cm 距离、当前方向左右 0..180°、每 10° 检查水面;
40250/ClientVS22/source/UserInterface/InstanceBase.cpp:364-377
—— 水面判据是 CPythonBackground::isAttrOn(..., ATTRIBUTE_WATER)
40250/ClientVS22/source/PRTerrainLib/Terrain.h:64-72
—— ATTRIBUTE_WATER = (1 << 1)。
改动:
extension/src/metin2_world.h/.cpp —— 绑定 get_fishing_rotation() /
can_fishing_position();读取已加载 Chunk 的 attr.atr,按 MapCoord 对应的 world frame
在前方 6m 扫描当前 heading ±0..180°,返回第一个水面 heading;无水 / 未加载区块返回 -1。
project/net_world.gd —— 将实体层收到的查询转发给 Metin2World;无地图 provider 返回 -2
让 NetPlay 保留协议-only fallback。
project/net_play.gd —— 优先采用扫描选中的 heading;扫描失败发送 FISHING_WRONG_PLACE
兼容旧 bool provider。
project/fishing_water_test.gd —— 加载 A1 全 20 区块,自动寻找真实 ATTRIBUTE_WATER 样本,
验证 600cm 查询、heading 返回、bool wrapper 和地图外拒绝。
测试:
cmake --build build -j2 —— PASSlibmtgodot.macos.template_debug.dylib 重新链接;
/opt/homebrew/bin/godot --headless --path project --script fishing_water_test.gd —— PASS
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
验收:
A1 全 20 区块真实水面样本通过扫描;NetWorld → Metin2World 桥接可用;没有地图 provider
时不误报 wrong-place;有地图且无水面时返回 -1 并由 NetPlay 转 wrong-place。
风险与依赖:
当前水面查询依赖区块已加载;真实流式切换、角色出生点 / 水面边界和真服钓鱼动作仍需验收。
该批次只实现位置判定,不包含 OnFishingWrongPlace 的 locale 文案、FISH item proto 校验、
背包落袋和鱼获 UI。
下一批次:
增量 105+ 实现真实 `GC_ITEM_*` 鱼获 / inventory 事务;坐骑、副本、观战者继续保持独立文件边界
并行推进,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。
```
### C.5 批次记录 · 增量 104(W3→M2 / §5.3 钓鱼反馈消息)
```text
批次:增量 104 —— W3→M2 §5.3(钓鱼反馈消息;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(消息分支已落地;真实 `GC_ITEM_*` 背包事务、坐骑 / 副本 / 观战者仍未完成)
参考:
40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860
—— `GC_FISHING` 的 START/STOP/REACT/SUCCESS/FAIL/FISH 分支;FAIL 调 `OnFishingFailure`
FISH info=0 调 `OnFishingNotifyUnknown`,未知 item proto 静默返回,fish / non-fish 依据
item type 与 `IsFishing()` 分别调 `OnFishingNotify` / `OnFishingSuccess`。
assets/locale/locale/en/locale_game.txt
—— `FISHING_FAILURE`、`FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2`、
`FISHING_UNKNOWN`、`FISHING_WRONG_PLACE` 文案键。
改动:
project/ui/chat_ui.gd —— `setup()` 加载 `locale_game.txt`;新增 `set_proto()` /
`on_fishing_feedback()`;按 `RecvFishing` 状态与 item proto 类型选择 failure / unknown /
notify / success 文案;`GC_FISHING(SUCCESS)` 只更新钓鱼意图和动画,不直接打印“鱼钩命中”。
project/game_scene.gd —— 注入 item proto 给 `ChatUI`,连接 `fishing_feedback` 的 wrong-place
反馈;`chat_test.gd` 增加鱼类 item、active / inactive 文案和 wrong-place 回归。
测试:
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
验收:
已验证 `FISHING_FAILURE`、`FISHING_UNKNOWN`、wrong-place、fish active / inactive 两组
locale 分支;未知 item proto 不产生日志;`SUCCESS` 不再被误报为鱼钩命中。
风险与依赖:
参考端的鱼获数量不是 `RecvFishing(FISH)` 直接修改,而是由独立 `GC_ITEM_*` 包完成;本批次
没有伪造本地落袋。下一批次需要先核对 item 包字段、库存事务和 `item proto`,再接鱼获 UI。
下一批次:
增量 105 已先接入坐骑攻击等级门;增量 106+ 继续实现真实 `GC_ITEM_*` 鱼获 / inventory 事务,
并行推进副本、观战者和坐骑模型,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。
```
### C.5 批次记录 · 增量 105(W3→M2 / §5.3 坐骑攻击等级门)
```text
批次:增量 105 —— W3→M2 §5.3(坐骑攻击限制;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(攻击等级门已落地;坐骑模型 / MODE_HORSE* 动作 / 移速和真服验收仍未完成)
参考:
40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216
—— SHORSE::GetLevel 按 mount race/vnum 映射等级;SHORSE::CanAttack 在骑乘 level<=1 时返回 false
40250/ClientVS22/source/UserInterface/InstanceBaseBattle.cpp:623-628
—— CInstanceBase::CanAttackHorseLevel 调用 SHORSE::CanAttack
40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:737-764
—— __CanAttack 对新坐骑要求 GetSkillGrade(109)>=1 或 GetSkillLevel(109)>=11
40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:226-235
—— __Update_AutoAttack 在骑乘且不能攻击时清除自动攻击目标。
改动:
project/net_play.gd —— 新增 _horse_level_for_mount()、_is_main_mounted()、_is_new_mount()、
_horse_skill_unlocked();接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门;
自动攻击改用 mount_vnum,不再只检查不存在的 mounting 字段。
project/netplay_test.gd —— 覆盖 20101 level 1 拒绝、20205 level 2 放行、
新坐骑 skill 109 level 1 拒绝 / level 11 放行。
测试:
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
验收:
已验证离线坐骑 vnum→level 和骑乘术等级门;未验证真实坐骑模型、MODE_HORSE* 动作 / 移速、
new mount 的所有 race/vnum 及真服时序。
风险与依赖:
未知 mount vnum 按参考端 GetLevel()==0 放行;这是兼容未来坐骑资源,不等于已完成真服坐骑验收。
坐骑模型和动画仍依赖实体 mount 数据、资源可用性和后续渲染接线。
下一批次:
增量 106 已接入小地图 GC_OBSERVER_ADD/REMOVE/MOVE 的 1 秒平滑 / world_reset 清理;
增量 107 已接入副本目的地状态 / HUD 罗盘;增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、
DungeonBlock、观战自由视角和坐骑模型。
```
### C.5 批次记录 · 增量 106(W3→M2 / §5.3 观战者小地图)
```text
批次:增量 106 —— W3→M2 §5.3(观战者小地图轨迹;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(小地图轨迹已落地;自由视角、真服生命周期和换图时序仍未完成)
参考:
40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180
—— AddObserver / MoveObserver / RemoveObserver 保存 observer 轨迹,MoveObserver 以 1 秒
线性插值到新坐标;PythonNetworkStreamCommand.cpp:295-310 的 ObserverMode 只切换
observer bool,本批次不把它扩展成参考端没有证据的独立自由相机。
改动:
project/ui/minimap.gd —— 新增 observer_event 消费、独立轨迹表、ADD 初始快照播种、MOVE 一秒
插值、REMOVE 删除,以及 world_reset 清理;不把 observer 当作 EntityStore 的可见实体。
project/p9_test.gd —— 覆盖 ADD / MOVE / REMOVE / reset 和插值中间态。
测试:
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
验收:
已验证小地图 observer 的增删、移动平滑和地图重置清理;未验证真实服务器包时序、观战状态
生命周期、自由视角相机和真服地图切换。
风险与依赖:
参考端 ObserverMode 证据是输入 / 攻击 / 技能门控,不足以证明存在独立自由相机;实现前仍需
找到相机切换的真实参考调用或通过真服行为验收,不凭经验添加自由镜头。
下一批次:
增量 107 接入副本目的地状态 / HUD 罗盘;增量 108+ 继续推进 observer 生命周期验收和
真实 GC_ITEM_* 鱼获 / inventory 事务。
```
### C.5 批次记录 · 增量 107(W3→M2 / §5.3 副本目的地状态)
```text
批次:增量 107 —— W3→M2 §5.3GC_DUNGEON 目的地消费与 HUD 罗盘;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(目的地消费 / 到达 / HUD 首版已落地;DungeonBlock、入口 / 结算和真服验收仍未完成)
参考:
40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901
—— RecvDungeon 的 TIME_ATTACK_START 分支为空;DESTINATION_POSITION 读取两个 unsigned long
调用 CPythonPlayer::SetDungeonDestinationPosition。
40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:147-167,1255-1285
—— 保存全局厘米坐标;当前位置与目标的 Manhattan 距离严格小于 10000cm 时清除目标,
未到达时每 20 秒重新 AlarmHaveToGo;收到目的地后立即创建目标罗盘特效。
改动:
project/dungeon_state.gd —— 消费 dungeon_event,保存原始全局厘米坐标和 MapCoord 世界坐标;
立即发出 alarm_requested,按参考端 Manhattan <10000cm 判定到达,每 20 秒再次提示,
并在 world_reset 清理。TIME_ATTACK_START 保持空分支,不虚构本地倒计时。
project/hud.gd —— 增加目标距离和相对朝向提示;这是参考端 AlarmHaveToGo 的 HUD 适配,
还没有加载参考 `.mse` 罗盘特效文件。
project/game_scene.gd —— 创建 DungeonState,接入 M2Client.dungeon_event / world_reset
将 destination_changed 接到 HUD。
project/dungeon_state_test.gd —— 覆盖空计时分支、目的地坐标转换、立即提示、100m 边界、
到达清除、第二个目的地和 world_reset。
测试:
/opt/homebrew/bin/godot --headless --path project --script dungeon_state_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
验收:
已验证 GC_DUNGEON 目的地状态的协议字段、厘米 / 米坐标转换、参考端到达边界和场景 reset;
未验证真实副本入口、DungeonBlock 的 `.mde` / `.mdatr` 解析与碰撞、9 参数结算窗、真服时序。
风险与依赖:
GC_DUNGEON 的目的地是全局坐标,不是当前区块局部坐标;真实地图 origin / 坐标基准仍需真服
场景验收。TIME_ATTACK_START 在参考源码中没有本地计时器,不能据此添加倒计时。
下一批次:
增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、DungeonBlock `.mdatr` collision、
副本入口 / 结算、observer 生命周期 / 相机证据、坐骑模型和 §4.2 / §4.3 剩余 UI 分支。
```
### C.5 批次记录 · 增量 108(W3→M2 / §5.2 任务信标世界箭头 / 屏幕边缘指示)
```text
批次:增量 108 —— W3→M2 §5.2(小地图任务信标越界夹边 + 方向箭头;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(世界箭头 / 屏幕边缘指示首版已落地;多列定位、真实 `.sub` 状态图仍未完成)
参考:
40250/ClientVS22/source/UserInterface/PythonEventManager.cpp:554-570
—— EVENT_TYPE_ADD_MAP_SIGNAL / CLEAR_MAP_SIGNAL 调用 CPythonMiniMap::AddSignalPoint(x,y)
/ ClearAllSignalPoint() + OpenAtlasWindow()。
m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:1334-1358
—— AddSignalPointpush TSignalPoint + AddWayPoint(TYPE_WAYPOINT, id, x, y, "")
ClearAllSignalPoint:逐 id RemoveWayPoint 后清表。
m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:441-462
—— Render 的 waypoint 夹边分支:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f
fDistanceFromCenter = sqrt(dx*dx+dy*dy)>= radius 时 fRadian = atan2f(dy,dx)
dx = radius*cosf(fRadian)、dy = radius*sinf(fRadian),标记贴到圆周(→ __RenderTargetMark)。
说明:40250/ClientVS22 的 PythonMiniMap.cpp 为裁剪版,无 AddSignalPoint / 夹边渲染定义,
故夹边几何以 m2dev 完整版为对照基线(seam:该逻辑不在唯一基线 40250/ClientVS22 内)。
改动:
project/ui/minimap.gd —— 新增 const SIGNAL_RIM_INSET = 9.0(对齐 c_fMiniMapWindowRadius 的
`- 9.0f` 留边);新增 static signal_marker_geometry(delta_px, rim_radius) → {clamped, offset,
angle}1:1 复刻 atan2 + radius*cos/sin 夹边;新增 _signal_offset(world, origin)(世界 delta
→ 像素,轴向与 _to_map 一致);_draw_map 的信标循环改为:圆内画橙星(原样),越界调
_draw_signal_arrow(pos, angle) 在圆周画指向真实目标的三角箭头(对应 __RenderTargetMark 的
边缘指示,本工程无 `.sub` 资源用三角形代替)。_signal_points 存储与 add/clear_signal_point
入口不变。
project/p9_test.gd —— 小地图段新增 8 断言:圆内保留真实 offset、越界夹到圆周、夹边保留
正东 / 对角 atan2 朝向、恰在半径按 `>=` 计为越界、add/clear_signal_point 计数。
测试:
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script atlas_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
回归:p8_test / p10_test / net_world_vis_test / netplay_test 均 PASS。
验收:
已验证夹边几何(atan2 朝向保持、越界贴圆周、留边常量)、信标增删计数和重绘存活;
未验证真实 `.sub` waypoint / target 图标视觉、Atlas 同一信标的夹边、任务日志多列定位、
真实 EventManager ADDMAPSIGNAL 端到端。
风险与依赖:
夹边逻辑的对照基线是 m2dev 完整版而非唯一基线 40250/ClientVS22(后者为裁剪版,缺该实现);
§5.2 剩余「多列定位」属 quest_log.gd 布局侧,未在本批次推进;OpenAtlasWindow 联动
ADDMAPSIGNAL 时自动打开 Atlas)仍由 quest_dialog 的 side effect 负责,未纳入本批次。
下一批次:
增量 109+ 推进 quest_log.gd 多列定位 / 真实图标视觉、§8.8 text_tail.gd(名字颜色对照基线
同样落在 m2dev 完整版)、§4.2 / §4.3 剩余 UI 分支。
```
### C.5 批次记录 · 增量 109(W3→M2 / §5.2 任务信件按钮条多列定位)
```text
批次:增量 109 —— W3→M2 §5.2quest 信件按钮条 __ArrangeQuestButton 1:1 多列布局;纯 GDScript,无 C++ 改动)
状态:🟡 部分完成(多列定位 + 最新在前 + 组队窗移位已落地;真实 `.sub` 信件图标 / 闪烁、任务日志窗体本身多列仍未做)
参考:
assets/root/interfacemodule.py:1357-1386 __ArrangeQuestButton
—— xPos = 130 (= 100 + 30) 若 wndParty.IsShow() 否则 20+15 RTL,本迁移不做 RTL);
yPos = 170 * screenHeight // 600 yCount = (screenHeight - 330) // 63
第 count 个:btn.SetPosition(xPos + (count // yCount) * 100, yPos + (count % yCount) * 63)。
assets/root/interfacemodule.py:1301-1353 BINARY_RecvQuest
—— 同 index 先 __DestroyQuestButton 再 questButtonList.insert(0, btn):无论新旧都移到最前;
__StartQuest / __DestroyQuestButton 后再 __ArrangeQuestButton 重排。
说明:40250/ClientVS22 的 source/ 树只含 C++questbuttonlist 的 Python 布局在 assets/root/
interfacemodule.py(原版随包脚本),非 seam。
改动:
project/ui/quest_log.gd —— 新增布局常量(QUEST_BTN_X=20 / QUEST_BTN_X_PARTY=130 /
QUEST_BTN_COL_W=100 / QUEST_BTN_ROW_H=63 / QUEST_BTN_SIZE);新增有序表 _quest_button_order
recv_quest 里 erase + push_front 复刻 insert(0, btn)clear_quest / _start_quest 同步 erase);
静态 quest_button_y_count(H)= maxi(1,(H-330)/63)(参考端无下限、H<393 会 ZeroDivision,夹 1 = 防御性偏离 seam)、
quest_button_y_pos(H)= 170*H/600、quest_button_slot_pos(slot,yCount,xPos,yPos)= 1:1 公式;
_refresh_quest_buttons 改为按 _quest_button_order 顺序、给每个 Button 绝对 position(不再 VBox 单列
sort by index);_button_root / _button_list 改为全屏 Control + MOUSE_FILTER_IGNOREroot 落原点、
坐标里已含 xPos/yPos_button_list 类型 VBoxContainer → Control(消掉 gamescene_test 的 SCRIPT ERROR);
组队窗可见性经 set_party_probe(Callable) 在 _process 里轮询(party_ui 无信号),变化即重排;
另加 set_party_shown / set_screen_size_override / quest_button_order / quest_button_position 便于回归。
project/game_scene.gd —— party_ui 建好后 quest_log.set_party_probe(func(): return party_ui.is_open())。
project/quest_test.gd —— quest_log 段尾新增 16 断言:yCount(600/1080/350 夹边)、yPos(600/1200)、
6 个信件 insert-at-front 顺序、slot 0/3/4(换列)/5 坐标、Button 节点 position、同 index 移到最前、
组队窗 xPos→130 且节点重定位、clear 从 order 移除、清空后 _button_root 隐藏。
测试:
/opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASSSCRIPT ERROR 消除)。
/opt/homebrew/bin/godot --headless --path project --script input_key_test.gd —— PASS。
回归:p8_test / p9_test / p10_test / net_world_vis_test 均 PASS。
验收:
已验证多列换列坐标、最新在前顺序、同 index 提前、组队窗 xPos 移位、清空隐藏。
未验证真实 `.sub` 信件图标 / highlight 视觉 / Flash 动画、RTL 的 +15、screenWidth 相关分支(原版未用)、
任务日志窗体(_list)自身的多列 / 分页、真实 EventManager QUESTBUTTON 端到端。
风险与依赖:
组队窗可见性靠 _process 轮询探针(party_ui 无 visibility-changed 信号),最坏 1 帧延迟;
screenHeight < 393 的 yCount 夹到 1 是防御性偏离(参考端会崩),实际分辨率不会触发;
__ArrangeQuestButton 每帧对全表 SetPosition,本迁移是「变化时重建」,功能等价但非逐帧。
下一批次:
增量 110+:任务日志窗体 _list 的多列 / 分页与真实 `.sub` 图标视觉、§8.8 text_tail.gd 名字颜色
ENameColor,对照基线 m2dev 完整版)、§4.2 / §4.3 剩余 tooltip / 鼠标物品态分支。
```
### C.5 批次记录 · 增量 110W3→M2 / §8.8 头顶名字色 ENameColor / GetNameColorIndex 1:1
```text
批次:增量 110 —— W3→M2 §8.8 + §5.4CInstanceBase::GetNameColorIndex / GetNameColor /
SetEmpireNameMode / GetIndexedNameColor 1:1(纯 GDScript,无 C++ 改动)
状态:✅ 完成(名字色算法 + 调色板 + net_world 接线;🔎 真服 GC_PVP / 公会战 / 决斗
下的边沿颜色待真机验收)
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,全部 ISO-8859 编码,
grep 需 -a):
UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄:
IsPC → (m_isKiller→PK) → (__IsExistMainInstance && !__IsMainInstance 时:
主角 GetDuelMode(): DUEL_CANNOTATTACK→NAMECOLOR_PC+GetEmpireID
DUEL_START→__FindDUELKey? PVP : NAMECOLOR_PC+GetEmpireID
IsSameEmpire(*this): __FindPVPKey→PVP / __FindGVGKey→PVP__FindDUELKey 分支参考端注释掉);
else(异帝国)→PVP)
→ IsPartyMemberByVID→PARTY → NAMECOLOR_PC+GetEmpireID
IsNPC→NPC / IsEnemy→MOB / IsPoly→MOB / 否则 D3DXCOLOR(0xffffffff) 当 UINT 传 → 越界 → 白
UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameModeg_isEmpireNameMode
开:MOB=EMPIRE_MOB / NPC=EMPIRE_NPC / PC=NORMAL_PC / for u∈[1,4): PC+u=EMPIRE_PC+u
关:MOB=NORMAL_MOB / NPC=NORMAL_NPC / for u∈[0,4): PC+u=NORMAL_PC
UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColoridx>=NAMECOLOR_NUM → 白)
UserInterface/InstanceBase.h:208/216 匿名枚举 EMPIRE_NONE/A/B/CEMPIRE_NUM=4+
NAMECOLOR_*(展开:MOB0 NPC1 PC2 NORMAL_MOB7 NORMAL_NPC8 NORMAL_PC9 EMPIRE_MOB14
EMPIRE_NPC15 EMPIRE_PC16 FUNC21 PK22 PVP23 PARTY24 WARP25 WAYPOINT26 NUM41
UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_MOB/NPC/PC 常量
重映射到 C++ NAMECOLOR_NORMAL_MOB/NORMAL_NPC/NORMAL_PCPK/PVP/PARTY/WARP/WAYPOINT/
EMPIRE_* 直通
assets/root/introloading.py:264 IntroLoading.__RegisterColorchrmgr.RegisterNameColor 写槽)
assets/root/colorInfo.py:11 CHR_NAME_RGB_MOB(235,22,9) NPC(122,231,93) PC(255,215,76)
PK(180,100,0) PVP(238,54,223) PARTY(128,192,255) WARP(136,218,241) WAYPOINT(255,255,255)
EMPIRE_MOB(235,22,9) EMPIRE_NPC(122,231,93) EMPIRE_PC_A(157,0,0) _B(222,160,47) _C(23,30,138)
assets/root/constinfo.py:126 CHRNAME_COLOR_INDEX 默认 0 → SET_DEFAULT_CHRNAME_COLOR →
SetEmpireNameMode(0) → 帝国名色**关**(所有 PC 名字黄)
**无 seam**40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetNameColorIndex
字节级等同 m2dev 完整版(此前 grep 漏读是因 ISO-8859 被当二进制,-a 后确认);
增量 109 批次记录里「对照基线 m2dev 完整版」的说法据此更正。
改动:
project/name_color.gd(新)—— 纯静态 `extends RefCounted`
NAMECOLOR_* 常量 + EMPIRE_NUM=4_registered_slots()introloading 注册后的 g_akD3DXClrName
实际槽,chrmgr 常量重映射已折进);_palette + _empire_name_mode 静态态;
set_empire_name_mode(bool):52 逐行,含开分支 for u∈[1,4)、关分支 for u∈[0,4));
indexed_name_color(idx):75,越界 → 白);name_color_index(self_e, main_e, ctx)
:525 逐行,复用 entity_rules._is_pc/_is_killer/_is_enemy/_is_poly/_is_same_empire/
_find_duel_key/_pair_in + DUEL_* 常量,本地补 _is_npc/_empire_id);name_color()=GetNameColor。
ctx 与 net_play._attack_ctx 同构(main_vid/duel_mode/duel_opponents/pvp_pairs/gvg_pairs/party_vids)。
偏离:__FindGVGKey 加 guild!=0 守卫(参考端 guild 0 的 PVPKEY 恒 0、集合里不会有;
与 entity_rules._is_pvp_instance 既有防御一致)。
project/net_world.gd —— const NameColor / EntityRulesvar kind_resolverfunc(dict)->int
game_scene 注入 net_play._entity_kind,补本 fork「NPC/怪 bType 都 0」的分类缺口,未注入时退化 ch_type);
_name_color_ctx()(自 client.get_duel/get_pvp_relations/get_guild_wars/get_party 拼 ctx)、
_main_name_snapshot()、_apply_name_color(vid)(写头顶 "Label3D".modulate)、_retint_all_names()、
set_empire_name_mode(bool) 透传;单发刷新点:_on_spawn / _on_info / _on_pvp_changedsrc+dst/
_on_duel_changed(全表)/ _on_main_set(全表);_process 里 ~2Hzaccum≥0.5s)整表兜底
(参考端 RenderName 每帧都算 GetNameColor)。删掉 _apply_anim / _on_motion 里
占位胶囊借 "Label3D".modulate 表现动作态的 _state_tint(该 modulate 现专属名字色),
_state_tint 函数一并删除。
project/game_scene.gd —— name_resolver 注入旁边加 `net_world.kind_resolver = net_play._entity_kind`。
project/name_color_test.gd(新)—— SceneTree 回归,约 55 断言:NAMECOLOR_* 序号、
帝国名色关/开两态、GetNameColorIndex 每分支(killer→PK、party→PARTY、异帝国→PVP、
victim empire 0 视同帝国、pvp/gvg 对表→PVP、决斗 CANNOTATTACK/START 两态、无主角实例、
自己、NPC/MOB/poly/未知)、killer 压过 party、GetIndexedNameColor 越界→白、
每个 ENameColor 槽解析出的 colorInfo RGB。
测试:
/opt/homebrew/bin/godot --headless --path project --script name_color_test.gd —— PASS。
回归全 PASSgamescene_test / netbridge_test / mob_view_test / net_world_vis_test / netplay_test /
entity_rules_test / quest_test / quest_event_test / input_key_test / p8_test / p9_test / p10_test /
remote_player_test / player_motion_test / combat_fx_test / skill_fx_test / player_move_test /
player_skill_test / app_flow_lifecycle_test / fly_test / channel_status_test。
验收:
已验证名字色算法逐分支、SetEmpireNameMode 调色板重写、越界回退、colorInfo RGB 映射(离线)。
未验证:真服 GC_PVP / GUILD_GC_WAR / 决斗包驱动下的实时颜色切换、帝国名色开关的选项 UI
(目前只有 net_world.set_empire_name_mode 运行期钩子,未挂 uisystemoption)、真模型(GRN
头顶 Label3D 与占位胶囊路径的一致性需真机目视。
风险与依赖:
net_world 无 proto,实体分类靠注入的 kind_resolver=net_play._entity_kind;未注入(裸 net_world
测试 / headless 无 assets)时退化到 ch_typem2dev 后端 NPC/怪 bType=0 会被当 PC(同 §5.4 既有 seam ⑥)。
__FindPVPKey / __FindGVGKey 的 key 集合 setter 仍是 §5.4 seam ①②(get_pvp_relations /
get_guild_wars 有值则消费)。名字色整表刷新 ~2Hz(非逐帧),队伍 / 关系变化最坏 0.5s 延迟。
静态 _palette / _empire_name_mode 是进程级单例(与参考端 g_akD3DXClrName / g_isEmpireNameMode 一致)。
下一批次:
§8.8 text_tail.gd(称号色 GetTitleColor / 公会名 / 屏幕空间血条 / 聊天气泡整合、
LALT + §8.4 三 radio 显示开关)、目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动、
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 111(W3→M2 / §8.8 头顶「公会名 / 称号 / 等级」子标签 1:1)
```text
批次:增量 111 —— W3→M2 §8.8CInstanceBase::GetTitleColor / RefreshTextTail /
AttachTextTail / UpdateTextTailLevel + CPythonTextTail 的公会名 / 称号 / 等级子标签、
EnablePKTitle 1:1(纯 GDScript,无 C++ 改动)
状态:✅ 完成(称号色算法 + 等级文本 + 公会名兜底 + net_world 三子标签接线 + game_scene
title_name_resolver 注入;🔎 真服 alignment / GC_GUILD 变化下的实时刷新、真模型头顶
排版目视待真机验收)
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/*.cpp
全部 ISO-8859 编码,grep 需 -a):
UserInterface/InstanceBaseEffect.cpp:609 GetTitleColor —— uGrade = GetAlignmentGrade();
if (uGrade >= TITLE_NUM(9)) return s_kD3DXClrTitleDefault(白); return g_akD3DXClrTitle[uGrade];
UserInterface/InstanceBaseEffect.cpp:664 RefreshTextTail —— SetCharacterTextTailColor(GetNameColor());
iAlignmentGrade = GetAlignmentGrade();
if (TITLE_NONE(4) == iAlignmentGrade) DetachTitle();
else if (g_TitleNameMap.find(grade) 命中) AttachTitle(name, GetTitleColor());
UserInterface/InstanceBaseEffect.cpp:684 RefreshTextTailTitle → RefreshTextTail
UserInterface/InstanceBaseEffect.cpp:621 AttachTextTail —— fTextTailHeight = IsMountingHorse()?110:10;
RegisterCharacterTextTail(m_dwGuildID, dwVID, 白, fTextTailHeight);
if (m_dwLevel) UpdateTextTailLevel(m_dwLevel);
UserInterface/InstanceBaseEffect.cpp:654 UpdateTextTailLevel —— s_kLevelColor =
D3DXCOLOR(152/255, 255/255, 51/255, 1.0); sprintf(szText, "Lv %d", level);
AttachLevel(vid, szText, s_kLevelColor);
UserInterface/InstanceBaseEffect.cpp:1107 RegisterTitleName —— g_TitleNameMap[iIndex] = c_szTitleName
UserInterface/InstanceBaseEffect.cpp:1131 RegisterTitleColor —— g_akD3DXClrTitle[u] =
__RGBToD3DXColoru(r,g,b)= D3DXCOLOR(r/255, g/255, b/255)
UserInterface/InstanceBase.h:262/263 TITLE_NUM = 9 / TITLE_NONE = 4
UserInterface/InstanceBase.cpp:2053 SetAlignment(sAlignment) → RefreshTextTailTitle()
UserInterface/InstanceBase.cpp:2079 SetPKMode 末尾 → RefreshTextTail()
UserInterface/PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FFARGB → RGB 239,211,255
UserInterface/PythonTextTail.cpp:23/938 bPKTitleEnable = TRUE / EnablePKTitle(bFlag)
UserInterface/PythonTextTail.cpp:509/543 RegisterCharacterTextTail —— dwGuildID != 0 才
New() 公会名 CGraphicTextInstanceif (!CPythonGuild::GetGuildName(dwGuildID, &str)) str = "Noname";
SetColor(c_TextTail_Guild_Name_Color)
UserInterface/PythonTextTail.cpp:890/943 AttachTitle / AttachLevel —— 均 if (!bPKTitleEnable) return;
AttachTitle EU 右对齐 / 否则居中;AttachLevel 恒右对齐
assets/root/introloading.py:260 __RegisterTitleName —— for i: chrmgr.RegisterTitleName(i, TITLE_NAME_LIST[i])
assets/root/introloading.py:287 __RegisterColor 的 TITLE_COLOR_DICT(注册序 0..8):
GOOD_4 GOOD_3 GOOD_2 GOOD_1 NORMAL EVIL_1 EVIL_2 EVIL_3 EVIL_4
assets/root/colorInfo.py:29 TITLE_RGB_GOOD_4(0,204,255) _3(0,144,255) _2(92,110,255) _1(155,155,255)
NORMAL(255,255,255) EVIL_1(207,117,0) _2(235,83,0) _3(227,0,0) _4(255,0,0)
assets/root/localeInfo.py:215 TITLE_NAME_LIST = (PVP_LEVEL0 .. PVP_LEVEL8,)
en locale_game.txt:441-449 Chivalric/Noble/Good/Friendly/Neutral/Aggressive/Fraudulent/Malicious/Cruel
assets/root/constinfo.py:4 + game.py:155 PVPMODE_ENABLE = 1textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE)
→ 起始即启用(本移植 _pk_title_enabled 默认 true
**无 seam**40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetTitleColor /
RefreshTextTail / UpdateTextTailLevel / RegisterTitleName / RegisterTitleColor
:609/:664/:654/:1107/:1131),字节级等同 m2dev 完整版。
改动:
project/text_tail.gd(新)—— 纯静态 `extends RefCounted`
TITLE_NUM=9 / TITLE_NONE=4_title_colors()g_akD3DXClrTitle 的 9 槽,Color8=D3DXCOLOR(x/255));
LEVEL_COLOR = Color(152/255,255/255,51/255)GUILD_NAME_COLOR = Color(239/255,211/255,255/255)
GUILD_NAME_FALLBACK = "Noname"_pk_title_enabled 静态态 + set/is_pk_title_enabledEnablePKTitle);
title_color(grade):609grade<0||>=TITLE_NUM → 白);title_name_key(grade)="PVP_LEVEL%d"
level_text(level)="Lv %d"text_tail_height(mounted)=110|10
refresh_title(alignment) → {show_title, grade, name_key, color}(复用 entity_rules.alignment_grade
grade==TITLE_NONE 或 !pk_title 时 show_title=false);
refresh_level(level) → {show_level=(pk_title && level>0), text, color}
refresh_guild(guild_id, resolved_name) → guild_id==0 不显示 / 否则 name 空串兜底 "Noname"。
偏离:EnablePKTitle(FALSE) 时参考端 Attach/Detach 均早退=「冻结上一态」,本移植 refresh_* 返回
show=false=「隐藏」(seam ⑦ 之外的小偏离,已在 §8.8 修改 2 与代码注释标注)。
project/net_world.gd —— const TextTailvar title_name_resolverfunc(grade)->String
game_scene 注入 Locale.t("PVP_LEVEL%d");未注入回退裸 keyseam ⑧);
_attach_nameplate 加 GuildTag(y=2.34)/TitleTag(y=1.86)/LevelTag(y=1.74) 三个 Label3D 子节点
billboard + no_depth_test + pixel_size 0.005,初始 text="" visible=false),经 _mk_sub_tag() 造;
_set_name_visibility 同步开关三子标签(visible = _names_visible && text != "");
_resolve_title_name / _resolve_guild_name(后者走 client.get_guild_name(gid));
_apply_text_tail_to(n, e) / _apply_text_tail(vid) / _retint_all_text_tails()(姊妹于 _apply_name_color 系);
刷新点:_on_spawn / _on_info 单发 + _process ~2Hz(与名字色同一 accum≥0.5s)整表兜底;
set_pk_title_enabled(bool) 透传 TextTail + 整表重刷。
project/game_scene.gd —— kind_resolver 注入旁加 title_name_resolver:新建 Locale("en")
lambda 查 PVP_LEVEL%dhas() 命中才返回,否则空串让 net_world 走 seam ⑧ 回退)。
project/text_tail_test.gd(新,第 22 个 canonical)—— SceneTree 回归,约 50 断言:
常量(TITLE_NUM/NONE、LEVEL/GUILD 色、"Noname")、title_color 9 槽 + 越界→白、
title_name_key / level_text / text_tail_height 字面、refresh_titleTITLE_NONE 不显示、
grade 0/8 显示 + 色 + name_key、grade 3/5 显示)、refresh_level"Lv 42" + 色、level 0 无)、
refresh_guildid 0 无、解出名、空串→"Noname")、EnablePKTitle(FALSE) 压制称号 + 等级但不压公会名 +
还原、grade 与 entity_rules.alignment_grade 9 组一致。
测试:
/opt/homebrew/bin/godot --headless --path project --script text_tail_test.gd —— PASS。
回归:project/*_test.gd 全 62 个全 PASS(含 name_color_test / entity_rules_test / gamescene_test /
netbridge_test / mob_view_test / net_world_vis_test / netplay_test / quest_test / input_key_test /
p8_test / p9_test / p10_test / remote_player_test / player_motion_test / combat_fx_test / …)。
验收:
已验证(离线):GetTitleColor 9 grade + 越界、RefreshTextTail 的 TITLE_NONE / 越界分支、
UpdateTextTailLevel 的 "Lv %d" + s_kLevelColor、RegisterCharacterTextTail 的 guild!=0 / "Noname"、
AttachTextTail 的 110/10、EnablePKTitle 开关、grade 与 alignment_grade 一致。
未验证:真服 SetAlignment / SetPKMode / GC_GUILD 驱动下的实时称号切换、真模型(GRN)头顶
GuildTag/TitleTag/LevelTag 与占位胶囊路径的排版一致性(需真机目视)、EU 右对齐 vs 居中的
水平对齐、g_TitleNameMap 未注册某 grade 时的 AttachTitle 跳过(本移植 0..8 全有 key)。
风险与依赖:
seam ⑦:billboard 3D 里把公会名(上)/ 称号·等级(下)竖排,参考端是屏幕空间像素排版
(公会名在名字上 c_fyGuildNamePosition=15px、称号 / 等级贴右侧),视觉近似非逐像素等同。
seam ⑧:title_name_resolver 未注入(裸 net_world 测试 / headless 无 assets)时称号显示裸
"PVP_LEVEL%d" key —— 生产环境 game_scene 恒注入 Locale。
公会名走 client.get_guild_name(gid)= world->guild_name),空串即视作 GetGuildName 失败 → "Noname"。
_pk_title_enabled 是进程级静态单例(与参考端 bPKTitleEnable 全局一致);未接选项 UI,
仅 net_world.set_pk_title_enabled() 运行期钩子。
称号 / 等级 / 公会名整表刷新 ~2Hz(复用名字色 accum),alignment / guild 变化最坏 0.5s 延迟。
下一批次:
§8.8 剩余:text_tail.gd 伤害数字(AttachInfoTail / AttachDamage+ 聊天气泡(AttachChat+
屏幕空间血条整合、LALT(§8.6)+ §8.4 三 radio 的头顶信息显示开关;
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 112W3→M2 / §8.8 头顶「聊天 / 信息」尾标 1:1)
```text
批次:增量 112 —— W3→M2 §8.8CPythonTextTail::RegisterChatTail / RegisterInfoTail +
TextTail_GetLivingTime / SetLivingTime + IsChatTextTail + RecvChatPacket 的头顶尾标派发
"名字: " 前缀剥离 / SHOUT·COMMAND 排除)1:1(纯 GDScript,无 C++ 改动)
状态:✅ 完成(chat_tail.gd 纯静态算法 + net_world "bubble" 子节点接线 + chat 派发规则对齐 +
game_scene._player_bubble 寿命对齐;🔎 真服多类型聊天并发下的气泡替换 / 到点硬删、真模型
头顶气泡与名字 Label3D 排布目视待真机验收)
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/*.cpp
全部 ISO-8859 编码,grep 需 -a):
UserInterface/PythonTextTail.cpp:16/17 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1)(白)/
c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1)(淡红)
UserInterface/PythonTextTail.cpp:26/28/34 gs_TextTail_LivingTime = 5000
TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000)TextTail_SetLivingTime(livingTime)
UserInterface/PythonTextTail.cpp:581 RegisterChatTail(vid, szChat) —— m_ChatTailMap.find(vid) 命中:
pTextInstance->SetValue(szChat); Color = c_TextTail_Chat_Color; LivingTime = now + GetLivingTime();
bNameFlag = TRUE; return; —— 否则 RegisterTextTail(vid, szChat, owner, height + 10.0f, Chat色),
LivingTime = now + GetLivingTime(); bNameFlag = TRUE; SetOutline(true); VERTICAL_ALIGN_BOTTOM;
m_ChatTailMap.insert(vid, pTextTail)
UserInterface/PythonTextTail.cpp:624 RegisterInfoTail(vid, szChat) —— 同上但 Color = Info色、
bNameFlag = FALSE;与 RegisterChatTail 共用 m_ChatTailMap(同 vid 互相覆盖)
UserInterface/PythonTextTail.cpp:328 UpdateAllTextTail —— for m_ChatTailMap: if (LivingTime < now)
{ DeleteTextTail; erase; }(硬删,无淡出)
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail —— chat 尾标 if (bNameFlag) ShowCharacterTextTail(vid)
UserInterface/PythonTextTail.cpp:105 UpdateShowingTextTail —— if (chatTail && chatTail->bNameFlag)
名字尾标 y = chatTail.y - 17.0f(气泡在上、名字下移)
UserInterface/PythonTextTail.cpp:684 IsChatTextTail(vid) —— m_ChatTailMap.find(vid) != end
UserInterface/PythonNetworkStreamPhaseGame.cpp:1301 CHAT_TYPE_COMMAND → ServerCommand(); return;(不进尾标)
UserInterface/PythonNetworkStreamPhaseGame.cpp:1321 TALKING/PARTY/GUILD/SHOUT/WHISPER
char * p = strchr(buf, ':'); if (p) p += 2; else p = buf; ... _snprintf(line, ..., "%s", p);
UserInterface/PythonNetworkStreamPhaseGame.cpp:1363 INFO/NOTICE/BIG_NOTICE/defaultline = buf(原样)
UserInterface/PythonNetworkStreamPhaseGame.cpp:1372 if (CHAT_TYPE_SHOUT != kChat.type)
CPythonTextTail::Instance().RegisterChatTail(kChat.dwVID, line);(带 VID 且实例可解析时)
UserInterface/PythonTextTailModule.cpp:76/91 textTailRegisterChatTail / textTailRegisterInfoTail
python 绑定;InfoTail 仅 assets/root/game.py:930/936 技能 / 射击错误显式调)
UserInterface/Packet.h:1410 enum EChatType { TALKING=0, INFO, NOTICE, PARTY, GUILD, COMMAND,
SHOUT, WHISPER, BIG_NOTICE, MAX_NUM }
assets/root/game.py:155 textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE)(与增量 111 同处)
**无 seam**40250 sole baseline 的 PythonTextTail.cpp / PythonNetworkStreamPhaseGame.cpp 本身含
RegisterChatTail / RegisterInfoTail / RecvChatPacket 全部逻辑,字节级等同 m2dev 完整版。
改动:
project/chat_tail.gd(新)—— 纯静态 `extends RefCounted`
CHAT_COLOR = Color(1,1,1) / INFO_COLOR = Color(1, .785, .785)LIVING_TIME_DEFAULT = 5000 /
LIVING_TIME_MIN = 1000 / HEIGHT_ADD = 10.0CT_TALKING..CT_BIG_NOTICE = 0..8EChatType);
_living_time 静态态 + set_living_time(ms)(不断言,对齐参考端断言在 getter/ living_time()
assert(_living_time > LIVING_TIME_MIN));
strip_name_prefix(raw) = raw.find(":") 命中则 substr(i+2) 否则整串(strchr ? p+2 : buf);
line_for_tail(type, raw) —— TALKING/PARTY/GUILD/SHOUT/WHISPER 走 strip,其余原样;
wants_chat_tail(type) = type != CT_SHOUT && type != CT_COMMAND
register_chat(tails, vid, text, now) / register_info(...) —— 共用 _register(),写
tails[vid] = {text, color, name_flag, living_time = now + living_time()},返回该条;
is_chat_text_tail(tails, vid) = tails.has(vid)
expire(tails, now) —— 收集 living_time < now 的 vid,从 tails 删除并返回列表(UpdateAllTextTail)。
project/net_world.gd —— const ChatTailvar _chat_tailsvid -> 条目,= m_ChatTailMap);
_bubble(vid, text) 改走 ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec())
+ _apply_chat_tail(n, tail)(本地 vid 仍 main_bubble.emit);
新增 register_info_tail(vid, text) 公开方法(= RegisterInfoTail,走 register_info,供技能 /
射击错误尾标调用);
_apply_chat_tail(n, tail) —— 复用 / 新建名为 "bubble" 的 Label3Dy=2.35, billboard,
no_depth_test, pixel_size 0.0055, outline_size 6),text = tail.text、modulate = tail.color
_expire_chat_tails() —— ChatTail.expire 后把到点 vid 的 "bubble" 节点 queue_free
接入 _process 的 accum ≥ 0.5s 块(与名字色 / 称号刷新同拍,到点最坏 0.5s 延迟);
_on_despawn 加 _chat_tails.erase(vid)
chat 信号 lambda`t in [0,3,4,6]` → `int(v) != 0 and ChatTail.wants_chat_tail(int(t))`
气泡文本改为 ChatTail.line_for_tail(int(t), s)(去 "名字: " 前缀)。
行为变化:SHOUT(原 t==6 会弹气泡)不再弹头顶气泡(对齐 :1372 显式排除);WHISPER / INFO /
NOTICE 带 VID 时现在会弹(此前被 [0,3,4,6] 过滤掉)。
project/game_scene.gd —— _player_bubble 的 tween_interval(3.0) → tween_interval(4.0)
+ 1.0s 淡出 = 5s,对齐 gs_TextTail_LivingTime;参考端到点硬删无淡出,本地气泡保留淡出 = 近似)。
project/chat_tail_test.gd(新,第 23 个 canonical)—— SceneTree 回归,约 45 断言:
常量(Chat/Info 色、5000/1000/10、EChatType 0..8)、living_time / set_living_time 往返 + 还原、
strip_name_prefix"Alice: x"→"x" / 无冒号原样 / 只吃首个冒号 / "Bob: "→"")、
line_for_tailTALKING/PARTY/GUILD/WHISPER/SHOUT strip、INFO/NOTICE/BIG_NOTICE 原样)、
wants_chat_tail(除 SHOUT/COMMAND 全 true)、register_chat(新建 + 同 vid 原地替换 + bNameFlag
TRUE + LivingTime = now+5000)、register_info(淡红 + bNameFlag FALSE + 覆盖同 vid chat 条)、
is_chat_text_tail、expire(到点返回 + 删除、未到点保留、空表返回 [])。
测试:
/opt/homebrew/bin/godot --headless --path project --script chat_tail_test.gd —— PASS。
回归:project/*_test.gd 全 63 个全 PASS(含 chat_tail_test / text_tail_test / name_color_test /
entity_rules_test / gamescene_test / netbridge_test / netplay_test / net_world_vis_test /
quest_test / input_key_test / p8_test / p9_test / p10_test / remote_player_test / …)。
验收:
已验证(离线):Chat/Info 色、gs_TextTail_LivingTime 默认 5000 + assert 下界、
strip_name_prefix 的 strchr ? p+2 : buf、line_for_tail 逐类型、wants_chat_tail 的
SHOUT/COMMAND 排除、RegisterChatTail 新建 / 原地替换 / bNameFlag / LivingTime、
RegisterInfoTail 淡红 + bNameFlag FALSE + 与 chat 共表覆盖、IsChatTextTail、expire 硬删。
未验证:真服 GC_CHATTALKING/PARTY/GUILD/ GC_WHISPER 驱动下的气泡出现 / 替换 / 5s 到点消失、
真模型(GRN)头顶 "bubble" Label3D 与名字 / 称号 / 等级子标签的排布一致性(需真机目视)、
TextTail_SetLivingTime 的 3s 长喊话(服务端可调 gs_TextTail_LivingTime)、
RegisterInfoTail 的实际调用方(技能 / 射击错误路径尚未接 register_info_tail())。
风险与依赖:
seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag
决定名字是否强制 ShowCharacterTextTail);本移植是 net_world 里名字 / 称号 / 等级 / "bubble"
各自 billboard Label3D 并存 —— 名字不强制顶出、不随气泡下移,name_flag 只存不用。
_chat_tails 到点删除靠 _process 的 accum ≥ 0.5s 块(非逐帧),气泡最多多留 ~0.5s。
本地玩家 vid 仍走 main_bubble → game_scene._player_bubble(独立 tween,非 _chat_tails 管理);
远端 vid 无对应节点(未 spawn / 已 despawn)时 _bubble / register_info_tail 静默 return。
_living_time 是进程级静态单例(与参考端 gs_TextTail_LivingTime 全局一致);未接
TextTail_SetLivingTime 的运行期钩子(默认 5000)。
SHOUT 不再弹头顶气泡是对齐参考的行为变化(喊话仍进聊天窗,见 §8.2)。
下一批次:
§8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立于 text tail 的
effect 系统)+ 屏幕空间血条整合、TextTail_SetLivingTime 运行期钩子、
chat 尾标 bNameFlag 驱动名字强制显示 / 下移 17px(seam ⑨ 收口);
或 §8.4 三 radio(名字颜色 / 伤害数字 / 总是显示名字)+ §8.6 LALT 接到 text_tail / chat_tail 显示开关;
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 113W3→M2 / §8.4 · §8.6 头顶名字「是否显示」1:1)
```text
批次:增量 113 —— W3→M2 §8.4 / §8.6CPythonSystem::IsAlwaysShowName /
SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的
3500 平面距裁剪 + ShowCharacterTextTail 资格判定 + UpdateAllTextTail 的 bNameFlag 强显
(纯 GDScript,无 C++ 改动)
状态:✅ 完成(name_show.gd 纯静态谓词 + net_world 头顶名字 / 子标签接线:__IsShowName
两路(总显示名字选项 + LALT)、target VID / chat bNameFlag 强显、3500 距离裁剪;
🔎 真机目视:3500 边界名字进出、悬停 chr.Pick() 强显(未接,seam ⑩)、屏幕空间
ArrangeTextTail 防重叠(未做,seam ⑩))
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/*.cpp
ISO-8859 编码,grep 需 -a):
UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true
UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME
UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName
UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1)
UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1
UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘)
UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName
root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True)
+ RefreshAlwaysShowName():224 OffButton -> SetAlwaysShowNameFlag(False)
root/game.py:323 onPressKeyDict[app.DIK_LALT] = lambda: self.ShowName()
root/game.py:406 onClickKeyDict[app.DIK_LALT] = lambda: self.HideName()
root/game.py:1143 ShowName() -> self.ShowNameFlag = True; playerGauge.EnableShowAlways(); SetQuickPage(+1)
root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False
root/game.py:1533 OnRenderidx = chr.Pick(); if -1 != idx: textTail.ShowCharacterTextTail(idx)
root/game.py:1535 OnRenderif 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(vid)
root/game.py:1550 OnRenderif __IsShowName(): textTail.ShowAllTextTail(); PickingItemIndex = textTail.Pick(x, y)
UserInterface/PythonTextTail.cpp:416 UpdateDistancefDistanceFromPlayer =
D3DXVec2Length(ownerX - centerX, -ownerY - centerY)
UserInterface/PythonTextTail.cpp:422 ShowAllTextTailfor m_CharacterTextTailMap:
if (fDistanceFromPlayer < 3500.0f) ShowCharacterTextTail(vid)item 尾标同阈)
UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在 map / 已在 m_CharacterTextTailList /
!pOwner->isShow() / !GetInstancePtr / IsGuildWall() -> returnCanPickInstance() 才 push_back
UserInterface/PythonTextTail.cpp:408 HideAllTextTailm_CharacterTextTailList.clear();(每帧末清列,下帧重建)
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTailfor m_ChatTailMap: if (bNameFlag)
ShowCharacterTextTail(vid)(不经 3500 裁剪)
seam ⑩:参考「名字显示」是每帧重建 m_CharacterTextTailListPick / target / ShowAll)再
Render / ArrangeTextTail(屏幕空间防重叠)/ HideAll 的流程;本移植是 net_world 每实体一块
billboard Label3D,靠 name_show 谓词逐帧 set .visible。① 鼠标悬停 chr.Pick() 强显未接
net_world 无悬停拾取)② ArrangeTextTail 防重叠未做 ③ CanPickInstance() 只按「死透」
近似(隐身 / 特定 affect 亦不可拾取)④ UpdateDistance 的 -ownerY 是渲染帧 / 像素帧混用
产物,本移植 pos_cm 单帧等价同帧平面距。
改动:
project/name_show.gd(新)—— 纯静态 `extends RefCounted`
ALWAYS_SHOW_NAME_DEFAULT = trueDEFAULT_VALUE_ALWAYS_SHOW_NAME/ SHOW_ALL_DISTANCE = 3500.0 /
CFG_KEY = "ALWAYS_VIEW_NAME"
always_show_from_flag(flag) = flag == 1SetAlwaysShowNameFlag);
always_show_from_cfg(value) = int(value) == 1(配置行 atoi == 1);
is_show_name(always, alt_held) = always or alt_held__IsShowName);
within_show_all(dist) = dist < 3500.0ShowAllTextTail);
planar_distance_cm(a, b) = a.distance_to(b)UpdateDistance,同帧);
eligible_character(is_shown, is_guild_wall, can_pick) = is_shown && !is_guild_wall && can_pick
ShowCharacterTextTail 早退取反);
name_visible_for(forced, show_name, dist, is_shown, is_guild_wall, can_pick) ——
先过 eligible_characterforcedPick / target / bNameFlag)绕过 3500 直接 true
否则需 show_name && within_show_all(dist)。
project/net_world.gd —— const NameShow
var _names_visible(true) 拆成 _always_show_name(= ALWAYS_SHOW_NAME_DEFAULT) + _alt_held(false)
+ _target_vid(0)
set_names_visible(v)LALT 入口,increment 53)改为写 _alt_held + _refresh_all_name_visibility()
names_visible() 返回 _alt_held
新增 set_always_show_name(on)uigameoption radio/ set_target_vid(vid)targetBoard);
_main_planar_cm() —— 主角 pos_cm 的 (x,y),未知返回 (INF,INF)
_name_shown(root) —— 取 root meta vid → client.get_entity → dead / race,组装
forcedvid == _target_vid 或 _chat_tails[vid].name_flag+ show_nameis_show_name(
_always_show_name, _alt_held)+ dist(主角已知则 planar_distance_cm,否则 0 fail-open
→ NameShow.name_visible_for
_set_name_visibility(root) 改为 shown = _name_shown(root),主名字 Label3D 与
GuildTag/TitleTag/LevelTag 子标签统一 shown(子标签仍 && text != "");
_apply_text_tail_to 的三处子标签 visible 由 _names_visible 改 _name_shown(n)
_attach_nameplate 的 tag.visible = _names_visible 改 = false_on_spawn 随后 _set_name_visibility);
_apply_chat_tail 末 + _set_name_visibility(n)bNameFlag 强显即时生效);
_expire_chat_tails 删 "bubble" 后 + _set_name_visibility(n)(强显撤销);
_process 的 accum ≥ 0.5s 块 + _refresh_all_name_visibility()(距离随移动变化的 ~2Hz 兜底)。
project/ui/game_option_ui.gd —— 新增 signal display_option_changed(key, value)radio 选中
回调在 _save() 后 emit(spec[0], spec[1]);新增 display_value(key) 供渲染层开局回填。
project/game_scene.gd —— net_play.setup 后接 net_play.target_changed → net_world.set_target_vid
game_option_ui.setup 后接 display_option_changed → _on_display_optionkey=="always_show_name"
→ net_world.set_always_show_name(value == 1)),并用 display_value("always_show_name") 开局回填。
project/name_show_test.gd(新,第 24 个 canonical)—— SceneTree 回归,约 35 断言:
常量(默认 true / 3500 / "ALWAYS_VIEW_NAME")、always_show_from_flag/from_cfg(严格 == 1)、
is_show_name 真值表、within_show_all3499.9 T / 3500.0 F 严格 <)、planar_distance_cm
3-4-5 / 同点 0 / x 轴)、eligible_characterisShow / IsGuildWall / CanPickInstance 各早退)、
name_visible_for(公会墙即便 forced 也 F、forced + 合格 + 超距 T、forced 但不合格 F、
非 forced 需 __IsShowName + <3500 + 合格)。
测试:
/opt/homebrew/bin/godot --headless --path project --script name_show_test.gd —— PASS。
回归:project/*_test.gd 全 64 个全 PASS(含 name_show_test / chat_tail_test / text_tail_test /
name_color_test / entity_rules_test / gamescene_test / netplay_test / net_world_vis_test /
input_key_test / quest_test / p8_test / p9_test / p10_test / remote_player_test / …)。
验收:
已验证(离线):DEFAULT_VALUE_ALWAYS_SHOW_NAME = true、SetAlwaysShowNameFlag 的 == 1 语义、
"ALWAYS_VIEW_NAME" 配置行 atoi == 1、__IsShowName 的 or、ShowAllTextTail 的
fDistanceFromPlayer < 3500 严格小于、ShowCharacterTextTail 的 isShow / IsGuildWall /
CanPickInstance 早退、forced 绕过 3500 但仍过资格、UpdateDistance 平面距、
game_option_ui radio → 信号 → net_world.set_always_show_name、target_changed →
set_target_vid、全 64 回归。
未验证:真机 —— 3500cm 边界处名字随主角 / 目标移动的进出、总显示名字选项关闭后仅
目标 / bNameFlag 尾标 / LALT 期间显示、真模型(GRN)头顶名字 / 子标签 billboard 与
参考屏幕空间排布的目视一致性、SetQuickPage(+1) / playerGauge.EnableShowAlways()
(ShowName 的另两步,属快捷栏 / 血条 UI,未接)。
风险与依赖:
seam ⑩(见上「参考」末):悬停 chr.Pick() 强显未接、ArrangeTextTail 防重叠未做、
CanPickInstance() 近似为「非死透」、UpdateDistance 的 -ownerY 帧差。
行为变化:此前 _names_visible 默认 true = 所有已 spawn 实体名字恒显;现默认
_always_show_name = true(对齐参考默认)但叠加 3500 平面距裁剪 —— 20000cm 视野内、
3500cm 外的实体现在有节点但无名字(对齐 ShowAllTextTail)。主角位置未知时 fail-open
(同 _is_visible_entity),离线 / FakeClient 测试名字仍恒显。
距离裁剪走 _process 的 accum ≥ 0.5s 块(非逐帧),3500 边界名字进出最坏 ~0.5s 延迟;
LALT / 选项 / 选中 / 尾标切换各自即时 _refresh / _set_name_visibility。
_always_show_name 由 game_option_ui 持久化到 user://system_option.cfg [gameopt]
always_show_name(默认 1);参考端 PythonSystem 仅在 != 默认值时才写 ALWAYS_VIEW_NAME 行,
本移植 ConfigFile 恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径)。
net_play.target_changed 已有(增量早期),net_world 此前不知道目标 VID;新增的
set_target_vid 仅驱动名字强显,不改选中 / 攻击逻辑。
下一批次:
§8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立 effect 系统)+
屏幕空间血条整合;chat 尾标 bNameFlag 下移名字 17pxseam ⑨ 收口);
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取已有 _ray_pick_t,接名字强显)+
ArrangeTextTail 屏幕空间防重叠;
或 §8.4 名字色 radioname_color_normal / name_color_empire)接到 name_color.gd 的
帝国名色模式 + 伤害数字 radioshow_damage)接到 text_tail 伤害数字显隐;
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 114W3→M2 / §8.8 · §5.4 头顶伤害飘字 AddDamageEffect / ProcessDamage 1:1
```text
批次:增量 114 —— W3→M2 §8.8 / §5.4CInstanceBase::AddDamageEffect + ProcessDamage 的分类
DODGE|BLOCK → MISS、self → "damage_"、nontarget → 不渲染、target → "target_"+ 数字逐位
拆分 + CPythonSystem::IsShowDamage / SetShowDamageFlag 门(纯 GDScript,无 C++ 改动)
状态:✅ 完成(damage_effect.gd 纯静态谓词 + net_world._on_damage 改为 AddDamageEffect 语义
入队 / _process_damage_queue 每帧 pop 一条 ProcessDamageuigameoption「伤害数字」radio
经 game_option_ui.display_option_changed → net_world.set_show_damage
🔎 真机目视:MISS / 数字飘字位置与升起、nontarget 不显、self 交替、IsShowDamage 关时无飘字。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/*.cpp
ISO-8859 编码,grep 需 -a):
UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2,
BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 }
UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; }
UserInterface/PythonNetworkStream.cpp:178 Set(HEADER_GC_DAMAGE_INFO, STATIC_SIZE_PACKET)
UserInterface/PythonNetworkStreamPhaseGame.cpp:396 case HEADER_GC_DAMAGE_INFO -> RecvDamageInfoPacket()
UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket
pInstTarget = GetInstancePtr(dwVID)bSelf = (pInstTarget == GetMainInstancePtr())
bTarget = (pInstTarget == m_pInstTarget)if (damage >= 0) AddDamageEffect(damage, flag, bSelf, bTarget)
else TraceError("Damage is equal or below 0.")
UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffectif (CPythonSystem::IsShowDamage())
{ m_DamageQueue.push_back({bSelf, bTarget, damage, flag}); }
UserInterface/InstanceBase.cpp:1910 ProcessDamage()(每帧 Update 调一次)
UserInterface/InstanceBaseEffect.cpp:100 ProcessDamageif (m_DamageQueue.empty()) return;
sDamage = m_DamageQueue.front(); m_DamageQueue.pop_front();(每帧仅一条)
(flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS; return;
(flag & CRITICAL) -> CreateEffect 被注释掉(该 build 无独立暴击视觉),落普通数字
POISON 分支整段注释掉
bSelf -> strDamageType="damage_"m_bDamageEffectType==0 ? EFFECT_DAMAGE_SELFDAMAGE :
EFFECT_DAMAGE_SELFDAMAGE2m_bDamageEffectType = !m_bDamageEffectType
bTarget==false -> strDamageType="nontarget_"EFFECT_DAMAGE_NOT_TARGETreturn;(不渲染)
else -> strDamageType="target_"EFFECT_DAMAGE_TARGET
while (damage > 0) { if (index > 7) { TraceError; break; } num = damage % 10; damage /= 10;
textures.push_back("d:/ymir work/effect/affect/damagevalue/"+strDamageType+num+".dds");
matTrans._41 = -(FONT_WIDTH(30) * index)(相机右方向逐位铺开); CreateEffect(...); index++; }
UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认)
UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage
UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1)
UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1
UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘)
UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage
seam ⑪:参考端每位数字是一张 damagevalue/<type><digit>.dds 贴图的 CEffectManager 特效,按
FONT_WIDTH*index 在相机右方向铺开;本移植 net_world 用一块 billboard Label3D 写 str(amount)
(无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。self 的
SELFDAMAGE/SELFDAMAGE2 交替退化成落字水平抖动符号交替(无双特效资源)。暴击在该 40250 build
参考端本就无独立视觉,本移植也不再放大 / 变色(相较增量 ≤112 的占位实现是回归对齐)。
另:参考 m_DamageQueue 是「每实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是
net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击会分帧显示。
改动:
project/damage_effect.gd(新)—— 纯静态 `extends RefCounted`
DAMAGE_NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL = 1<<0..1<<5DamageFlag);
SHOW_DAMAGE_DEFAULT = true / CFG_KEY = "SHOW_DAMAGE" / FONT_WIDTH = 30 / MAX_DIGITS = 8
enum Kind { MISS, DIGITS }
should_queue(show_damage) = show_damageAddDamageEffect 入队门);
show_damage_from_flag(flag) = flag == 1SetShowDamageFlag);
show_damage_from_cfg(value) = int(value) == 1(配置行 atoi == 1);
is_self(vid, main_vid) / is_target(vid, target_vid)RecvDamageInfoPacket 的 bSelf / bTarget);
valid_amount(amount) = amount >= 0damage < 0 记 TraceError 不处理);
classify(flag, b_self, b_target) -> {kind, type("damage_"/"target_"/"nontarget_"), show, self}
—— DODGE|BLOCK → MISStype 按 b_self);否则 b_self → DIGITS damage_ show
非 b_self 且非 b_target → DIGITS nontarget_ show=falseProcessDamage 里 return);
否则 → DIGITS target_ showCRITICAL / POISON 均落普通数字(对应分支参考端注释掉);
digits(damage) —— while(damage>0) num=damage%10; damage/=10; 低位在前、最多 8 位(index>7 break),
damage <= 0 产出 []
digit_offset_px(index) = FONT_WIDTH * index
self_damage_slot(toggle_state) —— false → SELFDAMAGE(0)、true → SELFDAMAGE2(1)。
project/net_world.gd —— const DamageEffect;删本地 const DAMAGE_DODGE / DAMAGE_CRITICAL
(改引用 DamageEffect.DAMAGE_*);新增 var _show_damage(= SHOW_DAMAGE_DEFAULT) /
_damage_queue(Array= m_DamageQueue) / _dmg_self_toggle(= m_bDamageEffectType)
新增 set_show_damage(on)uigameoption「伤害数字」radio);
_on_damage(vid, amount, flag) 改写 —— 仍恒发 damage_number 信号(本移植扩展,供战斗日志 /
测试);然后 valid_amount 门(< 0 return+ should_queue(_show_damage) 门(!IsShowDamage return
+ is_self(_main_vid) / is_target(_target_vid) → _damage_queue.append({vid, amount, flag, self, target});
新增 _process_damage_queue()_process 内 _update_visibility() 后每帧调一次)——
pop_front 一条 → 节点存在校验 → DamageEffect.classify → !shownontargetreturn →
MISS 写 "MISS" / DIGITS 写 str(amount)digits() 为空则跳过)→ self 数字按 self_damage_slot
交替水平抖动符号(0.3 / -0.3)并翻转 _dmg_self_toggle → 生成 billboard Label3D(白、font_size 32、
no_depth_test、pixel_size 0.007)在 y+1.9 处,tween 上升 1.2 / 0.8s + 淡出 → queue_free
clear_for_map_change() 增 _damage_queue.clear()。
project/ui/game_option_ui.gd —— 无改动(增量 113 的 display_option_changed 信号 + display_value
已覆盖 show_damage key_display 默认表已含 show_damage 默认 1)。
project/game_scene.gd —— game_option_ui.setup 后用 display_value("show_damage") == 1 开局
net_world.set_show_damage 回填;_on_display_option 增 elif key == "show_damage" →
net_world.set_show_damage(value == 1)。
project/damage_effect_test.gd(新,第 25 个 canonical)—— SceneTree 回归,约 50 断言:
常量(DamageFlag 位值 / 默认 true / "SHOW_DAMAGE" / FONT_WIDTH 30 / MAX_DIGITS 8)、
IsShowDamage 门(should_queue / from_flag / from_cfg 严格 == 1)、bSelf / bTarget、
valid_amount0 T / -1 F)、classifyDODGE / BLOCK → MISS,普通 self/target/nontarget
CRITICAL 仍普通数字、nontarget 不渲染,POISON 走普通)、digits0 → []、-5 → []、123 →
[3,2,1]、90 → [0,9]、2000000 → 7 位、9 位截 8 位)、digit_offset_px0/30/90)、
self_damage_slotfalse → 0、true → 1)。
测试:
/opt/homebrew/bin/godot --headless --path project --script damage_effect_test.gd —— PASS。
回归:project/*_test.gd 全 65 个全 PASS(含 damage_effect_test / name_show_test / chat_tail_test /
text_tail_test / name_color_test / netbridge_testdamage_number 信号仍恒发)/ gamescene_test /
netplay_test / net_world_vis_test / …)。
验收:
已验证(离线):DamageFlag 位值、IsShowDamage 默认 true、SetShowDamageFlag / "SHOW_DAMAGE"
配置行的 == 1 语义、RecvDamageInfoPacket 的 damage >= 0 门与 bSelf / bTarget 判定、
ProcessDamage 的 DODGE|BLOCK → MISS、nontarget 数字不渲染、self "damage_" 交替、
target "target_"、CRITICAL / POISON 落普通数字、数字逐位低位在前 + 8 位上限、
FONT_WIDTH*index 偏移、game_option_ui radio → display_option_changed → set_show_damage、
_process_damage_queue 每帧 pop 一条、全 65 回归。
未验证:真机 —— GC_DAMAGE_INFO 真包驱动下 MISS / 数字飘字的位置 / 升起 / 淡出目视、
nontarget(非 self 非目标)实体确实不显、self 连击时 SELFDAMAGE / SELFDAMAGE2 交替的观感、
「伤害数字」radio 关闭后完全无飘字、同帧多 VID 受击的分帧显示是否可接受、
damagevalue/<type><digit>.dds 逐位特效与相机右向铺排(seam ⑪,未做)。
风险与依赖:
seam ⑪(见上「参考」末):单块 Label3D 代替 damagevalue dds 逐位特效 / 相机右向像素铺排、
self 交替退化为水平抖动符号、暴击无独立视觉(对齐参考)、全局单队列每帧一条 vs 参考
每实例一队列。
行为变化:此前 _on_damage 无条件生成飘字(MISS 仅看 DODGE、暴击金色放大、随机水平抖动、
所有实体都显);现按 ProcessDamageBLOCK 也算 MISS、非 self 非目标实体的数字伤害不再
显示、暴击不再金色放大、飘字受 IsShowDamage()uigameoption「伤害数字」radio)门控、
每帧只弹一条(队列)。damage_number 信号维持无条件发出(本移植扩展,供 HUD 战斗日志 /
netbridge_test;参考端 AddDamageEffect 整体在 IsShowDamage() 内)。
_show_damage 由 game_option_ui 持久化到 user://system_option.cfg [gameopt] show_damage
(默认 1);参考端 PythonSystem 仅在 == 0(非默认)时才写 SHOW_DAMAGE 行,本移植 ConfigFile
恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径,与增量 113 的 ALWAYS_VIEW_NAME 同)。
bSelf 用 net_world._main_vid、bTarget 用 _target_vid(增量 113 由 net_play.target_changed 灌入);
离线 / FakeClient 若 _main_vid 未设,主角伤害会落 nontarget_ 分支而不显(与参考「无主实例」
行为一致)。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考
CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字
17pxseam ⑨ 收口);
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+
ArrangeTextTail 屏幕空间防重叠;
或 §8.4 名字色 radioname_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式;
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 115W3→M2 / §5.4 · §8.8 目标框 RecvTargetPacket 收包分派 1:1
```text
批次:增量 115 —— W3→M2 §5.4 / §8.8CPythonNetworkStream::RecvTargetPacket 的目标框判定树
(实例缺失 → CloseTargetBoard、目标已死透 → 不动框、PC|Building → CloseTargetBoardIfDifferent、
CanViewTargetHP → SetHPTargetBoard、否则 → CloseTargetBoard+ CInstanceBase::CanViewTargetHP
IsStone()||IsWoodenDoor()||IsEnemy()+ game.py 的 SetHPTargetBoard 换 VID 先 Reset /
CloseTargetBoardIfDifferent / SetPCTargetBoard LCONTROL 悄悄话捷径(纯 GDScript,无 C++ 改动)
状态:✅ 完成(target_board.gd 纯静态谓词 + net_play._on_target_info 改为按判定树分派目标框)
🔎 真机目视:选 PC 目标无血条框、选 NPC / warp 不弹框、选石头 / 怪弹血条框、换目标先清旧框、
目标死亡 / despawn → 清框。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/*.cpp
ISO-8859 编码,grep 需 -a):
UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; }
UserInterface/PythonNetworkStream.cpp Set(HEADER_GC_TARGET, sizeof(TPacketGCTarget))STATIC_SIZE_PACKET
UserInterface/PythonNetworkStreamPhaseGame.cpp case HEADER_GC_TARGET -> RecvTargetPacket()
UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket()
pInstPlayer = GetMainInstancePtr()pInstTarget = GetInstancePtr(TargetPacket.dwVID)
:2436 if (pInstPlayer && pInstTarget)
:2438 if (!pInstTarget->IsDead())
:2440 if (pInstTarget->IsPC() || pInstTarget->IsBuilding())
PyCall(GAME, "CloseTargetBoardIfDifferent", (i)dwVID)
:2442 else if (pInstPlayer->CanViewTargetHP(*pInstTarget))
PyCall(GAME, "SetHPTargetBoard", (ii)dwVID, bHPPercent)
:2444 else PyCall(GAME, "CloseTargetBoard", ())
:2447 m_pInstTarget = pInstTarget (仅在 !IsDead() 块内 —— 目标存在且未死才写)
:2453 else PyCall(GAME, "CloseTargetBoard", ()) pInstPlayer/pInstTarget 有一个缺失)
UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim)
if (victim.IsStone()) return true;
if (victim.IsWoodenDoor()) return true;
if (victim.IsEnemy()) return true;
return false;
UserInterface/InstanceBase.cpp:2310/2320/2325 IsPC() / IsEnemy() / IsStone()(→ GraphicThingInstance
UserInterface/InstanceBase.cpp:2380 IsBuilding()(→ GraphicThingInstance.IsBuilding()
UserInterface/InstanceBase.cpp:2390 IsWoodenDoor()IsDoor() 且 vnum == 13000 或 30111..30119
root/game.py:804 SetPCTargetBoard(vid, name)self.targetBoard.Open(vid, name)
if app.IsPressed(app.DIK_LCONTROL): if not player.IsSameEmpire(vid): return;
if player.IsMainCharacterIndex(vid): return;
elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return;
self.interface.OpenWhisperDialog(name)
root/game.py:829 SetHPTargetBoard(vid, hpPct)if vid != targetBoard.GetTargetVID():
targetBoard.ResetTargetBoard(); targetBoard.SetEnemyVID(vid); 然后 SetHP(hpPct) + Show()
root/game.py:837 CloseTargetBoardIfDifferent(vid)if vid != targetBoard.GetTargetVID(): Close()
root/game.py:841 CloseTargetBoard()targetBoard.Close()
seam ⑫:本 poc 无 building / wooden-door / stone-door 实体类型(_entity_kind 只到
0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building 恒 false、can_view 的
is_wooden_door 恒 false;「可看血」= 石头(KIND_STONE) 或 敌对怪(KIND_MONSTER)。木门 / 攻城门
做出来后再补 IsWoodenDoor / IsBuilding 分类。
另:poc 的 target_info(vid, hp%) 由 C++ 侧 extension/src/net/m2_client.cpp:3129/3633 从
EntityStore 抽出(w.target_vid() / w.target_hp_pct()),本移植只镜像 GDScript 侧对
(vid, hp%) 的分派,不改 C++ 收包(对齐「纯 GDScript」约束)。
LCONTROL 悄悄话捷径(SetPCTargetBoard 里按住 LCONTROL 对同阵营 PC 开 OpenWhisperDialog):
poc 目标框点击无 whisper dialog 钩子 → whisper_on_ctrl_click 谓词已备好,net_play 暂不接。
改动:
project/target_board.gd(新,第 26 个 canonical)—— 纯静态 `extends RefCounted`
enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE }
can_view_target_hp(is_stone, is_wooden_door, is_enemy) = 三者任一(CanViewTargetHP);
classify(target_exists, is_dead, is_pc, is_building, can_view_hp) -> Action
—— !target_exists → CLOSE:2453);is_dead → NONE:2438 整块跳过);
is_pc || is_building → CLOSE_IF_DIFFERENT:2440);can_view_hp → SET_HP:2442);
else → CLOSE:2444);
updates_current_target(target_exists, is_dead) = target_exists and not is_dead
m_pInstTarget = pInstTarget 在 :2447,仅 !IsDead() 块内);
needs_reset(new_vid, cur_vid) = new_vid != cur_vidSetHPTargetBoard 换 VID 先 Reset);
should_close_if_different(vid, cur_vid) = vid != cur_vidCloseTargetBoardIfDifferent);
whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building)
= lcontrol_down and same_empire and not is_main and not is_buildingSetPCTargetBoard 捷径);
clamp_hp_pct(v) = clampi(int(v), 0, 100)bHPPercent 是 BYTE)。
project/net_play.gd —— const TargetBoard = preload("res://target_board.gd")
_on_target_info(vid, hp_pct) 改写 —— 由「任意有名字 VID 都 hud.set_target」改为按判定树:
e = client.get_entity(vid) if vid != 0 else {}exists = vid != 0 and not e.is_empty()
dead = exists and e.deadkind = _entity_kind(e)-1 若不存在);
is_pc = kind == KIND_PCis_building = falseseam ⑫);
can_view = can_view_target_hp(kind == KIND_STONE, false, kind == KIND_MONSTER)
match classify(exists, dead, is_pc, is_building, can_view)
CLOSE → _clear_target()(内部 _target_vid==0 早退 + target_changed.emit(0) + hud.clear_target);
CLOSE_IF_DIFFERENT → should_close_if_different(vid, _target_vid) 才 _clear_target()
SET_HP → needs_reset(vid, _target_vid) 时 _target_vid = vid + target_changed.emit(vid)
再 hud.set_target(_entity_name(e) or "目标", clamp_hp_pct(hp_pct))
NONE → pass(目标已死透,不动框)。
project/target_board_test.gd(新,第 26 个 canonical)—— SceneTree 回归,约 35 断言:
can_view_target_hpStone / WoodenDoor / Enemy 各自 T、皆非 F、任一 T)、
classify(实例缺失 → CLOSE 且优先、已死 → NONE 且优先于 PC 分支、PC → CLOSE_IF_DIFFERENT、
Building → CLOSE_IF_DIFFERENT、PC 即便可看血也走 CLOSE_IF_DIFFERENT、非 PC 可看血 → SET_HP、
NPC/warp 不可看血 → CLOSE)、updates_current_target(存在且未死 T、已死 F、缺失 F)、
needs_reset(无目标 T / 换目标 T / 同目标 F)、should_close_if_different(异 VID T / 同 VID F /
无 VID T)、whisper_on_ctrl_click(全真 T、缺 LCONTROL / !同阵营 / 本人 / building 各 F)、
clamp_hp_pct40 / 0 / 100 原样、负 → 0、>100 → 100、float 37.9 → 37)。
测试:
/opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。
回归:project/*_test.gd 全 66 个全 PASS(新增 target_board_test;含 netplay_testtarget_info →
hud.set_target(pct=40),实体 2000 ch_type=2 走 SET_HP/ netbridge_test / gamescene_test /
combat_fx_test / net_world_vis_test / damage_effect_test / name_show_test / …)。
验收:
已验证(离线):CanViewTargetHP 三谓词、RecvTargetPacket 判定树全 5 分支 + 优先级
(缺失 > 已死 > PC|Building > 可看血 > 关框)、m_pInstTarget 写入条件、SetHPTargetBoard
换 VID 先 Reset、CloseTargetBoardIfDifferent 的 != 语义、SetPCTargetBoard LCONTROL 捷径
四个早退、bHPPercent 钳位、net_play._on_target_info 经 target_board.gd 分派、全 66 回归。
未验证:真机 —— GC_TARGET 真包驱动下选 PC 目标不弹血条框(只在换 VID 时清旧框)、
选 NPC / warp 不弹框、选石头 / 敌对怪弹血条框、换目标先清旧框的观感、
目标已死透时框保持不变、LCONTROL 悄悄话捷径(seam ⑫,未接)、
木门 / 攻城门作为可看血目标(seam ⑫,poc 无该实体类型)。
风险与依赖:
seam ⑫(见上「参考」末):poc 无 building / wooden-door 实体 → is_building 恒 false、
can_view 的 is_wooden_door 恒 falseLCONTROL 悄悄话捷径未接(无 whisper dialog 钩子);
target_info 由 C++ EntityStore 侧抽出,不改 C++ 收包。
行为变化:此前 _on_target_info 对任意 vid != 0 且有名字的目标都 hud.set_target(包括 NPC);
现按 RecvTargetPacket 判定树 —— PC / building 目标不再显血条框(只在框上是别的 VID 时清旧框)、
NPC / warp 等不可看血目标一律关框、目标已死透时不动框、hp% 钳到 0..100。
_change_target_to_picked_instance(点击选目标,PythonPlayerInput 路径)不在本批次范围内,
维持原逻辑(KIND_PC / KIND_MONSTER 可选中并 _refresh_target_hud)—— 点击 PC 仍会 set_target
并刷 HUD 目标名,与参考端 SetPCTargetBoardOpen + 名字)大方向一致,血条部分待 GC_TARGET
真包到达再由本批次的 _on_target_info 收敛。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考
CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字
17pxseam ⑨ 收口);
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+
ArrangeTextTail 屏幕空间防重叠;
或 seam ⑫ 收口:木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog
或 §8.4 名字色 radioname_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式;
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
```
### C.5 批次记录 · 增量 116W3→M2 / §8.8 · §5.4 OnRender Picking 块分派 + 悬停角色强显名字 1:1)
```text
批次:增量 116 —— W3→M2 §8.8 / §5.4game.py GameWindow.OnRender() 的「Picking」块判定树
wndMgr.IsPickedWindow 门 → chr.Pick() 命中角色 ShowCharacterTextTail、targetBoard.GetTargetVID()
ShowCharacterTextTail、__IsShowName() 分叉:非总显名字 → item.Pick(),总显名字 → ShowAllTextTail
+ textTail.Pick(),末尾 SelectItemName)抽成纯静态谓词;并把「悬停角色强显名字」按 :1535/:1537
接进 poc —— 仿增量 113 的 _target_vid forced 路数,给 net_world._name_shown 加第 5 路 forced
_hover_vid,由 player_controller 的射线悬停拾取喂入(纯 GDScript,无 C++ 改动)
状态:✅ 完成(pick_show.gd 纯静态谓词 + net_world._hover_vid / set_hover_vid + _name_shown forced
第 5 路 + player_controller.hover_entity_changed 信号 + game_scene 连线)
🔎 真机目视:鼠标悬停某 NPC / 怪 → 其头顶名字立即强显(即便未开「总显名字」且超 3500 距)、
移开 → 复原;鼠标压 UI / 悬停地面物品时不强显任何角色名。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;运行时 python =
mtgodot-poc/assets/root/game.py):
root/game.py:1519 GameWindow.OnRender()
root/game.py:1531 textTail.UpdateAllTextTail()
root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D game 窗口上才拾取
root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无)
root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx)
root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID)
root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick()
root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx)
root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail()
root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y)
root/game.py:1557 textTail.UpdateShowingTextTail()
root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布
root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx)
seam ⑨:名字尾标 y = 聊天气泡 y 17pxUserInterface/PythonTextTail.cpp:112-114)尚未接入 —
poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。
seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(UserInterface/PythonTextTail.cpp ArrangeTextTail
仍缺 —— poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。本增量只收口其中
一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。
seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内;
poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径 —
_hover_vid 在鼠标压 UI / 悬停地面物品时由 player_controller 主动置 0,算是部分等效于
IsPickedWindow 门。
改动:
project/pick_show.gd(新,第 27 个 canonical)—— 纯静态 `extends RefCounted`
const NO_PICK := -1chr.Pick() / item.Pick() 无命中哨兵);
char_pick_active(is_picked_window) = is_picked_window:1533);
forced_name_vids(is_picked_window, pick_char_vid, target_vid) -> Array
—— 非窗口 → [];否则收 pick_char_vid!= NO_PICK 且 != 0:1537)与 target_vid!= 0:1539),
去重,顺序 [hover, target]
should_pick_item(is_picked_window, is_show_name) = is_picked_window and not is_show_name:1543);
should_show_all_names(is_show_name) = is_show_name:1552);
should_pick_text_tail(is_show_name) = is_show_name:1554);
should_select_item_name(pick_item_index) = pick_item_index != NO_PICK:1560)。
project/net_world.gd —— const PickShow = preload("res://pick_show.gd")
var _hover_vid := 0
func set_hover_vid(vid)(仿 set_target_vid:值变才刷 prev / vid 两个节点的名字可见性);
_name_shown()forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)(原只 _target_vid)。
project/player_controller.gd —— signal hover_entity_changed(vid: int)var _hover_vid := 0
_update_hover_cursor() 三个出口都喂 _set_hover_vid():鼠标压 UI → 0、悬停地面物品 → 0、
射线命中 pickable → int(best.get_meta("vid", 0))、无命中 → 0
_set_hover_vid(vid):值变才 hover_entity_changed.emit(vid)(去抖)。
project/game_scene.gd —— 紧挨 net_play.target_changed → net_world.set_target_vid 那行,
加 pc.hover_entity_changed → net_world.set_hover_vid 连线(has_signal / has_method 守卫)。
project/pick_show_test.gd(新,第 27 个 canonical)—— SceneTree 回归,约 24 断言:
NO_PICK == -1、char_pick_active(窗口内 T / 外 F)、forced_name_vids(非窗口 → []、
hover+target → [hover, target]、只 hover、只 target、hover=0 视为无、hover==target 去重、
皆无 → [])、should_pick_item(窗口且非总显名字 T / 总显名字 F / 非窗口 F)、
should_show_all_names / should_pick_text_tail(总显名字 T / 否 F)、item.Pick 与 textTail.Pick
在窗口内恰好二选一、should_select_item_name7 T / 0 T / -1 F)。
测试:
/opt/homebrew/bin/godot --headless --path project --script pick_show_test.gd —— PASS。
回归:project/*_test.gd 全 67 个全 PASS(新增 pick_show_test;含 net_world_vis_test /
netplay_test / netbridge_test / gamescene_test / combat_fx_test / target_board_test /
name_show_test / damage_effect_test / …,两次运行均 PASS=67 FAIL=0)。
验收:
已验证(离线):OnRender Picking 判定树全部谓词 —— IsPickedWindow 门、chr.Pick() 强显集合
收集 + 去重 + [hover, target] 顺序、非窗口时连 target 强显也不做、__IsShowName() 分叉
(非总显名字 → item.Pick、总显名字 → ShowAllTextTail + textTail.Pick,二者互斥)、
SelectItemName 门;net_world._hover_vid 值变刷新、_name_shown forced 第 5 路;
player_controller 三出口喂 _hover_vid + emit 去抖;game_scene 连线;全 67 回归。
未验证:真机 —— 悬停 NPC / 怪时头顶名字强显、移开复原、鼠标压 UI / 悬停地面物品不误强显、
与增量 113 目标强显并存无闪烁、悬停命中判定(_ray_pick_t 命中半径)与参考 chr.Pick()
的观感差异、ArrangeTextTail 屏幕空间防重叠(seam ⑩,未做)、名字随 chat 气泡下移 17px
seam ⑨,未做)。
风险与依赖:
seam ⑨ / ⑩(见上「参考」):屏幕空间尾标 y 偏移与去重叠排布仍缺;本增量只收口
「悬停角色强显名字」一小块。
seam(近似):_target_vid / _hover_vid 当作无条件 forced,非严格 IsPickedWindow 门内;
_hover_vid 靠 player_controller 在压 UI / 悬停物品时置 0 部分等效。
行为变化:此前只有 targetBoard 目标(增量 113)会 forced 强显头顶名字;现悬停到的角色
player_controller 射线拾取,pickables 带 vid meta)也 forced 强显 —— 即便未开「总显名字」
且超 3500 平面距。悬停命中判定沿用既有 _ray_pick_t(cursor 用的同一套),未新增射线开销。
net_play._on_target_info(增量 115)不受影响。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 收口(名字尾标随 chat 气泡下移 17pxPythonTextTail.cpp:112-114);
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog);
或 §8.4 名字色 radioname_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。
```
### C.5 批次记录 · 增量 117W3→M2 / §8.8 seam ⑨ 收口:头顶名字尾标随聊天气泡上移 17px 1:1)
```text
批次:增量 117 —— W3→M2 §8.8 seam ⑨ 收口:CPythonTextTail::UpdateShowingTextTail 里
每条角色名字尾标投影完屏幕坐标后,若该 VID 在 m_ChatTailMap 命中且那条的 bNameFlag
为 TRUE(= RegisterChatTail 注册的「聊天」气泡,非 RegisterInfoTail 的「信息」气泡),
就把名字尾标的屏幕 y 顶到 `chatTail.y 17.0f`(屏幕 y 向下,减 17px = 上移一行贴气泡
上沿)。抽成纯静态谓词 + 公式,并接进 poc:有「聊天」气泡时把名字 Label3D 的局部 y
顶到气泡 y 之上 17 个名字像素,气泡消失或只是「信息」气泡时回默认 y(纯 GDScript)。
状态:✅ 完成(name_tail_layout.gd 纯静态第 28 个 canonical + net_world._reposition_name_tag
由 _apply_chat_tail / _expire_chat_tails 调用 + NAME_TAG_DEFAULT_Y / BUBBLE_TAG_Y 常量)
🔎 真机目视:某角色冒「聊天」气泡时其头顶名字上移贴到气泡上沿、气泡消失后名字回原位;
「信息」尾标(技能 / 射击错误,淡红)不顶名字。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f)
:519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10
UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建 fHeight = GetHeight() + 10.0f、
:603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白)
UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高,
:646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字
UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail()
:101 for (角色名字尾标 : m_CharacterTextTailList)
:104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z
:106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID)
:110 if (m_ChatTailMap.end() != itor)
:112 if (pChatTail->bNameFlag)
:114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动
ShowCharacterTextTail(dwVID) 强显(增量 112 / 113 已接 chat_tail.gd / name_show.gd)。
seam ⑨(收窄):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕
−17px 把名字从「和气泡重叠」顶成「贴气泡上沿」;poc 是每实体一个 billboard Label3D、
气泡默认在名字上方(局部 y 2.35 vs 2.1),本增量把屏幕 y 向下的 −17px 换成世界 y
向上的 +17px*pixel_size。剩余 seam ⑨:气泡与名字 Label3D 仍是两个并存节点(非参考
的单尾标链表)、子标签(GuildTag / TitleTag / LevelTag)不随动 = seam ⑦ 遗留。
改动:
project/name_tail_layout.gd(新,第 28 个 canonical)—— 纯静态 `extends RefCounted`
const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0PythonTextTail.cpp:114 的 17.0f);
name_follows_bubble(has_chat_tail, chat_name_flag) = has_chat_tail and chat_name_flag
:110 有尾标 && :112 bNameFlag);
name_tail_screen_y(default_y, bubble_y, follows) —— 参考端字面(屏幕 y 向下):
follows → bubble_y 17,否则 default_y
name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) —— poc 3D 适配
(世界 y 向上):follows → bubble_world_y + 17*px_size,否则 default_world_y。
project/net_world.gd —— const NameTailLayout = preload("res://name_tail_layout.gd")
const NAME_TAG_DEFAULT_Y := 2.1 / const BUBBLE_TAG_Y := 2.35(名字 / 气泡 Label3D 默认局部 y);
_attach_nameplate 用 NAME_TAG_DEFAULT_Y_apply_chat_tail 用 BUBBLE_TAG_Y 建气泡节点、
末尾 _set_name_visibility(n) 后加 _reposition_name_tag(n)
_expire_chat_tails 删气泡节点后同样 _set_name_visibility(n) → _reposition_name_tag(n)
func _reposition_name_tag(n):取 n 的 "Label3D" 子节点,读 _chat_tails[vid].name_flag
follows := NameTailLayout.name_follows_bubble(有尾标, name_flag)
tag.position.y = NameTailLayout.name_tail_world_y(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y,
follows, tag.pixel_size)。
project/name_tail_layout_test.gd(新,第 28 个 canonical)—— SceneTree 回归:
常量 17.0name_follows_bubbleT/T→T、T/F→F、F/T→F、F/F→F);
name_tail_screen_yfollows → 25017=233、!follows → 300、信息气泡 follows=false → 300);
name_tail_world_yfollows → 2.35+17*0.006=2.452、!follows → 2.1、结果 > 2.35、
px_size 翻倍 → 偏移翻倍 2.35+0.204);组合链(聊天气泡 → 2.452、气泡过期 → 2.1)。
project/chat_test.gd(改)—— 「气泡」段加断言:entity 2000 冒气泡后 Label3D.position.y
== bubble.position.y + 17*pixel_size 且 > bubble.position.y;再 register_info_tail(2000)
覆盖成信息尾标 → 名字回默认局部 y 2.1。
测试:
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
回归:project/*_test.gd 全 68 个全 PASS(新增 name_tail_layout_testchat_test 扩断言;
含 net_world_vis_test / chat_tail_test / name_show_test / pick_show_test / …,PASS=68 FAIL=0)。
验收:
已验证(离线):name_follows_bubble 判据(有 chat 尾标 && bNameFlag)、屏幕端字面公式
bubble_y 17、poc 世界端公式 bubble_world_y + 17*pixel_size(含 pixel_size 线性缩放)、
信息尾标(bNameFlag FALSE)不顶名字、气泡过期名字回默认 ynet_world 端 _apply_chat_tail /
_expire_chat_tails 两处调用后名字 Label3D 局部 y 正确切换(chat_test 端到端);全 68 回归。
未验证:真机 —— 冒气泡时名字上移贴气泡上沿的观感、气泡消失名字回位无跳变、与增量 113
的 bNameFlag 强显并存、信息尾标(技能 / 射击错误)不顶名字、子标签不随动的视觉突兀度
seam ⑦ / ⑨ 遗留)、ArrangeTextTail 屏幕空间防重叠(seam ⑩,仍未做)。
风险与依赖:
seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表);
子标签 GuildTag / TitleTag / LevelTag 不随名字上移 = seam ⑦ 遗留。
seam ⑩:ArrangeTextTail 屏幕空间尾标去重叠排布仍缺(poc 3D billboard 靠深度天然分层)。
换算常量:17px → 世界单位按名字 Label3D 的 pixel_size0.006)线性换算,与参考的
字体无关常量 17 对齐;气泡 Label3D 的 pixel_size0.0055)不参与,只用其局部 y。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表);
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog);
或 §8.4 名字色 radioname_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。
```
### C.5 批次记录 · 增量 118W3→M2 / §5.4 · §8.8 · §8.4 目标框「看他国玩家」前置过滤 + 名字色 radio 接线 1:1
```text
批次:增量 118 —— W3→M2 §5.4 / §8.8 / §8.4
(a) root/uitarget.py TargetBoard.Open(vid,name) 在整个 RecvTargetPacket 判定之前的
异阵营前置过滤(:189-193):game-option 关「看他国玩家目标框」
constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1 = 开)时,若目标与主角
不同阵营(player.IsSameEmpire(vid) 为假),直接 Hide() + return,不开目标框。
抽成纯静态谓词,接进 net_play._on_target_info 的 classify 之前。
(b) §8.4「名字色」radioname_color_normal / name_color_empire)接线收口:
game_scene._on_display_option 增两条分派 —— "name_color" → net_world.set_empire_name_mode、
"target_board" → net_play.set_view_other_empire_target;开局各自用
game_option_ui.display_value(...) == 1 回填。帝国名色调色板算法本体增量 110 已 1:1。
状态:✅ 完成(target_board.hidden_as_other_empire 纯静态谓词 + net_play._view_other_empire_target /
set_view_other_empire_target / _on_target_info 前置过滤 + game_scene 两条分派与两处开局回填 +
game_option_ui.gd target_board 默认 0→1 修正)
🔎 真机目视:game-option 关「看他国目标框」后点异阵营玩家不弹目标框、点同阵营 / 怪 / NPC 照旧;
「名字色」切「阵营」后头顶名字按阵营配色(红 / 橙 / 蓝),切「普通」后回黄。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/root/ = assets/root/):
root/uitarget.py:189-193 TargetBoard.Open(vid,name)
if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD():
if not player.IsSameEmpire(vid): self.Hide(); return
root/constinfo.py:38 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1(默认开)
root/constinfo.py:139-145 SET_ / GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)
root/uigameoption.py target_board_view / target_board_no_view radio →
constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)flag 值 == 按钮列表下标)
root/uigameoption.py __SetNameColorMode(index) → constInfo.SET_CHRNAME_COLOR_INDEX(index)
→ chrmgr.SetEmpireNameMode(index)name_color_normal→0(全 PC 黄)、name_color_empire→1(阵营色)
root/constinfo.py CHRNAME_COLOR_INDEX = 0(默认普通);SET_DEFAULT_CHRNAME_COLOR() 载入时
调 SetEmpireNameMode(CHRNAME_COLOR_INDEX)
UserInterface/InstanceBase.cpp:470 CInstanceBase::IsSameEmpire —— victim.empire == 0 或
任一方 IsGameMaster() 或 两方 empire 相等 → trueentity_rules._is_same_empire 已 1:1
UserInterface/InstanceBaseEffect.cpp:52 CInstanceBase::SetEmpireNameMode(bool) 调色板重写
(增量 110 已迁 name_color.gd::set_empire_name_mode
seam ⑫:poc 无独立「他国 PC 名字框」窗体(目标框只有 HP 条形态),本增量把同一道阵营门
加在 net_play._on_target_info 分派前;building / wooden-door 实体类型、LCONTROL 悄悄话
捷径仍未做(延续增量 115)。
改动:
project/target_board.gd(改)—— 新增静态谓词:
hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool
= not view_other_empire and not same_empireuitarget.py:189-193 字面);
头部 REF 段补 root/uitarget.py:189-193 前置过滤条目。
project/net_play.gd(改)—— var _view_other_empire_target := true
constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD 默认 1);
func set_view_other_empire_target(on: bool)(§8.4 radio 驱动);
_on_target_info:在 is_building 之后、can_view 之前插前置过滤 —— if exists:
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire):
_clear_target(); return
project/ui/game_option_ui.gd(改)—— setup() 默认值列表 line 95
"target_board" 并入默认 1 的键组(对齐 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1
原来漏了 → 默认落到 0,与参考相反)。radio 接线本就走通用 RADIO_GROUPS /
DISPLAY_RADIOS,无需再动。
project/game_scene.gd(改)—— 开局在 set_show_damage 回填之后加两段:
net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1)、
net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1)
_on_display_option 加两条 elif —— key == "name_color" → net_world.set_empire_name_mode(value == 1)、
key == "target_board" → net_play.set_view_other_empire_target(value == 1)。
project/target_board_test.gd(改)—— 新增 5b 段:hidden_as_other_empire
(关+异阵营→true、关+同阵营→false、开+异阵营→false、开+同阵营→false)。
project/netplay_test.gd(改)—— 新增 8b 段:主角 empire 1 + 异阵营 PCvid 2500empire 2);
看他国框开 → 异阵营 PC 目标框保留(CLOSE_IF_DIFFERENT,同 VID 不关);
看他国框关 → 前置过滤直接 _clear_target()_target_vid 归 0、hud.cleared +1);
看他国框关不影响怪(empire 0 → IsSameEmpire 真 → HP 血条照设);收尾还原开关 + 清 empire 键。
project/system_menu_ui_test.gd(改)—— GameOptionUI 段加断言:
name_color_empire 按下 → _display.name_color == 1 + display_option_changed("name_color",1)
target_board 默认读作 1target_board_no_view 按下 → _display.target_board == 0 +
display_option_changed("target_board",0);二次 ConfigFile 读 user://system_option.cfg
[gameopt] name_color == 1 / target_board == 0 已持久化。
测试:
/opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script system_menu_ui_test.gd —— PASS。
回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有三个;PASS=68 FAIL=0)。
验收:
已验证(离线):hidden_as_other_empire 四组合真值;net_play._on_target_info 前置过滤
(看他国框开→异阵营 PC 走原判定树、关→清框 return、关不影响 empire 0 的怪);
game_option_ui 的 target_board 默认值修正为 1、name_color / target_board radio →
_display + user://system_option.cfg 持久化 + display_option_changed 信号;全 68 回归。
未验证:真机 —— game-option 关「看他国目标框」后点异阵营玩家不弹框的观感、
切「名字色 / 阵营」后头顶名字按阵营配色(依赖真服 GC_PVP / empire 下发)、
与增量 115 目标框判定树、增量 110 名色整表刷新的并存;building / wooden-door
实体类型 + LCONTROL 悄悄话捷径仍是 seam ⑫。
风险与依赖:
seam ⑫(延续):poc 无 building / wooden-door 实体类型(is_building 恒 false)、
目标框点击 LCONTROL 悄悄话捷径未接 whisper dialog。
忠于参考:hidden_as_other_empire 不按 is_pc 收窄 —— empire 0 的怪 / NPC / 石头在
_is_same_empire 里恒「同阵营」故本就不会被挡,加 is_pc 反而偏离参考。
名字色算法本体(SetEmpireNameMode 调色板)增量 110 已 1:1 并测;本增量只补 radio → 运行期
开关的最后一段接线,不碰算法。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表);
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog)。
```
### C.5 批次记录 · 增量 119W3→M2 / §8.8 seam ⑦ 收口:公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1)
```text
批次:增量 119 —— W3→M2 §8.8 seam ⑦ 收口:CPythonTextTail::ArrangeTextTail 里,逐条
角色名字尾标(m_CharacterTextTailList)的公会名 / 称号 / 等级三个子标签的屏幕坐标
全部锚在 `pTextTail->y` 上(公会名 y - c_fyGuildNamePosition(15px)、称号 / 等级同 y),
而这个 y 已在 UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦因
「聊天」气泡上移,三个子标签整体跟随同一段纵向位移。增量 117 只让名字尾标本身跟随,
子标签当时留作 seam ⑦ 遗留 —— 本增量补齐「子标签随名字尾标纵向位移」这一 1:1 行为。
状态:✅ 完成(name_tail_layout.gd 增 name_tail_lift_delta / subtag_world_y 两个纯静态函数
+ 常量 GUILD_NAME_ABOVE_NAME_PXnet_world._reposition_name_tag 在顶名字尾标后按
lift_delta 平移 GuildTag / TitleTag / LevelTag;子标签默认局部 y 提为 SUBTAG_DEFAULT_Y 常量)
🔎 真机目视:远端 PC 冒聊天气泡时,头顶「公会名 / 称号 / 等级」随名字一起上移贴气泡,
气泡过期 / 只是「信息」尾标时整体回落默认位置;三者相对间距全程不变。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail()
:101 for (pTextTail : m_CharacterTextTailList)
:106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID)
:112 if (pChatTail->bNameFlag)
:114 pTextTail->y = pChatTail->y - 17.0f —— 名字尾标屏幕 y 顶到气泡上方 17px
UserInterface/PythonTextTail.cpp:151 ArrangeTextTail()
:211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标
:21 const float c_fyGuildNamePosition = 15.0f
:228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z)
:258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行
:283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行
—— 三个子标签的屏幕 y 都取自 pTextTail->y(已被 :114 顶过)。名字上移多少,子标签上移多少。
seam ⑦(收窄后剩余):poc 把三个子标签竖排(公会名局部 y 2.34 在名字上、称号 1.86 /
等级 1.74 在名字下),参考端是屏幕空间:公会名在名字上方 15px、称号 / 等级与名字同一
水平行按各自文本宽度左偏。这套逐像素水平排版差异仍留 seam ⑦;本增量只对齐「纵向
整体跟随名字尾标位移」。
改动:
project/name_tail_layout.gd(改)—— 新增:
const GUILD_NAME_ABOVE_NAME_PX := 15.0PythonTextTail.cpp:21 c_fyGuildNamePosition
仅作 1:1 记录,poc 竖排近似不用它做像素排版);
name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size) -> float
= name_tail_world_y(...) - default_world_yfollows 时 = 顶到气泡上方后的净上移,否则 0);
subtag_world_y(default_subtag_y, name_tail_lift_delta) -> float
= default_subtag_y + name_tail_lift_delta(三子标签整体平移同一段)。
头部 REF 段补 ArrangeTextTail :211/:228/:258/:283 条目;seam ⑨ 标「已收口」、
seam ⑦ 标「部分收口 · 增量 119」(纵向跟随已 1:1,逐像素水平排版仍近似)。
project/net_world.gd(改)—— const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34,
"TitleTag": 1.86, "LevelTag": 1.74}(原来散落在 _attach_nameplate 的字面量,提为单一来源);
_attach_nameplate 的 _mk_sub_tag 调用改用 SUBTAG_DEFAULT_Y[...]
_reposition_name_tag:在设完 tag.position.y 后算
lift := NameTailLayout.name_tail_lift_delta(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size)
for sub_name in SUBTAG_DEFAULT_Y: st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift)。
_reposition_name_tag 已在 _apply_chat_tail 气泡出现 / _expire_chat_tails 气泡过期两处调用,
双向覆盖。)
project/name_tail_layout_test.gd(改)—— 新增「4. seam ⑦ 收口」段:
GUILD_NAME_ABOVE_NAME_PX == 15.0name_tail_lift_deltafollows→0.352、!follows→0、
px_size 翻倍→更大);subtag_world_yGuildTag 2.34→2.692、TitleTag 1.86→2.212、
LevelTag 1.74→2.092);平移后子标签相对间距不变;lift 0 时回默认。
project/chat_test.gd(改)—— 气泡段(真 NetWorld + entity 2000)加断言:
entity 有 GuildTag / TitleTag / LevelTag;名字冒聊天气泡后 lift > 0 且三子标签
position.y == 默认 + liftregister_info_tail 覆盖成信息尾标后三子标签回默认
2.34 / 1.86 / 1.74。
测试:
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有两个;PASS=68 FAIL=0)。
验收:
已验证(离线):name_tail_lift_delta / subtag_world_y 数值;真 NetWorld 下 entity 冒
「聊天」气泡 → GuildTag / TitleTag / LevelTag 三者 position.y 各自加同一 lift、
相对间距不变;覆盖成「信息」尾标 → 三者回默认局部 y;全 68 回归。
未验证:真机 —— 远端 PC 冒气泡时子标签随名字上移贴气泡的观感、与增量 111 子标签
内容 / 颜色(RefreshTextTail / AttachTitle / AttachLevel)、增量 117 名字尾标位移的并存。
风险与依赖:
seam ⑦(收窄后剩余):子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、
称号 / 等级同水平行左偏)仍是近似;本增量只对齐纵向跟随。
seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表)。
忠于参考:子标签平移量取名字尾标的净位移(name_tail_lift_delta),与参考端「子标签
锚在已被顶过的 pTextTail->y」严格等价;不引入 poc 自造的额外偏移。
下一批次:
seam ⑩ 深化(ArrangeTextTail :151-210 item 尾标 isIn 去重叠:2D 投影后名字牌互相挤开);
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog)。
```
### C.5 批次记录 · 增量 120W3→M2 / §8.8 seam ⑩ 收口:ArrangeTextTail item 尾标去重叠 + isIn 1:1
```text
批次:增量 120 —— W3→M2 §8.8 seam ⑩ 收口:CPythonTextTail::ArrangeTextTail 的
m_ItemTextTailList 块(PythonTextTail.cpp:160-190)。逐条地面掉落名条,与其它每条比
isIn(标准 AABB 相交,:489-507),命中就把「自己」顶到对方框底下
`pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑,
单条最多下推 LimitCount=20 次防死循环。增量 116 只做了 OnRender Picking 分派(seam ⑩
的悬停强显名字部分),这次补齐「多条 item 尾标投影后互相挤开」这一 1:1 行为。
状态:✅ 完成(新增第 29 个 canonical 纯静态模块 text_tail_arrange.gdboxes_overlapisIn
+ arrange_item_tails(外层逐条 / 内层比对 / isIn 命中顶到框底下 / 从头重跑 / MAX_ITERS 封顶);
ground_items._arrange_tags 每帧把所有掉落名条世界锚点投影到屏幕、按文本字符数估框、
跑 arrange_item_tails、把被下推的屏幕 Δy 按 Label3D.pixel_size 折回世界局部 y
game_scene 把相机 getter 传进 ground_items.setup
🔎 真机目视:几件掉落物叠在一起时,名条在屏幕上依次往下错开不再糊成一团;散开 / 捡走后回位。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
UserInterface/PythonTextTail.cpp:15 const D3DXCOLOR c_TextTail_Item_Color = D3DXCOLOR(1,1,1,1)
UserInterface/PythonTextTail.cpp:153 ArrangeTextTail()
:160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条
:164 int yTemp = 5; int LimitCount = 0;
:167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对
:171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己
:176 if (LimitCount >= 20) break;
:180 if (isIn(pInsertTextTail, pCompareTextTail))
:182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp;
:184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑
:189 ++itorCompare;
UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget)
框 = [x + xStart, x + xEnd] × [y + yStart, y + yEnd](两框 Source / Target 同式);
:500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true; —— 含边界
UserInterface/PythonTextTail.cpp:810-813 尾标框(RegisterTextTail 内 GetTextSize 后):
xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize
UserInterface/PythonTextTail.cpp:567 RegisterItemTextTail(VirtualID, c_szText, pOwner)
→ RegisterTextTail(..., c_TextTail_Name_Position, c_TextTail_Item_Color) → m_ItemTextTailList.push_back
seam ⑩(收窄后剩余):参考端在屏幕像素空间跑(y 向下、GetTextSize 精确像素),poc 每物
一个 billboard Label3D —— 名条框按文本字符数 * 7px 估宽、单行 15px 估高(≠ 精确像素);
只对掉落 item 名条做,角色名字尾标不参与(与参考端 m_ItemTextTailList / m_CharacterTextTailList
分表一致)。屏幕 Δy 折世界用 name_tail_layout 的同一 pixel_size 约定。
改动:
project/text_tail_arrange.gd(新,第 29 个 canonical 纯静态模块,extends RefCounted):
const PUSH_GAP := 5.0yTemp)、const MAX_ITERS := 20LimitCount 上限);
box_start_x(w) = -w/2 - 2、box_end_x(w) = w/2 + 2、box_start_y() = -2、box_end_y(h) = h
boxes_overlap(a, b) —— isIn 的 AABB 相交(a/b = {x, y, w, h},含边界 <= / >=);
arrange_item_tails(tails: Array[{x,y,w,h}]) -> Array[float] —— 返回每条排布后的屏幕 y:
外层逐条 i,内层逐条 jj==i 跳过),boxes_overlap 命中则
ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP、j 归零、limit++limit >= MAX_ITERS 时 break
pCompare 用「已排布后」的 ys[j](与参考端共用一张链表就地改写一致)。
project/ui/ground_items.gd(改)—— const TextTailArrange = preload
const TAG_BASE_Y := 0.75 / TAG_CHAR_W_PX := 7.0 / TAG_LINE_H_PX := 15.0
var _camera_getter: Callablesetup() 尾部加可选 camera_getter 参数;
建 tag 时 tag.position.y = TAG_BASE_Y(原字面量 0.75);
_process 尾部调 _arrange_tags():取相机 → 按 vid 排序遍历掉落 → cam.unproject_position
各名条世界锚点(背面 / 无相机跳过并回默认)→ 组 {x, y, w = max(1, len*7), h = 15} →
arrange_item_tails → 各名条 position.y = TAG_BASE_Y - (arranged_y - y0) * tag.pixel_size
可见名条 < 2 时直接回默认。
project/game_scene.gd(改)—— ground_items.setup(...) 第 6 参传 func() -> Camera3D: return cam。
project/text_tail_arrange_test.gd(新)—— 常量;box_* 四边;boxes_overlapx/y 都叠→真、
对称、边界相接含边界→真、x 分离→假、y 分离→假);arrange_item_tails(单条不动、
两条不叠不动、两条同位→第 1 条顶到 121 第 2 条留 100、三条同位→[121,142,100] 且两两不叠、
40 条全同位→正常返回不死循环 & 下推量 <= MAX_ITERS*(h+gap)=340、列表顺序即
m_ItemTextTailList 顺序靠前的先让位)。
project/p2b_test.gd(改)—— 加 seam ⑩ 段:独立 mount2 / FakeClient / 真 Camera3D
单条掉落名条留默认 0.75;再加一条同位 → 靠前(vid 小)名条 position.y 被下推
(= 0.75 - (15+5)*0.005 = 0.65)、靠后留 0.75、两者局部 y 不同;把靠后一条挪远 → 两条回默认。
测试:
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS。
回归:project/*_test.gd 共 69 个全 PASS(新增 text_tail_arrange_test.gdPASS=69 FAIL=0)。
验收:
已验证(离线):isIn AABB(含边界);arrange_item_tails 逐条 / 从头重跑 / MAX_ITERS 封顶
的数值轨迹(两条同位 → [121, 100]、三条同位 → [121, 142, 100]、40 条不死循环);
真 Camera3D + 真 GroundItems 下同位两掉落名条投影后靠前一条按 (h+gap)*pixel_size 下推、
靠后留默认、拉开距离后回默认;全 69 回归。
未验证:真机 —— 几件掉落物同屏叠放时名条往下错开的观感、与增量 116 悬停强显、
拾取范围高亮(_process 里同时跑)的并存;文本框按字符数估宽 vs 参考 GetTextSize 的像素差。
风险与依赖:
seam ⑩(收窄后剩余):名条框按「字符数 * 7px」估,参考端是 GetTextSize 精确像素;
poc 是每物 billboard Label3D 折世界 y,参考端是屏幕像素空间正交文本实例。
忠于参考:只对掉落 item 名条去重叠(m_ItemTextTailList),角色名字尾标不动(分表一致);
下推公式、内层从头重跑、LimitCount=20 封顶均 1:1;屏幕 Δy 折世界沿用 name_tail_layout
既定的 pixel_size 约定,不引入 poc 自造额外系数。
每帧对所有掉落名条投影:poc 掉落物规模小可接受;真机若海量掉落需加节流 / 距离裁剪。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 深化(子标签逐像素水平排版:公会名名字上方 15px、称号 / 等级同水平行按文本宽左偏);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog)。
```
### C.5 批次记录 · 增量 121W3→M2 / §8.8 seam ⑦ 水平深化:ArrangeTextTail 角色块子标签逐像素水平排版 1:1)
```text
批次:增量 121 —— W3→M2 §8.8 seam ⑦ 水平深化:CPythonTextTail::ArrangeTextTail 的
m_CharacterTextTailList 块(PythonTextTail.cpp:211-320)非 EUROPE 分支的公会名 / 称号 /
等级 / 名字四个文本实例逐像素水平排布。增量 119 已收口这三个子标签「随名字尾标纵向位移」,
这次补齐「称号 / 等级摆在名字锚点左侧、名字本身右移 fxAdd」这一 1:1 水平行为。
状态:✅ 完成(name_tail_layout.gd(第 28 个 canonical)加 6 个纯静态水平排版函数:
name_x_shift / title_x_offset / level_x_offset / guild_name_x_offset / mark_x_offset /
subtag_world_xnet_world._apply_text_tail_to 尾部新增 _layout_subtags_x(n) 把这些像素
偏移按各子标签 Label3D.pixel_size 折成 billboard 本地 x
🔎 真机目视:角色有称号 / 等级时,等级在最左、称号居中偏左、名字略右移;关 PVP / 撤称号后回位。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:20 const float c_fxMarkPosition = 1.5f
UserInterface/PythonTextTail.cpp:21 const float c_fyGuildNamePosition = 15.0f
UserInterface/PythonTextTail.cpp:22 const float c_fyMarkPosition = 15.0f + 11.0f = 26
UserInterface/PythonTextTail.cpp:211 for (pTextTail : m_CharacterTextTailList)
:218 CGraphicMarkInstance * pMarkInstance = pTextTail->pMarkInstance;
:220 if (pMarkInstance && pGuildNameInstance) —— 公会名水平锚点仅此路改写
:224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition;
:227 pMarkInstance->SetPosition(x - iWidth/2 - iImageHalfSize, y - c_fyMarkPosition);
:228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition, z);
:231 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight);
:236 float fxAdd = 0.0f;
:242 if (pTitle) { fxAdd = 8.0f;
:258 pTitle->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (非 EUROPE
:263 if (pLevel)
:282 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd - iTitleWidth, pTextTail->y, z);
:288 } else { fxAdd = 4.0f;
:312 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (只有等级)
:320 pTextTail->pTextInstance->SetPosition(x + fxAdd, pTextTail->y, z); —— 名字右移 fxAdd
UserInterface/PythonTextTail.cpp:555 RegisterCharacterTextTail 内公会名实例 CENTER 对齐
UserInterface/PythonTextTail.cpp:905 AttachTitle:非 EUROPE HORIZONTAL_ALIGN_CENTER
UserInterface/PythonTextTail.cpp:958 AttachLevelHORIZONTAL_ALIGN_RIGHT
seam ⑦(收窄后剩余):① poc 无公会徽记 mark 实体 —— 参考端 :220 的
`if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点根本不被 ArrangeTextTail
改写,poc 亦让 GuildTag x 偏移留 0(名字中线正上方),不做 +markW/2+1.5
② 文本宽度 poc 按「字符数 * 7px」估,参考端是 GetTextSize 精确像素;
③ 三子标签 poc 仍竖排(各自默认局部 y),只把水平 x 偏移叠上去 —— 参考端称号 / 等级
与名字同一屏幕行(3D billboard 里三条挤一行会互相糊,保留竖排 + x 偏移的折衷)。
改动:
project/name_tail_layout.gd(改,第 28 个 canonical 纯静态模块):
const NAME_X_ADD_TITLE := 8.0:242 fxAdd)、NAME_X_ADD_LEVEL_ONLY := 4.0:288 fxAdd)、
MARK_X_PAD := 1.5:20)、MARK_ABOVE_NAME_PX := 26.0:22);
name_x_shift(has_title, has_level) -> 8 / 4 / 0:320 名字右移量);
title_x_offset(name_w) -> -(name_w/2) - 8:258,相对名字锚点 x 的像素偏移,负 = 左);
level_x_offset(name_w, title_w, has_title) -> 有称号 -(name_w/2) - 8 - title_w:282/
只有等级 -(name_w/2) - 4:312);
guild_name_x_offset(mark_w) -> mark_w/2 + 1.5:228,仅有徽记时用);
mark_x_offset(guild_w, mark_w) -> -(guild_w/2) - (mark_w/2 + 1.5):227);
subtag_world_x(px_offset, px_size) -> px_offset * px_size(屏幕 +x → billboard 本地 +x
按该 Label3D pixel_size 折世界)。
project/net_world.gd(改):
const SUBTAG_CHAR_W_PX := 7.0(字符数估宽,与 ground_items TAG_CHAR_W_PX 同源);
_apply_text_tail_to 尾部调新增 _layout_subtags_x(n, ttag, ltag)
has_title / has_level = 对应子标签 text != ""name_w / title_w 按字符数 * 7 估;
名字 Label3D.position.x = subtag_world_x(name_x_shift(has_title, has_level), px)
TitleTag.position.x = subtag_world_x(title_x_offset(name_w), px) 若有称号否则 0
LevelTag.position.x = subtag_world_x(level_x_offset(name_w, title_w, has_title), px) 若有等级否则 0
GuildTag.position.x = 0poc 无徽记 -> 参考端 :220 此路不改写)。
—— 纵向位移(_reposition_name_tag,增量 117/119)与横向排布(_layout_subtags_x,本增量)
互不干扰:前者只写 .position.y,后者只写 .position.x。
project/name_tail_layout_test.gd(改)—— 加 §5:6 个水平函数的常量 / 公式 / 符号 / 线性缩放
断言 + 组合「等级(-58) < 称号(-28) < 名字(+8)」水平顺序。
project/chat_test.gd(改)—— FakeClient 加 get_guild_name;新增 charlievid 3000
guild 7 / alignment 15000grade 0 有称号)/ level 42):_apply_text_tail 后断言
名字 x = 8*px、称号 x < 0、等级 x < 称号 x、GuildTag x = 0、称号 x 精确
= (-49/2 - 8)*px;再把 alignment 改 0grade 4 = TITLE_NONE-> 称号 x 归零、
名字 x 变 4*px、等级 x 精确 = (-49/2 - 4)*px。
测试:
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 name_tail_layout_test / chat_test
PASS=69 FAIL=0)。
验收:
已验证(离线):fxAdd 三态(0 / 4 / 8);称号 = -(nameW/2) - 8、等级(有称号)再 - titleW、
等级(只有等级)= -(nameW/2) - 4 的像素公式;subtag_world_x 线性且保符号;
真 net_world + 真实体:有称号时名字右移 8*px / 称号在左 / 等级更左 / GuildTag 不动,
撤称号后称号归零、名字改右移 4*px;全 69 回归。
未验证:真机 —— 角色同屏带称号 / 等级 / 公会名时的水平观感、与增量 116 悬停强显、
增量 117/119 气泡上移的并存;字符数估宽 vs GetTextSize 的像素差;公会徽记缺失下
公会名不做 +markW/2+1.5 偏移的观感差异。
风险与依赖:
seam ⑦(收窄后剩余):poc 无公会徽记 mark 实体(参考端 :220 gate)、文本宽按字符数 * 7px
估(≠ GetTextSize 精确像素)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
忠于参考:fxAdd 8 / 4 / 0、称号 / 等级的减项、名字右移 fxAdd、公会名仅有徽记时改写
均 1:1;屏幕像素 → 世界本地 x 沿用 name_tail_layout 既定 pixel_size 约定,不引入额外系数。
纵横解耦:_layout_subtags_x 只写 .position.x_reposition_name_tag 只写 .position.y
_apply_text_tail_to 每次(含 ~2Hz 兜底重染)重算 x,与 level / title / guild 变化同步。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
文本宽换真实字体度量);
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
LCONTROL 悄悄话捷径接 whisper dialog)。
```
### C.5 批次记录 · 增量 122W3→M2 / §5.4 · §8.8 seam ⑫ 收口:目标框点击 LCONTROL 悄悄话捷径接 whisper 1:1
```text
批次:增量 122 —— W3→M2 §5.4 / §8.8 seam ⑫ 收口:root/game.py CGameWindow::SetPCTargetBoard
:804-817)里「targetBoard.Open 之后,若按住 LCONTROL 且是同阵营的他人 PC(非本人、
非 building)→ interface.OpenWhisperDialog(name)」这条捷径接进 poc。谓词
target_board.whisper_on_ctrl_click 增量 118 已备好没接,这次是纯接线:net_play 侧加
一个信号 + 一个按键态 + _on_pick 里 KIND_PC 分支的轮询;game_scene 侧转 chat.start_whisper。
状态:✅ 完成(net_play.gdsignal whisper_requested(name)、var _lcontrol_down、
set_lcontrol_down(down)、_on_pick 尾部 KIND_PC 分支调
TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false)
真则 whisper_requested.emit(_entity_name(e))game_scene.gd_unhandled_input 加
KEY_CTRL 按下 / 松开 → net_play.set_lcontrol_down(e.pressed)chat 就绪后
net_play.whisper_requested → chat.start_whisper
🔎 真机目视:按住 Ctrl 点同帝国队友 → 聊天输入框预填 "/w <名> " 并抢焦点;
点异帝国 / 点自己 / 点怪 / 没按 Ctrl → 只普通选中,不预填。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/ + assets/root/game.py):
assets/root/game.py:94 self.targetBoard.SetWhisperEvent(ui.__mem_func__(self.interface.OpenWhisperDialog))
assets/root/game.py:804 def SetPCTargetBoard(self, vid, name):
:805 self.targetBoard.Open(vid, name)
:807 if app.IsPressed(app.DIK_LCONTROL):
:809 if not player.IsSameEmpire(vid): return
:812 if player.IsMainCharacterIndex(vid): return
:814 elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return
:817 self.interface.OpenWhisperDialog(name)
—— C++ picking 只对 PC 命中派发 SetPCTargetBoard(怪 / NPC 走别的),故 poc 也只在
_entity_kind(e) == KIND_PC 分支查这条捷径。
seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型
_entity_kind 只到 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building
实参恒传 false —— 参考端 :814 INSTANCE_TYPE_BUILDING 分支在 poc 永不触发;门实体
做出来后再把 GetInstanceType 分类补上。另 poc「悄悄话对话框」= 聊天输入预填 "/w 名 "
chat_ui.start_whisper),非独立 whisper dialog 窗口(seam:无独立 whisper UI)。
改动:
project/net_play.gd(改):
signal whisper_requested(name: String)game.py:817 OpenWhisperDialog 的 poc 通道);
var _lcontrol_down := falsegame.py:807 app.IsPressed(app.DIK_LCONTROL) 的按住态);
func set_lcontrol_down(down)game_scene 的 KEY_CTRL 按下 / 松开喂);
_on_pick_change_target_to_picked_instance(vid) 之后,若 _entity_kind(e) == KIND_PC
same_empire = EntityRules._is_same_empire(_main_entity(), e)
TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false)
为真且 _entity_name(e) != "" → whisper_requested.emit(name)。
project/game_scene.gd(改):
_unhandled_input 在 KEY_SHIFT 块后加 KEY_CTRLnet_play.set_lcontrol_down(e.pressed)
后 return(一处同时处理按下 / 松开,对齐参考端 IsPressed 轮询语义);
chat 装配处(fishing_feedback 接线之后):net_play.whisper_requested → chat.start_whisper。
project/target_board.gd(改):头注 seam ⑫ 段更新 —— whisper_on_ctrl_click 增量 122
已接线(net_play._on_pick → whisper_requested → game_scene → chat.start_whisper),
is_building 仍恒 false(门实体缺,seam ⑫ 余留)。
project/netplay_test.gd(改)—— 加 8c)seam ⑫ 段(无新增测试文件):
连 np.whisper_requested 收集 whispers;主角 empire=1
Bobvid 6100 / ch_type 0 / empire 1):没按 Ctrl 点 → whispers 空但 set_target 6100
按 Ctrl 点 → whispers == ["Bob"];改 empire=2 再点 → 空(:809 IsSameEmpire);
Wolf2ch_type 2)按 Ctrl 点 → 空(仅 PC 分派)。
测试:
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 netplay_testPASS=69 FAIL=0)。
验收:
已验证(离线):按住 LCONTROL 点同阵营他人 PC → whisper_requested(name);无 LCONTROL /
异阵营 PC / 非 PC(怪)→ 不发;点 PC 的普通选中(set_target)不受影响;
KEY_CTRL 按下 / 松开正确翻转 _lcontrol_down;全 69 回归。
未验证:真机 —— Ctrl+点队友后聊天框预填 "/w 名 " 的观感、与增量 116 悬停强显 /
增量 118「看他国框」前置过滤的并存;building 实体缺失下 :814 分支在 poc 恒不触发。
风险与依赖:
seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型 → is_building
实参恒 false:814 分支 poc 永不触发)、can_view 的 is_wooden_door 恒 false
poc 悄悄话 = 聊天输入预填,非独立 whisper dialog 窗口。
忠于参考:LCONTROL 轮询 + IsSameEmpire + IsMainCharacterIndex + 非 building 四条与
:807-817 一致;只对 PC 命中查(C++ picking 分派边界);谓词 whisper_on_ctrl_click
增量 118 已定,本批不改。
低风险:仅新增一条信号 + 一个按键态 + 一处 KIND_PC 分支判断 + 一处信号转接,
不触碰移动 / 攻击 / 目标框收包路径;纯 GDScript。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
文本宽换真实字体度量);
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType / IsWoodenDoor / IsBuilding 分类,
待门实体做出来);
或独立 whisper dialog 窗口(uiWhisper.py,替换聊天输入预填)。
```
### C.5 批次记录 · 增量 123W3→M2 / §8.8 seam ⑦ · ⑩ 收口:头顶子标签 / 掉落名条文本宽换真实字体度量 GetTextSize 1:1
```text
批次:增量 123 —— W3→M2 §8.8 seam ⑦ / ⑩ 收窄:ArrangeTextTailPythonTextTail.cpp)里
iNameWidth / iTitleWidth / iLevelWidth / 掉落名条框 xSize 参考端全部来自
CGraphicTextInstance::GetTextSize(&w, &h)——逐字形排版度量,非「字符数 * 定宽」。
poc 之前 net_world.SUBTAG_CHAR_W_PX / ground_items.TAG_CHAR_W_PX 都是 7.0 的粗估
(增量 120/121 遗留 seam)。这次抽一个纯静态 text_metrics.gd 包 Font.get_string_size()
做 1:1 度量,两处调用换成真实像素宽,无字体(headless / 未配主题)时回退旧估宽。
状态:✅ 完成(新增 project/text_metrics.gd 第 30 个 canonical 纯静态模块 +
project/text_metrics_test.gdnet_world._layout_subtags_x 的 name_w / title_w、
ground_items._arrange_tags 的框宽 w 换 TextMetrics.label_width
🔎 真机目视:带称号 / 等级的窄名与宽名头顶,称号 / 等级左偏量随真实名宽变化(长名推得更远);
多个长短不一的掉落名条重叠时按真实文本框挤开,短名不再被按 7px/字 高估的框推开。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight)
UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight)
UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight)
UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支)
UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支)
UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize)
UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)SetTextTailOwnerText
UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)SetTextTailTextitem 框尺寸)
—— GetTextSize 转发到字体实际字形度量(含字距 / 非等宽),Godot Font.get_string_size()
text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)是 1:1 对应物。
改动:
project/text_metrics.gd(新,纯静态第 30 个 canonical):
const FALLBACK_CHAR_W_PX := 7.0(=旧 SUBTAG/TAG_CHAR_W_PX,仅无字体兜底);
text_size(font, text, font_size) -> Vector2:空串 / font null / font_size<=0 → ZERO
否则 font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
width_of(font, text, font_size, fallback_char_px=7.0) -> float:空串 → 0
量得正宽 → 精确像素;否则 → text.length() * fallback_char_px
label_width(label: Label3D, fallback_char_px=7.0) -> floatnull → 0
f = label.font ?? ThemeDB.fallback_font,再 width_of(f, label.text, label.font_size, …)。
project/net_world.gd(改):const TextMetrics = preload(...)_layout_subtags_x 里
name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))、
title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX))
(原 float(text.length()) * SUBTAG_CHAR_W_PX);SUBTAG_CHAR_W_PX 常量保留作兜底。
project/ui/ground_items.gd(改):const TextMetrics = preload(...)_arrange_tags 里
box.w = maxf(1.0, TextMetrics.label_width(tag, TAG_CHAR_W_PX))
(原 tag.text.length() * TAG_CHAR_W_PX);TAG_CHAR_W_PX 常量保留作兜底。
name_tail_layout.gd / text_tail_arrange.gd 不动——它们收 name_w / title_w / w 作入参,
宽度来源换掉即可,纯静态排布公式(增量 119/120/121)不受影响。
测试:
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS。
回归:project/*_test.gd 共 70 个全 PASS(新增 text_metrics_testPASS=70 FAIL=0)。
验收:
已验证(离线):text_size 退化输入(空 / null / 非法字号)恒 ZERO;有 ThemeDB.fallback_font
时度量为正、随串长单调增、随字号单调增;width_of 空串 0、无字体回退 len*char_px、
有字体 == text_size.x、font_size<=0 回退;label_width 从 Label3D.font(未设回落
fallback_font+ font_size 量、null → 0、空文本 → 0net_world / ground_items 两处
调用编译通过、全 70 回归(含 netplay_test 目标 HUD、net_world_vis、gamescene)。
未验证:真机 —— 头顶称号 / 等级左偏量随真实名宽变化的观感、长短名条混排时按真实框挤开、
非拉丁字体 / CJK 名下的度量(ThemeDB.fallback_font 与打包字体可能不同字形宽)。
风险与依赖:
seam ⑦(收窄后剩余):文本宽已换 GetTextSize 1:1;仍留 —— poc 无公会徽记 mark 实体
(参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
seam ⑩(收窄后剩余):名条框宽已换 GetTextSize 1:1、单行高仍按 TAG_LINE_H_PX 15px 估
(参考端 ySize 亦来自 GetTextSize);只对掉落 item 名条做(角色名字尾标分表不参与,同参考)。
忠于参考:get_string_size 的 alignment/width/-1/font_size 参数对齐 GetTextSize 语义;
无字体路径的 7.0 与增量 120/121 的旧估宽数值一致,回退不改变既有 headless 行为。
低风险:新增一个纯静态模块 + 两处「宽度来源」替换,未碰排布公式 / 信号 / 收包路径;纯 GDScript。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
单行高亦走 GetTextSize);
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
或独立 whisper dialog 窗口(uiWhisper.py)。
```
### C.5 批次记录 · 增量 124W3→M2 / §8.8 seam ⑦ · ⑩ 末尾:掉落名条框「单行文本高」换真实字体度量 GetTextSize 的 h 1:1
```text
批次:增量 124 —— W3→M2 §8.8 seam ⑦ / ⑩ 末尾收窄:ArrangeTextTail item 尾标去重叠
用的框 yEnd = ySizePythonTextTail.cpp:749 / :809),ySize 与 xSize 一样来自
CGraphicTextInstance::GetTextSize(&xSize, &ySize)。增量 123 已把框宽 w 换成真实
Font.get_string_size().x,这次把框高 h 也换成 .y —— poc 之前 ground_items 里框高恒
用 TAG_LINE_H_PX = 15.0 粗估(增量 120 遗留)。text_metrics.gd 增 height_of /
label_height 两个纯静态方法(无新增 canonical,扩第 30 个),ground_items._arrange_tags
的 box.h 换 TextMetrics.label_height(tag, TAG_LINE_H_PX),无字体时回退 15.0。
状态:✅ 完成(project/text_metrics.gd 增 const FALLBACK_LINE_H_PX := 15.0 + height_of()
+ label_height()project/ui/ground_items.gd _arrange_tags 的 box "h" 换真实度量;
text_tail_arrange.gd 不动——它收 box h 作入参,box_end_y(h) = h 已对齐参考 yEnd = ySize
🔎 真机目视:长短名混排的掉落名条重叠时,按真实单行文本高(≈ 字体行高,非 15px)挤开,
竖直堆叠间距随打包字体行高一致;单条名条与多条时观感不变。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:749 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize)
SetTextTailOwnerText else 分支)
UserInterface/PythonTextTail.cpp:751 pTextTail->yEnd = (float) ySize;
UserInterface/PythonTextTail.cpp:809 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize)
__CreateCharacterTextTail / SetTextTailText
UserInterface/PythonTextTail.cpp:812 pTextTail->yEnd = (float) ySize;
UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y +
pCompareTextTail->yEnd + yTemp; —— 去重叠顶到框底
—— ySize 是单行 text instance 的字体行高(ascent + descent),与字形无关;
Godot Font.get_string_size(text, …, font_size).y 是 1:1 对应物。
改动:
project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件):
const FALLBACK_LINE_H_PX := 15.0(=旧 ground_items.TAG_LINE_H_PX,仅无字体兜底);
height_of(font, text, font_size, fallback_line_px=15.0) -> float:空串 → 0
量得正高(text_size(...).y)→ 精确像素;否则 → fallback_line_px
label_height(label: Label3D, fallback_line_px=15.0) -> floatnull → 0
f = label.font ?? ThemeDB.fallback_font,再 height_of(f, label.text, label.font_size, …)。
头注释 + seam 注释同步补「高 h」;REF 段补 :749 / :751 / :809 / :812 的 yEnd = ySize。
project/ui/ground_items.gd(改):_arrange_tags 里 box
"h": maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))
(原 "h": TAG_LINE_H_PX);TAG_LINE_H_PX 常量注释改为「无字体时的单行估高兜底」;
头注释「按文本字符数估文本框」→「按 Label3D 的真实字体度量取文本框」、
「文本框宽取…」→「文本框宽 / 高取…label_width / label_height…增量 123 / 124」。
text_tail_arrange.gd / name_tail_layout.gd 不动。
测试:
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS
+「5. height_of / label_height」段:FALLBACK_LINE_H_PX = 15.0;空串 → 0;无字体回退 15;
有字体高为正、与串内容无关(h("x") == h("WWWWWWWWWW"))、随字号单调、== text_size.y
label_height(null) → 0、回落 fallback_font、空 Label3D → 0)。
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件,扩 text_metrics_testPASS=70 FAIL=0)。
验收:
已验证(离线):height_of 退化输入(空 / 无字体 / font_size<=0)走回退;有 ThemeDB.fallback_font
时高为正、随字号单调增、与具体字形无关(单行行高)、== text_size.ylabel_height 从
Label3D.font(未设回落 fallback_font+ font_size 量、null / 空文本 → 0ground_items
_arrange_tags box.h 编译通过、全 70 回归(含 p2b_test 的真 GroundItems 去重叠段)。
未验证:真机 —— 打包字体行高与 ThemeDB.fallback_font 行高差异下的竖直堆叠观感;
带 owner 后缀换行的多行名条(poc 当前是单行 "name (owner)",参考端 owner 是独立 instance
且 yEnd += yOwnerSize + 4:733-736 —— poc 未拆分,仍按单行处理)。
风险与依赖:
seam ⑦ / ⑩(收窄后剩余):文本宽 w(123)+ 高 h124)均已换 GetTextSize 1:1;仍留 ——
poc 无公会徽记 mark 实体(参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷
(≠ 参考同一屏幕行)、掉落名条为唯一 item 尾标来源(owner 未拆成独立 instance)、
去重叠只对掉落 item 名条(角色名字尾标分表不参与,同参考)。
忠于参考:get_string_size(...).y 对齐 GetTextSize 的 ySize 语义(单行字体行高);
无字体路径的 15.0 与增量 120 的旧估高一致,回退不改变既有 headless 行为。
低风险:扩一个纯静态模块的两个方法 + 一处「框高来源」替换,未碰排布公式 / 去重叠算法 /
信号 / 收包路径;纯 GDScript。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
seam ⑩ 末尾(掉落名条 owner 拆成独立 text instance + yEnd += yOwnerSize + 4);
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
或独立 whisper dialog 窗口(uiWhisper.py)。
```
### C.5 批次记录 · 增量 125W3→M2 / §8.8 seam ⑩ 末尾:掉落名条去重叠框的 owner 后缀按参考 SetItemTextTailOwner「owner 独立度量 + yEnd += yOwnerSize + 4」1:1
```text
批次:增量 125 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端
CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)PythonTextTail.cpp:706-737
把 owner 后缀挂成一个独立的 CGraphicTextInstance(文本 = c_szName + strOwnership
strOwnership 在 IDS_POSSESSIVE_MORPHENE 为空时取字面 "'s";黄 1,1,0 / CENTER),
与名字实例分开 GetTextSize(&xOwnerSize, &yOwnerSize),再据此把去重叠框扩张:
:735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4
:736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1)
:737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1)
poc 之前 ground_items 只按合并串 "name (owner)" 的整条 Label3D 量一个框(增量 123/124
换了真实字体度量,但没按参考把 owner 拆出来单独量、也没有 yEnd += yOwnerSize + 4)。
这次把去重叠框的度量改为「名字子串」「owner + 所有格子串」分别度量后合成,代入 poc 的
{w,h} 框模型:h = name_h + owner_h + 4w = max(name_w, owner_w - 2)(令 box_end_x(w)
= w/2+2 恰好等于参考 :737 的 fMAX(name/2+2, owner/2+1),框 x 两边对称故 box_start_x
自动 = :736 的 fMIN)。无 owner → 退化为纯名字框,与参考 else 分支(删 owner 实例、
框复位 xEnd = w/2+2 / yEnd = h)一致。
状态:✅ 完成(text_tail_arrange.gd 增纯静态 item_box_with_owner()text_metrics.gd 增
string_width_like / string_height_like(用某 Label3D 的字体量任意子串);ground_items
_on_added 存 item_name / item_owner meta_arrange_tags 用两子串分别度量走
item_box_with_owner 合成去重叠框。均无新增 canonical / 测试文件——扩第 29、第 30 个
canonical 与其 test,另扩 p2b_test
🔎 真机目视:地面上有主人的掉落物,其名条与相邻名条重叠时,按「名字行 + owner 行 + 4px」
的总高把靠前名条挤开(比无主掉落挤得更远);owner 串比名字宽时框左右也相应外扩。
渲染仍是单个合并 "name (owner)" billboard Label3Dseam ⑩ 记录在案的折中,未拆成
两个 text instance / 未做 CENTER 黄字的 owner 行)。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName)
UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0) { ... pOwnerTextInstance = CGraphicTextInstance::New(); }
UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
UserInterface/PythonTextTail.cpp:723 strName += strOwnership;
UserInterface/PythonTextTail.cpp:725 pOwnerTextInstance->SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)
UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄
UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize)
UserInterface/PythonTextTail.cpp:734 pTextTail->yStart = -2.0f;
UserInterface/PythonTextTail.cpp:735 pTextTail->yEnd += float(yOwnerSize + 4);
UserInterface/PythonTextTail.cpp:736 pTextTail->xStart = fMIN(pTextTail->xStart, float(-xOwnerSize / 2 - 1));
UserInterface/PythonTextTail.cpp:737 pTextTail->xEnd = fMAX(pTextTail->xEnd, float(xOwnerSize / 2 + 1));
UserInterface/PythonTextTail.cpp:739-751 else 分支:Delete(pOwnerTextInstance);框复位
xStart=-xSize/2-2 / yStart=-2 / xEnd=xSize/2+2 / yEnd=ySize
UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp;
改动:
project/text_tail_arrange.gd(改,扩第 29 个 canonical,不新增文件):
item_box_with_owner(name_w, name_h, owner_w := 0.0, owner_h := 0.0) -> Dictionary
owner_w <= 0 或 owner_h <= 0 → {"w": name_w, "h": name_h}(无 owner / 参考 else 分支);
否则 → {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0}。
头注释 REF 段补 :706-737 的 SetItemTextTailOwner 公式与 {w,h} 推导。
project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件):
string_width_like(label: Label3D, text, fallback_char_px=7.0) -> float
label == null → text.length() * fallback_char_px;否则 width_of(label.font ??
ThemeDB.fallback_font, text, label.font_size, …)。
string_height_like(label: Label3D, text, fallback_line_px=15.0) -> float
text == "" → 0label == null → fallback_line_px;否则 height_of(…同上…)。
—— 让 ground_items 能用名条 Label3D 的字体分别量「名字子串」「owner 子串」
(对应参考端两个独立 text instance 各自 GetTextSize)。头注释补增量 125。
project/ui/ground_items.gd(改):
const OWNER_POSSESSIVE := "'s"(= SetItemTextTailOwner :722 strOwnership 字面值);
_on_added:新建 / 复用 drop 节点时都 set_meta("item_name", _name_for(vnum)) +
set_meta("item_owner", owner)
_arrange_tagsbox 不再对整条 tag 量一个框,改为
iname = meta item_nameiowner = meta item_ownerowner_txt = iowner + "'s"(空则 "");
nbox = TextTailArrange.item_box_with_owner(
TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX),
TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX),
TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX))
boxes.append({x, y, w: maxf(1, nbox.w), h: maxf(1, nbox.h)})。
头注释补增量 125(owner 拆两子串度量、渲染合并是折中)。
name_tail_layout.gd / text_tail.gd 等其它 canonical 不动。
测试:
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS
+「5. item_box_with_owner」段:无 owner / owner_w<=0 / owner_h<=0 → 退化纯名字框;
有 owner → h = name_h + owner_h + 4owner 窄 → w = name_w 且 box_end_x = nameW/2+2
owner 宽 → w = owner_w - 2 且 box_end_x = ownerW/2+1(对齐参考 :737 fMAX);
顶到带 owner 框底下 → push = (name_h+owner_h+4) + PUSH_GAP)。
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS
+「6. string_width_like / string_height_like」段:label null → 字符数*px / 单行估高;
空串 → 0;有 fallback_font 时量的是给定子串(≠ label.text)、随子串长单调、== text_size(子串))。
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS
FakeClient.add_ground 增可选 owner 入参;新增 mount3/fc3/gi3 段:301 无 owner 靠前、
302 带 owner "Alice" 靠后同位 → 无字体度量下 302 框 yEnd = 15+15+4 = 34,靠前 301 被顶
(34+5)*0.005 = 0.195> 无 owner 的 0.1);302 留默认;t302.text == "掉落28 (Alice)"
证明渲染仍单合并 Label3D)。
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。
验收:
已验证(离线):item_box_with_owner 纯函数在「无 owner 退化 / 有 owner 加高 / owner 更宽时
收进 ownerW/2+1」三路径与参考 :735-737 逐式对齐;string_*_like 在 label null / 空串 /
有子串三路径正确、量的是给定子串而非 label.textground_items._arrange_tags 对带 owner 的
掉落把去重叠框按「名字行 + owner 行 + 4」加高(p2b_test 真 GroundItems 段);全 70 回归。
未验证:真机 —— owner 行未真正独立渲染成 CENTER 黄字(poc 仍单条 "name (owner)"),
故只有「去重叠框度量」1:1、owner 行的视觉呈现仍是折中;strOwnership 只取了 "'s"
字面值(未接 ApplicationStringTable IDS_POSSESSIVE_MORPHENE 的本地化语素)。
风险与依赖:
seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120+ 框宽 w123+ 框高 h124
+ owner 后缀框度量(125)均已 1:1;仍留 —— owner 未拆成独立 text instance(渲染仍单个
合并 "name (owner)" Label3D,无 CENTER 黄字 owner 行)、strOwnership 未接本地化语素、
去重叠只对掉落 item 名条。
忠于参考:{w,h} 推导对 :736/:737 的 fMIN/fMAX 逐式验证(框 x 对称 → box_start_x 免算);
无字体路径沿用 TAG_CHAR_W_PX 7.0 / TAG_LINE_H_PX 15.0,回退不改既有 headless 行为。
低风险:扩两个纯静态模块各 1-2 个方法 + ground_items 一处「框度量来源」替换 + 存两个 meta;
未碰排布公式 / 去重叠算法 / 信号 / 收包路径 / 渲染节点结构;纯 GDScript。
下一批次:
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素);
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
或独立 whisper dialog 窗口(uiWhisper.py)。
```
### C.5 批次记录 · 增量 126W3→M2 / §8.8 seam ⑩ 末尾:带 owner 的掉落名条把 owner 后缀拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素)
```text
批次:增量 126 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端
CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)PythonTextTail.cpp:712-745)把 owner
后缀挂成「一个与名字实例分开的 CGraphicTextInstance」(:715 New),而不是拼进名字串:
:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
:723 strName = c_szName + strOwnership
:726 SetTextPointer(ms_pFont)
:727 SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER)
:729 SetColor(1.0f, 1.0f, 0.0f) —— 黄
排布(ArrangeTextTail :191-207,去重叠内层循环之后、对每条 item 尾标都跑):
:195 pInsertTextTail->pOwnerTextInstance->SetPosition(x, y, z) —— owner 落尾标基点
:200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z) —— 名字实例下移 15px
:206 (无 owner 时)pTextInstance->SetPosition(x, y, z)
owner 为空(:739-745):CGraphicTextInstance::Delete(pOwnerTextInstance)、指针置 NULL、
框复位纯名字(增量 125 已实现的 else 分支)。
渲染(Render :369-374):item 尾标先画名字实例、再画 pOwnerTextInstance。
poc 之前(增量 125):owner 只是拼进 "name (owner)" 的单个合并 Label3D,去重叠框按两子串
分别度量合成(yEnd += ownerH + 4),但 owner 没有自己的 text instance、没有 CENTER 黄字、
名字实例也没有下移 15px。这次把 owner 拆出来。
状态:✅ 完成(ground_items 加 owner_tag(第二个 Label3D+ _apply_owner_tag 建/删 +
_place_tail 统一落点 + setup(possessive) 注入;game_scene 从 EterLocale 解析
POSSESSIVE_MORPHENE 注入。无新增 canonical / 测试文件——扩 ground_items 与 p2b_test
🔎 真机目视:地面上有主人的掉落物,主人名(如 "Alice's")以黄色、居中显示在道具名的
上方一行(间距 15px),道具名本身在下;无主掉落只有道具名、位置不变;多条名条重叠时按
「owner 行 + 名字行 + 4px」的总高把靠前名条挤开(增量 125 的框度量不变)。
剩余折中:owner 行颜色恒黄(参考名字实例本身取 pInsertTextTail->Color 的道具品阶色,
poc 名字仍走 _process 的远近绿/金着色近似);去重叠只对掉落 item 名条做。
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName)
UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0)
UserInterface/PythonTextTail.cpp:715 pTextTail->pOwnerTextInstance = CGraphicTextInstance::New();
UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
UserInterface/PythonTextTail.cpp:723 strName += strOwnership;
UserInterface/PythonTextTail.cpp:726 pOwnerTextInstance->SetTextPointer(ms_pFont)
UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER)
UserInterface/PythonTextTail.cpp:728 pOwnerTextInstance->SetValue(strName.c_str())
UserInterface/PythonTextTail.cpp:729 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄
UserInterface/PythonTextTail.cpp:741-745 elseCGraphicTextInstance::Delete(pOwnerTextInstance)pOwnerTextInstance = NULL
UserInterface/PythonTextTail.cpp:193-196 ArrangeTextTailpOwnerTextInstance->SetPosition(x, y, z)Update()
UserInterface/PythonTextTail.cpp:200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z)
UserInterface/PythonTextTail.cpp:206 else,无 ownerpTextInstance->SetPosition(x, y, z)
UserInterface/PythonTextTail.cpp:369-374 Renderitem 尾标先画 pTextInstance、再画 pOwnerTextInstance
UserInterface/UserInterface.rc:33/56/237 IDS_POSSESSIVE_MORPHENE "'s"(西文 .rc 的字面值)
UserInterface/resource.h:6 #define IDS_POSSESSIVE_MORPHENE 2
改动:
project/ui/ground_items.gd(改):
const OWNER_NAME_GAP_PX := 15.0(= ArrangeTextTail :200 的 y + 15.0f);
var _possessive := OWNER_POSSESSIVE
setup(..., possessive := "")_possessive = possessive if possessive != "" else
OWNER_POSSESSIVE1:1 对齐 :722 的 == "" ? "'s" : loc);
_apply_owner_tag(node, owner)(新):owner 空 → 有 owner_tag 则 remove_child + queue_free
:741-745);否则懒建 Label3D "owner_tag"name / horizontal_alignment =
HORIZONTAL_ALIGNMENT_CENTER:727/ billboard = BILLBOARD_ENABLED / no_depth_test /
pixel_size 0.005 / modulate = Color(1,1,0):729/ position.y = TAG_BASE_Y),
文本 = owner + _possessive:723);
_on_added:名字 tag.text = _name_for(vnum)(只放名字,不再 _tag_text 拼 "name (owner)"),
新建 / 复用两条路径都调 _apply_owner_tag(node|existing, owner)
_place_tail(n, tag, d_px)(新):owner_tag 存在 → owner_tag.position.y = TAG_BASE_Y -
d_px*px、tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX)*px:195 / :200);
否则 tag.position.y = TAG_BASE_Y - d_px*px:206);
_arrange_tagsbehind-camera / 单条 / 多条排布三处落点都改走 _place_tailtags 条目多存 "n");
box 构建里 owner_txt 用 _possessive(原 OWNER_POSSESSIVE 常量);
_tag_text 删除(不再合并渲染);头注释补增量 126。
project/game_scene.gd(改):ground_items.setup 前,assets_root != "" 时用 EterLocale
(load("res://locale.gd")) 解析 POSSESSIVE_MORPHENE 键(缺则 ""),作第 7 参 possessive 注入
(对应 :722 ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE))。
text_tail_arrange.gd / text_metrics.gd 等 canonical 不动(增量 125 的 item_box_with_owner /
string_*_like 仍是去重叠框度量来源,未改)。
测试:
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS
seam ⑩ 末尾段重写:drop_302owner "Alice")——名字实例 t302.text == "掉落28"、
独立 owner_tag 存在且 text == "Alice's" / horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER
/ modulate ≈ Color(1,1,0) / position.y ≈ TAG_BASE_Y:195)、名字实例
position.y ≈ TAG_BASE_Y - 15 * pixel_size:200);drop_301(无 owner)不建 owner_tag
:741-745)、仍被带 owner 的框顶 (15+15+4+5)*0.005 = 0.195(增量 125 框高不变);
新增 mount4/gi4setup(possessive="@") → drop_401 owner_tag.text == "Bob@")。
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS(未改,item_box_with_owner 不变)。
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS(未改)。
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。
验收:
已验证(离线):owner 非空 → drop 节点下确实多出独立 Label3D "owner_tag"CENTER / 黄 /
文本 = owner + strOwnership),名字实例只剩名字且下移 15px;owner 为空 → 不建 owner_tag、
名字实例落基点;possessive 经 setup 注入生效("@" → "Bob@"),未注入时回落 "'s"
去重叠框高仍是增量 125 的 name_h + owner_h + 4p2b_test 靠前名条被顶量不变);全 70 回归。
未验证:真机 —— owner_tag 的 CENTER 对齐 / 黄色 / 15px 行距的实际视觉;EterLocale
POSSESSIVE_MORPHENE 键在 poc 资源里不存在,故实际注入值恒 "'s"(与 40250 西文 .rc 一致,
但没有真正跑通「本地化语素非空」分支)。
风险与依赖:
seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120+ 框宽 w123+ 框高 h124
+ owner 后缀框度量(125+ owner 独立 CENTER 黄字 text instance + strOwnership 接本地化语素
(126)均已 1:1;仍留 —— owner 行颜色恒黄(参考名字实例取 pInsertTextTail->Color 的道具
品阶色,poc 名字走 _process 远近着色近似)、去重叠只对掉落 item 名条做。
忠于参考:owner 落点 (x, y):195)、名字落点 (x, y + 15.0f):200)逐式对应;CENTER:727
/ 黄(:729/ 文本 = owner + strOwnership:723)逐属性对应;owner 空删实例(:743)对应
remove_child + queue_freestrOwnership 的 == "" ? "'s" : loc:722)对应 setup 里的
possessive if possessive != "" else OWNER_POSSESSIVE。
低风险:只改 ground_items(渲染节点多一个子 Label3D + 落点函数)+ game_scene 一处注入 +
p2b_test;未碰去重叠算法 / 排布公式 / 信号 / 收包路径 / 其它 canonical;纯 GDScript。
节点结构变化(drop 节点多 owner_tag 子节点)仅 ground dropshover_at / try_pickup 用
节点自身 global_position 不受影响。
下一批次:
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
完整 Quest / fishing / dungeon / mount / observer 玩法闭环与复杂 UI 分支;
真实服务器、真机、纯 pack、双架构和正式签名验收。
```
### C.5 批次记录 · 增量 127(W3→M2 / §8.8 屏幕空间血条、实体建筑分类、掉落 owner 品阶色、独立 whisper dialog
```text
批次:增量 127 —— 收口当前可在本地完成的四项尾部缺口,并同步修正过时的 seam 注释。
1. 屏幕空间 HP 条:
net_world.gd 删除旧的 Sprite3D HpBarsetup() 创建 CanvasLayer / HpOverlay
_sync_hp_bar() / _sync_hp_bar_values() 保持血量与死亡状态同步,_update_hp_bars() 使用
显式 Camera3D.unproject_position() 投影实体头顶锚点。无 max_hp、死亡、镜头后方、屏幕外、
实体移除和 clear_for_map_change 都会隐藏或释放条;screen_hp_bar.gd 负责无输入的 Control
背景 / 边框 / 填充绘制。
2. 建筑 / 木门分类:
entity_rules.gd 增加 KIND_BUILDING=5 / KIND_WOODEN_DOOR=6net_play._entity_kind() 优先
消费 building / 显式 kind,再消费 ch_type,并以 mob_proto bType=4 和 race 13000、30111..30119
识别木门。EntityRules._is_building() 同时把木门视作 building,目标框不会再把门误判为 PC,
LCONTROL 私聊也只对真实 PC 分支触发。
3. 掉落 owner 品阶颜色:
ground_items.gd 的 _item_color() 依次支持显式 item_color / rarity_color / name_color / color
和 item_grade / rarity / quality / gradeowner_tag 继承同一物品颜色,名字的近距离绿色高亮仍
只作用于名字实例。这样默认旧数据保持金色 fallback,同时新数据不再固定纯黄色 owner 行。
4. 独立 whisper dialog
chat_ui.gd 构建同级 WhisperDialogLCONTROL 点同阵营其他 PC 经 net_play.whisper_requested
和 game_scene 接到 start_whisper();目标只读、内容单独输入,发送后调用 client.whisper() 并关闭,
取消 / ESC 只关闭,不污染公共聊天输入。
回归:
cmake --build build -j2 && ctest --test-dir build --output-on-failure —— CTest 21/21 PASS。
/opt/homebrew/bin/godot --headless --rendering-method gl_compatibility --path project
--script <test>.gd —— project 下 71 个 GDScript 测试 PASS。
eterngrn_polish_test 的 Audio fixture 补齐 set_listener / play_character_sound_3dp10_test
的 serverinfo fixture 改用 /tmp,适配受限 headless 环境。macOS 系统证书读取噪声、受限 TCP
bind 的 skip 仍只记录为环境限制;真实服务器 / 真机 / 正式资源包未验收。
当前遗留:seam ⑨ 名字与气泡合并为单尾标链表,以及 Quest / fishing / dungeon / mount / observer
完整玩法、复杂 UI 分支、真实服务器和发布验收。
```
### C.5 批次记录 · 增量 128W3→M2 / §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义)
```text
批次:增量 128 —— 收口当前可在本地完成的两项尾部缺口,并同步修正 Quest DONE 的回调时机。
1. 公会会徽接入角色头顶尾标:
net_world.gd 监听客户端会徽缓存的 guild_marks_ready / guild_mark_updated。角色实体先于
会徽下载完成时,仍先生成名字、公会名、称号和等级;会徽准备好后按 guild_id 查图并懒建
GuildMark Sprite3DBILLBOARD、no_depth_test、16x12 像素尺寸、NEAREST),更新事件复用
既有节点替换纹理,不重复创建;公会清除或查不到图片时 queue_free 旧节点。
_layout_subtags_x() 在存在会徽时按参考 mark_x_offset / guild_name_x_offset 排布会徽与公会名,
_reposition_name_tag() 让会徽随聊天气泡造成的名字尾标上移。会徽到达晚于角色、纹理增量更新、
公会移除后的旧节点清理均由 project/guild_mark_tail_test.gd 覆盖。
2. Quest [DONE] 回调时机:
quest_dialog.gd 的 [DONE] 只设置 next_button_type = DONE 并呈现关闭按钮,不在 parse_script()
或接收脚本时发出 done_event;按钮实际 pressed 回调才发 done_event,再发送 script_answer(255)
并关闭窗口。这样与参考端 MakeNextButton(BUTTON_TYPE_DONE) 的按钮回调语义一致,普通
[NEXT] 仍只发送 script_answer(255)。project/quest_test.gd 覆盖解析无副作用与点击触发。
回归:
/opt/homebrew/bin/godot --headless --display-driver headless --audio-driver Dummy
--rendering-method gl_compatibility --path project --script <test>.gd —— project 下 74/74
个 GDScript 测试 PASS;其中新增 guild_mark_tail_test.gd PASSquest_test.gd PASS。
ctest --test-dir build --output-on-failure —— CTest 22/22 PASS。
macOS headless 的系统证书读取噪声、个别既有 fixture 的资源泄漏提示和受限 TCP bind / 连接失败
仅属于环境或测试替身限制,未将其记为代码失败。
验收边界:
已验证(离线):角色先生成后下载会徽、会徽纹理替换复用节点、会徽 / 公会名相对排布、聊天气泡
上移跟随、公会消失清理;[DONE] 解析阶段不触发、点击后触发 done_event 与 script_answer(255)。
未验证:真实公会会徽下载协议、真机视觉;单尾标链表(名字与气泡仍是两个节点)、完整 .msc
电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、复杂 UI 分支、真实服务器、
纯 pack、双架构和正式签名验收。
下一批次:
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
```