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 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
+517
-89
@@ -15,6 +15,8 @@
|
||||
# np.setup(m2client, player_ctl, net_world, hud)
|
||||
extends Node
|
||||
|
||||
signal level_up(new_level: int)
|
||||
|
||||
signal target_changed(vid: int)
|
||||
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
|
||||
# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。
|
||||
@@ -46,8 +48,8 @@ const FAN_HALF_ROT_MIN_DISTANCE := 1000.0
|
||||
# NEW_GetRandomPositionInFanRange(:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。
|
||||
const FAN_RANDOM_JITTER_DEG := 10.0
|
||||
const FAN_RANDOM_REACH_CM := 8000.0
|
||||
# MODE_CLICK_ITEM 的到手阈值(PythonPlayerInputMouse.cpp:297 `< 20.0f`,单位 cm)。
|
||||
const CLICK_ITEM_PICKUP_CM := 20.0
|
||||
# MODE_CLICK_ITEM 的到手阈值(40250 server item.cpp:435 DistanceValid <= 300cm)。
|
||||
const CLICK_ITEM_PICKUP_CM := 300.0
|
||||
# MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。
|
||||
const HUGE_RACE_DIST_FIX_CM := 200.0
|
||||
# _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。
|
||||
@@ -115,6 +117,11 @@ var client: Node # M2Client
|
||||
var pc: Node # PlayerController
|
||||
var net_world: Node # NetWorld
|
||||
var hud: Node # 可空
|
||||
var target_ui: Node # 40250 uitarget.py TargetBoard (可空)
|
||||
var audio: Node # 可空
|
||||
var _last_level := -1
|
||||
var _last_exp := -1
|
||||
var _last_dead_mob_pos := Vector3.INF
|
||||
# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime`
|
||||
# (`CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。
|
||||
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
|
||||
@@ -132,9 +139,18 @@ var player_view: Node:
|
||||
player_view = v
|
||||
if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound):
|
||||
v.motion_bound.connect(_on_motion_bound)
|
||||
if v and v.has_method("set_motion_mode"):
|
||||
v.set_motion_mode(combo_motion_mode)
|
||||
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
|
||||
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount)
|
||||
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
|
||||
var proto: Node: # Metin2Proto(可空,按 race 分类 NPC/怪 + 武器动作模式)
|
||||
set(v):
|
||||
proto = v
|
||||
# GameScene receives the initial entity/equipment burst before item_proto
|
||||
# finishes loading. Re-evaluate here or a 3150 weapon remains GENERAL until
|
||||
# the next inventory packet, visually leaving the left hand off the grip.
|
||||
if v:
|
||||
_refresh_motion_mode()
|
||||
|
||||
const DAMAGE_DODGE := 1 << 2
|
||||
const DAMAGE_CRITICAL := 1 << 5
|
||||
@@ -200,8 +216,13 @@ var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒)
|
||||
var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒)
|
||||
var _was_moving := false
|
||||
var _attack_cd := 0.0
|
||||
var _last_click_actor_t := -10.0
|
||||
var _last_click_actor_vid := 0
|
||||
var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing)
|
||||
var _using_skill := false # 施法中(§3.8 由 player_skill 驱动)
|
||||
var _skill_cast_start_time := -1.0 # 技能动作起播时间
|
||||
var _skill_cast_duration := 0.0 # 技能动作时长超时门控
|
||||
var _skill_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控
|
||||
# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo /
|
||||
# __OnEndCombo / __ClearCombo)
|
||||
var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shaman(race & 3)
|
||||
@@ -215,6 +236,7 @@ var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array }
|
||||
var _local_time := 0.0 # GetLocalTime()
|
||||
var _delay := 0.0 # m_fDelay
|
||||
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
|
||||
var _is_attacking := false # 当前是否处于挥击攻击动作中(40250 isLock 门)
|
||||
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
|
||||
# + m_HitDataMap + m_kVctkVictim 的等价)
|
||||
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
|
||||
@@ -253,12 +275,15 @@ var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBo
|
||||
var _dash_active := false # __CheckDashAffect
|
||||
var _charge_skill := false # 预约技能 IsChargeSkill()
|
||||
|
||||
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
|
||||
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, audio_node: Node = null) -> void:
|
||||
client = m2client
|
||||
pc = player_ctl
|
||||
net_world = nw
|
||||
hud = hud_node
|
||||
audio = audio_node
|
||||
pc.target_selected.connect(_on_pick)
|
||||
if pc.has_signal("ground_item_clicked"):
|
||||
pc.ground_item_clicked.connect(pick_ground_item)
|
||||
pc.moved.connect(_on_local_moved)
|
||||
pc.anim_state.connect(_on_anim_state)
|
||||
client.entity_main_set.connect(_on_main_set)
|
||||
@@ -274,7 +299,13 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
|
||||
client.vitals_changed.connect(_on_vitals)
|
||||
client.target_info.connect(_on_target_info)
|
||||
client.entity_despawned.connect(_on_entity_gone)
|
||||
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
|
||||
client.entity_dead.connect(func(v):
|
||||
if v == _target_vid:
|
||||
if is_instance_valid(net_world) and net_world.has_method("_entity_pos"):
|
||||
var mpos: Vector3 = net_world._entity_pos(v)
|
||||
if mpos != Vector3.INF:
|
||||
_last_dead_mob_pos = mpos
|
||||
_clear_target())
|
||||
if client.has_signal("damage"):
|
||||
client.damage.connect(_on_damage)
|
||||
if client.has_signal("fishing_event"):
|
||||
@@ -309,6 +340,16 @@ func set_attack_key(down: bool) -> void:
|
||||
_attack_key_down = down
|
||||
if not down:
|
||||
_attack_cd = 0.0
|
||||
_clear_auto_attack()
|
||||
_clear_reserved()
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
|
||||
# 键盘 / 摇杆手动移动:对齐 NEW_MoveToDirection,清预约并取消钓鱼
|
||||
func on_manual_move() -> bool:
|
||||
_clear_reserved()
|
||||
_clear_auto_attack()
|
||||
return cancel_fishing()
|
||||
|
||||
# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。
|
||||
# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。
|
||||
@@ -413,11 +454,31 @@ func _on_motion_bound(_state: String) -> void:
|
||||
_hit_dedup.clear()
|
||||
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
|
||||
_combo_index = 0
|
||||
if _state == "wait":
|
||||
_is_attacking = false
|
||||
_using_skill = false
|
||||
_charge_skill = false
|
||||
_skill_cancel_enable = false
|
||||
|
||||
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) / InterceptOnceMotion
|
||||
# 40250 PythonPlayerSkill.cpp:751: __ClearReservedAction(); __ClearAutoAttackTargetActorID(); NEW_Stop();
|
||||
func start_skill_cast(duration: float = 2.0, is_charge: bool = false, cancel_enable: bool = false) -> void:
|
||||
_using_skill = true
|
||||
_charge_skill = is_charge
|
||||
_skill_cancel_enable = cancel_enable
|
||||
_skill_cast_start_time = _local_time
|
||||
_skill_cast_duration = duration if duration > 0.0 else 2.0
|
||||
_clear_reserved()
|
||||
_clear_auto_attack()
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
|
||||
func set_combo_motion_mode(mode: int) -> void:
|
||||
if mode != combo_motion_mode:
|
||||
combo_motion_mode = mode
|
||||
_clear_combo()
|
||||
if player_view and player_view.has_method("set_motion_mode"):
|
||||
player_view.set_motion_mode(mode)
|
||||
|
||||
# §3.5 修改 6 —— CInstanceBase::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支:
|
||||
# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE /
|
||||
@@ -580,16 +641,14 @@ func _entity_kind(e: Dictionary) -> int:
|
||||
var explicit_kind := int(e.get("kind", -1))
|
||||
if explicit_kind >= EntityRules.KIND_BUILDING:
|
||||
return explicit_kind
|
||||
var ct := int(e.get("ch_type", 0))
|
||||
if ct != 0:
|
||||
return ct
|
||||
var race := int(e.get("race", 0))
|
||||
if EntityRules._is_wooden_door(e):
|
||||
return EntityRules.KIND_WOODEN_DOOR
|
||||
# 优先查 mob_proto:服务端 GC_CHARACTER_ADD 在经典协议 / 部分服务端实现下 bType
|
||||
# 经常全发 0(导致被客户端转为 MONSTER=2),权威分类必须以 mob_proto(bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR)为准。
|
||||
if race >= 1 and proto and proto.has_method("mob"):
|
||||
var m: Dictionary = proto.mob(race)
|
||||
if not m.is_empty():
|
||||
# mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
|
||||
match int(m.get("type", 0)):
|
||||
0: return 2
|
||||
1: return 1
|
||||
@@ -597,6 +656,11 @@ func _entity_kind(e: Dictionary) -> int:
|
||||
3: return 4
|
||||
4: return EntityRules.KIND_WOODEN_DOOR \
|
||||
if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING
|
||||
var ct := int(e.get("ch_type", 0))
|
||||
if ct != 0:
|
||||
return ct
|
||||
if explicit_kind >= 0:
|
||||
return explicit_kind
|
||||
return 0
|
||||
|
||||
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
|
||||
@@ -677,6 +741,10 @@ func on_use_skill(motion_skill: int, arg: int) -> void:
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
_main_vid = vid
|
||||
_clear_reserved()
|
||||
_clear_auto_attack()
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
if client and client.has_method("get_entity"):
|
||||
_on_entity_info(vid, client.get_entity(vid))
|
||||
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
|
||||
@@ -741,13 +809,50 @@ func _clickable_distance(te: Dictionary) -> float:
|
||||
if _is_bow_mode():
|
||||
cm = _bow_range_cm()
|
||||
match _entity_kind(te):
|
||||
1: cm = CLICK_DIST_NPC_CM # NPC
|
||||
1, 4: cm = CLICK_DIST_NPC_CM # NPC / 传送门
|
||||
3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物
|
||||
return cm / CM
|
||||
|
||||
# 40250 CActorInstance::IsClickableDistanceDestInstance (ActorInstanceBattle.cpp:117)
|
||||
# 遍历目标防御球,若存在防御球,判断 ||rkSphere.v3Position - kD3DVct3Src|| < rkSphere.fRadius + fDistance
|
||||
func _is_clickable_distance(tnode: Node3D, te: Dictionary) -> bool:
|
||||
if pc == null or pc.player == null or tnode == null:
|
||||
return false
|
||||
var base_dist := _clickable_distance(te)
|
||||
var my_pos := _world_pos(pc.player)
|
||||
var target_pos := _world_pos(tnode)
|
||||
var flat_dist := Vector2(my_pos.x - target_pos.x, my_pos.z - target_pos.z).length()
|
||||
if flat_dist <= base_dist:
|
||||
return true
|
||||
var spheres := _defending_spheres(int(te.get("vid", 0)), tnode, _actor_cm(target_pos))
|
||||
if not spheres.is_empty():
|
||||
for s in spheres:
|
||||
if s is Dictionary:
|
||||
var spos_cm: Vector3 = s.get("pos", Vector3.ZERO)
|
||||
var spos_m := _godot_m(spos_cm)
|
||||
var srad_m: float = float(s.get("radius", 0.0)) / CM
|
||||
var d_sph := Vector2(my_pos.x - spos_m.x, my_pos.z - spos_m.z).length()
|
||||
if d_sph < (base_dist + srad_m):
|
||||
return true
|
||||
return false
|
||||
return flat_dist <= base_dist
|
||||
|
||||
func _on_points(p: Dictionary) -> void:
|
||||
_apply_attack_speed(int(p.get("attack_speed", 0)))
|
||||
_bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange()
|
||||
|
||||
var cur_level := int(p.get("level", 0))
|
||||
if cur_level > 0:
|
||||
if _last_level > 0 and cur_level > _last_level:
|
||||
_trigger_level_up(cur_level, p)
|
||||
_last_level = cur_level
|
||||
|
||||
var cur_exp := int(p.get("exp", 0))
|
||||
if _last_exp >= 0 and cur_exp > _last_exp:
|
||||
var delta_exp := cur_exp - _last_exp
|
||||
_trigger_exp_gain(delta_exp, p)
|
||||
_last_exp = cur_exp
|
||||
|
||||
if hud == null:
|
||||
return
|
||||
if hud.has_method("set_vitals"):
|
||||
@@ -758,10 +863,78 @@ func _on_points(p: Dictionary) -> void:
|
||||
if hud.has_method("set_exp"):
|
||||
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
|
||||
if hud.has_method("set_level"):
|
||||
hud.set_level(int(p.get("level", 0)))
|
||||
hud.set_level(cur_level)
|
||||
if hud.has_method("set_energy"):
|
||||
hud.set_energy(int(p.get("energy", 0)), 100)
|
||||
|
||||
func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
|
||||
print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
|
||||
|
||||
# 1. 40250 官方经验聚能光球飞行与吸收
|
||||
if is_instance_valid(net_world) and net_world.has_method("spawn_exp_fly"):
|
||||
var spawn_pos := _last_dead_mob_pos
|
||||
var player_node: Node3D = pc.player if (pc and is_instance_valid(pc.player)) else null
|
||||
if spawn_pos == Vector3.INF and is_instance_valid(player_node):
|
||||
# 若无死亡怪物坐标记录,则在玩家正前方 2.5 米处生成光球回吸
|
||||
var p_pos := player_node.global_position if player_node.is_inside_tree() else player_node.position
|
||||
var forward := -player_node.global_transform.basis.z if player_node.is_inside_tree() else Vector3(0, 0, -1)
|
||||
spawn_pos = p_pos + forward * 2.5
|
||||
if spawn_pos != Vector3.INF:
|
||||
net_world.spawn_exp_fly(spawn_pos, player_node, 3)
|
||||
_last_dead_mob_pos = Vector3.INF
|
||||
|
||||
# 2. 40250 吸收音效与任务栏反馈
|
||||
if audio and audio.has_method("play_ui"):
|
||||
audio.play_ui("money.wav")
|
||||
|
||||
func _trigger_level_up(new_level: int, p: Dictionary) -> void:
|
||||
level_up.emit(new_level)
|
||||
print("[升级] 恭喜!等级提升至 Lv. %d!" % new_level)
|
||||
|
||||
# 1. 40250 EFFECT_LEVELUP 冲天金光特效
|
||||
_spawn_level_up_effect()
|
||||
|
||||
# 2. 40250 播放专属升级号角音效
|
||||
if audio and audio.has_method("play_ui"):
|
||||
audio.play_ui("levelup1_1.wav")
|
||||
|
||||
# 3. 40250 规则:升级瞬间 HP、SP 立即 100% 满状态恢复
|
||||
var max_hp := int(p.get("max_hp", 0))
|
||||
var max_sp := int(p.get("max_sp", 0))
|
||||
if max_hp > 0 and hud and hud.has_method("set_vitals"):
|
||||
hud.set_vitals(max_hp, max_hp, max_sp, max_sp)
|
||||
|
||||
# 4. 通知 HUD 刷新属性加点
|
||||
if hud and hud.has_method("on_level_up"):
|
||||
hud.on_level_up(new_level)
|
||||
|
||||
func _spawn_level_up_effect() -> void:
|
||||
var pl: Node3D = pc.player if (pc and "player" in pc) else null
|
||||
if pl == null or not is_instance_valid(pl):
|
||||
return
|
||||
var beam := MeshInstance3D.new()
|
||||
beam.name = "level_up_beam"
|
||||
var cyl := CylinderMesh.new()
|
||||
cyl.top_radius = 0.45
|
||||
cyl.bottom_radius = 0.65
|
||||
cyl.height = 7.0
|
||||
beam.mesh = cyl
|
||||
beam.position.y = 3.5
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(1.0, 0.85, 0.25, 0.75)
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(1.0, 0.88, 0.3)
|
||||
beam.material_override = mat
|
||||
pl.add_child(beam)
|
||||
|
||||
var tw := pl.create_tween()
|
||||
tw.set_parallel(true)
|
||||
tw.tween_property(beam, "scale:y", 1.4, 1.2)
|
||||
tw.tween_property(mat, "albedo_color:a", 0.0, 1.2)
|
||||
tw.chain().tween_callback(beam.queue_free)
|
||||
|
||||
func _on_net_moved(vid: int) -> void:
|
||||
if client == null or vid != _main_vid or pc.player == null:
|
||||
return
|
||||
@@ -769,15 +942,39 @@ func _on_net_moved(vid: int) -> void:
|
||||
if e.is_empty():
|
||||
return
|
||||
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
|
||||
sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
|
||||
var w: Node = pc.world if (pc and "world" in pc and pc.world) else (net_world.world if (net_world and "world" in net_world) else null)
|
||||
if w and w.has_method("sample_height"):
|
||||
sp.y = float(w.call("sample_height", sp.x, sp.z))
|
||||
else:
|
||||
sp.y = pc.player.position.y # 无地形时的兜底
|
||||
var d: float = pc.player.position.distance_to(sp)
|
||||
# 40250 对齐:CInstanceBase::PushTCPState 对主玩家显式忽略(if (__IsMainInstance()) return)。
|
||||
# 本地移动由客户端权威推进,常规移动不作平滑插值(避免网络 RTT 滞后引发反拉与按键对抗)。
|
||||
# 仅在距离超出瞬移阈值时(服务端传送、换图、防作弊重置)直接 Snap 同步。
|
||||
if d > CORRECTION_SNAP:
|
||||
pc.player.position = sp
|
||||
elif d > 0.3:
|
||||
pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
|
||||
# --- targeting / attack -----------------------------------------------------
|
||||
|
||||
static func _world_pos(n: Node3D) -> Vector3:
|
||||
if n == null:
|
||||
return Vector3.ZERO
|
||||
return n.global_position if n.is_inside_tree() else n.position
|
||||
|
||||
# 40250 CPythonPlayer::__SendClickActorPacket (PythonPlayer.cpp:1196)
|
||||
# 节流保护:s_dwNextTCPTime = dwCurTime + 1000(同目标 1 秒内不重复发送点击包,防止狂点刷包)
|
||||
func _send_click_actor_packet(vid: int) -> bool:
|
||||
if vid <= 0 or client == null or not client.has_method("click_npc"):
|
||||
return false
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if vid == _last_click_actor_vid and (now - _last_click_actor_t) < 1.0:
|
||||
return false
|
||||
_last_click_actor_t = now
|
||||
_last_click_actor_vid = vid
|
||||
return client.click_npc(vid)
|
||||
|
||||
func _on_pick(node: Node3D) -> void:
|
||||
if _observer_mode:
|
||||
return
|
||||
@@ -789,24 +986,63 @@ func _on_pick(node: Node3D) -> void:
|
||||
if _reserved_mode == ReservedMode.USE_SKILL and _dash_active:
|
||||
_vid_reserved = vid
|
||||
return
|
||||
# NPC / 石头 / warp -> 交互,不是攻击目标
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if _entity_kind(e) in [1, 3, 4]:
|
||||
client.click_npc(vid)
|
||||
if e.is_empty():
|
||||
return
|
||||
|
||||
var kind := _entity_kind(e)
|
||||
var tnode: Node3D = net_world.node_for(vid) if (net_world and net_world.node_for(vid)) else node
|
||||
|
||||
# 不可攻击目标(NPC / warp / 同阵营PC等) -> 交互或目标框
|
||||
if not _is_attackable(e):
|
||||
_auto_attack_vid = 0
|
||||
if kind in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
|
||||
if not _is_clickable_distance(tnode, e):
|
||||
# 40250 __OnClickActor: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近 NPC)
|
||||
_reserve_click_actor(vid)
|
||||
if pc and pc.has_method("walk_to") and tnode:
|
||||
pc.walk_to(_world_pos(tnode))
|
||||
return
|
||||
# 已在交互距离内(<= 5m):停止移动、朝向 NPC、发送点击包(40250 __SendClickActorPacket + NEW_Stop)
|
||||
_clear_reserved()
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
if pc and pc.player and tnode:
|
||||
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
|
||||
if to.length() > 0.01:
|
||||
pc.player.rotation.y = atan2(to.x, to.z)
|
||||
_send_click_actor_packet(vid)
|
||||
return
|
||||
_clear_reserved()
|
||||
_change_target_to_picked_instance(vid)
|
||||
if kind == EntityRules.KIND_PC:
|
||||
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
|
||||
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
|
||||
var nm := _entity_name(e)
|
||||
if nm != "":
|
||||
whisper_requested.emit(nm)
|
||||
return
|
||||
|
||||
# --- 40250 __OnPressActor: 可攻击实体(怪物、陨石、敌对PC等)---
|
||||
_attack_cd = 0.0
|
||||
_change_target_to_picked_instance(vid)
|
||||
# game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL
|
||||
# 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building
|
||||
# (:814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC
|
||||
# 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别,
|
||||
# 不满足 PC 分支时不会误触发悄悄话。
|
||||
if _entity_kind(e) == EntityRules.KIND_PC:
|
||||
var same_empire := EntityRules._is_same_empire(_main_entity(), e)
|
||||
if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
|
||||
var nm := _entity_name(e)
|
||||
if nm != "":
|
||||
whisper_requested.emit(nm)
|
||||
# 40250 __SetAutoAttackTargetActorID: 点击可攻击实体开启持续连击/追击
|
||||
_auto_attack_vid = vid
|
||||
_clear_reserved()
|
||||
|
||||
if not _can_attack():
|
||||
return
|
||||
|
||||
if not _is_clickable_distance(tnode, e):
|
||||
# 40250: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近)
|
||||
_reserve_click_actor(vid)
|
||||
if pc and pc.has_method("walk_to") and tnode:
|
||||
pc.walk_to(_world_pos(tnode))
|
||||
return
|
||||
|
||||
# 已在攻击距离内:立即朝向目标出刀
|
||||
if tnode != null:
|
||||
_do_attack_swing(tnode, e)
|
||||
|
||||
# poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
|
||||
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
|
||||
@@ -839,10 +1075,15 @@ func _on_target_info(vid: int, hp_pct: int) -> void:
|
||||
if TargetBoard.needs_reset(vid, _target_vid):
|
||||
_target_vid = vid
|
||||
target_changed.emit(vid)
|
||||
var nm := _entity_name(e)
|
||||
var hp_clamped := TargetBoard.clamp_hp_pct(hp_pct)
|
||||
if hud and hud.has_method("set_target"):
|
||||
var nm := _entity_name(e)
|
||||
hud.set_target(nm if nm != "" else "目标",
|
||||
TargetBoard.clamp_hp_pct(hp_pct))
|
||||
hud.set_target(nm if nm != "" else "目标", hp_clamped)
|
||||
if target_ui and target_ui.has_method("open"):
|
||||
var lvl := int(e.get("level", -1))
|
||||
var grade := int(e.get("grade", -1))
|
||||
target_ui.open(vid, nm if nm != "" else "目标", lvl, grade, is_pc)
|
||||
target_ui.set_hp(hp_clamped)
|
||||
TargetBoard.Action.NONE:
|
||||
pass
|
||||
|
||||
@@ -858,23 +1099,27 @@ func set_lcontrol_down(down: bool) -> void:
|
||||
func _clear_target() -> void:
|
||||
if _target_vid == 0:
|
||||
return
|
||||
_auto_attack_vid = 0
|
||||
_target_vid = 0
|
||||
_clear_reserved()
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
target_changed.emit(0)
|
||||
if hud and hud.has_method("clear_target"):
|
||||
hud.clear_target()
|
||||
if target_ui and target_ui.has_method("close"):
|
||||
target_ui.close()
|
||||
|
||||
# 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。
|
||||
# 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING),
|
||||
# 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。
|
||||
# 受击:40250 RecvDamageInfoPacket(PythonNetworkStreamPhaseGame.cpp:2415)
|
||||
# 仅展示伤害飘字和受击动作;普通伤害不晃动摄像机、不锁死移动控制(非 DAMAGE_FLYING 击飞)。
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
if vid != _main_vid:
|
||||
return
|
||||
var crit := (flag & DAMAGE_CRITICAL) != 0
|
||||
if camera and camera.has_method("shake"):
|
||||
camera.shake(0.08 if crit else 0.04, 10.0)
|
||||
if (flag & DAMAGE_DODGE) != 0:
|
||||
return
|
||||
_knock_down = true
|
||||
if camera and camera.has_method("shake"):
|
||||
camera.shake(0.2, 0.5)
|
||||
if player_view and player_view.has_method("set_anim_state"):
|
||||
player_view.set_anim_state("damage")
|
||||
|
||||
@@ -936,6 +1181,46 @@ func _can_use_skill() -> bool:
|
||||
func is_stunned() -> bool:
|
||||
return not _can_process_network_state()
|
||||
|
||||
# 40250 CInstanceBase::NEW_GetFrontInstance (InstanceBaseBattle.cpp:145)
|
||||
# 在正前方扇面检索最近的可攻击存活目标(最大射程默认 2000cm / 20m)
|
||||
func get_front_instance(f_distance_cm: float = 2000.0) -> int:
|
||||
if pc == null or pc.player == null or client == null:
|
||||
return 0
|
||||
var my_pos: Vector3 = _world_pos(pc.player)
|
||||
var my_yaw: float = pc.player.rotation.y
|
||||
# Godot 空间中 yaw=0 面向 -Z,朝向向量为 (-sin(yaw), 0, -cos(yaw))
|
||||
var fwd := Vector3(-sin(my_yaw), 0, -cos(my_yaw)).normalized()
|
||||
|
||||
var best_vid := 0
|
||||
var min_dist_m := 1e9
|
||||
var max_dist_m := f_distance_cm / CM
|
||||
|
||||
for e in client.get_entities():
|
||||
var vid := int(e.get("vid", 0))
|
||||
if vid == 0 or vid == _main_vid:
|
||||
continue
|
||||
if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)):
|
||||
continue
|
||||
var tnode: Node3D = net_world.node_for(vid) if net_world else null
|
||||
var pos := Vector3.ZERO
|
||||
if tnode != null:
|
||||
pos = _world_pos(tnode)
|
||||
else:
|
||||
pos = MapCoord.to_world(e.get("pos", my_pos))
|
||||
var to_target := pos - my_pos
|
||||
to_target.y = 0.0
|
||||
var d := to_target.length()
|
||||
if d > max_dist_m or d < 0.01:
|
||||
continue
|
||||
var to_dir := to_target.normalized()
|
||||
var dot := fwd.dot(to_dir)
|
||||
# 40250 扇形检测(HALF_FAN_ROT 10°~50°,约 60°~70° 锥角内)
|
||||
if dot > 0.35:
|
||||
if d < min_dist_m:
|
||||
min_dist_m = d
|
||||
best_vid = vid
|
||||
return best_vid
|
||||
|
||||
# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。
|
||||
# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* /
|
||||
# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。
|
||||
@@ -973,6 +1258,7 @@ func skill_context() -> Dictionary:
|
||||
"cur_sp": cur_sp,
|
||||
"bow_distance": int(_bow_distance_bonus),
|
||||
"target_distance": Callable(self, "skill_target_distance_cm"),
|
||||
"get_front_instance": Callable(self, "get_front_instance"),
|
||||
}
|
||||
|
||||
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE −200)——施法射程判定与
|
||||
@@ -983,7 +1269,7 @@ func skill_target_distance_cm(vid: int) -> float:
|
||||
var tnode: Node3D = net_world.node_for(vid) if net_world else null
|
||||
if tnode == null:
|
||||
return -1.0
|
||||
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
|
||||
var dist_cm: float = _world_pos(pc.player).distance_to(_world_pos(tnode)) * CM
|
||||
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
|
||||
dist_cm -= HUGE_RACE_DIST_FIX_CM
|
||||
return dist_cm
|
||||
@@ -992,14 +1278,54 @@ func skill_target_distance_cm(vid: int) -> float:
|
||||
func is_use_skill_reserved(slot: int) -> bool:
|
||||
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
|
||||
|
||||
# 40250 CActorInstance::isLock()(ActorInstanceMotion.cpp:472)
|
||||
# 挥击动作(普攻/连击/技能/钓鱼)期间,严格锁定移动(PythonPlayerInput.cpp:457)。
|
||||
# 连击段号仅用于连击输入窗口判断,动作播放结束自动切回 wait 状态并解锁。
|
||||
func is_lock() -> bool:
|
||||
if _using_skill:
|
||||
# 40250 ActorInstanceMotion.cpp:541: if (m_pkCurRaceMotionData->IsCancelEnableSkill()) return FALSE;
|
||||
if _skill_cancel_enable:
|
||||
return false
|
||||
if _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0 and (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
|
||||
_using_skill = false
|
||||
_charge_skill = false
|
||||
_skill_cancel_enable = false
|
||||
if player_view and player_view.has_method("reset_skill_motion"):
|
||||
player_view.reset_skill_motion()
|
||||
elif not _charge_skill:
|
||||
return true
|
||||
if combo_motion_mode == MOTION_MODE_FISHING:
|
||||
return true
|
||||
if _is_attacking:
|
||||
var md := _motion_data()
|
||||
var dur := float(md.get("duration", attack_period))
|
||||
if dur <= 0.0:
|
||||
dur = attack_period
|
||||
if _attacking_elapsed() < dur:
|
||||
return true
|
||||
else:
|
||||
_is_attacking = false
|
||||
_combo_index = 0
|
||||
return false
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
_advance_local_time(dt)
|
||||
if _using_skill and _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0:
|
||||
if (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
|
||||
_using_skill = false
|
||||
_charge_skill = false
|
||||
_skill_cancel_enable = false
|
||||
if not _can_process_network_state():
|
||||
_using_skill = false
|
||||
_charge_skill = false
|
||||
_skill_cancel_enable = false
|
||||
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
|
||||
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
|
||||
and not player_view.is_in_hit_reaction():
|
||||
_knock_down = false
|
||||
if pc:
|
||||
pc.set("frozen", not _can_process_network_state())
|
||||
pc.set("locked", is_lock())
|
||||
if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null:
|
||||
_clear_reserved()
|
||||
_auto_attack_vid = 0
|
||||
@@ -1014,32 +1340,39 @@ func _process(dt: float) -> void:
|
||||
_tick_on_waiting()
|
||||
_flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION
|
||||
|
||||
# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207)——每帧检查 auto-attack 目标
|
||||
# 是否仍可打,然后 __ReserveClickActor()。本工程用「按住攻击键 + 有目标」当 __OnPressActor(isAuto)。
|
||||
# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207)
|
||||
# 以及 NEW_Attack()(PythonPlayerInput.cpp:550,空格键自由挥击)
|
||||
func _update_auto_attack() -> void:
|
||||
if not _attack_key_down or _target_vid == 0:
|
||||
_auto_attack_vid = 0
|
||||
if _attack_key_down:
|
||||
if _auto_attack_vid == 0 and _target_vid != 0:
|
||||
_auto_attack_vid = _target_vid
|
||||
elif _auto_attack_vid == 0 and _target_vid == 0:
|
||||
# 40250 NEW_Attack(): 无目标按下空格键时朝当前面向自由挥剑
|
||||
# 连击 / 普攻节奏由动作数据 ComboInputData / 90% duration 自行 gate,不使用外层 cd 吞键
|
||||
if _can_attack():
|
||||
_do_attack_swing(null, {})
|
||||
return
|
||||
|
||||
if _auto_attack_vid == 0:
|
||||
return
|
||||
_auto_attack_vid = _target_vid
|
||||
|
||||
if not _can_process_network_state(): # ≈ !NEW_GetMainActorPtr() / __IsSyncing()
|
||||
return
|
||||
if _dash_active or (_using_skill and _charge_skill): # __IsUsingChargeSkill()
|
||||
return
|
||||
var v: Dictionary = client.get_entity(_auto_attack_vid)
|
||||
if v.is_empty():
|
||||
_clear_auto_attack()
|
||||
return
|
||||
if bool(v.get("dead", false)):
|
||||
if v.is_empty() or bool(v.get("dead", false)):
|
||||
_clear_auto_attack()
|
||||
return
|
||||
if _is_main_mounted() and not _can_attack_horse_level():
|
||||
_clear_auto_attack()
|
||||
return
|
||||
if not _is_attackable(v):
|
||||
_clear_auto_attack()
|
||||
return
|
||||
if bool(_main_entity().get("sleeping", false)):
|
||||
return
|
||||
# 参考端每帧无条件 __ReserveClickActor(攻击节奏由动作数据在 swing 处 gate,不在这里)。
|
||||
# 对齐 40250 CPythonPlayer::__Update_AutoAttack():只要目标有效存活且可攻击,每帧 __ReserveClickActor
|
||||
if _reserved_mode == ReservedMode.NONE and _can_attack():
|
||||
_reserve_click_actor(_auto_attack_vid)
|
||||
|
||||
@@ -1072,7 +1405,6 @@ func _clear_reserved() -> void:
|
||||
|
||||
func _clear_auto_attack() -> void:
|
||||
_auto_attack_vid = 0
|
||||
_clear_target()
|
||||
|
||||
func _emit_cannot(code: String) -> void:
|
||||
_last_cannot = code
|
||||
@@ -1168,15 +1500,23 @@ func _reserve_process_click_actor() -> void:
|
||||
_clear_reserved()
|
||||
return
|
||||
# 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection)
|
||||
var dist: float = pc.player.global_position.distance_to(tnode.global_position)
|
||||
if dist > _clickable_distance(rv):
|
||||
if not _is_clickable_distance(tnode, rv):
|
||||
if pc.has_method("walk_to"):
|
||||
pc.walk_to(tnode.global_position)
|
||||
pc.walk_to(_world_pos(tnode))
|
||||
return
|
||||
# 3 不可攻击 -> 停 + 发点击包(NPC 对话)+ 清预约
|
||||
elif pc and pc.has_method("is_going") and pc.is_going():
|
||||
if pc.has_method("stop"):
|
||||
pc.stop()
|
||||
# 3 不可攻击 -> 停 + 朝向 NPC + 发点击包(NPC 对话)+ 清预约
|
||||
if not _is_attackable(rv):
|
||||
pc.set("frozen", false)
|
||||
client.click_npc(_vid_reserved)
|
||||
if pc and pc.has_method("stop"):
|
||||
pc.stop()
|
||||
if pc and pc.player and tnode:
|
||||
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
|
||||
if to.length() > 0.01:
|
||||
pc.player.rotation.y = atan2(to.x, to.z)
|
||||
_send_click_actor_packet(_vid_reserved)
|
||||
_clear_reserved()
|
||||
return
|
||||
# 4 目标已死
|
||||
@@ -1208,9 +1548,8 @@ func _reserve_process_click_actor() -> void:
|
||||
target_changed.emit(_vid_reserved)
|
||||
_refresh_target_hud()
|
||||
# 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。
|
||||
# 连击 / 普攻节奏由动作数据 gate:cd 未到就本帧不 swing,仍清预约、下帧再来。
|
||||
if _attack_cd <= 0.0:
|
||||
_do_attack_swing(tnode, rv)
|
||||
# 对齐 40250 PythonPlayerInput.cpp:947:直接调用攻击,由动作状态机判定连击推进与输入窗
|
||||
_do_attack_swing(tnode, rv)
|
||||
_clear_reserved()
|
||||
|
||||
# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case)------------
|
||||
@@ -1284,6 +1623,10 @@ func _invoke_reserved_skill() -> bool:
|
||||
# NEW_GetGroundItemPosition(PythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。
|
||||
# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。
|
||||
func _ground_item_world_pos(iid: int):
|
||||
if pc and "ground_items" in pc and pc.ground_items != null and pc.ground_items.has_method("get_item_world_pos"):
|
||||
var p = pc.ground_items.get_item_world_pos(iid)
|
||||
if p != null:
|
||||
return p
|
||||
if client == null or not client.has_method("get_ground_items"):
|
||||
return null
|
||||
for gi in client.get_ground_items():
|
||||
@@ -1326,9 +1669,12 @@ func pick_ground_item(iid: int) -> void:
|
||||
var item_pos = _ground_item_world_pos(iid)
|
||||
if item_pos == null:
|
||||
return
|
||||
if pc.player.global_position.distance_to(item_pos) * CM < CLICK_DIST_RESOURCE_CM:
|
||||
if pc.player.global_position.distance_to(item_pos) * CM <= CLICK_ITEM_PICKUP_CM:
|
||||
_send_state(FUNC_WAIT, 0, pc.player.position)
|
||||
if client.has_method("pickup_item"):
|
||||
var gi = pc.ground_items if (pc and "ground_items" in pc) else null
|
||||
if gi != null and gi.has_method("try_pickup_vid"):
|
||||
gi.try_pickup_vid(iid)
|
||||
elif client.has_method("pickup_item"):
|
||||
client.pickup_item(iid)
|
||||
pc.set("frozen", false)
|
||||
else:
|
||||
@@ -1350,7 +1696,13 @@ func _is_huge_race(race: int) -> bool:
|
||||
# (对齐 __OnClickActor 的拆分)。
|
||||
func _change_target_to_picked_instance(vid: int) -> bool:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty() or bool(e.get("dead", false)) or _entity_kind(e) not in [0, 2]:
|
||||
var kind := _entity_kind(e)
|
||||
if e.is_empty() or bool(e.get("dead", false)) or kind not in [
|
||||
EntityRules.KIND_PC,
|
||||
EntityRules.KIND_MONSTER,
|
||||
EntityRules.KIND_STONE,
|
||||
EntityRules.KIND_WOODEN_DOOR,
|
||||
]:
|
||||
_target_vid = 0
|
||||
client.set_target(0)
|
||||
target_changed.emit(0)
|
||||
@@ -1485,14 +1837,23 @@ func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape:
|
||||
# NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand
|
||||
# (ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。
|
||||
func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void:
|
||||
var to: Vector3 = tnode.global_position - pc.player.global_position
|
||||
if to.length() > 0.01:
|
||||
pc.player.rotation.y = atan2(to.x, to.z)
|
||||
if tnode != null and is_instance_valid(tnode) and pc and pc.player:
|
||||
var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
|
||||
to.y = 0.0
|
||||
if to.length() > 0.01:
|
||||
pc.player.rotation.y = atan2(to.x, to.z)
|
||||
_was_moving = false
|
||||
|
||||
var vec := _combo_vec()
|
||||
if vec.is_empty():
|
||||
# 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 退化为单段普攻。
|
||||
# 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 对齐 40250 __CanInputNormalAttackCommand
|
||||
if _is_attacking and _attack_cd > 0.0:
|
||||
var md := _motion_data()
|
||||
var dur := float(md.get("duration", attack_period))
|
||||
if dur <= 0.0:
|
||||
dur = attack_period
|
||||
if _attacking_elapsed() <= dur * 0.9:
|
||||
return
|
||||
_emit_swing(NAME_NORMAL_ATTACK, false)
|
||||
return
|
||||
|
||||
@@ -1543,6 +1904,7 @@ func _clear_combo() -> void:
|
||||
_combo_index = 0
|
||||
_is_pre_input = false
|
||||
_is_next_pre_input = false
|
||||
_is_attacking = false
|
||||
_hit_windows = []
|
||||
_hit_dedup.clear()
|
||||
|
||||
@@ -1604,7 +1966,13 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
|
||||
if _hit_windows.is_empty():
|
||||
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
|
||||
# (PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
|
||||
client.attack(_swing_skill, _vid_reserved)
|
||||
var target := _target_vid if _target_vid != 0 else _vid_reserved
|
||||
if target == 0 and _auto_attack_vid != 0:
|
||||
target = _auto_attack_vid
|
||||
if target == 0:
|
||||
target = get_front_instance(250.0)
|
||||
if target != 0:
|
||||
client.attack(_swing_skill, target)
|
||||
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
|
||||
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
|
||||
attack_period = _current_attack_period()
|
||||
@@ -1640,6 +2008,7 @@ func _begin_hit_windows() -> void:
|
||||
_hit_external_force = float(md.get("external_force", 0.0))
|
||||
_hit_dedup.clear()
|
||||
_motion_start_t = _local_time
|
||||
_is_attacking = true
|
||||
|
||||
# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess ->
|
||||
# __NormalAttackProcess(ActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
|
||||
@@ -1652,18 +2021,19 @@ func _attack_process(dt: float) -> void:
|
||||
return
|
||||
_atk_frame += 1
|
||||
var t := _attacking_elapsed()
|
||||
var eff_dt := dt * _atk_speed_factor
|
||||
# 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking)
|
||||
var latest_end := 0.0
|
||||
for w in _hit_windows:
|
||||
latest_end = maxf(latest_end, float(w.get("end_time", 0.0)))
|
||||
if t - dt > latest_end:
|
||||
if t - eff_dt > latest_end:
|
||||
_hit_windows = []
|
||||
_hit_dedup.clear()
|
||||
return
|
||||
# CheckAttacking:自己在安全区直接不判定
|
||||
if bool(_main_entity().get("in_safe", false)):
|
||||
return
|
||||
var a_pos := _actor_cm(pc.player.global_position)
|
||||
var a_pos := _actor_cm(_world_pos(pc.player))
|
||||
var yaw := _player_yaw()
|
||||
for e in client.get_entities():
|
||||
var vid := int(e.get("vid", 0))
|
||||
@@ -1694,7 +2064,7 @@ static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
|
||||
|
||||
func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float,
|
||||
t: float, dt: float) -> void:
|
||||
var v_pos := _actor_cm(vnode.global_position)
|
||||
var v_pos := _actor_cm(_world_pos(vnode))
|
||||
var huge := HitCollision.is_huge_race(int(e.get("race", 0)))
|
||||
# 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留)
|
||||
var dx := v_pos.x - a_pos.x
|
||||
@@ -1704,36 +2074,56 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
|
||||
if dist2 >= lim * lim:
|
||||
return
|
||||
var spheres := _defending_spheres(vid, vnode, v_pos)
|
||||
var eff_dt := dt * _atk_speed_factor
|
||||
for wi in _hit_windows.size():
|
||||
var w: Dictionary = _hit_windows[wi]
|
||||
if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
|
||||
if t < float(w.get("start_time", 0.0)) or (t - eff_dt) > float(w.get("end_time", 0.0)):
|
||||
continue
|
||||
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
|
||||
var m: Dictionary = _hit_dedup.get(wi, {})
|
||||
if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time):
|
||||
continue
|
||||
var samples: Variant = w.get("samples", [])
|
||||
if not (samples is Array):
|
||||
continue
|
||||
for smp in samples:
|
||||
var st := float(smp.get("time", 0.0))
|
||||
if st < t - dt or st > t:
|
||||
continue
|
||||
var p: Vector3 = smp.get("pos", Vector3.ZERO)
|
||||
var lp: Vector3 = smp.get("last_pos", p)
|
||||
var d_pos := _rot_cm(p, yaw) + a_pos
|
||||
var d_last := _rot_cm(lp, yaw) + a_pos
|
||||
for sp in spheres:
|
||||
if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
|
||||
sp["last"], sp["pos"], float(sp["radius"])):
|
||||
var hit_detected := false
|
||||
if samples is Array and not (samples as Array).is_empty():
|
||||
for smp in samples:
|
||||
var st := float(smp.get("time", 0.0))
|
||||
if st < t - eff_dt or st > t:
|
||||
continue
|
||||
if not _register_hit(wi, vid):
|
||||
return
|
||||
var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5
|
||||
_process_attack_success(e, vid, vnode, hit_pos, huge)
|
||||
var p: Vector3 = smp.get("pos", Vector3.ZERO)
|
||||
var lp: Vector3 = smp.get("last_pos", p)
|
||||
var d_pos := _rot_cm(p, yaw) + a_pos
|
||||
var d_last := _rot_cm(lp, yaw) + a_pos
|
||||
for sp in spheres:
|
||||
if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
|
||||
sp["last"], sp["pos"], float(sp["radius"])):
|
||||
hit_detected = true
|
||||
break
|
||||
if hit_detected:
|
||||
break
|
||||
else:
|
||||
# 采样点为空时的近战朝向判定(正前方扇面)
|
||||
var fwd := Vector3(sin(yaw), 0, -cos(yaw))
|
||||
var to_mob := Vector3(dx, 0, dz).normalized()
|
||||
if fwd.dot(to_mob) > 0.2:
|
||||
hit_detected = true
|
||||
|
||||
# 近战兜底:如果在 2 米内且正对目标(锥角 ~60 度),在命中窗口内判定命中
|
||||
if not hit_detected and dist2 < 200.0 * 200.0:
|
||||
var fwd := Vector3(sin(yaw), 0, -cos(yaw))
|
||||
var to_mob := Vector3(dx, 0, dz).normalized()
|
||||
if fwd.dot(to_mob) > 0.3:
|
||||
hit_detected = true
|
||||
|
||||
if hit_detected:
|
||||
if not _register_hit(wi, vid):
|
||||
return
|
||||
var hit_pos: Vector3 = (a_pos + (spheres[0]["pos"] as Vector3)) * 0.5 if huge and not spheres.is_empty() else (a_pos + v_pos) * 0.5
|
||||
_process_attack_success(e, vid, vnode, hit_pos, huge)
|
||||
return
|
||||
|
||||
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
|
||||
# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorld,ActorInstanceCollisionDetection.cpp:66);
|
||||
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
|
||||
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
|
||||
var raw: Array = []
|
||||
@@ -1742,9 +2132,26 @@ func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
|
||||
if raw.is_empty():
|
||||
raw = [HIT_FALLBACK_SPHERE]
|
||||
var vyaw := vnode.rotation.y
|
||||
var anim: Node = vnode.get("anim") if vnode else null
|
||||
var model: Node3D = vnode.get("model") as Node3D if vnode else null
|
||||
var cur: Array = []
|
||||
for sp in raw:
|
||||
cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
|
||||
var spos: Vector3 = sp.get("pos", Vector3.ZERO)
|
||||
var bone: String = sp.get("bone", "")
|
||||
var sphere_pos_cm := Vector3.ZERO
|
||||
var has_bone_transform := false
|
||||
if bone != "" and anim != null and anim.has_method("get_effect_bone_pose") and model != null:
|
||||
var pose: Dictionary = anim.get_effect_bone_pose(bone)
|
||||
if pose.has("transform"):
|
||||
var bone_xform: Transform3D = pose["transform"]
|
||||
# 40250 ActorInstanceCollisionDetection.cpp:40: matDefending = matBone * matWorld, D3DXVec3TransformCoord
|
||||
# spos in .msm is in cm, bone_xform / model transform is in meters
|
||||
var world_m: Vector3 = model.global_transform * (bone_xform * (spos / 100.0))
|
||||
sphere_pos_cm = _actor_cm(world_m)
|
||||
has_bone_transform = true
|
||||
if not has_bone_transform:
|
||||
sphere_pos_cm = _rot_cm(spos, vyaw) + v_pos
|
||||
cur.append(sphere_pos_cm)
|
||||
var prev: Dictionary = _def_last.get(vid, {})
|
||||
var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size()
|
||||
var out: Array = []
|
||||
@@ -1807,10 +2214,15 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
|
||||
if not building:
|
||||
var stunned := bool(e.get("stunned", false))
|
||||
var scalar := cos(_player_yaw() - vnode.rotation.y)
|
||||
var resist_fallen := _is_resist_fallen(e)
|
||||
# 40250 CActorInstance::__Shake(100)
|
||||
if vnode.has_method("shake"):
|
||||
vnode.shake()
|
||||
if kind == EntityRules.KIND_STONE or door:
|
||||
if vnode.has_method("hit_stone"):
|
||||
vnode.hit_stone(stunned)
|
||||
elif _hit_type == HIT_TYPE_GOOD:
|
||||
elif _hit_type == HIT_TYPE_GOOD or resist_fallen:
|
||||
# 40250 ActorInstanceBattle.cpp:687: if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || rVictim.IsResistFallen())
|
||||
if vnode.has_method("hit_good"):
|
||||
vnode.hit_good(scalar, stunned)
|
||||
elif _hit_type == HIT_TYPE_GREAT:
|
||||
@@ -1818,12 +2230,28 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
|
||||
vnode.hit_greate(scalar, stunned)
|
||||
_on_hit(vid, huge)
|
||||
|
||||
const AFFECT_CHEONGEUN_BIT := 1 << 16
|
||||
const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33
|
||||
|
||||
# 40250 CActorInstance::IsResistFallen (ActorInstance.cpp:405 / InstanceBaseEffect.cpp:891)
|
||||
# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN)、Boss 或巨型怪物拥有霸体抗击退状态
|
||||
func _is_resist_fallen(e: Dictionary) -> bool:
|
||||
var flags: int = int(e.get("affect_flags", 0))
|
||||
if (flags & AFFECT_CHEONGEUN_BIT) != 0 or (flags & AFFECT_FALLEN_CHEONGEUN_BIT) != 0:
|
||||
return true
|
||||
if bool(e.get("resist_fallen", false)):
|
||||
return true
|
||||
var race: int = int(e.get("race", 0))
|
||||
if HitCollision.is_huge_race(race) or _is_huge_race(race):
|
||||
return true
|
||||
return false
|
||||
|
||||
# CActorInstance::__CanPushDestActor(ActorInstanceBattle.cpp)
|
||||
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
|
||||
var kind := _entity_kind(e)
|
||||
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
|
||||
return false
|
||||
if huge:
|
||||
if huge or _is_resist_fallen(e):
|
||||
return false
|
||||
if bool(e.get("stunned", false)):
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user