# 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