201 lines
8.7 KiB
GDScript
201 lines
8.7 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 := {}
|
||
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 = 1;CActorInstance::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")
|