Doc conflicts in CLIENT-GAP.md / CLIENT-GAP-FIX.md resolved by W0: keep both 增量 50 W1 + W2 change-log bullets and C.5 batch records. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
3219 lines
190 KiB
Markdown
3219 lines
190 KiB
Markdown
# CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22)
|
||
|
||
> 本文是 [`CLIENT-GAP.md`](CLIENT-GAP.md) §「ClientVS22 实现细节审计(2026-09-02)」的**逐条修改说明**。
|
||
> 编号与该节 1–10 完全对应。
|
||
>
|
||
> **原则**:每条只写 `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-02
|
||
|
||
---
|
||
|
||
## 实施进度(2026-09-02)
|
||
|
||
当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理
|
||
第一批;正在进入并行波 A,不等于 §1、§2.7 或完整场景生命周期全部完成。
|
||
|
||
已完成并已编译验证:
|
||
|
||
- [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、场景重建
|
||
相关 GDScript;C++ 扩展和 `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)`、
|
||
F1–F4 → `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)。
|
||
|
||
仍未完成:
|
||
|
||
- [ ] 真实 TCP E2E 证明两次游戏连接,以及第二次连接确实命中 `GC_LOGIN_SUCCESS4` 的槽地址;
|
||
- [ ] 移除 `MT_CLASSIC_ENTER_DELAY` 定时发送,改为 Loading/游戏窗口完成初始化后的明确发送点;
|
||
- [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景
|
||
节点无残留;受限环境下 localhost bind 子测试自动 skip。
|
||
- [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。
|
||
- [ ] 版本包 `0xf1` / 可执行文件名 / locale 分支(§1.5);当前仍是已知差距。
|
||
|
||
本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 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`。代码提交而没有
|
||
对应文档记录时,不得把条目标成完成。
|
||
|
||
批次记录至少包含:
|
||
|
||
- `W0–W5` 工作流、§ 条目、状态(⬜ 未开始 / 🟡 开发中 / ✅ 完成 / ⚠️ 阻塞 / 🔎 待真实验收);
|
||
- 参考文件与行号、修改文件、接口 / 数据结构变化;
|
||
- 自动化测试命令和结果,包含被 `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) 游戏连接 #1:HandShake → 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) 游戏连接 #2(DirectEnterMode):
|
||
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)`。
|
||
**跨文件待接线**:`M2Client` 需调用 `classic_sess->set_executable_name(...)`(W0,已在
|
||
`classic_session.h` 留 `TODO(W0)`)。
|
||
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:1(follow-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` 字节属 GDScript(W3 / 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`(cm;px==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` 需调用
|
||
`parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))`(W0,已在
|
||
`classic_parser.h` 留 `TODO(W0)`);未设置时 `npc_names.h` 回落
|
||
`$MT_ASSETS` / `$M2_ASSETS` 环境变量,再回落空串。
|
||
|
||
**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*` 走。
|
||
|
||
---
|
||
|
||
### 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. **不要**引入预测回滚 / 抖动缓冲——参考端没有。
|
||
|
||
---
|
||
|
||
### 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()` 的等价
|
||
**[待读参考:`InstanceBaseBattle.cpp` 的 `__GetBowRange`]**。
|
||
2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见
|
||
`metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end /
|
||
motion_duration`。
|
||
3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。
|
||
|
||
---
|
||
|
||
### 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_mouse_walking_direction()`,四个 case 分别照抄
|
||
`PythonPlayerInputMouse.cpp:288-370`(含 `MODE_CLICK_ITEM` 的 `< 20.0f` 拾取阈值、
|
||
`MODE_USE_SKILL` 的 `IS_HUGE_RACE` 距离 −200 修正)。
|
||
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` 文案表。
|
||
|
||
---
|
||
|
||
### 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 输入三时间,`.msa` 已解析结构见 `POC/extension/src/metin2_anim.cpp`。
|
||
2. `RaceData` 的 combo 表(`ComboIndexVector`,按 `MAKE_COMBO_KEY(motionMode, comboType)` 索引,
|
||
`REF/GameLib/RaceData.h:9`)需要从 `.msm` 解析出来;当前 `.msm` 解析只用于换肤。
|
||
3. `player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`),
|
||
删除 0/1/2 循环。
|
||
4. 命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`;
|
||
`FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条),
|
||
`M2Client::sync_positions()` 已有上限 16 的实现,直接复用。
|
||
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`,
|
||
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。
|
||
|
||
---
|
||
|
||
### 3.6 弓箭发射使用固定 1.5 秒定时器
|
||
|
||
**参考**:由 `.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`)。
|
||
|
||
**当前**:`game_scene.gd:218` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。
|
||
|
||
**修改**
|
||
|
||
1. `.msa` 的 motion event 分派(本工程已实现,见 `game_scene._on_local_motion_event`)新增
|
||
`FLY` 分支:触发 `OnSetFlyTarget` → `client.fly_targeting(vid, x, y, z)`;
|
||
飞行物命中/到期 → `OnShoot(skill)` → `client.shoot(skill)`。
|
||
2. 删除 `game_scene.gd:218` 的定时器与单槽 `pending skill`;改为**每次攻击创建一个
|
||
handler 实例**(对齐 `_AutoClear` 语义:射完自动清理),支持并发多发。
|
||
3. `SendFlyTargetingPacket` 的坐标取目标的 `OnGetFlyTargetPosition()`
|
||
**[待读参考:`REF/GameLib/ActorInstanceFly.cpp` 的 `OnGetFlyTargetPosition`]**。
|
||
|
||
---
|
||
|
||
### 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`。
|
||
|
||
---
|
||
|
||
### 3.8 技能缺少统一合法性检查
|
||
|
||
**参考**:三层,缺一不可。
|
||
|
||
**(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`
|
||
均按参考写成命名常量。
|
||
2. `CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill /
|
||
IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget /
|
||
CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`)
|
||
来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析
|
||
**[待读参考:`REF/UserInterface/PythonSkill.h` 的 `TSkillData` 字段与
|
||
`PythonSkill.cpp` 的 `RegisterSkillDesc` / `LoadSkillTable`]**。
|
||
3. `M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。
|
||
|
||
---
|
||
|
||
### 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. 在 `m2_client.cpp` 的每个公开 `send_*` 入口按上表补 `int` 范围校验,越界返回 `false`
|
||
并记日志——**不要**先 `static_cast<uint32_t>`。
|
||
2. 新建 `POC/extension/src/net/limits.h` 收纳参考常量:
|
||
`PLAYER_PER_ACCOUNT4=4, QUICKSLOT_MAX_NUM=36, QUICKSLOT_MAX_COUNT=32,
|
||
QUICKSLOT_MAX_COUNT_PER_LINE=8, QUICKSLOT_MAX_LINE=4, SHOP_HOST_ITEM_MAX_NUM=40,
|
||
METIN_SOCKET_COUNT=6, PARTY_AFFECT_SLOT_MAX_NUM=7, EXCHANGE_ITEM_MAX_NUM=12,
|
||
SAFEBOX_PAGE_SIZE=45, REFINE_MATERIAL_MAX_NUM=5, SYNC_POSITION_COUNT_LIMIT=16,
|
||
MAP_COORD_MAX=204800`(来源:`REF/UserInterface/Packet.h:380`、`PythonSafeBox.h:8`、
|
||
`PythonExchange.h:13`、`PythonPlayerEventHandler.cpp:200`、`PhaseGame.cpp:1132`)。
|
||
3. 回归:`net_bounds_test.gd` 对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。
|
||
|
||
---
|
||
|
||
### 3.10 技能升级传输已对齐但状态门控不完整
|
||
|
||
**证据边界与订正**:本次 `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()` 状态门。
|
||
|
||
**修改**
|
||
|
||
1. `skill_ui.gd` 按上面的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` /
|
||
`CanShowPlusButton`)**逐行**实现加号按钮的显示门控,替换现有单行判断。
|
||
2. 三个技能点来源分开取:`SKILL_ACTIVE` / `SKILL_SUPPORT` / `SKILL_HORSE`
|
||
(`uicharacter.py:1010-1019`),不是统一一个 `POINT_STAT`。
|
||
3. `skill.CanLevelUpSkill(skillIndex, skillLevel)` 与 `skill.GetSkillLevelLimit(skillIndex)`
|
||
需要 skill table 支持,见 §3.8 第 2 点的 `TSkillData` 解析
|
||
**[待读参考:`REF/UserInterface/PythonSkill.cpp` 的 `CanLevelUpSkill` / `GetSkillLevelLimit`]**。
|
||
4. `M2Client::skill_up(int skill_id)`(`m2_client.cpp:850`)的发包本身保持不变
|
||
(`/skillup <skillIndex>`),只补 §3.9 的入参范围校验。
|
||
5. 升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包驱动刷新,
|
||
失败提示走 `GC_CHAT` 的服务端文案——不本地伪造成功。
|
||
|
||
---
|
||
|
||
## 4. P1:物品、装备、商店和社交
|
||
|
||
### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则
|
||
|
||
**参考**:`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 = 122,6 槽 × 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`。
|
||
2. 装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 →
|
||
`limit`(等级/属性) → 发 `CG_ITEM_USE`。校验失败的文案走 `localeInfo` 的
|
||
`CANNOT_EQUIP_*` 键。
|
||
3. costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` /
|
||
`equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。
|
||
4. 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按
|
||
`REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现
|
||
**[待读参考:`InstanceBase.cpp` 的 `ChangeArmor` / `SetArmor` / `__AttachHorseSaddle`]**。
|
||
5. 远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把
|
||
`armor/weapon/hair` 三个字段接到换模。
|
||
|
||
---
|
||
|
||
### 4.2 物品拖放不是原版鼠标物品态
|
||
|
||
**参考**:全局单例 `mouseModule.mouseController`(`POC/assets/root/mousemodule.py`,
|
||
`class CMouseController`),是所有窗口之间拖放的**唯一中介**:
|
||
|
||
```
|
||
AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0) mousemodule.py:171
|
||
Type ∈ ESlotType(GameType.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`。
|
||
|
||
**当前**:各窗口各自实现点击/拖放,没有全局持有态。
|
||
|
||
**修改**
|
||
|
||
1. 新建 `POC/project/ui/mouse_controller.gd`(autoload 单例),API 逐个对齐上表
|
||
(名字保持 `attach_object / attach_money / deattach_object / is_attached /
|
||
get_attached_type / get_attached_slot_number / get_attached_item_index /
|
||
get_attached_item_count / is_attached_money`)。
|
||
2. `ESlotType` / `EWindows` / `SlotTypeToInvenType` 三张表移植到
|
||
`POC/project/ui/game_type.gd`,值与 `GameType.h` 完全一致。
|
||
3. 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot /
|
||
dragon_soul)统一改为 `select_empty_slot` / `select_item_slot` 两个入口,
|
||
分支照抄各自的 `ui*.py`。
|
||
4. 鼠标跟随图标:`CanvasLayer` 上画 `AttachedIconHandle` 等价物,位置跟随鼠标,
|
||
数量文字用 `countNumberLine`(`mousemodule.py:87`)。
|
||
5. 拖动取消:右键或落到无效目标 → `deattach_object()`,不发包(参考无回滚包)。
|
||
6. 音效按上表四个固定路径。
|
||
|
||
---
|
||
|
||
### 4.3 物品 tooltip 只覆盖基础信息
|
||
|
||
**参考**:`POC/assets/root/uitooltip.py`(2718 行)是完整实现,本仓已有该脚本。
|
||
入口按来源分:`SetInventoryItem / SetShopItem / SetExchangeOwnerItem /
|
||
SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem /
|
||
SetSkill / SetQuickSlotItem`。
|
||
|
||
**修改**
|
||
|
||
1. **不要**重写 tooltip 逻辑。按 §8 的窗口迁移样板(`char_status_ui.gd` 的做法)
|
||
直接移植 `uitooltip.py` 的 `ItemToolTip` 类:
|
||
- 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary /
|
||
GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等);
|
||
- 属性行(attribute)、魔石孔(socket,`METIN_SOCKET_COUNT = 6`)、
|
||
`ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、
|
||
精炼等级、使用时限(`GetItemDuration`)逐条对齐。
|
||
2. `proto` 层补齐 tooltip 需要的全部字段(`ItemProto.value[6] / limit[2] / apply[3] /
|
||
flags / anti_flags / wear_flags / socket / refine` 等),当前只导出了少量字段。
|
||
3. 装备对比(穿戴中 vs 鼠标所指)按 `uitooltip.py` 的 `__AppendEquipmentCompare` 等价实现
|
||
**[待读参考:`uitooltip.py` 中比较相关的方法名以本仓文件为准]**。
|
||
|
||
---
|
||
|
||
### 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` 驱动。
|
||
|
||
**当前**:`POC/project/ui/safebox_ui.gd:7` 是列表 + 自动找空位。
|
||
|
||
**修改**
|
||
|
||
1. `safebox_ui.gd` 重写为 5×9 网格 + 最多 3 页的页签,`__LocalPosToGlobalPos` 照抄。
|
||
2. 存取一律**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)`,
|
||
删除「找下一个空位」的逻辑。
|
||
3. 补 `CG_SAFEBOX_ITEM_MOVE(77)` 的发送(仓库内移动)。
|
||
4. 金钱走 `/safebox_money` 类聊天命令**[待读参考:`netSendSafeboxSaveMoneyPacket` 在
|
||
`PythonNetworkStreamModule.cpp` 中的实现,确认是包还是聊天命令]**;
|
||
关闭固定用 `client.say(0, "/safebox_close")`。
|
||
5. 接入 `mouse_controller`(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。
|
||
6. 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的重试提示接上。
|
||
|
||
---
|
||
|
||
### 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`。
|
||
|
||
**当前**:两列文字列表,无 accept / 距离 / anti-flag 规则。
|
||
|
||
**修改**
|
||
|
||
1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 +
|
||
`AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。
|
||
2. `EntityStore` 的交易状态补齐 `TExchangeData` 全字段(含 socket / attribute 二维数组)。
|
||
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`。
|
||
|
||
**当前**:基础列表 + 直接买。
|
||
|
||
**修改**
|
||
|
||
1. `shop_ui.gd` 用 `shopdialog.py` uiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。
|
||
2. 实现 BUY / SELL 光标模式:`app.SetCursor` 的等价见 §8.5 的光标管理器。
|
||
3. 卖出流程按上面的 8 步照抄,含 `ANTIFLAG_SELL` 弹窗、`IsValuableItem` 的确认框、
|
||
`÷5` 价格公式和 `Is1GoldItem` 分支。
|
||
4. 买入:点格子(光标为 BUY 时)→ `net.SendShopBuyPacket(GetIndexFromSlotPos(pos))`。
|
||
5. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。
|
||
6. 错误提示一律用服务端 `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`,而不是当前的「点选候选面板」。
|
||
2. 开店前检查:参考在 `uiprivateshopbuilder.py` 里检查
|
||
`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)
|
||
```
|
||
数字键 1–4 走 `pressNumber`(默认 = `__PressQuickSlot`),
|
||
按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage`(`game.py:506`)。
|
||
F1–F4 固定映射到 `__PressQuickSlot(4..7)`(`game.py:318`)。
|
||
|
||
**当前**:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、
|
||
客户端显示 32 格(4 页 × 8 格)。
|
||
|
||
**修改**
|
||
|
||
1. 数字键改为 **1–4 → 快捷栏 0–3**(`__PressNumKey` 在 `game.py:431`:
|
||
非 Ctrl 时 `if 1 <= num <= 4: self.pressNumber(num-1)`;
|
||
**Ctrl + 1..9 是表情**:`chrmgr.SetEmoticon(-1, num-1)` + `net.SendEmoticon(num-1)`)。
|
||
当前的「数字键 1–9 全给快捷栏」要改掉。
|
||
2. LSHIFT 按下 → `pressNumber = __SelectQuickPage`;松开 → `pressNumber = __PressQuickSlot`
|
||
(`game.py:370` / `:408`)。
|
||
3. **先修类型编号**:`EntityStore` / `M2Client` / `quickslot_ui` 里的快捷栏 type 一律改用
|
||
§4.2 移植的 `ESlotType`(`SLOT_TYPE_INVENTORY=1 / SKILL=2 / EMOTION=3`),
|
||
删掉 `m2_client.cpp:878` 的 `3 command / 4 emotion` 注释与对应分支;
|
||
`quickslot_add()` 的入参校验从 `1..4` 改为「只接受 1 / 2 / 3」。
|
||
这是**数据错误**,必须先于 UI 工作修复。
|
||
4. 快捷栏类型只保留 `INVENTORY / SKILL / EMOTION` 三种,`SKILL` 必须走 §3.8 的
|
||
`ClickSkillSlot` 完整链,不能直接发包;收到其它 `ESlotType` 一律不执行(与参考的
|
||
switch 落空行为一致)。
|
||
5. 边界常量按 §3.9 的 `limits.h`;`MoveQuickSlot` 改为 swap。
|
||
6. 物品拖入快捷栏:走 §4.2 的 `mouse_controller`,`AttachObject` 的
|
||
`SLOT_TYPE_QUICK_SLOT` 分支会递归解析真实类型(`mousemodule.py:218`)。
|
||
7. 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。
|
||
|
||
---
|
||
|
||
### 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 只支持少量标签
|
||
|
||
**参考**:标签表在 `CPythonEventManager::CPythonEventManager()`
|
||
(`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 38 个**:
|
||
|
||
```
|
||
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=1;end:全 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` 改)。
|
||
|
||
**当前**:`POC/project/ui/quest_dialog.gd:102` 只支持 8 个标签。
|
||
|
||
**修改**
|
||
|
||
1. `quest_dialog.gd` 的 `parse_script` 换成完整的 `EventTypeMap`(38 项)+
|
||
上表的 `ProcessEventSet` 分派,逐条实现。
|
||
2. 引入 `EventSet` 状态:`lWaitingTime / lLastDelayTime / iCurrentLetter /
|
||
iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag /
|
||
isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor`,字段名保持一致。
|
||
3. `SETMSGPOS` / `ADJMSGPOS` 实现为**空**(与参考一致),不要自行发挥。
|
||
4. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮
|
||
按 `PythonEventManager.cpp` 的对应函数移植
|
||
(`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。
|
||
5. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。
|
||
|
||
---
|
||
|
||
### 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)`。
|
||
|
||
**修改**
|
||
|
||
1. 按 §8 的窗口迁移样板移植 `uiquest.py`(QuestButton / 图标 / 倒计时 / 定位)。
|
||
2. `quest_log.gd:8` 的纯文字列表改为读 `icon_type` + `icon_name`,
|
||
图标资源走 `UiAssets.load_dds_image`。
|
||
3. 世界箭头 / 边缘指示对应 `EVENT_TYPE_ADD_MAP_SIGNAL` + `CPythonMiniMap::AddSignalPoint`,
|
||
接到已有的 minimap 实现。
|
||
|
||
---
|
||
|
||
### 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`)里都是**第一道门**。
|
||
|
||
**修改**
|
||
|
||
1. `player_controller.gd` 加 `is_fishing_mode / is_observer_mode / is_hand_mode` 三个状态位,
|
||
并把上面四处门禁接上。
|
||
2. 钓鱼:攻击键在钓鱼模式改发 `CG_FISHING(irot)`;方向键取消钓鱼;
|
||
`OnFishingWrongPlace` 走 `localeInfo` 文案。
|
||
3. 坐骑:`GC_MOUNT` / `mount_vnum` 已入 `EntityStore`;补模型挂载与
|
||
`MODE_HORSE*` 动作模式(§6.4)、`CanAttackHorseLevel` 限制。
|
||
4. 副本:`DungeonBlock` 已接入静态地图(§7.1 保持);结算窗按 `EVENT_TYPE_DUNGEON_RESULT`
|
||
的 9 个参数实现。
|
||
|
||
---
|
||
|
||
### 5.4 PVP、PK 和决斗没有完整规则
|
||
|
||
**参考**:`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)`,不做简化。
|
||
2. 依赖判据补齐:`IsKiller()`(alignment < 0 或 PK 标记)、`IsSameEmpire()`(empire 字段,
|
||
§2.5 已补)、`IsPVPInstance()`(`GC_PVP` 关系表)、`IsConflictAlignmentInstance()`
|
||
(alignment 符号相反)、`IsSamePartyMember()`(`get_party()`)、
|
||
`GetDuelMode()` + `__FindDUELKey`(`GC_DUEL_START` 已实现)。
|
||
3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。
|
||
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 只有粒子近似
|
||
|
||
**参考**:`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)`。
|
||
|
||
**当前**:`POC/project/fx/effect_player.gd:8` 只映射 Particle → `GPUParticles3D`;
|
||
`:134` 用程序化渐变代替 `.dds`;`.mde` 用 box 代替。
|
||
|
||
**修改**
|
||
|
||
1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把
|
||
`effect_player.gd:134` 的程序化渐变换掉。
|
||
2. `.mde` 网格特效:新增解析器输出 `ArrayMesh`,替换 box
|
||
**[待读参考:`REF/EffectLib/EffectMesh.{h,cpp}` 的 `CEffectMeshScript::LoadScript`]**。
|
||
3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`,
|
||
`range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。
|
||
4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 /
|
||
emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。
|
||
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`),本工程当前只有一盏。
|
||
|
||
**修改**
|
||
|
||
1. `environment_builder.cpp` 解析并落实上表**全部**字段;`TEnvironmentData` 的
|
||
字段名逐个对应,缺哪个补哪个。
|
||
2. 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个
|
||
`DirectionalLight3D` + `cull_mask` 分层实现。
|
||
3. 云层用 `strCloudTextureFileName` + `v2CloudScale/TextureScale/Speed` + `fCloudHeight` +
|
||
`CloudGradientColor` 真实渲染,不再省略。
|
||
4. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness /
|
||
bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。
|
||
5. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)是全屏后处理,补上。
|
||
6. 昼夜插值:参考按 `TEnvironmentDataMap`(`MapType.h:187`,`DWORD → TEnvironmentData*`)
|
||
在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。
|
||
7. 环境循环音属 §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/P2:UI、输入和本地化
|
||
|
||
### 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 帮助窗口未实现
|
||
|
||
**参考**:`POC/assets/root/uihelp.py`(136 行)+ uiscript `helpwindow.py`。
|
||
入口:`game.py:459 __PressHKey()` —— 非 Ctrl 时 `self.interface.OpenHelpWindow()`
|
||
(Ctrl+H 是 `/user_horse_ride`)。
|
||
|
||
**当前**:`POC/project/ui/system_menu_ui.gd:91` 只显示占位提示。
|
||
|
||
**修改**
|
||
|
||
1. 新建 `POC/project/ui/help_ui.gd`,按样板装 `helpwindow.py` uiscript,
|
||
内容绑定逐字对齐 `uihelp.py`。
|
||
2. 挂 `H` 键(非 Ctrl)与 ESC 菜单的 `help_button`,替换 `system_menu_ui.gd:91` 的占位。
|
||
3. 配 `help_ui_test.gd`。
|
||
|
||
---
|
||
|
||
### 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]`。
|
||
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 等。
|
||
|
||
**修改**
|
||
|
||
1. 新建 `POC/project/ui/cursor_manager.gd`:`set_cursor(shape)` / `get_cursor()`,
|
||
枚举与资源映射按上表;资源用 `.sub`(`UiAssets`)。
|
||
2. 鼠标跟随物品图标由 §4.2 的 `mouse_controller` 负责,与光标是两层。
|
||
3. 悬停实体时的光标:可攻击 → `ATTACK`;NPC → `TALK`;地面物品 → `PICK`;
|
||
不可达 → `CANT_GO`——判据用 §5.4 的 `is_attackable_instance` 与实体类型。
|
||
4. 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(`wndMgr` 的语义)。
|
||
|
||
---
|
||
|
||
### 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)
|
||
```
|
||
|
||
**当前**:只有 WASD、点地、相机拖动、滚轮、触屏、F1–F4、数字键 1–9。macOS 上物理 F1–F12
|
||
是否直接产生 `KEY_F1`–`KEY_F12` 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,
|
||
这是平台验收条件,不应写成 ClientVS22 的额外键位功能。
|
||
|
||
**修改**
|
||
|
||
1. `game_scene.gd` 的输入表按上面**逐行**补齐,包括每个 Ctrl 组合分支。
|
||
2. `SaveScreen()`(SYSRQ)实现正式截图,替代现有的调试截图。
|
||
3. `StartAttack/EndAttack`(空格)接 §3.4 的 `m_isAtkKey` 连续攻击。
|
||
4. `ShowName / HideName`(LALT)接 §8.8 的 TextTail 显示开关。
|
||
5. 键位重绑定 / 冲突检测:参考端**没有**——不要实现,若需要另立需求。
|
||
6. 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §7.6 第 5 点)。
|
||
|
||
---
|
||
|
||
### 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` **[待读参考:`REF/UserInterface/GameType.h` 或 `Packet.h`]**。
|
||
|
||
**修改**
|
||
|
||
1. 按 §8.2 迁移 `uichat.py`(notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。
|
||
2. 新建 `text_tail.gd` 移植 `PythonTextTail`:名字 / 称号 / 公会名 / 伤害数字 /
|
||
聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。
|
||
3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的
|
||
`ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。
|
||
4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。
|
||
|
||
---
|
||
|
||
### 8.9 任务日志图标仍是占位
|
||
|
||
见 §5.2,同一条修改。`POC/project/ui/quest_log.gd:8` 的文字列表在
|
||
`EVENT_TYPE_QUEST_BUTTON` 补齐 `icon_type` / `icon_name` 后即可接真图标。
|
||
|
||
---
|
||
|
||
## 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)走同一条选曲逻辑。
|
||
|
||
---
|
||
|
||
### 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` 不再被清空,曲目继续播放,恢复后音量还原。
|
||
|
||
---
|
||
|
||
### 9.3 生命周期所有权重复
|
||
|
||
**参考**:唯一协调者是 `CPythonApplication`
|
||
(`REF/UserInterface/PythonApplication.cpp` + `PythonApplicationEvent.cpp`),
|
||
`CPythonNetworkStream` 只是被它驱动的一个子系统。
|
||
|
||
**修改**
|
||
|
||
1. 确立 `AppFlow` 为唯一生命周期协调者(等价 `CPythonApplication`):
|
||
`AppLifecycle` 由 `AppFlow` 创建并持有,`GameScene` 不再自建。
|
||
2. `M2Client` 删除自己的 `NOTIFICATION_*` 处理,只保留被调用的
|
||
`suspend() / resume()`。
|
||
3. 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进
|
||
`POC/docs/CLIENT-PORT.md` 的生命周期小节。
|
||
|
||
---
|
||
|
||
### 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
|
||
```
|
||
|
||
**当前**:只有 BGM crossfade、UI 音效池和通用 3D 音效。
|
||
|
||
**修改**
|
||
|
||
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`。
|
||
2. 3D 音效位置一律除以 `SOUND_SCALE`。
|
||
3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现
|
||
(`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio;
|
||
`system_option_ui.gd` 的音量滑条改走这个换算(当前是线性)。
|
||
4. 三类声道分开:普通 3D(`PlaySound3D`)、环境(`PlayAmbienceSound3D`,
|
||
有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。
|
||
5. `.msa` 的 sound 事件走 `UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE)`
|
||
(角色动作声音一律开去重)。
|
||
6. 音乐通道支持 `FADE_LIMIT_OUT`(降到某音量而不是 0)与 `FadeOutAllMusic`。
|
||
7. `.mss` 声音脚本:`CEffectData::LoadSoundScriptData` 与
|
||
`CRaceMotionData::GetSoundScriptFileName()`(`REF/GameLib/RaceMotionData.h:220`)
|
||
**[待读参考:`REF/MilesLib/SoundData.cpp` 的 `.mss` 解析]**。
|
||
|
||
---
|
||
|
||
## 10. 发布和验证问题
|
||
|
||
> 本节没有 `ClientVS22` 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。
|
||
> 因此这里只写**可执行的动作**,不假装有 1:1 依据。
|
||
|
||
### 10.1 Mac App 不是自包含资源包
|
||
|
||
**修改**
|
||
|
||
1. `build-macos-client.sh` 里把 `assets` / `bgm` 的**符号链接**改为
|
||
`ditto` 复制进 `Contents/Resources/`(或打成 `.epk/.eix` 后复制)。
|
||
2. `AssetRoot.path()` 在 macOS 上优先解析 `Contents/Resources/assets`,
|
||
`MT_ASSETS` 仍可覆盖(开发用)。
|
||
3. 验收:把 `.app` 拷到另一台机器(或 `mv` 掉工作区)后能启动进图。
|
||
|
||
### 10.2 主程序和 GDExtension 架构不一致
|
||
|
||
**修改**
|
||
|
||
1. `build.sh` / `CMakeLists.txt` 为 macOS 增加 `x86_64` 目标,
|
||
用 `lipo -create` 合成 universal `.dylib`。
|
||
2. `*.gdextension` 的 `macos.debug/release` 指向该 universal 库。
|
||
3. 验收:`lipo -info` 显示 `x86_64 arm64`;Godot 主程序也是 universal。
|
||
|
||
### 10.3 当前是 Ad-hoc 签名
|
||
|
||
**修改**
|
||
|
||
1. `codesign --force --options runtime --sign "Developer ID Application: …"`
|
||
对 `.app` 内所有 `.dylib` 与主可执行文件逐个签名(先内后外)。
|
||
2. `xcrun notarytool submit --wait` 提交公证,`xcrun stapler staple` 装订。
|
||
3. 验收:`spctl -a -vvv -t install <app>` 通过。
|
||
|
||
### 10.4 发布构建仍包含 Demo 默认账号密码
|
||
|
||
**当前**:`POC/project/app_flow.gd:32-33`
|
||
```gdscript
|
||
var _id := "admin"
|
||
var _pw := "123456789"
|
||
```
|
||
|
||
**修改**
|
||
|
||
1. 两行改为空串。
|
||
2. 自动登录只在 `MT_AUTOLOGIN=1` 且 `MT_ACCOUNT` / `MT_PASSWORD` 环境变量存在时生效
|
||
(`app_flow.gd:37` 的 `auto_login` 保留,凭据不再硬编码)。
|
||
3. 加一条 CI 检查:release 预设的 PCK 里 `grep -c "123456789"` 必须为 0。
|
||
|
||
### 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 | ❶+❸ | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」——这一半正确。但原文**漏了当前实现的新问题**:`m2_client.cpp:878` 把 `3` 注释/使用为 `command`、`4` 为 `emotion`,而参考的 `TQuickSlot.Type` 直接就是 `ESlotType`(`SLOT_TYPE_EMOTION == 3`,`4` 是 `SLOT_TYPE_SHOP`)。**当前类型编号与参考端不兼容**;同时,键位表来源于本仓迁移资产,不能伪称为 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309` |
|
||
| §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` |
|
||
| §8.6 | ❷ | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;主文档已改为明确这一证据边界。macOS 物理 F1–F12 是否直接产生功能键事件受系统功能键设置 / Fn 键影响,应作为平台验收条件,不应虚构成参考端额外键位。 | `POC/assets/root/game.py:309-408`、Godot 当前 `project/game_scene.gd:558` |
|
||
| §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 `.mde` 解析 | `REF/EffectLib/EffectMesh.{h,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 接口冻结批次已落地(`Entity` 字段、`mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed`、`bgm_director.gd`、§8.6 键位入口);真实双连接和部分生命周期仍待验收 |
|
||
| G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 1(W1/W2/W3/W4)4 个并行工作流提交后执行 |
|
||
| 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 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 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 / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 |
|
||
| W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
|
||
| W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 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 / 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/4(BGM)
|
||
改动:
|
||
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 落 empire;reset_for_map_change 清 m_dirty
|
||
extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/
|
||
take_bgm_dirty;entity_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/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
|
||
验收:未验证(无真实 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:4155(SendClientVersionPacket);
|
||
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_ASSETS;mtnet-safe(std::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_codec;net.classic_session 及既有 16 项全绿);
|
||
godot --headless --path project --script res://netbridge_test.gd → PASS(exit 0);
|
||
godot --headless --path project --script res://p10_test.gd → PASS(exit 0)。
|
||
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
|
||
以填充 project/.godot/global_script_class_cache.cfg(worktree 内 .godot 为空且 gitignore),
|
||
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
|
||
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
|
||
风险与依赖:
|
||
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
|
||
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibake(follow-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 步骤 4–5(entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
|
||
合并逻辑对当前 W2 代码可工作
|
||
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlen;GC_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/182;REF/.../PhaseLoading.cpp:325;
|
||
m2dev src/UserInterface/InstanceBase.h enum EAffect(bit 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-return;mut_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 退出 0;cd 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-3(m_pending_actor 两包合并、IsInvisibleRace 分流)属 W1,未触碰
|
||
- net_world.gd 可见性门禁对无 pos_cm 的 fake client fail-open:信号驱动 spawn 不受
|
||
距离限制,且逐帧管理会主动建节点——与参考「信号即 AppendActor」的有意偏差
|
||
- AFFECT_SHOW_ALWAYS=1<<7 源自 m2dev InstanceBase.h(40250 树 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 集成
|
||
```
|