diff --git a/build-macos-client.sh b/build-macos-client.sh index 3fede2a7..d0c40486 100755 --- a/build-macos-client.sh +++ b/build-macos-client.sh @@ -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-hoc;CODESIGN_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'" diff --git a/build.sh b/build.sh index a44ee3ce..9321f3c7 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md index f130aeee..caec8135 100644 --- a/docs/CLIENT-GAP-FIX.md +++ b/docs/CLIENT-GAP-FIX.md @@ -12,14 +12,15 @@ > 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`, > `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。 > -> 创建 / 修订日期:2026-09-02 +> 创建 / 修订日期:2026-09-03 --- -## 实施进度(2026-09-02) +## 实施进度(2026-09-03) 当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理 -第一批;正在进入并行波 A,不等于 §1、§2.7 或完整场景生命周期全部完成。 +第一批;并行波 A 已有多项实现合入。当前仍有真实链路、平台和 1:1 表现验收项,不能把“代码回归 +通过”误记为全部完成。 已完成并已编译验证: @@ -68,6 +69,768 @@ `skill_fx_test`、`player_motion_test` 全绿。无 skip。 - 未做(留给 Phase 1):BGM 名 → 曲目 / 路径解析与音量真正应用到混音(W4 §9.1)、 `m_pending_actor` 合并逻辑(W1 §2.2)、§8.6 剩余逐行键位与 LSHIFT 翻页(W3)。 +- [x] **增量 51 —— W0 §9.3 生命周期单一属主**:生产入口 `client_main.gd → AppFlow` 创建并持有 + 唯一 `AppLifecycle`,登录 / 选人 / 游戏切屏不重复创建;`GameScene` 只暴露其 `Audio` 给 AppFlow + 绑定;`M2Client` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` 监听,只保留 + `suspend()/resume()`;旧的 `login.gd` 兼容启动器也保持自己的生命周期节点贯穿登录到游戏,且不再 + 在进场时销毁。新增 `app_flow_lifecycle_test.gd`,并扩展 `gamescene_test.gd` 断言无第二个节点。 +- [x] **增量 52 —— W0/W2 §3.9 公共网络 API 参数边界**:新增 + `extension/src/net/net_bounds.h`,统一封装 `u8/u16/u32/i32`、快捷栏 36 槽、交易 12 槽、Cube 24 槽、 + 私人商店 39 项、龙魂精炼 15 格等协议边界;`M2Client` 在转换为 wire 类型前拒绝负数、溢出、错误 + Variant 类型和超出数组 / 文本容量的输入。覆盖连接端口、角色创建、移动 / 同步 / 战斗 / 技能、聊天、 + 社交、公会、商店 / 交易 / 仓库 / 私人商店 / Cube、精炼、物品操作和会徽连接参数。新增 + `net_bounds_test`,C++ 扩展构建与 CTest 18/18 通过。 + +- [x] **增量 53 —— W3/W4/W5 §4.8 §6.3 §8.6 §10**:快捷栏按参考端改为服务器 36 槽、客户端 + 4 页 × 8 格,类型严格使用 `INVENTORY=1/SKILL=2/EMOTION=3`;表情槽接入 `send_emoticon()`, + 补齐 LSHIFT 翻页、F1–F4、Ctrl/Command+数字、空格连续攻击、Alt 显示名字、截图、帮助 / 坐骑 / + 角色窗口 / 小地图 / 聊天 / 相机等入口。`.mse` 粒子读取位置、发射形状、方向 / 速度和首个 + `TextureFiles` 的实际 `.dds`(`UiAssets` + 原生 DDS 解码)。Mac 打包脚本复制 `assets` / `bgm` + 到 `Contents/Resources`,支持 `MT_MAC_ARCHES=universal`、`lipo` 门禁和嵌套扩展先签名,发布凭据 + 改为环境变量。`fx_test`、`input_key_test`、`skill_test`、`netplay_test`、`netbridge_test`、 + `gamescene_test` 全绿;完整资源导出、当前机器双架构产物、正式证书 / notarization、真服 / 真机 + 仍未验收。 + +- [x] **增量 54 —— W3 §4.2 §4.9 / 鼠标物品态、光标和窗口焦点**:新增 + `project/ui/mouse_controller.gd` 全局拖放层和 `cursor_manager.gd` 光标状态层;背包物品可投放到 + 背包格 / 快捷栏,释放到无效区域、右键、ESC、失焦取消;`UiManager` 在窗口打开时拦截不应穿透到 + 世界的键盘事件;`PlayerController`、`GroundItems`、`NetWorld` 接入 PICK / TALK / ATTACK 等 + 首版状态。修正装备窗口拖放的 wire 坐标,并补齐普通 J、Ctrl+Q 和反引号拾取入口。新增 + `mouse_controller_test.gd`;相关 Godot 回归全绿。复杂窗口逐项 mousemodule 语义、真实 `.sub` + 光标资源、CJK IME 和真服 / 真机仍未验收。 + +- [x] **增量 55 —— W4 §9.4 / MilesLib 音频子集**:`audio.gd` 已实现参考端的 + `SetPosition/SetDirection/Update` 监听器接口、普通 / 环境 / 角色 3D 播放、200/1000 坐标缩放、 + 参考音量曲线、角色声音 5000cm / 0.3 秒限频、实例停止、全停和音乐限幅淡出;`.msa` 本地动作声音 + 已接入角色声道。新增 `audio_miles_test.gd`,覆盖常量、换算和限频规则并通过。 + +- [x] **增量 56 —— W3 §4.2/§4.4/§4.5/§4.6/§4.7 / 复杂窗口物品态**:仓库增加 5×9 网格和分页, + 支持背包↔仓库指定格、仓库内移动和仓库↔背包;商城增加分页网格并支持商城→背包;交易增加 12 个 + 指定投放格;NPC 商店增加 BUY/SELL 模式、出售投放区和 BUY/SELL 光标联动;私人商店的 40 格已 + 接入全局物品态。新增 `complex_item_drop_test.gd`,覆盖仓库 / 商城 / 交易 / 商店落点及购买模式 + 拒绝出售投放;代码回归通过。金钱附着、stack split、anti-flag / 售价确认、真实 `.sub` 资源、 + 完整交易事务和真服 / 真机仍未验收。 + +- [x] **增量 57 —— W3 §4.2/§4.5/§4.6/§4.7 + W1 item 实例字段**:全局物品 payload 保留实例 + `anti_flags`、`flags`、`sockets`、`attrs`;普通背包拖放携带整组数量,Shift+左键堆叠物品支持先选 + 附着数量,`MouseController` 增加 `ITEM_MONEY=-1` 独立金币态。交易按参考端补齐服务端 accept 镜像、 + `USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限和已放金币不可编辑; + NPC 商店补齐实例 `ANTIFLAG_SELL`、`IsValuableItem` / `Is1GoldItem` 售价公式、pick/drop/money/ + loginfail 音效和首次确认框;私人商店补齐 `ANTIFLAG_GIVE/MYSHOP`。新增 `proto_item_layout_test`、 + `shop_rules_test`,库存 / 鼠标 / 交易 / 摆摊 / 复杂窗口回归通过。参考端 safebox money 发送函数已核实 + 主动 `return false`,因此不实现伪 `/safebox_money` 命令;完整交易事务、真实 `.sub` 资源、真服 / 真机 + 仍待验收。 + +- [x] **增量 68 —— W4 §7.5 / `.msenv` 格式与静态云层**:按 `MapUtil.cpp` 补齐参考缺省值、 + Fog `IsDensity`、Filter、SkyBox `BTextureRenderMode`、六面天空文件名和 `Reserved`; + `environment_builder.cpp` 通过 `AssetResolver` 将真实云层贴图接入 + `ProceduralSkyMaterial.sky_cover`,并把 Godot 暂无等价渲染载体的参数写入 + `WorldEnvironment` 元数据;修正 macOS 环境路径强制小写问题。`formats_map_test`(内联 + 真实 A1)、 + `environment_test`、`world_probe` 和受影响回归通过;在该批次完成时,六面天空、双方向光分层、 + 云层 UV / 高度动画、滤色实际合成、风参数、lens flare、昼夜天气联动和真机视觉仍待实现 / 验收, + 后续增量 69/70/71/72 已分别补上双光层、texture-mode 六面天空首版、参考风缺省和纹理天空云层组合首版。 +- [x] **增量 69 —— W4 §7.5 / 背景光与角色光分层**:按 + `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264` 保留背景 / 角色两套方向光; + `Sun` 使用 layer 1,仅照地形 / 水面 / 建筑 / 树,新增 `CharacterLight` 使用 layer 2,仅照 + `Metin2Model`、装备和 LOD。换图卸载同时清理两盏灯;`environment_test`、`player_motion_test` + 和 C++ 构建通过。SpeedTree 自身风参数、真实阴影和真机视觉仍待验收。 +- [x] **增量 70 —— W4 §7.5 / texture-mode 六面天空**:按 + `40250/ClientVS22/source/EterLib/SkyBox.cpp:483-542` 对齐 front/back/left/right/top/bottom + 的面顺序和 UV;`environment_builder.cpp` 使用 Godot `sky` shader 加载真实天空面贴图,空面 + 回退 `.msenv` 渐变。新增 `skybox_test.gd`,以真实 CapeDragonHead 环境验证五面资源、空 bottom + 面和 shader 安装;`cmake --build`、`ctest`、`environment_test`、`skybox_test` 通过。纹理天空 + 与云层同时存在时的叠加、云层动画、滤色、风、lens flare、昼夜联动和真机视觉仍待验收。 +- [x] **增量 71 —— W4 §7.5 / SpeedTree 风缺省**:按 + `40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42` 确认 `Environment_Load` 不读取风 token, + 参考缺省为 `fWindStrength=0.2`、`fWindRandom=0`;树 proxy 不再误用 `1.0`, + `WorldEnvironment` 保存对应元数据,`environment_test` 增加断言。SpeedTree 真实几何、森林 + shader 常量和随机风语义仍待实现 / 验收。 +- [x] **增量 72 —— W4 §7.5 / texture-mode 天空与云层组合**:按 + `40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557,869-927` 将有限水平云面接入 Godot + `sky` shader:`CloudScale` / `CloudHeight` 做厘米到米的平面投影,`CloudTextureScale` / + `CloudSpeed` 做 UV 缩放和 `TIME` 秒级滚动,并复现 `MODULATEINVALPHA_ADDCOLOR` + + `ONE/INVSRCCOLOR` 的颜色合成。真实 DawnMistWood 的五面天空 + 云层资源回归通过;云层 + 渐变过渡、滤色、SpeedTree 真实几何 / 风语义、lens flare、昼夜联动和真机视觉仍待实现。 +- [x] **增量 100 —— W4 §6.3 / EffectLib emitter cycle 生命周期首版**:按 + `40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` 对齐缺省 + `CycleLength=0.05`,按 `ParticleSystemInstance.cpp:280-300` 接入有限 cycle / LoopCount 的 + 发射停止;重播时复位 CPU 时间门控,并移除把 cycle 长度错误写入粒子寿命的逻辑。`fx_test` 已 + 覆盖 cycle、LoopCount、独立粒子寿命和 OUTER fallback。OUTER / INNER 的精确径向速度、每周期 + emission residue / 时间曲线重置、随机起始帧和每粒子随机语义仍待实现。 +- [x] **增量 101 —— W3→M2 §5.2 / Quest 日志五行窗口、滚动和任务行事件**:按 + `assets/root/uicharacter.py:651-703,1339-1346` 对齐 `QUEST_MAX_NUM=5`、滚动起始索引和 + `event.QuestButtonClick(-2147483648 + questIndex)`;`quest_log.gd` 保存过滤后的任务快照, + 超过 5 条显示滚动条,按默认 `season1/icon/scroll_open.tga` 或服务端 icon 渲染,补齐任务行点击 + 与 `quest_selected`。`quest_test.gd` 新增 7 条任务、首尾 5 行滚动和事件参数回归,测试通过。 + 世界箭头 / 屏幕边缘指示见增量 108,信件按钮条多列定位见增量 109;任务日志窗体 `_list` 自身 + 多列 / 分页、完整 `.sub` 图标视觉和真服任务生命周期仍待实现。 +- [x] **增量 102 —— W3→M2 §5.3 / 钓鱼输入状态机首版**:按 + `40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24`、 + `PythonPlayerInput.cpp:419-425,506-549` 和 `Packet.h` 对齐钓鱼攻击键、方向 / 地面点击取消及 + `CG_FISHING` 方向参数;`net_play.gd` 在钓鱼模式下阻断普通攻击,使用 `MapCoord` 的 + yaw→server heading,`M2Client::fishing()` 再按参考 wire 量化为 `rot / 5`。收到 + `GC_FISHING` START/REACT/STOP/SUCCESS/FAIL/FISH 时维护客户端意图状态。 + `player_controller.gd` 接入方向键边沿与地面点击取消,保留 `NEW_CancelFishing` 的 500ms + 节流;`quickbar.gd` 对特殊技能 123 接入 `activate_fishing()`。`netplay_test`、 + `player_move_test`、`skill_test`、`gamescene_test`、`combat_fx_test` 已通过。 + `GetFishingRot` 水面像素扫描、wrong-place 文案、鱼获物品落袋以及副本 / 坐骑 / 观战者 + 完整玩法仍待实现。 +- [x] **增量 103 —— W3→M2 §5.3 / GetFishingRot 水面扫描首版**:按 + `40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108`、 + `InstanceBase.cpp:375-377` 和 `PRTerrainLib/Terrain.h:70-72` 接入真实 `ATTRIBUTE_WATER` + 查询。新增 `Metin2World.get_fishing_rotation()` / `can_fishing_position()`,在角色前方 + 600cm 对当前 heading 左右 `0..180°` 每 10° 扫描并返回选中的服务端 heading;`NetWorld` + 桥接地图层给 `NetPlay`,没有地图 provider 时保留协议 fallback。扩展重新编译通过, + `fishing_water_test.gd` 加载 A1 全 20 区块并找到真实水面回归通过。wrong-place locale + 文案、鱼获 item proto / inventory 落袋和副本 / 坐骑 / 观战者完整玩法仍待实现。 +- [x] **增量 99 —— W3→M2 §4.1 修改 5:远端 PC 真模型工厂与 `awPart[4]` 换装首版**: + `game_scene.gd` 对 `ch_type==0` 远端 PC 创建 `RemotePlayerView`,复用 `PlayerView` 的 race / + GR2 / MSA 装载;新增远端专用 `EquipModel.setup_remote()`,只消费 + `awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()`。`net_world.gd` 改为四字段 + `set_armor→set_weapon→set_head→set_hair`,并以真实 warrior 资源回归。当前仍待所有职业 / race、 + 变身字段、真实远端装备视觉和真服多角色验收。 +- [x] **增量 73 —— W2 §3.7 / 受击状态统一**:删除 `net_play.gd` 里参考端不存在的 + `_hitstun_until` 本地硬直计时器;「能否动作」统一走 + `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()` + 三个函数(对齐 `InstanceBase.cpp:1247` ≈ `__IsSyncing` / `InstanceBaseBattle.cpp:355` / + `:310`)。C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露、`mut_knockdown` + 落位),本地由受击 `.msa` 播放生命周期驱动(`player_view.is_in_hit_reaction()`), + 动作结束清零。`combat_fx_test` 断言改写,C++ 19/19 + 相关 GDScript 回归全绿。 + 远端实体 `knock_down` 填充留待后续增量(仅影响远端受击表现,不涉及输入闸门)。 +- [x] **增量 74 —— W2 §3.3 / 普攻节奏硬编码**:`formats/msa.{h,cpp}` 新增 + `Group ComboInputData` + `Group AttackingData` 解析(对齐 + `EterGrnLib CGrannyMotion::LoadMotionData` → `CRaceMotionData::TComboInputData / + TAttackingData`);`metin2_anim` 导出并绑定 `get_motion_data()`。`net_play.gd` + 删除 `ATTACK_RANGE`,改为 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐 + `InstanceBaseBattle.cpp:294 NEW_IsClickableDistanceDestInstance`),普攻节奏改为 + `_current_attack_period()` = `(.msa next_combo 或 DEFAULT_ATTACK_PERIOD) / + (bAttackSpeed/100)`,收 info 包时不再把 `attack_period` 写死。弓箭 `__GetBowRange()` + 留待 §3.5。C++ 19/19 + `combat_fx_test` / `netplay_test` 等 GDScript 回归全绿。 +- [x] **增量 75 —— W2 §3.4 / 目标预留状态机**:`net_play.gd` 新增 + `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` + + 预留字段,`_process` 每帧跑 `_update_auto_attack()`(照抄 `PythonPlayer.cpp:207 + __Update_AutoAttack` 的目标存在 / 死亡 / 骑乘 / charge-skill / 睡眠分支)→ + `_refresh_reserved()` 分派。`_reserve_process_click_actor()` 严格 10 步对齐 + `PythonPlayerInput.cpp:877`(出距离 `pc.walk_to` 靠近、不可攻击走 `click_npc`、 + `IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经新 `signal cannot_act(code)`、 + 第 10 步按 §3.3 节奏 gate `_do_attack_swing`)。安全区 / 弓箭 / 骑乘 / dash 等服务端 + 未下发的判定位先用实体键 + 本地占位接线(同 §3.7 `knock_down`)。CLICK_ITEM / + CLICK_POSITION / USE_SKILL 分派留桩。`netplay_test` 新增 5b 段;C++ 19/19 + + 其余 GDScript 回归全绿。 +- [x] **增量 76 —— W2 §3.1 / 本地移动 Src/Dst 模型**:`player_controller.gd` 去掉 + `_move_target` / `_has_target` 的直线改坐标,改为 `_src_pos` / `_dst_pos` / `_is_going` / + `_dst_rot` 模型,`walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`:太近 + (`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门(`frozen` / `locked` / `moving_skill` + → `_can_translate()`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s 重试 + (≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。 + `_process` 每帧朝 `_dst_pos` 推进、抵达清 `_is_going`,WASD 分支置 `_is_going = false`。 + 新增 `project/player_move_test.gd`(5 组);C++ 19/19 + 其余 GDScript 回归全绿。 + 未做:Actor 间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似)、坐骑 + `MODE_HORSE*`(依赖 §6.4 + §3.2)。 +- [x] **增量 77 —— W2 §3.2 / 远端同步命令队列(C++ 核心)**:`Entity` 新增 + `std::deque state_queue` + `mov_after_*` / `skip_collision`;`mut_move()` + 改为入队 `StateCmd`(≈ `PushTCPState`)后 `drain_state_queue()` 释放到期项,m2dev + `apply()` 与 classic parser 都走 `mut_move(...,arg,time)`,删掉旧 `start_move()` 直改 + 坐标路径。新增 `process_states()` + `apply_state_cmd()` 1:1 实现 `StateProcess` 的 + switch(`FUNC_WAIT` 1.0 阈值、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 阈值 + `m_kMovAfterFunc` + 到达后动作、`skip_collision`);释放门按 `m_server_frame_ms`(新 `set_server_frame_ms`, + 为 0 时立即释放 = 旧行为)+ `__CanProcessNetworkStatePacket`(dead / knock_down hold + 队列)。`tick()` 每帧先 `process_states()`。新增 `extension/tests/net_state_queue_test.cpp`; + `net_entity_test` 的 `FUNC_ATTACK` 断言按新语义改写;C++ 20/20 + GDScript 回归全绿。 + 留给增量 78:net_play.gd 上行 6 事件回调 + 节流常量、net_world.gd 去掉自有 lerp、 + 从 `GC_HANDSHAKE` `lDelta` 接真实服务器帧钟。 +- [x] **增量 78 —— W2 §3.2 / 上行 6 事件回调 + net_world 去 lerp**:`net_play.gd` 上行 + 按 `PythonPlayerEventHandler.cpp` 拆成 6 个回调——统一出口 `_send_state(func,arg,pos)` + (≈ `SendCharacterStatePacket`);`OnMove` 立即发 `FUNC_MOVE` 并重置 moving/waiting 两个 + 节流窗,`OnMoving` 300 ms 节流,`OnWaiting`(新 `_tick_on_waiting()`,`_process` 每帧) + 100 ms 节流且位移 ≥ 1 cm 才发 `FUNC_WAIT`,`OnStop` 不节流收尾 `FUNC_WAIT`,`OnAttack` + 在 `CG_ATTACK` 外额外 `_send_state(FUNC_COMBO, motion, pos)`,新增公共入口 + `on_use_skill(motion,arg)` → `FUNC_SKILL|motion`(调用点属 §3.8)。删旧 + `SEND_INTERVAL/SEND_DIST/_last_sent_t` 单一节流。`net_world.gd` `_process` 改 + `n.position = _grounded(e.pos)` 直接跟随,去掉自有 `follow_lerp`——位置插值全交 C++ + `EntityStore::tick()`,只朝向仍 GDScript 平滑。`netplay_test` 加第 11 段、`netbridge_test` + 的 near-move 断言改写;C++ 20/20 + 7 个 GDScript 回归全绿。留给增量 79:⑤ 从 + `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟(`ClassicStream` 已存时间基,待暴露访问器)。 +- [x] **增量 79 —— W2 §3.2 / 真实服务器毫秒帧钟(part C,收官)**:`ClassicStream` 暴露 + `server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)——`HDR_HANDSHAKE` 处理里已存的 + `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()`,帧钟即 + `now_ms() - m_client_time_base + m_server_time_base`;首个握手落地前返回 0。 + `ClassicSession::process()` 在 `m_world.tick()` 前 `set_server_frame_ms(m_stream.server_frame_ms())`, + 于是 classic 后端的 `drain_state_queue()` 按服务器时间放行队列而非即时释放;m2dev 后端无 + 握手 `lDelta` 对应物,保持 0 = 即时释放,无回归。`drain_state_queue` 加防呆阀 + `STATE_QUEUE_MAX_WAIT_MS = 1000`——`chk_time` 领先帧钟超过 1 s 视为异帧钟 `dwTime`,立即 + 放行以免远端 Actor 永久冻结(参考端无此上限,属有意偏离,已在 §3.2 记录)。 + `net_classic_stream_test` 加帧钟段(握手前 0、握手后 ≈ `hs.time+lDelta`、单调)、 + `net_state_queue_test` 加第 6 段(防呆阀边界);C++ 20/20 + 7 个 GDScript 回归全绿。 + §3.2 至此完成,真实服务器 `dwTime` 时基仍待联机核验。 + +- [x] **增量 80 —— W2 §3.5 / PC 连击段表解析(part A)**:参考端 PC 的连击段表 **不在** + `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)——它由 `playersettingmodule.py` + 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用在 `CRaceData` 上建起来 + (`PythonCharacterManagerModule.cpp` → `CRaceData::ReserveComboAttack` / `RegisterComboAttack`, + `RaceData.cpp`)。新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`):按 + `def __LoadGameEx` 分四个职业块,解析那批调用为 + `map>`(段号 = `NAME_COMBO_ATTACK_*` = + 14..21)。照 `CRaceData` 语义:`Reserve` 建长度 `count` 的 0 向量(`map::insert`,重复 key + 不覆盖)、`Register` 按下标写(key 未 Reserve 或下标越界则跳过)。认得 `chr.MOTION_MODE_*` + (= `CRaceMotionData::EMode`)、`chr.MOTION_COMBO_ATTACK_N`(= 13+N)、`chr.MOTION_NORMAL_ATTACK` + (= 13)、`COMBO_TYPE_N` / `COMBO_INDEX_N`(= N−1)、裸整数。新增 `formats_combo_table_test` + (合成片段断言 + `M2_ASSETS` 存在时对真实 `playersettingmodule.py` 断言 warrior/sura 1H、 + shaman FAN);C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 81:`player_controller.gd` 连击 + 状态机(`_cur_combo_index` / `_is_pre_input` / `_is_next_pre_input`)改用此表 + `.msa` + `ComboInputData` 三时间,替掉当前 `(_combo + 1) % 3` 硬编码;之后命中窗(`HitDataContainer`) + + 发包挂动作事件。 + +- [x] **增量 81 —— W2 §3.5 / PC 连击状态机(part B)**:`net_play.gd` 新增 + `_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo`, + 逐行对齐 `CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` / + `__OnEndCombo` / `__ClearCombo`(`REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`), + 替掉 `(_combo + 1) % 3`:`_combo_index==0` 直接推进;否则按 `.msa` `ComboInputData` 三时间 + (`pre_input_time` / `direct_input_time` / `input_limit_time`)判 `_is_pre_input` 挂起 / 直接推进 / + 弓模式 `_is_next_pre_input`;无 combo 输入组用 `duration*0.9` 兜底;`_combo_process()` 每帧触发 + 挂起输入、动作回 Wait 时 `_clear_combo()`;段号越界不推进;末段仅骑乘复位到 1。段号来自 + 新静态绑定 `Metin2AnimPlayer.parse_combo_tables(py_path)`(`extension/src/metin2_anim.{h,cpp}` → + 增量 80 的 `fmt::parse_player_combo_tables_file`),按 `race & 3` 选职业块、 + `combo_motion_mode<<16|type` 取段序列;缺段表退化为单段普攻。`_emit_swing` 保持增量 78 的两包 + (`CG_ATTACK{bType=0}` + `CG_CHARACTER_MOVE{FUNC_COMBO, 段号}`)。`game_scene.gd` 装配时 + `net_play.set_asset_root(_assets)`。`combat_fx_test.gd` 加 `FakeAnim` + 连击状态机全路径断言。 + C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 82+:`.msa` `HitDataContainer` 多命中窗导出、 + `THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION`、 + 武器种类 → `combo_motion_mode` 映射。 + +- [x] **增量 82 —— W2 §3.5 / `.msa` 多命中窗导出(part C,修改 1)**:`formats/msa.{h,cpp}` 把 + `Group AttackingData` 按 `GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法 + 解析成 `std::vector`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`, + 无 `HitDataCount`)= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` + + `Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每窗带 + `start_time`/`end_time`/`bone`/`weapon_length` + `List HitPosition` 采样 + (`{time, last_pos, pos}`,7 浮点一组);`attacking_type` 兼容 `AttackType`/`AttackingType` + 两种键,新增 `motion_type` / `hit_limit_count`;`attack_start_time`/`attack_end_time` 保留为 + 第一个窗起止(旧调用方兼容)。`formats/textscript.cpp` 同时补 `List Name { ... }` 块解析 + (原来 `List` 会把 `{` 吞进普通行、还串掉其后的 `Group`,`palbang.msa` 的 + `MotionEventData` 就丢了)。`extension/src/metin2_anim.cpp get_motion_data()` 新增 + `hit_windows`(数组:`{start_time,end_time,bone,weapon_length,samples:[{time,last_pos:Vector3,pos:Vector3}]}`) + + `motion_type` / `hit_limit_count`。`formats/tests/msa_test.cpp` 加内联单窗 / 新式双窗合成 + 断言 + `M2_ASSETS` 时对真实 `warrior/general/attack`(17 采样)、`twohand_sword/combo_05` + (武器长 150 / 37 采样)、`assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、 + `warrior/skill/palbang`(`AttackingData` 后 `MotionEventData` 3 事件)断言;`CMakeLists.txt` + 给 `formats.msa_loop_data` 加 `M2_ASSETS` 环境透传。C++ 21/21 + 7 个 GDScript 回归全绿。 + 留给增量 83+:修改 4(`THitData` 窗口命中判定 + `OnHit`→`CG_ATTACK` + `FlushVictimList` 帧末 + `CG_SYNC_POSITION` ≤16)、武器种类 → `combo_motion_mode` 映射。 + +- [x] **增量 83 —— W2 §3.5 / 命中窗几何判定 → `OnHit`(修改 4,§3.5 收官)**:`net_play.gd` + 新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` / `motion_type` / + `hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、`_attack_process(dt)` + (对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + `ActorInstanceCollisionDetection.cpp:333 + __NormalAttackProcess`):每帧动作时间窗 `[t-dt, t]` 与某 `hit_window [start,end]` 相交时, + 遍历 `client.get_entities()` 里可攻击 / 未死 / 双方不在安全区的实体,`net_world.node_for(vid)` + 取世界位;`_hit_deduped()`(`m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标一次、其余按 + `invisible_time` 冷却)+ `_register_hit()`(每窗命中上限:NORMAL/COMBO 16、SKILL + `hit_limit_count`)→ 命中发 `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`, + 普攻 `_swing_skill=0`),被击退目标记 `_victim_flush`。`_emit_swing()` 改为**只有拿不到 + 命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`。`_flush_victim_list()`(对齐 + `PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`。 + 几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、 + `(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。 + `combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` / + COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 → + `combo_motion_mode` 映射。 + +- [x] **增量 84 —— W2 §3.5 / 武器种类 → `combo_motion_mode`(修改 6)**:`net_play.gd` 补全 + `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+ `WEAPON_SUB_*`(`CItemData::EWeaponSubTypes`) + + `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射 `static motion_mode_for(item_type, weapon_sub, + mounted, poly, dress)` —— 1:1 复刻 `CInstanceBase::RefreshState`(`InstanceBase.cpp:2851`)里 + `SetMotionMode` 分支:Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、 + 钓竿→骑乘 HORSE / 否则 FISHING、骑乘按 subtype→HORSE_{ONEHAND,TWOHAND,DUALHAND,FAN,BELL,BOW} + 或 HORSE、否则按 subtype→{ONEHAND,TWOHAND,DUALHAND}_SWORD / BOW / FAN / BELL、ARROW 及其余→ + GENERAL。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`(为 0 回退 `get_equipment()` 的 + `WEAR_WEAPON` 槽),`_refresh_motion_mode()` 读 `proto.item(vnum)` 的 `type`/`sub_type` + + 实体 `mount_vnum`/`polymorph` → `set_combo_motion_mode()`(换模式会 `_clear_combo()`)。 + 接线:`_on_entity_info`(主角设定 / `GC_CHARACTER_UPDATE` parts 变)末尾、新 `_on_inv_changed_mode` + (`inventory_changed` window==EQUIPMENT)、`mount_changed` 信号(vid==主角)。`combat_fx_test.gd` + 加 `FakeProto` + 15 段 `motion_mode_for` 映射断言 + 2 段端到端(`parts[WEAPON]`=匕首→ + DUALHAND_SWORD 且清连击、上马→HORSE_DUALHAND_SWORD)。C++ 21/21 + 7 个 GDScript 回归全绿 + (`equip_model_test` 亦 PASS)。留给增量 85+:弓箭 `__GetBowRange()`(§3.5 命中距离)、 + §3.6 箭矢 FLY 事件。 + +- [x] **增量 85 —— W2 §3.5 / 弓箭 `__GetBowRange()`(可点击 / 攻击距离)**:`net_play.gd` + `_clickable_distance()` 逐分支对齐 `CInstanceBase::NEW_IsClickableDistanceDestInstance` + (`InstanceBaseBattle.cpp:294`):默认 150 cm → `_is_bow_mode()`(`combo_motion_mode` 为 + `MODE_BOW` / `MODE_HORSE_BOW`,对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)时 + 改用 `_bow_range_cm()` → 目标 `IsNPC()` 覆盖为 500 → 目标 `IsResource()` 覆盖为 100(后写的 + `if` 覆盖前面的)。新增 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus` + —— 1:1 复刻 `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`:`2500.0f - 100.0f` 再加主角 + `GetStatus(POINT_BOW_DISTANCE)`,参考端按 cm 直加不 ×100)。`_bow_distance_bonus` 由 + `_on_points()` 读 `points["bow_distance"]` 填充;`wire.h` 补 `POINT_BOW_DISTANCE = 34`, + `M2Client::get_points()` 增 `d["bow_distance"] = p.v[POINT_BOW_DISTANCE]`。`combat_fx_test.gd` + §3.5 段加 8 段断言(非 bow=150 / bow 基础=2400 / bow 目标=__GetBowRange / NPC 覆盖=500 / + 采集物覆盖=100 / HORSE_BOW 也算 bow / 加成叠加 / `_on_points` 读 `bow_distance`)。 + C++ 21/21 + 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 86:§3.6 箭矢 + `.msa` `MOTION_EVENT_TYPE_FLY` → `CG_FLY_TARGETING` / `CG_SHOOT`,替掉 `game_scene.gd` 约 1.5 s + 定时器兜底(`PythonPlayerEventHandler.cpp:235`)。 + +- [x] **增量 86 —— W2 §3.6 / 弓箭 FLY 事件(`OnSetFlyTarget` / `OnShoot`)**:拆掉 + `game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底,换成 + `_pending_shots: Array[int]`(FIFO,上限 `MAX_PENDING_SHOTS = 8`,满则丢最旧)—— + 1:1 对齐 `CNormalBowAttack_FlyEventHandler_AutoClear`:每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)` + 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event` 的 `type 6`) + `pop_front()` 弹一个发 `client.shoot()`(pop 即自清 = AutoClear,队列天然支持连射多箭在途)。 + 对齐 `CActorInstance::ProcessMotionEventFly`(`ActorInstanceMotionEvent.cpp:290`: + `m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`: + `__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`: + `_is_bow_mode()` 时先 `_send_fly_target()` —— 取 `_target_vid`(回退 `_vid_reserved`)在 + `net_world` 里的节点,按 `MapCoord.to_server_cm()` 换 server-XY 后 `client.add_fly_targeting(vid, x, y)` + (对齐 `SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition())`; + `OnGetFlyTargetPosition` = 目标包围球中心世界坐标,其平面分量与脚点一致, + `CG_FLY_TARGETING` 只带平面坐标,`ActorInstanceFly.cpp:4`)——再 `bow_shot_fired.emit(_swing_skill)` + 让 `game_scene` 入队。新增 `net_play` 信号 `bow_shot_fired(skill)`、`_send_fly_target()`, + `game_scene._queue_shot()` + `net_play.bow_shot_fired.connect(_queue_shot)` 接线(弓技能路径 + 经 `quickbar.skill_activated` 的 `is_ranged` 分支同样改走 `_queue_shot`,`use_skill` 里 C++ 已发 + fly-targeting 不重复)。`combat_fx_test.gd` 加 `FakeClient.add_fly_targeting/shoot` + 6 段 + §3.6 断言(非弓模式无 FLY 事件 / 弓挥击起手发 `CG_FLY_TARGETING(vid)` / 坐标 = 目标 server-XY / + `bow_shot_fired(0)` / 无目标不发);`eterngrn_polish_test.gd` FLY 段改为 FIFO 连射断言 + (连压 77/78 → 依次 pop → 队空不再发)。C++ 21/21 + 9 个 GDScript 回归全绿。留给后续: + 飞行物实体本身的生成 / 命中判定(`CFlyingManager` / `FlyingInstance`)、`__CanShot` 弓箭 + 合法性、真服 `CG_FLY_TARGETING` z 分量。 + +- [x] **增量 87 —— W2 §3.8 / TSkillData 判定位解析(修改 2)**:POC 无 C++ skill proto + (`extension/src/proto` 只有 item/mob),技能数据全在 GDScript 的 `project/ui/skill_table.gd` + 里读 `locale/locale//skilldesc.txt` TSV。故在该文件按 `RegisterSkillDesc` + (`REF/UserInterface/PythonSkill.cpp:280`)的做法,把第 11 列 attributes / 第 12 列 weapon + 的 `|` 分隔记号串折成 `attr_bits` / `need_weapon_bits` 两个位掩码(`_fold_bits()` 静态方法, + 记号名 1:1 抄 `PythonSkill.h:92-128` 的 `SKILL_ATTRIBUTE_*` / `SKILL_NEED_WEAPON_*`, + 未知记号忽略 = 参考端“map 查不到就跳过”;`DOUBLE_SWORD` 复用 `DAGGER` 位;weapon 位 = + `1 << CItemData::WEAPON_*`,`GameLib/ItemData.h:63-75`)。`load_file` 时连同 `skill_type` + (JOB 列经 `m_SkillTypeIndexMap` → `SKILL_TYPE_{NONE,ACTIVE,SUPPORT,GUILD,HORSE}`, + `PythonSkill.cpp:909-918`)一起预存进 entry。新增 / 改写谓词逐条对齐 `CPythonSkill`: + `is_can_use_skill`(`IsCanUseSkill` = 非 PASSIVE,`PythonSkill.cpp:1407`)、`is_standing`、 + `is_need_target`、`is_need_corpse`、`is_auto_search_target`、`can_change_direction`、 + `is_horse_skill`、`is_moving_skill`、`is_melee`、`is_charge_skill`、`is_use_hp`、 + `is_time_increase`、`is_need_empty_bottle`、`is_need_poison_bottle`、`can_use_for_me`、 + `is_only_for_alliance`、`can_use_if_not_enough`、`is_only_for_guild_war`、`is_fan_range`、 + `is_circle_range`、`has_weapon_limitation`、`can_use_weapon_type(id, weapon_type)` + (`CanUseWeaponType` = 无限制恒真,否则 `dwNeedWeapon & (1<= SKILL_MAX_NUM` 门;`target_vid` 保留 `>= 0 && bounds::u32`; + `cast_skill()` 的 `motion_idx ∈ [0,0x7f]` 不动(7 位 wire 位段)。`net_bounds_test.cpp` + +4 断言(`SKILL_MAX_NUM==255`、端点 1/254、越界 0/255/-1、拒大值)。② `project/ui/skill_table.gd` + `_load_cooltime` 更名 `_load_msk`,加解析 `.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]` + (`LocaleService_GetSkillPower` INTERNATIONAL 表,`REF/EterLib/Locale.cpp:126`, + `SKILL_POWER_NUM=50`、`level >= 50 → 0`)+ `_skill_point(level)` + `need_sp(id, level)` + (≈ `GetNeedSP`,`PythonSkill.cpp:1426`;`Expression.parse(formula, ["SkillPoint"])` 求值。 + POC 约定:无 `NeedSPFormula` 返回 `-1`(参考返回 0),避免误挡缺字段技能)+ `need_hp()` + (照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖,HP 需求也读 NeedSP 公式); + `cooldown_of` 的 `SkillPoint` 也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`, + `PythonPlayer.cpp:1037`),`max_level` 仍解析留给其它调用方。③ `project/player_skill.gd` + 新增 `SKILL_INDEX_SUMMON=131`(`PythonPlayerModule.cpp:15`)+ 上下文字段 `cur_sp` / `cur_hp` + (`-1` = 未知不判),把 `_check_short_mana` / `_check_short_life` 从桩改实装: + `_check_short_mana` 逐条移植 `__CheckShortMana`(`PythonPlayerSkill.cpp:320`:summon `131` + 豁免、非开关技 `need_sp == 0` → 坏数据拦、`can_use_if_not_enough` → `cur_sp <= 0` 拦、 + 否则 `need_sp != -1 && need_sp > cur_sp` 拦),`_check_short_life` 移植 `__CheckShortLife` + (`:354`:`!is_use_hp() → false` 放行、`need_hp > cur_hp` 拦;shipped `skilldesc.txt` + 无 USE_HP 技能故实为放行)。二者只在 `check_skill_usable` 非冲刺 `else` 支路命中, + 分别转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`。④ `project/net_play.gd::skill_context()` 读 + `client.get_points()` 的 `hp` / `sp` 填 `cur_hp` / `cur_sp`(缺则 `-1`)。⑤ + `player_skill_test.gd` +“(c) §3.8 修改 3”段(`_skill_point(20)==0.5`、`_skill_point(0)/(50)==0`、 + `need_sp(1,5)==52`、`need_hp==need_sp`、SP 不足→`NOT_ENOUGH_SP`、SP 充足→ok、SP 未知(-1)→ok、 + 无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 `NOT_ENOUGH_HP`),`_reset` 补 + `cur_hp=-1` / `cur_sp=-1`。C++ 21/21 + 13 个 GDScript 回归全绿。§3.8 三层校验(修改 1/2/3) + 至此全部落地。留给增量 90+:§3.10 技能升级门(audit 10)、`__ChangeTargetToPickedInstance` + 拾取回退、扇形/圆形多目标 fly-targeting。 + +- [x] **增量 90 —— W2 §3.10 技能升级门(加号按钮门控 + `skill_up` 边界收紧)**:① + `extension/src/net/wire.h` `EPointTypes` 加 `POINT_SUB_SKILL=27` / `POINT_SKILL=28` / + `POINT_HORSE_SKILL=113`(`UserInterface/Packet.h`;`uicharacter.py` `skillPageStatDict`); + `m2_client.cpp::get_points()` 新增 `skill_active` / `skill_support` / `skill_horse` 三键 + (镜像增量 85 的 `bow_distance`)。② `m2_client.cpp::skill_up(int skill_id)` 护栏 + `skill_id <= 0 || skill_id >= POINT_COUNT` → `!mtnet::bounds::skill_index(skill_id)` + (= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。③ `project/ui/skill_table.gd`: + `load_file` 补 `RegisterSkillDesc` 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析 + (`.msk` `maxlevel` 优先;shipped 22 列数据两列都缺,逻辑为兼容第三方数据);新增 + `max_level_of(id)`(= `SSkillData::GetMaxLevel()`,`PythonSkill.cpp:1392`,缺值默认 20 = + 构造默认 `byMaxLevel`,:1462)、`skill_level_limit(id)`(= `skillGetSkillLevelLimit`, + `PythonSkill.cpp:1741` = `byLevelLimit`;本 40250 数据恒 0)、`can_level_up_skill(id, level)` + (= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`;本 build 里前置技能 / 前置属性字段 + `isRequirement` / `RequireStatDataVector` 从不被填充(构造默认,:1471)→ 1:1 化简为 + `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`)。④ `project/ui/skill_ui.gd`: + `refresh()` 里逐行移植 `uicharacter.py` 的 + `RefreshSkillPlusButtonList`(:984)/ `__RefreshSkillPlusButton`(:937)/ + `CanShowPlusButton`(:925)——`_can_use_skill_now()`(= `client.get_skill_group() != 0`, + 对应 `__CanUseSkillNow` :1270)、`_stat_point_for(page)`(`SUPPORT`→`skill_support` / + `HORSE`→`skill_horse` / 其余→`skill_active`,三来源分开取)、`_can_show_plus(id, level, + grade, page, …)`(技能组门 → 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐 → `stat_point<=0` + 隐 → `skillGrade!=0` 隐 → 马术页 `plv >= skill_level_limit(id)` 时 `level < 20` 才显 → + 主动页 `table.can_level_up_skill(id, level)`)。按钮仍在 `_rebuild()` 为非被动 / 非 + `CANNOT_LEVEL_UP` 技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()` + + `ShowSlotButton()`)。常量 `HIDE_SUPPORT_SKILL_POINT`(`uicharacter.py:21`)/ + `RIDING_SKILL_MAX_LEVEL=20`(`__RefreshSkillPlusButton` HORSE 分支硬编码)。升级结果仍 + 由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动 `refresh()`,不本地伪造。⑤ `skill_test.gd` + FakeClient 补 `get_skill_group()` / `get_points()`;扩 `§3.10` 段(`max_level_of(1)==30` / + `(121)==20`、`skill_level_limit(1)==0`、`can_level_up_skill` 未满级 / 满级 / 超级 / + `CANNOT_LEVEL_UP` / 未知 id、加号显隐:主动页可见 / `skillGrade!=0` 隐 / `skill_group==0` + 隐 / 该页点数 0 隐 / 辅助页整页隐 / 马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。 + C++ 21/21 + 13 个 GDScript 回归全绿。留给增量 91+:`__ChangeTargetToPickedInstance` + 拾取回退、扇形/圆形多目标 fly-targeting、§3.6 飞行物实体(`CFlyingManager` / `FlyingInstance`)。 + +- [x] **增量 91 —— W2 §3.4 其余三种预约模式 + `__ChangeTargetToPickedInstance` + 扇形/圆形补 fly-targeting**: + ① `project/net_play.gd` `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection`, + `PythonPlayerInputMouse.cpp:287`)补齐自增量 75 留桩的三个 case:`_reserve_process_click_item()` + (MODE_CLICK_ITEM,:290——按 IID 查 `client.get_ground_items()` 世界坐标,`< 20cm` + `CLICK_ITEM_PICKUP_CM` 到手 → `_send_state(FUNC_WAIT)` + `pickup_item` + 解冻 + 清预约, + 否则 `walk_to`;物品消失 → 清预约)、`_reserve_process_click_position()`(MODE_CLICK_POSITION, + :320——`_can_translate()` 未锁且 `_reserved_delay <= 0`(`_process` 逐帧递减 ≈ + `NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` + 清预约)、 + `_reserve_process_use_skill()`(MODE_USE_SKILL,:340——`dist_cm`(含 `_is_huge_race` 的 + `-200cm` `HUGE_RACE_DIST_FIX_CM` 修正)`< _skill_range_reserved` → `set_target` + + `use_skill_hook(slot)` 成功才清预约,否则 `walk_to`;目标丢失 → 清预约)。② 预约入口 + `reserve_click_item(iid)` / `reserve_click_ground(pos)`(`_reserved_delay = 0.1`)/ + `reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`, + `range > 100` 先减 10 `RESERVE_SKILL_RANGE_TRIM_CM`)/ `pick_ground_item(iid)`(= `__OnPressItem`—— + 清预约 + auto-attack,在 `CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。 + `_clear_reserved()` 同时复位 `_ppos_reserved` / `_skill_range_reserved`。③ `_on_pick()` 里 + `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode == USE_SKILL && _dash_active` + 时只改 `_vid_reserved` 不选中;战斗目标改走 `_change_target_to_picked_instance(vid)`(= + `__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定 + PC/怪,否则 `set_target(0)` + `clear_target`;NPC/石头/warp 的交互仍由 `_on_pick` 先分流到 + `click_npc`)。`_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用 + `proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似,查不到 → false + (文档化的 seam)。④ 扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` + `if (dwTargetMaxCount>0 …)` 块):`send_fly_targeting(target_vid, range_cm, max_count, shape)`—— + 主目标的 `CG_FLY_TARGETING` 已由 `M2Client::use_skill` 发过,这里只补附加目标—— + `_instances_in_fan_range()`(= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`: + 先按圆形距离取,再按随距离收窄的半角 `fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN` + 过滤,常量 `FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/ + `_instances_in_circle_range()`(:243,无方位过滤)内的其它可攻击敌人经 + `client.add_fly_targeting(vid, sx, sy)`(= `SendAddFlyTargetingPacket`)补齐;名额不足时 + `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° + `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→ + `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。 + ⑤ `project/ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析 + (`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)` + (= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint` + 代入后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 的基础项 `PythonPlayerSkill.cpp:391`, + 主角弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0, + 对齐 `NetPlay.FlyShape`)。⑥ `project/ui/quickbar.gd`:`activate()` 里 `client.use_skill` + 返回后,若 `target_vid != 0` 且技能为 `FAN_RANGE` / `CIRCLE_RANGE` → 调 + `net_play.send_fly_targeting(target_vid, target_range, target_count(sid, lvl), fly_shape(sid))`; + 新增 `_skill_level(sid)`(扫 `client.get_skills()`)、`activate_reserved(global_slot)` + (`use_skill_hook` 回调,MODE_USE_SKILL 进射程后执行槽位施法)。⑦ `game_scene.gd`: + `net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。⑧ 测试: + `netplay_test.gd` FakeClient 补 `get_entities` / `pickup_item` / `add_fly_targeting` / + `get_ground_items` / `get_skills`,新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、 + 物品消失→清;CLICK_POSITION 延时门 + `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、 + 射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 / + 怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、 + `max_count<=1` / `SINGLE` → 0 附加);`skill_test.gd` 补 skill 47(gwangyeok,FAN + Range 2500 + + `target_count(lv1)=2` / `(lv20)=5`)/ skill 91(bipabu,FAN + Range 1800 + 无公式→0)/ + skill 1(`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿(`eterngrn_polish_test` + 的 `motion_event: sound` 仍是既有异步竞态,退出码 0)。留给增量 92+:§3.6 飞行物实体 + (`CFlyingManager` / `FlyingInstance`)生成 / 命中、`__CanShot` 弓箭合法性。 + +- [x] **增量 92 —— W2 §3.6 飞行物实体 `CFlyingManager` / `CFlyingInstance` 生成 / 命中 + `__CanShot` 弓箭合法性**: + ① 新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`,对齐 + `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.{h,cpp}`)。 + `FlyData` 字段取 `CFlyingData::__Initialize()` 默认值(米制换算):`init_vel` / + `cone_angle` / `roll_angle` / `gravity` / `accel` / `flat_range`(=`m_fRange`) / + `bomb_range`(=`m_fBombRange`) / `pierce_count` / `spreading` / `maintain_parallel` / + `is_homing` / `homing_start_time` / `homing_max_angle` / `hit_on_background`。 + `FlyInstance.create(d, start, target, can_attack)` ≈ `CFlyingInstance::Create` + + `__SetDataPointer` + `__SetTargetDirection`:`target` 按 `Node3D`(对象)/ `Vector3` + (位置)/ `Dictionary{vid,pos,node}` 分派;`dir = normalize(target_pos - pos)`、 + `vel = dir * init_vel`、`accel` 经 `Quaternion((0,-1,0), dir)` 旋到航向。 + `FlyInstance.update(dt)` 逐帧对齐 `CFlyingInstance::Update`:homing(`is_homing && + _elapsed > homing_start_time && 对象目标` → `_adjust_homing()` 单步限幅 + `deg_to_rad(homing_max_angle)`)→ `last_pos = pos` → `vel += accel*dt` → + `vel.y += gravity*dt`(seam:参考端重力作用于 `.z`=上,本 POC 映射到 `-Y`=下)→ + `movement = vel*dt`、`move_dist = |movement|`、`remain_range -= move_dist`、 + `pos += movement` → `remain_range < 0` → `out_of_range` + `_explode(false)`(不 `__Bomb`) + 返 false → 对象目标线段判定 `_seg_point_sq_dist(pos, last_pos, target_pos) < bomb_range²` + (`_seg_point_sq_dist` 1:1 移植 `GrpMath.h:80` `square_distance_between_linesegment_and_point`) + → `target_hitted = true`;`can_attack` → `shoot_damage` 信号(≈ `pVictim->OnShootDamage()`); + `at_target`;`pierce > 0` → `pierce-- + bomb` 继续飞,否则 `_explode()` 返 false → + 位置目标同线段判定 → `at_position` + `_explode()` → `hit_on_background && + world.sample_height(x, z) > pos.y`(≈ `GetTerrainHeight(x,-y) > z`)→ `at_background` + + `_explode()`。`_explode(bomb=true)`:`if not alive: return; alive = false; if bomb: bomb`。 + seam:怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素判定未移植(客户端仅做命中"时机", + 伤害由服务端裁决);`.fly` 资源加载器缺失 → `net_world._on_fly` 用合成箭参数 + (`init_vel = 45`、`flat_range = max(dist*1.5, 30)`、`bomb_range = 0.6`)。 + ② `project/net_world.gd`:`setup()` 挂 `FlyManager` 子节点并 `setup(parent, world)`, + `exploded` → 新增 `signal fly_exploded(world_pos, cause)`;重写 `_on_fly(type, start_vid, + end_vid)`——原 tween「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false, + FlyData, type)`(`GC_CREATE_FLY` 在本 40250 客户端无包处理器,仅 `NetStream.cpp` 头串索引 70; + 实际驱动路径是 POC 自有的 `fly_cue`——保留 shooter 的 `fly_target_queue` meta 消费)。 + ③ `project/net_play.gd`:新增 `_can_shot(target_vid := -1) -> String`(`__CanShot` 在 + 本发布源码树中无定义——防御式重建,参考 `IFlyEventHandler::OnNoTarget/OnNoArrow` + `FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 IsDead/IsStun/IsKnockDown/ + IsUsingSkill 门、`__GetBowRange` `InstanceBase.cpp:694`、`EMPTY_ARROW` 文案): + 非弓模式 → `"NOT_BOW_MODE"`;`_arrow_count == 0` → `"EMPTY_ARROW"`(`-1` =未跟踪/无限,放行); + 无目标(`_target_vid` / `_vid_reserved` 均 ≤ 0)→ `"NO_TARGET"`;目标实体已知且不可攻击 / + 已死 → `"NO_TARGET"`(`te.is_empty()` 不拒——headless 容差,与 `_send_fly_target`「有节点即可」一致); + 目标世界距离 `* CM > _bow_range_cm()` → `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支加门: + `_can_shot()` 非空 → `_emit_cannot(why)`(走 `OnNoArrow` / `OnNoTarget` 文案),不发 + `CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作照常(seam:参考端整挥击中止)。 + ④ 新增 `project/fly_test.gd`(第 14 个 GDScript 回归):直线命中对象目标 / 超程不 `__Bomb` / + `can_attack` 触 `shoot_damage(vid)` / 穿透后存活续飞再超程 / 位置目标 / 触地 / + `_can_shot` 六分支(非弓 / 无箭 / 无目标 / 在程 OK / 超弓程 / 死目标)/ `_seg_point_sq_dist` + 三段(垂足在段内 / 越 p1 / 越 p2)。C++ 21/21 + 14 个 GDScript 回归全绿。 + 留给增量 93+:§3.1 Actor-Actor 碰撞(`TestActorCollision` / `BlockMovement`)+ 坐骑 + `MODE_HORSE*`(依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。 +- [x] **增量 93 —— W2 §3.1 收尾 / Actor-Actor 碰撞 + 坐骑转向速度**: + ① `project/player_controller.gd` 新增 `_actor_blocked(next_pos)`——`CActorInstance::TestActorCollision` + (`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing` + (`InstanceBaseBattle.cpp:469`)的近似:距离门 `ACTOR_COLLIDE_MAX_DIST_M = 8.0 m` + (≈ `LengthSq(victim - self) > 800²` 直接不检)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`, + 自身 + 对方相加 = `combined`;参考用 race 碰撞球,POC 无骨骼碰撞数据 → 固定半径近似)→ + 「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` 末尾 + `dist(mainPos, victimPos) <= dist(mainLastPos, victimPos)` 才 `return TRUE`;远离时放行 → + 能从重叠里走出来)。候选=`pickables`(每个已生成的远端 NPC / 怪 / 他人节点), + `dead` meta 为真跳过(≈ `rVictim.IsDead()`)。 + ② `_process` 平移分支改为逐轴门控:`block_x = _actor_blocked(仅动 X) or _blocked(...)`, + `block_z` 同理——单个 Actor 时逐轴放行=滑移(≈ `AdjustDynamicCollisionMovement`,该函数不在 + 发布源码树),两轴都挡=停在上一帧位(≈ `uCollisionCount==2` / 调整后仍撞 → `BlockMovement()` + = `__InitializeMovement`);随后既有 `disp` 判定把顶人磨蹭切回 `_is_going = false` + `wait`。 + ③ `skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`, + §3.2 网络状态包驱动的移动整段跳过)——`net_play` 在观战开关(`_on_observer_mode` + `setup`) + 按 `_observer_mode` 置位。 + ④ 坐骑转向速度:`player_controller` 新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG = + 1200`=`c_fDefaultRotationSpeed`)+ `_turn_toward(target_yaw, dt)`——`SetAdvancingRotation` + 的逐帧限幅收敛(移动方向不受限,仅朝向变慢),替换原来两处 `player.rotation.y = atan2(...)` + 瞬转。`net_play._refresh_motion_mode()` 在 `mounted` 时把 `rotation_speed_deg` 切到 + `ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` + → `SetRotationSpeed(c_fDefaultHorseRotationSpeed)`,`InstanceBase.cpp:954`),下马恢复 1200。 + 坐骑动作模式(`MODE_HORSE*`)已由增量 84 的 `motion_mode_for` + `mount_changed` → `_refresh_motion_mode` + 覆盖,本增量不重复。 + ⑤ `project/player_move_test.gd` 扩 3 组(原 5 组 → 第 6/6b/6c/7):正前方 Actor 挡下且 + `is_going` 归零、`dead` Actor 不挡、`skip_actor_collision` 直穿、坐骑单帧转向 ≤ 300 deg/s + 且仍朝 Dst 平移;并在开头 `await process_frame`(对齐 fly_test,避免 `!is_inside_tree()`)+ + 清第 5 组遗留预约点。C++ 21/21 + 14 个 GDScript 回归全绿。 + seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`);`AdjustDynamicCollisionMovement` + 的精确滑移向量、门 / Actor 复合的 `uCollisionCount==2` 判定用逐轴近似代替;`CanSkipCollision` + 目前只由观战驱动,§3.2 网络状态包路径的 `EnableSkipCollision` 尚未接到本地预测玩家。 + 留给增量 94+:§3.2 / §3.5 收尾(多为待真实服务器);转 M2(W3 玩法)。 +- [x] **增量 94 —— W3→M2 §5.4 PVP / PK / 决斗可攻击判据(`IsAttackableInstance` 1:1)**: + ① 新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`)—— + `is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance` + (`REF/UserInterface/InstanceBase.cpp:2147`):主角 + 观战 → false;自打自 → false; + Stone(victim.IsPC → true)/ PC / Enemy(victim.IsPC || IsBuilding → true)/ Poly + (victim.IsPC || IsEnemy → true)四分支。PC 分支内:victim.IsStone → true;victim.IsPC 时 + 决斗三态(`DUEL_CANNOTATTACK` → false,`DUEL_START` → `__FindDUELKey` ? true : false)、 + `PK_MODE_GUILD` && 同公会 → false、`victim.IsKiller()` && !同队 → true、 + `PK_MODE_PROTECT != GetPKMode()` 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队 → true)/ + `PK_MODE_GUILD`(victim 非 PROTECT && !同队 && 公会不同 → true)、`IsSameEmpire(victim)` 时 + `IsPVPInstance` → true / `PK_MODE_REVENGE` && !同队 && `IsConflictAlignmentInstance` → true、 + 否则(异帝国)→ true;随后 victim.IsEnemy → true、victim.IsWoodenDoor → true。 + ② 判据 helper 全部 1:1:`alignment_grade`(:502 的 12000/8000/4000/1000/0/-4000/-8000/-12000 + 九档)、`alignment_type`(:524,grade 0-3 白 / 4 中 / 5-8 黑)、`is_conflict_alignment_instance` + (:2116,victim PROTECT → false;白 / 中对黑 → true;黑对非黑 → true)、`_is_same_empire` + (:470,victim.empire==0 || GM || 同 empire)、`_is_pvp_instance`(`InstanceBaseEffect.cpp:505`, + `GetDuelMode()` || `__FindPVPKey` || `__FindGVGKey`)、`_find_duel_key`(:495)、`_is_same_party` + (`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119)。 + `_is_pc` 依 `CActorInstance::IsPC`(`ActorInstance.cpp:282`)只看 `TYPE_PC`(变身玩家仍是 PC, + `IsPoly` 另经 race 越界判真);`_is_enemy` 只 `TYPE_ENEMY`;独立 `TYPE_POLY` 才走 Poly 分支。 + ③ `net_play.gd`:`_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼 + `self_e`(`_main_entity()` 快照 + `kind`)/ `victim_e`(+ `kind`)/ `_attack_ctx()`,委托 + `EntityRules.is_attackable_instance`;`_main_entity()` 为空(headless)退化到旧近似。 + 新增 `_attack_ctx()` 从 `client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` / + `get_party()` 装配 ctx(observer / main_vid / pk_mode / duel_mode / duel_opponents / + pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、 + `_can_shot`、`_hit_geometry`)无签名变化。 + ④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言);`combat_fx_test` / + `netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type` → `_entity_kind` 归 PC, + 桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。 + C++ 21/21 + 15 个 GDScript 回归全绿。 + seam:`__FindPVPKey`(`GC_PVP` 关系表 setter)/ `__FindGVGKey`(公会战对局 setter)/ + `IsGameMaster` 标记 / `IsBuilding`·`IsWall` 的 race 白名单(源码未随发布)/ m2dev 后端的 + `IsKiller` 状态位 / `_entity_kind` 无法分类时怪被当和平 PC(生产 mob_proto 恒加载); + 名字颜色 §8.8、`IsInSafe` 的 `ATTRIBUTE_BANPK` 地形属性仍未接。 + 留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt。 + +- [x] **增量 95 —— W3→M2 §5.1 Quest EventManager(`CPythonEventManager` 1:1)**: + ① `project/ui/quest_dialog.gd` 逐条重写脚本解析:`EVENT_TYPE_MAP`(42 条,键即 tag)+ + `enum { EVT_* }`(顺序照抄 `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+ + `_get_script_event_index()`(`GetScriptEventIndex` `PythonEventManager.cpp:1093`——前导 `/` 剥离为 + `EVENT_POSITION_END`,未知命令 `ok=false` → 跳过、不进 switch)。 + ② `class EventSet`(`SEventSet` `PythonEventManager.h:98`,字段名 1:1:`ix/iy/iWidth/iyLocal/ + isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/ + DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/ + iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ `init_event_set()`(`__InitEventSet` `:72` + 常量 `c_lNormal_Waiting_Time=10`/`iRestrictedCharacterCount=30`/`c_fLine_Temp=16`/ + `BOX_VISIBLE_LINE_COUNT=5`)、`insert_line()`(`__InsertLine` `:976`,推行 + `iyLocal += 16`)、 + `clear_line()`(`ClearLine` `:953`)、`body_text()`。 + ③ `_process_event_set(es, cmd)` = `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷: + `DELAY`/`COLOR`/`COLOR256` 的 START vs `/END` 分支、`LETTER`/`ITEM`/`MOB` 的 + `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE` + 的 `iAdjustLine += 2`、`WAIT`(`iyLocal=0; isLock=true`)、`CONFIRM_WAIT`(`isConfirmWait=true` + + `insert_line(center)`)、`END_CONFIRM_WAIT`(清 `isConfirmWait`)、`FADE_*`/`WHITE_*` + (`isWaitFlag=true`)、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**(引 `:562/566`)、`DUNGEON_RESULT` + 9 参、`QUESTBUTTON`/`QUESTBUTTON_CLOSE`、`SET_CAMERA`(`_camera_setting` 九参 = `:48` + `GetCameraSettingFromArgList`)/`BLEND_CAMERA`(+`blendtime`)/`RESTORE_CAMERA`。 + ④ `_tokenize()` 拆混排文本 / `[tag ...]`;裸网络文本折成合成 `LETTER`(`is_raw=true` → 跳过 30 字 + 断行,reference 脚本只有显式 `[LETTER]`)。arg 提取 `_tag_kv()`(`key(v)`/`key("v")`/`key;v`)+ + `_arg_s/_arg_i/_arg_f`(keyword 优先、位置回退,兼容旧测试的 `value;255` 与 `value(1)` 写法)。 + ⑤ `parse_script()` 返回字典在原键之外新增 `insert_images / dungeon_result / adjust_line / + waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`;首命令 + `RUN_CINEMA` → 记 `cinema_file` + 发 `cinema_requested` 信号。 + ⑥ 自检 `project/quest_event_test.gd`(新增,规范测试 #16,23 组断言);既有 + `quest_test.gd` / `select_item_ui_test.gd` 仍绿。C++ 21/21 + 18 个 GDScript 回归全绿。 + seam:逐帧 `UpdateEventSet` `:312` 的时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 / + `isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip`/`EndEventProcess`、`iConfirmEndTime` + 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`NEXT` vs `DONE` 按钮文案区分 + + `DoneEvent()` 回调、`camera_event_requested` 与 §7.6 相机模式对接——一次性跑完 token, + `isLock`/`isWaitFlag` 仅作状态保留并透出。 + 留给增量 96+:§5.2 任务日志、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口。 + +- [x] **增量 96 —— W3→M2 §4.1 wear 槽位版图 + costume/ring/belt 发包坐标(`GameType.h` 1:1)**: + ① `project/ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 分流—— + `c_Equipment_Start+0..10`(`EWearPositions` BODY..SHIELD)走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell; + `ENABLE_NEW_EQUIPMENT_SYSTEM` 的 `c_Costume_Slot_Start(90+19)` / `c_Costume_Slot_Hair(90+20)` / + `c_Equipment_Ring1(90+21)` / `c_Equipment_Ring2(90+22)` / `c_Equipment_Belt(90+23)` 在客户端 + 没有独立窗,继续用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。这修掉了 `inventory_test.gd` + 里 `_to_wire(113) == [1, 113]` 一直红的断言(旧实现返回 `[2, 23]`)。 + ② 新增常量 `WEAR_MAX_NUM=11` / `WEAR_COSTUME_BODY=19` / `WEAR_COSTUME_HAIR=20` / `WEAR_RING1=21` / + `WEAR_RING2=22` / `WEAR_BELT=23`(原值保持)/ `NEW_EQUIP_WEAR_START=19` / `WINDOW_EQUIPMENT=2`, + 全部 1:1 自 `GameType.h` 的 `c_Equipment_*` 与 `ItemData.h` 的 `EWearPositions`。 + ③ `_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 + + 16 格腰带背包」(`EWearPositions` 中 11..18 无位置);`refresh()` 的 `_cells.get(EQUIP_BASE+i)` + 对空槽自动跳过,无需改。头注释同步为 `_to_wire()` 的分流说明。 + ④ `inventory_test.gd` 增 7 条 wire 坐标断言(`_to_wire(90)→[2,0]`、`(100)→[2,10]`、 + `(109)→[1,109]`…`(113)→[1,113]`)。`inventory_ui_test.gd`(`move_to(5,91) → [1,5,2,1,1]`, + 锁定基础 wear 仍走 window=2)保持绿。C++ 21/21(`net.loopback_flow` 一次 localhost bind 抖动, + 重跑即过)+ 55 个 GDScript 回归全绿。 + seam:修改 2(`antiflag→wearable_flag→limit` 装备校验顺序 + `CANNOT_EQUIP_*` 文案)—— + 40250 客户端本身不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag`/`GetLimit`, + 仲裁在服务器 `char_item.cpp`),按 §3.9 `__CanShot` 方式防御性重建、tie-break 顺序留待接服务器; + 修改 4(穿 costume 隐藏本体 armor / 穿头盔隐藏发型,`InstanceBase.cpp` `ChangeArmor`/`SetHair`); + 修改 5(远端角色 `armor/weapon/hair` 三字段接换模,`net_world.gd` 调用序已在 §2.5 对齐)。 + 留给增量 97+:§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、 + §4.2 拖放鼠标物品态。 + +- [x] **增量 97 —— W3→M2 §4.1 修改 2:`CanEquipNow` 前置门(antiflag→wearable_flag→limit 1:1)**: + ① 新增 `project/equip_rules.gd`(`extends RefCounted` 纯校验器),枚举 1:1 自 + `REF/GameLib/ItemData.h`——`EItemAntiFlag`(classic 排列:`FEMALE=1<<0`…`SHAMAN=1<<5`, + 帝国位 `EMPIRE_A/B/R = 1<<9..1<<11`)、`EItemWearableFlag`(`BODY=1<<0`…`ARROW=1<<9`)、 + `EWearPositions`(0..10)+ `GameType.h` 新装备槽 19..23、`ELimitTypes`(`LIMIT_LEVEL=1`… + `LIMIT_CON=5`、`PCBANG=6`、`REAL_TIME=7`)、`EItemType`(`WEAPON=1`/`ARMOR=2`/`COSTUME=28`/ + `RING=33`/`BELT=34`)+ `EArmorSubTypes`/`ECostumeSubTypes`。 + ② `can_equip(item, wear, ctx) -> {ok, code, need}`,语义顺序对齐服务器 `char_item.cpp::CanEquipNow`: + 目标槽解析(`wear<0` → `default_wear_for()` 按 type/sub)→ anti-flag 性别(`ANTI_SEX`)→ + anti-flag 职业(`ANTI_JOB`,`JOB_ANTIFLAG[race%4]`)→ anti-flag 帝国(`ANTI_EMPIRE`, + `EMPIRE_ANTIFLAG[GC_EMPIRE 1/2/3]`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按 + `wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`: + `ctx.level < v`;`LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判,`< 0` 跳过)。 + `SEX_BY_RACE`/`job_of_race`/`sex_of_race` 表 1:1 自 `MAIN_RACE_*`。 + ③ `inventory_ui.gd`:`use()` 对「常规背包格 + 可穿戴物品」跑前置门,拦下则 `_set_hint()` + + 新 `signal equip_rejected(code, need)` + 返回 false(不发 `use_item`);新增公开 + `can_equip_item(vnum, wear)`、`_equip_ctx()`(从 `get_points().level` + `get_entities()` 主角 + `race`/`empire`/`level` 组装;`st/dx/ht/iq` 未由 M2Client 按名暴露 → 传 `-1` = seam)、 + `_equip_reject_hint()`(`ANTI_SEX`/`ANTI_JOB`/`ANTI_EMPIRE`/`NOT_WEARABLE_HERE`/`LIMIT_*` → 中文文案)。 + ④ 新增 canonical 测试 #17 `project/equip_rules_test.gd`(33 断言:race 表、`default_wear_for` + 各 type/sub、`slot_accepts`、anti-flag 性别/职业/帝国、`NOT_WEARABLE_HERE`、`LIMIT_LEVEL/STR/CON` + 含 need 值 + 属性 `-1` 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 `NO_ITEM`); + `inventory_ui_test.gd` 的 `FakeProto.item()` 补 `sub_type/anti_flags/wear_flags/limits` + 让 `iv.use(5)` 仍过门。C++ 21/21 + 56 个 GDScript 回归全绿。 + seam:精确 tie-break 顺序、已穿同槽替换、`LIMIT_PCBANG`/`LIMIT_REAL_TIME*`、属性 4 项按名读取, + 均留待接服务器 `char_item.cpp` 比对。 + 留给增量 98+:§4.1 修改 4(部位隐藏 / 遮挡)、修改 5(远端换装接字段)——均为 render 侧; + §5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。 + +- [x] **增量 98 —— W3→M2 §4.1 修改 4:变身 / 时装部位遮挡(`InstanceBase.cpp` 1:1)+ 修改 5 字段序确认**: + ① 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 + `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`)、 + `ANIMAL_WEAR_SHAPES=[100..103]`(`__IsShapeAnimalWear` → `SetWeapon`(:2747) / `SetParts` + 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress` → `m_eShape==201`)、 + `BOUQUET_VNUMS=[50201..50204]`(`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外一律 false)。 + 函数:`is_animal_wear` / `is_wearing_dress` / `is_bouquet` / `is_changable_weapon` + (婚纱→仅捧花可换)/ `weapon_hidden`(变身 ∨ 动物时装 ∨ 婚纱且非捧花)/ `hair_hidden` + (变身 ∨ 动物时装;婚纱不影响头发)/ `body_shape`(变身→0,动物时装 100-103 保留用于出时装体)/ + `effective_weapon`(被隐藏→0)。 + ② `equip_model.gd`:`refresh()` 刷新顺序改为对齐 `ChangeArmor`(身体 → 头发 → 武器 → 盾), + 解出身体 shape(`armor_shape_of` = `__ArmorVnumToShape` 的 `values[3]`/`=vnum`)后过 + `PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2` + `shield_gr2` + `hair_gr2`;婚纱非捧花隐藏 + 武器 + 盾、头发保留;脱下时 `head_key` 从 `-1` 复位触发发型重挂(`_last_head_vnum` 哨兵改 `-2`)。 + 新增 `_is_poly()` seam(40250 实体表暂无变身字段,恒 false)。 + ③ `net_world._apply_field_updates()`:确认 armor/weapon/hair 字段序已 1:1 于 + `NetworkActorManager.cpp:473`(`ChangeArmor→ChangeWeapon→ChangeHair→…`,增量 46 落地), + 补注变身 / 时装遮挡统一走模型侧 `part_hiding.gd`。 + ④ 新增 canonical 测试 #18 `project/part_hiding_test.gd`(45 断言:animal-wear 全集、 + dress / bouquet 判定、`is_changable_weapon` 婚纱门、`weapon_hidden` / `hair_hidden` / + `body_shape` / `effective_weapon` 各分支);`equip_model_test.gd` 补动物时装遮挡武器/盾/发、 + 脱时装恢复、婚纱遮武器保发、婚纱换捧花恢复武器 7 段。C++ 21/21 + 57 个 GDScript 回归全绿。 + seam:变身(`_is_poly` 恒 false,等 `GC_CHARACTER_UPDATE` 带变身 race);远端 PC 真模型工厂已由 + 增量 99 首版落地,但所有职业 / race 的资源可用性、精确体型 / 变身与真服换装仍未验收。 + 留给增量 108+:§5.3 fishing 鱼获 / `GC_ITEM_*` 事务、DungeonBlock `.mdatr` collision / 副本入口结算、mount UI、observer 自由视角 / 生命周期、§4.2 拖放鼠标物品态、 + §4.3 tooltip 特殊分支 + 装备对比;§5.2 的五行 / 滚动 / 任务行事件已由增量 101 落地。 + +- [x] **增量 104 —— W3→M2 §5.3 / 钓鱼反馈消息与 item proto 分支**:按 + `40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860` + 对齐 `GC_FISHING`:SUCCESS 只负责动画,不直接写“鱼钩命中”;FAIL / info=0 / fish-vs-nonfish + 分别走 `FISHING_FAILURE` / `FISHING_UNKNOWN` / `FISHING_NOTIFY1/2` / `FISHING_SUCCESS1/2`; + unknown item proto 静默忽略。`ChatUI` 加载 `locale_game.txt`,`game_scene.gd` 注入 item proto + 和 wrong-place 回调,`chat_test.gd`、`gamescene_test.gd` 通过。真正背包数量仍由独立 + `GC_ITEM_*` 包驱动,本批次不伪造落袋。 + +- [x] **增量 105 —— W3→M2 §5.3 / 坐骑攻击等级门**:按 + `40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216` 的 `SHORSE::GetLevel` 映射、 + `InstanceBaseBattle.cpp:623-628` 的 `CanAttackHorseLevel` 和 `PythonPlayerInput.cpp:737-764` + 的 `__CanAttack`,接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门; + 自动攻击改用实体 `mount_vnum` 判定。`netplay_test.gd` PASS。坐骑模型 / `MODE_HORSE*` + 动作 / 移速和真服坐骑数据仍待验收。 + +- [x] **增量 106 —— W3→M2 §5.3 / 观战者小地图插值**:按 + `40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180` 对齐 + `CPythonMiniMap::AddObserver/MoveObserver/RemoveObserver`,为 `GC_OBSERVER_ADD/REMOVE/MOVE` + 建立独立轨迹并以 1 秒线性插值;初始 `get_observers()` 快照可播种,`world_reset` 清空轨迹。 + `p9_test.gd` 覆盖 ADD / MOVE / REMOVE / reset 并 PASS。观战自由视角和真服完整生命周期仍待验收。 + +- [x] **增量 107 —— W3→M2 §5.3 / 副本目的地状态与 HUD 罗盘**:按 + `40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901`、 + `PythonPlayer.cpp:147-167,1255-1285` 接入 `GC_DUNGEON` 的目的地分支;保存全局厘米坐标, + 收包立即发出目标提示,按参考端 Manhattan `<10000cm` 清除目标,未到达时每 20 秒再次提示, + 并在 `world_reset` 清理。`TIME_ATTACK_START` 保持参考端空分支,不添加本地计时器。 + `project/dungeon_state.gd`、`project/hud.gd`、`project/game_scene.gd` 与 + `project/dungeon_state_test.gd` 已接线,副本状态和场景回归 PASS;DungeonBlock `.mdatr` + 碰撞、真实副本入口 / 结算窗口和真服验收仍待完成。 仍未完成: @@ -76,7 +839,9 @@ - [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景 节点无残留;受限环境下 localhost bind 子测试自动 skip。 - [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。 -- [ ] 版本包 `0xf1` / 可执行文件名 / locale 分支(§1.5);当前仍是已知差距。 +- [ ] locale 代码页转码(CP949 / CP1252)及真实 40250 服务端对 `0xf1` / 可执行文件名分支的接受情况; + 代码路径已接线,但仍属待真实验收。 +- [ ] AppLifecycle 的真实 Android/iOS 前后台、内存告警和连接断开重连验收;离线单一属主回归已通过。 本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 40250 服务端证据的项目仍保持“未验证”。 @@ -138,6 +903,7 @@ 且后者**在一次登录里会连接两次**。 ``` + 1) CAccountConnector::Connect(gameAddr, gamePort, authAddr, authPort) REF/UserInterface/AccountConnector.cpp:37 └ 记住 game 地址,连的是 auth 地址 2) auth 上发 CG_LOGIN / CG_LOGIN3 → GC_AUTH_SUCCESS{bResult, dwLoginKey} @@ -411,11 +1177,9 @@ return SendSequence(); header `HDR_CG_CLIENT_VERSION2`; - 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。 本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。 -3. ✅ `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)` 由 - `M2Client` 传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。 +3. ✅ `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)`, + `M2Client` 已传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。 填充经 §1.10 的 `mtnet::to_wire(name, sizeof(p.filename) - 1)`。 - **跨文件待接线**:`M2Client` 需调用 `classic_sess->set_executable_name(...)`(W0,已在 - `classic_session.h` 留 `TODO(W0)`)。 4. ✅ `MT_BUILD_TIMESTAMP` 由 `extension/CMakeLists.txt` 的 `string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")` + `target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP=...)` 注入,格式对齐 C 的 @@ -759,10 +1523,10 @@ else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error", `mut_shop_sign()` 的凭空建 Entity 由 W2 按 §2.2 步骤 4–5 处理;本批次的合并逻辑对当前 W2 代码即可工作(`mut_spawn_full` 已就位)。 5. ⬜ 同上,`mut_shop_sign()` → 独立 `ShopSign` 表现状态属 W2。 -6. ✅ **跨文件待接线**:`M2Client` 需调用 - `parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))`(W0,已在 - `classic_parser.h` 留 `TODO(W0)`);未设置时 `npc_names.h` 回落 - `$MT_ASSETS` / `$M2_ASSETS` 环境变量,再回落空串。 +6. ✅ **跨文件已接线**:`M2Client` 在 `MT_ASSETS` 配置时调用 + `parser().set_npclist_path(/root/npclist.txt)`;未配置时 `npc_names.h` 回落 + `$M2_ASSETS` 环境变量,再回落空串。后续仍可把资源根从 Godot `AssetRoot` 显式传入, + 以覆盖打包场景中不使用环境变量的路径。 **W1 增量 50 已落地(🔎 待真实验收)** @@ -1050,6 +1814,43 @@ affect 图标与剩余时间没有统一状态。 (= 停在上一帧位置,不做滑移)。 5. 坐骑:`IsMountingHorse()` 时移动速度与动作模式按 §6.4 的 `MODE_HORSE*` 走。 +**状态:✅ 完成(2026-09-02 / W2 / 增量 76;Actor-Actor 碰撞 + 坐骑转向速度 2026-09-03 / 增量 93)** + +- `player_controller.gd` 重构为 Src/Dst 模型:`_src_pos` / `_dst_pos` / `_is_going` / + `_dst_rot`,`walk_to()` → `_goto(dst_flat)` 一一对应 `CInstanceBase::NEW_Goto()`—— + 太近(`flat.length() < MOVABLE_GROUND_DISTANCE_M`,默认 1.0 m)直接返回 true 不动 + (≈ `__IsMovableGroundDistance`);否则设 Src=当前位、Dst=点、`_dst_rot=atan2(x,z)`、 + `_is_going=true`。`_process` 每帧朝 `_dst_pos` 推进,抵达即 `_is_going=false`。 +- 三个前置门用 `_can_translate()` = `not frozen and not locked and not moving_skill` + 统一(≈ `__IsSyncing` / `isLock` / `IsUsingMovingSkill`):门未过时 `_goto` 把点存进 + `_reserved_ground` 并置 `_reserved_delay_time = RESERVED_GROUND_DELAY`(0.1 s), + 照搬 `__ReserveClickGround` + `NEW_IsEmptyReservedDelayTime` 递减重试。 +- `moving_skill` 为真时 `_goto` 不平移、`_process` 只 `player.rotation.y = atan2(wish.x, wish.z)` + (≈ `IsUsingMovingSkill()` → 只转向)。WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection` + 的 `m_isGoing = FALSE`)。 +- 回归:新增 `project/player_move_test.gd`(5 组:`_goto` 远点设 Src/Dst/is_going/dst_rot、 + 太近不动、每帧推进抵达清 is_going、frozen 门 → 预约 + 0.1 s 后解冻重试、`moving_skill` + 只转向)。`netplay_test` / `combat_fx_test` / `gamescene_test` / C++ 19/19 全绿。 +- 增量 77 已补上 §3.2 的命令队列(`skip_collision` 标志已就位)。 +- **增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**:`player_controller._actor_blocked(next_pos)` + 实现 `CActorInstance::TestActorCollision`(`ActorInstanceCollisionDetection.cpp`)+ + `CInstanceBase::CheckAdvancing`(`InstanceBaseBattle.cpp:469`)近似——距离门 8 m + (≈ `LengthSq > 800²`)→ body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,固定半径近似真实 + race 碰撞球)→「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` + 末尾 `dist(cur) <= dist(last)` 才 `return TRUE`,远离放行)。候选=`pickables`,`dead` meta + 跳过(≈ `rVictim.IsDead()`)。`_process` 平移改逐轴门控(单 Actor 逐轴放行=滑移 ≈ + `AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧 ≈ `BlockMovement` / `__InitializeMovement`)。 + `skip_actor_collision`(≈ `CanSkipCollision` / `__EnableSkipCollision`)由 `net_play` 按观战态置位。 + 坐骑:`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛朝向,`_refresh_motion_mode()` 在 + `mounted` 时切 `ROT_SPEED_HORSE_DEG = 300`(=`c_fDefaultHorseRotationSpeed`, + `CInstanceBase::MountHorse` → `SetRotationSpeed`,`InstanceBase.cpp:954`),下马恢复 1200。 + `MODE_HORSE*` 动作模式沿用增量 84。`player_move_test.gd` 5 组 → 8 组。C++ 21/21 + 14 GDScript 全绿。 +- **仍未做(留后续)**:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`); + `AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替; + `EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家;`MOVABLE_GROUND_DISTANCE` + 参考默认值仍是估计(1.0 m),待读 `game.py` 侧 `SetMovableGroundDistance` 调用校准; + 路径寻路 / 坡度处理。 + --- ### 3.2 同步是距离阈值加 lerp @@ -1114,6 +1915,103 @@ affect 图标与剩余时间没有统一状态。 `server_time_ms()` 与之一致后统一使用,不要用本地 `ticks_msec`。 6. **不要**引入预测回滚 / 抖动缓冲——参考端没有。 +**状态:🟡 部分完成(2026-09-02 / W2 / 增量 77 —— C++ 命令队列核心)** + +- `Entity` 新增 `std::deque state_queue`(`{chk_time, dst_x, dst_y, rot, + func, arg, dur_ms}`)+ `mov_after_func` / `mov_after_arg` / `mov_after_rot` / + `skip_collision`(对应 `m_kMovAfterFunc` / `__EnableSkipCollision`)。 +- `mut_move()` 不再直接改坐标:入队 `StateCmd`(≈ `CInstanceBase::PushTCPState`)后 + 立即 `drain_state_queue()` 释放已到期项。m2dev `apply()` 的 `GC_MOVE` 与 classic + parser 都改走 `mut_move(vid, angle, func, tx, ty, dur, arg, time)`,删掉旧的 + `start_move()` 直改坐标路径。 +- 新增 `process_states()` + 私有 `drain_state_queue` / `apply_state_cmd` / + `begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`, + 1:1 实现上表 switch:`FUNC_WAIT` 的 `1.0` 阈值(走过去 vs 瞬移对齐)、 + `FUNC_COMBO/ATTACK/MOB_SKILL` 的 `50.0` 阈值(先走再动作 vs 瞬移+动作), + 「走过去」分支置 `skip_collision=true` 并把 combat 动作 + 动作索引 latch 到 + `mov_after_*`;`tick()` 里走到 Dst 后按 latch 把 `func` 切到 COMBO/ATTACK/MOB_SKILL + 再清 latch。 +- 释放门:`drain_state_queue` 先按服务器帧钟 `m_server_frame_ms`(新增 + `set_server_frame_ms` / `server_frame_ms`)比 `chk_time`——帧钟为 `0`(尚未对齐)时 + 一律立即释放,等价旧行为;再过 `__CanProcessNetworkStatePacket`(`dead` / `knock_down` + 时 hold 队列不丢包)。走路时长优先用服务器 `dur_ms`(≈ `SNetworkActorData::UpdatePosition` + 的插值窗口),缺省才按 `dist / moving_speed` 推。 +- `tick()` 每帧先 `process_states()` 再推进插值。 +- 回归:新增 `extension/tests/net_state_queue_test.cpp`(帧钟释放门、帧钟 0 立即释放、 + `FUNC_WAIT` 1.0 阈值、`FUNC_COMBO` 50.0 阈值 + 到达后动作、knock_down / dead hold 门、 + 多条到期命令一次排空按序);`net_entity_test` 的 `FUNC_ATTACK` 断言按新语义改写 + (近距 <50 瞬移+动作、远距 ≥50 先走后打)。C++ 20/20(含新 `net.state_queue`)、 + `netplay_test` / `combat_fx_test` / `gamescene_test` / `netbridge_test` / `p2b_test` / + `player_move_test` 回归全绿。 +- **留给增量 78**:④ `net_play.gd` 上行改 6 事件回调 + 节流常量(100 / 300 ms、位移 1.0); + ③ `net_world.gd` 停掉自己那份 lerp,C++ 插值只喂 §2.1 可见性;⑤ 从 `GC_HANDSHAKE` + `lDelta` 接真实服务器毫秒帧钟并 `set_server_frame_ms()`(现为 0 = 立即释放); + `is_enable_tcp_process` 的表情锁位(实体行暂无该状态);GC_SYNC_POSITION 纠偏是否清队列。 + +**状态:🟡 部分完成(2026-09-02 / W2 / 增量 78 —— 上行 6 回调 + net_world 去 lerp)** + +- **④ `net_play.gd` 上行改 6 事件回调**(对齐 `PythonPlayerEventHandler.cpp` 的 + `OnMove` / `OnMoving` / `OnWaiting` / `OnStop` / `OnAttack` / `OnUseSkill`): + - 新增统一出口 `_send_state(func, arg, world_pos)` → `client.move(func, arg, + heading, cm.x, cm.y)`(≈ `SendCharacterStatePacket` / `TPacketCGMove`,rot 的 + `/5.0` 与坐标 `__LocalPositionToGlobalPosition` 仍由 C++ `client.move()` 负责), + 并记 `_last_sent_pos`。 + - `_on_local_moved`(`pc.moved` 每帧):`_was_moving` 为假 → **OnMove** 立即发 + `FUNC_MOVE`,并把 `_last_moving_sent_t` / `_last_waiting_sent_t` 都推到「刚发过」 + (= 重置 moving/waiting 两个节流窗);否则 → **OnMoving**,`SEND_MOVING_INTERVAL` + 300 ms 节流后发 `FUNC_MOVE`。 + - `_on_anim_state("wait")` 且 `_was_moving` → **OnStop**:不节流,落一包 `FUNC_WAIT`。 + - 新增 `_tick_on_waiting()`(`_process` 每帧调):站立时 `SEND_WAITING_INTERVAL` + 100 ms 节流且相对 `_last_sent_pos` 位移 ≥ `SEND_WAITING_MIN_DISP_CM`(1 cm) 才发 + `FUNC_WAIT` —— 吸收本地被击退 / 碰撞推挤后的漂移。 + - `_do_attack_swing`:除既有 `client.attack()`(`CG_ATTACK`)外,**OnAttack** 额外 + `_send_state(FUNC_COMBO, attack_motion + _combo, pos)`,让远端对齐攻击者位置 / 朝向; + 并置 `_was_moving = false`。 + - 新增公共入口 `on_use_skill(motion_skill, arg)` → **OnUseSkill** + `_send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pos)`;调用点(技能合法性 / + CD / charge)属 §3.8 的 `player_skill.gd`,这里只留 1:1 上行出口(做法同 §3.4 预留桩)。 + - 删除旧的 `SEND_INTERVAL` / `SEND_DIST` / `_last_sent_t` 单一节流。 +- **③ `net_world.gd` 去掉自有 lerp**:`_process` 里 `n.position = _grounded(e.pos)` + 直接跟随——位置插值现在完全在 C++ `EntityStore::tick()`(命令队列 + + `SNetworkActorData::UpdatePosition` 式线性插值)里做,再叠第二层 `follow_lerp` 会把 + C++ 的到达时机拖慢、和 `.msa` 动作事件脱节。`follow_lerp` / `snap_dist` 字段保留只为 + 兼容外部读取,`_process` 不再引用。朝向仍在 GDScript 侧 `lerp_angle` 平滑(非位置)。 +- 回归:`netplay_test` 新增第 11 段(OnMove 立即发 + 重置节流窗 / OnMoving 300 ms 节流 / + OnStop 收尾 / `on_use_skill` → `FUNC_SKILL|motion`+arg),观战段基线改为「相对计数不变」; + `netbridge_test` 的「near move → lerp closes in」改为「node follows e.pos directly」。 + C++ 20/20 不变;`netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / + `p2b_test` / `combat_fx_test` / `player_motion_test` 全绿。 +- **留给增量 79**:⑤ 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟。`ClassicStream` + 已在 `HDR_HANDSHAKE` 存了 `m_server_time_base = hs.time + delta` / `m_client_time_base + = now_ms()`(`classic_stream.cpp:256`),需暴露 `server_frame_ms()` 访问器,穿过 + `classic_session` → `classic_parser`(持 `m_world`)或在 `m2_client` `w.tick()` 前算好 + `now - m_client_time_base + m_server_time_base` 调 `w.set_server_frame_ms()`;m2dev + 后端另有一路。接上前 `m_server_frame_ms == 0` 直通 = 增量 76 时序,无回归。 + `is_enable_tcp_process` 表情锁、`GC_SYNC_POSITION` 是否清队列同样留给 §3.x。 + +**状态:✅ 完成(2026-09-03 / W2 / 增量 79 —— 真实服务器毫秒帧钟,§3.2 收官)** + +- **⑤ `ClassicStream::server_frame_ms()`**(`classic_stream.h:107` / `.cpp:48`,≈ + `ELTimer_GetServerFrameMSec()`):`HDR_HANDSHAKE` 处理里早已存的 + `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()` + (`classic_stream.cpp:266-267`),帧钟 = `now_ms() - m_client_time_base + + m_server_time_base`;首个握手落地前 `m_client_time_base == 0`,返回 `0`。 + 另加 `has_server_clock()`。时间同步模式下每个 resync 握手都会刷新时间基,帧钟保持对齐。 +- **穿线**:`ClassicSession::process()` 在 `m_world.tick()` 前 + `m_world.set_server_frame_ms(m_stream.server_frame_ms())`(`classic_session.cpp:307`), + 于是 classic 后端 `drain_state_queue()` 按服务器时间放行队列。m2dev 后端无握手 `lDelta` + 对应物,`m_server_frame_ms` 恒 `0` = 即时释放 = 增量 76 时序,无回归。 +- **防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000`**(`entity_store.h`):参考端 `StateProcess` + 没有上限;我们的帧钟尚未联机核验,若服务器 `dwTime` 落在我们不共享的时基里,「等到 + `frame >= chk_time`」会让远端 Actor 永久冻结。故 `chk_time` 领先帧钟超过 1 s 视为异常, + 立即放行。**有意偏离参考,已在此记录。** +- 回归:`net_classic_stream_test` 加帧钟段(握手前 `0` + `!has_server_clock()`、握手后 + ≈ `hs.time + lDelta`、连续读单调不减);`net_state_queue_test` 加第 6 段(`chk_time` + 正好等于 `frame + cap` 仍 gated、超一毫秒立即放行)。C++ 20/20、7 个 GDScript 回归全绿。 +- **§3.2 至此完成**。仍待联机核验:真实服务器 `GC_MOVE` `dwTime` 的时基是否与握手帧钟一致 + (若一致,`STATE_QUEUE_MAX_WAIT_MS` 阀几乎不会触发);`is_enable_tcp_process` 表情锁位、 + `GC_SYNC_POSITION` 纠偏是否清队列,随对应 §3.x 再处理。 + --- ### 3.3 普攻节奏硬编码 @@ -1143,13 +2041,44 @@ affect 图标与剩余时间没有统一状态。 **修改** 1. 距离常量搬到 `net_play.gd`:`CLICK_DIST_DEFAULT=150.0`、`CLICK_DIST_NPC=500.0`、 - `CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价 - **[待读参考:`InstanceBaseBattle.cpp` 的 `__GetBowRange`]**。 + `CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价。 + 已读参考:`CInstanceBase::__GetBowRange`(`REF/UserInterface/InstanceBase.cpp:694`)= + `2500.0f - 100.0f`,主角再加 `IAbstractPlayer::GetStatus(POINT_BOW_DISTANCE)`(`Packet.h:1550` + 索引 34,注释单位 meter,但参考端按 cm 直加不 ×100);`IsBowMode()` = + `GetMotionMode()` 为 `MODE_BOW` / `MODE_HORSE_BOW`(`REF/GameLib/ActorInstance.cpp:258`)。 + —— **增量 85 落地**(见 §3.5 状态块 / 下方)。 2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见 `metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end / motion_duration`。 3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。 +**状态:✅ 完成(2026-09-02 / W2 / 增量 74)** + +- `formats/msa.{h,cpp}`:`parse_msa` / `parse_msa_file` 新增 `combat_data()`,读取 + `Group ComboInputData`(`PreInputTime` / `DirectInputTime` / `InputLimitTime` / + `LinkTime` → `TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime, fComboLinkTime}`) + 和 `Group AttackingData`(`AttackingType` / `HittingType` / `AttackingStartTime` / + `AttackingEndTime` / `StiffenTime` / `InvisibleTime` / `ExternalForce`)。缺组时 + `has_combo_input` / `has_attacking_data` 保持 `false`。`formats/tests/msa_test.cpp` + 用真实 `onehand_sword/combo_01.msa` 的字段值断言。 +- `extension/src/metin2_anim.{h,cpp}`:新增并绑定 `get_motion_data()`,导出 + `duration` / `next_combo`(`has_combo_input && DirectInputTime>0 ? DirectInputTime : + duration*0.9`)/ `attack_start_time` / `attack_end_time` / `stiffen_time` / + `invisible_time` / `external_force` 等。 +- `net_play.gd`:删除 `ATTACK_RANGE` 常量,改为 `CLICK_DIST_DEFAULT_CM=150` / + `CLICK_DIST_NPC_CM=500` / `CLICK_DIST_RESOURCE_CM=100`(`_clickable_distance(te)` + 按 `_entity_kind` 选,单位 cm→m)。`_apply_attack_speed()` 只把 `bAttackSpeed/100` + 夹到 `[0.25,3]` 写入 `_atk_speed_factor`(缩放播放速率),不再在收 info 包时把 + `attack_period` 直接写死。攻击命中点用 `attack_period = _current_attack_period()` + = `(next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`。 + —— **弓箭 `__GetBowRange()` 已在增量 85 接入**:`_clickable_distance()` 里 `_is_bow_mode()` + 时改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus` + (`_on_points()` 读 `points["bow_distance"]` = `POINT_BOW_DISTANCE`=34),NPC / 采集物分支覆盖之。 + 详见 §3.5 状态块。 +- 回归:C++ 19/19(`formats.msa_loop_data` 覆盖扩展断言);`combat_fx_test` / + `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / + `p2b_test` 全绿。 + --- ### 3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑 @@ -1199,9 +2128,15 @@ else if (main->IsAttackableInstance(*victim)) 1. `player_controller.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` 与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved / _reserved_delay`,逐字对齐 `PythonPlayerInput.cpp:820-865`。 -2. 每帧执行 `_refresh_mouse_walking_direction()`,四个 case 分别照抄 - `PythonPlayerInputMouse.cpp:288-370`(含 `MODE_CLICK_ITEM` 的 `< 20.0f` 拾取阈值、 - `MODE_USE_SKILL` 的 `IS_HUGE_RACE` 距离 −200 修正)。 +2. 每帧执行 `_refresh_reserved()`,四个 case 分别照抄 + `PythonPlayerInputMouse.cpp:288-370`:`MODE_CLICK_ITEM`(按 IID 查掉落物世界坐标, + `< 20.0f` `CLICK_ITEM_PICKUP_CM` 到手 → `FUNC_WAIT` + `pickup_item` + 解冻 + 清预约, + 否则 `walk_to`;物品消失 → 清预约)、`MODE_CLICK_POSITION`(`_can_translate()` 未锁且 + `_reserved_delay <= 0`(≈ `NEW_IsEmptyReservedDelayTime`)→ `walk_to(_ppos_reserved)` + + 清预约)、`MODE_USE_SKILL`(`dist_cm` 含 `IS_HUGE_RACE` 的 `-200cm` 修正,`< + _skill_range_reserved` → `set_target` + `use_skill_hook(slot)` 成功才清预约,否则 + `walk_to`;目标丢失 → 清预约)。`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用 + `proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似(seam)。 3. `_reserve_process_click_actor()` 严格按 (b) 的 10 步实现。 4. `_update_auto_attack()` 按 (a) 实现;`m_dwAutoAttackTargetVID` 只在 `__OnPressActor(isAuto=true)` 且 `IsAttackableInstance` 时设置 @@ -1211,6 +2146,82 @@ else if (main->IsAttackableInstance(*victim)) NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD`)走 `game.py` 同名回调的 `localeInfo` 文案表。 +**状态:✅ 完成(2026-09-03 / W2 / 增量 91)**(增量 75 完成 CLICK_ACTOR 全量;增量 91 补齐 +CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派 + `__ChangeTargetToPickedInstance` 拾取回退 + +扇形/圆形补 fly-targeting) + +- `net_play.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, + USE_SKILL}` 与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / + _skill_range_reserved / _reserved_delay / _auto_attack_vid / _last_cannot` + (对齐 `PythonPlayerInput.cpp:820-865`)。`_process` 改为每帧 + `_update_auto_attack()` → `_refresh_reserved()`(≈ `NEW_RefreshMouseWalkingDirection` + 的模式分派)。 +- `_update_auto_attack()` 逐条照抄 `__Update_AutoAttack()`(PythonPlayer.cpp:207): + 无目标 / `!_can_process_network_state()`(≈ `!NEW_GetMainActorPtr` / `__IsSyncing`)/ + `_dash_active || (_using_skill && _charge_skill)`(`__IsUsingChargeSkill`)/ 目标不存在 / + 死亡 / 骑乘且 `!CanAttackHorseLevel` 各自处理,可攻击且非睡眠时无条件 + `_reserve_click_actor()`(节奏不在这里 gate)。 +- `_reserve_process_click_actor()` 严格 10 步(`PythonPlayerInput.cpp:877`,顺序不可改): + 1 空引用清预约 → 2 出可点击距离 `pc.walk_to()` 继续靠近 → 3 不可攻击 `client.click_npc()` + + 清预约(NPC 对话)→ 4 目标死 → 5 `main.in_safe` → `OnCannotAttack("IN_SAFE")` → + 6 `reserved.in_safe` → `"DEST_IN_SAFE"` → 7 `_dash_active` 保持预约 → + 8 弓箭模式且 `_arrow_count==0` → `OnCannotShot("EMPTY_ARROW")` → 9 未选中则 `set_target` → + 10 `_attack_cd<=0` 才 `_do_attack_swing()`(面向 + combo + `CG_ATTACK` + 动作 + §3.3 节奏刷新), + 然后清预约、下帧重新预约。 +- `OnCannot*` 走新 `signal cannot_act(code)` + `_last_cannot`(HUD 接 `localeInfo`)。 +- 服务端尚未下发的判定位(`in_safe` / `bow_mode` / `mounting` / `sleeping` / dash affect / + charge skill / 箭矢数)先用实体字典键 + 本地占位变量(`_arrow_count=-1` / `_dash_active` / + `_charge_skill`)接线,真值填充与 §5.4 PVP / §3.8 技能 / 弓箭 `__GetBowRange` 一起补, + 做法同 §3.7 的 `knock_down`。 +- 回归:`netplay_test` 新增 5b 段(出距离→`walk_to` 且不发包、进距离→攻击且清预约、 + `IN_SAFE`→`cannot_act` 且不发包、目标死→auto-attack 清零);C++ 19/19、`combat_fx_test` / + `netbridge_test` / `gamescene_test` / `player_motion_test` / `p2b_test` / `input_key_test` + 全绿。 + +**增量 91 补充**(`net_play.gd` / `ui/skill_table.gd` / `ui/quickbar.gd` / `game_scene.gd`): + +- `_refresh_reserved()` 补齐自增量 75 留桩的三个 case,逐字对齐 `NEW_RefreshMouseWalkingDirection` + (`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`(:290)、 + `_reserve_process_click_position()`(:320,`_reserved_delay` 由 `_process` 逐帧递减 ≈ + `NEW_IsEmptyReservedDelayTime`)、`_reserve_process_use_skill()`(:340,`_is_huge_race` + 的 `HUGE_RACE_DIST_FIX_CM` `-200cm` 修正)。`_clear_reserved()` 同时复位 `_ppos_reserved` / + `_skill_range_reserved`。 +- 预约入口:`reserve_click_item(iid)` / `reserve_click_ground(pos)`(`_reserved_delay=0.1`)/ + `reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`, + `range > 100` 先减 `RESERVE_SKILL_RANGE_TRIM_CM(10)`)/ `pick_ground_item(iid)`(= `__OnPressItem`—— + 清预约 + auto-attack 归零,`CLICK_DIST_RESOURCE_CM` 内直接 `pickup_item` + 停,否则预约)。 +- `_on_pick()` 里 `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`):`_reserved_mode==USE_SKILL && + _dash_active` 时只改 `_vid_reserved`;战斗目标改走 `_change_target_to_picked_instance(vid)` + (= `__ChangeTargetToPickedInstance` :186 → `SetTarget` :130——`IsTargetableInstance` 限定 + PC/怪,否则 `set_target(0)` + `clear_target`;NPC/石头/warp 仍由 `_on_pick` 先分流到 `click_npc`)。 +- `_is_huge_race(race)`——`IS_HUGE_RACE` 的 vnum 白名单未随源码发布,改用 `proto.mob(race)` 的 + `size >= MOBSIZE_HUGE(3)` 或 `rank >= BOSS(4)` 近似,查不到 → false(文档化 seam)。 +- 扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块): + `send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING` 已由 + `M2Client::use_skill` 发过,这里只补附加目标——`_instances_in_fan_range()`(= + `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 + `(MAX-MIN) - RPM*min(dist,1000) + MIN`,`FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/ + `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid, + sx, sy)` 补齐;名额不足时 `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` + :124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点) + → `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。 +- `ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析 + (`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 `target_count(id, level)` + (= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0,否则 `SkillPoint` 代入 + 后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`, + 弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0)。 +- `ui/quickbar.gd`:`activate()` 里 `client.use_skill` 返回后,若 `target_vid != 0` 且技能为 + `FAN_RANGE` / `CIRCLE_RANGE` → 调 `net_play.send_fly_targeting(...)`;新增 `_skill_level(sid)`、 + `activate_reserved(global_slot)`(`use_skill_hook` 回调)。`game_scene.gd` 接 + `net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`。 +- 回归:`netplay_test` 新增「5c」段(CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、 + 物品消失→清;CLICK_POSITION 延时门 + `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、 + 射程内`hook`+`set_target`+清、目标丢失→清;`_change_target_to_picked_instance` NPC→清目标 / + 怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + 背后排除、CIRCLE 背后计入、随机落点补齐、 + `max_count<=1` / `SINGLE` → 0);`skill_test` 补 skill 47(gwangyeok,FAN + Range 2500 + + `target_count(lv1)=2` / `(lv20)=5`)/ skill 91(bipabu,FAN + Range 1800 + 无公式→0)/ + skill 1(`fly_shape=SINGLE`)。C++ 21/21 + 13 个 GDScript 回归全绿。 + --- ### 3.5 攻击动作、命中时机和网络包没有严格绑定 @@ -1262,21 +2273,141 @@ OnHit(uSkill, victimActor, isSendPacket): **修改** -1. `metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与 - combo 输入三时间,`.msa` 已解析结构见 `POC/extension/src/metin2_anim.cpp`。 -2. `RaceData` 的 combo 表(`ComboIndexVector`,按 `MAKE_COMBO_KEY(motionMode, comboType)` 索引, - `REF/GameLib/RaceData.h:9`)需要从 `.msm` 解析出来;当前 `.msm` 解析只用于换肤。 -3. `player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`), - 删除 0/1/2 循环。 -4. 命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`; - `FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条), - `M2Client::sync_positions()` 已有上限 16 的实现,直接复用。 +1. ~~`metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与 + combo 输入三时间~~ —— **完成(增量 82)**:`formats/msa.{h,cpp}` 把 `Group AttackingData` + 按 `GameType.cpp NRaceData::LoadMotionAttackData` 的两种写法解析成 + `std::vector`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`, + 无 `HitDataCount`)= 1 个窗;新式(`AttackType` + `MotionType` + `HitDataCount N` + + `Group HitData00..0N`)= N 个窗(多段挥击 / 双持左右手 = 2 个窗)。每个窗带 + `start_time` / `end_time` / `bone` / `weapon_length` + `List HitPosition` 采样 + (`{time, last_pos, pos}` × n,7 个浮点一组)。`textscript.cpp` 同时补上 + `List Name { ... }` 块解析(原来 `List` 会把花括号吞进普通行、还会串掉其后的 + `Group`)。`metin2_anim.get_motion_data()` 新增 `hit_windows` 数组 + `motion_type` / + `hit_limit_count`;`attack_start_time` / `attack_end_time` 保留为第一个窗的起止(兼容旧调用)。 + `msa_test` 加内联单窗 / 新式双窗合成断言 + `M2_ASSETS` 时对真实 + `warrior/general/attack`(17 采样)、`twohand_sword/combo_05`(150 武器长 / 37 采样)、 + `assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、`warrior/skill/palbang` + (`AttackingData` 后还有 `MotionEventData`,List 修复后 3 个事件都解析到)断言。 +2. ~~`RaceData` 的 combo 表需要从 `.msm` 解析出来~~ —— **更正(增量 80)**:PC 连击段表 + **不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`)。真正来源是 + `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / + `RegisterComboAttackNew` 调用(绑定 → `CRaceData::ReserveComboAttack` / + `RegisterComboAttack`,`REF/GameLib/RaceData.cpp:405`)。已由 **`formats/combo_table.{h,cpp}`** + (`fmt::PlayerComboTables`)解析:按 `def __LoadGameEx` 分 4 个职业块, + 每块一张 `map>`,语义对齐 + `CRaceData`(Reserve 用 `map::insert` 不覆盖、Register key 未登记或下标越界即跳过)。 + `formats_combo_table_test` 覆盖合成片段 + 真实 `playersettingmodule.py`。 +3. ~~`player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`), + 删除 0/1/2 循环。~~ —— **完成(增量 81)**:`net_play.gd` 的 `_do_attack_swing` / + `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 + `CActorInstance::InputComboAttackCommand` / `__RunNextCombo` / `ComboProcess` / + `__OnEndCombo` / `__ClearCombo`(`REF/GameLib/ActorInstanceBattle.cpp:157/213/256/297/308`): + `_combo_index==0` → 直接推进;否则按 `.msa` `ComboInputData` 三时间 + (`pre_input_time`=InputStartTime、`direct_input_time`=NextComboTime、`input_limit_time`=InputEndTime) + 判 `_is_pre_input` 挂起 / 直接推进 / 弓模式置 `_is_next_pre_input`;无 combo 输入组时用 + `duration*0.9` 兜底;`_combo_process()` 每帧触发挂起输入、动作回 Wait 时 `_clear_combo()`; + 段表越界(`__RunNextCombo` 的 OOB 分支)不推进;末段 `__OnEndCombo` 仅骑乘复位到 1。 + 段号来自增量 80 的 `Metin2AnimPlayer.parse_combo_tables()`(新静态绑定,读 + `root/playersettingmodule.py`),按 `race & 3` 选职业块、按 `combo_motion_mode<<16|type` 取段序列 + (`combo_motion_mode` 默认 GENERAL,武器→mode 映射留给增量 82+)。缺段表(headless / 缺资源) + 退化为单段普攻。`game_scene.gd` 装配时 `net_play.set_asset_root(_assets)`。 + `combat_fx_test.gd` 加 `FakeAnim`(可控 `.msa`)+ 连击状态机全路径断言。 +4. ~~命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`; + `FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条)~~ —— **完成(增量 83)**: + `net_play.gd` 新增 `_begin_hit_windows()`(挥击起手缓存 `get_motion_data().hit_windows` / + `motion_type` / `hit_limit_count` / `invisible_time`,清 `_hit_dedup`,置 `_swing_start_t`)、 + `_attack_process(dt)`(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + + `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):每帧动作时间窗 + `[t-dt, t]` 与某个 `hit_window [start,end]` 相交时,遍历 `client.get_entities()` 里可攻击、 + 未死、双方不在安全区的实体,`net_world.node_for(vid)` 取世界位做几何判定;`_hit_deduped()` + 查重(`m_HitDataMap` 等价:`MOTION_TYPE_COMBO` 同窗同目标只一次,其余按 `invisible_time` 冷却)、 + `_register_hit()` 校验每窗命中上限(NORMAL/COMBO 16、SKILL `hit_limit_count`);命中 → + `client.attack(_swing_skill, vid)`(= `OnHit` → `SendAttackPacket`,`_swing_skill` 普攻 0), + 被击退目标(`knock_down` / `stunned`)记入 `_victim_flush`。`_emit_swing()` 因此改为 + **只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给 + `_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在 + `_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。 + 几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 + `±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending + sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` + + 命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。 5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`, - 与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。 + 与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]** +6. ~~`combo_motion_mode` 恒 `MOTION_MODE_GENERAL`,未按装备武器种类切 motion set~~ —— **完成 + (增量 84)**:`net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+ + `WEAPON_SUB_*`(`CItemData::EWeaponSubTypes`)+ `ITEM_TYPE_WEAPON/ROD/PICK` 常量;新增纯映射 + `static motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 + `CInstanceBase::RefreshState`(`REF/UserInterface/InstanceBase.cpp:2851`)的 `SetMotionMode`: + Poly→GENERAL、婚纱→WEDDING_DRESS、镐→骑乘 HORSE / 否则 GENERAL、钓竿→骑乘 HORSE / 否则 + FISHING、骑乘按 subtype→`HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `HORSE_{FAN,BELL,BOW}` / + `HORSE`、否则按 subtype→`{ONEHAND,TWOHAND,DUALHAND}_SWORD` / `BOW` / `FAN` / `BELL`、 + `ARROW` 及其余→`GENERAL`。`_equipped_weapon_vnum()` 取主角 `parts[WEAPON]`(0 回退 + `get_equipment()[WEAR_WEAPON]`),`_refresh_motion_mode()` 读 `proto.item(vnum)` 的 + `type`/`sub_type` + 实体 `mount_vnum`/`polymorph` → `set_combo_motion_mode()`(换模式 + `_clear_combo()`)。接线在 `_on_entity_info` 末尾、`_on_inv_changed_mode`(`inventory_changed` + window==EQUIPMENT)、`mount_changed` 信号。`combat_fx_test.gd` 加 `FakeProto` + 15 段映射断言 + + 2 段端到端。 + +7. ~~弓箭模式 `_clickable_distance()` 缺 `__GetBowRange()` 分支~~ —— **完成(增量 85)**: + `net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance` + (`InstanceBaseBattle.cpp:294`):默认 150 → `_is_bow_mode()`(`MODE_BOW` / `MODE_HORSE_BOW`, + 对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)改用 `_bow_range_cm()` → NPC 500 → + 采集物 100(后写 `if` 覆盖前面)。`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + + `_bow_distance_bonus`,1:1 复刻 `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`); + `_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]` 填充,`wire.h` 补 + `POINT_BOW_DISTANCE=34`、`M2Client::get_points()` 增 `d["bow_distance"]`。`combat_fx_test.gd` + §3.5 段加 8 段断言。 + +**状态:🟡 部分完成(2026-09-03 / W2 / 增量 85 —— §3.5 修改 1-7 全落地)** +—— 修改 1 完成(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` 多命中窗 + `textscript.cpp` +`List` 块解析 + `get_motion_data().hit_windows` + `msa_test` 合成 / 真实资源断言); +修改 2 完成(增量 80:`formats/combo_table.{h,cpp}` + `formats_combo_table_test`); +修改 3 完成(增量 81:`net_play.gd` 连击状态机对齐参考端五函数 + `Metin2AnimPlayer.parse_combo_tables()` +静态绑定 + `game_scene` 装配 + `combat_fx_test` 全路径断言);修改 4 完成(增量 83: +`net_play.gd` `_begin_hit_windows` / `_attack_process` / `_hit_deduped` / `_register_hit` / +`_hit_geometry` / `_flush_victim_list` —— 命中窗几何判定 → `OnHit`→`CG_ATTACK`、帧末 +`FlushVictimList`→`CG_SYNC_POSITION`≤16,几何判定用 reach+正面弧近似留桩);修改 5 已在增量 78 落地; +修改 6 完成(增量 84:`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身 +切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85:`_clickable_distance()` 弓箭 +`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript +全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、 +服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。 --- -### 3.6 弓箭发射使用固定 1.5 秒定时器 +### 3.6 ~~弓箭发射使用固定 1.5 秒定时器~~ —— 已修复(增量 86 / 增量 92) + +**状态:✅ 完成(2026-09-03 / W2 / 增量 86;飞行物实体 + `__CanShot` 增量 92)** + +`game_scene.gd` 的单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底已换成 +`_pending_shots: Array[int]`(FIFO,上限 8,满则丢最旧):每次弓挥击 / 弓技能起手 +`_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event` +的 `type 6`)`pop_front()` 弹一个发 `client.shoot()`(pop 即自清 = `_AutoClear`,队列天然 +支持连射多箭在途),对齐 `CActorInstance::ProcessMotionEventFly` +(`ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`: +`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 `net_play.gd._emit_swing`: +`_is_bow_mode()` 时先 `_send_fly_target()`(取目标节点 → `MapCoord.to_server_cm()` → +`client.add_fly_targeting(vid, x, y)`),再 `bow_shot_fired.emit(_swing_skill)` 让 +`game_scene` 入队。 + +**增量 92 补充**(`project/fly_object.gd` 新增 + `net_world.gd` / `net_play.gd`): +飞行物实体本身已落地——`fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐 +`REF/GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget` / `Update` 的 +`while` 迭代 + `erase` / `CreateIndexedFly` 的 NORMAL / FIRE_CRACKER / AUTO_FIRE 分支)+ +`FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`;`Update` +的 `vel += accel*dt` → `remain_range -= |movement|` → 线段-点 `< bomb_range²` 命中 → +`OnShootDamage` / pierce / `__Explode` / `__Bomb`;`hit_on_background` 的 +`GetTerrainHeight` 判定)+ `FlyingData.cpp` `__Initialize()` 默认值。线段判定 +`_seg_point_sq_dist` 1:1 移植 `REF/EterLib/GrpMath.h:80`。`net_world._on_fly` 把原 +tween「发光球」换成 `FlyManager.spawn(...)` 真实弹道 + `signal fly_exploded`。 +`net_play._can_shot()` 防御式重建 `__CanShot`(非弓 / 无箭 `EMPTY_ARROW` / 无目标 +`NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走 +`OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`。 +seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70), +实际驱动走 POC 自有 `fly_cue`;`__CanShot` 在发布源码树中无定义(防御式重建); +`.fly` 资源加载器缺失 → 合成箭参数;怪物球体碰撞 / `CullingMgr` / 建筑体素判定未移植 +(客户端仅做命中时机,伤害服务端裁决);重力参考端作用于 `.z`=上,本 POC 映射到 `-Y`。 +仍待:真服 `CG_FLY_TARGETING` 的 z 分量、`.fly` 数据文件解析。 **参考**:由 `.msa` 的 `MOTION_EVENT_TYPE_FLY` 事件驱动, 经 `IFlyEventHandler` 两个回调(`REF/UserInterface/PythonPlayerEventHandler.cpp:235`): @@ -1292,17 +2423,21 @@ handler 由 `__OnPressActor` 通过 `MOTION_EVENT_TYPE_FLY` 是 `CRaceMotionData::EMotionEventType` 的第 6 项 (`REF/GameLib/RaceMotionData.h:168`)。 -**当前**:`game_scene.gd:218` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。 +**当前(增量 86 前)**:`game_scene.gd` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。 **修改** -1. `.msa` 的 motion event 分派(本工程已实现,见 `game_scene._on_local_motion_event`)新增 - `FLY` 分支:触发 `OnSetFlyTarget` → `client.fly_targeting(vid, x, y, z)`; - 飞行物命中/到期 → `OnShoot(skill)` → `client.shoot(skill)`。 -2. 删除 `game_scene.gd:218` 的定时器与单槽 `pending skill`;改为**每次攻击创建一个 - handler 实例**(对齐 `_AutoClear` 语义:射完自动清理),支持并发多发。 -3. `SendFlyTargetingPacket` 的坐标取目标的 `OnGetFlyTargetPosition()` - **[待读参考:`REF/GameLib/ActorInstanceFly.cpp` 的 `OnGetFlyTargetPosition`]**。 +1. ~~`.msa` 的 motion event 分派新增 `FLY` 分支~~ —— **完成(增量 86)**:`type 6` 分支 + `pop_front()` `_pending_shots` → `client.shoot(skill)`(`OnShoot`);`OnSetFlyTarget` 提前到 + `net_play._emit_swing` 里 `_send_fly_target()` → `client.add_fly_targeting(vid, x, y)`。 +2. ~~删除定时器与单槽 `pending skill`;改为每次攻击一个 handler 实例(`_AutoClear`),支持并发~~ + —— **完成(增量 86)**:`_pending_shots: Array[int]` FIFO(上限 8)替掉 `_pending_shoot_skill` + + `create_timer(1.5)`;`pop_front()` 即自清,连射多箭各自一发。 +3. ~~`SendFlyTargetingPacket` 坐标取 `OnGetFlyTargetPosition()`~~ —— **完成(增量 86)**: + `OnGetFlyTargetPosition`(`REF/GameLib/ActorInstanceFly.cpp:4`)= 目标 `m_v3Center` + (包围球中心)经 `GetTransform()` 变换到世界坐标;其平面分量与角色脚点一致,而 + `CG_FLY_TARGETING` 只带平面坐标,故 `_send_fly_target()` 直接取目标节点 `global_position` + 经 `MapCoord.to_server_cm()` 换 server-XY。 --- @@ -1329,10 +2464,61 @@ handler 由 `__OnPressActor` 通过 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`, 实现分别对齐 `InstanceBase.cpp:1247`、`InstanceBaseBattle.cpp:355`、`:310`。 +**状态:✅ 完成(2026-09-02 / W2 / 增量 73)** + +- `net_play.gd`:删除 `const HITSTUN` 与 `var _hitstun_until` 及其全部引用; + 新增 `var _knock_down`(击退动作播放中,对齐 `CActorInstance::IsPushing` / + `InterceptOnceMotion(NAME_DAMAGE_FLYING)`)和 `var _using_skill`(§3.8 占位)。 +- 三个闸门函数落地:`_can_process_network_state()` = `not(dead or stunned or + knock_down)`(≈ `__IsSyncing`);`_can_attack()` / `_can_use_skill()` = + 前者且 `not _using_skill`。`is_stunned()` 保留为兼容 shim = `not + _can_process_network_state()`。`_process` 的 `pc.frozen` 与攻击闸门改走这些函数。 +- `_on_damage`:非闪避命中只 `set_anim_state("damage")` + 抖屏 + 置 + `_knock_down`,不再写任何计时器;击退动作播完由 `_process` 轮询 + `player_view.is_in_hit_reaction()` 清零。 +- `player_view.gd`:`_on_playback_finished` 增加 `_state == "damage"` 分支 + (一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`。 +- C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露 `"knock_down"`, + `mut_knockdown(vid,on)` 落位并标脏);本地玩家由 `net_play._knock_down` + 驱动,**远端实体的 `knock_down` 填充仍待接线**(后续增量,仅影响远端受击 + 表现,不涉及输入闸门)。 +- 回归:`combat_fx_test.gd` 断言改写(命中→`_knock_down`、服务端 `stunned` + 标志独立驱动、闪避/他人受击不置位);C++ 19/19、`combat_fx_test` / + `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` + 全绿。 + --- ### 3.8 技能缺少统一合法性检查 +**状态:✅ 完成(2026-09-03 / W2 / 增量 87+88+89 —— 修改 1/2/3 全落地)** +—— 修改 2 完成(增量 87:`project/ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc +第 11/12 列折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`,一众 `IsXxx()` 谓词 +逐条对齐 `CPythonSkill`;`skill_test.gd` +34 断言)。修改 1 完成(增量 88:新建 +`project/player_skill.gd` —— `RefCounted` 纯校验器,逐段移植 `ClickSkillSlot`(a) / +`__CanUseSkill`(b) / `__CheckSkillUsable`(c) / `__UseSkill`(d),命名常量 +`SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / 骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996` / +钓鱼 `123` / 连击 `122`,TSkillData 判定全走增量 87 谓词;`net_play.skill_context()` 供运行期 +上下文,`quickbar.activate()` skill 分支先过 `click_skill_slot()` 再发 `use_skill`/`cast_skill`, +挡下发 `skill_rejected(id, code)` → `game_scene` 转 `cannot_act` 走同一条 `OnCannotUseSkill` +文案通道;新 `player_skill_test.gd` 逐分支断言)。POC 无 C++ skill proto,技能数据只在 +GDScript,故“在 `POC/extension/src/proto` 侧补解析”改为在 `skill_table.gd` 内落地(架构一致)。 +修改 3 完成(增量 89):① `net_bounds.h` 加 `SKILL_MAX_NUM=255` + `skill_index(v)=00 && bounds::u32` 收紧成 +`bounds::skill_index()`(带符号形参 + 发包前范围校验,对齐 `ClickSkillSlot` 的 +`slot >= SKILL_MAX_NUM` 门),`cast_skill()` 保留 `motion_idx ∈ [0,0x7f]`(7 位 wire 位段), +`net_bounds_test.cpp` +4 断言。② `skill_table.gd` `_load_cooltime` 更名 `_load_msk`,加解析 +`.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]`(`LocaleService_GetSkillPower` INTERNATIONAL +表,`Locale.cpp:126`)+ `_skill_point(level)` + `need_sp(id,level)`(≈ `GetNeedSP`, +`PythonSkill.cpp:1426`;POC 约定:无公式返回 `-1`,参考返回 0,避免误挡)+ `need_hp()` +(照抄 `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖);`cooldown_of` 的 `SkillPoint` +也改走 `_skill_point()`(与参考同一份 `fcurEfficientPercentage`)。③ `player_skill.gd` +落地 `_check_short_mana`(`__CheckShortMana:320`:summon `131` 豁免、非开关技 `iNeedSP==0` +→ 坏数据拦、`CanUseIfNotEnough` → `icurSP<=0` 拦、否则 `iNeedSP > icurSP` 拦)和 +`_check_short_life`(`__CheckShortLife:354`:`!IsUseHPSkill()` 放行、`dwNeedHP > curHP` 拦), +新增上下文字段 `cur_sp` / `cur_hp`(`net_play.skill_context()` 读 `M2Client.get_points()`, +`-1` = 未知不拦);`player_skill_test.gd` +“(c) §3.8 修改 3” HP/SP 段。 + **参考**:三层,缺一不可。 **(a) `CPythonPlayer::ClickSkillSlot(dwSlotIndex)`**(`REF/UserInterface/PythonPlayerSkill.cpp:108`) @@ -1381,16 +2567,52 @@ main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING **修改** -1. 在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植; +1. ~~在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植; `SKILL_MAX_NUM`、`SKILL_TYPE_GUILD`、骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996` - 均按参考写成命名常量。 -2. `CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill / + 均按参考写成命名常量。~~ + —— **完成(增量 88)**:`project/player_skill.gd`(`extends RefCounted`)。 + `click_skill_slot(id, is_active)` / `can_use_skill()` / `check_skill_usable(id)` / + `use_skill(id, is_active)` 逐段对齐 `PythonPlayerSkill.cpp:106/439/166/460`,返回 + `{ ok, code, target_vid }`;`code` 用 `OnCannotUseSkill` 原字符串 + (`NOT_HORSE_SKILL` / `HAVE_TO_RIDE` / `IN_SAFE` / `NEED_EMPTY_BOTTLE` / + `NEED_POISON_BOTTLE` / `REMOVE_FISHING_ROD` / `NOT_YET_LEARN` / + `NOT_MATCHABLE_WEAPON` / `EMPTY_ARROW` / `WAIT_COOLTIME` / `NEED_TARGET` / + `CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` / + `CANNOT_ATTACK` / `ONLY_FOR_CORPSE`),加静默码集(`PASSIVE` / `GUILD_ROUTED` / + `TOGGLE_OFF` / `SPECIAL_SKILL` / `PRIVATE_SHOP` / `BAD_SLOT` …)对齐参考端“静默 return”。 + 命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ `SKILL_TYPE_GUILD=3` / + `RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` / + `SKILL_INDEX_FISHING=123` / `SKILL_INDEX_COMBO=122`(`PythonPlayerModule.cpp:6-7`)。 + `__CheckRestSkillCoolTime` = 本地槽 `cd_end`(`m_sysIsCoolTime` 关则不查); + `__CheckDashAffect` 分支(冲刺 affect 下非蓄力技才查冷却)1:1 保留; + `__HasItem` 扫 `client.get_inventory()`;`__HasEnoughArrow` 用 `net_play._arrow_count` 占位; + `__CheckShortMana` / `__CheckShortLife` 于增量 89 落地(见修改 3)。 + 目标解析(`_resolve_target`)按 `__UseSkill:520-650` 判联盟 / 安全区 / 可攻击 / 尸体 / + 需要目标并回传 `target_vid`;距离 / `__ReserveUseSkill` / 扇形·圆形多目标 + `SendAddFlyTargetingPacket` 归 §3.4/§3.9,此处不判。接线见状态段。 +2. ~~`CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill / IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget / CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`) - 来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析 - **[待读参考:`REF/UserInterface/PythonSkill.h` 的 `TSkillData` 字段与 - `PythonSkill.cpp` 的 `RegisterSkillDesc` / `LoadSkillTable`]**。 -3. `M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。 + 来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析~~ + —— **完成(增量 87)**:POC 无 C++ skill proto,技能数据只在 GDScript + `project/ui/skill_table.gd`。按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc + 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` + 位掩码(记号名 1:1 抄 `PythonSkill.h:92-128`),`load_file` 时连同 `skill_type` + (JOB 列 → `SKILL_TYPE_*`)预存。上列全部谓词 + `can_use_weapon_type` / `is_need_bow` / + `is_only_for_guild_war` / `can_use_for_me` 等按 `CPythonSkill` 逐条实现;`skill_test.gd` + +34 断言。(参考:`PythonSkill.h` 位枚举、`PythonSkill.cpp:280/909/1143/1407`、 + `GameLib/ItemData.h:63-75`。) +3. ~~`M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。~~ + —— **完成(增量 89)**:`M2Client::use_skill(int skill_id, int target_vid)` 形参本就 + 带符号(增量 52),本次把 `skill_id` 的护栏从 `skill_id <= 0 || !bounds::u32()` 收紧成 + `!mtnet::bounds::skill_index(skill_id)`(`net_bounds.h` 新增 `SKILL_MAX_NUM=255` 常量 + + `skill_index(v) = 0 < v < 255` 谓词,对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` + 门 / `aSkill[]` 索引上限),`target_vid` 保留 `>= 0 && bounds::u32`;`cast_skill()` 的 + `motion_idx ∈ [0,0x7f]` 保持不动(7 位 wire 位段)。`net_bounds_test.cpp` +4 断言。 + 顺带把 `__CheckShortMana` / `__CheckShortLife` 从桩改为实装:`skill_table.gd` 解析 + `.msk` `NeedSPFormula`(`need_sp` / `need_hp` + `LocaleService_GetSkillPower` 功率表 + `_skill_point`),`player_skill.gd` 按 `PythonPlayerSkill.cpp:320/354` 逐条移植;HP/SP + 由 `net_play.skill_context()` 读 `get_points()` 供入,未知(`-1`)不判。 --- @@ -1410,21 +2632,39 @@ main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING **修改** -1. 在 `m2_client.cpp` 的每个公开 `send_*` 入口按上表补 `int` 范围校验,越界返回 `false` - 并记日志——**不要**先 `static_cast`。 -2. 新建 `POC/extension/src/net/limits.h` 收纳参考常量: - `PLAYER_PER_ACCOUNT4=4, QUICKSLOT_MAX_NUM=36, QUICKSLOT_MAX_COUNT=32, - QUICKSLOT_MAX_COUNT_PER_LINE=8, QUICKSLOT_MAX_LINE=4, SHOP_HOST_ITEM_MAX_NUM=40, - METIN_SOCKET_COUNT=6, PARTY_AFFECT_SLOT_MAX_NUM=7, EXCHANGE_ITEM_MAX_NUM=12, - SAFEBOX_PAGE_SIZE=45, REFINE_MATERIAL_MAX_NUM=5, SYNC_POSITION_COUNT_LIMIT=16, - MAP_COORD_MAX=204800`(来源:`REF/UserInterface/Packet.h:380`、`PythonSafeBox.h:8`、 - `PythonExchange.h:13`、`PythonPlayerEventHandler.cpp:200`、`PhaseGame.cpp:1132`)。 -3. 回归:`net_bounds_test.gd` 对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。 +1. ✅ 新建 `POC/extension/src/net/net_bounds.h`,只收纳已从参考 wire / `GameType.h` 核实的公共 + 协议边界:`u8/u16/u32/i32`、`QUICKSLOT_MAX_NUM=36`、`SHOP_HOST_ITEM_MAX_NUM=40`、 + `PRIVATE_SHOP_ITEM_MAX_NUM=39`、`EXCHANGE_ITEM_MAX_NUM=12`、`CUBE_ITEM_SLOT_COUNT=24`、 + `POINT_MAX_NUM=255`、`DS_REFINE_WINDOW_MAX_NUM=15`。未读到的 EAffect / 地图规则没有凭经验塞入 + 这个头文件。 +2. ✅ `M2Client` 的公共入口在所有窄类型转换前检查边界:坐标和 VID、动态文本包长度、物品位置、 + 快捷栏 / 交易 / 私人商店 / Cube 槽位、数量、金币、公会 / 会徽 ID 与端口;私人商店数组超限和 + Classic 原先的静默 39 项截断改为返回 `false`。私人商店字典要求整数字段,避免 Godot Variant + 隐式转换后再截断。 +3. ✅ 按参考语义保留必要特殊值:`set_target(0)` 清目标、`shop_sell(count=0)` 选择旧版单件包、 + `move_item(count=0)` 保留整堆语义;没有用“把负数改成 1”掩盖调用方错误。 +4. ✅ 回归:新增 `extension/tests/net_bounds_test.cpp` / CTest `net.bounds`,覆盖每个共享边界的 + 端点与越界值;`cmake --build build/extension -j2` 通过,`ctest --test-dir build + --output-on-failure` 为 18/18。相关 Godot 离线回归(`netbridge_test`、`p8_test`、 + `shop_cube_mall_test`、`guild_refine_test`、`dragon_soul_test`、`guild_mark_test`、 + `guild_war_skill_test`、`p9_test` 及完整脚本集)通过;Godot 测试按串行、独立日志运行。 +5. ⬜ 仍待真实 40250 服务端验证每种业务的服务端合法性、角色 / 物品语义和地图坐标限制;本批次只 + 保证客户端不会产生越界 wire 参数,不把本地范围检查冒充服务端规则。 --- ### 3.10 技能升级传输已对齐但状态门控不完整 +**状态:✅ 完成(2026-09-03 / W2 / 增量 90)** —— 加号按钮门控按 `uicharacter.py` +的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`) +逐行落地:`_can_use_skill_now()`(技能组门)+ 三个技能点来源分开取(`skill_active` / +`skill_support` / `skill_horse`,C++ `get_points()` 新增键)+ `skillGrade==0` + +马术页 `skillLevel < 20` + 主动页 `skill_table.can_level_up_skill(id, level)` + +辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐藏。`skill_table` 新增 `max_level_of` / +`skill_level_limit` / `can_level_up_skill`。`M2Client::skill_up()` 入参收紧为 +`mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果仍由 `GC_SKILL_LEVEL` +回包经 `skills_changed` 驱动,不本地伪造。 + **证据边界与订正**:本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓与该客户端配套的 迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此 断定当前必须改传技能 vnum。`uicharacter.py:1022 OnPressedSlotButton()`(本仓 @@ -1484,23 +2724,46 @@ if not skill.CanLevelUpSkill(skillIndex, skillLevel): return False return True ``` -**当前**:`POC/project/ui/skill_ui.gd:141` 只有 +**当前**:~~`POC/project/ui/skill_ui.gd:141` 只有 `if table.can_level_up(id) and not table.is_passive(id)`,缺技能点校验、skillGrade、 -等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。 +等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。~~ +—— **增量 90 全部落地**(见下方逐条)。 **修改** -1. `skill_ui.gd` 按上面的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / - `CanShowPlusButton`)**逐行**实现加号按钮的显示门控,替换现有单行判断。 -2. 三个技能点来源分开取:`SKILL_ACTIVE` / `SKILL_SUPPORT` / `SKILL_HORSE` - (`uicharacter.py:1010-1019`),不是统一一个 `POINT_STAT`。 -3. `skill.CanLevelUpSkill(skillIndex, skillLevel)` 与 `skill.GetSkillLevelLimit(skillIndex)` - 需要 skill table 支持,见 §3.8 第 2 点的 `TSkillData` 解析 - **[待读参考:`REF/UserInterface/PythonSkill.cpp` 的 `CanLevelUpSkill` / `GetSkillLevelLimit`]**。 -4. `M2Client::skill_up(int skill_id)`(`m2_client.cpp:850`)的发包本身保持不变 - (`/skillup `),只补 §3.9 的入参范围校验。 -5. 升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包驱动刷新, - 失败提示走 `GC_CHAT` 的服务端文案——不本地伪造成功。 +1. **完成(增量 90)**:`skill_ui.gd` `refresh()` 里按三段逐行实现加号显隐—— + `_can_use_skill_now()`(= `client.get_skill_group() != 0`,对应 `__CanUseSkillNow` :1270)→ + `_stat_point_for(page)`(技能点来源)→ `_can_show_plus(id, level, grade, page, …)` + (对应 `__RefreshSkillPlusButton` 每槽体 + `CanShowPlusButton`)。按钮仍在 `_rebuild()` + 为非被动 / 非 `CANNOT_LEVEL_UP` 的技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()` + + `ShowSlotButton()`)。 +2. **完成(增量 90)**:三个技能点来源分开取。`wire.h` 加 `POINT_SUB_SKILL=27` / + `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`;`M2Client::get_points()` 新增 + `skill_active` / `skill_support` / `skill_horse` 三个键(镜像 `bow_distance` 的做法); + `skill_ui._stat_point_for()` 按页取对应键,不合并成一个 `POINT_STAT`。 +3. **完成(增量 90)**:`skill_table.gd` 加 + `max_level_of(id)`(= `SSkillData::GetMaxLevel()`:`.msk` `maxlevel` > skilldesc `MAX_LEVEL` + 列 > 构造默认 20,`PythonSkill.cpp:1392/1462`)、 + `skill_level_limit(id)`(= `skillGetSkillLevelLimit`,`PythonSkill.cpp:1741` = `byLevelLimit`; + **本 40250 数据里 skilldesc 无 `LEVEL_LIMIT` 列、`.msk` 无 `levellimit` 记号 → 恒 0**, + 马术页等级前置门因而恒真)、 + `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`。 + **本 build 里 `isRequirement` / `strRequireSkillName` / `RequireStatDataVector` 从不被填充 + (构造默认,:1471)→ 1:1 化简为 `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`**)。 + `RegisterSkillDesc` 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析已在 `load_file` + 补上,兼容带这两列的第三方数据。 +4. **完成(增量 90)**:`M2Client::skill_up(int skill_id)`(`m2_client.cpp:961`)发包不变 + (`/skillup `),入参守卫由 `skill_id >= POINT_COUNT` 收紧为 + `!mtnet::bounds::skill_index(skill_id)`(= `0 < v < 255`,与 `use_skill` §3.9 同一谓词)。 +5. **保持(增量 90)**:升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包经 + `skills_changed` / `skill_group_changed` 驱动 `refresh()`,失败提示走 `GC_CHAT` 的 + 服务端文案——不本地伪造成功。 + +**测试**:`skill_test.gd` 扩 `§3.10` 段——`max_level_of` / `skill_level_limit` / +`can_level_up_skill`(未满级 / 满级 / 超级 / `CANNOT_LEVEL_UP` / 未知 id)+ 加号显隐 +(主动页可见、`skillGrade!=0` 隐、`skill_group==0` 隐、该页点数 0 隐、辅助页整页隐、 +马术页 `skill_horse>0 && level<20` 可见 / 点数 0 隐)。`net.bounds` C++ 用例覆盖 +`skill_index` 谓词。全 21 C++ + 13 GDScript 回归通过。 --- @@ -1508,6 +2771,8 @@ return True ### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则 +**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 96 + 97 + 98 + 99 —— 修改 1(proto 位标志导出,此前 §3.5)+ 修改 3(costume/ring/belt 槽位版图与发包坐标,增量 96:`inventory_ui.gd._to_wire()` 按 `GameType.h` 把 wear 0..10 走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell、costume/ring/belt(wear ≥ 19)留在 `WINDOW_INVENTORY(1)` 全局 cell 109..113;扩展面板只补 5 个真实新装备位)+ 修改 2(`CanEquipNow` 前置门,增量 97:新增 `equip_rules.gd` 纯校验器,`antiflag(性别/职业/帝国)→wearable_flag→limit` 顺序 1:1 自 `ItemData.h`,接入 `inventory_ui.gd.use()` + `equip_rejected` 信号 + 中文文案;canonical 测试 #17 `equip_rules_test.gd`)+ 修改 4(变身 / 时装部位遮挡,增量 98:新增 `part_hiding.gd` 纯函数 1:1 自 `InstanceBase.cpp` `IsPoly`/`__IsShapeAnimalWear`(100-103)/`IsWearingDress`(201)/`__IsChangableWeapon`(捧花 50201-50204),接入 `equip_model.gd.refresh()`——刷新序改对齐 `ChangeArmor`(身体→头发→武器→盾)、动物时装隐藏武器+盾+发、婚纱非捧花隐藏武器;canonical 测试 #18 `part_hiding_test.gd`)+ 修改 5(增量 99:远端 PC 通过 `game_scene.gd` 的 `RemotePlayerView` 创建真实 race 模型,远端 `EquipModel` 只消费四字段 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,不读取本地 `get_equipment()`;`net_world.gd` 按四字段调用 `set_armor→set_weapon→set_head→set_hair`,`remote_player_test.gd` 覆盖真实 warrior 资源和字段序)已落地。变身字段(`_is_poly` 恒 false)、所有职业 / race 资源、精确体型 / 远端真服换装、精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME` / 属性 4 项按名读取仍留待验收 / 接服务器 `char_item.cpp` 比对)** + **参考**:`ClientVS22` 的编译开关(`REF/UserInterface/Locale_inc.h`,本份为 `LOCALE_SERVICE_SINGAPORE`)打开了 `ENABLE_COSTUME_SYSTEM` / `ENABLE_ENERGY_SYSTEM` / `ENABLE_DRAGON_SOUL_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM`。对应的**槽位版图** @@ -1544,18 +2809,64 @@ c_Belt_Inventory_Width/Height = 4/4 → 16 格 **修改** -1. `proto` 层导出上述四组位标志(当前只用了 `specular`),落到 - `POC/extension/src/proto/item_proto.h` 的 `ItemProto`。 -2. 装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 → - `limit`(等级/属性) → 发 `CG_ITEM_USE`。校验失败的文案走 `localeInfo` 的 - `CANNOT_EQUIP_*` 键。 -3. costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` / - `equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。 -4. 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按 - `REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现 - **[待读参考:`InstanceBase.cpp` 的 `ChangeArmor` / `SetArmor` / `__AttachHorseSaddle`]**。 -5. 远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把 - `armor/weapon/hair` 三个字段接到换模。 +1. ~~`proto` 层导出上述四组位标志(当前只用了 `specular`),落到 + `POC/extension/src/proto/item_proto.h` 的 `ItemProto`。~~ **增量 96 已完成(此前 §3.5)**: + `proto_node.cpp` 已导出 `d["type"]` / `d["sub_type"]` / `d["anti_flags"]` / `d["wear_flags"]` / + `d["limits"]`(`[{type, value}] × 2`),装备校验数据层齐备,无需 C++ 重编。 +2. ~~装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 → + `limit`(等级/属性) → 发 `CG_ITEM_USE`。~~ **增量 97 已完成**:客户端 40250 本身不做 + `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / `GetLimit`,仲裁在服务器 + `char_item.cpp`),故按 §3.9 `__CanShot` 方式在 GDScript 侧防御性重建—— + 新增 `project/equip_rules.gd`(`extends RefCounted`),枚举 1:1 自 `REF/GameLib/ItemData.h` + (`EItemAntiFlag` classic 排列、`EItemWearableFlag`、`EWearPositions` + `GameType.h` 19..23、 + `ELimitTypes`、`EItemType` + `EArmorSubTypes`/`ECostumeSubTypes`)。`can_equip(item, wear, ctx) + -> {ok, code, need}`:目标槽解析 → anti-flag 性别(`ANTI_SEX`)→ 职业(`ANTI_JOB`,`race%4`)→ + 帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()`(新槽按 item type、旧 11 槽按 + `wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL`: + `ctx.level < v` → `need=v`;`LIMIT_STR/DEX/INT/CON`:对应属性 `>= 0 且 < v` 才判)。 + `inventory_ui.gd.use()` 对「常规背包格 + 可穿戴物品」跑此门,拦下则 `_set_hint()` + 新 + `signal equip_rejected(code, need)` + 不发 `use_item`;新增公开 `can_equip_item()`、`_equip_ctx()` + (`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` 未由 M2Client + 按名暴露 → 传 `-1` = seam)、`_equip_reject_hint()` 中文文案(对齐 `localeInfo` `CANNOT_EQUIP_*` 语义)。 + canonical 测试 #17 `equip_rules_test.gd`(33 断言)。**seam**:精确 tie-break 顺序、已穿同槽替换、 + `LIMIT_PCBANG` / `LIMIT_REAL_TIME*`、属性 4 项按名读取,留待接服务器 `char_item.cpp` 比对。 +3. ~~costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` / + `equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。~~ **增量 96 已完成**: + `inventory_ui.gd._to_wire()` 按 `GameType.h` 分流——`c_Equipment_Start+0..10`(BODY..SHIELD) + 走 `WINDOW_EQUIPMENT(2)` + 零基 wear cell;`ENABLE_NEW_EQUIPMENT_SYSTEM` 的 + `c_Costume_Slot_Start(90+19)` / `c_Equipment_Ring1(90+21)` / `c_Equipment_Belt(90+23)` + 在客户端无独立窗,仍用 `WINDOW_INVENTORY(1)` 的全局 cell 109..113。扩展面板 + `_add_extended_cells` 由「11..34 连续 24 格」收窄为「19..23 这 5 个真实新装备位 + 16 格腰带背包」。 + 新增常量 `WEAR_COSTUME_BODY/HAIR`、`WEAR_RING1/RING2`、`WEAR_BELT`、`NEW_EQUIP_WEAR_START`、 + `WINDOW_EQUIPMENT` 全部 1:1 自 `GameType.h`。`inventory_test.gd` 增 7 条 wire 断言 + (`_to_wire(90)→[2,0]`、`(100)→[2,10]`、`(109..113)→[1,109..113]`)。 +4. ~~部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按 + `REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现。~~ + **增量 98 已完成**:穿头盔覆盖发型槽、costume(parts[ARMOR]) 压过本体 armor 在增量 96/97 + 已由 `equip_model.gd` 的 `_eff()` + `_apply_head_or_hair()` 落地;本次补齐**变身 / 动物时装 / + 婚纱的整件遮挡**。新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 + `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`,`SetWeapon`/ + `SetParts` 提前 return)、`ANIMAL_WEAR_SHAPES=[100,101,102,103]`(`__IsShapeAnimalWear`(:2834) + → `SetWeapon`(:2747)/`SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、 + `BOUQUET_VNUMS=[50201..50204]`(`c_iBouquets`,`__IsChangableWeapon`(:901) 除捧花外 false)。 + `equip_model.gd.refresh()`:刷新序改为对齐 `ChangeArmor`(`SetRace→SetArmor→SetHair→SetWeapon`) + = 身体 → 头发 → 武器 → 盾;解出身体 shape(`armor_shape_of` = `__ArmorVnumToShape` 的 + `values[3]`/`=vnum`)后过 `PartHiding.hair_hidden` / `weapon_hidden` / `effective_weapon`: + 动物·怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`;婚纱非捧花隐藏武器 + 盾、头发保留。 + 新增 `_is_poly()` **seam**(40250 实体表暂无变身字段 `poly_race`/`polymorph`,恒 `false`; + 等 `GC_CHARACTER_UPDATE` 带变身 race)。canonical 测试 #18 `part_hiding_test.gd`(45 断言) + + `equip_model_test.gd` 补 7 段遮挡断言。 +5. ~~远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把 + `armor/weapon/hair` 三个字段接到换模。~~ **增量 99 首版完成**:参考端 + `Packet.h:1282-1289` 定义 `CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 / HAIR=3 / NUM=4`, + `game_scene.gd._make_entity_model()` 对 `ch_type==0` 创建 `RemotePlayerView`,复用 + `PlayerView.build()` 的 race / GR2 / MSA 装载;`ui/equip_model.gd.setup_remote()` 进入远端模式, + 只读取 `awPart[4]`,并提供 `set_armor/set_weapon/set_head/set_hair`,不访问本地 + `get_equipment()`。`net_world._apply_field_updates()` 现在按四字段调用 + `set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])`,对齐 + `NetworkActorManager.cpp:473` 的更新序;`remote_player_test.gd` 用真实 warrior 资源覆盖建模、换装和 + 字段序。**仍为 seam**:没有对应 `poly_race` 的实体字段,`_is_poly()` 仍恒 false;所有职业 / race + 资源、精确体型 / 变身、远端真实多角色换装和真服视觉尚未验收。 --- @@ -1590,23 +2901,34 @@ slot type 映射到 `EWindows`:只有 `SLOT_TYPE_INVENTORY→INVENTORY`、 音效固定:拿起 `sound/ui/pick.wav`、放下 `sound/ui/drop.wav`、金钱 `sound/ui/money.wav`、 非法 `sound/ui/loginfail.wav`。 -**当前**:各窗口各自实现点击/拖放,没有全局持有态。 +**当前(增量 57/60)**:`mouse_controller.gd` 提供全局持有态和鼠标跟随预览;`inventory_ui.gd` 注册 +背包 / 装备 / 腰带格,`quickbar.gd` 注册快捷栏格,safebox 注册 5×9 分页格,exchange 注册 12 个 +交易落点,shop 注册出售落点,mall 注册商城分页格,private_shop 的 40 格已接入全局投放。背包物品 +可按明确目标格调用现有 checkin / checkout / move / exchange / sell wire API;payload 还保留物品实例的 +`anti_flags`、`flags`、`sockets`、`attrs`,普通拖放保留整组数量,Shift+左键堆叠物品可先选择附着数量, +金币以 `ITEM_MONEY=-1` 独立态附着。无效区域、右键、ESC、失焦会取消且不发包。交易已接入服务端 +accept 镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限和已放金币不可编辑;NPC 商店已接入 +`ANTIFLAG_SELL`、`IsValuableItem` / `Is1GoldItem` 售价确认;私人商店已接入 `ANTIFLAG_GIVE/MYSHOP`。 +dragon_soul 仍按当前 40250 范围禁用;完整交易事务和真实 `.sub` 光标资源尚未完成。 -**修改** +**已完成 / 剩余** -1. 新建 `POC/project/ui/mouse_controller.gd`(autoload 单例),API 逐个对齐上表 - (名字保持 `attach_object / attach_money / deattach_object / is_attached / - get_attached_type / get_attached_slot_number / get_attached_item_index / - get_attached_item_count / is_attached_money`)。 -2. `ESlotType` / `EWindows` / `SlotTypeToInvenType` 三张表移植到 - `POC/project/ui/game_type.gd`,值与 `GameType.h` 完全一致。 -3. 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot / - dragon_soul)统一改为 `select_empty_slot` / `select_item_slot` 两个入口, - 分支照抄各自的 `ui*.py`。 -4. 鼠标跟随图标:`CanvasLayer` 上画 `AttachedIconHandle` 等价物,位置跟随鼠标, - 数量文字用 `countNumberLine`(`mousemodule.py:87`)。 -5. 拖动取消:右键或落到无效目标 → `deattach_object()`,不发包(参考无回滚包)。 -6. 音效按上表四个固定路径。 +1. ✅ 已完成全局 item attach / payload / cancel / drop 控制器、鼠标跟随图标和数量文字;payload + 保留 `window/cell/vnum/count/source` 以及实例 `anti_flags/flags/sockets/attrs`,并增加 + `ITEM_MONEY=-1` 的独立金币持有态。 +2. 已完成 inventory → inventory / equipment / belt 以及 inventory → quickbar 的首版投放; + 装备目标使用参考的 `window=2, wear cell` 坐标。 +3. 已完成 `UiManager.blocks_game_input()` 的键盘焦点屏蔽和失焦清理;光标管理器提供 + `NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM` 状态,暂时用系统光标作为无 `.sub` + 资源时的可运行 fallback。 +4. 已完成 safebox / mall / shop / exchange / private_shop 的首版明确落点:仓库支持 5×9 页签、仓库内 + move 和背包↔仓库;商城支持分页和商城→背包;交易支持背包→12 个指定交易格;商店支持背包拖到 + 出售目标;私人商店支持背包→40 格价格弹窗。`complex_item_drop_test.gd` 覆盖这些投放入口。 +5. ✅ `shop_ui.gd` 已增加 BUY/SELL toggle、出售目标可见性和 BUY/SELL 光标状态;购买模式不会接收 + 出售投放,出售模式才按数量调用 `shop_sell`。 +6. ✅ 已完成 stack split、pick/drop/money/loginfail 音效、交易的 accept / 距离 / anti-give / 金币边界、 + NPC 商店的 anti-sell / `IsValuableItem` / `Is1GoldItem` 售价确认,以及私人商店的 anti-give / anti-myshop。 + 剩余是完整交易事务、全部真实 `.sub` 光标资源和 dragon_soul(当前范围禁用)。 --- @@ -1617,19 +2939,33 @@ slot type 映射到 `EWindows`:只有 `SLOT_TYPE_INVENTORY→INVENTORY`、 SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem / SetSkill / SetQuickSlotItem`。 +**当前(增量 58/60)**:新增 `project/ui/item_tooltip.gd`,由 `item_proto` 的 236 字节记录提供 +`limits[2]`、`applies[3]`、`values[6]`、`sockets[3]`、`refined_vnum`、`refine_set`、 +`alter_to_magic_pct`、`specular`、`gain_socket_pct`;由物品实例提供真实 `sockets[3]` / `attrs[7]` +和数量;由 `locale/locale//itemdesc.txt` 提供描述。背包、装备查看、NPC 商店、仓库、商城、 +交易、私人商店都已接入同一个提示构造器,当前按参考的武器 / 防具数值、限制、基础属性、实例附加 +属性、魂石槽和精炼关联生成原生 tooltip。 + **修改** -1. **不要**重写 tooltip 逻辑。按 §8 的窗口迁移样板(`char_status_ui.gd` 的做法) - 直接移植 `uitooltip.py` 的 `ItemToolTip` 类: +1. ✅ 已完成 tooltip 的数据路径和基础内容生成;没有把 Python UI 类机械搬入 Godot, + `item_tooltip.gd` 保留 `uitooltip.py` 的数据顺序和关键公式,并使用 Godot 原生 tooltip 承担 + 定位 / 换行。 - 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary / GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等); - - 属性行(attribute)、魔石孔(socket,`METIN_SOCKET_COUNT = 6`)、 + - 属性行(attribute)、魔石孔(socket,`ITEM_SOCKET_MAX_NUM = 3`;龙魂石的 subtype 槽位数另为 6)、 `ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、 精炼等级、使用时限(`GetItemDuration`)逐条对齐。 -2. `proto` 层补齐 tooltip 需要的全部字段(`ItemProto.value[6] / limit[2] / apply[3] / - flags / anti_flags / wear_flags / socket / refine` 等),当前只导出了少量字段。 -3. 装备对比(穿戴中 vs 鼠标所指)按 `uitooltip.py` 的 `__AppendEquipmentCompare` 等价实现 - **[待读参考:`uitooltip.py` 中比较相关的方法名以本仓文件为准]**。 +2. ✅ `proto` 层已补齐固定记录字段(`value[6] / limit[2] / apply[3] / flags / anti_flags / + wear_flags / socket[3] / refine` 等),`proto_item_layout_test` 锁定 236 字节偏移。 +3. 装备对比:已仔细搜索 40250 `ClientVS22` 与本仓 `assets/root/uitooltip.py`,未发现 + `__AppendEquipmentCompare` 或等价的客户端 tooltip 比较方法;不能把它写成参考端已有语义。 + 当前应作为本工程新增功能单独定义规则,暂不宣称 1:1。 +4. 增量 60 已加入鱼竿 / 镐等级经验、鱼长度、彩票、混合物品、唯一物品剩余时间和龙魂阶段 / + 强化的基础分支;增量 62 又按技能书、遗忘书、变身道具的 `sockets[0]` 语义生成技能名 / + 怪物名标题,并按限制类型 7/8/9 生成实时剩余时间;对应离线断言已通过。剩余为 CJK 文案 / + 字体切换、其他特殊分支、完整计时来源语义。装备对比在参考源码中没有找到对应方法,若要保留 + 应另立产品需求,不能列为 1:1 必做项。 --- @@ -1673,19 +3009,26 @@ SetSkill / SetQuickSlotItem`。 - 关闭:`Close()` = `net.SendChatPacket("/safebox_close")`(`uisafebox.py:400`)。 - 密码 / 改密:`dlgChangePassword`(`uisafebox.py`),错误由 `GC_SAFEBOX_WRONG_PASSWORD` 驱动。 -**当前**:`POC/project/ui/safebox_ui.gd:7` 是列表 + 自动找空位。 +**当前(增量 56)**:`safebox_ui.gd` 已增加参考端 5×9(45 格)分页网格,按 `size` 显示最多实际页数, +`local + page*45` 映射全局槽位;背包 / 装备拖入指定仓库格走 `safebox_checkin`,仓库物品拖到另一 +空格走 `safebox_move`,仓库物品可拖回背包指定格走 `safebox_checkout`。旧 `deposit()` 仍保留给右键 +兼容入口,因此自动找空位不是新的鼠标落点语义。 -**修改** +**已完成 / 剩余** -1. `safebox_ui.gd` 重写为 5×9 网格 + 最多 3 页的页签,`__LocalPosToGlobalPos` 照抄。 -2. 存取一律**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)`, - 删除「找下一个空位」的逻辑。 -3. 补 `CG_SAFEBOX_ITEM_MOVE(77)` 的发送(仓库内移动)。 -4. 金钱走 `/safebox_money` 类聊天命令**[待读参考:`netSendSafeboxSaveMoneyPacket` 在 - `PythonNetworkStreamModule.cpp` 中的实现,确认是包还是聊天命令]**; - 关闭固定用 `client.say(0, "/safebox_close")`。 -5. 接入 `mouse_controller`(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。 -6. 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的重试提示接上。 +1. ✅ `safebox_ui.gd` 已实现 5×9 网格 + 按 `size` 分页,`local + page*45` 映射全局槽位。 +2. ✅ 鼠标存取使用**指定目标格**:`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)` / + `safebox_checkout(safe_pos, inven_window, inven_pos)`;旧 `deposit()` 仅作为右键兼容入口保留自动 + 找空位,不参与鼠标落点。 +3. ✅ `M2Client::safebox_move(from_cell, to_cell, count)` 已存在并由仓库内物品态调用,覆盖 + `CG_SAFEBOX_ITEM_MOVE(77)`。 +4. 金钱不能按当前参考端实现成 `/safebox_money`:`PythonNetworkStreamPhaseGameItem.cpp:14-27` + 的 `SendSafeBoxMoneyPacket()` 主动 `return false`,包发送代码被注释;本次不伪造聊天命令。 + 关闭固定用 `client.say(0, "/safebox_close")`。若产品需要仓库存钱,必须另立服务端 / 客户端协议。 +5. ✅ 已接入 `mouse_controller`:背包 / 装备→仓库、仓库→仓库、仓库→背包三类首版落点均按 + 空格 / 已有物品分支处理。 +6. ✅ 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的提示已接上;全局 `pick/drop/money/loginfail` 音效和 + BUY/SELL cursor 联动已在增量 57 接入。仓库金钱存取保持未实现,原因见第 4 项,不以假命令冒充完成。 --- @@ -1714,13 +3057,18 @@ SetSkill / SetQuickSlotItem`。 `abs(dx) > 1000 or abs(dy) > 1000` → `net.SendExchangeExitPacket()`。 - 标题栏关闭按钮直接绑 `net.SendExchangeExitPacket`。 -**当前**:两列文字列表,无 accept / 距离 / anti-flag 规则。 +**当前(增量 57/60)**:`exchange_ui.gd` 已接入 12 个指定交易格、服务端 accept 位镜像、Accept 按钮 +禁用 / 恢复、`USE_EXCHANGE_LIMIT_RANGE=1000` 距离退出、`ANTIFLAG_GIVE` 拒绝、7 位金币上限、已放 +金币不可编辑,并支持背包 / 装备和 `ITEM_MONEY=-1` 拖放。增量 60 已让 m2dev / Classic 的 +`GC_EXCHANGE` 解析、`EntityStore::ExchangeSlot` 和 `M2Client.get_exchange()` 保留 3 个 socket +与 7 个 attribute;真实双账号事务仍未完成。 **修改** 1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 + `AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。 -2. `EntityStore` 的交易状态补齐 `TExchangeData` 全字段(含 socket / attribute 二维数组)。 +2. ✅ `EntityStore` 的交易状态已补齐每个 `ExchangeSlot` 的 3 个 socket / 7 个 attribute,并由 + m2dev / Classic 两条收包路径写入、由 `M2Client.get_exchange()` 暴露给 tooltip。 3. accept 语义:**只跟随服务端**。发送 `CG_EXCHANGE(ACCEPT)` 后立刻禁用按钮; 收到 `GC_EXCHANGE` 且 `accept == 0` 时重新启用——不写本地「修改则取消」逻辑。 4. `OnUpdate` 的 1000 距离退出、`ANTIFLAG_GIVE` 拒绝、金钱 7 位上限与 @@ -1758,17 +3106,26 @@ SetSkill / SetQuickSlotItem`。 注意**卖价 = 单价 × 数量 ÷ 5**(整数除),`Is1GoldItem` 走另一条公式。 - 关闭:`OnPressEscapeKey`(`:250`)→ `self.Close()` 并 `return True`。 -**当前**:基础列表 + 直接买。 +**当前(增量 59)**:`shop_ui.gd` 已按 `shopdialog.py` 改为 5×8、40 格货位;空位也保留为不可用 +按钮,已放置货物显示名称 / 堆叠数并接入统一 tooltip,货架切换仍按 +`tabIdx * SHOP_SLOT_COUNT + slot` 计算全局槽号。BUY 模式点击货位进入购买确认,确认后固定调用 +`shop_buy(global_pos, 1)`;SELL 模式仍隐藏 / 拒绝出售目标以外的购买动作。出售模式显示投放区并切换 +`CursorManager.BUY/SELL`,背包物品拖到目标会按当前数量调用 `shop_sell`。实例 / 原型 +`ANTIFLAG_SELL`、`IsValuableItem`、普通 / `Is1GoldItem` 整数售价公式和 UI 音效已完成。 -**修改** +**已完成 / 剩余** -1. `shop_ui.gd` 用 `shopdialog.py` uiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。 -2. 实现 BUY / SELL 光标模式:`app.SetCursor` 的等价见 §8.5 的光标管理器。 -3. 卖出流程按上面的 8 步照抄,含 `ANTIFLAG_SELL` 弹窗、`IsValuableItem` 的确认框、 - `÷5` 价格公式和 `Is1GoldItem` 分支。 -4. 买入:点格子(光标为 BUY 时)→ `net.SendShopBuyPacket(GetIndexFromSlotPos(pos))`。 -5. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。 -6. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。 +1. ✅ 已增加 `shop_ui.gd` 的“拖动物品到此出售”目标,按当前数量调用 `shop_sell`。 +2. ✅ 已增加 BUY / SELL toggle、模式切换时的出售目标显隐和 `CursorManager.BUY/SELL` 状态;买入 + 按钮在 SELL 模式下先切回 BUY,不误发购买包。 +3. ✅ `shop_ui.gd` 已提供 5×8 货位网格、多货架全局槽号、空位禁用、tooltip 和 BUY 点击确认; + 确认购买固定发送数量 1,与参考 `SendShopBuyPacket` 的包体语义一致。当前仍使用 Godot 控件, + 未加载真实 `.sub` 槽位 / 按钮贴图与图标。 +4. ✅ 卖出流程已补齐:实例 / 原型 `ANTIFLAG_SELL` 拦截、`IsValuableItem` 首次确认、普通与 + `Is1GoldItem` 价格公式(均按整数除 5)和 `shop_sell(slot, count)`;取消或非法投放播放失败音效。 +5. ✅ 买入:点非空格子 → 购买确认 → `shop_buy(GetIndexFromSlotPos(pos), 1)`;空格子不发送。 +6. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。 +7. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。 --- @@ -1779,12 +3136,14 @@ SetSkill / SetQuickSlotItem`。 本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 + `display_pos` / 撤下 / 开设 / 撤收)与参考一致。 -**剩余差距与修改** +**当前 / 剩余差距** -1. 拖放改走 §4.2 的全局 `mouse_controller`,而不是当前的「点选候选面板」。 -2. 开店前检查:参考在 `uiprivateshopbuilder.py` 里检查 - `item.IsAntiFlag(item.ANTIFLAG_MYSHOP)`(`ITEM_ANTIFLAG_MYSHOP = 1<<16`, - `REF/GameLib/ItemData.h:204`)——当前缺,补上。 +1. ✅ 拖放已接入 §4.2 的全局 `mouse_controller`;40 个货位注册为落点,背包 payload 会弹价格窗并 + 写入 `itemStock[display_pos]`,保留原有点选候选面板作为触屏 / 无鼠标 fallback。 +2. ✅ 开店前检查:已按参考在 `uiprivateshopbuilder.py` 的物品投放路径检查 + `ITEM_ANTIFLAG_GIVE = 1<<13` 与 `item.IsAntiFlag(item.ANTIFLAG_MYSHOP)`(后者 + `ITEM_ANTIFLAG_MYSHOP = 1<<16`,`REF/GameLib/ItemData.h:204`);实例字段和原型字段取并集, + 拦截时不发开店包。 3. 其他玩家查看:`GC_SHOP` 的 `SHOP_SUBHEADER_GC_START_EX` 已实现;招牌走 `GC_SHOP_SIGN`(已实现),但需按 §8.2 换成真 `.sub` 资源渲染。 4. 撤下回滚:参考端**没有**本地回滚——撤下即发包,状态由 `GC_SHOP` 回包驱动。 @@ -1836,29 +3195,21 @@ SetSkill / SetQuickSlotItem`。 按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage`(`game.py:506`)。 F1–F4 固定映射到 `__PressQuickSlot(4..7)`(`game.py:318`)。 -**当前**:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、 -客户端显示 32 格(4 页 × 8 格)。 +**当前(增量 53)**:`quickbar.gd` 已显示参考端的 4 页 × 8 格;服务器状态仍保留 36 槽,末 4 槽 +不映射到本地显示。`M2Client.entity_dict()` / `quickslot_add()` 与 UI 使用同一套类型:1 道具、 +2 技能、3 表情;4(商店)恢复时不执行。技能走 `use_skill()` + `cast_skill()`,表情走 +`send_emoticon()`。 -**修改** +**已完成(增量 53)** -1. 数字键改为 **1–4 → 快捷栏 0–3**(`__PressNumKey` 在 `game.py:431`: - 非 Ctrl 时 `if 1 <= num <= 4: self.pressNumber(num-1)`; - **Ctrl + 1..9 是表情**:`chrmgr.SetEmoticon(-1, num-1)` + `net.SendEmoticon(num-1)`)。 - 当前的「数字键 1–9 全给快捷栏」要改掉。 -2. LSHIFT 按下 → `pressNumber = __SelectQuickPage`;松开 → `pressNumber = __PressQuickSlot` - (`game.py:370` / `:408`)。 -3. **先修类型编号**:`EntityStore` / `M2Client` / `quickslot_ui` 里的快捷栏 type 一律改用 - §4.2 移植的 `ESlotType`(`SLOT_TYPE_INVENTORY=1 / SKILL=2 / EMOTION=3`), - 删掉 `m2_client.cpp:878` 的 `3 command / 4 emotion` 注释与对应分支; - `quickslot_add()` 的入参校验从 `1..4` 改为「只接受 1 / 2 / 3」。 - 这是**数据错误**,必须先于 UI 工作修复。 -4. 快捷栏类型只保留 `INVENTORY / SKILL / EMOTION` 三种,`SKILL` 必须走 §3.8 的 - `ClickSkillSlot` 完整链,不能直接发包;收到其它 `ESlotType` 一律不执行(与参考的 - switch 落空行为一致)。 -5. 边界常量按 §3.9 的 `limits.h`;`MoveQuickSlot` 改为 swap。 -6. 物品拖入快捷栏:走 §4.2 的 `mouse_controller`,`AttachObject` 的 - `SLOT_TYPE_QUICK_SLOT` 分支会递归解析真实类型(`mousemodule.py:218`)。 -7. 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。 +1. `quickbar.gd` 已改为 4 页 × 8 格本地显示,保留服务器 36 槽状态;数字 1–4 激活 0–3,F1–F4 + 激活 4–7,LSHIFT 按住时数字 1–4 选择页。 +2. `M2Client` / `quickbar.gd` 已统一 `INVENTORY=1 / SKILL=2 / EMOTION=3`;`quickslot_add()` 只 + 接受 1–3;技能保留完整 `use_skill()` + `cast_skill()` 链;4(商店)不执行。 +3. Ctrl/Command+1–9 已调用 `send_emoticon()`;12 个迁移资产表情 token 和本地特效播放已接通。 +4. 技能拖放、清除、同页 swap 和服务器回包恢复已回归;普通物品拖入快捷栏仍属于 §4.2 的未完成项。 +5. 冷却仍由本地技能表预测并接受 `skill_cooldown_end` 解锁;死亡 / 变身 / 换武器后的完整刷新仍需 + 真实回包验收。 --- @@ -1906,8 +3257,11 @@ DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。 ### 5.1 Quest EventManager 只支持少量标签 +**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 95 —— EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 + SEventSet 状态字段名 1:1;逐帧 `UpdateEventSet` 时间推进(`lLastDelayTime` 递减 / `isWaitFlag` 暂停 / `isConfirmWait` 倒计时刷新)、`Skip`/`EndEventProcess`、`iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)仍为 seam)** + **参考**:标签表在 `CPythonEventManager::CPythonEventManager()` -(`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 38 个**: +(`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 42 个**(`EEventType` 枚举 42 项, +`CLEAR` 与 `CLEAR_TEXT` 是两个独立枚举但都走 `ClearLine`): ``` LETTER COLOR COLOR256 DELAY ENTER WAIT CLEAR QUESTION NEXT DONE @@ -1967,20 +3321,66 @@ INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM `isWaitFlag` 为真时整体暂停;`isConfirmWait` 时每帧刷新倒计时文本 `m_strLeftTimeString`(默认 `"남은 시간 : %d초"`,可由 `SetLeftTimeString` 改)。 -**当前**:`POC/project/ui/quest_dialog.gd:102` 只支持 8 个标签。 +**当前(增量 95)**:`POC/project/ui/quest_dialog.gd` 已按 `CPythonEventManager` 逐条重写脚本解析: + +- `EVENT_TYPE_MAP`(42 条,键即 tag)+ `enum { EVT_* }`(枚举顺序照抄 `PythonEventManager.h:35` + `EEventType`)+ `_get_script_event_index()`(`GetScriptEventIndex` `:1093`——前导 `/` 剥离为 + `EVENT_POSITION_END`,未知命令返回 `ok=false`、跳过、不进 switch)。 +- `class EventSet`(`SEventSet` `PythonEventManager.h:98`——字段名 1:1:`ix/iy/iWidth/iyLocal/ + isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/ + DiffuseColor/lWaitingTime/iRestrictedCharacterCount/iVisibleStartLine/iVisibleLineCount/ + iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ `init_event_set()`(`__InitEventSet` `:72` + 的常量:`c_lNormal_Waiting_Time=10`、`iRestrictedCharacterCount=30`、`c_fLine_Temp=16`、 + `BOX_VISIBLE_LINE_COUNT=5`、颜色 `(1,1,1,1)`)、`insert_line()`(`__InsertLine` `:976`:推行 + + `iyLocal += 16`)、`clear_line()`(`ClearLine` `:953`)、`body_text()`。 +- `_process_event_set(es, cmd)`——上表 `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷, + 含 `DELAY`/`COLOR`/`COLOR256` 的 `EVENT_POSITION_START` vs `END` 分支、`LETTER`/`ITEM`/`MOB` + 的 `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT`/`DONE`/`INSERT_IMAGE` + 的 `iAdjustLine += 2`、`WAIT` 的 `iyLocal=0; isLock=true`、`CONFIRM_WAIT` 的 + `isConfirmWait=true` + `insert_line(center)`、`END_CONFIRM_WAIT` 清 `isConfirmWait`、 + `FADE_*`/`WHITE_*` 的 `isWaitFlag=true`、`SETMSGPOS`/`ADJMSGPOS` 的**空实现**。 +- `_tokenize()` 把混排文本 / `[tag ...]` 拆成命令流,裸网络文本折成合成 `LETTER`(`is_raw=true`, + 跳过 30 字断行——reference 脚本只有显式 `[LETTER]`,逐字符断裸文本会破坏服务器句子)。 +- arg 提取 `_tag_kv()`(`key(val)` / `key("val")` / `key;val`)+ `_arg_s/_arg_i/_arg_f` + (keyword 优先,位置回退,兼容旧测试的 `value;255 value;128` 与 `value(1) value(2)` 写法)。 +- `parse_script()` 返回字典在原有键之外新增:`insert_images / dungeon_result / adjust_line / + waiting_time / is_locked / wait_flag / answer_count / cinema_file / end_confirm`(供回归断言)。 +- 首命令 `RUN_CINEMA` → 记 `cinema_file` 并发 `cinema_requested` 信号(`.msc` 文件加载是 seam)。 +- 回归 `POC/project/quest_event_test.gd`(新增,规范测试 #16,23 组断言:EventTypeMap 完整性 / + `/` 前缀 / DELAY START·END / 断行 / ENTER·CLEAR·CLEAR_TEXT / QUESTION·nAnswer / + NEXT·DONE·adjust_line / WAIT·isLock / COLOR·COLOR256 / INPUT·SELECT_ITEM / CONFIRM_WAIT· + END_CONFIRM_WAIT / DUNGEON_RESULT 9 int / INSERT_IMAGE 两路 / IMAGE 家族 / map signal / + QUESTBUTTON·CLOSE / camera SET·BLEND·RESTORE / FADE·WHITE·wait_flag / WINDOW_SIZE / + ITEM·MOB / SETMSGPOS·ADJMSGPOS no-op / 未知 tag 跳过 / RUN_CINEMA 首命令·非首命令), + 既有 `quest_test.gd` / `select_item_ui_test.gd` 仍绿。 **修改** -1. `quest_dialog.gd` 的 `parse_script` 换成完整的 `EventTypeMap`(38 项)+ - 上表的 `ProcessEventSet` 分派,逐条实现。 -2. 引入 `EventSet` 状态:`lWaitingTime / lLastDelayTime / iCurrentLetter / - iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag / - isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor`,字段名保持一致。 -3. `SETMSGPOS` / `ADJMSGPOS` 实现为**空**(与参考一致),不要自行发挥。 -4. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮 +1. ✅ `quest_dialog.gd` 已接入常用标签的解析元数据、颜色 / 居中 / 尺寸、ITEM/MOB 占位名、 + CONFIRM_WAIT 取消入口、任务信件 / 小地图 side effect,并在图片路径可解析时创建实际图片节点; + 路径不存在时安全跳过,不伪造错误资源。 +2. ~~剩余工作是把 `parse_script` 升级为完整的 `EventTypeMap`(38 项)+ 上表的 `ProcessEventSet` + 分派,逐条实现。~~ **增量 95 完成**:`EVENT_TYPE_MAP`(42 项)+ `_get_script_event_index` + + `_process_event_set` 已逐 case 对拷上表;裸文本折成合成 `LETTER`。 +3. ~~引入 `EventSet` 状态~~ **增量 95 完成**:`class EventSet` 字段名与 `SEventSet` 1:1 + (`lWaitingTime / lLastDelayTime / iCurrentLetter / iRestrictedCharacterCount / iAdjustLine / + iyLocal / isLock / isWaitFlag / isConfirmWait / iConfirmEndTime / isTextCenterMode / + CurrentColor` 等),`init_event_set()` 复位。**seam**:一次性把 token 跑完,`isLock` / + `isWaitFlag` / `lLastDelayTime` 仅作状态保留并透出,不做逐帧 `UpdateEventSet` 时间推进。 +4. ✅ `SETMSGPOS` / `ADJMSGPOS` 已实现为**空**(`_process_event_set` 的 `EVT_SET_MESSAGE_POSITION, + EVT_ADJUST_MESSAGE_POSITION: pass`,注释引 `PythonEventManager.cpp:562/566`)。 +5. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮 按 `PythonEventManager.cpp` 的对应函数移植 (`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。 -5. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。 + → **增量 95 落地**:`_make_question`(`nAnswer` = arg 数、逐 arg 入 `choices`)、`NEXT/DONE → + has_next + iAdjustLine += 2`、`CONFIRM_WAIT` 的 CANCEL 由 `_fill_buttons(confirm_wait)` 出、 + `insert_line`/`clear_line` 已按 `:976`/`:953` 移植。**未接**:`MakeNextButton` 的 `NEXT` vs + `DONE` 按钮文案区分(现均为「继续」)、`DoneEvent()` 回调。 +6. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。 + → **增量 95 落地一半**:`_process_event_set` 已解析 `SET_CAMERA`(`_camera_setting` 九参 + `x/y/z/up/view/cross/distance/rot/pitch`,`GetCameraSettingFromArgList` `:48`)、`BLEND_CAMERA` + (+`blendtime`)、`RESTORE_CAMERA` 并经 `camera_event_requested` 信号发出。**未接**:信号消费端 + 与 §7.6 的相机模式对接(仍为 game_scene 层 TODO)。 --- @@ -1990,13 +3390,34 @@ INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM 任务条目由 `EVENT_TYPE_QUEST_BUTTON` 的 `BINARY_RecvQuest(idx, name, icon_type, icon_name)` (§5.1)与 `GC_QUEST_INFO` 共同驱动;关闭走 `BINARY_ClearQuest(idx)`。 -**修改** +**当前(增量 101)**:`quest_log.gd` 已按参考端的 `QUEST_MAX_NUM=5` 保持任务快照,固定渲染 +5 行;任务数超过 5 时显示滚动条,并将滚动位置离散映射到 `questShowingStartIndex`。每行显示 +默认 `season1/icon/scroll_open.tga`(无 icon 路径)或服务端 icon、标题、counter 和 clock;clock +按参考的分钟 / 秒格式显示,并在窗口打开时按秒递减。点击任务行发送 +`script_button(-2147483648 + questIndex)`,并发出 `quest_selected`。另有独立 QuestButton 条带, +接收 `QUESTBUTTON/QUESTBUTTON_CLOSE`、点击发送 `script_button(index)`,并在对话打开时隐藏。 +`quest_dialog.gd` 已识别常用 EventManager 标签,把颜色、居中、尺寸、ITEM/MOB、确认等待、图片资源 +和地图信号暴露给 UI。 -1. 按 §8 的窗口迁移样板移植 `uiquest.py`(QuestButton / 图标 / 倒计时 / 定位)。 -2. `quest_log.gd:8` 的纯文字列表改为读 `icon_type` + `icon_name`, - 图标资源走 `UiAssets.load_dds_image`。 -3. 世界箭头 / 边缘指示对应 `EVENT_TYPE_ADD_MAP_SIGNAL` + `CPythonMiniMap::AddSignalPoint`, - 接到已有的 minimap 实现。 +**已完成 / 剩余** + +1. ✅ 任务日志已从纯文字列表升级为 5 行窗口:标题 + 默认 / 服务端 icon + counter + 倒计时; + 资源缺失时安全回退,不伪造图标;任务数量、滚动起始索引和可见行可由回归测试检查。 +2. ✅ 已补齐参考端任务行选择:滚动条按 `questCount - QUEST_MAX_NUM` 映射起始行,点击行按 + `-2147483648 + questIndex` 进入 QuestButton 事件路径。 +3. ✅ 已移植独立 QuestButton 的接收 / 替换 / 删除 / 点击发送和对话抑制;已将 + `EVENT_TYPE_ADD_MAP_SIGNAL` / `CPythonMiniMap::AddSignalPoint`、清除和 Atlas center adjust 接到 + 现有 minimap / Atlas。**增量 108**:小地图任务信标越出半径时按 `m2dev PythonMiniMap.cpp:441-462` + 的 `atan2 + radius*cos/sin` 夹到圆周并改画指向目标的方向箭头(`minimap.signal_marker_geometry` + + `_draw_signal_arrow`,留边常量 `SIGNAL_RIM_INSET=9.0` 对齐 `c_fMiniMapWindowRadius` 的 `- 9.0f`), + 即世界箭头 / 屏幕边缘指示首版。**增量 109**:quest 信件按钮条按 `assets/root/interfacemodule.py:1357-1386` + 的 `__ArrangeQuestButton` 1:1 多列定位——`yPos = 170*H//600`、`yCount = (H-330)//63`、第 i 个 + `= (xPos + (i//yCount)*100, yPos + (i%yCount)*63)`,`xPos` 组队窗显示时由 20 移到 130;`_quest_button_order` + 复刻 `questButtonList.insert(0, btn)`(最新在前、同 index 提前),`_button_list` 改绝对定位 Control + (不再 VBox 单列 sort by index),组队窗可见性经 `set_party_probe` 在 `_process` 轮询。剩余是 Atlas + 同一信标夹边、任务日志窗体 `_list` 自身多列 / 分页和完整 `.sub` 图标 / Flash 视觉。 +4. EventManager 的 38 项逐事件推进、立绘、淡入淡出、相机、电影和真实 `.sub` 资源仍按 §5.1 + 单独推进,不能以本批次的标签识别当作完整迁移。 --- @@ -2023,21 +3444,36 @@ INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM - 观战:`CPythonPlayer::IsObserverMode()`——它在 `__CanUseSkill()`(`PythonPlayerSkill.cpp:445`) 与 `IsAttackableInstance()`(`InstanceBase.cpp:2151`)里都是**第一道门**。 -**修改** +**当前状态:🟡 部分完成(2026-09-03 / 增量 107)** -1. `player_controller.gd` 加 `is_fishing_mode / is_observer_mode / is_hand_mode` 三个状态位, - 并把上面四处门禁接上。 -2. 钓鱼:攻击键在钓鱼模式改发 `CG_FISHING(irot)`;方向键取消钓鱼; - `OnFishingWrongPlace` 走 `localeInfo` 文案。 -3. 坐骑:`GC_MOUNT` / `mount_vnum` 已入 `EntityStore`;补模型挂载与 - `MODE_HORSE*` 动作模式(§6.4)、`CanAttackHorseLevel` 限制。 -4. 副本:`DungeonBlock` 已接入静态地图(§7.1 保持);结算窗按 `EVENT_TYPE_DUNGEON_RESULT` - 的 9 个参数实现。 +增量 102 已完成钓鱼模式攻击键发送 `CG_FISHING`、方向键和地面点击取消、500ms 取消节流、 +特殊技能 123 快捷栏入口,以及 `GC_FISHING` 五类事件的客户端意图状态维护;增量 103 又接入 +`GetFishingRot` 等价的真实 `ATTRIBUTE_WATER` 水面扫描和选中 heading;增量 104 又按 +`RecvFishing` 分支接入 wrong-place / failure / unknown / notify / success 的 locale 消息与 +item proto 类型判定;增量 105 接入普通 / 新坐骑的攻击等级门和自动攻击的 `mount_vnum` 判定; +增量 106 接入观战者小地图 ADD / MOVE / REMOVE 一秒插值和 `world_reset` 清理;增量 107 按 +`GC_DUNGEON DESTINATION_POSITION` 接入副本目的地状态、全局厘米坐标转换、目标到达判据和 +HUD 距离 / 朝向提示,并在 `world_reset` 时清理。`TIME_ATTACK_START` 保持参考端空分支,不添加 +本地计时器。具体文件和测试见本文件 C.5 增量 102 / 103 / 104 / 105 / 106 / 107 记录。 + +**剩余修改** + +1. 钓鱼剩余:接入真实 `GC_ITEM_*` 物品更新 / inventory 事务并验证 item proto 与服务端库存;当前 + 消息分支已完成,水面扫描依赖已加载区块,地图流式切换后的 provider 覆盖仍需真实场景验收。 +2. 坐骑剩余:攻击等级门已由增量 105 接入;仍需补真实坐骑模型、`MODE_HORSE*` 动作 / 骑乘动画、 + 移速和真服坐骑数据验收。 +3. 副本剩余:读取真实 `.mde` / `.mdatr` collision 的 DungeonBlock,补真实入口、结算窗口和 + `EVENT_TYPE_DUNGEON_RESULT` 的 9 参数字段;目的地消费 / 到达 / HUD 罗盘首版已由增量 107 接入, + 参考端 `TIME_ATTACK_START` 本身没有本地计时逻辑。 +4. 观战剩余:小地图插值和 `world_reset` 清理已由增量 106 接入;仍需补自由视角、真实服务器 + `GC_OBSERVER_ADD/REMOVE/MOVE` 完整生命周期和换图时序验收。 --- ### 5.4 PVP、PK 和决斗没有完整规则 +**状态:🟡 部分完成(2026-09-03 / W3→M2 / 增量 94 —— `IsAttackableInstance` 1:1;增量 110 —— 名字颜色 `GetNameColorIndex` 1:1,见 §8.8 / C.5 增量 110;`ATTRIBUTE_BANPK`、PVP/GVG/DUEL key 的 packet setter 仍为 seam)** + **参考**:`CInstanceBase::IsAttackableInstance(victim)` (`REF/UserInterface/InstanceBase.cpp:2147`)是**唯一**判据,完整逻辑: @@ -2081,15 +3517,57 @@ WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。 **修改** -1. 在 `player_controller.gd`(或 `entity_rules.gd`)**逐行**实现 - `is_attackable_instance(self_vid, victim_vid)`,不做简化。 -2. 依赖判据补齐:`IsKiller()`(alignment < 0 或 PK 标记)、`IsSameEmpire()`(empire 字段, - §2.5 已补)、`IsPVPInstance()`(`GC_PVP` 关系表)、`IsConflictAlignmentInstance()` - (alignment 符号相反)、`IsSamePartyMember()`(`get_party()`)、 - `GetDuelMode()` + `__FindDUELKey`(`GC_DUEL_START` 已实现)。 +1. ~~在 `player_controller.gd`(或 `entity_rules.gd`)**逐行**实现 + `is_attackable_instance(self_vid, victim_vid)`,不做简化。~~ + **增量 94 完成**:新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`), + `is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `InstanceBase.cpp:2147` —— + Stone / PC / Enemy / Poly 四分支、决斗三态(`DUEL_CANNOTATTACK`→false、`DUEL_START`→ + `__FindDUELKey`)、`PK_MODE_GUILD` 同公会提前放行、`victim.IsKiller()` && !同队、 + `PK_MODE_PROTECT != GetPKMode()` 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队)/ + `PK_MODE_GUILD`(再加公会不同)、`IsSameEmpire` → `IsPVPInstance` / `PK_MODE_REVENGE` + && !同队 && `IsConflictAlignmentInstance`、异帝国直接 true、`victim.IsEnemy()` / + `victim.IsWoodenDoor()`。附 `alignment_grade`(:502)、`alignment_type`(:524)、 + `is_conflict_alignment_instance`(:2116)、`_is_same_empire`(:470)、`_is_pvp_instance` + (`InstanceBaseEffect.cpp:505`)、`_find_duel_key`(:495)、`_is_same_party` + (`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119)。 + `_is_pc` 依 `CActorInstance::IsPC`(`ActorInstance.cpp:282`)只看 `TYPE_PC`,变身玩家仍是 PC; + `_is_enemy` 只 `TYPE_ENEMY`;独立 `TYPE_POLY` 实例才走 `else if (IsPoly())` 分支。 + `net_play.gd` 的 `_is_attackable(e)` 改为拼 `self_e`(`_main_entity()` + `kind`)/ `victim_e` + (+ `kind`)/ `_attack_ctx()`(`get_duel` / `get_pvp_relations` / `get_guild_wars` / `get_party`), + 委托 `EntityRules.is_attackable_instance`;`_main_entity()` 为空(headless)退化到旧的 + 「PC / 怪可打」近似。自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言, + 覆盖 grade/type 边界、观战门、自打自、四类别、帝国 same/diff/0、决斗三态、PK 五模式、 + killer、同队抑制、PVP-pair、GVG-pair、木门、冲突善恶、变身)。 +2. 依赖判据补齐:`IsKiller()`(`state_flags & ADD_CHARACTER_STATE_KILLER`=1<<3 位, + classic 后端 `classic_parser.cpp` 已接;m2dev 后端为 **seam**)、`IsSameEmpire()` + (empire 字段,§2.5 已补;`IsGameMaster()` 无 GM 标记 → **seam**,恒 false)、 + `IsSamePartyMember()`(`get_party()` 的 VID 集合,已接)、`GetDuelMode()` + `__FindDUELKey` + (`get_duel().opponents`,已接)。**仍为 seam**:`__FindPVPKey`(`get_pvp_relations()` 有值则用, + 其 setter=`GC_PVP` 未接)、`__FindGVGKey`(`get_guild_wars()` 对表,公会战对局 setter 未接)、 + `IsBuilding` / `IsWall` 的 race 白名单(源码未随发布)、`_entity_kind` 无法分类 + (无 `ch_type` 且 mob_proto 查不到 race)时怪会被当成和平 PC → 不可攻击 + (生产环境 mob_proto 恒加载,仅影响裸测试夹具——增量 94 已给相关夹具补 `ch_type`)。 3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。 + **增量 110 完成名字颜色**:`project/name_color.gd` 逐行照抄 `CInstanceBase::GetNameColorIndex` + (`REF/UserInterface/InstanceBaseEffect.cpp:525`)+ `SetEmpireNameMode`(:52)+ `GetIndexedNameColor` + (:75),复用本节 `entity_rules.gd` 的 `_is_pc / _is_enemy / _is_poly / _is_killer / _is_same_empire / + _find_duel_key / _pair_in` 与 `DUEL_*` 常量;ctx 与 `_attack_ctx()` 同构。`net_world.gd` 接进头顶 + `Label3D.modulate`。**增量 111 完成称号色**:`project/text_tail.gd` 照抄 `GetTitleColor` + (:609)+ `RefreshTextTail`(:664)+ `UpdateTextTailLevel`(:654),复用 `entity_rules.alignment_grade`; + `net_world.gd` 接进 `TitleTag` / `LevelTag` / `GuildTag` 子标签。**增量 112 完成聊天 / 信息尾标**: + `project/chat_tail.gd` 照抄 `CPythonTextTail::RegisterChatTail` / `RegisterInfoTail` + (`PythonTextTail.cpp:581/:624`)+ `TextTail_GetLivingTime`(:28)+ `RecvChatPacket` 派发 + (`PythonNetworkStreamPhaseGame.cpp:1301/:1321/:1372`);`net_world.gd` 每 vid 一条 `"bubble"` + `Label3D`、白 / 淡红按 chat/info、5s 硬删。**增量 113 完成名字「是否显示」**: + `project/name_show.gd` 照抄 `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag` + (`PythonSystem.cpp:327/:334`)+ `game.py::__IsShowName`(:1149)+ `CPythonTextTail::ShowAllTextTail` + 的 `fDistanceFromPlayer < 3500`(`PythonTextTail.cpp:422`)+ `ShowCharacterTextTail` 资格判定 + (:440)+ `UpdateAllTextTail` 的 chat `bNameFlag` 强显(:74);`net_world._name_shown()` 组装 + `_always_show_name`(uigameoption radio)/ `_alt_held`(LALT)/ `_target_vid` + (`net_play.target_changed`,即 `targetBoard.GetTargetVID()`)/ chat `bNameFlag` 四路。 + **目标框已让选中目标强显名字**;可攻击光标仍 **未接**;悬停 `chr.Pick()` 强显 = seam ⑩。 4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性 - `ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]**。 + `ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]** —— **未接**。 --- @@ -2142,7 +3620,7 @@ SetWindStrength(float) --- -### 6.3 EffectLib 只有粒子近似 +### 6.3 EffectLib 的 `.mde` 已接入,仍是渲染子集 **参考**:`CEffectData`(`REF/EffectLib/EffectData.h:9`)一个 `.mse` 由**三类元素 + 声音**组成: ``` @@ -2165,19 +3643,34 @@ float m_fBoundingSphereRadius; D3DXVECTOR3 m_v3BoundingSpherePosition; `GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount`, 以及一组时间曲线 `GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)`。 -**当前**:`POC/project/fx/effect_player.gd:8` 只映射 Particle → `GPUParticles3D`; -`:134` 用程序化渐变代替 `.dds`;`.mde` 用 box 代替。 +**当前(增量 100)**:Particle 已映射到 `GPUParticles3D`,首个 `.dds` 通过 `UiAssets` + 原生 +DDS 解码加载;`POC/project/fx/mde.gd` 按 `EffectMesh.cpp` 读取 `EffectData` v1 / `MDEData002` +v2,展开索引三角形、翻转 UV、厘米转米;`effect_player.gd` 已构建 `ArrayMesh`,播放几何帧, +并接入 `.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;增量 65 +又按 `SimpleLightData.cpp` / `SimpleLightInstance.cpp` 接入 Light 组的范围曲线、生命周期、循环 +和位置更新,并为粒子多贴图接入帧延迟 / 顺逆序、旋转类型 `NONE/TIME_EVENT/CW/CCW`。增量 66 +再按 `EffectElementBase.cpp:5-60` 接入直接段和二次 Bezier 位置曲线,让粒子、网格和 Light +共用同一边界钳制 / 插值逻辑;同时接入 `EmitterEmitFromEdgeFlag` 的球体边缘发射映射,并保存 +`EmitterAdvancedType` 元数据。增量 100 对齐缺省 `CycleLength=0.05`,将有限 cycle / LoopCount +转为 CPU 发射停止门控,重播时复位粒子时钟,并保持独立的粒子寿命;OUTER / INNER 当前使用明确 +标记的 `spread=180` 宽方向 fallback,不能当成参考端按粒子位置计算的精确径向速度。 **修改** 1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把 `effect_player.gd:134` 的程序化渐变换掉。 -2. `.mde` 网格特效:新增解析器输出 `ArrayMesh`,替换 box - **[待读参考:`REF/EffectLib/EffectMesh.{h,cpp}` 的 `CEffectMeshScript::LoadScript`]**。 +2. `.mde` 网格特效的基础读取和帧播放已完成;后续仍需把 `CEffectMeshScript` 的全部纹理帧 / 活跃 + 状态语义逐项对齐,并补真实资源视觉验收。当前实现见 `POC/project/fx/mde.gd`、 + `POC/project/fx/effect_player.gd`。 3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`, `range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。 + 增量 65 已完成这一可离线验证子集;仍需真实效果资源逐项校准和完整节点销毁 / 重建验收。 4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 / emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。 + 增量 65 已完成基础多贴图帧延迟 / 顺逆序和旋转类型;增量 66 已完成 MovingType 直接 / + Bezier 段、球体 edge 映射和 advanced type 元数据;增量 100 已补齐缺省 cycle、有限 LoopCount + 发射停止、重播复位和粒子寿命隔离;随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 精确 + 径向速度、每周期 emission residue / 时间曲线重置仍待补齐。 5. 骨骼挂点:`.mse` 挂到角色骨骼由 `CActorInstance::AttachEffect` **[待读参考:`REF/GameLib/ActorInstanceAttach.cpp`]** 驱动,接到本工程的 bone attach。 @@ -2393,7 +3886,7 @@ c_Section_xCellSize = 100 c_Section_yCellSize = 100 --- -### 7.5 `.msenv` 只完成基础天空、雾和光照 +### 7.5 `.msenv` 已完成解析与静态云层,完整环境渲染仍缺 **参考**:`TEnvironmentData`(`REF/GameLib/MapType.h:126`)的**完整字段表**: @@ -2432,22 +3925,48 @@ BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFla ``` **关键差异**:参考端**背景光和角色光是两盏独立方向光**(`ENV_DIRLIGHT_BACKGROUND` / -`ENV_DIRLIGHT_CHARACTER`),本工程当前只有一盏。 +`ENV_DIRLIGHT_CHARACTER`);本工程已在增量 69 用两个 `DirectionalLight3D` 和 layer/cull mask +分开作用于静态场景与角色。剩余差异集中在环境动态和 Godot 无直接等价的后处理。 + +**当前(增量 72)**:`formats/environment.*` 已按 `MapUtil.cpp:1-205` 读取参考缺省值、 +`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`; +`environment_builder.cpp` 通过 `AssetResolver` 解析实际环境资源,并把真实云层贴图接入渐变天空的 +`ProceduralSkyMaterial.sky_cover`,或接入 texture-mode sky shader 的有限云面;真实六面天空由 +`sky` shader 按参考面顺序和 UV 渲染,空面回退渐变。双方向光已按 layer 1/2 分层,原始云层 +scale / height / texture scale / speed 已映射到 shader,滤色 blend 因子、lens flare 等仍保存在 +`WorldEnvironment` 元数据;同时 +修正了 macOS 大小写敏感环境文件路径;树 proxy 使用参考风缺省 `0.2/0.0`,但尚未等价实现 +SpeedTree 森林级风常量。`formats_map_test`(内联 + 真实 A1)、`environment_test`、`skybox_test` +和 `world_probe` 已通过。 **修改** -1. `environment_builder.cpp` 解析并落实上表**全部**字段;`TEnvironmentData` 的 - 字段名逐个对应,缺哪个补哪个。 -2. 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个 - `DirectionalLight3D` + `cull_mask` 分层实现。 -3. 云层用 `strCloudTextureFileName` + `v2CloudScale/TextureScale/Speed` + `fCloudHeight` + - `CloudGradientColor` 真实渲染,不再省略。 -4. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness / +1. `formats/environment.*` 负责逐字段解析参考格式并保存原始值;当前已覆盖上表中的参考缺省值、 + `IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`。注意参考 + `MapUtil.cpp` 的 `Environment_Load` 没有读取风参数 token,`fWindStrength=.2`、`fWindRandom=0` + 是缺省值,不能凭经验添加不存在的键;`environment_builder.cpp` 目前只落实 Godot 能等价承载的 + 基础环境、静态云层、texture-mode 六面天空和云层组合首版,其余值通过元数据保留。 +2. **已完成(增量 69)**:两盏方向光分别作用于「地形 / 水面 / 建筑 / 树」与「角色 / 装备 / + LOD」层——Godot 侧用 `Sun` / `CharacterLight` 两个 `DirectionalLight3D` + `cull_mask`; + 静态几何固定 layer 1,`Metin2Model` 几何固定 layer 2,换图时清理 `CharacterLight`。 +3. **已完成(增量 70)**:`bTextureRenderMode` 时,六面贴图按 + `SkyBox.cpp` 的 front/back/left/right/top/bottom 顺序接入 `ShaderMaterial`;缺少的面使用 + 渐变回退,避免黑面。当前 `CapeDragonHead.msenv` 的五个非空面已由 `skybox_test.gd` 验证。 +4. **已完成首版(增量 68 / 72)**:无 texture mode 的环境用 + `strCloudTextureFileName` 接入 `ProceduralSkyMaterial.sky_cover`;texture mode 的环境用 sky + shader 接入有限云面,并按参考实现映射 `v2CloudScale`、`fCloudHeight`、 + `v2CloudTextureScale`、`v2CloudSpeed` 和 `CloudGradientColor` 的颜色合成。真实 DawnMistWood + 已验证五面天空、云层、UV 滚动参数和 shader 安装。云层渐变过渡、滤色、树 proxy 之外的 + SpeedTree 森林级 shader 常量 / `fWindRandom` 语义仍待实现;不能把首版 shader 映射宣称为 + 参考端完整视觉等价。 +5. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness / bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。 -5. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)是全屏后处理,补上。 -6. 昼夜插值:参考按 `TEnvironmentDataMap`(`MapType.h:187`,`DWORD → TEnvironmentData*`) +6. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)原始值已解析并保存在元数据;仍需补上 + Godot 端全屏后处理。注意参考端该版本 `CScreenFilter::SetEnable` 实际把 enable 置为 FALSE, + 因此必须先按 `MapOutdoor.cpp` 的真实调用语义验收,不能仅凭 `Filter.Enable` 打开效果。 +7. 昼夜插值:参考按 `TEnvironmentDataMap`(`MapType.h:187`,`DWORD → TEnvironmentData*`) 在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。 -7. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。 +8. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。 --- @@ -2610,7 +4129,18 @@ BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFla `LoadLastPlayFieldMusic`(`POC/assets/root/musicinfo.py`)与 `game.py:2217 __PlayMusic(flag, filename)`。 3. **名字颜色 / 伤害数字 / 总是显示名字**:接到 §8.8 的 TextTail 渲染, - 开关键名沿用现有 `user://system_option.cfg [gameopt]`。 + 开关键名沿用现有 `user://system_option.cfg [gameopt]`。名字颜色本身在增量 110 + 已按 `GetNameColorIndex` 1:1 落地(`name_color.gd`);`constinfo.SET_CHRNAME_COLOR_INDEX` + 对应的运行期开关是 `net_world.set_empire_name_mode(bool)`(默认关),尚未挂到选项 UI。 + **增量 113 已完成「总是显示名字」1:1**:`name_show.gd`(`CPythonSystem::IsAlwaysShowName` / + `SetAlwaysShowNameFlag` 的 `iFlag == 1` 语义 + `game.py::__IsShowName` = `IsAlwaysShowName() + or ShowNameFlag(LALT)` + `CPythonTextTail::ShowAllTextTail` 的 `fDistanceFromPlayer < 3500.0f` + 平面距裁剪 + `ShowCharacterTextTail` 的 `isShow / !IsGuildWall / CanPickInstance` 资格判定 + + `UpdateAllTextTail` 的 chat 尾标 `bNameFlag` 强显)。`game_option_ui.gd` 的 + `always_show_name` radio 现经新信号 `display_option_changed` 驱动 + `net_world.set_always_show_name()`;`net_play.target_changed` 驱动 + `net_world.set_target_vid()`(选中目标始终强显名字)。名字色 / 伤害数字两个 radio 仍未接 + (下一批次候选)。见 C.5 增量 113、§8.6、§8.8。 4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是 `TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)—— 改为按环境数据的近远距离设置,浓/中/淡对应三档缩放 @@ -2638,14 +4168,12 @@ HSIZE→cursor_hsize.sub VSIZE→cursor_vsize.sub HVSIZE→cursor_hvsize.sub `SetCursorNum` / `SetCursor` 的调用点:商店 BUY/SELL(§4.6)、 NPC 悬停 TALK、可攻击目标 ATTACK 等。 -**修改** +**当前(增量 56)**:新增 `cursor_manager.gd`,使用参考端的状态名,并把世界实体 / 地面物品 +的首版悬停状态接入 `PlayerController`;`UiManager` 的窗口键盘焦点屏蔽已接入。当前没有 +`cursor.sub` 等资源时使用系统光标,不伪称为视觉 1:1;商店 BUY / SELL 模式已切换对应状态。 -1. 新建 `POC/project/ui/cursor_manager.gd`:`set_cursor(shape)` / `get_cursor()`, - 枚举与资源映射按上表;资源用 `.sub`(`UiAssets`)。 -2. 鼠标跟随物品图标由 §4.2 的 `mouse_controller` 负责,与光标是两层。 -3. 悬停实体时的光标:可攻击 → `ATTACK`;NPC → `TALK`;地面物品 → `PICK`; - 不可达 → `CANT_GO`——判据用 §5.4 的 `is_attackable_instance` 与实体类型。 -4. 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(`wndMgr` 的语义)。 +**剩余**:不可达 CANT_GO、DOOR / CHAIR / MAGIC / camera resize 等完整状态, +以及 `.sub` 光标贴图和软件光标开关仍需逐项实现 / 验收。 --- @@ -2690,18 +4218,26 @@ COMMA → ShowConsole() LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode() (§4.8) ``` -**当前**:只有 WASD、点地、相机拖动、滚轮、触屏、F1–F4、数字键 1–9。macOS 上物理 F1–F12 -是否直接产生 `KEY_F1`–`KEY_F12` 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响, -这是平台验收条件,不应写成 ClientVS22 的额外键位功能。 +**当前(增量 53)**:`game_scene.gd` 已覆盖上表的主入口:数字 1–4 / F1–F4 快捷栏、 +Ctrl/Command+数字表情、LSHIFT 翻页、空格按住攻击、Alt 名字显示、截图、帮助 / 坐骑 / 角色窗口 / +小地图 / 聊天 / 相机入口,并将按住 / 松开动作分别处理。macOS 上物理 F1–F12 是否直接产生 +`KEY_F1`–`KEY_F12` 仍受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,这是平台验收条件, +不应写成 ClientVS22 的额外键位功能。 -**修改** +**已完成 / 剩余** -1. `game_scene.gd` 的输入表按上面**逐行**补齐,包括每个 Ctrl 组合分支。 -2. `SaveScreen()`(SYSRQ)实现正式截图,替代现有的调试截图。 -3. `StartAttack/EndAttack`(空格)接 §3.4 的 `m_isAtkKey` 连续攻击。 -4. `ShowName / HideName`(LALT)接 §8.8 的 TextTail 显示开关。 -5. 键位重绑定 / 冲突检测:参考端**没有**——不要实现,若需要另立需求。 -6. 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §7.6 第 5 点)。 +1. `game_scene.gd` 已按表补齐可映射入口,并通过 `input_key_test.gd`;`SYSRQ` 保存到用户截图目录, + 空格按住 / 松开驱动连续攻击,LALT 控制世界名字牌,Ctrl/Command 组合走独立分支。 + **增量 113**:LALT(`ShowName()` / `HideName()`)现只写 `net_world` 的 `_alt_held`,与 + uigameoption「总是显示名字」选项(`_always_show_name`)一起按 `game.py::__IsShowName()` = + `IsAlwaysShowName() or ShowNameFlag` 决定名字是否显示,再叠 `ShowAllTextTail` 的 3500 平面距 + 裁剪与 `ShowCharacterTextTail` 资格判定(`name_show.gd`,见 §8.4 / §8.8 / C.5 增量 113)。 + `ShowName()` 的另两步(`playerGauge.EnableShowAlways()` / `SetQuickPage(+1)`)属血条 / 快捷栏 + UI,未接。 +2. 表情快捷栏调用 `M2Client.send_emoticon()`,C++ 发参考 token 并发出本地播放信号;本地特效由 + `game_scene.gd` 按迁移资产注册顺序播放。 +3. 未完成的是 §4.2 的多格占用 / 全窗口精确 mousemodule 规则、真实 CJK IME 候选 / composition、以及 + macOS F1–F12 的物理键设置验收;键位重绑定 / 冲突检测和独立相机灵敏度持久化不是参考端能力。 --- @@ -2753,23 +4289,116 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn - 名字颜色:`ENameColor`(`REF/UserInterface/InstanceBase.h:227`,完整枚举见 §5.4)—— 按 `MOB / NPC / PC+empire / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP / WAYPOINT` 分类,`EMPIRE_NUM` 决定每类里帝国的偏移。 -- 帝国常量:`EMPIRE_NUM` **[待读参考:`REF/UserInterface/GameType.h` 或 `Packet.h`]**。 +- 帝国常量:`EMPIRE_NUM = 4`(`REF/UserInterface/InstanceBase.h:217` 的匿名枚举 + `EMPIRE_NONE/A/B/C`)。 +- **增量 110(2026-09-03)**:`CInstanceBase::GetNameColorIndex` / `GetNameColor` / + `SetEmpireNameMode` / `GetIndexedNameColor`(`REF/UserInterface/InstanceBaseEffect.cpp:52/75/520/525`) + 已 1:1 迁到 `project/name_color.gd`,并接进 `net_world.gd` 的头顶 `Label3D.modulate` + (spawn / add-info / GC_PVP / duel 单发刷新 + `_process` ~2Hz 整表兜底)。sole baseline + 40250 `source/` 本身含 `GetNameColorIndex`(ISO-8859 编码,需 `grep -a`),字节级等同 + m2dev 完整版 —— **无 seam**。见 C.5 增量 110。 +- **增量 111(2026-09-03)**:头顶「公会名 / 称号 / 等级」子标签 1:1。 + `CInstanceBase::GetTitleColor`(`InstanceBaseEffect.cpp:609`,`uGrade >= TITLE_NUM(9)` 白, + 否则 `g_akD3DXClrTitle[uGrade]`)、`RefreshTextTail`(:664,`TITLE_NONE(4)` 时 `DetachTitle`, + 否则按 `g_TitleNameMap[grade]` `AttachTitle(name, GetTitleColor())`)、`AttachTextTail`(:621, + `fTextTailHeight = IsMountingHorse() ? 110 : 10`,末尾 `if (m_dwLevel) UpdateTextTailLevel`)、 + `UpdateTextTailLevel`(:654,`s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1)`,`"Lv %d"`)、 + `CPythonTextTail::RegisterCharacterTextTail`(`PythonTextTail.cpp:509`,`dwGuildID != 0` 才建公会名 + 标签,`GetGuildName` 失败 → `"Noname"`;`c_TextTail_Guild_Name_Color = 0xFFEFD3FF`)、 + `EnablePKTitle`(:938,`AttachTitle` / `AttachLevel` 均 `if (!bPKTitleEnable) return`; + `game.py:155` 用 `constInfo.PVPMODE_ENABLE == 1` 初始化)全部迁到 `project/text_tail.gd`(纯静态)。 + 称号色表来自 `introloading.py:287 TITLE_COLOR_DICT` + `colorInfo.py:29-37`,称号名来自 + `localeInfo.py:215 TITLE_NAME_LIST == (PVP_LEVEL0..PVP_LEVEL8,)`。40250 `source/` 自带 + `InstanceBaseEffect.cpp` 的 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / + `RegisterTitleName` / `RegisterTitleColor`(:609/:664/:654/:1107/:1131)—— **无 seam**。 + 见 C.5 增量 111。 +- **增量 112(2026-09-03)**:头顶「聊天 / 信息」尾标 1:1。 + `CPythonTextTail::RegisterChatTail`(`PythonTextTail.cpp:581`,`m_ChatTailMap` 已有该 vid → + 改文本 + `Color = c_TextTail_Chat_Color(白)` + `LivingTime = now + TextTail_GetLivingTime()` + + `bNameFlag = TRUE`,否则新建,高度 = 角色高 + 10)、`RegisterInfoTail`(:624,同上但 + `Color = c_TextTail_Info_Color = D3DXCOLOR(1, .785, .785, 1)`、`bNameFlag = FALSE`;chat 与 + info 共用 `m_ChatTailMap`,同 vid 互相覆盖)、`TextTail_GetLivingTime` / `SetLivingTime` + (:26/:28/:34,`gs_TextTail_LivingTime = 5000`,`assert(> 1000)`)、`UpdateAllTextTail` 的 + `LivingTime < now` 硬删(:328,无淡出)、`IsChatTextTail`(:684)全部迁到 `project/chat_tail.gd` + (纯静态)。派发规则照抄 `PythonNetworkStreamPhaseGame.cpp::RecvChatPacket`:`CHAT_TYPE_COMMAND` + 提前 `return`(:1301);`TALKING/PARTY/GUILD/SHOUT/WHISPER` 的 `line = strchr(buf,':') ? p+2 : buf` + 去掉 `"名字: "` 前缀(:1321),`INFO/NOTICE/BIG_NOTICE` 原样(:1363);`if (CHAT_TYPE_SHOUT != + type) RegisterChatTail(vid, line)`(:1372)—— 即除 `SHOUT` / `COMMAND` 外带 VID 的聊天都进 + 「聊天尾标」,`RegisterInfoTail` 仅 `game.py:930/936` 技能 / 射击错误显式调。40250 `source/` + 自带 `RegisterChatTail` / `RegisterInfoTail` / `RecvChatPacket` —— **无 seam**(气泡与名字尾标 + 同屏排布 / `bNameFlag` 名字下移 17px = seam ⑨,表现层近似)。见 C.5 增量 112。 +- **增量 113(2026-09-03)**:头顶名字「是否显示」1:1(§8.4 / §8.6)。 + `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`(`PythonSystem.cpp:327/:334`, + `bAlwaysShowName = iFlag == 1`,默认 `DEFAULT_VALUE_ALWAYS_SHOW_NAME = true`,配置行 + `ALWAYS_VIEW_NAME` = `atoi == 1`)、`game.py::__IsShowName`(:1149,`IsAlwaysShowName() or + ShowNameFlag`,`ShowNameFlag` 由 LALT 按 / 抬 `ShowName()` / `HideName()` 翻转,:323/:406/:1143)、 + `CPythonTextTail::ShowAllTextTail`(`PythonTextTail.cpp:422`,`m_CharacterTextTailMap` 里 + `fDistanceFromPlayer < 3500.0f` 才 `ShowCharacterTextTail`)、`ShowCharacterTextTail`(:440, + 不在图 / 已入列 / `!pOwner->isShow()` / 无实例 / `IsGuildWall()` → return,`CanPickInstance()` + 才入列)、`UpdateAllTextTail` 的 chat 尾标 `bNameFlag` → `ShowCharacterTextTail`(:74,不受 + 3500 限制)、`game.py OnRender` 的 `chr.Pick()` 命中 + `targetBoard.GetTargetVID()` 两条强显 + (:1533/:1535)全部迁到 `project/name_show.gd`(纯静态)。40250 `source/` 自带 + `PythonSystem` / `PythonTextTail` 全部逻辑 —— 悬停 `chr.Pick()` 强显 / `ArrangeTextTail` + 屏幕空间防重叠 / `CanPickInstance()` 完整语义 = seam ⑩(net_world 每实体一块 billboard + `Label3D` 逐帧 set `.visible` 的近似)。见 C.5 增量 113。 **修改** 1. 按 §8.2 迁移 `uichat.py`(notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。 -2. 新建 `text_tail.gd` 移植 `PythonTextTail`:名字 / 称号 / 公会名 / 伤害数字 / - 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。 +2. 新建 `text_tail.gd` / `chat_tail.gd` / `name_show.gd` 移植 `PythonTextTail`:名字 / 称号 / + 公会名 / 伤害数字 / 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。 + **增量 113 已落地「总是显示名字」显示开关**(`name_show.gd` 纯静态 `IsAlwaysShowName` / + `__IsShowName` / `ShowAllTextTail` 3500 距离裁剪 / `ShowCharacterTextTail` 资格判定; + `net_world.gd` 把原 `_names_visible` 拆成 `_always_show_name`(uigameoption radio 经 + `game_option_ui` 新信号 `display_option_changed` 驱动)+ `_alt_held`(LALT)+ `_target_vid` + (`net_play.target_changed` 驱动,选中目标强显)+ chat 尾标 `bNameFlag` 强显;名字色 / + 伤害数字两个 radio 仍未接)。 + **增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / + `LivingTime` 5s 硬删 / 前缀剥离 / SHOUT·COMMAND 排除,`net_world.gd` `"bubble"` `Label3D` + 每 vid 一条 + `register_info_tail()`);伤害数字 / 屏幕空间血条仍待做。 + **增量 111 已落地称号 / 等级 / 公会名子标签**(`text_tail.gd` 纯静态 `GetTitleColor` / + `RefreshTextTail` / `UpdateTextTailLevel` / `RegisterCharacterTextTail` 公会名分支 / + `EnablePKTitle`,`net_world.gd` 的 `GuildTag` / `TitleTag` / `LevelTag` 三个 `Label3D` + 子节点 + `_apply_text_tail` 事件驱动 + ~2Hz 兜底;`game_scene.gd` 注入 `title_name_resolver` + → `Locale.t("PVP_LEVEL%d")`)。 + **增量 112 已落地聊天 / 信息尾标**(`chat_tail.gd` 纯静态 `RegisterChatTail` / + `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail` / `line_for_tail` 前缀剥离 / + `wants_chat_tail` SHOUT·COMMAND 排除;`net_world.gd` 每 vid 一条 `"bubble"` `Label3D` + 子节点、白 / 淡红按 chat/info、`LivingTime` 到点 `_expire_chat_tails` 硬删、新增 + `register_info_tail(vid, text)` 公开方法对齐 `RegisterInfoTail`;`chat` 派发从 + `t in [0,3,4,6]` 换成 `ChatTail.wants_chat_tail(t)`=除 SHOUT/COMMAND 全进、并去 `"名字: "` + 前缀;`game_scene._player_bubble` 寿命对齐 5s)。伤害数字 / 屏幕空间血条整合仍待做。 + billboard 3D 竖排 vs 参考屏幕空间像素排版 = seam ⑦;`title_name_resolver` 未注入时 + 回退裸 `PVP_LEVEL%d` key = seam ⑧;气泡与名字 `Label3D` 各自 billboard 并存(名字不强制 + 顶出、不随气泡下移 17px)= seam ⑨(**增量 113** 已让 chat 尾标 `bNameFlag` 强制显示名字, + 仅剩「下移 17px」未做)。 + **增量 113 已落地名字「是否显示」**(`name_show.gd` + `net_world._name_shown()`: + `__IsShowName()`=`_always_show_name or _alt_held`、`ShowAllTextTail` 的 + `fDistanceFromPlayer < 3500` 平面距裁剪、`ShowCharacterTextTail` 的 `isShow / !IsGuildWall / + CanPickInstance` 资格、`target VID` 与 chat `bNameFlag` 强显绕过 3500;`_process` ~2Hz 兜底 + 随移动刷新,LALT / 选项 / 选中 / 尾标切换即时刷新)。悬停 `chr.Pick()` 强显未接、 + `ArrangeTextTail` 屏幕空间防重叠未做、`CanPickInstance()` 近似为「非死透」= seam ⑩。 3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的 `ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。 + **增量 110 已完成名字色本身**(`name_color.gd` + `net_world.gd` 接线,见本节参考的增量 110 条); + **增量 111 已完成称号色 `GetTitleColor` + 公会名 + 等级标签**(见本节参考的增量 111 条); + **增量 112 已完成聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail`**(`chat_tail.gd` + + `net_world.gd` `"bubble"` 子节点,见本节参考的增量 112 条); + **增量 113 已完成名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` + 3500 裁剪**(`name_show.gd` + `net_world._name_shown()`,见本节参考的增量 113 条); + `text_tail.gd` 的伤害数字 / 屏幕空间血条整合仍待做。 4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。 --- -### 8.9 任务日志图标仍是占位 +### 8.9 任务日志导航和完整图标视觉仍不完整 -见 §5.2,同一条修改。`POC/project/ui/quest_log.gd:8` 的文字列表在 -`EVENT_TYPE_QUEST_BUTTON` 补齐 `icon_type` / `icon_name` 后即可接真图标。 +增量 101 已在 §5.2 补齐默认 / 服务端 icon、5 行窗口、滚动起始索引、倒计时和任务行事件; +增量 108 已补齐小地图任务信标的世界箭头 / 屏幕边缘指示(越界夹圆周 + 方向箭头,见 §5.2 条 3); +增量 109 已按 `interfacemodule.__ArrangeQuestButton` 补齐信件按钮条多列定位 + 最新在前 + 组队窗 +`xPos` 移位(见 §5.2 条 3、C.5 增量 109)。当前仍缺任务日志窗体 `_list` 自身的多列 / 分页、 +真实 `.sub` 状态图 / Flash 视觉和服务端 `iStartTime` 生命周期。 +不得再把 `project/ui/quest_log.gd` 描述成纯文字占位列表。 --- @@ -2888,32 +4517,37 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn ### 9.3 生命周期所有权重复 -**状态**:设计已确认,落地推迟到 Phase 2(由 W0 执行)。 +**状态**:✅ 代码与离线回归完成(2026-09-02 / W0 / 增量 51);🔎 真机通知和断线重连仍待验收。 -**当前**:`AppLifecycle` 由 `GameScene._build()` 自建(`game_scene.gd:181` `AppLifecycle.new()` -+ `add_child` + `bind(client, _audio)`);`M2Client` 自带 `_notification()` 处理 -`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 直接调 `suspend()/resume()` -(`extension/src/net/m2_client.cpp:2607`)。两处都在监听生命周期通知。 +**参考**:生命周期协调责任在 `CPythonApplication`(`REF/UserInterface/PythonApplication.cpp` ++ `PythonApplicationEvent.cpp`),网络流只是被它驱动的子系统,不应再独立消费同一组应用通知。 -**目标设计(推迟到 Phase 2 / W0)**:`AppFlow` 作为唯一生命周期属主(等价 -`CPythonApplication`),由它创建并持有 `AppLifecycle`;`GameScene` 不再自建; -`M2Client` 删除 `NOTIFICATION_*` 处理,只保留被 `AppLifecycle` 调用的 -`suspend() / resume()`。暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进 -`docs/CLIENT-PORT.md` 的生命周期小节。本批次未改 `game_scene.gd` / `app_flow.gd` / -`m2_client.*`(跨工作流共享文件,属 W0 / Phase 2 范围)。 +**已实现**: -**参考**:唯一协调者是 `CPythonApplication` -(`REF/UserInterface/PythonApplication.cpp` + `PythonApplicationEvent.cpp`), -`CPythonNetworkStream` 只是被它驱动的一个子系统。 +1. `project/app_flow.gd:56-62` 在生产入口 `client_main.gd` 下创建并持有唯一 `AppLifecycle`,从 + 登录、选人到游戏都不随界面切换销毁;`_goto_login()` 会先解除旧场景音频绑定, + `_goto_game()` 在场景装配完成后绑定当前 `Audio`(`app_flow.gd:143-181`)。 +2. `project/game_scene.gd:181-184` 不再创建 `AppLifecycle`,只提供 `lifecycle_audio()`(:892-895) + 让 AppFlow 绑定当前场景音频;因此切屏不会累积暂停 / 恢复监听器。 +3. `extension/src/net/m2_client.cpp:2620-2623` 删除 `NOTIFICATION_APPLICATION_PAUSED/RESUMED` + 入口,`M2Client` 仅保留可被协调者调用的 `suspend()/resume()`;头文件同步移除声明。 +4. 兼容启动器 `project/login.gd:33-37,142-152` 不属于生产 `AppFlow` 路径,但仍保持自己的单一 + 生命周期节点贯穿登录到游戏,进入游戏时重新绑定 `M2Client + Audio`,不再错误销毁节点。 -**修改** +**测试**: -1. 确立 `AppFlow` 为唯一生命周期协调者(等价 `CPythonApplication`): - `AppLifecycle` 由 `AppFlow` 创建并持有,`GameScene` 不再自建。 -2. `M2Client` 删除自己的 `NOTIFICATION_*` 处理,只保留被调用的 - `suspend() / resume()`。 -3. 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进 - `POC/docs/CLIENT-PORT.md` 的生命周期小节。 +- `godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log --script app_flow_lifecycle_test.gd` + → PASS:AppFlow 只有一个生命周期节点,登录构建后仍存在,客户端由同一 AppFlow 持有。 +- `godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log --script gamescene_test.gd` + → PASS:GameScene 不创建第二个节点且暴露场景音频。 +- 两个 Godot 进程不能并行共用默认 `user://logs`;首次并行运行触发共享日志 / 图形上下文冲突, + 已改为带独立 `--log-file` 的串行回归并通过。该失败不是代码断言失败,后续测试保持串行。 + +**验收**:未验证 Android/iOS 真机前后台、内存告警和后台约 30 秒后 TCP 被系统回收时的重连; +这些属于平台门禁,不在 headless 回归中伪标为完成。 + +**下一步**:由 W5 / 平台验收补真机证据;若需要产品级自动重连策略,应另立增强项,不把它混入 +ClientVS22 的“断线回登录”1:1 判据。 --- @@ -2974,25 +4608,53 @@ dx² + dy² > s_fLimitDistance → 不播 否则记录 m_PlaySoundHistoryMap[file] = now ``` -**当前**:只有 BGM crossfade、UI 音效池和通用 3D 音效。 +**当前(增量 55/63/67)**:已实现 MilesLib 可复用子集:BGM crossfade、UI 音效池、普通 / 环境 / +角色 3D 声道、监听器位置 / 方向、200/1000 缩放、参考音量曲线、角色 5000cm / 0.3 秒限频、3D +实例 stop / 全停和音乐限幅淡出;`.msa` 本地动作声音以及 `.mss` 动作声音时间表已接入角色声道。 -**修改** +**已实现** -1. `POC/project/audio.gd` 按上表补齐 API 与常量: +1. `POC/project/audio.gd` 已按上表补齐可复用 API 与常量: `SOUND_SCALE = 200.0`、`FADE_SPEED = 0.016`、`CHAR_SOUND_MIN_INTERVAL = 0.3`、 - `CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`。 -2. 3D 音效位置一律除以 `SOUND_SCALE`。 + `CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`;另有 `AMBIENCE_SOUND_SCALE = 1000.0`。 +2. `SetPosition/SetDirection/Update` 已由 `set_listener/update_listener` 提供,3D 音效位置按 + `(world - listener) / scale` 计算,活动实例会在监听器移动时重新基准。 3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现 (`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio; - `system_option_ui.gd` 的音量滑条改走这个换算(当前是线性)。 -4. 三类声道分开:普通 3D(`PlaySound3D`)、环境(`PlayAmbienceSound3D`, + 系统设置现有滑条继续保存线性应用值,另提供 `set_sound_volume_ratio/grade`、 + `set_music_volume_ratio/grade` 供协议入口使用。 +4. 三类声道已分开:普通 3D(`play_sound_3d`)、环境(`play_ambience_sound_3d`, 有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。 -5. `.msa` 的 sound 事件走 `UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE)` - (角色动作声音一律开去重)。 -6. 音乐通道支持 `FADE_LIMIT_OUT`(降到某音量而不是 0)与 `FadeOutAllMusic`。 -7. `.mss` 声音脚本:`CEffectData::LoadSoundScriptData` 与 - `CRaceMotionData::GetSoundScriptFileName()`(`REF/GameLib/RaceMotionData.h:220`) - **[待读参考:`REF/MilesLib/SoundData.cpp` 的 `.mss` 解析]**。 +5. `game_scene.gd` 的 `.msa` sound 事件已走 `play_character_sound_3d(..., check_frequency=true)`, + 对齐角色动作声音的频率保护。 +6. 已提供 `set_sound_volume_3d`、`stop_sound_3d`、`stop_all_sound_3d`,音乐支持 + `fade_limit_out_music` 与 `fade_out_all_music`。 + +7. 增量 63 已按 `REF/MilesLib/Type.cpp:17-74,118-136` 实现 `.mss`:解析 + `SoundDataCount` / `SoundData%02d` 的时间和虚拟声音路径,按 `DWORD(time / (1/60))` 转为声音 + 实例;`sound/` 前缀会映射到实际 patch 的 `sound` 根。`load_mss_for_motion()` 根据 `.msa` + 的 `ymir work` 相对路径找到配对脚本,`update_sound_instances_3d()` 在动作帧进入时按参考 + `UpdateSoundInstance` 触发 `PlayCharacterSound3D`。PlayerView / MobView 都已接入。 + +**仍未完成** + +8. `UpdateSoundData/UpdateSoundInstance` 的 `.mss` / sound-node 数据结构仍需继续补齐:当前已覆盖 + `.mss` 文件读取、实例帧换算和动作帧播放,但还没有把 EffectInstance 的完整生命周期 / 节点 + 停止语义全部接到 Godot。 +9. 增量 68–72 已按 `REF/GameLib/MapUtil.cpp:1-205`、`REF/GameLib/MapOutdoor.cpp:246-323` 和 + `REF/EterLib/SkyBox.cpp:403-542` 补齐 `.msenv` 解析字段与参考缺省值、背景 / 角色双方向光 + 分层、texture-mode 六面天空,以及真实云层贴图到 `ProceduralSkyMaterial.sky_cover` / sky shader; + `CloudScale` / `CloudHeight` / `CloudTextureScale` / `CloudSpeed` 已映射到 texture-mode 云面, + 原始参数已通过 `WorldEnvironment` 元数据保留。仍需完成云层渐变过渡、滤色实际合成、SpeedTree + 完整风语义、lens flare、昼夜插值,以及武器 / 技能 / 怪物 / NPC 所有声音事件。 + +10. 增量 67 已按 `REF/GameLib/Area.cpp:882,1316-1394` 与 + `REF/GameLib/MapType.cpp:195-260` 接入地图环境声:C++ 世界层解析每个已加载区块的 + `AreaAmbienceData`(兼容大小写文件名),通过 `.pra` CRC 提供位置、范围、最大音量区域比例、 + 播放类型、间隔和声音向量;该阶段独立于对象渲染和 splat 开关,并在区块卸载时移除来源。 + `audio.gd` 对齐参考的严格 `< range` 判定、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 / 随机 + 变化和离开区域停止;世界米坐标先还原为厘米再应用 Miles 的 3D 缩放。`ambience_test.gd` + 使用真实 OutdoorA3 区块和 `warp_test.mp3` 通过。 --- @@ -3003,46 +4665,36 @@ dx² + dy² > s_fLimitDistance → 不播 ### 10.1 Mac App 不是自包含资源包 -**修改** +**当前(增量 53)**:`build-macos-client.sh` 已改为把 `assets` / `bgm` 用 `ditto` 复制到 +`Contents/Resources/`,并检查 `assets/asset_index.txt`;`MT_ASSETS` 只作为开发覆盖路径。 +代码路径已完成,但尚未执行完整 2.2G 资源导出和跨机器启动验收。 -1. `build-macos-client.sh` 里把 `assets` / `bgm` 的**符号链接**改为 - `ditto` 复制进 `Contents/Resources/`(或打成 `.epk/.eix` 后复制)。 -2. `AssetRoot.path()` 在 macOS 上优先解析 `Contents/Resources/assets`, - `MT_ASSETS` 仍可覆盖(开发用)。 -3. 验收:把 `.app` 拷到另一台机器(或 `mv` 掉工作区)后能启动进图。 +**剩余验收**:把 `.app` 拷到另一台机器(或暂时移开工作区资源)后启动并进图;确认包内所有 +资源读取都没有回退到工作区。 ### 10.2 主程序和 GDExtension 架构不一致 -**修改** +**当前(增量 53)**:`build.sh` / `build-macos-client.sh` 支持 `MT_MAC_ARCHES=arm64`、 +`x86_64` 或 `universal`;universal 模式传入 `CMAKE_OSX_ARCHITECTURES=arm64;x86_64`,打包脚本 +用 `lipo -info` 拒绝单架构扩展。 -1. `build.sh` / `CMakeLists.txt` 为 macOS 增加 `x86_64` 目标, - 用 `lipo -create` 合成 universal `.dylib`。 -2. `*.gdextension` 的 `macos.debug/release` 指向该 universal 库。 -3. 验收:`lipo -info` 显示 `x86_64 arm64`;Godot 主程序也是 universal。 +**剩余验收**:当前已检查的 `project/bin/libmtgodot.macos.template_debug.dylib` 仍为 arm64; +需在可用的双架构工具链和完整导出模板下重建,并分别在 Apple Silicon / Intel Mac 启动。 ### 10.3 当前是 Ad-hoc 签名 -**修改** +**当前(增量 53)**:脚本支持 `CODESIGN_IDENTITY`,按 Contents 内嵌扩展先签、App 后签, +并传入 hardened runtime 选项;默认身份仍为 `-`(Ad-hoc),脚本未伪称正式发布。 -1. `codesign --force --options runtime --sign "Developer ID Application: …"` - 对 `.app` 内所有 `.dylib` 与主可执行文件逐个签名(先内后外)。 -2. `xcrun notarytool submit --wait` 提交公证,`xcrun stapler staple` 装订。 -3. 验收:`spctl -a -vvv -t install ` 通过。 +**剩余验收**:配置真实 Developer ID 后执行签名、公证和装订,并用 +`spctl -a -vvv -t install ` 验证;当前没有证书、notarization 或 stapled ticket 证据。 ### 10.4 发布构建仍包含 Demo 默认账号密码 -**当前**:`POC/project/app_flow.gd:32-33` -```gdscript -var _id := "admin" -var _pw := "123456789" -``` +**当前(增量 53)**:`app_flow.gd`、`login.gd`、`charselect.gd` 已移除硬编码账号密码; +账号 / 密码从 `MT_ACCOUNT` / `MT_PASSWORD` 读取,自动登录还要求 `MT_AUTOLOGIN=1`。 -**修改** - -1. 两行改为空串。 -2. 自动登录只在 `MT_AUTOLOGIN=1` 且 `MT_ACCOUNT` / `MT_PASSWORD` 环境变量存在时生效 - (`app_flow.gd:37` 的 `auto_login` 保留,凭据不再硬编码)。 -3. 加一条 CI 检查:release 预设的 PCK 里 `grep -c "123456789"` 必须为 0。 +**剩余验收**:release 预设的 PCK / 导出包加入字符串扫描门禁,确认不包含旧 Demo 密码。 ### 10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策 @@ -3092,9 +4744,9 @@ var _pw := "123456789" | §1.2 | ❷ | 参考端确实**没有**重连遮罩 / 倒计时,`OnRemoteDisconnect()` 只调 `SetLoginPhase()`。但「删掉 `reconnect_ui.gd`」只是**严格 1:1 时的选择,不是必然修复**;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 `disconnected`,`AppFlow` 无条件回 LOGIN;真实 TCP 断线仍待验收。 | `PythonNetworkStreamEvent.cpp:4` | | §2.7 | ❷ | `RecvWarpPacket()` 确实**无条件**按 `lAddr / wPort` 重连,`same_server()` 不是参考端逻辑。但应表述为「**`RecvWarpPacket()` 内不直接使用 `lX/lY`**」——这两个坐标在重连后的 `GC_MAIN_CHARACTER*` 阶段仍参与角色加载定位,不是「完全没用」。 | `PythonNetworkStreamPhaseGame.cpp:923` | | §3.10 | ❶+❸ | 本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓迁移资产中的 `uicharacter.py` 仍显示参考脚本通过 `net.SendChatPacket("/skillup " + str(skillIndex))` 发包,因此“应改成非命令路径”不成立,也不能仅据此断定当前应传 skill vnum。当前明确差距是 C++ 技能合法性 / 等级上限逻辑和 UI 加号按钮门控、失败提示、升级后状态刷新不完整。 | `POC/assets/root/uicharacter.py:1022`、`REF/UserInterface/PythonSkill.cpp:1833` | -| §4.8 | ❶+❸ | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」——这一半正确。但原文**漏了当前实现的新问题**:`m2_client.cpp:878` 把 `3` 注释/使用为 `command`、`4` 为 `emotion`,而参考的 `TQuickSlot.Type` 直接就是 `ESlotType`(`SLOT_TYPE_EMOTION == 3`,`4` 是 `SLOT_TYPE_SHOP`)。**当前类型编号与参考端不兼容**;同时,键位表来源于本仓迁移资产,不能伪称为 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309` | +| §4.8 | ✅(增量 53/54 已修复首版) | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」;`quickbar.gd` / `M2Client` 现已统一 1/2/3,4(`SLOT_TYPE_SHOP`)不执行。客户端显示 4×8,服务器保留 36 槽;技能、表情、F1–F4 / 数字键和 inventory → quickslot 投放已接通。仍未完成复杂窗口之间的完整物品态、stack split 和真实服务端验收。键位表仍来自迁移资产,不冒充 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309`、`POC/project/ui/quickbar.gd`、`POC/project/ui/mouse_controller.gd` | | §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` | -| §8.6 | ❷ | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;主文档已改为明确这一证据边界。macOS 物理 F1–F12 是否直接产生功能键事件受系统功能键设置 / Fn 键影响,应作为平台验收条件,不应虚构成参考端额外键位。 | `POC/assets/root/game.py:309-408`、Godot 当前 `project/game_scene.gd:558` | +| §8.6 | ✅(增量 53/54 已接通主入口) | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;`project/game_scene.gd` 已接通表中可映射的按下 / 松开入口,并有 `input_key_test.gd`;窗口键盘焦点屏蔽、PICK / TALK / ATTACK 首版光标和物品拖放已接入。macOS 物理 F1–F12 是否直接产生功能键事件仍受系统功能键设置 / Fn 键影响,应作为平台验收条件;复杂窗口 mousemodule 分支和 CJK IME 仍是独立差距。 | `POC/assets/root/game.py:309-408`、`POC/project/game_scene.gd`、`POC/project/input_key_test.gd`、`POC/project/ui/mouse_controller.gd` | | §1.5 | ✅ | **属实,保留**。参考这份 `ClientVS22`(`Locale_inc.h` = `LOCALE_SERVICE_SINGAPORE` → `LSS_EUROPE`)发 `HEADER_CG_CLIENT_VERSION2 = 0xf1` + `"1215955205"`;当前实现发 `0xfd` 却用了 0xf1 分支的 timestamp。另外当前 `filename` 固定 `"metin2.bin"`,参考端用**实际可执行文件名**(`GetExcutedFileName` + `NoPath`)。 | `PythonNetworkStreamPhaseGame.cpp:4155`、`Locale.cpp:186` | **§1.7 / §1.8 的订正属实并保留**,但补一条落地限定: @@ -3120,7 +4772,7 @@ var _pw := "123456789" | §4.4 仓库金钱包/命令 | `REF/UserInterface/PythonNetworkStreamModule.cpp` | | §4.9 `affects[7]` 槽位语义 | 40250 **服务端** `game/src/party.cpp` | | §5.4 `ATTRIBUTE_BANPK` 等地形属性 | `REF/GameLib/TerrainImpl`(`MapType.h` / `TerrainPatch.h`) | -| §6.3 `.mde` 解析 | `REF/EffectLib/EffectMesh.{h,cpp}` | +| §6.3 SimpleLight / TexAni 完整语义 | `REF/EffectLib/SimpleLightData.{h,cpp}`、`SimpleLightInstance.cpp`、`ParticleProperty.{h,cpp}`、`ParticleSystemInstance.cpp` | | §6.3 骨骼挂特效 | `REF/GameLib/ActorInstanceAttach.cpp` | | §6.5 `ChangeWeapon` 的 detach | `REF/UserInterface/InstanceBase.cpp` | | §6.6 材质 stage / alpha-test ref | `REF/EterGrnLib/Thing.cpp`、`Material.cpp` | @@ -3144,7 +4796,7 @@ var _pw := "123456789" | 门禁 | 目标 | 通过条件 | 当前状态 | |---|---|---|---| -| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结批次已落地(`Entity` 字段、`mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed`、`bgm_director.gd`、§8.6 键位入口);真实双连接和部分生命周期仍待验收 | +| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结、增量 51 生命周期属主、增量 52 参数边界已落地;真实双连接、真机通知和部分平台行为仍待验收 | | G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 1(W1/W2/W3/W4)4 个并行工作流提交后执行 | | G2 真实 / 发布验收 | 验证参考链路与可交付 Mac App | 40250 真实连接 / 换图、适用的双账号玩法、资源包、架构、签名和验收记录齐全 | ⬜ 未开始;受限环境 `skip` 不算通过 | @@ -3162,12 +4814,12 @@ var _pw := "123456789" | 工作流 | 当前状态 | 已有证据 | 下一批次 | |---|---|---|---| -| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1 | +| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结 + 增量 51 单一 `AppLifecycle` + 增量 52 `net_bounds` 已落地,当前 C++ `ctest` 15/15 + 生命周期 / 场景回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1;真机生命周期验收 | | W1 协议 / 账号会话 | 🟡 开发中(增量 50:§1.5 / §1.10 / §2.2 已落地,待真实验收) | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;§1.5 locale 分支 + `CGClientVersion2`(0xf1);§1.10 `text_codec.h` + `to_wire` 路由 + `net_text_codec_test`(套件 16→17);§2.2 `GC_CHARACTER_ADD` 隐身丢弃 / PC·NPC 暂存 / 其余按 `npclist.txt` 命名立即 spawn,`GC_CHAR_ADDITIONAL_INFO` 无 pending 丢弃、命中则 `mut_spawn_full` 合并;G1 已接线 `set_executable_name` / `set_npclist_path` / `mut_affect_flags` | locale 代码页转码(CP949/CP1252)、真实双连接、进场延迟 | | W2 实体 / 世界 / 战斗 | 🟡 开发中(Phase 1 批次已交) | 增量 50:§2.1 两层模型(`EntityStore` 纯数据层 + `net_world._update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点重建 + 上下马保坐标)、§2.4(点数快照清尾 / 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序刷新)已落地;`ctest` 16/16 + 7 项 GDScript 回归 + 新增 `net_world_vis_test` 全绿 | 远服端到端可见性 / VID 复用 / affect 表现验收;`state_flags` 表现层枚举 | -| W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛;§8.3 帮助窗(`help_ui.gd` = `helpwindow.py` + `uiScriptLocale.HELP_*` 逐字,`H` / ESC 菜单 `help_button` 入口,`help_ui_test` 绿)已落地 | §8.6 剩余逐行键位、LSHIFT 翻页、表情包;帮助窗视觉 / `.sub` 贴图与真服未验收 | -| W4 渲染 / 资产 / 音频 | 🟡 开发中 | 增量 50:§9.1 地图 BGM 选曲 + 音量(`bgm_director.gd` / 新 `musicinfo.gd`)、§9.2 后台音量存恢复(`audio.gd` / `app_lifecycle.gd`)已完成,`bgm_test` + 3 回归全绿;§9.3 设计已确认、落地推迟 Phase 2 | §9.3 由 W0 在 Phase 2 落地;BGM / 前后台真机验收 | -| W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | +| W3 玩法 UI / 输入 | 🟡 已有首版 | 增量 53 已收敛快捷栏 36/4×8、1–4 / F1–F4、LSHIFT 翻页、Ctrl/Command+数字表情、空格攻击、Alt 名字、截图及帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天 / 相机入口;增量 54 增加全局物品拖放、背包 / 快捷栏投放目标、PICK / TALK / ATTACK 光标和窗口键盘焦点屏蔽;增量 56 增加仓库 / 商城 / 交易 / 商店 / 私人商店的首版明确投放目标和商店 BUY/SELL 模式;增量 57 增加物品实例元数据、整组拖放、stack split、`ITEM_MONEY=-1` 金币态、交易 accept / 距离 / anti-give / 金币边界、NPC 商店 anti-sell / 售价确认、私人商店 anti-give / anti-myshop;增量 58 增加 236 字节 `item_proto` 固定字段和统一基础 tooltip,已接入背包 / 装备查看 / 商店 / 仓库 / 商城 / 交易 / 私人商店;增量 59 将 NPC 商店改为参考端 5×8 / 40 格货位和 BUY 点击确认;增量 60 补齐交易 sockets/attrs、任务日志 icon/clock 和常用事件标签;增量 61 接入 QuestButton 条带、任务小地图信号、Atlas center adjust 和对话图片资源加载;增量 62 接入技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间;相关 C++ / Godot 回归通过;§8.3 帮助窗已落地 | CJK IME、窗口视觉 / `.sub` 光标资源、tooltip 的完整本地化 / 其他特殊分支、完整交易事务、复杂窗口完整 mousemodule 规则、38 项 EventManager 逐事件表现和真服未验收 | +| W4 渲染 / 资产 / 音频 | 🟡 开发中 | 增量 50:§9.1 / §9.2;增量 51:AppFlow 单一生命周期;增量 53:EffectLib `.mse` 首个 `.dds`、位置、发射形状、方向 / 速度;增量 55:MilesLib 监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色声道、角色限频、stop / 限幅淡出;增量 63:`.mss` 解析、60 FPS 实例换算、`sound/` 虚拟路径和 PlayerView / MobView 动作帧播放;增量 64:`.mde` v1/v2 解析、几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65:`.mse` Light 组、范围 / 生命周期曲线、粒子多贴图帧延迟 / 顺逆序和旋转类型;增量 66:MovingType 直接 / 二次 Bezier 位置曲线、粒子 / 网格 / Light 共用位置和球体 edge 映射;增量 67:`AreaAmbienceData` + `.pra` 独立环境声源、LOOP / ONCE / STEP、距离音量和区块卸载停止;增量 68:`.msenv` 参考字段 / 缺省值 / `Reserved` / 云层贴图解析接入,macOS 路径大小写修正;增量 69:`Sun` / `CharacterLight` 的静态 / 角色 layer 1/2 分层;增量 70:texture-mode 六面天空 shader 和真实 CapeDragonHead 回归;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` 与场景回归全绿 | TexAni 随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree 真实几何 / 精确风常量、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare / 昼夜联动、完整声音节点生命周期、真实声音资源和真机验收 | +| W5 打包 / 发布 / 验收 | 🟡 开发中 | 增量 53 已实现 App 内自包含资源复制、universal 架构参数 / `lipo` 门禁、嵌套签名顺序和环境变量凭据 | 完整 2.2G 导出、当前机器双架构构建、正式证书 / notarization、跨机启动和 Mac App 验收 | ### C.4 每批次回写模板 @@ -3190,6 +4842,260 @@ var _pw := "123456789" ### C.5 批次记录 +```text +批次:2026-09-02 / W4 / §7.5(`.msenv` 解析、静态云层与 macOS 路径) +状态:🔎 待真实验收(格式解析、真实 A1 地图加载和静态云层回归完成;完整环境渲染 / 真机未验) +参考:40250/ClientVS22/source/GameLib/MapUtil.cpp:1-205(环境字段、缺省值与加载规则); + 40250/ClientVS22/source/GameLib/MapType.cpp:126-187(`TEnvironmentData` 字段布局); + 40250/ClientVS22/source/GameLib/MapOutdoor.cpp(环境应用与屏幕滤色行为) +改动: + formats/environment.h/.cpp —— 增加并解析 `Reserved`、Fog `IsDensity`、Filter、 + SkyBox `BTextureRenderMode`、六面天空文件名和参考缺省值; + extension/src/environment_builder.h/.cpp —— 通过 `AssetResolver` 读取真实云层贴图, + 接入 `ProceduralSkyMaterial.sky_cover`,其余暂不能等价渲染的原始参数写入元数据; + extension/src/metin2_world.cpp —— 保留 resolver 返回的真实大小写路径,修复 macOS 大小写敏感加载风险; + project/environment_test.gd —— 增加真实 A1 `.msenv`、云层材质和元数据断言; + project/world_probe.gd —— 按当前地图 patch 数递归统计地形节点,修正旧测试的区块粒度假设。 +测试:`cmake --build build/extension -j2`;`ctest --test-dir build/extension --output-on-failure -j1`; + `formats_map_test`(内联 + 真实 A1)、`environment_test`、`world_probe`、`audio_miles_test`、 + `fx_test`、`gamescene_test`、`quest_test`、`p8_test` 均通过;`fx_test` 保留既有 1 条 ObjectDB + leak warning,不影响断言结果。 +验收:未验证——六面天空实际渲染、云层 UV / 高度动画、滤色实际合成、双方向光分层、风参数、 + lens flare、昼夜天气联动、完整资源包和真机表现。 +风险与依赖:Godot 当前只直接承载基础环境和静态 sky cover;参考端的滤色接口在该版本实现中 + 实际禁用,不能仅凭字段值宣称滤色已经生效;风参数在 `Environment_Load` 中没有 token 读取, + 只能以参考缺省值保留;动态天气仍依赖后续读取 `MapOutdoor.cpp` 的环境选择 / 插值流程。 +下一批次:W4 继续云层动画、双方向光分层、滤色 / lens flare 与 SpeedTree;W5 执行完整 Mac App + 导出与 G1/G2;每批代码回归后立即回写本记录及 W4 看板。 + +批次:2026-09-02 / W4 / §7.5(双方向光分层) +状态:🔎 待真实验收(代码、离线环境 / 角色回归完成;真机视觉与 SpeedTree 真实光照未验) +参考:`40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264`(角色光传给 SpeedTree); + `40250/ClientVS22/source/GameLib/MapManager.cpp:239-247`(背景光 enable) +改动: + `extension/src/environment_builder.h/.cpp` —— 增加 `CharacterLight`,背景 / 角色 enable、颜色、 + 方向和元数据分别设置;`Sun` 使用 layer 1,`CharacterLight` 使用 layer 2; + `extension/src/metin2_world.cpp` —— 地形、水面、建筑、树代理固定 layer 1,卸载地图清理 + `Sun` / `CharacterLight`; + `extension/src/metin2_model.cpp` —— 角色本体、装备和 LOD 网格固定 layer 2; + `project/environment_test.gd`、`project/player_motion_test.gd` —— 增加两盏灯和角色网格层断言。 +测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、 + `environment_test.gd`、`player_motion_test.gd` 均通过(当前 CTest 15/15)。 +验收:未验证——真实 SpeedTree 光照、阴影质量、真机视觉和完整天气联动。 +风险与依赖:Godot 的 light cull mask 只能保证场景层隔离;SpeedTree 仍是 proxy,未把参考端 + `CSpeedTreeForest::SetLight` 的 shader 常量完整迁移。 +下一批次:W4 继续纹理天空、云层动态、滤色 / lens flare / 风参数;W5 继续 Mac App G2。 + +批次:2026-09-02 / W4 / §7.5(texture-mode 六面天空) +状态:🔎 待真实验收(真实资源和 shader 编译回归完成;纹理天空 + 云层组合、真机视觉未验) +参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:403-429,483-542`(六面顶点 / UV); + `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`(texture mode 设置与 Refresh) +改动: + `extension/src/environment_builder.cpp` —— 增加 Godot `sky` shader,按 M2 Z-up 到 Godot + x,y,-z 轴变换映射 front/back/left/right/top/bottom;通过 `AssetResolver` + 原生 DDS 解码 + 加载面贴图,空面以 `.msenv` 渐变回退;记录 `msenv_sky_face_count` 和 + `msenv_sky_texture_mode_applied` 元数据; + `project/skybox_test.gd` —— 加载真实 `metin2_patch_dragon_rock/metin2_map_capedragonhead` + 并断言五面贴图、空 bottom 面和 shader 安装。 +测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`、 + `environment_test.gd`、`skybox_test.gd` 均通过;首次回归发现 `sky` 的 `COLOR` 必须为 `vec3`, + 已修正并重新通过;无 skip。 +验收:未验证——纹理天空与云层同时存在时的叠加、云层 UV / 高度动画、滤色 / lens flare / 风、 + 昼夜切换和真机视觉。 +风险与依赖:Godot `Sky` 是无限天空,不直接消费参考的 `SkyBoxScale`;texture mode 且同时配置 + CloudTexture 时当前保留 ProceduralSkyMaterial 路径,避免丢云层,但六面 + 云层叠加仍是后续项。 +下一批次:W4 继续云层 shader / 动画和剩余环境后处理;W5 继续完整 Mac App 导出与 G2。 + +批次:2026-09-02 / W3 + W1 / §4.2 §4.5 §4.6 §4.7(物品实例字段、金钱态与边界规则) +状态:🔎 待真实验收(代码 + 离线回归完成;完整事务、资源、真服 / 真机未验) +参考:REF/GameLib/ItemData.h:196-204(`ANTIFLAG_SELL/GIVE/MYSHOP`)、:212(`COUNT_PER_1GOLD`); + REF/UserInterface/PythonPlayerModule.cpp:1098-1103(`Is1GoldItem`)、:1329-1366(`IsValuableItem`); + REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:455-482(出售包); + POC/assets/root/uiexchange.py(accept / 7 位金币 / 距离 / give); + REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:14-27(safebox money 发送函数主动返回 false) +改动: + extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析并暴露 item_proto 的 `anti_flags` / `flags`; + project/ui/mouse_controller.gd —— 保留实例元数据,增加 `ITEM_MONEY=-1` 独立金币持有态; + project/ui/inventory_ui.gd —— 普通拖放保留整组数量,Shift+左键堆叠物品先弹数量框,金币槽附着; + project/ui/exchange_ui.gd —— accept 服务端镜像、1000 距离退出、`ANTIFLAG_GIVE`、7 位金币上限、 + 已放金币不可编辑和金币投放; + project/ui/shop_ui.gd —— 实例 / 原型 `ANTIFLAG_SELL`、`IsValuableItem` 首次确认、 + 普通 / `Is1GoldItem` 售价公式和失败音效; + project/ui/private_shop_ui.gd —— `ANTIFLAG_GIVE/MYSHOP` 拦截; + extension/tests/proto_item_layout_test.cpp、project/shop_rules_test.gd —— 新增布局 / 出售规则回归。 +测试:`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure`; + `inventory_ui_test.gd`、`mouse_controller_test.gd`、`p8_test.gd`、`shop_rules_test.gd`、 + `private_shop_ui_test.gd`、`complex_item_drop_test.gd` 均通过。 +验收:未验证——完整交易事务 / 双账号回滚、真实 `.sub` 资源、40250 真服和真机输入;仓库金钱 + 不实现伪 `/safebox_money`,因为参考发送函数本身返回 false。 +风险与依赖:payload 的 socket / attribute 数据仍需由网络状态层完整承载;真实服务端回包是最终状态, + 离线规则测试不能替代真服业务验收;dragon_soul 按当前范围禁用。 +下一批次:W1/W3 补全 tooltip 的鱼竿 / 镐 / 龙魂等特殊分支,并处理 CJK 文案 / 字体;W4 继续 `.mss`、 + `.mde` 和真实资源;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W3 / §4.6(NPC 商店 5×8 货位与 BUY 点击确认) +状态:🔎 待真实验收(代码 + 离线回归完成;真实 `.sub` / 图标 / 非金币货币 / 真服未验) +参考:POC/assets/root/uishop.py:36-45、:226-227、:350-397(5×8 刷新、全局槽号、BUY 点击); + POC/assets/uiscript/uiscript/shopdialog.py:47-62(ItemSlot 5×8); + REF/UserInterface/PythonNetworkStreamPhaseGameItem.cpp:401-429(购买包固定 count=1) +改动: + project/ui/shop_ui.gd —— 用 5×8 / 40 格按钮网格替换纵向商品行;按货架计算全局 pos;空位禁用; + 非空货位 tooltip;BUY 点击购买确认,确认固定 `shop_buy(pos, 1)`;保留 SELL 投放和已有出售规则; + project/p8_test.gd —— 改为验证 40 格、购买确认和多货架槽位 5 / 43。 +测试:`p8_test.gd` 通过;`git diff --check` 通过。 +验收:未验证——真实 `.sub` 槽位 / 按钮贴图、物品图标、非金币 `coin_type`、40250 真服和真机。 +风险与依赖:购买数量必须保持 1,不能把库存 count 当作购买数量;真实 UI 资源接入前不宣称视觉 1:1。 +下一批次:W3 继续本地化 / 特殊 tooltip,W4 继续 `.mss` / `.mde` / SpeedTree,W5 执行完整 Mac App + 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W1 + W3 / §4.2 §4.3(item_proto 固定字段与统一基础 tooltip) +状态:🔎 待真实验收(代码 + 离线回归完成;特殊物品分支、资源视觉、真服 / 真机未验) +参考:REF/GameLib/ItemData.h:13-72(236 字节 TItemTable、limit/apply/value/socket/refine 字段); + POC/assets/root/uitooltip.py:970-1393(AddItemData 顺序与物品类型分支); + POC/assets/locale/locale/en/itemdesc.txt(描述数据) +改动: + extension/src/proto/proto.h/.cpp/.proto_node.cpp —— 解析 / 暴露 limits[2]、applies[3]、 + sockets[3]、refined_vnum、refine_set、alter_to_magic_pct、specular、gain_socket_pct; + project/ui/item_tooltip.gd —— 统一 item_proto + itemdesc + 实例 attrs/sockets 的基础提示, + 对齐武器攻击 / 魔攻、防具防御公式、限制、applies、属性、魂石孔和精炼关联; + project/ui/inventory_ui.gd、view_equipment_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、 + exchange_ui.gd、private_shop_ui.gd —— 接入同一个提示构造器; + extension/tests/proto_item_layout_test.cpp、project/item_tooltip_test.gd —— 锁定布局和提示内容。 +测试:`cmake --build build/extension -j2`;C++ CTest 全套(首次 `net.classic_encstream` 瞬时失败, + 定向重跑通过,需在集成前再重跑全套);`inventory_ui_test.gd`、`item_tooltip_test.gd` 通过。 +验收:未验证——鱼竿 / 镐经验、龙魂计时、技能书 / 变身特殊分支、装备对比、CJK / 真 `.sub`、 + 40250 真服和真机。 +风险与依赖:参考端普通 item socket 固定为 3 个,龙魂 subtype 槽位数为 6,不能混用;装备对比 + 在参考源码中没有找到对应方法,后续若需要应单独定义产品规则;真实服务端实例字段优先。 +下一批次:W3 继续特殊 tooltip / 本地化,W4 继续 `.mss` / `.mde` / SpeedTree,W5 执行完整 Mac App + 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W3 / §4.2 §4.4 §4.5 §4.6 §4.7(复杂窗口物品态与商店模式) +状态:🔎 待真实验收(代码 + 离线回归完成;完整原版 UI / 资源 / 真服 / 真机未验) +参考:POC/assets/root/mousemodule.py:171-343(全局物品态);POC/assets/root/uisafebox.py:357-451 + (仓库 45 格分页与 SelectEmptySlot / SelectItemSlot);POC/assets/root/uishop.py:226-291 + (BUY/SELL 光标模式与出售规则);REF/UserInterface/PythonApplication.h:63(光标状态) +改动: + project/ui/safebox_ui.gd —— 5×9 网格、按 size 分页、指定格 checkin/checkout、仓库内 move; + project/ui/mall_ui.gd —— 45 格分页网格和商城→背包投放; + project/ui/exchange_ui.gd —— 12 个指定交易格的背包 / 装备投放; + project/ui/shop_ui.gd —— 出售投放目标、BUY/SELL toggle、模式显隐、BUY/SELL 光标联动; + project/ui/inventory_ui.gd、quickbar.gd、game_scene.gd —— 复杂窗口投放装配和来源窗口回传; + project/complex_item_drop_test.gd(新)—— 仓库 / 商城 / 交易 / 商店落点与购买模式保护回归。 +测试:`complex_item_drop_test.gd`、`p8_test.gd`、`private_shop_ui_test.gd`、`inventory_ui_test.gd` + 均 exit 0 并输出 PASS;C++ 既有 CTest 18/18 保持通过。 +验收:未验证——`shopdialog.py` / `privateshopbuilder.py` 的完整 `.sub` 视觉、金钱附着、stack split、 + anti-flag / `IsValuableItem` 售价确认、交易事务回滚、完整 pick/drop 音效、40250 真服和真机输入体验。 +风险与依赖:当前落点调用已有 wire API,最终状态仍由服务端回包决定;`deposit()` 等旧右键入口保留为 + 兼容路径,不代表原版自动找空位是鼠标落点语义;dragon_soul 按当前 40250 范围继续禁用。 +下一批次:W3 补物品金钱态、stack split、anti-flag / 售价确认和交易状态;W4 继续 `.mss` / EffectLib; + W5 执行完整 Mac App 导出与 G1/G2 验收。 +``` + +```text +批次:2026-09-02 / W4 / §9.4(MilesLib 音频子集) +状态:🔎 待真实验收(代码 + 离线回归完成;`.mss` / 完整声音节点 / 真服 / 真机未验) +参考:REF/MilesLib/SoundManager.cpp:20-21(200/1000 缩放)、:72-89(监听器更新)、 + :245-250(ratio/grade 音量换算)、:349-420(普通 / 环境 / 角色 3D 播放与 5000cm、0.3 秒限频)、 + :510-545(限幅淡出 / 全部淡出);REF/MilesLib/SoundManager.h:23-52(公开 API) +改动: + project/audio.gd —— 新增 `set_listener/update_listener`、`SOUND_SCALE=200`、环境音 `1000`、 + 参考音量曲线、普通 / 环境 / 角色 3D 播放、活动实例位置重基准、play_count、单实例 / 全部停止、 + 角色声音距离与频率保护、音乐限幅淡出; + project/game_scene.gd —— 每帧更新 Miles listener;`.msa` 本地动作 sound 事件走角色声道; + project/ui/quickbar.gd —— assign 返回投放成功状态,失败时不清除拖放语义; + project/audio_miles_test.gd(新)—— 常量、换算、缩放 setter、5000cm / 0.3 秒规则回归。 +测试:`godot --headless --path project --script audio_miles_test.gd` → PASS(exit 0)。 + 音频测试使用无资产纯规则 fixture,不将音频文件缺失误报为资源播放通过。 +验收:未验证——`.mss` 解析、`.msenv` 环境参数 / 天气联动、完整 `UpdateSoundData/UpdateSoundInstance` 数据、 + 武器 / 技能 / 怪物 / NPC 事件,真实 40250 服务端和真机空间听感。 +风险与依赖:当前系统设置滑条继续写入 0..1 的应用音量;ratio / grade API 已提供但尚未由全部设置入口 + 切换。Godot 3D 音频实例使用统一空间根节点,具体 unit_size / attenuation 仍需受控参考帧和真机校准。 +下一批次:W4 读取 `SoundData.cpp` 完成 `.mss` / sound-node;W3 补复杂窗口 mousemodule / CJK IME; + W5 继续完整 Mac App 导出和发布验收。 +``` + +```text +批次:2026-09-02 / W3 / §4.2 §4.9(全局物品拖放、光标状态、窗口焦点) +状态:🔎 待真实验收(代码 + 离线回归完成;复杂窗口 / 真实资源 / 真服 / 真机未验) +参考:POC/assets/root/mousemodule.py:171-343(AttachObject / DeattachObject / payload); + REF/UserInterface/PythonApplication.h:63、POC/assets/root/mousemodule.py:100(光标枚举 / 映射); + REF/UserInterface/GameType.cpp:90-106(slot type → inventory window) +改动: + project/ui/mouse_controller.gd(新)——全局 item payload、鼠标跟随预览、有效目标投放、无效区域 / + 右键 / ESC / 失焦取消; + project/ui/cursor_manager.gd(新)——NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM 状态; + project/ui/inventory_ui.gd —— inventory / equipment / belt target 注册、全局拖放投放、装备 wire + 坐标修正、兼容缺少 belt API 的测试桩; + project/ui/quickbar.gd —— inventory item → quickslot 投放; + project/ui/ui_manager.gd ——窗口打开时拦截不应穿透到 PlayerController / GameCamera 的键盘事件; + project/player_controller.gd、ground_items.gd、net_world.gd、game_scene.gd ——悬停光标、实体点选 + 注册、地面物品 PICK 和 UI 装配;quest_log.gd —— Ctrl+Q 状态入口; + project/mouse_controller_test.gd、input_key_test.gd —— attach / drop / cancel 与 Ctrl+Q 回归 +测试:`mouse_controller_test.gd`、`inventory_ui_test.gd`、`input_key_test.gd`、`gamescene_test.gd`、 + `ui_test.gd`、`system_menu_ui_test.gd` 均 exit 0 并输出 PASS;`git diff --check` 通过。 +验收:未验证——safebox / mall / shop / exchange / private_shop 的完整 SelectEmptySlot / + SelectItemSlot 分支、金钱附着 / stack split、真实 `.sub` 光标贴图、CJK IME、40250 真服和真机体验。 +风险与依赖:当前光标没有 `.sub` 资源时使用 Godot 系统光标;Ctrl+Q 的迁移层保留按钮可见状态, + QuestButton 已有独立条带和接口;仍需原版滚动 / 多列定位、资源视觉和服务端回包验收。 +下一批次:W3 继续接入复杂物品窗口的 mousemodule 分支 / 音效与 CJK IME;W4 MilesLib;W5 + 完整 App 导出与发布验收。 +``` + +```text +批次:2026-09-02 / W3-W5 / §4.8 §6.3 §8.6 §10(快捷栏、输入、EffectLib、Mac 打包) +状态:🔎 待真实验收(代码 + 离线回归完成;真实服务 / 完整资源包 / 正式签名未验) +参考:POC/assets/root/game.py:309-431、509(键位 / 快捷栏);REF/UserInterface/GameType.h:140,307 + (ESlotType / TQuickSlot);REF/UserInterface/Packet.h:392(36/32 槽); + REF/EffectLib/EmitterProperty.h:12-15、ParticleSystemInstance.cpp:106-181、 + ParticleSystemData.cpp:282-287(发射器 / TextureFiles);§10 为本工程发布要求 +改动: + project/ui/quickbar.gd —— 服务器 36 槽、客户端 4×8、本地槽类型 1/2/3、表情槽和服务器恢复 + extension/src/net/m2_client.h/.cpp —— send_emoticon()、12 个参考 token、表情信号及快捷栏类型校正 + project/game_scene.gd、game_camera.gd、net_play.gd、net_world.gd、ui/chat_ui.gd —— 参考键位的 + 按下 / 松开、连续攻击、相机、名字牌、截图和窗口入口 + project/input_key_test.gd、skill_test.gd、netplay_test.gd、netbridge_test.gd —— 离线回归覆盖 + project/fx/effect_player.gd —— `.mse` 位置 / 形状 / 方向 / 速度及首个 TextureFiles → DDS + build.sh、build-macos-client.sh —— CMAKE_OSX_ARCHITECTURES、App 内复制 assets/bgm、lipo 门禁、签名顺序 + project/app_flow.gd、login.gd、charselect.gd —— 去除硬编码账号密码,自动登录改为显式环境变量 +测试:`fx_test.gd`、`input_key_test.gd`、`skill_test.gd`、`netplay_test.gd`、`netbridge_test.gd`、 + `gamescene_test.gd` 均 exit 0 并输出 PASS;`bash -n build.sh build-macos-client.sh`、`git diff --check` 通过; + C++ 扩展已有构建 / CTest 18/18 证据。Godot 回归串行执行,fx_test 仅有 1 个 ObjectDB leak warning, + 不影响返回码。 +验收:未验证——当前机器已有扩展仍是 arm64;完整 2.2G Mac App 尚未导出;没有 Developer ID / + notarization / stapled ticket;没有跨机资源隔离和 40250 真服 / macOS F1–F12 实机证据。 +风险与依赖:SimpleLight / TexAni 的完整随机与每粒子语义、MovingType / 完整时间轴、普通物品鼠标态、CJK IME 和 MilesLib 声音 + 事件模型仍未实现;universal 构建需要完整工具链 / 模板;`CODESIGN_IDENTITY` 不配置时仍为 ad-hoc。 +下一批次:W3 §4.2 物品态 / 光标与输入焦点;W4 `.mde` / MilesLib;W5 完整导出后执行 G1/G2; + 真实服务端到位后补 DirectEnter / Warp / 玩法矩阵。 +``` + +```text +批次:2026-09-02 / W0-W2 / §3.9(公共网络 API 参数边界) +状态:🔎 待真实验收(代码 + 离线回归完成;业务语义和真服未验) +参考:REF/UserInterface/PhaseGame.cpp:1132(地图坐标断言);REF/UserInterface/PythonPlayer.cpp:800/815/843/852/861 + (快捷栏边界);REF/UserInterface/PythonPlayerEventHandler.cpp:196/104(同步 16、动作索引); + REF/UserInterface/GameType.h(36/40/12 等数组容量);40250/ClientVS22/source 的 Classic wire 定长字段 +改动: + extension/src/net/net_bounds.h(新)——窄整数、快捷栏 / 商店 / 私人商店 / 交易 / Cube / 龙魂 / 点数组边界 + extension/src/net/m2_client.cpp —— 连接端口、角色创建、聊天 / 技能 / 社交 / 商店 / 仓库 / 私人商店 / + Cube / 公会 / 精炼 / 物品操作在 static_cast 前统一拒绝溢出和非法 Variant;移除静默截断 / 负数归一化 + extension/tests/net_bounds_test.cpp(新)、extension/CMakeLists.txt —— CTest net.bounds +测试:cmake --build build/extension -j2 通过;ctest --test-dir build --output-on-failure → 18/18; + Godot 离线回归按独立 --log-file 串行执行,受影响脚本均 exit 0 且输出 PASS。无新增 skip。 +验收:未验证——无真实 40250 服务端的完整业务包回执;Godot 测试中的 macOS 证书探测 / 资源释放提示 + 不影响脚本返回码,但不计作真实平台验收。 +风险与依赖:net_bounds 只表达已核实 wire / 数组容量,不替代技能冷却、目标类型、背包规则和服务端权限; + `M2Client` 的字段值仍需在真服对照服务端拒绝 / 接受行为。 +下一批次:W3 §8.6 剩余键位 / 表情与物品态,或 W4 §6.3 EffectLib,继续按文件主维护者与 G1 门禁同步。 +``` + ```text 批次:2026-09-02 / W0 / §2.1 §2.3 §2.5 §8.6 §9.1(接口冻结,Phase 1 前置) 状态:🟡 开发中(骨架落地;语义补全在 Phase 1 各工作流) @@ -3359,5 +5265,2924 @@ var _pw := "123456789" 风险与依赖:§9.3 需 W0 在 Phase 2 把 AppLifecycle 属主收敛到 AppFlow、M2Client 去掉 NOTIFICATION_* —— 本批次按约束未改 game_scene.gd/app_flow.gd/m2_client.*; set_music_volume 只作用于当前 _bgm[_bgm_cur] 声道 -下一批次:Phase 2 由 W0 执行 §9.3 落地 + G1 集成与文档合并 +下一批次:W0 已完成 §9.3 代码落地;下一批次由 W5 / 平台验收补真机证据,W4 继续 MilesLib +3D 声音模型;G1 集成回归保持串行并使用独立 Godot 日志文件 +``` + +```text +批次:2026-09-02 / W0 / §9.3(生命周期单一属主) +状态:✅ 完成(代码 + 离线回归);🔎 真机 / 真服断线重连待验收 +参考:REF/UserInterface/PythonApplication.cpp + PythonApplicationEvent.cpp(应用层统一驱动); + REF/EterLib/NetStream.cpp:332/343/351、REF/UserInterface/PythonNetworkStreamEvent.cpp:4 +改动: + project/app_flow.gd —— 生产入口创建唯一 AppLifecycle;登录阶段解绑旧 GameScene 音频, + 游戏场景 setup 完成后绑定当前 Audio + project/game_scene.gd —— 删除本地 AppLifecycle 创建,新增 lifecycle_audio() 作为绑定接口 + extension/src/net/m2_client.h/.cpp —— 删除 NOTIFICATION_APPLICATION_PAUSED/RESUMED + 监听,保留 suspend()/resume() 供 AppLifecycle 驱动 + project/login.gd —— 兼容启动器保留单一生命周期节点贯穿登录→游戏,不再进场销毁 + project/app_flow_lifecycle_test.gd(新)、project/gamescene_test.gd —— 单一属主 / 无重复节点回归 +测试: + godot --headless --path project --log-file /tmp/app_flow_lifecycle_test.log + --script app_flow_lifecycle_test.gd → PASS + godot --headless --path project --log-file /tmp/gamescene_lifecycle_test.log + --script gamescene_test.gd → PASS + 两个 Godot 进程并行时曾因共用 user://logs / 图形上下文触发 exit 134;改用独立日志文件串行 + 执行后通过。后续 Godot 回归不得并行启动。 +验收:未验证 Android/iOS 前后台通知、内存告警、后台 TCP 回收后的重连;无真实 40250 服务端。 +风险与依赖:AppFlow 是 `client_main.tscn` 的生产路径;`login.gd` 是旧兼容启动器,保留其自身 + 单一协调者不等同生产路径重复。若后续删除兼容启动器,应同步移除其专属生命周期代码。 +下一批次:W5 真机 / Mac App 验收;W4 补 MilesLib 3D listener、距离衰减和播放模型。 +``` + +```text +批次:2026-09-02 / W1 + W3 / §4.5 §5.1 §5.2(交易实例字段与任务显示 / 标签解析) +状态:🔎 待真实验收(本批范围代码 + 离线回归完成;完整事务、完整 EventManager 和真实资源未验) +参考:REF/UserInterface/Packet.h / PythonNetworkStreamPhaseGameItem.cpp(GC_EXCHANGE 的 3 sockets + + 7 attrs);REF/UserInterface/PythonEventManager.cpp:377-790(38 项标签分派); + POC/assets/root/uicharacter.py:657-723(任务 icon / counter / clock 展示) +改动: + extension/src/net/entity_store.h/.cpp —— ExchangeSlot 增加 sockets[3] / attrs[7];m2dev + GC_EXCHANGE ITEM_ADD 和 `mut_exchange_item()` 复制完整实例状态; + extension/src/net/classic/classic_parser.cpp —— classic GC_EXCHANGE 将 ItemAttr3 转为统一 + ItemAttr 后写入交易状态; + extension/src/net/m2_client.cpp —— get_exchange() 输出 sockets / attrs,供 GDScript tooltip 使用; + project/ui/quest_log.gd —— 读取 icon,资源存在时显示;clock 按参考分钟 / 秒格式倒计时,资源缺失安全回退; + project/ui/quest_dialog.gd —— 识别 COLOR/COLOR256、ITEM/MOB、WINDOW_SIZE、居中、CONFIRM_WAIT、 + IMAGE/TITLE_IMAGE 元数据和 DELAY/WAIT;game_scene.gd 传入 proto / assets_root; + project/ui/friend_ui.gd —— 确认框答复后主动隐藏,避免连续模态框残留; + extension/tests/net_entity_test.cpp、net_classic_session_test.cpp、project/quest_test.gd —— + 交易 sockets/attrs、任务标签、任务时钟回归。 +测试:`cmake --build build/extension -j2` 通过;`ctest --test-dir build/extension -R + 'net.entity|net.classic_session' --output-on-failure` 2/2 通过;`p8_test.gd`、`item_tooltip_test.gd`、 + `inventory_ui_test.gd`、`private_shop_ui_test.gd`、`complex_item_drop_test.gd`、`shop_rules_test.gd`、 + `mouse_controller_test.gd`、`quest_test.gd` 均 exit 0 并输出 PASS;修复私店测试中的 GDScript 类型推断错误。 +验收:未验证——完整交易双账号回滚 / 背包锁定、EventManager 逐事件等待 / 立绘 / 地图信号 / 相机 / 电影、 + 真实 `.sub` 图标、CJK IME、40250 真服和真机。 +风险与依赖:交易实例数组现在可用于提示,但最终物品状态仍以服务端回包为准;任务网络 cue 仍是完整文本, + 不能把标签识别当作 38 项 EventSet 迁移;任务 icon 沿用可选资源加载,找不到资源时不伪造图片。 +下一批次:W3 继续 EventManager 逐事件状态 / QuestButton / minimap signal 或 CJK 输入;W2 继续交易事务状态; + W4 继续 `.mss` / `.mde` / SpeedTree;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W3 / §5.1 §5.2 §9.5(任务信件、任务地图信号与对话图片) +状态:🔎 待真实验收(代码 + 离线回归完成;完整 EventSet、资源视觉、真服 / 真机未验) +参考:REF/UserInterface/PythonEventManager.cpp:554-614、:571-602(地图信号、QuestButton、Atlas center); + REF/UserInterface/PythonEventManager.cpp:504-532、:679(对话图片回调); + POC/assets/root/uiquest.py:1290-1409(任务信件接收、删除、点击、抑制和布局) +改动: + project/ui/quest_dialog.gd —— 增加 `QUESTBUTTON/QUESTBUTTON_CLOSE`、 + `ADDMAPSIGNAL/CLEARMAPSIGNAL/SETCMAPPOS`、相机 / 淡入淡出事件信号;对话图片在资源存在时 + 实际创建 `IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE/TITLE_IMAGE` 节点,空脚本也会先发 side effect; + project/ui/quest_log.gd —— 新增独立 QuestButton 条带,支持接收 / 替换 / 删除 / 点击 + `script_button(index)`、highlight 图标类型、对话打开抑制和资源 / item 图标解析; + project/ui/minimap.gd —— 保存 / 清除 / 绘制 EventManager 任务信号点; + project/ui/atlas_ui.gd —— 接入 `SetCenterPositionAdjust` 的 screen-space 偏移; + project/game_scene.gd —— 连接任务对话、QuestLog、Minimap 和 Atlas 的公开信号; + project/quest_test.gd —— 增加 QuestButton、地图信号、相机 / fade 元数据、条带点击回归。 +测试:`quest_test.gd`、`p9_test.gd`、`gamescene_test.gd` 均串行运行并输出 PASS;三项脚本 + `--check-only` 通过;此前并发 Godot 检查曾触发共享 MoltenVK / user://logs 崩溃,后续统一串行。 +验收:未验证——完整 38 项 EventSet 逐事件等待 / 分页、立绘 / 电影 / 相机实际表现、真实 `.sub` + 资源、40250 真服和真机输入 / 视觉。 +风险与依赖:QuestButton 的服务端回包仍是最终状态;地图信号按参考接口保存为绝对服务器厘米, + `SETCMAPPOS` 按参考解释为 Atlas screen-space center adjust,不当作角色传送;缺失图片只安全跳过。 +下一批次:W3 继续 EventSet 时间轴 / fade / camera / CJK IME;W2 继续完整交易事务;W4 继续 + `.mss` / `.mde` / SpeedTree;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W3 / §4.3(tooltip 特殊物品分支与实时限时) +状态:🔎 待真实验收(代码 + 离线回归完成;完整本地化、其他特殊分支、真实资源和真服 / 真机未验) +参考:POC/assets/root/uitooltip.py 的技能书、遗忘书、变身道具标题分支;40250 + ClientVS22/GameLib/ItemData.h 的 `limits[2]` / `sockets[3]` 固定布局;本地 + `locale/locale//skilldesc.txt` 与 mob 原型名称表。 +改动: + project/ui/item_tooltip.gd —— `setup()` 接收 mob 原型源并加载技能描述名;根据技能书 / 遗忘书 + 的 `sockets[0]` 生成技能名标题,根据变身道具 `sockets[0]` 生成怪物名标题;根据限制类型 + 7/8/9 与实例 socket 结束时间 / 持续时间生成剩余分钟 / 秒提示。 + project/ui/inventory_ui.gd、shop_ui.gd、safebox_ui.gd、mall_ui.gd、exchange_ui.gd、 + view_equipment_ui.gd、private_shop_ui.gd —— 统一把 mob 原型源传入 tooltip。 + project/item_tooltip_test.gd —— 增加技能书、变身道具、实时限时三类断言。 +测试:`item_tooltip.gd --check-only` 通过;`item_tooltip_test.gd` 输出 PASS;此前物品窗口、商店、 + 交易、拖放回归保持通过。 +验收:未验证——CJK 文案 / 字体切换、完整技能描述、其他物品特殊分支、真实 `.sub` / 音频资源、 + 40250 真服实例时间字段和 Mac 真机显示。 +风险与依赖:当前只把参考端已确认的技能 / 怪物名称映射接入;未发现的特殊分支不凭经验扩展。 + 限时提示依赖实例 socket 或显式 remaining 字段,最终过期状态仍以服务端物品回包为准。 +下一批次:W4 继续 `.mss` 声音脚本解析;W3 继续 CJK IME / 其他 EventManager;W2 继续完整交易事务; + W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W4 / §9.4(.mss 声音脚本与动作帧播放) +状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni / .msenv / 完整节点生命周期 / 真实资源和真机未验) +参考:40250/ClientVS22/source/MilesLib/Type.cpp:17-74(LoadSoundInformationPiece)、:118-136 + (DataToInstance);GameLib/RaceMotionData.cpp:459-562(.msa → sound/.mss 配对); + MilesLib/SoundManager.cpp:148-172(UpdateSoundData / UpdateSoundInstance)。 +改动: + project/audio.gd —— 解析 `SoundDataCount` / `SoundData%02d`,按参考转换 60 FPS frame;补齐 + `sound/` 虚拟前缀到 patch sound 根的查找;新增 `load_mss_for_motion()` 和 + `update_sound_instances_3d()`。 + project/ui/player_view.gd、project/ui/mob_view.gd —— 每次动作进入新帧时消费配对 `.mss` 实例, + 通过角色声音入口触发,并在动作切换 / 循环时重置帧游标。 + project/game_scene.gd —— 将 Audio 绑定到本地 PlayerView 和远端 MobView。 + project/audio_miles_test.gd —— 增加真实 `.mss` 记录、虚拟路径、60 FPS 换算和 `.msa` 配对断言; + project/player_motion_test.gd、project/mob_view_test.gd —— 增加玩家 / 怪物动作配对断言。 +测试:`audio.gd`、`ui/player_view.gd`、`ui/mob_view.gd` `--check-only` 通过; + `audio_miles_test.gd`、`player_motion_test.gd`、`mob_view_test.gd` 串行运行并输出 PASS。 +验收:未验证——效果实例完整声音生命周期、`.msenv` 环境参数 / 天气联动、所有武器 / 技能 / 怪物 / NPC + 资源逐项听感、40250 真服实体动作和 Mac 真机空间听感。 +风险与依赖:`.mss` 只描述触发时刻和虚拟路径,音频解码 / 3D 衰减仍由 Godot 音频层承担; + 缺失声音资源会安全跳过,不把文件存在误报为可听感通过。Godot 回归继续严格串行。 +下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet; + W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W4 / §7.5(SpeedTree 风缺省) +状态:🔎 待真实验收(参考缺省与 proxy 回归完成;真实 SpeedTree 视觉未验) +参考:`40250/ClientVS22/source/GameLib/MapUtil.cpp:41-42`(`fWindStrength=0.2`、`fWindRandom=0`); + `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:257-264`(角色光与风强度传给 SpeedTreeForest) +改动: + `extension/src/tree_placeholder.cpp` —— 将叶片 proxy 的 `wind_strength` 从错误的 `1.0` 修正为 + 参考缺省 `0.2`,并注明 `Environment_Load` 不读取风 token; + `extension/src/environment_builder.cpp` —— 将 `msenv_wind_strength=0.2`、 + `msenv_wind_random=0.0` 写入 `WorldEnvironment` 元数据; + `project/environment_test.gd` —— 增加两个缺省值断言。 +测试:`cmake --build build/extension -j2`、`environment_test.gd`、`skybox_test.gd` 均通过。 +验收:未验证——真实 SpeedTree 几何、森林级风 shader、随机风语义和真机视觉。 +风险与依赖:当前 `.spt` 仍是程序化 proxy;不能把 proxy 的确定性摆动等同于 + `CSpeedTreeForest::SetWindStrength` 的完整实现。 +下一批次:W4 继续纹理天空 + 云层叠加 / 云层动画;W5 继续 Mac App G2。 +``` + +```text +批次:2026-09-02 / W4 / §7.5(texture-mode 天空与云层组合) +状态:🔎 待真实验收(真实 DawnMistWood 资源、shader 参数和 shader 编译回归完成;真机视觉未验) +参考:`40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557`(云面四边形与 UV); + `40250/ClientVS22/source/EterLib/SkyBox.cpp:869-927`(云面高度、纹理矩阵、滚动和颜色 / blend); + `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:291-323`(SkyBox 参数应用) +改动: + `extension/src/environment_builder.cpp` —— texture mode 不再因存在 CloudTexture 而回退, + sky shader 增加有限水平云面:CloudScale / CloudHeight 从参考厘米换算为 Godot 米, + CloudTextureScale / CloudSpeed 映射到 UV 缩放和 `TIME` 秒级滚动;按参考 + `MODULATEINVALPHA_ADDCOLOR` + `ONE/INVSRCCOLOR` 合成云色;渐变天空继续使用 sky cover; + 新增 `msenv_sky_cloud_applied` 元数据。 + `project/skybox_test.gd` —— 增加真实 `metin2_map_dawnmistwood`,断言五面天空、CloudTexture、 + 2000m 云面、0.001 秒级速度和 shader 组合。 +测试:`git diff --check`、`cmake --build build/extension -j2`、`ctest --test-dir build/extension --output-on-failure -j1`、`environment_test.gd`、`skybox_test.gd` 通过; + skybox_test 串行加载真实 CapeDragonHead + DawnMistWood,未出现 shader 编译错误。 +验收:未验证——云层渐变过渡、滤色实际合成、SpeedTree 完整风语义、lens flare、昼夜天气联动、 + 完整资源包和真机视觉。 +风险与依赖:Godot sky shader 是对参考独立云面 pass 的等价投影近似;当前只应用 CloudColor + 首个颜色,完整 `StartTransition` / `SetCloudColor` 时间过渡仍待迁移;`SkyBoxScale` 与 D3D + 透视裁剪也没有 1:1 的 Godot sky 参数。 +下一批次:W4 继续云层颜色过渡、滤色 / lens flare、SpeedTree 真实几何;W5 继续 Mac App G2。 +``` + +```text +批次:2026-09-02 / W4 / §6.3(EffectElement MovingType 与发射边缘) +状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 advanced / cycle、完整时间轴、真机视觉未验) +参考:40250/ClientVS22/source/EffectLib/EffectElementBase.cpp:5-60(位置关键帧边界、直接插值、二次 + Bezier);EmitterProperty.h/.cpp(POINT / ELLIPSE / SQUARE / SPHERE、FREE / OUTER / INNER、edge); + ParticleSystemInstance.cpp:106-181,286-296(发射边缘、advanced 速度和 cycle 生命周期)。 +改动: + project/fx/effect_player.gd —— 新增 `_position_at()`,按参考在关键帧前后钳制、直接段线性插值、 + Bezier 段使用 `p0 + controlPoint` 控制点;粒子、Mesh 和 Light 节点按同一曲线更新;球体 + `EmitterEmitFromEdgeFlag` 映射为 Godot `SPHERE_SURFACE`,并保存 advanced / edge 元数据。 + project/fx_test.gd —— 增加直接段中点、二次 Bezier 中点、球体 edge 和 advanced 元数据断言。 +测试:`effect_player.gd --check-only`;`fx_test.gd` 串行运行并输出 PASS;C++ 扩展构建通过。 +验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、OUTER / INNER 速度、完整 emitter cycle / + loop、所有时间曲线和 Actor 骨骼挂点;真实 40250 效果逐项视觉、真服和 Mac 真机未验。 +风险与依赖:当前 edge 只对球体映射为 Godot 有对应的 surface shape;ELLIPSE / SQUARE 的精确 + 边缘采样、advanced 速度和 per-particle custom data 仍需继续读取参考实现,不以元数据保存冒充完成。 +下一批次:W4 继续 emitter advanced / cycle、TexAni 随机 / 每粒子语义或 `.msenv`;W3 继续 CJK IME / + 完整 EventSet;W2 继续交易事务;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W4 / §9.4(AreaAmbienceData 与 Miles 环境声) +状态:🔎 待真实验收(代码 + 离线 / 真实地图回归完成;`.msenv` 环境参数联动、完整资源听感、真服 / 真机未验) +参考:40250/ClientVS22/source/GameLib/Area.cpp:882,1316-1394(环境声文件加载、严格范围判断、 + LOOP / ONCE / STEP 生命周期与距离音量);MapType.cpp:195-260(Ambience `.pra` 字段)。 +改动: + extension/src/metin2_world.cpp/.h —— C++ 世界层解析每个已加载区块的 `AreaAmbienceData`,由 + `.pra` CRC 提供 position / range / max volume / play type / interval / sound vector;与对象 + 渲染和 splat 开关解耦,支持 `areaambiencedata.txt` / `AreaAmbienceData.txt`,区块卸载时清理来源。 + project/audio.gd —— 对齐参考的 `< range`、LOOP 距离音量、ONCE 进入边沿、STEP 间隔 + 随机变化、 + 离开区域停止;Miles 3D 相对坐标先从世界米换回厘米再除以缩放。 + project/game_scene.gd —— 每帧消费世界层 `get_ambience_sources()`;project/ambience_test.gd —— + 使用真实 OutdoorA3/003003 和 `warp_test.mp3` 验证来源、`.pra` 字段和路径。 +测试:`cmake --build build/extension -j2` 通过;`ambience_test.gd`、`audio_miles_test.gd` 串行运行 + 并输出 PASS;`formats_map_test`(含真实 A1 地图)通过。 +验收:未验证——`.msenv` 的云层 / 滤色 / 风 / lens flare / 双方向光 / 昼夜天气联动、全部环境声音资源、 + 长时间循环与切图听感、 + 40250 真服和 Mac 真机空间听感。 +风险与依赖:环境声来源现在随区块加载 / 卸载变化,Audio 以 key 做实例生命周期;资源缺失会安全 + 跳过,不能把来源发现成功等同于音频解码和真实听感通过。 +下一批次:W4 继续 `.msenv` / 完整声音节点生命周期或 emitter advanced;W3 继续 CJK IME / 完整 + EventSet;W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W4 / §6.3(SimpleLight / TexAni 基础子集) +状态:🔎 待真实验收(代码 + 离线回归完成;随机起始帧 / 每粒子语义、完整 MovingType / 时间轴、 + Direct3D 材质 stage、SpeedTree、`.msenv`、真实资源逐项视觉和真机未验) +参考:40250/ClientVS22/source/EffectLib/SimpleLightData.cpp(默认值、字段加载、GetRange 曲线); + SimpleLightInstance.cpp(生命周期、loop / loop count、范围 / 颜色 / 位置更新); + ParticleProperty.h / ParticleSystemInstance.cpp(旋转类型和 TexAni 基础语义)。 +改动: + project/fx/mse.gd —— 暴露 Light 组及 `MaxRange`、ambient/diffuse、attenuation、duration、loop / + loop count、`TimeEventRange`。 + project/fx/effect_player.gd —— 将 Light 组构建为 `OmniLight3D`,按范围曲线和生命周期更新;粒子 + 多贴图支持 `TexAniDelay`、顺 / 逆序,旋转类型区分 `NONE/TIME_EVENT/CW/CCW`。 + project/fx_test.gd —— 增加 Light 解析、范围曲线、开始时间和生命周期可见性断言。 +测试:`fx_test.gd` 通过;此前 `.mde`、`.mss`、玩家 / 怪物动作和场景回归保持通过;`git diff --check` + 通过。 +验收:未验证——随机起始帧、每粒子随机方向 / 帧语义、完整 emitter advanced / cycle、MovingType / + 时间轴、真实 40250 效果资源逐项视觉和 Mac 真机。 +风险与依赖:Godot `OmniLight3D` 的衰减和 Direct3D 固定功能材质不是逐像素等价;未将近似结果宣称为 + 1:1。随机和每粒子语义需要独立粒子数据或 shader / custom data 支持。 +下一批次:W4 继续 MovingType / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet; + W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W4 / §6.3(EffectMesh .mde 网格与帧播放) +状态:🔎 待真实验收(代码 + 离线回归完成;SimpleLight / TexAni 完整帧语义、MovingType / 骨骼挂点、 + 真实资源逐项视觉和真机未验) +参考:40250/ClientVS22/source/EffectLib/EffectMesh.cpp(EffectData / MDEData002 二进制布局、 + 索引顶点展开、UV.y 翻转、DiffuseMap / IFL 贴图);EffectMeshInstance.cpp(MeshFrameController、 + MeshElement 的 billboard / blending / visibility / alpha)。 +改动: + project/fx/mde.gd —— 新增 v1/v2 读取、数量 / 文件边界校验、每帧 visibility / 几何数组、索引 + TriangleList 展开、UV 翻转和厘米到米换算。 + project/fx/mse.gd —— 暴露 MeshAnimationLoopEnable / LoopCount。 + project/fx/effect_player.gd —— 去除 `.mde` box 占位,创建真实 ArrayMesh,播放几何帧,接入 + `.ifl` 贴图帧、MeshElement 的 billboard / blend / ColorFactor / TimeEventAlpha;修复真实 + `.mse` 触发的 Gradient 单点移除错误。 + project/fx_test.gd —— 使用 `geompung.mse` + `geompung.mde` 锁定 v1 的 3 个 geometry、2 帧、 + 展开顶点 / UV 和非占位 MeshInstance3D。 +测试:`fx_test.gd` 通过;`git diff --check` 通过。 +验收:未验证——SimpleLight、TexAni 的完整参考帧控制、MovingType / 完整时间曲线、骨骼挂点、 + 真实效果资源逐项视觉、40250 真服和 Mac 真机渲染。 +风险与依赖:`.mde` 参考端将索引数据展开为 `TPTVertex`,当前保持同一渲染顺序;Godot 的材质 / + billboard / blend 是等价近似而非 Direct3D stage 逐像素复制。缺失或损坏的可选贴图安全回退, + 不把回退资源当作 1:1 视觉通过。 +下一批次:W4 继续 SimpleLight / TexAni / 完整时间轴或 `.msenv`;W3 继续 CJK IME / 完整 EventSet; + W2 继续完整交易事务;W5 执行完整 Mac App 导出与 G1/G2。 +``` + +```text +批次:2026-09-02 / W2 / §3.7(受击状态统一 / 删除客户端硬直) +状态:✅ 完成(离线)——参考语义移植 + C++ 19/19 + 相关 GDScript 回归全绿; + 远端实体 knock_down 填充与真实 40250 受击时序仍待后续增量 / 真服验收。 +参考:REF/UserInterface/InstanceBase.cpp:1247(`__CanProcessNetworkStatePacket` 只查 + `IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`); + REF/UserInterface/InstanceBaseBattle.cpp:355(`NEW_Attack` 门:`IsDead / IsStun / + IsKnockDown / IsUsingSkill`)、:310(`NEW_UseSkill` 门); + REF/UserInterface/InstanceBaseEffect.cpp:932(`case AFFECT_STUN` → `SetStun`); + 40250/ClientVS22/source/GameLib/ActorInstanceSync.cpp:43-71(`__IsSyncing` = + `IsDead() || IsStun() || IsPushing()`,`InterceptOnceMotion(NAME_DAMAGE_FLYING)` + 作击退动作,`IsPushing()` = 物理 blending 中)。 +改动: + extension/src/net/entity_store.h —— `Entity` 增加 `bool knock_down`;声明 + `mut_knockdown(uint32_t vid, bool on)`; + extension/src/net/entity_store.cpp —— `mut_knockdown` 落位(值变才标脏 `ChangeKind::Info`); + extension/src/net/m2_client.cpp —— `entity_dict` 暴露 `d["knock_down"]`; + project/net_play.gd —— 删除 `const HITSTUN` / `var _hitstun_until` 及全部引用; + 新增 `var _knock_down` / `var _using_skill`;新增 + `_main_entity()` / `_can_process_network_state()` / `_can_attack()` / + `_can_use_skill()`;`is_stunned()` 改为兼容 shim;`_on_damage` 只置 + `_knock_down` + 播 `damage` + 抖屏;`_process` 轮询 + `player_view.is_in_hit_reaction()` 清 `_knock_down`,`pc.frozen` 与攻击闸门 + 改走新函数; + project/ui/player_view.gd —— `_on_playback_finished` 增加 `_state == "damage"` + 分支(一次性击退 clip 结束回 `wait`);新增 `is_in_hit_reaction()`; + project/combat_fx_test.gd —— 断言改写:命中→`_knock_down` 置位且不能行动; + 服务端 `stunned` 标志独立驱动闸门;击退动作结束 / 闪避 / 他人受击不置位。 +测试:`cmake --build build -j4`;`ctest --test-dir build --output-on-failure` → 19/19; + `godot --headless --path project --script combat_fx_test.gd` → PASS; + `netplay_test` / `netbridge_test` / `gamescene_test` / `input_key_test` / + `p2b_test` / `player_motion_test` → PASS。 +验收:未验证——远端实体 `knock_down` 的接线(net_world 从 motion 8/9 驱动、 + 从 wait/walk/run/dead 清零)留待后续增量;真实 40250 服务端的受击 / 击退 / stun + 时序与 `AFFECT_STUN` 清除路径仍需真服证据。 +风险与依赖:本地击退时长依赖 `player_view` 的 `damage.msa` 能正常发 + `playback_finished`(与既有 `__motion` 分支同一机制);`stunned` 目前只有 + `mut_stun` 置位、无独立清除包,属另一处已知缺口(§3.7 范围外)。 +下一批次:W2 §3.3 点击距离常量 / §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型; + 之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。 +``` + +```text +批次:2026-09-02 / W2 / §3.3(普攻节奏硬编码 → 动作数据驱动) +状态:✅ 完成(离线)——参考字段移植 + C++ 19/19 + 相关 GDScript 回归全绿; + 弓箭 __GetBowRange() 与 §3.5 命中窗接入仍待后续增量 / 真服验收。 +参考:40250/ClientVS22/source/EterGrnLib(CGrannyMotion::LoadMotionData → + CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime, + fComboLinkTime} / TAttackingData{...}); + REF/UserInterface/InstanceBaseBattle.cpp:294(`NEW_IsClickableDistanceDestInstance` + 默认 150 / IsBowMode → __GetBowRange / IsNPC → 500 / IsResource → 100,单位 cm); + REF/GameLib/ActorInstanceBattle.cpp:157(`InputComboAttackCommand` 用 + GetComboInputStartTime/GetNextComboTime/GetComboInputEndTime;无 combo 数据退化为 + `fElapsedTime > GetMotionDuration() * 0.9f`);`CInstanceBase::SetAttackSpeed` + (bAttackSpeed/100 只缩放动作播放速率)。 +改动: + formats/msa.h / msa.cpp —— `struct Msa` 增加 `has_combo_input` + + `combo_pre_input_time / combo_direct_input_time / combo_input_limit_time / + combo_link_time`、`has_attacking_data` + `attacking_type / hitting_type / + attack_start_time / attack_end_time / stiffen_time / invisible_time / + external_force`;新增 `combat_data()` 读取 `Group ComboInputData` / + `Group AttackingData`,`parse_msa` 与 `parse_msa_file` 均调用; + formats/tests/msa_test.cpp —— 无战斗组时标志为 false;再解析真实 + `onehand_sword/combo_01.msa` 形状,逐字段断言值; + extension/src/metin2_anim.h / metin2_anim.cpp —— 新增并绑定 `get_motion_data()`, + 导出 `duration / next_combo(has_combo_input && DirectInputTime>0 ? + DirectInputTime : duration*0.9)/ attacking_type / hitting_type / + attack_start_time / attack_end_time / stiffen_time / invisible_time / + external_force` 等; + project/net_play.gd —— 删除 `const ATTACK_RANGE`,新增 + `CLICK_DIST_DEFAULT_CM=150 / CLICK_DIST_NPC_CM=500 / CLICK_DIST_RESOURCE_CM=100` + 与 `_clickable_distance(te)`(按 `_entity_kind` 选,cm→m);`_apply_attack_speed()` + 只把 `bAttackSpeed/100` 夹到 `[0.25,3]` 写 `_atk_speed_factor`,不再写死 + `attack_period`;新增 `_current_attack_period()` = + `(get_motion_data().next_combo 或 DEFAULT_ATTACK_PERIOD) / _atk_speed_factor`, + 命中点用它刷新 `attack_period` / `_attack_cd`;`_process` 距离判定改用 + `_clickable_distance(te)`; + project/combat_fx_test.gd —— 移除已删除的 `np.ATTACK_RANGE` 引用,改断言 + 无动作数据时节奏回退 `DEFAULT_ATTACK_PERIOD`、攻速 150 → factor 1.5 且节奏按其缩放; + project/netplay_test.gd —— `attack_speed:150` 改断言 `_atk_speed_factor==1.5` 与 + `_current_attack_period()==DEFAULT_ATTACK_PERIOD/1.5`(不再断言收包即写死 0.4)。 +测试:`cmake --build build -j4`;`ctest --test-dir build --output-on-failure` → 19/19 + (`formats.msa_loop_data` 覆盖扩展断言); + `godot --headless --path project --script combat_fx_test.gd` → PASS; + `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / + `p2b_test` → PASS。 +验收:未验证——弓箭 `__GetBowRange()` 等价、`.msa` `AttackingData` / + `List HitPosition` 驱动的命中帧 / 硬直 / 无敌帧(§3.5)、真实 40250 服务端的 + 普攻节奏与攻速时序。 +风险与依赖:headless 无 `player_view` 时 `get_motion_data()` 取不到,节奏退化为 + `DEFAULT_ATTACK_PERIOD`(0.6s)——与参考端「无 combo 数据 → duration*0.9」是同类兜底; + `.msa` 的 combo 组是否随每个连击段切换 clip 而更新,取决于 `player_view.anim` + 的 `anim_path` 是否切到对应 `combo_0N.msa`,该切换属 §3.5 范围。 +下一批次:W2 §3.4 保留动作状态机 / §3.1 Src-Dst 移动模型 / §3.2 命令队列同步; + 之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。 +``` + +```text +批次:2026-09-02 / W2 / §3.4(自动攻击目标预留状态机) +状态:✅ 完成(离线,CLICK_ACTOR 全量)——参考 10 步逐条移植 + `netplay_test` 5b 段 + + C++ 19/19 + 其余 GDScript 回归全绿;CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派留桩, + 安全区 / 弓箭 / 骑乘 / dash 判定位待服务端下发或后续增量。 +参考:REF/UserInterface/PythonPlayer.cpp:207(`__Update_AutoAttack`)、:182 + (`__IsUsingChargeSkill`);REF/UserInterface/PythonPlayerInput.cpp:820-865(预约模式枚举 / + 字段)、:877(`__ReserveProcess_ClickActor` 10 步)、:306(`__OnPressActor`); + REF/UserInterface/PythonPlayerInputMouse.cpp:287(`NEW_RefreshMouseWalkingDirection` + 模式分派);OnCannotAttack / OnCannotShot 字符串码经 game.py 同名回调 → localeInfo。 +改动: + project/net_play.gd —— + · 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` + 与 `_reserved_mode / _vid_reserved / _iid_reserved / _ppos_reserved / + _skill_slot_reserved / _skill_range_reserved / _reserved_delay / _auto_attack_vid / + _last_cannot`;服务端未下发字段的本地占位 `_arrow_count(-1) / _dash_active / + _charge_skill`;新增 `signal cannot_act(code)`; + · `_process` 重写:帧序 knockdown 清闸 → `pc.frozen` → 环境守卫(observer / 未在游戏 / + 无 pc.player 时清预约)→ `_attack_cd -= dt` / `_reserved_delay -= dt` → + `_update_auto_attack()` → `_refresh_reserved()`; + · `_update_auto_attack()`:`!_attack_key_down || _target_vid==0` 清 auto-attack; + `!_can_process_network_state()` / `_dash_active || (_using_skill && _charge_skill)` + 直接返回;目标不存在 / 死亡 / 骑乘且 `!_can_attack_horse_level()` → `_clear_auto_attack()`; + 可攻击且非睡眠且 `_reserved_mode==NONE && _can_attack()` → `_reserve_click_actor()`; + · `_reserve_process_click_actor()` 10 步:① 空引用 / 无节点 → `_clear_reserved()` + ② `dist > _clickable_distance(rv)` → `pc.walk_to(tnode)` ③ `!_is_attackable` → + `pc.frozen=false` + `client.click_npc()` + 清预约 ④ 目标死 ⑤ `main.in_safe` → + `_emit_cannot("IN_SAFE")` ⑥ `rv.in_safe` → `"DEST_IN_SAFE"` ⑦ `_dash_active` 保持预约 + ⑧ `main.bow_mode && _arrow_count==0` → `_emit_cannot("EMPTY_ARROW")` ⑨ + `_target_vid != _vid_reserved` → `client.set_target` + `target_changed` + HUD 刷新 + ⑩ `_attack_cd<=0` 才 `_do_attack_swing()`,然后 `_clear_reserved()`; + · `_do_attack_swing()`:面向目标 + combo 窗口递增 + `client.attack(motion+combo, vid)` + + `player_view.set_anim_state("combo"/"attack")` + `attack_period = _current_attack_period()` + (§3.3); + · `_is_attackable(e)` = 非死亡且 `_entity_kind in [0,2]`(PC / MONSTER;帝国 / 决斗归 §5.4)。 + project/netplay_test.gd —— `FakePC` 加 `walk_to()` / `walk_calls` / `frozen`;新增 5b 段: + 出可点击距离 → `walk_to` 且不发攻击、预约保持 CLICK_ACTOR;进距离 → 攻击且预约清空; + `fc.ents[1000].in_safe=true` → `cannot_act("IN_SAFE")` 且不发包;目标死 → `_auto_attack_vid==0`。 +测试:`ctest --test-dir build` → 19/19; + `godot --headless --path project --script netplay_test.gd` → PASS; + `combat_fx_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / + `p2b_test` / `input_key_test` → PASS。 +验收:未验证——CLICK_ITEM(`< 20.0f` 拾取阈值 + `_reserved_delay`)/ CLICK_POSITION / + USE_SKILL(`IS_HUGE_RACE` −200 修正 / charge skill)分派;安全区 / 弓箭模式 / 箭矢数 / + 骑乘等级 / dash affect 的真实服务端来源;`OnCannot*` 文案接 HUD;真实 40250 自动攻击时序。 +风险与依赖:本工程用「按住攻击键 + 有目标」当 `__OnPressActor(isAuto)`;参考端 auto-attack + 不依赖按键,接入真实输入映射时需把 `_attack_key_down` 换成真正的 auto-attack 目标设置点。 + 判定位占位读实体字典键(`in_safe` / `bow_mode` / `mounting` / `sleeping`),服务端未填时 + 恒为安全默认(可打、不在安全区、非骑乘),与 §3.7 `knock_down` 同套做法。 +下一批次:W2 §3.1 Src-Dst 移动模型 / §3.2 命令队列同步;之后 §3.5 攻击动作-命中-封包绑定、 + §3.6 箭矢 FLY、§3.8 技能合法性。 +``` + +```text +批次:2026-09-02 / W2 / §3.1(本地移动 Src/Dst 模型) +状态:✅ 完成(离线,本地移动切片)——`NEW_Goto` 前置门 + Src/Dst + `__ReserveClickGround` + 0.1 s 重试逐条移植 + 新增 `player_move_test.gd`(5 组)+ C++ 19/19 + 其余 GDScript 回归全绿; + Actor 间碰撞与坐骑 `MODE_HORSE*` 留后续增量。 +参考:REF/UserInterface/InstanceBaseMovement.cpp:251(`CInstanceBase::NEW_Goto`)、:293 + (`NEW_MoveToDirection`);REF/UserInterface/PythonPlayerInput.cpp:419(`__OnPressGround`)、 + :445(`NEW_MoveToDirection`)、:626(`NEW_IsEmptyReservedDelayTime`)、:834 + (`__ReserveClickGround` 的 `m_fReservedDelayTime = 0.1f`); + REF/UserInterface/InstanceBaseBattle.cpp:469(`CheckAdvancing`)、:560(`ATTRIBUTE_BLOCK`)。 +改动: + project/player_controller.gd —— + · 删除 `var _move_target` / `var _has_target` 的直线改坐标; + · 新增 `const MOVABLE_GROUND_DISTANCE_M := 1.0` / `const RESERVED_GROUND_DELAY := 0.1`、 + `var locked` / `var moving_skill`、`_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot` / + `_reserved_ground` / `_reserved_delay_time`;`is_going()` / `_can_translate()` + (= `not frozen and not locked and not moving_skill`); + · `_goto(dst_flat)` 对齐 `NEW_Goto`:`flat.length() < MOVABLE_GROUND_DISTANCE_M` → 返回 + true 不动(≈ `__IsMovableGroundDistance`);`!_can_translate()` → 存 `_reserved_ground` + + `_reserved_delay_time = RESERVED_GROUND_DELAY` 返回 false(≈ `__ReserveClickGround`); + 否则 `_src_pos = 当前` / `_dst_pos = 点` / `_dst_rot = atan2(flat.x, flat.z)` / + `_is_going = true`;`walk_to()` → `_goto()`;`_on_click` 点地分支 → `_goto(hit)`; + · `_process`:新增 `_reserved_delay_time` 递减,归 0 且 `_can_translate()` → 重跑 + `_goto(_reserved_ground)`(≈ `NEW_IsEmptyReservedDelayTime` 递减重试);`frozen or locked` + 时发 `wait` 且**保留** `_is_going`(解冻后继续走);WASD 分支置 `_is_going = false` + (≈ `NEW_MoveToDirection` 的 `m_isGoing = FALSE`);点走分支用 `_is_going` / `_dst_pos`; + 平移段包一层 `if moving_skill: 只 rotation.y = atan2(wish.x, wish.z) else: <平移>`, + 平移到位 `_is_going = false`。 + project/player_move_test.gd(新增)—— headless `SceneTree`:① `_goto(远)` 设 + Src/Dst/is_going/dst_rot;② `< 1 m` 太近不动;③ 每帧推进抵达清 `is_going`; + ④ `frozen` → `_goto` 返回 false + 预约,解冻后 0.1 s delay 递减自动重试; + ⑤ `moving_skill` → `_goto` 不平移。 +测试:`ctest --test-dir build` → 19/19(本增量无 C++ 改动); + `godot --headless --path project --script player_move_test.gd` → PASS; + `netplay_test` / `combat_fx_test` / `gamescene_test` / `eterngrn_polish_test`(exit 0, + 既有无关 `motion_event: sound` FAIL)/ `player_motion_test` / `p2b_test` / + `netbridge_test` / `mouse_controller_test` → PASS。 +验收:未验证——Actor 间 `TestActorCollision` / `BlockMovement`(需远端 Actor 碰撞体)、 + 坐骑 `MODE_HORSE*` 移速与动作模式(依赖 §6.4 + §3.2 命令队列)、`MOVABLE_GROUND_DISTANCE` + 参考默认值(现取 1.0 m,待读 `game.py` 侧 `player.SetMovableGroundDistance`)、 + 网络状态包驱动移动的 `__EnableSkipCollision()`(§3.2)。 +风险与依赖:`_can_translate()` 把 `__IsSyncing`(Dead/Stun/Pushing)近似成 `frozen`; + 真实同步态位待 §3.2 命令队列接入后细化。碰撞仍只有地形阻挡(沿用 §3.1「当前」实现), + Actor 叠站问题留到有碰撞体时解决。 +下一批次:W2 §3.2 命令队列同步(`EntityStore` 状态队列 + 硬阈值,不引入预测回滚 / 抖动缓冲); + 之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。 +``` + +```text +批次:2026-09-02 / W2 / §3.2(远端同步命令队列 —— C++ 核心,part A) +状态:🟡 部分完成——`EntityStore` 命令队列 + 硬阈值 switch + 释放门已就位;net_play + 上行 6 回调、net_world 去自有 lerp、真实服务器帧钟留给增量 78。 +参考:REF/UserInterface/InstanceBase.cpp:1278(`CInstanceBase::StateProcess`)—— + `while` 队列非空:`ELTimer_GetServerFrameMSec() < front.m_dwChkTime` → return;pop; + `!__CanProcessNetworkStatePacket()`(Dead / KnockDown / 不可取消技能)→ return; + `!__IsEnableTCPProcess(eFunc)`(表情锁)→ return;`switch (eFunc)`: + `FUNC_WAIT` dirLen>1.0 → 走 + `m_kMovAfterFunc=WAIT`;`FUNC_WAIT` else → + `SCRIPT_SetPixelPosition(dst)` 贴齐;`FUNC_MOVE` → 恒走(after MOVE); + `FUNC_COMBO/ATTACK/MOB_SKILL` dirLen>=50.0 → 走到位再动作,else 贴齐 + 动作; + 所有「走过去」分支 `__EnableSkipCollision()`。 + REF/UserInterface/NetworkActorManager.cpp:8(`SNetworkActorData::UpdatePosition`)—— + `(now - m_dwClientSrcTime)/m_dwDuration` 线性插值,仅供可见性。 +改动: + extension/src/net/entity_store.h —— + · `#include `;FUNC_* 之后新增 `struct StateCmd { uint32_t chk_time; + float dst_x,dst_y,rot; uint8_t func,arg; uint32_t dur_ms; }`; + · `Entity` 新增 `std::deque state_queue` / `mov_after_func` / + `mov_after_arg` / `mov_after_rot` / `bool skip_collision`; + · 新增 `set_server_frame_ms()` / `server_frame_ms()` / `process_states()`; + `m_server_frame_ms = 0` 私有成员;`mut_move()` 签名加 `arg=0, chk_time=0`; + · 删 `start_move()` 声明;加 `drain_state_queue` / `apply_state_cmd` / + `begin_state_walk` / `can_process_network_state` / `is_enable_tcp_process`。 + extension/src/net/entity_store.cpp —— + · `#include `;删 `start_move()` 定义; + · `mut_move()` 改为 `state_queue.push_back({...})` + `drain_state_queue(e)` + + `ChangeKind::Move`; + · `drain_state_queue`:`while`:`m_server_frame_ms != 0 && m_server_frame_ms < + front.chk_time` → break;`!can_process_network_state(e)` → break; + `!is_enable_tcp_process(e, front.func)` → break;pop + `apply_state_cmd`; + · `apply_state_cmd`:`FUNC_WAIT` dir_len>1.0 → `begin_state_walk(WAIT)`,else + 贴齐 + `func=WAIT` + `skip_collision=false`;`FUNC_MOVE` → `begin_state_walk(MOVE)`; + `FUNC_COMBO/ATTACK/MOB_SKILL` dir_len>=50.0 → `begin_state_walk(c.func)`,else + 贴齐 + `func=c.func`;default 贴齐 + `func=c.func`; + · `begin_state_walk`:`angle/moving/func=FUNC_MOVE/sx,sy/tx,ty/move_start_ms/ + move_dur_ms` + `mov_after_*` + `skip_collision=true`;walk 时长优先 `c.dur_ms`, + 为 0 时 `dist / (moving_speed>0?moving_speed:150) * 1000`(min 1); + · `can_process_network_state` = `!e.dead && !e.knock_down`; + `is_enable_tcp_process` = `true`(表情锁待增量 78); + · `process_states()` 遍历 `m_ents` → `drain_state_queue`; + · `tick()`:插值循环前先 `process_states()`;`t>=1.0f` 到达分支加 + `e.skip_collision=false` + `mov_after_func` 为 COMBO/ATTACK/MOB_SKILL 时 + `func=mov_after_func; angle=mov_after_rot`,否则 `func=FUNC_WAIT`;清 `mov_after_func`; + · m2dev `apply()` 的 GC_MOVE 分支:`mut_move(vid, rot*360/256, func, x, y, + duration, arg, time)`。 + extension/src/net/classic/classic_parser.cpp —— HDR_GC_MOVE:`mut_move(vid, + rot*5.0f, func, x, y, duration, arg, time)`。 + extension/tests/net_entity_test.cpp —— 旧 FUNC_ATTACK 贴齐单测拆成「近」(dir_len + 30<50 → 贴齐 + `func==FUNC_ATTACK`)+「远」(dir_len 170>=50 → 先 `func==FUNC_MOVE` + 走,`tick()` 后到达 Dst + `func==FUNC_ATTACK`)。 + extension/tests/net_state_queue_test.cpp(新增,5 组)——① 服务器帧钟释放门(未来 + chk_time 挂队;帧钟追上才释放;帧钟 0 立即释放);② FUNC_WAIT 1.0 阈值(贴齐 vs 走 + + after WAIT + skip_collision);③ FUNC_COMBO 50.0 阈值 + 到达后动作 + arg 锁存; + ④ `__CanProcessNetworkStatePacket` 门(knock_down / dead 挂队,恢复即释放;未知 vid 不崩); + ⑤ 多条到期命令按序一次性 drain,末条胜出。 + extension/CMakeLists.txt —— 加 `net_state_queue_test` → `add_test(net.state_queue)`。 +测试:`ctest --test-dir build` → 20/20(新增 `net.state_queue`); + `netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / + `combat_fx_test` → PASS;`eterngrn_polish_test` exit 0(既有无关 `motion_event: sound` FAIL)。 +验收:未验证——真实 40250 `GC_HANDSHAKE` `lDelta` 帧钟对齐;`GC_SYNC_POSITION` 是否应 + 清队列;`__IsEnableTCPProcess` 表情锁;net_play 上行节流表(100/300 ms + disp≥1.0)与 + net_world 去 lerp(都在增量 78)。 +风险与依赖:`m_server_frame_ms == 0` 时释放门直通 = 退回增量 76 的即时时序,接真实帧钟 + (增量 78)前无回归但也无抖动吸收。`is_enable_tcp_process` 目前恒 `true`,表情锁 + (`FUNC_EMOTION` 期间拒 TCP 状态)待 §3.x 表情切片。Actor 碰撞近似仍未做,`skip_collision` + 标志已就位但 `tick()` 尚未据此跳过碰撞体(无碰撞体,暂无影响)。 +下一批次:W2 §3.2 part B(增量 78)—— net_play.gd 上行 6 事件回调 + 节流常量、 + net_world.gd 去掉自有 lerp(C++ 插值只喂 §2.1 可见性)、从 `GC_HANDSHAKE` `lDelta` + 接 `set_server_frame_ms()`;之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、§3.8 技能合法性。 +``` + +```text +批次:2026-09-02 / W2 / §3.2 part B(上行 6 事件回调 + net_world 去 lerp) +状态:🟡 部分完成——上行 6 回调 + 节流表 + net_world 去自有 lerp 落地;真实服务器 + 毫秒帧钟(`GC_HANDSHAKE` `lDelta` → `set_server_frame_ms()`)留给增量 79。 +参考:REF/UserInterface/PythonPlayerEventHandler.cpp —— OnMove(:76) 重置 wait+100ms / + moving+300ms 后发 FUNC_MOVE,0;OnMoving(:62) 300ms 节流 → FUNC_MOVE,0;OnWaiting(:38) + 100ms 且 disp≥1.0 → FUNC_WAIT,0;OnStop(:88) 不节流 → FUNC_WAIT,0;OnAttack(:102) + 不节流 → FUNC_COMBO,wMotionIndex;OnUseSkill(:120) 不节流 → FUNC_SKILL|uMotSkill,uArg。 + REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:1111(`SendCharacterStatePacket`)—— + `TPacketCGMove{ bFunc, bArg, bRot=rot/5.0f, lX, lY, dwTime }`,角度归一 [0,360)。 + REF/UserInterface/NetworkActorManager.cpp:8(`SNetworkActorData::UpdatePosition`)—— + 位置线性插值只供可见性;节点位置改由 C++ 命令队列(增量 77)驱动。 +改动: + project/net_play.gd —— + · 删 `const SEND_INTERVAL` / `const SEND_DIST` / `var _last_sent_t`;新增 + `const FUNC_COMBO=3` / `const FUNC_SKILL=0x80` / `SEND_MOVING_INTERVAL=0.3` / + `SEND_WAITING_INTERVAL=0.1` / `SEND_WAITING_MIN_DISP_CM=1.0`;`_last_sent_pos` + 改存世界坐标,新增 `_last_moving_sent_t` / `_last_waiting_sent_t`。 + · 新增 `_send_state(func, arg, world_pos)` 统一出口 → `client.move(func, arg, + heading, cm.x, cm.y)` + 记 `_last_sent_pos`。 + · `_on_local_moved`:`!_was_moving` → OnMove(立即 `_send_state(FUNC_MOVE,0)` + + `_last_moving_sent_t = _last_waiting_sent_t = now`);否则 OnMoving(`now - + _last_moving_sent_t < SEND_MOVING_INTERVAL` 则 return,否则发 `FUNC_MOVE`)。 + · `_on_anim_state`:"wait" 且 `_was_moving` → OnStop `_send_state(FUNC_WAIT,0)`。 + · 新增 `_tick_on_waiting()`(`_process` 尾部调):`!_was_moving` 时 100ms 节流 + + `_server_xy(player) 距 _server_xy(_last_sent_pos) >= 1.0` 才 `_send_state(FUNC_WAIT,0)`。 + · `_do_attack_swing`:`client.attack()` 后加 `_was_moving = false` + + `_send_state(FUNC_COMBO, attack_motion + _combo, player.pos)`(OnAttack)。 + · 新增公共 `on_use_skill(motion_skill, arg)` → `_send_state(FUNC_SKILL | + (motion_skill & 0x7F), arg, player.pos)`(OnUseSkill;调用点属 §3.8 `player_skill.gd`)。 + project/net_world.gd —— + · `_process`:`n.position = _grounded(e.get("pos", n.position))` 直接跟随,删掉 + `if dist > snap_dist ... else n.position.lerp(want, follow_lerp*dt)` 的自有二次平滑。 + · `follow_lerp` / `snap_dist` 字段保留(外部读取 / 旧测试兼容),`_process` 不再引用。 + project/netplay_test.gd —— 观战段基线改「相对计数不变」(`move_n` / `target_n`); + 新增第 11 段:OnMove 立即发 + 重置节流窗、OnMoving <300ms 节流 / 过期后放行、 + OnStop → FUNC_WAIT、`on_use_skill(5,2)` → `move[0] == [FUNC_SKILL|5, 2, ...]`。 + project/netbridge_test.gd —— 「near move → lerp closes in」改为「move → node follows + e.pos directly」(`distance < 0.01`)。 +测试:`ctest --test-dir build` → 20/20(无 C++ 改动); + `netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / + `combat_fx_test` / `player_motion_test` → PASS。 +验收:未验证——真实 40250 上行时序 / 服务端对 6 类 func 的接收;`OnWaiting` 的 `disp≥1.0` + 单位(现按服务器 cm);`OnAttack` 额外状态包与 `CG_ATTACK` 的先后;`on_use_skill` 的 + 真实调用点(§3.8)。 +风险与依赖:位置插值全交 C++ `EntityStore::tick()` 后,`net_world` 只跟随 `e.pos`—— + 若 C++ 帧钟(增量 79)未接,命令仍即时释放,观感等同增量 76 + 命令队列排序,无抖动吸收。 + `_tick_on_waiting` 用本地 `Time.get_ticks_msec()`,接真实服务器帧钟前节流窗口是客户端时钟。 +下一批次:W2 §3.2 part C(增量 79)—— 从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟 + (`ClassicStream::m_server_time_base` / `m_client_time_base` 已就位,需暴露访问器并穿到 + `EntityStore::set_server_frame_ms()`);之后 §3.5 攻击动作-命中-封包绑定、§3.6 箭矢 FLY、 + §3.8 技能合法性。 +``` + +```text +批次:2026-09-03 / W2 / §3.2 part C(真实服务器毫秒帧钟,§3.2 收官) +状态:✅ 完成 +参考:REF/UserInterface/EterLib/Timer.cpp(`ELTimer_GetServerFrameMSec()`)—— + 客户端帧钟 = 本地毫秒 - 握手时的本地基准 + 握手带来的服务器基准。 + REF/.../PythonNetworkStreamPhaseHandShake.cpp:124(`lDelta` 时钟对齐)。 +改动: + extension/src/net/classic/classic_stream.h —— + · public 加 `uint32_t server_frame_ms() const;` + `bool has_server_clock() const + { return m_client_time_base != 0; }`(附 §3.2 注释)。成员 `m_server_time_base` / + `m_client_time_base` 已存在(`:142-143`)。 + extension/src/net/classic/classic_stream.cpp —— + · `server_frame_ms()` 定义:`m_client_time_base == 0` 返回 0,否则 + `now_ms() - m_client_time_base + m_server_time_base`(`now_ms()` 为本文件匿名 + 命名空间里的 steady_clock 毫秒助手)。`HDR_HANDSHAKE` 处理里的 + `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()` + (`:266-267`)无改动。 + extension/src/net/classic/classic_session.cpp —— + · `process()` 在 `m_world.tick()` 前加 + `m_world.set_server_frame_ms(m_stream.server_frame_ms());`(`:307`)。 + extension/src/net/entity_store.h —— + · `StateCmd` 后加 `static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;` + (防呆阀,附注释说明是对参考端的有意偏离)。 + extension/src/net/entity_store.cpp —— + · `drain_state_queue` 帧钟门改为:`m_server_frame_ms != 0 && chk_time > + m_server_frame_ms && chk_time - m_server_frame_ms <= STATE_QUEUE_MAX_WAIT_MS` + 才 break;超过 cap 的领先量当异帧钟 `dwTime`,立即放行。 + extension/tests/net_classic_stream_test.cpp —— + · 新增「server frame clock (§3.2)」段:握手前 `server_frame_ms()==0` + + `!has_server_clock()`;喂一个 `hs.time=500000, delta=20` 的握手后 + `has_server_clock()` 且帧钟 ∈ [500020, 505020);连读两次单调不减。 + extension/tests/net_state_queue_test.cpp —— + · 新增第 6 段:`set_server_frame_ms(1000)`,`chk_time == 1000+cap` 仍 gated; + 另一实体 `chk_time == 1000+cap+1` 立即放行(防呆阀边界)。 +测试:`ctest --test-dir build` → 20/20(`net.classic_stream` / `net.state_queue` 覆盖新逻辑); + `netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / + `combat_fx_test` / `player_motion_test` → PASS。 +验收:未验证——真实 40250 `GC_MOVE` `dwTime` 是否与握手帧钟同一时基(若同,`STATE_QUEUE_ + MAX_WAIT_MS` 阀几乎不触发;若不同,阀保证远端 Actor 不会永久冻结)。m2dev 后端仍无 + 帧钟来源,`m_server_frame_ms == 0` 即时释放 = 增量 76 时序,无回归。 +风险与依赖:帧钟阀阈值 1 s 为经验值,联机核验后可收紧或去除。时间同步模式下每个 + resync 握手都会刷新 `m_server_time_base` / `m_client_time_base`,帧钟持续对齐。 +下一批次:§3.2 收官。W2 继续 §3.5 攻击动作-命中-封包绑定(`.msa` `AttackingData` / + `HitPosition`)、§3.6 箭矢 FLY 事件、§3.8 技能合法性(`player_skill.gd`,也是 + `net_play.on_use_skill` 的真实调用点)、§3.10 技能升级 gating。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 part A(PC 连击段表解析) +状态:🟡 部分完成(增量 80) +参考: + REF/GameLib/RaceData.h:9 —— MAKE_COMBO_KEY(motionMode, comboType) + = (DWORD(mode)<<16) | DWORD(type) + REF/GameLib/RaceData.cpp:405-475 —— CRaceData::ReserveComboAttack(map::insert, + 重复 key 不覆盖;ComboIndexVector.resize(count) 清零) + / RegisterComboAttack(key 未登记 return; + index >= size AssertLog+return;否则 vec[index]=motion) + REF/UserInterface/PythonCharacterManagerModule.cpp:260-330 —— + chrmgr.ReserveComboAttackNew / RegisterComboAttackNew 绑定 + assets/root/playersettingmodule.py —— 四个 def __LoadGameEx(race, path) + (Warrior@480 / Assassin@663 / Sura@876 / Shaman@1001), + 函数体与性别无关 → 连击段表按职业收。 + 结论:PC 连击段表**不在** `.msm` 里(`.msm` 只有 BaseModelFileName + HairData)。 +改动: + formats/combo_table.h(新)—— + · enum ComboMotionMode(CRaceMotionData::EMode,0..16) + · COMBO_NAME_NORMAL_ATTACK=13 / COMBO_NAME_COMBO_ATTACK_1=14 / _8=21 + · make_combo_key / combo_key_mode / combo_key_type(对齐 RaceData.h) + · enum ComboClass { WARRIOR=0, ASSASSIN=1, SURA=2, SHAMAN=3, COUNT=4 } + · struct ComboTable { map> combos; + get(mode,type) -> vec* 或 nullptr(= GetComboDataPointer 语义); empty(); key_count(); } + · struct PlayerComboTables { ComboTable per_class[4]; klass(c) 越界 -> 空表 } + · parse_player_combo_tables(py_text, out, err) / _file(path, out, err) + formats/combo_table.cpp(新)—— + · 逐行扫描;`def ` 行切块(只有四个 __LoadGameEx 命中,其它 def 复位 cur_class=-1) + · resolve_token:chr.MOTION_MODE_*(静态 17 名表)、chr.MOTION_COMBO_ATTACK_N(→13+N)、 + chr.MOTION_NORMAL_ATTACK(→13)、COMBO_TYPE_N / COMBO_INDEX_N(→N-1)、裸整数 + · ReserveComboAttackNew(要求 3 实参)→ combos.emplace(key, vector(count,0))(emplace 不覆盖) + · RegisterComboAttackNew(要求 4 实参)→ 找 key,未登记 continue,idx 越界 continue, + 否则 vec[idx]=motion + formats/CMakeLists.txt —— + · xr_formats 源表加 combo_table.cpp + · 加 formats_combo_table_test(+ M2_ASSETS 时透传做 live 断言) + formats/tests/combo_table_test.cpp(新)—— + · 合成片段:warrior GENERAL/1H type1-2(含越界 Register + 重复 Reserve 各一), + assassin DUALHAND type3(只设 idx5),非 Ex 的 def __LoadGameNPC 里一条 stray Reserve + · 断言:key 编码、段号向量逐格、size 不被重复 Reserve/越界 Register 改动、 + type3 未 Reserve→nullptr、职业隔离、块外调用不落任何职业(总 key_count==4)、 + klass(-1)/klass(99) 空表不崩 + · live(M2_ASSETS):真实 playersettingmodule.py → warrior 1H type1==[14,15,16,17]、 + sura 1H type3 size 6 且末段==17、shaman FAN type1==[14,15,16,17] +测试:`ctest --test-dir build` → 21/21(新 `formats.combo_table` 含 live 断言 `M2_ASSETS` OK); + `netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / + `combat_fx_test` / `player_motion_test` → PASS(7/7)。 +验收:解析器纯离线,合成 + 真实资源双覆盖,无需联机。 +下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出,`msa.h` 目前只有 + 单个 `AttackingData` 窗)+ 修改 4(`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + + `FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16)+ 武器种类 → `combo_motion_mode` 映射。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 part B(PC 连击状态机) +状态:🟡 部分完成(增量 81) +参考: + REF/GameLib/ActorInstanceBattle.cpp:157 —— InputComboAttackCommand:m_isPreInput 时忽略; + m_dwcurComboIndex==0 → __RunNextCombo;否则按 + GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime + 判 pre-input 挂起 / 直接推进 / 弓模式 m_isNextPreInput; + 无 combo 输入组 → GetMotionDuration()*0.9f 兜底 + REF/GameLib/ActorInstanceBattle.cpp:213 —— ComboProcess:index!=0 时 null-guard→__ClearCombo, + m_isPreInput && t>GetNextComboTime → __RunNextCombo; + index==0 时清 m_isPreInput,!IsUsingSkill && m_isNextPreInput + → __RunNextCombo + REF/GameLib/ActorInstanceBattle.cpp:256 —— __RunNextCombo:++index;arrIdx=wComboIndex-1; + GetComboDataPointer FALSE→return;OOB→TraceError+return; + ComboAttack(vec[arrIdx]); index==size → __OnEndCombo + REF/GameLib/ActorInstanceBattle.cpp:297/308 —— __OnEndCombo(骑乘 index=1,否则不复位)/ + __ClearCombo(index=0;isPreInput=FALSE;pkCurRaceMotionData=NULL) + REF/GameLib/ActorInstance.cpp:164 —— __GetCurrentComboType:弓 / GENERAL(手) / 骑乘 → 0 + 增量 80 的 formats/combo_table.{h,cpp} + assets/root/playersettingmodule.py +改动: + extension/src/metin2_anim.h / .cpp —— + · 新静态绑定 Metin2AnimPlayer.parse_combo_tables(py_path) -> { class:int -> + { key:int -> PackedInt32Array(段号) } },转发 fmt::parse_player_combo_tables_file + · 新静态绑定 Metin2AnimPlayer.make_combo_key(motion_mode, combo_type) -> int + · `.msa` ComboInputData 三时间 / AttackingData 已在 get_motion_data() 暴露,无需改 + project/net_play.gd —— + · 删 COMBO_WINDOW / _combo / _last_attack_t / attack_motion + · 加 MOTION_MODE_*(GENERAL=1 / BOW=5 / HORSE 9..15 / HORSE_BOW=13)、 + NAME_NORMAL_ATTACK=13 / NAME_COMBO_ATTACK_1=14; + combo_class / combo_motion_mode / _combo_type / _combo_index / _is_pre_input / + _is_next_pre_input / _combo_started_t / _combo_tables + · set_asset_root(root) → parse_combo_tables(root/root/playersettingmodule.py)(class_exists 守卫) + · set_combo_type(clamp 0..2)/ set_combo_motion_mode(变更即 _clear_combo)/ + _is_bow_mode / _current_combo_type(弓/GENERAL/骑乘 → 0)/ _combo_vec / _motion_data + · _on_entity_info:entity.race & 3 → combo_class + · _do_attack_swing / _run_next_combo / _combo_process / _on_end_combo / _clear_combo + 逐行对齐参考端五函数;段表空 → _emit_swing(NAME_NORMAL_ATTACK) + · _emit_swing:client.attack(0, vid)(CG_ATTACK bType=技能号,普攻 0)+ + _send_state(FUNC_COMBO, 段号)(这一包才带 14..21)——两个不同时刻的两个包(增量 78) + · _process:_update_auto_attack 前插 _combo_process() + project/game_scene.gd —— net_play 装配后 `if _assets != "": net_play.set_asset_root(_assets)` + project/combat_fx_test.gd —— 加 FakeAnim(可控 get_motion_data)+ FakeView.anim; + FakeClient.move 记录到 fc.moves;重写 combo 段:4 段合成表跑 + 首次输入→段1 / start 前忽略 / [start,next) 挂 pre-input / _combo_process 触发→段2 / + 过 NextComboTime 直接推进→段3→段4 / 末段越界不推进 / 动作回 Wait→__ClearCombo→0 / + 空段表→单段 NAME_NORMAL_ATTACK;断言 CG_ATTACK bType==0 且 FUNC_COMBO 带段号 +测试:`ctest --test-dir build` → 21/21;`netbridge_test` / `gamescene_test` / `netplay_test` / + `player_move_test` / `p2b_test` / `combat_fx_test` / `player_motion_test` → PASS(7/7)。 +验收:纯离线;连击推进 / pre-input / 弓 next-pre-input / 末段 / 清空全路径由 combat_fx_test 覆盖。 + 未验证:真实服务器对 CG_CHARACTER_MOVE{FUNC_COMBO} 段号的接受(联机 E2E)。 +下一批次(增量 82):§3.5 修改 1(`.msa` `HitDataContainer` 多命中窗导出)+ 修改 4(`THitData` + 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION` ≤16, + 复用 `M2Client::sync_positions()`)+ 武器种类 → `combo_motion_mode` 映射。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 part C(.msa 多命中窗导出,修改 1) +状态:🟡 部分完成(增量 82) +参考: + REF/GameLib/GameType.h:71-90 —— NRaceData::SHitData { fAttackStartTime; fAttackEndTime; + fWeaponLength; strBoneName; THitTimePositionMap mapHitPosition } + THitDataContainer = vector; + SMotionAttackData : SAttackData { iMotionType; THitDataContainer } + REF/GameLib/GameType.cpp:42-118 —— THitData::Load(attackingstarttime / attackingendtime / + attackingbone / weaponlength / GetTokenVector("hitposition") + 每 7 个浮点 = time + v3LastPosition + v3Position); + LoadMotionAttackData:无 hitdatacount → 组本身即单窗; + 有 hitdatacount N → SetChildNode(i) 逐个读 Group HitData0i + 真实 .msa:assets/PC/ymir work/pc/warrior/general/attack.msa(老式内联单窗)、 + .../twohand_sword/combo_05.msa(HitDataCount 1)、 + .../assassin/dualhand_sword/combo_01.msa(HitDataCount 2:R Hand + L Hand)、 + .../warrior/skill/palbang.msa(AttackingData 后还有 MotionEventData) +改动: + formats/textscript.cpp —— + · parse_body 把 `List Name { ... }` 与 `Group Name { ... }` 一样当子节点 + (List 体是一串裸值,收进子节点 lines[0])。原来 `List` 落入「普通行」分支,会把 + `{` 也 push 进行、并且吃不到配对的 `}`,导致其后的 `Group` 被串掉 + (palbang.msa 的 MotionEventData 整个丢失)。 + formats/msa.h —— + · 删单窗字段留兼容壳:attack_start_time / attack_end_time 现在 = hit_windows[0] 起止 + · 新 struct HitSample { float time; float last_pos[3]; float pos[3]; } + · 新 struct HitWindow { float start_time,end_time; string bone_name; float weapon_length; + vector samples; } + · Msa 加 vector hit_windows; int motion_type; int hit_limit_count; + attacking_type 现在读 AttackType||AttackingType + formats/msa.cpp —— + · hit_positions():List HitPosition 的 lines[0] 按 7 个一组切 HitSample + · hit_window():从一个节点读 AttackingStartTime/EndTime/AttackingBone/WeaponLength + 采样 + · combat_data():AttackingData 组内找名字前缀 "HitData" 的子组 → 各一窗; + 没有则组本身即单窗;填 attack_start_time/end_time = 首窗起止 + extension/src/metin2_anim.cpp get_motion_data() —— + · 新增 hit_windows 数组(每项 {start_time,end_time,bone,weapon_length, + samples:[{time,last_pos:Vector3,pos:Vector3}]})+ motion_type + hit_limit_count + formats/tests/msa_test.cpp —— + · 合成:内联单窗(2 采样,校验 bone / 起止 / last_pos / pos)、 + 新式 HitDataCount 2(HitData00 有采样 / HitData01 无采样 / motion_type / hit_limit_count) + · live(M2_ASSETS):general/attack(1 窗 17 采样)、twohand_sword/combo_05 + (1 窗 wlen 150 / 37 采样)、assassin/dualhand_sword/combo_01(2 窗 R Hand+L Hand 各 13 采样)、 + skill/palbang(1 窗 wlen 130 / hit_limit 18 + events 3 —— 验证 List 修复没串掉 MotionEventData) + formats/CMakeLists.txt —— formats.msa_loop_data 加 M2_ASSETS 环境透传 +测试:`ctest --test-dir build` → 21/21(`formats.msa_loop_data` 含 live 断言,`M2_ASSETS` OK); + `netbridge_test` / `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / + `combat_fx_test` / `player_motion_test` → PASS(7/7)。 +验收:纯离线,合成 + 4 个真实 PC .msa(含真正的双命中窗)双覆盖。 + 未接:net_play.gd 还没消费 hit_windows(命中判定 = 修改 4,增量 83)。 +下一批次(增量 83):§3.5 修改 4 —— `net_play.gd` 每帧按动作时间落在某个 `hit_window` + [start,end] 内时对附近实体做几何判定(`AttackingBone` 世界位 + `weapon_length` + + `hit_windows[].samples` 扫掠球),`OnHit` → `client.attack(uSkill, victimVID)`(去重按 + `hit_limit_count` / `invisible_time` 冷却),帧末把被击退者打包 `M2Client::sync_positions()` + (≤16)。之后武器种类 → `combo_motion_mode` 映射(增量 84)。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 修改 4(命中窗几何判定 → OnHit,§3.5 收官) +状态:🟡 部分完成(增量 83)——§3.5 修改 1-5 全落地;几何判定为近似留桩 +参考: + REF/UserInterface/InstanceBaseBattle.cpp:413 CInstanceBase::AttackProcess + —— CanCheckAttacking() 为真时遍历全实例做 CheckAttacking()(先查双方 IsInSafe, + 再 AttackingProcess);命中记 m_dwLastDmgActorVID + REF/GameLib/ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess + —— 距离粗筛(HUGE 500² / 普通 300²);遍历 pad->HitDataContainer 各 THitData; + m_HitDataMap[&hitData] 查重(MOTION_TYPE_COMBO 或 cooldown 未到即 continue); + mapHitPosition 取 (motiontime-elapsed, motiontime] 的采样构造动态球(c_fAttackRadius=20), + 按 GetRotation() 旋到世界;vs victim m_DefendingPointInstanceList 的动态圆柱; + 命中数上限:NORMAL/COMBO 16、SKILL pad->iHitLimitCount; + 命中 → __ProcessDataAttackSuccess → OnHit(uSkill = m_kCurMotNode.uSkill) + REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit + —— SetTarget(vid, FALSE); if(isSendPacket) SendAttackPacket(uSkill, vid); + if(!victim.IsPushing()) return; ... TEMP_Push + m_kVctkVictim.push_back + REF/UserInterface/PythonPlayerEventHandler.cpp:194 FlushVictimList + —— m_kVctkVictim 非空 → 一个 TPacketCGSyncPosition + 每个 element(SYNC_POSITION_COUNT_LIMIT=16) + REF/GameLib/GameType.h:52 NRaceData::EMotionType { NONE=0, NORMAL=1, COMBO=2, SKILL=3 } +改动: + project/net_play.gd —— + · 新常量 MOTION_TYPE_{NONE,NORMAL,COMBO,SKILL}、HIT_ATTACK_RADIUS_CM=20、 + HIT_VICTIM_RADIUS_CM=45、HIT_FRONT_COS=0.5、SYNC_POSITION_LIMIT=16 + · 新状态 _swing_start_t / _swing_skill / _hit_windows / _hit_motion_type / + _hit_limit_count / _hit_invisible_time / _hit_dedup{wi:{vid:cooldown}} / _victim_flush[] + · _begin_hit_windows():挥击起手从 get_motion_data() 缓存 hit_windows / motion_type / + hit_limit_count / invisible_time,清 _hit_dedup,置 _swing_start_t = _now() + · _emit_swing():不再无条件 client.attack;调 _begin_hit_windows() 后只有 + _hit_windows 为空(headless / 缺资源)才立即 client.attack(_swing_skill, _vid_reserved) + · _attack_process(dt):t=_now()-_swing_start_t;t-dt > 所有窗 end → 收起 _hit_windows; + 主角 in_safe → 跳过;遍历 client.get_entities() 可攻击 / 未死 / 双方非安全区实体, + net_world.node_for(vid) 取世界位;窗 [ws,we] 与 [t-dt,t] 相交 → _hit_deduped() / + _hit_geometry() / _register_hit() → client.attack(_swing_skill, vid); + 被击退目标(knock_down/stunned)→ _victim_flush.append({vid,x,y}) + · _hit_deduped(wi,vid):已命中过 && (COMBO || cooldown 未到) → true + · _register_hit(wi,vid):记 cooldown=_now()+invisible_time;非首次且 m.size() > cap + (NORMAL/COMBO 16、else hit_limit_count)→ false(本帧跳过) + · _hit_geometry():reach = max(WeaponLength, 窗内 (t-dt,t] 采样最大 hypot(pos.x,pos.y)); + 缺则 CLICK_DIST_DEFAULT_CM;命中 = 目标水平距 ≤ (reach+20+45)cm 且在正面 ±60° 弧内 + · _clear_combo():附带清 _hit_windows / _hit_dedup + · _process:_combo_process() 后插 _attack_process(dt);帧末 _flush_victim_list() + · _flush_victim_list():_victim_flush 非空 → client.sync_positions(_victim_flush.slice(0,16)) + → clear(slice 返回新数组:兼做 16 上限截断 + 不把马上 clear() 的引用传给下游) + project/combat_fx_test.gd —— + · FakeClient 加 get_entities()(ents.values())/ sync_positions()(记 synced) + · 新 class FakeNetWorld { node_for(vid) } + · combo 段 + mod-4 段各加一次 `await process_frame`:SceneTree 脚本里 add_child 到 root + 的节点要等一帧才 is_inside_tree(),几何判定读 global_position 故需入树 + · 新 6 段:有命中窗 → 挥击不发 CG_ATTACK;推进到窗内 → OnHit client.attack([0,2000]) + + 帧末 CG_SYNC_POSITION;COMBO 同窗同目标只一次;目标转身后 → 出正面弧不命中; + 动作越过所有窗 → _hit_windows 收起 +测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7)。 +验收:纯离线。几何判定是 reach + 正面弧近似(非动态圆柱扫掠)——真实版需骨骼矩阵 + + defending sphere 数据,与 §3.1 actor 间碰撞一起留桩。未验证:真实服务端对 + CG_ATTACK / CG_SYNC_POSITION 时序的接受(联机 E2E)。 +下一批次(增量 84):武器种类 → `combo_motion_mode` 映射(当前 net_play.gd + `combo_motion_mode` 恒 MOTION_MODE_GENERAL;应按装备武器 subtype 映射到 + GENERAL / BOW / DUAL / 骑乘等 CRaceMotionData::EMode)。之后 §3.6 箭矢 FLY 事件。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 修改 6(武器种类 → combo_motion_mode) +状态:🟡 部分完成(增量 84)——§3.5 修改 1-6 全落地;弓箭 __GetBowRange() 留给增量 85+ +参考: + REF/UserInterface/InstanceBase.cpp:2851 CInstanceBase::RefreshState + —— dwPartItemID = GetPartItemID(PART_WEAPON);byItemType = GetType(), + bySubType = GetWeaponType()(= m_ItemTable.bSubType) + IsPoly() → MODE_GENERAL + IsWearingDress() → MODE_WEDDING_DRESS + IsHoldingPickAxe() → 骑乘 MODE_HORSE / 否则 MODE_GENERAL + ITEM_TYPE_ROD → 骑乘 MODE_HORSE / 否则 MODE_FISHING + m_kHorse.IsMounting() → switch(bySubType) SWORD/TWO_HANDED/DAGGER/FAN/BELL/BOW + → MODE_HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_HORSE_{FAN,BELL,BOW} + / default MODE_HORSE + else → switch(bySubType) SWORD/TWO_HANDED/DAGGER/BOW/FAN/BELL + → MODE_{ONEHAND,TWOHAND,DUALHAND}_SWORD / MODE_BOW / MODE_FAN / MODE_BELL + / ARROW,default MODE_GENERAL + REF/GameLib/RaceMotionData.h:30 CRaceMotionData::EMode + —— 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=10, HORSE_TWOHAND=11, + HORSE_DUALHAND=12, HORSE_BOW=13, HORSE_FAN=14, HORSE_BELL=15, WEDDING_DRESS=16 + REF/GameLib/ItemData.h:22 EItemType(WEAPON=1, ROD=13, PICK=24) + REF/GameLib/ItemData.h:63 EWeaponSubTypes + —— SWORD=0, DAGGER=1, BOW=2, TWO_HANDED=3, BELL=4, FAN=5, ARROW=6 +改动: + project/net_play.gd —— + · 补全 MOTION_MODE_*(EMode 全 17 项)+ ITEM_TYPE_WEAPON/ROD/PICK + + WEAPON_SUB_{SWORD,DAGGER,BOW,TWO_HANDED,BELL,FAN,ARROW} + WEAR_WEAPON=4 / PART_WEAPON=1 + · static motion_mode_for(item_type, weapon_sub, mounted, poly, dress):逐分支复刻 + RefreshState 的 SetMotionMode(顺序 Poly → 婚纱 → 镐 → 钓竿 → 骑乘 switch → else switch) + · _equipped_weapon_vnum():主角 parts[WEAPON] 非 0 优先,否则 get_equipment()[WEAR_WEAPON].vnum + · _refresh_motion_mode():mounted = mount_vnum!=0 || mounting;poly = polymorph||poly_vnum; + dress = wedding_dress;vnum>0 && proto → proto.item(vnum).{type,sub_type}; + → set_combo_motion_mode(motion_mode_for(...))(换模式内部 _clear_combo()) + · _on_inv_changed_mode(window,_cell):window==2(EQUIPMENT) → _refresh_motion_mode() + · setup():连 inventory_changed → _on_inv_changed_mode;连 mount_changed → (v==主角) _refresh_motion_mode + · _on_entity_info() 末尾调 _refresh_motion_mode()(主角设定 / GC_CHARACTER_UPDATE parts 变) + project/combat_fx_test.gd —— + · 新 class FakeProto { item(vnum) -> {type,sub_type} } + · 15 段 motion_mode_for 断言(sword/two-handed/dagger/bow/fan/bell/arrow、rod±骑乘、 + 骑乘 sword/bow/arrow、poly、婚纱覆盖) + · 2 段端到端:proto 桩 + fc.ents[1000].parts=[0,19,0,0] → _refresh_motion_mode() + → combo_motion_mode == DUALHAND_SWORD 且 _combo_index 归零;再 mount_vnum=20101 + → HORSE_DUALHAND_SWORD +测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7), + `equip_model_test` 亦 PASS。 +验收:纯离线。映射表 1:1 对齐 RefreshState;换武器 / 上下马 / 换装 / 时装 parts 变都能触发。 + 未接:弓箭 `__GetBowRange()`(弓模式命中 / 点击距离,§3.5 / §3.6 一起做);婚纱 dress 标志 + 本工程实体暂无(读 e.wedding_dress,缺则 false)。 +下一批次(增量 85):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动 + `OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉固定 1.5s 定时器; + 连带弓模式 `__GetBowRange()` 命中 / 点击距离。 +``` + +```text +批次:2026-09-03 / W2 / §3.5 修改 7(弓箭 __GetBowRange() 可点击 / 攻击距离) +状态:✅ 完成(增量 85)——§3.5 修改 1-7 全落地;§3.6 箭矢 FLY 事件留给增量 86 +(增量 84 记录里「下一批次」原写「§3.6 + 连带弓模式距离」,这里把弓模式距离单独切成增量 85, + 更小更好回归;FLY 事件顺延增量 86。) +参考: + REF/UserInterface/InstanceBaseBattle.cpp:294 CInstanceBase::NEW_IsClickableDistanceDestInstance + fDistance = 150.0f; + if (IsBowMode()) fDistance = __GetBowRange(); + if (dst.IsNPC()) fDistance = 500.0f; + if (dst.IsResource()) fDistance = 100.0f; + return m_GraphicThingInstance.IsClickableDistanceDestInstance(dst..., fDistance); + —— 后写的 if 覆盖前面:NPC / 采集物距离优先于弓箭距离。 + REF/UserInterface/InstanceBase.cpp:694 CInstanceBase::__GetBowRange + float fRange = 2500.0f - 100.0f; + if (__IsMainInstance()) + fRange += float(IAbstractPlayer::GetSingleton().GetStatus(POINT_BOW_DISTANCE)); + return fRange; + —— 注意参考端把 POINT_BOW_DISTANCE 按 cm 直加,不 ×100(PythonPlayerSkill.cpp:393 + 另一处技能射程才 ×100);本工程照抄不 ×100。 + REF/GameLib/ActorInstance.cpp:258 CActorInstance::IsBowMode + return GetMotionMode()==MODE_BOW || GetMotionMode()==MODE_HORSE_BOW; + REF/UserInterface/Packet.h:1550 EPointTypes:POINT_BOW_DISTANCE = 34 +改动: + extension/src/net/wire.h —— enum 补 POINT_BOW_DISTANCE = 34 + extension/src/net/m2_client.cpp —— get_points() 增 d["bow_distance"] = p.v[POINT_BOW_DISTANCE] + project/net_play.gd —— + · 常量 BOW_RANGE_BASE_CM := 2500.0 - 100.0(保留字面式,对齐参考) + · 成员 _bow_distance_bonus := 0.0(GetStatus(POINT_BOW_DISTANCE)) + · _bow_range_cm() -> BOW_RANGE_BASE_CM + _bow_distance_bonus(= __GetBowRange) + · _clickable_distance(te):cm=150 → _is_bow_mode() 则 cm=_bow_range_cm() → + match _entity_kind:NPC 500 / 采集物 100(覆盖弓箭距离);末尾 /CM 转米 + · _on_points(p):_bow_distance_bonus = float(p.get("bow_distance", 0)) + (_is_bow_mode() 增量 84 已有:combo_motion_mode ∈ {MODE_BOW, MODE_HORSE_BOW}) + project/combat_fx_test.gd —— §3.5 段加 8 段断言: + 非 bow 玩家/怪 reach=150 / bow 基础 _bow_range_cm()=2400 / bow 目标 reach=__GetBowRange / + NPC 覆盖=500 / 采集物覆盖=100 / HORSE_BOW 也算 bow / +300 加成叠加 / _on_points 读 bow_distance +测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);7 个 GDScript 回归 → PASS(7/7), + `equip_model_test` 亦 PASS。 +验收:纯离线。距离分支 + __GetBowRange 公式 1:1 对齐参考端。未接:POINT_BOW_DISTANCE 的真服 + 下发路径未联机核验(`mut_set_point` 已能把任意 type 存进 v[],get_points 已透出);几何判定 + 仍是 reach + 正面弧近似(随 §3.1 actor 碰撞升级)。 +下一批次(增量 86):§3.6 箭矢 FLY 事件 —— `.msa` `MOTION_EVENT_TYPE_FLY` 驱动 + `OnSetFlyTarget`→`CG_FLY_TARGETING` / `OnShoot`→`CG_SHOOT`,替掉 `game_scene.gd` 约 1.5s + 定时器兜底(`PythonPlayerEventHandler.cpp:235`)。 +``` + +```text +批次:2026-09-03 / W2 / §3.6 弓箭 FLY 事件(OnSetFlyTarget / OnShoot) +状态:✅ 完成(增量 86)——§3.6 修改 1-3 全落地 +参考: + REF/UserInterface/PythonPlayerEventHandler.cpp:235 + CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget() + → SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING + CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex) + → SendShootPacket(dwSkillIndex) // CG_SHOOT + REF/GameLib/ActorInstanceMotion.cpp:372 CActorInstance::__SetMotion 尾 + if (m_pFlyEventHandler && __IsNeedFlyTargetMotion()) m_pFlyEventHandler->OnSetFlyTarget(); + —— OnSetFlyTarget 在弓动作「设定」时触发一次,不是 .msa motion event。 + REF/GameLib/ActorInstanceMotionEvent.cpp:255 CActorInstance::ProcessMotionEventFly + if (iType != MOTION_EVENT_TYPE_FLY) return; + if (m_kFlyTarget.IsValidTarget()) { + CFlyingManager::CreateFlyingInstanceFlyTarget(...); // 飞行物(本工程未做) + if (m_pFlyEventHandler) m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill); + } + —— OnShoot 在 .msa 的 FLY 帧触发,uSkill = 本次挥击技能号(普攻 0)。 + REF/GameLib/ActorInstanceFly.cpp:4 CActorInstance::OnGetFlyTargetPosition + v3Center = m_v3Center;(m_fRadius<=0 先 BuildBoundingSphere) + D3DXVec3TransformCoord(&v3Center, &v3Center, &GetTransform()); return v3Center; + —— 目标包围球中心的世界坐标;平面分量与脚点一致,CG_FLY_TARGETING 只带平面坐标。 + REF/UserInterface/PythonPlayerInput.cpp:358 __OnPressActor + rkInstMain.NEW_AttackToDestInstanceDirection(rkInstVictim, + rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&rkInstMain, &rkInstVictim)); +改动: + project/net_play.gd —— + · 新信号 bow_shot_fired(skill:int) + · _emit_swing() 头部:_is_bow_mode() 时 _send_fly_target() + bow_shot_fired.emit(_swing_skill) + · _send_fly_target():vid = _target_vid ?: _vid_reserved;net_world.node_for(vid) → + MapCoord.to_server_cm(global_position) → client.add_fly_targeting(vid, x, y) + project/game_scene.gd —— + · 删 _pending_shoot_skill:int + get_tree().create_timer(1.5) 兜底 + · 新 _pending_shots:Array[int] + const MAX_PENDING_SHOTS := 8 + _queue_shot(skill)(满丢最旧) + · quickbar.skill_activated 的 is_ranged 分支改 _queue_shot(sid) + · net_play.bow_shot_fired.connect(_queue_shot) + · _on_local_motion_event() type 6:not _pending_shots.is_empty() → client.shoot(_pending_shots.pop_front()) + project/combat_fx_test.gd —— FakeClient 加 add_fly_targeting/shoot + fly_targets/shots; + §3.6 段 6 断言(非弓无 FLY / 弓挥击发 CG_FLY_TARGETING(vid) / 坐标=目标 server-XY / + bow_shot_fired(0) / 无目标不发) + project/eterngrn_polish_test.gd —— FLY 段改 FIFO 连射断言(连压 77/78 → 依次 pop → 队空不发) +测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);9 个 GDScript 回归 → PASS + (combat_fx / eterngrn_polish / gamescene / netplay / player_move / player_motion / + netbridge / p2b / equip_model)。 +验收:纯离线。OnSetFlyTarget(动作起手)/ OnShoot(.msa FLY 帧)两个时刻分开,FIFO = AutoClear + + 并发多发,1:1 对齐参考端。未接:飞行物实体本身(CFlyingManager / FlyingInstance 的生成、 + 轨迹、命中 OnShootDamage)、__CanShot 弓箭合法性(PythonPlayerInput.cpp:343)、真服 + CG_FLY_TARGETING 的 z 分量(当前只发平面坐标)。 +下一批次(增量 87):§3.8 技能合法性 —— player_skill.gd 三层校验 + (ClickSkillSlot 冷却 / MP / 前置,PythonPlayerSkill.cpp:108)。 +``` + +```text +批次:2026-09-03 / W2 / §3.8 TSkillData 判定位解析(修改 2) +状态:🟡 部分完成(增量 87)——§3.8 修改 2 落地,修改 1/3 待续 +参考: + REF/UserInterface/PythonSkill.h:92-115 enum { SKILL_ATTRIBUTE_NEED_TARGET=(1<<0) … SEARCH_TARGET=(1<<23) } + REF/UserInterface/PythonSkill.h:120-128 enum { SKILL_NEED_WEAPON_SWORD=(1</skilldesc.txt TSV。故本修改在该文件内落地。) +改动: + project/ui/skill_table.gd —— + · const ATTR{}(24 个 SKILL_ATTRIBUTE_* 记号名→位)、WEAPON_* 序号常量、 + const NEED_WEAPON{}(9 个记号名→1<= SKILL_MAX_NUM → return + REF/UserInterface/Packet.h:2041 SKILL_MAX_NUM = 255 + REF/UserInterface/PythonPlayerSkill.cpp:320 __CheckShortMana(fCur) + · c_iSkillIndex_Summon(131) 豁免 → false + · iNeedSP = GetNeedSP(fcurEfficientPercentage);icurSP = GetStatus(POINT_SP) + · !IsToggleSkill() && iNeedSP == 0 → chat "strange game data"; return true(坏数据拦截) + · CanUseIfNotEnough() → icurSP <= 0 → return true + · 否则 -1 != iNeedSP && iNeedSP > icurSP → return true;default false + REF/UserInterface/PythonPlayerSkill.cpp:354 __CheckShortLife(fCur) + · !IsUseHPSkill() → return false(放行) + · dwNeedHP = GetNeedSP(fcurEfficientPercentage)(HP 需求也读 NeedSP 公式,参考端怪癖) + · dwNeedHP <= GetStatus(POINT_HP) → false;否则 true + REF/GameLib/PythonSkill.cpp:1426 GetNeedSP(float fSkillPoint) —— strNeedSPFormula 空 → 0;否则 poly 求值 + REF/GameLib/PythonSkill.cpp:1250 ProcessFormula —— 变量 "SkillPoint" / "k" 绑定传入 float + REF/UserInterface/PythonPlayer.cpp:1037 fcurEfficientPercentage = LocaleService_GetSkillPower(lv)/100 + REF/EterLib/Locale.cpp:126 LocaleService_GetSkillPower —— SKILL_POWER_NUM=50;lv>=50 → 0; + INTERNATIONAL 表 {0,5,6,8,10,12, 14,16,18,20,22, 24,26,28,30,32, 34,36,38,40,50, + 52,54,56,58,60, 63,66,69,72,82, 85,88,91,94,98, 102,106,110,115,125, 125} + REF/UserInterface/PythonPlayerModule.cpp:15 c_iSkillIndex_Summon = 131 +改动: + extension/src/net/net_bounds.h —— +SKILL_MAX_NUM=255(注释引 Packet.h:2041 / PythonPlayerSkill.cpp:108) + +inline constexpr bool skill_index(int64_t v) { return v > 0 && v < SKILL_MAX_NUM; } + extension/src/net/m2_client.cpp —— use_skill(int skill_id, int target_vid)(形参增量 52 起即带符号) + 护栏:skill_id <= 0 || !bounds::u32(skill_id) → !mtnet::bounds::skill_index(skill_id) + (target_vid 仍 >= 0 && bounds::u32;cast_skill() 的 motion_idx ∈ [0,0x7f] 不动) + extension/tests/net_bounds_test.cpp —— +4 CHECK(SKILL_MAX_NUM==255 / 端点 1、254 / + 越界 0、255、-1 / 拒 0x7fffffff+1) + project/ui/skill_table.gd —— + · _load_cooltime → _load_msk(调用点 line 152),+解析 .msk NeedSPFormula → entry["need_sp_formula"] + · +const _SKILL_POWER[](Locale.cpp INTERNATIONAL 表,41 项) + · +_skill_point(level): level<0 || level>=50 → 0.0;否则 _SKILL_POWER[min(level,40)]/100.0 + · +need_sp(id, level): 无 need_sp_formula → -1(POC 约定,参考返回 0,避免误挡); + 否则 Expression.parse(formula, ["SkillPoint"]).execute([_skill_point(level)]) → int + · +need_hp(id, level) → 直接 return need_sp(id, level)(照抄 __CheckShortLife 复用 GetNeedSP) + · cooldown_of 的 SkillPoint 改走 _skill_point(level)(原为 level/max_level);max_level 仍解析 + project/player_skill.gd —— + · +const SKILL_INDEX_SUMMON = 131 + · +var cur_sp := -1 / var cur_hp := -1(-1 = 未知不判) + · _check_short_mana 实装:table 无 need_sp / summon 131 / cur_sp<0 → false; + !is_toggle && need_sp==0 → true;can_use_if_not_enough → cur_sp<=0;否则 need_sp!=-1 && need_sp>cur_sp + · _check_short_life 实装:!is_use_hp / cur_hp<0 / need_hp<0 → false;否则 need_hp > cur_hp + (命中点:check_skill_usable 非 dash else 支路,既有 :270-273 已转 NOT_ENOUGH_HP / NOT_ENOUGH_SP) + project/net_play.gd —— skill_context()(~line 618)+cur_hp / cur_sp(读 client.get_points() 的 hp / sp,缺则 -1) + project/player_skill_test.gd —— +“(c) §3.8 修改 3”段(_skill_point(20)==0.5、_skill_point(0)/(50)==0、 + need_sp(1,5)==52、need_hp==need_sp、SP 不足→NOT_ENOUGH_SP、SP 充足→ok、SP 未知(-1)→ok、 + 无公式技 + SP 0→不挡、非 USE_HP 技 + HP 1→不报 NOT_ENOUGH_HP);_reset 补 cur_hp=-1 / cur_sp=-1 +测试:`ctest --test-dir build` → 21/21(含 M2_ASSETS live);`ctest -R net.bounds` → 1/1; + 13 个 GDScript 回归 → PASS(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / + player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。 + eterngrn_polish_test 的 stderr "FAIL: motion_event: sound → play_at" 为该测试既有 async 竞态 + (_init 未 await _run),非本批次触及,仍 exit 0 + PASS。probe 脚本确认 need_sp(1,5)=52 / + _skill_point(20)=0.5 / formula="40 + (100 * SkillPoint)"。 +验收:纯离线。use_skill 上限对齐 ClickSkillSlot 的 SKILL_MAX_NUM 门。__CheckShortMana / + __CheckShortLife 顺序 1:1 对齐 PythonPlayerSkill.cpp:320/354。架构差异:need_sp 无公式返回 -1 + (参考 0),HP/SP 由 net_play.skill_context() 经 client.get_points() 注入、-1 不判。 + shipped skilldesc.txt 无 USE_HP 技能,故 _check_short_life 对现有技能实为放行(与参考等价)。 +下一批次(增量 90):§3.10 技能升级门(CLIENT-GAP.md audit 10)—— 训练 / 读书 / 大师书 + 可升级判定 + M2Client 升级请求参数校验。 +``` + +```text +批次:2026-09-03 / W2 / §3.10 技能升级门(加号按钮门控 + skill_up 边界收紧) +状态:✅ 完成(增量 90) +参考: + POC/assets/root/uicharacter.py:984 RefreshSkillPlusButtonList() + · isLoaded==0 → return; RefreshSkillPlusPointLabel() + · not __CanUseSkillNow() → return(技能组门) + · curSelectedSkillGroup==PAGE_HORSE ? __RefreshSkillPlusButton("HORSE") : __RefreshSkillPlusButton("ACTIVE") + · __RefreshSkillPlusButton("SUPPORT") + POC/assets/root/uicharacter.py:1270 __CanUseSkillNow() —— 0 == net.GetMainActorSkillGroup() → False;else True + POC/assets/root/uicharacter.py:937 __RefreshSkillPlusButton(name) + · HIDE_SUPPORT_SKILL_POINT and name=="SUPPORT" → return(本资源 HIDE_SUPPORT_SKILL_POINT=True, :21) + · HideAllSlotButton(); slotStatType = skillPageStatDict[name](SUPPORT→SKILL_SUPPORT 27 / + ACTIVE→SKILL_ACTIVE 28 / HORSE→SKILL_HORSE 113,:225-228); 0==slotStatType → return + · statPoint = player.GetStatus(slotStatType); if statPoint > 0: for 每槽: + skillIndex/skillGrade/skillLevel; skillIndex==0 → continue; skillGrade!=0 → continue + name=="HORSE": GetStatus(LEVEL) >= skill.GetSkillLevelLimit(idx) → (skillLevel<20 → ShowSlotButton) + else: (SUPPORT 白名单 SHOW_LIMIT_SUPPORT_SKILL_LIST=[] 空 → 无限制) CanShowPlusButton(idx,lv,pt) → ShowSlotButton + POC/assets/root/uicharacter.py:925 CanShowPlusButton(idx, lv, pt) —— idx==0 → False; + not skill.CanLevelUpSkill(idx, lv) → False; else True + REF/UserInterface/PythonSkill.cpp:1833 skillCanLevelUpSkill(idx, lv) + · lv >= GetMaxLevel() → FALSE + · isRequirement → 前置技能 / 前置属性检查(本 build 里 isRequirement 恒 FALSE,:1471,构造默认, + strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 全程无人填充 → 分支永不进入) + · dwSkillAttribute & SKILL_ATTRIBUTE_CANNOT_LEVEL_UP (1<<18) → FALSE + · else TRUE ⇒ 本 build 化简为 lv < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP) + REF/UserInterface/PythonSkill.cpp:1741 skillGetSkillLevelLimit(idx) = c_pSkillData->byLevelLimit + · byLevelLimit 来源:skilldesc LEVEL_LIMIT 列(DESC_TOKEN_TYPE=26)或 .msk levellimit 记号 + · 本 40250:shipped skilldesc.txt 仅 22 列(无 26 列)、无 .msk 带 levellimit → 恒 0 → 马术页等级前置门恒真 + REF/UserInterface/PythonSkill.cpp:1392 SSkillData::GetMaxLevel() = byMaxLevel + REF/UserInterface/PythonSkill.cpp:1462 SSkillData 构造:byMaxLevel = 20(缺 .msk maxlevel / skilldesc MAX_LEVEL 时的默认) + REF/UserInterface/PythonSkill.cpp:280 RegisterSkillDesc —— LEVEL_LIMIT=col26 / MAX_LEVEL=col27 仅在 TokenVector.size() 够时解析 + REF/UserInterface/Packet.h:1540-1541,1641 POINT_SUB_SKILL=27 / POINT_SKILL=28 / POINT_HORSE_SKILL=113 +改动: + extension/src/net/wire.h —— EPointTypes +POINT_SUB_SKILL=27 / +POINT_SKILL=28 / +POINT_HORSE_SKILL=113 + extension/src/net/m2_client.cpp —— + · get_points() +d["skill_active"]=p.v[POINT_SKILL] / +d["skill_support"]=p.v[POINT_SUB_SKILL] + / +d["skill_horse"]=p.v[POINT_HORSE_SKILL](镜像增量 85 的 bow_distance) + · skill_up(int skill_id) 护栏 skill_id<=0 || skill_id>=bounds::POINT_COUNT + → !mtnet::bounds::skill_index(skill_id)(= 0= skill_level_limit(id) ? level < 20 : false; else can_level_up_skill(id, level) + · 按钮创建仍在 _rebuild() 门于 can_level_up(id) && !is_passive(id)(静态属性),refresh() 只切 visible + project/skill_test.gd —— + · FakeClient +skill_group=1 / +points={level:30, skill_active:5, skill_support:3, skill_horse:2} + / +get_skill_group() / +get_points() + · +§3.10 段:max_level_of(1)==30(samyeon.msk)/ (121)==20(构造默认)、skill_level_limit(1)==0、 + can_level_up_skill(1,5/29)==true、(1,30/31)==false、(125,1)==false(CANNOT_LEVEL_UP)、(9999,1)==false; + 主动页 skill 1 加号可见、skill 16 skillGrade!=0 隐、skill_group==0 隐、skill_active==0 隐、 + 辅助页整页隐、马术页 skill_horse>0 && level<20 可见 / skill_horse==0 隐 +测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21;13 个 GDScript 回归 → PASS + (player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion / + netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的 + stderr "FAIL: motion_event" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。 +验收:纯离线。加号显隐三段(RefreshSkillPlusButtonList / __RefreshSkillPlusButton / CanShowPlusButton) + 逐行对齐 uicharacter.py。CanLevelUpSkill 在本 40250 build 因前置字段无人填充化简为 + 「未满级 && !CANNOT_LEVEL_UP」,GetSkillLevelLimit 恒 0(无源数据列/记号)——两者均为 + 数据事实而非删减。skill_up 发包(/skillup )不变,升级结果由 GC_SKILL_LEVEL 经 + skills_changed 驱动 refresh(),无本地伪造。 +下一批次(增量 91):§3.4 __ChangeTargetToPickedInstance 拾取回退 + 扇形/圆形多目标 + fly-targeting;随后 §3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中。 +``` + +```text +批次:2026-09-03 / W2 / §3.4 其余三种预约模式 + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting +状态:✅ 完成(增量 91) +参考: + REF/UserInterface/PythonPlayerInputMouse.cpp:283 NEW_RefreshMouseWalkingDirection() + · MODE_CLICK_ITEM (:290):GetGroundItemPosition(iid, &pos) + · 找不到 → __ClearReservedAction(); return + · GetDistance(main, pos) < 20.0f → SendCharacterStatePacket(pos, FUNC_WAIT); + SendClickItemPacket(iid); NEW_Stop(); __ClearReservedAction() + · else NEW_MoveToDestPixelPositionDirection(pos) + · MODE_CLICK_POSITION (:320):if (!isLock()) if (NEW_IsEmptyReservedDelayTime(elapsed)) + { NEW_MoveToDestPixelPositionDirection(m_kPPosReserved); __ClearReservedAction(); } + · MODE_USE_SKILL (:340):pkInstReserved = NEW_FindActorPtr(m_dwVIDReserved) + · NULL → __ClearReservedAction() + · fDistance = GetDistance(main, reserved); if (IS_HUGE_RACE(race)) fDistance -= 200.0f + · fDistance < float(m_dwSkillRangeReserved) → { SetTarget(vid); if (__UseSkill(slot)) __ClearReservedAction(); } + · else NEW_MoveToDestInstanceDirection(reserved) + REF/UserInterface/PythonPlayerInput.cpp:836 __ReserveClickGround(x,y,z) —— MODE_CLICK_POSITION; + m_kPPosReserved=(x,y,z); m_fReservedDelayTime=0.1f + REF/UserInterface/PythonPlayerInput.cpp:854 __ReserveUseSkill(slot,vid,range) —— MODE_USE_SKILL; + m_dwSkillSlotReserved=slot; m_dwVIDReserved=vid; m_dwSkillRangeReserved=range; + if (m_dwSkillRangeReserved > 100) m_dwSkillRangeReserved -= 10 + REF/UserInterface/PythonPlayerInput.cpp:324 __OnPressActor —— 预约技能 + __CheckDashAffect 生效时 + 只 m_dwVIDReserved=vid、不 SetTarget / 不施法 + REF/UserInterface/PythonPlayerInput.cpp:186 __ChangeTargetToPickedInstance(vid) → SetTarget(vid) + :130 SetTarget —— IsTargetableInstance(PC/怪) 才选中,否则 ClearTarget() + REF/UserInterface/PythonPlayerSkill.cpp:680-746 __UseSkill 内 if (dwTargetMaxCount>0 && pkInstTarget) 块 + · dwTargetMaxCount = GetTargetCount(fEfficientPct); dwRange = __GetSkillTargetRange() + · dwTargetCount=1; FAN→NEW_GetInstanceVectorInFanRange / CIRCLE→NEW_GetInstanceVectorInCircleRange + · for each:count>=max break;target!=each && !each->IsDead() → + AddFlyTargetInstance(each); SendAddFlyTargetingPacket(each->VID, each->OnGetFlyTargetPosition()); count++ + · while (count0 → return; + pc.walk_to(_ppos_reserved)+_clear_reserved());USE_SKILL → _reserve_process_use_skill() + (client.get_entity(vid) 空 / net_world.node_for(vid) 空 → _clear_reserved(); + dist_cm = global_position.distance_to(tnode)*CM;_is_huge_race(race) → dist_cm -= 200; + dist_cm < _skill_range_reserved → _target_vid!=vid 时 set_target+emit+_refresh_target_hud(), + 再 _invoke_reserved_skill() 成功才 _clear_reserved();else pc.walk_to(tnode)) + · _clear_reserved() 追加复位 _ppos_reserved=ZERO / _skill_range_reserved=0.0 + · +use_skill_hook := Callable() / _invoke_reserved_skill()(slot<0 → false;hook.is_valid() → + bool(hook.call(slot));else false) + · +reserve_click_item(iid) / reserve_click_ground(pos)(_reserved_delay=0.1)/ + reserve_use_skill(vid, slot, range_cm)(range_cm>100 → -10)/ pick_ground_item(iid)(清预约 + + _auto_attack_vid=0;distance*CM < CLICK_DIST_RESOURCE_CM → _send_state(FUNC_WAIT)+pickup_item+ + 解冻;else reserve_click_item(iid)) + · +_ground_item_world_pos(iid)(扫 client.get_ground_items(),MapCoord.to_world(p)) + · +_is_huge_race(race)(race<=0 / 无 proto.mob → false;m=proto.mob(race);空→false; + int(m.size)>=3 or int(m.rank)>=4) + · +_change_target_to_picked_instance(vid) → bool(e 空 / dead / _entity_kind ∉ [0,2] → + _target_vid=0 + client.set_target(0) + target_changed.emit(0) + hud.clear_target() + false; + else _target_vid=vid + client.set_target(vid) + emit + _refresh_target_hud() + true) + · +_bearing_deg(from, to) = rad_to_deg(atan2(d.x, -d.z)) / +_dir_from_bearing(deg) = + Vector3(sin(r), 0, -cos(r)) / +_entity_world_pos(vid)(node_for → global_position; + else 实体 pos 经 MapCoord.to_world;else null) + · +_instances_in_circle_range(range_cm)(扫 get_entities(),跳 vid 0 / _main_vid / 非 _is_attackable, + dist_m <= range_cm/CM,按 dist_m 升序 → [{vid, dist_m, pos}]) + · +_instances_in_fan_range(range_cm, target_vid)(从圆形取,tp=_entity_world_pos(target_vid) 空→[]; + dst_rot=deg_to_rad(_bearing_deg(main, tp));rpm=(40-20)/1000;per cand: + each_dist_cm=min(dist_m*CM, 1000);half_fan=(40-20)-rpm*each_dist_cm+20; + absf(angle_difference(dst_rot, deg_to_rad(_bearing_deg(main, cand.pos)))) <= deg_to_rad(half_fan)) + · +_random_pos_in_fan_range(target_vid)(base_bearing = _bearing_deg(main, tp) 或 pc 朝向; + rot = base_bearing + randf_range(-10, 10);return main + _dir_from_bearing(rot) * (8000/CM)) + · +send_fly_targeting(target_vid, range_cm, max_count, shape) → int(无 add_fly_targeting / + max_count<=1 / target_vid==0 / shape==SINGLE / 无 pc.player → 0; + reach_cm = range_cm + _bow_distance_bonus*100;cands = FAN ? _instances_in_fan_range : _instances_in_circle_range; + count=1 extras=0;loop:count>=max break,跳 vid==target_vid / dead, + client.add_fly_targeting(vid, int(sc.x), int(sc.y)) with sc=_server_xy(pos),count++ extras++; + while count bool: return quickbar.activate_reserved(slot) + project/netplay_test.gd —— + · FakeClient +ground_items=[] / +skills=[] / calls +{"pickup": [], "fly": []}; + +get_entities() / pickup_item(vid)(calls.pickup.append)/ add_fly_targeting(vid,x,y)(calls.fly.append)/ + get_ground_items() / get_skills() + · +「5c」段:CLICK_ITEM 远→walk_to / 到手→pickup(777)+FUNC_WAIT+cleared / 物品消失→cleared; + CLICK_POSITION 延时门后 walk_to((9,0,-2))+cleared;USE_SKILL range>100 trim→790.0 / + 射程外→walk_to / 射程内→use_skill_hook([5])+set_target(4100)+cleared / 目标丢失→cleared; + _change_target_to_picked_instance(NPC 5000)→_target_vid==0 & set_target==[0]; + _change_target_to_picked_instance(怪 4100)→选中;send_fly_targeting FAN(4200 主目标 (0,0,-10)、 + 4201 (2,0,-10) & 4202 (-2,0,-10) 入扇、4203 (0,0,12) 背后排除;max 3 → 2 extras, + 主目标不重发,两侧翼入选);CIRCLE(4203 计入,max 4 → 3 extras); + FAN 名额不足 max 6 → 5 extras 含 ≥1 个 vid=0 随机落点;max_count<=1 → 0;SINGLE → 0。 + fly 测试前把实体 2000 & 4100 的 node+pos 移到 (9999,0,9999)(不 free,测试 #7-#11 仍引用), + 4200-4203 经 mk lambda 造(race:101,测试内无 proto → _entity_kind 返 0 → 可攻击/可选中) + project/skill_test.gd —— + · +§3.4 段(仅在有真实 skilldesc 资源时跑):is_fan_range(47) / fly_shape(47)==1 / + target_range(47)==2500 / target_count(47,1)==2 / target_count(47,20)==5 / + is_fan_range(91) / target_range(91)==1800 / target_count(91,20)==0(bipabu 无公式)/ + fly_shape(1)==0 / target_count(1,10)==0 +测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);13 个 GDScript 回归 → PASS + (player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion / + netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill)。eterngrn_polish_test 的 + stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。 +验收:纯离线。三种预约模式逐行对齐 NEW_RefreshMouseWalkingDirection(含 CLICK_ITEM 的 20cm + 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 IS_HUGE_RACE −200cm 修正)。 + 扇形/圆形补目标:主目标 CG_FLY_TARGETING + CG_USE_SKILL 仍由 M2Client::use_skill 发, + send_fly_targeting 只补附加目标(CG_ADD_FLY_TARGETING,含 vid=0 随机落点),几何逐行对齐 + NEW_GetInstanceVectorInFanRange / InCircleRange / GetRandomPositionInFanRange。 + 已文档化 seam:① IS_HUGE_RACE vnum 白名单未随源码发布 → proto.mob(race) size>=3 or rank>=4 近似; + ② __GetSkillTargetRange 与 __GetBowRange 在原版就一个 ×100 一个不 ×100,两处各自照抄; + ③ .msk Range token 用 _ascii_field 首个字节匹配,对含 AttackRange/SplashRange 前置的 .msk 会误配 + (shipped gwangyeok/bipabu 已 tr 验证只有一处 case-sensitive Range,安全); + ④ use_skill_hook 目前无 live producer(reserve_use_skill 无人调——为技能 click-cast 预留), + game_scene 把 hook 接到 quickbar.activate_reserved,测试直接注入 lambda; + activate_reserved 在 ready 槽位调 activate() 即返 true(对 `if (__UseSkill(slot))` 的保真差距)。 +下一批次(增量 92):§3.6 飞行物实体(CFlyingManager / FlyingInstance)生成 / 命中 + __CanShot 弓箭合法性。 +``` + +```text +批次:2026-09-03 / W2 / §3.6 飞行物实体 CFlyingManager / CFlyingInstance 生成 / 命中 + __CanShot 弓箭合法性 +状态:✅ 完成(增量 92) +参考: + REF/GameLib/FlyingObjectManager.h —— enum EIndexFlyType { NORMAL=0, FIRE_CRACKER=1, AUTO_FIRE=2 } + REF/GameLib/FlyingObjectManager.cpp + · CreateFlyingInstanceFlyTarget(dwID, v3Start, cr_FlyTarget, canAttack) —— CRC 查 CFlyingData*, + CFlyingInstance::New()->Create(...),push m_kLst_pkFlyInst,ID = m_IDCounter++ + · Update() —— while 迭代:if (!pkFlyInst->Update()) { CFlyingInstance::Delete(); i = erase(i); } else ++i + · CreateIndexedFly(dwIndex, pStartActor, pEndActor) —— switch byType: + NORMAL → start=posStart 像素坐标,target=pEndActor,canAttack=false + FIRE_CRACKER → fRot=fmod(GetRotation()-90+360,360)+frandom(-30,30),fDist=frandom(2000,5000), + fxRand=fDist*cos(rad(fRot)),fyRand=fDist*sin(rad(fRot)),fzRand=frandom(1000,2500), + start z+200,target=posStart+(fxRand,fyRand,fzRand) + AUTO_FIRE → start z+100,target=pEndActor + · DeleteAllInstances() —— PythonNetworkStreamPhaseLoading.cpp:187 调 + REF/GameLib/FlyingInstance.cpp + · Create(pData, startPos, target, canAttack) —— m_FlyTarget=target; m_canAttack=canAttack; + __SetDataPointer(pData, startPos); __SetTargetDirection(m_FlyTarget) + · __SetDataPointer —— m_v3Position=startPos; m_bAlive=true; m_fStartTime=now; + quat RotationYawPitchRoll(rad(m_fRollAngle-90), 0, rad(m_fConeAngle)); + opt m_bSpreading 随机扩散; m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0); + m_v3Accel=pData->m_v3Accel; m_fRemainRange=pData->m_fRange; m_iPierceCount=pData->m_iPierceCount + · __SetTargetDirection —— v3TargetPos=target.GetFlyTargetPosition(); + if (m_bMaintainParallel) v3TargetPos.z += 50; dir=normalize(v3TargetPos - m_v3Position); + __SetTargetNormalizedDirection —— SafeRotationNormalizedArc((0,-1,0), dir) 旋转 velocity + accel + · Update() 每帧: + if (!m_bAlive) return false; + homing:m_bIsHoming && m_fHomingStartTime+m_fStartTime < now && IsObject → AdjustDirectionForHoming + v3LastPosition=m_v3Position; m_v3Velocity += m_v3Accel*dt; m_v3Velocity.z += m_fGravity*dt; + v3Movement=m_v3Velocity*dt; _fMoveDistance=length(v3Movement); + fCollisionSphereRadius=max(_fMoveDistance*2, m_fCollisionSphereRadius); + m_fRemainRange -= _fMoveDistance; m_v3Position += v3Movement; + if (m_fRemainRange<0) → OnExplodingOutOfRange() + __Explode(false) + return false + 对象目标 && !m_bTargetHitted:square_distance_between_linesegment_and_point( + m_v3Position, v3LastPosition, targetPos) < m_fBombRange² → + m_bTargetHitted=TRUE; if (m_canAttack) pVictim->OnShootDamage(); + OnExplodingAtTarget(m_dwSkillIndex); if (m_iPierceCount) { m_iPierceCount--; __Bomb(); } else { __Explode(); return false; } + 位置目标:同线段判定 → __Explode(); return false + 怪物(m_bHitOnAnotherMonster):球 r=10 CullingMgr.ForInRange,m_HittedObjectSet 去重 → OnExplodingAtAnotherTarget + 背景(m_bHitOnBackground):GetTerrainHeight(x,-y) > z → OnExplodingAtBackground(); __Explode(); return false + 球 r=1 建筑判定 + return true + · __Explode(bBomb=true) —— if (!m_bAlive) return; m_bAlive=false; if (bBomb) __Bomb(); + · __Bomb() —— if (!m_pData->m_dwBombEffectID) return; 在 m_v3Position 生成 bomb effect + REF/GameLib/FlyingData.cpp __Initialize() —— m_fInitVel=200 / m_fConeAngle=0 / m_fRollAngle=0 / + m_fRange=500 / m_v3Accel=(0,0,0) / m_fHomingMaxAngle=3 / m_fHomingStartTime=0 / m_fGravity=0 / + m_bSpreading=false / m_bMaintainParallel=false / m_bHitOnBackground=false / m_bHitOnAnotherMonster=false / + m_iPierceCount=0 / m_fCollisionSphereRadius=0 / m_fBombRange=10 + REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(p1,p2,x) —— v=p2-p1; l=|v|²; + w=x-p1; d=dot(w,v); if (d<=0) return |w|²; if (d>=l) return |x-p2|²; return |cross(w,v)|²/l + REF/GameLib/FlyTarget.h —— EType { TYPE_NONE, TYPE_OBJECT, TYPE_POSITION } + REF/GameLib/ActorInstanceFly.cpp OnGetFlyTargetPosition —— 包围球心 m_v3Center 变换到世界 + REF/GameLib/FlyHandler.h —— IFlyEventHandler::OnNoTarget() / OnNoArrow()(__CanShot 失败走的回调) + REF/GameLib/InstanceBaseBattle.cpp:355 NEW_Attack —— IsDead / IsStun / IsKnockDown / IsUsingSkill 门 + REF/UserInterface/InstanceBase.cpp:694 __GetBowRange —— 弓模式命中 / 点击距离 + ★ GC_CREATE_FLY 在本 40250 客户端 EterLib/NetStream.cpp 仅头串索引 70,无包处理器 + (PythonNetworkStreamPhaseGame*.cpp 无 handler)——实际驱动走 POC 自有 fly_cue + ★ grep -rn "CanShot|__CanShot|CanShoot" 全树无命中——__CanShot 在发布源码树中无定义,防御式重建 +改动: + project/fly_object.gd(新)—— + · const INDEX_FLY_TYPE_NORMAL=0 / FIRE_CRACKER=1 / AUTO_FIRE=2 + · class FlyData extends RefCounted —— 米制换算的 __Initialize() 默认值:init_vel=2.0 / + cone_angle=0 / roll_angle=0 / gravity=0 / accel=ZERO / flat_range=5.0(=m_fRange) / + bomb_range=0.1(=m_fBombRange) / pierce_count=0 / spreading=false / maintain_parallel=false / + is_homing=false / homing_start_time=0 / homing_max_angle=0 / hit_on_background=false + · class FlyInstance extends RefCounted —— + · target_position() —— 对象目标每帧刷 _target_pos = _target_obj.global_position; + maintain_parallel → +0.5 y(= v3TargetPos.z+=50) + · create(d, start_world, target, attack) —— pos/last_pos/alive/_elapsed=0/target_hitted=false/ + pierce=d.pierce_count/remain_range=d.flat_range;target 按 Node3D(对象)/ Vector3(位置)/ + Dictionary{vid,pos,node} 分派;dir=(target_position()-pos).normalized()(回退 (0,0,-1)); + vel=dir*d.init_vel;accel=d.accel,非零则 accel=Quaternion((0,-1,0),dir).normalized()*accel + · _adjust_homing() —— vel 朝目标 dir 单步转向,限幅 deg_to_rad(homing_max_angle)(>=180 全转), + 经 vdir.slerp(tdir,t)*speed + · update(dt) -> bool —— if not alive: return false;_elapsed+=dt; + homing if is_homing and _elapsed>homing_start_time and _is_object;last_pos=pos; + vel+=accel*dt;vel.y+=gravity*dt(seam:参考端 .z=上,POC 映射 -Y=下); + movement=vel*dt;move_dist=movement.length();remain_range-=move_dist;pos+=movement; + remain_range<0 → _emit("out_of_range")+_explode(false)+return false; + br2=bomb_range²;对象分支(_is_object, not target_hitted): + _seg_point_sq_dist(pos,last_pos,target_position())0 → pierce-=1;_emit("bomb") else _explode();return false → return true; + 位置分支:_seg_point_sq_dist(...)pos.y → _emit("at_background")+_explode()+return false; + return true + · _explode(bomb=true) —— if not alive: return; alive=false; if bomb: _emit("bomb") + · _emit(ev) —— handler.call(ev, pos, target_vid) if valid + · static _seg_point_sq_dist(p1,p2,x) -> float —— 1:1 移植 GrpMath.h:80 + · class FlyManager(the Node,extends Node)—— parent:Node3D / world:Object / manual_step:=false / + _instances / _visuals / _id_counter=1;signal shoot_damage(target_vid) / exploded(world_pos, cause) + · setup(mount, w=null) —— parent=mount; world=w + · spawn(start_world, target, can_attack, data=null, skill_index=0) -> FlyInstance —— + null 则建 FlyData;建 FlyInstance;inst.id=_id_counter++; + inst.handler = func(ev,wp,vid): _on_instance_event(inst,ev,wp,vid);inst.create(...); + append _instances;parent!=null 则建 SphereMesh(r=0.08) 发光球 _visuals[inst] + · active_count() / step(dt)(while 迭代 update → 更新 visual.global_position → 不 keep 则 + queue_free + erase + remove_at,else ++i)/ _process(dt)(not manual_step → step(dt)) + · _on_instance_event —— "shoot_damage"→shoot_damage.emit(vid); + "out_of_range"/"at_target"/"at_position"/"at_background"/"at_another"→exploded.emit(wp,ev) + + _spawn_flash(wp) if parent and ev!="out_of_range";"bomb"→_spawn_flash if parent + · _spawn_flash(wp) —— MeshInstance3D,tween scale→ONE*6.0(0.25s) ∥ albedo_color:a→0.0,链 queue_free + project/net_world.gd —— + · +const FlyObject = preload("res://fly_object.gd") + · +signal fly_exploded(world_pos: Vector3, cause: String)(§3.6 命中 / 超程 / 触地) + · +var _fly_mgr: Node + · setup() 尾:_fly_mgr = FlyObject.new(); name="FlyManager"; add_child; _fly_mgr.setup(parent, world); + _fly_mgr.exploded.connect(func(wp,cause): fly_exploded.emit(wp,cause)) + · _on_fly(_type, start_vid, end_vid) 重写 —— 保留 a/b 端点 + shooter fly_target_queue meta 消费 + (现也据此设 target_node = _by_vid.get(target_vid) / null);原 tween「发光球」换成: + _fly_mgr.parent=parent; _fly_mgr.world=world;start_w=a+(0,1,0); goal_w=b+(0,1,0); + FlyData d:init_vel=45.0 / flat_range=max(start_w.distance_to(goal_w)*1.5, 30.0) / bomb_range=0.6 / pierce_count=0; + tgt = target_node if valid else goal_w;_fly_mgr.spawn(start_w, tgt, false, d, _type) + project/net_play.gd —— + · +func _can_shot(target_vid := -1) -> String(紧跟 _is_bow_mode(),含 seam 注释块)—— + not _is_bow_mode() → "NOT_BOW_MODE";_arrow_count==0 → "EMPTY_ARROW"(-1 = 未跟踪/无限,放行); + tv = target_vid>=0 ? target_vid : _target_vid;tv<=0 → tv=_vid_reserved;tv<=0 → "NO_TARGET"; + te = client.get_entity(tv);not te.is_empty() and not _is_attackable(te) → "NO_TARGET" + (te.is_empty() 不拒——headless 容差,与 _send_fly_target「有节点即可」一致); + tp/mp = _entity_world_pos(tv)/_entity_world_pos(_main_vid); + 两者 Vector3 and tp.distance_to(mp)*CM > _bow_range_cm() → "OUT_OF_RANGE";else "" + · _emit_swing 弓分支加门 —— 原 `if _is_bow_mode(): _send_fly_target(); bow_shot_fired.emit(_swing_skill)` + 改为:why=_can_shot();why!="" → _emit_cannot(why)(走 OnNoArrow / OnNoTarget 文案, + 不发 CG_FLY_TARGETING、不排 OnShoot);else _send_fly_target() + bow_shot_fired.emit(_swing_skill) + (seam:其余起手动作照常,参考端整挥击中止) + project/fly_test.gd(新,第 14 个 GDScript 回归)—— + · extends SceneTree;_init() → await _run() → PASS/quit(0) or quit(1) + · _run() 首行 await process_frame(SceneTree._init 早于 root 就绪);_mk_mgr()(manual_step=true); + _node_at(p)(root.add_child + global_position=p) + · _test_hit_object(直线命中对象目标,at_target 一次,实例移除) + · _test_out_of_range(remain_range<0 → out_of_range,无 shoot_damage,不 __Bomb) + · _test_can_attack_damage(Dictionary{vid,node} + can_attack → shoot_damage(4242) 恰一次) + · _test_pierce(pierce_count=1 → 命中后存活续飞 → 最终 out_of_range;causes==["at_target","out_of_range"]) + · _test_position_target(Vector3 目标 → at_position) + · _test_hit_background(_FakeWorld.sample_height=0.9 + gravity=-20 → at_background) + · _test_can_shot(_FakeClient + NetPlay:GENERAL→NOT_BOW_MODE / BOW+0 箭→EMPTY_ARROW / + BOW+箭+无目标→NO_TARGET / _arrow_count=-1 + 目标(5,0,0)→"" / 目标(400,0,0)→OUT_OF_RANGE / + 死目标→NO_TARGET) + · _test_seg_point_dist(_seg_point_sq_dist:垂足在段内→9.0 / 越 p1→16.0 / 越 p2→25.0) +测试:`M2_ASSETS=$PWD/assets ctest --test-dir build` → 21/21(无 C++ 改动);14 个 GDScript 回归 → PASS + (player_skill / skill / skill_fx / combat_fx / gamescene / netplay / player_move / player_motion / + netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill / fly)。eterngrn_polish_test 的 + stderr "FAIL: motion_event: sound → play_at" 为既有 async 竞态,非本批次触及,仍 exit 0 + PASS。 +验收:纯离线。CFlyingInstance::Update 的逐帧管线(homing → vel+=accel*dt → remain_range-=|movement| → + 线段-点 < bomb_range² 命中 → OnShootDamage / pierce / __Explode / __Bomb)逐行对齐; + _seg_point_sq_dist 1:1 移植 GrpMath.h:80;FlyData 默认值取 FlyingData.cpp __Initialize()。 + 已文档化 seam: + ① GC_CREATE_FLY 在本 40250 客户端无包处理器(仅 NetStream.cpp 头串索引 70)——实际驱动走 POC 自有 + fly_cue(M2Client 发 fly_cue / fly_targeting,net_world._on_fly 消费); + ② __CanShot 在发布源码树中无定义(grep 全树无命中)——据 OnNoTarget / OnNoArrow / NEW_Attack 的 + IsDead 门 / __GetBowRange 防御式重建;_can_shot 不因实体数据缺失(te.is_empty())而拒, + 只在确知不可攻击 / 已死时拒——headless 容差,保 combat_fx_test 的 §3.6 段(_target_vid=2000 + 但无 fc.ents[2000])仍过; + ③ _emit_swing 弓分支为「跳过 fly-targeting / OnShoot」门,不中止整挥(状态包 + 动画照常)—— + 参考端 __CanShot 失败中止整个攻击; + ④ 重力:参考端作用于 m_v3Velocity.z(=上),本 POC 映射到 vel.y(= -Y =下); + ⑤ 怪物球体碰撞 / CullingMgr.ForInRange / 建筑体素 r=1 判定未移植——客户端飞行物只做命中「时机」, + 伤害服务端裁决; + ⑥ 无 .fly 数据文件加载器——net_world._on_fly 用合成箭参数(init_vel=45 / flat_range=max(dist*1.5,30) / + bomb_range=0.6);FIRE_CRACKER / AUTO_FIRE 的 CreateIndexedFly 分支已在 fly_object.gd 常量层预留, + 驱动侧(GC_CREATE_FLY byType)本 build 无来源,未接线。 +下一批次(增量 93):§3.1 Actor-Actor 碰撞(TestActorCollision / BlockMovement)+ 坐骑 MODE_HORSE* + (依赖 §6.4);§3.2 / §3.5 收尾多为待真实服务器。 +``` + + + +### C.5 批次记录 · 增量 93(W2 / §3.1 收尾:Actor-Actor 碰撞 + 坐骑转向速度) + +```text +批次:增量 93 —— W2 §3.1 收尾(M1) +状态:完成(纯离线;C++ 21/21 + 14 GDScript 回归全绿) + +参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1): + GameLib/ActorInstanceCollisionDetection.cpp —— CActorInstance::TestActorCollision(距离门 800cm²、 + body sphere vs body sphere、末尾「dist(cur) <= dist(last) 才 return TRUE」=趋近判定)、 + BlockMovement()(→ __InitializeMovement,坐骑时转发 m_pkHorse)、CanSkipCollision() / + EnableSkipCollision() / DisableSkipCollision()(m_canSkipCollision)、__TestObjectCollision + (m_canSkipCollision || 零移动 → FALSE)。 + UserInterface/InstanceBaseBattle.cpp:469 CheckAdvancing —— CanSkipCollision → 不检; + 逐个 CInstanceBase:using-skill 时只撞 Door;TestActorCollision 命中 → uCollisionCount++, + ==2 → BlockMovement + return;否则 AdjustDynamicCollisionMovement(滑移,函数不在发布树)→ + 再测仍撞 → BlockMovement,否则 NEW_MoveToDestPixelPositionDirection 重推;末尾地形 + ATTRIBUTE_BLOCK 分段 + next-pos 检测 → BlockMovement。 + UserInterface/InstanceBase.cpp:17-18 c_fDefaultRotationSpeed=1200 / c_fDefaultHorseRotationSpeed=300; + :951 MountHorse(eRace) → SetMotionMode(MODE_HORSE) + SetRotationSpeed(c_fDefaultHorseRotationSpeed) + + m_GraphicThingInstance.Stop();:964 DismountHorse() → m_kHorse.Destroy(); + :3018 __InitializeRotationSpeed → SetRotationSpeed(c_fDefaultRotationSpeed)。 + IsMountingHorse() = m_kHorse.IsMounting()(InstanceBase.cpp:945)。 + seam:TestActorCollision / BlockMovement / CanSkipCollision 的「调用方」(CPythonCharacterManager + 的逐帧 Deform / Update 循环)不在发布源码树;AdjustDynamicCollisionMovement 亦不在。 + +改动: + project/player_controller.gd —— + · +const ACTOR_BODY_RADIUS_M := 0.55(自身 + 对方相加 = combined 判定阈值;固定半径近似 + 真实 race 碰撞球)、ACTOR_COLLIDE_MAX_DIST_M := 8.0(≈ LengthSq > 800² 距离门)、 + ROT_SPEED_DEFAULT_DEG := 1200.0、ROT_SPEED_HORSE_DEG := 300.0 + · +var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG、+var skip_actor_collision := false + · +func _actor_blocked(next_pos) -> bool —— skip_actor_collision → false;遍历 pickables, + 跳过 !is_instance_valid / dead meta(≈ rVictim.IsDead());d_now = XZ 距离,> 8m 跳过; + d_next = XZ(op, next_pos);d_next < combined && d_next <= d_now + 1e-4 → true(趋近才挡) + · +func _turn_toward(target_yaw, dt) —— max_step = deg_to_rad(rotation_speed_deg)*dt; + diff = wrapf(target_yaw - rot.y, -PI, PI);|diff| <= max_step → 直接到位,否则 += sign*max_step + · _process 平移分支:np = pos + wish*want_speed*dt 之后, + block_x = _actor_blocked(仅动 X) or _blocked(np.x, z);block_z 同理(逐轴门控= + 单 Actor 滑移 ≈ AdjustDynamicCollisionMovement,两轴都挡=停在上一帧 ≈ BlockMovement); + not block_x → pos.x = np.x;not block_z → pos.z = np.z;既有 disp 判定继续把顶人磨蹭 + 切回 _is_going=false + wait + · moving_skill 分支 + 正常分支的 `player.rotation.y = atan2(wish.x, wish.z)` 两处 → + `_turn_toward(atan2(wish.x, wish.z), dt)` + project/net_play.gd —— + · _refresh_motion_mode() 尾:if pc and "rotation_speed_deg" in pc → + pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG + (对齐 MountHorse → SetRotationSpeed;mounted 沿用既有 mount_vnum/mounting 判定) + · setup()(is_observer_mode 之后)+ _on_observer_mode(enabled):if pc and + "skip_actor_collision" in pc → pc.skip_actor_collision = _observer_mode / enabled + (观战=自由穿行 ≈ CanSkipCollision) + project/player_move_test.gd —— + · _run() 首行 +await process_frame(对齐 fly_test,避免 !is_inside_tree()) + · 第 5 组后清 _reserved_ground / _reserved_delay_time(免遗留预约点改向后续组) + · +第 6 组:正前方 2m 放 blocker(入 pickables)→ _goto(10,0,0) → 停在 x∈(0.3,1.5)、is_going 归零 + · +第 6b 组:blocker.dead meta = true → 直穿到 (10,0,0)(≈ rVictim.IsDead()) + · +第 6c 组:skip_actor_collision = true → 直穿 + · +第 7 组:rotation_speed_deg = 300 → 单个 50ms 步内 |rot.y| <= deg_to_rad(300)*0.05, + 且 pos.z 已 < -0.05(转向变慢但仍朝 Dst 平移) + +测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动); + 14 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / + player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill / + fly)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async + 竞态,非本批次触及,仍 exit 0 + PASS。 + +验收:纯离线。TestActorCollision 的距离门 + body 球重叠 + 趋近判定逐条对齐;BlockMovement + =停在上一帧(__InitializeMovement 语义);坐骑转向速度 300 / 1200 取 InstanceBase.cpp:17-18 + 常量;MODE_HORSE* 动作模式沿用增量 84。 + 已文档化 seam: + ① body sphere 半径用固定 0.55m 近似——真实值需 race 碰撞数据(.col / CollisionData.h); + ② AdjustDynamicCollisionMovement 精确滑移向量不在发布树——用 _process 既有逐轴门控近似 + (单 Actor 逐轴放行=滑移,两轴都挡=硬停);门 / Actor 复合的 uCollisionCount==2 亦以此近似; + ③ CanSkipCollision 目前只由观战驱动——§3.2 网络状态包路径的 EnableSkipCollision 尚未接到 + 本地预测玩家(本地走客户端预测,服务器只 lerp 纠正); + ④ TestActorCollision / CheckAdvancing 的逐帧「调用方」不在发布源码树——_process 内联近似; + ⑤ MOVABLE_GROUND_DISTANCE 参考默认值仍是估计(1.0m),待读 game.py 侧 SetMovableGroundDistance。 + +下一批次(增量 94):§3.2 / §3.5 收尾(多为待真实服务器);随后转 M2(W3 玩法:§5.1 quest 标签、 + §5.3 fishing/dungeon/mount/observer UI、§5.4 PVP/PK/duel、§4.1 costume/belt、§4.2 拖放鼠标物品态)。 +``` + +### C.5 批次记录 · 增量 94(W3→M2 / §5.4 PVP·PK·决斗可攻击判据 `IsAttackableInstance` 1:1) + +```text +批次:增量 94 —— W3→M2 §5.4(M2 起点;纯 GDScript,无 C++ 改动) +状态:完成(纯离线;C++ 21/21 + 15 GDScript 回归全绿) + +参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1): + UserInterface/InstanceBase.cpp:2147 CInstanceBase::IsAttackableInstance —— 唯一判据,逐行照抄。 + 结构:__IsMainInstance() && IsObserverMode() → false;GetVirtualID()==victim → false; + if IsStone() { victim.IsPC() → true } + else if IsPC() { + victim.IsStone() → true; + if victim.IsPC() { + switch GetDuelMode(): DUEL_CANNOTATTACK → false; DUEL_START → __FindDUELKey ? true : false; + if PK_MODE_GUILD==GetPKMode() && GetGuildID()==victim.GetGuildID() → false; + if victim.IsKiller() && !IsSamePartyMember(self,victim) → true; + if PK_MODE_PROTECT != GetPKMode() { + if PK_MODE_FREE==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 → true } + if PK_MODE_GUILD==GetPKMode() { if victim.GetPKMode()!=PROTECT && !同队 && 公会不同 → true } + } + if IsSameEmpire(victim) { + if IsPVPInstance(victim) → true; + if PK_MODE_REVENGE==GetPKMode() && !同队 && IsConflictAlignmentInstance(victim) → true; + } else return true; + } + if victim.IsEnemy() → true; + if victim.IsWoodenDoor() → true; + } + else if IsEnemy() { victim.IsPC() → true; victim.IsBuilding() → true } + else if IsPoly() { victim.IsPC() → true; victim.IsEnemy() → true } + return false; + InstanceBase.cpp:502 GetAlignmentGrade(>=12000→0 / >=8000→1 / >=4000→2 / >=1000→3 / >=0→4 / + >-4000→5 / >-8000→6 / >-12000→7 / else 8) + InstanceBase.cpp:524 GetAlignmentType(grade 0-3→WHITE / 4→NORMAL / 5-8→DARK) + InstanceBase.cpp:470 IsSameEmpire(victim.empire==0 → TRUE;IsGameMaster()(任一)→ TRUE; + 同 empire → TRUE;否则 FALSE) + InstanceBase.cpp:2116 IsConflictAlignmentInstance(victim.GetPKMode()==PROTECT → false; + self 类型 NORMAL/WHITE:victim==DARK → true;self==DARK:self!=victim → true;否则 false) + InstanceBase.cpp:2390 IsWoodenDoor(IsDoor() && (vnum==13000 || 30111<=vnum<=30119)) + InstanceBaseEffect.cpp:495 __FindDUELKey / :505 IsPVPInstance(GetDuelMode() → true; + 否则 __FindPVPKey(vidSrc,vidDst) || __FindGVGKey(guildSrc,guildDst)) + PythonPlayer.cpp:1445 IsSamePartyMember = IsPartyMemberByVID(a) && IsPartyMemberByVID(b) + GameLib/ActorInstance.cpp:270 IsPoly(TYPE_POLY || (TYPE_PC && race>=MAIN_RACE_MAX_NUM))/ + :282 IsPC(==TYPE_PC)/ :298 IsEnemy(==TYPE_ENEMY)/ :306 IsStone(==TYPE_STONE)—— + m_eActorType 单值,变身玩家仍是 TYPE_PC(故 IsAttackableInstance 的 else-if 链里 IsPC() 先命中, + IsPoly() 分支只服务真正的 TYPE_POLY 实例)。 + Packet.h:1273 EPKModes(PEACE=0/REVENGE=1/FREE=2/PROTECT=3/GUILD=4); + Packet.h:1264 ADD_CHARACTER_STATE_KILLER=1<<3;InstanceBase.cpp:2086 SetStateFlags 从位取 killer。 + seam:__FindPVPKey / __FindGVGKey 的 key 集合 setter(GC_PVP / 公会战对局包)、IsGameMaster 标记、 + IsBuilding·IsWall 的 race 白名单——均不在发布源码树 / 本 POC 未下发。 + +改动: + project/entity_rules.gd(新建,纯静态 extends RefCounted)—— + · const PK_MODE_* / ALIGNMENT_TYPE_* / DUEL_* / STATE_KILLER=1<<3 / STATE_PARTY=1<<4 / + KIND_PC=0..KIND_WARP=4(对齐 net_play._entity_kind 语义) + · alignment_grade(a) / alignment_type(a) —— :502 / :524 逐行 + · _kind / _is_poly(poly / polymorph / poly_vnum 任一)/ _is_pc(==KIND_PC)/ _is_stone / + _is_enemy(==KIND_MONSTER)/ _is_wooden_door(race 13000 或 30111..30119)/ _is_building(seam) + · _is_killer(state_flags & STATE_KILLER)/ _is_same_party(a,b 皆在 ctx.party_vids)/ + _is_same_empire(:470)/ _find_duel_key(victim 或 self 在 ctx.duel_opponents)/ + _pair_in(无序对匹配)/ _is_pvp_instance(duel_mode!=0 || pvp_pairs || 双方公会非 0 且 gvg_pairs)/ + is_conflict_alignment_instance(:2116)/ _pk_mode_of + · is_attackable_instance(self_e, victim_e, ctx) -> bool —— :2147 逐行 + project/net_play.gd —— + · +const EntityRules = preload("res://entity_rules.gd")(CM 常量后) + · _is_attackable(e):桩「dead? false : _entity_kind(e) in [0,2]」→ 拼 self_e(_main_entity() 快照 + + kind=_entity_kind)/ victim_e(+ kind)/ _attack_ctx(self_e),委托 EntityRules.is_attackable_instance; + 保留 dead 提前返回(便利,参考端此函数不查死亡,另在各调用点查);_main_entity() 空 → 退化 + _entity_kind(e) in [KIND_PC, KIND_MONSTER] + · +_attack_ctx(self_e) -> Dictionary —— get_duel()(active/cannot_attack → duel_mode,opponents)、 + get_pvp_relations()(mode!=0 的 src/dst_vid → pvp_pairs)、get_guild_wars()(src/dst → gvg_pairs)、 + get_party()(vid → party_vids);键:observer / main_vid / pk_mode / duel_mode / duel_opponents / + pvp_pairs / gvg_pairs / party_vids + · 5 处调用点(363 / 758 / 872=__ReserveProcess_ClickActor 第 3 步 / 1096 / 1351)签名不变 + project/entity_rules_test.gd(新建,第 15 个 canonical,extends SceneTree,25 组断言)—— + grade/type 边界、观战门、自打自、PC/怪/NPC/石头、同帝国 peace(+empire 0)、异帝国、 + PK_FREE(普通 / victim PROTECT / 同队)、PK_GUILD(同公会提前 false / 不同公会 true)、killer(±同队)、 + 决斗 CANNOTATTACK / START(±对手名单)、PVP-pair、GVG-pair(±双方公会)、REVENGE(±冲突善恶)、 + 石头 self / victim、木门 13000 / 30115 / 非木门 13001、is_conflict_alignment(victim PROTECT / 黑对中 / + 黑对黑)、变身玩家(仍走 PC 分支)、TYPE_POLY self(走 Poly 分支) + project/{combat_fx_test,netplay_test,fly_test}.gd —— + 怪物夹具(ents[2000] / ents[4100] / send_fly_targeting 的 mk 闭包 / fly_test ents[900-902])补 + "ch_type": 2 —— 此前无 ch_type → _entity_kind 归 PC(0),旧桩按 [0,2] 也「可打」;1:1 规则下 + 和平同帝国 PC 不可攻击 → 夹具须明确怪类别(真实服务器 GC_CHARACTER_ADD 带 type,或 m2dev 走 + mob_proto 查 race,生产环境二者恒具备) + +测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动); + 15 个 GDScript 回归(player_skill / skill / skill_fx / combat_fx / gamescene / netplay / + player_move / player_motion / netbridge / p2b / equip_model / eterngrn_polish / guild_war_skill / + fly / entity_rules)全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为 + 既有 async 竞态,非本批次触及,仍 exit 0 + PASS。 + +验收:纯离线。IsAttackableInstance 的四分支 + 决斗三态 + PK 五模式 + 帝国 / PVP / GVG / 善恶 / + killer / 同队全部逐行;判据 helper(alignment_grade/type、IsSameEmpire、IsConflictAlignmentInstance、 + IsPVPInstance、__FindDUELKey、IsSamePartyMember、IsWoodenDoor)1:1;IsPC / IsEnemy / IsPoly 依 + ActorInstance.cpp 的 m_eActorType 单值语义(变身玩家仍是 PC)。 + 已文档化 seam: + ① __FindPVPKey 的 key 集合 setter=GC_PVP 关系表未接(get_pvp_relations() 有值则消费); + ② __FindGVGKey 的公会战对局 setter 未接(get_guild_wars() 对表消费,来源 GUILD_GC_WAR); + ③ IsGameMaster 无 GM 标记 → _is_same_empire 里恒 false; + ④ IsBuilding / IsWall 的 race 白名单源码未随发布 → _is_building 读显式 building 字段; + ⑤ m2dev 后端的 IsKiller 状态位(state_flags & 1<<3)——classic 后端 classic_parser.cpp 已接; + ⑥ _entity_kind 无法分类(无 ch_type 且 mob_proto 查不到 race)时怪被当和平 PC → 不可攻击 + (生产 mob_proto 恒加载,仅裸测试夹具受影响,本批次已补 ch_type); + ⑦ 名字颜色 / 可攻击光标(ENameColor,§8.8)、IsInSafe 的 ATTRIBUTE_BANPK 地形属性 —— 未接。 + +下一批次(增量 95):§5.1 quest 38 tag(quest_dialog EventTypeMap + ProcessEventSet 分发)、 + §5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。 +``` + +### C.5 批次记录 · 增量 95(W3→M2 / §5.1 Quest EventManager `CPythonEventManager` 1:1) + +```text +批次:增量 95 —— W3→M2 §5.1(M2 玩法;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(纯离线;C++ 21/21 + 18 GDScript 回归全绿) + +参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1): + UserInterface/PythonEventManager.h —— :35 EEventType(42 项枚举)、:98 SEventSet、 + 常量 c_lNormal_Waiting_Time=10 / c_fLine_Temp=16 / BOX_VISIBLE_LINE_COUNT=5 / + EVENT_POSITION_START=0 / EVENT_POSITION_END=1。 + UserInterface/PythonEventManager.cpp —— :1148 ctor 填 EventTypeMap(42 条 std::map, + CLEAR 与 CLEAR_TEXT 是两个独立枚举但都走 ClearLine);:1093 GetScriptEventIndex(前导 `/` → + EVENT_POSITION_END + 剥离,未知名字 → 解析错误 / 跳过);:377 ProcessEventSet(大 switch, + 逐命令弹出);:312 UpdateEventSet(逐帧 lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait + 倒计时刷新 m_strLeftTimeString);:72 __InitEventSet;:976 __InsertLine;:953 ClearLine; + :884 MakeQuestion;:878 MakeNextButton;:48 GetCameraSettingFromArgList;:142 + RegisterEventSetFromString(首命令 RUN_CINEMA 特例:整脚本换成事件文件);:562/:566 SETMSGPOS / + ADJMSGPOS case 为**空实现**。 + +改动: + project/ui/quest_dialog.gd —— 由旧的单遍手写 parse_script 重写为 EventTypeMap + 状态机 + 分派: + · const EVENT_TYPE_MAP(42 键,tag 字符串 → EVT_*)+ enum { EVT_LETTER .. EVT_SELECT_ITEM } + (顺序 = EEventType)+ const EVENT_POSITION_START/END、C_NORMAL_WAITING_TIME=10、 + C_LINE_TEMP=16、BOX_VISIBLE_LINE_COUNT=5 + · _get_script_event_index(raw_name) -> {ok, position, type} —— 前导 `/` → position=END + 剥离; + 大写查表;未知 → ok=false / type=-1(调用方 return,不进 match) + · class EventSet extends RefCounted —— SEventSet 字段名 1:1(ix/iy/iWidth/iyLocal/isLock/ + lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/iConfirmEndTime/ + DiffuseColor/lWaitingTime/iRestrictedCharacterCount=30/iVisibleStartLine/iVisibleLineCount/ + iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer)+ POC 累加器(lines/color_open/choices/ + has_next/has_input/has_select_item/window_size/images/insert_images/title_image/ + quest_buttons/quest_button_closes/map_signals/clear_map_signals/atlas_center/camera_events/ + fade_events/dungeon_result/cinema_file/end_confirm);方法 init_event_set()(__InitEventSet)、 + insert_line()(__InsertLine:推行 + iyLocal += C_LINE_TEMP + 清 strCurrentLine/iCurrentLetter)、 + clear_line()(ClearLine)、body_text() + · _process_event_set(es, cmd) = ProcessEventSet 的 switch 逐 case 对拷:literal → 合成 LETTER + (is_raw=true);DELAY / COLOR / COLOR256 的 START vs `/END`;LETTER / ITEM / MOB 的 + iCurrentLetter >= iRestrictedCharacterCount → insert_line 断行(裸文本 is_raw 跳过断行); + NEXT / DONE / INSERT_IMAGE 的 iAdjustLine += 2;WAIT(iyLocal=0; isLock=true); + CLEAR / CLEAR_TEXT → clear_line;QUESTION → MakeQuestion(choices + nAnswer); + LEFTIMAGE / TOPIMAGE / BGIMAGE / IMAGE / TITLE_IMAGE / INSERT_IMAGE;ADDMAPSIGNAL / + CLEARMAPSIGNAL / SETCMAPPOS;SETMSGPOS / ADJMSGPOS → pass(引 :562/566 空实现);SLEEP → + lLastDelayTime;SET_CAMERA(_camera_setting 九参 = :48)/ BLEND_CAMERA(+blendtime)/ + RESTORE_CAMERA;FADE_OUT / FADE_IN / WHITE_OUT / WHITE_IN → fade_events + isWaitFlag=true; + TEXT_HORIZONTAL_ALIGN_CENTER → isTextCenterMode;DUNGEON_RESULT → 9 named int; + QUESTBUTTON / QUESTBUTTON_CLOSE;WINDOW_SIZE(w>0 && h>0);INPUT(insert_line + has_input); + CONFIRM_WAIT(isConfirmWait=true + iConfirmEndTime + insert_line(center));END_CONFIRM_WAIT + (清 isConfirmWait + end_confirm);SELECT_ITEM;RUN_CINEMA → pass(ProcessEventSet 无此 case) + · _tokenize(src) —— 拆混排文本 / `[tag ...]`;裸文本 → {literal},tag → {name(大写、保留前导 `/`), + pos, tok(括号内原文)} + · arg 提取:_tag_kv(tok)(key(v) / key("v") / key;v,括号深度 + 去引号,重复键取末次)+ + _arg_s / _arg_f / _arg_i(keyword 优先、位置回退——兼容旧测试 value;255 / value(1) 写法) + · parse_script(src) -> Dictionary —— 建 EventSet → init_event_set() → _tokenize → 首命令 + RUN_CINEMA 特例(仅当 cmds[0] 即 RUN_CINEMA、无前导文本 → es.cinema_file)→ + for cmd: _process_event_set;返回字典=旧契约全键不变,新增 + insert_images / dungeon_result / adjust_line / waiting_time / is_locked / wait_flag / + answer_count / cinema_file / end_confirm + · +signal cinema_requested(filename);_emit_side_effects 在 fade_events 循环后透出 + project/quest_event_test.gd —— 新建,第 16 个 canonical(extends SceneTree),23 组断言: + EventTypeMap 42 tag 完整性 + 无多余;_get_script_event_index 的 `/` → END / 未知 → ok=false / + 大小写不敏感;DELAY START→waiting_time=40 / `/DELAY`→10;显式 [LETTER] ≥30 字断 2 行 vs + 裸长文本仍 1 行;ENTER→行 / CLEAR·CLEAR_TEXT→重置;QUESTION→3 choices + answer_count=3; + NEXT→adjust_line=2 / NEXT+DONE→4;WAIT→is_locked;COLOR 命名 r/g/b + COLOR256 位置 + value;255 → [color=#00ff00] / [color=#ff8000];INPUT→has_input / SELECT_ITEM→has_select_item; + CONFIRM_WAIT→confirm_wait + confirm_timeout=30 / +END_CONFIRM_WAIT→清 + end_confirm; + DUNGEON_RESULT→9 named int;INSERT_IMAGE 带 image_name(is_item_icon=false / adjust_line=2)vs + 不带(is_item_icon=true / idx=19);IMAGE/LEFTIMAGE/TOPIMAGE/BGIMAGE→4 项 + panel + TITLE_IMAGE; + ADDMAPSIGNAL/SETCMAPPOS/CLEARMAPSIGNAL;QUESTBUTTON keyed + QUESTBUTTON_CLOSE idx; + camera SET(setting.distance=50)/BLEND(blendtime=1500)/RESTORE(3 事件);FADE_OUT/WHITE_IN → + fade_events + wait_flag;WINDOW_SIZE keyed + TEXT_CENTER;ITEM/MOB → "怪物 #77"/"物品 #19"; + SETMSGPOS/ADJMSGPOS → no-op;未知 tag → 跳过;RUN_CINEMA 首命令 → cinema_file="intro.msc" / + 非首命令 → cinema_file="" + body 保留 + +测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(无 C++ 改动); + 18 个 GDScript 回归(16 canonical,含新增 quest_event_test;+ quest_test + select_item_ui_test) + 全 PASS。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 async 竞态, + 非本批次触及,仍 exit 0 + PASS。 + +验收:纯离线。EventTypeMap 42 tag + GetScriptEventIndex 前导 `/` + ProcessEventSet 逐 case 分派 + + SEventSet 状态字段名 1:1。裸网络文本合成 LETTER(is_raw) → 跳过 iRestrictedCharacterCount + 30 字断行(server 权威句子不逐字符断),显式 [LETTER]/[ITEM]/[MOB] 仍断行(对齐参考)。 + 已文档化 seam: + ① 逐帧 UpdateEventSet :312 的时间推进(lLastDelayTime 递减 / isWaitFlag 暂停 / isConfirmWait + 倒计时刷新 m_strLeftTimeString)—— 一次性跑完 token,isLock / isWaitFlag 仅作状态保留并透出; + ② Skip / EndEventProcess / iConfirmEndTime 的 timeGetTime —— 帧驱动,未接; + ③ .msc 事件文件加载(RegisterEventSetFromString :142 的 RUN_CINEMA 换文件)—— 只记 + cinema_file + 发 cinema_requested,文件读取 / 脚本替换未接; + ④ MakeNextButton NEXT vs DONE 的按钮文案区分 + DoneEvent() 回调 —— 均归 has_next; + ⑤ camera_event_requested / fade_event_requested 消费端与 §7.6 相机模式、game_scene 的 + cinema_requested 接线 —— 信号已透出,consumer 未接; + ⑥ 物品名颜色 (1.0,0.2,0.2)(reference ITEM_NAME)—— 以纯文本落地,着色为 cosmetic seam。 + +下一批次(增量 96):§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、 + §4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。 +``` + +### C.5 批次记录 · 增量 96(W3→M2 / §4.1 wear 槽位版图 + costume/ring/belt 发包坐标) + +```text +批次:增量 96 —— W3→M2 §4.1(M2 玩法;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(纯离线;C++ 21/21 + 55 GDScript 回归全绿) + +参考(REF = /Users/shenlei/Work/mt/40250/ClientVS22/source,ISO-8859-1,grep 需 -a): + UserInterface/GameType.h:40-112 —— 槽位版图: + c_ItemSlot_Count=90 / c_Equipment_Start=90 / c_Equipment_Count=12 + c_Equipment_Body..Shield = 90+0..90+10(EWearPositions BODY..SHIELD,WEAR_MAX_NUM=11) + #ifdef ENABLE_COSTUME_SYSTEM c_Costume_Slot_Start = 90+19(Body=109 / Hair=110) + #ifdef ENABLE_NEW_EQUIPMENT_SYSTEM c_New_Equipment_Start = 90+21 + c_Equipment_Ring1=111 / c_Equipment_Ring2=112 / c_Equipment_Belt=113 + c_Wear_Max=32 / c_DragonSoul_Equip_Start = 90+32 = 122 / c_Belt_Inventory 4x4=16 + —— 109..113 全在 INVENTORY 全局 cell 命名空间(window=1),客户端无独立 costume/ring/belt 窗。 + GameLib/ItemData.h —— EWearPositions(:226)、EItemWearableFlag(:244,WEARABLE_BODY 1<<0 … + WEARABLE_ARROW 1<<9)、ELimitTypes(:160,LIMIT_LEVEL/STR/DEX/INT/CON,ITEM_LIMIT_MAX_NUM=2)。 + GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 仅取表, + 不做 CanEquipNow(仲裁在服务器 char_item.cpp)。 + UserInterface/PythonItemModule.cpp:185/207 —— itemIsWearableFlag / itemGetLimit 是 client + 暴露给 uiscript 的全部装备判据。 + extension/src/net/wire.h —— WINDOW_INVENTORY=1 / WINDOW_EQUIPMENT=2 / BELT_INVENTORY_SLOT_START + = 90+32+6*2+6*3 = 152 / BELT_INVENTORY_MAX_NUM=16。 + +改动: + project/ui/inventory_ui.gd + · 新增常量:WEAR_MAX_NUM=11 / WEAR_COSTUME_BODY=19 / WEAR_COSTUME_HAIR=20 / WEAR_RING1=21 / + WEAR_RING2=22 / WEAR_BELT=23(值不变)/ NEW_EQUIP_WEAR_START=WEAR_COSTUME_BODY / + WINDOW_EQUIPMENT=2 —— 全部 1:1 自 GameType.h / ItemData.h + · _to_wire(ui_idx):BELT_BASE(200)+ → [1, 152+n](不变);EQUIP_BASE(90)+ 拆两路—— + wear = ui_idx-90;wear >= 19 → [WINDOW_INVENTORY, 90+wear](全局 cell 109..113); + wear 0..10 → [WINDOW_EQUIPMENT, wear](零基 wear cell,不变);其余 → [1, ui_idx] + —— 修掉 _to_wire(113) 旧返回 [2, 23],现 [1, 113] + · _add_extended_cells:扩展装备格由 _add_grid(…, first=11, count=WEAR_COUNT=24, …) 收窄为 + _add_grid("时装 / 戒指 / 腰带", first=WEAR_COSTUME_BODY, count=WEAR_BELT-WEAR_COSTUME_BODY+1=5, …) + —— EWearPositions 11..18 无位置,不再生成死格;腰带背包 16 格不变 + · 头注释重写为 _to_wire 分流说明;WEAR_COUNT 保留为 get_equipment() 数组上限注释 + project/inventory_test.gd —— 加 7 条 wire 断言:_to_wire(90)==[2,0] / (100)==[2,10] / + (109)==[1,109] / (110)==[1,110] / (111)==[1,111] / (112)==[1,112](原有 (113)==[1,113] 转绿) + +测试:M2_ASSETS=$PWD/assets ctest --test-dir build → 21/21(net.loopback_flow 首跑 localhost bind + 抖动,--rerun-failed 即过;无 C++ 改动);55 个 GDScript 回归全 PASS(含 inventory_test / + inventory_ui_test)。eterngrn_polish_test 的 stderr "motion_event: sound → play_at" 为既有 + async 竞态,非本批次触及,仍 exit 0 + PASS。 + +验收:纯离线。wear 槽位版图与 GameType.h 逐值对齐;costume/ring/belt 发包坐标回到 INVENTORY + 全局 cell(客户端事实:这三类无独立窗);基础 11 wear 仍走 WINDOW_EQUIPMENT(inventory_ui_test + 的 move_to(5,91) → [1,5,2,1,1] 锁定)。 + 已文档化 seam: + ① §4.1 修改 2 —— antiflag(性别/职业/帝国) → wearable_flag 匹配目标槽 → limit(等级/属性) → + CG_ITEM_USE 的装备校验链 + CANNOT_EQUIP_* 文案:40250 客户端不做此前置门,需按 §3.9 + __CanShot 方式在 GDScript 防御性重建,精确 tie-break 顺序留待接服务器比对; + ② §4.1 修改 4 —— 部位隐藏 / 遮挡(穿 costume 隐藏本体 armor、穿头盔隐藏发型), + InstanceBase.cpp 的 ChangeArmor / SetHair / __IsShapeAnimalWear,render 侧未接; + ③ §4.1 修改 5 —— 远端角色 armor/weapon/hair 三字段接换模(net_world.gd 调用序已在 §2.5 对齐); + ④ 扩展装备面板的图标 / tooltip / 拖放目标高亮沿用现有背包格逻辑,未做 costume/ring 专属外观。 + +下一批次(增量 97):§4.1 修改 2/4/5、§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、 + §4.2 拖放鼠标物品态。 +``` + +### C.5 批次记录 · 增量 97(W3→M2 / §4.1 修改 2 —— CanEquipNow 前置门) + +```text +批次:增量 97 —— W3→M2 §4.1 修改 2(M2 玩法;纯 GDScript,无 C++ 改动) + +状态:🟡 §4.1 修改 2 完成(antiflag→wearable_flag→limit 装备校验链在客户端防御性重建); + §4.1 修改 4/5(render 侧部位隐藏 + 远端换装)仍为 seam,留待增量 98+。 + +参考: + REF/GameLib/ItemData.h + · EItemAntiFlag(classic 排列):FEMALE=1<<0 / MALE=1<<1 / WARRIOR=1<<2 / ASSASSIN=1<<3 / + SURA=1<<4 / SHAMAN=1<<5 / … / EMPIRE_A=1<<9 / EMPIRE_B=1<<10 / EMPIRE_R=1<<11 + · EItemWearableFlag:WEARABLE_BODY=1<<0 … WEARABLE_UNIQUE=1<<7 / SHIELD=1<<8 / ARROW=1<<9 + · EWearPositions:WEAR_BODY=0 … WEAR_SHIELD=10 / WEAR_MAX_NUM=11 + · ELimitTypes:LIMIT_NONE=0 / LIMIT_LEVEL=1 / LIMIT_STR=2 / LIMIT_DEX=3 / LIMIT_INT=4 / + LIMIT_CON=5 / LIMIT_PCBANG=6 / LIMIT_REAL_TIME=7 + · EItemType:ITEM_TYPE_WEAPON=1 / ARMOR=2 / COSTUME=28 / RING=33 / BELT=34 + · EArmorSubTypes:ARMOR_BODY=0 / HEAD=1 / SHIELD=2 / WRIST=3 / FOOTS=4 / NECK=5 / EAR=6 + · ECostumeSubTypes:COSTUME_BODY=0 / COSTUME_HAIR=1 + REF/GameLib/ItemData.cpp:308/318/334 —— IsWearableFlag / GetWearFlags / GetLimit 均为纯表 getter, + 客户端无 CanEquipNow(仲裁在服务器 char_item.cpp)。 + REF/UserInterface/PythonItemModule.cpp:185/207 —— uiscript 只拿到 itemIsWearableFlag / itemGetLimit。 + REF/UserInterface/GameType.h:40-112 —— 新装备槽 c_Costume_Slot_Body=90+19 / Hair=90+20 / + Ring1=90+21 / Ring2=90+22 / Belt=90+23。 + common length.h MAIN_RACE_* —— job = race%4;race 0..7 性别表见 SEX_BY_RACE。 + +改动: + project/equip_rules.gd(新增,extends RefCounted 纯校验器) + · 全部枚举常量 1:1 自 ItemData.h / GameType.h(见上「参考」) + · sex_of_race(race) / job_of_race(race)=race%4 —— 表 1:1 自 MAIN_RACE_* + · default_wear_for(item):type/sub → 默认 wear 槽(WEAPON→4 / COSTUME→19|20 / RING→21 / + BELT→23 / ARMOR→match sub);不可穿戴 → -1。is_equippable() = default_wear_for >= 0 + · wear_to_wearable(wear):wear 槽 → 需要的 WEARABLE_* 位;新槽 19..23 → 0 + · slot_accepts(item, wear):新槽(19/20 costume、21/22 ring、23 belt)按 item type; + 旧 11 槽按 (wear_flags & wear_to_wearable(wear)) != 0 + · can_equip(item, wear, ctx) -> {ok, code, need}: + item 空 → NO_ITEM + target = wear>=0 ? wear : default_wear_for(item);target<0 → NOT_EQUIPPABLE + anti-flag 性别(sex==FEMALE & ANTIFLAG_FEMALE / sex==MALE & ANTIFLAG_MALE)→ ANTI_SEX + anti-flag 职业(anti & JOB_ANTIFLAG[job])→ ANTI_JOB + anti-flag 帝国(EMPIRE_ANTIFLAG[ctx.empire] 命中)→ ANTI_EMPIRE + not slot_accepts(item, target) → NOT_WEARABLE_HERE + limits 逐条:value<=0 跳过;LIMIT_LEVEL:ctx.level= 0 且 < v → 对应 code(need=v); + PCBANG / REAL_TIME* → pass(服务器判,seam) + 全过 → {ok:true, code:"", need:0} + · ctx:{race, sex, job, empire, level, st, dx, ht, iq};sex/job 缺省由 race 推; + 属性 st/dx/ht/iq 缺省或 <0 = 不判(M2Client 未按名暴露 → seam) + project/ui/inventory_ui.gd + · const EquipRules = preload("res://equip_rules.gd") + · signal equip_rejected(code: String, need: int) + · use(ui_idx) 重写:ui_idx < EQUIP_BASE(常规背包格)且 proto.item(vnum) 可穿戴时, + 跑 EquipRules.can_equip(pi, -1, _equip_ctx());not ok → _set_hint(_equip_reject_hint(verdict)) + + equip_rejected.emit(code, need) + return false(不发 use_item);否则维持 _to_wire + use_item + · 新增 can_equip_item(vnum, wear=-1) -> Dictionary(公开查询) + · 新增 _equip_ctx():get_points().level + get_entities() 主角 race/empire/level;st/dx/ht/iq = -1 + · 新增 _equip_reject_hint(verdict):ANTI_SEX/ANTI_JOB/ANTI_EMPIRE/NOT_WEARABLE_HERE/LIMIT_* → 中文 + project/equip_rules_test.gd(新增,canonical #17)—— 33 断言:race 表、default_wear_for 各 + type/sub、slot_accepts、anti-flag 性别/职业/帝国、NOT_WEARABLE_HERE、LIMIT_LEVEL/STR/CON + 含 need + 属性 -1 跳过、校验顺序 anti→wearable→limit、新装备槽按 type 放行、空物品 NO_ITEM + project/inventory_ui_test.gd —— FakeProto.item() 补 sub_type=0 / anti_flags=0 / + wear_flags=1<<4 / limits=[] 让 iv.use(5)(vnum 19 武器)仍过新前置门 + +测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21 + (无 C++ 改动);56 个 GDScript 回归全 PASS(新增 equip_rules_test;inventory_test / + inventory_ui_test 保持绿)。 + +验收:纯离线。装备校验链枚举与 ItemData.h 逐值对齐;语义顺序(antiflag→wearable→limit)对齐 + 服务器 char_item.cpp::CanEquipNow;背包右键穿戴被前置门拦下时不发 use_item 并 emit + equip_rejected。 + 已文档化 seam: + ① 精确 tie-break 顺序 / 已穿同槽替换 / LIMIT_PCBANG / LIMIT_REAL_TIME* —— 留待接服务器比对; + ② 属性 4 项(st/dx/ht/iq)M2Client 未按名暴露 → _equip_ctx() 传 -1(对应 limit 项被跳过); + ③ §4.1 修改 4(部位隐藏 / 遮挡)+ 修改 5(远端换装接字段)—— render 侧,留待增量 98+。 + +下一批次(增量 98):§4.1 修改 4/5(render 侧)、§5.2 任务日志窗口、 + §5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。 +``` + +### C.5 批次记录 · 增量 98(W3→M2 / §4.1 修改 4 —— 变身 / 时装部位遮挡 + 修改 5 字段序确认) + +```text +批次:增量 98 —— W3→M2 §4.1 修改 4(部位遮挡)+ 修改 5(远端换装字段序)(M2 玩法;纯 GDScript,无 C++ 改动) + +状态:🟡 §4.1 修改 4 完成(变身 / 动物时装 / 婚纱的整件遮挡在渲染侧 1:1 重建); + 修改 5 字段序已确认 1:1(增量 46 落地),远端 PC 换模链要等真模型工厂(增量 99+); + 变身态本身是 seam(40250 实体表无变身字段,_is_poly 恒 false)。 + +参考: + REF/UserInterface/InstanceBase.cpp(ISO-8859-1;grep -a) + · IsPoly() → SetShape(0);SetWeapon / SetParts 提前 return + —— 变身:身体 shape 0、无武器、无头发 / 挂件 + · __IsShapeAnimalWear()(:2834)= GetShape() ∈ {100,101,102,103} + —— SetWeapon(:2747)/ SetParts 返回 false:穿动物 / 怪物时装时武器 + 头发被抑制 + · IsWearingDress() = m_eShape == 201(婚纱) + · __IsChangableWeapon(iWeaponID)(:901)—— 婚纱状态除 c_iBouquets 外一律 false; + c_iBouquets = {50201, 50202, 50203, 50204}(原表 0 结尾) + · __ArmorVnumToShape(iVnum, *pdwShape)(:281)—— 默认 *pdwShape = iVnum; + iVnum ∉ {0,1} 且 USE_ARMOR_SPECULAR 时 *pdwShape = pItemData->GetValue(3)(SHAPE_VALUE_SLOT_INDEX=3) + · ChangeArmor(:2780)—— SetRace(rebuild) → SetArmor → SetHair → SetWeapon → SetRotation → + __AttachHorseSaddle → RefreshState(NAME_WAIT,TRUE) → OnChangeShape() + REF/UserInterface/NetworkActorManager.cpp:457 UpdateActor —— 远端换装序(:473-482): + ChangeArmor(m_dwArmor) → ChangeWeapon(m_dwWeapon) → ChangeHair(m_dwHair) → ChangeGuild → + SetAffectFlagContainer → SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags + extension/src/net/entity_store.h:57 —— ch_type:0 PC / 1 NPC / 2 MONSTER / 3 STONE / 4 WARP + (m2_client.cpp:2429 暴露为 d["ch_type"]);entity dict 无 poly_race / polymorph 字段。 + +改动: + project/part_hiding.gd(新增,extends RefCounted,纯函数无状态) + · POLY_SHAPE = 0 + · ANIMAL_WEAR_SHAPES = [100, 101, 102, 103] # __IsShapeAnimalWear + · WEDDING_DRESS_SHAPE = 201 # IsWearingDress + · BOUQUET_VNUMS = [50201, 50202, 50203, 50204] # c_iBouquets + · is_animal_wear(shape) / is_wearing_dress(shape) / is_bouquet(vnum) + · is_changable_weapon(weapon_vnum, shape):婚纱 → 仅捧花;否则 true + · weapon_hidden(shape, is_poly, weapon_vnum=0):is_poly ∨ animal_wear ∨ (dress ∧ ¬bouquet) + · hair_hidden(shape, is_poly):is_poly ∨ animal_wear(婚纱不影响头发) + · body_shape(shape, is_poly):is_poly → 0;否则原样(动物时装 100-103 保留,用来出时装体) + · effective_weapon(weapon_vnum, shape, is_poly):weapon_hidden → 0;否则原值 + project/ui/equip_model.gd + · const PartHiding = preload("res://part_hiding.gd") + · refresh() 重排:身体 → 头 / 发 → 武器 → 盾(对齐 ChangeArmor 的 SetArmor→SetHair→SetWeapon) + · 解出 raw_shape = armor_shape_of.call(body_vnum)(= __ArmorVnumToShape:proto values[3] / =vnum) + · is_poly = _is_poly()(新增,seam 恒 false) + · hair_off = PartHiding.hair_hidden(raw_shape, is_poly):真 → 清 hair_gr2 / hair_skin, + head_key 用 -1;_last_head_vnum 哨兵从 -1 改 -2(-1 现在是「发型被遮挡」的有效态); + 恢复时 _last_hair_part = -1 强制重挂 + · wpn_eff = PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) → _resolve_weapon + · 盾:sh_eff = PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) ? 0 : sh_vnum + · _is_poly()(新增):return false —— 40250 实体表暂无变身字段,seam + project/net_world.gd + · _apply_field_updates() 头注补 §4.1 修改 5:armor/weapon/hair 字段序已 1:1 于 + NetworkActorManager.cpp:473;变身 / 时装遮挡统一走模型侧 part_hiding.gd; + 当前 seam —— 远端 PC 是占位胶囊(_make_entity_model 对 ch_type==0 返回 null), + set_armor/weapon/hair 是 no-op + project/part_hiding_test.gd(新增,canonical #18)—— 45 断言: + animal-wear 全集(100-103 真 / 0,5,99,104,201 假)、dress 判定、bouquet 判定、 + is_changable_weapon(婚纱+捧花 / 婚纱+剑 / 普通 shape / 动物 shape 不被此门拦)、 + weapon_hidden 各分支、hair_hidden(婚纱不隐藏头发)、body_shape(变身→0 / 动物时装保留)、 + effective_weapon 各分支 + project/equip_model_test.gd —— 补 7 段:动物时装(armor_shape_of 桩返回 101)遮挡 + weapon_gr2 / shield_gr2 / hair_gr2、脱时装武器重解析、婚纱(桩返回 201)遮武器保头发、 + 婚纱换捧花(vnum 50201)_last_weapon_vnum 恢复为 50201 + +测试:M2_ASSETS=/Users/shenlei/Work/mt/mtgodot-poc/assets ctest --test-dir build → 21/21 + (无 C++ 改动);57 个 GDScript 回归全 PASS(新增 part_hiding_test;equip_model_test / + equip_rules_test / inventory_ui_test 保持绿)。 + +验收:纯离线。part_hiding.gd 常量与 InstanceBase.cpp 逐值对齐;equip_model.refresh() 刷新序 + 对齐 ChangeArmor;动物 / 怪物时装隐藏武器+盾+发、婚纱非捧花隐藏武器+盾且头发保留、 + 脱下后恢复。 + 已文档化 seam: + ① 变身态 —— m2_client.cpp entity dict 无 poly_race / polymorph,_is_poly() 恒 false; + 等 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带变身 race 后接 client.get_entity(vid).poly_race; + ② 远端 PC 的真模型工厂不属于本批次,已在增量 99 首版落地;本批次当时仍只确认字段序, + 未把“字段已到位”误记为“远端模型已完成”。 + +下一批次(增量 99):远端 PC 真模型工厂(PlayerView + 只认 parts[] 的远端 EquipModel)、 + §5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。 +``` + +### C.5 批次记录 · 增量 99(W3→M2 / §4.1 修改 5 —— 远端 PC 真模型工厂与 awPart[4]) + +```text +批次:增量 99 —— W3→M2 §4.1 修改 5(M2 玩法 + 渲染接线;纯 GDScript,无 C++ 改动) + +状态:🟡 部分完成(远端 PC 首版真模型与四字段换装链已落地;所有职业 / race、变身和真服视觉仍待验收)。 +参考: + REF/UserInterface/Packet.h:1282-1289 —— CHR_EQUIPPART_ARMOR=0 / WEAPON=1 / HEAD=2 / + HAIR=3 / CHR_EQUIPPART_NUM=4; + REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:113-115,160-162,193-195 —— + 角色附加信息和更新包携带 awPart 数组; + REF/UserInterface/NetworkActorManager.cpp:473-482 —— ChangeArmor → ChangeWeapon → ChangeHair + → ChangeGuild → affects / move speed / attack speed / alignment / PK / state flags。 +改动: + project/ui/remote_player_view.gd(新增)—— 复用 player_view.gd 的 race / GR2 / MSA 建模, + 创建远端专用 EquipModel,只消费四槽 awPart[ARMOR..HAIR],提供四个部位 setter; + project/ui/equip_model.gd —— 新增 setup_remote() / _remote_parts,远端模式不读取 + client.get_equipment(),四个远端部位变更后刷新 PartHiding / 真实模型; + project/game_scene.gd —— 默认实体工厂对 ch_type==0 创建 RemotePlayerView,按 race 缓存资源 + 可用性,失败时仍安全回退占位胶囊;怪 / NPC 继续走 MobView; + project/net_world.gd —— _apply_field_updates() 从旧的三字段假设改为 + set_armor(parts[0]) → set_weapon(parts[1]) → set_head(parts[2]) → set_hair(parts[3]); + project/remote_player_test.gd(新增)—— 真实 warrior 资源建模、四槽换装及 NetWorld 字段序回归。 +测试: + /opt/homebrew/bin/godot --headless --path project --script remote_player_test.gd → PASS; + gamescene_test.gd、equip_model_test.gd、net_world_vis_test.gd、netplay_test.gd、 + player_motion_test.gd → 全部 PASS。 +验收:已验证真实 warrior 的基础模型 / 发型 / 武器加载,远端四槽数据不依赖本地装备快照, + 以及字段调用顺序;未验证其他职业 / race、远端真实服务器洪流、poly_race / 变身、精确体型与 + 服装资源视觉、跨地图重建和真机性能。 +风险与依赖:40250 当前 entity dict 没有 poly_race / polymorph,EquipModel._is_poly() 仍是 + false;部分 race 若缺少可用 GR2/MSA 会按工厂失败回退占位,不应把回退视为模型支持。 +下一批次:W3 继续 §5.2 任务日志 / §5.3 fishing、dungeon、mount、observer UI;W4 继续 EffectLib + 精确 OUTER / INNER 与 advanced;W5 继续 Mac App G1/G2。 +``` + +### C.5 批次记录 · 增量 100(W4 / §6.3 EffectLib emitter cycle 生命周期首版) + +```text +批次:增量 100 —— W4 §6.3(EffectLib;GDScript,无 C++ 改动) + +状态:🟡 部分完成(2026-09-03;`fx_test` 通过;精确 OUTER / INNER 与每粒子语义仍为 seam) +参考:`40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` + (缺省 `CycleLength=0.05`、`CycleLoopEnable=false`、`LoopCount=0`); + `40250/ClientVS22/source/EffectLib/ParticleSystemInstance.cpp:280-300` + (cycle 边界、LoopCount 递减和停止发射); + `ParticleSystemInstance.cpp:153-209`(OUTER / INNER 按粒子半径计算速度)。 +改动: + `project/fx/effect_player.gd` —— 区分缺省 cycle 与显式零值;保存 cycle / loop / loop count 元数据; + 有限 cycle 到期停止 GPU 发射,重播复位 CPU 时钟;`_longest_life()` 将发射窗口与粒子寿命分开, + 删除原来用 cycle 覆盖 `GPUParticles3D.lifetime` 的错误;OUTER / INNER 保留 source mode 并使用 + 明确记录的 `ParticleProcessMaterial.spread=180` 宽方向 fallback。 + `project/fx_test.gd` —— 增加 cycle、LoopCount、独立粒子寿命和 OUTER fallback 断言。 +测试:`/opt/homebrew/bin/godot --headless --path project --script fx_test.gd` 通过; + 输出 `PASS: fx_test`。本批次没有 C++ 改动,因此未把 CTest 结果冒充为本批次证据。 +验收:已验证缺省值、有限 cycle / LoopCount 停止、重播状态复位和粒子寿命不被 cycle 覆盖; + 未验证真实 40250 特效视觉、每周期 emission residue / 时间曲线重置、OUTER / INNER 精确径向速度、 + 随机起始帧 / 每粒子随机方向与帧语义、骨骼挂点和真机视觉。 +风险与依赖:Godot `ParticleProcessMaterial` 没有按每粒子初始位置求径向速度的属性;`spread=180` + 只是显式 fallback,不能称为 FREE / OUTER / INNER 的 1:1 实现。后续需要独立 particle shader + 或 CPU 粒子实例路径,并同时保留参考的 emission residue、时间曲线和纹理随机语义。 +下一批次:W4 继续 EffectLib 精确 advanced / TexAni;W3→M2 继续 + fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态与 §4.3 tooltip;W5 继续 Mac App G1/G2。 +``` + +### C.5 批次记录 · 增量 101(W3→M2 / §5.2 Quest 日志窗口) + +```text +批次:增量 101 —— W3→M2 §5.2(M2 玩法;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(离线 `quest_test` 通过;真实 Quest 生命周期、导航表现和真服资源仍未验) +参考:`assets/root/uicharacter.py:651-703,1339-1346`(任务行选择、5 行窗口、clock 与滚动); + `40250/ClientVS22/source/UserInterface/PythonQuest.h/.cpp`(`QUEST_MAX_NUM=5`、默认 icon、 + quest index / clock 数据;源码路径按当前 40250 checkout 记录) +改动: + `project/ui/quest_log.gd` —— 新增 `QUEST_MAX_NUM=5`、任务快照、可见行 / 起始索引、滚动条和 + 离散滚动比例;无 icon 时使用参考默认 `season1/icon/scroll_open.tga`;任务行点击发送 + `script_button(-2147483648 + questIndex)` 并发出 `quest_selected`;保留独立 QuestButton + 条带的原有 `script_button(index)` 路径; + `project/quest_test.gd` —— 新增 7 条任务,覆盖只显示 5 行、滚动到最后 5 行、QuestButton + 负基址参数和空 icon 回退路径。 +测试:`/opt/homebrew/bin/godot --headless --path project --script quest_test.gd`;PASS。 +验收:未验证——真实服务端 RegisterQuest / SetQuestClockValue 的 start time、任务完成 / 删除 + 生命周期、真实 icon / `.sub` 视觉、世界箭头、屏幕边缘指示、多列定位和真机布局。 +风险与依赖:当前 M2Client 任务字典没有把 `iStartTime` 单独透出,因此 clock 仍以现有服务端 + `clock_value` 快照按窗口打开后递减;要完全对齐参考端需先把 server start time / refresh 语义 + 接入数据层。多列定位和导航依赖后续 minimap / world marker 工作流。 +下一批次:W3→M2 继续 §5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态和 §4.3 + tooltip;W4 继续 EffectLib 精确 advanced / TexAni;W5 继续 Mac App G1/G2。 +``` + +### C.5 批次记录 · 增量 102(W3→M2 / §5.3 钓鱼输入状态机) + +```text +批次:增量 102 —— W3→M2 §5.3(M2 玩法;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(钓鱼输入 / 取消 / 水面扫描首版已落地;鱼获落袋、坐骑 / 副本 / 观战者仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/PythonPlayerInputKeyboard.cpp:6-16,24 + —— 按下攻击键且处于 IsFishingMode 时进入 NEW_Fishing;方向键进入 NEW_CancelFishing; + 40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:419-425,506-549 + —— 地面点击先清预约 / 自动攻击,再取消钓鱼;取消间隔 500ms;NEW_Fishing 发送方向或失败回调; + 40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108 + —— GetFishingRot 仍是未迁移的水面角度扫描依据; + 40250/ClientVS22/source/UserInterface/Packet.h + —— CG_FISHING / GC_FISHING 协议常量与事件。 +改动: + project/net_play.gd —— 增加 fishing_feedback、钓鱼意图状态、五类事件状态转移; + 钓鱼模式攻击键改发 CG_FISHING,不设置普通攻击键;方向由 MapCoord yaw 转服务端 heading; + activate_fishing() 处理特殊技能 123,on_ground_click() 清 reserved / auto attack 后取消, + cancel_fishing() 按 500ms 节流,保留 can_fishing_position 可选 seam。 + project/player_controller.gd —— 增加方向键边沿 / 地面点击取消回调;WASD 长按只在边沿取消一次, + 地面点击回调消费后不再继续移动。 + project/game_scene.gd —— 装配上述两个取消入口,并把 fishing_feedback 接回 cannot_act 通道。 + project/ui/quickbar.gd —— SPECIAL_SKILL + SKILL_INDEX_FISHING(123) 进入 activate_fishing()。 +测试: + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd + /opt/homebrew/bin/godot --headless --path project --script player_move_test.gd + /opt/homebrew/bin/godot --headless --path project --script skill_test.gd + /opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd + /opt/homebrew/bin/godot --headless --path project --script combat_fx_test.gd + 以上均 PASS。 +验收: + 已验证钓鱼模式不会误发普通攻击;yaw=0 按 MapCoord 发送 270°;START 后可取消; + 连续取消被 500ms 节流;方向键边沿、地面点击的顺序和特殊技能 123 入口均有回归覆盖。 +风险与依赖: + Metin2World 已提供 GetFishingRot 等价的 ATTRIBUTE_WATER 扫描;扫描仅覆盖已加载区块, + 无地图 provider 的协议-only 场景仍保留 fallback,流式换图后的 provider 覆盖需真实验收。 + GC_FISHING(FISH) 仍沿用已有鱼获 / 失败日志路径,没有 item proto 校验、背包落袋或鱼获 UI。 + 坐骑 m_kHorse.CanAttack、真实模型 / MODE_HORSE* 动作 / 移速,副本 DungeonBlock / 结算窗, + 观战自由视角和完整 ADD/REMOVE/MOVE 生命周期仍是独立缺口。 +下一批次: + 增量 104 优先实现 wrong-place locale 文案和鱼获 item proto / inventory 事务。坐骑、副本、 + 观战者继续拆成互不写同一文件的独立批次。 +``` + +### C.5 批次记录 · 增量 103(W3→M2 / §5.3 GetFishingRot 水面扫描) + +```text +批次:增量 103 —— W3→M2 §5.3(地图数据 + 钓鱼玩法;C++ 扩展与 GDScript 桥接) +状态:🟡 部分完成(GetFishingRot 水面扫描首版已落地;wrong-place 文案、鱼获事务和其他世界玩法仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/InstanceBaseMotion.cpp:90-108 + —— GetFishingRot 以 600cm 距离、当前方向左右 0..180°、每 10° 检查水面; + 40250/ClientVS22/source/UserInterface/InstanceBase.cpp:364-377 + —— 水面判据是 CPythonBackground::isAttrOn(..., ATTRIBUTE_WATER); + 40250/ClientVS22/source/PRTerrainLib/Terrain.h:64-72 + —— ATTRIBUTE_WATER = (1 << 1)。 +改动: + extension/src/metin2_world.h/.cpp —— 绑定 get_fishing_rotation() / + can_fishing_position();读取已加载 Chunk 的 attr.atr,按 MapCoord 对应的 world frame + 在前方 6m 扫描当前 heading ±0..180°,返回第一个水面 heading;无水 / 未加载区块返回 -1。 + project/net_world.gd —— 将实体层收到的查询转发给 Metin2World;无地图 provider 返回 -2 + 让 NetPlay 保留协议-only fallback。 + project/net_play.gd —— 优先采用扫描选中的 heading;扫描失败发送 FISHING_WRONG_PLACE, + 兼容旧 bool provider。 + project/fishing_water_test.gd —— 加载 A1 全 20 区块,自动寻找真实 ATTRIBUTE_WATER 样本, + 验证 600cm 查询、heading 返回、bool wrapper 和地图外拒绝。 +测试: + cmake --build build -j2 —— PASS,libmtgodot.macos.template_debug.dylib 重新链接; + /opt/homebrew/bin/godot --headless --path project --script fishing_water_test.gd —— PASS; + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 +验收: + A1 全 20 区块真实水面样本通过扫描;NetWorld → Metin2World 桥接可用;没有地图 provider + 时不误报 wrong-place;有地图且无水面时返回 -1 并由 NetPlay 转 wrong-place。 +风险与依赖: + 当前水面查询依赖区块已加载;真实流式切换、角色出生点 / 水面边界和真服钓鱼动作仍需验收。 + 该批次只实现位置判定,不包含 OnFishingWrongPlace 的 locale 文案、FISH item proto 校验、 + 背包落袋和鱼获 UI。 +下一批次: + 增量 105+ 实现真实 `GC_ITEM_*` 鱼获 / inventory 事务;坐骑、副本、观战者继续保持独立文件边界 + 并行推进,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。 +``` + +### C.5 批次记录 · 增量 104(W3→M2 / §5.3 钓鱼反馈消息) + +```text +批次:增量 104 —— W3→M2 §5.3(钓鱼反馈消息;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(消息分支已落地;真实 `GC_ITEM_*` 背包事务、坐骑 / 副本 / 观战者仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3798-3860 + —— `GC_FISHING` 的 START/STOP/REACT/SUCCESS/FAIL/FISH 分支;FAIL 调 `OnFishingFailure`, + FISH info=0 调 `OnFishingNotifyUnknown`,未知 item proto 静默返回,fish / non-fish 依据 + item type 与 `IsFishing()` 分别调 `OnFishingNotify` / `OnFishingSuccess`。 + assets/locale/locale/en/locale_game.txt + —— `FISHING_FAILURE`、`FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2`、 + `FISHING_UNKNOWN`、`FISHING_WRONG_PLACE` 文案键。 +改动: + project/ui/chat_ui.gd —— `setup()` 加载 `locale_game.txt`;新增 `set_proto()` / + `on_fishing_feedback()`;按 `RecvFishing` 状态与 item proto 类型选择 failure / unknown / + notify / success 文案;`GC_FISHING(SUCCESS)` 只更新钓鱼意图和动画,不直接打印“鱼钩命中”。 + project/game_scene.gd —— 注入 item proto 给 `ChatUI`,连接 `fishing_feedback` 的 wrong-place + 反馈;`chat_test.gd` 增加鱼类 item、active / inactive 文案和 wrong-place 回归。 +测试: + /opt/homebrew/bin/godot --headless --path project --script chat_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。 +验收: + 已验证 `FISHING_FAILURE`、`FISHING_UNKNOWN`、wrong-place、fish active / inactive 两组 + locale 分支;未知 item proto 不产生日志;`SUCCESS` 不再被误报为鱼钩命中。 +风险与依赖: + 参考端的鱼获数量不是 `RecvFishing(FISH)` 直接修改,而是由独立 `GC_ITEM_*` 包完成;本批次 + 没有伪造本地落袋。下一批次需要先核对 item 包字段、库存事务和 `item proto`,再接鱼获 UI。 +下一批次: + 增量 105 已先接入坐骑攻击等级门;增量 106+ 继续实现真实 `GC_ITEM_*` 鱼获 / inventory 事务, + 并行推进副本、观战者和坐骑模型,并补 §4.2 拖放、§4.3 tooltip 的剩余分支。 +``` + +### C.5 批次记录 · 增量 105(W3→M2 / §5.3 坐骑攻击等级门) + +```text +批次:增量 105 —— W3→M2 §5.3(坐骑攻击限制;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(攻击等级门已落地;坐骑模型 / MODE_HORSE* 动作 / 移速和真服验收仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216 + —— SHORSE::GetLevel 按 mount race/vnum 映射等级;SHORSE::CanAttack 在骑乘 level<=1 时返回 false; + 40250/ClientVS22/source/UserInterface/InstanceBaseBattle.cpp:623-628 + —— CInstanceBase::CanAttackHorseLevel 调用 SHORSE::CanAttack; + 40250/ClientVS22/source/UserInterface/PythonPlayerInput.cpp:737-764 + —— __CanAttack 对新坐骑要求 GetSkillGrade(109)>=1 或 GetSkillLevel(109)>=11; + 40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:226-235 + —— __Update_AutoAttack 在骑乘且不能攻击时清除自动攻击目标。 +改动: + project/net_play.gd —— 新增 _horse_level_for_mount()、_is_main_mounted()、_is_new_mount()、 + _horse_skill_unlocked();接入普通坐骑 level 1 禁攻、新坐骑骑乘术 109 level 11/master 门; + 自动攻击改用 mount_vnum,不再只检查不存在的 mounting 字段。 + project/netplay_test.gd —— 覆盖 20101 level 1 拒绝、20205 level 2 放行、 + 新坐骑 skill 109 level 1 拒绝 / level 11 放行。 +测试: + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 +验收: + 已验证离线坐骑 vnum→level 和骑乘术等级门;未验证真实坐骑模型、MODE_HORSE* 动作 / 移速、 + new mount 的所有 race/vnum 及真服时序。 +风险与依赖: + 未知 mount vnum 按参考端 GetLevel()==0 放行;这是兼容未来坐骑资源,不等于已完成真服坐骑验收。 + 坐骑模型和动画仍依赖实体 mount 数据、资源可用性和后续渲染接线。 +下一批次: + 增量 106 已接入小地图 GC_OBSERVER_ADD/REMOVE/MOVE 的 1 秒平滑 / world_reset 清理; + 增量 107 已接入副本目的地状态 / HUD 罗盘;增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、 + DungeonBlock、观战自由视角和坐骑模型。 +``` + +### C.5 批次记录 · 增量 106(W3→M2 / §5.3 观战者小地图) + +```text +批次:增量 106 —— W3→M2 §5.3(观战者小地图轨迹;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(小地图轨迹已落地;自由视角、真服生命周期和换图时序仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180 + —— AddObserver / MoveObserver / RemoveObserver 保存 observer 轨迹,MoveObserver 以 1 秒 + 线性插值到新坐标;PythonNetworkStreamCommand.cpp:295-310 的 ObserverMode 只切换 + observer bool,本批次不把它扩展成参考端没有证据的独立自由相机。 +改动: + project/ui/minimap.gd —— 新增 observer_event 消费、独立轨迹表、ADD 初始快照播种、MOVE 一秒 + 插值、REMOVE 删除,以及 world_reset 清理;不把 observer 当作 EntityStore 的可见实体。 + project/p9_test.gd —— 覆盖 ADD / MOVE / REMOVE / reset 和插值中间态。 +测试: + /opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 +验收: + 已验证小地图 observer 的增删、移动平滑和地图重置清理;未验证真实服务器包时序、观战状态 + 生命周期、自由视角相机和真服地图切换。 +风险与依赖: + 参考端 ObserverMode 证据是输入 / 攻击 / 技能门控,不足以证明存在独立自由相机;实现前仍需 + 找到相机切换的真实参考调用或通过真服行为验收,不凭经验添加自由镜头。 +下一批次: + 增量 107 接入副本目的地状态 / HUD 罗盘;增量 108+ 继续推进 observer 生命周期验收和 + 真实 GC_ITEM_* 鱼获 / inventory 事务。 +``` + +### C.5 批次记录 · 增量 107(W3→M2 / §5.3 副本目的地状态) + +```text +批次:增量 107 —— W3→M2 §5.3(GC_DUNGEON 目的地消费与 HUD 罗盘;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(目的地消费 / 到达 / HUD 首版已落地;DungeonBlock、入口 / 结算和真服验收仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901 + —— RecvDungeon 的 TIME_ATTACK_START 分支为空;DESTINATION_POSITION 读取两个 unsigned long, + 调用 CPythonPlayer::SetDungeonDestinationPosition。 + 40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:147-167,1255-1285 + —— 保存全局厘米坐标;当前位置与目标的 Manhattan 距离严格小于 10000cm 时清除目标, + 未到达时每 20 秒重新 AlarmHaveToGo;收到目的地后立即创建目标罗盘特效。 +改动: + project/dungeon_state.gd —— 消费 dungeon_event,保存原始全局厘米坐标和 MapCoord 世界坐标; + 立即发出 alarm_requested,按参考端 Manhattan <10000cm 判定到达,每 20 秒再次提示, + 并在 world_reset 清理。TIME_ATTACK_START 保持空分支,不虚构本地倒计时。 + project/hud.gd —— 增加目标距离和相对朝向提示;这是参考端 AlarmHaveToGo 的 HUD 适配, + 还没有加载参考 `.mse` 罗盘特效文件。 + project/game_scene.gd —— 创建 DungeonState,接入 M2Client.dungeon_event / world_reset, + 将 destination_changed 接到 HUD。 + project/dungeon_state_test.gd —— 覆盖空计时分支、目的地坐标转换、立即提示、100m 边界、 + 到达清除、第二个目的地和 world_reset。 +测试: + /opt/homebrew/bin/godot --headless --path project --script dungeon_state_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script netplay_test.gd —— PASS。 +验收: + 已验证 GC_DUNGEON 目的地状态的协议字段、厘米 / 米坐标转换、参考端到达边界和场景 reset; + 未验证真实副本入口、DungeonBlock 的 `.mde` / `.mdatr` 解析与碰撞、9 参数结算窗、真服时序。 +风险与依赖: + GC_DUNGEON 的目的地是全局坐标,不是当前区块局部坐标;真实地图 origin / 坐标基准仍需真服 + 场景验收。TIME_ATTACK_START 在参考源码中没有本地计时器,不能据此添加倒计时。 +下一批次: + 增量 108+ 并行推进真实 GC_ITEM_* 鱼获 / inventory 事务、DungeonBlock `.mdatr` collision、 + 副本入口 / 结算、observer 生命周期 / 相机证据、坐骑模型和 §4.2 / §4.3 剩余 UI 分支。 +``` + +### C.5 批次记录 · 增量 108(W3→M2 / §5.2 任务信标世界箭头 / 屏幕边缘指示) + +```text +批次:增量 108 —— W3→M2 §5.2(小地图任务信标越界夹边 + 方向箭头;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(世界箭头 / 屏幕边缘指示首版已落地;多列定位、真实 `.sub` 状态图仍未完成) +参考: + 40250/ClientVS22/source/UserInterface/PythonEventManager.cpp:554-570 + —— EVENT_TYPE_ADD_MAP_SIGNAL / CLEAR_MAP_SIGNAL 调用 CPythonMiniMap::AddSignalPoint(x,y) + / ClearAllSignalPoint() + OpenAtlasWindow()。 + m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:1334-1358 + —— AddSignalPoint:push TSignalPoint + AddWayPoint(TYPE_WAYPOINT, id, x, y, ""); + ClearAllSignalPoint:逐 id RemoveWayPoint 后清表。 + m2dev-client-src-main/src/UserInterface/PythonMiniMap.cpp:441-462 + —— Render 的 waypoint 夹边分支:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f; + fDistanceFromCenter = sqrt(dx*dx+dy*dy);>= radius 时 fRadian = atan2f(dy,dx), + dx = radius*cosf(fRadian)、dy = radius*sinf(fRadian),标记贴到圆周(→ __RenderTargetMark)。 + 说明:40250/ClientVS22 的 PythonMiniMap.cpp 为裁剪版,无 AddSignalPoint / 夹边渲染定义, + 故夹边几何以 m2dev 完整版为对照基线(seam:该逻辑不在唯一基线 40250/ClientVS22 内)。 +改动: + project/ui/minimap.gd —— 新增 const SIGNAL_RIM_INSET = 9.0(对齐 c_fMiniMapWindowRadius 的 + `- 9.0f` 留边);新增 static signal_marker_geometry(delta_px, rim_radius) → {clamped, offset, + angle},1:1 复刻 atan2 + radius*cos/sin 夹边;新增 _signal_offset(world, origin)(世界 delta + → 像素,轴向与 _to_map 一致);_draw_map 的信标循环改为:圆内画橙星(原样),越界调 + _draw_signal_arrow(pos, angle) 在圆周画指向真实目标的三角箭头(对应 __RenderTargetMark 的 + 边缘指示,本工程无 `.sub` 资源用三角形代替)。_signal_points 存储与 add/clear_signal_point + 入口不变。 + project/p9_test.gd —— 小地图段新增 8 断言:圆内保留真实 offset、越界夹到圆周、夹边保留 + 正东 / 对角 atan2 朝向、恰在半径按 `>=` 计为越界、add/clear_signal_point 计数。 +测试: + /opt/homebrew/bin/godot --headless --path project --script p9_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script atlas_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS。 + 回归:p8_test / p10_test / net_world_vis_test / netplay_test 均 PASS。 +验收: + 已验证夹边几何(atan2 朝向保持、越界贴圆周、留边常量)、信标增删计数和重绘存活; + 未验证真实 `.sub` waypoint / target 图标视觉、Atlas 同一信标的夹边、任务日志多列定位、 + 真实 EventManager ADDMAPSIGNAL 端到端。 +风险与依赖: + 夹边逻辑的对照基线是 m2dev 完整版而非唯一基线 40250/ClientVS22(后者为裁剪版,缺该实现); + §5.2 剩余「多列定位」属 quest_log.gd 布局侧,未在本批次推进;OpenAtlasWindow 联动 + (ADDMAPSIGNAL 时自动打开 Atlas)仍由 quest_dialog 的 side effect 负责,未纳入本批次。 +下一批次: + 增量 109+ 推进 quest_log.gd 多列定位 / 真实图标视觉、§8.8 text_tail.gd(名字颜色对照基线 + 同样落在 m2dev 完整版)、§4.2 / §4.3 剩余 UI 分支。 +``` + +### C.5 批次记录 · 增量 109(W3→M2 / §5.2 任务信件按钮条多列定位) + +```text +批次:增量 109 —— W3→M2 §5.2(quest 信件按钮条 __ArrangeQuestButton 1:1 多列布局;纯 GDScript,无 C++ 改动) +状态:🟡 部分完成(多列定位 + 最新在前 + 组队窗移位已落地;真实 `.sub` 信件图标 / 闪烁、任务日志窗体本身多列仍未做) +参考: + assets/root/interfacemodule.py:1357-1386 __ArrangeQuestButton + —— xPos = 130 (= 100 + 30) 若 wndParty.IsShow() 否则 20(+15 RTL,本迁移不做 RTL); + yPos = 170 * screenHeight // 600 ;yCount = (screenHeight - 330) // 63 ; + 第 count 个:btn.SetPosition(xPos + (count // yCount) * 100, yPos + (count % yCount) * 63)。 + assets/root/interfacemodule.py:1301-1353 BINARY_RecvQuest + —— 同 index 先 __DestroyQuestButton 再 questButtonList.insert(0, btn):无论新旧都移到最前; + __StartQuest / __DestroyQuestButton 后再 __ArrangeQuestButton 重排。 + 说明:40250/ClientVS22 的 source/ 树只含 C++,questbuttonlist 的 Python 布局在 assets/root/ + interfacemodule.py(原版随包脚本),非 seam。 +改动: + project/ui/quest_log.gd —— 新增布局常量(QUEST_BTN_X=20 / QUEST_BTN_X_PARTY=130 / + QUEST_BTN_COL_W=100 / QUEST_BTN_ROW_H=63 / QUEST_BTN_SIZE);新增有序表 _quest_button_order + (recv_quest 里 erase + push_front 复刻 insert(0, btn),clear_quest / _start_quest 同步 erase); + 静态 quest_button_y_count(H)= maxi(1,(H-330)/63)(参考端无下限、H<393 会 ZeroDivision,夹 1 = 防御性偏离 seam)、 + quest_button_y_pos(H)= 170*H/600、quest_button_slot_pos(slot,yCount,xPos,yPos)= 1:1 公式; + _refresh_quest_buttons 改为按 _quest_button_order 顺序、给每个 Button 绝对 position(不再 VBox 单列 + sort by index);_button_root / _button_list 改为全屏 Control + MOUSE_FILTER_IGNORE,root 落原点、 + 坐标里已含 xPos/yPos;_button_list 类型 VBoxContainer → Control(消掉 gamescene_test 的 SCRIPT ERROR); + 组队窗可见性经 set_party_probe(Callable) 在 _process 里轮询(party_ui 无信号),变化即重排; + 另加 set_party_shown / set_screen_size_override / quest_button_order / quest_button_position 便于回归。 + project/game_scene.gd —— party_ui 建好后 quest_log.set_party_probe(func(): return party_ui.is_open())。 + project/quest_test.gd —— quest_log 段尾新增 16 断言:yCount(600/1080/350 夹边)、yPos(600/1200)、 + 6 个信件 insert-at-front 顺序、slot 0/3/4(换列)/5 坐标、Button 节点 position、同 index 移到最前、 + 组队窗 xPos→130 且节点重定位、clear 从 order 移除、清空后 _button_root 隐藏。 +测试: + /opt/homebrew/bin/godot --headless --path project --script quest_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script quest_event_test.gd —— PASS。 + /opt/homebrew/bin/godot --headless --path project --script gamescene_test.gd —— PASS(SCRIPT ERROR 消除)。 + /opt/homebrew/bin/godot --headless --path project --script input_key_test.gd —— PASS。 + 回归:p8_test / p9_test / p10_test / net_world_vis_test 均 PASS。 +验收: + 已验证多列换列坐标、最新在前顺序、同 index 提前、组队窗 xPos 移位、清空隐藏。 + 未验证真实 `.sub` 信件图标 / highlight 视觉 / Flash 动画、RTL 的 +15、screenWidth 相关分支(原版未用)、 + 任务日志窗体(_list)自身的多列 / 分页、真实 EventManager QUESTBUTTON 端到端。 +风险与依赖: + 组队窗可见性靠 _process 轮询探针(party_ui 无 visibility-changed 信号),最坏 1 帧延迟; + screenHeight < 393 的 yCount 夹到 1 是防御性偏离(参考端会崩),实际分辨率不会触发; + __ArrangeQuestButton 每帧对全表 SetPosition,本迁移是「变化时重建」,功能等价但非逐帧。 +下一批次: + 增量 110+:任务日志窗体 _list 的多列 / 分页与真实 `.sub` 图标视觉、§8.8 text_tail.gd 名字颜色 + (ENameColor,对照基线 m2dev 完整版)、§4.2 / §4.3 剩余 tooltip / 鼠标物品态分支。 +``` + +### C.5 批次记录 · 增量 110(W3→M2 / §8.8 头顶名字色 ENameColor / GetNameColorIndex 1:1) + +```text +批次:增量 110 —— W3→M2 §8.8 + §5.4:CInstanceBase::GetNameColorIndex / GetNameColor / + SetEmpireNameMode / GetIndexedNameColor 1:1(纯 GDScript,无 C++ 改动) +状态:✅ 完成(名字色算法 + 调色板 + net_world 接线;🔎 真服 GC_PVP / 公会战 / 决斗 + 下的边沿颜色待真机验收) +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,全部 ISO-8859 编码, + grep 需 -a): + UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄: + IsPC → (m_isKiller→PK) → (__IsExistMainInstance && !__IsMainInstance 时: + 主角 GetDuelMode(): DUEL_CANNOTATTACK→NAMECOLOR_PC+GetEmpireID; + DUEL_START→__FindDUELKey? PVP : NAMECOLOR_PC+GetEmpireID; + IsSameEmpire(*this): __FindPVPKey→PVP / __FindGVGKey→PVP(__FindDUELKey 分支参考端注释掉); + else(异帝国)→PVP) + → IsPartyMemberByVID→PARTY → NAMECOLOR_PC+GetEmpireID + IsNPC→NPC / IsEnemy→MOB / IsPoly→MOB / 否则 D3DXCOLOR(0xffffffff) 当 UINT 传 → 越界 → 白 + UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameMode(g_isEmpireNameMode; + 开:MOB=EMPIRE_MOB / NPC=EMPIRE_NPC / PC=NORMAL_PC / for u∈[1,4): PC+u=EMPIRE_PC+u; + 关:MOB=NORMAL_MOB / NPC=NORMAL_NPC / for u∈[0,4): PC+u=NORMAL_PC) + UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColor(idx>=NAMECOLOR_NUM → 白) + UserInterface/InstanceBase.h:208/216 匿名枚举 EMPIRE_NONE/A/B/C(EMPIRE_NUM=4)+ + NAMECOLOR_*(展开:MOB0 NPC1 PC2 NORMAL_MOB7 NORMAL_NPC8 NORMAL_PC9 EMPIRE_MOB14 + EMPIRE_NPC15 EMPIRE_PC16 FUNC21 PK22 PVP23 PARTY24 WARP25 WAYPOINT26 NUM41) + UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_MOB/NPC/PC 常量 + 重映射到 C++ NAMECOLOR_NORMAL_MOB/NORMAL_NPC/NORMAL_PC;PK/PVP/PARTY/WARP/WAYPOINT/ + EMPIRE_* 直通 + assets/root/introloading.py:264 IntroLoading.__RegisterColor(chrmgr.RegisterNameColor 写槽) + assets/root/colorInfo.py:11 CHR_NAME_RGB_MOB(235,22,9) NPC(122,231,93) PC(255,215,76) + PK(180,100,0) PVP(238,54,223) PARTY(128,192,255) WARP(136,218,241) WAYPOINT(255,255,255) + EMPIRE_MOB(235,22,9) EMPIRE_NPC(122,231,93) EMPIRE_PC_A(157,0,0) _B(222,160,47) _C(23,30,138) + assets/root/constinfo.py:126 CHRNAME_COLOR_INDEX 默认 0 → SET_DEFAULT_CHRNAME_COLOR → + SetEmpireNameMode(0) → 帝国名色**关**(所有 PC 名字黄) + **无 seam**:40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetNameColorIndex, + 字节级等同 m2dev 完整版(此前 grep 漏读是因 ISO-8859 被当二进制,-a 后确认); + 增量 109 批次记录里「对照基线 m2dev 完整版」的说法据此更正。 +改动: + project/name_color.gd(新)—— 纯静态 `extends RefCounted`: + NAMECOLOR_* 常量 + EMPIRE_NUM=4;_registered_slots()(introloading 注册后的 g_akD3DXClrName + 实际槽,chrmgr 常量重映射已折进);_palette + _empire_name_mode 静态态; + set_empire_name_mode(bool)(:52 逐行,含开分支 for u∈[1,4)、关分支 for u∈[0,4)); + indexed_name_color(idx)(:75,越界 → 白);name_color_index(self_e, main_e, ctx) + (:525 逐行,复用 entity_rules._is_pc/_is_killer/_is_enemy/_is_poly/_is_same_empire/ + _find_duel_key/_pair_in + DUEL_* 常量,本地补 _is_npc/_empire_id);name_color()=GetNameColor。 + ctx 与 net_play._attack_ctx 同构(main_vid/duel_mode/duel_opponents/pvp_pairs/gvg_pairs/party_vids)。 + 偏离:__FindGVGKey 加 guild!=0 守卫(参考端 guild 0 的 PVPKEY 恒 0、集合里不会有; + 与 entity_rules._is_pvp_instance 既有防御一致)。 + project/net_world.gd —— const NameColor / EntityRules;var kind_resolver(func(dict)->int, + game_scene 注入 net_play._entity_kind,补本 fork「NPC/怪 bType 都 0」的分类缺口,未注入时退化 ch_type); + _name_color_ctx()(自 client.get_duel/get_pvp_relations/get_guild_wars/get_party 拼 ctx)、 + _main_name_snapshot()、_apply_name_color(vid)(写头顶 "Label3D".modulate)、_retint_all_names()、 + set_empire_name_mode(bool) 透传;单发刷新点:_on_spawn / _on_info / _on_pvp_changed(src+dst)/ + _on_duel_changed(全表)/ _on_main_set(全表);_process 里 ~2Hz(accum≥0.5s)整表兜底 + (参考端 RenderName 每帧都算 GetNameColor)。删掉 _apply_anim / _on_motion 里 + 占位胶囊借 "Label3D".modulate 表现动作态的 _state_tint(该 modulate 现专属名字色), + _state_tint 函数一并删除。 + project/game_scene.gd —— name_resolver 注入旁边加 `net_world.kind_resolver = net_play._entity_kind`。 + project/name_color_test.gd(新)—— SceneTree 回归,约 55 断言:NAMECOLOR_* 序号、 + 帝国名色关/开两态、GetNameColorIndex 每分支(killer→PK、party→PARTY、异帝国→PVP、 + victim empire 0 视同帝国、pvp/gvg 对表→PVP、决斗 CANNOTATTACK/START 两态、无主角实例、 + 自己、NPC/MOB/poly/未知)、killer 压过 party、GetIndexedNameColor 越界→白、 + 每个 ENameColor 槽解析出的 colorInfo RGB。 +测试: + /opt/homebrew/bin/godot --headless --path project --script name_color_test.gd —— PASS。 + 回归全 PASS:gamescene_test / netbridge_test / mob_view_test / net_world_vis_test / netplay_test / + entity_rules_test / quest_test / quest_event_test / input_key_test / p8_test / p9_test / p10_test / + remote_player_test / player_motion_test / combat_fx_test / skill_fx_test / player_move_test / + player_skill_test / app_flow_lifecycle_test / fly_test / channel_status_test。 +验收: + 已验证名字色算法逐分支、SetEmpireNameMode 调色板重写、越界回退、colorInfo RGB 映射(离线)。 + 未验证:真服 GC_PVP / GUILD_GC_WAR / 决斗包驱动下的实时颜色切换、帝国名色开关的选项 UI + (目前只有 net_world.set_empire_name_mode 运行期钩子,未挂 uisystemoption)、真模型(GRN) + 头顶 Label3D 与占位胶囊路径的一致性需真机目视。 +风险与依赖: + net_world 无 proto,实体分类靠注入的 kind_resolver=net_play._entity_kind;未注入(裸 net_world + 测试 / headless 无 assets)时退化到 ch_type,m2dev 后端 NPC/怪 bType=0 会被当 PC(同 §5.4 既有 seam ⑥)。 + __FindPVPKey / __FindGVGKey 的 key 集合 setter 仍是 §5.4 seam ①②(get_pvp_relations / + get_guild_wars 有值则消费)。名字色整表刷新 ~2Hz(非逐帧),队伍 / 关系变化最坏 0.5s 延迟。 + 静态 _palette / _empire_name_mode 是进程级单例(与参考端 g_akD3DXClrName / g_isEmpireNameMode 一致)。 +下一批次: + §8.8 text_tail.gd(称号色 GetTitleColor / 公会名 / 屏幕空间血条 / 聊天气泡整合、 + LALT + §8.4 三 radio 显示开关)、目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动、 + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 +``` + +### C.5 批次记录 · 增量 111(W3→M2 / §8.8 头顶「公会名 / 称号 / 等级」子标签 1:1) + +```text +批次:增量 111 —— W3→M2 §8.8:CInstanceBase::GetTitleColor / RefreshTextTail / + AttachTextTail / UpdateTextTailLevel + CPythonTextTail 的公会名 / 称号 / 等级子标签、 + EnablePKTitle 1:1(纯 GDScript,无 C++ 改动) +状态:✅ 完成(称号色算法 + 等级文本 + 公会名兜底 + net_world 三子标签接线 + game_scene + title_name_resolver 注入;🔎 真服 alignment / GC_GUILD 变化下的实时刷新、真模型头顶 + 排版目视待真机验收) +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp + 全部 ISO-8859 编码,grep 需 -a): + UserInterface/InstanceBaseEffect.cpp:609 GetTitleColor —— uGrade = GetAlignmentGrade(); + if (uGrade >= TITLE_NUM(9)) return s_kD3DXClrTitleDefault(白); return g_akD3DXClrTitle[uGrade]; + UserInterface/InstanceBaseEffect.cpp:664 RefreshTextTail —— SetCharacterTextTailColor(GetNameColor()); + iAlignmentGrade = GetAlignmentGrade(); + if (TITLE_NONE(4) == iAlignmentGrade) DetachTitle(); + else if (g_TitleNameMap.find(grade) 命中) AttachTitle(name, GetTitleColor()); + UserInterface/InstanceBaseEffect.cpp:684 RefreshTextTailTitle → RefreshTextTail + UserInterface/InstanceBaseEffect.cpp:621 AttachTextTail —— fTextTailHeight = IsMountingHorse()?110:10; + RegisterCharacterTextTail(m_dwGuildID, dwVID, 白, fTextTailHeight); + if (m_dwLevel) UpdateTextTailLevel(m_dwLevel); + UserInterface/InstanceBaseEffect.cpp:654 UpdateTextTailLevel —— s_kLevelColor = + D3DXCOLOR(152/255, 255/255, 51/255, 1.0); sprintf(szText, "Lv %d", level); + AttachLevel(vid, szText, s_kLevelColor); + UserInterface/InstanceBaseEffect.cpp:1107 RegisterTitleName —— g_TitleNameMap[iIndex] = c_szTitleName + UserInterface/InstanceBaseEffect.cpp:1131 RegisterTitleColor —— g_akD3DXClrTitle[u] = + __RGBToD3DXColoru(r,g,b)(= D3DXCOLOR(r/255, g/255, b/255)) + UserInterface/InstanceBase.h:262/263 TITLE_NUM = 9 / TITLE_NONE = 4 + UserInterface/InstanceBase.cpp:2053 SetAlignment(sAlignment) → RefreshTextTailTitle() + UserInterface/InstanceBase.cpp:2079 SetPKMode 末尾 → RefreshTextTail() + UserInterface/PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF(ARGB → RGB 239,211,255) + UserInterface/PythonTextTail.cpp:23/938 bPKTitleEnable = TRUE / EnablePKTitle(bFlag) + UserInterface/PythonTextTail.cpp:509/543 RegisterCharacterTextTail —— dwGuildID != 0 才 + New() 公会名 CGraphicTextInstance;if (!CPythonGuild::GetGuildName(dwGuildID, &str)) str = "Noname"; + SetColor(c_TextTail_Guild_Name_Color) + UserInterface/PythonTextTail.cpp:890/943 AttachTitle / AttachLevel —— 均 if (!bPKTitleEnable) return; + AttachTitle EU 右对齐 / 否则居中;AttachLevel 恒右对齐 + assets/root/introloading.py:260 __RegisterTitleName —— for i: chrmgr.RegisterTitleName(i, TITLE_NAME_LIST[i]) + assets/root/introloading.py:287 __RegisterColor 的 TITLE_COLOR_DICT(注册序 0..8): + GOOD_4 GOOD_3 GOOD_2 GOOD_1 NORMAL EVIL_1 EVIL_2 EVIL_3 EVIL_4 + assets/root/colorInfo.py:29 TITLE_RGB_GOOD_4(0,204,255) _3(0,144,255) _2(92,110,255) _1(155,155,255) + NORMAL(255,255,255) EVIL_1(207,117,0) _2(235,83,0) _3(227,0,0) _4(255,0,0) + assets/root/localeInfo.py:215 TITLE_NAME_LIST = (PVP_LEVEL0 .. PVP_LEVEL8,) + (en locale_game.txt:441-449 Chivalric/Noble/Good/Friendly/Neutral/Aggressive/Fraudulent/Malicious/Cruel) + assets/root/constinfo.py:4 + game.py:155 PVPMODE_ENABLE = 1;textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE) + → 起始即启用(本移植 _pk_title_enabled 默认 true) + **无 seam**:40250 sole baseline 的 InstanceBaseEffect.cpp 本身含 GetTitleColor / + RefreshTextTail / UpdateTextTailLevel / RegisterTitleName / RegisterTitleColor + (:609/:664/:654/:1107/:1131),字节级等同 m2dev 完整版。 +改动: + project/text_tail.gd(新)—— 纯静态 `extends RefCounted`: + TITLE_NUM=9 / TITLE_NONE=4;_title_colors()(g_akD3DXClrTitle 的 9 槽,Color8=D3DXCOLOR(x/255)); + LEVEL_COLOR = Color(152/255,255/255,51/255);GUILD_NAME_COLOR = Color(239/255,211/255,255/255); + GUILD_NAME_FALLBACK = "Noname";_pk_title_enabled 静态态 + set/is_pk_title_enabled(EnablePKTitle); + title_color(grade)(:609,grade<0||>=TITLE_NUM → 白);title_name_key(grade)="PVP_LEVEL%d"; + level_text(level)="Lv %d";text_tail_height(mounted)=110|10; + refresh_title(alignment) → {show_title, grade, name_key, color}(复用 entity_rules.alignment_grade, + grade==TITLE_NONE 或 !pk_title 时 show_title=false); + refresh_level(level) → {show_level=(pk_title && level>0), text, color}; + refresh_guild(guild_id, resolved_name) → guild_id==0 不显示 / 否则 name 空串兜底 "Noname"。 + 偏离:EnablePKTitle(FALSE) 时参考端 Attach/Detach 均早退=「冻结上一态」,本移植 refresh_* 返回 + show=false=「隐藏」(seam ⑦ 之外的小偏离,已在 §8.8 修改 2 与代码注释标注)。 + project/net_world.gd —— const TextTail;var title_name_resolver(func(grade)->String, + game_scene 注入 Locale.t("PVP_LEVEL%d");未注入回退裸 key=seam ⑧); + _attach_nameplate 加 GuildTag(y=2.34)/TitleTag(y=1.86)/LevelTag(y=1.74) 三个 Label3D 子节点 + (billboard + no_depth_test + pixel_size 0.005,初始 text="" visible=false),经 _mk_sub_tag() 造; + _set_name_visibility 同步开关三子标签(visible = _names_visible && text != ""); + _resolve_title_name / _resolve_guild_name(后者走 client.get_guild_name(gid)); + _apply_text_tail_to(n, e) / _apply_text_tail(vid) / _retint_all_text_tails()(姊妹于 _apply_name_color 系); + 刷新点:_on_spawn / _on_info 单发 + _process ~2Hz(与名字色同一 accum≥0.5s)整表兜底; + set_pk_title_enabled(bool) 透传 TextTail + 整表重刷。 + project/game_scene.gd —— kind_resolver 注入旁加 title_name_resolver:新建 Locale("en"), + lambda 查 PVP_LEVEL%d(has() 命中才返回,否则空串让 net_world 走 seam ⑧ 回退)。 + project/text_tail_test.gd(新,第 22 个 canonical)—— SceneTree 回归,约 50 断言: + 常量(TITLE_NUM/NONE、LEVEL/GUILD 色、"Noname")、title_color 9 槽 + 越界→白、 + title_name_key / level_text / text_tail_height 字面、refresh_title(TITLE_NONE 不显示、 + grade 0/8 显示 + 色 + name_key、grade 3/5 显示)、refresh_level("Lv 42" + 色、level 0 无)、 + refresh_guild(id 0 无、解出名、空串→"Noname")、EnablePKTitle(FALSE) 压制称号 + 等级但不压公会名 + + 还原、grade 与 entity_rules.alignment_grade 9 组一致。 +测试: + /opt/homebrew/bin/godot --headless --path project --script text_tail_test.gd —— PASS。 + 回归:project/*_test.gd 全 62 个全 PASS(含 name_color_test / entity_rules_test / gamescene_test / + netbridge_test / mob_view_test / net_world_vis_test / netplay_test / quest_test / input_key_test / + p8_test / p9_test / p10_test / remote_player_test / player_motion_test / combat_fx_test / …)。 +验收: + 已验证(离线):GetTitleColor 9 grade + 越界、RefreshTextTail 的 TITLE_NONE / 越界分支、 + UpdateTextTailLevel 的 "Lv %d" + s_kLevelColor、RegisterCharacterTextTail 的 guild!=0 / "Noname"、 + AttachTextTail 的 110/10、EnablePKTitle 开关、grade 与 alignment_grade 一致。 + 未验证:真服 SetAlignment / SetPKMode / GC_GUILD 驱动下的实时称号切换、真模型(GRN)头顶 + GuildTag/TitleTag/LevelTag 与占位胶囊路径的排版一致性(需真机目视)、EU 右对齐 vs 居中的 + 水平对齐、g_TitleNameMap 未注册某 grade 时的 AttachTitle 跳过(本移植 0..8 全有 key)。 +风险与依赖: + seam ⑦:billboard 3D 里把公会名(上)/ 称号·等级(下)竖排,参考端是屏幕空间像素排版 + (公会名在名字上 c_fyGuildNamePosition=15px、称号 / 等级贴右侧),视觉近似非逐像素等同。 + seam ⑧:title_name_resolver 未注入(裸 net_world 测试 / headless 无 assets)时称号显示裸 + "PVP_LEVEL%d" key —— 生产环境 game_scene 恒注入 Locale。 + 公会名走 client.get_guild_name(gid)(= world->guild_name),空串即视作 GetGuildName 失败 → "Noname"。 + _pk_title_enabled 是进程级静态单例(与参考端 bPKTitleEnable 全局一致);未接选项 UI, + 仅 net_world.set_pk_title_enabled() 运行期钩子。 + 称号 / 等级 / 公会名整表刷新 ~2Hz(复用名字色 accum),alignment / guild 变化最坏 0.5s 延迟。 +下一批次: + §8.8 剩余:text_tail.gd 伤害数字(AttachInfoTail / AttachDamage)+ 聊天气泡(AttachChat)+ + 屏幕空间血条整合、LALT(§8.6)+ §8.4 三 radio 的头顶信息显示开关; + 或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动; + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 +``` + +### C.5 批次记录 · 增量 112(W3→M2 / §8.8 头顶「聊天 / 信息」尾标 1:1) + +```text +批次:增量 112 —— W3→M2 §8.8:CPythonTextTail::RegisterChatTail / RegisterInfoTail + + TextTail_GetLivingTime / SetLivingTime + IsChatTextTail + RecvChatPacket 的头顶尾标派发 + ("名字: " 前缀剥离 / SHOUT·COMMAND 排除)1:1(纯 GDScript,无 C++ 改动) +状态:✅ 完成(chat_tail.gd 纯静态算法 + net_world "bubble" 子节点接线 + chat 派发规则对齐 + + game_scene._player_bubble 寿命对齐;🔎 真服多类型聊天并发下的气泡替换 / 到点硬删、真模型 + 头顶气泡与名字 Label3D 排布目视待真机验收) +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp + 全部 ISO-8859 编码,grep 需 -a): + UserInterface/PythonTextTail.cpp:16/17 c_TextTail_Chat_Color = D3DXCOLOR(1,1,1,1)(白)/ + c_TextTail_Info_Color = D3DXCOLOR(1, 0.785f, 0.785f, 1)(淡红) + UserInterface/PythonTextTail.cpp:26/28/34 gs_TextTail_LivingTime = 5000; + TextTail_GetLivingTime() assert(gs_TextTail_LivingTime > 1000);TextTail_SetLivingTime(livingTime) + UserInterface/PythonTextTail.cpp:581 RegisterChatTail(vid, szChat) —— m_ChatTailMap.find(vid) 命中: + pTextInstance->SetValue(szChat); Color = c_TextTail_Chat_Color; LivingTime = now + GetLivingTime(); + bNameFlag = TRUE; return; —— 否则 RegisterTextTail(vid, szChat, owner, height + 10.0f, Chat色), + LivingTime = now + GetLivingTime(); bNameFlag = TRUE; SetOutline(true); VERTICAL_ALIGN_BOTTOM; + m_ChatTailMap.insert(vid, pTextTail) + UserInterface/PythonTextTail.cpp:624 RegisterInfoTail(vid, szChat) —— 同上但 Color = Info色、 + bNameFlag = FALSE;与 RegisterChatTail 共用 m_ChatTailMap(同 vid 互相覆盖) + UserInterface/PythonTextTail.cpp:328 UpdateAllTextTail —— for m_ChatTailMap: if (LivingTime < now) + { DeleteTextTail; erase; }(硬删,无淡出) + UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail —— chat 尾标 if (bNameFlag) ShowCharacterTextTail(vid) + UserInterface/PythonTextTail.cpp:105 UpdateShowingTextTail —— if (chatTail && chatTail->bNameFlag) + 名字尾标 y = chatTail.y - 17.0f(气泡在上、名字下移) + UserInterface/PythonTextTail.cpp:684 IsChatTextTail(vid) —— m_ChatTailMap.find(vid) != end + UserInterface/PythonNetworkStreamPhaseGame.cpp:1301 CHAT_TYPE_COMMAND → ServerCommand(); return;(不进尾标) + UserInterface/PythonNetworkStreamPhaseGame.cpp:1321 TALKING/PARTY/GUILD/SHOUT/WHISPER: + char * p = strchr(buf, ':'); if (p) p += 2; else p = buf; ... _snprintf(line, ..., "%s", p); + UserInterface/PythonNetworkStreamPhaseGame.cpp:1363 INFO/NOTICE/BIG_NOTICE/default:line = buf(原样) + UserInterface/PythonNetworkStreamPhaseGame.cpp:1372 if (CHAT_TYPE_SHOUT != kChat.type) + CPythonTextTail::Instance().RegisterChatTail(kChat.dwVID, line);(带 VID 且实例可解析时) + UserInterface/PythonTextTailModule.cpp:76/91 textTailRegisterChatTail / textTailRegisterInfoTail + (python 绑定;InfoTail 仅 assets/root/game.py:930/936 技能 / 射击错误显式调) + UserInterface/Packet.h:1410 enum EChatType { TALKING=0, INFO, NOTICE, PARTY, GUILD, COMMAND, + SHOUT, WHISPER, BIG_NOTICE, MAX_NUM } + assets/root/game.py:155 textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE)(与增量 111 同处) + **无 seam**:40250 sole baseline 的 PythonTextTail.cpp / PythonNetworkStreamPhaseGame.cpp 本身含 + RegisterChatTail / RegisterInfoTail / RecvChatPacket 全部逻辑,字节级等同 m2dev 完整版。 +改动: + project/chat_tail.gd(新)—— 纯静态 `extends RefCounted`: + CHAT_COLOR = Color(1,1,1) / INFO_COLOR = Color(1, .785, .785);LIVING_TIME_DEFAULT = 5000 / + LIVING_TIME_MIN = 1000 / HEIGHT_ADD = 10.0;CT_TALKING..CT_BIG_NOTICE = 0..8(EChatType); + _living_time 静态态 + set_living_time(ms)(不断言,对齐参考端断言在 getter)/ living_time() + (assert(_living_time > LIVING_TIME_MIN)); + strip_name_prefix(raw) = raw.find(":") 命中则 substr(i+2) 否则整串(strchr ? p+2 : buf); + line_for_tail(type, raw) —— TALKING/PARTY/GUILD/SHOUT/WHISPER 走 strip,其余原样; + wants_chat_tail(type) = type != CT_SHOUT && type != CT_COMMAND; + register_chat(tails, vid, text, now) / register_info(...) —— 共用 _register(),写 + tails[vid] = {text, color, name_flag, living_time = now + living_time()},返回该条; + is_chat_text_tail(tails, vid) = tails.has(vid); + expire(tails, now) —— 收集 living_time < now 的 vid,从 tails 删除并返回列表(UpdateAllTextTail)。 + project/net_world.gd —— const ChatTail;var _chat_tails(vid -> 条目,= m_ChatTailMap); + _bubble(vid, text) 改走 ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec()) + + _apply_chat_tail(n, tail)(本地 vid 仍 main_bubble.emit); + 新增 register_info_tail(vid, text) 公开方法(= RegisterInfoTail,走 register_info,供技能 / + 射击错误尾标调用); + _apply_chat_tail(n, tail) —— 复用 / 新建名为 "bubble" 的 Label3D(y=2.35, billboard, + no_depth_test, pixel_size 0.0055, outline_size 6),text = tail.text、modulate = tail.color; + _expire_chat_tails() —— ChatTail.expire 后把到点 vid 的 "bubble" 节点 queue_free; + 接入 _process 的 accum ≥ 0.5s 块(与名字色 / 称号刷新同拍,到点最坏 0.5s 延迟); + _on_despawn 加 _chat_tails.erase(vid); + chat 信号 lambda:`t in [0,3,4,6]` → `int(v) != 0 and ChatTail.wants_chat_tail(int(t))`, + 气泡文本改为 ChatTail.line_for_tail(int(t), s)(去 "名字: " 前缀)。 + 行为变化:SHOUT(原 t==6 会弹气泡)不再弹头顶气泡(对齐 :1372 显式排除);WHISPER / INFO / + NOTICE 带 VID 时现在会弹(此前被 [0,3,4,6] 过滤掉)。 + project/game_scene.gd —— _player_bubble 的 tween_interval(3.0) → tween_interval(4.0) + (+ 1.0s 淡出 = 5s,对齐 gs_TextTail_LivingTime;参考端到点硬删无淡出,本地气泡保留淡出 = 近似)。 + project/chat_tail_test.gd(新,第 23 个 canonical)—— SceneTree 回归,约 45 断言: + 常量(Chat/Info 色、5000/1000/10、EChatType 0..8)、living_time / set_living_time 往返 + 还原、 + strip_name_prefix("Alice: x"→"x" / 无冒号原样 / 只吃首个冒号 / "Bob: "→"")、 + line_for_tail(TALKING/PARTY/GUILD/WHISPER/SHOUT strip、INFO/NOTICE/BIG_NOTICE 原样)、 + wants_chat_tail(除 SHOUT/COMMAND 全 true)、register_chat(新建 + 同 vid 原地替换 + bNameFlag + TRUE + LivingTime = now+5000)、register_info(淡红 + bNameFlag FALSE + 覆盖同 vid chat 条)、 + is_chat_text_tail、expire(到点返回 + 删除、未到点保留、空表返回 [])。 +测试: + /opt/homebrew/bin/godot --headless --path project --script chat_tail_test.gd —— PASS。 + 回归:project/*_test.gd 全 63 个全 PASS(含 chat_tail_test / text_tail_test / name_color_test / + entity_rules_test / gamescene_test / netbridge_test / netplay_test / net_world_vis_test / + quest_test / input_key_test / p8_test / p9_test / p10_test / remote_player_test / …)。 +验收: + 已验证(离线):Chat/Info 色、gs_TextTail_LivingTime 默认 5000 + assert 下界、 + strip_name_prefix 的 strchr ? p+2 : buf、line_for_tail 逐类型、wants_chat_tail 的 + SHOUT/COMMAND 排除、RegisterChatTail 新建 / 原地替换 / bNameFlag / LivingTime、 + RegisterInfoTail 淡红 + bNameFlag FALSE + 与 chat 共表覆盖、IsChatTextTail、expire 硬删。 + 未验证:真服 GC_CHAT(TALKING/PARTY/GUILD)/ GC_WHISPER 驱动下的气泡出现 / 替换 / 5s 到点消失、 + 真模型(GRN)头顶 "bubble" Label3D 与名字 / 称号 / 等级子标签的排布一致性(需真机目视)、 + TextTail_SetLivingTime 的 3s 长喊话(服务端可调 gs_TextTail_LivingTime)、 + RegisterInfoTail 的实际调用方(技能 / 射击错误路径尚未接 register_info_tail())。 +风险与依赖: + seam ⑨:参考端聊天尾标由 Render() 与名字尾标同屏排布(气泡在上、名字下移 17px,bNameFlag + 决定名字是否强制 ShowCharacterTextTail);本移植是 net_world 里名字 / 称号 / 等级 / "bubble" + 各自 billboard Label3D 并存 —— 名字不强制顶出、不随气泡下移,name_flag 只存不用。 + _chat_tails 到点删除靠 _process 的 accum ≥ 0.5s 块(非逐帧),气泡最多多留 ~0.5s。 + 本地玩家 vid 仍走 main_bubble → game_scene._player_bubble(独立 tween,非 _chat_tails 管理); + 远端 vid 无对应节点(未 spawn / 已 despawn)时 _bubble / register_info_tail 静默 return。 + _living_time 是进程级静态单例(与参考端 gs_TextTail_LivingTime 全局一致);未接 + TextTail_SetLivingTime 的运行期钩子(默认 5000)。 + SHOUT 不再弹头顶气泡是对齐参考的行为变化(喊话仍进聊天窗,见 §8.2)。 +下一批次: + §8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立于 text tail 的 + effect 系统)+ 屏幕空间血条整合、TextTail_SetLivingTime 运行期钩子、 + chat 尾标 bNameFlag 驱动名字强制显示 / 下移 17px(seam ⑨ 收口); + 或 §8.4 三 radio(名字颜色 / 伤害数字 / 总是显示名字)+ §8.6 LALT 接到 text_tail / chat_tail 显示开关; + 或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动; + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 +``` + +### C.5 批次记录 · 增量 113(W3→M2 / §8.4 · §8.6 头顶名字「是否显示」1:1) + +```text +批次:增量 113 —— W3→M2 §8.4 / §8.6:CPythonSystem::IsAlwaysShowName / + SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的 + 3500 平面距裁剪 + ShowCharacterTextTail 资格判定 + UpdateAllTextTail 的 bNameFlag 强显 + (纯 GDScript,无 C++ 改动) +状态:✅ 完成(name_show.gd 纯静态谓词 + net_world 头顶名字 / 子标签接线:__IsShowName + 两路(总显示名字选项 + LALT)、target VID / chat bNameFlag 强显、3500 距离裁剪; + 🔎 真机目视:3500 边界名字进出、悬停 chr.Pick() 强显(未接,seam ⑩)、屏幕空间 + ArrangeTextTail 防重叠(未做,seam ⑩)) +参考(sole baseline = /Users/shenlei/Work/mt/40250/ClientVS22/source/,UserInterface/*.cpp + ISO-8859 编码,grep 需 -a): + UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true + UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME + UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName + UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1) + UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 + UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘) + UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName + root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True) + + RefreshAlwaysShowName();:224 OffButton -> SetAlwaysShowNameFlag(False) + root/game.py:323 onPressKeyDict[app.DIK_LALT] = lambda: self.ShowName() + root/game.py:406 onClickKeyDict[app.DIK_LALT] = lambda: self.HideName() + root/game.py:1143 ShowName() -> self.ShowNameFlag = True; playerGauge.EnableShowAlways(); SetQuickPage(+1) + root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False + root/game.py:1533 OnRender:idx = chr.Pick(); if -1 != idx: textTail.ShowCharacterTextTail(idx) + root/game.py:1535 OnRender:if 0 != targetBoard.GetTargetVID(): textTail.ShowCharacterTextTail(vid) + root/game.py:1550 OnRender:if __IsShowName(): textTail.ShowAllTextTail(); PickingItemIndex = textTail.Pick(x, y) + UserInterface/PythonTextTail.cpp:416 UpdateDistance:fDistanceFromPlayer = + D3DXVec2Length(ownerX - centerX, -ownerY - centerY) + UserInterface/PythonTextTail.cpp:422 ShowAllTextTail:for m_CharacterTextTailMap: + if (fDistanceFromPlayer < 3500.0f) ShowCharacterTextTail(vid)(item 尾标同阈) + UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在 map / 已在 m_CharacterTextTailList / + !pOwner->isShow() / !GetInstancePtr / IsGuildWall() -> return;CanPickInstance() 才 push_back + UserInterface/PythonTextTail.cpp:408 HideAllTextTail:m_CharacterTextTailList.clear();(每帧末清列,下帧重建) + UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:for m_ChatTailMap: if (bNameFlag) + ShowCharacterTextTail(vid)(不经 3500 裁剪) + seam ⑩:参考「名字显示」是每帧重建 m_CharacterTextTailList(Pick / target / ShowAll)再 + Render / ArrangeTextTail(屏幕空间防重叠)/ HideAll 的流程;本移植是 net_world 每实体一块 + billboard Label3D,靠 name_show 谓词逐帧 set .visible。① 鼠标悬停 chr.Pick() 强显未接 + (net_world 无悬停拾取)② ArrangeTextTail 防重叠未做 ③ CanPickInstance() 只按「死透」 + 近似(隐身 / 特定 affect 亦不可拾取)④ UpdateDistance 的 -ownerY 是渲染帧 / 像素帧混用 + 产物,本移植 pos_cm 单帧等价同帧平面距。 +改动: + project/name_show.gd(新)—— 纯静态 `extends RefCounted`: + ALWAYS_SHOW_NAME_DEFAULT = true(DEFAULT_VALUE_ALWAYS_SHOW_NAME)/ SHOW_ALL_DISTANCE = 3500.0 / + CFG_KEY = "ALWAYS_VIEW_NAME"; + always_show_from_flag(flag) = flag == 1(SetAlwaysShowNameFlag); + always_show_from_cfg(value) = int(value) == 1(配置行 atoi == 1); + is_show_name(always, alt_held) = always or alt_held(__IsShowName); + within_show_all(dist) = dist < 3500.0(ShowAllTextTail); + planar_distance_cm(a, b) = a.distance_to(b)(UpdateDistance,同帧); + eligible_character(is_shown, is_guild_wall, can_pick) = is_shown && !is_guild_wall && can_pick + (ShowCharacterTextTail 早退取反); + name_visible_for(forced, show_name, dist, is_shown, is_guild_wall, can_pick) —— + 先过 eligible_character;forced(Pick / target / bNameFlag)绕过 3500 直接 true; + 否则需 show_name && within_show_all(dist)。 + project/net_world.gd —— const NameShow; + var _names_visible(true) 拆成 _always_show_name(= ALWAYS_SHOW_NAME_DEFAULT) + _alt_held(false) + + _target_vid(0); + set_names_visible(v)(LALT 入口,increment 53)改为写 _alt_held + _refresh_all_name_visibility(); + names_visible() 返回 _alt_held; + 新增 set_always_show_name(on)(uigameoption radio)/ set_target_vid(vid)(targetBoard); + _main_planar_cm() —— 主角 pos_cm 的 (x,y),未知返回 (INF,INF); + _name_shown(root) —— 取 root meta vid → client.get_entity → dead / race,组装 + forced(vid == _target_vid 或 _chat_tails[vid].name_flag)+ show_name(is_show_name( + _always_show_name, _alt_held))+ dist(主角已知则 planar_distance_cm,否则 0 fail-open) + → NameShow.name_visible_for; + _set_name_visibility(root) 改为 shown = _name_shown(root),主名字 Label3D 与 + GuildTag/TitleTag/LevelTag 子标签统一 shown(子标签仍 && text != ""); + _apply_text_tail_to 的三处子标签 visible 由 _names_visible 改 _name_shown(n); + _attach_nameplate 的 tag.visible = _names_visible 改 = false(_on_spawn 随后 _set_name_visibility); + _apply_chat_tail 末 + _set_name_visibility(n)(bNameFlag 强显即时生效); + _expire_chat_tails 删 "bubble" 后 + _set_name_visibility(n)(强显撤销); + _process 的 accum ≥ 0.5s 块 + _refresh_all_name_visibility()(距离随移动变化的 ~2Hz 兜底)。 + project/ui/game_option_ui.gd —— 新增 signal display_option_changed(key, value);radio 选中 + 回调在 _save() 后 emit(spec[0], spec[1]);新增 display_value(key) 供渲染层开局回填。 + project/game_scene.gd —— net_play.setup 后接 net_play.target_changed → net_world.set_target_vid; + game_option_ui.setup 后接 display_option_changed → _on_display_option(key=="always_show_name" + → net_world.set_always_show_name(value == 1)),并用 display_value("always_show_name") 开局回填。 + project/name_show_test.gd(新,第 24 个 canonical)—— SceneTree 回归,约 35 断言: + 常量(默认 true / 3500 / "ALWAYS_VIEW_NAME")、always_show_from_flag/from_cfg(严格 == 1)、 + is_show_name 真值表、within_show_all(3499.9 T / 3500.0 F 严格 <)、planar_distance_cm + (3-4-5 / 同点 0 / x 轴)、eligible_character(isShow / IsGuildWall / CanPickInstance 各早退)、 + name_visible_for(公会墙即便 forced 也 F、forced + 合格 + 超距 T、forced 但不合格 F、 + 非 forced 需 __IsShowName + <3500 + 合格)。 +测试: + /opt/homebrew/bin/godot --headless --path project --script name_show_test.gd —— PASS。 + 回归:project/*_test.gd 全 64 个全 PASS(含 name_show_test / chat_tail_test / text_tail_test / + name_color_test / entity_rules_test / gamescene_test / netplay_test / net_world_vis_test / + input_key_test / quest_test / p8_test / p9_test / p10_test / remote_player_test / …)。 +验收: + 已验证(离线):DEFAULT_VALUE_ALWAYS_SHOW_NAME = true、SetAlwaysShowNameFlag 的 == 1 语义、 + "ALWAYS_VIEW_NAME" 配置行 atoi == 1、__IsShowName 的 or、ShowAllTextTail 的 + fDistanceFromPlayer < 3500 严格小于、ShowCharacterTextTail 的 isShow / IsGuildWall / + CanPickInstance 早退、forced 绕过 3500 但仍过资格、UpdateDistance 平面距、 + game_option_ui radio → 信号 → net_world.set_always_show_name、target_changed → + set_target_vid、全 64 回归。 + 未验证:真机 —— 3500cm 边界处名字随主角 / 目标移动的进出、总显示名字选项关闭后仅 + 目标 / bNameFlag 尾标 / LALT 期间显示、真模型(GRN)头顶名字 / 子标签 billboard 与 + 参考屏幕空间排布的目视一致性、SetQuickPage(+1) / playerGauge.EnableShowAlways() + (ShowName 的另两步,属快捷栏 / 血条 UI,未接)。 +风险与依赖: + seam ⑩(见上「参考」末):悬停 chr.Pick() 强显未接、ArrangeTextTail 防重叠未做、 + CanPickInstance() 近似为「非死透」、UpdateDistance 的 -ownerY 帧差。 + 行为变化:此前 _names_visible 默认 true = 所有已 spawn 实体名字恒显;现默认 + _always_show_name = true(对齐参考默认)但叠加 3500 平面距裁剪 —— 20000cm 视野内、 + 3500cm 外的实体现在有节点但无名字(对齐 ShowAllTextTail)。主角位置未知时 fail-open + (同 _is_visible_entity),离线 / FakeClient 测试名字仍恒显。 + 距离裁剪走 _process 的 accum ≥ 0.5s 块(非逐帧),3500 边界名字进出最坏 ~0.5s 延迟; + LALT / 选项 / 选中 / 尾标切换各自即时 _refresh / _set_name_visibility。 + _always_show_name 由 game_option_ui 持久化到 user://system_option.cfg [gameopt] + always_show_name(默认 1);参考端 PythonSystem 仅在 != 默认值时才写 ALWAYS_VIEW_NAME 行, + 本移植 ConfigFile 恒写该键 —— 语义等价,落盘格式差异(非 1:1 关键路径)。 + net_play.target_changed 已有(增量早期),net_world 此前不知道目标 VID;新增的 + set_target_vid 仅驱动名字强显,不改选中 / 攻击逻辑。 +下一批次: + §8.8 剩余:伤害数字(CInstanceBase::AddDamageEffect / GC_DAMAGE_INFO 整合,独立 effect 系统)+ + 屏幕空间血条整合;chat 尾标 bNameFlag 下移名字 17px(seam ⑨ 收口); + 或 seam ⑩ 收口:悬停 chr.Pick() 强显(net_world 射线拾取已有 _ray_pick_t,接名字强显)+ + ArrangeTextTail 屏幕空间防重叠; + 或 §8.4 名字色 radio(name_color_normal / name_color_empire)接到 name_color.gd 的 + 帝国名色模式 + 伤害数字 radio(show_damage)接到 text_tail 伤害数字显隐; + 或目标框 / 可攻击光标按 ENameColor + is_attackable_instance 驱动; + 或 §4.2 / §4.3 剩余拖放鼠标物品态 / tooltip 分支。 ``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index ebd65b99..9017f45d 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -51,16 +51,180 @@ 6. **合并顺序不等于开发顺序。** 只要依赖接口已经冻结,W1–W5 可以同时开发;只有触及共享状态或 集成门禁时才回到 W0 排队。 -### 当前并行进度(2026-09-02) +### 当前并行进度(2026-09-03) | 工作流 | 状态 | 当前已知进展 | 下一步 | |---|---|---|---| -| W0 公共基础 / 集成 | 🟡 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版已落地;增量 49 接口冻结批次(`Entity` 补字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed` 信号、`bgm_director.gd`、§8.6 键位收敛)已提交 | Phase 1 4 工作流并行完成后执行 G1 集成 | +| W0 公共基础 / 集成 | 🟡 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版已落地;增量 49 接口冻结 + 增量 51 AppFlow 单一 `AppLifecycle` + 增量 52 `net_bounds` 已接入,GameScene 不再重复创建,当前 C++ CTest 15/15 与生命周期 / 场景回归通过 | Phase 1 4 工作流并行完成后执行 G1 集成;真机生命周期 / 业务边界验收 | | W1 协议 / 账号会话 | 🟡 | 增量 50:§1.5 版本包按 locale 分支(EUROPE 发 `CG_CLIENT_VERSION2` 0xf1 + `"1215955205"`,其余发 0xfd + `MT_BUILD_TIMESTAMP`)、可执行名 setter;§1.10 `text_codec.h` UTF-8 安全定长文本裁剪 + `net_text_codec_test`,会话侧所有定长 name/sign/comment 填充改走 `to_wire()`;§2.2 `GC_CHARACTER_ADD` 按 invisible-race 丢弃 / PC·NPC 暂存 `m_pending_actor` / 其余即时 `mut_spawn`(名字取自 npclist.txt),`GC_CHAR_ADDITIONAL_INFO` 无暂存则记录并丢弃、命中则合并 9 字段 `mut_spawn_full` 后清表 | 真实 locale codepage 转码、真实双连接、enter delay;GDScript 名称长度校验统一(G1 已收) | -| W2 实体 / 世界 / 战斗 | 🟡 | §2.1 两层模型(`EntityStore` 纯数据层 + `net_world.gd` 每帧 `_update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点销毁重建 + 上下坐骑保坐标)、§2.4(点数快照截断 / 尾部清零 + 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序节点刷新)已落地并回归(增量 50) | 远服 40250 端到端可见性 / VID 复用 / affect 表现验收;`state_flags` 表现层枚举与眩晕 / 隐身等表现 | -| W3 玩法 UI / 输入 | 🟡 | 多数窗口、背包 / 任务 / 社交、键位和输入已有首版;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛;**§8.3 帮助窗(`help_ui.gd` = `helpwindow.py` uiscript + `uiScriptLocale.HELP_*` 逐字,`H` 键 / ESC 菜单 `help_button` 入口,`help_ui_test` 绿)已落地** | §8.6 剩余逐行键位与表情包;帮助窗视觉 / `.sub` 贴图与真服未验证 | -| W4 渲染 / 资产 / 音频 | 🟡 | 增量 50:§9.1 地图 BGM 选曲(服务端名优先 / 回落 `musicInfo.fieldMusic` / 默认音量 0.02 / `world_reset` 重跑)在 `bgm_director.gd` 落地,新增 `musicinfo.gd`;§9.2 `Audio.save_volume/restore_volume` + `app_lifecycle.gd` 后台静音存恢复(不清 `_bgm_name`);§9.3 设计已确认、落地推迟到 Phase 2;`bgm_test` + 3 项回归全绿 | §9.3 由 W0 在 Phase 2 落地(`AppFlow` 单一属主);BGM 真服验收待 40250 服务端 | -| W5 打包 / 发布 / 验收 | ⬜ | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | +| W2 实体 / 世界 / 战斗 | 🟡 | §2.1 两层模型(`EntityStore` 纯数据层 + `net_world.gd` 每帧 `_update_visibility()` 按 `CHAR_STAGE_VIEW_BOUND` / `AFFECT_SHOW_ALWAYS` / `IsWall` 建 / 淡出节点)、§2.3(`mut_spawn`→`mut_spawn_full` 整行替换 + 节点销毁重建 + 上下坐骑保坐标)、§2.4(点数快照截断 / 尾部清零 + 单索引边界)、§2.5(`mut_affect_flags` 两段拼装 + 参考调用序节点刷新)已落地并回归(增量 50);增量 52 补齐 M2Client 公共动作 / 物品 / 交互入口的 wire 参数边界;增量 73(§3.7)删除 `net_play.gd` 的本地硬直计时器 `_hitstun_until`,「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),`Entity` 增加 `knock_down`(受击 `.msa` 播放生命周期驱动、动作播完清零),`dead` / `stunned` 保持服务端包驱动;增量 74(§3.3)`formats/msa` 新增 `ComboInputData` / `AttackingData` 解析、`metin2_anim.get_motion_data()`,`net_play.gd` 删除 `ATTACK_RANGE` 改 `CLICK_DIST_DEFAULT/NPC/RESOURCE_CM`(对齐 `InstanceBaseBattle.cpp:294`),普攻节奏改为 `.msa` `DirectInputTime`(缺组退化 `duration*0.9`)除以 `bAttackSpeed/100`;增量 75(§3.4)`net_play.gd` 新增 `enum ReservedMode` + `_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(对齐 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`),出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`;增量 76(§3.1)`player_controller.gd` 去掉直线改坐标,改 `_src_pos` / `_dst_pos` / `_is_going` / `_dst_rot`,`walk_to()` → `_goto()` 对齐 `NEW_Goto`(`InstanceBaseMovement.cpp:251`:太近不动、三门 `_can_translate()` 未过 → `__ReserveClickGround` 0.1 s 重试、`moving_skill` 只转向),新增 `player_move_test.gd`;增量 77(§3.2 命令队列 part A)`EntityStore` 把远端 Actor 位置改为 `std::deque` 命令队列驱动(对齐 `InstanceBase.cpp:1278 StateProcess`),`mut_move()` 入队(≈ `PushTCPState`)+ `drain_state_queue()` 释放到期项、删旧 `start_move()` 直改坐标、两套后端统一走 `mut_move(...,arg,time)`,新增 `process_states()` / `apply_state_cmd()` 1:1 复刻 switch(`FUNC_WAIT` 1.0、`FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`),释放门按 `set_server_frame_ms()`(为 0 = 帧钟未对齐即时释放)+ `__CanProcessNetworkStatePacket`(`dead` / `knock_down` 挂队),新增 `net_state_queue_test`(5 组)、C++ 20/20;增量 78(§3.2 part B)`net_play.gd` 上行按 `PythonPlayerEventHandler.cpp` 拆成 6 回调(统一出口 `_send_state`):OnMove 立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗、OnMoving 300 ms 节流、OnWaiting(新 `_tick_on_waiting`)100 ms + 位移 ≥ 1 cm、OnStop `FUNC_WAIT`、OnAttack 额外 `FUNC_COMBO`、新公共入口 `on_use_skill()` → `FUNC_SKILL|motion`;删旧 `SEND_INTERVAL/SEND_DIST` 单一节流;`net_world.gd` 去掉自有 `follow_lerp`,`_process` 直接 `n.position = e.pos`(插值全交 C++ `tick()`);`netplay_test` 加第 11 段、`netbridge_test` near-move 断言改写,7 个 GDScript 回归 + C++ 20/20 全绿;增量 79(§3.2 part C 收官)`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`,由 `HDR_HANDSHAKE` `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出)经 `ClassicSession::process()` 每帧 `set_server_frame_ms()` 喂给命令队列(m2dev 恒 0 = 即时释放,无回归),`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(异帧钟 `dwTime` 立即放行,有意偏离参考端),`net_classic_stream_test` + `net_state_queue_test` 补覆盖,C++ 20/20 + 7 GDScript 全绿;增量 80(§3.5 part A)查明 PC 连击段表**不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`),实为 `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用建在 `CRaceData` 上,新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`)按 `def __LoadGameEx` 分四职业块解析为 `map>`,语义对齐 `CRaceData::Reserve/RegisterComboAttack`(insert 不覆盖 / 越界跳过),新增 `formats_combo_table_test`(合成 + 真实 `playersettingmodule.py` 双覆盖),C++ 21/21 + 7 GDScript 全绿;增量 81(§3.5 part B)`net_play.gd` 用此表 + `.msa` `ComboInputData` 三时间实现连击状态机(`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`),替掉 `(_combo + 1) % 3`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` × `combo_motion_mode<<16\|type` 取,缺段表退化单段普攻,`game_scene` 装配时 `set_asset_root()`,`combat_fx_test` 加 `FakeAnim` 全路径断言,C++ 21/21 + 7 GDScript 全绿;增量 82(§3.5 part C,修改 1)导出 `.msa` `HitDataContainer` 多命中窗:`textscript.cpp` 补 `List Name { ... }` 解析(此前 `List HitPosition` 破坏后续 `Group`),`formats/msa.{h,cpp}` `AttackingData` → `std::vector`(`{start_time,end_time,bone_name,weapon_length,samples}`,吃老式内联 + 新式 `HitDataCount N` / `Group HitData0i`),新增 `motion_type` / `hit_limit_count`,`get_motion_data()` 增 `hit_windows` / `motion_type` / `hit_limit_count`,`formats_msa_test` 补合成 + `M2_ASSETS` 真机断言(真实双命中窗 `assassin/dualhand_sword/combo_01.msa`),C++ 21/21 + 7 GDScript 全绿;增量 83(§3.5 修改 4,收官)`net_play.gd` 新增 `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list`:逐帧按动作时间窗与 `hit_window [start,end]` 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定(`m_HitDataMap` 查重 + 每窗命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()`(≤16);`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`;几何判定 = `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似(真实动态圆柱扫掠随 §3.1 留桩),`combat_fx_test` 加 `FakeNetWorld` + 6 段断言,C++ 21/21 + 7 GDScript 全绿;增量 84(§3.5 修改 6,§3.5 修改 1-6 全落地)`net_play.gd` 补全 `MOTION_MODE_*`(`EMode` 17 项)+ `ITEM_TYPE_*` / `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`:Poly→GENERAL / 婚纱→WEDDING_DRESS / 镐·钓竿→骑乘 HORSE 否则 GENERAL·FISHING / 骑乘 switch → HORSE_* / else switch → ONEHAND·TWOHAND·DUALHAND_SWORD·BOW·FAN·BELL·默认 GENERAL),`_equipped_weapon_vnum()`(`parts[WEAPON]` 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`)+ `_refresh_motion_mode()`(读 `mount_vnum`/`polymorph`/`wedding_dress` + `proto.item(vnum).{type,sub_type}` → `set_combo_motion_mode()`,换模式内部 `_clear_combo()`),由 `_on_entity_info()` 末尾 / `inventory_changed`(EQUIPMENT) / `mount_changed`(主角) 三处触发,`combat_fx_test` 加 `FakeProto` + 15 段 `motion_mode_for` + 2 段端到端断言,C++ 21/21 + 7 GDScript(含 `equip_model_test`)全绿;增量 85(§3.5 修改 7,§3.5 修改 1-7 全落地)`net_play.gd` `_clickable_distance()` 逐分支对齐 `NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`:默认 150 → `_is_bow_mode()`(`MODE_BOW`/`MODE_HORSE_BOW`)改用 `_bow_range_cm()` → NPC 500 → 采集物 100,后写 `if` 覆盖前面),`_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(1:1 复刻 `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`),`_bow_distance_bonus` 由 `_on_points()` 读 `points["bow_distance"]`,`wire.h` 补 `POINT_BOW_DISTANCE=34` + `get_points()` 透出,`combat_fx_test` §3.5 段加 8 段断言,C++ 21/21 + 7 GDScript 全绿;增量 86(§3.6,修改 1-3 全落地)`game_scene.gd` 单槽 `_pending_shoot_skill` + `create_timer(1.5)` 换成 `_pending_shots: Array[int]` FIFO(上限 8):弓挥击 / 弓技能起手 `_queue_shot(uSkill)` 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧 `pop_front()` → `client.shoot()`(AutoClear + 连射并发,对齐 `ActorInstanceMotionEvent.cpp:290`);`OnSetFlyTarget` 移到 `net_play._emit_swing` 的 `_send_fly_target()`(目标节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting()`,对齐 `ActorInstanceMotion.cpp:372` + `ActorInstanceFly.cpp:4`),新增信号 `bow_shot_fired`,`combat_fx_test` +6 段 / `eterngrn_polish_test` FLY 段改 FIFO 连射断言,C++ 21/21 + 9 GDScript 全绿;增量 87(§3.8 修改 2)POC 无 C++ skill proto,故在 `ui/skill_table.gd` 按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`(`_fold_bits()` 静态方法,记号名 1:1 抄 `PythonSkill.h:92-128`;`DOUBLE_SWORD` 复用 `DAGGER` 位),`CPythonSkill` 谓词逐条落地(`is_can_use_skill` / `is_standing` / `is_need_target` / `is_need_corpse` / `is_auto_search_target` / `can_change_direction` / `is_horse_skill` / `is_moving_skill` / `is_melee` / `is_charge_skill` / `is_use_hp` / `is_time_increase` / `is_need_empty_bottle` / `is_need_poison_bottle` / `can_use_for_me` / `is_only_for_alliance` / `can_use_if_not_enough` / `is_only_for_guild_war` / `is_fan_range` / `is_circle_range` / `has_weapon_limitation` / `can_use_weapon_type` / `is_need_bow` / `skill_type_of` / `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle` / `is_ranged` / `can_level_up` 改查位),`skill_test.gd` +34 断言,C++ 21/21 + 9 GDScript 全绿;增量 88(§3.8 修改 1)新建 `project/player_skill.gd`(`extends RefCounted` 纯校验器),逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵)四层,返回 `{ok, code, target_vid}`,命名常量 `SKILL_MAX_NUM=255` / `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / 空瓶 `27995` / 毒瓶 `27996` / 连击 `122` / 钓鱼 `123`,`SILENT_CODES[]` + `static is_silent_code()`;`net_play.skill_context()` 每次注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道)、gate 的 `target_vid` 覆盖点击目标;`__CheckShortMana` / `__CheckShortLife` 打桩恒 false(无 `.msk` NeedSP/NeedHP 解析);新增 `player_skill_test.gd`,C++ 21/21 + 12 GDScript 全绿;增量 89(§3.8 修改 3,三层全落地)`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()`(对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门),`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` → `need_sp` / `need_hp` / `_skill_point`,`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `skill_context()` 读 `get_points()` 供入、`-1` 不判;`net_bounds_test.cpp` +4、`player_skill_test.gd` +“修改 3”段,C++ 21/21 + 13 GDScript 全绿;增量 90(§3.10 技能升级门)`wire.h` +`POINT_SUB_SKILL=27` / `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`、`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`M2Client::skill_up()` 入参收紧为 `!bounds::skill_index()`(与 `use_skill` 同一谓词),`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`),`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`(技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐),按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造,`skill_test.gd` +`§3.10` 段,C++ 21/21 + 13 GDScript 全绿;增量 91(§3.4 其余三种预约模式)`net_play.gd` `_refresh_reserved()` 由「只 CLICK_ACTOR」改四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`(`PythonPlayerInputMouse.cpp:287`):`_reserve_process_click_item()`(IID 查掉落物世界坐标,`< 20cm` `CLICK_ITEM_PICKUP_CM` → `FUNC_WAIT`+`pickup_item`+解冻+清,否则 `walk_to`;物品消失→清)、`_reserve_process_click_position()`(`_can_translate()` 未锁 && `_reserved_delay<=0` → `walk_to(_ppos_reserved)`+清)、`_reserve_process_use_skill()`(`dist_cm` 含 `_is_huge_race` 的 `-200cm` `HUGE_RACE_DIST_FIX_CM` 修正,`< _skill_range_reserved` → `set_target`+`use_skill_hook(slot)` 成功才清,否则 `walk_to`;目标丢失→清);预约入口 `reserve_click_item` / `reserve_click_ground`(`_reserved_delay=0.1`)/ `reserve_use_skill`(= `__ReserveUseSkill` `PythonPlayerInput.cpp:854`,`range>100→-10` `RESERVE_SKILL_RANGE_TRIM_CM`)/ `pick_ground_item`(= `__OnPressItem`);`_on_pick()` 补 `__OnPressActor` 特例(`_reserved_mode==USE_SKILL && _dash_active` 只改 `_vid_reserved`),战斗目标改走 `_change_target_to_picked_instance(vid)`(= `__ChangeTargetToPickedInstance` `PythonPlayerInput.cpp:186` → `SetTarget` :130,`IsTargetableInstance` 限 PC/怪否则 `set_target(0)`+`clear_target`);`_is_huge_race(race)` 因 `IS_HUGE_RACE` vnum 白名单未随源码发布 → `proto.mob(race)` `size>=MOBSIZE_HUGE(3)` 或 `rank>=BOSS(4)` 近似(seam);扇形/圆形补 fly-targeting(`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块)`net_play.send_fly_targeting(target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING`+`CG_USE_SKILL` 仍由 `M2Client::use_skill` 发,这里只补附加目标——`_instances_in_fan_range()`(= `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 `(MAX-MIN)-RPM·min(dist,1000)+MIN`,`FAN_HALF_ROT_MIN=20`/`MAX=40`/`MIN_DISTANCE=1000`)/ `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 `client.add_fly_targeting(vid, sx, sy)` 补齐,名额不足时 `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° `FAN_RANDOM_JITTER_DEG` 抖动、沿该方向 `FAN_RANDOM_REACH_CM=8000cm` 取点)→ `add_fly_targeting(0, sx, sy)`;`ui/skill_table.gd` `_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析,新增 `target_count(id, level)`(= `GetTargetCount` `PythonSkill.cpp:1361`)/ `target_range(id)`(= `__GetSkillTargetRange` 基础项 `PythonPlayerSkill.cpp:391`)/ `fly_shape(id)`(`FAN_RANGE`→1 / `CIRCLE_RANGE`→2 / 否则 0);`ui/quickbar.gd` `activate()` 里 `use_skill` 成功后 `target_vid!=0` && 范围技 → 调 `send_fly_targeting(...)`,新增 `_skill_level(sid)` / `activate_reserved(global_slot)`;`game_scene.gd` 接 `net_play.use_skill_hook = func(slot): return quickbar.activate_reserved(slot)`;`netplay_test` +「5c」段、`skill_test` 补 skill 47/91/1,C++ 21/21(无 C++ 改动)+ 13 GDScript 全绿;增量 92(§3.6 飞行物实体)新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}` + `FlyingData.cpp __Initialize()`:`Create`→`__SetDataPointer`→`__SetTargetDirection`,`Update()` 逐帧 `vel+=accel*dt`→`remain_range-=|movement|`→线段-点 `< bomb_range²` 命中→`OnShootDamage`/pierce/`__Explode`/`__Bomb`,超程→`__Explode(false)` 不 `__Bomb`,`m_bHitOnBackground` 触地;`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`),`net_world._on_fly` 把原 tween「发光球」占位换成 `_fly_mgr.spawn(...)` 真实弹道 + 新 `signal fly_exploded`,`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义——非弓 `NOT_BOW_MODE` / 无箭 `EMPTY_ARROW` / 无目标 `NO_TARGET` / 死目标 / 出弓程 `OUT_OF_RANGE`),`_emit_swing` 弓分支据此走 `OnNoArrow` / `OnNoTarget` 文案并跳过 `CG_FLY_TARGETING` / `OnShoot`;新增 `project/fly_test.gd`(第 14 个 GDScript 回归),C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`、重力参考端作用于 `.z`=上本 POC 映射 `-Y`、怪物球体碰撞 / `CullingMgr` / 建筑体素未移植(客户端只做命中时机)、`.fly` 加载器缺失 → 合成箭参数;增量 93(§3.1 收尾)`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 `ACTOR_COLLIDE_MAX_DIST_M` ≈ `LengthSq > 800²` + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` 相加 + 趋近判定 `d_next <= d_now` 才挡)+ `_process` 平移逐轴门控(单 Actor 逐轴放行=滑移 ≈ `AdjustDynamicCollisionMovement`,两轴都挡=停在上一帧位 ≈ `uCollisionCount==2` → `BlockMovement()`/`__InitializeMovement`),候选 = `pickables`、`dead` meta 跳过(≈ `rVictim.IsDead()`),`skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`)由 `net_play.setup()` / `_on_observer_mode` 按 `_observer_mode` 置位;坐骑转向速度 `player_controller.rotation_speed_deg` + `_turn_toward(target_yaw, dt)` 逐帧限幅收敛替换原两处 `rotation.y = atan2()` 瞬转,`net_play._refresh_motion_mode()` `mounted` 切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` → `SetRotationSpeed` `InstanceBase.cpp:954`)/ 下马 `ROT_SPEED_DEFAULT_DEG=1200`(`MODE_HORSE*` 动作模式已由增量 84 覆盖),`player_move_test.gd` 5→8 组,C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿;seam:真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`),`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` 用逐轴近似代替,`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的本地预测玩家,`MOVABLE_GROUND_DISTANCE` 参考默认值仍是估计(1.0 m);增量 94(§5.4,W3→M2 起点)新建 `entity_rules.gd`,`is_attackable_instance(self_e, victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`InstanceBase.cpp:2147`)+ `alignment_grade`/`alignment_type`(:502/:524)/ `is_conflict_alignment_instance`(:2116)/ `_is_same_empire`(:470)/ `_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)/ `_find_duel_key`(:495)/ `_is_same_party`(`PythonPlayer.cpp:1445`)/ `_is_wooden_door`(:2390),`net_play._is_attackable` 从 `_entity_kind in [0,2]` 桩改为拼 `self_e`/`victim_e`/`_attack_ctx()`(`get_duel`/`get_pvp_relations`/`get_guild_wars`/`get_party`)委托 `EntityRules`,`entity_rules_test.gd`(第 15 个 canonical,25 断言)+ 3 个既有测试的怪物夹具补 `ch_type`;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter,名字颜色 §8.8、`IsInSafe` 的 `ATTRIBUTE_BANPK` 未接;留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer UI、§4.1 costume/belt | 远服 40250 端到端可见性 / VID 复用 / affect 表现验收;远端实体 `knock_down` 接线、真实受击 / 击退 / `AFFECT_STUN` 清除时序;§3.5 命中帧绑定 / `ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x);§5.4 的 `GC_PVP` / 公会战对局 key setter、名字颜色、保护区地形属性;技能 / 目标语义与服务端权限仍需补齐 | +| W3 玩法 UI / 输入 | 🟡 | 增量 53–62 已覆盖快捷栏、输入入口、物品窗口 / 拖放、tooltip 基础字段、任务条带、地图信号和帮助窗;增量 95 完成 Quest EventManager 常用标签分派;增量 96–98 完成 wear 槽位 / 装备门 / 部位遮挡;增量 99 完成远端 PC `RemotePlayerView` + `awPart[4]` 四字段换装;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、滚动起始索引、默认 icon、倒计时和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 方向箭头(世界箭头 / 屏幕边缘指示,对照基线 m2dev `PythonMiniMap.cpp:441-462`);增量 109 完成 quest 信件按钮条 `__ArrangeQuestButton` 多列定位 + 最新在前 + 组队窗 `xPos` 移位;增量 110 完成头顶名字颜色 `CInstanceBase::GetNameColorIndex` / `SetEmpireNameMode` 1:1(`name_color.gd` 纯静态 + `net_world.gd` 事件驱动 / ~2Hz 兜底重染,帝国名色默认关);增量 111 完成头顶公会名 / 称号色 `GetTitleColor` / 等级子标签 1:1(`text_tail.gd` 纯静态 `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` + `net_world.gd` `GuildTag` / `TitleTag` / `LevelTag` 三 `Label3D` 子节点);增量 112 完成头顶聊天 / 信息尾标 `RegisterChatTail` / `RegisterInfoTail` 1:1(`chat_tail.gd` 纯静态 `LivingTime` 5s 硬删 / `"名字: "` 前缀剥离 / SHOUT·COMMAND 排除 + `net_world.gd` 每 vid 一条 `"bubble"` `Label3D`、白 / 淡红按 chat/info、新增 `register_info_tail()`);增量 113 完成头顶名字「是否显示」1:1(`name_show.gd` 纯静态 `IsAlwaysShowName` / `__IsShowName`=`总是显示名字选项 or LALT` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格判定 + `net_world._name_shown()` 四路组装:uigameoption `always_show_name` radio(经 `game_option_ui` 新信号)/ LALT / `net_play.target_changed` 目标强显 / chat 尾标 `bNameFlag` 强显);相关离线回归通过 | CJK IME、窗口视觉 / `.sub` 资源、完整交易事务、复杂 mousemodule 规则、EventManager 逐帧 / `.msc` / 相机消费端、任务日志窗体自身多列 / 分页、tooltip 特殊分支 / 装备对比、`text_tail.gd` 伤害数字(`GC_DAMAGE_INFO` / `AddDamageEffect`)/ 屏幕空间血条整合 / 目标高亮框、悬停 `chr.Pick()` 强显(seam ⑩)、真服和真机仍未验收 | +| W4 渲染 / 资产 / 音频 | 🟡 | 增量 50:§9.1 地图 BGM 选曲、§9.2 后台音量存恢复已落地;增量 51:AppFlow 绑定当前场景音频;增量 53:EffectLib 已读取 `.mse` 首个 `TextureFiles` 的实际 `.dds`,并映射位置、发射形状、方向 / 速度;增量 55:`audio.gd` 已接入 MilesLib 的监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色 3D 声道、角色 0.3 秒与 5000cm 限频、3D stop 和限幅淡出;增量 63:按 `MilesLib/Type.cpp` 解析 `.mss` 的 `SoundDataCount/SoundData%02d`,转换为 60 FPS 声音实例并接到 PlayerView / MobView 动作帧;增量 64:按 `EffectMesh.cpp` 解析 `.mde` EffectData/MDEData002,展开几何帧、UV、`.ifl` 贴图和 MeshElement 材质;增量 65:解析 `.mse` Light 组并生成范围 / 生命周期曲线,粒子多贴图支持帧延迟与顺逆序,旋转类型区分 NONE / TIME_EVENT / CW / CCW;增量 66:按 `EffectElementBase::GetPosition` 接入粒子 / 网格 / Light 的 MovingType 直接段与二次 Bezier 段,并映射球面边缘发射;增量 67:按 `Area.cpp` / `MapType.cpp` 接入 `AreaAmbienceData` + `.pra`,独立于对象渲染加载环境声源,Audio 实现 LOOP / ONCE / STEP、距离音量和卸载停止;增量 68:按 `MapUtil.cpp` 补齐 `.msenv` 参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、六面天空文件名和 `Reserved`,通过 `AssetResolver` 接入真实云层贴图到 `ProceduralSkyMaterial.sky_cover`,并修复 macOS 大小写敏感环境路径;增量 69:背景 / 角色方向光按参考端分成 `Sun` layer 1 与 `CharacterLight` layer 2,静态场景和 `Metin2Model` 分层,换图时清理两盏光;增量 70:真实 CapeDragonHead 五面天空贴图按 ClientVS22 面顺序接入 Godot `sky` shader,空 bottom 面回退渐变;增量 71:树 proxy 使用参考 `fWindStrength=0.2 / fWindRandom=0` 缺省;增量 72:真实 DawnMistWood 验证 texture-mode 五面天空与有限云层平面、CloudScale / Height / TextureScale / Speed 和参考颜色合成;`bgm_test`、`audio_miles_test`、`ambience_test`、`environment_test`、`skybox_test`、`world_probe`、`player_motion_test`、`mob_view_test`、`fx_test` + 生命周期 / 场景回归全绿 | TexAni 的随机起始帧 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴 / Actor motion、Direct3D 材质 stage、SpeedTree、云层渐变过渡、滤色实际合成、风参数完整语义、lens flare、昼夜天气联动、声音资源逐项和真机验收 | +| W5 打包 / 发布 / 验收 | 🟡 | 增量 53 已把 `assets` / `bgm` 复制进 `Contents/Resources`,支持 `MT_MAC_ARCHES=universal` 和 `lipo` 架构门禁,嵌套扩展先签名、App 后签名;默认凭据改为环境变量;脚本语法 / diff 检查通过 | 尚未执行完整 2.2G 资源导出;当前已检查的 dylib 仍为 arm64;正式 Developer ID / notarization、跨机启动和 Mac App 验收 | + +> **W4 最新进展(增量 100)**:EffectLib 已完成缺省 cycle、有限 LoopCount 发射停止、重播时钟复位和 +> 粒子寿命隔离;OUTER / INNER 当前仍是显式标记的宽方向 fallback,不计作精确径向速度完成。完整 +> 状态以本文变更记录和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5 为准。 + +> **W3→M2 最新进展(增量 99)**:远端 `ch_type==0` PC 已由 `game_scene.gd` 创建 +> `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA 模型;远端换装改为消费完整 +> `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world.gd` 的四字段调用序已覆盖。所有职业 / race、 +> 变身字段、远端真实服务器洪流和精确视觉仍待验收。详见 `CLIENT-GAP-FIX.md` §4.1 / C.5。 + +> **W3→M2 最新进展(增量 101)**:`quest_log.gd` 已按 ClientVS22 的 `QUEST_MAX_NUM=5` +> 固定显示 5 条任务;超过 5 条显示滚动条并映射 `questShowingStartIndex`,任务行点击发送 +> `-2147483648 + questIndex`,空 icon 路径使用 `season1/icon/scroll_open.tga` 默认图标。 +> `quest_test.gd` 已覆盖 7 条任务、首尾滚动和事件参数。多列定位、世界箭头、屏幕边缘指示、 +> 完整 `.sub` 图标视觉和真实任务生命周期仍待验收。详见 `CLIENT-GAP-FIX.md` §5.2 / C.5。 + +> **W3→M2 最新进展(增量 102)**:§5.3 钓鱼输入状态机已接入:钓鱼模式攻击键发送 +> `CG_FISHING` 方向、普通攻击不再误发;方向键边沿和地面点击按参考顺序取消钓鱼,并保留 +> `NEW_CancelFishing` 的 500ms 节流;特殊技能 123 已从快捷栏特殊技能门进入钓鱼入口。 +> `GC_FISHING` START/REACT/STOP/SUCCESS/FAIL/FISH 会更新客户端意图状态。水面角度扫描、 +> wrong-place 文案、鱼获物品落袋,以及副本 / 坐骑 / 观战者完整实现仍待验收。详见 +> `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **W3→M2 最新进展(增量 103)**:`Metin2World` 已按 ClientVS22 的 +> `InstanceBaseMotion::GetFishingRot` 通过 `attr.atr` 水面属性,在角色前方 600cm 对左右 +> `0..180°`、每 10° 扫描,并由 `NetWorld` 桥接给 `NetPlay`;选中的 heading 会发送给 +> `CG_FISHING`,没有水面时发出 wrong-place 状态。`fishing_water_test.gd` 加载 A1 全 20 区块 +> 找真实水面并通过。鱼获物品落袋、副本 / 坐骑 / 观战者完整玩法仍待验收。详见 +> `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **W3→M2 最新进展(增量 104)**:`ChatUI` 已按 ClientVS22 的 `RecvFishing` 分支接入 +> `FISHING_WRONG_PLACE`、`FISHING_FAILURE`、`FISHING_UNKNOWN`,以及 item proto 类型驱动的 +> `FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2` 文案;`GC_FISHING(SUCCESS)` 只切换动画状态, +> 不再错误打印“鱼钩命中”。`chat_test.gd`、`gamescene_test.gd` 通过。参考端的真正背包增加 +> 仍由独立 `GC_ITEM_*` 包驱动,本批次没有伪造本地落袋。详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **W3→M2 最新进展(增量 105)**:坐骑攻击门按 ClientVS22 的 `SHORSE::GetLevel/CanAttack` 与 +> `CPythonPlayer::__CanAttack` 接入:普通坐骑 level 1 禁止攻击,新坐骑要求骑乘术 level 11 或 master; +> 自动攻击路径同时改用实体 `mount_vnum` 判断,不再只检查不存在的 `mounting` 标志。`netplay_test.gd` +> 已覆盖普通 / 新坐骑门禁。坐骑模型 / `MODE_HORSE*` 完整动作、移速和真实服务器验收仍待完成。详见 +> `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **W3→M2 最新进展(增量 106)**:小地图已按 ClientVS22 `CPythonMiniMap::AddObserver / +> MoveObserver / RemoveObserver` 接入观战者紫色标记的一秒位置插值,初始快照可播种轨迹, +> `world_reset` 清理观战者轨迹。`p9_test.gd` 已覆盖 ADD / MOVE / REMOVE / reset;副本入口、 +> 观战自由视角及真实服务器完整观战生命周期仍待完成。详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **W3→M2 最新进展(增量 107)**:副本 `GC_DUNGEON DESTINATION_POSITION` 已按 +> `PythonNetworkStreamPhaseGame.cpp:3875-3901`、`PythonPlayer.cpp:147-167,1255-1285` +> 接入 `dungeon_state.gd`:保存全局厘米坐标、立即发出目标提示、按参考端 Manhattan `<10000cm` +> 清除目标,并在 `world_reset` 时清理。HUD 增加目标距离 / 朝向提示;`TIME_ATTACK_START` 保持 +> 参考端空分支,不虚构本地计时器。`dungeon_state_test.gd`、`gamescene_test.gd`、`p9_test.gd`、 +> `netplay_test.gd` 均通过。DungeonBlock 的 `.mdatr` 碰撞、真实副本入口 / 结算窗口及真服验收仍待完成。 +> 详见 `CLIENT-GAP-FIX.md` §5.3 / C.5。 + +> **总览表同步说明(增量 107)**:P1“世界玩法”中“副本入口 / 计时”仍是旧快照的概括;当前已完成的 +> 是 `GC_DUNGEON` 目的地消费、参考端到达判据和 HUD 罗盘提示,`TIME_ATTACK_START` 在参考端本身为空分支, +> 因此不实现本地计时器。仍待的是 DungeonBlock `.mdatr` 碰撞、真实副本入口 / 结算窗和真服验收。 + +> **W3→M2 最新进展(增量 108)**:§5.2 小地图任务信标已补齐世界箭头 / 屏幕边缘指示。信标越出 +> 小地图半径时,`minimap.gd` 按 `m2dev PythonMiniMap.cpp:441-462` 的 `atan2 + radius*cos/sin` +> 夹到圆周(留边常量 `SIGNAL_RIM_INSET=9.0` 对齐 `c_fMiniMapWindowRadius` 的 `- 9.0f`),并改画 +> 指向真实目标的方向箭头(`signal_marker_geometry` + `_draw_signal_arrow`)。`p9_test.gd` 加 +> 8 断言覆盖夹边朝向 / 半径 / 增删计数。夹边逻辑的对照基线落在 m2dev 完整版(40250/ClientVS22 +> 的 PythonMiniMap.cpp 为裁剪版,缺该实现);多列定位、真实 `.sub` 图标、Atlas 同一信标夹边 +> 仍待。详见 `CLIENT-GAP-FIX.md` §5.2 / §8.9 / C.5。 + +> **W3→M2 最新进展(增量 109)**:§5.2 quest 信件按钮条已按 `assets/root/interfacemodule.py:1357-1386` +> 的 `__ArrangeQuestButton` 1:1 多列定位:`yPos = 170*H//600`、`yCount = (H-330)//63`、第 i 个 +> `= (xPos + (i//yCount)*100, yPos + (i%yCount)*63)`,`xPos` 组队窗显示时由 20 移到 130。 +> `quest_log.gd` 新增有序表 `_quest_button_order` 复刻 `questButtonList.insert(0, btn)`(最新在前、 +> 同 index 提前),`_button_list` 从 VBox 单列改绝对定位 Control,组队窗可见性经 `set_party_probe` +> 在 `_process` 轮询。`quest_test.gd` 加 16 断言覆盖换列坐标 / 顺序 / 组队窗移位 / 清空隐藏。 +> 剩余:任务日志窗体 `_list` 自身多列 / 分页、真实 `.sub` 信件图标 / Flash 视觉。详见 +> `CLIENT-GAP-FIX.md` §5.2 / §8.9 / C.5 增量 109。 + +> **W3→M2 最新进展(增量 110)**:§8.8 头顶名字颜色按 `CInstanceBase::GetNameColorIndex` +> (`InstanceBaseEffect.cpp:525`)+ `GetIndexedNameColor`(:75)+ `SetEmpireNameMode`(:52) +> 1:1 落地。新建 `project/name_color.gd`(纯静态 `RefCounted`):`NAMECOLOR_*` 枚举 +> (`InstanceBase.h:216`,`EMPIRE_NUM = 4`)、`introloading.py:264` 注册的 RGB 槽表、 +> `chrmgr.NAMECOLOR_MOB/NPC/PC` → `NORMAL_*` 的 `PythonCharacterManagerModule.cpp:767` 重映射, +> `name_color_index(self_e, main_e, ctx)` 逐行照抄(IsPC → killer→PK → 存在主角实例且非本人时 +> 决斗 `DUEL_CANNOTATTACK`→本色 / `DUEL_START` 命中对局→PVP、同阵营查 `__FindPVPKey` / +> `__FindGVGKey`(guild≠0) → PVP、异阵营→PVP → 组队→PARTY → PC+GetEmpireID;NPC→NPC、 +> 敌对 / 变身→MOB、其余 fallthrough→白)。`ctx` 与 `entity_rules.gd` / +> `net_play._attack_ctx()` 同款(`main_vid` / `duel_mode` / `duel_opponents` / `pvp_pairs` / +> `gvg_pairs` / `party_vids`)。`net_world.gd` 把 `Label3D.modulate` 从非 1:1 的 +> `_state_tint` 占位(已删)改为专用名字色:spawn / info / pvp / duel / main_set 事件即时 +> `_apply_name_color(vid)` + `_process` 每 0.5 s `_retint_all_names()` 兜底(覆盖组队增减 / +> `set_empire_name_mode` 切换),`kind_resolver = net_play._entity_kind` 由 `game_scene.gd` +> 注入以补 m2dev fork「NPC / 怪 bType 均为 0」的分类缺口(未注入则回退裸 `ch_type`=seam ⑥)。 +> 帝国名色默认关(`constinfo.CHRNAME_COLOR_INDEX == 0`),运行期开关 +> `net_world.set_empire_name_mode(bool)` 尚未接 game_option UI。`name_color_test.gd`(第 21 +> 个 canonical,~55 断言)+ 全量离线回归通过。无 seam(40250 源含 `GetNameColorIndex`, +> ISO-8859 编码,与 m2dev 逐字节一致;更正增量 109「对照基线 m2dev 完整版」的措辞)。 +> 剩余:`text_tail.gd` 称号色 `GetTitleColor` / 公会名 / 屏幕空间血条 / 聊天气泡整合、 +> 目标框 / 可攻击光标按 `ENameColor` + `is_attackable_instance` 驱动。详见 +> `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 110。 + +> **W3→M2 最新进展(增量 111)**:§8.8 头顶「公会名 / 称号 / 等级」子标签按 +> `CInstanceBase::GetTitleColor`(`InstanceBaseEffect.cpp:609`)+ `RefreshTextTail`(:664)+ +> `AttachTextTail`(:621)+ `UpdateTextTailLevel`(:654)+ `CPythonTextTail::RegisterCharacterTextTail` +> 的公会名分支(`PythonTextTail.cpp:509`)+ `EnablePKTitle`(:938)1:1 落地。新建 +> `project/text_tail.gd`(纯静态 `RefCounted`):`TITLE_NUM=9` / `TITLE_NONE=4`、`g_akD3DXClrTitle` +> 的 9 色槽(`introloading.py:287` `TITLE_COLOR_DICT` + `colorInfo.py:29`)、`s_kLevelColor` +> = `Color(152/255, 255/255, 51/255)`、公会名色 `0xFFEFD3FF`、`"Noname"` 兜底; +> `title_color(grade)`(grade≥`TITLE_NUM` → 白)、`refresh_title(alignment)`(复用 +> `entity_rules.alignment_grade`,`grade==TITLE_NONE` 或 `EnablePKTitle` 关时不显示)、 +> `refresh_level(level)`(`"Lv %d"`,`level>0` 且 title 开)、`refresh_guild(id, name)` +> (`id==0` 不显示 / 空名→`"Noname"`)。`net_world.gd` 在 `_attach_nameplate` 加 +> `GuildTag` / `TitleTag` / `LevelTag` 三个 `Label3D` 子节点,`_apply_text_tail(vid)`(spawn / +> info 事件)+ `_process` 每 0.5 s `_retint_all_text_tails()` 兜底,`title_name_resolver` 由 +> `game_scene.gd` 注入 `Locale.t("PVP_LEVEL%d")`(称号名 `localeInfo.TITLE_NAME_LIST`)。 +> `text_tail_test.gd`(第 22 个 canonical,~50 断言)+ 全 62 个离线回归通过。无 seam +> (40250 源含 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `RegisterTitleColor`); +> seam ⑦:billboard 3D 竖排 vs 参考屏幕空间像素排版;seam ⑧:`title_name_resolver` 未注入 +> 回退裸 `PVP_LEVEL%d` key。剩余:伤害数字 / 聊天气泡 / 屏幕空间血条整合、头顶信息显示开关 +> (LALT + §8.4 三 radio)。详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 111。 + +> **W3→M2 最新进展(增量 112)**:§8.8 头顶「聊天 / 信息」尾标按 +> `CPythonTextTail::RegisterChatTail`(`PythonTextTail.cpp:581`)+ `RegisterInfoTail`(:624)+ +> `TextTail_GetLivingTime` / `SetLivingTime`(:26/:28/:34,`gs_TextTail_LivingTime = 5000`, +> `assert > 1000`)+ `UpdateAllTextTail` 的 `LivingTime < now` 硬删(:328)+ `IsChatTextTail`(:684)+ +> `RecvChatPacket` 的派发规则(`PythonNetworkStreamPhaseGame.cpp:1301/:1321/:1372`)1:1 落地。新建 +> `project/chat_tail.gd`(纯静态 `RefCounted`):`CHAT_COLOR`=白 / `INFO_COLOR`=`Color(1,.785,.785)`、 +> `LIVING_TIME_DEFAULT=5000`、`EChatType` 0..8;`strip_name_prefix`(`strchr(buf,':') ? p+2 : buf`)、 +> `line_for_tail`(TALKING/PARTY/GUILD/SHOUT/WHISPER 去 `"名字: "` 前缀,INFO/NOTICE 原样)、 +> `wants_chat_tail`(除 SHOUT / COMMAND 全进)、`register_chat` / `register_info`(共用 `_chat_tails`, +> 同 vid 互相覆盖,`bNameFlag` TRUE/FALSE)、`is_chat_text_tail`、`expire`。`net_world.gd` 的 +> `_bubble` 改走 `chat_tail`,每 vid 一条 `"bubble"` `Label3D`(白 / 淡红按 chat/info), +> `LivingTime` 到点由 `_expire_chat_tails`(`_process` 每 0.5 s)硬删;新增 `register_info_tail(vid, text)` +> 公开方法对齐 `RegisterInfoTail`;`chat` 派发从 `t in [0,3,4,6]` 换成 `ChatTail.wants_chat_tail(t)` +> —— 行为变化:SHOUT 不再弹头顶气泡(对齐 :1372),WHISPER / INFO / NOTICE 带 VID 时现在会弹。 +> `game_scene._player_bubble` 寿命对齐 5s。`chat_tail_test.gd`(第 23 个 canonical,~45 断言)+ +> 全 63 个离线回归通过。无 seam(40250 源含 `RegisterChatTail` / `RegisterInfoTail` / `RecvChatPacket`); +> seam ⑨:气泡与名字 `Label3D` 各自 billboard 并存,名字不强制顶出 / 不随气泡下移 17px, +> `bNameFlag` 只存不用。剩余:伤害数字(`GC_DAMAGE_INFO` / `AddDamageEffect`)/ 屏幕空间血条整合、 +> `TextTail_SetLivingTime` 运行期钩子。详见 `CLIENT-GAP-FIX.md` §8.8 / §5.4 / C.5 增量 112。 + +> **W3→M2 最新进展(增量 113)**:§8.4 / §8.6 头顶名字「是否显示」按 +> `40250/ClientVS22/source` 补 1:1。新建 `project/name_show.gd`(第 24 个 canonical 起点,纯静态 +> `extends RefCounted`,与 `name_color.gd` / `text_tail.gd` / `chat_tail.gd` 同套路)照抄 +> `CPythonSystem::IsAlwaysShowName` / `SetAlwaysShowNameFlag`(`iFlag == 1`)、配置行 +> `ALWAYS_VIEW_NAME`(`DEFAULT_VALUE_ALWAYS_SHOW_NAME = true`)、`game.py::__IsShowName()` +> =`IsAlwaysShowName() or ShowNameFlag`、`CPythonTextTail::ShowAllTextTail` 的 +> `fDistanceFromPlayer < 3500.0f` 平面距裁剪、`ShowCharacterTextTail` 的 +> `pOwner->isShow()` / `!IsGuildWall()` / `CanPickInstance()` 资格判定、`UpdateAllTextTail:74` +> 的 chat 尾标 `bNameFlag` 强显(不受 3500 限制)。`net_world.gd`:`_names_visible`(默认 true) +> 拆成 `_always_show_name`(默认 true,对齐参考默认值与 `game_option_ui` 默认 1)+ `_alt_held` +> + `_target_vid`;新增 `_name_shown()` 四路组装(`_always_show_name` / `_alt_held` / `_target_vid` +> / chat `bNameFlag`)+ `set_always_show_name()` / `set_target_vid()`,`_process` ~2Hz 节流兜底 +> 距离变化,LALT / 选项 / 选中 / 尾标增删即时刷新。`game_option_ui.gd` 加 `display_option_changed(key, value)` +> 信号 + `display_value()`(对齐 `uigameoption RefreshAlwaysShowName`);`game_scene.gd` 接 +> `net_play.target_changed` → `set_target_vid`、`display_option_changed` → `set_always_show_name`。 +> `name_show_test.gd`(第 24 个 canonical,~35 断言)+ 全 64 个离线回归通过。 +> 行为变化:3500–20000cm 的实体有节点但无名字(原先全显);主角位置未知时 fail-open(同 +> `_is_visible_entity`);配置总写 `ALWAYS_VIEW_NAME` 键(参考仅在非默认时落盘)。 +> seam ⑩:悬停 `chr.Pick()` 强显、屏幕空间 `ArrangeTextTail` 防重叠、`CanPickInstance` 仅按 +> 死透近似、参考 `UpdateDistance` 的 `-ownerY` 是渲染帧 / 像素帧混用产物(本移植统一 `pos_cm` 帧)。 +> `ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 两步未移植(blob / quickbar UI)。 +> 详见 `CLIENT-GAP-FIX.md` §8.4 / §8.6 / §8.8 / §5.4 / C.5 增量 113。 + +> **表格旧快照更正**:W3 行中若仍写“远端 PC 仍是占位胶囊”或“任务日志缺滚动 / 定位”,以本节 +> 增量 99 / 101 记录及 `CLIENT-GAP-FIX.md` 对应条目为准;后续编辑总览表时须同步替换旧描述。 ### 进度同步是交付条件 @@ -101,6 +265,656 @@ > m2dev / 旧实现,不代表当前 Classic 审计口径。当前结论以“协议覆盖审计”和 `MT_PROTOCOL` 变体 > 说明为准;历史记录中的 `CG_QUEST_CANCEL` 也不代表 Classic 存在同名独立 header。 > +> - **2026-09-03 增量 99(W3→M2 / §4.1 修改 5 远端 PC 真模型工厂)**:`game_scene.gd` 对 +> `ch_type==0` 创建 `RemotePlayerView`,复用 `PlayerView` 的 race / GR2 / MSA;新增远端专用 +> `EquipModel.setup_remote()`,只消费参考端 `Packet.h:1282-1289` 定义的 +> `awPart[ARMOR=0,WEAPON=1,HEAD=2,HAIR=3]`,不读取本地 `get_equipment()`。`net_world.gd` 按 +> `set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])` 对齐 +> `NetworkActorManager.cpp:473`;`remote_player_test.gd` 以真实 warrior 资源覆盖建模、换装和字段序, +> `gamescene_test` / `equip_model_test` / `net_world_vis_test` / `netplay_test` / +> `player_motion_test` 全部通过。其他职业 / race、变身、真服多角色换装和精确视觉仍待验收。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 + +> - **2026-09-03 增量 100(W4 / §6.3 EffectLib emitter cycle 生命周期首版)**:按 +> `40250/ClientVS22/source/EffectLib/ParticleSystemData.cpp:38-60` 对齐缺省 `CycleLength=0.05`, +> 按 `ParticleSystemInstance.cpp:280-300` 接入有限 cycle / LoopCount 的发射停止;重播时复位 CPU +> 时间门控,并移除把 cycle 长度错误写入粒子寿命的逻辑。`fx_test` 已覆盖 cycle、LoopCount、 +> 独立粒子寿命和 OUTER fallback。OUTER / INNER 精确径向速度、每周期 emission residue / 时间曲线 +> 重置、随机起始帧和每粒子随机语义仍待实现。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 +> +> - **2026-09-02 增量 73(W2 / §3.7 受击状态统一)**:删除 `net_play.gd` 里参考端不存在的 +> `_hitstun_until` / `HITSTUN` 本地硬直计时器及全部引用。「能否行动」的判定统一走 +> `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()` 三个函数:对齐 +> `InstanceBase.cpp:1247`(≈ `ActorInstanceSync.cpp` 的 `__IsSyncing` = `IsDead || IsStun || +> IsPushing`)、`InstanceBaseBattle.cpp:355`(`NEW_Attack` 多一个 `IsUsingSkill`)、`:310`。 +> C++ `Entity` 增加 `knock_down`(`entity_dict` 暴露、`mut_knockdown(vid,on)` 落位并标脏); +> 本地玩家的击退态由受击 `.msa` 播放生命周期驱动——`_on_damage` 只置位 + 播 `damage` 动作 + +> 抖屏,`_process` 轮询 `player_view.is_in_hit_reaction()` 在动作播完时清零(`player_view` +> 的 `_on_playback_finished` 增加 `damage` 一次性 clip 分支)。`dead` / `stunned` 保持由服务端 +> 包驱动。`combat_fx_test` 断言改写并通过;C++ 19/19、`netplay_test` / `netbridge_test` / +> `gamescene_test` / `player_motion_test` 回归全绿。远端实体 `knock_down` 的接线、真实 40250 +> 受击 / 击退 / `AFFECT_STUN` 清除时序仍待后续增量 / 真服验收。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.7 / C.5。 +> - **2026-09-02 增量 74(W2 / §3.3 普攻节奏硬编码 → 动作数据驱动)**:`formats/msa.{h,cpp}` +> 新增 `Group ComboInputData`(`PreInputTime` / `DirectInputTime` / `InputLimitTime` / +> `LinkTime`)与 `Group AttackingData`(`AttackingType` / `HittingType` / +> `AttackingStartTime` / `AttackingEndTime` / `StiffenTime` / `InvisibleTime` / +> `ExternalForce`)解析,对齐 `EterGrnLib CGrannyMotion::LoadMotionData` → +> `CRaceMotionData::TComboInputData / TAttackingData`;`metin2_anim` 新增并绑定 +> `get_motion_data()` 导出 `duration` / `next_combo`(`DirectInputTime` 或 +> `duration*0.9`)等。`net_play.gd` 删除 `ATTACK_RANGE` 常量,改为 +> `CLICK_DIST_DEFAULT_CM=150 / NPC_CM=500 / RESOURCE_CM=100` + `_clickable_distance(te)` +> (对齐 `InstanceBaseBattle.cpp:294 NEW_IsClickableDistanceDestInstance`);普攻节奏改为 +> `_current_attack_period()` = `(.msa next_combo 或 DEFAULT_ATTACK_PERIOD) / +> (bAttackSpeed/100)`,收 info 包时 `_apply_attack_speed()` 只写 `_atk_speed_factor` +> 不再写死间隔。弓箭 `__GetBowRange()`、`AttackingData` / `HitPosition` 驱动的命中帧 +> 留待 §3.5。C++ 19/19(`formats.msa_loop_data` 扩展断言)、`combat_fx_test` / +> `netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / +> `p2b_test` 回归全绿。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.3 / C.5。 +> - **2026-09-02 增量 75(W2 / §3.4 自动攻击目标预留状态机)**:`net_play.gd` 新增 +> `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}` 与预留字段 +> (对齐 `PythonPlayerInput.cpp:820-865`)。`_process` 改为每帧 +> `_update_auto_attack()`(照抄 `PythonPlayer.cpp:207 __Update_AutoAttack` 的目标存在 / +> 死亡 / 骑乘 / charge-skill / 睡眠分支)→ `_refresh_reserved()` 模式分派。 +> `_reserve_process_click_actor()` 严格 10 步对齐 `PythonPlayerInput.cpp:877`:出可点击距离 +> `pc.walk_to` 继续靠近、不可攻击走 `client.click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / +> `EMPTY_ARROW` 经新 `signal cannot_act(code)`、第 10 步按 §3.3 节奏 gate `_do_attack_swing` +> 后清预约并于下帧重新预约。安全区 / 弓箭模式 / 箭矢数 / 骑乘等级 / dash affect 等服务端 +> 未下发的判定位先用实体字典键 + 本地占位变量接线(做法同 §3.7 `knock_down`)。~~CLICK_ITEM / +> CLICK_POSITION / USE_SKILL 分派留桩。~~ —— **增量 91 已补齐**(见该增量条目)。`netplay_test` 新增 5b 段(出距离→`walk_to` 且不发包、 +> 进距离→攻击且清预约、`IN_SAFE`→`cannot_act` 且不发包、目标死→auto-attack 清零); +> C++ 19/19、`combat_fx_test` / `netbridge_test` / `gamescene_test` / `player_motion_test` / +> `p2b_test` / `input_key_test` 回归全绿。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.4 / C.5。 +> - **2026-09-02 增量 76(W2 / §3.1 本地移动 Src/Dst 模型)**:`player_controller.gd` 去掉 +> `_move_target` / `_has_target` 的直线改坐标,改为 `_src_pos` / `_dst_pos` / `_is_going` / +> `_dst_rot` 模型。`walk_to()` → `_goto(dst_flat)` 对齐 `CInstanceBase::NEW_Goto()` +> (`InstanceBaseMovement.cpp:251`):`flat.length() < MOVABLE_GROUND_DISTANCE_M`(1.0 m) → +> 返回 true 不动(≈ `__IsMovableGroundDistance`);三门 `_can_translate()` = `not frozen and +> not locked and not moving_skill` 未过 → 存 `_reserved_ground` + `RESERVED_GROUND_DELAY` +> 0.1 s,`_process` 里递减归 0 后自动重跑 `_goto`(≈ `__ReserveClickGround` / +> `NEW_IsEmptyReservedDelayTime`);`moving_skill` 只转向不平移。`_process` 每帧朝 `_dst_pos` +> 推进、抵达清 `_is_going`,WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection` 的 +> `m_isGoing = FALSE`)。新增 `project/player_move_test.gd`(5 组);C++ 19/19、`netplay_test` / +> `combat_fx_test` / `gamescene_test` / `eterngrn_polish_test`(既有无关 `motion_event: sound` +> FAIL)/ `player_motion_test` / `p2b_test` / `netbridge_test` / `mouse_controller_test` 回归全绿。 +> 未做:Actor 间碰撞(`TestActorCollision` / `BlockMovement` 的 Area3D 近似)、坐骑 +> `MODE_HORSE*`(依赖 §6.4 + §3.2)。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.1 / C.5。 +> - **2026-09-02 增量 77(W2 / §3.2 远端同步命令队列 —— C++ 核心 part A)**:`EntityStore` +> 把远端 Actor 位置改为命令队列驱动(对齐 `InstanceBase.cpp:1278 CInstanceBase::StateProcess`), +> 不引入预测回滚 / 抖动缓冲。`Entity` 新增 `std::deque state_queue` + `mov_after_func` +> / `mov_after_arg` / `mov_after_rot` / `bool skip_collision`;`mut_move()` 改为把 +> `{chk_time,dst,rot,func,arg,dur_ms}` 入队(≈ `PushTCPState`)后 `drain_state_queue()` 释放 +> 到期项;删掉旧 `start_move()` 直改坐标路径,m2dev `apply()` 的 GC_MOVE 与 classic parser 的 +> HDR_GC_MOVE 都统一走 `mut_move(vid, angle, func, tx, ty, dur, arg, time)`。新增 +> `process_states()` + `apply_state_cmd()` 1:1 实现 `StateProcess` 的 switch:`FUNC_WAIT` +> dir_len>1.0 → 走 + `m_kMovAfterFunc=WAIT`,else `SCRIPT_SetPixelPosition` 贴齐;`FUNC_MOVE` +> 恒走;`FUNC_COMBO/ATTACK/MOB_SKILL` dir_len>=50.0 → 走到位再动作,else 贴齐 + 动作;所有 +> 「走过去」分支置 `skip_collision`(≈ `__EnableSkipCollision`),`tick()` 到达帧据 +> `mov_after_func` 落地 COMBO/ATTACK/MOB_SKILL 动作并清标志。释放门按新 `set_server_frame_ms()` +> (`m_server_frame_ms == 0` = 帧钟未对齐 → 立即释放 = 退回增量 76 时序,无回归)+ +> `__CanProcessNetworkStatePacket`(`dead` / `knock_down` 时挂队不应用,恢复即释放)。 +> `SNetworkActorData::UpdatePosition` 式线性插值只保留给 §2.1 可见性、不再直接写节点位置的 +> 工作留给增量 78。新增 `extension/tests/net_state_queue_test.cpp`(5 组:帧钟释放门 / FUNC_WAIT +> 1.0 阈值 / FUNC_COMBO 50.0 阈值 + 到达后动作 / knock_down·dead 门 / 多条到期按序 drain); +> `net_entity_test` 的 FUNC_ATTACK 断言按新语义拆成近(贴齐)/ 远(先走后打)两例。 +> `ctest --test-dir build` → 20/20(新增 `net.state_queue`);`netbridge_test` / +> `gamescene_test` / `netplay_test` / `player_move_test` / `p2b_test` / `combat_fx_test` 回归全绿。 +> 留给增量 78:net_play.gd 上行 6 事件回调 + 节流常量(100/300 ms + disp≥1.0)、net_world.gd +> 去掉自有 lerp、从 `GC_HANDSHAKE` `lDelta` 接真实服务器帧钟;`__IsEnableTCPProcess` 表情锁与 +> Actor 碰撞近似另计。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 +> - **2026-09-02 增量 78(W2 / §3.2 上行 6 事件回调 + net_world 去 lerp —— part B)**: +> `net_play.gd` 的上行按 `PythonPlayerEventHandler.cpp` 拆成 6 个回调,统一走新出口 +> `_send_state(func,arg,pos)`(≈ `SendCharacterStatePacket` / `TPacketCGMove`):`OnMove` +> (`pc.moved` 首帧)立即发 `FUNC_MOVE` 并重置 moving/waiting 两个节流窗,`OnMoving` 按 +> `SEND_MOVING_INTERVAL=0.3s` 节流发 `FUNC_MOVE`,`OnWaiting`(新 `_tick_on_waiting()`, +> `_process` 每帧)按 `SEND_WAITING_INTERVAL=0.1s` 节流且相对上次上行位移 ≥ 1 cm 才发 +> `FUNC_WAIT`,`OnStop`(`anim_state("wait")` 且刚在移动)不节流收尾 `FUNC_WAIT`,`OnAttack` +> 在 `client.attack()`(`CG_ATTACK`) 外额外 `_send_state(FUNC_COMBO, attack_motion+_combo, +> pos)`,新增公共入口 `on_use_skill(motion,arg)` → `_send_state(FUNC_SKILL|(motion&0x7F), +> arg, pos)`(真实调用点属 §3.8 `player_skill.gd`,此处仅留 1:1 出口)。删掉旧的 +> `SEND_INTERVAL` / `SEND_DIST` / `_last_sent_t` 单一节流。`net_world.gd` 的 `_process` +> 改为 `n.position = _grounded(e.pos)` 直接跟随,去掉自有 `follow_lerp` 二次平滑——位置 +> 插值现在完全在 C++ `EntityStore::tick()`(增量 77 命令队列 + `SNetworkActorData:: +> UpdatePosition` 式线性插值)里做,只朝向仍在 GDScript 侧 `lerp_angle` 平滑;`follow_lerp` +> / `snap_dist` 字段保留仅为兼容。`netplay_test` 新增第 11 段(OnMove / OnMoving 节流 / +> OnStop / `on_use_skill`)、观战段基线改「相对计数不变」,`netbridge_test` 的 near-move +> 断言改「node follows e.pos directly」。C++ 20/20 不变;`netbridge_test` / `gamescene_test` +> / `netplay_test` / `player_move_test` / `p2b_test` / `combat_fx_test` / `player_motion_test` +> 回归全绿。留给增量 79:从 `GC_HANDSHAKE` `lDelta` 接真实服务器毫秒帧钟 +> (`ClassicStream` 已存 `m_server_time_base` / `m_client_time_base`,需暴露访问器并穿到 +> `EntityStore::set_server_frame_ms()`);`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` +> 是否清队列另计。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 +> - **2026-09-03 增量 79(W2 / §3.2 真实服务器毫秒帧钟 —— part C,§3.2 收官)**: +> `ClassicStream` 暴露 `server_frame_ms()`(`classic_stream.h:107` / `.cpp:48`,≈ +> `ELTimer_GetServerFrameMSec()`)——`HDR_HANDSHAKE` 处理里早已存的 +> `m_server_time_base = hs.time + lDelta` / `m_client_time_base = now_ms()` +> (`classic_stream.cpp:266-267`),帧钟 = `now_ms() - m_client_time_base + +> m_server_time_base`,首个握手前返回 0;另加 `has_server_clock()`。 +> `ClassicSession::process()` 在 `m_world.tick()` 前 +> `m_world.set_server_frame_ms(m_stream.server_frame_ms())`(`classic_session.cpp:307`), +> classic 后端 `drain_state_queue()` 遂按服务器时间放行命令队列而非即时释放。m2dev +> 后端无握手 `lDelta` 对应物,`m_server_frame_ms` 恒 0 = 即时释放 = 增量 76 时序,无回归。 +> `EntityStore::drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000` +> (`entity_store.h`)——`chk_time` 领先帧钟超过 1 s 视为异帧钟 `dwTime`,立即放行以免 +> 远端 Actor 永久冻结;参考端 `StateProcess` 无此上限,属有意偏离,已在 `CLIENT-GAP-FIX.md` +> §3.2 记录。`net_classic_stream_test` 加帧钟段(握手前 0 + `!has_server_clock()`、握手后 +> ≈ `hs.time + lDelta`、单调不减),`net_state_queue_test` 加第 6 段(防呆阀边界)。 +> C++ 20/20、7 个 GDScript 回归全绿。**§3.2 至此完成**,真实服务器 `GC_MOVE` `dwTime` +> 时基是否与握手帧钟一致仍待联机核验。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.2 / C.5。 +> - **2026-09-03 增量 80(W2 / §3.5 PC 连击段表解析 —— part A)**:查明参考端 PC 的连击 +> 段表 **不在** `.msm` 里(`.msm` 只有 `BaseModelFileName` + `HairData`),而是由 +> `assets/root/playersettingmodule.py` 的 `chrmgr.ReserveComboAttackNew` / +> `RegisterComboAttackNew` 调用在 `CRaceData` 上建起来(`PythonCharacterManagerModule.cpp` +> → `CRaceData::ReserveComboAttack` / `RegisterComboAttack`,`RaceData.cpp:405`)。新增 +> `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`):按 `def __LoadGameEx` +> 分四个职业块(男女同函数体 → 表按职业收),解析那批调用为 +> `map>`(段号 = `NAME_COMBO_ATTACK_*` +> = 14..21),语义对齐 `CRaceData`:`Reserve` 建长 `count` 的 0 向量(`map::insert`, +> 重复 key 不覆盖)、`Register` 按下标写(key 未 Reserve 或下标越界即跳过)。认得 +> `chr.MOTION_MODE_*`(= `CRaceMotionData::EMode`)、`chr.MOTION_COMBO_ATTACK_N`(= 13+N)、 +> `chr.MOTION_NORMAL_ATTACK`(= 13)、`COMBO_TYPE_N` / `COMBO_INDEX_N`(= N−1)、裸整数。 +> 新增 `formats_combo_table_test`(合成片段 + `M2_ASSETS` 时对真实 `playersettingmodule.py` +> 断言 warrior/sura 1H、shaman FAN),C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 81: +> `player_controller.gd` 连击状态机(`_cur_combo_index` / `_is_pre_input` / +> `_is_next_pre_input`)改用此表 + `.msa` `ComboInputData` 三时间,替掉 `net_play.gd` +> `_do_attack_swing` 的 `(_combo + 1) % 3`;之后 §3.5 命中窗(`.msa` `HitDataContainer`) +> + 命中判定 / `OnHit`→`CG_ATTACK` / `FlushVictimList` 帧末 `CG_SYNC_POSITION`。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 +> - **2026-09-03 增量 81(W2 / §3.5 PC 连击状态机 —— part B)**:`net_play.gd` 用增量 80 的 +> 连击段表 + `.msa` `ComboInputData` 三时间实现连击状态机,替掉 `_do_attack_swing` 的 +> `(_combo + 1) % 3`。新增 `_do_attack_swing` / `_run_next_combo` / `_combo_process` / +> `_on_end_combo` / `_clear_combo`,逐行对齐 `CActorInstance::InputComboAttackCommand` / +> `__RunNextCombo` / `ComboProcess` / `__OnEndCombo` / `__ClearCombo` +> (`ActorInstanceBattle.cpp:157/213/256/297/308`):`_combo_index==0` 直接推进;否则按 +> `pre_input_time`(InputStartTime)/ `direct_input_time`(NextComboTime)/ +> `input_limit_time`(InputEndTime)判 `_is_pre_input` 挂起 / 直接推进 / 弓模式 +> `_is_next_pre_input`;无 combo 输入组用 `duration*0.9` 兜底;`_combo_process()` 每帧触发 +> 挂起输入、动作回 Wait 时 `_clear_combo()`;段号越界不推进;末段仅骑乘复位到 1。段号取自 +> 新静态绑定 `Metin2AnimPlayer.parse_combo_tables(py_path)`(`extension/src/metin2_anim.{h,cpp}` +> 转发 `fmt::parse_player_combo_tables_file`),按 `race & 3` 选职业块、 +> `combo_motion_mode<<16|type` 取段序列(`combo_motion_mode` 默认 GENERAL,武器→mode 映射 +> 留给增量 82+);缺段表退化为单段普攻。`_emit_swing` 保持增量 78 的两包 +> (`CG_ATTACK{bType=0}` + `CG_CHARACTER_MOVE{FUNC_COMBO, 段号}`)。`game_scene.gd` 装配时 +> `net_play.set_asset_root(_assets)`。`combat_fx_test.gd` 加 `FakeAnim` + 连击状态机全路径断言。 +> C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 82+:`.msa` `HitDataContainer` 多命中窗 +> 导出、`THitData` 窗口命中判定 + `OnHit`/`CG_ATTACK` + `FlushVictimList` 帧末 +> `CG_SYNC_POSITION`、武器种类 → `combo_motion_mode` 映射。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 +> - **2026-09-03 增量 82(W2 / §3.5 `.msa` 多命中窗导出 —— part C,修改 1)**:`formats/msa.{h,cpp}` +> 把 `Group AttackingData` 按 `GameLib GameType.cpp NRaceData::LoadMotionAttackData` 的两种 +> 写法解析成 `std::vector`:老式(组内直接 `AttackingStartTime`/…/`List HitPosition`, +> 无 `HitDataCount`)= 1 窗;新式(`AttackType` + `MotionType` + `HitDataCount N` + +> `Group HitData00..0N`)= N 窗(多段挥击 / 双持左右手 = 2 窗)。每窗带 +> `start_time`/`end_time`/`bone`/`weapon_length` + `List HitPosition` 采样(`{time, last_pos, pos}`, +> 7 浮点一组);新增 `motion_type` / `hit_limit_count`,`attacking_type` 兼容 +> `AttackType`/`AttackingType`,`attack_start_time`/`attack_end_time` 保留为首窗起止(旧调用兼容)。 +> `formats/textscript.cpp` 补 `List Name { ... }` 块解析(原来 `List` 会把 `{` 吞进普通行、 +> 还串掉其后的 `Group`——`palbang.msa` 的 `MotionEventData` 就丢了)。 +> `extension/src/metin2_anim.cpp get_motion_data()` 新增 `hit_windows` 数组 +> (`{start_time,end_time,bone,weapon_length,samples:[{time,last_pos:Vector3,pos:Vector3}]}`) +> + `motion_type` / `hit_limit_count`。`formats/tests/msa_test.cpp` 加内联单窗 / 新式双窗合成 +> 断言 + `M2_ASSETS` 时对真实 `warrior/general/attack`、`twohand_sword/combo_05`、 +> `assassin/dualhand_sword/combo_01`(R Hand + L Hand 双窗)、`warrior/skill/palbang` 断言。 +> C++ **21/21** + 7 个 GDScript 回归全绿。留给增量 83+:修改 4(`hit_window` [start,end] 内 +> 几何判定 + `OnHit`→`CG_ATTACK` + `FlushVictimList` 帧末 `CG_SYNC_POSITION`)、 +> 武器种类 → `combo_motion_mode` 映射。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 +> - **2026-09-03 增量 83(W2 / §3.5 命中窗几何判定 → OnHit,修改 4 / §3.5 收官)**: +> `net_play.gd` 新增 `_begin_hit_windows()` / `_attack_process(dt)` / `_hit_deduped()` / +> `_register_hit()` / `_hit_geometry()` / `_flush_victim_list()`,逐帧按动作时间落在某个 +> `hit_window [start,end]` 内对 `client.get_entities()` 里可攻击 / 未死 / 双方非安全区的实体 +> 做几何判定(对齐 `InstanceBaseBattle.cpp:413 AttackProcess` + +> `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess`):`_hit_deduped` 复刻 +> `m_HitDataMap`(`MOTION_TYPE_COMBO` 同窗同目标一次、其余按 `invisible_time` 冷却), +> `_register_hit` 复刻每窗命中上限(NORMAL/COMBO 16、SKILL `hit_limit_count`),命中 → +> `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`,普攻 `_swing_skill=0`), +> 被击退目标帧末 `client.sync_positions()`(≤16,对齐 `PythonPlayerEventHandler.cpp:194 +> FlushVictimList`)。`_emit_swing()` 因此改为**只有拿不到命中窗数据**(headless / 缺资源) +> 才在挥击瞬间立即发 `CG_ATTACK`。几何判定用 `reach = max(WeaponLength, 窗内采样最大水平偏移)` +> + 正面 `±60°` 弧近似(真实动态圆柱扫掠需骨骼矩阵 + defending sphere,随 §3.1 留桩)。 +> `combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。C++ **21/21** + 7 个 GDScript 回归全绿。 +> 留给增量 84:武器种类 → `combo_motion_mode` 映射。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 +> - **2026-09-03 增量 84(W2 / §3.5 武器种类 → `combo_motion_mode`,修改 6 / §3.5 修改 1-6 全落地)**: +> `net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项)+ +> `ITEM_TYPE_WEAPON/ROD/PICK` + `WEAPON_SUB_*` 常量,新增静态 `motion_mode_for(item_type, +> weapon_sub, mounted, poly, dress)` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode` +> (`InstanceBase.cpp:2851`,顺序:Poly→`GENERAL` / 婚纱→`WEDDING_DRESS` / 镐→骑乘 `HORSE` 否则 +> `GENERAL` / 钓竿→骑乘 `HORSE` 否则 `FISHING` / 骑乘 `switch(subType)` → `HORSE_*` / else +> `switch(subType)` → `ONEHAND/TWOHAND/DUALHAND_SWORD`·`BOW`·`FAN`·`BELL`·默认 `GENERAL`), +> `_equipped_weapon_vnum()`(主角 `parts[WEAPON]` 非 0 优先,否则 `get_equipment()[WEAR_WEAPON].vnum`) +> + `_refresh_motion_mode()`(读 `mount_vnum` / `polymorph` / `wedding_dress` + `proto.item(vnum)` +> 的 `type`/`sub_type` → `set_combo_motion_mode(...)`,换模式内部 `_clear_combo()`),由 +> `_on_entity_info()` 末尾、`inventory_changed`(`window==2` EQUIPMENT)、`mount_changed`(主角)三处触发。 +> `combat_fx_test.gd` 加 `FakeProto` + 15 段 `motion_mode_for` 断言 + 2 段端到端(换匕首 → +> `DUALHAND_SWORD` 且 `_combo_index` 归零;再上马 → `HORSE_DUALHAND_SWORD`)。C++ **21/21** + +> 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 85+:弓箭 `__GetBowRange()` +> (§3.5 命中 / 点击距离)、§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / C.5。 +> - **2026-09-03 增量 85(W2 / §3.5 弓箭 `__GetBowRange()` 可点击 / 攻击距离,§3.5 修改 1-7 全落地)**: +> `net_play.gd` `_clickable_distance()` 逐分支对齐 +> `CInstanceBase::NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`): +> 默认 150 cm → `_is_bow_mode()`(`combo_motion_mode` ∈ {`MODE_BOW`, `MODE_HORSE_BOW`}, +> 对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)时改用 `_bow_range_cm()` → +> 目标 `IsNPC()` 覆盖为 500 → `IsResource()` 覆盖为 100(后写的 `if` 覆盖前面)。 +> `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus`,1:1 复刻 +> `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`:`2500.0f-100.0f` + 主角 +> `GetStatus(POINT_BOW_DISTANCE)`,参考端按 cm 直加不 ×100)。`_bow_distance_bonus` 由 +> `_on_points()` 读 `points["bow_distance"]` 填充;`wire.h` 补 `POINT_BOW_DISTANCE = 34`、 +> `M2Client::get_points()` 增 `d["bow_distance"]`。`combat_fx_test.gd` §3.5 段加 8 段断言。 +> C++ **21/21** + 7 个 GDScript 回归全绿(`equip_model_test` 亦 PASS)。留给增量 86:§3.6 箭矢 +> `.msa` `MOTION_EVENT_TYPE_FLY` → `CG_FLY_TARGETING` / `CG_SHOOT`,替掉约 1.5 s 定时器兜底。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.5 / §3.3 / C.5。 +> - **2026-09-03 增量 86(W2 / §3.6 弓箭 FLY 事件 `OnSetFlyTarget` / `OnShoot`,§3.6 修改 1-3 全落地)**: +> 拆掉 `game_scene.gd` 单槽 `_pending_shoot_skill` + `get_tree().create_timer(1.5)` 兜底,换成 +> `_pending_shots: Array[int]`(FIFO,上限 `MAX_PENDING_SHOTS = 8`,满丢最旧),1:1 对齐 +> `CNormalBowAttack_FlyEventHandler_AutoClear`:每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)` +> 压一个,`.msa` `MOTION_EVENT_TYPE_FLY` 帧(`_on_local_motion_event` 的 `type 6`)`pop_front()` +> 弹一个 `client.shoot()`(pop 即自清 = AutoClear,连射多箭在途各自一发;对齐 +> `CActorInstance::ProcessMotionEventFly` `ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget` +> (`ActorInstanceMotion.cpp:372`:`__SetMotion` 里 `__IsNeedFlyTargetMotion()` 为真即触发)放在 +> `net_play.gd._emit_swing`:`_is_bow_mode()` 时先 `_send_fly_target()`(`_target_vid` 回退 +> `_vid_reserved` → `net_world` 节点 → `MapCoord.to_server_cm()` → `client.add_fly_targeting(vid, x, y)`; +> 对齐 `SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition())`,`OnGetFlyTargetPosition` +> = 目标包围球中心世界坐标,平面分量与脚点一致,`CG_FLY_TARGETING` 只带平面坐标, +> `ActorInstanceFly.cpp:4`),再 `bow_shot_fired.emit(_swing_skill)` 让 `game_scene` 入队。 +> 新增 `net_play` 信号 `bow_shot_fired(skill)` + `_send_fly_target()`、`game_scene._queue_shot()`。 +> `combat_fx_test.gd` 加 `FakeClient.add_fly_targeting/shoot` + 6 段 §3.6 断言(非弓无 FLY / +> 弓挥击发 `CG_FLY_TARGETING(vid)` / 坐标 = 目标 server-XY / `bow_shot_fired(0)` / 无目标不发), +> `eterngrn_polish_test.gd` FLY 段改 FIFO 连射断言。C++ **21/21** + 9 个 GDScript 回归全绿。 +> 留给后续:飞行物实体本身(`CFlyingManager` / `FlyingInstance` 生成 / 轨迹 / 命中)、`__CanShot` +> 弓箭合法性、真服 `CG_FLY_TARGETING` z 分量。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.6 / C.5。 +> - **2026-09-03 增量 87(W2 / §3.8 TSkillData 判定位解析,修改 2)**:POC 无 C++ skill proto +> (`extension/src/proto` 只有 item/mob),技能数据全在 GDScript `project/ui/skill_table.gd` +> 读 `locale/locale//skilldesc.txt` TSV。按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 +> 第 11 列 attributes / 第 12 列 weapon 的 `|` 分隔记号折成 `attr_bits` / `need_weapon_bits` +> 位掩码(`_fold_bits()` 静态方法;记号名 1:1 抄 `PythonSkill.h:92-128` 的 `SKILL_ATTRIBUTE_*` / +> `SKILL_NEED_WEAPON_*`,未知记号忽略 = 参考端“map 查不到就跳过”;`DOUBLE_SWORD` 复用 `DAGGER` +> 位;weapon 位 = `1 << CItemData::WEAPON_*`,`GameLib/ItemData.h:63-75`),`load_file` 时连同 +> `skill_type`(JOB 列经 `m_SkillTypeIndexMap` → `SKILL_TYPE_{NONE,ACTIVE,SUPPORT,GUILD,HORSE}`) +> 一起预存进 entry。谓词逐条对齐 `CPythonSkill`:`is_can_use_skill`(=非 PASSIVE,`:1407`)、 +> `is_standing` / `is_need_target` / `is_need_corpse` / `is_auto_search_target` / +> `can_change_direction` / `is_horse_skill` / `is_moving_skill` / `is_melee` / `is_charge_skill` / +> `is_use_hp` / `is_time_increase` / `is_need_empty_bottle` / `is_need_poison_bottle` / +> `can_use_for_me` / `is_only_for_alliance` / `can_use_if_not_enough` / `is_only_for_guild_war` / +> `is_fan_range` / `is_circle_range` / `has_weapon_limitation` / `can_use_weapon_type(id,type)` +> (=无限制恒真否则 `dwNeedWeapon & (1< `is_guild_skill`;旧 `is_attack` / `is_passive` / `is_toggle` / `is_ranged` / `can_level_up` +> 改为查位(`is_ranged` 现含 ARROW 位)。`skill_test.gd` +34 条 §3.8 断言。C++ **21/21** + +> 9 个 GDScript 回归全绿。留给增量 88+:`player_skill.gd` 移植 (a)(b)(c)(d) 三层校验、 +> `M2Client::use_skill()` 带符号 + 范围校验(§3.9)。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / C.5。 +> - **2026-09-03 增量 88(W2 / §3.8 `player_skill.gd` 三层合法性校验,修改 1)**:新建 +> `project/player_skill.gd`(`extends RefCounted`,纯校验器,不发包 / 不驱动动画,返回 +> `{"ok", "code", "target_vid"}`),逐行移植 `PythonPlayerSkill.cpp` 的四层:(a)`click_skill_slot()` +> 分派(`:106`)、(b)`can_use_skill()` 全局可用性(`:439`,硬编码骑乘术 vnum `109`:骑乘中且 +> `grade<1 && level<20` → 禁)、(c)`check_skill_usable()` 逐项前置(`:166` 完整顺序:骑乘⇄马术 +> `NOT_HORSE_SKILL` / `HAVE_TO_RIDE` → 安全区攻击技 `IN_SAFE` → 被动 → 空瓶 `27995` → 毒瓶 +> `27996` → 钓鱼 `REMOVE_FISHING_ROD` → 未学 `NOT_YET_LEARN` → 武器不匹配 `NOT_MATCHABLE_WEAPON` +> → 无箭 `EMPTY_ARROW` → dash / 蓄力 / 冷却 `WAIT_COOLTIME` → HP / SP)、(d)`use_skill()` 施放 +> (私人商店静默、`__CheckSpecialSkill` 钓鱼 `123` / 连击 `122`、切换技 `TOGGLE_OFF`、施法中 +> `ALREADY_CASTING`、目标矩阵 `_resolve_target()` 同盟 / 安全区 / 可攻击 / 尸体 → +> `CANNOT_USE_SELF` / `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` / `CANNOT_ATTACK` / +> `ONLY_FOR_CORPSE` / `NEED_TARGET`)。命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ +> `SKILL_TYPE_GUILD=3` / `RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / +> `ITEM_POISON_BOTTLE=27996` / `SKILL_INDEX_COMBO=122` / `SKILL_INDEX_FISHING=123` +> (`PythonPlayerModule.cpp:6-7`);`SILENT_CODES[]` 13 个无文案码 + `static is_silent_code()`。 +> 运行期状态尽量走 `client`(`get_main_vid` / `get_entity` / `get_skills` / `get_target` / +> `get_inventory`),其余经公有 context 字段由 `net_play.skill_context()` 每次注入(观察 / 骑乘 / +> 安全区 / 钓鱼 / 施法中 / 可行动 / dash / `weapon_sub_type` / `arrow_count`)。`ui/quickbar.gd` +> `activate()` 技能分支施放前跑三层,`TOGGLE_OFF` → 关闭切换技 + `skill_activated`,非静默码 → +> 新信号 `skill_rejected` → `game_scene` 转 `net_play.cannot_act.emit(code)`(HUD 走同一条 +> `OnCannotUseSkill` 文案通道),gate 解析出的 `target_vid` 覆盖点击目标。`__CheckShortMana` / +> `__CheckShortLife` 打桩恒 false(POC 无 `.msk` NeedSP/NeedHP 公式解析,留给增量 89)。 +> 新增 `project/player_skill_test.gd`(读真 `skilldesc.txt`,覆盖 (a)(b)(c)(d) 每条分支 + +> 目标矩阵 + `is_silent_code`)。C++ **21/21** + **12** 个 GDScript 回归全绿。留给增量 89+: +> 修改 3(`M2Client::use_skill()` 带符号 + 范围校验,§3.9)+ HP/SP 校验落地。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / C.5。 +> - **2026-09-03 增量 89(W2 / §3.8 修改 3 —— `use_skill` 边界收紧 + 短蓝短血校验落地,§3.8 三层全落地)**: +> ① `extension/src/net/net_bounds.h` 新增 `SKILL_MAX_NUM=255`(`Packet.h:2041`)+ +> `skill_index(v) = 0 < v < 255` 谓词;`extension/src/net/m2_client.cpp::use_skill(int skill_id, +> int target_vid)`(形参增量 52 起即带符号)的 `skill_id` 护栏从 `skill_id <= 0 || +> !bounds::u32()` 收紧成 `!mtnet::bounds::skill_index(skill_id)`,对齐 `ClickSkillSlot` 的 +> `slot >= SKILL_MAX_NUM` 门;`target_vid` 保留 `>= 0 && bounds::u32`;`cast_skill()` 的 +> `motion_idx ∈ [0,0x7f]` 不动(7 位 wire 位段)。`net_bounds_test.cpp` +4 断言。② `ui/skill_table.gd` +> `_load_cooltime` 更名 `_load_msk`,加解析 `.msk` `NeedSPFormula`;新增 `_SKILL_POWER[]` +> (`LocaleService_GetSkillPower` INTERNATIONAL 表,`Locale.cpp:126`,`level >= 50 → 0`)+ +> `_skill_point(level)` + `need_sp(id, level)`(≈ `GetNeedSP`,`PythonSkill.cpp:1426`;POC 约定: +> 无 `NeedSPFormula` 返回 `-1`,参考返回 0,避免误挡缺字段技能)+ `need_hp()`(照抄 +> `__CheckShortLife:359` 复用 `GetNeedSP` 的怪癖);`cooldown_of` 的 `SkillPoint` 也改走 +> `_skill_point()`(与参考同一份 `fcurEfficientPercentage`)。③ `project/player_skill.gd` 新增 +> `SKILL_INDEX_SUMMON=131`(`PythonPlayerModule.cpp:15`)+ 上下文字段 `cur_sp` / `cur_hp` +> (`-1` = 未知不判),`_check_short_mana` 逐条移植 `__CheckShortMana`(`:320`:summon `131` 豁免、 +> 非开关技 `need_sp == 0` → 坏数据拦、`can_use_if_not_enough` → `cur_sp <= 0` 拦、否则 +> `need_sp != -1 && need_sp > cur_sp` 拦),`_check_short_life` 移植 `__CheckShortLife`(`:354`: +> `!is_use_hp() → false` 放行、`need_hp > cur_hp` 拦;shipped `skilldesc.txt` 无 USE_HP 技能故 +> 对现有技能等价放行),二者转 `NOT_ENOUGH_SP` / `NOT_ENOUGH_HP`。④ `net_play.gd::skill_context()` +> 读 `client.get_points()` 的 `hp` / `sp` 填 `cur_hp` / `cur_sp`。⑤ `player_skill_test.gd` +> +“(c) §3.8 修改 3”段(`_skill_point(20)==0.5` / `need_sp(1,5)==52` / `need_hp==need_sp` / +> SP 不足→`NOT_ENOUGH_SP` / SP 未知→ok / 无公式技 + SP 0→不挡 / 非 USE_HP 技 + HP 1→不报), +> `_reset` 补 `cur_hp=-1` / `cur_sp=-1`。C++ **21/21** + **13** 个 GDScript 回归全绿。§3.8 +> 三层校验(修改 1/2/3)至此全部落地。留给增量 90+:§3.10 技能升级门(audit 10)、 +> `__ChangeTargetToPickedInstance` 拾取回退、扇形 / 圆形多目标 fly-targeting。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / §3.9 / C.5。 +> - **2026-09-03 增量 90(W2 / §3.10 技能升级门 —— 加号按钮门控 + `skill_up` 边界收紧)**: +> 审计条目 10 的门控空缺补齐。① `extension/src/net/wire.h` `EPointTypes` 加 +> `POINT_SUB_SKILL=27` / `POINT_SKILL=28` / `POINT_HORSE_SKILL=113`(`UserInterface/Packet.h`; +> 对应 `uicharacter.py` `skillPageStatDict`);`m2_client.cpp::get_points()` 新增 +> `skill_active` / `skill_support` / `skill_horse` 三键(镜像增量 85 的 `bow_distance`)。 +> ② `m2_client.cpp::skill_up(int skill_id)` 护栏 `skill_id >= POINT_COUNT` 收紧成 +> `!mtnet::bounds::skill_index(skill_id)`(= `0 < v < 255`,与 `use_skill` §3.9 同一谓词); +> 发包 `/skillup ` 不变。③ `project/ui/skill_table.gd`:`load_file` 补 `RegisterSkillDesc` +> 的 `LEVEL_LIMIT`(列 26)/ `MAX_LEVEL`(列 27)解析(`.msk` `maxlevel` 优先;shipped 22 列 +> 数据两列都缺,逻辑为兼容第三方数据);新增 `max_level_of(id)`(= `SSkillData::GetMaxLevel()`, +> `PythonSkill.cpp:1392`,缺值默认 20 = 构造默认 `byMaxLevel`,:1462)、`skill_level_limit(id)` +> (= `skillGetSkillLevelLimit`,`PythonSkill.cpp:1741` = `byLevelLimit`;**本 40250 数据里 +> skilldesc 无 `LEVEL_LIMIT` 列、`.msk` 无 `levellimit` 记号 → 恒 0**,马术页等级前置门恒真)、 +> `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,`PythonSkill.cpp:1833`;**本 build 里 +> `isRequirement` / `strRequireSkillName` / `RequireStatDataVector` 从不被填充(构造默认,:1471) +> → 1:1 化简为 `level < GetMaxLevel() && !(attr & CANNOT_LEVEL_UP)`**)。④ `project/ui/skill_ui.gd`: +> `refresh()` 里逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList`(:984)/ +> `__RefreshSkillPlusButton`(:937)/ `CanShowPlusButton`(:925)——`_can_use_skill_now()` +> (= `client.get_skill_group() != 0`,对应 `__CanUseSkillNow` :1270)、`_stat_point_for(page)` +> (`SUPPORT`→`skill_support` / `HORSE`→`skill_horse` / 其余→`skill_active`,三来源分开取)、 +> `_can_show_plus(id, level, grade, page, …)`(技能组门 → 辅助页 `HIDE_SUPPORT_SKILL_POINT` +> 整页隐 → `stat_point<=0` 隐 → `skillGrade!=0` 隐 → 马术页 `plv >= skill_level_limit(id)` 时 +> `level < 20` 才显 → 主动页 `table.can_level_up_skill(id, level)`);按钮仍在 `_rebuild()` 为 +> 非被动 / 非 `CANNOT_LEVEL_UP` 技能创建,`refresh()` 只切 `visible`(= `HideAllSlotButton()` + +> `ShowSlotButton()`);常量 `HIDE_SUPPORT_SKILL_POINT`(`uicharacter.py:21`,本资源为 True → +> 辅助页整页不出加号)/ `RIDING_SKILL_MAX_LEVEL=20`。升级结果仍由 `GC_SKILL_LEVEL` 经 +> `skills_changed` 驱动 `refresh()`,不本地伪造。⑤ `skill_test.gd` FakeClient 补 +> `get_skill_group()` / `get_points()`;扩 `§3.10` 段(`max_level_of` / `skill_level_limit` / +> `can_level_up_skill` 未满级 / 满级 / 超级 / `CANNOT_LEVEL_UP` / 未知 id + 加号显隐:主动页 +> 可见 / `skillGrade!=0` 隐 / `skill_group==0` 隐 / 该页点数 0 隐 / 辅助页整页隐 / 马术页 +> `skill_horse>0 && level<20` 可见 / 点数 0 隐)。C++ **21/21** + **13** 个 GDScript 回归全绿。 +> 留给增量 91+:`__ChangeTargetToPickedInstance` 拾取回退、扇形 / 圆形多目标 fly-targeting、 +> §3.6 飞行物实体(`CFlyingManager` / `FlyingInstance`)。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。 +> - **2026-09-03 增量 91(W2 / §3.4 其余三种预约模式 + `__ChangeTargetToPickedInstance` + 扇形 / 圆形补 fly-targeting)**: +> § 3.4 自增量 75 起留桩的 `MODE_CLICK_ITEM` / `MODE_CLICK_POSITION` / `MODE_USE_SKILL` 三个 +> 分派补齐。① `project/net_play.gd` `_refresh_reserved()` 由「只跑 CLICK_ACTOR」改为四路 +> `match`,逐行对齐 `NEW_RefreshMouseWalkingDirection`(`PythonPlayerInputMouse.cpp:287`): +> `_reserve_process_click_item()`(按 IID 查 `client.get_ground_items()` 世界坐标, +> `< 20cm`(`CLICK_ITEM_PICKUP_CM`)到手 → `_send_state(FUNC_WAIT)` + `client.pickup_item()` + +> 解冻 + `_clear_reserved()`,否则 `pc.walk_to()`;物品消失 → `_clear_reserved()`)、 +> `_reserve_process_click_position()`(`pc._can_translate()` 未锁且 `_reserved_delay <= 0` +> (`_process` 逐帧递减 ≈ `NEW_IsEmptyReservedDelayTime`)→ `pc.walk_to(_ppos_reserved)` + +> `_clear_reserved()`)、`_reserve_process_use_skill()`(`dist_cm` 含 `_is_huge_race` 的 +> `-200cm`(`HUGE_RACE_DIST_FIX_CM`)修正,`< _skill_range_reserved` → `set_target` + +> `use_skill_hook(slot)` 成功才 `_clear_reserved()`,否则 `pc.walk_to(tnode)`;目标丢失 → +> `_clear_reserved()`)。② 预约入口 `reserve_click_item(iid)` / `reserve_click_ground(pos)` +> (`_reserved_delay = 0.1`)/ `reserve_use_skill(vid, slot, range_cm)`(= `__ReserveUseSkill` +> `PythonPlayerInput.cpp:854`,`range > 100` 先减 `RESERVE_SKILL_RANGE_TRIM_CM`(10))/ +> `pick_ground_item(iid)`(= `__OnPressItem`——清预约 + auto-attack 归零,`CLICK_DIST_RESOURCE_CM` +> 内直接 `pickup_item` + 停,否则预约)。`_clear_reserved()` 追加复位 `_ppos_reserved` / +> `_skill_range_reserved`。③ `_on_pick()` 补 `__OnPressActor` 特例(`PythonPlayerInput.cpp:324`): +> `_reserved_mode == USE_SKILL && _dash_active` 时只改 `_vid_reserved` 不选中;战斗目标改走 +> `_change_target_to_picked_instance(vid)`(= `__ChangeTargetToPickedInstance` :186 → +> `SetTarget` :130——`IsTargetableInstance` 限定 PC / 怪,否则 `set_target(0)` + `clear_target`; +> NPC / 石头 / warp 仍由 `_on_pick` 先分流到 `click_npc`)。`_is_huge_race(race)`——`IS_HUGE_RACE` +> 的 vnum 白名单未随源码发布,改用 `proto.mob(race)` 的 `size >= MOBSIZE_HUGE(3)` 或 +> `rank >= BOSS(4)` 近似,查不到 → false(文档化 seam)。④ 扇形 / 圆形补 fly-targeting +> (`PythonPlayerSkill.cpp:680-746` `if (dwTargetMaxCount>0 …)` 块):`net_play.send_fly_targeting( +> target_vid, range_cm, max_count, shape)`——主目标 `CG_FLY_TARGETING` + `CG_USE_SKILL` 仍由 +> `M2Client::use_skill` 发,这里只补附加目标——`_instances_in_fan_range()`(= +> `NEW_GetInstanceVectorInFanRange` `InstanceBaseBattle.cpp:192`:随距离收窄半角 +> `(MAX-MIN) - RPM·min(dist,1000) + MIN`,`FAN_HALF_ROT_MIN=20` / `MAX=40` / `MIN_DISTANCE=1000`)/ +> `_instances_in_circle_range()`(:243,无方位过滤)内其它可攻击敌人经 +> `client.add_fly_targeting(vid, sx, sy)`(= `SendAddFlyTargetingPacket`)补齐;名额不足时 +> `_random_pos_in_fan_range()`(= `NEW_GetRandomPositionInFanRange` :124:目标方位 ±10° +> (`FAN_RANDOM_JITTER_DEG`)抖动、沿该方向 `FAN_RANDOM_REACH_CM`(8000cm) 取点)→ +> `add_fly_targeting(0, sx, sy)`。方位角在 Godot world 帧统一用 `_bearing_deg`(角差与坐标系无关)。 +> ⑤ `project/ui/skill_table.gd`:`_load_msk` 补 `.msk` `TargetCountFormula` / `Range` 解析 +> (`CMotionSkillData` 的 `strTargetCountFormula` / `dwTargetRange`);新增 +> `target_count(id, level)`(= `GetTargetCount(fSkillPoint)` `PythonSkill.cpp:1361`——空公式 → 0, +> 否则 `SkillPoint` 代入后 `floor`)、`target_range(id)`(= `__GetSkillTargetRange` 基础项 +> `PythonPlayerSkill.cpp:391`,弓距加成留 `net_play` 叠加)、`fly_shape(id)`(`FAN_RANGE`→1 / +> `CIRCLE_RANGE`→2 / 否则 0,对齐 `NetPlay.FlyShape`)。⑥ `project/ui/quickbar.gd`:`activate()` +> 里 `client.use_skill` 成功后,若 `target_vid != 0` 且技能为 `FAN_RANGE` / `CIRCLE_RANGE` → 调 +> `net_play.send_fly_targeting(...)`;新增 `_skill_level(sid)`(扫 `client.get_skills()`)、 +> `activate_reserved(global_slot)`(`use_skill_hook` 回调,`MODE_USE_SKILL` 进射程后执行槽位施法)。 +> ⑦ `project/game_scene.gd`:`net_play.use_skill_hook = func(slot): return +> quickbar.activate_reserved(slot)`。⑧ 测试:`netplay_test.gd` FakeClient 补 `get_entities` / +> `pickup_item` / `add_fly_targeting` / `get_ground_items` / `get_skills`,新增「5c」段 +> (CLICK_ITEM 远→`walk_to` / 到手→`pickup`+`FUNC_WAIT`+清、物品消失→清;CLICK_POSITION 延时门 + +> `walk_to`;USE_SKILL `range>100→-10`、射程外`walk_to`、射程内`hook`+`set_target`+清、目标丢失→清; +> `_change_target_to_picked_instance` NPC→清目标 / 怪→选中;`send_fly_targeting` FAN 2 侧翼入扇 + +> 背后排除、CIRCLE 背后计入、随机落点补齐、`max_count<=1` / `SINGLE` → 0);`skill_test.gd` 补 +> skill 47(gwangyeok,FAN + Range 2500 + `target_count(lv1)=2` / `(lv20)=5`)/ skill 91 +> (bipabu,FAN + Range 1800 + 无公式→0)/ skill 1(`fly_shape=SINGLE`)。C++ **21/21**(无 C++ +> 改动)+ **13** 个 GDScript 回归全绿。留给增量 92+:§3.6 飞行物实体(`CFlyingManager` / +> `FlyingInstance`)生成 / 命中、`__CanShot` 弓箭合法性。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.4 / C.5。 +> - **2026-09-03 增量 92(W2 / §3.6 飞行物实体 `CFlyingManager` / `FlyingInstance` 生成 / 命中 + `__CanShot` 弓箭合法性)**: +> ① 新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`),对齐 +> `GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget`、`Update()` 的 `while` +> 迭代 + `erase`、`CreateIndexedFly` 的 `NORMAL` / `FIRE_CRACKER` / `AUTO_FIRE` 分支常量)+ +> `FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`;`Update()` +> 逐帧 `vel += accel*dt` → `vel.z += gravity*dt` → `remain_range -= |movement|` → 线段-点 +> `square_distance_between_linesegment_and_point < m_fBombRange²` 命中 → `OnShootDamage`(本 POC +> 转 `shoot_damage(vid)` 信号)/ pierce `--` + `__Bomb` 续飞 / `__Explode` + `return false`; +> `m_fRemainRange<0` → `OnExplodingOutOfRange` + `__Explode(false)`(不 `__Bomb`);位置目标同 +> 线段判定;`m_bHitOnBackground` 的 `GetTerrainHeight(x,-y) > z`)+ `FlyingData.cpp __Initialize()` +> 默认值(米制换算)。线段判定 `_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`。 +> ② `project/net_world.gd`:`setup()` 挂 `FlyManager` 子节点并 `setup(parent, world)`,新增 +> `signal fly_exploded(world_pos, cause)`;重写 `_on_fly(type, start_vid, end_vid)`——原 tween +> 「发光球」占位换成 `_fly_mgr.spawn(start_w, target_node|goal_w, false, FlyData, type)` 真实 +> 弹道 + 线段命中(合成箭参数 `init_vel=45` / `flat_range=max(dist*1.5, 30)` / `bomb_range=0.6`—— +> 无 `.fly` 资源加载器)。③ `project/net_play.gd`:新增 `_can_shot(target_vid := -1) -> String` +> (`__CanShot` 在本发布源码树中 `grep` 无命中——据 `IFlyEventHandler::OnNoTarget` / `OnNoArrow` +> `FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 `IsDead` 门、`__GetBowRange` +> `InstanceBase.cpp:694` 防御式重建):非弓 → `"NOT_BOW_MODE"`;`_arrow_count == 0` → +> `"EMPTY_ARROW"`(`-1` = 未跟踪 / 无限,放行);`_target_vid` / `_vid_reserved` 均 ≤ 0 → +> `"NO_TARGET"`;目标实体已知且不可攻击 / 已死 → `"NO_TARGET"`(`te.is_empty()` 不拒——headless +> 容差,与 `_send_fly_target`「有节点即可」一致);目标世界距离 `* CM > _bow_range_cm()` → +> `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支据此加门:`_can_shot()` 非空 → `_emit_cannot(why)` +> (走 `OnNoArrow` / `OnNoTarget` 文案),不发 `CG_FLY_TARGETING`、不排 `OnShoot`;其余起手动作 +> 照常(seam:参考端整挥击中止)。④ 新增 `project/fly_test.gd`(第 14 个 GDScript 回归):直线 +> 命中对象目标 / 超程不 `__Bomb` / `can_attack` 触 `shoot_damage(vid)` / 穿透后存活续飞再超程 / +> 位置目标 / 触地 / `_can_shot` 六分支 / `_seg_point_sq_dist` 三段。C++ **21/21**(无 C++ 改动) +> + **14** 个 GDScript 回归全绿。已文档化 seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器 +> (仅 `NetStream.cpp` 头串索引 70),实际驱动走 POC 自有 `fly_cue`;`__CanShot` 防御式重建; +> 重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr` / 建筑体素判定未 +> 移植(客户端飞行物只做命中时机,伤害服务端裁决);`.fly` 数据文件加载器缺失 → 合成箭参数。 +> 留给增量 93+:§3.1 Actor-Actor 碰撞(`TestActorCollision` / `BlockMovement`)+ 坐骑 `MODE_HORSE*`。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.6 / C.5。 +> - **2026-09-03 增量 93(W2 / §3.1 收尾:Actor-Actor 碰撞 + 坐骑转向速度)**: +> ① `project/player_controller.gd` 新增 `_actor_blocked(next_pos)`——`CActorInstance::TestActorCollision` +> (`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing`(`InstanceBaseBattle.cpp:469`) +> 近似:距离门 `ACTOR_COLLIDE_MAX_DIST_M = 8.0 m`(≈ `LengthSq(victim - self) > 800²` 直接不检)→ +> body 球重叠(`ACTOR_BODY_RADIUS_M = 0.55`,自身 + 对方相加 = 判定阈值;固定半径近似真实 race +> 碰撞球)→「趋近」判定 `d_next < combined && d_next <= d_now`(对齐 `TestActorCollision` 末尾 +> `dist(cur) <= dist(last)` 才 `return TRUE`,远离时放行 → 能从重叠里走出来)。候选 = `pickables` +> (每个已生成的远端 NPC / 怪 / 他人节点),`dead` meta 跳过(≈ `rVictim.IsDead()`)。 +> ② `_process` 平移分支改逐轴门控:`block_x = _actor_blocked(仅动 X) or _blocked(...)`、`block_z` +> 同理——单个 Actor 逐轴放行 = 滑移(≈ `AdjustDynamicCollisionMovement`,该函数不在发布源码树), +> 两轴都挡 = 停在上一帧位(≈ `uCollisionCount==2` / 调整后仍撞 → `BlockMovement()` = `__InitializeMovement`); +> 随后既有 `disp` 判定把顶人磨蹭切回 `_is_going = false` + `wait`。③ `skip_actor_collision` +> (≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`,§3.2 网络状态包驱动的移动整段 +> 跳过)——`net_play` 在 `setup()` / `_on_observer_mode` 按 `_observer_mode` 置位。④ 坐骑转向速度: +> `player_controller` 新增 `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG = 1200` = `c_fDefaultRotationSpeed`) +> + `_turn_toward(target_yaw, dt)`(`SetAdvancingRotation` 的逐帧限幅收敛,移动方向不受限,仅朝向变慢), +> 替换原来两处 `player.rotation.y = atan2(...)` 瞬转;`net_play._refresh_motion_mode()` 在 `mounted` +> 时把 `rotation_speed_deg` 切到 `ROT_SPEED_HORSE_DEG = 300`(= `c_fDefaultHorseRotationSpeed`,对齐 +> `CInstanceBase::MountHorse` → `SetRotationSpeed`,`InstanceBase.cpp:954`),下马恢复 1200。坐骑动作 +> 模式(`MODE_HORSE*`)已由增量 84 的 `motion_mode_for` + `mount_changed` → `_refresh_motion_mode` +> 覆盖,本增量不重复。⑤ `project/player_move_test.gd` 5 组 → 8 组(正前方 Actor 挡下且 `is_going` +> 归零、`dead` Actor 不挡、`skip_actor_collision` 直穿、坐骑单帧转向 ≤ 300 deg/s 且仍朝 Dst 平移)。 +> C++ **21/21**(无 C++ 改动)+ **14** 个 GDScript 回归全绿。seam:真实 body sphere 半径需 race +> 碰撞数据(`.col` / `CollisionData.h`);`AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor +> 复合的 `uCollisionCount==2` 用逐轴近似代替;`EnableSkipCollision` 尚未接到 §3.2 网络状态包驱动的 +> 本地预测玩家;`MOVABLE_GROUND_DISTANCE` 参考默认值仍是估计(1.0 m)。留给增量 94+:§3.2 / §3.5 +> 收尾(多为待真实服务器);转 M2(W3 玩法)。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.1 / C.5。 +> - **2026-09-03 增量 94(W3→M2 / §5.4 PVP·PK·决斗可攻击判据 `IsAttackableInstance` 1:1)**: +> ① 新建 `project/entity_rules.gd`(纯静态 `extends RefCounted`)——`is_attackable_instance(self_e, +> victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`REF/UserInterface/InstanceBase.cpp:2147`): +> 主角 + 观战 → false;自打自 → false;`IsStone` / `IsPC` / `IsEnemy` / `IsPoly` 四分支。PC 分支内 +> victim.IsPC 时按序:决斗三态(`DUEL_CANNOTATTACK` → false / `DUEL_START` → `__FindDUELKey` ? true : false)、 +> `PK_MODE_GUILD` && 同公会 → false、`victim.IsKiller()` && !同队 → true、`PK_MODE_PROTECT != GetPKMode()` +> 下 `PK_MODE_FREE`(victim 非 PROTECT && !同队 → true)/ `PK_MODE_GUILD`(再加公会不同 → true)、 +> `IsSameEmpire` 时 `IsPVPInstance` → true / `PK_MODE_REVENGE` && !同队 && `IsConflictAlignmentInstance` +> → true,异帝国直接 → true;随后 `victim.IsEnemy()` / `victim.IsWoodenDoor()` → true。判据 helper 全 +> 1:1:`alignment_grade`(:502 九档)、`alignment_type`(:524)、`is_conflict_alignment_instance`(:2116)、 +> `_is_same_empire`(:470)、`_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)、`_find_duel_key`(:495)、 +> `_is_same_party`(`PythonPlayer.cpp:1445`)、`_is_wooden_door`(:2390,vnum 13000 / 30111..30119); +> `_is_pc` / `_is_enemy` / `_is_poly` 依 `CActorInstance`(`ActorInstance.cpp:270/282/298`)的 `m_eActorType` +> 单值语义——变身玩家仍是 `TYPE_PC`(`else if (IsPC())` 先命中,`IsPoly()` 分支只服务真正的 `TYPE_POLY`)。 +> ② `project/net_play.gd`:`_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼 `self_e` +> (`_main_entity()` 快照 + `kind`)/ `victim_e`(+ `kind`)/ 新 `_attack_ctx()`(从 `client.get_duel()` / +> `get_pvp_relations()` / `get_guild_wars()` / `get_party()` 装配 observer / pk_mode / duel_mode / +> duel_opponents / pvp_pairs / gvg_pairs / party_vids),委托 `EntityRules.is_attackable_instance`; +> `_main_entity()` 为空(headless)退化旧近似。5 处调用点签名不变。③ 新增 `project/entity_rules_test.gd` +> (第 15 个 canonical,25 组断言);`combat_fx_test` / `netplay_test` / `fly_test` 的怪物夹具补 +> `"ch_type": 2`(此前无 `ch_type` → `_entity_kind` 归 PC,旧桩按 `[0,2]` 也「可打」;1:1 规则下和平同 +> 帝国 PC 不可攻击 → 夹具须明确怪类别,与真实服务器一致)。C++ **21/21**(无 C++ 改动)+ **15** 个 +> GDScript 回归全绿。seam:`__FindPVPKey`(`GC_PVP` 关系表 setter)/ `__FindGVGKey`(公会战对局 setter)/ +> `IsGameMaster` 标记 / `IsBuilding`·`IsWall` 的 race 白名单(源码未随发布)/ m2dev 后端 `IsKiller` 状态位 / +> `_entity_kind` 无法分类时怪被当和平 PC(生产 mob_proto 恒加载);名字颜色 §8.8、`IsInSafe` 的 +> `ATTRIBUTE_BANPK` 地形属性仍未接。留给增量 95+:§5.1 quest 38 tag、§5.3 fishing/dungeon/mount/observer +> UI、§4.1 costume/belt。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.4 / C.5。 +> - **2026-09-03 增量 95(W3→M2 / §5.1 Quest EventManager `CPythonEventManager` 1:1)**: +> `project/ui/quest_dialog.gd` 由旧的单遍手写 `parse_script` 重写为 `EventTypeMap` + 状态机 + +> 分派:① `const EVENT_TYPE_MAP`(42 键,tag 字符串 → `EVT_*`)+ `enum { EVT_LETTER .. EVT_SELECT_ITEM }` +> (顺序 = `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+ `_get_script_event_index()` +> (= `GetScriptEventIndex` `PythonEventManager.cpp:1093`:前导 `/` → `EVENT_POSITION_END` + 剥离, +> 未知名字 → `ok=false` / 跳过、不进 `match`)。② `class EventSet extends RefCounted`(= `SEventSet` +> `:98`,字段名 1:1:`ix/iy/iWidth/iyLocal/isLock/lLastDelayTime/iCurrentLetter/CurrentColor/ +> strCurrentLine/isConfirmWait/iConfirmEndTime/DiffuseColor/lWaitingTime/iRestrictedCharacterCount=30/ +> iVisibleStartLine/iVisibleLineCount/iAdjustLine/isTextCenterMode/isWaitFlag/nAnswer`)+ +> `init_event_set()`(`__InitEventSet` `:72`,常量 `c_lNormal_Waiting_Time=10` / `c_fLine_Temp=16` / +> `BOX_VISIBLE_LINE_COUNT=5`)、`insert_line()`(`__InsertLine` `:976`)、`clear_line()`(`ClearLine` `:953`)。 +> ③ `_process_event_set(es, cmd)` = `ProcessEventSet()` `:377` 的 `switch` 逐 `case` 对拷:`DELAY` / +> `COLOR` / `COLOR256` 的 START vs `/END`、`LETTER` / `ITEM` / `MOB` 的 +> `iCurrentLetter >= iRestrictedCharacterCount → insert_line` 断行、`NEXT` / `DONE` / `INSERT_IMAGE` 的 +> `iAdjustLine += 2`、`WAIT`(`iyLocal=0; isLock=true`)、`QUESTION`(= `MakeQuestion` `:884`)、 +> `CLEAR` / `CLEAR_TEXT` 均走 `clear_line`、image family + `TITLE_IMAGE`、`ADDMAPSIGNAL` / +> `CLEARMAPSIGNAL` / `SETCMAPPOS`、`SETMSGPOS` / `ADJMSGPOS` → `pass`(引 `:562/:566` 空实现)、 +> `SLEEP`、`SET_CAMERA`(`_camera_setting` 九参 = `GetCameraSettingFromArgList` `:48`)/ `BLEND_CAMERA` +> (+`blendtime`)/ `RESTORE_CAMERA`、`FADE_*` / `WHITE_*` → `isWaitFlag=true`、`DUNGEON_RESULT` 9 参、 +> `QUESTBUTTON` / `QUESTBUTTON_CLOSE`、`WINDOW_SIZE`、`INPUT`、`CONFIRM_WAIT` / `END_CONFIRM_WAIT`、 +> `SELECT_ITEM`、`RUN_CINEMA` → `pass`(`ProcessEventSet` 无此 case)。④ `_tokenize()` 拆混排文本 / +> `[tag ...]`;裸网络文本折成合成 `LETTER`(`is_raw=true` → 跳过 30 字断行,reference 脚本只有显式 +> `[LETTER]`)。arg 提取 `_tag_kv()`(`key(v)` / `key("v")` / `key;v`)+ `_arg_s/_arg_i/_arg_f` +> (keyword 优先、位置回退,兼容旧测试 `value;255` / `value(1)` 写法)。⑤ `parse_script()` 返回字典 +> =旧契约全键不变,新增 `insert_images / dungeon_result / adjust_line / waiting_time / is_locked / +> wait_flag / answer_count / cinema_file / end_confirm`;首命令 `RUN_CINEMA`(`RegisterEventSetFromString` +> `:142` 特例,仅当 `cmds[0]` 即 `RUN_CINEMA`、无前导文本)→ `es.cinema_file` + 新 `signal +> cinema_requested`(`_emit_side_effects` 透出)。新增 `project/quest_event_test.gd`(第 16 个 canonical, +> 23 组断言);既有 `quest_test` / `select_item_ui_test` 仍绿。C++ **21/21**(无 C++ 改动)+ **18** 个 +> GDScript 回归全绿。seam:逐帧 `UpdateEventSet` `:312` 的时间推进(`lLastDelayTime` 递减 / `isWaitFlag` +> 暂停 / `isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip` / `EndEventProcess`、`iConfirmEndTime` +> 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton` NEXT vs DONE 文案区分 + +> `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` / `cinema_requested` 消费端与 +> §7.6 相机模式、game_scene 接线、物品名颜色 `(1.0,0.2,0.2)` 均未接(一次性跑完 token,`isLock` / +> `isWaitFlag` 仅作状态保留并透出)。留给增量 96+:§5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer +> UI、§4.1 costume/belt 窗口、§4.2 拖放鼠标物品态。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.1 / C.5。 +> - **2026-09-03 增量 96(W3→M2 / §4.1 wear 槽位版图 + costume/ring/belt 发包坐标 `GameType.h` 1:1)**: +> `project/ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 把装备格拆两路—— +> `c_Equipment_Start+0..10`(`EWearPositions` BODY..SHIELD,`WEAR_MAX_NUM=11`)走 `WINDOW_EQUIPMENT(2)` + +> 零基 wear cell;`ENABLE_COSTUME_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM` 的 +> `c_Costume_Slot_Start(90+19)` / `c_Costume_Slot_Hair(90+20)` / `c_Equipment_Ring1(90+21)` / +> `c_Equipment_Ring2(90+22)` / `c_Equipment_Belt(90+23)` 在客户端没有独立窗,继续用 +> `WINDOW_INVENTORY(1)` 的全局 cell 109..113——修掉了 `inventory_test.gd` 里 `_to_wire(113) == [1, 113]` +> 一直红的断言(旧实现返回 `[2, 23]`)。新增常量 `WEAR_MAX_NUM=11` / `WEAR_COSTUME_BODY=19` / +> `WEAR_COSTUME_HAIR=20` / `WEAR_RING1=21` / `WEAR_RING2=22`(`WEAR_BELT=23` 值不变)/ +> `NEW_EQUIP_WEAR_START=19` / `WINDOW_EQUIPMENT=2` 全部 1:1 自 `GameType.h` 与 `ItemData.h` +> `EWearPositions`。`_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 这 5 个真实新 +> 装备位 + 16 格腰带背包」(`EWearPositions` 11..18 无位置,`refresh()` 对空槽自动跳过)。 +> `inventory_test.gd` 增 7 条 wire 坐标断言(`_to_wire(90)→[2,0]` / `(100)→[2,10]` / `(109..112)→ +> [1,109..112]`);`inventory_ui_test.gd`(`move_to(5,91) → [1,5,2,1,1]`,基础 wear 仍走 window=2) +> 保持绿。C++ **21/21**(`net.loopback_flow` 首跑 localhost bind 抖动,`--rerun-failed` 即过;无 C++ +> 改动)+ **55** 个 GDScript 回归全绿。seam:§4.1 修改 2(`antiflag→wearable_flag→limit` 装备校验链 + +> `CANNOT_EQUIP_*` 文案)——40250 客户端不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 +> `IsWearableFlag` / `GetLimit`,仲裁在服务器 `char_item.cpp`),需按 §3.9 `__CanShot` 方式防御性重建; +> 修改 4(穿 costume 隐藏本体 armor / 穿头盔隐藏发型,`InstanceBase.cpp` `ChangeArmor` / `SetHair`)、 +> 修改 5(远端角色 `armor/weapon/hair` 三字段接换模)render 侧未接。留给增量 97+:§4.1 修改 2/4/5、 +> §5.2 任务日志窗口、§5.3 fishing/dungeon/mount/observer UI、§4.2 拖放鼠标物品态。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 +> - **2026-09-03 增量 97(W3→M2 / §4.1 修改 2 —— `CanEquipNow` 前置门 `ItemData.h` 1:1)**: +> 新增 `project/equip_rules.gd`(`extends RefCounted` 纯校验器),枚举 1:1 自 `REF/GameLib/ItemData.h` +> ——`EItemAntiFlag`(classic 排列:`FEMALE=1<<0`…`SHAMAN=1<<5`,帝国位 `EMPIRE_A/B/R=1<<9..1<<11`)、 +> `EItemWearableFlag`(`BODY=1<<0`…`ARROW=1<<9`)、`EWearPositions`(0..10)+ `GameType.h` 新装备槽 +> 19..23、`ELimitTypes`(`LIMIT_LEVEL=1`…`LIMIT_CON=5`、`PCBANG=6`、`REAL_TIME=7`)、`EItemType` +> (`WEAPON=1`/`ARMOR=2`/`COSTUME=28`/`RING=33`/`BELT=34`)+ `EArmorSubTypes`/`ECostumeSubTypes`。 +> `can_equip(item, wear, ctx) -> {ok, code, need}`,语义顺序对齐服务器 `char_item.cpp::CanEquipNow`: +> 目标槽解析(`wear<0`→`default_wear_for()`)→ anti-flag 性别(`ANTI_SEX`)→ 职业(`ANTI_JOB`, +> `JOB_ANTIFLAG[race%4]`)→ 帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()`(新槽按 item +> type、旧 11 槽按 `wear_flags & wear_to_wearable(wear)`,`NOT_WEARABLE_HERE`)→ limit 逐条 +> (`LIMIT_LEVEL`:`ctx.level=0 且 `SEX_BY_RACE` / `job_of_race`=`race%4` / `sex_of_race` 表 1:1 自 `MAIN_RACE_*`。 +> `inventory_ui.gd.use()` 对「常规背包格 + 可穿戴物品」跑此门,拦下则 `_set_hint()` + 新 +> `signal equip_rejected(code, need)` + 不发 `use_item`;新增公开 `can_equip_item()`、`_equip_ctx()` +> (`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` 未由 M2Client 按名 +> 暴露 → 传 `-1` = seam)、`_equip_reject_hint()` 中文文案。新增 canonical 测试 #17 +> `project/equip_rules_test.gd`(33 断言);`inventory_ui_test.gd` 的 `FakeProto.item()` 补 +> `sub_type/anti_flags/wear_flags/limits` 让 `iv.use(5)` 仍过门。C++ **21/21**(无 C++ 改动)+ **56** +> 个 GDScript 回归全绿。seam:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / +> 属性 4 项按名读取留待接服务器比对;§4.1 修改 4(部位隐藏遮挡)+ 修改 5(远端换装接字段)为 render +> 侧,留给增量 98+。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 +> - **2026-09-03 增量 98(W3→M2 / §4.1 修改 4 —— 变身 / 时装部位遮挡 `InstanceBase.cpp` 1:1 + 修改 5 字段序确认)**: +> 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 +> `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`,`SetWeapon`/`SetParts` +> 提前 return)、`ANIMAL_WEAR_SHAPES=[100,101,102,103]`(`__IsShapeAnimalWear`:2834 → `SetWeapon`:2747 / +> `SetParts` 返回 false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、`BOUQUET_VNUMS=[50201..50204]` +> (`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外一律 false)。函数 `is_animal_wear` / `is_wearing_dress` +> / `is_bouquet` / `is_changable_weapon`(婚纱→仅捧花)/ `weapon_hidden`(变身 ∨ 动物时装 ∨ 婚纱非捧花)/ +> `hair_hidden`(变身 ∨ 动物时装;婚纱不影响头发)/ `body_shape`(变身→0,动物时装 100-103 保留)/ +> `effective_weapon`。`equip_model.gd.refresh()` 刷新序改为对齐 `ChangeArmor`(`SetArmor→SetHair→SetWeapon`) +> = 身体→头发→武器→盾;解出身体 shape(`armor_shape_of`=`__ArmorVnumToShape` 的 `values[3]`/`=vnum`) +> 后过 `PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`,婚纱非捧花隐藏武器+盾、 +> 头发保留;新增 `_is_poly()` **seam**(40250 实体表无变身字段,恒 `false`)。`net_world._apply_field_updates()` +> 确认 `set_armor(parts[0])→set_weapon(parts[1])→set_head(parts[2])→set_hair(parts[3])` 字段序已 1:1 于 +> `NetworkActorManager.cpp:473`(增量 46 落地),补注遮挡走模型侧 `part_hiding.gd`。新增 canonical 测试 +> #18 `project/part_hiding_test.gd`(45 断言)+ `equip_model_test.gd` 补 7 段遮挡断言。C++ **21/21** +> (无 C++ 改动)+ **57** 个 GDScript 回归全绿。seam:变身态(等 `GC_CHARACTER_UPDATE` 带变身 race); +> 远端 PC 真模型工厂已在增量 99 首版落地(`RemotePlayerView` + 只消费 `awPart[4]` 的远端 +> `EquipModel`),所有职业 / race、远端真服换装和精确视觉仍待验收。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 +> - **2026-09-03 增量 101(W3→M2 / §5.2 Quest 日志窗口)**:按参考端 `QUEST_MAX_NUM=5` 固定渲染 +> 5 行,超过 5 条显示滚动条并映射 `questShowingStartIndex`;空 icon 使用 +> `season1/icon/scroll_open.tga`,任务行点击发送 `-2147483648 + questIndex`;`quest_test.gd` +> 已覆盖 7 条任务、首尾滚动和事件参数。多列定位、世界箭头、屏幕边缘指示、完整 `.sub` 视觉 +> 和真实任务生命周期仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.2 / C.5。 +> - **2026-09-03 增量 102(W3→M2 / §5.3 钓鱼输入状态机)**:钓鱼模式攻击键改发 +> `CG_FISHING` 方向并阻断普通攻击;方向键边沿、地面点击接入 `NEW_CancelFishing`,地面点击 +> 先清预约 / 自动攻击再取消,取消保留 500ms 节流;快捷栏特殊技能 123 接入钓鱼入口; +> `GC_FISHING` 五类事件维护钓鱼意图状态。`netplay_test`、`player_move_test`、`skill_test`、 +> `gamescene_test`、`combat_fx_test` 全部 PASS。尚缺 `GetFishingRot` 水面像素扫描、 +> wrong-place 文案、鱼获物品落袋及副本 / 坐骑 / 观战者完整玩法。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 +> - **2026-09-03 增量 103(W3→M2 / §5.3 GetFishingRot 水面扫描)**:新增 +> `Metin2World.get_fishing_rotation()` / `can_fishing_position()`,读取已加载区块的 +> `ATTRIBUTE_WATER`,按参考端前方 600cm、左右 0..180° 每 10° 扫描并返回选中 heading; +> `NetWorld` 将地图层查询桥接给 `NetPlay`,无地图 provider 时保留协议 fallback。扩展重新编译通过, +> `fishing_water_test.gd` 真实 A1 20 区块回归 PASS。仍缺 wrong-place locale 文案、鱼获 item +> proto / inventory 落袋和副本 / 坐骑 / 观战者完整玩法。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 +> - **2026-09-03 增量 104(W3→M2 / §5.3 钓鱼消息分支)**:`ChatUI` 加载 locale_game.txt, +> 按 `RecvFishing` 的状态与 item proto 类型分派 `FISHING_NOTIFY1/2`、`FISHING_SUCCESS1/2`、 +> `FISHING_FAILURE`、`FISHING_UNKNOWN` 和 `FISHING_WRONG_PLACE`;`SUCCESS` 只作为动画事件, +> 不再误报“鱼钩命中”。`chat_test.gd`、`gamescene_test.gd` PASS。真正物品数量仍等待 +> 独立 `GC_ITEM_*` 包验证,副本 / 坐骑 / 观战者完整玩法仍未完成。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 +> - **2026-09-03 增量 105(W3→M2 / §5.3 坐骑攻击等级门)**:按 +> `40250/ClientVS22/source/UserInterface/InstanceBase.cpp:124-216` 与 +> `InstanceBaseBattle.cpp:623-628` 接入坐骑 vnum→level 映射和 `SHORSE::CanAttack`(level 1 禁攻); +> 按 `PythonPlayerInput.cpp:737-764` 接入新坐骑 `GetSkillGrade(109) < 1 && GetSkillLevel(109) < 11` +> 门禁。`net_play.gd` 的自动攻击改用 `mount_vnum`,`netplay_test.gd` PASS。坐骑真实模型 / +> `MODE_HORSE*` 动作 / 移速及真服坐骑数据仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) +> §5.3 / C.5。 +> - **2026-09-03 增量 106(W3→M2 / §5.3 观战者小地图插值)**:按 +> `40250/ClientVS22/source/UserInterface/PythonMiniMap.cpp:16-64,168-180` 接入 +> `GC_OBSERVER_ADD/REMOVE/MOVE` 的独立轨迹和 1 秒线性插值;新增初始快照播种和 `world_reset` +> 清理。`p9_test.gd` 覆盖 ADD / MOVE / REMOVE / reset 并 PASS。观战自由视角、真服完整生命周期 +> 和副本 / 坐骑剩余项仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 +> - **2026-09-03 增量 107(W3→M2 / §5.3 副本目的地状态)**:按 +> `40250/ClientVS22/source/UserInterface/PythonNetworkStreamPhaseGame.cpp:3875-3901` 与 +> `PythonPlayer.cpp:147-167,1255-1285` 接入 `GC_DUNGEON` 目的地状态:保存全局厘米坐标、 +> 收包立即提示、按 Manhattan `<10000cm` 清除目标、每 20 秒重新提示,并在 `world_reset` 清理。 +> `hud.gd` 增加目标距离 / 朝向提示;`TIME_ATTACK_START` 保持参考端空分支,不虚构本地计时器。 +> `dungeon_state_test.gd`、`gamescene_test.gd`、`p9_test.gd`、`netplay_test.gd` PASS。DungeonBlock +> `.mdatr` 碰撞、真实副本入口 / 结算窗和真服验收仍待完成。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §5.3 / C.5。 > - **2026-09-02 增量 46(W0/W1 公共基础第一批实现)**:Classic 角色槽已保存 `lAddr/wPort`, > 新增 `M2Client.enter_game(index)` 并接入 ClientVS22 DirectEnter 的第二次游戏连接入口; > 选人 / 登录 / 进场发送结果已透传,未知 header 不再 Loading 启发式重同步,已知未处理包不再 @@ -119,7 +933,7 @@ > §1.5:`ClassicSession::send_client_version()` 改为 locale 分支——EUROPE(本项目 locale)发 > `CG_CLIENT_VERSION2`(header `0xf1`,`CGClientVersion2` 67B,timestamp 固定 `"1215955205"`), > 非 EUROPE 发 `CG_CLIENT_VERSION`(`0xfd`)+ CMake 注入的 `MT_BUILD_TIMESTAMP`(C `__TIMESTAMP__` -> 形态);新增 `set_executable_name()`(默认 `"metin2.bin"`,`// TODO(W0)` 由 M2Client 传 +> 形态);新增 `set_executable_name()`(默认 `"metin2.bin"`,已由 M2Client 传入 > `OS::get_executable_path().get_file()`);版本包只在 `send_fixed()` 成功后置 `m_version_sent`。 > `wire_classic.h` 补 `CGClientVersion2` 结构 + 尺寸表项。§1.10:新增 `extension/src/net/text_codec.h` > —— `to_wire()/from_wire_str()` 做 UTF-8 安全的按字节裁剪(不切多字节序列,硬字节上限), @@ -131,8 +945,8 @@ > §2.2:`ClassicParser` `GC_CHARACTER_ADD` —— `is_invisible_race`(20025/20038/20039)直接丢弃; > `bType==PC||NPC` 仅把裸 `Entity`(vid/race/type/坐标/朝向/速度/state·affect flag)暂存进 > `m_pending_actor`,不碰世界模型;其余(怪 / 石 / 门 / 物件)即时 `mut_spawn()`,名字取自 -> `root/npclist.txt`(race→code,新增 `npc_names.h` + `set_npclist_path()` setter,`$MT_ASSETS`/ -> `$M2_ASSETS` 回退,`// TODO(W0)` 由 M2Client 传资源根,无表时名字为空)。`GC_CHAR_ADDITIONAL_INFO` +> `root/npclist.txt`(race→code,新增 `npc_names.h` + `set_npclist_path()` setter,M2Client 已按 +> `MT_ASSETS` 传入资源根,未配置时回退 `$M2_ASSETS`,无表时名字为空)。`GC_CHAR_ADDITIONAL_INFO` > —— 无对应暂存则写 `m_last_error` + `m_unhandled_headers` 并丢弃(绝不半创建);命中则把暂存 > `Entity` 与附加信息 9 字段(name/guild/level/alignment/pk_mode/empire/parts[armor/weapon/hair]/ > mount_vnum)合并为一条,`mut_spawn_full()` 落地后 `erase` 暂存项。新增 `pending_actor_count()` @@ -178,8 +992,8 @@ > `system_menu_ui_test` / `gamescene_test` / `ui_test`(附加 `system_option_ui_test` / > `char_status_ui_test`)全绿;本 worktree 无 `build/`,未跑 C++ `ctest`。视觉 / `.sub` 贴图 > 与真服未验证。 -> - **2026-09-02 增量 50(Phase 1 W4:地图 BGM / 音频 / 生命周期)**:落地 -> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.1 §9.2,§9.3 仅确认设计。§9.1:`bgm_director.gd` +> - **2026-09-02 增量 50(Phase 1 W4:地图 BGM / 音频)**:落地 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.1 §9.2。§9.1:`bgm_director.gd` > 补齐「地图 → 曲目」解析——服务端 `bgm_changed(name, volume)` 名非空即播;名为空回落 > `musicInfo.fieldMusic`(`METIN2THEMA = "M2BG.mp3"`);`volume < 0`(服务端未下发, > `GC_MAIN_CHARACTER3_BGM`)用默认 `1/5*0.1 = 0.02`,否则用服务端值;应用音量在 @@ -190,10 +1004,155 @@ > `save_volume() / restore_volume()`(字段名对齐参考 `m_fBackupMusicVolume / > m_fBackupSoundVolume`)与 `set_music_volume()`;`app_lifecycle.gd` 进后台改为 > `save_volume()` + 静音(不再 `stop_bgm()`),回前台 `restore_volume()`,`_bgm_name` -> 全程不清空,同一首继续播。§9.3:`AppLifecycle` 目前由 `GameScene` 自建、`M2Client` -> 自带 `NOTIFICATION_*`;目标设计(`AppFlow` 单一属主、`M2Client` 只留 `suspend/resume`) -> 已确认,落地推迟到 Phase 2 / W0。新增 `bgm_test.gd`;`bgm_test`、`gamescene_test`、 -> `netplay_test`、`p9_test` 全绿;无 C++ 改动。真服 BGM 验收待 40250 服务端。 +> 全程不清空,同一首继续播。新增 `bgm_test.gd`;`bgm_test`、`gamescene_test`、`netplay_test`、 +> `p9_test` 全绿;真服 BGM 验收待 40250 服务端。§9.3 后续由增量 51 单独落地。 +> - **2026-09-02 增量 52(W0-W2 / §3.9 公共网络 API 参数边界)**:新增 `extension/src/net/net_bounds.h` +> 统一封装 `u8/u16/u32/i32`、36 快捷栏槽、12 交易槽、24 Cube 槽、39 私人商店项、15 龙魂精炼格等 +> 已核实边界;`M2Client` 在窄类型转换前校验连接端口、角色创建、坐标 / VID、动态文本包长度、聊天 / +> 技能、社交、公会、商店 / 交易 / 仓库 / 私人商店 / Cube、精炼、物品与会徽参数,超限直接返回 `false`, +> 不再静默截断或把负数转为无符号值。新增 `net_bounds_test.cpp`,C++ `ctest` 18/18,通过相关 Godot +> 离线回归;真实 40250 业务合法性仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.9 / C.5。 +> - **2026-09-02 增量 53(W3/W4/W5 / §4.8 §6.3 §8.6 §10)**:W3 将快捷栏改为参考端的服务器 +> 36 槽 / 客户端 4 页 × 8 格,类型改为 `INVENTORY=1/SKILL=2/EMOTION=3`,表情槽调用 +> `send_emoticon()`;补齐 LSHIFT 翻页、F1–F4、Ctrl/Command+数字、空格按住攻击、Alt 显示名字、 +> 相机按住 / 释放、截图、帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天等键位入口。W4 的 `.mse` 粒子现在 +> 按参考 `EmitterShape` 读取位置、形状、方向 / 速度,并通过 `UiAssets` + 原生 DDS 解码加载首个 +> `TextureFiles`;`.mde` 等仍明确保留为未完成项。W5 将 `assets` / `bgm` 自包含复制进 App,支持 +> `MT_MAC_ARCHES=universal` 与 `lipo` 门禁、嵌套扩展签名顺序,移除发布代码中的硬编码凭据。离线 +> `fx_test`、`input_key_test`、`skill_test`、`netplay_test`、`netbridge_test`、`gamescene_test` 全绿; +> 2.2G 完整导出、当前机器双架构产物、正式证书 / notarization 和真服 / 真机仍未验证。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.8 / §6.3 / §8.6 / §10 / C.5。 +> - **2026-09-02 增量 54(W3 / §4.2 §4.9)**:新增 `cursor_manager.gd` 和 `mouse_controller.gd`, +> 对齐 `mousemodule.py` 的全局物品拖放边界:背包物品可拖到其他背包格或快捷栏,释放到无效区域、 +> 右键、ESC、失焦均取消;快捷栏物品槽类型和装备窗口 wire 坐标保持原语义。`UiManager` 增加窗口 +> 键盘焦点屏蔽,`PlayerController` / `GroundItems` / `NetWorld` 接入 Talk / Attack / Pick 等光标 +> 状态。新增 `mouse_controller_test.gd`,并重跑 `inventory_ui_test`、`input_key_test`、 +> `gamescene_test` 全绿。CJK IME 候选窗、复杂窗口逐项 cursor 资源和真服 / 真机仍未验证。 +> - **2026-09-02 增量 55(W4 / §9.4 MilesLib 音频子集)**:`project/audio.gd` 对齐 +> `CSoundManager` 的 `SOUND_SCALE=200`、环境音 `1000`、监听器位置 / 方向、世界坐标相对监听器 +> 缩放、`PlaySound3D` / `PlayAmbienceSound3D` / `PlayCharacterSound3D`、`SetSoundVolume3D` / +> `StopSound3D` / `StopAllSound3D`、`r<0.1 ? r : pow(10,r-1)` 音量曲线、角色声音 5000cm 距离和 +> 0.3 秒同文件限频;`.msa` 本地动作声音已走角色声音入口,GameScene 每帧更新监听器。新增 +> `audio_miles_test.gd`,回归通过。`.mss` 解析、真实声音节点数据和真机 / 真服仍未完成。 +> - **2026-09-02 增量 56(W3 / §4.2 §4.4 §4.5 §4.6 §4.7)**:仓库接入 5×9 网格分页、指定格存取和 +> 仓库内移动;商城接入分页网格与商城→背包;交易接入 12 个指定投放格;NPC 商店接入 BUY/SELL +> toggle、出售投放区和 BUY/SELL 光标联动;私人商店 40 格接入全局物品态。新增 +> `complex_item_drop_test.gd`,覆盖仓库 / 商城 / 交易 / 商店落点,并确认购买模式拒绝出售投放; +> 回归通过。金钱附着、stack split、anti-flag / 售价确认、完整交易事务、真实 `.sub` 资源和真服 / 真机 +> 仍未完成。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.2 / §4.4 / §4.6 / C.5。 +> - **2026-09-02 增量 60(W1/W3 / §4.5 §5.1 §5.2)**:交易协议状态补齐 `GC_EXCHANGE` 的 +> `sockets[3]` / `attrs[7]`,m2dev / Classic 两条解析路径、`EntityStore::ExchangeSlot` 和 +> `M2Client.get_exchange()` 保持同一实例数据;`quest_log.gd` 接入任务 icon(资源存在时显示)和 +> 按参考格式递减的分钟 / 秒倒计时;`quest_dialog.gd` 识别 `COLOR/COLOR256`、`ITEM/MOB`、 +> `WINDOW_SIZE`、居中、`CONFIRM_WAIT`、图片元数据和 `DELAY/WAIT`。新增交易字段断言与任务日志 +> 回归;C++ 构建、受影响 Godot 测试通过。完整交易事务、38 项逐事件 EventManager、真实资源和 +> 真服 / 真机仍待验收。 +> - **2026-09-02 增量 65(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/SimpleLightData.cpp` / +> `SimpleLightInstance.cpp` 接入 `.mse` Light 组:解析 `MaxRange`、ambient/diffuse、三项衰减、 +> duration、loop / loop count 和 `TimeEventRange`,在 `effect_player.gd` 中生成 `OmniLight3D`, +> 按参考范围曲线和生命周期更新位置、可见性和范围。粒子多贴图接入 `TexAniDelay` 及顺 / 逆序, +> `RotationType` 区分 `NONE / TIME_EVENT / CW / CCW`。`fx_test` 增加 Light 组范围 / 生命周期 +> 断言并通过;随机起始帧、每粒子随机语义、完整 MovingType / 时间轴、SpeedTree、`.msenv` 和 +> 真机视觉仍未完成。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 +> - **2026-09-02 增量 66(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/EffectElementBase.cpp:5-60` +> 接入效果元素位置时间曲线:直接段按前后关键帧线性插值,`MOVING_TYPE_BEZIER_CURVE` 按 +> `p0 + controlPoint` 做二次 Bezier,边界时间按参考端钳制;粒子、`.mde` 网格和 SimpleLight +> 都消费同一位置曲线。`EmitterEmitFromEdgeFlag` 的球体映射到 Godot 球面发射,并保存 +> `EmitterAdvancedType` / edge 元数据;新增直接段 / Bezier / sphere-edge 断言,`fx_test` 通过。 +> 随机起始帧、每粒子随机方向 / 帧语义、完整 emitter advanced / cycle、完整时间轴和真机视觉仍未完成。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 +> - **2026-09-02 增量 67(W4 / §9.4)**:按 `40250/ClientVS22/source/GameLib/Area.cpp` 与 +> `MapType.cpp` 接入地图 `AreaAmbienceData` 和 `.pra`:位置 / 范围 / 播放类型 / 间隔 / 声音向量 +> 由 C++ 世界层提供,且与对象渲染、splat 开关解耦;`audio.gd` 对齐 LOOP / ONCE / STEP 的严格 +> 范围判定、距离音量、进入边沿播放和离开区域停止;修正 Miles 3D 坐标的厘米换算。新增真实 +> A3 地图 `ambience_test`,并通过 C++ 构建、`audio_miles_test`、`fx_test` 回归。`.msenv` 的 +> 云层 / 滤色 / 双方向光 / 风参数 / 昼夜天气联动、声音资源逐项听感和真机验收仍未完成。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.4 / C.5。 +> - **2026-09-02 增量 72(W4 / §7.5)**:按 `40250/ClientVS22/source/EterLib/SkyBox.cpp:552-557,869-927` +> 补上 texture-mode 天空与云层的组合:有限水平云面按 `CloudScale` / `CloudHeight` 投影, +> `CloudTextureScale` / `CloudSpeed` 映射到 Godot sky shader 的 UV 缩放 / 秒级滚动,并保留 +> ClientVS22 的 `MODULATEINVALPHA_ADDCOLOR` + `ONE/INVSRCCOLOR` 颜色合成;真实 DawnMistWood +> 环境验证通过。云层渐变过渡、滤色、风、lens flare、昼夜联动和真机视觉仍待完成。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 +> - **2026-09-02 增量 70(W4 / §7.5)**:按 `40250/ClientVS22/source/EterLib/SkyBox.cpp:483-542` +> 对齐 texture mode 的六面顺序和 UV,`environment_builder.cpp` 用 Godot `sky` shader 接入 +> `Front/Back/Left/Right/Top/BottomFaceFileName`;真实 CapeDragonHead 的五个非空面可解析并渲染, +> 空 bottom 面回退 `.msenv` 渐变。新增 `skybox_test.gd`,验证五面资源、空面回退和 shader 安装。 +> 纹理天空与云层同时存在时的叠加、云层 UV / 高度动画、滤色、风、lens flare 和昼夜联动仍待实现; +> 后续增量 72 已补上前两项的首版 shader 映射。 +> 详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 +> - **2026-09-02 增量 69(W4 / §7.5)**:按 `40250/ClientVS22/source/GameLib/MapOutdoor.cpp:246-264` +> 对齐背景 / 角色两套方向光:`Sun` 只照 layer 1 静态场景,`CharacterLight` 只照 layer 2 的 +> `Metin2Model`、装备和 LOD;换图清理两盏光。`environment_test`、`player_motion_test` 和 C++ 构建通过。 +> SpeedTree 风参数、滤色、lens flare、昼夜联动和真机视觉仍未完成。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 +> - **2026-09-02 增量 68(W4 / §7.5)**:按 `40250/ClientVS22/source/GameLib/MapUtil.cpp` 与 +> `MapType.cpp` 补齐 `.msenv` 的参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、六面 +> 天空文件名和 `Reserved` 字段;`environment_builder.cpp` 通过 `AssetResolver` 解析真实资源, +> 将云层贴图接入 `ProceduralSkyMaterial.sky_cover`,并把 Godot 暂无等价渲染载体的原始参数保存 +> 到 `WorldEnvironment` 元数据。同步修复已解析环境文件路径被强制转小写导致的 macOS 加载风险。 +> `formats_map_test`(内联 + 真实 A1)、`environment_test`、`world_probe` 和 C++ / Godot 回归通过。 +> 六面天空、云层 UV / 高度动画、滤色实际合成、双方向光分层、风参数、lens flare 和昼夜天气联动 +> 在该增量记录时仍未宣称完成;后续增量 69/70/71/72 已分别补上双光层、纹理天空首版、参考风缺省和 +> 纹理天空云层组合首版。 +> 详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §7.5 / C.5。 +> - **2026-09-02 增量 64(W4 / §6.3)**:按 `40250/ClientVS22/source/EffectLib/EffectMesh.cpp` +> 实现 `.mde` 的 `EffectData` v1 / `MDEData002` v2 读取:校验几何体 / 帧 / 数量边界,读取每帧 +> visibility 与顶点、索引、UV,按参考展开 `TPTVertex`、翻转 UV,并将厘米坐标换为 Godot 米坐标。 +> `effect_player.gd` 改为构建真实 `ArrayMesh`,播放几何帧,接入 MeshElement 的 billboard、混合、 +> ColorFactor / TimeEventAlpha 和 `.ifl` 贴图帧;新增 `project/fx/mde.gd` 与真实 `geompung.mde` +> 解析 / 构建断言。`fx_test` 通过,并修复了真实 `.mse` 触发的 Gradient 单点移除运行时错误。 +> SimpleLight、TexAni 完整帧语义、MovingType / 骨骼挂点和资源逐项视觉 / 真机验收仍未完成。详见 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §6.3 / C.5。 +> - **2026-09-02 增量 63(W4 / §9.4)**:按 `40250/ClientVS22/source/MilesLib/Type.cpp:17-74,118-136` +> 实现 `.mss` 的 `SoundDataCount` / `SoundData%02d` 解析和 `DWORD(time / (1/60))` 帧换算; +> 修正 `sound/` 虚拟前缀到各 patch `sound` 根的查找,`audio.gd` 提供 `load_mss_for_motion()` / +> `update_sound_instances_3d()`,PlayerView / MobView 在动作帧进入时播放角色声音。新增 `.mss` +> 解析、虚拟路径、60 FPS 换算和玩家 / 怪物动作配对断言;`audio_miles_test`、`player_motion_test`、 +> `mob_view_test` 通过。SimpleLight、TexAni、SpeedTree、`.msenv`、完整音频资源和真机 / +> 真服听感仍待验收。 +> - **2026-09-02 增量 62(W3 / §4.3)**:按 `uitooltip.py` 的技能书、遗忘书、变身道具分支,把 +> `item_tooltip.gd` 的 `sockets[0]` 接到本地技能名 / 怪物原型名,并对限制类型 7/8/9 对应的 +> 结束时间或持续时长生成 +> 剩余分钟 / 秒提示;新增技能书、变身和限时提示断言,`item_tooltip_test` 通过。CJK 文案 / +> 字体切换、未覆盖的其他特殊分支、装备对比、真实资源和真服 / 真机仍待验收。 +> - **2026-09-02 增量 61(W3 / §5.1 §5.2 §9.5)**:按 `PythonEventManager.cpp:554-614` / `579-602` +> 接入任务脚本的 `ADDMAPSIGNAL`、`CLEARMAPSIGNAL`、`SETCMAPPOS`、`QUESTBUTTON`、 +> `QUESTBUTTON_CLOSE` side effect;任务日志增加独立的任务信件条带,点击后发送已有的 +> `script_button(index)`,对话打开期间按参考隐藏条带,关闭后恢复。`Minimap` 保存并绘制任务信号点, +> `AtlasUI` 接入原版 screen-space center adjust;`IMAGE` / `LEFTIMAGE` / `TOPIMAGE` / `BGIMAGE` / +> `TITLE_IMAGE` 在资源存在时实际创建图片节点,缺资源安全跳过。新增 quest 事件 / 条带断言并重跑 +> `quest_test`、`p9_test`、`gamescene_test`,均通过。完整 EventSet 逐事件等待 / 淡入淡出 / 相机 / +> 电影、真实 `.sub` 资源和真服 / 真机仍待验收。 +> - **2026-09-02 增量 59(W3 / §4.6)**:`shop_ui.gd` 按参考 `shopdialog.py` 改为 5×8、40 格 +> 货位,空位保持不可用,已放货物显示名称 / 数量并接入统一 tooltip;多货架仍按 +> `tabIdx * SHOP_SLOT_COUNT + slot` 计算全局槽号。BUY 模式点击非空货位进入确认,确认后固定 +> 发送数量 1,SELL 模式继续只接受出售投放。`p8_test` 已更新并通过;真实 `.sub` 槽位 / 按钮 +> 贴图、图标和非金币货币显示仍待完成。 +> - **2026-09-02 增量 58(W1/W3 / §4.2 §4.3)**:按 40250 `GameLib/ItemData.h` 的 236 字节 +> `TItemTable` 布局补齐 `limits[2]`、`applies[3]`、`values[6]`、`sockets[3]`、精炼关联、 +> 魔法属性概率、球面高光和开孔概率字段;`proto_item_layout_test` 锁定字段偏移。新增统一 +> `project/ui/item_tooltip.gd`,解析 `itemdesc.txt`,并接入背包、装备查看、NPC 商店、仓库、 +> 商城、交易、私人商店,生成基础数值 / 限制 / applies / 实例 attributes / sockets / refine 提示。 +> `item_tooltip_test`、库存回归和 C++ 构建通过。鱼竿 / 镐经验、龙魂计时、技能书 / 变身等特殊 +> 分支,CJK 文案 / 字体、装备对比、真实资源和真服 / 真机仍未完成。 +> - **2026-09-02 增量 57(W3 / §4.2 §4.5 §4.6 §4.7 + W1 item 实例字段)**:全局物品态现在保留 +> `anti_flags`、`flags`、`sockets` 等实例元数据;普通背包拖放携带整组数量,Shift+左键堆叠物品可先 +> 选择附着数量,金币使用 `ITEM_MONEY=-1` 独立拖放态。交易按参考端补齐服务端 accept 镜像、1000 +> 距离退出、`ANTIFLAG_GIVE`、7 位金币上限和已放金币不可编辑;NPC 商店补齐实例 `ANTIFLAG_SELL`、 +> `IsValuableItem` / `Is1GoldItem` 售价公式和首次确认框;私人商店补齐 `ANTIFLAG_GIVE/MYSHOP`。 +> 新增 `proto_item_layout_test`、`shop_rules_test`,并重跑库存 / 鼠标 / 交易 / 摆摊 / 复杂窗口回归。 +> 代码与离线回归完成;参考端 safebox money 发送函数确认主动 `return false`,未伪造 `/safebox_money`。 +> 完整交易事务、真实 `.sub` 资源、40250 真服和真机仍待验收。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) +> §4.2 / §4.4 / §4.5 / §4.6 / §4.7 / C.5。 +> - **2026-09-02 增量 51(W0 / §9.3 生命周期单一属主)**:生产入口 `client_main.gd → AppFlow` +> 创建并持有唯一 `AppLifecycle`,登录 / 选人 / 游戏切屏不重复创建;`GameScene` 删除本地 +> `AppLifecycle`,只通过 `lifecycle_audio()` 交给 AppFlow 绑定当前 `Audio`;`M2Client` 删除 +> `NOTIFICATION_APPLICATION_PAUSED/RESUMED` 监听,只保留 `suspend()/resume()`。旧兼容启动器 +> `login.gd` 也不再在进游戏时销毁自己的生命周期节点。新增 `app_flow_lifecycle_test.gd`, +> 扩展 `gamescene_test.gd` 验证单一属主;两项 Godot 回归使用独立 `--log-file` 串行通过。 +> 真机前后台 / 内存告警 / 后台 TCP 回收后的重连仍未验证。 > - **2026-09-02 增量 49(W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流 > 共享文件的骨架,冻结接口后再拆分。`EntityStore`:`Entity` 补 `empire / affect_flags / > owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、 @@ -525,8 +1484,11 @@ 当前的主要差距已从“有没有功能”转为: 1. 部分关键协议只实现了表现包或收包,没有完成原客户端的完整上行语义; -2. 数据模型已扩到 24 个 wear 位置和独立 16 格腰带,并接入部分装备 / 时装换模,但原版 - costume / belt 窗口、拖放、tooltip 和部位规则仍不完整; +2. 数据模型已扩到 24 个 wear 位置和独立 16 格腰带,并接入部分装备 / 时装换模,槽位版图与 + 发包坐标已按 `GameType.h` 对齐(增量 96),装备校验链(修改 2:`antiflag→wearable_flag→limit`) + 已由 `equip_rules.gd` 在客户端防御性重建(增量 97),变身 / 动物时装 / 婚纱的部位遮挡(修改 4) + 已由 `part_hiding.gd` + `equip_model.gd` 落地(增量 98),但原版 costume / belt 窗口、拖放、tooltip、 + 远端 PC 换模(修改 5,远端仍是占位胶囊)、变身态字段和校验链的精确 tie-break(接服务器)仍不完整; 3. 多数玩法窗口是简化实现,未复现原 Python UI 的完整行为; 4. EffectLib、SpeedTree、材质和世界表现仍有明确占位或近似; 5. 打包资产路径、桌面配置和输入体验尚未形成原客户端等价闭环。 @@ -553,17 +1515,17 @@ | 优先级 | 状态 | 模块 | 当前实现 | 需要迁移的原客户端语义 | |---|---|---|---|---| | P1 | 🟡 | 选人 / 建号 / 删号 / 改名 | Classic 当前使用 `CG_CHARACTER_CREATE=4` / `CG_CHARACTER_DELETE=5` / `CG_CHANGE_NAME=106` / `GC_PLAYER_CREATE_SUCCESS=8` / `GC_PLAYER_CREATE_FAILURE=9` / `GC_PLAYER_DELETE_SUCCESS=10` / `GC_PLAYER_DELETE_WRONG_SOCIAL_ID=11` / `GC_CHANGE_NAME=107`;全部有尺寸断言;`GameClient` 发送接口、角色槽位补丁和 `drain_*_events()`;`M2Client` 同名建删改 API、4 个建删结果信号及 `char_name_changed`;`net.loopback_flow` 覆盖建删和改名名称字节 / pid 广播。**真机验证(2026-08-31,`net_e2e MT_E2E_CHARCREATE=1`)**:建号、`GC_PLAYER_CREATE_FAILURE`、`GC_EMPIRE` 已验。选人页有建号 / 删号弹窗、强制改名弹窗与国家国旗 | `CG_CHARACTER_DELETE` 破坏性,真机未验;强制改名、重名 / 非法名错误码和服务端状态未真服验收;建号缺外形 / 发型选择与属性再分配 UI(现发 `shape=0` + 职业基础四维);原版选人台座、镜头动画、`OnCreateFailure` 完整错误码文案 | -| P0 | 🟡 | 技能施放协议 | `CG_USE_SKILL`、`CG_FLY_TARGETING`、`CG_ADD_FLY_TARGETING` 与 `CG_SHOOT` 已有尺寸断言、原生发送接口和 loopback 字节验证;服务端 `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 已进入 `EntityStore`,通过 `M2Client.fly_targeting` 暴露,且已补入 `GameClient` 世界分派;`GC_CHANGE_SKILL_GROUP` 会更新 `get_skill_group()` 并使技能快照失效;快捷栏先发 `use_skill(skill_id,target_vid)`,再同步 `CG_MOVE` 技能动作;弓技能会在 `.msa` FLY 事件发送 `CG_SHOOT`。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 | -| P0 | 🟡 | 快捷栏持久化 | 当前有 36 槽(4 页 × 9 格)、F1–F4 切页和 1–9 激活;拖放 / 清除 / 交换会分别发送 `CG_QUICKSLOT_ADD/DEL/SWAP`,服务器 `GC_*` 可恢复;技能冷却读取对应 `.msk` 的 `CoolTimeFormula` | 参考端服务端保存 36 槽,但客户端本地显示为 4 页 × 8 格(32 槽);可执行类型只有 `INVENTORY(1)` / `SKILL(2)` / `EMOTION(3)`,没有“命令类型”,当前 type=3/4 映射不兼容。参考端数字键默认只激活本地槽 0–3,Ctrl+1–9 发放表情,F1–F4 激活本地槽 4–7;当前 4×9 切页 / 1–9 全激活是自定义行为,仍需补物品拖放来源、鼠标物品态及完整刷新 | -| P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`,Ctrl+右键向当前目标 `ITEM_GIVE`,Alt+右键以数量框发 `ITEM_DROP2`;`ITEM_OWNERSHIP` 会实时更新地面名条;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需复刻原鼠标物品态、操作确认 / 错误提示、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互 | -| P0 | 🟡 | 装备 / 背包数据模型 | 常规背包维持 90 格;运行时模型已扩展到 24 wear 位置,并将腰带改为独立 16 格;背包右侧动态呈现能力 / 时装 / 戒指 / 腰带和 4×4 腰带格,发包使用原客户端 `INVENTORY` 全局 cell(`90 + wear` / `152 + belt`),按 `values[0]` 的原腰带等级规则锁格。`EquipModel` 已把本地主角 `parts[]`(时装优先)和装备槽接到身体 shape / TargetSkin、头盔 / 假发 / 发型、右手武器、左手盾牌,并用 `item_proto.specular/100` 驱动护甲球面高光 | 仍需复刻原 costume / belt 窗口的槽位贴图、分页、tooltip 和鼠标物品态;补齐饰品 / 翅膀等多部件遮挡规则、远端角色同等换装以及逐部位高光 / 卸装复位,不以当前兼容性扩展面板代替原 UI | -| P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED`、`GC_WALK_MODE`、`GC_CHARACTER_POSITION` 和 `GC_DIG_MOTION` 已恢复到实体状态,`NetPlay` 按 `moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理,而不是只靠距离阈值和 lerp;姿态动作还没有完整 UI / 动画语义 | -| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段 | 仍需 PK 模式上行、原版名字颜色、可攻击判定、阵营规则和原版目标板 / 决斗 UI | -| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 硬直 / 相机抖动;动作切换按 Granny `EaseInCurve(0,0,1,1)` 的 `2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏和屏闪(FLY / WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;实体 `attack_speed` 与本地 `POINT_ATT_SPEED` 已按原版 100 基准影响攻击请求间隔 | 仍需用真实 `.msa` motion duration、武器 / 坐骑规则和服务端合法性校准攻击节奏;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | -| P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;`GC_CREATE_FLY` 可消费队列并生成 tween 发光球 | 对齐 `FlyingObjectManager`:真实技能弹道模型、命中时机 / 命中特效、消失条件、声音与 EffectLib 特效 | -| P1 | 🟡 | 任务 / NPC | 支持 `[ENTER]`、`[CLEAR]`、`[NEXT]`、`[DONE]`、基础 `[QUESTION]`、`[INPUT]`、`[SELECT_ITEM]` 文本流程和任务日志;`SelectItemUI` 按原 `uiselectitem.RefreshSlot` 过滤魔石 / grade、映射背包格并发送 `CG_SCRIPT_SELECT_ITEM=114`;Classic 没有独立的 `CG_QUEST_CANCEL`,`quest_cancel()` 通过 `CG_SCRIPT_ANSWER=29` 发送 sentinel `255`;真服单会话已验证选择包 8B,取消触发 1 条 quest-change 且无断连 / unknown | 补原版 CJK IME / 输入校验、QuestButton、立绘、完整 EventManager 标签、世界箭头和屏幕边缘指示;选道具业务回包与完整资源视觉仍需真服 / 资源环境验收 | -| P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs`,`get_shop()`),`shop_ui` 带货架按钮 + `tabIdx*40+slot` 买位置** | 补图标格网格、数量输入、交易回滚提示、仓库密码 / 改密码、完整拖放规则、`coin_type` 非金币货币显示 | -| P1 | 🟡 | 私人商店 / 道具商城 | **首版**:`CG_MYSHOP=55`,`CG_MALL_CHECKOUT=69`,`GC_MALL_OPEN=122` / `GC_MALL_SET=128` / `GC_MALL_DEL=129`;`CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言;商城状态路由进 `EntityStore`,`private_shop_ui` / `mall_ui` 和真服探针均已接入。 | 仍缺真 `.sub` 版 `privateshopbuilder.py` uiscript、资源视觉和鼠标真拖放物品态 | +| P0 | 🟡 | 技能施放协议 | `CG_USE_SKILL`、`CG_FLY_TARGETING`、`CG_ADD_FLY_TARGETING` 与 `CG_SHOOT` 已有尺寸断言、原生发送接口和 loopback 字节验证;服务端 `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 已进入 `EntityStore`,通过 `M2Client.fly_targeting` 暴露,且已补入 `GameClient` 世界分派;`GC_CHANGE_SKILL_GROUP` 会更新 `get_skill_group()` 并使技能快照失效;快捷栏先发 `use_skill(skill_id,target_vid)`,再同步 `CG_MOVE` 技能动作;弓技能会在 `.msa` FLY 事件发送 `CG_SHOOT`(增量 86:单槽 + 1.5 s 定时器改成 FIFO `_pending_shots` 队列,支持连射多箭在途各自一发;弓普攻也走同一路径,并在挥击起手由 `net_play._send_fly_target()` 发 `CG_FLY_TARGETING`)。真服单会话已发送 `CG_USE_SKILL` / `CG_FLY_TARGETING` / `CG_ADD_FLY_TARGETING` / `CG_SHOOT`,均无断连 / unknown | 仍需实现范围 / 多目标选择器来自动驱动 `ADD_FLY_TARGETING`;冷却、目标合法性、真实弹道和客户端表现也须按技能类型细分;尚未在真服观察到对应 GC 飞行目标推送 | +| P0 | 🟡 | 快捷栏持久化 | 服务器保存 36 槽,客户端本地显示 4 页 × 8 格;类型统一为 `INVENTORY(1)` / `SKILL(2)` / `EMOTION(3)`,清除 / 交换 / 回包恢复和技能冷却已有;数字 1–4、F1–F4、LSHIFT 翻页、Ctrl/Command+1–9 表情已按参考接入,inventory → quickslot 有全局拖放首版 | 仍需完整刷新死亡 / 变身 / 换武器后的服务端状态、跨窗口快捷栏拖放和真实服务端验收;macOS F1–F12 受系统 Fn / 功能键设置影响 | +| P0 | 🟡 | 物品操作 | 已有移动、使用、丢弃、拾取;Shift 选“来源→目标”发 `ITEM_USE_TO_ITEM`,Ctrl+右键向当前目标 `ITEM_GIVE`,Alt+右键以数量框发 `ITEM_DROP2`;增量 54 增加全局 item payload、鼠标跟随图标、inventory / equipment / belt → inventory / quickbar 投放、无效区域 / 右键 / ESC / 失焦取消和 PICK / TALK / ATTACK 光标;增量 56 接入 safebox 45 格分页 / 仓库内移动、mall 分页、exchange 12 格、shop 出售目标和 private_shop 40 格;增量 57 使 payload 携带 `anti_flags` / `flags` / `sockets` 等实例字段,普通拖放保留整组数量,Shift+左键支持 stack split,金币使用 `ITEM_MONEY=-1` 独立态;交易、NPC 商店和私人商店的关键 anti-flag / 确认边界已接入;增量 58 统一基础 tooltip;增量 60 让 m2dev / Classic 交易包保留 `sockets[3]` 与 `attrs[7]`;`GC_VIEW_EQUIP` 已保存 11 格检查快照并弹出装备查看窗 | 仍需完整交易事务、宝石专用规则,以及装备查看窗的原版槽位图标、3D 纸娃娃与交互;CJK IME、真实 `.sub` 资源、tooltip 特殊分支、真服 / 真机输入仍待验收 | +| P0 | 🟡 | 装备 / 背包数据模型 | 常规背包 90 格、运行时 24 wear 位、腰带 16 格、基础 tooltip 已有;增量 96 按 `GameType.h` 对齐基础 wear / costume / ring / belt 发包坐标;增量 97 按 `ItemData.h` 防御性重建 `CanEquipNow` 的 antiflag→wearable→limit 校验;增量 98 完成变身 / 动物时装 / 婚纱部位遮挡;增量 99 让远端 PC 创建 `RemotePlayerView` 并消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]` 四字段换装 | 变身字段、所有职业 / race 的资源、真服远端换装、精确 tie-break / 同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性读取、原版 costume / belt 视觉、装备对比和真机仍待验收 | +| P0 | 🟡 | 移动与战斗同步 | 有 `GC_MOVE`、本地预测、攻击;`M2Client.character_position()` 可发送姿态回报,`M2Client.sync_positions()` 可按原包批量发送最多 16 个坐标,`M2Client.request_warp()` 可发送 `CG_WARP` 主动传送请求;`GC_SYNC_POSITION` 批量坐标纠偏、`GC_CHANGE_SPEED`、`GC_WALK_MODE`、`GC_CHARACTER_POSITION` 和 `GC_DIG_MOTION` 已恢复到实体状态,`NetPlay` 按 `moving_speed/100` 校准主角步行 / 奔跑预测,挖矿广播可驱动目标朝向 / dig 动画,`WALK_MODE` 会驱动远端 walk/run 动画选择;真服单会话已发送 `CG_CHARACTER_POSITION`(5B) 与 `CG_SYNC_POSITION`(16B),均无断连 / unknown;**本地移动改为 Src/Dst 模型(增量 76,§3.1)**:`player_controller.gd` 的 `walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()`(`InstanceBaseMovement.cpp:251`),太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 `_can_translate()`(`frozen`/`locked`/`moving_skill` ≈ `__IsSyncing`/`isLock`/`IsUsingMovingSkill`)未过时按 `__ReserveClickGround` 存点 + 0.1 s 延迟重试,`moving_skill` 只转向,WASD 分支 `_is_going = false`;**远端同步改为命令队列(增量 77,§3.2 part A)**:`EntityStore` 远端 Actor 位置由 `std::deque` 驱动(1:1 复刻 `StateProcess`,`InstanceBase.cpp:1278`),`mut_move()` 入队 + `drain_state_queue()` 按 `set_server_frame_ms()`(为 0 即时释放)+ `__CanProcessNetworkStatePacket` 逐条释放,`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision`,线性插值只保留给 §2.1 可见性;**上行 6 事件回调(增量 78,§3.2 part B)**:`net_play.gd` 按 `PythonPlayerEventHandler.cpp` 拆成 OnMove(立即 `FUNC_MOVE` + 重置 moving/waiting 节流窗)/ OnMoving(300 ms 节流)/ OnWaiting(`_tick_on_waiting`,100 ms + 位移 ≥ 1 cm)/ OnStop(`FUNC_WAIT`)/ OnAttack(`CG_ATTACK` 外额外 `FUNC_COMBO`)/ `on_use_skill()`(`FUNC_SKILL|motion`),统一走 `_send_state`;`net_world.gd` 去掉自有 `follow_lerp`,节点位置直接跟随 `e.pos`(插值全交 C++ `tick()`);**真实服务器毫秒帧钟(增量 79,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + lDelta` / `m_client_time_base` 算出,`ClassicSession::process()` 每帧 `set_server_frame_ms()` 后 classic 后端命令队列按服务器时间放行(m2dev 恒 0 = 即时释放,无回归);`drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS=1000`(`chk_time` 领先帧钟超 1 s 立即放行,参考端无此上限,有意偏离);**Actor-Actor 碰撞 + 坐骑转向速度(增量 93,§3.1 收尾)**:`player_controller._actor_blocked()` 近似 `TestActorCollision`(8 m 距离门 + body 球重叠 `ACTOR_BODY_RADIUS_M=0.55` + 趋近判定)+ `_process` 逐轴门控(单 Actor 滑移 / 两轴挡=`BlockMovement`),`skip_actor_collision`(≈ `CanSkipCollision()`)观战置位;`rotation_speed_deg` + `_turn_toward()` 逐帧限幅收敛,`net_play._refresh_motion_mode()` 坐骑切 `ROT_SPEED_HORSE_DEG=300`(`c_fDefaultHorseRotationSpeed`)/ 常态 1200,替换两处 `rotation.y = atan2()` 瞬转;`player_move_test.gd` 5→8 组 | 仍需按原客户端同步窗口、碰撞纠偏触发时机与保留动作处理;**抖动缓冲 / 预测回滚不做(§3.2/§6 排除)**;姿态动作还没有完整 UI / 动画语义;~~§3.1 的 Actor 间碰撞(`TestActorCollision`/`BlockMovement`)、坐骑 `MODE_HORSE*`(依赖 §6.4 + §3.2)~~(增量 93 已近似落地)、真实 body sphere 半径(`.col` race 碰撞数据)、`AdjustDynamicCollisionMovement` 精确滑移 + `uCollisionCount==2` 复合门、`MOVABLE_GROUND_DISTANCE` 默认值校准、网络驱动本地预测玩家的 `__EnableSkipCollision()`;真实服务器 `GC_MOVE` `dwTime` 时基联机核验、`__IsEnableTCPProcess` 表情锁、`GC_SYNC_POSITION` 是否清队列(随对应 §3.x) | +| P0 | 🟡 | PVP / 决斗 / 阵营 | `GC_PVP` 和 `DUEL_START` 已进入状态层,并以 `pvp_changed` / `duel_started` 信号提供给 Godot;挑战 / 战斗 / 复仇关系会在世界实体上显示标签;实体已有部分 empire / alignment / pk 字段;**可攻击判定 `IsAttackableInstance` 已 1:1(增量 94,`entity_rules.gd` + `net_play._is_attackable` / `_attack_ctx`,含 alignment grade/type、`IsSameEmpire`、`IsConflictAlignmentInstance`、`IsPVPInstance`、`__FindDUELKey`、`IsSamePartyMember`、`IsWoodenDoor`)** | ~~可攻击判定~~(增量 94 落地);~~原版名字颜色(`ENameColor` §8.8)~~(增量 110 落地:`name_color.gd` 1:1 `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关、开关 `net_world.set_empire_name_mode()` 未接 UI);~~头顶称号色 / 公会名 / 等级~~(增量 111 落地:`text_tail.gd` 1:1 `GetTitleColor` / `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag` 子标签、开关 `net_world.set_pk_title_enabled()` 未接 UI);~~头顶聊天 / 信息气泡~~(增量 112 落地:`chat_tail.gd` 1:1 `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除、`register_info_tail()` 公开方法);~~头顶名字「是否显示」/「总是显示名字」/ LALT 名字显示 / 目标强显~~(增量 113 落地:`name_show.gd` 1:1 `IsAlwaysShowName` / `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 `fDistanceFromPlayer < 3500` 平面距裁剪 / `ShowCharacterTextTail` 的 `isShow · !IsGuildWall · CanPickInstance` 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显,`net_world._name_shown()` 四路:`_always_show_name`(`game_option_ui` radio)/ `_alt_held`(LALT)/ `_target_vid`(`net_play.target_changed`)/ chat `bNameFlag`);仍需 PK 模式上行、保护区 `ATTRIBUTE_BANPK`、原版目标板 / 决斗 UI、目标框 / 可攻击光标按 `ENameColor` 驱动;seam:`__FindPVPKey` / `__FindGVGKey` / `IsGameMaster` / `IsWall` race 白名单 / m2dev `IsKiller` 位的 packet setter、`name_color` 的 `kind_resolver` 未注入时回退裸 `ch_type`(seam ⑥)、`text_tail` 子标签 billboard 竖排 vs 屏幕空间排版(seam ⑦)、`title_name_resolver` 未注入回退裸 key(seam ⑧)、`chat_tail` 气泡与名字 `Label3D` 并存、名字不随气泡强制顶出 / 下移 17px(seam ⑨)、悬停 `chr.Pick()` 强显 / 屏幕空间 `ArrangeTextTail` 防重叠 / `CanPickInstance` 仅按死透近似(seam ⑩) | +| P1 | 🟡 | Actor 战斗运行时 | 有骨骼动画、基础 combo / 相机抖动;受击不再用固定硬直秒数(增量 73 删除 `_hitstun_until`),「能否行动」统一走 `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310`),击退态由受击 `.msa` 播放生命周期驱动、动作播完清零;动作切换按 Granny `EaseInCurve(0,0,1,1)` 的 `2w²−w³` 逐骨 crossfade;角色 mesh 支持距离 LOD、15% 迟滞和约 0.18s 新旧 mesh 淡入(`LodGhost` + `lod_fade` uniform);右手武器 / 左手盾按 `weaponComposite[0] = weaponInvWorld[0]·weaponLocal[0]` grip 复合挂 `equip_right_hand`(探针实测 Δ≤0.06 m,非标准米级偏移文件夹断回退);`.msa` motion event 已分派声音、EffectPosition 特效、震屏、屏闪和弓箭 FLY(增量 86:`type 6` FIFO `_pending_shots` → `client.shoot()` = `OnShoot`,`OnSetFlyTarget` 在 `net_play._emit_swing` 发 `client.add_fly_targeting()`;WARP 留钩子);玩家 / 怪物强制实时投影并附脚底接触阴影 Decal;点击 / 攻击距离按目标类型分 `150 / 500 / 100` cm(增量 74,对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改由 `.msa` `ComboInputData.DirectInputTime`(`get_motion_data().next_combo`,缺组退化 `duration*0.9`)除以 `bAttackSpeed/100` 得出,`attack_speed` 只缩放动作播放速率不再写死间隔;自动攻击走 `ReservedMode` 状态机(增量 75):`_update_auto_attack()` + `_reserve_process_click_actor()` 严格 10 步(照 `PythonPlayer.cpp:207` / `PythonPlayerInput.cpp:877`:出距离 `pc.walk_to` 靠近、NPC 走 `click_npc`、`IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act`、第 10 步按节奏 `_do_attack_swing`) | PC 连击段表已解析(增量 80,`formats/combo_table.{h,cpp}` 从 `playersettingmodule.py` 的 `ReserveComboAttackNew` / `RegisterComboAttackNew` 建 `map>`,非 `.msm`),并已接进 `net_play.gd` 连击状态机替掉 `(_combo + 1) % 3`(增量 81:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / `_on_end_combo` / `_clear_combo` 逐行对齐 `ActorInstanceBattle.cpp:157/213/256/297/308`,按 `.msa` `ComboInputData` 三时间驱动 `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号经新静态绑定 `Metin2AnimPlayer.parse_combo_tables()` 按 `race & 3` 取);`.msa` `HitDataContainer` 多命中窗已导出(增量 82:`formats/msa.{h,cpp}` `Msa::HitWindow` + `get_motion_data().hit_windows` / `motion_type` / `hit_limit_count`,`textscript.cpp` 补 `List` 解析,含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`);命中帧已落地(增量 83:`net_play.gd` `_begin_hit_windows` / `_attack_process(dt)` / `_hit_deduped` / `_register_hit` / `_hit_geometry` / `_flush_victim_list` 逐帧按动作时间与 `hit_window` [start,end] 相交对 `client.get_entities()` 可攻击 / 未死 / 非安全区实体做几何判定,`m_HitDataMap` 每窗查重 + 命中上限 NORMAL/COMBO 16 / SKILL `hit_limit_count`,命中 → `client.attack(_swing_skill, vid)`(`OnHit`),被击退目标帧末 `client.sync_positions()` ≤16;`_emit_swing` 改为只有缺命中窗数据才在挥击瞬间发 `CG_ATTACK`);武器种类 → `combo_motion_mode` 映射已落地(增量 84:`net_play.gd` `motion_mode_for()` 逐分支复刻 `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`),`_refresh_motion_mode()` 读 `parts[WEAPON]` / `get_equipment()[WEAR_WEAPON].vnum` + `proto.item().{type,sub_type}` + `mount_vnum` / `polymorph` / `wedding_dress`,由 `_on_entity_info` / `inventory_changed` / `mount_changed` 三处触发,换模式内部 `_clear_combo()`;`combat_fx_test` 加 `FakeProto` + 15 段映射 + 2 段端到端断言);弓箭 `__GetBowRange()` 已落地(增量 85:`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `BOW_RANGE_BASE_CM`(2500-100) + `_bow_distance_bonus`(`POINT_BOW_DISTANCE`=34,`_on_points()` 读 `points["bow_distance"]`),NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + `__GetBowRange`;`combat_fx_test` §3.5 段加 8 段断言);剩余:几何判定当前是 `reach`(max WeaponLength / 采样水平偏移)+ 正面 ±60° 弧近似,真实动态 Z 圆柱 vs 防御球扫掠随 §3.1 Actor 碰撞一起补;坐骑移速规则、`POINT_BOW_DISTANCE` 真服下发路径和服务端合法性校准;技能合法性判定位数据层已落地(增量 87:`ui/skill_table.gd` 按 `RegisterSkillDesc` 把 skilldesc 第 11/12 列折成 `attr_bits` / `need_weapon_bits` + `skill_type`,`IsCanUseSkill` / `IsStandingSkill` / `IsHorseSkill` / `IsNeedTarget` / `IsNeedCorpse` / `CanChangeDirection` / `IsChargeSkill` / `CanUseWeaponType` 等谓词逐条对齐 `CPythonSkill`);三层校验已落地(增量 88:`project/player_skill.gd` `extends RefCounted` 纯校验器逐行移植 `PythonPlayerSkill.cpp` 的 (a)`click_skill_slot`(`:106`)/ (b)`can_use_skill`(`:439`,硬编码骑乘术 `109`)/ (c)`check_skill_usable`(`:166` 完整前置顺序)/ (d)`use_skill`(`:460` + `_resolve_target` 目标矩阵),返回 `{ok, code, target_vid}`,`net_play.skill_context()` 注入运行期状态,`ui/quickbar.gd activate()` 施放前跑三层、非静默码 → 信号 `skill_rejected` → `net_play.cannot_act`(同一 `OnCannotUseSkill` 通道),新增 `player_skill_test.gd`);边界收紧 + 短蓝短血校验已落地(增量 89,§3.8 修改 3 → 三层全落地:`net_bounds.h` `SKILL_MAX_NUM=255` + `skill_index()` 谓词,`M2Client::use_skill()` 的 `skill_id` 护栏收紧成 `!bounds::skill_index()` 对齐 `ClickSkillSlot` 的 `slot >= SKILL_MAX_NUM` 门;`skill_table.gd` 解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]` 功率表 → `need_sp` / `need_hp` / `_skill_point`;`player_skill.gd` `_check_short_mana` / `_check_short_life` 从桩改为移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),HP/SP 经 `net_play.skill_context()` 读 `get_points()` 供入、`-1` 不判);技能升级门已落地(增量 90,§3.10:`wire.h` +`POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`,`M2Client::get_points()` 透出 `skill_active` / `skill_support` / `skill_horse`,`skill_up()` 入参收紧为 `!bounds::skill_index()`;`skill_table.gd` +`max_level_of` / `skill_level_limit`(本 40250 数据无 `LEVEL_LIMIT` 列 / `.msk` 记号 → 恒 0)/ `can_level_up_skill(id, level)`(= `skillCanLevelUpSkill`,本 build 前置技能 / 前置属性字段无人填充 → 化简为 `level < GetMaxLevel() && !CANNOT_LEVEL_UP`);`skill_ui.gd` `refresh()` 逐行移植 `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / `CanShowPlusButton`——技能组门 `get_skill_group()!=0` + 三技能点来源分开取 + `skillGrade==0` + 马术页 `level<20` + 主动页 `can_level_up_skill` + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐;按钮 `_rebuild()` 建 / `refresh()` 切 `visible`,升级结果由 `GC_SKILL_LEVEL` 经 `skills_changed` 驱动、不本地伪造;`skill_test.gd` +`§3.10` 段);`ReservedMode` 的 CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派已落地(增量 91:`_refresh_reserved()` 四路 `match` 逐行对齐 `NEW_RefreshMouseWalkingDirection`,含 CLICK_ITEM 的 20cm 拾取阈值、CLICK_POSITION 的 0.1s ReservedDelay 门、USE_SKILL 的 `IS_HUGE_RACE` −200cm 修正 + `use_skill_hook` 进射程施法;`__ChangeTargetToPickedInstance` 拾取回退 = `IsTargetableInstance` 限 PC/怪、否则清目标;扇形 / 圆形补 fly-targeting = `send_fly_targeting()` 经 `_instances_in_fan_range` / `_instances_in_circle_range` + `_random_pos_in_fan_range` 补 `CG_ADD_FLY_TARGETING`,几何逐行对齐 `NEW_GetInstanceVectorInFanRange` / `InCircleRange`);安全区 / 弓箭 / 骑乘 / dash 判定位的服务端来源、`IS_HUGE_RACE` vnum 白名单(现用 `proto.mob` `size>=3` / `rank>=4` 近似)仍留桩;另补 combo 取消窗口、精确受击 / 击退、角色碰撞、同步碰撞、武器拖尾、剩余 motion event / 骨骼挂点语义、GPU skin LOD、双持 / 左手武器和目标锁定相机;`03150` 类偏移网格需 per-weapon 偏移数据或修 .gr2 | +| P1 | 🟡 | 飞行物与命中特效 | `GC_FLY_TARGETING` / `GC_ADD_FLY_TARGETING` 保存 shooter 的目标队列和 VID / 坐标;~~`GC_CREATE_FLY` 可消费队列并生成 tween 发光球~~ → **增量 92:`project/fly_object.gd` 的 `FlyManager` / `FlyInstance` / `FlyData` 对齐 `GameLib/FlyingObjectManager.{h,cpp}` + `FlyingInstance.{h,cpp}`——`Update()` 逐帧真实弹道(`vel += accel*dt` + `gravity`)+ 线段-点命中判定(`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80` `square_distance_between_linesegment_and_point` < `m_fBombRange²`)+ `OnShootDamage` / pierce `--` 续飞 / 超程 `__Explode(false)` / `m_bHitOnBackground` 触地;`FlyData` 默认值取 `FlyingData.cpp __Initialize()`;`net_world._on_fly` 换成 `_fly_mgr.spawn(...)` + `signal fly_exploded`。`net_play._can_shot()` 防御式重建 `__CanShot`(`grep` 全树无定义)——非弓 / 无箭 `EMPTY_ARROW` / 无目标 / 死目标 / 出弓程 `OUT_OF_RANGE`,`_emit_swing` 弓分支据此跳过 `CG_FLY_TARGETING` / `OnShoot`** | seam:`GC_CREATE_FLY` 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 `fly_cue`;重力参考端作用于 `.z`=上、本 POC 映射到 `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数(`init_vel=45` / `flat_range=max(dist*1.5,30)` / `bomb_range=0.6`);`FIRE_CRACKER` / `AUTO_FIRE` 的 `CreateIndexedFly` 分支已在常量层预留但本 build 无驱动来源;bomb effect / 声音 / EffectLib 特效仍是占位闪光 | +| P1 | 🟡 | 任务 / NPC | 增量 95 已完成 `CPythonEventManager` 常用标签解析 / 分派;增量 101 完成 Quest 日志 `QUEST_MAX_NUM=5` 五行窗口、离散滚动、默认 / 服务端 icon、clock 和任务行事件;增量 108 完成小地图任务信标越界夹圆周 + 指向目标的方向箭头(世界箭头 / 屏幕边缘指示首版,`minimap.signal_marker_geometry` + `_draw_signal_arrow`);增量 109 完成 quest 信件按钮条 `interfacemodule.__ArrangeQuestButton` 多列定位(`yCount=(H-330)//63`、换列 +100px、最新在前、组队窗 `xPos`→130);`SelectItemUI`、QuestButton、地图信号和对话图片已有首版 | 逐帧 `UpdateEventSet`、Skip / EndEventProcess、`.msc`、NEXT / DONE 文案、相机 / fade / cinema 消费端、CJK IME、任务日志窗体 `_list` 自身分页 / 多列、Atlas 同一信标夹边、完整 `.sub` 图标、立绘 / 电影、真实资源和真服仍待验收 | +| P1 | 🟡 | 商店 / 交易 / 仓库 | 普通 NPC 商店、交易状态和仓库列表已有首版;**`SHOP_GC_START_EX` 多货架已解析(`m_shop_tabs`,`get_shop()`),`shop_ui` 带 5×8/40 格货位、货架按钮 + `tabIdx*40+slot` 买位置、空位禁用和 BUY 确认(固定 count=1)**;增量 56 增加仓库 5×9 分页 / 指定格 / 仓库内移动、交易 12 格指定投放和商店 BUY/SELL toggle / 出售投放区;增量 57 补齐出售 anti-flag、首次贵重物品确认、普通 / `COUNT_PER_1GOLD` 价格公式和失败音效;增量 58 接入统一物品 tooltip;增量 60 交易物品状态补齐 sockets[3] / attrs[7] | 补交易事务回滚提示、仓库金钱 / 改密码、完整音效和 `coin_type` 非金币货币显示;真实 `.sub` / 图标和真服回包仍待验收 | +| P1 | 🟡 | 私人商店 / 道具商城 | **首版**:`CG_MYSHOP=55`,`CG_MALL_CHECKOUT=69`,`GC_MALL_OPEN=122` / `GC_MALL_SET=128` / `GC_MALL_DEL=129`;`CGMyShopHead`(38) + `MyShopItem`(13)×N 有尺寸断言;增量 56 增加商城 45 格分页 / 商城→背包以及私人商店 40 格全局物品态投放;增量 57 补齐 `ANTIFLAG_GIVE/MYSHOP`;增量 58 接入统一物品 tooltip;商城状态路由进 `EntityStore`,`private_shop_ui` / `mall_ui` 和真服探针均已接入。 | 仍缺真 `.sub` 版 `privateshopbuilder.py` / `shopdialog.py` uiscript、资源视觉、完整价格 / 确认回包流程和真实鼠标拖放物品态验收 | | — | ❌ | 拍卖行 / auction house | 不做 | 当前 40250 默认构建未发现完整 ClientVS22 拍卖行 UI / handler;但客户端 `Packet.h` 保留 `__AUCTION__` 下的 `CG_AUCTION_CMD=205`,服务端也保留列表包 `=206`。当前默认服务端关闭该宏,因此本阶段不做;若启用需另行验收,不能称为“协议不存在” | | P1 | 🟡 | Cube (제작) / 能量系统 | **首版**:Cube 走 `GC_CHAT`/`CHAT_TYPE_COMMAND` 文本总线(对齐 `ServerCommand()`),`EntityStore::apply_server_command` 解析 `cube open/close/info/success/fail/r_list/m_info`(配方 `v,c` 列表 + `@`/`&`/`|`/`/` 分层材料 + 金币)→ `CubeState` + `drain_cube_events()`;`GameClient::send_cube_make/material_info/result_list`(`/cube make|mInfo|rList`);`M2Client` `get_cube`/`cube_make`/`cube_request_*` + `cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号;`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작)。`net.entity_store` + `net.loopback_flow` 覆盖。真机(2026-08-31)`/cube rList` 送出不断连,但服务器只在玩家站到 cube NPC 前才回应 → `cube open=0`,需 NPC 交互完整验。`POINT_ENERGY` 已由 HUD 快照显示;仍缺原 `uiCube.py` 材料格 3D 图标 / 拖放 / 결과 애니、NPC 前完整验 send 命令字 | | P1 | 🟡 | 组队 | 邀请、接受、离队;**成员信息板(`party_ui.gd`,1:1 迁移 `uiparty.PartyMemberInfoBoard`):每员角色状态按钮(队长弹菜单 → `CG_PARTY_SET_STATE=75` 分配 攻/坦/迅捷/辅/宗/防 或踢人)+ 名字(★队长) + HP gauge + `affects[7]` 附加效果 chip;顶部 EXP 分配开关(`party_set_distribute`)+ 组队治疗(`party_use_skill(1,0)`)**;角色 / 治疗控件已按 40250 服务端 Leadership 规则本地门控(攻击≥10、坦克/迅捷≥20、辅助≥25、宗师≥35、防御≥40,治疗≥18);真服单会话 `CG_PARTY_USE_SKILL`(9B) 后仍在线;小地图 / Atlas 绿色队员点 + 队长环 | 仍需真版 `.sub` 图标 / 颜色、队伍技能冷却 / 权限、离线颜色语义和真实两账号组队验收 | @@ -571,15 +1533,15 @@ | P1 | 🟡 | 公会(暂缓) | 信息、成员、技能、公会战、会徽上 / 下载和部分操作已有首版;`GC_REQUEST_MAKE_GUILD=82` 已解析并以 `M2Client.guild_make_requested` 通知;公会窗已弹出创建名称输入并发送 `CG_ANSWER_MAKE_GUILD`;`CG_GUILD_SYMBOL_CRC=113` / `GC_GUILD_SYMBOL_DATA=133` 已支持原始符号文件下载 | 本阶段暂缓完整权限 / 等级页、公告与日志、成员管理、公会战应答、积分板、领地和公会建筑;恢复公会范围后再做完整真服验收 | | — | ❌ | 龙魂精炼 | 当前目标 40250 服务端不提供该玩法;本阶段不纳入支持或验收,已有兼容代码仅保留作协议参考 | 若未来服务端增加该玩法,再另立协议、UI 和验收需求 | | P1 | 🟡 | 世界玩法 | 有 `CG_WARP=65` 主动请求、`GC_WARP=65` 处理、`NetWorld` / `GameScene` 的无条件 `lAddr:wPort` 重连与换图清理首版、`CG_FISHING=82` / `GC_FISHING=89` 钓鱼方向与状态事件、`NetPlay` 钓鱼动作分发、`ChatUI` 鱼获 / 失败日志、`CG_DUNGEON=110` / `GC_DUNGEON=110` 副本请求与状态事件、`GC_DIG_MOTION=134` 挖矿动作广播、`GC_LAND_LIST=130` 领地状态、`GC_OBSERVER_ADD/REMOVE/MOVE=96–98` 观战者状态、频道、时间、NPC marker、天气粒子和 mount_vnum 字段;真服登录洪流已收到并解析 `GC_LAND_LIST`(436B),单会话发送 `CG_FISHING`(5B) 后收到“Please choose a Fishing Pole.”业务拒绝且连接保持 | 补完整钓鱼 UI / 鱼获背包落地、副本计时 / 入口 UI、坐骑真模型 / 骑乘动作 / 移速、地图 BGM / 天气配置、地形贴花、DungeonBlock;换图首版已落地,仍需真实 40250 端到端验收 | -| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、世界箭头、地图驱动天气配置和原版窗口视觉 | -| P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D` | 粒子 `.dds` 目前被程序化径向渐变替代,`.mde` 用 box 占位;需补 EffectMesh、SimpleLight、TexAni、MovingType、骨骼挂点和完整时间轴 | +| P1 | 🟡 | 小地图 / Atlas | 可画玩家、实体、NPC、任务标记、观战者紫色菱形和 `GC_LAND_LIST` 领地边框;支持 `set_scale()` / 鼠标滚轮缩放;增量 61 接入 EventManager 任务信号点、清除和 Atlas center adjust;增量 108 任务信号点越出小地图半径时按 `m2dev PythonMiniMap.cpp:441-462` 夹到圆周并改画指向目标的方向箭头(`SIGNAL_RIM_INSET=9.0`);Atlas 支持底图、地图名称、玩家坐标、拖动和坐标查询 | 补原 minimap / Atlas 贴图细节、提示、队员 / NPC 分类、Atlas 侧同一信标夹边、完整 `.sub` 边缘指示图标、地图驱动天气配置和原版窗口视觉 | +| P1 | 🟡 | EffectLib | `.mse` Particle 已映射到 `GPUParticles3D`;增量 53 已读取首个 `TextureFiles` 的实际 `.dds`,并接入位置、发射形状、方向 / 速度;增量 64 已解析 `.mde` v1/v2、展开三角形帧、翻转 UV、接入 `.ifl` 贴图和 MeshElement 材质 / 透明度;增量 65 已接入 Light 组、范围曲线、多贴图 TexAni 的帧延迟 / 顺逆序和旋转类型 | TexAni 随机起始帧 / 每粒子语义、MovingType / 时间曲线、Direct3D 材质 stage、骨骼挂点和资源逐项视觉验收 | | P1 | 🟡 | SpeedTree | `.spt` 只嗅探真实 bark / composite 贴图,几何为程序化 proxy | 迁移或离线转换真实树干、叶片、frond、LOD、billboard、风和阴影;当前不能作为 1:1 树木 | -| P1 | 🟡 | 地图 / 材质渲染 | A1 地形、静态物、水和 `.msenv` 环境已有首版;splat 已支持每区块最多 16 个活动层、原客户端 UV / offset 与最大源分辨率,地形拆 patch 做视锥 / 距离剔除;水使用原 30 帧 DDS 序列、水深 alpha 和轻微高度动画;静态物 Y-up 旋转共轭已修正。GR2 blend / two-sided 优先读材质信息,球面高光管线已接 `item_proto.specular`;角色有实时投影 + 接触阴影 | 补其余固定功能材质与多贴图分支、精确 alpha-test / blend / cull、透明排序、逐 patch 几何 LOD、baked / realtime 阴影 mask、血迹 / 施法阵等 Decal、云层和 lens flare;按受控参考帧校准水深、LOD、阴影、光照和材质 | +| P1 | 🟡 | 地图 / 材质渲染 | A1 地形、静态物、水和 `.msenv` 环境已有首版;splat 已支持每区块最多 16 个活动层、原客户端 UV / offset 与最大源分辨率,地形拆 patch 做视锥 / 距离剔除;水使用原 30 帧 DDS 序列、水深 alpha 和轻微高度动画;静态物 Y-up 旋转共轭已修正。GR2 blend / two-sided 优先读材质信息,球面高光管线已接 `item_proto.specular`;角色有实时投影 + 接触阴影;`.msenv` 双光层和纹理天空首版已接入 | 补其余固定功能材质与多贴图分支、精确 alpha-test / blend / cull、透明排序、逐 patch 几何 LOD、baked / realtime 阴影 mask、血迹 / 施法阵等 Decal、云层渐变过渡、滤色 / 风完整语义 / lens flare;按受控参考帧校准水深、LOD、阴影、光照和材质 | | P1 | 🟡 | UI 控件语义 | 80 个 `uiscript` 可解析和构建,覆盖约 20 类基础节点 | 解析成功不等于窗口完成;需补动画图片、slot 拖放、scrollbar/listbox 行为、tooltip、radio group、候选窗、原层级 / 锚点 / 焦点 / modal 规则 | | P1 | 🟡 | UI 窗口行为 | 背包、技能、任务、聊天、社交和商店等有简化窗口;腰带背包、扩展装备和装备查看已有兼容窗口;**选人页已按 Metin2 风重做**;**角色状态窗(`char_status_ui.gd`)已按迁移资产中的 `characterwindow.py` 布局与 `uicharacter.py.RefreshStatus` 逻辑实现,并使用原 `/stat` 加点命令作为迁移样板** | **选魔石窗** `select_item_ui.gd`、**系统设置窗** `system_option_ui.gd`、**游戏设置窗** `game_option_ui.gd`(`/setblockmode` + `/pkmode` 真命令 + 显示开关持久化)、**ESC 系统菜单** `system_menu_ui.gd`(`systemdialog.py` + `/phase_select`/`/logout`/`/in_game_mall`)、**私人商店窗** `private_shop_ui.gd`、**组队成员板** `party_ui.gd` 均已按迁移样板做成;这些 Python 文件不在本次 `ClientVS22/source/` 树中,仍需用对应 C++ 常量 / 行为和实际资源验收 | 按 `ClientVS22/source/UserInterface` 的窗口装配、消息、控件和资源行为逐个复核视觉 / 交互细节:帮助窗、Atlas、商城 / Cube uiscript、Guild Building、game_option 的名字颜色 / 伤害数字等渲染侧钩子 | -| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、当前自定义的 F1–F4 切页和数字键 1–9 激活已接;实体点选从固定半径提升为优先使用子 mesh 合并世界 AABB,无码型实体才回退胶囊近似 | 对齐 ClientVS22 的数字键 1–4、Ctrl+1–9 表情、F1–F4 本地槽位 4–7、空格连续攻击、显示名字、鼠标物品态、PK 模式、正式客户端截图和窗口上下文规则;键位重绑定 / 相机灵敏度持久化不是参考端已有能力 | +| P2 | 🟡 | 输入 / 快捷键 | WASD、点地、触屏、右键拖拽环绕、滚轮缩放、双指捏合、I/K/J/O/G/L/Z、数字 1–4、F1–F4、LSHIFT 翻页、Ctrl/Command+数字表情、空格连续攻击、Alt 显示名字、截图、帮助 / 坐骑 / 角色窗口和实体 PICK / TALK / ATTACK 光标已接;窗口键盘不会穿透到世界;增量 56 已接商店 BUY / SELL 光标 | 仍需 CJK IME、完整复杂窗口 mousemodule、不可达 / DOOR / CHAIR / MAGIC / camera resize 等光标状态、真实 `.sub` 光标资源、macOS Fn / 功能键实机验收;键位重绑定 / 相机灵敏度持久化不是参考端已有能力 | | P2 | 🟡 | CJK 输入与本地化 | 支持 KEY→VALUE 查表和基础字体注入 | 补 `PythonIME` 中 / 日 / 韩候选窗、复数 / 性别规则、字体自动切换、完整 item / mob 名称与 tooltip 本地化 | -| P2 | 🟡 | 音频 | 有 BGM crossfade、UI 音效池和 3D 音效 | 补地图 BGM 自动选择、`.msenv` 环境循环音、武器 / 技能 / 怪物 / NPC 声音事件和原距离衰减规则 | +| P2 | 🟡 | 音频 | BGM crossfade、UI 音效池和 MilesLib 子集已接入:监听器、200/1000 缩放、音量曲线、普通 / 环境 / 角色 3D 播放、角色限频、停止与限幅淡出;本地 `.msa` 动作声音和 `.mss` 60 FPS 动作声音表走角色入口;地图 `AreaAmbienceData` + `.pra` 已接入 LOOP / ONCE / STEP、距离音量和卸载停止 | 补 EffectInstance 完整声音节点生命周期、完整武器 / 技能 / 怪物 / NPC 资源逐项验收和真机空间听感校准;`.msenv` 剩余项归入地图环境渲染与天气联动 | | P2 | 🟡 | 客户端外壳 | Godot 工程已有 macOS / iOS / Android 构建脚本 | 补 `config.exe` 对应的分辨率、显示、音量、阴影、语言和鼠标配置;Movie / Web / Logo、补丁器约束、Discord RPC 按发布目标取舍 | | P0 发布 | 🟡 | Pack 资产统一读取 | `PackMount` / `AssetSource` 已实现并有 roundtrip 测试 | `Metin2World` / `Metin2Model` 等仍有直接 `AssetResolver::resolve()` 的散文件路径;必须统一切到 `AssetSource::read/to_path()`,否则 `.epk/.eix` 打包客户端不等价 | @@ -591,11 +1553,11 @@ | 参考模块 / 能力域 | 当前实现 | 与 40250/ClientVS22 的差距 | 优先级 | |---|---|---|---| | `AccountConnector` / `PythonNetworkStreamPhase*` 登录与相位 | auth → game → 选人 → LOADING → PHASE_GAME 已打通;`LoadingScreen`、频道状态探针、Classic DirectEnter 和 Classic `GC_WARP` 重连 / 换图清理均有首版 | 登录 / 选服 / 选频道的原版错误码、表单校验、超时与会话失效文案,真实双连接 / 换图验收、服务器列表刷新和所有相位边界清理仍未逐项对齐 | P1 | -| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有 | 缺称号、帮会名、名字颜色和目标高亮框;`GC_DAMAGE_INFO`、经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 | +| `PythonChat` / `PythonTextTail` / `PythonPlayer` | 聊天四标签、私聊 / 组队 / 公会 / 喊话路由,实体气泡、名字、HP 条、伤害飘字和本地气泡已有;**头顶名字颜色已 1:1(增量 110,`name_color.gd` `GetNameColorIndex` / `SetEmpireNameMode`,`net_world` 驱动 `Label3D.modulate`);头顶公会名 / 称号色 `GetTitleColor` / 等级子标签已 1:1(增量 111,`text_tail.gd` `RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`);头顶聊天 / 信息尾标已 1:1(增量 112,`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / `TextTail_GetLivingTime` / `IsChatTextTail`,`net_world` 每 vid 一条 `"bubble"` + 5s 硬删 + 前缀剥离 + SHOUT·COMMAND 排除);头顶名字「是否显示」`IsAlwaysShowName` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` 资格已 1:1(增量 113,`name_show.gd` 纯静态,`net_world._name_shown()` 四路 + `game_option_ui` `display_option_changed` 信号 + `net_play.target_changed` 强显)** | 缺目标高亮框、`GC_DAMAGE_INFO` 伤害数字整合和屏幕空间血条(名字颜色 / 称号色 / 公会名 / 等级 / 聊天气泡增量 110–112 已 1:1,名字显示开关 113);悬停 `chr.Pick()` 强显 / 屏幕空间 `ArrangeTextTail` 防重叠未做(seam ⑩);经验 / 金钱变化的原版日志行、notice / big-notice 排版、历史滚动和 `InsultChecker` 过滤未完整迁移 | P1 | | `PythonPlayer` HUD / affect / target | HP、SP、经验、等级、目标血条、死亡复活窗和 affect 色块条已绑定 | 原版状态栏布局、图标贴图、持续时间倒计时、目标板按钮 / 交互、所有 point 类型和战斗日志格式仍是简化版 | P1 | -| `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺原版光标状态(talk / attack / buy / sell / pick 等)、相机模式与灵敏度配置持久化、键位配置、窗口冲突和空格连续攻击语义 | P1 | -| `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏和通用弹窗已有 | `ani_image`、`mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、鼠标物品态、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 | -| `PythonEffect` / `PythonFly` / `EffectLib` | `.mse` Particle → `GPUParticles3D`、技能 / 服务器特效触发、基础飞行球和动作事件已有 | `.dds` 粒子纹理、`.mde` mesh、SimpleLight、MovingType / TexAni 时间轴、骨骼挂点、真实弹道 / 命中事件和声音仍缺 | P1 | +| `PythonPlayerInput*` / `CameraProcedure` / `PythonApplicationCursor` | WASD、点地、触屏点选 / 单指相机拖动 / 双指捏合、桌面右键拖拽与滚轮缩放、网格 AABB 点选、相机遮挡淡出和碰撞、首版 TALK / ATTACK / PICK 光标和窗口键盘焦点已有;`GC_CHANGE_SPEED` 会校准本地移动速度 | 缺 BUY / SELL / CANT_GO 等完整光标状态、真实 `.sub` 光标资源、相机模式与灵敏度配置持久化;macOS F1–F12 仍需实机验收 | P1 | +| `EterPythonLib` / `PythonApplicationModule` UI 运行时 | 80 个真实 `uiscript` 可解析;约 20 类控件、窗口栈、modal、拖动标题栏、全局物品 attach / drop / cancel 和通用弹窗已有 | `ani_image`、`mark`、真实 `grid_table` / scrollbar / list 行为、`.dds` UI 贴图、复杂窗口的落点分支、焦点 / 锚点 / 层级和原版九宫格视觉仍未完全对齐 | P1 | +| `PythonEffect` / `PythonFly` / `EffectLib` | `.mse` Particle → `GPUParticles3D`、首个 `.dds`、位置 / 形状 / 方向 / 速度、技能 / 服务器特效触发、基础飞行球和动作事件已有;`.mde` v1/v2 几何帧、UV、`.ifl` 贴图和基础 MeshElement 材质已接入;Light 组、范围曲线、多贴图 TexAni 基础帧控件、旋转类型和 MovingType 直接 / 二次 Bezier 位置曲线已接入 | TexAni 随机 / 每粒子语义、完整 emitter advanced / cycle、完整时间轴、Direct3D 材质 stage、骨骼挂点、真实弹道 / 命中事件和声音仍缺 | P1 | | `PythonMiniMap` / `PythonBackground` | 圆形小地图、实体 / NPC / 任务点、观战者、领地边框、队员 / 队长标记、鼠标滚轮缩放;Atlas 已有区块底图、拖动、坐标查询、地图名称和玩家 / 队员标记;地形 / 建筑 / 水体 / 天气和昼夜首版已有 | 缺原 minimap / Atlas 贴图细节、世界箭头、地图驱动天气配置和原版窗口视觉 | P1 | | `PythonSystem` / `MovieMan` / `PythonApplicationWebPage` / `Discord` | Godot macOS / iOS / Android 构建脚本、基础窗口和音频设置入口已有 | 缺 `config.exe` 的分辨率 / 阴影 / 语言 / 鼠标配置持久化、Movie / Logo / Web 页面、补丁器约束及按发布目标取舍的 Discord RPC | P2 | | `ProcessCRC` / `ProcessScanner` / `PythonProfiler` | 反作弊明确列为不做 | 若服务端未来强制校验,只补最小协议上报;性能采样、崩溃上报和诊断工具不属于当前 1:1 交付范围 | — | @@ -844,54 +1806,336 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 ### 3. P1:移动、战斗和技能 -1. **本地移动是直线位置修改。** 当前控制器直接修改 X/Z,只做地形阻挡检查,没有 Actor 碰撞、 - 路径寻路、坡度处理、斜坡碰撞、行动预留、移动打断和坐骑状态。见 - [`player_controller.gd:221`](/Users/shenlei/Work/mt/mtgodot-poc/project/player_controller.gd:221)。 +1. **本地移动改为 Src/Dst 模型(增量 76)。** ~~当前控制器直接修改 X/Z,只做地形阻挡检查~~ + —— `player_controller.gd` 去掉 `_move_target` / `_has_target`,改 `_src_pos` / `_dst_pos` / + `_is_going` / `_dst_rot`,`walk_to()` → `_goto()` 对齐 `CInstanceBase::NEW_Goto()` + (`InstanceBaseMovement.cpp:251`):太近(`< MOVABLE_GROUND_DISTANCE_M` 1.0 m)不动、三门 + `_can_translate()`(`not frozen and not locked and not moving_skill` ≈ `__IsSyncing` / + `isLock` / `IsUsingMovingSkill`)未过时存 `_reserved_ground` + `RESERVED_GROUND_DELAY` 0.1 s + 延迟重试(≈ `__ReserveClickGround` / `NEW_IsEmptyReservedDelayTime`),`moving_skill` 只转向。 + WASD 分支置 `_is_going = false`(≈ `NEW_MoveToDirection`)。见 + [`player_controller.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/player_controller.gd) + + `project/player_move_test.gd`。**增量 93 补完 Actor-Actor 碰撞 + 坐骑转向速度**: + `player_controller._actor_blocked(next_pos)` 近似 `CActorInstance::TestActorCollision` + (`ActorInstanceCollisionDetection.cpp`)+ `CInstanceBase::CheckAdvancing` + (`InstanceBaseBattle.cpp:469`)—— 8 m 距离门(≈ `LengthSq > 800²` 直接不检)+ body 球 + 重叠(`ACTOR_BODY_RADIUS_M = 0.55` 相加)+「趋近」判定(`d_next < combined && d_next <= d_now`, + 远离放行,能从重叠里走出来),候选 = `pickables`,`dead` meta 跳过(≈ `rVictim.IsDead()`); + `_process` 平移逐轴门控(单 Actor 逐轴放行 = 滑移 ≈ `AdjustDynamicCollisionMovement`,两轴都 + 挡 = 停在上一帧位 ≈ `uCollisionCount==2` → `BlockMovement()` / `__InitializeMovement`); + `skip_actor_collision`(≈ `CActorInstance::CanSkipCollision()` / `__EnableSkipCollision`)由 + `net_play` 在 `setup()` / `_on_observer_mode` 按 `_observer_mode` 置位。坐骑转向速度:新增 + `rotation_speed_deg`(默认 `ROT_SPEED_DEFAULT_DEG = 1200` = `c_fDefaultRotationSpeed`)+ + `_turn_toward(target_yaw, dt)` 逐帧限幅收敛,替换原两处 `player.rotation.y = atan2(...)` 瞬转; + `net_play._refresh_motion_mode()` 在 `mounted` 时切到 `ROT_SPEED_HORSE_DEG = 300` + (= `c_fDefaultHorseRotationSpeed`,对齐 `CInstanceBase::MountHorse` → `SetRotationSpeed`, + `InstanceBase.cpp:954`),下马恢复 1200;`MODE_HORSE*` 动作模式已由增量 84 覆盖。 + `player_move_test.gd` 5 组 → 8 组。**仍未接**:~~Actor 之间碰撞(`TestActorCollision` / + `BlockMovement` 的 Area3D 近似,需远端 Actor 碰撞体)、坐骑 `MODE_HORSE*` 移速与动作模式 + (依赖 §6.4 + §3.2)~~、真实 body sphere 半径需 race 碰撞数据(`.col` / `CollisionData.h`)、 + `AdjustDynamicCollisionMovement` 精确滑移向量 + 门 / Actor 复合的 `uCollisionCount==2` + (现用逐轴近似)、`MOVABLE_GROUND_DISTANCE` 参考默认值校准、路径寻路 / 坡度处理、网络驱动 + 移动的 `__EnableSkipCollision()`(§3.2 网络状态包驱动的本地预测玩家尚未接)。 -2. **同步是距离阈值加 lerp。** 当前按固定时间或距离发送移动,回包后按距离阈值 snap/lerp, - 没有完整 sequence、authoritative tick、抖动缓冲、预测回滚、动作保留和服务器纠偏时序。 +2. **远端同步改为命令队列 + 硬阈值(增量 77 / 78 / 79,§3.2 完成)。** ~~当前按固定时间或距离发送移动, + 回包后按距离阈值 snap/lerp~~ —— `EntityStore` 的远端 Actor 位置改为 `std::deque` + 命令队列驱动,1:1 复刻 `CInstanceBase::StateProcess`(`InstanceBase.cpp:1278`):`mut_move()` + 入队(≈ `PushTCPState`)后 `drain_state_queue()` 按 `m_server_frame_ms`(`set_server_frame_ms()`, + 为 0 = 帧钟未对齐 → 立即释放,退回增量 76 时序)+ `__CanProcessNetworkStatePacket` + (`dead` / `knock_down` 挂队)逐条释放;`apply_state_cmd()` 走 `FUNC_WAIT` 1.0 / + `FUNC_COMBO/ATTACK/MOB_SKILL` 50.0 硬阈值 + `m_kMovAfterFunc` 到达后动作 + `skip_collision` + (≈ `__EnableSkipCollision`)。`SNetworkActorData::UpdatePosition` 式线性插值保留但只喂 + §2.1 可见性。**增量 78(part B)**:`net_play.gd` 上行按 `PythonPlayerEventHandler.cpp` + 拆成 6 个回调(统一出口 `_send_state`)—— OnMove 立即发 `FUNC_MOVE` 并重置 moving/waiting + 两个节流窗、OnMoving 300 ms 节流、OnWaiting(`_tick_on_waiting`)100 ms 节流且位移 ≥ 1 cm、 + OnStop 收尾 `FUNC_WAIT`、OnAttack 额外 `FUNC_COMBO`、新公共入口 `on_use_skill()` → + `FUNC_SKILL|motion`;`net_world.gd` 去掉自有 `follow_lerp`,`_process` 直接 + `n.position = e.pos`(位置插值全交 C++ `tick()`,只朝向仍 GDScript 平滑)。 + **增量 79(part C,§3.2 收官)**:`ClassicStream::server_frame_ms()`(≈ + `ELTimer_GetServerFrameMSec()`)由 `HDR_HANDSHAKE` 存的 `m_server_time_base = hs.time + + lDelta` / `m_client_time_base = now_ms()` 算出(握手前返回 0);`ClassicSession::process()` + 在 `m_world.tick()` 前 `set_server_frame_ms(m_stream.server_frame_ms())`,classic 后端 + 命令队列遂按服务器时间放行(m2dev 无 `lDelta` 对应物,恒 0 = 即时释放,无回归)。 + `drain_state_queue` 加防呆阀 `STATE_QUEUE_MAX_WAIT_MS = 1000`——`chk_time` 领先帧钟超过 + 1 s 立即放行以免远端 Actor 冻结(参考端无此上限,有意偏离)。 + 仍未接:完整 sequence / authoritative tick,**抖动缓冲不做、预测回滚不做**(§3.2 / §6 + 明确排除);真实服务器 `GC_MOVE` `dwTime` 时基核验、`__IsEnableTCPProcess` 表情锁、 + `GC_SYNC_POSITION` 是否清队列随对应 §3.x 处理。见 §3.1 / §3.2 修复批次。 -3. **普攻节奏仍是简化实现。** 范围、攻击间隔、硬直和连击窗口均为固定值或简单按攻速缩放, - 未使用 race、武器、动作 duration、命中帧、mount 和 Actor battle state。见 - [`net_play.gd:34`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_play.gd:34)。 +3. **普攻节奏部分对齐动作数据(增量 74)。** ~~范围、攻击间隔、硬直和连击窗口均为固定值或简单按攻速缩放~~ + —— 点击 / 攻击距离改为 `CLICK_DIST_DEFAULT_CM=150 / NPC_CM=500 / RESOURCE_CM=100` + (对齐 `InstanceBaseBattle.cpp:294`);普攻节奏改为读 `.msa` `ComboInputData.DirectInputTime` + (`metin2_anim.get_motion_data().next_combo`,缺组退化 `duration*0.9` / headless 兜底 + `DEFAULT_ATTACK_PERIOD`)再除以 `bAttackSpeed/100`。弓箭 `__GetBowRange()` 已在增量 85 接入 + (`_clickable_distance()` 里 `_is_bow_mode()` 改用 `_bow_range_cm()` = `2500-100` + + `_bow_distance_bonus`,NPC/采集物分支覆盖之,逐分支对齐 `NEW_IsClickableDistanceDestInstance` + + `CInstanceBase::__GetBowRange` `InstanceBase.cpp:694`)。仍未接: + `AttackingData` / `HitPosition` 驱动的命中帧 / 硬直 / 无敌帧的真实动态圆柱扫掠(§3.5, + 当前 reach + 正面弧近似)、mount 与武器规则、服务端合法性校准。见 + [`net_play.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/net_play.gd:200)。 -4. **自动攻击没有复现 ClientVS22 的目标预留逻辑。** 参考客户端会持续检查目标存在、死亡、 - 可攻击、坐骑限制、charge skill、睡眠状态,并预留点击动作;当前主要是检测距离后发攻击包。 +4. **自动攻击目标预留逻辑已移植(增量 75)。** ~~当前主要是检测距离后发攻击包~~ —— `net_play.gd` + 新增 `enum ReservedMode` + `_update_auto_attack()`(照 `PythonPlayer.cpp:207`,检查目标存在 / + 死亡 / 骑乘 / charge skill / 睡眠后 `_reserve_click_actor()`)+ `_reserve_process_click_actor()` + 严格 10 步(照 `PythonPlayerInput.cpp:877`,出距离 `pc.walk_to` 靠近 / NPC 走 `click_npc` / + `IN_SAFE` / `DEST_IN_SAFE` / `EMPTY_ARROW` 经 `signal cannot_act` / 第 10 步按 §3.3 节奏 + `_do_attack_swing` 后清预约)。仍未接:CLICK_ITEM / CLICK_POSITION / USE_SKILL 分派、安全区 / + 弓箭 / 骑乘等级 / dash 等判定位的服务端来源、`OnCannot*` 文案接 HUD。 参考:[`ClientVS22/PythonPlayer.cpp:207`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonPlayer.cpp:207)。 -5. **攻击动作、命中时机和网络包没有严格绑定。** 当前 combo 只在 0/1/2 之间循环,动作播放、 - hit frame、范围、角度、武器轨迹、击退和受击动作没有由真实 motion data 驱动。 +5. **~~攻击动作、命中时机和网络包没有严格绑定~~ —— §3.5 修改 1-5 已全部落地(增量 80/81/82/83)。** + ~~当前 combo 仍只在 0/1/2 之间循环(`net_play.gd _do_attack_swing` 的 `(_combo + 1) % 3`)~~ + —— 增量 81 已替换:`_do_attack_swing` / `_run_next_combo` / `_combo_process` / + `_on_end_combo` / `_clear_combo` 逐行对齐 `CActorInstance::InputComboAttackCommand` / + `__RunNextCombo` / `ComboProcess` / `__OnEndCombo` / `__ClearCombo` + (`ActorInstanceBattle.cpp:157/213/256/297/308`),按 `.msa` `ComboInputData` 三时间 + (`pre_input_time` / `direct_input_time` / `input_limit_time`)驱动 + `_combo_index` / `_is_pre_input` / `_is_next_pre_input`,段号取自增量 80 的段表 + (新静态绑定 `Metin2AnimPlayer.parse_combo_tables()`,按 `race & 3` × `combo_motion_mode<<16|type`)。 + ~~hit frame、范围、角度、武器轨迹、击退和受击动作仍未由真实 motion data 驱动。~~ —— + 增量 83 已接命中帧(见下);武器轨迹拖尾仍留桩。 + ~~`RaceData` 的 combo 段表需从 `.msm` 解析~~ —— **更正**:PC 连击段表**不在** `.msm` 里 + (`.msm` 只有 `BaseModelFileName` + `HairData`),而是 `assets/root/playersettingmodule.py` + 的 `chrmgr.ReserveComboAttackNew` / `RegisterComboAttackNew` 调用建在 `CRaceData` 上 + (`PythonCharacterManagerModule.cpp` → `CRaceData::ReserveComboAttack` / + `RegisterComboAttack`)。增量 80 新增 `formats/combo_table.{h,cpp}`(`fmt::PlayerComboTables`) + 把那批调用解析为按职业分块的 + `map>`,语义 1:1 对齐 `CRaceData` + (`Reserve` insert 不覆盖、`Register` 越界 / 未登记跳过),`formats_combo_table_test` + 合成 + 真实资源双覆盖。增量 82(part C,修改 1)导出 `.msa` `HitDataContainer` 多命中窗: + `formats/textscript.cpp` 补 `List Name { ... }` 解析(此前 `List HitPosition` 会破坏后续 + `Group`,`palbang.msa` 的 `MotionEventData` 因此丢失),`formats/msa.{h,cpp}` 把 `AttackingData` + 重写为 `std::vector`(`HitWindow{start_time, end_time, bone_name, weapon_length, + vector}`),同时吃老式内联(无 `HitDataCount`)与新式 + `AttackType` + `MotionType` + `HitDataCount N` + `Group HitData0i` 两种写法,新增 `motion_type` / + `hit_limit_count`,`attack_start_time` / `attack_end_time` 保留为窗 0 别名;`get_motion_data()` + 增加 `hit_windows` / `motion_type` / `hit_limit_count`;`formats_msa_test` 补合成 + `M2_ASSETS` + 真机断言(含真实双命中窗 `assassin/dualhand_sword/combo_01.msa`:右手窗 `Bip01 R Hand` + + 左手窗 `Bip01 L Hand`)。增量 83(修改 4,§3.5 收官)在 `net_play.gd` 落地命中帧: + `_begin_hit_windows()` 挥击起手缓存 `get_motion_data().hit_windows` / `motion_type` / + `hit_limit_count` / `invisible_time`;`_attack_process(dt)` 每帧按动作时间窗 `[t-dt, t]` + 与某个 `hit_window [start,end]` 相交时,遍历 `client.get_entities()` 里可攻击 / 未死 / 双方 + 非安全区的实体,`net_world.node_for(vid)` 取世界位做几何判定(对齐 + `InstanceBaseBattle.cpp:413 AttackProcess` + `ActorInstanceCollisionDetection.cpp:333 + __NormalAttackProcess`):`_hit_deduped()` 复刻 `m_HitDataMap`(`MOTION_TYPE_COMBO` 同窗同目标 + 一次、其余按 `invisible_time` 冷却),`_register_hit()` 复刻每窗命中上限(NORMAL/COMBO 16、 + SKILL `hit_limit_count`),命中 → `client.attack(_swing_skill, vid)`(`OnHit`→`SendAttackPacket`, + 普攻 `_swing_skill=0`),被击退目标帧末 `_flush_victim_list()` → `client.sync_positions()`(≤16, + 对齐 `PythonPlayerEventHandler.cpp:194 FlushVictimList`)。`_emit_swing()` 因此改为只有拿不到 + 命中窗数据(headless / 缺资源)才在挥击瞬间立即发 `CG_ATTACK`。几何判定为近似: + `reach = max(WeaponLength, 窗内采样最大水平偏移)` + 正面 `±60°` 弧(真实动态圆柱扫掠需骨骼矩阵 + + defending sphere,随 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言。 + ~~**剩余(增量 84)**:武器种类 → `combo_motion_mode` 映射(当前恒 `MOTION_MODE_GENERAL`)。~~ + —— **增量 84 已落地**:`net_play.gd` 补全 `MOTION_MODE_*`(`CRaceMotionData::EMode` 全 17 项) + + `ITEM_TYPE_WEAPON/ROD/PICK` + `WEAPON_SUB_*` 常量,新增静态 + `motion_mode_for(item_type, weapon_sub, mounted, poly, dress)` 逐分支复刻 + `CInstanceBase::RefreshState` 的 `SetMotionMode`(`InstanceBase.cpp:2851`,顺序 Poly→`GENERAL` + / 婚纱→`WEDDING_DRESS` / 镐→骑乘 `HORSE` 否则 `GENERAL` / 钓竿→骑乘 `HORSE` 否则 `FISHING` + / 骑乘 `switch(subType)` → `HORSE_{ONEHAND,TWOHAND,DUALHAND}_SWORD`·`HORSE_{FAN,BELL,BOW}`·默认 + `HORSE` / else `switch(subType)` → `{ONEHAND,TWOHAND,DUALHAND}_SWORD`·`BOW`·`FAN`·`BELL`· + `ARROW`/默认 `GENERAL`),`_equipped_weapon_vnum()`(主角 `parts[WEAPON]` 非 0 优先,否则 + `get_equipment()[WEAR_WEAPON].vnum`)+ `_refresh_motion_mode()`(读 `mount_vnum` / `polymorph` + / `wedding_dress` + `proto.item(vnum).{type,sub_type}` → `set_combo_motion_mode(...)`,换模式 + 内部 `_clear_combo()`),由 `_on_entity_info()` 末尾、`inventory_changed`(`window==2` EQUIPMENT)、 + `mount_changed`(主角)三处触发。`combat_fx_test.gd` 加 `FakeProto` + 15 段 `motion_mode_for` + 断言 + 2 段端到端(换匕首 → `DUALHAND_SWORD` 且 `_combo_index` 归零;再上马 → `HORSE_DUALHAND_SWORD`)。 + ~~仍留桩:弓箭 `__GetBowRange()`(§3.5 命中 / 点击距离,增量 85+)~~ —— **增量 85 已落地**: + `_clickable_distance()` 里 `_is_bow_mode()`(`combo_motion_mode` ∈ {`MODE_BOW`,`MODE_HORSE_BOW`}, + 对齐 `CActorInstance::IsBowMode` `ActorInstance.cpp:258`)改用 `_bow_range_cm()` = + `BOW_RANGE_BASE_CM`(`2500-100`)+ `_bow_distance_bonus`(`_on_points()` 读 + `points["bow_distance"]` = `POINT_BOW_DISTANCE` 34),NPC 500 / 采集物 100 分支覆盖之, + 逐分支对齐 `NEW_IsClickableDistanceDestInstance`(`InstanceBaseBattle.cpp:294`)+ + `CInstanceBase::__GetBowRange`(`InstanceBase.cpp:694`);`wire.h` 补 `POINT_BOW_DISTANCE=34` + + `M2Client::get_points()` 透出,`combat_fx_test.gd` §3.5 段加 8 段断言。 + `wedding_dress` 实体标志本工程暂无(读 `e.wedding_dress`,缺则 false)。 + `OnAttack` 的 `CG_CHARACTER_MOVE{FUNC_COMBO}` 已在增量 78 落地。 -6. **弓箭发射仍有固定超时兜底。** 当前优先消费 `.msa` 的 FLY 事件,但 pending skill 仍由约 +6. ~~**弓箭发射仍有固定超时兜底。** 当前优先消费 `.msa` 的 FLY 事件,但 pending skill 仍由约 1.5 秒定时器兜底;不同技能、攻速、动作长度和网络延迟下可能偏离真实事件,连续释放还可能 - 覆盖 pending skill。见 [`game_scene.gd:220`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd:220)。 + 覆盖 pending skill。~~ —— **增量 86 已落地**:删掉 `game_scene.gd` 的单槽 + `_pending_shoot_skill` + `get_tree().create_timer(1.5)`,换成 `_pending_shots: Array[int]` + FIFO(上限 8,满丢最旧)。每次弓挥击 / 弓技能起手 `_queue_shot(uSkill)` 压一个,`.msa` + `MOTION_EVENT_TYPE_FLY` 帧 `pop_front()` → `client.shoot()`(pop 即自清 = `_AutoClear`, + 连射多箭在途各自一发;对齐 `CActorInstance::ProcessMotionEventFly` + `ActorInstanceMotionEvent.cpp:290`)。`OnSetFlyTarget`(`ActorInstanceMotion.cpp:372`)改在 + `net_play._emit_swing`:`_is_bow_mode()` 时 `_send_fly_target()`(目标节点 → + `MapCoord.to_server_cm()` → `client.add_fly_targeting(vid, x, y)`;`OnGetFlyTargetPosition` + = 目标包围球中心,平面分量与脚点一致,`ActorInstanceFly.cpp:4`)+ `bow_shot_fired.emit`。 + ~~仍待:飞行物实体(`CFlyingManager` / `FlyingInstance`)、`__CanShot` 弓箭合法性~~ —— + **增量 92 已落地**:新增 `project/fly_object.gd`(`FlyData` / `FlyInstance` / `FlyManager`), + 对齐 `GameLib/FlyingObjectManager.{h,cpp}`(`CreateFlyingInstanceFlyTarget`、`Update()` 的 + `while` 迭代 + `erase`、`CreateIndexedFly` 的 `NORMAL` / `FIRE_CRACKER` / `AUTO_FIRE` 分支 + 常量)+ `FlyingInstance.{h,cpp}`(`Create` → `__SetDataPointer` → `__SetTargetDirection`; + `Update()` 逐帧 `vel += accel*dt` → `vel.z += gravity*dt` → `remain_range -= |movement|` → + 线段-点 `square_distance_between_linesegment_and_point < m_fBombRange²` 命中 → + `if (m_canAttack) OnShootDamage()`(本 POC 转 `shoot_damage(vid)` 信号)→ `m_iPierceCount` + 则 `--` + `__Bomb()` 续飞、否则 `__Explode()` + `return false`;`m_fRemainRange<0` → + `OnExplodingOutOfRange` + `__Explode(false)`(不 `__Bomb`);位置目标同线段判定; + `m_bHitOnBackground` 的 `GetTerrainHeight(x,-y) > z`)+ `FlyingData.cpp __Initialize()` + 默认值(米制换算)。`_seg_point_sq_dist` 1:1 移植 `EterLib/GrpMath.h:80`。`net_world.setup()` + 挂 `FlyManager` 子节点,`_on_fly()` 把原 tween「发光球」占位换成 `_fly_mgr.spawn(start_w, + target_node|goal_w, false, FlyData, type)` 真实弹道,新增 `signal fly_exploded(world_pos, + cause)`。`net_play._can_shot(target_vid := -1) -> String` 防御式重建 `__CanShot`(`grep + -rn "CanShot|__CanShot|CanShoot"` 全树无命中——据 `IFlyEventHandler::OnNoTarget` / + `OnNoArrow` `FlyHandler.h`、`NEW_Attack` `InstanceBaseBattle.cpp:355` 的 `IsDead` 门、 + `__GetBowRange` `InstanceBase.cpp:694` 重建):非弓 → `"NOT_BOW_MODE"`;`_arrow_count == 0` + → `"EMPTY_ARROW"`(`-1` = 未跟踪 / 无限,放行);`_target_vid` / `_vid_reserved` 均 ≤ 0 → + `"NO_TARGET"`;目标实体已知且不可攻击 / 已死 → `"NO_TARGET"`(`te.is_empty()` 不拒—— + headless 容差,与 `_send_fly_target`「有节点即可」一致);目标世界距离 `* CM > _bow_range_cm()` + → `"OUT_OF_RANGE"`;否则 `""`。`_emit_swing` 弓分支据此加门:`_can_shot()` 非空 → + `_emit_cannot(why)`(走 `OnNoArrow` / `OnNoTarget` 文案),不发 `CG_FLY_TARGETING`、不排 + `OnShoot`;其余起手动作照常(seam:参考端整挥击中止)。新增 `project/fly_test.gd`(第 14 个 + GDScript 回归)。C++ 21/21(无 C++ 改动)+ 14 GDScript 全绿。已文档化 seam:`GC_CREATE_FLY` + 在本 40250 客户端无包处理器(仅 `NetStream.cpp` 头串索引 70)→ 实际驱动走 POC 自有 + `fly_cue`;`__CanShot` 防御式重建;重力参考端作用于 `m_v3Velocity.z`=上、本 POC 映射到 + `vel.y`= `-Y`;怪物球体碰撞 / `CullingMgr.ForInRange` / 建筑体素 r=1 判定未移植(客户端 + 飞行物只做命中"时机",伤害服务端裁决);无 `.fly` 数据文件加载器 → 合成箭参数。仍待:真服 + `CG_FLY_TARGETING` z 分量、`.fly` 数据文件解析。见 + [`fly_object.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fly_object.gd) / + [`game_scene.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/game_scene.gd) + `_queue_shot` / `_on_local_motion_event`。 -7. **受击状态分裂。** `EntityStore.stunned` 和 `NetPlay._hitstun_until` 是两套互不统一的状态, - 服务端 stun、客户端硬直、远程受击动作和恢复时机可能不一致。 +7. **受击状态分裂。** ~~`EntityStore.stunned` 和 `NetPlay._hitstun_until` 是两套互不统一的状态~~ + —— 增量 73 已删除 `_hitstun_until` 本地硬直计时器,「能否行动」统一走 + `_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`(对齐 + `InstanceBase.cpp:1247` / `InstanceBaseBattle.cpp:355` / `:310` ≈ `IsDead || IsStun || + IsPushing`)。`Entity.knock_down` 由本地受击 `.msa` 播放生命周期驱动,`dead` / `stunned` + 保持服务端包驱动。**剩余**:远端实体 `knock_down` 填充、真实 40250 受击 / 击退 / + `AFFECT_STUN` 清除时序、`mut_stun` 目前无独立清除包。 -8. **技能缺少统一合法性检查。** 当前主要检查 skill id 是否大于零,没有统一验证技能归属、等级、 +8. **~~技能缺少统一合法性检查。~~** ~~当前主要检查 skill id 是否大于零,没有统一验证技能归属、等级、 master、cooldown、消耗、目标类型、目标数量、距离、角度、当前动作、PK 和坐骑限制。 - `target_vid` 等参数还可能先被转换为无符号整数。 + `target_vid` 等参数还可能先被转换为无符号整数。~~ —— **§3.8 修改 1/2/3 于增量 87/88/89 + 全部落地。** + —— **增量 87 落地判定位数据层(§3.8 修改 2)**:POC 无 C++ skill proto,技能数据只在 GDScript + `project/ui/skill_table.gd`,现按 `RegisterSkillDesc`(`PythonSkill.cpp:280`)把 skilldesc + 第 11/12 列折成 `attr_bits` / `need_weapon_bits` 位掩码 + `skill_type`,`CPythonSkill` 的 + `IsCanUseSkill / IsStandingSkill / IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / + IsAutoSearchTarget / CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill / + CanUseWeaponType / IsOnlyForGuildWar` 等谓词逐条落地,`skill_test.gd` +34 断言。 + —— **增量 88 落地三层校验(§3.8 修改 1)**:新建 `project/player_skill.gd`(`extends RefCounted`, + 纯校验器,不发包 / 不驱动动画,返回 `{ok, code, target_vid}`),逐行移植 `PythonPlayerSkill.cpp`: + (a)`click_skill_slot()`(`:106` 入口分派:越界 / 无数据 / 公会技路由 / 被动 / 切换技)、 + (b)`can_use_skill()`(`:439` 全局:观察态 / 骑乘中且骑乘术 `109` `grade<1 && level<20` / 不可行动)、 + (c)`check_skill_usable()`(`:166` 完整前置顺序:骑乘⇄马术 `NOT_HORSE_SKILL` / `HAVE_TO_RIDE` → + 安全区攻击技 `IN_SAFE` → 被动 → 空瓶 `27995` → 毒瓶 `27996` → 钓鱼 `REMOVE_FISHING_ROD` → + 未学 `NOT_YET_LEARN` → 武器不匹配 `NOT_MATCHABLE_WEAPON` → 无箭 `EMPTY_ARROW` → + dash / 蓄力 / 冷却 `WAIT_COOLTIME` → HP / SP)、(d)`use_skill()`(`:460`:私人商店静默、 + `__CheckSpecialSkill` 钓鱼 `123` / 连击 `122`、切换技、施法中 `ALREADY_CASTING`、 + `_resolve_target()` 目标矩阵:同盟 / 安全区 / 可攻击 / 尸体 → `CANNOT_USE_SELF` / + `ONLY_FOR_ALLIANCE` / `CANNOT_ATTACK_ENEMY_IN_SAFE_AREA` / `CANNOT_ATTACK` / `ONLY_FOR_CORPSE` / + `NEED_TARGET`)。命名常量 `SKILL_MAX_NUM=255`(`Packet.h:2041`)/ `SKILL_TYPE_GUILD=3` / + `RIDING_SKILL_INDEX=109` / `ITEM_EMPTY_BOTTLE=27995` / `ITEM_POISON_BOTTLE=27996` / + `SKILL_INDEX_COMBO=122` / `SKILL_INDEX_FISHING=123`(`PythonPlayerModule.cpp:6-7`); + `SILENT_CODES[]` 13 个无文案码 + `static is_silent_code()`。运行期状态尽量走 `client` + (`get_main_vid` / `get_entity` / `get_skills` / `get_target` / `get_inventory`),其余经 + `net_play.skill_context()` 每次注入(观察 / 骑乘 / 安全区 / 钓鱼 / 施法中 / 可行动 / dash / + `weapon_sub_type` / `arrow_count`)。`ui/quickbar.gd activate()` 技能分支施放前跑三层, + `TOGGLE_OFF` → 关闭切换技 + `skill_activated`,非静默码 → 新信号 `skill_rejected` → + `game_scene` 转 `net_play.cannot_act.emit(code)`(同一条 `OnCannotUseSkill` 文案通道), + gate 解析出的 `target_vid` 覆盖点击目标。新增 `project/player_skill_test.gd`(读真 + `skilldesc.txt`,覆盖 (a)(b)(c)(d) 每条分支 + 目标矩阵 + `is_silent_code`)。 + —— **增量 89 落地边界收紧 + 短蓝短血校验(§3.8 修改 3,三层全落地)**:`net_bounds.h` + 新增 `SKILL_MAX_NUM=255` + `skill_index(v)=0= SKILL_MAX_NUM` 门(`net_bounds_test.cpp` +4 断言)。`skill_table.gd` `_load_cooltime` + → `_load_msk`,加解析 `.msk` `NeedSPFormula` + `_SKILL_POWER[]`(`LocaleService_GetSkillPower` + INTERNATIONAL 表,`Locale.cpp:126`)+ `_skill_point()` / `need_sp()`(≈ `GetNeedSP`, + `PythonSkill.cpp:1426`;无公式返回 `-1`,POC 约定与参考的 0 相异以免误挡)/ `need_hp()` + (照抄 `__CheckShortLife` 复用 `GetNeedSP` 的怪癖)。`player_skill.gd` +`SKILL_INDEX_SUMMON=131` + + 上下文 `cur_sp` / `cur_hp`(`-1`=未知不判),`_check_short_mana` / `_check_short_life` 从桩 + 改为逐条移植 `__CheckShortMana`(`:320`)/ `__CheckShortLife`(`:354`),转 `NOT_ENOUGH_SP` / + `NOT_ENOUGH_HP`;HP/SP 由 `net_play.skill_context()` 读 `get_points()` 供入。`player_skill_test.gd` + +“(c) §3.8 修改 3”段。C++ **21/21** + **13** 个 GDScript 回归全绿。见 + [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.8 / §3.9 / C.5。 -9. **公共网络 API 缺少参数边界检查。** `attack`、交易、仓库、物品和技能接口存在负数、窗口、 - cell、display position、count 等参数直接 cast 的情况,服务端拒绝不能替代客户端自身的协议校验。 +9. **公共网络 API 的 wire 参数边界已补齐,但业务合法性仍未完成。** 增量 52 已在 + `M2Client` 所有相关窄类型转换前拒绝负数、溢出、错误 Variant、超限槽位 / 数量 / 文本,覆盖 + `attack`、交易、仓库、物品、技能、社交、公会、Cube 和私人商店;剩余是技能目标 / 冷却、背包 + 规则、权限和地图坐标等业务语义,不能由通用整数边界代替。详见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) + §3.9。 -10. **技能升级的传输方式已与参考端一致,但门控和状态流程不完整。** ClientVS22 本身也通过 - `/skillup ` 发包;当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、 - 死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。见 - [`m2_client.cpp:850`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:850)、 - 参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)。 +10. **~~技能升级的传输方式已与参考端一致,但门控和状态流程不完整。~~** ClientVS22 本身也通过 + `/skillup ` 发包;~~当前缺少参考端的可升级条件门控(技能组、等级上限、技能点、 + 死亡 / 变身 / 骑乘等状态)、加号按钮显示规则、失败提示和升级后的技能状态刷新。~~ + —— **增量 90 落地加号按钮门控(§3.10)**:`skill_ui.gd` `refresh()` 逐行移植 + `uicharacter.py` 的 `RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` / + `CanShowPlusButton` —— 技能组门(`get_skill_group() != 0`,对应 `__CanUseSkillNow`)、 + 三个技能点来源分开取(`skill_active` / `skill_support` / `skill_horse`,`wire.h` 加 + `POINT_SKILL=28` / `POINT_SUB_SKILL=27` / `POINT_HORSE_SKILL=113`、`get_points()` 透出)、 + `skillGrade==0`、马术页 `skillLevel < 20`、主动页 `skill_table.can_level_up_skill(id, level)` + (= `skillCanLevelUpSkill`,本 40250 build 里前置技能 / 前置属性字段无人填充 → 化简为 + `level < GetMaxLevel() && !CANNOT_LEVEL_UP`;`GetSkillLevelLimit` 无源数据列 / 记号 → 恒 0)、 + 辅助页 `HIDE_SUPPORT_SKILL_POINT` 整页隐藏。`skill_table` 加 `max_level_of` / + `skill_level_limit` / `can_level_up_skill`。`M2Client::skill_up()` 入参收紧为 + `mtnet::bounds::skill_index`(与 `use_skill` 同一谓词)。升级结果由 `GC_SKILL_LEVEL` + 经 `skills_changed` 驱动刷新,不本地伪造。`skill_test.gd` +`§3.10` 段。 + C++ **21/21** + **13** GDScript 全绿。见 + [`m2_client.cpp:961`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:961)、 + 参考 [`PythonSkill.cpp:1833`](/Users/shenlei/Work/mt/40250/ClientVS22/source/UserInterface/PythonSkill.cpp:1833)、 + [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §3.10 / C.5。 ### 4. P1:物品、装备、商店和社交 -1. **24 个 wear 位置进入数据层,但没有完整视觉和规则。** costume、翅膀、饰品、双持、左手、 - anti-flag、遮挡、职业/性别变体、部位隐藏、卸装复位、远程角色换装和逐部位高光仍不完整。 +1. **24 个 wear 位置进入数据层,但没有完整视觉和规则。** + ~~costume、翅膀、饰品、双持、左手、anti-flag、遮挡、职业/性别变体、部位隐藏、卸装复位、 + 远程角色换装和逐部位高光仍不完整。~~ **增量 96(§4.1 修改 1 + 修改 3)**:`proto` 层此前 + (§3.5)已导出 `type` / `sub_type` / `anti_flags` / `wear_flags` / `limits`(校验数据层齐备); + `ui/inventory_ui.gd._to_wire()` 按 `REF/UserInterface/GameType.h:40-112` 分流 wear 槽—— + 基础 11 位(`EWearPositions` BODY..SHIELD,`WEAR_MAX_NUM=11`)走 `WINDOW_EQUIPMENT(2)` + + 零基 wear cell;`ENABLE_COSTUME_SYSTEM` 的 `c_Costume_Slot_Start(90+19)` / `Hair(90+20)`、 + `ENABLE_NEW_EQUIPMENT_SYSTEM` 的 `Ring1(90+21)` / `Ring2(90+22)` / `Belt(90+23)` 在客户端 + 无独立窗,继续用 `WINDOW_INVENTORY(1)` 全局 cell 109..113;新增常量 `WEAR_COSTUME_BODY/HAIR`、 + `WEAR_RING1/RING2`、`NEW_EQUIP_WEAR_START=19`、`WINDOW_EQUIPMENT=2` 全部 1:1 自 `GameType.h`; + `_add_extended_cells` 由「wear 11..34 连续 24 格」收窄为「19..23 五个真实新装备位 + 16 腰带格」; + `inventory_test.gd` 增 7 条 wire 断言(原 `_to_wire(113)==[1,113]` 转绿)。 + **增量 97(§4.1 修改 2)**:40250 客户端不做 `CanEquipNow` 前置门(`PythonItemModule` 只暴露 + `IsWearableFlag` / `GetLimit`,仲裁在服务器 `char_item.cpp`),故按 §3.9 `__CanShot` 方式在 + GDScript 侧防御性重建——新增 `project/equip_rules.gd`(`extends RefCounted`),枚举 1:1 自 + `REF/GameLib/ItemData.h`(`EItemAntiFlag` classic 排列 / `EItemWearableFlag` / `EWearPositions` + + `GameType.h` 19..23 / `ELimitTypes` / `EItemType` + `EArmorSubTypes`·`ECostumeSubTypes`)。 + `can_equip(item, wear, ctx) -> {ok, code, need}`:目标槽解析 → anti-flag 性别(`ANTI_SEX`)→ 职业 + (`ANTI_JOB`,`race%4`)→ 帝国(`ANTI_EMPIRE`,`GC_EMPIRE 1/2/3`)→ `slot_accepts()` + (`NOT_WEARABLE_HERE`)→ limit 逐条(`LIMIT_LEVEL` / `LIMIT_STR/DEX/INT/CON`,属性 `<0` 跳过)。 + `inventory_ui.gd.use()` 对常规背包格的可穿戴物品跑此门,拦下 → `_set_hint()` + 新 + `signal equip_rejected(code, need)` + 不发 `use_item`;加公开 `can_equip_item()` / `_equip_ctx()` + (`get_points().level` + `get_entities()` 主角 `race/empire/level`;`st/dx/ht/iq` = `-1` seam)/ + `_equip_reject_hint()` 中文文案。新增 canonical 测试 #17 `equip_rules_test.gd`(33 断言)。 + **增量 98(§4.1 修改 4 + 修改 5 字段序)**:穿头盔覆盖发型槽、costume(parts[ARMOR]) 压过本体 + armor 在增量 96/97 已由 `equip_model.gd` 落地;本次补齐**变身 / 动物时装 / 婚纱的整件遮挡**—— + 新增 `project/part_hiding.gd`(`extends RefCounted` 纯函数),常量 1:1 自 + `REF/UserInterface/InstanceBase*.cpp`:`POLY_SHAPE=0`(`IsPoly()`→`SetShape(0)`)、 + `ANIMAL_WEAR_SHAPES=[100..103]`(`__IsShapeAnimalWear`:2834 → `SetWeapon`:2747 / `SetParts` 返回 + false)、`WEDDING_DRESS_SHAPE=201`(`IsWearingDress`)、`BOUQUET_VNUMS=[50201..50204]` + (`c_iBouquets`,`__IsChangableWeapon`:901 除捧花外 false)。`equip_model.gd.refresh()` 刷新序改为 + 对齐 `ChangeArmor`(`SetArmor→SetHair→SetWeapon`)= 身体→头发→武器→盾;解出身体 shape 后过 + `PartHiding`:动物 / 怪物时装隐藏 `weapon_gr2`+`shield_gr2`+`hair_gr2`,婚纱非捧花隐藏武器+盾、 + 头发保留。`net_world._apply_field_updates()` 确认 `set_armor→set_weapon→set_hair` 字段序已 1:1 于 + `NetworkActorManager.cpp:473`。新增 canonical 测试 #18 `part_hiding_test.gd`(45 断言)+ + `equip_model_test.gd` 补 7 段。 + **仍为 seam**:精确 tie-break 顺序 / 已穿同槽替换 / `LIMIT_PCBANG`·`REAL_TIME*` / 属性 4 项按名读取 + 留待接服务器 `char_item.cpp` 比对;**变身态**(40250 实体表无 `poly_race` 字段,`_is_poly()` 恒 + `false`,等 `GC_CHARACTER_UPDATE` 带变身 race);**远端 PC 换模链首版已在增量 99 落地**—— + `game_scene` 对 `ch_type==0` 创建 `RemotePlayerView`,复用真实 `PlayerView` race / GR2 / MSA; + 远端 `EquipModel` 只消费 `awPart[ARMOR,WEAPON,HEAD,HAIR]`,`net_world` 按四字段调用 setter, + 不读取本地 `get_equipment()`。所有职业 / race、变身字段、远端真服换装和精确视觉仍待验收。见 + [`inventory_ui.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/inventory_ui.gd) / + [`equip_rules.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/equip_rules.gd) / + [`part_hiding.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/part_hiding.gd) / + [`equip_model.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/equip_model.gd),详见 + [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §4.1 / C.5。 -2. **物品拖放不是原版鼠标物品态。** 缺少全局持有物品、鼠标跟随图标、跨窗口拖放、拖动取消、 - 多格占用、stack split、装备预览和统一失败恢复。 +2. **物品拖放已有全局首版,但还不是完整原版鼠标物品态。** 已有全局持有物品、鼠标跟随图标、 + 背包 / 装备 / 腰带到背包 / 快捷栏投放、safebox / mall / shop / exchange / private_shop 的 + 首版落点、无效区域取消、stack split、实例元数据传递和统一失败恢复;仍缺多格占用、装备预览、 + 全部窗口的原版光标 / 音效和真服回滚语义。 -3. **物品 tooltip 只覆盖基础信息。** sockets、attributes、anti flags、wear flags、requirements、 - duration、refine、value、绑定状态、装备对比和本地化描述没有完整迁移。 +3. **物品 tooltip 已覆盖固定字段与部分特殊分支,但仍未完整迁移。** 增量 58 已接入 + `limits[2]`、`applies[3]`、`values[6]`、实例 `attrs[7]` / `sockets[3]`、精炼关联和英文 + `itemdesc.txt`;增量 60 已补鱼竿 / 镐、鱼长度、彩票、混合物品和龙魂基础提示;增量 62 已补 + 技能书 / 遗忘书 / 变身道具标题和基于限制字段的实时剩余时间。仍缺 CJK 文案 / 字体、其他 + 特殊分支、装备对比(参考源码未发现对应方法)和完整计时来源语义。 4. **Safebox 是列表和自动找空位。** 当前 deposit 使用下一个空位,缺少 45 格网格、分页、指定 目标格、仓库内移动、金币存取、密码修改、重试限制、完整错误状态和原版互斥规则。 @@ -900,16 +2144,17 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 5. **交易缺少事务状态。** 当前是两列文字列表,没有锁定、接受后修改自动取消接受、物品占用、 服务器回滚、交易失败恢复、对方物品变化提示和交易中的背包锁定。 -6. **NPC 商店仍是基础列表。** 缺少物品格、图标、购买确认、货币类型、库存限制、busy 状态、 - 精确错误提示和对应音效。 +6. **NPC 商店已有货位网格与购买确认,但仍非完整原版商店。** 增量 59 已接入 5×8/40 格货位、 + 空位禁用、货架全局槽号、tooltip 和 BUY 确认(固定数量 1);仍缺真实图标 / `.sub`、非金币货币、 + busy 状态、完整库存限制、精确回包和对应音效。 7. **私人商店是兼容实现。** 虽然 `CG_MYSHOP` 已能发送,但原版库存模型、拖放、价格编辑、 开店前检查、库存锁定、撤下回滚和其他玩家查看流程尚未完整迁移。 -8. **快捷栏类型映射和键位语义不完整。** 参考端可执行类型只有 `INVENTORY(1)`、`SKILL(2)`、 - `EMOTION(3)`,没有“命令类型”;当前 `m2_client.cpp` 仍把 3/4 映射为 command/emotion, - 与参考端不兼容。当前还缺表情执行、物品拖入、鼠标物品态、特殊技能冷却、死亡 / 变身 / - 换武器后的完整刷新。见 [`m2_client.cpp:878`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/m2_client.cpp:878)。 +8. **快捷栏类型映射和键位主语义已修正,刷新边界仍不完整。** 参考端可执行类型只有 + `INVENTORY(1)`、`SKILL(2)`、`EMOTION(3)`,当前已统一该编号、接通表情执行、4×8 显示、 + inventory → quickslot 投放以及数字 / F1–F4 / Shift 入口;仍缺特殊技能冷却、死亡 / 变身 / + 换武器后的完整服务端刷新。见 [`quickbar.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quickbar.gd)。 9. **组队状态是近似定义。** affect 标签仍有暂定映射,队伍技能冷却、队长权限动态变化、离线、 死亡、距离状态和多账号行为还未完成真实验收。 @@ -919,19 +2164,81 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 ### 5. P1:任务、NPC 和世界玩法 -1. **Quest EventManager 只支持少量标签。** 当前支持 `ENTER`、`CLEAR`、`NEXT`、`DONE`、 - `QUESTION`、`INPUT`、`SELECT_ITEM`、`LETTER`,但 `COLOR`、`DELAY`、`WAIT`、`IMAGE`、 - 立绘、样式、滚动、多阶段按钮和 `CONFIRM_WAIT` 等被忽略。见 - [`quest_dialog.gd:102`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_dialog.gd:102)。 +1. **Quest EventManager ~~已从少量标签扩展为常用标签的解析和部分界面 side effect,但不是完整事件机~~—— + 标签表 / 索引解析 / 命令分派 / 状态字段已 1:1(增量 95)。** ~~当前支持 `ENTER`、`CLEAR`、`NEXT`、 + `DONE`、`QUESTION`、`INPUT`、`SELECT_ITEM`、`LETTER`,并识别 `COLOR/COLOR256`、`ITEM/MOB`、 + `WINDOW_SIZE`、居中、`CONFIRM_WAIT`、图片资源加载、`DELAY/WAIT`、`QUESTBUTTON/QUESTBUTTON_CLOSE`、 + `ADDMAPSIGNAL/CLEARMAPSIGNAL/SETCMAPPOS`;仍未按参考端逐事件推进等待、分页……~~ 增量 95 把 + `project/ui/quest_dialog.gd` 由单遍手写 `parse_script` 重写为 `CPythonEventManager` 1:1: + `const EVENT_TYPE_MAP`(42 tag,键 = tag 字符串)+ `enum { EVT_LETTER .. EVT_SELECT_ITEM }` + (顺序 = `REF/UserInterface/PythonEventManager.h:35` 的 `EEventType`)+ `_get_script_event_index()` + (= `GetScriptEventIndex` `PythonEventManager.cpp:1093`:前导 `/` → `EVENT_POSITION_END` + 剥离, + 未知名字 → 跳过不进 `match`)+ `class EventSet extends RefCounted`(= `SEventSet` `:98`,字段名 + 1:1:`iyLocal/isLock/lLastDelayTime/iCurrentLetter/CurrentColor/strCurrentLine/isConfirmWait/ + iConfirmEndTime/lWaitingTime/iRestrictedCharacterCount=30/iAdjustLine/isTextCenterMode/isWaitFlag/ + nAnswer` 等)+ `init_event_set()`(`__InitEventSet` `:72`)/ `insert_line()`(`__InsertLine` `:976`)/ + `clear_line()`(`ClearLine` `:953`)+ `_process_event_set()`(= `ProcessEventSet` `:377` 的 `switch` + 逐 `case` 对拷:`DELAY`/`COLOR`/`COLOR256` 的 START vs `/END`、`LETTER`/`ITEM`/`MOB` 的 + `iCurrentLetter >= iRestrictedCharacterCount` 断行、`NEXT`/`DONE`/`INSERT_IMAGE` 的 `iAdjustLine += 2`、 + `WAIT`、`QUESTION`(`MakeQuestion`)、image family、`SETMSGPOS`/`ADJMSGPOS` 空实现(`:562/:566`)、 + `SET_CAMERA`(`GetCameraSettingFromArgList` `:48`)/`BLEND_CAMERA`/`RESTORE_CAMERA`、`FADE_*`/`WHITE_*`、 + `DUNGEON_RESULT`、`CONFIRM_WAIT`/`END_CONFIRM_WAIT`、`RUN_CINEMA` → `pass`)+ `_tokenize()` / + `_tag_kv()` / `_arg_s/_arg_i/_arg_f`(keyword 优先、位置回退)。`parse_script()` 返回字典保旧契约 + 全键不变,新增 `insert_images/dungeon_result/adjust_line/waiting_time/is_locked/wait_flag/ + answer_count/cinema_file/end_confirm`;首命令 `RUN_CINEMA`(`RegisterEventSetFromString` `:142` + 特例)→ `es.cinema_file` + 新 `signal cinema_requested`。裸网络文本折成合成 `LETTER` + (`is_raw=true` → 跳过 30 字断行,参考脚本只有显式 `[LETTER]`)。自检 + `project/quest_event_test.gd`(第 16 个 canonical,23 组断言),`quest_test` / `select_item_ui_test` + 仍绿。**仍为 seam**:逐帧 `UpdateEventSet` `:312` 时间推进(`lLastDelayTime` 递减 / `isWaitFlag` + 暂停 / `isConfirmWait` 倒计时刷新 `m_strLeftTimeString`)、`Skip` / `EndEventProcess`、 + `iConfirmEndTime` 的 `timeGetTime`、`.msc` 事件文件加载(`RUN_CINEMA` 换文件)、`MakeNextButton` + NEXT vs DONE 文案区分 + `DoneEvent()` 回调、`camera_event_requested` / `fade_event_requested` / + `cinema_requested` 消费端与 §7.6 相机模式对接、物品名颜色 `(1.0,0.2,0.2)`。见 + [`quest_dialog.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_dialog.gd) / + [`quest_event_test.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/quest_event_test.gd)。 -2. **任务日志不是完整 Quest 系统。** 当前主要是标题、counter 和 clock 文字列表,缺少真实 - 图标、开始/更新/删除状态、倒计时、QuestButton、世界箭头、边缘指示和点击定位。 +2. **任务日志已补 5 行窗口、滚动、默认图标、倒计时和独立 QuestButton 条带,但不是完整 Quest + 系统。** 增量 101 按参考端 `QUEST_MAX_NUM=5` 固定显示 5 行;超过 5 条滚动到离散的 + `questShowingStartIndex`,任务行点击发送 `-2147483648 + questIndex`,空 icon 使用 + `season1/icon/scroll_open.tga`。QuestButton 的开始 / 删除 / 点击发送、小地图任务信号已接入; + 仍缺多列定位、世界箭头、屏幕边缘指示、完整任务资源视觉和真实任务生命周期。 -3. **钓鱼、副本、坐骑和观战者只有部分协议入口。** 仍缺鱼获落袋、副本入口和计时、DungeonBlock、 - 坐骑模型与动作、坐骑攻击限制和速度、观战视角以及换图后的完整清理。 +3. **钓鱼输入、水面位置判定和反馈消息已完成首版,但钓鱼、副本、坐骑和观战者仍不是完整玩法。** 增量 102 已接入 + 钓鱼模式攻击键 → `CG_FISHING`、方向 / 地面点击取消和 500ms 节流、特殊技能 123 快捷栏入口, + 以及 `GC_FISHING` 五类事件的客户端状态维护;增量 103 已接 `GetFishingRot` 等价的 + `ATTRIBUTE_WATER` 扫描和选中 heading;增量 104 已接 wrong-place / failure / unknown / notify / + success locale 消息和 item proto 类型判定;增量 105 已接坐骑 vnum→level 攻击门和新坐骑技能门。 + 仍缺真实 `GC_ITEM_*` 鱼获事务 / 背包校验、副本入口 / 计时 / DungeonBlock、坐骑模型 / `MODE_HORSE*` + 动作 / 移速、观战自由视角 / 完整 ADD/REMOVE/MOVE 生命周期和换图后的完整清理。 -4. **PVP、PK 和决斗没有完整规则。** 当前有部分关系和字段,但缺 PK 模式上行、名字颜色、 - 可攻击判断、阵营限制、保护区、目标板、挑战/复仇和禁止攻击逻辑。 +4. **PVP、PK 和决斗~~没有完整规则~~——可攻击判据已 1:1(增量 94)。** ~~当前有部分关系和字段, + 但缺……可攻击判断……~~ 增量 94 新建 `project/entity_rules.gd`,`is_attackable_instance(self_e, + victim_e, ctx)` 逐行照抄 `CInstanceBase::IsAttackableInstance`(`REF/UserInterface/InstanceBase.cpp:2147`)—— + `IsStone` / `IsPC` / `IsEnemy` / `IsPoly` 四分支、决斗三态、`PK_MODE_GUILD` 同公会提前放行、 + `victim.IsKiller()` && !同队、`PK_MODE_FREE/GUILD` 的 `!= PROTECT` && !同队分支、`IsSameEmpire` → + `IsPVPInstance` / `PK_MODE_REVENGE` && 冲突善恶、异帝国直接可打、`victim.IsEnemy()` / `IsWoodenDoor()`; + 附 `alignment_grade`(:502)/ `alignment_type`(:524)/ `is_conflict_alignment_instance`(:2116)/ + `_is_same_empire`(:470)/ `_is_pvp_instance`(`InstanceBaseEffect.cpp:505`)/ `_find_duel_key`(:495)/ + `_is_same_party`(`PythonPlayer.cpp:1445`)/ `_is_wooden_door`(:2390)全部 1:1。`net_play.gd` 的 + `_is_attackable(e)` 从「`_entity_kind(e) in [0,2]`」桩改为拼 `self_e` / `victim_e` / `_attack_ctx()` + (`get_duel` / `get_pvp_relations` / `get_guild_wars` / `get_party`)委托 `EntityRules`;自检 + `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言)。名字颜色(`ENameColor`,§8.8) + 已由增量 110 的 `name_color.gd`(`GetNameColorIndex` / `SetEmpireNameMode` 1:1)+ `net_world` + 驱动补齐;称号色(`GetTitleColor`)/ 头顶公会名 / 等级已由增量 111 的 `text_tail.gd` + (`RefreshTextTail` / `UpdateTextTailLevel` / `EnablePKTitle` 1:1)+ `net_world` 三子标签补齐; + 头顶聊天 / 信息尾标已由增量 112 的 `chat_tail.gd`(`RegisterChatTail` / `RegisterInfoTail` / + `TextTail_GetLivingTime` / `IsChatTextTail` / `RecvChatPacket` 派发 1:1)+ `net_world` 每 vid + 一条 `"bubble"` `Label3D`(5s 硬删、`"名字: "` 前缀剥离、SHOUT·COMMAND 排除)补齐。 + 头顶名字「是否显示」已由增量 113 的 `name_show.gd` + `net_world._name_shown()` 补齐 + (`__IsShowName()` = `_always_show_name`(`IsAlwaysShowName`,`game_option_ui` `always_show_name` + radio 经 `display_option_changed` 信号)`or` `_alt_held`(LALT);`ShowAllTextTail` 的 + `fDistanceFromPlayer < 3500` 平面距裁剪;`net_play.target_changed` 目标 VID / chat 尾标 + `bNameFlag` 强显绕过 3500 但仍过 `isShow · !IsGuildWall · CanPickInstance` 资格)。 + **仍是 seam ⑩**:悬停 `chr.Pick()` 强显、屏幕空间 `ArrangeTextTail` 防重叠、`CanPickInstance` + 仅按死透近似;`ShowName()` 的 `playerGauge.EnableShowAlways()` / `SetQuickPage` 未移植。 + **仍缺**:PK 模式上行(`/pkmode` 增量 38 已有)、保护区 `IsInSafe` 的 + `ATTRIBUTE_BANPK` 地形属性、原版目标板 / 决斗 UI;seam:`__FindPVPKey`(`GC_PVP`)/ `__FindGVGKey`(公会战对局)/ `IsGameMaster` / + `IsBuilding`·`IsWall` race 白名单 / m2dev 后端 `IsKiller` 状态位的 packet setter 均未接。 ### 6. P1/P2:模型、动画和特效 @@ -945,10 +2252,15 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 [`tree_placeholder.cpp:190`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/tree_placeholder.cpp:190)、 [`PARITY-GAP.md:77`](/Users/shenlei/Work/mt/mtgodot-poc/docs/PARITY-GAP.md:77)。 -3. **EffectLib 只有粒子近似。** `.mse` Particle 已映射到 `GPUParticles3D`,但 `.dds` 纹理被 - 程序化渐变代替,`.mde` 被 box 代替,SimpleLight、MovingType、TexAni、骨骼挂点和完整时间轴 - 尚未实现。见 [`effect_player.gd:8`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd:8)、 - [`effect_player.gd:134`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd:134)。 +3. **EffectLib 已从粒子近似推进到 `.mde` 网格子集。** `.mse` Particle 已映射到 + `GPUParticles3D`,现在会读取首个 `TextureFiles` 的实际 `.dds`、位置、发射形状、方向 / 速度; + 增量 64 又按参考 `EffectMesh.cpp` 读取 `.mde` 的 `EffectData` / `MDEData002`,展开索引三角形、 + 翻转 UV、播放几何帧,并接入 `.ifl` 贴图帧和 MeshElement 的混合 / billboard / alpha;增量 65 + 接入 SimpleLight 的范围 / 生命周期曲线和 TexAni 基础帧延迟 / 顺逆序;增量 66 接入统一的 + MovingType 直接段 / 二次 Bezier 位置曲线,并让粒子、网格、Light 共用。仍缺 TexAni 随机起始帧 / + 每粒子语义、完整 emitter advanced / cycle、完整时间轴、骨骼挂点和真实资源逐项验收。见 + [`effect_player.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/effect_player.gd)、 + [`mde.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/fx/mde.gd)。 4. **GR2 运行时不是完整 ActorInstance。** 缺 race/weapon/sex motion mode、loop/interrupt/ accumulation、action reservation、mount motion、武器轨迹、击退、倒地、起身和完整死亡动作。 @@ -978,8 +2290,12 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 4. **水面是序列纹理加近似深度。** 反射、折射、岸线泡沫、法线、精确 blend/filter/wrap、透明 排序和水深标定仍缺。当前浅水透明度已被项目文档标记为未校准。 -5. **`.msenv` 只完成基础天空、雾和光照。** 云层实际渲染、环境循环音、lens flare、日夜插值、 - 天气切换、颜色曲线和原始 ambient/diffuse/specular 关系没有完整实现。 +5. **`.msenv` 已完成参考字段解析、静态云层、双方向光分层和 texture-mode 六面天空 / 云层组合首版, + 但完整环境表现仍缺。** 当前已覆盖参考缺省值、`IsDensity`、`Filter`、`BTextureRenderMode`、 + 六面天空文件名、`Reserved`,并将真实云层贴图接入渐变天空的 `sky_cover` 或 texture-mode 的 + 有限云面 shader;云层渐变过渡、滤色实际合成、SpeedTree 精确风常量 / 随机风语义、lens flare、 + 日夜 / 天气插值、颜色曲线和原始 ambient/diffuse/specular 关系仍没有完整实现;树 proxy 只采用 + 参考的 `0.2/0.0` 缺省。 6. **相机不是固定基线机位。** 当前依赖脚本中的 FOV、yaw、pitch、距离和 AABB 射线,缺 `CameraProcedure` 等价模式、灵敏度持久化、平滑碰撞、树木遮挡淡出、目标 Actor 状态和可回放 @@ -995,64 +2311,99 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 固定坐标和固定尺寸,没有完整复现原 `Interface.MakeInterface()`、`.sub` 资源、字体描边、 rect/anchor/z-index 和分辨率缩放。 -3. **帮助窗口未实现。** ESC 菜单中的帮助按钮仍只显示“帮助窗暂未实现”。见 - [`system_menu_ui.gd:91`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_menu_ui.gd:91)。 +3. **帮助窗口已有首版,仍缺视觉等价验收。** `help_ui.gd` 已装载 `helpwindow.py` 的布局和帮助 + 文案,H 键及 ESC 菜单入口已接通;仍需 `.sub` 贴图、字体描边和真分辨率视觉验收。见 + [`help_ui.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/help_ui.gd)。 4. **系统设置部分只是保存数值。** CPU/GPU tiling 不会改变实际渲染,BGM 更换按钮 disabled, - 名字颜色、伤害数字和部分画质设置没有完整接入渲染侧。见 + 伤害数字和部分画质设置没有完整接入渲染侧;名字颜色本身已 1:1(增量 110)、头顶称号色 / 公会名 / + 等级已 1:1(增量 111)、头顶聊天 / 信息气泡已 1:1(增量 112)、头顶名字「是否显示」/ + 「总是显示名字」/ LALT 名字显示已 1:1(增量 113,`name_show.gd` + `net_world._name_shown()`),但 game_option 的 + 「empire 名字颜色」开关尚未接到 `net_world.set_empire_name_mode(bool)`(默认关)、 + 「伤害数字」radio 尚未接到渲染侧(`GC_DAMAGE_INFO` 整合)、 + `EnablePKTitle` 开关尚未接到 `net_world.set_pk_title_enabled(bool)`(默认开)、 + `TextTail_SetLivingTime`(气泡停留秒数)尚无运行期钩子(默认 5000)。见 [`system_option_ui.gd:143`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/system_option_ui.gd:143)。 -5. **鼠标物品态和光标状态缺失。** Talk、Attack、Buy、Sell、Pick、NPC 交互光标、鼠标跟随物品、 - 拖放取消和窗口焦点屏蔽规则没有完成。 +5. **鼠标物品态和光标已有首版,复杂窗口语义仍不完整。** `mouse_controller.gd` 已提供鼠标跟随 + 物品、投放到背包 / 快捷栏、无效区域 / 右键 / ESC / 失焦取消;`cursor_manager.gd` 已提供 + NORMAL / TALK / ATTACK / PICK / BUY / SELL / ITEM 状态,窗口键盘事件不会穿透到世界。仍缺 + CJK / `.sub` 光标资源、商店 / 交易等复杂窗口的逐项 mousemodule 行为和真机体验验收。 -6. **键位实现与参考端不一致。** 当前已接 WASD、点地、相机拖动、滚轮、触屏、F1–F4 和数字键, - 但当前把 F1–F4 用作切页、数字 1–9 全部激活快捷栏;参考端是数字 1–4 激活本地槽 0–3、 - Ctrl+1–9 发放表情、F1–F4 激活本地槽 4–7。另缺空格连续攻击、显示名字、鼠标物品态、窗口 - 上下文屏蔽和正式截图。键位重绑定、冲突检测及独立相机灵敏度设置不是 ClientVS22 已有能力, - 不应伪装成参考端缺失项。 +6. **键位主入口已按参考端收敛,平台 / 上下文仍需验收。** `game_scene.gd` 已接数字 1–4 激活 + 本地槽 0–3、F1–F4 激活本地槽 4–7、Ctrl/Command+1–9 表情、LSHIFT 翻页、空格按住攻击、 + Alt 显示名字、截图、帮助 / 坐骑 / 角色窗口 / 小地图 / 聊天 / 相机入口;剩余差距是窗口焦点 + 屏蔽、物品态和 macOS F1–F12 的系统 Fn / 功能键设置验收。键位重绑定、冲突检测及独立相机 + 灵敏度设置不是 ClientVS22 已有能力,不应伪装成参考端缺失项。 7. **CJK 输入不等价。** 当前使用 Godot `LineEdit`,没有 `PythonIME` 候选窗、composition、 候选确认、输入法切换、特殊文本框行为、复数/性别规则和自动字体切换。 8. **聊天和头顶信息不完整。** 缺 notice/big notice、经验和金币日志、私聊窗口生命周期、历史 - 滚动、频道过滤、InsultChecker、empire 名字颜色、称号、公会名、PK 颜色、目标框和屏幕空间血条。 + 滚动、频道过滤、InsultChecker、目标框、`GC_DAMAGE_INFO` 伤害数字整合和屏幕空间血条。 + ~~empire 名字颜色 / PK 颜色~~ 增量 110 已 1:1(`name_color.gd` `GetNameColorIndex` / + `SetEmpireNameMode`,`net_world` 事件 + ~2Hz 兜底重染 `Label3D.modulate`,帝国名色默认关); + ~~称号色 `GetTitleColor` / 公会名 / 头顶等级~~ 增量 111 已 1:1(`text_tail.gd` `RefreshTextTail` / + `UpdateTextTailLevel` / `EnablePKTitle`,`net_world` 驱动 `GuildTag` / `TitleTag` / `LevelTag`); + ~~头顶聊天 / 信息气泡~~ 增量 112 已 1:1(`chat_tail.gd` `RegisterChatTail` / `RegisterInfoTail` / + `TextTail_GetLivingTime` / `IsChatTextTail` + `RecvChatPacket` 派发,`net_world` 每 vid 一条 + `"bubble"` `Label3D` + 5s 硬删 + `"名字: "` 前缀剥离 + SHOUT·COMMAND 排除 + `register_info_tail()`; + seam ⑨:气泡与名字 `Label3D` 并存、名字不随气泡强制顶出 / 下移 17px)。 + ~~总是显示名字 / LALT 名字显示 / 目标强显~~ 增量 113 已 1:1(`name_show.gd` `IsAlwaysShowName` / + `SetAlwaysShowNameFlag` / `__IsShowName` / `ShowAllTextTail` 的 3500 平面距裁剪 / `ShowCharacterTextTail` + 资格 / `UpdateAllTextTail` 的 chat `bNameFlag` 强显;`net_world._name_shown()` 四路 + `game_option_ui` + `display_option_changed` 信号 + `net_play.target_changed`;seam ⑩:悬停 `chr.Pick()` 强显 / + 屏幕空间 `ArrangeTextTail` 防重叠 / `CanPickInstance` 仅按死透近似未做)。 -9. **任务日志图标仍是占位。** 当前任务列表主要显示文字,真实 icon、状态变化、倒计时和任务导航 - 尚未接入。见 [`quest_log.gd:8`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_log.gd:8)。 +9. **任务导航和完整资源视觉仍未完成。** 增量 101 已接入默认 / 服务端 icon、5 行滚动、倒计时和 + QuestButton 事件参数;仍缺世界箭头、屏幕边缘指示、多列定位、真实 `.sub` 状态图和服务端 + `iStartTime` 生命周期。见 [`quest_log.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/ui/quest_log.gd)。 ### 9. P1/P2:音频和应用生命周期 -1. **地图 BGM 字段被解析但没有消费。** `GC_MAIN_CHARACTER3_BGM` 和 - `GC_MAIN_CHARACTER4_BGM_VOL` 中的 BGM 名称、音量被丢弃,当前主要靠登录页和选人页固定调用 - `play_bgm()`,没有按地图和服务端状态自动切换。见 - [`classic_parser.cpp:173`](/Users/shenlei/Work/mt/mtgodot-poc/extension/src/net/classic/classic_parser.cpp:173)。 +1. **地图 BGM 已有首版消费,仍缺真服 / 完整 MilesLib 验收。** `GC_MAIN_CHARACTER3_BGM` 和 + `GC_MAIN_CHARACTER4_BGM_VOL` 已路由到 `bgm_changed`,`bgm_director.gd` 按服务端名称、 + `musicInfo.fieldMusic` 回落和默认音量选曲;仍缺完整声音事件模型及真实 40250 地图验证。见 + [`bgm_director.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/bgm_director.gd)。 -2. **后台恢复后可能没有 BGM。** 后台时 `AppLifecycle` 调用 `stop_bgm()`,回前台只恢复客户端和 - 帧率,没有恢复上一次曲目;`stop_bgm()` 还清空了 `_bgm_name`。见 - [`app_lifecycle.gd:60`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd:60)、 - [`app_lifecycle.gd:75`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd:75)。 +2. **后台 BGM 恢复已有首版,仍缺真机验收。** `AppLifecycle` 后台保存并静音音量,回前台恢复 + 音量和当前曲目状态,不再清空 `_bgm_name`;Android/iOS 前后台切换和系统回收 TCP 后的恢复 + 仍未在真机验证。见 + [`app_lifecycle.gd`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_lifecycle.gd)。 -3. **生命周期所有权重复。** `M2Client` 自己处理通知,同时 GameScene 又创建 `AppLifecycle` 绑定 - 同一个客户端,暂停、恢复、内存警告和关闭的责任边界不清晰;目前即使部分操作幂等,也没有 - 明确的单一生命周期协调者。 +3. **生命周期单一属主代码已完成,仍缺平台验收。** 生产入口由 `AppFlow` 持有唯一 + `AppLifecycle`,`GameScene` 不再创建,`M2Client` 不再直接处理应用暂停 / 恢复通知;旧 + `login.gd` 兼容启动器也保持自己的单一节点贯穿全流程。仍需真机验证暂停、恢复、内存告警和 + 断线重连。见 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.3。 -4. **MilesLib 事件模型没有迁移。** 当前只有 BGM crossfade、UI 音效池和通用 3D 音效,缺少 - ambience、character sound、sound scale、精确距离衰减、fade/stop 策略、武器/技能/怪物/NPC - 动作事件和 `.mss` 支持。参考:[`ClientVS22/MilesLib/SoundManager.h`](/Users/shenlei/Work/mt/40250/ClientVS22/source/MilesLib/SoundManager.h:1)。 +4. **MilesLib 事件模型已迁移可复用子集,完整事件数据仍未迁移。** `audio.gd` 已实现 + ambience / character / 普通 3D 三类入口、监听器位置 / 方向、200/1000 缩放、参考音量曲线、 + 角色声音的 5000cm + 0.3 秒限频、实例停止和音乐限幅淡出;增量 63 已按 `MilesLib/Type.cpp` + 解析 `.mss`,以 60 FPS 帧实例接入 PlayerView / MobView 的动作声音。增量 67 又按 `Area.cpp` / + `MapType.cpp` 接入 `AreaAmbienceData` + `.pra`,并实现 LOOP / ONCE / STEP 和区域卸载停止; + 增量 68/72 又按 `MapUtil.cpp` / `SkyBox.cpp` 补齐 `.msenv` 解析字段与参考缺省值,并将真实云层贴图接入 + `ProceduralSkyMaterial.sky_cover` 或 texture-mode sky shader,未等价承载的参数保存在 + `WorldEnvironment` 元数据。EffectLib + 的 `.mde` 基础网格、SimpleLight 范围 / 生命周期和 MovingType 基础曲线已接入。仍缺 + TexAni 随机 / 每粒子语义、SpeedTree、完整 `.msenv` 环境渲染 / 天气联动、完整 `UpdateSoundData/UpdateSoundInstance` + 节点数据、全部武器 / 技能 / 怪物 / NPC 资源逐项验收和真实设备听感。参考:[`ClientVS22/MilesLib/SoundManager.h`](/Users/shenlei/Work/mt/40250/ClientVS22/source/MilesLib/SoundManager.h:1)。 ### 10. 发布和验证问题 -1. **Mac App 不是自包含资源包。** 最近构建产物中的 `assets` 和 `bgm` 仍是指向工作区的绝对 - 符号链接,只复制 `.app` 到另一台 Mac 不能保证资源可用。 +1. **Mac App 自包含脚本已修复,但完整产物尚未验收。** `build-macos-client.sh` 现在把 `assets` + 和 `bgm` 复制到 `Contents/Resources`,`MT_ASSETS` 只作为开发覆盖;仍需用完整导出产物跨机启动, + 不能把脚本通过等同于 App 验收。 -2. **主程序和 GDExtension 架构不一致。** App 主程序是 Universal,但 GDExtension 只有 arm64, - Intel Mac 可能无法加载核心扩展。 +2. **双架构构建入口已补齐,当前产物仍未证明 Universal。** `build.sh` / `build-macos-client.sh` + 支持 `MT_MAC_ARCHES=universal` 并在打包时用 `lipo` 拒绝单架构扩展;当前已检查的扩展仍为 arm64, + Intel Mac 兼容性待实际构建与运行验收。 -3. **当前是 Ad-hoc 签名,不是正式分发签名。** 没有 Developer ID、完整 hardened runtime、 - notarization 和 stapled ticket,不能直接当作面向用户的正式安装包。 +3. **签名入口已补齐,默认仍是 Ad-hoc。** 脚本支持 `CODESIGN_IDENTITY`,按嵌套扩展先于 App 的 + 顺序签名并启用 hardened runtime 选项;当前没有 Developer ID、notarization 或 stapled ticket + 证据,不能当作正式分发包。 -4. **发布构建仍包含 Demo 默认账号密码。** `AppFlow` 中默认值是 `admin / 123456789`, - 不能保留在正式客户端。见 [`app_flow.gd:32`](/Users/shenlei/Work/mt/mtgodot-poc/project/app_flow.gd:32)。 +4. **Demo 默认账号密码已移除。** `AppFlow`、登录和选人页只从 `MT_ACCOUNT` / `MT_PASSWORD` + 读取,自动登录还要求 `MT_AUTOLOGIN=1`;正式 PCK / 导出包仍应加入字符串扫描门禁。 5. **补丁器、Logo、Movie、Web、Discord 和反作弊策略没有形成发布决策。** 当前只是“能启动 并进游戏”的工程入口,不是完整的 ClientVS22 发布外壳。 @@ -1067,8 +2418,12 @@ Python 脚本;文档中出现的 `assets/root/*.py` 均属于本工程迁移 连接骨架和换图清理已经开始实现,但尚未完成真实双连接、换图和完整生命周期验收。 它还不能定义为:**ClientVS22 的跨平台等价客户端**。 -当前已完成 G0 公共接口的首版冻结,正在进入附录 C 的并行波 A;已完成项与剩余项以文档顶部的 -增量 47/48 和 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)“实施进度 / 进度看板”为准。后续仍需要完成以下闭环: +当前已完成 G0 公共接口的首版冻结,正在按附录 C 的并行波 A 持续交付;增量 49–107 的完成项、 + 自动化证据和剩余项以本文顶部看板及 [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)“实施进度 / + 进度看板 / C.5 批次记录”为准。W3 已完成远端 PC 真模型工厂、任务日志五行 / 滚动 / 事件和 + 钓鱼输入 / 水面扫描 / 反馈消息首版、坐骑攻击等级门、观战者小地图插值和副本目的地罗盘首版;W4 已完成环境格式解析、静态云层和 texture-mode 云层组合及 EffectLib + emitter cycle 生命周期首版接入,但均不代表完整玩法 / 环境渲染完成。 +后续仍需要完成以下闭环: 1. 完成 Classic 直连/Auth/Warp 的真实双连接验收,并补齐阶段清理和回登录后的完整生命周期; 2. 未知 VID、VID 重用、事件缓存和完整实体生命周期; diff --git a/extension/CMakeLists.txt b/extension/CMakeLists.txt index 7efd71dc..2cdca235 100644 --- a/extension/CMakeLists.txt +++ b/extension/CMakeLists.txt @@ -87,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 $) + 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 $) + # 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) @@ -117,6 +121,10 @@ if(BUILD_TESTING AND MT_HOST_BUILD) target_link_libraries(net_text_codec_test PRIVATE mtnet) add_test(NAME net.text_codec COMMAND $) + add_executable(net_bounds_test tests/net_bounds_test.cpp) + target_link_libraries(net_bounds_test PRIVATE mtnet) + add_test(NAME net.bounds COMMAND $) + 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 $) @@ -131,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 $) endif() # --- the extension library --- diff --git a/extension/src/environment_builder.cpp b/extension/src/environment_builder.cpp index 2548feb9..9865ee6a 100644 --- a/extension/src/environment_builder.cpp +++ b/extension/src/environment_builder.cpp @@ -1,8 +1,16 @@ #include "environment_builder.h" +#include "asset_io.h" +#include "dxt.h" + #include +#include +#include #include +#include +#include #include +#include #include #include @@ -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 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 image = godot::Image::create_from_data( + 1, 1, false, godot::Image::FORMAT_RGBA8, pixels); + image->generate_mipmaps(); + return ImageTexture::create_from_image(image); +} + +Ref load_texture(const std::string &name, const fmt::AssetResolver *resolver) { + if (name.empty() || resolver == nullptr) + return Ref(); + const std::string path = resolver->resolve(name, nullptr); + if (path.empty()) + return Ref(); + Ref 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(); + 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(); + 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( + 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(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 是独立六面 quad;Godot Sky shader 用 EYEDIR 采样 + // 同一套六面图,坐标和 UV 依照 EterLib/SkyBox.cpp 的面顶点顺序转换。 + int sky_face_count = 0; + bool sky_texture_applied = false; + bool sky_cloud_applied = false; { Ref 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.instantiate(); - sky->set_material(psm); + std::array, 6> faces; + std::array 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 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.instantiate(); + shader->set_code(String(SRC_SKYBOX)); + Ref 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; } diff --git a/extension/src/environment_builder.h b/extension/src/environment_builder.h index 3d281a16..c6aee57b 100644 --- a/extension/src/environment_builder.h +++ b/extension/src/environment_builder.h @@ -3,19 +3,24 @@ #include #include +#include #include // W5 —— .msenv(已由 formats/environment 解析)-> Godot 光照 / 天空 / 雾 / 色调。 -// SHINSOO §9-W5。DirectionalLight.Background 驱动场景主光;Character 光留待角色材质 -// uniform(§9-W5 note)。云 / lens flare 留 R2。 +// SHINSOO §9-W5。背景 / 角色方向光按参考端分成两个可见性层;云贴图接入 +// ProceduralSkyMaterial 的 sky cover;texture-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 diff --git a/extension/src/metin2_anim.cpp b/extension/src/metin2_anim.cpp index 87f9e328..4d856dc1 100644 --- a/extension/src/metin2_anim.cpp +++ b/extension/src/metin2_anim.cpp @@ -17,6 +17,9 @@ #include #include #include +#include + +#include 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(); diff --git a/extension/src/metin2_anim.h b/extension/src/metin2_anim.h index 3bbc28c1..0e77749c 100644 --- a/extension/src/metin2_anim.h +++ b/extension/src/metin2_anim.h @@ -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); diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp index 75734b49..f90a480f 100644 --- a/extension/src/metin2_model.cpp +++ b/extension/src/metin2_model.cpp @@ -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); diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp index 50bf91df..777834c2 100644 --- a/extension/src/metin2_world.cpp +++ b/extension/src/metin2_world.cpp @@ -45,6 +45,7 @@ #include #include #include +#include using namespace godot; @@ -90,9 +91,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 +254,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 +306,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 +322,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 +410,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 物体丢进动态阴影贴图(= 我们的实时投影); @@ -462,6 +473,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 +486,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 +539,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(); + 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(); - 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 +602,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 +633,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 +685,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; @@ -736,6 +785,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,6 +841,7 @@ 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 但未建碰撞 @@ -768,6 +854,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(); diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h index 2828f22d..ce632a0b 100644 --- a/extension/src/metin2_world.h +++ b/extension/src/metin2_world.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -84,7 +85,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 load_dds(const godot::String &path) const; @@ -144,13 +153,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 sounds; + }; std::vector chunks; std::vector> stream_queue; // 待建区块 + std::vector 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; diff --git a/extension/src/net/classic/classic_parser.cpp b/extension/src/net/classic/classic_parser.cpp index dd48326b..9ca1d2f0 100644 --- a/extension/src/net/classic/classic_parser.cpp +++ b/extension/src/net/classic/classic_parser.cpp @@ -271,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 @@ -286,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 @@ -367,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 @@ -687,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(p.arg2.cell), p.arg1, - static_cast(p.arg3)); + static_cast(p.arg3), p.sockets, attrs); break; + } case EXCHANGE_GC_ITEM_DEL: m_world.mut_exchange_item_del(self, static_cast(p.arg1)); break; diff --git a/extension/src/net/classic/classic_session.cpp b/extension/src/net/classic/classic_session.cpp index 3e6091e9..515b0fd5 100644 --- a/extension/src/net/classic/classic_session.cpp +++ b/extension/src/net/classic/classic_session.cpp @@ -301,6 +301,10 @@ void ClassicSession::pump() { 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(); } diff --git a/extension/src/net/classic/classic_stream.cpp b/extension/src/net/classic/classic_stream.cpp index 2df2e2f6..ca5cfa08 100644 --- a/extension/src/net/classic/classic_stream.cpp +++ b/extension/src/net/classic/classic_stream.cpp @@ -45,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(); diff --git a/extension/src/net/classic/classic_stream.h b/extension/src/net/classic/classic_stream.h index 6f9aa447..0579200a 100644 --- a/extension/src/net/classic/classic_stream.h +++ b/extension/src/net/classic/classic_stream.h @@ -99,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(); diff --git a/extension/src/net/entity_store.cpp b/extension/src/net/entity_store.cpp index c3116103..d0e6b12f 100644 --- a/extension/src/net/entity_store.cpp +++ b/extension/src/net/entity_store.cpp @@ -1,6 +1,7 @@ #include "entity_store.h" #include +#include #include #include #include @@ -65,25 +66,6 @@ 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, @@ -196,18 +178,153 @@ 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) { // 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 @@ -444,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()) { @@ -1246,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; } @@ -1515,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: { @@ -2443,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; } @@ -3350,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) { @@ -3365,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; diff --git a/extension/src/net/entity_store.h b/extension/src/net/entity_store.h index 467483df..73697f4e 100644 --- a/extension/src/net/entity_store.h +++ b/extension/src/net/entity_store.h @@ -11,6 +11,7 @@ #include "wire.h" #include +#include #include #include #include @@ -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,13 +75,24 @@ 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 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; @@ -262,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; @@ -458,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. @@ -491,8 +538,12 @@ public: 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). @@ -523,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); @@ -613,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); @@ -1098,13 +1155,19 @@ 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 m_ents; uint32_t m_main_vid = 0; uint32_t m_now = 0; + uint32_t m_server_frame_ms = 0; std::vector m_changes; std::vector m_dirty; std::string m_bgm_name; diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp index 4933bc93..6e528152 100644 --- a/extension/src/net/m2_client.cpp +++ b/extension/src/net/m2_client.cpp @@ -5,6 +5,7 @@ #include "classic/classic_session.h" #include "game_client.h" #include "mark_client.h" +#include "net_bounds.h" #include #include @@ -26,6 +27,27 @@ using namespace godot; namespace mtgodot { +namespace { + +bool valid_angle(double degrees) { + return std::isfinite(degrees); +} + +bool valid_item_pos(int window, int cell) { + return mtnet::bounds::u8(window) && mtnet::bounds::u16(cell); +} + +bool read_int(const Dictionary &dict, const char *key, int64_t &value, int64_t fallback) { + const Variant raw = dict.get(key, fallback); + if (raw.get_type() != Variant::INT) { + return false; + } + value = static_cast(raw); + return true; +} + +} // namespace + M2Client::M2Client() { set_process(true); } @@ -56,6 +78,7 @@ void M2Client::_bind_methods() { ClassDB::bind_method(D_METHOD("attack", "motion", "victim_vid"), &M2Client::attack); ClassDB::bind_method(D_METHOD("set_target", "victim_vid"), &M2Client::set_target); ClassDB::bind_method(D_METHOD("say", "type", "text"), &M2Client::say); + ClassDB::bind_method(D_METHOD("send_emoticon", "index"), &M2Client::send_emoticon); ClassDB::bind_method(D_METHOD("whisper", "to", "text"), &M2Client::whisper); ClassDB::bind_method(D_METHOD("cast_skill", "motion_idx", "rot_deg", "x", "y"), &M2Client::cast_skill); ClassDB::bind_method(D_METHOD("use_skill", "skill_id", "target_vid"), &M2Client::use_skill); @@ -134,6 +157,7 @@ void M2Client::_bind_methods() { PropertyInfo(Variant::DICTIONARY, "entity"))); ADD_SIGNAL(MethodInfo("chat", PropertyInfo(Variant::INT, "type"), PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::STRING, "text"))); + ADD_SIGNAL(MethodInfo("emoticon_requested", PropertyInfo(Variant::INT, "index"))); ADD_SIGNAL(MethodInfo("whisper_received", PropertyInfo(Variant::INT, "sub"), PropertyInfo(Variant::STRING, "from"), PropertyInfo(Variant::STRING, "text"))); // combat / status @@ -449,6 +473,12 @@ void M2Client::net_poll() { void M2Client::connect_to_server(const String &auth_host, int auth_port, const String &g_host, int g_port, const String &id, const String &pw) { disconnect_from_server(); + if (auth_host.is_empty() || g_host.is_empty() || auth_port <= 0 || g_port <= 0 || + !mtnet::bounds::u16(auth_port) || !mtnet::bounds::u16(g_port)) { + emit_signal("login_failed", String("invalid server address")); + set_stage(Stage::Failed); + return; + } game_host = g_host; game_port = g_port; account_id = id; @@ -466,6 +496,19 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S // then connect to game_host with the returned login ticket. if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") { classic_sess = std::make_unique(); + // §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's + // filename; feed it the real executable name rather than the built-in + // default so the server-side version gate sees what ClientVS22 sends. + classic_sess->set_executable_name( + std::string(OS::get_singleton()->get_executable_path().get_file().utf8().get_data())); + // §2.2 (W1 G1 wiring): non-PC spawn names come from root/npclist.txt. + // M2Client has no asset-root handle, so the parser falls back to + // $MT_ASSETS/$M2_ASSETS (see npc_names.h default_npclist_path()); an + // explicit set_npclist_path() awaits asset-root plumbing into M2Client. + if (const String assets = OS::get_singleton()->get_environment("MT_ASSETS"); !assets.is_empty()) { + classic_sess->parser().set_npclist_path( + std::string(assets.utf8().get_data()) + "/root/npclist.txt"); + } // The 40250 server expects CG_ENTERGAME shortly after the loading phase // starts. Keep the live client in the same timing window as m2dev while // allowing a deployment-specific override for unusually large bursts. @@ -570,14 +613,23 @@ bool M2Client::enter_game(int index) { bool M2Client::create_character(int slot, const godot::String &name, int job, int shape, int con, int intel, int str, int dex) { + const std::string name_wire = std::string(name.utf8().get_data()); + const size_t name_limit = classic_sess ? mtnet::classic::CHARACTER_NAME_MAX_LEN : + mtnet::CHARACTER_NAME_MAX_LEN; + if (slot < 0 || slot >= mtnet::PLAYER_PER_ACCOUNT4 || name_wire.empty() || + name_wire.size() > name_limit || !mtnet::bounds::u16(job) || !mtnet::bounds::u8(shape) || + !mtnet::bounds::u8(con) || !mtnet::bounds::u8(intel) || !mtnet::bounds::u8(str) || + !mtnet::bounds::u8(dex)) { + return false; + } if (classic_sess) { - return classic_sess->create_character(slot, std::string(name.utf8().get_data()), job, shape, + return classic_sess->create_character(slot, name_wire, job, shape, con, intel, str, dex); } if (!game) { return false; } - return game->create_character(slot, std::string(name.utf8().get_data()), job, shape, + return game->create_character(slot, name_wire, job, shape, con, intel, str, dex); } @@ -682,7 +734,8 @@ godot::Array M2Client::build_char_list() const { // --- in-game intents --- bool M2Client::move(int func, int arg, double rot_deg, int x, int y) { - if (!is_in_game()) { + if (!is_in_game() || !mtnet::bounds::u8(func) || !mtnet::bounds::u8(arg) || + !valid_angle(rot_deg) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { return false; } double r = std::fmod(rot_deg, 360.0); @@ -719,7 +772,7 @@ bool M2Client::request_warp() { } bool M2Client::fishing(double rot_deg) { - if (!is_in_game()) { + if (!is_in_game() || !valid_angle(rot_deg)) { return false; } double r = std::fmod(rot_deg, 360.0); @@ -757,11 +810,17 @@ bool M2Client::sync_positions(const Array &positions) { if (!d.has("vid") || !d.has("x") || !d.has("y")) { return false; } - const int64_t vid = (int64_t)d["vid"]; - const int64_t x = (int64_t)d["x"]; - const int64_t y = (int64_t)d["y"]; - if (vid < 0 || vid > 0xffffffffLL || x < INT32_MIN || x > INT32_MAX || - y < INT32_MIN || y > INT32_MAX) { + const Variant raw_vid = d["vid"]; + const Variant raw_x = d["x"]; + const Variant raw_y = d["y"]; + if (raw_vid.get_type() != Variant::INT || raw_x.get_type() != Variant::INT || + raw_y.get_type() != Variant::INT) { + return false; + } + const int64_t vid = (int64_t)raw_vid; + const int64_t x = (int64_t)raw_x; + const int64_t y = (int64_t)raw_y; + if (!mtnet::bounds::u32(vid) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { return false; } if (classic_sess) classic_elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y}); @@ -773,7 +832,8 @@ bool M2Client::sync_positions(const Array &positions) { } bool M2Client::attack(int motion, int victim_vid) { - if (!is_in_game()) { + if (!is_in_game() || !mtnet::bounds::u8(motion) || victim_vid <= 0 || + !mtnet::bounds::u32(victim_vid)) { return false; } if (classic_sess) { @@ -783,7 +843,8 @@ bool M2Client::attack(int motion, int victim_vid) { } bool M2Client::set_target(int victim_vid) { - if (!is_in_game()) { + // VID 0 is the stock client's explicit "clear target" packet. + if (!is_in_game() || victim_vid < 0 || !mtnet::bounds::u32(victim_vid)) { return false; } if (classic_sess) { @@ -793,34 +854,65 @@ bool M2Client::set_target(int victim_vid) { } bool M2Client::say(int type, const String &text) { - if (!is_in_game()) { + if (!is_in_game() || !mtnet::bounds::u8(type)) { + return false; + } + const std::string text_wire = std::string(text.utf8().get_data()); + if ((classic_sess && text_wire.size() >= 512) || + (!classic_sess && text_wire.size() > 0xffffu - sizeof(mtnet::CGChat) - 1)) { return false; } if (classic_sess) { - return classic_sess->send_chat((uint8_t)type, std::string(text.utf8().get_data())); + return classic_sess->send_chat((uint8_t)type, text_wire); } - return game && game->send_chat((uint8_t)type, std::string(text.utf8().get_data())); + return game && game->send_chat((uint8_t)type, text_wire); +} + +bool M2Client::send_emoticon(int index) { + // Registration order from playersettingmodule.py. The reference client's + // SendEmoticon sends this token through SendChatPacket, while the received + // token is parsed back into the matching visual effect by the chat handler. + static constexpr const char *kTokens[] = { + "(sweat)", "(money)", "(happy)", "(like)", "(love)", "(angry)", + "(aha)", "(sad)", "(sorry)", "(!)", "(?)", "(fish)" + }; + constexpr int kTokenCount = sizeof(kTokens) / sizeof(kTokens[0]); + if (index < 0 || index >= kTokenCount || !say(0 /* CHAT_TYPE_TALKING */, String(kTokens[index]))) { + return false; + } + emit_signal("emoticon_requested", index); + return true; } bool M2Client::whisper(const String &to, const String &text) { + const std::string to_wire = std::string(to.utf8().get_data()); + const std::string text_wire = std::string(text.utf8().get_data()); if (classic_sess) { - return is_in_game() && classic_sess->send_whisper(std::string(to.utf8().get_data()), - std::string(text.utf8().get_data())); + return is_in_game() && !to_wire.empty() && + to_wire.size() <= mtnet::classic::CHARACTER_NAME_MAX_LEN && text_wire.size() < 512 && + classic_sess->send_whisper(to_wire, text_wire); } - if (!game || !is_in_game()) { + if (!game || !is_in_game() || to_wire.empty() || + to_wire.size() > mtnet::CHARACTER_NAME_MAX_LEN || + text_wire.size() > 0xffffu - sizeof(mtnet::CGWhisper) - 1) { return false; } - return game->send_whisper(std::string(to.utf8().get_data()), - std::string(text.utf8().get_data())); + return game->send_whisper(to_wire, text_wire); } bool M2Client::cast_skill(int motion_idx, double rot_deg, int x, int y) { - // FUNC_SKILL | (motion_idx & 0x7F); reuse move()'s rot/time handling. - return move(0x80 | (motion_idx & 0x7F), 0, rot_deg, x, y); + // FUNC_SKILL | motion_idx; do not silently mask an invalid caller value. + if (motion_idx < 0 || motion_idx > 0x7f) { + return false; + } + return move(0x80 | motion_idx, 0, rot_deg, x, y); } bool M2Client::use_skill(int skill_id, int target_vid) { - if (!is_in_game() || skill_id <= 0) { + // §3.8 mod 3: signed params, range-checked before the uint32 wire cast. + // skill_id is a real skill index (0 < id < SKILL_MAX_NUM), not a free u32. + if (!is_in_game() || !mtnet::bounds::skill_index(skill_id) || target_vid < 0 || + !mtnet::bounds::u32(target_vid)) { return false; } if (classic_sess) { @@ -857,7 +949,8 @@ bool M2Client::shoot(int skill_id) { } bool M2Client::add_fly_targeting(int target_vid, int x, int y) { - if (!is_in_game() || target_vid < 0) { + if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) || + !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) { return false; } if (classic_sess) return classic_sess->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y); @@ -866,6 +959,10 @@ bool M2Client::add_fly_targeting(int target_vid, int x, int y) { } bool M2Client::skill_up(int skill_id) { + // §3.9 / §3.10: /skillup 只发正的、低于 SKILL_MAX_NUM 的技能索引(与 use_skill 同一谓词)。 + if (!is_in_game() || !mtnet::bounds::skill_index(skill_id)) { + return false; + } return say(0 /*CHAT_TYPE_TALKING*/, String("/skillup ") + String::num_int64(skill_id)); } @@ -906,7 +1003,7 @@ Array M2Client::get_quickslots() const { } Dictionary d; d["pos"] = pos; - d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion + d["type"] = (int)qs.type; // 1 inventory / 2 skill / 3 emotion / 4 shop (not executable) d["ref"] = (int)qs.position; // item cell / skill id / ... out.push_back(d); } @@ -914,8 +1011,8 @@ Array M2Client::get_quickslots() const { } bool M2Client::quickslot_add(int pos, int type, int ref) { - if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM || - type < 1 || type > 4 || ref < 0 || ref > 255) { + if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || !mtnet::bounds::u8(type) || + type < 1 || type > 3 || !mtnet::bounds::u8(ref)) { return false; } if (classic_sess) return classic_sess->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref); @@ -924,7 +1021,7 @@ bool M2Client::quickslot_add(int pos, int type, int ref) { } bool M2Client::quickslot_del(int pos) { - if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM) { + if (!is_in_game() || !mtnet::bounds::quick_slot(pos)) { return false; } if (classic_sess) return classic_sess->send_quickslot_del((uint8_t)pos); @@ -933,8 +1030,8 @@ bool M2Client::quickslot_del(int pos) { } bool M2Client::quickslot_swap(int pos, int change_pos) { - if (!is_in_game() || pos < 0 || pos >= mtnet::QUICKSLOT_MAX_NUM || - change_pos < 0 || change_pos >= mtnet::QUICKSLOT_MAX_NUM) { + if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || + !mtnet::bounds::quick_slot(change_pos)) { return false; } if (classic_sess) return classic_sess->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos); @@ -943,7 +1040,7 @@ bool M2Client::quickslot_swap(int pos, int change_pos) { } bool M2Client::click_npc(int vid) { - if (!is_in_game()) { + if (!is_in_game() || vid <= 0 || !mtnet::bounds::u32(vid)) { return false; } if (classic_sess) { @@ -952,7 +1049,7 @@ bool M2Client::click_npc(int vid) { return game && game->send_on_click((uint32_t)vid); } bool M2Client::script_answer(int answer) { - if (!is_in_game()) { + if (!is_in_game() || !mtnet::bounds::u8(answer)) { return false; } if (classic_sess) { @@ -961,11 +1058,14 @@ bool M2Client::script_answer(int answer) { return game && game->send_script_answer((uint8_t)answer); } bool M2Client::script_button(int idx) { - if (classic_sess) return is_in_game() && idx >= 0 && classic_sess->send_script_button((uint32_t)idx); - return game && is_in_game() && game->send_script_button((uint32_t)idx); + if (!is_in_game() || idx < 0 || !mtnet::bounds::u32(idx)) { + return false; + } + if (classic_sess) return classic_sess->send_script_button((uint32_t)idx); + return game && game->send_script_button((uint32_t)idx); } bool M2Client::script_select_item(int selection) { - if (!is_in_game() || selection < 0) { + if (!is_in_game() || selection < 0 || !mtnet::bounds::u32(selection)) { return false; } if (classic_sess) return classic_sess->send_script_select_item((uint32_t)selection); @@ -977,9 +1077,9 @@ bool M2Client::quest_input(const String &text) { return game && is_in_game() && game->send_quest_input(std::string(text.utf8().get_data())); } bool M2Client::quest_confirm(bool yes, int request_pid) { - if (classic_sess) return is_in_game() && request_pid >= 0 && + if (classic_sess) return is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) && classic_sess->send_quest_confirm(yes, (uint32_t)request_pid); - return game && is_in_game() && request_pid >= 0 && + return game && is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) && game->send_quest_confirm(yes, (uint32_t)request_pid); } bool M2Client::quest_cancel() { @@ -1015,20 +1115,26 @@ Array M2Client::get_quests() const { // --- P8 social / shop / storage --- bool M2Client::party_invite(int vid) { - if (classic_sess) return is_in_game() && vid > 0 && classic_sess->send_party_invite((uint32_t)vid); - return game && is_in_game() && game->send_party_invite((uint32_t)vid); + if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && + classic_sess->send_party_invite((uint32_t)vid); + return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && + game->send_party_invite((uint32_t)vid); } bool M2Client::party_answer(int leader_pid, bool accept) { - if (classic_sess) return is_in_game() && leader_pid > 0 && + if (classic_sess) return is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) && classic_sess->send_party_answer((uint32_t)leader_pid, accept); - return game && is_in_game() && game->send_party_invite_answer((uint32_t)leader_pid, accept); + return game && is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) && + game->send_party_invite_answer((uint32_t)leader_pid, accept); } bool M2Client::party_leave(int pid) { - if (classic_sess) return is_in_game() && pid > 0 && classic_sess->send_party_remove((uint32_t)pid); - return game && is_in_game() && pid >= 0 && game->send_party_remove((uint32_t)pid); + if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && + classic_sess->send_party_remove((uint32_t)pid); + return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && + game->send_party_remove((uint32_t)pid); } bool M2Client::party_use_skill(int skill_index, int target_vid) { - if (!is_in_game() || skill_index < 0 || skill_index > 255 || target_vid < 0) { + if (!is_in_game() || !mtnet::bounds::u8(skill_index) || target_vid < 0 || + !mtnet::bounds::u32(target_vid)) { return false; } if (classic_sess) return classic_sess->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid); @@ -1036,12 +1142,15 @@ bool M2Client::party_use_skill(int skill_index, int target_vid) { return game->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid); } bool M2Client::party_set_distribute(int mode) { - if (classic_sess) return is_in_game() && mode >= 0 && mode <= 255 && + if (classic_sess) return is_in_game() && mtnet::bounds::u8(mode) && classic_sess->send_party_parameter((uint8_t)mode); - return game && is_in_game() && game->send_party_parameter((uint8_t)mode); + return game && is_in_game() && mtnet::bounds::u8(mode) && + game->send_party_parameter((uint8_t)mode); } bool M2Client::party_set_state(int pid, int role, bool on) { - if (!is_in_game() || pid <= 0 || role < 0 || role > 255) return false; + if (!is_in_game() || pid <= 0 || !mtnet::bounds::u32(pid) || role < 0 || role >= 8) { + return false; + } if (classic_sess) return classic_sess->send_party_set_state((uint32_t)pid, (uint8_t)role, on); return game && game->send_party_set_state((uint32_t)pid, (uint8_t)role, on ? 1 : 0); } @@ -1130,15 +1239,18 @@ Dictionary M2Client::get_lover() const { } bool M2Client::shop_buy(int pos, int count) { - if (classic_sess) return is_in_game() && pos >= 0 && pos <= 255 && - classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count)); - return game && is_in_game() && game->send_shop_buy((uint8_t)pos, (uint8_t)(count < 1 ? 1 : count)); + if (!is_in_game() || !mtnet::bounds::u8(pos) || count < 1 || !mtnet::bounds::u8(count)) { + return false; + } + if (classic_sess) return classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)count); + return game && game->send_shop_buy((uint8_t)pos, (uint8_t)count); } bool M2Client::shop_sell(int inv_cell, int count) { - if (classic_sess) return is_in_game() && inv_cell >= 0 && inv_cell <= 255 && - classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count)); - return game && is_in_game() && - game->send_shop_sell((uint8_t)inv_cell, (uint8_t)(count < 1 ? 1 : count)); + if (!is_in_game() || !mtnet::bounds::u8(inv_cell) || count < 0 || !mtnet::bounds::u8(count)) { + return false; + } + if (classic_sess) return classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)count); + return game->send_shop_sell((uint8_t)inv_cell, (uint8_t)count); } bool M2Client::shop_close() { if (classic_sess) return is_in_game() && classic_sess->send_shop_close(); @@ -1206,27 +1318,34 @@ Dictionary M2Client::get_shop() const { } bool M2Client::exchange_start(int vid) { - if (classic_sess) { - return is_in_game() && classic_sess->send_exchange_start((uint32_t)vid); - } - return game && is_in_game() && game->send_exchange_start((uint32_t)vid); -} -bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) { - if (classic_sess) { - return is_in_game() && classic_sess->send_exchange_item_add((uint8_t)inv_window, - (uint16_t)inv_cell, (uint8_t)display_pos); - } - return game && is_in_game() && - game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, (uint8_t)display_pos); -} -bool M2Client::exchange_add_gold(int gold) { - if (gold < 0) { + if (!is_in_game() || !mtnet::bounds::u32(vid) || vid <= 0) { return false; } if (classic_sess) { - return is_in_game() && classic_sess->send_exchange_gold((uint32_t)gold); + return classic_sess->send_exchange_start((uint32_t)vid); } - return game && is_in_game() && game->send_exchange_gold((uint32_t)gold); + return game && game->send_exchange_start((uint32_t)vid); +} +bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) { + if (!is_in_game() || !valid_item_pos(inv_window, inv_cell) || + !mtnet::bounds::exchange_slot(display_pos)) { + return false; + } + if (classic_sess) { + return classic_sess->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, + (uint8_t)display_pos); + } + return game && game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell, + (uint8_t)display_pos); +} +bool M2Client::exchange_add_gold(int gold) { + if (!is_in_game() || !mtnet::bounds::u32(gold)) { + return false; + } + if (classic_sess) { + return classic_sess->send_exchange_gold((uint32_t)gold); + } + return game && game->send_exchange_gold((uint32_t)gold); } bool M2Client::exchange_accept() { if (classic_sess) { @@ -1260,10 +1379,26 @@ Dictionary M2Client::get_exchange() const { continue; } Dictionary s; - s["slot"] = i; - s["vnum"] = (int)arr[i].vnum; - s["count"] = (int)arr[i].count; - a.push_back(s); + s["slot"] = i; + s["vnum"] = (int)arr[i].vnum; + s["count"] = (int)arr[i].count; + Array sockets; + for (int j = 0; j < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++j) { + sockets.push_back(arr[i].sockets[j]); + } + s["sockets"] = sockets; + Array attrs; + for (int j = 0; j < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++j) { + if (arr[i].attrs[j].type == 0) { + continue; + } + Dictionary a; + a["type"] = (int)arr[i].attrs[j].type; + a["value"] = (int)arr[i].attrs[j].value; + attrs.push_back(a); + } + s["attrs"] = attrs; + a.push_back(s); } return a; }; @@ -1273,28 +1408,37 @@ Dictionary M2Client::get_exchange() const { } bool M2Client::safebox_checkin(int safe_pos, int inv_window, int inv_cell) { - if (classic_sess) { - return is_in_game() && classic_sess->send_safebox_checkin((uint8_t)safe_pos, - (uint8_t)inv_window, (uint16_t)inv_cell); + if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) { + return false; } - return game && is_in_game() && - game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell); + if (classic_sess) { + return classic_sess->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, + (uint16_t)inv_cell); + } + return game && game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window, + (uint16_t)inv_cell); } bool M2Client::safebox_checkout(int safe_pos, int inv_window, int inv_cell) { - if (classic_sess) { - return is_in_game() && classic_sess->send_safebox_checkout((uint8_t)safe_pos, - (uint8_t)inv_window, (uint16_t)inv_cell); + if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) { + return false; } - return game && is_in_game() && - game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, (uint16_t)inv_cell); + if (classic_sess) { + return classic_sess->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, + (uint16_t)inv_cell); + } + return game && game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window, + (uint16_t)inv_cell); } bool M2Client::safebox_move(int from_cell, int to_cell, int count) { - if (classic_sess) { - return is_in_game() && classic_sess->send_safebox_move((uint16_t)from_cell, - (uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count)); + if (!is_in_game() || !mtnet::bounds::u16(from_cell) || !mtnet::bounds::u16(to_cell) || + !mtnet::bounds::u8(count)) { + return false; } - return game && is_in_game() && - game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)(count < 1 ? 1 : count)); + if (classic_sess) { + return classic_sess->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, + (uint8_t)count); + } + return game && game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)count); } bool M2Client::is_safebox_open() const { const mtnet::EntityStore *world = active_world(); @@ -1358,9 +1502,12 @@ Array M2Client::get_mall_items() const { return out; } bool M2Client::mall_checkout(int mall_pos, int inv_window, int inv_cell) { + if (!is_in_game() || !mtnet::bounds::u8(mall_pos) || !valid_item_pos(inv_window, inv_cell)) { + return false; + } if (classic_sess) { - return is_in_game() && classic_sess->send_mall_checkout((uint8_t)mall_pos, - (uint8_t)inv_window, (uint16_t)inv_cell); + return classic_sess->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window, + (uint16_t)inv_cell); } return game && game->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window, (uint16_t)inv_cell); @@ -1383,38 +1530,76 @@ bool M2Client::mall_password(const String &password) { // --- private (PC) shop --- bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) { - if (!game && !classic_sess) { + if (!is_in_game() || (!game && !classic_sess) || + items.size() > mtnet::bounds::PRIVATE_SHOP_ITEM_COUNT) { return false; } + const std::string sign_wire = std::string(sign.utf8().get_data()); + if (sign_wire.size() > mtnet::SHOP_SIGN_MAX_LEN) { + return false; + } + + struct PrivateShopItemInput { + uint32_t vnum; + uint8_t count; + uint8_t inv_window; + uint16_t inv_cell; + uint32_t price; + uint8_t display_pos; + }; + std::vector parsed; + parsed.reserve(items.size()); + for (int i = 0; i < items.size(); ++i) { + const Variant raw = items[i]; + if (raw.get_type() != Variant::DICTIONARY) { + return false; + } + const Dictionary d = raw; + int64_t vnum = 0; + int64_t count = 1; + int64_t inv_window = mtnet::WINDOW_INVENTORY; + int64_t inv_cell = 0; + int64_t price = 0; + int64_t display_pos = i; + if (!read_int(d, "vnum", vnum, 0) || !read_int(d, "count", count, 1) || + !read_int(d, "inv_window", inv_window, mtnet::WINDOW_INVENTORY) || + !read_int(d, "inv_cell", inv_cell, 0) || !read_int(d, "price", price, 0) || + !read_int(d, "display_pos", display_pos, i) || !mtnet::bounds::u32(vnum) || + !mtnet::bounds::u8(count) || count < 1 || !mtnet::bounds::u8(inv_window) || + !mtnet::bounds::u16(inv_cell) || !mtnet::bounds::u32(price) || + display_pos < 0 || !mtnet::bounds::shop_slot(display_pos)) { + return false; + } + parsed.push_back({(uint32_t)vnum, (uint8_t)count, (uint8_t)inv_window, + (uint16_t)inv_cell, (uint32_t)price, (uint8_t)display_pos}); + } + if (classic_sess) { std::vector v; - v.reserve(items.size()); - for (int i = 0; i < items.size() && i < 39; ++i) { - Dictionary d = items[i]; + v.reserve(parsed.size()); + for (const auto &in : parsed) { mtnet::classic::CGMyShopItem e{}; - e.vnum = (uint32_t)(int64_t)d.get("vnum", 0); - e.count = (uint8_t)(int)d.get("count", 1); - e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY), - (uint16_t)(int)d.get("inv_cell", 0)}; - e.price = (uint32_t)(int64_t)d.get("price", 0); - e.display_pos = (uint8_t)(int)d.get("display_pos", i); + e.vnum = in.vnum; + e.count = in.count; + e.pos = {in.inv_window, in.inv_cell}; + e.price = in.price; + e.display_pos = in.display_pos; v.push_back(e); } - return classic_sess->send_private_shop(std::string(sign.utf8().get_data()), v); + return classic_sess->send_private_shop(sign_wire, v); } std::vector v; - for (int i = 0; i < items.size(); ++i) { - Dictionary d = items[i]; + v.reserve(parsed.size()); + for (const auto &in : parsed) { mtnet::MyShopItem e{}; - e.vnum = (uint32_t)(int64_t)d.get("vnum", 0); - e.count = (uint8_t)(int)d.get("count", 1); - e.pos = {(uint8_t)(int)d.get("inv_window", mtnet::WINDOW_INVENTORY), - (uint16_t)(int)d.get("inv_cell", 0)}; - e.price = (uint32_t)(int64_t)d.get("price", 0); - e.display_pos = (uint8_t)(int)d.get("display_pos", i); + e.vnum = in.vnum; + e.count = in.count; + e.pos = {in.inv_window, in.inv_cell}; + e.price = in.price; + e.display_pos = in.display_pos; v.push_back(e); } - return game->send_open_private_shop(std::string(sign.utf8().get_data()), v); + return game->send_open_private_shop(sign_wire, v); } bool M2Client::close_private_shop() { if (classic_sess) { @@ -1474,15 +1659,25 @@ Dictionary M2Client::get_cube() const { return d; } bool M2Client::cube_make(int result_index) { - if (classic_sess) return is_in_game() && classic_sess->send_cube_make(result_index); + if (!is_in_game() || result_index < 0 || !mtnet::bounds::u32(result_index)) { + return false; + } + if (classic_sess) return classic_sess->send_cube_make(result_index); return game && game->send_cube_make(result_index); } bool M2Client::cube_request_result_list(int npc_vnum) { - if (classic_sess) return is_in_game() && classic_sess->send_cube_result_list(npc_vnum); + if (!is_in_game() || !mtnet::bounds::u32(npc_vnum)) { + return false; + } + if (classic_sess) return classic_sess->send_cube_result_list(npc_vnum); return game && game->send_cube_result_list((uint32_t)npc_vnum); } bool M2Client::cube_request_materials(int start_index, int count) { - if (classic_sess) return is_in_game() && classic_sess->send_cube_materials(start_index, count); + if (!is_in_game() || !mtnet::bounds::u8(start_index) || count < 1 || + !mtnet::bounds::u8(count)) { + return false; + } + if (classic_sess) return classic_sess->send_cube_materials(start_index, count); return game && game->send_cube_material_info(start_index, count); } bool M2Client::cube_open() { @@ -1498,20 +1693,19 @@ bool M2Client::cube_list() { return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube list"); } bool M2Client::cube_add_item(int cube_index, int inventory_index) { - if (classic_sess) { - return is_in_game() && classic_sess->send_cube_add_item(cube_index, inventory_index); - } - if (!game || !is_in_game() || cube_index < 0 || cube_index >= 24 || - inventory_index < 0 || inventory_index > 255) { + if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index) || + !mtnet::bounds::u8(inventory_index)) { return false; } + if (classic_sess) return classic_sess->send_cube_add_item(cube_index, inventory_index); + if (!game) return false; return game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube add " + std::to_string(cube_index) + " " + std::to_string(inventory_index)); } bool M2Client::cube_delete_item(int cube_index) { - if (classic_sess) return is_in_game() && classic_sess->send_cube_delete_item(cube_index); - return game && is_in_game() && cube_index >= 0 && cube_index < 24 && - game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index)); + if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index)) return false; + if (classic_sess) return classic_sess->send_cube_delete_item(cube_index); + return game && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index)); } // --- guild / refine --- @@ -1571,23 +1765,25 @@ Array M2Client::get_guild_grades() const { return out; } bool M2Client::guild_add_member(int vid) { - if (classic_sess) return is_in_game() && vid > 0 && + if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_ADD_MEMBER, (uint32_t)vid); - return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid); + return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) && + game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid); } bool M2Client::guild_remove_member(int pid) { - if (classic_sess) return is_in_game() && pid > 0 && + if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid); - return game && is_in_game() && + return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) && game->send_guild_sub_u32(mtnet::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid); } bool M2Client::guild_offer(int amount) { - if (classic_sess) return is_in_game() && amount >= 0 && + if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) && classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_OFFER, (uint32_t)amount); - return game && is_in_game() && game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount); + return game && is_in_game() && mtnet::bounds::u32(amount) && + game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount); } bool M2Client::guild_charge_gsp(int amount) { - if (amount < 0) return false; + if (!mtnet::bounds::i32(amount)) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_i32(mtnet::classic::GUILD_CG_CHARGE_GSP, (int32_t)amount); return game && is_in_game() && @@ -1602,21 +1798,21 @@ bool M2Client::guild_change_grade_name(int grade, const String &name) { return game && is_in_game() && game->send_guild_grade_name((uint8_t)grade, value); } bool M2Client::guild_change_grade_authority(int grade, int authority) { - if (grade < 1 || grade >= 16 || authority < 0 || authority > 255) return false; + if (grade < 1 || grade >= 16 || !mtnet::bounds::u8(authority)) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority); return game && is_in_game() && game->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority); } bool M2Client::guild_change_member_grade(int pid, int grade) { - if (pid <= 0 || grade < 1 || grade >= 16) return false; + if (pid <= 0 || !mtnet::bounds::u32(pid) || grade < 1 || grade >= 16) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_member_grade((uint32_t)pid, (uint8_t)grade); return game && is_in_game() && game->send_guild_member_grade((uint32_t)pid, (uint8_t)grade); } bool M2Client::guild_change_member_general(int pid, bool enabled) { - if (pid <= 0) return false; + if (pid <= 0 || !mtnet::bounds::u32(pid)) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_member_general((uint32_t)pid, enabled); return game && is_in_game() && @@ -1629,7 +1825,7 @@ bool M2Client::guild_post_comment(const String &text) { return game && is_in_game() && game->send_guild_comment(comment); } bool M2Client::guild_delete_comment(int comment_id) { - if (comment_id <= 0) return false; + if (comment_id <= 0 || !mtnet::bounds::u32(comment_id)) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DELETE_COMMENT, (uint32_t)comment_id); @@ -1642,7 +1838,7 @@ bool M2Client::guild_refresh_comments() { return game && is_in_game() && game->send_guild_sub(mtnet::GUILD_CG_REFRESH_COMMENT); } bool M2Client::guild_answer_invite(int guild_id, bool accept) { - if (guild_id <= 0) return false; + if (guild_id <= 0 || !mtnet::bounds::u32(guild_id)) return false; if (classic_sess) return is_in_game() && classic_sess->send_guild_invite_answer((uint32_t)guild_id, accept); return game && is_in_game() && game->send_guild_invite_answer((uint32_t)guild_id, accept); @@ -1726,10 +1922,11 @@ String M2Client::get_guild_name(int guild_id) const { } bool M2Client::use_guild_skill(int skill_vnum, int target_vid) { - if (classic_sess) return is_in_game() && skill_vnum >= 0 && target_vid >= 0 && - classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid); - return game && is_in_game() && - game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid); + if (!is_in_game() || !mtnet::bounds::u32(skill_vnum) || !mtnet::bounds::u32(target_vid)) { + return false; + } + if (classic_sess) return classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid); + return game && game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid); } bool M2Client::declare_guild_war(const String &guild_name) { @@ -1739,7 +1936,7 @@ bool M2Client::declare_guild_war(const String &guild_name) { // --- guild marks --- bool M2Client::download_guild_marks(const String &host, int port) { - if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty()) { + if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty()) { return false; } mark_host = host; @@ -1777,7 +1974,8 @@ bool M2Client::download_guild_marks(const String &host, int port) { } bool M2Client::download_guild_symbol(const String &host, int port, int guild_id) { - if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || guild_id <= 0) { + if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || + guild_id <= 0 || !mtnet::bounds::u32(guild_id)) { return false; } symbol_guild_id = 0; @@ -1838,7 +2036,8 @@ Dictionary M2Client::get_mark_server() const { } bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, const Ref &img) { - if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || img.is_null()) { + if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || + guild_id <= 0 || !mtnet::bounds::u32(guild_id) || img.is_null()) { return false; } Ref rgba = img; @@ -1893,7 +2092,8 @@ bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, con bool M2Client::upload_guild_symbol(const String &host, int port, int guild_id, const PackedByteArray &file_bytes) { - if ((!game && !classic_sess) || port <= 0 || port > 65535 || host.is_empty() || file_bytes.is_empty()) { + if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() || + guild_id <= 0 || !mtnet::bounds::u32(guild_id) || file_bytes.is_empty()) { return false; } std::vector bytes(file_bytes.ptr(), file_bytes.ptr() + file_bytes.size()); @@ -2059,9 +2259,11 @@ void M2Client::pump_classic_mark() { } } bool M2Client::refine(int pos, int type) { - if (classic_sess) return is_in_game() && pos >= 0 && pos < mtnet::INVENTORY_MAX_NUM && - type >= 0 && type <= 255 && classic_sess->send_refine((uint8_t)pos, (uint8_t)type); - return game && is_in_game() && game->send_refine((uint8_t)pos, (uint8_t)type); + if (!is_in_game() || pos < 0 || pos >= mtnet::INVENTORY_MAX_NUM || !mtnet::bounds::u8(type)) { + return false; + } + if (classic_sess) return classic_sess->send_refine((uint8_t)pos, (uint8_t)type); + return game && game->send_refine((uint8_t)pos, (uint8_t)type); } bool M2Client::ds_refine(int mode, const Array &cells) { @@ -2077,7 +2279,7 @@ bool M2Client::ds_refine(int mode, const Array &cells) { if (classic_sess) { mtnet::classic::ItemPos grid[mtnet::classic::DS_REFINE_GRID_MAX_NUM] = {}; int n = (int)cells.size(); - if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) n = mtnet::classic::DS_REFINE_GRID_MAX_NUM; + if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) return false; for (int i = 0; i < n; ++i) { int cell = (int)cells[i]; if (cell < 0 || cell > 0xffff) return false; @@ -2088,11 +2290,10 @@ bool M2Client::ds_refine(int mode, const Array &cells) { if (!game) return false; mtnet::ItemPos grid[mtnet::DS_REFINE_WINDOW_MAX_NUM] = {}; int n = (int)cells.size(); - if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) { - n = mtnet::DS_REFINE_WINDOW_MAX_NUM; - } + if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) return false; for (int i = 0; i < n; ++i) { int cell = (int)cells[i]; + if (cell < 0 || cell > 0xffff) return false; grid[i].window_type = mtnet::WINDOW_INVENTORY; grid[i].cell = (uint16_t)cell; } @@ -2246,6 +2447,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) { d["level"] = e.level; d["dead"] = e.dead; d["stunned"] = e.stunned; + d["knock_down"] = e.knock_down; d["mount_vnum"] = (int)e.mount_vnum; d["guild"] = e.guild; d["alignment"] = (int)e.alignment; @@ -2324,6 +2526,11 @@ Dictionary M2Client::get_points() const { d["next_exp"] = (int64_t)(uint32_t)p.next_exp(); d["gold"] = p.gold(); d["attack_speed"] = p.v[mtnet::POINT_ATT_SPEED]; + d["bow_distance"] = p.v[mtnet::POINT_BOW_DISTANCE]; // __GetBowRange() 距离加成 + // §3.10 加号按钮门控:三个技能点来源分开取(uicharacter.py skillPageStatDict)。 + d["skill_active"] = p.v[mtnet::POINT_SKILL]; + d["skill_support"] = p.v[mtnet::POINT_SUB_SKILL]; + d["skill_horse"] = p.v[mtnet::POINT_HORSE_SKILL]; d["energy"] = p.energy(); d["energy_end_time"] = p.energy_end_time(); Array values; @@ -2370,7 +2577,8 @@ Array M2Client::get_affects() const { // --- items --- bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) { - if (!is_in_game()) { + if (!is_in_game() || !valid_item_pos(fw, fc) || !valid_item_pos(tw, tc) || + !mtnet::bounds::u8(count)) { return false; } if (classic_sess) { @@ -2381,7 +2589,7 @@ bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) { (uint8_t)count); } bool M2Client::use_item(int window, int cell) { - if (!is_in_game()) { + if (!is_in_game() || !valid_item_pos(window, cell)) { return false; } if (classic_sess) { @@ -2390,7 +2598,7 @@ bool M2Client::use_item(int window, int cell) { return game && game->send_item_use((uint8_t)window, (uint16_t)cell); } bool M2Client::drop_item(int window, int cell, int gold) { - if (!is_in_game()) { + if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold)) { return false; } if (classic_sess) { @@ -2399,7 +2607,8 @@ bool M2Client::drop_item(int window, int cell, int gold) { return game && game->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold); } bool M2Client::drop_item_count(int window, int cell, int gold, int count) { - if (!is_in_game() || count < 1 || count > 255) return false; + if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold) || + count < 1 || !mtnet::bounds::u8(count)) return false; if (classic_sess) { return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold, (uint8_t)count); @@ -2408,19 +2617,17 @@ bool M2Client::drop_item_count(int window, int cell, int gold, int count) { (uint8_t)count); } bool M2Client::use_item_to_item(int sw, int sc, int tw, int tc) { - if (!is_in_game()) return false; + if (!is_in_game() || !valid_item_pos(sw, sc) || !valid_item_pos(tw, tc)) return false; if (classic_sess) { - if (sw < 0 || sw > 255 || tw < 0 || tw > 255 || sc < 0 || sc > 0xffff || tc < 0 || tc > 0xffff) - return false; return classic_sess->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc); } if (!game) return false; return game->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc); } bool M2Client::give_item(int target_vid, int window, int cell, int count) { - if (!is_in_game() || target_vid <= 0 || count < 1 || count > 255) return false; + if (!is_in_game() || target_vid <= 0 || !mtnet::bounds::u32(target_vid) || + !valid_item_pos(window, cell) || count < 1 || !mtnet::bounds::u8(count)) return false; if (classic_sess) { - if (window < 0 || window > 255 || cell < 0 || cell > 0xffff) return false; return classic_sess->send_item_give((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count); } @@ -2428,7 +2635,7 @@ bool M2Client::give_item(int target_vid, int window, int cell, int count) { return game->send_give_item((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count); } bool M2Client::pickup_item(int ground_vid) { - if (!is_in_game()) { + if (!is_in_game() || ground_vid <= 0 || !mtnet::bounds::u32(ground_vid)) { return false; } if (classic_sess) { @@ -2604,18 +2811,10 @@ void M2Client::_process(double) { } } -void M2Client::_notification(int what) { - switch (what) { - case NOTIFICATION_APPLICATION_PAUSED: - suspend(); - break; - case NOTIFICATION_APPLICATION_RESUMED: - resume(); - break; - default: - break; - } -} +// §9.3: M2Client no longer listens for NOTIFICATION_APPLICATION_PAUSED/RESUMED. +// AppFlow owns the single AppLifecycle coordinator (equivalent to +// CPythonApplication); it drives suspend()/resume() below. Keeping a second +// listener here meant two entry points for the same lifecycle edge. void M2Client::suspend() { if (suspended) { diff --git a/extension/src/net/m2_client.h b/extension/src/net/m2_client.h index 64895a65..046080e1 100644 --- a/extension/src/net/m2_client.h +++ b/extension/src/net/m2_client.h @@ -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, @@ -89,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); diff --git a/extension/src/net/net_bounds.h b/extension/src/net/net_bounds.h new file mode 100644 index 00000000..c972ed35 --- /dev/null +++ b/extension/src/net/net_bounds.h @@ -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 +#include + +namespace mtnet::bounds { + +inline constexpr bool u8(int64_t value) { + return value >= 0 && value <= std::numeric_limits::max(); +} + +inline constexpr bool u16(int64_t value) { + return value >= 0 && value <= std::numeric_limits::max(); +} + +inline constexpr bool u32(int64_t value) { + return value >= 0 && value <= std::numeric_limits::max(); +} + +inline constexpr bool i32(int64_t value) { + return value >= std::numeric_limits::min() && + value <= std::numeric_limits::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 diff --git a/extension/src/net/wire.h b/extension/src/net/wire.h index 345ba64d..68cb0556 100644 --- a/extension/src/net/wire.h +++ b/extension/src/net/wire.h @@ -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, }; diff --git a/extension/src/proto/proto.cpp b/extension/src/proto/proto.cpp index d561670d..0b7cfcfb 100644 --- a/extension/src/proto/proto.cpp +++ b/extension/src/proto/proto.cpp @@ -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(i) * 5; + it.limits[i].type = r[off]; + it.limits[i].value = static_cast(rd_u32(r + off + 1)); + } + for (int i = 0; i < 3; ++i) { + const size_t off = 176 + static_cast(i) * 5; + it.applies[i].type = r[off]; + it.applies[i].value = static_cast(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(rd_u32(r + 215 + i * 4)); + } + it.refined_vnum = rd_u32(r + 227); + it.refine_set = static_cast(r[231] | (static_cast(r[232]) << 8)); + it.alter_to_magic_pct = r[233]; it.specular = r[234]; + it.gain_socket_pct = r[235]; return it; } diff --git a/extension/src/proto/proto.h b/extension/src/proto/proto.h index 7f2aa155..245f35c5 100644 --- a/extension/src/proto/proto.h +++ b/extension/src/proto/proto.h @@ -43,6 +43,16 @@ bool load_proto(const std::string &path, const std::array &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 limits{}; // aLimits[2] @ 166 (type + long) + std::array 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); diff --git a/extension/src/proto/proto_node.cpp b/extension/src/proto/proto_node.cpp index ff325adb..48bcd570 100644 --- a/extension/src/proto/proto_node.cpp +++ b/extension/src/proto/proto_node.cpp @@ -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; } diff --git a/extension/src/tree_placeholder.cpp b/extension/src/tree_placeholder.cpp index 71562004..569b017a 100644 --- a/extension/src/tree_placeholder.cpp +++ b/extension/src/tree_placeholder.cpp @@ -445,7 +445,9 @@ Ref 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; } diff --git a/extension/tests/net_bounds_test.cpp b/extension/tests/net_bounds_test.cpp new file mode 100644 index 00000000..a4f6b073 --- /dev/null +++ b/extension/tests/net_bounds_test.cpp @@ -0,0 +1,44 @@ +// Public M2Client argument bounds shared by the Classic and m2dev backends. +#include "../src/net/net_bounds.h" + +#include + +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; +} diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp index 22b52da8..76d00915 100644 --- a/extension/tests/net_classic_session_test.cpp +++ b/extension/tests/net_classic_session_test.cpp @@ -871,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; diff --git a/extension/tests/net_classic_stream_test.cpp b/extension/tests/net_classic_stream_test.cpp index 9b19a115..7900e5e7 100644 --- a/extension/tests/net_classic_stream_test.cpp +++ b/extension/tests/net_classic_stream_test.cpp @@ -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; diff --git a/extension/tests/net_entity_test.cpp b/extension/tests/net_entity_test.cpp index dd16e728..399d7544 100644 --- a/extension/tests/net_entity_test.cpp +++ b/extension/tests/net_entity_test.cpp @@ -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; diff --git a/extension/tests/net_state_queue_test.cpp b/extension/tests/net_state_queue_test.cpp new file mode 100644 index 00000000..2761fff4 --- /dev/null +++ b/extension/tests/net_state_queue_test.cpp @@ -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 +#include + +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; +} diff --git a/extension/tests/proto_item_layout_test.cpp b/extension/tests/proto_item_layout_test.cpp new file mode 100644 index 00000000..fa7ab064 --- /dev/null +++ b/extension/tests/proto_item_layout_test.cpp @@ -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 +#include +#include +#include + +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 &record, size_t offset, uint32_t value) { + record[offset + 0] = static_cast(value); + record[offset + 1] = static_cast(value >> 8); + record[offset + 2] = static_cast(value >> 16); + record[offset + 3] = static_cast(value >> 24); +} + +int main() { + std::array 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; +} diff --git a/formats/CMakeLists.txt b/formats/CMakeLists.txt index 72677ad4..0d1922c7 100644 --- a/formats/CMakeLists.txt +++ b/formats/CMakeLists.txt @@ -4,6 +4,7 @@ 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 连击段表 m2_tokvec.cpp # W0: LoadMultipleTextData 等价(Start/End 块 → token 向量表) m2_coord.cpp # W0: Metin2↔Godot 坐标 / 单位 / 区块编号(BACKLOG I5) @@ -27,11 +28,23 @@ 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_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) diff --git a/formats/combo_table.cpp b/formats/combo_table.cpp new file mode 100644 index 00000000..35a0938a --- /dev/null +++ b/formats/combo_table.cpp @@ -0,0 +1,186 @@ +// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。 +#include "combo_table.h" +#include "m2_tokvec.h" // fmt::read_file + +#include +#include + +namespace fmt { + +const std::vector* 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(s[a]))) ++a; + while (b > a && std::isspace(static_cast(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 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& 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 换块:只有四个 __LoadGameEx 有连击段调用,其它 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 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(args[0]), static_cast(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(static_cast(count), 0)); + continue; + } + + size_t g = t.find(kRegister); + if (g != std::string::npos) { + std::vector 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(args[0]), static_cast(args[1])); + const long idx = args[2]; + const uint16_t motion = static_cast(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(idx) >= it->second.size()) continue; // 越界:跳过 + it->second[static_cast(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 diff --git a/formats/combo_table.h b/formats/combo_table.h new file mode 100644 index 00000000..1d73b7bf --- /dev/null +++ b/formats/combo_table.h @@ -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 / RegisterComboAttack,GameLib/RaceData.cpp)。 +// 本解析器就把那批调用读成同一张表。 +// +// NPC/怪物的连击段走另一条路(RaceManager.cpp __LoadRaceMotionList 读 motlist.txt, +// 只在 MODE_GENERAL 下登记 NAME_COMBO_ATTACK_1/2/3,没有段号向量),不在这里。 +#pragma once +#include +#include +#include +#include + +namespace fmt { + +// CRaceMotionData::EMode(chr.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(motion_mode) << 16) | static_cast(combo_type); +} +inline uint16_t combo_key_mode(uint32_t key) { return static_cast((key >> 16) & 0xFFFF); } +inline uint16_t combo_key_type(uint32_t key) { return static_cast(key & 0xFFFF); } + +// playersettingmodule.py 按 __LoadGameEx(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_* 段号, + // 下标即 dwComboArrayIndex(m_dwcurComboIndex - 1)。 + std::map> combos; + + // GetComboDataPointer:命中返回段号向量指针,否则 nullptr。 + const std::vector* 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 __LoadGameEx 分块填 per_class。照 CRaceData 语义: +// Reserve -> combos[key] = vector(count, 0)(重复 key 不覆盖,同 std::map::insert) +// Register -> combos[key][index] = motion(key 未 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 diff --git a/formats/environment.cpp b/formats/environment.cpp index 09d14c53..b97cd6dd 100644 --- a/formats/environment.cpp +++ b/formats/environment.cpp @@ -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); diff --git a/formats/environment.h b/formats/environment.h index beb93776..ea4b01e3 100644 --- a/formats/environment.h +++ b/formats/environment.h @@ -13,26 +13,36 @@ using Vec3f = std::array; using Vec2f = std::array; 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 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 cloud_color; // List CloudColor std::vector 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); diff --git a/formats/msa.cpp b/formats/msa.cpp index 6c3c3b69..f89b1f2a 100644 --- a/formats/msa.cpp +++ b/formats/msa.cpp @@ -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& out) { + const Node* lst = win.group("HitPosition"); + if (!lst || lst->lines.empty()) return; + const std::vector& 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; diff --git a/formats/msa.h b/formats/msa.h index 61240114..5bf28485 100644 --- a/formats/msa.h +++ b/formats/msa.h @@ -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 / AttackingType(SAttackData::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; // HitLimitCount(0 = 不限) + + // 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 samples; // List HitPosition + }; + std::vector 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); diff --git a/formats/tests/combo_table_test.cpp b/formats/tests/combo_table_test.cpp new file mode 100644 index 00000000..a2aeecc7 --- /dev/null +++ b/formats/tests/combo_table_test.cpp @@ -0,0 +1,142 @@ +// M2 §3.5 —— playersettingmodule.py 的 ComboAttackNew 调用 -> PC 连击段表。 +// 照 GameLib/RaceData.cpp CRaceData::ReserveComboAttack / RegisterComboAttack 语义。 +#include + +#include +#include +#include + +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 的重复 Reserve:map::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 *v, std::initializer_list 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 / 20(COMBO_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; +} diff --git a/formats/tests/map_formats_test.cpp b/formats/tests/map_formats_test.cpp index 3387dfe9..fc4bc745 100644 --- a/formats/tests/map_formats_test.cpp +++ b/formats/tests/map_formats_test.cpp @@ -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()); diff --git a/formats/tests/msa_test.cpp b/formats/tests/msa_test.cpp index a8428e1f..d7cf785b 100644 --- a/formats/tests/msa_test.cpp +++ b/formats/tests/msa_test.cpp @@ -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; } diff --git a/formats/textscript.cpp b/formats/textscript.cpp index dc2bc29f..55319ad0 100644 --- a/formats/textscript.cpp +++ b/formats/textscript.cpp @@ -48,11 +48,14 @@ bool parse_body(const std::vector& 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)); diff --git a/project/ambience_test.gd b/project/ambience_test.gd new file mode 100644 index 00000000..561ba9c2 --- /dev/null +++ b/project/ambience_test.gd @@ -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) diff --git a/project/app_flow.gd b/project/app_flow.gd index acb0f81d..8eb8ca7a 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -14,6 +14,7 @@ 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 GameScene = preload("res://game_scene.gd") +const AppLifecycle = preload("res://app_lifecycle.gd") enum { LOGIN, SELECT, GAME } @@ -24,15 +25,16 @@ var _assets := "" var _ui: CanvasLayer # 登录 / 选人的临时界面层 var _loading: CanvasLayer var _game: Node +var _lifecycle: 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 _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 @@ -51,6 +53,13 @@ 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) _goto_login() if auto_login: call_deferred("_do_connect") @@ -133,6 +142,9 @@ func _clear_ui() -> void: func _goto_login() -> void: _state = LOGIN + if _lifecycle: + # GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。 + _lifecycle.bind(client) if _game: _game.queue_free() _game = null @@ -165,6 +177,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() diff --git a/project/app_flow_lifecycle_test.gd b/project/app_flow_lifecycle_test.gd new file mode 100644 index 00000000..1b9a6983 --- /dev/null +++ b/project/app_flow_lifecycle_test.gd @@ -0,0 +1,51 @@ +# app_flow_lifecycle_test —— §9.3 生命周期单一属主回归。 +# AppFlow 跨登录 / 选人 / 游戏持有唯一 AppLifecycle;GameScene 不重复创建。 +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 _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") + + 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) diff --git a/project/audio.gd b/project/audio.gd index 716723a0..ec50c31b 100644 --- a/project/audio.gd +++ b/project/audio.gd @@ -1,9 +1,19 @@ # Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。 # 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、 -# 3D wav(`**/sound/`)。纯资产接线,无解码器移植(`.mss` 不用)。 +# 3D wav(`**/sound/`)。`.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,20 @@ 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_fBackupSoundVolume(REF/MilesLib/SoundManager.h:31)。 @@ -34,6 +58,7 @@ func _ready() -> void: func setup(assets_root: String) -> void: _assets = assets_root + _sound_roots.clear() # bgm/ 与 assets/ 同级(/bgm,见 AssetRoot) _bgm_root = _assets.get_base_dir().path_join("bgm") # 已知声音根 + 各 patch 的 sound/ @@ -57,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 ----------------------------------------------------------- @@ -78,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 `/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) @@ -95,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/.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: @@ -141,6 +330,30 @@ func set_music_volume(v: float) -> void: 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 @@ -178,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) diff --git a/project/audio_miles_test.gd b/project/audio_miles_test.gd new file mode 100644 index 00000000..6243e744 --- /dev/null +++ b/project/audio_miles_test.gd @@ -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) diff --git a/project/charselect.gd b/project/charselect.gd index 76ed8225..a4b3b6df 100644 --- a/project/charselect.gd +++ b/project/charselect.gd @@ -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") diff --git a/project/chat_tail.gd b/project/chat_tail.gd new file mode 100644 index 00000000..5f30922e --- /dev/null +++ b/project/chat_tail.gd @@ -0,0 +1,109 @@ +# chat_tail —— §8.8 头顶「聊天 / 信息」尾标 1:1:CPythonTextTail::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_ChatTailMap,LivingTime < now 即 DeleteTextTail + erase +# (每 vid 只有一条 chat/info 尾标,chat 与 info 共用同一张 m_ChatTailMap) +# :74 UpdateAllTextTail:chat 尾标若 bNameFlag → ShowCharacterTextTail(vid)(名字也一起顶出来) +# :105 UpdateShowingTextTail:chat 尾标若 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/WHISPER:line = strchr(buf,':') ? p + 2 : buf(去掉 "名字: " 前缀) +# :1363 INFO/NOTICE/BIG_NOTICE/default:line = 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 # ms(gs_TextTail_LivingTime) +const LIVING_TIME_MIN := 1000 # assert 下界(TextTail_GetLivingTime) +const HEIGHT_ADD := 10.0 # 角色高 + 10(RegisterChatTail 新建分支) + +# 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_GetLivingTime:assert(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 的 switch:TALKING/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: + # RecvChatPacket:COMMAND 提前 return;SHOUT 显式排除;其余带 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: + # UpdateAllTextTail:LivingTime < 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 diff --git a/project/chat_tail_test.gd b/project/chat_tail_test.gd new file mode 100644 index 00000000..9e248bad --- /dev/null +++ b/project/chat_tail_test.gd @@ -0,0 +1,106 @@ +# chat_tail_test —— §8.8 头顶「聊天 / 信息」尾标 1:1: +# CPythonTextTail::RegisterChatTail / RegisterInfoTail 的白 / 淡红色、bNameFlag、 +# gs_TextTail_LivingTime(5000,assert >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_prefix:strchr(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_tail:switch 里 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_tail:RecvChatPacket 除 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. expire:UpdateAllTextTail 的 LivingTime < now 删除 ----------------- + # vid 42 LivingTime = t0+2000+5000 = t0+7000;vid 7 = t0+5000 + var dead := ChatTail.expire(tails, t0 + 6000) + _ck(dead == [7], "expire(now=t0+6000) 清掉 vid 7(LivingTime 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 返回 []") diff --git a/project/chat_test.gd b/project/chat_test.gd index d31ecf47..3d7ccfb5 100644 --- a/project/chat_test.gd +++ b/project/chat_test.gd @@ -27,6 +27,12 @@ class FakeClient extends Node: func say(t, s) -> bool: said.append([t, s]); return true func whisper(to, s) -> bool: whispered.append([to, s]); return true +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: if not c: @@ -52,7 +58,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 +90,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") diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd index 7251f15b..a995c442 100644 --- a/project/combat_fx_test.gd +++ b/project/combat_fx_test.gd @@ -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.6:CG_FLY_TARGETING + var shots := [] # §3.6:CG_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") + + # ComboProcess:elapsed 过了 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)") + + # 动作回到 Wait(elapsed > 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 m(yaw 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_mode(RefreshState 的 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-XY(OnGetFlyTargetPosition 平面分量)") + _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}] diff --git a/project/complex_item_drop_test.gd b/project/complex_item_drop_test.gd new file mode 100644 index 00000000..e8e0f5e5 --- /dev/null +++ b/project/complex_item_drop_test.gd @@ -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) diff --git a/project/dungeon_state.gd b/project/dungeon_state.gd new file mode 100644 index 00000000..a99e23e3 --- /dev/null +++ b/project/dungeon_state.gd @@ -0,0 +1,86 @@ +# DungeonState —— GC_DUNGEON 目的地状态(40250/ClientVS22)。 +# +# 参考端 PythonNetworkStreamPhaseGame.cpp:3875-3901 收到目的地后调用 +# CPythonPlayer::SetDungeonDestinationPosition;PythonPlayer.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() diff --git a/project/dungeon_state_test.gd b/project/dungeon_state_test.gd new file mode 100644 index 00000000..39f0de96 --- /dev/null +++ b/project/dungeon_state_test.gd @@ -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() diff --git a/project/entity_rules.gd b/project/entity_rules.gd new file mode 100644 index 00000000..3540c9aa --- /dev/null +++ b/project/entity_rules.gd @@ -0,0 +1,255 @@ +# 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 IsWoodenDoor(vnum 13000 / 30111..30119) +# +# 纯静态谓词。调用方(net_play.gd)负责把网络快照拼成 self_e / victim_e / ctx; +# 部分依赖在本 40250 客户端的发布源码里没有 setter(同 §3.4/§3.6/§3.8 的处理), +# 在下方逐条标注为 seam。 +extends RefCounted + +# EPKModes(REF/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 type(REF/UserInterface/InstanceBase.h:249) +const ALIGNMENT_TYPE_WHITE := 0 +const ALIGNMENT_TYPE_NORMAL := 1 +const ALIGNMENT_TYPE_DARK := 2 + +# duel mode(REF/UserInterface/InstanceBase.h:378,GetDuelMode 返回 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 / party(InstanceBase.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 +const KIND_PC := 0 +const KIND_NPC := 1 +const KIND_MONSTER := 2 +const KIND_STONE := 3 +const KIND_WARP := 4 + +# --- alignment(善恶)--------------------------------------------------------- + +# CInstanceBase::GetAlignmentGrade(InstanceBase.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::GetAlignmentType(InstanceBase.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::IsWoodenDoor(InstanceBase.cpp:2390) + 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()。 + # 建筑 / 城墙的 thing type 在本 POC 未单独下发(IsWall 的 race 白名单未随源码发布)→ seam。 + return bool(e.get("building", false)) + +# --- 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::IsSameEmpire(InstanceBase.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::__FindDUELKey(InstanceBaseEffect.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::IsPVPInstance(InstanceBaseEffect.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::IsConflictAlignmentInstance(InstanceBase.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::IsAttackableInstance(InstanceBase.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 diff --git a/project/entity_rules_test.gd b/project/entity_rules_test.gd new file mode 100644 index 00000000..a598f4dd --- /dev/null +++ b/project/entity_rules_test.gd @@ -0,0 +1,166 @@ +# 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 异帝国 PC(else 分支) + _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_FREE:victim 非 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_FREE:victim 是 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 victim(state_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 alignment(self 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") + + # 24) is_conflict_alignment_instance:victim 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") diff --git a/project/environment_test.gd b/project/environment_test.gd new file mode 100644 index 00000000..5871d665 --- /dev/null +++ b/project/environment_test.gd @@ -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) diff --git a/project/equip_model_test.gd b/project/equip_model_test.gd index d2821988..33173db3 100644 --- a/project/equip_model_test.gd +++ b/project/equip_model_test.gd @@ -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) diff --git a/project/equip_rules.gd b/project/equip_rules.gd new file mode 100644 index 00000000..159c3ef1 --- /dev/null +++ b/project/equip_rules.gd @@ -0,0 +1,234 @@ +# equip_rules.gd —— 装备穿戴合法性校验(纯函数校验器)。 +# +# 语义顺序对齐服务器 `char_item.cpp::CanEquipNow`: +# antiflag(性别 / 职业 / 帝国) → wearable_flag 与目标 wear 槽匹配 → limit(等级 / 属性)。 +# +# 参考枚举 1:1 自 `REF/GameLib/ItemData.h`(`EItemAntiFlag` / `EItemWearableFlag` / +# `EWearPositions` / `ELimitTypes` / `EItemType` / `EArmorSubTypes` / `ECostumeSubTypes`)。 +# 40250 客户端本身**不做** `CanEquipNow` 前置门(`PythonItemModule` 只暴露 `IsWearableFlag` / +# `GetLimit`,真正的仲裁在服务器)。这里按 §3.9 `__CanShot` 的方式在客户端防御式重建一道前置 +# 门——精确的 tie-break 顺序、PCBANG / REAL_TIME 类 limit、已穿同槽替换等留待接服务器比对(seam)。 +extends RefCounted + +# --- EItemAntiFlag(ItemData.h,classic 排列:帝国位在 1<<9..1<<11)--- +const ANTIFLAG_FEMALE := 1 << 0 +const ANTIFLAG_MALE := 1 << 1 +const ANTIFLAG_WARRIOR := 1 << 2 +const ANTIFLAG_ASSASSIN := 1 << 3 +const ANTIFLAG_SURA := 1 << 4 +const ANTIFLAG_SHAMAN := 1 << 5 +const ANTIFLAG_EMPIRE_A := 1 << 9 +const ANTIFLAG_EMPIRE_B := 1 << 10 +const ANTIFLAG_EMPIRE_R := 1 << 11 + +# --- EItemWearableFlag --- +const WEARABLE_BODY := 1 << 0 +const WEARABLE_HEAD := 1 << 1 +const WEARABLE_FOOTS := 1 << 2 +const WEARABLE_WRIST := 1 << 3 +const WEARABLE_WEAPON := 1 << 4 +const WEARABLE_NECK := 1 << 5 +const WEARABLE_EAR := 1 << 6 +const WEARABLE_UNIQUE := 1 << 7 +const WEARABLE_SHIELD := 1 << 8 +const WEARABLE_ARROW := 1 << 9 + +# --- EWearPositions(0..10)+ 新装备系统槽(GameType.h,19..23)--- +const WEAR_BODY := 0 +const WEAR_HEAD := 1 +const WEAR_FOOTS := 2 +const WEAR_WRIST := 3 +const WEAR_WEAPON := 4 +const WEAR_NECK := 5 +const WEAR_EAR := 6 +const WEAR_UNIQUE1 := 7 +const WEAR_UNIQUE2 := 8 +const WEAR_ARROW := 9 +const WEAR_SHIELD := 10 +const WEAR_MAX_NUM := 11 +const WEAR_COSTUME_BODY := 19 +const WEAR_COSTUME_HAIR := 20 +const WEAR_RING1 := 21 +const WEAR_RING2 := 22 +const WEAR_BELT := 23 + +# --- ELimitTypes --- +const LIMIT_NONE := 0 +const LIMIT_LEVEL := 1 +const LIMIT_STR := 2 +const LIMIT_DEX := 3 +const LIMIT_INT := 4 +const LIMIT_CON := 5 +const LIMIT_PCBANG := 6 +const LIMIT_REAL_TIME := 7 + +# --- EItemType(相关项)+ 子类 --- +const ITEM_TYPE_WEAPON := 1 +const ITEM_TYPE_ARMOR := 2 +const ITEM_TYPE_COSTUME := 28 +const ITEM_TYPE_RING := 33 +const ITEM_TYPE_BELT := 34 + +const ARMOR_BODY := 0 +const ARMOR_HEAD := 1 +const ARMOR_SHIELD := 2 +const ARMOR_WRIST := 3 +const ARMOR_FOOTS := 4 +const ARMOR_NECK := 5 +const ARMOR_EAR := 6 + +const COSTUME_BODY := 0 +const COSTUME_HAIR := 1 + +# --- 职业 / 性别 <- race(common length.h `MAIN_RACE_*`:job = race % 4;性别见下表)--- +const JOB_WARRIOR := 0 +const JOB_ASSASSIN := 1 +const JOB_SURA := 2 +const JOB_SHAMAN := 3 +const SEX_MALE := 0 +const SEX_FEMALE := 1 +# race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M +const SEX_BY_RACE := [SEX_MALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_FEMALE, SEX_MALE, SEX_FEMALE, SEX_MALE] +const JOB_ANTIFLAG := [ANTIFLAG_WARRIOR, ANTIFLAG_ASSASSIN, ANTIFLAG_SURA, ANTIFLAG_SHAMAN] +# 帝国 1/2/3(GC_EMPIRE)→ 对应 anti 位 +const EMPIRE_ANTIFLAG := {1: ANTIFLAG_EMPIRE_A, 2: ANTIFLAG_EMPIRE_B, 3: ANTIFLAG_EMPIRE_R} + + +static func sex_of_race(race: int) -> int: + return int(SEX_BY_RACE[race]) if race >= 0 and race < SEX_BY_RACE.size() else SEX_MALE + + +static func job_of_race(race: int) -> int: + return (race % 4) if race >= 0 else JOB_WARRIOR + + +# 物品默认落在哪个 wear 槽(type / sub_type 决定)。不可穿戴 → -1。 +static func default_wear_for(item: Dictionary) -> int: + var itype := int(item.get("type", -1)) + var sub := int(item.get("sub_type", -1)) + match itype: + ITEM_TYPE_WEAPON: + return WEAR_WEAPON + ITEM_TYPE_COSTUME: + return WEAR_COSTUME_HAIR if sub == COSTUME_HAIR else WEAR_COSTUME_BODY + ITEM_TYPE_RING: + return WEAR_RING1 + ITEM_TYPE_BELT: + return WEAR_BELT + ITEM_TYPE_ARMOR: + match sub: + ARMOR_HEAD: return WEAR_HEAD + ARMOR_SHIELD: return WEAR_SHIELD + ARMOR_WRIST: return WEAR_WRIST + ARMOR_FOOTS: return WEAR_FOOTS + ARMOR_NECK: return WEAR_NECK + ARMOR_EAR: return WEAR_EAR + _: return WEAR_BODY + return -1 + + +static func is_equippable(item: Dictionary) -> bool: + return default_wear_for(item) >= 0 + + +# wear 槽 → 需要的 wearable_flag 位(新装备槽 19..23 无 wearable_flag → 0)。 +static func wear_to_wearable(wear: int) -> int: + match wear: + WEAR_BODY: return WEARABLE_BODY + WEAR_HEAD: return WEARABLE_HEAD + WEAR_FOOTS: return WEARABLE_FOOTS + WEAR_WRIST: return WEARABLE_WRIST + WEAR_WEAPON: return WEARABLE_WEAPON + WEAR_NECK: return WEARABLE_NECK + WEAR_EAR: return WEARABLE_EAR + WEAR_UNIQUE1, WEAR_UNIQUE2: return WEARABLE_UNIQUE + WEAR_ARROW: return WEARABLE_ARROW + WEAR_SHIELD: return WEARABLE_SHIELD + return 0 + + +# 目标槽能否接受这件物品:新装备槽按 item type,旧 11 槽按 wearable_flag 位。 +static func slot_accepts(item: Dictionary, wear: int) -> bool: + var itype := int(item.get("type", -1)) + match wear: + WEAR_COSTUME_BODY, WEAR_COSTUME_HAIR: + return itype == ITEM_TYPE_COSTUME + WEAR_RING1, WEAR_RING2: + return itype == ITEM_TYPE_RING + WEAR_BELT: + return itype == ITEM_TYPE_BELT + var need := wear_to_wearable(wear) + if need == 0: + return false + return (int(item.get("wear_flags", 0)) & need) != 0 + + +static func _fail(code: String, need := 0) -> Dictionary: + return {"ok": false, "code": code, "need": need} + + +# 主校验。 +# item : proto 字典 —— {type, sub_type, anti_flags, wear_flags, limits:[{type,value}]} +# wear : 目标 EWearPositions(或新装备槽 19..23);传 -1 用 default_wear_for(item) +# ctx : {race, sex, job, empire, level, st, dx, ht, iq} +# —— sex/job 缺省时由 race 推;属性 st/dx/ht/iq 缺省或 < 0 = 不判(seam) +# 返回 {ok: bool, code: String, need: int}。code 见下方常量注释;need = 失败 limit 的要求值。 +static func can_equip(item: Dictionary, wear: int, ctx: Dictionary) -> Dictionary: + if item.is_empty(): + return _fail("NO_ITEM") + var target := wear if wear >= 0 else default_wear_for(item) + if target < 0: + return _fail("NOT_EQUIPPABLE") + + var anti := int(item.get("anti_flags", 0)) + var race := int(ctx.get("race", 0)) + var sex := int(ctx.get("sex", sex_of_race(race))) + var job := int(ctx.get("job", job_of_race(race))) + var empire := int(ctx.get("empire", 0)) + + # 1) anti-flag:性别 + if sex == SEX_FEMALE and (anti & ANTIFLAG_FEMALE): + return _fail("ANTI_SEX") + if sex == SEX_MALE and (anti & ANTIFLAG_MALE): + return _fail("ANTI_SEX") + # 2) anti-flag:职业 + if job >= 0 and job < JOB_ANTIFLAG.size() and (anti & int(JOB_ANTIFLAG[job])): + return _fail("ANTI_JOB") + # 3) anti-flag:帝国 + var emp_bit := int(EMPIRE_ANTIFLAG.get(empire, 0)) + if emp_bit != 0 and (anti & emp_bit): + return _fail("ANTI_EMPIRE") + + # 4) wearable_flag 与目标槽匹配 + if not slot_accepts(item, target): + return _fail("NOT_WEARABLE_HERE") + + # 5) limit(等级 / 属性)—— 逐条 GetLimit + for lim in item.get("limits", []): + var t := int(lim.get("type", 0)) + var v := int(lim.get("value", 0)) + if v <= 0: + continue + match t: + LIMIT_LEVEL: + if int(ctx.get("level", 0)) < v: + return _fail("LIMIT_LEVEL", v) + LIMIT_STR: + if _stat(ctx, "st") >= 0 and _stat(ctx, "st") < v: + return _fail("LIMIT_STR", v) + LIMIT_DEX: + if _stat(ctx, "dx") >= 0 and _stat(ctx, "dx") < v: + return _fail("LIMIT_DEX", v) + LIMIT_INT: + if _stat(ctx, "iq") >= 0 and _stat(ctx, "iq") < v: + return _fail("LIMIT_INT", v) + LIMIT_CON: + if _stat(ctx, "ht") >= 0 and _stat(ctx, "ht") < v: + return _fail("LIMIT_CON", v) + _: + pass # PCBANG / REAL_TIME* → 服务器判(seam) + return {"ok": true, "code": "", "need": 0} + + +static func _stat(ctx: Dictionary, key: String) -> int: + return int(ctx.get(key, -1)) diff --git a/project/equip_rules_test.gd b/project/equip_rules_test.gd new file mode 100644 index 00000000..e93aab62 --- /dev/null +++ b/project/equip_rules_test.gd @@ -0,0 +1,131 @@ +# equip_rules_test —— §4.1 修改 2:装备穿戴合法性校验(EquipRules)1:1 自检。 +# canonical #17。 godot --headless --path project --script equip_rules_test.gd +extends SceneTree + +const EquipRules = preload("res://equip_rules.gd") + +var failed := 0 + +func _ck(ok: bool, message: String) -> void: + if not ok: + failed += 1 + printerr("FAIL: " + message) + +func _weapon(extra := {}) -> Dictionary: + var d := {"type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []} + d.merge(extra, true) + return d + +func _init() -> void: + _test_race_tables() + _test_default_wear() + _test_slot_accepts() + _test_antiflag() + _test_wearable() + _test_limits() + _test_order_and_newslots() + if failed == 0: + print("PASS: equip_rules_test (§4.1 CanEquipNow antiflag→wearable→limit 1:1)") + quit(0) + else: + quit(1) + +func _test_race_tables() -> void: + # race 0..7 = WARRIOR_M / ASSASSIN_W / SURA_M / SHAMAN_W / WARRIOR_W / ASSASSIN_M / SURA_W / SHAMAN_M + _ck(EquipRules.sex_of_race(0) == EquipRules.SEX_MALE, "race 0 male") + _ck(EquipRules.sex_of_race(1) == EquipRules.SEX_FEMALE, "race 1 female") + _ck(EquipRules.sex_of_race(4) == EquipRules.SEX_FEMALE, "race 4 female (warrior W)") + _ck(EquipRules.sex_of_race(5) == EquipRules.SEX_MALE, "race 5 male (assassin M)") + _ck(EquipRules.job_of_race(0) == EquipRules.JOB_WARRIOR, "race 0 warrior") + _ck(EquipRules.job_of_race(1) == EquipRules.JOB_ASSASSIN, "race 1 assassin") + _ck(EquipRules.job_of_race(6) == EquipRules.JOB_SURA, "race 6 sura") + _ck(EquipRules.job_of_race(7) == EquipRules.JOB_SHAMAN, "race 7 shaman") + +func _test_default_wear() -> void: + _ck(EquipRules.default_wear_for(_weapon()) == EquipRules.WEAR_WEAPON, "weapon -> WEAR_WEAPON") + _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 0}) == EquipRules.WEAR_BODY, "armor body -> WEAR_BODY") + _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 1}) == EquipRules.WEAR_HEAD, "armor head -> WEAR_HEAD") + _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 2}) == EquipRules.WEAR_SHIELD, "armor shield -> WEAR_SHIELD") + _ck(EquipRules.default_wear_for({"type": 2, "sub_type": 5}) == EquipRules.WEAR_NECK, "armor neck -> WEAR_NECK") + _ck(EquipRules.default_wear_for({"type": 28, "sub_type": 0}) == EquipRules.WEAR_COSTUME_BODY, "costume body -> 19") + _ck(EquipRules.default_wear_for({"type": 28, "sub_type": 1}) == EquipRules.WEAR_COSTUME_HAIR, "costume hair -> 20") + _ck(EquipRules.default_wear_for({"type": 33}) == EquipRules.WEAR_RING1, "ring -> WEAR_RING1") + _ck(EquipRules.default_wear_for({"type": 34}) == EquipRules.WEAR_BELT, "belt -> WEAR_BELT") + _ck(EquipRules.default_wear_for({"type": 3}) == -1, "potion -> not equippable") + _ck(not EquipRules.is_equippable({"type": 3}), "is_equippable(potion) false") + _ck(EquipRules.is_equippable(_weapon()), "is_equippable(weapon) true") + +func _test_slot_accepts() -> void: + _ck(EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_WEAPON), "weapon accepted at WEAR_WEAPON") + _ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_BODY), "weapon rejected at WEAR_BODY") + _ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 0}, EquipRules.WEAR_BODY), "body armor at WEAR_BODY") + _ck(EquipRules.slot_accepts({"type": 2, "wear_flags": 1 << 7}, EquipRules.WEAR_UNIQUE2), "unique flag at WEAR_UNIQUE2") + _ck(EquipRules.slot_accepts({"type": 33}, EquipRules.WEAR_RING1), "ring type at WEAR_RING1 (no wear_flags)") + _ck(not EquipRules.slot_accepts(_weapon(), EquipRules.WEAR_RING1), "weapon rejected at WEAR_RING1") + _ck(EquipRules.slot_accepts({"type": 34}, EquipRules.WEAR_BELT), "belt type at WEAR_BELT") + +func _test_antiflag() -> void: + var female_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE}) + _ck(not EquipRules.can_equip(female_only, -1, {"race": 1})["ok"], "ANTI_FEMALE blocks female (race 1)") + _ck(EquipRules.can_equip(female_only, -1, {"race": 1})["code"] == "ANTI_SEX", "code ANTI_SEX") + _ck(EquipRules.can_equip(female_only, -1, {"race": 0})["ok"], "ANTI_FEMALE ok for male (race 0)") + + var male_only := _weapon({"anti_flags": EquipRules.ANTIFLAG_MALE}) + _ck(not EquipRules.can_equip(male_only, -1, {"race": 0})["ok"], "ANTI_MALE blocks male") + _ck(EquipRules.can_equip(male_only, -1, {"sex": EquipRules.SEX_FEMALE})["ok"], "ANTI_MALE ok when sex override female") + + var no_warrior := _weapon({"anti_flags": EquipRules.ANTIFLAG_WARRIOR}) + _ck(EquipRules.can_equip(no_warrior, -1, {"race": 0})["code"] == "ANTI_JOB", "ANTI_WARRIOR blocks warrior (race 0)") + _ck(EquipRules.can_equip(no_warrior, -1, {"race": 1})["ok"], "ANTI_WARRIOR ok for assassin (race 1)") + + var no_emp_a := _weapon({"anti_flags": EquipRules.ANTIFLAG_EMPIRE_A}) + _ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 1})["code"] == "ANTI_EMPIRE", "ANTI_EMPIRE_A blocks empire 1") + _ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 2})["ok"], "ANTI_EMPIRE_A ok for empire 2") + _ck(EquipRules.can_equip(no_emp_a, -1, {"empire": 0})["ok"], "no empire -> anti empire skipped") + +func _test_wearable() -> void: + var res := EquipRules.can_equip(_weapon(), EquipRules.WEAR_BODY, {"race": 0}) + _ck(not res["ok"] and res["code"] == "NOT_WEARABLE_HERE", "weapon at WEAR_BODY -> NOT_WEARABLE_HERE") + _ck(EquipRules.can_equip(_weapon(), EquipRules.WEAR_WEAPON, {"race": 0})["ok"], "weapon at WEAR_WEAPON ok") + +func _test_limits() -> void: + var lv30 := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 30}]}) + var r := EquipRules.can_equip(lv30, -1, {"race": 0, "level": 20}) + _ck(not r["ok"] and r["code"] == "LIMIT_LEVEL" and int(r["need"]) == 30, "level 20 < 30 -> LIMIT_LEVEL need 30") + _ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 30})["ok"], "level 30 == 30 ok") + _ck(EquipRules.can_equip(lv30, -1, {"race": 0, "level": 99})["ok"], "level 99 ok") + + var str50 := _weapon({"limits": [{"type": EquipRules.LIMIT_STR, "value": 50}]}) + _ck(EquipRules.can_equip(str50, -1, {"race": 0})["ok"], "STR limit skipped when ctx has no st (-1)") + _ck(not EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["ok"], "st 40 < 50 -> fail") + _ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 50})["ok"], "st 50 ok") + _ck(EquipRules.can_equip(str50, -1, {"race": 0, "st": 40})["code"] == "LIMIT_STR", "code LIMIT_STR") + + var con20 := _weapon({"limits": [{"type": EquipRules.LIMIT_CON, "value": 20}]}) + _ck(not EquipRules.can_equip(con20, -1, {"race": 0, "ht": 10})["ok"], "ht 10 < 20 -> LIMIT_CON") + + # value <= 0 的 limit 项被忽略 + var zero := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 0}]}) + _ck(EquipRules.can_equip(zero, -1, {"race": 0, "level": 1})["ok"], "limit value 0 ignored") + +func _test_order_and_newslots() -> void: + # anti-flag 先于 limit:两个都失败 → 返回 ANTI_SEX + var both := _weapon({"anti_flags": EquipRules.ANTIFLAG_FEMALE, + "limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) + _ck(EquipRules.can_equip(both, -1, {"race": 1, "level": 1})["code"] == "ANTI_SEX", + "antiflag checked before limit") + + # wearable 先于 limit + var wl := _weapon({"limits": [{"type": EquipRules.LIMIT_LEVEL, "value": 99}]}) + _ck(EquipRules.can_equip(wl, EquipRules.WEAR_BODY, {"race": 0, "level": 1})["code"] == "NOT_WEARABLE_HERE", + "wearable checked before limit") + + # 新装备槽:ring / belt / costume 按 type 判定,无 wear_flags 也能穿 + _ck(EquipRules.can_equip({"type": 33}, EquipRules.WEAR_RING1, {"race": 0})["ok"], "ring at RING1 ok") + _ck(EquipRules.can_equip({"type": 34}, EquipRules.WEAR_BELT, {"race": 0})["ok"], "belt at BELT ok") + _ck(EquipRules.can_equip({"type": 28, "sub_type": 0}, -1, {"race": 0})["ok"], "costume body default slot ok") + _ck(EquipRules.can_equip({"type": 28}, EquipRules.WEAR_BELT, {"race": 0})["code"] == "NOT_WEARABLE_HERE", + "costume at BELT rejected") + + # 空物品 + _ck(EquipRules.can_equip({}, -1, {})["code"] == "NO_ITEM", "empty item -> NO_ITEM") diff --git a/project/eterngrn_polish_test.gd b/project/eterngrn_polish_test.gd index b08d1904..31de27c1 100644 --- a/project/eterngrn_polish_test.gd +++ b/project/eterngrn_polish_test.gd @@ -126,11 +126,14 @@ func shoot(skill): shots.append(skill); return true await process_frame _ck(gs.ui.get_node_or_null("ColorRect") != null or gs._flash_rect != null, "motion_event: type 3 → 屏闪 ColorRect") - gs._pending_shoot_skill = 77 + gs._queue_shot(77) + gs._queue_shot(78) gs._on_local_motion_event(6, "", "", Vector3.ZERO) - _ck(fake_client.shots == [77], "motion_event: FLY → sends pending CG_SHOOT once") + _ck(fake_client.shots == [77], "motion_event: FLY → FIFO pop 一发 CG_SHOOT") gs._on_local_motion_event(6, "", "", Vector3.ZERO) - _ck(fake_client.shots == [77], "motion_event: FLY clears pending shoot") + _ck(fake_client.shots == [77, 78], "motion_event: FLY 连射按序 pop(AutoClear)") + gs._on_local_motion_event(6, "", "", Vector3.ZERO) + _ck(fake_client.shots == [77, 78], "motion_event: FLY 队列空 → 不再发") gs.queue_free() diff --git a/project/fishing_water_test.gd b/project/fishing_water_test.gd new file mode 100644 index 00000000..f42bca47 --- /dev/null +++ b/project/fishing_water_test.gd @@ -0,0 +1,60 @@ +# fishing_water_test —— ClientVS22 GetFishingRot / ATTRIBUTE_WATER 回归。 +# godot --headless --path project --script fishing_water_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 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)) + # A1(4x5) 的水面不保证位于 focus_tile=(0,0),加载完整地图后 + # 自动寻找 ATTRIBUTE_WATER 样本,测试不绑定某个无水区块。 + world.set("load_radius_tiles", -1) + world.set("splat_enabled", false) + world.set("objects_enabled", false) + world.set("env_enabled", false) + world.set("water_enabled", true) + get_root().add_child(world) + _ck(bool(world.call("load_map")), "A1 map loads for water probe") + + var water := Vector3(-1.0, 0.0, -1.0) + for z in range(5 * 256): + for x in range(4 * 256): + if (int(world.call("sample_attribute", float(x) + 0.5, float(z) + 0.5)) & 2) != 0: + water = Vector3(float(x) + 0.5, 0.0, float(z) + 0.5) + break + if water.x >= 0.0: + break + _ck(water.x >= 0.0, "A1 has an ATTRIBUTE_WATER sample in loaded chunk") + if water.x >= 0.0: + # The POC MapCoord frame maps heading 270 to world +Z. Put the + # player 600cm behind the sample and require the zero-offset probe + # to resolve a valid fishing heading. + var player := Vector3(water.x, 0.0, water.z - 6.0) + var resolved := float(world.call("get_fishing_rotation", player.x, player.z, 270.0)) + _ck(resolved >= 0.0 and resolved < 360.0, + "GetFishingRot resolves a heading over water, got %f" % resolved) + var can := bool(world.call("can_fishing_position", player.x, player.z, 270.0)) + _ck(can, "can_fishing_position agrees with GetFishingRot") + var outside := float(world.call("get_fishing_rotation", -50.0, -50.0, 0.0)) + _ck(outside < 0.0, "map-outside water probe rejects") + world.queue_free() + await process_frame + if _fail == 0: + print("PASS: fishing_water_test (GetFishingRot + ATTRIBUTE_WATER)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) diff --git a/project/fly_object.gd b/project/fly_object.gd new file mode 100644 index 00000000..d347e59e --- /dev/null +++ b/project/fly_object.gd @@ -0,0 +1,314 @@ +# fly_object.gd —— 1:1 复刻 GameLib 的飞行物实体系统(CFlyingManager / CFlyingInstance / +# CFlyingData),把原客户端「.msa FLY 帧 → OnShoot → CG_SHOOT」之后、由服务端 GC_CREATE_FLY +# 驱动的那颗真实弹道 + 命中判定搬到 POC。net_world.gd 收到 client.fly_cue 后调 +# FlyManager.spawn(),每帧 step() 推进运动学并做「线段到点」的爆炸判定,命中 / 超程 / 触地 +# 时回调 handler(对齐 IFlyEventHandler)。§3.6 +# +# 参考: +# REF/GameLib/FlyingObjectManager.cpp CFlyingManager::CreateFlyingInstanceFlyTarget / +# Update()(遍历实例,Update() 返 false 即 Delete + erase)/ CreateIndexedFly(NORMAL / +# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行) +# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointer(m_v3Position=起点、 +# m_bAlive、m_fStartTime、由 m_fRollAngle-90 yaw + m_fConeAngle roll 建四元数、可选 +# m_bSpreading 随机扩散、m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0)、m_v3Accel= +# pData->m_v3Accel、m_fRemainRange=m_fRange、m_iPierceCount)+ __SetTargetDirection +# (v3TargetPos=target.GetFlyTargetPosition();m_bMaintainParallel 时 z+=50;方向= +# normalize(target-pos),把局部速度 / 加速度旋到该方向) +# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Update():homing → AdjustDirectionForHoming; +# v3LastPosition=m_v3Position;m_v3Velocity+=m_v3Accel*dt;m_v3Velocity.z+=m_fGravity*dt; +# v3Movement=m_v3Velocity*dt;_fMoveDistance=|v3Movement|;m_fRemainRange-=_fMoveDistance; +# m_v3Position+=v3Movement;m_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false); +# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) < +# m_fBombRange² → m_bTargetHitted=TRUE;m_canAttack 时 pVictim->OnShootDamage(); +# handler->OnExplodingAtTarget(skill);m_iPierceCount>0 → --、__Bomb();否则 __Explode(); +# 位置目标同样线段判定 → __Explode();m_bHitOnBackground → GetTerrainHeight(x,-y)>z → 触地 +# REF/GameLib/FlyingInstance.cpp __Explode(bBomb=true):!m_bAlive return;m_bAlive=false; +# bBomb → __Bomb()(在 m_v3Position 生成 m_dwBombEffectID 特效) +# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(点到线段的平方距离, +# d<=0 取端点 p1、d>=l 取端点 p2、否则叉积模方 / l) +# REF/GameLib/FlyingData.cpp CFlyingData::__Initialize()(本文件 FlyData 默认值逐项对齐) +# REF/GameLib/FlyHandler.h IFlyEventHandler:OnExplodingOutOfRange / OnExplodingAtBackground / +# OnExplodingAtAnotherTarget(skill,vid) / OnExplodingAtTarget(skill) +extends Node + +# CFlyingManager::EIndexFlyType(REF/GameLib/FlyingObjectManager.h) +const INDEX_FLY_TYPE_NORMAL := 0 +const INDEX_FLY_TYPE_FIRE_CRACKER := 1 +const INDEX_FLY_TYPE_AUTO_FIRE := 2 + +# CFlyingData —— .fly 脚本字段。POC 无 .fly 加载器,默认值逐项对齐 CFlyingData::__Initialize() +# (长度单位:原版是 pixel==cm,这里换算成米后传进来,故 init_vel / range / bomb_range 都按米)。 +class FlyData extends RefCounted: + var init_vel := 2.0 # m_fInitVel=200(cm/s)→ 2 m/s(占位;net_world 用弓箭值覆盖) + var cone_angle := 0.0 # m_fConeAngle + var roll_angle := 0.0 # m_fRollAngle + var gravity := 0.0 # m_fGravity(原版加到速度 .z(=up),本实现映射到 -Y = 向下) + var accel := Vector3.ZERO # m_v3Accel + var flat_range := 5.0 # m_fRange=500(cm)→ 5 m + var bomb_range := 0.1 # m_fBombRange=10(cm)→ 0.1 m + var pierce_count := 0 # m_iPierceCount + var spreading := false # m_bSpreading + var maintain_parallel := false # m_bMaintainParallel(对象目标点 y 抬 0.5 m ≈ 原版 +50cm) + var is_homing := false # m_bIsHoming + var homing_start_time := 0.0 # m_fHomingStartTime + var homing_max_angle := 0.0 # m_fHomingMaxAngle(度 / 每次修正) + var hit_on_background := false # m_bHitOnBackground + +# 单颗飞行物 —— 对齐 CFlyingInstance。target 可为 Node3D(对象目标,跟随移动)、 +# Vector3(位置目标)、或 { "vid": int, "pos": Vector3 }。 +class FlyInstance extends RefCounted: + var id := 0 + var data: FlyData + var pos := Vector3.ZERO + var last_pos := Vector3.ZERO + var vel := Vector3.ZERO + var accel := Vector3.ZERO + var remain_range := 0.0 + var pierce := 0 + var alive := false + var can_attack := false + var skill_index := 0 + var target_vid := 0 + var target_hitted := false + var _target_obj: Node3D + var _target_pos := Vector3.ZERO + var _is_object := false + var _elapsed := 0.0 + var _world: Object # 可空:有 sample_height 时做触地 + # handler.call(event: String, world_pos: Vector3, vid: int) —— 对齐 IFlyEventHandler + var handler := Callable() + + func target_position() -> Vector3: + if _is_object and is_instance_valid(_target_obj): + _target_pos = _target_obj.global_position + var tp := _target_pos + if data and data.maintain_parallel: + tp.y += 0.5 + return tp + + # CFlyingInstance::Create → __SetDataPointer + __SetTargetDirection。 + func create(d: FlyData, start_world: Vector3, target, attack: bool) -> void: + data = d + can_attack = attack + pos = start_world + last_pos = start_world + alive = true + _elapsed = 0.0 + target_hitted = false + pierce = d.pierce_count + remain_range = d.flat_range + if target is Node3D: + _is_object = true + _target_obj = target + _target_pos = (target as Node3D).global_position + elif target is Vector3: + _is_object = false + _target_pos = target + elif target is Dictionary: + target_vid = int(target.get("vid", 0)) + var tp: Variant = target.get("pos", null) + if tp is Vector3: + _target_pos = tp + var tn: Variant = target.get("node", null) + if tn is Node3D: + _is_object = true + _target_obj = tn + _target_pos = (tn as Node3D).global_position + # __SetTargetDirection:方向 = normalize(target - pos),局部速度 (0,-init_vel,0) + # 旋到该方向即 dir*init_vel;加速度同样旋到该方向。 + var dir := (target_position() - pos) + if dir.length() < 0.0001: + dir = Vector3(0, 0, -1) + dir = dir.normalized() + vel = dir * d.init_vel + accel = d.accel + if accel.length_squared() > 0.0: + # 局部帧 -Y → dir 的最小弧旋转(对齐 __SetTargetNormalizedDirection)。 + accel = Quaternion(Vector3(0, -1, 0), dir).normalized() * accel + + # CFlyingInstance::AdjustDirectionForHoming —— 把速度朝目标方向转,单次夹在 + # homing_max_angle 度以内。 + func _adjust_homing() -> void: + var tdir := (target_position() - pos) + if tdir.length() < 0.0001: + return + tdir = tdir.normalized() + var vdir := vel.normalized() + var ang := vdir.angle_to(tdir) + if ang < 0.0001: + return + var maxr := deg_to_rad(data.homing_max_angle) if data.homing_max_angle < 180.0 else ang + var t := clampf(maxr / ang, 0.0, 1.0) + var speed := vel.length() + vel = vdir.slerp(tdir, t) * speed + + # CFlyingInstance::Update() —— 返 false 表示该销毁(超程 / 命中且不穿透 / 触地)。 + func update(dt: float) -> bool: + if not alive: + return false + _elapsed += dt + if data.is_homing and _elapsed > data.homing_start_time and _is_object: + _adjust_homing() + last_pos = pos + vel += accel * dt + vel.y += data.gravity * dt + var movement := vel * dt + var move_dist := movement.length() + remain_range -= move_dist + pos += movement + + if remain_range < 0.0: + _emit("out_of_range") + _explode(false) + return false + + var br2 := data.bomb_range * data.bomb_range + if _is_object: + if not target_hitted: + if _seg_point_sq_dist(pos, last_pos, target_position()) < br2: + target_hitted = true + if can_attack: + _emit("shoot_damage") + _emit("at_target") + if pierce > 0: + pierce -= 1 + _emit("bomb") + else: + _explode() + return false + return true + else: + if _seg_point_sq_dist(pos, last_pos, target_position()) < br2: + _emit("at_position") + _explode() + return false + + if data.hit_on_background and _world and _world.has_method("sample_height"): + var gh := float(_world.call("sample_height", pos.x, pos.z)) + if gh > pos.y: + _emit("at_background") + _explode() + return false + return true + + func _explode(bomb := true) -> void: + if not alive: + return + alive = false + if bomb: + _emit("bomb") + + func _emit(ev: String) -> void: + if handler.is_valid(): + handler.call(ev, pos, target_vid) + + # REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point + static func _seg_point_sq_dist(p1: Vector3, p2: Vector3, x: Vector3) -> float: + var v := p2 - p1 + var l := v.length_squared() + var w := x - p1 + var d := w.dot(v) + if d <= 0.0: + return w.length_squared() + if d >= l: + return (x - p2).length_squared() + return w.cross(v).length_squared() / l + +var parent: Node3D # 弹道 / 爆点可视节点挂这下面(可空 = 纯逻辑,仅回调) +var world: Object # Metin2World(可空,用于 hit_on_background 贴地) +var manual_step := false # true 时 _process 不自动推进(headless 测试用 step() 手动驱动) +var _instances: Array = [] # Array[FlyInstance] +var _visuals := {} # FlyInstance -> MeshInstance3D +var _id_counter := 1 + +signal shoot_damage(target_vid: int) +signal exploded(world_pos: Vector3, cause: String) + +func setup(mount: Node3D, w: Object = null) -> void: + parent = mount + world = w + +# CFlyingManager::CreateFlyingInstanceFlyTarget —— 建一颗飞行物并入表。 +func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null, + skill_index := 0) -> FlyInstance: + var d := data if data != null else FlyData.new() + var inst := FlyInstance.new() + inst.id = _id_counter + _id_counter += 1 + inst.skill_index = skill_index + inst._world = world + inst.handler = func(ev: String, wp: Vector3, vid: int) -> void: + _on_instance_event(inst, ev, wp, vid) + inst.create(d, start_world, target, can_attack) + _instances.append(inst) + if parent != null: + var m := MeshInstance3D.new() + var sm := SphereMesh.new() + sm.radius = 0.08 + sm.height = 0.16 + m.mesh = sm + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(1.0, 0.9, 0.4) + mat.emission_enabled = true + mat.emission = Color(0.9, 0.7, 0.2) + m.material_override = mat + parent.add_child(m) + m.global_position = inst.pos + _visuals[inst] = m + return inst + +func active_count() -> int: + return _instances.size() + +# CFlyingManager::Update() —— 遍历实例,Update() 返 false 即删除。 +func step(dt: float) -> void: + var i := 0 + while i < _instances.size(): + var inst: FlyInstance = _instances[i] + var keep := inst.update(dt) + var vis: MeshInstance3D = _visuals.get(inst, null) + if is_instance_valid(vis): + vis.global_position = inst.pos + if not keep: + if is_instance_valid(vis): + vis.queue_free() + _visuals.erase(inst) + _instances.remove_at(i) + else: + i += 1 + +func _process(dt: float) -> void: + if not manual_step: + step(dt) + +func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) -> void: + match ev: + "shoot_damage": + shoot_damage.emit(vid) + "out_of_range", "at_target", "at_position", "at_background", "at_another": + exploded.emit(wp, ev) + if parent != null and ev != "out_of_range": + _spawn_flash(wp) + "bomb": + if parent != null: + _spawn_flash(wp) + +func _spawn_flash(wp: Vector3) -> void: + var f := MeshInstance3D.new() + var sm := SphereMesh.new() + sm.radius = 0.05 + sm.height = 0.1 + f.mesh = sm + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(1.0, 0.75, 0.3, 0.9) + mat.emission_enabled = true + mat.emission = Color(1.0, 0.6, 0.2) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + f.material_override = mat + parent.add_child(f) + f.global_position = wp + var tw := create_tween() + tw.set_parallel(true) + tw.tween_property(f, "scale", Vector3.ONE * 6.0, 0.25) + tw.tween_property(mat, "albedo_color:a", 0.0, 0.25) + tw.chain().tween_callback(f.queue_free) diff --git a/project/fly_test.gd b/project/fly_test.gd new file mode 100644 index 00000000..51f299e3 --- /dev/null +++ b/project/fly_test.gd @@ -0,0 +1,241 @@ +# fly_test —— §3.6 飞行物实体(fly_object.gd / FlyManager / FlyInstance)+ net_play._can_shot() +# 的 headless 自检。 +# godot --headless --path project --script fly_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const FlyObject = preload("res://fly_object.gd") +const NetPlay = preload("res://net_play.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: fly_test (§3.6 CFlyingManager/CFlyingInstance + __CanShot)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _mk_mgr() -> Node: + var m := FlyObject.new() + m.manual_step = true + root.add_child(m) + return m + +func _node_at(p: Vector3) -> Node3D: + var n := Node3D.new() + root.add_child(n) + n.global_position = p + return n + +func _drain(mgr: Node, steps: int, dt: float) -> void: + for i in steps: + if mgr.active_count() == 0: + return + mgr.step(dt) + +func _run() -> void: + # SceneTree._init() 跑得比 root 就绪早;等一帧再建 Node3D,避免 !is_inside_tree()。 + await process_frame + _test_hit_object() + _test_out_of_range() + _test_can_attack_damage() + _test_pierce() + _test_position_target() + _test_hit_background() + _test_can_shot() + _test_seg_point_dist() + +# 直线箭命中对象目标 —— CFlyingInstance::Update 的线段-点判定 < m_fBombRange²。 +func _test_hit_object() -> void: + var mgr := _mk_mgr() + var tgt := _node_at(Vector3(10, 1, 0)) + var events: Array = [] + mgr.exploded.connect(func(wp: Vector3, cause: String): events.append([cause, wp])) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 30.0 + d.bomb_range = 0.6 + mgr.spawn(Vector3(0, 1, 0), tgt, false, d) + _ck(mgr.active_count() == 1, "hit_object: instance registered") + _drain(mgr, 60, 0.05) + _ck(events.size() == 1 and events[0][0] == "at_target", "hit_object: exploded at_target once") + if events.size() == 1: + _ck((events[0][1] as Vector3).distance_to(Vector3(10, 1, 0)) < 2.0, + "hit_object: explosion near the target") + _ck(mgr.active_count() == 0, "hit_object: instance removed after non-pierce hit") + mgr.free() + tgt.free() + +# 超程 —— m_fRemainRange < 0 → OnExplodingOutOfRange + __Explode(false)(不 __Bomb)。 +func _test_out_of_range() -> void: + var mgr := _mk_mgr() + var tgt := _node_at(Vector3(100, 1, 0)) + var causes: Array = [] + var dmg := [0] + mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) + mgr.shoot_damage.connect(func(_v: int): dmg[0] += 1) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 5.0 + d.bomb_range = 0.6 + mgr.spawn(Vector3(0, 1, 0), tgt, true, d) + _drain(mgr, 40, 0.05) + _ck(causes == ["out_of_range"], "out_of_range: exploded out_of_range, no target hit") + _ck(dmg[0] == 0, "out_of_range: no shoot_damage") + _ck(mgr.active_count() == 0, "out_of_range: instance removed") + mgr.free() + tgt.free() + +# canAttack=true 且对象目标 → OnShootDamage(本 POC 转成 shoot_damage(vid) 信号)。 +func _test_can_attack_damage() -> void: + var mgr := _mk_mgr() + var tgt := _node_at(Vector3(5, 1, 0)) + var hits: Array = [] + mgr.shoot_damage.connect(func(v: int): hits.append(v)) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 20.0 + d.bomb_range = 0.6 + mgr.spawn(Vector3(0, 1, 0), {"vid": 4242, "node": tgt}, true, d) + _drain(mgr, 40, 0.05) + _ck(hits == [4242], "can_attack: shoot_damage(4242) exactly once") + mgr.free() + tgt.free() + +# 穿透 —— m_iPierceCount>0 时命中后 --、__Bomb()、继续飞(Update 返 true),最终超程。 +func _test_pierce() -> void: + var mgr := _mk_mgr() + var tgt := _node_at(Vector3(6, 1, 0)) + var causes: Array = [] + mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 15.0 + d.bomb_range = 0.6 + d.pierce_count = 1 + mgr.spawn(Vector3(0, 1, 0), tgt, false, d) + # 命中前几步:命中那一步实例仍存活(pierce 消耗),不立即移除 + var seen_alive_after_hit := false + for i in 60: + mgr.step(0.05) + if causes.size() == 1 and causes[0] == "at_target" and mgr.active_count() == 1: + seen_alive_after_hit = true + if mgr.active_count() == 0: + break + _ck(seen_alive_after_hit, "pierce: instance survives the first hit") + _ck(causes == ["at_target", "out_of_range"], "pierce: at_target then out_of_range") + mgr.free() + tgt.free() + +# 位置目标 —— IsPosition 分支同样线段判定 → __Explode()。 +func _test_position_target() -> void: + var mgr := _mk_mgr() + var causes: Array = [] + mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 30.0 + d.bomb_range = 0.6 + mgr.spawn(Vector3(0, 1, 0), Vector3(8, 1, 0), false, d) + _drain(mgr, 40, 0.05) + _ck(causes == ["at_position"], "position_target: exploded at_position") + mgr.free() + +# hit_on_background —— GetTerrainHeight(x,-y) > z(本 POC:world.sample_height(x,z) > pos.y)。 +func _test_hit_background() -> void: + var mgr := _mk_mgr() + var world := _FakeWorld.new() + world.h = 0.9 + mgr.world = world + var causes: Array = [] + mgr.exploded.connect(func(_wp: Vector3, cause: String): causes.append(cause)) + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 + d.flat_range = 60.0 + d.bomb_range = 0.05 + d.gravity = -20.0 # 向下拉,几步内 y 跌破地面高度 + d.hit_on_background = true + mgr.spawn(Vector3(0, 1, 0), Vector3(40, 1, 0), false, d) + _drain(mgr, 40, 0.05) + _ck(causes.size() == 1 and causes[0] == "at_background", "hit_background: exploded at_background") + _ck(mgr.active_count() == 0, "hit_background: instance removed") + mgr.free() + +# net_play._can_shot() —— __CanShot 防御式重建(弓模式 && 有箭 && 有可攻击目标 && 在弓程内)。 +func _test_can_shot() -> void: + var fc := _FakeClient.new() + get_root().add_child(fc) + var np: Node = NetPlay.new() + get_root().add_child(np) + np.client = fc + np.net_world = null + np._main_vid = 1 + fc.ents[1] = {"vid": 1, "pos": Vector3.ZERO, "hp": 100, "max_hp": 100} + + np.combo_motion_mode = np.MOTION_MODE_GENERAL + _ck(np._can_shot() == "NOT_BOW_MODE", "can_shot: non-bow -> NOT_BOW_MODE") + + np.combo_motion_mode = np.MOTION_MODE_BOW + np._arrow_count = 0 + _ck(np._can_shot() == "EMPTY_ARROW", "can_shot: bow + 0 arrows -> EMPTY_ARROW") + + np._arrow_count = 5 + np._target_vid = 0 + np._vid_reserved = 0 + _ck(np._can_shot() == "NO_TARGET", "can_shot: bow + arrows + no target -> NO_TARGET") + + np._arrow_count = -1 # 未跟踪 = 无限 + fc.ents[900] = {"vid": 900, "ch_type": 2, "pos": Vector3(5, 0, 0), "hp": 30, "max_hp": 30, "dead": false} + np._target_vid = 900 + _ck(np._can_shot() == "", "can_shot: bow + target in range -> OK") + + fc.ents[901] = {"vid": 901, "ch_type": 2, "pos": Vector3(400, 0, 0), "hp": 30, "max_hp": 30, "dead": false} + np._target_vid = 901 + _ck(np._can_shot() == "OUT_OF_RANGE", "can_shot: target beyond __GetBowRange -> OUT_OF_RANGE") + + fc.ents[902] = {"vid": 902, "ch_type": 2, "pos": Vector3(3, 0, 0), "hp": 0, "max_hp": 30, "dead": true} + np._target_vid = 902 + _ck(np._can_shot() == "NO_TARGET", "can_shot: dead target -> NO_TARGET") + + np.free() + fc.free() + +func _test_seg_point_dist() -> void: + var f = FlyObject.FlyInstance + # 垂足在段内:点到直线距离 + _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(5, 3, 0)), 9.0), + "seg_dist: foot inside segment -> perpendicular²") + # 垂足在 p1 外:取端点 p1 + _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(-4, 0, 0)), 16.0), + "seg_dist: past p1 -> |x-p1|²") + # 垂足在 p2 外:取端点 p2 + _ck(is_equal_approx(f._seg_point_sq_dist(Vector3(0, 0, 0), Vector3(10, 0, 0), Vector3(13, 4, 0)), 25.0), + "seg_dist: past p2 -> |x-p2|²") + +class _FakeWorld extends RefCounted: + var h := 0.0 + func sample_height(_x: float, _z: float) -> float: + return h + +class _FakeClient extends Node: + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_main_set(vid: int) + signal entity_dead(vid: int) + signal vitals_changed(vid: int) + signal points_changed(points: Dictionary) + signal chat(type: int, vid: int, text: String) + signal damage(vid: int, amount: int, flag: int) + var ents := {} + func is_in_game() -> bool: return true + func get_main_vid() -> int: return 1 + func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) + func get_entities() -> Array: return ents.values() diff --git a/project/fx/effect_player.gd b/project/fx/effect_player.gd index 4ac05128..f877edee 100644 --- a/project/fx/effect_player.gd +++ b/project/fx/effect_player.gd @@ -6,15 +6,23 @@ # fx.play() # 或 fx.play(true) 一次性 # # 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 / -# billboard / 加法混合 / 旋转。纹理(.dds)暂用程序化径向渐变代替(GDScript 不解 DDS)。 -# Mesh 组:占位(MeshFileName 是 .mde,另需解码器)。SimpleLight:本资产集未用。 +# billboard / 加法混合 / 旋转;粒子 TextureFiles 通过 UiAssets + C++ DDS 解码加载。 +# Mesh 组:读取 ClientVS22 的 .mde v1/v2、展开 TriangleList、播放几何帧与 IFL 贴图。 +# SimpleLight:按参考的范围曲线 / 生命周期创建 OmniLight3D;现有资产集仍缺少逐项视觉样本。 extends Node3D +const UiAssets = preload("res://ui/ui_assets.gd") +const Mde = preload("res://fx/mde.gd") + var spec := {} var assets_root := "" var one_shot := false var _emitters: Array[GPUParticles3D] = [] +var _particle_states: Array[Dictionary] = [] var _mesh_nodes: Array[Node3D] = [] +var _mesh_states: Array[Dictionary] = [] +var _lights: Array[OmniLight3D] = [] +var _light_states: Array[Dictionary] = [] var _bsphere_r := 0.0 static var _glow_tex: Texture2D @@ -33,14 +41,32 @@ func build(mse_spec: Dictionary, assets := "") -> void: if mn: add_child(mn) _mesh_nodes.append(mn) + for l in spec.get("lights", []): + var light := _build_light(l) + if light: + add_child(light) + _lights.append(light) + +func _process(delta: float) -> void: + for state in _particle_states: + _advance_particle(state, delta) + for state in _mesh_states: + _advance_mesh(state, delta) + for state in _light_states: + _advance_light(state, delta) func play(force_one_shot := false) -> void: + # EffectPlayer can be reused by the registry. Reset the CPU-side clocks as + # well as the GPU emitters so cycle gates do not inherit the previous play. + for state in _particle_states: + state["clock"] = 0.0 + state["emission_stopped"] = false for i in _emitters.size(): var e := _emitters[i] var st := float(_emitters[i].get_meta("start_time", 0.0)) if force_one_shot: e.one_shot = true - var tree := get_tree() + var tree: SceneTree = get_tree() if is_inside_tree() else null if st <= 0.0 or tree == null: e.restart() e.emitting = true @@ -49,7 +75,7 @@ func play(force_one_shot := false) -> void: if is_instance_valid(e): e.restart() e.emitting = true) - var tr := get_tree() + var tr: SceneTree = get_tree() if is_inside_tree() else null if (force_one_shot or one_shot) and tr: var total := _longest_life() + 0.5 tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free) @@ -57,11 +83,24 @@ func play(force_one_shot := false) -> void: func stop() -> void: for e in _emitters: e.emitting = false + for light in _lights: + light.visible = false func _longest_life() -> float: var m := 1.0 for e in _emitters: - m = maxf(m, e.lifetime + float(e.get_meta("start_time", 0.0))) + var start := float(e.get_meta("start_time", 0.0)) + var cycle := float(e.get_meta("cycle_length", 0.0)) + var loop := bool(e.get_meta("cycle_loop", false)) + var loop_count := int(e.get_meta("loop_count", 0)) + var emission_window := 0.0 + if cycle > 0.0 and (not loop or loop_count > 0): + emission_window = cycle * (loop_count if loop else 1) + m = maxf(m, start + emission_window + e.lifetime) + for state in _mesh_states: + m = maxf(m, float(state.get("duration", 0.0))) + for state in _light_states: + m = maxf(m, float(state.get("duration", 0.0))) return m # --- particle --------------------------------------------------------- @@ -71,31 +110,68 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: var prop: Dictionary = p.get("particle", {}) var g := GPUParticles3D.new() g.set_meta("start_time", float(p.get("start_time", 0.0))) + g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0)))) + g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0) + var positions: Array = p.get("position", []) + if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: + # EffectLib stores effect coordinates in centimetres; the Godot scene is + # metres. The position curve is evaluated again as the effect advances. + g.position = Vector3(float(positions[0][2]), float(positions[0][3]), + float(positions[0][4])) * 0.01 g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16)))) g.explosiveness = 0.0 var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0) g.lifetime = clampf(life, 0.05, 12.0) - var cycle := _n(emit.get("CycleLength", 0.0)) + # ParticleSystemData.cpp supplies 0.05 when CycleLength is absent. Keep an + # explicit zero distinct: it means the source does not request a cycle gate. + var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05 var loop := int(_n(emit.get("CycleLoopEnable", 0))) + var loop_count := int(_n(emit.get("LoopCount", 0))) g.one_shot = (loop == 0) - if cycle > 0.0 and loop != 0: - g.lifetime = maxf(g.lifetime, cycle) + g.set_meta("cycle_length", maxf(cycle, 0.0)) + g.set_meta("cycle_loop", loop != 0) + g.set_meta("loop_count", maxi(loop_count, 0)) + g.set_meta("particle_lifetime", g.lifetime) + g.set_meta("emission_stopped", false) var pm := ParticleProcessMaterial.new() # 发射形状 var shape := int(_n(emit.get("EmitterShape", 0))) - if shape == 3: - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE - pm.emission_sphere_radius = maxf(0.01, _n(emit.get("EmittingRadius", 10.0)) * 0.01) - else: - pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT + var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0 + var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01 + var emitting_size := _vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])) * 0.01 + match shape: + 1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation) + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + pm.emission_box_extents = Vector3(maxf(radius, 0.01), maxf(radius, 0.01), 0.005) + 2: # CEmitterProperty::EMITTER_SHAPE_SQUARE + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005), + maxf(absf(emitting_size.y) * 0.5, 0.005), maxf(absf(emitting_size.z) * 0.5, 0.005)) + 3: # CEmitterProperty::EMITTER_SHAPE_SPHERE + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE if emit_from_edge else ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + pm.emission_sphere_radius = maxf(0.01, radius) + _: + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT # 方向 / 速度 var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01 - pm.direction = Vector3(0, 1, 0) - pm.spread = 25.0 + var direction := _vec3(emit.get("EmittingDirection", [0.0, 0.0, 0.0])) + direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0), + _last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0), + _last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0)) + pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0) + pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0 + # The reference computes OUTER/INNER velocity from each particle's radial + # position. ParticleProcessMaterial has no radial-velocity primitive, so keep + # the source mode visible and use a broad directional fallback until the + # particle-shader path is implemented. This avoids silently treating those + # modes as FREE while preserving the normal velocity range and gravity. + var advanced_type := int(g.get_meta("emitter_advanced_type", 0)) + if advanced_type == 1 or advanced_type == 2: + pm.spread = 180.0 pm.initial_velocity_min = vel * 0.6 - pm.initial_velocity_max = maxf(vel, 0.05) + pm.initial_velocity_max = vel # 重力 var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0) pm.gravity = Vector3(0, -grav * 0.01, 0) @@ -109,9 +185,16 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color) if ramp: pm.color_ramp = ramp - # 旋转 + # 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、 + # 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和 + # 时间表末值,避免把 NONE 误当成恒速旋转。 + var rotation_type := int(_n(prop.get("RotationType", 0))) var rspeed := _n(prop.get("RotationSpeed", 0.0)) - if rspeed != 0.0: + if rotation_type == 1: + rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed) + if rotation_type == 3: + rspeed = -rspeed + if rotation_type != 0 and rspeed != 0.0: pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3 pm.angular_velocity_max = deg_to_rad(rspeed) g.process_material = pm @@ -126,26 +209,407 @@ func _build_particle(p: Dictionary) -> GPUParticles3D: mat.billboard_keep_scale = true mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2)))) - mat.albedo_texture = _glow() + var tex := _particle_texture(prop) + mat.albedo_texture = tex if tex else _glow() mat.vertex_color_use_as_albedo = true g.material_override = mat + _particle_states.append({ + "node": g, + "rows": positions, + "clock": 0.0, + "start_time": float(p.get("start_time", 0.0)), + "cycle_length": maxf(cycle, 0.0), + "cycle_loop": loop != 0, + "loop_count": maxi(loop_count, 0), + "emission_stopped": false, + }) return g +func _advance_particle(state: Dictionary, delta: float) -> void: + var g: GPUParticles3D = state.get("node") + if g == null or not is_instance_valid(g): + return + var rows: Array = state.get("rows", []) + if rows.is_empty(): + return + var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) + state["clock"] = clock + var start := float(state.get("start_time", 0.0)) + if clock < start: + g.emitting = false + return + var local := clock - start + var cycle := float(state.get("cycle_length", 0.0)) + var loop := bool(state.get("cycle_loop", false)) + var loop_count := int(state.get("loop_count", 0)) + var emission_finished := cycle > 0.0 and ((not loop and local >= cycle) or + (loop and loop_count > 0 and local >= cycle * loop_count)) + if emission_finished: + g.emitting = false + state["emission_stopped"] = true + return + if not bool(state.get("emission_stopped", false)): + g.position = _position_at(rows, local) + func _build_mesh(m: Dictionary) -> Node3D: - # .mde 需要专门解码器;先占位一个小 box,位置对 + var mde_path := _resolve_mde(String(m.get("mesh_file", ""))) + if mde_path == "": + return null + var mesh_data := Mde.new().parse_file(mde_path) + if mesh_data.is_empty(): + return null var n := Node3D.new() n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename() - var mi := MeshInstance3D.new() - var bm := BoxMesh.new() - bm.size = Vector3(0.1, 0.1, 0.4) - mi.mesh = bm - var mat := StandardMaterial3D.new() - mat.albedo_color = Color(1, 0.9, 0.5, 0.6) - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mi.material_override = mat - n.add_child(mi) + var positions: Array = m.get("position", []) + if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: + n.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01 + + var geometries: Array = mesh_data.get("geometries", []) + var elements: Array = m.get("elements", []) + var states: Array[Dictionary] = [] + for geometry_index in geometries.size(): + var geometry: Dictionary = geometries[geometry_index] + var frames: Array = geometry.get("frames", []) + if frames.is_empty(): + continue + var meshes: Array = [] + for frame in frames: + var array_mesh := _array_mesh(frame) + meshes.append(array_mesh) + var first_mesh: ArrayMesh = null + for candidate in meshes: + if candidate is ArrayMesh: + first_mesh = candidate + break + if first_mesh == null: + continue + var mi := MeshInstance3D.new() + mi.name = String(geometry.get("name", "geometry_%d" % geometry_index)) + mi.mesh = first_mesh + var element: Dictionary = elements[geometry_index] if geometry_index < elements.size() else {} + var textures := _load_mesh_textures(mde_path, String(geometry.get("diffuse", ""))) + mi.material_override = _mesh_material(element, textures) + n.add_child(mi) + states.append({ + "node": mi, + "frames": frames, + "meshes": meshes, + "element": element, + "textures": textures, + "frame": -1, + "texture_frame": -1, + }) + + var frame_delay := maxf(float(m.get("frame_delay", 0.02)), 0.0001) + var frame_count := int(mesh_data.get("frame_count", 0)) + var loop_count := int(m.get("loop_count", 0)) + var loops := loop_count if loop_count > 0 else 1 + var duration := float(m.get("start_time", 0.0)) + frame_count * frame_delay * loops + var state := { + "node": n, + "children": states, + "position": positions, + "clock": 0.0, + "start_time": float(m.get("start_time", 0.0)), + "frame_delay": frame_delay, + "loop": int(m.get("loop", 0)) != 0, + "loop_count": loop_count, + "duration": maxf(duration, frame_delay), + } + _mesh_states.append(state) + n.set_meta("mde_path", mde_path) + n.set_meta("mde_version", int(mesh_data.get("version", 0))) + n.set_meta("mesh_states", states) + n.visible = false return n +func _build_light(l: Dictionary) -> OmniLight3D: + var light := OmniLight3D.new() + light.name = "simple_light" + var positions: Array = l.get("position", []) + if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5: + light.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01 + var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0]) + if diffuse.size() >= 4: + light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3])) + var attenuation := float(l.get("attenuation1", 0.1)) + float(l.get("attenuation2", 0.0)) + light.omni_attenuation = clampf(1.0 / maxf(1.0, 1.0 + attenuation * 10.0), 0.01, 1.0) + light.visible = false + var duration := maxf(float(l.get("duration", 1.0)), 0.0001) + var loop_count := int(l.get("loop_count", 0)) + var loops := loop_count if loop_count > 0 else 1 + var state := { + "node": light, + "data": l, + "clock": 0.0, + "start_time": float(l.get("start_time", 0.0)), + "duration": float(l.get("start_time", 0.0)) + duration * loops, + "loop": int(l.get("loop", 0)) != 0, + "loop_count": loop_count, + } + _light_states.append(state) + light.set_meta("simple_light", true) + light.set_meta("ambient", l.get("ambient", [])) + light.set_meta("max_range_cm", float(l.get("max_range", 300.0))) + return light + +func _advance_light(state: Dictionary, delta: float) -> void: + var light: OmniLight3D = state.get("node") + if light == null or not is_instance_valid(light): + return + var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) + state["clock"] = clock + var start := float(state.get("start_time", 0.0)) + if clock < start: + light.visible = false + return + var data: Dictionary = state.get("data", {}) + var duration := maxf(float(data.get("duration", 1.0)), 0.0001) + var local := clock - start + var loop := bool(state.get("loop", false)) + var loop_count := int(state.get("loop_count", 0)) + if loop: + if loop_count > 0 and local >= duration * loop_count: + light.visible = false + return + local = fmod(local, duration) + elif local >= duration: + light.visible = false + return + light.visible = true + light.position = _position_at(data.get("position", []), local) + var range_ratio := clampf(_event_value(data.get("range", []), local, 1.0), 0.0, 1.0) + light.omni_range = maxf(0.01, float(data.get("max_range", 300.0)) * range_ratio * 0.01) + var diffuse: Array = data.get("diffuse", [0.0, 0.0, 0.0, 1.0]) + if diffuse.size() >= 4: + var color := light.light_color + color.a = float(diffuse[3]) + light.light_color = color + + +func _advance_mesh(state: Dictionary, delta: float) -> void: + var n: Node3D = state.get("node") + if n == null or not is_instance_valid(n): + return + var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) + state["clock"] = clock + var start := float(state.get("start_time", 0.0)) + if clock < start: + n.visible = false + return + var local := clock - start + n.position = _position_at(state.get("position", []), local) + var children: Array = state.get("children", []) + if children.is_empty(): + return + var frame_delay := maxf(float(state.get("frame_delay", 0.02)), 0.0001) + var frame_count := 0 + for child in children: + frame_count = maxi(frame_count, Array(child.get("frames", [])).size()) + if frame_count < 1: + return + var loop := bool(state.get("loop", false)) + var loop_count := int(state.get("loop_count", 0)) + var frame_number := int(floor(local / frame_delay)) + if loop: + var total_frames := frame_count * (loop_count if loop_count > 0 else 1) + if loop_count > 0 and frame_number >= total_frames: + n.visible = false + return + frame_number = frame_number % frame_count + else: + if frame_number >= frame_count: + n.visible = false + return + n.visible = true + for child in children: + _set_mesh_child_frame(child, frame_number, local) + +func _set_mesh_child_frame(state: Dictionary, frame_number: int, local: float) -> void: + var frames: Array = state.get("frames", []) + var meshes: Array = state.get("meshes", []) + var mi: MeshInstance3D = state.get("node") + if mi == null or frame_number < 0 or frame_number >= frames.size() or frame_number >= meshes.size(): + return + if int(state.get("frame", -1)) != frame_number: + if meshes[frame_number] is ArrayMesh: + mi.mesh = meshes[frame_number] + state["frame"] = frame_number + var frame: Dictionary = frames[frame_number] + var visibility := clampf(float(frame.get("visibility", 1.0)), 0.0, 1.0) + var element: Dictionary = state.get("element", {}) + var alpha := _event_value(element.get("TimeEventAlpha", []), local, 1.0) + var mat := mi.material_override as StandardMaterial3D + if mat: + var color := mat.albedo_color + color.a = clampf(float(element.get("_base_alpha", color.a)) * visibility * alpha, 0.0, 1.0) + mat.albedo_color = color + var textures: Array = state.get("textures", []) + if textures.size() > 0 and mat: + var texture_delay := maxf(float(element.get("TextureAnimationFrameDelay", 0.02)), 0.0001) + var texture_frame := int(element.get("TextureAnimationStartFrame", 0)) + int(floor(local / texture_delay)) + if int(element.get("TextureAnimationLoopEnable", 1)) != 0: + texture_frame = texture_frame % textures.size() + else: + texture_frame = mini(texture_frame, textures.size() - 1) + if int(state.get("texture_frame", -1)) != texture_frame: + mat.albedo_texture = textures[texture_frame] + state["texture_frame"] = texture_frame + +func _array_mesh(frame: Dictionary) -> ArrayMesh: + var vertices: PackedVector3Array = frame.get("vertices", PackedVector3Array()) + var uvs: PackedVector2Array = frame.get("uvs", PackedVector2Array()) + var count := mini(vertices.size(), uvs.size()) + count -= count % 3 + if count < 3: + return null + if count != vertices.size(): + vertices = vertices.slice(0, count) + uvs = uvs.slice(0, count) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_TEX_UV] = uvs + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + +func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0)))) + mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2)))) + var cf = element.get("ColorFactor", [1.0, 1.0, 1.0, 1.0]) + var color := Color(1, 1, 1, 1) + if cf is Array and cf.size() >= 4: + color = Color(float(cf[0]), float(cf[1]), float(cf[2]), float(cf[3])) + element["_base_alpha"] = color.a + mat.albedo_color = color + mat.albedo_texture = textures[0] if textures.size() > 0 else _glow() + return mat + +func _load_mesh_textures(mde_path: String, diffuse: String) -> Array: + var out: Array = [] + if diffuse == "": + return out + var mde_dir := mde_path.get_base_dir() + var diffuse_name := diffuse.replace("\\", "/").get_file() + var diffuse_path := mde_dir.path_join(diffuse_name) + if not FileAccess.file_exists(diffuse_path): + diffuse_path = _resolve_asset_file(diffuse) + if diffuse_path != "" and diffuse_path.get_extension().to_lower() == "ifl": + for line in FileAccess.get_file_as_string(diffuse_path).split("\n"): + var name := line.strip_edges().trim_prefix("\"").trim_suffix("\"") + if name == "" or name.begins_with("#"): + continue + var tex := UiAssets.load_tex(diffuse_path.get_base_dir(), name) + if tex: + out.append(tex) + else: + var tex := UiAssets.load_tex(mde_dir, diffuse_name) + if tex == null: + tex = UiAssets.load_tex(assets_root, diffuse) + if tex: + out.append(tex) + return out + +func _resolve_mde(file_name: String) -> String: + if file_name == "": + return "" + var rel := file_name.replace("\\", "/") + var mse_dir := String(spec.get("dir", "")) + var sibling := mse_dir.path_join(rel.get_file()) if mse_dir != "" else "" + if sibling != "" and FileAccess.file_exists(sibling): + return sibling + var direct := assets_root.path_join(rel.lstrip("/")) if assets_root != "" else "" + if direct != "" and FileAccess.file_exists(direct): + return direct + return _scan_for_mde(assets_root, rel.get_file(), 8) if assets_root != "" else "" + +func _resolve_asset_file(file_name: String) -> String: + var base := file_name.replace("\\", "/").get_file() + var mse_dir := String(spec.get("dir", "")) + var sibling := mse_dir.path_join(base) if mse_dir != "" else "" + if sibling != "" and FileAccess.file_exists(sibling): + return sibling + return _scan_for_mde(assets_root, base, 8) if assets_root != "" else "" + +func _scan_for_mde(dir: String, basename: String, depth: int) -> String: + if dir == "" or depth < 0: + return "" + var da := DirAccess.open(dir) + if da == null: + return "" + for file in da.get_files(): + if file.to_lower() == basename.to_lower(): + return dir.path_join(file) + for sub in da.get_directories(): + if sub.begins_with("."): + continue + var result := _scan_for_mde(dir.path_join(sub), basename, depth - 1) + if result != "": + return result + return "" + +func _position_at(rows, time: float) -> Vector3: + # CEffectElementBase::GetPosition: each row's moving type describes the + # segment starting at that row. Bezier uses P0 + controlPoint as its middle + # control vertex, then keeps the result in the effect's centimetre space. + if not rows is Array or rows.is_empty(): + return Vector3.ZERO + var first = rows[0] + if not first is Array or first.size() < 5: + return Vector3.ZERO + if time <= float(first[0]): + return _position_vector(first) * 0.01 + for i in range(1, rows.size()): + var current = rows[i] + if not current is Array or current.size() < 5: + continue + var previous = rows[i - 1] + if not previous is Array or previous.size() < 5: + return _position_vector(current) * 0.01 + var t0 := float(previous[0]) + var t1 := float(current[0]) + if time > t1: + continue + var t := 1.0 if is_zero_approx(t1 - t0) else clampf((time - t0) / (t1 - t0), 0.0, 1.0) + var p0 := _position_vector(previous) + var p1 := _position_vector(current) + if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8: + var control := Vector3(float(previous[5]), float(previous[6]), float(previous[7])) + var middle := p0 + control + var inv := 1.0 - t + return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01 + return p0.lerp(p1, t) * 0.01 + var last = rows[-1] + return _position_vector(last) * 0.01 if last is Array and last.size() >= 5 else Vector3.ZERO + +func _position_vector(row) -> Vector3: + if row is Array and row.size() >= 5: + return Vector3(float(row[2]), float(row[3]), float(row[4])) + return Vector3.ZERO + +func _event_value(rows, time: float, fallback: float) -> float: + if not rows is Array or rows.is_empty(): + return fallback + var previous = rows[0] + if previous is Array and previous.size() >= 2 and time <= float(previous[0]): + return float(previous[1]) + for row in rows: + if not row is Array or row.size() < 2: + continue + var x := float(row[0]) + if time <= x: + var x0 := float(previous[0]) + var y0 := float(previous[1]) + var y1 := float(row[1]) + var t := 1.0 if is_zero_approx(x - x0) else clampf((time - x0) / (x - x0), 0.0, 1.0) + return lerpf(y0, y1, t) + previous = row + return float(previous[1]) if previous is Array and previous.size() >= 2 else fallback + # --- helpers --------------------------------------------------------- func _n(v) -> float: @@ -155,6 +619,46 @@ func _n(v) -> float: return float(v) return 0.0 +func _vec3(v) -> Vector3: + if v is Array and v.size() >= 3: + return Vector3(float(v[0]), float(v[1]), float(v[2])) + return Vector3.ZERO + +func _particle_texture(prop: Dictionary) -> Texture2D: + var rows: Variant = prop.get("TextureFiles", []) + if not rows is Array or rows.is_empty(): + return null + # ParticleSystemData resolves relative texture names against the .mse + # directory. UiAssets keeps the same behavior and uses the native DDS path. + var mse_dir := String(spec.get("dir", "")) + var textures: Array[Texture2D] = [] + for row in rows: + var file := String(row[0]) if row is Array and not row.is_empty() else String(row) + if file == "": + continue + var tex: Texture2D = UiAssets.load_tex(mse_dir, file) if mse_dir != "" else null + if tex == null: + tex = UiAssets.load_tex(assets_root, file) + if tex: + textures.append(tex) + if textures.is_empty(): + return null + var animation_type := int(_n(prop.get("TexAniType", 0))) + if textures.size() == 1 or animation_type == 0: + return textures[0] + # AnimatedTexture is shared by the draw pass, unlike the original per-particle + # frame index. This preserves the real frame order and delay for the common + # multi-file case; random-start variants remain deterministic until the GPU + # particle custom-data path is added. + if animation_type == 2: # TEXTURE_ANIMATION_TYPE_CCW + textures.reverse() + var animated := AnimatedTexture.new() + animated.fps = 1.0 / maxf(_n(prop.get("TexAniDelay", 0.05)), 0.001) + animated.frames = mini(textures.size(), 256) + for i in animated.frames: + animated.set_frame_texture(i, textures[i]) + return animated + # List 表末行的值(列 idx,默认 col 1 = 时间后第一个数) func _last_val(rows, def: float, col := 1) -> float: if rows is Array and rows.size() > 0: @@ -190,8 +694,6 @@ func _alpha_ramp(rows, base: Color) -> GradientTexture1D: if not (rows is Array) or rows.size() < 1: return null var grad := Gradient.new() - grad.offsets = PackedFloat32Array() - grad.colors = PackedColorArray() var pts := [] for r in rows: if r is Array and r.size() >= 2: @@ -203,10 +705,16 @@ func _alpha_ramp(rows, base: Color) -> GradientTexture1D: pts.push_front([0.0, pts[0][1]]) if pts[-1][0] < 1.0: pts.append([1.0, pts[-1][1]]) - for pt in pts: - grad.add_point(pt[0], Color(base.r, base.g, base.b, clampf(pt[1], 0, 1))) - grad.remove_point(0) - grad.remove_point(0) + if pts.size() == 1: + pts.append([minf(1.0, pts[0][0] + 0.001), pts[0][1]]) + grad.offsets = PackedFloat32Array([float(pts[0][0]), float(pts[-1][0])]) + grad.colors = PackedColorArray([ + Color(base.r, base.g, base.b, clampf(float(pts[0][1]), 0, 1)), + Color(base.r, base.g, base.b, clampf(float(pts[-1][1]), 0, 1)), + ]) + for i in range(1, pts.size() - 1): + var pt = pts[i] + grad.add_point(float(pt[0]), Color(base.r, base.g, base.b, clampf(float(pt[1]), 0, 1))) var gt := GradientTexture1D.new() gt.gradient = grad return gt diff --git a/project/fx/mde.gd b/project/fx/mde.gd new file mode 100644 index 00000000..4a68b1b4 --- /dev/null +++ b/project/fx/mde.gd @@ -0,0 +1,197 @@ +# Mde (P5) —— ClientVS22 EffectMesh 的 .mde 二进制读取器。 +# +# 40250/ClientVS22/source/EffectLib/EffectMesh.cpp 支持两种布局: +# EffectData —— v1:每个几何体固定顶点/索引/UV 数量,每帧只有 visibility; +# MDEData002 —— v2:每帧带 changed/visibility 以及三组数量。 +# 参考端把索引顶点展开成 TPTVertex,并把 UV.y 取反;这里同样展开, +# 同时把客户端厘米坐标换成 Godot 的米坐标。 +extends RefCounted + +const HEADER_SIZE := 11 +const GEOMETRY_HEADER_SIZE := 160 # object name 32 + diffuse path 128 +const MAX_GEOMETRIES := 256 +const MAX_FRAMES := 4096 +const MAX_ELEMENTS := 1_000_000 + +var last_error := "" + +func parse_file(path: String) -> Dictionary: + last_error = "" + if not FileAccess.file_exists(path): + last_error = "no such file: " + path + return {} + return parse_bytes(FileAccess.get_file_as_bytes(path)) + +func parse_bytes(data: PackedByteArray) -> Dictionary: + last_error = "" + if data.size() < HEADER_SIZE + 8: + last_error = "truncated header" + return {} + + var header := _fixed_string(data, 0, HEADER_SIZE) + var version := 0 + if header == "EffectData": + version = 1 + elif header == "MDEData002": + version = 2 + else: + last_error = "unknown header: " + header + return {} + + var offset := HEADER_SIZE + var geometry_count := int(data.decode_s32(offset)) + offset += 4 + var frame_count := int(data.decode_s32(offset)) + offset += 4 + if geometry_count < 1 or geometry_count > MAX_GEOMETRIES: + last_error = "invalid geometry count: %d" % geometry_count + return {} + if frame_count < 1 or frame_count > MAX_FRAMES: + last_error = "invalid frame count: %d" % frame_count + return {} + + var geometries: Array = [] + for geometry_index in geometry_count: + if not _has(data, offset, GEOMETRY_HEADER_SIZE): + last_error = "truncated geometry header %d" % geometry_index + return {} + var name := _fixed_string(data, offset, 32) + offset += 32 + var diffuse := _fixed_string(data, offset, 128) + offset += 128 + + var fixed_vertex_count := 0 + var fixed_index_count := 0 + var fixed_uv_count := 0 + if version == 1: + if not _has(data, offset, 12): + last_error = "truncated v1 geometry counts %d" % geometry_index + return {} + fixed_vertex_count = int(data.decode_u32(offset)) + fixed_index_count = int(data.decode_u32(offset + 4)) + fixed_uv_count = int(data.decode_u32(offset + 8)) + offset += 12 + if not _valid_counts(fixed_vertex_count, fixed_index_count, fixed_uv_count): + last_error = "invalid v1 geometry counts %d" % geometry_index + return {} + + var frames: Array = [] + for frame_index in frame_count: + var changed := 0 + var visibility := 1.0 + var vertex_count := fixed_vertex_count + var index_count := fixed_index_count + var uv_count := fixed_uv_count + if version == 2: + if not _has(data, offset, 17): # byte + float + 3 x DWORD + last_error = "truncated v2 frame header %d/%d" % [geometry_index, frame_index] + return {} + changed = int(data.decode_u8(offset)) + offset += 1 + visibility = float(data.decode_float(offset)) + offset += 4 + vertex_count = int(data.decode_u32(offset)) + offset += 4 + index_count = int(data.decode_u32(offset)) + offset += 4 + uv_count = int(data.decode_u32(offset)) + offset += 4 + else: + if not _has(data, offset, 4): + last_error = "truncated v1 frame header %d/%d" % [geometry_index, frame_index] + return {} + visibility = float(data.decode_float(offset)) + offset += 4 + if not _valid_counts(vertex_count, index_count, uv_count): + last_error = "invalid frame counts %d/%d" % [geometry_index, frame_index] + return {} + + var bytes_needed := vertex_count * 12 + index_count * 4 + uv_count * 8 + index_count * 4 + if not _has(data, offset, bytes_needed): + last_error = "truncated frame data %d/%d" % [geometry_index, frame_index] + return {} + + var positions := PackedVector3Array() + positions.resize(vertex_count) + for i in vertex_count: + var base := offset + i * 12 + positions[i] = Vector3( + float(data.decode_float(base)), + float(data.decode_float(base + 4)), + float(data.decode_float(base + 8))) * 0.01 + offset += vertex_count * 12 + + var indices := PackedInt32Array() + indices.resize(index_count) + for i in index_count: + indices[i] = int(data.decode_s32(offset + i * 4)) + offset += index_count * 4 + + var uvs := PackedVector2Array() + uvs.resize(uv_count) + for i in uv_count: + var base := offset + i * 8 + uvs[i] = Vector2( + float(data.decode_float(base)), + -float(data.decode_float(base + 4))) + offset += uv_count * 8 + + var texture_indices := PackedInt32Array() + texture_indices.resize(index_count) + for i in index_count: + texture_indices[i] = int(data.decode_s32(offset + i * 4)) + offset += index_count * 4 + + # EffectMesh.cpp renders a non-indexed TPTVertex array. Keep the + # same order so a frame is directly usable as a TriangleList. + var render_vertices := PackedVector3Array() + var render_uvs := PackedVector2Array() + render_vertices.resize(index_count) + render_uvs.resize(index_count) + for i in index_count: + var vi := indices[i] + var ti := texture_indices[i] + if vi < 0 or vi >= positions.size() or ti < 0 or ti >= uvs.size(): + last_error = "index out of range %d/%d" % [geometry_index, frame_index] + return {} + render_vertices[i] = positions[vi] + render_uvs[i] = uvs[ti] + frames.append({ + "changed": changed, + "visibility": visibility, + "vertex_count": vertex_count, + "index_count": index_count, + "uv_count": uv_count, + "vertices": render_vertices, + "uvs": render_uvs, + }) + + geometries.append({ + "name": name, + "diffuse": diffuse, + "frames": frames, + }) + + return { + "version": version, + "geometry_count": geometry_count, + "frame_count": frame_count, + "geometries": geometries, + } + +func _valid_counts(vertex_count: int, index_count: int, uv_count: int) -> bool: + return vertex_count >= 0 and index_count >= 0 and uv_count >= 0 \ + and vertex_count <= MAX_ELEMENTS and index_count <= MAX_ELEMENTS \ + and uv_count <= MAX_ELEMENTS + +func _has(data: PackedByteArray, offset: int, length: int) -> bool: + return offset >= 0 and length >= 0 and offset <= data.size() - length + +func _fixed_string(data: PackedByteArray, offset: int, length: int) -> String: + var out := "" + for i in length: + var value := int(data[offset + i]) + if value == 0: + break + out += String.chr(value) + return out diff --git a/project/fx/mse.gd b/project/fx/mse.gd index 3a42afbd..a34caa4e 100644 --- a/project/fx/mse.gd +++ b/project/fx/mse.gd @@ -42,12 +42,15 @@ func parse_text(src: String) -> Dictionary: "bsphere_pos": _vec3(top.get("BoundingSpherePosition", [0, 0, 0])), "particles": [], "meshes": [], + "lights": [], "dir": "", } for g in top.get("__group_Particle", []): out.particles.append(_norm_particle(g)) for g in top.get("__group_Mesh", []): out.meshes.append(_norm_mesh(g)) + for g in top.get("__group_Light", []): + out.lights.append(_norm_light(g)) return out # --- 归一化 ----------------------------------------------------------- @@ -70,10 +73,28 @@ func _norm_mesh(g: Dictionary) -> Dictionary: "start_time": float(_scalar(g.get("StartTime", 0.0))), "position": g.get("TimeEventPosition", []), "mesh_file": String(_scalar(g.get("MeshFileName", ""))), + "loop": int(_scalar(g.get("MeshAnimationLoopEnable", 0))), + "loop_count": int(_scalar(g.get("MeshAnimationLoopCount", 0))), "frame_delay": float(_scalar(g.get("MeshAnimationFrameDelay", 0.0))), "elements": elems, } +func _norm_light(g: Dictionary) -> Dictionary: + return { + "start_time": float(_scalar(g.get("StartTime", 0.0))), + "position": g.get("TimeEventPosition", []), + "duration": float(_scalar(g.get("Duration", 1.0))), + "loop": int(_scalar(g.get("LoopFlag", 0))), + "loop_count": int(_scalar(g.get("LoopCount", 0))), + "max_range": float(_scalar(g.get("MaxRange", 300.0))), + "ambient": g.get("AmbientColor", [0.5, 0.5, 0.5, 1.0]), + "diffuse": g.get("DiffuseColor", [0.0, 0.0, 0.0, 1.0]), + "attenuation0": float(_scalar(g.get("Attenuation0", 0.0))), + "attenuation1": float(_scalar(g.get("Attenuation1", 0.1))), + "attenuation2": float(_scalar(g.get("Attenuation2", 0.0))), + "range": g.get("TimeEventRange", []), + } + func _first(a) -> Dictionary: return a[0] if a is Array and a.size() > 0 else {} diff --git a/project/fx_test.gd b/project/fx_test.gd index 3f28df49..3d1bb626 100644 --- a/project/fx_test.gd +++ b/project/fx_test.gd @@ -3,6 +3,7 @@ extends SceneTree const Mse = preload("res://fx/mse.gd") +const Mde = preload("res://fx/mde.gd") const EffectPlayer = preload("res://fx/effect_player.gd") const EffectRegistry = preload("res://fx/effect_registry.gd") @@ -22,6 +23,7 @@ Group Particle MaxEmissionCount 5 CycleLength 0.400000 CycleLoopEnable 1 + LoopCount 2 EmitterShape 3 EmittingRadius 10.000000 List TimeEventLifeTime @@ -58,8 +60,38 @@ Group Particle Group Particle { StartTime 0.200000 - Group EmitterProperty { MaxEmissionCount 10 EmitterShape 0 } - Group ParticleProperty { SrcBlendType 5 DestBlendType 4 BillboardType 4 } + Group EmitterProperty + { + MaxEmissionCount 10 + EmitterShape 3 + EmittingRadius 20.0 + EmitterAdvancedType 1 + EmitterEmitFromEdgeFlag 1 + } + Group ParticleProperty + { + SrcBlendType 5 + DestBlendType 4 + BillboardType 4 + } +} +Group Light +{ + StartTime 0.100000 + Duration 0.500000 + LoopFlag 0 + LoopCount 0 + MaxRange 300.000000 + AmbientColor 0.100000 0.200000 0.300000 1.000000 + DiffuseColor 0.800000 0.400000 0.100000 1.000000 + Attenuation0 0.000000 + Attenuation1 0.100000 + Attenuation2 0.000000 + List TimeEventRange + { + 0.000000 0.000000 + 0.500000 1.000000 + } } """ @@ -72,6 +104,7 @@ func _ck(c: bool, m: String) -> void: func _init() -> void: _test_parser() _test_builder() + _test_mde() _test_registry() if _fail == 0: print("PASS: fx_test (.mse parser + EffectPlayer + registry)") @@ -97,6 +130,8 @@ func _test_parser() -> void: var tex = p0.particle.get("TextureFiles") _ck(tex is Array and tex.size() == 1 and String(tex[0][0]) == "bottom_under.dds", "p0 TextureFiles") _ck(abs(float(s.particles[1].start_time) - 0.2) < 0.01, "p1 start_time 0.2") + _ck(s.lights.size() == 1, "1 SimpleLight group") + _ck(abs(float(s.lights[0].duration) - 0.5) < 0.01 and s.lights[0].range.size() == 2, "light duration and range curve") func _test_builder() -> void: var m := Mse.new() @@ -107,14 +142,41 @@ func _test_builder() -> void: _ck(fx._emitters.size() == 2, "EffectPlayer built 2 GPUParticles3D") var g0: GPUParticles3D = fx._emitters[0] _ck(g0.amount == 5, "emitter 0 amount = 5") + _ck(abs(float(g0.get_meta("cycle_length", 0.0)) - 0.4) < 0.001, "emitter 0 cycle length = 0.4") + _ck(bool(g0.get_meta("cycle_loop", false)), "emitter 0 cycle loop enabled") + _ck(int(g0.get_meta("loop_count", 0)) == 2, "emitter 0 loop count = 2") + _ck(abs(g0.lifetime - 0.07732) < 0.01, "particle lifetime is not replaced by cycle length") _ck(g0.process_material is ParticleProcessMaterial, "emitter 0 has process material") var pm: ParticleProcessMaterial = g0.process_material _ck(pm.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, "emitter 0 sphere emission (shape 3)") _ck(g0.material_override is StandardMaterial3D, "emitter 0 draw material") _ck((g0.material_override as StandardMaterial3D).blend_mode == BaseMaterial3D.BLEND_MODE_ADD, "src5/dst2 -> additive blend") _ck(float(fx._emitters[1].get_meta("start_time")) == 0.2, "emitter 1 start_time meta = 0.2") + _ck(bool(fx._emitters[1].get_meta("emitter_emit_from_edge", false)), "emitter 1 edge emission metadata") + _ck(int(fx._emitters[1].get_meta("emitter_advanced_type", -1)) == 1, "emitter 1 advanced type metadata") + var pm1: ParticleProcessMaterial = fx._emitters[1].process_material + _ck(pm1.emission_shape == ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, + "emitter 1 edge sphere uses surface emission") + _ck(abs(pm1.spread - 180.0) < 0.01, "OUTER emitter uses radial-velocity fallback spread") + _ck(fx._lights.size() == 1, "EffectPlayer built 1 OmniLight3D") + fx._process(0.11) + _ck(fx._lights[0].visible, "SimpleLight becomes visible after start time") + _ck(abs(fx._lights[0].omni_range - 0.06) < 0.02, "SimpleLight range follows TimeEventRange") + var moving_rows: Array = [ + [0.0, "MOVING_TYPE_DIRECT", 0.0, 0.0, 0.0], + [1.0, "MOVING_TYPE_BEZIER_CURVE", 100.0, 0.0, 0.0, 0.0, 100.0, 0.0], + [2.0, "MOVING_TYPE_DIRECT", 200.0, 0.0, 0.0], + ] + var direct_mid: Vector3 = fx._position_at(moving_rows, 0.5) + var bezier_mid: Vector3 = fx._position_at(moving_rows, 1.5) + _ck(abs(direct_mid.x - 0.5) < 0.001 and abs(direct_mid.y) < 0.001, + "MOVING_TYPE_DIRECT interpolates in metres") + _ck(abs(bezier_mid.x - 1.25) < 0.001 and abs(bezier_mid.y - 0.5) < 0.001, + "MOVING_TYPE_BEZIER_CURVE uses previous control point") fx.play() _ck(g0.emitting, "emitter 0 emitting after play() (start_time 0)") + fx._process(0.81) + _ck(not g0.emitting, "finite particle cycle stops after LoopCount cycles") fx.queue_free() func _test_registry() -> void: @@ -130,9 +192,35 @@ func _test_registry() -> void: p = fxr.resolve("geompung_3_sword") _ck(p != "" and FileAccess.file_exists(p), "registry resolved a real .mse (%s)" % p) if p != "": - var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse") + var s := fxr.spec_for("PC/ymir work/pc/warrior/effect/geompung.mse") _ck(s.get("particles", []).size() > 0, "real .mse parsed to >=1 particle (%d)" % s.get("particles", []).size()) var parent := Node3D.new() get_root().add_child(parent) - var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung_3_sword.mse", parent, true) + var fx := fxr.spawn("PC/ymir work/pc/warrior/effect/geompung.mse", parent, true) _ck(fx != null and fx.get_parent() == parent, "spawn attached EffectPlayer") + _ck(fx._mesh_nodes.size() > 0, "spawn built real .mde mesh instead of placeholder") + if fx._mesh_nodes.size() > 0: + var mn: Node3D = fx._mesh_nodes[0] + _ck(int(mn.get_meta("mde_version", 0)) == 1, "real geompung.mde uses EffectData v1") + _ck(mn.get_child_count() > 0 and mn.get_child(0) is MeshInstance3D, "mde geometry is MeshInstance3D") + +func _test_mde() -> void: + var assets := AssetRoot.path() + if not DirAccess.dir_exists_absolute(assets): + print(" (skip mde: no assets)") + return + var path := assets.path_join("PC/ymir work/pc/warrior/effect/geompung.mde") + if not FileAccess.file_exists(path): + print(" (skip mde: fixture missing)") + return + var data := Mde.new() + var mesh := data.parse_file(path) + _ck(data.last_error == "", "mde parses EffectData v1 (%s)" % data.last_error) + _ck(int(mesh.get("version", 0)) == 1, "mde version 1") + _ck(int(mesh.get("geometry_count", 0)) == 3, "mde geometry count = 3") + _ck(int(mesh.get("frame_count", 0)) == 2, "mde frame count = 2") + var geometries: Array = mesh.get("geometries", []) + _ck(geometries.size() == 3 and geometries[0].frames.size() == 2, "mde keeps every geometry frame") + if geometries.size() > 0 and geometries[0].frames.size() > 0: + var frame: Dictionary = geometries[0].frames[0] + _ck(frame.vertices.size() == frame.uvs.size() and frame.vertices.size() > 0, "mde expands indexed vertices with UVs") diff --git a/project/game_camera.gd b/project/game_camera.gd index 659bff27..e397f7d7 100644 --- a/project/game_camera.gd +++ b/project/game_camera.gd @@ -34,6 +34,13 @@ var _orbit_active := false # 已越过 deadzone var _orbit_press := Vector2.ZERO var _pinch_last := -1.0 # 上一帧两指间距,<0 = 未在捏合 +# ClientVS22 game.py drives these while Q/E/R/F/T/G are held. Keeping the +# state here (instead of applying one large step per key event) gives the +# same continuous desktop-camera motion on macOS and Windows. +var _key_orbit := 0 +var _key_zoom := 0 +var _key_pitch := 0 + const OCCLUDER_MASK := 1 << 1 # 静态遮挡物层(Metin2World 给建筑的盒碰撞) const FADE_ALPHA := 0.72 # 挡住玩家时的透明度 var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D @@ -108,6 +115,15 @@ func heading() -> float: # 相机看向的水平方向(供角色移动「相对相机」用) return yaw +func set_key_orbit(direction: int) -> void: + _key_orbit = clampi(direction, -1, 1) + +func set_key_zoom(direction: int) -> void: + _key_zoom = clampi(direction, -1, 1) + +func set_key_pitch(direction: int) -> void: + _key_pitch = clampi(direction, -1, 1) + func _desired_pos(snap := false) -> Vector3: var head := target.global_position + head_offset var off := Vector3( @@ -146,6 +162,12 @@ func shake(strength: float, decay := 8.0) -> void: func _process(dt: float) -> void: if target == null: return + if _key_orbit != 0: + yaw += float(_key_orbit) * dt * 1.8 + if _key_zoom != 0: + dist = clampf(dist + float(_key_zoom) * dt * 5.0, min_dist, max_dist) + if _key_pitch != 0: + pitch = clampf(pitch + float(_key_pitch) * dt * 1.2, min_pitch, max_pitch) var want := _desired_pos() if _pos_ready: global_position = global_position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0)) diff --git a/project/game_scene.gd b/project/game_scene.gd index f5d9c6d4..9e2bda31 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -2,7 +2,7 @@ # # 把已有的件拼成一个能玩的场景: # Metin2World + 本地玩家 + GameCamera + PlayerController + NetWorld + HUD -# + NetPlay(胶水)+ Audio + AppLifecycle +# + NetPlay(胶水)+ Audio # # 由 login.gd 在 entered_game 时实例化: # var gs := preload("res://game_scene.gd").new() @@ -17,14 +17,16 @@ const PlayerCtl = preload("res://player_controller.gd") const NetWorld = preload("res://net_world.gd") const NetPlay = preload("res://net_play.gd") const Hud = preload("res://hud.gd") -const AppLifecycle = preload("res://app_lifecycle.gd") const Audio = preload("res://audio.gd") const BgmDirector = preload("res://bgm_director.gd") const UiManager = preload("res://ui/ui_manager.gd") +const CursorManager = preload("res://ui/cursor_manager.gd") +const MouseController = preload("res://ui/mouse_controller.gd") const InventoryUI = preload("res://ui/inventory_ui.gd") const ItemListDB = preload("res://ui/item_list.gd") const EquipModel = preload("res://ui/equip_model.gd") const PlayerView = preload("res://ui/player_view.gd") +const RemotePlayerView = preload("res://ui/remote_player_view.gd") const MobView = preload("res://ui/mob_view.gd") const GroundItems = preload("res://ui/ground_items.gd") const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd") @@ -56,6 +58,7 @@ const RefineUI = preload("res://ui/refine_ui.gd") const DragonSoulUI = preload("res://ui/dragon_soul_ui.gd") const Minimap = preload("res://ui/minimap.gd") const AtlasUI = preload("res://ui/atlas_ui.gd") +const DungeonState = preload("res://dungeon_state.gd") const WorldTime = preload("res://world/world_time.gd") const Weather = preload("res://fx/weather.gd") @@ -72,6 +75,8 @@ var net_world: Node var net_play: Node var hud: Node var ui: CanvasLayer # UiManager +var cursor_manager: Node +var mouse_controller: Node var proto: Node # Metin2Proto var inventory: Node # InventoryUI var item_list: RefCounted # ItemList @@ -105,6 +110,7 @@ var refine_ui: Node # RefineUI var dragon_soul_ui: Node # DragonSoulUI var minimap: Node # Minimap var atlas_ui: Node # AtlasUI +var dungeon_state: Node # GC_DUNGEON destination / compass state var world_time: Node # WorldTime var weather: Node3D # Weather var _assets := "" @@ -116,9 +122,16 @@ var _mount: Node3D var _sun: DirectionalLight3D var _env: Environment var _env_from_msenv := false # true = 用 Metin2World 从 .msenv 建的 Sun/WorldEnv -var _pending_shoot_skill := 0 +# CNormalBowAttack_FlyEventHandler_AutoClear 的等价:每次弓挥击 / 弓技能起手压一个 +# uSkill,`.msa` MOTION_EVENT_TYPE_FLY 帧(_on_local_motion_event 的 type 6)按 FIFO 弹一个 +# 发 CG_SHOOT —— pop 即自清(AutoClear),队列天然支持连射多箭在途。§3.6 +# 上限兜底:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时丢最旧的,避免无界增长。 +var _pending_shots: Array[int] = [] +const MAX_PENDING_SHOTS := 8 var _guild_invite_dialog: ConfirmationDialog var _gift_dialog: AcceptDialog +var _quick_page_mode := false +var _show_names := false func setup(m2client: Node, assets_root: String, initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void: @@ -159,6 +172,14 @@ func setup(m2client: Node, assets_root: String, net_world.world = world net_world.setup(client, _mount) net_world.set_local_node(player) + net_world.entity_added.connect(func(node: Node3D, _vid: int): + if node not in pc.pickables: + pc.pickables.append(node)) + net_world.entity_removed.connect(func(vid: int): + for pickable in pc.pickables.duplicate(): + if pickable == null or not is_instance_valid(pickable) \ + or int(pickable.get_meta("vid", -1)) == vid: + pc.pickables.erase(pickable)) hud = Hud.new() if _map_loaded(): @@ -171,6 +192,11 @@ func setup(m2client: Node, assets_root: String, add_child(net_play) net_play.setup(client, pc, net_world, hud) net_play.camera = cam + # §8.6 targetBoard.GetTargetVID() -> net_world:选中目标始终强显名字。 + if net_play.has_signal("target_changed") and net_world and net_world.has_method("set_target_vid"): + net_play.target_changed.connect(func(vid: int): net_world.set_target_vid(int(vid))) + if _assets != "": + net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表 await _yield() _audio = Audio.new() @@ -178,9 +204,6 @@ func setup(m2client: Node, assets_root: String, if assets_root != "": _audio.setup(assets_root) await _yield() - var life := AppLifecycle.new() - add_child(life) - life.bind(client, _audio) # §9.1 地图 BGM:M2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。 _bgm_director = BgmDirector.new() @@ -190,11 +213,18 @@ func setup(m2client: Node, assets_root: String, # UI 层 + 物品 proto + 背包窗(I 键开关) ui = UiManager.new() add_child(ui) + cursor_manager = CursorManager.new() + add_child(cursor_manager) + mouse_controller = MouseController.new() + add_child(mouse_controller) + mouse_controller.setup(ui, cursor_manager, _audio) # 聊天窗(Enter 聚焦输入) chat = ChatUI.new() add_child(chat) - chat.setup(client, ui) + chat.setup(client, ui, _assets) + if net_play and net_play.has_signal("fishing_feedback") and chat.has_method("on_fishing_feedback"): + net_play.fishing_feedback.connect(chat.on_fishing_feedback) if net_world and net_world.has_signal("main_bubble"): net_world.main_bubble.connect(_player_bubble) @@ -218,20 +248,35 @@ func setup(m2client: Node, assets_root: String, char_status_ui.setup(ui, client, assets_root) quickbar = Quickbar.new() add_child(quickbar) + quickbar.item_mouse = mouse_controller + quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文 quickbar.setup(client, skill_table, ui, func() -> Node: return player) + # §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。 + if net_play: + net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot) + if quickbar.has_signal("skill_rejected"): + quickbar.skill_rejected.connect(func(_sid: int, code: String): + if net_play and net_play.has_signal("cannot_act"): + net_play.cannot_act.emit(code) # HUD 走同一条 OnCannotUseSkill 文案通道 + ) + if net_play and net_play.has_signal("fishing_feedback") and net_play.has_signal("cannot_act"): + net_play.fishing_feedback.connect(func(code: String): net_play.cannot_act.emit(code)) if quickbar.has_signal("skill_activated"): quickbar.skill_activated.connect(func(sid: int): if skill_fx and player: skill_fx.spawn_skill(sid, _skill_master(sid), player) - # Bow skills send CG_SHOOT from the motion event, matching - # CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot. + # Bow skills send CG_SHOOT from the .msa FLY frame, matching + # CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列, + # 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6 if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid): - _pending_shoot_skill = sid - get_tree().create_timer(1.5).timeout.connect(func(): - if _pending_shoot_skill == sid: - _pending_shoot_skill = 0) + _queue_shot(sid) ) + # 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING, + # 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6 + if net_play and net_play.has_signal("bow_shot_fired"): + net_play.bow_shot_fired.connect(_queue_shot) + if client.has_signal("effect_cue"): client.effect_cue.connect(func(vid: int, name: String, special: int): var host: Node3D = _fx_host(vid) @@ -241,16 +286,31 @@ func setup(m2client: Node, assets_root: String, fx.spawn(name, host, true) # GC_SPECIFIC_EFFECT elif special >= 0 and skill_fx: skill_fx.spawn_special(special, host)) # GC_SPECIAL_EFFECT 内建 id + if client.has_signal("emoticon_requested"): + client.emoticon_requested.connect(_play_emoticon) if client.has_signal("gift_available"): client.gift_available.connect(_on_gift_available) # 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志 quest_dialog = QuestDialog.new() add_child(quest_dialog) - quest_dialog.setup(client, ui) + quest_dialog.setup(client, ui, proto, assets_root) quest_log = QuestLog.new() add_child(quest_log) quest_log.setup(client, ui) + if quest_dialog.has_signal("quest_button_received"): + quest_dialog.quest_button_received.connect(func(index: int, title: String, icon_type: String, icon_name: String): + if quest_log and quest_log.has_method("recv_quest"): + quest_log.recv_quest(index, title, icon_type, icon_name)) + quest_dialog.quest_button_cleared.connect(func(index: int): + if quest_log and quest_log.has_method("clear_quest"): + quest_log.clear_quest(index)) + quest_dialog.opened.connect(func(): + if quest_log and quest_log.has_method("set_buttons_suppressed"): + quest_log.set_buttons_suppressed(true)) + quest_dialog.closed.connect(func(): + if quest_log and quest_log.has_method("set_buttons_suppressed"): + quest_log.set_buttons_suppressed(false)) # 死亡窗 + 状态图标条(P4) var death := DeathUI.new() @@ -268,6 +328,8 @@ func setup(m2client: Node, assets_root: String, if FileAccess.file_exists(ip): proto.call("load_item_proto", ip) await _yield() + if chat and chat.has_method("set_proto"): + chat.set_proto(proto) var mp := assets_root.path_join("locale/locale/en/mob_proto") if FileAccess.file_exists(mp): proto.call("load_mob_proto", mp) @@ -275,14 +337,26 @@ func setup(m2client: Node, assets_root: String, net_play.proto = proto # 按 race 分类 NPC/怪(bType 在本 fork 不可靠) if net_world and "name_resolver" in net_world: net_world.name_resolver = net_play._entity_name # 头顶名字:怪走 mob_proto + if net_world and "kind_resolver" in net_world: + net_world.kind_resolver = net_play._entity_kind # §8.8 名字色分类:怪 / NPC 走 mob_proto bType + if net_world and "title_name_resolver" in net_world and assets_root != "": + # §8.8 称号名:introloading.__RegisterTitleName -> localeInfo.PVP_LEVEL + var _tt_loc: RefCounted = load("res://locale.gd").new() + _tt_loc.setup(assets_root, "en") + net_world.title_name_resolver = func(g: int) -> String: + var k := "PVP_LEVEL%d" % g + return _tt_loc.t(k) if _tt_loc.has(k) else "" # 怪 / NPC 真模型:race -> mob_proto.name -> monster/npc 目录 net_world.set_model_factory(_make_entity_model) if assets_root != "": item_list = ItemListDB.new() item_list.load_file(assets_root.path_join("locale/locale/common/item_list.txt")) await _yield() + if quest_log and quest_log.has_method("set_item_sources"): + quest_log.set_item_sources(proto, item_list, assets_root) inventory = InventoryUI.new() add_child(inventory) + inventory.item_mouse = mouse_controller inventory.setup(ui, client, proto, assets_root, item_list) # 装备 → 模型部件:武器 / 盾 / 身体 / 头盔 / 头发 equip_model = EquipModel.new() @@ -294,6 +368,8 @@ func setup(m2client: Node, assets_root: String, ground_items = GroundItems.new() add_child(ground_items) ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list) + pc.set_input_surfaces(cursor_manager, ui, ground_items, + Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click")) view_equipment_ui = ViewEquipmentUI.new() add_child(view_equipment_ui) view_equipment_ui.setup(client, ui, proto, item_list) @@ -309,6 +385,10 @@ func setup(m2client: Node, assets_root: String, party_ui = PartyUI.new() add_child(party_ui) party_ui.setup(client, ui) + # __ArrangeQuestButton 的 xPos 随组队窗可见性移位;party_ui 无信号,用探针轮询。 + if quest_log and quest_log.has_method("set_party_probe"): + quest_log.set_party_probe(func() -> bool: + return party_ui != null and party_ui.has_method("is_open") and party_ui.is_open()) friend_ui = FriendUI.new() add_child(friend_ui) friend_ui.setup(client, ui) @@ -321,21 +401,27 @@ func setup(m2client: Node, assets_root: String, chat.start_whisper(nm)) shop_ui = ShopUI.new() add_child(shop_ui) + shop_ui.item_mouse = mouse_controller + shop_ui.audio = _audio shop_ui.setup(client, ui, proto, item_list) exchange_ui = ExchangeUI.new() add_child(exchange_ui) + exchange_ui.item_mouse = mouse_controller exchange_ui.setup(client, ui, proto) safebox_ui = SafeboxUI.new() add_child(safebox_ui) + safebox_ui.item_mouse = mouse_controller safebox_ui.setup(client, ui, proto) mall_ui = MallUI.new() add_child(mall_ui) + mall_ui.item_mouse = mouse_controller mall_ui.setup(client, ui, proto) cube_ui = CubeUI.new() add_child(cube_ui) cube_ui.setup(client, ui, proto) private_shop_ui = PrivateShopUI.new() add_child(private_shop_ui) + private_shop_ui.item_mouse = mouse_controller private_shop_ui.setup(client, ui, proto, assets_root) guild_ui = GuildUI.new() add_child(guild_ui) @@ -360,9 +446,26 @@ func setup(m2client: Node, assets_root: String, minimap = Minimap.new() add_child(minimap) minimap.setup(client, ui, func() -> Node3D: return player) + dungeon_state = DungeonState.new() + add_child(dungeon_state) + dungeon_state.setup(client, func() -> Node3D: return player) + if dungeon_state.has_signal("destination_changed") and hud and hud.has_method("set_dungeon_destination"): + dungeon_state.destination_changed.connect(hud.set_dungeon_destination) atlas_ui = AtlasUI.new() add_child(atlas_ui) atlas_ui.setup(world, ui, func() -> Node3D: return player, map_path.get_file(), client) + if quest_dialog: + quest_dialog.map_signal_added.connect(func(x: float, y: float): + if minimap and minimap.has_method("add_signal_point"): + minimap.add_signal_point(x, y) + if atlas_ui and atlas_ui.has_method("open"): + atlas_ui.open()) + quest_dialog.map_signals_cleared.connect(func(): + if minimap and minimap.has_method("clear_signal_points"): + minimap.clear_signal_points()) + quest_dialog.atlas_center_requested.connect(func(x: int, y: int): + if atlas_ui and atlas_ui.has_method("set_center_position_adjust"): + atlas_ui.set_center_position_adjust(x, y)) world_time = WorldTime.new() add_child(world_time) # .msenv 已经给了固定的白天关照(A1 没有昼夜预设)——别让 world_time 每帧覆盖它。 @@ -381,6 +484,11 @@ func setup(m2client: Node, assets_root: String, game_option_ui = GameOptionUI.new() add_child(game_option_ui) game_option_ui.setup(ui, client, assets_root) + # §8.4「总显示名字」radio -> net_world(CPythonSystem::SetAlwaysShowNameFlag)。 + if game_option_ui.has_signal("display_option_changed"): + game_option_ui.display_option_changed.connect(_on_display_option) + if net_world and net_world.has_method("set_always_show_name") and game_option_ui.has_method("display_value"): + net_world.set_always_show_name(game_option_ui.display_value("always_show_name") == 1) system_menu_ui = SystemMenuUI.new() add_child(system_menu_ui) system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui) @@ -408,6 +516,17 @@ func setup(m2client: Node, assets_root: String, net_world.catch_up() set_process_unhandled_input(true) + set_process(true) + +func _process(_dt: float) -> void: + # MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio + # node keeps its listener at the origin and rebases active 3D instances. + if _audio == null or player == null or not is_instance_valid(player): + return + var basis := player.global_transform.basis + _audio.set_listener(player.global_position, -basis.z, basis.y) + if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"): + _audio.update_ambience_sources(world.call("get_ambience_sources")) # 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。 func _guild_mark_upload_image() -> Image: @@ -465,7 +584,10 @@ func _on_gift_available() -> void: func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vector3) -> void: var anchor: Node3D = player if sound != "" and _audio: - _audio.play_at(sound, anchor.global_position, anchor) + # Motion sounds are character sounds in ClientVS22 and use the optional + # 0.3s / 5000cm frequency-distance guard. + _audio.play_character_sound_3d(anchor.global_position.x, anchor.global_position.y, + anchor.global_position.z, sound, true) if effect != "" and fx: # EffectPosition 是骨骼本地锚点(cm);简版直接挂角色根 + y 偏移 var gp := anchor.global_position + Vector3(pos.x, pos.y, pos.z) * 0.01 @@ -476,13 +598,21 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto cam.shake(0.06, 9.0) 3: # SCREEN_FLASHING _screen_flash(0.12) - 6: # FLY —— 弓技能在动画飞行点发送 CG_SHOOT - if _pending_shoot_skill != 0 and client and client.has_method("shoot"): - client.shoot(_pending_shoot_skill) - _pending_shoot_skill = 0 + 6: # FLY —— `.msa` MOTION_EVENT_TYPE_FLY 帧:CActorInstance::ProcessMotionEventFly + # → m_pFlyEventHandler->OnShoot(m_kCurMotNode.uSkill)(ActorInstanceMotionEvent.cpp:290)。 + # FIFO pop = AutoClear + 连射多箭在途各自一发。 + if client and client.has_method("shoot") and not _pending_shots.is_empty(): + client.shoot(_pending_shots.pop_front()) 9: # WARP —— 传送起点,不是投射物 pass +# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。 +# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6 +func _queue_shot(skill: int) -> void: + _pending_shots.append(skill) + while _pending_shots.size() > MAX_PENDING_SHOTS: + _pending_shots.pop_front() + var _flash_rect: ColorRect func _screen_flash(secs: float) -> void: if hud == null: @@ -591,47 +721,168 @@ func _on_inv_context(window: int, cell: int) -> bool: return false func _unhandled_input(e: InputEvent) -> void: - if not (e is InputEventKey and e.pressed and not e.echo): + if not (e is InputEventKey) or e.echo: return - if e.keycode in [KEY_ENTER, KEY_KP_ENTER] and chat and not chat.is_typing(): - chat.focus_input() - get_viewport().set_input_as_handled() - elif e.keycode == KEY_I and inventory: - inventory.toggle() - elif e.keycode == KEY_K and skills: - skills.set_job(_job_name()) - skills.toggle() - elif e.keycode in [KEY_V, KEY_C] and char_status_ui: - char_status_ui.toggle() - elif e.keycode == KEY_H and not e.ctrl_pressed and not e.meta_pressed and system_menu_ui: - # 参考端 game.py __PressHKey:非 Ctrl → interface.OpenHelpWindow()(Ctrl+H 是 /user_horse_ride) - system_menu_ui.toggle_help() - elif e.keycode == KEY_J and quest_log: - quest_log.toggle() - elif e.keycode == KEY_O and friend_ui: - friend_ui.toggle() - elif e.keycode == KEY_G and guild_ui: - guild_ui.toggle() - elif e.keycode == KEY_L and ENABLE_DRAGON_SOUL and dragon_soul_ui: - dragon_soul_ui.toggle() - elif e.keycode == KEY_M and atlas_ui: - atlas_ui.toggle() - elif e.keycode == KEY_Z and ground_items: - ground_items.try_pickup() - elif e.keycode >= KEY_F1 and e.keycode <= KEY_F4 and quickbar: - # 参考端 game.py:318 —— F1..F4 固定 __PressQuickSlot(4..7)。 - quickbar.activate(4 + (e.keycode - KEY_F1)) - elif e.keycode >= KEY_1 and e.keycode <= KEY_9: - # 参考端 __PressNumKey(game.py:431): - # Ctrl+1..9 → 表情;否则 1..4 → 快捷栏 0..3,5..9 无操作。 - var n: int = e.keycode - KEY_1 - if e.ctrl_pressed or e.meta_pressed: - _emote(n) - elif n < 4 and quickbar: - quickbar.activate(n) - elif e.keycode == KEY_ESCAPE and system_menu_ui: - # ESC:ui_manager 先关最顶层窗口并吃掉事件;到这里说明没有窗口打开。 - system_menu_ui.toggle() + var key: Key = e.keycode + if ui and ui.has_method("blocks_game_input") and ui.blocks_game_input(e): + return + # The reference keeps a different callback for LSHIFT press/release. The + # flag is also useful on macOS where Command is the modifier for emotes. + if key == KEY_SHIFT: + _quick_page_mode = e.pressed + return + if not e.pressed: + match key: + KEY_SPACE: + if net_play and net_play.has_method("set_attack_key"): + net_play.set_attack_key(false) + KEY_ALT: + _set_names(false) + KEY_Q, KEY_E: + if cam and cam.has_method("set_key_orbit"): + cam.set_key_orbit(0) + KEY_R, KEY_F: + if cam and cam.has_method("set_key_zoom"): + cam.set_key_zoom(0) + KEY_T, KEY_G: + if cam and cam.has_method("set_key_pitch"): + cam.set_key_pitch(0) + return + + var command: bool = e.ctrl_pressed or e.meta_pressed + match key: + KEY_ENTER, KEY_KP_ENTER: + if chat and not chat.is_typing(): + chat.focus_input() + get_viewport().set_input_as_handled() + KEY_ALT: + _set_names(true) + KEY_PRINT: + _save_screen() + KEY_SPACE: + if net_play and net_play.has_method("set_attack_key"): + net_play.set_attack_key(true) + KEY_I: + if inventory: inventory.toggle() + KEY_K: + if skills: + skills.set_job(_job_name()) + skills.toggle() + KEY_V: + if char_status_ui: char_status_ui.open("SKILL") + KEY_C: + if not command and char_status_ui: char_status_ui.open("STATUS") + KEY_B: + if command: + _send_command("/user_horse_back") + elif char_status_ui: + char_status_ui.open("EMOTICON") + KEY_H: + if command: + _send_command("/user_horse_ride") + elif system_menu_ui: + # game.py __PressHKey: normal H opens the help window. + system_menu_ui.toggle_help() + KEY_J: + _toggle_horse() + KEY_N: + if not command and quest_log: quest_log.toggle() + KEY_F: + if command: + _send_command("/user_horse_feed") + elif cam and cam.has_method("set_key_zoom"): + cam.set_key_zoom(1) + KEY_G: + if command: + _send_command("/ride") + elif _show_names and guild_ui: + guild_ui.toggle() + elif cam and cam.has_method("set_key_pitch"): + cam.set_key_pitch(1) + KEY_Q: + if command: + if quest_log and quest_log.has_method("toggle_buttons"): quest_log.toggle_buttons() + elif cam and cam.has_method("set_key_orbit"): + cam.set_key_orbit(-1) + KEY_E: + if not command and cam and cam.has_method("set_key_orbit"): + cam.set_key_orbit(1) + KEY_R: + if not command and cam and cam.has_method("set_key_zoom"): + cam.set_key_zoom(-1) + KEY_T: + if not command and cam and cam.has_method("set_key_pitch"): + cam.set_key_pitch(-1) + KEY_L: + if not command and chat and chat.has_method("toggle_log"): chat.toggle_log() + KEY_M: + if not command and atlas_ui: atlas_ui.toggle() + KEY_KP_ADD: + if minimap and minimap.has_method("get_scale"): + minimap.set_scale(float(minimap.get_scale()) * 1.15) + KEY_KP_SUBTRACT: + if minimap and minimap.has_method("get_scale"): + minimap.set_scale(float(minimap.get_scale()) / 1.15) + KEY_Z: + if not command and ground_items: ground_items.try_pickup() + KEY_O: + if not command and ENABLE_DRAGON_SOUL and dragon_soul_ui: dragon_soul_ui.toggle() + KEY_ESCAPE: + if system_menu_ui: + # ui_manager consumes topmost windows before this reaches ESC. + system_menu_ui.toggle() + _: + if int(key) == 96 or int(e.physical_keycode) == 96: + if not command and ground_items: ground_items.try_pickup() + elif key >= KEY_F1 and key <= KEY_F4 and quickbar: + # game.py __PressQuickSlot(4..7), independent of page selection. + quickbar.activate(4 + (key - KEY_F1)) + elif key >= KEY_1 and key <= KEY_9: + var n: int = key - KEY_1 + if command: + _emote(n) + elif _quick_page_mode and n < 4 and quickbar: + quickbar.set_page(n) + elif n < 4 and quickbar: + quickbar.activate(n) + +func _set_names(visible: bool) -> void: + _show_names = visible + if net_world and net_world.has_method("set_names_visible"): + net_world.set_names_visible(visible) + +# GameOptionUI 显示开关 radio 选中(uigameoption RefreshAlwaysShowName 系)。 +func _on_display_option(key: String, value: int) -> void: + if key == "always_show_name" and net_world and net_world.has_method("set_always_show_name"): + net_world.set_always_show_name(value == 1) + +func _send_command(text: String) -> void: + if client and client.has_method("say"): + client.say(0, text) + +func _toggle_horse() -> void: + if client == null: + return + var main: Dictionary = client.get_entity(client.get_main_vid()) if client.has_method("get_entity") else {} + if int(main.get("mount_vnum", 0)) != 0: + _send_command("/unmount") + return + if private_shop_ui and private_shop_ui.has_method("is_open") and private_shop_ui.is_open(): + return + if not client.has_method("get_inventory") or not client.has_method("use_item"): + return + for item in client.get_inventory(): + if int(item.get("vnum", 0)) in [71114, 71116, 71118, 71120]: + client.use_item(1, int(item.get("cell", 0))) + return + +func _save_screen() -> void: + var texture := get_viewport().get_texture() + if texture == null: + return + var dir := OS.get_user_data_dir().path_join("screenshots") + DirAccess.make_dir_recursive_absolute(dir) + texture.get_image().save_png(dir.path_join("screen_%d.png" % Time.get_ticks_msec())) # 表情快捷键(§8.6 / §4.8)—— 参考端 Ctrl+1..9 = chrmgr.SetEmoticon(-1, n) + net.SendEmoticon(n)。 # W3 在 §8.6 里补全:本地立即播表情动作 + 发 classic 表情包。这里先留统一入口。 @@ -639,6 +890,13 @@ func _emote(index: int) -> void: if client and client.has_method("send_emoticon"): client.send_emoticon(index) +func _play_emoticon(index: int) -> void: + # playersettingmodule.py registers these in this exact order (0..11). + const EFFECTS := ["sweat", "money", "happy", "like", "love_s", "angry", + "aha", "gloom", "sorry", "!_mix_back", "question", "fish"] + if fx and player and index >= 0 and index < EFFECTS.size(): + fx.spawn(EFFECTS[index], player, true) + func _job_name() -> String: var r := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3 return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][r] @@ -663,8 +921,9 @@ func _player_bubble(text: String) -> void: lbl.pixel_size = 0.0055 lbl.outline_size = 6 player.add_child(lbl) + # §8.8:本地玩家气泡寿命对齐 CPythonTextTail gs_TextTail_LivingTime(默认 5s) var tw := create_tween() - tw.tween_interval(3.0) + tw.tween_interval(4.0) tw.tween_property(lbl, "modulate:a", 0.0, 1.0) tw.tween_callback(lbl.queue_free) @@ -676,17 +935,34 @@ func set_entity_model_factory(factory: Callable) -> void: net_world.set_model_factory(factory) var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试) +var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性) -# 默认工厂:怪 / NPC -> MobView(race = mob_proto vnum)。失败返回 null 用占位胶囊。 +# 默认工厂:远端 PC -> RemotePlayerView;怪 / NPC -> MobView。失败返回 null 用占位胶囊。 func _make_entity_model(d: Dictionary) -> Node3D: - if bool(d.get("is_main", false)) or proto == null or _assets == "": + if bool(d.get("is_main", false)) or _assets == "": return null + var ch_type := int(d.get("ch_type", -1)) var race := int(d.get("race", 0)) + var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() + if ch_type == 0: + if race < 0 or _remote_player_view_cache.get(race, true) == false: + return null + var pv := RemotePlayerView.new() + var parts: Array = d.get("parts", []) if d.get("parts", []) is Array else [] + if pv.build_remote(_assets, race, parts, item_list, proto, pump): + pv.set_audio(_audio) + _remote_player_view_cache[race] = true + return pv + _remote_player_view_cache[race] = false + pv.free() + return null + if proto == null: + return null if race < 1 or _mob_view_cache.get(race, true) == false: return null var mv := MobView.new() - var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() if mv.build(_assets, proto, race, pump): + mv.set_audio(_audio) _mob_view_cache[race] = true return mv _mob_view_cache[race] = false @@ -871,6 +1147,7 @@ func _on_main_set(vid: int) -> void: var pv := PlayerView.new() var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() if pv.build(_assets, int(e.get("race", 0)), pump): + pv.set_audio(_audio) set_player_model(pv) _model_built = true if net_play: @@ -893,6 +1170,11 @@ func _on_main_set(vid: int) -> void: p.y = float(world.call("sample_height", p.x, p.z)) player.position = p +# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。 +# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。 +func lifecycle_audio() -> Node: + return _audio + func _make_placeholder_player() -> Node3D: var root := Node3D.new() root.name = "LocalPlayer" diff --git a/project/gamescene_test.gd b/project/gamescene_test.gd index 8ba28584..6a73caac 100644 --- a/project/gamescene_test.gd +++ b/project/gamescene_test.gd @@ -70,6 +70,10 @@ func _run() -> void: _ck(gs.net_world != null, "net_world built") _ck(gs.net_play != null, "net_play built") _ck(gs.hud != null, "hud built") + _ck(gs.get_node_or_null("AppLifecycle") == null, + "GameScene does not create a second lifecycle coordinator") + _ck(gs.lifecycle_audio() != null and gs.lifecycle_audio().get_parent() == gs, + "GameScene exposes its audio to AppFlow lifecycle owner") _ck(gs.dragon_soul_ui == null, "Dragon Soul runtime entry disabled for target 40250") _ck(gs.atlas_ui != null, "atlas_ui built") _ck(gs.get_node_or_null("Entities") != null, "entity mount present") diff --git a/project/hud.gd b/project/hud.gd index 7b94c956..ef0bce7d 100644 --- a/project/hud.gd +++ b/project/hud.gd @@ -31,6 +31,11 @@ var _target_name: Label var _target_fill: ColorRect # 状态图标条(P4) var _affect_row: HBoxContainer +# GC_DUNGEON destination compass (ClientVS22: AlarmHaveToGo). +var _dungeon_panel: Control +var _dungeon_label: Label +var _dungeon_arrow: Label +var _dungeon_destination := Vector3.ZERO func setup(world: Node, player: Node3D) -> void: _world = world @@ -43,6 +48,7 @@ func setup(world: Node, player: Node3D) -> void: _build_status() _build_hotbar() _build_inventory() + _build_dungeon_compass() func reload_world(new_world: Node) -> void: _world = new_world @@ -312,6 +318,36 @@ func clear_target() -> void: if _target_panel: _target_panel.visible = false +func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: + _dungeon_destination = world_pos + if _dungeon_panel: + _dungeon_panel.visible = active + if not active: + _dungeon_label.text = "" + +func _build_dungeon_compass() -> void: + _dungeon_panel = Control.new() + _dungeon_panel.name = "DungeonCompass" + _dungeon_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) + _dungeon_panel.position = Vector2(-110, 18) + _dungeon_panel.size = Vector2(220, 42) + _dungeon_panel.visible = false + add_child(_dungeon_panel) + var bg := ColorRect.new() + bg.color = Color(0.04, 0.05, 0.08, 0.78) + bg.size = _dungeon_panel.size + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + _dungeon_panel.add_child(bg) + _dungeon_label = Label.new() + _dungeon_label.position = Vector2(30, 5) + _dungeon_label.add_theme_font_size_override("font_size", 12) + _dungeon_panel.add_child(_dungeon_label) + _dungeon_arrow = Label.new() + _dungeon_arrow.text = "▲" + _dungeon_arrow.position = Vector2(8, 9) + _dungeon_arrow.add_theme_font_size_override("font_size", 18) + _dungeon_panel.add_child(_dungeon_arrow) + func _build_target_panel() -> void: _target_panel = Control.new() _target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP) @@ -365,8 +401,15 @@ func _input(ev: InputEvent) -> void: _inv.visible = not _inv.visible func _process(_dt: float) -> void: - if _player == null or _minimap_rect == null: - return - var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0) - var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0) - _dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3) + if _player: + if _dungeon_panel and _dungeon_panel.visible: + var d := _dungeon_destination - _player.global_position + var distance := Vector2(d.x, d.z).length() + _dungeon_label.text = "副本目标 %.0fm" % distance + # Player forward is +Z in this scene; rotate the north-facing arrow + # relative to the current player yaw, keeping the target prompt local. + _dungeon_arrow.rotation = atan2(d.x, d.z) - _player.rotation.y + if _minimap_rect: + var u := clampf(_player.position.x / _map_w_m, 0.0, 1.0) + var v := clampf(_player.position.z / _map_h_m, 0.0, 1.0) + _dot.position = Vector2(u * _minimap_rect.size.x - 3, v * _minimap_rect.size.y - 3) diff --git a/project/input_key_test.gd b/project/input_key_test.gd new file mode 100644 index 00000000..5dd16d4d --- /dev/null +++ b/project/input_key_test.gd @@ -0,0 +1,145 @@ +# input_key_test —— ClientVS22 game.py 键盘映射的离线回归。 +# 覆盖 macOS Command / Windows Ctrl 表情键、快捷栏分页、按住攻击、 +# 相机按住/释放、名称显示和几个窗口/坐骑入口。 +extends SceneTree + +const GameScene = preload("res://game_scene.gd") + +class FakeClient extends Node: + var emotes := [] + var commands := [] + var used_items := [] + var main := 1000 + func send_emoticon(index: int) -> bool: emotes.append(index); return true + func say(_type: int, text: String) -> bool: commands.append(text); return true + func get_main_vid() -> int: return main + func get_entity(_vid: int) -> Dictionary: return {"mount_vnum": 123} + func get_inventory() -> Array: return [{"cell": 7, "vnum": 71114}] + func use_item(_window: int, cell: int) -> bool: used_items.append(cell); return true + +class FakeQuickbar extends Node: + var activated := [] + var pages := [] + func activate(slot: int) -> void: activated.append(slot) + func set_page(page: int) -> void: pages.append(page) + +class FakeNetPlay extends Node: + var attack_states := [] + func set_attack_key(down: bool) -> void: attack_states.append(down) + +class FakeCamera extends Camera3D: + var orbit := [] + var zoom := [] + var pitch := [] + func set_key_orbit(direction: int) -> void: orbit.append(direction) + func set_key_zoom(direction: int) -> void: zoom.append(direction) + func set_key_pitch(direction: int) -> void: pitch.append(direction) + +class FakeWorld extends Node: + var names := [] + func set_names_visible(visible: bool) -> void: names.append(visible) + +class FakeChat extends Node: + var toggles := 0 + func is_typing() -> bool: return false + func focus_input() -> void: pass + func toggle_log() -> void: toggles += 1 + +class FakeStatus extends Node: + var states := [] + func open(state: String) -> void: states.append(state) + +class FakeToggle extends Node: + var toggles := 0 + var button_toggles := 0 + func toggle() -> void: toggles += 1 + func toggle_help() -> void: toggles += 1 + func toggle_buttons() -> void: button_toggles += 1 + +var _fail := 0 + +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _key(code: Key, pressed := true, ctrl := false, meta := false) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = code + event.pressed = pressed + event.echo = false + event.ctrl_pressed = ctrl + event.meta_pressed = meta + return event + +func _init() -> void: + _run() + if _fail == 0: + print("PASS: input_key_test (ClientVS22 desktop key map)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var client := FakeClient.new() + var qb := FakeQuickbar.new() + var np := FakeNetPlay.new() + var camera := FakeCamera.new() + var world := FakeWorld.new() + var chat := FakeChat.new() + var status := FakeStatus.new() + var help := FakeToggle.new() + var quest := FakeToggle.new() + var gs: Node = GameScene.new() + gs.client = client + gs.quickbar = qb + gs.net_play = np + gs.cam = camera + gs.net_world = world + gs.chat = chat + gs.char_status_ui = status + gs.system_menu_ui = help + gs.quest_log = quest + get_root().add_child(gs) + + gs._unhandled_input(_key(KEY_1, true, true, false)) + gs._unhandled_input(_key(KEY_9, true, false, true)) + _ck(client.emotes == [0, 8], "Ctrl/Command+number -> matching emoticon") + gs._unhandled_input(_key(KEY_1)) + gs._unhandled_input(_key(KEY_F1)) + gs._unhandled_input(_key(KEY_F4)) + _ck(qb.activated == [0, 4, 7], "number/F1-F4 -> reference local quick slots") + gs._unhandled_input(_key(KEY_SHIFT)) + gs._unhandled_input(_key(KEY_4)) + gs._unhandled_input(_key(KEY_SHIFT, false)) + _ck(qb.pages == [3], "held Shift + number -> quick page 4") + + gs._unhandled_input(_key(KEY_SPACE)) + gs._unhandled_input(_key(KEY_SPACE, false)) + _ck(np.attack_states == [true, false], "Space press/release -> attack key state") + gs._unhandled_input(_key(KEY_Q)) + gs._unhandled_input(_key(KEY_Q, false)) + gs._unhandled_input(_key(KEY_R)) + gs._unhandled_input(_key(KEY_R, false)) + _ck(camera.orbit == [-1, 0] and camera.zoom == [-1, 0], "camera Q/R release stops continuous motion") + gs._unhandled_input(_key(KEY_ALT)) + gs._unhandled_input(_key(KEY_ALT, false)) + _ck(world.names == [true, false], "Alt press/release -> ShowName/HideName") + + gs._unhandled_input(_key(KEY_B)) + gs._unhandled_input(_key(KEY_V)) + gs._unhandled_input(_key(KEY_C)) + gs._unhandled_input(_key(KEY_H)) + gs._unhandled_input(_key(KEY_N)) + _ck(status.states == ["EMOTICON", "SKILL", "STATUS"], "character tabs match B/V/C") + _ck(help.toggles == 1 and quest.toggles == 1, "H help and N quest log") + gs._unhandled_input(_key(KEY_Q, true, true)) + _ck(quest.button_toggles == 1, "Ctrl+Q toggles quest buttons") + gs._unhandled_input(_key(KEY_L)) + _ck(chat.toggles == 1, "L toggles chat log") + gs._unhandled_input(_key(KEY_H, true, true)) + gs._unhandled_input(_key(KEY_B, true, false, true)) + gs._unhandled_input(_key(KEY_J, true, true)) + _ck(client.commands == ["/user_horse_ride", "/user_horse_back", "/unmount"], + "Ctrl/Command horse commands follow reference") diff --git a/project/inventory_test.gd b/project/inventory_test.gd index 8e78a628..f95d4fa4 100644 --- a/project/inventory_test.gd +++ b/project/inventory_test.gd @@ -23,6 +23,14 @@ func _check(ok: bool, message: String) -> void: func _init() -> void: var inv := InventoryUI.new() _check(inv._to_wire(0) == [1, 0], "normal inventory uses INVENTORY cell") + # 旧 11 格 wear(BODY..SHIELD)走 EQUIPMENT 窗(window=2, 零基 wear cell)。 + _check(inv._to_wire(90) == [2, 0], "wear body uses EQUIPMENT window cell 0") + _check(inv._to_wire(100) == [2, 10], "wear shield uses EQUIPMENT window cell 10") + # ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 留在 INVENTORY 窗全局 cell(GameType.h)。 + _check(inv._to_wire(109) == [1, 109], "costume body uses legacy global cell 109") + _check(inv._to_wire(110) == [1, 110], "costume hair uses legacy global cell 110") + _check(inv._to_wire(111) == [1, 111], "ring1 uses legacy global cell 111") + _check(inv._to_wire(112) == [1, 112], "ring2 uses legacy global cell 112") _check(inv._to_wire(113) == [1, 113], "wear belt uses legacy global cell 113") _check(inv._to_wire(200) == [1, 152], "belt UI cell 0 uses global cell 152") _check(inv._to_wire(215) == [1, 167], "belt UI cell 15 uses global cell 167") diff --git a/project/inventory_ui_test.gd b/project/inventory_ui_test.gd index f4beef36..a6f57de2 100644 --- a/project/inventory_ui_test.gd +++ b/project/inventory_ui_test.gd @@ -26,7 +26,9 @@ class FakeClient extends Node: class FakeProto extends Node: func item(vnum: int) -> Dictionary: - return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, "type": 1} + # type 1 = WEAPON;给出可穿戴位 + 空限制,让 EquipRules 前置门放行。 + return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum, + "type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4, "limits": []} var _fail := 0 func _ck(c: bool, m: String) -> void: @@ -61,6 +63,10 @@ func _run() -> void: fc.equip[1] = {"vnum": 11209, "count": 1, "wear": 1} var iv: Node = InventoryUI.new() + var mouse := preload("res://ui/mouse_controller.gd").new() + get_root().add_child(mouse) + mouse.setup(ui) + iv.item_mouse = mouse get_root().add_child(iv) iv.setup(ui, fc, fp, assets) iv.open() @@ -88,6 +94,10 @@ func _run() -> void: # 拖 5 -> 91(背包 -> 装备) iv.move_to(5, 91, 1) _ck(fc.moved.size() == 1 and fc.moved[0] == [1, 5, 2, 1, 1], "move_to(5,91) -> move_item(1,5,2,1,1)") + _ck(iv._drop_mouse_item({"window": 1, "cell": 6, "vnum": 27, "count": 3}, 8), + "mouse drop accepts inventory stack") + _ck(fc.moved.size() == 2 and fc.moved[1] == [1, 6, 1, 8, 3], + "mouse drop preserves attached stack count") iv.close() _ck(not iv.is_open(), "window closed") diff --git a/project/item_tooltip_test.gd b/project/item_tooltip_test.gd new file mode 100644 index 00000000..e14746af --- /dev/null +++ b/project/item_tooltip_test.gd @@ -0,0 +1,69 @@ +# item_tooltip_test —— item_proto 固定字段 + itemdesc + 实例 socket/attribute 的回归。 +extends SceneTree + +const ItemTooltip = preload("res://ui/item_tooltip.gd") + +class FakeMobProto extends RefCounted: + func mob(vnum: int) -> Dictionary: + return {"vnum": vnum, "name": "Wolf", "locale_name": "Wolf"} + +var _fail := 0 + +func _ck(condition: bool, message: String) -> void: + if not condition: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + var tooltip := ItemTooltip.new() + var assets := AssetRoot.path() + _ck(tooltip.setup(assets, "en"), "itemdesc en loaded") + var proto := { + "vnum": 71114, + "name": "Wild Boar Seal", + "locale_name": "Wild Boar Seal", + "type": 2, + "values": [12, 30, 0, 0, 0, 2], + "limits": [{"type": 1, "value": 15}, {"type": 0, "value": 0}], + "applies": [{"type": 1, "value": 100}, {"type": 0, "value": 0}, {"type": 0, "value": 0}], + "refined_vnum": 71115, + "refine_set": 42, + } + var instance := { + "count": 3, + "sockets": [28030, 0, -1], + "attrs": [{"type": 5, "value": 7}], + } + var text := tooltip.format(71114, 3, proto, instance) + _ck(text.begins_with("Wild Boar Seal\n数量:3"), "title and stack count") + _ck(text.contains("This seal's magic"), "itemdesc description included") + _ck(text.contains("等级要求:15"), "limit included") + _ck(text.contains("防御力:34"), "armor value formula included") + _ck(text.contains("最大生命:+100"), "proto apply included") + _ck(text.contains("附加力量:+7"), "instance attribute included") + _ck(text.contains("孔 1:#28030") and text.contains("孔 3:损坏"), "instance sockets included") + _ck(text.contains("精炼后:#71115") and text.contains("精炼套件:42"), "refine links included") + var rod_proto := {"locale_name": "Fishing Rod", "type": 13, "values": [30, 0, 120]} + var rod_text := tooltip.format(20000, 1, rod_proto, {"sockets": [120, 0, 0]}) + _ck(rod_text.contains("等级:3") and rod_text.contains("经验:120 / 120") and rod_text.contains("可升级"), + "rod level and experience included") + var fish_text := tooltip.format(27800, 1, {"locale_name": "Fish", "type": 12}, {"sockets": [235, 0, 0]}) + _ck(fish_text.contains("长度:2.35 cm"), "fish length included") + var ds_text := tooltip.format(12345, 1, {"locale_name": "Dragon Soul", "type": 29}, {}) + _ck(ds_text.contains("龙魂石阶段:4") and ds_text.contains("龙魂石强化:4"), "dragon soul stage included") + var special := ItemTooltip.new() + _ck(special.setup(assets, "en", FakeMobProto.new()), "special tooltip sources loaded") + var book_text := special.format(50300, 1, {"locale_name": "Skill Book", "type": 17}, {"sockets": [1, 0, 0]}) + _ck(book_text.begins_with("Three-Way Cut Skill Book"), "skill book title includes skill name") + var poly_text := special.format(70103, 1, {"locale_name": "Polymorph Marble", "type": 19}, {"sockets": [77, 0, 0]}) + _ck(poly_text.begins_with("Wolf Polymorph Marble"), "polymorph title includes monster name") + var timed := special.format(90001, 1, {"locale_name": "Timed", "type": 18, + "limits": [{"type": 7, "value": 0}, {"type": 0, "value": 0}]}, + {"sockets": [int(Time.get_unix_time_from_system()) + 65, 0, 0]}) + _ck(timed.contains("剩余时间:1分5秒"), "realtime limit renders remaining time") + if _fail == 0: + print("PASS: item_tooltip_test") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) diff --git a/project/login.gd b/project/login.gd index fd360658..cea13576 100644 --- a/project/login.gd +++ b/project/login.gd @@ -62,8 +62,8 @@ func _init() -> void: v.add_child(title) _host = _field(v, _loc.t("LOGIN_DEFAULT_SERVERADDR").split(",")[0], "192.168.21.203") - _id = _field(v, _loc.t("LOGIN_ID"), "admin") - _pw = _field(v, _loc.t("LOGIN_PASSWORD"), "123456789") + _id = _field(v, _loc.t("LOGIN_ID"), OS.get_environment("MT_ACCOUNT")) + _pw = _field(v, _loc.t("LOGIN_PASSWORD"), OS.get_environment("MT_PASSWORD")) _pw.secret = true var btn := Button.new() @@ -143,9 +143,10 @@ func _on_entered_game() -> void: _msg("已进入游戏 —— 加载场景") if _layer: _layer.visible = false - if _life: # 让 game_scene 自己的 AppLifecycle 接管 - _life.queue_free() - _life = null + # 兼容启动器没有 AppFlow,因此由这里创建的生命周期协调者贯穿 + # 登录→游戏;GameScene 不再创建第二个协调者,也不能在切屏时销毁它。 + if _life: + _life.bind(_client, _audio) _game = GameScene.new() get_root().add_child(_game) _game.setup(_client, _assets) diff --git a/project/mob_view_test.gd b/project/mob_view_test.gd index 2d87dc2a..995a794f 100644 --- a/project/mob_view_test.gd +++ b/project/mob_view_test.gd @@ -4,6 +4,7 @@ extends SceneTree const MobView = preload("res://ui/mob_view.gd") +const Audio = preload("res://audio.gd") var _fail := 0 func _ck(c: bool, m: String) -> void: @@ -30,6 +31,9 @@ func _run() -> void: return var proto: Object = ClassDB.instantiate("Metin2Proto") get_root().add_child(proto) + var audio: Node = Audio.new() + get_root().add_child(audio) + audio.setup(assets) var mp := assets.path_join("locale/locale/en/mob_proto") if not FileAccess.file_exists(mp) or not proto.call("load_mob_proto", mp): print(" (skip: 无 mob_proto)") @@ -42,6 +46,7 @@ func _run() -> void: _ck(ok, "MobView.build(102 = Wolf) 成功") if not ok: return + mv.set_audio(audio) _ck(mv._dir.get_file() == "wolf", "解到 wolf 目录(%s)" % mv._dir) _ck(mv.model != null and mv.model.get_class() == "Metin2Model", "Metin2Model 建好") _ck(mv._motions.size() >= 3, "motlist.txt 解出 %d 个动作" % mv._motions.size()) @@ -55,6 +60,7 @@ func _run() -> void: var s1: String = mv._state mv.set_anim_state("attack") _ck(mv._state == "attack" and s1 == "run", "set_anim_state 切换记录") + _ck(mv._sound_instances.size() > 0, "MobView attack motion loads paired .mss instances") # 未知 race -> build 失败(返回 null 用占位) var mv2: Node3D = MobView.new() diff --git a/project/mouse_controller_test.gd b/project/mouse_controller_test.gd new file mode 100644 index 00000000..faf2ac84 --- /dev/null +++ b/project/mouse_controller_test.gd @@ -0,0 +1,68 @@ +extends SceneTree + +var _failed := false + +class CursorStub extends Node: + var current := "NORMAL" + func set_cursor(shape: String) -> void: + current = shape + func reset() -> void: + current = "NORMAL" + +class AudioStub extends Node: + var calls: Array[String] = [] + func play_ui(name: String) -> void: + calls.append(name) + +func _init() -> void: + var host := Node.new() + root.add_child(host) + var cur := CursorStub.new() + host.add_child(cur) + var audio := AudioStub.new() + host.add_child(audio) + var mouse := preload("res://ui/mouse_controller.gd").new() + host.add_child(mouse) + mouse.setup(host, cur, audio) + _check(not mouse.is_attached(), "starts detached") + _check(mouse.attach_item(1, 7, 1001, 3), "attach inventory item") + _check(audio.calls == ["pick.wav"], "attach plays pick sound") + _check(mouse.is_attached(), "attached state visible") + _check(int(mouse.attached().get("cell", -1)) == 7, "attached source cell") + _check(cur.current == "ITEM", "item cursor state") + var target := Control.new() + target.position = Vector2.ZERO + target.size = Vector2(32, 32) + host.add_child(target) + mouse.register_target(target, func(payload: Dictionary): + host.set_meta("dropped_cell", int(payload.get("cell", -1))) + return int(payload.get("cell", -1)) == 7) + mouse._drop_at(target.get_global_rect().position + Vector2(4, 4)) + _check(int(host.get_meta("dropped_cell", -1)) == 7, "drop callback receives source payload") + _check(not mouse.is_attached(), "successful drop clears attached state") + _check(audio.calls == ["pick.wav", "drop.wav"], "successful drop plays drop sound") + mouse.attach_money(123) + _check(mouse.is_attached() and int(mouse.attached().get("vnum", 0)) == -1 + and int(mouse.attached().get("count", 0)) == 123, "money has independent attached state") + mouse.cancel() + mouse.cancel() + _check(not mouse.is_attached(), "cancel clears attached state") + _check(cur.current == "NORMAL", "cancel resets cursor") + mouse.attach_item(1, 7, 1001, 1) + mouse._drop_at(Vector2(999, 999)) + _check(not mouse.is_attached(), "invalid drop cancels attached state") + mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT) + mouse.attach_item(1, 7, 1001, 1) + mouse._notification(Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT) + _check(not mouse.is_attached(), "focus loss cancels drag") + _check(audio.calls == ["pick.wav", "drop.wav", "pick.wav", "pick.wav", "loginfail.wav", "pick.wav"], + "invalid drop plays cancel sound and focus loss cancels") + host.queue_free() + print("PASS: mouse_controller (global item drag state + cancel)") + quit(1 if _failed else 0) + +func _check(ok: bool, label: String) -> void: + if ok: + return + _failed = true + push_error("FAIL: " + label) diff --git a/project/name_color.gd b/project/name_color.gd new file mode 100644 index 00000000..da573e38 --- /dev/null +++ b/project/name_color.gd @@ -0,0 +1,175 @@ +# name_color.gd —— CInstanceBase::GetNameColorIndex / GetNameColor 的 1:1 迁移(§8.8) +# +# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/): +# UserInterface/InstanceBaseEffect.cpp:525 GetNameColorIndex —— 逐行照抄 +# UserInterface/InstanceBaseEffect.cpp:520 GetNameColor +# UserInterface/InstanceBaseEffect.cpp:52 SetEmpireNameMode +# UserInterface/InstanceBaseEffect.cpp:75 GetIndexedNameColor +# UserInterface/InstanceBase.h:216 NAMECOLOR_* 枚举(EMPIRE_NUM = 4) +# UserInterface/PythonCharacterManagerModule.cpp:767 chrmgr.NAMECOLOR_* -> C++ 枚举重映射 +# assets/root/introloading.py:264 IntroLoading.__RegisterColor(RGB 注册) +# assets/root/colorInfo.py:11 CHR_NAME_RGB_* +# assets/root/constinfo.py:126 SET_DEFAULT_CHRNAME_COLOR(默认 CHRNAME_COLOR_INDEX = 0 → 帝国名色关) +# +# 纯静态。调用方把网络快照拼成 self_e(目标实例)/ main_e(主角,空 = 无主角实例)/ +# ctx,语义与 entity_rules.gd 完全一致(net_play._attack_ctx / net_world._name_color_ctx +# 同款 ctx:main_vid / duel_mode / duel_opponents / pvp_pairs / gvg_pairs / party_vids)。 +extends RefCounted + +const EntityRules = preload("res://entity_rules.gd") + +# InstanceBase.h 的匿名枚举,EMPIRE_NUM = 4(EMPIRE_NONE/A/B/C)。 +const EMPIRE_NUM := 4 + +# NAMECOLOR_*(EMPIRE_NUM 展开后的实际序号) +const NAMECOLOR_MOB := 0 +const NAMECOLOR_NPC := 1 +const NAMECOLOR_PC := 2 # + GetEmpireID()(0..3)→ 2..5 +const NAMECOLOR_PC_END := 6 +const NAMECOLOR_NORMAL_MOB := 7 +const NAMECOLOR_NORMAL_NPC := 8 +const NAMECOLOR_NORMAL_PC := 9 +const NAMECOLOR_NORMAL_PC_END := 13 +const NAMECOLOR_EMPIRE_MOB := 14 +const NAMECOLOR_EMPIRE_NPC := 15 +const NAMECOLOR_EMPIRE_PC := 16 # + uEmpire → 16..20 +const NAMECOLOR_EMPIRE_PC_END := 20 +const NAMECOLOR_FUNC := 21 +const NAMECOLOR_PK := 22 +const NAMECOLOR_PVP := 23 +const NAMECOLOR_PARTY := 24 +const NAMECOLOR_WARP := 25 +const NAMECOLOR_WAYPOINT := 26 +const NAMECOLOR_NUM := 41 + +const _WHITE := Color(1, 1, 1) + +# IntroLoading.__RegisterColor 通过 chrmgr.RegisterNameColor 写进 g_akD3DXClrName 的槽。 +# chrmgr.NAMECOLOR_PC/NPC/MOB 三个常量经 PythonCharacterManagerModule.cpp:767 重映射 +# 到 NORMAL_PC/NORMAL_NPC/NORMAL_MOB,其余(PK/PVP/PARTY/WARP/WAYPOINT/EMPIRE_*)直通。 +# 未在此表里的索引 = D3DXCOLOR() 默认(透明黑),但 GetNameColorIndex 不会命中它们。 +static func _registered_slots() -> Dictionary: + return { + NAMECOLOR_NORMAL_MOB: Color8(235, 22, 9), + NAMECOLOR_NORMAL_NPC: Color8(122, 231, 93), + NAMECOLOR_NORMAL_PC: Color8(255, 215, 76), + NAMECOLOR_PK: Color8(180, 100, 0), + NAMECOLOR_PVP: Color8(238, 54, 223), + NAMECOLOR_PARTY: Color8(128, 192, 255), + NAMECOLOR_WARP: Color8(136, 218, 241), + NAMECOLOR_WAYPOINT: Color8(255, 255, 255), + NAMECOLOR_EMPIRE_MOB: Color8(235, 22, 9), + NAMECOLOR_EMPIRE_NPC: Color8(122, 231, 93), + NAMECOLOR_EMPIRE_PC + 1: Color8(157, 0, 0), + NAMECOLOR_EMPIRE_PC + 2: Color8(222, 160, 47), + NAMECOLOR_EMPIRE_PC + 3: Color8(23, 30, 138), + } + +# g_akD3DXClrName 的当前解析态(SetEmpireNameMode 重写 MOB/NPC + PC..PC+3 槽)。 +static var _palette := {} +static var _empire_name_mode := false + +static func _ensure_palette() -> void: + if _palette.is_empty(): + set_empire_name_mode(false) + +static func is_empire_name_mode() -> bool: + _ensure_palette() + return _empire_name_mode + +# CInstanceBase::SetEmpireNameMode(InstanceBaseEffect.cpp:52)。默认关(constinfo +# .CHRNAME_COLOR_INDEX == 0 → SET_DEFAULT_CHRNAME_COLOR → SetEmpireNameMode(0))。 +static func set_empire_name_mode(enabled: bool) -> void: + _empire_name_mode = enabled + var p := _registered_slots() + if enabled: + p[NAMECOLOR_MOB] = p[NAMECOLOR_EMPIRE_MOB] + p[NAMECOLOR_NPC] = p[NAMECOLOR_EMPIRE_NPC] + p[NAMECOLOR_PC] = p[NAMECOLOR_NORMAL_PC] + for u in range(1, EMPIRE_NUM): + p[NAMECOLOR_PC + u] = p.get(NAMECOLOR_EMPIRE_PC + u, _WHITE) + else: + p[NAMECOLOR_MOB] = p[NAMECOLOR_NORMAL_MOB] + p[NAMECOLOR_NPC] = p[NAMECOLOR_NORMAL_NPC] + for u in range(0, EMPIRE_NUM): + p[NAMECOLOR_PC + u] = p[NAMECOLOR_NORMAL_PC] + _palette = p + +# CInstanceBase::GetIndexedNameColor(InstanceBaseEffect.cpp:75) +static func indexed_name_color(index: int) -> Color: + _ensure_palette() + if index < 0 or index >= NAMECOLOR_NUM: + return _WHITE + return _palette.get(index, _WHITE) + +# --- 类别谓词(entity_rules 已有 _is_pc/_is_enemy/_is_poly;NPC 这里补) --------- + +static func _is_npc(e: Dictionary) -> bool: + return EntityRules._kind(e) == EntityRules.KIND_NPC + +static func _empire_id(e: Dictionary) -> int: + # GetEmpireID() → m_dwEmpireID(0 = 无,1..3)。防御性 clamp 到合法槽。 + return clampi(int(e.get("empire", 0)), 0, EMPIRE_NUM - 1) + +# --- CInstanceBase::GetNameColorIndex(InstanceBaseEffect.cpp:525)—— 逐行 -------- + +static func name_color_index(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> int: + if EntityRules._is_pc(self_e): + # if (m_isKiller) return NAMECOLOR_PK; + if EntityRules._is_killer(self_e): + return NAMECOLOR_PK + + var self_vid := int(self_e.get("vid", 0)) + var main_vid := int(ctx.get("main_vid", int(main_e.get("vid", 0)))) + var is_main := self_vid != 0 and self_vid == main_vid + + # if (__IsExistMainInstance() && !__IsMainInstance()) + if not main_e.is_empty() and not is_main: + var vid_main := int(main_e.get("vid", main_vid)) + + # if (pkInstMain->GetDuelMode()) switch(...) + var duel := int(ctx.get("duel_mode", 0)) + if duel != 0: + match duel: + EntityRules.DUEL_CANNOTATTACK: + return NAMECOLOR_PC + _empire_id(self_e) + EntityRules.DUEL_START: + if EntityRules._find_duel_key(vid_main, self_vid, ctx): + return NAMECOLOR_PVP + return NAMECOLOR_PC + _empire_id(self_e) + + # if (pkInstMain->IsSameEmpire(*this)) + if EntityRules._is_same_empire(main_e, self_e): + if EntityRules._pair_in(ctx.get("pvp_pairs", []), vid_main, self_vid): + return NAMECOLOR_PVP + var guild_main := int(main_e.get("guild", 0)) + var guild_self := int(self_e.get("guild", 0)) + # __FindGVGKey:guild 0 的 PVPKEY 恒为 0,参考端集合里不会有 → + # 这里与 entity_rules._is_pvp_instance 一样加 !=0 防御。 + if guild_main != 0 and guild_self != 0 \ + and EntityRules._pair_in(ctx.get("gvg_pairs", []), guild_main, guild_self): + return NAMECOLOR_PVP + # __FindDUELKey 分支在参考端此处被 /* */ 注释掉,故不实现。 + else: + return NAMECOLOR_PVP + + # IAbstractPlayer::IsPartyMemberByVID(GetVirtualID()) + if self_vid in ctx.get("party_vids", []): + return NAMECOLOR_PARTY + + return NAMECOLOR_PC + _empire_id(self_e) + + elif _is_npc(self_e): + return NAMECOLOR_NPC + elif EntityRules._is_enemy(self_e): + return NAMECOLOR_MOB + elif EntityRules._is_poly(self_e): + return NAMECOLOR_MOB + + # 参考端 fallthrough:`return D3DXCOLOR(0xffffffff);` 被当成 UINT 传给 + # GetIndexedNameColor → 越界 → 白。这里用 -1 表达同一含义。 + return -1 + +# CInstanceBase::GetNameColor(InstanceBaseEffect.cpp:520) +static func name_color(self_e: Dictionary, main_e: Dictionary, ctx: Dictionary) -> Color: + return indexed_name_color(name_color_index(self_e, main_e, ctx)) diff --git a/project/name_color_test.gd b/project/name_color_test.gd new file mode 100644 index 00000000..77718084 --- /dev/null +++ b/project/name_color_test.gd @@ -0,0 +1,180 @@ +# name_color_test —— §8.8 头顶名字色 CInstanceBase::GetNameColorIndex / GetNameColor 1:1: +# NAMECOLOR_* 序号、GetNameColorIndex 逐分支、SetEmpireNameMode 调色板重写、 +# GetIndexedNameColor 越界回退,以及 introloading.__RegisterColor + colorInfo.py 的 RGB。 +# godot --headless --path project --script name_color_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const NameColor = preload("res://name_color.gd") +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 _init() -> void: + _run() + if _fail == 0: + print("PASS: name_color_test (§8.8 GetNameColorIndex / SetEmpireNameMode 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _pc(vid: int, empire := 1, extra := {}) -> Dictionary: + var d := {"vid": vid, "kind": EntityRules.KIND_PC, "empire": empire, "guild": 0, + "alignment": 0, "pk_mode": 0, "state_flags": 0} + for k in extra: + d[k] = extra[k] + return d + +func _ctx(extra := {}) -> Dictionary: + var c := {"main_vid": 1, "duel_mode": 0, "duel_opponents": [], + "pvp_pairs": [], "gvg_pairs": [], "party_vids": []} + for k in extra: + c[k] = extra[k] + return c + +func _run() -> void: + # --- 1. NAMECOLOR_* 序号(InstanceBase.h,EMPIRE_NUM = 4)------------------ + _ck(NameColor.NAMECOLOR_MOB == 0, "NAMECOLOR_MOB == 0") + _ck(NameColor.NAMECOLOR_NPC == 1, "NAMECOLOR_NPC == 1") + _ck(NameColor.NAMECOLOR_PC == 2, "NAMECOLOR_PC == 2") + _ck(NameColor.NAMECOLOR_NORMAL_MOB == 7, "NAMECOLOR_NORMAL_MOB == 7") + _ck(NameColor.NAMECOLOR_NORMAL_NPC == 8, "NAMECOLOR_NORMAL_NPC == 8") + _ck(NameColor.NAMECOLOR_NORMAL_PC == 9, "NAMECOLOR_NORMAL_PC == 9") + _ck(NameColor.NAMECOLOR_EMPIRE_MOB == 14, "NAMECOLOR_EMPIRE_MOB == 14") + _ck(NameColor.NAMECOLOR_EMPIRE_NPC == 15, "NAMECOLOR_EMPIRE_NPC == 15") + _ck(NameColor.NAMECOLOR_EMPIRE_PC == 16, "NAMECOLOR_EMPIRE_PC == 16") + _ck(NameColor.NAMECOLOR_FUNC == 21, "NAMECOLOR_FUNC == 21") + _ck(NameColor.NAMECOLOR_PK == 22, "NAMECOLOR_PK == 22") + _ck(NameColor.NAMECOLOR_PVP == 23, "NAMECOLOR_PVP == 23") + _ck(NameColor.NAMECOLOR_PARTY == 24, "NAMECOLOR_PARTY == 24") + _ck(NameColor.NAMECOLOR_WARP == 25, "NAMECOLOR_WARP == 25") + _ck(NameColor.NAMECOLOR_WAYPOINT == 26, "NAMECOLOR_WAYPOINT == 26") + _ck(NameColor.NAMECOLOR_NUM == 41, "NAMECOLOR_NUM == 41") + + # --- 2. 帝国名色模式关(默认):GetNameColorIndex 逐分支 -------------------- + NameColor.set_empire_name_mode(false) + _ck(not NameColor.is_empire_name_mode(), "default empire-name-mode OFF") + + var main := _pc(1, 1) + + # 普通 PC(同帝国、无关系)→ NAMECOLOR_PC + empire + _ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1, + "normal PC (same empire) -> NAMECOLOR_PC + empire(1)") + _ck(NameColor.name_color_index(_pc(2, 3), _pc(1, 3), _ctx()) == NameColor.NAMECOLOR_PC + 3, + "normal PC empire 3 (same-empire main) -> NAMECOLOR_PC + 3") + # 关模式下 PC..PC+3 槽全解析成 NORMAL_PC(黄) + _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)), + "empire-off: PC name colour == CHR_NAME_RGB_PC (255,215,76)") + _ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(255, 215, 76)), + "empire-off: empire-3 PC still yellow") + + # m_isKiller → PK(最先判,压过一切) + var killer := _pc(2, 1, {"state_flags": EntityRules.STATE_KILLER}) + _ck(NameColor.name_color_index(killer, main, _ctx()) == NameColor.NAMECOLOR_PK, + "killer PC -> NAMECOLOR_PK") + _ck(NameColor.name_color(killer, main, _ctx()).is_equal_approx(Color8(180, 100, 0)), + "PK name colour == CHR_NAME_RGB_PK (180,100,0)") + + # 队伍成员 → PARTY + _ck(NameColor.name_color_index(_pc(7, 1), main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PARTY, + "party member -> NAMECOLOR_PARTY") + _ck(NameColor.name_color(_pc(7, 1), main, _ctx({"party_vids": [7]})).is_equal_approx(Color8(128, 192, 255)), + "PARTY name colour == CHR_NAME_RGB_PARTY (128,192,255)") + # 但 killer 优先于 party + _ck(NameColor.name_color_index(_pc(7, 1, {"state_flags": EntityRules.STATE_KILLER}), + main, _ctx({"party_vids": [7]})) == NameColor.NAMECOLOR_PK, + "killer beats party") + + # 异帝国 → PVP + _ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PVP, + "different empire -> NAMECOLOR_PVP") + _ck(NameColor.name_color(_pc(2, 2), _pc(1, 1), _ctx()).is_equal_approx(Color8(238, 54, 223)), + "PVP name colour == CHR_NAME_RGB_PVP (238,54,223)") + # victim empire 0 视作同帝国(IsSameEmpire 短路 true)→ 不判 PVP + _ck(NameColor.name_color_index(_pc(2, 0), _pc(1, 1), _ctx()) == NameColor.NAMECOLOR_PC + 0, + "victim empire 0 -> same-empire, NAMECOLOR_PC + 0") + + # 同帝国 + PVP 对表命中 → PVP + _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"pvp_pairs": [[1, 2]]})) == NameColor.NAMECOLOR_PVP, + "same empire + pvp pair -> NAMECOLOR_PVP") + # 同帝国 + 公会战对表命中(两边 guild 非 0)→ PVP + _ck(NameColor.name_color_index(_pc(2, 1, {"guild": 20}), _pc(1, 1, {"guild": 10}), + _ctx({"gvg_pairs": [[10, 20]]})) == NameColor.NAMECOLOR_PVP, + "same empire + gvg pair -> NAMECOLOR_PVP") + # guild 0 不触发公会战(防御性 !=0) + _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), _ctx({"gvg_pairs": [[0, 0]]})) == NameColor.NAMECOLOR_PC + 1, + "guild 0 gvg pair ignored") + + # 决斗:主角 DUEL_CANNOTATTACK → 恒 NAMECOLOR_PC + empire(即便异帝国) + _ck(NameColor.name_color_index(_pc(2, 2), _pc(1, 1), + _ctx({"duel_mode": EntityRules.DUEL_CANNOTATTACK})) == NameColor.NAMECOLOR_PC + 2, + "main DUEL_CANNOTATTACK -> NAMECOLOR_PC + empire, no PVP") + # 决斗:主角 DUEL_START + 对手命中 → PVP;未命中 → NAMECOLOR_PC + empire + _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), + _ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [2]})) == NameColor.NAMECOLOR_PVP, + "main DUEL_START + opponent -> NAMECOLOR_PVP") + _ck(NameColor.name_color_index(_pc(2, 1), _pc(1, 1), + _ctx({"duel_mode": EntityRules.DUEL_START, "duel_opponents": [99]})) == NameColor.NAMECOLOR_PC + 1, + "main DUEL_START, not an opponent -> NAMECOLOR_PC + empire") + + # 无主角实例(main_e 空)→ 跳过相对块,直接 party / NAMECOLOR_PC + empire + _ck(NameColor.name_color_index(_pc(2, 2), {}, _ctx()) == NameColor.NAMECOLOR_PC + 2, + "no main instance -> NAMECOLOR_PC + empire (no PVP)") + # 自己(vid == main_vid)→ 跳过相对块 + _ck(NameColor.name_color_index(_pc(1, 2), _pc(1, 2), _ctx({"main_vid": 1})) == NameColor.NAMECOLOR_PC + 2, + "self -> NAMECOLOR_PC + empire") + + # NPC / MOB / poly / 未知 + _ck(NameColor.name_color_index({"kind": EntityRules.KIND_NPC}, main, _ctx()) == NameColor.NAMECOLOR_NPC, + "NPC -> NAMECOLOR_NPC") + _ck(NameColor.name_color({"kind": EntityRules.KIND_NPC}, main, _ctx()).is_equal_approx(Color8(122, 231, 93)), + "NPC name colour == CHR_NAME_RGB_NPC (122,231,93)") + _ck(NameColor.name_color_index({"kind": EntityRules.KIND_MONSTER}, main, _ctx()) == NameColor.NAMECOLOR_MOB, + "MONSTER -> NAMECOLOR_MOB") + _ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)), + "MOB name colour == CHR_NAME_RGB_MOB (235,22,9)") + _ck(NameColor.name_color_index({"kind": 99, "poly_vnum": 101}, main, _ctx()) == NameColor.NAMECOLOR_MOB, + "standalone poly -> NAMECOLOR_MOB") + _ck(NameColor.name_color_index({"kind": 99}, main, _ctx()) == -1, + "unknown kind -> -1 (fallthrough)") + _ck(NameColor.name_color({"kind": 99}, main, _ctx()).is_equal_approx(Color(1, 1, 1)), + "unknown kind name colour -> white") + + # --- 3. SetEmpireNameMode(true):调色板重写 ------------------------------- + NameColor.set_empire_name_mode(true) + _ck(NameColor.is_empire_name_mode(), "empire-name-mode ON") + # 序号不变,但槽解析改了 + _ck(NameColor.name_color_index(_pc(2, 1), main, _ctx()) == NameColor.NAMECOLOR_PC + 1, + "empire-on: normal PC index unchanged (PC + empire)") + _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(157, 0, 0)), + "empire-on: empire-1 PC -> CHR_NAME_RGB_EMPIRE_PC_A (157,0,0)") + _ck(NameColor.name_color(_pc(2, 2), _pc(1, 2), _ctx()).is_equal_approx(Color8(222, 160, 47)), + "empire-on: empire-2 PC -> CHR_NAME_RGB_EMPIRE_PC_B (222,160,47)") + _ck(NameColor.name_color(_pc(2, 3), _pc(1, 3), _ctx()).is_equal_approx(Color8(23, 30, 138)), + "empire-on: empire-3 PC -> CHR_NAME_RGB_EMPIRE_PC_C (23,30,138)") + # empire 0 的 PC 槽(NAMECOLOR_PC + 0)在 ON 分支仍是 NORMAL_PC(黄) + _ck(NameColor.name_color(_pc(2, 0), _pc(1, 1), _ctx()).is_equal_approx(Color8(255, 215, 76)), + "empire-on: empire-0 PC slot stays NORMAL_PC yellow") + # MOB / NPC 在这套 colorInfo 里帝国色 == 普通色 + _ck(NameColor.name_color({"kind": EntityRules.KIND_MONSTER}, main, _ctx()).is_equal_approx(Color8(235, 22, 9)), + "empire-on: MOB colour unchanged (235,22,9)") + + # 还原默认,避免污染后续(静态状态) + NameColor.set_empire_name_mode(false) + _ck(NameColor.name_color(_pc(2, 1), main, _ctx()).is_equal_approx(Color8(255, 215, 76)), + "restore empire-off: PC yellow again") + + # --- 4. GetIndexedNameColor 越界 → 白 ----------------------------------- + _ck(NameColor.indexed_name_color(-1).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(-1) -> white") + _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_NUM).is_equal_approx(Color(1, 1, 1)), + "indexed_name_color(NUM) -> white") + _ck(NameColor.indexed_name_color(9999).is_equal_approx(Color(1, 1, 1)), "indexed_name_color(9999) -> white") + _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WARP).is_equal_approx(Color8(136, 218, 241)), + "indexed_name_color(WARP) == CHR_NAME_RGB_WARP (136,218,241)") + _ck(NameColor.indexed_name_color(NameColor.NAMECOLOR_WAYPOINT).is_equal_approx(Color8(255, 255, 255)), + "indexed_name_color(WAYPOINT) == CHR_NAME_RGB_WAYPOINT (255,255,255)") diff --git a/project/name_show.gd b/project/name_show.gd new file mode 100644 index 00000000..de6eb2d6 --- /dev/null +++ b/project/name_show.gd @@ -0,0 +1,77 @@ +# name_show —— §8.4 / §8.6 头顶名字「是否显示」1:1:CPythonSystem::IsAlwaysShowName / +# SetAlwaysShowNameFlag + game.py::__IsShowName + CPythonTextTail::ShowAllTextTail 的 +# 3500 距离裁剪 + ShowCharacterTextTail 的资格判定 + UpdateAllTextTail 的 bNameFlag 强显。 +# (与 name_color.gd / text_tail.gd / chat_tail.gd 同一套纯静态镜像套路。) +# +# REF(40250 ClientVS22 source/ 为唯一基线): +# UserInterface/PythonSystem.cpp:5 #define DEFAULT_VALUE_ALWAYS_SHOW_NAME true +# UserInterface/PythonSystem.cpp:307 m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME +# UserInterface/PythonSystem.cpp:327 IsAlwaysShowName() -> m_Config.bAlwaysShowName +# UserInterface/PythonSystem.cpp:334 SetAlwaysShowNameFlag(iFlag) -> bAlwaysShowName = (iFlag == 1) +# UserInterface/PythonSystem.cpp:449 config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 +# UserInterface/PythonSystem.cpp:535 save "ALWAYS_VIEW_NAME %d"(仅当 != 默认值时落盘) +# UserInterface/PythonSystemModule.cpp:181/214 systemSetAlwaysShowNameFlag / systemIsAlwaysShowName +# root/uigameoption.py:220 __OnClickAlwaysShowNameOnButton -> systemSetting.SetAlwaysShowNameFlag(True) + RefreshAlwaysShowName() +# root/game.py:323 onPressKeyDict[app.DIK_LALT] -> ShowName() +# root/game.py:406 onClickKeyDict[app.DIK_LALT] -> HideName() +# root/game.py:1143 ShowName() -> self.ShowNameFlag = True +# root/game.py:1149 __IsShowName() -> if IsAlwaysShowName(): return True; if ShowNameFlag: return True; return False +# root/game.py:1533 OnRender:chr.Pick() 命中 -> textTail.ShowCharacterTextTail(idx) +# root/game.py:1535 OnRender:targetBoard.GetTargetVID() != 0 -> textTail.ShowCharacterTextTail(vid) +# root/game.py:1550 OnRender:if __IsShowName(): textTail.ShowAllTextTail() +# UserInterface/PythonTextTail.cpp:416 UpdateDistance:fDistanceFromPlayer = |(ownerX-centerX, -ownerY-centerY)| +# UserInterface/PythonTextTail.cpp:422 ShowAllTextTail:m_CharacterTextTailMap 里 fDistanceFromPlayer < 3500.0f 才 ShowCharacterTextTail +# UserInterface/PythonTextTail.cpp:440 ShowCharacterTextTail:不在图 / 已入列 / !pOwner->isShow() / 无实例 / IsGuildWall() -> return;CanPickInstance() 才入列 +# UserInterface/PythonTextTail.cpp:74 UpdateAllTextTail:chat 尾标 bNameFlag -> ShowCharacterTextTail(不受 3500 限制) +# +# seam ⑩:参考端「名字显示」是每帧重建 m_CharacterTextTailList(Pick / target / ShowAll) +# 再 Render / Arrange(防重叠)/ Hide 的屏幕空间流程;本移植是 net_world 里每个实体一块 +# billboard Label3D,靠本模块谓词逐帧 set .visible。鼠标悬停 chr.Pick() 未接(net_world +# 无悬停拾取)、屏幕空间 ArrangeTextTail 防重叠未做 —— 记为 seam ⑩。 +# 另:参考 UpdateDistance 因 owner 在渲染帧(Y 取反)、center 在像素帧才写成 +# (ownerX-centerX, -ownerY-centerY);本移植所有坐标统一在 pos_cm 帧,等价于同帧平面距。 +extends RefCounted + +const ALWAYS_SHOW_NAME_DEFAULT := true # DEFAULT_VALUE_ALWAYS_SHOW_NAME +const SHOW_ALL_DISTANCE := 3500.0 # ShowAllTextTail 的 fDistanceFromPlayer < 3500(px==cm) +const CFG_KEY := "ALWAYS_VIEW_NAME" # PythonSystem 配置行 / 存盘键 + +# SetAlwaysShowNameFlag(int iFlag): m_Config.bAlwaysShowName = iFlag == 1 +static func always_show_from_flag(flag: int) -> bool: + return flag == 1 + +# config "ALWAYS_VIEW_NAME" 行:atoi(value) == 1 +static func always_show_from_cfg(value: Variant) -> bool: + return int(value) == 1 + +# __IsShowName() = IsAlwaysShowName() or ShowNameFlag(LALT 按住) +static func is_show_name(always_show_name: bool, alt_held: bool) -> bool: + return always_show_name or alt_held + +# ShowAllTextTail:只有平面距离 < 3500 的角色尾标才 ShowCharacterTextTail +static func within_show_all(dist_cm: float) -> bool: + return dist_cm < SHOW_ALL_DISTANCE + +# UpdateDistance 的平面距。net_world 的 pos_cm 已是统一帧,直接同帧平面距 +# (参考端的 -ownerY 是渲染帧 / 像素帧混用的产物,见文件头 seam ⑩ 注)。 +static func planar_distance_cm(a_cm: Vector2, b_cm: Vector2) -> float: + return a_cm.distance_to(b_cm) + +# ShowCharacterTextTail 的资格判定(把早退条件取反): +# pOwner->isShow()(可见 / 未死透)、!IsGuildWall()、CanPickInstance() +static func eligible_character(is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool: + return is_shown and not is_guild_wall and can_pick + +# 综合:某实体这一帧的头顶名字是否显示。 +# forced —— chr.Pick() 命中 / targetBoard VID / chat 尾标 bNameFlag:都走 ShowCharacterTextTail, +# 但绕过 ShowAllTextTail 的 3500 裁剪(资格判定仍要过)。 +# 非 forced —— 需 __IsShowName() 为真 且 距离 < 3500 且 资格通过。 +static func name_visible_for(forced: bool, show_name: bool, dist_cm: float, + is_shown: bool, is_guild_wall: bool, can_pick: bool) -> bool: + if not eligible_character(is_shown, is_guild_wall, can_pick): + return false + if forced: + return true + if not show_name: + return false + return within_show_all(dist_cm) diff --git a/project/name_show_test.gd b/project/name_show_test.gd new file mode 100644 index 00000000..02a69bcb --- /dev/null +++ b/project/name_show_test.gd @@ -0,0 +1,79 @@ +# name_show_test —— §8.4 / §8.6 头顶名字「是否显示」1:1: +# CPythonSystem::IsAlwaysShowName / SetAlwaysShowNameFlag(flag / 配置行 == 1)、 +# game.py::__IsShowName(IsAlwaysShowName or ShowNameFlag)、 +# CPythonTextTail::ShowAllTextTail 的 fDistanceFromPlayer < 3500、 +# ShowCharacterTextTail 的 isShow / !IsGuildWall / CanPickInstance 资格判定, +# 以及 forced(Pick / target / bNameFlag)绕过 3500 但仍过资格判定。 +# godot --headless --path project --script name_show_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const NameShow = preload("res://name_show.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: name_show_test (§8.4/§8.6 IsAlwaysShowName / __IsShowName / ShowAllTextTail 3500 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # --- 0. 常量(PythonSystem.cpp / PythonTextTail.cpp)------------------------ + _ck(NameShow.ALWAYS_SHOW_NAME_DEFAULT == true, "DEFAULT_VALUE_ALWAYS_SHOW_NAME == true") + _ck(is_equal_approx(NameShow.SHOW_ALL_DISTANCE, 3500.0), "ShowAllTextTail 距离阈 == 3500") + _ck(NameShow.CFG_KEY == "ALWAYS_VIEW_NAME", "配置键 == ALWAYS_VIEW_NAME") + + # --- 1. SetAlwaysShowNameFlag(iFlag): bAlwaysShowName = iFlag == 1 ---------- + _ck(NameShow.always_show_from_flag(1) == true, "flag 1 -> true") + _ck(NameShow.always_show_from_flag(0) == false, "flag 0 -> false") + _ck(NameShow.always_show_from_flag(2) == false, "flag 2 -> false(严格 == 1)") + + # --- 2. config "ALWAYS_VIEW_NAME" -> atoi(value) == 1 --------------------- + _ck(NameShow.always_show_from_cfg("1") == true, "cfg \"1\" -> true") + _ck(NameShow.always_show_from_cfg("0") == false, "cfg \"0\" -> false") + _ck(NameShow.always_show_from_cfg("2") == false, "cfg \"2\" -> false") + _ck(NameShow.always_show_from_cfg(1) == true, "cfg int 1 -> true") + + # --- 3. __IsShowName() = IsAlwaysShowName() or ShowNameFlag --------------- + _ck(NameShow.is_show_name(false, false) == false, "都关 -> 不显示") + _ck(NameShow.is_show_name(true, false) == true, "总显示名字开 -> 显示") + _ck(NameShow.is_show_name(false, true) == true, "LALT 按住 -> 显示") + _ck(NameShow.is_show_name(true, true) == true, "都开 -> 显示") + + # --- 4. ShowAllTextTail:fDistanceFromPlayer < 3500.0f ------------------- + _ck(NameShow.within_show_all(0.0) == true, "距 0 在范围内") + _ck(NameShow.within_show_all(3499.9) == true, "距 3499.9 在范围内") + _ck(NameShow.within_show_all(3500.0) == false, "距 3500.0 严格 < 不含边界") + _ck(NameShow.within_show_all(5000.0) == false, "距 5000 超范围") + + # --- 5. UpdateDistance 平面距(本移植同帧)------------------------------ + _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(0, 0), Vector2(3, 4)), 5.0), "(0,0)-(3,4) == 5") + _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(10, 10), Vector2(10, 10)), 0.0), "同点距 0") + _ck(is_equal_approx(NameShow.planar_distance_cm(Vector2(-100, 0), Vector2(200, 0)), 300.0), "x 轴距 300") + + # --- 6. ShowCharacterTextTail 资格:isShow && !IsGuildWall && CanPickInstance + _ck(NameShow.eligible_character(true, false, true) == true, "可见 / 非墙 / 可拾取 -> 合格") + _ck(NameShow.eligible_character(false, false, true) == false, "!isShow() -> 早退") + _ck(NameShow.eligible_character(true, true, true) == false, "IsGuildWall() -> 早退") + _ck(NameShow.eligible_character(true, false, false) == false, "!CanPickInstance() -> 不入列") + + # --- 7. name_visible_for 综合 ------------------------------------------ + # 公会墙:无论 forced 与否都不显示 + _ck(NameShow.name_visible_for(true, true, 0.0, true, true, true) == false, "公会墙即便 forced 也不显示") + # forced(Pick / target / bNameFlag):绕过 3500,但资格仍要过 + _ck(NameShow.name_visible_for(true, false, 9999.0, true, false, true) == true, "forced + 合格 + 超距 -> 显示(绕过 3500)") + _ck(NameShow.name_visible_for(true, true, 0.0, false, false, true) == false, "forced 但 !isShow() -> 不显示") + _ck(NameShow.name_visible_for(true, true, 0.0, true, false, false) == false, "forced 但 !CanPickInstance() -> 不显示") + # 非 forced:要 __IsShowName() 且 < 3500 且 合格 + _ck(NameShow.name_visible_for(false, false, 100.0, true, false, true) == false, "非 forced + __IsShowName 假 -> 不显示") + _ck(NameShow.name_visible_for(false, true, 1000.0, true, false, true) == true, "非 forced + 显示开 + 距 1000 + 合格 -> 显示") + _ck(NameShow.name_visible_for(false, true, 4000.0, true, false, true) == false, "非 forced + 显示开 但 距 4000 -> 被 3500 裁掉") + _ck(NameShow.name_visible_for(false, true, 1000.0, false, false, true) == false, "非 forced + 显示开 + 近 但 死透 -> 不显示") diff --git a/project/net_play.gd b/project/net_play.gd index efea0288..474c601d 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -2,7 +2,7 @@ # # 点地移动 -> M2Client.move(FUNC_MOVE, …) (节流:>1m 位移或 >0.2s) # 停下 -> M2Client.move(FUNC_WAIT, …) -# 点实体 -> M2Client.set_target(vid);进入攻击距离后按攻速自动 attack() +# 点实体 -> M2Client.set_target(vid);按住 SPACE 才在攻击距离内按攻速 attack() # 收到自己的 GC_MOVE -> 位置校正(差 >snap 瞬移,否则 lerp) # points_changed / vitals_changed / target_info -> HUD # @@ -16,23 +16,94 @@ extends Node signal target_changed(vid: int) +# OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码(game.py 同名回调), +# 供 HUD 弹 localeInfo 文案。§3.4 +signal cannot_act(code: String) +# PythonPlayerInput::OnFishingWrongPlace 的独立反馈通道。当前场景若没有 +# 水面查询器不会误报,等 Metin2World 接上水面采样后由这里统一上抛。 +signal fishing_feedback(code: String) +# 弓动作起手:本次挥击的 uSkill(普攻 0)。对齐 +# CNormalBowAttack_FlyEventHandler_AutoClear —— OnSetFlyTarget() 已在 _emit_swing 里 +# 直接发 CG_FLY_TARGETING,剩下的 OnShoot(uSkill) 要等 `.msa` MOTION_EVENT_TYPE_FLY 帧, +# 由 game_scene 收到本信号后入队、在 _on_local_motion_event(6) 里 pop 出来发 CG_SHOOT。§3.6 +signal bow_shot_fired(skill: int) + +# CPythonPlayer 的预约动作模式(PythonPlayerInput.cpp:820-856)。每帧在 +# _refresh_reserved()(≈ NEW_RefreshMouseWalkingDirection)里按模式分派。 +enum ReservedMode { NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL } + +# 扇形 / 圆形多目标 fly-targeting 的范围形状(skilldesc / .msk 的 +# SKILL_ATTRIBUTE_FLAG_FAN_RANGE / _CIRCLE_RANGE,PythonSkill.cpp:1103/1111)。 +enum FlyShape { SINGLE, FAN, CIRCLE } +# NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)的扇形半角随距离收窄: +# fHalfFanRot = (MAX-MIN) - RPM * min(dist, MIN_DIST) + MIN, RPM = (MAX-MIN)/MIN_DIST +const FAN_HALF_ROT_MIN := 20.0 +const FAN_HALF_ROT_MAX := 40.0 +const FAN_HALF_ROT_MIN_DISTANCE := 1000.0 +# NEW_GetRandomPositionInFanRange(:124):目标方位 ±10° 抖动、沿该方向 8000 单位取点。 +const FAN_RANDOM_JITTER_DEG := 10.0 +const FAN_RANDOM_REACH_CM := 8000.0 +# MODE_CLICK_ITEM 的到手阈值(PythonPlayerInputMouse.cpp:297 `< 20.0f`,单位 cm)。 +const CLICK_ITEM_PICKUP_CM := 20.0 +# MODE_USE_SKILL 的巨型种族距离修正(PythonPlayerInputMouse.cpp:352 `fDistance -= 200.0f`)。 +const HUGE_RACE_DIST_FIX_CM := 200.0 +# _is_huge_race 的 mob_proto 近似阈值(size / rank 见 proto_node.cpp 的 mob() 字典)。 +const HUGE_RACE_SIZE := 3 # MOBSIZE_HUGE +const HUGE_RACE_RANK := 4 # MOB_RANK_BOSS +# __ReserveUseSkill(PythonPlayerInput.cpp:854):range > 100 时先减 10 再存。 +const RESERVE_SKILL_RANGE_TRIM_CM := 10.0 const FUNC_WAIT := 0 const FUNC_MOVE := 1 +const FUNC_COMBO := 3 +const FUNC_SKILL := 0x80 const CM := 100.0 # 米 -> 厘米 -const SEND_INTERVAL := 0.2 # 移动包最小间隔(秒) -const SEND_DIST := 1.0 # 或位移超过这么多米立即发 -const ATTACK_RANGE := 2.5 # 米 + +# §5.4 —— PVP / PK / 决斗可攻击判据(CInstanceBase::IsAttackableInstance 1:1,逐行)。 +const EntityRules = preload("res://entity_rules.gd") +# §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调): +# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口 +# OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE +# OnWaiting—— 站立中周期上行:100 ms 节流且相对上次上行位移 ≥ 1 cm 才发 FUNC_WAIT +# OnStop —— 停下收尾:不节流,发 FUNC_WAIT +# OnAttack —— 不节流,发 FUNC_COMBO + motion index(见 _do_attack_swing) +# OnUseSkill—— 不节流,发 FUNC_SKILL|motion + arg(§3.8 施法路径) +const SEND_MOVING_INTERVAL := 0.3 # OnMoving 最小间隔(秒) +const SEND_WAITING_INTERVAL := 0.1 # OnWaiting 最小间隔(秒) +const SEND_WAITING_MIN_DISP_CM := 1.0 # OnWaiting 触发的最小位移(服务器 cm) +# 可点击 / 攻击距离(厘米),照 `InstanceBaseBattle.cpp:294` +# `NEW_IsClickableDistanceDestInstance`:默认 150,NPC 500,采集物 100,弓箭走 __GetBowRange。 +const CLICK_DIST_DEFAULT_CM := 150.0 +const CLICK_DIST_NPC_CM := 500.0 +const CLICK_DIST_RESOURCE_CM := 100.0 +# CInstanceBase::__GetBowRange(InstanceBase.cpp:694):基础 2500-100 cm, +# 主角再加 GetStatus(POINT_BOW_DISTANCE)(参考端直接按 cm 相加,不 ×100)。 +const BOW_RANGE_BASE_CM := 2500.0 - 100.0 const CORRECTION_SNAP := 6.0 # 服务器位置差超过就瞬移 const CORRECTION_LERP := 0.25 # 否则每次校正插值比例 +# §3.5 修改 4 —— 命中判定(对齐 `ActorInstanceCollisionDetection.cpp:333 __NormalAttackProcess` +# + `InstanceBaseBattle.cpp:413 AttackProcess` + `PythonPlayerEventHandler.cpp:135 OnHit` / +# `:194 FlushVictimList`)。`NRaceData::EMotionType`(GameType.h:52): +const MOTION_TYPE_NONE := 0 +const MOTION_TYPE_NORMAL := 1 +const MOTION_TYPE_COMBO := 2 +const MOTION_TYPE_SKILL := 3 +const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadius(ActorInstanceCollisionDetection.cpp:355) +const HIT_VICTIM_RADIUS_CM := 45.0 # 受击方防御圆柱近似半径(真实取自 defending sphere 数据) +const HIT_FRONT_COS := 0.5 # 正面 ±60° 弧内才判定命中(近似动态圆柱扫掠) +const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT + var client: Node # M2Client var pc: Node # PlayerController var net_world: Node # NetWorld var hud: Node # 可空 -var attack_motion := 0 # 普通攻击 motion index(P4 再按 race 细分) -const DEFAULT_ATTACK_PERIOD := 0.6 # 秒/次,100 = 原始动作速率 -var attack_period := DEFAULT_ATTACK_PERIOD +# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime` +# (`CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。 +# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。 +const DEFAULT_ATTACK_PERIOD := 0.6 +var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取 +var _atk_speed_factor := 1.0 # bAttackSpeed / 100(0.25..3),只缩放播放速率 var camera: Node # GameCamera(可空,用于受击抖屏) var player_view: Node # PlayerView(可空,set_anim_state("damage")) @@ -40,19 +111,105 @@ var proto: Node # Metin2Proto(可空,按 race 分类 NPC/ const DAMAGE_DODGE := 1 << 2 const DAMAGE_CRITICAL := 1 << 5 -const HITSTUN := 0.32 # 受击硬直秒数(锁输入) -const COMBO_WINDOW := 0.9 # 连击窗口 + +# --- §3.5 连击段(对齐 CActorInstance,ActorInstanceBattle.cpp:157-330)--- +# chr.MOTION_MODE_*(CRaceMotionData::EMode,RaceMotionData.h:30)——完整枚举 +const MOTION_MODE_RESERVED := 0 +const MOTION_MODE_GENERAL := 1 +const MOTION_MODE_ONEHAND_SWORD := 2 +const MOTION_MODE_TWOHAND_SWORD := 3 +const MOTION_MODE_DUALHAND_SWORD := 4 +const MOTION_MODE_BOW := 5 +const MOTION_MODE_FAN := 6 +const MOTION_MODE_BELL := 7 +const MOTION_MODE_FISHING := 8 +const MOTION_MODE_HORSE := 9 +const MOTION_MODE_HORSE_ONEHAND_SWORD := 10 +const MOTION_MODE_HORSE_TWOHAND_SWORD := 11 +const MOTION_MODE_HORSE_DUALHAND_SWORD := 12 +const MOTION_MODE_HORSE_BOW := 13 +const MOTION_MODE_HORSE_FAN := 14 +const MOTION_MODE_HORSE_BELL := 15 +const MOTION_MODE_WEDDING_DRESS := 16 +const MOTION_MODE_HORSE_FIRST := 9 # HORSE..HORSE_BELL = 9..15 +const MOTION_MODE_HORSE_LAST := 15 +# CItemData::EItemType(ItemData.h:22)子集 —— 只用到武器 / 钓竿 / 镐 +const ITEM_TYPE_WEAPON := 1 +const ITEM_TYPE_ROD := 13 +const ITEM_TYPE_PICK := 24 +# CItemData::EWeaponSubTypes(ItemData.h:63)—— GetWeaponType() 返回的 bSubType +const WEAPON_SUB_SWORD := 0 +const WEAPON_SUB_DAGGER := 1 +const WEAPON_SUB_BOW := 2 +const WEAPON_SUB_TWO_HANDED := 3 +const WEAPON_SUB_BELL := 4 +const WEAPON_SUB_FAN := 5 +const WEAPON_SUB_ARROW := 6 +# 装备槽 / parts[] 下标(对齐 EquipModel) +const WEAR_WEAPON := 4 +const PART_WEAPON := 1 +# 与 Packet.h 的 FISHING_SUBHEADER_GC_* 一致。 +const FISHING_START := 0 +const FISHING_STOP := 1 +const FISHING_REACT := 2 +const FISHING_SUCCESS := 3 +const FISHING_FAIL := 4 +const FISHING_FISH := 5 +const FISHING_CANCEL_COOLDOWN := 0.5 +# 段号 = CRaceMotionData::NAME_* ;ComboIndexVector 里存的就是这些 +const NAME_NORMAL_ATTACK := 13 +const NAME_COMBO_ATTACK_1 := 14 var _target_vid := 0 var _main_vid := 0 -var _last_sent_pos := Vector3.ZERO -var _last_sent_t := 0.0 +var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标 +var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒) +var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒) var _was_moving := false var _attack_cd := 0.0 -var _hitstun_until := 0.0 -var _combo := 0 -var _last_attack_t := 0.0 +var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing) +var _using_skill := false # 施法中(§3.8 由 player_skill 驱动) +# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo / +# __OnEndCombo / __ClearCombo) +var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shaman(race & 3) +var combo_motion_mode := MOTION_MODE_GENERAL # m_wcurMotionMode(§3.5 修改 6:按装备武器种类 + 骑乘态) +var _combo_type := 0 # m_wcurComboType(SetComboType;连击技能激活时 = MIN(lv,2)) +var _combo_index := 0 # m_dwcurComboIndex(0 = 不在连击中) +var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发) +var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击) +var _combo_started_t := 0.0 # 当前连击段动作起播时刻(GetAttackingElapsedTime 基准) +var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } } +# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer +# + m_HitDataMap + m_kVctkVictim 的等价) +var _swing_start_t := 0.0 # 本次挥击动作起播时刻(命中窗时间基准,独立于连击输入计时) +var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置) +var _hit_windows := [] # get_motion_data().hit_windows(THitDataContainer) +var _hit_motion_type := 0 # get_motion_data().motion_type(NRaceData::EMotionType) +var _hit_limit_count := 0 # get_motion_data().hit_limit_count(SKILL 类的每窗命中上限) +var _hit_invisible_time := 0.0 # get_motion_data().invisible_time(同目标再命中的冷却) +var _hit_dedup := {} # { window_idx:int -> { victim_vid:int -> cooldown_until:float } } +var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}] var _observer_mode := false +var _attack_key_down := false +var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角 +var _last_fishing_cancel_t := -1e9 # NEW_CancelFishing 的 500ms 节流 + +# --- §3.4 预约动作状态机(对齐 PythonPlayerInput.cpp:820-865 / :877 __ReserveProcess_ClickActor +# + PythonPlayer.cpp:207 __Update_AutoAttack)--- +var _reserved_mode: int = ReservedMode.NONE +var _vid_reserved := 0 # m_dwVIDReserved +var _iid_reserved := 0 # m_dwIIDReserved(拾取物,§3.4 后续) +var _ppos_reserved := Vector3.ZERO +var _skill_slot_reserved := -1 +var _skill_range_reserved := 0.0 +var _reserved_delay := 0.0 # 拾取 / 到点前的等待(CLICK_ITEM / CLICK_POSITION) +var _auto_attack_vid := 0 # m_dwAutoAttackTargetVID +var _last_cannot := "" # 最近一次 OnCannot* 码(供测试 / HUD) +# 服务端尚未下发的字段的本地占位(与 knock_down 同套做法,真值待后续增量 / 真服): +var _arrow_count := -1 # -1 = 未知/无限;0 = EMPTY_ARROW +var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBowRange() 加成 +var _dash_active := false # __CheckDashAffect +var _charge_skill := false # 预约技能 IsChargeSkill() func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void: client = m2client @@ -66,6 +223,11 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> client.entity_moved.connect(_on_net_moved) if client.has_signal("entity_info"): client.entity_info.connect(_on_entity_info) + if client.has_signal("inventory_changed"): + client.inventory_changed.connect(_on_inv_changed_mode) # §3.5 修改 6:换武器 → motion mode + if client.has_signal("mount_changed"): + client.mount_changed.connect(func(v): + if v == _main_vid: _refresh_motion_mode()) # §3.5 修改 6:上/下马 → HORSE_* client.points_changed.connect(_on_points) client.vitals_changed.connect(_on_vitals) client.target_info.connect(_on_target_info) @@ -79,9 +241,14 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> client.observer_mode_changed.connect(_on_observer_mode) if client.has_method("is_observer_mode"): _observer_mode = bool(client.is_observer_mode()) + if pc and "skip_actor_collision" in pc: + pc.skip_actor_collision = _observer_mode func _on_observer_mode(enabled: bool) -> void: _observer_mode = enabled + # 观战 = 自由镜头穿行 → CanSkipCollision 语义:本地移动整段跳过 Actor 碰撞。 + if pc and "skip_actor_collision" in pc: + pc.skip_actor_collision = enabled if enabled: _target_vid = 0 _attack_cd = 0.0 @@ -89,6 +256,235 @@ func _on_observer_mode(enabled: bool) -> void: if hud and hud.has_method("clear_target"): hud.clear_target() +# PythonPlayerInputKeyboard::SetAttackKeyState keeps m_isAtkKey; fishing mode +# diverts the press to NEW_Fishing and must not start ordinary combat. +func set_attack_key(down: bool) -> void: + if down and combo_motion_mode == MOTION_MODE_FISHING: + _new_fishing() + return + _attack_key_down = down + if not down: + _attack_cd = 0.0 + +# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。 +# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。 +func activate_fishing() -> String: + if combo_motion_mode != MOTION_MODE_FISHING: + return "EQUIP_FISHING_ROD" + return _new_fishing() + +func is_fishing() -> bool: + return _fishing_active + +# 地面点击前的动作清理顺序:__OnPressGround 先清预约 / 自动攻击,再取消钓鱼。 +func on_ground_click() -> bool: + _clear_reserved() + _auto_attack_vid = 0 + return cancel_fishing() + +# NEW_CancelFishing:只在 IsFishing() 时发 CG_FISHING(0),并按参考端节流 500ms。 +func cancel_fishing() -> bool: + if not _fishing_active or client == null or not client.has_method("fishing"): + return false + var now := _now() + if now < _last_fishing_cancel_t + FISHING_CANCEL_COOLDOWN: + return false + if not bool(client.fishing(0.0)): + return false + _last_fishing_cancel_t = now + return true + +func _fishing_rotation_deg() -> float: + if pc == null or pc.player == null: + return 0.0 + # GetFishingRot 返回客户端原生方向;MapCoord 与普通 CG_MOVE 使用同一 + # world-yaw -> server-heading 换算,M2Client 再按 5 度量化为 BYTE。 + return _heading_deg(pc.player.rotation.y) + +func _new_fishing() -> String: + if client == null or not client.has_method("fishing"): + return "FISHING_UNAVAILABLE" + if _fishing_active: + return "OK" if bool(client.fishing(0.0)) else "FISHING_SEND_FAILED" + if not _can_process_network_state() or _using_skill: + return "CANNOT_ACT" + if pc == null or pc.player == null: + return "CANNOT_ACT" + # InstanceBaseMotion::GetFishingRot:优先取水面扫描选出的 heading;旧的 + # can_fishing_position 只返回 bool,供尚未升级的 world provider 兼容。 + var fishing_dir := _fishing_rotation_deg() + if net_world and net_world.has_method("get_fishing_rotation"): + var resolved := float(net_world.call("get_fishing_rotation", + pc.player.position.x, pc.player.position.z, fishing_dir)) + if is_equal_approx(resolved, -2.0): + pass # NetWorld has no map provider (headless / protocol-only client). + elif resolved < 0.0: + fishing_feedback.emit("FISHING_WRONG_PLACE") + return "FISHING_WRONG_PLACE" + else: + fishing_dir = resolved + elif net_world and net_world.has_method("can_fishing_position"): + if not bool(net_world.call("can_fishing_position", + pc.player.position.x, pc.player.position.z, fishing_dir)): + fishing_feedback.emit("FISHING_WRONG_PLACE") + return "FISHING_WRONG_PLACE" + if bool(client.fishing(fishing_dir)): + return "OK" + return "FISHING_SEND_FAILED" + +# §3.5:从 assets/root/playersettingmodule.py 解析 PC 连击段表(不在 .msm 里, +# 而是 chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData)。 +func set_asset_root(root: String) -> void: + if not ClassDB.class_exists("Metin2AnimPlayer"): + return + var p := root.trim_suffix("/") + "/root/playersettingmodule.py" + var t: Variant = Metin2AnimPlayer.parse_combo_tables(p) + if t is Dictionary: + _combo_tables = t + +# PythonPlayerSkill.cpp:940 —— 连击技能激活时 SetComboType(MIN(skillLv, 2)),否则 0。 +func set_combo_type(t: int) -> void: + _combo_type = clampi(t, 0, 2) + +func set_combo_motion_mode(mode: int) -> void: + if mode != combo_motion_mode: + combo_motion_mode = mode + _clear_combo() + +# §3.5 修改 6 —— CInstanceBase::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支: +# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE / +# 否则 FISHING;骑乘时按武器 subtype 走 HORSE_* ;否则按 subtype 走 ONEHAND / TWOHAND / +# DUALHAND / BOW / FAN / BELL,ARROW 及其余 → GENERAL。 +static func motion_mode_for(item_type: int, weapon_sub: int, mounted: bool, + poly: bool, dress: bool) -> int: + if poly: + return MOTION_MODE_GENERAL + if dress: + return MOTION_MODE_WEDDING_DRESS + if item_type == ITEM_TYPE_PICK: + return MOTION_MODE_HORSE if mounted else MOTION_MODE_GENERAL + if item_type == ITEM_TYPE_ROD: + return MOTION_MODE_HORSE if mounted else MOTION_MODE_FISHING + if mounted: + match weapon_sub: + WEAPON_SUB_SWORD: return MOTION_MODE_HORSE_ONEHAND_SWORD + WEAPON_SUB_TWO_HANDED: return MOTION_MODE_HORSE_TWOHAND_SWORD + WEAPON_SUB_DAGGER: return MOTION_MODE_HORSE_DUALHAND_SWORD + WEAPON_SUB_FAN: return MOTION_MODE_HORSE_FAN + WEAPON_SUB_BELL: return MOTION_MODE_HORSE_BELL + WEAPON_SUB_BOW: return MOTION_MODE_HORSE_BOW + _: return MOTION_MODE_HORSE + match weapon_sub: + WEAPON_SUB_SWORD: return MOTION_MODE_ONEHAND_SWORD + WEAPON_SUB_TWO_HANDED: return MOTION_MODE_TWOHAND_SWORD + WEAPON_SUB_DAGGER: return MOTION_MODE_DUALHAND_SWORD + WEAPON_SUB_BOW: return MOTION_MODE_BOW + WEAPON_SUB_FAN: return MOTION_MODE_FAN + WEAPON_SUB_BELL: return MOTION_MODE_BELL + _: return MOTION_MODE_GENERAL + +# 当前装备武器 vnum:主角 parts[WEAPON] 优先(时装 / GC_CHARACTER_UPDATE 写这里), +# 为 0 回退 EQUIPMENT 窗 WEAR_WEAPON 槽(对齐 EquipModel._eff)。 +func _equipped_weapon_vnum() -> int: + var e := _main_entity() + var parts: Variant = e.get("parts", []) + if parts is Array and parts.size() > PART_WEAPON and int(parts[PART_WEAPON]) != 0: + return int(parts[PART_WEAPON]) + if client and client.has_method("get_equipment"): + var eq: Variant = client.get_equipment() + if eq is Array and eq.size() > WEAR_WEAPON: + return int(eq[WEAR_WEAPON].get("vnum", 0)) + return 0 + +func _is_main_mounted() -> bool: + var main := _main_entity() + return int(main.get("mount_vnum", 0)) != 0 or bool(main.get("mounting", false)) + +static func _is_new_mount(mount: int) -> bool: + return (mount >= 20205 and mount <= 20212) or mount in [20214, 20215, 20217, 20218, 20220] + +func _horse_skill_unlocked(min_level: int) -> bool: + if client == null or not client.has_method("get_skills"): + return false + for skill in client.get_skills(): + if int(skill.get("id", 0)) == 109: + return int(skill.get("master", 0)) >= 1 or int(skill.get("level", 0)) >= min_level + return false + +# 读装备 / 骑乘 / 变身状态 -> motion_mode_for -> set_combo_motion_mode(换武器 / 上下马时调用)。 +func _refresh_motion_mode() -> void: + var e := _main_entity() + var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) + var poly := int(e.get("polymorph", 0)) != 0 or int(e.get("poly_vnum", 0)) != 0 + var dress := bool(e.get("wedding_dress", false)) + var it := 0 + var sub := 0 + var vnum := _equipped_weapon_vnum() + if vnum > 0 and proto and proto.has_method("item"): + var d: Dictionary = proto.item(vnum) + it = int(d.get("type", 0)) + sub = int(d.get("sub_type", 0)) + set_combo_motion_mode(motion_mode_for(it, sub, mounted, poly, dress)) + # CInstanceBase::MountHorse → SetRotationSpeed(c_fDefaultHorseRotationSpeed 300); + # 下马恢复 c_fDefaultRotationSpeed 1200(InstanceBase.cpp:954 / 3020)。 + if pc and "rotation_speed_deg" in pc: + pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG if mounted else pc.ROT_SPEED_DEFAULT_DEG + +func _on_inv_changed_mode(window: int, _cell: int) -> void: + if window == 2: # mtnet::WINDOW_EQUIPMENT + _refresh_motion_mode() + +func _is_bow_mode() -> bool: + return combo_motion_mode == MOTION_MODE_BOW or combo_motion_mode == MOTION_MODE_HORSE_BOW + +# __CanShot 弓箭合法性 —— 40250 源码树没有成品 `__CanShot` / `CanShoot` 定义 +# (`grep -rn "CanShot|CanShoot"` 全树无匹配),按能门控射箭的三处反推: +# ① CNormalBowAttack_FlyEventHandler_AutoClear(REF/UserInterface/PythonPlayerEventHandler.cpp:235) +# 的 OnSetFlyTarget / OnShoot 之外有 OnNoTarget() / OnNoArrow() 两个失败回调(REF/GameLib/FlyHandler.h); +# ② NEW_Attack(REF/GameLib/InstanceBaseBattle.cpp:355)的 IsDead / IsStun / IsKnockDown / IsUsingSkill 门; +# ③ 弓普攻可点击 / 攻击距离走 __GetBowRange(InstanceBase.cpp:694),空箭走 EMPTY_ARROW 文案。 +# → 弓模式 && 有箭 && 有可攻击目标 && 目标在弓程内。返回 "" = 可射;否则返回 cannot 文案码。 +func _can_shot(target_vid := -1) -> String: + if not _is_bow_mode(): + return "NOT_BOW_MODE" + if _arrow_count == 0: # IFlyEventHandler::OnNoArrow —— -1=未跟踪/无限 + return "EMPTY_ARROW" + var tv := target_vid if target_vid >= 0 else _target_vid + if tv <= 0: + tv = _vid_reserved + if tv <= 0: # IFlyEventHandler::OnNoTarget + return "NO_TARGET" + var te: Dictionary = client.get_entity(tv) if client else {} + # 实体数据缺失(headless / 未进 store)时不据此否决——沿用 _send_fly_target 的「有节点即可」, + # 只在确知目标不可攻击 / 已死时否决。 + if not te.is_empty() and not _is_attackable(te): + return "NO_TARGET" + var tp = _entity_world_pos(tv) + var mp = _entity_world_pos(_main_vid) + if tp is Vector3 and mp is Vector3: + if (tp as Vector3).distance_to(mp) * CM > _bow_range_cm(): + return "OUT_OF_RANGE" + return "" + +# CActorInstance::__GetCurrentComboType(ActorInstance.cpp:164)——弓 / 徒手 / 骑乘模式恒 0。 +func _current_combo_type() -> int: + if _is_bow_mode() or combo_motion_mode == MOTION_MODE_GENERAL \ + or (combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST): + return 0 + return _combo_type + +# GetComboDataPointer(m_wcurMotionMode, wComboType) 的等价:命中返回段号数组,否则空。 +func _combo_vec() -> PackedInt32Array: + var cls: Dictionary = _combo_tables.get(combo_class, {}) + var key := (combo_motion_mode << 16) | _current_combo_type() + return cls.get(key, PackedInt32Array()) + +func _motion_data() -> Dictionary: + var pv_anim: Object = player_view.get("anim") if player_view else null + if pv_anim and pv_anim.has_method("get_motion_data"): + return pv_anim.get_motion_data() + return {} + # --- helpers -------------------------------------------------------------- func _vid_of(node: Object) -> int: @@ -138,26 +534,67 @@ func _entity_name(e: Dictionary) -> String: # --- movement ---------------------------------------------------------------- +# `pc.moved` 在平移的每一帧发。第一帧 = OnMove,之后各帧 = OnMoving。 func _on_local_moved(pos: Vector3) -> void: if _observer_mode or client == null or not client.is_in_game(): return - var now := Time.get_ticks_msec() / 1000.0 - if pos.distance_to(_last_sent_pos) < SEND_DIST and now - _last_sent_t < SEND_INTERVAL: + var now := _now() + if not _was_moving: + # OnMove:立即上行,并把 moving/waiting 两个节流窗口都推到「刚发过」, + # 让随后的 OnMoving / OnWaiting 各自等一个完整间隔(PythonPlayerEventHandler.cpp:76)。 + _send_state(FUNC_MOVE, 0, pos) + _was_moving = true + _last_moving_sent_t = now + _last_waiting_sent_t = now return - var xy := _server_xy(pos) - client.move(FUNC_MOVE, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) - _last_sent_pos = pos - _last_sent_t = now - _was_moving = true + # OnMoving:300 ms 节流(:62)。 + if now - _last_moving_sent_t < SEND_MOVING_INTERVAL: + return + _send_state(FUNC_MOVE, 0, pos) + _last_moving_sent_t = now func _on_anim_state(state: String) -> void: if _observer_mode: return + # OnStop:停下时不节流,落一包 FUNC_WAIT 收尾(PythonPlayerEventHandler.cpp:88)。 if state == "wait" and _was_moving: _was_moving = false if client and client.is_in_game() and pc.player: - var xy := _server_xy(pc.player.position) - client.move(FUNC_WAIT, 0, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) + _send_state(FUNC_WAIT, 0, pc.player.position) + +# 统一上行出口 —— 对齐 `SendCharacterStatePacket` / `TPacketCGMove` +# (PythonNetworkStreamPhaseGame.cpp:1111):bFunc / bArg / bRot / lX / lY / dwTime。 +# rot 的 /5.0 与坐标的 __LocalPositionToGlobalPosition 由 C++ 侧 client.move() 负责。 +func _send_state(func_id: int, arg: int, world_pos: Vector3) -> void: + if client == null or not client.is_in_game(): + return + var xy := _server_xy(world_pos) + client.move(func_id, arg, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) + _last_sent_pos = world_pos + +# OnWaiting —— 站立时周期性把当前位置补发上行,防止本地被击退 / 碰撞推挤后与 +# 服务器不一致(PythonPlayerEventHandler.cpp:38):100 ms 节流且相对上次上行位移 +# ≥ SEND_WAITING_MIN_DISP_CM 才发。 +func _tick_on_waiting() -> void: + if _was_moving or _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: + return + var now := _now() + if now - _last_waiting_sent_t < SEND_WAITING_INTERVAL: + return + var disp_cm := _server_xy(pc.player.position).distance_to(_server_xy(_last_sent_pos)) + if disp_cm < SEND_WAITING_MIN_DISP_CM: + return + _send_state(FUNC_WAIT, 0, pc.player.position) + _last_waiting_sent_t = now + +# OnUseSkill —— 施法瞬间的状态上行:FUNC_SKILL|uMotSkill + uArg(不节流, +# PythonPlayerEventHandler.cpp:120)。调用点(技能合法性 / CD / charge)属 §3.8 的 +# `player_skill.gd`,这里只留 1:1 的上行出口,做法同 §3.4 的预留桩。 +func on_use_skill(motion_skill: int, arg: int) -> void: + if _observer_mode or pc == null or pc.player == null: + return + _was_moving = false + _send_state(FUNC_SKILL | (motion_skill & 0x7F), arg, pc.player.position) func _on_main_set(vid: int) -> void: _main_vid = vid @@ -175,16 +612,43 @@ func _on_entity_info(vid: int, entity: Dictionary) -> void: if speed > 0: pc.set_server_speed(speed) _apply_attack_speed(int(entity.get("attack_speed", 0))) + if entity.has("race"): + combo_class = int(entity.get("race", 0)) & 3 # §3.5 连击段表按职业分块 + _refresh_motion_mode() # §3.5 修改 6:parts[WEAPON] / 骑乘 / 变身变化 → combo_motion_mode func _apply_attack_speed(speed: int) -> void: + # `CInstanceBase::SetAttackSpeed`:bAttackSpeed / 100 只缩放动作播放速率, + # 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。 if speed > 0: - # m2dev applies bAttackSpeed / 100.0 to motion playback. Use the same - # factor for the request cadence while keeping a playable safety range. - var factor := clampf(float(speed) / 100.0, 0.25, 3.0) - attack_period = DEFAULT_ATTACK_PERIOD / factor + _atk_speed_factor = clampf(float(speed) / 100.0, 0.25, 3.0) + +# 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。 +func _current_attack_period() -> float: + var base := DEFAULT_ATTACK_PERIOD + var nc := float(_motion_data().get("next_combo", 0.0)) + if nc > 0.0: + base = nc + return base / _atk_speed_factor + +# CInstanceBase::__GetBowRange(InstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。 +func _bow_range_cm() -> float: + return BOW_RANGE_BASE_CM + _bow_distance_bonus + +# 可点击 / 攻击距离(米),逐分支对齐 NEW_IsClickableDistanceDestInstance +# (InstanceBaseBattle.cpp:294):默认 150 → 弓箭 __GetBowRange() → NPC 500 → 采集物 100, +# 后面的 if 覆盖前面的(NPC / 采集物优先于弓箭距离)。 +func _clickable_distance(te: Dictionary) -> float: + var cm := CLICK_DIST_DEFAULT_CM + if _is_bow_mode(): + cm = _bow_range_cm() + match _entity_kind(te): + 1: cm = CLICK_DIST_NPC_CM # NPC + 3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物 + return cm / CM func _on_points(p: Dictionary) -> void: _apply_attack_speed(int(p.get("attack_speed", 0))) + _bow_distance_bonus = float(p.get("bow_distance", 0)) # POINT_BOW_DISTANCE → __GetBowRange() if hud == null: return if hud.has_method("set_vitals"): @@ -221,16 +685,18 @@ func _on_pick(node: Node3D) -> void: var vid := _vid_of(node) if vid == 0 or vid == _main_vid: return + # __OnPressActor 特例(PythonPlayerInput.cpp:324):预约技能 + 冲锋 affect 生效时, + # 只改预约目标、不立即选中 / 施法。 + if _reserved_mode == ReservedMode.USE_SKILL and _dash_active: + _vid_reserved = vid + return # NPC / 石头 / warp -> 交互,不是攻击目标 var e: Dictionary = client.get_entity(vid) if _entity_kind(e) in [1, 3, 4]: client.click_npc(vid) return - _target_vid = vid _attack_cd = 0.0 - client.set_target(vid) - target_changed.emit(vid) - _refresh_target_hud() + _change_target_to_picked_instance(vid) func _on_target_info(vid: int, hp_pct: int) -> void: if vid == 0: @@ -250,7 +716,9 @@ func _clear_target() -> void: if hud and hud.has_method("clear_target"): hud.clear_target() -# 受击:自己是 victim 且非闪避 -> 硬直(锁输入)+ 播 damage + 抖屏 +# 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。 +# 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING), +# 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。 func _on_damage(vid: int, amount: int, flag: int) -> void: if vid != _main_vid: return @@ -259,58 +727,850 @@ func _on_damage(vid: int, amount: int, flag: int) -> void: camera.shake(0.08 if crit else 0.04, 10.0) if (flag & DAMAGE_DODGE) != 0: return - _hitstun_until = _now() + HITSTUN - if pc and "force_run" in pc: - pass + _knock_down = true if player_view and player_view.has_method("set_anim_state"): player_view.set_anim_state("damage") # GC_FISHING motion events for the local player. `info` is the fisher VID for # START/STOP/REACT/SUCCESS/FAIL; FISH carries an item vnum and is UI-only. func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: - if info != _main_vid or player_view == null or not player_view.has_method("set_anim_state"): + if info != _main_vid: return - if subheader == 5: + # CActorInstance::IsFishing() 仅在 FISHING_WAIT / FISHING_REACT 为真; + # START 到达时先记录意图,随后 STOP/SUCCESS/FAIL 收尾清掉。 + match subheader: + FISHING_START, FISHING_REACT: + _fishing_active = true + FISHING_STOP, FISHING_SUCCESS, FISHING_FAIL, FISHING_FISH: + _fishing_active = false + if player_view == null or not player_view.has_method("set_anim_state"): + return + if subheader == FISHING_FISH: return var state := "fishing" match subheader: - 1: state = "wait" - 2: state = "fishing_react" - 3: state = "fishing_catch" - 4: state = "fishing_fail" + FISHING_STOP: state = "wait" + FISHING_REACT: state = "fishing_react" + FISHING_SUCCESS: state = "fishing_catch" + FISHING_FAIL: state = "fishing_fail" player_view.set_anim_state(state) +# --- 行动闸门(对齐 InstanceBase.cpp:1247 / InstanceBaseBattle.cpp:355 / :310)--- + +func _main_entity() -> Dictionary: + if client and client.has_method("get_entity") and _main_vid != 0: + return client.get_entity(_main_vid) + return {} + +# CanProcessNetworkStatePacket:死亡 / 晕眩 / 击退中都不接受状态输入(≈ __IsSyncing)。 +func _can_process_network_state() -> bool: + if _knock_down: + return false + var e := _main_entity() + return not (bool(e.get("dead", false)) or bool(e.get("stunned", false)) \ + or bool(e.get("knock_down", false))) + +# __CanAttack:可处理状态 + 不在施法中。 +func _can_attack() -> bool: + if not _can_process_network_state() or _using_skill: + return false + # CPythonPlayer::__CanAttack() 的新坐骑等级 11 门(:759), + # 只作用于 IsNewMount;普通坐骑仍由 CanAttackHorseLevel() 的 level<=1 门处理。 + var mount := int(_main_entity().get("mount_vnum", 0)) + if _is_new_mount(mount) and not _horse_skill_unlocked(11): + return false + return true + +# __CanUseSkill:同上(技能合法性 / CD 归 §3.8)。 +func _can_use_skill() -> bool: + return _can_process_network_state() and not _using_skill + +# 兼容旧调用:真正语义 = 「不能处理网络状态包 / 不能行动」。 func is_stunned() -> bool: - return _now() < _hitstun_until + return not _can_process_network_state() + +# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。 +# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* / +# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。 +func skill_context() -> Dictionary: + var e := _main_entity() + var mounted := int(e.get("mount_vnum", 0)) != 0 or bool(e.get("mounting", false)) + var wsub := -1 + var vnum := _equipped_weapon_vnum() + if vnum > 0 and proto and proto.has_method("item"): + var d: Dictionary = proto.item(vnum) + if int(d.get("type", -1)) == ITEM_TYPE_WEAPON: + wsub = int(d.get("sub_type", -1)) + # §3.8 修改 3:GetStatus(POINT_HP/SP) —— __CheckShortLife / __CheckShortMana 的即时值。 + var cur_hp := -1 + var cur_sp := -1 + if client and client.has_method("get_points"): + var pts: Dictionary = client.get_points() + if pts.has("hp"): + cur_hp = int(pts.get("hp", -1)) + if pts.has("sp"): + cur_sp = int(pts.get("sp", -1)) + return { + "observer_mode": _observer_mode, + "mounting": mounted, + "in_safe": bool(e.get("in_safe", false)), + "is_fishing": combo_motion_mode == MOTION_MODE_FISHING, + "is_using_skill": _using_skill, + "can_act": _can_process_network_state(), + "level_limit": true, + "cooltime_check": true, + "dash_active": _dash_active, + "weapon_sub_type": wsub, + "arrow_count": _arrow_count, + "cur_hp": cur_hp, + "cur_sp": cur_sp, + } func _process(dt: float) -> void: + # 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。 + if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \ + and not player_view.is_in_hit_reaction(): + _knock_down = false if pc: - pc.set("frozen", is_stunned()) - if _observer_mode or client == null or not client.is_in_game() or _target_vid == 0 or pc == null: - return - if is_stunned(): - return # 硬直:不发攻击 / 移动 - var tnode: Node3D = net_world.node_for(_target_vid) if net_world else null - if tnode == null or pc.player == null: - return - var te: Dictionary = client.get_entity(_target_vid) - if te.get("dead", false): - _clear_target() + pc.set("frozen", not _can_process_network_state()) + if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: + _clear_reserved() + _auto_attack_vid = 0 return _attack_cd -= dt + if _reserved_delay > 0.0: + _reserved_delay -= dt + _combo_process() + _attack_process(dt) # AttackProcess:命中窗几何判定 -> OnHit + _update_auto_attack() + _refresh_reserved() + _tick_on_waiting() + _flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION + +# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207)——每帧检查 auto-attack 目标 +# 是否仍可打,然后 __ReserveClickActor()。本工程用「按住攻击键 + 有目标」当 __OnPressActor(isAuto)。 +func _update_auto_attack() -> void: + if not _attack_key_down or _target_vid == 0: + _auto_attack_vid = 0 + return + _auto_attack_vid = _target_vid + if not _can_process_network_state(): # ≈ !NEW_GetMainActorPtr() / __IsSyncing() + return + if _dash_active or (_using_skill and _charge_skill): # __IsUsingChargeSkill() + return + var v: Dictionary = client.get_entity(_auto_attack_vid) + if v.is_empty(): + _clear_auto_attack() + return + if bool(v.get("dead", false)): + _clear_auto_attack() + return + if _is_main_mounted() and not _can_attack_horse_level(): + _clear_auto_attack() + return + if not _is_attackable(v): + return + if bool(_main_entity().get("sleeping", false)): + return + # 参考端每帧无条件 __ReserveClickActor(攻击节奏由动作数据在 swing 处 gate,不在这里)。 + if _reserved_mode == ReservedMode.NONE and _can_attack(): + _reserve_click_actor(_auto_attack_vid) + +# NEW_RefreshMouseWalkingDirection()(PythonPlayerInputMouse.cpp:287)里的模式分派—— +# 四个 case 逐一照抄 :288-370(含 MODE_CLICK_ITEM 的 `< 20.0f` 拾取阈值、 +# MODE_USE_SKILL 的 IS_HUGE_RACE 距离 −200 修正)。 +func _refresh_reserved() -> void: + match _reserved_mode: + ReservedMode.CLICK_ITEM: + _reserve_process_click_item() + ReservedMode.CLICK_ACTOR: + _reserve_process_click_actor() + ReservedMode.CLICK_POSITION: + _reserve_process_click_position() + ReservedMode.USE_SKILL: + _reserve_process_use_skill() + +func _reserve_click_actor(vid: int) -> void: + _reserved_mode = ReservedMode.CLICK_ACTOR + _vid_reserved = vid + +func _clear_reserved() -> void: + _reserved_mode = ReservedMode.NONE + _vid_reserved = 0 + _iid_reserved = 0 + _ppos_reserved = Vector3.ZERO + _skill_slot_reserved = -1 + _skill_range_reserved = 0.0 + _reserved_delay = 0.0 + +func _clear_auto_attack() -> void: + _auto_attack_vid = 0 + _clear_target() + +func _emit_cannot(code: String) -> void: + _last_cannot = code + cannot_act.emit(code) + +# CInstanceBase::IsAttackableInstance(InstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。 +# 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各 +# 调用点判:__ReserveProcess_ClickActor 第 4 步、_hit_geometry 等)——本 POC 保留一个 +# dead 提前返回作为便利,多数调用点本来也会再查一次。 +func _is_attackable(e: Dictionary) -> bool: + if bool(e.get("dead", false)): + return false + var se := _main_entity() + if se.is_empty(): + # headless / 主角未就位:退化到「PC / 怪可打」的旧近似。 + return _entity_kind(e) in [EntityRules.KIND_PC, EntityRules.KIND_MONSTER] + var self_e := se.duplicate() + self_e["kind"] = _entity_kind(se) + var victim_e := e.duplicate() + victim_e["kind"] = _entity_kind(e) + return EntityRules.is_attackable_instance(self_e, victim_e, _attack_ctx(self_e)) + +# IsAttackableInstance 需要的会话级上下文:观战、自身 PK 模式、决斗、PVP 关系表、 +# 公会战对表、队伍 VID 集合。数据全部来自已有的 M2Client 访问器。 +func _attack_ctx(self_e: Dictionary) -> Dictionary: + var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {} + var duel_mode := EntityRules.DUEL_NONE + if bool(duel.get("active", false)): + duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \ + else EntityRules.DUEL_START + var pvp_pairs := [] + if client and client.has_method("get_pvp_relations"): + for r in client.get_pvp_relations(): + if int(r.get("mode", 0)) != 0: + pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))]) + var gvg_pairs := [] + if client and client.has_method("get_guild_wars"): + for w in client.get_guild_wars(): + gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))]) + var party_vids := [] + if client and client.has_method("get_party"): + for m in client.get_party(): + party_vids.append(int(m.get("vid", 0))) + return { + "observer": _observer_mode, + "main_vid": _main_vid, + "pk_mode": int(self_e.get("pk_mode", 0)), + "duel_mode": duel_mode, + "duel_opponents": duel.get("opponents", []), + "pvp_pairs": pvp_pairs, + "gvg_pairs": gvg_pairs, # __FindGVGKey(公会战),来自 GUILD_GC_WAR 对表 + "party_vids": party_vids, + } + +func _can_attack_horse_level() -> bool: + # CInstanceBase::CanAttackHorseLevel() -> SHORSE::CanAttack() + # (InstanceBaseBattle.cpp:623, InstanceBase.cpp:216):骑乘等级 1 不能攻击; + # 未知坐骑沿用参考端 GetLevel()==0 的放行行为,避免凭空拒绝新坐骑。 + var main := _main_entity() + var mount := int(main.get("mount_vnum", 0)) + if mount == 0 and not bool(main.get("mounting", false)): + return true + return _horse_level_for_mount(mount) != 1 + +static func _horse_level_for_mount(mount: int) -> int: + match mount: + 20101, 20102, 20103: + return 1 + 20104, 20105, 20106, 20119, 20219, 20220, 20221, 20222: + return 2 + 20107, 20108, 20109, 20110, 20111, 20112, 20113, 20114, 20115, 20116, 20117, 20118, 20120, 20121, 20122, 20123, 20124, 20125: + return 3 + _ when mount >= 20205 and mount <= 20208: + return 2 + _ when mount >= 20209 and mount <= 20212: + return 3 + 20214, 20217, 20224, 20229: + return 2 + 20215, 20218, 20225, 20230: + return 3 + return 0 + +# CPythonPlayer::__ReserveProcess_ClickActor()(PythonPlayerInput.cpp:877)——顺序不可改。 +func _reserve_process_click_actor() -> void: + var main: Dictionary = _main_entity() + var rv: Dictionary = client.get_entity(_vid_reserved) + # 1 main / reserved 任一为空 + if main.is_empty() or rv.is_empty(): + _clear_reserved() + return + var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null + if tnode == null: + _clear_reserved() + return + # 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection) var dist: float = pc.player.global_position.distance_to(tnode.global_position) - if dist <= ATTACK_RANGE and _attack_cd <= 0.0: - var to: Vector3 = tnode.global_position - pc.player.global_position - if to.length() > 0.01: - pc.player.rotation.y = atan2(to.x, to.z) - # combo:连击窗口内递增 motion(0/1/2 循环),超时归零 - var now := _now() - _combo = (_combo + 1) % 3 if (now - _last_attack_t) < COMBO_WINDOW else 0 - _last_attack_t = now - client.attack(attack_motion + _combo, _target_vid) - if player_view and player_view.has_method("set_anim_state"): - player_view.set_anim_state("combo" if _combo > 0 else "attack") - _attack_cd = attack_period + if dist > _clickable_distance(rv): + if pc.has_method("walk_to"): + pc.walk_to(tnode.global_position) + return + # 3 不可攻击 -> 停 + 发点击包(NPC 对话)+ 清预约 + if not _is_attackable(rv): + pc.set("frozen", false) + client.click_npc(_vid_reserved) + _clear_reserved() + return + # 4 目标已死 + if bool(rv.get("dead", false)): + _clear_reserved() + return + # 5 自己在安全区 + if bool(main.get("in_safe", false)): + _emit_cannot("IN_SAFE") + _clear_reserved() + return + # 6 目标在安全区 + if bool(rv.get("in_safe", false)): + _emit_cannot("DEST_IN_SAFE") + _clear_reserved() + return + # 7 冲锋 / 弹射 affect 生效 -> 保持预约 + if _dash_active: + return + # 8 弓箭模式且箭不足 + if bool(main.get("bow_mode", false)) and _arrow_count == 0: + _emit_cannot("EMPTY_ARROW") + _clear_reserved() + return + # 9 目标未选中 -> 选中 + if _target_vid != _vid_reserved: + _target_vid = _vid_reserved + client.set_target(_vid_reserved) + target_changed.emit(_vid_reserved) + _refresh_target_hud() + # 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。 + # 连击 / 普攻节奏由动作数据 gate:cd 未到就本帧不 swing,仍清预约、下帧再来。 + if _attack_cd <= 0.0: + _do_attack_swing(tnode, rv) + _clear_reserved() + +# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case)------------ + +# MODE_CLICK_ITEM(PythonPlayerInputMouse.cpp:290):走到掉落物、到手距离内先补一包 +# 站立状态再发 CG_ITEM_PICKUP,物品消失则清预约。 +func _reserve_process_click_item() -> void: + if pc == null or pc.player == null: + _clear_reserved() + return + var item_pos = _ground_item_world_pos(_iid_reserved) + if item_pos == null: + _clear_reserved() + return + if pc.player.global_position.distance_to(item_pos) < CLICK_ITEM_PICKUP_CM / CM: + _send_state(FUNC_WAIT, 0, pc.player.position) + if client.has_method("pickup_item"): + client.pickup_item(_iid_reserved) + pc.set("frozen", false) + _clear_reserved() + elif pc.has_method("walk_to"): + pc.walk_to(item_pos) + +# MODE_CLICK_POSITION(PythonPlayerInputMouse.cpp:320):预约延时耗尽、本地未被锁, +# 再朝目标点起步并清预约(_reserved_delay 的递减在 _process 里,≈ NEW_IsEmptyReservedDelayTime)。 +func _reserve_process_click_position() -> void: + if pc and pc.has_method("_can_translate") and not pc._can_translate(): + return + if _reserved_delay > 0.0: + return + if pc and pc.has_method("walk_to"): + pc.walk_to(_ppos_reserved) + _clear_reserved() + +# MODE_USE_SKILL(PythonPlayerInputMouse.cpp:340):目标进入技能射程(含 IS_HUGE_RACE +# 的 −200cm 修正)就选中并施法,否则继续贴近。__UseSkill 的真正施法走 +# use_skill_hook(game_scene 注入的 quickbar 那条链),未注入则退化成只选中、留预约。 +func _reserve_process_use_skill() -> void: + if pc == null or pc.player == null: + _clear_reserved() + return + var rv: Dictionary = client.get_entity(_vid_reserved) + var tnode: Node3D = net_world.node_for(_vid_reserved) if net_world else null + if rv.is_empty() or tnode == null: + _clear_reserved() + return + var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM + if _is_huge_race(int(rv.get("race", 0))): + dist_cm -= HUGE_RACE_DIST_FIX_CM + if dist_cm < _skill_range_reserved: + if _target_vid != _vid_reserved: + _target_vid = _vid_reserved + client.set_target(_vid_reserved) + target_changed.emit(_vid_reserved) + _refresh_target_hud() + if _invoke_reserved_skill(): + _clear_reserved() + elif pc.has_method("walk_to"): + pc.walk_to(tnode.global_position) + +# __UseSkill(m_dwSkillSlotIndexReserved):实际槽位施法在 quickbar,由 game_scene 通过 +# use_skill_hook(Callable(global_slot:int) -> bool)注入。未注入 -> 返回 false(预约保持, +# 下一帧再试,与参考端 __UseSkill 失败留预约一致)。 +var use_skill_hook := Callable() + +func _invoke_reserved_skill() -> bool: + if _skill_slot_reserved < 0: + return false + if use_skill_hook.is_valid(): + return bool(use_skill_hook.call(_skill_slot_reserved)) + return false + +# NEW_GetGroundItemPosition(PythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。 +# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。 +func _ground_item_world_pos(iid: int): + if client == null or not client.has_method("get_ground_items"): + return null + for gi in client.get_ground_items(): + if int(gi.get("vid", 0)) == iid: + var p: Variant = gi.get("pos", null) + return MapCoord.to_world(p) if p is Vector3 else null + return null + +# __ReserveClickItem(PythonPlayerInput.cpp)——预约「走到掉落物再拾取」。 +func reserve_click_item(iid: int) -> void: + _clear_reserved() + _reserved_mode = ReservedMode.CLICK_ITEM + _iid_reserved = iid + +# __ReserveClickGround(PythonPlayerInput.cpp:836)——预约「延时 0.1s 后走到该点」。 +func reserve_click_ground(world_pos: Vector3) -> void: + _clear_reserved() + _reserved_mode = ReservedMode.CLICK_POSITION + _ppos_reserved = world_pos + _reserved_delay = 0.1 + +# __ReserveUseSkill(PythonPlayerInput.cpp:854)——预约「进射程再施法」。range > 100 时 +# 先减 10(参考端同款收边,避免边界处反复趋近)。slot 为全局快捷栏索引。 +func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: + _clear_reserved() + _reserved_mode = ReservedMode.USE_SKILL + _vid_reserved = vid + _skill_slot_reserved = slot + if range_cm > 100.0: + range_cm -= RESERVE_SKILL_RANGE_TRIM_CM + _skill_range_reserved = range_cm + +# __OnPressItem(PythonPlayerInput.cpp)——点地面掉落物:清预约 + auto-attack,在拾取 +# 距离内直接 CG_ITEM_PICKUP + 停,否则预约走过去。 +func pick_ground_item(iid: int) -> void: + if _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: + return + _clear_reserved() + _auto_attack_vid = 0 + var item_pos = _ground_item_world_pos(iid) + if item_pos == null: + return + if pc.player.global_position.distance_to(item_pos) * CM < CLICK_DIST_RESOURCE_CM: + _send_state(FUNC_WAIT, 0, pc.player.position) + if client.has_method("pickup_item"): + client.pickup_item(iid) + pc.set("frozen", false) + else: + reserve_click_item(iid) + +# IS_HUGE_RACE(vnum)(参考端是一张未随源码发布的硬编码 vnum 白名单)——用 mob_proto +# 的体型 / 阶级位近似:MOBSIZE_HUGE(3) 或 BOSS(4) 以上视为巨型。查不到 proto -> false。 +func _is_huge_race(race: int) -> bool: + if race <= 0 or proto == null or not proto.has_method("mob"): + return false + var m: Dictionary = proto.mob(race) + if m.is_empty(): + return false + return int(m.get("size", 0)) >= HUGE_RACE_SIZE or int(m.get("rank", 0)) >= HUGE_RACE_RANK + +# __ChangeTargetToPickedInstance(PythonPlayerInput.cpp:186)→ SetTarget(:130): +# 可选中就选中并发 CG_TARGET,否则清空目标并发 CG_TARGET(0)。这里「目标」限定为战斗 +# 目标(PC / 怪)——NPC / 石头 / warp 的交互在 _on_pick 里已分流到 click_npc +# (对齐 __OnClickActor 的拆分)。 +func _change_target_to_picked_instance(vid: int) -> bool: + var e: Dictionary = client.get_entity(vid) + if e.is_empty() or bool(e.get("dead", false)) or _entity_kind(e) not in [0, 2]: + _target_vid = 0 + client.set_target(0) + target_changed.emit(0) + if hud and hud.has_method("clear_target"): + hud.clear_target() + return false + _target_vid = vid + client.set_target(vid) + target_changed.emit(vid) + _refresh_target_hud() + return true + +# --- §3.4 扇形 / 圆形多目标 fly-targeting(PythonPlayerSkill.cpp:680-746)---------- + +# 主角 → 某点的水平方位角(度)。扇形过滤 / 随机点抖动只比较角差,与坐标系无关, +# 只要 target 与 each 用同一函数(对齐 NEW_Get*InFanRange 里 fDirRot 的比较)。 +func _bearing_deg(from_world: Vector3, to_world: Vector3) -> float: + var d := to_world - from_world + return rad_to_deg(atan2(d.x, -d.z)) + +func _dir_from_bearing(deg: float) -> Vector3: + var r := deg_to_rad(deg) + return Vector3(sin(r), 0.0, -cos(r)) + +# 场景节点优先(已在 world 帧),否则实体 pos 过 MapCoord。 +func _entity_world_pos(vid: int): + if net_world and net_world.has_method("node_for"): + var n: Node3D = net_world.node_for(vid) + if n != null: + return n.global_position + var e: Dictionary = client.get_entity(vid) + if e.is_empty() or not (e.get("pos", null) is Vector3): + return null + return MapCoord.to_world(e.get("pos")) + +# NEW_GetInstanceVectorInCircleRange(InstanceBaseBattle.cpp:243)——射程内全部可攻击 +# 实体,按距离升序。 +func _instances_in_circle_range(range_cm: float) -> Array: + var out := [] + if client == null or pc == null or pc.player == null: + return out + var main_pos: Vector3 = pc.player.global_position + var reach_m := range_cm / CM + for e in client.get_entities(): + var vid := int(e.get("vid", 0)) + if vid == 0 or vid == _main_vid or not _is_attackable(e): + continue + var wp = _entity_world_pos(vid) + if wp == null: + continue + var dist_m: float = main_pos.distance_to(wp) + if dist_m <= reach_m: + out.append({"vid": vid, "dist_m": dist_m, "pos": wp}) + out.sort_custom(func(a, b): return a["dist_m"] < b["dist_m"]) + return out + +# NEW_GetInstanceVectorInFanRange(InstanceBaseBattle.cpp:192)——先按圆形取,再按 +# 「随距离收窄的半角」过滤:fHalfFanRot = (MAX-MIN) - RPM*min(dist,1000) + MIN。 +func _instances_in_fan_range(range_cm: float, target_vid: int) -> Array: + var circle := _instances_in_circle_range(range_cm) + var tp = _entity_world_pos(target_vid) + if tp == null: + return [] + var main_pos: Vector3 = pc.player.global_position + var dst_rot := deg_to_rad(_bearing_deg(main_pos, tp)) + var rpm := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) / FAN_HALF_ROT_MIN_DISTANCE + var kept := [] + for c in circle: + var each_dist_cm: float = min(c["dist_m"] * CM, FAN_HALF_ROT_MIN_DISTANCE) + var half_fan := (FAN_HALF_ROT_MAX - FAN_HALF_ROT_MIN) - rpm * each_dist_cm + FAN_HALF_ROT_MIN + var each_rot := deg_to_rad(_bearing_deg(main_pos, c["pos"])) + if absf(angle_difference(dst_rot, each_rot)) <= deg_to_rad(half_fan): + kept.append(c) + return kept + +# NEW_GetRandomPositionInFanRange(InstanceBaseBattle.cpp:124)——目标方位 ±10° 抖动、 +# 沿该方向 8000cm 取点(参考端绕 Z 转 (0,8000,0);这里在 world 帧等价展开)。 +func _random_pos_in_fan_range(target_vid: int) -> Vector3: + var main_pos: Vector3 = pc.player.global_position + var tp = _entity_world_pos(target_vid) + var base_bearing: float = _bearing_deg(main_pos, tp) if tp != null else rad_to_deg(pc.player.rotation.y) + var rot := base_bearing + randf_range(-FAN_RANDOM_JITTER_DEG, FAN_RANDOM_JITTER_DEG) + return main_pos + _dir_from_bearing(rot) * (FAN_RANDOM_REACH_CM / CM) + +# __UseSkill(PythonPlayerSkill.cpp:684-746)里 GetTargetCount > 0 时的扇形 / 圆形补目标。 +# 主目标的 CG_FLY_TARGETING 已由 M2Client::use_skill 发过,这里只补「附加」目标—— +# 命中范围内的其他敌人(AddFlyTargetInstance)+ 名额不足时的随机落点(AddFlyTargetPosition, +# vid = 0)。返回补发的附加 fly-target 包数量。 +func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape: int) -> int: + if client == null or not client.has_method("add_fly_targeting"): + return 0 + if max_count <= 1 or target_vid == 0 or shape == FlyShape.SINGLE: + return 0 + if pc == null or pc.player == null: + return 0 + # __GetSkillTargetRange:基础射程再叠加主角 POINT_BOW_DISTANCE(×100,参考端此处 + # 与 __GetBowRange 的「不 ×100」不一致,照抄各自的写法)。 + var reach_cm := range_cm + _bow_distance_bonus * 100.0 + var cands: Array = _instances_in_fan_range(reach_cm, target_vid) if shape == FlyShape.FAN \ + else _instances_in_circle_range(reach_cm) + var count := 1 + var extras := 0 + for c in cands: + if count >= max_count: + break + var vid := int(c["vid"]) + if vid == target_vid: + continue + if bool(client.get_entity(vid).get("dead", false)): + continue + var sc := _server_xy(c["pos"]) + client.add_fly_targeting(vid, int(sc.x), int(sc.y)) + count += 1 + extras += 1 + while count < max_count: + var sc2 := _server_xy(_random_pos_in_fan_range(target_vid)) + client.add_fly_targeting(0, int(sc2.x), int(sc2.y)) + count += 1 + extras += 1 + return extras + +# NEW_AttackToDestInstanceDirection → CActorInstance::InputComboAttackCommand +# (ActorInstanceBattle.cpp:157):一次攻击输入按连击段表 + `.msa` ComboInputData 推进。 +func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void: + var to: Vector3 = tnode.global_position - pc.player.global_position + if to.length() > 0.01: + pc.player.rotation.y = atan2(to.x, to.z) + _was_moving = false + + var vec := _combo_vec() + if vec.is_empty(): + # 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 退化为单段普攻。 + _emit_swing(NAME_NORMAL_ATTACK, false) + return + + # m_isPreInput 挂起时忽略本次输入(等 ComboProcess 到点自动触发)。 + if _is_pre_input: + return + if _combo_index == 0: + _run_next_combo(vec) + return + + var t := _now() - _combo_started_t + var md := _motion_data() + if bool(md.get("has_combo_input", false)): + var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime + var t_next := float(md.get("direct_input_time", 0.0)) # GetNextComboTime + var t_end := float(md.get("input_limit_time", 0.0)) # GetComboInputEndTime + if t > t_end: + if _is_bow_mode(): + _is_next_pre_input = true + return + if t > t_next: + _run_next_combo(vec) + elif t > t_start: + _is_pre_input = true + else: + var dur := float(md.get("duration", attack_period)) + if t > dur * 0.9: + _run_next_combo(vec) + +# CActorInstance::__RunNextCombo(ActorInstanceBattle.cpp:256) +func _run_next_combo(vec: PackedInt32Array) -> void: + _combo_index += 1 + var arr_idx := _combo_index - 1 + if arr_idx < 0 or arr_idx >= vec.size(): + _combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进 + return + _combo_started_t = _now() + _emit_swing(int(vec[arr_idx]), _combo_index > 1) + if _combo_index == vec.size(): + _on_end_combo() + +# __OnEndCombo:骑乘时索引回 1,否则不复位(复位在动作回 Wait 时的 __ClearCombo)。 +func _on_end_combo() -> void: + if combo_motion_mode >= MOTION_MODE_HORSE_FIRST and combo_motion_mode <= MOTION_MODE_HORSE_LAST: + _combo_index = 1 + +# __ClearCombo:动作回到 Wait 时清空连击状态。 +func _clear_combo() -> void: + _combo_index = 0 + _is_pre_input = false + _is_next_pre_input = false + _hit_windows = [] + _hit_dedup.clear() + +# CActorInstance::ComboProcess(ActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。 +func _combo_process() -> void: + if _combo_index != 0: + var t := _now() - _combo_started_t + var md := _motion_data() + var has_ci := bool(md.get("has_combo_input", false)) + var full := float(md.get("duration", attack_period)) + if full <= 0.0: + full = attack_period + if _is_pre_input: + var t_next := float(md.get("direct_input_time", 0.0)) if has_ci else full * 0.9 + if t > t_next: + _is_pre_input = false + var vec := _combo_vec() + if not vec.is_empty(): + _run_next_combo(vec) + return + # 动作已回到 Wait(超过整段时长、无挂起输入)→ __ClearCombo + if not _is_pre_input and t > full: + _clear_combo() + else: + _is_pre_input = false + if _is_next_pre_input and not _using_skill: + var vec := _combo_vec() + if not vec.is_empty(): + _run_next_combo(vec) + _is_next_pre_input = false + +# OnAttack 状态包 + 缓存命中窗 + 播动作 + 刷新节奏。§3.5 +# OnHit(SendAttackPacket)不在这里:有 `.msa` 命中窗时它由 `_attack_process()` 的几何判定 +# 逐帧触发(1:1 对齐 `__ProcessDataAttackSuccess` → `OnHit`);只有拿不到命中窗数据 +# (headless / 缺资源)才在此退化为立即发一包。 +func _emit_swing(motion_index: int, is_combo: bool) -> void: + # 弓动作起手 = CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(在 + # CActorInstance::__SetMotion 里 __IsNeedFlyTargetMotion() 为真时触发, + # ActorInstanceMotion.cpp:372):立即发 CG_FLY_TARGETING(目标VID, 目标包围球中心)。 + # OnShoot(uSkill) 则要等 `.msa` FLY 帧,交给 game_scene(bow_shot_fired → 入队)。§3.6 + if _is_bow_mode(): + # __CanShot 关:无箭 / 无目标 / 出弓程 → 走 OnNoArrow / OnNoTarget 文案, + # 不发 CG_FLY_TARGETING、不排 OnShoot(其余起手动作照常,seam:参考端整挥击中止)。 + var why := _can_shot() + if why != "": + _emit_cannot(why) + else: + _send_fly_target() + bow_shot_fired.emit(_swing_skill) + # OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号 + # (PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。 + _send_state(FUNC_COMBO, motion_index, pc.player.position) + _begin_hit_windows() + if _hit_windows.is_empty(): + # CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号 + # (PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。 + client.attack(_swing_skill, _vid_reserved) + if player_view and player_view.has_method("set_anim_state"): + player_view.set_anim_state("combo" if is_combo else "attack") + # 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo), + # 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3 + attack_period = _current_attack_period() + _attack_cd = attack_period + +# CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget(PythonPlayerEventHandler.cpp:235): +# SendFlyTargetingPacket(target->GetVirtualID(), target->OnGetFlyTargetPosition()) +# OnGetFlyTargetPosition(ActorInstanceFly.cpp:4)返回目标包围球中心的世界坐标;其平面 +# 坐标与角色脚点一致,而 CG_FLY_TARGETING 只带平面坐标,故直接取目标节点位置换算 server-XY。 +func _send_fly_target() -> void: + var vid := _target_vid if _target_vid != 0 else _vid_reserved + if vid == 0 or client == null or net_world == null \ + or not client.has_method("add_fly_targeting"): + return + var tnode: Node3D = net_world.node_for(vid) + if tnode == null: + return + var sp := _server_xy(tnode.global_position) + client.add_fly_targeting(vid, int(sp.x), int(sp.y)) + +# 挥击起播:从当前 `.msa` 取 THitDataContainer + iMotionType + iHitLimitCount + fInvisibleTime, +# 清空上一击的 m_HitDataMap(对齐 __ProcessNormalAttack 里对 m_HitDataMap 的重建)。 +func _begin_hit_windows() -> void: + var md := _motion_data() + var w: Variant = md.get("hit_windows", []) + _hit_windows = w if w is Array else [] + _hit_motion_type = int(md.get("motion_type", 0)) + _hit_limit_count = int(md.get("hit_limit_count", 0)) + _hit_invisible_time = float(md.get("invisible_time", 0.0)) + _hit_dedup.clear() + _swing_start_t = _now() + +# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)+ CActorInstance::__NormalAttackProcess +# (ActorInstanceCollisionDetection.cpp:333)——挥击动作播放中,每帧按 hit_window [start,end] +# 对所有可攻击实体做扫掠球几何判定,命中即 OnHit(SendAttackPacket)。 +func _attack_process(dt: float) -> void: + if _hit_windows.is_empty() or client == null or not client.is_in_game(): + return + if pc == null or pc.player == null or net_world == null: + return + var t := _now() - _swing_start_t + # 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking) + var latest_end := 0.0 + for w in _hit_windows: + latest_end = maxf(latest_end, float(w.get("end_time", 0.0))) + if t - dt > latest_end: + _hit_windows = [] + _hit_dedup.clear() + return + # CheckAttacking:自己在安全区直接不判定 + if bool(_main_entity().get("in_safe", false)): + return + var yaw := _player_yaw() + var origin: Vector3 = pc.player.global_position + for e in client.get_entities(): + var vid := int(e.get("vid", 0)) + if vid == 0 or vid == _main_vid: + continue + if not _is_attackable(e) or bool(e.get("dead", false)) or bool(e.get("in_safe", false)): + continue + var vnode: Node3D = net_world.node_for(vid) + if vnode == null: + continue + for wi in _hit_windows.size(): + var w: Dictionary = _hit_windows[wi] + var ws := float(w.get("start_time", 0.0)) + var we := float(w.get("end_time", 0.0)) + # 扫掠:动作时间窗 [t-dt, t] 与命中窗 [ws, we] 相交(对齐 lower_bound(motiontime-elapsed)) + if t < ws or (t - dt) > we: + continue + if _hit_deduped(wi, vid): + continue + if not _hit_geometry(w, origin, yaw, vnode.global_position, t, dt): + continue + if not _register_hit(wi, vid): + break # 该窗命中数超上限 -> 本帧不再处理这个实体 + # OnHit:SetTarget(FALSE) + SendAttackPacket(uSkill, victimVID) + if _target_vid != vid: + _target_vid = vid + client.attack(_swing_skill, vid) + # IsPushing 的目标:帧末 CG_SYNC_POSITION 上报其被击退后的位置 + if bool(e.get("knock_down", false)) or bool(e.get("stunned", false)): + var sp := _server_xy(vnode.global_position) + _victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)}) + break # 一个实体一帧命中一次(对齐 __NormalAttackProcess 命中即 return TRUE) + +# m_HitDataMap 查重:COMBO 动作同窗同目标只一次;其余按 fInvisibleTime 冷却。 +func _hit_deduped(wi: int, vid: int) -> bool: + var m: Dictionary = _hit_dedup.get(wi, {}) + if not m.has(vid): + return false + if _hit_motion_type == MOTION_TYPE_COMBO: + return true + return float(m[vid]) > _now() + +# 记一次命中并做每窗命中上限校验:NORMAL/COMBO 上限 16,SKILL 用 iHitLimitCount。 +# 返回 false = 超上限,本帧跳过(对齐 iCurrentHitCount > … return FALSE)。 +func _register_hit(wi: int, vid: int) -> bool: + var m: Dictionary = _hit_dedup.get(wi, {}) + var first := not m.has(vid) + m[vid] = _now() + _hit_invisible_time + _hit_dedup[wi] = m + if first: + return true + var cap := 16 + if _hit_motion_type != MOTION_TYPE_COMBO and _hit_motion_type != MOTION_TYPE_NORMAL: + cap = _hit_limit_count + return m.size() <= cap + +# 扫掠球几何近似:reach = max(WeaponLength, 窗内采样最大水平偏移),命中条件 = 目标落在 +# 正面 reach 弧内。真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending sphere 数据(同 §3.1 留桩)。 +func _hit_geometry(w: Dictionary, origin: Vector3, yaw: float, victim_pos: Vector3, t: float, dt: float) -> bool: + var reach_cm := float(w.get("weapon_length", 0.0)) + var samples: Variant = w.get("samples", []) + if samples is Array: + for s in samples: + var st := float(s.get("time", 0.0)) + if st < t - dt or st > t: + continue + var p: Vector3 = s.get("pos", Vector3.ZERO) + reach_cm = maxf(reach_cm, Vector2(p.x, p.y).length()) + if reach_cm <= 0.0: + reach_cm = CLICK_DIST_DEFAULT_CM + var max_dist := (reach_cm + HIT_ATTACK_RADIUS_CM + HIT_VICTIM_RADIUS_CM) / CM + var to: Vector3 = victim_pos - origin + to.y = 0.0 + var d := to.length() + if d > max_dist: + return false + if d < 0.001: + return true + var fwd := Vector3(sin(yaw), 0.0, cos(yaw)) + return fwd.dot(to / d) >= HIT_FRONT_COS + +# CPythonPlayerEventHandler::FlushVictimList(PythonPlayerEventHandler.cpp:194)——帧末把 +# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。 +func _flush_victim_list() -> void: + if _victim_flush.is_empty(): + return + if client and client.is_in_game(): + # slice 返回新数组:既做 16 上限截断,也避免把马上要 clear() 的引用传出去。 + client.sync_positions(_victim_flush.slice(0, SYNC_POSITION_LIMIT)) + _victim_flush.clear() func _now() -> float: return Time.get_ticks_msec() / 1000.0 diff --git a/project/net_world.gd b/project/net_world.gd index 8f3a901f..141b48cb 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -16,11 +16,19 @@ # nw.main_entity_ready.connect(func(node, vid): _cam.target = node) extends Node +const FlyObject = preload("res://fly_object.gd") +const NameColor = preload("res://name_color.gd") +const TextTail = preload("res://text_tail.gd") +const ChatTail = preload("res://chat_tail.gd") +const NameShow = preload("res://name_show.gd") +const EntityRules = preload("res://entity_rules.gd") + signal entity_added(node: Node3D, vid: int) signal entity_removed(vid: int) signal main_entity_ready(node: Node3D, vid: int) signal chat_line(type: int, vid: int, text: String) signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) +signal fly_exploded(world_pos: Vector3, cause: String) # §3.6 飞行物命中 / 超程 / 触地 signal fishing_event(vid: int, subheader: int, dir: int) signal motion_event(vid: int, victim_vid: int, motion: int) signal vitals(vid: int, hp: int, max_hp: int, dead: bool) @@ -58,8 +66,10 @@ const WALL_RACES := [14201, 14202, 14203, 14204] var client: Node # M2Client var parent: Node3D # 实体挂这下面 var world: Node # Metin2World(可空,用 sample_height 贴地) -var follow_lerp := 14.0 # 位置逼近速度 -var snap_dist := 6.0 # 超过这么多米直接瞬移(初次出现/大跳) +# §3.2 后位置插值全在 C++ EntityStore::tick() 里做,这里直接跟随 e.pos。 +# 两个字段保留只为兼容外部读取 / 旧测试,_process 不再使用。 +var follow_lerp := 14.0 +var snap_dist := 6.0 var _by_vid := {} # vid:int -> Node3D var _main_vid := 0 @@ -67,10 +77,35 @@ var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下) var _model_factory: Callable # func(Dictionary) -> Node3D var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名走 mob_proto) +var kind_resolver: Callable = Callable() # §8.8 func(entity_dict) -> int(0 PC/1 NPC/2 怪/3 石/4 warp;net_play._entity_kind) +var title_name_resolver: Callable = Callable() # §8.8 func(grade:int) -> String(PVP_LEVEL 本地化;未注入回退裸 key,seam ⑧) +var _name_color_accum := 0.0 # §8.8 头顶名字色 / 称号标签的节流刷新累加器(~2Hz) +var _chat_tails := {} # §8.8 vid:int -> {text,color,name_flag,living_time}(CPythonTextTail::m_ChatTailMap) var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode} var _duel_opponents := {} # server-provided duel opponent VID set var _duel_cannot_attack := false var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done) +# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源: +# _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName) +# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag) +# 另加 forced 路径:_target_vid(targetBoard.GetTargetVID())+ chat 尾标 bNameFlag。 +var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT +var _alt_held := false +var _target_vid := 0 +var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6 + +# Fishing surface provider bridge. NetWorld owns entity presentation while +# Metin2World owns map data; keep the ClientVS22 GetFishingRot query on the +# latter and expose it without making NetPlay depend on the native class. +func get_fishing_rotation(gx_m: float, gz_m: float, heading_deg: float) -> float: + if world == null or not world.has_method("get_fishing_rotation"): + return -2.0 # provider unavailable; NetPlay keeps the protocol-only fallback + return float(world.call("get_fishing_rotation", gx_m, gz_m, heading_deg)) + +func can_fishing_position(gx_m: float, gz_m: float, heading_deg: float) -> bool: + if world == null or not world.has_method("can_fishing_position"): + return true + return bool(world.call("can_fishing_position", gx_m, gz_m, heading_deg)) func setup(m2client: Node, mount: Node3D) -> void: client = m2client @@ -82,8 +117,10 @@ func setup(m2client: Node, mount: Node3D) -> void: client.entity_info.connect(_on_info) client.chat.connect(func(t, v, s): chat_line.emit(t, v, s) - if t in [0, 3, 4, 6]: # 普通/队伍/公会/喊话 -> 头顶气泡 - _bubble(int(v), String(s))) + # §8.8 RecvChatPacket:1372 —— 带 VID 的聊天里,除 SHOUT / COMMAND 外都进头顶「聊天尾标」 + # (line 去掉 "名字: " 前缀,INFO/NOTICE 类不去) + if int(v) != 0 and ChatTail.wants_chat_tail(int(t)): + _bubble(int(v), ChatTail.line_for_tail(int(t), String(s)))) # entity_moved 不强依赖:_process 每帧统一插值。留个钩子便于调试。 client.entity_moved.connect(_on_moved) client.vitals_changed.connect(_on_vitals) @@ -103,6 +140,12 @@ func setup(m2client: Node, mount: Node3D) -> void: client.pvp_changed.connect(_on_pvp_changed) if client.has_signal("duel_changed"): client.duel_changed.connect(_on_duel_changed) + # §3.6 飞行物实体:GC_CREATE_FLY 之后由 FlyManager 跑真实弹道 + 线段命中判定。 + _fly_mgr = FlyObject.new() + _fly_mgr.name = "FlyManager" + add_child(_fly_mgr) + _fly_mgr.setup(parent, world) + _fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause)) # 已在局内(重连 / setup 是协程,进来时 spawn burst 已被 pump 抽干)—— # 把当前所有实体补建一遍,别漏掉进游戏那一批怪 / NPC / 玩家。 @@ -134,6 +177,83 @@ func set_local_vid(vid: int) -> void: func set_local_node(node: Node3D) -> void: _local_node = node +# game.py ShowName/HideName toggles actor name plates without changing the +# network data layer. Shop/PVP labels and chat bubbles remain independent. +# LALT 按 / 抬 -> game.py ShowNameFlag(__IsShowName 的一半)。 +func set_names_visible(visible: bool) -> void: + _alt_held = visible + _refresh_all_name_visibility() + +func names_visible() -> bool: + return _alt_held + +# uigameoption「总显示名字」radio -> CPythonSystem::SetAlwaysShowNameFlag(__IsShowName 的另一半)。 +func set_always_show_name(on: bool) -> void: + _always_show_name = on + _refresh_all_name_visibility() + +# targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。 +func set_target_vid(vid: int) -> void: + if _target_vid == vid: + return + var prev := _target_vid + _target_vid = vid + for v in [prev, vid]: + if v != 0 and _by_vid.has(v): + _set_name_visibility(_by_vid[v]) + +func _refresh_all_name_visibility() -> void: + for node in _by_vid.values(): + _set_name_visibility(node) + +# game.py OnRender:本地主角平面坐标(ShowAllTextTail 的距离中心);未知返回 (INF,INF)。 +func _main_planar_cm() -> Vector2: + if client and client.has_method("get_entity") and _main_vid != 0: + var m: Dictionary = client.get_entity(_main_vid) + if not m.is_empty() and m.get("pos_cm", null) is Vector3: + var mc: Vector3 = m["pos_cm"] + return Vector2(mc.x, mc.y) + return Vector2(INF, INF) + +# name_show.name_visible_for 的 net_world 侧组装:forced(target / chat bNameFlag)+ +# __IsShowName()(_always_show_name or _alt_held)+ 3500 平面距 + isShow/wall/CanPick 资格。 +func _name_shown(root: Node3D) -> bool: + if root == null or not is_instance_valid(root): + return false + var vid := int(root.get_meta("vid", 0)) + var e: Dictionary = {} + if vid != 0 and client and client.has_method("get_entity"): + e = client.get_entity(vid) + var dead := bool(e.get("dead", false)) + var is_wall := _is_wall(int(e.get("race", 0))) + # CanPickInstance() 更细(隐身 / 特定 affect 亦不可拾取)—— 这里只按死透近似,记 seam ⑩。 + var alive := not dead + var forced := vid != 0 and vid == _target_vid + var ct: Variant = _chat_tails.get(vid, null) + if ct is Dictionary and bool(ct.get("name_flag", false)): + forced = true + var show_name := NameShow.is_show_name(_always_show_name, _alt_held) + var dist := 0.0 # 主角位置未知 -> 不按距离裁剪(fail-open,同 _is_visible_entity) + if not e.is_empty() and e.get("pos_cm", null) is Vector3: + var mc := _main_planar_cm() + if mc.x != INF: + dist = NameShow.planar_distance_cm(_planar_cm(e), mc) + return NameShow.name_visible_for(forced, show_name, dist, alive, is_wall, alive) + +func _set_name_visibility(root: Node3D) -> void: + if root == null or not is_instance_valid(root): + return + var shown := _name_shown(root) + var tag := root.get_node_or_null("Label3D") as Label3D + if tag: + tag.visible = shown + # §8.8 称号 / 等级 / 公会名子标签随主名字整体开关;各自「是否有内容」仍由 + # _apply_text_tail 按 RefreshTextTail 决定。 + for sub in ["GuildTag", "TitleTag", "LevelTag"]: + var st := root.get_node_or_null(sub) as Label3D + if st: + st.visible = shown and st.text != "" + # ClientVS22 destroys the network actor manager when Loading starts. This is # called from M2Client.world_reset before the replacement connection sends its # first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst. @@ -202,10 +322,15 @@ func _on_spawn(d: Dictionary) -> void: node.set_meta("vid", vid) node.set_meta("func", int(d.get("func", FUNC_WAIT))) node.set_meta("mount_vnum", int(d.get("mount_vnum", 0))) + var ch_type := int(d.get("ch_type", 0)) + node.set_meta("cursor_shape", "TALK" if ch_type in [1, 4] else "ATTACK") _by_vid[vid] = node + _set_name_visibility(node) _refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_pvp_tag(vid) _apply_field_updates(node, d) + _apply_name_color(vid) + _apply_text_tail(vid) entity_added.emit(node, vid) if vid == _main_vid: main_entity_ready.emit(node, vid) @@ -223,6 +348,8 @@ func _on_info(vid: int, d: Dictionary) -> void: n.set_display_name(nm) _refresh_shop_sign(n, str(d.get("shop_sign", ""))) _apply_field_updates(n, d) + _apply_name_color(vid) + _apply_text_tail(vid) # §2.5: push the mutable entity fields onto a live view in the exact order the # reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp @@ -230,12 +357,18 @@ func _on_info(vid: int, d: Dictionary) -> void: # SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment -> # SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule # and views that do not model a field simply skip it. +# +# §4.1 修改 5(远端换装):armor/weapon/hair 字段顺序已 1:1(上面三行)。变身 / 时装 +# 遮挡由模型侧统一处理 —— 真模型(PlayerView + EquipModel)在 refresh() 里过 +# part_hiding.gd(见 equip_model.gd)。远端 PC 由 RemotePlayerView 消费 +# awPart[CHR_EQUIPPART_ARMOR..HAIR];本地玩家仍由 EquipModel 读取 get_equipment()。 func _apply_field_updates(n: Node3D, d: Dictionary) -> void: var parts: Variant = d.get("parts", []) - if parts is Array and parts.size() >= 3: + if parts is Array and parts.size() >= 4: _set_wear_slot(n, "set_armor", 0, int(parts[0])) _set_wear_slot(n, "set_weapon", 1, int(parts[1])) - _set_wear_slot(n, "set_hair", 2, int(parts[2])) + _set_wear_slot(n, "set_head", 2, int(parts[2])) + _set_wear_slot(n, "set_hair", 3, int(parts[3])) if n.has_method("set_guild"): n.call("set_guild", int(d.get("guild", 0))) if n.has_method("set_affect_flags"): @@ -366,9 +499,167 @@ func _display_name(d: Dictionary) -> String: return r return str(d.get("name", "")) +# --- §8.8 头顶名字色(CInstanceBase::GetNameColor)-------------------------------- +# net_world 直接持有 M2Client,能自建 name_color_index 需要的会话上下文 +# (net_play._attack_ctx 同款)。kind_resolver 由 game_scene 注入 net_play._entity_kind +# 以补本 fork「NPC/怪 bType 都是 0」的分类缺口;未注入时退化到 ch_type。 + +func _entity_kind_of(d: Dictionary) -> int: + if kind_resolver.is_valid(): + return int(kind_resolver.call(d)) + return int(d.get("ch_type", 0)) + +func _name_color_ctx() -> Dictionary: + var duel: Dictionary = client.get_duel() if client and client.has_method("get_duel") else {} + var duel_mode := EntityRules.DUEL_NONE + if bool(duel.get("active", false)): + duel_mode = EntityRules.DUEL_CANNOTATTACK if bool(duel.get("cannot_attack", false)) \ + else EntityRules.DUEL_START + var pvp_pairs: Array = [] + if client and client.has_method("get_pvp_relations"): + for r in client.get_pvp_relations(): + if int(r.get("mode", 0)) != 0: + pvp_pairs.append([int(r.get("src_vid", 0)), int(r.get("dst_vid", 0))]) + var gvg_pairs: Array = [] + if client and client.has_method("get_guild_wars"): + for w in client.get_guild_wars(): + gvg_pairs.append([int(w.get("src", 0)), int(w.get("dst", 0))]) + var party_vids: Array = [] + if client and client.has_method("get_party"): + for m in client.get_party(): + party_vids.append(int(m.get("vid", 0))) + return { + "main_vid": _main_vid, + "duel_mode": duel_mode, + "duel_opponents": duel.get("opponents", []), + "pvp_pairs": pvp_pairs, + "gvg_pairs": gvg_pairs, + "party_vids": party_vids, + } + +func _main_name_snapshot() -> Dictionary: + if _main_vid == 0 or client == null or not client.has_method("get_entity"): + return {} + var m: Dictionary = client.get_entity(_main_vid) + if m.is_empty(): + return {} + m = m.duplicate() + m["kind"] = _entity_kind_of(m) + return m + +func _apply_name_color(vid: int) -> void: + if vid == 0: + return + var n: Node3D = _by_vid.get(vid, null) + if n == null or not is_instance_valid(n) or not n.has_node("Label3D"): + return + if client == null or not client.has_method("get_entity"): + return + var e: Dictionary = client.get_entity(vid) + if e.is_empty(): + return + var self_e := e.duplicate() + self_e["kind"] = _entity_kind_of(e) + var col := NameColor.name_color(self_e, _main_name_snapshot(), _name_color_ctx()) + (n.get_node("Label3D") as Label3D).modulate = col + +func _retint_all_names() -> void: + if client == null or not client.has_method("get_entity"): + return + var ctx := _name_color_ctx() + var main_e := _main_name_snapshot() + for vid in _by_vid.keys(): + var n: Node3D = _by_vid[vid] + if not is_instance_valid(n) or not n.has_node("Label3D"): + continue + var e: Dictionary = client.get_entity(int(vid)) + if e.is_empty(): + continue + var self_e := e.duplicate() + self_e["kind"] = _entity_kind_of(e) + (n.get_node("Label3D") as Label3D).modulate = NameColor.name_color(self_e, main_e, ctx) + +# constinfo.SET_CHRNAME_COLOR_INDEX 对应的运行期开关(默认关)。改后立即整表重刷。 +func set_empire_name_mode(enabled: bool) -> void: + NameColor.set_empire_name_mode(enabled) + _retint_all_names() + +# --- §8.8 头顶「公会名 / 称号 / 等级」标签(CInstanceBase::RefreshTextTail 系)-------- +# _apply_name_color 的姊妹:把 self_e 快照喂给 text_tail.gd,落到 GuildTag/TitleTag/LevelTag。 + +func _resolve_title_name(grade: int) -> String: + if title_name_resolver.is_valid(): + var s: String = str(title_name_resolver.call(grade)) + if s != "": + return s + return TextTail.title_name_key(grade) # seam ⑧:未注入 Locale -> 裸 key + +func _resolve_guild_name(guild_id: int) -> String: + if guild_id != 0 and client and client.has_method("get_guild_name"): + return str(client.get_guild_name(guild_id)) + return "" + +func _apply_text_tail_to(n: Node3D, e: Dictionary) -> void: + if n == null or not is_instance_valid(n): + return + var alignment := int(e.get("alignment", 0)) + var level := int(e.get("level", 0)) + var guild_id := int(e.get("guild", 0)) + var shown := _name_shown(n) + + var gtag := n.get_node_or_null("GuildTag") as Label3D + if gtag: + var g := TextTail.refresh_guild(guild_id, _resolve_guild_name(guild_id)) + gtag.text = g["name"] if g["show_guild"] else "" + gtag.modulate = g["color"] + gtag.visible = shown and gtag.text != "" + + var ttag := n.get_node_or_null("TitleTag") as Label3D + if ttag: + var t := TextTail.refresh_title(alignment) + ttag.text = _resolve_title_name(int(t["grade"])) if t["show_title"] else "" + ttag.modulate = t["color"] + ttag.visible = shown and ttag.text != "" + + var ltag := n.get_node_or_null("LevelTag") as Label3D + if ltag: + var lv := TextTail.refresh_level(level) + ltag.text = lv["text"] if lv["show_level"] else "" + ltag.modulate = lv["color"] + ltag.visible = shown and ltag.text != "" + +func _apply_text_tail(vid: int) -> void: + if vid == 0 or client == null or not client.has_method("get_entity"): + return + var n: Node3D = _by_vid.get(vid, null) + if n == null or not is_instance_valid(n): + return + var e: Dictionary = client.get_entity(vid) + if e.is_empty(): + return + _apply_text_tail_to(n, e) + +func _retint_all_text_tails() -> void: + if client == null or not client.has_method("get_entity"): + return + for vid in _by_vid.keys(): + var n: Node3D = _by_vid[vid] + if not is_instance_valid(n): + continue + var e: Dictionary = client.get_entity(int(vid)) + if e.is_empty(): + continue + _apply_text_tail_to(n, e) + +# CPythonTextTail::EnablePKTitle(game.py:155 用 constInfo.PVPMODE_ENABLE 初始化)。 +func set_pk_title_enabled(enabled: bool) -> void: + TextTail.set_pk_title_enabled(enabled) + _retint_all_text_tails() + func _on_despawn(vid: int) -> void: _duel_opponents.erase(vid) _fading.erase(vid) + _chat_tails.erase(vid) var n: Node3D = _by_vid.get(vid, null) if n: _by_vid.erase(vid) @@ -380,6 +671,8 @@ func _on_main_set(vid: int) -> void: var n: Node3D = _by_vid.get(vid, null) if n: main_entity_ready.emit(n, vid) + # 主角变化会改写所有 PC 的相对名字色(同帝国 / PVP / 决斗 / 队伍判定都相对主角)。 + _retint_all_names() func _on_moved(_vid: int) -> void: pass # 插值在 _process 里统一做 @@ -425,8 +718,6 @@ func _on_motion(vid: int, victim_vid: int, motion: int) -> void: n.call("set_anim_state", state) else: n.set_meta("motion_state", state) - if n.has_node("Label3D"): - (n.get_node("Label3D") as Label3D).modulate = _state_tint(state) # GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响 # TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。 @@ -440,6 +731,8 @@ func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void: _pvp_relations[key] = {"src_vid": src_vid, "dst_vid": dst_vid, "mode": mode} _refresh_pvp_tag(src_vid) _refresh_pvp_tag(dst_vid) + _apply_name_color(src_vid) + _apply_name_color(dst_vid) func _pvp_key(a: int, b: int) -> String: return "%d:%d" % [min(a, b), max(a, b)] @@ -496,34 +789,60 @@ func _on_duel_changed(duel: Dictionary) -> void: _duel_opponents[opponent] = true for vid in _by_vid.keys(): _refresh_pvp_tag(int(vid)) + _apply_name_color(int(vid)) -# 头顶聊天气泡(~4s 淡出)。本地玩家 vid 走 main_bubble 信号让上层处理。 +# 头顶聊天 / 信息尾标(CPythonTextTail::RegisterChatTail / RegisterInfoTail)。 +# 每 vid 一条 "bubble" 子节点,LivingTime 到点(默认 5s)由 _expire_chat_tails 硬删—— +# 参考端无淡出。本地玩家 vid 走 main_bubble 信号让上层(game_scene._player_bubble)处理。 signal main_bubble(text: String) func _bubble(vid: int, text: String) -> void: + # CPythonTextTail::RegisterChatTail —— 普通聊天尾标(白、bNameFlag TRUE) if vid == _local_vid or vid == 0: main_bubble.emit(text) return var n: Node3D = _by_vid.get(vid, null) if n == null: return - var old := n.get_node_or_null("bubble") - if old: - old.queue_free() - var lbl := Label3D.new() - lbl.name = "bubble" - lbl.text = text - lbl.position.y = 2.35 - lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED - lbl.no_depth_test = true - lbl.pixel_size = 0.0055 - lbl.modulate = Color(1, 1, 1) - lbl.outline_size = 6 - n.add_child(lbl) - var tw := create_tween() - tw.tween_interval(3.0) - tw.tween_property(lbl, "modulate:a", 0.0, 1.0) - tw.tween_callback(lbl.queue_free) + _apply_chat_tail(n, ChatTail.register_chat(_chat_tails, vid, text, Time.get_ticks_msec())) + +func register_info_tail(vid: int, text: String) -> void: + # CPythonTextTail::RegisterInfoTail —— 信息尾标(淡红、bNameFlag FALSE)。 + # game.py:930 / :936 技能 / 射击错误尾标走这里;与聊天尾标共用同一张 _chat_tails。 + if vid == _local_vid or vid == 0: + main_bubble.emit(text) + return + var n: Node3D = _by_vid.get(vid, null) + if n == null: + return + _apply_chat_tail(n, ChatTail.register_info(_chat_tails, vid, text, Time.get_ticks_msec())) + +func _apply_chat_tail(n: Node3D, tail: Dictionary) -> void: + var lbl := n.get_node_or_null("bubble") as Label3D + if lbl == null: + lbl = Label3D.new() + lbl.name = "bubble" + lbl.position.y = 2.35 + lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED + lbl.no_depth_test = true + lbl.pixel_size = 0.0055 + lbl.outline_size = 6 + n.add_child(lbl) + lbl.text = String(tail.get("text", "")) + lbl.modulate = tail.get("color", ChatTail.CHAT_COLOR) + # UpdateAllTextTail:chat 尾标 bNameFlag TRUE 时强制显示该角色名字(不受 3500 限制)。 + _set_name_visibility(n) + +func _expire_chat_tails() -> void: + # UpdateAllTextTail:LivingTime < now 即删除对应 "bubble" 节点 + for vid in ChatTail.expire(_chat_tails, Time.get_ticks_msec()): + var n: Node3D = _by_vid.get(vid, null) + if n: + var lbl := n.get_node_or_null("bubble") + if lbl: + lbl.queue_free() + # 尾标没了 -> bNameFlag 强显撤销,名字回到 __IsShowName / 距离裁决。 + _set_name_visibility(n) func _on_vitals(vid: int) -> void: var n: Node3D = _by_vid.get(vid, null) @@ -549,12 +868,15 @@ func _on_dead(vid: int) -> void: if n.has_method("set_anim_state"): n.call("set_anim_state", "dead") -# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体。原客户端在 -# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以 -# 这里也消费 shooter 的目标队列,避免远端技能没有终点而被静默丢弃。 +# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在 +# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费 +# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道 +# (CFlyingManager::CreateIndexedFly 的 INDEX_FLY_TYPE_NORMAL 分支:起点取射手像素位、 +# 终点取目标 actor、canAttack=false —— 命中 / 伤害是服务端权威,客户端只做可视 + 命中时机)。 func _on_fly(_type: int, start_vid: int, end_vid: int) -> void: var a: Vector3 = _entity_pos(start_vid) var b: Vector3 = _entity_pos(end_vid) + var target_node: Node3D = _by_vid.get(end_vid, null) var shooter: Node3D = _by_vid.get(start_vid, null) if b == Vector3.INF and shooter: var target: Dictionary = {} @@ -565,28 +887,28 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void: var target_vid := int(target.get("vid", 0)) if target_vid != 0: b = _entity_pos(target_vid) + target_node = _by_vid.get(target_vid, null) else: var target_cm: Variant = target.get("cm", null) if target_cm is Vector2: b = _server_cm_to_world(target_cm) + target_node = null if a == Vector3.INF or b == Vector3.INF or parent == null: return - var proj := MeshInstance3D.new() - var sm := SphereMesh.new() - sm.radius = 0.08 - sm.height = 0.16 - proj.mesh = sm - var mat := StandardMaterial3D.new() - mat.albedo_color = Color(1.0, 0.9, 0.4) - mat.emission_enabled = true - mat.emission = Color(0.9, 0.7, 0.2) - proj.material_override = mat - parent.add_child(proj) - proj.global_position = a + Vector3(0, 1.0, 0) - var dur := maxf(0.15, a.distance_to(b) / 40.0) - var tw := create_tween() - tw.tween_property(proj, "global_position", b + Vector3(0, 1.0, 0), dur) - tw.tween_callback(proj.queue_free) + if _fly_mgr == null: + return + _fly_mgr.parent = parent + _fly_mgr.world = world + var start_w := a + Vector3(0, 1.0, 0) + var goal_w := b + Vector3(0, 1.0, 0) + # POC 无 .fly 加载器:合成一组弓箭弹道参数(换算成米),其余留 CFlyingData 默认。 + var d = FlyObject.FlyData.new() + d.init_vel = 45.0 # ~4500 cm/s 的箭速 + d.flat_range = maxf(start_w.distance_to(goal_w) * 1.5, 30.0) + d.bomb_range = 0.6 # 60 cm 命中球 + d.pierce_count = 0 + var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w + _fly_mgr.spawn(start_w, tgt, false, d, _type) # GC_FLY_TARGETING / GC_ADD_FLY_TARGETING — retain the authoritative target on # the shooter node so a later projectile cue can use it even before a target @@ -688,6 +1010,17 @@ func _process(dt: float) -> void: if client == null or parent == null: return _update_visibility() + # §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新 + # 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。 + _name_color_accum += dt + if _name_color_accum >= 0.5: + _name_color_accum = 0.0 + _retint_all_names() + _retint_all_text_tails() + _expire_chat_tails() + # §8.4 / §8.6:ShowAllTextTail 的 3500 平面距随主角 / 实体移动变化 —— 逐帧太密, + # 用同一 ~2Hz 节流兜底(LALT / 选项 / 选中 / 尾标切换已各自即时刷新)。 + _refresh_all_name_visibility() for vid in _by_vid.keys(): var n: Node3D = _by_vid[vid] if not is_instance_valid(n): @@ -696,11 +1029,11 @@ func _process(dt: float) -> void: var e: Dictionary = client.get_entity(vid) if e.is_empty(): continue + # §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 + + # SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再 + # 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。 var want := _grounded(e.get("pos", n.position)) - if n.position.distance_to(want) > snap_dist: - n.position = want - else: - n.position = n.position.lerp(want, clampf(follow_lerp * dt, 0.0, 1.0)) + n.position = want # 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。 var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0))) n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0)) @@ -727,16 +1060,8 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void: state = "emotion" if n.has_method("set_anim_state"): n.call("set_anim_state", state) - elif n.has_node("Label3D"): - (n.get_node("Label3D") as Label3D).modulate = _state_tint(state) - -func _state_tint(s: String) -> Color: - match s: - "walk": return Color(0.7, 0.9, 1.0) - "run": return Color(0.6, 1.0, 0.6) - "attack": return Color(1.0, 0.5, 0.4) - "skill": return Color(1.0, 0.8, 0.3) - _: return Color(1, 1, 1) + # 占位胶囊(无 set_anim_state)不再借头顶名字 Label3D 的 modulate 表现动作态—— + # 该 modulate 现在专属 §8.8 名字色(GetNameColor)。 func _grounded(p: Variant) -> Vector3: # p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。 @@ -771,7 +1096,16 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void: tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.pixel_size = 0.006 + tag.visible = false # _on_spawn 随后 _set_name_visibility(node) 按 __IsShowName / 距离定夺 root.add_child(tag) + # §8.8 头顶「公会名 / 称号 / 等级」子标签(CPythonTextTail 的 pGuildNameTextInstance / + # pTitleTextInstance / pLevelTextInstance)。参考端是屏幕空间:公会名在名字上方 + # c_fyGuildNamePosition(15px)、称号 / 等级贴右侧。这里在 billboard 3D 里近似成竖排 + # (公会名上、称号 / 等级下),精确像素排版差异记为 §8.8 seam ⑦。内容 / 颜色由 + # _apply_text_tail 按 RefreshTextTail / AttachTitle / AttachLevel 决定,初始空 = 隐藏。 + root.add_child(_mk_sub_tag("GuildTag", 2.34, TextTail.GUILD_NAME_COLOR)) + root.add_child(_mk_sub_tag("TitleTag", 1.86, Color(1, 1, 1))) + root.add_child(_mk_sub_tag("LevelTag", 1.74, TextTail.LEVEL_COLOR)) var bar := Sprite3D.new() bar.name = "HpBar" bar.texture = _white_1px() @@ -783,6 +1117,18 @@ func _attach_nameplate(root: Node3D, d: Dictionary) -> void: bar.scale.x = 1.0 root.add_child(bar) +func _mk_sub_tag(sub_name: String, y: float, col: Color) -> Label3D: + var t := Label3D.new() + t.name = sub_name + t.text = "" + t.position.y = y + t.billboard = BaseMaterial3D.BILLBOARD_ENABLED + t.no_depth_test = true + t.pixel_size = 0.005 + t.modulate = col + t.visible = false + return t + static var _white_tex: Texture2D static func _white_1px() -> Texture2D: if _white_tex == null: diff --git a/project/netbridge_test.gd b/project/netbridge_test.gd index f7e87c93..bba235ee 100644 --- a/project/netbridge_test.gd +++ b/project/netbridge_test.gd @@ -74,7 +74,7 @@ func _test_m2client_surface() -> void: var c: Object = ClassDB.instantiate("M2Client") get_root().add_child(c) for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "select_empire", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities", - "get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect", + "get_main_vid", "get_main_pid", "get_points", "get_target", "select_character", "enter_game", "reconnect", "send_emoticon", "suspend", "resume", "move_item", "use_item", "drop_item", "drop_item_count", "use_item_to_item", "give_item", "pickup_item", "get_inventory", "get_equipment", "get_belt_inventory", "get_view_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_duel", "get_lover", "get_land_areas", "get_observers", "is_observer_mode", "get_observer_count", "has_mobile_flag", "combo_skill_enabled", "get_stamina_state", "whisper", "party_invite", "party_answer", "party_leave", "party_set_distribute", "party_set_state", "get_party", "get_party_distribute_mode", @@ -89,7 +89,7 @@ func _test_m2client_surface() -> void: "refine", "ds_refine", "get_dragon_souls", "get_channel", "get_server_time", "get_npc_marks", "get_world_markers"]: _ck(c.has_method(m), "M2Client.%s bound" % m) for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set", "empire_changed", - "chat", "suspended", "resumed", "vitals_changed", "entity_dead", "damage", + "chat", "emoticon_requested", "suspended", "resumed", "vitals_changed", "entity_dead", "damage", "motion", "points_changed", "target_info", "inventory_changed", "ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received", "entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed", @@ -224,11 +224,11 @@ func _test_netbridge() -> void: _ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z _ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE") + # §3.2:位置插值移交 C++ EntityStore::tick(),net_world._process 直接跟随 e.pos, + # 不再叠自己的 follow_lerp。这里 FakeClient 的 pos 是瞬时值,节点应当直接对齐。 fc.move_to(2000, Vector3(11, 0, 41)) await process_frame - var d1 := mob.position.distance_to(Vector3(11, 0, -41)) - for i in 20: await process_frame - _ck(mob.position.distance_to(Vector3(11, 0, -41)) < d1, "near move -> lerp closes in") + _ck(mob.position.distance_to(Vector3(11, 0, -41)) < 0.01, "move -> node follows e.pos directly") var got_chat := [""] nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s) diff --git a/project/netplay_test.gd b/project/netplay_test.gd index 76d1a781..80eab135 100644 --- a/project/netplay_test.gd +++ b/project/netplay_test.gd @@ -18,18 +18,28 @@ class FakeClient extends Node: signal points_changed(points: Dictionary) signal target_info(vid: int, hp_percent: int) signal observer_mode_changed(enabled: bool) + signal fishing_event(subheader: int, info: int, dir: int) signal chat(type: int, vid: int, text: String) var ents := {} var main := 0 var in_game := true - var calls := {"move": [], "attack": [], "set_target": [], "click_npc": []} + var ground_items := [] + var skills := [] + var calls := {"move": [], "attack": [], "set_target": [], "click_npc": [], + "pickup": [], "fly": [], "fishing": []} func is_in_game() -> bool: return in_game func get_main_vid() -> int: return main func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) + func get_entities() -> Array: return ents.values() func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true func set_target(vid) -> bool: calls.set_target.append(vid); return true func click_npc(vid) -> bool: calls.click_npc.append(vid); return true + func pickup_item(vid) -> bool: calls.pickup.append(vid); return true + func add_fly_targeting(vid, x, y) -> bool: calls.fly.append([vid, x, y]); return true + func fishing(rot_deg) -> bool: calls.fishing.append(rot_deg); return true + func get_ground_items() -> Array: return ground_items + func get_skills() -> Array: return skills class FakePC extends Node: signal target_selected(node: Node3D) @@ -37,7 +47,10 @@ class FakePC extends Node: signal anim_state(state: String) var player: Node3D var server_speed := 1.0 + var walk_calls := [] + var frozen := false func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0 + func walk_to(p: Vector3) -> void: walk_calls.append(p) class FakeHud extends Node: var vitals := [] @@ -87,7 +100,41 @@ func _run() -> void: fc.entity_main_set.emit(1000) fc.entity_info.emit(1000, fc.ents[1000]) _ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale") - _ck(is_equal_approx(np.attack_period, 0.4), "entity attack_speed -> attack cadence") + # §3.3:attack_speed 只喂攻速系数(缩放播放速率),不再在收包时把 period 直接写死 + _ck(is_equal_approx(np._atk_speed_factor, 1.5), "entity attack_speed -> attack-speed factor") + _ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5), + "no motion data -> cadence = DEFAULT_ATTACK_PERIOD / factor") + + # §5.3:SHORSE::CanAttack / CPythonPlayer::__CanAttack 的坐骑等级门。 + # 普通坐骑 level 1 不能攻击;新坐骑还需要骑乘术 level 11 或 master。 + fc.skills = [{"id": 109, "level": 1, "master": 0}] + fc.ents[1000]["mount_vnum"] = 20101 + _ck(not np._can_attack_horse_level(), "horse level 1 -> CanAttackHorseLevel false") + fc.ents[1000]["mount_vnum"] = 20205 + _ck(np._can_attack_horse_level(), "horse level 2 -> CanAttackHorseLevel true") + _ck(not np._can_attack(), "new horse skill below level 11 -> attack blocked") + fc.skills[0]["level"] = 11 + _ck(np._can_attack(), "new horse skill level 11 -> attack allowed") + fc.ents[1000]["mount_vnum"] = 0 + fc.skills = [] + + # §5.3:PythonPlayerInputKeyboard::SetAttackKeyState 在 FISHING mode + # 改走 NEW_Fishing,不设置普通攻击键;GC_FISHING 到达后方向键/地面点击 + # 走 NEW_CancelFishing,且 500ms 内不重复发取消包。 + np.combo_motion_mode = np.MOTION_MODE_FISHING + pc.player.rotation.y = 0.0 + fc.calls.fishing.clear() + np.set_attack_key(true) + _ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 270.0), + "fishing attack key -> CG_FISHING with MapCoord heading") + _ck(not np._attack_key_down, "fishing attack key does not enable normal attack") + fc.fishing_event.emit(0, 1000, 0) + _ck(np.is_fishing(), "GC_FISHING START -> IsFishing state") + _ck(np.cancel_fishing(), "NEW_CancelFishing sends first cancel") + _ck(fc.calls.fishing.size() == 2 and is_equal_approx(float(fc.calls.fishing[1]), 0.0), + "cancel fishing -> CG_FISHING(0)") + _ck(not np.cancel_fishing(), "NEW_CancelFishing throttles within 500ms") + np.combo_motion_mode = np.MOTION_MODE_GENERAL # 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500) @@ -116,7 +163,7 @@ func _run() -> void: mob.set_meta("vid", 2000) root.add_child(mob) mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内 - fc.ents[2000] = {"vid": 2000, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false} + fc.ents[2000] = {"vid": 2000, "ch_type": 2, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false} nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中 pc.target_selected.emit(mob) _ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)") @@ -130,14 +177,191 @@ func _run() -> void: pc.target_selected.emit(npc) _ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)") - # 5) 攻击距离内 -> 按 attack_period 自动 attack + # 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack + np.set_attack_key(true) for i in 4: await process_frame - _ck(fc.calls.attack.size() >= 1, "auto-attack fired, got %d" % fc.calls.attack.size()) + _ck(fc.calls.attack.size() >= 1, "space-held attack fired, got %d" % fc.calls.attack.size()) if fc.calls.attack.size() >= 1: _ck(fc.calls.attack[0][1] == 2000, "attack victim == 2000") var n1: int = fc.calls.attack.size() await process_frame _ck(fc.calls.attack.size() == n1, "attack throttled within period") + np.set_attack_key(false) + + # 5b) §3.4 预约动作状态机(__ReserveProcess_ClickActor 的 10 步顺序) + # 步骤 2:目标在可点击距离外 -> 继续靠近(walk_to),不发攻击 + pc.walk_calls.clear() + fc.calls.attack.clear() + mob.position = Vector3(40, 0, -5) # 距玩家 ~37m,远超 1.5m 默认可点击距离 + np.set_attack_key(true) + for i in 3: await process_frame + _ck(pc.walk_calls.size() >= 1, "out-of-range reserved target -> walk_to (approach)") + _ck(fc.calls.attack.is_empty(), "out-of-range -> no attack packet") + _ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "reservation kept while approaching") + + # 步骤 10:进入距离 -> 攻击且预约清空 + mob.position = Vector3(4, 0, -5) + np._attack_cd = 0.0 + fc.calls.attack.clear() + await process_frame + _ck(fc.calls.attack.size() >= 1, "in-range reserved target -> attack fired") + _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation cleared after swing") + + # 步骤 5:自己在安全区 -> OnCannotAttack('IN_SAFE') + 清预约,不攻击 + var cannot: Array = [] + np.cannot_act.connect(func(code): cannot.append(code)) + fc.ents[1000]["in_safe"] = true + np._attack_cd = 0.0 + fc.calls.attack.clear() + for i in 2: await process_frame + _ck(cannot.has("IN_SAFE"), "main in safe zone -> cannot_act('IN_SAFE')") + _ck(fc.calls.attack.is_empty(), "safe zone -> no attack packet") + fc.ents[1000]["in_safe"] = false + + # __Update_AutoAttack:目标死亡 -> 清 auto-attack + 目标 + fc.ents[2000]["dead"] = true + np._attack_cd = 0.0 + await process_frame + _ck(np._auto_attack_vid == 0, "dead reserved target -> auto-attack cleared") + fc.ents[2000]["dead"] = false + np.set_attack_key(false) + np._clear_reserved() + + # 5c) §3.4 增量 91 —— CLICK_ITEM / CLICK_POSITION / USE_SKILL 预约分派 + # + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting + np._target_vid = 0 + np._auto_attack_vid = 0 + np.set_attack_key(false) + np._clear_reserved() + pc.player.position = Vector3.ZERO + pc.player.rotation.y = 0.0 + pc.walk_calls.clear() + fc.calls.move.clear() + + # CLICK_ITEM:远处掉落物 -> walk_to;到手 -> pickup + FUNC_WAIT + 清预约 + fc.ground_items = [{"vid": 777, "vnum": 1, "owner": "", "pos": Vector3(5, 0, 5)}] # to_world -> (5,0,-5) + np.reserve_click_item(777) + await process_frame + _ck(pc.walk_calls.size() >= 1, "CLICK_ITEM far -> walk_to") + _ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ITEM, "CLICK_ITEM reservation kept while approaching") + pc.player.position = Vector3(5, 0, -5) # 站到物品上 + var mv_n: int = fc.calls.move.size() + await process_frame + _ck(fc.calls.pickup == [777], "CLICK_ITEM in-range -> pickup_item(777)") + _ck(fc.calls.move.size() >= mv_n + 1 and fc.calls.move[mv_n][0] == np.FUNC_WAIT, + "CLICK_ITEM in-range -> FUNC_WAIT state packet") + _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_ITEM cleared after pickup") + + # CLICK_ITEM:物品已消失 -> 清预约,不走 + pc.walk_calls.clear() + fc.ground_items = [] + np.reserve_click_item(888) + await process_frame + _ck(pc.walk_calls.is_empty() and np._reserved_mode == NetPlay.ReservedMode.NONE, + "CLICK_ITEM missing item -> reservation cleared") + + # CLICK_POSITION:延时未到不动;延时耗尽 -> walk_to + 清预约 + pc.walk_calls.clear() + np.reserve_click_ground(Vector3(9, 0, -2)) + _ck(np._reserved_delay > 0.0, "reserve_click_ground sets a reserved delay") + np._reserved_delay = 999.0 + await process_frame + _ck(pc.walk_calls.is_empty(), "CLICK_POSITION waits out the reserved delay") + np._reserved_delay = 0.0 + await process_frame + _ck(pc.walk_calls.size() == 1 and pc.walk_calls[0] == Vector3(9, 0, -2), + "CLICK_POSITION delay elapsed -> walk_to(target)") + _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_POSITION cleared after move") + + # USE_SKILL:range>100 存 -10;射程外 walk_to;射程内 -> hook + set_target + 清预约 + var smob := Node3D.new() + smob.set_meta("vid", 4100) + root.add_child(smob) + nw._by_vid[4100] = smob + smob.position = Vector3(30, 0, 0) + fc.ents[4100] = {"vid": 4100, "ch_type": 2, "pos": smob.position, "hp": 80, "max_hp": 80, "dead": false, "race": 101} + var hook_calls: Array = [] + np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true + pc.player.position = Vector3.ZERO + np.reserve_use_skill(4100, 5, 800.0) + _ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10") + pc.walk_calls.clear() + fc.calls.set_target.clear() + await process_frame + _ck(pc.walk_calls.size() >= 1, "USE_SKILL out of range -> walk_to") + _ck(hook_calls.is_empty(), "USE_SKILL out of range -> no cast") + smob.position = Vector3(3, 0, 0) # 3m < 7.9m 射程 + await process_frame + _ck(hook_calls == [5], "USE_SKILL in range -> use_skill_hook(slot 5)") + _ck(fc.calls.set_target.has(4100), "USE_SKILL in range -> set_target(4100)") + _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL cleared after cast") + np.reserve_use_skill(999999, 5, 300.0) + await process_frame + _ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL missing target -> cleared") + + # __ChangeTargetToPickedInstance:NPC(非战斗目标)-> 清 _target_vid + set_target(0) + np._target_vid = 2000 + fc.calls.set_target.clear() + fc.ents[5000] = {"vid": 5000, "ch_type": 1, "name": "NPC"} + var ok_npc: bool = np._change_target_to_picked_instance(5000) + _ck(not ok_npc and np._target_vid == 0 and fc.calls.set_target == [0], + "_change_target_to_picked_instance(NPC) -> target cleared") + fc.calls.set_target.clear() + var ok_mob: bool = np._change_target_to_picked_instance(4100) + _ck(ok_mob and np._target_vid == 4100 and fc.calls.set_target == [4100], + "_change_target_to_picked_instance(monster) -> target set") + + # send_fly_targeting —— 把早前实体挪远(不销毁,后续用例还引用),铺一组已知几何 + np._target_vid = 0 + np._clear_reserved() + pc.player.position = Vector3.ZERO + for v in [2000, 4100]: + if nw._by_vid.has(v): + nw._by_vid[v].position = Vector3(9999, 0, 9999) + if fc.ents.has(v): + fc.ents[v]["pos"] = Vector3(9999, 0, 9999) + var mk := func(v: int, p: Vector3) -> void: + var n := Node3D.new() + n.set_meta("vid", v) + n.position = p + root.add_child(n) + nw._by_vid[v] = n + fc.ents[v] = {"vid": v, "ch_type": 2, "pos": p, "hp": 50, "max_hp": 50, "dead": false, "race": 101} + mk.call(4200, Vector3(0, 0, -10)) # 主目标,正前方 (-Z) + mk.call(4201, Vector3(2, 0, -10)) # 侧翼,扇内 + mk.call(4202, Vector3(-2, 0, -10)) # 侧翼,扇内 + mk.call(4203, Vector3(0, 0, 12)) # 背后,扇外 + + fc.calls.fly.clear() + var extras: int = np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.FAN) + _ck(extras == 2, "FAN send_fly_targeting: 2 extras (max 3 - primary), got %d" % extras) + var fly_vids := [] + for f in fc.calls.fly: + fly_vids.append(f[0]) + _ck(fly_vids.size() == 2 and not fly_vids.has(4200), "FAN: 2 extra packets, primary not re-sent") + _ck(not fly_vids.has(4203), "FAN: target-behind instance excluded from fan") + _ck(4201 in fly_vids and 4202 in fly_vids, "FAN: both flankers picked") + + fc.calls.fly.clear() + var extras_c: int = np.send_fly_targeting(4200, 2500.0, 4, NetPlay.FlyShape.CIRCLE) + _ck(extras_c == 3, "CIRCLE: 3 extras (max 4 - primary), got %d" % extras_c) + var cvids := [] + for f in fc.calls.fly: + cvids.append(f[0]) + _ck(4203 in cvids, "CIRCLE: behind-target instance included (no fan filter)") + + fc.calls.fly.clear() + var extras_r: int = np.send_fly_targeting(4200, 2500.0, 6, NetPlay.FlyShape.FAN) + _ck(extras_r == 5, "FAN shortfall: 5 extras for max 6, got %d" % extras_r) + var zero_fills := 0 + for f in fc.calls.fly: + if f[0] == 0: + zero_fills += 1 + _ck(zero_fills >= 1, "FAN shortfall: random vid=0 positions fill the remainder") + _ck(np.send_fly_targeting(4200, 2500.0, 1, NetPlay.FlyShape.FAN) == 0, "max_count<=1 -> no extras") + _ck(np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.SINGLE) == 0, "SINGLE shape -> no extras") + np._clear_reserved() + np._target_vid = 0 # 6) points_changed -> HUD fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80, @@ -165,9 +389,34 @@ func _run() -> void: # 10) 观战模式锁住本地移动/选目标,并清掉当前目标 fc.ents[2000]["dead"] = false fc.observer_mode_changed.emit(true) + var move_n: int = fc.calls.move.size() + var target_n: int = fc.calls.set_target.size() pc.player.position = Vector3(8, 0, -5) pc.moved.emit(pc.player.position) pc.target_selected.emit(mob) - _ck(fc.calls.move.size() == 2 and fc.calls.set_target.size() == 1, + _ck(fc.calls.move.size() == move_n and fc.calls.set_target.size() == target_n, "observer mode blocks local movement and targeting") fc.observer_mode_changed.emit(false) + + # 11) §3.2 上行 6 回调:OnMove 立即发 + 重置节流窗、OnMoving 300ms 节流、 + # OnStop 收尾、OnAttack 额外发 FUNC_COMBO、on_use_skill 发 FUNC_SKILL|motion。 + fc.observer_mode_changed.emit(false) + np._observer_mode = false + np._was_moving = false + np._last_moving_sent_t = 0.0 + fc.calls.move.clear() + pc.player.position = Vector3(1, 0, -1) + pc.moved.emit(pc.player.position) # OnMove + _ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == np.FUNC_MOVE, "OnMove -> immediate FUNC_MOVE") + pc.player.position = Vector3(1.5, 0, -1) + pc.moved.emit(pc.player.position) # OnMoving, throttled (<300ms) + _ck(fc.calls.move.size() == 1, "OnMoving within 300ms throttled") + np._last_moving_sent_t -= 1.0 # 让 300ms 窗口过期 + pc.moved.emit(pc.player.position) # OnMoving, now allowed + _ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE") + pc.anim_state.emit("wait") # OnStop + _ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT") + fc.calls.move.clear() + np.on_use_skill(5, 2) # OnUseSkill + _ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2, + "on_use_skill -> FUNC_SKILL|motion + arg") diff --git a/project/p8_test.gd b/project/p8_test.gd index cef0fa64..0602856e 100644 --- a/project/p8_test.gd +++ b/project/p8_test.gd @@ -31,6 +31,7 @@ class FakeClient extends Node: var safebox_size := 0 var safebox_gold := 0 var inventory := [] + var main_pos := Vector3.ZERO var main_vid := 20 var distribute_mode := 0 @@ -69,6 +70,12 @@ class FakeClient extends Node: func get_safebox_size() -> int: return safebox_size func get_safebox_gold() -> int: return safebox_gold func get_inventory() -> Array: return inventory + func get_item(window, cell) -> Dictionary: + if window != 1: return {} + for item in inventory: + if int(item.get("cell", -1)) == int(cell): return item + return {} + func get_entity(_vid) -> Dictionary: return {"pos_cm": main_pos} func safebox_checkin(sp, w, c) -> bool: calls.append(["sb_checkin", sp, w, c]); return true func safebox_checkout(sp, w, c) -> bool: calls.append(["sb_checkout", sp, w, c]); return true @@ -233,13 +240,12 @@ func _run() -> void: {"pos": 1, "vnum": 19, "price": 800, "count": 1}] fc.shop_opened.emit(8080) _ck(su.is_open(), "shop opens on shop_opened") - _ck(su._list.get_child_count() == 2, "shop: 2 item rows") - # 第一行的「买」按钮 - var buy_btn: Button = null - for b in su._list.get_child(0).find_children("*", "Button", true, false): - buy_btn = b - if buy_btn: - buy_btn.pressed.emit() + _ck(su._grid.get_child_count() == 40, "shop: 5x8 item grid") + # 第一格点击进入购买确认,再确认后固定以 count=1 发包。 + (su._grid.get_child(0) as Button).pressed.emit() + _ck(su._buy_confirm != null, "shop: slot click opens buy confirmation") + if su._buy_confirm: + su._buy_confirm.confirmed.emit() _ck(fc.calls.has(["shop_buy", 0, 1]), "shop: buy row 0") su.sell(5) _ck(fc.calls.has(["shop_sell", 5, 1]), "shop: sell inv cell 5") @@ -258,23 +264,19 @@ func _run() -> void: fc.shop_opened.emit(8080) _ck(su.is_open(), "shop_ex: opens") _ck(su._tabbar.visible and su._tabbar.get_child_count() == 2, "shop_ex: 2 tab buttons") - _ck(su._list.get_child_count() == 2, "shop_ex: tab 0 has 2 rows") - # tab 0 第二行(slot 5)买 -> pos = 0*40 + 5 - var r1_buy: Button = null - for b in su._list.get_child(1).find_children("*", "Button", true, false): - r1_buy = b - if r1_buy: - r1_buy.pressed.emit() + _ck(su._grid.get_child_count() == 40, "shop_ex: tab 0 has 5x8 grid") + # tab 0 第二个货位(slot 5)买 -> pos = 0*40 + 5 + (su._grid.get_child(5) as Button).pressed.emit() + if su._buy_confirm: + su._buy_confirm.confirmed.emit() _ck(fc.calls.has(["shop_buy", 5, 1]), "shop_ex: tab0 slot5 -> buy pos 5") # 切到货架 2 su._tabbar.get_child(1).pressed.emit() _ck(su._active_tab == 1, "shop_ex: switched to tab 1") - _ck(su._list.get_child_count() == 1, "shop_ex: tab 1 has 1 row") - var t1_buy: Button = null - for b in su._list.get_child(0).find_children("*", "Button", true, false): - t1_buy = b - if t1_buy: - t1_buy.pressed.emit() + _ck(su._grid.get_child_count() == 40, "shop_ex: tab 1 has 5x8 grid") + (su._grid.get_child(3) as Button).pressed.emit() + if su._buy_confirm: + su._buy_confirm.confirmed.emit() _ck(fc.calls.has(["shop_buy", 43, 1]), "shop_ex: tab1 slot3 -> buy pos 1*40+3=43") fc.shop_closed.emit() fc.shop_tabs = [] @@ -289,15 +291,39 @@ func _run() -> void: "peer_items": [], "self_gold": 100, "peer_gold": 0, "self_accept": false, "peer_accept": true, } + fc.inventory = [{"cell": 7, "vnum": 901, "count": 1, "anti_flags": 0}] fc.exchange_changed.emit() _ck(xu.is_open(), "exchange window opens when active") _ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row") _ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown") + _ck(not xu._accept_btn.disabled, "exchange: accept enabled before server accept") + xu._on_accept() + _ck(fc.calls.has(["ex_accept"]) and xu._accept_btn.disabled, + "exchange: accept sends once and disables immediately") + fc.exchange["self_accept"] = false + fc.exchange_changed.emit() + _ck(not xu._accept_btn.disabled, "exchange: server-cleared accept re-enables button") xu.offer(1, 7) _ck(fc.calls.has(["ex_add_item", 1, 7, 1]), "exchange: offer item uses first free display slot (1)") + fc.exchange["self_gold"] = 0 xu._gold_input.text = "500" xu._on_put_gold() _ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold") + fc.exchange["self_gold"] = 500 + xu._gold_input.text = "100" + xu._on_put_gold() + _ck(not fc.calls.has(["ex_gold", 100]), "exchange: existing gold cannot be edited") + fc.exchange["self_gold"] = 0 + xu._gold_input.text = "10000000" + xu._on_put_gold() + _ck(not fc.calls.has(["ex_gold", 10000000]), "exchange: gold is limited to 7 digits") + fc.inventory = [{"cell": 8, "vnum": 902, "count": 1, "anti_flags": 1 << 13}] + _ck(not xu._drop_to_slot({"window": 1, "cell": 8, "vnum": 902, "count": 1}, 3), + "exchange: ANTIFLAG_GIVE item rejected") + fc.main_pos = Vector3(1001, 0, 0) + xu._process(0.1) + _ck(not xu.is_open() and fc.calls.has(["ex_cancel"]), + "exchange: moving beyond 1000 exits trade") fc.exchange = {"active": false} fc.exchange_changed.emit() _ck(not xu.is_open(), "exchange closes when inactive") diff --git a/project/p9_test.gd b/project/p9_test.gd index 37fad2b1..a9becd71 100644 --- a/project/p9_test.gd +++ b/project/p9_test.gd @@ -12,6 +12,7 @@ class FakeClient extends Node: signal npc_marks_changed() signal land_areas_changed() signal observer_event(kind: int, vid: int, pos: Vector2) + signal world_reset() signal world_markers_changed() signal warp(pos: Vector3, same_server: bool) var _time := 0 @@ -82,10 +83,48 @@ func _run() -> void: fc.markers = [{"id": 1, "name": "Quest", "pos": Vector3(-4, 0, -4)}] fc.observers = [{"vid": 9001, "pos": Vector3(2, 0, -2)}] fc.land_areas = [{"id": 42, "guild_id": 77, "x": -200, "y": -200, "width": 400, "height": 400}] + # ClientVS22's CPythonMiniMap interpolates observer moves for 1 second. + fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(0, 0)) + var observer_start: Vector3 = mm._observer_position(9001, Vector3.ZERO) + _ck(observer_start.distance_to(Vector3.ZERO) < 0.01, "minimap: observer add seeds current position") + fc.observer_event.emit(mm.OBSERVER_MOVE, 9001, Vector2(10, 4)) + var observer_immediate: Vector3 = mm._observer_position(9001, Vector3.ZERO) + _ck(observer_immediate.distance_to(Vector3.ZERO) < 0.1, "minimap: observer move starts at current position") + await create_timer(1.05).timeout + var observer_final: Vector3 = mm._observer_position(9001, Vector3.ZERO) + _ck(observer_final.distance_to(Vector3(10, 0, 4)) < 0.1, "minimap: observer move reaches destination") + fc.observer_event.emit(mm.OBSERVER_REMOVE, 9001, Vector2.ZERO) + _ck(not mm._observer_tracks.has(9001), "minimap: observer remove clears track") + fc.observer_event.emit(mm.OBSERVER_ADD, 9001, Vector2(2, -2)) + fc.world_reset.emit() + _ck(mm._observer_tracks.is_empty(), "minimap: world reset clears observer tracks") mm._view.queue_redraw() await process_frame _ck(mm._view.is_visible_in_tree(), "minimap: redraw scheduled, view live") + # --- 任务信标:圆内画星,越出半径贴圆周画方向箭头(§5.2 世界箭头 / 屏幕边缘指示)--- + var rim: float = mm.RADIUS - mm.SIGNAL_RIM_INSET + var near_geo: Dictionary = mm.signal_marker_geometry(Vector2(10, 0), rim) + _ck(not near_geo["clamped"] and near_geo["offset"] == Vector2(10, 0), + "minimap signal: inside rim keeps true offset") + var far_geo: Dictionary = mm.signal_marker_geometry(Vector2(400, 0), rim) + _ck(far_geo["clamped"] and abs(far_geo["offset"].length() - rim) < 0.01, + "minimap signal: outside rim clamps to circle") + _ck(abs(far_geo["angle"]) < 0.001 and abs(far_geo["offset"].x - rim) < 0.01, + "minimap signal: clamp keeps bearing to target (east)") + var diag_geo: Dictionary = mm.signal_marker_geometry(Vector2(-300, -300), rim) + _ck(diag_geo["clamped"] and abs(diag_geo["angle"] - atan2(-300.0, -300.0)) < 0.001 + and abs(diag_geo["offset"].length() - rim) < 0.01, + "minimap signal: diagonal clamp preserves atan2 bearing") + var edge_geo: Dictionary = mm.signal_marker_geometry(Vector2(rim, 0), rim) + _ck(edge_geo["clamped"], "minimap signal: exactly on rim counts as clamped (>=)") + mm.add_signal_point(120000.0, 90000.0) + _ck(mm.signal_point_count() == 1, "minimap signal: add_signal_point stores waypoint") + mm._view.queue_redraw() + await process_frame + mm.clear_signal_points() + _ck(mm.signal_point_count() == 0, "minimap signal: clear_signal_points empties waypoints") + # --- 昼夜 --- var sun := DirectionalLight3D.new() get_root().add_child(sun) diff --git a/project/part_hiding.gd b/project/part_hiding.gd new file mode 100644 index 00000000..ec776394 --- /dev/null +++ b/project/part_hiding.gd @@ -0,0 +1,59 @@ +# part_hiding.gd —— §4.1 修改 4:变身 / 时装的部位遮挡规则(InstanceBase.cpp 1:1)。 +# +# 纯函数,无状态。EquipModel.refresh() 在解出身体 shape 后调用,决定武器 / 盾 / 头发 +# 是否该隐藏、身体 shape 是否被变身强制归零。远端角色换装走同一条路(EquipModel 是 +# 「渲染别的玩家用的同一条路」)。 +# +# 参考 REF/UserInterface/InstanceBase*.cpp: +# IsPoly() -> SetShape(0);SetWeapon / SetParts 提前 return +# -> 身体 shape 归 0、无武器、无头发 / 挂件 +# __IsShapeAnimalWear() -> GetShape() ∈ {100,101,102,103};SetWeapon(:2747) 与 +# SetParts 返回 false -> 穿动物 / 怪物时装时武器 + 头发被抑制 +# IsWearingDress() -> m_eShape == 201(婚纱);__IsChangableWeapon(iWeaponID) +# (:901) 除 c_iBouquets 外一律 false -> 非捧花时武器隐藏 +# (头发不受婚纱影响) +# __ArmorVnumToShape() -> shape = item_proto values[3](vnum ∉ {0,1} 且 +# USE_ARMOR_SPECULAR),否则 shape = vnum +extends RefCounted + +const POLY_SHAPE := 0 +const ANIMAL_WEAR_SHAPES := [100, 101, 102, 103] # __IsShapeAnimalWear +const WEDDING_DRESS_SHAPE := 201 # IsWearingDress +const BOUQUET_VNUMS := [50201, 50202, 50203, 50204] # c_iBouquets(原表 0 结尾) + +static func is_animal_wear(shape: int) -> bool: + return shape in ANIMAL_WEAR_SHAPES + +static func is_wearing_dress(shape: int) -> bool: + return shape == WEDDING_DRESS_SHAPE + +static func is_bouquet(weapon_vnum: int) -> bool: + return weapon_vnum in BOUQUET_VNUMS + +# __IsChangableWeapon:婚纱状态只有捧花可换,其它 shape 一律可换。 +static func is_changable_weapon(weapon_vnum: int, shape: int) -> bool: + if is_wearing_dress(shape): + return is_bouquet(weapon_vnum) + return true + +# 武器(含盾,走同一挂点规则)是否该隐藏:变身 / 动物时装 / 婚纱且非捧花。 +static func weapon_hidden(shape: int, is_poly: bool, weapon_vnum: int = 0) -> bool: + if is_poly: + return true + if is_animal_wear(shape): + return true + if is_wearing_dress(shape) and not is_bouquet(weapon_vnum): + return true + return false + +# 头发 / 挂件是否该隐藏:变身 / 动物时装(婚纱不影响头发)。 +static func hair_hidden(shape: int, is_poly: bool) -> bool: + return is_poly or is_animal_wear(shape) + +# 身体 shape:变身强制 0(SetShape(0)),否则原样(动物时装 100-103 保留,用来出时装体)。 +static func body_shape(shape: int, is_poly: bool) -> int: + return POLY_SHAPE if is_poly else shape + +# 有效武器 vnum:被隐藏则 0(SetWeapon 提前 return 的等效结果)。 +static func effective_weapon(weapon_vnum: int, shape: int, is_poly: bool) -> int: + return 0 if weapon_hidden(shape, is_poly, weapon_vnum) else weapon_vnum diff --git a/project/part_hiding_test.gd b/project/part_hiding_test.gd new file mode 100644 index 00000000..e6e2edbd --- /dev/null +++ b/project/part_hiding_test.gd @@ -0,0 +1,76 @@ +# part_hiding_test —— §4.1 修改 4:变身 / 时装部位遮挡(PartHiding)1:1 自检。 +# canonical #18。 godot --headless --path project --script part_hiding_test.gd +extends SceneTree + +const PartHiding = preload("res://part_hiding.gd") + +var failed := 0 + +func _ck(ok: bool, message: String) -> void: + if not ok: + failed += 1 + printerr("FAIL: " + message) + +func _init() -> void: + _test_animal_wear() + _test_dress_and_bouquet() + _test_changable_weapon() + _test_weapon_hidden() + _test_hair_hidden() + _test_body_shape() + _test_effective_weapon() + if failed == 0: + print("PASS: part_hiding_test (§4.1 修改 4 poly / animal-wear / dress 遮挡 1:1)") + quit(0) + else: + quit(1) + +func _test_animal_wear() -> void: + for s in [100, 101, 102, 103]: + _ck(PartHiding.is_animal_wear(s), "shape %d is animal-wear" % s) + for s in [0, 5, 99, 104, 201]: + _ck(not PartHiding.is_animal_wear(s), "shape %d not animal-wear" % s) + +func _test_dress_and_bouquet() -> void: + _ck(PartHiding.is_wearing_dress(201), "shape 201 is wedding dress") + for s in [0, 200, 202, 100]: + _ck(not PartHiding.is_wearing_dress(s), "shape %d not dress" % s) + for v in [50201, 50202, 50203, 50204]: + _ck(PartHiding.is_bouquet(v), "vnum %d is bouquet" % v) + for v in [0, 50200, 50205, 19]: + _ck(not PartHiding.is_bouquet(v), "vnum %d not bouquet" % v) + +func _test_changable_weapon() -> void: + # 婚纱:只有捧花可换 + _ck(PartHiding.is_changable_weapon(50201, 201), "dress + bouquet -> changable") + _ck(not PartHiding.is_changable_weapon(19, 201), "dress + sword -> not changable") + # 非婚纱 shape:任何武器可换(动物时装的隐藏走 weapon_hidden,不走这条) + _ck(PartHiding.is_changable_weapon(19, 5), "normal shape + sword -> changable") + _ck(PartHiding.is_changable_weapon(19, 100), "animal-wear shape not blocked by __IsChangableWeapon") + +func _test_weapon_hidden() -> void: + _ck(not PartHiding.weapon_hidden(5, false, 19), "normal shape, not poly -> weapon shown") + _ck(PartHiding.weapon_hidden(5, true, 19), "poly -> weapon hidden (any shape)") + _ck(PartHiding.weapon_hidden(101, false, 19), "animal-wear shape -> weapon hidden") + _ck(PartHiding.weapon_hidden(201, false, 19), "dress + non-bouquet -> weapon hidden") + _ck(not PartHiding.weapon_hidden(201, false, 50203), "dress + bouquet -> weapon shown") + _ck(PartHiding.weapon_hidden(201, false), "dress + no weapon -> hidden") + +func _test_hair_hidden() -> void: + _ck(not PartHiding.hair_hidden(5, false), "normal -> hair shown") + _ck(PartHiding.hair_hidden(5, true), "poly -> hair hidden") + _ck(PartHiding.hair_hidden(102, false), "animal-wear -> hair hidden") + _ck(not PartHiding.hair_hidden(201, false), "dress -> hair NOT hidden") + +func _test_body_shape() -> void: + _ck(PartHiding.body_shape(3, false) == 3, "normal shape passthrough") + _ck(PartHiding.body_shape(100, false) == 100, "animal-wear shape kept (出时装体)") + _ck(PartHiding.body_shape(3, true) == 0, "poly -> body shape 0") + _ck(PartHiding.body_shape(201, true) == 0, "poly overrides dress shape") + +func _test_effective_weapon() -> void: + _ck(PartHiding.effective_weapon(19, 5, false) == 19, "normal -> weapon passthrough") + _ck(PartHiding.effective_weapon(19, 100, false) == 0, "animal-wear -> weapon 0") + _ck(PartHiding.effective_weapon(50201, 201, false) == 50201, "dress + bouquet -> keep bouquet") + _ck(PartHiding.effective_weapon(19, 201, false) == 0, "dress + sword -> 0") + _ck(PartHiding.effective_weapon(19, 5, true) == 0, "poly -> weapon 0") diff --git a/project/player_controller.gd b/project/player_controller.gd index 641f7606..397b925f 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -15,6 +15,11 @@ signal moved(pos: Vector3) var player: Node3D var camera: Camera3D var world: Node +var cursor_manager: Node +var ui_manager: Node +var ground_items: Node +var cancel_fishing_input := Callable() # NEW_SetSingleDIKKeyState 等价入口 +var cancel_fishing_ground := Callable() # __OnPressGround 专用入口 var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等) const SPEED_WALK := 8.0 @@ -22,16 +27,53 @@ const SPEED_RUN := 18.0 const RUN_HOLD_KEY := KEY_SHIFT const ARRIVE_EPS := 0.4 const PICK_RADIUS := 1.4 # 点选命中半径(米) +# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由 +# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1 +const MOVABLE_GROUND_DISTANCE_M := 1.0 +# __ReserveClickGround 设的 m_fReservedDelayTime(PythonPlayerInput.cpp:834)。 +const RESERVED_GROUND_DELAY := 0.1 +# Actor-Actor 碰撞(对齐 CInstanceBase::CheckAdvancing,InstanceBaseBattle.cpp:469 + +# CActorInstance::TestActorCollision,ActorInstanceCollisionDetection.cpp)。参考用 race +# 碰撞球,POC 无骨骼碰撞数据 → 固定 body 半径近似;自身 + 对方两球相加为判定阈值。§3.1 +const ACTOR_BODY_RADIUS_M := 0.55 +# TestActorCollision 的距离门:LengthSq(victim - self) > 800 cm² 直接不检。 +const ACTOR_COLLIDE_MAX_DIST_M := 8.0 +# c_fDefaultRotationSpeed 1200 / c_fDefaultHorseRotationSpeed 300(InstanceBase.cpp:17-18): +# MountHorse() 把转向速度降到 300,下马恢复 1200。 +const ROT_SPEED_DEFAULT_DEG := 1200.0 +const ROT_SPEED_HORSE_DEG := 300.0 var force_run := false # 脚本化 / 自动跑 var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设) +var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect) +var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移 var server_speed_scale := 1.0 # GC_CHANGE_SPEED moving_speed / 100 +var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切换(§3.1 / §6.4) +# CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞 +# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。 +var skip_actor_collision := false +var _last_wasd := Vector2.ZERO + +# --- §3.1 Src/Dst 移动模型(对齐 InstanceBaseMovement.cpp NEW_Goto / NEW_MoveToDirection)--- +# _is_going = m_isGoing(点地走向 Dst);方向键移动 _is_going 保持 false。 +var _src_pos := Vector3.ZERO +var _dst_pos := Vector3.ZERO +var _is_going := false +var _dst_rot := 0.0 +var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点 +var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效 + +# CInstanceBase::__IsSyncing():Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。 +func is_going() -> bool: + return _is_going + +# NEW_Goto / NEW_MoveToDirection 的三道前置门:syncing / moving-skill(只转向) / lock。 +func _can_translate() -> bool: + return not frozen and not locked and not moving_skill const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点 const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点 -var _move_target := Vector3.ZERO -var _has_target := false var _run := false var _touch_count := 0 var _multi_gesture := false # 曾有 ≥2 指同时按下 -> 本轮不产生点地 @@ -46,10 +88,36 @@ func set_server_speed(moving_speed: int) -> void: return server_speed_scale = clampf(float(moving_speed) / 100.0, 0.25, 3.0) -# 程序化下发一个点地目标(脚本化截图 / AI) +func set_input_surfaces(cursor: Node, ui: Node = null, ground: Node = null, + cancel_fishing_cb := Callable(), ground_cancel_fishing_cb := Callable()) -> void: + cursor_manager = cursor + ui_manager = ui + ground_items = ground + cancel_fishing_input = cancel_fishing_cb + cancel_fishing_ground = ground_cancel_fishing_cb + +# 程序化下发一个点地目标(脚本化截图 / AI)——等价 NEW_MoveToDestPixelPositionDirection。 func walk_to(world_pos: Vector3) -> void: - _move_target = Vector3(world_pos.x, 0.0, world_pos.z) - _has_target = true + _goto(Vector3(world_pos.x, 0.0, world_pos.z)) + +# CInstanceBase::NEW_Goto(InstanceBaseMovement.cpp:251):三门通过后设 Src/Dst + m_isGoing。 +# 被门挡下(syncing / lock)时按 __ReserveClickGround 预约,delay 后重试。 +func _goto(dst_flat: Vector3) -> bool: + if player == null: + return false + var flat := Vector3(dst_flat.x - player.position.x, 0.0, dst_flat.z - player.position.z) + # __IsMovableGroundDistance:离脚下太近不动 + if flat.length() < MOVABLE_GROUND_DISTANCE_M: + return true + if not _can_translate(): + _reserved_ground = dst_flat + _reserved_delay_time = RESERVED_GROUND_DELAY + return false + _src_pos = player.position + _dst_pos = Vector3(dst_flat.x, 0.0, dst_flat.z) + _dst_rot = atan2(flat.x, flat.z) + _is_going = true + return true func _unhandled_input(e: InputEvent) -> void: if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: @@ -103,11 +171,14 @@ func _on_click(screen_pos: Vector2) -> void: target_selected.emit(best) return - # 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度) + # 2) 点地:射线与地表求交(沿射线二分找 y == 地表高度)→ NEW_Goto var hit: Variant = _ray_ground(from, dir) if hit != null: - _move_target = hit - _has_target = true + if cancel_fishing_ground.is_valid() and bool(cancel_fishing_ground.call()): + return + if cancel_fishing_input.is_valid() and bool(cancel_fishing_input.call()): + return + _goto(hit) # 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。 func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float: @@ -189,11 +260,56 @@ func _wasd() -> Vector2: func _blocked(x: float, z: float) -> bool: return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z)) +# CActorInstance::TestActorCollision(ActorInstanceCollisionDetection.cpp)近似:距离门 +# (800 cm)→ body 球重叠 → 「趋近」判定(新位比旧位更近才算撞,远离时放行 → 能从 +# 重叠里走出来)。每个 pickable(已生成的远端 NPC / 怪 / 他人节点)当作一个 Actor 碰撞体; +# `skip_actor_collision`(CanSkipCollision)为真整段跳过(≈ CheckAdvancing 开头)。 +func _actor_blocked(next_pos: Vector3) -> bool: + if skip_actor_collision or player == null: + return false + var cur := player.position + var combined := ACTOR_BODY_RADIUS_M * 2.0 + for n in pickables: + if not is_instance_valid(n): + continue + if bool(n.get_meta("dead", false)): # rVictim.IsDead() + continue + var op: Vector3 = n.global_position + var d_now := Vector2(op.x - cur.x, op.z - cur.z).length() + if d_now > ACTOR_COLLIDE_MAX_DIST_M: # LengthSq > 800² → 不检 + continue + var d_next := Vector2(op.x - next_pos.x, op.z - next_pos.z).length() + if d_next < combined and d_next <= d_now + 1e-4: + return true + return false + +# SetAdvancingRotation(InstanceBaseMovement.cpp):按转向速度逐帧收敛朝向;移动方向本身 +# 不受此限制(骑乘时 rotation_speed_deg = 300 → 转向明显变慢)。 +func _turn_toward(target_yaw: float, dt: float) -> void: + if player == null: + return + var max_step := deg_to_rad(rotation_speed_deg) * dt + var diff: float = wrapf(target_yaw - player.rotation.y, -PI, PI) + if max_step <= 0.0 or absf(diff) <= max_step: + player.rotation.y = target_yaw + else: + player.rotation.y += signf(diff) * max_step + func _process(dt: float) -> void: if player == null: return - if frozen: - _has_target = false + _update_hover_cursor() + + # __ReserveClickGround 的 delay 递减(NEW_IsEmptyReservedDelayTime);到点且门已开则重试。 + if _reserved_delay_time > 0.0: + _reserved_delay_time -= dt + if _reserved_delay_time <= 0.0 and _reserved_ground != null and _can_translate(): + var rg: Vector3 = _reserved_ground + _reserved_ground = null + _goto(rg) + + if frozen or locked: + # 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。 anim_state.emit("wait") return _run = force_run or Input.is_key_pressed(RUN_HOLD_KEY) @@ -201,34 +317,54 @@ func _process(dt: float) -> void: var wasd := _wasd() if wasd != Vector2.ZERO: - _has_target = false # 键盘覆盖点地 + # PythonPlayerInputKeyboard 只在方向键按下时尝试取消钓鱼;记录方向 + # 边沿,避免按住键时每帧重复发送 CG_FISHING(0)。 + if wasd != _last_wasd and cancel_fishing_input.is_valid() \ + and bool(cancel_fishing_input.call()): + _last_wasd = wasd + anim_state.emit("wait") + return + _last_wasd = wasd + _is_going = false # 键盘覆盖点地(NEW_MoveToDirection:m_isGoing = FALSE) var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0) var fwd := Vector3(sin(yaw), 0, cos(yaw)) var right := Vector3(fwd.z, 0, -fwd.x) wish = (fwd * -wasd.y + right * wasd.x).normalized() - elif _has_target: - var flat := Vector3(_move_target.x - player.position.x, 0, _move_target.z - player.position.z) + elif _is_going: + var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z) if flat.length() <= ARRIVE_EPS: - _has_target = false + _is_going = false else: wish = flat.normalized() + else: + _last_wasd = Vector2.ZERO var speed := 0.0 if wish != Vector3.ZERO: - var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale - var before := player.position - var np := player.position + wish * want_speed * dt - if not _blocked(np.x, player.position.z): - player.position.x = np.x - if not _blocked(player.position.x, np.z): - player.position.z = np.z - # 动画看「实际位移」而非意图:贴墙磨蹭时该切回 wait - var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length() - if disp > want_speed * dt * 0.25: - speed = want_speed - player.rotation.y = atan2(wish.x, wish.z) + # IsUsingMovingSkill():移动技能中只转向,不平移。 + if moving_skill: + _turn_toward(atan2(wish.x, wish.z), dt) else: - _has_target = false + var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale + var before := player.position + var np := player.position + wish * want_speed * dt + # 逐轴推进:地形 ATTRIBUTE_BLOCK + Actor 碰撞任一挡下该轴 → 不动那一轴 + # (单个 Actor 时等价 AdjustDynamicCollisionMovement 的滑移;两轴都挡 = BlockMovement)。 + var block_x := _actor_blocked(Vector3(np.x, before.y, player.position.z)) \ + or _blocked(np.x, player.position.z) + var block_z := _actor_blocked(Vector3(player.position.x, before.y, np.z)) \ + or _blocked(player.position.x, np.z) + if not block_x: + player.position.x = np.x + if not block_z: + player.position.z = np.z + # 动画看「实际位移」而非意图:贴墙 / 顶人磨蹭时该切回 wait + var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length() + if disp > want_speed * dt * 0.25: + speed = want_speed + _turn_toward(atan2(wish.x, wish.z), dt) + else: + _is_going = false if world and world.has_method("sample_height"): player.position.y = float(world.call("sample_height", player.position.x, player.position.z)) @@ -240,3 +376,32 @@ func _process(dt: float) -> void: anim_state.emit("walk") else: anim_state.emit("run") + +func _update_hover_cursor() -> void: + if cursor_manager == null or not cursor_manager.has_method("set_cursor") or camera == null: + return + var mouse_pos := get_viewport().get_mouse_position() + if ui_manager and ui_manager.has_method("blocks_game_input"): + var motion := InputEventMouseMotion.new() + motion.position = mouse_pos + if ui_manager.blocks_game_input(motion): + cursor_manager.set_cursor("NORMAL") + return + if ground_items and ground_items.has_method("hover_at") and ground_items.hover_at(camera, mouse_pos): + cursor_manager.set_cursor("PICK") + return + var from := camera.project_ray_origin(mouse_pos) + var dir := camera.project_ray_normal(mouse_pos) + var best: Node3D = null + var best_t := 1e20 + for n in pickables: + if not is_instance_valid(n): + continue + var t := _ray_pick_t(from, dir, n) + if t >= 0.0 and t < best_t: + best_t = t + best = n + if best: + cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK"))) + else: + cursor_manager.set_cursor("NORMAL") diff --git a/project/player_motion_test.gd b/project/player_motion_test.gd index 8d0998cc..966f9b2c 100644 --- a/project/player_motion_test.gd +++ b/project/player_motion_test.gd @@ -3,6 +3,7 @@ extends SceneTree const PlayerView = preload("res://ui/player_view.gd") +const Audio = preload("res://audio.gd") var _fail := 0 @@ -33,6 +34,17 @@ func _run() -> void: _ck(pv.build(assets, 0), "warrior PlayerView builds") if pv.anim == null: return + await process_frame + var body_mesh := pv.model.get_node_or_null("MeshInstance3D") as MeshInstance3D + var body_layer := body_mesh.get_layer_mask() if body_mesh != null else 0 + _ck(body_mesh != null and body_layer == 2, + "character mesh is assigned to the character light layer (got %d)" % body_layer) + var audio: Node = Audio.new() + get_root().add_child(audio) + audio.setup(assets) + pv.set_audio(audio) + pv.set_anim_state("run") + _ck(pv._sound_instances.size() == 3, "PlayerView run motion loads paired .mss instances") _ck(pv.set_motion_id(305), "clap motion resolves") _ck(String(pv.anim.get("anim_path")).ends_with("action/clap.msa"), "clap -> action/clap.msa") @@ -43,3 +55,4 @@ func _run() -> void: _ck(String(pv.anim.get("anim_path")).ends_with("action/slap_hurt.msa"), "slap hurt -> action/slap_hurt.msa") pv.queue_free() + audio.queue_free() diff --git a/project/player_move_test.gd b/project/player_move_test.gd new file mode 100644 index 00000000..50666793 --- /dev/null +++ b/project/player_move_test.gd @@ -0,0 +1,144 @@ +# player_move_test —— §3.1 Src/Dst 移动模型(player_controller.gd)headless 自检。 +# godot --headless --path project --script player_move_test.gd +# 覆盖:NEW_Goto 设 Src/Dst/is_going、__IsMovableGroundDistance 太近不动、 +# 三门(frozen/lock/moving_skill)、__ReserveClickGround 的 0.1s delay 重试。 +extends SceneTree + +const PlayerCtl = preload("res://player_controller.gd") + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: player_move_test (Src/Dst move model / gates / reserved ground)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # SceneTree._init() 早于 root 就绪;等一帧再建 Node3D,避免 !is_inside_tree()。 + await process_frame + var pc: Node = PlayerCtl.new() + get_root().add_child(pc) + pc.player = Node3D.new() + get_root().add_child(pc.player) + pc.player.position = Vector3.ZERO + + # 1) NEW_Goto:远点 -> 设 Dst + is_going + 面向 + var ok: bool = pc._goto(Vector3(10, 0, 0)) + _ck(ok, "_goto(far) returns true") + _ck(pc.is_going(), "_goto -> is_going()") + _ck(pc._dst_pos.is_equal_approx(Vector3(10, 0, 0)), "Dst set to click point") + _ck(pc._src_pos.is_equal_approx(Vector3.ZERO), "Src set to current pos") + _ck(is_equal_approx(pc._dst_rot, atan2(10.0, 0.0)), "_dst_rot faces the target") + + # 2) __IsMovableGroundDistance:太近(< 1 m)不动 + pc._is_going = false + var near_ok: bool = pc._goto(Vector3(0.5, 0, 0)) + _ck(near_ok, "_goto(near) returns true (treated as done)") + _ck(not pc.is_going(), "within MOVABLE_GROUND_DISTANCE -> no movement") + + # 3) 每帧推进:朝 Dst 走,抵达后 is_going 归零 + pc._goto(Vector3(3, 0, 0)) + for i in 240: + pc._process(0.05) + if not pc.is_going(): + break + _ck(pc.player.position.distance_to(Vector3(3, 0, 0)) < 0.5, "advances to Dst, got %s" % pc.player.position) + _ck(not pc.is_going(), "arrival clears is_going") + + # 4) 三门:frozen / lock -> _goto 被挡 -> 预约 + 0.1s 后重试 + pc.player.position = Vector3.ZERO + pc._is_going = false + pc.frozen = true + var blocked: bool = pc._goto(Vector3(8, 0, 0)) + _ck(not blocked, "_goto while frozen returns false (gated)") + _ck(not pc.is_going(), "frozen -> not going yet") + _ck(pc._reserved_ground != null, "frozen -> click ground reserved") + # 冻结中推进:不平移、不消预约 + pc._process(0.05) + _ck(pc.player.position.is_equal_approx(Vector3.ZERO), "frozen -> no translation") + # 解冻:delay 递减到 0 后自动重试预约点 + pc.frozen = false + for i in 10: + pc._process(0.02) + _ck(pc.is_going() or pc.player.position.x > 0.1, + "reserved ground fires after delay once unfrozen (going=%s x=%.2f)" % [pc.is_going(), pc.player.position.x]) + + # 5) moving_skill:只转向不平移 + pc.player.position = Vector3.ZERO + pc._is_going = false + pc.moving_skill = true + pc._goto(Vector3(0, 0, 6)) + _ck(not pc.is_going(), "_goto while moving_skill is gated (no translate)") + pc.moving_skill = false + # 清掉第 5 组留下的预约点,免得下面各组被它改向。 + pc._reserved_ground = null + pc._reserved_delay_time = 0.0 + + # 6) Actor-Actor 碰撞(TestActorCollision / BlockMovement 近似)——正前方有远端 Actor + # 时走不过去,停在两 body 球相接前;is_going 归零(BlockMovement = 不滑移硬停)。 + pc.player.position = Vector3.ZERO + pc.player.rotation.y = 0.0 + pc._is_going = false + pc.skip_actor_collision = false + var blocker := Node3D.new() + get_root().add_child(blocker) + blocker.global_position = Vector3(2, 0, 0) # 正前方 2 m + pc.pickables.append(blocker) + pc._goto(Vector3(10, 0, 0)) # 想穿过 blocker + for i in 240: + pc._process(0.05) + if not pc.is_going(): + break + _ck(pc.player.position.x > 0.3 and pc.player.position.x < 1.5, + "actor collision halts before overlap (x=%.2f)" % pc.player.position.x) + _ck(not pc.is_going(), "blocked advance clears is_going (BlockMovement)") + + # 6b) 死亡的 Actor 不挡(rVictim.IsDead()) + pc.player.position = Vector3.ZERO + pc._is_going = false + blocker.set_meta("dead", true) + pc._goto(Vector3(10, 0, 0)) + for i in 400: + pc._process(0.05) + if not pc.is_going(): + break + _ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6, + "dead actor does not block (x=%.2f)" % pc.player.position.x) + blocker.set_meta("dead", false) + + # 6c) CanSkipCollision(skip_actor_collision)为真 → 整段跳过,可穿过 + pc.player.position = Vector3.ZERO + pc._is_going = false + pc.skip_actor_collision = true + pc._goto(Vector3(10, 0, 0)) + for i in 400: + pc._process(0.05) + if not pc.is_going(): + break + _ck(pc.player.position.distance_to(Vector3(10, 0, 0)) < 0.6, + "skip_actor_collision passes straight through (x=%.2f)" % pc.player.position.x) + pc.skip_actor_collision = false + pc.pickables.erase(blocker) + blocker.free() + + # 7) 坐骑转向速度:MountHorse → c_fDefaultHorseRotationSpeed(300),逐帧收敛不瞬转 + _ck(is_equal_approx(pc.rotation_speed_deg, pc.ROT_SPEED_DEFAULT_DEG), + "default rotation speed is c_fDefaultRotationSpeed 1200") + pc.rotation_speed_deg = pc.ROT_SPEED_HORSE_DEG + pc.player.position = Vector3.ZERO + pc.player.rotation.y = 0.0 + pc._is_going = false + pc._goto(Vector3(0, 0, -6)) # 需要转到 yaw ≈ ±PI + pc._process(0.05) + _ck(absf(pc.player.rotation.y) <= deg_to_rad(pc.ROT_SPEED_HORSE_DEG) * 0.05 + 1e-3, + "mounted: turn clamped to 300 deg/s in one 50ms step (%.3f rad)" % pc.player.rotation.y) + _ck(pc.player.position.z < -0.05, "mounted: still translates toward Dst while turning") + pc.rotation_speed_deg = pc.ROT_SPEED_DEFAULT_DEG diff --git a/project/player_skill.gd b/project/player_skill.gd new file mode 100644 index 00000000..c8455cbb --- /dev/null +++ b/project/player_skill.gd @@ -0,0 +1,422 @@ +# PlayerSkill (§3.8 修改 1) —— 逐行移植 CPythonPlayer 的技能三层合法性校验。 +# +# 参考 REF/UserInterface/PythonPlayerSkill.cpp: +# (a) ClickSkillSlot(dwSlotIndex) :106 —— 槽位 / 技能数据 / 公会技路由 / 被动挡下 / +# 站立技 + 开关技 toggle-off 分支 +# (b) __CanUseSkill() :439 —— 主角存在 / 观战 / 骑乘缺骑乘术 / actor 可行动 +# (c) __CheckSkillUsable(dwSlotIndex) :166 —— 骑乘⇄骑乘术、安全区攻击技、瓶子、钓鱼、 +# 未学、武器种类、箭矢、冷却、HP/SP +# (d) __UseSkill(dwSlotIndex) :460 —— 编排:私人商店 / __CanUseSkill / __CheckSpecialSkill / +# toggle-active 早退 / __CheckSkillUsable / IsUsingSkill / +# 目标解析(联盟 / 安全区 / 可攻击 / 尸体 / 需要目标) +# +# POC 没有 C++ 技能 proto,TSkillData 判定位全部落在 `ui/skill_table.gd`(增量 87)。 +# 本类是纯校验器:不发包、不播动作,返回 { ok:bool, code:String, target_vid:int }。 +# 施放路径(quickbar.activate() 的 skill 分支)在调用 M2Client.use_skill() / +# cast_skill() 之前先过 use_skill(),ok=false 时按 code 弹 OnCannotUseSkill 文案。 +# +# 运行期上下文(网络下发 / net_play 本地占位)由调用方每次校验前刷新到公开字段, +# 对齐原客户端从 CInstanceBase / m_sysIs* / GetStatus() 读到的那些值。 +extends RefCounted + +# --- §3.8 修改 1 要求的命名常量 -------------------------------------------- +const SKILL_MAX_NUM := 255 # Packet.h:2041 +const SKILL_TYPE_GUILD := 3 # CPythonSkill::SKILL_TYPE_GUILD +const RIDING_SKILL_INDEX := 109 # __CanUseSkill 里硬编码的骑乘术判定 vnum +const RIDING_SKILL_MASTER_LEVEL := 20 # GetSkillLevel(109) < 20 的下限 +const ITEM_EMPTY_BOTTLE := 27995 # __CheckSkillUsable / __HasItem +const ITEM_POISON_BOTTLE := 27996 +const SKILL_INDEX_COMBO := 122 # PythonPlayerModule.cpp:6 +const SKILL_INDEX_FISHING := 123 # PythonPlayerModule.cpp:7 +const SKILL_INDEX_SUMMON := 131 # PythonPlayerModule.cpp:15(__CheckShortMana 豁免) + +# code 里这几类是「原客户端也不弹文案 / 已在别处处理」,调用方不该弹 OnCannotUseSkill: +const SILENT_CODES := [ + "", "OK", "PASSIVE", "BAD_SLOT", "NO_SKILL_DATA", "GUILD_ROUTED", + "TOGGLE_OFF", "SPECIAL_SKILL", "PRIVATE_SHOP", "RESERVED", "NO_RIDING_SKILL", + "CANNOT_ACT", "ALREADY_CASTING", +] + +var client: Object # M2Client +var table: RefCounted # SkillTable(增量 87 谓词) + +# --- 运行期上下文(调用方刷新;默认取「未知 / 放行」)----------------------- +var observer_mode := false # IsObserverMode() +var mounting := false # CInstanceBase::IsMountingHorse() +var in_safe := false # CInstanceBase::IsInSafe() +var is_fishing := false # CInstanceBase::IsFishingMode() +var is_using_skill := false # CInstanceBase::IsUsingSkill() +var can_act := true # CanProcessNetworkStatePacket(死亡/晕眩/击退 → false) +var private_shop_open := false # IsOpenPrivateShop() +var level_limit := true # m_sysIsLevelLimit +var cooltime_check := true # m_sysIsCoolTime +var dash_active := false # __CheckDashAffect +var weapon_sub_type := -1 # CInstanceBase::GetWeaponType();-1 = 未知(跳过匹配) +var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,0 = EMPTY_ARROW +var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000) +var cur_sp := -1 # GetStatus(POINT_SP);-1 = 未知(不判 NOT_ENOUGH_SP) +var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不判 NOT_ENOUGH_HP) + +func setup(m2client: Object, skill_table: RefCounted) -> void: + client = m2client + table = skill_table + +# --- 小工具 -------------------------------------------------------------- + +func _now() -> float: + return Time.get_ticks_msec() / 1000.0 + +func _ok(target_vid := 0) -> Dictionary: + return {"ok": true, "code": "OK", "target_vid": target_vid} + +func _no(code: String, target_vid := 0) -> Dictionary: + return {"ok": false, "code": code, "target_vid": target_vid} + +static func is_silent_code(code: String) -> bool: + return SILENT_CODES.has(code) + +func _main_vid() -> int: + if client and client.has_method("get_main_vid"): + return int(client.get_main_vid()) + return 0 + +func _main_entity() -> Dictionary: + var v := _main_vid() + if client and client.has_method("get_entity") and v != 0: + var e: Variant = client.get_entity(v) + if e is Dictionary: + return e + return {} + +# GetSkillLevel(idx) / GetSkillGrade(idx) 等价:查 M2Client.get_skills()(按技能 vnum)。 +func _skill_level(skill_id: int) -> int: + if client == null or not client.has_method("get_skills"): + return 0 + for s in client.get_skills(): + if int(s.get("id", 0)) == skill_id: + return int(s.get("level", 0)) + return 0 + +func _skill_grade(skill_id: int) -> int: + if client == null or not client.has_method("get_skills"): + return 0 + for s in client.get_skills(): + if int(s.get("id", 0)) == skill_id: + return int(s.get("master", 0)) # 0..3,master 即 grade + return 0 + +# __HasItem(dwItemID):扫背包(POC 无独立龙魂背包,省略那段)。 +func _has_item(vnum: int) -> bool: + if client == null or not client.has_method("get_inventory"): + return false + for it in client.get_inventory(): + if int(it.get("vnum", 0)) == vnum and int(it.get("count", 0)) > 0: + return true + return false + +# __HasEnoughArrow():原版查装备箭袋 subtype==WEAPON_ARROW;POC 用 net_play 的 +# _arrow_count 占位(真值待服务端 / 后续增量)。-1 视为无限。 +func _has_enough_arrow() -> bool: + return arrow_count != 0 + +# __CheckRestSkillCoolTime(slot):m_sysIsCoolTime 关 → 不在冷却;否则比本地剩余。 +func _check_rest_skill_cooltime() -> bool: + if not cooltime_check: + return false + return _now() < slot_cd_end + +# §3.8 修改 3:NeedSP 公式经 skill_table.need_sp()(.msk NeedSPFormula)解析。 +# +# __CheckShortLife(PythonPlayerSkill.cpp:354): +# !IsUseHPSkill() → false +# dwNeedHP = GetNeedSP(eff%) (HP 消耗也取 NeedSP,参考端怪癖) +# dwNeedHP <= GetStatus(POINT_HP) → false 否则 true +func _check_short_life(skill_id: int) -> bool: + if table == null or not table.has_method("need_hp"): + return false + if not table.is_use_hp(skill_id): + return false + if cur_hp < 0: # HP 未知 → 不误挡 + return false + var need_hp := int(table.need_hp(skill_id, _skill_level(skill_id))) + if need_hp < 0: # 公式解析不出 → 不挡 + return false + return need_hp > cur_hp + +# __CheckShortMana(PythonPlayerSkill.cpp:320): +# idx == c_iSkillIndex_Summon(131) → false +# iNeedSP = GetNeedSP(eff%) ; icurSP = GetStatus(POINT_SP) +# !IsToggleSkill() && iNeedSP == 0 → true("strange game data") +# CanUseIfNotEnough(): icurSP <= 0 → true +# else: iNeedSP != -1 && iNeedSP > icurSP → true +# 否则 false +func _check_short_mana(skill_id: int) -> bool: + if table == null or not table.has_method("need_sp"): + return false + if skill_id == SKILL_INDEX_SUMMON: + return false + if cur_sp < 0: # SP 未知 → 不误挡 + return false + var need_sp := int(table.need_sp(skill_id, _skill_level(skill_id))) + # 参考端:非开关技且 iNeedSP == 0 视为坏数据并拦截。POC 约定 need_sp() 用 -1 + # 表示「.msk 没有 NeedSPFormula」(解析不出),此时不套这条以免误挡;只有确有 + # 公式且算出 0 才按参考拦。 + if not table.is_toggle(skill_id) and need_sp == 0: + return true + if table.can_use_if_not_enough(skill_id): + return cur_sp <= 0 + return need_sp != -1 and need_sp > cur_sp + +func _entity_kind(e: Dictionary) -> int: + return int(e.get("kind", int(e.get("type", -1)))) + +# IsAttackableInstance 的近似:PC(0) / MONSTER(2) 且未死可打。 +# 帝国 / 结盟 / PK / 决斗规则归 §5.4,这里不判。 +func _is_attackable(e: Dictionary) -> bool: + if e.is_empty() or bool(e.get("dead", false)): + return false + return _entity_kind(e) in [0, 2] + +# ======================================================================== +# (a) ClickSkillSlot —— PythonPlayerSkill.cpp:106 +# ======================================================================== +# is_active:该槽当前是否已激活(IsSkillActive(slot)),仅开关技有意义。 +func click_skill_slot(skill_id: int, is_active := false) -> Dictionary: + # if (dwSlotIndex >= SKILL_MAX_NUM) return; + if skill_id < 0 or skill_id >= SKILL_MAX_NUM: + return _no("BAD_SLOT") + + # if (!GetSkillData(rkSkillInst.dwIndex, &pSkillData)) return; + if table == null or not table.has(skill_id): + return _no("NO_SKILL_DATA") + + # if (SKILL_TYPE_GUILD == pSkillData->byType) { UseGuildSkill(...); return; } + if table.skill_type_of(skill_id) == SKILL_TYPE_GUILD: + return _no("GUILD_ROUTED") + + # if (!pSkillData->IsCanUseSkill()) return; —— 被动技,静默 + if not table.is_can_use_skill(skill_id): + return _no("PASSIVE") + + # if (pSkillData->IsStandingSkill()) + if table.is_standing(skill_id): + # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return; } + if table.is_toggle(skill_id) and is_active: + if not can_act: # pkInstMain->IsUsingSkill() 之前的取指针失败 + return _no("CANNOT_ACT") + if is_using_skill: + return _no("ALREADY_CASTING") + return _no("TOGGLE_OFF") # toggle-off:调用方直接发 use_skill 关掉 + # __UseSkill(dwSlotIndex); + return use_skill(skill_id, is_active) + + # 非站立技:原版还有 m_dwcurSkillSlotIndex / __IsRightButtonSkillMode 的两段式选中, + # POC 快捷栏是一键即放,等价于 !__IsRightButtonSkillMode() 分支 -> __UseSkill。 + return use_skill(skill_id, is_active) + +# ======================================================================== +# (b) __CanUseSkill —— PythonPlayerSkill.cpp:439 +# ======================================================================== +func can_use_skill() -> Dictionary: + # if (!NEW_GetMainActorPtr()) return false; + if _main_vid() == 0: + return _no("CANNOT_ACT") + + # if (IsObserverMode()) return false; + if observer_mode: + return _no("OBSERVER") + + # if (IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20) return false; + if mounting and _skill_grade(RIDING_SKILL_INDEX) < 1 \ + and _skill_level(RIDING_SKILL_INDEX) < RIDING_SKILL_MASTER_LEVEL: + return _no("NO_RIDING_SKILL") + + # return pkInstMain->CanUseSkill(); —— 死亡 / 晕眩 / 击退 / 施法中 + if not can_act: + return _no("CANNOT_ACT") + + return _ok() + +# ======================================================================== +# (c) __CheckSkillUsable —— PythonPlayerSkill.cpp:166 +# ======================================================================== +func check_skill_usable(skill_id: int) -> Dictionary: + # if (!NEW_GetMainActorPtr()) return false; + if _main_vid() == 0: + return _no("CANNOT_ACT") + + # if (dwSlotIndex >= SKILL_MAX_NUM) return false; + if skill_id < 0 or skill_id >= SKILL_MAX_NUM: + return _no("BAD_SLOT") + + # if (!GetSkillData(...)) return false; + if table == null or not table.has(skill_id): + return _no("NO_SKILL_DATA") + + # if (IsMountingHorse() && !IsHorseSkill()) -> "NOT_HORSE_SKILL" + if mounting and not table.is_horse_skill(skill_id): + return _no("NOT_HORSE_SKILL") + + # if (IsHorseSkill() && !IsMountingHorse()) -> "HAVE_TO_RIDE" + if table.is_horse_skill(skill_id) and not mounting: + return _no("HAVE_TO_RIDE") + + # if (IsAttackSkill() && IsInSafe()) -> "IN_SAFE" + if table.is_attack(skill_id) and in_safe: + return _no("IN_SAFE") + + # if (!IsCanUseSkill()) return false; —— 被动技 + if not table.is_can_use_skill(skill_id): + return _no("PASSIVE") + + # if (IsNeedEmptyBottle() && !__HasItem(27995)) -> "NEED_EMPTY_BOTTLE" + if table.is_need_empty_bottle(skill_id) and not _has_item(ITEM_EMPTY_BOTTLE): + return _no("NEED_EMPTY_BOTTLE") + + # if (IsNeedPoisonBottle() && !__HasItem(27996)) -> "NEED_POISON_BOTTLE" + if table.is_need_poison_bottle(skill_id) and not _has_item(ITEM_POISON_BOTTLE): + return _no("NEED_POISON_BOTTLE") + + # if (IsFishingMode()) -> "REMOVE_FISHING_ROD" + if is_fishing: + return _no("REMOVE_FISHING_ROD") + + # if (m_sysIsLevelLimit && rkSkillInst.iLevel <= 0) -> "NOT_YET_LEARN" + if level_limit and _skill_level(skill_id) <= 0: + return _no("NOT_YET_LEARN") + + # if (!CanUseWeaponType(GetWeaponType())) -> "NOT_MATCHABLE_WEAPON" + # weapon_sub_type < 0(未知)时跳过,避免误挡。 + if weapon_sub_type >= 0 and not table.can_use_weapon_type(skill_id, weapon_sub_type): + return _no("NOT_MATCHABLE_WEAPON") + + # if (!IsHorseSkill()) { __CheckShortArrow; if (IsNeedBow() && !__HasEnoughArrow()) ... } + if not table.is_horse_skill(skill_id): + if table.is_need_bow(skill_id) and not _has_enough_arrow(): + return _no("EMPTY_ARROW") + + # __CheckDashAffect 分支:冲刺 affect 下、非蓄力技才查冷却;否则查冷却 + HP + SP + if dash_active: + if not table.is_charge_skill(skill_id): + if _check_rest_skill_cooltime(): + return _no("WAIT_COOLTIME") + else: + if _check_rest_skill_cooltime(): + return _no("WAIT_COOLTIME") + if _check_short_life(skill_id): + return _no("NOT_ENOUGH_HP") + if _check_short_mana(skill_id): + return _no("NOT_ENOUGH_SP") + + return _ok() + +# ======================================================================== +# (d) __UseSkill —— PythonPlayerSkill.cpp:460 +# ======================================================================== +func use_skill(skill_id: int, is_active := false) -> Dictionary: + # if (IsOpenPrivateShop()) return true; —— 静默成功但什么都不做 + if private_shop_open: + return _no("PRIVATE_SHOP") + + # if (!__CanUseSkill()) return false; + var b := can_use_skill() + if not b.ok: + return b + + # if (dwSlotIndex >= SKILL_MAX_NUM) return false; + if skill_id < 0 or skill_id >= SKILL_MAX_NUM: + return _no("BAD_SLOT") + + # if (__CheckSpecialSkill(rkSkillInst.dwIndex)) return true; + if _check_special_skill(skill_id): + return _no("SPECIAL_SKILL") + + # if (!GetSkillData(...)) return false; + if table == null or not table.has(skill_id): + return _no("NO_SKILL_DATA") + + # if (IsToggleSkill() && IsSkillActive(slot)) { SendUseSkillPacket; return false; } + if table.is_toggle(skill_id) and is_active: + return _no("TOGGLE_OFF") + + # if (!__CheckSkillUsable(dwSlotIndex)) return false; + var c := check_skill_usable(skill_id) + if not c.ok: + return c + + # if (pkInstMain->IsUsingSkill()) return false; + if is_using_skill: + return _no("ALREADY_CASTING") + + # 目标解析(IsNeedTarget || CanChangeDirection || IsAutoSearchTarget) + var need_target: bool = table.is_need_target(skill_id) \ + or table.can_change_direction(skill_id) \ + or table.is_auto_search_target(skill_id) + if not need_target: + return _ok(0) + + return _resolve_target(skill_id) + +# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。 +# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9, +# 这里只判目标「类型」是否合法并回传解析到的 vid。 +func _resolve_target(skill_id: int) -> Dictionary: + var main_vid := _main_vid() + var tvid := 0 + if client and client.has_method("get_target"): + tvid = int(client.get_target().get("vid", 0)) + + var need_corpse: bool = table.is_need_corpse(skill_id) + var te: Dictionary = {} + if tvid != 0 and client and client.has_method("get_entity"): + var e: Variant = client.get_entity(tvid) + if e is Dictionary: + te = e + + # --- 有目标 --- + if not te.is_empty(): + var is_self := tvid == main_vid + var target_dead := bool(te.get("dead", false)) + + # 需要尸体的技能:目标必须已死 + if need_corpse and not target_dead: + return _no("ONLY_FOR_CORPSE") + + if table.is_only_for_alliance(skill_id): + if is_self: + if not table.can_use_for_me(skill_id): + return _no("CANNOT_USE_SELF") + return _ok(main_vid) + # 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法 + if _entity_kind(te) == 0 and not _is_attackable(te): + return _ok(tvid) + if table.can_use_for_me(skill_id): + return _ok(main_vid) # 目标非法 -> 回落自身 + return _no("ONLY_FOR_ALLIANCE") + else: + # 攻击类:目标在安全区不可打 + if bool(te.get("in_safe", false)): + return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") + if _is_attackable(te): + return _ok(tvid) + return _no("CANNOT_ATTACK") + + # --- 无目标 --- + if table.is_auto_search_target(skill_id): + # NEW_GetFrontInstance 在 POC 侧没有等价物;无目标即失败。 + if not table.can_use_for_me(skill_id) and not need_corpse: + return _no("NEED_TARGET") + + if table.can_use_for_me(skill_id): + return _ok(main_vid) + if need_corpse: + return _no("ONLY_FOR_CORPSE") + return _no("NEED_TARGET") + +# __CheckSpecialSkill —— PythonPlayerSkill.cpp:950(钓鱼 123 / 连击 122)。 +func _check_special_skill(skill_id: int) -> bool: + if skill_id == SKILL_INDEX_FISHING: + return true + if skill_id == SKILL_INDEX_COMBO: + return true + return false diff --git a/project/player_skill_test.gd b/project/player_skill_test.gd new file mode 100644 index 00000000..3e7e7ba8 --- /dev/null +++ b/project/player_skill_test.gd @@ -0,0 +1,261 @@ +# player_skill_test —— §3.8 修改 1:player_skill.gd 技能三层合法性校验 headless 自检。 +# godot --headless --path project --script player_skill_test.gd +# +# 逐层覆盖 ClickSkillSlot(a) / __CanUseSkill(b) / __CheckSkillUsable(c) / __UseSkill(d) +# 的每个 return 分支,断言返回 { ok, code, target_vid } 与 PythonPlayerSkill.cpp 一致。 +extends SceneTree + +const SkillTable = preload("res://ui/skill_table.gd") +const PlayerSkill = preload("res://player_skill.gd") + +class FakeClient extends Node: + var main := 1000 + var target := {"vid": 2000} + var entities := {1000: {"kind": 0}, 2000: {"kind": 2}} + var skills := [ + {"id": 1, "level": 5, "master": 0}, + {"id": 46, "level": 10, "master": 0}, + {"id": 109, "level": 1, "master": 0}, + ] + var inv: Array = [] + func get_main_vid() -> int: return main + func get_entity(v) -> Dictionary: return entities.get(int(v), {}) + func get_target() -> Dictionary: return target + func get_skills() -> Array: return skills + func get_inventory() -> Array: return inv + +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: player_skill_test (§3.8 三层校验)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +# 每次校验前把 gate 的上下文复位到「放行」缺省,避免用例互相污染。 +func _reset(g: RefCounted) -> void: + g.observer_mode = false + g.mounting = false + g.in_safe = false + g.is_fishing = false + g.is_using_skill = false + g.can_act = true + g.private_shop_open = false + g.level_limit = true + g.cooltime_check = true + g.dash_active = false + g.weapon_sub_type = -1 + g.arrow_count = -1 + g.slot_cd_end = 0.0 + g.cur_hp = -1 + g.cur_sp = -1 + +func _run() -> void: + var assets := AssetRoot.path() + var st := SkillTable.new() + var loaded := false + for lang in ["en", "common"]: + if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)): + loaded = true + break + if not loaded: + print(" (skip: no skilldesc.txt)") + return + + var fc := FakeClient.new() + get_root().add_child(fc) + var g := PlayerSkill.new() + g.setup(fc, st) + + # 常量对齐 §3.8 修改 1 硬编码 + _ck(g.SKILL_MAX_NUM == 255, "SKILL_MAX_NUM == 255") + _ck(g.SKILL_TYPE_GUILD == 3, "SKILL_TYPE_GUILD == 3") + _ck(g.RIDING_SKILL_INDEX == 109, "RIDING_SKILL_INDEX == 109") + _ck(g.ITEM_EMPTY_BOTTLE == 27995 and g.ITEM_POISON_BOTTLE == 27996, "空瓶/毒瓶 vnum") + _ck(g.SKILL_INDEX_FISHING == 123 and g.SKILL_INDEX_COMBO == 122, "特殊技 vnum") + + # ---------- (a) ClickSkillSlot ---------- + _reset(g) + _ck(g.click_skill_slot(-1).code == "BAD_SLOT", "(a) 槽越界 -> BAD_SLOT") + _ck(g.click_skill_slot(999).code == "BAD_SLOT", "(a) 槽 >= SKILL_MAX_NUM -> BAD_SLOT") + # 表里查不到的 id + var missing := 0 + for cand in range(240, 255): + if not st.has(cand): + missing = cand + break + if missing != 0: + _ck(g.click_skill_slot(missing).code == "NO_SKILL_DATA", "(a) 无技能数据 -> NO_SKILL_DATA") + # 公会技 151 -> 路由 + if st.has(151) and st.is_guild_skill(151): + _ck(g.click_skill_slot(151).code == "GUILD_ROUTED", "(a) 公会技 -> GUILD_ROUTED") + # 被动技 121 -> 静默挡下 + if st.has(121) and st.is_passive(121): + var r := g.click_skill_slot(121) + _ck(not r.ok and r.code == "PASSIVE", "(a) 被动技 -> PASSIVE") + # 站立 + 开关技,已激活 -> TOGGLE_OFF + if st.has(63) and st.is_standing(63) and st.is_toggle(63): + _ck(g.click_skill_slot(63, true).code == "TOGGLE_OFF", "(a) 开关技已激活 -> TOGGLE_OFF") + + # ---------- (b) __CanUseSkill ---------- + _reset(g) + _ck(g.can_use_skill().ok, "(b) 缺省上下文 -> ok") + _reset(g); g.observer_mode = true + _ck(g.can_use_skill().code == "OBSERVER", "(b) 观战 -> OBSERVER") + _reset(g); g.can_act = false + _ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 死亡/晕眩/击退 -> CANNOT_ACT") + _reset(g); g.mounting = true # 无骑乘术(skills 里 109 只有 level 1 / grade 0) + _ck(g.can_use_skill().code == "NO_RIDING_SKILL", "(b) 骑乘缺骑乘术 -> NO_RIDING_SKILL") + _reset(g); g.mounting = true + fc.skills[2]["level"] = 20 # 骑乘术 109 练满 + _ck(g.can_use_skill().ok, "(b) 骑乘术 lv20 -> ok") + fc.skills[2]["level"] = 1 + # 无主角 + _reset(g); fc.main = 0 + _ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 无主角 -> CANNOT_ACT") + fc.main = 1000 + + # ---------- (c) __CheckSkillUsable ---------- + # skill 1 Three-Way Cut:ATTACK_SKILL|NEED_TARGET,非骑乘技,需武器 SWORD|TWO_HANDED + _reset(g) + _ck(g.check_skill_usable(1).ok, "(c) skill 1 缺省 -> ok") + _reset(g); g.mounting = true + _ck(g.check_skill_usable(1).code == "NOT_HORSE_SKILL", "(c) 骑乘中放非骑乘技 -> NOT_HORSE_SKILL") + # skill 138 Horse Stump:HORSE_SKILL + if st.has(138) and st.is_horse_skill(138): + _reset(g); g.level_limit = false + _ck(g.check_skill_usable(138).code == "HAVE_TO_RIDE", "(c) 未骑乘放骑乘技 -> HAVE_TO_RIDE") + _reset(g); g.mounting = true; g.level_limit = false + var r138 := g.check_skill_usable(138) + _ck(r138.ok, "(c) 骑乘放骑乘技 -> 放行") + _reset(g); g.in_safe = true + _ck(g.check_skill_usable(1).code == "IN_SAFE", "(c) 安全区放攻击技 -> IN_SAFE") + _reset(g); g.is_fishing = true + _ck(g.check_skill_usable(1).code == "REMOVE_FISHING_ROD", "(c) 钓鱼态 -> REMOVE_FISHING_ROD") + # 未学:表里有但 get_skills 没有 + var unlearned := 0 + for cand in [2, 3, 4, 5, 6, 7, 8, 9]: + if st.has(cand) and g._skill_level(cand) == 0 and not st.is_horse_skill(cand) \ + and not st.is_passive(cand): + unlearned = cand + break + if unlearned != 0: + _reset(g) + _ck(g.check_skill_usable(unlearned).code == "NOT_YET_LEARN", "(c) 未学技能 -> NOT_YET_LEARN") + _reset(g); g.level_limit = false + _ck(g.check_skill_usable(unlearned).code != "NOT_YET_LEARN", "(c) 关等级限制 -> 不再挡") + # 武器种类不匹配:skill 46 需 BOW + if st.has(46) and st.has_weapon_limitation(46) and st.is_need_bow(46): + _reset(g); g.weapon_sub_type = st.WEAPON_SWORD + _ck(g.check_skill_usable(46).code == "NOT_MATCHABLE_WEAPON", "(c) 拿剑放弓技 -> NOT_MATCHABLE_WEAPON") + _reset(g); g.weapon_sub_type = st.WEAPON_BOW + _ck(g.check_skill_usable(46).ok, "(c) 拿弓放弓技 -> ok") + _reset(g); g.weapon_sub_type = st.WEAPON_BOW; g.arrow_count = 0 + _ck(g.check_skill_usable(46).code == "EMPTY_ARROW", "(c) 弓技无箭 -> EMPTY_ARROW") + # 冷却 + _reset(g); g.slot_cd_end = g._now() + 5.0 + _ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) 冷却中 -> WAIT_COOLTIME") + _reset(g); g.slot_cd_end = g._now() + 5.0; g.cooltime_check = false + _ck(g.check_skill_usable(1).ok, "(c) m_sysIsCoolTime 关 -> 不查冷却") + # 冲刺 affect:非蓄力技仍查冷却 + _reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true + _ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) dash + 非蓄力 -> 仍查冷却") + # 蓄力技 5 Dash:冲刺 affect 下跳过冷却 + if st.has(5) and st.is_charge_skill(5): + _reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true; g.level_limit = false + var r5 := g.check_skill_usable(5) + _ck(r5.ok or r5.code == "NOT_MATCHABLE_WEAPON", "(c) dash + 蓄力技 -> 跳过冷却") + + # ---------- (c) §3.8 修改 3:NeedSP / __CheckShortMana / __CheckShortLife ---------- + # skill 1 samyeon.msk: NeedSPFormula "40 + (100 * SkillPoint)";_skill_point(5) = 12/100。 + _ck(is_equal_approx(st._skill_point(20), 0.5), "_skill_point(20) = master 50/100") + _ck(is_equal_approx(st._skill_point(0), 0.0) and is_equal_approx(st._skill_point(50), 0.0), + "_skill_point 越界 -> 0") + if st.has(1) and String(st.entry(1).get("need_sp_formula", "")) != "": + _ck(st.need_sp(1, 5) == 52, "need_sp(skill 1, lv5) = 40 + 100*0.12 = 52") + _ck(st.need_hp(1, 5) == st.need_sp(1, 5), "need_hp 复用 GetNeedSP") + # __CheckShortMana:SP 不足 -> NOT_ENOUGH_SP + _reset(g); g.cur_sp = 10 + _ck(g.check_skill_usable(1).code == "NOT_ENOUGH_SP", "(c) SP 10 < 52 -> NOT_ENOUGH_SP") + _reset(g); g.cur_sp = 200 + _ck(g.check_skill_usable(1).ok, "(c) SP 200 >= 52 -> ok") + _reset(g); g.cur_sp = -1 + _ck(g.check_skill_usable(1).ok, "(c) SP 未知 -> 不判 NOT_ENOUGH_SP") + # 未知公式 -> need_sp = -1,不拦截 + var no_sp := 0 + for cand in st.for_job("WARRIOR"): + if st.has(cand) and String(st.entry(cand).get("need_sp_formula", "")) == "" \ + and g._skill_level(cand) > 0 and not st.is_passive(cand) \ + and not st.is_horse_skill(cand) and not st.is_toggle(cand): + no_sp = cand + break + if no_sp != 0: + _reset(g); g.cur_sp = 0 + _ck(g.check_skill_usable(no_sp).code != "NOT_ENOUGH_SP", + "(c) 无 NeedSPFormula(%d) + SP 0 -> 不误挡" % no_sp) + # __CheckShortLife:skill 1 非 USE_HP -> 即便 HP 极低也不挡 + _reset(g); g.cur_hp = 1 + _ck(g.check_skill_usable(1).code != "NOT_ENOUGH_HP", "(c) 非 USE_HP 技 + HP 1 -> 不判 NOT_ENOUGH_HP") + + # ---------- (d) __UseSkill ---------- + _reset(g); g.private_shop_open = true + _ck(g.use_skill(1).code == "PRIVATE_SHOP", "(d) 私人商店 -> PRIVATE_SHOP") + _reset(g); g.is_using_skill = true + _ck(g.use_skill(1).code == "ALREADY_CASTING", "(d) 施法中 -> ALREADY_CASTING") + # 特殊技 + _reset(g) + _ck(g.use_skill(g.SKILL_INDEX_FISHING).code == "SPECIAL_SKILL", "(d) 钓鱼技 -> SPECIAL_SKILL") + _ck(g.use_skill(g.SKILL_INDEX_COMBO).code == "SPECIAL_SKILL", "(d) 连击技 -> SPECIAL_SKILL") + # 目标解析:skill 1 需要目标 + _reset(g) + if st.is_need_target(1): + fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2} + var rt := g.use_skill(1) + _ck(rt.ok and rt.target_vid == 2000, "(d) 有可攻击目标 -> ok, target=2000") + fc.target = {"vid": 0} + _ck(g.use_skill(1).code == "NEED_TARGET", "(d) 无目标 -> NEED_TARGET") + fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2, "in_safe": true} + _ck(g.use_skill(1).code == "CANNOT_ATTACK_ENEMY_IN_SAFE_AREA", + "(d) 目标在安全区 -> CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") + fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC + _ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK") + fc.entities[2000] = {"kind": 2} + # 结盟技 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE + if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109): + _reset(g) + var needs_tgt: bool = st.is_need_target(109) or st.can_change_direction(109) \ + or st.is_auto_search_target(109) + if needs_tgt: + fc.target = {"vid": 0} + var rself := g.use_skill(109) + _ck(rself.ok and rself.target_vid == fc.main, + "(d) 结盟技无目标 -> 回落自身 target=main") + fc.target = {"vid": 1000} # 自己 + _ck(g.use_skill(109).ok, "(d) 结盟技对自己 -> ok") + fc.target = {"vid": 2000} + + # 非目标类技能 -> 直接 ok, target 0 + var no_target_skill := 0 + for id in st.for_job("WARRIOR"): + if g._skill_level(id) > 0 and not st.is_need_target(id) \ + and not st.can_change_direction(id) and not st.is_auto_search_target(id) \ + and not st.is_passive(id) and not st.is_horse_skill(id): + no_target_skill = id + break + if no_target_skill != 0: + _reset(g) + var rnt := g.use_skill(no_target_skill) + _ck(rnt.ok and rnt.target_vid == 0 or rnt.code == "NOT_MATCHABLE_WEAPON", + "(d) 非目标技 %d -> ok/target 0" % no_target_skill) + + # is_silent_code + _ck(PlayerSkill.is_silent_code("PASSIVE") and PlayerSkill.is_silent_code("TOGGLE_OFF") + and not PlayerSkill.is_silent_code("IN_SAFE"), + "is_silent_code 分类正确") diff --git a/project/private_shop_ui_test.gd b/project/private_shop_ui_test.gd index 82d3f489..1fbf84fe 100644 --- a/project/private_shop_ui_test.gd +++ b/project/private_shop_ui_test.gd @@ -55,7 +55,8 @@ func _run() -> void: var fp := FakeProto.new() get_root().add_child(fc) get_root().add_child(fp) - fc.inventory = [{"cell": 4, "vnum": 11901, "count": 1}, {"cell": 5, "vnum": 27993, "count": 3}] + fc.inventory = [{"cell": 4, "vnum": 11901, "count": 1}, + {"cell": 5, "vnum": 27993, "count": 3}, {"cell": 6, "vnum": 30001, "count": 2}] var pu: Node = PrivateShopUI.new() get_root().add_child(pu) @@ -66,9 +67,13 @@ func _run() -> void: return _ck(pu._cells.size() == 40, "ItemSlot = 40-slot grid (5x8, shop.SHOP_SLOT_COUNT)") _ck(is_instance_valid(pu._sign), "NameLine overlaid with a LineEdit") - _ck(pu._inv_list.get_child_count() == 2, "2 inventory candidates") + _ck(pu._inv_list.get_child_count() == 3, "3 inventory candidates") _ck((pu._inv_list.get_child(0) as Button).has_method("_get_drag_data"), "inventory candidate supports drag data") + _ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 300, "count": 1, + "anti_flags": (1 << 13)}, 2), "ANTIFLAG_GIVE item cannot enter private shop") + _ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 301, "count": 1, + "anti_flags": (1 << 16)}, 2), "ANTIFLAG_MYSHOP item cannot enter private shop") # 未上货 → 개설 무동작 pu._ok() @@ -82,13 +87,14 @@ func _run() -> void: pu._place(3, 250000) _ck(pu._stock.has(3) and int(pu._stock[3]["price"]) == 250000, "stock[3] placed") _ck(pu._picked == null, "pick cleared after place") - _ck(pu._inv_list.get_child_count() == 1, "placed item leaves candidate list") + _ck(pu._inv_list.get_child_count() == 2, "placed item leaves candidate list") # 标准 Godot 拖放入口与点击选取共用同一个落位逻辑。 pu._drop_slot(Vector2.ZERO, {"private_shop_entry": {"cell": 5, "vnum": 27993, "count": 3}}, 6) _ck(is_instance_valid(pu._price_dialog), "dragging candidate onto empty slot opens price dialog") pu._place(6, 100) + _ck(pu._stock.has(6), "dragged item is retained in stock") - # 拿起第 2 件(cell 5)→ 落到格 0(更小格号 → 排前) + # 拿起第 3 件(cell 6)→ 落到格 0(更小格号 → 排前) (pu._inv_list.get_child(0) as Button).pressed.emit() pu._place(0, 99) pu._sign.text = "Cheap swords" @@ -96,10 +102,11 @@ func _run() -> void: _ck(_has_call(fc, "open_shop", func(c): if String(c[1]) != "Cheap swords": return false var items: Array = c[2] - if items.size() != 2: return false - return int(items[0]["vnum"]) == 27993 and int(items[0]["display_pos"]) == 0 \ + if items.size() != 3: return false + return int(items[0]["vnum"]) == 30001 and int(items[0]["display_pos"]) == 0 \ and int(items[1]["vnum"]) == 11901 and int(items[1]["price"]) == 250000 \ - and int(items[1]["inv_cell"]) == 4 and int(items[1]["display_pos"]) == 3), + and int(items[1]["inv_cell"]) == 4 and int(items[1]["display_pos"]) == 3 \ + and int(items[2]["vnum"]) == 27993 and int(items[2]["display_pos"]) == 6), "개설 → open_private_shop(sign, stock 按格号排序 + display_pos)") _ck(not pu.is_open(), "closes after 개설") diff --git a/project/quest_event_test.gd b/project/quest_event_test.gd new file mode 100644 index 00000000..408bf05e --- /dev/null +++ b/project/quest_event_test.gd @@ -0,0 +1,167 @@ +# quest_event_test —— §5.1 Quest EventManager 1:1(CPythonEventManager): +# EVENT_TYPE_MAP(42 tag)+ GetScriptEventIndex(前导 '/')+ ProcessEventSet 分发 + +# SEventSet 状态(字段名与 REF/UserInterface/PythonEventManager.h 一致)。 +# godot --headless --path project --script quest_event_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const QuestDialog = preload("res://ui/quest_dialog.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: quest_event_test (§5.1 EventTypeMap + ProcessEventSet 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + var qd := QuestDialog.new() + + # --- 1. EventTypeMap 完整性(PythonEventManager.cpp:1148-1207,42 条)--- + var tags := ["LETTER", "COLOR", "DELAY", "ENTER", "WAIT", "CLEAR", "QUESTION", "NEXT", + "DONE", "LEFTIMAGE", "TOPIMAGE", "BGIMAGE", "IMAGE", "ADDMAPSIGNAL", "CLEARMAPSIGNAL", + "SETMSGPOS", "ADJMSGPOS", "SETCMAPPOS", "QUESTBUTTON", "QUESTBUTTON_CLOSE", "SLEEP", + "SET_CAMERA", "BLEND_CAMERA", "RESTORE_CAMERA", "FADE_OUT", "FADE_IN", "WHITE_OUT", + "WHITE_IN", "CLEAR_TEXT", "TEXT_HORIZONTAL_ALIGN_CENTER", "TITLE_IMAGE", "RUN_CINEMA", + "DUNGEON_RESULT", "ITEM", "MOB", "COLOR256", "WINDOW_SIZE", "INPUT", "CONFIRM_WAIT", + "END_CONFIRM_WAIT", "INSERT_IMAGE", "SELECT_ITEM"] + _ck(tags.size() == 42, "EventTypeMap: 42 tags enumerated (%d)" % tags.size()) + var missing := 0 + for t in tags: + if not qd.EVENT_TYPE_MAP.has(t): + missing += 1 + printerr(" missing EVENT_TYPE_MAP[%s]" % t) + _ck(missing == 0, "EventTypeMap: every tag present") + _ck(qd.EVENT_TYPE_MAP.size() == 42, "EventTypeMap: no extra keys (%d)" % qd.EVENT_TYPE_MAP.size()) + + # --- 2. GetScriptEventIndex:前导 '/' = EVENT_POSITION_END --- + var s := qd._get_script_event_index("DELAY") + _ck(s.ok and s.position == qd.EVENT_POSITION_START and s.type == qd.EVT_DELAY, "GSEI: DELAY -> START/EVT_DELAY") + var e := qd._get_script_event_index("/DELAY") + _ck(e.ok and e.position == qd.EVENT_POSITION_END and e.type == qd.EVT_DELAY, "GSEI: /DELAY -> END/EVT_DELAY") + _ck(qd._get_script_event_index("nope").ok == false, "GSEI: unknown -> ok=false") + _ck(qd._get_script_event_index("letter").type == qd.EVT_LETTER, "GSEI: case-insensitive") + + # --- 3. DELAY START/END:lWaitingTime --- + var d := qd.parse_script("[DELAY value(40)]hi") + _ck(d.waiting_time == 40, "DELAY START -> lWaitingTime=40 (%d)" % d.waiting_time) + var d2 := qd.parse_script("[DELAY value(40)]hi[/DELAY]") + _ck(d2.waiting_time == 10, "/DELAY -> lWaitingTime=c_lNormal_Waiting_Time(10) (%d)" % d2.waiting_time) + + # --- 4. LETTER / 裸文本 / iRestrictedCharacterCount(30) 断行 --- + var lt := qd.parse_script("[LETTER value(\"0123456789012345678901234567890123\")]tail") + # 显式 [LETTER] 34 字 >= 30 -> __InsertLine,"tail" 落到下一行。 + _ck(lt.body.split("\n").size() == 2, "LETTER: >=iRestrictedCharacterCount wraps (%d lines)" % lt.body.split("\n").size()) + var raw := qd.parse_script("这是一段没有任何 ENTER 的很长的网络文本用来验证裸文本不会被 30 字断行处理掉哦哦哦哦哦哦哦") + _ck(raw.body.split("\n").size() == 1, "raw text: no 30-char guillotine (%d lines)" % raw.body.split("\n").size()) + + # --- 5. ENTER -> line;CLEAR / CLEAR_TEXT -> reset --- + _ck(qd.parse_script("a[ENTER]b").body == "a\nb", "ENTER -> line break") + _ck(qd.parse_script("gone[CLEAR]kept").body == "kept", "CLEAR -> body reset") + _ck(qd.parse_script("gone[CLEAR_TEXT]kept").body == "kept", "CLEAR_TEXT -> body reset") + + # --- 6. QUESTION -> choices + nAnswer --- + var q := qd.parse_script("Pick[QUESTION arg(\"yes\") arg(\"no\") arg(\"maybe\")]") + _ck(q.choices == ["yes", "no", "maybe"] and q.answer_count == 3, "QUESTION -> 3 choices + nAnswer=3") + + # --- 7. NEXT / DONE -> has_next + iAdjustLine += 2 --- + var nx := qd.parse_script("x[NEXT]") + _ck(nx.has_next and nx.adjust_line == 2, "NEXT -> has_next + adjust_line=2 (%d)" % nx.adjust_line) + var dn := qd.parse_script("x[NEXT][DONE]") + _ck(dn.has_next and dn.adjust_line == 4, "NEXT+DONE -> adjust_line=4 (%d)" % dn.adjust_line) + + # --- 8. WAIT -> isLock --- + var w := qd.parse_script("x[WAIT]y") + _ck(w.is_locked, "WAIT -> isLock true") + + # --- 9. COLOR / COLOR256 START(r/g/b & 位置) + END --- + var c1 := qd.parse_script("[COLOR r(0) g(1) b(0)]G[/COLOR]") + _ck(c1.body.contains("[color=#00ff00]") and c1.body.contains("[/color]"), "COLOR r/g/b named -> span") + var c2 := qd.parse_script("[COLOR256 value;255 value;128 value;0]O") + _ck(c2.body.contains("[color=#ff8000]"), "COLOR256 positional -> /255 span") + + # --- 10. INPUT / SELECT_ITEM --- + _ck(qd.parse_script("name?[INPUT]").has_input, "INPUT -> has_input") + _ck(qd.parse_script("[SELECT_ITEM]").has_select_item, "SELECT_ITEM -> has_select_item") + + # --- 11. CONFIRM_WAIT + END_CONFIRM_WAIT --- + var cw := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)]") + _ck(cw.confirm_wait and cw.confirm_timeout == 30, "CONFIRM_WAIT -> isConfirmWait + timeout=30") + var ce := qd.parse_script("sure?[CONFIRM_WAIT timeout(30)][END_CONFIRM_WAIT]") + _ck(ce.confirm_wait == false and ce.end_confirm, "END_CONFIRM_WAIT -> isConfirmWait cleared + CloseSelf") + + # --- 12. DUNGEON_RESULT -> 9 ints --- + var dr := qd.parse_script("[DUNGEON_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)]") + _ck(dr.dungeon_result.get("killmob_count", -1) == 40 and dr.dungeon_result.get("bonus_exp", -1) == 1234 + and dr.dungeon_result.size() == 9, "DUNGEON_RESULT -> 9 named ints") + + # --- 13. INSERT_IMAGE(有 image_name / 无 -> item icon)+ iAdjustLine += 2 --- + var ii := qd.parse_script("[INSERT_IMAGE image_name(\"a.tga\") title(\"T\") desc(\"D\") index(1) total(3)]") + _ck(ii.insert_images.size() == 1 and ii.insert_images[0].image_name == "a.tga" + and ii.insert_images[0].is_item_icon == false and ii.adjust_line == 2, "INSERT_IMAGE with image_name") + var ic := qd.parse_script("[INSERT_IMAGE image_type(\"item\") idx(19) title(\"T\") desc(\"D\") index(0) total(1)]") + _ck(ic.insert_images.size() == 1 and ic.insert_images[0].is_item_icon + and ic.insert_images[0].idx == 19, "INSERT_IMAGE empty image_name -> item icon") + + # --- 14. IMAGE 家族 --- + var im := qd.parse_script("[IMAGE x(10) y(20) src(\"pic.tga\")][LEFTIMAGE src(\"L.tga\")][TOPIMAGE src(\"T.tga\")][BGIMAGE src(\"B.tga\")][TITLE_IMAGE src(\"TT.tga\")]") + _ck(im.images.size() == 4, "IMAGE/LEFT/TOP/BG -> 4 image entries (%d)" % im.images.size()) + _ck(im.images[0].get("path", "") == "pic.tga" and im.images[0].get("x", 0) == 10, "IMAGE x/y/src") + _ck(im.images[1].get("panel", "") == "LEFTIMAGE" and im.images[2].get("panel", "") == "TOPIMAGE", "LEFT/TOP panels") + _ck(im.title_image == "TT.tga", "TITLE_IMAGE -> title_image") + + # --- 15. map signals --- + var ms := qd.parse_script("[ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL]") + _ck(ms.map_signals == [Vector2(1200, 3400)] and ms.atlas_center == [8, -4] and ms.clear_map_signals, + "ADDMAPSIGNAL / SETCMAPPOS / CLEARMAPSIGNAL") + + # --- 16. QUESTBUTTON / QUESTBUTTON_CLOSE --- + var qb := qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"q.sub\")][QUESTBUTTON_CLOSE idx(8)]") + _ck(qb.quest_buttons.size() == 1 and qb.quest_buttons[0].index == 12 + and qb.quest_buttons[0].title == "Read me" and qb.quest_buttons[0].icon_name == "q.sub", "QUESTBUTTON keyed") + _ck(qb.quest_button_closes == [8], "QUESTBUTTON_CLOSE idx -> close list") + + # --- 17. camera SET/BLEND/RESTORE --- + var cam := qd.parse_script("[SET_CAMERA x(1) y(2) z(3) distance(50)][BLEND_CAMERA x(4) blendtime(1500)][RESTORE_CAMERA]") + _ck(cam.camera_events.size() == 3, "camera: 3 events") + _ck(cam.camera_events[0].get("setting", {}).get("distance", 0) == 50, "SET_CAMERA setting.distance") + _ck(cam.camera_events[1].get("blendtime", 0) == 1500 and cam.camera_events[2].kind == "RESTORE_CAMERA", "BLEND blendtime + RESTORE") + + # --- 18. FADE_* / WHITE_* -> fade_events + isWaitFlag --- + var fx := qd.parse_script("[FADE_OUT speed(0.25)]") + _ck(fx.fade_events.size() == 1 and fx.fade_events[0].kind == "FADE_OUT" + and is_equal_approx(fx.fade_events[0].speed, 0.25) and fx.wait_flag, "FADE_OUT -> event + wait_flag") + var wx := qd.parse_script("[WHITE_IN speed(1)]") + _ck(wx.fade_events[0].kind == "WHITE_IN" and wx.wait_flag, "WHITE_IN -> event + wait_flag") + + # --- 19. WINDOW_SIZE / TEXT_HORIZONTAL_ALIGN_CENTER --- + var ws := qd.parse_script("[WINDOW_SIZE width(640) height(360)][TEXT_HORIZONTAL_ALIGN_CENTER]") + _ck(ws.window_size == [640, 360] and ws.text_centered, "WINDOW_SIZE keyed + TEXT_CENTER") + + # --- 20. ITEM / MOB 占位(无 proto/entity 时回退命名)--- + var nm := qd.parse_script("kill [MOB value(77)] get [ITEM value(19)]") + _ck(nm.body.contains("怪物 #77") and nm.body.contains("物品 #19"), "ITEM/MOB -> named placeholders") + + # --- 21. SETMSGPOS / ADJMSGPOS 故意留空(PythonEventManager.cpp:562/566)--- + var mp := qd.parse_script("body[SETMSGPOS x(1) y(2)][ADJMSGPOS x(3) y(4)]end") + _ck(mp.body == "bodyend", "SETMSGPOS/ADJMSGPOS -> no-op, no body change") + + # --- 22. 未知 tag 优雅跳过 --- + var uk := qd.parse_script("keep[TOTALLY_MADE_UP arg(1)]this") + _ck(uk.body == "keepthis", "unknown tag -> skipped, text preserved") + + # --- 23. RUN_CINEMA 首命令特例 -> cinema_file seam --- + var rc := qd.parse_script("[RUN_CINEMA value(\"intro.msc\")]") + _ck(rc.cinema_file == "intro.msc", "RUN_CINEMA (first cmd) -> cinema_file") + var rc2 := qd.parse_script("hi[RUN_CINEMA value(\"x.msc\")]") + _ck(rc2.cinema_file == "" and rc2.body == "hi", "RUN_CINEMA (non-first) -> silent no-op") + + qd.free() diff --git a/project/quest_test.gd b/project/quest_test.gd index 3c4f2b54..07e6b083 100644 --- a/project/quest_test.gd +++ b/project/quest_test.gd @@ -15,12 +15,14 @@ class FakeClient extends Node: var confirms := [] var clicks := [] var quests := [] + var script_buttons := [] func is_in_game() -> bool: return true func get_quests() -> Array: return quests func script_answer(a) -> bool: answers.append(a); return true func quest_cancel() -> bool: cancels += 1; return true func quest_input(text) -> bool: inputs.append(text); return true func quest_confirm(yes, pid) -> bool: confirms.append([yes, pid]); return true + func script_button(index) -> bool: script_buttons.append(index); return true func click_npc(vid) -> bool: clicks.append(vid); return true func get_entity(_v) -> Dictionary: return {} @@ -56,6 +58,19 @@ func _run() -> void: _ck(p.choices == ["I accept", "Not now"], "[QUESTION] -> 2 choices") var p2: Dictionary = qd.parse_script("Bye.[DONE]") _ck(p2.has_next and p2.choices.is_empty(), "[DONE] -> continue button, no choices") + var p3: Dictionary = qd.parse_script("[COLOR256 value;255 value;128 value;0]Gold[ENTER][COLOR value;0 value;1 value;0]Green[ITEM value;19][MOB value;77][WINDOW_SIZE value;640 value;360][TEXT_HORIZONTAL_ALIGN_CENTER][CONFIRM_WAIT value;30]") + _ck(p3.body.contains("[color=#ff8000]") and p3.body.contains("Gold") and p3.body.contains("[color=#00ff00]"), + "COLOR/COLOR256 -> RichTextLabel color spans") + _ck(p3.body.contains("物品 #19") and p3.body.contains("怪物 #77"), "ITEM/MOB -> named placeholders") + _ck(p3.window_size == [640, 360] and p3.text_centered and p3.confirm_wait and p3.confirm_timeout == 30, + "WINDOW_SIZE/TEXT_CENTER/CONFIRM_WAIT metadata") + var p4: Dictionary = qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL][SET_CAMERA value(1) value(2)][FADE_OUT speed(0.25)]") + _ck(p4.quest_buttons.size() == 1 and p4.quest_buttons[0].index == 12 and + p4.quest_buttons[0].title == "Read me", "QUESTBUTTON metadata") + _ck(p4.map_signals.size() == 1 and p4.map_signals[0] == Vector2(1200, 3400) and + p4.clear_map_signals and p4.atlas_center == [8, -4], "map signal metadata") + _ck(p4.camera_events.size() == 1 and p4.fade_events.size() == 1 and + p4.fade_events[0].kind == "FADE_OUT", "camera/fade metadata") # --- 对话弹窗 + 选择 -> script_answer --- fc.script_dialog.emit(1, "Pick one.[QUESTION arg(\"A\") arg(\"B\") arg(\"C\")]") @@ -94,6 +109,19 @@ func _run() -> void: _ck(fc.inputs == ["Alice"], "[INPUT] submit -> quest_input('Alice')") _ck(not qd.is_open(), "input submit closes quest dialog") + # EventManager side effects are surfaced to the interface owner. + var received := [] + var cleared := [] + var signal_points := [] + qd.quest_button_received.connect(func(index, title, icon_type, icon_name): received.append([index, title, icon_type, icon_name])) + qd.quest_button_cleared.connect(func(index): cleared.append(index)) + qd.map_signal_added.connect(func(x, y): signal_points.append(Vector2(x, y))) + fc.script_dialog.emit(0, "[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][QUESTBUTTON_CLOSE idx(8)][ADDMAPSIGNAL x(1200) y(3400)][CLEARMAPSIGNAL]") + await process_frame + _ck(received == [[12, "Read me", "file", "quest.sub"]] and cleared == [8], + "QUESTBUTTON events reach interface") + _ck(signal_points == [Vector2(1200, 3400)], "ADDMAPSIGNAL reaches minimap owner") + # --- quest_confirm --- fc.quest_confirm_ask.emit("Join the party?", 30, 999) await process_frame @@ -112,8 +140,10 @@ func _run() -> void: var ql: Node = QuestLog.new() get_root().add_child(ql) ql.setup(fc, canvas) + qd.quest_button_received.connect(func(index, title, icon_type, icon_name): ql.recv_quest(index, title, icon_type, icon_name)) + qd.quest_button_cleared.connect(ql.clear_quest) fc.quests = [ - {"index": 1, "title": "Slay 3 wolves", "counter_name": "Wolves", "counter_value": 2, "clock_name": "", "clock_value": 0}, + {"index": 1, "title": "Slay 3 wolves", "counter_name": "Wolves", "counter_value": 2, "clock_name": "Time", "clock_value": 65, "icon": "missing/icon.sub"}, {"index": 2, "title": "", "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0}, # 完成 -> 不显示 ] ql.toggle() @@ -126,6 +156,68 @@ func _run() -> void: if String(lbl.text).begins_with("◆"): titles += 1 _ck(titles == 1, "quest log shows 1 titled quest (got %d)" % titles) + _ck(ql._clock_labels.has(1) and String(ql._clock_labels[1].text).contains("1分5秒"), + "quest clock renders minutes and seconds") + # ClientVS22 only renders QUEST_MAX_NUM=5 rows and maps the scrollbar to a + # discrete questShowingStartIndex. Empty icon paths use the default scroll icon. + fc.quests.clear() + for i in range(7): + fc.quests.append({"index": 100 + i, "title": "Quest %d" % i, + "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0, "icon": ""}) + ql.refresh() + await process_frame + _ck(ql.quest_count() == 7 and ql._quest_scrollbar.visible, "quest log exposes all quests and scroll bar") + _ck(ql.visible_quest_indices() == [0, 1, 2, 3, 4], "quest log initially shows 5 rows") + ql.set_scroll_ratio(1.0) + await process_frame + _ck(ql.quest_start_index() == 2 and ql.visible_quest_indices() == [2, 3, 4, 5, 6], + "quest scroll maps to last 5 rows") + ql.select_quest(6) + _ck(fc.script_buttons == [4, -2147483648 + 106], "quest row selection -> event quest button packet") + fc.script_dialog.emit(0, "[QUESTBUTTON idx(4) name(\"Talk to guard\") icon_type(\"highlight\") icon_name(\"\")]") + await process_frame + _ck(ql.quest_button_count() == 1 and ql._button_root.visible, "quest button strip receives QUESTBUTTON") + (ql._button_list.get_child(0) as Button).pressed.emit() + _ck(fc.script_buttons == [4] and ql.quest_button_count() == 0, "quest button click -> script_button and remove") fc.quest_info.emit(1) # 刷新不炸 await process_frame _ck(true, "quest_info refresh ok") + + # --- 任务信件按钮条:多列定位(1:1 interfacemodule.__ArrangeQuestButton,§5.2 增量 109)--- + # 参考公式:yPos = 170*H//600 ; yCount = (H-330)//63 ; 第 i 个 = + # (xPos + (i//yCount)*100 , yPos + (i%yCount)*63) ; xPos = 20 / 130(组队窗显示) + _ck(QuestLog.quest_button_y_count(600.0) == 4, "quest btn: yCount(600) == 4") + _ck(QuestLog.quest_button_y_count(1080.0) == 11, "quest btn: yCount(1080) == 11") + _ck(QuestLog.quest_button_y_count(350.0) == 1, "quest btn: yCount clamps to 1 below ZeroDivision") + _ck(QuestLog.quest_button_y_pos(600.0) == 170, "quest btn: yPos(600) == 170") + _ck(QuestLog.quest_button_y_pos(1200.0) == 340, "quest btn: yPos scales with height") + ql.set_screen_size_override(Vector2(800, 600)) + for i in range(6): + ql.recv_quest(10 + i, "Letter %d" % i, "file", "") + await process_frame + # insert(0, btn):最新在前 + _ck(ql.quest_button_order() == [15, 14, 13, 12, 11, 10], "quest btn: newest inserted at front") + _ck(ql.quest_button_position(15) == Vector2(20, 170), "quest btn: slot 0 at (xPos, yPos)") + _ck(ql.quest_button_position(12) == Vector2(20, 359), "quest btn: slot 3 stacks down one column") + _ck(ql.quest_button_position(11) == Vector2(120, 170), "quest btn: slot 4 wraps to column 2") + _ck(ql.quest_button_position(10) == Vector2(120, 233), "quest btn: slot 5 is column 2 row 1") + _ck(ql._button_list.get_child(0).position == Vector2(20, 170), "quest btn: node position matches formula") + # 同 index 再来 -> 移到最前 + ql.recv_quest(13, "Letter 3 (again)", "file", "") + await process_frame + _ck(ql.quest_button_order() == [13, 15, 14, 12, 11, 10], "quest btn: re-received index moves to front") + # 组队窗显示 -> xPos 130 + ql.set_party_shown(true) + await process_frame + _ck(ql.quest_button_position(13) == Vector2(130, 170), "quest btn: party window shifts xPos to 130") + _ck(ql._button_list.get_child(0).position == Vector2(130, 170), "quest btn: node repositioned on party toggle") + ql.set_party_shown(false) + ql.clear_quest(13) + await process_frame + _ck(not ql.quest_button_order().has(13) and ql.quest_button_count() == 5, + "quest btn: clear drops index from order") + for i in range(6): + ql.clear_quest(10 + i) + await process_frame + _ck(ql.quest_button_order().is_empty() and not ql._button_root.visible, + "quest btn: emptied strip hides root") diff --git a/project/remote_player_test.gd b/project/remote_player_test.gd new file mode 100644 index 00000000..226de4cc --- /dev/null +++ b/project/remote_player_test.gd @@ -0,0 +1,70 @@ +# remote_player_test —— 远端 PC 真模型 + awPart[4] 换装链回归。 +# godot --headless --path project --script remote_player_test.gd +extends SceneTree + +const ItemListDB = preload("res://ui/item_list.gd") +const RemotePlayerView = preload("res://ui/remote_player_view.gd") +const NetWorld = preload("res://net_world.gd") + +class FieldProbe extends Node3D: + var calls: Array = [] + func set_armor(v: int) -> void: calls.append(["armor", v]) + func set_weapon(v: int) -> void: calls.append(["weapon", v]) + func set_head(v: int) -> void: calls.append(["head", v]) + func set_hair(v: int) -> void: calls.append(["hair", v]) + +var _fail := 0 + +func _ck(ok: bool, msg: String) -> void: + if not ok: + _fail += 1 + printerr("FAIL: " + msg) + +func _init() -> void: + await _run() + if _fail == 0: + print("PASS: remote_player_test (remote PlayerView + awPart[4])") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2AnimPlayer"): + print(" (skip: animation extension not registered)") + return + var assets := AssetRoot.path() + if not DirAccess.dir_exists_absolute(assets.path_join("PC/ymir work/pc/warrior")): + print(" (skip: PC assets unavailable)") + return + var il := ItemListDB.new() + if not il.load_file(assets.path_join("locale/locale/common/item_list.txt")): + print(" (skip: item_list unavailable)") + return + var pv := RemotePlayerView.new() + get_root().add_child(pv) + var parts: Array = [0, 19, 0, 5] # ARMOR / WEAPON / HEAD / HAIR + _ck(pv.build_remote(assets, 0, parts, il), "remote warrior PlayerView builds") + if pv.remote_equip == null: + return + _ck(pv.remote_equip._remote_parts == parts, "remote EquipModel receives awPart[4]") + await process_frame + pv.set_weapon(0) + await process_frame + _ck(int(pv.remote_equip._remote_parts[1]) == 0, "set_weapon updates awPart[WEAPON]") + pv.set_head(19) + await process_frame + _ck(int(pv.remote_equip._remote_parts[2]) == 19, "set_head updates awPart[HEAD]") + pv.set_hair(5) + await process_frame + _ck(int(pv.remote_equip._remote_parts[3]) == 5, "set_hair updates awPart[HAIR]") + pv.queue_free() + + # NetworkActorManager::UpdateActor 的四个 awPart 字段序必须保持 ARMOR/WEAPON/HEAD/HAIR。 + var nw := NetWorld.new() + var probe := FieldProbe.new() + nw._apply_field_updates(probe, {"parts": [11, 22, 33, 44]}) + _ck(probe.calls == [["armor", 11], ["weapon", 22], ["head", 33], ["hair", 44]], + "NetWorld applies awPart[4] in reference order") + probe.free() + nw.free() diff --git a/project/shop_rules_test.gd b/project/shop_rules_test.gd new file mode 100644 index 00000000..1fe947d8 --- /dev/null +++ b/project/shop_rules_test.gd @@ -0,0 +1,73 @@ +# shop_rules_test —— ClientVS22 出售价格、anti-flag 与贵重物品确认规则回归。 +extends SceneTree + +const ShopUI = preload("res://ui/shop_ui.gd") + +class FakeClient extends Node: + var calls: Array = [] + func get_shop_items() -> Array: + return [] + func get_shop() -> Dictionary: + return {"tabs": [{"name": "", "items": []}]} + func shop_sell(cell: int, count: int) -> bool: + calls.append([cell, count]) + return true + +class FakeProto extends Node: + var items: Dictionary = {} + func item(vnum: int) -> Dictionary: + return items.get(vnum, {}) + +var _failed := false + +func _check(ok: bool, label: String) -> void: + if ok: + return + _failed = true + push_error("FAIL: " + label) + +func _init() -> void: + var host := Control.new() + root.add_child(host) + var client := FakeClient.new() + root.add_child(client) + var proto := FakeProto.new() + proto.items = { + 31: {"sell_price": 6000, "flags": 0, "anti_flags": 0}, + 32: {"sell_price": 1000, "flags": 1 << 3, "anti_flags": 0}, + 33: {"sell_price": 1000, "flags": 0, "anti_flags": 1 << 8}, + 34: {"sell_price": 1000, "flags": 0, "anti_flags": 0}, + } + root.add_child(proto) + var shop := ShopUI.new() + root.add_child(shop) + shop.setup(client, host, proto) + shop.open() + shop._set_mode(2) + + _check(shop._sell_price(31, 3) == 3600, "normal sell price is unit price * count / 5") + _check(shop._sell_price(32, 30000) == 6, "count-per-1-gold uses count / unit price / 5") + _check(not shop._drop_to_sell({"window": 1, "cell": 3, "vnum": 33, "count": 1}), + "anti-flag sell is rejected") + _check(client.calls.is_empty(), "anti-flag does not send sell packet") + + shop._sell_quantity.value = 2 + _check(shop._drop_to_sell({"window": 1, "cell": 4, "vnum": 31, "count": 4}), + "valuable sell enters confirmation flow") + _check(shop._sell_confirm != null and client.calls.is_empty(), + "valuable sell waits for confirmation") + if shop._sell_confirm: + shop._sell_confirm.confirmed.emit() + _check(client.calls == [[4, 2]], "confirmed valuable sell sends selected count") + + shop._sell_quantity.value = 1 + _check(shop._drop_to_sell({"window": 1, "cell": 5, "vnum": 34, "count": 3}), + "ordinary sell sends immediately") + _check(client.calls == [[4, 2], [5, 1]], "ordinary sell uses selected count") + + host.queue_free() + if _failed: + quit(1) + else: + print("PASS: shop_rules_test (price + anti-flag + confirmation)") + quit(0) diff --git a/project/skill_test.gd b/project/skill_test.gd index e6ba2ca2..f9494d08 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -12,15 +12,22 @@ class FakeClient extends Node: signal skill_cooldown_end(skill: int) signal quickslots_changed() var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 2}] + var skill_group := 1 + var points := {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} var quickslots := [] var casts := [] var skill_uses := [] var quickslot_ops := [] var saids := [] + var emotes := [] var main := 1000 func get_main_vid() -> int: return main - func get_entity(_v) -> Dictionary: return {"race": 0} + # vid 2000 是选中目标:给个 kind=2(怪)让 §3.8 目标解析判其可攻击。 + func get_entity(_v) -> Dictionary: return {"race": 0, "kind": 2} + func get_inventory() -> Array: return [] func get_skills() -> Array: return skills + func get_skill_group() -> int: return skill_group + func get_points() -> Dictionary: return points func get_quickslots() -> Array: return quickslots func get_target() -> Dictionary: return {"vid": 2000} func cast_skill(mi, rot, x, y) -> bool: casts.append([mi, rot, x, y]); return true @@ -30,6 +37,7 @@ class FakeClient extends Node: func quickslot_swap(pos, change_pos) -> bool: quickslot_ops.append(["swap", pos, change_pos]); return true func skill_up(id) -> bool: saids.append(id); return true func use_item(w, c) -> bool: return true + func send_emoticon(id) -> bool: emotes.append(id); return true var _fail := 0 func _ck(c: bool, m: String) -> void: @@ -79,6 +87,95 @@ func _run() -> void: _ck(st.can_level_up(125) == false, "skill 125 'Item Creation' CANNOT_LEVEL_UP") _ck(st.for_category("HORSE").size() >= 3, "HORSE 分类有技能") + # --- §3.8 TSkillData 判定位解析(增量 87):对齐 PythonSkill.cpp 谓词 --- + # skill 1 Three-Way Cut:ATTACK_SKILL|NEED_TARGET|WEAPON_LIMITATION,W=SWORD|TWO_HANDED + _ck(st.attr_bits(1) & st.ATTR["ATTACK_SKILL"] != 0, "attr_bits(1) 含 ATTACK_SKILL 位") + _ck(st.is_need_target(1), "IsNeedTarget(1)") + _ck(st.is_can_use_skill(1), "IsCanUseSkill(1)(非被动)") + _ck(st.has_weapon_limitation(1), "skill 1 WEAPON_LIMITATION") + _ck(st.can_use_weapon_type(1, st.WEAPON_SWORD), "CanUseWeaponType(1, SWORD)") + _ck(st.can_use_weapon_type(1, st.WEAPON_TWO_HANDED), "CanUseWeaponType(1, TWO_HANDED)") + _ck(not st.can_use_weapon_type(1, st.WEAPON_BOW), "CanUseWeaponType(1, BOW) == false") + # skill 121 Leadership:PASSIVE —— IsCanUseSkill 必须挡下 + _ck(st.is_passive(121) and not st.is_can_use_skill(121), "IsCanUseSkill(121 被动) == false") + # skill 18 Stump:ATTACK_SKILL|STANDING_SKILL + _ck(st.is_standing(18) and st.is_attack(18), "IsStandingSkill(18) && IsAttackSkill(18)") + _ck(not st.is_standing(1), "skill 1 非 STANDING_SKILL") + # skill 46 Repetitive Shot:BOW + _ck(st.is_need_bow(46) and st.is_ranged(46), "IsNeedBow(46)") + _ck(st.can_use_weapon_type(46, st.WEAPON_BOW), "CanUseWeaponType(46, BOW)") + _ck(not st.can_use_weapon_type(46, st.WEAPON_SWORD), "CanUseWeaponType(46, SWORD) == false") + # skill 138 Horse Stump:HORSE_SKILL|SEARCH_TARGET|CHARGE_ATTACK + _ck(st.is_horse_skill(138), "IsHorseSkill(138)") + _ck(st.is_auto_search_target(138), "IsAutoSearchTarget(138)") + _ck(st.is_charge_skill(138), "IsChargeSkill(138)") + _ck(st.skill_type_of(138) == st.SKILL_TYPE_HORSE, "skill_type_of(138) == SKILL_TYPE_HORSE") + # skill 137 Horseback Slash:MOVING_SKILL + _ck(st.is_moving_skill(137), "IsMovingSkill(137)") + # skill 16 Spirit Strike (W):CAN_CHANGE_DIRECTION + _ck(st.can_change_direction(16), "CanChangeDirection(16)") + _ck(not st.can_change_direction(1), "skill 1 无 CAN_CHANGE_DIRECTION") + # skill 151 Dragon Eyes:JOB=GUILD + _ck(st.skill_type_of(151) == st.SKILL_TYPE_GUILD and st.is_guild_skill(151), + "skill_type_of(151) == SKILL_TYPE_GUILD") + # skill 152 Blood of Dragon God:ONLY_FOR_GUILD_WAR + _ck(st.is_only_for_guild_war(152), "IsOnlyForGuildWar(152)") + # skill 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE + _ck(st.can_use_for_me(109) and st.is_only_for_alliance(109), + "CanUseForMe(109) && IsOnlyForAlliance(109)") + # skill 5 Dash:CHARGE_ATTACK;skill 31 Ambush:MELEE_ATTACK + _ck(st.is_charge_skill(5), "IsChargeSkill(5)") + _ck(st.is_melee(31), "IsMeleeSkill(31)") + # skill 63 Enchanted Blade:TOGGLE|STANDING_SKILL|WEAPON_LIMITATION W=SWORD + _ck(st.is_toggle(63) and st.is_standing(63), "IsToggleSkill(63) && IsStandingSkill(63)") + _ck(st.can_use_weapon_type(63, st.WEAPON_SWORD) + and not st.can_use_weapon_type(63, st.WEAPON_DAGGER), + "skill 63 只吃 SWORD") + # 无 WEAPON_LIMITATION 的技能:CanUseWeaponType 恒真(PythonSkill.cpp:1143) + _ck(st.can_use_weapon_type(106, st.WEAPON_FAN) and st.can_use_weapon_type(106, st.WEAPON_BOW), + "skill 106 无武器限制 -> CanUseWeaponType 恒真") + # _fold_bits:瓶子 / USE_HP 记号(当前语言数据里没有对应技能,直接验折叠逻辑) + _ck(SkillTable._fold_bits("NEED_EMPTY_BOTTLE|NEED_POISON_BOTTLE", st.ATTR) + == (st.ATTR["NEED_EMPTY_BOTTLE"] | st.ATTR["NEED_POISON_BOTTLE"]), + "_fold_bits 折叠 NEED_*_BOTTLE") + _ck(SkillTable._fold_bits("use_hp , circle_range foo", st.ATTR) + == (st.ATTR["USE_HP"] | st.ATTR["CIRCLE_RANGE"]), + "_fold_bits 大小写/逗号/空格宽容,未知记号忽略") + _ck(SkillTable._fold_bits("SWORD|DOUBLE_SWORD", st.NEED_WEAPON) + == (1 << st.WEAPON_SWORD | 1 << st.WEAPON_DAGGER), + "_fold_bits DOUBLE_SWORD 复用 DAGGER 位") + + # --- §3.10 技能升级门(增量 90):max_level / level_limit / CanLevelUpSkill --- + # skill 1 samyeon.msk 有 MaxLevel "30" + _ck(st.max_level_of(1) == 30, "max_level_of(1) == 30(samyeon.msk MaxLevel)") + # skill 121 Leadership 是 SUPPORT,无 job_dir -> _load_msk 提前返回;skilldesc 无 MAX_LEVEL 列 + _ck(st.max_level_of(121) == 20, "max_level_of(121) == 20(SSkillData 构造默认)") + # 本 40250 shipped skilldesc.txt 只有 22 列,无 LEVEL_LIMIT 列 -> 恒 0 + _ck(st.skill_level_limit(1) == 0, "skill_level_limit(1) == 0(本 build 无 LEVEL_LIMIT 数据)") + # skillCanLevelUpSkill:未满级且非 CANNOT_LEVEL_UP + _ck(st.can_level_up_skill(1, 5), "can_level_up_skill(1, 5) == true") + _ck(st.can_level_up_skill(1, 29), "can_level_up_skill(1, 29) == true(29 < 30)") + _ck(not st.can_level_up_skill(1, 30), "can_level_up_skill(1, 30) == false(>= MaxLevel)") + _ck(not st.can_level_up_skill(1, 31), "can_level_up_skill(1, 31) == false") + _ck(not st.can_level_up_skill(125, 1), "can_level_up_skill(125, 1) == false(CANNOT_LEVEL_UP)") + _ck(not st.can_level_up_skill(9999, 1), "can_level_up_skill(未知 id) == false") + + # --- §3.4 扇形/圆形补目标元数据(增量 91):GetTargetCount / __GetSkillTargetRange / FlyShape --- + # skill 47 'Arrow Shower' = gwangyeok.msk:FAN_RANGE + TargetCountFormula + Range 2500 + _ck(st.is_fan_range(47), "skill 47 IsFanRange(gwangyeok FAN_RANGE)") + _ck(st.fly_shape(47) == 1, "fly_shape(47) == FAN(1)") + _ck(st.target_range(47) == 2500, "target_range(47) == 2500(gwangyeok.msk Range)") + # "2 + floor(6 * SkillPoint)":lv1 SkillPoint=0.05 -> 2;lv20(master) SkillPoint=0.5 -> 5 + _ck(st.target_count(47, 1) == 2, "target_count(47, lv1) == 2") + _ck(st.target_count(47, 20) == 5, "target_count(47, lv20) == 5") + # skill 91 'Flying Talisman' = bipabu.msk:FAN_RANGE + Range 1800,无 TargetCountFormula + _ck(st.is_fan_range(91), "skill 91 IsFanRange(bipabu FAN_RANGE)") + _ck(st.target_range(91) == 1800, "target_range(91) == 1800(bipabu.msk Range)") + _ck(st.target_count(91, 20) == 0, "target_count(91) == 0(bipabu 无 TargetCountFormula -> 仅主目标)") + # 非扇形/圆形技能:fly_shape == SINGLE(0),无 target_range + _ck(st.fly_shape(1) == 0, "fly_shape(1) == SINGLE(0)(samyeon 非范围技)") + _ck(st.target_count(1, 10) == 0, "target_count(1) == 0(无 TargetCountFormula)") + var fc := FakeClient.new() get_root().add_child(fc) var ui: CanvasLayer = UiManager.new() @@ -102,6 +199,25 @@ func _run() -> void: if sk._rows.has(16): _ck(String(sk._rows[16]["lv"].text) == "Lv 3 G", "skill 16 master 2 -> 'Lv 3 G'") + # --- §3.10 加号按钮门控(增量 90):主动页 --- + if sk._rows.has(1) and sk._rows[1]["up"] != null: + _ck(sk._rows[1]["up"].visible, "主动页 skill 1(lv5 grade0, skill_active=5, group=1)加号可见") + if sk._rows.has(16) and sk._rows[16]["up"] != null: + _ck(not sk._rows[16]["up"].visible, "skill 16 skillGrade!=0 -> 加号隐藏") + # 技能组未选(GetMainActorSkillGroup()==0)-> 整列隐藏加号 + fc.skill_group = 0 + sk.refresh() + if sk._rows.has(1) and sk._rows[1]["up"] != null: + _ck(not sk._rows[1]["up"].visible, "skill_group==0 -> __CanUseSkillNow 挡下,加号隐藏") + fc.skill_group = 1 + # 该页技能点为 0 -> 加号隐藏 + fc.points = {"level": 30, "skill_active": 0, "skill_support": 3, "skill_horse": 2} + sk.refresh() + if sk._rows.has(1) and sk._rows[1]["up"] != null: + _ck(not sk._rows[1]["up"].visible, "SKILL_ACTIVE 点数为 0 -> 加号隐藏") + fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} + sk.refresh() + # 切到「辅助」页 -> 列 SUPPORT,被动技能无 [+] sk._switch_tab(1) _ck(sk._cat_key() == "SUPPORT", "tab 1 -> SUPPORT") @@ -113,8 +229,29 @@ func _run() -> void: any_up = true _ck(any_up or sup.all(func(i): return st.is_passive(i) or not st.can_level_up(i)), "辅助页有可加点项 或 全是被动") + # §3.10:HIDE_SUPPORT_SKILL_POINT -> 辅助页所有加号按钮隐藏(即便技能点足够) + var sup_any_visible := false + for rid in sk._rows: + if sk._rows[rid]["up"] != null and sk._rows[rid]["up"].visible: + sup_any_visible = true + _ck(not sup_any_visible, "辅助页 HIDE_SUPPORT_SKILL_POINT -> 加号全隐藏") + sk._switch_tab(2) _ck(sk._cat_key() == "HORSE", "tab 2 -> HORSE") + # §3.10:马术页 —— skill_horse 点数 > 0 且 skillLevel < 20 -> 加号可见 + var horse_up_id := 0 + for rid in sk._rows: + if sk._rows[rid]["up"] != null: + horse_up_id = rid + break + if horse_up_id != 0: + _ck(sk._rows[horse_up_id]["up"].visible, + "马术页 skill %d(lv0 < 20, skill_horse=2)加号可见" % horse_up_id) + fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 0} + sk.refresh() + _ck(not sk._rows[horse_up_id]["up"].visible, "SKILL_HORSE 点数为 0 -> 马术加号隐藏") + fc.points = {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2} + sk.refresh() sk._switch_tab(0) # 切回主动,后面的 quickbar 断言不受影响 # quickbar @@ -140,25 +277,30 @@ func _run() -> void: qb.activate(0) _ck(fc.skill_uses.size() == 2, "cooldown_end -> can use_skill again") _ck(fc.casts.size() == 2, "cooldown_end -> can cast again") - # 36 槽 = 4 页 × 9 格;第四页的第 9 格正好是全局槽 35。 + # ClientVS22 的本地快捷栏显示 4 页 × 8 格;服务器仍保留 36 槽, + # 因此最后 4 个槽只可由协议恢复,不能从键盘本地显示/编辑。 qb.set_page(3) - qb.assign(8, "item", 9) - _ck(qb._state.size() == 36 and qb._state[35].kind == "item" and qb._state[35].id == 9, - "quickbar has 36 slots; page 4 slot 9 = global slot 35") - _ck(fc.quickslot_ops.back() == ["add", 35, 1, 9], "page 4 assign -> CG_QUICKSLOT_ADD{35,item,9}") + qb.assign(7, "item", 9) + _ck(qb._state.size() == 36 and qb._state[31].kind == "item" and qb._state[31].id == 9, + "quickbar has 36 server slots; page 4 slot 8 = global slot 31") + _ck(fc.quickslot_ops.back() == ["add", 31, 1, 9], "page 4 assign -> CG_QUICKSLOT_ADD{31,item,9}") qb.set_page(0) qb._swap(0, 1) # UI 的两次点击最终调用同一个交换操作 _ck(fc.quickslot_ops.back() == ["swap", 0, 1], "quickslot move -> CG_QUICKSLOT_SWAP") qb._clear(1) _ck(fc.quickslot_ops.back() == ["del", 1], "quickslot clear -> CG_QUICKSLOT_DEL") - # 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 -> 填格 + # 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格 var qb2: Node = Quickbar.new() get_root().add_child(qb2) - fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9}] + fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9}, + {"pos": 4, "type": 3, "ref": 11}] qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server _ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16") _ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9") + _ck(qb2._state[4].kind == "emote" and int(qb2._state[4].id) == 11, "quickslot 恢复:格4=表情11") + qb2.activate(4) + _ck(fc.emotes == [11], "表情快捷栏 -> send_emoticon(11)") fc.quickslots = [{"pos": 0, "type": 2, "ref": 1}] fc.quickslots_changed.emit() _ck(qb2._state[0].kind == "skill" and int(qb2._state[0].id) == 1, "quickslots_changed -> 重填") diff --git a/project/skybox_test.gd b/project/skybox_test.gd new file mode 100644 index 00000000..ce05f648 --- /dev/null +++ b/project/skybox_test.gd @@ -0,0 +1,95 @@ +# skybox_test —— 40250 ClientVS22 texture-mode SkyBox -> Godot sky shader。 +# 验证真实 texture-mode SkyBox:CapeDragonHead 的五个非空面回退渐变, +# DawnMistWood 的六面天空与云层同时接入并保留参考 UV / 速度参数。 +extends SceneTree + +var _fail := 0 + +func _ck(ok: bool, message: String) -> void: + if not ok: + _fail += 1 + printerr("FAIL: " + message) + +func _make_world(map_path: String) -> Node: + var world: Node = ClassDB.instantiate("Metin2World") + world.set("auto_load", false) + world.set("assets_root", AssetRoot.path()) + world.set("map_path", map_path) + world.set("focus_tile", Vector2i(0, 0)) + world.set("load_radius_tiles", 0) + world.set("objects_enabled", false) + world.set("splat_enabled", false) + world.set("water_enabled", false) + get_root().add_child(world) + return world + +func _init() -> void: + if not ClassDB.class_exists("Metin2World"): + printerr("FAIL: Metin2World extension is not registered") + quit(1) + return + var world := _make_world("metin2_patch_dragon_rock/metin2_map_capedragonhead") + _ck(bool(world.call("load_map")), "CapeDragonHead map loads") + var report: Dictionary = world.call("get_load_report") + _ck(bool(report.get("env_ok", false)), "texture-mode .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") + if env_node != null: + _ck(int(env_node.get_meta("msenv_sky_face_count", 0)) == 5, + "five non-empty SkyBox faces resolve") + _ck(bool(env_node.get_meta("msenv_sky_texture_mode_applied", false)), + "texture render mode uses six-face shader") + var sky := env_node.environment.sky + _ck(sky != null, "WorldEnv has sky") + if sky != null: + var mat := sky.get_material() as ShaderMaterial + _ck(mat != null, "texture-mode sky uses ShaderMaterial") + if mat != null: + _ck(mat.shader != null and mat.shader.code.find("shader_type sky;") >= 0, + "six-face sky shader is installed") + _ck(bool(mat.get_shader_parameter("has_front")), "front face is present") + _ck(bool(mat.get_shader_parameter("has_back")), "back face is present") + _ck(bool(mat.get_shader_parameter("has_left")), "left face is present") + _ck(bool(mat.get_shader_parameter("has_right")), "right face is present") + _ck(bool(mat.get_shader_parameter("has_top")), "top face is present") + _ck(not bool(mat.get_shader_parameter("has_bottom")), + "empty bottom face falls back to gradient") + world.queue_free() + await process_frame + + var cloud_world := _make_world("metin2_patch_dragon_rock/metin2_map_dawnmistwood") + _ck(bool(cloud_world.call("load_map")), "DawnMistWood map loads") + var cloud_report: Dictionary = cloud_world.call("get_load_report") + _ck(bool(cloud_report.get("env_ok", false)), "texture-mode + cloud .msenv env_ok") + var cloud_env := cloud_world.get_node_or_null("WorldEnv") as WorldEnvironment + _ck(cloud_env != null and cloud_env.environment != null, "cloud WorldEnv has Environment") + if cloud_env != null: + _ck(int(cloud_env.get_meta("msenv_sky_face_count", 0)) == 5, + "cloud map five non-empty SkyBox faces resolve") + _ck(bool(cloud_env.get_meta("msenv_sky_texture_mode_applied", false)), + "texture render mode remains active when CloudTexture exists") + _ck(bool(cloud_env.get_meta("msenv_sky_cloud_applied", false)), + "cloud texture is attached to texture-mode sky shader") + var cloud_sky := cloud_env.environment.sky + var cloud_mat := cloud_sky.get_material() as ShaderMaterial if cloud_sky != null else null + _ck(cloud_mat != null, "cloud map uses ShaderMaterial") + if cloud_mat != null: + _ck(bool(cloud_mat.get_shader_parameter("has_cloud")), + "sky shader enables cloud plane") + _ck(cloud_mat.shader != null and cloud_mat.shader.code.find("TIME * cloud_speed") >= 0, + "cloud shader preserves reference UV scroll") + if cloud_mat != null: + var scale := cloud_mat.get_shader_parameter("cloud_scale") as Vector2 + _ck(scale != null and is_equal_approx(scale.x, 2000.0) and is_equal_approx(scale.y, 2000.0), + "cloud scale converts reference centimetres to Godot metres") + var speed := cloud_mat.get_shader_parameter("cloud_speed") as Vector2 + _ck(speed != null and is_equal_approx(speed.x, 0.001) and is_equal_approx(speed.y, 0.001), + "cloud speed preserves reference seconds parameter") + cloud_world.queue_free() + await process_frame + if _fail == 0: + print("PASS: skybox_test (.msenv six-face texture sky)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) diff --git a/project/text_tail.gd b/project/text_tail.gd new file mode 100644 index 00000000..a2f8a363 --- /dev/null +++ b/project/text_tail.gd @@ -0,0 +1,127 @@ +# text_tail.gd —— 头顶「称号 / 等级 / 公会名」子标签的 1:1 迁移(§8.8) +# +# 参考(REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/): +# UserInterface/InstanceBaseEffect.cpp:609 GetTitleColor —— uGrade>=TITLE_NUM 时白,否则 g_akD3DXClrTitle[uGrade] +# UserInterface/InstanceBaseEffect.cpp:621 AttachTextTail —— fTextTailHeight = IsMountingHorse()?110:10;末尾 if(m_dwLevel) UpdateTextTailLevel +# UserInterface/InstanceBaseEffect.cpp:654 UpdateTextTailLevel —— s_kLevelColor = D3DXCOLOR(152/255,255/255,51/255,1);"Lv %d" +# UserInterface/InstanceBaseEffect.cpp:664 RefreshTextTail —— SetCharacterTextTailColor(GetNameColor()) + 按 AlignmentGrade attach/detach 称号 +# UserInterface/InstanceBaseEffect.cpp:1107 RegisterTitleName —— g_TitleNameMap[i] = c_szTitleName +# UserInterface/InstanceBaseEffect.cpp:1131 RegisterTitleColor —— g_akD3DXClrTitle[u] = __RGBToD3DXColoru(r,g,b) +# UserInterface/InstanceBase.h:262 TITLE_NUM = 9 / TITLE_NONE = 4 +# UserInterface/InstanceBase.cpp:2053 SetAlignment -> RefreshTextTailTitle -> RefreshTextTail +# UserInterface/PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF +# UserInterface/PythonTextTail.cpp:23 bPKTitleEnable = TRUE(EnablePKTitle) +# UserInterface/PythonTextTail.cpp:509 RegisterCharacterTextTail —— dwGuildID!=0 才建公会名标签;GetGuildName 失败 -> "Noname" +# UserInterface/PythonTextTail.cpp:890 AttachTitle / :943 AttachLevel —— 均 if(!bPKTitleEnable) return +# assets/root/introloading.py:287 TITLE_COLOR_DICT(注册序 0..8) +# assets/root/colorInfo.py:29 TITLE_RGB_GOOD_4 .. TITLE_RGB_EVIL_4 +# assets/root/localeInfo.py:215 TITLE_NAME_LIST = (PVP_LEVEL0..PVP_LEVEL8,) +# assets/root/constinfo.py:4 PVPMODE_ENABLE = 1;game.py:155 textTail.EnablePKTitle(constInfo.PVPMODE_ENABLE) +# +# 纯静态。调用方(net_world.gd)把网络快照 self_e({alignment,level,guild,mount_vnum,...}) +# 传进来,再自己把 title_name_key / 公会名解析成本地化字符串并落到 Label3D 上。 +extends RefCounted + +const EntityRules = preload("res://entity_rules.gd") + +# InstanceBase.h 匿名枚举 +const TITLE_NUM := 9 +const TITLE_NONE := 4 + +const _WHITE := Color(1, 1, 1) + +# UpdateTextTailLevel 的 s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1) +const LEVEL_COLOR := Color(152.0 / 255.0, 255.0 / 255.0, 51.0 / 255.0) +# PythonTextTail.cpp:18 c_TextTail_Guild_Name_Color = 0xFFEFD3FF(ARGB -> RGB 239,211,255) +const GUILD_NAME_COLOR := Color(239.0 / 255.0, 211.0 / 255.0, 255.0 / 255.0) +# CPythonGuild::GetGuildName 失败时的兜底名(PythonTextTail.cpp:551) +const GUILD_NAME_FALLBACK := "Noname" + +# g_akD3DXClrTitle:introloading.py TITLE_COLOR_DICT 经 RegisterTitleColor -> __RGBToD3DXColoru +# 写进的 9 个槽(idx0 GOOD_4 .. idx8 EVIL_4)。Color8(r,g,b) == D3DXCOLOR(r/255,g/255,b/255)。 +static func _title_colors() -> Array: + return [ + Color8(0, 204, 255), # 0 GOOD_4 + Color8(0, 144, 255), # 1 GOOD_3 + Color8(92, 110, 255), # 2 GOOD_2 + Color8(155, 155, 255), # 3 GOOD_1 + Color8(255, 255, 255), # 4 NORMAL(= TITLE_NONE,实际不会 attach) + Color8(207, 117, 0), # 5 EVIL_1 + Color8(235, 83, 0), # 6 EVIL_2 + Color8(227, 0, 0), # 7 EVIL_3 + Color8(255, 0, 0), # 8 EVIL_4 + ] + +# --- EnablePKTitle / bPKTitleEnable(PythonTextTail.cpp:23 / :938)-------------- +# game.py:155 起始 EnablePKTitle(constInfo.PVPMODE_ENABLE),constinfo.py:4 PVPMODE_ENABLE = 1。 +static var _pk_title_enabled := true + +static func set_pk_title_enabled(enabled: bool) -> void: + _pk_title_enabled = enabled + +static func is_pk_title_enabled() -> bool: + return _pk_title_enabled + +# --- CInstanceBase::GetTitleColor(InstanceBaseEffect.cpp:609)—— 逐行 ----------- +# 入参是 GetAlignmentGrade() 的结果(0..8),越界(>=TITLE_NUM)返回白。 +static func title_color(grade: int) -> Color: + if grade < 0 or grade >= TITLE_NUM: + return _WHITE + return _title_colors()[grade] + +# introloading.py __RegisterTitleName:chrmgr.RegisterTitleName(i, TITLE_NAME_LIST[i]), +# TITLE_NAME_LIST[i] == localeInfo.PVP_LEVEL。调用方用它查 Locale 表。 +static func title_name_key(grade: int) -> String: + return "PVP_LEVEL%d" % grade + +# UpdateTextTailLevel:sprintf(szText, "Lv %d", level) +static func level_text(level: int) -> String: + return "Lv %d" % level + +# AttachTextTail:fTextTailHeight = IsMountingHorse() ? 110.0f : 10.0f +static func text_tail_height(mounted: bool) -> float: + return 110.0 if mounted else 10.0 + +# --- CInstanceBase::RefreshTextTail 的称号分支(InstanceBaseEffect.cpp:664)------- +# 返回 { show_title, grade, name_key, color }。 +# iAlignmentGrade = GetAlignmentGrade(); +# if (TITLE_NONE == iAlignmentGrade) DetachTitle(); +# else if (g_TitleNameMap.has(grade)) AttachTitle(name, GetTitleColor()); +# AttachTitle/DetachTitle 均 if(!bPKTitleEnable) return —— 关闭时参考端「冻结」上一态; +# 本移植取「隐藏」(show_title=false),差异见 §8.8 seam ⑦。 +static func refresh_title(alignment: int) -> Dictionary: + var grade := EntityRules.alignment_grade(alignment) + var out := { + "show_title": false, + "grade": grade, + "name_key": title_name_key(grade), + "color": title_color(grade), + } + if not _pk_title_enabled: + return out + if grade == TITLE_NONE: + return out + # g_TitleNameMap 注册了 0..8 全部(introloading.__RegisterTitleName),故只排除 TITLE_NONE。 + if grade < 0 or grade >= TITLE_NUM: + return out + out["show_title"] = true + return out + +# --- AttachTextTail 的等级分支(InstanceBaseEffect.cpp:621 末 + UpdateTextTailLevel) +# if (m_dwLevel) UpdateTextTailLevel(m_dwLevel);AttachLevel 亦 if(!bPKTitleEnable) return。 +static func refresh_level(level: int) -> Dictionary: + var show := _pk_title_enabled and level > 0 + return { + "show_level": show, + "text": level_text(level) if show else "", + "color": LEVEL_COLOR, + } + +# --- RegisterCharacterTextTail 的公会名分支(PythonTextTail.cpp:543)-------------- +# dwGuildID != 0 才建公会名标签;CPythonGuild::GetGuildName(dwGuildID) 失败 -> "Noname"。 +# resolved_name 由调用方经 client.get_guild_name(guild_id) 解出(空串 = 解析失败)。 +static func refresh_guild(guild_id: int, resolved_name: String) -> Dictionary: + if guild_id == 0: + return {"show_guild": false, "name": "", "color": GUILD_NAME_COLOR} + var nm := resolved_name if resolved_name != "" else GUILD_NAME_FALLBACK + return {"show_guild": true, "name": nm, "color": GUILD_NAME_COLOR} diff --git a/project/text_tail_test.gd b/project/text_tail_test.gd new file mode 100644 index 00000000..ce91a9ea --- /dev/null +++ b/project/text_tail_test.gd @@ -0,0 +1,110 @@ +# text_tail_test —— §8.8 头顶「称号 / 等级 / 公会名」子标签 1:1: +# GetTitleColor(g_akD3DXClrTitle)、RefreshTextTail 的 TITLE_NONE / 越界分支、 +# UpdateTextTailLevel 的 "Lv %d" + s_kLevelColor、RegisterCharacterTextTail 的 +# dwGuildID!=0 / "Noname" 兜底、AttachTextTail 的 110/10 高度、EnablePKTitle 开关。 +# godot --headless --path project --script text_tail_test.gd +# 退出码 0 = 全过。 +extends SceneTree + +const TextTail = preload("res://text_tail.gd") +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 _init() -> void: + _run() + if _fail == 0: + print("PASS: text_tail_test (§8.8 GetTitleColor / RefreshTextTail / UpdateTextTailLevel 1:1)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _run() -> void: + # --- 0. 常量(InstanceBase.h / PythonTextTail.cpp)------------------------ + _ck(TextTail.TITLE_NUM == 9, "TITLE_NUM == 9") + _ck(TextTail.TITLE_NONE == 4, "TITLE_NONE == 4") + _ck(TextTail.GUILD_NAME_FALLBACK == "Noname", "guild fallback == \"Noname\"") + # s_kLevelColor = D3DXCOLOR(152/255, 255/255, 51/255, 1) + _ck(TextTail.LEVEL_COLOR.is_equal_approx(Color8(152, 255, 51)), "LEVEL_COLOR == (152,255,51)") + # c_TextTail_Guild_Name_Color = 0xFFEFD3FF -> RGB (239,211,255) + _ck(TextTail.GUILD_NAME_COLOR.is_equal_approx(Color8(239, 211, 255)), "GUILD_NAME_COLOR == 0xEFD3FF") + + # --- 1. GetTitleColor:g_akD3DXClrTitle[grade](introloading TITLE_COLOR_DICT) + _ck(TextTail.title_color(0).is_equal_approx(Color8(0, 204, 255)), "title_color(0) == GOOD_4 (0,204,255)") + _ck(TextTail.title_color(1).is_equal_approx(Color8(0, 144, 255)), "title_color(1) == GOOD_3 (0,144,255)") + _ck(TextTail.title_color(2).is_equal_approx(Color8(92, 110, 255)), "title_color(2) == GOOD_2 (92,110,255)") + _ck(TextTail.title_color(3).is_equal_approx(Color8(155, 155, 255)), "title_color(3) == GOOD_1 (155,155,255)") + _ck(TextTail.title_color(4).is_equal_approx(Color8(255, 255, 255)), "title_color(4) == NORMAL (255,255,255)") + _ck(TextTail.title_color(5).is_equal_approx(Color8(207, 117, 0)), "title_color(5) == EVIL_1 (207,117,0)") + _ck(TextTail.title_color(6).is_equal_approx(Color8(235, 83, 0)), "title_color(6) == EVIL_2 (235,83,0)") + _ck(TextTail.title_color(7).is_equal_approx(Color8(227, 0, 0)), "title_color(7) == EVIL_3 (227,0,0)") + _ck(TextTail.title_color(8).is_equal_approx(Color8(255, 0, 0)), "title_color(8) == EVIL_4 (255,0,0)") + # uGrade >= TITLE_NUM -> 白(GetTitleColor 的 s_kD3DXClrTitleDefault) + _ck(TextTail.title_color(9).is_equal_approx(Color(1, 1, 1)), "title_color(9) -> white") + _ck(TextTail.title_color(-1).is_equal_approx(Color(1, 1, 1)), "title_color(-1) -> white") + + # --- 2. title_name_key / level_text / text_tail_height 字面 --------------- + _ck(TextTail.title_name_key(0) == "PVP_LEVEL0", "title_name_key(0) == PVP_LEVEL0") + _ck(TextTail.title_name_key(8) == "PVP_LEVEL8", "title_name_key(8) == PVP_LEVEL8") + _ck(TextTail.level_text(1) == "Lv 1", "level_text(1) == \"Lv 1\"") + _ck(TextTail.level_text(99) == "Lv 99", "level_text(99) == \"Lv 99\"") + _ck(TextTail.text_tail_height(true) == 110.0, "text_tail_height(mounted) == 110") + _ck(TextTail.text_tail_height(false) == 10.0, "text_tail_height(on foot) == 10") + + # --- 3. RefreshTextTail 称号分支(按 GetAlignmentGrade)------------------ + TextTail.set_pk_title_enabled(true) + # alignment 0 -> grade 4 == TITLE_NONE -> 不显示(DetachTitle) + var t_none := TextTail.refresh_title(0) + _ck(int(t_none["grade"]) == 4, "align 0 -> grade 4") + _ck(not t_none["show_title"], "grade TITLE_NONE -> show_title false") + # alignment 12000 -> grade 0 -> 显示 GOOD_4 + var t_good := TextTail.refresh_title(12000) + _ck(int(t_good["grade"]) == 0 and t_good["show_title"], "align 12000 -> grade 0, show_title true") + _ck(t_good["name_key"] == "PVP_LEVEL0", "grade 0 name_key == PVP_LEVEL0") + _ck(t_good["color"].is_equal_approx(Color8(0, 204, 255)), "grade 0 title colour GOOD_4") + # alignment -12001 -> grade 8 -> EVIL_4 + var t_evil := TextTail.refresh_title(-12001) + _ck(int(t_evil["grade"]) == 8 and t_evil["show_title"], "align -12001 -> grade 8, show_title true") + _ck(t_evil["color"].is_equal_approx(Color8(255, 0, 0)), "grade 8 title colour EVIL_4") + # grade 3(align 1000)显示,grade 5(align -1)显示 + _ck(TextTail.refresh_title(1000)["show_title"], "grade 3 shows title") + _ck(TextTail.refresh_title(-1)["show_title"], "grade 5 shows title") + + # --- 4. UpdateTextTailLevel / AttachLevel ------------------------------ + var lv := TextTail.refresh_level(42) + _ck(lv["show_level"] and lv["text"] == "Lv 42", "level 42 -> \"Lv 42\"") + _ck(lv["color"].is_equal_approx(Color8(152, 255, 51)), "level colour == s_kLevelColor") + # m_dwLevel 0 -> AttachTextTail 不调 UpdateTextTailLevel + _ck(not TextTail.refresh_level(0)["show_level"], "level 0 -> no level tag") + + # --- 5. RegisterCharacterTextTail 公会名分支 -------------------------- + # dwGuildID == 0 -> 不建标签 + _ck(not TextTail.refresh_guild(0, "Whatever")["show_guild"], "guild id 0 -> no guild tag") + # guild != 0 且解出名字 + var g_ok := TextTail.refresh_guild(7, "RedKnights") + _ck(g_ok["show_guild"] and g_ok["name"] == "RedKnights", "guild 7 -> resolved name") + _ck(g_ok["color"].is_equal_approx(Color8(239, 211, 255)), "guild name colour") + # guild != 0 但 GetGuildName 失败(空串)-> "Noname" + var g_no := TextTail.refresh_guild(7, "") + _ck(g_no["show_guild"] and g_no["name"] == "Noname", "guild name lookup fail -> \"Noname\"") + + # --- 6. EnablePKTitle(FALSE):AttachTitle / AttachLevel 早退 ------------ + TextTail.set_pk_title_enabled(false) + _ck(not TextTail.is_pk_title_enabled(), "pk title disabled") + _ck(not TextTail.refresh_title(12000)["show_title"], "pk-title off -> title suppressed") + _ck(not TextTail.refresh_level(42)["show_level"], "pk-title off -> level suppressed") + # 公会名不受 bPKTitleEnable 影响(RegisterCharacterTextTail 里没这判) + _ck(TextTail.refresh_guild(7, "RedKnights")["show_guild"], "pk-title off -> guild name still shown") + # 还原静态状态,避免污染同进程后续测试 + TextTail.set_pk_title_enabled(true) + _ck(TextTail.refresh_title(12000)["show_title"], "restore: pk title on again") + + # --- 7. grade 与 entity_rules.alignment_grade 一致 -------------------- + for a in [15000, 9000, 5000, 2000, 500, -500, -5000, -9000, -15000]: + _ck(int(TextTail.refresh_title(a)["grade"]) == EntityRules.alignment_grade(a), + "grade matches alignment_grade(%d)" % a) diff --git a/project/ui/atlas_ui.gd b/project/ui/atlas_ui.gd index 9766d106..a7a469eb 100644 --- a/project/ui/atlas_ui.gd +++ b/project/ui/atlas_ui.gd @@ -34,6 +34,7 @@ var _drag_origin := Vector2.ZERO var _map_origin := Vector2.ZERO var _map_px := Vector2.ZERO var _world_size_m := Vector2(1, 1) +var _center_position_adjust := Vector2.ZERO func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable, name := "", m2client: Node = null) -> void: @@ -64,6 +65,16 @@ func close() -> void: ui.close(_win) _win = null +# CPythonMiniMap::SetAtlasCenterPosition ultimately calls the atlas window's +# SetCenterPositionAdjust. Keep the same screen-space adjustment instead of +# treating this event as a world-coordinate teleport. +func set_center_position_adjust(x: int, y: int) -> void: + _center_position_adjust = Vector2(x, y) + if _map_view and _map: + _map_origin = (_map_view.size - _map.size) * 0.5 + _center_position_adjust + _map.position = _map_origin + _overlay.position = _map.position + func reload_map(metin_world: Node, name := "") -> void: world = metin_world if name != "": @@ -149,7 +160,7 @@ func _build_map() -> void: _map.stretch_mode = TextureRect.STRETCH_SCALE _map.size = _map_px _map.mouse_filter = Control.MOUSE_FILTER_IGNORE - _map_origin = (_map_view.size - _map_px) * 0.5 + _map_origin = (_map_view.size - _map_px) * 0.5 + _center_position_adjust _map.position = _map_origin _map_view.add_child(_map) _overlay.position = _map.position diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd index 5db0c758..8b6cb7af 100644 --- a/project/ui/char_select_screen.gd +++ b/project/ui/char_select_screen.gd @@ -31,6 +31,12 @@ const JOB_BASE_STATS := { 2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura 3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman } +# §1.10 名称长度校验统一:与 wire 契约 CHARACTER_NAME_MAX_LEN(=24, name[25]) +# 对齐。角色名可含多字节字符,wire 缓冲是定长 25 字节,故按 UTF-8 字节长度设限, +# 与 guild_ui.gd 的公会名校验(非空 / 超长 / 控制字符)保持同一形状。 +const CHAR_NAME_MAX_BYTES := 24 +const CHAR_NAME_MIN_CHARS := 2 + const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"} const EMPIRE_FALLBACK := {0: "—", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"} const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"} @@ -93,6 +99,17 @@ func _first_occupied() -> int: return i return 0 +# §1.10:角色名校验("" = 合法)。创建与改名共用同一实现。 +func _validate_char_name(nm: String) -> String: + if nm.length() < CHAR_NAME_MIN_CHARS: + return "名称太短" + if nm.to_utf8_buffer().size() > CHAR_NAME_MAX_BYTES: + return "名称太长(最多 %d 字节)" % CHAR_NAME_MAX_BYTES + for ch in nm: + if ch.unicode_at(0) < 32: + return "名称包含非法字符" + return "" + # 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。 func set_chars(chars: Array) -> void: var keep := _slot_index() @@ -560,9 +577,9 @@ func _open_create_dialog() -> void: nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85))) var name_edit := LineEdit.new() name_edit.name = "Name" - name_edit.max_length = 12 + name_edit.max_length = CHAR_NAME_MAX_BYTES name_edit.custom_minimum_size = Vector2(240, 0) - name_edit.placeholder_text = "角色名(最多 12)" + name_edit.placeholder_text = "角色名(最多 24 字节)" nrow.add_child(name_edit) # 四维(起始属性,只读,随职业变) @@ -589,8 +606,9 @@ func _open_create_dialog() -> void: var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34) ok.pressed.connect(func(): var nm := name_edit.text.strip_edges() - if nm.length() < 2: - _set_status("名称太短") + var err := _validate_char_name(nm) + if err != "": + _set_status(err) return var j := jopt.get_selected_id() var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0]) @@ -620,9 +638,9 @@ func _open_change_name_dialog() -> void: nrow.add_child(_plain("新名称", 13, Color(0.72, 0.78, 0.85))) var name_edit := LineEdit.new() name_edit.name = "Name" - name_edit.max_length = 12 + name_edit.max_length = CHAR_NAME_MAX_BYTES name_edit.custom_minimum_size = Vector2(220, 0) - name_edit.placeholder_text = "角色名(最多 12)" + name_edit.placeholder_text = "角色名(最多 24 字节)" nrow.add_child(name_edit) box.add_child(_sep()) var brow := HBoxContainer.new() @@ -634,8 +652,9 @@ func _open_change_name_dialog() -> void: ok.custom_minimum_size = Vector2(120, 34) ok.pressed.connect(func(): var nm := name_edit.text.strip_edges() - if nm.length() < 2: - _set_status("名称太短") + var err := _validate_char_name(nm) + if err != "": + _set_status(err) return _rename_pending = true _close_dialog() diff --git a/project/ui/chat_ui.gd b/project/ui/chat_ui.gd index 454eec3e..567a3bfe 100644 --- a/project/ui/chat_ui.gd +++ b/project/ui/chat_ui.gd @@ -9,6 +9,8 @@ # 消费 M2Client:chat / whisper / item_picked_up。按类型分到 全部 / 私聊 / 系统 / 战斗 标签。 extends Node +const Locale = preload("res://locale.gd") + # EChatType (wire.h) const T_TALKING := 0 const T_INFO := 1 @@ -42,9 +44,15 @@ var _tab := 0 var _tab_btns := [] var _max_lines := 200 var _buffers := {} # tab -> Array[String] +var _locale: RefCounted +var _proto: Node +var _fishing_active := false -func setup(m2client: Node, parent: Node) -> void: +func setup(m2client: Node, parent: Node, assets_root := "") -> void: client = m2client + if assets_root != "": + _locale = Locale.new() + _locale.setup(assets_root, "en") _build(parent) if client: if client.has_signal("chat"): @@ -56,6 +64,20 @@ func setup(m2client: Node, parent: Node) -> void: if client.has_signal("fishing_event"): client.fishing_event.connect(_on_fishing_event) +func set_proto(item_proto: Node) -> void: + _proto = item_proto + +func _loc(key: String, fallback: String, args: Array = []) -> String: + if _locale and _locale.has(key): + return _locale.t(key, args) + return fallback % args if not args.is_empty() else fallback + +func on_fishing_feedback(code: String) -> void: + if code == "FISHING_WRONG_PLACE": + var s := _loc("FISHING_WRONG_PLACE", "You cannot go fishing here.") + _append(2, T_INFO, s) + _append(3, T_INFO, s) + func focus_input() -> void: if _input: _input.grab_focus() @@ -70,6 +92,15 @@ func start_whisper(name: String) -> void: func is_typing() -> bool: return _input != null and _input.has_focus() +# game.py ToggleChatLogWindow hides/shows the complete chat log while keeping +# the accumulated channel buffers. Enter can still focus the input afterward. +func toggle_log() -> void: + if _root: + _root.visible = not _root.visible + +func is_log_visible() -> bool: + return _root != null and _root.visible + # --- 建 UI -------------------------------------------------------------- func _build(parent: Node) -> void: @@ -187,16 +218,51 @@ func _on_pickup(vnum: int, count: int, from: String) -> void: _append(3, T_INFO, s) func _on_fishing_event(subheader: int, info: int, _dir: int) -> void: - var s := "" - if subheader == FISHING_SUCCESS: - s = "鱼钩命中" - elif subheader == FISHING_FAIL: - s = "钓鱼失败" - elif subheader == FISHING_FISH: - s = "捕获物品 #%d" % info if info != 0 else "没有捕获到物品" - if s != "": - _append(2, T_INFO, s) - _append(3, T_INFO, s) + # RecvFishing only calls a UI callback for FAIL and FISH. SUCCESS is an + # animation transition; the later FISH event decides notify vs success + # from the actor's IsFishing() state. + if subheader == 0: # FISHING_START + _fishing_active = true + return + if subheader == 2: # FISHING_REACT + _fishing_active = true + return + if subheader == FISHING_FAIL: + _fishing_active = false + var fail := _loc("FISHING_FAILURE", "You lost the Bait.") + _append(2, T_INFO, fail) + _append(3, T_INFO, fail) + return + if subheader != FISHING_FISH: + if subheader == FISHING_SUCCESS: + _fishing_active = false + elif subheader == 1: # FISHING_STOP + _fishing_active = false + return + if info == 0: + var unknown := _loc("FISHING_UNKNOWN", "Something has taken the bait but you can't see what it is.") + _append(2, T_INFO, unknown) + _append(3, T_INFO, unknown) + return + var item: Dictionary = _proto.item(info) if _proto and _proto.has_method("item") else {} + # ClientVS22 silently ignores a fish event when item_proto has no record. + if item.is_empty(): + return + var name := String(item.get("locale_name", item.get("name", ""))) + if name == "": + name = "#%d" % info + var is_fish := int(item.get("type", -1)) == 12 # CItemData::ITEM_TYPE_FISH + var key := "" + var fallback := "" + if _fishing_active: + key = "FISHING_NOTIFY1" if is_fish else "FISHING_NOTIFY2" + fallback = "It looks like %s is hooked." if is_fish else "It looks like %s is on the hook." + else: + key = "FISHING_SUCCESS1" if is_fish else "FISHING_SUCCESS2" + fallback = "You captured %s!" if is_fish else "You have pulled %s out of the water!" + var s := _loc(key, fallback, [name]) + _append(2, T_INFO, s) + _append(3, T_INFO, s) # --- 追加行 -------------------------------------------------------------- diff --git a/project/ui/cursor_manager.gd b/project/ui/cursor_manager.gd new file mode 100644 index 00000000..282b6200 --- /dev/null +++ b/project/ui/cursor_manager.gd @@ -0,0 +1,45 @@ +# CursorManager —— 对齐 ClientVS22 mousemodule.py 的光标状态机。 +# +# 光标和“鼠标跟随物品”是两层:本节点只管理当前交互语义, +# MouseController 负责跟随鼠标的物品图标。没有对应 .sub 资源时使用 Godot +# 系统光标,避免把资源缺失误报成输入状态缺失。 +extends Node + +signal changed(shape: String) + +const NORMAL := "NORMAL" +const ATTACK := "ATTACK" +const TALK := "TALK" +const CANT_GO := "CANT_GO" +const PICK := "PICK" +const BUY := "BUY" +const SELL := "SELL" +const ITEM := "ITEM" + +var _shape := NORMAL + +func set_cursor(shape: String) -> void: + if shape not in [NORMAL, ATTACK, TALK, CANT_GO, PICK, BUY, SELL, ITEM]: + shape = NORMAL + _shape = shape + Input.set_default_cursor_shape(_godot_shape(shape)) + changed.emit(shape) + +func get_cursor() -> String: + return _shape + +func reset() -> void: + set_cursor(NORMAL) + +func _godot_shape(shape: String) -> Input.CursorShape: + match shape: + ATTACK: + return Input.CURSOR_CROSS + TALK, BUY, SELL, PICK: + return Input.CURSOR_POINTING_HAND + CANT_GO: + return Input.CURSOR_FORBIDDEN + ITEM: + return Input.CURSOR_DRAG + _: + return Input.CURSOR_ARROW diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd index a0113df6..273cdb39 100644 --- a/project/ui/equip_model.gd +++ b/project/ui/equip_model.gd @@ -14,9 +14,15 @@ # 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进 # 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退 # get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。 +# +# §4.1 修改 4(部位遮挡):解出身体 shape 后过 PartHiding —— 动物 / 怪物时装 +# (shape 100-103)抑制武器 + 头发;婚纱(shape 201)非捧花时抑制武器;变身 +# (seam,暂无实体字段)会把身体 shape 归 0 且清武器 / 头发。刷新顺序对齐 +# InstanceBase.cpp ChangeArmor:身体 → 头发 → 武器(→ 盾)。 extends Node const RaceSpec = preload("res://ui/race_spec.gd") +const PartHiding = preload("res://part_hiding.gd") const WEAR_BODY := 0 const WEAR_HEAD := 1 @@ -40,10 +46,12 @@ var _spec: RefCounted # RaceSpec(惰性) var _spec_tried := false var _last_weapon_vnum := -1 var _last_body_vnum := -1 -var _last_head_vnum := -1 +var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵 var _last_shield_vnum := -1 var _last_hair_part := -1 var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用) +var _remote_mode := false +var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR] # armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3] # (对齐客户端 __ArmorVnumToShape,SHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。 var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum) @@ -70,6 +78,24 @@ func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "", if main_getter.is_valid() and int(main_getter.call()) == v: refresh()) +# 远端 PC 不读取本地 get_equipment();参考端直接消费 +# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。 +func setup_remote(il: RefCounted, model_getter: Callable, assets := "", + race_index := -1, parts := [], proto_node: Node = null) -> void: + client = null + item_list = il + _model_getter = model_getter + assets_root = assets + race = race_index + proto = proto_node + _remote_mode = true + _remote_parts = parts.duplicate() if parts is Array else [] + while _remote_parts.size() < 4: + _remote_parts.append(0) + if _remote_parts.size() > 4: + _remote_parts = _remote_parts.slice(0, 4) + refresh() + func set_race(r: int) -> void: race = r _spec = null @@ -81,36 +107,55 @@ func _on_inv_changed(window: int, _cell: int) -> void: func refresh() -> void: var model: Node = _model_getter.call() if _model_getter.is_valid() else null - if model == null or client == null: + if model == null or (client == null and not _remote_mode): return - var eq: Array = client.get_equipment() - if eq.size() <= WEAR_WEAPON: + var eq: Array = client.get_equipment() if client != null and client.has_method("get_equipment") else [] + if not _remote_mode and eq.size() <= WEAR_WEAPON: return var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq - # 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON - var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON) - if wpn_vnum != _last_weapon_vnum: - _last_weapon_vnum = wpn_vnum - model.set("weapon_gr2", _resolve_weapon(wpn_vnum)) - - if eq.size() > WEAR_SHIELD: - var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0)) - if sh_vnum != _last_shield_vnum: - _last_shield_vnum = sh_vnum - model.set("shield_gr2", _resolve_weapon(sh_vnum)) - - # 身体:parts[ARMOR] 优先(= 普通盔甲或时装),为 0 回退 WEAR_BODY + # --- 身体:parts[ARMOR] 优先(普通盔甲或时装),为 0 回退 WEAR_BODY ------------- var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY) + # 遮挡判定用的 shape(对齐 __ArmorVnumToShape:proto values[3] 非 0,否则 =vnum) + var raw_shape := int(armor_shape_of.call(body_vnum)) if armor_shape_of.is_valid() else body_vnum + var is_poly := _is_poly() if body_vnum != _last_body_vnum: _last_body_vnum = body_vnum _apply_body(model, body_vnum) - # 头:parts[HEAD] 优先,为 0 回退 WEAR_HEAD;有模型的头盔覆盖发型槽,否则用发型 + # --- 头 / 发:动物·怪物时装(shape 100-103)或变身 -> 隐藏;否则头盔覆盖 / 发型 ---- + var hair_off := PartHiding.hair_hidden(raw_shape, is_poly) var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD) - if head_vnum != _last_head_vnum: - _last_head_vnum = head_vnum - _apply_head_or_hair(model, head_vnum) + var head_key := -1 if hair_off else head_vnum + if head_key != _last_head_vnum: + _last_head_vnum = head_key + if hair_off: + model.set("hair_gr2", "") + model.set("hair_skin", "") + _last_hair_part = -1 # 恢复时强制重挂发型 + else: + _apply_head_or_hair(model, head_vnum) + + # --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 --------- + var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON) + var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly) + if wpn_eff != _last_weapon_vnum: + _last_weapon_vnum = wpn_eff + model.set("weapon_gr2", _resolve_weapon(wpn_eff)) + + # --- 盾(左手挂点,同武器遮挡规则)-------------------------------------------- + if eq.size() > WEAR_SHIELD: + var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0)) + var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum + if sh_eff != _last_shield_vnum: + _last_shield_vnum = sh_eff + model.set("shield_gr2", _resolve_weapon(sh_eff)) + +# §4.1 修改 4 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race / +# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读 +# client.get_entity(main_getter.call()).get("poly_race", 0) != 0 即可。 +func _is_poly() -> bool: + return false # 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽 func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int: @@ -119,6 +164,8 @@ func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int: return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0 func _parts() -> Array: + if _remote_mode: + return _remote_parts if not main_getter.is_valid() or client == null or not client.has_method("get_entity"): return [] var vid: int = main_getter.call() @@ -164,6 +211,8 @@ func _apply_hair_from_parts(model: Node, force := false) -> void: model.set("hair_skin", ts) func _hair_part() -> int: + if _remote_mode: + return int(_remote_parts[PART_HAIR]) if _remote_parts.size() > PART_HAIR else -1 if not main_getter.is_valid() or client == null or not client.has_method("get_entity"): return -1 var vid: int = main_getter.call() @@ -176,6 +225,10 @@ func _hair_part() -> int: return -1 func set_hair(index: int) -> void: + if _remote_mode: + _set_remote_part(PART_HAIR, index) + refresh() + return var model: Node = _model_getter.call() if _model_getter.is_valid() else null if model == null: return @@ -189,6 +242,29 @@ func set_hair(index: int) -> void: if ts != "": model.set("hair_skin", ts) +func set_armor(index: int) -> void: + if not _remote_mode: + return + _set_remote_part(PART_ARMOR, index) + refresh() + +func set_weapon(index: int) -> void: + if not _remote_mode: + return + _set_remote_part(PART_WEAPON, index) + refresh() + +func set_head(index: int) -> void: + if not _remote_mode: + return + _set_remote_part(PART_HEAD, index) + refresh() + +func _set_remote_part(part: int, value: int) -> void: + while _remote_parts.size() <= part: + _remote_parts.append(0) + _remote_parts[part] = value + # --- body ------------------------------------------------------------------ # armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape) diff --git a/project/ui/exchange_ui.gd b/project/ui/exchange_ui.gd index 99893ec4..0977e8bc 100644 --- a/project/ui/exchange_ui.gd +++ b/project/ui/exchange_ui.gd @@ -9,8 +9,11 @@ # inventory_ui 在交易开着时右键道具 → 调 xu.offer(win, cell)。 extends Node +const ItemTooltip = preload("res://ui/item_tooltip.gd") + var client: Node var proto: Node +var _tooltip_builder: RefCounted var _root: Control var _self_box: VBoxContainer var _peer_box: VBoxContainer @@ -21,10 +24,24 @@ var _accept_btn: Button var _gold_input: LineEdit var _next_display := 0 var _offered_cells := {} +var _self_grid: GridContainer +var _exchange_cells: Dictionary = {} +var item_mouse: Node + +const EXCHANGE_SLOT_COUNT := 12 +const INVENTORY_WINDOW := 1 +const EXCHANGE_LIMIT_RANGE := 1000.0 +const ITEM_ANTIFLAG_GIVE := 1 << 13 + +var _accept_pending := false +var _anchor := Vector2.ZERO +var _anchor_valid := false func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: client = m2client proto = proto_node + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(AssetRoot.path(), "en", proto) _build(parent) if client.has_signal("exchange_changed"): client.exchange_changed.connect(refresh) @@ -33,10 +50,16 @@ func is_open() -> bool: return _root != null and _root.visible func close() -> void: + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() if _root: _root.visible = false _next_display = 0 _offered_cells.clear() + _accept_pending = false + _anchor_valid = false if _gold_input: _gold_input.text = "" @@ -47,6 +70,8 @@ func offer(inv_window: int, inv_cell: int) -> void: if _offered_cells.has(key): _status.text = "该物品已经放入交易" return + if not _can_give(_item_at(inv_window, inv_cell)): + return var x: Dictionary = client.get_exchange() var used := {} for item in x.get("self_items", []): @@ -73,6 +98,13 @@ func _name_of(vnum: int) -> String: return n return "#%d" % vnum +func _tooltip_for(item: Dictionary) -> String: + var vnum := int(item.get("vnum", 0)) + if _tooltip_builder: + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))] + func refresh() -> void: if client == null: return @@ -81,45 +113,183 @@ func refresh() -> void: _root.visible = false _next_display = 0 _offered_cells.clear() + _accept_pending = false + _anchor_valid = false _gold_input.text = "" _status.text = "" return _root.visible = true + _capture_anchor() + _render_self_grid(x.get("self_items", [])) _fill(_self_box, x.get("self_items", [])) _fill(_peer_box, x.get("peer_items", [])) _self_gold.text = "金币: %d" % int(x.get("self_gold", 0)) _peer_gold.text = "金币: %d" % int(x.get("peer_gold", 0)) - var me: bool = x.get("self_accept", false) - var peer: bool = x.get("peer_accept", false) + var me := bool(x.get("self_accept", false)) + var peer := bool(x.get("peer_accept", false)) + # The server clears self_accept when either side changes the offer. Do not + # invent a local transaction state: mirror the authoritative bit exactly. + _accept_pending = me _accept_btn.text = "已接受 ✓" if me else "接受" + _accept_btn.disabled = me _accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1) _root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受" _status.text = "" +func _item_at(inv_window: int, inv_cell: int) -> Dictionary: + if client == null: + return {} + if client.has_method("get_item"): + var direct: Dictionary = client.get_item(inv_window, inv_cell) + if not direct.is_empty(): + return direct + if inv_window == INVENTORY_WINDOW and client.has_method("get_inventory"): + for item in client.get_inventory(): + if int(item.get("cell", -1)) == inv_cell: + return item + if inv_window == 2 and client.has_method("get_equipment"): + var equipment: Array = client.get_equipment() + if inv_cell >= 0 and inv_cell < equipment.size(): + return equipment[inv_cell] + return {} + +func _can_give(item_data: Dictionary) -> bool: + var vnum := int(item_data.get("vnum", 0)) + var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + var flags := int(item_data.get("anti_flags", 0)) | int(proto_data.get("anti_flags", 0)) + if (flags & ITEM_ANTIFLAG_GIVE) != 0: + _status.text = "该物品不可交易" + return false + return true + +func _render_self_grid(rows: Array) -> void: + if _self_grid == null: + return + for pos in _exchange_cells: + var cell: Button = _exchange_cells[pos] + cell.text = "" + cell.tooltip_text = "" + cell.set_meta("vnum", 0) + cell.set_meta("count", 0) + for row in rows: + var pos := int(row.get("slot", -1)) + if not _exchange_cells.has(pos): + continue + var vnum := int(row.get("vnum", 0)) + var count := int(row.get("count", 1)) + var cell: Button = _exchange_cells[pos] + cell.text = "%s%s" % [_name_of(vnum).substr(0, 6), (" ×%d" % count) if count > 1 else ""] + cell.tooltip_text = _tooltip_for(row) + cell.set_meta("vnum", vnum) + cell.set_meta("count", count) + +func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool: + if payload.is_empty() or display_pos < 0 or display_pos >= EXCHANGE_SLOT_COUNT: + return false + if bool(payload.get("money", false)): + if int(client.get_exchange().get("self_gold", 0)) > 0: + _status.text = "金币已经放入,不能修改" + return false + var amount := int(payload.get("count", 0)) + if amount <= 0 or not client.has_method("exchange_add_gold"): + return false + return client.exchange_add_gold(amount) + var source_window := int(payload.get("window", -1)) + var source_cell := int(payload.get("cell", -1)) + if source_window not in [INVENTORY_WINDOW, 2] or source_cell < 0: + return false + var item_data := payload.duplicate(true) + var source_data := _item_at(source_window, source_cell) + for key in source_data: + if not item_data.has(key): + item_data[key] = source_data[key] + if not _can_give(item_data): + return false + for row in client.get_exchange().get("self_items", []): + if int(row.get("slot", -1)) == display_pos: + return false + var key := "%d:%d" % [source_window, source_cell] + if _offered_cells.has(key): + return false + if not client.has_method("exchange_add_item") or not client.exchange_add_item( + source_window, source_cell, display_pos): + _status.text = "交易请求发送失败" + return false + _offered_cells[key] = true + return true + func _fill(box: VBoxContainer, rows: Array) -> void: for c in box.get_children(): c.queue_free() for r in rows: var l := Label.new() l.text = "%s ×%d" % [_name_of(int(r.get("vnum", 0))), int(r.get("count", 1))] + l.tooltip_text = _tooltip_for(r) l.add_theme_font_size_override("font_size", 12) box.add_child(l) func _on_put_gold() -> void: + var current := int(client.get_exchange().get("self_gold", 0)) + if current > 0: + _status.text = "金币已经放入,不能修改" + return var raw := _gold_input.text.strip_edges() if raw == "" or not raw.is_valid_int(): _status.text = "请输入有效金币数量" return var g := int(raw) - if g <= 0 or g > 2000000000: - _status.text = "金币数量必须在 1~2000000000 之间" + if g <= 0 or g > 9999999: + _status.text = "金币数量必须在 1~9999999 之间" return - if client.exchange_add_gold(g): + if client.has_method("exchange_add_gold") and client.exchange_add_gold(g): _gold_input.text = "" _status.text = "" else: _status.text = "金币请求发送失败" +func _on_accept() -> void: + if _accept_pending or _accept_btn.disabled: + return + if client.has_method("exchange_accept") and client.exchange_accept(): + _accept_pending = true + _accept_btn.disabled = true + else: + _status.text = "接受请求发送失败" + +func _capture_anchor() -> void: + if _anchor_valid or client == null or not client.has_method("get_main_vid") \ + or not client.has_method("get_entity"): + return + var entity: Dictionary = client.get_entity(int(client.get_main_vid())) + var p = entity.get("pos_cm", null) + if p is Vector3: + _anchor = Vector2(p.x, p.y) + _anchor_valid = true + elif entity.has("x") and entity.has("y"): + _anchor = Vector2(float(entity.get("x", 0)), float(entity.get("y", 0))) + _anchor_valid = true + +func _current_position() -> Variant: + if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): + return null + var entity: Dictionary = client.get_entity(int(client.get_main_vid())) + var p = entity.get("pos_cm", null) + if p is Vector3: + return Vector2(p.x, p.y) + if entity.has("x") and entity.has("y"): + return Vector2(float(entity.get("x", 0)), float(entity.get("y", 0))) + return null + +func _process(_dt: float) -> void: + if not is_open() or not _anchor_valid: + return + var current = _current_position() + if current is Vector2 and (absf(current.x - _anchor.x) > EXCHANGE_LIMIT_RANGE \ + or absf(current.y - _anchor.y) > EXCHANGE_LIMIT_RANGE): + if client.has_method("exchange_cancel"): + client.exchange_cancel() + close() + func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_CENTER) @@ -148,6 +318,21 @@ func _build(parent: Node) -> void: _self_box.position = Vector2(20, 56) _self_box.custom_minimum_size = Vector2(200, 0) _root.add_child(_self_box) + _self_grid = GridContainer.new() + _self_grid.columns = 6 + _self_grid.position = Vector2(20, 138) + _self_grid.add_theme_constant_override("h_separation", 3) + _self_grid.add_theme_constant_override("v_separation", 3) + _root.add_child(_self_grid) + for pos in EXCHANGE_SLOT_COUNT: + var cell := Button.new() + cell.custom_minimum_size = Vector2(52, 32) + cell.add_theme_font_size_override("font_size", 8) + _self_grid.add_child(cell) + _exchange_cells[pos] = cell + if item_mouse and item_mouse.has_method("register_target"): + item_mouse.register_target(cell, + func(payload: Dictionary): return _drop_to_slot(payload, pos), self) _self_gold = Label.new() _self_gold.text = "金币: 0" _self_gold.position = Vector2(20, 250) @@ -191,7 +376,7 @@ func _build(parent: Node) -> void: bottom.add_child(put) _accept_btn = Button.new() _accept_btn.text = "接受" - _accept_btn.pressed.connect(func() -> void: client.exchange_accept()) + _accept_btn.pressed.connect(_on_accept) bottom.add_child(_accept_btn) var cancel := Button.new() cancel.text = "取消" diff --git a/project/ui/friend_ui.gd b/project/ui/friend_ui.gd index e78e7c86..d8412ff1 100644 --- a/project/ui/friend_ui.gd +++ b/project/ui/friend_ui.gd @@ -32,6 +32,8 @@ func is_open() -> bool: func close() -> void: if _root: _root.visible = false + if _invite_dialog: + _invite_dialog.hide() func toggle() -> void: _root.visible = not _root.visible @@ -94,6 +96,8 @@ func _on_friend_invite(name: String) -> void: func _answer_invite(accept: bool) -> void: var name := _pending_invite _pending_invite = "" + if _invite_dialog: + _invite_dialog.hide() if name != "" and client != null and client.has_method("friend_answer"): client.friend_answer(name, accept) diff --git a/project/ui/game_option_ui.gd b/project/ui/game_option_ui.gd index 82b08a02..f88425c2 100644 --- a/project/ui/game_option_ui.gd +++ b/project/ui/game_option_ui.gd @@ -60,6 +60,10 @@ const LABELS := { const CFG_PATH := "user://system_option.cfg" +# uigameoption RefreshAlwaysShowName / 名字色 / 伤害数字等:radio 选中后广播给渲染层。 +# key ∈ DISPLAY_RADIOS 的 cfg key(name_color / always_show_name / show_damage / ...)。 +signal display_option_changed(key: String, value: int) + var ui: CanvasLayer var client: Node var assets_root := "" @@ -96,6 +100,10 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void: func is_open() -> bool: return not _win.is_empty() and is_instance_valid(_win.get("root")) +# 渲染层开局回填用:cfg [gameopt] 里某显示开关的当前值(0/1),缺省同 setup() 的默认。 +func display_value(key: String) -> int: + return int(_display.get(key, 0)) + func toggle() -> void: if is_open(): close() else: open() @@ -148,7 +156,8 @@ func _wire() -> void: _bind_radio(group, func(nm): var spec: Array = DISPLAY_RADIOS[nm] _display[spec[0]] = int(spec[1]) - _save()) + _save() + display_option_changed.emit(String(spec[0]), int(spec[1]))) var tb := _node("titlebar") if tb: for x in tb.find_children("*", "BaseButton", true, false): diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd index 6496bfe3..f0f3c562 100644 --- a/project/ui/ground_items.gd +++ b/project/ui/ground_items.gd @@ -118,6 +118,24 @@ func try_pickup() -> int: client.pickup_item(best_vid) return best_vid +# Mouse hover probe used by PlayerController's cursor state. Ground drops are +# deliberately tested in screen space, so clicking a visible label / mesh +# selects PICK even when the item is not the nearest drop in world distance. +func hover_at(camera: Camera3D, screen_pos: Vector2) -> bool: + if camera == null: + return false + var best := INF + for node in _by_vid.values(): + if not is_instance_valid(node): + continue + if camera.is_position_behind(node.global_position): + continue + var p := camera.unproject_position(node.global_position + Vector3(0, 0.35, 0)) + var d := p.distance_to(screen_pos) + if d <= 28.0: + best = minf(best, d) + return best < INF + func _name_for(vnum: int) -> String: if proto: var pd: Dictionary = proto.item(vnum) diff --git a/project/ui/inventory_ui.gd b/project/ui/inventory_ui.gd index 7a4f6d68..a4c47a25 100644 --- a/project/ui/inventory_ui.gd +++ b/project/ui/inventory_ui.gd @@ -10,39 +10,63 @@ # 拖到别的格=move_item。inventory_changed 信号来时刷新。 # # 格子编号约定(uiscript):0..89 背包,90+ 装备(wear = index-90)。 -# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板;发包仍 -# 使用原客户端的 INVENTORY 全局 cell(90+wear / 152+belt cell)。 +# 新装备 / 腰带没有可靠的旧 uiscript 布局时,在背包右侧生成扩展面板。发包坐标见 +# `_to_wire()`:旧 11 格 wear(BODY..SHIELD)走 EQUIPMENT 窗(window=2, wear cell); +# costume/ring/belt(wear >= 19)与 belt 背包走 INVENTORY 窗(window=1)的全局 cell +# (109..113 / 152+belt cell,见 GameType.h `c_Costume_Slot_Start` / `c_Equipment_Belt`)。 extends Node # 右键点道具时发出(window, cell 为 wire 坐标)。P8 的商店 / 交易 / 仓库窗接它。 signal item_context(window: int, cell: int) +# 背包物品右键穿戴被 EquipRules 前置门拦下时发出(code 见 equip_rules.gd,need = limit 要求值)。 +signal equip_rejected(code: String, need: int) + # 若设置:右键道具时先调它 (window, cell) -> bool;返回 true 表示已处理,不再 use_item。 var context_consumer: Callable = Callable() const UiAssets = preload("res://ui/ui_assets.gd") +const EquipRules = preload("res://equip_rules.gd") +const ItemTooltip = preload("res://ui/item_tooltip.gd") const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞 const BELT_SLOT_START := 152 # GameType.c_Belt_Inventory_Slot_Start const BELT_CELL_COUNT := 16 -const WEAR_COUNT := 24 -const WEAR_BELT := 23 -const WINDOW_INVENTORY := 1 # mtnet::WINDOW_* +const WEAR_COUNT := 24 # get_equipment() 数组上限(server wear 保留位;实际用到 0..23) + +# wear 位置编号(GameType.h `EWearPositions` / `c_Equipment_*`,wear = 全局 cell - c_Equipment_Start(90))。 +# 旧 11 格(BODY..SHIELD)在客户端走 EQUIPMENT 窗(window=2, wear cell); +# ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume/ring/belt 仍留在 INVENTORY 窗(window=1)的 +# 全局 cell 命名空间里 —— c_Costume_Slot_Start = 90+19, c_New_Equipment_Start = 90+21。 +const WEAR_MAX_NUM := 11 # ItemData.h EWearPositions::WEAR_MAX_NUM +const WEAR_COSTUME_BODY := 19 # GameType.h c_Costume_Slot_Body - c_Equipment_Start +const WEAR_COSTUME_HAIR := 20 # c_Costume_Slot_Hair - c_Equipment_Start +const WEAR_RING1 := 21 # c_Equipment_Ring1 - c_Equipment_Start +const WEAR_RING2 := 22 # c_Equipment_Ring2 - c_Equipment_Start +const WEAR_BELT := 23 # c_Equipment_Belt - c_Equipment_Start +const NEW_EQUIP_WEAR_START := WEAR_COSTUME_BODY # >= 此值的 wear 位置走 INVENTORY 全局 cell +const WINDOW_INVENTORY := 1 # mtnet::WINDOW_INVENTORY +const WINDOW_EQUIPMENT := 2 # mtnet::WINDOW_EQUIPMENT(旧 11 格 wear) +const WINDOW_SAFEBOX := 3 # global mouse payload source, not an inventory window +const WINDOW_MALL := 4 # global mouse payload source, not an inventory window const BELT_RULES := [1, 2, 4, 6, 3, 3, 4, 6, 5, 5, 5, 6, 7, 7, 7, 7] var ui: CanvasLayer # UiManager var client: Node # M2Client var proto: Node # Metin2Proto var item_list: RefCounted # ItemList(可空;给了就用它的 icon 路径) +var item_tooltip: RefCounted var assets_root := "" var uiscript_dir := "" +var item_mouse: Node # MouseController(可空,兼容旧测试桩) var _win: Dictionary = {} # { root, nodes } var _cells := {} # ui_index:int -> Panel var _drag_from := -1 var _combine_from := -1 # Shift 选中的“使用到物品”来源格 var _hint: Label +var _money_button: BaseButton func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String, il: RefCounted = null) -> void: @@ -51,6 +75,8 @@ func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: St proto = proto_node item_list = il assets_root = assets + item_tooltip = ItemTooltip.new() + item_tooltip.setup(assets_root, "en", proto) uiscript_dir = assets.path_join("uiscript/uiscript") if not DirAccess.dir_exists_absolute(uiscript_dir): uiscript_dir = assets.path_join("uiscript") @@ -65,6 +91,10 @@ func toggle() -> void: else: open() func close() -> void: + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() if is_open(): ui.close(_win["root"]) _win = {} @@ -83,6 +113,7 @@ func open() -> void: _index_cells(_win["root"]) _add_extended_cells(_win["root"]) _wire_cells() + _wire_money() refresh() # --- 填充 --------------------------------------------------------------- @@ -95,34 +126,40 @@ func refresh() -> void: for d in client.get_inventory(): var cell: Panel = _cells.get(int(d["cell"]), null) if cell: - _fill_cell(cell, int(d["vnum"]), int(d["count"])) + _fill_cell(cell, int(d["vnum"]), int(d["count"]), d) var eq: Array = client.get_equipment() for i in eq.size(): var it: Dictionary = eq[i] var cell: Panel = _cells.get(EQUIP_BASE + i, null) if cell and int(it.get("vnum", 0)) != 0: - _fill_cell(cell, int(it["vnum"]), int(it.get("count", 1))) - for d in client.get_belt_inventory(): + _fill_cell(cell, int(it["vnum"]), int(it.get("count", 1)), it) + var belt_items: Array = client.get_belt_inventory() if client.has_method("get_belt_inventory") else [] + for d in belt_items: var cell: Panel = _cells.get(BELT_BASE + int(d["cell"]), null) if cell: - _fill_cell(cell, int(d["vnum"]), int(d.get("count", 1))) + _fill_cell(cell, int(d["vnum"]), int(d.get("count", 1)), d) + if is_instance_valid(_money_button): + _money_button.text = _format_gold(_current_gold()) _apply_belt_locks(eq) func _on_inv_changed(_window: int, _cell: int) -> void: refresh() -func _fill_cell(cell: Panel, vnum: int, count: int) -> void: +func _fill_cell(cell: Panel, vnum: int, count: int, instance_data: Dictionary = {}) -> void: _clear_cell(cell) cell.set_meta("vnum", vnum) cell.set_meta("count", count) + cell.set_meta("item_data", instance_data.duplicate(true)) var name := "item %d" % vnum - var itype := 0 + var proto_data: Dictionary = {} if proto: - var pd: Dictionary = proto.item(vnum) - if not pd.is_empty(): - name = String(pd.get("locale_name", pd.get("name", name))) - itype = int(pd.get("type", 0)) - cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""] + proto_data = proto.item(vnum) + if not proto_data.is_empty(): + name = String(proto_data.get("locale_name", proto_data.get("name", name))) + if item_tooltip: + cell.tooltip_text = item_tooltip.format(vnum, count, proto_data, instance_data) + else: + cell.tooltip_text = "%s\n#%d%s" % [name, vnum, (" x%d" % count) if count > 1 else ""] var tex := _icon(vnum) if tex: var tr := TextureRect.new() @@ -152,6 +189,7 @@ func _fill_cell(cell: Panel, vnum: int, count: int) -> void: func _clear_cell(cell: Panel) -> void: cell.set_meta("vnum", 0) cell.set_meta("count", 0) + cell.set_meta("item_data", {}) cell.tooltip_text = "" for c in cell.get_children(): c.queue_free() @@ -175,7 +213,33 @@ func _index_cells(root: Node) -> void: func _wire_cells() -> void: for idx in _cells: var cell: Panel = _cells[idx] - cell.gui_input.connect(func(e: InputEvent): _on_cell_input(int(idx), e)) + var cell_idx: int = int(idx) + cell.gui_input.connect(func(e: InputEvent): _on_cell_input(cell_idx, e)) + if item_mouse and item_mouse.has_method("register_target"): + item_mouse.register_target(cell, func(payload: Dictionary): return _drop_mouse_item(payload, cell_idx), self) + +func _wire_money() -> void: + _money_button = _win.get("nodes", {}).get("Money_Slot", null) + if _money_button is BaseButton: + _money_button.pressed.connect(_on_money_pressed) + else: + _money_button = null + +func _current_gold() -> int: + if client and client.has_method("get_points"): + return maxi(0, int(client.get_points().get("gold", 0))) + return 0 + +func _format_gold(gold: int) -> String: + return str(gold) + +func _on_money_pressed() -> void: + if item_mouse == null or not item_mouse.has_method("attach_money"): + return + var gold := mini(_current_gold(), 9999999) + if gold <= 0: + return + _ask_count("金币数量", gold, func(amount: int): item_mouse.attach_money(amount, "inventory")) func _add_extended_cells(root: Control) -> void: # 旧 inventorywindow.py 只排出前 11 个传统装备位。剩余能力 / 时装 / @@ -190,7 +254,9 @@ func _add_extended_cells(root: Control) -> void: bg.set_border_width_all(1) panel.add_theme_stylebox_override("panel", bg) root.add_child(panel) - _add_grid(panel, "扩展装备", 11, WEAR_COUNT, EQUIP_BASE, Vector2(10, 30)) + # 旧 uiscript 只排 BODY..SHIELD(wear 0..10)。EWearPositions 里 11..18 无位置, + # 扩展面板只补 costume(19/20) / ring(21/22) / belt(23) 这 5 个真实新装备位。 + _add_grid(panel, "时装 / 戒指 / 腰带", WEAR_COSTUME_BODY, WEAR_BELT - WEAR_COSTUME_BODY + 1, EQUIP_BASE, Vector2(10, 30)) _add_grid(panel, "腰带背包", 0, BELT_CELL_COUNT, BELT_BASE, Vector2(10, 242)) _hint = Label.new() _hint.position = Vector2(10, 430) @@ -270,12 +336,24 @@ func _on_cell_input(ui_idx: int, e: InputEvent) -> void: elif e.button_index == MOUSE_BUTTON_LEFT: if e.pressed and vnum != 0: if e.shift_pressed: - _begin_combine(ui_idx) + if _item_count(ui_idx) > 1: + _split_stack(ui_idx) + else: + _begin_combine(ui_idx) return if _combine_from != -1: if _combine_from != ui_idx: _use_to_item(_combine_from, ui_idx) return + if item_mouse and item_mouse.has_method("attach_item"): + var wire := _to_wire(ui_idx) + var metadata: Dictionary = {} + if client.has_method("get_item"): + metadata = client.get_item(wire[0], wire[1]) + if item_mouse.attach_item(wire[0], wire[1], vnum, _item_count(ui_idx), + _icon(vnum), "inventory", metadata): + _drag_from = -1 + return _drag_from = ui_idx elif not e.pressed and _drag_from != -1: var over := _cell_under_mouse() @@ -283,6 +361,35 @@ func _on_cell_input(ui_idx: int, e: InputEvent) -> void: move_to(_drag_from, over, 1) _drag_from = -1 +func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool: + if client == null or payload.is_empty() or target_ui < 0: + return false + if bool(_cells.get(target_ui, null).get_meta("locked", false)) if _cells.get(target_ui, null) else true: + return false + var source_window := int(payload.get("window", -1)) + var source_cell := int(payload.get("cell", -1)) + if source_cell < 0: + return false + var target := _to_wire(target_ui) + if source_cell == int(target[1]) and source_window == int(target[0]): + return true + if source_window == WINDOW_SAFEBOX: + return target[0] == WINDOW_INVENTORY and client.has_method("safebox_checkout") \ + and client.safebox_checkout(source_cell, target[0], target[1]) + if source_window == WINDOW_MALL: + return target[0] == WINDOW_INVENTORY and client.has_method("mall_checkout") \ + and client.mall_checkout(source_cell, target[0], target[1]) + if source_window not in [WINDOW_INVENTORY, 2]: + return false + return move_wire(source_window, source_cell, int(target[0]), int(target[1]), + maxi(1, int(payload.get("count", 1)))) + +func move_wire(source_window: int, source_cell: int, target_window: int, + target_cell: int, count := 1) -> bool: + if client == null or not client.has_method("move_item"): + return false + return client.move_item(source_window, source_cell, target_window, target_cell, count) + # 把 ui 格子 from 的物品挪到 ui 格子 to(发 CG_ITEM_MOVE) func move_to(from_ui: int, to_ui: int, count := 1) -> bool: if client == null: @@ -291,13 +398,63 @@ func move_to(from_ui: int, to_ui: int, count := 1) -> bool: var b := _to_wire(to_ui) return client.move_item(a[0], a[1], b[0], b[1], count) -# 用掉 ui 格子里的物品(发 CG_ITEM_USE) +# 用掉 ui 格子里的物品(发 CG_ITEM_USE)。 +# 对背包里的可穿戴物品(右键 = 穿),先跑 EquipRules.can_equip 前置门(§4.1 修改 2): +# 校验不过 → 发 equip_rejected 并不发包(服务器仍是最终仲裁)。 func use(ui_idx: int) -> bool: if client == null: return false + if ui_idx < EQUIP_BASE: + var cell: Panel = _cells.get(ui_idx, null) + var vnum := int(cell.get_meta("vnum", 0)) if cell else 0 + if vnum != 0 and proto: + var pi: Dictionary = proto.item(vnum) + if EquipRules.is_equippable(pi): + var verdict := EquipRules.can_equip(pi, -1, _equip_ctx()) + if not bool(verdict["ok"]): + _set_hint(_equip_reject_hint(verdict)) + equip_rejected.emit(String(verdict["code"]), int(verdict["need"])) + return false var w := _to_wire(ui_idx) return client.use_item(w[0], w[1]) +# 公开校验入口(tooltip / 拖放目标预览可用)。wear 传 -1 = 用物品默认槽。 +func can_equip_item(vnum: int, wear := -1) -> Dictionary: + if vnum == 0 or proto == null: + return {"ok": false, "code": "NO_ITEM", "need": 0} + return EquipRules.can_equip(proto.item(vnum), wear, _equip_ctx()) + +# 组装 EquipRules.can_equip 需要的运行期上下文。属性点客户端未按名透出 → 传 -1(seam)。 +func _equip_ctx() -> Dictionary: + var ctx := {"race": 0, "empire": 0, "level": 0, "st": -1, "dx": -1, "ht": -1, "iq": -1} + if client == null: + return ctx + if client.has_method("get_points"): + var p: Dictionary = client.get_points() + ctx["level"] = int(p.get("level", 0)) + if client.has_method("get_entities"): + for e in client.get_entities(): + if bool(e.get("is_main", false)): + ctx["race"] = int(e.get("race", 0)) + ctx["empire"] = int(e.get("empire", 0)) + if int(e.get("level", 0)) > 0: + ctx["level"] = int(e.get("level", 0)) + break + return ctx + +func _equip_reject_hint(verdict: Dictionary) -> String: + match String(verdict.get("code", "")): + "ANTI_SEX": return "该装备不符合你的性别" + "ANTI_JOB": return "该装备不符合你的职业" + "ANTI_EMPIRE": return "该装备不符合你的帝国" + "NOT_WEARABLE_HERE": return "该装备不能穿在这个位置" + "LIMIT_LEVEL": return "需要等级 %d" % int(verdict.get("need", 0)) + "LIMIT_STR": return "需要力量 %d" % int(verdict.get("need", 0)) + "LIMIT_DEX": return "需要敏捷 %d" % int(verdict.get("need", 0)) + "LIMIT_INT": return "需要智力 %d" % int(verdict.get("need", 0)) + "LIMIT_CON": return "需要体力 %d" % int(verdict.get("need", 0)) + return "无法装备" + # Shift+左键(或 Shift+右键)选来源,再左键另一个非空格。 func _begin_combine(ui_idx: int) -> void: if _combine_from == ui_idx: @@ -308,6 +465,16 @@ func _begin_combine(ui_idx: int) -> void: _drag_from = -1 _set_hint("请选择目标物品(左键);再次 Shift 点击来源格可取消") +func _split_stack(ui_idx: int) -> void: + var count := _item_count(ui_idx) + if count <= 1 or item_mouse == null or not item_mouse.has_method("attach_item"): + return + _ask_count("拾取数量", count, func(amount: int): + var wire := _to_wire(ui_idx) + var metadata: Dictionary = client.get_item(wire[0], wire[1]) if client.has_method("get_item") else {} + item_mouse.attach_item(wire[0], wire[1], int(_cells[ui_idx].get_meta("vnum", 0)), + amount, _icon(int(_cells[ui_idx].get_meta("vnum", 0))), "inventory", metadata)) + func _use_to_item(source_ui: int, target_ui: int) -> bool: var target: Panel = _cells.get(target_ui, null) if target == null or int(target.get_meta("vnum", 0)) == 0: @@ -399,5 +566,11 @@ func _to_wire(ui_idx: int) -> Array: if ui_idx >= BELT_BASE: return [WINDOW_INVENTORY, BELT_SLOT_START + ui_idx - BELT_BASE] if ui_idx >= EQUIP_BASE: - return [WINDOW_INVENTORY, ui_idx] + var wear := ui_idx - EQUIP_BASE + # ENABLE_NEW_EQUIPMENT_SYSTEM 的 costume(19/20) / ring(21/22) / belt(23) + # 在客户端里没有独立窗,仍用 INVENTORY 窗的全局 cell(109..113,见 GameType.h)。 + if wear >= NEW_EQUIP_WEAR_START: + return [WINDOW_INVENTORY, EQUIP_BASE + wear] + # 旧 11 格(BODY..SHIELD)走 EQUIPMENT 窗(window=2)+ 零基 wear cell。 + return [WINDOW_EQUIPMENT, wear] return [WINDOW_INVENTORY, ui_idx] diff --git a/project/ui/item_tooltip.gd b/project/ui/item_tooltip.gd new file mode 100644 index 00000000..b3b29d4e --- /dev/null +++ b/project/ui/item_tooltip.gd @@ -0,0 +1,368 @@ +# ItemTooltip —— item_proto + itemdesc + 物品实例状态的统一文字提示。 +# +# 参考:ClientVS22/POC/assets/root/uitooltip.py::ItemToolTip.AddItemData。 +# Godot 的原生 tooltip 负责显示位置与换行;本类只负责把同一份物品快照 +# 组织成标题、描述、限制、基础数值、附加属性、魂石槽和精炼关联。 +extends RefCounted + +const ITEM_TYPE_WEAPON := 1 +const ITEM_TYPE_ARMOR := 2 +const ITEM_TYPE_LOTTERY := 8 +const ITEM_TYPE_FISH := 12 +const ITEM_TYPE_ROD := 13 +const ITEM_TYPE_UNIQUE := 16 +const ITEM_TYPE_PICK := 24 +const ITEM_TYPE_BLEND := 27 +const ITEM_TYPE_DS := 29 +const ITEM_TYPE_RING := 33 +const ITEM_TYPE_BELT := 34 + +const LIMIT_NAMES := { + 1: "等级要求", + 2: "力量要求", + 3: "敏捷要求", + 4: "智力要求", + 5: "体力要求", + 6: "网吧要求", + 7: "实时期限", + 8: "首次使用期限", + 9: "穿戴计时", +} + +const TYPE_NAMES := { + 1: "武器", + 2: "防具", + 3: "使用品", + 4: "自动使用品", + 5: "材料", + 6: "特殊物品", + 7: "工具", + 8: "抽奖品", + 9: "金币", + 10: "魔石", + 11: "容器", + 12: "鱼", + 13: "鱼竿", + 14: "资源", + 16: "独特物品", + 17: "技能书", + 18: "任务物品", + 19: "变身物品", + 20: "宝箱", + 21: "宝箱钥匙", + 22: "遗忘书", + 23: "礼盒", + 24: "镐", + 25: "发型", + 26: "图腾", + 27: "混合物", + 28: "时装", + 29: "龙魂石", + 30: "特殊龙魂石", + 31: "提取物", + 32: "副货币", + 33: "戒指", + 34: "腰带", +} + +# APPLY_* 编号来自 ClientVS22/source/GameLib/ItemData.h::EApplyTypes。 +const APPLY_NAMES := { + 1: "最大生命", + 2: "最大法力", + 3: "体质", + 4: "智力", + 5: "力量", + 6: "敏捷", + 7: "攻击速度", + 8: "移动速度", + 9: "施法速度", + 10: "生命恢复", + 11: "法力恢复", + 12: "中毒概率", + 13: "眩晕概率", + 14: "缓慢概率", + 15: "暴击概率", + 16: "穿透概率", + 17: "对人族伤害", + 18: "对动物伤害", + 19: "对兽人伤害", + 20: "对秘境伤害", + 21: "对不死族伤害", + 22: "对恶魔伤害", + 23: "生命吸收", + 24: "法力吸收", + 25: "法力燃烧", + 26: "伤害转法力", + 27: "格挡", + 28: "闪避", + 29: "剑防御", + 30: "双手防御", + 31: "匕首防御", + 32: "铃防御", + 33: "扇防御", + 34: "弓防御", + 35: "火抗性", + 36: "雷抗性", + 37: "魔法抗性", + 38: "风抗性", + 39: "近战反射", + 40: "诅咒反射", + 41: "中毒抵抗", + 42: "击杀恢复法力", + 43: "经验加成", + 44: "金币加成", + 45: "掉落加成", + 46: "药水效果", + 47: "击杀恢复生命", + 48: "免疫眩晕", + 49: "免疫缓慢", + 50: "免疫跌落", + 51: "技能", + 52: "弓箭距离", + 53: "攻击等级", + 54: "防御等级", + 55: "魔法攻击等级", + 56: "魔法防御等级", + 57: "诅咒概率", + 58: "最大耐力", + 59: "对战士伤害", + 60: "对刺客伤害", + 61: "对武士伤害", + 62: "对萨满伤害", + 63: "对怪物伤害", + 64: "商城攻击加成", + 65: "商城防御加成", + 66: "商城经验加成", + 67: "商城掉落加成", + 68: "商城金币加成", + 69: "最大生命百分比", + 70: "最大法力百分比", + 71: "技能伤害", + 72: "普通攻击伤害", + 73: "技能防御", + 74: "普通攻击防御", + 75: "生命提取", + 76: "网吧经验加成", + 77: "网吧掉落加成", + 78: "战士抗性", + 79: "刺客抗性", + 80: "武士抗性", + 81: "萨满抗性", + 82: "能量", + 83: "防御", + 84: "时装属性加成", + 85: "魔法攻击百分比", + 86: "近战魔法攻击百分比", + 87: "冰抗性", + 88: "土抗性", + 89: "暗抗性", + 90: "抗暴击", + 91: "抗穿透", +} + +var _descriptions: Dictionary = {} +var _skill_names: Dictionary = {} +var _mob_proto: Object = null + +func setup(assets_root: String, lang := "en", proto_node: Object = null) -> bool: + _descriptions.clear() + _skill_names.clear() + _mob_proto = proto_node + var path := assets_root.path_join("locale/locale/%s/itemdesc.txt" % lang) + if FileAccess.file_exists(path): + var file := FileAccess.open(path, FileAccess.READ) + if file: + while not file.eof_reached(): + var parts := file.get_line().split("\t", true) + if parts.size() < 2: + continue + var vnum := int(parts[0].strip_edges()) + if vnum <= 0: + continue + _descriptions[vnum] = { + "name": parts[1].strip_edges(), + "description": parts[2].strip_edges() if parts.size() > 2 else "", + } + _load_skill_names(assets_root, lang) + return not _descriptions.is_empty() or not _skill_names.is_empty() + +func format(vnum: int, count: int, proto_data: Dictionary, instance_data: Dictionary = {}) -> String: + if vnum <= 0: + return "" + var name := String(proto_data.get("locale_name", proto_data.get("name", "物品 #%d" % vnum))) + if name.is_empty(): + name = "物品 #%d" % vnum + var sockets: Array = instance_data.get("sockets", proto_data.get("sockets", [])) + name = _special_title(vnum, name, sockets) + var lines: Array[String] = [name] + if count > 1: + lines.append("数量:%d" % count) + var desc: Dictionary = _descriptions.get(vnum, {}) + if not String(desc.get("description", "")).is_empty(): + lines.append(String(desc["description"])) + + var item_type := int(proto_data.get("type", 0)) + var type_name: String = TYPE_NAMES.get(item_type, "") + if not type_name.is_empty(): + lines.append("类型:" + type_name) + var values: Array = proto_data.get("values", []) + if item_type == ITEM_TYPE_WEAPON and values.size() >= 6: + var min_attack := int(values[3]) + int(values[5]) + var max_attack := int(values[4]) + int(values[5]) + if min_attack > 0 or max_attack > 0: + lines.append("攻击力:%d - %d" % [min_attack, max_attack]) + if int(values[1]) > 0 or int(values[2]) > 0: + lines.append("魔法攻击:%d - %d" % [int(values[1]), int(values[2])]) + elif item_type == ITEM_TYPE_ARMOR and values.size() >= 6: + if int(values[1]) > 0: + lines.append("防御力:%d" % (int(values[1]) + int(values[5]) * 2)) + if int(values[0]) > 0: + lines.append("魔法防御:%d" % int(values[0])) + _append_special_item_data(lines, item_type, vnum, values, proto_data, instance_data) + + for limit in proto_data.get("limits", []): + var limit_type := int(limit.get("type", 0)) + var limit_value := int(limit.get("value", 0)) + if limit_type != 0 and limit_value > 0: + lines.append("%s:%d" % [LIMIT_NAMES.get(limit_type, "限制 %d" % limit_type), limit_value]) + for apply in proto_data.get("applies", []): + _append_bonus(lines, int(apply.get("type", 0)), int(apply.get("value", 0))) + for attr in instance_data.get("attrs", proto_data.get("attrs", [])): + _append_bonus(lines, int(attr.get("type", 0)), int(attr.get("value", 0)), "附加") + + var has_socket := false + for socket in sockets: + if int(socket) != 0: + has_socket = true + break + if has_socket: + for i in sockets.size(): + var socket_value := int(sockets[i]) + lines.append("孔 %d:%s" % [i + 1, "空" if socket_value == 0 else ("损坏" if socket_value < 0 else "#%d" % socket_value)]) + + var refined_vnum := int(proto_data.get("refined_vnum", 0)) + if refined_vnum > 0: + lines.append("精炼后:#%d" % refined_vnum) + var refine_set := int(proto_data.get("refine_set", 0)) + if refine_set > 0: + lines.append("精炼套件:%d" % refine_set) + _append_realtime_limit(lines, proto_data, sockets, instance_data) + return "\n".join(lines) + +func _load_skill_names(assets_root: String, lang: String) -> void: + var path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang) + if not FileAccess.file_exists(path): + return + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return + while not file.eof_reached(): + var parts := file.get_line().split("\t", false) + if parts.size() < 3: + continue + var skill_id := int(parts[0]) + var skill_name := parts[2].strip_edges() + if skill_id > 0 and skill_name != "": + _skill_names[skill_id] = skill_name + +func _special_title(vnum: int, item_name: String, sockets: Array) -> String: + if sockets.is_empty() or int(sockets[0]) <= 0: + return item_name + if vnum == 50300 or vnum == 70037 or vnum == 70055: + var skill_name := String(_skill_names.get(int(sockets[0]), "")) + return skill_name + " " + item_name if skill_name != "" else item_name + if vnum >= 70103 and vnum <= 70106 and _mob_proto and _mob_proto.has_method("mob"): + var mob: Dictionary = _mob_proto.mob(int(sockets[0])) + var mob_name := String(mob.get("locale_name", mob.get("name", ""))) + return mob_name + " " + item_name if mob_name != "" else item_name + return item_name + +func _append_realtime_limit(lines: Array[String], proto_data: Dictionary, sockets: Array, + instance_data: Dictionary) -> void: + if bool(instance_data.get("realtime_rendered", false)) or sockets.is_empty(): + return + var limit_type := 0 + for limit in proto_data.get("limits", []): + var candidate := int(limit.get("type", 0)) + if candidate == 7 or candidate == 8 or candidate == 9: + limit_type = candidate + break + if limit_type == 0: + return + var value := int(sockets[0]) + var remaining := 0 + if limit_type == 7 or limit_type == 8: + remaining = maxi(0, value - int(Time.get_unix_time_from_system())) + else: + remaining = maxi(0, value) + if remaining <= 0: + lines.append("剩余时间:已超时") + return + var minutes := remaining / 60 + var seconds := remaining % 60 + var text := "%d秒" % seconds + if minutes > 0: + text = "%d分%s" % [minutes, text] if seconds > 0 else "%d分" % minutes + lines.append("剩余时间:%s" % text) + +func _append_special_item_data(lines: Array[String], item_type: int, vnum: int, + values: Array, proto_data: Dictionary, instance_data: Dictionary) -> void: + var sockets: Array = instance_data.get("sockets", []) + if item_type == ITEM_TYPE_ROD or item_type == ITEM_TYPE_PICK: + if values.size() >= 3 and not sockets.is_empty(): + var level := int(values[0]) / 10 + var current_exp := int(sockets[0]) + var max_exp := int(values[2]) + lines.append("等级:%d" % level) + lines.append("经验:%d / %d" % [current_exp, max_exp]) + if max_exp > 0 and current_exp >= max_exp: + lines.append("经验已满,可升级") + elif item_type == ITEM_TYPE_FISH: + if not sockets.is_empty() and int(sockets[0]) > 0: + lines.append("长度:%.2f cm" % (float(sockets[0]) / 100.0)) + elif item_type == ITEM_TYPE_LOTTERY: + if sockets.size() >= 2 and (int(sockets[0]) != 0 or int(sockets[1]) != 0): + lines.append("彩票号码:%d" % int(sockets[0])) + lines.append("彩票步数:%d" % int(sockets[1])) + elif item_type == ITEM_TYPE_BLEND: + if sockets.size() >= 3 and int(sockets[0]) != 0: + var affect_type := int(sockets[0]) + var affect_value := int(sockets[1]) + _append_bonus(lines, affect_type, affect_value, "效果:") + var duration := int(sockets[2]) + lines.append("持续时间:%d 秒" % duration if duration > 0 else "无持续时间") + elif item_type == ITEM_TYPE_UNIQUE: + _append_item_remaining(lines, proto_data, sockets, instance_data) + elif item_type == ITEM_TYPE_DS: + var step := (vnum / 100) % 10 + var refine := (vnum / 10) % 10 + if step >= 0 and step <= 4: + lines.append("龙魂石阶段:%d" % (step + 1)) + lines.append("龙魂石强化:%d" % refine) + +func _append_item_remaining(lines: Array[String], proto_data: Dictionary, sockets: Array, + instance_data: Dictionary) -> void: + var remaining := int(instance_data.get("remaining_seconds", instance_data.get("duration", 0))) + if remaining <= 0 and not sockets.is_empty(): + var limit_type := int(instance_data.get("limit_type", 0)) + if limit_type == 0: + for limit in proto_data.get("limits", []): + if int(limit.get("type", 0)) != 0: + limit_type = int(limit.get("type", 0)) + break + if limit_type == 7 or limit_type == 8: + remaining = maxi(0, int(sockets[0]) - int(Time.get_unix_time_from_system())) + elif limit_type == 9: + remaining = int(sockets[0]) + elif sockets.size() >= 3: + remaining = int(sockets[2]) * 60 + if remaining > 0: + lines.append("剩余时间:%d 秒" % remaining) + +func _append_bonus(lines: Array[String], apply_type: int, value: int, prefix := "") -> void: + if apply_type == 0 or value == 0: + return + var label: String = APPLY_NAMES.get(apply_type, "属性 %d" % apply_type) + if not prefix.is_empty(): + label = prefix + label + lines.append("%s:%+d" % [label, value]) diff --git a/project/ui/mall_ui.gd b/project/ui/mall_ui.gd index 17a79ef4..467cec8e 100644 --- a/project/ui/mall_ui.gd +++ b/project/ui/mall_ui.gd @@ -7,17 +7,29 @@ # `mall_opened` → 显示;`mall_changed` → 刷新。列出商城道具 + [取出](放进背包第一个空格)。 extends Node +const ItemTooltip = preload("res://ui/item_tooltip.gd") + var client: Node var proto: Node +var _tooltip_builder: RefCounted var _root: Control var _list: VBoxContainer var _title: Label var _status: Label var _password_dialog: ConfirmationDialog +var item_mouse: Node +const MALL_WINDOW := 4 +const MALL_PAGE_SLOTS := 45 +var _grid: GridContainer +var _cells: Dictionary = {} +var _page_label: Label +var _page := 0 func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: client = m2client proto = proto_node + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(AssetRoot.path(), "en", proto) _build(parent) if client.has_signal("mall_opened"): client.mall_opened.connect(func(_s): refresh()) @@ -30,8 +42,13 @@ func is_open() -> bool: return _root != null and _root.visible func close() -> void: + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() if _root: _root.visible = false + _page = 0 if is_instance_valid(_password_dialog): _password_dialog.queue_free() _password_dialog = null @@ -44,6 +61,13 @@ func _name_of(vnum: int) -> String: return n return "#%d" % vnum +func _tooltip_for(item: Dictionary) -> String: + var vnum := int(item.get("vnum", 0)) + if _tooltip_builder: + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + "\n拖到背包取出" + return "%s\n#%d ×%d\n拖到背包取出" % [_name_of(vnum), vnum, int(item.get("count", 1))] + func _first_free_inv() -> int: var used := {} for it in client.get_inventory(): @@ -60,9 +84,13 @@ func refresh() -> void: if not _root.visible: return _status.text = "" - _title.text = "道具商城仓库(%d 格)" % client.get_mall_size() + var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS)))) + _page = clampi(_page, 0, pages - 1) + _title.text = "道具商城仓库(第 %d/%d 页)" % [_page + 1, pages] + _page_label.text = "%d / %d" % [_page + 1, pages] for c in _list.get_children(): c.queue_free() + _render_grid() var items: Array = client.get_mall_items() if items.is_empty(): var e := Label.new() @@ -72,6 +100,47 @@ func refresh() -> void: for it in items: _list.add_child(_row(it)) +func _render_grid() -> void: + if _grid == null: + return + for pos in _cells: + var cell: Button = _cells[pos] + cell.text = "" + cell.tooltip_text = "" + cell.set_meta("vnum", 0) + cell.set_meta("count", 0) + var items: Array = client.get_mall_items() if client and client.has_method("get_mall_items") else [] + for it in items: + var absolute_pos := int(it.get("cell", -1)) + var pos := absolute_pos - _page * MALL_PAGE_SLOTS + if not _cells.has(pos): + continue + var vnum := int(it.get("vnum", 0)) + var count := int(it.get("count", 1)) + var cell: Button = _cells[pos] + cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""] + cell.tooltip_text = _tooltip_for(it) + cell.set_meta("vnum", vnum) + cell.set_meta("count", count) + +func _on_grid_input(local_pos: int, event: InputEvent) -> void: + if not item_mouse or not item_mouse.has_method("attach_item"): + return + if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT + and event.pressed): + return + var cell: Button = _cells[local_pos] + var vnum := int(cell.get_meta("vnum", 0)) + if vnum == 0: + return + item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum, + int(cell.get_meta("count", 1)), null, "mall") + +func _change_page(delta: int) -> void: + var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS)))) + _page = clampi(_page + delta, 0, pages - 1) + refresh() + func _ask_password() -> void: if is_instance_valid(_password_dialog): _password_dialog.queue_free() @@ -100,14 +169,22 @@ func _ask_password() -> void: func _row(it: Dictionary) -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(300, 0) + var vnum := int(it.get("vnum", 0)) + var count := int(it.get("count", 1)) + var cell := int(it.get("cell", 0)) var nm := Label.new() - nm.text = "%s ×%d" % [_name_of(int(it.get("vnum", 0))), int(it.get("count", 1))] + nm.text = "%s ×%d" % [_name_of(vnum), count] nm.custom_minimum_size = Vector2(220, 0) nm.add_theme_font_size_override("font_size", 12) row.add_child(nm) + if item_mouse: + nm.mouse_filter = Control.MOUSE_FILTER_STOP + nm.gui_input.connect(func(e: InputEvent): + if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed \ + and item_mouse.has_method("attach_item"): + item_mouse.attach_item(MALL_WINDOW, cell, vnum, count, null, "mall")) var out := Button.new() out.text = "取出" - var cell := int(it.get("cell", 0)) out.pressed.connect(func() -> void: var target := _first_free_inv() if target < 0: @@ -135,6 +212,24 @@ func _build(parent: Node) -> void: _title.text = "道具商城仓库" _title.add_theme_font_size_override("font_size", 16) box.add_child(_title) + var pager := HBoxContainer.new() + pager.position = Vector2(230, 0) + pager.size = Vector2(90, 24) + var prev := Button.new() + prev.text = "‹" + prev.custom_minimum_size = Vector2(28, 24) + prev.pressed.connect(func(): _change_page(-1)) + pager.add_child(prev) + _page_label = Label.new() + _page_label.custom_minimum_size = Vector2(40, 24) + _page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + pager.add_child(_page_label) + var next := Button.new() + next.text = "›" + next.custom_minimum_size = Vector2(28, 24) + next.pressed.connect(func(): _change_page(1)) + pager.add_child(next) + _root.add_child(pager) _status = Label.new() _status.add_theme_font_size_override("font_size", 11) _status.add_theme_color_override("font_color", Color(1, 0.65, 0.55)) @@ -145,6 +240,20 @@ func _build(parent: Node) -> void: _list = VBoxContainer.new() _list.add_theme_constant_override("separation", 4) sc.add_child(_list) + _list.visible = false + _grid = GridContainer.new() + _grid.columns = 5 + _grid.position = Vector2(16, 58) + _grid.add_theme_constant_override("h_separation", 4) + _grid.add_theme_constant_override("v_separation", 4) + _root.add_child(_grid) + for pos in MALL_PAGE_SLOTS: + var cell := Button.new() + cell.custom_minimum_size = Vector2(58, 30) + cell.add_theme_font_size_override("font_size", 9) + cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e)) + _grid.add_child(cell) + _cells[pos] = cell var close := Button.new() close.text = "关闭" close.pressed.connect(func() -> void: _root.visible = false) diff --git a/project/ui/minimap.gd b/project/ui/minimap.gd index a36a60b7..fbec8351 100644 --- a/project/ui/minimap.gd +++ b/project/ui/minimap.gd @@ -11,9 +11,16 @@ extends Node const RADIUS := 70.0 const SCALE := 0.25 # 像素/米(越大越放大) +# CPythonMiniMap 的 waypoint 夹边留边:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f +# (m2dev PythonMiniMap.cpp:441)。信标越出此半径时,标记贴到圆周并改画方向箭头。 +const SIGNAL_RIM_INSET := 9.0 const NPC_WARP_TYPE := 2 # NPCMark.type: 2 = 传送 const MIN_SCALE := 0.10 const MAX_SCALE := 1.25 +const OBSERVER_ADD := 0 +const OBSERVER_REMOVE := 1 +const OBSERVER_MOVE := 2 +const OBSERVER_INTERPOLATION_MS := 1000.0 var client: Node var player_getter: Callable @@ -21,6 +28,8 @@ var _root: Control var _view: Control var _ch_label: Label var _scale := SCALE +var _signal_points: Array[Vector2] = [] +var _observer_tracks: Dictionary = {} func setup(m2client: Node, parent: Node, get_player: Callable) -> void: client = m2client @@ -28,6 +37,11 @@ func setup(m2client: Node, parent: Node, get_player: Callable) -> void: _build(parent) if client and client.has_signal("channel_changed"): client.channel_changed.connect(func(c): _ch_label.text = "CH %d" % c) + if client and client.has_signal("observer_event"): + client.observer_event.connect(_on_observer_event) + if client and client.has_signal("world_reset"): + client.world_reset.connect(_clear_observers) + _sync_observers_snapshot() func _build(parent: Node) -> void: _root = Control.new() @@ -63,6 +77,105 @@ func set_scale(pixels_per_meter: float) -> void: func get_scale() -> float: return _scale +# CPythonMiniMap::AddSignalPoint stores quest-script waypoints separately from +# server target markers. EventManager supplies absolute server centimetres. +func add_signal_point(x: float, y: float) -> void: + _signal_points.append(Vector2(x, y)) + if _view: + _view.queue_redraw() + +func clear_signal_points() -> void: + _signal_points.clear() + if _view: + _view.queue_redraw() + +func signal_point_count() -> int: + return _signal_points.size() + +# 1:1 复刻 m2dev CPythonMiniMap::Render 的 waypoint 夹边分支(PythonMiniMap.cpp:445-462): +# fDistanceFromCenter = sqrt(dx*dx + dy*dy) +# if fDistanceFromCenter >= radius: +# fRadian = atan2f(dy, dx); dx = radius*cos; dy = radius*sin +# 返回 {clamped, offset(相对圆心的像素偏移), angle(指向真实目标的弧度)}。 +static func signal_marker_geometry(delta_px: Vector2, rim_radius: float) -> Dictionary: + var angle := atan2(delta_px.y, delta_px.x) + if delta_px.length() >= rim_radius: + return { + "clamped": true, + "offset": Vector2(rim_radius * cos(angle), rim_radius * sin(angle)), + "angle": angle, + } + return {"clamped": false, "offset": delta_px, "angle": angle} + +func _signal_offset(world: Vector3, origin: Vector3) -> Dictionary: + var d := world - origin + # 与 _to_map 相同的世界→图轴向:world +x = 东(右),world −z = 北(上)。 + var delta_px := Vector2(d.x, d.z) * _scale + return signal_marker_geometry(delta_px, RADIUS - SIGNAL_RIM_INSET) + +# CPythonMiniMap::AddObserver / MoveObserver / RemoveObserver keep observer +# positions separate from ordinary character instances. The reference client +# interpolates each move over one second, so a packet burst does not make the +# purple observer marker teleport across the minimap. +func _observer_track(pos: Vector3, now_ms: int) -> Dictionary: + return {"cur": pos, "src": pos, "dst": pos, "started_ms": now_ms} + +func _set_observer_track(vid: int, pos: Vector3) -> void: + _observer_tracks[vid] = _observer_track(pos, Time.get_ticks_msec()) + +func _on_observer_event(kind: int, vid: int, pos: Vector2) -> void: + var net_pos := Vector3(pos.x, 0.0, pos.y) + match kind: + OBSERVER_ADD: + # AddObserver resets an existing observer to the packet position too. + _set_observer_track(vid, net_pos) + OBSERVER_REMOVE: + _observer_tracks.erase(vid) + OBSERVER_MOVE: + # Match MoveObserver: a move without a prior add is ignored. The + # snapshot sync below covers observers present before setup. + if not _observer_tracks.has(vid): + return + var cur := _observer_position(vid, net_pos) + var track: Dictionary = _observer_tracks[vid] + track["src"] = cur + track["dst"] = net_pos + track["cur"] = cur + track["started_ms"] = Time.get_ticks_msec() + _observer_tracks[vid] = track + +func _clear_observers() -> void: + _observer_tracks.clear() + +func _sync_observers_snapshot() -> void: + if client == null or not client.has_method("get_observers"): + return + # This seeds observers that arrived before the minimap connected. Once an + # observer_event stream exists, its transitions own the track's destination. + var has_events := client.has_signal("observer_event") + for o in client.get_observers(): + var vid := int(o.get("vid", 0)) + if vid == 0: + continue + var pos: Vector3 = o.get("pos", Vector3.ZERO) + if not has_events: + _set_observer_track(vid, pos) + elif not _observer_tracks.has(vid): + _set_observer_track(vid, pos) + +func _observer_position(vid: int, fallback: Vector3 = Vector3.ZERO) -> Vector3: + if not _observer_tracks.has(vid): + _set_observer_track(vid, fallback) + var track: Dictionary = _observer_tracks[vid] + var src: Vector3 = track.get("src", fallback) + var dst: Vector3 = track.get("dst", src) + var started_ms := float(track.get("started_ms", Time.get_ticks_msec())) + var f := clampf((float(Time.get_ticks_msec()) - started_ms) / OBSERVER_INTERPOLATION_MS, 0.0, 1.0) + var cur := src.lerp(dst, f) + track["cur"] = cur + _observer_tracks[vid] = track + return cur + func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_WHEEL_UP: @@ -83,6 +196,16 @@ func _to_map(world: Vector3, origin: Vector3) -> Vector2: p = p.normalized() * (RADIUS - 4.0) return Vector2(RADIUS, RADIUS) + p +# 圆周方向箭头(对应 __RenderTargetMark 的边缘指示;本工程无 .sub 资源,用三角形代替)。 +func _draw_signal_arrow(pos: Vector2, angle: float) -> void: + var dir := Vector2(cos(angle), sin(angle)) + var perp := Vector2(-dir.y, dir.x) + var tip := pos + dir * 7.0 + var base := pos - dir * 3.0 + _view.draw_colored_polygon( + PackedVector2Array([tip, base + perp * 4.0, base - perp * 4.0]), + Color(1.0, 0.6, 0.1, 0.95)) + func _draw_map() -> void: var c := Vector2(RADIUS, RADIUS) _view.draw_circle(c, RADIUS, Color(0.05, 0.06, 0.08, 0.82)) @@ -120,8 +243,11 @@ func _draw_map() -> void: # 观战者:原版在小地图上显示独立的观察点(紫色菱形)。 if client.has_method("get_observers"): + _sync_observers_snapshot() for o in client.get_observers(): - var op := _to_map(MapCoord.to_world(o.get("pos", Vector3.ZERO)), origin) + var vid := int(o.get("vid", 0)) + var observer_world := _observer_position(vid, o.get("pos", Vector3.ZERO)) + var op := _to_map(MapCoord.to_world(observer_world), origin) var diamond := PackedVector2Array([op + Vector2(0, -4), op + Vector2(4, 0), op + Vector2(0, 4), op + Vector2(-4, 0)]) _view.draw_colored_polygon(diamond, Color(0.8, 0.35, 1.0, 0.95)) @@ -142,7 +268,17 @@ func _draw_map() -> void: else Color(1.0, 0.75, 0.2, 0.85) _view.draw_rect(rect, col, false, 1.0) - # 任务标记:橙星(小方块代替) + # 任务标记(EventManager quest signals,本地 waypoint,不是 GC_TARGET): + # 圆内画橙星;越出小地图半径则贴到圆周画指向目标的方向箭头(世界箭头 / 屏幕边缘指示)。 + for signal_point in _signal_points: + var signal_pos := MapCoord.to_world(Vector3(signal_point.x * 0.01, 0, -signal_point.y * 0.01)) + var geo := _signal_offset(signal_pos, origin) + var smp: Vector2 = c + geo["offset"] + if geo["clamped"]: + _draw_signal_arrow(smp, geo["angle"]) + else: + _view.draw_circle(smp, 5.0, Color(1.0, 0.75, 0.15, 0.95)) + _view.draw_arc(smp, 8.0, 0.0, TAU, 16, Color(1.0, 0.4, 0.1, 0.9), 1.5) if client.has_method("get_world_markers"): for m in client.get_world_markers(): var mp := _to_map(MapCoord.to_world(m.get("pos", Vector3.ZERO)), origin) diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index 8961f76b..a09714a0 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -28,6 +28,9 @@ var anim: Node # Metin2AnimPlayer var _dir := "" var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _state := "" +var _audio: Node +var _sound_instances: Array = [] +var _sound_frame := -1 # root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。 # 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。 @@ -109,6 +112,10 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b CharShadow.attach(self, 1.6) # 怪脚印大一点 return true +func set_audio(audio_node: Node) -> void: + _audio = audio_node + _refresh_sound_script(String(anim.get("anim_path")) if anim else "") + func set_anim_state(s: String) -> void: if s == _state or anim == null: return @@ -118,6 +125,27 @@ func set_anim_state(s: String) -> void: return anim.set("loop", s in ["wait", "walk", "run"]) anim.set("anim_path", msa) + _refresh_sound_script(msa) + +func _process(_delta: float) -> void: + if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ + or not bool(anim.get("playing")): + return + var current_time: float = anim.call("get_time") + var frame := int(floor(current_time * 60.0)) + if frame == _sound_frame: + return + _sound_frame = frame + _audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z, + frame, _sound_instances, true) + +func _refresh_sound_script(motion_path: String) -> void: + _sound_instances = [] + _sound_frame = -1 + if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"): + return + if _audio.has_method("load_mss_for_motion"): + _sound_instances = _audio.load_mss_for_motion(motion_path) # 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。 func set_display_name(n: String) -> void: diff --git a/project/ui/mouse_controller.gd b/project/ui/mouse_controller.gd new file mode 100644 index 00000000..98c89b8a --- /dev/null +++ b/project/ui/mouse_controller.gd @@ -0,0 +1,157 @@ +# MouseController —— ClientVS22 mousemodule.py 的全局物品拖放层。 +# +# 目标控件通过 register_target() 注册;物品源调用 attach_item() 后,控制器 +# 接管鼠标释放,命中目标时调用回调,释放到窗口外 / 右键 / ESC / 失焦则取消。 +# 光标状态由 CursorManager 管理,跟随图标不参与世界点击。 +extends Node + +const CursorManager = preload("res://ui/cursor_manager.gd") + +var cursor: Node +var audio: Node +var _overlay: Control +var _icon: TextureRect +var _label: Label +var _attached: Dictionary = {} +var _targets: Array[Dictionary] = [] + +func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void: + cursor = cursor_manager + audio = audio_node + _overlay = Control.new() + _overlay.name = "MouseItemOverlay" + _overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + _overlay.z_index = 1000 + _overlay.visible = false + _overlay.size = Vector2(72, 72) + parent.add_child(_overlay) + _icon = TextureRect.new() + _icon.position = Vector2(0, 0) + _icon.size = Vector2(48, 48) + _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + _overlay.add_child(_icon) + _label = Label.new() + _label.position = Vector2(2, 48) + _label.size = Vector2(68, 20) + _label.add_theme_font_size_override("font_size", 10) + _label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _overlay.add_child(_label) + set_process_input(true) + +func attach_item(window: int, cell: int, vnum: int, count: int, + texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}) -> bool: + if vnum <= 0 or window < 0 or cell < 0: + return false + _attached = {"window": window, "cell": cell, "vnum": vnum, + "count": maxi(1, count), "source": source} + # Keep the instance fields that the reference mouse module exposes to the + # receiving window (anti flags, sockets, attributes, ...), while retaining + # the canonical source coordinates supplied by the caller. + for key in metadata: + if key not in ["window", "cell", "vnum", "count", "source"]: + _attached[key] = metadata[key] + if _icon: + _icon.texture = texture + _icon.visible = texture != null + if _label: + _label.text = "#%d%s" % [vnum, (" ×%d" % count) if count > 1 else ""] + _label.visible = true + if _overlay: + _overlay.visible = true + if cursor and cursor.has_method("set_cursor"): + cursor.set_cursor(CursorManager.ITEM) + _play_ui("pick.wav") + return true + +func attach_money(amount: int, source := "inventory") -> bool: + if amount <= 0: + return false + _attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount, + "source": source, "money": true} + if _icon: + _icon.texture = null + _icon.visible = false + if _label: + _label.text = "金币 ×%d" % amount + _label.visible = true + if _overlay: + _overlay.visible = true + if cursor and cursor.has_method("set_cursor"): + cursor.set_cursor(CursorManager.ITEM) + _play_ui("pick.wav") + return true + +func cancel() -> void: + _attached.clear() + if _overlay: + _overlay.visible = false + if cursor and cursor.has_method("reset"): + cursor.reset() + +func is_attached() -> bool: + return not _attached.is_empty() + +func attached() -> Dictionary: + return _attached.duplicate(true) + +func register_target(control: Control, callback: Callable, owner: Node = null) -> void: + if control == null or not callback.is_valid(): + return + _targets.append({"control": control, "callback": callback, "owner": owner}) + +func unregister_owner(owner: Node) -> void: + for i in range(_targets.size() - 1, -1, -1): + if _targets[i].get("owner", null) == owner: + _targets.remove_at(i) + +func _process(_dt: float) -> void: + if _overlay and _overlay.visible: + _overlay.global_position = get_viewport().get_mouse_position() + Vector2(12, 12) + # 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。 + for i in range(_targets.size() - 1, -1, -1): + var c: Control = _targets[i].get("control", null) + if c == null or not is_instance_valid(c): + _targets.remove_at(i) + +func _input(event: InputEvent) -> void: + if not is_attached(): + return + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + cancel() + get_viewport().set_input_as_handled() + return + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: + cancel() + get_viewport().set_input_as_handled() + return + if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _drop_at(event.position) + get_viewport().set_input_as_handled() + +func _drop_at(position: Vector2) -> void: + var payload := attached() + for i in range(_targets.size() - 1, -1, -1): + var c: Control = _targets[i].get("control", null) + if c == null or not is_instance_valid(c): + _targets.remove_at(i) + continue + if not c.visible or not c.get_global_rect().has_point(position): + continue + var cb: Callable = _targets[i].get("callback", Callable()) + if cb.is_valid() and bool(cb.call(payload)): + _play_ui("drop.wav") + cancel() + return + # 与参考端释放到无效区域的取消语义一致。 + _play_ui("loginfail.wav") + cancel() + +func _play_ui(name: String) -> void: + if audio and audio.has_method("play_ui"): + audio.play_ui(name) + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_WINDOW_FOCUS_OUT: + cancel() diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index 9c4b27b9..6f06c6f5 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -19,11 +19,16 @@ var anim: Node # Metin2AnimPlayer var motion_dir := "" var action_dir := "" var _state := "" +var _assets_root := "" +var _audio: Node +var _sound_instances: Array = [] +var _sound_frame := -1 var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"] # pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下 # 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。 func build(assets_root: String, race: int, pump := Callable()) -> bool: + _assets_root = assets_root if not ClassDB.class_exists("Metin2Model"): return false var cls: String = CLASS_OF[race & 3] @@ -64,6 +69,10 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: CharShadow.attach(self, 1.3) # 脚下接触阴影 + 强制 cast_shadow return true +func set_audio(audio_node: Node) -> void: + _audio = audio_node + _refresh_sound_script(String(anim.get("anim_path")) if anim else "") + func set_anim_state(s: String) -> void: if s == _state or anim == null or motion_dir == "": return @@ -74,6 +83,7 @@ func set_anim_state(s: String) -> void: if FileAccess.file_exists(msa): anim.set("loop", s in ["wait", "walk", "run"]) anim.set("anim_path", msa) + _refresh_sound_script(msa) # Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends # the numeric motion id; paired emotions use the other entity's race to select @@ -148,13 +158,41 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool: if String(anim.get("anim_path")) == msa: anim.set("anim_path", "") anim.set("anim_path", msa) + _refresh_sound_script(msa) return true func _on_playback_finished() -> void: - if _state == "__motion": + # CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip. + # When it ends the actor leaves the pushed state (CActorInstance::IsPushing), + # so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate. + if _state == "__motion" or _state == "damage": _state = "" set_anim_state("wait") +# True while the one-shot hit/knockback clip is still playing. +func is_in_hit_reaction() -> bool: + return _state == "damage" + +func _process(_delta: float) -> void: + if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ + or not bool(anim.get("playing")): + return + var current_time: float = anim.call("get_time") + var frame := int(floor(current_time * 60.0)) + if frame == _sound_frame: + return + _sound_frame = frame + _audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z, + frame, _sound_instances, true) + +func _refresh_sound_script(motion_path: String) -> void: + _sound_instances = [] + _sound_frame = -1 + if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"): + return + if _audio.has_method("load_mss_for_motion"): + _sound_instances = _audio.load_mss_for_motion(motion_path) + func _set(prop: StringName, val: Variant) -> bool: if String(prop) in _forward and model: model.set(prop, val) diff --git a/project/ui/private_shop_ui.gd b/project/ui/private_shop_ui.gd index ac2ab3ea..5dafb715 100644 --- a/project/ui/private_shop_ui.gd +++ b/project/ui/private_shop_ui.gd @@ -18,6 +18,8 @@ # M2Client classic 分支按该语义发送。 extends Node +const ItemTooltip = preload("res://ui/item_tooltip.gd") + class PrivateShopDragButton extends Button: var drag_entry: Dictionary = {} @@ -32,10 +34,13 @@ class PrivateShopDragButton extends Button: const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限) const SIGN_MAX := 25 # NameLine input_limit +const ITEM_ANTIFLAG_GIVE := 1 << 13 +const ITEM_ANTIFLAG_MYSHOP := 1 << 16 var client: Node var ui: CanvasLayer # UiManager var proto: Node +var _tooltip_builder: RefCounted var assets_root := "" var uiscript_dir := "" @@ -48,6 +53,8 @@ var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price} var _picked = null # {cell, vnum, count} 等待落位 var _price_dialog: Control = null var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList +var item_mouse: Node +const INVENTORY_WINDOW := 1 func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void: client = m2client @@ -56,6 +63,8 @@ func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, ass assets_root = assets if assets_root == "" and ui and "assets_root" in ui: assets_root = ui.assets_root + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(assets_root if assets_root != "" else AssetRoot.path(), "en", proto) uiscript_dir = assets_root.path_join("uiscript/uiscript") if not DirAccess.dir_exists_absolute(uiscript_dir): uiscript_dir = assets_root.path_join("uiscript") @@ -77,6 +86,10 @@ func toggle() -> void: func close() -> void: _dismiss_price_dialog() + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() if is_open(): ui.close(_win["root"]) _win = {} @@ -124,6 +137,28 @@ func _index_cells(root: Node) -> void: n.set_drag_forwarding(Callable(self, "_no_drag_data"), Callable(self, "_can_drop_slot").bind(idx), Callable(self, "_drop_slot").bind(idx)) + if item_mouse and item_mouse.has_method("register_target"): + item_mouse.register_target(n, + func(payload: Dictionary): return _drop_mouse_item(payload, idx), self) + +func _drop_mouse_item(payload: Dictionary, slot: int) -> bool: + if payload.is_empty() or slot < 0 or slot >= 40 or _stock.has(slot): + return false + if int(payload.get("window", -1)) != INVENTORY_WINDOW: + return false + var cell := int(payload.get("cell", -1)) + var vnum := int(payload.get("vnum", 0)) + if cell < 0 or vnum <= 0: + return false + var entry := payload.duplicate(true) + entry["cell"] = cell + entry["vnum"] = vnum + entry["count"] = maxi(1, int(payload.get("count", 1))) + if not _can_stock(entry): + return false + _picked = entry + _ask_price(slot) + return true # NameLine 在 uiscript 里是 text(Label)—— 盖一个 LineEdit 上去 func _overlay_name_edit(root: Control) -> void: @@ -219,10 +254,17 @@ func _refresh_inv() -> void: var count := int(it.get("count", 1)) var b := PrivateShopDragButton.new() b.text = "%s ×%d" % [_name_of(vnum), count] + if _tooltip_builder: + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + b.tooltip_text = _tooltip_builder.format(vnum, count, pd, it) b.alignment = HORIZONTAL_ALIGNMENT_LEFT b.custom_minimum_size = Vector2(196, 22) b.toggle_mode = true - var entry := {"cell": cell, "vnum": vnum, "count": count} + var entry: Dictionary = it.duplicate(true) + entry["cell"] = cell + entry["vnum"] = vnum + entry["count"] = count + entry["anti_flags"] = int(it.get("anti_flags", 0)) b.drag_entry = entry b.pressed.connect(func() -> void: _pick(entry, b)) if _picked != null and int(_picked["cell"]) == cell: @@ -256,7 +298,13 @@ func _refresh_grid() -> void: pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5)) pr.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.add_child(pr) - cell.tooltip_text = "%s ×%d\n%d 냥" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])] + var tooltip_item := s.duplicate(true) + tooltip_item["count"] = int(s["count"]) + if _tooltip_builder: + var pd: Dictionary = proto.item(int(s["vnum"])) if proto and proto.has_method("item") else {} + cell.tooltip_text = _tooltip_builder.format(int(s["vnum"]), int(s["count"]), pd, tooltip_item) + else: + cell.tooltip_text = "%s ×%d\n%d 냥" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])] cell.modulate = Color(1, 1, 1) else: cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1) @@ -264,6 +312,8 @@ func _refresh_grid() -> void: # --- 상호작용 -------------------------------------------------------- func _pick(entry: Dictionary, _btn: Button) -> void: + if not _can_stock(entry): + return if _picked != null and int(_picked["cell"]) == int(entry["cell"]): _picked = null else: @@ -281,9 +331,36 @@ func _drop_slot(_at_position: Vector2, data, slot: int) -> void: if not _can_drop_slot(Vector2.ZERO, data, slot): return var entry: Dictionary = data["private_shop_entry"] as Dictionary + if not _can_stock(entry): + return _picked = entry.duplicate(true) _ask_price(slot) +func _source_item(cell: int) -> Dictionary: + if client == null: + return {} + if client.has_method("get_item"): + var direct: Dictionary = client.get_item(INVENTORY_WINDOW, cell) + if not direct.is_empty(): + return direct + if client.has_method("get_inventory"): + for item in client.get_inventory(): + if int(item.get("cell", -1)) == cell: + return item + return {} + +func _can_stock(entry: Dictionary) -> bool: + var item_data := _source_item(int(entry.get("cell", -1))) + var vnum := int(entry.get("vnum", item_data.get("vnum", 0))) + var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + var flags := int(entry.get("anti_flags", 0)) | int(item_data.get("anti_flags", 0)) \ + | int(proto_data.get("anti_flags", 0)) + if (flags & (ITEM_ANTIFLAG_GIVE | ITEM_ANTIFLAG_MYSHOP)) != 0: + if is_instance_valid(_status): + _status.text = "该物品不可摆摊" + return false + return true + func _on_slot_clicked(slot: int) -> void: if _stock.has(slot): _stock.erase(slot) @@ -343,6 +420,10 @@ func _place(slot: int, price: int) -> void: if slot < 0 or slot >= 40 or price <= 0 or price > 2000000000 or _picked == null: _dismiss_price_dialog() return + if not _can_stock(_picked): + _dismiss_price_dialog() + _picked = null + return _stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]), "count": int(_picked["count"]), "price": price} _picked = null diff --git a/project/ui/quest_dialog.gd b/project/ui/quest_dialog.gd index 9785cdb0..8d56338b 100644 --- a/project/ui/quest_dialog.gd +++ b/project/ui/quest_dialog.gd @@ -17,19 +17,37 @@ # quest_confirm_ask(GC_QUEST_CONFIRM)也在这里弹 是/否 -> quest_confirm(yes, pid)。 extends Node +const UiAssets = preload("res://ui/ui_assets.gd") + signal opened() signal closed() # EventManager EVENT_TYPE_SELECT_ITEM —— 对应 interfacemodule.BINARY_OpenSelectItemWindow signal select_item_requested() +# EventManager side effects that are owned by the interface layer in ClientVS22. +# Keeping them as signals makes the dialog usable with both the real client and +# headless fixtures without smuggling minimap/camera state into the parser. +signal quest_button_received(index: int, title: String, icon_type: String, icon_name: String) +signal quest_button_cleared(index: int) +signal map_signal_added(x: float, y: float) +signal map_signals_cleared() +signal atlas_center_requested(x: int, y: int) +signal camera_event_requested(kind: String, values: Array) +signal fade_event_requested(kind: String, speed: float) var client: Node +var proto: Node +var _assets_root := "" var _root: Control var _text: RichTextLabel var _btnrow: VBoxContainer +var _image_layer: Control var _confirm_pid := 0 +var _confirm_wait := false -func setup(m2client: Node, parent: Node) -> void: +func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_root := "") -> void: client = m2client + proto = proto_node + _assets_root = assets_root if assets_root != "" else AssetRoot.path() set_process_unhandled_input(true) _build(parent) if client.has_signal("script_dialog"): @@ -45,14 +63,45 @@ func is_open() -> bool: func _on_script(_skin: int, text: String) -> void: var parsed := parse_script(text) if parsed.body.strip_edges() == "" and parsed.choices.is_empty(): + _emit_side_effects(parsed) close() return _text.text = parsed.body - _fill_buttons(parsed.choices, parsed.has_next, parsed.has_input) + _text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT + if parsed.window_size.size() >= 2: + var width := maxi(320, int(parsed.window_size[0])) + var height := maxi(220, int(parsed.window_size[1])) + _root.size = Vector2(width, height) + _text.size = Vector2(width - 32, height - 100) + _btnrow.position = Vector2(16, height - 78) + _btnrow.custom_minimum_size = Vector2(width - 32, 0) + _confirm_wait = parsed.confirm_wait + _render_images(parsed.images, parsed.title_image) + _fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, _confirm_wait) _root.visible = true opened.emit() + _emit_side_effects(parsed) + +func _emit_side_effects(parsed: Dictionary) -> void: if parsed.has_select_item: select_item_requested.emit() + for quest in parsed.quest_buttons: + quest_button_received.emit(int(quest.get("index", -1)), String(quest.get("title", "")), + String(quest.get("icon_type", "")), String(quest.get("icon_name", ""))) + for index in parsed.quest_button_closes: + quest_button_cleared.emit(int(index)) + for point in parsed.map_signals: + map_signal_added.emit(float(point.x), float(point.y)) + if parsed.clear_map_signals: + map_signals_cleared.emit() + if parsed.atlas_center.size() >= 2: + atlas_center_requested.emit(int(parsed.atlas_center[0]), int(parsed.atlas_center[1])) + for camera in parsed.camera_events: + camera_event_requested.emit(String(camera.get("kind", "")), camera.get("values", [])) + for fade in parsed.fade_events: + fade_event_requested.emit(String(fade.get("kind", "")), float(fade.get("speed", 0.0))) + if String(parsed.get("cinema_file", "")) != "": + cinema_requested.emit(String(parsed.cinema_file)) func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void: _confirm_pid = request_pid @@ -64,7 +113,8 @@ func _on_confirm(msg: String, _timeout: int, request_pid: int) -> void: func close() -> void: if _root: _root.visible = false - closed.emit() + _confirm_wait = false + closed.emit() func _unhandled_input(event: InputEvent) -> void: if not is_open() or not (event is InputEventKey): @@ -78,15 +128,224 @@ func _unhandled_input(event: InputEvent) -> void: close() get_viewport().set_input_as_handled() -# --- 脚本解析 -------------------------------------------------------- +# --- 脚本解析(CPythonEventManager 1:1)---------------------------------- +# +# 参照 REF/UserInterface/PythonEventManager.{h,cpp}: +# · EVENT_TYPE_MAP —— EventTypeMap(PythonEventManager.cpp:1148 起 42 条) +# · _get_script_event_index —— GetScriptEventIndex(:1093,前导 '/' = EVENT_POSITION_END) +# · EventSet —— SEventSet(PythonEventManager.h:98,字段名保持一致) +# · _process_event_set —— ProcessEventSet(:377 的 switch 逐 case 对拷) +# · EventSet.insert_line —— __InsertLine(:976) +# · EventSet.clear_line —— ClearLine(:953) +# · _make_question —— MakeQuestion(:884) +# · _make_next_button 效果 —— MakeNextButton(:878,本 POC 只置 has_next / iAdjustLine) +# +# 网络 GC_SCRIPT 里的裸文本被折成合成 LETTER(is_raw=true,跳过 iRestrictedCharacterCount +# 的 30 字断行——reference 脚本只有显式 [LETTER],裸文本是本客户端的扩展,逐字符断句会 +# 破坏服务器写好的句子)。 +# +# seam(无逐帧 Update 循环):isLock/isWaitFlag/lLastDelayTime/lWaitingTime 作为 EventSet +# 状态保留并透出,但一次性把剩余 token 跑完(保证按钮 / 选项 / 副作用仍然落地);Skip / +# EndEventProcess / iConfirmEndTime 的 timeGetTime 走 seam。 -# -> { body: String, choices: [String], has_next: bool } -func parse_script(src: String) -> Dictionary: - var body := "" - var choices := [] +signal cinema_requested(filename: String) + +enum { + EVT_LETTER, EVT_COLOR, EVT_DELAY, EVT_ENTER, EVT_WAIT, EVT_CLEAR, EVT_QUESTION, + EVT_NEXT, EVT_DONE, + EVT_LEFT_IMAGE, EVT_TOP_IMAGE, EVT_BACKGROUND_IMAGE, EVT_IMAGE, + EVT_ADD_MAP_SIGNAL, EVT_CLEAR_MAP_SIGNAL, EVT_SET_MESSAGE_POSITION, + EVT_ADJUST_MESSAGE_POSITION, EVT_SET_CENTER_MAP_POSITION, + EVT_QUEST_BUTTON, EVT_QUEST_BUTTON_CLOSE, + EVT_SLEEP, EVT_SET_CAMERA, EVT_BLEND_CAMERA, EVT_RESTORE_CAMERA, + EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN, + EVT_CLEAR_TEXT, EVT_TEXT_HORIZONTAL_ALIGN_CENTER, EVT_TITLE_IMAGE, + EVT_RUN_CINEMA, EVT_DUNGEON_RESULT, + EVT_ITEM_NAME, EVT_MONSTER_NAME, + EVT_COLOR256, EVT_WINDOW_SIZE, + EVT_INPUT, EVT_CONFIRM_WAIT, EVT_END_CONFIRM_WAIT, + EVT_INSERT_IMAGE, EVT_SELECT_ITEM, +} + +# EventTypeMap(PythonEventManager.cpp:1148-1207)。键即脚本 tag,前导 '/' 由 +# _get_script_event_index 剥离后再查表。 +const EVENT_TYPE_MAP := { + "LETTER": EVT_LETTER, "COLOR": EVT_COLOR, "DELAY": EVT_DELAY, "ENTER": EVT_ENTER, + "WAIT": EVT_WAIT, "CLEAR": EVT_CLEAR, "QUESTION": EVT_QUESTION, "NEXT": EVT_NEXT, "DONE": EVT_DONE, + "LEFTIMAGE": EVT_LEFT_IMAGE, "TOPIMAGE": EVT_TOP_IMAGE, "BGIMAGE": EVT_BACKGROUND_IMAGE, "IMAGE": EVT_IMAGE, + "ADDMAPSIGNAL": EVT_ADD_MAP_SIGNAL, "CLEARMAPSIGNAL": EVT_CLEAR_MAP_SIGNAL, + "SETMSGPOS": EVT_SET_MESSAGE_POSITION, "ADJMSGPOS": EVT_ADJUST_MESSAGE_POSITION, + "SETCMAPPOS": EVT_SET_CENTER_MAP_POSITION, + "QUESTBUTTON": EVT_QUEST_BUTTON, "QUESTBUTTON_CLOSE": EVT_QUEST_BUTTON_CLOSE, + "SLEEP": EVT_SLEEP, "SET_CAMERA": EVT_SET_CAMERA, "BLEND_CAMERA": EVT_BLEND_CAMERA, + "RESTORE_CAMERA": EVT_RESTORE_CAMERA, "FADE_OUT": EVT_FADE_OUT, "FADE_IN": EVT_FADE_IN, + "WHITE_OUT": EVT_WHITE_OUT, "WHITE_IN": EVT_WHITE_IN, "CLEAR_TEXT": EVT_CLEAR_TEXT, + "TEXT_HORIZONTAL_ALIGN_CENTER": EVT_TEXT_HORIZONTAL_ALIGN_CENTER, "TITLE_IMAGE": EVT_TITLE_IMAGE, + "RUN_CINEMA": EVT_RUN_CINEMA, "DUNGEON_RESULT": EVT_DUNGEON_RESULT, + "ITEM": EVT_ITEM_NAME, "MOB": EVT_MONSTER_NAME, + "COLOR256": EVT_COLOR256, "WINDOW_SIZE": EVT_WINDOW_SIZE, + "INPUT": EVT_INPUT, "CONFIRM_WAIT": EVT_CONFIRM_WAIT, "END_CONFIRM_WAIT": EVT_END_CONFIRM_WAIT, + "INSERT_IMAGE": EVT_INSERT_IMAGE, "SELECT_ITEM": EVT_SELECT_ITEM, +} + +const EVENT_POSITION_START := 0 +const EVENT_POSITION_END := 1 +const C_NORMAL_WAITING_TIME := 10 # c_lNormal_Waiting_Time +const C_LINE_TEMP := 16 # c_fLine_Temp +const BOX_VISIBLE_LINE_COUNT := 5 + +# SEventSet(PythonEventManager.h:98)—— 字段名与 C++ 保持一致,另加本 POC 的 +# 行累加 / 副作用收集容器(顶替 CGraphicTextInstance 链表与 Python handler 回调)。 +class EventSet: + extends RefCounted + var ix := 0 + var iy := 0 + var iWidth := 0 + var iyLocal := 0 + var isLock := false + var lLastDelayTime := 0 + var iCurrentLetter := 0 + var CurrentColor := Color(1, 1, 1, 1) + var strCurrentLine := "" + var isConfirmWait := false + var iConfirmEndTime := 0 + var DiffuseColor := Color(1, 1, 1, 1) + var lWaitingTime := C_NORMAL_WAITING_TIME + var iRestrictedCharacterCount := 30 + var iVisibleStartLine := 0 + var iVisibleLineCount := BOX_VISIBLE_LINE_COUNT + var iAdjustLine := 0 + var isTextCenterMode := false + var isWaitFlag := false + var nAnswer := 0 + # --- POC 累加 --- + var lines: Array[String] = [] + var color_open := false + var end_confirm := false + var choices: Array[String] = [] var has_next := false var has_input := false var has_select_item := false + var confirm_timeout := 0 + var window_size: Array[int] = [] + var images: Array[Dictionary] = [] + var insert_images: Array[Dictionary] = [] + var title_image := "" + var quest_buttons: Array[Dictionary] = [] + var quest_button_closes: Array[int] = [] + var map_signals: Array[Vector2] = [] + var clear_map_signals := false + var atlas_center: Array[int] = [] + var camera_events: Array[Dictionary] = [] + var fade_events: Array[Dictionary] = [] + var dungeon_result: Dictionary = {} + var cinema_file := "" + + func init_event_set() -> void: # __InitEventSet(PythonEventManager.cpp:72) + ix = 0; iy = 0; iWidth = 0; iyLocal = 0 + isLock = false + lLastDelayTime = 0 + iCurrentLetter = 0 + CurrentColor = Color(1, 1, 1, 1) + strCurrentLine = "" + isConfirmWait = false + iConfirmEndTime = 0 + DiffuseColor = Color(1, 1, 1, 1) + lWaitingTime = C_NORMAL_WAITING_TIME + iRestrictedCharacterCount = 30 + iVisibleStartLine = 0 + iVisibleLineCount = BOX_VISIBLE_LINE_COUNT + iAdjustLine = 0 + isTextCenterMode = false + isWaitFlag = false + # __InsertLine:init 时 pCurrentTextLine==NULL,不推行,只备一条空当前行。 + + func insert_line(_center := false) -> void: # __InsertLine(:976) + lines.append(strCurrentLine) + iyLocal += C_LINE_TEMP # __AddSpace(c_fLine_Temp) + strCurrentLine = "" + iCurrentLetter = 0 + + func clear_line() -> void: # ClearLine(:953) + lines.clear() + strCurrentLine = "" + iCurrentLetter = 0 + color_open = false + + func body_text() -> String: + var parts := lines.duplicate() + parts.append(strCurrentLine) + var s := "\n".join(parts) + if color_open: + s += "[/color]" + return s.strip_edges() + +# GetScriptEventIndex(PythonEventManager.cpp:1093)—— 前导 '/' = EVENT_POSITION_END。 +# -> { ok:bool, position:int, type:int } +func _get_script_event_index(raw_name: String) -> Dictionary: + var position := EVENT_POSITION_START + var name := raw_name + if name.begins_with("/"): + position = EVENT_POSITION_END + name = name.substr(1) + name = name.to_upper() + if not EVENT_TYPE_MAP.has(name): + return {"ok": false, "position": position, "type": -1} + return {"ok": true, "position": position, "type": int(EVENT_TYPE_MAP[name])} + +# -> { body, choices, has_next, has_input, has_select_item, confirm_wait, +# confirm_timeout, text_centered, window_size, images, title_image, +# quest_buttons, quest_button_closes, map_signals, clear_map_signals, +# atlas_center, camera_events, fade_events, +# insert_images, dungeon_result, adjust_line, waiting_time, +# is_locked, wait_flag, answer_count, cinema_file, end_confirm } +func parse_script(src: String) -> Dictionary: + var es := EventSet.new() + es.init_event_set() + var cmds := _tokenize(src) + # RUN_CINEMA 首命令特例(RegisterEventSetFromString:142 —— ScriptGroup.ReadCmd 读 + # 第一条命令)。本 POC 无法加载 .msc 事件文件,记录文件名走 seam;其余 token 照常 + # 处理。仅当 RUN_CINEMA 是脚本的第一个 token(无前导文本)时生效。 + if not cmds.is_empty() and String(cmds[0].get("literal", "")) == "" \ + and String(cmds[0].get("name", "")).to_upper().trim_prefix("/") == "RUN_CINEMA": + es.cinema_file = _arg_s(String(cmds[0].get("tok", "")), "value") + for cmd in cmds: + _process_event_set(es, cmd) + return { + "body": es.body_text(), + "choices": es.choices, + "has_next": es.has_next, + "has_input": es.has_input, + "has_select_item": es.has_select_item, + "confirm_wait": es.isConfirmWait, + "confirm_timeout": es.confirm_timeout, + "text_centered": es.isTextCenterMode, + "window_size": es.window_size, + "images": es.images, + "title_image": es.title_image, + "quest_buttons": es.quest_buttons, + "quest_button_closes": es.quest_button_closes, + "map_signals": es.map_signals, + "clear_map_signals": es.clear_map_signals, + "atlas_center": es.atlas_center, + "camera_events": es.camera_events, + "fade_events": es.fade_events, + # --- 增量 95 新增:EventSet 状态透出 --- + "insert_images": es.insert_images, + "dungeon_result": es.dungeon_result, + "adjust_line": es.iAdjustLine, + "waiting_time": es.lWaitingTime, + "is_locked": es.isLock, + "wait_flag": es.isWaitFlag, + "answer_count": es.nAnswer, + "cinema_file": es.cinema_file, + "end_confirm": es.end_confirm, + } + +# 把混排文本 / [tag ...] 拆成命令流。裸文本 -> { literal:String }; +# tag -> { name:String(大写,带/前缀), pos:int, tok:String(方括号内原文) }。 +func _tokenize(src: String) -> Array: + var out := [] var i := 0 var n := src.length() while i < n: @@ -94,36 +353,370 @@ func parse_script(src: String) -> Dictionary: if c == "[": var close_i := src.find("]", i) if close_i == -1: - body += src.substr(i) + out.append({"literal": src.substr(i)}) break var tok := src.substr(i + 1, close_i - i - 1).strip_edges() i = close_i + 1 - var name := tok.split(" ", false)[0].to_upper() if tok != "" else "" - match name: - "ENTER": - body += "\n" - "CLEAR", "CLEAR_TEXT": - body = "" - "NEXT", "DONE": - has_next = true - "QUESTION": - for m in _rx_args(tok): - choices.append(m) - "INPUT": - has_input = true - "SELECT_ITEM": - has_select_item = true - "LETTER": - var vv := _rx_args(tok) - if vv.size() > 0: - body += vv[0] - _: - pass # COLOR/DELAY/WAIT/IMAGE/... 忽略 + if tok == "": + continue + var first := tok.split(" ", false)[0] + var pos := EVENT_POSITION_START + if first.begins_with("/"): + pos = EVENT_POSITION_END + out.append({"name": first.to_upper(), "pos": pos, "tok": tok}) else: - body += c + var next_tag := src.find("[", i) + if next_tag == -1: + next_tag = n + var lit := src.substr(i, next_tag - i) + if lit != "": + out.append({"literal": lit}) + i = next_tag + return out + +# ProcessEventSet(PythonEventManager.cpp:377)的 switch 逐 case 对拷。 +func _process_event_set(es: EventSet, cmd: Dictionary) -> void: + var literal := String(cmd.get("literal", "")) + if literal != "": + _evt_letter(es, literal, true) + return + var idx := _get_script_event_index(String(cmd.get("name", ""))) + if not idx.ok: + # GetScriptEventIndex: 未知命令 -> PARSING ERROR,跳过(不进 switch)。 + return + var etype: int = idx.type + var pos: int = idx.position + var tok := String(cmd.get("tok", "")) + match etype: + EVT_LETTER: + var vv := _tag_strings(tok) + if vv.size() > 0: + _evt_letter(es, vv[0], false) + EVT_DELAY: + # START: lWaitingTime = atoi(value);END(/DELAY): 复位 c_lNormal_Waiting_Time。 + es.lWaitingTime = _arg_i(tok, "value", 0) if pos == EVENT_POSITION_START else C_NORMAL_WAITING_TIME + EVT_COLOR: + _evt_color(es, tok, pos, 1.0) + EVT_COLOR256: + _evt_color(es, tok, pos, 255.0) + EVT_ENTER: + es.insert_line() + EVT_WAIT: + es.iyLocal = 0 + es.isLock = true + EVT_CLEAR, EVT_CLEAR_TEXT: + es.clear_line() + EVT_QUESTION: + _make_question(es, tok) + EVT_NEXT: + # MakeNextButton(BUTTON_TYPE_NEXT); iAdjustLine += 2。 + es.has_next = true + es.iAdjustLine += 2 + EVT_DONE: + # MakeNextButton(BUTTON_TYPE_DONE); DoneEvent; iAdjustLine += 2。 + es.has_next = true + es.iAdjustLine += 2 + EVT_LEFT_IMAGE: + es.images.append({"panel": "LEFTIMAGE", "path": _arg_s(tok, "src")}) + EVT_TOP_IMAGE: + es.images.append({"panel": "TOPIMAGE", "path": _arg_s(tok, "src")}) + EVT_BACKGROUND_IMAGE: + es.images.append({"panel": "BGIMAGE", "path": _arg_s(tok, "src")}) + EVT_IMAGE: + var ipath := _arg_s(tok, "src", 0) + if ipath != "": + es.images.append({"x": _arg_i(tok, "x", 0), "y": _arg_i(tok, "y", 1), "path": ipath}) + EVT_INSERT_IMAGE: + var img := _arg_s(tok, "image_name") + es.insert_images.append({ + "image_name": img, + "image_type": _arg_s(tok, "image_type"), + "idx": _arg_i(tok, "idx"), + "title": _arg_s(tok, "title"), + "desc": _arg_s(tok, "desc"), + "index": _arg_i(tok, "index"), + "total": _arg_i(tok, "total"), + "is_item_icon": img == "", + }) + es.iAdjustLine += 2 + EVT_ADD_MAP_SIGNAL: + es.map_signals.append(Vector2(_arg_f(tok, "x", 0), _arg_f(tok, "y", 1))) + EVT_CLEAR_MAP_SIGNAL: + es.clear_map_signals = true + EVT_QUEST_BUTTON_CLOSE: + es.quest_button_closes.append(_arg_i(tok, "idx", 0)) + EVT_QUEST_BUTTON: + _make_quest_button(es, tok) + EVT_SET_MESSAGE_POSITION, EVT_ADJUST_MESSAGE_POSITION: + # PythonEventManager.cpp:562 / :566 —— 客户端故意留空(无副作用)。 + pass + EVT_SET_CENTER_MAP_POSITION: + es.atlas_center = [_arg_i(tok, "x", 0), _arg_i(tok, "y", 1)] + EVT_SLEEP: + es.lLastDelayTime = _arg_i(tok, "value", 0) + EVT_SET_CAMERA: + es.camera_events.append({"kind": "SET_CAMERA", "values": _tag_numbers(tok), + "setting": _camera_setting(tok)}) + EVT_BLEND_CAMERA: + es.camera_events.append({"kind": "BLEND_CAMERA", "values": _tag_numbers(tok), + "setting": _camera_setting(tok), "blendtime": _arg_f(tok, "blendtime")}) + EVT_RESTORE_CAMERA: + es.camera_events.append({"kind": "RESTORE_CAMERA", "values": []}) + EVT_FADE_OUT, EVT_FADE_IN, EVT_WHITE_OUT, EVT_WHITE_IN: + var kinds := {EVT_FADE_OUT: "FADE_OUT", EVT_FADE_IN: "FADE_IN", + EVT_WHITE_OUT: "WHITE_OUT", EVT_WHITE_IN: "WHITE_IN"} + es.fade_events.append({"kind": kinds[etype], "speed": _arg_f(tok, "speed", 0)}) + es.isWaitFlag = true # 等 EndEventProcess(seam:无逐帧循环,不真正阻塞)。 + EVT_TEXT_HORIZONTAL_ALIGN_CENTER: + es.isTextCenterMode = true + EVT_TITLE_IMAGE: + es.title_image = _arg_s(tok, "src", 0) + EVT_RUN_CINEMA: + # ProcessEventSet 无此 case(仅 RegisterEventSetFromString 首命令阶段处理)。 + pass + EVT_DUNGEON_RESULT: + es.dungeon_result = { + "killstone_count": _arg_i(tok, "killstone_count"), + "killmob_count": _arg_i(tok, "killmob_count"), + "find_hidden": _arg_i(tok, "find_hidden"), + "hidden_total": _arg_i(tok, "hidden_total"), + "use_potion": _arg_i(tok, "use_potion"), + "is_revived": _arg_i(tok, "is_revived"), + "killallmob": _arg_i(tok, "killallmob"), + "total_time": _arg_i(tok, "total_time"), + "bonus_exp": _arg_i(tok, "bonus_exp"), + } + EVT_ITEM_NAME: + _evt_item_name(es, _arg_i(tok, "value", 0)) + EVT_MONSTER_NAME: + _evt_mob_name(es, _arg_i(tok, "value", 0)) + EVT_WINDOW_SIZE: + var w := _arg_i(tok, "width", 0) + var h := _arg_i(tok, "height", 1) + if w > 0 and h > 0: + es.window_size = [w, h] + EVT_INPUT: + es.insert_line() + es.has_input = true + EVT_CONFIRM_WAIT: + es.confirm_timeout = _arg_i(tok, "timeout", 0) + es.isConfirmWait = true + es.iConfirmEndTime = es.confirm_timeout # timeGetTime()/1000 + timeout(seam) + es.insert_line(true) + # MakeNextButton(BUTTON_TYPE_CANCEL) —— 取消按钮由 _fill_buttons(confirm_wait) 出。 + EVT_END_CONFIRM_WAIT: + es.isConfirmWait = false + es.iConfirmEndTime = 0 + es.end_confirm = true + EVT_SELECT_ITEM: + es.has_select_item = true + _: + pass + +# EVENT_TYPE_LETTER / 裸文本(is_raw)。 +func _evt_letter(es: EventSet, value: String, is_raw: bool) -> void: + es.strCurrentLine += value + if not is_raw: + es.iCurrentLetter += value.length() + if es.iCurrentLetter >= es.iRestrictedCharacterCount: + es.insert_line() + es.lLastDelayTime = es.lWaitingTime + +# EVENT_TYPE_COLOR / EVENT_TYPE_COLOR256。BBCode span:新 span 前先关旧的。 +func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void: + if es.color_open: + es.strCurrentLine += "[/color]" + es.color_open = false + if pos == EVENT_POSITION_START: + var kv := _tag_kv(tok) + var r: float + var g: float + var b: float + if kv.has("r") or kv.has("g") or kv.has("b"): + r = float(kv.get("r", "0")) + g = float(kv.get("g", "0")) + b = float(kv.get("b", "0")) + else: + var nums := _tag_numbers(tok) + if nums.size() < 3: + return + r = nums[0]; g = nums[1]; b = nums[2] + es.CurrentColor = Color(clampf(r / divisor, 0.0, 1.0), + clampf(g / divisor, 0.0, 1.0), clampf(b / divisor, 0.0, 1.0), 1.0) + es.strCurrentLine += "[color=#%s]" % es.CurrentColor.to_html(false) + es.color_open = true + else: + es.CurrentColor = Color(1, 1, 1, 1) + +# MakeQuestion(PythonEventManager.cpp:884)。 +func _make_question(es: EventSet, tok: String) -> void: + var args := _tag_strings(tok) + if args.is_empty(): + return + for a in args: + es.choices.append(String(a)) + es.nAnswer = args.size() + +# EVENT_TYPE_QUEST_BUTTON(:558)—— idx/name/icon_type/icon_name,带位置回退。 +func _make_quest_button(es: EventSet, tok: String) -> void: + var kv := _tag_kv(tok) + var nums := _tag_numbers(tok) + var strs := _tag_strings(tok) + var qidx := -1 + if kv.has("idx") and String(kv["idx"]).is_valid_int(): + qidx = int(kv["idx"]) + elif nums.size() > 0: + qidx = int(nums[0]) + if qidx < 0: + return + var nm := String(kv.get("name", strs[0] if strs.size() > 0 else "")) + var itype := String(kv.get("icon_type", strs[1] if strs.size() > 1 else "")) + var iname := String(kv.get("icon_name", strs[2] if strs.size() > 2 else "")) + es.quest_buttons.append({"index": qidx, "title": nm, "icon_type": itype, "icon_name": iname}) + +func _evt_item_name(es: EventSet, vnum: int) -> void: + var nm := _quest_item_name([float(vnum)]) + es.strCurrentLine += nm + es.iCurrentLetter += nm.length() + if es.iCurrentLetter >= es.iRestrictedCharacterCount: + es.insert_line() + es.lLastDelayTime = es.lWaitingTime + +func _evt_mob_name(es: EventSet, vnum: int) -> void: + var nm := _quest_mob_name([float(vnum)]) + es.strCurrentLine += nm + es.iCurrentLetter += nm.length() + if es.iCurrentLetter >= es.iRestrictedCharacterCount: + es.insert_line() + es.lLastDelayTime = es.lWaitingTime + +# GetCameraSettingFromArgList(PythonEventManager.cpp:48)。 +func _camera_setting(tok: String) -> Dictionary: + return { + "x": _arg_i(tok, "x"), "y": _arg_i(tok, "y"), "z": _arg_i(tok, "z"), + "up": _arg_i(tok, "up"), "view": _arg_i(tok, "view"), "cross": _arg_i(tok, "cross"), + "distance": _arg_i(tok, "distance"), "rot": _arg_i(tok, "rot"), "pitch": _arg_i(tok, "pitch"), + } + +# --- arg 提取(keyword 优先,位置回退)-------------------------------- + +# key(val) / key("val") / key;val 的键值对。重复键取最后一个。 +func _tag_kv(tok: String) -> Dictionary: + var out := {} + var raw := tok.substr(tok.find(" ") + 1) if tok.find(" ") >= 0 else "" + var i := 0 + var n := raw.length() + while i < n: + while i < n and raw[i] in [" ", "\t", ","]: i += 1 - return {"body": body.strip_edges(), "choices": choices, "has_next": has_next, - "has_input": has_input, "has_select_item": has_select_item} + var start := i + while i < n and not (raw[i] in [" ", "\t", ",", "(", ";"]): + i += 1 + if i >= n: + break + var key := raw.substr(start, i - start) + var val := "" + if raw[i] == "(": + var depth := 1 + i += 1 + var vs := i + while i < n and depth > 0: + if raw[i] == "(": + depth += 1 + elif raw[i] == ")": + depth -= 1 + if depth == 0: + break + i += 1 + val = raw.substr(vs, i - vs) + i += 1 + elif raw[i] == ";": + i += 1 + var vs2 := i + while i < n and not (raw[i] in [" ", "\t", ","]): + i += 1 + val = raw.substr(vs2, i - vs2) + val = val.strip_edges() + if val.length() >= 2 and val[0] == '"' and val[val.length() - 1] == '"': + val = val.substr(1, val.length() - 2) + if key != "": + out[key] = val + return out + +func _arg_s(tok: String, key: String, fallback_idx: int = -1) -> String: + var kv := _tag_kv(tok) + if kv.has(key): + return String(kv[key]) + if fallback_idx >= 0: + var strs := _tag_strings(tok) + if fallback_idx < strs.size(): + return String(strs[fallback_idx]) + return "" + +func _arg_f(tok: String, key: String, fallback_idx: int = -1) -> float: + var kv := _tag_kv(tok) + if kv.has(key) and String(kv[key]).is_valid_float(): + return float(kv[key]) + if fallback_idx >= 0: + var nums := _tag_numbers(tok) + if fallback_idx < nums.size(): + return nums[fallback_idx] + return 0.0 + +func _arg_i(tok: String, key: String, fallback_idx: int = -1) -> int: + return int(_arg_f(tok, key, fallback_idx)) + +func _tag_strings(tok: String) -> Array[String]: + var quoted: Array[String] = [] + for raw in _rx_args(tok): + quoted.append(String(raw)) + if not quoted.is_empty(): + return quoted + var out: Array[String] = [] + var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else "" + for part in raw.replace(",", ";").split(";", false): + var value := part.strip_edges() + if value in ["value", "arg"] or value.is_empty(): + continue + if value.begins_with("value("): + value = value.trim_prefix("value(").trim_suffix(")") + if value.begins_with("arg("): + value = value.trim_prefix("arg(").trim_suffix(")") + if not value.is_empty(): + out.append(value) + return out + +func _tag_numbers(tok: String) -> Array[float]: + var out: Array[float] = [] + var raw := tok.substr(tok.find(" ") + 1).strip_edges() if tok.find(" ") >= 0 else "" + raw = raw.replace(",", " ").replace(";", " ").replace("(", " ").replace(")", " ") + for part in raw.split(" ", false): + var value := part.strip_edges() + if value.is_valid_float(): + out.append(float(value)) + return out + +func _quest_item_name(values: Array[float]) -> String: + if values.is_empty(): + return "" + var vnum := int(values[0]) + if proto and proto.has_method("item"): + var data: Dictionary = proto.item(vnum) + var name := String(data.get("locale_name", data.get("name", ""))) + if name != "": + return name + return "物品 #%d" % vnum + +func _quest_mob_name(values: Array[float]) -> String: + if values.is_empty(): + return "" + var vid := int(values[0]) + if client and client.has_method("get_entity"): + var entity: Dictionary = client.get_entity(vid) + var name := String(entity.get("name", "")) + if name != "": + return name + return "怪物 #%d" % vid # 抓 tok 里所有 arg("...") / value("...") 的字符串 func _rx_args(tok: String) -> Array: @@ -159,6 +752,10 @@ func _build(parent: Node) -> void: sb.set_corner_radius_all(4) panel.add_theme_stylebox_override("panel", sb) _root.add_child(panel) + _image_layer = Control.new() + _image_layer.set_anchors_preset(Control.PRESET_FULL_RECT) + _image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(_image_layer) _text = RichTextLabel.new() _text.bbcode_enabled = true _text.position = Vector2(16, 14) @@ -171,11 +768,51 @@ func _build(parent: Node) -> void: _btnrow.add_theme_constant_override("separation", 4) _root.add_child(_btnrow) +func _render_images(images: Array, title_image: String) -> void: + if _image_layer == null: + return + for child in _image_layer.get_children(): + child.queue_free() + for spec in images: + var path := String(spec.get("path", "")) + var tex: Texture2D = UiAssets.load_tex(_assets_root, path) + if tex == null: + continue + var image := TextureRect.new() + image.texture = tex + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + image.mouse_filter = Control.MOUSE_FILTER_IGNORE + var panel_name := String(spec.get("panel", "")) + if panel_name == "BGIMAGE": + image.set_anchors_preset(Control.PRESET_FULL_RECT) + elif panel_name == "TOPIMAGE": + image.position = Vector2(0, 0) + image.size = Vector2(_root.size.x, minf(84.0, tex.get_size().y)) + elif panel_name == "LEFTIMAGE": + image.position = Vector2(0, 0) + image.size = Vector2(minf(150.0, tex.get_size().x), _root.size.y) + else: + image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0))) + image.size = tex.get_size() + _image_layer.add_child(image) + if title_image != "": + var title_tex: Texture2D = UiAssets.load_tex(_assets_root, title_image) + if title_tex: + var title := TextureRect.new() + title.texture = title_tex + title.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + title.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + title.mouse_filter = Control.MOUSE_FILTER_IGNORE + title.position = Vector2((_root.size.x - title_tex.get_size().x) * 0.5, 4) + title.size = title_tex.get_size() + _image_layer.add_child(title) + func _clear_buttons() -> void: for c in _btnrow.get_children(): c.queue_free() -func _fill_buttons(choices: Array, has_next: bool, has_input := false) -> void: +func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false) -> void: _clear_buttons() if has_input: var input := LineEdit.new() @@ -186,6 +823,13 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false) -> void: submit.pressed.connect(func(): _submit_input(input)) input.text_submitted.connect(func(_text): _submit_input(input)) return + if confirm_wait: + var cancel := _mkbtn("取消") + cancel.pressed.connect(func(): + if client and client.has_method("quest_cancel"): + client.quest_cancel() + close()) + return if choices.is_empty(): var b := _mkbtn("继续" if has_next else "关闭") b.pressed.connect(func(): diff --git a/project/ui/quest_log.gd b/project/ui/quest_log.gd index 9c213a5c..668dcf9d 100644 --- a/project/ui/quest_log.gd +++ b/project/ui/quest_log.gd @@ -9,15 +9,59 @@ # `quest_info` 信号来时刷新。空标题的条目视为「已完成 / 移除」。 extends Node +const UiAssets = preload("res://ui/ui_assets.gd") +const QUEST_MAX_NUM := 5 +const QUEST_EVENT_BUTTON_BASE := -2147483648 + +# interfacemodule.__ArrangeQuestButton(interfacemodule.py:1357-1386)1:1 布局参数: +# xPos = 20(无组队窗)/ 130 = 100 + 30(组队窗显示时);(+15 RTL,本迁移不做 RTL) +# yPos = 170 * screenHeight // 600 +# yCount = (screenHeight - 330) // 63 每列容纳的按钮数 +# 第 i 个: x = xPos + (i // yCount) * 100 ; y = yPos + (i % yCount) * 63 +const QUEST_BTN_X := 20 +const QUEST_BTN_X_PARTY := 130 +const QUEST_BTN_COL_W := 100 +const QUEST_BTN_ROW_H := 63 +const QUEST_BTN_SIZE := Vector2(198, 48) + +signal quest_selected(index: int) + var client: Node var _root: Control var _list: VBoxContainer +var _quest_scrollbar: VScrollBar +var _quests: Array = [] +var _quest_start_index := 0 +var _visible_quest_indices: Array[int] = [] +var _button_root: Control +var _button_list: Control +var _quest_buttons_visible := true +var _quest_buttons_suppressed := false +var _quest_button_data: Dictionary = {} +var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的 questButtonList.insert(0, btn) +var _party_shown := false +var _party_probe: Callable = Callable() +var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用) +var _clock_remaining: Dictionary = {} +var _clock_labels: Dictionary = {} +var _clock_accumulator := 0.0 +var _proto: Node +var _item_list: RefCounted +var _assets_root := "" func setup(m2client: Node, parent: Node) -> void: client = m2client + set_process(true) _build(parent) if client.has_signal("quest_info"): client.quest_info.connect(func(_i): refresh()) + _refresh_quest_buttons() + +func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: String) -> void: + _proto = proto_node + _item_list = item_list + _assets_root = assets_root + _refresh_quest_buttons() func is_open() -> bool: return _root != null and _root.visible @@ -31,44 +75,251 @@ func toggle() -> void: if _root.visible: refresh() +# game.py Ctrl+Q toggles the quest-button strip. This migration has the quest +# entries in the quest-log surface, so keep the same state boundary explicit; +# a future HUD strip can bind this without changing the input contract. +func toggle_buttons() -> void: + _quest_buttons_visible = not _quest_buttons_visible + _update_button_visibility() + +func quest_buttons_visible() -> bool: + return _quest_buttons_visible + +func set_buttons_suppressed(suppressed: bool) -> void: + _quest_buttons_suppressed = suppressed + _update_button_visibility() + +# EventManager QUESTBUTTON / QUESTBUTTON_CLOSE interface. The quest-log +# snapshot and the transient quest-letter strip are separate in ClientVS22. +func recv_quest(index: int, title: String, icon_type := "file", icon_name := "") -> void: + if index < 0: + return + # BINARY_RecvQuest:同 index 先 __DestroyQuestButton 旧的,再 questButtonList.insert(0, btn) + # —— 即无论新旧都移到最前。 + _quest_button_data[index] = {"title": title, "icon_type": icon_type, "icon_name": icon_name} + _quest_button_order.erase(index) + _quest_button_order.push_front(index) + _refresh_quest_buttons() + +func clear_quest(index: int) -> void: + _quest_button_data.erase(index) + _quest_button_order.erase(index) + _refresh_quest_buttons() + +func quest_button_order() -> Array[int]: + return _quest_button_order.duplicate() + +# __ArrangeQuestButton 的 xPos 取决于 self.wndParty.IsShow()。组队窗可见性在本迁移里 +# 由 party_ui 的成员数驱动(无信号),故用一个探针回调,在 _process 里轮询变化。 +func set_party_probe(probe: Callable) -> void: + _party_probe = probe + +func set_party_shown(shown: bool) -> void: + if shown == _party_shown: + return + _party_shown = shown + _refresh_quest_buttons() + +func set_screen_size_override(size: Vector2) -> void: + _screen_size_override = size + _refresh_quest_buttons() + +func _screen_size() -> Vector2: + if _screen_size_override.x > 0.0 and _screen_size_override.y > 0.0: + return _screen_size_override + var vp := get_viewport() + if vp: + return vp.get_visible_rect().size + return Vector2(800, 600) + +# interfacemodule.py:1371 yCount = (screenHeight - 330) // 63 +# 参考端无下限,screenHeight < 393 会 ZeroDivision;这里夹到 1(防御性偏离,seam)。 +static func quest_button_y_count(screen_height: float) -> int: + return maxi(1, int(screen_height - 330.0) / 63) + +# interfacemodule.py:1370 yPos = 170 * screenHeight // 600 +static func quest_button_y_pos(screen_height: float) -> int: + return int(170.0 * screen_height) / 600 + +# interfacemodule.py:1375 +# btn.SetPosition(xPos + (int(count // yCount) * 100), yPos + (count % yCount * 63)) +static func quest_button_slot_pos(slot: int, y_count: int, x_pos: int, y_pos: int) -> Vector2: + var col := slot / y_count + var row := slot % y_count + return Vector2(x_pos + col * QUEST_BTN_COL_W, y_pos + row * QUEST_BTN_ROW_H) + +func quest_button_position(index: int) -> Vector2: + var slot := _quest_button_order.find(index) + if slot < 0: + return Vector2(-1, -1) + var screen := _screen_size() + return quest_button_slot_pos(slot, quest_button_y_count(screen.y), + QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X, quest_button_y_pos(screen.y)) + +func quest_button_count() -> int: + return _quest_button_data.size() + +func quest_count() -> int: + return _quests.size() + +func quest_start_index() -> int: + return _quest_start_index + +func visible_quest_indices() -> Array[int]: + return _visible_quest_indices.duplicate() + +func scroll_ratio() -> float: + if _quest_scrollbar == null or _quest_scrollbar.max_value <= 0.0: + return 0.0 + return _quest_scrollbar.value / _quest_scrollbar.max_value + +func set_scroll_ratio(ratio: float) -> void: + if _quest_scrollbar == null: + return + _quest_scrollbar.value = clampf(ratio, 0.0, 1.0) * _quest_scrollbar.max_value + +func select_quest(index: int) -> void: + if index < 0 or index >= _quests.size(): + return + var quest_index := int(_quests[index].get("index", -1)) + if quest_index < 0: + return + if client and client.has_method("script_button"): + client.script_button(QUEST_EVENT_BUTTON_BASE + quest_index) + quest_selected.emit(quest_index) + func refresh() -> void: if not is_open() or client == null: return + _quests.clear() + for q in client.get_quests(): + if String(q.get("title", "")).strip_edges() != "": + _quests.append(q) + _quest_start_index = mini(_quest_start_index, maxi(0, _quests.size() - QUEST_MAX_NUM)) + if _quest_scrollbar: + _quest_scrollbar.max_value = maxi(0, _quests.size() - QUEST_MAX_NUM) + _quest_scrollbar.value = _quest_start_index + _quest_scrollbar.visible = _quests.size() > QUEST_MAX_NUM + _render_quests() + +func _render_quests() -> void: for c in _list.get_children(): c.queue_free() - var quests: Array = client.get_quests() - if quests.is_empty(): + _clock_labels.clear() + _clock_remaining.clear() + _clock_accumulator = 0.0 + _visible_quest_indices.clear() + if _quests.is_empty(): var e := Label.new() e.text = "(无进行中的任务)" e.add_theme_font_size_override("font_size", 12) _list.add_child(e) return - for q in quests: - if String(q.get("title", "")).strip_edges() == "": - continue + var end_index := mini(_quests.size(), _quest_start_index + QUEST_MAX_NUM) + for data_index in range(_quest_start_index, end_index): + var q: Dictionary = _quests[data_index] + _visible_quest_indices.append(data_index) + var quest_index := int(q.get("index", -1)) + var clock_name := String(q.get("clock_name", "")) + if quest_index >= 0 and clock_name != "": + _clock_remaining[quest_index] = maxi(0, int(q.get("clock_value", 0))) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 1) + box.mouse_filter = Control.MOUSE_FILTER_STOP + box.gui_input.connect(func(event): + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + select_quest(data_index) + ) + var heading := HBoxContainer.new() + heading.add_theme_constant_override("separation", 6) + var icon_path := String(q.get("icon", "")) + if icon_path == "": + icon_path = "season1/icon/scroll_open.tga" + var icon_tex: Texture2D = UiAssets.load_tex(AssetRoot.path(), icon_path) + if icon_tex: + var icon := TextureRect.new() + icon.texture = icon_tex + icon.custom_minimum_size = Vector2(24, 24) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + heading.add_child(icon) + var text_column := VBoxContainer.new() + text_column.mouse_filter = Control.MOUSE_FILTER_IGNORE var t := Label.new() t.text = "◆ " + String(q.get("title", "")) t.add_theme_font_size_override("font_size", 13) - box.add_child(t) + t.mouse_filter = Control.MOUSE_FILTER_IGNORE + text_column.add_child(t) var cn := String(q.get("counter_name", "")) if cn != "": var cl := Label.new() cl.text = " %s: %d" % [cn, int(q.get("counter_value", 0))] cl.add_theme_font_size_override("font_size", 11) cl.modulate = Color(0.8, 0.85, 0.7) - box.add_child(cl) - var kn := String(q.get("clock_name", "")) - if kn != "": + cl.mouse_filter = Control.MOUSE_FILTER_IGNORE + text_column.add_child(cl) + if clock_name != "": var kl := Label.new() - kl.text = " %s: %d" % [kn, int(q.get("clock_value", 0))] kl.add_theme_font_size_override("font_size", 11) kl.modulate = Color(0.7, 0.8, 0.9) - box.add_child(kl) + kl.mouse_filter = Control.MOUSE_FILTER_IGNORE + text_column.add_child(kl) + _clock_labels[quest_index] = kl + _update_clock_label(quest_index, kl, clock_name) + heading.add_child(text_column) + heading.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(heading) _list.add_child(box) +func _process(delta: float) -> void: + # 组队窗可见性变化 → 重排(__ArrangeQuestButton 每帧读 wndParty.IsShow())。 + if _party_probe.is_valid(): + var shown := bool(_party_probe.call()) + if shown != _party_shown: + _party_shown = shown + _refresh_quest_buttons() + if not is_open() or _clock_remaining.is_empty(): + return + _clock_accumulator += delta + if _clock_accumulator < 1.0: + return + var elapsed := int(_clock_accumulator) + _clock_accumulator -= float(elapsed) + for quest_index in _clock_remaining.keys(): + _clock_remaining[quest_index] = maxi(0, int(_clock_remaining[quest_index]) - elapsed) + if _clock_labels.has(quest_index): + _update_clock_label(quest_index, _clock_labels[quest_index], "") + +func _update_clock_label(quest_index: int, label: Label, name: String) -> void: + var remaining := int(_clock_remaining.get(quest_index, 0)) + var prefix := name + if prefix == "": + var old := String(label.text) + prefix = old.get_slice(":", 0) if old.contains(":") else "时间" + if remaining <= 0: + label.text = " %s:已超时" % prefix + return + var minutes := remaining / 60 + var seconds := remaining % 60 + var value := "%d秒" % seconds + if minutes > 0: + value = "%d分%s" % [minutes, value] if seconds > 0 else "%d分" % minutes + label.text = " %s:%s" % [prefix, value] + func _build(parent: Node) -> void: + # 任务信件按钮条:绝对定位以 1:1 复刻 __ArrangeQuestButton 的多列布局, + # 因此 root 落在屏幕原点,按钮坐标里已含 xPos / yPos。 + _button_root = Control.new() + _button_root.set_anchors_preset(Control.PRESET_FULL_RECT) + _button_root.position = Vector2.ZERO + _button_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(_button_root) + _button_list = Control.new() + _button_list.set_anchors_preset(Control.PRESET_FULL_RECT) + _button_list.mouse_filter = Control.MOUSE_FILTER_IGNORE + _button_root.add_child(_button_list) + _root = Control.new() _root.set_anchors_preset(Control.PRESET_TOP_RIGHT) _root.position = Vector2(-320, 60) @@ -89,6 +340,89 @@ func _build(parent: Node) -> void: _root.add_child(title) _list = VBoxContainer.new() _list.position = Vector2(12, 34) - _list.custom_minimum_size = Vector2(276, 0) + _list.size = Vector2(260, 310) + _list.custom_minimum_size = Vector2(260, 310) _list.add_theme_constant_override("separation", 8) _root.add_child(_list) + _quest_scrollbar = VScrollBar.new() + _quest_scrollbar.position = Vector2(278, 34) + _quest_scrollbar.size = Vector2(14, 310) + _quest_scrollbar.step = 1.0 + _quest_scrollbar.page = 1.0 + _quest_scrollbar.visible = false + _quest_scrollbar.value_changed.connect(_on_quest_scroll) + _root.add_child(_quest_scrollbar) + +func _on_quest_scroll(value: float) -> void: + var next_start := clampi(roundi(value), 0, maxi(0, _quests.size() - QUEST_MAX_NUM)) + if next_start == _quest_start_index: + return + _quest_start_index = next_start + _render_quests() + +func _refresh_quest_buttons() -> void: + if _button_list == null: + return + for child in _button_list.get_children(): + child.queue_free() + # 顺序 = questButtonList(最新在前)。清掉 order 里 data 已删的残留 index。 + var order: Array[int] = [] + for idx in _quest_button_order: + if _quest_button_data.has(idx): + order.append(int(idx)) + _quest_button_order = order + var screen := _screen_size() + var x_pos := QUEST_BTN_X_PARTY if _party_shown else QUEST_BTN_X + var y_pos := quest_button_y_pos(screen.y) + var y_count := quest_button_y_count(screen.y) + var slot := 0 + for index in _quest_button_order: + var spec: Dictionary = _quest_button_data[index] + var button := Button.new() + button.custom_minimum_size = QUEST_BTN_SIZE + button.size = QUEST_BTN_SIZE + button.position = quest_button_slot_pos(slot, y_count, x_pos, y_pos) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.text = " " + String(spec.get("title", "")) + button.tooltip_text = String(spec.get("title", "")) + button.add_theme_font_size_override("font_size", 12) + if String(spec.get("icon_type", "")) == "highlight": + button.modulate = Color(1.0, 0.85, 0.35) + var tex := _quest_icon(spec) + if tex: + button.icon = tex + button.expand_icon = true + button.icon_max_width = 32 + var captured := int(index) + button.pressed.connect(func(): _start_quest(captured)) + _button_list.add_child(button) + slot += 1 + _update_button_visibility() + +func _quest_icon(spec: Dictionary) -> Texture2D: + var icon_type := String(spec.get("icon_type", "")) + var icon_name := String(spec.get("icon_name", "")) + if icon_type == "item": + var vnum := int(icon_name) + var path := "" + if _item_list and _item_list.has_method("icon"): + path = String(_item_list.icon(vnum)) + if path == "": + path = "icon/item/%05d.tga" % ((vnum / 10) * 10) + return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), path) + if icon_name != "": + return UiAssets.load_tex(_assets_root if _assets_root != "" else AssetRoot.path(), icon_name) + return null + +func _start_quest(index: int) -> void: + if client and client.has_method("script_button"): + client.script_button(index) + # ClientVS22 removes the letter as soon as it is clicked. The server can + # still re-send it if the quest remains actionable. + _quest_button_data.erase(index) + _quest_button_order.erase(index) + _refresh_quest_buttons() + +func _update_button_visibility() -> void: + if _button_root: + _button_root.visible = _quest_buttons_visible and not _quest_buttons_suppressed and not _quest_button_data.is_empty() diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index 9fd47f74..46676ad0 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -1,29 +1,36 @@ -# Quickbar (P6) —— 快捷栏:36 槽(4 页 × 9 格),数字键 1-9 施放技能 / 用物品。 +# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。 # # var qb := preload("res://ui/quickbar.gd").new() # add_child(qb) # qb.setup(m2client, skill_table, canvas_parent, player_getter) # qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1 # qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水 -# # 输入: game_scene 里 KEY_1..KEY_9 -> qb.activate(n),F1..F4 切页 +# # 输入:数字 1..4 -> 本页 0..3,F1..F4 -> 本页 4..7。 # # 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步 # M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。 # 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。 extends Node +const PlayerSkill := preload("res://player_skill.gd") + signal skill_activated(skill_id: int) # game_scene 接它播技能特效 +# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案 +signal skill_rejected(skill_id: int, code: String) const CM := 100.0 const DEFAULT_CD := 2.0 -const SLOTS_PER_PAGE := 9 +const SLOTS_PER_PAGE := 8 const PAGE_COUNT := 4 -const SLOT_COUNT := SLOTS_PER_PAGE * PAGE_COUNT +const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显示页 var client: Node var table: RefCounted # SkillTable +var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源 +var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1) var _player_getter: Callable var _root: Control +var item_mouse: Node var _slots := [] # 当前页 UI:[{btn, cd, lbl}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] var _page := 0 @@ -33,6 +40,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: client = m2client table = skill_table _player_getter = player_getter + _skill_gate = PlayerSkill.new() + _skill_gate.setup(client, table) for _i in SLOT_COUNT: _state.append({"kind": "", "id": 0, "cd_end": 0.0}) _build(parent) @@ -43,7 +52,7 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: restore_from_server() # 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。 -# 服务器 type:1 道具 / 2 技能 / 3 命令 / 4 表情;ref = 道具格 / 技能 id。 +# 参考 ESlotType:1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。 func restore_from_server() -> void: if client == null or not client.has_method("get_quickslots"): return @@ -57,19 +66,23 @@ func restore_from_server() -> void: match int(qs.get("type", 0)): 1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0} 2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0} + 3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0} _state = restored _refresh_page() -func assign(slot: int, kind: String, id: int, persist := true) -> void: +func assign(slot: int, kind: String, id: int, persist := true) -> bool: if slot < 0 or slot >= SLOTS_PER_PAGE: - return + return false + if kind not in ["skill", "item", "emote"] or id <= 0: + return false var global := _global_slot(slot) - _state[global] = {"kind": kind, "id": id, "cd_end": 0.0} if persist and client and client.has_method("quickslot_add"): - var type := 2 if kind == "skill" else 1 if kind == "item" else 0 + var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0 if type == 0 or not client.quickslot_add(global, type, id): - return + return false + _state[global] = {"kind": kind, "id": id, "cd_end": 0.0} _refresh_slot(slot) + return true func activate(slot: int) -> void: if slot < 0 or slot >= SLOTS_PER_PAGE: @@ -91,6 +104,38 @@ func activate(slot: int) -> void: var target_vid := 0 if client.has_method("get_target"): target_vid = int(client.get_target().get("vid", 0)) + # §3.8 修改 1:三层合法性校验(ClickSkillSlot -> __CanUseSkill / + # __CheckSkillUsable / __UseSkill)。挡下就发 skill_rejected(静默码除外), + # TOGGLE_OFF 只发 use_skill 关掉开关技、不施法。 + if _skill_gate: + if net_play and net_play.has_method("skill_context"): + var ctx: Dictionary = net_play.skill_context() + for k in ctx: + _skill_gate.set(k, ctx[k]) + _skill_gate.slot_cd_end = s.cd_end + var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id)) + if not bool(gate.get("ok", false)): + var code := String(gate.get("code", "")) + # PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端 + # 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。 + if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \ + and net_play and net_play.has_method("activate_fishing"): + var fishing_result := String(net_play.activate_fishing()) + if fishing_result == "OK": + skill_activated.emit(int(s.id)) + elif not PlayerSkill.is_silent_code(fishing_result): + skill_rejected.emit(int(s.id), fishing_result) + return + if code == "TOGGLE_OFF": + if client.has_method("use_skill"): + client.use_skill(int(s.id), 0) + skill_activated.emit(int(s.id)) + elif not PlayerSkill.is_silent_code(code): + skill_rejected.emit(int(s.id), code) + return + var resolved := int(gate.get("target_vid", 0)) + if resolved != 0: + target_vid = resolved # use_skill is the authoritative server intent. Keep the CG_MOVE skill # state packet as a separate visual/action-state sync, just like the # original client did. @@ -100,9 +145,42 @@ func activate(slot: int) -> void: if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)): s.cd_end = _now() + _skill_cooldown(int(s.id)) _state[global] = s + # §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过; + # PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。 + if target_vid != 0 and net_play and table \ + and net_play.has_method("send_fly_targeting") \ + and (table.is_fan_range(int(s.id)) or table.is_circle_range(int(s.id))): + var lvl := _skill_level(int(s.id)) + net_play.send_fly_targeting(target_vid, float(table.target_range(int(s.id))), + table.target_count(int(s.id), lvl), table.fly_shape(int(s.id))) skill_activated.emit(int(s.id)) elif s.kind == "item": client.use_item(1, s.id) # WINDOW_INVENTORY + elif s.kind == "emote" and client.has_method("send_emoticon"): + client.send_emoticon(s.id) + +# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏 +# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。 +func activate_reserved(global_slot: int) -> bool: + if global_slot < 0 or global_slot >= SLOT_COUNT: + return false + var local := global_slot - _page * SLOTS_PER_PAGE + if local < 0 or local >= SLOTS_PER_PAGE: + return false + var s: Dictionary = _state[global_slot] + if s.kind != "skill" or s.id == 0 or _now() < s.cd_end: + return false + activate(local) + return true + +# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。 +func _skill_level(sid: int) -> int: + if client == null or not client.has_method("get_skills"): + return 0 + for sk in client.get_skills(): + if int(sk.get("id", 0)) == sid: + return int(sk.get("level", 0)) + return 0 func set_page(page: int) -> void: if page < 0 or page >= PAGE_COUNT or page == _page: @@ -111,6 +189,13 @@ func set_page(page: int) -> void: _move_from = -1 _refresh_page() +func toggle_visible() -> void: + if _root: + _root.visible = not _root.visible + +func is_visible() -> bool: + return _root != null and _root.visible + func _global_slot(local_slot: int) -> int: return _page * SLOTS_PER_PAGE + local_slot @@ -141,7 +226,7 @@ func _swap(local_a: int, local_b: int) -> void: func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) - _root.position = Vector2(-9 * 23, -54) + _root.position = Vector2(-SLOTS_PER_PAGE * 23, -54) parent.add_child(_root) var prev := Button.new() prev.text = "‹" @@ -150,12 +235,12 @@ func _build(parent: Node) -> void: _root.add_child(prev) var next := Button.new() next.text = "›" - next.position = Vector2(9 * 44 + 2, 7) + next.position = Vector2(SLOTS_PER_PAGE * 44 + 2, 7) next.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT))) _root.add_child(next) var page_label := Label.new() page_label.name = "page" - page_label.position = Vector2(9 * 44 + 7, 38) + page_label.position = Vector2(SLOTS_PER_PAGE * 44 + 7, 38) page_label.add_theme_font_size_override("font_size", 9) _root.add_child(page_label) var row := HBoxContainer.new() @@ -194,8 +279,20 @@ func _build(parent: Node) -> void: _clear(idx)) row.add_child(slot) _slots.append({"btn": slot, "cd": cd, "lbl": lbl}) + if item_mouse and item_mouse.has_method("register_target"): + var local_idx: int = i + item_mouse.register_target(slot, + func(payload: Dictionary): return _drop_mouse_item(payload, local_idx), self) _refresh_page() +func _drop_mouse_item(payload: Dictionary, slot: int) -> bool: + if payload.is_empty() or String(payload.get("source", "")) != "inventory": + return false + if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0: + return false + # QuickSlot inventory entries store the source inventory cell, not the vnum. + return assign(slot, "item", int(payload.get("cell", -1))) + func _try_drop(slot: int) -> void: # skill_ui 把待拖技能放在 drag_skill_id;这里落点接 var sk_ui := _find_skill_ui() diff --git a/project/ui/remote_player_view.gd b/project/ui/remote_player_view.gd new file mode 100644 index 00000000..c9e945f0 --- /dev/null +++ b/project/ui/remote_player_view.gd @@ -0,0 +1,47 @@ +# RemotePlayerView —— ClientVS22 的远端 PC 表现层。 +# +# 远端 PC 与本地玩家共用 PlayerView 的 race / GR2 / MSA 装载,但装备来源 +# 必须是 awPart[CHR_EQUIPPART_ARMOR..HAIR],不能读取本地 get_equipment()。 +extends "res://ui/player_view.gd" + +const EquipModel = preload("res://ui/equip_model.gd") + +var remote_equip: Node +var _remote_parts: Array = [] + +func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCounted, + proto: Node = null, pump := Callable()) -> bool: + if not build(assets_root, race, pump): + return false + _remote_parts = parts.duplicate() + while _remote_parts.size() < 4: + _remote_parts.append(0) + if _remote_parts.size() > 4: + _remote_parts = _remote_parts.slice(0, 4) + remote_equip = EquipModel.new() + remote_equip.name = "RemoteEquipModel" + add_child(remote_equip) + remote_equip.setup_remote(item_list, func() -> Node: return self, assets_root, race, + _remote_parts, proto) + return true + +# net_world 按 NetworkActorManager.cpp:473 的顺序调用这四个字段。 +func set_armor(vnum: int) -> void: + _set_remote_part(EquipModel.PART_ARMOR, vnum) + +func set_weapon(vnum: int) -> void: + _set_remote_part(EquipModel.PART_WEAPON, vnum) + +func set_head(vnum: int) -> void: + _set_remote_part(EquipModel.PART_HEAD, vnum) + +func set_hair(vnum: int) -> void: + _set_remote_part(EquipModel.PART_HAIR, vnum) + +func _set_remote_part(index: int, vnum: int) -> void: + while _remote_parts.size() <= index: + _remote_parts.append(0) + _remote_parts[index] = vnum + if remote_equip: + remote_equip._set_remote_part(index, vnum) + remote_equip.refresh() diff --git a/project/ui/safebox_ui.gd b/project/ui/safebox_ui.gd index d1ab193f..1b34a57d 100644 --- a/project/ui/safebox_ui.gd +++ b/project/ui/safebox_ui.gd @@ -9,18 +9,31 @@ # 取出/存入用第一个空位(简版:deposit 用 safe_pos = 下一个空格;checkout 用道具 cell)。 extends Node +const ItemTooltip = preload("res://ui/item_tooltip.gd") + var client: Node var proto: Node +var _tooltip_builder: RefCounted var _root: Control var _list: VBoxContainer var _gold: Label var _title: Label var _status: Label var _password_dialog: ConfirmationDialog +var _grid: GridContainer +var _cells: Dictionary = {} +var _page_label: Label +var _page := 0 +var item_mouse: Node + +const SAFE_PAGE_SLOTS := 45 +const SAFE_WINDOW := 3 func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: client = m2client proto = proto_node + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(AssetRoot.path(), "en", proto) _build(parent) if client.has_signal("safebox_changed"): client.safebox_changed.connect(refresh) @@ -35,8 +48,13 @@ func is_open() -> bool: return _root != null and _root.visible func close() -> void: + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() if _root: _root.visible = false + _page = 0 if is_instance_valid(_password_dialog): _password_dialog.queue_free() _password_dialog = null @@ -69,6 +87,13 @@ func _name_of(vnum: int) -> String: return n return "#%d" % vnum +func _tooltip_for(item: Dictionary) -> String: + var vnum := int(item.get("vnum", 0)) + if _tooltip_builder: + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))] + func refresh() -> void: if client == null: return @@ -76,10 +101,14 @@ func refresh() -> void: if not _root.visible: return _status.text = "" - _title.text = "仓库(%d 页)" % client.get_safebox_size() + var pages := maxi(1, int(client.get_safebox_size())) + _page = clampi(_page, 0, pages - 1) + _title.text = "仓库(第 %d/%d 页)" % [_page + 1, pages] + _page_label.text = "%d / %d" % [_page + 1, pages] _gold.text = "仓库金币: %d" % client.get_safebox_gold() for c in _list.get_children(): c.queue_free() + _render_grid() var items: Array = client.get_safebox_items() if items.is_empty(): var e := Label.new() @@ -89,6 +118,64 @@ func refresh() -> void: for it in items: _list.add_child(_row(it)) +func _render_grid() -> void: + if _grid == null: + return + for pos in _cells: + var cell: Button = _cells[pos] + cell.text = "" + cell.tooltip_text = "" + cell.set_meta("vnum", 0) + cell.set_meta("count", 0) + var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else [] + for it in items: + var absolute_pos := int(it.get("cell", -1)) + var pos := absolute_pos - _page * SAFE_PAGE_SLOTS + if not _cells.has(pos): + continue + var cell: Button = _cells[pos] + var vnum := int(it.get("vnum", 0)) + var count := int(it.get("count", 1)) + cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""] + cell.tooltip_text = _tooltip_for(it) + cell.set_meta("vnum", vnum) + cell.set_meta("count", count) + +func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool: + if payload.is_empty() or not _cells.has(local_pos): + return false + var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos + var cell: Button = _cells[local_pos] + if int(cell.get_meta("vnum", 0)) != 0: + return false + var source_window := int(payload.get("window", -1)) + var source_cell := int(payload.get("cell", -1)) + if source_cell < 0: + return false + if source_window in [1, 2] and client.has_method("safebox_checkin"): + return client.safebox_checkin(safe_pos, source_window, source_cell) + if source_window == SAFE_WINDOW and client.has_method("safebox_move"): + return client.safebox_move(source_cell, safe_pos, 1) + return false + +func _on_grid_input(local_pos: int, event: InputEvent) -> void: + if not item_mouse or not item_mouse.has_method("attach_item"): + return + if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT + and event.pressed): + return + var cell: Button = _cells[local_pos] + var vnum := int(cell.get_meta("vnum", 0)) + if vnum == 0: + return + item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum, + int(cell.get_meta("count", 1)), null, "safebox") + +func _change_page(delta: int) -> void: + var pages := maxi(1, int(client.get_safebox_size())) + _page = clampi(_page + delta, 0, pages - 1) + refresh() + func _ask_password(kind: String) -> void: if is_instance_valid(_password_dialog): _password_dialog.queue_free() @@ -164,6 +251,23 @@ func _build(parent: Node) -> void: _title.text = "仓库" _title.position = Vector2(12, 8) _root.add_child(_title) + var prev := Button.new() + prev.text = "‹" + prev.position = Vector2(235, 6) + prev.size = Vector2(28, 24) + prev.pressed.connect(func(): _change_page(-1)) + _root.add_child(prev) + _page_label = Label.new() + _page_label.position = Vector2(266, 10) + _page_label.size = Vector2(60, 18) + _page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _root.add_child(_page_label) + var next := Button.new() + next.text = "›" + next.position = Vector2(302, 6) + next.size = Vector2(28, 24) + next.pressed.connect(func(): _change_page(1)) + _root.add_child(next) _gold = Label.new() _gold.text = "仓库金币: 0" _gold.position = Vector2(12, 30) @@ -179,3 +283,21 @@ func _build(parent: Node) -> void: _list.position = Vector2(12, 54) _list.add_theme_constant_override("separation", 4) _root.add_child(_list) + _list.visible = false + _grid = GridContainer.new() + _grid.columns = 5 + _grid.position = Vector2(12, 54) + _grid.add_theme_constant_override("h_separation", 4) + _grid.add_theme_constant_override("v_separation", 4) + _root.add_child(_grid) + for pos in SAFE_PAGE_SLOTS: + var cell := Button.new() + cell.custom_minimum_size = Vector2(58, 30) + cell.add_theme_font_size_override("font_size", 9) + cell.set_meta("safe_pos", pos) + cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e)) + if item_mouse and item_mouse.has_method("register_target"): + item_mouse.register_target(cell, + func(payload: Dictionary): return _drop_to_slot(payload, pos), self) + _grid.add_child(cell) + _cells[pos] = cell diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd index 8ada634c..970be037 100644 --- a/project/ui/shop_ui.gd +++ b/project/ui/shop_ui.gd @@ -9,22 +9,38 @@ # 卖:inventory_ui 在商店开着时右键道具 → 调 shop_ui.sell(cell)。 extends Node +const CursorManager = preload("res://ui/cursor_manager.gd") +const ItemTooltip = preload("res://ui/item_tooltip.gd") const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM) +const ITEM_ANTIFLAG_SELL := 1 << 8 +const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3 var client: Node var proto: Node var item_list # ItemListDB (RefCounted) +var _tooltip_builder: RefCounted var _root: Control var _tabbar: HBoxContainer -var _list: VBoxContainer +var _grid: GridContainer var _err: Label var _sell_quantity: SpinBox var _active_tab := 0 +var item_mouse: Node +var cursor_manager: Node +var audio: Node +var _mode := 1 # 1=BUY, 2=SELL +var _buy_mode_button: Button +var _sell_mode_button: Button +var _sell_drop_target: Button +var _sell_confirm: ConfirmationDialog +var _buy_confirm: ConfirmationDialog func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void: client = m2client proto = proto_node item_list = ilist + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(AssetRoot.path(), "en", proto) _build(parent) if client.has_signal("shop_opened"): client.shop_opened.connect(func(_v): open()) @@ -44,13 +60,26 @@ func open() -> void: _err.text = "" _sell_quantity.value = 1 _active_tab = 0 + _set_mode(1) refresh() func _close() -> void: + if item_mouse and item_mouse.has_method("unregister_owner"): + item_mouse.unregister_owner(self) + if item_mouse.has_method("is_attached") and item_mouse.is_attached(): + item_mouse.cancel() _root.visible = false _err.text = "" if _sell_quantity: _sell_quantity.value = 1 + if is_instance_valid(_sell_confirm): + _sell_confirm.queue_free() + _sell_confirm = null + if is_instance_valid(_buy_confirm): + _buy_confirm.hide() + _buy_confirm.queue_free() + _buy_confirm = null + _set_mode(1) func close_and_leave() -> void: client.shop_close() @@ -59,10 +88,119 @@ func close_and_leave() -> void: func sell(inv_cell: int, count: int = -1) -> void: if not is_open() or inv_cell < 0: return + var item := _inventory_item(inv_cell) var amount := count if count > 0 else int(_sell_quantity.value) - amount = clampi(amount, 1, 200) - if client.has_method("shop_sell") and not client.shop_sell(inv_cell, amount): + if not item.is_empty(): + amount = clampi(amount, 1, int(item.get("count", amount))) + else: + amount = clampi(amount, 1, 200) + var payload := item.duplicate(true) + payload["window"] = 1 + payload["cell"] = inv_cell + payload["vnum"] = int(item.get("vnum", 0)) + payload["count"] = int(item.get("count", amount)) + _request_sell(payload, amount) + +func _drop_to_sell(payload: Dictionary) -> bool: + if _mode != 2 or payload.is_empty() or int(payload.get("window", -1)) != 1: + return false + var cell := int(payload.get("cell", -1)) + if cell < 0 or not client.has_method("shop_sell"): + return false + var source_count := maxi(1, int(payload.get("count", 1))) + var amount := clampi(int(_sell_quantity.value), 1, mini(200, source_count)) + return _request_sell(payload, amount) + +func _inventory_item(cell: int) -> Dictionary: + if client == null or not client.has_method("get_inventory"): + return {} + for item in client.get_inventory(): + if int(item.get("cell", -1)) == cell: + return item + return {} + +func _proto_item(vnum: int) -> Dictionary: + if vnum > 0 and proto and proto.has_method("item"): + return proto.item(vnum) + return {} + +func _sell_price(vnum: int, count: int, item_data: Dictionary = {}) -> int: + var d := item_data if not item_data.is_empty() else _proto_item(vnum) + var unit := maxi(0, int(d.get("sell_price", 0))) + var flags := int(d.get("flags", 0)) + var raw := (count / maxi(1, unit)) if (flags & ITEM_FLAG_COUNT_PER_1GOLD) != 0 else unit * count + return maxi(0, int(raw) / 5) + +func _is_valuable(payload: Dictionary, item_data: Dictionary) -> bool: + if int(item_data.get("sell_price", 0)) > 5000: + return true + var sockets: Array = payload.get("sockets", []) + for value in sockets: + if int(value) != 0: + return true + return false + +func _request_sell(payload: Dictionary, amount: int) -> bool: + var vnum := int(payload.get("vnum", 0)) + var d := _proto_item(vnum) + var anti_flags := int(payload.get("anti_flags", 0)) | int(d.get("anti_flags", 0)) + if (anti_flags & ITEM_ANTIFLAG_SELL) != 0: + _on_error("CANNOT_SELL") + _play_ui("loginfail.wav") + return false + if _is_valuable(payload, d): + _ask_sell_confirmation(payload, amount, _sell_price(vnum, amount, d)) + return true + return _send_sell(int(payload.get("cell", -1)), amount) + +func _send_sell(cell: int, amount: int) -> bool: + if cell < 0 or not client.has_method("shop_sell"): + return false + if not client.shop_sell(cell, amount): _on_error("SEND_FAILED") + return false + _play_ui("money.wav") + return true + +func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> void: + if is_instance_valid(_sell_confirm): + _sell_confirm.hide() + _sell_confirm.queue_free() + _sell_confirm = ConfirmationDialog.new() + _sell_confirm.title = "确认出售" + _sell_confirm.dialog_text = "出售 %d 个物品,获得 %d 金币?" % [amount, price] + _sell_confirm.ok_button_text = "出售" + _sell_confirm.cancel_button_text = "取消" + var dialog := _sell_confirm + dialog.confirmed.connect(func() -> void: + _sell_confirm = null + dialog.hide() + dialog.queue_free() + _send_sell(int(payload.get("cell", -1)), amount)) + dialog.canceled.connect(func() -> void: + _sell_confirm = null + dialog.hide() + dialog.queue_free()) + _root.add_child(dialog) + if dialog.is_inside_tree(): + dialog.popup_centered() + else: + dialog.call_deferred("popup_centered") + +func _play_ui(name: String) -> void: + if audio and audio.has_method("play_ui"): + audio.play_ui(name) + +func _set_mode(mode: int) -> void: + _mode = 2 if mode == 2 else 1 + if _buy_mode_button: + _buy_mode_button.button_pressed = _mode == 1 + if _sell_mode_button: + _sell_mode_button.button_pressed = _mode == 2 + if _sell_drop_target: + _sell_drop_target.visible = _mode == 2 + if cursor_manager and cursor_manager.has_method("set_cursor"): + cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY) func _name_of(vnum: int) -> String: if proto and proto.has_method("item"): @@ -78,8 +216,8 @@ func refresh() -> void: for c in _tabbar.get_children(): _tabbar.remove_child(c) c.queue_free() - for c in _list.get_children(): - _list.remove_child(c) + for c in _grid.get_children(): + _grid.remove_child(c) c.queue_free() # START_EX 商店有多个货架(shop.GetTabCount);普通 START 商店 tabs 只 1 个。 @@ -108,47 +246,66 @@ func refresh() -> void: _tabbar.visible = false var items: Array = tabs[_active_tab].get("items", []) - if items.is_empty(): - var e := Label.new() - e.text = "(无货物)" - _list.add_child(e) - return # uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos var base := _active_tab * SHOP_SLOT_COUNT + var by_pos := {} for it in items: - _list.add_child(_row(it, base)) + var slot := int(it.get("pos", 0)) + if slot >= 0 and slot < SHOP_SLOT_COUNT: + by_pos[slot] = it + for slot in SHOP_SLOT_COUNT: + var it: Dictionary = by_pos.get(slot, {}) + _grid.add_child(_slot(it, base + slot)) -func _row(it: Dictionary, pos_base := 0) -> Control: - var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(320, 0) - var nm := Label.new() - nm.text = _name_of(int(it.get("vnum", 0))) - nm.custom_minimum_size = Vector2(170, 0) - nm.add_theme_font_size_override("font_size", 12) - row.add_child(nm) - var pr := Label.new() - pr.text = "%d 金" % int(it.get("price", 0)) - pr.custom_minimum_size = Vector2(90, 0) - pr.modulate = Color(0.95, 0.85, 0.5) - row.add_child(pr) - var buy := Button.new() - buy.text = "买" - var pos := pos_base + int(it.get("pos", 0)) - var stock := int(it.get("count", 0)) - var quantity := SpinBox.new() - quantity.name = "Quantity" - quantity.min_value = 1 - quantity.max_value = clampi(stock if stock > 0 else 200, 1, 200) - quantity.step = 1 - quantity.value = 1 - quantity.custom_minimum_size = Vector2(64, 0) - row.add_child(quantity) - buy.pressed.connect(func() -> void: - var amount := clampi(int(quantity.value), 1, int(quantity.max_value)) - if not client.shop_buy(pos, amount): +func _slot(it: Dictionary, pos: int) -> Button: + var slot := Button.new() + slot.custom_minimum_size = Vector2(66, 30) + slot.clip_text = true + var vnum := int(it.get("vnum", 0)) + if vnum > 0: + var count := int(it.get("count", 0)) + slot.text = _name_of(vnum).substr(0, 7) + if count > 1: + slot.text += " ×%d" % count + if _tooltip_builder: + var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + slot.tooltip_text = _tooltip_builder.format(vnum, maxi(1, count), pd, it) + slot.tooltip_text += "\n价格:%d" % int(it.get("price", 0)) + slot.pressed.connect(func() -> void: _buy_slot(pos, it)) + else: + slot.disabled = true + return slot + +func _buy_slot(pos: int, item: Dictionary) -> void: + if _mode != 1: + _set_mode(1) + return + if item.is_empty() or pos < 0: + return + if is_instance_valid(_buy_confirm): + _buy_confirm.hide() + _buy_confirm.queue_free() + _buy_confirm = ConfirmationDialog.new() + _buy_confirm.title = "确认购买" + _buy_confirm.dialog_text = "购买 %s?\n价格:%d 金币" % [_name_of(int(item.get("vnum", 0))), int(item.get("price", 0))] + _buy_confirm.ok_button_text = "购买" + _buy_confirm.cancel_button_text = "取消" + var dialog := _buy_confirm + dialog.confirmed.connect(func() -> void: + _buy_confirm = null + dialog.hide() + dialog.queue_free() + if not client.shop_buy(pos, 1): _on_error("SEND_FAILED")) - row.add_child(buy) - return row + dialog.canceled.connect(func() -> void: + _buy_confirm = null + dialog.hide() + dialog.queue_free()) + _root.add_child(dialog) + if dialog.is_inside_tree(): + dialog.popup_centered() + else: + dialog.call_deferred("popup_centered") func _on_error(kind: String) -> void: var tbl := { @@ -202,15 +359,42 @@ func _build(parent: Node) -> void: _sell_quantity.position = Vector2(160, 356) _sell_quantity.size = Vector2(70, 26) _root.add_child(_sell_quantity) + _sell_drop_target = Button.new() + _sell_drop_target.name = "SellDropTarget" + _sell_drop_target.text = "拖动物品到此出售" + _sell_drop_target.position = Vector2(12, 325) + _sell_drop_target.size = Vector2(210, 26) + _sell_drop_target.mouse_filter = Control.MOUSE_FILTER_STOP + _sell_drop_target.visible = false + _root.add_child(_sell_drop_target) + if item_mouse and item_mouse.has_method("register_target"): + item_mouse.register_target(_sell_drop_target, _drop_to_sell, self) + _buy_mode_button = Button.new() + _buy_mode_button.text = "购买" + _buy_mode_button.toggle_mode = true + _buy_mode_button.position = Vector2(235, 325) + _buy_mode_button.size = Vector2(62, 26) + _buy_mode_button.pressed.connect(func() -> void: _set_mode(1)) + _root.add_child(_buy_mode_button) + _sell_mode_button = Button.new() + _sell_mode_button.text = "出售" + _sell_mode_button.toggle_mode = true + _sell_mode_button.position = Vector2(300, 325) + _sell_mode_button.size = Vector2(62, 26) + _sell_mode_button.pressed.connect(func() -> void: _set_mode(2)) + _root.add_child(_sell_mode_button) _tabbar = HBoxContainer.new() _tabbar.position = Vector2(12, 30) _tabbar.add_theme_constant_override("separation", 4) _tabbar.visible = false _root.add_child(_tabbar) - _list = VBoxContainer.new() - _list.position = Vector2(12, 58) - _list.add_theme_constant_override("separation", 4) - _root.add_child(_list) + _grid = GridContainer.new() + _grid.columns = 5 + _grid.position = Vector2(12, 58) + _grid.size = Vector2(350, 252) + _grid.add_theme_constant_override("h_separation", 3) + _grid.add_theme_constant_override("v_separation", 2) + _root.add_child(_grid) var close_btn := Button.new() close_btn.text = "离开" close_btn.position = Vector2(300, 360) diff --git a/project/ui/skill_table.gd b/project/ui/skill_table.gd index e8e0f51c..a5d4ebde 100644 --- a/project/ui/skill_table.gd +++ b/project/ui/skill_table.gd @@ -11,6 +11,12 @@ # st.for_job("WARRIOR") # 主动职业技能 [id, …] # st.for_category("SUPPORT") # 辅助 / 被动树 # st.is_passive(121) # true +# +# §3.8 TSkillData 判定位:第 11 列 attributes / 第 12 列 weapon_limit 是 `|` 分隔的 +# 记号串,原客户端 RegisterSkillDesc(PythonSkill.cpp:280)把它们经 +# m_SkillAttributeIndexMap / m_SkillNeedWeaponIndexMap 折成 dwSkillAttribute / +# dwNeedWeapon 两个位掩码,CPythonSkill 的一众 IsXxx() 谓词就是对这两个掩码取位。 +# 这里在 load_file 时做同样的折叠,谓词逐条对齐 PythonSkill.cpp。 extends RefCounted var _by_id := {} @@ -23,6 +29,87 @@ const JOB_DIR := { "SHAMAN": "shaman", } +# --- SKILL_ATTRIBUTE_*(PythonSkill.h:92-115,记号名同 m_SkillAttributeIndexMap)--- +const ATTR := { + "NEED_TARGET": 1 << 0, + "TOGGLE": 1 << 1, + "WEAPON_LIMITATION": 1 << 2, + "MELEE_ATTACK": 1 << 3, + "USE_HP": 1 << 4, + "CAN_CHANGE_DIRECTION": 1 << 5, + "STANDING_SKILL": 1 << 6, + "ONLY_FOR_ALLIANCE": 1 << 7, + "CAN_USE_FOR_ME": 1 << 8, + "NEED_CORPSE": 1 << 9, + "FAN_RANGE": 1 << 10, + "CAN_USE_IF_NOT_ENOUGH": 1 << 11, + "NEED_EMPTY_BOTTLE": 1 << 12, + "NEED_POISON_BOTTLE": 1 << 13, + "ATTACK_SKILL": 1 << 14, + "TIME_INCREASE_SKILL": 1 << 15, + "CHARGE_ATTACK": 1 << 16, + "PASSIVE": 1 << 17, + "CANNOT_LEVEL_UP": 1 << 18, + "ONLY_FOR_GUILD_WAR": 1 << 19, + "MOVING_SKILL": 1 << 20, + "HORSE_SKILL": 1 << 21, + "CIRCLE_RANGE": 1 << 22, + "SEARCH_TARGET": 1 << 23, +} + +# --- CItemData::EWeaponSubTypes(GameLib/ItemData.h:63-75)--- +const WEAPON_SWORD := 0 +const WEAPON_DAGGER := 1 +const WEAPON_BOW := 2 +const WEAPON_TWO_HANDED := 3 +const WEAPON_BELL := 4 +const WEAPON_FAN := 5 +const WEAPON_ARROW := 6 +const WEAPON_NONE := 8 # WEAPON_NUM_TYPES(7) + 1 + +# --- SKILL_NEED_WEAPON_*(PythonSkill.h:120-128,位 = 1 << CItemData::WEAPON_*)--- +# 记号名同 m_SkillNeedWeaponIndexMap;DOUBLE_SWORD 复用 DAGGER 位。 +const NEED_WEAPON := { + "SWORD": 1 << WEAPON_SWORD, + "DAGGER": 1 << WEAPON_DAGGER, + "BOW": 1 << WEAPON_BOW, + "TWO_HANDED": 1 << WEAPON_TWO_HANDED, + "DOUBLE_SWORD": 1 << WEAPON_DAGGER, + "BELL": 1 << WEAPON_BELL, + "FAN": 1 << WEAPON_FAN, + "ARROW": 1 << WEAPON_ARROW, + "EMPTY_HAND": 1 << WEAPON_NONE, +} + +# --- CPythonSkill::ESkillType(PythonSkill.h)+ m_SkillTypeIndexMap(PythonSkill.cpp:909-918)--- +const SKILL_TYPE_NONE := 0 +const SKILL_TYPE_ACTIVE := 1 +const SKILL_TYPE_SUPPORT := 2 +const SKILL_TYPE_GUILD := 3 +const SKILL_TYPE_HORSE := 4 +const _SKILL_TYPE_MAP := { + "none": SKILL_TYPE_NONE, + "active": SKILL_TYPE_ACTIVE, + "support": SKILL_TYPE_SUPPORT, + "guild": SKILL_TYPE_GUILD, + "horse": SKILL_TYPE_HORSE, + "warrior": SKILL_TYPE_ACTIVE, + "assassin": SKILL_TYPE_ACTIVE, + "sura": SKILL_TYPE_ACTIVE, + "shaman": SKILL_TYPE_ACTIVE, + "passive": SKILL_TYPE_ACTIVE, +} + +# 把 `|`(或旧数据里的空格 / 逗号)分隔的记号串折成位掩码。未知记号忽略, +# 对齐原版“map 里查不到就跳过该记号”的行为(PythonSkill.cpp:374-378 / 393-397)。 +static func _fold_bits(tokens: String, name_to_bit: Dictionary) -> int: + var bits := 0 + for raw in tokens.replace(",", "|").replace(" ", "|").split("|", false): + var key := raw.strip_edges().to_upper() + if name_to_bit.has(key): + bits |= int(name_to_bit[key]) + return bits + func load_file(path: String) -> bool: _by_id.clear() count = 0 @@ -43,19 +130,34 @@ func load_file(path: String) -> bool: continue # 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11 # motion_name=12 motion_idx=13 grades=14 + var job := t[1].strip_edges() + var attrs := t[10].strip_edges() if t.size() > 10 else "" + var weapon := t[11].strip_edges() if t.size() > 11 else "" var entry := { "id": id, - "job": t[1].strip_edges(), + "job": job, "name": t[2].strip_edges(), "name2": t[3].strip_edges(), "name3": t[4].strip_edges(), - "attrs": t[10].strip_edges() if t.size() > 10 else "", - "weapon": t[11].strip_edges() if t.size() > 11 else "", + "attrs": attrs, + "weapon": weapon, "motion": t[12].strip_edges() if t.size() > 12 else "", "motion_idx": int(t[13]) if t.size() > 13 else 0, "grades": int(t[14]) if t.size() > 14 else 1, + # §3.8:预折叠的 TSkillData 判定位(对齐 dwSkillAttribute / dwNeedWeapon) + "attr_bits": _fold_bits(attrs, ATTR), + "need_weapon_bits": _fold_bits(weapon, NEED_WEAPON), + "skill_type": int(_SKILL_TYPE_MAP.get(job.to_lower(), SKILL_TYPE_NONE)), } - _load_cooltime(entry) + _load_msk(entry) + # RegisterSkillDesc(PythonSkill.cpp:280):DESC_TOKEN_TYPE LEVEL_LIMIT=26 / + # MAX_LEVEL=27 只在 TSV 列数够时解析。本 40250 shipped skilldesc.txt 只有 22 列, + # 这两列都缺,逻辑保留以对齐参考端、并兼容带这两列的第三方数据。 + # .msk 的 maxlevel(_load_msk 已处理)优先于 skilldesc MAX_LEVEL。 + if not entry.has("max_level") and t.size() > 27 and int(t[27]) > 0: + entry["max_level"] = max(1, int(t[27])) + if t.size() > 26 and int(t[26]) > 0: + entry["level_limit"] = int(t[26]) _by_id[id] = entry count = _by_id.size() return count > 0 @@ -75,27 +177,171 @@ func motion_idx_of(id: int) -> int: func _attrs(id: int) -> String: return String(_by_id.get(id, {}).get("attrs", "")) -func is_attack(id: int) -> bool: - return _attrs(id).contains("ATTACK_SKILL") +# §3.8:折叠后的判定位掩码。取位即原版一众 IsXxx() 谓词(PythonSkill.cpp)。 +func attr_bits(id: int) -> int: + return int(_by_id.get(id, {}).get("attr_bits", 0)) -# 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来, -# 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。 +func need_weapon_bits(id: int) -> int: + return int(_by_id.get(id, {}).get("need_weapon_bits", 0)) + +func _has_attr(id: int, name: String) -> bool: + return (attr_bits(id) & int(ATTR[name])) != 0 + +# --- 逐条对齐 CPythonSkill 谓词(PythonSkill.cpp)--- +func is_attack(id: int) -> bool: # IsAttackSkill + return _has_attr(id, "ATTACK_SKILL") + +func is_passive(id: int) -> bool: # SKILL_ATTRIBUTE_PASSIVE + return _has_attr(id, "PASSIVE") + +func is_toggle(id: int) -> bool: # IsToggleSkill + return _has_attr(id, "TOGGLE") + +func is_standing(id: int) -> bool: # IsStandingSkill + return _has_attr(id, "STANDING_SKILL") + +func is_need_target(id: int) -> bool: # IsNeedTarget + return _has_attr(id, "NEED_TARGET") + +func is_need_corpse(id: int) -> bool: # IsNeedCorpse + return _has_attr(id, "NEED_CORPSE") + +func is_auto_search_target(id: int) -> bool: # IsAutoSearchTarget + return _has_attr(id, "SEARCH_TARGET") + +func can_change_direction(id: int) -> bool: # CanChangeDirection + return _has_attr(id, "CAN_CHANGE_DIRECTION") + +func is_horse_skill(id: int) -> bool: # IsHorseSkill + return _has_attr(id, "HORSE_SKILL") + +func is_moving_skill(id: int) -> bool: # IsMovingSkill + return _has_attr(id, "MOVING_SKILL") + +func is_melee(id: int) -> bool: # IsMeleeSkill + return _has_attr(id, "MELEE_ATTACK") + +func is_charge_skill(id: int) -> bool: # IsChargeSkill + return _has_attr(id, "CHARGE_ATTACK") + +func is_use_hp(id: int) -> bool: # IsUseHPSkill + return _has_attr(id, "USE_HP") + +func is_time_increase(id: int) -> bool: # IsTimeIncreaseSkill + return _has_attr(id, "TIME_INCREASE_SKILL") + +func is_need_empty_bottle(id: int) -> bool: # IsNeedEmptyBottle + return _has_attr(id, "NEED_EMPTY_BOTTLE") + +func is_need_poison_bottle(id: int) -> bool: # IsNeedPoisonBottle + return _has_attr(id, "NEED_POISON_BOTTLE") + +func can_use_for_me(id: int) -> bool: # CanUseForMe + return _has_attr(id, "CAN_USE_FOR_ME") + +func is_only_for_alliance(id: int) -> bool: # IsOnlyForAlliance + return _has_attr(id, "ONLY_FOR_ALLIANCE") + +func can_use_if_not_enough(id: int) -> bool: # CanUseIfNotEnough + return _has_attr(id, "CAN_USE_IF_NOT_ENOUGH") + +func is_only_for_guild_war(id: int) -> bool: # IsOnlyForGuildWar + return _has_attr(id, "ONLY_FOR_GUILD_WAR") + +func is_fan_range(id: int) -> bool: # IsFanRange + return _has_attr(id, "FAN_RANGE") + +func is_circle_range(id: int) -> bool: # IsCircleRange + return _has_attr(id, "CIRCLE_RANGE") + +# GetTargetCount(fSkillPoint)(PythonSkill.cpp:1361):strTargetCountFormula 为空 → 0, +# 否则把 SkillPoint(= LocaleService_GetSkillPower/100)代入公式后向下取整。 +# 扇形/圆形补目标(PythonPlayerSkill.cpp:684 `if (dwTargetMaxCount>0 …)`)就靠这个 > 0。 +func target_count(id: int, level: int) -> int: + var formula := String(_by_id.get(id, {}).get("target_count_formula", "")) + if formula == "": + return 0 + var expression := Expression.new() + if expression.parse(formula, ["SkillPoint"]) != OK: + return 0 + var value: Variant = expression.execute([_skill_point(level)]) + if expression.has_execute_failed() or not (value is float or value is int): + return 0 + return int(floor(float(value))) + +# __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange() +# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成 +# 由 net_play(有玩家状态)叠加。 +func target_range(id: int) -> int: + return int(_by_id.get(id, {}).get("target_range", 0)) + +# skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShape(SINGLE/FAN/CIRCLE)。 +func fly_shape(id: int) -> int: + if is_fan_range(id): + return 1 + if is_circle_range(id): + return 2 + return 0 + +func has_weapon_limitation(id: int) -> bool: # SKILL_ATTRIBUTE_WEAPON_LIMITATION + return _has_attr(id, "WEAPON_LIMITATION") + +# IsCanUseSkill()(PythonSkill.cpp:1407):被动技一律不可主动施放,其余可。 +func is_can_use_skill(id: int) -> bool: + return not _has_attr(id, "PASSIVE") + +# CanUseWeaponType(dwWeaponType)(PythonSkill.cpp:1143):无武器限制恒真, +# 否则 dwNeedWeapon & (1 << dwWeaponType)。 +func can_use_weapon_type(id: int, weapon_type: int) -> bool: + if not has_weapon_limitation(id): + return true + return (need_weapon_bits(id) & (1 << weapon_type)) != 0 + +# IsNeedBow():dwNeedWeapon & SKILL_NEED_WEAPON_BOW。 +func is_need_bow(id: int) -> bool: + return (need_weapon_bits(id) & int(NEED_WEAPON["BOW"])) != 0 + +# 保留旧名:原本靠 weapon 列字符串判远程,现改为查 need-weapon 位(等价,含 ARROW)。 func is_ranged(id: int) -> bool: - return String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW") + return (need_weapon_bits(id) & (int(NEED_WEAPON["BOW"]) | int(NEED_WEAPON["ARROW"]))) != 0 -func is_passive(id: int) -> bool: - return _attrs(id).contains("PASSIVE") +# CPythonSkill::ESkillType:3=SKILL_TYPE_GUILD,4=SKILL_TYPE_HORSE,1=主动,2=辅助。 +func skill_type_of(id: int) -> int: + return int(_by_id.get(id, {}).get("skill_type", SKILL_TYPE_NONE)) -func is_toggle(id: int) -> bool: - return _attrs(id).contains("TOGGLE") +func is_guild_skill(id: int) -> bool: + return skill_type_of(id) == SKILL_TYPE_GUILD -func can_level_up(id: int) -> bool: - return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP") +func can_level_up(id: int) -> bool: # !CANNOT_LEVEL_UP + return _by_id.has(id) and not _has_attr(id, "CANNOT_LEVEL_UP") -# 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式 -# 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END -# 仍是服务器的提前解锁信号。 -func _load_cooltime(entry: Dictionary) -> void: +# SSkillData::GetMaxLevel()(PythonSkill.cpp:1392)= byMaxLevel。优先 .msk 的 +# maxlevel(_load_msk),其次 skilldesc MAX_LEVEL 列,都没有则构造默认 20 +# (SSkillData 构造,PythonSkill.cpp:1462)。 +func max_level_of(id: int) -> int: + return int(_by_id.get(id, {}).get("max_level", 20)) + +# skillGetSkillLevelLimit(PythonSkill.cpp:1741)= byLevelLimit。本 40250 数据里 +# skilldesc 无 LEVEL_LIMIT 列、.msk 也无 levellimit 记号,恒 0;参考端马术页门 +# `GetStatus(LEVEL) >= GetSkillLevelLimit()` 因而恒真。 +func skill_level_limit(id: int) -> int: + return int(_by_id.get(id, {}).get("level_limit", 0)) + +# skillCanLevelUpSkill(PythonSkill.cpp:1833)。本 build 里 isRequirement / +# strRequireSkillName / byRequireSkillLevel / RequireStatDataVector 从不被填充 +# (构造默认 isRequirement=FALSE、空 vector,PythonSkill.cpp:1471), +# .msk / skilldesc 也没有任何前置技能 / 前置属性字段,故 1:1 化简为: +# 未满级(level < GetMaxLevel())且 非 SKILL_ATTRIBUTE_CANNOT_LEVEL_UP。 +func can_level_up_skill(id: int, level: int) -> bool: + return _by_id.has(id) and level < max_level_of(id) and not _has_attr(id, "CANNOT_LEVEL_UP") + +# 读取对应 .msk 中的 CoolTimeFormula / MaxLevel / NeedSPFormula。原客户端用同一份公式 +# 按技能效率百分比计算本地冷却与消耗;这里在发送前 / 后做同样的本地预测, +# GC_SKILL_COOLTIME_END 仍是服务器的提前解锁信号。 +# NeedSPFormula 供 §3.8 修改 3 的 __CheckShortMana / __CheckShortLife +# (参考 GetNeedSP,PythonSkill.cpp:1426;__CheckShortLife 同样取 GetNeedSP, +# PythonPlayerSkill.cpp:359)。 +func _load_msk(entry: Dictionary) -> void: var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), "")) var motion := String(entry.get("motion", "")) if job_dir == "" or motion == "": @@ -115,6 +361,62 @@ func _load_cooltime(entry: Dictionary) -> void: var max_level := _ascii_field(bytes, "MaxLevel") if max_level != "": entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\""))) + var need_sp := _ascii_field(bytes, "NeedSPFormula") + if need_sp != "": + entry["need_sp_formula"] = need_sp.trim_prefix("\"").trim_suffix("\"") + # GetTargetCount / __GetSkillTargetRange 的数据源(.msk 的 TargetCountFormula / Range, + # CMotionSkillData 里对应 strTargetCountFormula / dwTargetRange)。 + var tcf := _ascii_field(bytes, "TargetCountFormula") + if tcf != "": + entry["target_count_formula"] = tcf.trim_prefix("\"").trim_suffix("\"") + var trange := _ascii_field(bytes, "Range") + if trange != "": + entry["target_range"] = max(0, int(trange.trim_prefix("\"").trim_suffix("\""))) + +# LocaleService_GetSkillPower(Locale.cpp:126,INTERNATIONAL 表)—— +# 原客户端 fcurEfficientPercentage = GetSkillPower(level) / 100,公式里的 +# SkillPoint / k 变量取这个值(ProcessFormula,PythonSkill.cpp:1258)。 +const _SKILL_POWER := [ + 0, + 5, 6, 8, 10, 12, + 14, 16, 18, 20, 22, + 24, 26, 28, 30, 32, + 34, 36, 38, 40, 50, # 20 = master + 52, 54, 56, 58, 60, + 63, 66, 69, 72, 82, # 30 = grand master + 85, 88, 91, 94, 98, + 102, 106, 110, 115, 125, # 40 = perfect master + 125, +] + +# fSkillPoint(= fcurEfficientPercentage):level 越界(>= 50,参考 SKILL_POWER_NUM)→ 0。 +func _skill_point(level: int) -> float: + if level < 0 or level >= 50: + return 0.0 + var idx: int = min(level, _SKILL_POWER.size() - 1) + return float(_SKILL_POWER[idx]) / 100.0 + +# GetNeedSP(fSkillPoint)(PythonSkill.cpp:1426):无公式 → 返回 -1(POC 约定: +# 「未知」,调用方按参考里的 `if (-1 != iNeedSP)` 分支不拦截;参考端此处返回 0, +# 但 POC 的 skilldesc/.msk 里不少主动技确实没有 NeedSPFormula,硬套 0 会误挡, +# 故这里显式区分「解析不出」= -1)。公式引用了非 SkillPoint 的状态变量时同样退化 -1。 +func need_sp(id: int, level: int) -> int: + var data := entry(id) + var formula := String(data.get("need_sp_formula", "")) + if formula == "": + return -1 + var expression := Expression.new() + if expression.parse(formula, ["SkillPoint"]) != OK: + return -1 + var value: Variant = expression.execute([_skill_point(level)]) + if expression.has_execute_failed() or not (value is float or value is int): + return -1 + return int(value) + +# __CheckShortLife(PythonPlayerSkill.cpp:359)里 dwNeedHP 也是取 GetNeedSP, +# 不是单独的 HP 公式——照抄这个怪癖。 +func need_hp(id: int, level: int) -> int: + return need_sp(id, level) func _ascii_field(bytes: PackedByteArray, field: String) -> String: var needle := field.to_ascii_buffer() @@ -142,12 +444,12 @@ func cooldown_of(id: int, level: int) -> float: var formula := String(data.get("cooltime_formula", "")) if formula == "": return 0.0 - var max_level: int = max(1, int(data.get("max_level", 20))) - var skill_point: float = clampf(float(level) / float(max_level), 0.0, 1.0) + # §3.8 修改 3:SkillPoint 统一走 LocaleService_GetSkillPower(与 GetNeedSP 同一份 + # fcurEfficientPercentage),不再用 level/max_level 近似。 var expression := Expression.new() if expression.parse(formula, ["SkillPoint"]) != OK: return 0.0 - var value: Variant = expression.execute([skill_point]) + var value: Variant = expression.execute([_skill_point(level)]) if expression.has_execute_failed() or not (value is float or value is int): return 0.0 return maxf(0.0, float(value)) diff --git a/project/ui/skill_ui.gd b/project/ui/skill_ui.gd index 8686a8c1..9a0acb8b 100644 --- a/project/ui/skill_ui.gd +++ b/project/ui/skill_ui.gd @@ -8,11 +8,20 @@ # # 每行:名字 + Lv X(/M/G/P) + [+](被动 / CANNOT_LEVEL_UP 无加点)+ [被动]/[切换] 标签。 # 点名字 -> drag_skill_id(quickbar 落点读)。`skills_changed` 刷新。 +# §3.10:[+] 的显隐逐行对齐 uicharacter.py RefreshSkillPlusButtonList / +# __RefreshSkillPlusButton / CanShowPlusButton —— 技能组门(get_skill_group()==0)、 +# 三个技能点来源分开取、skillGrade==0、马术页 skillLevel<20、主动页 CanLevelUpSkill、 +# 辅助页 HIDE_SUPPORT_SKILL_POINT 整页隐藏。升级结果仍由 GC_SKILL_LEVEL 回包驱动。 extends Node const TABS := ["主动", "辅助", "坐骑"] const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3 +# uicharacter.py:21 HIDE_SUPPORT_SKILL_POINT —— 本 migration 资源为 True,辅助页整页不出加号。 +const HIDE_SUPPORT_SKILL_POINT := true +# uicharacter.py __RefreshSkillPlusButton 的 HORSE 分支硬编码 `if skillLevel < 20`。 +const RIDING_SKILL_MAX_LEVEL := 20 + var client: Node var table: RefCounted # SkillTable var ui: CanvasLayer # UiManager @@ -161,7 +170,59 @@ func refresh() -> void: for s in client.get_skills(): lv[int(s["id"])] = int(s["level"]) ms[int(s["id"])] = int(s.get("master", 0)) + # RefreshSkillPlusButtonList(uicharacter.py:984)→ __RefreshSkillPlusButton(:937): + # 每页的技能点来源分开取,技能组未选时整列隐藏加号。 + var page := _cat_key() + var can_use_now := _can_use_skill_now() + var stat_point := _stat_point_for(page) + var plv := _player_level() for id in _rows: var lvl: int = lv.get(id, 0) var m: int = clampi(ms.get(id, 0), 0, 3) _rows[id]["lv"].text = "Lv %d%s" % [lvl, MASTER_SUFFIX[m]] + var up: Button = _rows[id]["up"] + if up != null: + up.visible = _can_show_plus(id, lvl, m, page, can_use_now, stat_point, plv) + +# __CanUseSkillNow(uicharacter.py:1270):net.GetMainActorSkillGroup()==0 -> False。 +func _can_use_skill_now() -> bool: + if client.has_method("get_skill_group"): + return int(client.get_skill_group()) != 0 + return true + +# skillPageStatDict(uicharacter.py:225):SUPPORT=POINT_SUB_SKILL(27) / +# ACTIVE=POINT_SKILL(28) / HORSE=POINT_HORSE_SKILL(113)。三个来源分开取,不合并成一个。 +func _stat_point_for(page: String) -> int: + if not client.has_method("get_points"): + return 1 + var p: Dictionary = client.get_points() + match page: + "SUPPORT": return int(p.get("skill_support", 0)) + "HORSE": return int(p.get("skill_horse", 0)) + _: return int(p.get("skill_active", 0)) + +func _player_level() -> int: + if not client.has_method("get_points"): + return 1 + return int(client.get_points().get("level", 1)) + +# __RefreshSkillPlusButton 每槽体(uicharacter.py:937)+ CanShowPlusButton(:925): +# 技能组未选 -> 全隐藏;辅助页 HIDE_SUPPORT_SKILL_POINT -> 整页隐藏; +# statPoint <= 0 -> 不显示;skillGrade != 0(已过 0 级,走大师书)-> 跳过; +# HORSE 页:GetStatus(LEVEL) >= GetSkillLevelLimit(idx) 时,skillLevel < 20 才显示; +# 其余页:skill.CanLevelUpSkill(idx, level)。 +func _can_show_plus(id: int, level: int, grade: int, page: String, + can_use_now: bool, stat_point: int, plv: int) -> bool: + if not can_use_now: + return false + if page == "SUPPORT" and HIDE_SUPPORT_SKILL_POINT: + return false + if stat_point <= 0: + return false + if grade != 0: + return false + if page == "HORSE": + if plv >= table.skill_level_limit(id): + return level < RIDING_SKILL_MAX_LEVEL + return false + return table.can_level_up_skill(id, level) diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd index 7b007d2b..bc7af369 100644 --- a/project/ui/ui_manager.gd +++ b/project/ui/ui_manager.gd @@ -35,6 +35,7 @@ func _ready() -> void: _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.visible = false _root.add_child(_dim) + set_process_input(true) set_process_unhandled_input(true) # --- open / close -------------------------------------------------------- @@ -85,6 +86,32 @@ func close_top() -> bool: func top() -> Control: return _stack[-1] if not _stack.is_empty() else null +# wndMgr 的输入边界:打开的窗口先拿到键盘 / 鼠标语义,世界控制不能从窗口 +# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗; +# 模态确认框只允许 ESC 回到窗口栈。 +func blocks_game_input(event: InputEvent) -> bool: + if _stack.is_empty(): + return false + var top_window: Control = _stack[-1] + if top_window == null or not is_instance_valid(top_window) or not top_window.visible: + return false + if bool(top_window.get_meta("modal", false)): + return not (event is InputEventKey and event.keycode == KEY_ESCAPE) + if event is InputEventMouse: + return top_window.get_global_rect().has_point(event.position) + if event is InputEventKey: + if event.keycode == KEY_ESCAPE: + return false + return event.keycode not in [KEY_I, KEY_K, KEY_V, KEY_N, KEY_B, KEY_L, KEY_M, KEY_O] + return false + +func _input(event: InputEvent) -> void: + # Keyboard events do not automatically enter Control.gui_input. Consume + # blocked keys here, before PlayerController/GameCamera receive them through + # _unhandled_input; mouse events remain available to child controls. + if event is InputEventKey and blocks_game_input(event): + get_viewport().set_input_as_handled() + func _restack() -> void: var any_modal := false for i in _stack.size(): diff --git a/project/ui/view_equipment_ui.gd b/project/ui/view_equipment_ui.gd index 858b349f..4be42077 100644 --- a/project/ui/view_equipment_ui.gd +++ b/project/ui/view_equipment_ui.gd @@ -3,11 +3,13 @@ extends Node const WEAR_NAMES := ["身体", "头部", "鞋子", "护腕", "武器", "项链", "耳环", "盾牌", "饰品 1", "饰品 2", "箭矢"] +const ItemTooltip = preload("res://ui/item_tooltip.gd") var client: Node var ui: CanvasLayer var proto: Node var item_list: RefCounted +var _tooltip_builder: RefCounted var _window: Control func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void: @@ -15,6 +17,8 @@ func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: Re ui = manager proto = proto_node item_list = il + _tooltip_builder = ItemTooltip.new() + _tooltip_builder.setup(AssetRoot.path() if ClassDB.class_exists("AssetRoot") else "", "en", proto) if client.has_signal("view_equipment"): client.view_equipment.connect(show_for) @@ -94,6 +98,9 @@ func _item_name(vnum: int, count: int) -> String: return "%s x%d" % [name, count] if count > 1 else name func _item_tooltip(d: Dictionary) -> String: + var vnum := int(d.get("vnum", 0)) + if _tooltip_builder and proto and proto.has_method("item"): + return _tooltip_builder.format(vnum, int(d.get("count", 1)), proto.item(vnum), d) var attrs: Array = d.get("attrs", []) if attrs.is_empty(): return "" diff --git a/project/world_probe.gd b/project/world_probe.gd index a57ddffd..e5aa30bf 100644 --- a/project/world_probe.gd +++ b/project/world_probe.gd @@ -21,9 +21,10 @@ func _init() -> void: print(" ", k, " = ", rep[k]) var terr: int = 0 - for c in w.get_children(): - if String(c.name).begins_with("Terrain_"): - terr += 1 + # Terrain_* now lives below Chunk_* roots so unloading can free a whole + # streamed chunk. Count recursively; direct-child counting reports a false 0. + for c in w.find_children("Terrain_*", "MeshInstance3D", true, false): + terr += 1 print("terrain MeshInstance3D children: ", terr) var h1: float = w.call("sample_height", 128.0, 128.0) @@ -33,7 +34,9 @@ func _init() -> void: # 顶视正交截图(W2 与 minimap 对齐用) _shot(w, rep) - var expect: int = int(rep["map_size_x"]) * int(rep["map_size_y"]) + var expected_chunks: int = int(rep["map_size_x"]) * int(rep["map_size_y"]) + var patch_side := maxi(1, int(w.get("terrain_patches"))) + var expect: int = expected_chunks * patch_side * patch_side var pass_ok: bool = ok and terr == expect and int(rep["chunks_failed"]) == 0 \ and h1 != 0.0 and h_out == 0.0 print("RESULT: ", "PASS" if pass_ok else "FAIL")