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:
shenlei
2026-09-02 16:06:06 +09:00
co-authored by Claude Sonnet 5
parent f32064c74a
commit 3487df2a16
8 changed files with 597 additions and 50 deletions
+134 -1
View File
@@ -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`cmpx==cm)。
`_on_spawn` 对信号路径新增 `_is_visible_entity(d)` 门禁;`drain_dirty` → `entity_info`
仅刷新已存在节点。**留白**:数据层不可查询时(fake client 无 `get_entities` /
`_main_vid == 0` / 缺 `pos_cm`)门禁 fail-open,信号驱动的 spawn 不受可见性限制——
与参考端「信号即 `AppendActor`」有意偏差,仅在真实 `M2Client` 上启用逐帧管理。
- **测试**`ctest` 16/16(含 `net.entity_store` 新增 §2.1 断言:远端 `GC_CHARACTER_ADD`
填充数据行 + 入队 Spawn;`UPDATE` 改写已有行;未知 VID 的 `mut_char_update` /
`mut_move` / `mut_mount` / `mut_char_info` / `mut_shop_sign` 既不建行也不缓存)。
新增 `project/net_world_vis_test.gd`:近实体 spawn 即建节点、远实体 spawn 不建节点、
主角走近后逐帧管理补建一次、走远后淡出且数据行保留、`AFFECT_SHOW_ALWAYS` 与
`race 14202` 常驻、走回后淡出结束再重建——全过。
- **验收**:未验证(无真实 40250 服务端;真服端到端可见性 / VID 复用 / affect 表现待接线后验收)。
---
### 2.2 `GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存
@@ -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_spawnmut_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/182REF/.../PhaseLoading.cpp:325
m2dev src/UserInterface/InstanceBase.h enum EAffectbit 7 = AFFECT_SHOW_ALWAYS
改动:
extension/src/net/entity_store.cpp —— 全部 mut_* 未知 VID → dbg_ignored()+return(无
pending/重放);条目仅 mut_spawn/mut_spawn_full/mut_spawn_main 建、mut_despawn/主 VID
清表删;mut_spawn_full() 整行替换 m_ents[vid]=e 并无条件 push {Spawn}mut_spawn()
委托 mut_spawn_full()mut_set_points() 短包清尾 + n>255 截断、空指针/n<=0 早退;
mut_set_point() type>=255 拒绝;mut_char_info()/mut_shop_sign()/mut_mount() 去掉裸
Entity 创建,find-or-returnmut_char_update() guild/alignment/pk_mode 变化也推 {Info}
+mut_affect_flags(vid,lo,hi)v=lo|(hi<<32),变化则 m_dirty);+匿名 dbg_ignored 助手
extension/src/net/entity_store.h —— +mut_affect_flags 声明
extension/tests/net_entity_test.cpp —— +§2.1 数据层断言块(远端 ADD 填行+入队 Spawn、
UPDATE 改写、未知 VID 五个 mut_* 既不建行也不缓存)
project/net_world.gd —— +CHAR_STAGE_VIEW_BOUND/AFFECT_SHOW_ALWAYS/WALL_RACES 常量;
+_update_visibility()(每帧 __IsVisibleActor 判定,建/淡出节点,_fading 防抖);
+_is_visible_entity/_planar_cm/_show_always/_is_wall/_fade_and_free
+_apply_field_updates()NetworkActorManager.cpp:470 调用序,has_method 守卫);
_on_spawn 支持已有节点销毁重建 + 上下马保留 global_position_on_info 末尾刷字段;
clear_for_map_change/_on_despawn 清 _fading
project/net_world_vis_test.gd(新)—— 两层可见性 headless 自检
测试:./build.sh Debug 退出 0cd build && ctest --output-on-failure → 16/16 全过
net.entity_store 含新断言);godot --headless --script 逐个:net_world_vis_test、
netbridge_test、gamescene_test、netplay_test、p9_test、combat_fx_test、player_motion_test
全绿;无 skip。(注:干净 worktree 需先跑一次 `godot --headless --editor --quit` 生成
global_script_class_cache 才能解析 MapCoord/AssetRoot 全局类)
验收:未验证(无真实 40250 服务端)
风险与依赖:
- W1 需在 classic_parser.cpp 的 GC_CHARACTER_ADD/ADD2/UPDATE 之后调
mut_affect_flags(vid, affect_flag[0], affect_flag[1])——mut_char_update 无 affect 形参
- §2.2 步骤 1-3m_pending_actor 两包合并、IsInvisibleRace 分流)属 W1,未触碰
- net_world.gd 可见性门禁对无 pos_cm 的 fake client fail-open:信号驱动 spawn 不受
距离限制,且逐帧管理会主动建节点——与参考「信号即 AppendActor」的有意偏差
- AFFECT_SHOW_ALWAYS=1<<7 源自 m2dev InstanceBase.h40250 树 AffectFlagContainer.h
未逐一拼出成员);WALL_RACES=[14201..14204] 源自 InstanceBase.cpp IsWall
- m2dev apply() 内联 touch() 路径(GC_CHARACTER_ADD 等旧协议分支)不在 §2.2 范围,未改
下一批次:W1 接线 mut_affect_flags + §2.2 两包合并;Phase 2 由 W0 执行 G1 集成
```
+22 -1
View File
@@ -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_spawnmut_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 增量 50Phase 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(1420114204) / 平面距主角 <
> 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 增量 49W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流
> 共享文件的骨架,冻结接口后再拆分。`EntityStore``Entity` 补 `empire / affect_flags /
> owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、
+94 -41
View File
@@ -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.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;
}
// 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});
}
}
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) {
+4
View File
@@ -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);
+43
View File
@@ -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);
+167 -1
View File
@@ -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)
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):
+126
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bhriuw4uo5d2b