Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
243 lines
8.3 KiB
GDScript
243 lines
8.3 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 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")
|