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
+319
View File
@@ -0,0 +1,319 @@
# NetPlay (P0) —— 本地输入 ←→ 网络的闭环胶水。
#
# 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s
# 停下 -> M2Client.move(FUNC_WAIT, …)
# 点实体 -> M2Client.set_target(vid);进入攻击距离后按攻速自动 attack()
# 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp
# points_changed / vitals_changed / target_info -> HUD
#
# 需要:M2Client、PlayerController`player_controller.gd`)、NetWorld`net_world.gd`)。
# 可选:HUD(有 set_vitals / set_exp / set_level / set_target / clear_target 就调)。
#
# 用法:
# var np := preload("res://net_play.gd").new()
# add_child(np)
# np.setup(m2client, player_ctl, net_world, hud)
extends Node
signal target_changed(vid: int)
const FUNC_WAIT := 0
const FUNC_MOVE := 1
const CM := 100.0 # 米 -> 厘米
const SEND_INTERVAL := 0.2 # 移动包最小间隔(秒)
const SEND_DIST := 1.0 # 或位移超过这么多米立即发
const ATTACK_RANGE := 2.5 # 米
const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移
const CORRECTION_LERP := 0.25 # 否则每次校正插值比例
var client: Node # M2Client
var pc: Node # PlayerController
var net_world: Node # NetWorld
var hud: Node # 可空
var attack_motion := 0 # 普通攻击 motion indexP4 再按 race 细分)
const DEFAULT_ATTACK_PERIOD := 0.6 # 秒/次,100 = 原始动作速率
var attack_period := DEFAULT_ATTACK_PERIOD
var camera: Node # GameCamera(可空,用于受击抖屏)
var player_view: Node # PlayerView(可空,set_anim_state("damage")
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
const HITSTUN := 0.32 # 受击硬直秒数(锁输入)
const COMBO_WINDOW := 0.9 # 连击窗口
var _target_vid := 0
var _main_vid := 0
var _last_sent_pos := Vector3.ZERO
var _last_sent_t := 0.0
var _was_moving := false
var _attack_cd := 0.0
var _hitstun_until := 0.0
var _combo := 0
var _last_attack_t := 0.0
func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
client = m2client
pc = player_ctl
net_world = nw
hud = hud_node
pc.target_selected.connect(_on_pick)
pc.moved.connect(_on_local_moved)
pc.anim_state.connect(_on_anim_state)
client.entity_main_set.connect(_on_main_set)
client.entity_moved.connect(_on_net_moved)
if client.has_signal("entity_info"):
client.entity_info.connect(_on_entity_info)
client.points_changed.connect(_on_points)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(func(v): if v == _target_vid: _clear_target())
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
# --- helpers --------------------------------------------------------------
func _vid_of(node: Object) -> int:
if node and node.has_meta("vid"):
return int(node.get_meta("vid"))
return 0
# 玩家节点在 Metin2World 本地帧 -> 服务器全局 cm(含 BasePosition
func _server_xy(world_pos: Vector3) -> Vector2:
return MapCoord.to_server_cm(world_pos)
func _heading_deg(yaw_rad: float) -> float:
return MapCoord.yaw_to_heading(yaw_rad)
func _player_yaw() -> float:
return pc.player.rotation.y if pc.player else 0.0
# CHRTYPE 分类:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP。
# 本 fork 的 GC_CHARACTER_ADD.bType 对 NPC/怪都是 0,按 race 查 mob_proto 补。
func _entity_kind(e: Dictionary) -> int:
var ct := int(e.get("ch_type", 0))
if ct != 0:
return ct
var race := int(e.get("race", 0))
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
# mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
match int(m.get("type", 0)):
0: return 2
1: return 1
2: return 3
3: return 4
return 0
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
func _entity_name(e: Dictionary) -> String:
var nm := String(e.get("name", ""))
if nm != "":
return nm
var race := int(e.get("race", 0))
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
return String(m.get("locale_name", m.get("name", "")))
return ""
# --- movement ----------------------------------------------------------------
func _on_local_moved(pos: Vector3) -> void:
if client == null or not client.is_in_game():
return
var now := Time.get_ticks_msec() / 1000.0
if pos.distance_to(_last_sent_pos) < SEND_DIST and now - _last_sent_t < SEND_INTERVAL:
return
var xy := _server_xy(pos)
client.move(FUNC_MOVE, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y))
_last_sent_pos = pos
_last_sent_t = now
_was_moving = true
func _on_anim_state(state: String) -> void:
if state == "wait" and _was_moving:
_was_moving = false
if client and client.is_in_game() and pc.player:
var xy := _server_xy(pc.player.position)
client.move(FUNC_WAIT, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y))
func _on_main_set(vid: int) -> void:
_main_vid = vid
if client and client.has_method("get_entity"):
_on_entity_info(vid, client.get_entity(vid))
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(vid)
func _on_entity_info(vid: int, entity: Dictionary) -> void:
if vid != _main_vid:
return
if pc and pc.has_method("set_server_speed"):
var speed := int(entity.get("moving_speed", 0))
if speed > 0:
pc.set_server_speed(speed)
_apply_attack_speed(int(entity.get("attack_speed", 0)))
func _apply_attack_speed(speed: int) -> void:
if speed > 0:
# m2dev applies bAttackSpeed / 100.0 to motion playback. Use the same
# factor for the request cadence while keeping a playable safety range.
var factor := clampf(float(speed) / 100.0, 0.25, 3.0)
attack_period = DEFAULT_ATTACK_PERIOD / factor
func _on_points(p: Dictionary) -> void:
_apply_attack_speed(int(p.get("attack_speed", 0)))
if hud == null:
return
if hud.has_method("set_vitals"):
hud.set_vitals(int(p.get("hp", 0)), int(p.get("max_hp", 0)),
int(p.get("sp", 0)), int(p.get("max_sp", 0)))
if hud.has_method("set_exp"):
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
if hud.has_method("set_level"):
hud.set_level(int(p.get("level", 0)))
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
func _on_net_moved(vid: int) -> void:
if client == null or vid != _main_vid or pc.player == null:
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
var d: float = pc.player.position.distance_to(sp)
if d > CORRECTION_SNAP:
pc.player.position = sp
elif d > 0.3:
pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
# --- targeting / attack -----------------------------------------------------
func _on_pick(node: Node3D) -> void:
var vid := _vid_of(node)
if vid == 0 or vid == _main_vid:
return
# NPC / 石头 / warp -> 交互,不是攻击目标
var e: Dictionary = client.get_entity(vid)
if _entity_kind(e) in [1, 3, 4]:
client.click_npc(vid)
return
_target_vid = vid
_attack_cd = 0.0
client.set_target(vid)
target_changed.emit(vid)
_refresh_target_hud()
func _on_target_info(vid: int, hp_pct: int) -> void:
if vid == 0:
_clear_target()
return
_target_vid = vid
if hud and hud.has_method("set_target"):
var e: Dictionary = client.get_entity(vid)
var nm := _entity_name(e) if not e.is_empty() else ""
hud.set_target(nm if nm != "" else "目标", hp_pct)
func _clear_target() -> void:
if _target_vid == 0:
return
_target_vid = 0
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
# 受击:自己是 victim 且非闪避 -> 硬直(锁输入)+ 播 damage + 抖屏
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid != _main_vid:
return
var crit := (flag & DAMAGE_CRITICAL) != 0
if camera and camera.has_method("shake"):
camera.shake(0.08 if crit else 0.04, 10.0)
if (flag & DAMAGE_DODGE) != 0:
return
_hitstun_until = _now() + HITSTUN
if pc and "force_run" in pc:
pass
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("damage")
# GC_FISHING motion events for the local player. `info` is the fisher VID for
# START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only.
func _on_fishing_event(subheader: int, info: int, _dir: int) -> void:
if info != _main_vid or player_view == null or not player_view.has_method("set_anim_state"):
return
if subheader == 5:
return
var state := "fishing"
match subheader:
1: state = "wait"
2: state = "fishing_react"
3: state = "fishing_catch"
4: state = "fishing_fail"
player_view.set_anim_state(state)
func is_stunned() -> bool:
return _now() < _hitstun_until
func _process(dt: float) -> void:
if pc:
pc.set("frozen", is_stunned())
if client == null or not client.is_in_game() or _target_vid == 0 or pc == null:
return
if is_stunned():
return # 硬直:不发攻击 / 移动
var tnode: Node3D = net_world.node_for(_target_vid) if net_world else null
if tnode == null or pc.player == null:
return
var te: Dictionary = client.get_entity(_target_vid)
if te.get("dead", false):
_clear_target()
return
_attack_cd -= dt
var dist: float = pc.player.global_position.distance_to(tnode.global_position)
if dist <= ATTACK_RANGE and _attack_cd <= 0.0:
var to: Vector3 = tnode.global_position - pc.player.global_position
if to.length() > 0.01:
pc.player.rotation.y = atan2(to.x, to.z)
# combo:连击窗口内递增 motion(0/1/2 循环),超时归零
var now := _now()
_combo = (_combo + 1) % 3 if (now - _last_attack_t) < COMBO_WINDOW else 0
_last_attack_t = now
client.attack(attack_motion + _combo, _target_vid)
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if _combo > 0 else "attack")
_attack_cd = attack_period
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
# --- HUD -----------------------------------------------------------------
func _on_vitals(vid: int) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
if vid == _main_vid and hud and hud.has_method("set_vitals"):
hud.set_vitals(int(e.get("hp", 0)), int(e.get("max_hp", 0)),
int(e.get("sp", 0)), int(e.get("max_sp", 0)))
if vid == _target_vid:
_refresh_target_hud()
func _refresh_target_hud() -> void:
if hud == null or not hud.has_method("set_target"):
return
var e: Dictionary = client.get_entity(_target_vid)
if e.is_empty():
return
var mh := int(e.get("max_hp", 0))
var pct := int(100.0 * float(e.get("hp", 0)) / float(mh)) if mh > 0 else 0
hud.set_target(_entity_name(e), pct)