40250 classic W2: EntityStore data-layer only + net_world visibility (增量 50)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f32064c74a
commit
3487df2a16
+134
-1
@@ -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 集成
|
||||
```
|
||||
|
||||
+22
-1
@@ -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 两包合并的单次落地入口)、
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "entity_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+168
-2
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhriuw4uo5d2b
|
||||
Reference in New Issue
Block a user