Files
mtgodot-poc/project/game_camera.gd
T
shenleiandClaude Code 8092ff44e2 装备校验链/快捷栏/公会/好友情侣/交易仓库五项差距修复(增量 129)
- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排,
  新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS),
  帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案
- 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、
  物品槽失配置灰(服务器回收/换武器后刷新)
- 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、
  被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1)
- 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/
  near/far/divorce 命令事件→在线/在身边状态与离婚隐藏
- 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示;
  仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close;
  仓库金钱经核实 40250 无 wire 协议,UI 定界只读
- app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫
- 相机 look_at 零向量守卫(首帧/传送落点)
- 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为
  HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路)
- docs/CLIENT-GAP.md 同步增量 129

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
2026-09-07 21:43:30 +09:00

393 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 := 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:
# 相机看向的水平方向(供角色移动「相对相机」用)
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 指向它的 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