Files
mtgodot-poc/project/player_controller.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

609 lines
24 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_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 RUN_HOLD_KEY := KEY_SHIFT
const ARRIVE_EPS := 0.05
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 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 才生效
var active := true # 进游戏装配/加载中置假,防止过早响应点地
# 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
_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")
# 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 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 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:
return
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 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)):
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
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 ui_manager and ui_manager.has_method("blocks_game_input"):
var dummy := InputEventKey.new()
dummy.keycode = KEY_W
if ui_manager.blocks_game_input(dummy):
return Vector2.ZERO
var is_up := Input.is_key_pressed(KEY_W) or Input.is_physical_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP)
var is_down := Input.is_key_pressed(KEY_S) or Input.is_physical_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN)
var is_left := Input.is_key_pressed(KEY_A) or Input.is_physical_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT)
var is_right := Input.is_key_pressed(KEY_D) or Input.is_physical_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT)
if not (is_up or is_down or is_left or is_right):
return Vector2.ZERO
var d := Vector2.ZERO
# 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于DownLeft优先于Right
if is_up:
d.y = -1.0
elif is_down:
d.y = 1.0
if is_left:
d.x = -1.0
elif is_right:
d.x = 1.0
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 碰撞体;
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)):
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
player.rotation.x = 0.0
player.rotation.z = 0.0
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
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
player.rotation.y = wrapf(cur_yaw + signf(diff) * max_step, -PI, PI)
func _process(dt: float) -> void:
if player == null or not active:
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,等价预约不丢)。
# 处于攻击或施法锁定中(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
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()
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
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)
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
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:
var to_dst := Vector2(_dst_pos.x - player.position.x, _dst_pos.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
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)