# 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 var target: Node3D var world: Node # Metin2World,用 sample_height 防穿(可空) var head_offset := Vector3(0, 1.15, 0) var yaw := 0.7 var pitch := 0.55 var dist := 8.0 var min_pitch := deg_to_rad(12.0) var max_pitch := deg_to_rad(78.0) var min_dist := 3.5 var max_dist := 20.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 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 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 elif e.button_index == MOUSE_BUTTON_WHEEL_UP and e.pressed: dist = maxf(min_dist, dist - zoom_step) elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN and e.pressed: dist = minf(max_dist, dist + zoom_step) elif e is InputEventMouseMotion and _dragging: 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 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 := target.global_position + head_offset var off := Vector3( sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch)) * dist var want := head + off # 地形防穿:沿 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 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 _key_orbit != 0: yaw += float(_key_orbit) * dt * 1.8 if _key_zoom != 0: dist = clampf(dist + float(_key_zoom) * dt * 5.0, min_dist, max_dist) if _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(target.global_position + head_offset, Vector3.UP) _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