# 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 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 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_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 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_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: 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优先于Down,Left优先于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::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)): 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 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_MoveToDirection:m_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) # 鼠标压 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)