Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+191
View File
@@ -0,0 +1,191 @@
# 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 = 未在捏合
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 _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
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 指向它的 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