- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
213 lines
8.5 KiB
GDScript
213 lines
8.5 KiB
GDScript
# net_world_remote_action_test —— 40250 remote attack/combo action routing.
|
|
#
|
|
# The native client keeps FUNC_ATTACK and FUNC_COMBO separate all the way from
|
|
# StateProcess to RunNormalAttack/RunComboAttack. This headless test checks
|
|
# that the Godot presentation bridge preserves the weapon motion mode, the
|
|
# combo segment (bArg), and the attack-speed ratio.
|
|
extends SceneTree
|
|
|
|
const NetWorld = preload("res://net_world.gd")
|
|
|
|
class ActionView extends Node3D:
|
|
var attack_calls: Array = []
|
|
var skill_calls: Array = []
|
|
var emotion_calls: Array = []
|
|
var sync_push_calls := 0
|
|
var states: Array[String] = []
|
|
signal motion_bound(state: String)
|
|
|
|
func get_motion_mode() -> int:
|
|
return 3 # CRaceMotionData::MODE_TWOHAND_SWORD
|
|
|
|
func play_attack_motion(mode: int, index: int, speed: float) -> bool:
|
|
attack_calls.append([mode, index, speed])
|
|
return true
|
|
|
|
func set_anim_state(state: String) -> void:
|
|
states.append(state)
|
|
|
|
func play_skill_motion(name: String, grade: int, loop_count: int,
|
|
moving_skill: bool) -> bool:
|
|
skill_calls.append([name, grade, loop_count, moving_skill])
|
|
return true
|
|
|
|
func set_motion_id(motion: int, target_race: int) -> bool:
|
|
emotion_calls.append([motion, target_race])
|
|
return true
|
|
|
|
func sync_push_motion() -> bool:
|
|
sync_push_calls += 1
|
|
return true
|
|
|
|
class SkillTableFake extends RefCounted:
|
|
func motion_name_by_idx(idx: int) -> String:
|
|
return "skill_7" if idx == 51 else "skill_%d" % idx
|
|
func motion_grade_by_idx(idx: int) -> int:
|
|
return 2 if idx == 51 else 0
|
|
|
|
class CollisionWorld extends Node:
|
|
var samples := 0
|
|
|
|
func is_blocked(x: float, _z: float) -> bool:
|
|
samples += 1
|
|
return x > 1.0
|
|
|
|
class GateClient extends Node:
|
|
var calls: Array = []
|
|
var knockdown_calls: Array = []
|
|
var entities := {}
|
|
|
|
func get_entity(vid: int) -> Dictionary:
|
|
return entities.get(vid, {})
|
|
|
|
func get_entities() -> Array:
|
|
return entities.values()
|
|
|
|
func set_entity_network_state_gate(vid: int, skill_active: bool, skill_can_cancel: bool,
|
|
acting_emotion: bool, tcp_state_enabled: bool) -> void:
|
|
calls.append([vid, skill_active, skill_can_cancel, acting_emotion, tcp_state_enabled])
|
|
|
|
func set_entity_knockdown(vid: int, on: bool) -> void:
|
|
knockdown_calls.append([vid, on])
|
|
|
|
var _fail := 0
|
|
|
|
func _ck(ok: bool, message: String) -> void:
|
|
if not ok:
|
|
_fail += 1
|
|
printerr("FAIL: " + message)
|
|
|
|
func _init() -> void:
|
|
await _run()
|
|
if _fail == 0:
|
|
print("PASS: net_world_remote_action_test")
|
|
quit(0)
|
|
else:
|
|
printerr("%d check(s) failed" % _fail)
|
|
quit(1)
|
|
|
|
func _run() -> void:
|
|
var nw: Node = NetWorld.new()
|
|
get_root().add_child(nw)
|
|
var view := ActionView.new()
|
|
get_root().add_child(view)
|
|
view.set_meta("attack_speed", 150)
|
|
|
|
# Reference RunNormalAttack has no motion argument: it selects the normal
|
|
# attack slot (13) in the active weapon motion mode.
|
|
nw._apply_anim(view, NetWorld.FUNC_ATTACK, false, NetWorld.WALKMODE_RUN, false, 0)
|
|
_ck(view.attack_calls.size() == 1, "FUNC_ATTACK reaches the attack-motion bridge")
|
|
if view.attack_calls.size() >= 1:
|
|
var normal: Array = view.attack_calls[0]
|
|
_ck(int(normal[0]) == 3, "normal attack keeps the active weapon mode")
|
|
_ck(int(normal[1]) == NetWorld.MOTION_NORMAL_ATTACK,
|
|
"normal attack uses CRaceMotionData::NAME_NORMAL_ATTACK")
|
|
_ck(is_equal_approx(float(normal[2]), 1.5), "attack speed 150 becomes fSpeedRatio 1.5")
|
|
|
|
# Reference RunComboAttack passes the packet bArg through unchanged.
|
|
nw._apply_anim(view, NetWorld.FUNC_COMBO, false, NetWorld.WALKMODE_RUN, false, 16)
|
|
_ck(view.attack_calls.size() == 2, "FUNC_COMBO reaches the attack-motion bridge")
|
|
if view.attack_calls.size() >= 2:
|
|
var combo: Array = view.attack_calls[1]
|
|
_ck(int(combo[0]) == 3, "combo keeps the active weapon mode")
|
|
_ck(int(combo[1]) == 16, "combo preserves the packet motion index")
|
|
_ck(is_equal_approx(float(combo[2]), 1.5), "combo keeps the remote attack speed")
|
|
_ck(view.states.is_empty(), "successful attack-resource routing does not fall back to generic state")
|
|
|
|
# The same bridge preserves the remote skill arg and releases the native
|
|
# queue only from the one-shot motion tail; FUNC_EMOTION uses its uArg as the
|
|
# CRaceMotionData action id.
|
|
var gates := GateClient.new()
|
|
get_root().add_child(gates)
|
|
nw.client = gates
|
|
nw.skill_table = SkillTableFake.new()
|
|
view.set_meta("vid", 77)
|
|
nw._by_vid[77] = view
|
|
view.motion_bound.connect(nw._on_remote_motion_bound.bind(77))
|
|
nw._apply_anim(view, NetWorld.FUNC_SKILL | 7, false, NetWorld.WALKMODE_RUN, false, 0x12)
|
|
_ck(view.skill_calls.size() == 1 and view.skill_calls[0] == ["skill_7", 0, 2, true],
|
|
"FUNC_SKILL keeps loop-count and moving-skill bits")
|
|
_ck(gates.calls.size() == 1 and gates.calls[0] == [77, true, false, false, true],
|
|
"non-cancellable remote skill closes the TCP state gate")
|
|
nw._apply_anim(view, NetWorld.FUNC_SKILL | 51, false, NetWorld.WALKMODE_RUN, false, 0)
|
|
_ck(view.skill_calls.size() == 2 and view.skill_calls[1] == ["skill_7", 2, 0, false],
|
|
"grade-specific FUNC_SKILL restores the M/G/P motion grade before binding")
|
|
view.motion_bound.emit("wait")
|
|
_ck(gates.calls.size() == 3 and gates.calls.back() == [77, false, true, false, true],
|
|
"skill motion tail reopens the TCP state gate")
|
|
|
|
nw._apply_anim(view, NetWorld.FUNC_EMOTION, false, NetWorld.WALKMODE_RUN, false, 305)
|
|
_ck(view.emotion_calls.size() == 1 and view.emotion_calls[0] == [305, -1],
|
|
"FUNC_EMOTION uses uArg as the one-shot motion id")
|
|
_ck(gates.calls.size() == 4 and gates.calls.back() == [77, false, true, true, false],
|
|
"active remote emotion disables later TCP processing")
|
|
view.motion_bound.emit("wait")
|
|
_ck(gates.calls.size() == 5 and gates.calls.back() == [77, false, true, false, true],
|
|
"emotion motion tail reopens TCP processing")
|
|
|
|
# GC_SYNC_POSITION enters TEMP_Push: a large correction triggers the
|
|
# DAMAGE_FLYING -> STAND_UP chain, while the presentation offset starts at
|
|
# the old position and eases back to the authoritative target.
|
|
view.set_meta("vid", 77)
|
|
view.set_meta("sync_push_serial", 0)
|
|
gates.entities[77] = {"vid": 77, "pos": Vector3(3.0, 0.0, 0.0),
|
|
"pos_cm": Vector3(300.0, 0.0, 0.0), "sync_push_serial": 1,
|
|
"sync_push_from_cm": Vector3.ZERO, "sync_push_large": true,
|
|
"sync_push_skill_active": false}
|
|
nw._on_moved(77)
|
|
_ck(view.sync_push_calls == 1, "large sync correction starts TEMP_Push reaction")
|
|
_ck(gates.knockdown_calls.size() == 1 and gates.knockdown_calls[0] == [77, true],
|
|
"large sync correction closes the knockdown network-state gate")
|
|
var corrected: Vector3 = nw._sync_pushed_position(77, gates.entities[77], 0.0,
|
|
MapCoord.to_world(Vector3(3.0, 0.0, 0.0)))
|
|
_ck(is_equal_approx(corrected.x, 0.0),
|
|
"TEMP_Push starts from the pre-correction presentation position")
|
|
view.motion_bound.emit("wait")
|
|
_ck(gates.knockdown_calls.size() == 2 and gates.knockdown_calls.back() == [77, false],
|
|
"sync push motion tail releases the knockdown gate")
|
|
|
|
# A physical obstacle aborts TEMP_Push at the first blocked sample and must
|
|
# leave the authoritative target in charge without starting a second motion.
|
|
var obstacle := CollisionWorld.new()
|
|
get_root().add_child(obstacle)
|
|
nw.world = obstacle
|
|
var blocked_entity: Dictionary = gates.entities[77]
|
|
blocked_entity["sync_push_serial"] = 2
|
|
gates.entities[77] = blocked_entity
|
|
nw._on_moved(77)
|
|
_ck(obstacle.samples > 1, "TEMP_Push probes the correction path in segments")
|
|
_ck(not nw._sync_push.has(77) and view.sync_push_calls == 1,
|
|
"blocked TEMP_Push resets blending without replaying knockdown")
|
|
|
|
# A spawn can already carry an action state. The reference binds that motion
|
|
# during actor creation; it must not wait for a later func edge.
|
|
var nw2: Node = NetWorld.new()
|
|
get_root().add_child(nw2)
|
|
var mount := Node3D.new()
|
|
get_root().add_child(mount)
|
|
var spawn_client := GateClient.new()
|
|
get_root().add_child(spawn_client)
|
|
nw2.client = spawn_client
|
|
nw2.parent = mount
|
|
nw2.set_model_factory(func(_d: Dictionary) -> Node3D: return ActionView.new())
|
|
var initial := {"vid": 88, "name": "initial", "race": 101, "ch_type": 2,
|
|
"func": NetWorld.FUNC_COMBO, "func_arg": 15, "moving": false,
|
|
"walk_mode": NetWorld.WALKMODE_RUN, "attack_speed": 125,
|
|
"hp": 100, "max_hp": 100, "dead": false, "pos": Vector3.ZERO,
|
|
"parts": [0, 0, 0, 0], "affect_flags": 0}
|
|
spawn_client.entities[88] = initial
|
|
nw2._on_spawn(initial)
|
|
var initial_view := nw2.node_for(88) as ActionView
|
|
_ck(initial_view != null and initial_view.attack_calls.size() == 1
|
|
and initial_view.attack_calls[0] == [3, 15, 1.25],
|
|
"spawned FUNC_COMBO binds immediately after actor fields are applied")
|
|
|
|
nw.free()
|
|
nw2.free()
|
|
obstacle.free()
|
|
mount.free()
|
|
spawn_client.free()
|
|
gates.free()
|
|
view.free()
|
|
await process_frame
|