Compare commits

..
12 Commits
Author SHA1 Message Date
shenlei b19d5b5dd3 feat: complete mobile UI implementation 2026-09-04 19:54:33 +09:00
shenlei 9a4a044da5 Complete remaining client gap features 2026-09-04 13:25:05 +09:00
shenlei 13ccb8d02d 完善客户端功能并同步差距文档 2026-09-03 18:40:01 +09:00
shenleiandClaude Sonnet 5 f93a172296 40250 classic: G1 integrate W4 (§9.1 map BGM selection, §9.2 background volume save/restore)
Doc conflicts resolved by W0: all four 增量 50 change-log bullets kept, W4 C.5
batch record appended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:13:17 +09:00
shenleiandClaude Sonnet 5 804744c28f 40250 classic: G1 integrate W3 (§8.3 in-game help window)
Doc conflicts resolved by W0: kept all four 增量 50 change-log bullets and
appended the W3 C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:10:48 +09:00
shenleiandClaude Sonnet 5 61ddf791fa 40250 classic: G1 integrate W2 (§2.1 data-layer EntityStore + net_world visibility, §2.3/§2.4/§2.5)
Doc conflicts in CLIENT-GAP.md / CLIENT-GAP-FIX.md resolved by W0: keep both
增量 50 W1 + W2 change-log bullets and C.5 batch records.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:08:59 +09:00
shenleiandClaude Sonnet 5 ce715c7c38 40250 classic: G1 integrate W1 (§1.5 client version, §1.10 text codec, §2.2 two-packet merge)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:07:17 +09:00
shenleiandClaude Sonnet 5 3487df2a16 40250 classic W2: EntityStore data-layer only + net_world visibility (增量 50)
CLIENT-GAP-FIX §2.1/§2.3/§2.4/§2.5 (+ §2.2 steps 4-5):

- §2.1 two-layer model: EntityStore is data-only. Entries are created only by
  mut_spawn / mut_spawn_full / mut_spawn_main and removed only by mut_despawn /
  main-vid table clear. Every other mut_* now does find-or-return with a
  MT_NET_TRACE-gated debug log; no pending / replay buffer. Visibility moves to
  net_world.gd::_update_visibility() — per frame it walks client.get_entities()
  and mirrors is_main / AFFECT_SHOW_ALWAYS(bit 7) / IsWall(14201-14204) /
  planar-distance < CHAR_STAGE_VIEW_BOUND (200*100 cm), building a node on entry
  and fading + freeing it past +10 while keeping the data row (_fading guard).
  Gate is fail-open when the data layer is not queryable (no get_entities /
  _main_vid == 0 / no pos_cm) so existing fake-client tests are unaffected.
- §2.3 one overwrite rule: mut_spawn_full() does a clean whole-row value replace
  (m_ents[vid] = e), no preservation of prior move state on VID reuse; mut_spawn()
  now delegates to it. net_world._on_spawn destroys + recreates a node when the
  vid already has one; mount/dismount rebuild keeps the old global_position.
- §2.4 points bounds: mut_set_points() clamps counts > POINT_MAX_NUM (255) and
  zeroes the tail of a short snapshot; mut_set_point() rejects type >= 255.
- §2.5 field apply order + affect flags: new EntityStore::mut_affect_flags(vid,
  lo, hi) assembles v = lo | (hi << 32) and queues a dirty tick; mut_char_update
  guild/alignment/pk changes now also push Info. net_world._apply_field_updates()
  applies fields in the reference call order (NetworkActorManager.cpp:470).
- §2.2 steps 4-5: mut_char_info() / mut_shop_sign() drop the touch()-creates-
  entity path. Steps 1-3 (m_pending_actor two-packet merge) remain W1-owned.

Tests: ./build.sh Debug exit 0; ctest 16/16 (net.entity_store gains a §2.1
data-layer assertion block). New project/net_world_vis_test.gd for scene-node
visibility. GDScript regressions green: netbridge_test, gamescene_test,
netplay_test, p9_test, combat_fx_test, player_motion_test, net_world_vis_test.

Follow-up for W1: call mut_affect_flags(vid, affect_flag[0], affect_flag[1])
from classic_parser.cpp after GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE
(mut_char_update has no affect param). wire_classic.h already has the wanted
static_assert on GCPoints.points.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 16:06:06 +09:00
shenleiandClaude Sonnet 5 b296457e3c 40250 classic: increment 50 (Phase 1 W1) — §1.5 client version, §1.10 text codec, §2.2 two-packet merge
§1.5 CG_CLIENT_VERSION: send_client_version() now branches on locale. EUROPE
(this project's locale) sends CGClientVersion2 (header 0xf1, 67B, timestamp
"1215955205"); non-EUROPE sends CGClientVersion (0xfd) + CMake-injected
MT_BUILD_TIMESTAMP in C __TIMESTAMP__ form. New ClassicSession::set_executable_name()
(default "metin2.bin"; TODO(W0): M2Client passes OS::get_executable_path().get_file()).
m_version_sent set only after send_fixed() succeeds. wire_classic.h gains
CGClientVersion2 + size-table entry.

§1.10 fixed-length text: new extension/src/net/text_codec.h — to_wire()/
from_wire_str() apply a hard byte cap without splitting a UTF-8 multibyte
sequence; godot::String overloads are __has_include-guarded so mtnet stays
godot-free. classic_session.cpp routes 8 fixed-length name/sign/comment fills
through to_wire(); parser reads GC_CHAR_ADDITIONAL_INFO name via from_wire_str().
New net_text_codec_test (ctest net.text_codec). Locale codepage (CP949/CP1252)
transcoding remains a follow-up; wire bytes are currently UTF-8 (ASCII round-trips).

§2.2 two-packet PC/NPC merge: ClassicParser GC_CHARACTER_ADD drops invisible
races (20025/20038/20039); PC/NPC only stash a bare Entity into m_pending_actor;
everything else spawns immediately with a name from root/npclist.txt (new
npc_names.h + set_npclist_path(); $MT_ASSETS/$M2_ASSETS fallback; TODO(W0):
M2Client passes the asset root). GC_CHAR_ADDITIONAL_INFO with no pending entry
records m_last_error + m_unhandled_headers and drops (never half-creates);
on a hit it merges the pending Entity with the additional-info's 9 fields into
one mut_spawn_full() then erases the pending entry. New pending_actor_count()
test hook; net_classic_session_test updated (version assertions -> 0xf1;
stash -> merge -> spawn, immediate monster spawn, invisible-race drop,
no-pending additional-info is a no-op). Depends on W2 §2.2 steps 4-5
(entity_store mut_char_info touch / mut_shop_sign cleanup) — the merge works
with current W2 code as-is.

Build + ctest 17/17 (16 + net.text_codec); netbridge_test, p10_test green.
Docs: CLIENT-GAP.md W1 row + increment-50 change-log entry; CLIENT-GAP-FIX.md
§1.5/§1.10/§2.2 status + facts and C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:51:48 +09:00
shenleiandClaude Sonnet 5 d7711ba5f0 40250 classic: increment 50 (Phase 1 W3) — §8.3 in-game help window
Implement the in-game Help window as a 1:1 migration of uihelp.py's
HelpWindow (single-page: ENABLE_HELP_MULTIPAGE = 0 -> LoadDialogSinglePage).

- project/ui/help_ui.gd (new): loads the real helpwindow.py uiscript via
  UiScript -> UiBuild; _relabel() resolves the <HELP_*>/<CLOSE> placeholders
  (produced when UiManager has no locale callback) from the project Locale
  table (en) with verbatim English fallbacks, covering the original script's
  two duplicate help_02 rows (right/middle camera). open/close/toggle/is_open;
  close_button -> close; ESC handled by the UiManager window stack.
- project/ui/system_menu_ui.gd: help_button changed from the placeholder toast
  to close() + open_help(); adds const HelpUI, an injectable help_ui member,
  _ensure_help() (lazy build, reuses the menu's ui/client/assets_root),
  open_help(), toggle_help(); setup() gains an optional 6th arg (old 5-arg
  callers unaffected).
- project/game_scene.gd: single elif in _unhandled_input — KEY_H without
  ctrl/meta -> system_menu_ui.toggle_help() (Ctrl+H stays free for
  /user_horse_ride).
- project/help_ui_test.gd (new): headless SceneTree test — helpwindow.py load,
  19 verbatim help rows + taskbar labels bound and non-placeholder,
  close_button text, rows() helper, close/open/toggle, ESC stack top,
  SystemMenuUI.open_help() lazy build.

Tests: help_ui_test, system_menu_ui_test, gamescene_test, ui_test all PASS
(plus system_option_ui_test, char_status_ui_test). No build/ in this worktree
so C++ ctest not run (no C++ change). Real-server and visual (.sub textures)
not verified. Docs: CLIENT-GAP.md W3 row + increment 50 bullet;
CLIENT-GAP-FIX.md §8.3 status/改动/测试/验收 + C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:34:36 +09:00
shenleiandClaude Sonnet 5 9732f2799e 40250 classic: increment 50 (W4) — map BGM selection + background volume save/restore
§9.1 map BGM selection (bgm_director.gd):
- server bgm_changed(name, volume): non-empty name plays it as field music;
  empty name falls back to musicInfo.fieldMusic (METIN2THEMA = "M2BG.mp3")
- volume < 0 (server unspecified, GC_MAIN_CHARACTER3_BGM) -> default
  1/5*0.1 = 0.02; otherwise server volume; volume applied before play_bgm()
  fade-in (matches game.py:190 SetMusicVolume/SetSoundVolume then FadeInMusic)
- connect M2Client.world_reset -> re-run selection on map change
- new musicinfo.gd: METIN2THEMA/loginMusic/createMusic/selectMusic/fieldMusic
  constants + save/load_last_play_field_music -> user://BGM/lastplay.inf

§9.2 background/foreground volume save-restore:
- audio.gd: save_volume()/restore_volume() (backup fields echo reference
  m_fBackupMusicVolume / m_fBackupSoundVolume) + set_music_volume()
- app_lifecycle.gd: _enter_background() uses save_volume() + mute instead of
  stop_bgm(); _exit_background() calls restore_volume(); _bgm_name never cleared

§9.3 lifecycle ownership: DOC ONLY — design confirmed, deferred to Phase 2 / W0
(AppFlow sole lifecycle owner; M2Client drops NOTIFICATION_* keeps suspend/resume).
game_scene.gd / app_flow.gd / m2_client.* left untouched per workflow constraints.

Tests: bgm_test.gd (new) PASS; gamescene_test / netplay_test / p9_test green.
Docs: CLIENT-GAP.md increment 50 bullet + W4 progress row; CLIENT-GAP-FIX.md
§9.1 §9.2 状态/改动/测试/验收, §9.3 deferred note, C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:31:58 +09:00
shenleiandClaude Sonnet 5 f32064c74a 40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
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
2026-09-02 15:13:01 +09:00
281 changed files with 41268 additions and 1372 deletions
@@ -0,0 +1,56 @@
---
sketch: 001
name: mobile-hud
question: "暗黑系 ARPG 横屏战斗 HUD 如何兼顾 MMORPG 的完整系统入口?"
winner: "single"
tags: [mobile, landscape, hud, touch, combat, arpg, mmo, dark-glass]
---
# Sketch 001: Mobile HUD
## Design Question
在不遮挡 Godot 游戏世界的前提下,如何用暗黑系 ARPG 的横屏战斗框架,让移动、普通攻击、技能、物品、拾取/对话、任务、地图、聊天、队伍、公会以及背包等系统保持易达?本轮重点比较圆形战斗控件、深色金属边框、职业资源条与低频功能收纳方式。
## How to View
直接打开:
`open .planning/sketches/001-mobile-hud/index.html`
也可以使用文件链接打开 [index.html](/Users/shenlei/Work/mt/mtgodot-poc/.planning/sketches/001-mobile-hud/index.html)。
页面无需构建工具。草图以横屏为基准:手机为 844×390,平板和桌面为 16:9。右下角工具栏切换手机/平板/桌面预览和标注模式;首页下方的“全量 UI 移动端交互图”支持按模块筛选,点击节点可查看入口、触控方式、页面形态和返回路径。
## Single Direction
- **暗黑 ARPG 横屏 HUD** — 左上按“名称→头像→HP/MP/资源短条”纵向排列,顶部中央目标条,右上按“背包→小地图→菜单”排列,地图通过点击小地图进入;左下摇杆、右下环形技能盘,底部中央不放功能栏。角色、装备、技能、坐骑和表情只通过左上头像进入;菜单仅保留“社交、成长、活动、商业、系统”五个一级入口。
## Covered Functions
移动摇杆、相机/地图入口、玩家状态、目标、普攻、技能、药水/物品、拾取、对话、钓鱼、副本入口、任务、背包、角色、技能、地图、队伍、公会、好友、聊天、情侣/爱意、商店、商城、仓库、私人商店、交易、兑换、Cube 制作、精炼、龙魂、坐骑、观战、表情、系统、帮助、登出、角色切换、死亡复活、加载和断线重连。
## Interaction Maps
- 进入游戏:登录 → 服务器/频道 → 角色列表 → 加载 → 首页 HUD。
- 战斗:移动摇杆 → 目标选择 → 普攻/技能 → 掉落/场景交互 → 死亡/复活。
- 任务:任务栏 → 任务日志 → 小地图/大地图 → 自动寻路 → NPC 对话 → 副本结算。
- 角色与物品:头像/菜单 → 角色/装备/技能,以及背包 → 物品详情 → 使用/装备/出售 → 仓库。
- 社交:右上菜单 → 社交 → 聊天/好友/情侣/公会;组队后队员状态固定在左上头像下方,点击队员查看资料或定位。
- 经济:NPC/玩家 → 商店/商城/交易/私人商店 → Cube 制作 → 精炼/龙魂。
- 系统状态:右上菜单 → 设置/帮助/角色切换/登出,以及死亡、加载、断线重连等状态流程。
## What to Look For
- 暗黑系圆形战斗盘是否能让右手拇指快速区分普攻、技能和药水。
- 战斗控件是否遮挡角色、敌人、掉落物或任务目标。
- 当前任务条是否足够醒目,点击任务条能否直接进入任务面板。
- 低频功能是否通过菜单一跳到达,同时不让战斗 HUD 变得拥挤。
- 名称是否位于头像正上方,HP/MP/资源短条是否位于头像下方且不抢占战斗画面。
- 右上菜单是否位于最外侧,小地图是否紧挨菜单,背包是否替代原地图按钮。
- 横屏手机布局在平板/桌面预览下是否有足够的延展空间。
- 技能冷却、按钮按压、抽屉打开、聊天发送和摇杆复位反馈是否足够明确。
## Next Decision
当前方向已确定为单一方案,交互图已补齐全部功能入口。下一步可按交互图逐条映射到 Godot Control 节点,并优先实现首页 HUD、任务、背包、菜单和战斗操作。草图只借鉴暗黑系 ARPG 的布局语言,不复用参考图中的原始素材。
@@ -0,0 +1,664 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>移动端 HUD 草图 · 暗黑 ARPG</title>
<link rel="stylesheet" href="../themes/default.css">
<style>
* { box-sizing: border-box; }
:root { color-scheme: dark; }
html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at 50% 0%, #1c3048 0, #0b1018 42rem); color: var(--color-text); font-family: var(--font-sans); }
body { padding: 34px 20px 60px; }
button, input { font: inherit; }
button { color: inherit; cursor: pointer; }
.page-head { max-width: 1040px; margin: 0 auto 24px; }
.eyebrow { color: var(--color-primary); font-size: var(--text-xs); letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 5px 0; font-size: clamp(1.45rem, 3vw, 2.2rem); }
.page-head p { margin: 6px 0 0; max-width: 720px; color: var(--color-text-muted); line-height: 1.55; }
.variant { max-width: 1040px; margin: auto; }
.variant-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 auto 14px; max-width: 920px; }
.variant-intro h2 { margin: 0; font-size: var(--text-lg); }
.variant-intro p { margin: 0; color: var(--color-text-muted); font-size: var(--text-sm); }
.device-wrap { display: flex; justify-content: center; }
.device { position: relative; width: min(844px, calc(100vw - 40px)); aspect-ratio: 844 / 390; overflow: hidden; border: 1px solid rgba(221, 238, 255, .45); border-radius: 30px; background: #0a1018; box-shadow: 0 28px 80px rgba(0,0,0,.55), 0 0 0 7px rgba(255,255,255,.035); transition: width .25s ease, aspect-ratio .25s ease, border-radius .25s ease; }
.preview-tablet .device { width: min(1280px, calc(100vw - 40px)); aspect-ratio: 16 / 9; border-radius: 24px; }
.preview-desktop .device { width: min(1440px, calc(100vw - 40px)); aspect-ratio: 16 / 9; border-radius: 18px; }
.scene { position: absolute; inset: 0; overflow: hidden; background: linear-gradient(145deg, rgba(16,39,54,.85), rgba(8,17,25,.88) 48%, rgba(32,22,42,.94)); }
.scene::before { content: ""; position: absolute; inset: -12%; opacity: .55; background: radial-gradient(ellipse at 25% 37%, rgba(95,181,187,.28), transparent 24%), radial-gradient(ellipse at 78% 23%, rgba(144,97,221,.28), transparent 22%), linear-gradient(145deg, transparent 35%, rgba(255,211,108,.08) 36%, transparent 37% 58%, rgba(116,185,255,.08) 59%, transparent 60%); transform: rotate(-7deg); }
.scene::after { content: ""; position: absolute; inset: 0; background: linear-gradient(transparent 57%, rgba(4,8,13,.42) 82%, rgba(4,8,13,.78)); pointer-events: none; }
.terrain { position: absolute; inset: 32% 4% 16%; opacity: .4; background: repeating-linear-gradient(164deg, transparent 0 19px, rgba(173,214,213,.16) 20px 21px), repeating-linear-gradient(22deg, transparent 0 36px, rgba(46,70,82,.42) 37px 39px); clip-path: polygon(0 25%, 25% 4%, 53% 24%, 75% 0, 100% 21%, 100% 100%, 0 100%); }
.hero { position: absolute; left: 48%; top: 40%; width: 48px; height: 82px; transform: translate(-50%, -50%); border-radius: 45% 45% 28% 28%; background: linear-gradient(90deg, #152d44, #b7d7e8 45%, #315375 58%, #101d2d); box-shadow: 0 0 18px rgba(116,185,255,.45); }
.hero::before { content: ""; position: absolute; left: 9px; top: -26px; width: 30px; height: 32px; border-radius: 50%; background: radial-gradient(circle at 40% 42%, #ffe9c5 0 18%, #6f4c3e 19% 54%, #1a2432 55%); }
.hero::after { content: "✦"; position: absolute; right: -29px; top: 6px; color: var(--color-accent); font-size: 28px; text-shadow: 0 0 14px #ffb638; }
.mob { position: absolute; left: 68%; top: 44%; width: 58px; height: 42px; border-radius: 48% 52% 35% 35%; background: linear-gradient(140deg, #2b4155, #8c6672 45%, #201824); box-shadow: 0 0 18px rgba(255,111,120,.2); }
.mob::before { content: ""; position: absolute; left: 13px; top: -17px; width: 34px; height: 22px; border-radius: 55% 45% 35% 35%; background: #5f3442; }
.mob::after { content: "· ·"; position: absolute; left: 9px; top: -13px; color: #ffb1a9; letter-spacing: 6px; font-size: 13px; }
.top-status { position: absolute; z-index: 2; top: 14px; left: 14px; right: 14px; display: flex; align-items: center; gap: 9px; }
.avatar { width: 44px; height: 44px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid rgba(255,211,108,.78); border-radius: 50%; background: radial-gradient(circle, #658fb6, #182637 62%, #0b121c); box-shadow: 0 0 0 3px rgba(12,20,30,.58); font-size: 20px; }
.status-copy { min-width: 0; flex: 0 0 148px; width: 148px; }
.status-line { display: flex; align-items: center; justify-content: space-between; gap: 7px; font-size: var(--text-xs); }
.status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.level { color: var(--color-accent); font-family: var(--font-mono); }
.meter { height: 7px; margin-top: 4px; overflow: hidden; border: 1px solid rgba(255,255,255,.18); border-radius: 8px; background: rgba(3,8,13,.72); }
.meter i { display: block; height: 100%; border-radius: inherit; }
.meter.hp i { width: 78%; background: linear-gradient(90deg, #f05767, #ff9d82); }
.meter.mp i { width: 62%; background: linear-gradient(90deg, #4f91f8, #8dc8ff); }
.meter.resource { height: 5px; margin-top: 3px; }
.meter.resource i { width: 68%; background: linear-gradient(90deg, #39217d, #8c55ed 70%, #caa0ff); }
.mini-map { position: relative; width: 74px; height: 74px; flex: 0 0 auto; overflow: hidden; border: 1px solid rgba(213,235,255,.52); border-radius: 50%; background: radial-gradient(circle at 44% 58%, rgba(101,178,161,.5) 0 12%, transparent 13%), linear-gradient(35deg, #17313a 0 38%, #284b58 39% 44%, #17313a 45% 63%, #4c385d 64%); box-shadow: inset 0 0 0 4px rgba(8,14,22,.55), 0 6px 14px rgba(0,0,0,.3); }
.mini-map::before { content: ""; position: absolute; left: 47%; top: 43%; width: 7px; height: 7px; border-radius: 50%; background: var(--color-accent); box-shadow: 0 0 0 4px rgba(255,211,108,.18), 16px -20px 0 -2px var(--color-danger); }
.target-card { position: absolute; z-index: 2; top: 94px; left: 50%; width: 190px; transform: translateX(-50%); padding: 8px 11px; opacity: .92; border: 1px solid rgba(255,111,120,.36); border-radius: 12px; background: rgba(13,19,29,.62); backdrop-filter: blur(12px); box-shadow: var(--shadow-sm); }
.target-card strong { display: block; font-size: var(--text-sm); }
.target-card small { color: #ffabb0; font-size: var(--text-xs); }
.target-card .meter { height: 5px; margin-top: 6px; }
.target-card .meter i { width: 43%; background: linear-gradient(90deg, #e65060, #ffab87); }
.quest-pill { position: absolute; z-index: 2; top: 102px; right: 14px; display: flex; align-items: center; gap: 6px; border: 1px solid rgba(255,211,108,.36); border-radius: 12px; padding: 8px 10px; background: rgba(13,19,29,.54); color: #ffe9ab; font-size: var(--text-xs); backdrop-filter: blur(12px); }
.chat-bubble { position: absolute; z-index: 2; left: 50%; top: 57%; transform: translateX(-50%); max-width: 168px; padding: 7px 10px; border: 1px solid rgba(255,255,255,.23); border-radius: 12px 12px 12px 3px; background: rgba(10,16,24,.72); color: #e3efff; font-size: var(--text-xs); backdrop-filter: blur(10px); }
.chat-bubble::after { content: ""; position: absolute; left: 11px; bottom: -7px; border: 6px solid transparent; border-top-color: rgba(10,16,24,.72); border-left: 0; }
.joystick { position: absolute; z-index: 4; left: 22px; bottom: 116px; width: 110px; height: 110px; border: 1px solid rgba(213,235,255,.24); border-radius: 50%; background: radial-gradient(circle, rgba(116,185,255,.2) 0 28%, rgba(116,185,255,.07) 29% 62%, rgba(10,16,24,.15) 63%); box-shadow: inset 0 0 0 12px rgba(9,17,26,.22); touch-action: none; }
.joystick::before { content: ""; position: absolute; inset: 18px; border: 1px dashed rgba(213,235,255,.24); border-radius: 50%; }
.stick { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; transform: translate(-50%,-50%); border: 1px solid rgba(213,235,255,.64); border-radius: 50%; background: radial-gradient(circle at 35% 28%, rgba(255,255,255,.32), rgba(73,134,193,.66) 38%, rgba(19,49,75,.88)); box-shadow: 0 5px 12px rgba(0,0,0,.36); transition: transform .12s ease; }
.joystick.is-active .stick { box-shadow: 0 0 0 6px rgba(116,185,255,.14), 0 5px 12px rgba(0,0,0,.36); }
.combat-cluster { position: absolute; z-index: 4; right: 16px; bottom: 116px; width: 156px; height: 156px; }
.skill { position: absolute; display: grid; place-items: center; width: 52px; height: 52px; border: 1px solid rgba(213,235,255,.54); border-radius: 50%; background: radial-gradient(circle at 36% 30%, rgba(255,255,255,.33), rgba(55,88,129,.8) 45%, rgba(10,19,31,.92)); box-shadow: 0 6px 14px rgba(0,0,0,.36); font-size: 20px; transition: transform .15s ease, filter .15s ease; }
.skill:hover, .skill:focus-visible { transform: scale(1.08); filter: brightness(1.25); }
.skill.main { right: 0; bottom: 14px; width: 78px; height: 78px; border-color: rgba(255,211,108,.74); background: radial-gradient(circle at 35% 25%, #ffdf9a, #ad5b4d 42%, #2b1b2e 78%); font-size: 29px; }
.skill.s1 { left: 12px; top: 5px; }
.skill.s2 { left: 52px; top: 52px; }
.skill.s3 { right: 4px; top: 3px; }
.skill.s4 { left: 0; bottom: 10px; }
.skill em { position: absolute; right: -2px; bottom: -2px; display: none; min-width: 18px; padding: 2px 4px; border-radius: 8px; background: #0a111c; color: #f9fcff; font-size: 10px; font-style: normal; }
.skill.cooling { filter: grayscale(.7) brightness(.58); }
.skill.cooling em { display: block; }
.context-action { position: absolute; z-index: 4; right: 23px; bottom: 279px; display: flex; align-items: center; gap: 6px; border: 1px solid rgba(101,225,178,.45); border-radius: 18px; padding: 7px 10px; background: rgba(14,44,46,.72); color: #adffe1; font-size: var(--text-xs); backdrop-filter: blur(10px); }
.utility-btn { min-width: 41px; height: 38px; display: grid; place-items: center; flex: 1; border: 1px solid rgba(213,235,255,.12); border-radius: 12px; background: rgba(116,185,255,.08); font-size: 17px; transition: all .15s ease; }
.utility-btn:hover, .utility-btn.active { border-color: rgba(116,185,255,.5); background: rgba(116,185,255,.21); transform: translateY(-2px); }
.utility-btn span { display: block; margin-top: 1px; color: var(--color-text-muted); font-size: 9px; }
.floating-cooldown { position: absolute; z-index: 5; left: 50%; top: 69%; transform: translate(-50%, -50%); padding: 9px 13px; border: 1px solid rgba(255,211,108,.42); border-radius: 16px; background: rgba(22,18,19,.74); color: #ffe4a0; font-size: var(--text-xs); opacity: 0; transition: opacity .2s ease; }
.floating-cooldown.show { opacity: 1; }
.modal-layer { position: absolute; z-index: 8; inset: 0; display: none; align-items: flex-end; background: rgba(2,6,10,.48); backdrop-filter: blur(6px); }
.modal-layer.open { display: flex; }
.sheet { width: 100%; max-height: 74%; overflow: auto; padding: 17px; border-top: 1px solid rgba(213,235,255,.32); border-radius: 24px 24px 0 0; background: rgba(12,21,33,.95); box-shadow: 0 -16px 42px rgba(0,0,0,.4); transform: translateY(20px); animation: rise .18s ease forwards; }
@keyframes rise { to { transform: translateY(0); } }
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.sheet-head h3 { margin: 0; font-size: var(--text-lg); }
.sheet-title-row { display: flex; align-items: center; gap: 8px; }
.close-btn { width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface-soft); }
.back-btn { width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface-soft); }
.action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.action-item { min-height: 58px; border: 1px solid rgba(213,235,255,.13); border-radius: 14px; background: rgba(116,185,255,.07); color: var(--color-text); transition: all .15s ease; }
.action-item:hover { border-color: rgba(116,185,255,.5); background: rgba(116,185,255,.16); transform: translateY(-2px); }
.action-item b { display: block; font-size: 20px; }
.action-item span { color: var(--color-text-muted); font-size: 10px; }
.menu-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.menu-item { min-height: 88px; padding: 10px 7px; border: 1px solid rgba(213,235,255,.16); border-radius: 15px; background: linear-gradient(145deg, rgba(116,185,255,.13), rgba(10,16,24,.35)); color: var(--color-text); text-align: center; transition: transform .15s ease, border-color .15s ease, background .15s ease; }
.menu-item:hover, .menu-item:focus-visible { transform: translateY(-2px); border-color: rgba(255,211,108,.72); background: rgba(116,185,255,.23); outline: none; }
.menu-item b { display: block; margin-bottom: 5px; color: var(--color-accent); font-size: 25px; line-height: 1; }
.menu-item span { display: block; font-size: var(--text-sm); }
.menu-item small { display: block; margin-top: 4px; color: var(--color-text-muted); font-size: 9px; line-height: 1.3; }
.sheet-note { margin: 10px 0 0; color: var(--color-text-muted); font-size: var(--text-xs); line-height: 1.4; }
.chat-list { display: grid; gap: 8px; margin-bottom: 10px; }
.chat-line { padding: 8px 10px; border-radius: 12px; background: rgba(255,255,255,.055); font-size: var(--text-sm); }
.chat-line strong { color: var(--color-primary); }
.chat-compose { display: flex; gap: 8px; }
.chat-compose input { min-width: 0; flex: 1; padding: 10px; border: 1px solid var(--color-border); border-radius: 12px; background: rgba(0,0,0,.25); color: var(--color-text); outline: none; }
.send-btn { padding: 0 13px; border: 1px solid rgba(101,225,178,.4); border-radius: 12px; background: rgba(101,225,178,.14); color: #adffe1; }
.annotation-label { position: fixed; z-index: 50; display: none; padding: 5px 7px; pointer-events: none; border: 1px solid rgba(255,211,108,.45); border-radius: 7px; background: #101a27; color: #ffe4a0; font: 11px var(--font-mono); box-shadow: var(--shadow-sm); }
body.annotate [data-label]:hover { outline: 1px dashed rgba(255,211,108,.7); outline-offset: 3px; }
.toolbar { position: fixed; z-index: 30; right: 16px; bottom: 16px; display: flex; align-items: center; gap: 7px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.16); border-radius: 12px; background: rgba(5,9,14,.78); color: #fff; font-size: 11px; opacity: .44; transition: opacity .2s ease; backdrop-filter: blur(12px); }
.toolbar:hover { opacity: 1; }
.toolbar button, .toolbar select { border: 1px solid rgba(255,255,255,.19); border-radius: 7px; padding: 5px 7px; background: rgba(255,255,255,.08); color: #fff; }
.toolbar button:hover, .toolbar select:hover { background: rgba(116,185,255,.22); }
.toast { position: fixed; z-index: 60; left: 50%; bottom: 72px; transform: translate(-50%, 12px); padding: 9px 14px; opacity: 0; border: 1px solid rgba(213,235,255,.28); border-radius: 12px; background: rgba(9,16,25,.93); color: var(--color-text); box-shadow: var(--shadow-md); transition: all .18s ease; pointer-events: none; font-size: var(--text-sm); }
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.interaction-section { max-width: 1040px; margin: 54px auto 0; }
.interaction-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
.interaction-head h2 { margin: 5px 0 6px; font-size: clamp(1.35rem, 2.6vw, 2rem); }
.interaction-head p { max-width: 720px; margin: 0; color: #c6b399; line-height: 1.55; }
.interaction-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: 330px; }
.legend-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 9px; border: 1px solid rgba(224, 181, 105, .26); border-radius: 999px; background: rgba(93, 53, 25, .2); color: #d8bd91; font-size: 10px; white-space: nowrap; }
.legend-chip i { width: 7px; height: 7px; border-radius: 50%; background: #e0b15e; box-shadow: 0 0 8px rgba(224, 177, 91, .7); }
.legend-chip.menu i { background: #8f6be8; box-shadow: 0 0 8px rgba(143, 107, 232, .7); }
.legend-chip.full i { background: #68baa0; box-shadow: 0 0 8px rgba(104, 186, 160, .7); }
.legend-chip.modal i { background: #e26c5c; box-shadow: 0 0 8px rgba(226, 108, 92, .7); }
.flow-filter { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 14px; }
.flow-filter button { padding: 7px 12px; border: 1px solid rgba(224, 181, 105, .24); border-radius: 999px; background: rgba(44, 24, 14, .62); color: #c8b18e; font-size: 11px; }
.flow-filter button.active, .flow-filter button:hover { border-color: rgba(239, 195, 113, .72); background: rgba(163, 100, 44, .28); color: #f5d998; }
.flow-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.flow-card { min-width: 0; padding: 14px; border: 1px solid rgba(224, 181, 105, .25); border-radius: 16px; background: linear-gradient(145deg, rgba(64, 35, 20, .72), rgba(20, 11, 8, .88)); box-shadow: 0 10px 26px rgba(0,0,0,.18); }
.flow-card.is-hidden { display: none; }
.flow-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.flow-card-head h3 { margin: 0; color: #f4e0b5; font-size: 14px; }
.flow-index { color: #d8a758; font: 11px var(--font-mono); letter-spacing: .08em; }
.flow-scope { color: #a99478; font-size: 10px; }
.flow-track { display: flex; align-items: stretch; gap: 6px; overflow-x: auto; padding: 2px 1px 5px; scrollbar-width: thin; }
.flow-node { position: relative; min-width: 104px; min-height: 67px; padding: 9px 8px; border: 1px solid rgba(224, 181, 105, .24); border-radius: 12px; background: rgba(16, 9, 7, .8); color: #f5ead7; text-align: left; transition: transform .15s ease, border-color .15s ease, background .15s ease; }
.flow-node:hover, .flow-node:focus-visible { transform: translateY(-2px); border-color: rgba(239, 195, 113, .85); background: rgba(117, 68, 32, .36); outline: none; }
.flow-node b { display: block; margin-bottom: 5px; font-size: 12px; }
.flow-node small { display: block; color: #bba586; font-size: 10px; line-height: 1.35; }
.flow-node.menu-node { border-color: rgba(143, 107, 232, .4); }
.flow-node.full-node { border-color: rgba(104, 186, 160, .4); }
.flow-node.modal-node { border-color: rgba(226, 108, 92, .42); }
.flow-arrow { align-self: center; flex: 0 0 auto; color: #d8a758; font-size: 16px; opacity: .78; }
.flow-detail { margin-top: 14px; min-height: 132px; padding: 16px 18px; border: 1px solid rgba(239, 195, 113, .34); border-radius: 16px; background: rgba(20, 10, 7, .84); box-shadow: 0 15px 32px rgba(0,0,0,.22); }
.flow-detail.empty { display: grid; place-items: center; color: #bba586; text-align: center; }
.flow-detail h3 { margin: 4px 0 8px; color: #f4e0b5; font-size: 17px; }
.detail-kicker { color: #d8a758; font: 10px var(--font-mono); letter-spacing: .14em; text-transform: uppercase; }
.detail-grid { display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: 14px; }
.detail-block strong { display: block; margin-bottom: 4px; color: #d8bd91; font-size: 11px; }
.detail-block p { margin: 0; color: #c8b18e; font-size: 11px; line-height: 1.5; }
.detail-block p + p { margin-top: 4px; }
.flow-note { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid rgba(224, 181, 105, .14); color: #9f8b70; font-size: 10px; line-height: 1.5; }
.hidden { display: none !important; }
@media (max-width: 620px) { body { padding-inline: 10px; } .variant-intro { display: block; padding-inline: 4px; } .variant-intro p { margin-top: 4px; } .toolbar { right: 8px; bottom: 8px; } }
/* Diablo-inspired ARPG direction: warm bronze frames, dark glass, circular combat controls. */
body { background: radial-gradient(circle at 50% 0%, #3a2416 0, #170e0b 35rem, #090706 70rem); }
.page-head p { color: #c6b399; }
.device { border-color: rgba(224, 181, 105, .72); border-radius: 22px; box-shadow: 0 28px 80px rgba(0,0,0,.7), 0 0 0 6px rgba(204, 147, 64, .08), inset 0 0 0 1px rgba(255, 229, 175, .14); }
.scene { background: linear-gradient(145deg, #725031, #b98045 40%, #4b2b1d 78%, #1b100c); }
.scene::before { opacity: .7; background: radial-gradient(ellipse at 34% 40%, rgba(255, 224, 152, .28), transparent 24%), radial-gradient(ellipse at 70% 28%, rgba(42, 16, 13, .4), transparent 27%), repeating-linear-gradient(151deg, rgba(255, 220, 155, .06) 0 2px, transparent 2px 38px); }
.scene::after { background: linear-gradient(180deg, rgba(12, 7, 5, .54), transparent 20%, transparent 62%, rgba(8, 5, 4, .7)); }
.terrain { opacity: .62; background: repeating-linear-gradient(164deg, transparent 0 17px, rgba(70, 35, 18, .18) 18px 20px), repeating-linear-gradient(22deg, transparent 0 35px, rgba(255, 218, 152, .16) 36px 38px); }
.hero { background: linear-gradient(90deg, #1a1210, #d2a85d 43%, #6b3d25 59%, #110b09); box-shadow: 0 0 20px rgba(255, 198, 93, .48); }
.hero::before { background: radial-gradient(circle at 40% 42%, #ffe0b2 0 18%, #6d3b2b 19% 54%, #160e0c 55%); }
.hero::after { color: #ffe0a1; text-shadow: 0 0 14px #ef812e; }
.mob { background: linear-gradient(140deg, #493021, #963f32 45%, #1c1110); box-shadow: 0 0 18px rgba(214, 69, 45, .35); }
.mob::before { background: #4f1e1b; }
.mob::after { color: #ffc0a0; }
.top-status { top: 15px; left: 18px; right: auto; }
.player-status { display: flex; width: 148px; flex-direction: column; align-items: center; gap: 3px; }
.player-name { display: flex; width: 148px; align-items: center; justify-content: center; gap: 6px; color: #f4e0b5; font: 600 11px var(--font-mono); text-shadow: 0 2px 4px #000; white-space: nowrap; }
.player-meters { width: 44px; }
.party-rail { position: absolute; z-index: 4; top: 112px; left: 18px; display: grid; width: 148px; gap: 5px; }
.party-member { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 34px; padding: 4px 6px 4px 4px; border: 1px solid rgba(223, 181, 111, .32); border-radius: 8px; background: rgba(19, 10, 7, .65); color: #f4e0b5; text-align: left; box-shadow: 0 4px 10px rgba(0,0,0,.28); transition: transform .15s ease, border-color .15s ease, background .15s ease; }
.party-member:hover, .party-member:focus-visible { transform: translateX(2px); border-color: rgba(239, 195, 113, .76); background: rgba(87, 46, 22, .78); outline: none; }
.party-avatar { display: grid; width: 25px; height: 25px; place-items: center; border: 1px solid rgba(239, 195, 112, .6); border-radius: 50%; background: radial-gradient(circle, #89684e, #291711 68%); color: #f7d58f; font-size: 12px; }
.party-copy { min-width: 0; }
.party-copy strong { display: block; overflow: hidden; color: #e9d5ae; font-size: 9px; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; }
.party-hp { height: 4px; margin-top: 3px; overflow: hidden; border-radius: 4px; background: rgba(5, 3, 2, .72); }
.party-hp i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a2525, #e85a46); }
.party-distance { color: #bfa77f; font: 9px var(--font-mono); }
.top-right-controls { position: absolute; z-index: 4; top: 14px; right: 14px; display: flex; align-items: flex-start; gap: 7px; }
.top-right-controls .menu-button { min-width: 42px; width: 42px; height: 42px; flex: 0 0 auto; background: rgba(17, 9, 7, .78); border-color: rgba(224, 177, 91, .5); box-shadow: 0 4px 10px rgba(0,0,0,.42); }
.avatar { border-color: rgba(239, 195, 112, .92); background: radial-gradient(circle, #99704c, #2b1a13 62%, #0d0907); box-shadow: 0 0 0 3px rgba(13, 8, 6, .7), 0 0 16px rgba(231, 167, 68, .22); }
.level { color: #f4ca7b; }
.meter { height: 9px; border-color: rgba(255, 227, 170, .25); background: rgba(11, 6, 5, .82); box-shadow: inset 0 1px 2px rgba(0,0,0,.45); }
.meter.hp i { background: linear-gradient(90deg, #761b1e, #e2432c 72%, #ff8961); }
.meter.mp i { background: linear-gradient(90deg, #3c247f, #8454f0 72%, #c58aff); }
.mini-map { width: 142px; height: 86px; border-radius: 9px; border-color: rgba(223, 187, 119, .72); background: radial-gradient(circle at 43% 58%, rgba(226, 179, 83, .42) 0 7%, transparent 8%), linear-gradient(32deg, #21150e 0 31%, #6a4323 32% 39%, #271711 40% 63%, #80603b 64%); box-shadow: inset 0 0 0 4px rgba(8, 5, 4, .68), 0 6px 14px rgba(0,0,0,.48); }
.mini-map::before { left: 47%; top: 43%; background: #f8d17e; box-shadow: 0 0 0 4px rgba(255, 205, 106, .2), 16px -20px 0 -2px #d65838; }
.mini-map::after { content: "荒漠边境"; position: absolute; right: 7px; bottom: 5px; color: rgba(245, 213, 157, .7); font: 10px var(--font-mono); letter-spacing: .08em; }
.target-card { top: 13px; width: 260px; padding: 4px 12px 6px; border: 0; border-radius: 0; background: rgba(13, 8, 6, .2); box-shadow: none; text-align: center; }
.target-card strong { color: #f4e0b5; font: 600 15px var(--font-mono); letter-spacing: .1em; text-shadow: 0 2px 4px #000; }
.target-card small { color: #d0b285; }
.target-card .meter { height: 11px; margin-top: 5px; border-color: rgba(10, 5, 4, .9); }
.target-card .meter i { background: linear-gradient(90deg, #6d171a, #d43d2e 75%, #f06d45); }
.quest-pill { top: 109px; right: 168px; border-color: rgba(224, 177, 91, .38); background: rgba(24, 13, 9, .65); color: #f1d49a; box-shadow: 0 5px 13px rgba(0,0,0,.25); }
.chat-bubble { top: 57%; background: rgba(17, 9, 7, .72); border-color: rgba(236, 197, 133, .28); color: #f4e6cb; }
.context-action { right: 235px; bottom: 104px; border-color: rgba(219, 173, 93, .58); background: rgba(25, 12, 8, .74); color: #f6d593; box-shadow: 0 5px 15px rgba(0,0,0,.32); }
.right-rail { position: absolute; z-index: 4; top: 111px; right: 14px; display: grid; gap: 8px; }
.right-rail .utility-btn { min-width: 42px; width: 42px; height: 42px; flex: 0 0 auto; background: rgba(17, 9, 7, .64); border-color: rgba(216, 171, 99, .3); box-shadow: 0 4px 10px rgba(0,0,0,.3); }
.right-rail .utility-btn span { color: #d8bd91; }
.joystick { left: 25px; bottom: 18px; width: 120px; height: 120px; border-color: rgba(223, 181, 111, .32); background: radial-gradient(circle, rgba(185, 119, 58, .26) 0 28%, rgba(74, 39, 22, .42) 29% 62%, rgba(12, 7, 5, .26) 63%); box-shadow: inset 0 0 0 12px rgba(27, 13, 8, .28), 0 7px 18px rgba(0,0,0,.35); }
.joystick::before { border-color: rgba(238, 202, 139, .2); }
.stick { border-color: rgba(236, 194, 117, .58); background: radial-gradient(circle at 35% 28%, rgba(255, 239, 196, .28), rgba(114, 70, 38, .82) 38%, rgba(30, 14, 9, .94)); }
.combat-cluster { right: 20px; bottom: 15px; width: 190px; height: 190px; }
.combat-cluster::before { content: ""; position: absolute; inset: 16px -1px -2px 8px; border: 1px solid rgba(185, 132, 68, .3); border-radius: 50%; background: radial-gradient(circle, rgba(21, 10, 8, .56), rgba(12, 6, 5, .12) 68%, transparent 69%); box-shadow: 0 0 0 11px rgba(12, 6, 5, .18); }
.combat-cluster::after { content: ""; position: absolute; inset: 5px -4px 12px 5px; border: 2px solid rgba(219, 169, 83, .28); border-left-color: transparent; border-bottom-color: transparent; border-radius: 50%; transform: rotate(34deg); pointer-events: none; }
.skill { z-index: 1; width: 58px; height: 58px; border-color: rgba(232, 191, 115, .58); background: radial-gradient(circle at 35% 26%, rgba(247, 225, 177, .24), rgba(62, 28, 22, .94) 46%, rgba(12, 6, 6, .98)); box-shadow: 0 6px 14px rgba(0,0,0,.6), inset 0 0 0 3px rgba(6, 3, 3, .36); }
.skill:hover, .skill:focus-visible { border-color: #f4d28d; filter: brightness(1.3); box-shadow: 0 0 0 3px rgba(238, 187, 90, .2), 0 7px 16px rgba(0,0,0,.6); }
.skill.main { width: 88px; height: 88px; border-color: rgba(234, 196, 123, .86); background: radial-gradient(circle at 35% 25%, #7e5cf1, #321d75 48%, #0e0820 78%); box-shadow: 0 0 0 4px rgba(15, 7, 28, .75), 0 0 18px rgba(107, 65, 220, .6), 0 7px 17px rgba(0,0,0,.65); font-size: 31px; }
.skill.main:hover, .skill.main:focus-visible { box-shadow: 0 0 0 5px rgba(232, 190, 100, .27), 0 0 24px rgba(138, 92, 255, .78); }
.skill.s1 { left: 10px; top: 14px; }
.skill.s2 { left: 62px; top: 70px; }
.skill.s3 { right: 2px; top: 15px; }
.skill.s4 { left: 13px; bottom: 2px; }
#mobile-hud .skill.s4 { left: auto; right: 16px; top: -52px; bottom: auto; border-color: rgba(223, 66, 44, .86); background: radial-gradient(circle at 35% 25%, #f06a40, #7d1f25 45%, #1a080b 78%); }
.skill em { background: #140907; color: #f2d491; }
.utility-btn { border-color: rgba(215, 171, 98, .2); border-radius: 7px; background: rgba(124, 76, 40, .16); }
.utility-btn:hover, .utility-btn.active { border-color: rgba(240, 200, 123, .72); background: rgba(173, 107, 46, .28); }
.utility-btn span { color: #cdb58d; }
.modal-layer { background: rgba(7, 3, 2, .6); }
.sheet { border-top-color: rgba(226, 181, 99, .5); background: rgba(25, 14, 10, .97); }
.action-item { border-color: rgba(220, 176, 100, .24); background: rgba(139, 82, 40, .12); }
.action-item:hover { border-color: rgba(239, 195, 113, .7); background: rgba(163, 100, 44, .24); }
.action-item span, .sheet-note { color: #c8b18e; }
.menu-item { border-color: rgba(220, 176, 100, .3); background: linear-gradient(145deg, rgba(128, 73, 34, .38), rgba(30, 15, 9, .72)); box-shadow: inset 0 1px 0 rgba(255, 224, 163, .08), 0 6px 14px rgba(0,0,0,.2); }
.menu-item:hover, .menu-item:focus-visible { border-color: rgba(239, 195, 113, .82); background: linear-gradient(145deg, rgba(164, 95, 40, .52), rgba(44, 20, 10, .82)); }
.menu-item b { color: #f0c979; }
.menu-item small { color: #bda887; }
.send-btn { border-color: rgba(117, 186, 123, .5); background: rgba(75, 122, 73, .2); color: #c3e8b9; }
.toolbar { background: rgba(10, 6, 4, .88); border-color: rgba(228, 183, 103, .32); }
.toolbar button:hover, .toolbar select:hover { background: rgba(171, 104, 43, .28); }
.toast { background: rgba(20, 10, 7, .96); border-color: rgba(224, 178, 98, .42); }
@media (max-height: 460px) and (orientation: landscape) {
body { padding-top: 66px; }
.top-status { top: 10px; }
.player-status, .player-name { width: 122px; }
.player-meters { width: 44px; }
.player-name { font-size: 9px; }
.party-rail { top: 91px; left: 10px; width: 122px; gap: 4px; }
.party-member { min-height: 30px; padding-block: 3px; }
.party-avatar { width: 22px; height: 22px; font-size: 10px; }
.top-right-controls { top: 9px; right: 9px; }
.mini-map { width: 108px; height: 64px; }
.right-rail { top: 82px; }
.status-copy { flex-basis: 122px; width: 122px; }
.quest-pill { top: 84px; right: 128px; }
.target-card { top: 8px; }
.joystick { width: 96px; height: 96px; left: 18px; bottom: 13px; }
.combat-cluster { right: 14px; bottom: 10px; transform: scale(.82); transform-origin: bottom right; }
.context-action { bottom: 88px; }
}
@media (max-width: 820px) {
.interaction-head { display: block; }
.interaction-legend { justify-content: flex-start; margin-top: 12px; }
.flow-grid { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr; gap: 9px; }
}
</style>
</head>
<body>
<header class="page-head">
<div class="eyebrow">Mobile ARPG Control HUD / Sketch 001</div>
<h1>暗黑 ARPG 风格 · 横屏 HUD 草图</h1>
<p>按 844×390 横屏手机设计,保持世界画面优先,战斗操作触手可达;底部中央保持留白,低频功能统一收纳到右上角菜单。点击首页控件或下方交互图节点,查看完整的移动端 UI 流程。</p>
</header>
<main id="sketch-canvas" class="preview-phone">
<section id="mobile-hud" class="variant">
<div class="variant-intro"><h2>暗黑 ARPG 横屏 HUD</h2><p>左上按“名称→头像→HP/MP/资源短条”排列;右上按“背包→小地图→菜单”排列,地图通过点击小地图进入,底部中央保持留白。</p></div>
<div class="device-wrap"><div class="device" data-label="844 × 390 landscape · 暗黑 ARPG">
<div class="scene">
<div class="terrain"></div><div class="hero" data-label="玩家角色"></div><div class="mob"></div>
<div class="top-status" data-label="玩家状态"><div class="player-status"><div class="player-name"><span>Azure · 暮光骑士</span><span class="level">Lv.42</span></div><button class="avatar demo-btn" data-panel="character" data-label="打开角色状态"></button><div class="player-meters"><div class="meter hp" aria-label="生命值"><i></i></div><div class="meter mp" aria-label="法力值"><i></i></div><div class="meter resource" aria-label="职业资源:奥能 68%"><i></i></div></div></div></div>
<div class="party-rail" data-label="队伍信息:仅组队后显示"><button class="party-member demo-btn" data-panel="party" data-label="队员 Mira · 点击查看或定位"><span class="party-avatar">M</span><span class="party-copy"><strong>Mira · 圣骑士</strong><span class="party-hp"><i style="width: 83%"></i></span></span><span class="party-distance">12m</span></button><button class="party-member demo-btn" data-panel="party" data-label="队员 Ken · 点击查看或定位"><span class="party-avatar">K</span><span class="party-copy"><strong>Ken · 游侠</strong><span class="party-hp"><i style="width: 61%"></i></span></span><span class="party-distance">34m</span></button></div>
<div class="top-right-controls" data-label="顶部背包、地图与菜单"><button class="utility-btn top-bag demo-btn" data-panel="inventory" data-label="打开背包"><span>背包</span></button><button class="mini-map demo-btn" data-panel="map" aria-label="打开地图" data-label="小地图 / 地图"></button><button class="utility-btn menu-button demo-btn" data-panel="more" data-label="打开菜单"><span>菜单</span></button></div>
<button class="target-card demo-btn" data-panel="target" data-label="目标板"><strong>血狼 · Lv.40</strong><small>敌对目标</small><div class="meter"><i></i></div></button>
<button class="quest-pill demo-btn" data-panel="quest" data-label="任务追踪">◆ 采集狼牙 <b>2/5</b></button>
<div class="chat-bubble" data-label="头顶聊天气泡">附近:小心精英怪!</div>
<button class="context-action demo-btn" data-toast="靠近目标后:拾取 / 对话 / 钓鱼 / 进入副本">✦ 交互</button>
<div class="joystick" data-joystick data-label="虚拟摇杆"><div class="stick"></div></div>
<div class="combat-cluster" data-label="战斗技能区">
<button class="skill s1 demo-btn" data-skill="冰刃" data-toast="释放技能:冰刃"><em>8</em></button>
<button class="skill s2 demo-btn" data-skill="冲锋" data-toast="释放技能:冲锋"><em>5</em></button>
<button class="skill s3 demo-btn" data-skill="治疗" data-toast="释放技能:治疗"><em>12</em></button>
<button class="skill s4 demo-btn" data-skill="药水" data-toast="使用物品:红色药水"><em>3</em></button>
<button class="skill main demo-btn" data-skill="普攻" data-toast="普通攻击"></button>
</div>
<div class="floating-cooldown" data-cooldown>技能冷却中</div>
<div class="modal-layer" data-modal><div class="sheet"><div class="sheet-head"><div class="sheet-title-row"><button class="back-btn hidden" data-back aria-label="返回上一级"></button><h3 data-sheet-title>功能</h3></div><button class="close-btn" data-close>×</button></div><div data-sheet-body></div></div></div>
</div>
</div></div>
</section>
</main>
<section class="interaction-section" aria-labelledby="interaction-title">
<div class="interaction-head">
<div>
<div class="eyebrow">Interaction map / all screens</div>
<h2 id="interaction-title">全量 UI 移动端交互图</h2>
<p>每张图表示一条完整操作路径。点击任意节点,查看它的入口位置、触控方式、页面形态和返回路径;任务始终从任务栏进入,组队信息固定显示在左上玩家头像下方。</p>
</div>
<div class="interaction-legend" aria-label="页面形态图例">
<span class="legend-chip"><i></i>常驻 HUD</span>
<span class="legend-chip menu"><i></i>右上角菜单</span>
<span class="legend-chip full"><i></i>全屏面板</span>
<span class="legend-chip modal"><i></i>场景弹窗</span>
</div>
</div>
<div class="flow-filter" role="tablist" aria-label="交互图筛选">
<button class="active" data-flow-filter="all" role="tab" aria-selected="true">全部流程</button>
<button data-flow-filter="entry" role="tab" aria-selected="false">进入游戏</button>
<button data-flow-filter="combat" role="tab" aria-selected="false">战斗与任务</button>
<button data-flow-filter="character" role="tab" aria-selected="false">角色与物品</button>
<button data-flow-filter="social" role="tab" aria-selected="false">社交与经济</button>
<button data-flow-filter="system" role="tab" aria-selected="false">系统状态</button>
</div>
<div class="flow-grid" id="flow-grid">
<article class="flow-card" data-flow-group="entry">
<div class="flow-card-head"><h3>进入游戏与角色入口</h3><span class="flow-index">01 · ENTRY</span></div>
<div class="flow-track">
<button class="flow-node full-node" data-detail="login"><b>登录</b><small>账号密码<br>失败提示</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="server"><b>服务器</b><small>频道选择<br>连接确认</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="character_select"><b>角色列表</b><small>选择 / 创建<br>删除 / 改名</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="loading"><b>加载</b><small>资源加载<br>进入场景</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="hud"><b>首页 HUD</b><small>开始操作</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="combat">
<div class="flow-card-head"><h3>首页战斗与掉落</h3><span class="flow-index">02 · COMBAT</span></div>
<div class="flow-track">
<button class="flow-node" data-detail="hud"><b>首页 HUD</b><small>左上状态<br>右上入口</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="movement"><b>移动摇杆</b><small>拖动移动<br>松手复位</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="target"><b>锁定目标</b><small>点击目标<br>显示血条</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="combat_action"><b>普攻 / 技能</b><small>点击释放<br>冷却反馈</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="loot"><b>掉落 / 交互</b><small>拾取、对话<br>进入副本</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="death"><b>死亡 / 复活</b><small>战斗失败<br>回城或复活</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="combat">
<div class="flow-card-head"><h3>任务、地图与副本</h3><span class="flow-index">03 · QUEST</span></div>
<div class="flow-track">
<button class="flow-node" data-detail="quest"><b>任务栏</b><small>首页常驻<br>直接点击</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="quest_log"><b>任务日志</b><small>列表 / 奖励<br>任务详情</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="map"><b>小地图 / 大地图</b><small>点击小地图<br>地图信标</small></button><span class="flow-arrow"></span>
<button class="flow-node" data-detail="navigation"><b>自动寻路</b><small>导航至目标<br>到达提示</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="npc_quest"><b>NPC 对话</b><small>接取 / 完成<br>任务奖励</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="dungeon_result"><b>副本结算</b><small>奖励 / 再次挑战<br>退出副本</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="character">
<div class="flow-card-head"><h3>角色、装备与技能</h3><span class="flow-index">04 · CHARACTER</span></div>
<div class="flow-track">
<button class="flow-node" data-detail="character_entry"><b>玩家头像</b><small>左上角<br>唯一入口</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="character_status"><b>角色属性</b><small>等级、属性<br>成长信息</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="equipment"><b>装备</b><small>穿戴、替换<br>查看对比</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="skills"><b>技能</b><small>主动、被动<br>坐骑、表情</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="skill_upgrade"><b>技能加点</b><small>查看消耗<br>确认升级</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="inspect"><b>查看玩家</b><small>点击角色<br>查看装备</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="character">
<div class="flow-card-head"><h3>背包、物品与仓库</h3><span class="flow-index">05 · ITEMS</span></div>
<div class="flow-track">
<button class="flow-node menu-node" data-detail="inventory"><b>背包</b><small>右上快捷入口<br>菜单也可进入</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="item_tooltip"><b>物品详情</b><small>点击选中<br>长按查看</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="item_action"><b>使用 / 装备 / 出售</b><small>按钮操作<br>危险操作确认</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="safebox"><b>仓库</b><small>背包与仓库<br>左右切换</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="select_item"><b>任务物品选择</b><small>选择数量<br>提交确认</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="social">
<div class="flow-card-head"><h3>队伍状态、聊天、好友与公会</h3><span class="flow-index">06 · SOCIAL</span></div>
<div class="flow-track">
<button class="flow-node" data-detail="party"><b>左侧队伍条</b><small>头像下方<br>成员状态</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="party"><b>队员信息</b><small>查看 / 定位<br>离队设置</small></button><span class="flow-arrow"></span>
<button class="flow-node menu-node" data-detail="social_entry"><b>右上菜单</b><small>社交入口<br>不含队伍</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="chat"><b>聊天</b><small>综合、私聊<br>队伍、公会</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="friend"><b>好友</b><small>申请、私聊<br>查看资料</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="love"><b>情侣 / 爱意</b><small>关系信息<br>互动操作</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="guild"><b>公会</b><small>成员、技能<br>公告、公会战</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="social">
<div class="flow-card-head"><h3>商店、交易与制作</h3><span class="flow-index">07 · ECONOMY</span></div>
<div class="flow-track">
<button class="flow-node modal-node" data-detail="commerce_entry"><b>NPC / 玩家</b><small>靠近后点击<br>出现交互</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="shop"><b>商店 / 商城</b><small>购买、出售<br>商城物品</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="trade"><b>玩家交易</b><small>拖入物品<br>双方确认</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="private_shop"><b>私人商店</b><small>浏览、购买<br>摆摊管理</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="cube"><b>制作</b><small>放入材料<br>制作确认</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="refine"><b>精炼 / 龙魂</b><small>强化结果<br>失败确认</small></button>
</div>
</article>
<article class="flow-card" data-flow-group="system">
<div class="flow-card-head"><h3>系统设置与异常状态</h3><span class="flow-index">08 · SYSTEM</span></div>
<div class="flow-track">
<button class="flow-node menu-node" data-detail="system_entry"><b>右上菜单</b><small>系统入口<br>随时可打开</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="options"><b>系统 / 游戏设置</b><small>音量、画面<br>操作、屏蔽</small></button><span class="flow-arrow"></span>
<button class="flow-node full-node" data-detail="help"><b>帮助</b><small>操作说明<br>功能说明</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="character_switch"><b>选择角色</b><small>离开当前角色<br>二次确认</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="logout"><b>登出 / 退出</b><small>保存状态<br>确认离开</small></button><span class="flow-arrow"></span>
<button class="flow-node modal-node" data-detail="reconnect"><b>断线重连</b><small>连接恢复<br>重新进入场景</small></button>
</div>
</article>
</div>
<aside class="flow-detail empty" id="flow-detail" aria-live="polite">
<div><div class="detail-kicker">Tap a node</div><p>点击上方任意节点,查看该界面的移动端入口、位置、交互方式和返回路径。</p></div>
</aside>
</section>
<div class="toolbar" id="sketch-tools" aria-label="草图工具">
<span>横屏预览</span><button data-viewport="phone">手机</button><button data-viewport="tablet">平板</button><button data-viewport="desktop">桌面</button>
<select id="theme-switcher" aria-label="主题"><option value="default">暗黑铜金</option></select>
<button id="annotation-toggle">标注</button>
</div>
<div class="toast" id="toast" role="status"></div><div class="annotation-label" id="annotation-label"></div>
<script>
const body = document.body;
const canvas = document.getElementById('sketch-canvas');
const toast = document.getElementById('toast');
let toastTimer;
const featureCatalog = [
['♟', '社交', 'social', '聊天 · 好友 · 公会'],
['✧', '成长', 'growth', '强化 · 制作 · 龙魂'],
['▣', '活动', 'adventure', '副本 · 钓鱼'],
['◈', '商业', 'market', '商店 · 交易 · 仓库'],
['⚙', '系统', 'system', '设置 · 帮助 · 退出']
];
const featureCopy = {
inventory: '<p class="sheet-note">装备 / 腰带 / 物品拖放 · 右侧滑出面板,回到战斗时自动收起。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="拖动红药到快捷栏">♥<span>红药 ×12</span></button><button class="action-item demo-btn" data-toast="查看装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-toast="打开腰带">▤<span>腰带</span></button><button class="action-item demo-btn" data-toast="整理背包">✦<span>整理</span></button></div>',
character: '<p class="sheet-note">头像入口承载 HP / MP、状态、装备、技能、坐骑和表情动作。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开状态页">♥<span>状态</span></button><button class="action-item demo-btn" data-toast="查看装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-panel="skills"><b>✦</b><span>技能</span></button><button class="action-item demo-btn" data-panel="mount"><b>♞</b><span>坐骑</span></button><button class="action-item demo-btn" data-panel="emote"><b>☺</b><span>表情</span></button><button class="action-item demo-btn" data-toast="打开称号页">◆<span>称号</span></button></div>',
quest: '<p class="sheet-note">任务追踪使用半透明卡片;点击任务行进入日志,地图信标从卡片右上角进入。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="追踪:采集狼牙">◆<span>追踪</span></button><button class="action-item demo-btn" data-toast="打开任务日志">▤<span>日志</span></button><button class="action-item demo-btn" data-toast="显示地图信标">⌖<span>导航</span></button><button class="action-item demo-btn" data-toast="收起任务条">×<span>隐藏</span></button></div>',
map: '<p class="sheet-note">小地图点击后升起全屏地图,保留副本目的地罗盘和任务信标。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="地图缩放 +"><span>放大</span></button><button class="action-item demo-btn" data-toast="地图缩放 -"><span>缩小</span></button><button class="action-item demo-btn" data-toast="查看世界地图">⌖<span>世界地图</span></button><button class="action-item demo-btn" data-toast="回到玩家位置">●<span>定位</span></button></div>',
target: '<p class="sheet-note">目标板支持切换目标、查看血量和情境动作;按住目标卡可进入更多交互。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="切换目标">⇄<span>切换</span></button><button class="action-item demo-btn" data-toast="开始自动攻击">⚔<span>攻击</span></button><button class="action-item demo-btn" data-toast="打开目标信息">? <span>信息</span></button><button class="action-item demo-btn" data-toast="取消目标">×<span>清除</span></button></div>',
chat: '<div class="chat-list"><div class="chat-line"><strong>[队伍]</strong> Mira:副本入口在北门。</div><div class="chat-line"><strong>[附近]</strong> 你:收到,马上到。</div><div class="chat-line"><strong>[公会]</strong> Ken:今晚 20:00 公会战。</div></div><div class="chat-compose"><input placeholder="输入消息…"><button class="send-btn" data-send>发送</button></div>',
party: '<p class="sheet-note">队伍信息从左上玩家头像下方的队员条进入;未组队时该区域自动隐藏。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="定位队员">⌖<span>定位</span></button><button class="action-item demo-btn" data-toast="查看队员资料">♟<span>资料</span></button><button class="action-item demo-btn" data-toast="打开队伍设置">⚙<span>设置</span></button><button class="action-item demo-btn" data-toast="离开队伍确认">×<span>离队</span></button></div>',
more: '<p class="sheet-note">菜单只保留五个一级入口;角色相关功能点击左上头像进入,背包、小地图和任务仍从首页直接进入。</p><div class="menu-grid">' + featureCatalog.map(([icon, name, key, note]) => `<button class="menu-item demo-btn" data-panel="${key}" data-toast="打开${name}"><b>${icon}</b><span>${name}</span><small>${note}</small></button>`).join('') + '</div>',
guild: '<p class="sheet-note">公会信息、成员、会徽、公会战和公告从左侧社交轨进入。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开公会成员">♟<span>成员</span></button><button class="action-item demo-btn" data-toast="查看公会技能">✧<span>技能</span></button><button class="action-item demo-btn" data-toast="查看公会会徽">✦<span>会徽</span></button><button class="action-item demo-btn" data-toast="查看公会战">⚔<span>公会战</span></button></div>'
};
Object.assign(featureCopy, {
social: '<p class="sheet-note">社交菜单只保留聊天、好友、公会、情侣和观战;队伍状态固定在左上玩家头像下方。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="chat"><b>☵</b><span>聊天</span></button><button class="action-item demo-btn" data-panel="friend"><b>♧</b><span>好友</span></button><button class="action-item demo-btn" data-panel="guild"><b>♜</b><span>公会</span></button><button class="action-item demo-btn" data-panel="love"><b>♥</b><span>情侣</span></button><button class="action-item demo-btn" data-panel="observer"><b>◉</b><span>观战</span></button></div>',
growth: '<p class="sheet-note">成长功能统一处理角色长期养成;技能、坐骑和表情仍归入角色页面,避免重复入口。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="refine"><b>⚒</b><span>精炼</span></button><button class="action-item demo-btn" data-panel="dragon"><b>✧</b><span>龙魂</span></button><button class="action-item demo-btn" data-panel="cube"><b>▣</b><span>制作</span></button></div>',
adventure: '<p class="sheet-note">活动通过任务追踪、场景交互或这里进入;地图仍只通过右上小地图打开。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="dungeon"><b>▣</b><span>副本</span></button><button class="action-item demo-btn" data-panel="fishing"><b>♨</b><span>钓鱼</span></button></div>',
market: '<p class="sheet-note">涉及货币与物品流转的功能统一归入商业;背包仍在首页右上角单独打开。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="shop"><b>▤</b><span>商店</span></button><button class="action-item demo-btn" data-panel="mall"><b>◈</b><span>商城</span></button><button class="action-item demo-btn" data-panel="trade"><b>⇄</b><span>交易</span></button><button class="action-item demo-btn" data-panel="private_shop"><b>▥</b><span>私人商店</span></button><button class="action-item demo-btn" data-panel="safebox"><b>▤</b><span>仓库</span></button><button class="action-item demo-btn" data-panel="exchange"><b>↔</b><span>兑换</span></button></div>',
friend: '<p class="sheet-note">好友与申请使用统一列表;选择好友后可私聊、查看资料或邀请组队。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看好友申请"><span>申请</span></button><button class="action-item demo-btn" data-toast="打开好友资料">♧<span>资料</span></button><button class="action-item demo-btn" data-toast="发起私聊">☵<span>私聊</span></button><button class="action-item demo-btn" data-toast="邀请组队">♟<span>组队</span></button></div>',
love: '<p class="sheet-note">情侣与爱意信息放在社交分组内,避免成为首页固定入口。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看关系信息">♥<span>关系</span></button><button class="action-item demo-btn" data-toast="查看爱意等级">✦<span>等级</span></button><button class="action-item demo-btn" data-toast="发送互动">☺<span>互动</span></button><button class="action-item demo-btn" data-toast="打开情侣设置">⚙<span>设置</span></button></div>',
skills: '<p class="sheet-note">技能分为主动、被动、坐骑和表情动作;战斗中的技能按钮由此处配置。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开主动技能">❄<span>主动</span></button><button class="action-item demo-btn" data-toast="打开被动技能">✧<span>被动</span></button><button class="action-item demo-btn" data-toast="打开坐骑技能">♞<span>坐骑</span></button><button class="action-item demo-btn" data-toast="打开表情动作">☺<span>表情</span></button></div>',
shop: '<p class="sheet-note">NPC 商店支持购买和出售;商城使用同一套商品详情与确认交互。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开购买列表"><span>购买</span></button><button class="action-item demo-btn" data-toast="打开出售列表"><span>出售</span></button><button class="action-item demo-btn" data-toast="查看商品详情">? <span>详情</span></button><button class="action-item demo-btn" data-toast="确认交易金额">✓<span>确认</span></button></div>',
mall: '<p class="sheet-note">商城从菜单进入,购买前查看价格、限购和获得方式,支付动作使用二次确认。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="浏览商城分类">▤<span>分类</span></button><button class="action-item demo-btn" data-toast="查看商品详情">? <span>详情</span></button><button class="action-item demo-btn" data-toast="加入购物车"><span>加入</span></button><button class="action-item demo-btn" data-toast="确认购买">✓<span>购买</span></button></div>',
safebox: '<p class="sheet-note">仓库采用背包与仓库左右切换;长按物品看详情,拖动完成存入或取出。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="存入仓库">↓<span>存入</span></button><button class="action-item demo-btn" data-toast="取出物品">↑<span>取出</span></button><button class="action-item demo-btn" data-toast="整理仓库">✦<span>整理</span></button><button class="action-item demo-btn" data-toast="切换商城仓库">▤<span>商城仓库</span></button></div>',
trade: '<p class="sheet-note">玩家交易通过点击玩家或交互按钮进入;双方放入物品后分别确认,避免误操作。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="拖入交易物品">⇄<span>放入</span></button><button class="action-item demo-btn" data-toast="查看对方物品">? <span>查看</span></button><button class="action-item demo-btn" data-toast="锁定交易内容">▣<span>锁定</span></button><button class="action-item demo-btn" data-toast="等待对方确认">✓<span>确认</span></button></div>',
private_shop: '<p class="sheet-note">私人商店通过点击摊位或玩家进入;浏览和摆摊管理共用一个全屏面板。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="浏览私人商店">▤<span>浏览</span></button><button class="action-item demo-btn" data-toast="购买摊位物品"><span>购买</span></button><button class="action-item demo-btn" data-toast="打开摆摊管理">⚒<span>管理</span></button><button class="action-item demo-btn" data-toast="关闭私人商店">×<span>关闭</span></button></div>',
exchange: '<p class="sheet-note">兑换界面展示兑换材料、产出和数量;滑动数量后点击兑换并确认。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择兑换材料">▥<span>材料</span></button><button class="action-item demo-btn" data-toast="调整兑换数量">↔<span>数量</span></button><button class="action-item demo-btn" data-toast="查看兑换产出">✦<span>产出</span></button><button class="action-item demo-btn" data-toast="确认兑换">✓<span>兑换</span></button></div>',
cube: '<p class="sheet-note">Cube 制作将材料槽放在中央,底部只保留一个主要制作按钮,制作完成后弹出结果。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择制作配方">▤<span>配方</span></button><button class="action-item demo-btn" data-toast="放入制作材料"><span>材料</span></button><button class="action-item demo-btn" data-toast="查看制作结果">? <span>预览</span></button><button class="action-item demo-btn" data-toast="确认制作">⚒<span>制作</span></button></div>',
refine: '<p class="sheet-note">精炼和龙魂使用同一套强化确认流程:选择装备、查看成功率和消耗,再确认结果。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择强化装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-toast="查看强化材料">▥<span>材料</span></button><button class="action-item demo-btn" data-toast="查看成功率"><span>成功率</span></button><button class="action-item demo-btn" data-toast="确认强化">✧<span>强化</span></button></div>',
dragon: '<p class="sheet-note">龙魂属于制作与强化分支,保留独立页面入口但不放在首页常驻 HUD。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看龙魂槽位">✧<span>槽位</span></button><button class="action-item demo-btn" data-toast="选择龙魂">◇<span>选择</span></button><button class="action-item demo-btn" data-toast="查看龙魂属性">? <span>属性</span></button><button class="action-item demo-btn" data-toast="确认镶嵌">✓<span>镶嵌</span></button></div>',
fishing: '<p class="sheet-note">钓鱼是场景情境交互:靠近钓点后出现交互按钮,完成小游戏后返回场景。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="开始钓鱼">♨<span>开始</span></button><button class="action-item demo-btn" data-toast="收杆">↗<span>收杆</span></button><button class="action-item demo-btn" data-toast="查看鱼获">▥<span>鱼获</span></button><button class="action-item demo-btn" data-toast="退出钓鱼">×<span>退出</span></button></div>',
dungeon: '<p class="sheet-note">副本入口通过任务导航或场景交互按钮进入;副本内显示目标罗盘,完成后进入结算。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看副本目标">⌖<span>目标</span></button><button class="action-item demo-btn" data-toast="查看队伍状态">♟<span>队伍</span></button><button class="action-item demo-btn" data-toast="退出副本">×<span>退出</span></button><button class="action-item demo-btn" data-toast="开始副本">▣<span>进入</span></button></div>',
mount: '<p class="sheet-note">坐骑从技能页面配置,也可在场景中通过情境按钮召唤和收起。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="召唤坐骑">♞<span>召唤</span></button><button class="action-item demo-btn" data-toast="打开坐骑技能">✧<span>技能</span></button><button class="action-item demo-btn" data-toast="更换坐骑">⇄<span>更换</span></button><button class="action-item demo-btn" data-toast="收起坐骑">×<span>收起</span></button></div>',
observer: '<p class="sheet-note">观战从社交菜单或队员资料进入,观战时隐藏自身战斗操作,保留退出观战按钮。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择观战对象">◉<span>对象</span></button><button class="action-item demo-btn" data-toast="切换观战视角">↔<span>视角</span></button><button class="action-item demo-btn" data-toast="查看战斗信息">? <span>信息</span></button><button class="action-item demo-btn" data-toast="退出观战">×<span>退出</span></button></div>',
emote: '<p class="sheet-note">表情动作从技能菜单配置,战斗中不占常驻按钮;选择后在角色头顶显示反馈。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="播放表情:挥手">☺<span>挥手</span></button><button class="action-item demo-btn" data-toast="播放表情:欢呼">✦<span>欢呼</span></button><button class="action-item demo-btn" data-toast="播放动作:坐下">♟<span>动作</span></button><button class="action-item demo-btn" data-toast="设置快捷表情"><span>配置</span></button></div>',
system: '<p class="sheet-note">系统菜单从右上角进入,采用右侧抽屉;设置和帮助进入全屏面板,离开游戏使用确认弹窗。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="options"><b>⚙</b><span>设置</span></button><button class="action-item demo-btn" data-panel="help"><b>?</b><span>帮助</span></button><button class="action-item demo-btn" data-toast="打开角色选择">♜<span>角色</span></button><button class="action-item demo-btn" data-toast="打开退出确认">×<span>退出</span></button></div>',
options: '<p class="sheet-note">设置分为音频、画面、操作和社交屏蔽;滑动条与开关都在全屏面板中完成。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="调整音乐音量">♫<span>音乐</span></button><button class="action-item demo-btn" data-toast="调整音效音量">♪<span>音效</span></button><button class="action-item demo-btn" data-toast="调整镜头距离">◎<span>镜头</span></button><button class="action-item demo-btn" data-toast="保存设置">✓<span>保存</span></button></div>',
help: '<p class="sheet-note">帮助按移动、战斗、任务、社交和系统分类,使用短卡片说明,支持上下滚动。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看移动说明">↕<span>移动</span></button><button class="action-item demo-btn" data-toast="查看战斗说明">⚔<span>战斗</span></button><button class="action-item demo-btn" data-toast="查看系统说明">⚙<span>系统</span></button><button class="action-item demo-btn" data-toast="查看快捷操作">✦<span>快捷操作</span></button></div>',
reconnect: '<p class="sheet-note">断线时覆盖场景显示连接状态;恢复成功后自动回到原位置,失败时提供重试或返回角色列表。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="重新连接中">↻<span>重试</span></button><button class="action-item demo-btn" data-toast="查看网络状态">⌁<span>网络</span></button><button class="action-item demo-btn" data-toast="返回角色列表">♜<span>角色列表</span></button><button class="action-item demo-btn" data-toast="关闭提示">×<span>关闭</span></button></div>'
});
const titles = { inventory: '背包', character: '角色', social: '社交', growth: '成长', adventure: '活动', market: '商业', skills: '技能', quest: '任务', map: '地图', party: '社交 / 队伍', friend: '好友', love: '情侣 / 爱意', guild: '公会', target: '目标', chat: '聊天', shop: '商店', mall: '商城', safebox: '仓库', trade: '玩家交易', private_shop: '私人商店', exchange: '兑换', cube: 'Cube 制作', refine: '精炼 / 强化', dragon: '龙魂', fishing: '钓鱼', dungeon: '副本', mount: '坐骑', observer: '观战', emote: '表情动作', system: '系统', help: '帮助', options: '设置', more: '功能菜单' };
const flowDetails = {
login: { kind: '全屏入口', title: '登录界面', entry: '启动游戏后全屏显示;账号密码输入区保持在横屏安全区中央。', touch: '点击输入框唤起键盘,点击登录提交;错误信息在表单下方即时提示。', back: '登录成功进入服务器选择;失败留在当前页并保留已输入账号。', note: '不与战斗 HUD 共存。' },
server: { kind: '全屏入口', title: '服务器 / 频道选择', entry: '登录成功后进入;服务器列表居中,推荐频道置顶。', touch: '点击频道选中,再点击进入;网络状态和人数使用标签显示。', back: '返回登录界面,或进入角色列表。', note: '连接失败直接复用断线重连提示。' },
character_select: { kind: '全屏入口', title: '角色列表与角色管理', entry: '服务器选择后进入;角色卡横向排列。', touch: '点击角色选中,点击创建、删除、改名;删除和改名必须二次确认。', back: '返回服务器选择或进入加载界面。', note: '阵营、职业和等级放在角色卡内,不进入战斗 HUD。' },
loading: { kind: '状态弹窗', title: '加载界面', entry: '角色进入场景、切换地图或副本时覆盖全屏。', touch: '无需操作;加载失败显示重试按钮。', back: '加载完成进入首页 HUD,异常时转断线重连。', note: '避免用户在资源未完成时点击世界。' },
hud: { kind: '常驻 HUD', title: '首页 HUD', entry: '进入场景后的默认页面;世界画面优先。', touch: '左上头像看角色,右上背包/小地图/菜单,任务栏直接进入任务,底部两侧完成移动和战斗。', back: '所有功能面板关闭后回到这里。', note: '名称在头像顶部,HP/MP 条在头像下方且与头像同宽。' },
movement: { kind: '常驻 HUD', title: '移动摇杆', entry: '首页左下角,仅作为战斗操作控件。', touch: '按住并拖动控制方向,松手复位;支持边缘拖动防止拇指滑出。', back: '没有独立页面,松手即返回待机状态。', note: '底部中央保持空白,不放功能菜单。' },
target: { kind: '常驻 HUD', title: '目标选择与目标板', entry: '点击敌人、NPC 或目标板触发;目标板位于顶部中央。', touch: '点击切换目标,长按查看信息;普通攻击和技能自动作用于当前目标。', back: '点击空白清除目标,或选择新目标。', note: '目标血条不与玩家血条混淆。' },
combat_action: { kind: '常驻 HUD', title: '普通攻击与技能', entry: '首页右下角环形战斗盘。', touch: '点击普攻;点击技能释放;长按可预览需要瞄准的技能;冷却用灰度和数字反馈。', back: '释放完成回到战斗状态,技能配置从菜单进入。', note: '药水可作为战斗盘上的物品按钮,不另设底部功能栏。' },
loot: { kind: '场景弹窗', title: '掉落物与情境交互', entry: '靠近掉落物、NPC、钓点或副本入口时,在角色附近出现交互按钮。', touch: '点击拾取、对话、钓鱼或进入;掉落物名称显示在世界中并支持点击。', back: '动作完成回到场景;物品自动进入背包并给出短暂提示。', note: '不在首页固定显示拾取菜单。' },
death: { kind: '场景弹窗', title: '死亡与复活', entry: 'HP 归零后覆盖场景中央。', touch: '点击原地复活、回城或等待;危险选项二次确认。', back: '复活后回到首页 HUD,回城后进入加载流程。', note: '死亡时暂时禁用摇杆和技能按钮。' },
quest: { kind: '常驻 HUD', title: '任务追踪栏', entry: '首页上方常驻显示,不放入右上菜单。', touch: '点击任务行进入日志;点击目标或导航标记进入地图/自动寻路。', back: '关闭任务日志后回到首页,任务进度仍保留。', note: '任务栏是任务系统的唯一首页快捷入口。' },
quest_log: { kind: '全屏面板', title: '任务日志', entry: '点击任务追踪栏打开;采用列表 + 详情布局。', touch: '点击任务切换详情,滚动查看奖励;点击追踪更新首页任务栏。', back: '关闭后返回首页 HUD,不改变当前追踪任务。', note: '任务地图标记从详情页进入。' },
map: { kind: '全屏面板', title: '小地图与大地图', entry: '点击右上小地图进入;没有独立地图按钮。', touch: '双指缩放、拖动地图、点击信标导航,点击定位回到玩家位置。', back: '点击关闭或系统返回回到首页 HUD。', note: '背包已替代原地图快捷按钮。' },
navigation: { kind: '常驻 + 场景', title: '自动寻路', entry: '从任务详情、地图信标或 NPC 目标触发。', touch: '点击导航开始;到达后显示交互按钮;移动摇杆可随时接管。', back: '手动移动、点击停止或到达目的地后结束。', note: '不额外占用常驻 HUD 按钮。' },
npc_quest: { kind: '场景弹窗', title: 'NPC 对话与任务', entry: '靠近 NPC 后点击角色或情境交互按钮。', touch: '点击对话选项;接取/完成任务使用明确的主按钮。', back: '关闭对话回到场景;完成后刷新任务栏和奖励提示。', note: '商店、仓库和任务可以在同一 NPC 对话入口分流。' },
dungeon_result: { kind: '场景弹窗', title: '副本结算', entry: '副本完成或失败后自动弹出。', touch: '滚动查看奖励,点击再次挑战或退出;奖励领取使用主按钮。', back: '退出回到场景,或再次挑战进入加载界面。', note: '副本内保留目标罗盘和队伍状态。' },
character_entry: { kind: '头像入口', title: '角色功能入口', entry: '只点击左上角玩家头像进入。', touch: '点击头像进入角色页,在其中切换属性、装备、技能、坐骑和外观。', back: '关闭角色面板回到首页 HUD。', note: '角色不出现在右上菜单。' },
character_status: { kind: '全屏面板', title: '角色属性', entry: '左上头像 → 角色属性。', touch: '左右切换属性分类,滚动查看等级、属性、经验和资源。', back: '返回角色入口,再返回首页。', note: '战斗中只展示精简状态条。' },
equipment: { kind: '全屏面板', title: '装备界面', entry: '左上头像 → 角色 → 装备。', touch: '点击装备槽查看,长按物品详情,拖动穿戴或替换。', back: '关闭装备页返回角色或首页。', note: '物品对比使用上下对照卡片。' },
skills: { kind: '全屏面板', title: '技能与快捷栏配置', entry: '左上头像 → 角色 → 技能。', touch: '点击技能查看说明,长按查看数值,拖动到首页技能槽。', back: '保存配置后回到首页,技能按钮即时更新。', note: '主动、被动、坐骑、表情分标签显示。' },
skill_upgrade: { kind: '场景弹窗', title: '技能升级 / 加点', entry: '技能详情页点击升级。', touch: '点击加点预览消耗,再点击确认;资源不足显示原因。', back: '确认后刷新技能等级,取消回到技能详情。', note: '升级不可误触,必须二次确认。' },
inspect: { kind: '场景弹窗', title: '查看其他玩家装备', entry: '点击场景玩家后选择查看,或从队伍/好友资料进入。', touch: '点击玩家资料页签查看装备、等级和公会;不可修改对方数据。', back: '关闭资料卡回到场景或社交列表。', note: '交易、私聊、组队使用同一上下文操作栏。' },
inventory: { kind: '全屏面板', title: '背包', entry: '右上背包快捷按钮或菜单 → 背包。', touch: '点击选中,长按详情,拖动整理/装备/放入快捷栏;使用和出售用明确按钮。', back: '关闭返回首页,物品操作结果用 toast 提示。', note: '腰带、装备栏、任务物品作为背包内页。' },
item_tooltip: { kind: '场景弹窗', title: '物品详情提示', entry: '背包、仓库、商店或掉落物点击/长按。', touch: '点击物品查看详情;长按保持详情卡,点击操作按钮执行动作。', back: '点空白或返回关闭详情卡。', note: '详情卡不遮挡关键操作区域。' },
item_action: { kind: '场景弹窗', title: '使用、装备、出售', entry: '物品详情卡底部操作区。', touch: '点击主操作,数量类动作弹出数量选择;出售和稀有物品使用二次确认。', back: '完成后刷新背包,取消回到详情卡。', note: '拖放和点击操作同时支持。' },
safebox: { kind: '全屏面板', title: '仓库与扩展物品栏', entry: '菜单 → 背包 → 仓库,或靠近仓库 NPC 后进入。', touch: '左右切换背包/仓库/商城仓库;拖动物品存入或取出。', back: '关闭面板返回 NPC 对话或首页。', note: '私人商店背包沿用同一物品组件。' },
select_item: { kind: '场景弹窗', title: '任务物品选择', entry: '任务奖励、提交材料或任务详情中的选择按钮。', touch: '点击物品,滑动或加减调整数量,提交前显示所需数量。', back: '确认后回到任务流程,取消不消耗物品。', note: '数量选择不使用难操作的小型输入框。' },
social_entry: { kind: '右上角菜单', title: '社交功能入口', entry: '右上菜单 → 社交;队伍不出现在社交菜单。', touch: '点击聊天、好友、情侣、公会或观战进入对应页面。', back: '关闭社交页面回到首页 HUD。', note: '组队信息固定显示在左上玩家头像下方。' },
chat: { kind: '全屏面板', title: '聊天与战斗日志', entry: '菜单 → 社交 → 聊天;新消息以短暂提示或气泡显示。', touch: '点击频道切换,点击输入框唤起键盘,长按消息进行复制/私聊。', back: '关闭回到首页,未读数显示在菜单角标。', note: '综合、私聊、系统、战斗、队伍、公会统一在频道中。' },
party: { kind: '左侧 HUD', title: '队伍信息', entry: '组队后显示在左上玩家头像下方;未组队时自动隐藏。', touch: '点击队员查看资料或定位,长按打开队伍设置;邀请通过好友或玩家交互触发。', back: '关闭队员卡回到首页,队伍血量和距离继续常驻显示。', note: '队伍不出现在社交菜单,也不设置独立首页按钮。' },
friend: { kind: '全屏面板', title: '好友与好友申请', entry: '菜单 → 社交 → 好友,或点击玩家资料。', touch: '点击申请、同意、私聊、查看资料;列表上下滚动。', back: '返回社交菜单或首页。', note: '好友申请通过临时弹窗提醒。' },
love: { kind: '全屏面板', title: '情侣 / 爱意', entry: '菜单 → 社交 → 情侣 / 爱意。', touch: '点击关系资料和互动动作;关键关系操作使用确认。', back: '关闭返回社交或首页。', note: '低频关系信息不占用首页 HUD。' },
guild: { kind: '全屏面板', title: '公会', entry: '菜单 → 社交 → 公会。', touch: '顶部标签切换成员、技能、公告、公会战和会徽;列表支持滚动。', back: '返回社交菜单或首页。', note: '公会邀请通过临时确认弹窗进入。' },
commerce_entry: { kind: '场景弹窗', title: 'NPC / 玩家情境入口', entry: '靠近 NPC、玩家、摊位或制作设施后显示交互按钮。', touch: '点击交互按钮,从上下文菜单选择对话、商店、交易、仓库或制作。', back: '关闭上下文菜单返回场景。', note: '只在附近有可交互对象时出现。' },
shop: { kind: '全屏面板', title: 'NPC 商店与商城', entry: 'NPC 交互 → 商店,或菜单 → 商店 / 商城。', touch: '点击商品查看详情,数量选择后购买/出售;支付前二次确认。', back: '返回 NPC 对话或首页。', note: '商城使用同一套物品详情和确认组件。' },
trade: { kind: '全屏面板', title: '玩家交易', entry: '点击玩家 → 交易,或场景交互菜单进入。', touch: '拖动物品到交易栏,双方锁定后分别确认;内容变化时重新确认。', back: '取消交易返回场景,已锁定物品自动退回。', note: '交易金额和物品变化必须明显反馈。' },
private_shop: { kind: '全屏面板', title: '私人商店', entry: '点击玩家摊位进入浏览;自己的摊位从菜单管理。', touch: '点击商品购买,管理页拖动物品上架;上架/下架有确认提示。', back: '关闭返回场景,摊位状态保留。', note: '使用背包的物品组件,减少学习成本。' },
cube: { kind: '全屏面板', title: 'Cube 制作', entry: '菜单 → 交易与制作 → Cube,或靠近制作设施。', touch: '选择配方,拖入材料,查看产出预览,点击制作。', back: '结果弹窗关闭后回到制作页或首页。', note: '制作按钮放在面板底部,不占用首页底部。' },
refine: { kind: '全屏 + 弹窗', title: '精炼 / 强化 / 龙魂', entry: '菜单 → 交易与制作 → 精炼,龙魂作为同组页签。', touch: '选择装备、查看材料和成功率,确认强化;结果用中央弹窗反馈。', back: '返回制作菜单或首页,失败也明确显示消耗。', note: '龙魂当前代码入口需单独开启后再接入正式菜单。' },
system_entry: { kind: '右上角菜单', title: '系统入口', entry: '右上角菜单最外侧按钮。', touch: '点击菜单打开右侧抽屉,点击遮罩或关闭按钮收起。', back: '回到首页 HUD,世界输入恢复。', note: '菜单打开时暂停战斗点击,防止误操作。' },
options: { kind: '全屏面板', title: '系统设置与游戏设置', entry: '菜单 → 系统 → 设置。', touch: '滑动音量、镜头距离等,点击开关控制 PK、名称、伤害数字和屏蔽项。', back: '即时保存后返回菜单或首页。', note: '设置项按音频、画面、操作、社交分组。' },
help: { kind: '全屏面板', title: '帮助界面', entry: '菜单 → 系统 → 帮助。', touch: '点击分类卡片,上下滚动查看说明;可从快捷入口直接跳转。', back: '返回系统菜单或首页。', note: '帮助文案与实际移动端手势保持一致。' },
character_switch: { kind: '全屏 + 弹窗', title: '选择角色', entry: '菜单 → 系统 → 选择角色。', touch: '点击后先保存并弹出确认,再回到角色列表。', back: '取消确认继续当前角色。', note: '切换角色触发加载流程。' },
logout: { kind: '场景弹窗', title: '登出 / 退出游戏', entry: '菜单 → 系统 → 登出或退出。', touch: '点击后弹出确认,显示未保存状态;确认后返回登录或关闭游戏。', back: '取消后回到系统菜单。', note: '属于高风险操作,必须二次确认。' },
reconnect: { kind: '状态弹窗', title: '断线重连', entry: '网络中断时覆盖当前页面,不清除当前 HUD 状态。', touch: '自动重试;用户可点击重试、查看网络或返回角色列表。', back: '连接成功恢复原场景,失败则进入角色列表。', note: '恢复后保留任务追踪、目标和队伍状态。' }
};
function showToast(message) {
toast.textContent = message;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('show'), 1400);
}
function currentVariantRoot(el) { return el.closest('.variant'); }
function renderPanel(root, key, history = []) {
const modal = root.querySelector('[data-modal]');
const title = root.querySelector('[data-sheet-title]');
const bodyNode = root.querySelector('[data-sheet-body]');
const back = root.querySelector('[data-back]');
title.textContent = titles[key] || key;
bodyNode.innerHTML = featureCopy[key] || `<p class="sheet-note">${titles[key] || key} 面板草图。</p>`;
modal.dataset.current = key;
modal.dataset.history = JSON.stringify(history);
back.classList.toggle('hidden', history.length === 0);
bindDynamic(bodyNode);
}
function openPanel(trigger, key) {
const root = currentVariantRoot(trigger);
const modal = root.querySelector('[data-modal]');
const history = modal.classList.contains('open') && modal.dataset.current ? JSON.parse(modal.dataset.history || '[]') : [];
if (modal.classList.contains('open') && modal.dataset.current && modal.dataset.current !== key) history.push(modal.dataset.current);
renderPanel(root, key, history);
modal.classList.add('open');
}
function closePanel(trigger) {
const modal = trigger.closest('[data-modal]');
modal.classList.remove('open');
modal.dataset.current = '';
modal.dataset.history = '[]';
}
function backPanel(trigger) {
const modal = trigger.closest('[data-modal]');
const history = JSON.parse(modal.dataset.history || '[]');
const key = history.pop();
if (!key) return;
renderPanel(trigger.closest('.variant'), key, history);
}
function bindDynamic(scope = document) {
scope.querySelectorAll('[data-toast]').forEach(btn => {
if (btn.dataset.bound) return;
btn.dataset.bound = '1';
btn.addEventListener('click', () => showToast(btn.dataset.toast));
});
const send = scope.querySelector('[data-send]');
if (send && !send.dataset.bound) {
send.dataset.bound = '1';
send.addEventListener('click', () => {
const input = scope.querySelector('input');
if (!input.value.trim()) { showToast('请输入聊天内容'); return; }
showToast('消息已发送'); input.value = '';
});
}
}
const flowDetail = document.getElementById('flow-detail');
function renderFlowDetail(key) {
const detail = flowDetails[key];
if (!detail) return;
flowDetail.classList.remove('empty');
flowDetail.innerHTML = `<div class="detail-kicker">${detail.kind}</div><h3>${detail.title}</h3><div class="detail-grid"><div class="detail-block"><strong>入口 / 所在位置</strong><p>${detail.entry}</p></div><div class="detail-block"><strong>移动端触控</strong><p>${detail.touch}</p></div><div class="detail-block"><strong>返回路径</strong><p>${detail.back}</p></div></div><p class="flow-note">设计备注:${detail.note}</p>`;
flowDetail.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
document.querySelectorAll('[data-detail]').forEach(node => node.addEventListener('click', () => renderFlowDetail(node.dataset.detail)));
document.querySelectorAll('[data-flow-filter]').forEach(filter => filter.addEventListener('click', () => {
const value = filter.dataset.flowFilter;
document.querySelectorAll('[data-flow-filter]').forEach(item => {
const active = item === filter;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.flow-card').forEach(card => card.classList.toggle('is-hidden', value !== 'all' && card.dataset.flowGroup !== value));
showToast(value === 'all' ? '显示全部交互流程' : '已筛选交互流程');
}));
document.addEventListener('click', event => {
const btn = event.target.closest('.demo-btn');
if (btn) {
if (btn.dataset.panel) openPanel(btn, btn.dataset.panel);
if (btn.dataset.skill) {
showToast(btn.dataset.toast || ('触发:' + btn.dataset.skill));
if (btn.dataset.skill !== '普攻') {
btn.classList.add('cooling');
const badge = btn.querySelector('em');
if (badge) badge.textContent = '3';
setTimeout(() => { btn.classList.remove('cooling'); if (badge) badge.textContent = ''; }, 900);
}
}
}
const close = event.target.closest('[data-close]');
if (close) closePanel(close);
const back = event.target.closest('[data-back]');
if (back) backPanel(back);
if (event.target.matches('[data-modal]')) closePanel(event.target.querySelector('[data-close]'));
});
document.querySelectorAll('[data-viewport]').forEach(btn => btn.addEventListener('click', () => {
canvas.className = 'preview-' + btn.dataset.viewport;
showToast('预览尺寸:' + btn.textContent);
}));
document.getElementById('annotation-toggle').addEventListener('click', event => {
body.classList.toggle('annotate');
event.currentTarget.textContent = body.classList.contains('annotate') ? '关闭标注' : '标注';
showToast(body.classList.contains('annotate') ? '标注模式已开启' : '标注模式已关闭');
});
const annotation = document.getElementById('annotation-label');
document.addEventListener('mousemove', event => {
if (!body.classList.contains('annotate')) return;
const el = event.target.closest('[data-label]');
if (!el) { annotation.style.display = 'none'; return; }
annotation.textContent = el.dataset.label;
annotation.style.left = Math.min(window.innerWidth - 180, event.clientX + 12) + 'px';
annotation.style.top = Math.min(window.innerHeight - 40, event.clientY + 12) + 'px';
annotation.style.display = 'block';
});
document.querySelectorAll('[data-joystick]').forEach(joystick => {
let active = false;
const stick = joystick.querySelector('.stick');
const move = event => {
if (!active) return;
const rect = joystick.getBoundingClientRect();
const x = (event.clientX ?? event.touches?.[0]?.clientX) - (rect.left + rect.width / 2);
const y = (event.clientY ?? event.touches?.[0]?.clientY) - (rect.top + rect.height / 2);
const limit = 26, length = Math.max(1, Math.hypot(x, y)), scale = Math.min(1, limit / length);
stick.style.transform = `translate(calc(-50% + ${x * scale}px), calc(-50% + ${y * scale}px))`;
};
const release = () => { active = false; joystick.classList.remove('is-active'); stick.style.transform = 'translate(-50%, -50%)'; showToast('移动摇杆已复位'); };
joystick.addEventListener('pointerdown', event => { active = true; joystick.classList.add('is-active'); joystick.setPointerCapture(event.pointerId); move(event); });
joystick.addEventListener('pointermove', move); joystick.addEventListener('pointerup', release); joystick.addEventListener('pointercancel', release);
});
bindDynamic();
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
# Sketch Manifest
## Design Direction
面向移动端 MMORPG 的暗黑系 ARPG 横屏 HUD:让战斗世界保持清晰,把高频操作放在拇指自然覆盖区,底部中央保持留白,把低频系统收纳进菜单和右上入口。整体采用深色玻璃、暖铜金属边框、血红生命条、紫色职业资源与圆形技能盘;任务通过当前任务条直接进入。角色相关功能固定由左上头像进入;组队后队员信息固定显示在该头像下方,队伍不出现在社交菜单。菜单参考暗黑 ARPG 的分组方式,仅保留社交、成长、活动、商业、系统五个一级入口,其余功能最多第二层进入。
## Reference Points
参考用户提供的暗黑系 ARPG 横屏战斗 HUD:左上按名称、头像、HP/MP/资源短条纵向排列,顶部中央目标条,右上按背包、小地图、菜单排列,左下虚拟摇杆、右下环形技能盘;底部中央保持留白。当前草图以项目已有的移动、相机缩放、战斗、掉落物交互、聊天、背包、任务和社交入口为内容基础,并补充进入游戏、任务副本、角色物品、社交经济、系统状态等全量交互图;使用自绘占位符,不复用原始游戏素材。
## Sketches
| # | Name | Design Question | Winner | Tags |
|---|------|-----------------|--------|------|
| 001 | mobile-hud | 暗黑系 ARPG 横屏战斗 HUD 如何兼顾 MMORPG 的完整系统入口? | single | mobile, landscape, hud, touch, combat, arpg, mmo, dark-glass |
+40
View File
@@ -0,0 +1,40 @@
:root {
--color-bg: #140e0b;
--color-surface: rgba(29, 20, 16, 0.78);
--color-surface-strong: rgba(17, 12, 10, 0.94);
--color-surface-soft: rgba(178, 132, 73, 0.14);
--color-border: rgba(191, 148, 84, 0.34);
--color-border-bright: rgba(238, 199, 126, 0.72);
--color-text: #f5ead7;
--color-text-muted: #b7a58e;
--color-primary: #dfb86e;
--color-primary-hover: #f5d998;
--color-accent: #f0c979;
--color-danger: #c83f39;
--color-success: #73bb7d;
--color-mana: #7957ed;
--color-energy: #b468f4;
--color-glass-highlight: rgba(255, 231, 185, 0.18);
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", Consolas, monospace;
--text-xs: 0.7rem;
--text-sm: 0.78rem;
--text-base: 0.92rem;
--text-lg: 1.1rem;
--text-xl: 1.35rem;
--text-2xl: 1.7rem;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--radius-full: 9999px;
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.2);
--shadow-md: 0 12px 28px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5);
}
+31 -9
View File
@@ -3,7 +3,7 @@
#
# ./build-macos-client.sh [debug|release]
#
# 产物:build/export/mtgodot-poc.app + 旁边一个 assets 符号链接(不进包,2.1G)。
# 产物:build/export/mtgodot-poc.app(资源和 BGM 自包含在 Contents/Resources/)。
# 依赖:Godot 4.7.1 的 macOS 导出模板(editor 里 “管理导出模板” 装,或放
# ~/Library/Application Support/Godot/export_templates/4.7.1.stable/macos.zip)。
set -euo pipefail
@@ -13,14 +13,32 @@ CFG="${1:-debug}"
OUT="$REPO/build/export"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
BUILD_TYPE="Debug"
if [ "$CFG" = release ]; then
BUILD_TYPE="Release"
fi
echo "== 1. GDExtension .dylib =="
if [ "$MAC_ARCHES" = universal ]; then
CMAKE_ARCHES="arm64;x86_64"
else
CMAKE_ARCHES="$MAC_ARCHES"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_OSX_ARCHITECTURES="$CMAKE_ARCHES"
cmake --build build --target mtgodot -j8
DYLIB="project/bin/libmtgodot.macos.template_${CFG}.dylib"
if [ ! -f "$DYLIB" ]; then
# CMake 目前只出 debug 名;release 就复用它
cp -f project/bin/libmtgodot.macos.template_debug.dylib "$DYLIB"
fi
if [ "$MAC_ARCHES" = universal ]; then
LIPO_INFO="$(lipo -info "$DYLIB")"
case "$LIPO_INFO" in
*x86_64*arm64*|*arm64*x86_64*) ;;
*) echo "错误:GDExtension 不是 universal$LIPO_INFO" >&2; exit 1 ;;
esac
fi
echo "== 2. 导出 =="
mkdir -p "$OUT"
@@ -30,16 +48,20 @@ else
"$GODOT" --headless --path project --export-debug macOS "$APP"
fi
echo "== 3. 资源(不打进包=="
# .app 同级放一个 assets 链接;AssetRoot 会在 <.app 同级>/assets 找到它。
ln -sfn "$REPO/assets" "$OUT/assets"
ln -sfn "$REPO/bgm" "$OUT/bgm"
echo "== 3. 资源(自包含=="
mkdir -p "$APP/Contents/Resources"
ditto "$REPO/assets" "$APP/Contents/Resources/assets"
ditto "$REPO/bgm" "$APP/Contents/Resources/bgm"
test -f "$APP/Contents/Resources/assets/asset_index.txt"
echo "== 4. ad-hoc 签名(Apple Silicon 直跑=="
codesign --force --deep --sign - "$APP" 2>/dev/null || true
echo "== 4. 签名(默认 ad-hocCODESIGN_IDENTITY 可切正式证书=="
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.a' \) -exec \
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; 2>/dev/null || true
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" "$APP" 2>/dev/null || true
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo
echo "OK -> $APP"
echo "跑: open '$APP' (用 $OUT/assets"
echo "或: MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc' (看控制台日志)"
echo "跑: open '$APP'"
echo "开发覆盖资源:MT_ASSETS='$REPO/assets' '$APP/Contents/MacOS/mtgodot-poc'"
+5 -1
View File
@@ -5,6 +5,7 @@ cd "$(dirname "$0")"
CONFIG="${1:-Debug}" # Debug | Release
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
MAC_ARCHES="${MT_MAC_ARCHES:-$(uname -m)}" # arm64, x86_64, or universal
# godot-cpp + the vendored deps (zstd, libsodium-cmake and its nested libsodium)
# are submodules — recurse. miniLZO is vendored in-tree, nothing to fetch.
@@ -15,7 +16,10 @@ if [ ! -f extension/godot-cpp/CMakeLists.txt ] \
git submodule update --init --recursive
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG"
if [ "$MAC_ARCHES" = universal ]; then
MAC_ARCHES="arm64;x86_64"
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE="$CONFIG" -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHES"
cmake --build build --config "$CONFIG" -j "$JOBS"
echo
+10147
View File
File diff suppressed because it is too large Load Diff
+2753 -94
View File
File diff suppressed because one or more lines are too long
+656
View File
@@ -0,0 +1,656 @@
# 移动端 UI 与现有 PC UI 共存实施方案
## 1. 目标与结论
本方案用于把横屏移动端 UI 草图落地到现有 Godot 客户端,同时保留当前 PC UI、40250 交互语义、网络协议和玩法逻辑。
草图入口:[`../.planning/sketches/001-mobile-hud/index.html`](../.planning/sketches/001-mobile-hud/index.html)
总体结论:
- 不重写网络、角色、物品、技能、任务、组队和商店数据逻辑。
- PC 与移动端共用同一套客户端状态和业务控制器,分别使用不同的 HUD 与窗口布局。
- 首页 HUD、虚拟摇杆、技能盘、菜单路由和移动端输入仲裁需要新增。
- 高频窗口需要移动端专用布局;低频窗口可先套移动端容器,随后逐步重排。
- 服务端或 NPC 上下文驱动的窗口不能被菜单强制打开,菜单只能展示入口条件或导航到对应场景。
- 整体属于 UI 层中等偏大改动,但不是客户端重写。预计可复用约 70% 的现有功能逻辑。
## 2. 已确定的移动端设计规则
### 2.1 设备与视觉方向
- 横屏为唯一移动端布局方向。
- 设计基准为 844×390,布局必须适配更宽的 Android 屏幕、平板和刘海/挖孔安全区。
- 风格采用现代手游半透明 HUD、暗色玻璃、暖铜金边框和低饱和背景。
- 不直接复制其他游戏素材,只参考暗黑 ARPG 的信息层级、触控半径和功能收纳方式。
### 2.2 首页 HUD
```text
┌──────────────────────────────────────────────────────────────┐
│ 名称 背包 小地图 菜单 │
│ 头像 │
│ HP/MP 目标名称与血条 │
│ 队员 1 │
│ 队员 2 当前任务追踪 │
│ │
│ 游戏世界 │
│ │
│ 移动摇杆 技能 / 普攻 │
└──────────────────────────────────────────────────────────────┘
```
固定规则:
- 玩家名称位于左上头像上方。
- HP、MP 和职业资源条位于头像下方,宽度与头像一致。
- 点击玩家头像进入角色、装备、技能、坐骑、表情和称号页面。
- 角色功能不出现在右上菜单。
- 组队后,队员头像、名称、HP 和距离显示在玩家头像下方;未组队时不占空间。
- 队伍不出现在社交菜单,也不设置独立按钮。
- 右上角从左到右为背包、小地图、菜单;点击小地图打开大地图。
- 当前任务条直接打开任务日志,不放入菜单。
- 底部中央不设置功能栏,只在左下和右下保留必要战斗控件。
### 2.3 右上菜单
菜单只保留五个一级分类:
| 一级分类 | 二级内容 |
|---|---|
| 社交 | 聊天、好友、公会、情侣、观战 |
| 成长 | 精炼、龙魂、Cube 制作 |
| 活动 | 副本、钓鱼 |
| 商业 | 商店、商城、交易、私人商店、仓库、兑换 |
| 系统 | 系统设置、游戏设置、帮助、选择角色、登出、退出 |
页面层级最多两层。二级页必须提供返回上一级和关闭两个操作。
## 3. 当前代码基础
### 3.1 UI 装配
`project/game_scene.gd` 已负责创建并连接以下模块:
- `Hud`
- `InventoryUI`
- `SkillUI`
- `CharStatusUI`
- `Quickbar`
- `QuestLog`
- `PartyUI`
- `FriendUI`
- `GuildUI`
- `LoveUI`
- `ShopUI`
- `ExchangeUI`
- `SafeboxUI`
- `MallUI`
- `CubeUI`
- `RefineUI`
- `DragonSoulUI`(当前功能开关关闭)
- `Minimap`
- `AtlasUI`
- `SystemMenuUI`
这些模块已经连接 `M2Client` 信号并负责现有功能行为。移动端不应复制这一套网络和状态处理。
### 3.2 现有 HUD 接口可作为兼容协议
`project/net_play.gd` 通过以下方法更新 `Hud`
```gdscript
set_vitals(hp, max_hp, sp, max_sp)
set_stamina(value, max_value)
set_exp(xp, next_xp)
set_level(level)
set_energy(value, max_value)
set_target(name, hp_pct)
clear_target()
```
移动端 HUD 实现相同的方法后,可以直接作为 `net_play.setup(..., hud_node)` 的 HUD 参数,无需修改网络更新流程。
### 3.3 PC 固定布局不能直接缩放成移动端
当前工程基准分辨率为 1920×1080,`UiManager.screen` 也默认使用该尺寸。`hud.gd` 中状态条锚定左下且 HP 宽度为 240,现有 Python UI 脚本还包含大量固定坐标。
因此不能把整个 PC CanvasLayer 等比缩小后直接作为最终移动端 UI,否则会出现:
- 字体和触控目标过小。
- 横屏短边空间不足。
- 多窗口互相覆盖。
- 标题栏拖动、右键、鼠标悬停等 PC 交互无法在触屏完成。
- 刘海、圆角和系统手势区遮挡 UI。
### 3.4 当前触摸能力
现有代码已经具备:
- 单指轻点地面移动或选择实体。
- 单指拖动旋转镜头。
- 双指缩放镜头。
- `pointing/emulate_mouse_from_touch=false`,不会把触摸再次模拟成鼠标事件。
当前已具备:
- 虚拟摇杆连续移动,以及普攻、药水和 4 个技能触摸按钮。
- UI 触点按 index 捕获,摇杆/战斗触点不会被世界点地和镜头误判;窗口打开时由 `UiManager` 阻断世界输入。
- 双指世界手势不会生成点地移动;切后台、失焦和恢复时会释放摇杆/攻击状态。
- 背包支持点按选择、再次点按使用、点另一格移动;交易、私人商店、仓库和商城支持移动端候选列表或点按操作。
代码已覆盖:
- 技能长按瞄准、拖动调整方向和松手释放;短按仍沿用现有目标/自动射程逻辑。
- 角色属性/装备、技能、聊天/私聊、系统设置和游戏设置的移动横屏页面。
- 物品长按详情与触摸拖放、技能拖入快捷栏、快捷栏槽位交换;无法拖放时仍保留点按替代路径。
后续主要是 Android/iOS 真机上的 DPI、键盘、手势区和视觉验收。
### 3.5 现有队伍 UI 可直接演进
`PartyUI` 已经:
- 监听 `party_changed` 和生命状态更新。
- 队伍为空时自动隐藏。
- 显示成员名字、HP、队长和角色状态。
- 点击成员可选中对应角色。
- 支持角色分配、踢人、经验分配和组队治疗。
移动端只需要增加紧凑布局模式并更换挂载位置,无需重写队伍逻辑。
## 4. 目标架构
```text
GameScene
├── M2Client / NetPlay / PlayerController / GameCamera
│ └── PC 与移动端共享,不复制协议和玩法状态
├── UiManager
│ └── 继续管理功能窗口栈、模态遮罩和关闭顺序
├── DesktopUiRoot
│ ├── 当前 Hud
│ ├── 当前 Quickbar
│ └── 当前 PC 窗口布局
└── MobileUiRoot
├── MobileHud
│ ├── PlayerCard
│ ├── PartyHost
│ ├── TargetCard
│ ├── QuestTracker
│ ├── MinimapHost
│ └── TopRightActions
├── MobileInputOverlay
│ ├── VirtualJoystick
│ ├── AttackButton
│ ├── SkillWheel
│ └── ContextAction
├── MobileMenuDrawer
├── MobileWindowHost
└── MobileUiRoot(当前同时承担路由职责)
```
职责边界:
- `MobileHud` 只显示状态并发出点击信号,不直接发送网络包。
- `MobileInputOverlay` 把触控动作转换为现有控制器调用。
- `MobileUiRoot` 把头像、背包、小地图、任务和菜单项路由到现有 UI 控制器;后续如路由继续膨胀,再拆出独立 `MobileUiRouter`
- `MobileWindowHost` 负责安全区、全屏面板、返回键和过渡动画。
- 原有 UI 控制器继续处理数据、服务器信号和业务动作。
## 5. UI 模式选择
新增 `UiProfile`,支持 `AUTO``DESKTOP``MOBILE` 三种模式。
自动选择建议:
```gdscript
enum Mode { AUTO, DESKTOP, MOBILE }
static func resolve(mode: Mode) -> Mode:
if mode != Mode.AUTO:
return mode
return Mode.MOBILE if OS.has_feature("mobile") else Mode.DESKTOP
```
要求:
- Android 和 iOS 默认移动 UI。
- macOS 默认 PC UI。
- 桌面开发时允许通过项目设置或启动参数强制移动 UI,便于调试。
- 自动判断只在进入游戏时执行,切换 UI 模式需要重建 UI 根节点,避免运行中残留窗口。
建议新增项目配置:
```text
mt/ui/profile = auto | desktop | mobile
mt/ui/mobile_scale = 1.0
mt/ui/show_touch_debug = false
```
## 6. 建议文件结构
```text
project/ui/
├── mobile/
│ ├── mobile_ui_root.gd
│ ├── mobile_hud.gd
│ ├── mobile_input_overlay.gd
│ ├── mobile_menu_drawer.gd
│ ├── mobile_ui_router.gd
│ ├── mobile_window_host.gd
│ └── components/
│ ├── player_card.gd
│ ├── party_member_strip.gd
│ ├── quest_tracker.gd
│ ├── virtual_joystick.gd
│ ├── skill_button.gd
│ └── item_touch_slot.gd
├── desktop/
│ └── 可后续把当前 HUD 迁入,首期不要求移动文件
└── ui_profile.gd
```
首期不要搬动所有现有文件,以减少路径变化和回归风险。
## 7. 现有功能与移动端入口映射
### 7.1 首页直达入口
| 移动端入口 | 现有目标 | 接入方式 | 改动等级 |
|---|---|---|---|
| 玩家头像 | `CharStatusUI``SkillUI` | 头像打开角色容器;容器内调用现有角色/技能状态 | 中 |
| 背包 | `InventoryUI.toggle()` | 右上按钮经路由器调用 | 低 |
| 小地图 | `AtlasUI.toggle()` | 小地图自身保持显示,点击打开大地图 | 低 |
| 当前任务 | `QuestLog.toggle()` | 任务条点击调用;不进入菜单 | 低 |
| 队员条 | `PartyUI` | 增加 compact 模式,挂载到玩家头像下 | 中低 |
| 普攻 | `NetPlay.set_attack_key()` | 按下设为 true,抬起设为 false | 中 |
| 技能 | `Quickbar.activate(slot)` | 技能按钮使用现有槽位和冷却逻辑 | 中 |
| 拾取/交互 | `GroundItems``NetPlay` | 使用现有拾取、实体选择和场景交互路径 | 中 |
### 7.2 右上菜单路由
| 分类 | 功能 | 当前可否直接打开 | 移动端行为 |
|---|---|---|---|
| 社交 | 聊天 | 可以 | 调用现有聊天日志入口 |
| 社交 | 好友 | 可以 | 调用 `FriendUI.toggle()` |
| 社交 | 公会 | 可以 | 调用 `GuildUI.toggle()` |
| 社交 | 情侣 | 状态驱动 | 展示情侣状态;无关系时显示空状态 |
| 社交 | 观战 | 场景状态驱动 | 展示观战说明或当前观战状态 |
| 成长 | 精炼 | 服务器询问驱动 | 显示“前往精炼 NPC”,收到 `refine_ask` 后打开窗口 |
| 成长 | 龙魂 | 当前禁用 | 在 `ENABLE_DRAGON_SOUL` 开启前隐藏或显示未开放 |
| 成长 | Cube 制作 | NPC/服务器驱动 | 导航到制作 NPC;收到 `cube_opened` 后打开 |
| 活动 | 副本 | 任务/场景驱动 | 展示目标与导航,不绕过服务端进入条件 |
| 活动 | 钓鱼 | 场景驱动 | 展示说明;到达钓点后由情境按钮开始 |
| 商业 | NPC 商店 | NPC 驱动 | 导航或提示靠近 NPC,不能本地强制打开 |
| 商业 | 商城 | 服务器状态驱动 | 有商城会话时显示内容,否则显示不可用状态 |
| 商业 | 玩家交易 | 玩家交互驱动 | 选择附近玩家后发起交易 |
| 商业 | 私人商店 | 玩家/摊位驱动 | 点击摊位浏览;自己的商店进入管理模式 |
| 商业 | 仓库 | NPC/服务器驱动 | 导航到仓库 NPC,服务器打开后显示 |
| 商业 | 兑换 | NPC/任务上下文驱动 | 显示导航,不伪造兑换上下文 |
| 系统 | 设置/帮助 | 可以 | 使用移动端全屏设置页或现有控制器适配页 |
| 系统 | 选择角色/登出/退出 | 可以 | 二次确认后调用现有系统操作 |
菜单只是功能入口,不改变原服务端授权条件。
## 8. HUD 实施细节
### 8.1 `MobileHud` 兼容接口
`MobileHud` 实现与当前 `Hud` 相同的状态更新方法,并额外提供以下信号:
```gdscript
signal avatar_pressed
signal inventory_pressed
signal minimap_pressed
signal menu_pressed
signal quest_pressed(quest_id: int)
signal party_member_pressed(vid: int)
signal context_action_pressed(action: String)
```
`GameScene` 根据 UI 模式创建 `Hud``MobileHud`,并把最终对象继续传给 `NetPlay`
### 8.2 避免重复 HUD
当前 `hud.gd` 自己创建状态条、小地图占位、快捷栏和背包占位,同时 `GameScene` 还创建正式 `Quickbar``InventoryUI``Minimap`
移动模式必须明确只有一个拥有者:
- `MobileHud` 拥有首页状态、目标、任务、顶部按钮和触控布局。
- `Quickbar` 继续拥有技能槽数据、冷却和激活逻辑,但其 PC 可视节点在移动模式隐藏。
- `InventoryUI` 继续拥有背包数据和动作,但其 PC 入口隐藏。
- `Minimap` 继续提供地图数据/绘制能力,移动模式把可视根节点挂入 `MobileHud.MinimapHost` 或增加 compact 布局。
不能同时显示 PC HUD 和移动 HUD。
### 8.3 安全区与缩放
- 根布局使用 Godot anchors,不使用整页固定坐标。
- 使用 `DisplayServer.get_display_safe_area()` 获取安全区。
- 左上玩家区和右上按钮组至少保留安全区边距。
- 基准触控目标不小于 48dp;普攻主按钮建议 80–96dp。
- HUD 缩放按短边计算,并设置最小/最大范围,避免平板按钮过大。
- 字体单独分级,不跟随 PC 窗口做纯比例缩放。
## 9. 移动输入方案
### 9.1 虚拟摇杆
`PlayerController` 中增加移动轴输入,不模拟键盘事件:
```gdscript
var _mobile_axis := Vector2.ZERO
func set_mobile_axis(axis: Vector2) -> void:
_mobile_axis = axis.limit_length(1.0)
```
移动优先级建议:
```text
虚拟摇杆 > PC 键盘 > 点地移动 > 无移动
```
摇杆输入不为零时:
- 取消当前点地目标 `_is_going`
- 沿用现有相机相对方向、速度、阻挡、Actor 碰撞和动画逻辑。
- 摇杆松开后 `_mobile_axis` 清零。
- 受击、锁定、移动技能等现有移动门继续生效。
### 9.2 普攻与技能
- 普攻按钮按下调用 `NetPlay.set_attack_key(true)`,抬起或触摸取消调用 `false`
- 技能按钮点击调用 `Quickbar.activate(slot)`
- 技能长按进入瞄准态,拖动调整方向,松手调用 `Quickbar.activate_aimed()`;短按仍调用 `Quickbar.activate()`
- 方向计算、技能冷却、不可使用原因和目标选择继续由现有 `Quickbar`/`NetPlay` 处理。
- 触摸取消必须清理攻击按下状态,避免切后台后持续攻击。
### 9.3 触摸仲裁
当前 `PlayerController``GameCamera` 都接收 `InputEventScreenTouch/Drag`。新增移动控件后必须防止同一个触点同时操作 UI 和世界。
建议规则:
1. `MobileInputOverlay``_gui_input` 中记录被 UI 捕获的 touch index。
2. 被捕获的 index 不传给玩家点地和镜头控制。
3. 摇杆、技能、菜单、窗口区域调用 `get_viewport().set_input_as_handled()`
4. 未被捕获的单指仍用于点地/选中或镜头拖动。
5. 双指缩放只统计世界区域中的触点。
6. 模态窗口打开时阻止所有世界输入。
7. 应用暂停、失焦、死亡和过场时强制释放全部触点状态。
建议把捕获状态集中到一个 `TouchRouter`,不要让每个按钮各自维护全局触点。
## 10. 功能窗口适配策略
### 10.1 三种适配级别
#### A. 直接复用控制器并更换入口
适用:背包开关、大地图、任务日志、好友、公会、系统菜单。
首期由 `MobileUiRoot` 调用现有 `toggle/open/close` 方法。
#### B. 同一控制器增加移动布局
适用:队伍、快捷栏、小地图、聊天、系统设置。
保留数据和信号,给 `_build()` 增加 `layout_mode`,分别构建 desktop/mobile 视图。
#### C. 拆分逻辑和视图
适用:背包、角色、技能、商店、交易、仓库、Cube 等固定坐标和拖放较重的窗口。
逐步拆成:
```text
FeatureController:数据、信号、网络操作、校验
DesktopFeatureView:现有 PC 布局
MobileFeatureView:横屏全屏布局
```
首期允许保留现有控制器,并只在真正重做对应页面时拆分,避免一次性重构全部 UI。
### 10.2 PC 专属交互的移动替代
| PC 交互 | 移动端替代 |
|---|---|
| 鼠标悬停 tooltip | 短按选中、长按详情 |
| 右键使用/出售 | 选中后显示主操作按钮 |
| 标题栏拖动窗口 | 固定全屏或右侧面板 |
| 键盘 I/K/C/N/M | 首页入口或五类菜单 |
| 数字键/F1–F4 技能 | 右下技能盘 |
| 鼠标拖动物品 | 长按进入拖动,松手落位 |
| ESC 关闭 | 系统返回、面板关闭按钮、遮罩点击 |
## 11. `GameScene` 接入方案
建议把 UI 创建拆成三个阶段:
```gdscript
func _build_shared_feature_controllers() -> void:
# 创建背包、技能、任务、社交、商店等共享控制器
func _build_desktop_presentation() -> void:
# 创建当前 Hud、PC Quickbar、PC 窗口入口
func _build_mobile_presentation() -> void:
# 创建 MobileUiRoot、MobileHud、触控层和移动菜单
```
接入顺序:
1. 创建 `UiManager` 和共享功能控制器。
2. 检测 `UiProfile`
3. 创建 Desktop 或 Mobile 表现层。
4. 把当前 HUD 实例传给 `NetPlay`
5.`Quickbar``PlayerController``GameCamera``GroundItems` 注入 `MobileInputOverlay`
6. 将现有功能控制器注入 `MobileUiRoot` 的路由绑定。
7. 地图切换或世界重载时,只重载世界相关 HUD 数据,不重建所有功能控制器。
## 12. 开发阶段
### 阶段 M1:双 UI 骨架(已完成,待真机验收)
目标:PC UI 完全不变,桌面可强制预览移动 UI。
- 增加 `UiProfile`
- 增加 `MobileUiRoot`、安全区和横屏约束。
- `GameScene` 能在 Desktop/Mobile 表现层之间选择。
- 移动 HUD 使用假数据完成布局,但不重复显示 PC HUD。
验收:
- macOS 默认仍显示现有 PC UI。
- 强制 mobile 时只显示移动 HUD。
- Android/iOS 自动选择移动 UI。
### 阶段 M2:首页状态与入口(已完成,待真机验收)
- 接入玩家名称、HP、MP、资源、经验、等级和状态效果。
- 接入目标名称与目标 HP。
- 接入背包、小地图、头像、任务条和五类菜单。
- 接入左上队伍紧凑条。
- 接入死亡、重连、副本目标等覆盖状态。
验收:所有首页入口打开正确功能,返回后恢复场景输入。
### 阶段 M3:移动与战斗触控(代码完成,待真机验收)
- 新增虚拟摇杆连续移动。
- 新增普攻、技能、药水和情境交互按钮。
- 处理触摸 index 捕获、取消、切后台和模态窗口。
- 接入技能冷却、不可使用原因和目标选择反馈。
验收:移动、镜头、攻击、技能可同时使用且不串触摸。
### 阶段 M4:高频功能页(部分完成)
- 角色/装备/技能全屏页:角色属性/装备、技能分类和技能升级已有横屏专用布局;角色表情也提供移动触控页。
- 背包和物品详情:背包点按操作、长按详情、数字键盘数量输入、跨窗口长按拖放已完成;仍需真机确认系统手势区和键盘共存体验。
- 任务日志和大地图:已有移动托管;大地图支持单指拖动并在双指时冻结单指拖动。
- 聊天与好友:聊天/私聊已有横屏输入页、触控按钮和弹窗返回处理,真机键盘体验待验收。
验收:不依赖右键、鼠标悬停和键盘即可完成完整操作。
### 阶段 M5:商业、制作与系统(部分完成)
- 商店、商城、交易、私人商店、仓库和兑换:移动端入口/候选列表/点按操作已覆盖;服务端上下文仍需联机验收。
- Cube、精炼、龙魂入口状态:Cube 配方/材料/制作和精炼按钮已有横屏触控布局,龙魂仍按功能开关控制。
- 系统设置、游戏设置、帮助、角色切换、登出和退出:系统设置/游戏设置/帮助已有横屏专用布局;系统菜单增加移动端专用横屏入口,好友、公会创建、公会邀请、商城/仓库密码、商店确认等确认弹窗已扩大触控尺寸并接入返回处理。
- 确保所有上下文窗口只在服务端允许时打开。
### 阶段 M6:真机验收与视觉统一(未完成)
- Android 和 iOS 安全区。
- 不同屏幕比例、分辨率和 DPI。
- 多指触控、后台恢复和断线重连(代码已接入,仍需真机/联机验收)。
- GPU/CPU/内存与触控延迟。
- 统一暗色玻璃、铜金边框、字号、按钮和动画。
## 13. 测试方案
### 13.1 自动化测试(已实现)
以下移动端专项测试已经加入项目,并与现有 PC/业务测试一起运行:
- `mobile_ui_test.gd`:模式解析、横屏保护、安全区、HUD 状态、菜单分类和 UI/世界触点仲裁。
- `mobile_window_host_test.gd`:返回键、模态阻断和窗口栈。
- `mobile_gesture_test.gd`:长按/拖动触摸状态、技能瞄准、技能拖入快捷栏、快捷栏交换和物品触摸拖放。
- `chat_mobile_ui_test.gd`:横屏聊天日志、频道切换、私聊目标和数字键盘友好的输入页。
- `gamescene_test.gd`:PC/移动 UI 装配互斥和移动根节点接线。
- `char_status_ui_test.gd``inventory_ui_test.gd`:角色页、背包详情、数量输入和触摸操作。
- `p8_test.gd`:交易候选列表、仓库取出和移动端仓库存入候选列表。
- `shop_cube_mall_test.gd`:Cube 配方、材料添加/删除、制作按钮和移动端横屏布局。
- `guild_creation_ui_test.gd``guild_war_skill_test.gd``help_ui_test.gd`:公会、帮助和确认弹窗的移动布局。
- `system_menu_ui_test.gd``system_option_ui_test.gd``private_shop_ui_test.gd`:系统入口、设置和商业窗口的移动交互。
完整测试集当前共 78 个 `*_test.gd`,现有 PC UI、业务、网络、战斗和移动端测试均通过。推荐命令:
```bash
for test in $(rg --files project -g '*test.gd' | sort); do
/opt/homebrew/bin/godot --headless \
--rendering-method gl_compatibility \
--rendering-driver opengl3 \
--audio-driver Dummy \
--log-file "/tmp/mtgodot-${test##*/}.log" \
--path project --script "${test#project/}" || exit 1
done
```
### 13.2 桌面模拟验收
- 844×390、16:9 平板、超宽屏三档。
- 鼠标模拟触控验证所有移动入口。
- 强制 mobile profile 后走完登录、角色选择和游戏内 UI。
- 同一进程切换地图、死亡复活和断线重连。
### 13.3 Android/iOS 真机验收
- 刘海和系统手势区不遮挡按钮。
- 左手持续移动时,右手可选目标、普攻和连续释放技能。
- 两指缩放不会触发点地或技能。
- 拖动物品时不会移动角色或旋转镜头。
- 来电/切后台后,摇杆和普攻状态正确释放。
- 队伍成员变化、HP 和距离能实时更新。
- 商店、仓库、制作等页面不会绕过服务器上下文。
## 14. 完成标准
移动端 UI 可认为完成,必须同时满足:
1. PC UI 默认行为和快捷键无回归。
2. Android/iOS 自动进入移动 UI,桌面可强制预览。
3. 首页严格符合已确认草图布局。
4. 角色只从玩家头像进入。
5. 队伍只在头像下方显示,不在社交菜单出现。
6. 任务、小地图和背包保持首页直达。
7. 右上菜单只有社交、成长、活动、商业、系统五类。
8. 不使用键盘、鼠标右键或悬停,也能完成所有移动端操作。
9. 所有功能继续使用现有服务器授权和网络包流程。
10. 自动化测试、桌面模拟和 Android/iOS 真机测试均通过。
## 15. 改动规模与主要风险
### 15.1 改动规模
| 层级 | 改动规模 | 说明 |
|---|---|---|
| 网络协议与服务端 | 小/无 | 继续使用现有 `M2Client` 与 40250 流程 |
| 游戏状态与战斗逻辑 | 小 | 增加移动输入入口,不重写校验和攻击流程 |
| 首页 HUD | 大 | 新建移动端表现层 |
| 输入系统 | 中到大 | 摇杆、技能盘、多指仲裁是核心新增 |
| 高频功能页 | 中到大 | 背包、角色、技能、任务需重排 |
| 低频窗口 | 中 | 可分阶段适配 |
| PC UI | 小 | 原样保留,只增加模式分流 |
### 15.2 主要风险
1. **触摸穿透**:技能或摇杆触摸同时触发地面移动/镜头。
- 对策:集中式 `TouchRouter`,按 touch index 捕获。
2. **重复 HUD**:旧 HUD、Quickbar、Minimap 与新 HUD 同时显示。
- 对策:明确 Desktop/Mobile 表现层互斥创建。
3. **固定坐标窗口过小**PC Python UI 在移动端不可操作。
- 对策:高频页优先重排,低频页先用安全区容器过渡。
4. **菜单绕过上下文**:直接打开商店、仓库、精炼等造成无效状态。
- 对策:`MobileUiRoot` 区分“可直接打开”和“需要 NPC/服务器状态”。
5. **PC 回归**:共享控制器重构影响键盘和鼠标行为。
- 对策:首期新增并行表现层,拆分控制器时补双端测试。
6. **横屏安全区差异**:按钮被刘海或系统边缘手势遮挡。
- 对策:运行时读取安全区并保留可配置边距。
## 16. 推荐首个实现切片
首个可真机验证的垂直切片已经完成,当前作为移动端联调基线:
```text
UI 模式选择
→ 移动 HUD
→ 头像/背包/地图/任务/菜单入口
→ 左上队伍条
→ 虚拟摇杆
→ 普攻 + 4 个技能
→ Android 真机战斗验证
```
该切片已让客户端具备可用的移动端首页和核心战斗能力;角色、背包、技能等全屏页也已按使用频率完成移动布局,剩余工作集中在真机/联机验收和视觉微调。
## 17. 当前实现状态
截至 2026-09-04,代码已完成首个垂直切片,并继续补齐了部分商业/制作页面:
- `project/ui/ui_profile.gd``AUTO/DESKTOP/MOBILE` 模式选择,支持移动设备自动识别、`mt/ui/profile` 配置和 `--mobile-ui` / `--desktop-ui` 预览参数。
- `project/ui/mobile/mobile_ui_root.gd`:移动端 CanvasLayer 根节点,按 844×390 横屏设计基准自适应缩放并处理功能路由。
- `project/ui/mobile/mobile_hud.gd`:名称、头像、同宽 HP/MP、目标、队伍紧凑条、任务入口、背包/小地图/菜单入口。
- `project/ui/mobile/mobile_input_overlay.gd``virtual_joystick.gd``mobile_touch_button.gd`:虚拟摇杆、普攻、药水、4 个技能触控按钮、触摸取消和失焦释放。
- `project/ui/mobile/mobile_input_overlay.gd``project/player_controller.gd`:集中记录移动 UI 触点,避免“摇杆 + 世界触点”触发点地移动。
- `project/ui/mobile/mobile_menu_drawer.gd`:社交、成长、活动、商业、系统五类两级菜单。
- `project/player_controller.gd`:新增移动轴输入,沿用现有相机相对移动、阻挡、动画和状态门。
- `project/ui/char_status_ui.gd`:角色属性/装备和表情移动横屏页面,属性点仍走原 `/stat` 命令。
- `project/ui/skill_ui.gd``project/ui/quickbar.gd`:技能移动横屏列表、升级和一键放入快捷栏空位。
- `project/ui/inventory_ui.gd`:物品长按详情、使用/选择操作和移动端数字键盘数量输入。
- `project/ui/chat_ui.gd`:横屏聊天日志、频道切换和私聊输入页。
- `project/ui/system_option_ui.gd``project/ui/game_option_ui.gd`:横屏系统/游戏设置页,保留原配置和服务器命令。
- `project/ui/help_ui.gd``project/ui/system_menu_ui.gd`:横屏帮助页和移动模式下的帮助路由。
- `project/ui/minimap.gd`:复用现有小地图绘制和数据,移动模式挂载到右上 HUD;点击地图仍打开大地图。
- `project/ui/party_ui.gd``quickbar.gd``quest_log.gd`:移动模式隐藏 PC 表现层,保留原数据和业务控制器。
- `project/game_scene.gd`:PC/移动表现层互斥装配,移动端控制器注入统一路由;PC HUD 默认路径保持不变。
- `project/ui/safebox_ui.gd`:移动端背包候选列表,点按存入、点按取出和重复请求保护。
- `project/ui/cube_ui.gd`:移动端横屏双列滚动布局,配方、材料添加/删除和制作按钮均可触摸。
- `project/ui/refine_ui.gd`:移动端横屏精炼信息与操作布局。
- `project/ui/guild_ui.gd`:移动端横屏公会四页布局、成员/技能/公会战/公告的触控按钮和创建公会弹窗。
- `project/ui/system_menu_ui.gd`:保留桌面原始系统菜单,移动端使用横屏专用系统菜单,入口和服务器命令保持一致。
- `project/ui/friend_ui.gd``project/game_scene.gd``project/ui/shop_ui.gd``project/ui/mall_ui.gd``project/ui/safebox_ui.gd`:确认弹窗移动触控尺寸与 Android 返回优先取消处理。
- `project/ui/ui_manager.gd``project/ui/mobile/mobile_window_host.gd`:动态 Button 触摸桥接、原生确认弹窗登记、模态阻断、返回顺序和安全区变化重排。
- `project/ui/reconnect_ui.gd``project/app_flow.gd`:游戏内断线保留 `GameScene`,显示移动重连遮罩,自动重新进入上次角色。
- `project/project.godot`:增加移动 UI 配置项和横屏方向设置。
当前验证结果:
- 全部 78 个 `*_test.gd` 通过,包括 `app_flow_lifecycle_test.gd``p10_test.gd``mobile_ui_test.gd``mobile_window_host_test.gd``mobile_gesture_test.gd`Godot 脚本解析检查通过。
尚未完成的部分包括:Android/iOS 真机安全区、不同屏幕比例、多指、后台恢复、性能与触控延迟验收,以及商业页面在真实服务器上下文下的联机验收。断线重连的代码路径已经完成:游戏中断线会保留当前 `GameScene`、显示遮罩并自动重新进入上次角色,但仍需要在真机和真实服务器上验证网络切换、后台恢复及重连失败边界。低频页面仍需要在真机上做字号、键盘和安全区的视觉微调;当前不能据此视为最终移动端交付完成。
+20
View File
@@ -28,6 +28,10 @@ add_library(mtnet STATIC
target_include_directories(mtnet PUBLIC src/net)
target_link_libraries(mtnet PUBLIC mt3p::sodium mt3p::minilzo mt3p::cryptopp)
target_compile_features(mtnet PUBLIC cxx_std_20)
# §1.5: non-EUROPE CG_CLIENT_VERSION timestamp, in C __TIMESTAMP__ form
# ("Www Mmm dd hh:mm:ss yyyy"); regenerated on each CMake configure.
string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")
target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP="${MT_BUILD_TS}")
# --- mtpack: m2dev-fork asset pack reader/writer (libsodium + zstd) ---
add_library(mtpack STATIC
@@ -83,6 +87,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
target_link_libraries(net_entity_test PRIVATE mtnet)
add_test(NAME net.entity_store COMMAND $<TARGET_FILE:net_entity_test>)
add_executable(net_state_queue_test tests/net_state_queue_test.cpp)
target_link_libraries(net_state_queue_test PRIVATE mtnet)
add_test(NAME net.state_queue COMMAND $<TARGET_FILE:net_state_queue_test>)
# 40250 "classic" backend (MT_PROTOCOL=classic).
add_executable(net_classic_wire_test tests/net_classic_wire_test.cpp)
target_include_directories(net_classic_wire_test PRIVATE src/net)
@@ -109,6 +117,14 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
target_link_libraries(net_classic_mark_test PRIVATE mtnet)
add_test(NAME net.classic_mark COMMAND $<TARGET_FILE:net_classic_mark_test>)
add_executable(net_text_codec_test tests/net_text_codec_test.cpp)
target_link_libraries(net_text_codec_test PRIVATE mtnet)
add_test(NAME net.text_codec COMMAND $<TARGET_FILE:net_text_codec_test>)
add_executable(net_bounds_test tests/net_bounds_test.cpp)
target_link_libraries(net_bounds_test PRIVATE mtnet)
add_test(NAME net.bounds COMMAND $<TARGET_FILE:net_bounds_test>)
add_executable(net_mark_test tests/net_mark_test.cpp)
target_link_libraries(net_mark_test PRIVATE mtnet mt3p::minilzo)
add_test(NAME net.guild_mark COMMAND $<TARGET_FILE:net_mark_test>)
@@ -123,6 +139,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(proto.item_mob PROPERTIES ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(proto_item_layout_test tests/proto_item_layout_test.cpp)
target_link_libraries(proto_item_layout_test PRIVATE mtproto)
add_test(NAME proto.item_layout COMMAND $<TARGET_FILE:proto_item_layout_test>)
endif()
# --- the extension library ---
+290 -3
View File
@@ -1,8 +1,16 @@
#include "environment_builder.h"
#include "asset_io.h"
#include "dxt.h"
#include <godot_cpp/classes/environment.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/procedural_sky_material.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/sky.hpp>
#include <godot_cpp/classes/texture2d.hpp>
#include <godot_cpp/core/object.hpp>
#include <m2_coord.h>
@@ -18,9 +26,158 @@ namespace {
Color rgba(const fmt::Rgba &c) { return Color(c[0], c[1], c[2], c[3]); }
float luma(const fmt::Rgba &c) { return 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; }
const char *SRC_SKYBOX = R"(shader_type sky;
render_mode use_debanding;
uniform sampler2D front_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D back_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D left_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D right_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D top_tex : source_color, filter_linear_mipmap_anisotropic;
uniform sampler2D bottom_tex : source_color, filter_linear_mipmap_anisotropic;
uniform bool has_front = false;
uniform bool has_back = false;
uniform bool has_left = false;
uniform bool has_right = false;
uniform bool has_top = false;
uniform bool has_bottom = false;
uniform vec4 gradient_top = vec4(0.2, 0.3, 0.6, 1.0);
uniform vec4 gradient_horizon = vec4(0.5, 0.6, 0.8, 1.0);
uniform vec4 gradient_bottom = vec4(0.2, 0.2, 0.25, 1.0);
uniform sampler2D cloud_tex : source_color, repeat_enable, filter_linear_mipmap_anisotropic;
uniform bool has_cloud = false;
uniform vec2 cloud_scale = vec2(2000.0, 2000.0);
uniform float cloud_height = 300.0;
uniform vec2 cloud_texture_scale = vec2(4.0, 4.0);
uniform vec2 cloud_speed = vec2(0.001, 0.001);
uniform vec4 cloud_tint = vec4(0.0, 0.0, 0.0, 0.0);
vec3 gradient_color(vec3 direction) {
float height = clamp(direction.y * 0.5 + 0.5, 0.0, 1.0);
if (height < 0.5)
return mix(gradient_bottom.rgb, gradient_horizon.rgb, height * 2.0);
return mix(gradient_horizon.rgb, gradient_top.rgb, (height - 0.5) * 2.0);
}
vec3 apply_cloud(vec3 color, vec3 direction) {
if (!has_cloud || direction.y <= 0.001 || cloud_height <= 0.0 ||
cloud_scale.x <= 0.001 || cloud_scale.y <= 0.001)
return color;
// ClientVS22 renders a finite horizontal quad at camera.z + CloudHeight.
// After the Metin2 -> Godot conversion this is camera.y + height. The
// source quad maps source +Y -> U=0 and source +X -> V=1; source Y is
// Godot -Z, so keep that orientation here.
float distance_to_cloud = cloud_height / direction.y;
vec2 plane = direction.xz * distance_to_cloud;
if (abs(plane.x) > cloud_scale.x || abs(plane.y) > cloud_scale.y)
return color;
vec2 uv = vec2(
0.5 + 0.5 * plane.y / cloud_scale.y,
0.5 + 0.5 * plane.x / cloud_scale.x);
uv = fract(uv * cloud_texture_scale + TIME * cloud_speed);
// SkyBox.cpp uses MODULATEINVALPHA_ADDCOLOR followed by ONE /
// INVSRCCOLOR blending: cloud.rgb + sky * (1 - cloud.rgb).
vec4 texel = texture(cloud_tex, uv);
vec3 cloud_rgb = clamp(texel.rgb * (1.0 - cloud_tint.a) + cloud_tint.rgb,
0.0, 1.0);
return cloud_rgb + color * (1.0 - cloud_rgb);
}
void sky() {
vec3 direction = normalize(EYEDIR);
vec3 color = gradient_color(direction);
float ax = abs(direction.x);
float ay = abs(direction.y);
float az = abs(direction.z);
vec2 uv;
// ClientVS22 uses Metin2 Z-up faces. After x,z,-y conversion:
// front=+Z, back=-Z, left=+X, right=-X, top=+Y, bottom=-Y.
if (az >= ax && az >= ay) {
if (direction.z > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_front)
color = texture(front_tex, uv).rgb;
} else {
uv = vec2(0.5 + 0.5 * direction.x / az,
0.5 - 0.5 * direction.y / az);
if (has_back)
color = texture(back_tex, uv).rgb;
}
} else if (ax >= ay) {
if (direction.x > 0.0) {
uv = vec2(0.5 + 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_left)
color = texture(left_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.z / ax,
0.5 - 0.5 * direction.y / ax);
if (has_right)
color = texture(right_tex, uv).rgb;
}
} else if (direction.y > 0.0) {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_top)
color = texture(top_tex, uv).rgb;
} else {
uv = vec2(0.5 - 0.5 * direction.x / ay,
0.5 - 0.5 * direction.z / ay);
if (has_bottom)
color = texture(bottom_tex, uv).rgb;
}
COLOR = apply_cloud(color, direction);
}
)";
Ref<ImageTexture> solid_texture(const Color &color) {
PackedByteArray pixels;
pixels.resize(4);
uint8_t *p = pixels.ptrw();
p[0] = (uint8_t)std::round(std::clamp(color.r, 0.0f, 1.0f) * 255.0f);
p[1] = (uint8_t)std::round(std::clamp(color.g, 0.0f, 1.0f) * 255.0f);
p[2] = (uint8_t)std::round(std::clamp(color.b, 0.0f, 1.0f) * 255.0f);
p[3] = (uint8_t)std::round(std::clamp(color.a, 0.0f, 1.0f) * 255.0f);
Ref<godot::Image> image = godot::Image::create_from_data(
1, 1, false, godot::Image::FORMAT_RGBA8, pixels);
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver *resolver) {
if (name.empty() || resolver == nullptr)
return Ref<ImageTexture>();
const std::string path = resolver->resolve(name, nullptr);
if (path.empty())
return Ref<ImageTexture>();
Ref<godot::Image> image;
const String godot_path(path.c_str());
if (godot_path.get_extension().to_lower() == "dds") {
const mtgodot::Image decoded = mtgodot::dds_from_file(godot_path);
if (!decoded.ok())
return Ref<ImageTexture>();
PackedByteArray pixels;
pixels.resize((int64_t)decoded.rgba.size());
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
godot::Image::FORMAT_RGBA8, pixels);
} else {
image = godot::Image::load_from_file(godot_path);
}
if (image.is_null() || image->is_empty())
return Ref<ImageTexture>();
image->generate_mipmaps();
return ImageTexture::create_from_image(image);
}
} // namespace
EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
EnvNodes apply_environment(const fmt::Environment &env, Node *parent,
const fmt::AssetResolver *resolver) {
EnvNodes out;
// --- DirectionalLight (Background) ---
@@ -30,6 +187,8 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
out.sun->set_name("Sun");
parent->add_child(out.sun);
}
out.sun->set_cull_mask(1u << 0); // background / terrain layer
out.sun->set_visible(env.dir_light.bg_enable);
// .msenv Direction 是 Metin2 Z-up 向量(光传播方向)。
fmt::m2coord::Vec3 d = fmt::m2coord::direction_to_godot(
env.dir_light.direction[0], env.dir_light.direction[1], env.dir_light.direction[2]);
@@ -56,6 +215,26 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
// 角色/物体的间接补光:暖色,能量取自 Material.Ambient
out.sun->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// ClientVS22 keeps the character light separate from the background light
// (MapOutdoor::OnBeginEnvironment -> SpeedTree::SetLight). Godot's light
// cull mask is the closest exact scene-level equivalent: static world
// geometry is layer 1, Metin2Model geometry is layer 2.
out.character_light = Object::cast_to<DirectionalLight3D>(
parent->get_node_or_null(NodePath("CharacterLight")));
if (!out.character_light) {
out.character_light = memnew(DirectionalLight3D);
out.character_light->set_name("CharacterLight");
parent->add_child(out.character_light);
}
out.character_light->set_cull_mask(1u << 1);
out.character_light->set_visible(env.dir_light.ch_enable);
out.character_light->set_transform(Transform3D(b, Vector3(0, 0, 0)));
out.character_light->set_color(rgba(env.dir_light.ch_diffuse));
out.character_light->set_param(Light3D::PARAM_ENERGY,
std::clamp(0.9f + luma(env.dir_light.ch_diffuse) * 0.4f, 0.7f, 1.6f));
out.character_light->set_shadow(false);
out.character_light->set_param(Light3D::PARAM_SPECULAR, 0.4f);
// --- WorldEnvironment ---
out.world_env =
Object::cast_to<WorldEnvironment>(parent->get_node_or_null(NodePath("WorldEnv")));
@@ -68,7 +247,12 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
if (e.is_null())
e.instantiate();
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 天空:SkyBox Gradient -> ProceduralSky 的三段色(zenith / horizon / ground
// 参考端的 texture mode 是独立六面 quadGodot Sky shader 用 EYEDIR 采样
// 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。
int sky_face_count = 0;
bool sky_texture_applied = false;
bool sky_cloud_applied = false;
{
Ref<ProceduralSkyMaterial> psm;
psm.instantiate();
@@ -83,7 +267,79 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
psm->set_sun_angle_max(6.0f);
Ref<Sky> sky;
sky.instantiate();
sky->set_material(psm);
std::array<Ref<ImageTexture>, 6> faces;
std::array<bool, 6> face_loaded{};
for (int i = 0; i < 6; ++i) {
if (env.sky.face_textures[i].empty())
continue;
faces[i] = load_texture(env.sky.face_textures[i], resolver);
face_loaded[i] = faces[i].is_valid();
if (face_loaded[i])
++sky_face_count;
}
Ref<ImageTexture> cloud_texture;
if (!env.sky.cloud_texture.empty())
cloud_texture = load_texture(env.sky.cloud_texture, resolver);
const bool use_texture_sky = env.sky.texture_render_mode && sky_face_count > 0;
sky_texture_applied = use_texture_sky;
if (use_texture_sky) {
Ref<Shader> shader;
shader.instantiate();
shader->set_code(String(SRC_SKYBOX));
Ref<ShaderMaterial> material;
material.instantiate();
material->set_shader(shader);
Color fallback = g.empty() ? Color(0.35f, 0.45f, 0.7f, 1.0f) : rgba(g.front());
Color horizon = g.size() < 2 ? fallback : rgba(g[g.size() / 2]);
Color ground = g.empty() ? Color(0.2f, 0.2f, 0.25f, 1.0f) : rgba(g.back());
material->set_shader_parameter("gradient_top", fallback);
material->set_shader_parameter("gradient_horizon", horizon);
material->set_shader_parameter("gradient_bottom", ground);
static constexpr const char *kFaceParams[6] = {
"front_tex", "back_tex", "left_tex", "right_tex", "top_tex", "bottom_tex"};
static constexpr const char *kFaceFlags[6] = {
"has_front", "has_back", "has_left", "has_right", "has_top", "has_bottom"};
for (int i = 0; i < 6; ++i) {
if (!faces[i].is_valid())
faces[i] = solid_texture(i == 5 ? ground : horizon);
material->set_shader_parameter(kFaceParams[i], faces[i]);
material->set_shader_parameter(kFaceFlags[i], face_loaded[i]);
}
if (cloud_texture.is_valid()) {
material->set_shader_parameter("cloud_tex", cloud_texture);
material->set_shader_parameter("has_cloud", true);
material->set_shader_parameter("cloud_scale", Vector2(
std::max(env.sky.cloud_scale[0] * (float)fmt::m2coord::CM_TO_M, 0.01f),
std::max(env.sky.cloud_scale[1] * (float)fmt::m2coord::CM_TO_M, 0.01f)));
material->set_shader_parameter("cloud_height",
std::max(env.sky.cloud_height * (float)fmt::m2coord::CM_TO_M, 0.01f));
material->set_shader_parameter("cloud_texture_scale", Vector2(
env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
material->set_shader_parameter("cloud_speed", Vector2(
env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
Color tint = Color(0, 0, 0, 0);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
material->set_shader_parameter("cloud_tint", tint);
sky_cloud_applied = true;
}
sky->set_material(material);
} else {
sky->set_material(psm);
}
// Reference SkyBox scrolls clouds in its own renderer. Godot's procedural
// sky has no equivalent UV-speed control, but sky_cover preserves the real
// cloud asset and alpha/color instead of silently dropping CloudTextureFileName.
if (!env.sky.cloud_texture.empty() && !use_texture_sky) {
if (cloud_texture.is_valid()) {
psm->set_sky_cover(cloud_texture);
Color tint = Color(1, 1, 1, 1);
if (!env.sky.cloud_color.empty())
tint = rgba(env.sky.cloud_color.front());
psm->set_sky_cover_modulate(tint);
sky_cloud_applied = true;
}
}
e->set_sky(sky);
e->set_background(godot::Environment::BG_SKY);
}
@@ -145,6 +401,37 @@ EnvNodes apply_environment(const fmt::Environment &env, Node *parent) {
e->set_ssao_radius(1.2f);
e->set_ssao_intensity(0.9f);
// Keep parsed reference values visible to the runtime/debug probe even where
// Godot has no 1:1 mapping (six cube faces, D3D blend factors, cloud motion,
// and lens flare). This also prevents a future builder from silently losing
// the fields while the renderer equivalent is being implemented.
out.world_env->set_meta("msenv_sky_texture_mode", env.sky.texture_render_mode);
out.world_env->set_meta("msenv_sky_face_count", sky_face_count);
out.world_env->set_meta("msenv_sky_texture_mode_applied", sky_texture_applied);
out.world_env->set_meta("msenv_sky_cloud_applied", sky_cloud_applied);
out.world_env->set_meta("msenv_sky_scale",
Vector3(env.sky.scale[0], env.sky.scale[1], env.sky.scale[2]));
out.world_env->set_meta("msenv_cloud_scale",
Vector2(env.sky.cloud_scale[0], env.sky.cloud_scale[1]));
out.world_env->set_meta("msenv_cloud_height", env.sky.cloud_height);
out.world_env->set_meta("msenv_cloud_texture_scale",
Vector2(env.sky.cloud_texture_scale[0], env.sky.cloud_texture_scale[1]));
out.world_env->set_meta("msenv_cloud_speed",
Vector2(env.sky.cloud_speed[0], env.sky.cloud_speed[1]));
out.world_env->set_meta("msenv_filter_enabled", env.filter.enable);
out.world_env->set_meta("msenv_filter_color", rgba(env.filter.color));
out.world_env->set_meta("msenv_filter_alpha_src", env.filter.alpha_src);
out.world_env->set_meta("msenv_filter_alpha_dest", env.filter.alpha_dest);
out.world_env->set_meta("msenv_lens_flare_enabled", env.lens_flare.enable);
out.world_env->set_meta("msenv_lens_flare_texture", env.lens_flare.main_flare_texture.c_str());
out.world_env->set_meta("msenv_background_light_enabled", env.dir_light.bg_enable);
out.world_env->set_meta("msenv_character_light_enabled", env.dir_light.ch_enable);
out.world_env->set_meta("msenv_background_light_ambient", rgba(env.dir_light.bg_ambient));
out.world_env->set_meta("msenv_character_light_ambient", rgba(env.dir_light.ch_ambient));
// MapUtil.cpp defaults; Environment_Load has no wind token to override these.
out.world_env->set_meta("msenv_wind_strength", 0.2f);
out.world_env->set_meta("msenv_wind_random", 0.0f);
out.world_env->set_environment(e);
return out;
}
+8 -3
View File
@@ -3,19 +3,24 @@
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/world_environment.hpp>
#include <asset_resolver.h>
#include <environment.h>
// W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。
// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质
// uniform(§9-W5 note)。云 / lens flare 留 R2。
// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入
// ProceduralSkyMaterial 的 sky covertexture-mode 下再用 Godot sky shader 叠加
// 参考端的有限云层平面与 UV 滚动。六面天空、滤色和 lens flare 仍保持显式待办,
// 不把不等价的近似标成完成。
namespace mtgodot {
struct EnvNodes {
godot::DirectionalLight3D *sun = nullptr;
godot::DirectionalLight3D *character_light = nullptr;
godot::WorldEnvironment *world_env = nullptr;
};
// 在 parent 下建 / 配 DirectionalLight3D + WorldEnvironment。已存在则复用。
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent);
EnvNodes apply_environment(const fmt::Environment &env, godot::Node *parent,
const fmt::AssetResolver *resolver = nullptr);
} // namespace mtgodot
+87
View File
@@ -17,6 +17,9 @@
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <combo_table.h>
using namespace godot;
@@ -47,6 +50,11 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("parse_combo_tables", "py_path"), &Metin2AnimPlayer::parse_combo_tables);
ClassDB::bind_static_method("Metin2AnimPlayer",
D_METHOD("make_combo_key", "motion_mode", "combo_type"), &Metin2AnimPlayer::make_combo_key);
// Fired when playback time crosses a .msa MotionEventData entry.
ADD_SIGNAL(MethodInfo("motion_event",
@@ -187,6 +195,85 @@ Dictionary Metin2AnimPlayer::get_loop_data() const {
return d;
}
// CLIENT-GAP §3.3 / §3.5 — the currently-loaded .msa's ComboInputData / AttackingData.
// `next_combo` is the reference普攻节奏 (CRaceMotionData::GetNextComboTime); when the
// motion has no ComboInputData it falls back to MotionDuration * 0.9.
Dictionary Metin2AnimPlayer::get_motion_data() const {
const fmt::Msa &m = msa_metadata;
Dictionary d;
d["duration"] = duration > 0.0 ? duration : m.duration;
d["has_combo_input"] = m.has_combo_input;
d["pre_input_time"] = m.combo_pre_input_time;
d["direct_input_time"] = m.combo_direct_input_time;
d["input_limit_time"] = m.combo_input_limit_time;
d["link_time"] = m.combo_link_time;
const double dur = duration > 0.0 ? duration : m.duration;
d["next_combo"] = m.has_combo_input && m.combo_direct_input_time > 0.0
? (double)m.combo_direct_input_time
: dur * 0.9;
d["has_attacking_data"] = m.has_attacking_data;
d["attacking_type"] = m.attacking_type;
d["motion_type"] = m.motion_type;
d["hitting_type"] = m.hitting_type;
d["attack_start_time"] = m.attack_start_time;
d["attack_end_time"] = m.attack_end_time;
d["stiffen_time"] = m.stiffen_time;
d["invisible_time"] = m.invisible_time;
d["external_force"] = m.external_force;
d["hit_limit_count"] = m.hit_limit_count;
// CLIENT-GAP §3.5 修改 1 —— THitDataContainer 多命中窗(多段挥击 / 双持 = 多个窗)。
// [{start_time, end_time, bone, weapon_length, samples:[{time, last_pos:Vector3, pos:Vector3}]}]
Array windows;
for (const fmt::Msa::HitWindow &w : m.hit_windows) {
Dictionary wd;
wd["start_time"] = w.start_time;
wd["end_time"] = w.end_time;
wd["bone"] = String::utf8(w.bone_name.c_str());
wd["weapon_length"] = w.weapon_length;
Array samples;
for (const fmt::Msa::HitSample &s : w.samples) {
Dictionary sd;
sd["time"] = s.time;
sd["last_pos"] = Vector3(s.last_pos[0], s.last_pos[1], s.last_pos[2]);
sd["pos"] = Vector3(s.pos[0], s.pos[1], s.pos[2]);
samples.push_back(sd);
}
wd["samples"] = samples;
windows.push_back(wd);
}
d["hit_windows"] = windows;
return d;
}
int Metin2AnimPlayer::make_combo_key(int motion_mode, int combo_type) {
return (int)fmt::make_combo_key((uint16_t)motion_mode, (uint16_t)combo_type);
}
Dictionary Metin2AnimPlayer::parse_combo_tables(const String &py_path) {
Dictionary out;
fmt::PlayerComboTables tables;
std::string err;
if (!fmt::parse_player_combo_tables_file(
std::string(py_path.utf8().get_data()), tables, &err)) {
UtilityFunctions::push_warning(
String("Metin2AnimPlayer.parse_combo_tables: ") + String(err.c_str()));
return out;
}
for (int c = 0; c < fmt::COMBO_CLASS_COUNT; ++c) {
Dictionary cls;
for (const auto &kv : tables.klass(c).combos) {
PackedInt32Array segs;
segs.resize((int)kv.second.size());
for (int i = 0; i < (int)kv.second.size(); ++i) {
segs.set(i, (int)kv.second[i]);
}
cls[(int)kv.first] = segs;
}
out[c] = cls;
}
return out;
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
+10
View File
@@ -68,6 +68,16 @@ public:
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
// { class_idx:int -> { combo_key:int -> PackedInt32Array(段号 14..21) } }
// with class_idx 0=warrior 1=assassin 2=sura 3=shaman and
// combo_key = (motion_mode << 16) | combo_type. Empty Dictionary on failure.
static godot::Dictionary parse_combo_tables(const godot::String &py_path);
// (motion_mode << 16) | combo_type — mirrors RaceData.h MAKE_COMBO_KEY.
static int make_combo_key(int motion_mode, int combo_type);
// NaN-scan every animation in anim_path across [0,dur]; returns a report string.
godot::String selfcheck(int samples = 24);
+3
View File
@@ -265,6 +265,7 @@ void Metin2Model::_set_lod(int n) {
if (mi->get_mesh().is_valid() && material_mode != String("standard")) {
lod_prev_mi = memnew(MeshInstance3D);
lod_prev_mi->set_name("LodGhost");
lod_prev_mi->set_layer_mask(1u << 1);
lod_prev_mi->set_mesh(mi->get_mesh());
add_child(lod_prev_mi);
lod_prev_mi->set_transform(mi->get_transform());
@@ -457,6 +458,7 @@ void Metin2Model::reload() {
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
mi->set_layer_mask(1u << 1); // CharacterLight visibility layer.
mi->set_mesh(mesh);
add_child(mi);
mi->set_owner(get_owner() ? get_owner() : this);
@@ -722,6 +724,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
slot_mi = memnew(MeshInstance3D);
slot_mi->set_name(node_name);
slot_mi->set_layer_mask(1u << 1); // equipment follows the character light.
slot_mi->set_mesh(wmesh);
add_child(slot_mi);
+294 -34
View File
@@ -3,6 +3,8 @@
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/box_shape3d.hpp>
#include <godot_cpp/classes/collision_shape3d.hpp>
#include <godot_cpp/classes/concave_polygon_shape3d.hpp>
#include <godot_cpp/classes/cylinder_shape3d.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/geometry_instance3d.hpp>
#include <godot_cpp/classes/height_map_shape3d.hpp>
@@ -13,6 +15,7 @@
#include <godot_cpp/classes/multi_mesh_instance3d.hpp>
#include <godot_cpp/classes/performance.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/sphere_shape3d.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/basis.hpp>
@@ -31,6 +34,7 @@
#include <property.h>
#include <splat.h>
#include <terrain_mesh.h>
#include <attribute_data.h>
#include "dxt.h"
#include "environment_builder.h"
@@ -45,11 +49,155 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
using namespace godot;
namespace mtgodot {
namespace {
// AttributeData stores rotations as D3DX quaternions in Metin2's Z-up
// coordinate system. Convert through the same axis change as m2coord positions.
Basis attribute_quaternion_basis(const std::array<float, 4> &q) {
double x = q[0], y = q[1], z = q[2], w = q[3];
double length = std::sqrt(x * x + y * y + z * z + w * w);
if (length < 1e-8)
return Basis();
x /= length; y /= length; z /= length; w /= length;
const double r[9] = {
1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w),
2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w),
2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y),
};
const fmt::m2coord::Mat3 m2{{r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]}};
const fmt::m2coord::Mat3 c{{1, 0, 0, 0, 0, 1, 0, -1, 0}};
const fmt::m2coord::Mat3 ci{{1, 0, 0, 0, 0, -1, 0, 1, 0}};
auto mul = [](const fmt::m2coord::Mat3 &a, const fmt::m2coord::Mat3 &b) {
fmt::m2coord::Mat3 result{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
for (int k = 0; k < 3; ++k)
result.m[i * 3 + j] += a.m[i * 3 + k] * b.m[k * 3 + j];
return result;
};
const fmt::m2coord::Mat3 godot = mul(mul(c, m2), ci);
return Basis(Vector3(godot.m[0], godot.m[3], godot.m[6]),
Vector3(godot.m[1], godot.m[4], godot.m[7]),
Vector3(godot.m[2], godot.m[5], godot.m[8]));
}
Vector3 attribute_position(const std::array<float, 3> &p) {
const fmt::m2coord::Vec3 v = fmt::m2coord::position_to_godot(p[0], p[1], p[2]);
return Vector3((float)v.x, (float)v.y, (float)v.z);
}
bool add_attribute_collision(const fmt::AttributeCollision &collision,
StaticBody3D *body) {
using Type = fmt::AttributeCollisionType;
const auto type = static_cast<Type>(collision.type);
Ref<Shape3D> shape;
Transform3D local(attribute_quaternion_basis(collision.quaternion),
attribute_position(collision.position));
const float cm = 0.01f;
const float thickness = 0.04f; // finite-plane approximation, 4 cm
switch (type) {
case Type::Plane: {
Ref<BoxShape3D> plane;
plane.instantiate();
plane->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * cm),
thickness, std::max(0.02f, std::abs(collision.dimensions[1]) * cm)));
shape = plane;
break;
}
case Type::Box:
case Type::Aabb:
case Type::Obb: {
Ref<BoxShape3D> box;
box.instantiate();
// CollisionData stores these as half-extents. Swap source y/z for Godot.
box->set_size(Vector3(std::max(0.02f, std::abs(collision.dimensions[0]) * 2.0f * cm),
std::max(0.02f, std::abs(collision.dimensions[2]) * 2.0f * cm),
std::max(0.02f, std::abs(collision.dimensions[1]) * 2.0f * cm)));
shape = box;
if (type == Type::Aabb)
local.basis = Basis();
break;
}
case Type::Sphere: {
Ref<SphereShape3D> sphere;
sphere.instantiate();
sphere->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm));
shape = sphere;
local.basis = Basis();
break;
}
case Type::Cylinder: {
Ref<CylinderShape3D> cylinder;
cylinder.instantiate();
cylinder->set_radius(std::max(0.01f, std::abs(collision.dimensions[0]) * cm));
cylinder->set_height(std::max(0.02f, std::abs(collision.dimensions[1]) * cm));
shape = cylinder;
break;
}
default:
return false;
}
CollisionShape3D *node = memnew(CollisionShape3D);
node->set_name(String(collision.name.c_str()));
node->set_shape(shape);
node->set_transform(local);
body->add_child(node);
return true;
}
int add_attribute_data(const std::string &path, const Transform3D &place,
Node3D *root, MeshInstance3D *mesh) {
fmt::AttributeData data;
std::string error;
if (!fmt::parse_attribute_data_file(path, data, &error)) {
UtilityFunctions::push_warning(String("[mdatr] load failed: ") + path.c_str() +
" (" + error.c_str() + ")");
return 0;
}
StaticBody3D *body = memnew(StaticBody3D);
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
body->set_collision_layer(2);
body->set_collision_mask(0);
body->set_transform(place);
int shapes = 0;
for (const auto &collision : data.collisions)
if (add_attribute_collision(collision, body))
++shapes;
for (const auto &height : data.heights) {
if (height.vertices.size() < 3)
continue;
PackedVector3Array faces;
faces.resize((int)height.vertices.size());
for (int i = 0; i < (int)height.vertices.size(); ++i)
faces[i] = attribute_position(height.vertices[(size_t)i]);
Ref<ConcavePolygonShape3D> concave;
concave.instantiate();
concave->set_faces(faces);
CollisionShape3D *node = memnew(CollisionShape3D);
node->set_name(String(height.name.c_str()));
node->set_shape(concave);
body->add_child(node);
++shapes;
}
if (shapes == 0) {
body->queue_free();
return 0;
}
body->set_meta("occ_mesh", mesh);
root->add_child(body);
return shapes;
}
} // namespace
Metin2World::Metin2World() {}
Metin2World::~Metin2World() {}
@@ -90,9 +238,14 @@ void Metin2World::_bind_methods() {
ClassDB::bind_method(D_METHOD("sample_height", "gx_m", "gz_m"), &Metin2World::sample_height);
ClassDB::bind_method(D_METHOD("sample_attribute", "gx_m", "gz_m"), &Metin2World::sample_attribute);
ClassDB::bind_method(D_METHOD("is_blocked", "gx_m", "gz_m"), &Metin2World::is_blocked);
ClassDB::bind_method(D_METHOD("get_fishing_rotation", "gx_m", "gz_m", "heading_deg"),
&Metin2World::get_fishing_rotation);
ClassDB::bind_method(D_METHOD("can_fishing_position", "gx_m", "gz_m", "heading_deg"),
&Metin2World::can_fishing_position);
ClassDB::bind_method(D_METHOD("load_dds", "path"), &Metin2World::load_dds);
ClassDB::bind_method(D_METHOD("chunk_dir", "tx", "ty"), &Metin2World::chunk_dir);
ClassDB::bind_method(D_METHOD("get_load_report"), &Metin2World::get_load_report);
ClassDB::bind_method(D_METHOD("get_ambience_sources"), &Metin2World::get_ambience_sources);
ClassDB::bind_method(D_METHOD("bake_asset_index", "out_path"), &Metin2World::bake_asset_index);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "assets_root", PROPERTY_HINT_GLOBAL_DIR),
@@ -248,6 +401,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
MeshInstance3D *pm = memnew(MeshInstance3D);
pm->set_name(Pn > 1 ? vformat("Terrain_%d_%d", pi, pj) : String("Terrain"));
pm->set_layer_mask(1u << 0); // Background light visibility layer.
pm->set_mesh(pmesh);
if (Pn > 1 && terrain_patch_view > 0.0f) {
// 硬剔除(无半透明淡出),避免远景地形变透明
@@ -299,6 +453,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
continue;
MeshInstance3D *w = memnew(MeshInstance3D);
w->set_name("Water");
w->set_layer_mask(1u << 0);
w->set_mesh(p.mesh);
croot->add_child(w);
++water_pieces;
@@ -314,6 +469,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
trees_placed += ck.trees;
chunks.push_back(std::move(ck));
@@ -400,6 +557,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
MeshInstance3D *mi = memnew(MeshInstance3D);
mi->set_name(String(p->name.c_str()) + "_" + String::num_uint64(o.crc));
mi->set_layer_mask(1u << 0); // Background light visibility layer.
mi->set_mesh(mesh);
mi->set_transform(xform);
// ShadowFlag: 客户端把 isShadowFlag 物体丢进动态阴影贴图(= 我们的实时投影);
@@ -413,17 +571,23 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
pids.push_back(pid);
mi->set_meta("portal_ids", pids);
}
// .mdatr 静态碰撞:未实现。有同名 .mdatr 的计数上报(SHINSOO §9-W3 第 7 项)
{
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md))
++objects_mdatr_pending;
}
root->add_child(mi);
// §8.2/§8.3: 盒碰撞体(层 2 = 静态遮挡物)。相机用它做防穿 + 遮挡淡出。
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
bool mdatr_built = false;
if (collision_enabled) {
String md = String(rp.c_str()).get_basename() + ".mdatr";
if (FileAccess::file_exists(md)) {
const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi);
mdatr_built = built > 0;
if (mdatr_built)
++objects_mdatr_built;
}
}
// A malformed/missing attribute resource still gets a conservative
// GR2 AABB fallback. A valid .mdatr owns the exact collision set.
if (collision_enabled && !mdatr_built) {
AABB ab = mesh->get_aabb(); // gr2 原始 cm / Z-up
if (ab.size.length() > 0.001f) {
Vector3 c_cm = ab.position + ab.size * 0.5f; // 中心 cm Z-up
@@ -462,6 +626,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
mm->set_instance_transform(i, kv.second.xforms[i]);
MultiMeshInstance3D *mmi = memnew(MultiMeshInstance3D);
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_layer_mask(1u << 0);
mmi->set_multimesh(mm);
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
@@ -474,6 +639,46 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
}
}
void Metin2World::place_chunk_ambience(int tx, int ty) {
// Area ambience is a separate file in ClientVS22. The .pra property owns
// play type / interval / sound names; Audio owns the runtime instances.
const std::string mdir = std::string(map_dir().utf8().get_data());
fmt::AreaAmbienceData ad;
std::string e;
const std::string base = mdir + "/" + fmt::m2coord::tile_dir(tx, ty) + "/";
bool loaded = fmt::parse_area_ambience_file(base + "areaambiencedata.txt", ad, &e);
if (!loaded)
loaded = fmt::parse_area_ambience_file(base + "AreaAmbienceData.txt", ad, &e);
if (!loaded)
return; // 老地图可能没有环境声文件。
for (size_t i = 0; i < ad.objects.size(); ++i) {
const fmt::AreaAmbienceObject &o = ad.objects[i];
const fmt::Property *p = registry.find(o.crc);
if (!p || p->type != fmt::PropertyType::Ambience)
continue;
AmbienceSource source;
source.tile_x = tx;
source.tile_y = ty;
source.object_index = (int)i;
const fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, o.z);
source.position = Vector3((float)g.x, (float)g.y, (float)g.z);
source.range_cm = std::max(0, (int)std::lround(o.range));
source.max_volume_area_percentage = (float)std::strtof(
p->get("maxvolumeareapercentage", "0").c_str(), nullptr);
source.play_interval = (float)std::strtof(
p->get("playinterval", "0").c_str(), nullptr);
source.play_interval_variation = (float)std::strtof(
p->get("playintervalvariation", "0").c_str(), nullptr);
source.play_type = p->get("playtype", "LOOP").c_str();
const std::string sounds = p->get("ambiencesoundvector");
if (!sounds.empty())
source.sounds.push_back(sounds);
if (!source.sounds.empty() && source.range_cm > 0)
ambience_sources.push_back(std::move(source));
}
}
bool Metin2World::load_map() {
unload_map();
@@ -487,39 +692,35 @@ bool Metin2World::load_map() {
}
setting_ok = true;
// splat 前置:TextureSet + AssetResolver(整盘扫描,一次)
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
splat_ready = false;
if (splat_enabled) {
const std::string root = std::string(assets_root.utf8().get_data());
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (!resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
if (splat_enabled && resolver) {
std::string ts_rel = setting.texture_set;
for (char &c : ts_rel) {
if (c == '\\')
c = '/';
c = (char)std::tolower((unsigned char)c);
}
const std::string root = std::string(assets_root.utf8().get_data());
std::string tse;
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse)) {
resolver = std::make_shared<fmt::AssetResolver>();
std::string re;
// asset_index.txt 存在就装载(PCK/移动端必走),否则扫盘(桌面开发)。
if (resolver->build_or_load(root, fmt::AssetResolver::default_priority(), &re)) {
splat_ready = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] AssetResolver: ") + re.c_str());
resolver.reset();
}
} else {
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse))
splat_ready = true;
else
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
}
}
// Property CRC 注册表(一次;splat 已建 resolver
// Property CRC 注册表(对象和环境声共用;不依赖 objects_enabled)。
registry_ok = false;
if (objects_enabled) {
const std::string root(assets_root.utf8().get_data());
std::string re;
// resolver 已建(splat 阶段):用它的文件清单,避免再 std::filesystem 扫盘
// PCK 里扫不了)。没 resolver 时退回目录递归。
{
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
bool ok = resolver
? registry.scan_list(root, resolver->all_rel(), &re)
: registry.scan(root + "/Property", &re);
@@ -554,12 +755,9 @@ bool Metin2World::load_map() {
if (rp.empty())
rp = std::string(assets_root.utf8().get_data()) +
"/ETC/ymir work/environment/" + setting.environment;
// 小写文件名
for (size_t i = rp.rfind('/') + 1; i < rp.size(); ++i)
rp[i] = (char)std::tolower((unsigned char)rp[i]);
std::string ee;
if (fmt::parse_environment_file(rp, env, &ee)) {
apply_environment(env, this);
apply_environment(env, this, resolver.get());
env_ok = true;
} else {
UtilityFunctions::push_warning(String("[Metin2World] .msenv: ") + ee.c_str());
@@ -588,6 +786,9 @@ void Metin2World::unload_chunk(int idx) {
--chunks_built;
if (c.root)
c.root->queue_free();
ambience_sources.erase(std::remove_if(ambience_sources.begin(), ambience_sources.end(),
[&c](const AmbienceSource &s) { return s.tile_x == c.tx && s.tile_y == c.ty; }),
ambience_sources.end());
chunks.erase(chunks.begin() + idx);
}
@@ -637,11 +838,12 @@ void Metin2World::unload_map() {
while (!chunks.empty())
unload_chunk((int)chunks.size() - 1);
stream_queue.clear();
ambience_sources.clear();
if (objects_root) {
objects_root->queue_free();
objects_root = nullptr;
}
for (const char *nm : {"Sun", "WorldEnv"})
for (const char *nm : {"Sun", "CharacterLight", "WorldEnv"})
if (Node *n = get_node_or_null(NodePath(nm)))
n->queue_free();
water_pieces = 0;
@@ -652,7 +854,7 @@ void Metin2World::unload_map() {
chunks_built = chunks_failed = chunks_splatted = 0;
objects_placed = objects_skipped = objects_missing_model = 0;
trees_placed = tree_species = 0;
objects_mdatr_pending = 0;
objects_mdatr_built = 0;
build_ms = 0;
splat_ready = registry_ok = false;
last_error = "";
@@ -736,6 +938,42 @@ int Metin2World::sample_attribute(double gx_m, double gz_m) const {
return c->am->data[size_t(ay) * fmt::ATTRMAP_XY + ax];
}
double Metin2World::get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const {
// InstanceBaseMotion.cpp uses c_fFishingDistance=600.0f and
// ELPlainCoord_GetRotatedPixelPosition (sin(rot), cos(rot)). The POC
// world frame is the MapCoord frame, where heading -> world yaw is
// heading + 90 degrees; using the same conversion keeps the probe in
// the coordinate frame used by player_controller / MapCoord.
constexpr double DISTANCE_M = 6.0;
constexpr double WATER_ATTRIBUTE = 1 << 1; // PRTerrainLib::ATTRIBUTE_WATER
constexpr double DEG_TO_RAD = 3.14159265358979323846 / 180.0;
auto normalized = [](double d) {
d = std::fmod(d, 360.0);
return d < 0.0 ? d + 360.0 : d;
};
auto water_at = [&](double candidate_heading) {
const double yaw = (candidate_heading + 90.0) * DEG_TO_RAD;
const double x = gx_m + std::sin(yaw) * DISTANCE_M;
const double z = gz_m + std::cos(yaw) * DISTANCE_M;
return (sample_attribute(x, z) & int(WATER_ATTRIBUTE)) != 0;
};
for (int step = 0; step <= 18; ++step) {
const double offset = double(step * 10);
const double right = heading_deg + offset;
if (water_at(right))
return normalized(right);
const double left = heading_deg - offset;
if (water_at(left))
return normalized(left);
}
return -1.0;
}
bool Metin2World::can_fishing_position(double gx_m, double gz_m, double heading_deg) const {
return get_fishing_rotation(gx_m, gz_m, heading_deg) >= 0.0;
}
Dictionary Metin2World::get_load_report() const {
Dictionary d;
d["map_path"] = map_path;
@@ -756,9 +994,10 @@ Dictionary Metin2World::get_load_report() const {
d["objects_placed"] = objects_placed;
d["objects_skipped"] = objects_skipped;
d["objects_missing_model"] = objects_missing_model;
d["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["objects_mdatr_pending"] = objects_mdatr_pending; // .mdatr 但未建碰撞
d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数
d["static_meshes"] = static_cache.loaded;
d["build_ms"] = build_ms;
d["resident_chunks"] = (int)chunks.size();
@@ -768,6 +1007,27 @@ Dictionary Metin2World::get_load_report() const {
return d;
}
Array Metin2World::get_ambience_sources() const {
Array out;
for (const AmbienceSource &s : ambience_sources) {
Dictionary d;
d["key"] = String::num_int64(s.tile_x) + ":" + String::num_int64(s.tile_y) + ":" +
String::num_int64(s.object_index);
d["position"] = s.position;
d["range_cm"] = s.range_cm;
d["max_volume_area_percentage"] = s.max_volume_area_percentage;
d["play_interval"] = s.play_interval;
d["play_interval_variation"] = s.play_interval_variation;
d["play_type"] = s.play_type;
Array sounds;
for (const std::string &sound : s.sounds)
sounds.push_back(String(sound.c_str()));
d["sounds"] = sounds;
out.push_back(d);
}
return out;
}
Dictionary Metin2World::get_perf() const {
Dictionary d;
Performance *pf = Performance::get_singleton();
+24 -1
View File
@@ -3,6 +3,7 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/string.hpp>
@@ -14,6 +15,7 @@
#include <texture_set.h>
#include <memory>
#include <cstdint>
#include <vector>
#include "static_object.h"
@@ -84,7 +86,15 @@ public:
// attr.atr 属性字节(bit0=BLOCK, bit1=WATER…);地图外返回 0。
int sample_attribute(double gx_m, double gz_m) const;
bool is_blocked(double gx_m, double gz_m) const { return (sample_attribute(gx_m, gz_m) & 1) != 0; }
// ClientVS22 InstanceBaseMotion::GetFishingRot 等价:在角色前方 600cm
// 按当前 heading ±(0..180°,步长 10°) 扫描 ATTRIBUTE_WATER。
// 返回选中的服务端 heading;没有水面或目标区块未加载时返回 -1。
double get_fishing_rotation(double gx_m, double gz_m, double heading_deg) const;
bool can_fishing_position(double gx_m, double gz_m, double heading_deg) const;
godot::Dictionary get_load_report() const;
// Area ambience sources from AreaAmbienceData + .pra properties. Audio owns
// playback; the world only exposes the reference source records.
godot::Array get_ambience_sources() const;
// 便捷:解一张 DDS 为 Image(HUD 小地图等用;Godot 原生不支持 .dds)。
godot::Ref<godot::Image> load_dds(const godot::String &path) const;
@@ -133,7 +143,7 @@ private:
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
int trees_placed = 0, tree_species = 0;
int objects_mdatr_pending = 0;
int objects_mdatr_built = 0;
godot::Node3D *objects_root = nullptr;
double build_ms = 0;
@@ -144,13 +154,26 @@ private:
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
int object_index = 0;
godot::Vector3 position;
int range_cm = 0;
float max_volume_area_percentage = 0.0f;
float play_interval = 0.0f;
float play_interval_variation = 0.0f;
godot::String play_type;
std::vector<std::string> sounds;
};
std::vector<Chunk> chunks;
std::vector<std::pair<int, int>> stream_queue; // 待建区块
std::vector<AmbienceSource> ambience_sources;
int stream_budget = 1; // 每帧最多建几个区块(streaming 时)
godot::String map_dir() const;
bool build_chunk(int tx, int ty);
void place_chunk_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
const Chunk *chunk_at(int tx, int ty) const;
+114 -15
View File
@@ -1,10 +1,31 @@
#include "classic_parser.h"
#include <cstring>
#include <string>
#include <utility>
#include <vector>
namespace mtnet::classic {
void ClassicParser::set_npclist_path(std::string path) {
if (path == m_npclist_path) {
return;
}
m_npclist_path = std::move(path);
m_npc_names.clear();
m_npc_names_loaded = false;
}
const std::string &ClassicParser::npc_name_for(uint16_t race) {
static const std::string kEmpty;
if (!m_npc_names_loaded) {
m_npc_names = load_npclist(m_npclist_path.empty() ? default_npclist_path() : m_npclist_path);
m_npc_names_loaded = true;
}
const auto it = m_npc_names.find(race);
return it == m_npc_names.end() ? kEmpty : it->second;
}
void ClassicParser::parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle, uint32_t rkey) {
m_slots.clear();
@@ -23,6 +44,8 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
s.play_minutes = sp.play_minutes;
s.x = sp.x;
s.y = sp.y;
s.addr = sp.addr;
s.port = sp.port;
s.main_part = sp.main_part;
s.hair_part = sp.hair_part;
s.skill_group = sp.skill_group;
@@ -37,6 +60,7 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
}
bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_last_error.clear();
switch (header) {
case HDR_GC_LOGIN_SUCCESS_NEWSLOT: { // 32 — what 40250 actually sends
GCLoginSuccess p;
@@ -74,10 +98,12 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
case HDR_GC_XTRAP_CS1_REQUEST:
case HDR_GC_PANAMA_PACK:
// These packets belong to optional account/anti-cheat integrations.
// Their static sizes are still registered in wire_classic.h so they
// cannot desynchronise the game stream; this client has no matching
// Godot UI or vendor runtime to act on them.
return true;
// Their static sizes are still registered in wire_classic.h, but this
// client has no matching Godot UI or vendor runtime to act on them.
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
m_unhandled_headers.push_back(header);
return false;
case HDR_GC_CHARACTER_CREATE_SUCCESS: {
GCPlayerCreateSuccess p;
if (!fill(p, header, body, len)) {
@@ -178,6 +204,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
// §9.1: route the map's background-music track; volume unspecified.
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
return true;
}
case HDR_GC_MAIN_CHARACTER4_BGM_VOL: {
@@ -188,6 +217,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
// §9.1: track + server-authored volume (0..1).
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
p.bgm_volume);
return true;
}
case HDR_GC_CHARACTER_ADD: { // 1
@@ -195,7 +228,36 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_world.mut_spawn(p.vid, p.race, p.type, /*name*/ std::string(), (float)p.x, (float)p.y,
// §0.3 / §2.2: the reference client (IsInvisibleRace) never spawns
// these — drop before any stash / spawn.
if (is_invisible_race(p.race)) {
return true;
}
if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
// parts, so stash a bare record and wait for
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
// is untouched until then (§0.2 data layer only).
Entity &e = m_pending_actor[p.vid];
e = Entity{};
e.vid = p.vid;
e.race = p.race;
e.ch_type = p.type;
e.x = (float)p.x;
e.y = (float)p.y;
e.z = (float)p.z;
e.angle = p.angle;
e.moving_speed = p.moving_speed;
e.attack_speed = p.attack_speed;
e.state_flags = p.state_flag;
e.affect_flags = (uint64_t)p.affect_flag[0] |
((uint64_t)p.affect_flag[1] << 32);
return true;
}
// Monster / stone / door / object: appended immediately, name from
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
// PC-specific field stays 0.
m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
return true;
}
@@ -209,6 +271,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
m_world.mut_char_info(p.vid, {}, p.parts, p.empire, (int32_t)p.guild, 0,
p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): affect flags travel in their own two-word
// segment, not through mut_char_info.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_DEL: { // 2
@@ -224,9 +289,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move)
// classic wire heading: deg = bRot * 5 (client NetStream Send/Recv Move).
// §3.2: p.arg is the motion index, p.time the server command time that
// gates when the queued state command is released.
m_world.mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
p.duration);
p.duration, p.arg, p.time);
return true;
}
case HDR_GC_CHARACTER_POINTS: { // 16
@@ -305,6 +372,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
m_world.mut_char_update(p.vid, p.parts, p.moving_speed, p.attack_speed,
(int32_t)p.guild_id, p.alignment, p.pk_mode, p.mount_vnum);
// §2.5 (W1↔W2 G1 wiring): mut_char_update carries no affect segment.
m_world.mut_affect_flags(p.vid, p.affect_flag[0], p.affect_flag[1]);
return true;
}
case HDR_GC_CHARACTER_POSITION: { // 43
@@ -384,9 +453,31 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
m_world.mut_char_info(p.vid, std::string(p.name, strnlen(p.name, sizeof(p.name))),
p.parts, p.empire, (int32_t)p.guild_id, (int32_t)p.level, p.alignment,
p.pk_mode, p.mount_vnum);
// §2.2: this only ever completes a pending GC_CHARACTER_ADD (the
// reference merges into s_kNetActorData, populated by that add). No
// pending record -> nothing to build a PC/NPC from: record + drop,
// never half-create.
const auto it = m_pending_actor.find(p.vid);
if (it == m_pending_actor.end()) {
m_last_error = "GC_CHAR_ADDITIONAL_INFO: no pending actor for vid " +
std::to_string(p.vid);
m_unhandled_headers.push_back(header);
return true;
}
Entity e = it->second;
e.vid = p.vid;
e.name = mtnet::from_wire_str(p.name, sizeof(p.name)); // §1.10
e.guild = (int32_t)p.guild_id;
e.level = (int32_t)p.level;
e.alignment = p.alignment;
e.pk_mode = p.pk_mode;
e.empire = p.empire;
for (int i = 0; i < CHR_EQUIPPART_NUM; ++i) {
e.parts[i] = p.parts[i];
}
e.mount_vnum = p.mount_vnum;
m_world.mut_spawn_full(e);
m_pending_actor.erase(it);
return true;
}
case HDR_GC_SKILL_LEVEL: { // 76
@@ -603,9 +694,16 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_exchange_start(p.arg1);
break;
case EXCHANGE_GC_ITEM_ADD:
{
ItemAttr attrs[ITEM_ATTRIBUTE_MAX_NUM]{};
for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) {
attrs[i].type = p.attrs[i].type;
attrs[i].value = p.attrs[i].value;
}
m_world.mut_exchange_item(self, static_cast<uint8_t>(p.arg2.cell), p.arg1,
static_cast<uint8_t>(p.arg3));
static_cast<uint8_t>(p.arg3), p.sockets, attrs);
break;
}
case EXCHANGE_GC_ITEM_DEL:
m_world.mut_exchange_item_del(self, static_cast<uint8_t>(p.arg1));
break;
@@ -1244,7 +1342,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(packet, header, body, len)) {
return false;
}
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port);
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port, true);
return true;
}
case HDR_GC_TIME: {
@@ -1400,9 +1498,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
default:
// not handled yet — harmless, keep going. classic_stream already
// rejected unknown *static* headers via the size table.
return true;
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
m_unhandled_headers.push_back(header);
return false;
}
}
@@ -8,6 +8,7 @@
// [header][uint16 size] prefix); `len` its length — i.e. exactly what
// ClassicStream::on_packet delivers.
#include "npc_names.h"
#include "wire_classic.h"
#include "../entity_store.h"
@@ -15,6 +16,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
namespace mtnet::classic {
@@ -27,6 +29,8 @@ struct CharSlot {
uint8_t st = 0, ht = 0, dx = 0, iq = 0;
uint32_t play_minutes = 0;
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
uint16_t main_part = 0, hair_part = 0;
uint8_t skill_group = 0;
// Server requires this character to be renamed before entering the game.
@@ -62,6 +66,23 @@ public:
const std::string &login_failure() const { return m_login_failure; }
uint32_t login_key() const { return m_login_key; } // GC_LOGIN_KEY (auth path)
uint8_t empire() const { return m_empire; }
void set_phase_name(const std::string &name) { m_phase_name = name; }
const std::string &last_error() const { return m_last_error; }
// §2.2: path to root/npclist.txt (race -> model-code table). Used to name
// monster / stone / object spawns, mirroring CPythonNonPlayer::GetName().
// Empty path -> names resolve to "" (permitted). Falls back to
// $MT_ASSETS / $M2_ASSETS when never set.
// TODO(W0): M2Client should call
// parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))
void set_npclist_path(std::string path);
// §2.2 test hook: number of GC_CHARACTER_ADD records awaiting their
// GC_CHAR_ADDITIONAL_INFO merge.
size_t pending_actor_count() const { return m_pending_actor.size(); }
std::vector<uint8_t> drain_unhandled_headers() {
auto v = std::move(m_unhandled_headers);
m_unhandled_headers.clear();
return v;
}
std::vector<CharEvent> drain_char_events() {
auto v = std::move(m_char_events);
m_char_events.clear();
@@ -91,8 +112,18 @@ private:
void parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle,
uint32_t rkey);
// Lazily loads npclist.txt on first use; returns "" for an unknown race or
// when no list is available.
const std::string &npc_name_for(uint16_t race);
EntityStore &m_world;
// §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD stages a bare Entity here
// keyed by vid; GC_CHAR_ADDITIONAL_INFO merges its fields and flushes the
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
std::unordered_map<uint32_t, Entity> m_pending_actor;
std::string m_npclist_path;
std::unordered_map<uint32_t, std::string> m_npc_names;
bool m_npc_names_loaded = false;
std::vector<CharSlot> m_slots;
int m_slot_count = 0;
uint32_t m_handle = 0;
@@ -104,6 +135,9 @@ private:
int m_guild_make_requests = 0;
bool m_char_list_ready = false;
std::string m_login_failure;
std::string m_phase_name = "Offline";
std::string m_last_error;
std::vector<uint8_t> m_unhandled_headers;
};
} // namespace mtnet::classic
+232 -39
View File
@@ -4,8 +4,25 @@
#include <cstring>
#include <vector>
#ifndef MT_BUILD_TIMESTAMP
// Injected by CMake (extension/CMakeLists.txt) in C __TIMESTAMP__ form
// ("Www Mmm dd hh:mm:ss yyyy"); §1.5 uses it as the non-EUROPE
// CG_CLIENT_VERSION timestamp. This fallback keeps a non-CMake build compiling.
#define MT_BUILD_TIMESTAMP __TIMESTAMP__
#endif
namespace mtnet::classic {
namespace {
std::string ipv4_from_slot_addr(int32_t addr) {
const uint32_t value = static_cast<uint32_t>(addr);
return std::to_string(value & 0xffu) + "." +
std::to_string((value >> 8) & 0xffu) + "." +
std::to_string((value >> 16) & 0xffu) + "." +
std::to_string((value >> 24) & 0xffu);
}
} // namespace
ClassicSession::ClassicSession() : m_parser(m_world) {
m_stream.on_phase = [this](uint8_t p) { on_phase(p); };
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
@@ -22,6 +39,9 @@ ClassicSession::ClassicSession() : m_parser(m_world) {
}
};
m_stream.on_state_change = [this](ClassicStream::State s) {
if (s == ClassicStream::State::Online && m_game_started) {
m_game_connection_seen = true;
}
if (s == ClassicStream::State::Offline && !m_auth_transition &&
m_stage != Stage::Failed &&
m_stage != Stage::Offline) {
@@ -61,6 +81,17 @@ void ClassicSession::set_stage(Stage s) {
return;
}
m_stage = s;
const char *phase = "Offline";
switch (s) {
case Stage::Connecting: phase = "HandShake"; break;
case Stage::LoggingIn: phase = "Login"; break;
case Stage::CharSelect: phase = "Select"; break;
case Stage::Loading: phase = "Loading"; break;
case Stage::InGame: phase = "Game"; break;
case Stage::Failed: phase = "Failed"; break;
case Stage::Offline: break;
}
m_parser.set_phase_name(phase);
if (on_stage_change) {
on_stage_change(s);
}
@@ -78,6 +109,10 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw)
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_game_connection_seen = false;
m_direct_enter = false;
m_direct_enter_slot = -1;
m_selected_slot = -1;
m_last_error.clear();
// During PHASE_HANDSHAKE even retry handshakes must be answered with
// 0xFF. The normal game client switches to CG_TIME_SYNC only after the
@@ -145,6 +180,82 @@ bool ClassicSession::connect_with_login_key(const std::string &game_host, uint16
return true;
}
bool ClassicSession::connect_direct_enter(int slot) {
if (slot < 0 || slot >= static_cast<int>(m_parser.char_slots().size())) {
m_last_error = "direct enter slot out of range";
return false;
}
const CharSlot &character = m_parser.char_slots()[slot];
if (character.id == 0 || character.addr == 0 || character.port == 0 ||
m_auth_login_key == 0) {
m_last_error = "direct enter slot has no game address or login key";
return false;
}
m_auth_transition = true;
m_stream.disconnect();
m_auth_transition = false;
m_use_auth = false;
m_auth_succeeded = true;
m_game_started = true;
m_authenticated_game = true;
m_direct_enter = true;
m_direct_enter_slot = slot;
m_selected_slot = slot;
m_game_host = ipv4_from_slot_addr(character.addr);
m_game_port = character.port;
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
const bool connected = m_stream.connect(m_game_host, m_game_port);
m_auth_transition = false;
if (!connected) {
m_last_error = m_stream.last_error();
set_stage(Stage::Failed);
return false;
}
set_stage(Stage::Connecting);
return true;
}
bool ClassicSession::connect_warp(const std::string &game_host, uint16_t game_port) {
if (m_selected_slot < 0 || m_auth_login_key == 0 || game_host.empty() || game_port == 0) {
m_last_error = "warp has no selected character, login key, or game address";
return false;
}
// Keep the same ClassicSession so the login ticket and the selected slot
// survive the reconnect. This is the important distinction from the old
// warp_to_game_server() path, which rebuilt the parser and lost DirectEnter.
m_auth_transition = true;
m_stream.disconnect();
m_auth_transition = false;
m_use_auth = false;
m_auth_succeeded = true;
m_game_started = true;
m_authenticated_game = true;
m_direct_enter = true;
m_direct_enter_slot = m_selected_slot;
m_game_host = game_host;
m_game_port = game_port;
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
const bool connected = m_stream.connect(m_game_host, m_game_port);
m_auth_transition = false;
if (!connected) {
m_last_error = m_stream.last_error();
set_stage(Stage::Failed);
return false;
}
set_stage(Stage::Connecting);
return true;
}
void ClassicSession::disconnect() {
m_auth_transition = true;
m_auth_stream.disconnect();
@@ -184,18 +295,25 @@ void ClassicSession::pump() {
// sending it too early makes the server drop us mid spawn-burst).
if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 &&
m_now - m_loading_since >= m_entergame_delay) {
enter_game();
if (!enter_game()) {
m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" :
m_stream.last_error();
set_stage(Stage::Failed);
}
}
// §3.2 — feed the server frame clock (from the GC_HANDSHAKE lDelta exchange)
// so EntityStore::drain_state_queue() gates queued StateCmds on server time
// instead of releasing immediately. 0 until the first handshake lands.
m_world.set_server_frame_ms(m_stream.server_frame_ms());
m_world.tick();
}
void ClassicSession::send_login() {
bool ClassicSession::send_login() {
if (m_login_sent) {
return;
return true;
}
if (m_authenticated_game) {
send_login_by_key();
return;
return send_login_by_key();
}
// docs §2.2: the client turns sequence mode on around here; CG_LOGIN is a
// bSeq packet and the server expects the trailing byte from packet #1.
@@ -205,13 +323,17 @@ void ClassicSession::send_login() {
p.header = HDR_CG_LOGIN;
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN";
return false;
}
m_login_sent = true;
return true;
}
void ClassicSession::send_auth_login() {
bool ClassicSession::send_auth_login() {
if (m_auth_login_sent) {
return;
return true;
}
m_auth_stream.set_sequence_mode(true);
CGLogin3 p{};
@@ -220,11 +342,15 @@ void ClassicSession::send_auth_login() {
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
// The stock connector sends four client keys here. Zero is valid for this
// client, provided the same values are used in the later CG_LOGIN2 packet.
m_auth_stream.send_fixed(&p, sizeof(p));
if (!m_auth_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN3";
return false;
}
m_auth_login_sent = true;
return true;
}
void ClassicSession::send_login_by_key() {
bool ClassicSession::send_login_by_key() {
// The game side must use the ticket returned by the auth server; sending
// CG_LOGIN here would hit the server's locale/version gate.
m_stream.set_sequence_mode(true);
@@ -232,14 +358,20 @@ void ClassicSession::send_login_by_key() {
p.header = HDR_CG_LOGIN2;
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
p.login_key = m_auth_login_key;
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
m_last_error = "failed to send CG_LOGIN2";
return false;
}
m_login_sent = true;
return true;
}
void ClassicSession::on_auth_phase(uint8_t phase) {
if (phase == PHASE_AUTH) {
set_stage(Stage::LoggingIn);
send_auth_login();
if (!send_auth_login()) {
set_stage(Stage::Failed);
}
} else if (phase == PHASE_CLOSE) {
m_last_error = "auth server closed phase";
set_stage(Stage::Failed);
@@ -276,8 +408,16 @@ bool ClassicSession::on_auth_packet(uint8_t header, const uint8_t *body, uint32_
set_stage(Stage::Failed);
return false;
}
// Optional Panama/hybrid-crypt packets are already framed and can be
// ignored by this asset-loader client.
if (header == HDR_GC_AUTH_SUCCESS_OPENID || header == HDR_GC_MATRIX_CARD ||
header == HDR_GC_RUNUP_MATRIX_QUIZ || header == HDR_GC_REQUEST_PASSPOD ||
header == HDR_GC_REQUEST_PASSPOD_FAILED || header == HDR_GC_HS_REQUEST ||
header == HDR_GC_XTRAP_CS1_REQUEST || header == HDR_GC_PANAMA_PACK ||
header == HDR_GC_HYBRIDCRYPT_KEYS || header == HDR_GC_HYBRIDCRYPT_SDB) {
m_last_error = "auth phase does not handle GC header " + std::to_string(header);
return false;
}
// Optional hybrid-crypt packets are deliberately unsupported as well; the
// stream still frames them, then surfaces the unsupported integration.
return true;
}
@@ -290,7 +430,8 @@ bool ClassicSession::create_character(int slot, const std::string &name, int job
CGPlayerCreate p{};
p.header = HDR_CG_CHARACTER_CREATE;
p.index = static_cast<uint8_t>(slot);
std::memcpy(p.name, name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
std::memcpy(p.name, wire_name.data(), wire_name.size());
p.job = static_cast<uint16_t>(job);
p.shape = static_cast<uint8_t>(shape);
p.con = static_cast<uint8_t>(con);
@@ -320,7 +461,8 @@ bool ClassicSession::change_name(int slot, const std::string &name) {
CGChangeName p{};
p.header = HDR_CG_CHANGE_NAME;
p.index = static_cast<uint8_t>(slot);
std::memcpy(p.name, name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
std::memcpy(p.name, wire_name.data(), wire_name.size());
return m_stream.send_fixed(&p, sizeof(p));
}
@@ -336,13 +478,31 @@ bool ClassicSession::send_client_version() {
if (m_version_sent) {
return true;
}
CGClientVersion p{};
// The stock 40250 game client sends the version report immediately after
// receiving the main-character packet and advances the normal CG sequence.
p.header = HDR_CG_CLIENT_VERSION;
std::strncpy(p.filename, "metin2.bin", sizeof(p.filename) - 1);
std::strncpy(p.timestamp, "1215955205", sizeof(p.timestamp) - 1);
if (!m_stream.send_fixed(&p, sizeof(p))) {
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) with the fixed
// ymir epoch string; every other locale sends CG_CLIENT_VERSION (0xfd) with
// the build timestamp. All fixed-length text fills go through
// mtnet::to_wire() (§1.10) — a hard byte cap on a code-point boundary.
bool ok = false;
if (m_locale_is_europe) {
CGClientVersion2 p{};
p.header = HDR_CG_CLIENT_VERSION2;
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
std::memcpy(p.filename, fn.data(), fn.size());
const std::string ts = mtnet::to_wire(std::string_view("1215955205"), sizeof(p.timestamp) - 1);
std::memcpy(p.timestamp, ts.data(), ts.size());
ok = m_stream.send_fixed(&p, sizeof(p));
} else {
CGClientVersion p{};
p.header = HDR_CG_CLIENT_VERSION;
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
std::memcpy(p.filename, fn.data(), fn.size());
const std::string ts = mtnet::to_wire(std::string_view(MT_BUILD_TIMESTAMP), sizeof(p.timestamp) - 1);
std::memcpy(p.timestamp, ts.data(), ts.size());
ok = m_stream.send_fixed(&p, sizeof(p));
}
if (!ok) {
return false;
}
m_version_sent = true;
@@ -357,16 +517,27 @@ void ClassicSession::on_phase(uint8_t phase) {
switch (phase) {
case PHASE_LOGIN:
set_stage(Stage::LoggingIn);
send_login();
if (!send_login()) {
set_stage(Stage::Failed);
}
break;
case PHASE_SELECT:
set_stage(Stage::CharSelect);
if (m_parser.char_list_ready() && on_char_list) {
if (m_direct_enter) {
set_stage(Stage::Loading);
if (!send_select_char(m_direct_enter_slot)) {
set_stage(Stage::Failed);
}
} else {
set_stage(Stage::CharSelect);
}
if (!m_direct_enter && m_parser.char_list_ready() && on_char_list) {
on_char_list(m_parser.char_slots());
}
break;
case PHASE_LOADING:
set_stage(Stage::Loading);
m_direct_enter = false;
m_direct_enter_slot = -1;
m_loading_since = m_now;
m_entergame_sent = false;
break;
@@ -387,12 +558,15 @@ void ClassicSession::on_phase(uint8_t phase) {
bool ClassicSession::on_packet(uint8_t header, const uint8_t *body, uint32_t len) {
if (!m_parser.on_gc(header, body, len)) {
m_last_error = "parse error on GC header " + std::to_string(header);
m_last_error = m_parser.last_error().empty() ?
"parse error on GC header " + std::to_string(header) : m_parser.last_error();
m_stream.set_last_error(m_last_error);
return false;
}
if ((header == HDR_GC_MAIN_CHARACTER || header == HDR_GC_MAIN_CHARACTER3_BGM ||
header == HDR_GC_MAIN_CHARACTER4_BGM_VOL) && !send_client_version()) {
m_last_error = "failed to send client version";
m_stream.set_last_error(m_last_error);
return false;
}
if (!m_parser.login_failure().empty() && m_stage == Stage::LoggingIn) {
@@ -409,14 +583,24 @@ bool ClassicSession::select_char(int slot) {
if (m_stage != Stage::CharSelect) {
return false;
}
if (slot < 0 || slot >= m_parser.slot_count()) {
return false;
}
if (!send_select_char(slot)) {
return false;
}
m_selected_slot = slot;
return true;
}
bool ClassicSession::send_select_char(int slot) {
if (slot < 0 || slot >= m_parser.slot_count()) {
return false;
}
CGPlayerSelect p{};
p.header = HDR_CG_CHARACTER_SELECT;
p.player_index = static_cast<uint8_t>(slot);
m_stream.send_fixed(&p, sizeof(p));
return true;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::enter_game() {
@@ -425,9 +609,11 @@ bool ClassicSession::enter_game() {
}
CGEnterGame p{};
p.header = HDR_CG_ENTERGAME;
m_stream.send_fixed(&p, sizeof(p));
if (!m_stream.send_fixed(&p, sizeof(p))) {
return false;
}
m_entergame_sent = true;
return true;
return m_stream.flush_internal();
}
// --- in-game intents ---------------------------------------------------------
@@ -653,7 +839,8 @@ bool ClassicSession::send_whisper(const std::string &to, const std::string &text
CGWhisper p{};
p.header = HDR_CG_WHISPER;
p.size = static_cast<uint16_t>(total);
std::memcpy(p.name_to, to.data(), to.size());
const std::string wire_to = mtnet::to_wire(to, sizeof(p.name_to) - 1); // §1.10
std::memcpy(p.name_to, wire_to.data(), wire_to.size());
std::memcpy(buf.data(), &p, sizeof(p));
std::memcpy(buf.data() + sizeof(p), text.data(), text.size());
return m_stream.send_dynamic(buf.data(), buf.size());
@@ -819,7 +1006,8 @@ bool ClassicSession::send_private_shop(const std::string &sign,
std::vector<uint8_t> buf(total, 0);
CGMyShopHead head{};
head.header = HDR_CG_MYSHOP;
std::memcpy(head.sign, sign.data(), sign.size());
const std::string wire_sign = mtnet::to_wire(sign, sizeof(head.sign) - 1); // §1.10
std::memcpy(head.sign, wire_sign.data(), wire_sign.size());
head.count = static_cast<uint8_t>(n);
std::memcpy(buf.data(), &head, sizeof(head));
if (n > 0) {
@@ -904,7 +1092,8 @@ bool ClassicSession::send_guild_grade_name(uint8_t grade, const std::string &nam
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_GRADE_NAME};
std::memcpy(buf, &head, sizeof(head));
buf[sizeof(head)] = grade;
std::memcpy(buf + sizeof(head) + 1, name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, GUILD_GRADE_NAME_MAX_LEN); // §1.10
std::memcpy(buf + sizeof(head) + 1, wire_name.data(), wire_name.size());
return m_stream.send_fixed(buf, sizeof(buf));
}
@@ -941,12 +1130,13 @@ bool ClassicSession::send_guild_member_general(uint32_t pid, bool enabled) {
bool ClassicSession::send_guild_comment(const std::string &text) {
if (m_stage != Stage::InGame || text.empty() ||
text.size() + 1 > GUILD_COMMENT_MAX_LEN) return false;
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + text.size() + 1, 0);
const std::string wire_text = mtnet::to_wire(text, GUILD_COMMENT_MAX_LEN - 1); // §1.10
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + wire_text.size() + 1, 0);
CGGuild head{HDR_CG_GUILD, GUILD_CG_POST_COMMENT};
std::memcpy(buf.data(), &head, sizeof(head));
buf[sizeof(head)] = static_cast<uint8_t>(text.size() + 1);
if (!text.empty()) {
std::memcpy(buf.data() + sizeof(head) + 1, text.data(), text.size());
buf[sizeof(head)] = static_cast<uint8_t>(wire_text.size() + 1);
if (!wire_text.empty()) {
std::memcpy(buf.data() + sizeof(head) + 1, wire_text.data(), wire_text.size());
}
return m_stream.send_fixed(buf.data(), buf.size());
}
@@ -965,7 +1155,8 @@ bool ClassicSession::send_guild_answer_make(const std::string &name) {
if (m_stage != Stage::InGame || name.empty() || name.size() > GUILD_NAME_MAX_LEN) return false;
CGAnswerMakeGuild p{};
p.header = HDR_CG_ANSWER_MAKE_GUILD;
std::memcpy(p.guild_name, name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, sizeof(p.guild_name) - 1); // §1.10
std::memcpy(p.guild_name, wire_name.data(), wire_name.size());
return m_stream.send_fixed(&p, sizeof(p));
}
@@ -1009,7 +1200,8 @@ bool ClassicSession::send_friend_add(const std::string &name) {
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_ADD_BY_NAME};
std::memcpy(buf.data(), &p, sizeof(p));
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
return m_stream.send_fixed(buf.data(), buf.size());
}
@@ -1020,7 +1212,8 @@ bool ClassicSession::send_friend_remove(const std::string &name) {
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_REMOVE};
std::memcpy(buf.data(), &p, sizeof(p));
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
return m_stream.send_fixed(buf.data(), buf.size());
}
+30 -3
View File
@@ -17,6 +17,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
namespace mtnet::classic {
@@ -37,6 +38,12 @@ public:
void disconnect() override;
bool select_char(int slot) override;
bool enter_game() override;
// ClientVS22 DirectEnter: reconnect to the slot's lAddr:wPort, then the
// loading phase sends CG_CHARACTER_SELECT on the new game connection.
bool connect_direct_enter(int slot);
// ClientVS22 GC_WARP: every warp is a new game connection to the address
// carried by the packet. The selected slot and login ticket are retained.
bool connect_warp(const std::string &game_host, uint16_t game_port);
bool create_character(int slot, const std::string &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const std::string &private_code);
@@ -143,6 +150,7 @@ public:
ClassicStream &auth_stream() { return m_auth_stream; } // auth-flow tests / trace
ClassicParser &parser() { return m_parser; }
uint32_t login_key() const { return m_auth_login_key; }
bool was_online_lost() const { return m_game_connection_seen; }
uint32_t now_ms() const { return m_now; }
void set_now(uint32_t ms) {
m_now = ms;
@@ -152,6 +160,18 @@ public:
// caller drives enter_game()). The 40250 test account sends a large map-load
// burst; waiting for that burst to drain avoids racing the server's spawn data.
void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; }
// §1.5: the client-version report (CG_CLIENT_VERSION / _VERSION2) carries the
// running executable's file name — the stock client passes the launched
// .exe/.bin name (CPythonApplication). The net layer has no way to know it,
// so it stays "metin2.bin" until a host sets it.
// TODO(W0): M2Client should call
// classic_sess->set_executable_name(OS::get_executable_path().get_file())
void set_executable_name(std::string name) { m_executable_name = std::move(name); }
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) + the fixed ymir
// epoch string "1215955205"; every other locale sends CG_CLIENT_VERSION
// (0xfd) + the build timestamp. This project's LOCALE_SERVICE_* resolves to
// EUROPE (see docs/CLIENT-GAP-FIX.md §1.5), hence the default.
void set_locale_is_europe(bool on) { m_locale_is_europe = on; }
void set_wire_trace(bool on) {
m_stream.set_wire_trace(on);
m_auth_stream.set_wire_trace(on);
@@ -163,10 +183,11 @@ private:
void on_auth_phase(uint8_t phase);
bool on_packet(uint8_t header, const uint8_t *body, uint32_t len);
bool on_auth_packet(uint8_t header, const uint8_t *body, uint32_t len);
void send_login();
void send_auth_login();
void send_login_by_key();
bool send_login();
bool send_auth_login();
bool send_login_by_key();
bool send_client_version();
bool send_select_char(int slot);
EntityStore m_world;
ClassicStream m_stream;
@@ -192,6 +213,12 @@ private:
bool m_authenticated_game = false;
bool m_auth_transition = false;
bool m_version_sent = false;
bool m_locale_is_europe = true;
std::string m_executable_name = "metin2.bin";
bool m_game_connection_seen = false;
bool m_direct_enter = false;
int m_direct_enter_slot = -1;
int m_selected_slot = -1;
};
} // namespace mtnet::classic
+37 -36
View File
@@ -29,16 +29,6 @@ void set_nonblocking(int fd) {
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
bool plausible_character_add(const uint8_t *p, size_t available) {
if (available < sizeof(GCCharacterAdd) || p[0] != HDR_GC_CHARACTER_ADD) {
return false;
}
GCCharacterAdd packet{};
std::memcpy(&packet, p, sizeof(packet));
return packet.vid != 0 && packet.race != 0 && packet.type <= 8 &&
std::isfinite(packet.angle) && std::fabs(packet.angle) <= 360.0f &&
packet.moving_speed <= 255 && packet.attack_speed <= 255;
}
} // namespace
ClassicStream::~ClassicStream() {
@@ -55,6 +45,16 @@ void ClassicStream::set_state(State s) {
}
}
uint32_t ClassicStream::server_frame_ms() const {
// §3.2 — mirror EterLib ELTimer_GetServerFrameMSec(): the handshake stored
// (m_server_time_base, m_client_time_base); the server clock advances with
// local wall time from there. 0 until the first handshake lands.
if (m_client_time_base == 0) {
return 0;
}
return now_ms() - m_client_time_base + m_server_time_base;
}
// --------------------------------------------------------------------- socket
bool ClassicStream::connect(const std::string &host, uint16_t port) {
disconnect();
@@ -114,6 +114,11 @@ void ClassicStream::discard_recv(size_t n) {
m_recv.discard(n);
}
void ClassicStream::remember_packet(uint8_t header) {
m_last_pkt[0] = m_last_pkt[1];
m_last_pkt[1] = header;
}
void ClassicStream::decrypt_appended(size_t n) {
if (n == 0 || !m_cipher.activated()) {
return;
@@ -209,6 +214,10 @@ size_t ClassicStream::take_outgoing(void *dst, size_t cap) {
return n;
}
bool ClassicStream::flush_internal() {
return m_sock < 0 || flush_send();
}
void ClassicStream::append_sequence_if_needed(uint8_t header) {
if (!m_seq_on || !is_sequence_cg(header)) {
return;
@@ -422,6 +431,7 @@ void ClassicStream::dispatch() {
return;
}
if (consumed) {
remember_packet(header);
continue;
}
// control handler said "need more bytes" for a control header it owns?
@@ -488,41 +498,25 @@ void ClassicStream::dispatch() {
bool ok = !on_packet || on_packet(header, body, body_len);
discard_recv(body_len);
if (!ok) {
if (m_last_error.empty()) {
m_last_error = "packet handler rejected GC header " + std::to_string(header);
}
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
remember_packet(header);
continue;
}
// --- static-size GC packet --- (packet_size_gc = FULL size incl. header)
int total = packet_size_gc(header);
if (total <= 0) {
m_last_error = "unknown/unsupported GC header " + std::to_string(header);
// Some 40250 deployments append one legacy entity packet variant that
// is not present in the checked-in header map. It has no length field,
// but the following normal GC_CHARACTER_ADD stream is self-describing.
// During PHASE_LOADING, recover only at a strongly validated character
// boundary; this keeps a deployment-specific spawn packet from aborting
// the whole login while leaving malformed encrypted data fatal elsewhere.
if (m_phase == PHASE_LOADING) {
const uint8_t *bytes = m_recv.read_ptr();
bool recovered = false;
for (size_t i = 1; i + sizeof(GCCharacterAdd) <= m_recv.readable(); ++i) {
if (!plausible_character_add(bytes + i, m_recv.readable() - i)) {
continue;
}
if (m_trace) {
std::fprintf(stderr, "[classic] recovering loading stream at +%zu\n", i);
}
discard_recv(i);
m_last_error.clear();
recovered = true;
break;
}
if (recovered) {
continue;
}
}
m_last_error = "unknown GC header " + std::to_string(header) +
" (last: " + std::to_string(m_last_pkt[0]) + "," +
std::to_string(m_last_pkt[1]) + ")";
if (on_error) {
on_error(m_last_error);
}
@@ -539,9 +533,16 @@ void ClassicStream::dispatch() {
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
discard_recv(static_cast<size_t>(total));
if (!ok) {
if (m_last_error.empty()) {
m_last_error = "packet handler rejected GC header " + std::to_string(header);
}
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
remember_packet(header);
}
}
@@ -66,6 +66,7 @@ public:
State state() const { return m_state; }
bool is_online() const { return m_state == State::Online; }
const std::string &last_error() const { return m_last_error; }
void set_last_error(const std::string &error) { m_last_error = error; }
// --- socket-free framing (tests / alternative transports) ---
void feed(const void *data, size_t n); // append inbound bytes, run dispatch
@@ -78,6 +79,8 @@ public:
bool send_fixed(const void *struct_bytes, size_t n);
// Caller supplies the full dynamic packet incl. [header][uint16 size][...].
bool send_dynamic(const void *bytes, size_t n);
// Flush immediately for a real socket. Socket-free tests retain queued bytes.
bool flush_internal();
// docs §2.2: turn on once the client would call net.SetPacketSequenceMode()
// (LOGIN phase onward). Off during pure HANDSHAKE.
@@ -96,6 +99,14 @@ public:
bool cipher_active() const { return m_cipher.activated(); }
void set_polarity(bool client) { m_polarity = client; }
// §3.2 — ELTimer_GetServerFrameMSec() equivalent. The GC_HANDSHAKE exchange
// (handle_control HDR_HANDSHAKE) stores m_server_time_base = hs.time + lDelta
// and m_client_time_base = local now; the server frame clock is then
// local_now - m_client_time_base + m_server_time_base. Returns 0 until the
// first handshake has landed (EntityStore treats 0 as "release immediately").
uint32_t server_frame_ms() const;
bool has_server_clock() const { return m_client_time_base != 0; }
private:
void set_state(State s);
bool recv_into_buffer();
@@ -125,10 +136,12 @@ private:
bool m_time_sync_mode = false;
bool m_trace = false;
uint8_t m_phase = PHASE_HANDSHAKE;
uint8_t m_last_pkt[2] = {0, 0};
// server clock offset from the last handshake exchange (informational).
uint32_t m_server_time_base = 0;
uint32_t m_client_time_base = 0;
void remember_packet(uint8_t header);
};
} // namespace mtnet::classic
+81
View File
@@ -0,0 +1,81 @@
#pragma once
// npc_names — race -> display name for non-PC/NPC spawns (§2.2 / §0.3).
//
// Reference: a monster / stone / door / object spawn is appended immediately
// with its name taken from CPythonNonPlayer::GetName(race)
// (REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp). The localized
// mob_proto ships translated szName strings that no longer resolve to a model
// folder, so the client keeps root/npclist.txt as the race -> code table
// (see project/ui/mob_view.gd, which loads the same file). We reuse that code
// as the entity name; PC/NPC names come from GC_CHAR_ADDITIONAL_INFO instead
// and never hit this path.
//
// Line format (tab- or space-separated): "<vnum>\t<code>\t[<folder>]".
// Missing / unreadable file -> empty table -> empty name (allowed by §2.2).
//
// mtnet has no godot-cpp, so this reads with std::ifstream. The asset root is
// not plumbed into ClassicParser today: set_npclist_path() lets a future
// M2Client pass "<asset_root>/root/npclist.txt"; absent that we fall back to
// $MT_ASSETS / $M2_ASSETS. See the cross-file follow-up in the W1 report.
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <string>
#include <unordered_map>
namespace mtnet::classic {
inline std::unordered_map<uint32_t, std::string> load_npclist(const std::string &path) {
std::unordered_map<uint32_t, std::string> table;
if (path.empty()) {
return table;
}
std::ifstream in(path);
if (!in) {
return table;
}
std::string line;
while (std::getline(in, line)) {
size_t i = 0;
const size_t n = line.size();
auto skip_ws = [&] {
while (i < n && (line[i] == ' ' || line[i] == '\t' || line[i] == '\r')) {
++i;
}
};
skip_ws();
size_t start = i;
while (i < n && std::isdigit(static_cast<unsigned char>(line[i]))) {
++i;
}
if (i == start) {
continue; // no leading vnum
}
const uint32_t vnum = static_cast<uint32_t>(std::strtoul(line.c_str() + start, nullptr, 10));
skip_ws();
start = i;
while (i < n && line[i] != ' ' && line[i] != '\t' && line[i] != '\r') {
++i;
}
const std::string code = line.substr(start, i - start);
if (vnum != 0 && !code.empty()) {
table.emplace(vnum, code);
}
}
return table;
}
// Resolve the default npclist.txt location from the environment when no
// explicit path was supplied.
inline std::string default_npclist_path() {
for (const char *var : {"MT_ASSETS", "M2_ASSETS"}) {
if (const char *root = std::getenv(var); root && *root) {
return std::string(root) + "/root/npclist.txt";
}
}
return {};
}
} // namespace mtnet::classic
+40 -2
View File
@@ -23,6 +23,11 @@
#include <cstddef>
#include <cstdint>
// §1.10: every fixed-length `char name[...]` / sign / comment field below is
// filled at the call site via mtnet::to_wire(src, cap) (byte cap, no split
// multibyte char) and read back via mtnet::from_wire_str().
#include "../text_codec.h"
namespace mtnet::classic {
// ------------------------------------------------------------------ constants
@@ -52,6 +57,28 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
inline constexpr int GUILD_MARK_HEIGHT = 12;
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
// (client GameType.h / server char.h). Values line up with the Entity comment.
enum : uint8_t {
CHRTYPE_PC = 0,
CHRTYPE_NPC = 1,
CHRTYPE_MONSTER = 2,
CHRTYPE_STONE = 3,
CHRTYPE_WARP = 4,
CHRTYPE_DOOR = 5,
CHRTYPE_BUILDING = 6,
CHRTYPE_PET = 7,
CHRTYPE_HORSE = 8,
CHRTYPE_GOTO = 9,
};
// Races the reference client never spawns — REF/UserInterface/
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
// for one of these is dropped before any pending/stash step.
inline constexpr bool is_invisible_race(uint16_t race) {
return race == 20025 || race == 20038 || race == 20039;
}
// EPacketShopSubHeaders (client Packet.h L1864)
enum : uint8_t {
SHOP_SUB_START = 0,
@@ -466,6 +493,17 @@ struct CGClientVersion {
};
static_assert(sizeof(CGClientVersion) == 67);
// CG_CLIENT_VERSION2 (header 0xf1) — TPacketCGClientVersion2. Same layout as
// CGClientVersion; the EUROPE / non-YMIR locale sends THIS one with a fixed
// timestamp "1215955205" (REF/.../PythonNetworkStreamPhaseGame.cpp:4155,
// Locale.cpp:186). The 0xfd variant carries the C __TIMESTAMP__ instead.
struct CGClientVersion2 {
uint8_t header;
char filename[33];
char timestamp[33];
};
static_assert(sizeof(CGClientVersion2) == 67);
struct GCPlayerCreateSuccess { uint8_t header; uint8_t account_slot; SimplePlayer player; };
static_assert(sizeof(GCPlayerCreateSuccess) == 2 + 63);
struct GCPlayerCreateFailure { uint8_t header; uint8_t type; };
@@ -1221,8 +1259,8 @@ constexpr int packet_size_cg(uint8_t h) {
case HDR_CG_CHARACTER_DELETE: return sizeof(CGPlayerDelete);
case HDR_CG_CHANGE_NAME: return sizeof(CGChangeName);
case HDR_CG_EMPIRE: return sizeof(CGEmpire);
case HDR_CG_CLIENT_VERSION:
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion);
case HDR_CG_CLIENT_VERSION: return sizeof(CGClientVersion);
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);
case HDR_CG_ENTERGAME: return sizeof(CGEnterGame);
case HDR_CG_MOVE: return sizeof(CGMove);
case HDR_CG_ATTACK: return sizeof(CGAttack);
+421 -68
View File
@@ -1,5 +1,9 @@
#include "entity_store.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <utility>
@@ -29,6 +33,17 @@ constexpr uint16_t MOTION_CHEERUP = 347;
constexpr uint16_t MOTION_BANTER = 348;
constexpr uint16_t MOTION_JOY = 349;
// CLIENT-GAP §2.1/§2.2: the data-layer mutators never create an entity except
// through mut_spawn / mut_spawn_full / mut_spawn_main. Every other mut_* that is
// handed an unknown VID logs (when MT_NET_TRACE is set) and returns instead of
// silently spawning a half-populated row or buffering a replay.
void dbg_ignored(const char *what, uint32_t id) {
static const bool trace = std::getenv("MT_NET_TRACE") != nullptr;
if (trace) {
std::fprintf(stderr, "[entity_store] %s: unknown VID %u -> ignored\n", what, id);
}
}
} // namespace
Entity &EntityStore::touch(uint32_t vid, bool &created) {
@@ -51,36 +66,19 @@ void EntityStore::mark_vitals(uint32_t vid) {
m_vitals.push_back(vid);
}
void EntityStore::start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms,
uint8_t func) {
e.func = func;
if (func == FUNC_MOVE && dur_ms > 0) {
e.moving = true;
e.sx = e.x;
e.sy = e.y;
e.tx = tx;
e.ty = ty;
e.move_start_ms = start_ms;
e.move_dur_ms = dur_ms;
} else {
// non-move func (attack / wait / skill / instant): snap to the target
e.moving = false;
e.x = tx;
e.y = ty;
}
}
// --- protocol-neutral mutation API (m2dev apply() + classic parser share it) ---
void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
float x, float y, float z, float angle, uint16_t moving_speed, uint8_t attack_speed) {
bool created;
Entity &e = touch(vid, created);
// CLIENT-GAP §2.3: a plain character-add is just a full spawn with the
// remaining fields left at their defaults. Route it through mut_spawn_full so
// there is exactly one "whole row replace" code path and VID reuse always
// resets transient state (dead / stun / mount / guild / affects / move).
Entity e{};
e.vid = vid;
e.race = race;
e.ch_type = ch_type;
if (!name.empty()) {
e.name = name;
}
e.name = name;
e.x = x;
e.y = y;
e.z = z;
@@ -88,7 +86,7 @@ void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const
e.moving_speed = moving_speed;
e.attack_speed = attack_speed;
e.moving = false;
m_changes.push_back({ChangeKind::Spawn, vid});
mut_spawn_full(e);
}
void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x,
@@ -111,6 +109,66 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
}
}
void EntityStore::mut_spawn_full(const Entity &src) {
// CLIENT-GAP §2.3 "one overwrite rule": a spawn replaces the whole row keyed
// by vid. The reference (SNetworkActorData::__copy__) copies every field from
// the incoming data on VID reuse and does NOT preserve a prior in-progress
// move — so we do a clean value replace here. Transient state (dead, stun,
// mount, guild, affect_flags, func/move) is whatever `src` carries, which for
// a fresh Entity{} is the default. Copy into a local first to stay safe if a
// caller ever passes `store.get(vid)` back in.
Entity e = src;
e.vid = src.vid;
m_ents[src.vid] = e;
if (e.is_main) {
m_main_vid = src.vid;
}
m_changes.push_back({ChangeKind::Spawn, src.vid});
}
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
if (it->second.owner_vid != owner_vid) {
it->second.owner_vid = owner_vid;
m_dirty.push_back(vid);
}
}
void EntityStore::mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi) {
// CLIENT-GAP §2.5: GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE carry the affect
// bitset as two 32-bit words (client CopyData(0,4,&flag[0]) +
// CopyData(32,4,&flag[1])); re-assemble as lo | (hi << 32). No-op for an
// unknown VID — the bitset lives on the entity row only.
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
dbg_ignored("mut_affect_flags", vid);
return;
}
const uint64_t v = (uint64_t)lo | ((uint64_t)hi << 32);
if (it->second.affect_flags != v) {
it->second.affect_flags = v;
m_dirty.push_back(vid);
}
}
void EntityStore::mut_map_bgm(const std::string &name, float volume) {
bool changed = false;
if (name != m_bgm_name) {
m_bgm_name = name;
changed = true;
}
if (volume >= 0.0f && volume != m_bgm_volume) {
m_bgm_volume = volume;
changed = true;
}
if (changed) {
m_bgm_dirty = true;
}
}
void EntityStore::mut_despawn(uint32_t vid) {
if (m_ents.erase(vid)) {
m_changes.push_back({ChangeKind::Despawn, vid});
@@ -120,23 +178,169 @@ void EntityStore::mut_despawn(uint32_t vid) {
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
// StateCmd to the entity's TCP state queue (reference CInstanceBase::PushTCPState)
// and then drains anything already due, so a command whose chk_time is in the
// past (or 0) still applies synchronously.
void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms) {
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
Entity &e = it->second;
e.angle = angle_deg;
start_move(e, tx, ty, m_now, dur_ms, func);
e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
drain_state_queue(e);
m_changes.push_back({ChangeKind::Move, vid});
}
// ~ CInstanceBase::__CanProcessNetworkStatePacket: a dead or knocked-down actor
// holds its queue (the pending commands stay, they just don't fire yet).
bool EntityStore::can_process_network_state(const Entity &e) {
return !e.dead && !e.knock_down;
}
// ~ CInstanceBase::__IsEnableTCPProcess: the reference blocks TCP processing
// during an un-cancellable emote. We have no emote-lock state on the row yet, so
// this is always true for now (documented deferral, §3.2).
bool EntityStore::is_enable_tcp_process(const Entity &, uint8_t) {
return true;
}
// Kick off a walk toward the command's Dst (reference NEW_SetSrcPixelPosition /
// NEW_SetDstPixelPosition + StartWalking). Records the "after arrival" action and
// enables skip-collision for the duration of the walk.
void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dist = std::sqrt(dx * dx + dy * dy);
// The reference data layer (SNetworkActorData::UpdatePosition) interpolates
// against the server-supplied duration; only when the server omits it do we
// derive one from distance / move speed.
uint32_t dur = c.dur_ms;
if (dur == 0) {
const float speed = e.moving_speed > 0 ? (float)e.moving_speed : 150.0f;
dur = speed > 0.0f ? (uint32_t)(dist / speed * 1000.0f) : 0;
}
if (dur == 0) {
dur = 1; // never a zero-length walk; tick() finishes it next frame
}
e.angle = c.rot;
e.moving = true;
e.func = FUNC_MOVE;
e.sx = e.x;
e.sy = e.y;
e.tx = c.dst_x;
e.ty = c.dst_y;
e.move_start_ms = m_now;
e.move_dur_ms = dur;
e.mov_after_func = after_func;
e.mov_after_arg = c.arg;
e.mov_after_rot = c.rot;
e.skip_collision = true;
}
// The reference eFunc switch (InstanceBase.cpp StateProcess), with its two hard
// thresholds: 1.0 for FUNC_WAIT (walk vs. snap-align) and 50.0 for the attack
// funcs (walk-then-act vs. snap-and-act). No lerp, no jitter buffer.
void EntityStore::apply_state_cmd(Entity &e, const StateCmd &c) {
const float dx = c.dst_x - e.x;
const float dy = c.dst_y - e.y;
const float dir_len = std::sqrt(dx * dx + dy * dy);
switch (c.func) {
case FUNC_WAIT:
if (dir_len > 1.0f) {
begin_state_walk(e, c, FUNC_WAIT);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = FUNC_WAIT;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
case FUNC_MOVE:
begin_state_walk(e, c, FUNC_MOVE);
break;
case FUNC_COMBO:
case FUNC_ATTACK:
case FUNC_MOB_SKILL:
if (dir_len >= 50.0f) {
begin_state_walk(e, c, c.func);
} else {
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
}
break;
default:
// FUNC_EMOTION / FUNC_SKILL|idx and anything else: snap + set func,
// same as the pre-§3.2 non-move path.
e.moving = false;
e.x = c.dst_x;
e.y = c.dst_y;
e.angle = c.rot;
e.func = c.func;
e.mov_after_func = FUNC_WAIT;
e.skip_collision = false;
break;
}
}
// Release the due prefix of one entity's queue (reference StateProcess while-loop).
void EntityStore::drain_state_queue(Entity &e) {
while (!e.state_queue.empty()) {
const StateCmd &front = e.state_queue.front();
// server-frame gate (reference: ELTimer_GetServerFrameMSec() < m_dwChkTime):
// only when the clock is aligned (non-zero). STATE_QUEUE_MAX_WAIT_MS is a
// staleness valve — a chk_time further ahead than that means the server's
// dwTime is not in our frame-clock timebase (skew / re-stamp mismatch), so
// release now rather than freeze the actor. Real-server dwTime semantics
// still pending validation (§3.2).
if (m_server_frame_ms != 0 && front.chk_time > m_server_frame_ms &&
front.chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS) {
break;
}
if (!can_process_network_state(e)) {
break;
}
if (!is_enable_tcp_process(e, front.func)) {
break;
}
StateCmd cmd = front;
e.state_queue.pop_front();
apply_state_cmd(e, cmd);
}
}
void EntityStore::process_states() {
for (auto &kv : m_ents) {
drain_state_queue(kv.second);
}
}
void EntityStore::mut_set_points(const int32_t *pts, int n) {
if (n > 256) {
n = 256;
// CLIENT-GAP §2.4: GC_CHARACTER_POINTS is a full snapshot. The wire array is
// exactly POINT_MAX_NUM (255) ints; clamp a longer count and zero the tail of
// a shorter one so a stale value can never survive under a fresh snapshot.
// (W1 owns wire_classic.h; GCPoints there already carries
// static_assert(sizeof(GCPoints) == 1 + 255 * 4), i.e. points[POINT_MAX_NUM].)
if (!pts || n <= 0) {
return;
}
if (n > POINT_MAX_NUM) {
n = POINT_MAX_NUM;
}
std::memcpy(m_points.v, pts, sizeof(int32_t) * (size_t)n);
for (int i = n; i < POINT_MAX_NUM; ++i) {
m_points.v[i] = 0;
}
m_current_stamina = (uint32_t)std::max(0, m_points.stamina());
m_stamina_current_f = (float)m_current_stamina;
m_points_dirty = true;
@@ -158,6 +362,12 @@ void EntityStore::mut_set_points(const int32_t *pts, int n) {
}
void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
// CLIENT-GAP §2.4: a single-index point change must stay inside the array.
// Valid indices are 0..POINT_MAX_NUM-1 (0..254).
if (type >= POINT_MAX_NUM) {
dbg_ignored("mut_set_point index out of range", type);
return;
}
if (vid == m_main_vid) {
m_points.v[type] = value;
if (type == POINT_STAMINA) {
@@ -286,9 +496,14 @@ void EntityStore::mut_char_update(uint32_t vid, const uint16_t *parts, uint8_t m
e.attack_speed = attack_speed;
changed = true;
}
e.guild = guild_id;
e.alignment = alignment;
e.pk_mode = pk_mode;
// CLIENT-GAP §2.5: guild / alignment / pk-mode are part of the node refresh
// apply order, so a change to any of them must re-mirror the entity too.
if (e.guild != guild_id || e.alignment != alignment || e.pk_mode != pk_mode) {
e.guild = guild_id;
e.alignment = alignment;
e.pk_mode = pk_mode;
changed = true;
}
if (e.mount_vnum != mount_vnum) {
e.mount_vnum = mount_vnum;
m_mount_changes.push_back(vid);
@@ -346,6 +561,18 @@ void EntityStore::mut_stun(uint32_t vid) {
mark_vitals(vid);
}
void EntityStore::mut_knockdown(uint32_t vid, bool on) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
return;
}
if (it->second.knock_down == on) {
return;
}
it->second.knock_down = on;
m_changes.push_back({ChangeKind::Info, vid});
}
void EntityStore::mut_dead(uint32_t vid) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
@@ -376,13 +603,17 @@ void EntityStore::mut_damage(uint32_t vid, uint8_t flag, int32_t amount) {
}
void EntityStore::mut_mount(uint32_t vid, uint32_t mount_vnum) {
bool created = false;
Entity &e = touch(vid, created);
e.mount_vnum = mount_vnum;
m_mount_changes.push_back(vid);
if (!created) {
m_changes.push_back({ChangeKind::Info, vid});
// CLIENT-GAP §2.1: mount toggles never create an entity. A GC_MOUNT for a VID
// we have not spawned is dropped (the server always sends the character-add
// first).
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
dbg_ignored("mut_mount", vid);
return;
}
it->second.mount_vnum = mount_vnum;
m_mount_changes.push_back(vid);
m_changes.push_back({ChangeKind::Info, vid});
}
void EntityStore::mut_fly(uint8_t type, uint32_t start_vid, uint32_t end_vid) {
@@ -458,8 +689,15 @@ void EntityStore::mut_view_equipment(uint32_t vid, const Item *items, int n) {
void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uint16_t *parts,
uint8_t empire, int32_t guild_id, int32_t level, int16_t alignment, uint8_t pk_mode,
uint32_t mount_vnum) {
bool created;
Entity &e = touch(vid, created);
// CLIENT-GAP §2.2: GC_CHAR_ADDITIONAL_INFO decorates an entity the spawn
// packet already created. An unknown VID means we missed (or already culled)
// the add — drop it instead of touch()-ing a nameless placeholder into being.
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
dbg_ignored("mut_char_info", vid);
return;
}
Entity &e = it->second;
if (!name.empty()) {
e.name = name;
}
@@ -474,25 +712,19 @@ void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uin
e.mount_vnum = mount_vnum;
m_mount_changes.push_back(vid);
}
(void)empire;
if (!created) {
m_changes.push_back({ChangeKind::Info, vid});
if (empire) {
e.empire = empire;
}
m_changes.push_back({ChangeKind::Info, vid});
}
void EntityStore::mut_shop_sign(uint32_t vid, const std::string &sign) {
// CLIENT-GAP §2.2: the sign is a field on the entity that owns the private
// shop. An unknown VID never spawns a bare row here — the character-add
// packet is the only thing that creates entities. Drop it and log.
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
// A non-empty sign can legally race the character-add packet during a
// shop broadcast. Keep it instead of silently dropping the server state;
// the later character-add packet fills in the rest of this entity.
if (sign.empty()) {
return;
}
bool created = false;
Entity &e = touch(vid, created);
e.shop_sign = sign;
m_changes.push_back({ChangeKind::Spawn, vid});
dbg_ignored("mut_shop_sign", vid);
return;
}
if (it->second.shop_sign == sign) {
@@ -685,8 +917,108 @@ void EntityStore::mut_quest_info(uint16_t index, uint8_t flag, bool begin, const
m_quest_changes.push_back(index);
}
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port) {
m_warps.push_back({x, y, addr, port});
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port, bool reconnect) {
m_warps.push_back({x, y, addr, port, reconnect});
}
void EntityStore::reset_for_map_change() {
// CPythonNetworkStream::SetLoadingPhase() calls CPythonPlayer::Clear() and
// destroys the network actor manager before the next connection starts.
// Clear queued deltas as well: they describe the old connection and must not
// be replayed after the new GC_MAIN_CHARACTER arrives.
m_ents.clear();
m_main_vid = 0;
m_changes.clear();
m_dirty.clear();
m_vitals.clear();
m_damage.clear();
m_motions.clear();
m_dig_motions.clear();
m_fishing_events.clear();
m_dungeon_events.clear();
m_view_equipment.clear();
m_mount_changes.clear();
m_ground.clear();
m_ground_changes.clear();
m_pvp.clear();
m_pvp_changes.clear();
m_duel_opponents.clear();
m_duel_started = false;
m_duel_cannot_attack = false;
m_observers.clear();
m_observer_events.clear();
m_npc_marks.clear();
m_npc_marks_dirty = true;
m_land_areas.clear();
m_land_dirty = true;
m_markers.clear();
m_markers_dirty = true;
// CPythonPlayer::Clear() resets local combat/UI state. Inventory is kept,
// like the reference client: a warp does not make the server-side items
// disappear while the new loading packets are arriving.
m_points = PlayerPoints{};
m_points_dirty = true;
m_target_vid = 0;
m_target_hp_pct = 0;
m_target_dirty = true;
std::memset(m_skills, 0, sizeof(m_skills));
std::memset(m_skill_master, 0, sizeof(m_skill_master));
m_skill_group = 0;
m_skill_group_dirty = true;
m_skills_dirty = true;
std::fill(std::begin(m_quickslots), std::end(m_quickslots), QuickSlot{});
m_quickslots_dirty = true;
m_cooldown_ends.clear();
m_affects.clear();
m_affect_changes.clear();
m_stamina_consuming = false;
m_stamina_per_sec = 0;
m_current_stamina = 0;
m_stamina_current_f = 0.0f;
m_stamina_last_ms = 0;
m_observer_mode = false;
m_observer_count = 0;
m_mobile_flag = false;
m_combo_skill_flag = false;
// Loading must not leave an old modal transaction or one-shot event alive.
m_shop_open = false;
m_shop_vid = 0;
m_shop_items.clear();
m_shop_tabs.clear();
m_shop_selling_price = 0;
m_shop_errors.clear();
m_shop_dirty = true;
m_exchange = ExchangeState{};
m_exchange_dirty = true;
for (Item &item : m_safebox) {
item = Item{};
}
m_safebox_open = false;
m_safebox_size = 0;
m_safebox_gold = 0;
m_safebox_dirty = true;
for (Item &item : m_mall) {
item = Item{};
}
m_mall_open = false;
m_mall_size = 0;
m_mall_dirty = true;
m_cube = CubeState{};
m_cube_events.clear();
m_cube_last_success = CubeResultEntry{};
m_server_commands.clear();
m_scripts.clear();
m_confirms.clear();
m_item_events.clear();
m_warps.clear();
m_refine_cues.clear();
m_ds_cues.clear();
for (Item &item : m_dragon_soul) {
item = Item{};
}
}
void EntityStore::mut_server_time(int64_t time) {
@@ -1043,13 +1375,20 @@ void EntityStore::mut_exchange_start(uint32_t partner_vid) {
m_exchange_dirty = true;
}
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count) {
void EntityStore::mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets, const ItemAttr *attrs) {
if (slot >= 12) {
return;
}
ExchangeSlot &item = self ? m_exchange.self_items[slot] : m_exchange.peer_items[slot];
item.vnum = vnum;
item.count = count;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
item.sockets[i] = sockets ? sockets[i] : 0;
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
item.attrs[i] = attrs ? attrs[i] : ItemAttr{};
}
m_exchange_dirty = true;
}
@@ -1312,16 +1651,10 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCMove p;
std::memcpy(&p, body, sizeof(p));
auto it = m_ents.find(p.vid);
if (it == m_ents.end()) {
return; // move for an unknown entity
}
Entity &e = it->second;
e.angle = (float)p.rot * (360.0f / 256.0f);
// server sends absolute start time + duration; interpolate against m_now
uint32_t dur = p.duration;
start_move(e, (float)p.x, (float)p.y, m_now, dur, p.func);
m_changes.push_back({ChangeKind::Move, p.vid});
// CLIENT-GAP §3.2: same TCP state queue as the classic backend.
// m2dev heading is rot*360/256.
mut_move(p.vid, (float)p.rot * (360.0f / 256.0f), p.func, (float)p.x, (float)p.y,
p.duration, p.arg, p.time);
return;
}
case GC_CHARACTER_POSITION: {
@@ -2240,6 +2573,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
if (s) {
s->vnum = p.arg1;
s->count = (uint8_t)p.arg3;
for (int i = 0; i < ITEM_SOCKET_SLOT_MAX_NUM; ++i) {
s->sockets[i] = p.sockets[i];
}
for (int i = 0; i < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++i) {
s->attrs[i] = p.attrs[i];
}
}
break;
}
@@ -3147,6 +3486,9 @@ void EntityStore::tick() {
m_points_dirty = true;
}
}
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
// advance the walk. A queued command released here starts its walk this tick.
process_states();
for (auto &kv : m_ents) {
Entity &e = kv.second;
if (!e.moving) {
@@ -3162,7 +3504,18 @@ void EntityStore::tick() {
e.x = e.tx;
e.y = e.ty;
e.moving = false;
e.func = FUNC_WAIT;
e.skip_collision = false;
// reference m_kMovAfterFunc: once the walk reaches Dst, run the
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
// otherwise settle to WAIT.
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
e.angle = e.mov_after_rot;
} else {
e.func = FUNC_WAIT;
}
e.mov_after_func = FUNC_WAIT;
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
+118 -8
View File
@@ -11,6 +11,7 @@
#include "wire.h"
#include <cstdint>
#include <deque>
#include <string>
#include <unordered_map>
#include <vector>
@@ -27,6 +28,29 @@ enum : uint8_t {
FUNC_SKILL = 0x80,
};
// CLIENT-GAP §3.2: one entry of a remote actor's TCP state queue, mirroring the
// reference CInstanceBase::PushTCPState / StateProcess model. `chk_time` is the
// server command time (TPacketGCMove::dwTime); the entry is released once the
// store's server-frame clock reaches it. `arg` carries the motion index for the
// FUNC_COMBO / FUNC_SKILL cases.
struct StateCmd {
uint32_t chk_time = 0;
float dst_x = 0, dst_y = 0;
float rot = 0;
uint8_t func = FUNC_WAIT;
uint8_t arg = 0;
uint32_t dur_ms = 0; // server-supplied walk duration; 0 = derive from distance/speed
};
// Staleness valve for the server-frame gate. The reference StateProcess has no
// such cap, but our server-frame clock is not yet validated against a live
// 40250 server (§3.2): if chk_time is stamped in a timebase we don't share, a
// naive "wait until frame >= chk_time" would freeze the remote actor forever.
// A chk_time more than this far ahead of the store's frame clock is treated as
// out-of-band and released immediately. Deliberate deviation, documented in
// CLIENT-GAP-FIX.md §3.2.
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
@@ -51,27 +75,46 @@ struct Entity {
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
// actor-vs-actor collision until it arrives.
std::deque<StateCmd> state_queue;
uint8_t mov_after_func = FUNC_WAIT;
uint8_t mov_after_arg = 0;
float mov_after_rot = 0;
bool skip_collision = false;
// combat / status (0 = unknown until the server sends it)
uint8_t attack_speed = 0; // GC_CHARACTER_ADD[2]/UPDATE bAttackSpeed (x100)
int32_t hp = 0, max_hp = 0;
int32_t sp = 0, max_sp = 0;
int32_t level = 0;
bool dead = false;
bool stunned = false;
bool stunned = false; // server AFFECT_STUN (InstanceBaseEffect.cpp:932)
bool knock_down = false; // knockback motion is playing (CActorInstance::IsKnockDown)
uint32_t mount_vnum = 0; // 0 = on foot (GC_MOUNT / GC_CHAR_ADD_INFO)
int32_t guild = 0;
int16_t alignment = 0;
uint8_t pk_mode = 0;
// --- W0 interface freeze (CLIENT-GAP §2.3/§2.5) ---
uint8_t empire = 0; // 1 Shinsoo, 2 Chunjo, 3 Jinno (GC_CHAR_ADDITIONAL_INFO)
uint64_t affect_flags = 0; // AFF_* bitset carried on the entity (buff icons / aura)
uint32_t owner_vid = 0; // summon / horse owner; 0 = none
uint32_t state_flags = 0; // presentation-layer latch bits (W2 defines the enum)
};
// --- P9 world systems ----------------------------------------------------
// GC_WARP — teleport target. same_server() -> just move the player; otherwise
// the caller must reconnect to addr:port (P10).
// GC_WARP — teleport target. The m2dev protocol uses addr==0 as an in-place
// warp; the 40250 classic protocol always reconnects, including when lAddr is
// zero. `reconnect` records that protocol-specific distinction at parse time.
struct WarpCue {
int32_t x = 0, y = 0;
int32_t addr = 0;
uint16_t port = 0;
bool same_server() const { return addr == 0; }
bool reconnect = false;
bool same_server() const { return !reconnect && addr == 0; }
};
// One atlas/minimap NPC entry (GC_NPC_POSITION).
struct NPCMark {
@@ -254,6 +297,8 @@ struct ShopTab {
struct ExchangeSlot {
uint32_t vnum = 0;
uint8_t count = 0;
int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
};
struct ExchangeState {
bool active = false;
@@ -450,6 +495,16 @@ public:
};
void set_now(uint32_t now_ms) { m_now = now_ms; }
// CLIENT-GAP §3.2: the server-frame clock the TCP state queue releases against
// (reference ELTimer_GetServerFrameMSec). 0 = "not clock-aligned yet" — while
// it is 0 every queued command is released immediately (degrades to the old
// apply-on-arrival behaviour until the handshake lDelta clock is wired, §3.2/5).
void set_server_frame_ms(uint32_t ms) { m_server_frame_ms = ms; }
uint32_t server_frame_ms() const { return m_server_frame_ms; }
// Release every due entry of every entity's TCP state queue. Called from tick()
// and again right after mut_move() enqueues, so an already-due command applies
// synchronously.
void process_states();
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
@@ -466,11 +521,29 @@ public:
uint8_t attack_speed = 0);
void mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, float y,
float z);
// Full spawn (CLIENT-GAP §2.3): merge every field of `e` into the world in one
// shot, keyed by e.vid, so a two-packet PC/NPC add (GC_CHARACTER_ADD +
// GC_CHAR_ADDITIONAL_INFO) lands as a single Spawn change with name/parts/
// empire/affect_flags/owner already populated. Creates the entity if new.
void mut_spawn_full(const Entity &e);
// summon / horse ownership (CLIENT-GAP §2.5); no-op if vid unknown.
void mut_ownership(uint32_t vid, uint32_t owner_vid);
// Affect bitset from GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE, delivered as
// the two 32-bit wire words (CLIENT-GAP §2.5: v = lo | (hi << 32)). Queues a
// dirty tick so net_world re-mirrors the node; no-op if vid unknown.
void mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi);
// Map background music (CLIENT-GAP §9.1). name is the resolved track id or
// path; volume < 0 means "server did not specify, keep current".
void mut_map_bgm(const std::string &name, float volume);
void mut_despawn(uint32_t vid);
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
// CLIENT-GAP §3.2: enqueues a StateCmd on the entity's TCP state queue rather
// than mutating the position directly, then drains anything already due.
// `arg` is the motion index (FUNC_COMBO / FUNC_SKILL); `chk_time` is the
// server command time (0 = release immediately).
void mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms);
uint32_t dur_ms, uint8_t arg = 0, uint32_t chk_time = 0);
// Full stat block (index = POINT_*). `n` <= 256.
void mut_set_points(const int32_t *pts, int n);
// One stat delta (GC_CHARACTER_POINT_CHANGE equivalent).
@@ -501,6 +574,9 @@ public:
void mut_change_speed(uint32_t vid, uint16_t moving_speed);
void mut_walk_mode(uint32_t vid, uint8_t mode);
void mut_stun(uint32_t vid);
// CLIENT-GAP §3.7 — knockback-motion latch (client-only, animation-bound).
// Set true when a knockdown .msa starts, false when it ends; never from a timer.
void mut_knockdown(uint32_t vid, bool on);
void mut_dead(uint32_t vid);
void mut_motion(uint32_t vid, uint32_t victim_vid, uint16_t motion);
void mut_target(uint32_t vid, uint8_t hp_pct);
@@ -554,7 +630,12 @@ public:
int32_t counter_value, const std::string &icon);
// --- P9 world systems ---
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port);
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port,
bool reconnect = false);
// Clear the loading-phase state that ClientVS22 resets before a new map
// connection. Persistent account data (friends, guild, quests, inventory)
// is retained; entities and map-local/transient state are discarded.
void reset_for_map_change();
void mut_server_time(int64_t time);
void mut_channel(uint8_t channel);
void mut_npc_marks(const std::vector<NPCMark> &marks);
@@ -586,7 +667,10 @@ public:
// or deny a pending friend request.
void mut_friend_invite(const std::string &name);
void mut_exchange_start(uint32_t partner_vid);
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count);
// `sockets` and `attrs` mirror the 40250 GC_EXCHANGE item payload. They are
// optional for synthetic/server-command updates and default to zero.
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count,
const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
void mut_exchange_item_del(bool self, uint8_t slot);
void mut_exchange_gold(bool self, int64_t gold);
void mut_exchange_accept(bool self, bool accepted);
@@ -968,6 +1052,22 @@ public:
m_changes.clear();
return v;
}
// vids whose entity fields changed without a Spawn/Move/Info change since the
// last drain (deduped). W2 uses this for the §2.1 visibility pass so a bare
// ownership/affect update still re-mirrors the node.
std::vector<uint32_t> drain_dirty() {
auto v = std::move(m_dirty);
m_dirty.clear();
return v;
}
// Map BGM (§9.1). Returns true once after mut_map_bgm and consumes the flag.
bool take_bgm_dirty() {
bool d = m_bgm_dirty;
m_bgm_dirty = false;
return d;
}
const std::string &bgm_name() const { return m_bgm_name; }
float bgm_volume() const { return m_bgm_volume; }
std::vector<ChatMsg> drain_chat() {
auto v = std::move(m_chat);
m_chat.clear();
@@ -1055,14 +1155,24 @@ public:
private:
Entity &touch(uint32_t vid, bool &created);
void start_move(Entity &e, float tx, float ty, uint32_t start_ms, uint32_t dur_ms, uint8_t func);
void mark_vitals(uint32_t vid);
Item *mut_slot(uint8_t window, uint16_t cell);
// CLIENT-GAP §3.2 helpers for the TCP state queue.
void drain_state_queue(Entity &e); // release due entries for one entity
void apply_state_cmd(Entity &e, const StateCmd &c); // the FUNC_* switch (1.0 / 50.0 thresholds)
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
uint32_t m_server_frame_ms = 0;
std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty;
std::string m_bgm_name;
float m_bgm_volume = -1.0f;
bool m_bgm_dirty = false;
std::vector<ChatMsg> m_chat;
std::vector<uint32_t> m_vitals;
std::vector<DamageEvent> m_damage;
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -48,7 +48,6 @@ public:
~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,
@@ -58,6 +57,8 @@ public:
// 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);
@@ -87,6 +88,9 @@ public:
bool attack(int motion, int victim_vid);
bool set_target(int victim_vid);
bool say(int type, const godot::String &text);
// Reference client SendEmoticon: send the registered chat token and let the
// presentation layer play the matching local effect.
bool send_emoticon(int index);
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);
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// Public GDScript-facing network arguments arrive as signed integers. The
// wire structs use narrower unsigned fields, so every M2Client entry point
// must validate before converting instead of allowing a negative value to
// wrap into a large packet argument.
#include <cstdint>
#include <limits>
namespace mtnet::bounds {
inline constexpr bool u8(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint8_t>::max();
}
inline constexpr bool u16(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint16_t>::max();
}
inline constexpr bool u32(int64_t value) {
return value >= 0 && value <= std::numeric_limits<uint32_t>::max();
}
inline constexpr bool i32(int64_t value) {
return value >= std::numeric_limits<int32_t>::min() &&
value <= std::numeric_limits<int32_t>::max();
}
// Protocol geometry / array limits from ClientVS22 Packet.h and GameType.h.
inline constexpr int QUICK_SLOT_COUNT = 36;
inline constexpr int SHOP_ITEM_SLOT_COUNT = 40;
inline constexpr int PRIVATE_SHOP_ITEM_COUNT = 39;
inline constexpr int EXCHANGE_ITEM_SLOT_COUNT = 12;
inline constexpr int CUBE_ITEM_SLOT_COUNT = 24;
inline constexpr int POINT_COUNT = 255;
inline constexpr int DS_REFINE_SLOT_COUNT = 15;
// Packet.h:2041 SKILL_MAX_NUM; ClickSkillSlot rejects slot >= SKILL_MAX_NUM
// (PythonPlayerSkill.cpp:108) and the aSkill[] index is always below it.
inline constexpr int SKILL_MAX_NUM = 255;
inline constexpr bool quick_slot(int64_t value) {
return value >= 0 && value < QUICK_SLOT_COUNT;
}
inline constexpr bool shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool private_shop_slot(int64_t value) {
return value >= 0 && value < SHOP_ITEM_SLOT_COUNT;
}
inline constexpr bool exchange_slot(int64_t value) {
return value >= 0 && value < EXCHANGE_ITEM_SLOT_COUNT;
}
inline constexpr bool cube_slot(int64_t value) {
return value >= 0 && value < CUBE_ITEM_SLOT_COUNT;
}
// Real skill intent (CG_USE_SKILL vnum). Reference use path only ever sends a
// positive index below SKILL_MAX_NUM; a caller value outside that is rejected
// rather than masked into the uint32 wire field.
inline constexpr bool skill_index(int64_t value) {
return value > 0 && value < SKILL_MAX_NUM;
}
} // namespace mtnet::bounds
+102
View File
@@ -0,0 +1,102 @@
#pragma once
// text_codec — one place to fill / read the 40250 wire's fixed-length text
// fields (CLIENT-GAP-FIX §1.10).
//
// Reference: the stock client stores names / signs / comments as **single-byte
// locale-codepage** strings (EterLocale + LocaleService_GetCodePage(), see
// REF/UserInterface/Locale_inc_*.h) and fills the C buffers with
// strncpy(dst, src, sizeof(dst) - 1);
// i.e. a hard **byte** cap, never a character count.
//
// This project carries text as UTF-8 end to end (GDScript String ->
// std::string). Until the per-locale codepage tables are ported, to_wire() /
// from_wire() do UTF-8-preserving byte truncation: cut on a code-point
// boundary so a multi-byte sequence is never split, and stop at `cap` bytes.
//
// FOLLOW-UP (W1/W3, tracked in §1.10): wire the real locale codepage
// (project/Locale.gd -> LocaleService_GetCodePage -> CP949 / CP1252 / …)
// and transcode here. Today the bytes on the wire are UTF-8; a server that
// expects a single-byte codepage sees mojibake for non-ASCII names. ASCII
// (the common case for account-created names) round-trips unchanged.
//
// mtnet (ClassicParser / ClassicSession) links **without** godot-cpp, so the
// std::string_view core below is the one those call sites use. The
// godot::String overloads are compiled only where <godot_cpp/...> is on the
// include path (the M2Client / GDScript boundary).
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#if defined(__has_include)
# if __has_include(<godot_cpp/variant/string.hpp>)
# include <godot_cpp/variant/char_string.hpp>
# include <godot_cpp/variant/string.hpp>
# define MT_TEXT_CODEC_HAS_GODOT 1
# endif
#endif
namespace mtnet {
// Byte length of the longest prefix of [s, s+n) that (a) ends on a UTF-8
// code-point boundary and (b) is <= cap bytes. Invalid lead bytes advance one
// byte at a time (ASCII-safe) so malformed input still terminates.
inline size_t utf8_byte_truncate(const char *s, size_t n, size_t cap) {
size_t i = 0;
size_t last_boundary = 0;
while (i < n) {
const unsigned char c = static_cast<unsigned char>(s[i]);
size_t seq = 1;
if (c >= 0xF0 && c <= 0xF4) {
seq = 4;
} else if (c >= 0xE0 && c <= 0xEF) {
seq = 3;
} else if (c >= 0xC2 && c <= 0xDF) {
seq = 2;
}
if (i + seq > n) {
break; // truncated / invalid trailing sequence
}
if (i + seq > cap) {
break; // would overflow the byte cap
}
i += seq;
last_boundary = i;
}
return last_boundary;
}
// Fill helper for a fixed-length `char[cap+1]` wire field: returns the bytes to
// memcpy (<= cap, never a split multi-byte char). Caller value-initialises the
// struct so the tail stays NUL.
inline std::string to_wire(std::string_view utf8, size_t cap) {
return std::string(utf8.data(), utf8_byte_truncate(utf8.data(), utf8.size(), cap));
}
// Read helper: bytes up to the first NUL or `cap`, then guarded against a
// dangling partial sequence.
inline std::string from_wire_str(const char *bytes, size_t cap) {
size_t n = 0;
while (n < cap && bytes[n] != '\0') {
++n;
}
return std::string(bytes, utf8_byte_truncate(bytes, n, n));
}
#ifdef MT_TEXT_CODEC_HAS_GODOT
// §1.10 named API — the GDScript / M2Client boundary.
inline std::string to_wire(const godot::String &s, size_t cap) {
const godot::CharString u = s.utf8();
const char *data = u.get_data();
return to_wire(std::string_view(data ? data : "", data ? static_cast<size_t>(u.length()) : 0),
cap);
}
inline godot::String from_wire(const char *bytes, size_t cap) {
const std::string s = from_wire_str(bytes, cap);
return godot::String::utf8(s.c_str(), static_cast<int64_t>(s.size()));
}
#endif
} // namespace mtnet
+4
View File
@@ -330,6 +330,10 @@ enum : uint8_t {
POINT_MAX_STAMINA = 10,
POINT_GOLD = 11,
POINT_ATT_SPEED = 17,
POINT_SUB_SKILL = 27, // 辅助/被动技能点 SKILL_SUB — uicharacter.py skillPageStatDict["SUPPORT"]
POINT_SKILL = 28, // 主动技能点 SKILL — skillPageStatDict["ACTIVE"]
POINT_BOW_DISTANCE = 34, // 활 사정거리 증가치 — __GetBowRange() bonus
POINT_HORSE_SKILL = 113, // 马术技能点 — skillPageStatDict["HORSE"] (Packet.h:1641)
POINT_ENERGY = 128,
POINT_ENERGY_END_TIME = 129,
};
+19 -2
View File
@@ -197,15 +197,32 @@ ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.anti_flags = rd_u32(r + 142);
it.flags = rd_u32(r + 146);
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 2; ++i) {
const size_t off = 166 + static_cast<size_t>(i) * 5;
it.limits[i].type = r[off];
it.limits[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 3; ++i) {
const size_t off = 176 + static_cast<size_t>(i) * 5;
it.applies[i].type = r[off];
it.applies[i].value = static_cast<int32_t>(rd_u32(r + off + 1));
}
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
for (int i = 0; i < 3; ++i) {
it.sockets[i] = static_cast<int32_t>(rd_u32(r + 215 + i * 4));
}
it.refined_vnum = rd_u32(r + 227);
it.refine_set = static_cast<uint16_t>(r[231] | (static_cast<uint16_t>(r[232]) << 8));
it.alter_to_magic_pct = r[233];
it.specular = r[234];
it.gain_socket_pct = r[235];
return it;
}
+19
View File
@@ -43,6 +43,16 @@ bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Pro
// --- typed views over the leading fields (rest is offset-stable per stride) ---
struct ItemLimit {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemApply {
uint8_t type = 0;
int32_t value = 0;
};
struct ItemRecord {
uint32_t vnum = 0;
uint32_t vnum_range = 0;
@@ -52,11 +62,20 @@ struct ItemRecord {
uint8_t sub_type = 0; // @ 139
uint8_t weight = 0; // @ 140
uint8_t size = 0; // @ 141
uint32_t anti_flags = 0; // @ 142 (ITEM_ANTIFLAG_*; sell = 1 << 8)
uint32_t flags = 0; // @ 146 (ITEM_FLAG_*; count-per-1-gold = 1 << 3)
uint32_t wear_flags = 0; // @ 150
uint32_t buy_price = 0; // @ 158
uint32_t sell_price = 0; // @ 162
std::array<ItemLimit, 2> limits{}; // aLimits[2] @ 166 (type + long)
std::array<ItemApply, 3> applies{}; // aApplies[3] @ 176 (type + long)
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
int32_t sockets[3] = {0}; // alSockets[3] @ 215
uint32_t refined_vnum = 0; // dwRefinedVnum @ 227
uint16_t refine_set = 0; // wRefineSet @ 231
uint8_t alter_to_magic_pct = 0; // bAlterToMagicItemPct @ 233
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
uint8_t gain_socket_pct = 0; // bGainSocketPct @ 235
};
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
+33
View File
@@ -71,9 +71,31 @@ Dictionary Metin2Proto::item(int vnum) const {
d["sub_type"] = (int)r.sub_type;
d["weight"] = (int)r.weight;
d["size"] = (int)r.size;
d["anti_flags"] = (int)r.anti_flags;
d["flags"] = (int)r.flags;
d["wear_flags"] = (int)r.wear_flags;
d["buy_price"] = (int)r.buy_price;
d["sell_price"] = (int)r.sell_price;
{
Array limits;
for (const mtproto::ItemLimit &limit : r.limits) {
Dictionary entry;
entry["type"] = (int)limit.type;
entry["value"] = (int)limit.value;
limits.push_back(entry);
}
d["limits"] = limits;
}
{
Array applies;
for (const mtproto::ItemApply &apply : r.applies) {
Dictionary entry;
entry["type"] = (int)apply.type;
entry["value"] = (int)apply.value;
applies.push_back(entry);
}
d["applies"] = applies;
}
{
Array vals;
for (int i = 0; i < 6; ++i) {
@@ -81,7 +103,18 @@ Dictionary Metin2Proto::item(int vnum) const {
}
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
}
{
Array sockets;
for (int socket : r.sockets) {
sockets.push_back(socket);
}
d["sockets"] = sockets;
}
d["refined_vnum"] = (int)r.refined_vnum;
d["refine_set"] = (int)r.refine_set;
d["alter_to_magic_pct"] = (int)r.alter_to_magic_pct;
d["specular"] = (int)r.specular;
d["gain_socket_pct"] = (int)r.gain_socket_pct;
return d;
}
+3 -1
View File
@@ -445,7 +445,9 @@ Ref<ArrayMesh> build_proxy_impl(const std::string &species, float height_m,
leaf->set_shader(leaf_shader());
leaf->set_shader_parameter("leaf_tex",
textures.composite.is_valid() ? textures.composite : fallback_leaf_texture(conifer));
leaf->set_shader_parameter("wind_strength", 1.0f);
// MapUtil.cpp initializes fWindStrength to 0.2 and fWindRandom to 0;
// Environment_Load does not read a wind token from .msenv.
leaf->set_shader_parameter("wind_strength", 0.2f);
mesh->surface_set_material(1, leaf);
return mesh;
}
+44
View File
@@ -0,0 +1,44 @@
// Public M2Client argument bounds shared by the Classic and m2dev backends.
#include "../src/net/net_bounds.h"
#include <cstdio>
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
int main() {
using namespace mtnet::bounds;
CHECK(u8(0) && u8(255), "u8 accepts wire range");
CHECK(!u8(-1) && !u8(256), "u8 rejects outside range");
CHECK(u16(65535) && !u16(-1) && !u16(65536), "u16 range");
CHECK(u32(0) && u32(0xffffffffLL) && !u32(-1), "u32 range");
CHECK(i32(-2147483648LL) && i32(2147483647LL), "i32 accepts signed range");
CHECK(!i32(-2147483649LL) && !i32(2147483648LL), "i32 rejects outside range");
CHECK(quick_slot(0) && quick_slot(35), "quick slot endpoints");
CHECK(!quick_slot(-1) && !quick_slot(36), "quick slot outside range");
CHECK(private_shop_slot(0) && private_shop_slot(39), "private shop endpoints");
CHECK(!private_shop_slot(40), "private shop outside range");
CHECK(exchange_slot(0) && exchange_slot(11) && !exchange_slot(12), "exchange range");
CHECK(cube_slot(0) && cube_slot(23) && !cube_slot(24), "cube range");
// §3.8 mod 3: use_skill skill index. 1..254 accepted, 0 / >=255 / negative rejected.
CHECK(SKILL_MAX_NUM == 255, "SKILL_MAX_NUM matches Packet.h");
CHECK(skill_index(1) && skill_index(254), "skill index endpoints");
CHECK(!skill_index(0) && !skill_index(255) && !skill_index(-1), "skill index outside range");
CHECK(!skill_index(0x7fffffffLL + 1), "skill index rejects large value");
if (g_fail) {
std::fprintf(stderr, "%d checks FAILED\n", g_fail);
return 1;
}
std::puts("net_bounds_test: OK");
return 0;
}
+196 -14
View File
@@ -3,9 +3,14 @@
// session.stream().feed(), outgoing checked via take_outgoing().
#include "../src/net/classic/classic_session.h"
#include <arpa/inet.h>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
using namespace mtnet::classic;
@@ -43,6 +48,42 @@ static std::vector<uint8_t> drain(ClassicSession &s) {
return out;
}
static int listen_loopback(uint16_t &port) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return -1;
}
int one = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (::bind(fd, reinterpret_cast<const sockaddr *>(&addr), sizeof(addr)) < 0 ||
::listen(fd, 4) < 0) {
::close(fd);
return -1;
}
socklen_t len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<sockaddr *>(&addr), &len) < 0) {
::close(fd);
return -1;
}
int flags = ::fcntl(fd, F_GETFL, 0);
::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
port = ntohs(addr.sin_port);
return fd;
}
static bool accept_loopback(int listener, int &accepted) {
pollfd pfd{listener, POLLIN, 0};
if (::poll(&pfd, 1, 500) <= 0) {
return false;
}
accepted = ::accept(listener, nullptr, nullptr);
return accepted >= 0;
}
int main() {
ClassicSession s;
s.set_now(0);
@@ -53,6 +94,7 @@ int main() {
s.on_entered_game = [&]() { entered = true; };
s.start_offline("admin", "123456789");
s.set_auto_entergame_delay(1500);
CHECK(s.stage() == mtnet::INetSession::Stage::Connecting, "start_offline -> Connecting");
// --- handshake: server HANDSHAKE -> client echoes it back ---
@@ -98,6 +140,8 @@ int main() {
ls.players[0].level = 42;
ls.players[0].ht = 20;
ls.players[0].change_name = 1;
ls.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1, little-endian lAddr
ls.players[0].port = 13002;
ls.players[1].id = 1002;
std::strcpy(ls.players[1].name, "Sura");
ls.players[1].job = 4;
@@ -110,6 +154,8 @@ int main() {
const auto &sl = s.char_slots();
CHECK(sl.size() == 4 && sl[0].name == "Warrior" && sl[0].level == 42 && sl[0].change_name,
"slot 0 + forced rename flag");
CHECK(sl[0].addr == static_cast<int32_t>(0x0100007f) && sl[0].port == 13002,
"slot 0 game address carried");
CHECK(sl[1].name == "Sura" && sl[1].job == 4, "slot 1");
CHECK(sl[2].empty() && sl[3].empty(), "slots 2,3 empty");
}
@@ -167,25 +213,74 @@ int main() {
const auto *me = s.world().get(7777);
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
auto version = drain(s);
CHECK(version.size() == sizeof(CGClientVersion) + 1, "CG_CLIENT_VERSION + seq");
CGClientVersion cv{};
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
CHECK(version.size() == sizeof(CGClientVersion2) + 1, "CG_CLIENT_VERSION2 + seq");
CGClientVersion2 cv{};
std::memcpy(&cv, version.data(), sizeof(cv));
CHECK(cv.header == HDR_CG_CLIENT_VERSION && std::strcmp(cv.filename, "metin2.bin") == 0 &&
CHECK(cv.header == HDR_CG_CLIENT_VERSION2 && std::strcmp(cv.filename, "metin2.bin") == 0 &&
std::strcmp(cv.timestamp, "1215955205") == 0, "client version fields");
CHECK(version[sizeof(CGClientVersion)] == SEQUENCE_TABLE[2], "version seq == table[2]");
CHECK(version[sizeof(CGClientVersion2)] == SEQUENCE_TABLE[2], "version seq == table[2]");
// --- §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD only stashes; the
// actor is not spawned until GC_CHAR_ADDITIONAL_INFO merges it ---
GCCharacterAdd add{};
add.header = HDR_GC_CHARACTER_ADD;
add.vid = 8888;
add.race = 101;
add.type = 1; // NPC
add.type = CHRTYPE_NPC;
add.x = 100500;
add.y = 200500;
add.moving_speed = 150;
feed(s, raw(add));
CHECK(s.world().get(8888) == nullptr, "PC/NPC add does not spawn yet");
CHECK(s.world().size() == 1, "still just the main character");
CHECK(s.parser().pending_actor_count() == 1, "actor stashed pending its info");
GCCharAddInfo info{};
info.header = HDR_GC_CHAR_ADDITIONAL_INFO;
info.vid = 8888;
std::strcpy(info.name, "Shopkeeper");
info.parts[0] = 7;
info.empire = 2;
info.level = 9;
feed(s, raw(info));
const auto *npc = s.world().get(8888);
CHECK(npc && npc->ch_type == 1 && npc->race == 101, "npc spawned");
CHECK(s.world().size() == 2, "2 entities");
CHECK(npc && npc->ch_type == CHRTYPE_NPC && npc->race == 101, "merged actor spawned");
CHECK(npc && npc->name == "Shopkeeper" && npc->level == 9 && npc->parts[0] == 7 &&
npc->empire == 2, "additional-info fields merged in");
CHECK(npc && npc->x == 100500.f && npc->y == 200500.f && npc->moving_speed == 150,
"stashed spawn fields survive the merge");
CHECK(s.world().size() == 2 && s.parser().pending_actor_count() == 0,
"pending record flushed to the world");
// a monster spawns immediately, with no additional-info handshake
GCCharacterAdd mob{};
mob.header = HDR_GC_CHARACTER_ADD;
mob.vid = 8890;
mob.race = 101;
mob.type = CHRTYPE_MONSTER;
mob.x = 101000;
mob.y = 201000;
feed(s, raw(mob));
CHECK(s.world().get(8890) && s.world().get(8890)->ch_type == CHRTYPE_MONSTER,
"monster spawned on GC_CHARACTER_ADD alone");
CHECK(s.world().size() == 3, "3 entities incl. the monster");
// an invisible race is dropped outright (neither stashed nor spawned)
GCCharacterAdd ghost{};
ghost.header = HDR_GC_CHARACTER_ADD;
ghost.vid = 8891;
ghost.race = 20025;
ghost.type = CHRTYPE_MONSTER;
feed(s, raw(ghost));
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
s.world().size() == 3, "invisible race dropped");
// drop the monster so the rest of the flow sees just main + NPC 8888
GCCharacterDel mobdel{HDR_GC_CHARACTER_DEL, 8890};
feed(s, raw(mobdel));
CHECK(s.world().size() == 2, "monster removed");
GCShopSign sign{};
sign.header = HDR_GC_SHOP_SIGN;
@@ -218,6 +313,79 @@ int main() {
CHECK(s.in_game() && entered, "in_game() + on_entered_game");
}
// --- known but unsupported integration packet is not silently accepted ---
{
ClassicSession unsupported;
unsupported.start_offline("admin", "123456789");
std::vector<uint8_t> packet(sizeof(GCMatrixCard), 0);
packet[0] = HDR_GC_MATRIX_CARD;
feed(unsupported, packet);
CHECK(unsupported.stage() == mtnet::INetSession::Stage::Failed,
"unsupported integration packet -> Failed");
CHECK(unsupported.last_error().find("does not handle") != std::string::npos,
"unsupported integration packet -> explicit error");
auto unsupported_headers = unsupported.parser().drain_unhandled_headers();
CHECK(unsupported_headers.size() == 1 && unsupported_headers[0] == HDR_GC_MATRIX_CARD,
"unsupported integration packet -> header event");
}
// --- DirectEnter opens the character slot's TCP endpoint ---
{
uint16_t initial_port = 0;
uint16_t slot_port = 0;
uint16_t warp_port = 0;
const int initial_listener = listen_loopback(initial_port);
const int slot_listener = listen_loopback(slot_port);
const int warp_listener = listen_loopback(warp_port);
if (initial_listener >= 0 && slot_listener >= 0 && warp_listener >= 0) {
ClassicSession direct;
CHECK(direct.connect_with_login_key("127.0.0.1", initial_port, "admin", 0x12345678),
"direct enter initial game connection");
int initial_connection = -1;
CHECK(accept_loopback(initial_listener, initial_connection),
"direct enter first TCP connection accepted");
GCLoginSuccess login{};
login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT;
login.players[0].id = 2001;
std::strcpy(login.players[0].name, "Direct");
login.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1
login.players[0].port = slot_port;
feed(direct, raw(login));
CHECK(direct.char_slots()[0].addr == static_cast<int32_t>(0x0100007f) &&
direct.char_slots()[0].port == slot_port, "direct enter slot endpoint parsed");
CHECK(direct.connect_direct_enter(0), "direct enter reconnect accepted");
int slot_connection = -1;
CHECK(accept_loopback(slot_listener, slot_connection),
"direct enter second TCP connection targets slot endpoint");
CHECK(direct.connect_warp("127.0.0.1", warp_port),
"GC_WARP reconnect keeps selected slot and login key");
int warp_connection = -1;
CHECK(accept_loopback(warp_listener, warp_connection),
"GC_WARP reconnect targets packet endpoint");
if (initial_connection >= 0) {
::close(initial_connection);
}
if (slot_connection >= 0) {
::close(slot_connection);
}
if (warp_connection >= 0) {
::close(warp_connection);
}
} else {
std::puts("SKIP: DirectEnter TCP target test (localhost bind unavailable)");
}
if (initial_listener >= 0) {
::close(initial_listener);
}
if (slot_listener >= 0) {
::close(slot_listener);
}
if (warp_listener >= 0) {
::close(warp_listener);
}
}
// --- GC_MOVE(3): NPC starts moving, interpolation advances on tick ---
{
GCMove mv{};
@@ -248,7 +416,7 @@ int main() {
feed(s, raw(warp));
auto warps = s.world().drain_warps();
CHECK(warps.size() == 1 && warps[0].x == 432100 && warps[0].y == 876500 &&
warps[0].same_server(), "classic GC_WARP parsed and surfaced");
!warps[0].same_server(), "classic GC_WARP always requests reconnect");
}
// --- GC_CHARACTER_POINTS(16): stat block ---
@@ -280,7 +448,7 @@ int main() {
// --- in-game intents: CG_MOVE / ATTACK / CHAT / TARGET, each + a seq byte ---
{
// sequence index so far: [0]=CG_LOGIN, [1]=CG_CHARACTER_SELECT,
// [2]=CG_CLIENT_VERSION, [3]=CG_ENTERGAME.
// [2]=CG_CLIENT_VERSION2, [3]=CG_ENTERGAME.
CHECK(s.stream().sequence_index() == 4, "seq index at 4 before intents");
CHECK(s.send_move(mtnet::FUNC_MOVE, 0, 90.0f, 111111, 222222, 4242), "send_move");
@@ -703,9 +871,15 @@ int main() {
ex.arg1 = 27001;
ex.arg2 = {0, 4};
ex.arg3 = 7;
ex.sockets[1] = 9002;
ex.attrs[2] = {17, -33};
feed(s, raw(ex));
CHECK(s.world().exchange().self_items[4].vnum == 27001 &&
s.world().exchange().self_items[4].count == 7, "exchange item add");
s.world().exchange().self_items[4].count == 7 &&
s.world().exchange().self_items[4].sockets[1] == 9002 &&
s.world().exchange().self_items[4].attrs[2].type == 17 &&
s.world().exchange().self_items[4].attrs[2].value == -33,
"exchange item add keeps sockets/attrs");
ex.subheader = EXCHANGE_GC_GOLD_ADD;
ex.arg1 = 123456;
@@ -1011,19 +1185,27 @@ int main() {
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
}
// --- GC_CHAR_ADDITIONAL_INFO(136): fills a spawned entity's name/parts/level ---
// --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD
// is dropped and leaves the live entity untouched (§2.2) ---
{
const auto *before = s.world().get(8888);
CHECK(before != nullptr, "8888 present before the drop test");
const std::string name0 = before->name;
const int32_t level0 = before->level;
const uint16_t part0 = before->parts[0];
GCCharAddInfo ci{};
ci.header = HDR_GC_CHAR_ADDITIONAL_INFO;
ci.vid = 8888;
std::strcpy(ci.name, "Goblin");
ci.parts[0] = 3;
ci.empire = 1;
ci.guild_id = 0;
ci.level = 12;
feed(s, raw(ci));
const auto *e = s.world().get(8888);
CHECK(e && e->name == "Goblin" && e->level == 12 && e->parts[0] == 3, "char_add_info");
CHECK(e && e->name == name0 && e->level == level0 && e->parts[0] == part0,
"additional-info without a pending add is a no-op");
CHECK(s.parser().pending_actor_count() == 0, "no stray pending record created");
}
// --- GC_SYNC_POSITION(5, dynamic): snap entities ---
@@ -78,6 +78,30 @@ int main() {
CHECK(out[sizeof(Handshake)] == SEQUENCE_TABLE[0], "time-sync uses next sequence byte");
}
// -------------------------------------------- server frame clock (§3.2)
{
ClassicStream s;
CHECK(s.server_frame_ms() == 0, "server_frame_ms is 0 before any handshake");
CHECK(!s.has_server_clock(), "has_server_clock false before handshake");
Handshake hs{};
hs.header = HDR_HANDSHAKE;
hs.handshake = 0x1234;
hs.time = 500000;
hs.delta = 20;
auto b = raw(hs);
s.feed(b.data(), b.size());
(void)drain(s);
CHECK(s.has_server_clock(), "has_server_clock true after handshake");
uint32_t base = 500000u + 20u; // hs.time + lDelta
uint32_t f1 = s.server_frame_ms();
CHECK(f1 >= base && f1 < base + 5000u,
"server_frame_ms ~ hs.time + lDelta right after handshake");
uint32_t f2 = s.server_frame_ms();
CHECK(f2 >= f1, "server frame clock is monotonic non-decreasing");
}
// ---------------------------------------------------------- GC_PHASE
{
ClassicStream s;
@@ -218,6 +242,24 @@ int main() {
s.feed(&bad, 1);
CHECK(!err.empty(), "unknown header -> on_error");
CHECK(s.state() == ClassicStream::State::Offline, "unknown header -> disconnect");
GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99};
auto trailing = raw(del);
s.feed(trailing.data(), trailing.size());
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes");
}
// ---------------------------------------------------------- rejected handler
{
ClassicStream s;
std::string err;
s.on_error = [&](const std::string &e) { err = e; };
s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { return false; };
GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99};
auto b = raw(del);
s.feed(b.data(), b.size());
CHECK(err.find("packet handler rejected") != std::string::npos,
"rejected packet -> explicit on_error");
CHECK(s.state() == ClassicStream::State::Offline, "rejected packet -> disconnect");
}
// ---- GC_KEY_AGREEMENT: a partial packet just waits (no error, no crash) ----
+90 -5
View File
@@ -165,20 +165,41 @@ int main() {
CHECK(!es.get(2)->moving, "stopped at t=1");
CHECK(es.get(2)->func == FUNC_WAIT, "func back to WAIT");
// --- a non-move func snaps ---
// --- §3.2 state queue: a near attack func (dir_len < 50) snaps and acts ---
// entity 2 is at (1500,500); target (1530,500) -> dir_len 30 < 50.
GCMove atk{};
atk.header = GC_MOVE;
atk.length = sizeof(atk);
atk.vid = 2;
atk.func = FUNC_ATTACK;
atk.x = 1600;
atk.y = 600;
atk.x = 1530;
atk.y = 500;
atk.duration = 0;
b = pkt(atk);
es.apply(GC_MOVE, b.data(), (uint16_t)b.size());
CHECK(!es.get(2)->moving && es.get(2)->x == 1600, "attack func snaps position");
CHECK(!es.get(2)->moving && es.get(2)->x == 1530, "near attack func snaps position");
CHECK(es.get(2)->func == FUNC_ATTACK, "func = ATTACK");
// --- §3.2 state queue: a far attack func (dir_len >= 50) walks, then attacks
// on arrival (reference m_kMovAfterFunc). ---
GCMove fatk{};
fatk.header = GC_MOVE;
fatk.length = sizeof(fatk);
fatk.vid = 2;
fatk.func = FUNC_ATTACK;
fatk.x = 1700; // from (1530,500): dir_len 170 >= 50
fatk.y = 500;
fatk.duration = 0;
b = pkt(fatk);
es.set_now(2000);
es.apply(GC_MOVE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->moving && es.get(2)->func == FUNC_MOVE, "far attack func walks first");
es.set_now(20000); // well past arrival
es.tick();
CHECK(!es.get(2)->moving && std::abs(es.get(2)->x - 1700.0f) < 0.01f,
"far attack func: arrived at Dst");
CHECK(es.get(2)->func == FUNC_ATTACK, "far attack func: attacks on arrival");
// --- movement metadata + batched correction packets ---
{
GCChangeSpeed speed{GC_CHANGE_SPEED, sizeof(GCChangeSpeed), 2, 175};
@@ -964,9 +985,17 @@ int main() {
xi.arg1 = 19; // vnum
xi.arg2.cell = 2; // exchange slot
xi.arg3 = 1; // count
xi.sockets[0] = 7001;
xi.sockets[2] = -1;
xi.attrs[0] = {5, 42};
b = pkt(xi);
es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size());
CHECK(es.exchange().self_items[2].vnum == 19, "exchange: self item add");
CHECK(es.exchange().self_items[2].vnum == 19 &&
es.exchange().self_items[2].sockets[0] == 7001 &&
es.exchange().self_items[2].sockets[2] == -1 &&
es.exchange().self_items[2].attrs[0].type == 5 &&
es.exchange().self_items[2].attrs[0].value == 42,
"exchange: self item add keeps sockets/attrs");
GCExchange xe{};
xe.header = GC_EXCHANGE;
@@ -1590,6 +1619,62 @@ int main() {
CHECK(despawn, "despawn change emitted");
}
// --- CLIENT-GAP §2.1: EntityStore is the data layer. A far entity is a full
// row here with a queued Spawn/dirty; no scene, no replay buffer. Scene-
// node visibility (CHAR_STAGE_VIEW_BOUND cull / re-show) is asserted by
// project/net_world_vis_test.gd, which drives net_world.gd directly. ---
{
EntityStore d;
d.set_now(0);
d.mut_spawn_main(1, 1, "hero", 0, 0, 0);
d.drain_changes();
// GC_CHARACTER_ADD far outside any view bound -> still a full data row.
d.mut_spawn(4242, 101, 2, "far-orc", 900000.0f, 0.0f, 0.0f, 0.0f, 300, 90);
const Entity *orc = d.get(4242);
CHECK(orc && orc->race == 101 && orc->attack_speed == 90 && orc->name == "far-orc",
"§2.1 data layer: far GC_CHARACTER_ADD populates the entity row");
{
auto ch = d.drain_changes();
bool spawn = false;
for (auto &c : ch) {
if (c.kind == EntityStore::ChangeKind::Spawn && c.vid == 4242) {
spawn = true;
}
}
CHECK(spawn, "§2.1 data layer: a Spawn change is queued for the far entity");
}
// GC_CHARACTER_UPDATE for that same VID updates the row in place.
d.mut_char_update(4242, nullptr, 40, 120, 7, -3, 1, 0);
orc = d.get(4242);
CHECK(orc && orc->attack_speed == 120 && orc->moving_speed == 40 && orc->guild == 7,
"§2.1 data layer: UPDATE mutates the existing row");
// Unknown VID: no create, no replay buffer, no size growth.
const size_t n_before = d.size();
d.mut_char_update(999999, nullptr, 10, 10, 0, 0, 0, 0);
d.mut_move(999999, 90.0f, 1, 5.0f, 5.0f, 200);
d.mut_mount(999999, 20101);
d.mut_char_info(999999, "ghost", nullptr, 1, 0, 5, 0, 0, 0);
d.mut_shop_sign(999999, "ghost shop");
CHECK(d.size() == n_before && d.get(999999) == nullptr,
"§2.1 data layer: mutators for an unknown VID never spawn or buffer");
}
// --- ClientVS22 loading reset: discard map-local state, retain no stale deltas ---
es.mut_spawn(99, 101, 1, "reset-me", 10, 20, 0, 0);
es.mut_ground_add(700, 27100, 12, 24, 0);
es.mut_pvp(99, 1, 2);
es.mut_target(99, 50);
es.reset_for_map_change();
CHECK(es.size() == 0 && es.main_vid() == 0, "map reset clears network entities");
CHECK(es.ground_vids().empty() && es.pvp_relations().empty(),
"map reset clears ground items and pvp state");
CHECK(es.target_vid() == 0 && es.take_target_dirty(), "map reset clears target state");
CHECK(es.drain_changes().empty() && es.drain_ground().empty(),
"map reset drops stale entity and ground deltas");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
+159
View File
@@ -0,0 +1,159 @@
// CLIENT-GAP §3.2 — EntityStore TCP state queue: server-frame release gate,
// __CanProcessNetworkStatePacket gate, and the FUNC_* switch with its 1.0 / 50.0
// hard thresholds (no lerp, no jitter buffer). Drives the protocol-neutral
// mutation API directly (same path the classic backend uses).
#include "../src/net/entity_store.h"
#include <cmath>
#include <cstdio>
using namespace mtnet;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void spawn(EntityStore &es, uint32_t vid, float x, float y) {
es.mut_spawn(vid, 101, 0, "Mob", x, y, 0.0f, 0.0f, 200 /*moving_speed*/, 0);
}
int main() {
// --- 1) server-frame release gate ---------------------------------------
{
EntityStore es;
es.set_now(0);
spawn(es, 10, 0, 0);
es.set_server_frame_ms(1000);
// command timestamped in the future: stays queued, entity does not move.
es.mut_move(10, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0, /*chk_time*/ 2000);
CHECK(es.get(10)->state_queue.size() == 1, "future cmd stays queued");
CHECK(!es.get(10)->moving, "future cmd: not moving yet");
es.process_states();
CHECK(es.get(10)->state_queue.size() == 1, "still queued below chk_time");
es.set_server_frame_ms(2000); // clock reaches the command time
es.process_states();
CHECK(es.get(10)->state_queue.empty(), "cmd released once frame clock catches up");
CHECK(es.get(10)->moving && es.get(10)->func == FUNC_MOVE, "released FUNC_MOVE walks");
// frame clock 0 == "not aligned yet" -> release immediately.
EntityStore es0;
es0.set_now(0);
spawn(es0, 11, 0, 0);
es0.mut_move(11, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0, /*chk_time*/ 9999);
CHECK(es0.get(11)->state_queue.empty() && es0.get(11)->moving,
"frame clock 0 releases regardless of chk_time");
}
// --- 2) FUNC_WAIT: 1.0 threshold (snap-align vs. walk) ------------------
{
EntityStore es;
es.set_now(0);
spawn(es, 20, 0, 0);
// within 1.0 -> snap align, no walk.
es.mut_move(20, 90.0f, FUNC_WAIT, 0.5f, 0.0f, 0, 0, 0);
CHECK(!es.get(20)->moving, "FUNC_WAIT dir_len<=1.0: no walk");
CHECK(std::abs(es.get(20)->x - 0.5f) < 1e-3f, "FUNC_WAIT dir_len<=1.0: snapped to Dst");
CHECK(es.get(20)->func == FUNC_WAIT && std::abs(es.get(20)->angle - 90.0f) < 1e-3f,
"FUNC_WAIT snap: func + heading applied");
// beyond 1.0 -> walk there, m_kMovAfterFunc == WAIT.
es.mut_move(20, 0.0f, FUNC_WAIT, 100.0f, 0.0f, 0, 0, 0);
CHECK(es.get(20)->moving && es.get(20)->func == FUNC_MOVE, "FUNC_WAIT dir_len>1.0: walks");
CHECK(es.get(20)->mov_after_func == FUNC_WAIT, "FUNC_WAIT walk: after-func WAIT");
CHECK(es.get(20)->skip_collision, "network-driven walk enables skip-collision");
es.set_now(100000);
es.tick();
CHECK(!es.get(20)->moving && es.get(20)->func == FUNC_WAIT, "FUNC_WAIT walk: settles to WAIT");
CHECK(!es.get(20)->skip_collision, "skip-collision cleared on arrival");
}
// --- 3) FUNC_COMBO: 50.0 threshold + after-arrival action --------------
{
EntityStore es;
es.set_now(0);
spawn(es, 30, 0, 0);
// near (dir_len 20 < 50) -> snap + act now, arg is on the row's func.
es.mut_move(30, 45.0f, FUNC_COMBO, 20.0f, 0.0f, 0, /*arg*/ 7, 0);
CHECK(!es.get(30)->moving && es.get(30)->func == FUNC_COMBO,
"FUNC_COMBO dir_len<50: snap + act");
CHECK(std::abs(es.get(30)->x - 20.0f) < 1e-3f, "FUNC_COMBO near: snapped to Dst");
// far (dir_len 200 >= 50) -> walk first, combat + arg deferred to arrival.
es.mut_move(30, 0.0f, FUNC_COMBO, 220.0f, 0.0f, 0, /*arg*/ 9, 0);
CHECK(es.get(30)->moving && es.get(30)->func == FUNC_MOVE, "FUNC_COMBO dir_len>=50: walks");
CHECK(es.get(30)->mov_after_func == FUNC_COMBO && es.get(30)->mov_after_arg == 9,
"FUNC_COMBO far: combo + motion index latched for arrival");
es.set_now(100000);
es.tick();
CHECK(!es.get(30)->moving && es.get(30)->func == FUNC_COMBO,
"FUNC_COMBO far: combo runs on arrival");
CHECK(es.get(30)->mov_after_func == FUNC_WAIT, "after-func latch cleared post-arrival");
}
// --- 4) __CanProcessNetworkStatePacket gate (dead / knocked-down) ------
{
EntityStore es;
es.set_now(0);
spawn(es, 40, 0, 0);
es.mut_knockdown(40, true);
es.mut_move(40, 0.0f, FUNC_MOVE, 300.0f, 0.0f, 0, 0, 0);
CHECK(es.get(40)->state_queue.size() == 1 && !es.get(40)->moving,
"knocked-down: state cmd held, not applied");
es.mut_knockdown(40, false);
es.process_states();
CHECK(es.get(40)->state_queue.empty() && es.get(40)->moving,
"recovered: held state cmd releases");
es.mut_dead(41); // unknown vid: no-op, must not crash
spawn(es, 42, 0, 0);
es.mut_dead(42);
es.mut_move(42, 0.0f, FUNC_MOVE, 300.0f, 0.0f, 0, 0, 0);
CHECK(!es.get(42)->moving && es.get(42)->state_queue.size() == 1,
"dead: state cmd held");
}
// --- 5) queue preserves order and drains multiple due commands --------
{
EntityStore es;
es.set_now(0);
es.set_server_frame_ms(0);
spawn(es, 50, 0, 0);
es.mut_move(50, 0.0f, FUNC_WAIT, 0.2f, 0.0f, 0, 0, 0); // snap (near)
es.mut_move(50, 0.0f, FUNC_ATTACK, 10.0f, 0.0f, 0, 3, 0); // near attack -> snap + act
CHECK(es.get(50)->state_queue.empty(), "both due cmds drained in one pass");
CHECK(es.get(50)->func == FUNC_ATTACK && std::abs(es.get(50)->x - 10.0f) < 1e-3f,
"last cmd wins: ATTACK at (10,0)");
}
// --- 6) staleness valve: chk_time far ahead of the frame clock -----------
// Reference StateProcess has no cap; ours releases a command whose chk_time
// is > STATE_QUEUE_MAX_WAIT_MS ahead of the (unvalidated) server-frame clock
// so a wrong-timebase dwTime can never freeze the actor forever.
{
EntityStore es;
es.set_now(0);
es.set_server_frame_ms(1000);
// exactly at the cap -> still gated.
spawn(es, 60, 0, 0);
es.mut_move(60, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0,
/*chk_time*/ 1000 + STATE_QUEUE_MAX_WAIT_MS);
CHECK(es.get(60)->state_queue.size() == 1 && !es.get(60)->moving,
"chk_time == frame+cap: still gated");
// one ms past the cap -> treated as out-of-band, released now.
spawn(es, 61, 0, 0);
es.mut_move(61, 0.0f, FUNC_MOVE, 500.0f, 0.0f, 0, 0,
/*chk_time*/ 1000 + STATE_QUEUE_MAX_WAIT_MS + 1);
CHECK(es.get(61)->state_queue.empty() && es.get(61)->moving && es.get(61)->func == FUNC_MOVE,
"chk_time past cap: released immediately (staleness valve)");
}
if (g_fail == 0) {
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
}
return g_fail == 0 ? 0 : 1;
}
+106
View File
@@ -0,0 +1,106 @@
// net_text_codec_test — §1.10 fixed-length wire text: to_wire() / from_wire_str()
// must apply a hard BYTE cap (like the stock client's strncpy) while never
// splitting a UTF-8 multi-byte sequence.
#include "../src/net/text_codec.h"
#include <cstdio>
#include <cstring>
#include <string>
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
// A CJK code point is 3 bytes in UTF-8 (U+6C49 "汉" = e6 b1 89).
static std::string cjk(int n) {
std::string s;
for (int i = 0; i < n; ++i) {
s += "\xE6\xB1\x89";
}
return s;
}
int main() {
using mtnet::to_wire;
using mtnet::from_wire_str;
// --- CHARACTER_NAME_MAX_LEN-equivalent cap = 24 bytes ---
// 8 CJK chars == exactly 24 bytes: accepted whole.
{
const std::string in = cjk(8);
CHECK(in.size() == 24, "8 CJK == 24 bytes");
const std::string w = to_wire(in, 24);
CHECK(w.size() == 24 && w == in, "exactly-24 accepted unchanged");
}
// 9 CJK chars == 27 bytes: truncated to 24 (8 chars), never 25/26.
{
const std::string in = cjk(9);
CHECK(in.size() == 27, "9 CJK == 27 bytes");
const std::string w = to_wire(in, 24);
CHECK(w.size() == 24, "27 -> 24 bytes");
CHECK(w == cjk(8), "truncated on the code-point boundary (8 chars)");
}
// 25-byte input (8 CJK + 1 stray lead byte): a 24-byte cap keeps 8 chars,
// and even a 25- or 26-byte cap cannot fit the 9th char -> still 24.
{
std::string in = cjk(8);
in += '\xE6'; // dangling lead byte of a 9th char
CHECK(in.size() == 25, "25-byte input");
CHECK(to_wire(in, 24).size() == 24, "cap 24 -> 24");
CHECK(to_wire(in, 25) == cjk(8), "cap 25 -> 24 (no split)");
CHECK(to_wire(in, 26) == cjk(8), "cap 26 -> 24 (9th char needs 3)");
}
// Mid-sequence cap: cutting 2 bytes into a 3-byte char drops the whole char.
{
const std::string in = cjk(4); // 12 bytes
CHECK(to_wire(in, 11).size() == 9, "cap 11 -> 9 (3 whole chars)");
CHECK(to_wire(in, 10).size() == 9, "cap 10 -> 9");
CHECK(to_wire(in, 12) == in, "cap 12 -> all 12");
}
// ASCII round-trips against the same cap unchanged.
{
const std::string in = "Warrior";
CHECK(to_wire(in, 24) == in, "ascii under cap unchanged");
CHECK(to_wire(std::string(40, 'x'), 24).size() == 24, "ascii over cap -> 24");
}
// --- from_wire_str: stop at NUL, then guard a dangling partial sequence ---
{
char buf[25] = {};
const std::string src = cjk(3); // 9 bytes
std::memcpy(buf, src.data(), src.size());
CHECK(from_wire_str(buf, sizeof(buf) - 1) == src, "reads up to NUL");
}
{
char buf[25];
std::memset(buf, 0, sizeof(buf));
const std::string src = cjk(8);
std::memcpy(buf, src.data(), src.size());
buf[24] = '\xE6'; // 25th byte: lead of a truncated char, no NUL room
const std::string got = from_wire_str(buf, 25);
CHECK(got == cjk(8), "dangling lead byte trimmed on read");
}
{
// Full 24-byte field, no NUL terminator: read all 8 chars.
char buf[24];
const std::string src = cjk(8);
std::memcpy(buf, src.data(), src.size());
CHECK(from_wire_str(buf, 24) == src, "unterminated full field read whole");
}
if (g_fail) {
std::fprintf(stderr, "%d checks FAILED\n", g_fail);
return 1;
}
std::puts("net_text_codec_test: OK");
return 0;
}
@@ -0,0 +1,79 @@
// Synthetic item_proto record test. It locks the offsets used by the
// ClientVS22 TItemTable layout without requiring the 2.2G asset tree.
#include "../src/proto/proto.h"
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
static void put_u32(std::array<uint8_t, 236> &record, size_t offset, uint32_t value) {
record[offset + 0] = static_cast<uint8_t>(value);
record[offset + 1] = static_cast<uint8_t>(value >> 8);
record[offset + 2] = static_cast<uint8_t>(value >> 16);
record[offset + 3] = static_cast<uint8_t>(value >> 24);
}
int main() {
std::array<uint8_t, 236> record{};
put_u32(record, 0, 12345);
put_u32(record, 4, 7);
std::memcpy(record.data() + 8, "TestItem", 9);
std::memcpy(record.data() + 73, "LocaleItem", 11);
record[138] = 3;
record[139] = 4;
record[140] = 2;
record[141] = 1;
put_u32(record, 142, 1u << 8);
put_u32(record, 146, 1u << 3);
put_u32(record, 150, 0x12345678);
put_u32(record, 158, 9000);
put_u32(record, 162, 6000);
record[166] = 1;
put_u32(record, 167, 42);
record[176] = 2;
put_u32(record, 177, -7);
put_u32(record, 191 + 3 * 4, 3);
put_u32(record, 215, 1001);
put_u32(record, 219, -1);
put_u32(record, 227, 12346);
record[231] = 0x34;
record[232] = 0x12;
record[233] = 11;
record[234] = 75;
record[235] = 88;
const mtproto::ItemRecord item = mtproto::parse_item(record.data(), record.size());
CHECK(item.vnum == 12345, "vnum offset");
CHECK(item.vnum_range == 7, "vnum range offset");
CHECK(item.name == "TestItem", "name offset");
CHECK(item.locale_name == "LocaleItem", "locale name offset");
CHECK(item.type == 3 && item.sub_type == 4, "type offsets");
CHECK(item.anti_flags == (1u << 8), "anti_flags offset");
CHECK(item.flags == (1u << 3), "flags offset");
CHECK(item.wear_flags == 0x12345678, "wear_flags offset");
CHECK(item.buy_price == 9000 && item.sell_price == 6000, "price offsets");
CHECK(item.limits[0].type == 1 && item.limits[0].value == 42, "limit offsets");
CHECK(item.applies[0].type == 2 && item.applies[0].value == -7, "apply offsets");
CHECK(item.values[3] == 3, "values offset");
CHECK(item.sockets[0] == 1001 && item.sockets[1] == -1, "socket offsets");
CHECK(item.refined_vnum == 12346 && item.refine_set == 0x1234, "refine offsets");
CHECK(item.alter_to_magic_pct == 11 && item.specular == 75 && item.gain_socket_pct == 88,
"tail offsets");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all item layout checks passed\n");
return 0;
}
+22
View File
@@ -4,6 +4,8 @@ add_library(xr_formats STATIC
textscript.cpp # M2 T1: Metin2 token .msa / .msm
msm.cpp # base gr2 + RaceDataScript
msa.cpp # anim gr2 + duration + accumulation +
combo_table.cpp # §3.5: playersettingmodule.py ComboAttackNew -> PC
attribute_data.cpp # .mdatr:
m2_tokvec.cpp # W0: LoadMultipleTextData Start/End token
m2_coord.cpp # W0: Metin2Godot / / BACKLOG I5
@@ -27,11 +29,31 @@ if(BUILD_TESTING)
add_executable(formats_msa_test tests/msa_test.cpp)
target_link_libraries(formats_msa_test PRIVATE xrender::formats)
add_test(NAME formats.msa_loop_data COMMAND formats_msa_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.msa_loop_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_msm_test tests/msm_test.cpp)
target_link_libraries(formats_msm_test PRIVATE xrender::formats)
add_test(NAME formats.msm_hair COMMAND formats_msm_test)
add_executable(formats_combo_table_test tests/combo_table_test.cpp)
target_link_libraries(formats_combo_table_test PRIVATE xrender::formats)
add_test(NAME formats.combo_table COMMAND formats_combo_table_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.combo_table PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_attribute_data_test tests/attribute_data_test.cpp)
target_link_libraries(formats_attribute_data_test PRIVATE xrender::formats)
add_test(NAME formats.attribute_data COMMAND formats_attribute_data_test)
if(DEFINED ENV{M2_ASSETS})
set_tests_properties(formats.attribute_data PROPERTIES
ENVIRONMENT "M2_ASSETS=$ENV{M2_ASSETS}")
endif()
add_executable(formats_map_test tests/map_formats_test.cpp)
target_link_libraries(formats_map_test PRIVATE xrender::formats)
add_test(NAME formats.map_formats COMMAND formats_map_test)
+165
View File
@@ -0,0 +1,165 @@
#include "attribute_data.h"
#include <cstring>
#include <fstream>
#include <limits>
namespace fmt {
namespace {
constexpr char kHeader[] = "AttributeData";
constexpr std::size_t kHeaderSize = sizeof(kHeader); // include trailing NUL
constexpr std::uint32_t kMaxRecords = 1u << 20;
constexpr std::uint32_t kMaxHeightVertices = 1u << 24;
struct Reader {
const std::uint8_t *data = nullptr;
std::size_t size = 0;
std::size_t offset = 0;
std::string *error = nullptr;
bool fail(const std::string &message) {
if (error)
*error = message + " at offset " + std::to_string(offset);
return false;
}
bool require(std::size_t count) {
if (count > size || offset > size - count)
return fail("AttributeData truncated");
return true;
}
template <typename T>
bool read(T &value) {
if (!require(sizeof(T)))
return false;
std::memcpy(&value, data + offset, sizeof(T));
offset += sizeof(T);
return true;
}
bool read_bytes(void *dst, std::size_t count) {
if (!require(count))
return false;
std::memcpy(dst, data + offset, count);
offset += count;
return true;
}
};
std::size_t dimension_count(std::uint32_t type) {
switch (static_cast<AttributeCollisionType>(type)) {
case AttributeCollisionType::Plane:
case AttributeCollisionType::Cylinder:
return 2;
case AttributeCollisionType::Box:
case AttributeCollisionType::Sphere:
case AttributeCollisionType::Aabb:
case AttributeCollisionType::Obb:
return type == static_cast<std::uint32_t>(AttributeCollisionType::Sphere) ? 1 : 3;
default:
return 0;
}
}
std::string fixed_name(const char (&raw)[32]) {
const void *nul = std::memchr(raw, '\0', 32);
const auto count = nul ? static_cast<const char *>(nul) - raw : 32;
return std::string(raw, static_cast<std::size_t>(count));
}
} // namespace
bool parse_attribute_data(const std::uint8_t *data, std::size_t size,
AttributeData &out, std::string *err) {
out = AttributeData{};
if (data == nullptr)
return err ? ((*err = "AttributeData buffer is null"), false) : false;
Reader r{data, size, 0, err};
char header[kHeaderSize] = {};
if (!r.read_bytes(header, kHeaderSize))
return false;
if (std::memcmp(header, kHeader, kHeaderSize) != 0)
return r.fail("AttributeData bad header");
std::uint32_t collision_count = 0;
std::uint32_t height_count = 0;
if (!r.read(collision_count) || !r.read(height_count))
return false;
if (collision_count > kMaxRecords || height_count > kMaxRecords)
return r.fail("AttributeData record count is unreasonable");
out.collisions.reserve(collision_count);
for (std::uint32_t i = 0; i < collision_count; ++i) {
AttributeCollision collision;
if (!r.read(collision.type))
return false;
const std::size_t dims = dimension_count(collision.type);
if (dims == 0)
return r.fail("AttributeData unknown collision type");
char raw_name[32] = {};
if (!r.read_bytes(raw_name, sizeof(raw_name)) ||
!r.read_bytes(collision.position.data(), sizeof(float) * 3) ||
!r.read_bytes(collision.dimensions.data(), sizeof(float) * dims) ||
!r.read_bytes(collision.quaternion.data(), sizeof(float) * 4))
return false;
collision.name = fixed_name(raw_name);
out.collisions.push_back(std::move(collision));
}
out.heights.reserve(height_count);
for (std::uint32_t i = 0; i < height_count; ++i) {
AttributeHeight height;
char raw_name[32] = {};
std::uint32_t vertex_count = 0;
if (!r.read_bytes(raw_name, sizeof(raw_name)) || !r.read(vertex_count))
return false;
if (vertex_count > kMaxHeightVertices)
return r.fail("AttributeData height vertex count is unreasonable");
if (vertex_count > 0 && vertex_count > (std::numeric_limits<std::size_t>::max() / 12))
return r.fail("AttributeData height size overflows");
if (!r.require(static_cast<std::size_t>(vertex_count) * 12))
return false;
height.name = fixed_name(raw_name);
height.vertices.resize(vertex_count);
for (auto &vertex : height.vertices) {
if (!r.read_bytes(vertex.data(), sizeof(float) * 3))
return false;
}
out.heights.push_back(std::move(height));
}
if (r.offset != size)
return r.fail("AttributeData trailing bytes");
return true;
}
bool parse_attribute_data_file(const std::string &path, AttributeData &out,
std::string *err) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) {
if (err)
*err = "cannot open AttributeData: " + path;
return false;
}
const std::streamoff end = file.tellg();
if (end < 0) {
if (err)
*err = "cannot stat AttributeData: " + path;
return false;
}
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(end));
file.seekg(0, std::ios::beg);
if (!bytes.empty() && !file.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
if (err)
*err = "cannot read AttributeData: " + path;
return false;
}
return parse_attribute_data(bytes.data(), bytes.size(), out, err);
}
} // namespace fmt
+56
View File
@@ -0,0 +1,56 @@
// .mdatr —— Metin2 AttributeData 静态碰撞 / 高度网格资源。
//
// The legacy client reads this file as a packed little-endian stream. Keep the
// format layer independent from Godot so it can be used by the world loader and
// by host-side validation tests alike.
#pragma once
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace fmt {
enum class AttributeCollisionType : std::uint32_t {
Plane = 0,
Box = 1,
Sphere = 2,
Cylinder = 3,
Aabb = 4,
Obb = 5,
};
struct AttributeCollision {
std::uint32_t type = 0;
std::string name;
std::array<float, 3> position = {0.0f, 0.0f, 0.0f};
// Plane/Cylinder use dimensions[0..1], the other supported primitives use
// dimensions[0..2]. Values retain the source units (centimetres).
std::array<float, 3> dimensions = {0.0f, 0.0f, 0.0f};
// x, y, z, w in the same order as the D3DXQUATERNION on disk.
std::array<float, 4> quaternion = {0.0f, 0.0f, 0.0f, 1.0f};
};
struct AttributeHeight {
std::string name;
// The legacy file calls these vertices "primitives". They are a flat
// triangle list: every consecutive three vertices form one triangle.
std::vector<std::array<float, 3>> vertices;
};
struct AttributeData {
std::vector<AttributeCollision> collisions;
std::vector<AttributeHeight> heights;
};
// Parse the complete byte buffer. The parser rejects truncated data, unknown
// primitive types and unreasonable counts rather than accepting a partial
// collision resource.
bool parse_attribute_data(const std::uint8_t *data, std::size_t size,
AttributeData &out, std::string *err = nullptr);
bool parse_attribute_data_file(const std::string &path, AttributeData &out,
std::string *err = nullptr);
} // namespace fmt
+186
View File
@@ -0,0 +1,186 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
#include "combo_table.h"
#include "m2_tokvec.h" // fmt::read_file
#include <cctype>
#include <cstdlib>
namespace fmt {
const std::vector<uint16_t>* ComboTable::get(uint16_t motion_mode, uint16_t combo_type) const {
auto it = combos.find(make_combo_key(motion_mode, combo_type));
return it == combos.end() ? nullptr : &it->second;
}
const ComboTable& PlayerComboTables::klass(int combo_class) const {
static const ComboTable kEmpty;
if (combo_class < 0 || combo_class >= COMBO_CLASS_COUNT) return kEmpty;
return per_class[combo_class];
}
namespace {
std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
bool starts_with(const std::string& s, const char* p) {
return s.rfind(p, 0) == 0;
}
// chr.MOTION_MODE_XXX -> CRaceMotionData::EMode 值。
bool resolve_motion_mode(const std::string& suffix, long& out) {
static const std::map<std::string, long> kMap = {
{"RESERVED", 0}, {"GENERAL", 1}, {"ONEHAND_SWORD", 2}, {"TWOHAND_SWORD", 3},
{"DUALHAND_SWORD", 4}, {"BOW", 5}, {"FAN", 6}, {"BELL", 7}, {"FISHING", 8},
{"HORSE", 9}, {"HORSE_ONEHAND_SWORD", 10}, {"HORSE_TWOHAND_SWORD", 11},
{"HORSE_DUALHAND_SWORD", 12}, {"HORSE_BOW", 13}, {"HORSE_FAN", 14},
{"HORSE_BELL", 15}, {"WEDDING_DRESS", 16},
};
auto it = kMap.find(suffix);
if (it == kMap.end()) return false;
out = it->second;
return true;
}
// COMBO_TYPE_N / COMBO_INDEX_N -> N-1(两者都是从 1 起的人读编号,值从 0 起)。
bool resolve_one_based(const std::string& tail, long& out) {
if (tail.empty()) return false;
char* end = nullptr;
long n = std::strtol(tail.c_str(), &end, 10);
if (end == tail.c_str() || *end != '\0' || n < 1) return false;
out = n - 1;
return true;
}
// 一个实参 token -> 整数。认符号常量,也认裸整数。
bool resolve_token(const std::string& tok_in, long& out) {
const std::string tok = trim(tok_in);
if (tok.empty()) return false;
if (starts_with(tok, "chr.MOTION_MODE_")) {
return resolve_motion_mode(tok.substr(sizeof("chr.MOTION_MODE_") - 1), out);
}
if (starts_with(tok, "chr.MOTION_COMBO_ATTACK_")) {
if (!resolve_one_based(tok.substr(sizeof("chr.MOTION_COMBO_ATTACK_") - 1), out)) return false;
out = COMBO_NAME_COMBO_ATTACK_1 + out; // 0-based n -> 段号 14 + n
return true;
}
if (tok == "chr.MOTION_NORMAL_ATTACK") { out = COMBO_NAME_NORMAL_ATTACK; return true; }
if (starts_with(tok, "COMBO_TYPE_")) { return resolve_one_based(tok.substr(sizeof("COMBO_TYPE_") - 1), out); }
if (starts_with(tok, "COMBO_INDEX_")) { return resolve_one_based(tok.substr(sizeof("COMBO_INDEX_") - 1), out); }
// 裸整数
char* end = nullptr;
long n = std::strtol(tok.c_str(), &end, 10);
if (end == tok.c_str() || *end != '\0') return false;
out = n;
return true;
}
// "a, b, c)"call 名后的部分)-> 解析出的整数实参。分号里没有嵌套括号。
bool parse_call_args(const std::string& after_paren, std::vector<long>& out) {
size_t close = after_paren.find(')');
if (close == std::string::npos) return false;
const std::string inner = after_paren.substr(0, close);
out.clear();
size_t start = 0;
while (start <= inner.size()) {
size_t comma = inner.find(',', start);
std::string tok = inner.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
tok = trim(tok);
if (!tok.empty()) {
long v = 0;
if (!resolve_token(tok, v)) return false;
out.push_back(v);
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return true;
}
int class_from_def_line(const std::string& line) {
if (line.find("def __LoadGameWarriorEx") != std::string::npos) return COMBO_CLASS_WARRIOR;
if (line.find("def __LoadGameAssassinEx") != std::string::npos) return COMBO_CLASS_ASSASSIN;
if (line.find("def __LoadGameSuraEx") != std::string::npos) return COMBO_CLASS_SURA;
if (line.find("def __LoadGameShamanEx") != std::string::npos) return COMBO_CLASS_SHAMAN;
return -1;
}
} // namespace
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err) {
out = PlayerComboTables{};
int cur_class = -1;
size_t pos = 0;
const size_t n = py_text.size();
while (pos < n) {
size_t eol = py_text.find('\n', pos);
std::string line = py_text.substr(pos, eol == std::string::npos ? std::string::npos : eol - pos);
pos = (eol == std::string::npos) ? n : eol + 1;
const std::string t = trim(line);
if (t.empty() || t[0] == '#') continue;
// 顶层 def 换块:只有四个 __LoadGame<Class>Ex 有连击段调用,其它 def 复位。
if (starts_with(line, "def ")) {
cur_class = class_from_def_line(line);
continue;
}
if (cur_class < 0) continue;
const char* kReserve = "chrmgr.ReserveComboAttackNew(";
const char* kRegister = "chrmgr.RegisterComboAttackNew(";
size_t r = t.find(kReserve);
if (r != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(r + std::string(kReserve).size()), args) || args.size() != 3) {
if (err) *err = "combo_table: 无法解析 ReserveComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long count = args[2] < 0 ? 0 : args[2];
// CRaceData::ReserveComboAttack 用 map::insert —— 已存在的 key 不覆盖。
out.per_class[cur_class].combos.emplace(
key, std::vector<uint16_t>(static_cast<size_t>(count), 0));
continue;
}
size_t g = t.find(kRegister);
if (g != std::string::npos) {
std::vector<long> args;
if (!parse_call_args(t.substr(g + std::string(kRegister).size()), args) || args.size() != 4) {
if (err) *err = "combo_table: 无法解析 RegisterComboAttackNew: " + t;
return false;
}
const uint32_t key = make_combo_key(static_cast<uint16_t>(args[0]), static_cast<uint16_t>(args[1]));
const long idx = args[2];
const uint16_t motion = static_cast<uint16_t>(args[3]);
auto it = out.per_class[cur_class].combos.find(key);
if (it == out.per_class[cur_class].combos.end()) continue; // 未 Reserve:跳过
if (idx < 0 || static_cast<size_t>(idx) >= it->second.size()) continue; // 越界:跳过
it->second[static_cast<size_t>(idx)] = motion;
continue;
}
}
if (err) err->clear();
return true;
}
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err) {
std::string s;
if (!read_file(path, s)) {
if (err) *err = "combo_table: 打不开 " + path;
return false;
}
return parse_player_combo_tables(s, out, err);
}
} // namespace fmt
+92
View File
@@ -0,0 +1,92 @@
// combo_table —— PC 连击段表(CRaceData::TComboAttackDataMap 的等价)。M2 §3.5。
//
// 参考端的 PC 连击段表**不在** .msm 里(.msm 只有 BaseModelFileName + HairData)。
// 它由 playersettingmodule.py 的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用在 CRaceData 上建起来(UserInterface/PythonCharacterManagerModule.cpp →
// CRaceData::ReserveComboAttack / RegisterComboAttackGameLib/RaceData.cpp)。
// 本解析器就把那批调用读成同一张表。
//
// NPC/怪物的连击段走另一条路(RaceManager.cpp __LoadRaceMotionList 读 motlist.txt
// 只在 MODE_GENERAL 下登记 NAME_COMBO_ATTACK_1/2/3,没有段号向量),不在这里。
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace fmt {
// CRaceMotionData::EModechr.MOTION_MODE_*)。combo key 的高 16 位。
enum ComboMotionMode : uint16_t {
COMBO_MODE_RESERVED = 0,
COMBO_MODE_GENERAL = 1,
COMBO_MODE_ONEHAND_SWORD = 2,
COMBO_MODE_TWOHAND_SWORD = 3,
COMBO_MODE_DUALHAND_SWORD = 4,
COMBO_MODE_BOW = 5,
COMBO_MODE_FAN = 6,
COMBO_MODE_BELL = 7,
COMBO_MODE_FISHING = 8,
COMBO_MODE_HORSE = 9,
COMBO_MODE_HORSE_ONEHAND_SWORD = 10,
COMBO_MODE_HORSE_TWOHAND_SWORD = 11,
COMBO_MODE_HORSE_DUALHAND_SWORD = 12,
COMBO_MODE_HORSE_BOW = 13,
COMBO_MODE_HORSE_FAN = 14,
COMBO_MODE_HORSE_BELL = 15,
COMBO_MODE_WEDDING_DRESS = 16,
};
// CRaceMotionData::EName 的攻击段(chr.MOTION_NORMAL_ATTACK / chr.MOTION_COMBO_ATTACK_*)。
// ComboIndexVector 里存的就是这些段号。
enum : uint16_t {
COMBO_NAME_NORMAL_ATTACK = 13,
COMBO_NAME_COMBO_ATTACK_1 = 14,
COMBO_NAME_COMBO_ATTACK_8 = 21,
};
// RaceData.h: MAKE_COMBO_KEY(motion_mode, combo_type)
inline uint32_t make_combo_key(uint16_t motion_mode, uint16_t combo_type) {
return (static_cast<uint32_t>(motion_mode) << 16) | static_cast<uint32_t>(combo_type);
}
inline uint16_t combo_key_mode(uint32_t key) { return static_cast<uint16_t>((key >> 16) & 0xFFFF); }
inline uint16_t combo_key_type(uint32_t key) { return static_cast<uint16_t>(key & 0xFFFF); }
// playersettingmodule.py 按 __LoadGame<Class>Ex(race, path) 分四块,男女同一函数体
// (连击段表与性别无关),所以这里按职业收,不按 race。
enum ComboClass : int {
COMBO_CLASS_WARRIOR = 0,
COMBO_CLASS_ASSASSIN = 1,
COMBO_CLASS_SURA = 2,
COMBO_CLASS_SHAMAN = 3,
COMBO_CLASS_COUNT = 4,
};
// CRaceData::TComboAttackDataMap 的等价:MAKE_COMBO_KEY -> ComboIndexVector。
struct ComboTable {
// key = make_combo_key(mode, type)value = 有序的 COMBO_NAME_COMBO_ATTACK_* 段号,
// 下标即 dwComboArrayIndexm_dwcurComboIndex - 1)。
std::map<uint32_t, std::vector<uint16_t>> combos;
// GetComboDataPointer:命中返回段号向量指针,否则 nullptr。
const std::vector<uint16_t>* get(uint16_t motion_mode, uint16_t combo_type) const;
bool empty() const { return combos.empty(); }
size_t key_count() const { return combos.size(); }
};
struct PlayerComboTables {
ComboTable per_class[COMBO_CLASS_COUNT];
// 越界返回一张空表(不抛)。
const ComboTable& klass(int combo_class) const;
};
// 解析 playersettingmodule.py 里的 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew
// 调用,按 def __LoadGame<Class>Ex 分块填 per_class。照 CRaceData 语义:
// Reserve -> combos[key] = vector(count, 0)(重复 key 不覆盖,同 std::map::insert
// Register -> combos[key][index] = motionkey 未 Reserve 或 index 越界则跳过)
// 认得的符号:chr.MOTION_MODE_*、chr.MOTION_COMBO_ATTACK_N=13+N)、
// chr.MOTION_NORMAL_ATTACK=13)、COMBO_TYPE_N=N-1)、COMBO_INDEX_N=N-1)、裸整数。
bool parse_player_combo_tables(const std::string& py_text, PlayerComboTables& out, std::string* err);
bool parse_player_combo_tables_file(const std::string& path, PlayerComboTables& out, std::string* err);
} // namespace fmt
+14
View File
@@ -140,6 +140,7 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
if (auto* l = root.kv("ScriptType")) out.script_type = l->size() > 1 ? (*l)[1] : "";
out.script_version = root.f("ScriptVersion", 1);
out.reserved = root.i("Reserved") != 0;
if (const Block* dl = root.child("DirectionalLight")) {
if (auto* d = dl->kv("Direction"))
@@ -163,15 +164,28 @@ bool parse_environment(const std::string& text, Environment& out, std::string* e
}
if (const Block* fg = root.child("Fog")) {
out.fog.fog_level = fg->i("foglevel", 0);
out.fog.density_fog = fg->i("IsDensity") != 0;
out.fog.near_distance = fg->f("NearDistance", 1);
out.fog.far_distance = fg->f("FarDistance", 1);
out.fog.color = rgba(fg->kv("Color"));
out.fog.enable = (fg->kv("Enable") && fg->i("Enable") != 0) || out.fog.fog_level > 0;
}
if (const Block* flt = root.child("Filter")) {
out.filter.enable = flt->i("Enable") != 0;
out.filter.color = rgba(flt->kv("Color"));
out.filter.alpha_src = flt->i("AlphaSrc", out.filter.alpha_src);
out.filter.alpha_dest = flt->i("AlphaDest", out.filter.alpha_dest);
}
if (const Block* sb = root.child("SkyBox")) {
out.sky.texture_render_mode = sb->i("BTextureRenderMode") != 0;
for (int k = 0; k < 3; ++k) out.sky.scale[k] = sb->f("Scale", k + 1);
out.sky.gradient_level_upper = sb->i("GradientLevelUpper");
out.sky.gradient_level_lower = sb->i("GradientLevelLower");
static constexpr const char* kFaceKeys[6] = {
"FrontFaceFileName", "BackFaceFileName", "LeftFaceFileName",
"RightFaceFileName", "TopFaceFileName", "BottomFaceFileName"};
for (int k = 0; k < 6; ++k)
out.sky.face_textures[k] = sb->s(kFaceKeys[k]);
for (int k = 0; k < 2; ++k) {
out.sky.cloud_scale[k] = sb->f("CloudScale", k + 1);
out.sky.cloud_texture_scale[k] = sb->f("CloudTextureScale", k + 1);
+22 -10
View File
@@ -13,26 +13,36 @@ using Vec3f = std::array<float, 3>;
using Vec2f = std::array<float, 2>;
struct EnvDirLight {
Vec3f direction{{0, 0, 0}};
Vec3f direction{{0.5f, 0.5f, -0.5f}};
bool bg_enable = false, ch_enable = false;
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0, 0, 0, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0, 0, 0, 1}};
Rgba bg_diffuse{{1, 1, 1, 1}}, bg_ambient{{0.5f, 0.5f, 0.5f, 1}};
Rgba ch_diffuse{{1, 1, 1, 1}}, ch_ambient{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvMaterial {
Rgba diffuse{{1, 1, 1, 1}}, ambient{{1, 1, 1, 1}}, emissive{{0, 0, 0, 1}};
Rgba diffuse{{0.8f, 0.8f, 0.8f, 1}}, ambient{{0.8f, 0.8f, 0.8f, 1}},
emissive{{0.8f, 0.8f, 0.8f, 1}};
};
struct EnvFog {
// 两种写法:老版 `Enable`/`NearDistance`/`FarDistance`A1 实盘用 `foglevel`0=关,>0=开)。
bool enable = false; // Enable != 0 || fog_level > 0
bool density_fog = false; // IsDensity(参考端保留,当前 Godot 用深度雾)
int fog_level = 0; // foglevel
float near_distance = 0, far_distance = 0;
Rgba color{{1, 1, 1, 1}};
float near_distance = 25600.0f * 0.5f, far_distance = 25600.0f * 0.7f;
Rgba color{{0.5f, 0.5f, 0.5f, 1}};
};
struct EnvFilter {
bool enable = false;
Rgba color{{0.3f, 0.1f, 0.1f, 0}};
int alpha_src = 1; // D3DBLEND_ONE
int alpha_dest = 1; // D3DBLEND_ONE
};
struct EnvSkyBox {
Vec3f scale{{1, 1, 1}};
Vec3f scale{{3500, 3500, 3500}};
bool texture_render_mode = false;
int gradient_level_upper = 0, gradient_level_lower = 0;
Vec2f cloud_scale{{1, 1}}, cloud_texture_scale{{1, 1}}, cloud_speed{{0, 0}};
float cloud_height = 0;
std::array<std::string, 6> face_textures{}; // front/back/left/right/top/bottom
Vec2f cloud_scale{{200000, 200000}}, cloud_texture_scale{{4, 4}}, cloud_speed{{0.001f, 0.001f}};
float cloud_height = 30000;
std::string cloud_texture;
std::vector<Rgba> cloud_color; // List CloudColor
std::vector<Rgba> gradient; // List Gradient(成对 = 一段渐变的上下色)
@@ -40,7 +50,7 @@ struct EnvSkyBox {
struct EnvLensFlare {
bool enable = false, main_flare_enable = false;
Rgba brightness_color{{1, 1, 1, 1}};
float max_brightness = 0, main_flare_size = 0;
float max_brightness = 1, main_flare_size = 0.2f;
std::string main_flare_texture;
};
@@ -50,8 +60,10 @@ struct Environment {
EnvDirLight dir_light;
EnvMaterial material;
EnvFog fog;
EnvFilter filter;
EnvSkyBox sky;
EnvLensFlare lens_flare;
bool reserved = false;
};
bool parse_environment(const std::string& text, Environment& out, std::string* err);
+66
View File
@@ -20,6 +20,70 @@ static void loop_data(const Node& root, Msa& out) {
}
}
// `List HitPosition { t lastXYZ XYZ t lastXYZ XYZ ... }` —— 7 个一组切成采样点。
static void hit_positions(const Node& win, std::vector<Msa::HitSample>& out) {
const Node* lst = win.group("HitPosition");
if (!lst || lst->lines.empty()) return;
const std::vector<std::string>& t = lst->lines[0];
for (size_t i = 0; i + 7 <= t.size(); i += 7) {
Msa::HitSample s;
s.time = float(std::atof(t[i].c_str()));
for (int k = 0; k < 3; ++k) s.last_pos[k] = float(std::atof(t[i + 1 + k].c_str()));
for (int k = 0; k < 3; ++k) s.pos[k] = float(std::atof(t[i + 4 + k].c_str()));
out.push_back(s);
}
}
// 一个命中窗(NRaceData::THitData)。老式写法里 win == AttackingData 组本身。
static Msa::HitWindow hit_window(const Node& win) {
Msa::HitWindow w;
w.start_time = win.num("AttackingStartTime");
w.end_time = win.num("AttackingEndTime");
w.bone_name = win.str("AttackingBone");
w.weapon_length = win.num("WeaponLength");
hit_positions(win, w.samples);
return w;
}
// Group ComboInputData / Group AttackingData —— 照 EterGrnLib CGrannyMotion::LoadMotionData
// 与 GameLib NRaceData::LoadMotionAttackData 的字段名读取;缺组时保持默认值。
static void combat_data(const Node& root, Msa& out) {
if (const Node* c = root.group("ComboInputData")) {
out.has_combo_input = true;
out.combo_pre_input_time = c->num("PreInputTime");
out.combo_direct_input_time = c->num("DirectInputTime");
out.combo_input_limit_time = c->num("InputLimitTime");
out.combo_link_time = c->num("LinkTime");
}
if (const Node* a = root.group("AttackingData")) {
out.has_attacking_data = true;
out.attacking_type = a->inum("AttackType", a->inum("AttackingType", 0));
out.motion_type = a->inum("MotionType", out.attacking_type);
out.hitting_type = a->inum("HittingType", 0);
out.stiffen_time = a->num("StiffenTime");
out.invisible_time = a->num("InvisibleTime");
out.external_force = a->num("ExternalForce");
out.hit_limit_count = a->inum("HitLimitCount", 0);
// 新式:HitDataCount N + Group HitData00..0N;老式:无 HitDataCount,组本身即窗。
bool has_children = false;
for (const Node& g : a->groups) {
const std::string& n = g.name;
if (n.size() >= 7 && (n.compare(0, 7, "HitData") == 0 || n.compare(0, 7, "hitdata") == 0)) {
out.hit_windows.push_back(hit_window(g));
has_children = true;
}
}
if (!has_children)
out.hit_windows.push_back(hit_window(*a));
if (!out.hit_windows.empty()) {
out.attack_start_time = out.hit_windows.front().start_time;
out.attack_end_time = out.hit_windows.front().end_time;
}
}
}
bool parse_msa(const std::string& text, Msa& out, std::string* err) {
Node root;
if (!parse_textscript(text, root, err)) return false;
@@ -29,6 +93,7 @@ bool parse_msa(const std::string& text, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData")) {
int count = med->inum("MotionEventDataCount", int(med->groups.size()));
@@ -57,6 +122,7 @@ bool parse_msa_file(const std::string& path, Msa& out, std::string* err) {
out.duration = root.num("MotionDuration");
vec3(root.find("Accumulation"), out.accumulation);
loop_data(root, out);
combat_data(root, out);
if (const Node* med = root.group("MotionEventData"))
for (const Node& ev : med->groups) {
MotionEvent e;
+45
View File
@@ -23,6 +23,51 @@ struct Msa {
bool loop_cancel_enable = false;
float loop_start_time = 0;
float loop_end_time = 0;
// Group ComboInputData —— 连击输入时间窗(EterGrnLib CGrannyMotion::LoadMotionData →
// CRaceMotionData::TComboInputData)。攻击节奏来自这里,不是硬编码间隔。
// PreInputTime → fInputStartTime 连击输入窗开始(可提前缓存下一击)
// DirectInputTime→ fNextComboTime 直接输入阈值 / 下一段连击起点(= 普攻节奏)
// InputLimitTime → fInputEndTime 连击输入窗关闭(超时归零)
// LinkTime → fComboLinkTime 段间衔接时间
bool has_combo_input = false;
float combo_pre_input_time = 0;
float combo_direct_input_time = 0;
float combo_input_limit_time = 0;
float combo_link_time = 0;
// Group AttackingData —— 命中判定窗(GameLib GameType.cpp NRaceData::TMotionAttackData)。
// 两种写法:老式(组内直接 AttackingStartTime/…/List HitPosition,无 HitDataCount=
// 单个命中窗;新式(AttackType + MotionType + HitDataCount N + Group HitData00..0N=
// N 个命中窗(多段挥击 / 旋风斩)。命中帧 / 硬直 / 无敌帧由动作数据驱动。
bool has_attacking_data = false;
int attacking_type = 0; // AttackType / AttackingTypeSAttackData::iAttackType
int motion_type = 0; // MotionType(缺省回退到 attacking_type
int hitting_type = 0; // HittingType
float stiffen_time = 0; // StiffenTime(受击硬直,喂给 §3.7 / 受击方)
float invisible_time = 0; // InvisibleTime(攻击者无敌帧)
float external_force = 0; // ExternalForce(击退力度)
int hit_limit_count = 0; // HitLimitCount0 = 不限)
// HitPosition 单个采样:time + 上一帧位置(xyz) + 当前帧位置(xyz),用于挥击扫掠球判定。
struct HitSample {
float time = 0;
float last_pos[3] = {0, 0, 0};
float pos[3] = {0, 0, 0};
};
// 一个命中窗(NRaceData::THitData)。
struct HitWindow {
float start_time = 0; // AttackingStartTime
float end_time = 0; // AttackingEndTime
std::string bone_name; // AttackingBone
float weapon_length = 0; // WeaponLength
std::vector<HitSample> samples; // List HitPosition
};
std::vector<HitWindow> hit_windows; // = THitDataContainer;老式写法长度为 1
// 兼容旧调用方:第一个命中窗的起止时间(无命中窗时为 0)。
float attack_start_time = 0;
float attack_end_time = 0;
};
bool parse_msa(const std::string& text, Msa& out, std::string* err);
+132
View File
@@ -0,0 +1,132 @@
#include "attribute_data.h"
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace {
int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
std::fprintf(stderr, "FAIL: %s (%s:%d)\n", (message), __FILE__, \
__LINE__); \
++failures; \
} \
} while (0)
template <typename T>
void append(std::vector<std::uint8_t> &bytes, const T &value) {
const auto *p = reinterpret_cast<const std::uint8_t *>(&value);
bytes.insert(bytes.end(), p, p + sizeof(T));
}
void append_name(std::vector<std::uint8_t> &bytes, const char *name) {
char fixed[32] = {};
std::strncpy(fixed, name, sizeof(fixed));
bytes.insert(bytes.end(), reinterpret_cast<std::uint8_t *>(fixed),
reinterpret_cast<std::uint8_t *>(fixed) + sizeof(fixed));
}
void append_collision(std::vector<std::uint8_t> &bytes, std::uint32_t type,
const char *name, const float *position,
const float *dimensions, const float *quaternion,
std::size_t dimension_count) {
append(bytes, type);
append_name(bytes, name);
for (std::size_t i = 0; i < 3; ++i)
append(bytes, position[i]);
for (std::size_t i = 0; i < dimension_count; ++i)
append(bytes, dimensions[i]);
for (std::size_t i = 0; i < 4; ++i)
append(bytes, quaternion[i]);
}
void test_synthetic() {
std::vector<std::uint8_t> bytes;
const char header[] = "AttributeData";
bytes.insert(bytes.end(), header, header + sizeof(header));
const std::uint32_t collisions = 3;
const std::uint32_t heights = 1;
append(bytes, collisions);
append(bytes, heights);
const float p0[] = {10, 20, 30}, d0[] = {40, 50}, q0[] = {0, 0, 0, 1};
append_collision(bytes, 0, "plane", p0, d0, q0, 2);
const float p1[] = {-1, 2, -3}, d1[] = {7}, q1[] = {1, 2, 3, 4};
append_collision(bytes, 2, "sphere", p1, d1, q1, 1);
const float p2[] = {4, 5, 6}, d2[] = {8, 9, 10}, q2[] = {0, 0.5f, 0, 0.5f};
append_collision(bytes, 5, "obb", p2, d2, q2, 3);
append_name(bytes, "height0");
const std::uint32_t vertex_count = 3;
append(bytes, vertex_count);
const float vertices[] = {0, 0, 0, 100, 0, 0, 0, 100, 0};
for (float value : vertices)
append(bytes, value);
fmt::AttributeData parsed;
std::string error;
CHECK(fmt::parse_attribute_data(bytes.data(), bytes.size(), parsed, &error),
"synthetic .mdatr parses");
CHECK(parsed.collisions.size() == 3 && parsed.heights.size() == 1,
"synthetic counts");
CHECK(parsed.collisions[0].name == "plane" &&
parsed.collisions[0].dimensions[1] == 50.0f,
"plane fields");
CHECK(parsed.collisions[1].type == 2 &&
parsed.collisions[1].dimensions[0] == 7.0f &&
parsed.collisions[1].quaternion[0] == 1.0f,
"sphere fields");
CHECK(parsed.collisions[2].name == "obb" &&
parsed.collisions[2].dimensions[2] == 10.0f,
"obb fields");
CHECK(parsed.heights[0].name == "height0" &&
parsed.heights[0].vertices.size() == 3 &&
parsed.heights[0].vertices[1][0] == 100.0f,
"height triangle fields");
auto truncated = bytes;
truncated.pop_back();
CHECK(!fmt::parse_attribute_data(truncated.data(), truncated.size(), parsed, &error),
"truncated .mdatr rejected");
auto trailing = bytes;
trailing.push_back(0);
CHECK(!fmt::parse_attribute_data(trailing.data(), trailing.size(), parsed, &error),
"trailing .mdatr rejected");
}
void test_real_asset() {
const char *root = std::getenv("M2_ASSETS");
if (!root || !*root)
return;
const std::string path = std::string(root) +
"/metin2_patch_eu3/ymir work/zone/devilcave/devil_entrance1f.mdatr";
fmt::AttributeData parsed;
std::string error;
CHECK(fmt::parse_attribute_data_file(path, parsed, &error),
"real devil_entrance1f.mdatr parses");
CHECK(parsed.collisions.size() == 9 && parsed.heights.empty(),
"real .mdatr collision/height count");
CHECK(parsed.collisions[0].type == 0 && parsed.collisions[0].name == "collision04",
"real .mdatr first collision");
}
} // namespace
int main() {
test_synthetic();
test_real_asset();
if (failures == 0) {
std::puts("PASS: formats_attribute_data_test");
return 0;
}
std::fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
+142
View File
@@ -0,0 +1,142 @@
// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。
// 照 GameLib/RaceData.cpp CRaceData::ReserveComboAttack / RegisterComboAttack 语义。
#include <combo_table.h>
#include <cstdio>
#include <cstdlib>
#include <string>
using namespace fmt;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", (msg)); \
++g_fail; \
} \
} while (0)
// warrior_m/_w 共用的 __LoadGameWarriorEx 片段 + assassin 的 DUALHAND(男限),
// 外加一个非 Ex 的 def 用来验证块复位。缩进 / tab 混用照原文。
static const char *kPy = R"PY(
COMBO_TYPE_1 = 0
COMBO_TYPE_2 = 1
COMBO_TYPE_3 = 2
COMBO_INDEX_1 = 0
COMBO_INDEX_6 = 5
def __LoadGameWarriorEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
## Combo Type 1
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, 4)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_1, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_4)
## Combo Type 2 —— 段号跳到 5 / 7(非连续,验证按下标写而非追加)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_1, chr.MOTION_COMBO_ATTACK_1)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_2, chr.MOTION_COMBO_ATTACK_2)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_3, chr.MOTION_COMBO_ATTACK_3)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_4, chr.MOTION_COMBO_ATTACK_5)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, COMBO_INDEX_5, chr.MOTION_COMBO_ATTACK_7)
# 越界的 Register(下标 5 >= size 5):照参考端 AssertLog + return,忽略
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 5, chr.MOTION_COMBO_ATTACK_8)
# 对已存在 key 的重复 Reservemap::insert 不覆盖,原向量保留
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_ONEHAND_SWORD, COMBO_TYPE_2, 99)
def __LoadGameAssassinEx(race, path):
chrmgr.SelectRace(race)
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, 6)
chrmgr.RegisterComboAttackNew(chr.MOTION_MODE_DUALHAND_SWORD, COMBO_TYPE_3, COMBO_INDEX_6, chr.MOTION_COMBO_ATTACK_8)
def __LoadGameNPC():
# 块外的调用不该落到任何职业里
chrmgr.ReserveComboAttackNew(chr.MOTION_MODE_GENERAL, COMBO_TYPE_1, 3)
)PY";
static void check_vec(const std::vector<uint16_t> *v, std::initializer_list<uint16_t> want, const char *msg) {
if (!v) { std::fprintf(stderr, "FAIL: %s (nullptr)\n", msg); ++g_fail; return; }
if (v->size() != want.size()) { std::fprintf(stderr, "FAIL: %s (size %zu != %zu)\n", msg, v->size(), want.size()); ++g_fail; return; }
size_t i = 0;
for (uint16_t w : want) {
if ((*v)[i] != w) { std::fprintf(stderr, "FAIL: %s ([%zu]=%u != %u)\n", msg, i, (*v)[i], w); ++g_fail; return; }
++i;
}
}
int main() {
// ---- key 编码:RaceData.h MAKE_COMBO_KEY -------------------------------
CHECK(make_combo_key(COMBO_MODE_ONEHAND_SWORD, 1) == ((2u << 16) | 1u), "make_combo_key");
CHECK(combo_key_mode(make_combo_key(5, 2)) == 5 && combo_key_type(make_combo_key(5, 2)) == 2, "combo_key split");
// ---- 解析 ------------------------------------------------------------
PlayerComboTables tbl;
std::string err;
if (!parse_player_combo_tables(kPy, tbl, &err)) {
std::fprintf(stderr, "parse failed: %s\n", err.c_str());
return 1;
}
const ComboTable &w = tbl.klass(COMBO_CLASS_WARRIOR);
const ComboTable &a = tbl.klass(COMBO_CLASS_ASSASSIN);
const ComboTable &s = tbl.klass(COMBO_CLASS_SURA);
// GENERAL / type1 —— 单段普攻连击(NAME_COMBO_ATTACK_1 = 14
check_vec(w.get(COMBO_MODE_GENERAL, 0), {14}, "warrior GENERAL type1 = [14]");
// ONEHAND_SWORD / type1 —— 连续 4 段 14..17
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 0), {14, 15, 16, 17}, "warrior 1H type1 = [14,15,16,17]");
// ONEHAND_SWORD / type2 —— 5 段,末两段跳到 18 / 20COMBO_ATTACK_5 / _7
check_vec(w.get(COMBO_MODE_ONEHAND_SWORD, 1), {14, 15, 16, 18, 20}, "warrior 1H type2 = [14,15,16,18,20]");
// 越界 Register 被忽略;重复 Reserve 不覆盖(size 仍是 5,不是 99)
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 1)->size() == 5, "dup Reserve ignored / OOB Register ignored");
// type3 从没 Reserve 过 -> GetComboDataPointer 应为 nullptr
CHECK(w.get(COMBO_MODE_ONEHAND_SWORD, 2) == nullptr, "warrior 1H type3 absent -> nullptr");
// 职业隔离:DUALHAND 只在 assassin 块里;warrior/sura 没有
check_vec(a.get(COMBO_MODE_DUALHAND_SWORD, 2), {0, 0, 0, 0, 0, 21}, "assassin DUAL type3 idx5 = COMBO_ATTACK_8(21)");
CHECK(w.get(COMBO_MODE_DUALHAND_SWORD, 2) == nullptr, "warrior has no DUALHAND table");
CHECK(s.empty(), "sura block absent in snippet -> empty table");
// 块外(__LoadGameNPC)的 Reserve 不该落进任何职业
size_t total = 0;
for (int c = 0; c < COMBO_CLASS_COUNT; ++c) total += tbl.klass(c).key_count();
CHECK(total == 4, "only the 4 in-Ex-block combo keys registered (2 warrior 1H + 1 warrior GENERAL + 1 assassin DUAL)");
// 越界职业索引 -> 空表,不崩
CHECK(tbl.klass(-1).empty() && tbl.klass(99).empty(), "out-of-range class -> empty");
// ---- 可选:真实 playersettingmodule.py(设了 M2_ASSETS 才跑)----------
if (const char *root = std::getenv("M2_ASSETS")) {
const std::string path = std::string(root) + "/root/playersettingmodule.py";
PlayerComboTables live;
std::string lerr;
if (parse_player_combo_tables_file(path, live, &lerr)) {
check_vec(live.klass(COMBO_CLASS_WARRIOR).get(COMBO_MODE_ONEHAND_SWORD, 0),
{14, 15, 16, 17}, "live warrior 1H type1");
// sura ONEHAND type3 末段回到 COMBO_ATTACK_4 (17)
const auto *sv = live.klass(COMBO_CLASS_SURA).get(COMBO_MODE_ONEHAND_SWORD, 2);
CHECK(sv && sv->size() == 6 && sv->back() == 17, "live sura 1H type3 tail = COMBO_ATTACK_4");
// shaman FAN type1 = 4 连续段
check_vec(live.klass(COMBO_CLASS_SHAMAN).get(COMBO_MODE_FAN, 0),
{14, 15, 16, 17}, "live shaman FAN type1");
std::printf("live playersettingmodule.py: OK\n");
} else {
std::fprintf(stderr, "live parse skipped: %s\n", lerr.c_str());
}
}
if (g_fail == 0) {
std::printf("PASS: formats_combo_table_test (§3.5 PC 连击段表)\n");
}
return g_fail == 0 ? 0 : 1;
}
+27
View File
@@ -199,12 +199,22 @@ Group DirectionalLight
Group Fog
{
foglevel 6
IsDensity 1
Color 0.69 0.74 0.83 1.0
}
Group Filter
{
Enable 1
Color 0.1 0.2 0.3 0.4
AlphaSrc 5
AlphaDest 6
}
Group SkyBox
{
BTextureRenderMode 1
Scale 3500.0 3500.0 3500.0
CloudTextureFileName "d:/ymir work/environment/clouds_zone01.tga"
FrontFaceFileName "d:/ymir work/environment/skybox/front.dds"
List Gradient
{
0.09 0.28 0.65 0.0
@@ -221,6 +231,12 @@ Group SkyBox
CHECK(e.dir_light.bg_enable && e.dir_light.ch_enable, "env bg/ch enable");
CHECK(std::fabs(e.dir_light.bg_diffuse[1] - 0.97f) < 1e-3f, "env bg diffuse");
CHECK(e.fog.enable && e.fog.fog_level == 6, "env fog (foglevel form)");
CHECK(e.fog.density_fog, "env density fog flag");
CHECK(e.filter.enable && e.filter.alpha_src == 5 && e.filter.alpha_dest == 6,
"env screen filter fields");
CHECK(std::fabs(e.filter.color[2] - 0.3f) < 1e-3f, "env screen filter color");
CHECK(e.sky.texture_render_mode && e.sky.face_textures[0].find("front.dds") != std::string::npos,
"env skybox texture mode + front face");
CHECK(std::fabs(e.sky.scale[0] - 3500.0f) < 1e-1f, "env skybox scale");
CHECK(e.sky.cloud_texture.find("clouds_zone01.tga") != std::string::npos, "env cloud tex path");
CHECK(e.sky.gradient.size() == 3, "env List Gradient rows");
@@ -452,6 +468,17 @@ static void test_live(const std::string& assets) {
CHECK(env.script_type == "EnvrionmentData", "live msenv script_type");
CHECK(env.fog.enable && env.sky.gradient.size() >= 8, "live msenv fog + gradient list");
fmt::Environment trent;
CHECK(fmt::parse_environment_file(
assets + "/season2/season2/metin2_map_trent02_a/trent02.msenv", trent, &err),
("live trent02.msenv: " + err).c_str());
CHECK(trent.dir_light.ch_enable && trent.dir_light.ch_ambient[0] > 0.39f,
"live trent character light");
CHECK(!trent.filter.enable && trent.filter.alpha_src == 2 && trent.filter.alpha_dest == 2,
"live trent filter fields");
CHECK(trent.sky.cloud_color.size() == 2 && trent.lens_flare.enable,
"live trent cloud gradient + lens flare");
// Property CRC 注册表
fmt::PropertyRegistry reg;
CHECK(reg.scan(assets + "/Property", &err), ("live Property scan: " + err).c_str());
+197
View File
@@ -30,5 +30,202 @@ Group LoopData
std::fprintf(stderr, "LoopData fields were not preserved\n");
return 1;
}
// A motion with no ComboInputData / AttackingData leaves the flags clear.
if (msa.has_combo_input || msa.has_attacking_data) {
std::fprintf(stderr, "combat groups reported present when absent\n");
return 1;
}
// CLIENT-GAP §3.3 / §3.5 —— real onehand_sword/combo_01.msa field values.
const std::string combo = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/onehand_sword/combo_01.gr2"
MotionDuration 1.000000
Accumulation 0.00 -131.76 0.00
Group ComboInputData
{
PreInputTime 0.167094
DirectInputTime 0.533333
InputLimitTime 0.602564
LinkTime 0.058889
}
Group AttackingData
{
AttackingType 2
HittingType 2
StiffenTime 0.0
InvisibleTime 0.100000
ExternalForce 8.000000
AttackingStartTime 0.192308
AttackingEndTime 0.315385
}
)";
fmt::Msa cm;
if (!fmt::parse_msa(combo, cm, &err)) {
std::fprintf(stderr, "combo parse failed: %s\n", err.c_str());
return 1;
}
const bool combo_ok = cm.has_combo_input &&
std::fabs(cm.combo_pre_input_time - 0.167094f) < 1e-5f &&
std::fabs(cm.combo_direct_input_time - 0.533333f) < 1e-5f &&
std::fabs(cm.combo_input_limit_time - 0.602564f) < 1e-5f &&
std::fabs(cm.combo_link_time - 0.058889f) < 1e-5f;
const bool atk_ok = cm.has_attacking_data && cm.attacking_type == 2 && cm.hitting_type == 2 &&
std::fabs(cm.attack_start_time - 0.192308f) < 1e-5f &&
std::fabs(cm.attack_end_time - 0.315385f) < 1e-5f &&
std::fabs(cm.invisible_time - 0.1f) < 1e-5f &&
std::fabs(cm.external_force - 8.0f) < 1e-5f;
if (!combo_ok || !atk_ok) {
std::fprintf(stderr, "ComboInputData / AttackingData fields were not preserved\n");
return 1;
}
// §3.5 修改 1 —— 老式内联写法(无 HitDataCount):单个命中窗,含 List HitPosition 采样。
const std::string inl = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/general/attack.gr2"
MotionDuration 1.0
Group AttackingData
{
AttackingType 1
HittingType 2
StiffenTime 0.0
InvisibleTime 0.5
ExternalForce 0.0
AttackingStartTime 0.456410
AttackingEndTime 0.597436
AttackingBone "equip_right_hand"
WeaponLength 0.0
List HitPosition
{
0.456410 1.0 2.0 3.0 4.0 5.0 6.0
0.464744 7.0 8.0 9.0 10.0 11.0 12.0
}
}
)";
fmt::Msa im;
if (!fmt::parse_msa(inl, im, &err)) {
std::fprintf(stderr, "inline attacking parse failed: %s\n", err.c_str());
return 1;
}
if (im.hit_windows.size() != 1 || im.hit_windows[0].samples.size() != 2 ||
im.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(im.hit_windows[0].start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].end_time - 0.597436f) > 1e-5f ||
std::fabs(im.attack_start_time - 0.456410f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].time - 0.464744f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].last_pos[0] - 7.0f) > 1e-5f ||
std::fabs(im.hit_windows[0].samples[1].pos[2] - 12.0f) > 1e-5f) {
std::fprintf(stderr, "inline HitData window / HitPosition samples not preserved\n");
return 1;
}
// §3.5 修改 1 —— 新式写法:AttackType + MotionType + HitDataCount N + Group HitData0N。
const std::string multi = R"(
ScriptType MotionData
MotionFileName "d:/ymir work/pc/warrior/twohand_sword/combo_05.gr2"
MotionDuration 1.6
Group AttackingData
{
AttackType 0
HittingType 2
StiffenTime 0.0
InvisibleTime 0.2
ExternalForce 5.0
HitLimitCount 3
MotionType 2
HitDataCount 2
Group HitData00
{
AttackingStartTime 0.455475
AttackingEndTime 0.759124
AttackingBone "equip_right_hand"
WeaponLength 150.0
List HitPosition
{
0.455475 54.5 -30.5 71.1 89.9 105.7 19.4
}
}
Group HitData01
{
AttackingStartTime 0.9
AttackingEndTime 1.1
AttackingBone "equip_left_hand"
WeaponLength 120.0
}
}
)";
fmt::Msa mm;
if (!fmt::parse_msa(multi, mm, &err)) {
std::fprintf(stderr, "multi HitData parse failed: %s\n", err.c_str());
return 1;
}
if (mm.attacking_type != 0 || mm.motion_type != 2 || mm.hit_limit_count != 3 ||
mm.hit_windows.size() != 2 ||
std::fabs(mm.hit_windows[0].start_time - 0.455475f) > 1e-5f ||
mm.hit_windows[0].samples.size() != 1 ||
mm.hit_windows[1].bone_name != "equip_left_hand" ||
std::fabs(mm.hit_windows[1].end_time - 1.1f) > 1e-5f ||
mm.hit_windows[1].samples.size() != 0 ||
std::fabs(mm.attack_start_time - 0.455475f) > 1e-5f) {
std::fprintf(stderr, "multi HitData windows not preserved\n");
return 1;
}
// ---- 可选:真实 PC .msa(设了 M2_ASSETS 才跑)----------------------------
if (const char* root = std::getenv("M2_ASSETS")) {
const std::string base = std::string(root) + "/PC/ymir work/pc/warrior/";
fmt::Msa a1;
if (fmt::parse_msa_file(base + "general/attack.msa", a1, &err)) {
if (a1.hit_windows.size() != 1 || a1.hit_windows[0].bone_name != "equip_right_hand" ||
std::fabs(a1.hit_windows[0].start_time - 0.456410f) > 1e-4f ||
std::fabs(a1.hit_windows[0].end_time - 0.597436f) > 1e-4f ||
a1.hit_windows[0].samples.size() != 17) {
std::fprintf(stderr, "live general/attack.msa window/samples wrong "
"(windows=%zu samples=%zu)\n", a1.hit_windows.size(),
a1.hit_windows.empty() ? 0 : a1.hit_windows[0].samples.size());
return 1;
}
}
fmt::Msa c5;
if (fmt::parse_msa_file(base + "twohand_sword/combo_05.msa", c5, &err)) {
if (c5.hit_windows.size() != 1 || c5.motion_type != 2 ||
std::fabs(c5.hit_windows[0].weapon_length - 150.0f) > 1e-3f ||
std::fabs(c5.hit_windows[0].start_time - 0.455475f) > 1e-4f ||
c5.hit_windows[0].samples.size() != 37) {
std::fprintf(stderr, "live twohand_sword/combo_05.msa wrong "
"(windows=%zu samples=%zu)\n", c5.hit_windows.size(),
c5.hit_windows.empty() ? 0 : c5.hit_windows[0].samples.size());
return 1;
}
}
// 真正的双持双命中窗(右手窗 + 左手窗)。
const std::string adh = std::string(root) + "/PC/ymir work/pc/assassin/dualhand_sword/combo_01.msa";
fmt::Msa dh;
if (fmt::parse_msa_file(adh, dh, &err)) {
if (dh.hit_windows.size() != 2 ||
dh.hit_windows[0].bone_name != "Bip01 R Hand" ||
dh.hit_windows[1].bone_name != "Bip01 L Hand" ||
dh.hit_windows[0].samples.size() != 13 ||
dh.hit_windows[1].samples.size() != 13 ||
std::fabs(dh.hit_windows[1].start_time - 0.605128f) > 1e-4f) {
std::fprintf(stderr, "live assassin dualhand combo_01.msa two-window wrong "
"(windows=%zu)\n", dh.hit_windows.size());
return 1;
}
}
fmt::Msa pb;
if (fmt::parse_msa_file(base + "skill/palbang.msa", pb, &err)) {
// AttackingData 后还有 Group MotionEventData —— List 修复后二者都要解析到。
if (pb.hit_windows.size() != 1 || pb.hit_limit_count != 18 ||
std::fabs(pb.hit_windows[0].weapon_length - 130.0f) > 1e-3f ||
pb.events.size() != 3) {
std::fprintf(stderr, "live skill/palbang.msa wrong "
"(windows=%zu events=%zu)\n", pb.hit_windows.size(), pb.events.size());
return 1;
}
}
}
return 0;
}
+6 -3
View File
@@ -48,11 +48,14 @@ bool parse_body(const std::vector<std::string>& t, size_t& i, Node& node, std::s
while (i < t.size()) {
const std::string& tok = t[i];
if (tok == "}") { ++i; return true; }
if (ieq(tok, "Group")) {
if (i + 2 >= t.size()) { if (err) *err = "Group 缺名字 / {"; return false; }
// `Group Name { ... }` 和 `List Name { ... }`CTextFileLoader 的 GetTokenVector
// 都当子节点:Group 体是 key/子组,List 体是一串裸值(收进子节点的单行)。
if (ieq(tok, "Group") || ieq(tok, "List")) {
const char* kind = ieq(tok, "Group") ? "Group" : "List";
if (i + 2 >= t.size()) { if (err) *err = std::string(kind) + " 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = "Group '" + sub.name + "' 后缺 {"; return false; }
if (t[i + 2] != "{") { if (err) *err = std::string(kind) + " '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));
+46
View File
@@ -0,0 +1,46 @@
# ambience_test —— AreaAmbienceData + .pra -> Audio source contract.
# godot --headless --path project --script ambience_test.gd
extends SceneTree
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
if not ClassDB.class_exists("Metin2World"):
printerr("FAIL: Metin2World extension is not registered")
quit(1)
return
var assets := AssetRoot.path()
var world: Node = ClassDB.instantiate("Metin2World")
world.set("auto_load", false)
world.set("assets_root", assets)
world.set("map_path", "OutdoorA3/metin2_map_a3")
world.set("focus_tile", Vector2i(3, 3))
world.set("load_radius_tiles", 0)
world.set("splat_enabled", false)
world.set("water_enabled", false)
world.set("env_enabled", false)
get_root().add_child(world)
var ok: bool = world.call("load_map")
_ck(ok, "A3 map chunk loads")
var sources: Array = world.call("get_ambience_sources")
_ck(sources.size() == 1, "A3 chunk exposes one ambience source")
if sources.size() == 1:
var source: Dictionary = sources[0]
_ck(int(source.get("range_cm", 0)) == 3300, "ambience range is preserved in cm")
_ck(String(source.get("play_type", "")).to_upper() == "LOOP", "ambience play type is LOOP")
var sounds: Array = source.get("sounds", [])
_ck(sounds.size() == 1 and String(sounds[0]) == "sound/ambience/warp_test.mp3",
".pra sound vector is exposed to Audio")
world.queue_free()
await process_frame
if _fail == 0:
print("PASS: ambience_test (AreaAmbienceData + .pra + world/audio contract)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://p7skimuw8kud
+70 -16
View File
@@ -1,20 +1,23 @@
# AppFlow (P10) —— 登录 → 选人 → 进游戏 的串场控制器。
#
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen / ReconnectUI 常驻。
# 把它做场景根:一个 M2Client 贯穿全程,LoadingScreen 常驻。
# var af := preload("res://app_flow.gd").new()
# add_child(af)
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:在 GAME 里保留场景交给 ReconnectUI 自动重连;在 LOGIN/SELECT 回登录。
# 断线:登录/选人阶段回到登录;游戏阶段保留 GameScene,显示重连遮罩,
# 重连成功后自动重新进入上次角色,避免短暂闪回选人界面。
extends Node
const ServerInfoRes = preload("res://net/serverinfo.gd")
const ChannelStatus = preload("res://net/channel_status.gd")
const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const GameScene = preload("res://game_scene.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const UiProfile = preload("res://ui/ui_profile.gd")
enum { LOGIN, SELECT, GAME }
@@ -24,17 +27,20 @@ var _state := LOGIN
var _assets := ""
var _ui: CanvasLayer # 登录 / 选人的临时界面层
var _loading: CanvasLayer
var _reconnect: CanvasLayer
var _game: Node
var _lifecycle: Node # 全流程唯一生命周期协调者
var _reconnect: Node
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
var _sel_server := 0
var _sel_channel := 1
var _id := "admin"
var _pw := "123456789"
var _id := OS.get_environment("MT_ACCOUNT")
var _pw := OS.get_environment("MT_PASSWORD")
var _chars: Array = []
var _selected_char_slot := -1
var _reconnecting_game := false
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1"
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root
@@ -53,9 +59,18 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_loading = LoadingScreen.new()
add_child(_loading)
_loading.setup(client)
# §9.3:生命周期属于 AppFlow,而不是会被切屏销毁的 GameScene。
# 这样登录、选人、进游戏始终只有一个通知入口;Audio 在 GameScene
# 完成装配后再绑定,客户端从 start() 起就已经绑定。
_lifecycle = AppLifecycle.new()
_lifecycle.name = "AppLifecycle"
add_child(_lifecycle)
_lifecycle.bind(client)
_reconnect = ReconnectUI.new()
_reconnect.name = "ReconnectUI"
add_child(_reconnect)
_reconnect.setup(client)
_reconnect.set_mobile_mode(UiProfile.resolve() == UiProfile.Mode.MOBILE)
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -76,6 +91,16 @@ func _wire_client() -> void:
func _on_char_list(list: Array) -> void:
_chars = list
if _reconnecting_game:
# A full reconnect replays the character list before it can enter the
# previous character. Do not briefly replace the live game with SELECT;
# re-enter the remembered slot and keep the reconnect mask on screen.
var slot := _selected_char_slot
if slot < 0 and not _chars.is_empty():
slot = int(_chars[0].get("index", 0))
if slot >= 0:
_enter_character(slot)
return
# 已在选人页(建号 / 删号后 M2Client 会重发 char_list)——只刷数据,别整屏重建
if _state == SELECT and _ui:
var sc: Node = _ui.get_node_or_null("CharSelect")
@@ -83,8 +108,12 @@ func _on_char_list(list: Array) -> void:
sc.set_chars(list)
return
_goto_select()
if auto_login and not _chars.is_empty() and client.has_method("select_character"):
client.select_character(int(_chars[0].get("index", 0)))
if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0))
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
@@ -92,6 +121,9 @@ func _on_char_name_changed(pid: int, name: String) -> void:
sc.on_char_name_changed(pid, name)
func _on_entered_game() -> void:
_reconnecting_game = false
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_goto_game()
_start_guild_mark_download()
@@ -117,15 +149,25 @@ func _on_guild_mark_updated(_guild_id: int, _img_idx: int) -> void:
_start_guild_mark_download()
func _on_login_failed(reason: String) -> void:
if _reconnecting_game:
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
if _state != GAME:
_goto_login()
_set_status("登录失败:%s" % reason)
func _on_disconnected(_reason: String) -> void:
# GAME 里:保留 game_sceneReconnectUI 负责重连遮罩
# LOGIN/SELECT 里:回登录
if _state != GAME:
_goto_login()
func _on_disconnected(reason: String) -> void:
if _state == GAME and _game != null and is_instance_valid(_game):
_reconnecting_game = true
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
_reconnecting_game = false
_goto_login()
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_set_status("连接断开:%s" % reason)
# --- 切屏 ------------------------------------------------------------------
@@ -136,6 +178,10 @@ func _clear_ui() -> void:
func _goto_login() -> void:
_state = LOGIN
_reconnecting_game = false
if _lifecycle:
# GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。
_lifecycle.bind(client)
if _game:
_game.queue_free()
_game = null
@@ -168,6 +214,8 @@ func _goto_game() -> void:
add_child(_game)
if _game.has_method("setup"):
await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket
if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"):
_lifecycle.bind(client, _game.lifecycle_audio())
if _loading:
_loading.hide_screen()
@@ -323,8 +371,7 @@ func _build_char_list() -> void:
_ui.add_child(screen)
screen.setup(client, _assets, _chars)
screen.select_requested.connect(func(idx: int):
if client.has_method("select_character"):
client.select_character(idx))
_enter_character(idx))
screen.back_requested.connect(func():
if client.has_method("disconnect_from_server"):
client.disconnect_from_server()
@@ -363,3 +410,10 @@ func _on_marks_ready_for_select() -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui else null
if sc and sc.has_method("refresh_crest"):
sc.refresh_crest()
func _enter_character(idx: int) -> void:
_selected_char_slot = idx
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
+78
View File
@@ -0,0 +1,78 @@
# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。
# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycleGameScene 不重复创建。
extends SceneTree
const AppFlow = preload("res://app_flow.gd")
class FakeClient extends Node:
signal phase_changed(phase: String)
signal disconnected(reason: String)
signal char_list(characters: Array)
signal char_name_changed(pid: int, name: String)
signal entered_game()
signal login_failed(reason: String)
signal char_created(slot: int)
signal char_create_failed(reason: int)
signal char_deleted(slot: int)
signal char_delete_failed()
var selects := []
var reconnects := 0
func select_character(index: int) -> bool:
selects.append(index)
return true
func reconnect() -> bool:
reconnects += 1
return true
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var client := FakeClient.new()
var flow := AppFlow.new()
flow.build_game_scene = false
get_root().add_child(flow)
flow.start("", client)
await process_frame
var lifecycle_nodes: Array[Node] = []
for child in flow.get_children():
if child.name == "AppLifecycle":
lifecycle_nodes.append(child)
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
_ck(flow.get_node_or_null("AppLifecycle") != null,
"AppLifecycle survives login screen construction")
_ck(client.get_parent() == flow,
"AppFlow keeps the same client beside lifecycle coordinator")
# A live game keeps its scene during a transient disconnect. ReconnectUI
# owns the retry while the next character list silently re-enters the last
# selected slot instead of flashing the character-select page.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._selected_char_slot = 2
client.disconnected.emit("wifi lost")
_ck(flow.state() == AppFlow.GAME and flow._reconnecting_game,
"game disconnect enters reconnecting state without leaving GAME")
_ck(flow._reconnect != null and flow._reconnect.is_showing(),
"game disconnect shows reconnect overlay")
client.char_list.emit([{"index": 2, "name": "Hero"}])
_ck(client.selects == [2], "reconnect re-enters remembered character slot")
client.entered_game.emit()
_ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game
and not flow._reconnect.is_showing(),
"successful reconnect restores GAME and hides overlay")
flow.queue_free()
await process_frame
if _fail == 0:
print("PASS: app_flow_lifecycle_test (single lifecycle owner)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://dn635j1v44kp5
+10 -1
View File
@@ -64,7 +64,13 @@ func _enter_background() -> void:
if background_max_fps > 0:
_saved_max_fps = Engine.max_fps
Engine.max_fps = background_max_fps
if _bound_audio and _bound_audio.has_method("stop_bgm"):
# §9.2:不停 BGM,改为「保存音量 + 静音」(对标 CSoundManager::SaveVolume 后
# SetMusicVolume(0))。_bgm_name 不清空,回前台恢复音量即可同一首继续播。
if _bound_audio and _bound_audio.has_method("save_volume"):
_bound_audio.save_volume()
if _bound_audio.has_method("set_music_volume"):
_bound_audio.set_music_volume(0.0)
elif _bound_audio and _bound_audio.has_method("stop_bgm"):
_bound_audio.stop_bgm()
if _bound_client and _bound_client.has_method("suspend"):
_bound_client.suspend()
@@ -80,6 +86,9 @@ func _exit_background() -> void:
get_tree().paused = false
if background_max_fps > 0:
Engine.max_fps = _saved_max_fps
# §9.2:还原后台前保存的音量。
if _bound_audio and _bound_audio.has_method("restore_volume"):
_bound_audio.restore_volume()
if _bound_client and _bound_client.has_method("resume"):
_bound_client.resume()
resumed.emit()
+48
View File
@@ -67,6 +67,54 @@ func _run() -> void:
drag.position = Vector2(140, 120)
atlas._on_map_input(drag)
_ck(atlas._map.position != old_pos, "atlas left drag moves map")
# 移动端地图用单指平移;第二根手指出现后,当前拖动必须被冻结,不能
# 把双指手势继续解释成地图移动或世界点地。
atlas._recenter_map()
var single_down := InputEventScreenTouch.new()
single_down.index = 4
single_down.pressed = true
single_down.position = Vector2(120, 120)
atlas._on_map_input(single_down)
var single_drag := InputEventScreenDrag.new()
single_drag.index = 4
single_drag.position = Vector2(160, 145)
single_drag.relative = Vector2(40, 25)
atlas._on_map_input(single_drag)
_ck(atlas._map.position != atlas._map_origin, "atlas single-finger touch drag moves map")
var single_up := InputEventScreenTouch.new()
single_up.index = 4
single_up.pressed = false
single_up.position = single_drag.position
atlas._on_map_input(single_up)
atlas._recenter_map()
var first_down := InputEventScreenTouch.new()
first_down.index = 5
first_down.pressed = true
first_down.position = Vector2(120, 120)
atlas._on_map_input(first_down)
var second_down := InputEventScreenTouch.new()
second_down.index = 6
second_down.pressed = true
second_down.position = Vector2(300, 120)
atlas._on_map_input(second_down)
var before_two_finger_drag: Vector2 = atlas._map.position
var two_finger_drag := InputEventScreenDrag.new()
two_finger_drag.index = 5
two_finger_drag.position = Vector2(220, 220)
two_finger_drag.relative = Vector2(100, 100)
atlas._on_map_input(two_finger_drag)
_ck(atlas._map.position == before_two_finger_drag,
"two-finger map gesture does not pan from one finger")
var first_up := InputEventScreenTouch.new()
first_up.index = 5
first_up.pressed = false
first_up.position = two_finger_drag.position
atlas._on_map_input(first_up)
var second_up := InputEventScreenTouch.new()
second_up.index = 6
second_up.pressed = false
second_up.position = Vector2(300, 120)
atlas._on_map_input(second_up)
var reset := InputEventMouseButton.new()
reset.button_index = MOUSE_BUTTON_RIGHT
reset.pressed = true
+416 -11
View File
@@ -1,9 +1,19 @@
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
# 对标客户端 AudioLibBGM`bgm/*.mp3`)、UI wav`**/sound/ui/`)、
# 3D wav`**/sound/<rel>`)。纯资产接线,无解码器移植(`.mss` 不用)。
# 3D wav`**/sound/<rel>`)。`.mss` 只保存按帧触发的声音文件名,
# 在 Godot 侧解析为 60 FPS 的声音实例,再由同一个 Miles 音频入口播放。
# 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。
extends Node
## MilesLib constants (40250/ClientVS22/source/MilesLib/SoundManager.cpp).
## The reference client keeps the listener at (0, 0, 0) and submits world
## coordinates relative to it, divided by the sound scale.
const SOUND_SCALE := 200.0
const AMBIENCE_SOUND_SCALE := 1000.0
const FADE_SPEED := 0.016
const CHARACTER_SOUND_MIN_INTERVAL := 0.3
const CHARACTER_SOUND_LIMIT_DISTANCE_SQ := 5000.0 * 5000.0
var _assets: String
var _bgm_root: String
var _sound_roots: PackedStringArray = []
@@ -18,6 +28,26 @@ var _ui_next := 0
var master_bgm := 0.6
var master_sfx := 0.9
var _queued_bgm := ""
var sound_scale := SOUND_SCALE
var ambience_sound_scale := AMBIENCE_SOUND_SCALE
var _listener_world := Vector3.ZERO
var _listener_forward := Vector3(0, 0, -1)
var _listener_up := Vector3.UP
var _spatial_root: Node3D
var _listener: AudioListener3D
var _next_3d_id := 1
var _active_3d := {} # int -> AudioStreamPlayer3D
var _character_history := {} # sound path -> last time (seconds)
var _mss_cache := {} # mss path + header -> sound instances
var _ambience_states := {} # source key -> {id, inside, next_time}
const SOUND_FPS := 60.0
# §9.2 前后台音量存/恢复,字段名对齐参考 CSoundManager 的
# m_fBackupMusicVolume / m_fBackupSoundVolumeREF/MilesLib/SoundManager.h:31)。
var _backup_music_volume := -1.0
var _backup_sound_volume := -1.0
var _volume_saved := false
func _ready() -> void:
if _queued_bgm != "":
@@ -28,6 +58,7 @@ func _ready() -> void:
func setup(assets_root: String) -> void:
_assets = assets_root
_sound_roots.clear()
# bgm/ 与 assets/ 同级(<repo>/bgm,见 AssetRoot
_bgm_root = _assets.get_base_dir().path_join("bgm")
# 已知声音根 + 各 patch 的 sound/
@@ -51,6 +82,48 @@ func setup(assets_root: String) -> void:
var pl := AudioStreamPlayer.new()
add_child(pl)
_ui_pool.append(pl)
_ensure_spatial_audio()
func _ensure_spatial_audio() -> void:
if _spatial_root and is_instance_valid(_spatial_root):
return
_spatial_root = Node3D.new()
_spatial_root.name = "MilesSpatialAudio"
add_child(_spatial_root)
_listener = AudioListener3D.new()
_listener.name = "MilesListener"
_spatial_root.add_child(_listener)
_listener.current = true
func set_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
# SetPosition / SetDirection + Update in SoundManager. The Godot listener
# stays at the origin; active instances are re-based just like Miles3D.
_listener_world = world_pos
_listener_forward = forward.normalized() if forward.length_squared() > 0.000001 else Vector3(0, 0, -1)
_listener_up = up.normalized() if up.length_squared() > 0.000001 else Vector3.UP
if _listener:
_listener.position = Vector3.ZERO
_listener.look_at(_listener.position + _listener_forward, _listener_up)
for id in _active_3d:
var pl: AudioStreamPlayer3D = _active_3d[id]
if is_instance_valid(pl):
var scale: float = float(pl.get_meta("miles_scale", sound_scale))
pl.position = (pl.get_meta("miles_world", _listener_world) - _listener_world) * 100.0 / maxf(scale, 0.001)
func update_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void:
set_listener(world_pos, forward, up)
func set_sound_scale(value: float) -> void:
sound_scale = maxf(value, 0.001)
func set_ambience_sound_scale(value: float) -> void:
ambience_sound_scale = maxf(value, 0.001)
func get_sound_scale() -> float:
return sound_scale
func get_ambience_sound_scale() -> float:
return ambience_sound_scale
# --- resolution -----------------------------------------------------------
@@ -72,9 +145,17 @@ func _load_stream(path: String) -> AudioStream:
func _find_sound(rel: String) -> String:
rel = rel.replace("\\", "/")
var cands := PackedStringArray([rel])
if rel.get_extension().is_empty():
cands = PackedStringArray([rel + ".wav", rel + ".ogg"])
var rels := PackedStringArray([rel])
# ClientVS22's .mss files store paths with the virtual `sound/` prefix,
# while each mounted sound root already points at `<patch>/sound`.
if rel.to_lower().begins_with("sound/"):
rels.append(rel.substr(6))
var cands := PackedStringArray()
for candidate: String in rels:
cands.append(candidate)
if candidate.get_extension().is_empty():
cands.append(candidate + ".wav")
cands.append(candidate + ".ogg")
for root in _sound_roots:
for c in cands:
var p := root.path_join(c)
@@ -89,6 +170,120 @@ func _find_sound(rel: String) -> String:
return p
return ""
# --- MilesLib .mss sound scripts -----------------------------------------
# ClientVS22/ MilesLib/Type.cpp::LoadSoundInformationPiece. A sound script
# contains `SoundDataCount N` followed by exactly N `SoundData%02d time
# "path"` records. The native loader replaces the output vector on every
# call and rejects a missing or malformed record; preserve that contract here.
func load_sound_information_piece(file_path: String, path_header: String = "") -> Array:
var cache_key := file_path + "\n" + path_header
if _mss_cache.has(cache_key):
return _mss_cache[cache_key].duplicate(true)
var empty: Array = []
var f := FileAccess.open(file_path, FileAccess.READ)
if f == null:
_mss_cache[cache_key] = empty
return empty
var count := -1
var records := {}
while not f.eof_reached():
var tokens := _mss_tokens(f.get_line())
if tokens.is_empty():
continue
var key := String(tokens[0]).to_lower()
if key == "sounddatacount":
if tokens.size() != 2:
_mss_cache[cache_key] = empty
return empty
count = int(tokens[1])
continue
if not key.begins_with("sounddata") or key.length() <= 9:
continue
var index_text := key.substr(9)
if not index_text.is_valid_int() or tokens.size() != 3:
continue
var index := int(index_text)
if index < 0:
continue
records[index] = {"time": float(tokens[1]),
"sound": path_header + String(tokens[2])}
if count < 0 or count > 4096:
_mss_cache[cache_key] = empty
return empty
var out: Array = []
for i in count:
if not records.has(i):
_mss_cache[cache_key] = empty
return empty
out.append(records[i])
_mss_cache[cache_key] = out.duplicate(true)
return out
# Exact equivalent of NSound::DataToInstance: `DWORD(time / (1/60))`.
func data_to_sound_instances(sound_data: Array) -> Array:
var out: Array = []
for row in sound_data:
if not row is Dictionary:
continue
var time := maxf(0.0, float(row.get("time", 0.0)))
out.append({"frame": int(time * SOUND_FPS), "sound": String(row.get("sound", "")),
"time": time})
return out
func load_mss_instances(file_path: String, path_header: String = "") -> Array:
return data_to_sound_instances(load_sound_information_piece(file_path, path_header))
# Resolve the virtual `sound/<tail>.mss` generated by RaceMotionData from a
# motion .msa path. Sound roots are patch-aware and searched in their setup
# order, just like `_find_sound`.
func load_mss_for_motion(motion_path: String) -> Array:
var p := motion_path.replace("\\", "/")
var lower := p.to_lower()
var marker := "/ymir work/"
var at := lower.find(marker)
if at < 0:
return []
var tail := p.substr(at + marker.length()).get_basename() + ".mss"
for root in _sound_roots:
var candidate := root.path_join(tail)
if FileAccess.file_exists(candidate):
return load_mss_instances(candidate)
return []
# Equivalent of CSoundManager::UpdateSoundInstance(fx, fy, fz, frame, ...).
# The caller owns the animation frame cursor and calls this once per newly
# reached frame, so looping animations naturally replay frame-zero records.
func update_sound_instances_3d(fx: float, fy: float, fz: float, frame: int,
instances: Array, check_frequency := false) -> int:
var played := 0
for row in instances:
if not row is Dictionary or int(row.get("frame", -1)) != frame:
continue
var id := play_character_sound_3d(fx, fy, fz, String(row.get("sound", "")), check_frequency)
if id >= 0:
played += 1
return played
func _mss_tokens(line: String) -> Array:
var out: Array = []
var word := ""
var quoted := false
for i in line.length():
var c := line.substr(i, 1)
if c == "\"":
quoted = not quoted
continue
if not quoted and (c == " " or c == "\t" or c == "\r"):
if word != "":
out.append(word)
word = ""
continue
word += c
if word != "":
out.append(word)
return out
# --- BGM ---------------------------------------------------------------
func play_bgm(name: String, fade := 1.2) -> void:
@@ -127,6 +322,56 @@ func _fade(pl: AudioStreamPlayer, to_db: float, dur: float, stop_after := false)
if stop_after:
tw.tween_callback(pl.stop)
# 立即把 master_bgm 应用到当前正在播放的 BGM 声道(无淡入)。
# 对标 CSoundManager::SetMusicVolume:改的是「当前音乐实例」的音量。
func set_music_volume(v: float) -> void:
master_bgm = clampf(v, 0.0, 1.0)
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
if pl and pl.playing:
pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0
static func ratio_to_apply_volume(ratio: float) -> float:
# CSoundManager::__ConvertRatioVolumeToApplyVolume.
ratio = clampf(ratio, 0.0, 1.0)
return ratio if ratio < 0.1 else pow(10.0, -1.0 + ratio)
static func grade_to_apply_volume(grade: int) -> float:
return ratio_to_apply_volume(float(grade) / 5.0)
func set_sound_volume(v: float) -> void:
master_sfx = clampf(v, 0.0, 1.0)
_apply_3d_volume()
func set_sound_volume_ratio(ratio: float) -> void:
set_sound_volume(ratio_to_apply_volume(ratio))
func set_sound_volume_grade(grade: int) -> void:
set_sound_volume(grade_to_apply_volume(grade))
func set_music_volume_ratio(ratio: float) -> void:
set_music_volume(ratio_to_apply_volume(ratio))
func set_music_volume_grade(grade: int) -> void:
set_music_volume(ratio_to_apply_volume(float(grade) / 5.0))
# §9.2:进入后台前保存音量(CSoundManager::SaveVolume)。
func save_volume() -> void:
_backup_music_volume = master_bgm
_backup_sound_volume = master_sfx
_volume_saved = true
# §9.2:回到前台后恢复音量(CSoundManager::RestoreVolume)。
# 不碰 _bgm_name / _bgm[],同一首继续播,只把音量拉回来。
func restore_volume() -> void:
if not _volume_saved:
return
master_bgm = _backup_music_volume
master_sfx = _backup_sound_volume
_volume_saved = false
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
if pl and pl.playing:
pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0
# --- SFX -------------------------------------------------------------------
func play_ui(name: String) -> void:
@@ -146,18 +391,178 @@ func play_ui(name: String) -> void:
pl.play()
func play_at(rel: String, world_pos: Vector3, parent: Node = null) -> void:
play_sound_3d(world_pos.x, world_pos.y, world_pos.z, rel, 1)
func play_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
return _play_3d(rel, Vector3(fx, fy, fz), sound_scale, play_count)
func play_ambience_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int:
return _play_3d(rel, Vector3(fx, fy, fz), ambience_sound_scale, play_count)
func update_ambience_sources(sources: Array) -> void:
# CArea::UpdateAroundAmbience: source positions/ranges are in centimetres
# on the reference side. The world adapter exposes metres, so convert the
# listener distance back to centimetres before applying the strict range.
var now := Time.get_ticks_msec() / 1000.0
var seen := {}
for i in sources.size():
var source: Dictionary = sources[i] if sources[i] is Dictionary else {}
var key := String(source.get("key", i))
seen[key] = true
var sounds: Array = source.get("sounds", [])
if sounds.is_empty():
continue
var pos: Vector3 = source.get("position", Vector3.ZERO)
var range_cm := maxf(0.0, float(source.get("range_cm", 0.0)))
var inside := (pos - _listener_world).length() * 100.0 < range_cm
var state: Dictionary = _ambience_states.get(key, {"id": -1, "inside": false, "next_time": 0.0})
var sound := String(sounds[0])
var play_type := String(source.get("play_type", "LOOP")).to_upper()
var id := int(state.get("id", -1))
if play_type == "LOOP":
if inside:
if id < 0 or not _active_3d.has(id):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 0)
if id >= 0:
var percentage := clampf(float(source.get("max_volume_area_percentage", 0.0)), 0.0, 1.0)
var full_volume_radius := range_cm * percentage
var distance_cm := (pos - _listener_world).length() * 100.0
var volume := 1.0 if full_volume_radius <= 0.0 or distance_cm <= full_volume_radius else \
1.0 - ((distance_cm - full_volume_radius) / maxf(range_cm - full_volume_radius, 0.001))
set_sound_volume_3d(id, clampf(volume, 0.0, 1.0))
else:
if id >= 0:
stop_sound_3d(id)
id = -1
elif play_type == "ONCE":
if inside and not bool(state.get("inside", false)):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
elif not inside:
# ClientVS22 drops the bookkeeping id on exit; the one-shot itself
# is allowed to finish playing.
id = -1
elif play_type == "STEP":
if inside and now >= float(state.get("next_time", 0.0)):
id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1)
var interval := maxf(0.0, float(source.get("play_interval", 0.0)))
var variation := maxf(0.0, float(source.get("play_interval_variation", 0.0)))
state["next_time"] = now + interval + randf_range(0.0, variation)
elif not inside:
id = -1
state["id"] = id
state["inside"] = inside
_ambience_states[key] = state
for key in _ambience_states.keys().duplicate():
if seen.has(key):
continue
var old: Dictionary = _ambience_states[key]
var old_id := int(old.get("id", -1))
if old_id >= 0:
stop_sound_3d(old_id)
_ambience_states.erase(key)
func can_play_character_sound(rel: String, world_pos: Vector3, now := -1.0) -> bool:
# PlayCharacterSound3D's optional bCheckFrequency path. Coordinates in the
# game scene are metres; convert to centimetres before applying the exact
# 5000*5000 ClientVS22 threshold.
var delta_cm := (world_pos - _listener_world) * 100.0
if delta_cm.x * delta_cm.x + delta_cm.y * delta_cm.y > CHARACTER_SOUND_LIMIT_DISTANCE_SQ:
return false
if now < 0.0:
now = Time.get_ticks_msec() / 1000.0
var key := rel.replace("\\", "/")
if _character_history.has(key) and now - float(_character_history[key]) < CHARACTER_SOUND_MIN_INTERVAL:
return false
_character_history[key] = now
return true
func play_character_sound_3d(fx: float, fy: float, fz: float, rel: String, check_frequency := false) -> int:
var p := Vector3(fx, fy, fz)
if check_frequency and not can_play_character_sound(rel, p):
return -1
return _play_3d(rel, p, sound_scale, 1)
func _play_3d(rel: String, world_pos: Vector3, scale: float, play_count: int) -> int:
if master_sfx <= 0.0:
return -1
var path := _find_sound(rel)
if path.is_empty():
return
return -1
var s := _load_stream(path)
if s == null:
return
return -1
_ensure_spatial_audio()
var pl := AudioStreamPlayer3D.new()
pl.stream = s
pl.unit_size = 6.0
pl.max_distance = 60.0
pl.volume_db = linear_to_db(master_sfx)
(parent if parent else self).add_child(pl)
pl.global_position = world_pos
pl.play()
pl.finished.connect(pl.queue_free)
pl.volume_db = _volume_db(master_sfx)
_spatial_root.add_child(pl)
pl.set_meta("miles_world", world_pos)
pl.set_meta("miles_scale", scale)
pl.position = (world_pos - _listener_world) * 100.0 / maxf(scale, 0.001)
var id := _next_3d_id
_next_3d_id += 1
_active_3d[id] = pl
pl.set_meta("miles_remaining", maxi(play_count, 1))
pl.set_meta("miles_loop", play_count == 0)
pl.finished.connect(func():
if not is_instance_valid(pl):
return
if bool(pl.get_meta("miles_loop", false)):
pl.play()
return
var remaining := int(pl.get_meta("miles_remaining", 1)) - 1
if remaining > 0:
pl.set_meta("miles_remaining", remaining)
pl.play()
else:
_active_3d.erase(id)
pl.queue_free())
if pl.is_inside_tree():
pl.play()
else:
pl.call_deferred("play")
return id
func _volume_db(value: float) -> float:
return linear_to_db(maxf(value, 0.0001)) if value > 0.0 else -80.0
func _apply_3d_volume() -> void:
for id in _active_3d:
var pl: AudioStreamPlayer3D = _active_3d[id]
if is_instance_valid(pl):
pl.volume_db = _volume_db(master_sfx)
func set_sound_volume_3d(id: int, value: float) -> void:
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
if pl and is_instance_valid(pl):
pl.volume_db = _volume_db(clampf(value, 0.0, 1.0))
func stop_sound_3d(id: int) -> void:
var pl: AudioStreamPlayer3D = _active_3d.get(id, null)
_active_3d.erase(id)
if pl and is_instance_valid(pl):
pl.stop()
pl.queue_free()
func stop_all_sound_3d() -> void:
for id in _active_3d.keys().duplicate():
stop_sound_3d(int(id))
_character_history.clear()
_ambience_states.clear()
func fade_limit_out_music(name: String, limit_volume: float, speed := FADE_SPEED) -> void:
if name != _bgm_name:
return
var pl: AudioStreamPlayer = _bgm[_bgm_cur]
if pl == null or not pl.playing:
return
var limit_db := _volume_db(ratio_to_apply_volume(limit_volume))
var duration := maxf(absf(pl.volume_db - limit_db) / maxf(speed * 60.0, 0.001), 0.01)
_fade(pl, limit_db, duration)
func fade_out_all_music(speed := 0.01) -> void:
var duration := maxf(1.0 / maxf(speed * 60.0, 0.001), 0.01)
_fade(_bgm[0], -80.0, duration, true)
_fade(_bgm[1], -80.0, duration, true)
+88
View File
@@ -0,0 +1,88 @@
# audio_miles_test —— MilesLib SoundManager 对齐回归。
# 验证 40250 的缩放、音量曲线和角色音效限频/距离规则。
extends SceneTree
const Audio = preload("res://audio.gd")
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
var audio := Audio.new()
get_root().add_child(audio)
_ck(is_equal_approx(Audio.SOUND_SCALE, 200.0), "sound scale is 200")
_ck(is_equal_approx(Audio.AMBIENCE_SOUND_SCALE, 1000.0), "ambience scale is 1000")
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.05), 0.05), "low ratio stays linear")
_ck(is_equal_approx(Audio.ratio_to_apply_volume(0.5), pow(10.0, -0.5)),
"ratio volume uses ClientVS22 logarithmic curve")
_ck(is_equal_approx(Audio.grade_to_apply_volume(5), 1.0), "grade 5 maps to full volume")
_ck(is_equal_approx(Audio.grade_to_apply_volume(0), 0.0), "grade 0 maps to silence")
audio.set_sound_scale(320.0)
audio.set_ambience_sound_scale(1200.0)
_ck(is_equal_approx(audio.get_sound_scale(), 320.0), "sound scale setter")
_ck(is_equal_approx(audio.get_ambience_sound_scale(), 1200.0), "ambience scale setter")
audio.set_listener(Vector3.ZERO, Vector3(0, 0, -1), Vector3.UP)
var assets := AssetRoot.path()
if DirAccess.dir_exists_absolute(assets):
audio.setup(assets)
var mss := assets.path_join("sound_m/sound/pc/warrior/general/run.mss")
if FileAccess.file_exists(mss):
var data: Array = audio.load_sound_information_piece(mss)
_ck(data.size() == 3, ".mss SoundDataCount and records parse")
_ck(is_equal_approx(float(data[0]["time"]), 0.759), ".mss preserves event time")
_ck(String(data[0]["sound"]) == "sound/common/walk_dirt_n.wav",
".mss preserves virtual sound path")
var instances: Array = audio.data_to_sound_instances(data)
_ck(int(instances[0]["frame"]) == 45 and int(instances[2]["frame"]) == 11,
".mss time converts to DWORD 60 FPS frames")
_ck(audio._find_sound(String(data[0]["sound"])) != "",
"virtual sound/ prefix resolves inside mounted sound root")
var motion := assets.path_join("PC/ymir work/pc/warrior/general/run.msa")
var motion_instances: Array = audio.load_mss_for_motion(motion)
_ck(motion_instances.size() == 3, "motion .msa resolves paired .mss")
var ambience := [{
"key": "waterfall-test",
"position": Vector3(20, 0, 0),
"range_cm": 3300,
"max_volume_area_percentage": 0.5,
"play_type": "LOOP",
"sounds": ["sound/ambience/warp_test.mp3"],
}]
audio.update_ambience_sources(ambience)
var ambience_state: Dictionary = audio._ambience_states.get("waterfall-test", {})
var ambience_id := int(ambience_state.get("id", -1))
_ck(ambience_id >= 0 and audio._active_3d.has(ambience_id),
"area ambience LOOP creates a Miles 3D instance")
if ambience_id >= 0 and audio._active_3d.has(ambience_id):
var ambience_player: AudioStreamPlayer3D = audio._active_3d[ambience_id]
_ck(abs(ambience_player.position.x - (20.0 * 100.0 / 1200.0)) < 0.001,
"ambience source uses cm -> ambience scale conversion")
audio.update_ambience_sources([])
_ck(not audio._ambience_states.has("waterfall-test") and not audio._active_3d.has(ambience_id),
"leaving / unloading area ambience stops its loop")
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.0),
"near character sound is allowed")
_ck(not audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.2),
"same character sound is suppressed within 0.3 seconds")
_ck(audio.can_play_character_sound("step.wav", Vector3(1, 0, 0), 10.31),
"same character sound resumes after 0.3 seconds")
_ck(not audio.can_play_character_sound("far.wav", Vector3(51, 0, 0), 20.0),
"character sound beyond 5000cm is suppressed")
audio.set_sound_volume(0.0)
_ck(is_equal_approx(audio.master_sfx, 0.0), "sound volume setter accepts silence")
audio.stop_all_sound_3d()
audio.free()
if _fail == 0:
print("PASS: audio_miles_test (scale + volume curve + character sound guard)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://ctbdjmg33pe67
+81
View File
@@ -0,0 +1,81 @@
# BgmDirector —— 把 M2Client.bgm_changed 信号翻译成 Audio 的 BGM 播放。
#
# 存在的理由:§9.1 的地图 BGM 消费点,从 net_world.gd 里拆出来,避免 W2(可见性)
# 和 W4(音频)同时改 net_world.gd。地图 → 曲目解析和音量应用在这里(§9.1)。
#
# 选曲逻辑对标 game.py:190GameWindow.Open 内):
# snd.SetMusicVolume(...); snd.SetSoundVolume(...) # 先应用音量
# name = net.GetFieldMusicFileName()
# if name: snd.FadeInMusic("BGM/" + name)
# elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
# 默认音量 gs_fieldMusic_volume = 1.0f/5.0f*0.1f = 0.02f(服务器未下发时)。
#
# 用法:
# var bd := preload("res://bgm_director.gd").new()
# add_child(bd)
# bd.bind(client, audio)
extends Node
const MusicInfo = preload("res://musicinfo.gd")
# gs_fieldMusic_volume 默认值(PythonNetworkStreamPhaseLoading.cpp:299)。
const DEFAULT_FIELD_MUSIC_VOLUME := 1.0 / 5.0 * 0.1 # = 0.02
var _client: Node
var _audio: Node
# 服务器最近一次下发的 BGM(曲目 id 或路径)和音量(<0 表示未指定)。
var current_track := ""
var current_volume := -1.0
func bind(client: Node, audio: Node) -> void:
_client = client
_audio = audio
if _client and _client.has_signal("bgm_changed"):
_client.bgm_changed.connect(_on_bgm_changed)
# 换图(§1.3):world_reset 后重跑选曲逻辑。新图的 BGM 包到达前沿用当前曲目。
if _client and _client.has_signal("world_reset"):
_client.world_reset.connect(_on_world_reset)
func _on_bgm_changed(name: String, volume: float) -> void:
# M2Client 已在 EntityStore 侧做音量保留(BGM3 只带名、BGM4 带名+音量),
# 这里拿到的 volume 就是「有效音量」,<0 仅表示从未收到过 BGM4。
current_track = name
current_volume = volume
_select()
func _on_world_reset() -> void:
_select()
# game.py:190 的三行选曲 + 先置音量。
func _select() -> void:
if _audio == null:
return
# 1) 选曲:服务端名优先,否则回落 musicInfo.fieldMusicMETIN2THEMA = "M2BG.mp3")。
var track := current_track
if track == "":
track = String(MusicInfo.fieldMusic)
# 2) 音量:服务端未指定(<0)用默认 0.02,否则用服务端值。
var vol := current_volume if current_volume >= 0.0 else DEFAULT_FIELD_MUSIC_VOLUME
vol = clampf(vol, 0.0, 1.0)
# 3) 先应用音量再淡入:Audio.play_bgm() 的淡入目标是 linear_to_db(master_bgm)
# 对标 game.py 先 SetMusicVolume/SetSoundVolume 再 FadeInMusic。
# 声音(SFX)音量在参考里取自系统设置(systemSetting.GetSoundVolume),
# 不随 BGM 包下发,这里不改 master_sfx。
if "master_bgm" in _audio:
_audio.master_bgm = vol
if _audio.has_method("set_music_volume"):
_audio.set_music_volume(vol)
if track == "":
if _audio.has_method("stop_bgm"):
_audio.stop_bgm()
return
if _audio.has_method("play_bgm"):
_audio.play_bgm(track)
# musicInfo.SaveLastPlayFieldMusic 语义:记录本次战场曲目。
MusicInfo.save_last_play_field_music(track)
+1
View File
@@ -0,0 +1 @@
uid://yglonaagb03t
+153
View File
@@ -0,0 +1,153 @@
# bgm_test —— §9.1 地图 BGM 选曲 + §9.2 前后台音量存/恢复的 headless 自检。
# godot --headless --path project --script bgm_test.gd
# exit 0 = pass。
extends SceneTree
const BgmDirector = preload("res://bgm_director.gd")
const Audio = preload("res://audio.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const MusicInfo = preload("res://musicinfo.gd")
const DEFAULT_VOL := 1.0 / 5.0 * 0.1 # 0.02
# 假 M2Client:只发 §9.1 关心的两个信号。
class FakeClient extends Node:
signal bgm_changed(name: String, volume: float)
signal world_reset()
# 假 Audio:记录 play_bgm / stop_bgm 调用与音量,够 director 断言用。
class FakeAudio extends Node:
var master_bgm := 0.6
var master_sfx := 0.9
var _bgm_name := ""
var played: Array[String] = []
var stopped := 0
func play_bgm(name: String, _fade := 1.2) -> void:
if name == _bgm_name:
return
_bgm_name = name
played.append(name)
func stop_bgm(_fade := 1.0) -> void:
_bgm_name = ""
stopped += 1
func set_music_volume(v: float) -> void:
master_bgm = clampf(v, 0.0, 1.0)
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_test_select_server_volume()
_test_select_default_volume()
_test_select_empty_falls_back()
_test_world_reset_reruns()
_test_musicinfo_lastplay_roundtrip()
_test_background_save_restore()
if _fail == 0:
print("PASS: bgm_test (§9.1 map BGM select + §9.2 volume save/restore)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _mk_director() -> Array:
var client := FakeClient.new()
var audio := FakeAudio.new()
var bd := BgmDirector.new()
get_root().add_child(client)
get_root().add_child(audio)
get_root().add_child(bd)
bd.bind(client, audio)
return [client, audio, bd]
# §9.1:服务端给了名字 + 音量 -> 用服务端音量并播放该曲。
func _test_select_server_volume() -> void:
var t := _mk_director()
var client: FakeClient = t[0]
var audio: FakeAudio = t[1]
client.bgm_changed.emit("pvp_bgm.mp3", 0.5)
_ck(audio.played == ["pvp_bgm.mp3"], "server name played, got %s" % [audio.played])
_ck(is_equal_approx(audio.master_bgm, 0.5), "server volume applied, got %f" % audio.master_bgm)
_ck(audio.stopped == 0, "no stop_bgm on server-provided track")
t[0].free(); t[1].free(); t[2].free()
# §9.1:服务端给了名字但音量 <0GC_MAIN_CHARACTER3_BGM,未收 BGM4-> 默认 0.02。
func _test_select_default_volume() -> void:
var t := _mk_director()
var client: FakeClient = t[0]
var audio: FakeAudio = t[1]
client.bgm_changed.emit("field_a.mp3", -1.0)
_ck(audio.played == ["field_a.mp3"], "name played on unspecified volume, got %s" % [audio.played])
_ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 applied, got %f" % audio.master_bgm)
t[0].free(); t[1].free(); t[2].free()
# §9.1:服务端名为空 -> 回落 musicInfo.fieldMusicMETIN2THEMA = "M2BG.mp3")。
func _test_select_empty_falls_back() -> void:
var t := _mk_director()
var client: FakeClient = t[0]
var audio: FakeAudio = t[1]
client.bgm_changed.emit("", -1.0)
_ck(audio.played == [String(MusicInfo.fieldMusic)],
"empty name falls back to fieldMusic, got %s" % [audio.played])
_ck(String(MusicInfo.fieldMusic) == "M2BG.mp3", "METIN2THEMA constant is M2BG.mp3")
_ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 on fallback, got %f" % audio.master_bgm)
t[0].free(); t[1].free(); t[2].free()
# §9.1 / §1.3:换图(world_reset)重跑选曲逻辑。
func _test_world_reset_reruns() -> void:
var t := _mk_director()
var client: FakeClient = t[0]
var audio: FakeAudio = t[1]
var bd: BgmDirector = t[2]
_ck(client.world_reset.is_connected(bd._on_world_reset), "director connected to world_reset")
client.bgm_changed.emit("map_a.mp3", 0.4)
# warpworld_reset 先到(旧曲目仍在 store 里),随后新图的 BGM 包。
client.world_reset.emit()
_ck(audio.played == ["map_a.mp3"], "world_reset keeps current track, got %s" % [audio.played])
client.bgm_changed.emit("map_b.mp3", 0.2)
_ck(audio.played == ["map_a.mp3", "map_b.mp3"], "new map BGM played, got %s" % [audio.played])
_ck(is_equal_approx(audio.master_bgm, 0.2), "new map volume applied, got %f" % audio.master_bgm)
t[0].free(); t[1].free(); t[2].free()
# §9.1musicInfo.SaveLastPlayFieldMusic / LoadLastPlayFieldMusic 往返。
func _test_musicinfo_lastplay_roundtrip() -> void:
MusicInfo.save_last_play_field_music("last_field.mp3")
_ck(MusicInfo.load_last_play_field_music() == "last_field.mp3",
"lastplay.inf roundtrip, got %s" % MusicInfo.load_last_play_field_music())
# §9.2:后台保存音量 + 静音,前台恢复;_bgm_name 不变,同一首继续播。
func _test_background_save_restore() -> void:
var client := FakeClient.new()
var audio := Audio.new()
var life := AppLifecycle.new()
get_root().add_child(audio)
audio.setup("res://__no_assets__") # 无资源,play_bgm 只置名不出声
get_root().add_child(life)
life.pause_tree_on_background = false # 别把测试树暂停了
life.background_max_fps = 0
life.bind(null, audio)
audio.master_bgm = 0.6
audio.master_sfx = 0.9
# 模拟「某首战场曲目正在播放」(headless 无资源,不实际解码)。
audio._bgm_name = "M2BG.mp3"
var name_before := audio._bgm_name
_ck(name_before == "M2BG.mp3", "bgm name set before background, got %s" % name_before)
life._enter_background()
_ck(is_equal_approx(audio.master_bgm, 0.0), "music muted in background, got %f" % audio.master_bgm)
_ck(audio._volume_saved, "save_volume ran (backup taken)")
_ck(is_equal_approx(audio._backup_music_volume, 0.6), "backup music volume, got %f" % audio._backup_music_volume)
_ck(is_equal_approx(audio._backup_sound_volume, 0.9), "backup sound volume, got %f" % audio._backup_sound_volume)
_ck(audio._bgm_name == name_before, "_bgm_name unchanged in background, got %s" % audio._bgm_name)
life._exit_background()
_ck(is_equal_approx(audio.master_bgm, 0.6), "music volume restored, got %f" % audio.master_bgm)
_ck(is_equal_approx(audio.master_sfx, 0.9), "sound volume restored, got %f" % audio.master_sfx)
_ck(audio._bgm_name == name_before, "_bgm_name still unchanged after restore, got %s" % audio._bgm_name)
_ck(not audio._volume_saved, "backup flag cleared after restore")
client.free(); life.free(); audio.free()
+1
View File
@@ -0,0 +1 @@
uid://cxhy4wk3451c1
+16
View File
@@ -150,3 +150,19 @@ func _run() -> void:
cs.close()
_ck(not cs.is_open(), "character window closed")
# 移动端使用独立横屏视图,不把 PC 固定坐标页面缩小后直接复用。
ui.set_mobile_mode(true)
cs.set_mobile_mode(true)
cs.open("STATUS")
_ck(cs.is_open(), "mobile character window opened")
_ck(cs._mobile_root.size == Vector2(760, 340), "mobile character base size is landscape")
_ck(cs._mobile_status_page.visible and not cs._mobile_emote_page.visible,
"mobile character opens on status/equipment page")
_ck(cs._mobile_stats.has("STR") and cs._mobile_stats["STR"]["value"].text == "33",
"mobile character binds STR")
cs.open("EMOTICON")
_ck(not cs._mobile_status_page.visible and cs._mobile_emote_page.visible,
"mobile character switches to emote page")
cs.close()
_ck(not cs.is_open(), "mobile character window closed")
+15 -5
View File
@@ -1,6 +1,6 @@
# 选人界面 —— 3 个旋转台角色 + 九宫格信息面板 + 选择/创建按钮。
# 数据来自 M2Client.char_list(联机)或 --mock 合成列表(离线开发)。
# 选中 -> M2Client.select_character(index) -> entered_game。
# 选中 -> M2Client.enter_game(index) / DirectEnter -> entered_game。
# 用法(离线预览):godot --path project --script charselect.gd --mock --quit-after 120
extends SceneTree
@@ -54,8 +54,13 @@ func _init() -> void:
_client.connect("stage_changed", func(s): _set_status("stage: " + s))
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
_client.connect("entered_game", func(): _set_status("entered game"))
var host := "192.168.21.203"
_client.call("connect_to_server", host, 11000, host, 13002, "admin", "123456789")
var host := OS.get_environment("MT_SERVER_HOST")
var account := OS.get_environment("MT_ACCOUNT")
var password := OS.get_environment("MT_PASSWORD")
if host.is_empty() or account.is_empty() or password.is_empty():
_set_status("请设置 MT_SERVER_HOST / MT_ACCOUNT / MT_PASSWORD,或使用 --mock")
return
_client.call("connect_to_server", host, 11000, host, 13002, account, password)
_set_status("connecting to " + host + " ...")
else:
_set_status("M2Client 未注册 —— 用 --mock")
@@ -209,8 +214,13 @@ func _enter() -> void:
_set_status("空槽不能进入(创建角色未实现)")
return
if _client:
_client.call("select_character", int(s.get("index", _sel)))
_set_status("select_character(%d) sent" % int(s.get("index", _sel)))
var idx := int(s.get("index", _sel))
if _client.has_method("enter_game"):
_client.call("enter_game", idx)
_set_status("enter_game(%d) sent" % idx)
else:
_client.call("select_character", idx)
_set_status("select_character(%d) sent" % idx)
else:
_set_status("[mock] 进入游戏:%s" % s.get("name"))
+51
View File
@@ -0,0 +1,51 @@
# chat_mobile_ui_test —— 横屏聊天页与私聊输入的移动布局自检。
extends SceneTree
const ChatUI = preload("res://ui/chat_ui.gd")
class FakeClient extends Node:
var sent := []
func say(channel: int, text: String) -> bool:
sent.append(["say", channel, text]); return true
func whisper(name: String, text: String) -> bool:
sent.append(["whisper", name, text]); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: chat_mobile_ui_test (landscape chat + whisper input)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var client := FakeClient.new()
get_root().add_child(client)
var chat := ChatUI.new()
get_root().add_child(chat)
chat.setup(client, get_root())
chat.set_mobile_mode(true)
_ck(chat._root.size == Vector2(760, 340), "mobile chat uses landscape page size")
_ck(chat._log[0].size == Vector2(732, 190), "mobile chat log has touch-friendly content area")
_ck(chat._input.size == Vector2(732, 38), "mobile chat input is keyboard-friendly")
_ck(not chat.is_log_visible(), "mobile chat is hidden until explicitly opened")
chat.toggle_log()
_ck(chat.is_log_visible(), "chat opens from menu")
chat._on_submit("/s hello")
_ck(client.sent == [["say", 6, "hello"]], "chat channel prefix remains server-compatible")
chat.start_whisper("Bob")
_ck(chat.is_whisper_open(), "whisper dialog opens")
_ck(chat._whisper_dialog.size == Vector2(480, 260), "mobile whisper uses landscape touch layout")
chat._on_whisper_submit("hi")
_ck(client.sent.size() == 2 and client.sent[1] == ["whisper", "Bob", "hi"],
"mobile whisper sends target and message")
chat.close()
chat.queue_free()
client.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bhyev28dml0wh
+109
View File
@@ -0,0 +1,109 @@
# chat_tail —— §8.8 头顶「聊天 / 信息」尾标 1:1CPythonTextTail::RegisterChatTail /
# RegisterInfoTail 的纯静态镜像(与 name_color.gd / text_tail.gd 同套路)。
#
# REF 40250/ClientVS22/source/UserInterface/PythonTextTail.cpp
# :16 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1) —— 普通聊天气泡=白
# :17 c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1) —— 信息气泡=淡红
# :26 gs_TextTail_LivingTime = 5000 —— TEXTTAIL_LIVINGTIME_CONTROL
# :28 TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000)
# :34 TextTail_SetLivingTime(livingTime)
# :581 RegisterChatTail(vid, szChat)m_ChatTailMap 已有该 vid → 改文本 + Color=Chat +
# LivingTime = now + GetLivingTime() + bNameFlag=TRUE,直接 return
# 否则新建(高度 = 角色高 + 10),LivingTime = now + GetLivingTime()bNameFlag=TRUE
# SetOutline(true)VERTICAL_ALIGN_BOTTOM,插入 m_ChatTailMap
# :624 RegisterInfoTail(vid, szChat):同上,但 Color=Info、bNameFlag=FALSE
# :328 UpdateAllTextTail:遍历 m_ChatTailMapLivingTime < now 即 DeleteTextTail + erase
# (每 vid 只有一条 chat/info 尾标,chat 与 info 共用同一张 m_ChatTailMap
# :74 UpdateAllTextTailchat 尾标若 bNameFlag → ShowCharacterTextTail(vid)(名字也一起顶出来)
# :105 UpdateShowingTextTailchat 尾标若 bNameFlag → 名字尾标 y = chatTail.y - 17(名字挪到气泡下方)
# :684 IsChatTextTail(vid)m_ChatTailMap.find(vid) != end
#
# REF 40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp
# :1301 CHAT_TYPE_COMMAND → ServerCommand,提前 return(不进尾标)
# :1321 TALKING/PARTY/GUILD/SHOUT/WHISPERline = strchr(buf,':') ? p + 2 : buf(去掉 "名字: " 前缀)
# :1363 INFO/NOTICE/BIG_NOTICE/defaultline = buf(原样)
# :1372 if (CHAT_TYPE_SHOUT != kChat.type) RegisterChatTail(kChat.dwVID, line);
# → 带 VID 的聊天里,除 SHOUT / COMMAND 外都进「聊天尾标」;RegisterInfoTail 仅
# game.py:930 / :936 技能 / 射击错误尾标显式调。
#
# seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag 决定
# 名字是否强制显示);本移植是 net_world 里名字 Label3D 与 "bubble" Label3D 各自 billboard 并存,
# 名字不强制顶出、不随气泡下移——差异记入 §8.8。
extends RefCounted
const CHAT_COLOR := Color(1.0, 1.0, 1.0)
const INFO_COLOR := Color(1.0, 0.785, 0.785)
const LIVING_TIME_DEFAULT := 5000 # msgs_TextTail_LivingTime
const LIVING_TIME_MIN := 1000 # assert 下界(TextTail_GetLivingTime
const HEIGHT_ADD := 10.0 # 角色高 + 10RegisterChatTail 新建分支)
# CHAT_TYPE_*Packet.h:1410 enum EChatType
const CT_TALKING := 0
const CT_INFO := 1
const CT_NOTICE := 2
const CT_PARTY := 3
const CT_GUILD := 4
const CT_COMMAND := 5
const CT_SHOUT := 6
const CT_WHISPER := 7
const CT_BIG_NOTICE := 8
static var _living_time := LIVING_TIME_DEFAULT
static func set_living_time(ms: int) -> void:
# TextTail_SetLivingTime —— 参考端不在此断言(断言在 GetLivingTime
_living_time = ms
static func living_time() -> int:
# TextTail_GetLivingTimeassert(gs_TextTail_LivingTime > 1000)
assert(_living_time > LIVING_TIME_MIN)
return _living_time
static func strip_name_prefix(raw: String) -> String:
# strchr(buf, ':') ? p + 2 : buf —— 有冒号则取 ": " 之后正文,否则整串原样
var i := raw.find(":")
return raw.substr(i + 2) if i != -1 else raw
static func line_for_tail(chat_type: int, raw: String) -> String:
# RecvChatPacket 的 switchTALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀;其余原样
match chat_type:
CT_TALKING, CT_PARTY, CT_GUILD, CT_SHOUT, CT_WHISPER:
return strip_name_prefix(raw)
_:
return raw
static func wants_chat_tail(chat_type: int) -> bool:
# RecvChatPacketCOMMAND 提前 returnSHOUT 显式排除;其余带 VID 聊天都进尾标
return chat_type != CT_SHOUT and chat_type != CT_COMMAND
static func _register(tails: Dictionary, vid: int, text: String, now_ms: int, col: Color, name_flag: bool) -> Dictionary:
var e := {
"text": text,
"color": col,
"name_flag": name_flag,
"living_time": now_ms + living_time(),
}
tails[vid] = e
return e
static func register_chat(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
# CPythonTextTail::RegisterChatTail —— 白 + bNameFlag TRUE,已存在则原地替换
return _register(tails, vid, text, now_ms, CHAT_COLOR, true)
static func register_info(tails: Dictionary, vid: int, text: String, now_ms: int) -> Dictionary:
# CPythonTextTail::RegisterInfoTail —— 淡红 + bNameFlag FALSE,与 chat 共用同一张表
return _register(tails, vid, text, now_ms, INFO_COLOR, false)
static func is_chat_text_tail(tails: Dictionary, vid: int) -> bool:
# CPythonTextTail::IsChatTextTail
return tails.has(vid)
static func expire(tails: Dictionary, now_ms: int) -> Array:
# UpdateAllTextTailLivingTime < now 即删除;返回被清掉的 vid 列表
var dead: Array = []
for vid in tails.keys():
if int(tails[vid].get("living_time", 0)) < now_ms:
dead.append(vid)
for vid in dead:
tails.erase(vid)
return dead
+1
View File
@@ -0,0 +1 @@
uid://bilb10438dl5i
+106
View File
@@ -0,0 +1,106 @@
# chat_tail_test —— §8.8 头顶「聊天 / 信息」尾标 1:1:
# CPythonTextTail::RegisterChatTail / RegisterInfoTail 的白 / 淡红色、bNameFlag、
# gs_TextTail_LivingTime5000assert >1000)、m_ChatTailMap 每 vid 一条 + chat/info 共表、
# UpdateAllTextTail 的 LivingTime < now 删除、IsChatTextTail,以及
# RecvChatPacket 的 "名字: " 前缀剥离 + SHOUT/COMMAND 不进尾标。
# godot --headless --path project --script chat_tail_test.gd
# 退出码 0 = 全过。
extends SceneTree
const ChatTail = preload("res://chat_tail.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: chat_tail_test (§8.8 RegisterChatTail / RegisterInfoTail / LivingTime 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# --- 0. 常量(PythonTextTail.cpp / Packet.h---------------------------------
_ck(ChatTail.CHAT_COLOR.is_equal_approx(Color(1, 1, 1)), "c_TextTail_Chat_Color == white")
_ck(ChatTail.INFO_COLOR.is_equal_approx(Color(1.0, 0.785, 0.785)), "c_TextTail_Info_Color == (1,.785,.785)")
_ck(ChatTail.LIVING_TIME_DEFAULT == 5000, "gs_TextTail_LivingTime == 5000")
_ck(ChatTail.LIVING_TIME_MIN == 1000, "TextTail_GetLivingTime assert 下界 == 1000")
_ck(ChatTail.HEIGHT_ADD == 10.0, "新建分支高度 = 角色高 + 10")
# EChatType 枚举顺序(Packet.h:1410
_ck(ChatTail.CT_TALKING == 0 and ChatTail.CT_INFO == 1 and ChatTail.CT_NOTICE == 2, "EChatType 0/1/2")
_ck(ChatTail.CT_PARTY == 3 and ChatTail.CT_GUILD == 4 and ChatTail.CT_COMMAND == 5, "EChatType 3/4/5")
_ck(ChatTail.CT_SHOUT == 6 and ChatTail.CT_WHISPER == 7 and ChatTail.CT_BIG_NOTICE == 8, "EChatType 6/7/8")
# --- 1. TextTail_GetLivingTime / SetLivingTime ------------------------------
_ck(ChatTail.living_time() == 5000, "living_time() 默认 5000")
ChatTail.set_living_time(8000)
_ck(ChatTail.living_time() == 8000, "set_living_time(8000) 生效")
ChatTail.set_living_time(ChatTail.LIVING_TIME_DEFAULT) # 还原,避免污染同进程后续测试
_ck(ChatTail.living_time() == 5000, "restore: living_time 回 5000")
# --- 2. strip_name_prefixstrchr(buf,':') ? p+2 : buf --------------------
_ck(ChatTail.strip_name_prefix("Alice: hello world") == "hello world", "去掉 \"Alice: \" 前缀")
_ck(ChatTail.strip_name_prefix("nocolon here") == "nocolon here", "无冒号 -> 原样")
_ck(ChatTail.strip_name_prefix("A: x: y") == "x: y", "只吃第一个冒号")
_ck(ChatTail.strip_name_prefix("Bob: ") == "", "\"Bob: \" -> 空")
# --- 3. line_for_tailswitch 里 TALKING/PARTY/GUILD/SHOUT/WHISPER 去前缀 ----
_ck(ChatTail.line_for_tail(ChatTail.CT_TALKING, "Kane: hi") == "hi", "TALKING 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_PARTY, "Kane: hi") == "hi", "PARTY 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_GUILD, "Kane: hi") == "hi", "GUILD 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_WHISPER, "Kane: hi") == "hi", "WHISPER 去前缀")
_ck(ChatTail.line_for_tail(ChatTail.CT_SHOUT, "Kane: hi") == "hi", "SHOUT 也走去前缀分支")
_ck(ChatTail.line_for_tail(ChatTail.CT_INFO, "You got 10 gold") == "You got 10 gold", "INFO 原样")
_ck(ChatTail.line_for_tail(ChatTail.CT_NOTICE, "server: down soon") == "server: down soon", "NOTICE 原样")
_ck(ChatTail.line_for_tail(ChatTail.CT_BIG_NOTICE, "boss: alive") == "boss: alive", "BIG_NOTICE 原样")
# --- 4. wants_chat_tailRecvChatPacket 除 SHOUT / COMMAND 外都进尾标 --------
_ck(ChatTail.wants_chat_tail(ChatTail.CT_TALKING), "TALKING 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_PARTY), "PARTY 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_GUILD), "GUILD 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_WHISPER), "WHISPER 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_INFO), "INFO 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_NOTICE), "NOTICE 进尾标")
_ck(ChatTail.wants_chat_tail(ChatTail.CT_BIG_NOTICE), "BIG_NOTICE 进尾标")
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_SHOUT), "SHOUT 不进尾标(:1372 显式排除)")
_ck(not ChatTail.wants_chat_tail(ChatTail.CT_COMMAND), "COMMAND 不进尾标(提前 return")
# --- 5. RegisterChatTail:新建 / 原地替换 / bNameFlag / LivingTime ----------
var tails := {}
var t0 := 100000
var c := ChatTail.register_chat(tails, 42, "hello", t0)
_ck(tails.size() == 1 and tails.has(42), "register_chat -> m_ChatTailMap 里一条 vid 42")
_ck(c["text"] == "hello", "chat tail 文本 == hello")
_ck(c["color"].is_equal_approx(Color(1, 1, 1)), "chat tail 色 == 白")
_ck(c["name_flag"] == true, "chat tail bNameFlag == TRUE")
_ck(int(c["living_time"]) == t0 + 5000, "chat tail LivingTime == now + GetLivingTime()")
# 同 vid 再来一条 —— 原地替换(find != end 分支),仍只有一条
var c2 := ChatTail.register_chat(tails, 42, "hello again", t0 + 1000)
_ck(tails.size() == 1, "同 vid 再 register_chat 不新增键")
_ck(c2["text"] == "hello again" and int(c2["living_time"]) == t0 + 6000, "原地改文本 + 续命")
_ck(ChatTail.is_chat_text_tail(tails, 42), "IsChatTextTail(42) == true")
_ck(not ChatTail.is_chat_text_tail(tails, 99), "IsChatTextTail(99) == false")
# --- 6. RegisterInfoTail:淡红 + bNameFlag FALSE,与 chat 共用 m_ChatTailMap --
var i := ChatTail.register_info(tails, 42, "used skill fail", t0 + 2000)
_ck(tails.size() == 1, "info tail 覆盖同 vid 的 chat tail(共表),仍一条")
_ck(i["color"].is_equal_approx(Color(1.0, 0.785, 0.785)), "info tail 色 == 淡红")
_ck(i["name_flag"] == false, "info tail bNameFlag == FALSE")
_ck(i["text"] == "used skill fail", "info tail 文本替换")
var i2 := ChatTail.register_info(tails, 7, "no arrows", t0)
_ck(tails.size() == 2 and i2["name_flag"] == false, "另一 vid 的 info tail 独立成键")
# --- 7. expireUpdateAllTextTail 的 LivingTime < now 删除 -----------------
# vid 42 LivingTime = t0+2000+5000 = t0+7000vid 7 = t0+5000
var dead := ChatTail.expire(tails, t0 + 6000)
_ck(dead == [7], "expire(now=t0+6000) 清掉 vid 7LivingTime t0+5000")
_ck(tails.size() == 1 and tails.has(42), "vid 42 未到点,保留")
var dead2 := ChatTail.expire(tails, t0 + 7001)
_ck(dead2 == [42], "expire(now=t0+7001) 清掉 vid 42")
_ck(tails.is_empty(), "全部到点后 m_ChatTailMap 空")
_ck(ChatTail.expire(tails, t0 + 99999) == [], "空表 expire 返回 []")
+1
View File
@@ -0,0 +1 @@
uid://dh4ucte7pxc3u
+90 -5
View File
@@ -26,6 +26,13 @@ class FakeClient extends Node:
func get_entities() -> Array: return ents.values()
func say(t, s) -> bool: said.append([t, s]); return true
func whisper(to, s) -> bool: whispered.append([to, s]); return true
func get_guild_name(gid: int) -> String: return "Camelot" if gid != 0 else ""
class FakeProto extends Node:
func item(vnum: int) -> Dictionary:
if vnum == 30001:
return {"vnum": vnum, "type": 12, "locale_name": "Trout"}
return {}
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -52,7 +59,10 @@ func _run() -> void:
get_root().add_child(fc)
var chat: Node = ChatUI.new()
get_root().add_child(chat)
chat.setup(fc, canvas)
chat.setup(fc, canvas, AssetRoot.path())
var proto := FakeProto.new()
get_root().add_child(proto)
chat.set_proto(proto)
_ck(chat._log.size() == 4, "4 tabs built")
var tAll: RichTextLabel = chat._log[0]
@@ -81,11 +91,17 @@ func _run() -> void:
fc.item_picked_up.emit(27, 3, "")
_ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System")
_ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle")
# 钓鱼服务端事件:成功/鱼获进入系统和战斗日志。
fc.fishing_event.emit(3, 1000, 0)
_ck(_last(tSys).contains("鱼钩命中"), "fishing success -> System")
# 钓鱼服务端事件:SUCCESS 只切动画;FISH 按 IsFishing() + item type
# 走 ClientVS22 的 notify/success locale 分支。
fc.fishing_event.emit(0, 1000, 0)
fc.fishing_event.emit(5, 30001, 0)
_ck(_last(tBattle).contains("捕获物品 #30001"), "fishing item -> Battle")
_ck(_last(tBattle).contains("Trout") and _last(tBattle).contains("hooked"),
"fishing notify fish -> Battle locale")
fc.fishing_event.emit(3, 1000, 0)
fc.fishing_event.emit(5, 30001, 0)
_ck(_last(tSys).contains("You captured Trout!"), "fishing success fish -> System locale")
chat.on_fishing_feedback("FISHING_WRONG_PLACE")
_ck(_last(tSys).contains("You cannot go fishing here."), "wrong place -> locale")
# --- 前缀解析 ---
chat._on_submit("hello world")
@@ -97,6 +113,14 @@ func _run() -> void:
chat._on_submit("/w Bob psst hey")
_ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')")
_ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab")
# game.py OpenWhisperDialog:目标框独立于公共聊天输入行,回车发送后关闭。
chat.start_whisper("Carol")
_ck(chat.is_whisper_open() and chat.whisper_target() == "Carol",
"start_whisper -> 独立私聊窗口打开并锁定目标")
chat._on_whisper_submit("private window")
_ck(fc.whispered.size() == 2 and fc.whispered[1] == ["Carol", "private window"],
"独立私聊窗口发送 -> whisper('Carol', msg)")
_ck(not chat.is_whisper_open(), "私聊发送后关闭独立窗口")
# --- 气泡 ---
var nw: Node = NetWorld.new()
@@ -111,6 +135,67 @@ func _run() -> void:
nw._bubble(2000, "hi there")
var bob: Node3D = nw.node_for(2000)
_ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node")
# §8.8 seam ⑨(UpdateShowingTextTail :110-114):有「聊天」气泡时名字尾标顶到气泡上方 17px。
var bob_name := bob.get_node_or_null("Label3D") as Label3D
var bob_bubble := bob.get_node_or_null("bubble") as Label3D
_ck(bob_name != null and bob_bubble != null, "entity 2000 has Label3D + bubble")
_ck(absf(bob_name.position.y - (bob_bubble.position.y + 17.0 * bob_name.pixel_size)) < 0.0001,
"聊天气泡在 -> 名字 y = 气泡 y + 17*pixel_size")
_ck(bob_name.position.y > bob_bubble.position.y, "名字尾标顶到气泡上方")
# §8.8 seam ⑦ 收口(增量 119 / ArrangeTextTail :228/:258/:283):公会名 / 称号 / 等级
# 子标签锚在名字尾标上,名字被气泡顶起时三者整体平移同一段。
var g_tag := bob.get_node_or_null("GuildTag") as Label3D
var t_tag := bob.get_node_or_null("TitleTag") as Label3D
var l_tag := bob.get_node_or_null("LevelTag") as Label3D
_ck(g_tag != null and t_tag != null and l_tag != null, "entity 2000 has 公会/称号/等级子标签")
var lift := bob_name.position.y - 2.1
_ck(lift > 0.0, "名字尾标确有上移量")
_ck(absf(g_tag.position.y - (2.34 + lift)) < 0.0001, "GuildTag 随名字上移同一段")
_ck(absf(t_tag.position.y - (1.86 + lift)) < 0.0001, "TitleTag 随名字上移同一段")
_ck(absf(l_tag.position.y - (1.74 + lift)) < 0.0001, "LevelTag 随名字上移同一段")
# 信息尾标(bNameFlag FALSE)不顶名字 —— 复用同一张 _chat_tails,覆盖成 info 后名字回默认。
nw.register_info_tail(2000, "skill fizzled")
_ck(absf(bob_name.position.y - 2.1) < 0.0001, "信息尾标覆盖 -> 名字回默认局部 y 2.1")
_ck(absf(g_tag.position.y - 2.34) < 0.0001, "信息尾标覆盖 -> GuildTag 回默认 2.34")
_ck(absf(t_tag.position.y - 1.86) < 0.0001, "信息尾标覆盖 -> TitleTag 回默认 1.86")
_ck(absf(l_tag.position.y - 1.74) < 0.0001, "信息尾标覆盖 -> LevelTag 回默认 1.74")
# --- §8.8 seam ⑦ 水平排版(增量 121 / ArrangeTextTail :211-320 非 EUROPE 分支)------
# charlie 有公会 + 善恶度(称号) + 等级 -> 三子标签沿名字锚点水平排开:
# 等级在最左、称号居中偏左、名字本身右移 fxAdd(=8);公会名(无徽记)留 x = 0。
fc.ents[3000] = {"vid": 3000, "name": "Charlie", "guild": 7, "alignment": 15000, "level": 42}
fc.entity_spawned.emit({"vid": 3000, "name": "Charlie", "pos": Vector3(3, 0, 0), "func": 0, "angle_deg": 0.0})
await process_frame
var charlie: Node3D = nw.node_for(3000)
_ck(charlie != null, "entity 3000 spawned")
nw._apply_text_tail(3000)
var c_name := charlie.get_node_or_null("Label3D") as Label3D
var c_g := charlie.get_node_or_null("GuildTag") as Label3D
var c_t := charlie.get_node_or_null("TitleTag") as Label3D
var c_l := charlie.get_node_or_null("LevelTag") as Label3D
_ck(c_g.text == "Camelot", "GuildTag 文本经 get_guild_name 解出")
_ck(c_t.text != "", "TitleTag 文本非空(alignment 15000 -> grade 0 -> 有称号)")
_ck(c_l.text != "", "LevelTag 文本非空(level 42")
# 名字本身右移 fxAdd —— 有称号 -> 8px 折世界。
_ck(absf(c_name.position.x - 8.0 * c_name.pixel_size) < 0.0001,
"名字 x = name_x_shift(有称号)=8 * pixel_size")
# 称号 / 等级在名字左侧(x < 0),等级比称号更靠左。
_ck(c_t.position.x < 0.0, "称号在名字锚点左侧")
_ck(c_l.position.x < c_t.position.x, "等级比称号更靠左(-nameW/2 - 8 - titleW")
# 公会名无徽记 -> 留在名字中线正上方。
_ck(absf(c_g.position.x) < 0.0001, "GuildTag x = 0poc 无公会徽记)")
# 精确值:nameW = len("Charlie")*7 = 49,称号 x 偏移 = -(49/2) - 8 = -32.5。
_ck(absf(c_t.position.x - (-(7.0 * 7.0 / 2.0) - 8.0) * c_t.pixel_size) < 0.0001,
"称号 x = (-nameW/2 - 8) * pixel_size 精确")
# 撤销称号(alignment -> 0grade 4 = TITLE_NONE)后称号 x 归零、名字改右移 4(只剩等级)。
fc.ents[3000]["alignment"] = 0
nw._apply_text_tail(3000)
_ck(c_t.text == "" and absf(c_t.position.x) < 0.0001, "称号消失 -> TitleTag x 归零")
_ck(absf(c_name.position.x - 4.0 * c_name.pixel_size) < 0.0001,
"只剩等级 -> 名字 x = name_x_shift(仅等级)=4 * pixel_size")
# 精确值(无称号):等级 x 偏移 = -(nameW/2) - 4 = -(49/2) - 4 = -28.5。
_ck(absf(c_l.position.x - (-(7.0 * 7.0 / 2.0) - 4.0) * c_l.pixel_size) < 0.0001,
"只剩等级:等级 x = (-nameW/2 - 4) * pixel_size 精确")
nw.set_local_vid(1000)
nw._bubble(1000, "my line")
_ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")
+273 -21
View File
@@ -22,17 +22,33 @@ class FakeClient extends Node:
var main := 1000
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
var attacks := []
var moves := []
var synced := []
var said := []
var affects := []
var fly_targets := [] # §3.6CG_FLY_TARGETING
var shots := [] # §3.6CG_SHOOT
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main
func get_entity(vid) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_affects() -> Array: return affects
func attack(motion, vid) -> bool: attacks.append([motion, vid]); return true
func add_fly_targeting(vid, x, y) -> bool: fly_targets.append([vid, x, y]); return true
func shoot(skill) -> bool: shots.append(skill); return true
func set_target(v) -> bool: return true
func move(a, b, c, d, e) -> bool: return true
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
func sync_positions(arr) -> bool: synced.append(arr); return true
func say(t, s) -> bool: said.append([t, s]); return true
class FakeNetWorld extends Node:
var nodes := {}
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
class FakeProto extends Node:
var items := {}
func item(vnum: int) -> Dictionary: return items.get(vnum, {})
class FakePC extends Node:
signal target_selected(node: Node3D)
signal moved(pos: Vector3)
@@ -40,8 +56,13 @@ class FakePC extends Node:
var player: Node3D
var frozen := false
class FakeAnim extends Node:
var md := {}
func get_motion_data() -> Dictionary: return md
class FakeView extends Node3D:
var states := []
var anim := FakeAnim.new()
func set_anim_state(s): states.append(s)
class FakeHud extends Node:
@@ -57,7 +78,7 @@ func _ck(c: bool, m: String) -> void:
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: combat_fx_test (hitstun / combo / shake / affects / death)")
print("PASS: combat_fx_test (hit-gate / combo / shake / affects / death)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
@@ -81,23 +102,36 @@ func _run() -> void:
np.player_view = view
np._main_vid = 1000
# --- 受击硬直 + 抖屏 ---
# --- 受击:播击退动作 + 抖屏;「命中」不靠固定硬直秒数锁输入(§3.7)---
fc.damage.emit(1000, 20, 0) # 命中(非闪避)
_ck(np.is_stunned(), "hit -> stunned")
_ck(np._knock_down, "hit -> knock_down latch set")
_ck(np.is_stunned(), "hit (knock_down) -> cannot act")
_ck(cam._shake > 0.0, "hit -> camera shake queued")
_ck("damage" in view.states, "hit -> view set_anim_state('damage')")
np._process(0.016)
_ck(pc.frozen == true, "stun -> pc.frozen = true (got %s)" % pc.frozen)
_ck(pc.frozen == true, "knock_down -> pc.frozen = true (got %s)" % pc.frozen)
# 闪避不硬直
np._hitstun_until = 0.0
# 击退动作结束 -> 解除(动作时长驱动)
np._knock_down = false
_ck(not np.is_stunned(), "knockback motion ended -> can act")
# 服务端 stun 标志独立驱动闸门
fc.ents[1000]["stunned"] = true
_ck(np.is_stunned(), "server stunned flag -> cannot act")
_ck(not np._can_attack(), "server stunned -> _can_attack() false")
fc.ents[1000]["stunned"] = false
_ck(not np.is_stunned(), "server stun cleared -> can act")
# 闪避不播击退
np._knock_down = false
fc.damage.emit(1000, 0, 1 << 2) # DODGE
_ck(not np.is_stunned(), "dodge -> not stunned")
_ck(not np._knock_down, "dodge -> no knock_down")
_ck(not np.is_stunned(), "dodge -> can act")
# 别人受击不影响自己
np._hitstun_until = 0.0
np._knock_down = false
fc.damage.emit(2000, 30, 0)
_ck(not np.is_stunned(), "other's damage -> not stunned")
_ck(not np._knock_down, "other's damage -> no knock_down")
# Fishing motion subheaders drive the local player view; FISH item outcome is ignored.
np.player_view = view
@@ -110,19 +144,237 @@ func _run() -> void:
_ck(view.states.size() == before, "fishing fish item -> no local motion")
# --- combo:连续攻击 motion 递增 ---
np._hitstun_until = 0.0
np._knock_down = false
np._target_vid = 2000
np.attack_period = 0.0
np.ATTACK_RANGE # (const, no-op)
# 让目标在攻击距离内
# net_world 为 null -> _process 里 node_for 走不到;直接测 combo 计数逻辑:
np._last_attack_t = np._now()
np._combo = 0
# 模拟三次「窗口内攻击」
for i in 3:
np._combo = (np._combo + 1) % 3 if (np._now() - np._last_attack_t) < np.COMBO_WINDOW else 0
np._last_attack_t = np._now()
_ck(np._combo == 0, "combo cycles 1->2->0 over 3 hits, got %d" % np._combo)
# §3.3:普攻节奏来自动作数据 / 攻速系数,不再是硬编码常量
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD),
"no motion data -> attack period falls back to DEFAULT_ATTACK_PERIOD")
np._apply_attack_speed(150)
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"attack speed scales the resolved period")
np._apply_attack_speed(100)
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
np.combo_class = 0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
view.anim.md = {
"has_combo_input": true, "duration": 1.0,
"pre_input_time": 0.10, "direct_input_time": 0.20, "input_limit_time": 0.40,
"next_combo": 0.20,
}
var pcn := Node3D.new(); get_root().add_child(pcn)
np.pc.player = pcn
np._vid_reserved = 2000
fc.attacks.clear()
await process_frame # SceneTree 脚本:等一帧让 add_child 的节点真正入树(global_position 才有效)
# 第一次输入:m_dwcurComboIndex 0 -> __RunNextCombo -> 段 1(段号 14
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1, "first input -> combo index 1, got %d" % np._combo_index)
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
# elapsed 落在 [start, next):置 m_isPreInput,不推进
np._combo_started_t = np._now() - 0.15
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
# ComboProcesselapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
np._combo_started_t = np._now() - 0.25
np._combo_process()
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "advance to last seg 4")
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
np._combo_started_t = np._now() - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
# 动作回到 Waitelapsed > duration-> __ClearCombo
np._combo_started_t = np._now() - 1.5
np._combo_process()
_ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo")
# 没有段表(缺资源)-> 退化为单段普攻(段号 13)
np._combo_tables = {}
fc.moves.clear()
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
"no combo table -> single NORMAL_ATTACK swing")
# --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList ---
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree()
# 几何判定读 global_position,所以这一小节先 await 一帧。
var nw := FakeNetWorld.new(); get_root().add_child(nw)
np.net_world = nw
var vnode := Node3D.new(); get_root().add_child(vnode)
nw.nodes[2000] = vnode
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true}
fc.ents[1000]["dead"] = false
await process_frame
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 0 -> 前向 +Z
pcn.global_position = Vector3.ZERO
pcn.rotation.y = 0.0
view.anim.md = {
"hit_windows": [{
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand",
"weapon_length": 120.0, "samples": [],
}],
"motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1,
"next_combo": 0.2,
}
np._combo_tables = {}
fc.attacks.clear()
fc.synced.clear()
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
np._swing_start_t = np._now() - 0.1 # 推进到命中窗内
np._process(0.016)
_ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000],
"in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)")
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000,
"pushed victim -> frame-end CG_SYNC_POSITION")
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
vnode.global_position = Vector3(0, 0, -1.0) # 转到身后
np._hit_dedup.clear()
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit")
vnode.global_position = Vector3(0, 0, 1.0)
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
np._process(0.016)
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
np.net_world = null
# --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)---
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, false, false) \
== np.MOTION_MODE_ONEHAND_SWORD, "sword -> ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_TWO_HANDED, false, false, false) \
== np.MOTION_MODE_TWOHAND_SWORD, "two-handed -> TWOHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_DAGGER, false, false, false) \
== np.MOTION_MODE_DUALHAND_SWORD, "dagger -> DUALHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, false, false, false) \
== np.MOTION_MODE_BOW, "bow -> BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_FAN, false, false, false) \
== np.MOTION_MODE_FAN, "fan -> FAN")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BELL, false, false, false) \
== np.MOTION_MODE_BELL, "bell -> BELL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, false, false, false) \
== np.MOTION_MODE_GENERAL, "arrow / bare hand -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, false, false, false) \
== np.MOTION_MODE_FISHING, "fishing rod -> FISHING")
_ck(np.motion_mode_for(np.ITEM_TYPE_ROD, 0, true, false, false) \
== np.MOTION_MODE_HORSE, "rod while mounted -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, false) \
== np.MOTION_MODE_HORSE_ONEHAND_SWORD, "mounted sword -> HORSE_ONEHAND_SWORD")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_BOW, true, false, false) \
== np.MOTION_MODE_HORSE_BOW, "mounted bow -> HORSE_BOW")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_ARROW, true, false, false) \
== np.MOTION_MODE_HORSE, "mounted arrow -> HORSE")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, false, true, false) \
== np.MOTION_MODE_GENERAL, "poly overrides weapon -> GENERAL")
_ck(np.motion_mode_for(np.ITEM_TYPE_WEAPON, np.WEAPON_SUB_SWORD, true, false, true) \
== np.MOTION_MODE_WEDDING_DRESS, "wedding dress overrides all -> WEDDING_DRESS")
# 端到端:proto 桩 + parts[WEAPON] -> _refresh_motion_mode() -> combo_motion_mode 变,且清连击
var fp := FakeProto.new()
fp.items[19] = {"type": np.ITEM_TYPE_WEAPON, "sub_type": np.WEAPON_SUB_DAGGER}
np.proto = fp
np._main_vid = 1000
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._combo_index = 3
fc.ents[1000]["parts"] = [0, 19, 0, 0]
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_DUALHAND_SWORD, "parts[WEAPON]=dagger -> DUALHAND_SWORD")
_ck(np._combo_index == 0, "motion mode change clears the running combo")
fc.ents[1000]["mount_vnum"] = 20101
np._refresh_motion_mode()
_ck(np.combo_motion_mode == np.MOTION_MODE_HORSE_DUALHAND_SWORD, "mount up -> HORSE_DUALHAND_SWORD")
fc.ents[1000].erase("parts")
fc.ents[1000].erase("mount_vnum")
np.proto = null
# --- §3.5 增量 85:弓箭 __GetBowRange() → _clickable_distance ---
np._bow_distance_bonus = 0.0
np.combo_motion_mode = np.MOTION_MODE_GENERAL
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), np.CLICK_DIST_DEFAULT_CM / np.CM),
"non-bow: 玩家 / 怪目标 reach = 150cm")
np.combo_motion_mode = np.MOTION_MODE_BOW
_ck(is_equal_approx(np._bow_range_cm(), 2500.0 - 100.0), "__GetBowRange 基础 = 2500-100")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0) / np.CM),
"bow mode: 非 NPC / 非采集物目标 reach = __GetBowRange()")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 1}), np.CLICK_DIST_NPC_CM / np.CM),
"NPC 覆盖弓箭距离 → 500cm")
_ck(is_equal_approx(np._clickable_distance({"ch_type": 3}), np.CLICK_DIST_RESOURCE_CM / np.CM),
"采集物覆盖弓箭距离 → 100cm")
np.combo_motion_mode = np.MOTION_MODE_HORSE_BOW
_ck(np._is_bow_mode(), "HORSE_BOW 也算 bow mode")
np._bow_distance_bonus = 300.0
_ck(is_equal_approx(np._clickable_distance({"ch_type": 0}), (2500.0 - 100.0 + 300.0) / np.CM),
"POINT_BOW_DISTANCE 加成叠加进 __GetBowRange()")
np._on_points({"attack_speed": 0, "bow_distance": 0})
_ck(is_equal_approx(np._bow_distance_bonus, 0.0), "_on_points 读取 bow_distance → _bow_distance_bonus")
np.combo_motion_mode = np.MOTION_MODE_GENERAL
# --- §3.6 增量 86:弓 FLY 事件(OnSetFlyTarget / OnShoot---
var nw2 := FakeNetWorld.new(); get_root().add_child(nw2)
var tnode := Node3D.new(); get_root().add_child(tnode)
await process_frame
tnode.global_position = Vector3(3.0, 0.0, 4.0)
nw2.nodes[2000] = tnode
np.net_world = nw2
np._target_vid = 2000
np._swing_skill = 0
np._combo_tables = {}
view.anim.md = {}
fc.fly_targets.clear()
fc.attacks.clear()
var got_shot := []
np.bow_shot_fired.connect(func(sk: int): got_shot.append(sk))
# 非弓模式:起手不发 CG_FLY_TARGETING,也不冒 bow_shot_fired
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.is_empty() and got_shot.is_empty(), "非弓模式挥击 → 无 FLY 事件")
# 弓模式:起手即发 CG_FLY_TARGETING(目标VID, 目标平面坐标) + 冒 bow_shot_fired(uSkill)
np.combo_motion_mode = np.MOTION_MODE_BOW
np._emit_swing(np.NAME_NORMAL_ATTACK, false)
_ck(fc.fly_targets.size() == 1 and fc.fly_targets[0][0] == 2000,
"弓挥击起手 → OnSetFlyTarget 发 CG_FLY_TARGETING(vid 2000)")
var exp_xy := MapCoord.to_server_cm(Vector3(3.0, 0.0, 4.0))
_ck(fc.fly_targets[0][1] == int(exp_xy.x) and fc.fly_targets[0][2] == int(exp_xy.y),
"CG_FLY_TARGETING 坐标 = 目标节点 server-XYOnGetFlyTargetPosition 平面分量)")
_ck(got_shot == [0], "弓挥击 → bow_shot_fired(uSkill=0)OnShoot 延后到 .msa FLY 帧)")
# 无目标:不发
np._target_vid = 0
np._vid_reserved = 0
fc.fly_targets.clear()
np._send_fly_target()
_ck(fc.fly_targets.is_empty(), "无目标 → 不发 CG_FLY_TARGETING")
np.net_world = null
np.combo_motion_mode = np.MOTION_MODE_GENERAL
np._swing_skill = 0
# --- 状态图标条 ---
fc.affects = [{"type": 7001, "point_idx": 19, "value": 25, "duration": 300}]
+150
View File
@@ -0,0 +1,150 @@
# complex_item_drop_test —— 仓库 / 商城 / 交易 / NPC 商店的物品态回归。
# 验证增量 54 的全局 MouseController 可以把物品送到各窗口的明确目标槽,
# 而不是只验证背包和快捷栏。
extends SceneTree
const MouseController = preload("res://ui/mouse_controller.gd")
const CursorManager = preload("res://ui/cursor_manager.gd")
const SafeboxUI = preload("res://ui/safebox_ui.gd")
const MallUI = preload("res://ui/mall_ui.gd")
const ShopUI = preload("res://ui/shop_ui.gd")
const ExchangeUI = preload("res://ui/exchange_ui.gd")
class FakeClient extends Node:
signal safebox_changed()
signal mall_opened(size: int)
signal mall_changed()
signal shop_opened(vid: int)
signal shop_closed()
signal shop_error(kind: String)
signal exchange_changed()
var calls: Array = []
var safe_open := true
var safe_size := 1
var safe_items: Array = []
var mall_open := true
var mall_size := 1
var mall_items: Array = []
var shop_open := true
var exchange := {"active": true, "self_items": [], "peer_items": []}
func is_safebox_open() -> bool: return safe_open
func get_safebox_size() -> int: return safe_size
func get_safebox_gold() -> int: return 0
func get_safebox_items() -> Array: return safe_items
func safebox_checkin(dst, window, cell) -> bool:
calls.append(["safe_checkin", dst, window, cell]); return true
func safebox_move(src, dst, count) -> bool:
calls.append(["safe_move", src, dst, count]); return true
func safebox_checkout(src, window, cell) -> bool:
calls.append(["safe_checkout", src, window, cell]); return true
func is_mall_open() -> bool: return mall_open
func get_mall_size() -> int: return mall_size
func get_mall_items() -> Array: return mall_items
func mall_checkout(src, window, cell) -> bool:
calls.append(["mall_checkout", src, window, cell]); return true
func is_shop_open() -> bool: return shop_open
func get_shop_items() -> Array: return []
func get_shop() -> Dictionary: return {"tabs": [{"name": "", "items": []}]}
func shop_sell(cell, count) -> bool:
calls.append(["shop_sell", cell, count]); return true
func get_exchange() -> Dictionary: return exchange
func exchange_add_item(window, cell, slot) -> bool:
calls.append(["exchange_add_item", window, cell, slot]); return true
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _has_call(client: FakeClient, name: String, predicate: Callable) -> bool:
for call in client.calls:
if call[0] == name and predicate.call(call):
return true
return false
func _init() -> void:
var host := Control.new()
get_root().add_child(host)
var cursor := CursorManager.new()
get_root().add_child(cursor)
var mouse := MouseController.new()
get_root().add_child(mouse)
mouse.setup(host, cursor)
var client := FakeClient.new()
get_root().add_child(client)
var safe := SafeboxUI.new()
safe.item_mouse = mouse
get_root().add_child(safe)
safe.setup(client, host)
client.safe_items = [{"cell": 2, "vnum": 19, "count": 3}]
safe.refresh()
_ck(mouse._targets.size() >= 45, "safebox registers all 45 visible drop slots")
_ck(safe._drop_to_slot({"window": 1, "cell": 7, "vnum": 19, "count": 1}, 4),
"inventory item drops into empty safebox slot")
_ck(_has_call(client, "safe_checkin", func(c): return c[1] == 4 and c[2] == 1 and c[3] == 7),
"safebox drop uses explicit destination slot")
var safe_item := safe._cells[2] as Button
var safe_event := InputEventMouseButton.new()
safe_event.button_index = MOUSE_BUTTON_LEFT
safe_event.pressed = true
safe._on_grid_input(2, safe_event)
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 3,
"safebox item enters global item state")
mouse.cancel()
var mall := MallUI.new()
mall.item_mouse = mouse
get_root().add_child(mall)
mall.setup(client, host)
client.mall_items = [{"cell": 5, "vnum": 27, "count": 2}]
mall.refresh()
var mall_name := mall._list.get_child(0).get_child(0) as Label
var mall_event := InputEventMouseButton.new()
mall_event.button_index = MOUSE_BUTTON_LEFT
mall_event.pressed = true
mall_name.gui_input.emit(mall_event)
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 4
and int(mouse.attached().get("cell", -1)) == 5,
"mall item enters global item state")
mouse.cancel()
var exchange := ExchangeUI.new()
exchange.item_mouse = mouse
get_root().add_child(exchange)
exchange.setup(client, host)
_ck(exchange._drop_to_slot({"window": 1, "cell": 8, "vnum": 29, "count": 1}, 6),
"inventory item drops into empty exchange slot")
_ck(_has_call(client, "exchange_add_item", func(c): return c[1] == 1 and c[2] == 8 and c[3] == 6),
"exchange drop uses explicit display slot")
var shop := ShopUI.new()
shop.item_mouse = mouse
get_root().add_child(shop)
shop.setup(client, host)
shop.open()
_ck(shop._mode == 1 and not shop._sell_drop_target.visible,
"shop opens in buy mode with sell target hidden")
_ck(not shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
"buy mode rejects item drop to sell target")
shop._set_mode(2)
_ck(shop._mode == 2 and shop._sell_drop_target.visible,
"sell mode shows sell target")
_ck(shop._drop_to_sell({"window": 1, "cell": 9, "vnum": 31, "count": 4}),
"inventory item drops into shop sell target")
_ck(_has_call(client, "shop_sell", func(c): return c[1] == 9 and c[2] == 1),
"shop drop uses selected sell quantity")
if _fail == 0:
print("PASS: complex_item_drop_test (safebox + mall + exchange + shop)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://x2vuateiv3w1
+117
View File
@@ -0,0 +1,117 @@
# damage_effect —— §8.8 / §5.4 头顶伤害飘字「是否 / 如何显示」1:1:
# CInstanceBase::AddDamageEffect + ProcessDamage 的分类(DODGE|BLOCK -> MISS、
# self -> "damage_"、nontarget -> 不渲染、target -> "target_"+ 数字逐位拆分 +
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
# (与 name_color.gd / text_tail.gd / chat_tail.gd / name_show.gd 同一套纯静态镜像套路。)
#
# REF40250 ClientVS22 source/ 为唯一基线):
# UserInterface/InstanceBase.h:300 enum DamageFlag { NORMAL 1<<0, POISON 1<<1, DODGE 1<<2, BLOCK 1<<3, PENETRATE 1<<4, CRITICAL 1<<5 }
# UserInterface/Packet.h:1943 TPacketGCDamageInfo { BYTE header; DWORD dwVID; BYTE flag; int damage; }
# UserInterface/PythonNetworkStreamPhaseGame.cpp:2401 RecvDamageInfoPacket
# pInstTarget = GetInstancePtr(dwVID)bSelf = (pInstTarget == MainInstance)
# bTarget = (pInstTarget == m_pInstTarget)damage >= 0 才 AddDamageEffect< 0 记 TraceError
# UserInterface/InstanceBaseEffect.cpp:87 AddDamageEffect:仅当 IsShowDamage() 才 push m_DamageQueue{bSelf,bTarget,damage,flag}
# UserInterface/InstanceBaseEffect.cpp:100 ProcessDamageInstanceBase.cpp:1910 每帧调一次):
# 每帧 pop_front 一条;
# (flag & DODGE) || (flag & BLOCK) -> bSelf ? EFFECT_DAMAGE_MISS : EFFECT_DAMAGE_TARGETMISSreturn
# (flag & CRITICAL) -> 该 build CreateEffect 被注释掉,落到普通数字(无独立暴击视觉)
# POISON 分支整段注释掉
# bSelf -> strDamageType="damage_"EFFECT_DAMAGE_SELFDAMAGE / SELFDAMAGE2 交替(m_bDamageEffectType 每次取反)
# !bTarget -> strDamageType="nontarget_"EFFECT_DAMAGE_NOT_TARGET 后 **return(不渲染)**
# else -> strDamageType="target_"EFFECT_DAMAGE_TARGET
# while(damage>0)num=damage%10; damage/=10; index 0..7>7 TraceError break);
# 贴图 "d:/ymir work/effect/affect/damagevalue/<type><num>.dds"
# 水平偏移 FONT_WIDTH(30)*index(相机右方向,matTrans._41 = -(FONT_WIDTH*index)
# UserInterface/PythonSystem.cpp:308 m_Config.bShowDamage = true(默认)
# UserInterface/PythonSystem.cpp:337 IsShowDamage() -> m_Config.bShowDamage
# UserInterface/PythonSystem.cpp:342 SetShowDamageFlag(iFlag) -> bShowDamage = (iFlag == 1)
# UserInterface/PythonSystem.cpp:451 config "SHOW_DAMAGE" -> atoi(value) == 1
# UserInterface/PythonSystem.cpp:537 save "SHOW_DAMAGE %d"(仅当 == 0,即非默认时落盘)
# UserInterface/PythonSystemModule.cpp:192/219 systemSetShowDamageFlag / systemIsShowDamage
# root/uigameoption.py __OnClickShowDamageOn/OffButton -> systemSetting.SetShowDamageFlag(...)
#
# seam ⑪:参考端每位数字是一张 damagevalue/<type><digit>.dds 贴图做的 CEffectManager 特效,
# 在相机右方向按 FONT_WIDTH*index 像素铺开;本移植 net_world 用一块 billboard Label3D 直接写
# str(amount)(无 damagevalue dds 图集 / 无逐位特效 / 无相机右向像素铺排),MISS 写 "MISS"。
# self 的 SELFDAMAGE/SELFDAMAGE2 交替本移植退化成落字水平抖动符号交替(仅存在感,无双特效资源)。
# 暴击在该 40250 build 参考端本就无独立视觉,这里也不再放大 / 变色(与 <=112 的占位实现相比是回归对齐)。
# 另:参考端 m_DamageQueue 是「每个实例一条」,ProcessDamage 每帧每实例 pop 一条;本移植是
# net_world 一条全局队列每帧 pop 一条 —— 同一帧多个 VID 同时受击时会分帧显示。
extends RefCounted
# CInstanceBase::DamageFlag
const DAMAGE_NORMAL := 1 << 0
const DAMAGE_POISON := 1 << 1
const DAMAGE_DODGE := 1 << 2
const DAMAGE_BLOCK := 1 << 3
const DAMAGE_PENETRATE := 1 << 4
const DAMAGE_CRITICAL := 1 << 5
const SHOW_DAMAGE_DEFAULT := true # m_Config.bShowDamage 默认
const CFG_KEY := "SHOW_DAMAGE" # PythonSystem 配置行 / 存盘键
const FONT_WIDTH := 30 # ProcessDamage 逐位水平像素步进
const MAX_DIGITS := 8 # index > 7 -> break
enum Kind { MISS, DIGITS }
# AddDamageEffect:仅当 IsShowDamage() 才入队
static func should_queue(show_damage: bool) -> bool:
return show_damage
# SetShowDamageFlag(int iFlag): m_Config.bShowDamage = iFlag == 1
static func show_damage_from_flag(flag: int) -> bool:
return flag == 1
# config "SHOW_DAMAGE" 行:atoi(value) == 1
static func show_damage_from_cfg(value: Variant) -> bool:
return int(value) == 1
# RecvDamageInfoPacketbSelf = 目标实例是主角
static func is_self(vid: int, main_vid: int) -> bool:
return vid != 0 and vid == main_vid
# RecvDamageInfoPacketbTarget = 目标实例是当前目标框 VID
static func is_target(vid: int, target_vid: int) -> bool:
return vid != 0 and vid == target_vid
# RecvDamageInfoPacketdamage < 0 记 TraceError 不处理
static func valid_amount(amount: int) -> bool:
return amount >= 0
# ProcessDamage 分类。返回:
# {"kind": Kind, "type": String("damage_"/"target_"/"nontarget_"), "show": bool, "self": bool}
# show == false 只有一种来源:非 self 且非 target 的数字伤害(ProcessDamage 里 return,不渲染)。
static func classify(flag: int, b_self: bool, b_target: bool) -> Dictionary:
if (flag & DAMAGE_DODGE) != 0 or (flag & DAMAGE_BLOCK) != 0:
# EFFECT_DAMAGE_MISSself/ EFFECT_DAMAGE_TARGETMISS(其它)
return {"kind": Kind.MISS, "type": "damage_" if b_self else "target_", "show": true, "self": b_self}
if b_self:
return {"kind": Kind.DIGITS, "type": "damage_", "show": true, "self": true}
if not b_target:
return {"kind": Kind.DIGITS, "type": "nontarget_", "show": false, "self": false}
return {"kind": Kind.DIGITS, "type": "target_", "show": true, "self": false}
# while(damage>0){ num=damage%10; damage/=10; } —— 低位在前,最多 8 位(index>7 break)。
# damage <= 0 不产出任何位(与参考 while 一致)。
static func digits(damage: int) -> Array:
var out: Array = []
var d := damage
if d <= 0:
return out
var index := 0
while d > 0:
if index > MAX_DIGITS - 1:
break
out.append(d % 10)
d = d / 10
index += 1
return out
# 第 index 位数字相对锚点的水平像素偏移(相机右方向)。
static func digit_offset_px(index: int) -> float:
return float(FONT_WIDTH * index)
# ProcessDamage 的 self 特效交替:m_bDamageEffectType==false -> SELFDAMAGE(0),否则 SELFDAMAGE2(1)
# 每次自伤后取反。返回本次用的槽位(0/1)。
static func self_damage_slot(toggle_state: bool) -> int:
return 1 if toggle_state else 0
+1
View File
@@ -0,0 +1 @@
uid://dlpn6gv5jrh14
+112
View File
@@ -0,0 +1,112 @@
# damage_effect_test —— §8.8 / §5.4 头顶伤害飘字 1:1
# CInstanceBase::AddDamageEffect / ProcessDamage 的分类(DODGE|BLOCK -> MISS、
# self -> "damage_"、nontarget -> 不渲染、target -> "target_")、数字逐位拆分、
# CPythonSystem::IsShowDamage / SetShowDamageFlag 门。
# godot --headless --path project --script damage_effect_test.gd
# 退出码 0 = 全过。
extends SceneTree
const DamageEffect = preload("res://damage_effect.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: damage_effect_test (§8.8/§5.4 AddDamageEffect / ProcessDamage 分类 + IsShowDamage 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# --- 0. 常量(InstanceBase.h / PythonSystem.cpp / InstanceBaseEffect.cpp----
_ck(DamageEffect.DAMAGE_NORMAL == 1 << 0, "DAMAGE_NORMAL == 1<<0")
_ck(DamageEffect.DAMAGE_POISON == 1 << 1, "DAMAGE_POISON == 1<<1")
_ck(DamageEffect.DAMAGE_DODGE == 1 << 2, "DAMAGE_DODGE == 1<<2")
_ck(DamageEffect.DAMAGE_BLOCK == 1 << 3, "DAMAGE_BLOCK == 1<<3")
_ck(DamageEffect.DAMAGE_PENETRATE == 1 << 4, "DAMAGE_PENETRATE == 1<<4")
_ck(DamageEffect.DAMAGE_CRITICAL == 1 << 5, "DAMAGE_CRITICAL == 1<<5")
_ck(DamageEffect.SHOW_DAMAGE_DEFAULT == true, "m_Config.bShowDamage 默认 true")
_ck(DamageEffect.CFG_KEY == "SHOW_DAMAGE", "配置键 == SHOW_DAMAGE")
_ck(DamageEffect.FONT_WIDTH == 30, "FONT_WIDTH == 30")
_ck(DamageEffect.MAX_DIGITS == 8, "index 上限 8>7 break")
# --- 1. IsShowDamage 门 -----------------------------------------------------
_ck(DamageEffect.should_queue(true) == true, "IsShowDamage() -> 入队")
_ck(DamageEffect.should_queue(false) == false, "!IsShowDamage() -> 不入队")
_ck(DamageEffect.show_damage_from_flag(1) == true, "SetShowDamageFlag(1) -> true")
_ck(DamageEffect.show_damage_from_flag(0) == false, "SetShowDamageFlag(0) -> false")
_ck(DamageEffect.show_damage_from_flag(2) == false, "SetShowDamageFlag(2) -> false(严格 == 1")
_ck(DamageEffect.show_damage_from_cfg("1") == true, "cfg \"1\" -> true")
_ck(DamageEffect.show_damage_from_cfg("0") == false, "cfg \"0\" -> false")
_ck(DamageEffect.show_damage_from_cfg("2") == false, "cfg \"2\" -> false")
_ck(DamageEffect.show_damage_from_cfg(1) == true, "cfg int 1 -> true")
# --- 2. bSelf / bTargetRecvDamageInfoPacket----------------------------
_ck(DamageEffect.is_self(42, 42) == true, "vid == main -> bSelf")
_ck(DamageEffect.is_self(42, 7) == false, "vid != main -> !bSelf")
_ck(DamageEffect.is_self(0, 0) == false, "vid 0 -> !bSelf")
_ck(DamageEffect.is_target(9, 9) == true, "vid == targetBoard VID -> bTarget")
_ck(DamageEffect.is_target(9, 0) == false, "无目标 -> !bTarget")
_ck(DamageEffect.is_target(0, 0) == false, "vid 0 -> !bTarget")
# --- 3. damage >= 0 门 --------------------------------------------------
_ck(DamageEffect.valid_amount(0) == true, "damage 0 -> 处理(>= 0")
_ck(DamageEffect.valid_amount(5) == true, "damage 5 -> 处理")
_ck(DamageEffect.valid_amount(-1) == false, "damage < 0 -> TraceError 不处理")
# --- 4. ProcessDamage 分类 --------------------------------------------------
var m := DamageEffect.Kind.MISS
var g := DamageEffect.Kind.DIGITS
# DODGE / BLOCK -> MISS
var c := DamageEffect.classify(DamageEffect.DAMAGE_DODGE, true, true)
_ck(c["kind"] == m and c["type"] == "damage_" and c["show"] and c["self"], "DODGE + self -> MISS damage_ show self")
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE, false, true)
_ck(c["kind"] == m and c["type"] == "target_" and c["show"] and not c["self"], "DODGE + 非 self -> MISS target_")
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, true, false)
_ck(c["kind"] == m and c["type"] == "damage_", "BLOCK + self -> MISS damage_")
c = DamageEffect.classify(DamageEffect.DAMAGE_BLOCK, false, false)
_ck(c["kind"] == m and c["type"] == "target_", "BLOCK + 非 self -> MISS target_(即便非 target")
c = DamageEffect.classify(DamageEffect.DAMAGE_DODGE | DamageEffect.DAMAGE_BLOCK, false, true)
_ck(c["kind"] == m, "DODGE|BLOCK 合并 -> MISS")
# 数字:self / target / nontarget
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, true, false)
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"] and c["self"], "普通 + self -> DIGITS damage_ show self")
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, true)
_ck(c["kind"] == g and c["type"] == "target_" and c["show"] and not c["self"], "普通 + target -> DIGITS target_ show")
c = DamageEffect.classify(DamageEffect.DAMAGE_NORMAL, false, false)
_ck(c["kind"] == g and c["type"] == "nontarget_" and c["show"] == false, "普通 + 非 self 非 target -> nontarget_ 不渲染")
# CRITICAL 在该 build 无独立视觉 -> 仍按数字
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, true, false)
_ck(c["kind"] == g and c["type"] == "damage_" and c["show"], "CRITICAL + self -> 仍普通数字 damage_")
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, true)
_ck(c["kind"] == g and c["type"] == "target_" and c["show"], "CRITICAL + target -> 普通数字 target_")
c = DamageEffect.classify(DamageEffect.DAMAGE_CRITICAL, false, false)
_ck(c["show"] == false, "CRITICAL + nontarget -> 仍不渲染")
# POISON 分支被注释掉 -> 走普通
c = DamageEffect.classify(DamageEffect.DAMAGE_POISON, false, true)
_ck(c["kind"] == g and c["type"] == "target_", "POISON(分支注释掉)+ target -> 普通数字 target_")
# --- 5. digits:低位在前,最多 8 位 --------------------------------------
_ck(DamageEffect.digits(0) == [], "digits(0) == []while 零次)")
_ck(DamageEffect.digits(-5) == [], "digits(-5) == []")
_ck(DamageEffect.digits(7) == [7], "digits(7) == [7]")
_ck(DamageEffect.digits(123) == [3, 2, 1], "digits(123) == [3,2,1](低位在前)")
_ck(DamageEffect.digits(90) == [0, 9], "digits(90) == [0,9]")
_ck(DamageEffect.digits(2000000) == [0, 0, 0, 0, 0, 0, 2], "digits(2000000) 7 位")
_ck(DamageEffect.digits(999999999).size() == 8, "digits(9 位) 截到 8 位(index>7 break")
_ck(DamageEffect.digits(999999999) == [9, 9, 9, 9, 9, 9, 9, 9], "digits(999999999) == 8×9")
# --- 6. digit_offset_pxFONT_WIDTH*index ------------------------------
_ck(is_equal_approx(DamageEffect.digit_offset_px(0), 0.0), "第 0 位偏移 0")
_ck(is_equal_approx(DamageEffect.digit_offset_px(1), 30.0), "第 1 位偏移 30")
_ck(is_equal_approx(DamageEffect.digit_offset_px(3), 90.0), "第 3 位偏移 90")
# --- 7. self 特效交替(m_bDamageEffectType---------------------------
_ck(DamageEffect.self_damage_slot(false) == 0, "toggle false -> SELFDAMAGE(0)")
_ck(DamageEffect.self_damage_slot(true) == 1, "toggle true -> SELFDAMAGE2(1)")
+1
View File
@@ -0,0 +1 @@
uid://c77cvj7gtqck4
+44
View File
@@ -0,0 +1,44 @@
# dungeon_result_test —— DUNGEON_RESULT 的九字段 UI 消费回归。
extends SceneTree
const DungeonResultUI = preload("res://ui/dungeon_result_ui.gd")
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: dungeon_result_test (ShowDungeonResult nine fields)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var layer := CanvasLayer.new()
get_root().add_child(layer)
var result_ui := DungeonResultUI.new()
get_root().add_child(result_ui)
result_ui.setup(layer)
var result := {
"killstone_count": 2, "killmob_count": 40, "find_hidden": 1, "hidden_total": 3,
"use_potion": 5, "is_revived": 0, "killallmob": 1, "total_time": 600,
"bonus_exp": 1234,
}
result_ui.show_result(result)
await process_frame
_ck(result_ui.is_open(), "result window is visible")
_ck(result_ui.result() == result, "all nine server fields are retained")
var rows: Array = result_ui._display_rows()
_ck(rows.size() == 8, "display has the eight grouped rows")
_ck(str(rows[6][1]) == "10:00", "total_time is formatted as mm:ss")
_ck(str(rows[7][1]) == "1234", "bonus_exp is displayed")
result_ui.close()
_ck(not result_ui.is_open(), "close hides result window")
layer.queue_free()
result_ui.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://icsacgdld3gb
+86
View File
@@ -0,0 +1,86 @@
# DungeonState —— GC_DUNGEON 目的地状态(40250/ClientVS22)。
#
# 参考端 PythonNetworkStreamPhaseGame.cpp:3875-3901 收到目的地后调用
# CPythonPlayer::SetDungeonDestinationPositionPythonPlayer.cpp:147-167、
# :1255-1285 保存全局像素/厘米坐标、立即提示,并在接近目标后清除,未到达时
# 每 20 秒再次提示。本工程把目标状态与 UI 解耦,UI 只消费信号。
extends Node
signal destination_changed(active: bool, world_pos: Vector3)
signal destination_reached(world_pos: Vector3)
signal alarm_requested(world_pos: Vector3)
const TIME_ATTACK_START := 0
const DESTINATION_POSITION := 1
const ARRIVAL_THRESHOLD_CM := 10000.0
const ALARM_INTERVAL_SEC := 20.0
var client: Node
var player_getter: Callable
var active := false
var destination_cm := Vector2.ZERO
var destination_world := Vector3.ZERO
var last_subheader := -1
var alarm_count := 0
var _last_alarm_ms := -1
func setup(m2client: Node, get_player: Callable) -> void:
client = m2client
player_getter = get_player
if client and client.has_signal("dungeon_event"):
client.dungeon_event.connect(_on_dungeon_event)
if client and client.has_signal("world_reset"):
client.world_reset.connect(clear)
func _on_dungeon_event(subheader: int, x: int, y: int, has_destination: bool) -> void:
last_subheader = subheader
# ClientVS22's TIME_ATTACK_START branch is intentionally empty. Consume the
# packet but do not invent a local timer that the reference client does not run.
if subheader != DESTINATION_POSITION or not has_destination:
return
destination_cm = Vector2(float(x), float(y))
# GC_DUNGEON carries TPixelPosition x/y in global server centimetres. The
# network frame uses -Y for Godot Z before MapCoord applies the map base.
destination_world = MapCoord.to_world(Vector3(float(x) * 0.01, 0.0, -float(y) * 0.01))
active = true
_last_alarm_ms = -1
destination_changed.emit(true, destination_world)
_alarm()
func clear() -> void:
var was_active := active
active = false
destination_cm = Vector2.ZERO
destination_world = Vector3.ZERO
_last_alarm_ms = -1
if was_active:
destination_changed.emit(false, Vector3.ZERO)
func _player() -> Node3D:
if not player_getter.is_valid():
return null
var p: Variant = player_getter.call()
return p as Node3D
func _alarm() -> void:
_last_alarm_ms = Time.get_ticks_msec()
alarm_count += 1
alarm_requested.emit(destination_world)
func _process(_dt: float) -> void:
if not active:
return
var p := _player()
if p:
# Match PythonPlayer.cpp:155: integer Manhattan distance, not Euclidean.
var p_cm := MapCoord.to_server_cm(p.global_position)
var distance_cm := absf(p_cm.x - destination_cm.x) + absf(p_cm.y - destination_cm.y)
if distance_cm < ARRIVAL_THRESHOLD_CM:
var reached := destination_world
active = false
destination_changed.emit(false, reached)
destination_reached.emit(reached)
return
var now := Time.get_ticks_msec()
if _last_alarm_ms < 0 or now - _last_alarm_ms > int(ALARM_INTERVAL_SEC * 1000.0):
_alarm()
+1
View File
@@ -0,0 +1 @@
uid://da5ixd8n62gyh
+71
View File
@@ -0,0 +1,71 @@
# dungeon_state_test —— GC_DUNGEON 目的地状态回归。
# godot --headless --path project --script dungeon_state_test.gd
extends SceneTree
const DungeonState = preload("res://dungeon_state.gd")
class FakeClient extends Node:
signal dungeon_event(subheader: int, x: int, y: int, has_destination: bool)
signal world_reset
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: dungeon_state_test (GC_DUNGEON destination / arrival / reset)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
MapCoord.set_base(Vector2.ZERO)
var client := FakeClient.new()
var player := Node3D.new()
get_root().add_child(client)
get_root().add_child(player)
var state := DungeonState.new()
get_root().add_child(state)
state.setup(client, func() -> Node3D: return player)
await process_frame
var changes: Array = []
var alarms: Array = []
state.destination_changed.connect(func(active: bool, pos: Vector3): changes.append([active, pos]))
state.alarm_requested.connect(func(pos: Vector3): alarms.append(pos))
client.dungeon_event.emit(state.TIME_ATTACK_START, 0, 0, false)
_ck(not state.active and state.last_subheader == state.TIME_ATTACK_START,
"TIME_ATTACK_START is consumed without inventing a timer")
client.dungeon_event.emit(state.DESTINATION_POSITION, 3210, 6540, true)
_ck(state.active, "destination packet activates route")
_ck(state.destination_cm == Vector2(3210, 6540), "destination keeps server cm")
_ck(state.destination_world.distance_to(Vector3(32.1, 0.0, 65.4)) < 0.01,
"destination converts global cm through MapCoord")
_ck(changes.size() == 1 and changes[0][0] == true, "destination_changed opens target")
_ck(alarms.size() == 1, "destination alarms immediately")
# Reference uses Manhattan < 10000 cm, so exactly 100m is not complete and
# 99m is complete. Keep the player in the same global coordinate frame.
player.global_position = Vector3(32.1, 0.0, 65.4 - 100.0)
state._process(0.0)
_ck(state.active, "Manhattan distance exactly 100m remains active")
player.global_position = Vector3(32.1, 0.0, 65.4 - 99.0)
state._process(0.0)
_ck(not state.active, "Manhattan distance below 100m completes route")
_ck(changes.size() == 2 and changes[1][0] == false, "arrival closes target")
client.dungeon_event.emit(state.DESTINATION_POSITION, 10000, 20000, true)
_ck(state.active, "second destination reopens route")
client.world_reset.emit()
_ck(not state.active and state.destination_cm == Vector2.ZERO and changes.size() == 4,
"world reset clears dungeon destination")
client.queue_free()
player.queue_free()
state.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://exadl2wqkpmh
+261
View File
@@ -0,0 +1,261 @@
# entity_rules.gd —— CInstanceBase::IsAttackableInstance 及其判据的 1:1 迁移(§5.4)。
#
# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/):
# UserInterface/InstanceBase.cpp:2147 IsAttackableInstance —— 唯一判据,逐行照抄
# UserInterface/InstanceBase.cpp:2116 IsConflictAlignmentInstance
# UserInterface/InstanceBase.cpp:470 IsSameEmpire
# UserInterface/InstanceBase.cpp:502 GetAlignmentGrade
# UserInterface/InstanceBase.cpp:524 GetAlignmentType
# UserInterface/InstanceBase.cpp:2059 SetPKMode / :2073 SetKiller / :2086 SetStateFlags
# UserInterface/InstanceBaseEffect.cpp:495 __FindDUELKey
# UserInterface/InstanceBaseEffect.cpp:505 IsPVPInstance
# UserInterface/PythonPlayer.cpp:1445 IsSamePartyMember
# UserInterface/InstanceBase.cpp:2390 IsWoodenDoorvnum 13000 / 30111..30119
#
# 纯静态谓词。调用方(net_play.gd)负责把网络快照拼成 self_e / victim_e / ctx
# 部分依赖在本 40250 客户端的发布源码里没有 setter(同 §3.4/§3.6/§3.8 的处理),
# 在下方逐条标注为 seam。
extends RefCounted
# EPKModesREF/UserInterface/Packet.h:1273
const PK_MODE_PEACE := 0
const PK_MODE_REVENGE := 1
const PK_MODE_FREE := 2
const PK_MODE_PROTECT := 3
const PK_MODE_GUILD := 4
# alignment typeREF/UserInterface/InstanceBase.h:249
const ALIGNMENT_TYPE_WHITE := 0
const ALIGNMENT_TYPE_NORMAL := 1
const ALIGNMENT_TYPE_DARK := 2
# duel modeREF/UserInterface/InstanceBase.h:378GetDuelMode 返回 m_dwDuelMode
const DUEL_NONE := 0
const DUEL_CANNOTATTACK := 1
const DUEL_START := 2
# ADD_CHARACTER_STATE_*REF/UserInterface/Packet.h:1264)—— SetStateFlags 从 state_flags
# 位里取 killer / partyInstanceBase.cpp:2086)。
const STATE_KILLER := 1 << 3
const STATE_PARTY := 1 << 4
# _entity_kind 语义(net_play._entity_kind):0 PC / 1 NPC / 2 MONSTER / 3 STONE /
# 4 WARP / 5 BUILDING / 6 WOODEN_DOOR。
const KIND_PC := 0
const KIND_NPC := 1
const KIND_MONSTER := 2
const KIND_STONE := 3
const KIND_WARP := 4
const KIND_BUILDING := 5
const KIND_WOODEN_DOOR := 6
# --- alignment(善恶)---------------------------------------------------------
# CInstanceBase::GetAlignmentGradeInstanceBase.cpp:502
static func alignment_grade(a: int) -> int:
if a >= 12000: return 0
elif a >= 8000: return 1
elif a >= 4000: return 2
elif a >= 1000: return 3
elif a >= 0: return 4
elif a > -4000: return 5
elif a > -8000: return 6
elif a > -12000: return 7
return 8
# CInstanceBase::GetAlignmentTypeInstanceBase.cpp:524
static func alignment_type(a: int) -> int:
match alignment_grade(a):
0, 1, 2, 3:
return ALIGNMENT_TYPE_WHITE
5, 6, 7, 8:
return ALIGNMENT_TYPE_DARK
return ALIGNMENT_TYPE_NORMAL
# --- 类别谓词(GraphicThingInstance 的 type 位,见 net_play._entity_kind---------
static func _kind(e: Dictionary) -> int:
return int(e.get("kind", e.get("ch_type", 0)))
static func _is_poly(e: Dictionary) -> bool:
# m_GraphicThingInstance.IsPoly()(变身:模型换成怪,thing type 变 ENEMY
return bool(e.get("poly", false)) \
or int(e.get("polymorph", 0)) != 0 \
or int(e.get("poly_vnum", 0)) != 0
static func _is_pc(e: Dictionary) -> bool:
# CActorInstance::IsPC = (TYPE_PC == m_eActorType)ActorInstance.cpp:282)。
# 变身中的玩家仍是 TYPE_PC —— IsPoly() 另经 race >= MAIN_RACE_MAX_NUM 判真,
# 但 IsPC() 不看 race,所以这里**不**排除 poly(否则 IsAttackableInstance 的
# `else if (IsPC())` 会漏掉变身玩家,与参考端不一致)。
return _kind(e) == KIND_PC
static func _is_stone(e: Dictionary) -> bool:
return _kind(e) == KIND_STONE
static func _is_enemy(e: Dictionary) -> bool:
# CActorInstance::IsEnemy = (TYPE_ENEMY == m_eActorType)ActorInstance.cpp:298)——
# 仅普通怪。变身玩家是 TYPE_PC 不是 ENEMY;独立的 TYPE_POLY 实例归 _is_poly。
return _kind(e) == KIND_MONSTER
static func _is_wooden_door(e: Dictionary) -> bool:
# CInstanceBase::IsWoodenDoorInstanceBase.cpp:2390
if _kind(e) == KIND_WOODEN_DOOR:
return true
var vnum := int(e.get("race", e.get("vnum", 0)))
return vnum == 13000 or (vnum >= 30111 and vnum <= 30119)
static func _is_building(e: Dictionary) -> bool:
# CInstanceBase::IsBuilding() = m_GraphicThingInstance.IsBuilding()。
# classic 的 bType / POC fixture 可直接给 kind;旧包仍可用 building 布尔字段。
return bool(e.get("building", false)) or _kind(e) == KIND_BUILDING \
or _kind(e) == KIND_WOODEN_DOOR
# --- killer / party / empire / duel / pvp -----------------------------------
static func _is_killer(e: Dictionary) -> bool:
# m_isKiller,由 SetStateFlags(state_flags & ADD_CHARACTER_STATE_KILLER) 置位。
return (int(e.get("state_flags", 0)) & STATE_KILLER) != 0
static func _is_same_party(a_vid: int, b_vid: int, ctx: Dictionary) -> bool:
# CPythonPlayer::IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b)
var vids: Array = ctx.get("party_vids", [])
return a_vid in vids and b_vid in vids
static func _is_same_empire(self_e: Dictionary, victim_e: Dictionary) -> bool:
# CInstanceBase::IsSameEmpireInstanceBase.cpp:470
if int(victim_e.get("empire", 0)) == 0:
return true
# IsGameMaster() —— 本 POC 无 GM 标记 → seam(恒 false)。
if bool(self_e.get("game_master", false)) or bool(victim_e.get("game_master", false)):
return true
return int(victim_e.get("empire", 0)) == int(self_e.get("empire", 0))
static func _find_duel_key(self_vid: int, victim_vid: int, ctx: Dictionary) -> bool:
# CInstanceBase::__FindDUELKeyInstanceBaseEffect.cpp:495)—— g_kSet_dwDUELKey 里
# 有 MAKE_PVPKEY(min,max) 才算。本 POC 的 get_duel().opponents 是主角的对手 VID 列表。
var opp: Array = ctx.get("duel_opponents", [])
return victim_vid in opp or self_vid in opp
static func _pair_in(pairs: Array, a: int, b: int) -> bool:
for p in pairs:
if (int(p[0]) == a and int(p[1]) == b) or (int(p[0]) == b and int(p[1]) == a):
return true
return false
static func _is_pvp_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool:
# CInstanceBase::IsPVPInstanceInstanceBaseEffect.cpp:505):
# if (GetDuelMode()) return true;
# return __FindPVPKey(vidSrc, vidDst) || __FindGVGKey(guildSrc, guildDst);
if int(ctx.get("duel_mode", 0)) != 0:
return true
var sv := int(self_e.get("vid", 0))
var vv := int(victim_e.get("vid", 0))
if _pair_in(ctx.get("pvp_pairs", []), sv, vv):
return true
var sg := int(self_e.get("guild", 0))
var vg := int(victim_e.get("guild", 0))
if sg != 0 and vg != 0 and _pair_in(ctx.get("gvg_pairs", []), sg, vg):
return true
return false
static func is_conflict_alignment_instance(self_e: Dictionary, victim_e: Dictionary) -> bool:
# CInstanceBase::IsConflictAlignmentInstanceInstanceBase.cpp:2116
if _pk_mode_of(victim_e) == PK_MODE_PROTECT:
return false
var mine := alignment_type(int(self_e.get("alignment", 0)))
var theirs := alignment_type(int(victim_e.get("alignment", 0)))
match mine:
ALIGNMENT_TYPE_NORMAL, ALIGNMENT_TYPE_WHITE:
if theirs == ALIGNMENT_TYPE_DARK:
return true
ALIGNMENT_TYPE_DARK:
if mine != theirs:
return true
return false
static func _pk_mode_of(e: Dictionary) -> int:
return int(e.get("pk_mode", 0))
# --- CInstanceBase::IsAttackableInstanceInstanceBase.cpp:2147)—— 逐行 -----------
static func is_attackable_instance(self_e: Dictionary, victim_e: Dictionary, ctx: Dictionary) -> bool:
var sv := int(self_e.get("vid", 0))
var vv := int(victim_e.get("vid", -1))
# if (__IsMainInstance()) { if (rkPlayer.IsObserverMode()) return false; }
if sv == int(ctx.get("main_vid", 0)) and bool(ctx.get("observer", false)):
return false
# if (GetVirtualID() == rkInstVictim.GetVirtualID()) return false;
if sv == vv:
return false
var self_pk := int(ctx.get("pk_mode", 0))
if _is_stone(self_e):
if _is_pc(victim_e):
return true
elif _is_pc(self_e):
if _is_stone(victim_e):
return true
if _is_pc(victim_e):
var duel := int(ctx.get("duel_mode", 0))
if duel != 0:
match duel:
DUEL_CANNOTATTACK:
return false
DUEL_START:
return _find_duel_key(sv, vv, ctx)
# if (PK_MODE_GUILD == GetPKMode()) if (GetGuildID() == victim.GetGuildID()) return false;
if self_pk == PK_MODE_GUILD:
if int(self_e.get("guild", 0)) == int(victim_e.get("guild", 0)):
return false
# if (victim.IsKiller()) if (!IsSamePartyMember(...)) return true;
if _is_killer(victim_e):
if not _is_same_party(sv, vv, ctx):
return true
# if (PK_MODE_PROTECT != GetPKMode()) { ... }
if self_pk != PK_MODE_PROTECT:
if self_pk == PK_MODE_FREE:
if _pk_mode_of(victim_e) != PK_MODE_PROTECT:
if not _is_same_party(sv, vv, ctx):
return true
if self_pk == PK_MODE_GUILD:
if _pk_mode_of(victim_e) != PK_MODE_PROTECT:
if not _is_same_party(sv, vv, ctx):
if int(self_e.get("guild", 0)) != int(victim_e.get("guild", 0)):
return true
# if (IsSameEmpire(victim)) { ... } else { return true; }
if _is_same_empire(self_e, victim_e):
if _is_pvp_instance(self_e, victim_e, ctx):
return true
if self_pk == PK_MODE_REVENGE:
if not _is_same_party(sv, vv, ctx):
if is_conflict_alignment_instance(self_e, victim_e):
return true
else:
return true
if _is_enemy(victim_e):
return true
if _is_wooden_door(victim_e):
return true
elif _is_enemy(self_e):
if _is_pc(victim_e):
return true
if _is_building(victim_e):
return true
elif _is_poly(self_e):
if _is_pc(victim_e):
return true
if _is_enemy(victim_e):
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://7g66aavury6r
+172
View File
@@ -0,0 +1,172 @@
# entity_rules_test —— §5.4 CInstanceBase::IsAttackableInstance 1:1 迁移自检。
# godot --headless --path project --script entity_rules_test.gd
# 覆盖:alignment grade/type 边界、观战门、自打自、PC/怪/NPC/石头/木门、
# 同帝国 peace、异帝国、PK_FREE/GUILD/REVENGE 分支、killer、决斗三态、
# PVP 关系表、公会战对表、conflict alignment、变身自身。
extends SceneTree
const EntityRules = preload("res://entity_rules.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _e(kind: int, vid: int, extra: Dictionary = {}) -> Dictionary:
var d := {"kind": kind, "vid": vid, "empire": 1, "alignment": 0, "pk_mode": 0, "guild": 0, "state_flags": 0}
for k in extra:
d[k] = extra[k]
return d
func _ctx(extra: Dictionary = {}) -> Dictionary:
var d := {
"observer": false, "main_vid": 1, "pk_mode": 0, "duel_mode": 0,
"duel_opponents": [], "pvp_pairs": [], "gvg_pairs": [], "party_vids": [],
}
for k in extra:
d[k] = extra[k]
return d
func _init() -> void:
_run()
if _fail == 0:
print("PASS: entity_rules_test (§5.4 IsAttackableInstance 1:1)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var R = EntityRules
# 1) alignment grade / type 边界(InstanceBase.cpp:502 / :524
_ck(R.alignment_grade(12000) == 0 and R.alignment_grade(11999) == 1, "grade 12000/11999")
_ck(R.alignment_grade(0) == 4 and R.alignment_grade(-1) == 5, "grade 0/-1")
_ck(R.alignment_grade(-12000) == 8 and R.alignment_grade(-11999) == 7, "grade -12000/-11999")
_ck(R.alignment_type(5000) == R.ALIGNMENT_TYPE_WHITE, "type white (grade<=3)")
_ck(R.alignment_type(0) == R.ALIGNMENT_TYPE_NORMAL, "type normal (grade 4)")
_ck(R.alignment_type(-5000) == R.ALIGNMENT_TYPE_DARK, "type dark (grade>=5)")
var me := _e(R.KIND_PC, 1)
# 2) 观战模式:主角一律不可攻击
_ck(not R.is_attackable_instance(me, _e(R.KIND_MONSTER, 2),
_ctx({"observer": true})), "observer -> not attackable")
# 3) 自己打自己
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 1), _ctx()), "self == victim -> false")
# 4) PC vs 怪(IsEnemy
_ck(R.is_attackable_instance(me, _e(R.KIND_MONSTER, 9), _ctx()), "PC vs monster -> true")
# 5) PC vs NPC —— 无分支命中
_ck(not R.is_attackable_instance(me, _e(R.KIND_NPC, 9), _ctx()), "PC vs NPC -> false")
# 6) PC vs 同帝国 peace PC
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2), _ctx()),
"PC vs same-empire peace PC -> false")
# 7) PC vs 异帝国 PCelse 分支)
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 2}), _ctx()),
"PC vs different-empire PC -> true")
# empire==0 的 victim 视为同帝国(IsSameEmpire 首行)
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"empire": 0}), _ctx()),
"victim empire 0 -> treated same empire -> false")
# 8) PK_FREEvictim 非 protect 且不同队 -> true
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs normal PC -> true")
# 9) PK_FREEvictim 是 protect -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"pk_mode": R.PK_MODE_PROTECT}),
_ctx({"pk_mode": R.PK_MODE_FREE})), "PK_FREE vs protect victim -> false")
# 10) PK_FREE:同队 -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pk_mode": R.PK_MODE_FREE, "party_vids": [1, 2]})),
"PK_FREE same party -> false")
# 11) PK_GUILD:同公会 -> 提前 false
var me_g := _e(R.KIND_PC, 1, {"guild": 7})
_ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 7}),
_ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD same guild -> false")
# 12) PK_GUILD:不同公会、victim 非 protect、不同队 -> true
_ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}),
_ctx({"pk_mode": R.PK_MODE_GUILD})), "PK_GUILD diff guild -> true")
# 13) killer victimstate_flags bit3):不同队即可打,即便自己 peace
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}), _ctx()),
"killer victim, not party -> true")
# 14) killer victim 但同队 -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2, {"state_flags": R.STATE_KILLER}),
_ctx({"party_vids": [1, 2]})), "killer victim, same party -> false")
# 15) 决斗 DUEL_CANNOTATTACK -> false
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"duel_mode": R.DUEL_CANNOTATTACK})), "DUEL_CANNOTATTACK -> false")
# 16) 决斗 DUEL_START:对手在名单 -> true;不在 -> false
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})),
"DUEL_START vs opponent -> true")
_ck(not R.is_attackable_instance(me, _e(R.KIND_PC, 3),
_ctx({"duel_mode": R.DUEL_START, "duel_opponents": [2]})),
"DUEL_START vs non-opponent -> false")
# 17) PVP 关系表(__FindPVPKey):同帝国 peace 也能打
_ck(R.is_attackable_instance(me, _e(R.KIND_PC, 2),
_ctx({"pvp_pairs": [[2, 1]]})), "PVP pair present -> true")
# 18) 公会战对表(__FindGVGKey):双方都有公会且对表命中
_ck(R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 9}),
_ctx({"gvg_pairs": [[7, 9]]})), "GVG pair present -> true")
_ck(not R.is_attackable_instance(me_g, _e(R.KIND_PC, 2, {"guild": 0}),
_ctx({"gvg_pairs": [[7, 0]]})), "GVG needs both guilds non-zero -> false")
# 19) PK_REVENGE + conflict alignmentself white, victim dark)不同队 -> true
var me_w := _e(R.KIND_PC, 1, {"alignment": 10000})
_ck(R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": -10000}),
_ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + conflict alignment -> true")
# 20) PK_REVENGE 但 alignment 不冲突(都 white-> false
_ck(not R.is_attackable_instance(me_w, _e(R.KIND_PC, 2, {"alignment": 10000}),
_ctx({"pk_mode": R.PK_MODE_REVENGE})), "REVENGE + non-conflict -> false")
# 21) 石头自身 vs PC victim -> true
_ck(R.is_attackable_instance(_e(R.KIND_STONE, 5), _e(R.KIND_PC, 2), _ctx({"main_vid": 5})),
"stone self vs PC -> true")
# 22) PC self vs 石头 victim -> true
_ck(R.is_attackable_instance(me, _e(R.KIND_STONE, 5), _ctx()), "PC vs stone -> true")
# 23) PC vs 木门(race 13000 / 30111..30119
_ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13000}, _ctx()),
"PC vs wooden door 13000 -> true")
_ck(R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 30115}, _ctx()),
"PC vs wooden door 30115 -> true")
_ck(not R.is_attackable_instance(me, {"kind": 9, "vid": 8, "race": 13001}, _ctx()),
"PC vs stone door 13001 -> false")
_ck(R._is_wooden_door({"kind": R.KIND_WOODEN_DOOR, "race": 999}),
"explicit WOODEN_DOOR kind -> IsWoodenDoor")
_ck(R._is_building({"kind": R.KIND_BUILDING}),
"explicit BUILDING kind -> IsBuilding")
_ck(R._is_building({"kind": R.KIND_WOODEN_DOOR}),
"wooden door kind -> IsBuilding")
# 24) is_conflict_alignment_instancevictim PK_PROTECT 一律 false
_ck(not R.is_conflict_alignment_instance(me_w,
_e(R.KIND_PC, 2, {"alignment": -10000, "pk_mode": R.PK_MODE_PROTECT})),
"conflict alignment: protect victim -> false")
# dark self vs 非 dark victim -> conflict
var me_d := _e(R.KIND_PC, 1, {"alignment": -10000})
_ck(R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": 0})),
"conflict alignment: dark vs normal -> true")
_ck(not R.is_conflict_alignment_instance(me_d, _e(R.KIND_PC, 2, {"alignment": -20000})),
"conflict alignment: dark vs dark -> false")
# 25a) 变身玩家(TYPE_PC + 越界 race):IsPC() 仍为真 → 完全走 PC 分支。
var me_polypc := _e(R.KIND_PC, 1, {"poly": true})
_ck(not R.is_attackable_instance(me_polypc, _e(R.KIND_PC, 2), _ctx()),
"polymorphed PC vs peaceful same-empire PC -> false (still a PC)")
_ck(R.is_attackable_instance(me_polypc, _e(R.KIND_MONSTER, 3), _ctx()),
"polymorphed PC vs monster -> true")
# 25b) 真正的 TYPE_POLY 实例(非 PC 类别)走 else-if IsPoly() 分支:vs PC / vs 怪 均可打。
var poly_self := _e(R.KIND_NPC, 1, {"poly": true})
_ck(R.is_attackable_instance(poly_self, _e(R.KIND_PC, 2), _ctx()), "TYPE_POLY self vs PC -> true")
_ck(R.is_attackable_instance(poly_self, _e(R.KIND_MONSTER, 3), _ctx()), "TYPE_POLY self vs enemy -> true")
+1
View File
@@ -0,0 +1 @@
uid://b8hnrnm5whxx
+61
View File
@@ -0,0 +1,61 @@
# environment_test —— 真实 .msenv -> Godot 运行时断言。
# 验证参考端的 Filter / Character light / cloud fields are parsed and that
# an existing cloud texture is attached to the appropriate sky material.
extends SceneTree
var _fail := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
if not ClassDB.class_exists("Metin2World"):
printerr("FAIL: Metin2World extension is not registered")
quit(1)
return
var world: Node = ClassDB.instantiate("Metin2World")
world.set("auto_load", false)
world.set("assets_root", AssetRoot.path())
world.set("map_path", "OutdoorA1/metin2_map_a1")
world.set("focus_tile", Vector2i(0, 0))
world.set("load_radius_tiles", 0)
world.set("splat_enabled", false)
world.set("water_enabled", false)
get_root().add_child(world)
_ck(bool(world.call("load_map")), "A1 map loads with .msenv")
var rep: Dictionary = world.call("get_load_report")
_ck(bool(rep.get("env_ok", false)), ".msenv env_ok")
var env_node := world.get_node_or_null("WorldEnv") as WorldEnvironment
_ck(env_node != null and env_node.environment != null, "WorldEnv has Environment")
var background_light := world.get_node_or_null("Sun") as DirectionalLight3D
var character_light := world.get_node_or_null("CharacterLight") as DirectionalLight3D
_ck(background_light != null and character_light != null, "background and character lights exist")
if background_light != null and character_light != null:
_ck(background_light.light_cull_mask == 1, "background light only sees layer 1")
_ck(character_light.light_cull_mask == 2, "character light only sees layer 2")
if env_node != null:
_ck(bool(env_node.get_meta("msenv_filter_enabled", false)) == false,
"A1 filter flag preserved")
_ck(is_equal_approx(float(env_node.get_meta("msenv_wind_strength", -1.0)), 0.2),
"reference wind strength default preserved")
_ck(is_equal_approx(float(env_node.get_meta("msenv_wind_random", -1.0)), 0.0),
"reference wind random default preserved")
_ck(env_node.has_meta("msenv_cloud_scale") and env_node.has_meta("msenv_cloud_speed"),
"cloud scale/speed metadata preserved")
var sky := env_node.environment.sky
_ck(sky != null, "WorldEnv has sky")
if sky != null:
var mat := sky.get_material() as ProceduralSkyMaterial
_ck(mat != null, "sky uses ProceduralSkyMaterial")
if mat != null:
_ck(mat.sky_cover != null, "real A1 cloud texture attached as sky cover")
world.queue_free()
await process_frame
if _fail == 0:
print("PASS: environment_test (.msenv parser + runtime sky cover)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://crv0udnsb76ik
+43
View File
@@ -163,3 +163,46 @@ func _run() -> void:
_ck(em4._eff([9, 0, 0, 0], 0, [{"vnum": 5}], 0) == 9, "_eff: parts 非 0 优先")
_ck(em4._eff([0, 0, 0, 0], 0, [{"vnum": 5}], 0) == 5, "_eff: parts 0 -> 装备槽")
_ck(em4._eff([], 1, [{"vnum": 1}, {"vnum": 2}], 1) == 2, "_eff: 无 parts -> 装备槽")
# §4.1 修改 4:动物 / 怪物时装(shape 100-103)遮挡武器 + 头发
var em5: Node = EquipModel.new()
get_root().add_child(em5)
em5.setup(fc, il, func() -> Node: return model, assets)
em5.main_getter = func() -> int: return 1000
em5.armor_shape_of = func(v: int) -> int: return 101 if v == 88888 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4} # 有武器
fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾
fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101
model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x"
em5._last_weapon_vnum = -1; em5._last_shield_vnum = -1
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2)
_ck(model.shield_gr2 == "", "动物时装 -> shield_gr2 清空 (%s)" % model.shield_gr2)
_ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2)
# 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态)
fc.main_parts = [0, 0, 0, 5]
em5._last_weapon_vnum = -999; em5._last_shield_vnum = -999
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"),
"脱时装 -> weapon 重新解析 (%s)" % model.weapon_gr2)
# 婚纱(shape 201)遮挡武器,但头发保留;捧花例外
var em6: Node = EquipModel.new()
get_root().add_child(em6)
em6.setup(fc, il, func() -> Node: return model, assets)
em6.main_getter = func() -> int: return 1000
em6.armor_shape_of = func(v: int) -> int: return 201 if v == 99999 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4}
fc.main_parts = [99999, 0, 0, 5]
model.weapon_gr2 = "x"; model.hair_gr2 = "x"
em6._last_weapon_vnum = -1; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(model.weapon_gr2 == "", "婚纱 + 剑 -> weapon_gr2 清空")
_ck(model.hair_gr2 != "", "婚纱 -> 头发保留 (%s)" % model.hair_gr2)
# 换捧花(vnum 50201-> 武器恢复
fc.main_parts = [99999, 50201, 0, 5]
em6._last_weapon_vnum = -999; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(em6._last_weapon_vnum == 50201, "婚纱 + 捧花 -> 武器 vnum 保留 (%d)" % em6._last_weapon_vnum)

Some files were not shown because too many files have changed in this diff Show More