按 docs/ 目录逐篇核对,只改与事实/路径不符的部分,不动尚未验证的计划条目: - `docs/steps/**` 在早前整理时已迁到 `docs/reference/steps/**`,全仓的旧路径 引用(PLAN.md / README.md / M0-gr2-reader.md 及 libgr2 的头注释)一并改正。 - CLIENT-GAP.md / CLIENT-GAP-FIX.md / BACKLOG.md / MIDREVIEW.md:把已经落地的 条目从「待办」改为已完成,删掉与代码现状矛盾的描述。 - ANDROID-TESTING.md / CLIENT-PORT.md / CLIENT-ROADMAP.md / GODOT-POC-PLAN.md / SHINSOO-WORLD-RENDERING.md / PARITY-GAP.md:同上,另补当前实际的构建/测试入口。 - THIRD-PARTY.md:补齐实际在用的第三方来源与许可说明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
10148 lines
789 KiB
Markdown
10148 lines
789 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/metin2-client/`。
|
||
>
|
||
> 创建 / 修订日期:2026-09-04
|
||
|
||
---
|
||
|
||
## 实施进度(2026-09-04)
|
||
|
||
当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理
|
||
第一批;并行波 A 已有多项实现合入。当前仍有真实链路、平台和 1:1 表现验收项,不能把“代码回归
|
||
通过”误记为全部完成。
|
||
|
||
已完成并已编译验证:
|
||
|
||
- [x] **增量 128 —— W3→M2 §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义修正**:
|
||
- `net_world.gd` 消费 `guild_marks_ready` / `guild_mark_updated`,为已经生成的角色动态创建、
|
||
更新和移除 `GuildMark` `Sprite3D`,并按 `mark_x_offset` / `guild_name_x_offset` 与公会名排布;
|
||
会徽会跟随聊天气泡造成的名字尾标上移。`guild_mark_tail_test.gd` 覆盖延迟下载、纹理替换、
|
||
水平排布和公会移除。
|
||
- `quest_dialog.gd` 保持 `[DONE]` 解析无副作用,`done_event` 改为只在用户点击关闭按钮时触发;
|
||
`quest_test.gd` 覆盖解析阶段不触发、点击后触发和 `script_answer(255)`。
|
||
- CTest 22/22、Godot 74/74 离线回归通过;macOS headless 证书读取噪声不计为代码失败,真实
|
||
服务器、真机和正式资源包仍需外部验收。
|
||
|
||
- [x] **增量 127 —— W3→M2 §8.8 / §5.4 / §2.1 四项本地缺口收口**:
|
||
- `net_world.gd` 移除旧 `Sprite3D HpBar`,由 `CanvasLayer + Control` 的 `ScreenHpBar` 按
|
||
`Camera3D.unproject_position()` 每帧投影;无最大血量、死亡、镜头后方、屏幕外和换图时隐藏 / 清理。
|
||
`screen_hp_bar.gd` / `screen_hp_bar_test.gd` 覆盖血量同步与投影边界。
|
||
- `entity_rules.gd` / `net_play.gd` 增加 `KIND_BUILDING=5`、`KIND_WOODEN_DOOR=6`;支持显式
|
||
`kind`、`building`、`mob_proto bType=4`,以及 13000 / 30111..30119 木门 race。目标框和
|
||
攻击分类不再把建筑 / 木门误当 PC;`entity_rules_test.gd` / `netplay_test.gd` 覆盖。
|
||
- `ground_items.gd` 的掉落名 / owner 行按显式颜色、`item_grade` / `quality` / `rarity` 等
|
||
品阶字段取色,owner 行继承物品颜色,不再固定纯黄色;`p2b_test.gd` 覆盖显式品阶色。
|
||
- `chat_ui.gd` 新增独立 `WhisperDialog`,LCONTROL 点同阵营其他 PC 后可发送、取消或 ESC 关闭,
|
||
不污染公共聊天输入;`game_scene.gd` / `chat_test.gd` 完成接线与回归。
|
||
- `eterngrn_polish_test.gd` 的 Audio stub 补齐 MilesLib 接口,`p10_test.gd` 改用受限环境可写的
|
||
`/tmp` fixture;最终 C++/CTest 21/21、Godot 脚本 71/71 通过。Godot macOS headless 的
|
||
系统证书读取噪声和受限 TCP bind skip 不计作代码失败;真实服务器、真机和正式资源包仍需外部验收。
|
||
|
||
- [x] `GC_LOGIN_SUCCESS3/4` 的角色槽保存 `lAddr/wPort`;`M2Client.enter_game(index)` 已接入
|
||
Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送
|
||
`CG_CHARACTER_SELECT`。
|
||
- [x] `select_char()`、登录包、`CG_ENTERGAME` 均透传 `send_fixed()` 结果;`CG_ENTERGAME` 成功后
|
||
执行即时 flush。`m_entergame_sent` 只在发送成功后置位。
|
||
- [x] 未知 header 不再扫描 Loading 缓冲寻找伪边界;保留参考端唯一的 `header == 0` 填充跳过,错误会
|
||
记录最近两个 header 并断开。
|
||
- [x] ClassicParser 对已知但未实现的集成包不再静默成功,而是记录阶段 / header 并触发失败链路。
|
||
- [x] 已建立游戏连接后的断线现在发 `disconnected`;`AppFlow` 无条件回 LOGIN,匹配
|
||
`OnRemoteDisconnect() -> SetLoginPhase()` 的最小语义。
|
||
- [x] 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序;
|
||
`net_classic_stream_test`、`net_classic_session_test`、`netbridge_test`、`p10_test` 已通过。
|
||
`net_classic_session_test` 内的 localhost 双监听目标测试因当前受限环境禁止 bind 而自动 skip,
|
||
未将 skip 计作真实 TCP / 真服验收。
|
||
- [x] Classic `GC_WARP` 透传 `lX/lY/lAddr/wPort`,不再用 `addr==0` 判断同服;保留登录票据和已选
|
||
角色槽,通过同一个 `ClassicSession` 重连目标 `lAddr:wPort`,并在重连前清理 `EntityStore` 的
|
||
地图实体、掉落物、目标、PVP / 决斗、技能 / affect / 交互状态。
|
||
- [x] 新增 `M2Client.world_reset`;`GameScene` / `NetWorld` / `GroundItems` 清理旧表现节点,
|
||
重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。
|
||
- [x] 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建
|
||
相关 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)。
|
||
- [x] **增量 51 —— W0 §9.3 生命周期单一属主**:生产入口 `client_main.gd → AppFlow` 创建并持有
|
||
唯一 `AppLifecycle`,登录 / 选人 / 游戏切屏不重复创建;`GameScene` 只暴露其 `Audio` 给 AppFlow
|
||
绑定;`M2Client` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` 监听,只保留
|
||
`suspend()/resume()`;旧的 `login.gd` 兼容启动器也保持自己的生命周期节点贯穿登录到游戏,且不再
|
||
在进场时销毁。新增 `app_flow_lifecycle_test.gd`,并扩展 `gamescene_test.gd` 断言无第二个节点。
|
||
- [x] **增量 52 —— W0/W2 §3.9 公共网络 API 参数边界**:新增
|
||
`extension/src/net/net_bounds.h`,统一封装 `u8/u16/u32/i32`、快捷栏 36 槽、交易 12 槽、Cube 24 槽、
|
||
私人商店 39 项、龙魂精炼 15 格等协议边界;`M2Client` 在转换为 wire 类型前拒绝负数、溢出、错误
|
||
Variant 类型和超出数组 / 文本容量的输入。覆盖连接端口、角色创建、移动 / 同步 / 战斗 / 技能、聊天、
|
||
社交、公会、商店 / 交易 / 仓库 / 私人商店 / Cube、精炼、物品操作和会徽连接参数。新增
|
||
`net_bounds_test`,C++ 扩展构建与 CTest 18/18 通过。
|
||
|
||
- [x] **增量 53 —— W3/W4/W5 §4.8 §6.3 §8.6 §10**:快捷栏按参考端改为服务器 36 槽、客户端
|
||
4 页 × 8 格,类型严格使用 `INVENTORY=1/SKILL=2/EMOTION=3`;表情槽接入 `send_emoticon()`,
|
||
补齐 LSHIFT 翻页、F1–F4、Ctrl/Command+数字、空格连续攻击、Alt 显示名字、截图、帮助 / 坐骑 /
|
||
角色窗口 / 小地图 / 聊天 / 相机等入口。`.mse` 粒子读取位置、发射形状、方向 / 速度和首个
|
||
`TextureFiles` 的实际 `.dds`(`UiAssets` + 原生 DDS 解码)。Mac 打包脚本复制 `assets` / `bgm`
|
||
到 `Contents/Resources`,支持 `MT_MAC_ARCHES=universal`、`lipo` 门禁和嵌套扩展先签名,发布凭据
|
||
改为环境变量。`fx_test`、`input_key_test`、`skill_test`、`netplay_test`、`netbridge_test`、
|
||
`gamescene_test` 全绿;完整资源导出、当前机器双架构产物、正式证书 / notarization、真服 / 真机
|
||
仍未验收。
|
||
|
||
- [x] **增量 54 —— W3 §4.2 §4.9 / 鼠标物品态、光标和窗口焦点**:新增
|
||
`project/ui/mouse_controller.gd` 全局拖放层和 `cursor_manager.gd` 光标状态层;背包物品可投放到
|
||
背包格 / 快捷栏,释放到无效区域、右键、ESC、失焦取消;`UiManager` 在窗口打开时拦截不应穿透到
|
||
世界的键盘事件;`PlayerController`、`GroundItems`、`NetWorld` 接入 PICK / TALK / ATTACK 等
|
||
首版状态。修正装备窗口拖放的 wire 坐标,并补齐普通 J、Ctrl+Q 和反引号拾取入口。新增
|
||
`mouse_controller_test.gd`;相关 Godot 回归全绿。复杂窗口逐项 mousemodule 语义、真实 `.sub`
|
||
光标资源、CJK IME 和真服 / 真机仍未验收。
|
||
|
||
- [x] **增量 55 —— W4 §9.4 / MilesLib 音频子集**:`audio.gd` 已实现参考端的
|
||
`SetPosition/SetDirection/Update` 监听器接口、普通 / 环境 / 角色 3D 播放、200/1000 坐标缩放、
|
||
参考音量曲线、角色声音 5000cm / 0.3 秒限频、实例停止、全停和音乐限幅淡出;`.msa` 本地动作声音
|
||
已接入角色声道。新增 `audio_miles_test.gd`,覆盖常量、换算和限频规则并通过。
|
||
|
||
- [x] **增量 56 —— W3 §4.2/§4.4/§4.5/§4.6/§4.7 / 复杂窗口物品态**:仓库增加 5×9 网格和分页,
|
||
支持背包↔仓库指定格、仓库内移动和仓库↔背包;商城增加分页网格并支持商城→背包;交易增加 12 个
|
||
指定投放格;NPC 商店增加 BUY/SELL 模式、出售投放区和 BUY/SELL 光标联动;私人商店的 40 格已
|
||
接入全局物品态。新增 `complex_item_drop_test.gd`,覆盖仓库 / 商城 / 交易 / 商店落点及购买模式
|
||
拒绝出售投放;代码回归通过。金钱附着、stack split、anti-flag / 售价确认、真实 `.sub` 资源、
|
||
完整交易事务和真服 / 真机仍未验收。
|
||
|
||
- [x] **增量 57 —— W3 §4.2/§4.5/§4.6/§4.7 + W1 item 实例字段**:全局物品 payload 保留实例
|
||
`anti_flags`、`flags`、`sockets`、`attrs`;普通背包拖放携带整组数量,Shift+左键堆叠物品支持先选
|
||
附着数量,`MouseController` 增加 `ITEM_MONEY=-1` 独立金币态。交易按参考端补齐服务端 accept 镜像、
|
||
`USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限和已放金币不可编辑;
|
||
NPC 商店补齐实例 `ANTIFLAG_SELL`、`IsValuableItem` / `Is1GoldItem` 售价公式、pick/drop/money/
|
||
loginfail 音效和首次确认框;私人商店补齐 `ANTIFLAG_GIVE/MYSHOP`。新增 `proto_item_layout_test`、
|
||
`shop_rules_test`,库存 / 鼠标 / 交易 / 摆摊 / 复杂窗口回归通过。参考端 safebox money 发送函数已核实
|
||
主动 `return false`,因此不实现伪 `/safebox_money` 命令;完整交易事务、真实 `.sub` 资源、真服 / 真机
|
||
仍待验收。
|
||
|
||
- [x] **增量 68 —— W4 §7.5 / `.msenv` 格式与静态云层**:按 `MapUtil.cpp` 补齐参考缺省值、
|
||
Fog `IsDensity`、Filter、SkyBox `BTextureRenderMode`、六面天空文件名和 `Reserved`;
|
||
`environment_builder.cpp` 通过 `AssetResolver` 将真实云层贴图接入
|
||
`ProceduralSkyMaterial.sky_cover`,并把 Godot 暂无等价渲染载体的参数写入
|
||
`WorldEnvironment` 元数据;修正 macOS 环境路径强制小写问题。`formats_map_test`(内联 + 真实 A1)、
|
||
`environment_test`、`world_probe` 和受影响回归通过;在该批次完成时,六面天空、双方向光分层、
|
||
云层 UV / 高度动画、滤色实际合成、风参数、lens flare、昼夜天气联动和真机视觉仍待实现 / 验收,
|
||
后续增量 69/70/71/72 已分别补上双光层、texture-mode 六面天空首版、参考风缺省和纹理天空云层组合首版。
|
||
- [x] **增量 69 —— W4 §7.5 / 背景光与角色光分层**:按
|
||
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264` 保留背景 / 角色两套方向光;
|
||
`Sun` 使用 layer 1,仅照地形 / 水面 / 建筑 / 树,新增 `CharacterLight` 使用 layer 2,仅照
|
||
`Metin2Model`、装备和 LOD。换图卸载同时清理两盏灯;`environment_test`、`player_motion_test`
|
||
和 C++ 构建通过。SpeedTree 自身风参数、真实阴影和真机视觉仍待验收。
|
||
- [x] **增量 70 —— W4 §7.5 / texture-mode 六面天空**:按
|
||
`40250/ClientVS22/source/EterLib/SkyBox.cpp:483-542` 对齐 front/back/left/right/top/bottom
|
||
的面顺序和 UV;`environment_builder.cpp` 使用 Godot `sky` shader 加载真实天空面贴图,空面
|
||
回退 `.msenv` 渐变。新增 `skybox_test.gd`,以真实 CapeDragonHead 环境验证五面资源、空 bottom
|
||
面和 shader 安装;`cmake --build`、`ctest`、`environment_test`、`skybox_test` 通过。纹理天空
|
||
与云层同时存在时的叠加、云层动画、滤色、风、lens flare、昼夜联动和真机视觉仍待验收。
|
||
- [x] **增量 71 —— W4 §7.5 / SpeedTree 风缺省**:按
|
||
`40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42` 确认 `Environment_Load` 不读取风 token,
|
||
参考缺省为 `fWindStrength=0.2`、`fWindRandom=0`;树 proxy 不再误用 `1.0`,
|
||
`WorldEnvironment` 保存对应元数据,`environment_test` 增加断言。SpeedTree 真实几何、森林
|
||
shader 常量和随机风语义仍待实现 / 验收。
|
||
- [x] **增量 72 —— W4 §7.5 / texture-mode 天空与云层组合**:按
|
||
`40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557,869-927` 将有限水平云面接入 Godot
|
||
`sky` shader:`CloudScale` / `CloudHeight` 做厘米到米的平面投影,`CloudTextureScale` /
|
||
`CloudSpeed` 做 UV 缩放和 `TIME` 秒级滚动,并复现 `MODULATEINVALPHA_ADDCOLOR` +
|
||
`ONE/INVSRCCOLOR` 的颜色合成。真实 DawnMistWood 的五面天空 + 云层资源回归通过;云层
|
||
渐变过渡、滤色、SpeedTree 真实几何 / 风语义、lens flare、昼夜联动和真机视觉仍待实现。
|
||
- [x] **增量 100 —— W4 §6.3 / EffectLib emitter cycle 生命周期首版**:按
|
||
`40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` 对齐缺省
|
||
`CycleLength=0.05`,按 `ParticleSystemInstance.cpp:280-300` 接入有限 cycle / LoopCount 的
|
||
发射停止;重播时复位 CPU 时间门控,并移除把 cycle 长度错误写入粒子寿命的逻辑。`fx_test` 已
|
||
覆盖 cycle、LoopCount、独立粒子寿命和 OUTER fallback。OUTER / INNER 的精确径向速度、每周期
|
||
emission residue / 时间曲线重置、随机起始帧和每粒子随机语义仍待实现。
|
||
- [x] **增量 101 —— W3→M2 §5.2 / Quest 日志五行窗口、滚动和任务行事件**:按
|
||
`assets/root/uicharacter.py:651-703,1339-1346` 对齐 `QUEST_MAX_NUM=5`、滚动起始索引和
|
||
`event.QuestButtonClick(-2147483648 + questIndex)`;`quest_log.gd` 保存过滤后的任务快照,
|
||
超过 5 条显示滚动条,按默认 `season1/icon/scroll_open.tga` 或服务端 icon 渲染,补齐任务行点击
|
||
与 `quest_selected`。`quest_test.gd` 新增 7 条任务、首尾 5 行滚动和事件参数回归,测试通过。
|
||
世界箭头 / 屏幕边缘指示见增量 108,信件按钮条多列定位见增量 109;任务日志窗体 `_list` 自身
|
||
多列 / 分页、完整 `.sub` 图标视觉和真服任务生命周期仍待实现。
|
||
- [x] **增量 102 —— W3→M2 §5.3 / 钓鱼输入状态机首版**:按
|
||
`40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24`、
|
||
`PythonPlayerInput.cpp:419-425,506-549` 和 `Packet.h` 对齐钓鱼攻击键、方向 / 地面点击取消及
|
||
`CG_FISHING` 方向参数;`net_play.gd` 在钓鱼模式下阻断普通攻击,使用 `MapCoord` 的
|
||
yaw→server heading,`M2Client::fishing()` 再按参考 wire 量化为 `rot / 5`。收到
|
||
`GC_FISHING` START/REACT/STOP/SUCCESS/FAIL/FISH 时维护客户端意图状态。
|
||
`player_controller.gd` 接入方向键边沿与地面点击取消,保留 `NEW_CancelFishing` 的 500ms
|
||
节流;`quickbar.gd` 对特殊技能 123 接入 `activate_fishing()`。`netplay_test`、
|
||
`player_move_test`、`skill_test`、`gamescene_test`、`combat_fx_test` 已通过。
|
||
`GetFishingRot` 水面像素扫描、wrong-place 文案、鱼获物品落袋以及副本 / 坐骑 / 观战者
|
||
完整玩法仍待实现。
|
||
- [x] **增量 103 —— W3→M2 §5.3 / GetFishingRot 水面扫描首版**:按
|
||
`40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108`、
|
||
`InstanceBase.cpp:375-377` 和 `PRTerrainLib/Terrain.h:70-72` 接入真实 `ATTRIBUTE_WATER`
|
||
查询。新增 `Metin2World.get_fishing_rotation()` / `can_fishing_position()`,在角色前方
|
||
600cm 对当前 heading 左右 `0..180°` 每 10° 扫描并返回选中的服务端 heading;`NetWorld`
|
||
桥接地图层给 `NetPlay`,没有地图 provider 时保留协议 fallback。扩展重新编译通过,
|
||
`fishing_water_test.gd` 加载 A1 全 20 区块并找到真实水面回归通过。wrong-place locale
|
||
文案、鱼获 item proto / inventory 落袋和副本 / 坐骑 / 观战者完整玩法仍待实现。
|
||
- [x] **增量 99 —— W3→M2 §4.1 修改 5:远端 PC 真模型工厂与 `awPart[4]` 换装首版**:
|
||
`game_scene.gd` 对 `ch_type==0` 远端 PC 创建 `RemotePlayerView`,复用 `PlayerView` 的 race /
|
||
GR2 / MSA 装载;新增远端专用 `EquipModel.setup_remote()`,只消费
|
||
`awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()`。`net_world.gd` 改为四字段
|
||
`set_armor→set_weapon→set_head→set_hair`,并以真实 warrior 资源回归。当前仍待所有职业 / race、
|
||
变身字段、真实远端装备视觉和真服多角色验收。
|
||
- [x] **增量 73 —— W2 §3.7 / 受击状态统一**:删除 `net_play.gd` 里参考端不存在的
|
||
`_hitstun_until` 本地硬直计时器;「能否动作」统一走
|
||
`_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`
|
||
三个函数(对齐 `InstanceBase.cpp:1247` ≈ `__IsSyncing` / `InstanceBaseBattle.cpp:355` /
|
||
`:310`)。C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露、`mut_knockdown`
|
||
落位),本地由受击 `.msa` 播放生命周期驱动(`player_view.is_in_hit_reaction()`),
|
||
动作结束清零。`combat_fx_test` 断言改写,C++ 19/19 + 相关 GDScript 回归全绿。
|
||
远端实体 `knock_down` 填充留待后续增量(仅影响远端受击表现,不涉及输入闸门)。
|
||
- [x] **增量 74 —— W2 §3.3 / 普攻节奏硬编码**:`formats/msa.{h,cpp}` 新增
|
||
`Group ComboInputData` + `Group AttackingData` 解析(对齐
|
||
`EterGrnLib CGrannyMotion::LoadMotionData` → `CRaceMotionData::TComboInputData /
|
||
TAttackingData`);`metin2_anim` 导出并绑定 `get_motion_data()`。`net_play.gd`
|
||
删除 `ATTACK_RANGE`,改为 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐
|
||
`InstanceBaseBattle.cpp:294 NEW_IsClickableDistanceDestInstance`),普攻节奏改为
|
||
`_current_attack_period()` = `(.msa next_combo 或 DEFAULT_ATTACK_PERIOD) /
|
||
(bAttackSpeed/100)`,收 info 包时不再把 `attack_period` 写死。弓箭 `__GetBowRange()`
|
||
留待 §3.5。C++ 19/19 + `combat_fx_test` / `netplay_test` 等 GDScript 回归全绿。
|
||
- [x] **增量 75 —— W2 §3.4 / 目标预留状态机**:`net_play.gd` 新增
|
||
`enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` +
|
||
预留字段,`_process` 每帧跑 `_update_auto_attack()`(照抄 `PythonPlayer.cpp:207
|
||
__Update_AutoAttack` 的目标存在 / 死亡 / 骑乘 / charge-skill / 睡眠分支)→
|
||
`_refresh_reserved()` 分派。`_reserve_process_click_actor()` 严格 10 步对齐
|
||
`PythonPlayerInput.cpp:877`(出距离 `pc.walk_to` 靠近、不可攻击走 `click_npc`、
|
||
`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经新 `signal cannot_act(code)`、
|
||
第 10 步按 §3.3 节奏 gate `_do_attack_swing`)。安全区 / 弓箭 / 骑乘 / dash 等服务端
|
||
未下发的判定位先用实体键 + 本地占位接线(同 §3.7 `knock_down`)。CLICK_ITEM /
|
||
CLICK_POSITION / USE_SKILL 分派留桩。`netplay_test` 新增 5b 段;C++ 19/19 +
|
||
其余 GDScript 回归全绿。
|
||
- [x] **增量 76 —— W2 §3.1 / 本地移动 Src/Dst 模型**:`player_controller.gd` 去掉
|
||
`_move_target` / `_has_target` 的直线改坐标,改为 `_src_pos` / `_dst_pos` / `_is_going` /
|
||
`_dst_rot` 模型,`walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`:太近
|
||
(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门(`frozen` / `locked` / `moving_skill`
|
||
→ `_can_translate()`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s 重试
|
||
(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。
|
||
`_process` 每帧朝 `_dst_pos` 推进、抵达清 `_is_going`,WASD 分支置 `_is_going = false`。
|
||
新增 `project/player_move_test.gd`(5 组);C++ 19/19 + 其余 GDScript 回归全绿。
|
||
未做:Actor 间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似)、坐骑
|
||
`MODE_HORSE*`(依赖 §6.4 + §3.2)。
|
||
- [x] **增量 77 —— W2 §3.2 / 远端同步命令队列(C++ 核心)**:`Entity` 新增
|
||
`std::deque<StateCmd> state_queue` + `mov_after_*` / `skip_collision`;`mut_move()`
|
||
改为入队 `StateCmd`(≈ `PushTCPState`)后 `drain_state_queue()` 释放到期项,m2dev
|
||
`apply()` 与 classic parser 都走 `mut_move(...,arg,time)`,删掉旧 `start_move()` 直改
|
||
坐标路径。新增 `process_states()` + `apply_state_cmd()` 1:1 实现 `StateProcess` 的
|
||
switch(`FUNC_WAIT` 1.0 阈值、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 阈值 + `m_kMovAfterFunc`
|
||
到达后动作、`skip_collision`);释放门按 `m_server_frame_ms`(新 `set_server_frame_ms`,
|
||
为 0 时立即释放 = 旧行为)+ `__CanProcessNetworkStatePacket`(dead / knock_down hold
|
||
队列)。`tick()` 每帧先 `process_states()`。新增 `extension/tests/net_state_queue_test.cpp`;
|
||
`net_entity_test` 的 `FUNC_ATTACK` 断言按新语义改写;C++ 20/20 + GDScript 回归全绿。
|
||
留给增量 78:net_play.gd 上行 6 事件回调 + 节流常量、net_world.gd 去掉自有 lerp、
|
||
从 `GC_HANDSHAKE` `lDelta` 接真实服务器帧钟。
|
||
- [x] **增量 78 —— W2 §3.2 / 上行 6 事件回调 + net_world 去 lerp**:`net_play.gd` 上行
|
||
按 `PythonPlayerEventHandler.cpp` 拆成 6 个回调——统一出口 `_send_state(func,arg,pos)`
|
||
(≈ `SendCharacterStatePacket`);`OnMove` 立即发 `FUNC_MOVE` 并重置 moving/waiting 两个
|
||
节流窗,`OnMoving` 300 ms 节流,`OnWaiting`(新 `_tick_on_waiting()`,`_process` 每帧)
|
||
100 ms 节流且位移 ≥ 1 cm 才发 `FUNC_WAIT`,`OnStop` 不节流收尾 `FUNC_WAIT`,`OnAttack`
|
||
在 `CG_ATTACK` 外额外 `_send_state(FUNC_COMBO, motion, pos)`,新增公共入口
|
||
`on_use_skill(motion,arg)` → `FUNC_SKILL|motion`(调用点属 §3.8)。删旧
|
||
`SEND_INTERVAL/SEND_DIST/_last_sent_t` 单一节流。`net_world.gd` `_process` 改
|
||
`n.position = _grounded(e.pos)` 直接跟随,去掉自有 `follow_lerp`——位置插值全交 C++
|
||
`EntityStore::tick()`,只朝向仍 GDScript 平滑。`netplay_test` 加第 11 段、`netbridge_test`
|
||
的 near-move 断言改写;C++ 20/20 + 7 个 GDScript 回归全绿。留给增量 79:⑤ 从
|
||
`GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟(`ClassicStream` 已存时间基,待暴露访问器)。
|
||
- [x] **增量 79 —— W2 §3.2 / 真实服务器毫秒帧钟(part C,收官)**:`ClassicStream` 暴露
|
||
`server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)——`HDR_HANDSHAKE` 处理里已存的
|
||
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`,帧钟即
|
||
`now_ms() - m_client_time_base + m_server_time_base`;首个握手落地前返回 0。
|
||
`ClassicSession::process()` 在 `m_world.tick()` 前 `set_server_frame_ms(m_stream.server_frame_ms())`,
|
||
于是 classic 后端的 `drain_state_queue()` 按服务器时间放行队列而非即时释放;m2dev 后端无
|
||
握手 `lDelta` 对应物,保持 0 = 即时释放,无回归。`drain_state_queue` 加防呆阀
|
||
`STATE_QUEUE_MAX_WAIT_MS = 1000`——`chk_time` 领先帧钟超过 1 s 视为异帧钟 `dwTime`,立即
|
||
放行以免远端 Actor 永久冻结(参考端无此上限,属有意偏离,已在 §3.2 记录)。
|
||
`net_classic_stream_test` 加帧钟段(握手前 0、握手后 ≈ `hs.time+lDelta`、单调)、
|
||
`net_state_queue_test` 加第 6 段(防呆阀边界);C++ 20/20 + 7 个 GDScript 回归全绿。
|
||
§3.2 至此完成,真实服务器 `dwTime` 时基仍待联机核验。
|
||
|
||
- [x] **增量 80 —— W2 §3.5 / PC 连击段表解析(part A)**:参考端 PC 的连击段表 **不在**
|
||
`.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)——它由 `playersettingmodule.py`
|
||
的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用在 `CRaceData` 上建起来
|
||
(`PythonCharacterManagerModule.cpp` → `CRaceData::ReserveComboAttack` / `RegisterComboAttack`,
|
||
`RaceData.cpp`)。新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`):按
|
||
`def __LoadGame<Class>Ex` 分四个职业块,解析那批调用为
|
||
`map<MAKE_COMBO_KEY(motionMode,comboType), vector<段号>>`(段号 = `NAME_COMBO_ATTACK_*` =
|
||
14..21)。照 `CRaceData` 语义:`Reserve` 建长度 `count` 的 0 向量(`map::insert`,重复 key
|
||
不覆盖)、`Register` 按下标写(key 未 Reserve 或下标越界则跳过)。认得 `chr.MOTION_MODE_*`
|
||
(= `CRaceMotionData::EMode`)、`chr.MOTION_COMBO_ATTACK_N`(= 13+N)、`chr.MOTION_NORMAL_ATTACK`
|
||
(= 13)、`COMBO_TYPE_N` / `COMBO_INDEX_N`(= N−1)、裸整数。新增 `formats_combo_table_test`
|
||
(合成片段断言 + `M2_ASSETS` 存在时对真实 `playersettingmodule.py` 断言 warrior/sura 1H、
|
||
shaman FAN);C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 81:`player_controller.gd` 连击
|
||
状态机(`_cur_combo_index` / `_is_pre_input` / `_is_next_pre_input`)改用此表 + `.msa`
|
||
`ComboInputData` 三时间,替掉当前 `(_combo + 1) % 3` 硬编码;之后命中窗(`HitDataContainer`)
|
||
+ 发包挂动作事件。
|
||
|
||
- [x] **增量 81 —— W2 §3.5 / PC 连击状态机(part B)**:`net_play.gd` 新增
|
||
`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo`,
|
||
逐行对齐 `CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` /
|
||
`__OnEndCombo` / `__ClearCombo`(`REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`),
|
||
替掉 `(_combo + 1) % 3`:`_combo_index==0` 直接推进;否则按 `.msa` `ComboInputData` 三时间
|
||
(`pre_input_time` / `direct_input_time` / `input_limit_time`)判 `_is_pre_input` 挂起 / 直接推进 /
|
||
弓模式 `_is_next_pre_input`;无 combo 输入组用 `duration*0.9` 兜底;`_combo_process()` 每帧触发
|
||
挂起输入、动作回 Wait 时 `_clear_combo()`;段号越界不推进;末段仅骑乘复位到 1。段号来自
|
||
新静态绑定 `Metin2AnimPlayer.parse_combo_tables(py_path)`(`extension/src/metin2_anim.{h,cpp}` →
|
||
增量 80 的 `fmt::parse_player_combo_tables_file`),按 `race & 3` 选职业块、
|
||
`combo_motion_mode<<16|type` 取段序列;缺段表退化为单段普攻。`_emit_swing` 保持增量 78 的两包
|
||
(`CG_ATTACK{bType=0}` + `CG_CHARACTER_MOVE{FUNC_COMBO, 段号}`)。`game_scene.gd` 装配时
|
||
`net_play.set_asset_root(_assets)`。`combat_fx_test.gd` 加 `FakeAnim` + 连击状态机全路径断言。
|
||
C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 82+:`.msa` `HitDataContainer` 多命中窗导出、
|
||
`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION`、
|
||
武器种类 → `combo_motion_mode` 映射。
|
||
|
||
- [x] **增量 82 —— W2 §3.5 / `.msa` 多命中窗导出(part C,修改 1)**:`formats/msa.{h,cpp}` 把
|
||
`Group AttackingData` 按 `GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法
|
||
解析成 `std::vector<Msa::HitWindow>`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`,
|
||
无 `HitDataCount`)= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` +
|
||
`Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每窗带
|
||
`start_time`/`end_time`/`bone`/`weapon_length` + `List HitPosition` 采样
|
||
(`{time, last_pos, pos}`,7 浮点一组);`attacking_type` 兼容 `AttackType`/`AttackingType`
|
||
两种键,新增 `motion_type` / `hit_limit_count`;`attack_start_time`/`attack_end_time` 保留为
|
||
第一个窗起止(旧调用方兼容)。`formats/textscript.cpp` 同时补 `List Name { ... }` 块解析
|
||
(原来 `List` 会把 `{` 吞进普通行、还串掉其后的 `Group`,`palbang.msa` 的
|
||
`MotionEventData` 就丢了)。`extension/src/metin2_anim.cpp get_motion_data()` 新增
|
||
`hit_windows`(数组:`{start_time,end_time,bone,weapon_length,samples:[{time,last_pos:Vector3,pos:Vector3}]}`)
|
||
+ `motion_type` / `hit_limit_count`。`formats/tests/msa_test.cpp` 加内联单窗 / 新式双窗合成
|
||
断言 + `M2_ASSETS` 时对真实 `warrior/general/attack`(17 采样)、`twohand_sword/combo_05`
|
||
(武器长 150 / 37 采样)、`assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、
|
||
`warrior/skill/palbang`(`AttackingData` 后 `MotionEventData` 3 事件)断言;`CMakeLists.txt`
|
||
给 `formats.msa_loop_data` 加 `M2_ASSETS` 环境透传。C++ 21/21 + 7 个 GDScript 回归全绿。
|
||
留给增量 83+:修改 4(`THitData` 窗口命中判定 + `OnHit`→`CG_ATTACK` + `FlushVictimList` 帧末
|
||
`CG_SYNC_POSITION` ≤16)、武器种类 → `combo_motion_mode` 映射。
|
||
|
||
- [x] **增量 83 —— W2 §3.5 / 命中窗几何判定 → `OnHit`(修改 4,§3.5 收官)**:`net_play.gd`
|
||
新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` / `motion_type` /
|
||
`hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、`_attack_process(dt)`
|
||
(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + `ActorInstanceCollisionDetection.cpp:333
|
||
__NormalAttackProcess`):每帧动作时间窗 `[t-dt, t]` 与某 `hit_window [start,end]` 相交时,
|
||
遍历 `client.get_entities()` 里可攻击 / 未死 / 双方不在安全区的实体,`net_world.node_for(vid)`
|
||
取世界位;`_hit_deduped()`(`m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标一次、其余按
|
||
`invisible_time` 冷却)+ `_register_hit()`(每窗命中上限:NORMAL/COMBO 16、SKILL
|
||
`hit_limit_count`)→ 命中发 `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`,
|
||
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush`。`_emit_swing()` 改为**只有拿不到
|
||
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`。`_flush_victim_list()`(对齐
|
||
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`。
|
||
几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、
|
||
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。
|
||
`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
|
||
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
|
||
`combo_motion_mode` 映射。
|
||
|
||
- [x] **增量 84 —— W2 §3.5 / 武器种类 → `combo_motion_mode`(修改 6)**:`net_play.gd` 补全
|
||
`MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+ `WEAPON_SUB_*`(`CItemData::EWeaponSubTypes`)
|
||
+ `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射 `static motion_mode_for(item_type, weapon_sub,
|
||
mounted, poly, dress)` —— 1:1 复刻 `CInstanceBase::RefreshState`(`InstanceBase.cpp:2851`)里
|
||
`SetMotionMode` 分支:Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、
|
||
钓竿→骑乘 HORSE / 否则 FISHING、骑乘按 subtype→HORSE_{ONEHAND,TWOHAND,DUALHAND,FAN,BELL,BOW}
|
||
或 HORSE、否则按 subtype→{ONEHAND,TWOHAND,DUALHAND}_SWORD / BOW / FAN / BELL、ARROW 及其余→
|
||
GENERAL。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`(为 0 回退 `get_equipment()` 的
|
||
`WEAR_WEAPON` 槽),`_refresh_motion_mode()` 读 `proto.item(vnum)` 的 `type`/`sub_type` +
|
||
实体 `mount_vnum`/`polymorph` → `set_combo_motion_mode()`(换模式会 `_clear_combo()`)。
|
||
接线:`_on_entity_info`(主角设定 / `GC_CHARACTER_UPDATE` parts 变)末尾、新 `_on_inv_changed_mode`
|
||
(`inventory_changed` window==EQUIPMENT)、`mount_changed` 信号(vid==主角)。`combat_fx_test.gd`
|
||
加 `FakeProto` + 15 段 `motion_mode_for` 映射断言 + 2 段端到端(`parts[WEAPON]`=匕首→
|
||
DUALHAND_SWORD 且清连击、上马→HORSE_DUALHAND_SWORD)。C++ 21/21 + 7 个 GDScript 回归全绿
|
||
(`equip_model_test` 亦 PASS)。留给增量 85+:弓箭 `__GetBowRange()`(§3.5 命中距离)、
|
||
§3.6 箭矢 FLY 事件。
|
||
|
||
- [x] **增量 85 —— W2 §3.5 / 弓箭 `__GetBowRange()`(可点击 / 攻击距离)**:`net_play.gd`
|
||
`_clickable_distance()` 逐分支对齐 `CInstanceBase::NEW_IsClickableDistanceDestInstance`
|
||
(`InstanceBaseBattle.cpp:294`):默认 150 cm → `_is_bow_mode()`(`combo_motion_mode` 为
|
||
`MODE_BOW` / `MODE_HORSE_BOW`,对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)时
|
||
改用 `_bow_range_cm()` → 目标 `IsNPC()` 覆盖为 500 → 目标 `IsResource()` 覆盖为 100(后写的
|
||
`if` 覆盖前面的)。新增 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus`
|
||
—— 1:1 复刻 `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`:`2500.0f - 100.0f` 再加主角
|
||
`GetStatus(POINT_BOW_DISTANCE)`,参考端按 cm 直加不 ×100)。`_bow_distance_bonus` 由
|
||
`_on_points()` 读 `points["bow_distance"]` 填充;`wire.h` 补 `POINT_BOW_DISTANCE = 34`,
|
||
`M2Client::get_points()` 增 `d["bow_distance"] = p.v[POINT_BOW_DISTANCE]`。`combat_fx_test.gd`
|
||
§3.5 段加 8 段断言(非 bow=150 / bow 基础=2400 / bow 目标=__GetBowRange / NPC 覆盖=500 /
|
||
采集物覆盖=100 / HORSE_BOW 也算 bow / 加成叠加 / `_on_points` 读 `bow_distance`)。
|
||
C++ 21/21 + 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 86:§3.6 箭矢
|
||
`.msa` `MOTION_EVENT_TYPE_FLY` → `CG_FLY_TARGETING` / `CG_SHOOT`,替掉 `game_scene.gd` 约 1.5 s
|
||
定时器兜底(`PythonPlayerEventHandler.cpp:235`)。
|
||
|
||
- [x] **增量 86 —— W2 §3.6 / 弓箭 FLY 事件(`OnSetFlyTarget` / `OnShoot`)**:拆掉
|
||
`game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底,换成
|
||
`_pending_shots: Array[int]`(FIFO,上限 `MAX_PENDING_SHOTS = 8`,满则丢最旧)——
|
||
1:1 对齐 `CNormalBowAttack_FlyEventHandler_AutoClear`:每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)`
|
||
压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event` 的 `type 6`)
|
||
`pop_front()` 弹一个发 `client.shoot()`(pop 即自清 = AutoClear,队列天然支持连射多箭在途)。
|
||
对齐 `CActorInstance::ProcessMotionEventFly`(`ActorInstanceMotionEvent.cpp:290`:
|
||
`m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`:
|
||
`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`:
|
||
`_is_bow_mode()` 时先 `_send_fly_target()` —— 取 `_target_vid`(回退 `_vid_reserved`)在
|
||
`net_world` 里的节点,按 `MapCoord.to_server_cm()` 换 server-XY 后 `client.add_fly_targeting(vid, x, y)`
|
||
(对齐 `SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition())`;
|
||
`OnGetFlyTargetPosition` = 目标包围球中心世界坐标,其平面分量与脚点一致,
|
||
`CG_FLY_TARGETING` 只带平面坐标,`ActorInstanceFly.cpp:4`)——再 `bow_shot_fired.emit(_swing_skill)`
|
||
让 `game_scene` 入队。新增 `net_play` 信号 `bow_shot_fired(skill)`、`_send_fly_target()`,
|
||
`game_scene._queue_shot()` + `net_play.bow_shot_fired.connect(_queue_shot)` 接线(弓技能路径
|
||
经 `quickbar.skill_activated` 的 `is_ranged` 分支同样改走 `_queue_shot`,`use_skill` 里 C++ 已发
|
||
fly-targeting 不重复)。`combat_fx_test.gd` 加 `FakeClient.add_fly_targeting/shoot` + 6 段
|
||
§3.6 断言(非弓模式无 FLY 事件 / 弓挥击起手发 `CG_FLY_TARGETING(vid)` / 坐标 = 目标 server-XY /
|
||
`bow_shot_fired(0)` / 无目标不发);`eterngrn_polish_test.gd` FLY 段改为 FIFO 连射断言
|
||
(连压 77/78 → 依次 pop → 队空不再发)。C++ 21/21 + 9 个 GDScript 回归全绿。留给后续:
|
||
飞行物实体本身的生成 / 命中判定(`CFlyingManager` / `FlyingInstance`)、`__CanShot` 弓箭
|
||
合法性、真服 `CG_FLY_TARGETING` z 分量。
|
||
|
||
- [x] **增量 87 —— W2 §3.8 / TSkillData 判定位解析(修改 2)**:POC 无 C++ skill proto
|
||
(`extension/src/proto` 只有 item/mob),技能数据全在 GDScript 的 `project/ui/skill_table.gd`
|
||
里读 `locale/locale/<lang>/skilldesc.txt` TSV。故在该文件按 `RegisterSkillDesc`
|
||
(`REF/UserInterface/PythonSkill.cpp:280`)的做法,把第 11 列 attributes / 第 12 列 weapon
|
||
的 `|` 分隔记号串折成 `attr_bits` / `need_weapon_bits` 两个位掩码(`_fold_bits()` 静态方法,
|
||
记号名 1:1 抄 `PythonSkill.h:92-128` 的 `SKILL_ATTRIBUTE_*` / `SKILL_NEED_WEAPON_*`,
|
||
未知记号忽略 = 参考端“map 查不到就跳过”;`DOUBLE_SWORD` 复用 `DAGGER` 位;weapon 位 =
|
||
`1 << CItemData::WEAPON_*`,`GameLib/ItemData.h:63-75`)。`load_file` 时连同 `skill_type`
|
||
(JOB 列经 `m_SkillTypeIndexMap` → `SKILL_TYPE_{NONE,ACTIVE,SUPPORT,GUILD,HORSE}`,
|
||
`PythonSkill.cpp:909-918`)一起预存进 entry。新增 / 改写谓词逐条对齐 `CPythonSkill`:
|
||
`is_can_use_skill`(`IsCanUseSkill` = 非 PASSIVE,`PythonSkill.cpp:1407`)、`is_standing`、
|
||
`is_need_target`、`is_need_corpse`、`is_auto_search_target`、`can_change_direction`、
|
||
`is_horse_skill`、`is_moving_skill`、`is_melee`、`is_charge_skill`、`is_use_hp`、
|
||
`is_time_increase`、`is_need_empty_bottle`、`is_need_poison_bottle`、`can_use_for_me`、
|
||
`is_only_for_alliance`、`can_use_if_not_enough`、`is_only_for_guild_war`、`is_fan_range`、
|
||
`is_circle_range`、`has_weapon_limitation`、`can_use_weapon_type(id, weapon_type)`
|
||
(`CanUseWeaponType` = 无限制恒真,否则 `dwNeedWeapon & (1<<type)`,`PythonSkill.cpp:1143`)、
|
||
`is_need_bow`、`skill_type_of` / `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle`
|
||
/ `is_ranged` / `can_level_up` 改为查位(等价,`is_ranged` 现含 ARROW 位)。`skill_test.gd`
|
||
加 34 条 §3.8 断言(skill 1/16/18/46/63/106/109/137/138/151/152 的位面 + `_fold_bits`
|
||
大小写/分隔宽容 + `DOUBLE_SWORD` 位)。C++ 21/21 + 9 个 GDScript 回归全绿。留给增量 88+:
|
||
`player_skill.gd` 移植 (a)(b)(c)(d) 三层校验、`M2Client::use_skill()` 带符号 + 范围校验(§3.9)。
|
||
|
||
- [x] **增量 88 —— W2 §3.8 / `player_skill.gd` 技能三层合法性校验(修改 1)**:新建
|
||
`project/player_skill.gd`(`extends RefCounted`,纯校验器,不发包不播动作),逐段移植
|
||
`REF/UserInterface/PythonPlayerSkill.cpp` 的 `ClickSkillSlot`(a):106 /
|
||
`__CanUseSkill`(b):439 / `__CheckSkillUsable`(c):166 / `__UseSkill`(d):460 —— 每个
|
||
`return` 分支对应一个返回码,`code` 用 `OnCannotUseSkill` 原字符串,另加静默码集
|
||
(`PASSIVE` / `GUILD_ROUTED` / `TOGGLE_OFF` / `SPECIAL_SKILL` / `PRIVATE_SHOP` / `BAD_SLOT`
|
||
/ `NO_SKILL_DATA` / `CANNOT_ACT` …)对齐参考端“静默 return”。命名常量 `SKILL_MAX_NUM=255`
|
||
(`Packet.h:2041`)/ `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` /
|
||
`ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` / `SKILL_INDEX_FISHING=123` /
|
||
`SKILL_INDEX_COMBO=122`(`PythonPlayerModule.cpp:6-7`)。TSkillData 判定全走增量 87 的
|
||
`skill_table.gd` 谓词;`__CheckRestSkillCoolTime` = 本地槽 `cd_end`(`m_sysIsCoolTime` 关则不查),
|
||
`__CheckDashAffect` 分支 1:1 保留,`__HasItem` 扫 `client.get_inventory()`,`__HasEnoughArrow`
|
||
用 `net_play._arrow_count` 占位,`__CheckShortMana`/`__CheckShortLife` 暂为桩(POC 无
|
||
skilldesc 的 NeedSP/NeedHP 公式解析)。目标解析 `_resolve_target` 按 `__UseSkill:520-650`
|
||
判联盟 / 安全区 / 可攻击 / 尸体 / 需要目标并回传 `target_vid`;距离 / `__ReserveUseSkill` /
|
||
扇形·圆形多目标归 §3.4/§3.9。接线:`net_play.gd` +`skill_context()` 快照
|
||
(observer / mounting / in_safe / fishing / using_skill / can_act / dash / weapon_sub_type /
|
||
arrow_count);`quickbar.gd` +`net_play` 引用 + `_skill_gate`(`PlayerSkill.new()`)+
|
||
`skill_rejected(id, code)` 信号,`activate()` skill 分支先 `click_skill_slot()`,ok 才发
|
||
`use_skill`/`cast_skill`,`TOGGLE_OFF` 只发 `use_skill` 关开关技,其余非静默码发
|
||
`skill_rejected`;`game_scene.gd` 把它转 `net_play.cannot_act` 走同一条文案通道。新
|
||
`project/player_skill_test.gd` 逐分支断言((a) BAD_SLOT / NO_SKILL_DATA / GUILD_ROUTED /
|
||
PASSIVE / TOGGLE_OFF;(b) OBSERVER / CANNOT_ACT / NO_RIDING_SKILL / 骑乘术 lv20;
|
||
(c) NOT_HORSE_SKILL / HAVE_TO_RIDE / IN_SAFE / REMOVE_FISHING_ROD / NOT_YET_LEARN /
|
||
NOT_MATCHABLE_WEAPON / EMPTY_ARROW / WAIT_COOLTIME / dash+charge 跳冷却;
|
||
(d) PRIVATE_SHOP / ALREADY_CASTING / SPECIAL_SKILL / 目标类型矩阵 / 结盟技回落自身);
|
||
`skill_test.gd` FakeClient `get_entity` 补 `kind`。C++ 21/21 + 12 个 GDScript 回归全绿。
|
||
留给增量 89+:修改 3(`M2Client::use_skill()` 带符号 + 范围校验,§3.9)、`__CheckShortMana` /
|
||
`__CheckShortLife` 落地(依赖 `.msk` NeedSP/NeedHP 公式解析)、`__ChangeTargetToPickedInstance`
|
||
拾取回退、扇形/圆形多目标 fly-targeting。
|
||
|
||
- [x] **增量 89 —— W2 §3.8 修改 3 / `use_skill` 边界收紧 + 短蓝短血校验落地**:①
|
||
`extension/src/net/net_bounds.h` 新增 `SKILL_MAX_NUM=255`(`Packet.h:2041`)+
|
||
`skill_index(v) = 0 < v < 255` 谓词;`extension/src/net/m2_client.cpp::use_skill(int
|
||
skill_id, int target_vid)`(形参增量 52 起即带符号)的 `skill_id` 护栏从
|
||
`skill_id <= 0 || !bounds::u32()` 收紧成 `!mtnet::bounds::skill_index(skill_id)`,对齐
|
||
`ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`target_vid` 保留 `>= 0 && bounds::u32`;
|
||
`cast_skill()` 的 `motion_idx ∈ [0,0x7f]` 不动(7 位 wire 位段)。`net_bounds_test.cpp`
|
||
+4 断言(`SKILL_MAX_NUM==255`、端点 1/254、越界 0/255/-1、拒大值)。② `project/ui/skill_table.gd`
|
||
`_load_cooltime` 更名 `_load_msk`,加解析 `.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]`
|
||
(`LocaleService_GetSkillPower` INTERNATIONAL 表,`REF/EterLib/Locale.cpp:126`,
|
||
`SKILL_POWER_NUM=50`、`level >= 50 → 0`)+ `_skill_point(level)` + `need_sp(id, level)`
|
||
(≈ `GetNeedSP`,`PythonSkill.cpp:1426`;`Expression.parse(formula, ["SkillPoint"])` 求值。
|
||
POC 约定:无 `NeedSPFormula` 返回 `-1`(参考返回 0),避免误挡缺字段技能)+ `need_hp()`
|
||
(照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖,HP 需求也读 NeedSP 公式);
|
||
`cooldown_of` 的 `SkillPoint` 也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`,
|
||
`PythonPlayer.cpp:1037`),`max_level` 仍解析留给其它调用方。③ `project/player_skill.gd`
|
||
新增 `SKILL_INDEX_SUMMON=131`(`PythonPlayerModule.cpp:15`)+ 上下文字段 `cur_sp` / `cur_hp`
|
||
(`-1` = 未知不判),把 `_check_short_mana` / `_check_short_life` 从桩改实装:
|
||
`_check_short_mana` 逐条移植 `__CheckShortMana`(`PythonPlayerSkill.cpp:320`:summon `131`
|
||
豁免、非开关技 `need_sp == 0` → 坏数据拦、`can_use_if_not_enough` → `cur_sp <= 0` 拦、
|
||
否则 `need_sp != -1 && need_sp > cur_sp` 拦),`_check_short_life` 移植 `__CheckShortLife`
|
||
(`:354`:`!is_use_hp() → false` 放行、`need_hp > cur_hp` 拦;shipped `skilldesc.txt`
|
||
无 USE_HP 技能故实为放行)。二者只在 `check_skill_usable` 非冲刺 `else` 支路命中,
|
||
分别转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`。④ `project/net_play.gd::skill_context()` 读
|
||
`client.get_points()` 的 `hp` / `sp` 填 `cur_hp` / `cur_sp`(缺则 `-1`)。⑤
|
||
`player_skill_test.gd` +“(c) §3.8 修改 3”段(`_skill_point(20)==0.5`、`_skill_point(0)/(50)==0`、
|
||
`need_sp(1,5)==52`、`need_hp==need_sp`、SP 不足→`NOT_ENOUGH_SP`、SP 充足→ok、SP 未知(-1)→ok、
|
||
无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 `NOT_ENOUGH_HP`),`_reset` 补
|
||
`cur_hp=-1` / `cur_sp=-1`。C++ 21/21 + 13 个 GDScript 回归全绿。§3.8 三层校验(修改 1/2/3)
|
||
至此全部落地。留给增量 90+:§3.10 技能升级门(audit 10)、`__ChangeTargetToPickedInstance`
|
||
拾取回退、扇形/圆形多目标 fly-targeting。
|
||
|
||
- [x] **增量 90 —— W2 §3.10 技能升级门(加号按钮门控 + `skill_up` 边界收紧)**:①
|
||
`extension/src/net/wire.h` `EPointTypes` 加 `POINT_SUB_SKILL=27` / `POINT_SKILL=28` /
|
||
`POINT_HORSE_SKILL=113`(`UserInterface/Packet.h`;`uicharacter.py` `skillPageStatDict`);
|
||
`m2_client.cpp::get_points()` 新增 `skill_active` / `skill_support` / `skill_horse` 三键
|
||
(镜像增量 85 的 `bow_distance`)。② `m2_client.cpp::skill_up(int skill_id)` 护栏
|
||
`skill_id <= 0 || skill_id >= POINT_COUNT` → `!mtnet::bounds::skill_index(skill_id)`
|
||
(= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。③ `project/ui/skill_table.gd`:
|
||
`load_file` 补 `RegisterSkillDesc` 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析
|
||
(`.msk` `maxlevel` 优先;shipped 22 列数据两列都缺,逻辑为兼容第三方数据);新增
|
||
`max_level_of(id)`(= `SSkillData::GetMaxLevel()`,`PythonSkill.cpp:1392`,缺值默认 20 =
|
||
构造默认 `byMaxLevel`,:1462)、`skill_level_limit(id)`(= `skillGetSkillLevelLimit`,
|
||
`PythonSkill.cpp:1741` = `byLevelLimit`;本 40250 数据恒 0)、`can_level_up_skill(id, level)`
|
||
(= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`;本 build 里前置技能 / 前置属性字段
|
||
`isRequirement` / `RequireStatDataVector` 从不被填充(构造默认,:1471)→ 1:1 化简为
|
||
`level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`)。④ `project/ui/skill_ui.gd`:
|
||
`refresh()` 里逐行移植 `uicharacter.py` 的
|
||
`RefreshSkillPlusButtonList`(:984)/ `__RefreshSkillPlusButton`(:937)/
|
||
`CanShowPlusButton`(:925)——`_can_use_skill_now()`(= `client.get_skill_group() != 0`,
|
||
对应 `__CanUseSkillNow` :1270)、`_stat_point_for(page)`(`SUPPORT`→`skill_support` /
|
||
`HORSE`→`skill_horse` / 其余→`skill_active`,三来源分开取)、`_can_show_plus(id, level,
|
||
grade, page, …)`(技能组门 → 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐 → `stat_point<=0`
|
||
隐 → `skillGrade!=0` 隐 → 马术页 `plv >= skill_level_limit(id)` 时 `level < 20` 才显 →
|
||
主动页 `table.can_level_up_skill(id, level)`)。按钮仍在 `_rebuild()` 为非被动 / 非
|
||
`CANNOT_LEVEL_UP` 技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()` +
|
||
`ShowSlotButton()`)。常量 `HIDE_SUPPORT_SKILL_POINT`(`uicharacter.py:21`)/
|
||
`RIDING_SKILL_MAX_LEVEL=20`(`__RefreshSkillPlusButton` HORSE 分支硬编码)。升级结果仍
|
||
由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动 `refresh()`,不本地伪造。⑤ `skill_test.gd`
|
||
FakeClient 补 `get_skill_group()` / `get_points()`;扩 `§3.10` 段(`max_level_of(1)==30` /
|
||
`(121)==20`、`skill_level_limit(1)==0`、`can_level_up_skill` 未满级 / 满级 / 超级 /
|
||
`CANNOT_LEVEL_UP` / 未知 id、加号显隐:主动页可见 / `skillGrade!=0` 隐 / `skill_group==0`
|
||
隐 / 该页点数 0 隐 / 辅助页整页隐 / 马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。
|
||
C++ 21/21 + 13 个 GDScript 回归全绿。留给增量 91+:`__ChangeTargetToPickedInstance`
|
||
拾取回退、扇形/圆形多目标 fly-targeting、§3.6 飞行物实体(`CFlyingManager` / `FlyingInstance`)。
|
||
|
||
- [x] **增量 91 —— W2 §3.4 其余三种预约模式 + `__ChangeTargetToPickedInstance` + 扇形/圆形补 fly-targeting**:
|
||
① `project/net_play.gd` `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection`,
|
||
`PythonPlayerInputMouse.cpp:287`)补齐自增量 75 留桩的三个 case:`_reserve_process_click_item()`
|
||
(MODE_CLICK_ITEM,:290——按 IID 查 `client.get_ground_items()` 世界坐标,`< 20cm`
|
||
`CLICK_ITEM_PICKUP_CM` 到手 → `_send_state(FUNC_WAIT)` + `pickup_item` + 解冻 + 清预约,
|
||
否则 `walk_to`;物品消失 → 清预约)、`_reserve_process_click_position()`(MODE_CLICK_POSITION,
|
||
:320——`_can_translate()` 未锁且 `_reserved_delay <= 0`(`_process` 逐帧递减 ≈
|
||
`NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` + 清预约)、
|
||
`_reserve_process_use_skill()`(MODE_USE_SKILL,:340——`dist_cm`(含 `_is_huge_race` 的
|
||
`-200cm` `HUGE_RACE_DIST_FIX_CM` 修正)`< _skill_range_reserved` → `set_target` +
|
||
`use_skill_hook(slot)` 成功才清预约,否则 `walk_to`;目标丢失 → 清预约)。② 预约入口
|
||
`reserve_click_item(iid)` / `reserve_click_ground(pos)`(`_reserved_delay = 0.1`)/
|
||
`reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`,
|
||
`range > 100` 先减 10 `RESERVE_SKILL_RANGE_TRIM_CM`)/ `pick_ground_item(iid)`(= `__OnPressItem`——
|
||
清预约 + auto-attack,在 `CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。
|
||
`_clear_reserved()` 同时复位 `_ppos_reserved` / `_skill_range_reserved`。③ `_on_pick()` 里
|
||
`__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode == USE_SKILL && _dash_active`
|
||
时只改 `_vid_reserved` 不选中;战斗目标改走 `_change_target_to_picked_instance(vid)`(=
|
||
`__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定
|
||
PC/怪,否则 `set_target(0)` + `clear_target`;NPC/石头/warp 的交互仍由 `_on_pick` 先分流到
|
||
`click_npc`)。`_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用
|
||
`proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似,查不到 → false
|
||
(文档化的 seam)。④ 扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746`
|
||
`if (dwTargetMaxCount>0 …)` 块):`send_fly_targeting(target_vid, range_cm, max_count, shape)`——
|
||
主目标的 `CG_FLY_TARGETING` 已由 `M2Client::use_skill` 发过,这里只补附加目标——
|
||
`_instances_in_fan_range()`(= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:
|
||
先按圆形距离取,再按随距离收窄的半角 `fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN`
|
||
过滤,常量 `FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/
|
||
`_instances_in_circle_range()`(:243,无方位过滤)内的其它可攻击敌人经
|
||
`client.add_fly_targeting(vid, sx, sy)`(= `SendAddFlyTargetingPacket`)补齐;名额不足时
|
||
`_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10°
|
||
`FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→
|
||
`add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。
|
||
⑤ `project/ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析
|
||
(`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)`
|
||
(= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint`
|
||
代入后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 的基础项 `PythonPlayerSkill.cpp:391`,
|
||
主角弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0,
|
||
对齐 `NetPlay.FlyShape`)。⑥ `project/ui/quickbar.gd`:`activate()` 里 `client.use_skill`
|
||
返回后,若 `target_vid != 0` 且技能为 `FAN_RANGE` / `CIRCLE_RANGE` → 调
|
||
`net_play.send_fly_targeting(target_vid, target_range, target_count(sid, lvl), fly_shape(sid))`;
|
||
新增 `_skill_level(sid)`(扫 `client.get_skills()`)、`activate_reserved(global_slot)`
|
||
(`use_skill_hook` 回调,MODE_USE_SKILL 进射程后执行槽位施法)。⑦ `game_scene.gd`:
|
||
`net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。⑧ 测试:
|
||
`netplay_test.gd` FakeClient 补 `get_entities` / `pickup_item` / `add_fly_targeting` /
|
||
`get_ground_items` / `get_skills`,新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、
|
||
物品消失→清;CLICK_POSITION 延时门 + `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、
|
||
射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 /
|
||
怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、
|
||
`max_count<=1` / `SINGLE` → 0 附加);`skill_test.gd` 补 skill 47(gwangyeok,FAN + Range 2500 +
|
||
`target_count(lv1)=2` / `(lv20)=5`)/ skill 91(bipabu,FAN + Range 1800 + 无公式→0)/
|
||
skill 1(`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿(`eterngrn_polish_test`
|
||
的 `motion_event: sound` 仍是既有异步竞态,退出码 0)。留给增量 92+:§3.6 飞行物实体
|
||
(`CFlyingManager` / `FlyingInstance`)生成 / 命中、`__CanShot` 弓箭合法性。
|
||
|
||
- [x] **增量 92 —— W2 §3.6 飞行物实体 `CFlyingManager` / `CFlyingInstance` 生成 / 命中 + `__CanShot` 弓箭合法性**:
|
||
① 新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`,对齐
|
||
`GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.{h,cpp}`)。
|
||
`FlyData` 字段取 `CFlyingData::__Initialize()` 默认值(米制换算):`init_vel` /
|
||
`cone_angle` / `roll_angle` / `gravity` / `accel` / `flat_range`(=`m_fRange`) /
|
||
`bomb_range`(=`m_fBombRange`) / `pierce_count` / `spreading` / `maintain_parallel` /
|
||
`is_homing` / `homing_start_time` / `homing_max_angle` / `hit_on_background`。
|
||
`FlyInstance.create(d, start, target, can_attack)` ≈ `CFlyingInstance::Create` +
|
||
`__SetDataPointer` + `__SetTargetDirection`:`target` 按 `Node3D`(对象)/ `Vector3`
|
||
(位置)/ `Dictionary{vid,pos,node}` 分派;`dir = normalize(target_pos - pos)`、
|
||
`vel = dir * init_vel`、`accel` 经 `Quaternion((0,-1,0), dir)` 旋到航向。
|
||
`FlyInstance.update(dt)` 逐帧对齐 `CFlyingInstance::Update`:homing(`is_homing &&
|
||
_elapsed > homing_start_time && 对象目标` → `_adjust_homing()` 单步限幅
|
||
`deg_to_rad(homing_max_angle)`)→ `last_pos = pos` → `vel += accel*dt` →
|
||
`vel.y += gravity*dt`(seam:参考端重力作用于 `.z`=上,本 POC 映射到 `-Y`=下)→
|
||
`movement = vel*dt`、`move_dist = |movement|`、`remain_range -= move_dist`、
|
||
`pos += movement` → `remain_range < 0` → `out_of_range` + `_explode(false)`(不 `__Bomb`)
|
||
返 false → 对象目标线段判定 `_seg_point_sq_dist(pos, last_pos, target_pos) < bomb_range²`
|
||
(`_seg_point_sq_dist` 1:1 移植 `GrpMath.h:80` `square_distance_between_linesegment_and_point`)
|
||
→ `target_hitted = true`;`can_attack` → `shoot_damage` 信号(≈ `pVictim->OnShootDamage()`);
|
||
`at_target`;`pierce > 0` → `pierce-- + bomb` 继续飞,否则 `_explode()` 返 false →
|
||
位置目标同线段判定 → `at_position` + `_explode()` → `hit_on_background &&
|
||
world.sample_height(x, z) > pos.y`(≈ `GetTerrainHeight(x,-y) > z`)→ `at_background` +
|
||
`_explode()`。`_explode(bomb=true)`:`if not alive: return; alive = false; if bomb: bomb`。
|
||
seam:怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素判定未移植(客户端仅做命中"时机",
|
||
伤害由服务端裁决);`.fly` 资源加载器缺失 → `net_world._on_fly` 用合成箭参数
|
||
(`init_vel = 45`、`flat_range = max(dist*1.5, 30)`、`bomb_range = 0.6`)。
|
||
② `project/net_world.gd`:`setup()` 挂 `FlyManager` 子节点并 `setup(parent, world)`,
|
||
`exploded` → 新增 `signal fly_exploded(world_pos, cause)`;重写 `_on_fly(type, start_vid,
|
||
end_vid)`——原 tween「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false,
|
||
FlyData, type)`(`GC_CREATE_FLY` 在本 40250 客户端无包处理器,仅 `NetStream.cpp` 头串索引 70;
|
||
实际驱动路径是 POC 自有的 `fly_cue`——保留 shooter 的 `fly_target_queue` meta 消费)。
|
||
③ `project/net_play.gd`:新增 `_can_shot(target_vid := -1) -> String`(`__CanShot` 在
|
||
本发布源码树中无定义——防御式重建,参考 `IFlyEventHandler::OnNoTarget/OnNoArrow`
|
||
`FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 IsDead/IsStun/IsKnockDown/
|
||
IsUsingSkill 门、`__GetBowRange` `InstanceBase.cpp:694`、`EMPTY_ARROW` 文案):
|
||
非弓模式 → `"NOT_BOW_MODE"`;`_arrow_count == 0` → `"EMPTY_ARROW"`(`-1` =未跟踪/无限,放行);
|
||
无目标(`_target_vid` / `_vid_reserved` 均 ≤ 0)→ `"NO_TARGET"`;目标实体已知且不可攻击 /
|
||
已死 → `"NO_TARGET"`(`te.is_empty()` 不拒——headless 容差,与 `_send_fly_target`「有节点即可」一致);
|
||
目标世界距离 `* CM > _bow_range_cm()` → `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支加门:
|
||
`_can_shot()` 非空 → `_emit_cannot(why)`(走 `OnNoArrow` / `OnNoTarget` 文案),不发
|
||
`CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作照常(seam:参考端整挥击中止)。
|
||
④ 新增 `project/fly_test.gd`(第 14 个 GDScript 回归):直线命中对象目标 / 超程不 `__Bomb` /
|
||
`can_attack` 触 `shoot_damage(vid)` / 穿透后存活续飞再超程 / 位置目标 / 触地 /
|
||
`_can_shot` 六分支(非弓 / 无箭 / 无目标 / 在程 OK / 超弓程 / 死目标)/ `_seg_point_sq_dist`
|
||
三段(垂足在段内 / 越 p1 / 越 p2)。C++ 21/21 + 14 个 GDScript 回归全绿。
|
||
留给增量 93+:§3.1 Actor-Actor 碰撞(`TestActorCollision` / `BlockMovement`)+ 坐骑
|
||
`MODE_HORSE*`(依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。
|
||
- [x] **增量 93 —— W2 §3.1 收尾 / Actor-Actor 碰撞 + 坐骑转向速度**:
|
||
① `project/player_controller.gd` 新增 `_actor_blocked(next_pos)`——`CActorInstance::TestActorCollision`
|
||
(`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing`
|
||
(`InstanceBaseBattle.cpp:469`)的近似:距离门 `ACTOR_COLLIDE_MAX_DIST_M = 8.0 m`
|
||
(≈ `LengthSq(victim - self) > 800²` 直接不检)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,
|
||
自身 + 对方相加 = `combined`;参考用 race 碰撞球,POC 无骨骼碰撞数据 → 固定半径近似)→
|
||
「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` 末尾
|
||
`dist(mainPos, victimPos) <= dist(mainLastPos, victimPos)` 才 `return TRUE`;远离时放行 →
|
||
能从重叠里走出来)。候选=`pickables`(每个已生成的远端 NPC / 怪 / 他人节点),
|
||
`dead` meta 为真跳过(≈ `rVictim.IsDead()`)。
|
||
② `_process` 平移分支改为逐轴门控:`block_x = _actor_blocked(仅动 X) or _blocked(...)`,
|
||
`block_z` 同理——单个 Actor 时逐轴放行=滑移(≈ `AdjustDynamicCollisionMovement`,该函数不在
|
||
发布源码树),两轴都挡=停在上一帧位(≈ `uCollisionCount==2` / 调整后仍撞 → `BlockMovement()`
|
||
= `__InitializeMovement`);随后既有 `disp` 判定把顶人磨蹭切回 `_is_going = false` + `wait`。
|
||
③ `skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`,
|
||
§3.2 网络状态包驱动的移动整段跳过)——`net_play` 在观战开关(`_on_observer_mode` + `setup`)
|
||
按 `_observer_mode` 置位。
|
||
④ 坐骑转向速度:`player_controller` 新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG =
|
||
1200`=`c_fDefaultRotationSpeed`)+ `_turn_toward(target_yaw, dt)`——`SetAdvancingRotation`
|
||
的逐帧限幅收敛(移动方向不受限,仅朝向变慢),替换原来两处 `player.rotation.y = atan2(...)`
|
||
瞬转。`net_play._refresh_motion_mode()` 在 `mounted` 时把 `rotation_speed_deg` 切到
|
||
`ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse`
|
||
→ `SetRotationSpeed(c_fDefaultHorseRotationSpeed)`,`InstanceBase.cpp:954`),下马恢复 1200。
|
||
坐骑动作模式(`MODE_HORSE*`)已由增量 84 的 `motion_mode_for` + `mount_changed` → `_refresh_motion_mode`
|
||
覆盖,本增量不重复。
|
||
⑤ `project/player_move_test.gd` 扩 3 组(原 5 组 → 第 6/6b/6c/7):正前方 Actor 挡下且
|
||
`is_going` 归零、`dead` Actor 不挡、`skip_actor_collision` 直穿、坐骑单帧转向 ≤ 300 deg/s
|
||
且仍朝 Dst 平移;并在开头 `await process_frame`(对齐 fly_test,避免 `!is_inside_tree()`)+
|
||
清第 5 组遗留预约点。C++ 21/21 + 14 个 GDScript 回归全绿。
|
||
seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`);`AdjustDynamicCollisionMovement`
|
||
的精确滑移向量、门 / Actor 复合的 `uCollisionCount==2` 判定用逐轴近似代替;`CanSkipCollision`
|
||
目前只由观战驱动,§3.2 网络状态包路径的 `EnableSkipCollision` 尚未接到本地预测玩家。
|
||
留给增量 94+:§3.2 / §3.5 收尾(多为待真实服务器);转 M2(W3 玩法)。
|
||
- [x] **增量 94 —— W3→M2 §5.4 PVP / PK / 决斗可攻击判据(`IsAttackableInstance` 1:1)**:
|
||
① 新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`)——
|
||
`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`
|
||
(`REF/UserInterface/InstanceBase.cpp:2147`):主角 + 观战 → false;自打自 → false;
|
||
Stone(victim.IsPC → true)/ PC / Enemy(victim.IsPC || IsBuilding → true)/ Poly
|
||
(victim.IsPC || IsEnemy → true)四分支。PC 分支内:victim.IsStone → true;victim.IsPC 时
|
||
决斗三态(`DUEL_CANNOTATTACK` → false,`DUEL_START` → `__FindDUELKey` ? true : false)、
|
||
`PK_MODE_GUILD` && 同公会 → false、`victim.IsKiller()` && !同队 → true、
|
||
`PK_MODE_PROTECT != GetPKMode()` 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队 → true)/
|
||
`PK_MODE_GUILD`(victim 非 PROTECT && !同队 && 公会不同 → true)、`IsSameEmpire(victim)` 时
|
||
`IsPVPInstance` → true / `PK_MODE_REVENGE` && !同队 && `IsConflictAlignmentInstance` → true、
|
||
否则(异帝国)→ true;随后 victim.IsEnemy → true、victim.IsWoodenDoor → true。
|
||
② 判据 helper 全部 1:1:`alignment_grade`(:502 的 12000/8000/4000/1000/0/-4000/-8000/-12000
|
||
九档)、`alignment_type`(:524,grade 0-3 白 / 4 中 / 5-8 黑)、`is_conflict_alignment_instance`
|
||
(:2116,victim PROTECT → false;白 / 中对黑 → true;黑对非黑 → true)、`_is_same_empire`
|
||
(:470,victim.empire==0 || GM || 同 empire)、`_is_pvp_instance`(`InstanceBaseEffect.cpp:505`,
|
||
`GetDuelMode()` || `__FindPVPKey` || `__FindGVGKey`)、`_find_duel_key`(:495)、`_is_same_party`
|
||
(`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119)。
|
||
`_is_pc` 依 `CActorInstance::IsPC`(`ActorInstance.cpp:282`)只看 `TYPE_PC`(变身玩家仍是 PC,
|
||
`IsPoly` 另经 race 越界判真);`_is_enemy` 只 `TYPE_ENEMY`;独立 `TYPE_POLY` 才走 Poly 分支。
|
||
③ `net_play.gd`:`_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼
|
||
`self_e`(`_main_entity()` 快照 + `kind`)/ `victim_e`(+ `kind`)/ `_attack_ctx()`,委托
|
||
`EntityRules.is_attackable_instance`;`_main_entity()` 为空(headless)退化到旧近似。
|
||
新增 `_attack_ctx()` 从 `client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
|
||
`get_party()` 装配 ctx(observer / main_vid / pk_mode / duel_mode / duel_opponents /
|
||
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
|
||
`_can_shot`、`_hit_geometry`)无签名变化。
|
||
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言);`combat_fx_test` /
|
||
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type` → `_entity_kind` 归 PC,
|
||
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
|
||
C++ 21/21 + 15 个 GDScript 回归全绿。
|
||
seam:`__FindPVPKey`(`GC_PVP` 关系表 setter)/ `__FindGVGKey`(公会战对局 setter)/
|
||
`IsGameMaster` 标记 / `IsBuilding`·`IsWall` 的 race 白名单(源码未随发布)/ m2dev 后端的
|
||
`IsKiller` 状态位 / `_entity_kind` 无法分类时怪被当和平 PC(生产 mob_proto 恒加载);
|
||
名字颜色 §8.8、`IsInSafe` 的 `ATTRIBUTE_BANPK` 地形属性仍未接。
|
||
留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt。
|
||
|
||
- [x] **增量 95 —— W3→M2 §5.1 Quest EventManager(`CPythonEventManager` 1:1)**:
|
||
① `project/ui/quest_dialog.gd` 逐条重写脚本解析:`EVENT_TYPE_MAP`(42 条,键即 tag)+
|
||
`enum { EVT_* }`(顺序照抄 `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+
|
||
`_get_script_event_index()`(`GetScriptEventIndex` `PythonEventManager.cpp:1093`——前导 `/` 剥离为
|
||
`EVENT_POSITION_END`,未知命令 `ok=false` → 跳过、不进 switch)。
|
||
② `class EventSet`(`SEventSet` `PythonEventManager.h:98`,字段名 1:1:`ix/iy/iWidth/iyLocal/
|
||
isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
|
||
DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/
|
||
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ `init_event_set()`(`__InitEventSet` `:72`
|
||
常量 `c_lNormal_Waiting_Time=10`/`iRestrictedCharacterCount=30`/`c_fLine_Temp=16`/
|
||
`BOX_VISIBLE_LINE_COUNT=5`)、`insert_line()`(`__InsertLine` `:976`,推行 + `iyLocal += 16`)、
|
||
`clear_line()`(`ClearLine` `:953`)、`body_text()`。
|
||
③ `_process_event_set(es, cmd)` = `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷:
|
||
`DELAY`/`COLOR`/`COLOR256` 的 START vs `/END` 分支、`LETTER`/`ITEM`/`MOB` 的
|
||
`iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE`
|
||
的 `iAdjustLine += 2`、`WAIT`(`iyLocal=0; isLock=true`)、`CONFIRM_WAIT`(`isConfirmWait=true` +
|
||
`insert_line(center)`)、`END_CONFIRM_WAIT`(清 `isConfirmWait`)、`FADE_*`/`WHITE_*`
|
||
(`isWaitFlag=true`)、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**(引 `:562/566`)、`DUNGEON_RESULT`
|
||
9 参、`QUESTBUTTON`/`QUESTBUTTON_CLOSE`、`SET_CAMERA`(`_camera_setting` 九参 = `:48`
|
||
`GetCameraSettingFromArgList`)/`BLEND_CAMERA`(+`blendtime`)/`RESTORE_CAMERA`。
|
||
④ `_tokenize()` 拆混排文本 / `[tag ...]`;裸网络文本折成合成 `LETTER`(`is_raw=true` → 跳过 30 字
|
||
断行,reference 脚本只有显式 `[LETTER]`)。arg 提取 `_tag_kv()`(`key(v)`/`key("v")`/`key;v`)+
|
||
`_arg_s/_arg_i/_arg_f`(keyword 优先、位置回退,兼容旧测试的 `value;255` 与 `value(1)` 写法)。
|
||
⑤ `parse_script()` 返回字典在原键之外新增 `insert_images / dungeon_result / adjust_line /
|
||
waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`;首命令
|
||
`RUN_CINEMA` → 记 `cinema_file` + 发 `cinema_requested` 信号。
|
||
⑥ 自检 `project/quest_event_test.gd`(新增,规范测试 #16,23 组断言);既有
|
||
`quest_test.gd` / `select_item_ui_test.gd` 仍绿。C++ 21/21 + 18 个 GDScript 回归全绿。
|
||
seam:逐帧 `UpdateEventSet` `:312` 的时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 /
|
||
`isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip`/`EndEventProcess`、`iConfirmEndTime`
|
||
的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`NEXT` vs `DONE` 按钮文案区分 +
|
||
`DoneEvent()` 回调、`camera_event_requested` 与 §7.6 相机模式对接——一次性跑完 token,
|
||
`isLock`/`isWaitFlag` 仅作状态保留并透出。
|
||
留给增量 96+:§5.2 任务日志、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口。
|
||
|
||
- [x] **增量 96 —— W3→M2 §4.1 wear 槽位版图 + costume/ring/belt 发包坐标(`GameType.h` 1:1)**:
|
||
① `project/ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 分流——
|
||
`c_Equipment_Start+0..10`(`EWearPositions` BODY..SHIELD)走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell;
|
||
`ENABLE_NEW_EQUIPMENT_SYSTEM` 的 `c_Costume_Slot_Start(90+19)` / `c_Costume_Slot_Hair(90+20)` /
|
||
`c_Equipment_Ring1(90+21)` / `c_Equipment_Ring2(90+22)` / `c_Equipment_Belt(90+23)` 在客户端
|
||
没有独立窗,继续用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。这修掉了 `inventory_test.gd`
|
||
里 `_to_wire(113) == [1, 113]` 一直红的断言(旧实现返回 `[2, 23]`)。
|
||
② 新增常量 `WEAR_MAX_NUM=11` / `WEAR_COSTUME_BODY=19` / `WEAR_COSTUME_HAIR=20` / `WEAR_RING1=21` /
|
||
`WEAR_RING2=22` / `WEAR_BELT=23`(原值保持)/ `NEW_EQUIP_WEAR_START=19` / `WINDOW_EQUIPMENT=2`,
|
||
全部 1:1 自 `GameType.h` 的 `c_Equipment_*` 与 `ItemData.h` 的 `EWearPositions`。
|
||
③ `_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 +
|
||
16 格腰带背包」(`EWearPositions` 中 11..18 无位置);`refresh()` 的 `_cells.get(EQUIP_BASE+i)`
|
||
对空槽自动跳过,无需改。头注释同步为 `_to_wire()` 的分流说明。
|
||
④ `inventory_test.gd` 增 7 条 wire 坐标断言(`_to_wire(90)→[2,0]`、`(100)→[2,10]`、
|
||
`(109)→[1,109]`…`(113)→[1,113]`)。`inventory_ui_test.gd`(`move_to(5,91) → [1,5,2,1,1]`,
|
||
锁定基础 wear 仍走 window=2)保持绿。C++ 21/21(`net.loopback_flow` 一次 localhost bind 抖动,
|
||
重跑即过)+ 55 个 GDScript 回归全绿。
|
||
seam:修改 2(`antiflag→wearable_flag→limit` 装备校验顺序 + `CANNOT_EQUIP_*` 文案)——
|
||
40250 客户端本身不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag`/`GetLimit`,
|
||
仲裁在服务器 `char_item.cpp`),按 §3.9 `__CanShot` 方式防御性重建、tie-break 顺序留待接服务器;
|
||
修改 4(穿 costume 隐藏本体 armor / 穿头盔隐藏发型,`InstanceBase.cpp` `ChangeArmor`/`SetHair`);
|
||
修改 5(远端角色 `armor/weapon/hair` 三字段接换模,`net_world.gd` 调用序已在 §2.5 对齐)。
|
||
留给增量 97+:§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
|
||
§4.2 拖放鼠标物品态。
|
||
|
||
- [x] **增量 97 —— W3→M2 §4.1 修改 2:`CanEquipNow` 前置门(antiflag→wearable_flag→limit 1:1)**:
|
||
① 新增 `project/equip_rules.gd`(`extends RefCounted` 纯校验器),枚举 1:1 自
|
||
`REF/GameLib/ItemData.h`——`EItemAntiFlag`(classic 排列:`FEMALE=1<<0`…`SHAMAN=1<<5`,
|
||
帝国位 `EMPIRE_A/B/R = 1<<9..1<<11`)、`EItemWearableFlag`(`BODY=1<<0`…`ARROW=1<<9`)、
|
||
`EWearPositions`(0..10)+ `GameType.h` 新装备槽 19..23、`ELimitTypes`(`LIMIT_LEVEL=1`…
|
||
`LIMIT_CON=5`、`PCBANG=6`、`REAL_TIME=7`)、`EItemType`(`WEAPON=1`/`ARMOR=2`/`COSTUME=28`/
|
||
`RING=33`/`BELT=34`)+ `EArmorSubTypes`/`ECostumeSubTypes`。
|
||
② `can_equip(item, wear, ctx) -> {ok, code, need}`,语义顺序对齐服务器 `char_item.cpp::CanEquipNow`:
|
||
目标槽解析(`wear<0` → `default_wear_for()` 按 type/sub)→ anti-flag 性别(`ANTI_SEX`)→
|
||
anti-flag 职业(`ANTI_JOB`,`JOB_ANTIFLAG[race%4]`)→ anti-flag 帝国(`ANTI_EMPIRE`,
|
||
`EMPIRE_ANTIFLAG[GC_EMPIRE 1/2/3]`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按
|
||
`wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`:
|
||
`ctx.level < v`;`LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判,`< 0` 跳过)。
|
||
`SEX_BY_RACE`/`job_of_race`/`sex_of_race` 表 1:1 自 `MAIN_RACE_*`。
|
||
③ `inventory_ui.gd`:`use()` 对「常规背包格 + 可穿戴物品」跑前置门,拦下则 `_set_hint()` +
|
||
新 `signal equip_rejected(code, need)` + 返回 false(不发 `use_item`);新增公开
|
||
`can_equip_item(vnum, wear)`、`_equip_ctx()`(从 `get_points().level` + `get_entities()` 主角
|
||
`race`/`empire`/`level` 组装;`st/dx/ht/iq` 未由 M2Client 按名暴露 → 传 `-1` = seam)、
|
||
`_equip_reject_hint()`(`ANTI_SEX`/`ANTI_JOB`/`ANTI_EMPIRE`/`NOT_WEARABLE_HERE`/`LIMIT_*` → 中文文案)。
|
||
④ 新增 canonical 测试 #17 `project/equip_rules_test.gd`(33 断言:race 表、`default_wear_for`
|
||
各 type/sub、`slot_accepts`、anti-flag 性别/职业/帝国、`NOT_WEARABLE_HERE`、`LIMIT_LEVEL/STR/CON`
|
||
含 need 值 + 属性 `-1` 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 `NO_ITEM`);
|
||
`inventory_ui_test.gd` 的 `FakeProto.item()` 补 `sub_type/anti_flags/wear_flags/limits`
|
||
让 `iv.use(5)` 仍过门。C++ 21/21 + 56 个 GDScript 回归全绿。
|
||
seam:精确 tie-break 顺序、已穿同槽替换、`LIMIT_PCBANG`/`LIMIT_REAL_TIME*`、属性 4 项按名读取,
|
||
均留待接服务器 `char_item.cpp` 比对。
|
||
留给增量 98+:§4.1 修改 4(部位隐藏 / 遮挡)、修改 5(远端换装接字段)——均为 render 侧;
|
||
§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
|
||
|
||
- [x] **增量 98 —— W3→M2 §4.1 修改 4:变身 / 时装部位遮挡(`InstanceBase.cpp` 1:1)+ 修改 5 字段序确认**:
|
||
① 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自
|
||
`REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`)、
|
||
`ANIMAL_WEAR_SHAPES=[100..103]`(`__IsShapeAnimalWear` → `SetWeapon`(:2747) / `SetParts`
|
||
返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress` → `m_eShape==201`)、
|
||
`BOUQUET_VNUMS=[50201..50204]`(`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外一律 false)。
|
||
函数:`is_animal_wear` / `is_wearing_dress` / `is_bouquet` / `is_changable_weapon`
|
||
(婚纱→仅捧花可换)/ `weapon_hidden`(变身 ∨ 动物时装 ∨ 婚纱且非捧花)/ `hair_hidden`
|
||
(变身 ∨ 动物时装;婚纱不影响头发)/ `body_shape`(变身→0,动物时装 100-103 保留用于出时装体)/
|
||
`effective_weapon`(被隐藏→0)。
|
||
② `equip_model.gd`:`refresh()` 刷新顺序改为对齐 `ChangeArmor`(身体 → 头发 → 武器 → 盾),
|
||
解出身体 shape(`armor_shape_of` = `__ArmorVnumToShape` 的 `values[3]`/`=vnum`)后过
|
||
`PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2` + `shield_gr2` + `hair_gr2`;婚纱非捧花隐藏
|
||
武器 + 盾、头发保留;脱下时 `head_key` 从 `-1` 复位触发发型重挂(`_last_head_vnum` 哨兵改 `-2`)。
|
||
新增 `_is_poly()` seam(40250 实体表暂无变身字段,恒 false)。
|
||
③ `net_world._apply_field_updates()`:确认 armor/weapon/hair 字段序已 1:1 于
|
||
`NetworkActorManager.cpp:473`(`ChangeArmor→ChangeWeapon→ChangeHair→…`,增量 46 落地),
|
||
补注变身 / 时装遮挡统一走模型侧 `part_hiding.gd`。
|
||
④ 新增 canonical 测试 #18 `project/part_hiding_test.gd`(45 断言:animal-wear 全集、
|
||
dress / bouquet 判定、`is_changable_weapon` 婚纱门、`weapon_hidden` / `hair_hidden` /
|
||
`body_shape` / `effective_weapon` 各分支);`equip_model_test.gd` 补动物时装遮挡武器/盾/发、
|
||
脱时装恢复、婚纱遮武器保发、婚纱换捧花恢复武器 7 段。C++ 21/21 + 57 个 GDScript 回归全绿。
|
||
seam:变身(`_is_poly` 恒 false,等 `GC_CHARACTER_UPDATE` 带变身 race);远端 PC 真模型工厂已由
|
||
增量 99 首版落地,但所有职业 / race 的资源可用性、精确体型 / 变身与真服换装仍未验收。
|
||
留给增量 108+:§5.3 fishing 鱼获 / `GC_ITEM_*` 事务、DungeonBlock `.mdatr` collision / 副本入口结算、mount UI、observer 自由视角 / 生命周期、§4.2 拖放鼠标物品态、
|
||
§4.3 tooltip 特殊分支 + 装备对比;§5.2 的五行 / 滚动 / 任务行事件已由增量 101 落地。
|
||
|
||
- [x] **增量 104 —— W3→M2 §5.3 / 钓鱼反馈消息与 item proto 分支**:按
|
||
`40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860`
|
||
对齐 `GC_FISHING`:SUCCESS 只负责动画,不直接写“鱼钩命中”;FAIL / info=0 / fish-vs-nonfish
|
||
分别走 `FISHING_FAILURE` / `FISHING_UNKNOWN` / `FISHING_NOTIFY1/2` / `FISHING_SUCCESS1/2`;
|
||
unknown item proto 静默忽略。`ChatUI` 加载 `locale_game.txt`,`game_scene.gd` 注入 item proto
|
||
和 wrong-place 回调,`chat_test.gd`、`gamescene_test.gd` 通过。真正背包数量仍由独立
|
||
`GC_ITEM_*` 包驱动,本批次不伪造落袋。
|
||
|
||
- [x] **增量 105 —— W3→M2 §5.3 / 坐骑攻击等级门**:按
|
||
`40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216` 的 `SHORSE::GetLevel` 映射、
|
||
`InstanceBaseBattle.cpp:623-628` 的 `CanAttackHorseLevel` 和 `PythonPlayerInput.cpp:737-764`
|
||
的 `__CanAttack`,接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门;
|
||
自动攻击改用实体 `mount_vnum` 判定。`netplay_test.gd` PASS。坐骑模型 / `MODE_HORSE*`
|
||
动作 / 移速和真服坐骑数据仍待验收。
|
||
|
||
- [x] **增量 106 —— W3→M2 §5.3 / 观战者小地图插值**:按
|
||
`40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180` 对齐
|
||
`CPythonMiniMap::AddObserver/MoveObserver/RemoveObserver`,为 `GC_OBSERVER_ADD/REMOVE/MOVE`
|
||
建立独立轨迹并以 1 秒线性插值;初始 `get_observers()` 快照可播种,`world_reset` 清空轨迹。
|
||
`p9_test.gd` 覆盖 ADD / MOVE / REMOVE / reset 并 PASS。观战自由视角和真服完整生命周期仍待验收。
|
||
|
||
- [x] **增量 107 —— W3→M2 §5.3 / 副本目的地状态与 HUD 罗盘**:按
|
||
`40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901`、
|
||
`PythonPlayer.cpp:147-167,1255-1285` 接入 `GC_DUNGEON` 的目的地分支;保存全局厘米坐标,
|
||
收包立即发出目标提示,按参考端 Manhattan `<10000cm` 清除目标,未到达时每 20 秒再次提示,
|
||
并在 `world_reset` 清理。`TIME_ATTACK_START` 保持参考端空分支,不添加本地计时器。
|
||
`project/dungeon_state.gd`、`project/hud.gd`、`project/game_scene.gd` 与
|
||
`project/dungeon_state_test.gd` 已接线,副本状态和场景回归 PASS;DungeonBlock `.mdatr`
|
||
碰撞、真实副本入口 / 结算窗口和真服验收仍待完成。
|
||
|
||
仍未完成:
|
||
|
||
- [ ] 真实 TCP E2E 证明两次游戏连接,以及第二次连接确实命中 `GC_LOGIN_SUCCESS4` 的槽地址;
|
||
- [ ] 移除 `MT_CLASSIC_ENTER_DELAY` 定时发送,改为 Loading/游戏窗口完成初始化后的明确发送点;
|
||
- [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景
|
||
节点无残留;受限环境下 localhost bind 子测试自动 skip。
|
||
- [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。
|
||
- [ ] locale 代码页转码(CP949 / CP1252)及真实 40250 服务端对 `0xf1` / 可执行文件名分支的接受情况;
|
||
代码路径已接线,但仍属待真实验收。
|
||
- [ ] AppLifecycle 的真实 Android/iOS 前后台、内存告警和连接断开重连验收;离线单一属主回归已通过。
|
||
|
||
本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 40250 服务端证据的项目仍保持“未验证”。
|
||
|
||
## 并行开发与进度同步协议
|
||
|
||
本细则按工作流并行执行,不按 §0–§10 的编号逐条串行实现。章节号用于定位差距,工作流用于分配
|
||
代码边界;依赖关系通过公共接口和集成门禁处理。
|
||
|
||
### 工作流边界
|
||
|
||
| 工作流 | 负责条目 | 主要修改范围 | 不得越界修改 |
|
||
|---|---|---|---|
|
||
| W0 公共基础 / 集成 | §0、共享会话、实体接口、场景生命周期、文档 | `extension/src/net/m2_client.*`、`entity_store.*`、`classic_session.*`、`project/game_scene.gd`、两份差距文档 | 其他工作流不得各自复制网络核心或场景 reset;接口改动必须回 W0 |
|
||
| W1 协议 / 账号 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | `extension/src/net/classic/*`、`extension/tests/net_classic_*`、登录 / 选人专属脚本 | 不直接重写 W0 的公共信号和 EntityStore 生命周期 |
|
||
| W2 实体 / 世界 / 战斗 | §2–§3、移动、动作、技能、目标和换图后的实体表现 | `project/net_world.gd`、`net_play.gd`、`player_controller.gd`、`project/world/*`、动画 / 战斗扩展和测试 | 不把 UI 状态写入协议层;共享 reset 只通过 W0 接口接入 |
|
||
| W3 玩法 UI / 输入 | §4–§5、§8、背包、任务、社交、IME、键位 | `project/ui/*`、`hud.gd`、`login.gd`、`charselect.gd`、UI 测试和本地化资源 | 不修改 Classic parser;缺少真服时使用 fixture,并注明未验证 |
|
||
| W4 渲染 / 资产 / 音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM、生命周期表现 | `extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*`、`project/fx/*`、音频 / 渲染专属脚本和测试 | 不把渲染 fallback 塞进网络状态机;场景钩子由 W0 提供 |
|
||
| W5 打包 / 发布 / 验收 | §10、Mac App 自包含资源、架构、签名、发布配置和验收矩阵 | `project/export_presets.cfg`、打包 / 发布脚本、`extension/CMakeLists.txt`、发布验收文档 | 不以“能启动”代替资源、架构、签名和真实功能验收 |
|
||
|
||
### 执行波次
|
||
|
||
1. **G0 公共基础冻结**:W0 维护 `enter_game`、`warp`、`world_reset`、实体快照、UI 信号和
|
||
测试 fixture 的契约,并在本文登记影响文件。
|
||
2. **并行波 A**:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug
|
||
构建完成自己的回归,不等待其他工作流的全部功能。
|
||
3. **G1 接口集成**:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突
|
||
只在这一层解决,不在各工作流中反复复制修复。
|
||
4. **并行波 B**:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实
|
||
服务端、双账号和 Mac App 验收按矩阵分别记录。
|
||
5. **G2 发布门禁**:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全,
|
||
对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的 `skip` 只能记为未验证。
|
||
|
||
### 每批次必须同步文档
|
||
|
||
每个开发批次都要在**开始、完成、阻塞、重新验证**时更新本文和 `CLIENT-GAP.md`。代码提交而没有
|
||
对应文档记录时,不得把条目标成完成。
|
||
|
||
批次记录至少包含:
|
||
|
||
- `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)`。
|
||
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` 在 `MT_ASSETS` 配置时调用
|
||
`parser().set_npclist_path(<MT_ASSETS>/root/npclist.txt)`;未配置时 `npc_names.h` 回落
|
||
`$M2_ASSETS` 环境变量,再回落空串。后续仍可把资源根从 Godot `AssetRoot` 显式传入,
|
||
以覆盖打包场景中不使用环境变量的路径。
|
||
|
||
**W1 增量 50 已落地(🔎 待真实验收)**
|
||
|
||
- 改动:`extension/src/net/classic/npc_names.h`(新,`load_npclist` / `default_npclist_path`,
|
||
mtnet-safe)、`classic_parser.{h,cpp}`(`set_npclist_path` / `pending_actor_count` /
|
||
`npc_name_for` 惰性加载、`GC_CHARACTER_ADD` 与 `GC_CHAR_ADDITIONAL_INFO` 处理重写)。
|
||
`GC_CHARACTER_ADD2` 处理**未动**(超出本条范围,登记为 follow-up)。
|
||
- 测试:`net_classic_session_test` 的 NPC 块重写为“先 `GC_CHARACTER_ADD`(NPC) 只暂存
|
||
(`world().size()` 不变、`pending_actor_count()==1`)→ 再 `GC_CHAR_ADDITIONAL_INFO` 合并落地
|
||
(暂存坐标 / 速度保留、9 字段合入、`pending_actor_count()==0`)→ 怪物立即 spawn →
|
||
隐身 race 丢弃 → 无 pending 的 additional-info 为 no-op”。`ctest` `net.classic_session` 通过。
|
||
- 验收:未验证(两包合并需真实 40250 服务端 `GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 序列)。
|
||
|
||
**步骤 4-5 状态:✅ 完成(2026-09-02 / W2 / 增量 50)**——`mut_char_info()` 改为
|
||
`m_ents.find == end → dbg_ignored() + return`,命中则更新字段并无条件 `push {Info, vid}`;
|
||
`mut_shop_sign()` 同样去掉裸 `Entity` 创建,未知 VID 直接 return。步骤 1-3(`m_pending_actor`
|
||
两包合并、`GC_CHARACTER_ADD` 分流、`IsInvisibleRace`)属 W1 的 `ClassicParser` 范畴,未在本批次触碰。
|
||
|
||
---
|
||
|
||
### 2.3 VID 重用时旧状态可能污染新实体
|
||
|
||
**参考**:`CNetworkActorManager::AppendActor`(`NetworkActorManager.cpp:409`)
|
||
```cpp
|
||
SNetworkActorData& rkNetActorData = m_kNetActorDict[c_rkNetActorData.m_dwVID];
|
||
rkNetActorData = c_rkNetActorData; // operator= → __copy__ 覆盖**全部** 23 个字段
|
||
```
|
||
`SNetworkActorData::__copy__`(`NetworkActorManager.cpp:53`)逐个赋值
|
||
`m_bType, m_dwVID, m_dwStateFlags, m_dwEmpireID, m_dwRace, m_dwMovSpd, m_dwAtkSpd, m_fRot,
|
||
m_lCurX/Y, m_lSrcX/Y, m_lDstX/Y, m_kAffectFlags, m_dwServerSrcTime, m_dwClientSrcTime,
|
||
m_dwDuration, m_dwArmor, m_dwWeapon, m_dwHair, m_dwOwnerVID, m_sAlignment, m_byPKMode,
|
||
m_dwMountVnum, m_dwGuildID, m_dwLevel, m_stName`。
|
||
表现层同样重建:`__AppendCharacterManagerActor`(`:327`)在已有实例时先
|
||
`rkChrMgr.DeleteInstance(dwVID)` 再 `CreateInstance(kCreateData)`——**整个实例重建**,
|
||
只有上/下马时才把旧坐标覆盖进 `kCreateData`(`:342`)。
|
||
|
||
**当前**:`EntityStore::mut_spawn()`(`entity_store.cpp:75`)用 `touch()` 后只覆盖
|
||
`race/ch_type/name/x/y/z/angle/moving_speed/attack_speed/moving`。
|
||
|
||
**修改**
|
||
|
||
1. `mut_spawn_full()`(§2.2 新增)实现为:
|
||
```cpp
|
||
Entity e{}; // 值初始化 —— 等价 SNetworkActorData 默认构造 + operator=
|
||
e.vid = vid; ...逐字段赋值全部字段...
|
||
m_ents[vid] = e; // 整条替换,不做 touch/局部覆盖
|
||
m_changes.push_back({ChangeKind::Spawn, vid});
|
||
```
|
||
即 `dead / stunned / mount_vnum / guild / pk_mode / alignment / parts / shop_sign /
|
||
affect / func / walk_mode / fly_target_*` 全部回默认值。
|
||
2. `net_world.gd` 收到 `Spawn` 且节点已存在 → **销毁旧节点重建**(对齐
|
||
`DeleteInstance` + `CreateInstance`);例外:上下马导致的重建保留旧世界坐标
|
||
(`kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;`,注意 Y 取反)。
|
||
3. `mut_spawn()` 旧签名保留给 `GC_MAIN_CHARACTER` 之外的老调用点,但内部改为委托
|
||
`mut_spawn_full()`,避免两套覆盖规则。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 50)**
|
||
|
||
- **改动**
|
||
- `extension/src/net/entity_store.cpp`:`mut_spawn_full(const Entity& src)` 实现为
|
||
`Entity e = src; e.vid = src.vid; m_ents[src.vid] = e;`(整行替换,VID 复用时不保留
|
||
任何在途移动状态——对齐 `SNetworkActorData::__copy__` 从 src 取 Src/Dst/Duration),
|
||
`src.is_main` 时设 `m_main_vid`,无条件 `push {Spawn, vid}`。`mut_spawn(...)` 保留原
|
||
签名,内部构造 `Entity e{}` 填 vid/race/ch_type/name/x/y/z/angle/speeds/`moving=false`
|
||
后委托 `mut_spawn_full(e)`。
|
||
- `project/net_world.gd`:`_on_spawn` 收到已有节点的 `vid` → `queue_free` 旧节点后重建
|
||
(对齐 `DeleteInstance` + `CreateInstance`);例外:上/下马驱动的重建
|
||
(`(old_mount == 0) != (new_mount == 0)`)用 `existing.global_position` 保留旧世界坐标。
|
||
节点存 `mount_vnum` meta 供该判定。
|
||
- **测试**:`ctest` 16/16(`net.entity_store` 现有 VID-复用 / loading-reset 断言不回归);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` 全绿。
|
||
- **验收**:未验证(VID 复用需真服)。
|
||
|
||
---
|
||
|
||
### 2.4 点数组可能残留旧值
|
||
|
||
**参考**:`__RecvPlayerPoints()`(`REF/.../PythonNetworkStreamPhaseLoading.cpp:325`)
|
||
```cpp
|
||
for (DWORD i = 0; i < POINT_MAX_NUM; ++i)
|
||
CPythonPlayer::Instance().SetStatus(i, PointsPacket.points[i]);
|
||
```
|
||
`TPacketGCPoints.points` 是**定长 `POINT_MAX_NUM` 数组**,全量覆盖,不存在“部分长度”。
|
||
|
||
**当前**:`EntityStore::mut_set_points(pts, n)`(`entity_store.cpp:133`)只 `memcpy` 前 `n` 个;
|
||
当前 Classic 固定长度包路径传入 `POINT_MAX_NUM`,所以该路径本身是全量覆盖,残留风险只存在于
|
||
未来或其他调用者传入较短长度时。
|
||
|
||
**修改**
|
||
|
||
1. `wire_classic.h` 确认 `GCPoints.points` 为 `int32_t[POINT_MAX_NUM]`,加 `static_assert`。
|
||
2. 若 `mut_set_points()` 继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须
|
||
传入并校验 `POINT_MAX_NUM`,部分更新不得被误称为参考端的 `GC_POINTS` 全量包;必要时在
|
||
部分更新前清零尾部,避免未来调用者读到旧值。
|
||
3. `GC_POINT_CHANGE`(单点更新)保持只改一个下标——参考同样是单点
|
||
(`PhaseSelect.cpp:129` 在 Select 阶段直接 `Recv` 丢弃,Game 阶段才处理)。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 50)**
|
||
|
||
- **改动**(`extension/src/net/entity_store.cpp`)
|
||
- `mut_set_points(pts, n)`(全量快照):`if (!pts || n <= 0) return;`;`n > POINT_MAX_NUM`
|
||
(255)时截断为 `POINT_MAX_NUM`;`memcpy` 之后 `for (i = n; i < POINT_MAX_NUM; ++i)
|
||
m_points.v[i] = 0` 清尾,杜绝短包留下旧值。
|
||
- `mut_set_point(type, value)`(单点):`if (type >= POINT_MAX_NUM) { dbg_ignored(...);
|
||
return; }` 越界拒绝。
|
||
- **W1 跟进项**:`wire_classic.h:606-607` 已有
|
||
`struct GCPoints { uint8_t header; int32_t points[POINT_MAX_NUM]; }` +
|
||
`static_assert(sizeof(GCPoints) == 1 + 255 * 4)`,本条要求的编译期断言已满足;运行期钳制
|
||
仍在 `EntityStore` 侧独立加了一层。
|
||
- **测试**:`ctest` 16/16(`net.entity_store` 点数断言不回归)。
|
||
- **验收**:未验证(需真服 `GC_POINTS` / `GC_POINT_CHANGE` 流量)。
|
||
|
||
---
|
||
|
||
### 2.5 实体数据字段不完整
|
||
|
||
**参考**:`SNetworkActorData`(`REF/UserInterface/NetworkActorManager.h:5`)的完整字段表见 §2.3。
|
||
`CInstanceBase::SCreateData` 又比它多出 `m_isMain`。参考端各字段的**唯一来源**:
|
||
|
||
| 字段 | 来源包 | 参考行 |
|
||
|---|---|---|
|
||
| `m_bType`(CHRTYPE) / `m_dwRace` / `m_dwStateFlags` / `m_fRot` / `m_dwMovSpd` / `m_dwAtkSpd` / 位置 / `m_kAffectFlags`(64 bit, 两段 32) | `GC_CHARACTER_ADD` | `PhaseGameActor.cpp:83` |
|
||
| `m_stName` / `m_dwGuildID` / `m_dwLevel` / `m_sAlignment` / `m_byPKMode` / `m_dwEmpireID` / `armor,weapon,hair`(`awPart[CHR_EQUIPPART_*]`) / `m_dwMountVnum` | `GC_CHAR_ADDITIONAL_INFO` | `PhaseGameActor.cpp:140` |
|
||
| 同上 9 项的后续变更 | `GC_CHARACTER_UPDATE[2]` | `PhaseGameActor.cpp:211/236` |
|
||
| `m_dwOwnerVID` | `GC_OWNERSHIP` | `PhaseGameActor.cpp:375` |
|
||
|
||
`UpdateActor` 应用到实例的调用序(`NetworkActorManager.cpp:470`,顺序即参考顺序):
|
||
`ChangeArmor → ChangeWeapon → ChangeHair → ChangeGuild → SetAffectFlagContainer →
|
||
SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags`。
|
||
(`MountHorse/DismountHorse` 在参考里是**注释掉的**,坐骑变化只经 `AppendActor` 重建。)
|
||
|
||
**当前**:`mut_char_info()`(`entity_store.cpp:477`)显式 `(void)empire;` 丢弃 empire;
|
||
名字颜色 / 称号 / 会徽 / target highlight / attackable / weapon mode / motion mode /
|
||
affect 图标与剩余时间没有统一状态。
|
||
|
||
**修改**
|
||
|
||
1. `Entity` 结构补齐到与 `SNetworkActorData` 一一对应:
|
||
新增 `uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags;`
|
||
(`state_flags` 已有 `func/position` 的话合并成一个字段,按 `bStateFlag` 原值保存)。
|
||
2. `mut_char_info()` 写入 `e.empire = empire;`,删除 `(void)empire;`。
|
||
3. `affect_flags` 按参考的两段拷贝:`CopyData(0, 4, &dwAffectFlag[0])` +
|
||
`CopyData(32, 4, &dwAffectFlag[1])` → `uint64_t = lo | (hi << 32)`。
|
||
`AFFECT_SHOW_ALWAYS` 的位号取 `REF/UserInterface/InstanceBase.h` 的 `AFFECT_*` 枚举
|
||
**[待读参考:`InstanceBase.h` 的 `enum EAffect`]**。
|
||
4. `net_world.gd` 的实体刷新按上表的**参考调用序**执行,不要重排。
|
||
5. 名字颜色 / 称号 / PK 颜色 / 目标框 属表现层,见 §8.8,数据源就是本节的
|
||
`empire / guild / alignment / pk_mode / level`。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 50)**——affect 位拷贝 + 应用序落地;
|
||
`empire` 字段由 §增量 49(W0)补齐,本批次不再重复。
|
||
|
||
- **改动**
|
||
- `extension/src/net/entity_store.{h,cpp}`:新增
|
||
`void mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi)`——
|
||
`v = (uint64_t)lo | ((uint64_t)hi << 32)`,命中且值变化时写 `affect_flags` 并
|
||
`m_dirty.push_back(vid)`(去重刷新);未知 VID `dbg_ignored + return`。
|
||
`mut_char_update()` 的 `guild / alignment / pk_mode` 变化现在也置 `changed` 并推
|
||
`{Info, vid}`(原先漏推)。
|
||
- `project/net_world.gd`:新增 `_apply_field_updates(n, d)`,严格按
|
||
`NetworkActorManager.cpp:470` 调用序:ChangeArmor → ChangeWeapon → ChangeHair →
|
||
ChangeGuild → SetAffectFlagContainer → SetMoveSpeed → SetAttackSpeed → SetAlignment →
|
||
SetPKMode → SetStateFlags;每个 setter 用 `has_method` 守卫,占位胶囊与不建模该字段
|
||
的视图自动跳过。`_on_spawn` / `_on_info` 末尾各调一次。
|
||
`const AFFECT_SHOW_ALWAYS := 1 << 7`、`const WALL_RACES := [14201,14202,14203,14204]`。
|
||
- **待读参考落定**:40250 `ClientVS22` 树的 `AffectFlagContainer.h` 未逐一拼出枚举成员;
|
||
`AFFECT_SHOW_ALWAYS` = bit 7 取自 m2dev 姊妹源 `InstanceBase.h` 的 `enum EAffect`
|
||
序号 7,已在 `net_world.gd` 注释标注为本地常量。
|
||
- **W1 跟进项**:`GCCharacterAdd/Add2/Update` 都带 `uint32_t affect_flag[2]`,但
|
||
`mut_char_update` 无 affect 形参——W1 需在 `classic_parser.cpp` 的
|
||
`GC_CHARACTER_ADD` / `ADD2` / `GC_CHARACTER_UPDATE` 之后调
|
||
`mut_affect_flags(vid, affect_flag[0], affect_flag[1])`。
|
||
- **测试**:`ctest` 16/16;`netbridge_test` / `gamescene_test` / `combat_fx_test` /
|
||
`player_motion_test` 全绿;新增 `net_world_vis_test` 覆盖 `AFFECT_SHOW_ALWAYS` 常驻。
|
||
- **验收**:未验证(换装 / affect 表现需真服)。
|
||
|
||
---
|
||
|
||
### 2.6 一次性事件没有可靠的生命周期缓存
|
||
|
||
**参考**:参考端**不缓存**。`CPythonNetworkStream` 收到包立刻
|
||
`PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], ...)`;窗口指针由 Python 在
|
||
`SetPhaseWindow(ePhaseWnd, poPhaseWnd)`(`PythonNetworkStream.h:133`)注册,
|
||
`PyCallClassMemberFunc` 对空指针是安全 no-op。真正的保证是**顺序**:
|
||
`GC_PHASE(PHASE_GAME)` 之前 Python 已经 `game.py:137 GameWindow.Open()` 完成装配,
|
||
而 `SendEnterGamePacket()` 是 `Open()` 的倒数第二步(`game.py:207`)——
|
||
**服务端在收到 `CG_ENTERGAME` 之前不会推游戏事件**。
|
||
|
||
**当前**:`M2Client` 的 drain queue 在 `game_scene` 装配完成前可能被消费。
|
||
|
||
**修改**
|
||
|
||
1. **不要**加事件缓存。改为对齐顺序:
|
||
`CG_ENTERGAME` 的发送点从 `ClassicSession` 的定时器(§1.1 已删)挪到
|
||
`game_scene.gd` 完成全部窗口装配后显式调用 `client.enter_game()`,
|
||
即 1:1 对齐 `game.py:207`。
|
||
2. `game_scene.gd` 的装配顺序照抄 `game.py:137 Open()`:
|
||
相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 → `SetFocus/Show/ShowCursor`
|
||
→ `net.SendEnterGamePacket()` → `StartGame()`。
|
||
3. `M2Client` 的 drain 接口保持「不缓存」,但在 `enter_game()` 之前**不启动** world 分派,
|
||
避免登录洪流里的包被提前消费。
|
||
|
||
---
|
||
|
||
### 2.7 GC_WARP 永远重连,换图必须清理并重建世界
|
||
|
||
**参考**:`CPythonNetworkStream::RecvWarpPacket()`(`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:923`)
|
||
整体只有三步:接收 `{bHeader, lX, lY, lAddr, wPort}`、设置
|
||
`__DirectEnterMode_Set(m_dwSelectedCharacterIndex)`、调用
|
||
`CNetworkStream::Connect((DWORD)lAddr, wPort)`。
|
||
|
||
结论:
|
||
|
||
- `GC_WARP` 在参考端**永远是重连**,不存在用 `addr == 0` 判断“同服只移动玩家”的分支。
|
||
- `lX / lY` 在 `RecvWarpPacket()` 这一步不直接消费,但重连后的
|
||
`GC_MAIN_CHARACTER*` / `PhaseLoading.cpp:211 LoadData(lX, lY)` 仍会用它们定位角色,必须
|
||
透传,不能标成无用字段。
|
||
- `CNetworkStream::Connect()`(`REF/EterLib/NetStream.cpp:401`)先 `Clear()`,会清理 socket、
|
||
收发缓冲和 sequence;新连接再按 DirectEnter 流程 HandShake → Login → Select → Loading。
|
||
|
||
**当前首版(已实现,仍待真实服务端)**:
|
||
|
||
- `classic_parser.cpp` 将 Classic `GC_WARP` 标记为 `reconnect=true`;`WarpCue::same_server()`
|
||
不再作为 Classic 的判据,m2dev 旧语义仍可保留在其独立协议路径。
|
||
- `M2Client` 在收到 Warp 后发出 `world_reset`,保存登录 key / 已选角色槽,清空实体、掉落、
|
||
目标、PVP / 决斗、技能 / affect / 交互等地图局部状态,再由 `ClassicSession::connect_warp()`
|
||
按 `lAddr:wPort` 建立第二次游戏连接。
|
||
- Godot `GameScene` / `NetWorld` / `GroundItems` 清理旧节点并重建地图、HUD、Atlas、环境和
|
||
玩家位置;背包、任务、好友等跨地图数据保留。
|
||
|
||
**剩余修改与验收**
|
||
|
||
1. 保持 `lX / lY / lAddr / wPort` 的 wire、信号和重连路径一致;注释统一写“本步不消费”,
|
||
不写“坐标无用”。
|
||
2. 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的 `lAddr:wPort`、发送 DirectEnter / Select,
|
||
收到目标 `GC_MAIN_CHARACTER*`,并确认旧场景节点、旧掉落和旧实体没有残留。
|
||
3. 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前
|
||
只覆盖基础场景和 UI。
|
||
4. 回归:`net_classic_session_test` 覆盖 endpoint / selected slot 保留,`net_entity_test` 覆盖
|
||
loading reset,`gamescene_test` / `p9_test` / `p2b_test` 覆盖场景重建;localhost bind 被
|
||
`skip` 时只能记为“未验证”,不能替代真实 E2E。
|
||
|
||
---
|
||
|
||
## 3. P1:移动、战斗和技能
|
||
|
||
### 3.1 本地移动是直线位置修改
|
||
|
||
**参考**:本地移动**不是**直接改坐标,而是「设 Src/Dst + 起走路动作 + 每帧推进 + 碰撞回退」。
|
||
|
||
- 点地移动 `CPythonPlayer::__OnPressGround`(`REF/UserInterface/PythonPlayerInput.cpp:419`):
|
||
```
|
||
__ClearReservedAction(); __ClearAutoAttackTargetActorID();
|
||
if (NEW_CancelFishing()) return;
|
||
if (!__IsMovableGroundDistance(...)) return; // 距离 < MOVABLE_GROUND_DISTANCE 不动
|
||
if (!rkInstMain.NEW_MoveToDestPixelPositionDirection(p)) __ReserveClickGround(p);
|
||
```
|
||
`__ReserveClickGround` 会设 `m_fReservedDelayTime = 0.1f`(`PythonPlayerInput.cpp:834`)。
|
||
- `CInstanceBase::NEW_Goto()`(`REF/UserInterface/InstanceBaseMovement.cpp:251`)的**前置门**依序:
|
||
`__IsSyncing()` → false;`IsUsingMovingSkill()` → 只转向;`isLock()` → false;
|
||
`!NEW_CanMoveToDestPixelPosition(dst)` → 停走并返回 true。
|
||
通过后:`NEW_SetSrcPixelPosition(cur) / NEW_SetDstPixelPosition(dst) /
|
||
NEW_SetDstPixelPositionZ(src.z) / m_fDstRot / m_isGoing=TRUE / StartWalking()`。
|
||
- 方向键移动 `NEW_MoveToDirection`(`InstanceBaseMovement.cpp:293`)同样有
|
||
`__IsSyncing / IsUsingMovingSkill / isLock` 三门,且 `m_isGoing = FALSE`。
|
||
- 玩家层 `CPythonPlayer::NEW_MoveToDirection`(`PythonPlayerInput.cpp:445`)额外:
|
||
`IsOpenPrivateShop()` → 直接返回;`isLock() && !IsUsingMovingSkill()` → 返回。
|
||
- 碰撞:`CInstanceBase::CheckAdvancing()`(`InstanceBaseBattle.cpp:469`)——
|
||
地形 `ATTRIBUTE_BLOCK` 检测(`InstanceBaseBattle.cpp:560`)+ 与其它 `CInstanceBase` 的
|
||
`TestActorCollision`,命中即 `BlockMovement()`。`CanSkipCollision()` 为真时整段跳过
|
||
(网络状态包驱动的移动会 `__EnableSkipCollision()`,见 §3.2)。
|
||
|
||
**当前**:`player_controller.gd:216` 直接改 X/Z,只做地形阻挡。
|
||
|
||
**修改**
|
||
|
||
1. `player_controller.gd` 重构为「Src/Dst + `is_going` + `walk_mode`」模型,一一对应上面的
|
||
`NEW_Goto` / `NEW_MoveToDirection`,并保留三个前置门(`syncing / moving_skill / lock`)。
|
||
2. 新增 `MOVABLE_GROUND_DISTANCE`(参考默认值由 `SetMovableGroundDistance` 从 Python 设置
|
||
**[待读参考:`POC/assets/root/game.py` / `playersettingmodule.py` 中的
|
||
`player.SetMovableGroundDistance` 调用]**)。
|
||
3. `__ReserveClickGround` 的 0.1 s 延迟、`NEW_IsEmptyReservedDelayTime` 的递减逻辑
|
||
(`PythonPlayerInput.cpp:626`)照搬。
|
||
4. Actor 之间的碰撞:用 Godot 的 Area3D 近似 `TestActorCollision`;命中 → `BlockMovement()`
|
||
(= 停在上一帧位置,不做滑移)。
|
||
5. 坐骑:`IsMountingHorse()` 时移动速度与动作模式按 §6.4 的 `MODE_HORSE*` 走。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 76;Actor-Actor 碰撞 + 坐骑转向速度 2026-09-03 / 增量 93)**
|
||
|
||
- `player_controller.gd` 重构为 Src/Dst 模型:`_src_pos` / `_dst_pos` / `_is_going` /
|
||
`_dst_rot`,`walk_to()` → `_goto(dst_flat)` 一一对应 `CInstanceBase::NEW_Goto()`——
|
||
太近(`flat.length() < MOVABLE_GROUND_DISTANCE_M`,默认 1.0 m)直接返回 true 不动
|
||
(≈ `__IsMovableGroundDistance`);否则设 Src=当前位、Dst=点、`_dst_rot=atan2(x,z)`、
|
||
`_is_going=true`。`_process` 每帧朝 `_dst_pos` 推进,抵达即 `_is_going=false`。
|
||
- 三个前置门用 `_can_translate()` = `not frozen and not locked and not moving_skill`
|
||
统一(≈ `__IsSyncing` / `isLock` / `IsUsingMovingSkill`):门未过时 `_goto` 把点存进
|
||
`_reserved_ground` 并置 `_reserved_delay_time = RESERVED_GROUND_DELAY`(0.1 s),
|
||
照搬 `__ReserveClickGround` + `NEW_IsEmptyReservedDelayTime` 递减重试。
|
||
- `moving_skill` 为真时 `_goto` 不平移、`_process` 只 `player.rotation.y = atan2(wish.x, wish.z)`
|
||
(≈ `IsUsingMovingSkill()` → 只转向)。WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`
|
||
的 `m_isGoing = FALSE`)。
|
||
- 回归:新增 `project/player_move_test.gd`(5 组:`_goto` 远点设 Src/Dst/is_going/dst_rot、
|
||
太近不动、每帧推进抵达清 is_going、frozen 门 → 预约 + 0.1 s 后解冻重试、`moving_skill`
|
||
只转向)。`netplay_test` / `combat_fx_test` / `gamescene_test` / C++ 19/19 全绿。
|
||
- 增量 77 已补上 §3.2 的命令队列(`skip_collision` 标志已就位)。
|
||
- **增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**:`player_controller._actor_blocked(next_pos)`
|
||
实现 `CActorInstance::TestActorCollision`(`ActorInstanceCollisionDetection.cpp`)+
|
||
`CInstanceBase::CheckAdvancing`(`InstanceBaseBattle.cpp:469`)近似——距离门 8 m
|
||
(≈ `LengthSq > 800²`)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,固定半径近似真实
|
||
race 碰撞球)→「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision`
|
||
末尾 `dist(cur) <= dist(last)` 才 `return TRUE`,远离放行)。候选=`pickables`,`dead` meta
|
||
跳过(≈ `rVictim.IsDead()`)。`_process` 平移改逐轴门控(单 Actor 逐轴放行=滑移 ≈
|
||
`AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧 ≈ `BlockMovement` / `__InitializeMovement`)。
|
||
`skip_actor_collision`(≈ `CanSkipCollision` / `__EnableSkipCollision`)由 `net_play` 按观战态置位。
|
||
坐骑:`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛朝向,`_refresh_motion_mode()` 在
|
||
`mounted` 时切 `ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`,
|
||
`CInstanceBase::MountHorse` → `SetRotationSpeed`,`InstanceBase.cpp:954`),下马恢复 1200。
|
||
`MODE_HORSE*` 动作模式沿用增量 84。`player_move_test.gd` 5 组 → 8 组。C++ 21/21 + 14 GDScript 全绿。
|
||
- **仍未做(留后续)**:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`);
|
||
`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替;
|
||
`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家;`MOVABLE_GROUND_DISTANCE`
|
||
参考默认值仍是估计(1.0 m),待读 `game.py` 侧 `SetMovableGroundDistance` 调用校准;
|
||
路径寻路 / 坡度处理。
|
||
|
||
---
|
||
|
||
### 3.2 同步是距离阈值加 lerp
|
||
|
||
**参考**:远端 Actor 的位置**完全由一条命令队列**驱动,而不是 lerp。
|
||
|
||
- 入队:`CInstanceBase::PushTCPState(dwCmdTime, kPPosDst, fRot, eFunc, uArg)`,
|
||
由 `CNetworkActorManager::MoveActor`(`NetworkActorManager.cpp:520`)与
|
||
`__AppendCharacterManagerActor`(`:410`,用于补发正在移动中的 actor)调用。
|
||
- 出队:`CInstanceBase::StateProcess()`(`REF/UserInterface/InstanceBase.cpp:1278`)
|
||
```
|
||
while (队非空):
|
||
if (ELTimer_GetServerFrameMSec() < front.m_dwChkTime) return; // 按服务器帧时间释放
|
||
pop;
|
||
if (!__CanProcessNetworkStatePacket()) return; // Dead / KnockDown / 不可取消的技能中
|
||
if (!__IsEnableTCPProcess(eFunc)) return; // 表情中 / m_bEnableTCPState==false
|
||
switch (eFunc) { ... }
|
||
```
|
||
- 各 `eFunc` 的阈值是**硬阈值**,不是 lerp:
|
||
| eFunc | 条件 | 行为 |
|
||
|---|---|---|
|
||
| `FUNC_WAIT` | `fDirLen > 1.0f` | 走过去(Src/Dst + StartWalking,`m_kMovAfterFunc = WAIT`) |
|
||
| `FUNC_WAIT` | 否则 | `SCRIPT_SetPixelPosition(dst)` + `SetAdvancingRotation` + `SetRotation`(瞬移对齐) |
|
||
| `FUNC_MOVE` | 无条件 | Src/Dst + StartWalking,`m_kMovAfterFunc = MOVE` |
|
||
| `FUNC_COMBO` | `fDirLen >= 50.0f` | 先走过去,到达后 `RunComboAttack(rot, uArg)` |
|
||
| `FUNC_COMBO` | 否则 | 瞬移对齐 + `RunComboAttack(rot, uArg)` |
|
||
| `FUNC_ATTACK` | `fDirLen >= 50.0f` | 先走过去,到达后普攻 |
|
||
| `FUNC_ATTACK` | 否则 | 瞬移对齐 + `BlendRotation` + `RunNormalAttack(rot)` |
|
||
| `FUNC_MOB_SKILL` | `fDirLen >= 50.0f` / 否则 | 同上,动作换 mob skill |
|
||
所有「走过去」分支都会 `__EnableSkipCollision()`。
|
||
- 数据层的插值另有一份,只用于可见性判定:`SNetworkActorData::UpdatePosition()`
|
||
(`NetworkActorManager.cpp:8`)按 `(now - m_dwClientSrcTime) / m_dwDuration` 线性插值,
|
||
超时钳到 Dst。
|
||
- 本地上行的节流(`REF/UserInterface/PythonPlayerEventHandler.cpp`):
|
||
| 事件 | 节流 | 发送 |
|
||
|---|---|---|
|
||
| `OnMove` (:76) | 重置两个计时器(wait +100 ms / moving +300 ms) | `FUNC_MOVE, 0` |
|
||
| `OnMoving`(:62) | 300 ms | `FUNC_MOVE, 0` |
|
||
| `OnWaiting`(:38)| 100 ms **且**位移 ≥ 1.0 | `FUNC_WAIT, 0` |
|
||
| `OnStop` (:88) | 无 | `FUNC_WAIT, 0` |
|
||
| `OnAttack`(:102)| 无 | `FUNC_COMBO, wMotionIndex` |
|
||
| `OnUseSkill`(:120)| 无 | `FUNC_SKILL\|uMotSkill, uArg` |
|
||
- 上行包 `SendCharacterStatePacket`(`PythonNetworkStreamPhaseGame.cpp:1111`):
|
||
`TPacketCGMove{ header=HEADER_CG_CHARACTER_MOVE, bFunc, bArg, bRot = rot/5.0f,
|
||
lX, lY, dwTime = ELTimer_GetServerMSec() }`;发送前 `__CanActMainInstance()` 为假直接返回 true;
|
||
角度先归一到 `[0,360)`;坐标最后经 `__LocalPositionToGlobalPosition`。
|
||
|
||
**当前**:`net_world.gd` 按距离阈值 snap/lerp;`net_play.gd` 按固定时间/距离发送。
|
||
|
||
**修改**
|
||
|
||
1. `EntityStore` 新增每实体的 `std::deque<StateCmd>`(`{chk_time, dst_x, dst_y, rot, func, arg}`),
|
||
`mut_move()` 改为 `push_tcp_state()`,不再直接改坐标。
|
||
2. 新增 `EntityStore::process_states(server_frame_ms)`,1:1 实现上表的 switch
|
||
(含 `1.0f` / `50.0f` 两个阈值与 `m_kMovAfterFunc` 的「到达后动作」)。
|
||
3. `EntityStore::update(now)` 里保留 `SNetworkActorData::UpdatePosition()` 的线性插值,
|
||
但**只**用于 §2.1 的可见性判定,不驱动节点位置。
|
||
4. `net_play.gd` 的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0),
|
||
删除现有的「固定时间或距离」逻辑。
|
||
5. `ELTimer_GetServerMSec()` 的等价:`GC_HANDSHAKE` 的 `lDelta` 已用于时钟对齐
|
||
(`REF/.../PythonNetworkStreamPhaseHandShake.cpp:124`),确认 `M2Client` 的
|
||
`server_time_ms()` 与之一致后统一使用,不要用本地 `ticks_msec`。
|
||
6. **不要**引入预测回滚 / 抖动缓冲——参考端没有。
|
||
|
||
**状态:🟡 部分完成(2026-09-02 / W2 / 增量 77 —— C++ 命令队列核心)**
|
||
|
||
- `Entity` 新增 `std::deque<StateCmd> state_queue`(`{chk_time, dst_x, dst_y, rot,
|
||
func, arg, dur_ms}`)+ `mov_after_func` / `mov_after_arg` / `mov_after_rot` /
|
||
`skip_collision`(对应 `m_kMovAfterFunc` / `__EnableSkipCollision`)。
|
||
- `mut_move()` 不再直接改坐标:入队 `StateCmd`(≈ `CInstanceBase::PushTCPState`)后
|
||
立即 `drain_state_queue()` 释放已到期项。m2dev `apply()` 的 `GC_MOVE` 与 classic
|
||
parser 都改走 `mut_move(vid, angle, func, tx, ty, dur, arg, time)`,删掉旧的
|
||
`start_move()` 直改坐标路径。
|
||
- 新增 `process_states()` + 私有 `drain_state_queue` / `apply_state_cmd` /
|
||
`begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`,
|
||
1:1 实现上表 switch:`FUNC_WAIT` 的 `1.0` 阈值(走过去 vs 瞬移对齐)、
|
||
`FUNC_COMBO/ATTACK/MOB_SKILL` 的 `50.0` 阈值(先走再动作 vs 瞬移+动作),
|
||
「走过去」分支置 `skip_collision=true` 并把 combat 动作 + 动作索引 latch 到
|
||
`mov_after_*`;`tick()` 里走到 Dst 后按 latch 把 `func` 切到 COMBO/ATTACK/MOB_SKILL
|
||
再清 latch。
|
||
- 释放门:`drain_state_queue` 先按服务器帧钟 `m_server_frame_ms`(新增
|
||
`set_server_frame_ms` / `server_frame_ms`)比 `chk_time`——帧钟为 `0`(尚未对齐)时
|
||
一律立即释放,等价旧行为;再过 `__CanProcessNetworkStatePacket`(`dead` / `knock_down`
|
||
时 hold 队列不丢包)。走路时长优先用服务器 `dur_ms`(≈ `SNetworkActorData::UpdatePosition`
|
||
的插值窗口),缺省才按 `dist / moving_speed` 推。
|
||
- `tick()` 每帧先 `process_states()` 再推进插值。
|
||
- 回归:新增 `extension/tests/net_state_queue_test.cpp`(帧钟释放门、帧钟 0 立即释放、
|
||
`FUNC_WAIT` 1.0 阈值、`FUNC_COMBO` 50.0 阈值 + 到达后动作、knock_down / dead hold 门、
|
||
多条到期命令一次排空按序);`net_entity_test` 的 `FUNC_ATTACK` 断言按新语义改写
|
||
(近距 <50 瞬移+动作、远距 ≥50 先走后打)。C++ 20/20(含新 `net.state_queue`)、
|
||
`netplay_test` / `combat_fx_test` / `gamescene_test` / `netbridge_test` / `p2b_test` /
|
||
`player_move_test` 回归全绿。
|
||
- **留给增量 78**:④ `net_play.gd` 上行改 6 事件回调 + 节流常量(100 / 300 ms、位移 1.0);
|
||
③ `net_world.gd` 停掉自己那份 lerp,C++ 插值只喂 §2.1 可见性;⑤ 从 `GC_HANDSHAKE`
|
||
`lDelta` 接真实服务器毫秒帧钟并 `set_server_frame_ms()`(现为 0 = 立即释放);
|
||
`is_enable_tcp_process` 的表情锁位(实体行暂无该状态);GC_SYNC_POSITION 纠偏是否清队列。
|
||
|
||
**状态:🟡 部分完成(2026-09-02 / W2 / 增量 78 —— 上行 6 回调 + net_world 去 lerp)**
|
||
|
||
- **④ `net_play.gd` 上行改 6 事件回调**(对齐 `PythonPlayerEventHandler.cpp` 的
|
||
`OnMove` / `OnMoving` / `OnWaiting` / `OnStop` / `OnAttack` / `OnUseSkill`):
|
||
- 新增统一出口 `_send_state(func, arg, world_pos)` → `client.move(func, arg,
|
||
heading, cm.x, cm.y)`(≈ `SendCharacterStatePacket` / `TPacketCGMove`,rot 的
|
||
`/5.0` 与坐标 `__LocalPositionToGlobalPosition` 仍由 C++ `client.move()` 负责),
|
||
并记 `_last_sent_pos`。
|
||
- `_on_local_moved`(`pc.moved` 每帧):`_was_moving` 为假 → **OnMove** 立即发
|
||
`FUNC_MOVE`,并把 `_last_moving_sent_t` / `_last_waiting_sent_t` 都推到「刚发过」
|
||
(= 重置 moving/waiting 两个节流窗);否则 → **OnMoving**,`SEND_MOVING_INTERVAL`
|
||
300 ms 节流后发 `FUNC_MOVE`。
|
||
- `_on_anim_state("wait")` 且 `_was_moving` → **OnStop**:不节流,落一包 `FUNC_WAIT`。
|
||
- 新增 `_tick_on_waiting()`(`_process` 每帧调):站立时 `SEND_WAITING_INTERVAL`
|
||
100 ms 节流且相对 `_last_sent_pos` 位移 ≥ `SEND_WAITING_MIN_DISP_CM`(1 cm) 才发
|
||
`FUNC_WAIT` —— 吸收本地被击退 / 碰撞推挤后的漂移。
|
||
- `_do_attack_swing`:除既有 `client.attack()`(`CG_ATTACK`)外,**OnAttack** 额外
|
||
`_send_state(FUNC_COMBO, attack_motion + _combo, pos)`,让远端对齐攻击者位置 / 朝向;
|
||
并置 `_was_moving = false`。
|
||
- 新增公共入口 `on_use_skill(motion_skill, arg)` → **OnUseSkill**
|
||
`_send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pos)`;调用点(技能合法性 /
|
||
CD / charge)属 §3.8 的 `player_skill.gd`,这里只留 1:1 上行出口(做法同 §3.4 预留桩)。
|
||
- 删除旧的 `SEND_INTERVAL` / `SEND_DIST` / `_last_sent_t` 单一节流。
|
||
- **③ `net_world.gd` 去掉自有 lerp**:`_process` 里 `n.position = _grounded(e.pos)`
|
||
直接跟随——位置插值现在完全在 C++ `EntityStore::tick()`(命令队列 +
|
||
`SNetworkActorData::UpdatePosition` 式线性插值)里做,再叠第二层 `follow_lerp` 会把
|
||
C++ 的到达时机拖慢、和 `.msa` 动作事件脱节。`follow_lerp` / `snap_dist` 字段保留只为
|
||
兼容外部读取,`_process` 不再引用。朝向仍在 GDScript 侧 `lerp_angle` 平滑(非位置)。
|
||
- 回归:`netplay_test` 新增第 11 段(OnMove 立即发 + 重置节流窗 / OnMoving 300 ms 节流 /
|
||
OnStop 收尾 / `on_use_skill` → `FUNC_SKILL|motion`+arg),观战段基线改为「相对计数不变」;
|
||
`netbridge_test` 的「near move → lerp closes in」改为「node follows e.pos directly」。
|
||
C++ 20/20 不变;`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` /
|
||
`p2b_test` / `combat_fx_test` / `player_motion_test` 全绿。
|
||
- **留给增量 79**:⑤ 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟。`ClassicStream`
|
||
已在 `HDR_HANDSHAKE` 存了 `m_server_time_base = hs.time + delta` / `m_client_time_base
|
||
= now_ms()`(`classic_stream.cpp:256`),需暴露 `server_frame_ms()` 访问器,穿过
|
||
`classic_session` → `classic_parser`(持 `m_world`)或在 `m2_client` `w.tick()` 前算好
|
||
`now - m_client_time_base + m_server_time_base` 调 `w.set_server_frame_ms()`;m2dev
|
||
后端另有一路。接上前 `m_server_frame_ms == 0` 直通 = 增量 76 时序,无回归。
|
||
`is_enable_tcp_process` 表情锁、`GC_SYNC_POSITION` 是否清队列同样留给 §3.x。
|
||
|
||
**状态:✅ 完成(2026-09-03 / W2 / 增量 79 —— 真实服务器毫秒帧钟,§3.2 收官)**
|
||
|
||
- **⑤ `ClassicStream::server_frame_ms()`**(`classic_stream.h:107` / `.cpp:48`,≈
|
||
`ELTimer_GetServerFrameMSec()`):`HDR_HANDSHAKE` 处理里早已存的
|
||
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`
|
||
(`classic_stream.cpp:266-267`),帧钟 = `now_ms() - m_client_time_base +
|
||
m_server_time_base`;首个握手落地前 `m_client_time_base == 0`,返回 `0`。
|
||
另加 `has_server_clock()`。时间同步模式下每个 resync 握手都会刷新时间基,帧钟保持对齐。
|
||
- **穿线**:`ClassicSession::process()` 在 `m_world.tick()` 前
|
||
`m_world.set_server_frame_ms(m_stream.server_frame_ms())`(`classic_session.cpp:307`),
|
||
于是 classic 后端 `drain_state_queue()` 按服务器时间放行队列。m2dev 后端无握手 `lDelta`
|
||
对应物,`m_server_frame_ms` 恒 `0` = 即时释放 = 增量 76 时序,无回归。
|
||
- **防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000`**(`entity_store.h`):参考端 `StateProcess`
|
||
没有上限;我们的帧钟尚未联机核验,若服务器 `dwTime` 落在我们不共享的时基里,「等到
|
||
`frame >= chk_time`」会让远端 Actor 永久冻结。故 `chk_time` 领先帧钟超过 1 s 视为异常,
|
||
立即放行。**有意偏离参考,已在此记录。**
|
||
- 回归:`net_classic_stream_test` 加帧钟段(握手前 `0` + `!has_server_clock()`、握手后
|
||
≈ `hs.time + lDelta`、连续读单调不减);`net_state_queue_test` 加第 6 段(`chk_time`
|
||
正好等于 `frame + cap` 仍 gated、超一毫秒立即放行)。C++ 20/20、7 个 GDScript 回归全绿。
|
||
- **§3.2 至此完成**。仍待联机核验:真实服务器 `GC_MOVE` `dwTime` 的时基是否与握手帧钟一致
|
||
(若一致,`STATE_QUEUE_MAX_WAIT_MS` 阀几乎不会触发);`is_enable_tcp_process` 表情锁位、
|
||
`GC_SYNC_POSITION` 纠偏是否清队列,随对应 §3.x 再处理。
|
||
|
||
---
|
||
|
||
### 3.3 普攻节奏硬编码
|
||
|
||
**参考**:攻击节奏完全来自 `.msa` 动作数据,没有任何硬编码间隔。
|
||
|
||
- 攻击距离:`CInstanceBase::NEW_IsClickableDistanceDestInstance`
|
||
(`REF/UserInterface/InstanceBaseBattle.cpp:294`)
|
||
```
|
||
fDistance = 150.0f;
|
||
if (IsBowMode()) fDistance = __GetBowRange();
|
||
if (dst.IsNPC()) fDistance = 500.0f;
|
||
if (dst.IsResource()) fDistance = 100.0f;
|
||
```
|
||
对像素坐标的通用判定 `NEW_IsClickableDistanceDestPixelPosition` 固定 `150.0f`(`:283`)。
|
||
- 攻击间隔:由动作时长决定。`CActorInstance::InputComboAttackCommand`
|
||
(`REF/GameLib/ActorInstanceBattle.cpp:157`)用
|
||
`m_pkCurRaceMotionData->GetComboInputStartTime() / GetNextComboTime() / GetComboInputEndTime()`
|
||
(`CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime}`,
|
||
`REF/GameLib/RaceMotionData.h:177`);无 combo 输入数据时退化为
|
||
`fElapsedTime > GetMotionDuration() * 0.9f`。
|
||
- 攻速:`SetAttackSpeed(m_dwAtkSpd)` 影响动作播放速率
|
||
**[待读参考:`REF/UserInterface/InstanceBase.cpp` 的 `CInstanceBase::SetAttackSpeed`]**。
|
||
|
||
**当前**:`POC/project/net_play.gd:25` 一组固定常量。
|
||
|
||
**修改**
|
||
|
||
1. 距离常量搬到 `net_play.gd`:`CLICK_DIST_DEFAULT=150.0`、`CLICK_DIST_NPC=500.0`、
|
||
`CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价。
|
||
已读参考:`CInstanceBase::__GetBowRange`(`REF/UserInterface/InstanceBase.cpp:694`)=
|
||
`2500.0f - 100.0f`,主角再加 `IAbstractPlayer::GetStatus(POINT_BOW_DISTANCE)`(`Packet.h:1550`
|
||
索引 34,注释单位 meter,但参考端按 cm 直加不 ×100);`IsBowMode()` =
|
||
`GetMotionMode()` 为 `MODE_BOW` / `MODE_HORSE_BOW`(`REF/GameLib/ActorInstance.cpp:258`)。
|
||
—— **增量 85 落地**(见 §3.5 状态块 / 下方)。
|
||
2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见
|
||
`metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end /
|
||
motion_duration`。
|
||
3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 74)**
|
||
|
||
- `formats/msa.{h,cpp}`:`parse_msa` / `parse_msa_file` 新增 `combat_data()`,读取
|
||
`Group ComboInputData`(`PreInputTime` / `DirectInputTime` / `InputLimitTime` /
|
||
`LinkTime` → `TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime, fComboLinkTime}`)
|
||
和 `Group AttackingData`(`AttackingType` / `HittingType` / `AttackingStartTime` /
|
||
`AttackingEndTime` / `StiffenTime` / `InvisibleTime` / `ExternalForce`)。缺组时
|
||
`has_combo_input` / `has_attacking_data` 保持 `false`。`formats/tests/msa_test.cpp`
|
||
用真实 `onehand_sword/combo_01.msa` 的字段值断言。
|
||
- `extension/src/metin2_anim.{h,cpp}`:新增并绑定 `get_motion_data()`,导出
|
||
`duration` / `next_combo`(`has_combo_input && DirectInputTime>0 ? DirectInputTime :
|
||
duration*0.9`)/ `attack_start_time` / `attack_end_time` / `stiffen_time` /
|
||
`invisible_time` / `external_force` 等。
|
||
- `net_play.gd`:删除 `ATTACK_RANGE` 常量,改为 `CLICK_DIST_DEFAULT_CM=150` /
|
||
`CLICK_DIST_NPC_CM=500` / `CLICK_DIST_RESOURCE_CM=100`(`_clickable_distance(te)`
|
||
按 `_entity_kind` 选,单位 cm→m)。`_apply_attack_speed()` 只把 `bAttackSpeed/100`
|
||
夹到 `[0.25,3]` 写入 `_atk_speed_factor`(缩放播放速率),不再在收 info 包时把
|
||
`attack_period` 直接写死。攻击命中点用 `attack_period = _current_attack_period()`
|
||
= `(next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`。
|
||
—— **弓箭 `__GetBowRange()` 已在增量 85 接入**:`_clickable_distance()` 里 `_is_bow_mode()`
|
||
时改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`
|
||
(`_on_points()` 读 `points["bow_distance"]` = `POINT_BOW_DISTANCE`=34),NPC / 采集物分支覆盖之。
|
||
详见 §3.5 状态块。
|
||
- 回归:C++ 19/19(`formats.msa_loop_data` 覆盖扩展断言);`combat_fx_test` /
|
||
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
|
||
`p2b_test` 全绿。
|
||
|
||
---
|
||
|
||
### 3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑
|
||
|
||
**参考**:两段。
|
||
|
||
**(a) `CPythonPlayer::__Update_AutoAttack()`**(`REF/UserInterface/PythonPlayer.cpp:207`),每帧:
|
||
```
|
||
if (0 == m_dwAutoAttackTargetVID) return;
|
||
if (!NEW_GetMainActorPtr()) return;
|
||
if (__IsUsingChargeSkill()) return; // 弹射/冲锋中跳过
|
||
victim = NEW_FindActorPtr(m_dwAutoAttackTargetVID);
|
||
if (!victim) __ClearAutoAttackTargetActorID();
|
||
else if (victim->IsDead()) __ClearAutoAttackTargetActorID();
|
||
else if (main->IsMountingHorse() && !main->CanAttackHorseLevel())
|
||
__ClearAutoAttackTargetActorID();
|
||
else if (main->IsAttackableInstance(*victim))
|
||
{ if (main->IsSleep()) { /* skip */ } else __ReserveClickActor(m_dwAutoAttackTargetVID); }
|
||
```
|
||
`__IsUsingChargeSkill()`(`PythonPlayer.cpp:182`):`__CheckDashAffect` 为真,或
|
||
`MODE_USE_SKILL` 且预约技能是 `IsChargeSkill()`。
|
||
|
||
**(b) `CPythonPlayer::__ReserveProcess_ClickActor()`**(`PythonPlayerInput.cpp:877`),
|
||
每帧在 `MODE_CLICK_ACTOR` 下跑,**顺序不可改**:
|
||
```
|
||
1 main / reserved 任一为空 → __ClearReservedAction(); return
|
||
2 !NEW_IsClickableDistanceDestInstance → NEW_MoveToDestInstanceDirection(); return (继续靠近)
|
||
3 !IsAttackableInstance → NEW_Stop(); __SendClickActorPacket(); 清预约; return (NPC 对话)
|
||
4 reserved->IsDead() → 清预约; return
|
||
5 main->IsInSafe() → OnCannotAttack(..., "IN_SAFE"); NEW_Stop(); 清预约; return
|
||
6 reserved->IsInSafe() → OnCannotAttack(..., "DEST_IN_SAFE"); NEW_Stop(); 清预约; return
|
||
7 __CheckDashAffect(main) → return (保持预约)
|
||
8 main->IsBowMode() && !__HasEnoughArrow() → OnCannotShot(..., "EMPTY_ARROW"); NEW_Stop(); 清预约; return
|
||
9 reserved VID != GetTargetVID() → SetTarget(reserved VID)
|
||
10 NEW_AttackToDestInstanceDirection(reserved); __ClearReservedAction();
|
||
```
|
||
|
||
**(c) 预约模式枚举**(`PythonPlayerInput.cpp:820-856`):
|
||
`MODE_NONE / MODE_CLICK_ITEM / MODE_CLICK_ACTOR / MODE_CLICK_POSITION / MODE_USE_SKILL`,
|
||
每帧在 `NEW_RefreshMouseWalkingDirection()`(`PythonPlayerInputMouse.cpp:287`)里分派;
|
||
末尾还依次处理 `m_isSmtMov / m_isDirMov / m_isDirKey / m_isAtkKey`。
|
||
|
||
**当前**:只有「测距后发攻击包」。
|
||
|
||
**修改**
|
||
|
||
1. `player_controller.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}`
|
||
与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved /
|
||
_reserved_delay`,逐字对齐 `PythonPlayerInput.cpp:820-865`。
|
||
2. 每帧执行 `_refresh_reserved()`,四个 case 分别照抄
|
||
`PythonPlayerInputMouse.cpp:288-370`:`MODE_CLICK_ITEM`(按 IID 查掉落物世界坐标,
|
||
`< 20.0f` `CLICK_ITEM_PICKUP_CM` 到手 → `FUNC_WAIT` + `pickup_item` + 解冻 + 清预约,
|
||
否则 `walk_to`;物品消失 → 清预约)、`MODE_CLICK_POSITION`(`_can_translate()` 未锁且
|
||
`_reserved_delay <= 0`(≈ `NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` +
|
||
清预约)、`MODE_USE_SKILL`(`dist_cm` 含 `IS_HUGE_RACE` 的 `-200cm` 修正,`<
|
||
_skill_range_reserved` → `set_target` + `use_skill_hook(slot)` 成功才清预约,否则
|
||
`walk_to`;目标丢失 → 清预约)。`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用
|
||
`proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似(seam)。
|
||
3. `_reserve_process_click_actor()` 严格按 (b) 的 10 步实现。
|
||
4. `_update_auto_attack()` 按 (a) 实现;`m_dwAutoAttackTargetVID` 只在
|
||
`__OnPressActor(isAuto=true)` 且 `IsAttackableInstance` 时设置
|
||
(`PythonPlayerInput.cpp:306` `__OnPressActor`)。
|
||
5. `OnCannotAttack` / `OnCannotShot` / `OnCannotUseSkill` 的字符串码
|
||
(`IN_SAFE / DEST_IN_SAFE / EMPTY_ARROW / NEED_TARGET / NOT_HORSE_SKILL / HAVE_TO_RIDE /
|
||
NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD`)走 `game.py` 同名回调的
|
||
`localeInfo` 文案表。
|
||
|
||
**状态:✅ 完成(2026-09-03 / W2 / 增量 91)**(增量 75 完成 CLICK_ACTOR 全量;增量 91 补齐
|
||
CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派 + `__ChangeTargetToPickedInstance` 拾取回退 +
|
||
扇形/圆形补 fly-targeting)
|
||
|
||
- `net_play.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION,
|
||
USE_SKILL}` 与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved /
|
||
_skill_range_reserved / _reserved_delay / _auto_attack_vid / _last_cannot`
|
||
(对齐 `PythonPlayerInput.cpp:820-865`)。`_process` 改为每帧
|
||
`_update_auto_attack()` → `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection`
|
||
的模式分派)。
|
||
- `_update_auto_attack()` 逐条照抄 `__Update_AutoAttack()`(PythonPlayer.cpp:207):
|
||
无目标 / `!_can_process_network_state()`(≈ `!NEW_GetMainActorPtr` / `__IsSyncing`)/
|
||
`_dash_active || (_using_skill && _charge_skill)`(`__IsUsingChargeSkill`)/ 目标不存在 /
|
||
死亡 / 骑乘且 `!CanAttackHorseLevel` 各自处理,可攻击且非睡眠时无条件
|
||
`_reserve_click_actor()`(节奏不在这里 gate)。
|
||
- `_reserve_process_click_actor()` 严格 10 步(`PythonPlayerInput.cpp:877`,顺序不可改):
|
||
1 空引用清预约 → 2 出可点击距离 `pc.walk_to()` 继续靠近 → 3 不可攻击 `client.click_npc()` +
|
||
清预约(NPC 对话)→ 4 目标死 → 5 `main.in_safe` → `OnCannotAttack("IN_SAFE")` →
|
||
6 `reserved.in_safe` → `"DEST_IN_SAFE"` → 7 `_dash_active` 保持预约 →
|
||
8 弓箭模式且 `_arrow_count==0` → `OnCannotShot("EMPTY_ARROW")` → 9 未选中则 `set_target` →
|
||
10 `_attack_cd<=0` 才 `_do_attack_swing()`(面向 + combo + `CG_ATTACK` + 动作 + §3.3 节奏刷新),
|
||
然后清预约、下帧重新预约。
|
||
- `OnCannot*` 走新 `signal cannot_act(code)` + `_last_cannot`(HUD 接 `localeInfo`)。
|
||
- 服务端尚未下发的判定位(`in_safe` / `bow_mode` / `mounting` / `sleeping` / dash affect /
|
||
charge skill / 箭矢数)先用实体字典键 + 本地占位变量(`_arrow_count=-1` / `_dash_active` /
|
||
`_charge_skill`)接线,真值填充与 §5.4 PVP / §3.8 技能 / 弓箭 `__GetBowRange` 一起补,
|
||
做法同 §3.7 的 `knock_down`。
|
||
- 回归:`netplay_test` 新增 5b 段(出距离→`walk_to` 且不发包、进距离→攻击且清预约、
|
||
`IN_SAFE`→`cannot_act` 且不发包、目标死→auto-attack 清零);C++ 19/19、`combat_fx_test` /
|
||
`netbridge_test` / `gamescene_test` / `player_motion_test` / `p2b_test` / `input_key_test`
|
||
全绿。
|
||
|
||
**增量 91 补充**(`net_play.gd` / `ui/skill_table.gd` / `ui/quickbar.gd` / `game_scene.gd`):
|
||
|
||
- `_refresh_reserved()` 补齐自增量 75 留桩的三个 case,逐字对齐 `NEW_RefreshMouseWalkingDirection`
|
||
(`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`(:290)、
|
||
`_reserve_process_click_position()`(:320,`_reserved_delay` 由 `_process` 逐帧递减 ≈
|
||
`NEW_IsEmptyReservedDelayTime`)、`_reserve_process_use_skill()`(:340,`_is_huge_race`
|
||
的 `HUGE_RACE_DIST_FIX_CM` `-200cm` 修正)。`_clear_reserved()` 同时复位 `_ppos_reserved` /
|
||
`_skill_range_reserved`。
|
||
- 预约入口:`reserve_click_item(iid)` / `reserve_click_ground(pos)`(`_reserved_delay=0.1`)/
|
||
`reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`,
|
||
`range > 100` 先减 `RESERVE_SKILL_RANGE_TRIM_CM(10)`)/ `pick_ground_item(iid)`(= `__OnPressItem`——
|
||
清预约 + auto-attack 归零,`CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。
|
||
- `_on_pick()` 里 `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode==USE_SKILL &&
|
||
_dash_active` 时只改 `_vid_reserved`;战斗目标改走 `_change_target_to_picked_instance(vid)`
|
||
(= `__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定
|
||
PC/怪,否则 `set_target(0)` + `clear_target`;NPC/石头/warp 仍由 `_on_pick` 先分流到 `click_npc`)。
|
||
- `_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用 `proto.mob(race)` 的
|
||
`size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似,查不到 → false(文档化 seam)。
|
||
- 扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块):
|
||
`send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING` 已由
|
||
`M2Client::use_skill` 发过,这里只补附加目标——`_instances_in_fan_range()`(=
|
||
`NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角
|
||
`(MAX-MIN) - RPM*min(dist,1000) + MIN`,`FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/
|
||
`_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid,
|
||
sx, sy)` 补齐;名额不足时 `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange`
|
||
:124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)
|
||
→ `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。
|
||
- `ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析
|
||
(`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)`
|
||
(= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint` 代入
|
||
后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`,
|
||
弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0)。
|
||
- `ui/quickbar.gd`:`activate()` 里 `client.use_skill` 返回后,若 `target_vid != 0` 且技能为
|
||
`FAN_RANGE` / `CIRCLE_RANGE` → 调 `net_play.send_fly_targeting(...)`;新增 `_skill_level(sid)`、
|
||
`activate_reserved(global_slot)`(`use_skill_hook` 回调)。`game_scene.gd` 接
|
||
`net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。
|
||
- 回归:`netplay_test` 新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、
|
||
物品消失→清;CLICK_POSITION 延时门 + `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、
|
||
射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 /
|
||
怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、
|
||
`max_count<=1` / `SINGLE` → 0);`skill_test` 补 skill 47(gwangyeok,FAN + Range 2500 +
|
||
`target_count(lv1)=2` / `(lv20)=5`)/ skill 91(bipabu,FAN + Range 1800 + 无公式→0)/
|
||
skill 1(`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿。
|
||
|
||
---
|
||
|
||
### 3.5 攻击动作、命中时机和网络包没有严格绑定
|
||
|
||
**参考**:三条链完全由动作数据驱动。
|
||
|
||
**(a) 连击索引**(`REF/GameLib/ActorInstanceBattle.cpp:157 / 213 / 253`):
|
||
```
|
||
InputComboAttackCommand(rot):
|
||
m_fAtkDirRot = rot;
|
||
if (m_isPreInput) return false;
|
||
if (0 == m_dwcurComboIndex) { __RunNextCombo(); return true; }
|
||
if (motion->IsComboInputTimeData()):
|
||
t = GetAttackingElapsedTime()
|
||
if (t > GetComboInputEndTime()) { if (IsBowMode()) m_isNextPreInput = TRUE; return false; }
|
||
if (t > GetNextComboTime()) { __RunNextCombo(); return true; }
|
||
if (t > GetComboInputStartTime()) { m_isPreInput = TRUE; return false; }
|
||
else:
|
||
if (GetAttackingElapsedTime() > GetMotionDuration()*0.9f) { __RunNextCombo(); return true; }
|
||
```
|
||
`__RunNextCombo()`(`:253`):`++m_dwcurComboIndex`,用
|
||
`m_pkCurRaceData->GetComboDataPointer(m_wcurMotionMode, __GetCurrentComboType(), &pComboData)`
|
||
取 `ComboIndexVector[m_dwcurComboIndex-1]` 作为动作号,`ComboAttack(idx, m_fAtkDirRot, 0.1f)`。
|
||
连击打满时 `__OnEndCombo()`:骑马下 `m_dwcurComboIndex = 1`,否则**不复位**(靠动作回 Wait 复位)。
|
||
`__ClearCombo()`(`:305`)才把索引清零。
|
||
|
||
**(b) 命中帧**(`ActorInstanceBattle.cpp:355 / 386` + `RaceMotionData`):
|
||
`isValidAttacking()` 遍历 `TMotionAttackData::HitDataContainer` 的
|
||
`THitData{fAttackStartTime, fAttackEndTime}`,落在窗口内才算命中窗口;
|
||
`CanCheckAttacking()` = `isAttacking()`(普攻 / 连击 / splash 三者之一)。
|
||
|
||
**(c) 发包**(`REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit`):
|
||
```
|
||
OnHit(uSkill, victimActor, isSendPacket):
|
||
CPythonPlayer::SetTarget(victimVID, FALSE);
|
||
if (isSendPacket) rkStream.SendAttackPacket(uSkill, victimVID); // CG_ATTACK{bType, dwVictimVID}
|
||
if (!victim.IsPushing()) return;
|
||
if (IS_HUGE_RACE(victim.GetRace())) return;
|
||
CPythonCharacterManager::AdjustCollisionWithOtherObjects(&victim);
|
||
victim.TEMP_Push(lastX, lastY); 记录到 m_kVctkVictim
|
||
```
|
||
`FlushVictimList()`(`:194`)把本帧所有被击退者打成一个 `TPacketCGSyncPosition`
|
||
(上限 `SYNC_POSITION_COUNT_LIMIT = 16`)。
|
||
`CInstanceBase::AttackProcess()`(`InstanceBaseBattle.cpp:413`)在 `CanCheckAttacking()` 为真时
|
||
遍历全部实例做 `CheckAttacking()`,后者先查 `IsInSafe()` 双方,再
|
||
`AttackingProcess(victimActor)` 做真正的几何判定。
|
||
|
||
**当前**:`net_play.gd` 的 combo 只在 0/1/2 循环,命中/发包用定时器。
|
||
|
||
**修改**
|
||
|
||
1. ~~`metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与
|
||
combo 输入三时间~~ —— **完成(增量 82)**:`formats/msa.{h,cpp}` 把 `Group AttackingData`
|
||
按 `GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法解析成
|
||
`std::vector<Msa::HitWindow>`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`,
|
||
无 `HitDataCount`)= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` +
|
||
`Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每个窗带
|
||
`start_time` / `end_time` / `bone` / `weapon_length` + `List HitPosition` 采样
|
||
(`{time, last_pos, pos}` × n,7 个浮点一组)。`textscript.cpp` 同时补上
|
||
`List Name { ... }` 块解析(原来 `List` 会把花括号吞进普通行、还会串掉其后的
|
||
`Group`)。`metin2_anim.get_motion_data()` 新增 `hit_windows` 数组 + `motion_type` /
|
||
`hit_limit_count`;`attack_start_time` / `attack_end_time` 保留为第一个窗的起止(兼容旧调用)。
|
||
`msa_test` 加内联单窗 / 新式双窗合成断言 + `M2_ASSETS` 时对真实
|
||
`warrior/general/attack`(17 采样)、`twohand_sword/combo_05`(150 武器长 / 37 采样)、
|
||
`assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、`warrior/skill/palbang`
|
||
(`AttackingData` 后还有 `MotionEventData`,List 修复后 3 个事件都解析到)断言。
|
||
2. ~~`RaceData` 的 combo 表需要从 `.msm` 解析出来~~ —— **更正(增量 80)**:PC 连击段表
|
||
**不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)。真正来源是
|
||
`assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` /
|
||
`RegisterComboAttackNew` 调用(绑定 → `CRaceData::ReserveComboAttack` /
|
||
`RegisterComboAttack`,`REF/GameLib/RaceData.cpp:405`)。已由 **`formats/combo_table.{h,cpp}`**
|
||
(`fmt::PlayerComboTables`)解析:按 `def __LoadGame<Class>Ex` 分 4 个职业块,
|
||
每块一张 `map<MAKE_COMBO_KEY(motionMode,comboType), vector<段号>>`,语义对齐
|
||
`CRaceData`(Reserve 用 `map::insert` 不覆盖、Register key 未登记或下标越界即跳过)。
|
||
`formats_combo_table_test` 覆盖合成片段 + 真实 `playersettingmodule.py`。
|
||
3. ~~`player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`),
|
||
删除 0/1/2 循环。~~ —— **完成(增量 81)**:`net_play.gd` 的 `_do_attack_swing` /
|
||
`_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐
|
||
`CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` /
|
||
`__OnEndCombo` / `__ClearCombo`(`REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`):
|
||
`_combo_index==0` → 直接推进;否则按 `.msa` `ComboInputData` 三时间
|
||
(`pre_input_time`=InputStartTime、`direct_input_time`=NextComboTime、`input_limit_time`=InputEndTime)
|
||
判 `_is_pre_input` 挂起 / 直接推进 / 弓模式置 `_is_next_pre_input`;无 combo 输入组时用
|
||
`duration*0.9` 兜底;`_combo_process()` 每帧触发挂起输入、动作回 Wait 时 `_clear_combo()`;
|
||
段表越界(`__RunNextCombo` 的 OOB 分支)不推进;末段 `__OnEndCombo` 仅骑乘复位到 1。
|
||
段号来自增量 80 的 `Metin2AnimPlayer.parse_combo_tables()`(新静态绑定,读
|
||
`root/playersettingmodule.py`),按 `race & 3` 选职业块、按 `combo_motion_mode<<16|type` 取段序列
|
||
(`combo_motion_mode` 默认 GENERAL,武器→mode 映射留给增量 82+)。缺段表(headless / 缺资源)
|
||
退化为单段普攻。`game_scene.gd` 装配时 `net_play.set_asset_root(_assets)`。
|
||
`combat_fx_test.gd` 加 `FakeAnim`(可控 `.msa`)+ 连击状态机全路径断言。
|
||
4. ~~命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`;
|
||
`FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条)~~ —— **完成(增量 83)**:
|
||
`net_play.gd` 新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` /
|
||
`motion_type` / `hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、
|
||
`_attack_process(dt)`(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` +
|
||
`ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):每帧动作时间窗
|
||
`[t-dt, t]` 与某个 `hit_window [start,end]` 相交时,遍历 `client.get_entities()` 里可攻击、
|
||
未死、双方不在安全区的实体,`net_world.node_for(vid)` 取世界位做几何判定;`_hit_deduped()`
|
||
查重(`m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标只一次,其余按 `invisible_time` 冷却)、
|
||
`_register_hit()` 校验每窗命中上限(NORMAL/COMBO 16、SKILL `hit_limit_count`);命中 →
|
||
`client.attack(_swing_skill, vid)`(= `OnHit` → `SendAttackPacket`,`_swing_skill` 普攻 0),
|
||
被击退目标(`knock_down` / `stunned`)记入 `_victim_flush`。`_emit_swing()` 因此改为
|
||
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
|
||
`_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
|
||
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。
|
||
几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
|
||
`±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
|
||
sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` +
|
||
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
|
||
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`,
|
||
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
|
||
6. ~~`combo_motion_mode` 恒 `MOTION_MODE_GENERAL`,未按装备武器种类切 motion set~~ —— **完成
|
||
(增量 84)**:`net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+
|
||
`WEAPON_SUB_*`(`CItemData::EWeaponSubTypes`)+ `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射
|
||
`static motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻
|
||
`CInstanceBase::RefreshState`(`REF/UserInterface/InstanceBase.cpp:2851`)的 `SetMotionMode`:
|
||
Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、钓竿→骑乘 HORSE / 否则
|
||
FISHING、骑乘按 subtype→`HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `HORSE_{FAN,BELL,BOW}` /
|
||
`HORSE`、否则按 subtype→`{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `BOW` / `FAN` / `BELL`、
|
||
`ARROW` 及其余→`GENERAL`。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`(0 回退
|
||
`get_equipment()[WEAR_WEAPON]`),`_refresh_motion_mode()` 读 `proto.item(vnum)` 的
|
||
`type`/`sub_type` + 实体 `mount_vnum`/`polymorph` → `set_combo_motion_mode()`(换模式
|
||
`_clear_combo()`)。接线在 `_on_entity_info` 末尾、`_on_inv_changed_mode`(`inventory_changed`
|
||
window==EQUIPMENT)、`mount_changed` 信号。`combat_fx_test.gd` 加 `FakeProto` + 15 段映射断言
|
||
+ 2 段端到端。
|
||
|
||
7. ~~弓箭模式 `_clickable_distance()` 缺 `__GetBowRange()` 分支~~ —— **完成(增量 85)**:
|
||
`net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance`
|
||
(`InstanceBaseBattle.cpp:294`):默认 150 → `_is_bow_mode()`(`MODE_BOW` / `MODE_HORSE_BOW`,
|
||
对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)改用 `_bow_range_cm()` → NPC 500 →
|
||
采集物 100(后写 `if` 覆盖前面)。`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) +
|
||
`_bow_distance_bonus`,1:1 复刻 `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`);
|
||
`_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]` 填充,`wire.h` 补
|
||
`POINT_BOW_DISTANCE=34`、`M2Client::get_points()` 增 `d["bow_distance"]`。`combat_fx_test.gd`
|
||
§3.5 段加 8 段断言。
|
||
|
||
**状态:🟡 部分完成(2026-09-03 / W2 / 增量 85 —— §3.5 修改 1-7 全落地)**
|
||
—— 修改 1 完成(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` 多命中窗 + `textscript.cpp`
|
||
`List` 块解析 + `get_motion_data().hit_windows` + `msa_test` 合成 / 真实资源断言);
|
||
修改 2 完成(增量 80:`formats/combo_table.{h,cpp}` + `formats_combo_table_test`);
|
||
修改 3 完成(增量 81:`net_play.gd` 连击状态机对齐参考端五函数 + `Metin2AnimPlayer.parse_combo_tables()`
|
||
静态绑定 + `game_scene` 装配 + `combat_fx_test` 全路径断言);修改 4 完成(增量 83:
|
||
`net_play.gd` `_begin_hit_windows` / `_attack_process` / `_hit_deduped` / `_register_hit` /
|
||
`_hit_geometry` / `_flush_victim_list` —— 命中窗几何判定 → `OnHit`→`CG_ATTACK`、帧末
|
||
`FlushVictimList`→`CG_SYNC_POSITION`≤16,几何判定用 reach+正面弧近似留桩);修改 5 已在增量 78 落地;
|
||
修改 6 完成(增量 84:`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
|
||
切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85:`_clickable_distance()` 弓箭
|
||
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
|
||
全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
|
||
服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
|
||
|
||
---
|
||
|
||
### 3.6 ~~弓箭发射使用固定 1.5 秒定时器~~ —— 已修复(增量 86 / 增量 92)
|
||
|
||
**状态:✅ 完成(2026-09-03 / W2 / 增量 86;飞行物实体 + `__CanShot` 增量 92)**
|
||
|
||
`game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底已换成
|
||
`_pending_shots: Array[int]`(FIFO,上限 8,满则丢最旧):每次弓挥击 / 弓技能起手
|
||
`_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event`
|
||
的 `type 6`)`pop_front()` 弹一个发 `client.shoot()`(pop 即自清 = `_AutoClear`,队列天然
|
||
支持连射多箭在途),对齐 `CActorInstance::ProcessMotionEventFly`
|
||
(`ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`:
|
||
`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`:
|
||
`_is_bow_mode()` 时先 `_send_fly_target()`(取目标节点 → `MapCoord.to_server_cm()` →
|
||
`client.add_fly_targeting(vid, x, y)`),再 `bow_shot_fired.emit(_swing_skill)` 让
|
||
`game_scene` 入队。
|
||
|
||
**增量 92 补充**(`project/fly_object.gd` 新增 + `net_world.gd` / `net_play.gd`):
|
||
飞行物实体本身已落地——`fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐
|
||
`REF/GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget` / `Update` 的
|
||
`while` 迭代 + `erase` / `CreateIndexedFly` 的 NORMAL / FIRE_CRACKER / AUTO_FIRE 分支)+
|
||
`FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`;`Update`
|
||
的 `vel += accel*dt` → `remain_range -= |movement|` → 线段-点 `< bomb_range²` 命中 →
|
||
`OnShootDamage` / pierce / `__Explode` / `__Bomb`;`hit_on_background` 的
|
||
`GetTerrainHeight` 判定)+ `FlyingData.cpp` `__Initialize()` 默认值。线段判定
|
||
`_seg_point_sq_dist` 1:1 移植 `REF/EterLib/GrpMath.h:80`。`net_world._on_fly` 把原
|
||
tween「发光球」换成 `FlyManager.spawn(...)` 真实弹道 + `signal fly_exploded`。
|
||
`net_play._can_shot()` 防御式重建 `__CanShot`(非弓 / 无箭 `EMPTY_ARROW` / 无目标
|
||
`NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走
|
||
`OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`。
|
||
seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70),
|
||
实际驱动走 POC 自有 `fly_cue`;`__CanShot` 在发布源码树中无定义(防御式重建);
|
||
`.fly` 资源加载器缺失 → 合成箭参数;怪物球体碰撞 / `CullingMgr` / 建筑体素判定未移植
|
||
(客户端仅做命中时机,伤害服务端裁决);重力参考端作用于 `.z`=上,本 POC 映射到 `-Y`。
|
||
仍待:真服 `CG_FLY_TARGETING` 的 z 分量、`.fly` 数据文件解析。
|
||
|
||
**参考**:由 `.msa` 的 `MOTION_EVENT_TYPE_FLY` 事件驱动,
|
||
经 `IFlyEventHandler` 两个回调(`REF/UserInterface/PythonPlayerEventHandler.cpp:235`):
|
||
```
|
||
CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget()
|
||
→ SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING
|
||
CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex)
|
||
→ SendShootPacket(dwSkillIndex) // CG_SHOOT
|
||
```
|
||
handler 由 `__OnPressActor` 通过
|
||
`rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&main, &victim)` 取得并传给
|
||
`NEW_AttackToDestInstanceDirection`(`PythonPlayerInput.cpp:358`)。
|
||
`MOTION_EVENT_TYPE_FLY` 是 `CRaceMotionData::EMotionEventType` 的第 6 项
|
||
(`REF/GameLib/RaceMotionData.h:168`)。
|
||
|
||
**当前(增量 86 前)**:`game_scene.gd` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。
|
||
|
||
**修改**
|
||
|
||
1. ~~`.msa` 的 motion event 分派新增 `FLY` 分支~~ —— **完成(增量 86)**:`type 6` 分支
|
||
`pop_front()` `_pending_shots` → `client.shoot(skill)`(`OnShoot`);`OnSetFlyTarget` 提前到
|
||
`net_play._emit_swing` 里 `_send_fly_target()` → `client.add_fly_targeting(vid, x, y)`。
|
||
2. ~~删除定时器与单槽 `pending skill`;改为每次攻击一个 handler 实例(`_AutoClear`),支持并发~~
|
||
—— **完成(增量 86)**:`_pending_shots: Array[int]` FIFO(上限 8)替掉 `_pending_shoot_skill`
|
||
+ `create_timer(1.5)`;`pop_front()` 即自清,连射多箭各自一发。
|
||
3. ~~`SendFlyTargetingPacket` 坐标取 `OnGetFlyTargetPosition()`~~ —— **完成(增量 86)**:
|
||
`OnGetFlyTargetPosition`(`REF/GameLib/ActorInstanceFly.cpp:4`)= 目标 `m_v3Center`
|
||
(包围球中心)经 `GetTransform()` 变换到世界坐标;其平面分量与角色脚点一致,而
|
||
`CG_FLY_TARGETING` 只带平面坐标,故 `_send_fly_target()` 直接取目标节点 `global_position`
|
||
经 `MapCoord.to_server_cm()` 换 server-XY。
|
||
|
||
---
|
||
|
||
### 3.7 受击状态分裂
|
||
|
||
**参考**:**没有**「客户端硬直」这种本地状态。可用于门控的只有三个,且全部来自
|
||
`CActorInstance`:
|
||
- `IsDead()` / `IsKnockDown()` / `IsStun()`;
|
||
- `__CanProcessNetworkStatePacket()`(`REF/UserInterface/InstanceBase.cpp:1247`)只查
|
||
`IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`;
|
||
- `NEW_Attack()`(`InstanceBaseBattle.cpp:355`)与 `NEW_UseSkill()`(`:310`)的门也只有
|
||
`IsDead / IsStun / IsKnockDown`(`NEW_Attack` 多一个 `IsUsingSkill`)。
|
||
`Stun` 来自服务端 affect(`InstanceBaseEffect.cpp:932 case AFFECT_STUN`),
|
||
`KnockDown` 来自被击退动作,**都不是本地计时器**。
|
||
|
||
**当前**:`EntityStore.stunned` 与 `NetPlay._hitstun_until` 两套并存。
|
||
|
||
**修改**
|
||
|
||
1. **删除 `net_play.gd` 的 `_hitstun_until` 及其全部引用**——参考端不存在这个概念。
|
||
2. `Entity` 增加 `knock_down: bool`,由受击动作(`.msa` 的 knockdown 动作播放中)置位,
|
||
动作结束清零;`dead` / `stunned` 保持由服务端包驱动。
|
||
3. 所有「能否动作」的判定统一走三个函数:
|
||
`_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`,
|
||
实现分别对齐 `InstanceBase.cpp:1247`、`InstanceBaseBattle.cpp:355`、`:310`。
|
||
|
||
**状态:✅ 完成(2026-09-02 / W2 / 增量 73)**
|
||
|
||
- `net_play.gd`:删除 `const HITSTUN` 与 `var _hitstun_until` 及其全部引用;
|
||
新增 `var _knock_down`(击退动作播放中,对齐 `CActorInstance::IsPushing` /
|
||
`InterceptOnceMotion(NAME_DAMAGE_FLYING)`)和 `var _using_skill`(§3.8 占位)。
|
||
- 三个闸门函数落地:`_can_process_network_state()` = `not(dead or stunned or
|
||
knock_down)`(≈ `__IsSyncing`);`_can_attack()` / `_can_use_skill()` =
|
||
前者且 `not _using_skill`。`is_stunned()` 保留为兼容 shim = `not
|
||
_can_process_network_state()`。`_process` 的 `pc.frozen` 与攻击闸门改走这些函数。
|
||
- `_on_damage`:非闪避命中只 `set_anim_state("damage")` + 抖屏 + 置
|
||
`_knock_down`,不再写任何计时器;击退动作播完由 `_process` 轮询
|
||
`player_view.is_in_hit_reaction()` 清零。
|
||
- `player_view.gd`:`_on_playback_finished` 增加 `_state == "damage"` 分支
|
||
(一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`。
|
||
- C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露 `"knock_down"`,
|
||
`mut_knockdown(vid,on)` 落位并标脏);本地玩家由 `net_play._knock_down`
|
||
驱动,**远端实体的 `knock_down` 填充仍待接线**(后续增量,仅影响远端受击
|
||
表现,不涉及输入闸门)。
|
||
- 回归:`combat_fx_test.gd` 断言改写(命中→`_knock_down`、服务端 `stunned`
|
||
标志独立驱动、闪避/他人受击不置位);C++ 19/19、`combat_fx_test` /
|
||
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
|
||
全绿。
|
||
|
||
---
|
||
|
||
### 3.8 技能缺少统一合法性检查
|
||
|
||
**状态:✅ 完成(2026-09-03 / W2 / 增量 87+88+89 —— 修改 1/2/3 全落地)**
|
||
—— 修改 2 完成(增量 87:`project/ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc
|
||
第 11/12 列折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`,一众 `IsXxx()` 谓词
|
||
逐条对齐 `CPythonSkill`;`skill_test.gd` +34 断言)。修改 1 完成(增量 88:新建
|
||
`project/player_skill.gd` —— `RefCounted` 纯校验器,逐段移植 `ClickSkillSlot`(a) /
|
||
`__CanUseSkill`(b) / `__CheckSkillUsable`(c) / `__UseSkill`(d),命名常量
|
||
`SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / 骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996` /
|
||
钓鱼 `123` / 连击 `122`,TSkillData 判定全走增量 87 谓词;`net_play.skill_context()` 供运行期
|
||
上下文,`quickbar.activate()` skill 分支先过 `click_skill_slot()` 再发 `use_skill`/`cast_skill`,
|
||
挡下发 `skill_rejected(id, code)` → `game_scene` 转 `cannot_act` 走同一条 `OnCannotUseSkill`
|
||
文案通道;新 `player_skill_test.gd` 逐分支断言)。POC 无 C++ skill proto,技能数据只在
|
||
GDScript,故“在 `POC/extension/src/proto` 侧补解析”改为在 `skill_table.gd` 内落地(架构一致)。
|
||
修改 3 完成(增量 89):① `net_bounds.h` 加 `SKILL_MAX_NUM=255` + `skill_index(v)=0<v<255`
|
||
谓词,`M2Client::use_skill()` 的 `skill_id` 形参从 `>0 && bounds::u32` 收紧成
|
||
`bounds::skill_index()`(带符号形参 + 发包前范围校验,对齐 `ClickSkillSlot` 的
|
||
`slot >= SKILL_MAX_NUM` 门),`cast_skill()` 保留 `motion_idx ∈ [0,0x7f]`(7 位 wire 位段),
|
||
`net_bounds_test.cpp` +4 断言。② `skill_table.gd` `_load_cooltime` 更名 `_load_msk`,加解析
|
||
`.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]`(`LocaleService_GetSkillPower` INTERNATIONAL
|
||
表,`Locale.cpp:126`)+ `_skill_point(level)` + `need_sp(id,level)`(≈ `GetNeedSP`,
|
||
`PythonSkill.cpp:1426`;POC 约定:无公式返回 `-1`,参考返回 0,避免误挡)+ `need_hp()`
|
||
(照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖);`cooldown_of` 的 `SkillPoint`
|
||
也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`)。③ `player_skill.gd`
|
||
落地 `_check_short_mana`(`__CheckShortMana:320`:summon `131` 豁免、非开关技 `iNeedSP==0`
|
||
→ 坏数据拦、`CanUseIfNotEnough` → `icurSP<=0` 拦、否则 `iNeedSP > icurSP` 拦)和
|
||
`_check_short_life`(`__CheckShortLife:354`:`!IsUseHPSkill()` 放行、`dwNeedHP > curHP` 拦),
|
||
新增上下文字段 `cur_sp` / `cur_hp`(`net_play.skill_context()` 读 `M2Client.get_points()`,
|
||
`-1` = 未知不拦);`player_skill_test.gd` +“(c) §3.8 修改 3” HP/SP 段。
|
||
|
||
**参考**:三层,缺一不可。
|
||
|
||
**(a) `CPythonPlayer::ClickSkillSlot(dwSlotIndex)`**(`REF/UserInterface/PythonPlayerSkill.cpp:108`)
|
||
```
|
||
slot >= SKILL_MAX_NUM → return
|
||
!GetSkillData(inst.dwIndex, &data) → return
|
||
SKILL_TYPE_GUILD == data->byType → UseGuildSkill(slot); return
|
||
!data->IsCanUseSkill() → return // 被动技能挡在这
|
||
data->IsStandingSkill():
|
||
data->IsToggleSkill() && IsSkillActive(slot):
|
||
main->IsUsingSkill() → return
|
||
SendUseSkillPacket(inst.dwIndex); return // 关闭 toggle
|
||
__UseSkill(slot); return
|
||
m_dwcurSkillSlotIndex == slot → __UseSkill(slot); return
|
||
!__IsRightButtonSkillMode() → __UseSkill(slot)
|
||
else → m_dwcurSkillSlotIndex = slot; ChangeCurrentSkill(slot)
|
||
```
|
||
|
||
**(b) `CPythonPlayer::__CanUseSkill()`**(`PythonPlayerSkill.cpp:439`)
|
||
```
|
||
!NEW_GetMainActorPtr() → false
|
||
IsObserverMode() → false
|
||
IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20 → false // 骑乘术硬编码
|
||
return main->CanUseSkill();
|
||
```
|
||
|
||
**(c) `CPythonPlayer::__CheckSkillUsable(slot)`**(`PythonPlayerSkill.cpp:166`)
|
||
```
|
||
IsMountingHorse() && !data->IsHorseSkill() → OnCannotUseSkill("NOT_HORSE_SKILL"); false
|
||
data->IsHorseSkill() && !IsMountingHorse() → OnCannotUseSkill("HAVE_TO_RIDE"); false
|
||
data->IsAttackSkill() && main->IsInSafe() → OnCannotUseSkill("IN_SAFE"); false
|
||
!data->IsCanUseSkill() → false
|
||
data->IsNeedEmptyBottle() && !__HasItem(27995) → OnCannotUseSkill("NEED_EMPTY_BOTTLE"); false
|
||
data->IsNeedPoisonBottle() && !__HasItem(27996) → OnCannotUseSkill("NEED_POISON_BOTTLE"); false
|
||
main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING_ROD"); false
|
||
```
|
||
|
||
**(d) `__UseSkill(slot)`**(`PythonPlayerSkill.cpp:460`)额外:
|
||
`IsOpenPrivateShop()` → true(静默);`__CheckSpecialSkill(idx)` → true;
|
||
`data->IsToggleSkill() && IsSkillActive(slot)` → 发 `SendUseSkillPacket(idx, slot)` 并返回 false;
|
||
`main->IsUsingSkill()` → false;
|
||
需要目标时(`IsNeedTarget() || CanChangeDirection() || IsAutoSearchTarget()`)
|
||
按 `IsNeedCorpse()` 取死/活目标,取不到就 `__ChangeTargetToPickedInstance()` 再取一次。
|
||
|
||
**当前**:只查 `skill id > 0`;`target_vid` 等参数被无符号化。
|
||
|
||
**修改**
|
||
|
||
1. ~~在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植;
|
||
`SKILL_MAX_NUM`、`SKILL_TYPE_GUILD`、骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996`
|
||
均按参考写成命名常量。~~
|
||
—— **完成(增量 88)**:`project/player_skill.gd`(`extends RefCounted`)。
|
||
`click_skill_slot(id, is_active)` / `can_use_skill()` / `check_skill_usable(id)` /
|
||
`use_skill(id, is_active)` 逐段对齐 `PythonPlayerSkill.cpp:106/439/166/460`,返回
|
||
`{ ok, code, target_vid }`;`code` 用 `OnCannotUseSkill` 原字符串
|
||
(`NOT_HORSE_SKILL` / `HAVE_TO_RIDE` / `IN_SAFE` / `NEED_EMPTY_BOTTLE` /
|
||
`NEED_POISON_BOTTLE` / `REMOVE_FISHING_ROD` / `NOT_YET_LEARN` /
|
||
`NOT_MATCHABLE_WEAPON` / `EMPTY_ARROW` / `WAIT_COOLTIME` / `NEED_TARGET` /
|
||
`CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` /
|
||
`CANNOT_ATTACK` / `ONLY_FOR_CORPSE`),加静默码集(`PASSIVE` / `GUILD_ROUTED` /
|
||
`TOGGLE_OFF` / `SPECIAL_SKILL` / `PRIVATE_SHOP` / `BAD_SLOT` …)对齐参考端“静默 return”。
|
||
命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ `SKILL_TYPE_GUILD=3` /
|
||
`RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` /
|
||
`SKILL_INDEX_FISHING=123` / `SKILL_INDEX_COMBO=122`(`PythonPlayerModule.cpp:6-7`)。
|
||
`__CheckRestSkillCoolTime` = 本地槽 `cd_end`(`m_sysIsCoolTime` 关则不查);
|
||
`__CheckDashAffect` 分支(冲刺 affect 下非蓄力技才查冷却)1:1 保留;
|
||
`__HasItem` 扫 `client.get_inventory()`;`__HasEnoughArrow` 用 `net_play._arrow_count` 占位;
|
||
`__CheckShortMana` / `__CheckShortLife` 于增量 89 落地(见修改 3)。
|
||
目标解析(`_resolve_target`)按 `__UseSkill:520-650` 判联盟 / 安全区 / 可攻击 / 尸体 /
|
||
需要目标并回传 `target_vid`;距离 / `__ReserveUseSkill` / 扇形·圆形多目标
|
||
`SendAddFlyTargetingPacket` 归 §3.4/§3.9,此处不判。接线见状态段。
|
||
2. ~~`CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill /
|
||
IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget /
|
||
CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`)
|
||
来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析~~
|
||
—— **完成(增量 87)**:POC 无 C++ skill proto,技能数据只在 GDScript
|
||
`project/ui/skill_table.gd`。按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc
|
||
第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits`
|
||
位掩码(记号名 1:1 抄 `PythonSkill.h:92-128`),`load_file` 时连同 `skill_type`
|
||
(JOB 列 → `SKILL_TYPE_*`)预存。上列全部谓词 + `can_use_weapon_type` / `is_need_bow` /
|
||
`is_only_for_guild_war` / `can_use_for_me` 等按 `CPythonSkill` 逐条实现;`skill_test.gd`
|
||
+34 断言。(参考:`PythonSkill.h` 位枚举、`PythonSkill.cpp:280/909/1143/1407`、
|
||
`GameLib/ItemData.h:63-75`。)
|
||
3. ~~`M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。~~
|
||
—— **完成(增量 89)**:`M2Client::use_skill(int skill_id, int target_vid)` 形参本就
|
||
带符号(增量 52),本次把 `skill_id` 的护栏从 `skill_id <= 0 || !bounds::u32()` 收紧成
|
||
`!mtnet::bounds::skill_index(skill_id)`(`net_bounds.h` 新增 `SKILL_MAX_NUM=255` 常量
|
||
+ `skill_index(v) = 0 < v < 255` 谓词,对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM`
|
||
门 / `aSkill[]` 索引上限),`target_vid` 保留 `>= 0 && bounds::u32`;`cast_skill()` 的
|
||
`motion_idx ∈ [0,0x7f]` 保持不动(7 位 wire 位段)。`net_bounds_test.cpp` +4 断言。
|
||
顺带把 `__CheckShortMana` / `__CheckShortLife` 从桩改为实装:`skill_table.gd` 解析
|
||
`.msk` `NeedSPFormula`(`need_sp` / `need_hp` + `LocaleService_GetSkillPower` 功率表
|
||
`_skill_point`),`player_skill.gd` 按 `PythonPlayerSkill.cpp:320/354` 逐条移植;HP/SP
|
||
由 `net_play.skill_context()` 读 `get_points()` 供入,未知(`-1`)不判。
|
||
|
||
---
|
||
|
||
### 3.9 公共网络 API 缺少参数边界检查
|
||
|
||
**参考**:参考端在**发送前**就有断言/裁剪,而不是靠服务端拒绝:
|
||
- `SendCharacterStatePacket`:`assert(lX >= 0 && lX < 204800)`(`PhaseGame.cpp:1132`);
|
||
角度先归一到 `[0,360)`;`bRot = rot/5.0f`。
|
||
- `OnAttack`:`assert(wMotionIndex < 255)`(`PythonPlayerEventHandler.cpp:104`)。
|
||
- `FlushVictimList`:`SYNC_POSITION_COUNT_LIMIT = 16`(`:196`)。
|
||
- `AddQuickSlot / DeleteQuickSlot / MoveQuickSlot`:`< 0 || >= QUICKSLOT_MAX_NUM(36)` 直接返回
|
||
(`PythonPlayer.cpp:843/852/861`)。
|
||
- `RequestUseLocalQuickSlot`:`>= QUICKSLOT_MAX_COUNT_PER_LINE(8)` 返回(`:815`)。
|
||
- `RequestDeleteGlobalQuickSlot`:`>= QUICKSLOT_MAX_COUNT(32)` 返回(`:800`)。
|
||
- `GetAccountCharacterSlotData*`:`>= PLAYER_PER_ACCOUNT4(4)` 返回 0(`PythonNetworkStream.cpp:378`)。
|
||
- `__RecvPlayerCreateSuccessPacket`:槽位越界 `TraceError` 并**吞掉**(`PhaseSelect.cpp:239`)。
|
||
|
||
**修改**
|
||
|
||
1. ✅ 新建 `POC/extension/src/net/net_bounds.h`,只收纳已从参考 wire / `GameType.h` 核实的公共
|
||
协议边界:`u8/u16/u32/i32`、`QUICKSLOT_MAX_NUM=36`、`SHOP_HOST_ITEM_MAX_NUM=40`、
|
||
`PRIVATE_SHOP_ITEM_MAX_NUM=39`、`EXCHANGE_ITEM_MAX_NUM=12`、`CUBE_ITEM_SLOT_COUNT=24`、
|
||
`POINT_MAX_NUM=255`、`DS_REFINE_WINDOW_MAX_NUM=15`。未读到的 EAffect / 地图规则没有凭经验塞入
|
||
这个头文件。
|
||
2. ✅ `M2Client` 的公共入口在所有窄类型转换前检查边界:坐标和 VID、动态文本包长度、物品位置、
|
||
快捷栏 / 交易 / 私人商店 / Cube 槽位、数量、金币、公会 / 会徽 ID 与端口;私人商店数组超限和
|
||
Classic 原先的静默 39 项截断改为返回 `false`。私人商店字典要求整数字段,避免 Godot Variant
|
||
隐式转换后再截断。
|
||
3. ✅ 按参考语义保留必要特殊值:`set_target(0)` 清目标、`shop_sell(count=0)` 选择旧版单件包、
|
||
`move_item(count=0)` 保留整堆语义;没有用“把负数改成 1”掩盖调用方错误。
|
||
4. ✅ 回归:新增 `extension/tests/net_bounds_test.cpp` / CTest `net.bounds`,覆盖每个共享边界的
|
||
端点与越界值;`cmake --build build/extension -j2` 通过,`ctest --test-dir build
|
||
--output-on-failure` 为 18/18。相关 Godot 离线回归(`netbridge_test`、`p8_test`、
|
||
`shop_cube_mall_test`、`guild_refine_test`、`dragon_soul_test`、`guild_mark_test`、
|
||
`guild_war_skill_test`、`p9_test` 及完整脚本集)通过;Godot 测试按串行、独立日志运行。
|
||
5. ⬜ 仍待真实 40250 服务端验证每种业务的服务端合法性、角色 / 物品语义和地图坐标限制;本批次只
|
||
保证客户端不会产生越界 wire 参数,不把本地范围检查冒充服务端规则。
|
||
|
||
---
|
||
|
||
### 3.10 技能升级传输已对齐但状态门控不完整
|
||
|
||
**状态:✅ 完成(2026-09-03 / W2 / 增量 90)** —— 加号按钮门控按 `uicharacter.py`
|
||
的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`)
|
||
逐行落地:`_can_use_skill_now()`(技能组门)+ 三个技能点来源分开取(`skill_active` /
|
||
`skill_support` / `skill_horse`,C++ `get_points()` 新增键)+ `skillGrade==0` +
|
||
马术页 `skillLevel < 20` + 主动页 `skill_table.can_level_up_skill(id, level)` +
|
||
辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐藏。`skill_table` 新增 `max_level_of` /
|
||
`skill_level_limit` / `can_level_up_skill`。`M2Client::skill_up()` 入参收紧为
|
||
`mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果仍由 `GC_SKILL_LEVEL`
|
||
回包经 `skills_changed` 驱动,不本地伪造。
|
||
|
||
**证据边界与订正**:本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓与该客户端配套的
|
||
迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此
|
||
断定当前必须改传技能 vnum。`uicharacter.py:1022 OnPressedSlotButton()`(本仓
|
||
`POC/assets/root/uicharacter.py`):
|
||
```python
|
||
srcSlotIndex = self.__RealSkillSlotToSourceSlot(slotNumber)
|
||
skillIndex = player.GetSkillIndex(srcSlotIndex)
|
||
curLevel = player.GetSkillLevel(srcSlotIndex) # 本版本算了但未使用
|
||
maxLevel = skill.GetSkillMaxLevel(skillIndex) # 本版本算了但未使用
|
||
net.SendChatPacket("/skillup " + str(skillIndex))
|
||
```
|
||
所以 `CLIENT-GAP.md` §3.10 的「应改成非命令路径」不成立,主文档已改为记录门控和状态流程差距。
|
||
|
||
**同时**:本文早前版本曾断言「当前实现应把参数从技能槽改为技能 vnum」——
|
||
该结论**未经证明,撤回**。`POC/project/ui/skill_ui.gd:145` 传给
|
||
`client.skill_up(sid)` 的 `sid` 来自 `table.for_category()`,本来就是技能表 ID/索引,
|
||
与参考端 `player.GetSkillIndex(srcSlotIndex)` 的产物同类。
|
||
|
||
**真正的差距是「加号按钮门控」完全缺失。** 迁移资产中的参考脚本把全部条件放在按钮
|
||
**显示**阶段(`uicharacter.py`),点下去就无条件发包;C++ 侧可核实的技能上限检查见
|
||
`PythonSkill.cpp`:
|
||
|
||
```python
|
||
# RefreshSkillPlusButtonList() :984
|
||
if self.isLoaded == 0: return
|
||
self.RefreshSkillPlusPointLabel()
|
||
if not self.__CanUseSkillNow(): return # 死亡/变身/骑乘等状态门(:1270)
|
||
curSelectedSkillGroup == PAGE_HORSE ? __RefreshSkillPlusButton("HORSE")
|
||
: __RefreshSkillPlusButton("ACTIVE")
|
||
__RefreshSkillPlusButton("SUPPORT")
|
||
|
||
# __RefreshSkillPlusButton(name) :937
|
||
if HIDE_SUPPORT_SKILL_POINT and name == "SUPPORT": return
|
||
slotWindow.HideAllSlotButton()
|
||
slotStatType = self.skillPageStatDict[name] # ACTIVE→SKILL_ACTIVE, SUPPORT→SKILL_SUPPORT, HORSE→SKILL_HORSE
|
||
if 0 == slotStatType: return
|
||
statPoint = player.GetStatus(slotStatType)
|
||
if statPoint > 0: # ← 技能点校验
|
||
for 每个槽:
|
||
skillIndex = player.GetSkillIndex(slot); skillGrade = player.GetSkillGrade(slot)
|
||
skillLevel = player.GetSkillLevel(slot)
|
||
if skillIndex == 0: continue
|
||
if skillGrade != 0: continue # ← 已升阶的技能不给加号
|
||
if name == "HORSE":
|
||
if player.GetStatus(player.LEVEL) >= skill.GetSkillLevelLimit(skillIndex): # ← 等级前置
|
||
if skillLevel < 20: # ← 马术上限 20
|
||
slotWindow.ShowSlotButton(__GetETCSkillRealSlotIndex(slot))
|
||
else:
|
||
if name == "SUPPORT" and SHOW_LIMIT_SUPPORT_SKILL_LIST:
|
||
if skillIndex not in SHOW_LIMIT_SUPPORT_SKILL_LIST: continue # ← 辅助技能白名单
|
||
if self.CanShowPlusButton(skillIndex, skillLevel, statPoint):
|
||
slotWindow.ShowSlotButton(slot)
|
||
|
||
# CanShowPlusButton(skillIndex, skillLevel, curStatPoint) :925
|
||
if 0 == skillIndex: return False
|
||
if not skill.CanLevelUpSkill(skillIndex, skillLevel): return False
|
||
return True
|
||
```
|
||
|
||
**当前**:~~`POC/project/ui/skill_ui.gd:141` 只有
|
||
`if table.can_level_up(id) and not table.is_passive(id)`,缺技能点校验、skillGrade、
|
||
等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。~~
|
||
—— **增量 90 全部落地**(见下方逐条)。
|
||
|
||
**修改**
|
||
|
||
1. **完成(增量 90)**:`skill_ui.gd` `refresh()` 里按三段逐行实现加号显隐——
|
||
`_can_use_skill_now()`(= `client.get_skill_group() != 0`,对应 `__CanUseSkillNow` :1270)→
|
||
`_stat_point_for(page)`(技能点来源)→ `_can_show_plus(id, level, grade, page, …)`
|
||
(对应 `__RefreshSkillPlusButton` 每槽体 + `CanShowPlusButton`)。按钮仍在 `_rebuild()`
|
||
为非被动 / 非 `CANNOT_LEVEL_UP` 的技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()`
|
||
+ `ShowSlotButton()`)。
|
||
2. **完成(增量 90)**:三个技能点来源分开取。`wire.h` 加 `POINT_SUB_SKILL=27` /
|
||
`POINT_SKILL=28` / `POINT_HORSE_SKILL=113`;`M2Client::get_points()` 新增
|
||
`skill_active` / `skill_support` / `skill_horse` 三个键(镜像 `bow_distance` 的做法);
|
||
`skill_ui._stat_point_for()` 按页取对应键,不合并成一个 `POINT_STAT`。
|
||
3. **完成(增量 90)**:`skill_table.gd` 加
|
||
`max_level_of(id)`(= `SSkillData::GetMaxLevel()`:`.msk` `maxlevel` > skilldesc `MAX_LEVEL`
|
||
列 > 构造默认 20,`PythonSkill.cpp:1392/1462`)、
|
||
`skill_level_limit(id)`(= `skillGetSkillLevelLimit`,`PythonSkill.cpp:1741` = `byLevelLimit`;
|
||
**本 40250 数据里 skilldesc 无 `LEVEL_LIMIT` 列、`.msk` 无 `levellimit` 记号 → 恒 0**,
|
||
马术页等级前置门因而恒真)、
|
||
`can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`。
|
||
**本 build 里 `isRequirement` / `strRequireSkillName` / `RequireStatDataVector` 从不被填充
|
||
(构造默认,:1471)→ 1:1 化简为 `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`**)。
|
||
`RegisterSkillDesc` 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析已在 `load_file`
|
||
补上,兼容带这两列的第三方数据。
|
||
4. **完成(增量 90)**:`M2Client::skill_up(int skill_id)`(`m2_client.cpp:961`)发包不变
|
||
(`/skillup <skillIndex>`),入参守卫由 `skill_id >= POINT_COUNT` 收紧为
|
||
`!mtnet::bounds::skill_index(skill_id)`(= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。
|
||
5. **保持(增量 90)**:升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包经
|
||
`skills_changed` / `skill_group_changed` 驱动 `refresh()`,失败提示走 `GC_CHAT` 的
|
||
服务端文案——不本地伪造成功。
|
||
|
||
**测试**:`skill_test.gd` 扩 `§3.10` 段——`max_level_of` / `skill_level_limit` /
|
||
`can_level_up_skill`(未满级 / 满级 / 超级 / `CANNOT_LEVEL_UP` / 未知 id)+ 加号显隐
|
||
(主动页可见、`skillGrade!=0` 隐、`skill_group==0` 隐、该页点数 0 隐、辅助页整页隐、
|
||
马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。`net.bounds` C++ 用例覆盖
|
||
`skill_index` 谓词。全 21 C++ + 13 GDScript 回归通过。
|
||
|
||
---
|
||
|
||
## 4. P1:物品、装备、商店和社交
|
||
|
||
### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则
|
||
|
||
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 96 + 97 + 98 + 99 —— 修改 1(proto 位标志导出,此前 §3.5)+ 修改 3(costume/ring/belt 槽位版图与发包坐标,增量 96:`inventory_ui.gd._to_wire()` 按 `GameType.h` 把 wear 0..10 走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell、costume/ring/belt(wear ≥ 19)留在 `WINDOW_INVENTORY(1)` 全局 cell 109..113;扩展面板只补 5 个真实新装备位)+ 修改 2(`CanEquipNow` 前置门,增量 97:新增 `equip_rules.gd` 纯校验器,`antiflag(性别/职业/帝国)→wearable_flag→limit` 顺序 1:1 自 `ItemData.h`,接入 `inventory_ui.gd.use()` + `equip_rejected` 信号 + 中文文案;canonical 测试 #17 `equip_rules_test.gd`)+ 修改 4(变身 / 时装部位遮挡,增量 98:新增 `part_hiding.gd` 纯函数 1:1 自 `InstanceBase.cpp` `IsPoly`/`__IsShapeAnimalWear`(100-103)/`IsWearingDress`(201)/`__IsChangableWeapon`(捧花 50201-50204),接入 `equip_model.gd.refresh()`——刷新序改对齐 `ChangeArmor`(身体→头发→武器→盾)、动物时装隐藏武器+盾+发、婚纱非捧花隐藏武器;canonical 测试 #18 `part_hiding_test.gd`)+ 修改 5(增量 99:远端 PC 通过 `game_scene.gd` 的 `RemotePlayerView` 创建真实 race 模型,远端 `EquipModel` 只消费四字段 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()`;`net_world.gd` 按四字段调用 `set_armor→set_weapon→set_head→set_hair`,`remote_player_test.gd` 覆盖真实 warrior 资源和字段序)已落地。变身字段(`_is_poly` 恒 false)、所有职业 / race 资源、精确体型 / 远端真服换装、精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME` / 属性 4 项按名读取仍留待验收 / 接服务器 `char_item.cpp` 比对)**
|
||
|
||
**参考**:`ClientVS22` 的编译开关(`REF/UserInterface/Locale_inc.h`,本份为
|
||
`LOCALE_SERVICE_SINGAPORE`)打开了 `ENABLE_COSTUME_SYSTEM` / `ENABLE_ENERGY_SYSTEM` /
|
||
`ENABLE_DRAGON_SOUL_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM`。对应的**槽位版图**
|
||
(`REF/UserInterface/GameType.h:40-112`):
|
||
|
||
```
|
||
c_Inventory_Page_Size = 5*9 = 45 c_Inventory_Page_Count = 2
|
||
c_ItemSlot_Count = 90 c_Equipment_Start = 90 c_Equipment_Count = 12
|
||
+0 Body +1 Head +2 Shoes +3 Wrist +4 Weapon +5 Neck
|
||
+6 Ear +7 Unique1 +8 Unique2 +9 Arrow +10 Shield
|
||
+19 Costume_Body +20 Costume_Hair (ENABLE_COSTUME_SYSTEM)
|
||
+21 Ring1 +22 Ring2 +23 Belt (ENABLE_NEW_EQUIPMENT_SYSTEM)
|
||
c_Wear_Max = 32
|
||
c_DragonSoul_Equip_Start = 90 + 32 = 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`。~~ **增量 96 已完成(此前 §3.5)**:
|
||
`proto_node.cpp` 已导出 `d["type"]` / `d["sub_type"]` / `d["anti_flags"]` / `d["wear_flags"]` /
|
||
`d["limits"]`(`[{type, value}] × 2`),装备校验数据层齐备,无需 C++ 重编。
|
||
2. ~~装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 →
|
||
`limit`(等级/属性) → 发 `CG_ITEM_USE`。~~ **增量 97 已完成**:客户端 40250 本身不做
|
||
`CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / `GetLimit`,仲裁在服务器
|
||
`char_item.cpp`),故按 §3.9 `__CanShot` 方式在 GDScript 侧防御性重建——
|
||
新增 `project/equip_rules.gd`(`extends RefCounted`),枚举 1:1 自 `REF/GameLib/ItemData.h`
|
||
(`EItemAntiFlag` classic 排列、`EItemWearableFlag`、`EWearPositions` + `GameType.h` 19..23、
|
||
`ELimitTypes`、`EItemType` + `EArmorSubTypes`/`ECostumeSubTypes`)。`can_equip(item, wear, ctx)
|
||
-> {ok, code, need}`:目标槽解析 → anti-flag 性别(`ANTI_SEX`)→ 职业(`ANTI_JOB`,`race%4`)→
|
||
帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按
|
||
`wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`:
|
||
`ctx.level < v` → `need=v`;`LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判)。
|
||
`inventory_ui.gd.use()` 对「常规背包格 + 可穿戴物品」跑此门,拦下则 `_set_hint()` + 新
|
||
`signal equip_rejected(code, need)` + 不发 `use_item`;新增公开 `can_equip_item()`、`_equip_ctx()`
|
||
(`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` 未由 M2Client
|
||
按名暴露 → 传 `-1` = seam)、`_equip_reject_hint()` 中文文案(对齐 `localeInfo` `CANNOT_EQUIP_*` 语义)。
|
||
canonical 测试 #17 `equip_rules_test.gd`(33 断言)。**seam**:精确 tie-break 顺序、已穿同槽替换、
|
||
`LIMIT_PCBANG` / `LIMIT_REAL_TIME*`、属性 4 项按名读取,留待接服务器 `char_item.cpp` 比对。
|
||
3. ~~costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` /
|
||
`equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。~~ **增量 96 已完成**:
|
||
`inventory_ui.gd._to_wire()` 按 `GameType.h` 分流——`c_Equipment_Start+0..10`(BODY..SHIELD)
|
||
走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell;`ENABLE_NEW_EQUIPMENT_SYSTEM` 的
|
||
`c_Costume_Slot_Start(90+19)` / `c_Equipment_Ring1(90+21)` / `c_Equipment_Belt(90+23)`
|
||
在客户端无独立窗,仍用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。扩展面板
|
||
`_add_extended_cells` 由「11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 + 16 格腰带背包」。
|
||
新增常量 `WEAR_COSTUME_BODY/HAIR`、`WEAR_RING1/RING2`、`WEAR_BELT`、`NEW_EQUIP_WEAR_START`、
|
||
`WINDOW_EQUIPMENT` 全部 1:1 自 `GameType.h`。`inventory_test.gd` 增 7 条 wire 断言
|
||
(`_to_wire(90)→[2,0]`、`(100)→[2,10]`、`(109..113)→[1,109..113]`)。
|
||
4. ~~部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按
|
||
`REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现。~~
|
||
**增量 98 已完成**:穿头盔覆盖发型槽、costume(parts[ARMOR]) 压过本体 armor 在增量 96/97
|
||
已由 `equip_model.gd` 的 `_eff()` + `_apply_head_or_hair()` 落地;本次补齐**变身 / 动物时装 /
|
||
婚纱的整件遮挡**。新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自
|
||
`REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`,`SetWeapon`/
|
||
`SetParts` 提前 return)、`ANIMAL_WEAR_SHAPES=[100,101,102,103]`(`__IsShapeAnimalWear`(:2834)
|
||
→ `SetWeapon`(:2747)/`SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、
|
||
`BOUQUET_VNUMS=[50201..50204]`(`c_iBouquets`,`__IsChangableWeapon`(:901) 除捧花外 false)。
|
||
`equip_model.gd.refresh()`:刷新序改为对齐 `ChangeArmor`(`SetRace→SetArmor→SetHair→SetWeapon`)
|
||
= 身体 → 头发 → 武器 → 盾;解出身体 shape(`armor_shape_of` = `__ArmorVnumToShape` 的
|
||
`values[3]`/`=vnum`)后过 `PartHiding.hair_hidden` / `weapon_hidden` / `effective_weapon`:
|
||
动物·怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`;婚纱非捧花隐藏武器 + 盾、头发保留。
|
||
新增 `_is_poly()` **seam**(40250 实体表暂无变身字段 `poly_race`/`polymorph`,恒 `false`;
|
||
等 `GC_CHARACTER_UPDATE` 带变身 race)。canonical 测试 #18 `part_hiding_test.gd`(45 断言)
|
||
+ `equip_model_test.gd` 补 7 段遮挡断言。
|
||
5. ~~远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把
|
||
`armor/weapon/hair` 三个字段接到换模。~~ **增量 99 首版完成**:参考端
|
||
`Packet.h:1282-1289` 定义 `CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 / HAIR=3 / NUM=4`,
|
||
`game_scene.gd._make_entity_model()` 对 `ch_type==0` 创建 `RemotePlayerView`,复用
|
||
`PlayerView.build()` 的 race / GR2 / MSA 装载;`ui/equip_model.gd.setup_remote()` 进入远端模式,
|
||
只读取 `awPart[4]`,并提供 `set_armor/set_weapon/set_head/set_hair`,不访问本地
|
||
`get_equipment()`。`net_world._apply_field_updates()` 现在按四字段调用
|
||
`set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])`,对齐
|
||
`NetworkActorManager.cpp:473` 的更新序;`remote_player_test.gd` 用真实 warrior 资源覆盖建模、换装和
|
||
字段序。**仍为 seam**:没有对应 `poly_race` 的实体字段,`_is_poly()` 仍恒 false;所有职业 / race
|
||
资源、精确体型 / 变身、远端真实多角色换装和真服视觉尚未验收。
|
||
|
||
---
|
||
|
||
### 4.2 物品拖放不是原版鼠标物品态
|
||
|
||
**参考**:全局单例 `mouseModule.mouseController`(`POC/assets/root/mousemodule.py`,
|
||
`class CMouseController`),是所有窗口之间拖放的**唯一中介**:
|
||
|
||
```
|
||
AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0) mousemodule.py:171
|
||
Type ∈ 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`。
|
||
|
||
**当前(增量 57/60)**:`mouse_controller.gd` 提供全局持有态和鼠标跟随预览;`inventory_ui.gd` 注册
|
||
背包 / 装备 / 腰带格,`quickbar.gd` 注册快捷栏格,safebox 注册 5×9 分页格,exchange 注册 12 个
|
||
交易落点,shop 注册出售落点,mall 注册商城分页格,private_shop 的 40 格已接入全局投放。背包物品
|
||
可按明确目标格调用现有 checkin / checkout / move / exchange / sell wire API;payload 还保留物品实例的
|
||
`anti_flags`、`flags`、`sockets`、`attrs`,普通拖放保留整组数量,Shift+左键堆叠物品可先选择附着数量,
|
||
金币以 `ITEM_MONEY=-1` 独立态附着。无效区域、右键、ESC、失焦会取消且不发包。交易已接入服务端
|
||
accept 镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限和已放金币不可编辑;NPC 商店已接入
|
||
`ANTIFLAG_SELL`、`IsValuableItem` / `Is1GoldItem` 售价确认;私人商店已接入 `ANTIFLAG_GIVE/MYSHOP`。
|
||
dragon_soul 仍按当前 40250 范围禁用;完整交易事务和真实 `.sub` 光标资源尚未完成。
|
||
|
||
**已完成 / 剩余**
|
||
|
||
1. ✅ 已完成全局 item attach / payload / cancel / drop 控制器、鼠标跟随图标和数量文字;payload
|
||
保留 `window/cell/vnum/count/source` 以及实例 `anti_flags/flags/sockets/attrs`,并增加
|
||
`ITEM_MONEY=-1` 的独立金币持有态。
|
||
2. 已完成 inventory → inventory / equipment / belt 以及 inventory → quickbar 的首版投放;
|
||
装备目标使用参考的 `window=2, wear cell` 坐标。
|
||
3. 已完成 `UiManager.blocks_game_input()` 的键盘焦点屏蔽和失焦清理;光标管理器提供
|
||
`NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM` 状态,暂时用系统光标作为无 `.sub`
|
||
资源时的可运行 fallback。
|
||
4. 已完成 safebox / mall / shop / exchange / private_shop 的首版明确落点:仓库支持 5×9 页签、仓库内
|
||
move 和背包↔仓库;商城支持分页和商城→背包;交易支持背包→12 个指定交易格;商店支持背包拖到
|
||
出售目标;私人商店支持背包→40 格价格弹窗。`complex_item_drop_test.gd` 覆盖这些投放入口。
|
||
5. ✅ `shop_ui.gd` 已增加 BUY/SELL toggle、出售目标可见性和 BUY/SELL 光标状态;购买模式不会接收
|
||
出售投放,出售模式才按数量调用 `shop_sell`。
|
||
6. ✅ 已完成 stack split、pick/drop/money/loginfail 音效、交易的 accept / 距离 / anti-give / 金币边界、
|
||
NPC 商店的 anti-sell / `IsValuableItem` / `Is1GoldItem` 售价确认,以及私人商店的 anti-give / anti-myshop。
|
||
剩余是完整交易事务、全部真实 `.sub` 光标资源和 dragon_soul(当前范围禁用)。
|
||
|
||
---
|
||
|
||
### 4.3 物品 tooltip 只覆盖基础信息
|
||
|
||
**参考**:`POC/assets/root/uitooltip.py`(2718 行)是完整实现,本仓已有该脚本。
|
||
入口按来源分:`SetInventoryItem / SetShopItem / SetExchangeOwnerItem /
|
||
SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem /
|
||
SetSkill / SetQuickSlotItem`。
|
||
|
||
**当前(增量 58/60)**:新增 `project/ui/item_tooltip.gd`,由 `item_proto` 的 236 字节记录提供
|
||
`limits[2]`、`applies[3]`、`values[6]`、`sockets[3]`、`refined_vnum`、`refine_set`、
|
||
`alter_to_magic_pct`、`specular`、`gain_socket_pct`;由物品实例提供真实 `sockets[3]` / `attrs[7]`
|
||
和数量;由 `locale/locale/<lang>/itemdesc.txt` 提供描述。背包、装备查看、NPC 商店、仓库、商城、
|
||
交易、私人商店都已接入同一个提示构造器,当前按参考的武器 / 防具数值、限制、基础属性、实例附加
|
||
属性、魂石槽和精炼关联生成原生 tooltip。
|
||
|
||
**修改**
|
||
|
||
1. ✅ 已完成 tooltip 的数据路径和基础内容生成;没有把 Python UI 类机械搬入 Godot,
|
||
`item_tooltip.gd` 保留 `uitooltip.py` 的数据顺序和关键公式,并使用 Godot 原生 tooltip 承担
|
||
定位 / 换行。
|
||
- 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary /
|
||
GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等);
|
||
- 属性行(attribute)、魔石孔(socket,`ITEM_SOCKET_MAX_NUM = 3`;龙魂石的 subtype 槽位数另为 6)、
|
||
`ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、
|
||
精炼等级、使用时限(`GetItemDuration`)逐条对齐。
|
||
2. ✅ `proto` 层已补齐固定记录字段(`value[6] / limit[2] / apply[3] / flags / anti_flags /
|
||
wear_flags / socket[3] / refine` 等),`proto_item_layout_test` 锁定 236 字节偏移。
|
||
3. 装备对比:已仔细搜索 40250 `ClientVS22` 与本仓 `assets/root/uitooltip.py`,未发现
|
||
`__AppendEquipmentCompare` 或等价的客户端 tooltip 比较方法;不能把它写成参考端已有语义。
|
||
当前应作为本工程新增功能单独定义规则,暂不宣称 1:1。
|
||
4. 增量 60 已加入鱼竿 / 镐等级经验、鱼长度、彩票、混合物品、唯一物品剩余时间和龙魂阶段 /
|
||
强化的基础分支;增量 62 又按技能书、遗忘书、变身道具的 `sockets[0]` 语义生成技能名 /
|
||
怪物名标题,并按限制类型 7/8/9 生成实时剩余时间;对应离线断言已通过。剩余为 CJK 文案 /
|
||
字体切换、其他特殊分支、完整计时来源语义。装备对比在参考源码中没有找到对应方法,若要保留
|
||
应另立产品需求,不能列为 1:1 必做项。
|
||
|
||
---
|
||
|
||
### 4.4 Safebox 是列表和自动找空位
|
||
|
||
**参考**
|
||
|
||
- 容量:`CPythonSafeBox`(`REF/UserInterface/PythonSafeBox.h:8`)
|
||
`SAFEBOX_SLOT_X_COUNT = 5`、`SAFEBOX_SLOT_Y_COUNT = 9`、`SAFEBOX_PAGE_SIZE = 45`。
|
||
- 分页:`uisafebox.py:362 SetTableSize(size)`
|
||
```python
|
||
pageCount = max(1, size // safebox.SAFEBOX_SLOT_Y_COUNT)
|
||
pageCount = min(3, pageCount) # 最多 3 页
|
||
size = safebox.SAFEBOX_SLOT_Y_COUNT
|
||
self.wndItem.ArrangeSlot(0, SAFEBOX_SLOT_X_COUNT, size, 32, 32, 0, 0)
|
||
wnd_height = 130 + 32 * size
|
||
```
|
||
全局槽号 `__LocalPosToGlobalPos(local) = curPageIndex * SAFEBOX_PAGE_SIZE + local`(`:357`)。
|
||
- 协议(`REF/UserInterface/Packet.h:98`):
|
||
`CG_SAFEBOX_CHECKIN = 70`、`CG_SAFEBOX_CHECKOUT = 71`、`CG_SAFEBOX_ITEM_MOVE = 77`;
|
||
`GC_SAFEBOX_MONEY_CHANGE = 84`、`GC_SAFEBOX_SET = 85`、`GC_SAFEBOX_DEL = 86`、
|
||
`GC_SAFEBOX_WRONG_PASSWORD = 87`、`GC_SAFEBOX_SIZE = 88`。
|
||
发送函数:`SendSafeBoxCheckinPacket(TItemPos inventoryPos, BYTE safeBoxPos)`
|
||
(`REF/.../PythonNetworkStreamPhaseGameItem.cpp:29`)——**目标格由调用方指定**,
|
||
`SendSafeBoxCheckoutPacket(BYTE safeBoxPos, TItemPos inventoryPos)`(`:43`)、
|
||
`SendSafeBoxItemMovePacket(BYTE src, BYTE dst, BYTE count)`(`:57`)。
|
||
`SendSafeBoxMoneyPacket` 在参考里是 `assert(!"…사용하지 않는 함수")`——**已废弃**,
|
||
金钱走聊天命令(见下)。
|
||
- 落点分支(`uisafebox.py:411 SelectEmptySlot`):
|
||
```
|
||
attachedType == SLOT_TYPE_SAFEBOX → SendSafeboxItemMovePacket(src, dst, 0) # 仓库内移动
|
||
否则 attachedInvenType = SlotTypeToInvenType(attachedType)
|
||
RESERVED_WINDOW → return
|
||
ITEM_MONEY → SendSafeboxSaveMoneyPacket(count) + sound/ui/money.wav
|
||
其它 → SendSafeboxCheckinPacket(attachedInvenType, attachedSlotPos, selectedSlotPos)
|
||
最后 DeattachObject()
|
||
```
|
||
`SelectItemSlot`(`:451`):持物时只处理金钱;未持物时先看光标——
|
||
`app.SELL` → `SAFEBOX_SELL_DISABLE_SAFEITEM`;`app.BUY` → `SHOP_BUY_INFO`;
|
||
否则 `AttachObject(self, SLOT_TYPE_SAFEBOX, pos, itemID)` + `sound/ui/pick.wav`。
|
||
- 关闭:`Close()` = `net.SendChatPacket("/safebox_close")`(`uisafebox.py:400`)。
|
||
- 密码 / 改密:`dlgChangePassword`(`uisafebox.py`),错误由 `GC_SAFEBOX_WRONG_PASSWORD` 驱动。
|
||
|
||
**当前(增量 56)**:`safebox_ui.gd` 已增加参考端 5×9(45 格)分页网格,按 `size` 显示最多实际页数,
|
||
`local + page*45` 映射全局槽位;背包 / 装备拖入指定仓库格走 `safebox_checkin`,仓库物品拖到另一
|
||
空格走 `safebox_move`,仓库物品可拖回背包指定格走 `safebox_checkout`。旧 `deposit()` 仍保留给右键
|
||
兼容入口,因此自动找空位不是新的鼠标落点语义。
|
||
|
||
**已完成 / 剩余**
|
||
|
||
1. ✅ `safebox_ui.gd` 已实现 5×9 网格 + 按 `size` 分页,`local + page*45` 映射全局槽位。
|
||
2. ✅ 鼠标存取使用**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)` /
|
||
`safebox_checkout(safe_pos, inven_window, inven_pos)`;旧 `deposit()` 仅作为右键兼容入口保留自动
|
||
找空位,不参与鼠标落点。
|
||
3. ✅ `M2Client::safebox_move(from_cell, to_cell, count)` 已存在并由仓库内物品态调用,覆盖
|
||
`CG_SAFEBOX_ITEM_MOVE(77)`。
|
||
4. 金钱不能按当前参考端实现成 `/safebox_money`:`PythonNetworkStreamPhaseGameItem.cpp:14-27`
|
||
的 `SendSafeBoxMoneyPacket()` 主动 `return false`,包发送代码被注释;本次不伪造聊天命令。
|
||
关闭固定用 `client.say(0, "/safebox_close")`。若产品需要仓库存钱,必须另立服务端 / 客户端协议。
|
||
5. ✅ 已接入 `mouse_controller`:背包 / 装备→仓库、仓库→仓库、仓库→背包三类首版落点均按
|
||
空格 / 已有物品分支处理。
|
||
6. ✅ 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的提示已接上;全局 `pick/drop/money/loginfail` 音效和
|
||
BUY/SELL cursor 联动已在增量 57 接入。仓库金钱存取保持未实现,原因见第 4 项,不以假命令冒充完成。
|
||
|
||
---
|
||
|
||
### 4.5 交易缺少事务状态
|
||
|
||
**参考**
|
||
|
||
- 数据:`CPythonExchange`(`REF/UserInterface/PythonExchange.h:9`)
|
||
`EXCHANGE_ITEM_MAX_NUM = 12`;`TExchangeData{ name[25], item_vnum[12], item_count[12],
|
||
item_metin[12][ITEM_SOCKET_SLOT_MAX_NUM], item_attr[12][ITEM_ATTRIBUTE_SLOT_MAX_NUM],
|
||
accept, elk }`,self / target 各一份。
|
||
- UI(`POC/assets/root/uiexchange.py`):
|
||
- `AcceptExchange()`:`net.SendExchangeAcceptPacket()` 然后 `self.AcceptButton.Disable()`。
|
||
- `Refresh()`:`GetAcceptFromSelf()` 为真 → `OwnerAcceptLight.Down()`;
|
||
**为假 → `AcceptButton.Enable(); AcceptButton.SetUp(); OwnerAcceptLight.SetUp()`**
|
||
—— 即「对方/自己改动导致服务端清 accept」时按钮自动恢复可点,这就是
|
||
“接受后修改自动取消接受”的 1:1 实现,客户端**不本地判定**,只跟随服务端的 accept 位。
|
||
- `OpenPickMoneyDialog()`:`if exchange.GetElkFromSelf() > 0:` →
|
||
`EXCHANGE_CANT_EDIT_MONEY`,不弹窗(金钱一旦放入不可改)。`SetMax(7)` = 7 位数上限。
|
||
- `SelectOwnerEmptySlot()`:持金钱 → `SendExchangeElkAddPacket(amount)`;
|
||
持物品且 `attachedSlotType ∈ {SLOT_TYPE_INVENTORY, SLOT_TYPE_DRAGON_SOUL_INVENTORY}` →
|
||
`item.SelectItem(itemID)`;`item.IsAntiFlag(item.ANTIFLAG_GIVE)` → 提示
|
||
`EXCHANGE_CANNOT_GIVE` 并 `DeattachObject()` 返回;否则
|
||
`net.SendExchangeItemAddPacket(attachedInvenType, SrcSlotNumber, DstSlotNumber)`。
|
||
- `OnUpdate()`:`USE_EXCHANGE_LIMIT_RANGE = 1000`——自开窗坐标起
|
||
`abs(dx) > 1000 or abs(dy) > 1000` → `net.SendExchangeExitPacket()`。
|
||
- 标题栏关闭按钮直接绑 `net.SendExchangeExitPacket`。
|
||
|
||
**当前(增量 57/60)**:`exchange_ui.gd` 已接入 12 个指定交易格、服务端 accept 位镜像、Accept 按钮
|
||
禁用 / 恢复、`USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限、已放
|
||
金币不可编辑,并支持背包 / 装备和 `ITEM_MONEY=-1` 拖放。增量 60 已让 m2dev / Classic 的
|
||
`GC_EXCHANGE` 解析、`EntityStore::ExchangeSlot` 和 `M2Client.get_exchange()` 保留 3 个 socket
|
||
与 7 个 attribute;真实双账号事务仍未完成。
|
||
|
||
**修改**
|
||
|
||
1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 +
|
||
`AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。
|
||
2. ✅ `EntityStore` 的交易状态已补齐每个 `ExchangeSlot` 的 3 个 socket / 7 个 attribute,并由
|
||
m2dev / Classic 两条收包路径写入、由 `M2Client.get_exchange()` 暴露给 tooltip。
|
||
3. accept 语义:**只跟随服务端**。发送 `CG_EXCHANGE(ACCEPT)` 后立刻禁用按钮;
|
||
收到 `GC_EXCHANGE` 且 `accept == 0` 时重新启用——不写本地「修改则取消」逻辑。
|
||
4. `OnUpdate` 的 1000 距离退出、`ANTIFLAG_GIVE` 拒绝、金钱 7 位上限与
|
||
`EXCHANGE_CANT_EDIT_MONEY` 全部照抄。
|
||
5. 交易中的背包锁定:参考端**没有**——不要加。
|
||
|
||
---
|
||
|
||
### 4.6 NPC 商店仍是基础列表
|
||
|
||
**参考**(`POC/assets/root/uishop.py`)
|
||
|
||
- 槽位:`shop.SHOP_SLOT_COUNT`;多货架的全局槽号
|
||
`GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos`(`:226`,
|
||
本工程增量 34 已按此实现,保持)。
|
||
- 买卖是**光标模式**,不是按钮直接买:
|
||
```python
|
||
OnBuy(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_BUY_INFO); app.SetCursor(app.BUY); btnSell.SetUp()
|
||
OnSell(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_SELL_INFO); app.SetCursor(app.SELL); btnBuy.SetUp()
|
||
CancelShopping(): btnBuy.SetUp(); btnSell.SetUp(); app.SetCursor(app.NORMAL)
|
||
```
|
||
- 卖出 `SellAttachedItem()`(`:279`):
|
||
```
|
||
shop.IsPrivateShop() → DeattachObject(); return
|
||
attachedSlotType 必须是 SLOT_TYPE_INVENTORY,否则 sound/ui/loginfail.wav
|
||
item.IsAntiFlag(item.ANTIFLAG_SELL) → PopupDialog(SHOP_CANNOT_SELL_ITEM); return
|
||
player.IsValuableItem(pos):
|
||
itemPrice = item.GetISellItemPrice()
|
||
if item.Is1GoldItem(): itemPrice = attachedCount // itemPrice // 5
|
||
else: itemPrice = itemPrice * max(1, attachedCount) // 5
|
||
QuestionDialog(DO_YOU_SELL_ITEM(name, count, price)) → OnSellItem(pos, count, itemtype)
|
||
否则直接 OnSellItem(...)
|
||
OnSellItem: net.SendShopSellPacketNew(slotPos, count, itemtype); sound/ui/money.wav
|
||
```
|
||
注意**卖价 = 单价 × 数量 ÷ 5**(整数除),`Is1GoldItem` 走另一条公式。
|
||
- 关闭:`OnPressEscapeKey`(`:250`)→ `self.Close()` 并 `return True`。
|
||
|
||
**当前(增量 59)**:`shop_ui.gd` 已按 `shopdialog.py` 改为 5×8、40 格货位;空位也保留为不可用
|
||
按钮,已放置货物显示名称 / 堆叠数并接入统一 tooltip,货架切换仍按
|
||
`tabIdx * SHOP_SLOT_COUNT + slot` 计算全局槽号。BUY 模式点击货位进入购买确认,确认后固定调用
|
||
`shop_buy(global_pos, 1)`;SELL 模式仍隐藏 / 拒绝出售目标以外的购买动作。出售模式显示投放区并切换
|
||
`CursorManager.BUY/SELL`,背包物品拖到目标会按当前数量调用 `shop_sell`。实例 / 原型
|
||
`ANTIFLAG_SELL`、`IsValuableItem`、普通 / `Is1GoldItem` 整数售价公式和 UI 音效已完成。
|
||
|
||
**已完成 / 剩余**
|
||
|
||
1. ✅ 已增加 `shop_ui.gd` 的“拖动物品到此出售”目标,按当前数量调用 `shop_sell`。
|
||
2. ✅ 已增加 BUY / SELL toggle、模式切换时的出售目标显隐和 `CursorManager.BUY/SELL` 状态;买入
|
||
按钮在 SELL 模式下先切回 BUY,不误发购买包。
|
||
3. ✅ `shop_ui.gd` 已提供 5×8 货位网格、多货架全局槽号、空位禁用、tooltip 和 BUY 点击确认;
|
||
确认购买固定发送数量 1,与参考 `SendShopBuyPacket` 的包体语义一致。当前仍使用 Godot 控件,
|
||
未加载真实 `.sub` 槽位 / 按钮贴图与图标。
|
||
4. ✅ 卖出流程已补齐:实例 / 原型 `ANTIFLAG_SELL` 拦截、`IsValuableItem` 首次确认、普通与
|
||
`Is1GoldItem` 价格公式(均按整数除 5)和 `shop_sell(slot, count)`;取消或非法投放播放失败音效。
|
||
5. ✅ 买入:点非空格子 → 购买确认 → `shop_buy(GetIndexFromSlotPos(pos), 1)`;空格子不发送。
|
||
6. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。
|
||
7. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。
|
||
|
||
---
|
||
|
||
### 4.7 私人商店是兼容实现
|
||
|
||
**参考**:`POC/assets/root/uiprivateshopbuilder.py`(`itemStock` 模型)+
|
||
`PRIVATE_SHOP_ITEM_MAX_NUM`(= `TPacketCGMyShop::bCount` 上限)。
|
||
本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 +
|
||
`display_pos` / 撤下 / 开设 / 撤收)与参考一致。
|
||
|
||
**当前 / 剩余差距**
|
||
|
||
1. ✅ 拖放已接入 §4.2 的全局 `mouse_controller`;40 个货位注册为落点,背包 payload 会弹价格窗并
|
||
写入 `itemStock[display_pos]`,保留原有点选候选面板作为触屏 / 无鼠标 fallback。
|
||
2. ✅ 开店前检查:已按参考在 `uiprivateshopbuilder.py` 的物品投放路径检查
|
||
`ITEM_ANTIFLAG_GIVE = 1<<13` 与 `item.IsAntiFlag(item.ANTIFLAG_MYSHOP)`(后者
|
||
`ITEM_ANTIFLAG_MYSHOP = 1<<16`,`REF/GameLib/ItemData.h:204`);实例字段和原型字段取并集,
|
||
拦截时不发开店包。
|
||
3. 其他玩家查看:`GC_SHOP` 的 `SHOP_SUBHEADER_GC_START_EX` 已实现;招牌走
|
||
`GC_SHOP_SIGN`(已实现),但需按 §8.2 换成真 `.sub` 资源渲染。
|
||
4. 撤下回滚:参考端**没有**本地回滚——撤下即发包,状态由 `GC_SHOP` 回包驱动。
|
||
现有实现若做了本地乐观更新,删掉。
|
||
|
||
---
|
||
|
||
### 4.8 快捷栏来源类型不完整
|
||
|
||
**参考**
|
||
|
||
- 容量(`REF/UserInterface/Packet.h:392`):
|
||
`QUICKSLOT_MAX_LINE = 4`、`QUICKSLOT_MAX_COUNT_PER_LINE = 8`、
|
||
`QUICKSLOT_MAX_COUNT = 32`、`QUICKSLOT_MAX_NUM = 36`(**服务器保存 36,客户端只显示 32**)。
|
||
- 类型:`TQuickSlot{ BYTE Type; BYTE Position; }`(`REF/UserInterface/GameType.h:307`),
|
||
其中 **`Type` 直接就是 `ESlotType`**(`GameType.h:140`)——
|
||
`CPythonPlayer::RequestUseLocalQuickSlot`(`REF/UserInterface/PythonPlayer.cpp:813`)
|
||
拿 `GetLocalQuickSlotData()` 的原值直接 switch:
|
||
```
|
||
SLOT_TYPE_INVENTORY (1) → SendItemUsePacket(TItemPos(INVENTORY, pos))
|
||
SLOT_TYPE_SKILL (2) → ClickSkillSlot(pos) // 走 §3.8 的完整链
|
||
SLOT_TYPE_EMOTION (3) → BINARY_ActEmotion(pos)
|
||
```
|
||
**参考端快捷栏没有「命令」类型**,`CLIENT-GAP.md` 里提到的“缺命令”不成立。
|
||
|
||
- **⚠ 当前实现的类型编号与参考端不兼容**:
|
||
`POC/extension/src/net/m2_client.cpp:878` 是
|
||
```cpp
|
||
d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion
|
||
```
|
||
而参考端 `3 == SLOT_TYPE_EMOTION`、`4 == SLOT_TYPE_SHOP`(快捷栏不可执行)。
|
||
也就是说**当前把服务端发来的 EMOTION(3) 当成了 command,把不可执行的 SHOP(4) 当成了
|
||
emotion**——服务端下发的表情快捷栏会被误判,`quickslot_add()`
|
||
(`m2_client.cpp:897` 的 `type < 1 || type > 4`)也会把错误类型发回服务端。
|
||
- 边界(`PythonPlayer.cpp:800/815/843/852/861`):
|
||
`RequestDeleteGlobalQuickSlot >= QUICKSLOT_MAX_COUNT(32) → return`;
|
||
`RequestUseLocalQuickSlot >= QUICKSLOT_MAX_COUNT_PER_LINE(8) → return`;
|
||
`AddQuickSlot / DeleteQuickSlot / MoveQuickSlot` 越界 `QUICKSLOT_MAX_NUM(36)` → return。
|
||
`MoveQuickSlot(src, dst)` 是 `std::swap`,不是覆盖。
|
||
- 键位(`POC/assets/root/game.py:509`):
|
||
```python
|
||
def __PressQuickSlot(self, localSlotIndex):
|
||
if app.IsRTL():
|
||
if 0 <= localSlotIndex < 4: player.RequestUseLocalQuickSlot(3 - localSlotIndex)
|
||
else: player.RequestUseLocalQuickSlot(11 - localSlotIndex)
|
||
else: player.RequestUseLocalQuickSlot(localSlotIndex)
|
||
```
|
||
数字键 1–4 走 `pressNumber`(默认 = `__PressQuickSlot`),
|
||
按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage`(`game.py:506`)。
|
||
F1–F4 固定映射到 `__PressQuickSlot(4..7)`(`game.py:318`)。
|
||
|
||
**当前(增量 53)**:`quickbar.gd` 已显示参考端的 4 页 × 8 格;服务器状态仍保留 36 槽,末 4 槽
|
||
不映射到本地显示。`M2Client.entity_dict()` / `quickslot_add()` 与 UI 使用同一套类型:1 道具、
|
||
2 技能、3 表情;4(商店)恢复时不执行。技能走 `use_skill()` + `cast_skill()`,表情走
|
||
`send_emoticon()`。
|
||
|
||
**已完成(增量 53)**
|
||
|
||
1. `quickbar.gd` 已改为 4 页 × 8 格本地显示,保留服务器 36 槽状态;数字 1–4 激活 0–3,F1–F4
|
||
激活 4–7,LSHIFT 按住时数字 1–4 选择页。
|
||
2. `M2Client` / `quickbar.gd` 已统一 `INVENTORY=1 / SKILL=2 / EMOTION=3`;`quickslot_add()` 只
|
||
接受 1–3;技能保留完整 `use_skill()` + `cast_skill()` 链;4(商店)不执行。
|
||
3. Ctrl/Command+1–9 已调用 `send_emoticon()`;12 个迁移资产表情 token 和本地特效播放已接通。
|
||
4. 技能拖放、清除、同页 swap 和服务器回包恢复已回归;普通物品拖入快捷栏仍属于 §4.2 的未完成项。
|
||
5. 冷却仍由本地技能表预测并接受 `skill_cooldown_end` 解锁;死亡 / 变身 / 换武器后的完整刷新仍需
|
||
真实回包验收。
|
||
|
||
---
|
||
|
||
### 4.9 组队状态是近似定义
|
||
|
||
**参考**:`PARTY_AFFECT_SLOT_MAX_NUM = 7`(`REF/UserInterface/Packet.h:404`)。
|
||
角色枚举与 Leadership 门槛在本工程增量 43 已按 40250 服务端 `CParty::Update` 对齐
|
||
(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / HASTE 6 /
|
||
DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。
|
||
|
||
**剩余差距与修改**
|
||
|
||
1. `affects[7]` 的**槽位语义**目前是“暂定映射”。需要从 40250 服务端
|
||
`game/src/party.cpp` 的 `CParty::ComputeRolePoint / Update` 读出每个下标的含义
|
||
**[待读参考:服务端源码,不在 ClientVS22 内]**;在拿到之前,
|
||
UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。
|
||
2. 队伍技能冷却:参考客户端**不本地计时**,冷却由服务端拒绝 + `GC_CHAT` 提示体现。
|
||
若当前有本地冷却,删除。
|
||
3. 队长权限动态变化:以 `get_party()` 里本地 VID 那条的 `leader` 位为唯一判据(已实现),
|
||
收到 `GC_PARTY_*` 后立即重算按钮可用性。
|
||
4. 离线 / 死亡 / 距离状态:来自 `GC_PARTY_UPDATE` 的字段,接到 `PartyMemberInfoBoard` 的
|
||
对应显示位。
|
||
|
||
---
|
||
|
||
### 4.10 好友、情侣、公会只有首版状态展示
|
||
|
||
**参考**:`POC/assets/root/uimessenger.py`(739 行,分组 / 上线离线 / 右键菜单)、
|
||
`uiwhisper.py`(私聊窗生命周期)、`uiguild.py`。
|
||
公会名长度 `GUILD_NAME_MAX_LEN = 12`、等级名 `GUILD_GRADE_NAME_MAX_LEN = 8`、
|
||
等级数 `GUILD_GRADE_COUNT = 15`、公告 `GULID_COMMENT_MAX_LEN = 50`
|
||
(`REF/UserInterface/Packet.h:408`)。
|
||
|
||
**修改**
|
||
|
||
1. 按 §8 的窗口迁移样板逐窗移植 `uimessenger.py` / `uiwhisper.py` / `uiguild.py`,
|
||
不自行设计交互。
|
||
2. 公会相关按 `CLIENT-GAP.md` 的当前范围说明**暂缓**,但常量先落到 `limits.h`。
|
||
3. 情侣:已有 `GC_LOVER_INFO` / `GC_LOVE_POINT_UPDATE` 与 `love_ui`;
|
||
完整交互按 `uiwhisper.py` / `uicharacter.py` 里的情侣分支移植。
|
||
|
||
---
|
||
|
||
## 5. P1:任务、NPC 和世界玩法
|
||
|
||
### 5.1 Quest EventManager 只支持少量标签
|
||
|
||
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 95 —— EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 + SEventSet 状态字段名 1:1;逐帧 `UpdateEventSet` 时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 / `isConfirmWait` 倒计时刷新)、`Skip`/`EndEventProcess`、`iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)仍为 seam)**
|
||
|
||
**参考**:标签表在 `CPythonEventManager::CPythonEventManager()`
|
||
(`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 42 个**(`EEventType` 枚举 42 项,
|
||
`CLEAR` 与 `CLEAR_TEXT` 是两个独立枚举但都走 `ClearLine`):
|
||
|
||
```
|
||
LETTER COLOR COLOR256 DELAY ENTER WAIT CLEAR QUESTION NEXT DONE
|
||
LEFTIMAGE TOPIMAGE BGIMAGE IMAGE INSERT_IMAGE TITLE_IMAGE
|
||
ADDMAPSIGNAL CLEARMAPSIGNAL SETMSGPOS ADJMSGPOS SETCMAPPOS
|
||
QUESTBUTTON QUESTBUTTON_CLOSE
|
||
SLEEP SET_CAMERA BLEND_CAMERA RESTORE_CAMERA
|
||
FADE_OUT FADE_IN WHITE_OUT WHITE_IN
|
||
CLEAR_TEXT TEXT_HORIZONTAL_ALIGN_CENTER
|
||
RUN_CINEMA DUNGEON_RESULT ITEM MOB WINDOW_SIZE
|
||
INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM
|
||
```
|
||
|
||
分派在 `ProcessEventSet()`(`PythonEventManager.cpp:377`),每个 case 的语义
|
||
(**逐条照抄**,行号为 `PythonEventManager.cpp`):
|
||
|
||
| 标签 | 行 | 行为 |
|
||
|---|---|---|
|
||
| `LETTER` | 399 | 追加 `value` 到当前行;着色 `CurrentColor`;`iCurrentLetter += len`;超 `iRestrictedCharacterCount` 换行;`lLastDelayTime = lWaitingTime` |
|
||
| `DELAY` | 416 | start:`lWaitingTime = atoi(value)`;end:恢复 `c_lNormal_Waiting_Time` |
|
||
| `COLOR` | 425 | start:`r/g/b` 取 `atof`,a=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` 改)。
|
||
|
||
**当前(增量 95)**:`POC/project/ui/quest_dialog.gd` 已按 `CPythonEventManager` 逐条重写脚本解析:
|
||
|
||
- `EVENT_TYPE_MAP`(42 条,键即 tag)+ `enum { EVT_* }`(枚举顺序照抄 `PythonEventManager.h:35`
|
||
`EEventType`)+ `_get_script_event_index()`(`GetScriptEventIndex` `:1093`——前导 `/` 剥离为
|
||
`EVENT_POSITION_END`,未知命令返回 `ok=false`、跳过、不进 switch)。
|
||
- `class EventSet`(`SEventSet` `PythonEventManager.h:98`——字段名 1:1:`ix/iy/iWidth/iyLocal/
|
||
isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
|
||
DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/
|
||
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ `init_event_set()`(`__InitEventSet` `:72`
|
||
的常量:`c_lNormal_Waiting_Time=10`、`iRestrictedCharacterCount=30`、`c_fLine_Temp=16`、
|
||
`BOX_VISIBLE_LINE_COUNT=5`、颜色 `(1,1,1,1)`)、`insert_line()`(`__InsertLine` `:976`:推行 +
|
||
`iyLocal += 16`)、`clear_line()`(`ClearLine` `:953`)、`body_text()`。
|
||
- `_process_event_set(es, cmd)`——上表 `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷,
|
||
含 `DELAY`/`COLOR`/`COLOR256` 的 `EVENT_POSITION_START` vs `END` 分支、`LETTER`/`ITEM`/`MOB`
|
||
的 `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE`
|
||
的 `iAdjustLine += 2`、`WAIT` 的 `iyLocal=0; isLock=true`、`CONFIRM_WAIT` 的
|
||
`isConfirmWait=true` + `insert_line(center)`、`END_CONFIRM_WAIT` 清 `isConfirmWait`、
|
||
`FADE_*`/`WHITE_*` 的 `isWaitFlag=true`、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**。
|
||
- `_tokenize()` 把混排文本 / `[tag ...]` 拆成命令流,裸网络文本折成合成 `LETTER`(`is_raw=true`,
|
||
跳过 30 字断行——reference 脚本只有显式 `[LETTER]`,逐字符断裸文本会破坏服务器句子)。
|
||
- arg 提取 `_tag_kv()`(`key(val)` / `key("val")` / `key;val`)+ `_arg_s/_arg_i/_arg_f`
|
||
(keyword 优先,位置回退,兼容旧测试的 `value;255 value;128` 与 `value(1) value(2)` 写法)。
|
||
- `parse_script()` 返回字典在原有键之外新增:`insert_images / dungeon_result / adjust_line /
|
||
waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`(供回归断言)。
|
||
- 首命令 `RUN_CINEMA` → 记 `cinema_file` 并发 `cinema_requested` 信号(`.msc` 文件加载是 seam)。
|
||
- 回归 `POC/project/quest_event_test.gd`(新增,规范测试 #16,23 组断言:EventTypeMap 完整性 /
|
||
`/` 前缀 / DELAY START·END / 断行 / ENTER·CLEAR·CLEAR_TEXT / QUESTION·nAnswer /
|
||
NEXT·DONE·adjust_line / WAIT·isLock / COLOR·COLOR256 / INPUT·SELECT_ITEM / CONFIRM_WAIT·
|
||
END_CONFIRM_WAIT / DUNGEON_RESULT 9 int / INSERT_IMAGE 两路 / IMAGE 家族 / map signal /
|
||
QUESTBUTTON·CLOSE / camera SET·BLEND·RESTORE / FADE·WHITE·wait_flag / WINDOW_SIZE /
|
||
ITEM·MOB / SETMSGPOS·ADJMSGPOS no-op / 未知 tag 跳过 / RUN_CINEMA 首命令·非首命令),
|
||
既有 `quest_test.gd` / `select_item_ui_test.gd` 仍绿。
|
||
|
||
**修改**
|
||
|
||
1. ✅ `quest_dialog.gd` 已接入常用标签的解析元数据、颜色 / 居中 / 尺寸、ITEM/MOB 占位名、
|
||
CONFIRM_WAIT 取消入口、任务信件 / 小地图 side effect,并在图片路径可解析时创建实际图片节点;
|
||
路径不存在时安全跳过,不伪造错误资源。
|
||
2. ~~剩余工作是把 `parse_script` 升级为完整的 `EventTypeMap`(38 项)+ 上表的 `ProcessEventSet`
|
||
分派,逐条实现。~~ **增量 95 完成**:`EVENT_TYPE_MAP`(42 项)+ `_get_script_event_index` +
|
||
`_process_event_set` 已逐 case 对拷上表;裸文本折成合成 `LETTER`。
|
||
3. ~~引入 `EventSet` 状态~~ **增量 95 完成**:`class EventSet` 字段名与 `SEventSet` 1:1
|
||
(`lWaitingTime / lLastDelayTime / iCurrentLetter / iRestrictedCharacterCount / iAdjustLine /
|
||
iyLocal / isLock / isWaitFlag / isConfirmWait / iConfirmEndTime / isTextCenterMode /
|
||
CurrentColor` 等),`init_event_set()` 复位。**seam**:一次性把 token 跑完,`isLock` /
|
||
`isWaitFlag` / `lLastDelayTime` 仅作状态保留并透出,不做逐帧 `UpdateEventSet` 时间推进。
|
||
4. ✅ `SETMSGPOS` / `ADJMSGPOS` 已实现为**空**(`_process_event_set` 的 `EVT_SET_MESSAGE_POSITION,
|
||
EVT_ADJUST_MESSAGE_POSITION: pass`,注释引 `PythonEventManager.cpp:562/566`)。
|
||
5. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮
|
||
按 `PythonEventManager.cpp` 的对应函数移植
|
||
(`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。
|
||
→ **增量 95 落地**:`_make_question`(`nAnswer` = arg 数、逐 arg 入 `choices`)、`NEXT/DONE →
|
||
has_next + iAdjustLine += 2`、`CONFIRM_WAIT` 的 CANCEL 由 `_fill_buttons(confirm_wait)` 出、
|
||
`insert_line`/`clear_line` 已按 `:976`/`:953` 移植。**未接**:`MakeNextButton` 的 `NEXT` vs
|
||
`DONE` 按钮文案区分(现均为「继续」)、`DoneEvent()` 回调。
|
||
6. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。
|
||
→ **增量 95 落地一半**:`_process_event_set` 已解析 `SET_CAMERA`(`_camera_setting` 九参
|
||
`x/y/z/up/view/cross/distance/rot/pitch`,`GetCameraSettingFromArgList` `:48`)、`BLEND_CAMERA`
|
||
(+`blendtime`)、`RESTORE_CAMERA` 并经 `camera_event_requested` 信号发出。**未接**:信号消费端
|
||
与 §7.6 的相机模式对接(仍为 game_scene 层 TODO)。
|
||
|
||
---
|
||
|
||
### 5.2 任务日志不是完整 Quest 系统
|
||
|
||
**参考**:`REF/UserInterface/PythonQuest.h` + `POC/assets/root/uiquest.py`(926 行)。
|
||
任务条目由 `EVENT_TYPE_QUEST_BUTTON` 的 `BINARY_RecvQuest(idx, name, icon_type, icon_name)`
|
||
(§5.1)与 `GC_QUEST_INFO` 共同驱动;关闭走 `BINARY_ClearQuest(idx)`。
|
||
|
||
**当前(增量 101)**:`quest_log.gd` 已按参考端的 `QUEST_MAX_NUM=5` 保持任务快照,固定渲染
|
||
5 行;任务数超过 5 时显示滚动条,并将滚动位置离散映射到 `questShowingStartIndex`。每行显示
|
||
默认 `season1/icon/scroll_open.tga`(无 icon 路径)或服务端 icon、标题、counter 和 clock;clock
|
||
按参考的分钟 / 秒格式显示,并在窗口打开时按秒递减。点击任务行发送
|
||
`script_button(-2147483648 + questIndex)`,并发出 `quest_selected`。另有独立 QuestButton 条带,
|
||
接收 `QUESTBUTTON/QUESTBUTTON_CLOSE`、点击发送 `script_button(index)`,并在对话打开时隐藏。
|
||
`quest_dialog.gd` 已识别常用 EventManager 标签,把颜色、居中、尺寸、ITEM/MOB、确认等待、图片资源
|
||
和地图信号暴露给 UI。
|
||
|
||
**已完成 / 剩余**
|
||
|
||
1. ✅ 任务日志已从纯文字列表升级为 5 行窗口:标题 + 默认 / 服务端 icon + counter + 倒计时;
|
||
资源缺失时安全回退,不伪造图标;任务数量、滚动起始索引和可见行可由回归测试检查。
|
||
2. ✅ 已补齐参考端任务行选择:滚动条按 `questCount - QUEST_MAX_NUM` 映射起始行,点击行按
|
||
`-2147483648 + questIndex` 进入 QuestButton 事件路径。
|
||
3. ✅ 已移植独立 QuestButton 的接收 / 替换 / 删除 / 点击发送和对话抑制;已将
|
||
`EVENT_TYPE_ADD_MAP_SIGNAL` / `CPythonMiniMap::AddSignalPoint`、清除和 Atlas center adjust 接到
|
||
现有 minimap / Atlas。**增量 108**:小地图任务信标越出半径时按 `m2dev PythonMiniMap.cpp:441-462`
|
||
的 `atan2 + radius*cos/sin` 夹到圆周并改画指向目标的方向箭头(`minimap.signal_marker_geometry` +
|
||
`_draw_signal_arrow`,留边常量 `SIGNAL_RIM_INSET=9.0` 对齐 `c_fMiniMapWindowRadius` 的 `- 9.0f`),
|
||
即世界箭头 / 屏幕边缘指示首版。**增量 109**:quest 信件按钮条按 `assets/root/interfacemodule.py:1357-1386`
|
||
的 `__ArrangeQuestButton` 1:1 多列定位——`yPos = 170*H//600`、`yCount = (H-330)//63`、第 i 个
|
||
`= (xPos + (i//yCount)*100, yPos + (i%yCount)*63)`,`xPos` 组队窗显示时由 20 移到 130;`_quest_button_order`
|
||
复刻 `questButtonList.insert(0, btn)`(最新在前、同 index 提前),`_button_list` 改绝对定位 Control
|
||
(不再 VBox 单列 sort by index),组队窗可见性经 `set_party_probe` 在 `_process` 轮询。剩余是 Atlas
|
||
同一信标夹边、任务日志窗体 `_list` 自身多列 / 分页和完整 `.sub` 图标 / Flash 视觉。
|
||
4. EventManager 的 38 项逐事件推进、立绘、淡入淡出、相机、电影和真实 `.sub` 资源仍按 §5.1
|
||
单独推进,不能以本批次的标签识别当作完整迁移。
|
||
|
||
---
|
||
|
||
### 5.3 钓鱼、副本、坐骑和观战者只有部分协议入口
|
||
|
||
**参考要点**
|
||
|
||
- 钓鱼:`CPythonPlayer::NEW_Fishing()`(`REF/UserInterface/PythonPlayerInput.cpp:526`)
|
||
```
|
||
if (pkInstMain->GetFishingRot(&irot)) SendFishingPacket(irot);
|
||
else PyCall(gameWindow, "OnFishingWrongPlace")
|
||
```
|
||
攻击键在钓鱼模式下改为钓鱼:`SetAttackKeyState`(`PythonPlayerInputKeyboard.cpp:6`)
|
||
`if (isPress && main->IsFishingMode()) { NEW_Fishing(); return; }`。
|
||
方向键会 `NEW_CancelFishing()`(`PythonPlayerInputKeyboard.cpp:24`)。
|
||
- 坐骑:`CInstanceBase::MountHorse` → `SetMotionMode(MODE_HORSE)`(`InstanceBase.cpp:953`);
|
||
攻击限制 `CanAttackHorseLevel()`(`InstanceBaseBattle.cpp:623`)=
|
||
`IsMountingHorse() && m_kHorse.CanAttack()`;
|
||
骑马手部模式下禁止徒手攻击:`NEW_Attack()`(`PythonPlayerInput.cpp:613`)
|
||
`if (main->IsMountingHorse() && main->IsHandMode()) return;`
|
||
动作模式表见 §6.4。
|
||
- 副本:`CDungeonBlock`(`REF/GameLib/DungeonBlock.h`)+ `PROPERTY_TYPE_DUNGEON_BLOCK`
|
||
(§7.1);结算走 `EVENT_TYPE_DUNGEON_RESULT`(§5.1)。
|
||
- 观战:`CPythonPlayer::IsObserverMode()`——它在 `__CanUseSkill()`(`PythonPlayerSkill.cpp:445`)
|
||
与 `IsAttackableInstance()`(`InstanceBase.cpp:2151`)里都是**第一道门**。
|
||
|
||
**当前状态:🟡 部分完成(2026-09-03 / 增量 107)**
|
||
|
||
增量 102 已完成钓鱼模式攻击键发送 `CG_FISHING`、方向键和地面点击取消、500ms 取消节流、
|
||
特殊技能 123 快捷栏入口,以及 `GC_FISHING` 五类事件的客户端意图状态维护;增量 103 又接入
|
||
`GetFishingRot` 等价的真实 `ATTRIBUTE_WATER` 水面扫描和选中 heading;增量 104 又按
|
||
`RecvFishing` 分支接入 wrong-place / failure / unknown / notify / success 的 locale 消息与
|
||
item proto 类型判定;增量 105 接入普通 / 新坐骑的攻击等级门和自动攻击的 `mount_vnum` 判定;
|
||
增量 106 接入观战者小地图 ADD / MOVE / REMOVE 一秒插值和 `world_reset` 清理;增量 107 按
|
||
`GC_DUNGEON DESTINATION_POSITION` 接入副本目的地状态、全局厘米坐标转换、目标到达判据和
|
||
HUD 距离 / 朝向提示,并在 `world_reset` 时清理。`TIME_ATTACK_START` 保持参考端空分支,不添加
|
||
本地计时器。具体文件和测试见本文件 C.5 增量 102 / 103 / 104 / 105 / 106 / 107 记录。
|
||
|
||
**剩余修改**
|
||
|
||
1. 钓鱼剩余:接入真实 `GC_ITEM_*` 物品更新 / inventory 事务并验证 item proto 与服务端库存;当前
|
||
消息分支已完成,水面扫描依赖已加载区块,地图流式切换后的 provider 覆盖仍需真实场景验收。
|
||
2. 坐骑剩余:攻击等级门已由增量 105 接入;仍需补真实坐骑模型、`MODE_HORSE*` 动作 / 骑乘动画、
|
||
移速和真服坐骑数据验收。
|
||
3. 副本剩余:读取真实 `.mde` / `.mdatr` collision 的 DungeonBlock,补真实入口、结算窗口和
|
||
`EVENT_TYPE_DUNGEON_RESULT` 的 9 参数字段;目的地消费 / 到达 / HUD 罗盘首版已由增量 107 接入,
|
||
参考端 `TIME_ATTACK_START` 本身没有本地计时逻辑。
|
||
4. 观战剩余:小地图插值和 `world_reset` 清理已由增量 106 接入;仍需补自由视角、真实服务器
|
||
`GC_OBSERVER_ADD/REMOVE/MOVE` 完整生命周期和换图时序验收。
|
||
|
||
---
|
||
|
||
### 5.4 PVP、PK 和决斗没有完整规则
|
||
|
||
**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 94 —— `IsAttackableInstance` 1:1;增量 110 —— 名字颜色 `GetNameColorIndex` 1:1,见 §8.8 / C.5 增量 110;`ATTRIBUTE_BANPK`、PVP/GVG/DUEL key 的 packet setter 仍为 seam)**
|
||
|
||
**参考**:`CInstanceBase::IsAttackableInstance(victim)`
|
||
(`REF/UserInterface/InstanceBase.cpp:2147`)是**唯一**判据,完整逻辑:
|
||
|
||
```
|
||
主角且 IsObserverMode() → false
|
||
自己打自己 → false
|
||
IsStone(): victim.IsPC() → true
|
||
IsPC():
|
||
victim.IsStone() → true
|
||
victim.IsPC():
|
||
GetDuelMode():
|
||
DUEL_CANNOTATTACK → false
|
||
DUEL_START → __FindDUELKey(self, victim) ? true : false
|
||
PK_MODE_GUILD == GetPKMode() && 同公会 → false
|
||
victim.IsKiller() && !同队 → true
|
||
PK_MODE_PROTECT != GetPKMode():
|
||
PK_MODE_FREE : victim 非 PROTECT && !同队 → true
|
||
PK_MODE_GUILD : victim 非 PROTECT && !同队 && 公会不同 → true
|
||
IsSameEmpire(victim):
|
||
IsPVPInstance(victim) → true
|
||
PK_MODE_REVENGE && !同队 && IsConflictAlignmentInstance → true
|
||
else (不同帝国) → true
|
||
victim.IsEnemy() → true
|
||
victim.IsWoodenDoor() → true
|
||
IsEnemy(): victim.IsPC() || victim.IsBuilding() → true
|
||
IsPoly(): victim.IsPC() || victim.IsEnemy() → true
|
||
否则 false
|
||
```
|
||
|
||
`EPKModes`(`REF/UserInterface/Packet.h:1273`):
|
||
`PK_MODE_PEACE=0, PK_MODE_REVENGE=1, PK_MODE_FREE=2, PK_MODE_PROTECT=3, PK_MODE_GUILD=4,
|
||
PK_MODE_MAX_NUM=5`。
|
||
|
||
**注意**:`uigameoption.py` 的 PK 单选按钮映射是
|
||
`peace→/pkmode 0`、`revenge→/pkmode 1`、`guild→/pkmode 4`、`free→/pkmode 2`
|
||
(本工程增量 38 已实现,与上面的枚举一致,保持)。
|
||
|
||
名字颜色枚举 `ENameColor`(`REF/UserInterface/InstanceBase.h:227`):
|
||
`NAMECOLOR_MOB / NPC / PC(+EMPIRE_NUM) / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY /
|
||
WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。
|
||
|
||
**修改**
|
||
|
||
1. ~~在 `player_controller.gd`(或 `entity_rules.gd`)**逐行**实现
|
||
`is_attackable_instance(self_vid, victim_vid)`,不做简化。~~
|
||
**增量 94 完成**:新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`),
|
||
`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `InstanceBase.cpp:2147` ——
|
||
Stone / PC / Enemy / Poly 四分支、决斗三态(`DUEL_CANNOTATTACK`→false、`DUEL_START`→
|
||
`__FindDUELKey`)、`PK_MODE_GUILD` 同公会提前放行、`victim.IsKiller()` && !同队、
|
||
`PK_MODE_PROTECT != GetPKMode()` 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队)/
|
||
`PK_MODE_GUILD`(再加公会不同)、`IsSameEmpire` → `IsPVPInstance` / `PK_MODE_REVENGE`
|
||
&& !同队 && `IsConflictAlignmentInstance`、异帝国直接 true、`victim.IsEnemy()` /
|
||
`victim.IsWoodenDoor()`。附 `alignment_grade`(:502)、`alignment_type`(:524)、
|
||
`is_conflict_alignment_instance`(:2116)、`_is_same_empire`(:470)、`_is_pvp_instance`
|
||
(`InstanceBaseEffect.cpp:505`)、`_find_duel_key`(:495)、`_is_same_party`
|
||
(`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119)。
|
||
`_is_pc` 依 `CActorInstance::IsPC`(`ActorInstance.cpp:282`)只看 `TYPE_PC`,变身玩家仍是 PC;
|
||
`_is_enemy` 只 `TYPE_ENEMY`;独立 `TYPE_POLY` 实例才走 `else if (IsPoly())` 分支。
|
||
`net_play.gd` 的 `_is_attackable(e)` 改为拼 `self_e`(`_main_entity()` + `kind`)/ `victim_e`
|
||
(+ `kind`)/ `_attack_ctx()`(`get_duel` / `get_pvp_relations` / `get_guild_wars` / `get_party`),
|
||
委托 `EntityRules.is_attackable_instance`;`_main_entity()` 为空(headless)退化到旧的
|
||
「PC / 怪可打」近似。自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言,
|
||
覆盖 grade/type 边界、观战门、自打自、四类别、帝国 same/diff/0、决斗三态、PK 五模式、
|
||
killer、同队抑制、PVP-pair、GVG-pair、木门、冲突善恶、变身)。
|
||
2. 依赖判据补齐:`IsKiller()`(`state_flags & ADD_CHARACTER_STATE_KILLER`=1<<3 位,
|
||
classic 后端 `classic_parser.cpp` 已接;m2dev 后端为 **seam**)、`IsSameEmpire()`
|
||
(empire 字段,§2.5 已补;`IsGameMaster()` 无 GM 标记 → **seam**,恒 false)、
|
||
`IsSamePartyMember()`(`get_party()` 的 VID 集合,已接)、`GetDuelMode()` + `__FindDUELKey`
|
||
(`get_duel().opponents`,已接)。**仍为 seam**:`__FindPVPKey`(`get_pvp_relations()` 有值则用,
|
||
其 setter=`GC_PVP` 未接)、`__FindGVGKey`(`get_guild_wars()` 对表,公会战对局 setter 未接)、
|
||
`IsBuilding` / `IsWall` 的 race 白名单(源码未随发布)、`_entity_kind` 无法分类
|
||
(无 `ch_type` 且 mob_proto 查不到 race)时怪会被当成和平 PC → 不可攻击
|
||
(生产环境 mob_proto 恒加载,仅影响裸测试夹具——增量 94 已给相关夹具补 `ch_type`)。
|
||
3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。
|
||
**增量 110 完成名字颜色**:`project/name_color.gd` 逐行照抄 `CInstanceBase::GetNameColorIndex`
|
||
(`REF/UserInterface/InstanceBaseEffect.cpp:525`)+ `SetEmpireNameMode`(:52)+ `GetIndexedNameColor`
|
||
(:75),复用本节 `entity_rules.gd` 的 `_is_pc / _is_enemy / _is_poly / _is_killer / _is_same_empire /
|
||
_find_duel_key / _pair_in` 与 `DUEL_*` 常量;ctx 与 `_attack_ctx()` 同构。`net_world.gd` 接进头顶
|
||
`Label3D.modulate`。**增量 111 完成称号色**:`project/text_tail.gd` 照抄 `GetTitleColor`
|
||
(:609)+ `RefreshTextTail`(:664)+ `UpdateTextTailLevel`(:654),复用 `entity_rules.alignment_grade`;
|
||
`net_world.gd` 接进 `TitleTag` / `LevelTag` / `GuildTag` 子标签。**增量 112 完成聊天 / 信息尾标**:
|
||
`project/chat_tail.gd` 照抄 `CPythonTextTail::RegisterChatTail` / `RegisterInfoTail`
|
||
(`PythonTextTail.cpp:581/:624`)+ `TextTail_GetLivingTime`(:28)+ `RecvChatPacket` 派发
|
||
(`PythonNetworkStreamPhaseGame.cpp:1301/:1321/:1372`);`net_world.gd` 每 vid 一条 `"bubble"`
|
||
`Label3D`、白 / 淡红按 chat/info、5s 硬删。**增量 113 完成名字「是否显示」**:
|
||
`project/name_show.gd` 照抄 `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`
|
||
(`PythonSystem.cpp:327/:334`)+ `game.py::__IsShowName`(:1149)+ `CPythonTextTail::ShowAllTextTail`
|
||
的 `fDistanceFromPlayer < 3500`(`PythonTextTail.cpp:422`)+ `ShowCharacterTextTail` 资格判定
|
||
(:440)+ `UpdateAllTextTail` 的 chat `bNameFlag` 强显(:74);`net_world._name_shown()` 组装
|
||
`_always_show_name`(uigameoption radio)/ `_alt_held`(LALT)/ `_target_vid`
|
||
(`net_play.target_changed`,即 `targetBoard.GetTargetVID()`)/ chat `bNameFlag` 四路。
|
||
**目标框已让选中目标强显名字**;可攻击光标仍 **未接**;悬停 `chr.Pick()` 强显 = seam ⑩。
|
||
**增量 114 完成头顶伤害飘字**:`project/damage_effect.gd` 照抄 `CInstanceBase::AddDamageEffect`
|
||
(`InstanceBaseEffect.cpp:87`,`IsShowDamage()` 门 + `m_DamageQueue`)+ `ProcessDamage`
|
||
(:100,每帧 `pop_front` 一条 + `DODGE|BLOCK → MISS` / `self "damage_"` 交替 / `nontarget_` 不渲染
|
||
/ `target "target_"` 分类 + 数字逐位低位在前)+ `CPythonSystem::IsShowDamage` / `SetShowDamageFlag`
|
||
(`PythonSystem.cpp:337/:342`);`net_world._on_damage` 改为按此入队、`_process_damage_queue()`
|
||
每帧 pop 一条渲染 billboard `Label3D`,`bSelf` 用 `_main_vid`、`bTarget` 用 `_target_vid`;
|
||
`game_option_ui` 的「伤害数字」radio 经 `display_option_changed` → `net_world.set_show_damage`。
|
||
seam ⑪:单块 `Label3D` 代替 `damagevalue/<type><digit>.dds` 逐位特效 / 相机右向像素铺排、
|
||
暴击无独立视觉(对齐该 build 参考端)、全局单队列每帧一条 vs 参考每实例一队列。
|
||
**增量 115 完成目标框收包分派**:`project/target_board.gd` 照抄 `CPythonNetworkStream::RecvTargetPacket`
|
||
(`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树 + `CInstanceBase::CanViewTargetHP`
|
||
(`InstanceBase.cpp:2293`= `IsStone() || IsWoodenDoor() || IsEnemy()`)+ `game.py` 的
|
||
`SetHPTargetBoard`(:829,换 VID 先 `ResetTargetBoard` + `SetEnemyVID`)/ `CloseTargetBoardIfDifferent`
|
||
(:837)/ `CloseTargetBoard`(:841)/ `SetPCTargetBoard` 的 `LCONTROL` 悄悄话捷径(:804);
|
||
`classify(target_exists, is_dead, is_pc, is_building, can_view_hp)` → `Action { CLOSE,
|
||
CLOSE_IF_DIFFERENT, SET_HP, NONE }`(实例缺失 → CLOSE、已死 → NONE 不动框、PC|Building →
|
||
CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)。`net_play._on_target_info(vid, hp%)` 由
|
||
「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、
|
||
`can_view = KIND_STONE 或 KIND_MONSTER`、SET_HP 时换 VID 先 `_target_vid = vid` +
|
||
`target_changed.emit` 再 `hud.set_target(名字, clamp_hp_pct(hp%))`。seam ⑫:poc 无 building /
|
||
wooden-door / stone-door 实体类型(`is_building` 恒 false、木门分类缺);`target_info` 由 C++
|
||
`m2_client.cpp:3129/3633` 从 EntityStore 抽出,不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词
|
||
`whisper_on_ctrl_click` 已备、`net_play` 暂不接(无 whisper dialog 钩子)~~ 增量 122 已接线
|
||
(`net_play._on_pick` KIND_PC 分支轮询 `whisper_on_ctrl_click` → `whisper_requested` →
|
||
`game_scene` → `chat.start_whisper`);seam ⑫ 余留:`is_building` 恒 false(门实体缺)、
|
||
poc 悄悄话 = 聊天输入预填非独立 whisper dialog 窗口。
|
||
**增量 116 完成 `OnRender` Picking 块分派 + 悬停角色强显名字**:`project/pick_show.gd` 照抄
|
||
`game.py GameWindow.OnRender()` 的「Picking」块(:1519)—— `wndMgr.IsPickedWindow` 门(:1533)→
|
||
`chr.Pick()` 命中角色 `ShowCharacterTextTail`(:1535/:1537)、`targetBoard.GetTargetVID()`
|
||
`ShowCharacterTextTail`(:1539)、`__IsShowName()` 分叉(非总显名字 → `item.Pick()` :1543、
|
||
总显名字 → `ShowAllTextTail` + `textTail.Pick()` :1552/:1554)、末尾 `SelectItemName`(:1560);
|
||
`forced_name_vids(is_picked_window, pick_char_vid, target_vid)` 收集 `[hover, target]` 去重。
|
||
poc 侧仿增量 113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`:
|
||
`net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点可见性)由 `player_controller`
|
||
`_update_hover_cursor()` 的射线悬停拾取喂入(`hover_entity_changed` 信号,压 UI / 悬停地面物品
|
||
→ emit 0),`game_scene` 连 `pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字
|
||
尾标 y = chat 气泡 y − 17px(`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail`
|
||
屏幕空间去重叠排布仍缺(本增量只收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced,
|
||
靠 `player_controller` 压 UI / 悬停物品时置 0 部分等效 `IsPickedWindow` 门。
|
||
**增量 117 收口 seam ⑨(名字尾标随聊天气泡上移 17px)**:`project/name_tail_layout.gd` 照抄
|
||
`CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)—— 角色名字尾标投影完屏幕
|
||
坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag` TRUE
|
||
(:112,= `RegisterChatTail` 而非 `RegisterInfoTail`)→ `pTextTail->y = pChatTail->y - 17.0f`
|
||
(:114)。`name_follows_bubble(has_chat_tail, chat_name_flag)` / `name_tail_screen_y`(参考端
|
||
字面 `bubble_y − 17`)/ `name_tail_world_y`(poc 3D:屏幕 y 向下 −17px → 世界 y 向上
|
||
`bubble_world_y + 17*pixel_size`)。poc 侧 `net_world._reposition_name_tag(n)` 由
|
||
`_apply_chat_tail` / `_expire_chat_tails` 调用,冒「聊天」气泡时名字 `Label3D` 局部 y 顶到
|
||
「`BUBBLE_TAG_Y` 2.35 + 17*pixel_size」、气泡消失或只是「信息」尾标(`bNameFlag` FALSE)时
|
||
回 `NAME_TAG_DEFAULT_Y` 2.1。
|
||
**增量 119 收口 seam ⑦(子标签随名字尾标纵向位移)**:`CPythonTextTail::ArrangeTextTail`
|
||
(`PythonTextTail.cpp:151`)里逐条角色名字尾标(:211)的公会名(:228,`pTextTail->y -
|
||
c_fyGuildNamePosition` 15px 上方)/ 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)
|
||
三个子标签屏幕 y 全部锚在 `pTextTail->y` 上 —— 而这个 y 已被 `UpdateShowingTextTail :114`
|
||
顶过 −17。`name_tail_layout.gd` 增 `name_tail_lift_delta`(名字尾标相对默认 y 的净位移,
|
||
follows 时非 0)/ `subtag_world_y`(`default_subtag_y + lift_delta`);`net_world`
|
||
把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为 `SUBTAG_DEFAULT_Y` 常量
|
||
(2.34 / 1.86 / 1.74),`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签,
|
||
气泡过期 / 「信息」尾标时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版
|
||
(公会名上方 15px、称号 / 等级同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个
|
||
并存 `Label3D`(非参考单尾标链表)。
|
||
**增量 120 收口 seam ⑩(item 尾标去重叠 + `isIn`)**:`CPythonTextTail::ArrangeTextTail`
|
||
(`PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它
|
||
每条比 `isIn`(:489-507,标准 AABB 含边界),命中就把「自己」顶到对方框底下
|
||
`pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(`yTemp = 5`),内层从头重跑、
|
||
`LimitCount >= 20` 封顶。新增第 29 个 canonical `project/text_tail_arrange.gd`:
|
||
`boxes_overlap`(`isIn`)/ `box_*` 四边(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`)
|
||
/ `arrange_item_tails(tails) -> Array`(外层逐条 / 内层比对 / 命中顶到框底下 + `PUSH_GAP` 5 /
|
||
从头重跑 / `MAX_ITERS` 20 封顶)。poc 侧 `project/ui/ground_items.gd._arrange_tags` 每帧把所有
|
||
掉落名条世界锚点 `cam.unproject_position` 到屏幕、按 `文本字符数 * 7px` 估宽、单行 15px 估高
|
||
组框,跑 `arrange_item_tails`,把被下推的屏幕 Δy 按 `Label3D.pixel_size` 折回世界局部 y
|
||
(`TAG_BASE_Y − Δpx*pixel_size`),可见名条 < 2 直接回默认;`game_scene` 把相机 getter 传进
|
||
`ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs 参考 `GetTextSize` 精确像素~~
|
||
(增量 123 已换真实字体度量 `TextMetrics.label_width`);~~单行高仍按 `TAG_LINE_H_PX` 15px 估~~
|
||
(增量 124 已换 `TextMetrics.label_height` = `GetTextSize` 的 h,见下);
|
||
只对掉落 item 名条做(与参考 `m_ItemTextTailList` / `m_CharacterTextTailList` 分表一致)。
|
||
**增量 121 深化 seam ⑦(子标签逐像素水平排版)**:`CPythonTextTail::ArrangeTextTail` 的
|
||
`m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态
|
||
(:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)),称号 `x - iNameWidth/2 - fxAdd`
|
||
(:258,居中对齐)、等级有称号时再 `- iTitleWidth`(:282,右对齐)否则 `x - iNameWidth/2 - 4`
|
||
(:312)、名字本身 `x + fxAdd`(:320);公会名的水平锚点参考端仅在「有公会徽记 `pMarkInstance`」
|
||
时(:220)才改写成 `x + markW/2 + 1.5`(:228)。`name_tail_layout.gd`(第 28 个 canonical)加
|
||
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
|
||
`subtag_world_x`(屏幕像素 → billboard 本地 x,按 `Label3D.pixel_size` 折世界)。`net_world`
|
||
新增 `_layout_subtags_x(n)`(在 `_apply_text_tail_to` 尾部):名字 / `TitleTag` / `LevelTag` 各设
|
||
`.position.x`,`GuildTag.position.x = 0`(poc 无徽记 → 参考端 :220 此路不改写);文本宽按
|
||
`字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`;增量 123 已换真实字体度量)。与增量 117/119 的纵向
|
||
位移解耦(前者写 `.position.y`、本增量写 `.position.x`)。剩余 seam ⑦:无公会徽记 mark 实体、
|
||
~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
|
||
**增量 123 收口 seam ⑦ / ⑩(文本宽换真实字体度量 `GetTextSize`)**:`ArrangeTextTail` 里
|
||
`iNameWidth` / `iTitleWidth` / `iLevelWidth`(`PythonTextTail.cpp:225/233/240/267/297`)与掉落
|
||
名条框 `xSize`(:733/:748/:809)参考端全部来自 `CGraphicTextInstance::GetTextSize(&w, &h)`。
|
||
新增第 30 个 canonical `project/text_metrics.gd`:`text_size(font, text, font_size)` =
|
||
`Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(空 / null / 非法字号 →
|
||
`Vector2.ZERO`)、`width_of(font, text, font_size, fallback_char_px=7.0)`(量得正宽用精确像素,
|
||
否则回退 `len * fallback_char_px`)、`label_width(label: Label3D, fallback_char_px=7.0)`
|
||
(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,`null` → 0)。`net_world._layout_subtags_x`
|
||
的 `name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX`
|
||
换成 `TextMetrics.label_width(label, *_CHAR_W_PX)`;`SUBTAG_CHAR_W_PX` / `TAG_CHAR_W_PX = 7.0`
|
||
常量保留作无字体(headless / 未配主题)兜底。`name_tail_layout.gd` / `text_tail_arrange.gd`
|
||
收宽度作入参、排布公式(增量 119/120/121)不动。测试 `text_metrics_test.gd`,回归 70 全 PASS。
|
||
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:名条单行高
|
||
仍按 15px 估、只对掉落 item 名条做。
|
||
**增量 124 收口 seam ⑦ / ⑩ 末尾(单行文本高换真实字体度量 `GetTextSize` 的 h)**:去重叠
|
||
用的 item 尾标框 `yEnd = ySize`(`PythonTextTail.cpp:751/:812`),`ySize` 与 `xSize` 同样来自
|
||
`CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)—— 单行文本的 `ySize` ≈ 字体
|
||
行高(ascent + descent),与字形无关,`Font.get_string_size(...).y` 是 1:1 对应物。`text_metrics.gd`
|
||
增 `const FALLBACK_LINE_H_PX := 15.0`(=旧 `ground_items.TAG_LINE_H_PX`)、`height_of(font,
|
||
text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素;否则回退)、
|
||
`label_height(label: Label3D, fallback_line_px=15.0)`(`label.font ?? ThemeDB.fallback_font` +
|
||
`label.font_size`,`null` → 0)。`ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX`
|
||
换成 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX = 15.0` 常量保留
|
||
作无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 收框高作入参、
|
||
排布公式不动。测试扩 `text_metrics_test.gd`「5. height_of / label_height」段,回归 70 全 PASS。
|
||
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷;~~剩余 seam ⑩:掉落名条
|
||
owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,:733-736,poc 仍按单行
|
||
`"name (owner)"` 处理)~~ 增量 125 已把去重叠框度量 1:1、只对掉落 item 名条做。
|
||
**增量 125 收口 seam ⑩ 末尾(owner 后缀框度量:owner 独立度量 + `yEnd += yOwnerSize + 4`)**:
|
||
参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)—— 带 owner 的
|
||
掉落名条,owner 后缀挂成独立 `CGraphicTextInstance`(文本 = `c_szName + strOwnership`,
|
||
`IDS_POSSESSIVE_MORPHENE` 为空取字面 `"'s"`;黄 1,1,0 / CENTER),与名字实例分开
|
||
`GetTextSize(&xOwnerSize, &yOwnerSize)`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、
|
||
`xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737);
|
||
owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个
|
||
canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`:
|
||
`owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0),
|
||
name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX(name/2+2, owner/2+1)`,
|
||
框 x 两边对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical)
|
||
加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` →
|
||
字符数 * `fallback_char_px` / `fallback_line_px`,空串 → 0)。`ground_items._on_added` 存
|
||
`item_name` / `item_owner` meta,`_arrange_tags` 把「名字子串」「owner + `'s` 子串」分别
|
||
度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是单个合并 `"name (owner)"` billboard
|
||
`Label3D`(seam ⑩ 记录在案的折中)。测试扩 `text_tail_arrange_test.gd`「5. item_box_with_owner」
|
||
段 / `text_metrics_test.gd`「6. string_*_like」段 / `p2b_test.gd`(`FakeClient.add_ground`
|
||
加可选 owner 入参 + 带 owner 掉落被顶 `(15+15+4+5)*0.005` 段),回归 70 全 PASS(无新增
|
||
测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接
|
||
`ApplicationStringTable` 的本地化语素~~ 增量 126 已收口(见下),只对掉落 item 名条做。
|
||
**增量 126 收口 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
|
||
接本地化语素)**:参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`)
|
||
+ `ArrangeTextTail`(:191-207)—— owner 非空时新建 `pOwnerTextInstance`(:715)、文本
|
||
= `c_szName + strOwnership`(:723;`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)
|
||
== "" ? "'s" : ...`,:722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、
|
||
`SetColor(1.0f, 1.0f, 0.0f)` 黄(:729);排布 `pOwnerTextInstance->SetPosition(x, y, z)`(:195)
|
||
而名字实例 `pTextInstance->SetPosition(x, y + 15.0f, z)`(:200);owner 为空时
|
||
`CGraphicTextInstance::Delete(pOwnerTextInstance)`(:743)。`ground_items`:drop 节点下加第二个
|
||
`Label3D "owner_tag"`(`horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate =
|
||
Color(1,1,0)` / billboard / `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),
|
||
`_apply_owner_tag(node, owner)` 建 / `remove_child + queue_free` 删;名字 `tag` 只放
|
||
`_name_for(vnum)`(`_tag_text` 随之删除,不再拼 `"name (owner)"`)。新增
|
||
`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)`:有 `owner_tag` 时
|
||
`owner_tag.position.y = TAG_BASE_Y - d_px*px`、`tag.position.y = TAG_BASE_Y - (d_px + 15)*px`;
|
||
无则 `tag.position.y = TAG_BASE_Y - d_px*px`(`_arrange_tags` 的三处落点——behind-camera /
|
||
单条 / 多条排布——都改走它)。`setup()` 加可选 `possessive := ""`:`_possessive = possessive
|
||
if possessive != "" else OWNER_POSSESSIVE`(1:1 对齐 :722 的 `== "" ? "'s" :` 语义),
|
||
`game_scene` 用 `EterLocale` 的 `POSSESSIVE_MORPHENE` 键解析后注入(poc 的 `locale/*.txt`
|
||
无此键 → 回落 `"'s"`,与 40250 西文 `UserInterface.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"`
|
||
一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner`(`yEnd += ownerH + 4`)不变。
|
||
测试扩 `p2b_test.gd`(drop_302 名字实例文本 `"掉落28"`、`owner_tag` 文本 `"Alice's"` /
|
||
`HORIZONTAL_ALIGNMENT_CENTER` / `Color(1,1,0)` / 落基点,名字实例下移 `15 * pixel_size`;
|
||
无 owner 的 drop_301 不建 `owner_tag`;drop_401 用 `setup(possessive="@")` → `owner_tag`
|
||
文本 `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余 seam ⑩:owner 行颜色语义固定黄
|
||
(参考名字实例取 `pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、
|
||
去重叠只对掉落 item 名条做。
|
||
4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性
|
||
`ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]** —— **未接**。
|
||
|
||
---
|
||
|
||
## 6. P1/P2:模型、动画和特效
|
||
|
||
### 6.1 本地角色先显示胶囊占位体
|
||
|
||
**参考**:参考端**没有占位体**。加载期由 Python 的加载幕布盖住:
|
||
`PHASE_WINDOW_LOAD` 的 `LoadData(lX, lY)`(`REF/.../PythonNetworkStreamPhaseLoading.cpp:211`)
|
||
在 `introloading.py` 里显示加载画面;真正的主角实例由
|
||
`GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 走 §0.3 的合流创建;
|
||
`GameWindow.Open()`(`game.py:137`)在 `PHASE_GAME` 才 `Show()`。
|
||
|
||
**当前**:`game_scene.gd:136` 先建 Capsule,`:811` 再换真模型。
|
||
|
||
**修改**
|
||
|
||
1. 删除 `game_scene.gd:136` 与 `:811` 的 Capsule 占位路径。
|
||
2. 主角节点只在收到 `Spawn(main_vid)`(合流后)时创建一次,位置/朝向直接取包内值。
|
||
3. 在此之前保持 `introloading` 等价的加载幕布(`POC/project/ui` 下已有 `_loading` 层)覆盖,
|
||
与 `GameWindow.Open()` 的 `Show()` 时机对齐(§2.6 第 2 步)。
|
||
4. 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。
|
||
|
||
---
|
||
|
||
### 6.2 SpeedTree 仍是程序化代理
|
||
|
||
**参考**:`REF/SpeedTreeLib/`(`SpeedTreeWrapper.{h,cpp}` / `SpeedTreeForest*.cpp` /
|
||
`SpeedTreeMaterial.h` / `SpeedGrass*`),核心 API:
|
||
```
|
||
LoadTree(pszSptFile, pbBlock, uiBlockSize, nSeed=1, fSize=-1, fSizeVariance=-1) // .spt
|
||
RenderBranches() / RenderFronds() / RenderLeaves() / RenderBillboards() // 四类几何
|
||
GetLeafMaterial() / GetLeafLightingAdjustment()
|
||
SetWindStrength(float)
|
||
```
|
||
地图侧:`CArea::__SetObjectInstance_SetTree` → `prt::PROPERTY_TYPE_TREE`,
|
||
属性 `TPropertyTree{strName, strFileName, fSize, fVariance}`(`REF/GameLib/MapType.h:47`);
|
||
风强度来自 `TEnvironmentData.fWindStrength / fWindRandom`(`MapType.h:154`)。
|
||
|
||
**当前**:`POC/extension/src/tree_placeholder.cpp:190` 只嗅探贴图 + proxy 几何。
|
||
|
||
**修改**
|
||
|
||
1. 这是**独立的大工作量项**:需要实现 `.spt` 解析器(SpeedTree 5 二进制),产出
|
||
branches / fronds / leaves / billboards 四组几何与各自材质。
|
||
2. 在 `.spt` 解析落地之前,`tree_placeholder.cpp` 应至少:
|
||
- 读 `TPropertyTree` 的 `fSize` / `fVariance` 并按 `nSeed` 做确定性缩放(当前缺);
|
||
- 把风强度接到 `TEnvironmentData.fWindStrength / fWindRandom`(`.msenv`,§7.5)。
|
||
3. 在 `PARITY-GAP.md:77` 标注为「参考模块未移植」,不要按截图反推参数。
|
||
|
||
---
|
||
|
||
### 6.3 EffectLib 的 `.mde` 已接入,仍是渲染子集
|
||
|
||
**参考**:`CEffectData`(`REF/EffectLib/EffectData.h:9`)一个 `.mse` 由**三类元素 + 声音**组成:
|
||
```
|
||
TParticleVector m_ParticleVector; // CParticleSystemData —— 粒子系统
|
||
TMeshVector m_MeshVector; // CEffectMeshScript —— .mde 网格特效
|
||
TLightVector m_LightVector; // CLightData —— SimpleLight
|
||
NSound::TSoundInstanceVector m_SoundInstanceVector;
|
||
float m_fBoundingSphereRadius; D3DXVECTOR3 m_v3BoundingSpherePosition;
|
||
```
|
||
- `CLightData`(`REF/EffectLib/SimpleLightData.h`):
|
||
`m_fMaxRange / m_fDuration / m_cAmbient / m_cDiffuse / m_bLoopFlag /
|
||
m_fAttenuation0 / m_fAttenuation1 / m_fAttenuation2`,`GetRange(fTime, &range)` 是时间曲线。
|
||
- `CParticleProperty`(`REF/EffectLib/ParticleProperty.h:12`):
|
||
`ERotationType{NONE, TIME_EVENT, CW, CCW, RANDOM_DIRECTION}`、
|
||
`ETextureAnimationType{NONE, CW, CCW, RANDOM_FRAME, RANDOM_DIRECTION}`,
|
||
多贴图(`InsertTexture`)+ `GetTextureAnimationFrameCount()`。
|
||
- `CEmitterProperty`(`REF/EffectLib/EmitterProperty.h:9`):
|
||
`EmitterShape{POINT, ELLIPSE, SQUARE, SPHERE}`、
|
||
`EmitterAdvancedType{FREE, OUTER, INNER}`、`isEmitFromEdge()`、
|
||
`GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount`,
|
||
以及一组时间曲线 `GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)`。
|
||
|
||
**当前(增量 100)**:Particle 已映射到 `GPUParticles3D`,首个 `.dds` 通过 `UiAssets` + 原生
|
||
DDS 解码加载;`POC/project/fx/mde.gd` 按 `EffectMesh.cpp` 读取 `EffectData` v1 / `MDEData002`
|
||
v2,展开索引三角形、翻转 UV、厘米转米;`effect_player.gd` 已构建 `ArrayMesh`,播放几何帧,
|
||
并接入 `.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;增量 65
|
||
又按 `SimpleLightData.cpp` / `SimpleLightInstance.cpp` 接入 Light 组的范围曲线、生命周期、循环
|
||
和位置更新,并为粒子多贴图接入帧延迟 / 顺逆序、旋转类型 `NONE/TIME_EVENT/CW/CCW`。增量 66
|
||
再按 `EffectElementBase.cpp:5-60` 接入直接段和二次 Bezier 位置曲线,让粒子、网格和 Light
|
||
共用同一边界钳制 / 插值逻辑;同时接入 `EmitterEmitFromEdgeFlag` 的球体边缘发射映射,并保存
|
||
`EmitterAdvancedType` 元数据。增量 100 对齐缺省 `CycleLength=0.05`,将有限 cycle / LoopCount
|
||
转为 CPU 发射停止门控,重播时复位粒子时钟,并保持独立的粒子寿命;OUTER / INNER 当前使用明确
|
||
标记的 `spread=180` 宽方向 fallback,不能当成参考端按粒子位置计算的精确径向速度。
|
||
|
||
**修改**
|
||
|
||
1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把
|
||
`effect_player.gd:134` 的程序化渐变换掉。
|
||
2. `.mde` 网格特效的基础读取和帧播放已完成;后续仍需把 `CEffectMeshScript` 的全部纹理帧 / 活跃
|
||
状态语义逐项对齐,并补真实资源视觉验收。当前实现见 `POC/project/fx/mde.gd`、
|
||
`POC/project/fx/effect_player.gd`。
|
||
3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`,
|
||
`range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。
|
||
增量 65 已完成这一可离线验证子集;仍需真实效果资源逐项校准和完整节点销毁 / 重建验收。
|
||
4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 /
|
||
emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。
|
||
增量 65 已完成基础多贴图帧延迟 / 顺逆序和旋转类型;增量 66 已完成 MovingType 直接 /
|
||
Bezier 段、球体 edge 映射和 advanced type 元数据;增量 100 已补齐缺省 cycle、有限 LoopCount
|
||
发射停止、重播复位和粒子寿命隔离;随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 精确
|
||
径向速度、每周期 emission residue / 时间曲线重置仍待补齐。
|
||
5. 骨骼挂点:`.mse` 挂到角色骨骼由 `CActorInstance::AttachEffect`
|
||
**[待读参考:`REF/GameLib/ActorInstanceAttach.cpp`]** 驱动,接到本工程的 bone attach。
|
||
|
||
---
|
||
|
||
### 6.4 GR2 运行时不是完整 ActorInstance
|
||
|
||
**参考**
|
||
|
||
- 动作模式枚举 `CRaceMotionData::EMotionMode`(`REF/GameLib/RaceMotionData.h:33`):
|
||
```
|
||
MODE_RESERVED, MODE_GENERAL, MODE_ONEHAND_SWORD, MODE_TWOHAND_SWORD,
|
||
MODE_DUALHAND_SWORD, MODE_BOW, MODE_FAN, MODE_BELL, MODE_FISHING,
|
||
MODE_HORSE, MODE_HORSE_ONEHAND_SWORD, MODE_HORSE_TWOHAND_SWORD,
|
||
MODE_HORSE_DUALHAND_SWORD, MODE_HORSE_BOW, MODE_HORSE_FAN, MODE_HORSE_BELL,
|
||
MODE_WEDDING_DRESS, MODE_MAX_NUM
|
||
```
|
||
- 选择逻辑 `CInstanceBase::RefreshState`(`REF/UserInterface/InstanceBase.cpp:2851`),
|
||
**顺序即优先级**:
|
||
```
|
||
IsPoly() → MODE_GENERAL
|
||
IsWearingDress() → MODE_WEDDING_DRESS
|
||
IsHoldingPickAxe() → 骑马 ? MODE_HORSE : MODE_GENERAL
|
||
ITEM_TYPE_ROD → 骑马 ? MODE_HORSE : MODE_FISHING
|
||
骑马: WEAPON_SWORD→HORSE_ONEHAND_SWORD, TWO_HANDED→HORSE_TWOHAND_SWORD,
|
||
DAGGER→HORSE_DUALHAND_SWORD, FAN→HORSE_FAN, BELL→HORSE_BELL,
|
||
BOW→HORSE_BOW, default→MODE_HORSE
|
||
徒步: WEAPON_SWORD→ONEHAND_SWORD, TWO_HANDED→TWOHAND_SWORD,
|
||
DAGGER→DUALHAND_SWORD, BOW→MODE_BOW, FAN→MODE_FAN, BELL→MODE_BELL,
|
||
WEAPON_ARROW/default→MODE_GENERAL
|
||
最后: isLoop ? InterceptLoopMotion(idx) : InterceptOnceMotion(idx); RefreshActorInstance();
|
||
```
|
||
`SetMotionMode(m)`(`REF/GameLib/ActorInstanceMotion.cpp:247`):非法值回落 `MODE_GENERAL`。
|
||
- 动作查表:`m_pkCurRaceData->GetMotionKey(m_wcurMotionMode, dwMotion, &dwMotionKey)`
|
||
(`ActorInstanceMotion.cpp:306/345/404`)——**动作号必须带 motion mode 一起查**。
|
||
- 技能动作:`CInstanceBase::NEW_UseSkill(uSkill, uMot, uMotLoopCount, isMovingSkill)`
|
||
(`InstanceBaseBattle.cpp:310`)
|
||
```
|
||
IsDead/IsStun/IsKnockDown → false
|
||
isMovingSkill ? (未走则 StartWalking, m_isGoing=TRUE) : (在走则 EndWalking, m_isGoing=FALSE)
|
||
SetAdvancingRotation(GetTargetRotation())
|
||
InterceptOnceMotion(CRaceMotionData::NAME_SKILL + uMot, 0.1f, uSkill, 1.0f)
|
||
__OnUseSkill(uMot, uMotLoopCount, isMovingSkill)
|
||
uMotLoopCount > 0 → SetMotionLoopCount(uMotLoopCount)
|
||
```
|
||
- 动作事件类型 `EMotionEventType`(`RaceMotionData.h:160`):
|
||
`NONE, EFFECT, SCREEN_WAVING, SCREEN_FLASHING, SPECIAL_ATTACKING, SOUND, FLY,
|
||
CHARACTER_SHOW, CHARACTER_HIDE, WARP, EFFECT_TO_TARGET`。
|
||
- 动作数据接口(`RaceMotionData.h:216`):
|
||
`GetMotionDuration / IsAccumulationMotion / GetAccumulationPosition /
|
||
IsComboInputTimeData / GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime /
|
||
isAttackingMotion / GetMotionAttackDataPointer / HasSplashMotionEvent /
|
||
IsCancelEnableSkill / IsLoopMotion / GetLoopStartTime / GetLoopEndTime /
|
||
GetMotionEventDataCount / GetEventType / GetEventStartTime / GetSoundInstanceVectorPointer`。
|
||
|
||
**当前**:无 motion mode 概念;loop / interrupt / accumulation / 动作保留未实现。
|
||
|
||
**修改**
|
||
|
||
1. `metin2_model` / `metin2_anim` 引入 `motion_mode`,`.msm` 解析出
|
||
「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表
|
||
(`CRaceData::TMotionModeDataMap` / `TMotionVectorMap`,`REF/GameLib/RaceData.h:50`)。
|
||
2. 实现 `refresh_state()`:**逐行**照抄上面的优先级链,输入是
|
||
`is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting`。
|
||
3. 播放接口对齐三种:`InterceptLoopMotion` / `InterceptOnceMotion` / `SetLoopMotion`,
|
||
带 `fBlendTime`(技能固定 `0.1f`)与 `fSpeedRatio`。
|
||
4. `accumulation motion`(位移由动作数据驱动)与 `SetMotionLoopCount` 补上。
|
||
5. 击退 / 倒地 / 起身 / 死亡动作:按 `NAME_*` 动作名枚举(`RaceMotionData.h:40-160`)接入。
|
||
6. 武器轨迹 `CWeaponTrace`(`REF/GameLib/WeaponTrace.{h,cpp}`)单列一项。
|
||
|
||
---
|
||
|
||
### 6.5 武器和特效骨骼挂点不完整
|
||
|
||
**参考**:`CRaceData::EParts`(`REF/GameLib/RaceData.h:16`)只有 5 个:
|
||
```
|
||
PART_MAIN, PART_WEAPON, PART_HEAD, PART_WEAPON_LEFT, PART_HAIR, PART_MAX_NUM
|
||
```
|
||
挂点名表 `TAttachingBoneNameMap`(`RaceData.h:35`)与
|
||
`TModelData{ NRaceData::TAttachingDataVector AttachingDataVector; }`(`:63`)来自 `.msm`。
|
||
挂载实现在 `REF/GameLib/ActorInstanceAttach.cpp`。
|
||
|
||
**修改**
|
||
|
||
1. `PART_WEAPON_LEFT` 是参考里明确存在的部件——补左手武器 / 双持挂点
|
||
(本工程当前只有右手 + 部分盾牌)。
|
||
2. 挂点骨骼名从 `.msm` 的 `TAttachingBoneNameMap` 读,不再硬编码
|
||
(本工程当前硬编码 `equip_right_hand` 等)。
|
||
3. 坐骑挂点、骨骼特效(`EFFECT_TO_TARGET`)按 `ActorInstanceAttach.cpp` 移植。
|
||
4. 装备切换时清理旧挂点:参考在 `ChangeWeapon` 里 detach 后再 attach
|
||
**[待读参考:`InstanceBase.cpp` 的 `ChangeWeapon` / `__AttachWeapon`]**。
|
||
|
||
---
|
||
|
||
### 6.6 材质是固定功能近似
|
||
|
||
**参考**:材质开关来自 `.gr2` 的 `MaterialInfo`(本工程增量 30 已接 `alpha_blend / two_sided`);
|
||
多贴图 stage / color operation 由 `CGraphicThing` 的渲染路径决定
|
||
**[待读参考:`REF/EterGrnLib/Thing.cpp` 与 `Material.cpp` 的 stage 设置]**。
|
||
球面高光来自 `item_proto` 的 `bSpecular`(本工程已接 `specular/100`)。
|
||
|
||
**修改**
|
||
|
||
1. `m2_material.h:39` 的 `spec_power` 默认 0 保持——参考端确实由装备数据驱动;
|
||
缺的是**远端角色**也要按其 `armor/weapon` 的 proto 取 specular(当前只有本地主角)。
|
||
2. alpha-test ref 值、贴图 stage 数、color op 三项在 `EterGrnLib` 的渲染端读出后再定,
|
||
目前不要猜数值。
|
||
3. 透明排序:参考按 `CArea` 的 `m_ThingCloneInstaceVector` 与角色分批渲染;
|
||
Godot 侧对应 `render_priority` + `transparency` 的固定分层,先在文档记录映射再实现。
|
||
|
||
---
|
||
|
||
## 7. P1/P2:地图、环境和相机
|
||
|
||
### 7.1 静态地图只完整接入 Building / DungeonBlock
|
||
|
||
**参考**:`CArea`(`REF/GameLib/Area.{h,cpp}`)的对象只有 **5 种**
|
||
(`prt::PROPERTY_TYPE_*`,`Area.cpp:398-446`):
|
||
|
||
| 类型 | 属性结构(`REF/GameLib/MapType.h`) | 运行时 | 参考行 |
|
||
|---|---|---|---|
|
||
| `TREE` | `TPropertyTree{strName, strFileName, fSize, fVariance}` (:47) | `CSpeedTreeWrapper` | `Area.cpp:590` |
|
||
| `BUILDING` | `TPropertyBuilding{strName, strFileName, strAttributeDataFileName, isShadowFlag}` (:56) | `CGraphicThingInstance` + 属性/碰撞/高度 | `Area.cpp:615` |
|
||
| `EFFECT` | `TPropertyEffect{strName, strFileName}` (:64) | `CEffectManager` 实例 | `Area.cpp:531` |
|
||
| `AMBIENCE` | `TPropertyAmbience{strName, strPlayType, fPlayInterval, fPlayIntervalVariation, fMaxVolumeAreaPercentage, AmbienceSoundVector}` (:85) | `TAmbienceInstance` | `Area.cpp:661` |
|
||
| `DUNGEON_BLOCK` | `TPropertyDungeonBlock{strName, strFileName, strAttributeDataFileName}` (:96) | `CDungeonBlock` | `Area.cpp:695` |
|
||
|
||
对象静态数据 `CArea::TObjectData`(`Area.h:27`):
|
||
`Position(D3DXVECTOR3) / dwCRC / abyPortalID[PORTAL_ID_MAX_NUM] /
|
||
m_fYaw / m_fPitch / m_fRoll / m_fHeightBias / dwRange / fMaxVolumeAreaPercentage`。
|
||
旋转步进常量(`Area.h:13`):`ROTATION_STEP_COUNT = 24`、`ROTATION_STEP_AMOUNT = 15`,
|
||
`YAW/PITCH` 同。
|
||
|
||
Ambience 的三种播放型(`Area.cpp:678`):
|
||
`"ONCE" → UpdateOnceSound`、`"STEP" → UpdateStepSound`、`"LOOP" → UpdateLoopSound`;
|
||
位置 `z = Position.z + m_fHeightBias`,作用半径 `dwRange`。
|
||
|
||
`CArea::Refresh()`(`Area.cpp:383`)把对象分桶到
|
||
`m_TreeCloneInstaceVector / m_ThingCloneInstaceVector / m_AniThingCloneInstanceVector /
|
||
m_ShadowThingCloneInstaceVector / m_AmbienceCloneInstanceVector /
|
||
m_DungeonBlockCloneInstanceVector`;`IsMotionThing()` 的 building 会额外进动画桶并
|
||
`SetMotion(0)`;`isShadowFlag` 决定是否进阴影桶。
|
||
|
||
**当前**:`POC/extension/src/metin2_world.cpp:367` 只处理 Building / DungeonBlock。
|
||
|
||
**修改**
|
||
|
||
1. 补 `PROPERTY_TYPE_EFFECT`:按 `TPropertyEffect.strFileName` 注册 `.mse` 并在
|
||
对象位置创建常驻特效实例(旋转按 `Yaw/Pitch/Roll` 的 `D3DXMatrixRotationYawPitchRoll`)。
|
||
2. 补 `PROPERTY_TYPE_AMBIENCE`:三种播放型 + `dwRange` + `fMaxVolumeAreaPercentage`,
|
||
接 §9.4 的 ambience 声道。
|
||
3. Building 补 `isShadowFlag` 与 `IsMotionThing() → SetMotion(0)` 两条分桶规则。
|
||
4. `m_fHeightBias` 目前是否参与位置计算需核对;`abyPortalID` / portal / area 关系
|
||
**[待读参考:`REF/GameLib/Area.cpp` 中 `abyPortalID` 的消费点]**。
|
||
5. 对象 LOD 与透明排序按 `Refresh()` 的分桶来做,不要自定义分层。
|
||
|
||
---
|
||
|
||
### 7.2 Pack 资源读取没有统一
|
||
|
||
**参考**:`CEterPackManager`(`REF/EterPack/`)是唯一入口,
|
||
连 `CAccountConnector::__AuthState_RecvAuthSuccess`(`AccountConnector.cpp:465`)都要
|
||
`CEterPackManager::instance().DecryptPackIV(dwPanamaKey)`——**pack 的解密 IV 来自登录密钥**。
|
||
|
||
**当前**:`metin2_world.cpp:380`、`terrain_splat.cpp:133` 等仍直接
|
||
`AssetResolver::resolve()`,绕过 `AssetSource`/`PackMount`。
|
||
|
||
**修改**
|
||
|
||
1. 全量替换:`grep -rn "AssetResolver::resolve" POC/extension/src` 逐个改为
|
||
`AssetSource::open()`;`asset_io.cpp` 已经把 gr2/dds/map/proto 路由到 `FileAccess`,
|
||
把 world / terrain / tree / water / static object 补齐。
|
||
2. `AssetResolver` 的目录扫描(Phase 2b)改为 `AssetSource` 的统一枚举接口。
|
||
3. 新增 CI 门禁:`MT_ASSETS_PACK_ONLY=1` 时禁用散文件回退,跑一遍现有全部 GDScript 测试。
|
||
4. `DecryptPackIV(loginKey ^ clientKey[0..3])` 这条参考行为若目标服务端使用,需要在
|
||
`ClassicSession` 收到 `GC_AUTH_SUCCESS` 后同样调用——当前未实现,先记录。
|
||
|
||
---
|
||
|
||
### 7.3 地形 splat 仍非完整固定功能复现
|
||
|
||
**参考**:`REF/GameLib/MapOutdoor*.cpp`(`MapOutdoorRenderSTP.cpp` = 单贴图通道、
|
||
`MapOutdoorRenderHTP.cpp` = 高贴图通道、`MapOutdoorIndexBuffer.cpp` = patch 索引、
|
||
`MapOutdoorQuadtree.cpp` = LOD/剔除、`TerrainPatch.{h,cpp}`、`TerrainQuadtree.{h,cpp}`)。
|
||
Section/Cell 常量(`REF/GameLib/MapType.h:216`):
|
||
```
|
||
c_Section_xAttributeCellSize = 50 c_Section_yAttributeCellSize = 50
|
||
c_Section_xCellSize = 100 c_Section_yCellSize = 100
|
||
```
|
||
|
||
**修改**
|
||
|
||
1. 本条是**渲染端逐项对照**,应在 `PARITY-GAP.md` 建立子清单,每项列
|
||
「参考文件:函数 / 当前实现 / 差异」,包括:
|
||
alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、
|
||
patch 碰撞、baked shadow mask 叠加。
|
||
2. 在读完 `MapOutdoorRenderSTP.cpp` / `MapOutdoorRenderHTP.cpp` 之前不要调数值——
|
||
本文档不给具体参数。
|
||
3. 先落地的是**常量对齐**:把上面 4 个 Section/Cell 常量核对进
|
||
`POC/extension/src/terrain_splat.cpp`。
|
||
|
||
---
|
||
|
||
### 7.4 水面是序列纹理加近似深度
|
||
|
||
**参考**:`REF/GameLib/MapOutdoorWater.cpp`。
|
||
|
||
**修改**
|
||
|
||
1. 同 §7.3:先读 `MapOutdoorWater.cpp` 建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 /
|
||
blend·filter·wrap / 透明排序 / 水深标定),再改实现。
|
||
2. `PARITY-GAP.md` 里「浅水透明度未校准」的标记保留,直到参考端的水深公式读出为止。
|
||
|
||
---
|
||
|
||
### 7.5 `.msenv` 已完成解析与静态云层,完整环境渲染仍缺
|
||
|
||
**参考**:`TEnvironmentData`(`REF/GameLib/MapType.h:126`)的**完整字段表**:
|
||
|
||
```
|
||
// 光照
|
||
BOOL bDirLightsEnable[2]; // ENV_DIRLIGHT_BACKGROUND, ENV_DIRLIGHT_CHARACTER
|
||
D3DLIGHT8 DirLights[2]; // 背景光 / 角色光 分开!
|
||
D3DMATERIAL8 Material;
|
||
|
||
// 雾
|
||
BOOL bFogEnable; BOOL bDensityFog;
|
||
float m_fFogNearDistance; float m_fFogFarDistance;
|
||
D3DXCOLOR FogColor;
|
||
|
||
// 滤色
|
||
BOOL bFilteringEnable; D3DXCOLOR FilteringColor;
|
||
BYTE byFilteringAlphaSrc; BYTE byFilteringAlphaDest;
|
||
|
||
// 风
|
||
float fWindStrength; float fWindRandom;
|
||
|
||
// 天空盒
|
||
D3DXVECTOR3 v3SkyBoxScale; BOOL bSkyBoxTextureRenderMode;
|
||
BYTE bySkyBoxGradientLevelUpper; BYTE bySkyBoxGradientLevelLower;
|
||
std::string strSkyBoxFaceFileName[6]; // front/back/left/right/top/bottom
|
||
std::vector<TGradientColor> SkyBoxGradientColorVector;
|
||
|
||
// 云
|
||
D3DXVECTOR2 v2CloudScale; float fCloudHeight;
|
||
D3DXVECTOR2 v2CloudTextureScale; D3DXVECTOR2 v2CloudSpeed;
|
||
std::string strCloudTextureFileName; TGradientColor CloudGradientColor;
|
||
|
||
// 镜头光晕
|
||
BOOL bLensFlareEnable; D3DXCOLOR LensFlareBrightnessColor; float fLensFlareMaxBrightness;
|
||
BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFlareSize;
|
||
```
|
||
|
||
**关键差异**:参考端**背景光和角色光是两盏独立方向光**(`ENV_DIRLIGHT_BACKGROUND` /
|
||
`ENV_DIRLIGHT_CHARACTER`);本工程已在增量 69 用两个 `DirectionalLight3D` 和 layer/cull mask
|
||
分开作用于静态场景与角色。剩余差异集中在环境动态和 Godot 无直接等价的后处理。
|
||
|
||
**当前(增量 72)**:`formats/environment.*` 已按 `MapUtil.cpp:1-205` 读取参考缺省值、
|
||
`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`;
|
||
`environment_builder.cpp` 通过 `AssetResolver` 解析实际环境资源,并把真实云层贴图接入渐变天空的
|
||
`ProceduralSkyMaterial.sky_cover`,或接入 texture-mode sky shader 的有限云面;真实六面天空由
|
||
`sky` shader 按参考面顺序和 UV 渲染,空面回退渐变。双方向光已按 layer 1/2 分层,原始云层
|
||
scale / height / texture scale / speed 已映射到 shader,滤色 blend 因子、lens flare 等仍保存在
|
||
`WorldEnvironment` 元数据;同时
|
||
修正了 macOS 大小写敏感环境文件路径;树 proxy 使用参考风缺省 `0.2/0.0`,但尚未等价实现
|
||
SpeedTree 森林级风常量。`formats_map_test`(内联 + 真实 A1)、`environment_test`、`skybox_test`
|
||
和 `world_probe` 已通过。
|
||
|
||
**修改**
|
||
|
||
1. `formats/environment.*` 负责逐字段解析参考格式并保存原始值;当前已覆盖上表中的参考缺省值、
|
||
`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`。注意参考
|
||
`MapUtil.cpp` 的 `Environment_Load` 没有读取风参数 token,`fWindStrength=.2`、`fWindRandom=0`
|
||
是缺省值,不能凭经验添加不存在的键;`environment_builder.cpp` 目前只落实 Godot 能等价承载的
|
||
基础环境、静态云层、texture-mode 六面天空和云层组合首版,其余值通过元数据保留。
|
||
2. **已完成(增量 69)**:两盏方向光分别作用于「地形 / 水面 / 建筑 / 树」与「角色 / 装备 /
|
||
LOD」层——Godot 侧用 `Sun` / `CharacterLight` 两个 `DirectionalLight3D` + `cull_mask`;
|
||
静态几何固定 layer 1,`Metin2Model` 几何固定 layer 2,换图时清理 `CharacterLight`。
|
||
3. **已完成(增量 70)**:`bTextureRenderMode` 时,六面贴图按
|
||
`SkyBox.cpp` 的 front/back/left/right/top/bottom 顺序接入 `ShaderMaterial`;缺少的面使用
|
||
渐变回退,避免黑面。当前 `CapeDragonHead.msenv` 的五个非空面已由 `skybox_test.gd` 验证。
|
||
4. **已完成首版(增量 68 / 72)**:无 texture mode 的环境用
|
||
`strCloudTextureFileName` 接入 `ProceduralSkyMaterial.sky_cover`;texture mode 的环境用 sky
|
||
shader 接入有限云面,并按参考实现映射 `v2CloudScale`、`fCloudHeight`、
|
||
`v2CloudTextureScale`、`v2CloudSpeed` 和 `CloudGradientColor` 的颜色合成。真实 DawnMistWood
|
||
已验证五面天空、云层、UV 滚动参数和 shader 安装。云层渐变过渡、滤色、树 proxy 之外的
|
||
SpeedTree 森林级 shader 常量 / `fWindRandom` 语义仍待实现;不能把首版 shader 映射宣称为
|
||
参考端完整视觉等价。
|
||
5. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness /
|
||
bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。
|
||
6. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)原始值已解析并保存在元数据;仍需补上
|
||
Godot 端全屏后处理。注意参考端该版本 `CScreenFilter::SetEnable` 实际把 enable 置为 FALSE,
|
||
因此必须先按 `MapOutdoor.cpp` 的真实调用语义验收,不能仅凭 `Filter.Enable` 打开效果。
|
||
7. 昼夜插值:参考按 `TEnvironmentDataMap`(`MapType.h:187`,`DWORD → TEnvironmentData*`)
|
||
在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。
|
||
8. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。
|
||
|
||
---
|
||
|
||
### 7.6 相机不是固定基线机位
|
||
|
||
**参考**
|
||
|
||
- 默认机位(`POC/assets/root/game.py:58`,`GameWindow.Open()` 时
|
||
`app.SetCamera(cameraDistance, cameraPitch, cameraRotation, cameraHeight)`):
|
||
```python
|
||
cameraDistance = 1550.0 cameraPitch = 27.0
|
||
cameraRotation = 0.0 cameraHeight = 100.0
|
||
```
|
||
关窗时 `(cameraDistance, cameraPitch, cameraRotation, cameraHeight) = app.GetCamera()`
|
||
存回全局(`game.py:225`)——即**跨进出图保持**。
|
||
- 距离范围(`REF/UserInterface/CameraProcedure.cpp:10`):
|
||
```cpp
|
||
float CCamera::CAMERA_MIN_DISTANCE = 200.0f;
|
||
float CCamera::CAMERA_MAX_DISTANCE = 2500.0f;
|
||
```
|
||
- 相机模式(`REF/UserInterface/PythonApplicationCamera.cpp:23/48/57`):
|
||
`CAMERA_MODE_NORMAL`(跟随主角 + `GetTargetHeight()`)、
|
||
`CAMERA_MODE_STAND`(固定点)、`CAMERA_MODE_BLEND`(线性插值 zoom/pitch/rotation,
|
||
`BlendValueByLinear(elapsed, blendTime, from, to)`)。
|
||
`SetDefaultCamera()`(`:295`)回 NORMAL 并恢复 `m_DefaultCameraSetting`。
|
||
- 地形碰撞(`CameraProcedure.cpp:13 ProcessTerrainCollision`):
|
||
两条射线 `m_kTargetToCameraBottomRay` 与 `m_kCameraBottomToTerrainRay`,
|
||
命中即 `SetCameraState(CAMERA_STATE_CANTGODOWN)` 并把 eye 抬到
|
||
`碰撞点 + 2.0f * m_fTerrainCollisionRadius * m_v3Up`(只在抬高时才 `SetEye`)。
|
||
未命中则 `SetCameraState(CAMERA_STATE_NORMAL)`。
|
||
- 建筑碰撞(`:125 ProcessBuildingCollision`):
|
||
`fMoveAmountSmall = 2.0f` / `fMoveAmountLarge = 4.0f`,用
|
||
`CameraCollisionChecker` + `CollisionDynamicSphere` 逐对象判定。
|
||
- 键位(`POC/assets/root/game.py:340`):
|
||
`Q/E → RotateCamera(NEGATIVE/POSITIVE)`、`R → ZoomCamera(NEGATIVE)`、
|
||
`F → ZoomCamera(POSITIVE)`(Ctrl+F 是喂马)、`T/G → PitchCamera(NEGATIVE/POSITIVE)`,
|
||
松键一律 `CAMERA_STOP`;小键盘 4/6/8/2 + PGUP/PGDN 是 Movie 相机。
|
||
|
||
**当前**:`POC/project/game_camera.gd` 用脚本内 FOV/yaw/pitch/距离 + AABB 射线。
|
||
|
||
**修改**
|
||
|
||
1. 默认值改为 `distance 1550 / pitch 27 / rotation 0 / height 100`,
|
||
夹在 `[CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]`。
|
||
(§8.4 的「远/近视角」两个 radio 也要落在这个范围内。)
|
||
2. 实现三种模式 `NORMAL / STAND / BLEND`,`BLEND` 用线性插值三参数;
|
||
`SetDefaultCamera()` 恢复默认设置——这三者是 §5.1 的
|
||
`SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA` 标签的落点。
|
||
3. 地形碰撞按上面两条射线 + `2 * m_fTerrainCollisionRadius` 的抬高规则重写,
|
||
替换现有 AABB 射线。
|
||
4. 建筑碰撞用 `2.0f / 4.0f` 两个位移量的球体检测。
|
||
5. 相机参数跨进出图保持(`game.py:225` 的全局回写语义)。
|
||
6. 键位按上表补齐 Q/E/R/F/T/G 与松键停止。
|
||
|
||
---
|
||
|
||
## 8. P1/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 帮助窗口未实现
|
||
|
||
**状态**:🟡 开发中(首版落地,真服 / 视觉未验收)—— 批次 2026-09-02 / W3。
|
||
|
||
**参考**:`assets/root/uihelp.py`(`HelpWindow`,单页版 `ENABLE_HELP_MULTIPAGE = 0` →
|
||
`LoadDialogSinglePage()` 装 `UIScript/HelpWindow.py`)+ uiscript
|
||
`assets/uiscript/uiscript/helpwindow.py`(19 条 `help_XX` 浮动文本 + `taskbar_help_*`
|
||
贴纸标签 + `close_button`,文本取自 `uiScriptLocale.HELP_*`)。
|
||
入口:`game.py __PressHKey()` —— 非 Ctrl 时 `self.interface.OpenHelpWindow()`
|
||
(Ctrl+H 是 `/user_horse_ride`)。
|
||
|
||
> 注:本仓 `uihelp.py` 并非“136 行含键位/说明行”的版本;帮助行文本实际在
|
||
> `helpwindow.py` uiscript 里以 `uiScriptLocale.HELP_*` 引用,逐字值在
|
||
> `locale/locale/<lang>/locale_interface.txt`。故“逐字对齐 `uihelp.py`”按“逐字对齐
|
||
> `helpwindow.py` + `uiScriptLocale.HELP_*`(英文表)”落实。
|
||
|
||
**当前**:`system_menu_ui.gd` 的 `help_button` 已由占位提示改为打开 `help_ui.gd`。
|
||
|
||
**改动**
|
||
|
||
- 新增 `project/ui/help_ui.gd`:`ui.open_script("helpwindow.py")`(UiScript → UiBuild)
|
||
非模态开窗;`_relabel()` 把 `helpwindow.py` 解析出的 `<HELP_*>` / `<CLOSE>` 占位
|
||
(`UiManager` 未接 locale 回调时产生)用本工程 `Locale`(`en`)表 + 逐字英文兜底补回,
|
||
覆盖原脚本重名的两个 `help_02`(右键 / 中键相机说明);`open/close/toggle/is_open`、
|
||
`close_button` → `close`、ESC 由 `UiManager` 窗口栈统一关顶层。
|
||
- `project/ui/system_menu_ui.gd`:`help_button` → `close()` + `open_help()`;新增
|
||
`const HelpUI`、可注入的 `help_ui` 成员、`_ensure_help()`(懒建,复用菜单的
|
||
`ui/client/assets_root`)、`open_help()`、`toggle_help()`;`setup()` 追加可选第 6 参
|
||
`help_win`(旧 5 参调用不受影响)。
|
||
- `project/game_scene.gd`:`_unhandled_input` 加**单条** `elif` —— `KEY_H` 且
|
||
`not ctrl_pressed and not meta_pressed` → `system_menu_ui.toggle_help()`;`Ctrl+H`
|
||
未占用,仍留给 `/user_horse_ride`。
|
||
- 接口 / 数据结构变化:仅 `SystemMenuUI.setup()` 追加可选尾参;无网络 / EntityStore 改动。
|
||
|
||
**测试**
|
||
|
||
- 新增 `project/help_ui_test.gd`(headless SceneTree):helpwindow.py 装载、19 条帮助行
|
||
逐字绑定且非 `<KEY>` 占位、任务栏标签、`close_button` 文本 = `Close`、`rows()` 便捷器、
|
||
`close_button` 关窗、`open/toggle`、ESC 栈顶、`SystemMenuUI.open_help()` 懒建并打开。
|
||
- `godot --headless --path project --script res://help_ui_test.gd` → `PASS`(exit 0)。
|
||
- 回归:`system_menu_ui_test`、`gamescene_test`、`ui_test` 全 `PASS`;附加
|
||
`system_option_ui_test`、`char_status_ui_test` 亦 `PASS`。
|
||
- skip:本 worktree 无 `build/`,未跑 C++ `ctest`(本条无 C++ 改动)。
|
||
|
||
**验收**
|
||
|
||
- 真服 / 双账号 / Mac App:未验证(§8.3 为纯本地 UI,无服务端依赖)。
|
||
- 视觉:`helpwindow.py` 的 `.sub` / `help_stick.tga` 贴图未逐像素比对;字体描边
|
||
(`"outline": 1`)按 `ui.py` 默认,未单独标定。
|
||
|
||
---
|
||
|
||
### 8.4 系统设置部分只是保存数值
|
||
|
||
**参考**:`POC/assets/root/uisystemoption.py`(本工程增量 36 已按其绑定)。
|
||
其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。
|
||
|
||
**修改**
|
||
|
||
1. **CPU/GPU tiling**:参考行为是切换渲染路径,Godot 渲染器没有等价物。
|
||
保留控件但明确标注为「参考功能无 Godot 对应」,并在 `CLIENT-GAP.md` 记为
|
||
❌(超出移植范围),不要伪造行为。
|
||
2. **BGM 更换按钮**:参考对应 `uiselectmusic.py`(本仓已有)——
|
||
按 §8.2 迁移该窗口后启用按钮,接 `musicInfo.SaveLastPlayFieldMusic` /
|
||
`LoadLastPlayFieldMusic`(`POC/assets/root/musicinfo.py`)与
|
||
`game.py:2217 __PlayMusic(flag, filename)`。
|
||
3. **名字颜色 / 伤害数字 / 总是显示名字**:接到 §8.8 的 TextTail 渲染,
|
||
开关键名沿用现有 `user://system_option.cfg [gameopt]`。名字颜色本身在增量 110
|
||
已按 `GetNameColorIndex` 1:1 落地(`name_color.gd`);`constinfo.SET_CHRNAME_COLOR_INDEX`
|
||
对应的运行期开关是 `net_world.set_empire_name_mode(bool)`(默认关),尚未挂到选项 UI。
|
||
**增量 113 已完成「总是显示名字」1:1**:`name_show.gd`(`CPythonSystem::IsAlwaysShowName` /
|
||
`SetAlwaysShowNameFlag` 的 `iFlag == 1` 语义 + `game.py::__IsShowName` = `IsAlwaysShowName()
|
||
or ShowNameFlag(LALT)` + `CPythonTextTail::ShowAllTextTail` 的 `fDistanceFromPlayer < 3500.0f`
|
||
平面距裁剪 + `ShowCharacterTextTail` 的 `isShow / !IsGuildWall / CanPickInstance` 资格判定 +
|
||
`UpdateAllTextTail` 的 chat 尾标 `bNameFlag` 强显)。`game_option_ui.gd` 的
|
||
`always_show_name` radio 现经新信号 `display_option_changed` 驱动
|
||
`net_world.set_always_show_name()`;`net_play.target_changed` 驱动
|
||
`net_world.set_target_vid()`(选中目标始终强显名字)。
|
||
**增量 114 已接「伤害数字」radio** → `net_world.set_show_damage()`(见 §8.8)。
|
||
**增量 118 已接「名字色」radio(`name_color_normal` / `name_color_empire`)** →
|
||
`game_scene._on_display_option("name_color", v)` → `net_world.set_empire_name_mode(v == 1)`
|
||
(`uigameoption.__SetNameColorMode` → `constInfo.SET_CHRNAME_COLOR_INDEX` →
|
||
`chrmgr.SetEmpireNameMode`;算法本体增量 110 已 1:1),开局用
|
||
`display_value("name_color") == 1` 回填。
|
||
**增量 118 同时接「看他国玩家目标框」radio(`target_board_view` / `target_board_no_view`)** →
|
||
`net_play.set_view_other_empire_target(v == 1)`;见 §5.4 / §8.8。§8.4 三个显示 radio
|
||
+ LALT 至此全部接线完毕。见 C.5 增量 113 / 114 / 118、§8.6、§8.8。
|
||
4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是
|
||
`TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)——
|
||
改为按环境数据的近远距离设置,浓/中/淡对应三档缩放
|
||
**[待读参考:`uisystemoption.py` 的 `__SetFogLevel` 实参]**。
|
||
|
||
---
|
||
|
||
### 8.5 鼠标物品态和光标状态缺失
|
||
|
||
**参考**:光标枚举 `CPythonApplication::ECursorShapes`
|
||
(`REF/UserInterface/PythonApplication.h:63`):
|
||
```
|
||
NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, BUY, SELL,
|
||
CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, COUNT
|
||
```
|
||
资源(`REF/UserInterface/Cursors/`,Python 侧映射见 `mousemodule.py:100`):
|
||
```
|
||
NORMAL→cursor.sub ATTACK/TARGET→cursor_attack.sub TALK→cursor_talk.sub
|
||
CANT_GO→cursor_no.sub PICK→cursor_pick.sub DOOR→cursor_door.sub
|
||
CHAIR/MAGIC→cursor_chair.sub BUY→cursor_buy.sub SELL→cursor_sell.sub
|
||
CAMERA_ROTATE→cursor_camera_rotate.sub
|
||
HSIZE→cursor_hsize.sub VSIZE→cursor_vsize.sub HVSIZE→cursor_hvsize.sub
|
||
```
|
||
软件光标开关:`systemSetting.IsSoftwareCursor()`(`mousemodule.py:98`)。
|
||
`SetCursorNum` / `SetCursor` 的调用点:商店 BUY/SELL(§4.6)、
|
||
NPC 悬停 TALK、可攻击目标 ATTACK 等。
|
||
|
||
**当前(增量 56)**:新增 `cursor_manager.gd`,使用参考端的状态名,并把世界实体 / 地面物品
|
||
的首版悬停状态接入 `PlayerController`;`UiManager` 的窗口键盘焦点屏蔽已接入。当前没有
|
||
`cursor.sub` 等资源时使用系统光标,不伪称为视觉 1:1;商店 BUY / SELL 模式已切换对应状态。
|
||
|
||
**剩余**:不可达 CANT_GO、DOOR / CHAIR / MAGIC / camera resize 等完整状态,
|
||
以及 `.sub` 光标贴图和软件光标开关仍需逐项实现 / 验收。
|
||
|
||
---
|
||
|
||
### 8.6 键位配置不完整
|
||
|
||
**参考补充**:`ClientVS22/source/` 不含完整 Python 键位脚本;下表来自本仓迁移资产
|
||
`POC/assets/root/game.py:309-408`,只能作为历史脚本行为依据,仍需用当前目标构建和实际
|
||
输入验收确认。逐条:
|
||
|
||
```
|
||
数字 1..9 → __PressNumKey(n)
|
||
Ctrl+1..9 → chrmgr.SetEmoticon(-1, n-1) + net.SendEmoticon(n-1)
|
||
否则 1..4 → pressNumber(n-1) (§4.8)
|
||
F1..F4 → __PressQuickSlot(4..7)
|
||
LALT 按/放 → ShowName() / HideName()
|
||
LCONTROL → ShowMouseImage() / HideMouseImage()
|
||
SYSRQ → SaveScreen()
|
||
SPACE 按/放 → StartAttack() / EndAttack()
|
||
方向键/WASD → MoveUp/Down/Left/Right,松键 StopUp/Down/Left/Right
|
||
D 键:Ctrl+D → 龙魂 deck 1;否则 MoveRight
|
||
Q / E → RotateCamera(NEGATIVE / POSITIVE),松键 CAMERA_STOP
|
||
Ctrl+Q → 切换所有 QuestButton 显示
|
||
R → ZoomCamera(NEGATIVE),松键 STOP
|
||
F → ZoomCamera(POSITIVE);Ctrl+F → "/user_horse_feed"
|
||
T → PitchCamera(NEGATIVE),松键 STOP
|
||
G → 若 ShowNameFlag 则 ToggleGuildWindow(),否则 PitchCamera(POSITIVE)
|
||
Ctrl+G → "/ride";松键 CAMERA_STOP
|
||
NUMPAD 9/4/6/8/2, PGUP, PGDN → Movie 相机
|
||
GRAVE / Z → PickUpItem()
|
||
C → Ctrl+C → 龙魂 deck 0;否则 ToggleCharacterWindow("STATUS")
|
||
V → ToggleCharacterWindow("SKILL")
|
||
N → ToggleCharacterWindow("QUEST")
|
||
B → Ctrl+B → "/user_horse_back";否则 ToggleCharacterWindow("EMOTICON")
|
||
H → Ctrl+H → "/user_horse_ride";否则 OpenHelpWindow() (§8.3)
|
||
J → Ctrl+J → 骑乘中 "/unmount",否则在背包里找 71114/71116/71118/71120 使用
|
||
I → ToggleInventoryWindow()
|
||
O → ToggleDragonSoulWindowWithNoInfo()
|
||
M → interface.PressMKey()
|
||
ADD/SUBTRACT→ MiniMapScaleUp / MiniMapScaleDown
|
||
L → ToggleChatLogWindow()
|
||
COMMA → ShowConsole()
|
||
LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode() (§4.8)
|
||
```
|
||
|
||
**当前(增量 53)**:`game_scene.gd` 已覆盖上表的主入口:数字 1–4 / F1–F4 快捷栏、
|
||
Ctrl/Command+数字表情、LSHIFT 翻页、空格按住攻击、Alt 名字显示、截图、帮助 / 坐骑 / 角色窗口 /
|
||
小地图 / 聊天 / 相机入口,并将按住 / 松开动作分别处理。macOS 上物理 F1–F12 是否直接产生
|
||
`KEY_F1`–`KEY_F12` 仍受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,这是平台验收条件,
|
||
不应写成 ClientVS22 的额外键位功能。
|
||
|
||
**已完成 / 剩余**
|
||
|
||
1. `game_scene.gd` 已按表补齐可映射入口,并通过 `input_key_test.gd`;`SYSRQ` 保存到用户截图目录,
|
||
空格按住 / 松开驱动连续攻击,LALT 控制世界名字牌,Ctrl/Command 组合走独立分支。
|
||
**增量 113**:LALT(`ShowName()` / `HideName()`)现只写 `net_world` 的 `_alt_held`,与
|
||
uigameoption「总是显示名字」选项(`_always_show_name`)一起按 `game.py::__IsShowName()` =
|
||
`IsAlwaysShowName() or ShowNameFlag` 决定名字是否显示,再叠 `ShowAllTextTail` 的 3500 平面距
|
||
裁剪与 `ShowCharacterTextTail` 资格判定(`name_show.gd`,见 §8.4 / §8.8 / C.5 增量 113)。
|
||
`ShowName()` 的另两步(`playerGauge.EnableShowAlways()` / `SetQuickPage(+1)`)属血条 / 快捷栏
|
||
UI,未接。
|
||
2. 表情快捷栏调用 `M2Client.send_emoticon()`,C++ 发参考 token 并发出本地播放信号;本地特效由
|
||
`game_scene.gd` 按迁移资产注册顺序播放。
|
||
3. 未完成的是 §4.2 的多格占用 / 全窗口精确 mousemodule 规则、真实 CJK IME 候选 / composition、以及
|
||
macOS F1–F12 的物理键设置验收;键位重绑定 / 冲突检测和独立相机灵敏度持久化不是参考端能力。
|
||
|
||
---
|
||
|
||
### 8.7 CJK 输入不等价
|
||
|
||
**参考**:`REF/UserInterface/PythonIME.{h,cpp}`,接口:
|
||
```
|
||
Create(HWND) / MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete
|
||
OnTab / OnReturn / OnEscape / OnWM_CHAR(wParam, lParam) / OnUpdate / OnChangeCodePage
|
||
OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd
|
||
```
|
||
即:候选窗(candidate list)、读音窗(reading window)、代码页切换(`OnChangeCodePage`,
|
||
与 §1.10 的 `LocaleService_GetCodePage()` 联动)都由客户端自绘。
|
||
|
||
**修改**
|
||
|
||
**定位**:macOS / Godot **可以用原生 IME 取代 Windows IMM32 实现**(不必自绘候选窗),
|
||
但**必须保持参考端的输入行为**——不能因为换了实现就默认等价。以下逐项验证后才算完成:
|
||
|
||
1. **组合文本(composition)**:未确认的组合串要显示在输入位置,且**不计入**已提交文本;
|
||
对应 `OnWM_CHAR` / `OnUpdate`。
|
||
2. **候选词与候选窗**:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标,
|
||
不被游戏窗口遮挡;对应 `OnOpenCandidateList` / `OnCloseCandidateList` /
|
||
`OnOpenReadingWnd` / `OnCloseReadingWnd`。
|
||
3. **焦点切换**:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致;
|
||
窗口失焦 / 切窗时不残留候选窗。
|
||
4. **光标与编辑**:`MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete`
|
||
六个操作在含多字节字符时按**字符**移动,不能按字节。
|
||
5. **长度限制**:见 §1.10——上限是**目标字段的字节数**(角色名 24、聊天 128、
|
||
公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。
|
||
6. **代码页**:`OnChangeCodePage` 对应 `LocaleService_GetCodePage()`;
|
||
locale 切换后输入与显示都要跟着换(§1.10)。
|
||
7. **`OnTab / OnReturn / OnEscape`**:聊天框 Tab 切换频道、Return 发送、Escape 关闭——
|
||
按 `uichat.py` 的绑定;这三个键在**候选窗打开时**必须优先给 IME,不能被窗口吃掉。
|
||
|
||
上述任一项不满足,本条不得标 ✅;`CLIENT-GAP.md` 里也不应写成「平台差异,等价即可」。
|
||
|
||
---
|
||
|
||
### 8.8 聊天和头顶信息不完整
|
||
|
||
**参考**
|
||
|
||
- 聊天:`REF/UserInterface/PythonChat.{h,cpp}` + `POC/assets/root/uichat.py`;
|
||
`CHAT_MAX_NUM = 128`(`Packet.h:383`)。
|
||
`InsultChecker`(`REF/UserInterface/InsultChecker.cpp`)做脏词过滤。
|
||
- 头顶信息:`REF/UserInterface/PythonTextTail.{h,cpp}`——名字 / 称号 / 公会名 /
|
||
伤害数字 / 聊天气泡都在这里。
|
||
- 名字颜色:`ENameColor`(`REF/UserInterface/InstanceBase.h:227`,完整枚举见 §5.4)——
|
||
按 `MOB / NPC / PC+empire / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP /
|
||
WAYPOINT` 分类,`EMPIRE_NUM` 决定每类里帝国的偏移。
|
||
- 帝国常量:`EMPIRE_NUM = 4`(`REF/UserInterface/InstanceBase.h:217` 的匿名枚举
|
||
`EMPIRE_NONE/A/B/C`)。
|
||
- **增量 110(2026-09-03)**:`CInstanceBase::GetNameColorIndex` / `GetNameColor` /
|
||
`SetEmpireNameMode` / `GetIndexedNameColor`(`REF/UserInterface/InstanceBaseEffect.cpp:52/75/520/525`)
|
||
已 1:1 迁到 `project/name_color.gd`,并接进 `net_world.gd` 的头顶 `Label3D.modulate`
|
||
(spawn / add-info / GC_PVP / duel 单发刷新 + `_process` ~2Hz 整表兜底)。sole baseline
|
||
40250 `source/` 本身含 `GetNameColorIndex`(ISO-8859 编码,需 `grep -a`),字节级等同
|
||
m2dev 完整版 —— **无 seam**。见 C.5 增量 110。
|
||
- **增量 111(2026-09-03)**:头顶「公会名 / 称号 / 等级」子标签 1:1。
|
||
`CInstanceBase::GetTitleColor`(`InstanceBaseEffect.cpp:609`,`uGrade >= TITLE_NUM(9)` 白,
|
||
否则 `g_akD3DXClrTitle[uGrade]`)、`RefreshTextTail`(:664,`TITLE_NONE(4)` 时 `DetachTitle`,
|
||
否则按 `g_TitleNameMap[grade]` `AttachTitle(name, GetTitleColor())`)、`AttachTextTail`(:621,
|
||
`fTextTailHeight = IsMountingHorse() ? 110 : 10`,末尾 `if (m_dwLevel) UpdateTextTailLevel`)、
|
||
`UpdateTextTailLevel`(:654,`s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1)`,`"Lv %d"`)、
|
||
`CPythonTextTail::RegisterCharacterTextTail`(`PythonTextTail.cpp:509`,`dwGuildID != 0` 才建公会名
|
||
标签,`GetGuildName` 失败 → `"Noname"`;`c_TextTail_Guild_Name_Color = 0xFFEFD3FF`)、
|
||
`EnablePKTitle`(:938,`AttachTitle` / `AttachLevel` 均 `if (!bPKTitleEnable) return`;
|
||
`game.py:155` 用 `constInfo.PVPMODE_ENABLE == 1` 初始化)全部迁到 `project/text_tail.gd`(纯静态)。
|
||
称号色表来自 `introloading.py:287 TITLE_COLOR_DICT` + `colorInfo.py:29-37`,称号名来自
|
||
`localeInfo.py:215 TITLE_NAME_LIST == (PVP_LEVEL0..PVP_LEVEL8,)`。40250 `source/` 自带
|
||
`InstanceBaseEffect.cpp` 的 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` /
|
||
`RegisterTitleName` / `RegisterTitleColor`(:609/:664/:654/:1107/:1131)—— **无 seam**。
|
||
见 C.5 增量 111。
|
||
- **增量 112(2026-09-03)**:头顶「聊天 / 信息」尾标 1:1。
|
||
`CPythonTextTail::RegisterChatTail`(`PythonTextTail.cpp:581`,`m_ChatTailMap` 已有该 vid →
|
||
改文本 + `Color = c_TextTail_Chat_Color(白)` + `LivingTime = now + TextTail_GetLivingTime()` +
|
||
`bNameFlag = TRUE`,否则新建,高度 = 角色高 + 10)、`RegisterInfoTail`(:624,同上但
|
||
`Color = c_TextTail_Info_Color = D3DXCOLOR(1, .785, .785, 1)`、`bNameFlag = FALSE`;chat 与
|
||
info 共用 `m_ChatTailMap`,同 vid 互相覆盖)、`TextTail_GetLivingTime` / `SetLivingTime`
|
||
(:26/:28/:34,`gs_TextTail_LivingTime = 5000`,`assert(> 1000)`)、`UpdateAllTextTail` 的
|
||
`LivingTime < now` 硬删(:328,无淡出)、`IsChatTextTail`(:684)全部迁到 `project/chat_tail.gd`
|
||
(纯静态)。派发规则照抄 `PythonNetworkStreamPhaseGame.cpp::RecvChatPacket`:`CHAT_TYPE_COMMAND`
|
||
提前 `return`(:1301);`TALKING/PARTY/GUILD/SHOUT/WHISPER` 的 `line = strchr(buf,':') ? p+2 : buf`
|
||
去掉 `"名字: "` 前缀(:1321),`INFO/NOTICE/BIG_NOTICE` 原样(:1363);`if (CHAT_TYPE_SHOUT !=
|
||
type) RegisterChatTail(vid, line)`(:1372)—— 即除 `SHOUT` / `COMMAND` 外带 VID 的聊天都进
|
||
「聊天尾标」,`RegisterInfoTail` 仅 `game.py:930/936` 技能 / 射击错误显式调。40250 `source/`
|
||
自带 `RegisterChatTail` / `RegisterInfoTail` / `RecvChatPacket` —— **无 seam**(气泡与名字尾标
|
||
同屏排布 / `bNameFlag` 名字下移 17px = seam ⑨,表现层近似)。见 C.5 增量 112。
|
||
- **增量 113(2026-09-03)**:头顶名字「是否显示」1:1(§8.4 / §8.6)。
|
||
`CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`(`PythonSystem.cpp:327/:334`,
|
||
`bAlwaysShowName = iFlag == 1`,默认 `DEFAULT_VALUE_ALWAYS_SHOW_NAME = true`,配置行
|
||
`ALWAYS_VIEW_NAME` = `atoi == 1`)、`game.py::__IsShowName`(:1149,`IsAlwaysShowName() or
|
||
ShowNameFlag`,`ShowNameFlag` 由 LALT 按 / 抬 `ShowName()` / `HideName()` 翻转,:323/:406/:1143)、
|
||
`CPythonTextTail::ShowAllTextTail`(`PythonTextTail.cpp:422`,`m_CharacterTextTailMap` 里
|
||
`fDistanceFromPlayer < 3500.0f` 才 `ShowCharacterTextTail`)、`ShowCharacterTextTail`(:440,
|
||
不在图 / 已入列 / `!pOwner->isShow()` / 无实例 / `IsGuildWall()` → return,`CanPickInstance()`
|
||
才入列)、`UpdateAllTextTail` 的 chat 尾标 `bNameFlag` → `ShowCharacterTextTail`(:74,不受
|
||
3500 限制)、`game.py OnRender` 的 `chr.Pick()` 命中 + `targetBoard.GetTargetVID()` 两条强显
|
||
(:1533/:1535)全部迁到 `project/name_show.gd`(纯静态)。40250 `source/` 自带
|
||
`PythonSystem` / `PythonTextTail` 全部逻辑 —— 悬停 `chr.Pick()` 强显 / `ArrangeTextTail`
|
||
屏幕空间防重叠 / `CanPickInstance()` 完整语义 = seam ⑩(net_world 每实体一块 billboard
|
||
`Label3D` 逐帧 set `.visible` 的近似)。见 C.5 增量 113。
|
||
- **增量 114(2026-09-03)**:头顶伤害飘字 1:1(§8.8 / §5.4)。
|
||
`CInstanceBase::AddDamageEffect`(`InstanceBaseEffect.cpp:87`,`if (CPythonSystem::IsShowDamage())`
|
||
才 `m_DamageQueue.push_back({bSelf, bTarget, damage, flag})`)、`ProcessDamage`(:100,
|
||
`InstanceBase.cpp:1910` 每帧调,`m_DamageQueue` 每帧 `pop_front` 一条:`(flag & DODGE) ||
|
||
(flag & BLOCK)` → `bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS` + `return`;`CRITICAL`
|
||
/ `POISON` 分支参考端 `CreateEffect` 均注释掉 → 落普通数字;`bSelf` → `"damage_"` +
|
||
`EFFECT_DAMAGE_SELFDAMAGE` / `SELFDAMAGE2` 按 `m_bDamageEffectType` 交替;`bTarget == false`
|
||
→ `"nontarget_"` + `return`(不渲染);否则 `"target_"`;`while (damage > 0)` 逐位
|
||
`damage % 10` / `damage /= 10`,`index 0..7`(`> 7` `TraceError` break),每位一张
|
||
`damagevalue/<type><digit>.dds` 特效按 `FONT_WIDTH(30) * index` 相机右向铺开)、
|
||
`TPacketGCDamageInfo`(`Packet.h:1943`:`header / dwVID / flag / int damage`)经
|
||
`RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`,`bSelf = 目标是主实例`、
|
||
`bTarget = 目标是 m_pInstTarget`、`damage >= 0` 才 `AddDamageEffect`)、`CPythonSystem::IsShowDamage`
|
||
/ `SetShowDamageFlag`(`PythonSystem.cpp:337/:342`,`bShowDamage = iFlag == 1`,默认 `true`,
|
||
配置行 `SHOW_DAMAGE` = `atoi == 1`)全部迁到 `project/damage_effect.gd`(纯静态)。40250 `source/`
|
||
自带 `AddDamageEffect` / `ProcessDamage` / `RecvDamageInfoPacket` / `PythonSystem` —— `damagevalue`
|
||
dds 逐位特效 / 相机右向像素铺排 / self `SELFDAMAGE`⇄`SELFDAMAGE2` 双资源 / 每实例独立队列
|
||
= seam ⑪(net_world 用一块 billboard `Label3D` 写 `str(amount)`、self 退化为水平抖动符号交替、
|
||
暴击不再放大 / 变色(对齐该 build)、全局单队列每帧一条)。见 C.5 增量 114。
|
||
- **增量 115(2026-09-03)**:目标框 `RecvTargetPacket` 收包分派 1:1(§5.4 / §8.8)。
|
||
`CPythonNetworkStream::RecvTargetPacket`(`PythonNetworkStreamPhaseGame.cpp:2424`)的判定树:
|
||
`pInstPlayer && pInstTarget` 都在且 `!IsDead()` 时 —— `IsPC() || IsBuilding()` →
|
||
`CloseTargetBoardIfDifferent(dwVID)`(:2440)、否则 `CanViewTargetHP(*pInstTarget)` →
|
||
`SetHPTargetBoard(dwVID, bHPPercent)`(:2442)、否则 `CloseTargetBoard()`(:2444);
|
||
`m_pInstTarget = pInstTarget` 仅在该 `!IsDead()` 块内(:2447);实例缺失 → `CloseTargetBoard()`
|
||
(:2453)、目标已死透 → 整块跳过(不动框)。`CInstanceBase::CanViewTargetHP`
|
||
(`InstanceBase.cpp:2293`)= `IsStone() || IsWoodenDoor() || IsEnemy()`。`TPacketGCTarget`
|
||
(`Packet.h:1937`:`header / dwVID / bHPPercent`)。`game.py` 侧 `SetHPTargetBoard`(:829,
|
||
`vid != GetTargetVID()` → `ResetTargetBoard()` + `SetEnemyVID(vid)`,再 `SetHP` + `Show`)、
|
||
`CloseTargetBoardIfDifferent`(:837,`vid != GetTargetVID()` 才 `Close()`)、`CloseTargetBoard`
|
||
(:841)、`SetPCTargetBoard`(:804,按住 `LCONTROL` 且 `IsSameEmpire` 且非本人且非 building →
|
||
`OpenWhisperDialog`)全部迁到 `project/target_board.gd`(纯静态)。40250 `source/` 自带
|
||
`RecvTargetPacket` / `CanViewTargetHP` —— poc 无 building / wooden-door / stone-door 实体类型
|
||
(`is_building` 恒 false、`can_view` 的 `is_wooden_door` 恒 false)、`LCONTROL` 悄悄话捷径未接
|
||
= seam ⑫。见 C.5 增量 115。
|
||
- **增量 116(2026-09-03)**:`OnRender` Picking 块分派 + 悬停角色强显名字 1:1(§8.8 / §5.4)。
|
||
`game.py GameWindow.OnRender()` 的「Picking」块(:1519):`wndMgr.IsPickedWindow(self.hWnd)` 门
|
||
(:1533)内 —— `chr.Pick()` 命中角色(!= -1)→ `textTail.ShowCharacterTextTail(idx)`(:1535/:1537)、
|
||
`targetBoard.GetTargetVID()` != 0 → `ShowCharacterTextTail(targetVID)`(:1539)、`not __IsShowName()`
|
||
时 `item.Pick()` → `ShowItemTextTail`(:1543/:1546);门外 `__IsShowName()` 时 `ShowAllTextTail()`
|
||
+ `PickingItemIndex = textTail.Pick(x, y)`(:1552/:1554);末尾 `UpdateShowingTextTail` +
|
||
`ArrangeTextTail`(:1557/:1558)、`PickingItemIndex != -1` → `SelectItemName`(:1560)。全部迁到
|
||
`project/pick_show.gd`(纯静态第 27 个 canonical):`NO_PICK := -1`、`char_pick_active`、
|
||
`forced_name_vids(is_picked_window, pick_char_vid, target_vid)`(收 `[hover, target]` 去重)、
|
||
`should_pick_item` / `should_show_all_names` / `should_pick_text_tail` / `should_select_item_name`。
|
||
poc 侧接:`net_world._hover_vid` + `set_hover_vid` + `_name_shown()` forced 第 5 路,
|
||
`player_controller.hover_entity_changed` 信号(`_update_hover_cursor()` 射线拾取,压 UI / 悬停
|
||
地面物品 → 0),`game_scene` 连线。40250 `source/` 自带 `OnRender` —— 名字尾标 y = chat 气泡
|
||
y − 17px(`PythonTextTail.cpp:112-114`)未接 = seam ⑨;`ArrangeTextTail` 屏幕空间去重叠排布
|
||
仍缺 = seam ⑩(本增量只收口「悬停强显名字」一小块)。见 C.5 增量 116。
|
||
- **增量 117(2026-09-04)**:seam ⑨ 收口 —— 头顶名字尾标随聊天气泡上移 17px 1:1(§8.8)。
|
||
`CPythonTextTail::UpdateShowingTextTail`(`PythonTextTail.cpp:88`)在角色名字尾标投影完
|
||
屏幕坐标后(:104),`m_ChatTailMap.find(dwVirtualID)` 命中(:106/:110)且那条 `bNameFlag`
|
||
为 TRUE(:112,= `RegisterChatTail` 的「聊天」气泡,非 `RegisterInfoTail` 的「信息」气泡)
|
||
→ `pTextTail->y = pChatTail->y - 17.0f`(:114,屏幕 y 向下减 17px = 名字上移一行贴气泡
|
||
上沿)。名字与气泡默认同世界高(`GetHeight()+10`,:519/:581),全靠这条 −17px 分开。
|
||
迁到 `project/name_tail_layout.gd`(纯静态第 28 个 canonical):`NAME_OFFSET_ABOVE_BUBBLE_PX
|
||
:= 17.0`、`name_follows_bubble(has_chat_tail, chat_name_flag)`、`name_tail_screen_y`(参考端
|
||
字面 `bubble_y − 17`)、`name_tail_world_y`(poc 3D:屏幕 y 向下的 −17px → 世界 y 向上的
|
||
`bubble_world_y + 17*pixel_size`)。poc 侧接:`net_world._reposition_name_tag(n)` 由
|
||
`_apply_chat_tail` / `_expire_chat_tails` 调用,读 `_chat_tails[vid].name_flag` 决定名字
|
||
`Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「气泡 2.35 + 17*pixel_size」之间切换。
|
||
剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D` 节点、子标签不随动 = seam ⑦ 遗留。见 C.5 增量 117。
|
||
- **增量 118(2026-09-04)**:目标框「看他国玩家」前置过滤 + §8.4 名字色 radio 接线 1:1(§5.4 / §8.8 / §8.4)。
|
||
`root/uitarget.py TargetBoard.Open(vid, name)`(:189-193)在整个 `RecvTargetPacket` 判定之前
|
||
先过滤:`if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(): if not player.IsSameEmpire(vid):
|
||
self.Hide(); return`(`constinfo.VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1` 默认,
|
||
`uigameoption.py` 的 `target_board_view` / `target_board_no_view` radio →
|
||
`constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)`)。迁到
|
||
`project/target_board.gd`:`hidden_as_other_empire(view_other_empire, same_empire)` =
|
||
`not view_other_empire and not same_empire`(不按 `is_pc` 收窄 —— 忠于参考;empire 0 的
|
||
怪 / NPC / 石头在 `EntityRules._is_same_empire` 里算「同阵营」故永不被挡)。poc 侧接:
|
||
`net_play._view_other_empire_target`(默认 true)+ `set_view_other_empire_target(on)` +
|
||
`_on_target_info` 在 `TargetBoard.classify(...)` 之前先 `if exists and
|
||
TargetBoard.hidden_as_other_empire(...): _clear_target(); return`;`same_empire` 由
|
||
`EntityRules._is_same_empire(_main_entity(), e)` 求。同批把 §8.4「名字色」radio
|
||
(`name_color_normal` / `name_color_empire`)接进 `game_scene._on_display_option` →
|
||
`net_world.set_empire_name_mode(v == 1)`(帝国名色调色板算法增量 110 已 1:1),并修
|
||
`game_option_ui.gd` 的 `target_board` 默认值 0 → 1(对齐 `VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1`)。
|
||
40250 `source/` 自带 `TargetBoard.Open` —— poc 无独立「他国 PC 名字框」窗体,本增量只是
|
||
在 `_on_target_info` 分派前加同一道阵营门(seam ⑫:building / wooden-door 实体类型、
|
||
`LCONTROL` 悄悄话捷径仍未做)。见 C.5 增量 118。
|
||
- **增量 119(2026-09-04)**:seam ⑦ 收口 —— 公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1(§8.8)。
|
||
`CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把
|
||
公会名(:228,`pTextTail->y - c_fyGuildNamePosition`,`c_fyGuildNamePosition = 15.0f` :21)/
|
||
称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个子标签的屏幕 y 全部锚在
|
||
`pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 −17(增量 117)。
|
||
迁到 `project/name_tail_layout.gd`:`GUILD_NAME_ABOVE_NAME_PX := 15.0`、
|
||
`name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size)`(=
|
||
`name_tail_world_y(...) - default_world_y`,follows 时为净上移量、否则 0)、
|
||
`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=二者相加,三子标签整体平移)。
|
||
poc 侧接:`net_world` 把 `GuildTag`/`TitleTag`/`LevelTag` 默认局部 y 提为
|
||
`SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,`_reposition_name_tag` 顶完名字尾标后按
|
||
同一 `lift` 平移三个子标签;气泡过期 / 「信息」尾标(`bNameFlag` FALSE)时回默认。
|
||
剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、称号 / 等级
|
||
同水平行左偏)仍近似。剩余 seam ⑨:气泡与名字仍是两个并存 `Label3D`(非单尾标链表)。见 C.5 增量 119。
|
||
- **增量 120(2026-09-04)**:seam ⑩ 收口 —— `ArrangeTextTail` item 尾标去重叠 + `isIn` 1:1(§8.8)。
|
||
`CPythonTextTail::ArrangeTextTail`(`PythonTextTail.cpp:153`)的 `m_ItemTextTailList` 块
|
||
(:160-190):逐条掉落名条与其它每条比 `isIn`(:489-507,`[x+xStart,x+xEnd]×[y+yStart,y+yEnd]`
|
||
的 AABB 含边界 `<=`/`>=`),命中就把「自己」顶到 `pCompareTextTail->y + pCompareTextTail->yEnd
|
||
+ yTemp`(`yTemp = 5`),内层从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical
|
||
`project/text_tail_arrange.gd`(`extends RefCounted` 全静态):`PUSH_GAP := 5.0` / `MAX_ITERS := 20`、
|
||
`box_start_x/end_x/start_y/end_y`(`-w/2-2` / `w/2+2` / `-2` / `h`,`PythonTextTail.cpp:810-813`)、
|
||
`boxes_overlap(a,b)`(`isIn`)、`arrange_item_tails(tails:Array)->Array`(外层逐条 i、内层逐条 j
|
||
跳过自身、命中 `ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP` 后 j 归零 limit++、`limit >= MAX_ITERS`
|
||
break;`pCompare` 用已排布后的 `ys[j]`)。poc 侧接:`project/ui/ground_items.gd` 加
|
||
`_camera_getter` + 常量 `TAG_BASE_Y 0.75` / `TAG_CHAR_W_PX 7` / `TAG_LINE_H_PX 15`,
|
||
`_process` 尾部 `_arrange_tags()` 把所有掉落名条世界锚点 `cam.unproject_position` 到屏幕、
|
||
按字符数估框、跑 `arrange_item_tails`、`position.y = TAG_BASE_Y - (arranged_y - y0) * pixel_size`,
|
||
可见名条 < 2 直接回默认;`game_scene` 把 `func() -> Camera3D: return cam` 传进
|
||
`ground_items.setup` 第 6 参。剩余 seam ⑩:名条框按「字符数 * 7px」估宽 vs 参考
|
||
`GetTextSize` 精确像素;只对掉落 item 名条做(角色名字尾标分表不参与)。见 C.5 增量 120。
|
||
- **增量 121(2026-09-04)**:seam ⑦ 水平深化 —— 角色子标签逐像素水平排版 1:1(§8.8)。
|
||
`CPythonTextTail::ArrangeTextTail` 的 `m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)
|
||
非 EUROPE 分支:`fxAdd` 三态(:236 `0` / :242 `8`(有称号)/ :288 `4`(只有等级)),
|
||
称号 `x - iNameWidth/2 - fxAdd`(:258)、等级有称号时再 `- iTitleWidth`(:282)否则
|
||
`x - iNameWidth/2 - 4`(:312)、名字本身 `x + fxAdd`(:320);公会名水平锚点仅在有公会徽记
|
||
`pMarkInstance`(:220)时改写成 `x + markW/2 + 1.5`(:228)。`name_tail_layout.gd` 加
|
||
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
|
||
`subtag_world_x`。`net_world._layout_subtags_x`(`_apply_text_tail_to` 尾部)设名字 /
|
||
`TitleTag` / `LevelTag` 的 `.position.x`(`GuildTag` 留 0,poc 无徽记),文本宽 `字符数 * 7px`
|
||
估(`SUBTAG_CHAR_W_PX`);与增量 117/119 纵向位移解耦(`.position.y` vs `.position.x`)。
|
||
剩余 seam ⑦:无公会徽记 mark 实体、字符数估宽 vs `GetTextSize`、三子标签竖排 + x 偏移的
|
||
折衷(≠ 参考同一屏幕行)。见 C.5 增量 121。
|
||
- **增量 122(2026-09-04)**:seam ⑫ 收口 —— 目标框点击 `LCONTROL` 悄悄话捷径接 whisper 1:1(§5.4 / §8.8)。
|
||
`assets/root/game.py CGameWindow::SetPCTargetBoard`(:804-817):`self.targetBoard.Open(vid, name)`
|
||
之后,`if app.IsPressed(app.DIK_LCONTROL)`(:807)且 `player.IsSameEmpire(vid)`(:809)且
|
||
非本人(:812 `IsMainCharacterIndex`)且 `chr.INSTANCE_TYPE_BUILDING != GetInstanceType(vid)`
|
||
(:814)→ `self.interface.OpenWhisperDialog(name)`(:817)。谓词
|
||
`target_board.whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building)`
|
||
增量 118 已备好没接,本增量纯接线:`net_play` 加 `signal whisper_requested(name)` +
|
||
`var _lcontrol_down` + `set_lcontrol_down(down)`,`_on_pick` 在 `_entity_kind(e) == KIND_PC`
|
||
分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调 `whisper_on_ctrl_click(_lcontrol_down,
|
||
_is_same_empire(_main_entity(), e), vid == _main_vid, false)` 真则 `whisper_requested.emit(
|
||
_entity_name(e))`;`game_scene._unhandled_input` 加 `KEY_CTRL` 按下 / 松开 →
|
||
`net_play.set_lcontrol_down(e.pressed)`,`chat` 就绪后 `net_play.whisper_requested` →
|
||
`chat.start_whisper`(poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`)。
|
||
剩余 seam ⑫:poc 无 building / wooden-door / stone-door 实体类型(`is_building` 实参恒 false,
|
||
:814 分支永不触发)、无独立 whisper dialog 窗口。见 C.5 增量 122。
|
||
- **增量 123(2026-09-04)**:seam ⑦ / ⑩ 收口 —— 头顶子标签 / 掉落名条文本宽换真实字体度量
|
||
`GetTextSize` 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 里 `iNameWidth` / `iTitleWidth` /
|
||
`iLevelWidth`(`PythonTextTail.cpp:225/233/240/267/297`)与掉落名条框 `xSize`(:733/:748/:809)
|
||
参考端全部取自 `CGraphicTextInstance::GetTextSize(&w, &h)`——逐字形排版度量,非「字符数 * 定宽」。
|
||
新增纯静态 `project/text_metrics.gd`(第 30 个 canonical):`text_size(font, text, font_size)`
|
||
= `Font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`(退化输入 → `ZERO`)、
|
||
`width_of(...)`(量得正宽用精确像素,无字体回退 `len * 7.0`)、`label_width(label: Label3D)`
|
||
(`label.font ?? ThemeDB.fallback_font` + `label.font_size`)。`net_world._layout_subtags_x` 的
|
||
`name_w` / `title_w`、`ground_items._arrange_tags` 的框宽 `w` 由 `float(text.length()) * *_CHAR_W_PX`
|
||
换成 `TextMetrics.label_width(label, *_CHAR_W_PX)`;`*_CHAR_W_PX = 7.0` 常量保留作无字体兜底。
|
||
`name_tail_layout.gd` / `text_tail_arrange.gd` 收宽度作入参、排布公式(增量 119/120/121)不动。
|
||
剩余 seam ⑦:无公会徽记 mark 实体、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行);
|
||
剩余 seam ⑩:名条单行高仍按 `TAG_LINE_H_PX` 15px 估、只对掉落 item 名条做。见 C.5 增量 123。
|
||
- **增量 124(2026-09-04)**:seam ⑦ / ⑩ 末尾 —— 掉落名条去重叠框的「单行文本高」换真实字体
|
||
度量 `GetTextSize` 的 h 1:1(§8.8)。`CPythonTextTail::ArrangeTextTail` 的 item 尾标去重叠
|
||
用 `pTextTail->yEnd`,而 `yEnd = ySize`(`PythonTextTail.cpp:751/:812`),`ySize` 与增量 123
|
||
换过的 `xSize` 同出 `CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)。
|
||
`project/text_metrics.gd`(仍第 30 个 canonical,扩非新增)加 `const FALLBACK_LINE_H_PX := 15.0`
|
||
+ `height_of(font, text, font_size, fallback_line_px=15.0)`(空串 → 0;量得正高用精确像素
|
||
`text_size(...).y`;否则回退)+ `label_height(label: Label3D, fallback_line_px=15.0)`
|
||
(`label.font ?? ThemeDB.fallback_font` + `label.font_size`,`null` → 0)。
|
||
`ground_items._arrange_tags` 的框高 `h` 由硬编码 `TAG_LINE_H_PX` 换成
|
||
`maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`;`TAG_LINE_H_PX = 15.0` 保留作
|
||
无字体兜底。`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd` 收框高作
|
||
入参、排布公式不动。测试扩 `text_metrics_test.gd`(+「5. height_of / label_height」段),
|
||
回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。剩余 seam ⑦:无公会徽记 mark 实体、
|
||
三子标签竖排 + x 偏移的折衷;剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance
|
||
(参考端 `yEnd += yOwnerSize + 4`)、只对掉落 item 名条做。见 C.5 增量 124。
|
||
- **增量 125(2026-09-04)**:seam ⑩ 末尾 —— 带 owner 的掉落名条去重叠框按参考
|
||
`CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)「owner 独立度量 +
|
||
`yEnd += yOwnerSize + 4`」1:1(§8.8)。参考端 owner 后缀是与名字分开的
|
||
`CGraphicTextInstance`(文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE` 为空取
|
||
字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、
|
||
`xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737)。
|
||
`text_tail_arrange.gd`(扩第 29 个 canonical)加纯静态 `item_box_with_owner(name_w, name_h,
|
||
owner_w:=0.0, owner_h:=0.0)`:无 owner → `{name_w, name_h}`(对齐参考 else 分支);有 owner →
|
||
`{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的
|
||
`fMAX`,框 x 对称故 `box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical)
|
||
加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串,`null` /
|
||
空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags` 把
|
||
「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框;渲染仍是
|
||
单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩
|
||
`text_tail_arrange_test.gd`(+「5. item_box_with_owner」段)/ `text_metrics_test.gd`
|
||
(+「6. string_*_like」段)/ `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参 +
|
||
带 owner 掉落被顶 `(34+5)*0.005` 段),回归 `project/*_test.gd` 70 全 PASS(无新增测试文件)。
|
||
剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、`strOwnership` 未接本地化语素、
|
||
只对掉落 item 名条做。见 C.5 增量 125。
|
||
- **增量 126(2026-09-04)**:seam ⑩ 末尾 —— 带 owner 的掉落名条按参考
|
||
`CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`)把 owner 后缀拆成
|
||
**一个独立的 CENTER 黄字(`1,1,0`)text instance**、并按 `ArrangeTextTail`(:191-207)把名字
|
||
实例下移 15px(§8.8)。参考端 owner 非空时新建 `pOwnerTextInstance`(:715)、文本
|
||
= `c_szName + strOwnership`(:723,`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE)`
|
||
为空取字面 `"'s"`,:722)、`SetHorizonalAlign(CENTER)`(:727)、`SetColor(1,1,0)`(:729);
|
||
排布时 `pOwnerTextInstance->SetPosition(x, y, z)`(:195)、`pTextInstance->SetPosition(x,
|
||
y + 15.0f, z)`(:200);owner 为空时 `Delete(pOwnerTextInstance)`(:743)。`ground_items`:
|
||
drop 节点下加第二个 `Label3D "owner_tag"`(CENTER / `modulate = Color(1,1,0)` / billboard /
|
||
`no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag(node,
|
||
owner)` 建 / 删;名字 `tag` 只放 `_name_for(vnum)`(不再拼 `"name (owner)"`);新增
|
||
`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail(n, tag, d_px)` —— 有 owner 时
|
||
`owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y = TAG_BASE_Y - (d_px + 15)*px`,无 owner 时
|
||
沿用 `tag.y = TAG_BASE_Y - d_px*px`;`setup()` 加可选 `possessive := ""`(`_possessive =
|
||
possessive if possessive != "" else OWNER_POSSESSIVE`,1:1 对齐 :722 的 `== "" ? "'s" :` 语义),
|
||
`game_scene` 从 EterLocale `POSSESSIVE_MORPHENE` 键解析后注入(poc txt 无此键 → 回落 `"'s"`,
|
||
与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE "'s"` 一致)。去重叠框度量仍是增量 125 的
|
||
`item_box_with_owner`(`yEnd += ownerH + 4`)不变。`_tag_text` 删除(不再合并渲染)。测试扩
|
||
`p2b_test.gd`(drop_302 名字实例文本 = `"掉落28"`、`owner_tag` 文本 `"Alice's"` / CENTER /
|
||
黄字 / 落基点、名字实例下移 15px、无 owner 的 drop_301 不建 `owner_tag`;drop_401 用
|
||
`setup(possessive="@")` → `owner_tag` 文本 `"Bob@"`),回归 `project/*_test.gd` 70 全 PASS
|
||
(无新增测试文件)。剩余 seam ⑩:owner 行的品阶/颜色语义仍是固定黄(参考名字实例取
|
||
`pInsertTextTail->Color` 的道具品阶色、poc 名字走 `_process` 远近着色)、去重叠只对掉落
|
||
item 名条做。见 C.5 增量 126。
|
||
|
||
**修改**
|
||
|
||
1. 按 §8.2 迁移 `uichat.py`(notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。
|
||
2. 新建 `text_tail.gd` / `chat_tail.gd` / `name_show.gd` 移植 `PythonTextTail`:名字 / 称号 /
|
||
公会名 / 伤害数字 / 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。
|
||
**增量 113 已落地「总是显示名字」显示开关**(`name_show.gd` 纯静态 `IsAlwaysShowName` /
|
||
`__IsShowName` / `ShowAllTextTail` 3500 距离裁剪 / `ShowCharacterTextTail` 资格判定;
|
||
`net_world.gd` 把原 `_names_visible` 拆成 `_always_show_name`(uigameoption radio 经
|
||
`game_option_ui` 新信号 `display_option_changed` 驱动)+ `_alt_held`(LALT)+ `_target_vid`
|
||
(`net_play.target_changed` 驱动,选中目标强显)+ chat 尾标 `bNameFlag` 强显;名字色 /
|
||
伤害数字两个 radio 仍未接)。
|
||
**增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` /
|
||
`LivingTime` 5s 硬删 / 前缀剥离 / SHOUT·COMMAND 排除,`net_world.gd` `"bubble"` `Label3D`
|
||
每 vid 一条 + `register_info_tail()`);伤害数字 / 屏幕空间血条仍待做。
|
||
**增量 111 已落地称号 / 等级 / 公会名子标签**(`text_tail.gd` 纯静态 `GetTitleColor` /
|
||
`RefreshTextTail` / `UpdateTextTailLevel` / `RegisterCharacterTextTail` 公会名分支 /
|
||
`EnablePKTitle`,`net_world.gd` 的 `GuildTag` / `TitleTag` / `LevelTag` 三个 `Label3D`
|
||
子节点 + `_apply_text_tail` 事件驱动 + ~2Hz 兜底;`game_scene.gd` 注入 `title_name_resolver`
|
||
→ `Locale.t("PVP_LEVEL%d")`)。
|
||
**增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` 纯静态 `RegisterChatTail` /
|
||
`RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` / `line_for_tail` 前缀剥离 /
|
||
`wants_chat_tail` SHOUT·COMMAND 排除;`net_world.gd` 每 vid 一条 `"bubble"` `Label3D`
|
||
子节点、白 / 淡红按 chat/info、`LivingTime` 到点 `_expire_chat_tails` 硬删、新增
|
||
`register_info_tail(vid, text)` 公开方法对齐 `RegisterInfoTail`;`chat` 派发从
|
||
`t in [0,3,4,6]` 换成 `ChatTail.wants_chat_tail(t)`=除 SHOUT/COMMAND 全进、并去 `"名字: "`
|
||
前缀;`game_scene._player_bubble` 寿命对齐 5s)。伤害数字 / 屏幕空间血条整合仍待做。
|
||
billboard 3D 竖排 vs 参考屏幕空间像素排版 = seam ⑦;`title_name_resolver` 未注入时
|
||
回退裸 `PVP_LEVEL%d` key = seam ⑧;气泡与名字 `Label3D` 各自 billboard 并存(名字不强制
|
||
顶出、不随气泡下移 17px)= seam ⑨(**增量 113** 已让 chat 尾标 `bNameFlag` 强制显示名字,
|
||
仅剩「下移 17px」未做)。
|
||
**增量 113 已落地名字「是否显示」**(`name_show.gd` + `net_world._name_shown()`:
|
||
`__IsShowName()`=`_always_show_name or _alt_held`、`ShowAllTextTail` 的
|
||
`fDistanceFromPlayer < 3500` 平面距裁剪、`ShowCharacterTextTail` 的 `isShow / !IsGuildWall /
|
||
CanPickInstance` 资格、`target VID` 与 chat `bNameFlag` 强显绕过 3500;`_process` ~2Hz 兜底
|
||
随移动刷新,LALT / 选项 / 选中 / 尾标切换即时刷新)。悬停 `chr.Pick()` 强显未接、
|
||
`ArrangeTextTail` 屏幕空间防重叠未做、`CanPickInstance()` 近似为「非死透」= seam ⑩。
|
||
**增量 114 已落地头顶伤害飘字**(`damage_effect.gd` 纯静态:`AddDamageEffect`
|
||
(`InstanceBaseEffect.cpp:87`,`IsShowDamage()` 门 → `m_DamageQueue`)、`ProcessDamage`
|
||
(:100,每帧 `pop_front` 一条:`DODGE|BLOCK` → `MISS`/`TARGETMISS` return、`CRITICAL`/`POISON`
|
||
分支参考端注释掉走普通数字、`bSelf` → `"damage_"` + `SELFDAMAGE`⇄`SELFDAMAGE2` 交替、
|
||
`!bTarget` → `"nontarget_"` return 不渲染、否则 `"target_"`、`while(damage>0)` 逐位 `%10`/`/10`
|
||
index 0..7、每位 `damagevalue/<type><digit>.dds` 按 `FONT_WIDTH*index` 相机右向铺开)、
|
||
`RecvDamageInfoPacket`(`PythonNetworkStreamPhaseGame.cpp:2401`,`bSelf`/`bTarget`/`damage>=0`)、
|
||
`IsShowDamage`/`SetShowDamageFlag`(`PythonSystem.cpp:337/:342`,默认 `true`)。
|
||
`net_world._on_damage` 照 `AddDamageEffect`(`damage_number` 信号仍无条件发=本移植扩展;
|
||
飘字本体 `_show_damage` 门后入 `_damage_queue`)、`_process_damage_queue()` 每帧 `_process`
|
||
里 pop 一条照 `ProcessDamage` 分类,一块 billboard `Label3D` 写 `str(amount)`/`"MISS"`;
|
||
`net_world.set_show_damage(on)` ← `game_scene._on_display_option("show_damage")` ←
|
||
`game_option_ui.display_option_changed`(uigameoption「伤害数字」radio),初值
|
||
`display_value("show_damage") == 1` 回填;`clear_for_map_change` 清 `_damage_queue`。
|
||
seam ⑪:单 `Label3D` 写整串 vs `damagevalue` dds 逐位特效 / 相机右向像素铺排;self
|
||
`SELFDAMAGE`⇄`SELFDAMAGE2` 退化成落字水平抖动符号交替(±0.3);暴击不再放大 / 变色
|
||
(对齐该 40250 build 本就无独立暴击视觉);`_damage_queue` 全局单条 vs 参考每实例一条。
|
||
**增量 115 已落地目标框收包分派**(`target_board.gd` 纯静态:`Action { CLOSE,
|
||
CLOSE_IF_DIFFERENT, SET_HP, NONE }`、`can_view_target_hp`(石头 / 木门 / 敌对任一)、
|
||
`classify(target_exists, is_dead, is_pc, is_building, can_view_hp)`= `RecvTargetPacket` 判定树
|
||
(缺失 → CLOSE、已死 → NONE、PC|Building → CLOSE_IF_DIFFERENT、可看血 → SET_HP、否则 CLOSE)、
|
||
`updates_current_target`(`m_pInstTarget` 写入条件)、`needs_reset` / `should_close_if_different`
|
||
(`!= GetTargetVID()`)、`whisper_on_ctrl_click`、`clamp_hp_pct`)。`net_play._on_target_info`
|
||
由「任意有名字 VID 都 `hud.set_target`」改为按 `classify` 分派:`is_pc = _entity_kind == KIND_PC`、
|
||
`can_view = KIND_STONE 或 KIND_MONSTER`、CLOSE / CLOSE_IF_DIFFERENT → `_clear_target()`、
|
||
SET_HP → 换 VID 先 `_target_vid = vid` + `target_changed.emit` 再 `hud.set_target(名字,
|
||
clamp_hp_pct(hp%))`、NONE → 不动。seam ⑫:poc 无 building / wooden-door 实体(`is_building`
|
||
恒 false、木门分类缺);`target_info` 由 C++ `m2_client.cpp:3129/3633` 从 EntityStore 抽出,
|
||
不改 C++ 收包;~~`LCONTROL` 悄悄话捷径谓词已备、`net_play` 暂不接~~ 增量 122 已接线
|
||
(`_on_pick` KIND_PC 分支 → `whisper_requested` → `game_scene` → `chat.start_whisper`)。
|
||
**增量 116 已落地 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` 纯静态第 27
|
||
个 canonical:`NO_PICK := -1`、`char_pick_active(is_picked_window)`、`forced_name_vids(
|
||
is_picked_window, pick_char_vid, target_vid)`= `IsPickedWindow` 门内收 `chr.Pick()` 命中角色
|
||
(:1537)+ `targetBoard.GetTargetVID()`(:1539),去重、顺序 `[hover, target]`、`should_pick_item(
|
||
is_picked_window, is_show_name)`= `and not`(:1543)、`should_show_all_names` / `should_pick_text_tail`
|
||
= `is_show_name`(:1552/:1554)、`should_select_item_name`= `!= NO_PICK`(:1560))。poc 侧仿增量
|
||
113 的 `_target_vid` 路数给 `net_world._name_shown()` 加第 5 路 forced `_hover_vid`:
|
||
`net_world.set_hover_vid(vid)`(值变才刷 prev/vid 两节点名字可见性)由 `player_controller`
|
||
`_update_hover_cursor()` 三出口喂(压 UI → 0、悬停地面物品 → 0、射线命中 pickable →
|
||
`best.get_meta("vid")`、无命中 → 0),`hover_entity_changed` 信号去抖,`game_scene` 连
|
||
`pc.hover_entity_changed → net_world.set_hover_vid`。seam ⑨:名字尾标 y = chat 气泡 y − 17px
|
||
(`PythonTextTail.cpp:112-114`)未接;seam ⑩:`ArrangeTextTail` 屏幕空间去重叠仍缺(本增量只
|
||
收口「悬停强显名字」一小块);`_hover_vid` 当无条件 forced,靠 `player_controller` 压 UI / 悬停
|
||
物品时置 0 部分等效 `IsPickedWindow` 门。
|
||
**增量 117 已落地 seam ⑨ 收口(名字尾标随聊天气泡上移 17px)**(`name_tail_layout.gd`
|
||
纯静态第 28 个 canonical:`NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0`、`name_follows_bubble(
|
||
has_chat_tail, chat_name_flag)`= `and`(`UpdateShowingTextTail` :110 命中 && :112
|
||
`bNameFlag`)、`name_tail_screen_y(default_y, bubble_y, follows)`= 参考端字面 `bubble_y − 17`
|
||
(:114)、`name_tail_world_y(default_world_y, bubble_world_y, follows, px_size)`= poc 3D
|
||
适配(屏幕 y 向下 −17px → 世界 y 向上 `bubble_world_y + 17*px_size`))。poc 侧
|
||
`net_world._reposition_name_tag(n)` 由 `_apply_chat_tail`(冒气泡)/ `_expire_chat_tails`
|
||
(气泡过期)调用,读 `_chat_tails[vid].name_flag`(`RegisterChatTail` TRUE / `RegisterInfoTail`
|
||
FALSE)决定名字 `Label3D` 局部 y 在 `NAME_TAG_DEFAULT_Y`(2.1)与「`BUBBLE_TAG_Y` 2.35 +
|
||
17*pixel_size」之间切换。换算按名字 `Label3D` 的 `pixel_size`(0.006),与参考的字体无关
|
||
常量 17 对齐。
|
||
**增量 119 已落地 seam ⑦ 收口(子标签随名字尾标纵向位移)**:`ArrangeTextTail`
|
||
(`PythonTextTail.cpp:151`)逐条角色名字尾标(:211)把公会名(:228,`pTextTail->y −
|
||
c_fyGuildNamePosition` 15px)/ 称号(:258,同 `pTextTail->y`)/ 等级(:283,同 y)三个
|
||
子标签屏幕 y 全锚在 `pTextTail->y` 上,而这个 y 已被 `UpdateShowingTextTail :114` 顶过 −17。
|
||
`name_tail_layout.gd` 增 `GUILD_NAME_ABOVE_NAME_PX := 15.0`、`name_tail_lift_delta(
|
||
default_world_y, bubble_world_y, follows, px_size)`(= `name_tail_world_y(...) −
|
||
default_world_y`)、`subtag_world_y(default_subtag_y, name_tail_lift_delta)`(=相加)。
|
||
poc 侧 `net_world` 把子标签默认局部 y 提为 `SUBTAG_DEFAULT_Y`(2.34 / 1.86 / 1.74)常量,
|
||
`_reposition_name_tag` 顶完名字尾标后按同一 `lift` 平移三个子标签,气泡过期 / 「信息」尾标
|
||
时回默认。剩余 seam ⑦:子标签竖排 vs 参考屏幕空间逐像素水平排版仍近似。剩余 seam ⑨:
|
||
气泡与名字仍是两个并存 `Label3D` 节点(非参考单尾标链表)。
|
||
**增量 120 已落地 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn`)**:
|
||
`PythonTextTail.cpp:153` 的 `m_ItemTextTailList` 块(:160-190)—— 逐条掉落名条与其它每条比
|
||
`isIn`(:489-507,AABB 含边界),命中把「自己」顶到 `pCompareTextTail->y + yEnd + 5`,内层
|
||
从头重跑、`LimitCount >= 20` 封顶。新增第 29 个 canonical `text_tail_arrange.gd`
|
||
(`PUSH_GAP` 5 / `MAX_ITERS` 20、`box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`、`boxes_overlap`=`isIn`、
|
||
`arrange_item_tails(tails)->Array`)。poc 侧 `ground_items._arrange_tags` 每帧把掉落名条锚点
|
||
`cam.unproject_position` 到屏幕、按字符数估框、跑 `arrange_item_tails`、把被下推屏幕 Δy 按
|
||
`Label3D.pixel_size` 折回局部 y(`TAG_BASE_Y − Δpx*pixel_size`),可见名条 < 2 回默认;
|
||
`game_scene` 把相机 getter 传进 `ground_items.setup`。剩余 seam ⑩:~~名条框按字符数估宽 vs
|
||
参考 `GetTextSize` 精确像素~~(增量 123 已换真实字体度量);~~单行高仍按 15px 估~~(增量 124
|
||
已换 `TextMetrics.label_height` = `GetTextSize` 的 h);只对掉落 item 名条(角色名字尾标分表不参与)。
|
||
**增量 121 已落地 seam ⑦ 水平深化(子标签逐像素水平排版)**:`ArrangeTextTail` 的
|
||
`m_CharacterTextTailList` 块(`PythonTextTail.cpp:211-320`)非 EUROPE 分支 —— `fxAdd` 三态
|
||
(:236 `0` / :242 `8` / :288 `4`),称号 `x − iNameWidth/2 − fxAdd`(:258)、等级有称号时
|
||
再 `− iTitleWidth`(:282)否则 `x − iNameWidth/2 − 4`(:312)、名字本身 `x + fxAdd`(:320);
|
||
公会名水平锚点仅在有公会徽记 `pMarkInstance`(:220)时改写。`name_tail_layout.gd` 加
|
||
`name_x_shift` / `title_x_offset` / `level_x_offset` / `guild_name_x_offset` / `mark_x_offset` /
|
||
`subtag_world_x`(屏幕像素 × `pixel_size` → billboard 本地 x)。poc 侧 `net_world._layout_subtags_x`
|
||
(`_apply_text_tail_to` 尾部)设名字 / `TitleTag` / `LevelTag` 的 `.position.x`,`GuildTag` 留 0
|
||
(poc 无徽记 → 参考端 :220 此路不改写),文本宽 `字符数 * 7px` 估(`SUBTAG_CHAR_W_PX`;
|
||
增量 123 已换真实字体度量);与增量 117/119 的纵向位移解耦(`.position.y` vs `.position.x`)。
|
||
剩余 seam ⑦:无公会徽记 mark 实体、~~字符数估宽 vs `GetTextSize`~~(增量 123 收口)、
|
||
三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
|
||
**增量 124 已落地 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量)**:
|
||
`ArrangeTextTail` 的 item 尾标去重叠顶到 `pCompareTextTail->y + yEnd + 5`,`yEnd = ySize`
|
||
(`PythonTextTail.cpp:751/:812`),`ySize` 与增量 123 换过的 `xSize` 同出
|
||
`CGraphicTextInstance::GetTextSize(&xSize, &ySize)`(:749/:809)。`text_metrics.gd`
|
||
(仍第 30 个 canonical)加 `const FALLBACK_LINE_H_PX := 15.0` + `height_of(...)`(空串 → 0;
|
||
量得正高用 `text_size(...).y`;否则回退)+ `label_height(label: Label3D)`
|
||
(`label.font ?? ThemeDB.fallback_font`,`null` → 0)。`ground_items._arrange_tags` 的框高 `h`
|
||
由硬编码 `TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,
|
||
`TAG_LINE_H_PX = 15.0` 留作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)不动。
|
||
~~剩余 seam ⑩:掉落名条 owner 未拆成独立 text instance(参考端 `yEnd += yOwnerSize + 4`,
|
||
:733-736)~~ 增量 125 已按参考把去重叠框度量 1:1;只对掉落 item 名条做。
|
||
**增量 125 已落地 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd += yOwnerSize + 4`)**:
|
||
参考 `CPythonTextTail::SetItemTextTailOwner`(`PythonTextTail.cpp:706-737`)—— owner 后缀是
|
||
与名字分开的 `CGraphicTextInstance`(文本 = `c_szName + strOwnership`,`IDS_POSSESSIVE_MORPHENE`
|
||
为空取字面 `"'s"`),单独 `GetTextSize`,据此 `yEnd += float(yOwnerSize + 4)`(:735)、
|
||
`xStart = fMIN(xStart, -xOwnerSize/2-1)`(:736)、`xEnd = fMAX(xEnd, xOwnerSize/2+1)`(:737);
|
||
owner 为空则删 owner 实例、框复位纯名字(:739-751)。`text_tail_arrange.gd`(扩第 29 个
|
||
canonical)加纯静态 `item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)`:
|
||
`owner_w<=0 或 owner_h<=0` → `{name_w, name_h}`;否则 → `{maxf(name_w, owner_w-2.0),
|
||
name_h+owner_h+4.0}`(令 `box_end_x(w)=w/2+2` 恰等于 :737 的 `fMAX`,框 x 对称故
|
||
`box_start_x` 自动 = :736 `fMIN`)。`text_metrics.gd`(扩第 30 个 canonical)加
|
||
`string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串;`null` /
|
||
空串回退)。`ground_items._on_added` 存 `item_name` / `item_owner` meta,`_arrange_tags`
|
||
把「名字子串」「owner + `'s` 子串」分别度量后走 `item_box_with_owner` 合成去重叠框;
|
||
渲染仍是单个合并 `"name (owner)"` billboard `Label3D`(seam ⑩ 记录在案的折中)。测试扩
|
||
`text_tail_arrange_test.gd`「5. item_box_with_owner」段 / `text_metrics_test.gd`
|
||
「6. string_*_like」段 / `p2b_test.gd`(`FakeClient.add_ground` 加可选 owner 入参),
|
||
回归 70 全 PASS(无新增测试文件)。~~剩余 seam ⑩:owner 未真正独立渲染成 CENTER 黄字、
|
||
`strOwnership` 未接本地化语素~~ 增量 126 已收口(见下);只对掉落 item 名条做。
|
||
**增量 126 已落地 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
|
||
接本地化语素)**:参考 `SetItemTextTailOwner`(`PythonTextTail.cpp:712-745`)+ `ArrangeTextTail`
|
||
(:191-207)—— owner 非空 → 新建 `pOwnerTextInstance`(:715)、文本 = `c_szName + strOwnership`
|
||
(:723,`strOwnership = ApplicationStringTable(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...`,
|
||
:722)、`SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)`(:727)、`SetColor(1,1,0)` 黄(:729);
|
||
排布时 owner 落 `(x, y)`(:195)、名字实例落 `(x, y + 15.0f)`(:200);owner 为空 →
|
||
`Delete(pOwnerTextInstance)`(:743)。`ground_items`:drop 节点下第二个 `Label3D "owner_tag"`
|
||
(`horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER` / `modulate = Color(1,1,0)` / billboard
|
||
/ `no_depth_test` / `pixel_size 0.005`,文本 = `owner + _possessive`),`_apply_owner_tag` 建 /
|
||
删;名字 `tag` 只放 `_name_for(vnum)`(`_tag_text` 删)。新增 `const OWNER_NAME_GAP_PX := 15.0`
|
||
+ `_place_tail(n, tag, d_px)`:有 owner → `owner_tag.y = TAG_BASE_Y - d_px*px`、`tag.y =
|
||
TAG_BASE_Y - (d_px + 15)*px`;无 → `tag.y = TAG_BASE_Y - d_px*px`。`setup()` 加可选
|
||
`possessive := ""`(`_possessive = possessive if possessive != "" else OWNER_POSSESSIVE`,
|
||
1:1 对齐 :722 的 `== "" ? "'s" :` 语义),`game_scene` 从 `EterLocale` 的 `POSSESSIVE_MORPHENE`
|
||
键解析后注入(poc 无此键 → 回落 `"'s"`,与 40250 西文 `.rc` 的 `IDS_POSSESSIVE_MORPHENE
|
||
"'s"` 一致)。去重叠框度量仍是增量 125 的 `item_box_with_owner`(`yEnd += ownerH + 4`)不变。
|
||
测试扩 `p2b_test.gd`(drop_302 名字实例 `"掉落28"`、`owner_tag` `"Alice's"` / CENTER / 黄 /
|
||
落基点、名字下移 `15 * pixel_size`;无 owner 的 drop_301 不建 `owner_tag`;drop_401
|
||
`setup(possessive="@")` → `owner_tag` `"Bob@"`),回归 70 全 PASS(无新增测试文件)。剩余
|
||
seam ⑩:owner 行颜色语义固定黄(参考名字实例取 `pInsertTextTail->Color` 品阶色、poc 名字
|
||
走 `_process` 远近着色)、去重叠只对掉落 item 名条做。
|
||
3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的
|
||
`ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。
|
||
**增量 110 已完成名字色本身**(`name_color.gd` + `net_world.gd` 接线,见本节参考的增量 110 条);
|
||
**增量 111 已完成称号色 `GetTitleColor` + 公会名 + 等级标签**(见本节参考的增量 111 条);
|
||
**增量 112 已完成聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail`**(`chat_tail.gd` +
|
||
`net_world.gd` `"bubble"` 子节点,见本节参考的增量 112 条);
|
||
**增量 113 已完成名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail`
|
||
3500 裁剪**(`name_show.gd` + `net_world._name_shown()`,见本节参考的增量 113 条);
|
||
**增量 114 已完成头顶伤害飘字 `AddDamageEffect` / `ProcessDamage` 分类 + `IsShowDamage` 门**
|
||
(`damage_effect.gd` + `net_world._on_damage` / `_process_damage_queue` / `set_show_damage`,
|
||
见本节参考的增量 114 条;seam ⑪);
|
||
**增量 115 已完成目标框 `RecvTargetPacket` 收包分派 + `CanViewTargetHP`**(`target_board.gd`
|
||
+ `net_play._on_target_info` 按判定树分派 —— PC / building 目标不再显血条框、NPC / warp
|
||
不弹框、石头 / 敌对怪 `SetHPTargetBoard`、换 VID 先 Reset,见本节参考的增量 115 条;seam ⑫);
|
||
**增量 116 已完成 `OnRender` Picking 块分派 + 悬停角色强显名字**(`pick_show.gd` +
|
||
`net_world._hover_vid` / `set_hover_vid` / `_name_shown()` forced 第 5 路 +
|
||
`player_controller.hover_entity_changed` + `game_scene` 连线,见本节参考的增量 116 条;
|
||
seam ⑨ / ⑩);
|
||
**增量 117 已完成 seam ⑨ 收口(名字尾标随聊天气泡上移 17px,`PythonTextTail.cpp:112-114`)**
|
||
(`name_tail_layout.gd` + `net_world._reposition_name_tag` 由 `_apply_chat_tail` /
|
||
`_expire_chat_tails` 调用 —— 冒「聊天」气泡时名字 `Label3D` 局部 y 顶到「气泡 y +
|
||
17*pixel_size」、气泡消失或只是「信息」尾标时回默认 2.1,见本节参考的增量 117 条);
|
||
**增量 118 已完成目标框「看他国玩家」前置过滤(`uitarget.py TargetBoard.Open :189-193`)**
|
||
(`target_board.hidden_as_other_empire(view_other_empire, same_empire)` +
|
||
`net_play._on_target_info` 在 `classify` 前先判 —— game-option 关「看他国目标框」且目标
|
||
异阵营(`EntityRules._is_same_empire` 为假,empire 0 的怪 / NPC / 石头恒同阵营不受影响)
|
||
→ `_clear_target()` 直接 return;`net_play.set_view_other_empire_target()` 由 §8.4 radio
|
||
驱动,见本节参考的增量 118 条);
|
||
**增量 119 已完成 seam ⑦ 收口(子标签随名字尾标纵向位移,`ArrangeTextTail` :211/:228/:258/:283)**
|
||
(`name_tail_layout.gd` 增 `name_tail_lift_delta` / `subtag_world_y` +
|
||
`net_world` `SUBTAG_DEFAULT_Y` 常量 + `_reposition_name_tag` 顶完名字尾标后按同一
|
||
`lift` 平移 `GuildTag`/`TitleTag`/`LevelTag`,气泡过期 / 「信息」尾标时回默认,见本节参考的增量 119 条);
|
||
**增量 120 已完成 seam ⑩ 收口(`ArrangeTextTail` item 尾标去重叠 + `isIn`,`PythonTextTail.cpp:160-190` / :489-507)**
|
||
(第 29 个 canonical `text_tail_arrange.gd`:`boxes_overlap`=`isIn` + `arrange_item_tails`
|
||
逐条 / 从头重跑 / `MAX_ITERS` 20 封顶 + `box_*` 四边 `-w/2-2` `w/2+2` `-2` `h`;
|
||
`ground_items._arrange_tags` 每帧投影所有掉落名条、跑去重叠、按 `pixel_size` 折回局部 y +
|
||
`game_scene` 传相机 getter,见本节参考的增量 120 条);
|
||
**增量 121 已完成 seam ⑦ 水平深化(角色子标签逐像素水平排版,`ArrangeTextTail` :211-320 非 EUROPE 分支)**
|
||
(`name_tail_layout.gd` 加 `name_x_shift`(fxAdd 8/4/0)/ `title_x_offset`(`−nameW/2 − 8`)/
|
||
`level_x_offset`(有称号再 `− titleW`)/ `guild_name_x_offset` / `mark_x_offset` / `subtag_world_x`;
|
||
`net_world._layout_subtags_x` 在 `_apply_text_tail_to` 尾部设名字 / `TitleTag` / `LevelTag` 的
|
||
`.position.x`,`GuildTag` 留 0(poc 无公会徽记),文本宽 `字符数 * 7px` 估,与增量 117/119
|
||
纵向位移解耦,见本节参考的增量 121 条);
|
||
**增量 122 已完成 seam ⑫ 收口(目标框点击 `LCONTROL` 悄悄话捷径,`game.py SetPCTargetBoard :804-817`)**
|
||
(`net_play` 加 `signal whisper_requested(name)` / `var _lcontrol_down` / `set_lcontrol_down(down)`,
|
||
`_on_pick` 在 `KIND_PC` 分支(C++ picking 只对 PC 派发 `SetPCTargetBoard`)调
|
||
`target_board.whisper_on_ctrl_click(_lcontrol_down, _is_same_empire(_main_entity(), e),
|
||
vid == _main_vid, false)` 真则发 `whisper_requested`;`game_scene._unhandled_input` 加 `KEY_CTRL`
|
||
按下 / 松开 → `set_lcontrol_down(e.pressed)`,`net_play.whisper_requested` → `chat.start_whisper`
|
||
(poc 版 `OpenWhisperDialog` = 聊天输入预填 `"/w 名 "`);`is_building` 实参恒 false(门实体缺,
|
||
:814 分支永不触发)、无独立 whisper dialog 窗口 = seam ⑫ 余留,见本节参考的增量 122 条);
|
||
**增量 123 已完成 seam ⑦ / ⑩ 收口(头顶子标签 / 掉落名条文本宽换真实字体度量 `GetTextSize` 1:1)**
|
||
(新增纯静态 `text_metrics.gd` 第 30 个 canonical:`text_size` = `Font.get_string_size(text,
|
||
HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)`、`width_of`(无字体回退 `len * 7.0`)、`label_width`
|
||
(`label.font ?? ThemeDB.fallback_font`);`net_world._layout_subtags_x` 的 `name_w` / `title_w`、
|
||
`ground_items._arrange_tags` 的框宽 `w` 由 `字符数 * *_CHAR_W_PX` 换 `TextMetrics.label_width`,
|
||
`*_CHAR_W_PX = 7.0` 留作无字体兜底;`name_tail_layout.gd` / `text_tail_arrange.gd` 排布公式不动,
|
||
见本节参考的增量 123 条);
|
||
**增量 124 已完成 seam ⑦ / ⑩ 末尾(掉落名条去重叠框的单行文本高换真实字体度量 `GetTextSize`
|
||
的 h 1:1)**(`text_metrics.gd` 仍第 30 个 canonical、扩非新增:加 `const FALLBACK_LINE_H_PX
|
||
:= 15.0` + `height_of`(空串 → 0;量得正高用 `text_size(...).y`;否则回退)+ `label_height`
|
||
(`label.font ?? ThemeDB.fallback_font`);`ground_items._arrange_tags` 的框高 `h` 由硬编码
|
||
`TAG_LINE_H_PX` 换 `maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))`,`TAG_LINE_H_PX
|
||
= 15.0` 留作无字体兜底;`text_tail_arrange.gd`(`box_end_y(h) = h`)/ `name_tail_layout.gd`
|
||
排布公式不动,测试扩 `text_metrics_test.gd`,回归 70 全 PASS,见本节参考的增量 124 条);
|
||
**增量 125 已完成 seam ⑩ 末尾(带 owner 的掉落名条去重叠框:owner 独立度量 + `yEnd +=
|
||
yOwnerSize + 4` 1:1)**(`text_tail_arrange.gd` 扩第 29 个 canonical:加纯静态
|
||
`item_box_with_owner(name_w, name_h, owner_w:=0.0, owner_h:=0.0)` —— 无 owner → 纯名字框,
|
||
有 owner → `{maxf(name_w, owner_w-2.0), name_h+owner_h+4.0}`(`box_end_x(w)` 恰等于参考
|
||
`SetItemTextTailOwner` :737 的 `fMAX(name/2+2, owner/2+1)`);`text_metrics.gd` 扩第 30 个
|
||
canonical:加 `string_width_like` / `string_height_like`(用某 `Label3D` 的字体量任意子串);
|
||
`ground_items._on_added` 存 `item_name` / `item_owner` meta、`_arrange_tags` 把名字子串与
|
||
owner + `'s` 子串分别度量后走 `item_box_with_owner`;渲染仍单个合并 `"name (owner)"`
|
||
`Label3D`(seam ⑩ 折中);测试扩 `text_tail_arrange_test.gd` / `text_metrics_test.gd` /
|
||
`p2b_test.gd`,回归 70 全 PASS,见本节参考的增量 125 条);
|
||
**增量 126 已完成 seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + `strOwnership`
|
||
接本地化语素)**(`ground_items`:drop 节点下第二个 `Label3D "owner_tag"`(CENTER /
|
||
`modulate = Color(1,1,0)` / 文本 = `owner + _possessive`,`_apply_owner_tag` 建 / 删),
|
||
名字 `tag` 只放名字(`_tag_text` 删);`const OWNER_NAME_GAP_PX := 15.0` + `_place_tail`:
|
||
有 owner → owner 落 `TAG_BASE_Y - d_px*px`、名字落 `TAG_BASE_Y - (d_px + 15)*px`(参考
|
||
`ArrangeTextTail` :195 / :200);`setup()` 加可选 `possessive`(`== "" ? OWNER_POSSESSIVE`,
|
||
对齐 `SetItemTextTailOwner` :722),`game_scene` 从 `EterLocale` `POSSESSIVE_MORPHENE` 键注入
|
||
(无则回落 `"'s"`);去重叠框度量仍是增量 125 的 `item_box_with_owner`;测试扩 `p2b_test.gd`,
|
||
回归 70 全 PASS,见本节参考的增量 126 条);
|
||
`text_tail.gd` 的屏幕空间血条整合已由增量 127 收口;掉落 owner 行品阶色也已由增量 127
|
||
接入。当前仍待:三子标签并到名字同一屏幕行 / 接公会徽记 mark(seam ⑦ 末尾)、名字与气泡
|
||
合成单尾标链表(seam ⑨),以及真实服务器 / 真机验收。建筑 / 木门分类和独立 whisper dialog
|
||
也已由增量 127 接入,不再列为未实现。
|
||
4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。
|
||
|
||
---
|
||
|
||
### 8.9 任务日志导航和完整图标视觉仍不完整
|
||
|
||
增量 101 已在 §5.2 补齐默认 / 服务端 icon、5 行窗口、滚动起始索引、倒计时和任务行事件;
|
||
增量 108 已补齐小地图任务信标的世界箭头 / 屏幕边缘指示(越界夹圆周 + 方向箭头,见 §5.2 条 3);
|
||
增量 109 已按 `interfacemodule.__ArrangeQuestButton` 补齐信件按钮条多列定位 + 最新在前 + 组队窗
|
||
`xPos` 移位(见 §5.2 条 3、C.5 增量 109)。当前仍缺任务日志窗体 `_list` 自身的多列 / 分页、
|
||
真实 `.sub` 状态图 / Flash 视觉和服务端 `iStartTime` 生命周期。
|
||
不得再把 `project/ui/quest_log.gd` 描述成纯文字占位列表。
|
||
|
||
---
|
||
|
||
## 9. P1/P2:音频和应用生命周期
|
||
|
||
### 9.1 地图 BGM 字段被解析但没有消费
|
||
|
||
**参考**
|
||
|
||
- 解析与存储(`REF/UserInterface/PythonNetworkStreamPhaseLoading.cpp`):
|
||
```
|
||
RecvMainCharacter3_BGM() :244 → __SetFieldMusicFileName(mainChrPacket.szBGMName) // :261
|
||
RecvMainCharacter4_BGM_VOL() :271 → __SetFieldMusicFileInfo(szBGMName, fBGMVol) // :288
|
||
```
|
||
```cpp
|
||
// PythonNetworkStreamPhaseLoading.cpp:299
|
||
static std::string gs_fieldMusic_fileName;
|
||
static float gs_fieldMusic_volume = 1.0f / 5.0f * 0.1f; // = 0.02f 默认值
|
||
void __SetFieldMusicFileName(const char* n) { gs_fieldMusic_fileName = n; }
|
||
void __SetFieldMusicFileInfo(const char* n, float vol) { gs_fieldMusic_fileName = n; gs_fieldMusic_volume = vol; }
|
||
const char* GetFieldMusicFileName() { return gs_fieldMusic_fileName.c_str(); }
|
||
float GetFieldMusicVolume() { return gs_fieldMusic_volume; }
|
||
```
|
||
- 消费(`POC/assets/root/game.py:190`,`GameWindow.Open()` 内):
|
||
```python
|
||
snd.SetMusicVolume(systemSetting.GetMusicVolume())
|
||
snd.SetSoundVolume(systemSetting.GetSoundVolume())
|
||
netFieldMusicFileName = net.GetFieldMusicFileName()
|
||
if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName)
|
||
elif musicInfo.fieldMusic != "": snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
|
||
```
|
||
关窗(`game.py:225`):`if musicInfo.fieldMusic != "": snd.FadeOutMusic("BGM/" + musicInfo.fieldMusic)`。
|
||
- 默认曲目(`POC/assets/root/musicinfo.py`):
|
||
```python
|
||
METIN2THEMA = "M2BG.mp3"
|
||
loginMusic = "login_window.mp3"
|
||
createMusic = "characterselect.mp3"
|
||
selectMusic = "characterselect.mp3"
|
||
fieldMusic = METIN2THEMA
|
||
SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf"
|
||
```
|
||
|
||
**当前**:`POC/extension/src/net/classic/classic_parser.cpp:173` 丢弃 BGM 名与音量。
|
||
|
||
**修改**
|
||
|
||
1. `ClassicParser` 保存 `field_music_name` / `field_music_volume`,默认音量
|
||
`1.0f/5.0f*0.1f = 0.02f`;`M2Client` 暴露 `get_field_music_name()` / `get_field_music_volume()`。
|
||
2. `game_scene.gd` 进图时按 `game.py:190` 的三行逻辑选 BGM(服务端名优先,
|
||
否则 `musicInfo.fieldMusic`),并先 `SetMusicVolume/SetSoundVolume`。
|
||
3. `musicinfo.gd` 落 `METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic`
|
||
五个常量与 `save_last_play_field_music / load_last_play_field_music`
|
||
(文件路径 `user://BGM/lastplay.inf`)。
|
||
4. 换图(§1.3)走同一条选曲逻辑。
|
||
|
||
**状态**:✅ 完成(曲目解析 + 音量在 Godot 侧落地;C++ 侧 BGM 名/音量路由与保留由增量 49 完成)。
|
||
真服验收待 40250 服务端。
|
||
|
||
**改动**(2026-09-02 / W4)
|
||
|
||
- `project/bgm_director.gd`:`_on_bgm_changed` / 新增 `_on_world_reset` → `_select()`;
|
||
`_select()` 实现 `game.py:190` 三行逻辑——服务端 `current_track` 非空即播,为空回落
|
||
`MusicInfo.fieldMusic`(`METIN2THEMA = "M2BG.mp3"`);音量 `current_volume >= 0` 用服务端值,
|
||
否则 `DEFAULT_FIELD_MUSIC_VOLUME = 1.0/5.0*0.1 = 0.02`;先 `master_bgm = vol` +
|
||
`set_music_volume(vol)` 再 `play_bgm(track)`(淡入前置音量);`bind()` 连
|
||
`M2Client.world_reset` 做换图重选。声音(SFX)音量参考取自系统设置,不随 BGM 包下发,
|
||
故不改 `master_sfx`。
|
||
- `project/musicinfo.gd`(新):常量 `METIN2THEMA / loginMusic / createMusic / selectMusic /
|
||
fieldMusic`;静态 `save_last_play_field_music(name)` / `load_last_play_field_music()`
|
||
持久化到 `user://BGM/lastplay.inf`。
|
||
|
||
**测试**:`godot --headless --path project --script res://bgm_test.gd` → PASS(exit 0)。
|
||
覆盖:服务端名+音量、名+音量未指定(默认 0.02)、名为空回落 `fieldMusic`、`world_reset`
|
||
重跑选曲、`lastplay.inf` 往返。`gamescene_test` / `netplay_test` / `p9_test` 回归全绿。无 C++ 改动。
|
||
|
||
**验收**:未验证(无真实 40250 服务端;地图 BGM 名 / 音量需服务端 `GC_MAIN_CHARACTER3/4` 数据)。
|
||
|
||
---
|
||
|
||
### 9.2 后台恢复后可能没有 BGM
|
||
|
||
**参考**:参考端是 Windows 桌面客户端,没有 Android/iOS 的前后台切换语义——
|
||
本条**没有 1:1 参考**。最接近的参考行为是
|
||
`CSoundManager::SaveVolume() / RestoreVolume()`(`REF/MilesLib/SoundManager.h:31`),
|
||
它保存/恢复 `m_fBackupMusicVolume` / `m_fBackupSoundVolume`。
|
||
|
||
**修改**(按参考的 Save/Restore 语义,而不是 stop/replay)
|
||
|
||
1. `POC/project/app_lifecycle.gd:67` 的 `_enter_background()` 把
|
||
`_bound_audio.stop_bgm()` 改为 `_bound_audio.save_volume()` + 静音
|
||
(等价 `SaveVolume()` 后 `SetMusicVolume(0)`)。
|
||
2. `_exit_background()`(`app_lifecycle.gd:75`)加 `_bound_audio.restore_volume()`。
|
||
3. `Audio` 增加 `save_volume() / restore_volume()`,字段名对齐
|
||
`m_fBackupMusicVolume / m_fBackupSoundVolume`。
|
||
4. 这样 `_bgm_name` 不再被清空,曲目继续播放,恢复后音量还原。
|
||
|
||
**状态**:✅ 完成。
|
||
|
||
**改动**(2026-09-02 / W4)
|
||
|
||
- `project/audio.gd`:新增 `_backup_music_volume` / `_backup_sound_volume`(对齐参考
|
||
`m_fBackupMusicVolume / m_fBackupSoundVolume`)+ `_volume_saved` 守卫;`save_volume()`
|
||
存 `master_bgm/master_sfx`,`restore_volume()` 还原并把音量重新贴到当前 BGM 声道;
|
||
新增 `set_music_volume(v)`(立即把 `master_bgm` 应用到正在播放的声道,无淡入)。
|
||
- `project/app_lifecycle.gd`:`_enter_background()` 把 `_bound_audio.stop_bgm()` 换成
|
||
`save_volume()` + `set_music_volume(0.0)`(等价 `SaveVolume()` 后 `SetMusicVolume(0)`);
|
||
`_exit_background()` 加 `restore_volume()`。`_bgm_name` / `_bgm[]` 全程不动。
|
||
|
||
**测试**:`bgm_test.gd` 的 `_test_background_save_restore`——`_enter_background()` 后
|
||
`master_bgm == 0`、备份为 `0.6/0.9`、`_bgm_name` 不变;`_exit_background()` 后
|
||
`master_bgm/master_sfx` 还原为 `0.6/0.9`、`_bgm_name` 仍不变、备份标志清除。PASS(exit 0)。
|
||
|
||
**验收**:未验证(需真机 Android/iOS 前后台切换;本条无 1:1 桌面参考)。
|
||
|
||
---
|
||
|
||
### 9.3 生命周期所有权重复
|
||
|
||
**状态**:✅ 代码与离线回归完成(2026-09-02 / W0 / 增量 51);🔎 真机通知和断线重连仍待验收。
|
||
|
||
**参考**:生命周期协调责任在 `CPythonApplication`(`REF/UserInterface/PythonApplication.cpp`
|
||
+ `PythonApplicationEvent.cpp`),网络流只是被它驱动的子系统,不应再独立消费同一组应用通知。
|
||
|
||
**已实现**:
|
||
|
||
1. `project/app_flow.gd:56-62` 在生产入口 `client_main.gd` 下创建并持有唯一 `AppLifecycle`,从
|
||
登录、选人到游戏都不随界面切换销毁;`_goto_login()` 会先解除旧场景音频绑定,
|
||
`_goto_game()` 在场景装配完成后绑定当前 `Audio`(`app_flow.gd:143-181`)。
|
||
2. `project/game_scene.gd:181-184` 不再创建 `AppLifecycle`,只提供 `lifecycle_audio()`(:892-895)
|
||
让 AppFlow 绑定当前场景音频;因此切屏不会累积暂停 / 恢复监听器。
|
||
3. `extension/src/net/m2_client.cpp:2620-2623` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED`
|
||
入口,`M2Client` 仅保留可被协调者调用的 `suspend()/resume()`;头文件同步移除声明。
|
||
4. 兼容启动器 `project/login.gd:33-37,142-152` 不属于生产 `AppFlow` 路径,但仍保持自己的单一
|
||
生命周期节点贯穿登录到游戏,进入游戏时重新绑定 `M2Client + Audio`,不再错误销毁节点。
|
||
|
||
**测试**:
|
||
|
||
- `godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log --script app_flow_lifecycle_test.gd`
|
||
→ PASS:AppFlow 只有一个生命周期节点,登录构建后仍存在,客户端由同一 AppFlow 持有。
|
||
- `godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log --script gamescene_test.gd`
|
||
→ PASS:GameScene 不创建第二个节点且暴露场景音频。
|
||
- 两个 Godot 进程不能并行共用默认 `user://logs`;首次并行运行触发共享日志 / 图形上下文冲突,
|
||
已改为带独立 `--log-file` 的串行回归并通过。该失败不是代码断言失败,后续测试保持串行。
|
||
|
||
**验收**:未验证 Android/iOS 真机前后台、内存告警和后台约 30 秒后 TCP 被系统回收时的重连;
|
||
这些属于平台门禁,不在 headless 回归中伪标为完成。
|
||
|
||
**下一步**:由 W5 / 平台验收补真机证据;若需要产品级自动重连策略,应另立增强项,不把它混入
|
||
ClientVS22 的“断线回登录”1:1 判据。
|
||
|
||
---
|
||
|
||
### 9.4 MilesLib 事件模型没有迁移
|
||
|
||
**参考**:`CSoundManager`(`REF/MilesLib/SoundManager.h`)的完整接口:
|
||
|
||
```
|
||
// 3D 监听者
|
||
SetPosition(x,y,z) SetDirection(xDir,yDir,zDir, xUp,yUp,zUp) Update()
|
||
|
||
// 缩放与音量
|
||
float m_fSoundScale = 200.0f; // SoundManager.cpp:20
|
||
GetSoundScale / SetSoundScale / SetAmbienceSoundScale
|
||
SetSoundVolume / SetSoundVolumeRatio / SetSoundVolumeGrade(int)
|
||
SetMusicVolume / SetMusicVolumeRatio / SetMusicVolumeGrade(int)
|
||
SaveVolume / RestoreVolume / GetSoundVolume / GetMusicVolume
|
||
|
||
// 音量换算(SoundManager.cpp:245/250)
|
||
__ConvertGradeVolumeToApplyVolume(grade) = __ConvertRatioVolumeToApplyVolume(grade / 5.0f)
|
||
__ConvertRatioVolumeToApplyVolume(r) = (r < 0.1f) ? r : pow(10.0f, -1.0f + r)
|
||
|
||
// 播放
|
||
PlaySound2D(file)
|
||
PlaySound3D(x,y,z,file, playCount=1) StopSound3D(index)
|
||
PlayAmbienceSound3D(x,y,z,file, playCount=1)
|
||
PlayCharacterSound3D(x,y,z,file, bCheckFrequency=FALSE)
|
||
SetSoundVolume3D(index, volume) StopAllSound3D()
|
||
|
||
// 音乐(MUSIC_INSTANCE_MAX_NUM 个通道)
|
||
PlayMusic(file)
|
||
FadeInMusic(file, volumeSpeed = 0.016f)
|
||
FadeOutMusic(file, volumeSpeed = 0.016f)
|
||
FadeLimitOutMusic(file, limitVolume, volumeSpeed = 0.016f)
|
||
FadeOutAllMusic() FadeAll()
|
||
EMusicState { OFF, PLAY, FADE_IN, FADE_OUT, FADE_LIMIT_OUT }
|
||
|
||
// 动作声音节点(.msa 的 sound 事件)
|
||
UpdateSoundData(curFrame, soundDataVector)
|
||
UpdateSoundData(x,y,z, curFrame, soundDataVector)
|
||
UpdateSoundInstance(x,y,z, curFrame, soundInstanceVector, bCheckFrequency=FALSE)
|
||
UpdateSoundInstance(curFrame, soundInstanceVector)
|
||
```
|
||
|
||
3D 定位(`SoundManager.cpp:349` / `:415`):
|
||
```cpp
|
||
pInstance->SetPosition((fx - m_fxPosition) / m_fSoundScale,
|
||
(fy - m_fyPosition) / m_fSoundScale,
|
||
(fz - m_fzPosition) / m_fSoundScale);
|
||
```
|
||
即**世界坐标差 ÷ 200** 才是音频引擎坐标。
|
||
|
||
角色声音去重(`PlayCharacterSound3D`,`SoundManager.cpp:380`,仅当 `bCheckFrequency`):
|
||
```
|
||
static float s_fLimitDistance = 5000*5000; // 距离平方上限
|
||
dx² + dy² > s_fLimitDistance → 不播
|
||
同一文件距上次播放 < 0.3f 秒 → 不播
|
||
否则记录 m_PlaySoundHistoryMap[file] = now
|
||
```
|
||
|
||
**当前(增量 55/63/67)**:已实现 MilesLib 可复用子集:BGM crossfade、UI 音效池、普通 / 环境 /
|
||
角色 3D 声道、监听器位置 / 方向、200/1000 缩放、参考音量曲线、角色 5000cm / 0.3 秒限频、3D
|
||
实例 stop / 全停和音乐限幅淡出;`.msa` 本地动作声音以及 `.mss` 动作声音时间表已接入角色声道。
|
||
|
||
**已实现**
|
||
|
||
1. `POC/project/audio.gd` 已按上表补齐可复用 API 与常量:
|
||
`SOUND_SCALE = 200.0`、`FADE_SPEED = 0.016`、`CHAR_SOUND_MIN_INTERVAL = 0.3`、
|
||
`CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`;另有 `AMBIENCE_SOUND_SCALE = 1000.0`。
|
||
2. `SetPosition/SetDirection/Update` 已由 `set_listener/update_listener` 提供,3D 音效位置按
|
||
`(world - listener) / scale` 计算,活动实例会在监听器移动时重新基准。
|
||
3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现
|
||
(`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio;
|
||
系统设置现有滑条继续保存线性应用值,另提供 `set_sound_volume_ratio/grade`、
|
||
`set_music_volume_ratio/grade` 供协议入口使用。
|
||
4. 三类声道已分开:普通 3D(`play_sound_3d`)、环境(`play_ambience_sound_3d`,
|
||
有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。
|
||
5. `game_scene.gd` 的 `.msa` sound 事件已走 `play_character_sound_3d(..., check_frequency=true)`,
|
||
对齐角色动作声音的频率保护。
|
||
6. 已提供 `set_sound_volume_3d`、`stop_sound_3d`、`stop_all_sound_3d`,音乐支持
|
||
`fade_limit_out_music` 与 `fade_out_all_music`。
|
||
|
||
7. 增量 63 已按 `REF/MilesLib/Type.cpp:17-74,118-136` 实现 `.mss`:解析
|
||
`SoundDataCount` / `SoundData%02d` 的时间和虚拟声音路径,按 `DWORD(time / (1/60))` 转为声音
|
||
实例;`sound/` 前缀会映射到实际 patch 的 `sound` 根。`load_mss_for_motion()` 根据 `.msa`
|
||
的 `ymir work` 相对路径找到配对脚本,`update_sound_instances_3d()` 在动作帧进入时按参考
|
||
`UpdateSoundInstance` 触发 `PlayCharacterSound3D`。PlayerView / MobView 都已接入。
|
||
|
||
**仍未完成**
|
||
|
||
8. `UpdateSoundData/UpdateSoundInstance` 的 `.mss` / sound-node 数据结构仍需继续补齐:当前已覆盖
|
||
`.mss` 文件读取、实例帧换算和动作帧播放,但还没有把 EffectInstance 的完整生命周期 / 节点
|
||
停止语义全部接到 Godot。
|
||
9. 增量 68–72 已按 `REF/GameLib/MapUtil.cpp:1-205`、`REF/GameLib/MapOutdoor.cpp:246-323` 和
|
||
`REF/EterLib/SkyBox.cpp:403-542` 补齐 `.msenv` 解析字段与参考缺省值、背景 / 角色双方向光
|
||
分层、texture-mode 六面天空,以及真实云层贴图到 `ProceduralSkyMaterial.sky_cover` / sky shader;
|
||
`CloudScale` / `CloudHeight` / `CloudTextureScale` / `CloudSpeed` 已映射到 texture-mode 云面,
|
||
原始参数已通过 `WorldEnvironment` 元数据保留。仍需完成云层渐变过渡、滤色实际合成、SpeedTree
|
||
完整风语义、lens flare、昼夜插值,以及武器 / 技能 / 怪物 / NPC 所有声音事件。
|
||
|
||
10. 增量 67 已按 `REF/GameLib/Area.cpp:882,1316-1394` 与
|
||
`REF/GameLib/MapType.cpp:195-260` 接入地图环境声:C++ 世界层解析每个已加载区块的
|
||
`AreaAmbienceData`(兼容大小写文件名),通过 `.pra` CRC 提供位置、范围、最大音量区域比例、
|
||
播放类型、间隔和声音向量;该阶段独立于对象渲染和 splat 开关,并在区块卸载时移除来源。
|
||
`audio.gd` 对齐参考的严格 `< range` 判定、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 / 随机
|
||
变化和离开区域停止;世界米坐标先还原为厘米再应用 Miles 的 3D 缩放。`ambience_test.gd`
|
||
使用真实 OutdoorA3 区块和 `warp_test.mp3` 通过。
|
||
|
||
---
|
||
|
||
## 10. 发布和验证问题
|
||
|
||
> 本节没有 `ClientVS22` 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。
|
||
> 因此这里只写**可执行的动作**,不假装有 1:1 依据。
|
||
|
||
### 10.1 Mac App 不是自包含资源包
|
||
|
||
**当前(增量 53)**:`build-macos-client.sh` 已改为把 `assets` / `bgm` 用 `ditto` 复制到
|
||
`Contents/Resources/`,并检查 `assets/asset_index.txt`;`MT_ASSETS` 只作为开发覆盖路径。
|
||
代码路径已完成,但尚未执行完整 2.2G 资源导出和跨机器启动验收。
|
||
|
||
**剩余验收**:把 `.app` 拷到另一台机器(或暂时移开工作区资源)后启动并进图;确认包内所有
|
||
资源读取都没有回退到工作区。
|
||
|
||
### 10.2 主程序和 GDExtension 架构不一致
|
||
|
||
**当前(增量 53)**:`build.sh` / `build-macos-client.sh` 支持 `MT_MAC_ARCHES=arm64`、
|
||
`x86_64` 或 `universal`;universal 模式传入 `CMAKE_OSX_ARCHITECTURES=arm64;x86_64`,打包脚本
|
||
用 `lipo -info` 拒绝单架构扩展。
|
||
|
||
**剩余验收**:当前已检查的 `project/bin/libmtgodot.macos.template_debug.dylib` 仍为 arm64;
|
||
需在可用的双架构工具链和完整导出模板下重建,并分别在 Apple Silicon / Intel Mac 启动。
|
||
|
||
### 10.3 当前是 Ad-hoc 签名
|
||
|
||
**当前(增量 53)**:脚本支持 `CODESIGN_IDENTITY`,按 Contents 内嵌扩展先签、App 后签,
|
||
并传入 hardened runtime 选项;默认身份仍为 `-`(Ad-hoc),脚本未伪称正式发布。
|
||
|
||
**剩余验收**:配置真实 Developer ID 后执行签名、公证和装订,并用
|
||
`spctl -a -vvv -t install <app>` 验证;当前没有证书、notarization 或 stapled ticket 证据。
|
||
|
||
### 10.4 发布构建仍包含 Demo 默认账号密码
|
||
|
||
**当前(增量 53)**:`app_flow.gd`、`login.gd`、`charselect.gd` 已移除硬编码账号密码;
|
||
账号 / 密码从 `MT_ACCOUNT` / `MT_PASSWORD` 读取,自动登录还要求 `MT_AUTOLOGIN=1`。
|
||
|
||
**剩余验收**:release 预设的 PCK / 导出包加入字符串扫描门禁,确认不包含旧 Demo 密码。
|
||
|
||
### 10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策
|
||
|
||
**参考侧的事实**(用于做决策,不是要求实现):
|
||
- `REF/UserInterface/PythonApplicationLogo.cpp`、`MovieMan.cpp`、
|
||
`PythonApplicationWebPage.cpp` + `REF/CWebBrowser/`、`REF/Discord/`
|
||
(`ENABLE_DISCORD_RPC` 在 `REF/UserInterface/Locale_inc.h:6` 是**注释掉的**)、
|
||
`CheckLatestFiles.cpp`(补丁器)。
|
||
- 反作弊四件套见 §1.6,已在不做清单。
|
||
|
||
**修改**
|
||
|
||
1. 在 `CLIENT-GAP.md` 的不做清单里逐项定性:
|
||
Logo / Movie / Web / Discord(参考默认关闭)→ ❌;
|
||
补丁器(`CheckLatestFiles`)→ 单独立项或 ❌。
|
||
2. 决策写进 `CLIENT-ROADMAP.md`,避免每轮审计重复出现。
|
||
|
||
### 10.6 缺少真实验收门禁
|
||
|
||
**修改**:建立 `POC/docs/ACCEPTANCE.md`,每项写「前置条件 / 步骤 / 通过判据」:
|
||
|
||
1. 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。
|
||
2. 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。
|
||
3. 断线重连:拔网线 → 回登录页(§1.2)。
|
||
4. 纯 pack 运行:`MT_ASSETS_PACK_ONLY=1`(§7.2)。
|
||
5. Intel Mac(§10.2)。
|
||
6. 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约
|
||
(见 `PARITY-GAP.md §0`,该门禁已存在,保持)。
|
||
7. 移动端真机性能。
|
||
|
||
---
|
||
|
||
## 附录 A:本次审计对 `CLIENT-GAP.md` 的订正
|
||
|
||
对照参考源码后,`CLIENT-GAP.md`(2026-09-02 审计节)有以下条目需要订正或加限定。
|
||
**订正分三档**:❶ 参考端行为与原文不符 · ❷ 原文方向对但表述需加限定 ·
|
||
❸ 原文低估了差距(当前实现另有问题)。
|
||
|
||
| 原条目 | 档 | 订正后的说法 | 依据 |
|
||
|---|---|---|---|
|
||
| 协议覆盖审计 | ❶+❸ | `Packet.h` 是按 To Server / From Server 分段的全局 enum,不是 `namespace CG/GC`。主文档此前把 m2dev `wire.h` 与默认 Classic 混算,并把历史统计写成当前结论;应按 `MT_PROTOCOL=classic` 与 `MT_PROTOCOL=m2dev` 分开。当前默认 Classic 的参考编号是 `CG_CHARACTER_CREATE=4`、`CG_MYSHOP=55`、`CG_WARP=65`、`CG_FISHING=82`、`CG_DUNGEON=110`,而不是 `0x0201`、`0x0802`、`0x0B01` 等 m2dev 编号。 | `REF/UserInterface/Packet.h:25`、`POC/extension/src/net/classic/wire_classic.h:104`、`POC/project/client_main.gd:11` |
|
||
| `CG_TEXT` | ❶ | `CG_TEXT=64 (0x40)`,不是 `0x0011`。它存在于 40250 Server / Classic wire,但不在 ClientVS22 客户端普通 To Server 命名项中,不应计入 ClientVS22 客户端 header 总数。 | `40250/Server/.../packet.h:49`、`REF/UserInterface/Packet.h:88-93` |
|
||
| 拍卖行 | ❶ | 默认构建未见完整 UI / handler,但 `__AUCTION__` 条件下仍有 `CG_AUCTION_CMD=205` 和 GC 列表包 `=206`;当前 40250 默认服务配置关闭该宏,因此当前不做。不能写成全源码 / 协议 / 资产零命中。 | `REF/UserInterface/Packet.h:158-160,354-356`、`40250/Server/.../service.h:8` |
|
||
| `CG_HACK` | ❶ | 参考编号是 `105 (0x69)`,当前 Classic 也使用该值;不做决定可以保留,但编号必须更正。 | `REF/UserInterface/Packet.h:134-136`、`POC/extension/src/net/classic/wire_classic.h:162` |
|
||
| 物品 header 变体 | ❷ | `GC_ITEM_DEL/SET` 的 20/21 映射受 `GAIDEN` 条件编译影响;当前 40250 Server 使用 `DEL=20、SET=21`,文档应明确目标构建变体,不能概括为无条件的 ClientVS22 语义。 | `REF/UserInterface/Packet.h:191-197`、`40250/Server/.../packet.h:150-153` |
|
||
| `GC_ITEM_DROP` | ❷ | ClientVS22 保留 `GC_ITEM_DROP=23` 声明但没有注册 / 消费,实际掉落流程使用 `GC_ITEM_GROUND_ADD=26`。应标记为“保留声明 / 未消费”,不能宣称两个包逐字节等价。 | `REF/UserInterface/Packet.h:199-202`、`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:332` |
|
||
| §1.2 | ❷ | 参考端确实**没有**重连遮罩 / 倒计时,`OnRemoteDisconnect()` 只调 `SetLoginPhase()`。但「删掉 `reconnect_ui.gd`」只是**严格 1:1 时的选择,不是必然修复**;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 `disconnected`,`AppFlow` 无条件回 LOGIN;真实 TCP 断线仍待验收。 | `PythonNetworkStreamEvent.cpp:4` |
|
||
| §2.7 | ❷ | `RecvWarpPacket()` 确实**无条件**按 `lAddr / wPort` 重连,`same_server()` 不是参考端逻辑。但应表述为「**`RecvWarpPacket()` 内不直接使用 `lX/lY`**」——这两个坐标在重连后的 `GC_MAIN_CHARACTER*` 阶段仍参与角色加载定位,不是「完全没用」。 | `PythonNetworkStreamPhaseGame.cpp:923` |
|
||
| §3.10 | ❶+❸ | 本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓迁移资产中的 `uicharacter.py` 仍显示参考脚本通过 `net.SendChatPacket("/skillup " + str(skillIndex))` 发包,因此“应改成非命令路径”不成立,也不能仅据此断定当前应传 skill vnum。当前明确差距是 C++ 技能合法性 / 等级上限逻辑和 UI 加号按钮门控、失败提示、升级后状态刷新不完整。 | `POC/assets/root/uicharacter.py:1022`、`REF/UserInterface/PythonSkill.cpp:1833` |
|
||
| §4.8 | ✅(增量 53/54 已修复首版) | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」;`quickbar.gd` / `M2Client` 现已统一 1/2/3,4(`SLOT_TYPE_SHOP`)不执行。客户端显示 4×8,服务器保留 36 槽;技能、表情、F1–F4 / 数字键和 inventory → quickslot 投放已接通。仍未完成复杂窗口之间的完整物品态、stack split 和真实服务端验收。键位表仍来自迁移资产,不冒充 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309`、`POC/project/ui/quickbar.gd`、`POC/project/ui/mouse_controller.gd` |
|
||
| §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` |
|
||
| §8.6 | ✅(增量 53/54 已接通主入口) | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;`project/game_scene.gd` 已接通表中可映射的按下 / 松开入口,并有 `input_key_test.gd`;窗口键盘焦点屏蔽、PICK / TALK / ATTACK 首版光标和物品拖放已接入。macOS 物理 F1–F12 是否直接产生功能键事件仍受系统功能键设置 / Fn 键影响,应作为平台验收条件;复杂窗口 mousemodule 分支和 CJK IME 仍是独立差距。 | `POC/assets/root/game.py:309-408`、`POC/project/game_scene.gd`、`POC/project/input_key_test.gd`、`POC/project/ui/mouse_controller.gd` |
|
||
| §1.5 | ✅ | **属实,保留**。参考这份 `ClientVS22`(`Locale_inc.h` = `LOCALE_SERVICE_SINGAPORE` → `LSS_EUROPE`)发 `HEADER_CG_CLIENT_VERSION2 = 0xf1` + `"1215955205"`;当前实现发 `0xfd` 却用了 0xf1 分支的 timestamp。另外当前 `filename` 固定 `"metin2.bin"`,参考端用**实际可执行文件名**(`GetExcutedFileName` + `NoPath`)。 | `PythonNetworkStreamPhaseGame.cpp:4155`、`Locale.cpp:186` |
|
||
|
||
**§1.7 / §1.8 的订正属实并保留**,但补一条落地限定:
|
||
参考端对未知 header 是 `ClearRecvBuffer()` + `PostQuitMessage(0)`,对阶段内未处理 header 是
|
||
`TraceError` + `ClearRecvBuffer()`。移植到 Godot 时**不必字面调用 `PostQuitMessage(0)`**——
|
||
应映射为**明确的协议错误 + 断线处理**(走 §1.2 的 `disconnected` → 回登录页),
|
||
关键是保住「不做启发式重同步、不静默吞包」这两条语义。
|
||
|
||
## 附录 B:本文标注 `[待读参考]` 的条目
|
||
|
||
以下细节本次没有读到参考源码,实现前必须先读,**不得按经验补写**:
|
||
|
||
| 条目 | 应读文件 |
|
||
|---|---|
|
||
| §2.5 affect 位号 | `REF/UserInterface/InstanceBase.h` 的 `enum EAffect` |
|
||
| §3.1 `MOVABLE_GROUND_DISTANCE` 默认值 | `POC/assets/root/game.py` / `playersettingmodule.py` |
|
||
| §3.3 攻速对动作速率的影响、`__GetBowRange` | `REF/UserInterface/InstanceBase.cpp` / `InstanceBaseBattle.cpp` |
|
||
| §3.6 `OnGetFlyTargetPosition` | `REF/GameLib/ActorInstanceFly.cpp` |
|
||
| §3.8 `TSkillData` 字段与加载 | `REF/UserInterface/PythonSkill.{h,cpp}` |
|
||
| §3.10 `CanLevelUpSkill` / `GetSkillLevelLimit` | `REF/UserInterface/PythonSkill.cpp` |
|
||
| §4.1 换装遮挡规则 | `REF/UserInterface/InstanceBase.cpp` 的 `ChangeArmor/SetArmor` |
|
||
| §4.3 装备对比 | `POC/assets/root/uitooltip.py` |
|
||
| §4.4 仓库金钱包/命令 | `REF/UserInterface/PythonNetworkStreamModule.cpp` |
|
||
| §4.9 `affects[7]` 槽位语义 | 40250 **服务端** `game/src/party.cpp` |
|
||
| §5.4 `ATTRIBUTE_BANPK` 等地形属性 | `REF/GameLib/TerrainImpl`(`MapType.h` / `TerrainPatch.h`) |
|
||
| §6.3 SimpleLight / TexAni 完整语义 | `REF/EffectLib/SimpleLightData.{h,cpp}`、`SimpleLightInstance.cpp`、`ParticleProperty.{h,cpp}`、`ParticleSystemInstance.cpp` |
|
||
| §6.3 骨骼挂特效 | `REF/GameLib/ActorInstanceAttach.cpp` |
|
||
| §6.5 `ChangeWeapon` 的 detach | `REF/UserInterface/InstanceBase.cpp` |
|
||
| §6.6 材质 stage / alpha-test ref | `REF/EterGrnLib/Thing.cpp`、`Material.cpp` |
|
||
| §7.1 `abyPortalID` 消费点、`m_fHeightBias` | `REF/GameLib/Area.cpp` |
|
||
| §7.3 地形渲染逐项 | `REF/GameLib/MapOutdoorRenderSTP.cpp` / `RenderHTP.cpp` / `TerrainPatch.cpp` |
|
||
| §7.4 水面逐项 | `REF/GameLib/MapOutdoorWater.cpp` |
|
||
| §7.5 昼夜插值 / `TEnvironmentDataMap` 键 | `REF/GameLib/MapOutdoor.cpp` |
|
||
| §8.4 雾等级实参 | `POC/assets/root/uisystemoption.py` |
|
||
| §8.8 `EMPIRE_NUM` | `REF/UserInterface/GameType.h` / `Packet.h` |
|
||
| §9.4 `.mss` 解析 | `REF/MilesLib/SoundData.cpp` |
|
||
|
||
---
|
||
|
||
## 附录 C:并行实施计划、依赖门禁与进度看板
|
||
|
||
本附录取代旧版“按 1–12 步串行实施”的顺序。条目仍按依赖关系分组,但同一波次内的工作流可以
|
||
并行;只有公共接口和集成门禁需要排队。具体的文件主维护者、状态定义和同步要求见本文
|
||
《并行开发与进度同步协议》以及 `CLIENT-GAP.md` 顶部的同名章节。
|
||
|
||
### C.1 公共门禁
|
||
|
||
| 门禁 | 目标 | 通过条件 | 当前状态 |
|
||
|---|---|---|---|
|
||
| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结、增量 51 生命周期属主、增量 52 参数边界已落地;真实双连接、真机通知和部分平台行为仍待验收 |
|
||
| G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 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 接口冻结 + 增量 51 单一 `AppLifecycle` + 增量 52 `net_bounds` 已落地,当前 C++ `ctest` 15/15 + 生命周期 / 场景回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1;真机生命周期验收 |
|
||
| W1 协议 / 账号会话 | 🟡 开发中(增量 50:§1.5 / §1.10 / §2.2 已落地,待真实验收) | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;§1.5 locale 分支 + `CGClientVersion2`(0xf1);§1.10 `text_codec.h` + `to_wire` 路由 + `net_text_codec_test`(套件 16→17);§2.2 `GC_CHARACTER_ADD` 隐身丢弃 / PC·NPC 暂存 / 其余按 `npclist.txt` 命名立即 spawn,`GC_CHAR_ADDITIONAL_INFO` 无 pending 丢弃、命中则 `mut_spawn_full` 合并;G1 已接线 `set_executable_name` / `set_npclist_path` / `mut_affect_flags` | locale 代码页转码(CP949/CP1252)、真实双连接、进场延迟 |
|
||
| W2 实体 / 世界 / 战斗 | 🟡 开发中(Phase 1 批次已交) | 增量 50:§2.1 两层模型(`EntityStore` 纯数据层 + `net_world._update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点重建 + 上下马保坐标)、§2.4(点数快照清尾 / 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序刷新)已落地;`ctest` 16/16 + 7 项 GDScript 回归 + 新增 `net_world_vis_test` 全绿 | 远服端到端可见性 / VID 复用 / affect 表现验收;`state_flags` 表现层枚举 |
|
||
| W3 玩法 UI / 输入 | 🟡 已有首版 | 增量 53 已收敛快捷栏 36/4×8、1–4 / F1–F4、LSHIFT 翻页、Ctrl/Command+数字表情、空格攻击、Alt 名字、截图及帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天 / 相机入口;增量 54 增加全局物品拖放、背包 / 快捷栏投放目标、PICK / TALK / ATTACK 光标和窗口键盘焦点屏蔽;增量 56 增加仓库 / 商城 / 交易 / 商店 / 私人商店的首版明确投放目标和商店 BUY/SELL 模式;增量 57 增加物品实例元数据、整组拖放、stack split、`ITEM_MONEY=-1` 金币态、交易 accept / 距离 / anti-give / 金币边界、NPC 商店 anti-sell / 售价确认、私人商店 anti-give / anti-myshop;增量 58 增加 236 字节 `item_proto` 固定字段和统一基础 tooltip,已接入背包 / 装备查看 / 商店 / 仓库 / 商城 / 交易 / 私人商店;增量 59 将 NPC 商店改为参考端 5×8 / 40 格货位和 BUY 点击确认;增量 60 补齐交易 sockets/attrs、任务日志 icon/clock 和常用事件标签;增量 61 接入 QuestButton 条带、任务小地图信号、Atlas center adjust 和对话图片资源加载;增量 62 接入技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间;相关 C++ / Godot 回归通过;§8.3 帮助窗已落地 | CJK IME、窗口视觉 / `.sub` 光标资源、tooltip 的完整本地化 / 其他特殊分支、完整交易事务、复杂窗口完整 mousemodule 规则、38 项 EventManager 逐事件表现和真服未验收 |
|
||
| W4 渲染 / 资产 / 音频 | 🟡 开发中 | 增量 50:§9.1 / §9.2;增量 51:AppFlow 单一生命周期;增量 53:EffectLib `.mse` 首个 `.dds`、位置、发射形状、方向 / 速度;增量 55:MilesLib 监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色声道、角色限频、stop / 限幅淡出;增量 63:`.mss` 解析、60 FPS 实例换算、`sound/` 虚拟路径和 PlayerView / MobView 动作帧播放;增量 64:`.mde` v1/v2 解析、几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65:`.mse` Light 组、范围 / 生命周期曲线、粒子多贴图帧延迟 / 顺逆序和旋转类型;增量 66:MovingType 直接 / 二次 Bezier 位置曲线、粒子 / 网格 / Light 共用位置和球体 edge 映射;增量 67:`AreaAmbienceData` + `.pra` 独立环境声源、LOOP / ONCE / STEP、距离音量和区块卸载停止;增量 68:`.msenv` 参考字段 / 缺省值 / `Reserved` / 云层贴图解析接入,macOS 路径大小写修正;增量 69:`Sun` / `CharacterLight` 的静态 / 角色 layer 1/2 分层;增量 70:texture-mode 六面天空 shader 和真实 CapeDragonHead 回归;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` 与场景回归全绿 | TexAni 随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree 真实几何 / 精确风常量、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare / 昼夜联动、完整声音节点生命周期、真实声音资源和真机验收 |
|
||
| W5 打包 / 发布 / 验收 | 🟡 开发中 | 增量 53 已实现 App 内自包含资源复制、universal 架构参数 / `lipo` 门禁、嵌套签名顺序和环境变量凭据 | 完整 2.2G 导出、当前机器双架构构建、正式证书 / notarization、跨机启动和 Mac App 验收 |
|
||
|
||
### C.4 每批次回写模板
|
||
|
||
后续开发不得只在提交说明中记录进度;每个批次完成后,将下面的信息同时回写到
|
||
`CLIENT-GAP.md` 的变更记录 / 总览表和本文对应工作流 / 条目:
|
||
|
||
```text
|
||
批次:YYYY-MM-DD / W? / §x.x
|
||
状态:🟡 开发中 | ✅ 完成 | ⚠️ 阻塞 | 🔎 待真实验收
|
||
参考:ClientVS22/source/...:line
|
||
改动:文件列表;接口 / 数据结构变化
|
||
测试:命令、结果;skip 项及原因
|
||
验收:真服 / 双账号 / Mac App 证据,或明确“未验证”
|
||
风险与依赖:...
|
||
下一批次:...
|
||
```
|
||
|
||
同步责任固定为:工作流提交者提供事实和证据,W0 集成维护者统一合并两份文档;任何状态从
|
||
“开发中 / 待验收”改为“完成”前,必须同时满足代码回归和文档回写。
|
||
|
||
### C.5 批次记录
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §7.5(`.msenv` 解析、静态云层与 macOS 路径)
|
||
状态:🔎 待真实验收(格式解析、真实 A1 地图加载和静态云层回归完成;完整环境渲染 / 真机未验)
|
||
参考:40250/ClientVS22/source/GameLib/MapUtil.cpp:1-205(环境字段、缺省值与加载规则);
|
||
40250/ClientVS22/source/GameLib/MapType.cpp:126-187(`TEnvironmentData` 字段布局);
|
||
40250/ClientVS22/source/GameLib/MapOutdoor.cpp(环境应用与屏幕滤色行为)
|
||
改动:
|
||
formats/environment.h/.cpp —— 增加并解析 `Reserved`、Fog `IsDensity`、Filter、
|
||
SkyBox `BTextureRenderMode`、六面天空文件名和参考缺省值;
|
||
extension/src/environment_builder.h/.cpp —— 通过 `AssetResolver` 读取真实云层贴图,
|
||
接入 `ProceduralSkyMaterial.sky_cover`,其余暂不能等价渲染的原始参数写入元数据;
|
||
extension/src/metin2_world.cpp —— 保留 resolver 返回的真实大小写路径,修复 macOS 大小写敏感加载风险;
|
||
project/environment_test.gd —— 增加真实 A1 `.msenv`、云层材质和元数据断言;
|
||
project/world_probe.gd —— 按当前地图 patch 数递归统计地形节点,修正旧测试的区块粒度假设。
|
||
测试:`cmake --build build/extension -j2`;`ctest --test-dir build/extension --output-on-failure -j1`;
|
||
`formats_map_test`(内联 + 真实 A1)、`environment_test`、`world_probe`、`audio_miles_test`、
|
||
`fx_test`、`gamescene_test`、`quest_test`、`p8_test` 均通过;`fx_test` 保留既有 1 条 ObjectDB
|
||
leak warning,不影响断言结果。
|
||
验收:未验证——六面天空实际渲染、云层 UV / 高度动画、滤色实际合成、双方向光分层、风参数、
|
||
lens flare、昼夜天气联动、完整资源包和真机表现。
|
||
风险与依赖:Godot 当前只直接承载基础环境和静态 sky cover;参考端的滤色接口在该版本实现中
|
||
实际禁用,不能仅凭字段值宣称滤色已经生效;风参数在 `Environment_Load` 中没有 token 读取,
|
||
只能以参考缺省值保留;动态天气仍依赖后续读取 `MapOutdoor.cpp` 的环境选择 / 插值流程。
|
||
下一批次:W4 继续云层动画、双方向光分层、滤色 / lens flare 与 SpeedTree;W5 执行完整 Mac App
|
||
导出与 G1/G2;每批代码回归后立即回写本记录及 W4 看板。
|
||
|
||
批次:2026-09-02 / W4 / §7.5(双方向光分层)
|
||
状态:🔎 待真实验收(代码、离线环境 / 角色回归完成;真机视觉与 SpeedTree 真实光照未验)
|
||
参考:`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264`(角色光传给 SpeedTree);
|
||
`40250/ClientVS22/source/GameLib/MapManager.cpp:239-247`(背景光 enable)
|
||
改动:
|
||
`extension/src/environment_builder.h/.cpp` —— 增加 `CharacterLight`,背景 / 角色 enable、颜色、
|
||
方向和元数据分别设置;`Sun` 使用 layer 1,`CharacterLight` 使用 layer 2;
|
||
`extension/src/metin2_world.cpp` —— 地形、水面、建筑、树代理固定 layer 1,卸载地图清理
|
||
`Sun` / `CharacterLight`;
|
||
`extension/src/metin2_model.cpp` —— 角色本体、装备和 LOD 网格固定 layer 2;
|
||
`project/environment_test.gd`、`project/player_motion_test.gd` —— 增加两盏灯和角色网格层断言。
|
||
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、
|
||
`environment_test.gd`、`player_motion_test.gd` 均通过(当前 CTest 15/15)。
|
||
验收:未验证——真实 SpeedTree 光照、阴影质量、真机视觉和完整天气联动。
|
||
风险与依赖:Godot 的 light cull mask 只能保证场景层隔离;SpeedTree 仍是 proxy,未把参考端
|
||
`CSpeedTreeForest::SetLight` 的 shader 常量完整迁移。
|
||
下一批次:W4 继续纹理天空、云层动态、滤色 / lens flare / 风参数;W5 继续 Mac App G2。
|
||
|
||
批次:2026-09-02 / W4 / §7.5(texture-mode 六面天空)
|
||
状态:🔎 待真实验收(真实资源和 shader 编译回归完成;纹理天空 + 云层组合、真机视觉未验)
|
||
参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:403-429,483-542`(六面顶点 / UV);
|
||
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`(texture mode 设置与 Refresh)
|
||
改动:
|
||
`extension/src/environment_builder.cpp` —— 增加 Godot `sky` shader,按 M2 Z-up 到 Godot
|
||
x,y,-z 轴变换映射 front/back/left/right/top/bottom;通过 `AssetResolver` + 原生 DDS 解码
|
||
加载面贴图,空面以 `.msenv` 渐变回退;记录 `msenv_sky_face_count` 和
|
||
`msenv_sky_texture_mode_applied` 元数据;
|
||
`project/skybox_test.gd` —— 加载真实 `metin2_patch_dragon_rock/metin2_map_capedragonhead`
|
||
并断言五面贴图、空 bottom 面和 shader 安装。
|
||
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、
|
||
`environment_test.gd`、`skybox_test.gd` 均通过;首次回归发现 `sky` 的 `COLOR` 必须为 `vec3`,
|
||
已修正并重新通过;无 skip。
|
||
验收:未验证——纹理天空与云层同时存在时的叠加、云层 UV / 高度动画、滤色 / lens flare / 风、
|
||
昼夜切换和真机视觉。
|
||
风险与依赖:Godot `Sky` 是无限天空,不直接消费参考的 `SkyBoxScale`;texture mode 且同时配置
|
||
CloudTexture 时当前保留 ProceduralSkyMaterial 路径,避免丢云层,但六面 + 云层叠加仍是后续项。
|
||
下一批次:W4 继续云层 shader / 动画和剩余环境后处理;W5 继续完整 Mac App 导出与 G2。
|
||
|
||
批次:2026-09-02 / W3 + W1 / §4.2 §4.5 §4.6 §4.7(物品实例字段、金钱态与边界规则)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;完整事务、资源、真服 / 真机未验)
|
||
参考:REF/GameLib/ItemData.h:196-204(`ANTIFLAG_SELL/GIVE/MYSHOP`)、:212(`COUNT_PER_1GOLD`);
|
||
REF/UserInterface/PythonPlayerModule.cpp:1098-1103(`Is1GoldItem`)、:1329-1366(`IsValuableItem`);
|
||
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:455-482(出售包);
|
||
POC/assets/root/uiexchange.py(accept / 7 位金币 / 距离 / give);
|
||
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:14-27(safebox money 发送函数主动返回 false)
|
||
改动:
|
||
extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析并暴露 item_proto 的 `anti_flags` / `flags`;
|
||
project/ui/mouse_controller.gd —— 保留实例元数据,增加 `ITEM_MONEY=-1` 独立金币持有态;
|
||
project/ui/inventory_ui.gd —— 普通拖放保留整组数量,Shift+左键堆叠物品先弹数量框,金币槽附着;
|
||
project/ui/exchange_ui.gd —— accept 服务端镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限、
|
||
已放金币不可编辑和金币投放;
|
||
project/ui/shop_ui.gd —— 实例 / 原型 `ANTIFLAG_SELL`、`IsValuableItem` 首次确认、
|
||
普通 / `Is1GoldItem` 售价公式和失败音效;
|
||
project/ui/private_shop_ui.gd —— `ANTIFLAG_GIVE/MYSHOP` 拦截;
|
||
extension/tests/proto_item_layout_test.cpp、project/shop_rules_test.gd —— 新增布局 / 出售规则回归。
|
||
测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`;
|
||
`inventory_ui_test.gd`、`mouse_controller_test.gd`、`p8_test.gd`、`shop_rules_test.gd`、
|
||
`private_shop_ui_test.gd`、`complex_item_drop_test.gd` 均通过。
|
||
验收:未验证——完整交易事务 / 双账号回滚、真实 `.sub` 资源、40250 真服和真机输入;仓库金钱
|
||
不实现伪 `/safebox_money`,因为参考发送函数本身返回 false。
|
||
风险与依赖:payload 的 socket / attribute 数据仍需由网络状态层完整承载;真实服务端回包是最终状态,
|
||
离线规则测试不能替代真服业务验收;dragon_soul 按当前范围禁用。
|
||
下一批次:W1/W3 补全 tooltip 的鱼竿 / 镐 / 龙魂等特殊分支,并处理 CJK 文案 / 字体;W4 继续 `.mss`、
|
||
`.mde` 和真实资源;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §4.6(NPC 商店 5×8 货位与 BUY 点击确认)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;真实 `.sub` / 图标 / 非金币货币 / 真服未验)
|
||
参考:POC/assets/root/uishop.py:36-45、:226-227、:350-397(5×8 刷新、全局槽号、BUY 点击);
|
||
POC/assets/uiscript/uiscript/shopdialog.py:47-62(ItemSlot 5×8);
|
||
REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:401-429(购买包固定 count=1)
|
||
改动:
|
||
project/ui/shop_ui.gd —— 用 5×8 / 40 格按钮网格替换纵向商品行;按货架计算全局 pos;空位禁用;
|
||
非空货位 tooltip;BUY 点击购买确认,确认固定 `shop_buy(pos, 1)`;保留 SELL 投放和已有出售规则;
|
||
project/p8_test.gd —— 改为验证 40 格、购买确认和多货架槽位 5 / 43。
|
||
测试:`p8_test.gd` 通过;`git diff --check` 通过。
|
||
验收:未验证——真实 `.sub` 槽位 / 按钮贴图、物品图标、非金币 `coin_type`、40250 真服和真机。
|
||
风险与依赖:购买数量必须保持 1,不能把库存 count 当作购买数量;真实 UI 资源接入前不宣称视觉 1:1。
|
||
下一批次:W3 继续本地化 / 特殊 tooltip,W4 继续 `.mss` / `.mde` / SpeedTree,W5 执行完整 Mac App
|
||
导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W1 + W3 / §4.2 §4.3(item_proto 固定字段与统一基础 tooltip)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;特殊物品分支、资源视觉、真服 / 真机未验)
|
||
参考:REF/GameLib/ItemData.h:13-72(236 字节 TItemTable、limit/apply/value/socket/refine 字段);
|
||
POC/assets/root/uitooltip.py:970-1393(AddItemData 顺序与物品类型分支);
|
||
POC/assets/locale/locale/en/itemdesc.txt(描述数据)
|
||
改动:
|
||
extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析 / 暴露 limits[2]、applies[3]、
|
||
sockets[3]、refined_vnum、refine_set、alter_to_magic_pct、specular、gain_socket_pct;
|
||
project/ui/item_tooltip.gd —— 统一 item_proto + itemdesc + 实例 attrs/sockets 的基础提示,
|
||
对齐武器攻击 / 魔攻、防具防御公式、限制、applies、属性、魂石孔和精炼关联;
|
||
project/ui/inventory_ui.gd、view_equipment_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、
|
||
exchange_ui.gd、private_shop_ui.gd —— 接入同一个提示构造器;
|
||
extension/tests/proto_item_layout_test.cpp、project/item_tooltip_test.gd —— 锁定布局和提示内容。
|
||
测试:`cmake --build build/extension -j2`;C++ CTest 全套(首次 `net.classic_encstream` 瞬时失败,
|
||
定向重跑通过,需在集成前再重跑全套);`inventory_ui_test.gd`、`item_tooltip_test.gd` 通过。
|
||
验收:未验证——鱼竿 / 镐经验、龙魂计时、技能书 / 变身特殊分支、装备对比、CJK / 真 `.sub`、
|
||
40250 真服和真机。
|
||
风险与依赖:参考端普通 item socket 固定为 3 个,龙魂 subtype 槽位数为 6,不能混用;装备对比
|
||
在参考源码中没有找到对应方法,后续若需要应单独定义产品规则;真实服务端实例字段优先。
|
||
下一批次:W3 继续特殊 tooltip / 本地化,W4 继续 `.mss` / `.mde` / SpeedTree,W5 执行完整 Mac App
|
||
导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §4.2 §4.4 §4.5 §4.6 §4.7(复杂窗口物品态与商店模式)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;完整原版 UI / 资源 / 真服 / 真机未验)
|
||
参考:POC/assets/root/mousemodule.py:171-343(全局物品态);POC/assets/root/uisafebox.py:357-451
|
||
(仓库 45 格分页与 SelectEmptySlot / SelectItemSlot);POC/assets/root/uishop.py:226-291
|
||
(BUY/SELL 光标模式与出售规则);REF/UserInterface/PythonApplication.h:63(光标状态)
|
||
改动:
|
||
project/ui/safebox_ui.gd —— 5×9 网格、按 size 分页、指定格 checkin/checkout、仓库内 move;
|
||
project/ui/mall_ui.gd —— 45 格分页网格和商城→背包投放;
|
||
project/ui/exchange_ui.gd —— 12 个指定交易格的背包 / 装备投放;
|
||
project/ui/shop_ui.gd —— 出售投放目标、BUY/SELL toggle、模式显隐、BUY/SELL 光标联动;
|
||
project/ui/inventory_ui.gd、quickbar.gd、game_scene.gd —— 复杂窗口投放装配和来源窗口回传;
|
||
project/complex_item_drop_test.gd(新)—— 仓库 / 商城 / 交易 / 商店落点与购买模式保护回归。
|
||
测试:`complex_item_drop_test.gd`、`p8_test.gd`、`private_shop_ui_test.gd`、`inventory_ui_test.gd`
|
||
均 exit 0 并输出 PASS;C++ 既有 CTest 18/18 保持通过。
|
||
验收:未验证——`shopdialog.py` / `privateshopbuilder.py` 的完整 `.sub` 视觉、金钱附着、stack split、
|
||
anti-flag / `IsValuableItem` 售价确认、交易事务回滚、完整 pick/drop 音效、40250 真服和真机输入体验。
|
||
风险与依赖:当前落点调用已有 wire API,最终状态仍由服务端回包决定;`deposit()` 等旧右键入口保留为
|
||
兼容路径,不代表原版自动找空位是鼠标落点语义;dragon_soul 按当前 40250 范围继续禁用。
|
||
下一批次:W3 补物品金钱态、stack split、anti-flag / 售价确认和交易状态;W4 继续 `.mss` / EffectLib;
|
||
W5 执行完整 Mac App 导出与 G1/G2 验收。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §9.4(MilesLib 音频子集)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;`.mss` / 完整声音节点 / 真服 / 真机未验)
|
||
参考:REF/MilesLib/SoundManager.cpp:20-21(200/1000 缩放)、:72-89(监听器更新)、
|
||
:245-250(ratio/grade 音量换算)、:349-420(普通 / 环境 / 角色 3D 播放与 5000cm、0.3 秒限频)、
|
||
:510-545(限幅淡出 / 全部淡出);REF/MilesLib/SoundManager.h:23-52(公开 API)
|
||
改动:
|
||
project/audio.gd —— 新增 `set_listener/update_listener`、`SOUND_SCALE=200`、环境音 `1000`、
|
||
参考音量曲线、普通 / 环境 / 角色 3D 播放、活动实例位置重基准、play_count、单实例 / 全部停止、
|
||
角色声音距离与频率保护、音乐限幅淡出;
|
||
project/game_scene.gd —— 每帧更新 Miles listener;`.msa` 本地动作 sound 事件走角色声道;
|
||
project/ui/quickbar.gd —— assign 返回投放成功状态,失败时不清除拖放语义;
|
||
project/audio_miles_test.gd(新)—— 常量、换算、缩放 setter、5000cm / 0.3 秒规则回归。
|
||
测试:`godot --headless --path project --script audio_miles_test.gd` → PASS(exit 0)。
|
||
音频测试使用无资产纯规则 fixture,不将音频文件缺失误报为资源播放通过。
|
||
验收:未验证——`.mss` 解析、`.msenv` 环境参数 / 天气联动、完整 `UpdateSoundData/UpdateSoundInstance` 数据、
|
||
武器 / 技能 / 怪物 / NPC 事件,真实 40250 服务端和真机空间听感。
|
||
风险与依赖:当前系统设置滑条继续写入 0..1 的应用音量;ratio / grade API 已提供但尚未由全部设置入口
|
||
切换。Godot 3D 音频实例使用统一空间根节点,具体 unit_size / attenuation 仍需受控参考帧和真机校准。
|
||
下一批次:W4 读取 `SoundData.cpp` 完成 `.mss` / sound-node;W3 补复杂窗口 mousemodule / CJK IME;
|
||
W5 继续完整 Mac App 导出和发布验收。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §4.2 §4.9(全局物品拖放、光标状态、窗口焦点)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;复杂窗口 / 真实资源 / 真服 / 真机未验)
|
||
参考:POC/assets/root/mousemodule.py:171-343(AttachObject / DeattachObject / payload);
|
||
REF/UserInterface/PythonApplication.h:63、POC/assets/root/mousemodule.py:100(光标枚举 / 映射);
|
||
REF/UserInterface/GameType.cpp:90-106(slot type → inventory window)
|
||
改动:
|
||
project/ui/mouse_controller.gd(新)——全局 item payload、鼠标跟随预览、有效目标投放、无效区域 /
|
||
右键 / ESC / 失焦取消;
|
||
project/ui/cursor_manager.gd(新)——NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM 状态;
|
||
project/ui/inventory_ui.gd —— inventory / equipment / belt target 注册、全局拖放投放、装备 wire
|
||
坐标修正、兼容缺少 belt API 的测试桩;
|
||
project/ui/quickbar.gd —— inventory item → quickslot 投放;
|
||
project/ui/ui_manager.gd ——窗口打开时拦截不应穿透到 PlayerController / GameCamera 的键盘事件;
|
||
project/player_controller.gd、ground_items.gd、net_world.gd、game_scene.gd ——悬停光标、实体点选
|
||
注册、地面物品 PICK 和 UI 装配;quest_log.gd —— Ctrl+Q 状态入口;
|
||
project/mouse_controller_test.gd、input_key_test.gd —— attach / drop / cancel 与 Ctrl+Q 回归
|
||
测试:`mouse_controller_test.gd`、`inventory_ui_test.gd`、`input_key_test.gd`、`gamescene_test.gd`、
|
||
`ui_test.gd`、`system_menu_ui_test.gd` 均 exit 0 并输出 PASS;`git diff --check` 通过。
|
||
验收:未验证——safebox / mall / shop / exchange / private_shop 的完整 SelectEmptySlot /
|
||
SelectItemSlot 分支、金钱附着 / stack split、真实 `.sub` 光标贴图、CJK IME、40250 真服和真机体验。
|
||
风险与依赖:当前光标没有 `.sub` 资源时使用 Godot 系统光标;Ctrl+Q 的迁移层保留按钮可见状态,
|
||
QuestButton 已有独立条带和接口;仍需原版滚动 / 多列定位、资源视觉和服务端回包验收。
|
||
下一批次:W3 继续接入复杂物品窗口的 mousemodule 分支 / 音效与 CJK IME;W4 MilesLib;W5
|
||
完整 App 导出与发布验收。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3-W5 / §4.8 §6.3 §8.6 §10(快捷栏、输入、EffectLib、Mac 打包)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;真实服务 / 完整资源包 / 正式签名未验)
|
||
参考:POC/assets/root/game.py:309-431、509(键位 / 快捷栏);REF/UserInterface/GameType.h:140,307
|
||
(ESlotType / TQuickSlot);REF/UserInterface/Packet.h:392(36/32 槽);
|
||
REF/EffectLib/EmitterProperty.h:12-15、ParticleSystemInstance.cpp:106-181、
|
||
ParticleSystemData.cpp:282-287(发射器 / TextureFiles);§10 为本工程发布要求
|
||
改动:
|
||
project/ui/quickbar.gd —— 服务器 36 槽、客户端 4×8、本地槽类型 1/2/3、表情槽和服务器恢复
|
||
extension/src/net/m2_client.h/.cpp —— send_emoticon()、12 个参考 token、表情信号及快捷栏类型校正
|
||
project/game_scene.gd、game_camera.gd、net_play.gd、net_world.gd、ui/chat_ui.gd —— 参考键位的
|
||
按下 / 松开、连续攻击、相机、名字牌、截图和窗口入口
|
||
project/input_key_test.gd、skill_test.gd、netplay_test.gd、netbridge_test.gd —— 离线回归覆盖
|
||
project/fx/effect_player.gd —— `.mse` 位置 / 形状 / 方向 / 速度及首个 TextureFiles → DDS
|
||
build.sh、build-macos-client.sh —— CMAKE_OSX_ARCHITECTURES、App 内复制 assets/bgm、lipo 门禁、签名顺序
|
||
project/app_flow.gd、login.gd、charselect.gd —— 去除硬编码账号密码,自动登录改为显式环境变量
|
||
测试:`fx_test.gd`、`input_key_test.gd`、`skill_test.gd`、`netplay_test.gd`、`netbridge_test.gd`、
|
||
`gamescene_test.gd` 均 exit 0 并输出 PASS;`bash -n build.sh build-macos-client.sh`、`git diff --check` 通过;
|
||
C++ 扩展已有构建 / CTest 18/18 证据。Godot 回归串行执行,fx_test 仅有 1 个 ObjectDB leak warning,
|
||
不影响返回码。
|
||
验收:未验证——当前机器已有扩展仍是 arm64;完整 2.2G Mac App 尚未导出;没有 Developer ID /
|
||
notarization / stapled ticket;没有跨机资源隔离和 40250 真服 / macOS F1–F12 实机证据。
|
||
风险与依赖:SimpleLight / TexAni 的完整随机与每粒子语义、MovingType / 完整时间轴、普通物品鼠标态、CJK IME 和 MilesLib 声音
|
||
事件模型仍未实现;universal 构建需要完整工具链 / 模板;`CODESIGN_IDENTITY` 不配置时仍为 ad-hoc。
|
||
下一批次:W3 §4.2 物品态 / 光标与输入焦点;W4 `.mde` / MilesLib;W5 完整导出后执行 G1/G2;
|
||
真实服务端到位后补 DirectEnter / Warp / 玩法矩阵。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W0-W2 / §3.9(公共网络 API 参数边界)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;业务语义和真服未验)
|
||
参考:REF/UserInterface/PhaseGame.cpp:1132(地图坐标断言);REF/UserInterface/PythonPlayer.cpp:800/815/843/852/861
|
||
(快捷栏边界);REF/UserInterface/PythonPlayerEventHandler.cpp:196/104(同步 16、动作索引);
|
||
REF/UserInterface/GameType.h(36/40/12 等数组容量);40250/ClientVS22/source 的 Classic wire 定长字段
|
||
改动:
|
||
extension/src/net/net_bounds.h(新)——窄整数、快捷栏 / 商店 / 私人商店 / 交易 / Cube / 龙魂 / 点数组边界
|
||
extension/src/net/m2_client.cpp —— 连接端口、角色创建、聊天 / 技能 / 社交 / 商店 / 仓库 / 私人商店 /
|
||
Cube / 公会 / 精炼 / 物品操作在 static_cast 前统一拒绝溢出和非法 Variant;移除静默截断 / 负数归一化
|
||
extension/tests/net_bounds_test.cpp(新)、extension/CMakeLists.txt —— CTest net.bounds
|
||
测试:cmake --build build/extension -j2 通过;ctest --test-dir build --output-on-failure → 18/18;
|
||
Godot 离线回归按独立 --log-file 串行执行,受影响脚本均 exit 0 且输出 PASS。无新增 skip。
|
||
验收:未验证——无真实 40250 服务端的完整业务包回执;Godot 测试中的 macOS 证书探测 / 资源释放提示
|
||
不影响脚本返回码,但不计作真实平台验收。
|
||
风险与依赖:net_bounds 只表达已核实 wire / 数组容量,不替代技能冷却、目标类型、背包规则和服务端权限;
|
||
`M2Client` 的字段值仍需在真服对照服务端拒绝 / 接受行为。
|
||
下一批次:W3 §8.6 剩余键位 / 表情与物品态,或 W4 §6.3 EffectLib,继续按文件主维护者与 G1 门禁同步。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W0 / §2.1 §2.3 §2.5 §8.6 §9.1(接口冻结,Phase 1 前置)
|
||
状态:🟡 开发中(骨架落地;语义补全在 Phase 1 各工作流)
|
||
参考:POC/assets/root/game.py:309-431(键位);ClientVS22 GC_MAIN_CHARACTER3/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 集成
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §8.3(帮助窗)
|
||
状态:🟡 开发中(首版落地;真服 / 视觉未验收)
|
||
参考:assets/root/uihelp.py(HelpWindow.LoadDialogSinglePage);
|
||
assets/uiscript/uiscript/helpwindow.py(help_01..help_19 + taskbar_help_* + close_button);
|
||
assets/locale/locale/en/locale_interface.txt(HELP_* / CLOSE 逐字);
|
||
game.py __PressHKey(H 非 Ctrl → interface.OpenHelpWindow(),Ctrl+H = /user_horse_ride)
|
||
改动:
|
||
project/ui/help_ui.gd(新)—— ui.open_script("helpwindow.py") 非模态;_relabel() 用
|
||
Locale(en) + 英文兜底把 <HELP_*>/<CLOSE> 占位补回(含重名的两个 help_02);
|
||
open/close/toggle/is_open、close_button→close、ESC 走 UiManager 窗口栈、rows() 便捷器
|
||
project/ui/system_menu_ui.gd —— help_button 由占位提示改为 close()+open_help();
|
||
+const HelpUI、+help_ui 成员、+_ensure_help()/open_help()/toggle_help();
|
||
setup() 追加可选第 6 参 help_win(旧 5 参调用不受影响)
|
||
project/game_scene.gd —— _unhandled_input 加单条 elif:KEY_H 且非 ctrl/meta →
|
||
system_menu_ui.toggle_help()(Ctrl+H 未占用)
|
||
接口变化:仅 SystemMenuUI.setup() 追加可选尾参;无 net / EntityStore 改动
|
||
测试:godot --headless --path project --script res://help_ui_test.gd → PASS(exit 0);
|
||
system_menu_ui_test / gamescene_test / ui_test 全 PASS;附加 system_option_ui_test /
|
||
char_status_ui_test 亦 PASS
|
||
skip:本 worktree 无 build/,未跑 C++ ctest(本批次无 C++ 改动)
|
||
验收:真服 / 双账号 / Mac App 未验证(§8.3 纯本地 UI,无服务端依赖);
|
||
helpwindow.py 的 .sub / help_stick.tga 贴图与字体描边未逐像素比对
|
||
风险与依赖:依赖 W0 冻结的 UiManager/UiScript/UiBuild 与 Locale;未改共享文件,
|
||
game_scene.gd 仅加 1 条 elif(W3 许可范围)。1:1 偏差:本仓 uihelp.py 非“136 行含
|
||
键位行”版本,逐字源实际为 helpwindow.py + uiScriptLocale.HELP_*(已在 §8.3 注明);
|
||
帮助窗按非模态实现(原版 Lock()),ESC/关闭行为等价
|
||
下一批次:Phase 2 由 W0 执行 G1 集成;§8.6 剩余逐行键位与表情包仍在 W3 范围
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §9.1 §9.2 §9.3-doc
|
||
状态:✅ 完成(§9.1 §9.2 代码 + 回归);§9.3 设计已确认,落地推迟到 Phase 2
|
||
参考:POC/assets/root/game.py:190/225(选曲 + 先置音量);POC/assets/root/musicinfo.py
|
||
(常量 + Save/LoadLastPlayFieldMusic);PythonNetworkStreamPhaseLoading.cpp:299
|
||
(gs_fieldMusic_volume = 1/5*0.1);REF/MilesLib/SoundManager.h:31(SaveVolume/RestoreVolume)
|
||
改动:
|
||
project/bgm_director.gd —— _select() 补齐地图→曲目解析(服务端名优先 / 回落
|
||
MusicInfo.fieldMusic / 默认音量 0.02 / 淡入前置音量);bind() 连 M2Client.world_reset
|
||
换图重选
|
||
project/musicinfo.gd(新)—— METIN2THEMA/loginMusic/createMusic/selectMusic/fieldMusic
|
||
五常量 + save/load_last_play_field_music → user://BGM/lastplay.inf
|
||
project/audio.gd —— +save_volume()/restore_volume()(字段 _backup_music_volume/
|
||
_backup_sound_volume 对齐 m_fBackupMusicVolume/m_fBackupSoundVolume)+ set_music_volume()
|
||
project/app_lifecycle.gd —— _enter_background 用 save_volume()+set_music_volume(0) 取代
|
||
stop_bgm();_exit_background 加 restore_volume();_bgm_name 不清空
|
||
project/bgm_test.gd(新)—— §9.1 选曲 4 例 + lastplay 往返 + §9.2 后台→前台存恢复
|
||
测试:godot --headless --path project --script res://bgm_test.gd → PASS(exit 0);
|
||
gamescene_test / netplay_test / p9_test 回归全绿;无 build/ 目录,无 C++ 改动(ctest 不涉及)
|
||
验收:未验证(无真实 40250 服务端;前后台切换需真机 Android/iOS)
|
||
风险与依赖:§9.3 需 W0 在 Phase 2 把 AppLifecycle 属主收敛到 AppFlow、M2Client 去掉
|
||
NOTIFICATION_* —— 本批次按约束未改 game_scene.gd/app_flow.gd/m2_client.*;
|
||
set_music_volume 只作用于当前 _bgm[_bgm_cur] 声道
|
||
下一批次:W0 已完成 §9.3 代码落地;下一批次由 W5 / 平台验收补真机证据,W4 继续 MilesLib
|
||
3D 声音模型;G1 集成回归保持串行并使用独立 Godot 日志文件
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W0 / §9.3(生命周期单一属主)
|
||
状态:✅ 完成(代码 + 离线回归);🔎 真机 / 真服断线重连待验收
|
||
参考:REF/UserInterface/PythonApplication.cpp + PythonApplicationEvent.cpp(应用层统一驱动);
|
||
REF/EterLib/NetStream.cpp:332/343/351、REF/UserInterface/PythonNetworkStreamEvent.cpp:4
|
||
改动:
|
||
project/app_flow.gd —— 生产入口创建唯一 AppLifecycle;登录阶段解绑旧 GameScene 音频,
|
||
游戏场景 setup 完成后绑定当前 Audio
|
||
project/game_scene.gd —— 删除本地 AppLifecycle 创建,新增 lifecycle_audio() 作为绑定接口
|
||
extension/src/net/m2_client.h/.cpp —— 删除 NOTIFICATION_APPLICATION_PAUSED/RESUMED
|
||
监听,保留 suspend()/resume() 供 AppLifecycle 驱动
|
||
project/login.gd —— 兼容启动器保留单一生命周期节点贯穿登录→游戏,不再进场销毁
|
||
project/app_flow_lifecycle_test.gd(新)、project/gamescene_test.gd —— 单一属主 / 无重复节点回归
|
||
测试:
|
||
godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log
|
||
--script app_flow_lifecycle_test.gd → PASS
|
||
godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log
|
||
--script gamescene_test.gd → PASS
|
||
两个 Godot 进程并行时曾因共用 user://logs / 图形上下文触发 exit 134;改用独立日志文件串行
|
||
执行后通过。后续 Godot 回归不得并行启动。
|
||
验收:未验证 Android/iOS 前后台通知、内存告警、后台 TCP 回收后的重连;无真实 40250 服务端。
|
||
风险与依赖:AppFlow 是 `client_main.tscn` 的生产路径;`login.gd` 是旧兼容启动器,保留其自身
|
||
单一协调者不等同生产路径重复。若后续删除兼容启动器,应同步移除其专属生命周期代码。
|
||
下一批次:W5 真机 / Mac App 验收;W4 补 MilesLib 3D listener、距离衰减和播放模型。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W1 + W3 / §4.5 §5.1 §5.2(交易实例字段与任务显示 / 标签解析)
|
||
状态:🔎 待真实验收(本批范围代码 + 离线回归完成;完整事务、完整 EventManager 和真实资源未验)
|
||
参考:REF/UserInterface/Packet.h / PythonNetworkStreamPhaseGameItem.cpp(GC_EXCHANGE 的 3 sockets
|
||
+ 7 attrs);REF/UserInterface/PythonEventManager.cpp:377-790(38 项标签分派);
|
||
POC/assets/root/uicharacter.py:657-723(任务 icon / counter / clock 展示)
|
||
改动:
|
||
extension/src/net/entity_store.h/.cpp —— ExchangeSlot 增加 sockets[3] / attrs[7];m2dev
|
||
GC_EXCHANGE ITEM_ADD 和 `mut_exchange_item()` 复制完整实例状态;
|
||
extension/src/net/classic/classic_parser.cpp —— classic GC_EXCHANGE 将 ItemAttr3 转为统一
|
||
ItemAttr 后写入交易状态;
|
||
extension/src/net/m2_client.cpp —— get_exchange() 输出 sockets / attrs,供 GDScript tooltip 使用;
|
||
project/ui/quest_log.gd —— 读取 icon,资源存在时显示;clock 按参考分钟 / 秒格式倒计时,资源缺失安全回退;
|
||
project/ui/quest_dialog.gd —— 识别 COLOR/COLOR256、ITEM/MOB、WINDOW_SIZE、居中、CONFIRM_WAIT、
|
||
IMAGE/TITLE_IMAGE 元数据和 DELAY/WAIT;game_scene.gd 传入 proto / assets_root;
|
||
project/ui/friend_ui.gd —— 确认框答复后主动隐藏,避免连续模态框残留;
|
||
extension/tests/net_entity_test.cpp、net_classic_session_test.cpp、project/quest_test.gd ——
|
||
交易 sockets/attrs、任务标签、任务时钟回归。
|
||
测试:`cmake --build build/extension -j2` 通过;`ctest --test-dir build/extension -R
|
||
'net.entity|net.classic_session' --output-on-failure` 2/2 通过;`p8_test.gd`、`item_tooltip_test.gd`、
|
||
`inventory_ui_test.gd`、`private_shop_ui_test.gd`、`complex_item_drop_test.gd`、`shop_rules_test.gd`、
|
||
`mouse_controller_test.gd`、`quest_test.gd` 均 exit 0 并输出 PASS;修复私店测试中的 GDScript 类型推断错误。
|
||
验收:未验证——完整交易双账号回滚 / 背包锁定、EventManager 逐事件等待 / 立绘 / 地图信号 / 相机 / 电影、
|
||
真实 `.sub` 图标、CJK IME、40250 真服和真机。
|
||
风险与依赖:交易实例数组现在可用于提示,但最终物品状态仍以服务端回包为准;任务网络 cue 仍是完整文本,
|
||
不能把标签识别当作 38 项 EventSet 迁移;任务 icon 沿用可选资源加载,找不到资源时不伪造图片。
|
||
下一批次:W3 继续 EventManager 逐事件状态 / QuestButton / minimap signal 或 CJK 输入;W2 继续交易事务状态;
|
||
W4 继续 `.mss` / `.mde` / SpeedTree;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §5.1 §5.2 §9.5(任务信件、任务地图信号与对话图片)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;完整 EventSet、资源视觉、真服 / 真机未验)
|
||
参考:REF/UserInterface/PythonEventManager.cpp:554-614、:571-602(地图信号、QuestButton、Atlas center);
|
||
REF/UserInterface/PythonEventManager.cpp:504-532、:679(对话图片回调);
|
||
POC/assets/root/uiquest.py:1290-1409(任务信件接收、删除、点击、抑制和布局)
|
||
改动:
|
||
project/ui/quest_dialog.gd —— 增加 `QUESTBUTTON/QUESTBUTTON_CLOSE`、
|
||
`ADDMAPSIGNAL/CLEARMAPSIGNAL/SETCMAPPOS`、相机 / 淡入淡出事件信号;对话图片在资源存在时
|
||
实际创建 `IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE/TITLE_IMAGE` 节点,空脚本也会先发 side effect;
|
||
project/ui/quest_log.gd —— 新增独立 QuestButton 条带,支持接收 / 替换 / 删除 / 点击
|
||
`script_button(index)`、highlight 图标类型、对话打开抑制和资源 / item 图标解析;
|
||
project/ui/minimap.gd —— 保存 / 清除 / 绘制 EventManager 任务信号点;
|
||
project/ui/atlas_ui.gd —— 接入 `SetCenterPositionAdjust` 的 screen-space 偏移;
|
||
project/game_scene.gd —— 连接任务对话、QuestLog、Minimap 和 Atlas 的公开信号;
|
||
project/quest_test.gd —— 增加 QuestButton、地图信号、相机 / fade 元数据、条带点击回归。
|
||
测试:`quest_test.gd`、`p9_test.gd`、`gamescene_test.gd` 均串行运行并输出 PASS;三项脚本
|
||
`--check-only` 通过;此前并发 Godot 检查曾触发共享 MoltenVK / user://logs 崩溃,后续统一串行。
|
||
验收:未验证——完整 38 项 EventSet 逐事件等待 / 分页、立绘 / 电影 / 相机实际表现、真实 `.sub`
|
||
资源、40250 真服和真机输入 / 视觉。
|
||
风险与依赖:QuestButton 的服务端回包仍是最终状态;地图信号按参考接口保存为绝对服务器厘米,
|
||
`SETCMAPPOS` 按参考解释为 Atlas screen-space center adjust,不当作角色传送;缺失图片只安全跳过。
|
||
下一批次:W3 继续 EventSet 时间轴 / fade / camera / CJK IME;W2 继续完整交易事务;W4 继续
|
||
`.mss` / `.mde` / SpeedTree;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W3 / §4.3(tooltip 特殊物品分支与实时限时)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;完整本地化、其他特殊分支、真实资源和真服 / 真机未验)
|
||
参考:POC/assets/root/uitooltip.py 的技能书、遗忘书、变身道具标题分支;40250
|
||
ClientVS22/GameLib/ItemData.h 的 `limits[2]` / `sockets[3]` 固定布局;本地
|
||
`locale/locale/<lang>/skilldesc.txt` 与 mob 原型名称表。
|
||
改动:
|
||
project/ui/item_tooltip.gd —— `setup()` 接收 mob 原型源并加载技能描述名;根据技能书 / 遗忘书
|
||
的 `sockets[0]` 生成技能名标题,根据变身道具 `sockets[0]` 生成怪物名标题;根据限制类型
|
||
7/8/9 与实例 socket 结束时间 / 持续时间生成剩余分钟 / 秒提示。
|
||
project/ui/inventory_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、exchange_ui.gd、
|
||
view_equipment_ui.gd、private_shop_ui.gd —— 统一把 mob 原型源传入 tooltip。
|
||
project/item_tooltip_test.gd —— 增加技能书、变身道具、实时限时三类断言。
|
||
测试:`item_tooltip.gd --check-only` 通过;`item_tooltip_test.gd` 输出 PASS;此前物品窗口、商店、
|
||
交易、拖放回归保持通过。
|
||
验收:未验证——CJK 文案 / 字体切换、完整技能描述、其他物品特殊分支、真实 `.sub` / 音频资源、
|
||
40250 真服实例时间字段和 Mac 真机显示。
|
||
风险与依赖:当前只把参考端已确认的技能 / 怪物名称映射接入;未发现的特殊分支不凭经验扩展。
|
||
限时提示依赖实例 socket 或显式 remaining 字段,最终过期状态仍以服务端物品回包为准。
|
||
下一批次:W4 继续 `.mss` 声音脚本解析;W3 继续 CJK IME / 其他 EventManager;W2 继续完整交易事务;
|
||
W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §9.4(.mss 声音脚本与动作帧播放)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni / .msenv / 完整节点生命周期 / 真实资源和真机未验)
|
||
参考:40250/ClientVS22/source/MilesLib/Type.cpp:17-74(LoadSoundInformationPiece)、:118-136
|
||
(DataToInstance);GameLib/RaceMotionData.cpp:459-562(.msa → sound/<tail>.mss 配对);
|
||
MilesLib/SoundManager.cpp:148-172(UpdateSoundData / UpdateSoundInstance)。
|
||
改动:
|
||
project/audio.gd —— 解析 `SoundDataCount` / `SoundData%02d`,按参考转换 60 FPS frame;补齐
|
||
`sound/` 虚拟前缀到 patch sound 根的查找;新增 `load_mss_for_motion()` 和
|
||
`update_sound_instances_3d()`。
|
||
project/ui/player_view.gd、project/ui/mob_view.gd —— 每次动作进入新帧时消费配对 `.mss` 实例,
|
||
通过角色声音入口触发,并在动作切换 / 循环时重置帧游标。
|
||
project/game_scene.gd —— 将 Audio 绑定到本地 PlayerView 和远端 MobView。
|
||
project/audio_miles_test.gd —— 增加真实 `.mss` 记录、虚拟路径、60 FPS 换算和 `.msa` 配对断言;
|
||
project/player_motion_test.gd、project/mob_view_test.gd —— 增加玩家 / 怪物动作配对断言。
|
||
测试:`audio.gd`、`ui/player_view.gd`、`ui/mob_view.gd` `--check-only` 通过;
|
||
`audio_miles_test.gd`、`player_motion_test.gd`、`mob_view_test.gd` 串行运行并输出 PASS。
|
||
验收:未验证——效果实例完整声音生命周期、`.msenv` 环境参数 / 天气联动、所有武器 / 技能 / 怪物 / NPC
|
||
资源逐项听感、40250 真服实体动作和 Mac 真机空间听感。
|
||
风险与依赖:`.mss` 只描述触发时刻和虚拟路径,音频解码 / 3D 衰减仍由 Godot 音频层承担;
|
||
缺失声音资源会安全跳过,不把文件存在误报为可听感通过。Godot 回归继续严格串行。
|
||
下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet;
|
||
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §7.5(SpeedTree 风缺省)
|
||
状态:🔎 待真实验收(参考缺省与 proxy 回归完成;真实 SpeedTree 视觉未验)
|
||
参考:`40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42`(`fWindStrength=0.2`、`fWindRandom=0`);
|
||
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:257-264`(角色光与风强度传给 SpeedTreeForest)
|
||
改动:
|
||
`extension/src/tree_placeholder.cpp` —— 将叶片 proxy 的 `wind_strength` 从错误的 `1.0` 修正为
|
||
参考缺省 `0.2`,并注明 `Environment_Load` 不读取风 token;
|
||
`extension/src/environment_builder.cpp` —— 将 `msenv_wind_strength=0.2`、
|
||
`msenv_wind_random=0.0` 写入 `WorldEnvironment` 元数据;
|
||
`project/environment_test.gd` —— 增加两个缺省值断言。
|
||
测试:`cmake --build build/extension -j2`、`environment_test.gd`、`skybox_test.gd` 均通过。
|
||
验收:未验证——真实 SpeedTree 几何、森林级风 shader、随机风语义和真机视觉。
|
||
风险与依赖:当前 `.spt` 仍是程序化 proxy;不能把 proxy 的确定性摆动等同于
|
||
`CSpeedTreeForest::SetWindStrength` 的完整实现。
|
||
下一批次:W4 继续纹理天空 + 云层叠加 / 云层动画;W5 继续 Mac App G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §7.5(texture-mode 天空与云层组合)
|
||
状态:🔎 待真实验收(真实 DawnMistWood 资源、shader 参数和 shader 编译回归完成;真机视觉未验)
|
||
参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557`(云面四边形与 UV);
|
||
`40250/ClientVS22/source/EterLib/SkyBox.cpp:869-927`(云面高度、纹理矩阵、滚动和颜色 / blend);
|
||
`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`(SkyBox 参数应用)
|
||
改动:
|
||
`extension/src/environment_builder.cpp` —— texture mode 不再因存在 CloudTexture 而回退,
|
||
sky shader 增加有限水平云面:CloudScale / CloudHeight 从参考厘米换算为 Godot 米,
|
||
CloudTextureScale / CloudSpeed 映射到 UV 缩放和 `TIME` 秒级滚动;按参考
|
||
`MODULATEINVALPHA_ADDCOLOR` + `ONE/INVSRCCOLOR` 合成云色;渐变天空继续使用 sky cover;
|
||
新增 `msenv_sky_cloud_applied` 元数据。
|
||
`project/skybox_test.gd` —— 增加真实 `metin2_map_dawnmistwood`,断言五面天空、CloudTexture、
|
||
2000m 云面、0.001 秒级速度和 shader 组合。
|
||
测试:`git diff --check`、`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure -j1`、`environment_test.gd`、`skybox_test.gd` 通过;
|
||
skybox_test 串行加载真实 CapeDragonHead + DawnMistWood,未出现 shader 编译错误。
|
||
验收:未验证——云层渐变过渡、滤色实际合成、SpeedTree 完整风语义、lens flare、昼夜天气联动、
|
||
完整资源包和真机视觉。
|
||
风险与依赖:Godot sky shader 是对参考独立云面 pass 的等价投影近似;当前只应用 CloudColor
|
||
首个颜色,完整 `StartTransition` / `SetCloudColor` 时间过渡仍待迁移;`SkyBoxScale` 与 D3D
|
||
透视裁剪也没有 1:1 的 Godot sky 参数。
|
||
下一批次:W4 继续云层颜色过渡、滤色 / lens flare、SpeedTree 真实几何;W5 继续 Mac App G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §6.3(EffectElement MovingType 与发射边缘)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 advanced / cycle、完整时间轴、真机视觉未验)
|
||
参考:40250/ClientVS22/source/EffectLib/EffectElementBase.cpp:5-60(位置关键帧边界、直接插值、二次
|
||
Bezier);EmitterProperty.h/.cpp(POINT / ELLIPSE / SQUARE / SPHERE、FREE / OUTER / INNER、edge);
|
||
ParticleSystemInstance.cpp:106-181,286-296(发射边缘、advanced 速度和 cycle 生命周期)。
|
||
改动:
|
||
project/fx/effect_player.gd —— 新增 `_position_at()`,按参考在关键帧前后钳制、直接段线性插值、
|
||
Bezier 段使用 `p0 + controlPoint` 控制点;粒子、Mesh 和 Light 节点按同一曲线更新;球体
|
||
`EmitterEmitFromEdgeFlag` 映射为 Godot `SPHERE_SURFACE`,并保存 advanced / edge 元数据。
|
||
project/fx_test.gd —— 增加直接段中点、二次 Bezier 中点、球体 edge 和 advanced 元数据断言。
|
||
测试:`effect_player.gd --check-only`;`fx_test.gd` 串行运行并输出 PASS;C++ 扩展构建通过。
|
||
验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 速度、完整 emitter cycle /
|
||
loop、所有时间曲线和 Actor 骨骼挂点;真实 40250 效果逐项视觉、真服和 Mac 真机未验。
|
||
风险与依赖:当前 edge 只对球体映射为 Godot 有对应的 surface shape;ELLIPSE / SQUARE 的精确
|
||
边缘采样、advanced 速度和 per-particle custom data 仍需继续读取参考实现,不以元数据保存冒充完成。
|
||
下一批次:W4 继续 emitter advanced / cycle、TexAni 随机 / 每粒子语义或 `.msenv`;W3 继续 CJK IME /
|
||
完整 EventSet;W2 继续交易事务;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §9.4(AreaAmbienceData 与 Miles 环境声)
|
||
状态:🔎 待真实验收(代码 + 离线 / 真实地图回归完成;`.msenv` 环境参数联动、完整资源听感、真服 / 真机未验)
|
||
参考:40250/ClientVS22/source/GameLib/Area.cpp:882,1316-1394(环境声文件加载、严格范围判断、
|
||
LOOP / ONCE / STEP 生命周期与距离音量);MapType.cpp:195-260(Ambience `.pra` 字段)。
|
||
改动:
|
||
extension/src/metin2_world.cpp/.h —— C++ 世界层解析每个已加载区块的 `AreaAmbienceData`,由
|
||
`.pra` CRC 提供 position / range / max volume / play type / interval / sound vector;与对象
|
||
渲染和 splat 开关解耦,支持 `areaambiencedata.txt` / `AreaAmbienceData.txt`,区块卸载时清理来源。
|
||
project/audio.gd —— 对齐参考的 `< range`、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 + 随机变化、
|
||
离开区域停止;Miles 3D 相对坐标先从世界米换回厘米再除以缩放。
|
||
project/game_scene.gd —— 每帧消费世界层 `get_ambience_sources()`;project/ambience_test.gd ——
|
||
使用真实 OutdoorA3/003003 和 `warp_test.mp3` 验证来源、`.pra` 字段和路径。
|
||
测试:`cmake --build build/extension -j2` 通过;`ambience_test.gd`、`audio_miles_test.gd` 串行运行
|
||
并输出 PASS;`formats_map_test`(含真实 A1 地图)通过。
|
||
验收:未验证——`.msenv` 的云层 / 滤色 / 风 / lens flare / 双方向光 / 昼夜天气联动、全部环境声音资源、
|
||
长时间循环与切图听感、
|
||
40250 真服和 Mac 真机空间听感。
|
||
风险与依赖:环境声来源现在随区块加载 / 卸载变化,Audio 以 key 做实例生命周期;资源缺失会安全
|
||
跳过,不能把来源发现成功等同于音频解码和真实听感通过。
|
||
下一批次:W4 继续 `.msenv` / 完整声音节点生命周期或 emitter advanced;W3 继续 CJK IME / 完整
|
||
EventSet;W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §6.3(SimpleLight / TexAni 基础子集)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 MovingType / 时间轴、
|
||
Direct3D 材质 stage、SpeedTree、`.msenv`、真实资源逐项视觉和真机未验)
|
||
参考:40250/ClientVS22/source/EffectLib/SimpleLightData.cpp(默认值、字段加载、GetRange 曲线);
|
||
SimpleLightInstance.cpp(生命周期、loop / loop count、范围 / 颜色 / 位置更新);
|
||
ParticleProperty.h / ParticleSystemInstance.cpp(旋转类型和 TexAni 基础语义)。
|
||
改动:
|
||
project/fx/mse.gd —— 暴露 Light 组及 `MaxRange`、ambient/diffuse、attenuation、duration、loop /
|
||
loop count、`TimeEventRange`。
|
||
project/fx/effect_player.gd —— 将 Light 组构建为 `OmniLight3D`,按范围曲线和生命周期更新;粒子
|
||
多贴图支持 `TexAniDelay`、顺 / 逆序,旋转类型区分 `NONE/TIME_EVENT/CW/CCW`。
|
||
project/fx_test.gd —— 增加 Light 解析、范围曲线、开始时间和生命周期可见性断言。
|
||
测试:`fx_test.gd` 通过;此前 `.mde`、`.mss`、玩家 / 怪物动作和场景回归保持通过;`git diff --check`
|
||
通过。
|
||
验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、完整 emitter advanced / cycle、MovingType /
|
||
时间轴、真实 40250 效果资源逐项视觉和 Mac 真机。
|
||
风险与依赖:Godot `OmniLight3D` 的衰减和 Direct3D 固定功能材质不是逐像素等价;未将近似结果宣称为
|
||
1:1。随机和每粒子语义需要独立粒子数据或 shader / custom data 支持。
|
||
下一批次:W4 继续 MovingType / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet;
|
||
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W4 / §6.3(EffectMesh .mde 网格与帧播放)
|
||
状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni 完整帧语义、MovingType / 骨骼挂点、
|
||
真实资源逐项视觉和真机未验)
|
||
参考:40250/ClientVS22/source/EffectLib/EffectMesh.cpp(EffectData / MDEData002 二进制布局、
|
||
索引顶点展开、UV.y 翻转、DiffuseMap / IFL 贴图);EffectMeshInstance.cpp(MeshFrameController、
|
||
MeshElement 的 billboard / blending / visibility / alpha)。
|
||
改动:
|
||
project/fx/mde.gd —— 新增 v1/v2 读取、数量 / 文件边界校验、每帧 visibility / 几何数组、索引
|
||
TriangleList 展开、UV 翻转和厘米到米换算。
|
||
project/fx/mse.gd —— 暴露 MeshAnimationLoopEnable / LoopCount。
|
||
project/fx/effect_player.gd —— 去除 `.mde` box 占位,创建真实 ArrayMesh,播放几何帧,接入
|
||
`.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;修复真实
|
||
`.mse` 触发的 Gradient 单点移除错误。
|
||
project/fx_test.gd —— 使用 `geompung.mse` + `geompung.mde` 锁定 v1 的 3 个 geometry、2 帧、
|
||
展开顶点 / UV 和非占位 MeshInstance3D。
|
||
测试:`fx_test.gd` 通过;`git diff --check` 通过。
|
||
验收:未验证——SimpleLight、TexAni 的完整参考帧控制、MovingType / 完整时间曲线、骨骼挂点、
|
||
真实效果资源逐项视觉、40250 真服和 Mac 真机渲染。
|
||
风险与依赖:`.mde` 参考端将索引数据展开为 `TPTVertex`,当前保持同一渲染顺序;Godot 的材质 /
|
||
billboard / blend 是等价近似而非 Direct3D stage 逐像素复制。缺失或损坏的可选贴图安全回退,
|
||
不把回退资源当作 1:1 视觉通过。
|
||
下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet;
|
||
W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.7(受击状态统一 / 删除客户端硬直)
|
||
状态:✅ 完成(离线)——参考语义移植 + C++ 19/19 + 相关 GDScript 回归全绿;
|
||
远端实体 knock_down 填充与真实 40250 受击时序仍待后续增量 / 真服验收。
|
||
参考:REF/UserInterface/InstanceBase.cpp:1247(`__CanProcessNetworkStatePacket` 只查
|
||
`IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`);
|
||
REF/UserInterface/InstanceBaseBattle.cpp:355(`NEW_Attack` 门:`IsDead / IsStun /
|
||
IsKnockDown / IsUsingSkill`)、:310(`NEW_UseSkill` 门);
|
||
REF/UserInterface/InstanceBaseEffect.cpp:932(`case AFFECT_STUN` → `SetStun`);
|
||
40250/ClientVS22/source/GameLib/ActorInstanceSync.cpp:43-71(`__IsSyncing` =
|
||
`IsDead() || IsStun() || IsPushing()`,`InterceptOnceMotion(NAME_DAMAGE_FLYING)`
|
||
作击退动作,`IsPushing()` = 物理 blending 中)。
|
||
改动:
|
||
extension/src/net/entity_store.h —— `Entity` 增加 `bool knock_down`;声明
|
||
`mut_knockdown(uint32_t vid, bool on)`;
|
||
extension/src/net/entity_store.cpp —— `mut_knockdown` 落位(值变才标脏 `ChangeKind::Info`);
|
||
extension/src/net/m2_client.cpp —— `entity_dict` 暴露 `d["knock_down"]`;
|
||
project/net_play.gd —— 删除 `const HITSTUN` / `var _hitstun_until` 及全部引用;
|
||
新增 `var _knock_down` / `var _using_skill`;新增
|
||
`_main_entity()` / `_can_process_network_state()` / `_can_attack()` /
|
||
`_can_use_skill()`;`is_stunned()` 改为兼容 shim;`_on_damage` 只置
|
||
`_knock_down` + 播 `damage` + 抖屏;`_process` 轮询
|
||
`player_view.is_in_hit_reaction()` 清 `_knock_down`,`pc.frozen` 与攻击闸门
|
||
改走新函数;
|
||
project/ui/player_view.gd —— `_on_playback_finished` 增加 `_state == "damage"`
|
||
分支(一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`;
|
||
project/combat_fx_test.gd —— 断言改写:命中→`_knock_down` 置位且不能行动;
|
||
服务端 `stunned` 标志独立驱动闸门;击退动作结束 / 闪避 / 他人受击不置位。
|
||
测试:`cmake --build build -j4`;`ctest --test-dir build --output-on-failure` → 19/19;
|
||
`godot --headless --path project --script combat_fx_test.gd` → PASS;
|
||
`netplay_test` / `netbridge_test` / `gamescene_test` / `input_key_test` /
|
||
`p2b_test` / `player_motion_test` → PASS。
|
||
验收:未验证——远端实体 `knock_down` 的接线(net_world 从 motion 8/9 驱动、
|
||
从 wait/walk/run/dead 清零)留待后续增量;真实 40250 服务端的受击 / 击退 / stun
|
||
时序与 `AFFECT_STUN` 清除路径仍需真服证据。
|
||
风险与依赖:本地击退时长依赖 `player_view` 的 `damage.msa` 能正常发
|
||
`playback_finished`(与既有 `__motion` 分支同一机制);`stunned` 目前只有
|
||
`mut_stun` 置位、无独立清除包,属另一处已知缺口(§3.7 范围外)。
|
||
下一批次:W2 §3.3 点击距离常量 / §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型;
|
||
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.3(普攻节奏硬编码 → 动作数据驱动)
|
||
状态:✅ 完成(离线)——参考字段移植 + C++ 19/19 + 相关 GDScript 回归全绿;
|
||
弓箭 __GetBowRange() 与 §3.5 命中窗接入仍待后续增量 / 真服验收。
|
||
参考:40250/ClientVS22/source/EterGrnLib(CGrannyMotion::LoadMotionData →
|
||
CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime,
|
||
fComboLinkTime} / TAttackingData{...});
|
||
REF/UserInterface/InstanceBaseBattle.cpp:294(`NEW_IsClickableDistanceDestInstance`
|
||
默认 150 / IsBowMode → __GetBowRange / IsNPC → 500 / IsResource → 100,单位 cm);
|
||
REF/GameLib/ActorInstanceBattle.cpp:157(`InputComboAttackCommand` 用
|
||
GetComboInputStartTime/GetNextComboTime/GetComboInputEndTime;无 combo 数据退化为
|
||
`fElapsedTime > GetMotionDuration() * 0.9f`);`CInstanceBase::SetAttackSpeed`
|
||
(bAttackSpeed/100 只缩放动作播放速率)。
|
||
改动:
|
||
formats/msa.h / msa.cpp —— `struct Msa` 增加 `has_combo_input` +
|
||
`combo_pre_input_time / combo_direct_input_time / combo_input_limit_time /
|
||
combo_link_time`、`has_attacking_data` + `attacking_type / hitting_type /
|
||
attack_start_time / attack_end_time / stiffen_time / invisible_time /
|
||
external_force`;新增 `combat_data()` 读取 `Group ComboInputData` /
|
||
`Group AttackingData`,`parse_msa` 与 `parse_msa_file` 均调用;
|
||
formats/tests/msa_test.cpp —— 无战斗组时标志为 false;再解析真实
|
||
`onehand_sword/combo_01.msa` 形状,逐字段断言值;
|
||
extension/src/metin2_anim.h / metin2_anim.cpp —— 新增并绑定 `get_motion_data()`,
|
||
导出 `duration / next_combo(has_combo_input && DirectInputTime>0 ?
|
||
DirectInputTime : duration*0.9)/ attacking_type / hitting_type /
|
||
attack_start_time / attack_end_time / stiffen_time / invisible_time /
|
||
external_force` 等;
|
||
project/net_play.gd —— 删除 `const ATTACK_RANGE`,新增
|
||
`CLICK_DIST_DEFAULT_CM=150 / CLICK_DIST_NPC_CM=500 / CLICK_DIST_RESOURCE_CM=100`
|
||
与 `_clickable_distance(te)`(按 `_entity_kind` 选,cm→m);`_apply_attack_speed()`
|
||
只把 `bAttackSpeed/100` 夹到 `[0.25,3]` 写 `_atk_speed_factor`,不再写死
|
||
`attack_period`;新增 `_current_attack_period()` =
|
||
`(get_motion_data().next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`,
|
||
命中点用它刷新 `attack_period` / `_attack_cd`;`_process` 距离判定改用
|
||
`_clickable_distance(te)`;
|
||
project/combat_fx_test.gd —— 移除已删除的 `np.ATTACK_RANGE` 引用,改断言
|
||
无动作数据时节奏回退 `DEFAULT_ATTACK_PERIOD`、攻速 150 → factor 1.5 且节奏按其缩放;
|
||
project/netplay_test.gd —— `attack_speed:150` 改断言 `_atk_speed_factor==1.5` 与
|
||
`_current_attack_period()==DEFAULT_ATTACK_PERIOD/1.5`(不再断言收包即写死 0.4)。
|
||
测试:`cmake --build build -j4`;`ctest --test-dir build --output-on-failure` → 19/19
|
||
(`formats.msa_loop_data` 覆盖扩展断言);
|
||
`godot --headless --path project --script combat_fx_test.gd` → PASS;
|
||
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
|
||
`p2b_test` → PASS。
|
||
验收:未验证——弓箭 `__GetBowRange()` 等价、`.msa` `AttackingData` /
|
||
`List HitPosition` 驱动的命中帧 / 硬直 / 无敌帧(§3.5)、真实 40250 服务端的
|
||
普攻节奏与攻速时序。
|
||
风险与依赖:headless 无 `player_view` 时 `get_motion_data()` 取不到,节奏退化为
|
||
`DEFAULT_ATTACK_PERIOD`(0.6s)——与参考端「无 combo 数据 → duration*0.9」是同类兜底;
|
||
`.msa` 的 combo 组是否随每个连击段切换 clip 而更新,取决于 `player_view.anim`
|
||
的 `anim_path` 是否切到对应 `combo_0N.msa`,该切换属 §3.5 范围。
|
||
下一批次:W2 §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型 / §3.2 命令队列同步;
|
||
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.4(自动攻击目标预留状态机)
|
||
状态:✅ 完成(离线,CLICK_ACTOR 全量)——参考 10 步逐条移植 + `netplay_test` 5b 段 +
|
||
C++ 19/19 + 其余 GDScript 回归全绿;CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派留桩,
|
||
安全区 / 弓箭 / 骑乘 / dash 判定位待服务端下发或后续增量。
|
||
参考:REF/UserInterface/PythonPlayer.cpp:207(`__Update_AutoAttack`)、:182
|
||
(`__IsUsingChargeSkill`);REF/UserInterface/PythonPlayerInput.cpp:820-865(预约模式枚举 /
|
||
字段)、:877(`__ReserveProcess_ClickActor` 10 步)、:306(`__OnPressActor`);
|
||
REF/UserInterface/PythonPlayerInputMouse.cpp:287(`NEW_RefreshMouseWalkingDirection`
|
||
模式分派);OnCannotAttack / OnCannotShot 字符串码经 game.py 同名回调 → localeInfo。
|
||
改动:
|
||
project/net_play.gd ——
|
||
· 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}`
|
||
与 `_reserved_mode / _vid_reserved / _iid_reserved / _ppos_reserved /
|
||
_skill_slot_reserved / _skill_range_reserved / _reserved_delay / _auto_attack_vid /
|
||
_last_cannot`;服务端未下发字段的本地占位 `_arrow_count(-1) / _dash_active /
|
||
_charge_skill`;新增 `signal cannot_act(code)`;
|
||
· `_process` 重写:帧序 knockdown 清闸 → `pc.frozen` → 环境守卫(observer / 未在游戏 /
|
||
无 pc.player 时清预约)→ `_attack_cd -= dt` / `_reserved_delay -= dt` →
|
||
`_update_auto_attack()` → `_refresh_reserved()`;
|
||
· `_update_auto_attack()`:`!_attack_key_down || _target_vid==0` 清 auto-attack;
|
||
`!_can_process_network_state()` / `_dash_active || (_using_skill && _charge_skill)`
|
||
直接返回;目标不存在 / 死亡 / 骑乘且 `!_can_attack_horse_level()` → `_clear_auto_attack()`;
|
||
可攻击且非睡眠且 `_reserved_mode==NONE && _can_attack()` → `_reserve_click_actor()`;
|
||
· `_reserve_process_click_actor()` 10 步:① 空引用 / 无节点 → `_clear_reserved()`
|
||
② `dist > _clickable_distance(rv)` → `pc.walk_to(tnode)` ③ `!_is_attackable` →
|
||
`pc.frozen=false` + `client.click_npc()` + 清预约 ④ 目标死 ⑤ `main.in_safe` →
|
||
`_emit_cannot("IN_SAFE")` ⑥ `rv.in_safe` → `"DEST_IN_SAFE"` ⑦ `_dash_active` 保持预约
|
||
⑧ `main.bow_mode && _arrow_count==0` → `_emit_cannot("EMPTY_ARROW")` ⑨
|
||
`_target_vid != _vid_reserved` → `client.set_target` + `target_changed` + HUD 刷新
|
||
⑩ `_attack_cd<=0` 才 `_do_attack_swing()`,然后 `_clear_reserved()`;
|
||
· `_do_attack_swing()`:面向目标 + combo 窗口递增 + `client.attack(motion+combo, vid)` +
|
||
`player_view.set_anim_state("combo"/"attack")` + `attack_period = _current_attack_period()`
|
||
(§3.3);
|
||
· `_is_attackable(e)` = 非死亡且 `_entity_kind in [0,2]`(PC / MONSTER;帝国 / 决斗归 §5.4)。
|
||
project/netplay_test.gd —— `FakePC` 加 `walk_to()` / `walk_calls` / `frozen`;新增 5b 段:
|
||
出可点击距离 → `walk_to` 且不发攻击、预约保持 CLICK_ACTOR;进距离 → 攻击且预约清空;
|
||
`fc.ents[1000].in_safe=true` → `cannot_act("IN_SAFE")` 且不发包;目标死 → `_auto_attack_vid==0`。
|
||
测试:`ctest --test-dir build` → 19/19;
|
||
`godot --headless --path project --script netplay_test.gd` → PASS;
|
||
`combat_fx_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` /
|
||
`p2b_test` / `input_key_test` → PASS。
|
||
验收:未验证——CLICK_ITEM(`< 20.0f` 拾取阈值 + `_reserved_delay`)/ CLICK_POSITION /
|
||
USE_SKILL(`IS_HUGE_RACE` −200 修正 / charge skill)分派;安全区 / 弓箭模式 / 箭矢数 /
|
||
骑乘等级 / dash affect 的真实服务端来源;`OnCannot*` 文案接 HUD;真实 40250 自动攻击时序。
|
||
风险与依赖:本工程用「按住攻击键 + 有目标」当 `__OnPressActor(isAuto)`;参考端 auto-attack
|
||
不依赖按键,接入真实输入映射时需把 `_attack_key_down` 换成真正的 auto-attack 目标设置点。
|
||
判定位占位读实体字典键(`in_safe` / `bow_mode` / `mounting` / `sleeping`),服务端未填时
|
||
恒为安全默认(可打、不在安全区、非骑乘),与 §3.7 `knock_down` 同套做法。
|
||
下一批次:W2 §3.1 Src-Dst 移动模型 / §3.2 命令队列同步;之后 §3.5 攻击动作-命中-封包绑定、
|
||
§3.6 箭矢 FLY、§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.1(本地移动 Src/Dst 模型)
|
||
状态:✅ 完成(离线,本地移动切片)——`NEW_Goto` 前置门 + Src/Dst + `__ReserveClickGround`
|
||
0.1 s 重试逐条移植 + 新增 `player_move_test.gd`(5 组)+ C++ 19/19 + 其余 GDScript 回归全绿;
|
||
Actor 间碰撞与坐骑 `MODE_HORSE*` 留后续增量。
|
||
参考:REF/UserInterface/InstanceBaseMovement.cpp:251(`CInstanceBase::NEW_Goto`)、:293
|
||
(`NEW_MoveToDirection`);REF/UserInterface/PythonPlayerInput.cpp:419(`__OnPressGround`)、
|
||
:445(`NEW_MoveToDirection`)、:626(`NEW_IsEmptyReservedDelayTime`)、:834
|
||
(`__ReserveClickGround` 的 `m_fReservedDelayTime = 0.1f`);
|
||
REF/UserInterface/InstanceBaseBattle.cpp:469(`CheckAdvancing`)、:560(`ATTRIBUTE_BLOCK`)。
|
||
改动:
|
||
project/player_controller.gd ——
|
||
· 删除 `var _move_target` / `var _has_target` 的直线改坐标;
|
||
· 新增 `const MOVABLE_GROUND_DISTANCE_M := 1.0` / `const RESERVED_GROUND_DELAY := 0.1`、
|
||
`var locked` / `var moving_skill`、`_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot` /
|
||
`_reserved_ground` / `_reserved_delay_time`;`is_going()` / `_can_translate()`
|
||
(= `not frozen and not locked and not moving_skill`);
|
||
· `_goto(dst_flat)` 对齐 `NEW_Goto`:`flat.length() < MOVABLE_GROUND_DISTANCE_M` → 返回
|
||
true 不动(≈ `__IsMovableGroundDistance`);`!_can_translate()` → 存 `_reserved_ground`
|
||
+ `_reserved_delay_time = RESERVED_GROUND_DELAY` 返回 false(≈ `__ReserveClickGround`);
|
||
否则 `_src_pos = 当前` / `_dst_pos = 点` / `_dst_rot = atan2(flat.x, flat.z)` /
|
||
`_is_going = true`;`walk_to()` → `_goto()`;`_on_click` 点地分支 → `_goto(hit)`;
|
||
· `_process`:新增 `_reserved_delay_time` 递减,归 0 且 `_can_translate()` → 重跑
|
||
`_goto(_reserved_ground)`(≈ `NEW_IsEmptyReservedDelayTime` 递减重试);`frozen or locked`
|
||
时发 `wait` 且**保留** `_is_going`(解冻后继续走);WASD 分支置 `_is_going = false`
|
||
(≈ `NEW_MoveToDirection` 的 `m_isGoing = FALSE`);点走分支用 `_is_going` / `_dst_pos`;
|
||
平移段包一层 `if moving_skill: 只 rotation.y = atan2(wish.x, wish.z) else: <平移>`,
|
||
平移到位 `_is_going = false`。
|
||
project/player_move_test.gd(新增)—— headless `SceneTree`:① `_goto(远)` 设
|
||
Src/Dst/is_going/dst_rot;② `< 1 m` 太近不动;③ 每帧推进抵达清 `is_going`;
|
||
④ `frozen` → `_goto` 返回 false + 预约,解冻后 0.1 s delay 递减自动重试;
|
||
⑤ `moving_skill` → `_goto` 不平移。
|
||
测试:`ctest --test-dir build` → 19/19(本增量无 C++ 改动);
|
||
`godot --headless --path project --script player_move_test.gd` → PASS;
|
||
`netplay_test` / `combat_fx_test` / `gamescene_test` / `eterngrn_polish_test`(exit 0,
|
||
既有无关 `motion_event: sound` FAIL)/ `player_motion_test` / `p2b_test` /
|
||
`netbridge_test` / `mouse_controller_test` → PASS。
|
||
验收:未验证——Actor 间 `TestActorCollision` / `BlockMovement`(需远端 Actor 碰撞体)、
|
||
坐骑 `MODE_HORSE*` 移速与动作模式(依赖 §6.4 + §3.2 命令队列)、`MOVABLE_GROUND_DISTANCE`
|
||
参考默认值(现取 1.0 m,待读 `game.py` 侧 `player.SetMovableGroundDistance`)、
|
||
网络状态包驱动移动的 `__EnableSkipCollision()`(§3.2)。
|
||
风险与依赖:`_can_translate()` 把 `__IsSyncing`(Dead/Stun/Pushing)近似成 `frozen`;
|
||
真实同步态位待 §3.2 命令队列接入后细化。碰撞仍只有地形阻挡(沿用 §3.1「当前」实现),
|
||
Actor 叠站问题留到有碰撞体时解决。
|
||
下一批次:W2 §3.2 命令队列同步(`EntityStore` 状态队列 + 硬阈值,不引入预测回滚 / 抖动缓冲);
|
||
之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.2(远端同步命令队列 —— C++ 核心,part A)
|
||
状态:🟡 部分完成——`EntityStore` 命令队列 + 硬阈值 switch + 释放门已就位;net_play
|
||
上行 6 回调、net_world 去自有 lerp、真实服务器帧钟留给增量 78。
|
||
参考:REF/UserInterface/InstanceBase.cpp:1278(`CInstanceBase::StateProcess`)——
|
||
`while` 队列非空:`ELTimer_GetServerFrameMSec() < front.m_dwChkTime` → return;pop;
|
||
`!__CanProcessNetworkStatePacket()`(Dead / KnockDown / 不可取消技能)→ return;
|
||
`!__IsEnableTCPProcess(eFunc)`(表情锁)→ return;`switch (eFunc)`:
|
||
`FUNC_WAIT` dirLen>1.0 → 走 + `m_kMovAfterFunc=WAIT`;`FUNC_WAIT` else →
|
||
`SCRIPT_SetPixelPosition(dst)` 贴齐;`FUNC_MOVE` → 恒走(after MOVE);
|
||
`FUNC_COMBO/ATTACK/MOB_SKILL` dirLen>=50.0 → 走到位再动作,else 贴齐 + 动作;
|
||
所有「走过去」分支 `__EnableSkipCollision()`。
|
||
REF/UserInterface/NetworkActorManager.cpp:8(`SNetworkActorData::UpdatePosition`)——
|
||
`(now - m_dwClientSrcTime)/m_dwDuration` 线性插值,仅供可见性。
|
||
改动:
|
||
extension/src/net/entity_store.h ——
|
||
· `#include <deque>`;FUNC_* 之后新增 `struct StateCmd { uint32_t chk_time;
|
||
float dst_x,dst_y,rot; uint8_t func,arg; uint32_t dur_ms; }`;
|
||
· `Entity` 新增 `std::deque<StateCmd> state_queue` / `mov_after_func` /
|
||
`mov_after_arg` / `mov_after_rot` / `bool skip_collision`;
|
||
· 新增 `set_server_frame_ms()` / `server_frame_ms()` / `process_states()`;
|
||
`m_server_frame_ms = 0` 私有成员;`mut_move()` 签名加 `arg=0, chk_time=0`;
|
||
· 删 `start_move()` 声明;加 `drain_state_queue` / `apply_state_cmd` /
|
||
`begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`。
|
||
extension/src/net/entity_store.cpp ——
|
||
· `#include <cmath>`;删 `start_move()` 定义;
|
||
· `mut_move()` 改为 `state_queue.push_back({...})` + `drain_state_queue(e)` +
|
||
`ChangeKind::Move`;
|
||
· `drain_state_queue`:`while`:`m_server_frame_ms != 0 && m_server_frame_ms <
|
||
front.chk_time` → break;`!can_process_network_state(e)` → break;
|
||
`!is_enable_tcp_process(e, front.func)` → break;pop + `apply_state_cmd`;
|
||
· `apply_state_cmd`:`FUNC_WAIT` dir_len>1.0 → `begin_state_walk(WAIT)`,else
|
||
贴齐 + `func=WAIT` + `skip_collision=false`;`FUNC_MOVE` → `begin_state_walk(MOVE)`;
|
||
`FUNC_COMBO/ATTACK/MOB_SKILL` dir_len>=50.0 → `begin_state_walk(c.func)`,else
|
||
贴齐 + `func=c.func`;default 贴齐 + `func=c.func`;
|
||
· `begin_state_walk`:`angle/moving/func=FUNC_MOVE/sx,sy/tx,ty/move_start_ms/
|
||
move_dur_ms` + `mov_after_*` + `skip_collision=true`;walk 时长优先 `c.dur_ms`,
|
||
为 0 时 `dist / (moving_speed>0?moving_speed:150) * 1000`(min 1);
|
||
· `can_process_network_state` = `!e.dead && !e.knock_down`;
|
||
`is_enable_tcp_process` = `true`(表情锁待增量 78);
|
||
· `process_states()` 遍历 `m_ents` → `drain_state_queue`;
|
||
· `tick()`:插值循环前先 `process_states()`;`t>=1.0f` 到达分支加
|
||
`e.skip_collision=false` + `mov_after_func` 为 COMBO/ATTACK/MOB_SKILL 时
|
||
`func=mov_after_func; angle=mov_after_rot`,否则 `func=FUNC_WAIT`;清 `mov_after_func`;
|
||
· m2dev `apply()` 的 GC_MOVE 分支:`mut_move(vid, rot*360/256, func, x, y,
|
||
duration, arg, time)`。
|
||
extension/src/net/classic/classic_parser.cpp —— HDR_GC_MOVE:`mut_move(vid,
|
||
rot*5.0f, func, x, y, duration, arg, time)`。
|
||
extension/tests/net_entity_test.cpp —— 旧 FUNC_ATTACK 贴齐单测拆成「近」(dir_len
|
||
30<50 → 贴齐 + `func==FUNC_ATTACK`)+「远」(dir_len 170>=50 → 先 `func==FUNC_MOVE`
|
||
走,`tick()` 后到达 Dst + `func==FUNC_ATTACK`)。
|
||
extension/tests/net_state_queue_test.cpp(新增,5 组)——① 服务器帧钟释放门(未来
|
||
chk_time 挂队;帧钟追上才释放;帧钟 0 立即释放);② FUNC_WAIT 1.0 阈值(贴齐 vs 走 +
|
||
after WAIT + skip_collision);③ FUNC_COMBO 50.0 阈值 + 到达后动作 + arg 锁存;
|
||
④ `__CanProcessNetworkStatePacket` 门(knock_down / dead 挂队,恢复即释放;未知 vid 不崩);
|
||
⑤ 多条到期命令按序一次性 drain,末条胜出。
|
||
extension/CMakeLists.txt —— 加 `net_state_queue_test` → `add_test(net.state_queue)`。
|
||
测试:`ctest --test-dir build` → 20/20(新增 `net.state_queue`);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
|
||
`combat_fx_test` → PASS;`eterngrn_polish_test` exit 0(既有无关 `motion_event: sound` FAIL)。
|
||
验收:未验证——真实 40250 `GC_HANDSHAKE` `lDelta` 帧钟对齐;`GC_SYNC_POSITION` 是否应
|
||
清队列;`__IsEnableTCPProcess` 表情锁;net_play 上行节流表(100/300 ms + disp≥1.0)与
|
||
net_world 去 lerp(都在增量 78)。
|
||
风险与依赖:`m_server_frame_ms == 0` 时释放门直通 = 退回增量 76 的即时时序,接真实帧钟
|
||
(增量 78)前无回归但也无抖动吸收。`is_enable_tcp_process` 目前恒 `true`,表情锁
|
||
(`FUNC_EMOTION` 期间拒 TCP 状态)待 §3.x 表情切片。Actor 碰撞近似仍未做,`skip_collision`
|
||
标志已就位但 `tick()` 尚未据此跳过碰撞体(无碰撞体,暂无影响)。
|
||
下一批次:W2 §3.2 part B(增量 78)—— net_play.gd 上行 6 事件回调 + 节流常量、
|
||
net_world.gd 去掉自有 lerp(C++ 插值只喂 §2.1 可见性)、从 `GC_HANDSHAKE` `lDelta`
|
||
接 `set_server_frame_ms()`;之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-02 / W2 / §3.2 part B(上行 6 事件回调 + net_world 去 lerp)
|
||
状态:🟡 部分完成——上行 6 回调 + 节流表 + net_world 去自有 lerp 落地;真实服务器
|
||
毫秒帧钟(`GC_HANDSHAKE` `lDelta` → `set_server_frame_ms()`)留给增量 79。
|
||
参考:REF/UserInterface/PythonPlayerEventHandler.cpp —— OnMove(:76) 重置 wait+100ms /
|
||
moving+300ms 后发 FUNC_MOVE,0;OnMoving(:62) 300ms 节流 → FUNC_MOVE,0;OnWaiting(:38)
|
||
100ms 且 disp≥1.0 → FUNC_WAIT,0;OnStop(:88) 不节流 → FUNC_WAIT,0;OnAttack(:102)
|
||
不节流 → FUNC_COMBO,wMotionIndex;OnUseSkill(:120) 不节流 → FUNC_SKILL|uMotSkill,uArg。
|
||
REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:1111(`SendCharacterStatePacket`)——
|
||
`TPacketCGMove{ bFunc, bArg, bRot=rot/5.0f, lX, lY, dwTime }`,角度归一 [0,360)。
|
||
REF/UserInterface/NetworkActorManager.cpp:8(`SNetworkActorData::UpdatePosition`)——
|
||
位置线性插值只供可见性;节点位置改由 C++ 命令队列(增量 77)驱动。
|
||
改动:
|
||
project/net_play.gd ——
|
||
· 删 `const SEND_INTERVAL` / `const SEND_DIST` / `var _last_sent_t`;新增
|
||
`const FUNC_COMBO=3` / `const FUNC_SKILL=0x80` / `SEND_MOVING_INTERVAL=0.3` /
|
||
`SEND_WAITING_INTERVAL=0.1` / `SEND_WAITING_MIN_DISP_CM=1.0`;`_last_sent_pos`
|
||
改存世界坐标,新增 `_last_moving_sent_t` / `_last_waiting_sent_t`。
|
||
· 新增 `_send_state(func, arg, world_pos)` 统一出口 → `client.move(func, arg,
|
||
heading, cm.x, cm.y)` + 记 `_last_sent_pos`。
|
||
· `_on_local_moved`:`!_was_moving` → OnMove(立即 `_send_state(FUNC_MOVE,0)` +
|
||
`_last_moving_sent_t = _last_waiting_sent_t = now`);否则 OnMoving(`now -
|
||
_last_moving_sent_t < SEND_MOVING_INTERVAL` 则 return,否则发 `FUNC_MOVE`)。
|
||
· `_on_anim_state`:"wait" 且 `_was_moving` → OnStop `_send_state(FUNC_WAIT,0)`。
|
||
· 新增 `_tick_on_waiting()`(`_process` 尾部调):`!_was_moving` 时 100ms 节流 +
|
||
`_server_xy(player) 距 _server_xy(_last_sent_pos) >= 1.0` 才 `_send_state(FUNC_WAIT,0)`。
|
||
· `_do_attack_swing`:`client.attack()` 后加 `_was_moving = false` +
|
||
`_send_state(FUNC_COMBO, attack_motion + _combo, player.pos)`(OnAttack)。
|
||
· 新增公共 `on_use_skill(motion_skill, arg)` → `_send_state(FUNC_SKILL |
|
||
(motion_skill & 0x7F), arg, player.pos)`(OnUseSkill;调用点属 §3.8 `player_skill.gd`)。
|
||
project/net_world.gd ——
|
||
· `_process`:`n.position = _grounded(e.get("pos", n.position))` 直接跟随,删掉
|
||
`if dist > snap_dist ... else n.position.lerp(want, follow_lerp*dt)` 的自有二次平滑。
|
||
· `follow_lerp` / `snap_dist` 字段保留(外部读取 / 旧测试兼容),`_process` 不再引用。
|
||
project/netplay_test.gd —— 观战段基线改「相对计数不变」(`move_n` / `target_n`);
|
||
新增第 11 段:OnMove 立即发 + 重置节流窗、OnMoving <300ms 节流 / 过期后放行、
|
||
OnStop → FUNC_WAIT、`on_use_skill(5,2)` → `move[0] == [FUNC_SKILL|5, 2, ...]`。
|
||
project/netbridge_test.gd —— 「near move → lerp closes in」改为「move → node follows
|
||
e.pos directly」(`distance < 0.01`)。
|
||
测试:`ctest --test-dir build` → 20/20(无 C++ 改动);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
|
||
`combat_fx_test` / `player_motion_test` → PASS。
|
||
验收:未验证——真实 40250 上行时序 / 服务端对 6 类 func 的接收;`OnWaiting` 的 `disp≥1.0`
|
||
单位(现按服务器 cm);`OnAttack` 额外状态包与 `CG_ATTACK` 的先后;`on_use_skill` 的
|
||
真实调用点(§3.8)。
|
||
风险与依赖:位置插值全交 C++ `EntityStore::tick()` 后,`net_world` 只跟随 `e.pos`——
|
||
若 C++ 帧钟(增量 79)未接,命令仍即时释放,观感等同增量 76 + 命令队列排序,无抖动吸收。
|
||
`_tick_on_waiting` 用本地 `Time.get_ticks_msec()`,接真实服务器帧钟前节流窗口是客户端时钟。
|
||
下一批次:W2 §3.2 part C(增量 79)—— 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟
|
||
(`ClassicStream::m_server_time_base` / `m_client_time_base` 已就位,需暴露访问器并穿到
|
||
`EntityStore::set_server_frame_ms()`);之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、
|
||
§3.8 技能合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.2 part C(真实服务器毫秒帧钟,§3.2 收官)
|
||
状态:✅ 完成
|
||
参考:REF/UserInterface/EterLib/Timer.cpp(`ELTimer_GetServerFrameMSec()`)——
|
||
客户端帧钟 = 本地毫秒 - 握手时的本地基准 + 握手带来的服务器基准。
|
||
REF/.../PythonNetworkStreamPhaseHandShake.cpp:124(`lDelta` 时钟对齐)。
|
||
改动:
|
||
extension/src/net/classic/classic_stream.h ——
|
||
· public 加 `uint32_t server_frame_ms() const;` + `bool has_server_clock() const
|
||
{ return m_client_time_base != 0; }`(附 §3.2 注释)。成员 `m_server_time_base` /
|
||
`m_client_time_base` 已存在(`:142-143`)。
|
||
extension/src/net/classic/classic_stream.cpp ——
|
||
· `server_frame_ms()` 定义:`m_client_time_base == 0` 返回 0,否则
|
||
`now_ms() - m_client_time_base + m_server_time_base`(`now_ms()` 为本文件匿名
|
||
命名空间里的 steady_clock 毫秒助手)。`HDR_HANDSHAKE` 处理里的
|
||
`m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`
|
||
(`:266-267`)无改动。
|
||
extension/src/net/classic/classic_session.cpp ——
|
||
· `process()` 在 `m_world.tick()` 前加
|
||
`m_world.set_server_frame_ms(m_stream.server_frame_ms());`(`:307`)。
|
||
extension/src/net/entity_store.h ——
|
||
· `StateCmd` 后加 `static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;`
|
||
(防呆阀,附注释说明是对参考端的有意偏离)。
|
||
extension/src/net/entity_store.cpp ——
|
||
· `drain_state_queue` 帧钟门改为:`m_server_frame_ms != 0 && chk_time >
|
||
m_server_frame_ms && chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS`
|
||
才 break;超过 cap 的领先量当异帧钟 `dwTime`,立即放行。
|
||
extension/tests/net_classic_stream_test.cpp ——
|
||
· 新增「server frame clock (§3.2)」段:握手前 `server_frame_ms()==0` +
|
||
`!has_server_clock()`;喂一个 `hs.time=500000, delta=20` 的握手后
|
||
`has_server_clock()` 且帧钟 ∈ [500020, 505020);连读两次单调不减。
|
||
extension/tests/net_state_queue_test.cpp ——
|
||
· 新增第 6 段:`set_server_frame_ms(1000)`,`chk_time == 1000+cap` 仍 gated;
|
||
另一实体 `chk_time == 1000+cap+1` 立即放行(防呆阀边界)。
|
||
测试:`ctest --test-dir build` → 20/20(`net.classic_stream` / `net.state_queue` 覆盖新逻辑);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
|
||
`combat_fx_test` / `player_motion_test` → PASS。
|
||
验收:未验证——真实 40250 `GC_MOVE` `dwTime` 是否与握手帧钟同一时基(若同,`STATE_QUEUE_
|
||
MAX_WAIT_MS` 阀几乎不触发;若不同,阀保证远端 Actor 不会永久冻结)。m2dev 后端仍无
|
||
帧钟来源,`m_server_frame_ms == 0` 即时释放 = 增量 76 时序,无回归。
|
||
风险与依赖:帧钟阀阈值 1 s 为经验值,联机核验后可收紧或去除。时间同步模式下每个
|
||
resync 握手都会刷新 `m_server_time_base` / `m_client_time_base`,帧钟持续对齐。
|
||
下一批次:§3.2 收官。W2 继续 §3.5 攻击动作-命中-封包绑定(`.msa` `AttackingData` /
|
||
`HitPosition`)、§3.6 箭矢 FLY 事件、§3.8 技能合法性(`player_skill.gd`,也是
|
||
`net_play.on_use_skill` 的真实调用点)、§3.10 技能升级 gating。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 part A(PC 连击段表解析)
|
||
状态:🟡 部分完成(增量 80)
|
||
参考:
|
||
REF/GameLib/RaceData.h:9 —— MAKE_COMBO_KEY(motionMode, comboType)
|
||
= (DWORD(mode)<<16) | DWORD(type)
|
||
REF/GameLib/RaceData.cpp:405-475 —— CRaceData::ReserveComboAttack(map::insert,
|
||
重复 key 不覆盖;ComboIndexVector.resize(count) 清零)
|
||
/ RegisterComboAttack(key 未登记 return;
|
||
index >= size AssertLog+return;否则 vec[index]=motion)
|
||
REF/UserInterface/PythonCharacterManagerModule.cpp:260-330 ——
|
||
chrmgr.ReserveComboAttackNew / RegisterComboAttackNew 绑定
|
||
assets/root/playersettingmodule.py —— 四个 def __LoadGame<Class>Ex(race, path)
|
||
(Warrior@480 / Assassin@663 / Sura@876 / Shaman@1001),
|
||
函数体与性别无关 → 连击段表按职业收。
|
||
结论:PC 连击段表**不在** `.msm` 里(`.msm` 只有 BaseModelFileName + HairData)。
|
||
改动:
|
||
formats/combo_table.h(新)——
|
||
· enum ComboMotionMode(CRaceMotionData::EMode,0..16)
|
||
· COMBO_NAME_NORMAL_ATTACK=13 / COMBO_NAME_COMBO_ATTACK_1=14 / _8=21
|
||
· make_combo_key / combo_key_mode / combo_key_type(对齐 RaceData.h)
|
||
· enum ComboClass { WARRIOR=0, ASSASSIN=1, SURA=2, SHAMAN=3, COUNT=4 }
|
||
· struct ComboTable { map<uint32_t, vector<uint16_t>> combos;
|
||
get(mode,type) -> vec* 或 nullptr(= GetComboDataPointer 语义); empty(); key_count(); }
|
||
· struct PlayerComboTables { ComboTable per_class[4]; klass(c) 越界 -> 空表 }
|
||
· parse_player_combo_tables(py_text, out, err) / _file(path, out, err)
|
||
formats/combo_table.cpp(新)——
|
||
· 逐行扫描;`def ` 行切块(只有四个 __LoadGame<Class>Ex 命中,其它 def 复位 cur_class=-1)
|
||
· resolve_token:chr.MOTION_MODE_*(静态 17 名表)、chr.MOTION_COMBO_ATTACK_N(→13+N)、
|
||
chr.MOTION_NORMAL_ATTACK(→13)、COMBO_TYPE_N / COMBO_INDEX_N(→N-1)、裸整数
|
||
· ReserveComboAttackNew(要求 3 实参)→ combos.emplace(key, vector(count,0))(emplace 不覆盖)
|
||
· RegisterComboAttackNew(要求 4 实参)→ 找 key,未登记 continue,idx 越界 continue,
|
||
否则 vec[idx]=motion
|
||
formats/CMakeLists.txt ——
|
||
· xr_formats 源表加 combo_table.cpp
|
||
· 加 formats_combo_table_test(+ M2_ASSETS 时透传做 live 断言)
|
||
formats/tests/combo_table_test.cpp(新)——
|
||
· 合成片段:warrior GENERAL/1H type1-2(含越界 Register + 重复 Reserve 各一),
|
||
assassin DUALHAND type3(只设 idx5),非 Ex 的 def __LoadGameNPC 里一条 stray Reserve
|
||
· 断言:key 编码、段号向量逐格、size 不被重复 Reserve/越界 Register 改动、
|
||
type3 未 Reserve→nullptr、职业隔离、块外调用不落任何职业(总 key_count==4)、
|
||
klass(-1)/klass(99) 空表不崩
|
||
· live(M2_ASSETS):真实 playersettingmodule.py → warrior 1H type1==[14,15,16,17]、
|
||
sura 1H type3 size 6 且末段==17、shaman FAN type1==[14,15,16,17]
|
||
测试:`ctest --test-dir build` → 21/21(新 `formats.combo_table` 含 live 断言 `M2_ASSETS` OK);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
|
||
`combat_fx_test` / `player_motion_test` → PASS(7/7)。
|
||
验收:解析器纯离线,合成 + 真实资源双覆盖,无需联机。
|
||
下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出,`msa.h` 目前只有
|
||
单个 `AttackingData` 窗)+ 修改 4(`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` +
|
||
`FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16)+ 武器种类 → `combo_motion_mode` 映射。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 part B(PC 连击状态机)
|
||
状态:🟡 部分完成(增量 81)
|
||
参考:
|
||
REF/GameLib/ActorInstanceBattle.cpp:157 —— InputComboAttackCommand:m_isPreInput 时忽略;
|
||
m_dwcurComboIndex==0 → __RunNextCombo;否则按
|
||
GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime
|
||
判 pre-input 挂起 / 直接推进 / 弓模式 m_isNextPreInput;
|
||
无 combo 输入组 → GetMotionDuration()*0.9f 兜底
|
||
REF/GameLib/ActorInstanceBattle.cpp:213 —— ComboProcess:index!=0 时 null-guard→__ClearCombo,
|
||
m_isPreInput && t>GetNextComboTime → __RunNextCombo;
|
||
index==0 时清 m_isPreInput,!IsUsingSkill && m_isNextPreInput
|
||
→ __RunNextCombo
|
||
REF/GameLib/ActorInstanceBattle.cpp:256 —— __RunNextCombo:++index;arrIdx=wComboIndex-1;
|
||
GetComboDataPointer FALSE→return;OOB→TraceError+return;
|
||
ComboAttack(vec[arrIdx]); index==size → __OnEndCombo
|
||
REF/GameLib/ActorInstanceBattle.cpp:297/308 —— __OnEndCombo(骑乘 index=1,否则不复位)/
|
||
__ClearCombo(index=0;isPreInput=FALSE;pkCurRaceMotionData=NULL)
|
||
REF/GameLib/ActorInstance.cpp:164 —— __GetCurrentComboType:弓 / GENERAL(手) / 骑乘 → 0
|
||
增量 80 的 formats/combo_table.{h,cpp} + assets/root/playersettingmodule.py
|
||
改动:
|
||
extension/src/metin2_anim.h / .cpp ——
|
||
· 新静态绑定 Metin2AnimPlayer.parse_combo_tables(py_path) -> { class:int ->
|
||
{ key:int -> PackedInt32Array(段号) } },转发 fmt::parse_player_combo_tables_file
|
||
· 新静态绑定 Metin2AnimPlayer.make_combo_key(motion_mode, combo_type) -> int
|
||
· `.msa` ComboInputData 三时间 / AttackingData 已在 get_motion_data() 暴露,无需改
|
||
project/net_play.gd ——
|
||
· 删 COMBO_WINDOW / _combo / _last_attack_t / attack_motion
|
||
· 加 MOTION_MODE_*(GENERAL=1 / BOW=5 / HORSE 9..15 / HORSE_BOW=13)、
|
||
NAME_NORMAL_ATTACK=13 / NAME_COMBO_ATTACK_1=14;
|
||
combo_class / combo_motion_mode / _combo_type / _combo_index / _is_pre_input /
|
||
_is_next_pre_input / _combo_started_t / _combo_tables
|
||
· set_asset_root(root) → parse_combo_tables(root/root/playersettingmodule.py)(class_exists 守卫)
|
||
· set_combo_type(clamp 0..2)/ set_combo_motion_mode(变更即 _clear_combo)/
|
||
_is_bow_mode / _current_combo_type(弓/GENERAL/骑乘 → 0)/ _combo_vec / _motion_data
|
||
· _on_entity_info:entity.race & 3 → combo_class
|
||
· _do_attack_swing / _run_next_combo / _combo_process / _on_end_combo / _clear_combo
|
||
逐行对齐参考端五函数;段表空 → _emit_swing(NAME_NORMAL_ATTACK)
|
||
· _emit_swing:client.attack(0, vid)(CG_ATTACK bType=技能号,普攻 0)+
|
||
_send_state(FUNC_COMBO, 段号)(这一包才带 14..21)——两个不同时刻的两个包(增量 78)
|
||
· _process:_update_auto_attack 前插 _combo_process()
|
||
project/game_scene.gd —— net_play 装配后 `if _assets != "": net_play.set_asset_root(_assets)`
|
||
project/combat_fx_test.gd —— 加 FakeAnim(可控 get_motion_data)+ FakeView.anim;
|
||
FakeClient.move 记录到 fc.moves;重写 combo 段:4 段合成表跑
|
||
首次输入→段1 / start 前忽略 / [start,next) 挂 pre-input / _combo_process 触发→段2 /
|
||
过 NextComboTime 直接推进→段3→段4 / 末段越界不推进 / 动作回 Wait→__ClearCombo→0 /
|
||
空段表→单段 NAME_NORMAL_ATTACK;断言 CG_ATTACK bType==0 且 FUNC_COMBO 带段号
|
||
测试:`ctest --test-dir build` → 21/21;`netbridge_test` / `gamescene_test` / `netplay_test` /
|
||
`player_move_test` / `p2b_test` / `combat_fx_test` / `player_motion_test` → PASS(7/7)。
|
||
验收:纯离线;连击推进 / pre-input / 弓 next-pre-input / 末段 / 清空全路径由 combat_fx_test 覆盖。
|
||
未验证:真实服务器对 CG_CHARACTER_MOVE{FUNC_COMBO} 段号的接受(联机 E2E)。
|
||
下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出)+ 修改 4(`THitData`
|
||
窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16,
|
||
复用 `M2Client::sync_positions()`)+ 武器种类 → `combo_motion_mode` 映射。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 part C(.msa 多命中窗导出,修改 1)
|
||
状态:🟡 部分完成(增量 82)
|
||
参考:
|
||
REF/GameLib/GameType.h:71-90 —— NRaceData::SHitData { fAttackStartTime; fAttackEndTime;
|
||
fWeaponLength; strBoneName; THitTimePositionMap mapHitPosition }
|
||
THitDataContainer = vector<THitData>;
|
||
SMotionAttackData : SAttackData { iMotionType; THitDataContainer }
|
||
REF/GameLib/GameType.cpp:42-118 —— THitData::Load(attackingstarttime / attackingendtime /
|
||
attackingbone / weaponlength / GetTokenVector("hitposition")
|
||
每 7 个浮点 = time + v3LastPosition + v3Position);
|
||
LoadMotionAttackData:无 hitdatacount → 组本身即单窗;
|
||
有 hitdatacount N → SetChildNode(i) 逐个读 Group HitData0i
|
||
真实 .msa:assets/PC/ymir work/pc/warrior/general/attack.msa(老式内联单窗)、
|
||
.../twohand_sword/combo_05.msa(HitDataCount 1)、
|
||
.../assassin/dualhand_sword/combo_01.msa(HitDataCount 2:R Hand + L Hand)、
|
||
.../warrior/skill/palbang.msa(AttackingData 后还有 MotionEventData)
|
||
改动:
|
||
formats/textscript.cpp ——
|
||
· parse_body 把 `List Name { ... }` 与 `Group Name { ... }` 一样当子节点
|
||
(List 体是一串裸值,收进子节点 lines[0])。原来 `List` 落入「普通行」分支,会把
|
||
`{` 也 push 进行、并且吃不到配对的 `}`,导致其后的 `Group` 被串掉
|
||
(palbang.msa 的 MotionEventData 整个丢失)。
|
||
formats/msa.h ——
|
||
· 删单窗字段留兼容壳:attack_start_time / attack_end_time 现在 = hit_windows[0] 起止
|
||
· 新 struct HitSample { float time; float last_pos[3]; float pos[3]; }
|
||
· 新 struct HitWindow { float start_time,end_time; string bone_name; float weapon_length;
|
||
vector<HitSample> samples; }
|
||
· Msa 加 vector<HitWindow> hit_windows; int motion_type; int hit_limit_count;
|
||
attacking_type 现在读 AttackType||AttackingType
|
||
formats/msa.cpp ——
|
||
· hit_positions():List HitPosition 的 lines[0] 按 7 个一组切 HitSample
|
||
· hit_window():从一个节点读 AttackingStartTime/EndTime/AttackingBone/WeaponLength + 采样
|
||
· combat_data():AttackingData 组内找名字前缀 "HitData" 的子组 → 各一窗;
|
||
没有则组本身即单窗;填 attack_start_time/end_time = 首窗起止
|
||
extension/src/metin2_anim.cpp get_motion_data() ——
|
||
· 新增 hit_windows 数组(每项 {start_time,end_time,bone,weapon_length,
|
||
samples:[{time,last_pos:Vector3,pos:Vector3}]})+ motion_type + hit_limit_count
|
||
formats/tests/msa_test.cpp ——
|
||
· 合成:内联单窗(2 采样,校验 bone / 起止 / last_pos / pos)、
|
||
新式 HitDataCount 2(HitData00 有采样 / HitData01 无采样 / motion_type / hit_limit_count)
|
||
· live(M2_ASSETS):general/attack(1 窗 17 采样)、twohand_sword/combo_05
|
||
(1 窗 wlen 150 / 37 采样)、assassin/dualhand_sword/combo_01(2 窗 R Hand+L Hand 各 13 采样)、
|
||
skill/palbang(1 窗 wlen 130 / hit_limit 18 + events 3 —— 验证 List 修复没串掉 MotionEventData)
|
||
formats/CMakeLists.txt —— formats.msa_loop_data 加 M2_ASSETS 环境透传
|
||
测试:`ctest --test-dir build` → 21/21(`formats.msa_loop_data` 含 live 断言,`M2_ASSETS` OK);
|
||
`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` /
|
||
`combat_fx_test` / `player_motion_test` → PASS(7/7)。
|
||
验收:纯离线,合成 + 4 个真实 PC .msa(含真正的双命中窗)双覆盖。
|
||
未接:net_play.gd 还没消费 hit_windows(命中判定 = 修改 4,增量 83)。
|
||
下一批次(增量 83):§3.5 修改 4 —— `net_play.gd` 每帧按动作时间落在某个 `hit_window`
|
||
[start,end] 内时对附近实体做几何判定(`AttackingBone` 世界位 + `weapon_length` +
|
||
`hit_windows[].samples` 扫掠球),`OnHit` → `client.attack(uSkill, victimVID)`(去重按
|
||
`hit_limit_count` / `invisible_time` 冷却),帧末把被击退者打包 `M2Client::sync_positions()`
|
||
(≤16)。之后武器种类 → `combo_motion_mode` 映射(增量 84)。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 修改 4(命中窗几何判定 → OnHit,§3.5 收官)
|
||
状态:🟡 部分完成(增量 83)——§3.5 修改 1-5 全落地;几何判定为近似留桩
|
||
参考:
|
||
REF/UserInterface/InstanceBaseBattle.cpp:413 CInstanceBase::AttackProcess
|
||
—— CanCheckAttacking() 为真时遍历全实例做 CheckAttacking()(先查双方 IsInSafe,
|
||
再 AttackingProcess);命中记 m_dwLastDmgActorVID
|
||
REF/GameLib/ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess
|
||
—— 距离粗筛(HUGE 500² / 普通 300²);遍历 pad->HitDataContainer 各 THitData;
|
||
m_HitDataMap[&hitData] 查重(MOTION_TYPE_COMBO 或 cooldown 未到即 continue);
|
||
mapHitPosition 取 (motiontime-elapsed, motiontime] 的采样构造动态球(c_fAttackRadius=20),
|
||
按 GetRotation() 旋到世界;vs victim m_DefendingPointInstanceList 的动态圆柱;
|
||
命中数上限:NORMAL/COMBO 16、SKILL pad->iHitLimitCount;
|
||
命中 → __ProcessDataAttackSuccess → OnHit(uSkill = m_kCurMotNode.uSkill)
|
||
REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit
|
||
—— SetTarget(vid, FALSE); if(isSendPacket) SendAttackPacket(uSkill, vid);
|
||
if(!victim.IsPushing()) return; ... TEMP_Push + m_kVctkVictim.push_back
|
||
REF/UserInterface/PythonPlayerEventHandler.cpp:194 FlushVictimList
|
||
—— m_kVctkVictim 非空 → 一个 TPacketCGSyncPosition + 每个 element(SYNC_POSITION_COUNT_LIMIT=16)
|
||
REF/GameLib/GameType.h:52 NRaceData::EMotionType { NONE=0, NORMAL=1, COMBO=2, SKILL=3 }
|
||
改动:
|
||
project/net_play.gd ——
|
||
· 新常量 MOTION_TYPE_{NONE,NORMAL,COMBO,SKILL}、HIT_ATTACK_RADIUS_CM=20、
|
||
HIT_VICTIM_RADIUS_CM=45、HIT_FRONT_COS=0.5、SYNC_POSITION_LIMIT=16
|
||
· 新状态 _swing_start_t / _swing_skill / _hit_windows / _hit_motion_type /
|
||
_hit_limit_count / _hit_invisible_time / _hit_dedup{wi:{vid:cooldown}} / _victim_flush[]
|
||
· _begin_hit_windows():挥击起手从 get_motion_data() 缓存 hit_windows / motion_type /
|
||
hit_limit_count / invisible_time,清 _hit_dedup,置 _swing_start_t = _now()
|
||
· _emit_swing():不再无条件 client.attack;调 _begin_hit_windows() 后只有
|
||
_hit_windows 为空(headless / 缺资源)才立即 client.attack(_swing_skill, _vid_reserved)
|
||
· _attack_process(dt):t=_now()-_swing_start_t;t-dt > 所有窗 end → 收起 _hit_windows;
|
||
主角 in_safe → 跳过;遍历 client.get_entities() 可攻击 / 未死 / 双方非安全区实体,
|
||
net_world.node_for(vid) 取世界位;窗 [ws,we] 与 [t-dt,t] 相交 → _hit_deduped() /
|
||
_hit_geometry() / _register_hit() → client.attack(_swing_skill, vid);
|
||
被击退目标(knock_down/stunned)→ _victim_flush.append({vid,x,y})
|
||
· _hit_deduped(wi,vid):已命中过 && (COMBO || cooldown 未到) → true
|
||
· _register_hit(wi,vid):记 cooldown=_now()+invisible_time;非首次且 m.size() > cap
|
||
(NORMAL/COMBO 16、else hit_limit_count)→ false(本帧跳过)
|
||
· _hit_geometry():reach = max(WeaponLength, 窗内 (t-dt,t] 采样最大 hypot(pos.x,pos.y));
|
||
缺则 CLICK_DIST_DEFAULT_CM;命中 = 目标水平距 ≤ (reach+20+45)cm 且在正面 ±60° 弧内
|
||
· _clear_combo():附带清 _hit_windows / _hit_dedup
|
||
· _process:_combo_process() 后插 _attack_process(dt);帧末 _flush_victim_list()
|
||
· _flush_victim_list():_victim_flush 非空 → client.sync_positions(_victim_flush.slice(0,16))
|
||
→ clear(slice 返回新数组:兼做 16 上限截断 + 不把马上 clear() 的引用传给下游)
|
||
project/combat_fx_test.gd ——
|
||
· FakeClient 加 get_entities()(ents.values())/ sync_positions()(记 synced)
|
||
· 新 class FakeNetWorld { node_for(vid) }
|
||
· combo 段 + mod-4 段各加一次 `await process_frame`:SceneTree 脚本里 add_child 到 root
|
||
的节点要等一帧才 is_inside_tree(),几何判定读 global_position 故需入树
|
||
· 新 6 段:有命中窗 → 挥击不发 CG_ATTACK;推进到窗内 → OnHit client.attack([0,2000]) +
|
||
帧末 CG_SYNC_POSITION;COMBO 同窗同目标只一次;目标转身后 → 出正面弧不命中;
|
||
动作越过所有窗 → _hit_windows 收起
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7)。
|
||
验收:纯离线。几何判定是 reach + 正面弧近似(非动态圆柱扫掠)——真实版需骨骼矩阵 +
|
||
defending sphere 数据,与 §3.1 actor 间碰撞一起留桩。未验证:真实服务端对
|
||
CG_ATTACK / CG_SYNC_POSITION 时序的接受(联机 E2E)。
|
||
下一批次(增量 84):武器种类 → `combo_motion_mode` 映射(当前 net_play.gd
|
||
`combo_motion_mode` 恒 MOTION_MODE_GENERAL;应按装备武器 subtype 映射到
|
||
GENERAL / BOW / DUAL / 骑乘等 CRaceMotionData::EMode)。之后 §3.6 箭矢 FLY 事件。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 修改 6(武器种类 → combo_motion_mode)
|
||
状态:🟡 部分完成(增量 84)——§3.5 修改 1-6 全落地;弓箭 __GetBowRange() 留给增量 85+
|
||
参考:
|
||
REF/UserInterface/InstanceBase.cpp:2851 CInstanceBase::RefreshState
|
||
—— dwPartItemID = GetPartItemID(PART_WEAPON);byItemType = GetType(),
|
||
bySubType = GetWeaponType()(= m_ItemTable.bSubType)
|
||
IsPoly() → MODE_GENERAL
|
||
IsWearingDress() → MODE_WEDDING_DRESS
|
||
IsHoldingPickAxe() → 骑乘 MODE_HORSE / 否则 MODE_GENERAL
|
||
ITEM_TYPE_ROD → 骑乘 MODE_HORSE / 否则 MODE_FISHING
|
||
m_kHorse.IsMounting() → switch(bySubType) SWORD/TWO_HANDED/DAGGER/FAN/BELL/BOW
|
||
→ MODE_HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_HORSE_{FAN,BELL,BOW}
|
||
/ default MODE_HORSE
|
||
else → switch(bySubType) SWORD/TWO_HANDED/DAGGER/BOW/FAN/BELL
|
||
→ MODE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_BOW / MODE_FAN / MODE_BELL
|
||
/ ARROW,default MODE_GENERAL
|
||
REF/GameLib/RaceMotionData.h:30 CRaceMotionData::EMode
|
||
—— RESERVED=0, GENERAL=1, ONEHAND_SWORD=2, TWOHAND_SWORD=3, DUALHAND_SWORD=4,
|
||
BOW=5, FAN=6, BELL=7, FISHING=8, HORSE=9, HORSE_ONEHAND=10, HORSE_TWOHAND=11,
|
||
HORSE_DUALHAND=12, HORSE_BOW=13, HORSE_FAN=14, HORSE_BELL=15, WEDDING_DRESS=16
|
||
REF/GameLib/ItemData.h:22 EItemType(WEAPON=1, ROD=13, PICK=24)
|
||
REF/GameLib/ItemData.h:63 EWeaponSubTypes
|
||
—— SWORD=0, DAGGER=1, BOW=2, TWO_HANDED=3, BELL=4, FAN=5, ARROW=6
|
||
改动:
|
||
project/net_play.gd ——
|
||
· 补全 MOTION_MODE_*(EMode 全 17 项)+ ITEM_TYPE_WEAPON/ROD/PICK +
|
||
WEAPON_SUB_{SWORD,DAGGER,BOW,TWO_HANDED,BELL,FAN,ARROW} + WEAR_WEAPON=4 / PART_WEAPON=1
|
||
· static motion_mode_for(item_type, weapon_sub, mounted, poly, dress):逐分支复刻
|
||
RefreshState 的 SetMotionMode(顺序 Poly → 婚纱 → 镐 → 钓竿 → 骑乘 switch → else switch)
|
||
· _equipped_weapon_vnum():主角 parts[WEAPON] 非 0 优先,否则 get_equipment()[WEAR_WEAPON].vnum
|
||
· _refresh_motion_mode():mounted = mount_vnum!=0 || mounting;poly = polymorph||poly_vnum;
|
||
dress = wedding_dress;vnum>0 && proto → proto.item(vnum).{type,sub_type};
|
||
→ set_combo_motion_mode(motion_mode_for(...))(换模式内部 _clear_combo())
|
||
· _on_inv_changed_mode(window,_cell):window==2(EQUIPMENT) → _refresh_motion_mode()
|
||
· setup():连 inventory_changed → _on_inv_changed_mode;连 mount_changed → (v==主角) _refresh_motion_mode
|
||
· _on_entity_info() 末尾调 _refresh_motion_mode()(主角设定 / GC_CHARACTER_UPDATE parts 变)
|
||
project/combat_fx_test.gd ——
|
||
· 新 class FakeProto { item(vnum) -> {type,sub_type} }
|
||
· 15 段 motion_mode_for 断言(sword/two-handed/dagger/bow/fan/bell/arrow、rod±骑乘、
|
||
骑乘 sword/bow/arrow、poly、婚纱覆盖)
|
||
· 2 段端到端:proto 桩 + fc.ents[1000].parts=[0,19,0,0] → _refresh_motion_mode()
|
||
→ combo_motion_mode == DUALHAND_SWORD 且 _combo_index 归零;再 mount_vnum=20101
|
||
→ HORSE_DUALHAND_SWORD
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7),
|
||
`equip_model_test` 亦 PASS。
|
||
验收:纯离线。映射表 1:1 对齐 RefreshState;换武器 / 上下马 / 换装 / 时装 parts 变都能触发。
|
||
未接:弓箭 `__GetBowRange()`(弓模式命中 / 点击距离,§3.5 / §3.6 一起做);婚纱 dress 标志
|
||
本工程实体暂无(读 e.wedding_dress,缺则 false)。
|
||
下一批次(增量 85):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动
|
||
`OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉固定 1.5s 定时器;
|
||
连带弓模式 `__GetBowRange()` 命中 / 点击距离。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.5 修改 7(弓箭 __GetBowRange() 可点击 / 攻击距离)
|
||
状态:✅ 完成(增量 85)——§3.5 修改 1-7 全落地;§3.6 箭矢 FLY 事件留给增量 86
|
||
(增量 84 记录里「下一批次」原写「§3.6 + 连带弓模式距离」,这里把弓模式距离单独切成增量 85,
|
||
更小更好回归;FLY 事件顺延增量 86。)
|
||
参考:
|
||
REF/UserInterface/InstanceBaseBattle.cpp:294 CInstanceBase::NEW_IsClickableDistanceDestInstance
|
||
fDistance = 150.0f;
|
||
if (IsBowMode()) fDistance = __GetBowRange();
|
||
if (dst.IsNPC()) fDistance = 500.0f;
|
||
if (dst.IsResource()) fDistance = 100.0f;
|
||
return m_GraphicThingInstance.IsClickableDistanceDestInstance(dst..., fDistance);
|
||
—— 后写的 if 覆盖前面:NPC / 采集物距离优先于弓箭距离。
|
||
REF/UserInterface/InstanceBase.cpp:694 CInstanceBase::__GetBowRange
|
||
float fRange = 2500.0f - 100.0f;
|
||
if (__IsMainInstance())
|
||
fRange += float(IAbstractPlayer::GetSingleton().GetStatus(POINT_BOW_DISTANCE));
|
||
return fRange;
|
||
—— 注意参考端把 POINT_BOW_DISTANCE 按 cm 直加,不 ×100(PythonPlayerSkill.cpp:393
|
||
另一处技能射程才 ×100);本工程照抄不 ×100。
|
||
REF/GameLib/ActorInstance.cpp:258 CActorInstance::IsBowMode
|
||
return GetMotionMode()==MODE_BOW || GetMotionMode()==MODE_HORSE_BOW;
|
||
REF/UserInterface/Packet.h:1550 EPointTypes:POINT_BOW_DISTANCE = 34
|
||
改动:
|
||
extension/src/net/wire.h —— enum 补 POINT_BOW_DISTANCE = 34
|
||
extension/src/net/m2_client.cpp —— get_points() 增 d["bow_distance"] = p.v[POINT_BOW_DISTANCE]
|
||
project/net_play.gd ——
|
||
· 常量 BOW_RANGE_BASE_CM := 2500.0 - 100.0(保留字面式,对齐参考)
|
||
· 成员 _bow_distance_bonus := 0.0(GetStatus(POINT_BOW_DISTANCE))
|
||
· _bow_range_cm() -> BOW_RANGE_BASE_CM + _bow_distance_bonus(= __GetBowRange)
|
||
· _clickable_distance(te):cm=150 → _is_bow_mode() 则 cm=_bow_range_cm() →
|
||
match _entity_kind:NPC 500 / 采集物 100(覆盖弓箭距离);末尾 /CM 转米
|
||
· _on_points(p):_bow_distance_bonus = float(p.get("bow_distance", 0))
|
||
(_is_bow_mode() 增量 84 已有:combo_motion_mode ∈ {MODE_BOW, MODE_HORSE_BOW})
|
||
project/combat_fx_test.gd —— §3.5 段加 8 段断言:
|
||
非 bow 玩家/怪 reach=150 / bow 基础 _bow_range_cm()=2400 / bow 目标 reach=__GetBowRange /
|
||
NPC 覆盖=500 / 采集物覆盖=100 / HORSE_BOW 也算 bow / +300 加成叠加 / _on_points 读 bow_distance
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7),
|
||
`equip_model_test` 亦 PASS。
|
||
验收:纯离线。距离分支 + __GetBowRange 公式 1:1 对齐参考端。未接:POINT_BOW_DISTANCE 的真服
|
||
下发路径未联机核验(`mut_set_point` 已能把任意 type 存进 v[],get_points 已透出);几何判定
|
||
仍是 reach + 正面弧近似(随 §3.1 actor 碰撞升级)。
|
||
下一批次(增量 86):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动
|
||
`OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉 `game_scene.gd` 约 1.5s
|
||
定时器兜底(`PythonPlayerEventHandler.cpp:235`)。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.6 弓箭 FLY 事件(OnSetFlyTarget / OnShoot)
|
||
状态:✅ 完成(增量 86)——§3.6 修改 1-3 全落地
|
||
参考:
|
||
REF/UserInterface/PythonPlayerEventHandler.cpp:235
|
||
CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget()
|
||
→ SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING
|
||
CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex)
|
||
→ SendShootPacket(dwSkillIndex) // CG_SHOOT
|
||
REF/GameLib/ActorInstanceMotion.cpp:372 CActorInstance::__SetMotion 尾
|
||
if (m_pFlyEventHandler && __IsNeedFlyTargetMotion()) m_pFlyEventHandler->OnSetFlyTarget();
|
||
—— OnSetFlyTarget 在弓动作「设定」时触发一次,不是 .msa motion event。
|
||
REF/GameLib/ActorInstanceMotionEvent.cpp:255 CActorInstance::ProcessMotionEventFly
|
||
if (iType != MOTION_EVENT_TYPE_FLY) return;
|
||
if (m_kFlyTarget.IsValidTarget()) {
|
||
CFlyingManager::CreateFlyingInstanceFlyTarget(...); // 飞行物(本工程未做)
|
||
if (m_pFlyEventHandler) m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill);
|
||
}
|
||
—— OnShoot 在 .msa 的 FLY 帧触发,uSkill = 本次挥击技能号(普攻 0)。
|
||
REF/GameLib/ActorInstanceFly.cpp:4 CActorInstance::OnGetFlyTargetPosition
|
||
v3Center = m_v3Center;(m_fRadius<=0 先 BuildBoundingSphere)
|
||
D3DXVec3TransformCoord(&v3Center, &v3Center, &GetTransform()); return v3Center;
|
||
—— 目标包围球中心的世界坐标;平面分量与脚点一致,CG_FLY_TARGETING 只带平面坐标。
|
||
REF/UserInterface/PythonPlayerInput.cpp:358 __OnPressActor
|
||
rkInstMain.NEW_AttackToDestInstanceDirection(rkInstVictim,
|
||
rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&rkInstMain, &rkInstVictim));
|
||
改动:
|
||
project/net_play.gd ——
|
||
· 新信号 bow_shot_fired(skill:int)
|
||
· _emit_swing() 头部:_is_bow_mode() 时 _send_fly_target() + bow_shot_fired.emit(_swing_skill)
|
||
· _send_fly_target():vid = _target_vid ?: _vid_reserved;net_world.node_for(vid) →
|
||
MapCoord.to_server_cm(global_position) → client.add_fly_targeting(vid, x, y)
|
||
project/game_scene.gd ——
|
||
· 删 _pending_shoot_skill:int + get_tree().create_timer(1.5) 兜底
|
||
· 新 _pending_shots:Array[int] + const MAX_PENDING_SHOTS := 8 + _queue_shot(skill)(满丢最旧)
|
||
· quickbar.skill_activated 的 is_ranged 分支改 _queue_shot(sid)
|
||
· net_play.bow_shot_fired.connect(_queue_shot)
|
||
· _on_local_motion_event() type 6:not _pending_shots.is_empty() → client.shoot(_pending_shots.pop_front())
|
||
project/combat_fx_test.gd —— FakeClient 加 add_fly_targeting/shoot + fly_targets/shots;
|
||
§3.6 段 6 断言(非弓无 FLY / 弓挥击发 CG_FLY_TARGETING(vid) / 坐标=目标 server-XY /
|
||
bow_shot_fired(0) / 无目标不发)
|
||
project/eterngrn_polish_test.gd —— FLY 段改 FIFO 连射断言(连压 77/78 → 依次 pop → 队空不发)
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);9 个 GDScript 回归 → PASS
|
||
(combat_fx / eterngrn_polish / gamescene / netplay / player_move / player_motion /
|
||
netbridge / p2b / equip_model)。
|
||
验收:纯离线。OnSetFlyTarget(动作起手)/ OnShoot(.msa FLY 帧)两个时刻分开,FIFO = AutoClear +
|
||
并发多发,1:1 对齐参考端。未接:飞行物实体本身(CFlyingManager / FlyingInstance 的生成、
|
||
轨迹、命中 OnShootDamage)、__CanShot 弓箭合法性(PythonPlayerInput.cpp:343)、真服
|
||
CG_FLY_TARGETING 的 z 分量(当前只发平面坐标)。
|
||
下一批次(增量 87):§3.8 技能合法性 —— player_skill.gd 三层校验
|
||
(ClickSkillSlot 冷却 / MP / 前置,PythonPlayerSkill.cpp:108)。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.8 TSkillData 判定位解析(修改 2)
|
||
状态:🟡 部分完成(增量 87)——§3.8 修改 2 落地,修改 1/3 待续
|
||
参考:
|
||
REF/UserInterface/PythonSkill.h:92-115 enum { SKILL_ATTRIBUTE_NEED_TARGET=(1<<0) … SEARCH_TARGET=(1<<23) }
|
||
REF/UserInterface/PythonSkill.h:120-128 enum { SKILL_NEED_WEAPON_SWORD=(1<<CItemData::WEAPON_SWORD) … }
|
||
REF/GameLib/ItemData.h:63-75 enum EWeaponSubTypes { WEAPON_SWORD=0,DAGGER,BOW,TWO_HANDED,BELL,FAN,ARROW, NUM_TYPES=7, NONE=8 }
|
||
REF/UserInterface/PythonSkill.cpp:280 RegisterSkillDesc —— SplitLineByTab;ATTRIBUTE 列 SplitLine("|")
|
||
每记号查 m_SkillAttributeIndexMap → dwSkillAttribute |= bit;WEAPON 列同理 → dwNeedWeapon;查不到就跳过
|
||
REF/UserInterface/PythonSkill.cpp:909-943 m_SkillTypeIndexMap(none/active/support/guild/horse + 职业名→ACTIVE)
|
||
/ m_SkillAttributeIndexMap(记号名→位)/ m_SkillNeedWeaponIndexMap(SWORD…EMPTY_HAND,DOUBLE_SWORD→DAGGER 位)
|
||
REF/UserInterface/PythonSkill.cpp:1143 CanUseWeaponType(t):!(attr & WEAPON_LIMITATION) → TRUE;else dwNeedWeapon & (1<<t)
|
||
REF/UserInterface/PythonSkill.cpp:1407 IsCanUseSkill():attr & PASSIVE → false;else true
|
||
(POC 无 C++ skill proto:extension/src/proto 只有 item/mob;技能数据全在 GDScript
|
||
project/ui/skill_table.gd 读 locale/locale/<lang>/skilldesc.txt TSV。故本修改在该文件内落地。)
|
||
改动:
|
||
project/ui/skill_table.gd ——
|
||
· const ATTR{}(24 个 SKILL_ATTRIBUTE_* 记号名→位)、WEAPON_* 序号常量、
|
||
const NEED_WEAPON{}(9 个记号名→1<<WEAPON_*,DOUBLE_SWORD 复用 DAGGER)、
|
||
SKILL_TYPE_* 常量 + _SKILL_TYPE_MAP{}
|
||
· static _fold_bits(tokens, name_to_bit):`|`/`,`/空格 分隔、大小写宽容、未知记号忽略 → 位掩码
|
||
· load_file():entry 增 attr_bits = _fold_bits(第11列, ATTR)、
|
||
need_weapon_bits = _fold_bits(第12列, NEED_WEAPON)、skill_type = _SKILL_TYPE_MAP[job.lower()]
|
||
· 谓词(逐条对齐 PythonSkill.cpp):attr_bits / need_weapon_bits / _has_attr、
|
||
is_can_use_skill / is_standing / is_need_target / is_need_corpse / is_auto_search_target /
|
||
can_change_direction / is_horse_skill / is_moving_skill / is_melee / is_charge_skill /
|
||
is_use_hp / is_time_increase / is_need_empty_bottle / is_need_poison_bottle / can_use_for_me /
|
||
is_only_for_alliance / can_use_if_not_enough / is_only_for_guild_war / is_fan_range /
|
||
is_circle_range / has_weapon_limitation / can_use_weapon_type(id,t) / is_need_bow /
|
||
skill_type_of / is_guild_skill;旧 is_attack/is_passive/is_toggle/is_ranged/can_level_up 改查位
|
||
project/skill_test.gd —— §3.8 段 +34 断言(skill 1/16/18/46/63/106/109/137/138/151/152
|
||
的位面判定 + can_use_weapon_type 有/无 WEAPON_LIMITATION 两路 + _fold_bits 分隔/大小写宽容
|
||
+ DOUBLE_SWORD 复用 DAGGER 位)
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);9 个 GDScript 回归 → PASS
|
||
(skill / skill_fx / guild_war_skill / gamescene / netplay / player_move / combat_fx /
|
||
eterngrn_polish + skill_test 扩展)。
|
||
验收:纯离线。位折叠 + 谓词 1:1 对齐 CPythonSkill。架构差异:参考端说的
|
||
“extension/src/proto 侧补解析”改在 skill_table.gd 内(POC 技能数据只在 GDScript)。
|
||
未接:修改 1(player_skill.gd 三层校验移植)、修改 3(M2Client::use_skill() 带符号 + 范围校验)。
|
||
下一批次(增量 88):§3.8 修改 1 —— 新建 project/player_skill.gd,逐行移植
|
||
ClickSkillSlot (a) / __CanUseSkill (b) / __CheckSkillUsable (c) / __UseSkill (d)
|
||
(PythonPlayerSkill.cpp:108/439/166/460),命名常量 SKILL_MAX_NUM / SKILL_TYPE_GUILD /
|
||
骑乘术 109 / 空瓶 27995 / 毒瓶 27996,接进技能施放路径。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.8 修改 1(player_skill.gd 三层合法性校验)
|
||
状态:🟡 部分完成(增量 88)——§3.8 修改 1 落地,修改 3 待续
|
||
参考:
|
||
REF/UserInterface/PythonPlayerSkill.cpp:106 ClickSkillSlot(slot) —— 层 (a) 入口分派
|
||
REF/UserInterface/PythonPlayerSkill.cpp:439 __CanUseSkill() —— 层 (b) 全局可用性
|
||
(硬编码骑乘术 vnum 109:IsMountingHorse() && GetSkillGrade(109)<1 && GetSkillLevel(109)<20 → false)
|
||
REF/UserInterface/PythonPlayerSkill.cpp:166 __CheckSkillUsable(slot) —— 层 (c) 逐项前置
|
||
顺序:骑乘⇄马术(NOT_HORSE_SKILL / HAVE_TO_RIDE)→ 安全区攻击技(IN_SAFE)→ !IsCanUseSkill()(被动)
|
||
→ 空瓶 27995(NEED_EMPTY_BOTTLE)→ 毒瓶 27996(NEED_POISON_BOTTLE)→ IsFishingMode()(REMOVE_FISHING_ROD)
|
||
→ m_sysIsLevelLimit && iLevel<=0(NOT_YET_LEARN)→ !CanUseWeaponType()(NOT_MATCHABLE_WEAPON)
|
||
→ !IsHorseSkill(): __CheckShortArrow + IsNeedBow && !__HasEnoughArrow(EMPTY_ARROW)
|
||
→ dash 分支:__CheckDashAffect && !IsChargeSkill && __CheckRestSkillCoolTime → WAIT_COOLTIME
|
||
否则 __CheckRestSkillCoolTime → WAIT_COOLTIME、__CheckShortLife → NOT_ENOUGH_HP、__CheckShortMana → NOT_ENOUGH_SP
|
||
REF/UserInterface/PythonPlayerSkill.cpp:460 __UseSkill(slot) —— 层 (d) 施放
|
||
IsOpenPrivateShop() → 静默 return true;__CheckSpecialSkill(idx) → true(钓鱼 123 / 连击 122);
|
||
IsToggleSkill() && IsSkillActive(slot) → SendUseSkillPacket + return false;IsUsingSkill() → false;
|
||
目标解析(NEED_TARGET || CanChangeDirection || IsAutoSearchTarget):520-650 —— 同盟 / 安全区 / 可攻击 / 尸体分支
|
||
→ CANNOT_USE_SELF / ONLY_FOR_ALLIANCE / CANNOT_ATTACK_ENEMY_IN_SAFE_AREA / CANNOT_ATTACK / ONLY_FOR_CORPSE / NEED_TARGET
|
||
REF/UserInterface/PythonPlayerModule.cpp:6-7 c_iSkillIndex_Combo=122 / c_iSkillIndex_Fishing=123
|
||
REF/UserInterface/Packet.h:2041 SKILL_MAX_NUM=255
|
||
改动:
|
||
project/player_skill.gd(新建)—— extends RefCounted,纯校验器(不发包、不驱动动画):
|
||
· 返回 {"ok": bool, "code": String, "target_vid": int}
|
||
· 命名常量 SKILL_MAX_NUM=255 / SKILL_TYPE_GUILD=3 / RIDING_SKILL_INDEX=109 /
|
||
RIDING_SKILL_MASTER_LEVEL=20 / ITEM_EMPTY_BOTTLE=27995 / ITEM_POISON_BOTTLE=27996 /
|
||
SKILL_INDEX_COMBO=122 / SKILL_INDEX_FISHING=123
|
||
· SILENT_CODES[](13 个无文案码:空串 / OK / PASSIVE / BAD_SLOT / NO_SKILL_DATA / GUILD_ROUTED /
|
||
TOGGLE_OFF / SPECIAL_SKILL / PRIVATE_SHOP / RESERVED / NO_RIDING_SKILL / CANNOT_ACT / ALREADY_CASTING)
|
||
+ static is_silent_code(code)
|
||
· setup(m2client, skill_table);运行期状态尽量走 client(get_main_vid / get_entity /
|
||
get_skills / get_target / get_inventory),其余经公有 context 字段由调用方每次刷新
|
||
· click_skill_slot() 层 (a) / can_use_skill() 层 (b) / check_skill_usable() 层 (c) /
|
||
use_skill() 层 (d) / _resolve_target() 目标矩阵 / _check_special_skill()
|
||
· __CheckShortMana / __CheckShortLife 打桩恒 false —— POC 无 .msk NeedSP/NeedHP 公式解析
|
||
(skill_table 只解析 CoolTimeFormula / MaxLevel),留给增量 89
|
||
project/net_play.gd —— +skill_context():观察 / 骑乘 / 安全区 / 钓鱼 / 施法中 / 可行动 /
|
||
dash / weapon_sub_type(经 _equipped_weapon_vnum + proto.item().sub_type,非武器→-1)/ arrow_count 快照
|
||
project/ui/quickbar.gd —— preload player_skill.gd;signal skill_rejected(skill_id, code);
|
||
var net_play / _skill_gate;setup() 内实例化 gate;activate() 技能分支施放前跑三层,
|
||
TOGGLE_OFF → 走 use_skill 关闭 + skill_activated;非静默码 → skill_rejected.emit + return;
|
||
gate 返回 target_vid 非 0 → 覆盖 target_vid
|
||
project/game_scene.gd —— quickbar.net_play = net_play;skill_rejected → net_play.cannot_act.emit(code)
|
||
(HUD 走同一条 OnCannotUseSkill 文案通道)
|
||
project/skill_test.gd —— FakeClient get_entity 返回 {"race":0,"kind":2}(选中目标判可攻击)+ get_inventory()
|
||
project/player_skill_test.gd(新建)—— extends SceneTree,读真 skilldesc.txt,
|
||
覆盖层 (a)(b)(c)(d) 每条分支 + 目标矩阵 + is_silent_code
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);12 个 GDScript 回归 → PASS
|
||
(skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
|
||
netbridge / p2b / equip_model / eterngrn_polish + player_skill_test 新建)。
|
||
验收:纯离线。三层顺序 1:1 对齐 PythonPlayerSkill.cpp。架构差异:校验器读 client 运行期状态、
|
||
其余状态经 net_play.skill_context() 注入,保持解耦可单测。
|
||
未接:__CheckShortMana / __CheckShortLife(需 .msk NeedSP/NeedHP 公式解析)、
|
||
修改 3(M2Client::use_skill() 带符号 + 范围校验,§3.9)。
|
||
下一批次(增量 89):§3.8 修改 3 —— M2Client::use_skill() 及相关公有 API 形参带符号 + 范围校验;
|
||
同时补 __CheckShortMana / __CheckShortLife(.msk NeedSP/NeedHP 公式解析)。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.8 修改 3(use_skill 边界收紧 + 短蓝短血校验落地)
|
||
状态:✅ 完成(增量 89)——§3.8 三层校验(修改 1/2/3)全部落地
|
||
参考:
|
||
REF/UserInterface/PythonPlayerSkill.cpp:108 ClickSkillSlot —— slot >= SKILL_MAX_NUM → return
|
||
REF/UserInterface/Packet.h:2041 SKILL_MAX_NUM = 255
|
||
REF/UserInterface/PythonPlayerSkill.cpp:320 __CheckShortMana(fCur)
|
||
· c_iSkillIndex_Summon(131) 豁免 → false
|
||
· iNeedSP = GetNeedSP(fcurEfficientPercentage);icurSP = GetStatus(POINT_SP)
|
||
· !IsToggleSkill() && iNeedSP == 0 → chat "strange game data"; return true(坏数据拦截)
|
||
· CanUseIfNotEnough() → icurSP <= 0 → return true
|
||
· 否则 -1 != iNeedSP && iNeedSP > icurSP → return true;default false
|
||
REF/UserInterface/PythonPlayerSkill.cpp:354 __CheckShortLife(fCur)
|
||
· !IsUseHPSkill() → return false(放行)
|
||
· dwNeedHP = GetNeedSP(fcurEfficientPercentage)(HP 需求也读 NeedSP 公式,参考端怪癖)
|
||
· dwNeedHP <= GetStatus(POINT_HP) → false;否则 true
|
||
REF/GameLib/PythonSkill.cpp:1426 GetNeedSP(float fSkillPoint) —— strNeedSPFormula 空 → 0;否则 poly 求值
|
||
REF/GameLib/PythonSkill.cpp:1250 ProcessFormula —— 变量 "SkillPoint" / "k" 绑定传入 float
|
||
REF/UserInterface/PythonPlayer.cpp:1037 fcurEfficientPercentage = LocaleService_GetSkillPower(lv)/100
|
||
REF/EterLib/Locale.cpp:126 LocaleService_GetSkillPower —— SKILL_POWER_NUM=50;lv>=50 → 0;
|
||
INTERNATIONAL 表 {0,5,6,8,10,12, 14,16,18,20,22, 24,26,28,30,32, 34,36,38,40,50,
|
||
52,54,56,58,60, 63,66,69,72,82, 85,88,91,94,98, 102,106,110,115,125, 125}
|
||
REF/UserInterface/PythonPlayerModule.cpp:15 c_iSkillIndex_Summon = 131
|
||
改动:
|
||
extension/src/net/net_bounds.h —— +SKILL_MAX_NUM=255(注释引 Packet.h:2041 / PythonPlayerSkill.cpp:108)
|
||
+inline constexpr bool skill_index(int64_t v) { return v > 0 && v < SKILL_MAX_NUM; }
|
||
extension/src/net/m2_client.cpp —— use_skill(int skill_id, int target_vid)(形参增量 52 起即带符号)
|
||
护栏:skill_id <= 0 || !bounds::u32(skill_id) → !mtnet::bounds::skill_index(skill_id)
|
||
(target_vid 仍 >= 0 && bounds::u32;cast_skill() 的 motion_idx ∈ [0,0x7f] 不动)
|
||
extension/tests/net_bounds_test.cpp —— +4 CHECK(SKILL_MAX_NUM==255 / 端点 1、254 /
|
||
越界 0、255、-1 / 拒 0x7fffffff+1)
|
||
project/ui/skill_table.gd ——
|
||
· _load_cooltime → _load_msk(调用点 line 152),+解析 .msk NeedSPFormula → entry["need_sp_formula"]
|
||
· +const _SKILL_POWER[](Locale.cpp INTERNATIONAL 表,41 项)
|
||
· +_skill_point(level): level<0 || level>=50 → 0.0;否则 _SKILL_POWER[min(level,40)]/100.0
|
||
· +need_sp(id, level): 无 need_sp_formula → -1(POC 约定,参考返回 0,避免误挡);
|
||
否则 Expression.parse(formula, ["SkillPoint"]).execute([_skill_point(level)]) → int
|
||
· +need_hp(id, level) → 直接 return need_sp(id, level)(照抄 __CheckShortLife 复用 GetNeedSP)
|
||
· cooldown_of 的 SkillPoint 改走 _skill_point(level)(原为 level/max_level);max_level 仍解析
|
||
project/player_skill.gd ——
|
||
· +const SKILL_INDEX_SUMMON = 131
|
||
· +var cur_sp := -1 / var cur_hp := -1(-1 = 未知不判)
|
||
· _check_short_mana 实装:table 无 need_sp / summon 131 / cur_sp<0 → false;
|
||
!is_toggle && need_sp==0 → true;can_use_if_not_enough → cur_sp<=0;否则 need_sp!=-1 && need_sp>cur_sp
|
||
· _check_short_life 实装:!is_use_hp / cur_hp<0 / need_hp<0 → false;否则 need_hp > cur_hp
|
||
(命中点:check_skill_usable 非 dash else 支路,既有 :270-273 已转 NOT_ENOUGH_HP / NOT_ENOUGH_SP)
|
||
project/net_play.gd —— skill_context()(~line 618)+cur_hp / cur_sp(读 client.get_points() 的 hp / sp,缺则 -1)
|
||
project/player_skill_test.gd —— +“(c) §3.8 修改 3”段(_skill_point(20)==0.5、_skill_point(0)/(50)==0、
|
||
need_sp(1,5)==52、need_hp==need_sp、SP 不足→NOT_ENOUGH_SP、SP 充足→ok、SP 未知(-1)→ok、
|
||
无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 NOT_ENOUGH_HP);_reset 补 cur_hp=-1 / cur_sp=-1
|
||
测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);`ctest -R net.bounds` → 1/1;
|
||
13 个 GDScript 回归 → PASS(player_skill / skill / skill_fx / combat_fx / gamescene / netplay /
|
||
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。
|
||
eterngrn_polish_test 的 stderr "FAIL: motion_event: sound → play_at" 为该测试既有 async 竞态
|
||
(_init 未 await _run),非本批次触及,仍 exit 0 + PASS。probe 脚本确认 need_sp(1,5)=52 /
|
||
_skill_point(20)=0.5 / formula="40 + (100 * SkillPoint)"。
|
||
验收:纯离线。use_skill 上限对齐 ClickSkillSlot 的 SKILL_MAX_NUM 门。__CheckShortMana /
|
||
__CheckShortLife 顺序 1:1 对齐 PythonPlayerSkill.cpp:320/354。架构差异:need_sp 无公式返回 -1
|
||
(参考 0),HP/SP 由 net_play.skill_context() 经 client.get_points() 注入、-1 不判。
|
||
shipped skilldesc.txt 无 USE_HP 技能,故 _check_short_life 对现有技能实为放行(与参考等价)。
|
||
下一批次(增量 90):§3.10 技能升级门(CLIENT-GAP.md audit 10)—— 训练 / 读书 / 大师书
|
||
可升级判定 + M2Client 升级请求参数校验。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.10 技能升级门(加号按钮门控 + skill_up 边界收紧)
|
||
状态:✅ 完成(增量 90)
|
||
参考:
|
||
POC/assets/root/uicharacter.py:984 RefreshSkillPlusButtonList()
|
||
· isLoaded==0 → return; RefreshSkillPlusPointLabel()
|
||
· not __CanUseSkillNow() → return(技能组门)
|
||
· curSelectedSkillGroup==PAGE_HORSE ? __RefreshSkillPlusButton("HORSE") : __RefreshSkillPlusButton("ACTIVE")
|
||
· __RefreshSkillPlusButton("SUPPORT")
|
||
POC/assets/root/uicharacter.py:1270 __CanUseSkillNow() —— 0 == net.GetMainActorSkillGroup() → False;else True
|
||
POC/assets/root/uicharacter.py:937 __RefreshSkillPlusButton(name)
|
||
· HIDE_SUPPORT_SKILL_POINT and name=="SUPPORT" → return(本资源 HIDE_SUPPORT_SKILL_POINT=True, :21)
|
||
· HideAllSlotButton(); slotStatType = skillPageStatDict[name](SUPPORT→SKILL_SUPPORT 27 /
|
||
ACTIVE→SKILL_ACTIVE 28 / HORSE→SKILL_HORSE 113,:225-228); 0==slotStatType → return
|
||
· statPoint = player.GetStatus(slotStatType); if statPoint > 0: for 每槽:
|
||
skillIndex/skillGrade/skillLevel; skillIndex==0 → continue; skillGrade!=0 → continue
|
||
name=="HORSE": GetStatus(LEVEL) >= skill.GetSkillLevelLimit(idx) → (skillLevel<20 → ShowSlotButton)
|
||
else: (SUPPORT 白名单 SHOW_LIMIT_SUPPORT_SKILL_LIST=[] 空 → 无限制) CanShowPlusButton(idx,lv,pt) → ShowSlotButton
|
||
POC/assets/root/uicharacter.py:925 CanShowPlusButton(idx, lv, pt) —— idx==0 → False;
|
||
not skill.CanLevelUpSkill(idx, lv) → False; else True
|
||
REF/UserInterface/PythonSkill.cpp:1833 skillCanLevelUpSkill(idx, lv)
|
||
· lv >= GetMaxLevel() → FALSE
|
||
· isRequirement → 前置技能 / 前置属性检查(本 build 里 isRequirement 恒 FALSE,:1471,构造默认,
|
||
strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 全程无人填充 → 分支永不进入)
|
||
· dwSkillAttribute & SKILL_ATTRIBUTE_CANNOT_LEVEL_UP (1<<18) → FALSE
|
||
· else TRUE ⇒ 本 build 化简为 lv < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)
|
||
REF/UserInterface/PythonSkill.cpp:1741 skillGetSkillLevelLimit(idx) = c_pSkillData->byLevelLimit
|
||
· byLevelLimit 来源:skilldesc LEVEL_LIMIT 列(DESC_TOKEN_TYPE=26)或 .msk levellimit 记号
|
||
· 本 40250:shipped skilldesc.txt 仅 22 列(无 26 列)、无 .msk 带 levellimit → 恒 0 → 马术页等级前置门恒真
|
||
REF/UserInterface/PythonSkill.cpp:1392 SSkillData::GetMaxLevel() = byMaxLevel
|
||
REF/UserInterface/PythonSkill.cpp:1462 SSkillData 构造:byMaxLevel = 20(缺 .msk maxlevel / skilldesc MAX_LEVEL 时的默认)
|
||
REF/UserInterface/PythonSkill.cpp:280 RegisterSkillDesc —— LEVEL_LIMIT=col26 / MAX_LEVEL=col27 仅在 TokenVector.size() 够时解析
|
||
REF/UserInterface/Packet.h:1540-1541,1641 POINT_SUB_SKILL=27 / POINT_SKILL=28 / POINT_HORSE_SKILL=113
|
||
改动:
|
||
extension/src/net/wire.h —— EPointTypes +POINT_SUB_SKILL=27 / +POINT_SKILL=28 / +POINT_HORSE_SKILL=113
|
||
extension/src/net/m2_client.cpp ——
|
||
· get_points() +d["skill_active"]=p.v[POINT_SKILL] / +d["skill_support"]=p.v[POINT_SUB_SKILL]
|
||
/ +d["skill_horse"]=p.v[POINT_HORSE_SKILL](镜像增量 85 的 bow_distance)
|
||
· skill_up(int skill_id) 护栏 skill_id<=0 || skill_id>=bounds::POINT_COUNT
|
||
→ !mtnet::bounds::skill_index(skill_id)(= 0<v<255,与 use_skill §3.9 同一谓词)
|
||
project/ui/skill_table.gd ——
|
||
· load_file +解析 skilldesc LEVEL_LIMIT(t[26])/ MAX_LEVEL(t[27]),.msk maxlevel 优先;
|
||
shipped 22 列数据两列都缺,逻辑为兼容第三方数据
|
||
· +max_level_of(id) → entry.get("max_level", 20)
|
||
· +skill_level_limit(id) → entry.get("level_limit", 0)(本 build 恒 0)
|
||
· +can_level_up_skill(id, level) → has(id) && level < max_level_of(id) && !_has_attr("CANNOT_LEVEL_UP")
|
||
project/ui/skill_ui.gd ——
|
||
· +const HIDE_SUPPORT_SKILL_POINT=true(uicharacter.py:21)/ +const RIDING_SKILL_MAX_LEVEL=20
|
||
· refresh() 重写:算 page / can_use_now / stat_point / plv,对每个已建按钮切 up.visible
|
||
· +_can_use_skill_now() → client.has_method("get_skill_group") ? get_skill_group()!=0 : true
|
||
· +_stat_point_for(page) → get_points() 的 skill_support / skill_horse / skill_active(按页),无 get_points → 1
|
||
· +_player_level() → get_points().get("level", 1)
|
||
· +_can_show_plus(id, level, grade, page, can_use_now, stat_point, plv):
|
||
!can_use_now → false; SUPPORT && HIDE_SUPPORT_SKILL_POINT → false; stat_point<=0 → false;
|
||
grade!=0 → false; HORSE: plv >= skill_level_limit(id) ? level < 20 : false; else can_level_up_skill(id, level)
|
||
· 按钮创建仍在 _rebuild() 门于 can_level_up(id) && !is_passive(id)(静态属性),refresh() 只切 visible
|
||
project/skill_test.gd ——
|
||
· FakeClient +skill_group=1 / +points={level:30, skill_active:5, skill_support:3, skill_horse:2}
|
||
/ +get_skill_group() / +get_points()
|
||
· +§3.10 段:max_level_of(1)==30(samyeon.msk)/ (121)==20(构造默认)、skill_level_limit(1)==0、
|
||
can_level_up_skill(1,5/29)==true、(1,30/31)==false、(125,1)==false(CANNOT_LEVEL_UP)、(9999,1)==false;
|
||
主动页 skill 1 加号可见、skill 16 skillGrade!=0 隐、skill_group==0 隐、skill_active==0 隐、
|
||
辅助页整页隐、马术页 skill_horse>0 && level<20 可见 / skill_horse==0 隐
|
||
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21;13 个 GDScript 回归 → PASS
|
||
(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
|
||
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的
|
||
stderr "FAIL: motion_event" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
|
||
验收:纯离线。加号显隐三段(RefreshSkillPlusButtonList / __RefreshSkillPlusButton / CanShowPlusButton)
|
||
逐行对齐 uicharacter.py。CanLevelUpSkill 在本 40250 build 因前置字段无人填充化简为
|
||
「未满级 && !CANNOT_LEVEL_UP」,GetSkillLevelLimit 恒 0(无源数据列/记号)——两者均为
|
||
数据事实而非删减。skill_up 发包(/skillup <idx>)不变,升级结果由 GC_SKILL_LEVEL 经
|
||
skills_changed 驱动 refresh(),无本地伪造。
|
||
下一批次(增量 91):§3.4 __ChangeTargetToPickedInstance 拾取回退 + 扇形/圆形多目标
|
||
fly-targeting;随后 §3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.4 其余三种预约模式 + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting
|
||
状态:✅ 完成(增量 91)
|
||
参考:
|
||
REF/UserInterface/PythonPlayerInputMouse.cpp:283 NEW_RefreshMouseWalkingDirection()
|
||
· MODE_CLICK_ITEM (:290):GetGroundItemPosition(iid, &pos)
|
||
· 找不到 → __ClearReservedAction(); return
|
||
· GetDistance(main, pos) < 20.0f → SendCharacterStatePacket(pos, FUNC_WAIT);
|
||
SendClickItemPacket(iid); NEW_Stop(); __ClearReservedAction()
|
||
· else NEW_MoveToDestPixelPositionDirection(pos)
|
||
· MODE_CLICK_POSITION (:320):if (!isLock()) if (NEW_IsEmptyReservedDelayTime(elapsed))
|
||
{ NEW_MoveToDestPixelPositionDirection(m_kPPosReserved); __ClearReservedAction(); }
|
||
· MODE_USE_SKILL (:340):pkInstReserved = NEW_FindActorPtr(m_dwVIDReserved)
|
||
· NULL → __ClearReservedAction()
|
||
· fDistance = GetDistance(main, reserved); if (IS_HUGE_RACE(race)) fDistance -= 200.0f
|
||
· fDistance < float(m_dwSkillRangeReserved) → { SetTarget(vid); if (__UseSkill(slot)) __ClearReservedAction(); }
|
||
· else NEW_MoveToDestInstanceDirection(reserved)
|
||
REF/UserInterface/PythonPlayerInput.cpp:836 __ReserveClickGround(x,y,z) —— MODE_CLICK_POSITION;
|
||
m_kPPosReserved=(x,y,z); m_fReservedDelayTime=0.1f
|
||
REF/UserInterface/PythonPlayerInput.cpp:854 __ReserveUseSkill(slot,vid,range) —— MODE_USE_SKILL;
|
||
m_dwSkillSlotReserved=slot; m_dwVIDReserved=vid; m_dwSkillRangeReserved=range;
|
||
if (m_dwSkillRangeReserved > 100) m_dwSkillRangeReserved -= 10
|
||
REF/UserInterface/PythonPlayerInput.cpp:324 __OnPressActor —— 预约技能 + __CheckDashAffect 生效时
|
||
只 m_dwVIDReserved=vid、不 SetTarget / 不施法
|
||
REF/UserInterface/PythonPlayerInput.cpp:186 __ChangeTargetToPickedInstance(vid) → SetTarget(vid)
|
||
:130 SetTarget —— IsTargetableInstance(PC/怪) 才选中,否则 ClearTarget()
|
||
REF/UserInterface/PythonPlayerSkill.cpp:680-746 __UseSkill 内 if (dwTargetMaxCount>0 && pkInstTarget) 块
|
||
· dwTargetMaxCount = GetTargetCount(fEfficientPct); dwRange = __GetSkillTargetRange()
|
||
· dwTargetCount=1; FAN→NEW_GetInstanceVectorInFanRange / CIRCLE→NEW_GetInstanceVectorInCircleRange
|
||
· for each:count>=max break;target!=each && !each->IsDead() →
|
||
AddFlyTargetInstance(each); SendAddFlyTargetingPacket(each->VID, each->OnGetFlyTargetPosition()); count++
|
||
· while (count<max):NEW_GetRandomPositionInFanRange(target, &pos); pos.y=-pos.y;
|
||
AddFlyTargetPosition(pos); SendAddFlyTargetingPacket(0, pos); count++
|
||
· __ClearReservedAction()
|
||
REF/GameLib/InstanceBaseBattle.cpp:192 NEW_GetInstanceVectorInFanRange —— HALF_FAN_ROT_MIN=20 /
|
||
MAX=40 / MIN_DISTANCE=1000 / RPM=0.02;per char:fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN;
|
||
bearing(main→each) ∈ [bearing(main→target) ± fHalfFanRot] 才计入;按距离升序
|
||
:243 NEW_GetInstanceVectorInCircleRange —— 同上但无方位过滤
|
||
:124 NEW_GetRandomPositionInFanRange —— fRot = frandom(fDstDirRot-10, fDstDirRot+10);
|
||
rotate (0, 8000, 0) by -fRot around Z;dst = curPixelPos(main) + rotated
|
||
REF/UserInterface/PythonSkill.cpp:1361 GetTargetCount(fSkillPoint) —— 空公式 → 0;
|
||
else ProcessFormula(strTargetCountFormula, fSkillPoint)(变量名 SkillPoint)
|
||
REF/UserInterface/PythonPlayerSkill.cpp:391 __GetSkillTargetRange —— GetTargetRange() +
|
||
GetStatus(POINT_BOW_DISTANCE)*100(基础项 = .msk Range,弓距加成留调用方叠加)
|
||
改动:
|
||
project/net_play.gd ——
|
||
· +const HUGE_RACE_SIZE=3 / HUGE_RACE_RANK=4 / CLICK_ITEM_PICKUP_CM=20 / HUGE_RACE_DIST_FIX_CM=200 /
|
||
RESERVE_SKILL_RANGE_TRIM_CM=10 / FAN_HALF_ROT_MIN=20 / FAN_HALF_ROT_MAX=40 /
|
||
FAN_HALF_ROT_MIN_DISTANCE=1000 / FAN_RANDOM_JITTER_DEG=10 / FAN_RANDOM_REACH_CM=8000;
|
||
+enum FlyShape {SINGLE, FAN, CIRCLE}
|
||
· _refresh_reserved() 由「只 CLICK_ACTOR」改为 4-case match:CLICK_ITEM →
|
||
_reserve_process_click_item()(_ground_item_world_pos(iid) 消失→_clear_reserved();
|
||
global_position.distance_to(pos) < 20/CM → _send_state(FUNC_WAIT,0,pos)+client.pickup_item(iid)+
|
||
pc.frozen=false+_clear_reserved();else pc.walk_to(pos));CLICK_POSITION →
|
||
_reserve_process_click_position()(pc._can_translate()==false → return;_reserved_delay>0 → return;
|
||
pc.walk_to(_ppos_reserved)+_clear_reserved());USE_SKILL → _reserve_process_use_skill()
|
||
(client.get_entity(vid) 空 / net_world.node_for(vid) 空 → _clear_reserved();
|
||
dist_cm = global_position.distance_to(tnode)*CM;_is_huge_race(race) → dist_cm -= 200;
|
||
dist_cm < _skill_range_reserved → _target_vid!=vid 时 set_target+emit+_refresh_target_hud(),
|
||
再 _invoke_reserved_skill() 成功才 _clear_reserved();else pc.walk_to(tnode))
|
||
· _clear_reserved() 追加复位 _ppos_reserved=ZERO / _skill_range_reserved=0.0
|
||
· +use_skill_hook := Callable() / _invoke_reserved_skill()(slot<0 → false;hook.is_valid() →
|
||
bool(hook.call(slot));else false)
|
||
· +reserve_click_item(iid) / reserve_click_ground(pos)(_reserved_delay=0.1)/
|
||
reserve_use_skill(vid, slot, range_cm)(range_cm>100 → -10)/ pick_ground_item(iid)(清预约 +
|
||
_auto_attack_vid=0;distance*CM < CLICK_DIST_RESOURCE_CM → _send_state(FUNC_WAIT)+pickup_item+
|
||
解冻;else reserve_click_item(iid))
|
||
· +_ground_item_world_pos(iid)(扫 client.get_ground_items(),MapCoord.to_world(p))
|
||
· +_is_huge_race(race)(race<=0 / 无 proto.mob → false;m=proto.mob(race);空→false;
|
||
int(m.size)>=3 or int(m.rank)>=4)
|
||
· +_change_target_to_picked_instance(vid) → bool(e 空 / dead / _entity_kind ∉ [0,2] →
|
||
_target_vid=0 + client.set_target(0) + target_changed.emit(0) + hud.clear_target() + false;
|
||
else _target_vid=vid + client.set_target(vid) + emit + _refresh_target_hud() + true)
|
||
· +_bearing_deg(from, to) = rad_to_deg(atan2(d.x, -d.z)) / +_dir_from_bearing(deg) =
|
||
Vector3(sin(r), 0, -cos(r)) / +_entity_world_pos(vid)(node_for → global_position;
|
||
else 实体 pos 经 MapCoord.to_world;else null)
|
||
· +_instances_in_circle_range(range_cm)(扫 get_entities(),跳 vid 0 / _main_vid / 非 _is_attackable,
|
||
dist_m <= range_cm/CM,按 dist_m 升序 → [{vid, dist_m, pos}])
|
||
· +_instances_in_fan_range(range_cm, target_vid)(从圆形取,tp=_entity_world_pos(target_vid) 空→[];
|
||
dst_rot=deg_to_rad(_bearing_deg(main, tp));rpm=(40-20)/1000;per cand:
|
||
each_dist_cm=min(dist_m*CM, 1000);half_fan=(40-20)-rpm*each_dist_cm+20;
|
||
absf(angle_difference(dst_rot, deg_to_rad(_bearing_deg(main, cand.pos)))) <= deg_to_rad(half_fan))
|
||
· +_random_pos_in_fan_range(target_vid)(base_bearing = _bearing_deg(main, tp) 或 pc 朝向;
|
||
rot = base_bearing + randf_range(-10, 10);return main + _dir_from_bearing(rot) * (8000/CM))
|
||
· +send_fly_targeting(target_vid, range_cm, max_count, shape) → int(无 add_fly_targeting /
|
||
max_count<=1 / target_vid==0 / shape==SINGLE / 无 pc.player → 0;
|
||
reach_cm = range_cm + _bow_distance_bonus*100;cands = FAN ? _instances_in_fan_range : _instances_in_circle_range;
|
||
count=1 extras=0;loop:count>=max break,跳 vid==target_vid / dead,
|
||
client.add_fly_targeting(vid, int(sc.x), int(sc.y)) with sc=_server_xy(pos),count++ extras++;
|
||
while count<max:client.add_fly_targeting(0, _server_xy(_random_pos_in_fan_range(target_vid))),
|
||
count++ extras++;return extras)
|
||
· _on_pick() 重写:vid==0 / ==_main_vid → return;_reserved_mode==USE_SKILL && _dash_active →
|
||
_vid_reserved=vid; return;_entity_kind(e) ∈ [1,3,4] → client.click_npc(vid); return;
|
||
_attack_cd=0; _change_target_to_picked_instance(vid)
|
||
project/ui/skill_table.gd ——
|
||
· _load_msk 追加解析 .msk TargetCountFormula → entry.target_count_formula、Range →
|
||
entry.target_range(max(0, int(...)))
|
||
· +target_count(id, level)(formula 空 → 0;Expression.parse(formula, ["SkillPoint"]);
|
||
execute([_skill_point(level)]);失败 / 非数 → 0;int(floor(value)))
|
||
· +target_range(id) → int(entry.get("target_range", 0))
|
||
· +fly_shape(id)(is_fan_range → 1;is_circle_range → 2;else 0,对齐 NetPlay.FlyShape)
|
||
project/ui/quickbar.gd ——
|
||
· activate() 里 client.cast_skill 成功后、skill_activated.emit 前:target_vid!=0 && net_play &&
|
||
table && net_play.has_method("send_fly_targeting") && (is_fan_range || is_circle_range) →
|
||
net_play.send_fly_targeting(target_vid, float(table.target_range(id)),
|
||
table.target_count(id, _skill_level(id)), table.fly_shape(id))
|
||
· +activate_reserved(global_slot) → bool(越界 → false;换算 local;s.kind!="skill" / s.id==0 /
|
||
_now()<s.cd_end → false;activate(local);return true)
|
||
· +_skill_level(sid)(扫 client.get_skills(),匹配 id → level;否则 0)
|
||
project/game_scene.gd ——
|
||
· quickbar.setup(...) 后:if net_play: net_play.use_skill_hook =
|
||
func(slot: int) -> bool: return quickbar.activate_reserved(slot)
|
||
project/netplay_test.gd ——
|
||
· FakeClient +ground_items=[] / +skills=[] / calls +{"pickup": [], "fly": []};
|
||
+get_entities() / pickup_item(vid)(calls.pickup.append)/ add_fly_targeting(vid,x,y)(calls.fly.append)/
|
||
get_ground_items() / get_skills()
|
||
· +「5c」段:CLICK_ITEM 远→walk_to / 到手→pickup(777)+FUNC_WAIT+cleared / 物品消失→cleared;
|
||
CLICK_POSITION 延时门后 walk_to((9,0,-2))+cleared;USE_SKILL range>100 trim→790.0 /
|
||
射程外→walk_to / 射程内→use_skill_hook([5])+set_target(4100)+cleared / 目标丢失→cleared;
|
||
_change_target_to_picked_instance(NPC 5000)→_target_vid==0 & set_target==[0];
|
||
_change_target_to_picked_instance(怪 4100)→选中;send_fly_targeting FAN(4200 主目标 (0,0,-10)、
|
||
4201 (2,0,-10) & 4202 (-2,0,-10) 入扇、4203 (0,0,12) 背后排除;max 3 → 2 extras,
|
||
主目标不重发,两侧翼入选);CIRCLE(4203 计入,max 4 → 3 extras);
|
||
FAN 名额不足 max 6 → 5 extras 含 ≥1 个 vid=0 随机落点;max_count<=1 → 0;SINGLE → 0。
|
||
fly 测试前把实体 2000 & 4100 的 node+pos 移到 (9999,0,9999)(不 free,测试 #7-#11 仍引用),
|
||
4200-4203 经 mk lambda 造(race:101,测试内无 proto → _entity_kind 返 0 → 可攻击/可选中)
|
||
project/skill_test.gd ——
|
||
· +§3.4 段(仅在有真实 skilldesc 资源时跑):is_fan_range(47) / fly_shape(47)==1 /
|
||
target_range(47)==2500 / target_count(47,1)==2 / target_count(47,20)==5 /
|
||
is_fan_range(91) / target_range(91)==1800 / target_count(91,20)==0(bipabu 无公式)/
|
||
fly_shape(1)==0 / target_count(1,10)==0
|
||
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);13 个 GDScript 回归 → PASS
|
||
(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
|
||
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的
|
||
stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
|
||
验收:纯离线。三种预约模式逐行对齐 NEW_RefreshMouseWalkingDirection(含 CLICK_ITEM 的 20cm
|
||
拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 IS_HUGE_RACE −200cm 修正)。
|
||
扇形/圆形补目标:主目标 CG_FLY_TARGETING + CG_USE_SKILL 仍由 M2Client::use_skill 发,
|
||
send_fly_targeting 只补附加目标(CG_ADD_FLY_TARGETING,含 vid=0 随机落点),几何逐行对齐
|
||
NEW_GetInstanceVectorInFanRange / InCircleRange / GetRandomPositionInFanRange。
|
||
已文档化 seam:① IS_HUGE_RACE vnum 白名单未随源码发布 → proto.mob(race) size>=3 or rank>=4 近似;
|
||
② __GetSkillTargetRange 与 __GetBowRange 在原版就一个 ×100 一个不 ×100,两处各自照抄;
|
||
③ .msk Range token 用 _ascii_field 首个字节匹配,对含 AttackRange/SplashRange 前置的 .msk 会误配
|
||
(shipped gwangyeok/bipabu 已 tr 验证只有一处 case-sensitive Range,安全);
|
||
④ use_skill_hook 目前无 live producer(reserve_use_skill 无人调——为技能 click-cast 预留),
|
||
game_scene 把 hook 接到 quickbar.activate_reserved,测试直接注入 lambda;
|
||
activate_reserved 在 ready 槽位调 activate() 即返 true(对 `if (__UseSkill(slot))` 的保真差距)。
|
||
下一批次(增量 92):§3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中 + __CanShot 弓箭合法性。
|
||
```
|
||
|
||
```text
|
||
批次:2026-09-03 / W2 / §3.6 飞行物实体 CFlyingManager / CFlyingInstance 生成 / 命中 + __CanShot 弓箭合法性
|
||
状态:✅ 完成(增量 92)
|
||
参考:
|
||
REF/GameLib/FlyingObjectManager.h —— enum EIndexFlyType { NORMAL=0, FIRE_CRACKER=1, AUTO_FIRE=2 }
|
||
REF/GameLib/FlyingObjectManager.cpp
|
||
· CreateFlyingInstanceFlyTarget(dwID, v3Start, cr_FlyTarget, canAttack) —— CRC 查 CFlyingData*,
|
||
CFlyingInstance::New()->Create(...),push m_kLst_pkFlyInst,ID = m_IDCounter++
|
||
· Update() —— while 迭代:if (!pkFlyInst->Update()) { CFlyingInstance::Delete(); i = erase(i); } else ++i
|
||
· CreateIndexedFly(dwIndex, pStartActor, pEndActor) —— switch byType:
|
||
NORMAL → start=posStart 像素坐标,target=pEndActor,canAttack=false
|
||
FIRE_CRACKER → fRot=fmod(GetRotation()-90+360,360)+frandom(-30,30),fDist=frandom(2000,5000),
|
||
fxRand=fDist*cos(rad(fRot)),fyRand=fDist*sin(rad(fRot)),fzRand=frandom(1000,2500),
|
||
start z+200,target=posStart+(fxRand,fyRand,fzRand)
|
||
AUTO_FIRE → start z+100,target=pEndActor
|
||
· DeleteAllInstances() —— PythonNetworkStreamPhaseLoading.cpp:187 调
|
||
REF/GameLib/FlyingInstance.cpp
|
||
· Create(pData, startPos, target, canAttack) —— m_FlyTarget=target; m_canAttack=canAttack;
|
||
__SetDataPointer(pData, startPos); __SetTargetDirection(m_FlyTarget)
|
||
· __SetDataPointer —— m_v3Position=startPos; m_bAlive=true; m_fStartTime=now;
|
||
quat RotationYawPitchRoll(rad(m_fRollAngle-90), 0, rad(m_fConeAngle));
|
||
opt m_bSpreading 随机扩散; m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0);
|
||
m_v3Accel=pData->m_v3Accel; m_fRemainRange=pData->m_fRange; m_iPierceCount=pData->m_iPierceCount
|
||
· __SetTargetDirection —— v3TargetPos=target.GetFlyTargetPosition();
|
||
if (m_bMaintainParallel) v3TargetPos.z += 50; dir=normalize(v3TargetPos - m_v3Position);
|
||
__SetTargetNormalizedDirection —— SafeRotationNormalizedArc((0,-1,0), dir) 旋转 velocity + accel
|
||
· Update() 每帧:
|
||
if (!m_bAlive) return false;
|
||
homing:m_bIsHoming && m_fHomingStartTime+m_fStartTime < now && IsObject → AdjustDirectionForHoming
|
||
v3LastPosition=m_v3Position; m_v3Velocity += m_v3Accel*dt; m_v3Velocity.z += m_fGravity*dt;
|
||
v3Movement=m_v3Velocity*dt; _fMoveDistance=length(v3Movement);
|
||
fCollisionSphereRadius=max(_fMoveDistance*2, m_fCollisionSphereRadius);
|
||
m_fRemainRange -= _fMoveDistance; m_v3Position += v3Movement;
|
||
if (m_fRemainRange<0) → OnExplodingOutOfRange() + __Explode(false) + return false
|
||
对象目标 && !m_bTargetHitted:square_distance_between_linesegment_and_point(
|
||
m_v3Position, v3LastPosition, targetPos) < m_fBombRange² →
|
||
m_bTargetHitted=TRUE; if (m_canAttack) pVictim->OnShootDamage();
|
||
OnExplodingAtTarget(m_dwSkillIndex); if (m_iPierceCount) { m_iPierceCount--; __Bomb(); } else { __Explode(); return false; }
|
||
位置目标:同线段判定 → __Explode(); return false
|
||
怪物(m_bHitOnAnotherMonster):球 r=10 CullingMgr.ForInRange,m_HittedObjectSet 去重 → OnExplodingAtAnotherTarget
|
||
背景(m_bHitOnBackground):GetTerrainHeight(x,-y) > z → OnExplodingAtBackground(); __Explode(); return false + 球 r=1 建筑判定
|
||
return true
|
||
· __Explode(bBomb=true) —— if (!m_bAlive) return; m_bAlive=false; if (bBomb) __Bomb();
|
||
· __Bomb() —— if (!m_pData->m_dwBombEffectID) return; 在 m_v3Position 生成 bomb effect
|
||
REF/GameLib/FlyingData.cpp __Initialize() —— m_fInitVel=200 / m_fConeAngle=0 / m_fRollAngle=0 /
|
||
m_fRange=500 / m_v3Accel=(0,0,0) / m_fHomingMaxAngle=3 / m_fHomingStartTime=0 / m_fGravity=0 /
|
||
m_bSpreading=false / m_bMaintainParallel=false / m_bHitOnBackground=false / m_bHitOnAnotherMonster=false /
|
||
m_iPierceCount=0 / m_fCollisionSphereRadius=0 / m_fBombRange=10
|
||
REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(p1,p2,x) —— v=p2-p1; l=|v|²;
|
||
w=x-p1; d=dot(w,v); if (d<=0) return |w|²; if (d>=l) return |x-p2|²; return |cross(w,v)|²/l
|
||
REF/GameLib/FlyTarget.h —— EType { TYPE_NONE, TYPE_OBJECT, TYPE_POSITION }
|
||
REF/GameLib/ActorInstanceFly.cpp OnGetFlyTargetPosition —— 包围球心 m_v3Center 变换到世界
|
||
REF/GameLib/FlyHandler.h —— IFlyEventHandler::OnNoTarget() / OnNoArrow()(__CanShot 失败走的回调)
|
||
REF/GameLib/InstanceBaseBattle.cpp:355 NEW_Attack —— IsDead / IsStun / IsKnockDown / IsUsingSkill 门
|
||
REF/UserInterface/InstanceBase.cpp:694 __GetBowRange —— 弓模式命中 / 点击距离
|
||
★ GC_CREATE_FLY 在本 40250 客户端 EterLib/NetStream.cpp 仅头串索引 70,无包处理器
|
||
(PythonNetworkStreamPhaseGame*.cpp 无 handler)——实际驱动走 POC 自有 fly_cue
|
||
★ grep -rn "CanShot|__CanShot|CanShoot" 全树无命中——__CanShot 在发布源码树中无定义,防御式重建
|
||
改动:
|
||
project/fly_object.gd(新)——
|
||
· const INDEX_FLY_TYPE_NORMAL=0 / FIRE_CRACKER=1 / AUTO_FIRE=2
|
||
· class FlyData extends RefCounted —— 米制换算的 __Initialize() 默认值:init_vel=2.0 /
|
||
cone_angle=0 / roll_angle=0 / gravity=0 / accel=ZERO / flat_range=5.0(=m_fRange) /
|
||
bomb_range=0.1(=m_fBombRange) / pierce_count=0 / spreading=false / maintain_parallel=false /
|
||
is_homing=false / homing_start_time=0 / homing_max_angle=0 / hit_on_background=false
|
||
· class FlyInstance extends RefCounted ——
|
||
· target_position() —— 对象目标每帧刷 _target_pos = _target_obj.global_position;
|
||
maintain_parallel → +0.5 y(= v3TargetPos.z+=50)
|
||
· create(d, start_world, target, attack) —— pos/last_pos/alive/_elapsed=0/target_hitted=false/
|
||
pierce=d.pierce_count/remain_range=d.flat_range;target 按 Node3D(对象)/ Vector3(位置)/
|
||
Dictionary{vid,pos,node} 分派;dir=(target_position()-pos).normalized()(回退 (0,0,-1));
|
||
vel=dir*d.init_vel;accel=d.accel,非零则 accel=Quaternion((0,-1,0),dir).normalized()*accel
|
||
· _adjust_homing() —— vel 朝目标 dir 单步转向,限幅 deg_to_rad(homing_max_angle)(>=180 全转),
|
||
经 vdir.slerp(tdir,t)*speed
|
||
· update(dt) -> bool —— if not alive: return false;_elapsed+=dt;
|
||
homing if is_homing and _elapsed>homing_start_time and _is_object;last_pos=pos;
|
||
vel+=accel*dt;vel.y+=gravity*dt(seam:参考端 .z=上,POC 映射 -Y=下);
|
||
movement=vel*dt;move_dist=movement.length();remain_range-=move_dist;pos+=movement;
|
||
remain_range<0 → _emit("out_of_range")+_explode(false)+return false;
|
||
br2=bomb_range²;对象分支(_is_object, not target_hitted):
|
||
_seg_point_sq_dist(pos,last_pos,target_position())<br2 → target_hitted=true;
|
||
can_attack → _emit("shoot_damage");_emit("at_target");
|
||
pierce>0 → pierce-=1;_emit("bomb") else _explode();return false → return true;
|
||
位置分支:_seg_point_sq_dist(...)<br2 → _emit("at_position")+_explode()+return false;
|
||
背景:hit_on_background and _world.sample_height(pos.x,pos.z)>pos.y → _emit("at_background")+_explode()+return false;
|
||
return true
|
||
· _explode(bomb=true) —— if not alive: return; alive=false; if bomb: _emit("bomb")
|
||
· _emit(ev) —— handler.call(ev, pos, target_vid) if valid
|
||
· static _seg_point_sq_dist(p1,p2,x) -> float —— 1:1 移植 GrpMath.h:80
|
||
· class FlyManager(the Node,extends Node)—— parent:Node3D / world:Object / manual_step:=false /
|
||
_instances / _visuals / _id_counter=1;signal shoot_damage(target_vid) / exploded(world_pos, cause)
|
||
· setup(mount, w=null) —— parent=mount; world=w
|
||
· spawn(start_world, target, can_attack, data=null, skill_index=0) -> FlyInstance ——
|
||
null 则建 FlyData;建 FlyInstance;inst.id=_id_counter++;
|
||
inst.handler = func(ev,wp,vid): _on_instance_event(inst,ev,wp,vid);inst.create(...);
|
||
append _instances;parent!=null 则建 SphereMesh(r=0.08) 发光球 _visuals[inst]
|
||
· active_count() / step(dt)(while 迭代 update → 更新 visual.global_position → 不 keep 则
|
||
queue_free + erase + remove_at,else ++i)/ _process(dt)(not manual_step → step(dt))
|
||
· _on_instance_event —— "shoot_damage"→shoot_damage.emit(vid);
|
||
"out_of_range"/"at_target"/"at_position"/"at_background"/"at_another"→exploded.emit(wp,ev) +
|
||
_spawn_flash(wp) if parent and ev!="out_of_range";"bomb"→_spawn_flash if parent
|
||
· _spawn_flash(wp) —— MeshInstance3D,tween scale→ONE*6.0(0.25s) ∥ albedo_color:a→0.0,链 queue_free
|
||
project/net_world.gd ——
|
||
· +const FlyObject = preload("res://fly_object.gd")
|
||
· +signal fly_exploded(world_pos: Vector3, cause: String)(§3.6 命中 / 超程 / 触地)
|
||
· +var _fly_mgr: Node
|
||
· setup() 尾:_fly_mgr = FlyObject.new(); name="FlyManager"; add_child; _fly_mgr.setup(parent, world);
|
||
_fly_mgr.exploded.connect(func(wp,cause): fly_exploded.emit(wp,cause))
|
||
· _on_fly(_type, start_vid, end_vid) 重写 —— 保留 a/b 端点 + shooter fly_target_queue meta 消费
|
||
(现也据此设 target_node = _by_vid.get(target_vid) / null);原 tween「发光球」换成:
|
||
_fly_mgr.parent=parent; _fly_mgr.world=world;start_w=a+(0,1,0); goal_w=b+(0,1,0);
|
||
FlyData d:init_vel=45.0 / flat_range=max(start_w.distance_to(goal_w)*1.5, 30.0) / bomb_range=0.6 / pierce_count=0;
|
||
tgt = target_node if valid else goal_w;_fly_mgr.spawn(start_w, tgt, false, d, _type)
|
||
project/net_play.gd ——
|
||
· +func _can_shot(target_vid := -1) -> String(紧跟 _is_bow_mode(),含 seam 注释块)——
|
||
not _is_bow_mode() → "NOT_BOW_MODE";_arrow_count==0 → "EMPTY_ARROW"(-1 = 未跟踪/无限,放行);
|
||
tv = target_vid>=0 ? target_vid : _target_vid;tv<=0 → tv=_vid_reserved;tv<=0 → "NO_TARGET";
|
||
te = client.get_entity(tv);not te.is_empty() and not _is_attackable(te) → "NO_TARGET"
|
||
(te.is_empty() 不拒——headless 容差,与 _send_fly_target「有节点即可」一致);
|
||
tp/mp = _entity_world_pos(tv)/_entity_world_pos(_main_vid);
|
||
两者 Vector3 and tp.distance_to(mp)*CM > _bow_range_cm() → "OUT_OF_RANGE";else ""
|
||
· _emit_swing 弓分支加门 —— 原 `if _is_bow_mode(): _send_fly_target(); bow_shot_fired.emit(_swing_skill)`
|
||
改为:why=_can_shot();why!="" → _emit_cannot(why)(走 OnNoArrow / OnNoTarget 文案,
|
||
不发 CG_FLY_TARGETING、不排 OnShoot);else _send_fly_target() + bow_shot_fired.emit(_swing_skill)
|
||
(seam:其余起手动作照常,参考端整挥击中止)
|
||
project/fly_test.gd(新,第 14 个 GDScript 回归)——
|
||
· extends SceneTree;_init() → await _run() → PASS/quit(0) or quit(1)
|
||
· _run() 首行 await process_frame(SceneTree._init 早于 root 就绪);_mk_mgr()(manual_step=true);
|
||
_node_at(p)(root.add_child + global_position=p)
|
||
· _test_hit_object(直线命中对象目标,at_target 一次,实例移除)
|
||
· _test_out_of_range(remain_range<0 → out_of_range,无 shoot_damage,不 __Bomb)
|
||
· _test_can_attack_damage(Dictionary{vid,node} + can_attack → shoot_damage(4242) 恰一次)
|
||
· _test_pierce(pierce_count=1 → 命中后存活续飞 → 最终 out_of_range;causes==["at_target","out_of_range"])
|
||
· _test_position_target(Vector3 目标 → at_position)
|
||
· _test_hit_background(_FakeWorld.sample_height=0.9 + gravity=-20 → at_background)
|
||
· _test_can_shot(_FakeClient + NetPlay:GENERAL→NOT_BOW_MODE / BOW+0 箭→EMPTY_ARROW /
|
||
BOW+箭+无目标→NO_TARGET / _arrow_count=-1 + 目标(5,0,0)→"" / 目标(400,0,0)→OUT_OF_RANGE /
|
||
死目标→NO_TARGET)
|
||
· _test_seg_point_dist(_seg_point_sq_dist:垂足在段内→9.0 / 越 p1→16.0 / 越 p2→25.0)
|
||
测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);14 个 GDScript 回归 → PASS
|
||
(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion /
|
||
netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill / fly)。eterngrn_polish_test 的
|
||
stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
|
||
验收:纯离线。CFlyingInstance::Update 的逐帧管线(homing → vel+=accel*dt → remain_range-=|movement| →
|
||
线段-点 < bomb_range² 命中 → OnShootDamage / pierce / __Explode / __Bomb)逐行对齐;
|
||
_seg_point_sq_dist 1:1 移植 GrpMath.h:80;FlyData 默认值取 FlyingData.cpp __Initialize()。
|
||
已文档化 seam:
|
||
① GC_CREATE_FLY 在本 40250 客户端无包处理器(仅 NetStream.cpp 头串索引 70)——实际驱动走 POC 自有
|
||
fly_cue(M2Client 发 fly_cue / fly_targeting,net_world._on_fly 消费);
|
||
② __CanShot 在发布源码树中无定义(grep 全树无命中)——据 OnNoTarget / OnNoArrow / NEW_Attack 的
|
||
IsDead 门 / __GetBowRange 防御式重建;_can_shot 不因实体数据缺失(te.is_empty())而拒,
|
||
只在确知不可攻击 / 已死时拒——headless 容差,保 combat_fx_test 的 §3.6 段(_target_vid=2000
|
||
但无 fc.ents[2000])仍过;
|
||
③ _emit_swing 弓分支为「跳过 fly-targeting / OnShoot」门,不中止整挥(状态包 + 动画照常)——
|
||
参考端 __CanShot 失败中止整个攻击;
|
||
④ 重力:参考端作用于 m_v3Velocity.z(=上),本 POC 映射到 vel.y(= -Y =下);
|
||
⑤ 怪物球体碰撞 / CullingMgr.ForInRange / 建筑体素 r=1 判定未移植——客户端飞行物只做命中「时机」,
|
||
伤害服务端裁决;
|
||
⑥ 无 .fly 数据文件加载器——net_world._on_fly 用合成箭参数(init_vel=45 / flat_range=max(dist*1.5,30) /
|
||
bomb_range=0.6);FIRE_CRACKER / AUTO_FIRE 的 CreateIndexedFly 分支已在 fly_object.gd 常量层预留,
|
||
驱动侧(GC_CREATE_FLY byType)本 build 无来源,未接线。
|
||
下一批次(增量 93):§3.1 Actor-Actor 碰撞(TestActorCollision / BlockMovement)+ 坐骑 MODE_HORSE*
|
||
(依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。
|
||
```
|
||
|
||
|
||
|
||
### C.5 批次记录 · 增量 93(W2 / §3.1 收尾:Actor-Actor 碰撞 + 坐骑转向速度)
|
||
|
||
```text
|
||
批次:增量 93 —— W2 §3.1 收尾(M1)
|
||
状态:完成(纯离线;C++ 21/21 + 14 GDScript 回归全绿)
|
||
|
||
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1):
|
||
GameLib/ActorInstanceCollisionDetection.cpp —— CActorInstance::TestActorCollision(距离门 800cm²、
|
||
body sphere vs body sphere、末尾「dist(cur) <= dist(last) 才 return TRUE」=趋近判定)、
|
||
BlockMovement()(→ __InitializeMovement,坐骑时转发 m_pkHorse)、CanSkipCollision() /
|
||
EnableSkipCollision() / DisableSkipCollision()(m_canSkipCollision)、__TestObjectCollision
|
||
(m_canSkipCollision || 零移动 → FALSE)。
|
||
UserInterface/InstanceBaseBattle.cpp:469 CheckAdvancing —— CanSkipCollision → 不检;
|
||
逐个 CInstanceBase:using-skill 时只撞 Door;TestActorCollision 命中 → uCollisionCount++,
|
||
==2 → BlockMovement + return;否则 AdjustDynamicCollisionMovement(滑移,函数不在发布树)→
|
||
再测仍撞 → BlockMovement,否则 NEW_MoveToDestPixelPositionDirection 重推;末尾地形
|
||
ATTRIBUTE_BLOCK 分段 + next-pos 检测 → BlockMovement。
|
||
UserInterface/InstanceBase.cpp:17-18 c_fDefaultRotationSpeed=1200 / c_fDefaultHorseRotationSpeed=300;
|
||
:951 MountHorse(eRace) → SetMotionMode(MODE_HORSE) + SetRotationSpeed(c_fDefaultHorseRotationSpeed)
|
||
+ m_GraphicThingInstance.Stop();:964 DismountHorse() → m_kHorse.Destroy();
|
||
:3018 __InitializeRotationSpeed → SetRotationSpeed(c_fDefaultRotationSpeed)。
|
||
IsMountingHorse() = m_kHorse.IsMounting()(InstanceBase.cpp:945)。
|
||
seam:TestActorCollision / BlockMovement / CanSkipCollision 的「调用方」(CPythonCharacterManager
|
||
的逐帧 Deform / Update 循环)不在发布源码树;AdjustDynamicCollisionMovement 亦不在。
|
||
|
||
改动:
|
||
project/player_controller.gd ——
|
||
· +const ACTOR_BODY_RADIUS_M := 0.55(自身 + 对方相加 = combined 判定阈值;固定半径近似
|
||
真实 race 碰撞球)、ACTOR_COLLIDE_MAX_DIST_M := 8.0(≈ LengthSq > 800² 距离门)、
|
||
ROT_SPEED_DEFAULT_DEG := 1200.0、ROT_SPEED_HORSE_DEG := 300.0
|
||
· +var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG、+var skip_actor_collision := false
|
||
· +func _actor_blocked(next_pos) -> bool —— skip_actor_collision → false;遍历 pickables,
|
||
跳过 !is_instance_valid / dead meta(≈ rVictim.IsDead());d_now = XZ 距离,> 8m 跳过;
|
||
d_next = XZ(op, next_pos);d_next < combined && d_next <= d_now + 1e-4 → true(趋近才挡)
|
||
· +func _turn_toward(target_yaw, dt) —— max_step = deg_to_rad(rotation_speed_deg)*dt;
|
||
diff = wrapf(target_yaw - rot.y, -PI, PI);|diff| <= max_step → 直接到位,否则 += sign*max_step
|
||
· _process 平移分支:np = pos + wish*want_speed*dt 之后,
|
||
block_x = _actor_blocked(仅动 X) or _blocked(np.x, z);block_z 同理(逐轴门控=
|
||
单 Actor 滑移 ≈ AdjustDynamicCollisionMovement,两轴都挡=停在上一帧 ≈ BlockMovement);
|
||
not block_x → pos.x = np.x;not block_z → pos.z = np.z;既有 disp 判定继续把顶人磨蹭
|
||
切回 _is_going=false + wait
|
||
· moving_skill 分支 + 正常分支的 `player.rotation.y = atan2(wish.x, wish.z)` 两处 →
|
||
`_turn_toward(atan2(wish.x, wish.z), dt)`
|
||
project/net_play.gd ——
|
||
· _refresh_motion_mode() 尾:if pc and "rotation_speed_deg" in pc →
|
||
pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG
|
||
(对齐 MountHorse → SetRotationSpeed;mounted 沿用既有 mount_vnum/mounting 判定)
|
||
· setup()(is_observer_mode 之后)+ _on_observer_mode(enabled):if pc and
|
||
"skip_actor_collision" in pc → pc.skip_actor_collision = _observer_mode / enabled
|
||
(观战=自由穿行 ≈ CanSkipCollision)
|
||
project/player_move_test.gd ——
|
||
· _run() 首行 +await process_frame(对齐 fly_test,避免 !is_inside_tree())
|
||
· 第 5 组后清 _reserved_ground / _reserved_delay_time(免遗留预约点改向后续组)
|
||
· +第 6 组:正前方 2m 放 blocker(入 pickables)→ _goto(10,0,0) → 停在 x∈(0.3,1.5)、is_going 归零
|
||
· +第 6b 组:blocker.dead meta = true → 直穿到 (10,0,0)(≈ rVictim.IsDead())
|
||
· +第 6c 组:skip_actor_collision = true → 直穿
|
||
· +第 7 组:rotation_speed_deg = 300 → 单个 50ms 步内 |rot.y| <= deg_to_rad(300)*0.05,
|
||
且 pos.z 已 < -0.05(转向变慢但仍朝 Dst 平移)
|
||
|
||
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
|
||
14 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay /
|
||
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill /
|
||
fly)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async
|
||
竞态,非本批次触及,仍 exit 0 + PASS。
|
||
|
||
验收:纯离线。TestActorCollision 的距离门 + body 球重叠 + 趋近判定逐条对齐;BlockMovement
|
||
=停在上一帧(__InitializeMovement 语义);坐骑转向速度 300 / 1200 取 InstanceBase.cpp:17-18
|
||
常量;MODE_HORSE* 动作模式沿用增量 84。
|
||
已文档化 seam:
|
||
① body sphere 半径用固定 0.55m 近似——真实值需 race 碰撞数据(.col / CollisionData.h);
|
||
② AdjustDynamicCollisionMovement 精确滑移向量不在发布树——用 _process 既有逐轴门控近似
|
||
(单 Actor 逐轴放行=滑移,两轴都挡=硬停);门 / Actor 复合的 uCollisionCount==2 亦以此近似;
|
||
③ CanSkipCollision 目前只由观战驱动——§3.2 网络状态包路径的 EnableSkipCollision 尚未接到
|
||
本地预测玩家(本地走客户端预测,服务器只 lerp 纠正);
|
||
④ TestActorCollision / CheckAdvancing 的逐帧「调用方」不在发布源码树——_process 内联近似;
|
||
⑤ MOVABLE_GROUND_DISTANCE 参考默认值仍是估计(1.0m),待读 game.py 侧 SetMovableGroundDistance。
|
||
|
||
下一批次(增量 94):§3.2 / §3.5 收尾(多为待真实服务器);随后转 M2(W3 玩法:§5.1 quest 标签、
|
||
§5.3 fishing/dungeon/mount/observer UI、§5.4 PVP/PK/duel、§4.1 costume/belt、§4.2 拖放鼠标物品态)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 94(W3→M2 / §5.4 PVP·PK·决斗可攻击判据 `IsAttackableInstance` 1:1)
|
||
|
||
```text
|
||
批次:增量 94 —— W3→M2 §5.4(M2 起点;纯 GDScript,无 C++ 改动)
|
||
状态:完成(纯离线;C++ 21/21 + 15 GDScript 回归全绿)
|
||
|
||
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1):
|
||
UserInterface/InstanceBase.cpp:2147 CInstanceBase::IsAttackableInstance —— 唯一判据,逐行照抄。
|
||
结构:__IsMainInstance() && IsObserverMode() → false;GetVirtualID()==victim → false;
|
||
if IsStone() { victim.IsPC() → true }
|
||
else if IsPC() {
|
||
victim.IsStone() → true;
|
||
if victim.IsPC() {
|
||
switch GetDuelMode(): DUEL_CANNOTATTACK → false; DUEL_START → __FindDUELKey ? true : false;
|
||
if PK_MODE_GUILD==GetPKMode() && GetGuildID()==victim.GetGuildID() → false;
|
||
if victim.IsKiller() && !IsSamePartyMember(self,victim) → true;
|
||
if PK_MODE_PROTECT != GetPKMode() {
|
||
if PK_MODE_FREE==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 → true }
|
||
if PK_MODE_GUILD==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 && 公会不同 → true }
|
||
}
|
||
if IsSameEmpire(victim) {
|
||
if IsPVPInstance(victim) → true;
|
||
if PK_MODE_REVENGE==GetPKMode() && !同队 && IsConflictAlignmentInstance(victim) → true;
|
||
} else return true;
|
||
}
|
||
if victim.IsEnemy() → true;
|
||
if victim.IsWoodenDoor() → true;
|
||
}
|
||
else if IsEnemy() { victim.IsPC() → true; victim.IsBuilding() → true }
|
||
else if IsPoly() { victim.IsPC() → true; victim.IsEnemy() → true }
|
||
return false;
|
||
InstanceBase.cpp:502 GetAlignmentGrade(>=12000→0 / >=8000→1 / >=4000→2 / >=1000→3 / >=0→4 /
|
||
>-4000→5 / >-8000→6 / >-12000→7 / else 8)
|
||
InstanceBase.cpp:524 GetAlignmentType(grade 0-3→WHITE / 4→NORMAL / 5-8→DARK)
|
||
InstanceBase.cpp:470 IsSameEmpire(victim.empire==0 → TRUE;IsGameMaster()(任一)→ TRUE;
|
||
同 empire → TRUE;否则 FALSE)
|
||
InstanceBase.cpp:2116 IsConflictAlignmentInstance(victim.GetPKMode()==PROTECT → false;
|
||
self 类型 NORMAL/WHITE:victim==DARK → true;self==DARK:self!=victim → true;否则 false)
|
||
InstanceBase.cpp:2390 IsWoodenDoor(IsDoor() && (vnum==13000 || 30111<=vnum<=30119))
|
||
InstanceBaseEffect.cpp:495 __FindDUELKey / :505 IsPVPInstance(GetDuelMode() → true;
|
||
否则 __FindPVPKey(vidSrc,vidDst) || __FindGVGKey(guildSrc,guildDst))
|
||
PythonPlayer.cpp:1445 IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b)
|
||
GameLib/ActorInstance.cpp:270 IsPoly(TYPE_POLY || (TYPE_PC && race>=MAIN_RACE_MAX_NUM))/
|
||
:282 IsPC(==TYPE_PC)/ :298 IsEnemy(==TYPE_ENEMY)/ :306 IsStone(==TYPE_STONE)——
|
||
m_eActorType 单值,变身玩家仍是 TYPE_PC(故 IsAttackableInstance 的 else-if 链里 IsPC() 先命中,
|
||
IsPoly() 分支只服务真正的 TYPE_POLY 实例)。
|
||
Packet.h:1273 EPKModes(PEACE=0/REVENGE=1/FREE=2/PROTECT=3/GUILD=4);
|
||
Packet.h:1264 ADD_CHARACTER_STATE_KILLER=1<<3;InstanceBase.cpp:2086 SetStateFlags 从位取 killer。
|
||
seam:__FindPVPKey / __FindGVGKey 的 key 集合 setter(GC_PVP / 公会战对局包)、IsGameMaster 标记、
|
||
IsBuilding·IsWall 的 race 白名单——均不在发布源码树 / 本 POC 未下发。
|
||
|
||
改动:
|
||
project/entity_rules.gd(新建,纯静态 extends RefCounted)——
|
||
· const PK_MODE_* / ALIGNMENT_TYPE_* / DUEL_* / STATE_KILLER=1<<3 / STATE_PARTY=1<<4 /
|
||
KIND_PC=0..KIND_WARP=4(对齐 net_play._entity_kind 语义)
|
||
· alignment_grade(a) / alignment_type(a) —— :502 / :524 逐行
|
||
· _kind / _is_poly(poly / polymorph / poly_vnum 任一)/ _is_pc(==KIND_PC)/ _is_stone /
|
||
_is_enemy(==KIND_MONSTER)/ _is_wooden_door(race 13000 或 30111..30119)/ _is_building(seam)
|
||
· _is_killer(state_flags & STATE_KILLER)/ _is_same_party(a,b 皆在 ctx.party_vids)/
|
||
_is_same_empire(:470)/ _find_duel_key(victim 或 self 在 ctx.duel_opponents)/
|
||
_pair_in(无序对匹配)/ _is_pvp_instance(duel_mode!=0 || pvp_pairs || 双方公会非 0 且 gvg_pairs)/
|
||
is_conflict_alignment_instance(:2116)/ _pk_mode_of
|
||
· is_attackable_instance(self_e, victim_e, ctx) -> bool —— :2147 逐行
|
||
project/net_play.gd ——
|
||
· +const EntityRules = preload("res://entity_rules.gd")(CM 常量后)
|
||
· _is_attackable(e):桩「dead? false : _entity_kind(e) in [0,2]」→ 拼 self_e(_main_entity() 快照 +
|
||
kind=_entity_kind)/ victim_e(+ kind)/ _attack_ctx(self_e),委托 EntityRules.is_attackable_instance;
|
||
保留 dead 提前返回(便利,参考端此函数不查死亡,另在各调用点查);_main_entity() 空 → 退化
|
||
_entity_kind(e) in [KIND_PC, KIND_MONSTER]
|
||
· +_attack_ctx(self_e) -> Dictionary —— get_duel()(active/cannot_attack → duel_mode,opponents)、
|
||
get_pvp_relations()(mode!=0 的 src/dst_vid → pvp_pairs)、get_guild_wars()(src/dst → gvg_pairs)、
|
||
get_party()(vid → party_vids);键:observer / main_vid / pk_mode / duel_mode / duel_opponents /
|
||
pvp_pairs / gvg_pairs / party_vids
|
||
· 5 处调用点(363 / 758 / 872=__ReserveProcess_ClickActor 第 3 步 / 1096 / 1351)签名不变
|
||
project/entity_rules_test.gd(新建,第 15 个 canonical,extends SceneTree,25 组断言)——
|
||
grade/type 边界、观战门、自打自、PC/怪/NPC/石头、同帝国 peace(+empire 0)、异帝国、
|
||
PK_FREE(普通 / victim PROTECT / 同队)、PK_GUILD(同公会提前 false / 不同公会 true)、killer(±同队)、
|
||
决斗 CANNOTATTACK / START(±对手名单)、PVP-pair、GVG-pair(±双方公会)、REVENGE(±冲突善恶)、
|
||
石头 self / victim、木门 13000 / 30115 / 非木门 13001、is_conflict_alignment(victim PROTECT / 黑对中 /
|
||
黑对黑)、变身玩家(仍走 PC 分支)、TYPE_POLY self(走 Poly 分支)
|
||
project/{combat_fx_test,netplay_test,fly_test}.gd ——
|
||
怪物夹具(ents[2000] / ents[4100] / send_fly_targeting 的 mk 闭包 / fly_test ents[900-902])补
|
||
"ch_type": 2 —— 此前无 ch_type → _entity_kind 归 PC(0),旧桩按 [0,2] 也「可打」;1:1 规则下
|
||
和平同帝国 PC 不可攻击 → 夹具须明确怪类别(真实服务器 GC_CHARACTER_ADD 带 type,或 m2dev 走
|
||
mob_proto 查 race,生产环境二者恒具备)
|
||
|
||
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
|
||
15 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay /
|
||
player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill /
|
||
fly / entity_rules)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为
|
||
既有 async 竞态,非本批次触及,仍 exit 0 + PASS。
|
||
|
||
验收:纯离线。IsAttackableInstance 的四分支 + 决斗三态 + PK 五模式 + 帝国 / PVP / GVG / 善恶 /
|
||
killer / 同队全部逐行;判据 helper(alignment_grade/type、IsSameEmpire、IsConflictAlignmentInstance、
|
||
IsPVPInstance、__FindDUELKey、IsSamePartyMember、IsWoodenDoor)1:1;IsPC / IsEnemy / IsPoly 依
|
||
ActorInstance.cpp 的 m_eActorType 单值语义(变身玩家仍是 PC)。
|
||
已文档化 seam:
|
||
① __FindPVPKey 的 key 集合 setter=GC_PVP 关系表未接(get_pvp_relations() 有值则消费);
|
||
② __FindGVGKey 的公会战对局 setter 未接(get_guild_wars() 对表消费,来源 GUILD_GC_WAR);
|
||
③ IsGameMaster 无 GM 标记 → _is_same_empire 里恒 false;
|
||
④ IsBuilding / IsWall 的 race 白名单源码未随发布 → _is_building 读显式 building 字段;
|
||
⑤ m2dev 后端的 IsKiller 状态位(state_flags & 1<<3)——classic 后端 classic_parser.cpp 已接;
|
||
⑥ _entity_kind 无法分类(无 ch_type 且 mob_proto 查不到 race)时怪被当和平 PC → 不可攻击
|
||
(生产 mob_proto 恒加载,仅裸测试夹具受影响,本批次已补 ch_type);
|
||
⑦ 名字颜色 / 可攻击光标(ENameColor,§8.8)、IsInSafe 的 ATTRIBUTE_BANPK 地形属性 —— 未接。
|
||
|
||
下一批次(增量 95):§5.1 quest 38 tag(quest_dialog EventTypeMap + ProcessEventSet 分发)、
|
||
§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 95(W3→M2 / §5.1 Quest EventManager `CPythonEventManager` 1:1)
|
||
|
||
```text
|
||
批次:增量 95 —— W3→M2 §5.1(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(纯离线;C++ 21/21 + 18 GDScript 回归全绿)
|
||
|
||
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1):
|
||
UserInterface/PythonEventManager.h —— :35 EEventType(42 项枚举)、:98 SEventSet、
|
||
常量 c_lNormal_Waiting_Time=10 / c_fLine_Temp=16 / BOX_VISIBLE_LINE_COUNT=5 /
|
||
EVENT_POSITION_START=0 / EVENT_POSITION_END=1。
|
||
UserInterface/PythonEventManager.cpp —— :1148 ctor 填 EventTypeMap(42 条 std::map<string,int>,
|
||
CLEAR 与 CLEAR_TEXT 是两个独立枚举但都走 ClearLine);:1093 GetScriptEventIndex(前导 `/` →
|
||
EVENT_POSITION_END + 剥离,未知名字 → 解析错误 / 跳过);:377 ProcessEventSet(大 switch,
|
||
逐命令弹出);:312 UpdateEventSet(逐帧 lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait
|
||
倒计时刷新 m_strLeftTimeString);:72 __InitEventSet;:976 __InsertLine;:953 ClearLine;
|
||
:884 MakeQuestion;:878 MakeNextButton;:48 GetCameraSettingFromArgList;:142
|
||
RegisterEventSetFromString(首命令 RUN_CINEMA 特例:整脚本换成事件文件);:562/:566 SETMSGPOS /
|
||
ADJMSGPOS case 为**空实现**。
|
||
|
||
改动:
|
||
project/ui/quest_dialog.gd —— 由旧的单遍手写 parse_script 重写为 EventTypeMap + 状态机 + 分派:
|
||
· const EVENT_TYPE_MAP(42 键,tag 字符串 → EVT_*)+ enum { EVT_LETTER .. EVT_SELECT_ITEM }
|
||
(顺序 = EEventType)+ const EVENT_POSITION_START/END、C_NORMAL_WAITING_TIME=10、
|
||
C_LINE_TEMP=16、BOX_VISIBLE_LINE_COUNT=5
|
||
· _get_script_event_index(raw_name) -> {ok, position, type} —— 前导 `/` → position=END + 剥离;
|
||
大写查表;未知 → ok=false / type=-1(调用方 return,不进 match)
|
||
· class EventSet extends RefCounted —— SEventSet 字段名 1:1(ix/iy/iWidth/iyLocal/isLock/
|
||
lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/
|
||
DiffuseColor/lWaitingTime/iRestrictedCharacterCount=30/iVisibleStartLine/iVisibleLineCount/
|
||
iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer)+ POC 累加器(lines/color_open/choices/
|
||
has_next/has_input/has_select_item/window_size/images/insert_images/title_image/
|
||
quest_buttons/quest_button_closes/map_signals/clear_map_signals/atlas_center/camera_events/
|
||
fade_events/dungeon_result/cinema_file/end_confirm);方法 init_event_set()(__InitEventSet)、
|
||
insert_line()(__InsertLine:推行 + iyLocal += C_LINE_TEMP + 清 strCurrentLine/iCurrentLetter)、
|
||
clear_line()(ClearLine)、body_text()
|
||
· _process_event_set(es, cmd) = ProcessEventSet 的 switch 逐 case 对拷:literal → 合成 LETTER
|
||
(is_raw=true);DELAY / COLOR / COLOR256 的 START vs `/END`;LETTER / ITEM / MOB 的
|
||
iCurrentLetter >= iRestrictedCharacterCount → insert_line 断行(裸文本 is_raw 跳过断行);
|
||
NEXT / DONE / INSERT_IMAGE 的 iAdjustLine += 2;WAIT(iyLocal=0; isLock=true);
|
||
CLEAR / CLEAR_TEXT → clear_line;QUESTION → MakeQuestion(choices + nAnswer);
|
||
LEFTIMAGE / TOPIMAGE / BGIMAGE / IMAGE / TITLE_IMAGE / INSERT_IMAGE;ADDMAPSIGNAL /
|
||
CLEARMAPSIGNAL / SETCMAPPOS;SETMSGPOS / ADJMSGPOS → pass(引 :562/566 空实现);SLEEP →
|
||
lLastDelayTime;SET_CAMERA(_camera_setting 九参 = :48)/ BLEND_CAMERA(+blendtime)/
|
||
RESTORE_CAMERA;FADE_OUT / FADE_IN / WHITE_OUT / WHITE_IN → fade_events + isWaitFlag=true;
|
||
TEXT_HORIZONTAL_ALIGN_CENTER → isTextCenterMode;DUNGEON_RESULT → 9 named int;
|
||
QUESTBUTTON / QUESTBUTTON_CLOSE;WINDOW_SIZE(w>0 && h>0);INPUT(insert_line + has_input);
|
||
CONFIRM_WAIT(isConfirmWait=true + iConfirmEndTime + insert_line(center));END_CONFIRM_WAIT
|
||
(清 isConfirmWait + end_confirm);SELECT_ITEM;RUN_CINEMA → pass(ProcessEventSet 无此 case)
|
||
· _tokenize(src) —— 拆混排文本 / `[tag ...]`;裸文本 → {literal},tag → {name(大写、保留前导 `/`),
|
||
pos, tok(括号内原文)}
|
||
· arg 提取:_tag_kv(tok)(key(v) / key("v") / key;v,括号深度 + 去引号,重复键取末次)+
|
||
_arg_s / _arg_f / _arg_i(keyword 优先、位置回退——兼容旧测试 value;255 / value(1) 写法)
|
||
· parse_script(src) -> Dictionary —— 建 EventSet → init_event_set() → _tokenize → 首命令
|
||
RUN_CINEMA 特例(仅当 cmds[0] 即 RUN_CINEMA、无前导文本 → es.cinema_file)→
|
||
for cmd: _process_event_set;返回字典=旧契约全键不变,新增
|
||
insert_images / dungeon_result / adjust_line / waiting_time / is_locked / wait_flag /
|
||
answer_count / cinema_file / end_confirm
|
||
· +signal cinema_requested(filename);_emit_side_effects 在 fade_events 循环后透出
|
||
project/quest_event_test.gd —— 新建,第 16 个 canonical(extends SceneTree),23 组断言:
|
||
EventTypeMap 42 tag 完整性 + 无多余;_get_script_event_index 的 `/` → END / 未知 → ok=false /
|
||
大小写不敏感;DELAY START→waiting_time=40 / `/DELAY`→10;显式 [LETTER] ≥30 字断 2 行 vs
|
||
裸长文本仍 1 行;ENTER→行 / CLEAR·CLEAR_TEXT→重置;QUESTION→3 choices + answer_count=3;
|
||
NEXT→adjust_line=2 / NEXT+DONE→4;WAIT→is_locked;COLOR 命名 r/g/b + COLOR256 位置
|
||
value;255 → [color=#00ff00] / [color=#ff8000];INPUT→has_input / SELECT_ITEM→has_select_item;
|
||
CONFIRM_WAIT→confirm_wait + confirm_timeout=30 / +END_CONFIRM_WAIT→清 + end_confirm;
|
||
DUNGEON_RESULT→9 named int;INSERT_IMAGE 带 image_name(is_item_icon=false / adjust_line=2)vs
|
||
不带(is_item_icon=true / idx=19);IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE→4 项 + panel + TITLE_IMAGE;
|
||
ADDMAPSIGNAL/SETCMAPPOS/CLEARMAPSIGNAL;QUESTBUTTON keyed + QUESTBUTTON_CLOSE idx;
|
||
camera SET(setting.distance=50)/BLEND(blendtime=1500)/RESTORE(3 事件);FADE_OUT/WHITE_IN →
|
||
fade_events + wait_flag;WINDOW_SIZE keyed + TEXT_CENTER;ITEM/MOB → "怪物 #77"/"物品 #19";
|
||
SETMSGPOS/ADJMSGPOS → no-op;未知 tag → 跳过;RUN_CINEMA 首命令 → cinema_file="intro.msc" /
|
||
非首命令 → cinema_file="" + body 保留
|
||
|
||
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动);
|
||
18 个 GDScript 回归(16 canonical,含新增 quest_event_test;+ quest_test + select_item_ui_test)
|
||
全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async 竞态,
|
||
非本批次触及,仍 exit 0 + PASS。
|
||
|
||
验收:纯离线。EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 +
|
||
SEventSet 状态字段名 1:1。裸网络文本合成 LETTER(is_raw) → 跳过 iRestrictedCharacterCount
|
||
30 字断行(server 权威句子不逐字符断),显式 [LETTER]/[ITEM]/[MOB] 仍断行(对齐参考)。
|
||
已文档化 seam:
|
||
① 逐帧 UpdateEventSet :312 的时间推进(lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait
|
||
倒计时刷新 m_strLeftTimeString)—— 一次性跑完 token,isLock / isWaitFlag 仅作状态保留并透出;
|
||
② Skip / EndEventProcess / iConfirmEndTime 的 timeGetTime —— 帧驱动,未接;
|
||
③ .msc 事件文件加载(RegisterEventSetFromString :142 的 RUN_CINEMA 换文件)—— 只记
|
||
cinema_file + 发 cinema_requested,文件读取 / 脚本替换未接;
|
||
④ MakeNextButton NEXT vs DONE 的按钮文案区分 + DoneEvent() 回调 —— 均归 has_next;
|
||
⑤ camera_event_requested / fade_event_requested 消费端与 §7.6 相机模式、game_scene 的
|
||
cinema_requested 接线 —— 信号已透出,consumer 未接;
|
||
⑥ 物品名颜色 (1.0,0.2,0.2)(reference ITEM_NAME)—— 以纯文本落地,着色为 cosmetic seam。
|
||
|
||
下一批次(增量 96):§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
|
||
§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 96(W3→M2 / §4.1 wear 槽位版图 + costume/ring/belt 发包坐标)
|
||
|
||
```text
|
||
批次:增量 96 —— W3→M2 §4.1(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(纯离线;C++ 21/21 + 55 GDScript 回归全绿)
|
||
|
||
参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1,grep 需 -a):
|
||
UserInterface/GameType.h:40-112 —— 槽位版图:
|
||
c_ItemSlot_Count=90 / c_Equipment_Start=90 / c_Equipment_Count=12
|
||
c_Equipment_Body..Shield = 90+0..90+10(EWearPositions BODY..SHIELD,WEAR_MAX_NUM=11)
|
||
#ifdef ENABLE_COSTUME_SYSTEM c_Costume_Slot_Start = 90+19(Body=109 / Hair=110)
|
||
#ifdef ENABLE_NEW_EQUIPMENT_SYSTEM c_New_Equipment_Start = 90+21
|
||
c_Equipment_Ring1=111 / c_Equipment_Ring2=112 / c_Equipment_Belt=113
|
||
c_Wear_Max=32 / c_DragonSoul_Equip_Start = 90+32 = 122 / c_Belt_Inventory 4x4=16
|
||
—— 109..113 全在 INVENTORY 全局 cell 命名空间(window=1),客户端无独立 costume/ring/belt 窗。
|
||
GameLib/ItemData.h —— EWearPositions(:226)、EItemWearableFlag(:244,WEARABLE_BODY 1<<0 …
|
||
WEARABLE_ARROW 1<<9)、ELimitTypes(:160,LIMIT_LEVEL/STR/DEX/INT/CON,ITEM_LIMIT_MAX_NUM=2)。
|
||
GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 仅取表,
|
||
不做 CanEquipNow(仲裁在服务器 char_item.cpp)。
|
||
UserInterface/PythonItemModule.cpp:185/207 —— itemIsWearableFlag / itemGetLimit 是 client
|
||
暴露给 uiscript 的全部装备判据。
|
||
extension/src/net/wire.h —— WINDOW_INVENTORY=1 / WINDOW_EQUIPMENT=2 / BELT_INVENTORY_SLOT_START
|
||
= 90+32+6*2+6*3 = 152 / BELT_INVENTORY_MAX_NUM=16。
|
||
|
||
改动:
|
||
project/ui/inventory_ui.gd
|
||
· 新增常量:WEAR_MAX_NUM=11 / WEAR_COSTUME_BODY=19 / WEAR_COSTUME_HAIR=20 / WEAR_RING1=21 /
|
||
WEAR_RING2=22 / WEAR_BELT=23(值不变)/ NEW_EQUIP_WEAR_START=WEAR_COSTUME_BODY /
|
||
WINDOW_EQUIPMENT=2 —— 全部 1:1 自 GameType.h / ItemData.h
|
||
· _to_wire(ui_idx):BELT_BASE(200)+ → [1, 152+n](不变);EQUIP_BASE(90)+ 拆两路——
|
||
wear = ui_idx-90;wear >= 19 → [WINDOW_INVENTORY, 90+wear](全局 cell 109..113);
|
||
wear 0..10 → [WINDOW_EQUIPMENT, wear](零基 wear cell,不变);其余 → [1, ui_idx]
|
||
—— 修掉 _to_wire(113) 旧返回 [2, 23],现 [1, 113]
|
||
· _add_extended_cells:扩展装备格由 _add_grid(…, first=11, count=WEAR_COUNT=24, …) 收窄为
|
||
_add_grid("时装 / 戒指 / 腰带", first=WEAR_COSTUME_BODY, count=WEAR_BELT-WEAR_COSTUME_BODY+1=5, …)
|
||
—— EWearPositions 11..18 无位置,不再生成死格;腰带背包 16 格不变
|
||
· 头注释重写为 _to_wire 分流说明;WEAR_COUNT 保留为 get_equipment() 数组上限注释
|
||
project/inventory_test.gd —— 加 7 条 wire 断言:_to_wire(90)==[2,0] / (100)==[2,10] /
|
||
(109)==[1,109] / (110)==[1,110] / (111)==[1,111] / (112)==[1,112](原有 (113)==[1,113] 转绿)
|
||
|
||
测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(net.loopback_flow 首跑 localhost bind
|
||
抖动,--rerun-failed 即过;无 C++ 改动);55 个 GDScript 回归全 PASS(含 inventory_test /
|
||
inventory_ui_test)。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有
|
||
async 竞态,非本批次触及,仍 exit 0 + PASS。
|
||
|
||
验收:纯离线。wear 槽位版图与 GameType.h 逐值对齐;costume/ring/belt 发包坐标回到 INVENTORY
|
||
全局 cell(客户端事实:这三类无独立窗);基础 11 wear 仍走 WINDOW_EQUIPMENT(inventory_ui_test
|
||
的 move_to(5,91) → [1,5,2,1,1] 锁定)。
|
||
已文档化 seam:
|
||
① §4.1 修改 2 —— antiflag(性别/职业/帝国) → wearable_flag 匹配目标槽 → limit(等级/属性) →
|
||
CG_ITEM_USE 的装备校验链 + CANNOT_EQUIP_* 文案:40250 客户端不做此前置门,需按 §3.9
|
||
__CanShot 方式在 GDScript 防御性重建,精确 tie-break 顺序留待接服务器比对;
|
||
② §4.1 修改 4 —— 部位隐藏 / 遮挡(穿 costume 隐藏本体 armor、穿头盔隐藏发型),
|
||
InstanceBase.cpp 的 ChangeArmor / SetHair / __IsShapeAnimalWear,render 侧未接;
|
||
③ §4.1 修改 5 —— 远端角色 armor/weapon/hair 三字段接换模(net_world.gd 调用序已在 §2.5 对齐);
|
||
④ 扩展装备面板的图标 / tooltip / 拖放目标高亮沿用现有背包格逻辑,未做 costume/ring 专属外观。
|
||
|
||
下一批次(增量 97):§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、
|
||
§4.2 拖放鼠标物品态。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 97(W3→M2 / §4.1 修改 2 —— CanEquipNow 前置门)
|
||
|
||
```text
|
||
批次:增量 97 —— W3→M2 §4.1 修改 2(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
|
||
状态:🟡 §4.1 修改 2 完成(antiflag→wearable_flag→limit 装备校验链在客户端防御性重建);
|
||
§4.1 修改 4/5(render 侧部位隐藏 + 远端换装)仍为 seam,留待增量 98+。
|
||
|
||
参考:
|
||
REF/GameLib/ItemData.h
|
||
· EItemAntiFlag(classic 排列):FEMALE=1<<0 / MALE=1<<1 / WARRIOR=1<<2 / ASSASSIN=1<<3 /
|
||
SURA=1<<4 / SHAMAN=1<<5 / … / EMPIRE_A=1<<9 / EMPIRE_B=1<<10 / EMPIRE_R=1<<11
|
||
· EItemWearableFlag:WEARABLE_BODY=1<<0 … WEARABLE_UNIQUE=1<<7 / SHIELD=1<<8 / ARROW=1<<9
|
||
· EWearPositions:WEAR_BODY=0 … WEAR_SHIELD=10 / WEAR_MAX_NUM=11
|
||
· ELimitTypes:LIMIT_NONE=0 / LIMIT_LEVEL=1 / LIMIT_STR=2 / LIMIT_DEX=3 / LIMIT_INT=4 /
|
||
LIMIT_CON=5 / LIMIT_PCBANG=6 / LIMIT_REAL_TIME=7
|
||
· EItemType:ITEM_TYPE_WEAPON=1 / ARMOR=2 / COSTUME=28 / RING=33 / BELT=34
|
||
· EArmorSubTypes:ARMOR_BODY=0 / HEAD=1 / SHIELD=2 / WRIST=3 / FOOTS=4 / NECK=5 / EAR=6
|
||
· ECostumeSubTypes:COSTUME_BODY=0 / COSTUME_HAIR=1
|
||
REF/GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 均为纯表 getter,
|
||
客户端无 CanEquipNow(仲裁在服务器 char_item.cpp)。
|
||
REF/UserInterface/PythonItemModule.cpp:185/207 —— uiscript 只拿到 itemIsWearableFlag / itemGetLimit。
|
||
REF/UserInterface/GameType.h:40-112 —— 新装备槽 c_Costume_Slot_Body=90+19 / Hair=90+20 /
|
||
Ring1=90+21 / Ring2=90+22 / Belt=90+23。
|
||
common length.h MAIN_RACE_* —— job = race%4;race 0..7 性别表见 SEX_BY_RACE。
|
||
|
||
改动:
|
||
project/equip_rules.gd(新增,extends RefCounted 纯校验器)
|
||
· 全部枚举常量 1:1 自 ItemData.h / GameType.h(见上「参考」)
|
||
· sex_of_race(race) / job_of_race(race)=race%4 —— 表 1:1 自 MAIN_RACE_*
|
||
· default_wear_for(item):type/sub → 默认 wear 槽(WEAPON→4 / COSTUME→19|20 / RING→21 /
|
||
BELT→23 / ARMOR→match sub);不可穿戴 → -1。is_equippable() = default_wear_for >= 0
|
||
· wear_to_wearable(wear):wear 槽 → 需要的 WEARABLE_* 位;新槽 19..23 → 0
|
||
· slot_accepts(item, wear):新槽(19/20 costume、21/22 ring、23 belt)按 item type;
|
||
旧 11 槽按 (wear_flags & wear_to_wearable(wear)) != 0
|
||
· can_equip(item, wear, ctx) -> {ok, code, need}:
|
||
item 空 → NO_ITEM
|
||
target = wear>=0 ? wear : default_wear_for(item);target<0 → NOT_EQUIPPABLE
|
||
anti-flag 性别(sex==FEMALE & ANTIFLAG_FEMALE / sex==MALE & ANTIFLAG_MALE)→ ANTI_SEX
|
||
anti-flag 职业(anti & JOB_ANTIFLAG[job])→ ANTI_JOB
|
||
anti-flag 帝国(EMPIRE_ANTIFLAG[ctx.empire] 命中)→ ANTI_EMPIRE
|
||
not slot_accepts(item, target) → NOT_WEARABLE_HERE
|
||
limits 逐条:value<=0 跳过;LIMIT_LEVEL:ctx.level<v → LIMIT_LEVEL(need=v);
|
||
LIMIT_STR/DEX/INT/CON:_stat(ctx,key) >= 0 且 < v → 对应 code(need=v);
|
||
PCBANG / REAL_TIME* → pass(服务器判,seam)
|
||
全过 → {ok:true, code:"", need:0}
|
||
· ctx:{race, sex, job, empire, level, st, dx, ht, iq};sex/job 缺省由 race 推;
|
||
属性 st/dx/ht/iq 缺省或 <0 = 不判(M2Client 未按名暴露 → seam)
|
||
project/ui/inventory_ui.gd
|
||
· const EquipRules = preload("res://equip_rules.gd")
|
||
· signal equip_rejected(code: String, need: int)
|
||
· use(ui_idx) 重写:ui_idx < EQUIP_BASE(常规背包格)且 proto.item(vnum) 可穿戴时,
|
||
跑 EquipRules.can_equip(pi, -1, _equip_ctx());not ok → _set_hint(_equip_reject_hint(verdict))
|
||
+ equip_rejected.emit(code, need) + return false(不发 use_item);否则维持 _to_wire + use_item
|
||
· 新增 can_equip_item(vnum, wear=-1) -> Dictionary(公开查询)
|
||
· 新增 _equip_ctx():get_points().level + get_entities() 主角 race/empire/level;st/dx/ht/iq = -1
|
||
· 新增 _equip_reject_hint(verdict):ANTI_SEX/ANTI_JOB/ANTI_EMPIRE/NOT_WEARABLE_HERE/LIMIT_* → 中文
|
||
project/equip_rules_test.gd(新增,canonical #17)—— 33 断言:race 表、default_wear_for 各
|
||
type/sub、slot_accepts、anti-flag 性别/职业/帝国、NOT_WEARABLE_HERE、LIMIT_LEVEL/STR/CON
|
||
含 need + 属性 -1 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 NO_ITEM
|
||
project/inventory_ui_test.gd —— FakeProto.item() 补 sub_type=0 / anti_flags=0 /
|
||
wear_flags=1<<4 / limits=[] 让 iv.use(5)(vnum 19 武器)仍过新前置门
|
||
|
||
测试:M2_ASSETS=/Users/shenlei/Work/mt/metin2-client/assets ctest --test-dir build → 21/21
|
||
(无 C++ 改动);56 个 GDScript 回归全 PASS(新增 equip_rules_test;inventory_test /
|
||
inventory_ui_test 保持绿)。
|
||
|
||
验收:纯离线。装备校验链枚举与 ItemData.h 逐值对齐;语义顺序(antiflag→wearable→limit)对齐
|
||
服务器 char_item.cpp::CanEquipNow;背包右键穿戴被前置门拦下时不发 use_item 并 emit
|
||
equip_rejected。
|
||
已文档化 seam:
|
||
① 精确 tie-break 顺序 / 已穿同槽替换 / LIMIT_PCBANG / LIMIT_REAL_TIME* —— 留待接服务器比对;
|
||
② 属性 4 项(st/dx/ht/iq)M2Client 未按名暴露 → _equip_ctx() 传 -1(对应 limit 项被跳过);
|
||
③ §4.1 修改 4(部位隐藏 / 遮挡)+ 修改 5(远端换装接字段)—— render 侧,留待增量 98+。
|
||
|
||
下一批次(增量 98):§4.1 修改 4/5(render 侧)、§5.2 任务日志窗口、
|
||
§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 98(W3→M2 / §4.1 修改 4 —— 变身 / 时装部位遮挡 + 修改 5 字段序确认)
|
||
|
||
```text
|
||
批次:增量 98 —— W3→M2 §4.1 修改 4(部位遮挡)+ 修改 5(远端换装字段序)(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
|
||
状态:🟡 §4.1 修改 4 完成(变身 / 动物时装 / 婚纱的整件遮挡在渲染侧 1:1 重建);
|
||
修改 5 字段序已确认 1:1(增量 46 落地),远端 PC 换模链要等真模型工厂(增量 99+);
|
||
变身态本身是 seam(40250 实体表无变身字段,_is_poly 恒 false)。
|
||
|
||
参考:
|
||
REF/UserInterface/InstanceBase.cpp(ISO-8859-1;grep -a)
|
||
· IsPoly() → SetShape(0);SetWeapon / SetParts 提前 return
|
||
—— 变身:身体 shape 0、无武器、无头发 / 挂件
|
||
· __IsShapeAnimalWear()(:2834)= GetShape() ∈ {100,101,102,103}
|
||
—— SetWeapon(:2747)/ SetParts 返回 false:穿动物 / 怪物时装时武器 + 头发被抑制
|
||
· IsWearingDress() = m_eShape == 201(婚纱)
|
||
· __IsChangableWeapon(iWeaponID)(:901)—— 婚纱状态除 c_iBouquets 外一律 false;
|
||
c_iBouquets = {50201, 50202, 50203, 50204}(原表 0 结尾)
|
||
· __ArmorVnumToShape(iVnum, *pdwShape)(:281)—— 默认 *pdwShape = iVnum;
|
||
iVnum ∉ {0,1} 且 USE_ARMOR_SPECULAR 时 *pdwShape = pItemData->GetValue(3)(SHAPE_VALUE_SLOT_INDEX=3)
|
||
· ChangeArmor(:2780)—— SetRace(rebuild) → SetArmor → SetHair → SetWeapon → SetRotation →
|
||
__AttachHorseSaddle → RefreshState(NAME_WAIT,TRUE) → OnChangeShape()
|
||
REF/UserInterface/NetworkActorManager.cpp:457 UpdateActor —— 远端换装序(:473-482):
|
||
ChangeArmor(m_dwArmor) → ChangeWeapon(m_dwWeapon) → ChangeHair(m_dwHair) → ChangeGuild →
|
||
SetAffectFlagContainer → SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags
|
||
extension/src/net/entity_store.h:57 —— ch_type:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP
|
||
(m2_client.cpp:2429 暴露为 d["ch_type"]);entity dict 无 poly_race / polymorph 字段。
|
||
|
||
改动:
|
||
project/part_hiding.gd(新增,extends RefCounted,纯函数无状态)
|
||
· POLY_SHAPE = 0
|
||
· ANIMAL_WEAR_SHAPES = [100, 101, 102, 103] # __IsShapeAnimalWear
|
||
· WEDDING_DRESS_SHAPE = 201 # IsWearingDress
|
||
· BOUQUET_VNUMS = [50201, 50202, 50203, 50204] # c_iBouquets
|
||
· is_animal_wear(shape) / is_wearing_dress(shape) / is_bouquet(vnum)
|
||
· is_changable_weapon(weapon_vnum, shape):婚纱 → 仅捧花;否则 true
|
||
· weapon_hidden(shape, is_poly, weapon_vnum=0):is_poly ∨ animal_wear ∨ (dress ∧ ¬bouquet)
|
||
· hair_hidden(shape, is_poly):is_poly ∨ animal_wear(婚纱不影响头发)
|
||
· body_shape(shape, is_poly):is_poly → 0;否则原样(动物时装 100-103 保留,用来出时装体)
|
||
· effective_weapon(weapon_vnum, shape, is_poly):weapon_hidden → 0;否则原值
|
||
project/ui/equip_model.gd
|
||
· const PartHiding = preload("res://part_hiding.gd")
|
||
· refresh() 重排:身体 → 头 / 发 → 武器 → 盾(对齐 ChangeArmor 的 SetArmor→SetHair→SetWeapon)
|
||
· 解出 raw_shape = armor_shape_of.call(body_vnum)(= __ArmorVnumToShape:proto values[3] / =vnum)
|
||
· is_poly = _is_poly()(新增,seam 恒 false)
|
||
· hair_off = PartHiding.hair_hidden(raw_shape, is_poly):真 → 清 hair_gr2 / hair_skin,
|
||
head_key 用 -1;_last_head_vnum 哨兵从 -1 改 -2(-1 现在是「发型被遮挡」的有效态);
|
||
恢复时 _last_hair_part = -1 强制重挂
|
||
· wpn_eff = PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) → _resolve_weapon
|
||
· 盾:sh_eff = PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) ? 0 : sh_vnum
|
||
· _is_poly()(新增):return false —— 40250 实体表暂无变身字段,seam
|
||
project/net_world.gd
|
||
· _apply_field_updates() 头注补 §4.1 修改 5:armor/weapon/hair 字段序已 1:1 于
|
||
NetworkActorManager.cpp:473;变身 / 时装遮挡统一走模型侧 part_hiding.gd;
|
||
当前 seam —— 远端 PC 是占位胶囊(_make_entity_model 对 ch_type==0 返回 null),
|
||
set_armor/weapon/hair 是 no-op
|
||
project/part_hiding_test.gd(新增,canonical #18)—— 45 断言:
|
||
animal-wear 全集(100-103 真 / 0,5,99,104,201 假)、dress 判定、bouquet 判定、
|
||
is_changable_weapon(婚纱+捧花 / 婚纱+剑 / 普通 shape / 动物 shape 不被此门拦)、
|
||
weapon_hidden 各分支、hair_hidden(婚纱不隐藏头发)、body_shape(变身→0 / 动物时装保留)、
|
||
effective_weapon 各分支
|
||
project/equip_model_test.gd —— 补 7 段:动物时装(armor_shape_of 桩返回 101)遮挡
|
||
weapon_gr2 / shield_gr2 / hair_gr2、脱时装武器重解析、婚纱(桩返回 201)遮武器保头发、
|
||
婚纱换捧花(vnum 50201)_last_weapon_vnum 恢复为 50201
|
||
|
||
测试:M2_ASSETS=/Users/shenlei/Work/mt/metin2-client/assets ctest --test-dir build → 21/21
|
||
(无 C++ 改动);57 个 GDScript 回归全 PASS(新增 part_hiding_test;equip_model_test /
|
||
equip_rules_test / inventory_ui_test 保持绿)。
|
||
|
||
验收:纯离线。part_hiding.gd 常量与 InstanceBase.cpp 逐值对齐;equip_model.refresh() 刷新序
|
||
对齐 ChangeArmor;动物 / 怪物时装隐藏武器+盾+发、婚纱非捧花隐藏武器+盾且头发保留、
|
||
脱下后恢复。
|
||
已文档化 seam:
|
||
① 变身态 —— m2_client.cpp entity dict 无 poly_race / polymorph,_is_poly() 恒 false;
|
||
等 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带变身 race 后接 client.get_entity(vid).poly_race;
|
||
② 远端 PC 的真模型工厂不属于本批次,已在增量 99 首版落地;本批次当时仍只确认字段序,
|
||
未把“字段已到位”误记为“远端模型已完成”。
|
||
|
||
下一批次(增量 99):远端 PC 真模型工厂(PlayerView + 只认 parts[] 的远端 EquipModel)、
|
||
§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 99(W3→M2 / §4.1 修改 5 —— 远端 PC 真模型工厂与 awPart[4])
|
||
|
||
```text
|
||
批次:增量 99 —— W3→M2 §4.1 修改 5(M2 玩法 + 渲染接线;纯 GDScript,无 C++ 改动)
|
||
|
||
状态:🟡 部分完成(远端 PC 首版真模型与四字段换装链已落地;所有职业 / race、变身和真服视觉仍待验收)。
|
||
参考:
|
||
REF/UserInterface/Packet.h:1282-1289 —— CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 /
|
||
HAIR=3 / CHR_EQUIPPART_NUM=4;
|
||
REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:113-115,160-162,193-195 ——
|
||
角色附加信息和更新包携带 awPart 数组;
|
||
REF/UserInterface/NetworkActorManager.cpp:473-482 —— ChangeArmor → ChangeWeapon → ChangeHair
|
||
→ ChangeGuild → affects / move speed / attack speed / alignment / PK / state flags。
|
||
改动:
|
||
project/ui/remote_player_view.gd(新增)—— 复用 player_view.gd 的 race / GR2 / MSA 建模,
|
||
创建远端专用 EquipModel,只消费四槽 awPart[ARMOR..HAIR],提供四个部位 setter;
|
||
project/ui/equip_model.gd —— 新增 setup_remote() / _remote_parts,远端模式不读取
|
||
client.get_equipment(),四个远端部位变更后刷新 PartHiding / 真实模型;
|
||
project/game_scene.gd —— 默认实体工厂对 ch_type==0 创建 RemotePlayerView,按 race 缓存资源
|
||
可用性,失败时仍安全回退占位胶囊;怪 / NPC 继续走 MobView;
|
||
project/net_world.gd —— _apply_field_updates() 从旧的三字段假设改为
|
||
set_armor(parts[0]) → set_weapon(parts[1]) → set_head(parts[2]) → set_hair(parts[3]);
|
||
project/remote_player_test.gd(新增)—— 真实 warrior 资源建模、四槽换装及 NetWorld 字段序回归。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script remote_player_test.gd → PASS;
|
||
gamescene_test.gd、equip_model_test.gd、net_world_vis_test.gd、netplay_test.gd、
|
||
player_motion_test.gd → 全部 PASS。
|
||
验收:已验证真实 warrior 的基础模型 / 发型 / 武器加载,远端四槽数据不依赖本地装备快照,
|
||
以及字段调用顺序;未验证其他职业 / race、远端真实服务器洪流、poly_race / 变身、精确体型与
|
||
服装资源视觉、跨地图重建和真机性能。
|
||
风险与依赖:40250 当前 entity dict 没有 poly_race / polymorph,EquipModel._is_poly() 仍是
|
||
false;部分 race 若缺少可用 GR2/MSA 会按工厂失败回退占位,不应把回退视为模型支持。
|
||
下一批次:W3 继续 §5.2 任务日志 / §5.3 fishing、dungeon、mount、observer UI;W4 继续 EffectLib
|
||
精确 OUTER / INNER 与 advanced;W5 继续 Mac App G1/G2。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 100(W4 / §6.3 EffectLib emitter cycle 生命周期首版)
|
||
|
||
```text
|
||
批次:增量 100 —— W4 §6.3(EffectLib;GDScript,无 C++ 改动)
|
||
|
||
状态:🟡 部分完成(2026-09-03;`fx_test` 通过;精确 OUTER / INNER 与每粒子语义仍为 seam)
|
||
参考:`40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60`
|
||
(缺省 `CycleLength=0.05`、`CycleLoopEnable=false`、`LoopCount=0`);
|
||
`40250/ClientVS22/source/EffectLib/ParticleSystemInstance.cpp:280-300`
|
||
(cycle 边界、LoopCount 递减和停止发射);
|
||
`ParticleSystemInstance.cpp:153-209`(OUTER / INNER 按粒子半径计算速度)。
|
||
改动:
|
||
`project/fx/effect_player.gd` —— 区分缺省 cycle 与显式零值;保存 cycle / loop / loop count 元数据;
|
||
有限 cycle 到期停止 GPU 发射,重播复位 CPU 时钟;`_longest_life()` 将发射窗口与粒子寿命分开,
|
||
删除原来用 cycle 覆盖 `GPUParticles3D.lifetime` 的错误;OUTER / INNER 保留 source mode 并使用
|
||
明确记录的 `ParticleProcessMaterial.spread=180` 宽方向 fallback。
|
||
`project/fx_test.gd` —— 增加 cycle、LoopCount、独立粒子寿命和 OUTER fallback 断言。
|
||
测试:`/opt/homebrew/bin/godot --headless --path project --script fx_test.gd` 通过;
|
||
输出 `PASS: fx_test`。本批次没有 C++ 改动,因此未把 CTest 结果冒充为本批次证据。
|
||
验收:已验证缺省值、有限 cycle / LoopCount 停止、重播状态复位和粒子寿命不被 cycle 覆盖;
|
||
未验证真实 40250 特效视觉、每周期 emission residue / 时间曲线重置、OUTER / INNER 精确径向速度、
|
||
随机起始帧 / 每粒子随机方向与帧语义、骨骼挂点和真机视觉。
|
||
风险与依赖:Godot `ParticleProcessMaterial` 没有按每粒子初始位置求径向速度的属性;`spread=180`
|
||
只是显式 fallback,不能称为 FREE / OUTER / INNER 的 1:1 实现。后续需要独立 particle shader
|
||
或 CPU 粒子实例路径,并同时保留参考的 emission residue、时间曲线和纹理随机语义。
|
||
下一批次:W4 继续 EffectLib 精确 advanced / TexAni;W3→M2 继续
|
||
fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态与 §4.3 tooltip;W5 继续 Mac App G1/G2。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 101(W3→M2 / §5.2 Quest 日志窗口)
|
||
|
||
```text
|
||
批次:增量 101 —— W3→M2 §5.2(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(离线 `quest_test` 通过;真实 Quest 生命周期、导航表现和真服资源仍未验)
|
||
参考:`assets/root/uicharacter.py:651-703,1339-1346`(任务行选择、5 行窗口、clock 与滚动);
|
||
`40250/ClientVS22/source/UserInterface/PythonQuest.h/.cpp`(`QUEST_MAX_NUM=5`、默认 icon、
|
||
quest index / clock 数据;源码路径按当前 40250 checkout 记录)
|
||
改动:
|
||
`project/ui/quest_log.gd` —— 新增 `QUEST_MAX_NUM=5`、任务快照、可见行 / 起始索引、滚动条和
|
||
离散滚动比例;无 icon 时使用参考默认 `season1/icon/scroll_open.tga`;任务行点击发送
|
||
`script_button(-2147483648 + questIndex)` 并发出 `quest_selected`;保留独立 QuestButton
|
||
条带的原有 `script_button(index)` 路径;
|
||
`project/quest_test.gd` —— 新增 7 条任务,覆盖只显示 5 行、滚动到最后 5 行、QuestButton
|
||
负基址参数和空 icon 回退路径。
|
||
测试:`/opt/homebrew/bin/godot --headless --path project --script quest_test.gd`;PASS。
|
||
验收:未验证——真实服务端 RegisterQuest / SetQuestClockValue 的 start time、任务完成 / 删除
|
||
生命周期、真实 icon / `.sub` 视觉、世界箭头、屏幕边缘指示、多列定位和真机布局。
|
||
风险与依赖:当前 M2Client 任务字典没有把 `iStartTime` 单独透出,因此 clock 仍以现有服务端
|
||
`clock_value` 快照按窗口打开后递减;要完全对齐参考端需先把 server start time / refresh 语义
|
||
接入数据层。多列定位和导航依赖后续 minimap / world marker 工作流。
|
||
下一批次:W3→M2 继续 §5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态和 §4.3
|
||
tooltip;W4 继续 EffectLib 精确 advanced / TexAni;W5 继续 Mac App G1/G2。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 102(W3→M2 / §5.3 钓鱼输入状态机)
|
||
|
||
```text
|
||
批次:增量 102 —— W3→M2 §5.3(M2 玩法;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(钓鱼输入 / 取消 / 水面扫描首版已落地;鱼获落袋、坐骑 / 副本 / 观战者仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24
|
||
—— 按下攻击键且处于 IsFishingMode 时进入 NEW_Fishing;方向键进入 NEW_CancelFishing;
|
||
40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:419-425,506-549
|
||
—— 地面点击先清预约 / 自动攻击,再取消钓鱼;取消间隔 500ms;NEW_Fishing 发送方向或失败回调;
|
||
40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108
|
||
—— GetFishingRot 仍是未迁移的水面角度扫描依据;
|
||
40250/ClientVS22/source/UserInterface/Packet.h
|
||
—— CG_FISHING / GC_FISHING 协议常量与事件。
|
||
改动:
|
||
project/net_play.gd —— 增加 fishing_feedback、钓鱼意图状态、五类事件状态转移;
|
||
钓鱼模式攻击键改发 CG_FISHING,不设置普通攻击键;方向由 MapCoord yaw 转服务端 heading;
|
||
activate_fishing() 处理特殊技能 123,on_ground_click() 清 reserved / auto attack 后取消,
|
||
cancel_fishing() 按 500ms 节流,保留 can_fishing_position 可选 seam。
|
||
project/player_controller.gd —— 增加方向键边沿 / 地面点击取消回调;WASD 长按只在边沿取消一次,
|
||
地面点击回调消费后不再继续移动。
|
||
project/game_scene.gd —— 装配上述两个取消入口,并把 fishing_feedback 接回 cannot_act 通道。
|
||
project/ui/quickbar.gd —— SPECIAL_SKILL + SKILL_INDEX_FISHING(123) 进入 activate_fishing()。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd
|
||
/opt/homebrew/bin/godot --headless --path project --script player_move_test.gd
|
||
/opt/homebrew/bin/godot --headless --path project --script skill_test.gd
|
||
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd
|
||
/opt/homebrew/bin/godot --headless --path project --script combat_fx_test.gd
|
||
以上均 PASS。
|
||
验收:
|
||
已验证钓鱼模式不会误发普通攻击;yaw=0 按 MapCoord 发送 270°;START 后可取消;
|
||
连续取消被 500ms 节流;方向键边沿、地面点击的顺序和特殊技能 123 入口均有回归覆盖。
|
||
风险与依赖:
|
||
Metin2World 已提供 GetFishingRot 等价的 ATTRIBUTE_WATER 扫描;扫描仅覆盖已加载区块,
|
||
无地图 provider 的协议-only 场景仍保留 fallback,流式换图后的 provider 覆盖需真实验收。
|
||
GC_FISHING(FISH) 仍沿用已有鱼获 / 失败日志路径,没有 item proto 校验、背包落袋或鱼获 UI。
|
||
坐骑 m_kHorse.CanAttack、真实模型 / MODE_HORSE* 动作 / 移速,副本 DungeonBlock / 结算窗,
|
||
观战自由视角和完整 ADD/REMOVE/MOVE 生命周期仍是独立缺口。
|
||
下一批次:
|
||
增量 104 优先实现 wrong-place locale 文案和鱼获 item proto / inventory 事务。坐骑、副本、
|
||
观战者继续拆成互不写同一文件的独立批次。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 103(W3→M2 / §5.3 GetFishingRot 水面扫描)
|
||
|
||
```text
|
||
批次:增量 103 —— W3→M2 §5.3(地图数据 + 钓鱼玩法;C++ 扩展与 GDScript 桥接)
|
||
状态:🟡 部分完成(GetFishingRot 水面扫描首版已落地;wrong-place 文案、鱼获事务和其他世界玩法仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108
|
||
—— GetFishingRot 以 600cm 距离、当前方向左右 0..180°、每 10° 检查水面;
|
||
40250/ClientVS22/source/UserInterface/InstanceBase.cpp:364-377
|
||
—— 水面判据是 CPythonBackground::isAttrOn(..., ATTRIBUTE_WATER);
|
||
40250/ClientVS22/source/PRTerrainLib/Terrain.h:64-72
|
||
—— ATTRIBUTE_WATER = (1 << 1)。
|
||
改动:
|
||
extension/src/metin2_world.h/.cpp —— 绑定 get_fishing_rotation() /
|
||
can_fishing_position();读取已加载 Chunk 的 attr.atr,按 MapCoord 对应的 world frame
|
||
在前方 6m 扫描当前 heading ±0..180°,返回第一个水面 heading;无水 / 未加载区块返回 -1。
|
||
project/net_world.gd —— 将实体层收到的查询转发给 Metin2World;无地图 provider 返回 -2
|
||
让 NetPlay 保留协议-only fallback。
|
||
project/net_play.gd —— 优先采用扫描选中的 heading;扫描失败发送 FISHING_WRONG_PLACE,
|
||
兼容旧 bool provider。
|
||
project/fishing_water_test.gd —— 加载 A1 全 20 区块,自动寻找真实 ATTRIBUTE_WATER 样本,
|
||
验证 600cm 查询、heading 返回、bool wrapper 和地图外拒绝。
|
||
测试:
|
||
cmake --build build -j2 —— PASS,libmtgodot.macos.template_debug.dylib 重新链接;
|
||
/opt/homebrew/bin/godot --headless --path project --script fishing_water_test.gd —— PASS;
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
验收:
|
||
A1 全 20 区块真实水面样本通过扫描;NetWorld → Metin2World 桥接可用;没有地图 provider
|
||
时不误报 wrong-place;有地图且无水面时返回 -1 并由 NetPlay 转 wrong-place。
|
||
风险与依赖:
|
||
当前水面查询依赖区块已加载;真实流式切换、角色出生点 / 水面边界和真服钓鱼动作仍需验收。
|
||
该批次只实现位置判定,不包含 OnFishingWrongPlace 的 locale 文案、FISH item proto 校验、
|
||
背包落袋和鱼获 UI。
|
||
下一批次:
|
||
增量 105+ 实现真实 `GC_ITEM_*` 鱼获 / inventory 事务;坐骑、副本、观战者继续保持独立文件边界
|
||
并行推进,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 104(W3→M2 / §5.3 钓鱼反馈消息)
|
||
|
||
```text
|
||
批次:增量 104 —— W3→M2 §5.3(钓鱼反馈消息;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(消息分支已落地;真实 `GC_ITEM_*` 背包事务、坐骑 / 副本 / 观战者仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860
|
||
—— `GC_FISHING` 的 START/STOP/REACT/SUCCESS/FAIL/FISH 分支;FAIL 调 `OnFishingFailure`,
|
||
FISH info=0 调 `OnFishingNotifyUnknown`,未知 item proto 静默返回,fish / non-fish 依据
|
||
item type 与 `IsFishing()` 分别调 `OnFishingNotify` / `OnFishingSuccess`。
|
||
assets/locale/locale/en/locale_game.txt
|
||
—— `FISHING_FAILURE`、`FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2`、
|
||
`FISHING_UNKNOWN`、`FISHING_WRONG_PLACE` 文案键。
|
||
改动:
|
||
project/ui/chat_ui.gd —— `setup()` 加载 `locale_game.txt`;新增 `set_proto()` /
|
||
`on_fishing_feedback()`;按 `RecvFishing` 状态与 item proto 类型选择 failure / unknown /
|
||
notify / success 文案;`GC_FISHING(SUCCESS)` 只更新钓鱼意图和动画,不直接打印“鱼钩命中”。
|
||
project/game_scene.gd —— 注入 item proto 给 `ChatUI`,连接 `fishing_feedback` 的 wrong-place
|
||
反馈;`chat_test.gd` 增加鱼类 item、active / inactive 文案和 wrong-place 回归。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
|
||
验收:
|
||
已验证 `FISHING_FAILURE`、`FISHING_UNKNOWN`、wrong-place、fish active / inactive 两组
|
||
locale 分支;未知 item proto 不产生日志;`SUCCESS` 不再被误报为鱼钩命中。
|
||
风险与依赖:
|
||
参考端的鱼获数量不是 `RecvFishing(FISH)` 直接修改,而是由独立 `GC_ITEM_*` 包完成;本批次
|
||
没有伪造本地落袋。下一批次需要先核对 item 包字段、库存事务和 `item proto`,再接鱼获 UI。
|
||
下一批次:
|
||
增量 105 已先接入坐骑攻击等级门;增量 106+ 继续实现真实 `GC_ITEM_*` 鱼获 / inventory 事务,
|
||
并行推进副本、观战者和坐骑模型,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 105(W3→M2 / §5.3 坐骑攻击等级门)
|
||
|
||
```text
|
||
批次:增量 105 —— W3→M2 §5.3(坐骑攻击限制;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(攻击等级门已落地;坐骑模型 / MODE_HORSE* 动作 / 移速和真服验收仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216
|
||
—— SHORSE::GetLevel 按 mount race/vnum 映射等级;SHORSE::CanAttack 在骑乘 level<=1 时返回 false;
|
||
40250/ClientVS22/source/UserInterface/InstanceBaseBattle.cpp:623-628
|
||
—— CInstanceBase::CanAttackHorseLevel 调用 SHORSE::CanAttack;
|
||
40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:737-764
|
||
—— __CanAttack 对新坐骑要求 GetSkillGrade(109)>=1 或 GetSkillLevel(109)>=11;
|
||
40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:226-235
|
||
—— __Update_AutoAttack 在骑乘且不能攻击时清除自动攻击目标。
|
||
改动:
|
||
project/net_play.gd —— 新增 _horse_level_for_mount()、_is_main_mounted()、_is_new_mount()、
|
||
_horse_skill_unlocked();接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门;
|
||
自动攻击改用 mount_vnum,不再只检查不存在的 mounting 字段。
|
||
project/netplay_test.gd —— 覆盖 20101 level 1 拒绝、20205 level 2 放行、
|
||
新坐骑 skill 109 level 1 拒绝 / level 11 放行。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
验收:
|
||
已验证离线坐骑 vnum→level 和骑乘术等级门;未验证真实坐骑模型、MODE_HORSE* 动作 / 移速、
|
||
new mount 的所有 race/vnum 及真服时序。
|
||
风险与依赖:
|
||
未知 mount vnum 按参考端 GetLevel()==0 放行;这是兼容未来坐骑资源,不等于已完成真服坐骑验收。
|
||
坐骑模型和动画仍依赖实体 mount 数据、资源可用性和后续渲染接线。
|
||
下一批次:
|
||
增量 106 已接入小地图 GC_OBSERVER_ADD/REMOVE/MOVE 的 1 秒平滑 / world_reset 清理;
|
||
增量 107 已接入副本目的地状态 / HUD 罗盘;增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、
|
||
DungeonBlock、观战自由视角和坐骑模型。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 106(W3→M2 / §5.3 观战者小地图)
|
||
|
||
```text
|
||
批次:增量 106 —— W3→M2 §5.3(观战者小地图轨迹;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(小地图轨迹已落地;自由视角、真服生命周期和换图时序仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180
|
||
—— AddObserver / MoveObserver / RemoveObserver 保存 observer 轨迹,MoveObserver 以 1 秒
|
||
线性插值到新坐标;PythonNetworkStreamCommand.cpp:295-310 的 ObserverMode 只切换
|
||
observer bool,本批次不把它扩展成参考端没有证据的独立自由相机。
|
||
改动:
|
||
project/ui/minimap.gd —— 新增 observer_event 消费、独立轨迹表、ADD 初始快照播种、MOVE 一秒
|
||
插值、REMOVE 删除,以及 world_reset 清理;不把 observer 当作 EntityStore 的可见实体。
|
||
project/p9_test.gd —— 覆盖 ADD / MOVE / REMOVE / reset 和插值中间态。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
验收:
|
||
已验证小地图 observer 的增删、移动平滑和地图重置清理;未验证真实服务器包时序、观战状态
|
||
生命周期、自由视角相机和真服地图切换。
|
||
风险与依赖:
|
||
参考端 ObserverMode 证据是输入 / 攻击 / 技能门控,不足以证明存在独立自由相机;实现前仍需
|
||
找到相机切换的真实参考调用或通过真服行为验收,不凭经验添加自由镜头。
|
||
下一批次:
|
||
增量 107 接入副本目的地状态 / HUD 罗盘;增量 108+ 继续推进 observer 生命周期验收和
|
||
真实 GC_ITEM_* 鱼获 / inventory 事务。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 107(W3→M2 / §5.3 副本目的地状态)
|
||
|
||
```text
|
||
批次:增量 107 —— W3→M2 §5.3(GC_DUNGEON 目的地消费与 HUD 罗盘;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(目的地消费 / 到达 / HUD 首版已落地;DungeonBlock、入口 / 结算和真服验收仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901
|
||
—— RecvDungeon 的 TIME_ATTACK_START 分支为空;DESTINATION_POSITION 读取两个 unsigned long,
|
||
调用 CPythonPlayer::SetDungeonDestinationPosition。
|
||
40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:147-167,1255-1285
|
||
—— 保存全局厘米坐标;当前位置与目标的 Manhattan 距离严格小于 10000cm 时清除目标,
|
||
未到达时每 20 秒重新 AlarmHaveToGo;收到目的地后立即创建目标罗盘特效。
|
||
改动:
|
||
project/dungeon_state.gd —— 消费 dungeon_event,保存原始全局厘米坐标和 MapCoord 世界坐标;
|
||
立即发出 alarm_requested,按参考端 Manhattan <10000cm 判定到达,每 20 秒再次提示,
|
||
并在 world_reset 清理。TIME_ATTACK_START 保持空分支,不虚构本地倒计时。
|
||
project/hud.gd —— 增加目标距离和相对朝向提示;这是参考端 AlarmHaveToGo 的 HUD 适配,
|
||
还没有加载参考 `.mse` 罗盘特效文件。
|
||
project/game_scene.gd —— 创建 DungeonState,接入 M2Client.dungeon_event / world_reset,
|
||
将 destination_changed 接到 HUD。
|
||
project/dungeon_state_test.gd —— 覆盖空计时分支、目的地坐标转换、立即提示、100m 边界、
|
||
到达清除、第二个目的地和 world_reset。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script dungeon_state_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
验收:
|
||
已验证 GC_DUNGEON 目的地状态的协议字段、厘米 / 米坐标转换、参考端到达边界和场景 reset;
|
||
未验证真实副本入口、DungeonBlock 的 `.mde` / `.mdatr` 解析与碰撞、9 参数结算窗、真服时序。
|
||
风险与依赖:
|
||
GC_DUNGEON 的目的地是全局坐标,不是当前区块局部坐标;真实地图 origin / 坐标基准仍需真服
|
||
场景验收。TIME_ATTACK_START 在参考源码中没有本地计时器,不能据此添加倒计时。
|
||
下一批次:
|
||
增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、DungeonBlock `.mdatr` collision、
|
||
副本入口 / 结算、observer 生命周期 / 相机证据、坐骑模型和 §4.2 / §4.3 剩余 UI 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 108(W3→M2 / §5.2 任务信标世界箭头 / 屏幕边缘指示)
|
||
|
||
```text
|
||
批次:增量 108 —— W3→M2 §5.2(小地图任务信标越界夹边 + 方向箭头;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(世界箭头 / 屏幕边缘指示首版已落地;多列定位、真实 `.sub` 状态图仍未完成)
|
||
参考:
|
||
40250/ClientVS22/source/UserInterface/PythonEventManager.cpp:554-570
|
||
—— EVENT_TYPE_ADD_MAP_SIGNAL / CLEAR_MAP_SIGNAL 调用 CPythonMiniMap::AddSignalPoint(x,y)
|
||
/ ClearAllSignalPoint() + OpenAtlasWindow()。
|
||
m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:1334-1358
|
||
—— AddSignalPoint:push TSignalPoint + AddWayPoint(TYPE_WAYPOINT, id, x, y, "");
|
||
ClearAllSignalPoint:逐 id RemoveWayPoint 后清表。
|
||
m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:441-462
|
||
—— Render 的 waypoint 夹边分支:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f;
|
||
fDistanceFromCenter = sqrt(dx*dx+dy*dy);>= radius 时 fRadian = atan2f(dy,dx),
|
||
dx = radius*cosf(fRadian)、dy = radius*sinf(fRadian),标记贴到圆周(→ __RenderTargetMark)。
|
||
说明:40250/ClientVS22 的 PythonMiniMap.cpp 为裁剪版,无 AddSignalPoint / 夹边渲染定义,
|
||
故夹边几何以 m2dev 完整版为对照基线(seam:该逻辑不在唯一基线 40250/ClientVS22 内)。
|
||
改动:
|
||
project/ui/minimap.gd —— 新增 const SIGNAL_RIM_INSET = 9.0(对齐 c_fMiniMapWindowRadius 的
|
||
`- 9.0f` 留边);新增 static signal_marker_geometry(delta_px, rim_radius) → {clamped, offset,
|
||
angle},1:1 复刻 atan2 + radius*cos/sin 夹边;新增 _signal_offset(world, origin)(世界 delta
|
||
→ 像素,轴向与 _to_map 一致);_draw_map 的信标循环改为:圆内画橙星(原样),越界调
|
||
_draw_signal_arrow(pos, angle) 在圆周画指向真实目标的三角箭头(对应 __RenderTargetMark 的
|
||
边缘指示,本工程无 `.sub` 资源用三角形代替)。_signal_points 存储与 add/clear_signal_point
|
||
入口不变。
|
||
project/p9_test.gd —— 小地图段新增 8 断言:圆内保留真实 offset、越界夹到圆周、夹边保留
|
||
正东 / 对角 atan2 朝向、恰在半径按 `>=` 计为越界、add/clear_signal_point 计数。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script atlas_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。
|
||
回归:p8_test / p10_test / net_world_vis_test / netplay_test 均 PASS。
|
||
验收:
|
||
已验证夹边几何(atan2 朝向保持、越界贴圆周、留边常量)、信标增删计数和重绘存活;
|
||
未验证真实 `.sub` waypoint / target 图标视觉、Atlas 同一信标的夹边、任务日志多列定位、
|
||
真实 EventManager ADDMAPSIGNAL 端到端。
|
||
风险与依赖:
|
||
夹边逻辑的对照基线是 m2dev 完整版而非唯一基线 40250/ClientVS22(后者为裁剪版,缺该实现);
|
||
§5.2 剩余「多列定位」属 quest_log.gd 布局侧,未在本批次推进;OpenAtlasWindow 联动
|
||
(ADDMAPSIGNAL 时自动打开 Atlas)仍由 quest_dialog 的 side effect 负责,未纳入本批次。
|
||
下一批次:
|
||
增量 109+ 推进 quest_log.gd 多列定位 / 真实图标视觉、§8.8 text_tail.gd(名字颜色对照基线
|
||
同样落在 m2dev 完整版)、§4.2 / §4.3 剩余 UI 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 109(W3→M2 / §5.2 任务信件按钮条多列定位)
|
||
|
||
```text
|
||
批次:增量 109 —— W3→M2 §5.2(quest 信件按钮条 __ArrangeQuestButton 1:1 多列布局;纯 GDScript,无 C++ 改动)
|
||
状态:🟡 部分完成(多列定位 + 最新在前 + 组队窗移位已落地;真实 `.sub` 信件图标 / 闪烁、任务日志窗体本身多列仍未做)
|
||
参考:
|
||
assets/root/interfacemodule.py:1357-1386 __ArrangeQuestButton
|
||
—— xPos = 130 (= 100 + 30) 若 wndParty.IsShow() 否则 20(+15 RTL,本迁移不做 RTL);
|
||
yPos = 170 * screenHeight // 600 ;yCount = (screenHeight - 330) // 63 ;
|
||
第 count 个:btn.SetPosition(xPos + (count // yCount) * 100, yPos + (count % yCount) * 63)。
|
||
assets/root/interfacemodule.py:1301-1353 BINARY_RecvQuest
|
||
—— 同 index 先 __DestroyQuestButton 再 questButtonList.insert(0, btn):无论新旧都移到最前;
|
||
__StartQuest / __DestroyQuestButton 后再 __ArrangeQuestButton 重排。
|
||
说明:40250/ClientVS22 的 source/ 树只含 C++,questbuttonlist 的 Python 布局在 assets/root/
|
||
interfacemodule.py(原版随包脚本),非 seam。
|
||
改动:
|
||
project/ui/quest_log.gd —— 新增布局常量(QUEST_BTN_X=20 / QUEST_BTN_X_PARTY=130 /
|
||
QUEST_BTN_COL_W=100 / QUEST_BTN_ROW_H=63 / QUEST_BTN_SIZE);新增有序表 _quest_button_order
|
||
(recv_quest 里 erase + push_front 复刻 insert(0, btn),clear_quest / _start_quest 同步 erase);
|
||
静态 quest_button_y_count(H)= maxi(1,(H-330)/63)(参考端无下限、H<393 会 ZeroDivision,夹 1 = 防御性偏离 seam)、
|
||
quest_button_y_pos(H)= 170*H/600、quest_button_slot_pos(slot,yCount,xPos,yPos)= 1:1 公式;
|
||
_refresh_quest_buttons 改为按 _quest_button_order 顺序、给每个 Button 绝对 position(不再 VBox 单列
|
||
sort by index);_button_root / _button_list 改为全屏 Control + MOUSE_FILTER_IGNORE,root 落原点、
|
||
坐标里已含 xPos/yPos;_button_list 类型 VBoxContainer → Control(消掉 gamescene_test 的 SCRIPT ERROR);
|
||
组队窗可见性经 set_party_probe(Callable) 在 _process 里轮询(party_ui 无信号),变化即重排;
|
||
另加 set_party_shown / set_screen_size_override / quest_button_order / quest_button_position 便于回归。
|
||
project/game_scene.gd —— party_ui 建好后 quest_log.set_party_probe(func(): return party_ui.is_open())。
|
||
project/quest_test.gd —— quest_log 段尾新增 16 断言:yCount(600/1080/350 夹边)、yPos(600/1200)、
|
||
6 个信件 insert-at-front 顺序、slot 0/3/4(换列)/5 坐标、Button 节点 position、同 index 移到最前、
|
||
组队窗 xPos→130 且节点重定位、clear 从 order 移除、清空后 _button_root 隐藏。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS(SCRIPT ERROR 消除)。
|
||
/opt/homebrew/bin/godot --headless --path project --script input_key_test.gd —— PASS。
|
||
回归:p8_test / p9_test / p10_test / net_world_vis_test 均 PASS。
|
||
验收:
|
||
已验证多列换列坐标、最新在前顺序、同 index 提前、组队窗 xPos 移位、清空隐藏。
|
||
未验证真实 `.sub` 信件图标 / highlight 视觉 / Flash 动画、RTL 的 +15、screenWidth 相关分支(原版未用)、
|
||
任务日志窗体(_list)自身的多列 / 分页、真实 EventManager QUESTBUTTON 端到端。
|
||
风险与依赖:
|
||
组队窗可见性靠 _process 轮询探针(party_ui 无 visibility-changed 信号),最坏 1 帧延迟;
|
||
screenHeight < 393 的 yCount 夹到 1 是防御性偏离(参考端会崩),实际分辨率不会触发;
|
||
__ArrangeQuestButton 每帧对全表 SetPosition,本迁移是「变化时重建」,功能等价但非逐帧。
|
||
下一批次:
|
||
增量 110+:任务日志窗体 _list 的多列 / 分页与真实 `.sub` 图标视觉、§8.8 text_tail.gd 名字颜色
|
||
(ENameColor,对照基线 m2dev 完整版)、§4.2 / §4.3 剩余 tooltip / 鼠标物品态分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 110(W3→M2 / §8.8 头顶名字色 ENameColor / GetNameColorIndex 1:1)
|
||
|
||
```text
|
||
批次:增量 110 —— W3→M2 §8.8 + §5.4:CInstanceBase::GetNameColorIndex / GetNameColor /
|
||
SetEmpireNameMode / GetIndexedNameColor 1:1(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(名字色算法 + 调色板 + net_world 接线;🔎 真服 GC_PVP / 公会战 / 决斗
|
||
下的边沿颜色待真机验收)
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,全部 ISO-8859 编码,
|
||
grep 需 -a):
|
||
UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄:
|
||
IsPC → (m_isKiller→PK) → (__IsExistMainInstance && !__IsMainInstance 时:
|
||
主角 GetDuelMode(): DUEL_CANNOTATTACK→NAMECOLOR_PC+GetEmpireID;
|
||
DUEL_START→__FindDUELKey? PVP : NAMECOLOR_PC+GetEmpireID;
|
||
IsSameEmpire(*this): __FindPVPKey→PVP / __FindGVGKey→PVP(__FindDUELKey 分支参考端注释掉);
|
||
else(异帝国)→PVP)
|
||
→ IsPartyMemberByVID→PARTY → NAMECOLOR_PC+GetEmpireID
|
||
IsNPC→NPC / IsEnemy→MOB / IsPoly→MOB / 否则 D3DXCOLOR(0xffffffff) 当 UINT 传 → 越界 → 白
|
||
UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameMode(g_isEmpireNameMode;
|
||
开:MOB=EMPIRE_MOB / NPC=EMPIRE_NPC / PC=NORMAL_PC / for u∈[1,4): PC+u=EMPIRE_PC+u;
|
||
关:MOB=NORMAL_MOB / NPC=NORMAL_NPC / for u∈[0,4): PC+u=NORMAL_PC)
|
||
UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColor(idx>=NAMECOLOR_NUM → 白)
|
||
UserInterface/InstanceBase.h:208/216 匿名枚举 EMPIRE_NONE/A/B/C(EMPIRE_NUM=4)+
|
||
NAMECOLOR_*(展开:MOB0 NPC1 PC2 NORMAL_MOB7 NORMAL_NPC8 NORMAL_PC9 EMPIRE_MOB14
|
||
EMPIRE_NPC15 EMPIRE_PC16 FUNC21 PK22 PVP23 PARTY24 WARP25 WAYPOINT26 NUM41)
|
||
UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_MOB/NPC/PC 常量
|
||
重映射到 C++ NAMECOLOR_NORMAL_MOB/NORMAL_NPC/NORMAL_PC;PK/PVP/PARTY/WARP/WAYPOINT/
|
||
EMPIRE_* 直通
|
||
assets/root/introloading.py:264 IntroLoading.__RegisterColor(chrmgr.RegisterNameColor 写槽)
|
||
assets/root/colorInfo.py:11 CHR_NAME_RGB_MOB(235,22,9) NPC(122,231,93) PC(255,215,76)
|
||
PK(180,100,0) PVP(238,54,223) PARTY(128,192,255) WARP(136,218,241) WAYPOINT(255,255,255)
|
||
EMPIRE_MOB(235,22,9) EMPIRE_NPC(122,231,93) EMPIRE_PC_A(157,0,0) _B(222,160,47) _C(23,30,138)
|
||
assets/root/constinfo.py:126 CHRNAME_COLOR_INDEX 默认 0 → SET_DEFAULT_CHRNAME_COLOR →
|
||
SetEmpireNameMode(0) → 帝国名色**关**(所有 PC 名字黄)
|
||
**无 seam**:40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetNameColorIndex,
|
||
字节级等同 m2dev 完整版(此前 grep 漏读是因 ISO-8859 被当二进制,-a 后确认);
|
||
增量 109 批次记录里「对照基线 m2dev 完整版」的说法据此更正。
|
||
改动:
|
||
project/name_color.gd(新)—— 纯静态 `extends RefCounted`:
|
||
NAMECOLOR_* 常量 + EMPIRE_NUM=4;_registered_slots()(introloading 注册后的 g_akD3DXClrName
|
||
实际槽,chrmgr 常量重映射已折进);_palette + _empire_name_mode 静态态;
|
||
set_empire_name_mode(bool)(:52 逐行,含开分支 for u∈[1,4)、关分支 for u∈[0,4));
|
||
indexed_name_color(idx)(:75,越界 → 白);name_color_index(self_e, main_e, ctx)
|
||
(:525 逐行,复用 entity_rules._is_pc/_is_killer/_is_enemy/_is_poly/_is_same_empire/
|
||
_find_duel_key/_pair_in + DUEL_* 常量,本地补 _is_npc/_empire_id);name_color()=GetNameColor。
|
||
ctx 与 net_play._attack_ctx 同构(main_vid/duel_mode/duel_opponents/pvp_pairs/gvg_pairs/party_vids)。
|
||
偏离:__FindGVGKey 加 guild!=0 守卫(参考端 guild 0 的 PVPKEY 恒 0、集合里不会有;
|
||
与 entity_rules._is_pvp_instance 既有防御一致)。
|
||
project/net_world.gd —— const NameColor / EntityRules;var kind_resolver(func(dict)->int,
|
||
game_scene 注入 net_play._entity_kind,补本 fork「NPC/怪 bType 都 0」的分类缺口,未注入时退化 ch_type);
|
||
_name_color_ctx()(自 client.get_duel/get_pvp_relations/get_guild_wars/get_party 拼 ctx)、
|
||
_main_name_snapshot()、_apply_name_color(vid)(写头顶 "Label3D".modulate)、_retint_all_names()、
|
||
set_empire_name_mode(bool) 透传;单发刷新点:_on_spawn / _on_info / _on_pvp_changed(src+dst)/
|
||
_on_duel_changed(全表)/ _on_main_set(全表);_process 里 ~2Hz(accum≥0.5s)整表兜底
|
||
(参考端 RenderName 每帧都算 GetNameColor)。删掉 _apply_anim / _on_motion 里
|
||
占位胶囊借 "Label3D".modulate 表现动作态的 _state_tint(该 modulate 现专属名字色),
|
||
_state_tint 函数一并删除。
|
||
project/game_scene.gd —— name_resolver 注入旁边加 `net_world.kind_resolver = net_play._entity_kind`。
|
||
project/name_color_test.gd(新)—— SceneTree 回归,约 55 断言:NAMECOLOR_* 序号、
|
||
帝国名色关/开两态、GetNameColorIndex 每分支(killer→PK、party→PARTY、异帝国→PVP、
|
||
victim empire 0 视同帝国、pvp/gvg 对表→PVP、决斗 CANNOTATTACK/START 两态、无主角实例、
|
||
自己、NPC/MOB/poly/未知)、killer 压过 party、GetIndexedNameColor 越界→白、
|
||
每个 ENameColor 槽解析出的 colorInfo RGB。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script name_color_test.gd —— PASS。
|
||
回归全 PASS:gamescene_test / netbridge_test / mob_view_test / net_world_vis_test / netplay_test /
|
||
entity_rules_test / quest_test / quest_event_test / input_key_test / p8_test / p9_test / p10_test /
|
||
remote_player_test / player_motion_test / combat_fx_test / skill_fx_test / player_move_test /
|
||
player_skill_test / app_flow_lifecycle_test / fly_test / channel_status_test。
|
||
验收:
|
||
已验证名字色算法逐分支、SetEmpireNameMode 调色板重写、越界回退、colorInfo RGB 映射(离线)。
|
||
未验证:真服 GC_PVP / GUILD_GC_WAR / 决斗包驱动下的实时颜色切换、帝国名色开关的选项 UI
|
||
(目前只有 net_world.set_empire_name_mode 运行期钩子,未挂 uisystemoption)、真模型(GRN)
|
||
头顶 Label3D 与占位胶囊路径的一致性需真机目视。
|
||
风险与依赖:
|
||
net_world 无 proto,实体分类靠注入的 kind_resolver=net_play._entity_kind;未注入(裸 net_world
|
||
测试 / headless 无 assets)时退化到 ch_type,m2dev 后端 NPC/怪 bType=0 会被当 PC(同 §5.4 既有 seam ⑥)。
|
||
__FindPVPKey / __FindGVGKey 的 key 集合 setter 仍是 §5.4 seam ①②(get_pvp_relations /
|
||
get_guild_wars 有值则消费)。名字色整表刷新 ~2Hz(非逐帧),队伍 / 关系变化最坏 0.5s 延迟。
|
||
静态 _palette / _empire_name_mode 是进程级单例(与参考端 g_akD3DXClrName / g_isEmpireNameMode 一致)。
|
||
下一批次:
|
||
§8.8 text_tail.gd(称号色 GetTitleColor / 公会名 / 屏幕空间血条 / 聊天气泡整合、
|
||
LALT + §8.4 三 radio 显示开关)、目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动、
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 111(W3→M2 / §8.8 头顶「公会名 / 称号 / 等级」子标签 1:1)
|
||
|
||
```text
|
||
批次:增量 111 —— W3→M2 §8.8:CInstanceBase::GetTitleColor / RefreshTextTail /
|
||
AttachTextTail / UpdateTextTailLevel + CPythonTextTail 的公会名 / 称号 / 等级子标签、
|
||
EnablePKTitle 1:1(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(称号色算法 + 等级文本 + 公会名兜底 + net_world 三子标签接线 + game_scene
|
||
title_name_resolver 注入;🔎 真服 alignment / GC_GUILD 变化下的实时刷新、真模型头顶
|
||
排版目视待真机验收)
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp
|
||
全部 ISO-8859 编码,grep 需 -a):
|
||
UserInterface/InstanceBaseEffect.cpp:609 GetTitleColor —— uGrade = GetAlignmentGrade();
|
||
if (uGrade >= TITLE_NUM(9)) return s_kD3DXClrTitleDefault(白); return g_akD3DXClrTitle[uGrade];
|
||
UserInterface/InstanceBaseEffect.cpp:664 RefreshTextTail —— SetCharacterTextTailColor(GetNameColor());
|
||
iAlignmentGrade = GetAlignmentGrade();
|
||
if (TITLE_NONE(4) == iAlignmentGrade) DetachTitle();
|
||
else if (g_TitleNameMap.find(grade) 命中) AttachTitle(name, GetTitleColor());
|
||
UserInterface/InstanceBaseEffect.cpp:684 RefreshTextTailTitle → RefreshTextTail
|
||
UserInterface/InstanceBaseEffect.cpp:621 AttachTextTail —— fTextTailHeight = IsMountingHorse()?110:10;
|
||
RegisterCharacterTextTail(m_dwGuildID, dwVID, 白, fTextTailHeight);
|
||
if (m_dwLevel) UpdateTextTailLevel(m_dwLevel);
|
||
UserInterface/InstanceBaseEffect.cpp:654 UpdateTextTailLevel —— s_kLevelColor =
|
||
D3DXCOLOR(152/255, 255/255, 51/255, 1.0); sprintf(szText, "Lv %d", level);
|
||
AttachLevel(vid, szText, s_kLevelColor);
|
||
UserInterface/InstanceBaseEffect.cpp:1107 RegisterTitleName —— g_TitleNameMap[iIndex] = c_szTitleName
|
||
UserInterface/InstanceBaseEffect.cpp:1131 RegisterTitleColor —— g_akD3DXClrTitle[u] =
|
||
__RGBToD3DXColoru(r,g,b)(= D3DXCOLOR(r/255, g/255, b/255))
|
||
UserInterface/InstanceBase.h:262/263 TITLE_NUM = 9 / TITLE_NONE = 4
|
||
UserInterface/InstanceBase.cpp:2053 SetAlignment(sAlignment) → RefreshTextTailTitle()
|
||
UserInterface/InstanceBase.cpp:2079 SetPKMode 末尾 → RefreshTextTail()
|
||
UserInterface/PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF(ARGB → RGB 239,211,255)
|
||
UserInterface/PythonTextTail.cpp:23/938 bPKTitleEnable = TRUE / EnablePKTitle(bFlag)
|
||
UserInterface/PythonTextTail.cpp:509/543 RegisterCharacterTextTail —— dwGuildID != 0 才
|
||
New() 公会名 CGraphicTextInstance;if (!CPythonGuild::GetGuildName(dwGuildID, &str)) str = "Noname";
|
||
SetColor(c_TextTail_Guild_Name_Color)
|
||
UserInterface/PythonTextTail.cpp:890/943 AttachTitle / AttachLevel —— 均 if (!bPKTitleEnable) return;
|
||
AttachTitle EU 右对齐 / 否则居中;AttachLevel 恒右对齐
|
||
assets/root/introloading.py:260 __RegisterTitleName —— for i: chrmgr.RegisterTitleName(i, TITLE_NAME_LIST[i])
|
||
assets/root/introloading.py:287 __RegisterColor 的 TITLE_COLOR_DICT(注册序 0..8):
|
||
GOOD_4 GOOD_3 GOOD_2 GOOD_1 NORMAL EVIL_1 EVIL_2 EVIL_3 EVIL_4
|
||
assets/root/colorInfo.py:29 TITLE_RGB_GOOD_4(0,204,255) _3(0,144,255) _2(92,110,255) _1(155,155,255)
|
||
NORMAL(255,255,255) EVIL_1(207,117,0) _2(235,83,0) _3(227,0,0) _4(255,0,0)
|
||
assets/root/localeInfo.py:215 TITLE_NAME_LIST = (PVP_LEVEL0 .. PVP_LEVEL8,)
|
||
(en locale_game.txt:441-449 Chivalric/Noble/Good/Friendly/Neutral/Aggressive/Fraudulent/Malicious/Cruel)
|
||
assets/root/constinfo.py:4 + game.py:155 PVPMODE_ENABLE = 1;textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE)
|
||
→ 起始即启用(本移植 _pk_title_enabled 默认 true)
|
||
**无 seam**:40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetTitleColor /
|
||
RefreshTextTail / UpdateTextTailLevel / RegisterTitleName / RegisterTitleColor
|
||
(:609/:664/:654/:1107/:1131),字节级等同 m2dev 完整版。
|
||
改动:
|
||
project/text_tail.gd(新)—— 纯静态 `extends RefCounted`:
|
||
TITLE_NUM=9 / TITLE_NONE=4;_title_colors()(g_akD3DXClrTitle 的 9 槽,Color8=D3DXCOLOR(x/255));
|
||
LEVEL_COLOR = Color(152/255,255/255,51/255);GUILD_NAME_COLOR = Color(239/255,211/255,255/255);
|
||
GUILD_NAME_FALLBACK = "Noname";_pk_title_enabled 静态态 + set/is_pk_title_enabled(EnablePKTitle);
|
||
title_color(grade)(:609,grade<0||>=TITLE_NUM → 白);title_name_key(grade)="PVP_LEVEL%d";
|
||
level_text(level)="Lv %d";text_tail_height(mounted)=110|10;
|
||
refresh_title(alignment) → {show_title, grade, name_key, color}(复用 entity_rules.alignment_grade,
|
||
grade==TITLE_NONE 或 !pk_title 时 show_title=false);
|
||
refresh_level(level) → {show_level=(pk_title && level>0), text, color};
|
||
refresh_guild(guild_id, resolved_name) → guild_id==0 不显示 / 否则 name 空串兜底 "Noname"。
|
||
偏离:EnablePKTitle(FALSE) 时参考端 Attach/Detach 均早退=「冻结上一态」,本移植 refresh_* 返回
|
||
show=false=「隐藏」(seam ⑦ 之外的小偏离,已在 §8.8 修改 2 与代码注释标注)。
|
||
project/net_world.gd —— const TextTail;var title_name_resolver(func(grade)->String,
|
||
game_scene 注入 Locale.t("PVP_LEVEL%d");未注入回退裸 key=seam ⑧);
|
||
_attach_nameplate 加 GuildTag(y=2.34)/TitleTag(y=1.86)/LevelTag(y=1.74) 三个 Label3D 子节点
|
||
(billboard + no_depth_test + pixel_size 0.005,初始 text="" visible=false),经 _mk_sub_tag() 造;
|
||
_set_name_visibility 同步开关三子标签(visible = _names_visible && text != "");
|
||
_resolve_title_name / _resolve_guild_name(后者走 client.get_guild_name(gid));
|
||
_apply_text_tail_to(n, e) / _apply_text_tail(vid) / _retint_all_text_tails()(姊妹于 _apply_name_color 系);
|
||
刷新点:_on_spawn / _on_info 单发 + _process ~2Hz(与名字色同一 accum≥0.5s)整表兜底;
|
||
set_pk_title_enabled(bool) 透传 TextTail + 整表重刷。
|
||
project/game_scene.gd —— kind_resolver 注入旁加 title_name_resolver:新建 Locale("en"),
|
||
lambda 查 PVP_LEVEL%d(has() 命中才返回,否则空串让 net_world 走 seam ⑧ 回退)。
|
||
project/text_tail_test.gd(新,第 22 个 canonical)—— SceneTree 回归,约 50 断言:
|
||
常量(TITLE_NUM/NONE、LEVEL/GUILD 色、"Noname")、title_color 9 槽 + 越界→白、
|
||
title_name_key / level_text / text_tail_height 字面、refresh_title(TITLE_NONE 不显示、
|
||
grade 0/8 显示 + 色 + name_key、grade 3/5 显示)、refresh_level("Lv 42" + 色、level 0 无)、
|
||
refresh_guild(id 0 无、解出名、空串→"Noname")、EnablePKTitle(FALSE) 压制称号 + 等级但不压公会名 +
|
||
还原、grade 与 entity_rules.alignment_grade 9 组一致。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script text_tail_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 62 个全 PASS(含 name_color_test / entity_rules_test / gamescene_test /
|
||
netbridge_test / mob_view_test / net_world_vis_test / netplay_test / quest_test / input_key_test /
|
||
p8_test / p9_test / p10_test / remote_player_test / player_motion_test / combat_fx_test / …)。
|
||
验收:
|
||
已验证(离线):GetTitleColor 9 grade + 越界、RefreshTextTail 的 TITLE_NONE / 越界分支、
|
||
UpdateTextTailLevel 的 "Lv %d" + s_kLevelColor、RegisterCharacterTextTail 的 guild!=0 / "Noname"、
|
||
AttachTextTail 的 110/10、EnablePKTitle 开关、grade 与 alignment_grade 一致。
|
||
未验证:真服 SetAlignment / SetPKMode / GC_GUILD 驱动下的实时称号切换、真模型(GRN)头顶
|
||
GuildTag/TitleTag/LevelTag 与占位胶囊路径的排版一致性(需真机目视)、EU 右对齐 vs 居中的
|
||
水平对齐、g_TitleNameMap 未注册某 grade 时的 AttachTitle 跳过(本移植 0..8 全有 key)。
|
||
风险与依赖:
|
||
seam ⑦:billboard 3D 里把公会名(上)/ 称号·等级(下)竖排,参考端是屏幕空间像素排版
|
||
(公会名在名字上 c_fyGuildNamePosition=15px、称号 / 等级贴右侧),视觉近似非逐像素等同。
|
||
seam ⑧:title_name_resolver 未注入(裸 net_world 测试 / headless 无 assets)时称号显示裸
|
||
"PVP_LEVEL%d" key —— 生产环境 game_scene 恒注入 Locale。
|
||
公会名走 client.get_guild_name(gid)(= world->guild_name),空串即视作 GetGuildName 失败 → "Noname"。
|
||
_pk_title_enabled 是进程级静态单例(与参考端 bPKTitleEnable 全局一致);未接选项 UI,
|
||
仅 net_world.set_pk_title_enabled() 运行期钩子。
|
||
称号 / 等级 / 公会名整表刷新 ~2Hz(复用名字色 accum),alignment / guild 变化最坏 0.5s 延迟。
|
||
下一批次:
|
||
§8.8 剩余:text_tail.gd 伤害数字(AttachInfoTail / AttachDamage)+ 聊天气泡(AttachChat)+
|
||
屏幕空间血条整合、LALT(§8.6)+ §8.4 三 radio 的头顶信息显示开关;
|
||
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 112(W3→M2 / §8.8 头顶「聊天 / 信息」尾标 1:1)
|
||
|
||
```text
|
||
批次:增量 112 —— W3→M2 §8.8:CPythonTextTail::RegisterChatTail / RegisterInfoTail +
|
||
TextTail_GetLivingTime / SetLivingTime + IsChatTextTail + RecvChatPacket 的头顶尾标派发
|
||
("名字: " 前缀剥离 / SHOUT·COMMAND 排除)1:1(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(chat_tail.gd 纯静态算法 + net_world "bubble" 子节点接线 + chat 派发规则对齐 +
|
||
game_scene._player_bubble 寿命对齐;🔎 真服多类型聊天并发下的气泡替换 / 到点硬删、真模型
|
||
头顶气泡与名字 Label3D 排布目视待真机验收)
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp
|
||
全部 ISO-8859 编码,grep 需 -a):
|
||
UserInterface/PythonTextTail.cpp:16/17 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1)(白)/
|
||
c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1)(淡红)
|
||
UserInterface/PythonTextTail.cpp:26/28/34 gs_TextTail_LivingTime = 5000;
|
||
TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000);TextTail_SetLivingTime(livingTime)
|
||
UserInterface/PythonTextTail.cpp:581 RegisterChatTail(vid, szChat) —— m_ChatTailMap.find(vid) 命中:
|
||
pTextInstance->SetValue(szChat); Color = c_TextTail_Chat_Color; LivingTime = now + GetLivingTime();
|
||
bNameFlag = TRUE; return; —— 否则 RegisterTextTail(vid, szChat, owner, height + 10.0f, Chat色),
|
||
LivingTime = now + GetLivingTime(); bNameFlag = TRUE; SetOutline(true); VERTICAL_ALIGN_BOTTOM;
|
||
m_ChatTailMap.insert(vid, pTextTail)
|
||
UserInterface/PythonTextTail.cpp:624 RegisterInfoTail(vid, szChat) —— 同上但 Color = Info色、
|
||
bNameFlag = FALSE;与 RegisterChatTail 共用 m_ChatTailMap(同 vid 互相覆盖)
|
||
UserInterface/PythonTextTail.cpp:328 UpdateAllTextTail —— for m_ChatTailMap: if (LivingTime < now)
|
||
{ DeleteTextTail; erase; }(硬删,无淡出)
|
||
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail —— chat 尾标 if (bNameFlag) ShowCharacterTextTail(vid)
|
||
UserInterface/PythonTextTail.cpp:105 UpdateShowingTextTail —— if (chatTail && chatTail->bNameFlag)
|
||
名字尾标 y = chatTail.y - 17.0f(气泡在上、名字下移)
|
||
UserInterface/PythonTextTail.cpp:684 IsChatTextTail(vid) —— m_ChatTailMap.find(vid) != end
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:1301 CHAT_TYPE_COMMAND → ServerCommand(); return;(不进尾标)
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:1321 TALKING/PARTY/GUILD/SHOUT/WHISPER:
|
||
char * p = strchr(buf, ':'); if (p) p += 2; else p = buf; ... _snprintf(line, ..., "%s", p);
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:1363 INFO/NOTICE/BIG_NOTICE/default:line = buf(原样)
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:1372 if (CHAT_TYPE_SHOUT != kChat.type)
|
||
CPythonTextTail::Instance().RegisterChatTail(kChat.dwVID, line);(带 VID 且实例可解析时)
|
||
UserInterface/PythonTextTailModule.cpp:76/91 textTailRegisterChatTail / textTailRegisterInfoTail
|
||
(python 绑定;InfoTail 仅 assets/root/game.py:930/936 技能 / 射击错误显式调)
|
||
UserInterface/Packet.h:1410 enum EChatType { TALKING=0, INFO, NOTICE, PARTY, GUILD, COMMAND,
|
||
SHOUT, WHISPER, BIG_NOTICE, MAX_NUM }
|
||
assets/root/game.py:155 textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE)(与增量 111 同处)
|
||
**无 seam**:40250 sole baseline 的 PythonTextTail.cpp / PythonNetworkStreamPhaseGame.cpp 本身含
|
||
RegisterChatTail / RegisterInfoTail / RecvChatPacket 全部逻辑,字节级等同 m2dev 完整版。
|
||
改动:
|
||
project/chat_tail.gd(新)—— 纯静态 `extends RefCounted`:
|
||
CHAT_COLOR = Color(1,1,1) / INFO_COLOR = Color(1, .785, .785);LIVING_TIME_DEFAULT = 5000 /
|
||
LIVING_TIME_MIN = 1000 / HEIGHT_ADD = 10.0;CT_TALKING..CT_BIG_NOTICE = 0..8(EChatType);
|
||
_living_time 静态态 + set_living_time(ms)(不断言,对齐参考端断言在 getter)/ living_time()
|
||
(assert(_living_time > LIVING_TIME_MIN));
|
||
strip_name_prefix(raw) = raw.find(":") 命中则 substr(i+2) 否则整串(strchr ? p+2 : buf);
|
||
line_for_tail(type, raw) —— TALKING/PARTY/GUILD/SHOUT/WHISPER 走 strip,其余原样;
|
||
wants_chat_tail(type) = type != CT_SHOUT && type != CT_COMMAND;
|
||
register_chat(tails, vid, text, now) / register_info(...) —— 共用 _register(),写
|
||
tails[vid] = {text, color, name_flag, living_time = now + living_time()},返回该条;
|
||
is_chat_text_tail(tails, vid) = tails.has(vid);
|
||
expire(tails, now) —— 收集 living_time < now 的 vid,从 tails 删除并返回列表(UpdateAllTextTail)。
|
||
project/net_world.gd —— const ChatTail;var _chat_tails(vid -> 条目,= m_ChatTailMap);
|
||
_bubble(vid, text) 改走 ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec())
|
||
+ _apply_chat_tail(n, tail)(本地 vid 仍 main_bubble.emit);
|
||
新增 register_info_tail(vid, text) 公开方法(= RegisterInfoTail,走 register_info,供技能 /
|
||
射击错误尾标调用);
|
||
_apply_chat_tail(n, tail) —— 复用 / 新建名为 "bubble" 的 Label3D(y=2.35, billboard,
|
||
no_depth_test, pixel_size 0.0055, outline_size 6),text = tail.text、modulate = tail.color;
|
||
_expire_chat_tails() —— ChatTail.expire 后把到点 vid 的 "bubble" 节点 queue_free;
|
||
接入 _process 的 accum ≥ 0.5s 块(与名字色 / 称号刷新同拍,到点最坏 0.5s 延迟);
|
||
_on_despawn 加 _chat_tails.erase(vid);
|
||
chat 信号 lambda:`t in [0,3,4,6]` → `int(v) != 0 and ChatTail.wants_chat_tail(int(t))`,
|
||
气泡文本改为 ChatTail.line_for_tail(int(t), s)(去 "名字: " 前缀)。
|
||
行为变化:SHOUT(原 t==6 会弹气泡)不再弹头顶气泡(对齐 :1372 显式排除);WHISPER / INFO /
|
||
NOTICE 带 VID 时现在会弹(此前被 [0,3,4,6] 过滤掉)。
|
||
project/game_scene.gd —— _player_bubble 的 tween_interval(3.0) → tween_interval(4.0)
|
||
(+ 1.0s 淡出 = 5s,对齐 gs_TextTail_LivingTime;参考端到点硬删无淡出,本地气泡保留淡出 = 近似)。
|
||
project/chat_tail_test.gd(新,第 23 个 canonical)—— SceneTree 回归,约 45 断言:
|
||
常量(Chat/Info 色、5000/1000/10、EChatType 0..8)、living_time / set_living_time 往返 + 还原、
|
||
strip_name_prefix("Alice: x"→"x" / 无冒号原样 / 只吃首个冒号 / "Bob: "→"")、
|
||
line_for_tail(TALKING/PARTY/GUILD/WHISPER/SHOUT strip、INFO/NOTICE/BIG_NOTICE 原样)、
|
||
wants_chat_tail(除 SHOUT/COMMAND 全 true)、register_chat(新建 + 同 vid 原地替换 + bNameFlag
|
||
TRUE + LivingTime = now+5000)、register_info(淡红 + bNameFlag FALSE + 覆盖同 vid chat 条)、
|
||
is_chat_text_tail、expire(到点返回 + 删除、未到点保留、空表返回 [])。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script chat_tail_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 63 个全 PASS(含 chat_tail_test / text_tail_test / name_color_test /
|
||
entity_rules_test / gamescene_test / netbridge_test / netplay_test / net_world_vis_test /
|
||
quest_test / input_key_test / p8_test / p9_test / p10_test / remote_player_test / …)。
|
||
验收:
|
||
已验证(离线):Chat/Info 色、gs_TextTail_LivingTime 默认 5000 + assert 下界、
|
||
strip_name_prefix 的 strchr ? p+2 : buf、line_for_tail 逐类型、wants_chat_tail 的
|
||
SHOUT/COMMAND 排除、RegisterChatTail 新建 / 原地替换 / bNameFlag / LivingTime、
|
||
RegisterInfoTail 淡红 + bNameFlag FALSE + 与 chat 共表覆盖、IsChatTextTail、expire 硬删。
|
||
未验证:真服 GC_CHAT(TALKING/PARTY/GUILD)/ GC_WHISPER 驱动下的气泡出现 / 替换 / 5s 到点消失、
|
||
真模型(GRN)头顶 "bubble" Label3D 与名字 / 称号 / 等级子标签的排布一致性(需真机目视)、
|
||
TextTail_SetLivingTime 的 3s 长喊话(服务端可调 gs_TextTail_LivingTime)、
|
||
RegisterInfoTail 的实际调用方(技能 / 射击错误路径尚未接 register_info_tail())。
|
||
风险与依赖:
|
||
seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag
|
||
决定名字是否强制 ShowCharacterTextTail);本移植是 net_world 里名字 / 称号 / 等级 / "bubble"
|
||
各自 billboard Label3D 并存 —— 名字不强制顶出、不随气泡下移,name_flag 只存不用。
|
||
_chat_tails 到点删除靠 _process 的 accum ≥ 0.5s 块(非逐帧),气泡最多多留 ~0.5s。
|
||
本地玩家 vid 仍走 main_bubble → game_scene._player_bubble(独立 tween,非 _chat_tails 管理);
|
||
远端 vid 无对应节点(未 spawn / 已 despawn)时 _bubble / register_info_tail 静默 return。
|
||
_living_time 是进程级静态单例(与参考端 gs_TextTail_LivingTime 全局一致);未接
|
||
TextTail_SetLivingTime 的运行期钩子(默认 5000)。
|
||
SHOUT 不再弹头顶气泡是对齐参考的行为变化(喊话仍进聊天窗,见 §8.2)。
|
||
下一批次:
|
||
§8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立于 text tail 的
|
||
effect 系统)+ 屏幕空间血条整合、TextTail_SetLivingTime 运行期钩子、
|
||
chat 尾标 bNameFlag 驱动名字强制显示 / 下移 17px(seam ⑨ 收口);
|
||
或 §8.4 三 radio(名字颜色 / 伤害数字 / 总是显示名字)+ §8.6 LALT 接到 text_tail / chat_tail 显示开关;
|
||
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 113(W3→M2 / §8.4 · §8.6 头顶名字「是否显示」1:1)
|
||
|
||
```text
|
||
批次:增量 113 —— W3→M2 §8.4 / §8.6:CPythonSystem::IsAlwaysShowName /
|
||
SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的
|
||
3500 平面距裁剪 + ShowCharacterTextTail 资格判定 + UpdateAllTextTail 的 bNameFlag 强显
|
||
(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(name_show.gd 纯静态谓词 + net_world 头顶名字 / 子标签接线:__IsShowName
|
||
两路(总显示名字选项 + LALT)、target VID / chat bNameFlag 强显、3500 距离裁剪;
|
||
🔎 真机目视:3500 边界名字进出、悬停 chr.Pick() 强显(未接,seam ⑩)、屏幕空间
|
||
ArrangeTextTail 防重叠(未做,seam ⑩))
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp
|
||
ISO-8859 编码,grep 需 -a):
|
||
UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true
|
||
UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME
|
||
UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName
|
||
UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1)
|
||
UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1
|
||
UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘)
|
||
UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName
|
||
root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True)
|
||
+ RefreshAlwaysShowName();:224 OffButton -> SetAlwaysShowNameFlag(False)
|
||
root/game.py:323 onPressKeyDict[app.DIK_LALT] = lambda: self.ShowName()
|
||
root/game.py:406 onClickKeyDict[app.DIK_LALT] = lambda: self.HideName()
|
||
root/game.py:1143 ShowName() -> self.ShowNameFlag = True; playerGauge.EnableShowAlways(); SetQuickPage(+1)
|
||
root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False
|
||
root/game.py:1533 OnRender:idx = chr.Pick(); if -1 != idx: textTail.ShowCharacterTextTail(idx)
|
||
root/game.py:1535 OnRender:if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(vid)
|
||
root/game.py:1550 OnRender:if __IsShowName(): textTail.ShowAllTextTail(); PickingItemIndex = textTail.Pick(x, y)
|
||
UserInterface/PythonTextTail.cpp:416 UpdateDistance:fDistanceFromPlayer =
|
||
D3DXVec2Length(ownerX - centerX, -ownerY - centerY)
|
||
UserInterface/PythonTextTail.cpp:422 ShowAllTextTail:for m_CharacterTextTailMap:
|
||
if (fDistanceFromPlayer < 3500.0f) ShowCharacterTextTail(vid)(item 尾标同阈)
|
||
UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在 map / 已在 m_CharacterTextTailList /
|
||
!pOwner->isShow() / !GetInstancePtr / IsGuildWall() -> return;CanPickInstance() 才 push_back
|
||
UserInterface/PythonTextTail.cpp:408 HideAllTextTail:m_CharacterTextTailList.clear();(每帧末清列,下帧重建)
|
||
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:for m_ChatTailMap: if (bNameFlag)
|
||
ShowCharacterTextTail(vid)(不经 3500 裁剪)
|
||
seam ⑩:参考「名字显示」是每帧重建 m_CharacterTextTailList(Pick / target / ShowAll)再
|
||
Render / ArrangeTextTail(屏幕空间防重叠)/ HideAll 的流程;本移植是 net_world 每实体一块
|
||
billboard Label3D,靠 name_show 谓词逐帧 set .visible。① 鼠标悬停 chr.Pick() 强显未接
|
||
(net_world 无悬停拾取)② ArrangeTextTail 防重叠未做 ③ CanPickInstance() 只按「死透」
|
||
近似(隐身 / 特定 affect 亦不可拾取)④ UpdateDistance 的 -ownerY 是渲染帧 / 像素帧混用
|
||
产物,本移植 pos_cm 单帧等价同帧平面距。
|
||
改动:
|
||
project/name_show.gd(新)—— 纯静态 `extends RefCounted`:
|
||
ALWAYS_SHOW_NAME_DEFAULT = true(DEFAULT_VALUE_ALWAYS_SHOW_NAME)/ SHOW_ALL_DISTANCE = 3500.0 /
|
||
CFG_KEY = "ALWAYS_VIEW_NAME";
|
||
always_show_from_flag(flag) = flag == 1(SetAlwaysShowNameFlag);
|
||
always_show_from_cfg(value) = int(value) == 1(配置行 atoi == 1);
|
||
is_show_name(always, alt_held) = always or alt_held(__IsShowName);
|
||
within_show_all(dist) = dist < 3500.0(ShowAllTextTail);
|
||
planar_distance_cm(a, b) = a.distance_to(b)(UpdateDistance,同帧);
|
||
eligible_character(is_shown, is_guild_wall, can_pick) = is_shown && !is_guild_wall && can_pick
|
||
(ShowCharacterTextTail 早退取反);
|
||
name_visible_for(forced, show_name, dist, is_shown, is_guild_wall, can_pick) ——
|
||
先过 eligible_character;forced(Pick / target / bNameFlag)绕过 3500 直接 true;
|
||
否则需 show_name && within_show_all(dist)。
|
||
project/net_world.gd —— const NameShow;
|
||
var _names_visible(true) 拆成 _always_show_name(= ALWAYS_SHOW_NAME_DEFAULT) + _alt_held(false)
|
||
+ _target_vid(0);
|
||
set_names_visible(v)(LALT 入口,increment 53)改为写 _alt_held + _refresh_all_name_visibility();
|
||
names_visible() 返回 _alt_held;
|
||
新增 set_always_show_name(on)(uigameoption radio)/ set_target_vid(vid)(targetBoard);
|
||
_main_planar_cm() —— 主角 pos_cm 的 (x,y),未知返回 (INF,INF);
|
||
_name_shown(root) —— 取 root meta vid → client.get_entity → dead / race,组装
|
||
forced(vid == _target_vid 或 _chat_tails[vid].name_flag)+ show_name(is_show_name(
|
||
_always_show_name, _alt_held))+ dist(主角已知则 planar_distance_cm,否则 0 fail-open)
|
||
→ NameShow.name_visible_for;
|
||
_set_name_visibility(root) 改为 shown = _name_shown(root),主名字 Label3D 与
|
||
GuildTag/TitleTag/LevelTag 子标签统一 shown(子标签仍 && text != "");
|
||
_apply_text_tail_to 的三处子标签 visible 由 _names_visible 改 _name_shown(n);
|
||
_attach_nameplate 的 tag.visible = _names_visible 改 = false(_on_spawn 随后 _set_name_visibility);
|
||
_apply_chat_tail 末 + _set_name_visibility(n)(bNameFlag 强显即时生效);
|
||
_expire_chat_tails 删 "bubble" 后 + _set_name_visibility(n)(强显撤销);
|
||
_process 的 accum ≥ 0.5s 块 + _refresh_all_name_visibility()(距离随移动变化的 ~2Hz 兜底)。
|
||
project/ui/game_option_ui.gd —— 新增 signal display_option_changed(key, value);radio 选中
|
||
回调在 _save() 后 emit(spec[0], spec[1]);新增 display_value(key) 供渲染层开局回填。
|
||
project/game_scene.gd —— net_play.setup 后接 net_play.target_changed → net_world.set_target_vid;
|
||
game_option_ui.setup 后接 display_option_changed → _on_display_option(key=="always_show_name"
|
||
→ net_world.set_always_show_name(value == 1)),并用 display_value("always_show_name") 开局回填。
|
||
project/name_show_test.gd(新,第 24 个 canonical)—— SceneTree 回归,约 35 断言:
|
||
常量(默认 true / 3500 / "ALWAYS_VIEW_NAME")、always_show_from_flag/from_cfg(严格 == 1)、
|
||
is_show_name 真值表、within_show_all(3499.9 T / 3500.0 F 严格 <)、planar_distance_cm
|
||
(3-4-5 / 同点 0 / x 轴)、eligible_character(isShow / IsGuildWall / CanPickInstance 各早退)、
|
||
name_visible_for(公会墙即便 forced 也 F、forced + 合格 + 超距 T、forced 但不合格 F、
|
||
非 forced 需 __IsShowName + <3500 + 合格)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script name_show_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 64 个全 PASS(含 name_show_test / chat_tail_test / text_tail_test /
|
||
name_color_test / entity_rules_test / gamescene_test / netplay_test / net_world_vis_test /
|
||
input_key_test / quest_test / p8_test / p9_test / p10_test / remote_player_test / …)。
|
||
验收:
|
||
已验证(离线):DEFAULT_VALUE_ALWAYS_SHOW_NAME = true、SetAlwaysShowNameFlag 的 == 1 语义、
|
||
"ALWAYS_VIEW_NAME" 配置行 atoi == 1、__IsShowName 的 or、ShowAllTextTail 的
|
||
fDistanceFromPlayer < 3500 严格小于、ShowCharacterTextTail 的 isShow / IsGuildWall /
|
||
CanPickInstance 早退、forced 绕过 3500 但仍过资格、UpdateDistance 平面距、
|
||
game_option_ui radio → 信号 → net_world.set_always_show_name、target_changed →
|
||
set_target_vid、全 64 回归。
|
||
未验证:真机 —— 3500cm 边界处名字随主角 / 目标移动的进出、总显示名字选项关闭后仅
|
||
目标 / bNameFlag 尾标 / LALT 期间显示、真模型(GRN)头顶名字 / 子标签 billboard 与
|
||
参考屏幕空间排布的目视一致性、SetQuickPage(+1) / playerGauge.EnableShowAlways()
|
||
(ShowName 的另两步,属快捷栏 / 血条 UI,未接)。
|
||
风险与依赖:
|
||
seam ⑩(见上「参考」末):悬停 chr.Pick() 强显未接、ArrangeTextTail 防重叠未做、
|
||
CanPickInstance() 近似为「非死透」、UpdateDistance 的 -ownerY 帧差。
|
||
行为变化:此前 _names_visible 默认 true = 所有已 spawn 实体名字恒显;现默认
|
||
_always_show_name = true(对齐参考默认)但叠加 3500 平面距裁剪 —— 20000cm 视野内、
|
||
3500cm 外的实体现在有节点但无名字(对齐 ShowAllTextTail)。主角位置未知时 fail-open
|
||
(同 _is_visible_entity),离线 / FakeClient 测试名字仍恒显。
|
||
距离裁剪走 _process 的 accum ≥ 0.5s 块(非逐帧),3500 边界名字进出最坏 ~0.5s 延迟;
|
||
LALT / 选项 / 选中 / 尾标切换各自即时 _refresh / _set_name_visibility。
|
||
_always_show_name 由 game_option_ui 持久化到 user://system_option.cfg [gameopt]
|
||
always_show_name(默认 1);参考端 PythonSystem 仅在 != 默认值时才写 ALWAYS_VIEW_NAME 行,
|
||
本移植 ConfigFile 恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径)。
|
||
net_play.target_changed 已有(增量早期),net_world 此前不知道目标 VID;新增的
|
||
set_target_vid 仅驱动名字强显,不改选中 / 攻击逻辑。
|
||
下一批次:
|
||
§8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立 effect 系统)+
|
||
屏幕空间血条整合;chat 尾标 bNameFlag 下移名字 17px(seam ⑨ 收口);
|
||
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取已有 _ray_pick_t,接名字强显)+
|
||
ArrangeTextTail 屏幕空间防重叠;
|
||
或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 的
|
||
帝国名色模式 + 伤害数字 radio(show_damage)接到 text_tail 伤害数字显隐;
|
||
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 114(W3→M2 / §8.8 · §5.4 头顶伤害飘字 AddDamageEffect / ProcessDamage 1:1)
|
||
|
||
```text
|
||
批次:增量 114 —— W3→M2 §8.8 / §5.4:CInstanceBase::AddDamageEffect + ProcessDamage 的分类
|
||
(DODGE|BLOCK → MISS、self → "damage_"、nontarget → 不渲染、target → "target_")+ 数字逐位
|
||
拆分 + CPythonSystem::IsShowDamage / SetShowDamageFlag 门(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(damage_effect.gd 纯静态谓词 + net_world._on_damage 改为 AddDamageEffect 语义
|
||
入队 / _process_damage_queue 每帧 pop 一条 ProcessDamage;uigameoption「伤害数字」radio
|
||
经 game_option_ui.display_option_changed → net_world.set_show_damage)
|
||
🔎 真机目视:MISS / 数字飘字位置与升起、nontarget 不显、self 交替、IsShowDamage 关时无飘字。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp
|
||
ISO-8859 编码,grep 需 -a):
|
||
UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2,
|
||
BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 }
|
||
UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; }
|
||
UserInterface/PythonNetworkStream.cpp:178 Set(HEADER_GC_DAMAGE_INFO, STATIC_SIZE_PACKET)
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:396 case HEADER_GC_DAMAGE_INFO -> RecvDamageInfoPacket()
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket:
|
||
pInstTarget = GetInstancePtr(dwVID);bSelf = (pInstTarget == GetMainInstancePtr());
|
||
bTarget = (pInstTarget == m_pInstTarget);if (damage >= 0) AddDamageEffect(damage, flag, bSelf, bTarget)
|
||
else TraceError("Damage is equal or below 0.")
|
||
UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:if (CPythonSystem::IsShowDamage())
|
||
{ m_DamageQueue.push_back({bSelf, bTarget, damage, flag}); }
|
||
UserInterface/InstanceBase.cpp:1910 ProcessDamage()(每帧 Update 调一次)
|
||
UserInterface/InstanceBaseEffect.cpp:100 ProcessDamage:if (m_DamageQueue.empty()) return;
|
||
sDamage = m_DamageQueue.front(); m_DamageQueue.pop_front();(每帧仅一条)
|
||
(flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISS; return;
|
||
(flag & CRITICAL) -> CreateEffect 被注释掉(该 build 无独立暴击视觉),落普通数字
|
||
POISON 分支整段注释掉
|
||
bSelf -> strDamageType="damage_";m_bDamageEffectType==0 ? EFFECT_DAMAGE_SELFDAMAGE :
|
||
EFFECT_DAMAGE_SELFDAMAGE2;m_bDamageEffectType = !m_bDamageEffectType
|
||
bTarget==false -> strDamageType="nontarget_";EFFECT_DAMAGE_NOT_TARGET;return;(不渲染)
|
||
else -> strDamageType="target_";EFFECT_DAMAGE_TARGET
|
||
while (damage > 0) { if (index > 7) { TraceError; break; } num = damage % 10; damage /= 10;
|
||
textures.push_back("d:/ymir work/effect/affect/damagevalue/"+strDamageType+num+".dds");
|
||
matTrans._41 = -(FONT_WIDTH(30) * index)(相机右方向逐位铺开); CreateEffect(...); index++; }
|
||
UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认)
|
||
UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage
|
||
UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1)
|
||
UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1
|
||
UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘)
|
||
UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage
|
||
seam ⑪:参考端每位数字是一张 damagevalue/<type><digit>.dds 贴图的 CEffectManager 特效,按
|
||
FONT_WIDTH*index 在相机右方向铺开;本移植 net_world 用一块 billboard Label3D 写 str(amount)
|
||
(无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。self 的
|
||
SELFDAMAGE/SELFDAMAGE2 交替退化成落字水平抖动符号交替(无双特效资源)。暴击在该 40250 build
|
||
参考端本就无独立视觉,本移植也不再放大 / 变色(相较增量 ≤112 的占位实现是回归对齐)。
|
||
另:参考 m_DamageQueue 是「每实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是
|
||
net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击会分帧显示。
|
||
改动:
|
||
project/damage_effect.gd(新)—— 纯静态 `extends RefCounted`:
|
||
DAMAGE_NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL = 1<<0..1<<5(DamageFlag);
|
||
SHOW_DAMAGE_DEFAULT = true / CFG_KEY = "SHOW_DAMAGE" / FONT_WIDTH = 30 / MAX_DIGITS = 8;
|
||
enum Kind { MISS, DIGITS };
|
||
should_queue(show_damage) = show_damage(AddDamageEffect 入队门);
|
||
show_damage_from_flag(flag) = flag == 1(SetShowDamageFlag);
|
||
show_damage_from_cfg(value) = int(value) == 1(配置行 atoi == 1);
|
||
is_self(vid, main_vid) / is_target(vid, target_vid)(RecvDamageInfoPacket 的 bSelf / bTarget);
|
||
valid_amount(amount) = amount >= 0(damage < 0 记 TraceError 不处理);
|
||
classify(flag, b_self, b_target) -> {kind, type("damage_"/"target_"/"nontarget_"), show, self}
|
||
—— DODGE|BLOCK → MISS(type 按 b_self);否则 b_self → DIGITS damage_ show;
|
||
非 b_self 且非 b_target → DIGITS nontarget_ show=false(ProcessDamage 里 return);
|
||
否则 → DIGITS target_ show;CRITICAL / POISON 均落普通数字(对应分支参考端注释掉);
|
||
digits(damage) —— while(damage>0) num=damage%10; damage/=10; 低位在前、最多 8 位(index>7 break),
|
||
damage <= 0 产出 [];
|
||
digit_offset_px(index) = FONT_WIDTH * index;
|
||
self_damage_slot(toggle_state) —— false → SELFDAMAGE(0)、true → SELFDAMAGE2(1)。
|
||
project/net_world.gd —— const DamageEffect;删本地 const DAMAGE_DODGE / DAMAGE_CRITICAL
|
||
(改引用 DamageEffect.DAMAGE_*);新增 var _show_damage(= SHOW_DAMAGE_DEFAULT) /
|
||
_damage_queue(Array,= m_DamageQueue) / _dmg_self_toggle(= m_bDamageEffectType);
|
||
新增 set_show_damage(on)(uigameoption「伤害数字」radio);
|
||
_on_damage(vid, amount, flag) 改写 —— 仍恒发 damage_number 信号(本移植扩展,供战斗日志 /
|
||
测试);然后 valid_amount 门(< 0 return)+ should_queue(_show_damage) 门(!IsShowDamage return)
|
||
+ is_self(_main_vid) / is_target(_target_vid) → _damage_queue.append({vid, amount, flag, self, target});
|
||
新增 _process_damage_queue()(_process 内 _update_visibility() 后每帧调一次)——
|
||
pop_front 一条 → 节点存在校验 → DamageEffect.classify → !show(nontarget)return →
|
||
MISS 写 "MISS" / DIGITS 写 str(amount)(digits() 为空则跳过)→ self 数字按 self_damage_slot
|
||
交替水平抖动符号(0.3 / -0.3)并翻转 _dmg_self_toggle → 生成 billboard Label3D(白、font_size 32、
|
||
no_depth_test、pixel_size 0.007)在 y+1.9 处,tween 上升 1.2 / 0.8s + 淡出 → queue_free;
|
||
clear_for_map_change() 增 _damage_queue.clear()。
|
||
project/ui/game_option_ui.gd —— 无改动(增量 113 的 display_option_changed 信号 + display_value
|
||
已覆盖 show_damage key,_display 默认表已含 show_damage 默认 1)。
|
||
project/game_scene.gd —— game_option_ui.setup 后用 display_value("show_damage") == 1 开局
|
||
net_world.set_show_damage 回填;_on_display_option 增 elif key == "show_damage" →
|
||
net_world.set_show_damage(value == 1)。
|
||
project/damage_effect_test.gd(新,第 25 个 canonical)—— SceneTree 回归,约 50 断言:
|
||
常量(DamageFlag 位值 / 默认 true / "SHOW_DAMAGE" / FONT_WIDTH 30 / MAX_DIGITS 8)、
|
||
IsShowDamage 门(should_queue / from_flag / from_cfg 严格 == 1)、bSelf / bTarget、
|
||
valid_amount(0 T / -1 F)、classify(DODGE / BLOCK → MISS,普通 self/target/nontarget,
|
||
CRITICAL 仍普通数字、nontarget 不渲染,POISON 走普通)、digits(0 → []、-5 → []、123 →
|
||
[3,2,1]、90 → [0,9]、2000000 → 7 位、9 位截 8 位)、digit_offset_px(0/30/90)、
|
||
self_damage_slot(false → 0、true → 1)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script damage_effect_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 65 个全 PASS(含 damage_effect_test / name_show_test / chat_tail_test /
|
||
text_tail_test / name_color_test / netbridge_test(damage_number 信号仍恒发)/ gamescene_test /
|
||
netplay_test / net_world_vis_test / …)。
|
||
验收:
|
||
已验证(离线):DamageFlag 位值、IsShowDamage 默认 true、SetShowDamageFlag / "SHOW_DAMAGE"
|
||
配置行的 == 1 语义、RecvDamageInfoPacket 的 damage >= 0 门与 bSelf / bTarget 判定、
|
||
ProcessDamage 的 DODGE|BLOCK → MISS、nontarget 数字不渲染、self "damage_" 交替、
|
||
target "target_"、CRITICAL / POISON 落普通数字、数字逐位低位在前 + 8 位上限、
|
||
FONT_WIDTH*index 偏移、game_option_ui radio → display_option_changed → set_show_damage、
|
||
_process_damage_queue 每帧 pop 一条、全 65 回归。
|
||
未验证:真机 —— GC_DAMAGE_INFO 真包驱动下 MISS / 数字飘字的位置 / 升起 / 淡出目视、
|
||
nontarget(非 self 非目标)实体确实不显、self 连击时 SELFDAMAGE / SELFDAMAGE2 交替的观感、
|
||
「伤害数字」radio 关闭后完全无飘字、同帧多 VID 受击的分帧显示是否可接受、
|
||
damagevalue/<type><digit>.dds 逐位特效与相机右向铺排(seam ⑪,未做)。
|
||
风险与依赖:
|
||
seam ⑪(见上「参考」末):单块 Label3D 代替 damagevalue dds 逐位特效 / 相机右向像素铺排、
|
||
self 交替退化为水平抖动符号、暴击无独立视觉(对齐参考)、全局单队列每帧一条 vs 参考
|
||
每实例一队列。
|
||
行为变化:此前 _on_damage 无条件生成飘字(MISS 仅看 DODGE、暴击金色放大、随机水平抖动、
|
||
所有实体都显);现按 ProcessDamage:BLOCK 也算 MISS、非 self 非目标实体的数字伤害不再
|
||
显示、暴击不再金色放大、飘字受 IsShowDamage()(uigameoption「伤害数字」radio)门控、
|
||
每帧只弹一条(队列)。damage_number 信号维持无条件发出(本移植扩展,供 HUD 战斗日志 /
|
||
netbridge_test;参考端 AddDamageEffect 整体在 IsShowDamage() 内)。
|
||
_show_damage 由 game_option_ui 持久化到 user://system_option.cfg [gameopt] show_damage
|
||
(默认 1);参考端 PythonSystem 仅在 == 0(非默认)时才写 SHOW_DAMAGE 行,本移植 ConfigFile
|
||
恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径,与增量 113 的 ALWAYS_VIEW_NAME 同)。
|
||
bSelf 用 net_world._main_vid、bTarget 用 _target_vid(增量 113 由 net_play.target_changed 灌入);
|
||
离线 / FakeClient 若 _main_vid 未设,主角伤害会落 nontarget_ 分支而不显(与参考「无主实例」
|
||
行为一致)。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考
|
||
CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字
|
||
17px(seam ⑨ 收口);
|
||
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+
|
||
ArrangeTextTail 屏幕空间防重叠;
|
||
或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式;
|
||
或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动;
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 115(W3→M2 / §5.4 · §8.8 目标框 RecvTargetPacket 收包分派 1:1)
|
||
|
||
```text
|
||
批次:增量 115 —— W3→M2 §5.4 / §8.8:CPythonNetworkStream::RecvTargetPacket 的目标框判定树
|
||
(实例缺失 → CloseTargetBoard、目标已死透 → 不动框、PC|Building → CloseTargetBoardIfDifferent、
|
||
CanViewTargetHP → SetHPTargetBoard、否则 → CloseTargetBoard)+ CInstanceBase::CanViewTargetHP
|
||
(IsStone()||IsWoodenDoor()||IsEnemy())+ game.py 的 SetHPTargetBoard 换 VID 先 Reset /
|
||
CloseTargetBoardIfDifferent / SetPCTargetBoard LCONTROL 悄悄话捷径(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(target_board.gd 纯静态谓词 + net_play._on_target_info 改为按判定树分派目标框)
|
||
🔎 真机目视:选 PC 目标无血条框、选 NPC / warp 不弹框、选石头 / 怪弹血条框、换目标先清旧框、
|
||
目标死亡 / despawn → 清框。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp
|
||
ISO-8859 编码,grep 需 -a):
|
||
UserInterface/Packet.h:1937 TPacketGCTarget { BYTE header; DWORD dwVID; BYTE bHPPercent; }
|
||
UserInterface/PythonNetworkStream.cpp Set(HEADER_GC_TARGET, sizeof(TPacketGCTarget))(STATIC_SIZE_PACKET)
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp case HEADER_GC_TARGET -> RecvTargetPacket()
|
||
UserInterface/PythonNetworkStreamPhaseGame.cpp:2424 RecvTargetPacket():
|
||
pInstPlayer = GetMainInstancePtr();pInstTarget = GetInstancePtr(TargetPacket.dwVID)
|
||
:2436 if (pInstPlayer && pInstTarget)
|
||
:2438 if (!pInstTarget->IsDead())
|
||
:2440 if (pInstTarget->IsPC() || pInstTarget->IsBuilding())
|
||
PyCall(GAME, "CloseTargetBoardIfDifferent", (i)dwVID)
|
||
:2442 else if (pInstPlayer->CanViewTargetHP(*pInstTarget))
|
||
PyCall(GAME, "SetHPTargetBoard", (ii)dwVID, bHPPercent)
|
||
:2444 else PyCall(GAME, "CloseTargetBoard", ())
|
||
:2447 m_pInstTarget = pInstTarget (仅在 !IsDead() 块内 —— 目标存在且未死才写)
|
||
:2453 else PyCall(GAME, "CloseTargetBoard", ()) (pInstPlayer/pInstTarget 有一个缺失)
|
||
UserInterface/InstanceBase.cpp:2293 CanViewTargetHP(victim):
|
||
if (victim.IsStone()) return true;
|
||
if (victim.IsWoodenDoor()) return true;
|
||
if (victim.IsEnemy()) return true;
|
||
return false;
|
||
UserInterface/InstanceBase.cpp:2310/2320/2325 IsPC() / IsEnemy() / IsStone()(→ GraphicThingInstance)
|
||
UserInterface/InstanceBase.cpp:2380 IsBuilding()(→ GraphicThingInstance.IsBuilding())
|
||
UserInterface/InstanceBase.cpp:2390 IsWoodenDoor():IsDoor() 且 vnum == 13000 或 30111..30119
|
||
root/game.py:804 SetPCTargetBoard(vid, name):self.targetBoard.Open(vid, name);
|
||
if app.IsPressed(app.DIK_LCONTROL): if not player.IsSameEmpire(vid): return;
|
||
if player.IsMainCharacterIndex(vid): return;
|
||
elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return;
|
||
self.interface.OpenWhisperDialog(name)
|
||
root/game.py:829 SetHPTargetBoard(vid, hpPct):if vid != targetBoard.GetTargetVID():
|
||
targetBoard.ResetTargetBoard(); targetBoard.SetEnemyVID(vid); 然后 SetHP(hpPct) + Show()
|
||
root/game.py:837 CloseTargetBoardIfDifferent(vid):if vid != targetBoard.GetTargetVID(): Close()
|
||
root/game.py:841 CloseTargetBoard():targetBoard.Close()
|
||
seam ⑫:本 poc 无 building / wooden-door / stone-door 实体类型(_entity_kind 只到
|
||
0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building 恒 false、can_view 的
|
||
is_wooden_door 恒 false;「可看血」= 石头(KIND_STONE) 或 敌对怪(KIND_MONSTER)。木门 / 攻城门
|
||
做出来后再补 IsWoodenDoor / IsBuilding 分类。
|
||
另:poc 的 target_info(vid, hp%) 由 C++ 侧 extension/src/net/m2_client.cpp:3129/3633 从
|
||
EntityStore 抽出(w.target_vid() / w.target_hp_pct()),本移植只镜像 GDScript 侧对
|
||
(vid, hp%) 的分派,不改 C++ 收包(对齐「纯 GDScript」约束)。
|
||
LCONTROL 悄悄话捷径(SetPCTargetBoard 里按住 LCONTROL 对同阵营 PC 开 OpenWhisperDialog):
|
||
poc 目标框点击无 whisper dialog 钩子 → whisper_on_ctrl_click 谓词已备好,net_play 暂不接。
|
||
改动:
|
||
project/target_board.gd(新,第 26 个 canonical)—— 纯静态 `extends RefCounted`:
|
||
enum Action { CLOSE, CLOSE_IF_DIFFERENT, SET_HP, NONE };
|
||
can_view_target_hp(is_stone, is_wooden_door, is_enemy) = 三者任一(CanViewTargetHP);
|
||
classify(target_exists, is_dead, is_pc, is_building, can_view_hp) -> Action
|
||
—— !target_exists → CLOSE(:2453);is_dead → NONE(:2438 整块跳过);
|
||
is_pc || is_building → CLOSE_IF_DIFFERENT(:2440);can_view_hp → SET_HP(:2442);
|
||
else → CLOSE(:2444);
|
||
updates_current_target(target_exists, is_dead) = target_exists and not is_dead
|
||
(m_pInstTarget = pInstTarget 在 :2447,仅 !IsDead() 块内);
|
||
needs_reset(new_vid, cur_vid) = new_vid != cur_vid(SetHPTargetBoard 换 VID 先 Reset);
|
||
should_close_if_different(vid, cur_vid) = vid != cur_vid(CloseTargetBoardIfDifferent);
|
||
whisper_on_ctrl_click(lcontrol_down, same_empire, is_main, is_building)
|
||
= lcontrol_down and same_empire and not is_main and not is_building(SetPCTargetBoard 捷径);
|
||
clamp_hp_pct(v) = clampi(int(v), 0, 100)(bHPPercent 是 BYTE)。
|
||
project/net_play.gd —— const TargetBoard = preload("res://target_board.gd");
|
||
_on_target_info(vid, hp_pct) 改写 —— 由「任意有名字 VID 都 hud.set_target」改为按判定树:
|
||
e = client.get_entity(vid) if vid != 0 else {};exists = vid != 0 and not e.is_empty();
|
||
dead = exists and e.dead;kind = _entity_kind(e)(-1 若不存在);
|
||
is_pc = kind == KIND_PC;is_building = false(seam ⑫);
|
||
can_view = can_view_target_hp(kind == KIND_STONE, false, kind == KIND_MONSTER);
|
||
match classify(exists, dead, is_pc, is_building, can_view):
|
||
CLOSE → _clear_target()(内部 _target_vid==0 早退 + target_changed.emit(0) + hud.clear_target);
|
||
CLOSE_IF_DIFFERENT → should_close_if_different(vid, _target_vid) 才 _clear_target();
|
||
SET_HP → needs_reset(vid, _target_vid) 时 _target_vid = vid + target_changed.emit(vid);
|
||
再 hud.set_target(_entity_name(e) or "目标", clamp_hp_pct(hp_pct));
|
||
NONE → pass(目标已死透,不动框)。
|
||
project/target_board_test.gd(新,第 26 个 canonical)—— SceneTree 回归,约 35 断言:
|
||
can_view_target_hp(Stone / WoodenDoor / Enemy 各自 T、皆非 F、任一 T)、
|
||
classify(实例缺失 → CLOSE 且优先、已死 → NONE 且优先于 PC 分支、PC → CLOSE_IF_DIFFERENT、
|
||
Building → CLOSE_IF_DIFFERENT、PC 即便可看血也走 CLOSE_IF_DIFFERENT、非 PC 可看血 → SET_HP、
|
||
NPC/warp 不可看血 → CLOSE)、updates_current_target(存在且未死 T、已死 F、缺失 F)、
|
||
needs_reset(无目标 T / 换目标 T / 同目标 F)、should_close_if_different(异 VID T / 同 VID F /
|
||
无 VID T)、whisper_on_ctrl_click(全真 T、缺 LCONTROL / !同阵营 / 本人 / building 各 F)、
|
||
clamp_hp_pct(40 / 0 / 100 原样、负 → 0、>100 → 100、float 37.9 → 37)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 66 个全 PASS(新增 target_board_test;含 netplay_test(target_info →
|
||
hud.set_target(pct=40),实体 2000 ch_type=2 走 SET_HP)/ netbridge_test / gamescene_test /
|
||
combat_fx_test / net_world_vis_test / damage_effect_test / name_show_test / …)。
|
||
验收:
|
||
已验证(离线):CanViewTargetHP 三谓词、RecvTargetPacket 判定树全 5 分支 + 优先级
|
||
(缺失 > 已死 > PC|Building > 可看血 > 关框)、m_pInstTarget 写入条件、SetHPTargetBoard
|
||
换 VID 先 Reset、CloseTargetBoardIfDifferent 的 != 语义、SetPCTargetBoard LCONTROL 捷径
|
||
四个早退、bHPPercent 钳位、net_play._on_target_info 经 target_board.gd 分派、全 66 回归。
|
||
未验证:真机 —— GC_TARGET 真包驱动下选 PC 目标不弹血条框(只在换 VID 时清旧框)、
|
||
选 NPC / warp 不弹框、选石头 / 敌对怪弹血条框、换目标先清旧框的观感、
|
||
目标已死透时框保持不变、LCONTROL 悄悄话捷径(seam ⑫,未接)、
|
||
木门 / 攻城门作为可看血目标(seam ⑫,poc 无该实体类型)。
|
||
风险与依赖:
|
||
seam ⑫(见上「参考」末):poc 无 building / wooden-door 实体 → is_building 恒 false、
|
||
can_view 的 is_wooden_door 恒 false;LCONTROL 悄悄话捷径未接(无 whisper dialog 钩子);
|
||
target_info 由 C++ EntityStore 侧抽出,不改 C++ 收包。
|
||
行为变化:此前 _on_target_info 对任意 vid != 0 且有名字的目标都 hud.set_target(包括 NPC);
|
||
现按 RecvTargetPacket 判定树 —— PC / building 目标不再显血条框(只在框上是别的 VID 时清旧框)、
|
||
NPC / warp 等不可看血目标一律关框、目标已死透时不动框、hp% 钳到 0..100。
|
||
_change_target_to_picked_instance(点击选目标,PythonPlayerInput 路径)不在本批次范围内,
|
||
维持原逻辑(KIND_PC / KIND_MONSTER 可选中并 _refresh_target_hud)—— 点击 PC 仍会 set_target
|
||
并刷 HUD 目标名,与参考端 SetPCTargetBoard(Open + 名字)大方向一致,血条部分待 GC_TARGET
|
||
真包到达再由本批次的 _on_target_info 收敛。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条,对齐参考
|
||
CPythonCharacterManager 的 RenderCharacterName / bar 排布);chat 尾标 bNameFlag 下移名字
|
||
17px(seam ⑨ 收口);
|
||
或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取 _ray_pick_t 接名字强显)+
|
||
ArrangeTextTail 屏幕空间防重叠;
|
||
或 seam ⑫ 收口:木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog;
|
||
或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 帝国名色模式;
|
||
或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 116(W3→M2 / §8.8 · §5.4 OnRender Picking 块分派 + 悬停角色强显名字 1:1)
|
||
|
||
```text
|
||
批次:增量 116 —— W3→M2 §8.8 / §5.4:game.py GameWindow.OnRender() 的「Picking」块判定树
|
||
(wndMgr.IsPickedWindow 门 → chr.Pick() 命中角色 ShowCharacterTextTail、targetBoard.GetTargetVID()
|
||
ShowCharacterTextTail、__IsShowName() 分叉:非总显名字 → item.Pick(),总显名字 → ShowAllTextTail
|
||
+ textTail.Pick(),末尾 SelectItemName)抽成纯静态谓词;并把「悬停角色强显名字」按 :1535/:1537
|
||
接进 poc —— 仿增量 113 的 _target_vid forced 路数,给 net_world._name_shown 加第 5 路 forced
|
||
_hover_vid,由 player_controller 的射线悬停拾取喂入(纯 GDScript,无 C++ 改动)
|
||
状态:✅ 完成(pick_show.gd 纯静态谓词 + net_world._hover_vid / set_hover_vid + _name_shown forced
|
||
第 5 路 + player_controller.hover_entity_changed 信号 + game_scene 连线)
|
||
🔎 真机目视:鼠标悬停某 NPC / 怪 → 其头顶名字立即强显(即便未开「总显名字」且超 3500 距)、
|
||
移开 → 复原;鼠标压 UI / 悬停地面物品时不强显任何角色名。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;运行时 python =
|
||
metin2-client/assets/root/game.py):
|
||
root/game.py:1519 GameWindow.OnRender()
|
||
root/game.py:1531 textTail.UpdateAllTextTail()
|
||
root/game.py:1533 if True == wndMgr.IsPickedWindow(self.hWnd) —— 鼠标在 3D game 窗口上才拾取
|
||
root/game.py:1535 self.PickingCharacterIndex = chr.Pick() —— 射线拾取角色(-1 = 无)
|
||
root/game.py:1537 if -1 != PickingCharacterIndex: textTail.ShowCharacterTextTail(idx)
|
||
root/game.py:1539 if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(targetVID)
|
||
root/game.py:1543 if not self.__IsShowName(): PickingItemIndex = item.Pick()
|
||
root/game.py:1546 if -1 != PickingItemIndex: textTail.ShowItemTextTail(idx)
|
||
root/game.py:1552 if self.__IsShowName(): textTail.ShowAllTextTail()
|
||
root/game.py:1554 self.PickingItemIndex = textTail.Pick(x, y)
|
||
root/game.py:1557 textTail.UpdateShowingTextTail()
|
||
root/game.py:1558 textTail.ArrangeTextTail() —— 屏幕空间去重叠排布
|
||
root/game.py:1560 if -1 != PickingItemIndex: textTail.SelectItemName(idx)
|
||
seam ⑨:名字尾标 y = 聊天气泡 y − 17px(UserInterface/PythonTextTail.cpp:112-114)尚未接入 —
|
||
poc 名字牌是每实体一个 Label3D,暂无「随 chat 气泡下移」的整合。
|
||
seam ⑩:ArrangeTextTail 的屏幕空间尾标去重叠排布(UserInterface/PythonTextTail.cpp ArrangeTextTail)
|
||
仍缺 —— poc 名字牌是 3D billboard,靠深度天然分层,未做 2D 投影后的挤开。本增量只收口其中
|
||
一小块:把「悬停角色强显名字」按 :1535/:1537 补上(forced 第 5 路)。
|
||
seam(近似):参考里 chr.Pick() 强显与 targetBoard 强显都在 :1533 的 IsPickedWindow 门内;
|
||
poc 把 _target_vid(增量 113)与 _hover_vid(本增量)都当作「无条件 forced」路径 —
|
||
_hover_vid 在鼠标压 UI / 悬停地面物品时由 player_controller 主动置 0,算是部分等效于
|
||
IsPickedWindow 门。
|
||
改动:
|
||
project/pick_show.gd(新,第 27 个 canonical)—— 纯静态 `extends RefCounted`:
|
||
const NO_PICK := -1(chr.Pick() / item.Pick() 无命中哨兵);
|
||
char_pick_active(is_picked_window) = is_picked_window(:1533);
|
||
forced_name_vids(is_picked_window, pick_char_vid, target_vid) -> Array
|
||
—— 非窗口 → [];否则收 pick_char_vid(!= NO_PICK 且 != 0,:1537)与 target_vid(!= 0,:1539),
|
||
去重,顺序 [hover, target];
|
||
should_pick_item(is_picked_window, is_show_name) = is_picked_window and not is_show_name(:1543);
|
||
should_show_all_names(is_show_name) = is_show_name(:1552);
|
||
should_pick_text_tail(is_show_name) = is_show_name(:1554);
|
||
should_select_item_name(pick_item_index) = pick_item_index != NO_PICK(:1560)。
|
||
project/net_world.gd —— const PickShow = preload("res://pick_show.gd");
|
||
var _hover_vid := 0;
|
||
func set_hover_vid(vid)(仿 set_target_vid:值变才刷 prev / vid 两个节点的名字可见性);
|
||
_name_shown():forced := vid != 0 and (vid == _target_vid or vid == _hover_vid)(原只 _target_vid)。
|
||
project/player_controller.gd —— signal hover_entity_changed(vid: int);var _hover_vid := 0;
|
||
_update_hover_cursor() 三个出口都喂 _set_hover_vid():鼠标压 UI → 0、悬停地面物品 → 0、
|
||
射线命中 pickable → int(best.get_meta("vid", 0))、无命中 → 0;
|
||
_set_hover_vid(vid):值变才 hover_entity_changed.emit(vid)(去抖)。
|
||
project/game_scene.gd —— 紧挨 net_play.target_changed → net_world.set_target_vid 那行,
|
||
加 pc.hover_entity_changed → net_world.set_hover_vid 连线(has_signal / has_method 守卫)。
|
||
project/pick_show_test.gd(新,第 27 个 canonical)—— SceneTree 回归,约 24 断言:
|
||
NO_PICK == -1、char_pick_active(窗口内 T / 外 F)、forced_name_vids(非窗口 → []、
|
||
hover+target → [hover, target]、只 hover、只 target、hover=0 视为无、hover==target 去重、
|
||
皆无 → [])、should_pick_item(窗口且非总显名字 T / 总显名字 F / 非窗口 F)、
|
||
should_show_all_names / should_pick_text_tail(总显名字 T / 否 F)、item.Pick 与 textTail.Pick
|
||
在窗口内恰好二选一、should_select_item_name(7 T / 0 T / -1 F)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script pick_show_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 67 个全 PASS(新增 pick_show_test;含 net_world_vis_test /
|
||
netplay_test / netbridge_test / gamescene_test / combat_fx_test / target_board_test /
|
||
name_show_test / damage_effect_test / …,两次运行均 PASS=67 FAIL=0)。
|
||
验收:
|
||
已验证(离线):OnRender Picking 判定树全部谓词 —— IsPickedWindow 门、chr.Pick() 强显集合
|
||
收集 + 去重 + [hover, target] 顺序、非窗口时连 target 强显也不做、__IsShowName() 分叉
|
||
(非总显名字 → item.Pick、总显名字 → ShowAllTextTail + textTail.Pick,二者互斥)、
|
||
SelectItemName 门;net_world._hover_vid 值变刷新、_name_shown forced 第 5 路;
|
||
player_controller 三出口喂 _hover_vid + emit 去抖;game_scene 连线;全 67 回归。
|
||
未验证:真机 —— 悬停 NPC / 怪时头顶名字强显、移开复原、鼠标压 UI / 悬停地面物品不误强显、
|
||
与增量 113 目标强显并存无闪烁、悬停命中判定(_ray_pick_t 命中半径)与参考 chr.Pick()
|
||
的观感差异、ArrangeTextTail 屏幕空间防重叠(seam ⑩,未做)、名字随 chat 气泡下移 17px
|
||
(seam ⑨,未做)。
|
||
风险与依赖:
|
||
seam ⑨ / ⑩(见上「参考」):屏幕空间尾标 y 偏移与去重叠排布仍缺;本增量只收口
|
||
「悬停角色强显名字」一小块。
|
||
seam(近似):_target_vid / _hover_vid 当作无条件 forced,非严格 IsPickedWindow 门内;
|
||
_hover_vid 靠 player_controller 在压 UI / 悬停物品时置 0 部分等效。
|
||
行为变化:此前只有 targetBoard 目标(增量 113)会 forced 强显头顶名字;现悬停到的角色
|
||
(player_controller 射线拾取,pickables 带 vid meta)也 forced 强显 —— 即便未开「总显名字」
|
||
且超 3500 平面距。悬停命中判定沿用既有 _ray_pick_t(cursor 用的同一套),未新增射线开销。
|
||
net_play._on_target_info(增量 115)不受影响。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 收口(名字尾标随 chat 气泡下移 17px,PythonTextTail.cpp:112-114);
|
||
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog);
|
||
或 §8.4 名字色 radio(name_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 117(W3→M2 / §8.8 seam ⑨ 收口:头顶名字尾标随聊天气泡上移 17px 1:1)
|
||
|
||
```text
|
||
批次:增量 117 —— W3→M2 §8.8 seam ⑨ 收口:CPythonTextTail::UpdateShowingTextTail 里
|
||
每条角色名字尾标投影完屏幕坐标后,若该 VID 在 m_ChatTailMap 命中且那条的 bNameFlag
|
||
为 TRUE(= RegisterChatTail 注册的「聊天」气泡,非 RegisterInfoTail 的「信息」气泡),
|
||
就把名字尾标的屏幕 y 顶到 `chatTail.y − 17.0f`(屏幕 y 向下,减 17px = 上移一行贴气泡
|
||
上沿)。抽成纯静态谓词 + 公式,并接进 poc:有「聊天」气泡时把名字 Label3D 的局部 y
|
||
顶到气泡 y 之上 17 个名字像素,气泡消失或只是「信息」气泡时回默认 y(纯 GDScript)。
|
||
状态:✅ 完成(name_tail_layout.gd 纯静态第 28 个 canonical + net_world._reposition_name_tag
|
||
由 _apply_chat_tail / _expire_chat_tails 调用 + NAME_TAG_DEFAULT_Y / BUBBLE_TAG_Y 常量)
|
||
🔎 真机目视:某角色冒「聊天」气泡时其头顶名字上移贴到气泡上沿、气泡消失后名字回原位;
|
||
「信息」尾标(技能 / 射击错误,淡红)不顶名字。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
|
||
UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail(..., float fAddHeight=10.0f)
|
||
:519 fHeight = GetHeight() + fAddHeight —— 名字尾标世界高=角色高 + 10
|
||
UserInterface/PythonTextTail.cpp:581 RegisterChatTail:新建 fHeight = GetHeight() + 10.0f、
|
||
:603 / :618 pTextTail->bNameFlag = TRUE —— 「聊天」气泡(白)
|
||
UserInterface/PythonTextTail.cpp:623 RegisterInfoTail:同高,
|
||
:646 / :661 pTextTail->bNameFlag = FALSE —— 「信息」气泡(淡红),不顶名字
|
||
UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail()
|
||
:101 for (角色名字尾标 : m_CharacterTextTailList)
|
||
:104 UpdateTextTail(pTextTail) —— 先投影出屏幕 x/y/z
|
||
:106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID)
|
||
:110 if (m_ChatTailMap.end() != itor)
|
||
:112 if (pChatTail->bNameFlag)
|
||
:114 pTextTail->y = pChatTail->y - 17.0f —— 名字屏幕 y 顶到气泡上方 17px
|
||
UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:同一个 bNameFlag 还驱动
|
||
ShowCharacterTextTail(dwVID) 强显(增量 112 / 113 已接 chat_tail.gd / name_show.gd)。
|
||
seam ⑨(收窄):参考端名字与气泡默认投影到同一世界点(GetHeight()+10),靠这条屏幕
|
||
−17px 把名字从「和气泡重叠」顶成「贴气泡上沿」;poc 是每实体一个 billboard Label3D、
|
||
气泡默认在名字上方(局部 y 2.35 vs 2.1),本增量把屏幕 y 向下的 −17px 换成世界 y
|
||
向上的 +17px*pixel_size。剩余 seam ⑨:气泡与名字 Label3D 仍是两个并存节点(非参考
|
||
的单尾标链表)、子标签(GuildTag / TitleTag / LevelTag)不随动 = seam ⑦ 遗留。
|
||
改动:
|
||
project/name_tail_layout.gd(新,第 28 个 canonical)—— 纯静态 `extends RefCounted`:
|
||
const NAME_OFFSET_ABOVE_BUBBLE_PX := 17.0(PythonTextTail.cpp:114 的 17.0f);
|
||
name_follows_bubble(has_chat_tail, chat_name_flag) = has_chat_tail and chat_name_flag
|
||
(:110 有尾标 && :112 bNameFlag);
|
||
name_tail_screen_y(default_y, bubble_y, follows) —— 参考端字面(屏幕 y 向下):
|
||
follows → bubble_y − 17,否则 default_y;
|
||
name_tail_world_y(default_world_y, bubble_world_y, follows, px_size) —— poc 3D 适配
|
||
(世界 y 向上):follows → bubble_world_y + 17*px_size,否则 default_world_y。
|
||
project/net_world.gd —— const NameTailLayout = preload("res://name_tail_layout.gd");
|
||
const NAME_TAG_DEFAULT_Y := 2.1 / const BUBBLE_TAG_Y := 2.35(名字 / 气泡 Label3D 默认局部 y);
|
||
_attach_nameplate 用 NAME_TAG_DEFAULT_Y;_apply_chat_tail 用 BUBBLE_TAG_Y 建气泡节点、
|
||
末尾 _set_name_visibility(n) 后加 _reposition_name_tag(n);
|
||
_expire_chat_tails 删气泡节点后同样 _set_name_visibility(n) → _reposition_name_tag(n);
|
||
func _reposition_name_tag(n):取 n 的 "Label3D" 子节点,读 _chat_tails[vid].name_flag,
|
||
follows := NameTailLayout.name_follows_bubble(有尾标, name_flag),
|
||
tag.position.y = NameTailLayout.name_tail_world_y(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y,
|
||
follows, tag.pixel_size)。
|
||
project/name_tail_layout_test.gd(新,第 28 个 canonical)—— SceneTree 回归:
|
||
常量 17.0;name_follows_bubble(T/T→T、T/F→F、F/T→F、F/F→F);
|
||
name_tail_screen_y(follows → 250−17=233、!follows → 300、信息气泡 follows=false → 300);
|
||
name_tail_world_y(follows → 2.35+17*0.006=2.452、!follows → 2.1、结果 > 2.35、
|
||
px_size 翻倍 → 偏移翻倍 2.35+0.204);组合链(聊天气泡 → 2.452、气泡过期 → 2.1)。
|
||
project/chat_test.gd(改)—— 「气泡」段加断言:entity 2000 冒气泡后 Label3D.position.y
|
||
== bubble.position.y + 17*pixel_size 且 > bubble.position.y;再 register_info_tail(2000)
|
||
覆盖成信息尾标 → 名字回默认局部 y 2.1。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 68 个全 PASS(新增 name_tail_layout_test;chat_test 扩断言;
|
||
含 net_world_vis_test / chat_tail_test / name_show_test / pick_show_test / …,PASS=68 FAIL=0)。
|
||
验收:
|
||
已验证(离线):name_follows_bubble 判据(有 chat 尾标 && bNameFlag)、屏幕端字面公式
|
||
bubble_y − 17、poc 世界端公式 bubble_world_y + 17*pixel_size(含 pixel_size 线性缩放)、
|
||
信息尾标(bNameFlag FALSE)不顶名字、气泡过期名字回默认 y;net_world 端 _apply_chat_tail /
|
||
_expire_chat_tails 两处调用后名字 Label3D 局部 y 正确切换(chat_test 端到端);全 68 回归。
|
||
未验证:真机 —— 冒气泡时名字上移贴气泡上沿的观感、气泡消失名字回位无跳变、与增量 113
|
||
的 bNameFlag 强显并存、信息尾标(技能 / 射击错误)不顶名字、子标签不随动的视觉突兀度
|
||
(seam ⑦ / ⑨ 遗留)、ArrangeTextTail 屏幕空间防重叠(seam ⑩,仍未做)。
|
||
风险与依赖:
|
||
seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表);
|
||
子标签 GuildTag / TitleTag / LevelTag 不随名字上移 = seam ⑦ 遗留。
|
||
seam ⑩:ArrangeTextTail 屏幕空间尾标去重叠排布仍缺(poc 3D billboard 靠深度天然分层)。
|
||
换算常量:17px → 世界单位按名字 Label3D 的 pixel_size(0.006)线性换算,与参考的
|
||
字体无关常量 17 对齐;气泡 Label3D 的 pixel_size(0.0055)不参与,只用其局部 y。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表);
|
||
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog);
|
||
或 §8.4 名字色 radio(name_color_normal / name_color_empire)接 name_color.gd 帝国名色模式。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 118(W3→M2 / §5.4 · §8.8 · §8.4 目标框「看他国玩家」前置过滤 + 名字色 radio 接线 1:1)
|
||
|
||
```text
|
||
批次:增量 118 —— W3→M2 §5.4 / §8.8 / §8.4:
|
||
(a) root/uitarget.py TargetBoard.Open(vid,name) 在整个 RecvTargetPacket 判定之前的
|
||
异阵营前置过滤(:189-193):game-option 关「看他国玩家目标框」
|
||
(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD,默认 1 = 开)时,若目标与主角
|
||
不同阵营(player.IsSameEmpire(vid) 为假),直接 Hide() + return,不开目标框。
|
||
抽成纯静态谓词,接进 net_play._on_target_info 的 classify 之前。
|
||
(b) §8.4「名字色」radio(name_color_normal / name_color_empire)接线收口:
|
||
game_scene._on_display_option 增两条分派 —— "name_color" → net_world.set_empire_name_mode、
|
||
"target_board" → net_play.set_view_other_empire_target;开局各自用
|
||
game_option_ui.display_value(...) == 1 回填。帝国名色调色板算法本体增量 110 已 1:1。
|
||
状态:✅ 完成(target_board.hidden_as_other_empire 纯静态谓词 + net_play._view_other_empire_target /
|
||
set_view_other_empire_target / _on_target_info 前置过滤 + game_scene 两条分派与两处开局回填 +
|
||
game_option_ui.gd target_board 默认 0→1 修正)
|
||
🔎 真机目视:game-option 关「看他国目标框」后点异阵营玩家不弹目标框、点同阵营 / 怪 / NPC 照旧;
|
||
「名字色」切「阵营」后头顶名字按阵营配色(红 / 橙 / 蓝),切「普通」后回黄。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/;root/ = assets/root/):
|
||
root/uitarget.py:189-193 TargetBoard.Open(vid,name):
|
||
if not constInfo.GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD():
|
||
if not player.IsSameEmpire(vid): self.Hide(); return
|
||
root/constinfo.py:38 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1(默认开)
|
||
root/constinfo.py:139-145 SET_ / GET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)
|
||
root/uigameoption.py target_board_view / target_board_no_view radio →
|
||
constInfo.SET_VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD(index)(flag 值 == 按钮列表下标)
|
||
root/uigameoption.py __SetNameColorMode(index) → constInfo.SET_CHRNAME_COLOR_INDEX(index)
|
||
→ chrmgr.SetEmpireNameMode(index);name_color_normal→0(全 PC 黄)、name_color_empire→1(阵营色)
|
||
root/constinfo.py CHRNAME_COLOR_INDEX = 0(默认普通);SET_DEFAULT_CHRNAME_COLOR() 载入时
|
||
调 SetEmpireNameMode(CHRNAME_COLOR_INDEX)
|
||
UserInterface/InstanceBase.cpp:470 CInstanceBase::IsSameEmpire —— victim.empire == 0 或
|
||
任一方 IsGameMaster() 或 两方 empire 相等 → true(entity_rules._is_same_empire 已 1:1)
|
||
UserInterface/InstanceBaseEffect.cpp:52 CInstanceBase::SetEmpireNameMode(bool) 调色板重写
|
||
(增量 110 已迁 name_color.gd::set_empire_name_mode)
|
||
seam ⑫:poc 无独立「他国 PC 名字框」窗体(目标框只有 HP 条形态),本增量把同一道阵营门
|
||
加在 net_play._on_target_info 分派前;building / wooden-door 实体类型、LCONTROL 悄悄话
|
||
捷径仍未做(延续增量 115)。
|
||
改动:
|
||
project/target_board.gd(改)—— 新增静态谓词:
|
||
hidden_as_other_empire(view_other_empire: bool, same_empire: bool) -> bool
|
||
= not view_other_empire and not same_empire(uitarget.py:189-193 字面);
|
||
头部 REF 段补 root/uitarget.py:189-193 前置过滤条目。
|
||
project/net_play.gd(改)—— var _view_other_empire_target := true
|
||
(constinfo VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD 默认 1);
|
||
func set_view_other_empire_target(on: bool)(§8.4 radio 驱动);
|
||
_on_target_info:在 is_building 之后、can_view 之前插前置过滤 —— if exists:
|
||
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
|
||
if TargetBoard.hidden_as_other_empire(_view_other_empire_target, same_empire):
|
||
_clear_target(); return
|
||
project/ui/game_option_ui.gd(改)—— setup() 默认值列表 line 95:
|
||
"target_board" 并入默认 1 的键组(对齐 VIEW_OTHER_EMPIRE_PLAYER_TARGET_BOARD = 1;
|
||
原来漏了 → 默认落到 0,与参考相反)。radio 接线本就走通用 RADIO_GROUPS /
|
||
DISPLAY_RADIOS,无需再动。
|
||
project/game_scene.gd(改)—— 开局在 set_show_damage 回填之后加两段:
|
||
net_world.set_empire_name_mode(game_option_ui.display_value("name_color") == 1)、
|
||
net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1);
|
||
_on_display_option 加两条 elif —— key == "name_color" → net_world.set_empire_name_mode(value == 1)、
|
||
key == "target_board" → net_play.set_view_other_empire_target(value == 1)。
|
||
project/target_board_test.gd(改)—— 新增 5b 段:hidden_as_other_empire
|
||
(关+异阵营→true、关+同阵营→false、开+异阵营→false、开+同阵营→false)。
|
||
project/netplay_test.gd(改)—— 新增 8b 段:主角 empire 1 + 异阵营 PC(vid 2500,empire 2);
|
||
看他国框开 → 异阵营 PC 目标框保留(CLOSE_IF_DIFFERENT,同 VID 不关);
|
||
看他国框关 → 前置过滤直接 _clear_target()(_target_vid 归 0、hud.cleared +1);
|
||
看他国框关不影响怪(empire 0 → IsSameEmpire 真 → HP 血条照设);收尾还原开关 + 清 empire 键。
|
||
project/system_menu_ui_test.gd(改)—— GameOptionUI 段加断言:
|
||
name_color_empire 按下 → _display.name_color == 1 + display_option_changed("name_color",1);
|
||
target_board 默认读作 1;target_board_no_view 按下 → _display.target_board == 0 +
|
||
display_option_changed("target_board",0);二次 ConfigFile 读 user://system_option.cfg
|
||
[gameopt] name_color == 1 / target_board == 0 已持久化。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script target_board_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script system_menu_ui_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有三个;PASS=68 FAIL=0)。
|
||
验收:
|
||
已验证(离线):hidden_as_other_empire 四组合真值;net_play._on_target_info 前置过滤
|
||
(看他国框开→异阵营 PC 走原判定树、关→清框 return、关不影响 empire 0 的怪);
|
||
game_option_ui 的 target_board 默认值修正为 1、name_color / target_board radio →
|
||
_display + user://system_option.cfg 持久化 + display_option_changed 信号;全 68 回归。
|
||
未验证:真机 —— game-option 关「看他国目标框」后点异阵营玩家不弹框的观感、
|
||
切「名字色 / 阵营」后头顶名字按阵营配色(依赖真服 GC_PVP / empire 下发)、
|
||
与增量 115 目标框判定树、增量 110 名色整表刷新的并存;building / wooden-door
|
||
实体类型 + LCONTROL 悄悄话捷径仍是 seam ⑫。
|
||
风险与依赖:
|
||
seam ⑫(延续):poc 无 building / wooden-door 实体类型(is_building 恒 false)、
|
||
目标框点击 LCONTROL 悄悄话捷径未接 whisper dialog。
|
||
忠于参考:hidden_as_other_empire 不按 is_pc 收窄 —— empire 0 的怪 / NPC / 石头在
|
||
_is_same_empire 里恒「同阵营」故本就不会被挡,加 is_pc 反而偏离参考。
|
||
名字色算法本体(SetEmpireNameMode 调色板)增量 110 已 1:1 并测;本增量只补 radio → 运行期
|
||
开关的最后一段接线,不碰算法。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑦ / ⑨ 深化(子标签随名字上移 / 名字与气泡合成单尾标链表);
|
||
seam ⑩ 深化(ArrangeTextTail:2D 投影后名字牌互相挤开防重叠);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 119(W3→M2 / §8.8 seam ⑦ 收口:公会名 / 称号 / 等级子标签随名字尾标纵向位移 1:1)
|
||
|
||
```text
|
||
批次:增量 119 —— W3→M2 §8.8 seam ⑦ 收口:CPythonTextTail::ArrangeTextTail 里,逐条
|
||
角色名字尾标(m_CharacterTextTailList)的公会名 / 称号 / 等级三个子标签的屏幕坐标
|
||
全部锚在 `pTextTail->y` 上(公会名 y - c_fyGuildNamePosition(15px)、称号 / 等级同 y),
|
||
而这个 y 已在 UpdateShowingTextTail :114 被聊天气泡顶过 -17。所以名字尾标一旦因
|
||
「聊天」气泡上移,三个子标签整体跟随同一段纵向位移。增量 117 只让名字尾标本身跟随,
|
||
子标签当时留作 seam ⑦ 遗留 —— 本增量补齐「子标签随名字尾标纵向位移」这一 1:1 行为。
|
||
状态:✅ 完成(name_tail_layout.gd 增 name_tail_lift_delta / subtag_world_y 两个纯静态函数
|
||
+ 常量 GUILD_NAME_ABOVE_NAME_PX;net_world._reposition_name_tag 在顶名字尾标后按
|
||
lift_delta 平移 GuildTag / TitleTag / LevelTag;子标签默认局部 y 提为 SUBTAG_DEFAULT_Y 常量)
|
||
🔎 真机目视:远端 PC 冒聊天气泡时,头顶「公会名 / 称号 / 等级」随名字一起上移贴气泡,
|
||
气泡过期 / 只是「信息」尾标时整体回落默认位置;三者相对间距全程不变。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:88 UpdateShowingTextTail()
|
||
:101 for (pTextTail : m_CharacterTextTailList)
|
||
:106 itor = m_ChatTailMap.find(pTextTail->dwVirtualID)
|
||
:112 if (pChatTail->bNameFlag)
|
||
:114 pTextTail->y = pChatTail->y - 17.0f —— 名字尾标屏幕 y 顶到气泡上方 17px
|
||
UserInterface/PythonTextTail.cpp:151 ArrangeTextTail()
|
||
:211 for (pTextTail : m_CharacterTextTailList) —— 逐条角色名字尾标
|
||
:21 const float c_fyGuildNamePosition = 15.0f
|
||
:228 pGuildNameInstance->SetPosition(x + iImageHalfSize, pTextTail->y - c_fyGuildNamePosition, z)
|
||
:258 pTitle->SetPosition(x - iNameWidth/2 - fxAdd, pTextTail->y, z) —— 称号与名字同一行
|
||
:283 pLevel->SetPosition(x - iNameWidth/2 - fxAdd - iTitleWidth, pTextTail->y, z) —— 等级同一行
|
||
—— 三个子标签的屏幕 y 都取自 pTextTail->y(已被 :114 顶过)。名字上移多少,子标签上移多少。
|
||
seam ⑦(收窄后剩余):poc 把三个子标签竖排(公会名局部 y 2.34 在名字上、称号 1.86 /
|
||
等级 1.74 在名字下),参考端是屏幕空间:公会名在名字上方 15px、称号 / 等级与名字同一
|
||
水平行按各自文本宽度左偏。这套逐像素水平排版差异仍留 seam ⑦;本增量只对齐「纵向
|
||
整体跟随名字尾标位移」。
|
||
改动:
|
||
project/name_tail_layout.gd(改)—— 新增:
|
||
const GUILD_NAME_ABOVE_NAME_PX := 15.0(PythonTextTail.cpp:21 c_fyGuildNamePosition,
|
||
仅作 1:1 记录,poc 竖排近似不用它做像素排版);
|
||
name_tail_lift_delta(default_world_y, bubble_world_y, follows, px_size) -> float
|
||
= name_tail_world_y(...) - default_world_y(follows 时 = 顶到气泡上方后的净上移,否则 0);
|
||
subtag_world_y(default_subtag_y, name_tail_lift_delta) -> float
|
||
= default_subtag_y + name_tail_lift_delta(三子标签整体平移同一段)。
|
||
头部 REF 段补 ArrangeTextTail :211/:228/:258/:283 条目;seam ⑨ 标「已收口」、
|
||
seam ⑦ 标「部分收口 · 增量 119」(纵向跟随已 1:1,逐像素水平排版仍近似)。
|
||
project/net_world.gd(改)—— const SUBTAG_DEFAULT_Y := {"GuildTag": 2.34,
|
||
"TitleTag": 1.86, "LevelTag": 1.74}(原来散落在 _attach_nameplate 的字面量,提为单一来源);
|
||
_attach_nameplate 的 _mk_sub_tag 调用改用 SUBTAG_DEFAULT_Y[...];
|
||
_reposition_name_tag:在设完 tag.position.y 后算
|
||
lift := NameTailLayout.name_tail_lift_delta(NAME_TAG_DEFAULT_Y, BUBBLE_TAG_Y, follows, tag.pixel_size),
|
||
for sub_name in SUBTAG_DEFAULT_Y: st.position.y = NameTailLayout.subtag_world_y(SUBTAG_DEFAULT_Y[sub_name], lift)。
|
||
(_reposition_name_tag 已在 _apply_chat_tail 气泡出现 / _expire_chat_tails 气泡过期两处调用,
|
||
双向覆盖。)
|
||
project/name_tail_layout_test.gd(改)—— 新增「4. seam ⑦ 收口」段:
|
||
GUILD_NAME_ABOVE_NAME_PX == 15.0;name_tail_lift_delta(follows→0.352、!follows→0、
|
||
px_size 翻倍→更大);subtag_world_y(GuildTag 2.34→2.692、TitleTag 1.86→2.212、
|
||
LevelTag 1.74→2.092);平移后子标签相对间距不变;lift 0 时回默认。
|
||
project/chat_test.gd(改)—— 气泡段(真 NetWorld + entity 2000)加断言:
|
||
entity 有 GuildTag / TitleTag / LevelTag;名字冒聊天气泡后 lift > 0 且三子标签
|
||
position.y == 默认 + lift;register_info_tail 覆盖成信息尾标后三子标签回默认
|
||
2.34 / 1.86 / 1.74。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
|
||
回归:project/*_test.gd 全 68 个全 PASS(无新增测试文件,断言并入既有两个;PASS=68 FAIL=0)。
|
||
验收:
|
||
已验证(离线):name_tail_lift_delta / subtag_world_y 数值;真 NetWorld 下 entity 冒
|
||
「聊天」气泡 → GuildTag / TitleTag / LevelTag 三者 position.y 各自加同一 lift、
|
||
相对间距不变;覆盖成「信息」尾标 → 三者回默认局部 y;全 68 回归。
|
||
未验证:真机 —— 远端 PC 冒气泡时子标签随名字上移贴气泡的观感、与增量 111 子标签
|
||
内容 / 颜色(RefreshTextTail / AttachTitle / AttachLevel)、增量 117 名字尾标位移的并存。
|
||
风险与依赖:
|
||
seam ⑦(收窄后剩余):子标签竖排 vs 参考屏幕空间逐像素水平排版(公会名上方 15px、
|
||
称号 / 等级同水平行左偏)仍是近似;本增量只对齐纵向跟随。
|
||
seam ⑨(收窄后剩余):气泡与名字仍是两个并存 Label3D 节点(非参考单尾标链表)。
|
||
忠于参考:子标签平移量取名字尾标的净位移(name_tail_lift_delta),与参考端「子标签
|
||
锚在已被顶过的 pTextTail->y」严格等价;不引入 poc 自造的额外偏移。
|
||
下一批次:
|
||
seam ⑩ 深化(ArrangeTextTail :151-210 item 尾标 isIn 去重叠:2D 投影后名字牌互相挤开);
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 120(W3→M2 / §8.8 seam ⑩ 收口:ArrangeTextTail item 尾标去重叠 + isIn 1:1)
|
||
|
||
```text
|
||
批次:增量 120 —— W3→M2 §8.8 seam ⑩ 收口:CPythonTextTail::ArrangeTextTail 的
|
||
m_ItemTextTailList 块(PythonTextTail.cpp:160-190)。逐条地面掉落名条,与其它每条比
|
||
isIn(标准 AABB 相交,:489-507),命中就把「自己」顶到对方框底下
|
||
`pCompareTextTail->y + pCompareTextTail->yEnd + yTemp`(yTemp = 5),内层循环从头重跑,
|
||
单条最多下推 LimitCount=20 次防死循环。增量 116 只做了 OnRender Picking 分派(seam ⑩
|
||
的悬停强显名字部分),这次补齐「多条 item 尾标投影后互相挤开」这一 1:1 行为。
|
||
状态:✅ 完成(新增第 29 个 canonical 纯静态模块 text_tail_arrange.gd:boxes_overlap(isIn)
|
||
+ arrange_item_tails(外层逐条 / 内层比对 / isIn 命中顶到框底下 / 从头重跑 / MAX_ITERS 封顶);
|
||
ground_items._arrange_tags 每帧把所有掉落名条世界锚点投影到屏幕、按文本字符数估框、
|
||
跑 arrange_item_tails、把被下推的屏幕 Δy 按 Label3D.pixel_size 折回世界局部 y;
|
||
game_scene 把相机 getter 传进 ground_items.setup)
|
||
🔎 真机目视:几件掉落物叠在一起时,名条在屏幕上依次往下错开不再糊成一团;散开 / 捡走后回位。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:19 const float c_TextTail_Name_Position = -10.0f
|
||
UserInterface/PythonTextTail.cpp:15 const D3DXCOLOR c_TextTail_Item_Color = D3DXCOLOR(1,1,1,1)
|
||
UserInterface/PythonTextTail.cpp:153 ArrangeTextTail()
|
||
:160 for (pInsertTextTail : m_ItemTextTailList) —— 逐条 item 名条
|
||
:164 int yTemp = 5; int LimitCount = 0;
|
||
:167 for (pCompareTextTail : m_ItemTextTailList) —— 内层比对
|
||
:171 if (*itorCompare == *itor) { ++itorCompare; continue; } —— 跳过自己
|
||
:176 if (LimitCount >= 20) break;
|
||
:180 if (isIn(pInsertTextTail, pCompareTextTail))
|
||
:182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp;
|
||
:184 itorCompare = m_ItemTextTailList.begin(); ++LimitCount; continue; —— 从头重跑
|
||
:189 ++itorCompare;
|
||
UserInterface/PythonTextTail.cpp:489 isIn(pSource, pTarget):
|
||
框 = [x + xStart, x + xEnd] × [y + yStart, y + yEnd](两框 Source / Target 同式);
|
||
:500 if (x1S <= x2T && x2S >= x1T && y1S <= y2T && y2S >= y1T) return true; —— 含边界
|
||
UserInterface/PythonTextTail.cpp:810-813 尾标框(RegisterTextTail 内 GetTextSize 后):
|
||
xStart = -xSize/2 - 2、yStart = -2、xEnd = xSize/2 + 2、yEnd = ySize
|
||
UserInterface/PythonTextTail.cpp:567 RegisterItemTextTail(VirtualID, c_szText, pOwner)
|
||
→ RegisterTextTail(..., c_TextTail_Name_Position, c_TextTail_Item_Color) → m_ItemTextTailList.push_back
|
||
seam ⑩(收窄后剩余):参考端在屏幕像素空间跑(y 向下、GetTextSize 精确像素),poc 每物
|
||
一个 billboard Label3D —— 名条框按文本字符数 * 7px 估宽、单行 15px 估高(≠ 精确像素);
|
||
只对掉落 item 名条做,角色名字尾标不参与(与参考端 m_ItemTextTailList / m_CharacterTextTailList
|
||
分表一致)。屏幕 Δy 折世界用 name_tail_layout 的同一 pixel_size 约定。
|
||
改动:
|
||
project/text_tail_arrange.gd(新,第 29 个 canonical 纯静态模块,extends RefCounted):
|
||
const PUSH_GAP := 5.0(yTemp)、const MAX_ITERS := 20(LimitCount 上限);
|
||
box_start_x(w) = -w/2 - 2、box_end_x(w) = w/2 + 2、box_start_y() = -2、box_end_y(h) = h;
|
||
boxes_overlap(a, b) —— isIn 的 AABB 相交(a/b = {x, y, w, h},含边界 <= / >=);
|
||
arrange_item_tails(tails: Array[{x,y,w,h}]) -> Array[float] —— 返回每条排布后的屏幕 y:
|
||
外层逐条 i,内层逐条 j(j==i 跳过),boxes_overlap 命中则
|
||
ys[i] = ys[j] + box_end_y(h_j) + PUSH_GAP、j 归零、limit++,limit >= MAX_ITERS 时 break;
|
||
pCompare 用「已排布后」的 ys[j](与参考端共用一张链表就地改写一致)。
|
||
project/ui/ground_items.gd(改)—— const TextTailArrange = preload;
|
||
const TAG_BASE_Y := 0.75 / TAG_CHAR_W_PX := 7.0 / TAG_LINE_H_PX := 15.0;
|
||
var _camera_getter: Callable;setup() 尾部加可选 camera_getter 参数;
|
||
建 tag 时 tag.position.y = TAG_BASE_Y(原字面量 0.75);
|
||
_process 尾部调 _arrange_tags():取相机 → 按 vid 排序遍历掉落 → cam.unproject_position
|
||
各名条世界锚点(背面 / 无相机跳过并回默认)→ 组 {x, y, w = max(1, len*7), h = 15} →
|
||
arrange_item_tails → 各名条 position.y = TAG_BASE_Y - (arranged_y - y0) * tag.pixel_size;
|
||
可见名条 < 2 时直接回默认。
|
||
project/game_scene.gd(改)—— ground_items.setup(...) 第 6 参传 func() -> Camera3D: return cam。
|
||
project/text_tail_arrange_test.gd(新)—— 常量;box_* 四边;boxes_overlap(x/y 都叠→真、
|
||
对称、边界相接含边界→真、x 分离→假、y 分离→假);arrange_item_tails(单条不动、
|
||
两条不叠不动、两条同位→第 1 条顶到 121 第 2 条留 100、三条同位→[121,142,100] 且两两不叠、
|
||
40 条全同位→正常返回不死循环 & 下推量 <= MAX_ITERS*(h+gap)=340、列表顺序即
|
||
m_ItemTextTailList 顺序靠前的先让位)。
|
||
project/p2b_test.gd(改)—— 加 seam ⑩ 段:独立 mount2 / FakeClient / 真 Camera3D,
|
||
单条掉落名条留默认 0.75;再加一条同位 → 靠前(vid 小)名条 position.y 被下推
|
||
(= 0.75 - (15+5)*0.005 = 0.65)、靠后留 0.75、两者局部 y 不同;把靠后一条挪远 → 两条回默认。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS。
|
||
回归:project/*_test.gd 共 69 个全 PASS(新增 text_tail_arrange_test.gd;PASS=69 FAIL=0)。
|
||
验收:
|
||
已验证(离线):isIn AABB(含边界);arrange_item_tails 逐条 / 从头重跑 / MAX_ITERS 封顶
|
||
的数值轨迹(两条同位 → [121, 100]、三条同位 → [121, 142, 100]、40 条不死循环);
|
||
真 Camera3D + 真 GroundItems 下同位两掉落名条投影后靠前一条按 (h+gap)*pixel_size 下推、
|
||
靠后留默认、拉开距离后回默认;全 69 回归。
|
||
未验证:真机 —— 几件掉落物同屏叠放时名条往下错开的观感、与增量 116 悬停强显、
|
||
拾取范围高亮(_process 里同时跑)的并存;文本框按字符数估宽 vs 参考 GetTextSize 的像素差。
|
||
风险与依赖:
|
||
seam ⑩(收窄后剩余):名条框按「字符数 * 7px」估,参考端是 GetTextSize 精确像素;
|
||
poc 是每物 billboard Label3D 折世界 y,参考端是屏幕像素空间正交文本实例。
|
||
忠于参考:只对掉落 item 名条去重叠(m_ItemTextTailList),角色名字尾标不动(分表一致);
|
||
下推公式、内层从头重跑、LimitCount=20 封顶均 1:1;屏幕 Δy 折世界沿用 name_tail_layout
|
||
既定的 pixel_size 约定,不引入 poc 自造额外系数。
|
||
每帧对所有掉落名条投影:poc 掉落物规模小可接受;真机若海量掉落需加节流 / 距离裁剪。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 深化(子标签逐像素水平排版:公会名名字上方 15px、称号 / 等级同水平行按文本宽左偏);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 121(W3→M2 / §8.8 seam ⑦ 水平深化:ArrangeTextTail 角色块子标签逐像素水平排版 1:1)
|
||
|
||
```text
|
||
批次:增量 121 —— W3→M2 §8.8 seam ⑦ 水平深化:CPythonTextTail::ArrangeTextTail 的
|
||
m_CharacterTextTailList 块(PythonTextTail.cpp:211-320)非 EUROPE 分支的公会名 / 称号 /
|
||
等级 / 名字四个文本实例逐像素水平排布。增量 119 已收口这三个子标签「随名字尾标纵向位移」,
|
||
这次补齐「称号 / 等级摆在名字锚点左侧、名字本身右移 fxAdd」这一 1:1 水平行为。
|
||
状态:✅ 完成(name_tail_layout.gd(第 28 个 canonical)加 6 个纯静态水平排版函数:
|
||
name_x_shift / title_x_offset / level_x_offset / guild_name_x_offset / mark_x_offset /
|
||
subtag_world_x;net_world._apply_text_tail_to 尾部新增 _layout_subtags_x(n) 把这些像素
|
||
偏移按各子标签 Label3D.pixel_size 折成 billboard 本地 x)
|
||
🔎 真机目视:角色有称号 / 等级时,等级在最左、称号居中偏左、名字略右移;关 PVP / 撤称号后回位。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:20 const float c_fxMarkPosition = 1.5f
|
||
UserInterface/PythonTextTail.cpp:21 const float c_fyGuildNamePosition = 15.0f
|
||
UserInterface/PythonTextTail.cpp:22 const float c_fyMarkPosition = 15.0f + 11.0f (= 26)
|
||
UserInterface/PythonTextTail.cpp:211 for (pTextTail : m_CharacterTextTailList)
|
||
:218 CGraphicMarkInstance * pMarkInstance = pTextTail->pMarkInstance;
|
||
:220 if (pMarkInstance && pGuildNameInstance) —— 公会名水平锚点仅此路改写
|
||
:224 iImageHalfSize = pMarkInstance->GetWidth()/2 + c_fxMarkPosition;
|
||
:227 pMarkInstance->SetPosition(x - iWidth/2 - iImageHalfSize, y - c_fyMarkPosition);
|
||
:228 pGuildNameInstance->SetPosition(x + iImageHalfSize, y - c_fyGuildNamePosition, z);
|
||
:231 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight);
|
||
:236 float fxAdd = 0.0f;
|
||
:242 if (pTitle) { fxAdd = 8.0f;
|
||
:258 pTitle->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (非 EUROPE)
|
||
:263 if (pLevel)
|
||
:282 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd - iTitleWidth, pTextTail->y, z);
|
||
:288 } else { fxAdd = 4.0f;
|
||
:312 pLevel->SetPosition(x - (iNameWidth/2) - fxAdd, pTextTail->y, z); (只有等级)
|
||
:320 pTextTail->pTextInstance->SetPosition(x + fxAdd, pTextTail->y, z); —— 名字右移 fxAdd
|
||
UserInterface/PythonTextTail.cpp:555 RegisterCharacterTextTail 内公会名实例 CENTER 对齐
|
||
UserInterface/PythonTextTail.cpp:905 AttachTitle:非 EUROPE HORIZONTAL_ALIGN_CENTER
|
||
UserInterface/PythonTextTail.cpp:958 AttachLevel:HORIZONTAL_ALIGN_RIGHT
|
||
seam ⑦(收窄后剩余):① poc 无公会徽记 mark 实体 —— 参考端 :220 的
|
||
`if (pMarkInstance && pGuildNameInstance)` 不成立时公会名水平锚点根本不被 ArrangeTextTail
|
||
改写,poc 亦让 GuildTag x 偏移留 0(名字中线正上方),不做 +markW/2+1.5;
|
||
② 文本宽度 poc 按「字符数 * 7px」估,参考端是 GetTextSize 精确像素;
|
||
③ 三子标签 poc 仍竖排(各自默认局部 y),只把水平 x 偏移叠上去 —— 参考端称号 / 等级
|
||
与名字同一屏幕行(3D billboard 里三条挤一行会互相糊,保留竖排 + x 偏移的折衷)。
|
||
改动:
|
||
project/name_tail_layout.gd(改,第 28 个 canonical 纯静态模块):
|
||
const NAME_X_ADD_TITLE := 8.0(:242 fxAdd)、NAME_X_ADD_LEVEL_ONLY := 4.0(:288 fxAdd)、
|
||
MARK_X_PAD := 1.5(:20)、MARK_ABOVE_NAME_PX := 26.0(:22);
|
||
name_x_shift(has_title, has_level) -> 8 / 4 / 0(:320 名字右移量);
|
||
title_x_offset(name_w) -> -(name_w/2) - 8(:258,相对名字锚点 x 的像素偏移,负 = 左);
|
||
level_x_offset(name_w, title_w, has_title) -> 有称号 -(name_w/2) - 8 - title_w(:282)/
|
||
只有等级 -(name_w/2) - 4(:312);
|
||
guild_name_x_offset(mark_w) -> mark_w/2 + 1.5(:228,仅有徽记时用);
|
||
mark_x_offset(guild_w, mark_w) -> -(guild_w/2) - (mark_w/2 + 1.5)(:227);
|
||
subtag_world_x(px_offset, px_size) -> px_offset * px_size(屏幕 +x → billboard 本地 +x,
|
||
按该 Label3D pixel_size 折世界)。
|
||
project/net_world.gd(改):
|
||
const SUBTAG_CHAR_W_PX := 7.0(字符数估宽,与 ground_items TAG_CHAR_W_PX 同源);
|
||
_apply_text_tail_to 尾部调新增 _layout_subtags_x(n, ttag, ltag):
|
||
has_title / has_level = 对应子标签 text != "";name_w / title_w 按字符数 * 7 估;
|
||
名字 Label3D.position.x = subtag_world_x(name_x_shift(has_title, has_level), px);
|
||
TitleTag.position.x = subtag_world_x(title_x_offset(name_w), px) 若有称号否则 0;
|
||
LevelTag.position.x = subtag_world_x(level_x_offset(name_w, title_w, has_title), px) 若有等级否则 0;
|
||
GuildTag.position.x = 0(poc 无徽记 -> 参考端 :220 此路不改写)。
|
||
—— 纵向位移(_reposition_name_tag,增量 117/119)与横向排布(_layout_subtags_x,本增量)
|
||
互不干扰:前者只写 .position.y,后者只写 .position.x。
|
||
project/name_tail_layout_test.gd(改)—— 加 §5:6 个水平函数的常量 / 公式 / 符号 / 线性缩放
|
||
断言 + 组合「等级(-58) < 称号(-28) < 名字(+8)」水平顺序。
|
||
project/chat_test.gd(改)—— FakeClient 加 get_guild_name;新增 charlie(vid 3000,
|
||
guild 7 / alignment 15000(grade 0 有称号)/ level 42):_apply_text_tail 后断言
|
||
名字 x = 8*px、称号 x < 0、等级 x < 称号 x、GuildTag x = 0、称号 x 精确
|
||
= (-49/2 - 8)*px;再把 alignment 改 0(grade 4 = TITLE_NONE)-> 称号 x 归零、
|
||
名字 x 变 4*px、等级 x 精确 = (-49/2 - 4)*px。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script name_tail_layout_test.gd —— PASS。
|
||
/opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。
|
||
回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 name_tail_layout_test / chat_test;
|
||
PASS=69 FAIL=0)。
|
||
验收:
|
||
已验证(离线):fxAdd 三态(0 / 4 / 8);称号 = -(nameW/2) - 8、等级(有称号)再 - titleW、
|
||
等级(只有等级)= -(nameW/2) - 4 的像素公式;subtag_world_x 线性且保符号;
|
||
真 net_world + 真实体:有称号时名字右移 8*px / 称号在左 / 等级更左 / GuildTag 不动,
|
||
撤称号后称号归零、名字改右移 4*px;全 69 回归。
|
||
未验证:真机 —— 角色同屏带称号 / 等级 / 公会名时的水平观感、与增量 116 悬停强显、
|
||
增量 117/119 气泡上移的并存;字符数估宽 vs GetTextSize 的像素差;公会徽记缺失下
|
||
公会名不做 +markW/2+1.5 偏移的观感差异。
|
||
风险与依赖:
|
||
seam ⑦(收窄后剩余):poc 无公会徽记 mark 实体(参考端 :220 gate)、文本宽按字符数 * 7px
|
||
估(≠ GetTextSize 精确像素)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
|
||
忠于参考:fxAdd 8 / 4 / 0、称号 / 等级的减项、名字右移 fxAdd、公会名仅有徽记时改写
|
||
均 1:1;屏幕像素 → 世界本地 x 沿用 name_tail_layout 既定 pixel_size 约定,不引入额外系数。
|
||
纵横解耦:_layout_subtags_x 只写 .position.x,_reposition_name_tag 只写 .position.y;
|
||
_apply_text_tail_to 每次(含 ~2Hz 兜底重染)重算 x,与 level / title / guild 变化同步。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
|
||
文本宽换真实字体度量);
|
||
或 seam ⑫ 收口(木门 / 攻城门实体类型 + IsWoodenDoor / IsBuilding 分类 + 目标框点击
|
||
LCONTROL 悄悄话捷径接 whisper dialog)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 122(W3→M2 / §5.4 · §8.8 seam ⑫ 收口:目标框点击 LCONTROL 悄悄话捷径接 whisper 1:1)
|
||
|
||
```text
|
||
批次:增量 122 —— W3→M2 §5.4 / §8.8 seam ⑫ 收口:root/game.py CGameWindow::SetPCTargetBoard
|
||
(:804-817)里「targetBoard.Open 之后,若按住 LCONTROL 且是同阵营的他人 PC(非本人、
|
||
非 building)→ interface.OpenWhisperDialog(name)」这条捷径接进 poc。谓词
|
||
target_board.whisper_on_ctrl_click 增量 118 已备好没接,这次是纯接线:net_play 侧加
|
||
一个信号 + 一个按键态 + _on_pick 里 KIND_PC 分支的轮询;game_scene 侧转 chat.start_whisper。
|
||
状态:✅ 完成(net_play.gd:signal whisper_requested(name)、var _lcontrol_down、
|
||
set_lcontrol_down(down)、_on_pick 尾部 KIND_PC 分支调
|
||
TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false)
|
||
真则 whisper_requested.emit(_entity_name(e));game_scene.gd:_unhandled_input 加
|
||
KEY_CTRL 按下 / 松开 → net_play.set_lcontrol_down(e.pressed),chat 就绪后
|
||
net_play.whisper_requested → chat.start_whisper)
|
||
🔎 真机目视:按住 Ctrl 点同帝国队友 → 聊天输入框预填 "/w <名> " 并抢焦点;
|
||
点异帝国 / 点自己 / 点怪 / 没按 Ctrl → 只普通选中,不预填。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/ + assets/root/game.py):
|
||
assets/root/game.py:94 self.targetBoard.SetWhisperEvent(ui.__mem_func__(self.interface.OpenWhisperDialog))
|
||
assets/root/game.py:804 def SetPCTargetBoard(self, vid, name):
|
||
:805 self.targetBoard.Open(vid, name)
|
||
:807 if app.IsPressed(app.DIK_LCONTROL):
|
||
:809 if not player.IsSameEmpire(vid): return
|
||
:812 if player.IsMainCharacterIndex(vid): return
|
||
:814 elif chr.INSTANCE_TYPE_BUILDING == chr.GetInstanceType(vid): return
|
||
:817 self.interface.OpenWhisperDialog(name)
|
||
—— C++ picking 只对 PC 命中派发 SetPCTargetBoard(怪 / NPC 走别的),故 poc 也只在
|
||
_entity_kind(e) == KIND_PC 分支查这条捷径。
|
||
seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型
|
||
(_entity_kind 只到 0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP),故 is_building
|
||
实参恒传 false —— 参考端 :814 INSTANCE_TYPE_BUILDING 分支在 poc 永不触发;门实体
|
||
做出来后再把 GetInstanceType 分类补上。另 poc「悄悄话对话框」= 聊天输入预填 "/w 名 "
|
||
(chat_ui.start_whisper),非独立 whisper dialog 窗口(seam:无独立 whisper UI)。
|
||
改动:
|
||
project/net_play.gd(改):
|
||
signal whisper_requested(name: String)(game.py:817 OpenWhisperDialog 的 poc 通道);
|
||
var _lcontrol_down := false(game.py:807 app.IsPressed(app.DIK_LCONTROL) 的按住态);
|
||
func set_lcontrol_down(down)(game_scene 的 KEY_CTRL 按下 / 松开喂);
|
||
_on_pick:_change_target_to_picked_instance(vid) 之后,若 _entity_kind(e) == KIND_PC,
|
||
same_empire = EntityRules._is_same_empire(_main_entity(), e),
|
||
TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid==_main_vid, false)
|
||
为真且 _entity_name(e) != "" → whisper_requested.emit(name)。
|
||
project/game_scene.gd(改):
|
||
_unhandled_input 在 KEY_SHIFT 块后加 KEY_CTRL:net_play.set_lcontrol_down(e.pressed)
|
||
后 return(一处同时处理按下 / 松开,对齐参考端 IsPressed 轮询语义);
|
||
chat 装配处(fishing_feedback 接线之后):net_play.whisper_requested → chat.start_whisper。
|
||
project/target_board.gd(改):头注 seam ⑫ 段更新 —— whisper_on_ctrl_click 增量 122
|
||
已接线(net_play._on_pick → whisper_requested → game_scene → chat.start_whisper),
|
||
is_building 仍恒 false(门实体缺,seam ⑫ 余留)。
|
||
project/netplay_test.gd(改)—— 加 8c)seam ⑫ 段(无新增测试文件):
|
||
连 np.whisper_requested 收集 whispers;主角 empire=1;
|
||
Bob(vid 6100 / ch_type 0 / empire 1):没按 Ctrl 点 → whispers 空但 set_target 6100;
|
||
按 Ctrl 点 → whispers == ["Bob"];改 empire=2 再点 → 空(:809 IsSameEmpire);
|
||
Wolf2(ch_type 2)按 Ctrl 点 → 空(仅 PC 分派)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。
|
||
回归:project/*_test.gd 共 69 个全 PASS(无新增测试文件,扩 netplay_test;PASS=69 FAIL=0)。
|
||
验收:
|
||
已验证(离线):按住 LCONTROL 点同阵营他人 PC → whisper_requested(name);无 LCONTROL /
|
||
异阵营 PC / 非 PC(怪)→ 不发;点 PC 的普通选中(set_target)不受影响;
|
||
KEY_CTRL 按下 / 松开正确翻转 _lcontrol_down;全 69 回归。
|
||
未验证:真机 —— Ctrl+点队友后聊天框预填 "/w 名 " 的观感、与增量 116 悬停强显 /
|
||
增量 118「看他国框」前置过滤的并存;building 实体缺失下 :814 分支在 poc 恒不触发。
|
||
风险与依赖:
|
||
seam ⑫(收窄后剩余):poc 无 building / wooden-door / stone-door 实体类型 → is_building
|
||
实参恒 false(:814 分支 poc 永不触发)、can_view 的 is_wooden_door 恒 false;
|
||
poc 悄悄话 = 聊天输入预填,非独立 whisper dialog 窗口。
|
||
忠于参考:LCONTROL 轮询 + IsSameEmpire + IsMainCharacterIndex + 非 building 四条与
|
||
:807-817 一致;只对 PC 命中查(C++ picking 分派边界);谓词 whisper_on_ctrl_click
|
||
增量 118 已定,本批不改。
|
||
低风险:仅新增一条信号 + 一个按键态 + 一处 KIND_PC 分支判断 + 一处信号转接,
|
||
不触碰移动 / 攻击 / 目标框收包路径;纯 GDScript。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
|
||
文本宽换真实字体度量);
|
||
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType / IsWoodenDoor / IsBuilding 分类,
|
||
待门实体做出来);
|
||
或独立 whisper dialog 窗口(uiWhisper.py,替换聊天输入预填)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 123(W3→M2 / §8.8 seam ⑦ · ⑩ 收口:头顶子标签 / 掉落名条文本宽换真实字体度量 GetTextSize 1:1)
|
||
|
||
```text
|
||
批次:增量 123 —— W3→M2 §8.8 seam ⑦ / ⑩ 收窄:ArrangeTextTail(PythonTextTail.cpp)里
|
||
iNameWidth / iTitleWidth / iLevelWidth / 掉落名条框 xSize 参考端全部来自
|
||
CGraphicTextInstance::GetTextSize(&w, &h)——逐字形排版度量,非「字符数 * 定宽」。
|
||
poc 之前 net_world.SUBTAG_CHAR_W_PX / ground_items.TAG_CHAR_W_PX 都是 7.0 的粗估
|
||
(增量 120/121 遗留 seam)。这次抽一个纯静态 text_metrics.gd 包 Font.get_string_size()
|
||
做 1:1 度量,两处调用换成真实像素宽,无字体(headless / 未配主题)时回退旧估宽。
|
||
状态:✅ 完成(新增 project/text_metrics.gd 第 30 个 canonical 纯静态模块 +
|
||
project/text_metrics_test.gd;net_world._layout_subtags_x 的 name_w / title_w、
|
||
ground_items._arrange_tags 的框宽 w 换 TextMetrics.label_width)
|
||
🔎 真机目视:带称号 / 等级的窄名与宽名头顶,称号 / 等级左偏量随真实名宽变化(长名推得更远);
|
||
多个长短不一的掉落名条重叠时按真实文本框挤开,短名不再被按 7px/字 高估的框推开。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:225 pGuildNameInstance->GetTextSize(&iWidth, &iHeight)
|
||
UserInterface/PythonTextTail.cpp:233 pTextTail->pTextInstance->GetTextSize(&iNameWidth, &iNameHeight)
|
||
UserInterface/PythonTextTail.cpp:240 pTitle->GetTextSize(&iTitleWidth, &iTitleHeight)
|
||
UserInterface/PythonTextTail.cpp:267 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(有称号分支)
|
||
UserInterface/PythonTextTail.cpp:297 pLevel->GetTextSize(&iLevelWidth, &iLevelHeight)(只有等级分支)
|
||
UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize)
|
||
UserInterface/PythonTextTail.cpp:748 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailOwnerText)
|
||
UserInterface/PythonTextTail.cpp:809 pTextInstance->GetTextSize(&xSize, &ySize)(SetTextTailText,item 框尺寸)
|
||
—— GetTextSize 转发到字体实际字形度量(含字距 / 非等宽),Godot Font.get_string_size()
|
||
(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)是 1:1 对应物。
|
||
改动:
|
||
project/text_metrics.gd(新,纯静态第 30 个 canonical):
|
||
const FALLBACK_CHAR_W_PX := 7.0(=旧 SUBTAG/TAG_CHAR_W_PX,仅无字体兜底);
|
||
text_size(font, text, font_size) -> Vector2:空串 / font null / font_size<=0 → ZERO,
|
||
否则 font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size);
|
||
width_of(font, text, font_size, fallback_char_px=7.0) -> float:空串 → 0;
|
||
量得正宽 → 精确像素;否则 → text.length() * fallback_char_px;
|
||
label_width(label: Label3D, fallback_char_px=7.0) -> float:null → 0;
|
||
f = label.font ?? ThemeDB.fallback_font,再 width_of(f, label.text, label.font_size, …)。
|
||
project/net_world.gd(改):const TextMetrics = preload(...);_layout_subtags_x 里
|
||
name_w = maxf(1.0, TextMetrics.label_width(nlbl, SUBTAG_CHAR_W_PX))、
|
||
title_w = maxf(1.0, TextMetrics.label_width(ttag, SUBTAG_CHAR_W_PX))
|
||
(原 float(text.length()) * SUBTAG_CHAR_W_PX);SUBTAG_CHAR_W_PX 常量保留作兜底。
|
||
project/ui/ground_items.gd(改):const TextMetrics = preload(...);_arrange_tags 里
|
||
box.w = maxf(1.0, TextMetrics.label_width(tag, TAG_CHAR_W_PX))
|
||
(原 tag.text.length() * TAG_CHAR_W_PX);TAG_CHAR_W_PX 常量保留作兜底。
|
||
name_tail_layout.gd / text_tail_arrange.gd 不动——它们收 name_w / title_w / w 作入参,
|
||
宽度来源换掉即可,纯静态排布公式(增量 119/120/121)不受影响。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS。
|
||
回归:project/*_test.gd 共 70 个全 PASS(新增 text_metrics_test;PASS=70 FAIL=0)。
|
||
验收:
|
||
已验证(离线):text_size 退化输入(空 / null / 非法字号)恒 ZERO;有 ThemeDB.fallback_font
|
||
时度量为正、随串长单调增、随字号单调增;width_of 空串 0、无字体回退 len*char_px、
|
||
有字体 == text_size.x、font_size<=0 回退;label_width 从 Label3D.font(未设回落
|
||
fallback_font)+ font_size 量、null → 0、空文本 → 0;net_world / ground_items 两处
|
||
调用编译通过、全 70 回归(含 netplay_test 目标 HUD、net_world_vis、gamescene)。
|
||
未验证:真机 —— 头顶称号 / 等级左偏量随真实名宽变化的观感、长短名条混排时按真实框挤开、
|
||
非拉丁字体 / CJK 名下的度量(ThemeDB.fallback_font 与打包字体可能不同字形宽)。
|
||
风险与依赖:
|
||
seam ⑦(收窄后剩余):文本宽已换 GetTextSize 1:1;仍留 —— poc 无公会徽记 mark 实体
|
||
(参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷(≠ 参考同一屏幕行)。
|
||
seam ⑩(收窄后剩余):名条框宽已换 GetTextSize 1:1、单行高仍按 TAG_LINE_H_PX 15px 估
|
||
(参考端 ySize 亦来自 GetTextSize);只对掉落 item 名条做(角色名字尾标分表不参与,同参考)。
|
||
忠于参考:get_string_size 的 alignment/width/-1/font_size 参数对齐 GetTextSize 语义;
|
||
无字体路径的 7.0 与增量 120/121 的旧估宽数值一致,回退不改变既有 headless 行为。
|
||
低风险:新增一个纯静态模块 + 两处「宽度来源」替换,未碰排布公式 / 信号 / 收包路径;纯 GDScript。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset /
|
||
单行高亦走 GetTextSize);
|
||
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
|
||
或独立 whisper dialog 窗口(uiWhisper.py)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 124(W3→M2 / §8.8 seam ⑦ · ⑩ 末尾:掉落名条框「单行文本高」换真实字体度量 GetTextSize 的 h 1:1)
|
||
|
||
```text
|
||
批次:增量 124 —— W3→M2 §8.8 seam ⑦ / ⑩ 末尾收窄:ArrangeTextTail item 尾标去重叠
|
||
用的框 yEnd = ySize(PythonTextTail.cpp:749 / :809),ySize 与 xSize 一样来自
|
||
CGraphicTextInstance::GetTextSize(&xSize, &ySize)。增量 123 已把框宽 w 换成真实
|
||
Font.get_string_size().x,这次把框高 h 也换成 .y —— poc 之前 ground_items 里框高恒
|
||
用 TAG_LINE_H_PX = 15.0 粗估(增量 120 遗留)。text_metrics.gd 增 height_of /
|
||
label_height 两个纯静态方法(无新增 canonical,扩第 30 个),ground_items._arrange_tags
|
||
的 box.h 换 TextMetrics.label_height(tag, TAG_LINE_H_PX),无字体时回退 15.0。
|
||
状态:✅ 完成(project/text_metrics.gd 增 const FALLBACK_LINE_H_PX := 15.0 + height_of()
|
||
+ label_height();project/ui/ground_items.gd _arrange_tags 的 box "h" 换真实度量;
|
||
text_tail_arrange.gd 不动——它收 box h 作入参,box_end_y(h) = h 已对齐参考 yEnd = ySize)
|
||
🔎 真机目视:长短名混排的掉落名条重叠时,按真实单行文本高(≈ 字体行高,非 15px)挤开,
|
||
竖直堆叠间距随打包字体行高一致;单条名条与多条时观感不变。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:749 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize)
|
||
(SetTextTailOwnerText else 分支)
|
||
UserInterface/PythonTextTail.cpp:751 pTextTail->yEnd = (float) ySize;
|
||
UserInterface/PythonTextTail.cpp:809 pTextTail->pTextInstance->GetTextSize(&xSize, &ySize)
|
||
(__CreateCharacterTextTail / SetTextTailText)
|
||
UserInterface/PythonTextTail.cpp:812 pTextTail->yEnd = (float) ySize;
|
||
UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y +
|
||
pCompareTextTail->yEnd + yTemp; —— 去重叠顶到框底
|
||
—— ySize 是单行 text instance 的字体行高(ascent + descent),与字形无关;
|
||
Godot Font.get_string_size(text, …, font_size).y 是 1:1 对应物。
|
||
改动:
|
||
project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件):
|
||
const FALLBACK_LINE_H_PX := 15.0(=旧 ground_items.TAG_LINE_H_PX,仅无字体兜底);
|
||
height_of(font, text, font_size, fallback_line_px=15.0) -> float:空串 → 0;
|
||
量得正高(text_size(...).y)→ 精确像素;否则 → fallback_line_px;
|
||
label_height(label: Label3D, fallback_line_px=15.0) -> float:null → 0;
|
||
f = label.font ?? ThemeDB.fallback_font,再 height_of(f, label.text, label.font_size, …)。
|
||
头注释 + seam 注释同步补「高 h」;REF 段补 :749 / :751 / :809 / :812 的 yEnd = ySize。
|
||
project/ui/ground_items.gd(改):_arrange_tags 里 box
|
||
"h": maxf(1.0, TextMetrics.label_height(tag, TAG_LINE_H_PX))
|
||
(原 "h": TAG_LINE_H_PX);TAG_LINE_H_PX 常量注释改为「无字体时的单行估高兜底」;
|
||
头注释「按文本字符数估文本框」→「按 Label3D 的真实字体度量取文本框」、
|
||
「文本框宽取…」→「文本框宽 / 高取…label_width / label_height…增量 123 / 124」。
|
||
text_tail_arrange.gd / name_tail_layout.gd 不动。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS
|
||
(+「5. height_of / label_height」段:FALLBACK_LINE_H_PX = 15.0;空串 → 0;无字体回退 15;
|
||
有字体高为正、与串内容无关(h("x") == h("WWWWWWWWWW"))、随字号单调、== text_size.y;
|
||
label_height(null) → 0、回落 fallback_font、空 Label3D → 0)。
|
||
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件,扩 text_metrics_test;PASS=70 FAIL=0)。
|
||
验收:
|
||
已验证(离线):height_of 退化输入(空 / 无字体 / font_size<=0)走回退;有 ThemeDB.fallback_font
|
||
时高为正、随字号单调增、与具体字形无关(单行行高)、== text_size.y;label_height 从
|
||
Label3D.font(未设回落 fallback_font)+ font_size 量、null / 空文本 → 0;ground_items
|
||
_arrange_tags box.h 编译通过、全 70 回归(含 p2b_test 的真 GroundItems 去重叠段)。
|
||
未验证:真机 —— 打包字体行高与 ThemeDB.fallback_font 行高差异下的竖直堆叠观感;
|
||
带 owner 后缀换行的多行名条(poc 当前是单行 "name (owner)",参考端 owner 是独立 instance
|
||
且 yEnd += yOwnerSize + 4,:733-736 —— poc 未拆分,仍按单行处理)。
|
||
风险与依赖:
|
||
seam ⑦ / ⑩(收窄后剩余):文本宽 w(123)+ 高 h(124)均已换 GetTextSize 1:1;仍留 ——
|
||
poc 无公会徽记 mark 实体(参考端 :220 gate 不成立)、三子标签竖排 + x 偏移的折衷
|
||
(≠ 参考同一屏幕行)、掉落名条为唯一 item 尾标来源(owner 未拆成独立 instance)、
|
||
去重叠只对掉落 item 名条(角色名字尾标分表不参与,同参考)。
|
||
忠于参考:get_string_size(...).y 对齐 GetTextSize 的 ySize 语义(单行字体行高);
|
||
无字体路径的 15.0 与增量 120 的旧估高一致,回退不改变既有 headless 行为。
|
||
低风险:扩一个纯静态模块的两个方法 + 一处「框高来源」替换,未碰排布公式 / 去重叠算法 /
|
||
信号 / 收包路径;纯 GDScript。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
|
||
seam ⑩ 末尾(掉落名条 owner 拆成独立 text instance + yEnd += yOwnerSize + 4);
|
||
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
|
||
或独立 whisper dialog 窗口(uiWhisper.py)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 125(W3→M2 / §8.8 seam ⑩ 末尾:掉落名条去重叠框的 owner 后缀按参考 SetItemTextTailOwner「owner 独立度量 + yEnd += yOwnerSize + 4」1:1)
|
||
|
||
```text
|
||
批次:增量 125 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端
|
||
CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)(PythonTextTail.cpp:706-737)
|
||
把 owner 后缀挂成一个独立的 CGraphicTextInstance(文本 = c_szName + strOwnership,
|
||
strOwnership 在 IDS_POSSESSIVE_MORPHENE 为空时取字面 "'s";黄 1,1,0 / CENTER),
|
||
与名字实例分开 GetTextSize(&xOwnerSize, &yOwnerSize),再据此把去重叠框扩张:
|
||
:735 pTextTail->yEnd += float(yOwnerSize + 4) —— owner 另起一行、行距 4
|
||
:736 pTextTail->xStart = fMIN(xStart, -xOwnerSize/2 - 1)
|
||
:737 pTextTail->xEnd = fMAX(xEnd, xOwnerSize/2 + 1)
|
||
poc 之前 ground_items 只按合并串 "name (owner)" 的整条 Label3D 量一个框(增量 123/124
|
||
换了真实字体度量,但没按参考把 owner 拆出来单独量、也没有 yEnd += yOwnerSize + 4)。
|
||
这次把去重叠框的度量改为「名字子串」「owner + 所有格子串」分别度量后合成,代入 poc 的
|
||
{w,h} 框模型:h = name_h + owner_h + 4;w = max(name_w, owner_w - 2)(令 box_end_x(w)
|
||
= w/2+2 恰好等于参考 :737 的 fMAX(name/2+2, owner/2+1),框 x 两边对称故 box_start_x
|
||
自动 = :736 的 fMIN)。无 owner → 退化为纯名字框,与参考 else 分支(删 owner 实例、
|
||
框复位 xEnd = w/2+2 / yEnd = h)一致。
|
||
状态:✅ 完成(text_tail_arrange.gd 增纯静态 item_box_with_owner();text_metrics.gd 增
|
||
string_width_like / string_height_like(用某 Label3D 的字体量任意子串);ground_items
|
||
_on_added 存 item_name / item_owner meta,_arrange_tags 用两子串分别度量走
|
||
item_box_with_owner 合成去重叠框。均无新增 canonical / 测试文件——扩第 29、第 30 个
|
||
canonical 与其 test,另扩 p2b_test)
|
||
🔎 真机目视:地面上有主人的掉落物,其名条与相邻名条重叠时,按「名字行 + owner 行 + 4px」
|
||
的总高把靠前名条挤开(比无主掉落挤得更远);owner 串比名字宽时框左右也相应外扩。
|
||
渲染仍是单个合并 "name (owner)" billboard Label3D(seam ⑩ 记录在案的折中,未拆成
|
||
两个 text instance / 未做 CENTER 黄字的 owner 行)。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName)
|
||
UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0) { ... pOwnerTextInstance = CGraphicTextInstance::New(); }
|
||
UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
|
||
UserInterface/PythonTextTail.cpp:723 strName += strOwnership;
|
||
UserInterface/PythonTextTail.cpp:725 pOwnerTextInstance->SetHorizonalAlign(HORIZONTAL_ALIGN_CENTER)
|
||
UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄
|
||
UserInterface/PythonTextTail.cpp:733 pOwnerTextInstance->GetTextSize(&xOwnerSize, &yOwnerSize)
|
||
UserInterface/PythonTextTail.cpp:734 pTextTail->yStart = -2.0f;
|
||
UserInterface/PythonTextTail.cpp:735 pTextTail->yEnd += float(yOwnerSize + 4);
|
||
UserInterface/PythonTextTail.cpp:736 pTextTail->xStart = fMIN(pTextTail->xStart, float(-xOwnerSize / 2 - 1));
|
||
UserInterface/PythonTextTail.cpp:737 pTextTail->xEnd = fMAX(pTextTail->xEnd, float(xOwnerSize / 2 + 1));
|
||
UserInterface/PythonTextTail.cpp:739-751 else 分支:Delete(pOwnerTextInstance);框复位
|
||
xStart=-xSize/2-2 / yStart=-2 / xEnd=xSize/2+2 / yEnd=ySize
|
||
UserInterface/PythonTextTail.cpp:182 pInsertTextTail->y = pCompareTextTail->y + pCompareTextTail->yEnd + yTemp;
|
||
改动:
|
||
project/text_tail_arrange.gd(改,扩第 29 个 canonical,不新增文件):
|
||
item_box_with_owner(name_w, name_h, owner_w := 0.0, owner_h := 0.0) -> Dictionary:
|
||
owner_w <= 0 或 owner_h <= 0 → {"w": name_w, "h": name_h}(无 owner / 参考 else 分支);
|
||
否则 → {"w": maxf(name_w, owner_w - 2.0), "h": name_h + owner_h + 4.0}。
|
||
头注释 REF 段补 :706-737 的 SetItemTextTailOwner 公式与 {w,h} 推导。
|
||
project/text_metrics.gd(改,扩第 30 个 canonical,不新增文件):
|
||
string_width_like(label: Label3D, text, fallback_char_px=7.0) -> float:
|
||
label == null → text.length() * fallback_char_px;否则 width_of(label.font ??
|
||
ThemeDB.fallback_font, text, label.font_size, …)。
|
||
string_height_like(label: Label3D, text, fallback_line_px=15.0) -> float:
|
||
text == "" → 0;label == null → fallback_line_px;否则 height_of(…同上…)。
|
||
—— 让 ground_items 能用名条 Label3D 的字体分别量「名字子串」「owner 子串」
|
||
(对应参考端两个独立 text instance 各自 GetTextSize)。头注释补增量 125。
|
||
project/ui/ground_items.gd(改):
|
||
const OWNER_POSSESSIVE := "'s"(= SetItemTextTailOwner :722 strOwnership 字面值);
|
||
_on_added:新建 / 复用 drop 节点时都 set_meta("item_name", _name_for(vnum)) +
|
||
set_meta("item_owner", owner);
|
||
_arrange_tags:box 不再对整条 tag 量一个框,改为
|
||
iname = meta item_name;iowner = meta item_owner;owner_txt = iowner + "'s"(空则 "");
|
||
nbox = TextTailArrange.item_box_with_owner(
|
||
TextMetrics.string_width_like(tag, iname, TAG_CHAR_W_PX),
|
||
TextMetrics.string_height_like(tag, iname, TAG_LINE_H_PX),
|
||
TextMetrics.string_width_like(tag, owner_txt, TAG_CHAR_W_PX),
|
||
TextMetrics.string_height_like(tag, owner_txt, TAG_LINE_H_PX));
|
||
boxes.append({x, y, w: maxf(1, nbox.w), h: maxf(1, nbox.h)})。
|
||
头注释补增量 125(owner 拆两子串度量、渲染合并是折中)。
|
||
name_tail_layout.gd / text_tail.gd 等其它 canonical 不动。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS
|
||
(+「5. item_box_with_owner」段:无 owner / owner_w<=0 / owner_h<=0 → 退化纯名字框;
|
||
有 owner → h = name_h + owner_h + 4;owner 窄 → w = name_w 且 box_end_x = nameW/2+2;
|
||
owner 宽 → w = owner_w - 2 且 box_end_x = ownerW/2+1(对齐参考 :737 fMAX);
|
||
顶到带 owner 框底下 → push = (name_h+owner_h+4) + PUSH_GAP)。
|
||
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS
|
||
(+「6. string_width_like / string_height_like」段:label null → 字符数*px / 单行估高;
|
||
空串 → 0;有 fallback_font 时量的是给定子串(≠ label.text)、随子串长单调、== text_size(子串))。
|
||
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS
|
||
(FakeClient.add_ground 增可选 owner 入参;新增 mount3/fc3/gi3 段:301 无 owner 靠前、
|
||
302 带 owner "Alice" 靠后同位 → 无字体度量下 302 框 yEnd = 15+15+4 = 34,靠前 301 被顶
|
||
(34+5)*0.005 = 0.195(> 无 owner 的 0.1);302 留默认;t302.text == "掉落28 (Alice)"
|
||
证明渲染仍单合并 Label3D)。
|
||
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。
|
||
验收:
|
||
已验证(离线):item_box_with_owner 纯函数在「无 owner 退化 / 有 owner 加高 / owner 更宽时
|
||
收进 ownerW/2+1」三路径与参考 :735-737 逐式对齐;string_*_like 在 label null / 空串 /
|
||
有子串三路径正确、量的是给定子串而非 label.text;ground_items._arrange_tags 对带 owner 的
|
||
掉落把去重叠框按「名字行 + owner 行 + 4」加高(p2b_test 真 GroundItems 段);全 70 回归。
|
||
未验证:真机 —— owner 行未真正独立渲染成 CENTER 黄字(poc 仍单条 "name (owner)"),
|
||
故只有「去重叠框度量」1:1、owner 行的视觉呈现仍是折中;strOwnership 只取了 "'s"
|
||
字面值(未接 ApplicationStringTable IDS_POSSESSIVE_MORPHENE 的本地化语素)。
|
||
风险与依赖:
|
||
seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120)+ 框宽 w(123)+ 框高 h(124)
|
||
+ owner 后缀框度量(125)均已 1:1;仍留 —— owner 未拆成独立 text instance(渲染仍单个
|
||
合并 "name (owner)" Label3D,无 CENTER 黄字 owner 行)、strOwnership 未接本地化语素、
|
||
去重叠只对掉落 item 名条。
|
||
忠于参考:{w,h} 推导对 :736/:737 的 fMIN/fMAX 逐式验证(框 x 对称 → box_start_x 免算);
|
||
无字体路径沿用 TAG_CHAR_W_PX 7.0 / TAG_LINE_H_PX 15.0,回退不改既有 headless 行为。
|
||
低风险:扩两个纯静态模块各 1-2 个方法 + ground_items 一处「框度量来源」替换 + 存两个 meta;
|
||
未碰排布公式 / 去重叠算法 / 信号 / 收包路径 / 渲染节点结构;纯 GDScript。
|
||
下一批次:
|
||
§8.8 剩余:屏幕空间血条整合(HP 条从 billboard Sprite3D 换成屏幕空间条);
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
|
||
seam ⑩ 末尾(owner 拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素);
|
||
seam ⑫ 末尾(木门 / 攻城门实体类型 + GetInstanceType 分类,待门实体);
|
||
或独立 whisper dialog 窗口(uiWhisper.py)。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 126(W3→M2 / §8.8 seam ⑩ 末尾:带 owner 的掉落名条把 owner 后缀拆成独立 CENTER 黄字 text instance + strOwnership 接本地化语素)
|
||
|
||
```text
|
||
批次:增量 126 —— W3→M2 §8.8 seam ⑩ 末尾收窄:带 owner 的掉落名条,参考端
|
||
CPythonTextTail::SetItemTextTailOwner(dwVID, c_szName)(PythonTextTail.cpp:712-745)把 owner
|
||
后缀挂成「一个与名字实例分开的 CGraphicTextInstance」(:715 New),而不是拼进名字串:
|
||
:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
|
||
:723 strName = c_szName + strOwnership
|
||
:726 SetTextPointer(ms_pFont)
|
||
:727 SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER)
|
||
:729 SetColor(1.0f, 1.0f, 0.0f) —— 黄
|
||
排布(ArrangeTextTail :191-207,去重叠内层循环之后、对每条 item 尾标都跑):
|
||
:195 pInsertTextTail->pOwnerTextInstance->SetPosition(x, y, z) —— owner 落尾标基点
|
||
:200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z) —— 名字实例下移 15px
|
||
:206 (无 owner 时)pTextInstance->SetPosition(x, y, z)
|
||
owner 为空(:739-745):CGraphicTextInstance::Delete(pOwnerTextInstance)、指针置 NULL、
|
||
框复位纯名字(增量 125 已实现的 else 分支)。
|
||
渲染(Render :369-374):item 尾标先画名字实例、再画 pOwnerTextInstance。
|
||
poc 之前(增量 125):owner 只是拼进 "name (owner)" 的单个合并 Label3D,去重叠框按两子串
|
||
分别度量合成(yEnd += ownerH + 4),但 owner 没有自己的 text instance、没有 CENTER 黄字、
|
||
名字实例也没有下移 15px。这次把 owner 拆出来。
|
||
状态:✅ 完成(ground_items 加 owner_tag(第二个 Label3D)+ _apply_owner_tag 建/删 +
|
||
_place_tail 统一落点 + setup(possessive) 注入;game_scene 从 EterLocale 解析
|
||
POSSESSIVE_MORPHENE 注入。无新增 canonical / 测试文件——扩 ground_items 与 p2b_test)
|
||
🔎 真机目视:地面上有主人的掉落物,主人名(如 "Alice's")以黄色、居中显示在道具名的
|
||
上方一行(间距 15px),道具名本身在下;无主掉落只有道具名、位置不变;多条名条重叠时按
|
||
「owner 行 + 名字行 + 4px」的总高把靠前名条挤开(增量 125 的框度量不变)。
|
||
剩余折中:owner 行颜色恒黄(参考名字实例本身取 pInsertTextTail->Color 的道具品阶色,
|
||
poc 名字仍走 _process 的远近绿/金着色近似);去重叠只对掉落 item 名条做。
|
||
参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
|
||
UserInterface/PythonTextTail.cpp:706 void CPythonTextTail::SetItemTextTailOwner(DWORD dwVID, const char * c_szName)
|
||
UserInterface/PythonTextTail.cpp:712 if (strlen(c_szName) > 0)
|
||
UserInterface/PythonTextTail.cpp:715 pTextTail->pOwnerTextInstance = CGraphicTextInstance::New();
|
||
UserInterface/PythonTextTail.cpp:722 strOwnership = ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE) == "" ? "'s" : ...
|
||
UserInterface/PythonTextTail.cpp:723 strName += strOwnership;
|
||
UserInterface/PythonTextTail.cpp:726 pOwnerTextInstance->SetTextPointer(ms_pFont)
|
||
UserInterface/PythonTextTail.cpp:727 pOwnerTextInstance->SetHorizonalAlign(CGraphicTextInstance::HORIZONTAL_ALIGN_CENTER)
|
||
UserInterface/PythonTextTail.cpp:728 pOwnerTextInstance->SetValue(strName.c_str())
|
||
UserInterface/PythonTextTail.cpp:729 pOwnerTextInstance->SetColor(1.0f, 1.0f, 0.0f) —— 黄
|
||
UserInterface/PythonTextTail.cpp:741-745 else:CGraphicTextInstance::Delete(pOwnerTextInstance);pOwnerTextInstance = NULL
|
||
UserInterface/PythonTextTail.cpp:193-196 ArrangeTextTail:pOwnerTextInstance->SetPosition(x, y, z);Update()
|
||
UserInterface/PythonTextTail.cpp:200 pInsertTextTail->pTextInstance->SetPosition(x, y + 15.0f, z)
|
||
UserInterface/PythonTextTail.cpp:206 (else,无 owner)pTextInstance->SetPosition(x, y, z)
|
||
UserInterface/PythonTextTail.cpp:369-374 Render:item 尾标先画 pTextInstance、再画 pOwnerTextInstance
|
||
UserInterface/UserInterface.rc:33/56/237 IDS_POSSESSIVE_MORPHENE "'s"(西文 .rc 的字面值)
|
||
UserInterface/resource.h:6 #define IDS_POSSESSIVE_MORPHENE 2
|
||
改动:
|
||
project/ui/ground_items.gd(改):
|
||
const OWNER_NAME_GAP_PX := 15.0(= ArrangeTextTail :200 的 y + 15.0f);
|
||
var _possessive := OWNER_POSSESSIVE;
|
||
setup(..., possessive := ""):_possessive = possessive if possessive != "" else
|
||
OWNER_POSSESSIVE(1:1 对齐 :722 的 == "" ? "'s" : loc);
|
||
_apply_owner_tag(node, owner)(新):owner 空 → 有 owner_tag 则 remove_child + queue_free
|
||
(:741-745);否则懒建 Label3D "owner_tag"(name / horizontal_alignment =
|
||
HORIZONTAL_ALIGNMENT_CENTER(:727)/ billboard = BILLBOARD_ENABLED / no_depth_test /
|
||
pixel_size 0.005 / modulate = Color(1,1,0)(:729)/ position.y = TAG_BASE_Y),
|
||
文本 = owner + _possessive(:723);
|
||
_on_added:名字 tag.text = _name_for(vnum)(只放名字,不再 _tag_text 拼 "name (owner)"),
|
||
新建 / 复用两条路径都调 _apply_owner_tag(node|existing, owner);
|
||
_place_tail(n, tag, d_px)(新):owner_tag 存在 → owner_tag.position.y = TAG_BASE_Y -
|
||
d_px*px、tag.position.y = TAG_BASE_Y - (d_px + OWNER_NAME_GAP_PX)*px(:195 / :200);
|
||
否则 tag.position.y = TAG_BASE_Y - d_px*px(:206);
|
||
_arrange_tags:behind-camera / 单条 / 多条排布三处落点都改走 _place_tail(tags 条目多存 "n");
|
||
box 构建里 owner_txt 用 _possessive(原 OWNER_POSSESSIVE 常量);
|
||
_tag_text 删除(不再合并渲染);头注释补增量 126。
|
||
project/game_scene.gd(改):ground_items.setup 前,assets_root != "" 时用 EterLocale
|
||
(load("res://locale.gd")) 解析 POSSESSIVE_MORPHENE 键(缺则 ""),作第 7 参 possessive 注入
|
||
(对应 :722 ApplicationStringTable_GetString(IDS_POSSESSIVE_MORPHENE))。
|
||
text_tail_arrange.gd / text_metrics.gd 等 canonical 不动(增量 125 的 item_box_with_owner /
|
||
string_*_like 仍是去重叠框度量来源,未改)。
|
||
测试:
|
||
/opt/homebrew/bin/godot --headless --path project --script p2b_test.gd —— PASS
|
||
(seam ⑩ 末尾段重写:drop_302(owner "Alice")——名字实例 t302.text == "掉落28"、
|
||
独立 owner_tag 存在且 text == "Alice's" / horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER
|
||
/ modulate ≈ Color(1,1,0) / position.y ≈ TAG_BASE_Y(:195)、名字实例
|
||
position.y ≈ TAG_BASE_Y - 15 * pixel_size(:200);drop_301(无 owner)不建 owner_tag
|
||
(:741-745)、仍被带 owner 的框顶 (15+15+4+5)*0.005 = 0.195(增量 125 框高不变);
|
||
新增 mount4/gi4:setup(possessive="@") → drop_401 owner_tag.text == "Bob@")。
|
||
/opt/homebrew/bin/godot --headless --path project --script text_tail_arrange_test.gd —— PASS(未改,item_box_with_owner 不变)。
|
||
/opt/homebrew/bin/godot --headless --path project --script text_metrics_test.gd —— PASS(未改)。
|
||
回归:project/*_test.gd 共 70 个全 PASS(无新增测试文件;PASS=70 FAIL=0)。
|
||
验收:
|
||
已验证(离线):owner 非空 → drop 节点下确实多出独立 Label3D "owner_tag"(CENTER / 黄 /
|
||
文本 = owner + strOwnership),名字实例只剩名字且下移 15px;owner 为空 → 不建 owner_tag、
|
||
名字实例落基点;possessive 经 setup 注入生效("@" → "Bob@"),未注入时回落 "'s";
|
||
去重叠框高仍是增量 125 的 name_h + owner_h + 4(p2b_test 靠前名条被顶量不变);全 70 回归。
|
||
未验证:真机 —— owner_tag 的 CENTER 对齐 / 黄色 / 15px 行距的实际视觉;EterLocale
|
||
POSSESSIVE_MORPHENE 键在 poc 资源里不存在,故实际注入值恒 "'s"(与 40250 西文 .rc 一致,
|
||
但没有真正跑通「本地化语素非空」分支)。
|
||
风险与依赖:
|
||
seam ⑩(收窄后剩余):hover 强显(116)+ item 去重叠(120)+ 框宽 w(123)+ 框高 h(124)
|
||
+ owner 后缀框度量(125)+ owner 独立 CENTER 黄字 text instance + strOwnership 接本地化语素
|
||
(126)均已 1:1;仍留 —— owner 行颜色恒黄(参考名字实例取 pInsertTextTail->Color 的道具
|
||
品阶色,poc 名字走 _process 远近着色近似)、去重叠只对掉落 item 名条做。
|
||
忠于参考:owner 落点 (x, y)(:195)、名字落点 (x, y + 15.0f)(:200)逐式对应;CENTER(:727)
|
||
/ 黄(:729)/ 文本 = owner + strOwnership(:723)逐属性对应;owner 空删实例(:743)对应
|
||
remove_child + queue_free;strOwnership 的 == "" ? "'s" : loc(:722)对应 setup 里的
|
||
possessive if possessive != "" else OWNER_POSSESSIVE。
|
||
低风险:只改 ground_items(渲染节点多一个子 Label3D + 落点函数)+ game_scene 一处注入 +
|
||
p2b_test;未碰去重叠算法 / 排布公式 / 信号 / 收包路径 / 其它 canonical;纯 GDScript。
|
||
节点结构变化(drop 节点多 owner_tag 子节点)仅 ground drops,hover_at / try_pickup 用
|
||
节点自身 global_position 不受影响。
|
||
下一批次:
|
||
seam ⑨ 深化(名字与气泡合成单尾标链表,去掉两个并存 Label3D);
|
||
seam ⑦ 末尾(三子标签并到名字同一屏幕行 / 接公会徽记 mark 实体 + guild_name_x_offset);
|
||
完整 Quest / fishing / dungeon / mount / observer 玩法闭环与复杂 UI 分支;
|
||
真实服务器、真机、纯 pack、双架构和正式签名验收。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 127(W3→M2 / §8.8 屏幕空间血条、实体建筑分类、掉落 owner 品阶色、独立 whisper dialog)
|
||
|
||
```text
|
||
批次:增量 127 —— 收口当前可在本地完成的四项尾部缺口,并同步修正过时的 seam 注释。
|
||
|
||
1. 屏幕空间 HP 条:
|
||
net_world.gd 删除旧的 Sprite3D HpBar,setup() 创建 CanvasLayer / HpOverlay,
|
||
_sync_hp_bar() / _sync_hp_bar_values() 保持血量与死亡状态同步,_update_hp_bars() 使用
|
||
显式 Camera3D.unproject_position() 投影实体头顶锚点。无 max_hp、死亡、镜头后方、屏幕外、
|
||
实体移除和 clear_for_map_change 都会隐藏或释放条;screen_hp_bar.gd 负责无输入的 Control
|
||
背景 / 边框 / 填充绘制。
|
||
|
||
2. 建筑 / 木门分类:
|
||
entity_rules.gd 增加 KIND_BUILDING=5 / KIND_WOODEN_DOOR=6;net_play._entity_kind() 优先
|
||
消费 building / 显式 kind,再消费 ch_type,并以 mob_proto bType=4 和 race 13000、30111..30119
|
||
识别木门。EntityRules._is_building() 同时把木门视作 building,目标框不会再把门误判为 PC,
|
||
LCONTROL 私聊也只对真实 PC 分支触发。
|
||
|
||
3. 掉落 owner 品阶颜色:
|
||
ground_items.gd 的 _item_color() 依次支持显式 item_color / rarity_color / name_color / color
|
||
和 item_grade / rarity / quality / grade;owner_tag 继承同一物品颜色,名字的近距离绿色高亮仍
|
||
只作用于名字实例。这样默认旧数据保持金色 fallback,同时新数据不再固定纯黄色 owner 行。
|
||
|
||
4. 独立 whisper dialog:
|
||
chat_ui.gd 构建同级 WhisperDialog,LCONTROL 点同阵营其他 PC 经 net_play.whisper_requested
|
||
和 game_scene 接到 start_whisper();目标只读、内容单独输入,发送后调用 client.whisper() 并关闭,
|
||
取消 / ESC 只关闭,不污染公共聊天输入。
|
||
|
||
回归:
|
||
cmake --build build -j2 && ctest --test-dir build --output-on-failure —— CTest 21/21 PASS。
|
||
/opt/homebrew/bin/godot --headless --rendering-method gl_compatibility --path project
|
||
--script <test>.gd —— project 下 71 个 GDScript 测试 PASS。
|
||
eterngrn_polish_test 的 Audio fixture 补齐 set_listener / play_character_sound_3d;p10_test
|
||
的 serverinfo fixture 改用 /tmp,适配受限 headless 环境。macOS 系统证书读取噪声、受限 TCP
|
||
bind 的 skip 仍只记录为环境限制;真实服务器 / 真机 / 正式资源包未验收。
|
||
|
||
当前遗留:seam ⑨ 名字与气泡合并为单尾标链表,以及 Quest / fishing / dungeon / mount / observer
|
||
完整玩法、复杂 UI 分支、真实服务器和发布验收。
|
||
```
|
||
|
||
### C.5 批次记录 · 增量 128(W3→M2 / §8.8 seam ⑦ 公会徽记尾标接入 + §5.1 `[DONE]` 点击语义)
|
||
|
||
```text
|
||
批次:增量 128 —— 收口当前可在本地完成的两项尾部缺口,并同步修正 Quest DONE 的回调时机。
|
||
|
||
1. 公会会徽接入角色头顶尾标:
|
||
net_world.gd 监听客户端会徽缓存的 guild_marks_ready / guild_mark_updated。角色实体先于
|
||
会徽下载完成时,仍先生成名字、公会名、称号和等级;会徽准备好后按 guild_id 查图并懒建
|
||
GuildMark Sprite3D(BILLBOARD、no_depth_test、16x12 像素尺寸、NEAREST),更新事件复用
|
||
既有节点替换纹理,不重复创建;公会清除或查不到图片时 queue_free 旧节点。
|
||
_layout_subtags_x() 在存在会徽时按参考 mark_x_offset / guild_name_x_offset 排布会徽与公会名,
|
||
_reposition_name_tag() 让会徽随聊天气泡造成的名字尾标上移。会徽到达晚于角色、纹理增量更新、
|
||
公会移除后的旧节点清理均由 project/guild_mark_tail_test.gd 覆盖。
|
||
|
||
2. Quest [DONE] 回调时机:
|
||
quest_dialog.gd 的 [DONE] 只设置 next_button_type = DONE 并呈现关闭按钮,不在 parse_script()
|
||
或接收脚本时发出 done_event;按钮实际 pressed 回调才发 done_event,再发送 script_answer(255)
|
||
并关闭窗口。这样与参考端 MakeNextButton(BUTTON_TYPE_DONE) 的按钮回调语义一致,普通
|
||
[NEXT] 仍只发送 script_answer(255)。project/quest_test.gd 覆盖解析无副作用与点击触发。
|
||
|
||
回归:
|
||
/opt/homebrew/bin/godot --headless --display-driver headless --audio-driver Dummy
|
||
--rendering-method gl_compatibility --path project --script <test>.gd —— project 下 74/74
|
||
个 GDScript 测试 PASS;其中新增 guild_mark_tail_test.gd PASS,quest_test.gd PASS。
|
||
ctest --test-dir build --output-on-failure —— CTest 22/22 PASS。
|
||
macOS headless 的系统证书读取噪声、个别既有 fixture 的资源泄漏提示和受限 TCP bind / 连接失败
|
||
仅属于环境或测试替身限制,未将其记为代码失败。
|
||
|
||
验收边界:
|
||
已验证(离线):角色先生成后下载会徽、会徽纹理替换复用节点、会徽 / 公会名相对排布、聊天气泡
|
||
上移跟随、公会消失清理;[DONE] 解析阶段不触发、点击后触发 done_event 与 script_answer(255)。
|
||
未验证:真实公会会徽下载协议、真机视觉;单尾标链表(名字与气泡仍是两个节点)、完整 .msc
|
||
电影播放器、Quest/fishing/dungeon/mount/observer 完整玩法、复杂 UI 分支、真实服务器、
|
||
纯 pack、双架构和正式签名验收。
|
||
|
||
下一批次:
|
||
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
|
||
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
|
||
```
|