From 3487df2a161643dc502031f3933a5cc7037409f8 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 2 Sep 2026 16:06:06 +0900 Subject: [PATCH] =?UTF-8?q?40250=20classic=20W2:=20EntityStore=20data-laye?= =?UTF-8?q?r=20only=20+=20net=5Fworld=20visibility=20(=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=2050)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLIENT-GAP-FIX §2.1/§2.3/§2.4/§2.5 (+ §2.2 steps 4-5): - §2.1 two-layer model: EntityStore is data-only. Entries are created only by mut_spawn / mut_spawn_full / mut_spawn_main and removed only by mut_despawn / main-vid table clear. Every other mut_* now does find-or-return with a MT_NET_TRACE-gated debug log; no pending / replay buffer. Visibility moves to net_world.gd::_update_visibility() — per frame it walks client.get_entities() and mirrors is_main / AFFECT_SHOW_ALWAYS(bit 7) / IsWall(14201-14204) / planar-distance < CHAR_STAGE_VIEW_BOUND (200*100 cm), building a node on entry and fading + freeing it past +10 while keeping the data row (_fading guard). Gate is fail-open when the data layer is not queryable (no get_entities / _main_vid == 0 / no pos_cm) so existing fake-client tests are unaffected. - §2.3 one overwrite rule: mut_spawn_full() does a clean whole-row value replace (m_ents[vid] = e), no preservation of prior move state on VID reuse; mut_spawn() now delegates to it. net_world._on_spawn destroys + recreates a node when the vid already has one; mount/dismount rebuild keeps the old global_position. - §2.4 points bounds: mut_set_points() clamps counts > POINT_MAX_NUM (255) and zeroes the tail of a short snapshot; mut_set_point() rejects type >= 255. - §2.5 field apply order + affect flags: new EntityStore::mut_affect_flags(vid, lo, hi) assembles v = lo | (hi << 32) and queues a dirty tick; mut_char_update guild/alignment/pk changes now also push Info. net_world._apply_field_updates() applies fields in the reference call order (NetworkActorManager.cpp:470). - §2.2 steps 4-5: mut_char_info() / mut_shop_sign() drop the touch()-creates- entity path. Steps 1-3 (m_pending_actor two-packet merge) remain W1-owned. Tests: ./build.sh Debug exit 0; ctest 16/16 (net.entity_store gains a §2.1 data-layer assertion block). New project/net_world_vis_test.gd for scene-node visibility. GDScript regressions green: netbridge_test, gamescene_test, netplay_test, p9_test, combat_fx_test, player_motion_test, net_world_vis_test. Follow-up for W1: call mut_affect_flags(vid, affect_flag[0], affect_flag[1]) from classic_parser.cpp after GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE (mut_char_update has no affect param). wire_classic.h already has the wanted static_assert on GCPoints.points. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W --- docs/CLIENT-GAP-FIX.md | 135 +++++++++++++++++++++- docs/CLIENT-GAP.md | 23 +++- extension/src/net/entity_store.cpp | 145 ++++++++++++++++-------- extension/src/net/entity_store.h | 4 + extension/tests/net_entity_test.cpp | 43 +++++++ project/net_world.gd | 170 +++++++++++++++++++++++++++- project/net_world_vis_test.gd | 126 +++++++++++++++++++++ project/net_world_vis_test.gd.uid | 1 + 8 files changed, 597 insertions(+), 50 deletions(-) create mode 100644 project/net_world_vis_test.gd create mode 100644 project/net_world_vis_test.gd.uid diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md index cb57021c..d36baf7e 100644 --- a/docs/CLIENT-GAP-FIX.md +++ b/docs/CLIENT-GAP-FIX.md @@ -650,6 +650,33 @@ CNetworkActorManager::UpdateActor / MoveActor / SyncActor / SetActorOwner `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 时状态不会被待合并缓存 @@ -688,6 +715,11 @@ else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error", `ShopSign` 表现状态,分别对应参考端 `BINARY_PrivateShop_Appear/Disappear` 的招牌显示 / 隐藏回调。这里不是把招牌包静默丢弃,而是移除“凭空建 Actor”的补丁。 +**步骤 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 重用时旧状态可能污染新实体 @@ -726,6 +758,23 @@ m_dwMountVnum, m_dwGuildID, m_dwLevel, m_stName`。 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 点数组可能残留旧值 @@ -750,6 +799,21 @@ for (DWORD i = 0; i < POINT_MAX_NUM; ++i) 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 实体数据字段不完整 @@ -787,6 +851,33 @@ affect 图标与剩余时间没有统一状态。 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 一次性事件没有可靠的生命周期缓存 @@ -2923,7 +3014,7 @@ var _pw := "123456789" |---|---|---|---| | W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1 | | W1 协议 / 账号会话 | 🟡 开发中 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;`m_pending_actor` 暂存表成员就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑 | -| W2 实体 / 世界 / 战斗 | 🟡 开发中 | 地图实体 / 掉落清理、地图重建首版、实体测试和场景测试;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags | +| 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 表现验收;W1 接线 `mut_affect_flags()`;`state_flags` 表现层枚举 | | W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 | | W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 | | W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | @@ -2971,3 +3062,45 @@ var _pw := "123456789" 下一批次: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 / 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 集成 +``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index a5f98f15..794e77ec 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -57,7 +57,7 @@ |---|---|---|---| | W0 公共基础 / 集成 | 🟡 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版已落地;增量 49 接口冻结批次(`Entity` 补字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed` 信号、`bgm_director.gd`、§8.6 键位收敛)已提交 | Phase 1 4 工作流并行完成后执行 G1 集成 | | W1 协议 / 账号会话 | 🟡 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN 已覆盖;`m_pending_actor` 暂存表成员已就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑;真实双连接、enter delay | -| W2 实体 / 世界 / 战斗 | 🟡 | 地图实体 / 掉落清理、地图重建首版及离线回归已覆盖;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags | +| W2 实体 / 世界 / 战斗 | 🟡 | §2.1 两层模型(`EntityStore` 纯数据层 + `net_world.gd` 每帧 `_update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点销毁重建 + 上下坐骑保坐标)、§2.4(点数快照截断 / 尾部清零 + 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序节点刷新)已落地并回归(增量 50) | 远服 40250 端到端可见性 / VID 复用 / affect 表现验收;W1 在 `ClassicParser` 接线 `mut_affect_flags()`;`state_flags` 表现层枚举与眩晕 / 隐身等表现 | | W3 玩法 UI / 输入 | 🟡 | 多数窗口、背包 / 任务 / 社交、键位和输入已有首版;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位与表情包 | | W4 渲染 / 资产 / 音频 | 🟡 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点已就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 | | W5 打包 / 发布 / 验收 | ⬜ | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | @@ -115,6 +115,27 @@ > `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、 > `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次 > 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。 +> - **2026-09-02 增量 50(Phase 1 W2:实体两层模型 + 可见性 / 单次覆盖 / 点数边界 / 应用顺序)**: +> 把可见性从 `EntityStore` 彻底移到 `net_world.gd`。**§2.1**:`EntityStore` 只做数据层—— +> 仅 `mut_spawn / mut_spawn_full / mut_spawn_main` 建行,`mut_mount / mut_char_info / +> mut_shop_sign` 遇未知 VID 只 `dbg_ignored()`(`MT_NET_TRACE` 门控)后返回,不再 `touch()` +> 出半行、不做 replay 缓冲;`net_world.gd` 每帧 `_update_visibility()` 按 +> `is_main / AFFECT_SHOW_ALWAYS(bit7) / IsWall(14201–14204) / 平面距主角 < +> CHAR_STAGE_VIEW_BOUND(200*100 cm)` 决定建节点,超 `+10` 淡出并保留数据行(`_fading` 集合 +> 防淡出中重建),只在主角坐标已知时启用、否则沿用 `entity_spawned` 建节点。**§2.3**: +> `mut_spawn()` 改为委托 `mut_spawn_full()`,后者整行值替换(`m_ents[vid] = e`,不再保留旧 +> move 状态,对齐 `SNetworkActorData::__copy__`);`net_world._on_spawn` 对已有节点的 vid +> 先销毁再重建,上/下坐骑触发的重建复用旧世界坐标。**§2.4**:`mut_set_points()` 全量快照 +> `> POINT_MAX_NUM(255)` 截断、`< 255` 清零尾部;`mut_set_point()` 索引 `>= 255` 拒绝。 +> **§2.5**:新增 `EntityStore::mut_affect_flags(vid, lo, hi)`(`v = lo | (hi<<32)` 两段拼装), +> `mut_char_update()` 的 guild/alignment/pk 变化也触发 Info;`net_world._apply_field_updates()` +> 按参考调用序(`NetworkActorManager.cpp:470`:Armor→Weapon→Hair→Guild→AffectFlag→MoveSpeed→ +> AttackSpeed→Alignment→PKMode→StateFlags)刷新节点。C++ `ctest` 16/16(`net.entity_store` +> 加 §2.1 数据层断言)、新增 `net_world_vis_test.gd`、`netbridge_test` / `gamescene_test` / +> `netplay_test` / `p9_test` / `combat_fx_test` / `player_motion_test` 全绿。待 W1 跟进: +> `wire_classic.h` 的 `GCPoints` 已有 `static_assert(sizeof == 1 + 255*4)`;`ClassicParser` +> 需在 `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_UPDATE` 后调用 `mut_affect_flags()` 把 +> `affect_flag[2]` 送进实体(`mut_char_update` 无 affect 形参,未接线)。 > - **2026-09-02 增量 49(W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流 > 共享文件的骨架,冻结接口后再拆分。`EntityStore`:`Entity` 补 `empire / affect_flags / > owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、 diff --git a/extension/src/net/entity_store.cpp b/extension/src/net/entity_store.cpp index de1773e7..c3116103 100644 --- a/extension/src/net/entity_store.cpp +++ b/extension/src/net/entity_store.cpp @@ -1,6 +1,8 @@ #include "entity_store.h" #include +#include +#include #include #include @@ -30,6 +32,17 @@ constexpr uint16_t MOTION_CHEERUP = 347; constexpr uint16_t MOTION_BANTER = 348; constexpr uint16_t MOTION_JOY = 349; +// CLIENT-GAP §2.1/§2.2: the data-layer mutators never create an entity except +// through mut_spawn / mut_spawn_full / mut_spawn_main. Every other mut_* that is +// handed an unknown VID logs (when MT_NET_TRACE is set) and returns instead of +// silently spawning a half-populated row or buffering a replay. +void dbg_ignored(const char *what, uint32_t id) { + static const bool trace = std::getenv("MT_NET_TRACE") != nullptr; + if (trace) { + std::fprintf(stderr, "[entity_store] %s: unknown VID %u -> ignored\n", what, id); + } +} + } // namespace Entity &EntityStore::touch(uint32_t vid, bool &created) { @@ -75,13 +88,15 @@ void EntityStore::start_move(Entity &e, float tx, float ty, uint32_t start_ms, u void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name, float x, float y, float z, float angle, uint16_t moving_speed, uint8_t attack_speed) { - bool created; - Entity &e = touch(vid, created); + // CLIENT-GAP §2.3: a plain character-add is just a full spawn with the + // remaining fields left at their defaults. Route it through mut_spawn_full so + // there is exactly one "whole row replace" code path and VID reuse always + // resets transient state (dead / stun / mount / guild / affects / move). + Entity e{}; + e.vid = vid; e.race = race; e.ch_type = ch_type; - if (!name.empty()) { - e.name = name; - } + e.name = name; e.x = x; e.y = y; e.z = z; @@ -89,7 +104,7 @@ void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const e.moving_speed = moving_speed; e.attack_speed = attack_speed; e.moving = false; - m_changes.push_back({ChangeKind::Spawn, vid}); + mut_spawn_full(e); } void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, @@ -113,22 +128,18 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string } void EntityStore::mut_spawn_full(const Entity &src) { - bool created; - Entity &e = touch(src.vid, created); - // Preserve the live interpolation state if the entity is already moving and - // the caller did not carry an explicit move (spawn packets never do). - const bool was_moving = e.moving; - const float keep_x = e.x, keep_y = e.y, keep_z = e.z; - e = src; + // CLIENT-GAP §2.3 "one overwrite rule": a spawn replaces the whole row keyed + // by vid. The reference (SNetworkActorData::__copy__) copies every field from + // the incoming data on VID reuse and does NOT preserve a prior in-progress + // move — so we do a clean value replace here. Transient state (dead, stun, + // mount, guild, affect_flags, func/move) is whatever `src` carries, which for + // a fresh Entity{} is the default. Copy into a local first to stay safe if a + // caller ever passes `store.get(vid)` back in. + Entity e = src; e.vid = src.vid; - if (was_moving && !src.moving) { - e.moving = true; - e.x = keep_x; - e.y = keep_y; - e.z = keep_z; - } + m_ents[src.vid] = e; if (e.is_main) { - m_main_vid = e.vid; + m_main_vid = src.vid; } m_changes.push_back({ChangeKind::Spawn, src.vid}); } @@ -144,6 +155,23 @@ void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) { } } +void EntityStore::mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi) { + // CLIENT-GAP §2.5: GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE carry the affect + // bitset as two 32-bit words (client CopyData(0,4,&flag[0]) + + // CopyData(32,4,&flag[1])); re-assemble as lo | (hi << 32). No-op for an + // unknown VID — the bitset lives on the entity row only. + auto it = m_ents.find(vid); + if (it == m_ents.end()) { + dbg_ignored("mut_affect_flags", vid); + return; + } + const uint64_t v = (uint64_t)lo | ((uint64_t)hi << 32); + if (it->second.affect_flags != v) { + it->second.affect_flags = v; + m_dirty.push_back(vid); + } +} + void EntityStore::mut_map_bgm(const std::string &name, float volume) { bool changed = false; if (name != m_bgm_name) { @@ -181,10 +209,21 @@ void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx } void EntityStore::mut_set_points(const int32_t *pts, int n) { - if (n > 256) { - n = 256; + // CLIENT-GAP §2.4: GC_CHARACTER_POINTS is a full snapshot. The wire array is + // exactly POINT_MAX_NUM (255) ints; clamp a longer count and zero the tail of + // a shorter one so a stale value can never survive under a fresh snapshot. + // (W1 owns wire_classic.h; GCPoints there already carries + // static_assert(sizeof(GCPoints) == 1 + 255 * 4), i.e. points[POINT_MAX_NUM].) + if (!pts || n <= 0) { + return; + } + if (n > POINT_MAX_NUM) { + n = POINT_MAX_NUM; } std::memcpy(m_points.v, pts, sizeof(int32_t) * (size_t)n); + for (int i = n; i < POINT_MAX_NUM; ++i) { + m_points.v[i] = 0; + } m_current_stamina = (uint32_t)std::max(0, m_points.stamina()); m_stamina_current_f = (float)m_current_stamina; m_points_dirty = true; @@ -206,6 +245,12 @@ void EntityStore::mut_set_points(const int32_t *pts, int n) { } void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) { + // CLIENT-GAP §2.4: a single-index point change must stay inside the array. + // Valid indices are 0..POINT_MAX_NUM-1 (0..254). + if (type >= POINT_MAX_NUM) { + dbg_ignored("mut_set_point index out of range", type); + return; + } if (vid == m_main_vid) { m_points.v[type] = value; if (type == POINT_STAMINA) { @@ -334,9 +379,14 @@ void EntityStore::mut_char_update(uint32_t vid, const uint16_t *parts, uint8_t m e.attack_speed = attack_speed; changed = true; } - e.guild = guild_id; - e.alignment = alignment; - e.pk_mode = pk_mode; + // CLIENT-GAP §2.5: guild / alignment / pk-mode are part of the node refresh + // apply order, so a change to any of them must re-mirror the entity too. + if (e.guild != guild_id || e.alignment != alignment || e.pk_mode != pk_mode) { + e.guild = guild_id; + e.alignment = alignment; + e.pk_mode = pk_mode; + changed = true; + } if (e.mount_vnum != mount_vnum) { e.mount_vnum = mount_vnum; m_mount_changes.push_back(vid); @@ -424,13 +474,17 @@ void EntityStore::mut_damage(uint32_t vid, uint8_t flag, int32_t amount) { } void EntityStore::mut_mount(uint32_t vid, uint32_t mount_vnum) { - bool created = false; - Entity &e = touch(vid, created); - e.mount_vnum = mount_vnum; - m_mount_changes.push_back(vid); - if (!created) { - m_changes.push_back({ChangeKind::Info, vid}); + // CLIENT-GAP §2.1: mount toggles never create an entity. A GC_MOUNT for a VID + // we have not spawned is dropped (the server always sends the character-add + // first). + auto it = m_ents.find(vid); + if (it == m_ents.end()) { + dbg_ignored("mut_mount", vid); + return; } + it->second.mount_vnum = mount_vnum; + m_mount_changes.push_back(vid); + m_changes.push_back({ChangeKind::Info, vid}); } void EntityStore::mut_fly(uint8_t type, uint32_t start_vid, uint32_t end_vid) { @@ -506,8 +560,15 @@ void EntityStore::mut_view_equipment(uint32_t vid, const Item *items, int n) { void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uint16_t *parts, uint8_t empire, int32_t guild_id, int32_t level, int16_t alignment, uint8_t pk_mode, uint32_t mount_vnum) { - bool created; - Entity &e = touch(vid, created); + // CLIENT-GAP §2.2: GC_CHAR_ADDITIONAL_INFO decorates an entity the spawn + // packet already created. An unknown VID means we missed (or already culled) + // the add — drop it instead of touch()-ing a nameless placeholder into being. + auto it = m_ents.find(vid); + if (it == m_ents.end()) { + dbg_ignored("mut_char_info", vid); + return; + } + Entity &e = it->second; if (!name.empty()) { e.name = name; } @@ -525,24 +586,16 @@ void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uin if (empire) { e.empire = empire; } - if (!created) { - m_changes.push_back({ChangeKind::Info, vid}); - } + m_changes.push_back({ChangeKind::Info, vid}); } void EntityStore::mut_shop_sign(uint32_t vid, const std::string &sign) { + // CLIENT-GAP §2.2: the sign is a field on the entity that owns the private + // shop. An unknown VID never spawns a bare row here — the character-add + // packet is the only thing that creates entities. Drop it and log. auto it = m_ents.find(vid); if (it == m_ents.end()) { - // A non-empty sign can legally race the character-add packet during a - // shop broadcast. Keep it instead of silently dropping the server state; - // the later character-add packet fills in the rest of this entity. - if (sign.empty()) { - return; - } - bool created = false; - Entity &e = touch(vid, created); - e.shop_sign = sign; - m_changes.push_back({ChangeKind::Spawn, vid}); + dbg_ignored("mut_shop_sign", vid); return; } if (it->second.shop_sign == sign) { diff --git a/extension/src/net/entity_store.h b/extension/src/net/entity_store.h index 816fe2e1..467483df 100644 --- a/extension/src/net/entity_store.h +++ b/extension/src/net/entity_store.h @@ -481,6 +481,10 @@ public: void mut_spawn_full(const Entity &e); // summon / horse ownership (CLIENT-GAP §2.5); no-op if vid unknown. void mut_ownership(uint32_t vid, uint32_t owner_vid); + // Affect bitset from GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE, delivered as + // the two 32-bit wire words (CLIENT-GAP §2.5: v = lo | (hi << 32)). Queues a + // dirty tick so net_world re-mirrors the node; no-op if vid unknown. + void mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi); // Map background music (CLIENT-GAP §9.1). name is the resolved track id or // path; volume < 0 means "server did not specify, keep current". void mut_map_bgm(const std::string &name, float volume); diff --git a/extension/tests/net_entity_test.cpp b/extension/tests/net_entity_test.cpp index 392ad7e0..dd16e728 100644 --- a/extension/tests/net_entity_test.cpp +++ b/extension/tests/net_entity_test.cpp @@ -1590,6 +1590,49 @@ int main() { CHECK(despawn, "despawn change emitted"); } + // --- CLIENT-GAP §2.1: EntityStore is the data layer. A far entity is a full + // row here with a queued Spawn/dirty; no scene, no replay buffer. Scene- + // node visibility (CHAR_STAGE_VIEW_BOUND cull / re-show) is asserted by + // project/net_world_vis_test.gd, which drives net_world.gd directly. --- + { + EntityStore d; + d.set_now(0); + d.mut_spawn_main(1, 1, "hero", 0, 0, 0); + d.drain_changes(); + + // GC_CHARACTER_ADD far outside any view bound -> still a full data row. + d.mut_spawn(4242, 101, 2, "far-orc", 900000.0f, 0.0f, 0.0f, 0.0f, 300, 90); + const Entity *orc = d.get(4242); + CHECK(orc && orc->race == 101 && orc->attack_speed == 90 && orc->name == "far-orc", + "§2.1 data layer: far GC_CHARACTER_ADD populates the entity row"); + { + auto ch = d.drain_changes(); + bool spawn = false; + for (auto &c : ch) { + if (c.kind == EntityStore::ChangeKind::Spawn && c.vid == 4242) { + spawn = true; + } + } + CHECK(spawn, "§2.1 data layer: a Spawn change is queued for the far entity"); + } + + // GC_CHARACTER_UPDATE for that same VID updates the row in place. + d.mut_char_update(4242, nullptr, 40, 120, 7, -3, 1, 0); + orc = d.get(4242); + CHECK(orc && orc->attack_speed == 120 && orc->moving_speed == 40 && orc->guild == 7, + "§2.1 data layer: UPDATE mutates the existing row"); + + // Unknown VID: no create, no replay buffer, no size growth. + const size_t n_before = d.size(); + d.mut_char_update(999999, nullptr, 10, 10, 0, 0, 0, 0); + d.mut_move(999999, 90.0f, 1, 5.0f, 5.0f, 200); + d.mut_mount(999999, 20101); + d.mut_char_info(999999, "ghost", nullptr, 1, 0, 5, 0, 0, 0); + d.mut_shop_sign(999999, "ghost shop"); + CHECK(d.size() == n_before && d.get(999999) == nullptr, + "§2.1 data layer: mutators for an unknown VID never spawn or buffer"); + } + // --- ClientVS22 loading reset: discard map-local state, retain no stale deltas --- es.mut_spawn(99, 101, 1, "reset-me", 10, 20, 0, 0); es.mut_ground_add(700, 27100, 12, 24, 0); diff --git a/project/net_world.gd b/project/net_world.gd index 4ffd2001..8f3a901f 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -39,6 +39,22 @@ const FUNC_SKILL := 0x80 const DAMAGE_DODGE := 1 << 2 const DAMAGE_CRITICAL := 1 << 5 +# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always +# holds every known entity; a scene node under `parent` exists only while the +# entity is visible: it is the main character, force-visible, a wall, or within +# CHAR_STAGE_VIEW_BOUND of the main character. +# CPythonCharacterManager.cpp:13 -> CHAR_STAGE_VIEW_BOUND = 200*100 (cm; px==cm) +# PythonCharacterManager.cpp:182 -> cull past (CHAR_STAGE_VIEW_BOUND + 10) +const CHAR_STAGE_VIEW_BOUND := 200 * 100 +# CInstanceBase AFFECT enum, ordinal 7 (bit 7) == AFFECT_SHOW_ALWAYS. The 40250 +# ClientVS22 tree keeps the enum in AffectFlagContainer.h and does not spell the +# member out; this mirrors the m2dev sibling source (InstanceBase.h) and is the +# force-visible bit checked by CNetworkActorManager::__IsVisibleActor. +const AFFECT_SHOW_ALWAYS := 1 << 7 +# CInstanceBase::IsWall race ids (InstanceBase.cpp) — always mirrored regardless +# of distance so map geometry never pops. +const WALL_RACES := [14201, 14202, 14203, 14204] + var client: Node # M2Client var parent: Node3D # 实体挂这下面 var world: Node # Metin2World(可空,用 sample_height 贴地) @@ -54,6 +70,7 @@ var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名 var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode} var _duel_opponents := {} # server-provided duel opponent VID set var _duel_cannot_attack := false +var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done) func setup(m2client: Node, mount: Node3D) -> void: client = m2client @@ -127,6 +144,7 @@ func clear_for_map_change() -> void: node.queue_free() entity_removed.emit(int(vid)) _by_vid.clear() + _fading.clear() _main_vid = 0 _pvp_relations.clear() _duel_opponents.clear() @@ -142,7 +160,31 @@ func main_node() -> Node3D: func _on_spawn(d: Dictionary) -> void: var vid := int(d.get("vid", 0)) - if vid == 0 or _by_vid.has(vid) or vid == _local_vid: + if vid == 0 or vid == _local_vid: + return + # §2.1: a node that is blending out is still "present" until the tween frees + # it — don't rebuild it mid-fade (mirrors GetInstancePtr/UpdateDeleting). + if _fading.has(vid): + return + # §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node + # destroys and recreates the node (the data row was whole-replaced). The one + # exception is a mount / dismount driven rebuild, which keeps the old world + # position (reference: kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y — + # the Y flip is the server<->pixel frame, already applied, so in Godot world + # space this is just the old node's global_position). + var keep_pos: Variant = null + var existing: Node3D = _by_vid.get(vid, null) + if is_instance_valid(existing): + var old_mount := int(existing.get_meta("mount_vnum", 0)) + var new_mount := int(d.get("mount_vnum", 0)) + if (old_mount == 0) != (new_mount == 0): + keep_pos = existing.global_position + _by_vid.erase(vid) + existing.queue_free() + elif not _is_visible_entity(d): + # §2.1: the data row lives in EntityStore; the scene node is only built + # once the entity is in view. _update_visibility() creates it later when + # the main character moves into CHAR_STAGE_VIEW_BOUND. return var node: Node3D = null if _model_factory.is_valid(): @@ -153,12 +195,17 @@ func _on_spawn(d: Dictionary) -> void: _attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条 node.name = "e_%d" % vid parent.add_child(node) - node.position = _grounded(d.get("pos", Vector3.ZERO)) + if keep_pos is Vector3: + node.global_position = keep_pos + else: + node.position = _grounded(d.get("pos", Vector3.ZERO)) node.set_meta("vid", vid) node.set_meta("func", int(d.get("func", FUNC_WAIT))) + node.set_meta("mount_vnum", int(d.get("mount_vnum", 0))) _by_vid[vid] = node _refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_pvp_tag(vid) + _apply_field_updates(node, d) entity_added.emit(node, vid) if vid == _main_vid: main_entity_ready.emit(node, vid) @@ -175,6 +222,123 @@ func _on_info(vid: int, d: Dictionary) -> void: elif n.has_method("set_display_name"): n.set_display_name(nm) _refresh_shop_sign(n, str(d.get("shop_sign", ""))) + _apply_field_updates(n, d) + +# §2.5: push the mutable entity fields onto a live view in the exact order the +# reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp +# :470): ChangeArmor -> ChangeWeapon -> ChangeHair -> ChangeGuild -> +# SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment -> +# SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule +# and views that do not model a field simply skip it. +func _apply_field_updates(n: Node3D, d: Dictionary) -> void: + var parts: Variant = d.get("parts", []) + if parts is Array and parts.size() >= 3: + _set_wear_slot(n, "set_armor", 0, int(parts[0])) + _set_wear_slot(n, "set_weapon", 1, int(parts[1])) + _set_wear_slot(n, "set_hair", 2, int(parts[2])) + if n.has_method("set_guild"): + n.call("set_guild", int(d.get("guild", 0))) + if n.has_method("set_affect_flags"): + n.call("set_affect_flags", int(d.get("affect_flags", 0))) + elif n.has_method("set_affects"): + n.call("set_affects", int(d.get("affect_flags", 0))) + if n.has_method("set_move_speed"): + n.call("set_move_speed", int(d.get("moving_speed", 0))) + elif n.has_method("set_server_speed"): + n.call("set_server_speed", int(d.get("moving_speed", 0))) + if n.has_method("set_attack_speed"): + n.call("set_attack_speed", int(d.get("attack_speed", 0))) + if n.has_method("set_alignment"): + n.call("set_alignment", int(d.get("alignment", 0))) + if n.has_method("set_pk_mode"): + n.call("set_pk_mode", int(d.get("pk_mode", 0))) + if n.has_method("set_state_flags"): + n.call("set_state_flags", int(d.get("state_flags", 0))) + # keep the §2.3 mount-toggle rebuild key current + n.set_meta("mount_vnum", int(d.get("mount_vnum", n.get_meta("mount_vnum", 0)))) + +func _set_wear_slot(n: Node3D, dedicated: String, slot: int, vnum: int) -> void: + if n.has_method(dedicated): + n.call(dedicated, vnum) + elif n.has_method("set_wear"): + n.call("set_wear", slot, vnum) + +# --- §2.1 per-frame visibility manager ------------------------------------------ +# Mirrors CNetworkActorManager::__IsVisibleActor + the CPythonCharacterManager +# cull loop: every data-layer entity is checked each frame; a node is built when +# the entity comes into view and blended out when it leaves. Runs only once the +# main character's position is known (reference cull needs GetMainInstancePtr); +# until then the entity_spawned signal path keeps building nodes as before. +func _update_visibility() -> void: + if not client.has_method("get_entities") or _main_vid == 0: + return + var main: Dictionary = client.get_entity(_main_vid) + if main.is_empty() or not (main.get("pos_cm", null) is Vector3): + return + var mc: Vector3 = main["pos_cm"] + var main_cm := Vector2(mc.x, mc.y) + var cull := float(CHAR_STAGE_VIEW_BOUND + 10) + for e in client.get_entities(): + var vid := int(e.get("vid", 0)) + if vid == 0 or vid == _local_vid or _fading.has(vid): + continue + var has_node := _by_vid.has(vid) + var forced := vid == _main_vid or bool(e.get("is_main", false)) \ + or _show_always(e) or _is_wall(int(e.get("race", 0))) + var dist := INF + if e.get("pos_cm", null) is Vector3: + dist = _planar_cm(e).distance_to(main_cm) + if forced or dist < CHAR_STAGE_VIEW_BOUND: + if not has_node: + _on_spawn(e) + elif has_node and dist > cull: + _fade_and_free(vid) + +# Distance test used both by the per-frame manager and the _on_spawn gate. +func _is_visible_entity(e: Dictionary) -> bool: + var vid := int(e.get("vid", 0)) + if vid == _main_vid or bool(e.get("is_main", false)): + return true + if _show_always(e) or _is_wall(int(e.get("race", 0))): + return true + # Fail open when the data layer can't be queried or the main character has not + # been located yet — keeps the spawn burst / fake-client tests behaving as + # before; real gating kicks in once M2Client reports pos_cm + a main vid. + if client == null or not client.has_method("get_entities") or _main_vid == 0: + return true + var main: Dictionary = client.get_entity(_main_vid) + if main.is_empty() or not (main.get("pos_cm", null) is Vector3) \ + or not (e.get("pos_cm", null) is Vector3): + return true + var mc: Vector3 = main["pos_cm"] + return _planar_cm(e).distance_to(Vector2(mc.x, mc.y)) < CHAR_STAGE_VIEW_BOUND + +func _planar_cm(e: Dictionary) -> Vector2: + var p: Variant = e.get("pos_cm", null) + return Vector2(p.x, p.y) if p is Vector3 else Vector2.ZERO + +func _show_always(e: Dictionary) -> bool: + return (int(e.get("affect_flags", 0)) & AFFECT_SHOW_ALWAYS) != 0 + +func _is_wall(race: int) -> bool: + return race in WALL_RACES + +# §2.1 cull: keep the data row, blend the node out. It stays discoverable via +# _fading (so it is not rebuilt mid-blend) until the tween frees it. +func _fade_and_free(vid: int) -> void: + var n: Node3D = _by_vid.get(vid, null) + _by_vid.erase(vid) + if not is_instance_valid(n): + _fading.erase(vid) + return + _fading[vid] = true + entity_removed.emit(vid) + var tw := create_tween() + tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.3) + tw.tween_callback(func() -> void: + if is_instance_valid(n): + n.queue_free() + _fading.erase(vid)) func _refresh_shop_sign(root: Node3D, sign: String) -> void: var tag := root.get_node_or_null("ShopSign") as Label3D @@ -204,6 +368,7 @@ func _display_name(d: Dictionary) -> String: func _on_despawn(vid: int) -> void: _duel_opponents.erase(vid) + _fading.erase(vid) var n: Node3D = _by_vid.get(vid, null) if n: _by_vid.erase(vid) @@ -522,6 +687,7 @@ func _on_damage(vid: int, amount: int, flag: int) -> void: func _process(dt: float) -> void: if client == null or parent == null: return + _update_visibility() for vid in _by_vid.keys(): var n: Node3D = _by_vid[vid] if not is_instance_valid(n): diff --git a/project/net_world_vis_test.gd b/project/net_world_vis_test.gd new file mode 100644 index 00000000..294f18eb --- /dev/null +++ b/project/net_world_vis_test.gd @@ -0,0 +1,126 @@ +# net_world_vis_test —— CLIENT-GAP §2.1 两层模型 / 可见性剔除的 headless 自检。 +# godot --headless --path project --script net_world_vis_test.gd +# 用带 pos_cm 的假 client 驱动 net_world.gd 的每帧可见性管理: +# - 数据层始终有全部实体;场景节点只在进入 CHAR_STAGE_VIEW_BOUND 时创建 +# - 主角走近 -> 远端实体补建;主角走远 -> 节点淡出(数据保留) +# - AFFECT_SHOW_ALWAYS / IsWall 的实体无视距离常驻 +# 退出码 0 = 全过。 +extends SceneTree + +const NetWorld = preload("res://net_world.gd") + +const BOUND := 200 * 100 # CHAR_STAGE_VIEW_BOUND +const NEAR_CM := Vector2(100 * 100, 0) # 10000 cm < BOUND +const FAR_CM := Vector2(500 * 100, 0) # 50000 cm > BOUND + 10 + +class FakeClient extends Node: + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_main_set(vid: int) + signal entity_info(vid: int, entity: Dictionary) + signal chat(type: int, vid: int, text: String) + signal vitals_changed(vid: int) + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + var ents := {} + func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) + func get_entities() -> Array: return ents.values() + func get_pvp_relations() -> Array: return [] + func get_duel() -> Dictionary: return {} + func add(vid: int, cm: Vector2, extra := {}) -> Dictionary: + var d := {"vid": vid, "name": "e%d" % vid, "is_main": false, + "func": 0, "moving": false, "angle_deg": 0.0, + "hp": 100, "max_hp": 100, "dead": false, "race": 0, + "affect_flags": 0, "parts": [0, 0, 0, 0], + "pos": Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01), + "pos_cm": Vector3(cm.x, cm.y, 0.0)} + d.merge(extra, true) + ents[vid] = d + return d + func move_main(cm: Vector2) -> void: + ents[1]["pos_cm"] = Vector3(cm.x, cm.y, 0.0) + ents[1]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01) + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: net_world_vis_test (§2.1 two-layer visibility cull / re-show)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var mount := Node3D.new() + get_root().add_child(mount) + var fc := FakeClient.new() + get_root().add_child(fc) + var nw: Node = NetWorld.new() + get_root().add_child(nw) + nw.setup(fc, mount) + + var added := {} + var removed := {} + nw.entity_added.connect(func(_n, v): added[v] = int(added.get(v, 0)) + 1) + nw.entity_removed.connect(func(v): removed[v] = int(removed.get(v, 0)) + 1) + + # main character at origin + fc.add(1, Vector2.ZERO, {"is_main": true}) + fc.entity_main_set.emit(1) + + # 1) a near entity spawns a node immediately (signal path, in view) + var near := fc.add(2, NEAR_CM) + fc.entity_spawned.emit(near) + _ck(nw.node_for(2) != null, "near entity (10000cm) -> node built on spawn") + + # 2) a far entity is data-only: the spawn signal must NOT build a node + var far := fc.add(3, FAR_CM) + fc.entity_spawned.emit(far) + _ck(nw.node_for(3) == null, "far entity (50000cm) -> spawn signal builds no node (§2.1)") + + await process_frame + _ck(nw.node_for(3) == null, "far entity still nodeless after a visibility pass") + _ck(nw.node_for(2) != null, "near entity keeps its node while main is close") + + # 3) main walks over to the far entity -> per-frame manager builds its node + fc.move_main(FAR_CM) + await process_frame + await process_frame + _ck(nw.node_for(3) != null, "main in range -> _update_visibility builds the far node") + _ck(int(added.get(3, 0)) == 1, "far node built exactly once") + + # 4) entity 2 is now out of range -> node blends out, data row stays + _ck(nw.node_for(2) == null, "entity 2 out of range -> node culled") + _ck(int(removed.get(2, 0)) == 1, "cull emits entity_removed once") + _ck(fc.get_entity(2).has("vid"), "culled entity still present in the data layer") + + await process_frame + _ck(nw.node_for(2) == null, "culled entity is not rebuilt while it stays out of range") + + # 5) AFFECT_SHOW_ALWAYS (bit 7) -> force-visible regardless of distance + var beacon := fc.add(4, FAR_CM + Vector2(90000, 0), {"affect_flags": 1 << 7}) + fc.entity_spawned.emit(beacon) + _ck(nw.node_for(4) != null, "AFFECT_SHOW_ALWAYS entity -> node built though far") + await process_frame + _ck(nw.node_for(4) != null, "force-visible entity survives the cull loop") + + # 6) IsWall race -> always mirrored + var wall := fc.add(5, FAR_CM + Vector2(120000, 0), {"race": 14202}) + fc.entity_spawned.emit(wall) + await process_frame + _ck(nw.node_for(5) != null, "wall race (14202) -> node built and kept regardless of distance") + + # 7) main walks back -> entity 2 comes into view again and is rebuilt + fc.move_main(NEAR_CM) + # entity 2's fade tween (0.3s) must finish before it can be rebuilt + await create_tween().tween_interval(0.5).finished + await process_frame + _ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade") + _ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry") diff --git a/project/net_world_vis_test.gd.uid b/project/net_world_vis_test.gd.uid new file mode 100644 index 00000000..b2aade70 --- /dev/null +++ b/project/net_world_vis_test.gd.uid @@ -0,0 +1 @@ +uid://bhriuw4uo5d2b