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>
829 lines
34 KiB
GDScript
829 lines
34 KiB
GDScript
# 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 OnRender:wndMgr.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 # 上一帧悬停命中的实体 vid(hover_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_fReservedDelayTime(PythonPlayerInput.cpp:834)。
|
||
const RESERVED_GROUND_DELAY := 0.1
|
||
# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancing,InstanceBaseBattle.cpp:469 +
|
||
# CActorInstance::TestActorCollision,ActorInstanceCollisionDetection.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 300(InstanceBase.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_Goto(InstanceBaseMovement.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优先于Down,Left优先于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=正前)。折算成角色相对相机的方位角 fDirRot(0=前 90=左 180=后 270=右,
|
||
# 与 GetDegreeFromPosition 的取值一致),再按参考实现同样的公式折叠到 [-90,90] 的
|
||
# fRotRat:正前/正后为 0(不转),正左/右为 ±90(转速最快),对角线为 ±45(减半)。
|
||
# 参考实现用 CCamera::Roll(fRotDeg)(D3D 增量式 roll,fRotDeg=-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::TestActorCollision(ActorInstanceCollisionDetection.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)
|
||
|
||
# SetAdvancingRotation(InstanceBaseMovement.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_MoveToDirection:m_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) # 鼠标压 UI:IsPickedWindow 为假 -> 无 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)
|