# 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 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)