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
320 lines
11 KiB
GDScript
320 lines
11 KiB
GDScript
# 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 index(P4 再按 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)
|