# PlayerController —— Metin2 风移动 + 选目标(客户端预测,无服务端纠正)。 # 左键点地面 -> 走/跑过去(沿途查 attr.atr 可行走,遇阻停) # 左键点实体 -> 选中(emit target_selected) # WASD/方向键 -> 直接移动(相对相机),覆盖点地 # 触屏(F7):单指轻点 = 点地/点选(快速抬起且位移小);单指拖拽归相机环绕, # 双指手势时不产生点地。 # 需要:player(Node3D)、camera(GameCamera)、world(Metin2World)。 # 每帧 emit anim_state("wait"|"walk"|"run") 供上层切动画。 extends Node signal anim_state(state: String) signal target_selected(node: Node3D) signal moved(pos: Vector3) var player: Node3D var camera: Camera3D var world: Node var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等) const SPEED_WALK := 8.0 const SPEED_RUN := 18.0 const RUN_HOLD_KEY := KEY_SHIFT const ARRIVE_EPS := 0.4 const PICK_RADIUS := 1.4 # 点选命中半径(米) var force_run := false # 脚本化 / 自动跑 var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设) var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100 const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点 const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点 var _move_target := Vector3.ZERO var _has_target := false 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) # 程序化下发一个点地目标(脚本化截图 / AI) func walk_to(world_pos: Vector3) -> void: _move_target = Vector3(world_pos.x, 0.0, world_pos.z) _has_target = true func _unhandled_input(e: InputEvent) -> void: if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: _on_click(e.position) elif e is InputEventScreenTouch: _on_touch(e) elif e is InputEventScreenDrag: # 触点一旦拖出去,就不再是「轻点」,交给相机环绕 if e.index == _tap_index and e.position.distance_to(_tap_press_pos) > _TAP_TRAVEL_MAX: _tap_index = -1 func _on_touch(e: InputEventScreenTouch) -> void: if e.pressed: _touch_count += 1 if _touch_count >= 2: _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: _multi_gesture = false func _on_click(screen_pos: Vector2) -> void: if camera == null: return var from := camera.project_ray_origin(screen_pos) var dir := camera.project_ray_normal(screen_pos) # 1) 实体点选:射线 vs 每个 pickable 的网格世界包围盒(slab 相交,逐实体精确, # 大怪 / 小怪都不再被固定 1.4m 半径误判)。无网格的回退到胶囊近似。 var best: Node3D = null var best_t := 1e20 for n in pickables: if not is_instance_valid(n): continue var t := _ray_pick_t(from, dir, n) if t >= 0.0 and t < best_t: best_t = t best = n if best: target_selected.emit(best) return # 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度) var hit: Variant = _ray_ground(from, dir) if hit != null: _move_target = hit _has_target = true # 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。 func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float: var box := AABB() var have := false for m in node.find_children("*", "MeshInstance3D", true, false): var mi := m as MeshInstance3D if mi.mesh == null: continue var wb: AABB = mi.get_global_transform() * mi.get_aabb() box = wb if not have else box.merge(wb) have = true if not have: # 无网格:退回胶囊近似(头顶 1m,半径 PICK_RADIUS) var to := node.global_position + Vector3(0, 1.0, 0) - from var tp := to.dot(dir) if tp < 0.0: return -1.0 var perp := (from + dir * tp) - (node.global_position + Vector3(0, 1.0, 0)) return tp if perp.length() < PICK_RADIUS else -1.0 box = box.grow(0.15) # 点选宽容度 # slab 法求射线进入 box 的 t var tmin := -1e20 var tmax := 1e20 for axis in 3: var o: float = from[axis] var d: float = dir[axis] var lo: float = box.position[axis] var hi: float = box.position[axis] + box.size[axis] if absf(d) < 1e-9: if o < lo or o > hi: return -1.0 else: var t1 := (lo - o) / d var t2 := (hi - o) / d if t1 > t2: var tmp := t1; t1 = t2; t2 = tmp tmin = maxf(tmin, t1) tmax = minf(tmax, t2) if tmin > tmax: return -1.0 return tmin if tmin >= 0.0 else (tmax if tmax >= 0.0 else -1.0) func _ray_ground(from: Vector3, dir: Vector3) -> Variant: if world == null or not world.has_method("sample_height"): return null var t := 0.0 var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0 for _i in 240: t += 1.0 var p := from + dir * t if t > 800.0: break var above: bool = p.y - float(world.call("sample_height", p.x, p.z)) > 0.0 if above != last_above: # 在 [t-1, t] 之间二分 var a := t - 1.0 var b := t for _j in 12: var m := (a + b) * 0.5 var pm := from + dir * m if (pm.y - world.call("sample_height", pm.x, pm.z) > 0) == last_above: a = m else: b = m var pf := from + dir * b return Vector3(pf.x, float(world.call("sample_height", pf.x, pf.z)), pf.z) last_above = above return null func _wasd() -> Vector2: var d := Vector2.ZERO if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): d.y -= 1 if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): d.y += 1 if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): d.x -= 1 if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): d.x += 1 return d.normalized() func _blocked(x: float, z: float) -> bool: return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z)) func _process(dt: float) -> void: if player == null: return if frozen: _has_target = false anim_state.emit("wait") return _run = force_run or Input.is_key_pressed(RUN_HOLD_KEY) var wish := Vector3.ZERO var wasd := _wasd() if wasd != Vector2.ZERO: _has_target = false # 键盘覆盖点地 var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) var fwd := Vector3(sin(yaw), 0, cos(yaw)) var right := Vector3(fwd.z, 0, -fwd.x) wish = (fwd * -wasd.y + right * wasd.x).normalized() elif _has_target: var flat := Vector3(_move_target.x - player.position.x, 0, _move_target.z - player.position.z) if flat.length() <= ARRIVE_EPS: _has_target = false else: wish = flat.normalized() var speed := 0.0 if wish != Vector3.ZERO: var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale var before := player.position var np := player.position + wish * want_speed * dt if not _blocked(np.x, player.position.z): player.position.x = np.x if not _blocked(player.position.x, np.z): player.position.z = np.z # 动画看「实际位移」而非意图:贴墙磨蹭时该切回 wait var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length() if disp > want_speed * dt * 0.25: speed = want_speed player.rotation.y = atan2(wish.x, wish.z) else: _has_target = false if world and world.has_method("sample_height"): player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) moved.emit(player.position) if speed <= 0.01: anim_state.emit("wait") elif not _run: anim_state.emit("walk") else: anim_state.emit("run")