Files
mtgodot-poc/project/combat_fx_test.gd
T
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

420 lines
18 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.
# combat_fx_test —— P4:受击硬直 / combo / 抖屏 / 状态图标 / 死亡窗 headless 自检。
# godot --headless --path project --script combat_fx_test.gd
extends SceneTree
const NetPlay = preload("res://net_play.gd")
const GameCamera = preload("res://game_camera.gd")
const DeathUI = preload("res://ui/death_ui.gd")
class FakeClient extends Node:
signal entity_main_set(vid: int)
signal entity_moved(vid: int)
signal entity_despawned(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 damage(vid: int, amount: int, flag: int)
signal fishing_event(subheader: int, info: int, dir: int)
signal affect_added(affect: Dictionary)
signal affect_removed(type: int)
signal phase_changed(phase: String)
var main := 1000
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
var attacks := []
var moves := []
var synced := []
var said := []
var affects := []
var fly_targets := [] # §3.6CG_FLY_TARGETING
var shots := [] # §3.6CG_SHOOT
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_affects() -> Array: return affects
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
func add_fly_targeting(vid, x, y) -> bool: fly_targets.append([vid, x, y]); return true
func shoot(skill) -> bool: shots.append(skill); return true
func set_target(v) -> bool: return true
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
func sync_positions(arr) -> bool: synced.append(arr); return true
func say(t, s) -> bool: said.append([t, s]); return true
class FakeNetWorld extends Node:
var nodes := {}
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
class FakeProto extends Node:
var items := {}
func item(vnum: int) -> Dictionary: return items.get(vnum, {})
class FakePC extends Node:
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
signal anim_state(state: String)
var player: Node3D
var frozen := false
class FakeAnim extends Node:
var md := {}
func get_motion_data() -> Dictionary: return md
class FakeView extends Node3D:
var states := []
var anim := FakeAnim.new()
func _init() -> void:
add_child(anim)
func set_anim_state(s): states.append(s)
class FakeHud extends Node:
var affects := []
func set_affects(a): affects = a
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: combat_fx_test (hit-gate / combo / shake / affects / death)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var fc := FakeClient.new()
var pc := FakePC.new()
var view: Node3D = FakeView.new()
var hud := FakeHud.new()
var cam: Camera3D = GameCamera.new()
pc.player = Node3D.new()
for n in [fc, pc, view, hud, cam, pc.player]:
get_root().add_child(n)
cam.target = pc.player
var np: Node = NetPlay.new()
get_root().add_child(np)
np.setup(fc, pc, null, hud)
np.camera = cam
np.player_view = view
np._main_vid = 1000
# --- 受击:播击退动作 + 抖屏;「命中」不靠固定硬直秒数锁输入(§3.7)---
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
_ck(np._knock_down, "hit -> knock_down latch set")
_ck(np.is_stunned(), "hit (knock_down) -> cannot act")
_ck(cam._shake > 0.0, "hit -> camera shake queued")
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
np._process(0.016)
_ck(pc.frozen == true, "knock_down -> pc.frozen = true (got %s)" % pc.frozen)
# 击退动作结束 -> 解除(动作时长驱动)
np._knock_down = false
_ck(not np.is_stunned(), "knockback motion ended -> can act")
# 服务端 stun 标志独立驱动闸门
fc.ents[1000]["stunned"] = true
_ck(np.is_stunned(), "server stunned flag -> cannot act")
_ck(not np._can_attack(), "server stunned -> _can_attack() false")
fc.ents[1000]["stunned"] = false
_ck(not np.is_stunned(), "server stun cleared -> can act")
# 闪避不播击退
np._knock_down = false
fc.damage.emit(1000, 0, 1 << 2) # DODGE
_ck(not np._knock_down, "dodge -> no knock_down")
_ck(not np.is_stunned(), "dodge -> can act")
# 别人受击不影响自己
np._knock_down = false
fc.damage.emit(2000, 30, 0)
_ck(not np._knock_down, "other's damage -> no knock_down")
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
np.player_view = view
fc.fishing_event.emit(0, 1000, 18)
_ck(view.states.back() == "fishing", "fishing start -> local fishing animation")
fc.fishing_event.emit(3, 1000, 0)
_ck(view.states.back() == "fishing_catch", "fishing success -> catch animation")
var before: int = view.states.size()
fc.fishing_event.emit(5, 30001, 0)
_ck(view.states.size() == before, "fishing fish item -> no local motion")
# --- combo:连续攻击 motion 递增 ---
np._knock_down = false
np._target_vid = 2000
np.attack_period = 0.0
# §3.3:普攻节奏来自动作数据 / 攻速系数,不再是硬编码常量
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD),
"no motion data -> attack period falls back to DEFAULT_ATTACK_PERIOD")
np._apply_attack_speed(150)
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"attack speed scales the resolved period")
np._apply_attack_speed(100)
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
np.combo_class = 0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
view.anim.md = {
"has_combo_input": true, "duration": 1.0,
"pre_input_time": 0.10, "direct_input_time": 0.20, "input_limit_time": 0.40,
"next_combo": 0.20,
}
var pcn := Node3D.new(); get_root().add_child(pcn)
np.pc.player = pcn
np._vid_reserved = 2000
fc.attacks.clear()
await process_frame # SceneTree 脚本:等一帧让 add_child 的节点真正入树(global_position 才有效)
# 第一次输入:m_dwcurComboIndex 0 -> __RunNextCombo -> 段 1(段号 14
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1, "first input -> combo index 1, got %d" % np._combo_index)
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
# elapsed 落在 [start, next):置 m_isPreInput,不推进
np._combo_started_t = np._now() - 0.15
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
# ComboProcesselapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
np._combo_started_t = np._now() - 0.25
np._combo_process()
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "advance to last seg 4")
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
# 动作回到 Waitelapsed > duration-> __ClearCombo
np._combo_started_t = np._now() - 1.5
np._combo_process()
_ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo")
# 没有段表(缺资源)-> 退化为单段普攻(段号 13)
np._combo_tables = {}
fc.moves.clear()
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
"no combo table -> single NORMAL_ATTACK swing")
# --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList ---
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree()
# 几何判定读 global_position,所以这一小节先 await 一帧。
var nw := FakeNetWorld.new(); get_root().add_child(nw)
np.net_world = nw
var vnode := Node3D.new(); get_root().add_child(vnode)
nw.nodes[2000] = vnode
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true}
fc.ents[1000]["dead"] = false
await process_frame
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 0 -> 前向 +Z
pcn.global_position = Vector3.ZERO
pcn.rotation.y = 0.0
view.anim.md = {
"hit_windows": [{
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand",
"weapon_length": 120.0, "samples": [],
}],
"motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1,
"next_combo": 0.2,
}
np._combo_tables = {}
fc.attacks.clear()
fc.synced.clear()
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
np._swing_start_t = np._now() - 0.1 # 推进到命中窗内
np._process(0.016)
_ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000],
"in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)")
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000,
"pushed victim -> frame-end CG_SYNC_POSITION")
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
vnode.global_position = Vector3(0, 0, -1.0) # 转到身后
np._hit_dedup.clear()
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit")
vnode.global_position = Vector3(0, 0, 1.0)
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
np._process(0.016)
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
# 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中)
fc.ents[2000]["dead"] = true
np._do_attack_swing(pcn, {})
np._hit_dedup.clear()
np._swing_start_t = np._now() - 0.1
np._process(0.016)
_ck(fc.attacks.size() == 1, "dead victim inside the hit window -> no CG_ATTACK")
fc.ents[2000]["dead"] = false
np.net_world = null
# --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)---
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, false, false) \
== np.MOTION_MODE_ONEHAND_SWORD, "sword -> ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_TWO_HANDED, false, false, false) \
== np.MOTION_MODE_TWOHAND_SWORD, "two-handed -> TWOHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_DAGGER, false, false, false) \
== np.MOTION_MODE_DUALHAND_SWORD, "dagger -> DUALHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, false, false, false) \
== np.MOTION_MODE_BOW, "bow -> BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_FAN, false, false, false) \
== np.MOTION_MODE_FAN, "fan -> FAN")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BELL, false, false, false) \
== np.MOTION_MODE_BELL, "bell -> BELL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, false, false, false) \
== np.MOTION_MODE_GENERAL, "arrow / bare hand -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, false, false, false) \
== np.MOTION_MODE_FISHING, "fishing rod -> FISHING")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, true, false, false) \
== np.MOTION_MODE_HORSE, "rod while mounted -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, false) \
== np.MOTION_MODE_HORSE_ONEHAND_SWORD, "mounted sword -> HORSE_ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, true, false, false) \
== np.MOTION_MODE_HORSE_BOW, "mounted bow -> HORSE_BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, true, false, false) \
== np.MOTION_MODE_HORSE, "mounted arrow -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, true, false) \
== np.MOTION_MODE_GENERAL, "poly overrides weapon -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, true) \
== np.MOTION_MODE_WEDDING_DRESS, "wedding dress overrides all -> WEDDING_DRESS")
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
var fp := FakeProto.new()
get_root().add_child(fp)
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
np.proto = fp
np._main_vid = 1000
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._combo_index = 3
fc.ents[1000]["parts"] = [0, 19, 0, 0]
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_DUALHAND_SWORD, "parts[WEAPON]=dagger -> DUALHAND_SWORD")
_ck(np._combo_index == 0, "motion mode change clears the running combo")
fc.ents[1000]["mount_vnum"] = 20101
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_HORSE_DUALHAND_SWORD, "mount up -> HORSE_DUALHAND_SWORD")
fc.ents[1000].erase("parts")
fc.ents[1000].erase("mount_vnum")
np.proto = null
# --- §3.5 增量 85:弓箭 __GetBowRange() → _clickable_distance ---
np._bow_distance_bonus = 0.0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), np.CLICK_DIST_DEFAULT_CM / np.CM),
"non-bow: 玩家 / 怪目标 reach = 150cm")
np.combo_motion_mode = np.MOTION_MODE_BOW
_ck(is_equal_approx(np._bow_range_cm(), 2500.0 - 100.0), "__GetBowRange 基础 = 2500-100")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0) / np.CM),
"bow mode: 非 NPC / 非采集物目标 reach = __GetBowRange()")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 1}), np.CLICK_DIST_NPC_CM / np.CM),
"NPC 覆盖弓箭距离 → 500cm")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 3}), np.CLICK_DIST_RESOURCE_CM / np.CM),
"采集物覆盖弓箭距离 → 100cm")
np.combo_motion_mode = np.MOTION_MODE_HORSE_BOW
_ck(np._is_bow_mode(), "HORSE_BOW 也算 bow mode")
np._bow_distance_bonus = 300.0
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0 + 300.0) / np.CM),
"POINT_BOW_DISTANCE 加成叠加进 __GetBowRange()")
np._on_points({"attack_speed": 0, "bow_distance": 0})
_ck(is_equal_approx(np._bow_distance_bonus, 0.0), "_on_points 读取 bow_distance → _bow_distance_bonus")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# --- §3.6 增量 86:弓 FLY 事件(OnSetFlyTarget / OnShoot---
var nw2 := FakeNetWorld.new(); get_root().add_child(nw2)
var tnode := Node3D.new(); get_root().add_child(tnode)
await process_frame
tnode.global_position = Vector3(3.0, 0.0, 4.0)
nw2.nodes[2000] = tnode
np.net_world = nw2
np._target_vid = 2000
np._swing_skill = 0
np._combo_tables = {}
view.anim.md = {}
fc.fly_targets.clear()
fc.attacks.clear()
var got_shot := []
np.bow_shot_fired.connect(func(sk: int): got_shot.append(sk))
# 非弓模式:起手不发 CG_FLY_TARGETING,也不冒 bow_shot_fired
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.is_empty() and got_shot.is_empty(), "非弓模式挥击 → 无 FLY 事件")
# 弓模式:起手即发 CG_FLY_TARGETING(目标VID, 目标平面坐标) + 冒 bow_shot_fired(uSkill)
np.combo_motion_mode = np.MOTION_MODE_BOW
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.size() == 1 and fc.fly_targets[0][0] == 2000,
"弓挥击起手 → OnSetFlyTarget 发 CG_FLY_TARGETING(vid 2000)")
var exp_xy := MapCoord.to_server_cm(Vector3(3.0, 0.0, 4.0))
_ck(fc.fly_targets[0][1] == int(exp_xy.x) and fc.fly_targets[0][2] == int(exp_xy.y),
"CG_FLY_TARGETING 坐标 = 目标节点 server-XYOnGetFlyTargetPosition 平面分量)")
_ck(got_shot == [0], "弓挥击 → bow_shot_fired(uSkill=0)OnShoot 延后到 .msa FLY 帧)")
# 无目标:不发
np._target_vid = 0
np._vid_reserved = 0
fc.fly_targets.clear()
np._send_fly_target()
_ck(fc.fly_targets.is_empty(), "无目标 → 不发 CG_FLY_TARGETING")
np.net_world = null
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._swing_skill = 0
# --- 状态图标条 ---
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
# game_scene 里连的是 refresh lambda;这里直接调 hud
hud.set_affects(fc.get_affects())
_ck(hud.affects.size() == 1 and int(hud.affects[0]["type"]) == 7001, "hud.set_affects")
# --- 死亡窗 ---
var canvas := CanvasLayer.new()
get_root().add_child(canvas)
var death: Node = DeathUI.new()
get_root().add_child(death)
death.setup(fc, canvas)
_ck(not death._root.visible, "death dialog hidden initially")
fc.ents[1000]["dead"] = true
fc.entity_dead.emit(1000)
_ck(death._root.visible, "entity_dead(main) -> death dialog shown")
# 点「在此复活」
var hb: Button = null
for n in death._root.find_children("*", "Button", true, false):
if n.text == "在此复活":
hb = n
_ck(hb != null, "restart-here button present")
if hb:
hb.pressed.emit()
_ck(fc.said.size() == 1 and fc.said[0] == [0, "/restart_here"], "restart -> say(0, '/restart_here')")
# 复活:hp 回来 -> 自动关
fc.ents[1000]["dead"] = false
fc.ents[1000]["hp"] = 100
fc.vitals_changed.emit(1000)
_ck(not death._root.visible, "revive (hp>0) -> dialog auto-hides")