Files
mtgodot-poc/project/player_controller.gd
T
shenleiandClaude Sonnet 5 c5c183da37 fix(movement): remove unreferenced [0.25,3.0] clamp on local player's movSpd scale
CInstanceBase::SetMoveSpeed only guards moving_speed > 1100 -> 0; the local
player's set_server_speed() was additionally floor/ceil-clamping the scale to
an arbitrary [0.25, 3.0] band with no basis in the reference or in this
project's already-fixed remote-entity equivalent (EntityStore::motion_move_speed).
A heavy haste stack or slow debuff on the local player would silently diverge
from what everyone else sees. Removed the extra clamp; kept the moving_speed<=0
early-return guard against pre-spawn/malformed values.

audit: movement.keyboard.motion/server-speed-scale-clamp closed with reference
citation, regression test, and full related-suite pass; contract stays PARTIAL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:31:17 +09:00

829 lines
34 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.
# 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 ground_clicked(world_pos: Vector3)
signal ground_item_clicked(vid: int)
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:
set(v):
player = v
if player:
_src_pos = player.position
_dst_pos = player.position
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 := 1.8
const SPEED_RUN := 4.8
const KEYBOARD_MOVE_TARGET_M := 3.0 # 40250 NEW_MoveToDirection: Dst = Cur + 300 px
const RUN_HOLD_KEY := KEY_SHIFT
const ARRIVE_EPS := 0.05
const CAMERA_AUTO_ROTATE_SPEED_DEG := 20.0 # 40250 CPythonPlayer::m_fCmrRotSpd 默认值
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 default_run := true # 40250 对齐:默认奔跑模式(ActorInstance.cpp:544 m_isWalking = FALSE
var force_run := false # 脚本化 / 自动跑
var force_walk := false # 步行模式
var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设)
var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect)
var dead := false # 40250 CActorInstance::IsDead:死亡期间停止一切本地移动输入
var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移
var private_shop_open := false # CPythonPlayer::IsOpenPrivateShop:摆摊期间输入只保留按键状态
var processing_emotion := false # CPythonPlayer::__IsProcessingEmotion:情绪动作期间禁止移动
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
# 40250 CPythonPlayer::NEW_SetSingleDirKeyState:方向由进入游戏后的按下/松开事件
# 维护,不能每帧查询系统全局键盘状态。否则加载场景时仍按着的键会被继承,表现为
# 刚进入游戏角色就自动移动。
var _key_up := false
var _key_down := false
var _key_left := false
var _key_right := 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);方向键移动保持独立的 300cm Dst,
# 因为 40250 NEW_MoveToDirection 会把 m_isGoing 置 false。
var _src_pos := Vector3.ZERO
var _dst_pos := Vector3.ZERO
var _is_going := false
var _dst_rot := 0.0
var _direction_dst := Vector3.ZERO
var _direction_wish := Vector3.ZERO
var _direction_active := false
var _direction_resume_pending := false
var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点
var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效
var active := true: # 进游戏装配/加载中置假,防止过早响应点地
set(value):
if active == value:
return
active = value
# 切换输入阶段相当于 40250 game.Close/Open 重建按键状态。只有在游戏窗
# 激活后新收到的 KeyDown 才能驱动角色。
_clear_direction_keys()
# 40250 CInstanceBase::NEW_Stop() / CPythonPlayer::NEW_Stop():停止点地移动与方向输入,重置预约
func stop() -> void:
_is_going = false
_reserved_ground = null
_reserved_delay_time = 0.0
_last_wasd = Vector2.ZERO
_clear_direction_keys()
_mobile_active = false
_mobile_axis = Vector2.ZERO
_mobile_ui_touches.clear()
_multi_gesture = false
_touch_count = 0
_tap_index = -1
if player:
_src_pos = player.position
_dst_pos = player.position
anim_state.emit("wait")
func set_dead(value: bool) -> void:
if dead == value:
return
dead = value
if dead:
# GC_DEAD/HP<=0 has the same movement boundary as NEW_Stop, but must
# retain the dead flag until a positive HP/re-entry transition arrives.
stop()
else:
# Re-entry starts from the server/current actor position. Do not emit a
# second wait here; the next authoritative animation state owns that.
if player:
_src_pos = player.position
_dst_pos = player.position
# 40250 CPythonPlayer::OpenPrivateShop / ClosePrivateShop。
# 个人摊位建立后,方向键仍由 NEW_SetSingleDirKeyState 记录,但
# NEW_SetMultiDirKeyState -> __CanMove 不允许产生新的平移;收摊后由
# Update() 再次提交仍按住的方向键。
func set_private_shop_open(value: bool) -> void:
if private_shop_open == value:
return
private_shop_open = value
if private_shop_open:
_is_going = false
_reserved_ground = null
_reserved_delay_time = 0.0
_direction_active = false
_direction_resume_pending = _wasd() != Vector2.ZERO
_direction_wish = Vector3.ZERO
if player:
_direction_dst = player.position
# 40250 CPythonPlayer::StartEmotionProcess / EndEmotionProcess。
# 情绪开始时会清预约动作并锁住主角,方向位不能丢,动作结束后由
# PlayerController 的下一次 Update 恢复与原客户端 m_isDirKey 相同的重试。
func set_processing_emotion(value: bool) -> void:
if processing_emotion == value:
return
processing_emotion = value
if processing_emotion:
_is_going = false
_reserved_ground = null
_reserved_delay_time = 0.0
_direction_active = false
_direction_resume_pending = _wasd() != Vector2.ZERO
_direction_wish = Vector3.ZERO
if player:
_direction_dst = player.position
func _clear_direction_keys() -> void:
_key_up = false
_key_down = false
_key_left = false
_key_right = false
_direction_active = false
_direction_resume_pending = false
_direction_wish = Vector3.ZERO
if player:
_direction_dst = player.position
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
# KeyUp 可能在窗口失焦期间丢失;参考客户端失去游戏窗口输入后停止移动。
_clear_direction_keys()
_last_wasd = Vector2.ZERO
func _input(e: InputEvent) -> void:
if not (e is InputEventKey) or e.echo:
return
var key := e as InputEventKey
# 松开事件必须始终接收,即使此时 UI 正在拦截游戏输入,否则会留下粘键。
# KeyDown 则在 _unhandled_input 中接收,让聊天输入框有机会先消费 WASD。
if not key.pressed:
_set_direction_key_state(key, false)
func _set_direction_key_state(e: InputEventKey, pressed: bool) -> void:
var logical := e.keycode
var physical := e.physical_keycode
if logical in [KEY_W, KEY_UP] or physical in [KEY_W, KEY_UP]:
_key_up = pressed
elif logical in [KEY_S, KEY_DOWN] or physical in [KEY_S, KEY_DOWN]:
_key_down = pressed
elif logical in [KEY_A, KEY_LEFT] or physical in [KEY_A, KEY_LEFT]:
_key_left = pressed
elif logical in [KEY_D, KEY_RIGHT] or physical in [KEY_D, KEY_RIGHT]:
_key_right = pressed
_refresh_direction_target()
func _keyboard_wish(dir: Vector2) -> Vector3:
if dir == Vector2.ZERO:
return Vector3.ZERO
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)
return (fwd * -dir.y + right * dir.x).normalized()
# CPythonPlayer::NEW_SetMultiDirKeyState -> CInstanceBase::NEW_MoveToDirection.
# The reference writes a new 300-pixel destination only when the direction state
# changes; it does not continuously extend the destination every frame.
func _refresh_direction_target() -> void:
if not active or player == null:
return
var dir := _wasd()
if dir == Vector2.ZERO:
_direction_active = false
_direction_resume_pending = false
_direction_wish = Vector3.ZERO
_direction_dst = player.position
return
if dead or frozen or locked or private_shop_open or processing_emotion:
_direction_active = false
_direction_resume_pending = true
_direction_wish = Vector3.ZERO
_direction_dst = player.position
return
_direction_wish = _keyboard_wish(dir)
# NEW_MoveToDirection keeps the facing update for moving skills, but does
# not create a translating destination while the skill owns movement.
if moving_skill:
_direction_active = false
_direction_resume_pending = true
_direction_dst = player.position
return
_direction_dst = player.position + _direction_wish * KEYBOARD_MOVE_TARGET_M
_direction_active = true
_direction_resume_pending = false
# 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 active and not dead and not frozen and not locked and not moving_skill \
and not private_shop_open and not processing_emotion
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
# CInstanceBase::SetMoveSpeed(UINT uMovSpd): only guard is uMovSpd > 1100 ->
# 0 (frozen walk pose, no root-motion advance); otherwise uMovSpd/100.0f is
# applied unclamped. EntityStore::motion_move_speed already mirrors this for
# remote actors; there is no reference basis for an arbitrary [0.25, 3.0]
# band on the local player, so a big haste stack or heavy slow debuff must
# scale local prediction the same way it scales everyone else's remote view.
server_speed_scale = 0.0 if moving_speed > 1100 else float(moving_speed) / 100.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 not active or 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 not active or dead:
return
if e is InputEventKey:
var key := e as InputEventKey
if key.pressed and not key.echo:
_set_direction_key_state(key, true)
elif 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 not active or camera == null or player == null:
return
# 0) 掉落物点选(对齐 40250 __GetPickedItemID 优先于 Actor/Ground
if ground_items and ground_items.has_method("pick_at"):
var picked_item_vid: int = ground_items.call("pick_at", camera, screen_pos)
if picked_item_vid != 0:
ground_item_clicked.emit(picked_item_vid)
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
if bool(n.get_meta("dead", false)) or not bool(n.get_meta("can_pick", true)):
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
ground_clicked.emit(Vector3(hit.x, hit.y, hit.z))
_goto(hit)
else:
# 40250 __OnClickSmart falls through to NEW_Stop when the click hits
# neither an item, actor, nor ground. Without this boundary a previous
# click-to-move destination keeps running after clicking empty UI-free
# space, which makes the actor appear to move on its own.
stop()
# 射线 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
if t1 > tmin: tmin = t1
if t2 < tmax: tmax = t2
if tmin > tmax or tmax < 0.0:
return -1.0
return tmin if tmin >= 0.0 else tmax
# 射线 vs 地表:沿射线在 [0, 80m] 区间二分找 ray.y == world.sample_height(ray.x, ray.z)。
func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
if world == null or not world.has_method("sample_height"):
return null
var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0
var t := 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:
if not (_key_up or _key_down or _key_left or _key_right):
return Vector2.ZERO
var d := Vector2.ZERO
# 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于DownLeft优先于Right
if _key_up:
d.y = -1.0
elif _key_down:
d.y = 1.0
if _key_left:
d.x = -1.0
elif _key_right:
d.x = 1.0
return d.normalized()
# 对齐 40250 CPythonPlayer::NEW_MoveToDirection 的 m_isCmrRot 分支:dir 是 _wasd() 的相机相对
# 输入向量(0,-1=正前)。折算成角色相对相机的方位角 fDirRot0=前 90=左 180=后 270=右,
# 与 GetDegreeFromPosition 的取值一致),再按参考实现同样的公式折叠到 [-90,90] 的
# fRotRat:正前/正后为 0(不转),正左/右为 ±90(转速最快),对角线为 ±45(减半)。
# 参考实现用 CCamera::Roll(fRotDeg)D3D 增量式 rollfRotDeg=-speed*dt*fRotRat/90);
# Godot 的 yaw 增大对应视线转向左,和 D3D 的 Roll 正方向手性相反,因此这里的增量对
# fRotRat 取正号而不是参考的负号 —— 这是platform_adaptation,用“相机持续转向新朝向、
# 从不越过目标”这一收敛不变式核验(keyboard_motion_timeline_test.gd),而不是逐位对照
# D3D 数值。
func _camera_auto_rotate(dir: Vector2, dt: float) -> void:
if camera == null or not camera.has_method("heading") or dir == Vector2.ZERO:
return
var dir_rot_deg := fposmod(rad_to_deg(atan2(-dir.x, -dir.y)), 360.0)
var sig_dir_rot := dir_rot_deg
if sig_dir_rot > 180.0:
sig_dir_rot -= 360.0
var rot_rat := sig_dir_rot
if rot_rat > 90.0:
rot_rat = 180.0 - rot_rat
elif rot_rat < -90.0:
rot_rat = -180.0 - rot_rat
camera.yaw += deg_to_rad(CAMERA_AUTO_ROTATE_SPEED_DEG * dt * rot_rat / 90.0)
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 碰撞体;
static func _is_static_actor(n: Node3D) -> bool:
if not is_instance_valid(n):
return false
var shape: String = String(n.get_meta("cursor_shape", ""))
if shape == "DOOR":
return true
var kind: int = int(n.get_meta("kind", -1))
if kind in [0, 1, 3]: # KIND_BUILDING, KIND_WOODEN_DOOR, KIND_STONE
return true
return false
# 40250 Actor-Actor 阻挡判定与阻挡实体获取
# ActorInstanceCollisionDetection.cpp:640: 仅在两实体距离缩小(d_next < d_now)且侵入包围半径时阻挡
func _get_blocking_actor(next_pos: Vector3) -> Node3D:
if skip_actor_collision or player == null or locked:
return null
var cur: Vector3 = player.global_position if player.is_inside_tree() else 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)) or not bool(n.get_meta("can_pick", true)):
continue
var op: Vector3 = n.global_position if n.is_inside_tree() else n.position
var d_now := Vector2(op.x - cur.x, op.z - cur.z).length()
if d_now > ACTOR_COLLIDE_MAX_DIST_M:
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:
return n
return null
func _actor_blocked(next_pos: Vector3) -> bool:
var b := _get_blocking_actor(next_pos)
return b != null and _is_static_actor(b)
# SetAdvancingRotationInstanceBaseMovement.cpp:142-154):
# 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/s)。
func _turn_toward(target_yaw: float, dt: float) -> void:
if player == null:
return
var cur_yaw: float = wrapf(player.rotation.y, -PI, PI)
var diff: float = wrapf(target_yaw - cur_yaw, -PI, PI)
var spd := rotation_speed_deg
if absf(diff) <= deg_to_rad(45.0):
spd *= 5.0 / 12.0
var max_step := deg_to_rad(spd) * dt
var next_yaw := target_yaw if max_step <= 0.0 or absf(diff) <= max_step \
else wrapf(cur_yaw + signf(diff) * max_step, -PI, PI)
# 只允许直立偏航。逐项修改 Euler 分量在 W/S 180° 快切和 ±PI 边界附近可能
# 被引擎重新分解为 X/Z = PI,看起来就是角色翻跟头。
player.rotation = Vector3(0.0, wrapf(next_yaw, -PI, PI), 0.0)
func _process(dt: float) -> void:
if player == null or not active:
return
if dead:
if world and world.has_method("sample_height") and is_instance_valid(player):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
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 or private_shop_open or processing_emotion:
# 40250 keeps m_isDirKey and retries NEW_SetMultiDirKeyState from
# CPythonPlayer::Update while the actor is locked/syncing. Do not lose a
# held WASD direction or let its old fixed target resume after unlock.
if _wasd() != Vector2.ZERO:
_direction_active = false
_direction_resume_pending = true
_direction_wish = Vector3.ZERO
_direction_dst = player.position
# 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。
# 处于攻击或施法锁定中(locked),不发送 wait 动画中断技能/普攻动作
if frozen and not locked:
anim_state.emit("wait")
if world and world.has_method("sample_height") and is_instance_valid(player):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
return
# 40250 CPythonPlayer::Update 每帧对仍按住的方向键重试 NEW_SetMultiDirKeyState ->
# NEW_MoveToDirection,其 m_isCmrRot 分支与是否已建立平移目标无关,门检查(上面
# 的 frozen/locked/shop/emotion)通过后即执行,因此放在移动逻辑之前、无条件按
# 当前方向键调用。
_camera_auto_rotate(_wasd(), dt)
if force_walk:
_run = false
elif force_run:
_run = true
elif default_run:
_run = not Input.is_key_pressed(RUN_HOLD_KEY) and not Input.is_physical_key_pressed(RUN_HOLD_KEY)
else:
_run = Input.is_key_pressed(RUN_HOLD_KEY) or Input.is_physical_key_pressed(RUN_HOLD_KEY)
var wish := Vector3.ZERO
var wasd := _wasd()
if moving_skill and wasd != Vector2.ZERO and _direction_active:
# IsUsingMovingSkill() takes ownership of the actor motion immediately;
# discard the old translating destination but keep the held direction for
# the skill's facing update and for the post-skill retry.
_direction_active = false
_direction_resume_pending = true
_direction_wish = _keyboard_wish(wasd)
_direction_dst = player.position
if wasd != Vector2.ZERO and _direction_resume_pending and _can_translate():
# The next Update after a lock/moving-skill edge is the same retry point
# as the reference's per-frame m_isDirKey branch.
_refresh_direction_target()
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
_reserved_ground = null
_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
_reserved_ground = null
# The reference creates a fixed 300-pixel destination on the key event;
# do not continuously extend it from the current position every frame.
if _direction_active or moving_skill:
wish = _direction_wish
elif _is_going:
_mobile_active = false
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
var dist := flat.length()
if dist <= ARRIVE_EPS:
player.position.x = _dst_pos.x
player.position.z = _dst_pos.z
_is_going = false
else:
wish = flat / dist
else:
_mobile_active = false
_last_wasd = Vector2.ZERO
var speed := 0.0
var keyboard_motion := wasd != Vector2.ZERO and _direction_active
if wish != Vector3.ZERO:
# IsUsingMovingSkill():移动技能中只转向,不平移。
if moving_skill:
_turn_toward(atan2(wish.x, wish.z), dt)
else:
var base_speed := SPEED_RUN if _run else SPEED_WALK
if player and player.has_method("get_move_motion_speeds"):
var ms: Vector2 = player.call("get_move_motion_speeds")
var m_spd: float = (ms.y if _run else ms.x) * 0.01
if m_spd > 0.0:
base_speed = m_spd
var want_speed := base_speed * server_speed_scale
var before := player.position
var move_step := want_speed * dt
if _is_going or keyboard_motion:
var target := _direction_dst if keyboard_motion else _dst_pos
var to_dst := Vector2(target.x - player.position.x, target.z - player.position.z).length()
if move_step >= to_dst:
move_step = to_dst
var np := player.position + wish * move_step
var direct_blocked := false
var blocker := _get_blocking_actor(np)
if blocker:
direct_blocked = true
var b_pos: Vector3 = blocker.global_position if blocker.is_inside_tree() else blocker.position
var to_player := Vector3(player.position.x - b_pos.x, 0, player.position.z - b_pos.z)
var norm := to_player.normalized()
var tangent := Vector3(-norm.z, 0, norm.x)
if tangent.dot(wish) < 0.0:
tangent = -tangent
var slide_np := player.position + tangent * (wish.dot(tangent) * move_step)
if not _actor_blocked(slide_np) and not _blocked(slide_np.x, slide_np.z):
np = slide_np
direct_blocked = false
if direct_blocked or _blocked(np.x, np.z) or _actor_blocked(np):
# 逐轴推进降级保底:地形 ATTRIBUTE_BLOCK 或静态物体阻挡
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
else:
player.position = np
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
if _is_going and Vector2(_dst_pos.x - player.position.x, _dst_pos.z - player.position.z).length() <= ARRIVE_EPS:
_is_going = false
if keyboard_motion and Vector2(_direction_dst.x - player.position.x, _direction_dst.z - player.position.z).length() <= ARRIVE_EPS:
_direction_active = false
_direction_resume_pending = false
var is_manual := (wasd != Vector2.ZERO or mobile_axis != Vector2.ZERO)
if disp > want_speed * dt * 0.25 or is_manual:
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))
if speed > 0.01:
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
if camera.has_method("is_dragging") and bool(camera.call("is_dragging")):
cursor_manager.set_cursor("CAMERA_ROTATE")
_set_hover_vid(0)
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
if bool(n.get_meta("dead", false)):
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:
_set_hover_vid(0)
var ghit: Variant = _ray_ground(from, dir)
if ghit != null and world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", ghit.x, ghit.z)):
cursor_manager.set_cursor("CANT_GO")
else:
cursor_manager.set_cursor("NORMAL")
# 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)