Files
mtgodot-poc/project/player_controller.gd
T

408 lines
16 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 moved(pos: Vector3)
var player: Node3D
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/怪等)
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 # 点选命中半径(米)
# __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 force_run := 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
# --- §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 才生效
# 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 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
# 程序化下发一个点地目标(脚本化截图 / 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 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 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 == 地表高度)→ 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
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))
# CActorInstance::TestActorCollisionActorInstanceCollisionDetection.cpp)近似:距离门
# 800 cm)→ body 球重叠 → 「趋近」判定(新位比旧位更近才算撞,远离时放行 → 能从
# 重叠里走出来)。每个 pickable(已生成的远端 NPC / 怪 / 他人节点)当作一个 Actor 碰撞体;
# `skip_actor_collision`CanSkipCollision)为真整段跳过(≈ CheckAdvancing 开头)。
func _actor_blocked(next_pos: Vector3) -> bool:
if skip_actor_collision or player == null:
return false
var cur := 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)): # rVictim.IsDead()
continue
var op: Vector3 = n.global_position
var d_now := Vector2(op.x - cur.x, op.z - cur.z).length()
if d_now > ACTOR_COLLIDE_MAX_DIST_M: # LengthSq > 800² → 不检
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 + 1e-4:
return true
return false
# SetAdvancingRotationInstanceBaseMovement.cpp):按转向速度逐帧收敛朝向;移动方向本身
# 不受此限制(骑乘时 rotation_speed_deg = 300 → 转向明显变慢)。
func _turn_toward(target_yaw: float, dt: float) -> void:
if player == null:
return
var max_step := deg_to_rad(rotation_speed_deg) * dt
var diff: float = wrapf(target_yaw - player.rotation.y, -PI, PI)
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
player.rotation.y += signf(diff) * max_step
func _process(dt: float) -> void:
if player == null:
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,等价预约不丢)。
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:
# 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
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:
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
if flat.length() <= ARRIVE_EPS:
_is_going = false
else:
wish = flat.normalized()
else:
_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 want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
var before := player.position
var np := player.position + wish * want_speed * dt
# 逐轴推进:地形 ATTRIBUTE_BLOCK + Actor 碰撞任一挡下该轴 → 不动那一轴
# (单个 Actor 时等价 AdjustDynamicCollisionMovement 的滑移;两轴都挡 = BlockMovement)。
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
# 动画看「实际位移」而非意图:贴墙 / 顶人磨蹭时该切回 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
_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))
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
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")
return
if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos):
cursor_manager.set_cursor("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
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")))
else:
cursor_manager.set_cursor("NORMAL")