Files
mtgodot-poc/project/netplay_test.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

533 lines
24 KiB
GDScript
Raw 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.
# netplay_test —— P0 闭环胶水(net_play.gd)的 headless 自检。
# godot --headless --path project --script netplay_test.gd
# 用假 M2Client / 假 PlayerController / 假 HUD 驱动,断言 net_play 产生的调用正确。
# 退出码 0 = 全过。
extends SceneTree
const NetPlay = preload("res://net_play.gd")
const NetWorld = preload("res://net_world.gd")
const EntityRules = preload("res://entity_rules.gd")
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_info(vid: int, entity: Dictionary)
signal entity_main_set(vid: int)
signal entity_dead(vid: int)
signal vitals_changed(vid: int)
signal points_changed(points: Dictionary)
signal target_info(vid: int, hp_percent: int)
signal observer_mode_changed(enabled: bool)
signal fishing_event(subheader: int, info: int, dir: int)
signal chat(type: int, vid: int, text: String)
var ents := {}
var main := 0
var in_game := true
var ground_items := []
var skills := []
var calls := {"move": [], "attack": [], "set_target": [], "click_npc": [],
"pickup": [], "fly": [], "fishing": []}
func is_in_game() -> bool: return in_game
func get_main_vid() -> int: return main
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true
func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true
func set_target(vid) -> bool: calls.set_target.append(vid); return true
func click_npc(vid) -> bool: calls.click_npc.append(vid); return true
func pickup_item(vid) -> bool: calls.pickup.append(vid); return true
func add_fly_targeting(vid, x, y) -> bool: calls.fly.append([vid, x, y]); return true
func fishing(rot_deg) -> bool: calls.fishing.append(rot_deg); return true
func get_ground_items() -> Array: return ground_items
func get_skills() -> Array: return skills
class FakePC extends Node:
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
signal anim_state(state: String)
var player: Node3D
var server_speed := 1.0
var walk_calls := []
var frozen := false
var going := false
func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0
func walk_to(p: Vector3) -> void: walk_calls.append(p); going = true
func is_going() -> bool: return going
func stop() -> void: going = false
class FakeHud extends Node:
var vitals := []
var exp_v := []
var level := 0
var target := []
var energy := []
var cleared := 0
func set_vitals(hp, mhp, sp, msp): vitals = [hp, mhp, sp, msp]
func set_exp(xp, nxp): exp_v = [xp, nxp]
func set_level(lv): level = lv
func set_energy(value, maximum): energy = [value, maximum]
func set_target(nm, pct): target = [nm, pct]
func clear_target(): cleared += 1
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: netplay_test (P0 move/attack/target/HUD loop)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var root := get_root()
var fc := FakeClient.new()
var pc := FakePC.new()
var hud := FakeHud.new()
var nw: Node = NetWorld.new()
pc.player = Node3D.new()
root.add_child(fc); root.add_child(pc); root.add_child(hud); root.add_child(nw)
root.add_child(pc.player)
var np: Node = NetPlay.new()
root.add_child(np)
np.setup(fc, pc, nw, hud)
fc.main = 1000
fc.ents[1000] = {"vid": 1000, "pos": Vector3.ZERO, "hp": 200, "max_hp": 200,
"moving_speed": 150, "attack_speed": 150}
fc.entity_main_set.emit(1000)
fc.entity_info.emit(1000, fc.ents[1000])
_ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale")
# §3.3attack_speed 只喂攻速系数(缩放播放速率),不再在收包时把 period 直接写死
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "entity attack_speed -> attack-speed factor")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"no motion data -> cadence = DEFAULT_ATTACK_PERIOD / factor")
# §5.3SHORSE::CanAttack / CPythonPlayer::__CanAttack 的坐骑等级门。
# 普通坐骑 level 1 不能攻击;新坐骑还需要骑乘术 level 11 或 master。
fc.skills = [{"id": 109, "level": 1, "master": 0}]
fc.ents[1000]["mount_vnum"] = 20101
_ck(not np._can_attack_horse_level(), "horse level 1 -> CanAttackHorseLevel false")
fc.ents[1000]["mount_vnum"] = 20205
_ck(np._can_attack_horse_level(), "horse level 2 -> CanAttackHorseLevel true")
_ck(not np._can_attack(), "new horse skill below level 11 -> attack blocked")
fc.skills[0]["level"] = 11
_ck(np._can_attack(), "new horse skill level 11 -> attack allowed")
fc.ents[1000]["mount_vnum"] = 0
fc.skills = []
# §5.3PythonPlayerInputKeyboard::SetAttackKeyState 在 FISHING mode
# 改走 NEW_Fishing,不设置普通攻击键;GC_FISHING 到达后方向键/地面点击
# 走 NEW_CancelFishing,且 500ms 内不重复发取消包。
np.combo_motion_mode = np.MOTION_MODE_FISHING
pc.player.rotation.y = 0.0
fc.calls.fishing.clear()
np.set_attack_key(true)
_ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 0.0),
"fishing attack key -> CG_FISHING with MapCoord heading")
_ck(not np._attack_key_down, "fishing attack key does not enable normal attack")
fc.fishing_event.emit(0, 1000, 0)
_ck(np.is_fishing(), "GC_FISHING START -> IsFishing state")
_ck(np.cancel_fishing(), "NEW_CancelFishing sends first cancel")
_ck(fc.calls.fishing.size() == 2 and is_equal_approx(float(fc.calls.fishing[1]), 0.0),
"cancel fishing -> CG_FISHING(0)")
_ck(not np.cancel_fishing(), "NEW_CancelFishing throttles within 500ms")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading
pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500)
pc.player.rotation.y = 0.0
pc.moved.emit(pc.player.position)
_ck(fc.calls.move.size() == 1, "one move sent")
if fc.calls.move.size() == 1:
var mv: Array = fc.calls.move[0]
_ck(mv[0] == 1, "func == FUNC_MOVE")
_ck(mv[3] == 300 and mv[4] == -500, "server cm (300,-500), got (%d,%d)" % [mv[3], mv[4]])
_ck(mv[2] >= 0 and mv[2] < 360, "heading in [0,360)")
# 2) 节流:紧接着的小位移不发
pc.player.position = Vector3(3.3, 0, -5)
pc.moved.emit(pc.player.position)
_ck(fc.calls.move.size() == 1, "small quick move throttled")
# 3) 停下 -> move(FUNC_WAIT) 一次
pc.anim_state.emit("wait")
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == 0, "stop -> FUNC_WAIT sent")
pc.anim_state.emit("wait")
_ck(fc.calls.move.size() == 2, "second wait not re-sent")
# 4) 点实体 -> set_target
var mob := Node3D.new()
mob.set_meta("vid", 2000)
root.add_child(mob)
mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false}
nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中
pc.target_selected.emit(mob)
_ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)")
# NPC/传送点/石头走 click_npc,而不是被误当成攻击目标;这是 Cube
# 窗口由服务端 cube_open 命令打开前的入口。
var npc := Node3D.new()
npc.set_meta("vid", 3000)
npc.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 交互距离内(<= 5m
root.add_child(npc)
nw._by_vid[3000] = npc
fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position}
pc.target_selected.emit(npc)
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
# 远距离点选 NPC(> 5m):预约走近,到达交互距离后停下并触发 click_npc
var far_npc := Node3D.new()
far_npc.set_meta("vid", 3010)
far_npc.position = Vector3(20, 0, -5) # 距玩家 16.7m -> 超出 5m 交互范围
root.add_child(far_npc)
nw._by_vid[3010] = far_npc
fc.ents[3010] = {"vid": 3010, "ch_type": 1, "name": "Far NPC", "pos": far_npc.position}
pc.target_selected.emit(far_npc)
_ck(np._reserved_mode == 2, "远距离点 NPC -> 预约走近 (CLICK_ACTOR)")
_ck(pc.is_going(), "远距离点 NPC -> 角色自动向 NPC 移动")
_ck(fc.calls.click_npc == [3000], "尚未到达时不发点击包")
# 玩家走近到 NPC 交互距离内
pc.player.position = Vector3(18, 0, -5)
np._reserve_process_click_actor()
_ck(fc.calls.click_npc == [3000, 3010], "走近到交互距离后 -> 触发 click_npc(3010)")
_ck(np._reserved_mode == 0, "触发交互后清空预约")
_ck(not pc.is_going(), "触发交互后停止移动")
nw._by_vid.erase(3010)
fc.ents.erase(3010)
far_npc.queue_free()
pc.player.position = Vector3(3.3, 0, -5)
# §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。
_ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR,
"race 13000 -> WOODEN_DOOR")
_ck(np._entity_kind({"vid": 3002, "kind": EntityRules.KIND_BUILDING}) == EntityRules.KIND_BUILDING,
"explicit BUILDING kind -> BUILDING")
# 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack
np.set_attack_key(true)
for i in 4: await process_frame
_ck(fc.calls.attack.size() >= 1, "space-held attack fired, got %d" % fc.calls.attack.size())
if fc.calls.attack.size() >= 1:
_ck(fc.calls.attack[0][1] == 2000, "attack victim == 2000")
var n1: int = fc.calls.attack.size()
await process_frame
_ck(fc.calls.attack.size() == n1, "attack throttled within period")
np.set_attack_key(false)
# 5b) §3.4 预约动作状态机(__ReserveProcess_ClickActor 的 10 步顺序)
# 步骤 2:目标在可点击距离外 -> 继续靠近(walk_to),不发攻击
pc.walk_calls.clear()
fc.calls.attack.clear()
mob.position = Vector3(40, 0, -5) # 距玩家 ~37m,远超 1.5m 默认可点击距离
np.set_attack_key(true)
for i in 3: await process_frame
_ck(pc.walk_calls.size() >= 1, "out-of-range reserved target -> walk_to (approach)")
_ck(fc.calls.attack.is_empty(), "out-of-range -> no attack packet")
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "reservation kept while approaching")
# 步骤 10:进入距离 -> 攻击且预约清空
mob.position = Vector3(4, 0, -5)
np._attack_cd = 0.0
fc.calls.attack.clear()
await process_frame
_ck(fc.calls.attack.size() >= 1, "in-range reserved target -> attack fired")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation cleared after swing")
# 步骤 5:自己在安全区 -> OnCannotAttack('IN_SAFE') + 清预约,不攻击
var cannot: Array = []
np.cannot_act.connect(func(code): cannot.append(code))
fc.ents[1000]["in_safe"] = true
np._attack_cd = 0.0
fc.calls.attack.clear()
for i in 2: await process_frame
_ck(cannot.has("IN_SAFE"), "main in safe zone -> cannot_act('IN_SAFE')")
_ck(fc.calls.attack.is_empty(), "safe zone -> no attack packet")
fc.ents[1000]["in_safe"] = false
# __Update_AutoAttack:目标死亡 -> 清 auto-attack + 目标
fc.ents[2000]["dead"] = true
np._attack_cd = 0.0
await process_frame
_ck(np._auto_attack_vid == 0, "dead reserved target -> auto-attack cleared")
fc.ents[2000]["dead"] = false
np.set_attack_key(false)
np._clear_reserved()
# 5c) §3.4 增量 91 —— CLICK_ITEM / CLICK_POSITION / USE_SKILL 预约分派
# + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting
np._target_vid = 0
np._auto_attack_vid = 0
np.set_attack_key(false)
np._clear_reserved()
pc.player.position = Vector3.ZERO
pc.player.rotation.y = 0.0
pc.walk_calls.clear()
fc.calls.move.clear()
# CLICK_ITEM:远处掉落物 -> walk_to;到手 -> pickup + FUNC_WAIT + 清预约
fc.ground_items = [{"vid": 777, "vnum": 1, "owner": "", "pos": Vector3(5, 0, 5)}] # to_world -> (5,0,-5)
np.reserve_click_item(777)
await process_frame
_ck(pc.walk_calls.size() >= 1, "CLICK_ITEM far -> walk_to")
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ITEM, "CLICK_ITEM reservation kept while approaching")
pc.player.position = Vector3(5, 0, -5) # 站到物品上
var mv_n: int = fc.calls.move.size()
await process_frame
_ck(fc.calls.pickup == [777], "CLICK_ITEM in-range -> pickup_item(777)")
_ck(fc.calls.move.size() >= mv_n + 1 and fc.calls.move[mv_n][0] == np.FUNC_WAIT,
"CLICK_ITEM in-range -> FUNC_WAIT state packet")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_ITEM cleared after pickup")
# CLICK_ITEM:物品已消失 -> 清预约,不走
pc.walk_calls.clear()
fc.ground_items = []
np.reserve_click_item(888)
await process_frame
_ck(pc.walk_calls.is_empty() and np._reserved_mode == NetPlay.ReservedMode.NONE,
"CLICK_ITEM missing item -> reservation cleared")
# CLICK_POSITION:延时未到不动;延时耗尽 -> walk_to + 清预约
pc.walk_calls.clear()
np.reserve_click_ground(Vector3(9, 0, -2))
_ck(np._reserved_delay > 0.0, "reserve_click_ground sets a reserved delay")
np._reserved_delay = 999.0
await process_frame
_ck(pc.walk_calls.is_empty(), "CLICK_POSITION waits out the reserved delay")
np._reserved_delay = 0.0
await process_frame
_ck(pc.walk_calls.size() == 1 and pc.walk_calls[0] == Vector3(9, 0, -2),
"CLICK_POSITION delay elapsed -> walk_to(target)")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_POSITION cleared after move")
# USE_SKILLrange>100 存 -10;射程外 walk_to;射程内 -> hook + set_target + 清预约
var smob := Node3D.new()
smob.set_meta("vid", 4100)
root.add_child(smob)
nw._by_vid[4100] = smob
smob.position = Vector3(30, 0, 0)
fc.ents[4100] = {"vid": 4100, "ch_type": 2, "pos": smob.position, "hp": 80, "max_hp": 80, "dead": false, "race": 101}
var hook_calls: Array = []
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
pc.player.position = Vector3.ZERO
_ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm")
_ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1")
var ctx_distance: Callable = np.skill_context().get("target_distance", Callable())
_ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0),
"skill_context exposes the same distance to the PlayerSkill gate")
_ck(not np.is_use_skill_reserved(5), "no reservation yet")
np.reserve_use_skill(4100, 5, 800.0)
_ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10")
_ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot")
pc.walk_calls.clear()
fc.calls.set_target.clear()
await process_frame
_ck(pc.walk_calls.size() >= 1, "USE_SKILL out of range -> walk_to")
_ck(hook_calls.is_empty(), "USE_SKILL out of range -> no cast")
smob.position = Vector3(3, 0, 0) # 3m < 7.9m 射程
await process_frame
_ck(hook_calls == [5], "USE_SKILL in range -> use_skill_hook(slot 5)")
_ck(fc.calls.set_target.has(4100), "USE_SKILL in range -> set_target(4100)")
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL cleared after cast")
np.reserve_use_skill(999999, 5, 300.0)
await process_frame
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL missing target -> cleared")
# __ChangeTargetToPickedInstanceNPC(非战斗目标)-> 清 _target_vid + set_target(0)
np._target_vid = 2000
fc.calls.set_target.clear()
fc.ents[5000] = {"vid": 5000, "ch_type": 1, "name": "NPC"}
var ok_npc: bool = np._change_target_to_picked_instance(5000)
_ck(not ok_npc and np._target_vid == 0 and fc.calls.set_target == [0],
"_change_target_to_picked_instance(NPC) -> target cleared")
fc.calls.set_target.clear()
var ok_mob: bool = np._change_target_to_picked_instance(4100)
_ck(ok_mob and np._target_vid == 4100 and fc.calls.set_target == [4100],
"_change_target_to_picked_instance(monster) -> target set")
# send_fly_targeting —— 把早前实体挪远(不销毁,后续用例还引用),铺一组已知几何
np._target_vid = 0
np._clear_reserved()
pc.player.position = Vector3.ZERO
for v in [2000, 4100]:
if nw._by_vid.has(v):
nw._by_vid[v].position = Vector3(9999, 0, 9999)
if fc.ents.has(v):
fc.ents[v]["pos"] = Vector3(9999, 0, 9999)
var mk := func(v: int, p: Vector3) -> void:
var n := Node3D.new()
n.set_meta("vid", v)
n.position = p
root.add_child(n)
nw._by_vid[v] = n
fc.ents[v] = {"vid": v, "ch_type": 2, "pos": p, "hp": 50, "max_hp": 50, "dead": false, "race": 101}
mk.call(4200, Vector3(0, 0, -10)) # 主目标,正前方 (-Z)
mk.call(4201, Vector3(2, 0, -10)) # 侧翼,扇内
mk.call(4202, Vector3(-2, 0, -10)) # 侧翼,扇内
mk.call(4203, Vector3(0, 0, 12)) # 背后,扇外
fc.calls.fly.clear()
var extras: int = np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.FAN)
_ck(extras == 2, "FAN send_fly_targeting: 2 extras (max 3 - primary), got %d" % extras)
var fly_vids := []
for f in fc.calls.fly:
fly_vids.append(f[0])
_ck(fly_vids.size() == 2 and not fly_vids.has(4200), "FAN: 2 extra packets, primary not re-sent")
_ck(not fly_vids.has(4203), "FAN: target-behind instance excluded from fan")
_ck(4201 in fly_vids and 4202 in fly_vids, "FAN: both flankers picked")
fc.calls.fly.clear()
var extras_c: int = np.send_fly_targeting(4200, 2500.0, 4, NetPlay.FlyShape.CIRCLE)
_ck(extras_c == 3, "CIRCLE: 3 extras (max 4 - primary), got %d" % extras_c)
var cvids := []
for f in fc.calls.fly:
cvids.append(f[0])
_ck(4203 in cvids, "CIRCLE: behind-target instance included (no fan filter)")
fc.calls.fly.clear()
var extras_r: int = np.send_fly_targeting(4200, 2500.0, 6, NetPlay.FlyShape.FAN)
_ck(extras_r == 5, "FAN shortfall: 5 extras for max 6, got %d" % extras_r)
var zero_fills := 0
for f in fc.calls.fly:
if f[0] == 0:
zero_fills += 1
_ck(zero_fills >= 1, "FAN shortfall: random vid=0 positions fill the remainder")
_ck(np.send_fly_targeting(4200, 2500.0, 1, NetPlay.FlyShape.FAN) == 0, "max_count<=1 -> no extras")
_ck(np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.SINGLE) == 0, "SINGLE shape -> no extras")
np._clear_reserved()
np._target_vid = 0
# 6) points_changed -> HUD
fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80,
"exp": 50, "next_exp": 100, "level": 7, "energy": 75})
_ck(hud.vitals == [120, 200, 30, 80], "hud.set_vitals from points")
_ck(hud.exp_v == [50, 100], "hud.set_exp")
_ck(hud.level == 7, "hud.set_level")
_ck(hud.energy == [75, 100], "hud.set_energy from POINT_ENERGY")
# 7) 自己的 GC_MOVE 位置校正(远 -> 瞬移)
fc.ents[1000]["pos"] = Vector3(50, 0, -5)
fc.entity_moved.emit(1000)
_ck(pc.player.position.distance_to(Vector3(50, 0, pc.player.position.z)) < 0.01,
"far server pos -> snap, got %s" % pc.player.position)
# 8) target_info -> hud.set_target
fc.target_info.emit(2000, 40)
_ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)")
# 8b) §8.4「看他国玩家目标框」前置过滤(uitarget.py:189-193 / target_board.hidden_as_other_empire
fc.ents[1000]["empire"] = 1
fc.ents[2500] = {"vid": 2500, "ch_type": 0, "name": "Enemy", "empire": 2,
"pos": Vector3(5, 0, 5), "hp": 100, "max_hp": 100, "dead": false}
# 开关开(默认 true):异阵营 PC 目标框照旧走 CLOSE_IF_DIFFERENT,框上正是该 VID -> 保留
np._target_vid = 2500
fc.target_info.emit(2500, 60)
_ck(np._target_vid == 2500, "看他国框开 -> 异阵营 PC 目标框保留")
# 开关关:异阵营 PC -> 前置过滤直接清目标框
var cleared_n: int = hud.cleared
np.set_view_other_empire_target(false)
np._target_vid = 2500
fc.target_info.emit(2500, 60)
_ck(np._target_vid == 0 and hud.cleared == cleared_n + 1, "看他国框关 -> 异阵营 PC 目标框被前置过滤清掉")
# 开关关不影响怪(empire 0 -> IsSameEmpire 为真)
np._target_vid = 0
fc.target_info.emit(2000, 30)
_ck(hud.target[1] == 30 and np._target_vid == 2000, "看他国框关不影响怪目标(HP 血条照设)")
np.set_view_other_empire_target(true)
fc.ents[1000].erase("empire")
fc.ents.erase(2500)
# 收尾:_target_vid 落在 2000(与原 8 一致),供 9) 目标死亡清框
# 8c) seam ⑫ 收口 —— game.py SetPCTargetBoard :804targetBoard.Open 之后,若按住
# LCONTROL:807)且同阵营(:809 IsSameEmpire)且非本人(:812)且非 building
# :814-> interface.OpenWhisperDialog(name)。poc 侧 net_play 发 whisper_requested
# game_scene 接到 chat.start_whisper()。C++ 只对 PC 分派 SetPCTargetBoard。
var whispers: Array = []
np.whisper_requested.connect(func(nm: String): whispers.append(nm))
fc.ents[1000]["empire"] = 1
var bob := Node3D.new()
bob.set_meta("vid", 6100)
bob.position = Vector3(4, 0, -5)
root.add_child(bob)
nw._by_vid[6100] = bob
fc.ents[6100] = {"vid": 6100, "ch_type": 0, "name": "Bob", "empire": 1,
"pos": bob.position, "hp": 100, "max_hp": 100, "dead": false}
# LCONTROL 没按住 -> 只普通选中,不开悄悄话
np._target_vid = 0
np.set_lcontrol_down(false)
pc.target_selected.emit(bob)
_ck(whispers.is_empty(), "无 LCONTROL 点 PC -> 不开悄悄话")
_ck(fc.calls.set_target.back() == 6100, "点 PC 仍照常选中(set_target 6100")
# 按住 LCONTROL + 同阵营他人 PC -> whisper_requested("Bob")
np.set_lcontrol_down(true)
pc.target_selected.emit(bob)
_ck(whispers == ["Bob"], "LCONTROL 点同阵营他人 PC -> whisper_requested('Bob')")
# 异阵营 PC -> 不请求(:809 IsSameEmpire
whispers.clear()
fc.ents[6100]["empire"] = 2
pc.target_selected.emit(bob)
_ck(whispers.is_empty(), "LCONTROL 点异阵营 PC -> 不开悄悄话")
fc.ents[6100]["empire"] = 1
# 怪(KIND_MONSTER)即使按住 LCONTROL 也不走(C++ 只对 PC 分派 SetPCTargetBoard
whispers.clear()
var wolf2 := Node3D.new()
wolf2.set_meta("vid", 6200)
wolf2.position = Vector3(4, 0, -5)
root.add_child(wolf2)
nw._by_vid[6200] = wolf2
fc.ents[6200] = {"vid": 6200, "ch_type": 2, "name": "Wolf2", "empire": 0,
"pos": wolf2.position, "hp": 50, "max_hp": 50, "dead": false}
pc.target_selected.emit(wolf2)
_ck(whispers.is_empty(), "LCONTROL 点怪 -> 不开悄悄话(仅 PC 分派)")
np.set_lcontrol_down(false)
fc.ents[1000].erase("empire")
fc.ents.erase(6100); fc.ents.erase(6200)
nw._by_vid.erase(6100); nw._by_vid.erase(6200)
np._target_vid = 2000 # 复位到 8b 收尾态,供 9) 目标死亡清框
# 9) 目标死亡 -> 清目标
fc.ents[2000]["dead"] = true
fc.entity_dead.emit(2000)
_ck(hud.cleared >= 1, "target cleared on death")
# 10) 观战模式锁住本地移动/选目标,并清掉当前目标
fc.ents[2000]["dead"] = false
fc.observer_mode_changed.emit(true)
var move_n: int = fc.calls.move.size()
var target_n: int = fc.calls.set_target.size()
pc.player.position = Vector3(8, 0, -5)
pc.moved.emit(pc.player.position)
pc.target_selected.emit(mob)
_ck(fc.calls.move.size() == move_n and fc.calls.set_target.size() == target_n,
"observer mode blocks local movement and targeting")
fc.observer_mode_changed.emit(false)
# 11) §3.2 上行 6 回调:OnMove 立即发 + 重置节流窗、OnMoving 300ms 节流、
# OnStop 收尾、OnAttack 额外发 FUNC_COMBO、on_use_skill 发 FUNC_SKILL|motion。
fc.observer_mode_changed.emit(false)
np._observer_mode = false
np._was_moving = false
np._last_moving_sent_t = 0.0
fc.calls.move.clear()
pc.player.position = Vector3(1, 0, -1)
pc.moved.emit(pc.player.position) # OnMove
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == np.FUNC_MOVE, "OnMove -> immediate FUNC_MOVE")
pc.player.position = Vector3(1.5, 0, -1)
pc.moved.emit(pc.player.position) # OnMoving, throttled (<300ms)
_ck(fc.calls.move.size() == 1, "OnMoving within 300ms throttled")
np._last_moving_sent_t -= 1.0 # 让 300ms 窗口过期
pc.moved.emit(pc.player.position) # OnMoving, now allowed
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE")
pc.anim_state.emit("wait") # OnStop
_ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT")
fc.calls.move.clear()
np.on_use_skill(5, 2) # OnUseSkill
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2,
"on_use_skill -> FUNC_SKILL|motion + arg")