Files
mtgodot-poc/project/net_play.gd
T

340 lines
12 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.
# 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
var _observer_mode := false
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)
if client.has_signal("observer_mode_changed"):
client.observer_mode_changed.connect(_on_observer_mode)
if client.has_method("is_observer_mode"):
_observer_mode = bool(client.is_observer_mode())
func _on_observer_mode(enabled: bool) -> void:
_observer_mode = enabled
if enabled:
_target_vid = 0
_attack_cd = 0.0
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
# --- 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 _observer_mode or 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 _observer_mode:
return
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_stamina"):
hud.set_stamina(int(p.get("stamina", 0)), int(p.get("max_stamina", 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:
if _observer_mode:
return
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 _observer_mode or 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)