- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级) - 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植) - _register_hit 改为 map::insert 语义,修正命中上限 - 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段 - 去掉本地连击超时,motion_bound 清命中表 / 连击段号 - __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、 命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd) - ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING - mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上 - 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试; gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体 - 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
393 lines
14 KiB
GDScript
393 lines
14 KiB
GDScript
# GameCamera —— Metin2 风第三人称轨道相机。
|
||
# 桌面:右键拖拽 -> 环绕(yaw/pitch) 滚轮 -> 缩放
|
||
# 触屏(F7):单指拖拽 -> 环绕 双指捏合 -> 缩放
|
||
# 跟随 target,地形/建筑防穿。
|
||
# 用法:var c = preload("res://game_camera.gd").new(); c.target = player;
|
||
# c.world = metin2_world; add_child(c); c.make_current()
|
||
extends Camera3D
|
||
|
||
enum CameraMode {
|
||
NORMAL,
|
||
STAND,
|
||
BLEND,
|
||
}
|
||
|
||
# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene
|
||
# is metres/radians, so keep the conversion at this boundary instead of
|
||
# leaking mixed units into the orbit controls.
|
||
const DEFAULT_DISTANCE_CM := 2100.0
|
||
const DEFAULT_PITCH_DEG := 34.0
|
||
const DEFAULT_ROTATION_DEG := 0.0
|
||
const DEFAULT_HEIGHT_CM := 100.0
|
||
|
||
var target: Node3D
|
||
var world: Node # Metin2World,用 sample_height 防穿(可空)
|
||
var head_offset := Vector3(0, 1.15, 0)
|
||
|
||
var yaw := deg_to_rad(DEFAULT_ROTATION_DEG)
|
||
var pitch := deg_to_rad(DEFAULT_PITCH_DEG)
|
||
var dist := DEFAULT_DISTANCE_CM * 0.01
|
||
var min_pitch := deg_to_rad(12.0)
|
||
var max_pitch := deg_to_rad(78.0)
|
||
var min_dist := 2.0
|
||
var max_dist := 25.0
|
||
var sensitivity := 0.006
|
||
var zoom_step := 1.4
|
||
var follow_lerp := 12.0
|
||
|
||
# F7 触屏手势
|
||
var touch_sensitivity := 0.005 # 单指拖拽 -> yaw/pitch
|
||
var pinch_zoom_gain := 0.03 # 捏合像素差 -> dist 变化
|
||
const _ORBIT_DEADZONE := 8.0 # 单指移动超过这么多像素才算「拖拽环绕」(低于视作点击)
|
||
|
||
var _dragging := false
|
||
var _pos_ready := false
|
||
var _touches := {} # index:int -> position:Vector2(当前按下的手指)
|
||
var _orbit_touch := -1 # 正在环绕的手指 index,-1 = 无
|
||
var _orbit_active := false # 已越过 deadzone
|
||
var _orbit_press := Vector2.ZERO
|
||
var _pinch_last := -1.0 # 上一帧两指间距,<0 = 未在捏合
|
||
|
||
# ClientVS22 game.py drives these while Q/E/R/F/T/G are held. Keeping the
|
||
# state here (instead of applying one large step per key event) gives the
|
||
# same continuous desktop-camera motion on macOS and Windows.
|
||
var _key_orbit := 0
|
||
var _key_zoom := 0
|
||
var _key_pitch := 0
|
||
|
||
# PythonApplicationCamera.cpp's NORMAL / STAND / BLEND modes. The normal
|
||
# camera follows the player; event cameras use a fixed centre and temporarily
|
||
# lock the orbit input. `setting` dictionaries intentionally retain the
|
||
# reference field names so quest scripts can be passed through unchanged.
|
||
var camera_mode: CameraMode = CameraMode.NORMAL
|
||
var _event_setting := {}
|
||
var _default_setting := {}
|
||
var _blend_from := {}
|
||
var _blend_to := {}
|
||
var _blend_elapsed := 0.0
|
||
var _blend_duration := 0.0
|
||
var _event_offset := Vector3.ZERO
|
||
var _event_center := Vector3.ZERO
|
||
|
||
const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞)
|
||
const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
|
||
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
|
||
|
||
func _ready() -> void:
|
||
# Match the original third-person framing: farther camera and narrower lens.
|
||
fov = 45.0
|
||
far = 4000.0
|
||
_apply_setting(_normal_setting())
|
||
if target:
|
||
global_position = _desired_pos(true)
|
||
_pos_ready = true
|
||
|
||
func _unhandled_input(e: InputEvent) -> void:
|
||
if e is InputEventMouseButton:
|
||
if e.button_index == MOUSE_BUTTON_RIGHT:
|
||
_dragging = e.pressed and not is_event_locked()
|
||
elif e.button_index == MOUSE_BUTTON_WHEEL_UP and e.pressed:
|
||
if not is_event_locked():
|
||
dist = maxf(min_dist, dist - zoom_step)
|
||
elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN and e.pressed:
|
||
if not is_event_locked():
|
||
dist = minf(max_dist, dist + zoom_step)
|
||
elif e is InputEventMouseMotion and _dragging and not is_event_locked():
|
||
yaw -= e.relative.x * sensitivity
|
||
pitch = clampf(pitch + e.relative.y * sensitivity, min_pitch, max_pitch)
|
||
elif e is InputEventScreenTouch:
|
||
_on_touch(e)
|
||
elif e is InputEventScreenDrag:
|
||
_on_drag(e)
|
||
|
||
func _on_touch(e: InputEventScreenTouch) -> void:
|
||
if e.pressed:
|
||
_touches[e.index] = e.position
|
||
if _touches.size() == 1:
|
||
_orbit_touch = e.index
|
||
_orbit_active = false
|
||
_orbit_press = e.position
|
||
elif _touches.size() == 2:
|
||
_orbit_touch = -1 # 第二指落下 -> 转捏合,取消环绕
|
||
_pinch_last = _pinch_dist()
|
||
else:
|
||
_touches.erase(e.index)
|
||
if _touches.size() < 2:
|
||
_pinch_last = -1.0
|
||
if _touches.size() == 1:
|
||
_orbit_touch = _touches.keys()[0] # 抬起一指后,剩下的接管环绕
|
||
_orbit_active = false
|
||
_orbit_press = _touches[_orbit_touch]
|
||
elif _touches.is_empty():
|
||
_orbit_touch = -1
|
||
|
||
func _on_drag(e: InputEventScreenDrag) -> void:
|
||
if _touches.has(e.index):
|
||
_touches[e.index] = e.position
|
||
if _touches.size() >= 2:
|
||
var d := _pinch_dist()
|
||
if _pinch_last > 0.0:
|
||
# 两指张开(d 变大)-> 拉近;捏拢 -> 推远
|
||
dist = clampf(dist + (_pinch_last - d) * pinch_zoom_gain, min_dist, max_dist)
|
||
_pinch_last = d
|
||
elif e.index == _orbit_touch:
|
||
if not _orbit_active and e.position.distance_to(_orbit_press) > _ORBIT_DEADZONE:
|
||
_orbit_active = true
|
||
if _orbit_active:
|
||
yaw -= e.relative.x * touch_sensitivity
|
||
pitch = clampf(pitch + e.relative.y * touch_sensitivity, min_pitch, max_pitch)
|
||
|
||
func _pinch_dist() -> float:
|
||
var ks := _touches.keys()
|
||
if ks.size() < 2:
|
||
return 0.0
|
||
return (_touches[ks[0]] as Vector2).distance_to(_touches[ks[1]] as Vector2)
|
||
|
||
func heading() -> float:
|
||
# 相机方位角:相机在 head + (sin yaw, cos yaw) 一侧,视线水平前方是 -(sin yaw, cos yaw)。
|
||
return yaw
|
||
|
||
func is_event_locked() -> bool:
|
||
return camera_mode != CameraMode.NORMAL
|
||
|
||
func set_key_orbit(direction: int) -> void:
|
||
_key_orbit = clampi(direction, -1, 1)
|
||
|
||
func set_key_zoom(direction: int) -> void:
|
||
_key_zoom = clampi(direction, -1, 1)
|
||
|
||
func set_key_pitch(direction: int) -> void:
|
||
_key_pitch = clampi(direction, -1, 1)
|
||
|
||
func _desired_pos(snap := false) -> Vector3:
|
||
var head := _look_target()
|
||
var off := Vector3(
|
||
sin(yaw) * cos(pitch),
|
||
sin(pitch),
|
||
cos(yaw) * cos(pitch)) * dist
|
||
var want := head + off + _event_offset
|
||
if is_event_locked():
|
||
# Event cameras are deliberately fixed shots. The reference event
|
||
# camera does not chase the actor while a quest is being presented.
|
||
return want
|
||
# 地形防穿:沿 head->want 采样,低于地表就把相机拉回
|
||
if world and world.has_method("sample_height"):
|
||
var d := want - head
|
||
var steps := 8
|
||
for i in range(1, steps + 1):
|
||
var p := head + d * (float(i) / steps)
|
||
var gy: float = world.call("sample_height", p.x, p.z) + 0.4
|
||
if p.y < gy:
|
||
want = head + d * (float(i - 1) / steps)
|
||
break
|
||
# §8.3 建筑防穿:head->want 射线撞静态遮挡物就把相机拉到撞点前
|
||
var world3d := get_world_3d()
|
||
if world3d:
|
||
var q := PhysicsRayQueryParameters3D.create(head, want, OCCLUDER_MASK)
|
||
var hit := world3d.direct_space_state.intersect_ray(q)
|
||
if hit:
|
||
var n := (want - head).normalized()
|
||
want = hit.position - n * 0.3
|
||
return want
|
||
|
||
func _look_target() -> Vector3:
|
||
if is_event_locked():
|
||
return _event_center
|
||
if target:
|
||
return target.global_position + head_offset
|
||
return global_position
|
||
|
||
func _normal_setting() -> Dictionary:
|
||
var centre := target.global_position if target else Vector3.ZERO
|
||
var server := MapCoord.to_server_cm(centre)
|
||
return {
|
||
"x": server.x, "y": server.y, "z": centre.y * 100.0,
|
||
"up": 0.0, "view": 0.0, "cross": 0.0,
|
||
"distance": dist * 100.0,
|
||
"rot": rad_to_deg(yaw),
|
||
"pitch": rad_to_deg(pitch),
|
||
}
|
||
|
||
static func _setting_value(setting: Dictionary, key: String, fallback: float) -> float:
|
||
if not setting.has(key):
|
||
return fallback
|
||
return float(setting[key])
|
||
|
||
func _setting_world_center(setting: Dictionary) -> Vector3:
|
||
# SCameraSetting uses the same x/y ground plane and z-up convention as
|
||
# server positions. MapCoord is the single source of truth for the map
|
||
# origin and the Y/Z handedness conversion.
|
||
var sx := _setting_value(setting, "x", 0.0)
|
||
var sy := _setting_value(setting, "y", 0.0)
|
||
var sz := _setting_value(setting, "z", DEFAULT_HEIGHT_CM)
|
||
return MapCoord.to_world(Vector3(sx * 0.01, sz * 0.01, -sy * 0.01))
|
||
|
||
func _runtime_setting(setting: Dictionary) -> Dictionary:
|
||
var s := setting.duplicate(true)
|
||
s["_world_center"] = _setting_world_center(s)
|
||
s["distance"] = clampf(_setting_value(s, "distance", DEFAULT_DISTANCE_CM) * 0.01,
|
||
min_dist, max_dist)
|
||
s["pitch"] = clampf(deg_to_rad(_setting_value(s, "pitch", DEFAULT_PITCH_DEG)),
|
||
min_pitch, max_pitch)
|
||
s["rot"] = deg_to_rad(_setting_value(s, "rot", DEFAULT_ROTATION_DEG))
|
||
s["up"] = _setting_value(s, "up", 0.0) * 0.01
|
||
s["view"] = _setting_value(s, "view", 0.0) * 0.01
|
||
s["cross"] = _setting_value(s, "cross", 0.0) * 0.01
|
||
return s
|
||
|
||
func _apply_setting(setting: Dictionary) -> void:
|
||
var s := _runtime_setting(setting)
|
||
yaw = s["rot"]
|
||
pitch = s["pitch"]
|
||
dist = s["distance"]
|
||
_event_offset = Vector3(s["cross"], s["up"], s["view"])
|
||
|
||
func _setting_for_blend() -> Dictionary:
|
||
if camera_mode == CameraMode.NORMAL:
|
||
return _normal_setting()
|
||
return _event_setting.duplicate(true)
|
||
|
||
func _lerp_setting(a: Dictionary, b: Dictionary, t: float) -> Dictionary:
|
||
var out := {}
|
||
for key in ["x", "y", "z", "up", "view", "cross", "distance", "rot", "pitch"]:
|
||
out[key] = lerpf(_setting_value(a, key, 0.0), _setting_value(b, key, 0.0), t)
|
||
return out
|
||
|
||
# CPythonApplication::SetEventCamera. The first event preserves the player's
|
||
# current normal settings so RESTORE_CAMERA returns to the user's view.
|
||
func set_event_camera(setting: Dictionary) -> void:
|
||
if camera_mode == CameraMode.NORMAL:
|
||
_default_setting = _normal_setting()
|
||
_event_setting = setting.duplicate(true)
|
||
_event_center = _setting_world_center(_event_setting)
|
||
_apply_setting(_event_setting)
|
||
camera_mode = CameraMode.STAND
|
||
_pos_ready = false
|
||
|
||
# CPythonApplication::BlendEventCamera. `blendtime` follows the reference
|
||
# camera API and is expressed in seconds (the event parser passes it through).
|
||
func blend_event_camera(setting: Dictionary, blendtime: float) -> void:
|
||
_blend_from = _setting_for_blend()
|
||
_blend_to = setting.duplicate(true)
|
||
_blend_elapsed = 0.0
|
||
_blend_duration = maxf(0.0, blendtime)
|
||
_event_setting = _blend_from.duplicate(true)
|
||
_event_center = _setting_world_center(_blend_from)
|
||
camera_mode = CameraMode.BLEND
|
||
_event_offset = Vector3.ZERO
|
||
_event_center = Vector3.ZERO
|
||
if _blend_duration <= 0.0:
|
||
_event_setting = _blend_to.duplicate(true)
|
||
_event_center = _setting_world_center(_event_setting)
|
||
_apply_setting(_event_setting)
|
||
camera_mode = CameraMode.STAND
|
||
_pos_ready = false
|
||
|
||
func set_default_camera() -> void:
|
||
camera_mode = CameraMode.NORMAL
|
||
_blend_elapsed = 0.0
|
||
_blend_duration = 0.0
|
||
_event_offset = Vector3.ZERO
|
||
if not _default_setting.is_empty():
|
||
_apply_setting(_default_setting)
|
||
_default_setting = {}
|
||
_event_setting = {}
|
||
_pos_ready = false
|
||
|
||
func get_camera_setting() -> Dictionary:
|
||
var s := _setting_for_blend()
|
||
# During BLEND, expose the interpolated frame, matching GetCameraSetting's
|
||
# use by a subsequent camera event.
|
||
if camera_mode == CameraMode.BLEND:
|
||
var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0)
|
||
s = _lerp_setting(_blend_from, _blend_to, t)
|
||
return s
|
||
|
||
func snap_to_target() -> void:
|
||
if target == null:
|
||
return
|
||
_pos_ready = false
|
||
if camera_mode == CameraMode.NORMAL:
|
||
global_position = _desired_pos(true)
|
||
else:
|
||
global_position = _desired_pos(true)
|
||
_look_at_safe()
|
||
|
||
# 相机位置与注视点重合时(首帧、传送落点、极近距离)方向向量为零,look_at() 会
|
||
# 报错并且不改变朝向。跳过这一帧,下一帧插值分开后自然恢复。
|
||
func _look_at_safe() -> void:
|
||
var look_to := _look_target()
|
||
if not global_position.is_equal_approx(look_to):
|
||
look_at(look_to, Vector3.UP)
|
||
|
||
var _shake := 0.0 # 当前抖动强度(米),指数衰减
|
||
var _shake_decay := 8.0
|
||
|
||
# P4:受击 / 暴击轻抖。strength 米,decay 越大越快停。
|
||
func shake(strength: float, decay := 8.0) -> void:
|
||
_shake = maxf(_shake, strength)
|
||
_shake_decay = decay
|
||
|
||
func _process(dt: float) -> void:
|
||
if target == null:
|
||
return
|
||
if camera_mode == CameraMode.BLEND:
|
||
_blend_elapsed += maxf(0.0, dt)
|
||
var t := 1.0 if _blend_duration <= 0.0 else clampf(_blend_elapsed / _blend_duration, 0.0, 1.0)
|
||
_event_setting = _lerp_setting(_blend_from, _blend_to, clampf(t, 0.0, 1.0))
|
||
_event_center = _setting_world_center(_event_setting)
|
||
_apply_setting(_event_setting)
|
||
if t >= 1.0:
|
||
camera_mode = CameraMode.STAND
|
||
_event_setting = _blend_to.duplicate(true)
|
||
if camera_mode == CameraMode.NORMAL and _key_orbit != 0:
|
||
yaw += float(_key_orbit) * dt * 1.8
|
||
if camera_mode == CameraMode.NORMAL and _key_zoom != 0:
|
||
dist = clampf(dist + float(_key_zoom) * dt * 5.0, min_dist, max_dist)
|
||
if camera_mode == CameraMode.NORMAL and _key_pitch != 0:
|
||
pitch = clampf(pitch + float(_key_pitch) * dt * 1.2, min_pitch, max_pitch)
|
||
var want := _desired_pos()
|
||
if _pos_ready:
|
||
global_position = global_position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0))
|
||
else:
|
||
global_position = want
|
||
_pos_ready = true
|
||
if _shake > 0.001:
|
||
global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1), randf_range(-1, 1)) * _shake
|
||
_shake = lerpf(_shake, 0.0, clampf(_shake_decay * dt, 0.0, 1.0))
|
||
else:
|
||
_shake = 0.0
|
||
_look_at_safe()
|
||
if camera_mode == CameraMode.NORMAL:
|
||
_fade_occluders()
|
||
|
||
# §8.2 相机与玩家之间的建筑半透明淡出。沿 cam->head 逐段射线,逐个 hit 淡出,
|
||
# 移过撞点继续;本帧没被挡到的恢复不透明。
|
||
func _fade_occluders() -> void:
|
||
var world3d := get_world_3d()
|
||
if world3d == null:
|
||
return
|
||
var head := target.global_position + head_offset
|
||
var from := global_position
|
||
var this_frame: Array[Node] = []
|
||
var ss := world3d.direct_space_state
|
||
var exclude: Array[RID] = []
|
||
for _i in 4:
|
||
var q := PhysicsRayQueryParameters3D.create(from, head, OCCLUDER_MASK)
|
||
q.exclude = exclude
|
||
var hit := ss.intersect_ray(q)
|
||
if hit.is_empty():
|
||
break
|
||
exclude.append(hit.rid)
|
||
# StaticBody3D 上的 meta 指向它的 MeshInstance3D(Metin2World 设的)
|
||
var body: Object = hit.collider
|
||
var gi: Node = body.get_meta("occ_mesh", null) if body and body.has_meta("occ_mesh") else null
|
||
if gi and gi is GeometryInstance3D and not this_frame.has(gi):
|
||
(gi as GeometryInstance3D).transparency = FADE_ALPHA
|
||
this_frame.append(gi)
|
||
from = hit.position + (head - from).normalized() * 0.05
|
||
for g in _faded:
|
||
if is_instance_valid(g) and not this_frame.has(g):
|
||
g.transparency = 0.0
|
||
_faded = this_frame
|