按 docs/ 目录逐篇核对,只改与事实/路径不符的部分,不动尚未验证的计划条目: - `docs/steps/**` 在早前整理时已迁到 `docs/reference/steps/**`,全仓的旧路径 引用(PLAN.md / README.md / M0-gr2-reader.md 及 libgr2 的头注释)一并改正。 - CLIENT-GAP.md / CLIENT-GAP-FIX.md / BACKLOG.md / MIDREVIEW.md:把已经落地的 条目从「待办」改为已完成,删掉与代码现状矛盾的描述。 - ANDROID-TESTING.md / CLIENT-PORT.md / CLIENT-ROADMAP.md / GODOT-POC-PLAN.md / SHINSOO-WORLD-RENDERING.md / PARITY-GAP.md:同上,另补当前实际的构建/测试入口。 - THIRD-PARTY.md:补齐实际在用的第三方来源与许可说明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
61 KiB
客户端玩法开发计划
把
CLIENT-GAP.md的缺口排成可执行的阶段计划。 网络栈进度见CLIENT-PORT.md,渲染见PARITY-GAP.md/BACKLOG.md。规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+ 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client) 更新日期:2026-08-30 · 自用,无发布门禁 P0–P10 首版全部完成;
192.168.21.203已联通,net_e2e端到端到 PHASE_GAME。里程碑见文末。适用范围(2026-09-08 复核):本文整篇以 m2dev fork 协议为基线。该后端已不是默认——
project/client_main.gd:14-15默认走 40250classic(见CLIENT-40250-PORT.md)。 玩法拆解、UI 与阶段规划仍然适用;但文末「通用做法」第 4 条「封包号 / 结构以m2dev-client-src-main/src/UserInterface/Packet.h为准」只适用于MT_PROTOCOL=m2dev。 classic 后端的封包依据是40250/ClientVS22/source/UserInterface/Packet.h+extension/src/net/classic/wire_classic.h。上面「已联通」也是指 m2dev 联调服; 40250 真服(FreeBSD + MySQL)尚未搭起,见CLIENT-40250-PORT.md§7.1。
已就绪的地基(这些不重复做)
- net 栈:握手 / auth / login / select / game 相位,
M2Client编排 + 生命周期 EntityStore:实体模型 + 插值 + 战斗 / 状态封包(points / vitals / damage / motion / stun / dead / target)NetWorld(net_world.gd):实体→场景桥,占位胶囊 + HP 条 + 伤害飘字 + 死亡倒地M2ClientGDScript 面:move/attack/set_target/say+get_entities/get_entity/get_points/get_target+ 12 个信号- 渲染节点:
Metin2Model(骨骼 + crossfade + 武器挂点 + LOD + 换肤)、Metin2World、Metin2AnimPlayer - 资产:
mtproto(item/mob_proto)、mtpack(eterpack)、locale.gd、audio.gd
依赖关系
graph TD
P0["P0 闭环骨架 💻"] --> P1["P1 UI 工具层 💻"]
P0 --> P4["P4 战斗表现 🌐"]
P1 --> P2["P2 物品/背包/装备 🌐"]
P1 --> P3["P3 聊天 + TextTail 💻"]
P1 --> P8["P8 社交/商店/仓库 🌐"]
P5["P5 EffectLib 特效引擎 💻→🌐"] --> P4
P5 --> P6["P6 技能系统 🌐"]
P2 --> P6
P1 --> P6
P1 --> P7["P7 任务/NPC 🌐"]
P2 --> P9["P9 世界系统 🌐"]
P0 --> P10["P10 网络补全 🌐"]
P1(UI 工具层)是 P2/P3/P6/P7/P8 的共同前置。P5(特效)可任何时候并行起
(.mse 解析器纯离线)。
P0 — 闭环骨架(M,💻)—— ✅ 离线部分完成,待服务器端到端
目标:现有骨架变成「点怪→走过去→挥→掉血→切目标」的可玩手感,无新系统。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 输入接线 | S | ✅ | net_play.gd:pc.moved→M2Client.move(FUNC_MOVE)(节流 >1m / >0.2s),anim_state("wait")→move(FUNC_WAIT)(一次),pc.target_selected→set_target(vid)(vid 取节点 vid meta) |
| 本地预测 + 服务器校正 | S | ✅ | net_play._on_net_moved:收到自己 vid 的 entity_moved → 差 >6m 瞬移、>0.3m lerp 0.25,高度本地贴地不信服务器 z |
| HUD 数据绑定 | S | ✅ | hud.gd 加 set_vitals/set_exp/set_level/set_target/clear_target;net_play 绑 points_changed/vitals_changed。血条 / 蓝条 / 经验细条 / 等级 label |
| 目标血条 | S | ✅ | hud._build_target_panel(顶部居中,名字 + 180px 血条);net_play 绑 target_info(vid,hp%) + 目标 vitals_changed 刷新;目标死亡 / despawn → clear_target |
| 攻击动作驱动 | S | 🟡 | net_play:进 2.5m 距离按 attack_period(0.6s) 自动 attack(0, vid) + 面向目标。真模型动作切换靠 net_world 的 set_model_factory + set_anim_state("attack")(P4 细化 motion index / 攻速) |
| 坐标 / 朝向换算 | S | ✅ | Godot 米 ↔ 服务器 cm(position_to_godot 逆)、Godot yaw ↔ Metin2 罗盘度(net_world 逆) |
| net_world 去重主角节点 | S | ✅ | net_world.set_local_vid(vid):本地玩家由 player_controller 的节点代表,net_world 不再画一个 |
| 场景组装 | S | ✅ | game_scene.gd(简版):setup(client, assets_root, map_path?) 装 Metin2World(load 失败则平地兜底)+ 占位玩家 + GameCamera + PlayerController + NetWorld + HUD + NetPlay + Audio + AppLifecycle;entity_main_set → 主角定位出生点。login.gd 的 entered_game → 隐藏登录层 + 实例化(--auto 自动选槽 0)。set_player_model() / set_entity_model_factory() 留给 P2 |
已验证:
netplay_test.gd—— move 带正确 server cm + 节流、FUNC_WAIT 单发、set_target、攻击距离内自动 attack + 节流、points→HUD、远程校正瞬移、target_info→HUD、死亡清目标。gamescene_test.gd—— 装配 + 子系统接线 + 实体镜像(主角不重复画)+ 出生点定位(无地图兜底)。- 项目导入无错,
netbridge_test仍过。
未完成(仅剩服务器相关):
- 端到端:走真
GC_MOVE/GC_PLAYER_POINTS/GC_TARGET_INFO需192.168.21.203可达。net 栈就绪,改 IP 即测。 - 攻击动作用真
Metin2Model(set_player_model/model_factory把 race→gr2,P2 装备系统会带出来)。 login → charselect → game正式串场(现在--auto直接选槽 0;charselect.gd已存在,接一下即可,属 P10)。
P1 — UI 工具层(L,💻)—— 🟡 核心完成(装载器打通全量语料)
目标:一层 Godot Control 封装对齐 EterPythonLib,让 P2+ 的窗口可批量搬。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| uiscript 装载器 | M | ✅ | ui/uiscript.gd:解析原始 assets/uiscript/*.py 的 window={...} dict(子集解释器:str/int/float、NAME、uiScriptLocale.X、算术 + - * /、% 格式化、相邻字符串拼接、SCREEN_WIDTH/HEIGHT + 文件顶层常量、#/## 注释)。防跑飞 guard。实测 80/80 个真实 uiscript 文件解析 + 构建全绿 |
| 控件封装 | M | 🟡 | ui/ui_build.gd:dict → Control 树,覆盖 ~20 种 type(window/board/thinboard/board_with_titlebar/titlebar/text/button/toggle_button/radio_button/image/expanded_image/editline/line/listbox/gauge/grid_table/slotbar/sliderbar/scrollbar)+ horizontal_align/vertical_align/text_color(0xAARRGGBB)/按钮 default/over/down 贴图。ui/ui_assets.gd 解 .sub/.tga/.png/.jpg(.dds 暂不支持→降级主题)。九宫格复用 ui_kit.board。⬜ 少见 type(ani_image 动画、mark、grid_table 内容填充、真 scrollbar 行为)按窗口逐个细化 |
| 窗口管理器 | S | ✅ | ui/ui_manager.gd(CanvasLayer layer=10):窗口栈、open/close/close_top/top、ESC 关顶层、modal 变暗层、标题栏(titlebar / board_with_titlebar / meta is_titlebar)拖动、点击置顶。open_script(path, assets) 一步解析+构建+开 |
| 通用弹窗 | S | ✅ | ui/dialogs.gd:confirm(text,on_ok,on_cancel) / alert(text) / input(prompt,on_submit,default),走 UiManager.open(modal=true) |
| 拖拽层(物品图标跟光标 + 落点命中 slot) | S | ⬜ | 归 P2(背包才用到) |
已验证:ui_test.gd —— 解析器(算术 710、hex 色、locale 解析、常量、坏输入→{})、
构建器(节点类型 / 尺寸 / 位置 / center 对齐数学 / board 标题)、窗口管理器(栈 / 置顶 /
close_top / modal dim)、弹窗(confirm 打开 + modal + on_ok + 关闭)、真实文件
(popupdialog.py name/children/.sub 按钮 + 构建)。全过。全量语料 sweep 80/80。
P2 — 物品 / 背包 / 装备(L,🌐 解析+UI 💻)—— 🟢 主体完成(离线部分),端到端待服务器
目标:捡东西、开背包、穿脱装备(穿装备换模型 → 解锁 PARITY §2.7 高光 / §2.3 时装)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
GC_ITEM_* 封包 + 模型 |
S | ✅ | wire.h:ItemPos(3) / ItemAttr(3) + GC_ITEM_SET(54) / DEL(7) / UPDATE(41) / USE(19) / GROUND_ADD(24) / GROUND_DEL(8) / GET(75) + CG_ITEM_MOVE(11) / USE(7) / DROP(11) / PICKUP(8),全 static_assert。EWindows 常量。EntityStore:m_inventory[90] / m_equipment[11] / m_ground map + apply() 全部 7 包 + drain_inv/drain_ground/drain_item_events。game_client.h 路由 + send_item_move/use/drop/pickup |
Metin2Proto 节点 |
S | ✅ | 新 GDExtension 类:load_item_proto/load_mob_proto → vnum→index map;item(vnum) / mob(vnum) → Dict(name / locale_name / type / sub_type / wear_flags / prices / specular …)。实测载入 5748 真实 item,item(1)=Yang、item(19)=Sword+9 |
M2Client 物品面 |
S | ✅ | get_inventory() / get_equipment() / get_item(win,cell) / get_ground_items()(坐标转 Godot 米)+ move_item/use_item/drop_item/pickup_item;信号 inventory_changed(win,cell) / ground_item_added(Dict) / ground_item_removed(vid) / item_picked_up(vnum,count,from) / item_used(vnum) |
| 背包 / 装备窗 | M | ✅ | ui/inventory_ui.gd:ui_manager.open_script("inventorywindow.py")(P1 装载器)→ 索引格子 → M2Client.get_inventory/get_equipment 填(icon icon/item/<vnum>.tga 或名字文本 + 数量 + tooltip 用 Metin2Proto 名字)→ inventory_changed 刷新 → 右键 use_item / 拖到别格 move_item。ui/ui_build.gd 加 slot-cell 构建(grid_table/slot/slotbar 的 start_index/x_count/y_count/x_step/y_step 网格 + 显式 "slot" 元组)。接进 game_scene.gd(I 键开关)。UI 格子编号:0..89 背包 / 90+ 装备(wear=idx-90) |
| item_list.txt 读取 | S | ✅ | ui/item_list.gd:解析 locale/locale/common/item_list.txt(TSV:vnum→type/icon/model)。用于背包图标(替代 vnum 猜路径)+ 武器模型解析 |
| 本地玩家真模型 | M | ✅ | ui/player_view.gd(PlayerView,Node3D):build(assets, race) —— race 0..7 → class + 性别 → pc(2)/<class>/<class>_novice.gr2 身体 + hair/ + Metin2AnimPlayer。set_anim_state("wait/walk/run/attack/dead") → general/<s>.msa(crossfade)。_set 转发 weapon_gr2/gr2_path/hair_* 给内部 Metin2Model。game_scene._on_main_set 首次拿到 race 时 set_player_model(pv) + 接 pc.anim_state |
| 穿脱 → 换模型 | M | ✅ | ui/equip_model.gd:inventory_changed(EQUIPMENT) → get_equipment():WEAR_WEAPON(4) → item_list.model(vnum) → player.set("weapon_gr2", …);WEAR_SHIELD(10) → shield_gr2(Metin2Model 新增第二个刚体挂点,默认骨 Bip01 L Hand —— PC 骨架没 equip_left_hand,共用 _load_attach 抽出的加载器);WEAR_BODY(0) → ui/race_spec.gd 解 <race>.msm(ShapeData/HairData 组 → model + SourceSkin/TargetSkin)→ armor_shape_of(vnum)(默认 =vnum,可覆写)→ gr2_path 换整模 + 换肤;WEAR_HEAD(1) 有模型的头盔 → 覆盖 hair_gr2 槽(Metin2 约定:头防替发型),拆下回 parts[CHR_EQUIPPART_HAIR] → race_spec.hair(idx)。头发也随 entity_info(GC_CHARACTER_UPDATE 改 parts)刷新。equip_model_test:武器 / 盾装拆、armor_model_map 注入、头盔覆盖 hair 槽 / 拆回 parts 发型;race_spec_test:真 warrior.msm 26 shape、shape 9 → warrior_cheongrin.gr2 + 换肤 |
| 地面掉落物 + 拾取 | S | ✅ | ui/ground_items.gd:ground_item_added(Dict) → 旋转小方块 + billboard 名条(Metin2Proto locale 名);_process 近距离名条变绿;try_pickup()(Z 键)→ 范围内最近的 → pickup_item(vid);ground_item_removed → free。接进 game_scene.gd |
| belt inventory / 快捷栏 | S | ⬜ | beltinventorywindow.py taskbar.py |
已验证(8 个 GDScript 测试套件全绿 + ctest 9/9 + uiscript sweep 80/80 + iOS 编过):
netbridge_test(物品面 + Metin2Proto 5748 item)·
inventory_ui_test(真 inventorywindow.py 开窗 / 填充 / tooltip / 刷新 / use / move)·
equip_model_test(item_list.txt + 武器 vnum→真实 gr2)·
p2b_test(地面掉落 spawn/名条/拾取 + PlayerView 真 warrior 模型 + set_anim_state)·
race_spec_test(warrior.msm 解析 26 shape + 装 shape 9→真实 cheongrin.gr2 + 换肤)。
未完成:belt / 快捷栏、.epk 内 icon(需 AssetSource → GDScript)、
拖动图标跟手视觉、armor_shape_of 用真 item_proto value[3](现 =vnum)、
端到端(真服务器 192.168.21.203)。
P3 — 聊天 + TextTail(M,💻)—— 🟢 主体完成(离线部分)
目标:完整聊天窗 + 头顶信息。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
GC_WHISPER / CG_WHISPER |
S | ✅ | wire.h:GCWhisper(70) / CGWhisper(69) + EChatType / WHISPER_TYPE_* 常量,static_assert。EntityStore::apply(GC_WHISPER) → ChatMsg{type=WHISPER, from, sub, text}(ChatMsg 加 from/sub 字段)。game_client.h 路由 + send_whisper(name,text)。M2Client:whisper(to,text) 方法 + whisper_received(sub,from,text) 信号(与方法不同名,避 GDScript 冲突) |
| 聊天窗 | M | ✅ | ui/chat_ui.gd:底部面板,4 标签(全部 / 私聊 / 系统 / 战斗)+ RichTextLabel 滚动 + LineEdit 输入。前缀 /w 名 话→whisper / /g→GUILD / /p→PARTY / /s→SHOUT / 其它→TALKING。消费 chat(按 type 分标签,名字取 get_entity(vid).name)/ whisper_received / item_picked_up。每类型配色(bbcode)。接进 game_scene.gd(Enter 聚焦输入) |
| 聊天气泡 | S | ✅ | net_world.gd._bubble:普通 / 队伍 / 公会 / 喊话 → 实体头顶 Label3D(3s + 1s 淡出);本地玩家 vid → main_bubble 信号 → game_scene._player_bubble 挂 player 头顶 |
| 头顶信息完善 | S | 🟡 | 名字 + HP 条 + 伤害飘字已有(P2);⬜ 称号 / 公会名 / 目标高亮框 |
| 系统消息 / 战斗日志 | S | 🟡 | 拾取 → 系统 + 战斗标签;⬜ GC_DAMAGE_INFO / 经验 / 金钱变化的文本行(signal 已到位,接一下即可) |
已验证:net.entity_store 扩展(whisper 包解析);chat_test.gd(4 标签路由:talking→全部带名 / info→系统+全部 / whisper→私聊+全部 / 系统 whisper→[系统] / 拾取→系统+战斗;前缀解析 /w /g /s + 本地回显;气泡:实体头顶节点 + 本地 vid→main_bubble)。9 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
P4 — 战斗表现完善(L,🌐 表现 💻)—— 🟡 大部分完成(离线部分)
目标:战斗「有肉感」——拖尾、硬直、连段、状态、死亡复活、镜头。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| affect 封包 + 图标条 | M | ✅ | wire.h:AffectElement(21) / GCAffectAdd(25) / GCAffectRemove(9),static_assert。EntityStore:m_affects map + drain_affects() + affects()。M2Client:get_affects() + affect_added(Dict) / affect_removed(type) 信号。hud.set_affects(array) —— 顶部图标条(色块 by type + tooltip 显 type/point/value/duration)。接进 game_scene |
| 受击硬直 / 锁输入 | S | ✅ | net_play._on_damage:自己是 victim 且非 DAMAGE_DODGE → _hitstun_until = now + 0.32s;is_stunned() 期间 _process 不发攻击 + pc.frozen = true(player_controller._process 见 frozen 即 anim_state("wait") 早退)+ player_view.set_anim_state("damage") |
| combo 连段 | S | ✅ | net_play._process 攻击:COMBO_WINDOW(0.9s) 内连续攻击 → _combo 0→1→2 循环,attack(attack_motion + _combo, vid) + set_anim_state("combo"/"attack") |
| 相机抖动 | S | ✅ | game_camera.shake(strength, decay) —— _process 里加随机偏移,指数衰减。net_play._on_damage 命中 0.04m / 暴击 0.08m |
| 死亡 / 复活流程 | S | ✅ | ui/death_ui.gd:entity_dead(main) 或 phase_changed("dead") → 灰屏 + 窗(在此复活 / 回城复活 → say(0, "/restart_here"/"/restart_town"),同客户端走 quest 命令);vitals_changed hp>0 → 自动关。接进 game_scene |
| 相机锁定目标视角 | S | ⬜ | game_camera 锁定 target 时 yaw 跟随;低优先 |
| 武器挥砍拖尾 | S | ⬜ | 需 Metin2Model 暴露每帧武器骨骼世界变换 → ImmediateMesh 条带。纯视觉,延后 |
已验证:net.entity_store 扩展(affect add/remove);combat_fx_test.gd(受击→硬直+抖屏+damage 动作,闪避不硬直,他人受击不影响;combo 0→1→2 循环;hud.set_affects;死亡窗弹出 + 在此复活→say("/restart_here") + 复活自动关)。10 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
P5 — EffectLib 特效引擎(XL,💻 起 → 🌐 用)⏭ 独立立项 —— 🟡 首版打通
目标:.mse 特效脚本 → Godot。见 PARITY-GAP.md §1.2。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
.mse 解析器 |
M | ✅ | fx/mse.gd:CTextFileLoader 花括号树 + List 行表解析。parse_file → {bsphere_r, bsphere_pos, particles:[{start_time, position, emitter, particle}], meshes:[...]}。实测真 geompung_3_sword.mse → 6 particle。.mse 全资产集只有 3 种 group:Particle(514)/Mesh(22)/MeshElement,无 SimpleLight |
| Particle → GPUParticles3D | M | ✅ | fx/effect_player.gd:每 Particle 组 → GPUParticles3D + ParticleProcessMaterial。映射:EmitterShape(0点/3球+EmittingRadius)、MaxEmissionCount→amount、TimeEventLifeTime→lifetime、TimeEventEmittingVelocity→速度、TimeEventGravity→重力、TimeEventSizeX→scale、TimeEventScaleX→scale_curve(Curve)、TimeEventColorRGB+TimeEventAlpha→color+color_ramp(Gradient)、RotationSpeed→angular_velocity。绘制:QuadMesh + StandardMaterial3D(BillboardType→billboard_mode、Src/DestBlendType→blend_mode(dst=2→ADD)、程序化径向渐变代 .dds) |
| EffectMesh / Light / 时间轴 | M | 🟡 | 时间轴:每组按 StartTime 延迟 create_timer 起 emitting;one_shot 到寿命自动 queue_free。Mesh 组占位小方块(.mde 解码器待做)。Light:本资产集未用 |
| 挂点系统 | S | 🟡 | spawn(name, parent_node) 挂到任意节点下(跟随变换);spawn_at(name, world, pos) 世界定点。绑骨骼需 Metin2Model 暴露骨骼节点,待做 |
| 特效索引 + 服务器触发 | S | ✅ | fx/effect_registry.gd:名字 / 相对路径 / 盘符路径 → .mse(散包扫 + 裸名递归找 depth 7)+ 解析缓存。wire.h GC_SPECIAL_EFFECT(9) / GC_SPECIFIC_EFFECT(136) + EntityStore::drain_effect_cues() + M2Client.effect_cue(vid, name, special) 信号 → game_scene 在实体上 fx.spawn(name) |
已验证:net.entity_store 扩展(specific/special effect cue 解析);fx_test.gd(.mse 解析:bsphere / 2 particle / List 行表 / 引号枚举 / 嵌套 group;EffectPlayer:2 GPUParticles3D + amount + 球形发射 + 加法混合 + start_time meta + play()→emitting;registry:解析真 .mse 6 particle + spawn 挂载)。11 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
未完成:.dds 粒子纹理(GDScript 不解 DDS,需暴露 Metin2Image.load_dds 静态方法)、.mde mesh 解码、SimpleLight(本资产集未用)、绑骨骼挂点、GC_SPECIAL_EFFECT 的 id→路径表、时间轴的 MovingType 位置动画、TexAni 帧动画。
P6 — 技能系统(L,🌐)—— 🟢 主体完成(离线部分)
依赖 P1(技能窗)+ P2(技能书物品)+ P5(技能特效)。
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 技能表 | S | ✅ | ui/skill_table.gd:解析 skilldesc.txt(列偏移修正 —— 0-idx:id0 job1 name2-4 desc5-8 (9空) attrs10 weapon11 motion_name12 motion_idx13 grades14;旧代码整体差 1 列,motion_idx 一直是 0)。entry/name_of/motion_idx_of/is_attack/is_passive/is_toggle/can_level_up/category_of/for_category(for_job = 别名)。JOB 列即分类:WARRIOR/ASSASSIN/SURA/SHAMAN(主动) · SUPPORT(辅助/被动) · HORSE · GUILD |
| 技能封包 | S | ✅ | wire.h:GCSkillLevel(259,flat u8[255]) / GCSkillCooltimeEnd(5) / GCCreateFly(13),static_assert。EntityStore:m_skills[255] + skills_dirty() + drain_cooldown_ends() + drain_fly_cues()。M2Client:get_skills()([{id,level}])+ skills_changed / skill_cooldown_end(skill) / fly_cue(type,start,end) 信号 |
| 技能窗 + 加点 | M | ✅ | ui/skill_ui.gd(K 键):三个分类页 [主动][辅助][坐骑](→ for_category(job/SUPPORT/HORSE))。每行 = 名 + Lv X(+ M/G/P 按 master_type) + [被动]/[切换] 标签 + [+](is_passive 或 CANNOT_LEVEL_UP 时无 +)。+ → M2Client.skill_up(id)。点名 → drag_skill_id。skills_changed 刷新等级 + master |
| 快捷栏施放 + 冷却 | S | ✅ | ui/quickbar.gd(数字键 1-8):assign(slot, "skill"/"item", id)。技能 → M2Client.cast_skill(motion_idx, heading, x, y) + 本地 2s 冷却占位;GC_SKILL_COOLTIME_END 提前解锁。冷却遮罩自上而下扫。restore_from_server() 从 get_quickslots() 恢复。施放时 emit skill_activated(id) → game_scene 播技能特效 |
| 技能特效表 | S | ✅ | fx/skill_fx.gd:skill_effect_name(id, master) = <skilldesc.motion_name>_<clamp(2+master,2,4)>(PC/ymir work/pc/<cls>/effect/*.mse,_blow/_head/… 子特效存在就一起播);GC_SPECIAL_EFFECT 内建 id → SPECIAL_FX 表(drugup_red/blue/green、firecracker_1、fail …)。game_scene 接 effect_cue 的 special 分支 + quickbar.skill_activated |
| 飞行道具 | S | 🟡 | net_world._on_fly:GC_CREATE_FLY → 从 start 实体飞向 end 实体的小发光球(tween,按距离算时长)。⬜ 真弹道 mesh / 命中特效 / GC_FLY_TARGETING 追踪 |
已验证:net.entity_store 扩展(skill level / cooltime end / fly cue);skill_test.gd(真 skilldesc.txt >30 skill + WARRIOR 列 + skill 1 job/name/motion_idx;skill 窗行数 = 职业技能数 + +→skill_up(1) + Lv 5 显示;quickbar assign→activate→cast_skill 用正确 motion_idx + 冷却中不重发 + cooldown_end 后可再发)。12 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
未完成:真弹道、加点等级公式 / 消耗校验(服务器管)。
被动 / 支援技能树 ✅(skilldesc 列偏移修 + skill_ui 三分类页 + is_passive/master_type 显示)。
技能特效表 ✅:fx/skill_fx.gd —— skill id + master → <motion_name>_<2+master夹2..4>(skilldesc col13 的
motion_name 就是特效基名,上一轮列偏移修好后自然接上);子特效 _blow/_head/_foot/_yong 存在就一起播。
GC_SPECIAL_EFFECT 内建 id → SPECIAL_FX 小表(SE_HPUP_RED=1→drugup_red、SE_AUTO_HPUP=19、
SE_FAIL=12→fail …)。quickbar.skill_activated 信号 → game_scene 播;effect_cue 的 special 分支
接上。实测真资产:skill 1/5/17/31/92/93 + SE 1/2/3/9/12/19/20 全部 resolve 到真 .mse。
P7 — 任务 / NPC(L,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| NPC 交互 | S | ✅ | wire.h:CGOnClick(8)、CG_ON_CLICK(0x0A02)。M2Client.click_npc(vid) → send_on_click。net_play._on_pick:ch_type ∈ {1,3,4}(NPC/石头/warp)→ click_npc(vid) 而非攻击 |
| 对话框 | 🟡 | 🟡 | wire.h:GCScript(7,skin+src_size)、GCQuestConfirm(77)、CGScriptAnswer(5) / CGScriptButton(8) / CGQuestInputString(69) / CGQuestConfirm(9),static_assert。EntityStore:ScriptCue{skin,text} / ConfirmCue{msg,timeout,pid} + drain_scripts() / drain_confirms()。ui/quest_dialog.gd:parse_script 解 [ENTER]/[CLEAR]/[NEXT]/[DONE]/[QUESTION arg(..)]/[LETTER] → 文本 + 选项 + 继续钮;选项 → script_answer(idx),无选项 → script_answer(255);GC_QUEST_CONFIRM → 接受/拒绝 → quest_confirm(yes,pid)。⬜ [INPUT] 输入框、SCRIPT_SELECT_ITEM 选道具、左右立绘 |
| 任务日志 + 指引 | 🟡 | 🟡 | wire.h:GCQuestInfo(7 + flag 驱动变长尾)、QUEST_SEND_* flag 枚举。EntityStore:QuestInfo{index,flag,begin,title,clock_*,counter_*,icon} + m_quests map + drain_quest_changes() / quest(idx) / quest_indices()。ui/quest_log.gd(J 键):列 ◆ title + counter_name: value + clock_name: value,quest_info 信号刷新。⬜ 地图 / 世界箭头(TARGET_CREATE 位置目标)、[QUESTBUTTON] → script_button |
| quest 脚本执行 | — | ✅(选型②) | 采纳推荐方案:客户端纯渲染 GC_SCRIPT,quest 逻辑全在服务器。不内嵌 Python 解释器 |
已验证:net.entity_store 扩展(GC_SCRIPT 带 [QUESTION] → ScriptCue;GC_QUEST_CONFIRM → ConfirmCue;GC_QUEST_INFO 带 TITLE+COUNTER flag → QuestInfo 变长尾解析,length 字段回填);quest_test.gd(parse_script:纯文本保留 / [ENTER]→换行 / [QUESTION]→2 选项 / [DONE]→继续钮;script_dialog 信号 → 弹窗 + 3 选项钮 + 选 B → script_answer(1) + 关窗;无选项 → 继续 → script_answer(255);quest_confirm_ask → 接受 → quest_confirm(true,999);quest_log 只显示有标题的 quest + quest_info 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
未完成:[INPUT] 文本输入 UI(CGQuestInputString 封包已备)、SCRIPT_SELECT_ITEM 选道具、NPC 对话左右立绘、[QUESTBUTTON] → script_button、地图 / 世界任务箭头(TARGET_CREATE_TYPE_LOCATION)、端到端(见 CLIENT-PORT.md §3:握手 + 认证已联通,进游戏串场归 P10)。
P8 — 社交 / 商店 / 仓库(XL,🌐)
依赖 P1。窗口多,首版覆盖组队 / 好友 / 商店 / 交易 / 仓库五件;公会 / 精炼 / 私人商店留后续(拍卖行 m2dev-client 无)。
| 子系统 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 组队 | M | ✅ | wire.h:GCPartyInvite/Add/Update/Remove/Link/Unlink/Parameter(0x0710-16) + CGPartyInvite/InviteAnswer/Remove/Parameter(0x0701-06),static_assert。EntityStore:m_party(pid→PartyMember{vid,name,state,hp_pct,affects[7]}) + party_dirty() + drain_party_invites() + party_distribute_mode()。M2Client:party_invite/party_answer/party_leave/party_set_distribute + get_party() + party_changed/party_invite_ask(leader_pid) 信号。ui/party_ui.gd:左侧常驻队员面板(名字 + ★队长 + HP 条),收邀请 → Dialogs.confirm → party_answer |
| 私聊 / 好友 | M | ✅ | wire.h:GC_MESSENGER(0x0741) / CG_MESSENGER(0x0740) sub-header 包(GCSubHeader/CGSubHeader)+ MESSENGER_GC/CG_* 子头枚举。EntityStore:m_friends(name→Friend{online}) + friends_dirty(),解 LIST({u8 connected,u8 len,name} 循环)/ LOGIN/LOGOUT/REMOVE_FRIEND。M2Client:add_friend/remove_friend(ADD_BY_NAME/REMOVE 变长包)+ get_friends() + friends_changed。ui/friend_ui.gd(O 键):在线点 + 名字 + [×],点名 → whisper_to → chat.start_whisper("/w 名 ") |
| NPC 商店 | M | 🟡 | wire.h:GC_SHOP(0x0810) / CG_SHOP(0x0801) sub-header + SHOP_GC/CG_* 子头 + ShopItem(43,TShopItemData)。EntityStore:SHOP_GC_START(u32 owner_vid + ShopItem[])→ m_shop_items + m_shop_open/m_shop_vid;END 清;错误 → drain_shop_errors()。M2Client:shop_buy(pos,cnt)(BUY = [head][u8 cnt][u8 pos])/ shop_sell(cell,cnt)(SELL2)/ shop_close + get_shop_items() + shop_opened(vid)/shop_closed/shop_error(kind)。ui/shop_ui.gd:名字 + 价格 + [买],背包右键 → sell(cell),错误红字。⬜ START_EX 多标签商店、图标、批量 |
| 交易 | M | 🟡 | wire.h:GC_EXCHANGE/CG_EXCHANGE(0x051C/0x0508) + EXCHANGE_SUB_*(START/ITEM_ADD/ITEM_DEL/ELK_ADD/ACCEPT/CANCEL;GC 侧 END/ALREADY/LESS_ELK)+ GCExchange(50,is_me+arg1/2/3+sockets+attrs)/CGExchange(13)。EntityStore:ExchangeState{active,partner_vid,self/peer_items[12],self/peer_gold,self/peer_accept} + exchange_dirty()。M2Client:exchange_start/add_item/add_gold/accept/cancel + get_exchange() + exchange_changed。ui/exchange_ui.gd:我方 / 对方两栏 + 金币 + [接受]/[取消],背包右键 → offer(win,cell)(display pos 0..11 轮转)。⬜ 道具图标格、拒绝时回滚提示 |
| 仓库 | M | 🟡 | wire.h:GC_SAFEBOX_SET/DEL(0x0830-31,body 复用 GCItemSet/GCItemDel) / SIZE(0x0833) / MONEY_CHANGE(0x0834) / WRONG_PASSWORD(0x0832);CG_SAFEBOX_CHECKIN/CHECKOUT(0x0820-21,{u8 safe_pos, ItemPos inv}) / ITEM_MOVE(0x0822,复用 CGItemMove)。EntityStore:m_safebox[135] + m_safebox_size/gold/open + safebox_dirty()。M2Client:safebox_checkin/checkout/move + get_safebox_items()/get_safebox_size()/get_safebox_gold() + safebox_changed。ui/safebox_ui.gd:金币 + 道具行 + [取出],背包右键 → deposit(win,cell)(自动挑空位)。⬜ 密码框、商城页(MALL_*)、拖拽网格 |
| 公会 | L | 🟡 | wire.h:GC_GUILD(0x0730) sub-header + GUILD_GC_*/GUILD_CG_* 枚举 + GuildInfoBody(35)/GuildSubMember(13,name_flag 后跟 name[65])/GuildSubGrade(10),static_assert。EntityStore:GuildState + m_guild_members map + m_guild_grades[16] + guild_dirty(),解 INFO/LIST/GRADE/GRADE_NAME/CHANGE_EXP/MONEY_CHANGE。M2Client:get_guild/get_guild_members/get_guild_grades + guild_add_member/guild_remove_member/guild_offer/guild_answer_make + guild_changed 信号。ui/guild_ui.gd(G 键):信息行 + 成员列表(按 grade 排序,grade 名解析,★官员)+ 16×12 会徽图(放大 3×,NEAREST)。真机实测:[GM-TEAM] lvl 20 members 1/70。会徽下载(独立连接):wire.h GC_MARK_IDXLIST(0x0C11)/GC_MARK_BLOCK(0x0C10)/GC_MARK_UPDATE(0x0B15) + CG_MARK_LOGIN(0x0C01)/CG_MARK_IDXLIST(0x0C04)/CG_MARK_CRCLIST(0x0C02),GC_MARK_* body 用 u32 buf_size 而非 u16 帧长。NetStream 加 on_cipher_active() + on_raw()(子类自定义分帧)钩子。mark_image.{h,cpp}:MarkImageSet(guild_id→mark_id、LZO1X 解压 64×48 块拼进 512×512、rect_of/mark_pixels)+ parse_mark_idxlist/parse_mark_block。mark_client.h:MarkClient : NetStream(KX→CG_MARK_LOGIN{handle,random_key}→CG_MARK_IDXLIST→逐图 CG_MARK_CRCLIST(全 0 CRC = 整图)→GC_MARK_BLOCK)。GameClient 存 mark_handle/mark_random_key(来自 GC_LOGIN_SUCCESS3/4)+ per-slot guild_id/guild_name + drain_mark_updates()。M2Client:download_guild_marks(host,port) / get_guild_mark(gid) / get_guild_mark_image(gid)→Image + guild_marks_ready / guild_mark_updated 信号。serverinfo.gd 第 8 列 mark_port(0=跳过);app_flow 进游戏时按配置连、guild_mark_updated 1s 冷却重连。公会战 + 公会技能页:wire.h GUILD_GC_SKILL_INFO(12) GuildSkillInfoBody(17={u8 point, u8 lvl[12], u16 gp, u16 max_gp})、GUILD_GC_WAR(15) GCGuildWar(10)、GUILD_GC_GUILD_WAR_LIST/END_LIST(17/18) GuildWarPair(8)、GUILD_GC_WAR_POINT(19) GuildWarPoint(12)、GUILD_GC_NAME(16) GuildNameEntry(16,无 NUL);EGuildWarState 枚举;GUILD_CG_USE_SKILL(9)。EntityStore:GuildSkillState / GuildWarStatus / m_guild_wars / m_guild_names map + guild_skill_dirty()/guild_war_dirty() + drain_guild_war_events()/drain_guild_war_scores()。GameClient.send_guild_use_skill(vnum, target_vid)。M2Client:get_guild_skill/get_guild_wars/get_guild_war/get_guild_name + use_guild_skill(vnum, target) + declare_guild_war(name)(发 /war <name> 聊天命令)+ guild_skill_changed/guild_war_changed/guild_war_event/guild_war_point 信号。ui/guild_ui.gd 改三页 Tab(成员 / 技能 / 公会战):技能页显示技能点 + 公会点 + 12 技能等级(名字取 skill_table.for_category("GUILD"))+ 已学技能「施放」按钮;公会战页显示当前战状态 + 进行中 GvG 列表(我方标记)+ 宣战输入框。会徽上传:wire.h CG_MARK_UPLOAD(0x0C03) CGMarkUpload(776={gid, u8 image[16*12*4]})、CG_GUILD_SYMBOL_UPLOAD(0x0722) CGSymbolUpload(8) + 追加原始文件字节;NetStream.send_pending();MarkClient 加 Mode(Download / UploadMark / UploadSymbol)+ set_upload_mark/set_upload_symbol + upload_done()(发缓冲排空即完成)。M2Client:upload_guild_mark(host, port, gid, Image)(自动转 RGBA8 / 缩放到 16×12)/ upload_guild_symbol(host, port, gid, PackedByteArray) / get_mark_server() + guild_mark_uploaded(ok) 信号。guild_ui 成员页「上传会徽」按钮(配了 mark_port + 图源才出),game_scene._guild_mark_upload_image() 优先 res://ui/default_guild_mark.png 否则现造占位。⬜ 等级/日志页、公会战应答(/war 系聊天命令) |
| 精炼 / 强化 | M | 🟡 | wire.h:GC_REFINE_INFO(0x051D)/_NEW(0x051E) GCRefineInfo(63) = type+pos+RefineTable(57,src/result vnum + cost + prob + RefineMaterial[5]);CG_REFINE(0x050C) CGRefine(6)={pos,type}。EntityStore:RefineCue + drain_refine_cues()。M2Client:refine(pos,type) + refine_ask(dict) 信号。ui/refine_ui.gd:refine_ask → 弹框(src→result / 成功率 / 费用 / 材料)→ [精炼]→refine(pos,type) / [取消]。⬜ 精炼结果动画 |
| 龙魂精炼 | M | 🟡 | wire.h:GC_DRAGON_SOUL_REFINE(0x051F) GCDragonSoulRefine(8)={sub_type, ItemPos};CG_DRAGON_SOUL_REFINE(0x050D) CGDragonSoulRefine(50)={sub_type, ItemPos grid[15]};DS_SUB_* 枚举(OPEN / DO_UPGRADE·IMPROVEMENT·REFINE / REFINE_SUCCEED / REFINE_FAIL_*);DRAGON_SOUL_MAX_NUM(180) + WINDOW_DRAGON_SOUL(5) 走 mut_slot。EntityStore:DragonSoulCue + drain_ds_cues() + m_dragon_soul[180] + dragon_soul_slot()。M2Client:ds_refine(mode, cells)(mode 0/1/2 → DO_UPGRADE/IMPROVEMENT/REFINE,cells 为背包格,grid[0]=龙魂)+ get_dragon_souls() + ds_window_open / ds_refine_result(ok, sub_type, cell) 信号。ui/dragon_soul_ui.gd(L 键 / ds_window_open 自动弹):3 模式按钮 + 15 格(背包右键填/取)+ [执行]→ds_refine + 成功/失败提示。⬜ 龙魂属性表 / 结果动画 |
| 私人商店 / 道具商城 / Cube | M | 🟡 | 首版:CG_MYSHOP(0x0802) CGMyShopHead(38)+MyShopItem(13)×N;GC_MALL_OPEN(0x0841)/SET(0x0842)/DEL(0x0843) 路由进 EntityStore.m_mall[135],CG_MALL_CHECKOUT(0x0840)。Cube 走 GC_CHAT/CHAT_TYPE_COMMAND 文本总线(EntityStore.apply_server_command 解 cube open/close/info/success/fail/r_list/m_info)→ CubeState + drain_cube_events();send_cube_make/material_info/result_list 走 /cube …。M2Client:open/close_private_shop、get_mall_items/mall_checkout、get_cube/cube_make/cube_request_* + mall_opened/mall_changed/cube_opened/cube_closed/cube_changed/cube_result。ui/{private_shop,mall,cube}_ui.gd。net.entity_store+net.loopback_flow+shop_cube_mall_test.gd 覆盖。⬜ 能量条、头顶招牌 3D、START_EX 多标签、39 格拖放、send 命令字对真服校准 |
已验证:net.entity_store 扩测(party ADD/UPDATE/LINK/PARAMETER/REMOVE + INVITE 入队;messenger LIST 2 好友 + 在线位 + LOGIN 翻转;shop START owner+item / 错误 / END;exchange START/ITEM_ADD/ELK_ADD/ACCEPT/END;safebox SIZE/SET/MONEY/DEL);net.loopback_flow MockServer 断言 CG_PARTY_INVITE{vid} / CG_SHOP BUY{cnt,pos} / CG_EXCHANGE START{vid} / CG_SAFEBOX_CHECKIN{safe,inv} 字节到位;p8_test.gd(5 个 UI 节点 + FakeClient:队员面板 2 行 + 自动接受邀请、好友行 + 点名 whisper + 添加、商店 2 行 + 买 + 卖 + 错误本地化 + 关、交易两栏 + offer + 放金币 + 非 active 自动关、仓库开 + 金币 + 取出 + 存入挑空位)。13 套 GDScript 测试全绿,ctest 9/9,项目导入干净,iOS 编过。
已验证(公会 + 精炼):net.entity_store 扩测(GC_GUILD INFO 名/等级/资金/容量 + LIST name_flag 变长成员 + GRADE [count][idx][GuildSubGrade] 名/权限;GC_REFINE_INFO src/result/cost/prob + materials[5]);guild_refine_test.gd(公会窗无公会提示 / 信息行 / 成员按 grade 排序 + grade 名 / ★官员;精炼框 refine_ask 弹出 + 成功率 + 费用 + 材料行 + [精炼]→refine(7,1) + [取消] 不发包)。真机 net_e2e:[GM-TEAM] 公会数据解析正确。
已验证(龙魂精炼):net.entity_store 扩测(GC_DRAGON_SOUL_REFINE OPEN cue / REFINE_SUCCEED 带 cell+window / REFINE_FAIL_* / drain 清空);dragon_soul_test.gd(ds_window_open 弹出 + 默认精炼模式 + 切模式禁用当前按钮 + 背包右键按序填 15 格 + 非背包窗忽略 + 再次右键取出/末尾重放 + [执行]→ds_refine(mode, [cells]) + 成功清空/失败留原因码 + 清空/关闭按钮)。
已验证(会徽下载):新 CTest net.guild_mark(parse_mark_idxlist 2 条 → guild→mark_id + needed_images 升序去重;rect_of 按 mark_id/1280 选图、%1280 定位;合成 LZO1X 压缩块 → parse_mark_block → apply_block 拼图 → mark_pixels 逐像素回读;坏压缩数据 / 越界索引 / 截断 body 全部安全拒绝);guild_mark_test.gd(guild_ui 无图时 TextureRect 隐藏 → guild_marks_ready 出图 16×12 → 离开公会收起;app_flow mark_port=0 不下载 / 配置后 download_guild_marks(host,11002) / guild_mark_updated 冷却外重下、1s 内不重下)。独立连接 KX + MarkClient 分帧逻辑离线覆盖,未接真机(联调服未开会徽服端口)。附带修复:game_scene.gd 里 _skill_master() 之前被误插进 setup() 中段,截断了后半段(导致 assets_root 等未声明)——已把它移到 setup() 之后,多个依赖 game_scene 的 GDScript 套件恢复真绿。
已验证(会徽上传):net.loopback_flow 加 MockServer::Mode::Mark:真 loopback socket 跑完整 KX → CG_MARK_LOGIN{handle,random_key} 字节到位 → CG_MARK_UPLOAD{gid=77, image[768]} 服务端逐字节校验和一致 → upload_done();同法验 CG_GUILD_SYMBOL_UPLOAD{gid=88} + 2000 字节原始附加。guild_mark_test.gd:未配 mark server 无「上传会徽」按钮 → 配好后按钮出现 → upload_guild_mark("1.2.3.4",11002,88,img) + guild_mark_uploaded(true) → 状态更新。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。
已验证(公会战 + 公会技能):net.entity_store 扩测(GUILD_GC_NAME id→名注册、SKILL_INFO 点数/12 等级/公会点、WAR ON_WAR 状态 + 事件 drain、WAR_LIST 2 对 → WAR_END_LIST 删 1、WAR_POINT drain);guild_war_skill_test.gd(技能页无数据提示 → guild_skill_changed 出点数行 + GUILD 技能名 + 等级 + 第 12 技能配 levels[11];已学技能有「施放」→ use_guild_skill(4051,0);公会战页无战提示 + 宣战 → declare_guild_war("Tigers") + guild_war_changed → 「交战中」+ GvG 行「◀我方」)。23 套 GDScript 全绿,ctest 10/10,导入干净,iOS 编过。真机(2026-08-31,net_e2e):[GM-TEAM] 账号进游戏后服务器自动推 GUILD_GC_SKILL_INFO,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);GUILD_GC_WAR 无战状态解析正确;trace GC_GUILD 子包 len=171/40/83/22/21/9/5 无 unknown。
未完成:私人商店打磨;商店 START_EX;交易 / 仓库道具图标网格;仓库密码框 + 商城页;公会战应答聊天命令;公会等级/日志页;龙魂属性表 + 精炼结果动画。
P9 — 世界系统(L,🌐,分散)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| 换图 / 传送 | S | 🟡 | wire.h GCWarp(18,x/y/addr/port)。EntityStore WarpCue + drain_warps()(same_server() = addr==0)。M2Client warp(pos, same_server) 信号(pos 已转 Godot 米)。game_scene._on_warp:同服 → 直接挪玩家 + 相机 snap;跨服(addr≠0)→ warning,归 P10 重连串场 |
| 频道 | S | ✅ | wire.h GCChannel(5)。M2Client get_channel() + channel_changed(ch) 信号。小地图角标显示 CH n(hud.set_channel 若有也调) |
| 游戏内时间 / 昼夜 | S | ✅ | wire.h GCTime(12,unix 秒 i64)。M2Client get_server_time() + time_changed(epoch)。world/world_time.gd:epoch → 日内秒 → 太阳 pitch/azimuth + 光强/光色(夜黑冷蓝 → 黄昏橙 → 正午暖白)+ Environment ambient/背景色。无 GC_TIME 时退回系统本地时间 |
| 小地图 | M | 🟡 | ui/minimap.gd:右上角圆形,北朝上,_draw 画底圆 + 外环 + 玩家朝向箭头 + 实体点(怪红 / 其它蓝)+ NPC 点(传送青 / 其它黄)+ 任务标记橙方。世界→图 = (d.x, d.z)*SCALE 夹到半径。⬜ 缩放 / 拖动 / 大地图窗 / atlas 底图 |
| NPC 位置 / 世界任务标记 | M | 🟡 | wire.h GCNPCPosition(6 + count×NPCPositionEntry78) / GCTargetCreate(46) / GCTargetUpdate(16) / GCTargetDelete(8) + CREATE_TARGET_TYPE_*。EntityStore m_npc_marks + m_markers map + npc_marks()/markers() + dirty。M2Client get_npc_marks()/get_world_markers() + npc_marks_changed/world_markers_changed。小地图消费。⬜ 世界空间 3D 箭头 / 屏幕边缘指示 |
| 天气 / 环境粒子 | S | 🟡 | fx/weather.gd:跟随相机上方的 GPUParticles3D 盒发射,set_weather("snow"/"rain"/"none") 切重力 / 速度 / 尺寸 / 颜色 / 湍流。⬜ 由地图 .msenv 属性驱动、闪电 / 雾 |
| 坐骑 | S | 🟡 | wire.h GCMount(21)。Entity.mount_vnum + drain_mount_changes()。M2Client mount_changed(vid) 信号 + entity_dict 带 mount_vnum。⬜ 换骑乘模型 / 坐骑动作集 / 移速 |
| 地形贴花 | S | ⬜ | GameLib/TerrainDecal.cpp —— 血迹 / 施法阵 / 脚印(Godot Decal 节点) |
| 钓鱼 / 副本 | M | ⬜ | GC_FISHING(0x0B10) DUNGEON(0x0B11) |
已验证:net.entity_store 扩测(warp 入队 + same_server;time set + dirty once;channel set;GC_NPC_POSITION 2 条 mark;marker create→update→delete;mount → Entity.mount_vnum + change);p9_test.gd(小地图:view + CH 角标 + _to_map 北朝上/东朝右/夹半径;world_time:正午 day_fraction≈0.5 + 太阳亮,午夜 ≈0 + 太阳暗;weather:off→snow→rain(雨落更快)→off;warp 同服信号)。14 套 GDScript 测试全绿,ctest 9/9,项目导入干净。
未完成:大地图窗 + atlas 底图、地形贴花、钓鱼 / 副本、坐骑换模型、世界空间任务箭头、天气按地图驱动、跨服换图(P10)。
P10 — 网络补全(M,🌐)
| 任务 | 规模 | 状态 | 落点 |
|---|---|---|---|
| LOADING → PHASE_GAME(真机端到端的最后一跳) | S | ✅ | wire.h CGClientVersion(0x000D,70B,filename+timestamp)。GameClient:收到 GC_MAIN_CHARACTER 后自动发 CG_CLIENT_VERSION(fork 用固定 timestamp 1215955205)—— 这是服务器放行到 PHASE_GAME 的门槛,之前缺它就在 phase 4 断线。net_e2e 真机验证:phase = 5 / IN GAME,收到 121 个实体 + HP/SP/level + 20 格背包 + 好友列表 |
| Loading 相位 | S | ✅ | ui/loading_screen.gd(CanvasLayer layer 60):phase_changed → "login/select→连接中/载入角色"、"loading→载入地图" 显示转轮 + 阶段文案 + 可选进度条;"game" 隐藏。断线也收起 |
| 选服 / 选频道 | S | 🟡 | net/serverinfo.gd(class_name ServerInfo):解析 TSV 服务器表(name / auth_host / auth_port / game_host / game_port / channels),内置当前联调服默认项。address(server, channel) → 频道 N 的 game 端口 = base + (N-1)×10(经典 Metin2 约定)。app_flow.gd 登录表单里做服务器 / 频道下拉。⬜ 频道在线人数轮询(ServerStateChecker UDP 探针) |
| Offline / 断线重连 | S | ✅ | ui/reconnect_ui.gd(layer 70):disconnected(reason) → 遮罩 + "N 秒后自动重连(次数/上限)" 倒计时 → M2Client.reconnect()(已有);[立即重连] / [停止] 按钮;entered_game 或非 offline 的 phase_changed 自动收起。最多重试 5 次 |
| charselect 串场 | M | ✅ | app_flow.gd(Node 场景根):一个 M2Client 贯穿全程,状态机 LOGIN → SELECT → GAME。char_list → 选人列表;点角色 → select_character;entered_game → 实例化 game_scene 并 setup;LOGIN/SELECT 期间断线 → 回登录,GAME 期间断线 → 保留场景交给 ReconnectUI。LoadingScreen + ReconnectUI 常驻。build_game_scene 开关便于 headless 测状态机 |
| Metin2 风选人页 | M | ✅ | ui/char_select_screen.gd 取代裸按钮列表:全屏 select.jpg 背景 + 右侧 SubViewport 里 PlayerView 真模型(正面 + wait + 缓摆,包围球 r/sin(fovV/2) 自适应机位,不再切远侧手)+ 左上职业名(locale name_<class>.sub→select.dds,DDS 走 Metin2World.load_dds;退中文描边字)+ 左侧 board 板(帮会徽 / 国家名+国旗 get_empire() / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)+ 开始/创建/删除/退出 + ◀▶ 槽位切换(_pad_slots 补空槽)。建号/删号 1:1:CG_CHARACTER_CREATE(0x0201)/CG_CHARACTER_DELETE(0x0202) + GC_PLAYER_CREATE_SUCCESS/FAILURE(0x020C/D) + GC_PLAYER_DELETE_SUCCESS(0x020E)/WRONG_SOCIAL_ID(0x020F);GameClient.create/delete_character + drain_char_events + replace_slot/clear_slot(服务器补丁槽位不重发);M2Client 同名方法 + char_created/char_create_failed/char_deleted/char_delete_failed 信号 + get_empire/get_slot_count;选人页建号弹窗(职业+名称+各职业起始四维)、删号弹窗(secret 删除码);app_flow._on_char_list 在 SELECT 只 set_chars 不整屏重建。net.loopback_flow MockServer + char_create_delete_test.gd 覆盖。C++ CharSlot+dict 补 play_minutes/st/ht/dx/iq/main_part/hair_part;ui_assets 加运行时 DDS 解码 |
| attack CRC | S | ✅ | net_e2e 真机实测:进游戏后发 CG_MOVE + CG_ATTACK(crc_proc=0 crc_file=0),保持连接 10s 不被踢。结论:0 CRC 服务器接受,不需要占位 CRC |
ESC 系统菜单 + 游戏设置窗(uisystem.SystemDialog + uigameoption.OptionDialog) |
M | ✅ | system_menu_ui.gd(systemdialog.py)8 键:system_option / game_option 开子窗、change_button→/phase_select、logout_button→/logout、mall_button→/in_game_mall、exit_button→quit。ESC 改开此菜单。game_option_ui.gd(gameoptiondialog.py):/setblockmode <mask^bit>(EBlockAction 位 1<<0..1<<5,本地 _block_mode + cfg 持久化)+ /pkmode {0,1,2,4} PK radio + 6 组显示开关持久化 user://system_option.cfg [gameopt](渲染钩子待补)。system_menu_ui_test.gd 覆盖。C++ 无改动 |
组队成员信息板(uiparty.PartyMemberInfoBoard) |
M | ✅ | wire.h CGPartySetState(10) + EPartyRole;send_party_set_state / M2Client.party_set_state;get_party() 补 state+affects[7]。party_ui.gd 每员 strip = 角色状态按钮(队长弹菜单 CG_PARTY_SET_STATE 攻/坦/狂/辅/宗/防 + 踢人)+ 名字(★) + HP gauge + affect chip;顶部 EXP 分配开关 + 组队治疗。点名字 → set_target。p8_test 组队段扩覆盖;顺带修 p8_test._init 未 await 的隐患 + shop_ui.refresh queue_free 延迟 bug |
系统设置窗(uisystemoption.OptionDialog) |
S | ✅ | ui/system_option_ui.gd:装 systemoptiondialog.py 真 uiscript,绑定照 uisystemoption.py:音乐/音效音量滑条→Audio.master_bgm/sfx(含在播 BGM 即时)、camera_short/long→game_camera.max_dist(近 11/远 20)、fog_level0/1/2(浓/中/淡)→Environment.fog_density、tiling_* Godot 无对应保留占位。持久化 user://system_option.cfg(= systemSetting 配置),setup 读回即时生效,.msenv 换 env 后重贴。game_scene ESC 呼出。system_option_ui_test.gd 覆盖 |
私人商店开설창(PrivateShopBuilder) |
S | ✅ | 增量 36:布局改走真 uiscript privateshopbuilder.py(40 格 grid + NameLine 盖 LineEdit + Ok/Close,候选面板挂窗右侧)。itemStock 交互(拿起/落位/价格弹窗/排序+display_pos/撤下/개설/철수)不变。测试拆出 private_shop_ui_test.gd。以下为原 |
商店 SHOP_GC_START_EX(多货架) |
S | ✅ | entity_store 解析 SHOP_GC_START_EX(10) = {u32 owner_vid, u8 tab_count} + tab_count×{ShopTabHead(name[32]+coin_type), ShopItem[40]};ShopEntry.pos 保槽位,m_shop_tabs(ShopTab{name,coin_type,items}),SHOP_GC_START 也按 40-槽填 pos,END 清货架。M2Client.get_shop() → {vid,open,tabs:[…]}。shop_ui.gd 多货架按钮 + 买位置 tabIdx*40+slot(1:1 uishop.py GetIndexFromSlotPos)。net_entity_test 双货架合成包 + p8_test 切页 / buy pos 5 / buy pos 43 |
| 选魔石窗(样板第 2 例) | S | ✅ | ui/select_item_ui.gd:装 selectitemwindow.py(5×8 grid),填格照 uiselectitem.py.RefreshSlot(只 ITEM_TYPE_METIN=10 + GetItemGrade(内部名末位数字)≤2,≤54 个,升序映射背包格)。点格 → client.script_select_item(invCell)(CG_SCRIPT_SELECT_ITEM 0x0903 = net.SendSelectItemPacket)+ 关窗;关闭 → script_select_item(0)。触发链:quest_dialog.parse_script 识别 [SELECT_ITEM] token → select_item_requested 信号(= PythonEventManager EVENT_TYPE_SELECT_ITEM → BINARY_OpenSelectItemWindow)→ game_scene 接 select_item_ui.open。select_item_ui_test.gd 覆盖 |
| 角色状态窗(1:1 窗口样板) | M | ✅ | ui/char_status_ui.gd:UiScript+UiBuild 装载 characterwindow.py 真布局,数值逐字对照 uicharacter.py.RefreshStatus(Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT=`(min |
已验证:net.loopback_flow MockServer 断言「GC_MAIN_CHARACTER 后客户端自动发 CG_CLIENT_VERSION」;p10_test.gd(serverinfo 内置默认 + 文件解析 + 频道端口偏移;loading 遮罩 phase=loading 显示 / progress 条 / phase=game 隐藏;reconnect 断线 → 倒计时耗尽调 reconnect() + 手动重连 + entered_game 收起;app_flow 状态机 LOGIN→连接用 serverinfo 地址→char_list→SELECT→点角色→select_character→entered_game→GAME,SELECT 断线→LOGIN)。15 套 GDScript 测试全绿,ctest 9/9。net_e2e 真机:auth → game → 选人 → LOADING → PHASE_GAME 全程打通。
真机字节校准 + 收尾(2026-08-30,已做):
GC_CHAR_ADD_INFO(0x0207,97B) 补上 → 实体名/等级/公会/骑乘出(NPC「Old Man」实测正确);GC_CHARACTER_UPDATE(0x0209) 按TPacketGCCharacterUpdate全解(parts/guild/alignment/pk/mount)。ch_type改在 GDScript 按 race 查mob_proto.type分类(net_play._entity_kind/_entity_name),game_scene载mob_proto。GC_PLAYER_POINTS本来就对(gold 13.5 亿 = GM 号真实值, exp/next_exp 按 unsigned 输出)。- 3D 名字标签刷新:
ChangeKind::Info+entity_info信号 →net_world._on_info刷 Label3D,name_resolver让怪名走mob_proto。 - 跨服换图(
GC_WARPaddr≠0):M2Client.warp_to_game_server拆连接、复用login_key直连新 game server。断点快连:reconnect()优先直连 game server 省一次 auth 往返。 - 频道负载查询:
net/channel_status.gd=CServerStateChecker端口(明文 TCP,CG_STATE_CHECKER0x000F →GC_RESPOND_CHANNELSTATUS0x0010 →{port: 0关/1正常/2拥挤/3爆满})。真机11011回{11011:1,11012:1,11013:1,11991:1}→serverinfo频道口步长改 +1(port_step可按服覆盖)。 - 怪 / NPC 真 3D 模型:
ui/mob_view.gd(race →root/npclist.txt代号 → 目录逐段回退 (bear_brown→bear)→Metin2Model(网格 / 贴图用完整代号)+motlist.txt动作),game_scene._make_entity_model作net_world默认工厂,失败回退占位胶囊。net_world._attach_nameplate抽出,真模型也挂名字 + HP 条。翻译版mob_protoszName被本地化 ("Brown Bear")解不出目录,故必须走npclist.txt。 - 已在局内 / 重连补拉实体:
net_world.catch_up()遍历client.get_entities()补 spawn (协程化的setup()让entity_spawned接得晚,会漏初始 spawn 洪流);game_scene先set_local_vid+net_play._on_main_set再catch_up,避免本地玩家被画成压身的蓝胶囊。
详见 CLIENT-PORT.md §3。23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。
剩的不是代码问题:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
里程碑
P0–P10 首版全部完成(2026-08-30)。真机端到端(net_e2e)从 auth 握手一路打通到
PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列表。18 套 GDScript + 9 CTest +
项目导入 + macOS/iOS 扩展全绿。
| 里程碑 | 含 | 首版状态 | 距「打磨完成」还差 |
|---|---|---|---|
| M1 可打 | P0 闭环 + P4 硬直 / 连击 / 相机抖 / 死亡窗 | ✅ 首版 | 攻速 / motion index 按 POINT_ATT_SPEED 细化、武器拖尾、相机锁定 |
| M2 角色养成 | P1 UI 层 + P2 物品 / 装备(武器 / 盾 / 身体 / 头盔 / 头发 / armor shape / 时装)+ P6 技能(分类页 + 技能特效表 + 内建特效)+ P3 聊天 | ✅ | 加点等级公式(服务器管)、真弹道 |
| M3 世界 | P7 任务 / NPC + P9 世界系统 + P10 网络补全 | ✅ 首版 | [INPUT] / 选道具 / 立绘、大地图窗 + atlas、地形贴花、跨服换图、断点续连 |
| M4 社交 | P8 组队 / 好友 / 商店 / 交易 / 仓库 / 公会(技能页 + 公会战 + 会徽上/下载)/ 精炼 / 龙魂精炼 | 🟡 首版(12 件) | 龙魂属性表 + 结果动画、私人商店打磨、公会等级/日志页 |
| 特效 | P5 EffectLib(.mse → GPUParticles) |
🟡 首版 | .dds 纹理、.mde mesh、绑骨骼、skill / 命中特效表 |
下一步优先级(都是「首版 → 打磨」)
真机字节校准第二轮✅ 已做(2026-08-30):GC_SKILL_LEVEL_NEW(0x021B,本服才发这个, 之前技能窗全空) +GC_QUICKSLOT_*(0x0519/1A/1B) 补上;GC_NPC_POSITION/GC_ITEM_UPDATE/GC_CHAT/GC_AFFECT_ADD/GC_SPECIAL_EFFECT/GC_QUEST_INFO逐字节确认全对。详见CLIENT-PORT.md§3。换装完整✅ 已做(2026-08-30):Metin2Model加第二刚体挂点(盾,Bip01 L Hand,_load_attach抽公共加载器);equip_model补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随parts[HAIR]。 armor→shape 真表:proto 读器加alValues[6]@191,equip_model._armor_shape_default=item_proto values[3](非 0)否则 =vnum(对齐__ArmorVnumToShape)+ armorspecular→model.specular_power(§2.7)。时装:Metin2 不发独立 costume 槽包,服务器把 costume vnum 写进主角parts[ARMOR/HAIR];equip_model每槽改成_eff()(parts[]非 0 优先,否则回退get_equipment()),大编号parts[HAIR]走item_list.model(假发)。GC_CHARACTER_UPDATEparts 变 → 推Info变更触发全刷。也是渲染 远端玩家外观的同一条路。被动 / 支援技能树✅ 已做(2026-08-30):skilldesc.txt列偏移修正(旧代码差 1 列,motion_idx一直读成 0);skill_ui三分类页[主动][辅助][坐骑](for_category),被动技能无[+],master_type显示为Lv X M/G/P。技能特效表✅ 已做(2026-08-30):fx/skill_fx.gd,skill id + master →<motion_name>_<N>(skilldesccol13),GC_SPECIAL_EFFECTid →SPECIAL_FX小表;quickbar.skill_activated+effect_cue.special接上。- 全量资产 —— 换完整 monster / npc / effect 包,
mob_view/effect_registry/skill_fx立刻全量。 公会 + 精炼✅ 已做(2026-08-30):GC_GUILD(0x0730) INFO/LIST/GRADE 解析 +ui/guild_ui.gd(G 键, 真机[GM-TEAM]验证);GC_REFINE_INFO(0x051D) +ui/refine_ui.gd(refine_ask弹框 →refine(pos,type))。公会战 / 技能页✅ 已做(2026-08-30):GUILD_GC_SKILL_INFO(12)/WAR(15)/GUILD_WAR_LIST·END_LIST(17/18)/WAR_POINT(19)/NAME(16) 解析 +GUILD_CG_USE_SKILL(9);M2Clientget_guild_skill/get_guild_wars/use_guild_skill/declare_guild_war(/war命令) + 4 信号;guild_ui改三页 Tab(成员/技能/公会战)。会徽上传✅ 已做(2026-08-30):CG_MARK_UPLOAD(0x0C03,{gid, u8 image[768]}) /CG_GUILD_SYMBOL_UPLOAD(0x0722) + 追加文件字节。MarkClient加Mode(Download/UploadMark/UploadSymbol)+set_upload_mark/_symbolupload_done()(NetStream.send_pending()归零即完成)。M2Client.upload_guild_mark(host,port,gid,Image)(自动转 RGBA8 + 缩放 16×12)/upload_guild_symbol/get_mark_server+guild_mark_uploaded(ok)。guild_ui成员页 「上传会徽」按钮。net.loopback_flow加MockServer::Mode::Mark真 socket 校验字节。
龙魂精炼✅ 已做(2026-08-30):GC_DRAGON_SOUL_REFINE(0x051F) /CG_DRAGON_SOUL_REFINE(0x050D,grid[15]) +DS_SUB_*枚举;EntityStore.DragonSoulCue+m_dragon_soul[180](WINDOW_DRAGON_SOUL走mut_slot);M2Client.ds_refine(mode, cells)+ds_window_open/ds_refine_result;ui/dragon_soul_ui.gd(L 键 / 自动弹,3 模式 + 15 格背包右键填 + [执行])。剩龙魂属性表、精炼结果动画。会徽下载✅ 已做(2026-08-30):MARK_*(0x0C0x/0x0B15)独立连接。NetStream加on_cipher_active()on_raw()钩子让子类自定义分帧(GC_MARK_*用 u32buf_size)。mark_image.{h,cpp}MarkImageSet(LZO1X 解 64×48 块 → 512×512 →mark_pixels)+mark_client.hMarkClient(KX →CG_MARK_LOGIN{handle, random_key}→CG_MARK_IDXLIST→ 逐图CG_MARK_CRCLIST(全 0) →GC_MARK_BLOCK)。M2Client.download_guild_marks/get_guild_mark_image→Image;guild_ui显示 16×12 会徽;serverinfomark_port第 8 列(0=跳过);app_flow进游戏连、guild_mark_updated冷却重连。CTestnet.guild_mark+guild_mark_test.gd。未接真机 (联调服未开会徽服端口)。
- 私人商店打磨、商店
START_EX、道具图标网格 —— 按需。(拍卖行不在 m2dev-client 内)
通用做法
- 每个 P 先做 💻 部分:
wire.h加封包 +EntityStore解析 + 合成封包单测(net_entity_test风格)+ 假 client 驱动 UI(netbridge_test风格)。 - 🌐 端到端用
build/extension/net_e2e(真机跑 auth→game→选人→进游戏);MT_NET_TRACE=1逐包 header/len/consumed、MT_NET_DUMP=1逐包 hex,对字节用。 - UI 一律走 P1 的 uiscript 装载器,不手搓布局。
- 封包号 / 结构:
MT_PROTOCOL=m2dev时以../m2dev-client-src-main/src/UserInterface/Packet.h为准,默认的classic后端以../40250/ClientVS22/source/UserInterface/Packet.h+extension/src/net/classic/wire_classic.h为准,static_assert尺寸;真机字节和参考头文件不符时以真机为准(已踩:GC_LOGIN_SUCCESS4、bType、CG_CLIENT_VERSION)。 - 玩法逻辑尽量服务器驱动、客户端只渲染(尤其 quest / 商店 / 公会),少搬业务规则。