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
127 lines
5.0 KiB
GDScript
127 lines
5.0 KiB
GDScript
# 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")
|