Files
mtgodot-poc/project/player_controller.gd
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00

475 lines
19 KiB
GDScript
Raw Permalink 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.
# PlayerController —— Metin2 风移动 + 选目标(客户端预测,无服务端纠正)。
# 左键点地面 -> 走/跑过去(沿途查 attr.atr 可行走,遇阻停)
# 左键点实体 -> 选中(emit target_selected
# WASD/方向键 -> 直接移动(相对相机),覆盖点地
# 触屏(F7):单指轻点 = 点地/点选(快速抬起且位移小);单指拖拽归相机环绕,
# 双指手势时不产生点地。
# 需要:player(Node3D)、camera(GameCamera)、world(Metin2World)。
# 每帧 emit anim_state("wait"|"walk"|"run") 供上层切动画。
extends Node
signal anim_state(state: String)
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
# game.py OnRenderwndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。
# 悬停命中的实体 vid 变化时 emit(无命中 / 鼠标压 UI / 悬停地面物品 -> emit 0)。见 pick_show.gd。
signal hover_entity_changed(vid: int)
var player: Node3D
var camera: Camera3D
var world: Node
var cursor_manager: Node
var ui_manager: Node
var ground_items: Node
var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口
var cancel_fishing_ground := Callable() # __OnPressGround 专用入口
var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等)
var _hover_vid := 0 # 上一帧悬停命中的实体 vidhover_entity_changed 去抖)
const SPEED_WALK := 8.0
const SPEED_RUN := 18.0
const RUN_HOLD_KEY := KEY_SHIFT
const ARRIVE_EPS := 0.4
const PICK_RADIUS := 1.4 # 点选命中半径(米)
# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由
# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1
const MOVABLE_GROUND_DISTANCE_M := 1.0
# __ReserveClickGround 设的 m_fReservedDelayTimePythonPlayerInput.cpp:834)。
const RESERVED_GROUND_DELAY := 0.1
# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancingInstanceBaseBattle.cpp:469 +
# CActorInstance::TestActorCollisionActorInstanceCollisionDetection.cpp)。参考用 race
# 碰撞球,POC 无骨骼碰撞数据 → 固定 body 半径近似;自身 + 对方两球相加为判定阈值。§3.1
const ACTOR_BODY_RADIUS_M := 0.55
# TestActorCollision 的距离门:LengthSq(victim - self) > 800 cm² 直接不检。
const ACTOR_COLLIDE_MAX_DIST_M := 8.0
# c_fDefaultRotationSpeed 1200 / c_fDefaultHorseRotationSpeed 300InstanceBase.cpp:17-18):
# MountHorse() 把转向速度降到 300,下马恢复 1200。
const ROT_SPEED_DEFAULT_DEG := 1200.0
const ROT_SPEED_HORSE_DEG := 300.0
var force_run := false # 脚本化 / 自动跑
var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设)
var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect)
var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移
var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100
var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切换(§3.1 / §6.4
# CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞
# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。
var skip_actor_collision := false
var _last_wasd := Vector2.ZERO
var _mobile_axis := Vector2.ZERO
var _mobile_active := false
var _mobile_ui_touches := {}
# --- §3.1 Src/Dst 移动模型(对齐 InstanceBaseMovement.cpp NEW_Goto / NEW_MoveToDirection---
# _is_going = m_isGoing(点地走向 Dst);方向键移动 _is_going 保持 false。
var _src_pos := Vector3.ZERO
var _dst_pos := Vector3.ZERO
var _is_going := false
var _dst_rot := 0.0
var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点
var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效
# CInstanceBase::__IsSyncing()Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。
func is_going() -> bool:
return _is_going
# NEW_Goto / NEW_MoveToDirection 的三道前置门:syncing / moving-skill(只转向) / lock。
func _can_translate() -> bool:
return not frozen and not locked and not moving_skill
const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点
const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点
var _run := false
var _touch_count := 0
var _multi_gesture := false # 曾有 ≥2 指同时按下 -> 本轮不产生点地
var _tap_index := -1
var _tap_press_pos := Vector2.ZERO
var _tap_press_t := 0.0
func set_server_speed(moving_speed: int) -> void:
# Metin2 POINT_MOV_SPEED uses 100 as the normal baseline. Keep malformed or
# pre-spawn zero values from freezing local prediction.
if moving_speed <= 0:
return
server_speed_scale = clampf(float(moving_speed) / 100.0, 0.25, 3.0)
func set_input_surfaces(cursor: Node, ui: Node = null, ground: Node = null,
cancel_fishing_cb := Callable(), ground_cancel_fishing_cb := Callable()) -> void:
cursor_manager = cursor
ui_manager = ui
ground_items = ground
cancel_fishing_input = cancel_fishing_cb
cancel_fishing_ground = ground_cancel_fishing_cb
# MobileUiRoot feeds a normalized left-thumb axis directly. Keeping it on the
# controller (instead of synthesizing keyboard events) preserves the existing
# camera-relative movement, collision and animation path for both platforms.
func set_mobile_axis(axis: Vector2) -> void:
_mobile_axis = axis.limit_length(1.0)
func clear_mobile_input() -> void:
_mobile_axis = Vector2.ZERO
_mobile_active = false
_mobile_ui_touches.clear()
if _touch_count == 0:
_multi_gesture = false
## MobileInputOverlay calls this before/after a control consumes its touch.
## World touch handling must know about that finger even though the GUI event
## never reaches _unhandled_input; otherwise a joystick finger plus a world
## finger can look like a single tap-to-move gesture.
func set_mobile_ui_touch(index: int, pressed: bool) -> void:
if pressed:
_mobile_ui_touches[index] = true
if _touch_count > 0:
_multi_gesture = true
_tap_index = -1
else:
_mobile_ui_touches.erase(index)
if _touch_count == 0 and _mobile_ui_touches.is_empty():
_multi_gesture = false
# 程序化下发一个点地目标(脚本化截图 / AI)——等价 NEW_MoveToDestPixelPositionDirection。
func walk_to(world_pos: Vector3) -> void:
_goto(Vector3(world_pos.x, 0.0, world_pos.z))
# CInstanceBase::NEW_GotoInstanceBaseMovement.cpp:251):三门通过后设 Src/Dst + m_isGoing。
# 被门挡下(syncing / lock)时按 __ReserveClickGround 预约,delay 后重试。
func _goto(dst_flat: Vector3) -> bool:
if player == null:
return false
var flat := Vector3(dst_flat.x - player.position.x, 0.0, dst_flat.z - player.position.z)
# __IsMovableGroundDistance:离脚下太近不动
if flat.length() < MOVABLE_GROUND_DISTANCE_M:
return true
if not _can_translate():
_reserved_ground = dst_flat
_reserved_delay_time = RESERVED_GROUND_DELAY
return false
_src_pos = player.position
_dst_pos = Vector3(dst_flat.x, 0.0, dst_flat.z)
_dst_rot = atan2(flat.x, flat.z)
_is_going = true
return true
func _unhandled_input(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_click(e.position)
elif e is InputEventScreenTouch:
_on_touch(e)
elif e is InputEventScreenDrag:
# 触点一旦拖出去,就不再是「轻点」,交给相机环绕
if e.index == _tap_index and e.position.distance_to(_tap_press_pos) > _TAP_TRAVEL_MAX:
_tap_index = -1
func _on_touch(e: InputEventScreenTouch) -> void:
if e.pressed:
_touch_count += 1
if _touch_count >= 2 or not _mobile_ui_touches.is_empty():
_multi_gesture = true
_tap_index = -1
elif not _multi_gesture:
_tap_index = e.index
_tap_press_pos = e.position
_tap_press_t = Time.get_ticks_msec() / 1000.0
else:
_touch_count = max(0, _touch_count - 1)
if e.index == _tap_index and not _multi_gesture:
var travel := e.position.distance_to(_tap_press_pos)
var held := Time.get_ticks_msec() / 1000.0 - _tap_press_t
if travel <= _TAP_TRAVEL_MAX and held <= _TAP_TIME_MAX:
_on_click(e.position)
_tap_index = -1
if _touch_count == 0 and _mobile_ui_touches.is_empty():
_multi_gesture = false
func _on_click(screen_pos: Vector2) -> void:
if camera == null:
return
var from := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
# 1) 实体点选:射线 vs 每个 pickable 的网格世界包围盒(slab 相交,逐实体精确,
# 大怪 / 小怪都不再被固定 1.4m 半径误判)。无网格的回退到胶囊近似。
var best: Node3D = null
var best_t := 1e20
for n in pickables:
if not is_instance_valid(n):
continue
var t := _ray_pick_t(from, dir, n)
if t >= 0.0 and t < best_t:
best_t = t
best = n
if best:
target_selected.emit(best)
return
# 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)→ NEW_Goto
var hit: Variant = _ray_ground(from, dir)
if hit != null:
if cancel_fishing_ground.is_valid() and bool(cancel_fishing_ground.call()):
return
if cancel_fishing_input.is_valid() and bool(cancel_fishing_input.call()):
return
_goto(hit)
# 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。
func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float:
var box := AABB()
var have := false
for m in node.find_children("*", "MeshInstance3D", true, false):
var mi := m as MeshInstance3D
if mi.mesh == null:
continue
var wb: AABB = mi.get_global_transform() * mi.get_aabb()
box = wb if not have else box.merge(wb)
have = true
if not have:
# 无网格:退回胶囊近似(头顶 1m,半径 PICK_RADIUS)
var to := node.global_position + Vector3(0, 1.0, 0) - from
var tp := to.dot(dir)
if tp < 0.0:
return -1.0
var perp := (from + dir * tp) - (node.global_position + Vector3(0, 1.0, 0))
return tp if perp.length() < PICK_RADIUS else -1.0
box = box.grow(0.15) # 点选宽容度
# slab 法求射线进入 box 的 t
var tmin := -1e20
var tmax := 1e20
for axis in 3:
var o: float = from[axis]
var d: float = dir[axis]
var lo: float = box.position[axis]
var hi: float = box.position[axis] + box.size[axis]
if absf(d) < 1e-9:
if o < lo or o > hi:
return -1.0
else:
var t1 := (lo - o) / d
var t2 := (hi - o) / d
if t1 > t2:
var tmp := t1; t1 = t2; t2 = tmp
tmin = maxf(tmin, t1)
tmax = minf(tmax, t2)
if tmin > tmax:
return -1.0
return tmin if tmin >= 0.0 else (tmax if tmax >= 0.0 else -1.0)
func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
if world == null or not world.has_method("sample_height"):
return null
var t := 0.0
var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0
for _i in 240:
t += 1.0
var p := from + dir * t
if t > 800.0:
break
var above: bool = p.y - float(world.call("sample_height", p.x, p.z)) > 0.0
if above != last_above:
# 在 [t-1, t] 之间二分
var a := t - 1.0
var b := t
for _j in 12:
var m := (a + b) * 0.5
var pm := from + dir * m
if (pm.y - world.call("sample_height", pm.x, pm.z) > 0) == last_above:
a = m
else:
b = m
var pf := from + dir * b
return Vector3(pf.x, float(world.call("sample_height", pf.x, pf.z)), pf.z)
last_above = above
return null
func _wasd() -> Vector2:
var d := Vector2.ZERO
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): d.y -= 1
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): d.y += 1
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): d.x -= 1
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): d.x += 1
return d.normalized()
func _blocked(x: float, z: float) -> bool:
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z))
# CActorInstance::TestActorCollisionActorInstanceCollisionDetection.cpp)近似:距离门
# 800 cm)→ body 球重叠 → 「趋近」判定(新位比旧位更近才算撞,远离时放行 → 能从
# 重叠里走出来)。每个 pickable(已生成的远端 NPC / 怪 / 他人节点)当作一个 Actor 碰撞体;
# `skip_actor_collision`CanSkipCollision)为真整段跳过(≈ CheckAdvancing 开头)。
func _actor_blocked(next_pos: Vector3) -> bool:
if skip_actor_collision or player == null:
return false
var cur := player.position
var combined := ACTOR_BODY_RADIUS_M * 2.0
for n in pickables:
if not is_instance_valid(n):
continue
if bool(n.get_meta("dead", false)): # rVictim.IsDead()
continue
var op: Vector3 = n.global_position
var d_now := Vector2(op.x - cur.x, op.z - cur.z).length()
if d_now > ACTOR_COLLIDE_MAX_DIST_M: # LengthSq > 800² → 不检
continue
var d_next := Vector2(op.x - next_pos.x, op.z - next_pos.z).length()
if d_next < combined and d_next <= d_now + 1e-4:
return true
return false
# SetAdvancingRotationInstanceBaseMovement.cpp):按转向速度逐帧收敛朝向;移动方向本身
# 不受此限制(骑乘时 rotation_speed_deg = 300 → 转向明显变慢)。
func _turn_toward(target_yaw: float, dt: float) -> void:
if player == null:
return
var max_step := deg_to_rad(rotation_speed_deg) * dt
var diff: float = wrapf(target_yaw - player.rotation.y, -PI, PI)
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
player.rotation.y += signf(diff) * max_step
func _process(dt: float) -> void:
if player == null:
return
_update_hover_cursor()
# __ReserveClickGround 的 delay 递减(NEW_IsEmptyReservedDelayTime);到点且门已开则重试。
if _reserved_delay_time > 0.0:
_reserved_delay_time -= dt
if _reserved_delay_time <= 0.0 and _reserved_ground != null and _can_translate():
var rg: Vector3 = _reserved_ground
_reserved_ground = null
_goto(rg)
if frozen or locked:
# 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。
anim_state.emit("wait")
return
_run = force_run or Input.is_key_pressed(RUN_HOLD_KEY)
var wish := Vector3.ZERO
var wasd := _wasd()
var mobile_axis := _mobile_axis
if mobile_axis != Vector2.ZERO:
# A new thumb gesture has the same fishing-cancel edge as a keyboard move.
if not _mobile_active and cancel_fishing_input.is_valid() \
and bool(cancel_fishing_input.call()):
_mobile_active = true
_last_wasd = Vector2.ZERO
anim_state.emit("wait")
return
_mobile_active = true
_is_going = false
_last_wasd = Vector2.ZERO
# GameCamera 位于 head + (sin yaw, cos yaw)*dist 并看向 head:视线的水平前方是
# -(sin yaw, cos yaw),屏幕右 = 前方 × UP。
var mobile_yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var mobile_fwd := -Vector3(sin(mobile_yaw), 0, cos(mobile_yaw))
var mobile_right := Vector3(-mobile_fwd.z, 0, mobile_fwd.x)
wish = (mobile_fwd * -mobile_axis.y + mobile_right * mobile_axis.x).normalized()
elif wasd != Vector2.ZERO:
_mobile_active = false
# PythonPlayerInputKeyboard 只在方向键按下时尝试取消钓鱼;记录方向
# 边沿,避免按住键时每帧重复发送 CG_FISHING(0)。
if wasd != _last_wasd and cancel_fishing_input.is_valid() \
and bool(cancel_fishing_input.call()):
_last_wasd = wasd
anim_state.emit("wait")
return
_last_wasd = wasd
_is_going = false # 键盘覆盖点地(NEW_MoveToDirectionm_isGoing = FALSE
var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var fwd := -Vector3(sin(yaw), 0, cos(yaw))
var right := Vector3(-fwd.z, 0, fwd.x)
wish = (fwd * -wasd.y + right * wasd.x).normalized()
elif _is_going:
_mobile_active = false
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
if flat.length() <= ARRIVE_EPS:
_is_going = false
else:
wish = flat.normalized()
else:
_mobile_active = false
_last_wasd = Vector2.ZERO
var speed := 0.0
if wish != Vector3.ZERO:
# IsUsingMovingSkill():移动技能中只转向,不平移。
if moving_skill:
_turn_toward(atan2(wish.x, wish.z), dt)
else:
var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
var before := player.position
var np := player.position + wish * want_speed * dt
# 逐轴推进:地形 ATTRIBUTE_BLOCK + Actor 碰撞任一挡下该轴 → 不动那一轴
# (单个 Actor 时等价 AdjustDynamicCollisionMovement 的滑移;两轴都挡 = BlockMovement)。
var block_x := _actor_blocked(Vector3(np.x, before.y, player.position.z)) \
or _blocked(np.x, player.position.z)
var block_z := _actor_blocked(Vector3(player.position.x, before.y, np.z)) \
or _blocked(player.position.x, np.z)
if not block_x:
player.position.x = np.x
if not block_z:
player.position.z = np.z
# 动画看「实际位移」而非意图:贴墙 / 顶人磨蹭时该切回 wait
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
if disp > want_speed * dt * 0.25:
speed = want_speed
_turn_toward(atan2(wish.x, wish.z), dt)
else:
_is_going = false
if world and world.has_method("sample_height"):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
moved.emit(player.position)
if speed <= 0.01:
anim_state.emit("wait")
elif not _run:
anim_state.emit("walk")
else:
anim_state.emit("run")
func _update_hover_cursor() -> void:
if cursor_manager == null or not cursor_manager.has_method("set_cursor") or camera == null:
return
var mouse_pos := get_viewport().get_mouse_position()
if ui_manager and ui_manager.has_method("blocks_game_input"):
var motion := InputEventMouseMotion.new()
motion.position = mouse_pos
if ui_manager.blocks_game_input(motion):
cursor_manager.set_cursor("NORMAL")
_set_hover_vid(0) # 鼠标压 UIIsPickedWindow 为假 -> 无 chr.Pick 强显
return
if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos):
cursor_manager.set_cursor("PICK")
_set_hover_vid(0) # 悬停地面物品:item.Pick 领域,非 chr.Pick
return
var from := camera.project_ray_origin(mouse_pos)
var dir := camera.project_ray_normal(mouse_pos)
var best: Node3D = null
var best_t := 1e20
for n in pickables:
if not is_instance_valid(n):
continue
var t := _ray_pick_t(from, dir, n)
if t >= 0.0 and t < best_t:
best_t = t
best = n
if best:
cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK")))
_set_hover_vid(int(best.get_meta("vid", 0)))
else:
cursor_manager.set_cursor("NORMAL")
_set_hover_vid(0)
# game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停命中的角色 vid 变化时才 emit。
func _set_hover_vid(vid: int) -> void:
if vid == _hover_vid:
return
_hover_vid = vid
hover_entity_changed.emit(vid)