Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.
Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:
- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
(§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).
Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
367 lines
16 KiB
C++
367 lines
16 KiB
C++
#pragma once
|
|
// M2Client — GDExtension Node wrapping the Metin2 net client. GDScript drives it:
|
|
//
|
|
// var c = M2Client.new()
|
|
// add_child(c)
|
|
// c.phase_changed.connect(func(p): print("phase ", p))
|
|
// c.char_list.connect(func(list): c.select_character(list[0]["index"]))
|
|
// c.entered_game.connect(func(): print("in game"))
|
|
// c.connect_to_server("192.168.21.203", 11000, "192.168.21.203", 11011,
|
|
// "admin", "123456789")
|
|
//
|
|
// Orchestration: AuthClient(auth_host:auth_port) -> on auth_ok, connect
|
|
// GameClient(game_host:game_port) which does its own KX handshake, sends
|
|
// CG_LOGIN2, surfaces the character list, then select_character() drives to the
|
|
// game phase. _process() pumps whichever stream is active.
|
|
|
|
#include <godot_cpp/classes/image.hpp>
|
|
#include <godot_cpp/classes/node.hpp>
|
|
#include <godot_cpp/classes/ref.hpp>
|
|
#include <godot_cpp/variant/array.hpp>
|
|
#include <godot_cpp/variant/dictionary.hpp>
|
|
#include <godot_cpp/variant/packed_byte_array.hpp>
|
|
#include <godot_cpp/variant/string.hpp>
|
|
|
|
#include <memory>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
namespace mtnet {
|
|
class AuthClient;
|
|
class GameClient;
|
|
class MarkClient;
|
|
class MarkImageSet;
|
|
class EntityStore;
|
|
namespace classic {
|
|
class ClassicSession;
|
|
class ClassicMarkClient;
|
|
}
|
|
} // namespace mtnet
|
|
|
|
namespace mtgodot {
|
|
|
|
class M2Client : public godot::Node {
|
|
GDCLASS(M2Client, godot::Node)
|
|
|
|
public:
|
|
M2Client();
|
|
~M2Client() override;
|
|
|
|
void _process(double delta) override;
|
|
void _notification(int what);
|
|
|
|
// host/port for auth and game servers; account credentials.
|
|
void connect_to_server(const godot::String &auth_host, int auth_port,
|
|
const godot::String &game_host, int game_port, const godot::String &id,
|
|
const godot::String &pw);
|
|
void disconnect_from_server();
|
|
|
|
// pick a slot from the char_list payload (its "index" field).
|
|
bool select_character(int index);
|
|
// Enter a character through the stock two-connection direct-enter flow.
|
|
bool enter_game(int index);
|
|
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
|
|
bool create_character(int slot, const godot::String &name, int job, int shape,
|
|
int con, int intel, int str, int dex);
|
|
bool delete_character(int slot, const godot::String &private_code);
|
|
// Rename a character slot with a server-side rename card.
|
|
bool change_name(int slot, const godot::String &name);
|
|
// GC_EMPIRE (0 = server still wants an empire pick before select).
|
|
int get_empire() const;
|
|
// CG_EMPIRE: choose one of the three empires when the account has no empire.
|
|
// Both network backends expose the same intent for the character-select UI.
|
|
bool select_empire(int empire);
|
|
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
|
|
int get_slot_count() const;
|
|
|
|
// --- in-game intents (mirror CPythonNetworkStream) ---
|
|
// rot_deg is a compass heading in degrees; wire form is rot_deg/5 like the
|
|
// original client. x/y are server cm. time is filled from the frame clock.
|
|
bool move(int func, int arg, double rot_deg, int x, int y);
|
|
// Sends CG_CHARACTER_POSITION (legacy posture/position enum, 0..255).
|
|
bool character_position(int position);
|
|
// Sends a CG_SYNC_POSITION batch. Each entry is {vid, x, y} in server cm;
|
|
// the legacy packet allows at most 16 entries.
|
|
bool sync_positions(const godot::Array &positions);
|
|
bool request_warp();
|
|
bool fishing(double rot_deg);
|
|
bool request_dungeon();
|
|
bool attack(int motion, int victim_vid);
|
|
bool set_target(int victim_vid);
|
|
bool say(int type, const godot::String &text);
|
|
bool whisper(const godot::String &to, const godot::String &text);
|
|
// cast: CG_MOVE with func = FUNC_SKILL(0x80) | (motion_idx & 0x7F).
|
|
bool cast_skill(int motion_idx, double rot_deg, int x, int y);
|
|
// Real skill intent. For a tracked target, sends CG_FLY_TARGETING before
|
|
// CG_USE_SKILL, matching the legacy client packet order.
|
|
bool use_skill(int skill_id, int target_vid);
|
|
// Ranged animation events and area-target selection use separate packets.
|
|
bool shoot(int skill_id);
|
|
bool add_fly_targeting(int target_vid, int x, int y);
|
|
// server-driven quest command: "/skillup <skill_id>".
|
|
bool skill_up(int skill_id);
|
|
int get_skill_group() const;
|
|
godot::Array get_skills() const; // [{id, level, master}] for skills the player has
|
|
godot::Array get_quickslots() const; // [{pos, type, ref}] restored quickslots
|
|
bool quickslot_add(int pos, int type, int ref);
|
|
bool quickslot_del(int pos);
|
|
bool quickslot_swap(int pos, int change_pos);
|
|
|
|
// quest / NPC
|
|
bool click_npc(int vid);
|
|
bool script_answer(int answer); // dialog choice (0..N-1) or 255 = continue
|
|
bool script_button(int idx); // quest-log button
|
|
bool script_select_item(int selection); // inventory cell/item position
|
|
bool quest_input(const godot::String &text);
|
|
bool quest_confirm(bool yes, int request_pid);
|
|
bool quest_cancel();
|
|
godot::Array get_quests() const; // [{index, title, counter_name, counter_value, ...}]
|
|
|
|
// --- P8 party ---
|
|
bool party_invite(int vid);
|
|
bool party_answer(int leader_pid, bool accept);
|
|
bool party_leave(int pid); // expel <pid>, or your own pid to leave
|
|
bool party_use_skill(int skill_index, int target_vid);
|
|
bool party_set_distribute(int mode);
|
|
bool party_set_state(int pid, int role, bool on); // CG_PARTY_SET_STATE (role = PARTY_ROLE_*)
|
|
godot::Array get_party() const; // [{pid, vid, name, leader, hp_pct, state, affects[7]}]
|
|
int get_party_distribute_mode() const;
|
|
|
|
// --- P8 messenger / friends ---
|
|
bool add_friend(const godot::String &name);
|
|
bool remove_friend(const godot::String &name);
|
|
bool friend_answer(const godot::String &name, bool accept);
|
|
godot::Array get_friends() const; // [{name, online}]
|
|
godot::Dictionary get_lover() const; // {valid, name, love_point}
|
|
|
|
// --- P8 NPC shop ---
|
|
bool shop_buy(int pos, int count);
|
|
bool shop_sell(int inv_cell, int count);
|
|
bool shop_close();
|
|
bool is_shop_open() const;
|
|
godot::Array get_shop_items() const; // [{pos, vnum, price, count}] — tab 0
|
|
godot::Dictionary get_shop() const; // {vid, open, tabs:[{name, coin_type, items:[...]}]}
|
|
|
|
// --- P8 exchange / trade ---
|
|
bool exchange_start(int vid);
|
|
bool exchange_add_item(int inv_window, int inv_cell, int display_pos);
|
|
bool exchange_add_gold(int gold);
|
|
bool exchange_accept();
|
|
bool exchange_cancel();
|
|
godot::Dictionary get_exchange() const; // {active, partner_vid, self_items, peer_items, ...}
|
|
|
|
// --- P8 safebox / storage ---
|
|
bool safebox_checkin(int safe_pos, int inv_window, int inv_cell);
|
|
bool safebox_checkout(int safe_pos, int inv_window, int inv_cell);
|
|
bool safebox_move(int from_cell, int to_cell, int count);
|
|
bool is_safebox_open() const;
|
|
int get_safebox_size() const;
|
|
int get_safebox_gold() const;
|
|
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
|
|
bool safebox_password(const godot::String &password);
|
|
|
|
// --- item-mall (창고몰) ---
|
|
bool is_mall_open() const;
|
|
int get_mall_size() const;
|
|
godot::Array get_mall_items() const; // [{cell, vnum, count}]
|
|
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
|
|
bool mall_password(const godot::String &password);
|
|
|
|
// --- private (PC) shop ---
|
|
// items: Array of {vnum, count, inv_cell, price, display_pos}
|
|
bool open_private_shop(const godot::String &sign, const godot::Array &items);
|
|
bool close_private_shop();
|
|
|
|
// --- cube (제작) ---
|
|
godot::Dictionary get_cube() const; // {open, npc_vnum, need_gold, recipes:[...]}
|
|
bool cube_make(int result_index);
|
|
bool cube_request_result_list(int npc_vnum);
|
|
bool cube_request_materials(int start_index, int count);
|
|
bool cube_open();
|
|
bool cube_close();
|
|
bool cube_list();
|
|
bool cube_add_item(int cube_index, int inventory_index);
|
|
bool cube_delete_item(int cube_index);
|
|
|
|
// --- guild ---
|
|
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
|
|
godot::Array get_guild_members() const; // [{pid, name, grade, job, level, offer, general}]
|
|
godot::Array get_guild_grades() const; // [{name, auth}] index = grade
|
|
bool guild_add_member(int vid);
|
|
bool guild_remove_member(int pid);
|
|
bool guild_offer(int amount);
|
|
bool guild_charge_gsp(int amount);
|
|
bool guild_change_grade_name(int grade, const godot::String &name);
|
|
bool guild_change_grade_authority(int grade, int authority);
|
|
bool guild_change_member_grade(int pid, int grade);
|
|
bool guild_change_member_general(int pid, bool enabled);
|
|
bool guild_post_comment(const godot::String &text);
|
|
bool guild_delete_comment(int comment_id);
|
|
bool guild_refresh_comments();
|
|
bool guild_answer_invite(int guild_id, bool accept);
|
|
bool guild_answer_make(const godot::String &name);
|
|
godot::Array get_guild_comments() const; // [{id, name, content}]
|
|
|
|
// --- guild war / guild skill ---
|
|
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
|
|
godot::Array get_guild_wars() const; // [{src, dst, src_name, dst_name}] active GvG
|
|
godot::Dictionary get_guild_war() const; // {opp_guild_id, opp_name, type, state}
|
|
godot::String get_guild_name(int guild_id) const;
|
|
bool use_guild_skill(int skill_vnum, int target_vid);
|
|
bool declare_guild_war(const godot::String &guild_name); // sends "/war <name>"
|
|
|
|
// --- guild marks (会徽) ---
|
|
// Opens the side connection to `host:port` and pulls the mark images using
|
|
// the handle/random_key from login. `guild_marks_ready` fires when done.
|
|
// A port of 0 skips the query. Safe to call again on `guild_mark_updated`.
|
|
bool download_guild_marks(const godot::String &host, int port);
|
|
// Download one raw guild-symbol file via the mark side connection.
|
|
bool download_guild_symbol(const godot::String &host, int port, int guild_id);
|
|
// Raw bytes from the last completed download_guild_symbol call.
|
|
godot::PackedByteArray get_guild_symbol() const;
|
|
bool are_guild_marks_ready() const;
|
|
// {host, port} last used for a mark connection (0 port = none configured yet).
|
|
godot::Dictionary get_mark_server() const;
|
|
// Upload this guild's 16x12 mark (a godot::Image, converted/resized as needed).
|
|
// `guild_mark_uploaded(ok)` fires when the packet has left the socket.
|
|
bool upload_guild_mark(const godot::String &host, int port, int guild_id,
|
|
const godot::Ref<godot::Image> &img);
|
|
// Upload the raw bytes of a guild-symbol image file (server validates 64x128).
|
|
bool upload_guild_symbol(const godot::String &host, int port, int guild_id,
|
|
const godot::PackedByteArray &file_bytes);
|
|
// {found, img_idx, x, y, w, h} — position of a guild's 16x12 mark in its image.
|
|
godot::Dictionary get_guild_mark(int guild_id) const;
|
|
// A 16x12 RGBA8 godot::Image for a guild's mark, or an empty (null) Ref if we
|
|
// have no mark for it yet.
|
|
godot::Ref<godot::Image> get_guild_mark_image(int guild_id) const;
|
|
|
|
// --- refine / upgrade ---
|
|
bool refine(int pos, int type); // confirm refine of the item at inventory `pos`
|
|
|
|
// --- dragon soul refine ---
|
|
// mode: 0 = 升级 (upgrade), 1 = 改良 (improvement), 2 = 精炼 (refine).
|
|
// cells: inventory cells; cells[0] = the dragon soul, cells[1..] = materials (<=15 total).
|
|
bool ds_refine(int mode, const godot::Array &cells);
|
|
godot::Array get_dragon_souls() const; // [{cell, vnum, count}] non-empty DS-window slots
|
|
|
|
// --- P9 world systems ---
|
|
int get_channel() const;
|
|
int64_t get_server_time() const; // unix seconds as of last GC_TIME
|
|
godot::Array get_npc_marks() const; // [{type, vnum, name, pos}] pos = Godot metres
|
|
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
|
|
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
|
|
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
|
|
bool is_observer_mode() const;
|
|
int get_observer_count() const;
|
|
bool has_mobile_flag() const;
|
|
bool combo_skill_enabled() const;
|
|
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
|
|
|
|
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
|
|
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
|
|
bool use_item(int window, int cell);
|
|
bool drop_item(int window, int cell, int gold);
|
|
bool drop_item_count(int window, int cell, int gold, int count);
|
|
bool use_item_to_item(int source_window, int source_cell, int target_window, int target_cell);
|
|
bool give_item(int target_vid, int window, int cell, int count);
|
|
bool pickup_item(int ground_vid);
|
|
godot::Array get_inventory() const; // non-empty slots
|
|
godot::Array get_equipment() const; // WEAR_MAX_NUM slots (may be empty)
|
|
godot::Array get_belt_inventory() const; // non-empty 4x4 belt slots
|
|
godot::Array get_view_equipment(int vid) const; // 11 legacy inspect slots (may be empty)
|
|
godot::Dictionary get_item(int window, int cell) const;
|
|
godot::Array get_ground_items() const; // {vid, vnum, pos}
|
|
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
|
|
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
|
|
|
|
// --- networked world snapshot (positions already Godot-space, metres) ---
|
|
godot::Dictionary get_entity(int vid) const;
|
|
godot::Array get_entities() const;
|
|
int get_main_vid() const;
|
|
int get_main_pid() const;
|
|
godot::Dictionary get_points() const; // local player stat block
|
|
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
|
|
godot::Array get_affects() const; // active buffs/debuffs on the local player
|
|
|
|
// App lifecycle (F5). suspend() stops pumping the socket (called
|
|
// automatically on NOTIFICATION_APPLICATION_PAUSED); resume() re-enables it.
|
|
// A mobile OS tears the TCP connection down within seconds of backgrounding,
|
|
// so the first pump after resume typically surfaces `disconnected` — call
|
|
// reconnect() to redo the login with the stored credentials.
|
|
void suspend();
|
|
void resume();
|
|
bool reconnect();
|
|
bool is_suspended() const { return suspended; }
|
|
|
|
godot::String get_stage() const { return stage_name(); }
|
|
bool is_in_game() const;
|
|
|
|
// Drain the socket + answer PING once, WITHOUT emitting the per-frame world
|
|
// signals. Call this from GDScript around any blocking work (model loads,
|
|
// map build) so the server doesn't drop us for going silent.
|
|
void net_poll();
|
|
|
|
protected:
|
|
static void _bind_methods();
|
|
|
|
private:
|
|
enum class Stage { Idle, AuthConnect, AuthWait, GameConnect, GameLogin, InGame, Failed };
|
|
Stage stage = Stage::Idle;
|
|
godot::String stage_name() const;
|
|
void set_stage(Stage s);
|
|
|
|
godot::String game_host;
|
|
int game_port = 0;
|
|
godot::String account_id;
|
|
|
|
// stored so reconnect() can redo the full auth->game flow after a resume.
|
|
godot::String cfg_auth_host, cfg_game_host, cfg_id, cfg_pw;
|
|
int cfg_auth_port = 0, cfg_game_port = 0;
|
|
bool have_cfg = false;
|
|
bool suspended = false;
|
|
|
|
std::unique_ptr<mtnet::AuthClient> auth;
|
|
std::unique_ptr<mtnet::GameClient> game;
|
|
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
|
|
// (connect/login/select/enter-game/world/intents). Null otherwise.
|
|
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
|
|
bool classic_list_emitted = false;
|
|
int classic_last_stage = -1;
|
|
int classic_last_empire = -1;
|
|
void pump_classic();
|
|
// the EntityStore of whichever backend is active (classic_sess or game).
|
|
const mtnet::EntityStore *active_world() const;
|
|
std::unique_ptr<mtnet::MarkClient> mark;
|
|
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
|
|
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
|
|
godot::String mark_host;
|
|
int mark_port = 0;
|
|
bool mark_ready = false;
|
|
uint32_t symbol_guild_id = 0;
|
|
std::vector<uint8_t> symbol_data;
|
|
void pump_mark();
|
|
void pump_classic_mark();
|
|
|
|
int last_auth_state = -1;
|
|
int last_game_state = -1;
|
|
int last_game_phase = -1;
|
|
int last_game_empire = -1;
|
|
bool char_list_emitted = false;
|
|
uint32_t selected_pid = 0;
|
|
godot::Array build_char_list() const;
|
|
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
|
|
// it before the server finishes the spawn burst makes it drop us ~10s later.
|
|
uint64_t loading_since_ms = 0;
|
|
bool enter_game_sent = false;
|
|
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
|
bool mall_open_seen = false;
|
|
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
|
void warp_to_game_server(const godot::String &host, int port);
|
|
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
|
|
|
void pump_auth();
|
|
void pump_game();
|
|
};
|
|
|
|
} // namespace mtgodot
|