409 lines
18 KiB
GDScript
409 lines
18 KiB
GDScript
# 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.6:CG_FLY_TARGETING
|
||
var shots := [] # §3.6:CG_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 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")
|
||
|
||
# ComboProcess:elapsed 过了 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)")
|
||
|
||
# 动作回到 Wait(elapsed > 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 m(yaw 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")
|
||
np.net_world = null
|
||
|
||
# --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 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()
|
||
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-XY(OnGetFlyTargetPosition 平面分量)")
|
||
_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")
|