Files
mtgodot-poc/project/net_world_vis_test.gd
2026-09-16 22:15:52 +09:00

201 lines
8.7 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 := {}
var motion_speeds := {} # vid -> Vector2(walk, run)
func set_entity_motion_speed(vid: int, walk: float, run: float) -> void:
motion_speeds[vid] = Vector2(walk, run)
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")
await _dead_motion(fc, nw)
await _walk_mode_motion(fc, nw)
class AnimView extends Node3D:
var states: Array[String] = []
func set_anim_state(s: String) -> void:
states.append(s)
# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and
# __SetMotion rejects every non-dead motion while the actor is dead — the
# func/moving edge that arrives with the death must not restart WAIT.
func _dead_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(d: Dictionary) -> Node3D:
return AnimView.new() if int(d.get("race", 0)) == 2301 else null)
var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true})
fc.entity_spawned.emit(ent)
await process_frame
var view := nw.node_for(6) as AnimView
_ck(view != null and view.states.back() == "run", "moving tree monster -> run requested")
if view == null:
return
ent["moving"] = false
ent["dead"] = true
fc.entity_dead.emit(6)
await create_tween().tween_interval(0.4).finished
await process_frame
_ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states])
_ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x)
var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2})
fc.entity_spawned.emit(capsule)
var placeholder: Node3D = nw.node_for(7)
capsule["dead"] = true
fc.entity_dead.emit(7)
await create_tween().tween_interval(0.4).finished
_ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue")
class MoveView extends AnimView:
func get_move_motion_speeds() -> Vector2:
return Vector2(87.5, 425.75)
# packet.h WALKMODE_RUN = 0 / WALKMODE_WALK = 1CActorInstance::Move 按 m_isWalking
# 选 WALK / RUN。模型建好后把走 / 跑根运动速度推给 EntityStore(骑马不推)。
func _walk_mode_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(_d: Dictionary) -> Node3D: return MoveView.new())
var walker := fc.add(8, NEAR_CM + Vector2(900, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 1})
fc.entity_spawned.emit(walker)
var runner := fc.add(9, NEAR_CM + Vector2(1200, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 0})
fc.entity_spawned.emit(runner)
var rider := fc.add(10, NEAR_CM + Vector2(1500, 0), {"race": 101, "ch_type": 0, "mount_vnum": 20030})
fc.entity_spawned.emit(rider)
await process_frame
await process_frame # process_frame 在节点 _process 之前发
var wv := nw.node_for(8) as AnimView
var rv := nw.node_for(9) as AnimView
_ck(wv != null and wv.states.back() == "walk", "walk_mode 1 (WALKMODE_WALK) -> walk: %s" % [wv.states if wv else []])
_ck(rv != null and rv.states.back() == "run", "walk_mode 0 (WALKMODE_RUN) -> run: %s" % [rv.states if rv else []])
_ck(fc.motion_speeds.get(8, Vector2.ZERO) == Vector2(87.5, 425.75), "spawn pushes walk/run motion speeds: %s" % [fc.motion_speeds])
_ck(not fc.motion_speeds.has(10), "mounted actor keeps the duration lerp (no push)")
walker["walk_mode"] = 0
await process_frame
await process_frame
_ck(wv.states.back() == "run", "GC_WALK_MODE flip to run re-selects RUN: %s" % [wv.states])
# 匀角速度转向:一帧转角不超过 1200°/s × dt。
var before := wv.rotation.y
walker["angle_deg"] = 180.0
await process_frame
var t0 := Time.get_ticks_usec()
await process_frame
var dt := float(Time.get_ticks_usec() - t0) / 1e6
_ck(absf(angle_difference(before, wv.rotation.y)) <= deg_to_rad(1200.0) * (dt + 0.05), "turn limited to 1200deg/s")