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
+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