Files
mtgodot-poc/project/game_camera.gd
T

385 lines
14 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.
# 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 := 1550.0
const DEFAULT_PITCH_DEG := 27.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:
fov = 55.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:
# 相机看向的水平方向(供角色移动「相对相机」用)
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(_look_target(), 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(_look_target(), Vector3.UP)
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 指向它的 MeshInstance3DMetin2World 设的)
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