diff --git a/assets/scratch_gauge.png b/assets/scratch_gauge.png
new file mode 100644
index 00000000..7110ba3d
Binary files /dev/null and b/assets/scratch_gauge.png differ
diff --git a/assets/uiscript/uiscript/systemdialog.py b/assets/uiscript/uiscript/systemdialog.py
index 7d2c108e..6800cdd5 100644
--- a/assets/uiscript/uiscript/systemdialog.py
+++ b/assets/uiscript/uiscript/systemdialog.py
@@ -6,11 +6,11 @@ window = {
"name" : "SystemDialog",
"style" : ("float",),
- "x" : (SCREEN_WIDTH - 200) /2,
- "y" : (SCREEN_HEIGHT - 288) /2,
+ "x" : SCREEN_WIDTH/2 - 100,
+ "y" : SCREEN_HEIGHT/2 - 114,
"width" : 200,
- "height" : 288,
+ "height" : 258,
"children" :
(
@@ -22,7 +22,7 @@ window = {
"y" : 0,
"width" : 200,
- "height" : 288,
+ "height" : 258,
"children" :
(
@@ -39,27 +39,13 @@ window = {
"over_image" : ROOT + "XLarge_Button_02.sub",
"down_image" : ROOT + "XLarge_Button_03.sub",
},
- {
- "name" : "mall_button",
- "type" : "button",
-
- "x" : 10,
- "y" : 57,
-
- "text" : uiScriptLocale.SYSTEM_MALL,
- "text_color" : 0xffF8BF24,
-
- "default_image" : ROOT + "XLarge_Button_02.sub",
- "over_image" : ROOT + "XLarge_Button_02.sub",
- "down_image" : ROOT + "XLarge_Button_02.sub",
- },
{
"name" : "system_option_button",
"type" : "button",
"x" : 10,
- "y" : 87,
+ "y" : 57,
"text" : uiScriptLocale.SYSTEMOPTION_TITLE,
@@ -72,7 +58,7 @@ window = {
"type" : "button",
"x" : 10,
- "y" : 117,
+ "y" : 87,
"text" : uiScriptLocale.GAMEOPTION_TITLE,
@@ -85,7 +71,7 @@ window = {
"type" : "button",
"x" : 10,
- "y" : 147,
+ "y" : 117,
"text" : uiScriptLocale.SYSTEM_CHANGE,
@@ -98,7 +84,7 @@ window = {
"type" : "button",
"x" : 10,
- "y" : 177,
+ "y" : 147,
"text" : uiScriptLocale.SYSTEM_LOGOUT,
@@ -111,7 +97,7 @@ window = {
"type" : "button",
"x" : 10,
- "y" : 217,
+ "y" : 177,
"text" : uiScriptLocale.SYSTEM_EXIT,
@@ -124,7 +110,7 @@ window = {
"type" : "button",
"x" : 10,
- "y" : 247,
+ "y" : 217,
"text" : uiScriptLocale.CANCEL,
diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md
index 09e9767d..81057d3c 100644
--- a/docs/CLIENT-GAP.md
+++ b/docs/CLIENT-GAP.md
@@ -7,7 +7,7 @@
> 本文按“协议、数据、交互、表现均与原客户端等价”的严格 1:1 标准审计;
> `CLIENT-ROADMAP.md` 中的“P0–P10 首版完成”只表示主要纵向流程已有首版,不等于 1:1 完成。
> 渲染细节另见 `PARITY-GAP.md`,动画、材质和资产管线另见 `BACKLOG.md`,网络实现另见
-> `CLIENT-PORT.md`。
+> `CLIENT-40250-PORT.md`。
>
> **本文末「ClientVS22 实现细节审计(2026-09-02)」每一条的具体修改细节见
> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md)**(逐条给出 40250/ClientVS22 的参考
@@ -1604,8 +1604,9 @@
> ⑦ **武器挂点复核**:隔离探针实测 weapon MI 原点 == `equip_right_hand` 骨骼原点
> Δ≤0.06 m(旧「偏 10 m」为 cpu_skin / MapCoord 修复前的失效结论);新增 grip
> pre-transform `weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`(对齐
-> `ModelInstanceUpdate.cpp:171` 的 `weaponComposite[0]`),30 cm 位移夹断退回原始手部位姿。
-> `03150` 类网格在 .gr2 里就离骨骼原点 ~3 m(资产缺陷,非变换)。
+> `ModelInstanceUpdate.cpp:171` 的 `weaponComposite[0]`)。2026-09-15 删除错误的 30 cm
+> 位移夹断:`03150` 的原始顶点虽离原点约 3 m,但文件内逆绑定矩阵正用于抵消该偏移;
+> `weapon_attach_test.gd` 现检查真实可见 AABB 在待机/走/跑/攻击中均接触手骨。
> 新增测试 `eterngrn_polish_test.gd`;`ctest 10/10` + 29 套 GDScript + iOS 全绿。
> - **2026-08-31 增量 38(ESC 系统菜单 + 游戏设置窗)**:
> ① **`system_menu_ui.gd`**(`systemdialog.py` 真 uiscript,thinboard 8 键)= `uisystem.py`
diff --git a/docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md b/docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md
new file mode 100644
index 00000000..de9ea7f0
--- /dev/null
+++ b/docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md
@@ -0,0 +1,89 @@
+# 40250 客户端对拍缺陷发现与修复工程指南 (CLIENT-PARITY-AUDIT-AND-FIX-GUIDE)
+
+> **目标**:建立系统化的排查、探针探测与修复流程,解决 Godot 客户端从 40250 移植过程中由于“管线实现不完整”、“只实现裸数据漏掉复合层”导致的隐性细节缺陷(典型案例如:桥梁可行走但高度采样遗漏 `.mdatr` 导致掉落穿透)。
+
+---
+
+## 一、问题本质剖析:为何会出现此类缺陷?
+
+在 Metin2 40250 原版中,几乎所有物理、移动、战斗与视觉表现都是**“多层复合叠加”**的。在向 Godot 移植的早期原型阶段,很多系统往往只实现了最底层的第 1 层,缺少了第 2 层和第 3 层的复合逻辑:
+
+```mermaid
+flowchart TD
+ subgraph 原版 40250 表现
+ L1["基础数据层 (如: PRTerrain height.raw 裸高度图)"]
+ L2["静态实体层 (如: CGraphicThingInstance / .mdatr 高度多边形)"]
+ L3["动态状态层 (如: 骨骼动作高度、骑乘偏置、水体/浮力判定)"]
+ L1 --> L2 --> L3 --> R1["最终真实表现 (如: 角色稳稳立于桥面)"]
+ end
+
+ subgraph 缺陷实现模式
+ D1["基础数据层 (fmt::terrain_height_at)"]
+ D1 --> R2["直接作为最终结果 (角色跌落到桥底裸地形)"]
+ end
+```
+
+如果仅依赖玩家跑图偶然触发,发现成本极高且容易重复反弹。必须转为**白盒走查、探针自动化与协议日志审计**。
+
+---
+
+## 二、高危缺陷领域清单与 40250 真实机制对照
+
+| 领域 | 常见隐性缺陷表现 | 40250 原版真实机制(唯一真相源) | 关键代码位置 |
+| :--- | :--- | :--- | :--- |
+| **1. 空间与地貌 (Terrain & Objects)** | • 桥梁、楼梯、门廊台阶悬空或掉落穿透
• 水陆交界处判断错误(干地游泳或踩深水不减速/不可钓鱼)
• 地下城/山洞进出门廊穿透掉落 | • `CMapOutdoor::GetHeight` 查询 `.mdatr` 中的 `heights` 网格多边形,与裸高度图取 `fMAX`
• `attr.atr` 中的 `ATTRIBUTE_WATER` 与水面多边形绝对高度差
• `CDungeonBlock::GetObjectHeight` | `GameLib/MapOutdoor.cpp`
`EterLib/AttributeInstance.cpp`
`GameLib/AreaTerrain.cpp` |
+| **2. 物理与移动 (Physics & Locomotion)** | • 遇到陡坡时直接穿山或以相同速度瞬移上山
• 被怪物或技能击退(Knockback)时穿透墙体飞出地图
• 自动寻路走直线卡死在栅栏/岩石缝隙 | • `ActorInstanceSync.cpp` 坡度倾角与重力滑落向量计算
• `ActorInstanceCollisionDetection.cpp` 击退时沿障碍物法线切向滑动
• `CMapOutdoor::is_blocked` 的栅格阻挡光栅化与逐轴滑动降级 | `GameLib/ActorInstanceSync.cpp`
`GameLib/ActorInstanceCollisionDetection.cpp` |
+| **3. 动作与挂点 (Animation & Attachment)** | • 武器/盾牌朝向偏转或拿反、脱离手部
• 骑马时双手未握缰绳或双腿穿透马鞍
• 更换头盔/发型时头部穿模或发色异常 | • `CItemData::GetItemScale` 与骨骼挂点矩阵 `m_matBone` 变换
• `InstanceBaseMotion.cpp` 的骑乘姿势 override (`COMBO_TYPE_HORSE`)
• `CRaceData::SetHair` 的 MSM 顶点隐藏机制与 Alpha Blending 管道 | `GameLib/ActorInstanceAttach.cpp`
`UserInterface/InstanceBaseMotion.cpp`
`GameLib/RaceData.cpp` |
+| **4. 战斗与射弹 (Combat & Flying)** | • 弓箭/法球穿透墙体或直接打入地底
• 普攻挥刀动作还没劈下来伤害数值已提前跳出
• 怪物死后不立刻倒地或死亡姿势嵌入地面 | • `CFlyingInstance::Update` 中的 `BackgroundHit` 碰撞(与地面/建筑交点)
• `.msa` 中的 `MotionEvent`(命中帧 `HIT` 严格帧事件驱动)
• `ActorInstanceBlend.cpp` 死亡动作的地面投影与骨骼终帧冻结 | `GameLib/FlyingInstance.cpp`
`GameLib/ActorInstanceBattle.cpp`
`GameLib/ActorInstanceBlend.cpp` |
+| **5. 交互与拾取 (Picking & Decal)** | • 鼠标点击桥面、屋顶或 NPC 拾取点偏移到桥下
• 地面范围技能光圈(Decal)漏到桥底或悬空 | • `CMapOutdoor::GetPickingPointWithRay` 射线先求交物体再求交地形
• `CTerrainDecal` 的多边形表面投影裁切 | `GameLib/MapOutdoor.cpp`
`GameLib/TerrainDecal.cpp` |
+
+---
+
+## 三、四大系统化排查手段(主动发现)
+
+### 手段 1:全图几何与阻挡坐标探针(Headless Coordinate Probes)
+由于地图静态放置数据由 `areadata.txt` 和 `attr.atr` 完全定义,无需人工跑图,可由自动化脚本扫盘检测:
+1. **特殊静态模型巡检**:
+ - 提取地图上全部引用了含有 `.mdatr`(带有 `heights` 网格)的物体(桥梁、台阶、悬崖栈道等)。
+ - 在该物体几何中心上方向下打垂直射线或采样 `sample_height(x, z)`。
+ - **断言**:采样高度必须落在物体表面标高区间内,与裸地形高度应有明显的阶跃高差;若 `sample_height == terrain_height`,直接报警并定位对应模型路径与坐标。
+2. **地貌高度一阶导数(梯度突变)扫描**:
+ - 按 0.5m 步长对可通行路网进行网格采样。
+ - 检测相邻点高度差:若连续两点高度差 $> 2.5\text{m}$ 且未标记 `ATTRIBUTE_BLOCK`,判定为地貌穿透或缝隙掉落点。
+
+### 手段 2:40250 核心链路白盒对拍(Static Call-Graph Audit)
+以 40250 源码的关键函数为索引,走查当前客户端对应的实现:
+- **查阅 40250 源码**:`40250/Server Client TMP4/ClientVS22/source/`
+- **重点审查模式**:
+ - 40250 中包含 `fMAX(...)`、`min(...)`、多层分支判断的函数,当前客户端是否只实现了其中单条简单路径?
+ - 40250 中从资源读取参数的逻辑(如 `.msa` 帧事件、`.msm` 挂点、`.mdatr` 碰撞体),当前客户端是否被硬编码(hardcoded)常量替代?
+
+### 手段 3:未决协议与资源缺失日志审计(Log & Protocol Auditing)
+在测试与实机运行期间,关注并收集控制台日志中的警告:
+- 过滤关键 Warning:`unhandled packet`、`missing model`、`cannot find mdatr`、`mesh has no height`。
+- 检查服务端发送的所有 GC 封包(如 `GC_MOVE`, `GC_SYNC_POSITION`, `GC_ATTACK`, `GC_FLY_TARGETING`),确认不仅有网络层的解析反序列化,更在表现层驱动了正确的物理与动画状态。
+
+### 手段 4:自动化无头路网巡逻测试(Headless Patrol Suite)
+利用 Godot `--headless` 启动无头客户端:
+- 让角色沿关键地貌路网(广场 $\to$ 跨河桥梁 $\to$ 仓库台阶 $\to$ 城门通道 $\to$ 郊外野怪区)自动移动。
+- 监控指标:
+ - 角色 Y 坐标是否发生非预期的下坠(掉水、穿地);
+ - `_blocked` 是否产生虚假阻挡导致角色提前停步卡死;
+ - 状态机是否在 `walk` / `run` / `wait` 之间异常震荡。
+
+---
+
+## 四、工程化修改与防回退治理原则
+
+每次发现并修复类似细节问题时,必须严格执行以下三条工程原则:
+
+### 原则 1:拒绝打补丁式硬编码(Treat the Root, Never Patch Coordinates)
+- **禁止**针对个别坐标做特殊条件判断(如 `if x > 750 and x < 770: pos.y = 206.0`)。
+- **必须**在底层管线实现通用机制(如在 C++ 引擎层解析 `.mdatr` 的 `heights` 并求交平面),确保全服数十张地图、数百座桥梁与所有建筑台阶自动获得修复。
+
+### 原则 2:路线 A(严格 40250 原版实现)
+- 不凭空发明“更好”的经验算法,遇到模糊细节直接翻阅 40250 对应 C++ 模块源码,将原版的数学公式、坐标系旋转(Z-up 转 Y-up、米制转换)与浮点容差 1:1 映射。
+
+### 原则 3:修一处问题,必补一个 Parity 自动化回归测试
+- 修复缺陷后,必须在 `project/` 下建立对应的 `test_*_parity.gd` 单元/集成测试(例如针对桥梁建立的 `test_bridge_height_parity.gd`)。
+- 将测试纳入持续集成验证集(134+ Parity 测试套件),任何一次打包或构建前运行,确保旧缺陷绝不回退。
diff --git a/docs/CLIENT-PORT.md b/docs/CLIENT-PORT.md
deleted file mode 100644
index 95bc777a..00000000
--- a/docs/CLIENT-PORT.md
+++ /dev/null
@@ -1,252 +0,0 @@
-# 客户端移植 —— 从素材渲染器到能联机的客户端
-
-> **适用范围(2026-09-08 复核)**:本文描述的是 **m2dev fork 协议**后端。该后端**已不是默认**——
-> `project/client_main.gd:14-15` 在 `MT_PROTOCOL` 为空时无条件兜底成 `classic`,当前默认走
-> 40250 经典协议。迁移方案与 classic 后端的协议细节见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md)。
-> 本文仍然有效的部分:m2dev 后端自身的握手 / 封包 / 相位编排(`MT_PROTOCOL=m2dev` 时仍可切回),
-> 以及 §2 记录的移植进度。**不要**把本文的封包号或 `Packet.h` 依据用到 classic 后端上。
-
-> 起点:本仓库(`metin2-client`)的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
-> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
-> 物品、战斗、聊天都以封包为数据源。
->
-> 玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
-> [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
-
-## 0. 目标服务端
-
-- LAN:`192.168.21.203`。~~握手端口 connection refused~~ —— **已解决(2026-08-30)**:真机
- 端到端已打通到 PHASE_GAME,见本文 §3。
-- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`。
-- 测试账号:`admin` / `123456789`。
-
-## 1. 协议(本 m2dev fork,**不是老 Metin2**)
-
-### 1.1 帧格式
-
-每个包 = `[uint16 header LE][uint16 length LE][payload...]`,`length` = 含 4 字节前缀的整包长度
-(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 `CG::`/`GC::` 命名空间常量
-(`UserInterface/Packet.h` + `EterLib/ControlPackets.h`)。
-
-### 1.2 加密握手(libsodium)
-
-`EterBase/SecureCipher` —— X25519 密钥交换(`crypto_kx`)+ 每方向固定 nonce 的 XChaCha20
-流加密(带运行字节计数,**顺序敏感**)+ `crypto_auth` HMAC 挑战应答 + XChaCha20-Poly1305
-AEAD 加密 session token。
-
-握手序列(连上 auth server 后):
-
-| 方向 | 包 | header | 动作 |
-|---|---|---|---|
-| S→C | `GCPhase{phase=PHASE_HANDSHAKE}` | `GC_PHASE` 0x0008 | 进握手态 |
-| S→C | `GCKeyChallenge{server_pk[32], challenge[32], server_time}` | `GC_KEY_CHALLENGE` 0x000B | `Initialize()`;`compute_client_keys(server_pk)` |
-| C→S | `CGKeyResponse{client_pk[32], challenge_response[32]}` | `CG_KEY_RESPONSE` 0x000A | `get_public_key()`;`compute_challenge_response(challenge)` |
-| S→C | `GCKeyComplete{encrypted_token[48], nonce[24]}` | `GC_KEY_COMPLETE` 0x000C | `decrypt_token()`→token;`set_activated(true)`;解密已缓冲的剩余字节 |
-| S→C | `GCPhase{phase=PHASE_AUTH}` | `GC_PHASE` | 进认证态 |
-| C→S | `CGLogin3{name[31], pwd[17]}` | `CG_LOGIN3` 0x0102 | 明文账号密码(**此时流已加密**)|
-| S→C | `GCAuthSuccess{login_key, result}` | `GC_AUTH_SUCCESS` 0x0108 / 失败 `GC_LOGIN_FAILURE` 0x0106 | `result` 真→拿 `login_key` |
-
-拿到 `login_key` 后断开 auth,连**游戏服**(`:11011`),同样握手,然后 `CGLogin2{name, login_key}`
-(`CG_LOGIN2` 0x0101)→ 选人 → 加载 → 进游戏相位。
-
-> S→C 流 nonce `[0]=0x01`,C→S `[0]=0x02`。`ping`(`GC_PING` 0x0007)→`pong`(`CG_PONG` 0x0006) 全程要回。
-
-## 2. 进度
-
-| 模块 | 状态 | 位置 |
-|---|---|---|
-| `SecureCipher`(KX + 流 + AEAD token) | ✅ 端口完成,`net.cipher_roundtrip` CTest(KX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) | `extension/src/net/secure_cipher.{h,cpp}` |
-| `wire.h`(帧 + 控制 + KX + auth 结构体) | ✅ `#pragma pack(1)` 尺寸断言 | `extension/src/net/wire.h` |
-| `ByteBuffer`(收发暂存) | ✅ header-only | `extension/src/net/byte_buffer.h` |
-| libsodium 接入 | ✅ 从源码 vendor(`extension/third_party/libsodium-cmake` + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 `docs/THIRD-PARTY.md` | `extension/third_party/CMakeLists.txt` |
-| `NetStream`(非阻塞 POSIX socket + `process()` 循环 + 帧解析 + 内建控制面 handler:`GC_PHASE` / `GC_PING`→`CG_PONG` / KX 握手 / 激活后解密已缓冲字节) | ✅ 编译通过 | `extension/src/net/net_stream.{h,cpp}` |
-| `AuthClient`(auth 相位:`PHASE_AUTH`→发 `CGLogin3`→收 `GCAuthSuccess`/`GCLoginFailure`) | ✅ 编译通过,等可达服务端端到端测 | `extension/src/net/auth_client.h` |
-| `GameClient`(游戏服:`PHASE_LOGIN`→发 `CGLogin2{name, login_key}`→收 `GCLoginSuccess3` 角色列表→`select_character()`→`PHASE_SELECT/LOADING/GAME`) | ✅ 编译通过。角色列表按 `TSimplePlayerInformation`(pack(1),103B)解析成 `{index,id,name,job,level,x,y}`;游戏相位包暂只回 PING、记未知 header | `extension/src/net/game_client.h` |
-| `net_probe` 诊断工具 | ✅ `net_probe `,打印 state / 握手 / 登录结果 / login_key | `extension/tools/net_probe.cpp` |
-| **`M2Client` GDExtension 节点** | ✅ 已注册可用。`connect_to_server(...)` 编排 Auth→(`auth_ok`)→GameConnect→游戏相位;`select_character(index)`。信号 `stage_changed` / `phase_changed` / `auth_ok` / `login_failed` / `char_list` / `entered_game` / `disconnected`。生命周期(F5):`suspend()`/`resume()`/`reconnect()` + `suspended`/`resumed` 信号,`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 自动触发。烟测:正确报 `auth_connect → login_failed(Connection refused) → failed` | `extension/src/net/m2_client.{h,cpp}` |
-| **`M2Client` 游戏内 API** | ✅ 出站 `move(func,arg,rot_deg,x,y)`(rot `/5`、`time` 取帧钟)/ `attack(motion,vid)` / `set_target(vid)` / `say(type,text)`,未入局返回 false。快照 `get_entities()` / `get_entity(vid)`(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ `get_main_vid()` / `get_points()`(HP/SP/level/exp/gold)/ `get_target()`(vid + hp%)。`pump_game()` 每帧 `EntityStore::set_now/tick` 并抽信号:`entity_spawned/despawned/moved/main_set`、`chat`、`vitals_changed(vid)`、`entity_dead(vid)`(首次去重)、`damage(vid,amount,flag)`、`motion(vid,victim,motion)`、`points_changed(Dict)`、`target_info(vid,hp%)`。`net.loopback_flow` 扩展:出站 `CG_MOVE/ATTACK/TARGET/CHAT` 经加密信道字节级校验 | `extension/src/net/m2_client.{h,cpp}` |
-| **游戏相位封包结构** | ✅ `wire.h`:`GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_DEL` / `GC_CHARACTER_UPDATE` / `GC_MOVE` / `GC_CHAT` + `CG_MOVE` / `CG_ATTACK` / `CG_TARGET` / `CG_CHAT`。**战斗/状态**(对 `Packet.h`):`GC_PLAYER_POINTS`(0x0214, int32[255] 1024B) / `GC_PLAYER_POINT_CHANGE`(0x0215) / `GC_STUN`(0x0216) / `GC_DEAD`(0x0217) / `GC_MOTION`(0x0307) / `GC_DAMAGE_INFO`(0x0410) / `GC_TARGET_INFO`(0x0A10) + `EPointTypes`(HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ `EDamageFlag`(NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 `static_assert` 尺寸 | `extension/src/net/wire.h` |
-| **`EntityStore`(封包→世界模型)** | ✅ headless。`apply()` 消化上述包 → `vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned}`;`GC_MOVE` FUNC_MOVE 插值/其他 func 吸附;`tick()` 按 `now_ms` 插值。战斗:`GC_PLAYER_POINTS`→`points()` 全量属性 + 镜像到主角实体;`GC_PLAYER_POINT_CHANGE`→按 `EPointTypes` 更新单实体 vital;`GC_DEAD`/`GC_STUN`→标记;拉取口 `drain_changes` / `drain_chat` / `drain_vitals` / `drain_damage` / `drain_motions` / `take_points_dirty` / `take_target_dirty`。单测 `net.entity_store` 扩展覆盖全部战斗包 | `extension/src/net/entity_store.{h,cpp}` |
-| **`CG_*` 出站构造** | ✅ `GameClient::send_move/send_attack/send_target/send_chat`(`CG_CHAT` 变长) | `extension/src/net/game_client.h` |
-| **环回集成测试** | ✅ `net.loopback_flow` CTest:进程内 `MockServer`(真协议:帧 + libsodium KX + 脚本相位包)驱动 `AuthClient`→`GameClient` 走完 auth→角色列表→选人→`PHASE_GAME`→游戏包进 `EntityStore`→NPC 移动插值。服务端一通改个 IP 即可 | `extension/tests/net_loopback_test.cpp` |
-| **`EntityStore` 单测** | ✅ `net.entity_store` CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn | `extension/tests/net_entity_test.cpp` |
-| **`NetWorld` 场景桥(GDScript)** | ✅ `net_world.gd`:消费 M2Client 实体信号,每 vid 一个子节点,位置向 `get_entity(vid).pos` 平滑逼近(超 `snap_dist` 瞬移)、`rotation.y` 取自 `angle_deg`、动画状态取自 `func`。默认「占位胶囊 + 名字牌 + HP 条」;`set_model_factory(cb)` 换真模型。**战斗**:`vitals_changed`→缩放 HP 条 + emit `vitals(vid,hp,max_hp,dead)`;`damage`→实体头顶飘字(crit 放大变金 / dodge=MISS,tween 上浮淡出)+ emit `damage_number`;`entity_dead`→倒地 + `set_anim_state("dead")`。`main_entity_ready` / `chat_line` 信号。headless 自检 `netbridge_test.gd`(spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 | `project/net_world.gd` |
-| **P0 闭环胶水 + 场景组装** | ✅ `net_play.gd`:`player_controller` ↔ `M2Client` —— 点地→`move(FUNC_MOVE)`(节流)/ 停→`move(FUNC_WAIT)` / 点实体→`set_target` + 进 2.5m 自动 `attack` / 收自己 `GC_MOVE`→位置校正 / `points_changed`+`vitals_changed`+`target_info`→HUD。`hud.gd` 加 `set_vitals/set_exp/set_level/set_target/clear_target`。`game_scene.gd`(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,`login.gd` 的 `entered_game`(`--auto` 自动选槽)实例化。`netplay_test.gd` + `gamescene_test.gd` headless 全过 | `project/{net_play,game_scene,hud,login}.gd` |
-| **P1 UI 工具层** | ✅ `ui/uiscript.gd` 解析原始 `uiscript/*.py`(子集解释器,**80/80 真实文件通过**)+ `ui/ui_build.gd` dict→Control 树(~20 种 type)+ `ui/ui_assets.gd`(`.sub`/tga/png)+ `ui/ui_manager.gd`(窗口栈 / ESC / modal / 拖动)+ `ui/dialogs.gd`。`ui_test.gd` 全过 | `project/ui/*.gd` |
-| **P2 物品数据层** | 🟡 `wire.h`:`GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET` + `CG_ITEM_MOVE/USE/DROP/PICKUP`(全 static_assert)。`EntityStore`:`inventory[90]` / `equipment[11]` / ground map + `drain_inv/ground/item_events`。`M2Client`:`get_inventory/equipment/item/ground_items` + `move_item/use_item/drop_item/pickup_item` + 5 信号。新 `Metin2Proto` 节点(`item(vnum)`/`mob(vnum)`,载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 | `extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*}` |
-| **P3 聊天** | ✅ `wire.h` `GC_WHISPER`/`CG_WHISPER` + `EntityStore` whisper 解析(`ChatMsg` 加 from/sub)+ `M2Client.whisper()` + `whisper_received` 信号。`ui/chat_ui.gd`:4 标签 + 前缀 `/w`/`/g`/`/p`/`/s` + 系统/战斗日志。`net_world._bubble` 头顶气泡 + `main_bubble`。`chat_test.gd` 全过 | `extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*}` · `project/ui/chat_ui.gd` |
-| **P4 战斗表现** | 🟡 `wire.h` `GC_AFFECT_ADD/REMOVE` + `EntityStore` affect map + `M2Client.get_affects()` / `affect_added`·`affect_removed`。受击硬直(`net_play` + `pc.frozen`)· combo(motion 递增)· 相机抖动(`game_camera.shake`)· 死亡窗(`ui/death_ui.gd` → `/restart_here`·`/restart_town`)· `hud.set_affects` 图标条。`combat_fx_test.gd` 全过。⬜ 武器拖尾 / 相机锁定 | `extension/src/net/*` · `project/{net_play,game_camera,hud}.gd` · `project/ui/death_ui.gd` |
-| **P5 特效引擎(首版)** | 🟡 `fx/mse.gd`(`.mse` 花括号树解析,真文件 6 particle)+ `fx/effect_player.gd`(Particle→`GPUParticles3D`:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ `fx/effect_registry.gd`(名字→`.mse` + 缓存 + spawn)。`wire.h` `GC_SPECIAL/SPECIFIC_EFFECT` + `M2Client.effect_cue` 信号 → `game_scene` 在实体上播。`fx_test.gd` 全过。⬜ `.dds` 纹理 / `.mde` mesh / 绑骨骼 | `extension/src/net/*` · `project/fx/*.gd` |
-| **P6 技能系统(首版)** | 🟢 `wire.h` `GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY` + `EntityStore` skill 表 + `M2Client.get_skills`/`cast_skill`/`skill_up` + `skills_changed`/`skill_cooldown_end`/`fly_cue` 信号。`ui/skill_table.gd`(skilldesc.txt)+ `ui/skill_ui.gd`(K 键,加点)+ `ui/quickbar.gd`(1-8 施放 + 冷却)+ `net_world` 飞行球。`skill_test.gd` 全过 | `extension/src/net/*` · `project/ui/{skill_table,skill_ui,quickbar}.gd` |
-| **P7 任务 / NPC(首版)** | 🟡 `wire.h` `GC_SCRIPT`/`GC_QUEST_CONFIRM`/`GC_QUEST_INFO` + `CG_ON_CLICK`/`SCRIPT_ANSWER`/`SCRIPT_BUTTON`/`QUEST_INPUT_STRING`/`QUEST_CONFIRM`(全 static_assert)。`EntityStore`:`ScriptCue`/`ConfirmCue`/`QuestInfo` map + `drain_scripts/confirms/quest_changes` + `quest(idx)`。`Entity` 加 `ch_type`。`M2Client`:`click_npc`/`script_answer`/`script_button`/`quest_input`/`quest_confirm`/`get_quests` + `script_dialog`/`quest_confirm_ask`/`quest_info` 信号。`ui/quest_dialog.gd`(EventManager 脚本渲染:`[ENTER]`/`[QUESTION]`/`[DONE]` → 文本+选项+确认)+ `ui/quest_log.gd`(J 键)。`net_play._on_pick` NPC 分流。quest 逻辑纯服务器驱动(选型②)。`quest_test.gd` 全过。⬜ `[INPUT]` 输入 UI / 选道具 / 立绘 / 世界任务箭头 | `extension/src/net/*` · `project/ui/{quest_dialog,quest_log}.gd` · `project/net_play.gd` |
-| charselect 串场 | ⬜ P10 | — |
-| **P8 社交 / 商店 / 仓库(首版)** | 🟡 `wire.h` `GC_PARTY_*`(0x0710-16) / `GC_MESSENGER`(0x0741) / `GC_SHOP`(0x0810) / `GC_EXCHANGE`(0x051C) / `GC_SAFEBOX_*`(0x0830-34) + 对应 CG(全 static_assert;`ShopItem`/`GCExchange`/`GCSubHeader` 等)。`EntityStore`:`PartyMember` map / `Friend` map / `ShopEntry` 表 / `ExchangeState` / `m_safebox[135]` + dirty 标志 + `drain_party_invites`/`drain_shop_errors`。`M2Client`:`party_invite/answer/leave` · `add/remove_friend` · `shop_buy/sell/close` · `exchange_start/add_item/add_gold/accept/cancel` · `safebox_checkin/checkout/move` + `get_party/friends/shop_items/exchange/safebox_items` + 8 信号。`ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd`(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。`inventory_ui` 右键分流(商店卖 / 交易放 / 仓库存)。`p8_test.gd` + `net.entity_store` / `net.loopback_flow` 扩测全过。⬜ 公会(`PythonGuild` 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 | `extension/src/net/*` · `project/ui/{party,friend,shop,exchange,safebox}_ui.gd` · `project/game_scene.gd` |
-| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 `GC_GUILD`(0x0730) INFO/LIST/GRADE + `ui/guild_ui.gd`(G,真机 `[GM-TEAM]`);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd`(`refine_ask` → `refine(pos,type)`);`GC_DRAGON_SOUL_REFINE`(0x051F)/`CG`(0x050D `grid[15]`) + `DS_SUB_*` + `EntityStore.DragonSoulCue`/`m_dragon_soul[180]` + `M2Client.ds_refine(mode,cells)`/`ds_window_open`/`ds_refine_result` + `ui/dragon_soul_ui.gd`(L / 自动弹,3 模式 + 15 格背包右键填)。**会徽**:`MARK_*` 独立连接(`GC_MARK_IDXLIST`/`BLOCK` 0x0C1x、`GC_MARK_UPDATE` 0x0B15、`CG_MARK_LOGIN/IDXLIST/CRCLIST` 0x0C0x);`NetStream` 加 `on_cipher_active()`/`on_raw()`(u32 `buf_size` 分帧);`mark_image.{h,cpp}` `MarkImageSet`(LZO1X → 512×512 → `mark_pixels`)+ `mark_client.h` `MarkClient`;`M2Client.download_guild_marks`/`get_guild_mark_image`→`Image`;`serverinfo` `mark_port` 第 8 列;`app_flow` 进游戏连 + `guild_mark_updated` 冷却重连。**公会战 + 技能页**:`GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17)、`WAR`(15) `GCGuildWar`(10)、`GUILD_WAR_LIST`/`END_LIST`(17/18) `GuildWarPair`(8)、`WAR_POINT`(19) `GuildWarPoint`(12)、`NAME`(16) `GuildNameEntry`(16);`EntityStore` `GuildSkillState`/`GuildWarStatus`/`m_guild_wars`/`m_guild_names` + `drain_guild_war_events`/`_scores`;`GameClient.send_guild_use_skill`;`M2Client` `get_guild_skill`/`get_guild_wars`/`get_guild_war`/`use_guild_skill`/`declare_guild_war`(`/war` 命令) + `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号;`ui/guild_ui.gd` 改三页 Tab(成员/技能/公会战)。**会徽上传**:`CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8)+文件字节;`NetStream.send_pending()`;`MarkClient` `Mode`(Download/UploadMark/UploadSymbol) + `upload_done()`;`M2Client.upload_guild_mark`(转 RGBA8/缩放)/`upload_guild_symbol`/`get_mark_server` + `guild_mark_uploaded`;`guild_ui` 成员页「上传会徽」按钮。`net.loopback_flow` 加 `MockServer::Mode::Mark` 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。`guild_refine_test.gd` + `dragon_soul_test.gd` + `guild_mark_test.gd` + `guild_war_skill_test.gd` + `net.entity_store` + `net.guild_mark` + `net.loopback_flow`(CTest) 全过。**真机验证(2026-08-31,`net_e2e`)**:`GUILD_GC_SKILL_INFO`(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR`(sub 15) 无战状态解析正确;trace 显示 `GC_GUILD`(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 | `extension/src/net/*` · `project/ui/{guild,refine,dragon_soul}_ui.gd` · `project/{net/serverinfo,app_flow}.gd` |
-| **私人商店 / 道具商城 / Cube(首版)** | 🟡 `wire.h`:`CG_MYSHOP`(0x0802) `CGMyShopHead`(38,`char sign[33]+u8 count`) + `MyShopItem`(13,`u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos`)×N;`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8);`GC_MALL_OPEN`(0x0841) `GCMallOpen`(5)、`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 复用 `GCItemSet`/`GCItemDel`,全 static_assert。`GameClient`:`send_open_private_shop(sign, vector)`(打包头+N项)/ `send_close_private_shop`(=`SHOP_CG_END`) / `send_mall_checkout` / `send_cube_make|material_info|result_list`(`/cube make|mInfo|rList` 走 `send_chat(CHAT_TYPE_COMMAND,…)`)。`EntityStore`:`m_mall[135]` + `mall_open/size/slot/dirty`;`GC_CHAT` 若 `type==CHAT_TYPE_COMMAND` 不进聊天,转 `apply_server_command()` 解析 `cube open/close/info/success/fail/r_list/m_info`(`v,c` 结果列表 + `@`配方 `&`组 `|`槽 `/`金币)→ `CubeState{open,npc,recipes[],results[],need_*}` + `drain_cube_events()`。`M2Client`:`open/close_private_shop`、`get_mall_items`/`mall_checkout`、`get_cube`/`cube_make`/`cube_request_result_list`/`cube_request_materials` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号。`ui/private_shop_ui.gd`(招牌 + 背包挑件 + 定价 + 개설/철수)、`ui/mall_ui.gd`(列表 + 取出到背包空格)、`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작);`game_scene` 装配三窗。`net.entity_store`(合成 GC_MALL_* + `GC_CHAT/COMMAND` cube 全家桶)+ `net.loopback_flow`(真 socket:server 推 mall+cube,client 发 `CG_MYSHOP`/`CG_MALL_CHECKOUT`/`/cube` 字节校验)+ `shop_cube_mall_test.gd`(三窗 FakeClient)全过。**真机验证(2026-08-31,`net_e2e MT_E2E_MYSHOP=1`)**:`CG_MYSHOP`(0x0802) 38B head + 13B item×1(vnum 149,price 99999999)被真服接受,无断连 / 无 shop error;`SHOP_CG_END` 关店正常。Cube `/cube rList` 与 `CG_MALL_CHECKOUT` 送出路径不断连,但服务器只在玩家站到对应 NPC 前才回应,`mall_open=0` / `cube open=0` —— 需 NPC 交互才能完整验。⬜ `GC_SHOP_SIGN`/`SYMBOL_DATA` 头顶招牌、他人开店 `START_EX` 多标签、39 格拖放网格 / 鼠标物品态、能量条、NPC 前完整验 cube/mall。**拍卖行**:m2dev-client 无此系统(`0x08xx` 只有 shop/myshop/safebox/mall),非移植项,需自定义协议 + 服务端 | `extension/src/net/{wire.h,game_client.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/{private_shop,mall,cube}_ui.gd` |
-| **P9 世界系统(首版)** | 🟡 `wire.h` `GCWarp`(18) / `GCTime`(12) / `GCChannel`(5) / `GCNPCPosition`(6+78×n) / `GCTargetCreate/Update/Delete`(46/16/8) / `GCMount`(21),全 static_assert。`EntityStore`:`WarpCue` + `drain_warps`、`server_time`/`channel` + dirty、`m_npc_marks` / `m_markers` map、`Entity.mount_vnum` + `drain_mount_changes`。`M2Client`:`get_channel/get_server_time/get_npc_marks/get_world_markers` + `warp`/`time_changed`/`channel_changed`/`npc_marks_changed`/`world_markers_changed`/`mount_changed` 信号。`ui/minimap.gd`(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ `world/world_time.gd`(epoch → 太阳角度/光色/ambient 昼夜)+ `fx/weather.gd`(雪/雨 GPUParticles 跟相机)。`game_scene._on_warp` 同服挪人。`p9_test.gd` + `net.entity_store` 扩测全过 | `extension/src/net/*` · `project/ui/minimap.gd` · `project/world/world_time.gd` · `project/fx/weather.gd` · `project/game_scene.gd` |
-| **P10 网络补全(首版)** | 🟢 `wire.h` `CGClientVersion`(0x000D,70B)。`GameClient` 收 `GC_MAIN_CHARACTER` → 自动发 `CG_CLIENT_VERSION` = **打通 LOADING→PHASE_GAME**(真机验证进游戏 + 121 实体)。`net/serverinfo.gd`(`class_name ServerInfo`,TSV 服务器表 + 频道端口偏移)+ `ui/loading_screen.gd`(相位驱动读取遮罩)+ `ui/reconnect_ui.gd`(断线倒计时 + 自动/手动重连)+ `app_flow.gd`(LOGIN→SELECT→GAME 串场状态机,单 `M2Client` 贯穿)。attack CRC 全 0 实测服务器接受。`net_e2e` 加 move+attack poke;`net.loopback_flow` 断言自动版本报文;`p10_test.gd` 全过 | `extension/src/net/{wire.h,game_client.h}` · `extension/tools/net_e2e.cpp` · `project/{app_flow,net/serverinfo}.gd` · `project/ui/{loading_screen,reconnect_ui}.gd` |
-| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md) P9·P10 剩余 | — |
-| 物品 / 装备(`item_proto` + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) | ⬜ | — |
-
-## 3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME)
-
-服务端 = **m2dev-fork 协议**。`net_e2e`(`extension/tools/net_e2e.cpp`,`build/extension/net_e2e`)
-驱动 auth→game→选人→进游戏全程;`MT_NET_TRACE=1` 打印每包 header/length/consumed。
-
-**真机结果 —— 全程打通**:
-- ✅ auth 握手(libsodium KX)+ `CG_LOGIN3` → `GC_AUTH_SUCCESS`
-- ✅ game `CG_LOGIN2` → **`GC_LOGIN_SUCCESS4`(0x0105,4 槽)** 角色列表(`[SA]Admin` lv105)
-- ✅ `CG_CHARACTER_SELECT` → `GC_PHASE(LOADING)` → 整包洪流全部按正确字节解析
-- ✅ **`GC_MAIN_CHARACTER` 后自动发 `CG_CLIENT_VERSION`(0x000D)** → `GC_PHASE(GAME)` / **IN GAME**
-- ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
-- ✅ `CG_MOVE` + `CG_ATTACK`(CRC 全 0)保持连接不被踢 → **不需要占位 CRC**
-
-**修的 3 个真 bug(对任何真服务器都必需)**:
-1. `GC_LOGIN_SUCCESS4`(0x0105) 没处理(只认 3 槽的)→ 加 `GCLoginSuccess4`(492B) + handler。
-2. 帧分发器 "did not consume" 误判:连续同 header 的包(一串 `GC_PLAYER_POINT_CHANGE`)会误杀连接
- → 改成按**实际消费字节数**判定(`net_stream.cpp` dispatch())。
-3. **缺 `CG_CLIENT_VERSION`**:服务器收到 `GC_MAIN_CHARACTER` 后等客户端回版本报文,缺它就在
- phase 4 断线 → `GameClient` 收到 `GC_MAIN_CHARACTER` 时自动发(fork 固定 timestamp `1215955205`)。
-
-**真机字节校准(2026-08-30,已做)** —— 用 `MT_NET_DUMP=1 net_e2e` 抓真实封包逐字节对:
-- **实体名 / 等级 / 公会 / 骑乘** ✅ 走 `GC_CHAR_ADD_INFO`(0x0207,`TPacketGCCharacterAdditionalInfo`
- 97B),之前根本没处理。加 `GCCharAddInfo` struct + handler,NPC「Old Man」(race 20009) 名字正确出。
-- **`GC_CHARACTER_UPDATE`**(0x0209,38B) ✅ 之前只粗解 parts;现按 `TPacketGCCharacterUpdate` 全解
- (parts / guild / alignment / pk_mode / mount,`GCCharacterUpdate` struct)。
-- **`ch_type`**:`GC_CHARACTER_ADD.bType` 对 NPC/怪都是 0(服务器不填)。改在 GDScript 按 `race` 查
- `mob_proto.type` 分类(`net_play._entity_kind`:0 怪 / 1 NPC / 2 石头 / 3 warp),`_entity_name`
- 怪名回退 `mob_proto.locale_name`。`game_scene` 现在也载 `mob_proto`。
-- **`GC_PLAYER_POINTS`**(0x0214,1024B) —— **本来就对的**。真机 gold=13.5 亿(GM 号)、exp=0、
- next_exp=int32 溢出为负都是服务器真实值。`M2Client.get_points` 把 exp/next_exp 按 unsigned 输出。
-
-**第二轮校准(2026-08-30,已做)** —— 又抓了 in-game 洪流里没对过的包:
-- **`GC_SKILL_LEVEL_NEW`**(0x021B,1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
- `get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
- u32 next_read}`(6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
- 技能全 lvl 40 master 3(GM 号)。`get_skills()` 加 `master`(0 普/1 M/2 G/3 P)。
-- **`GC_QUICKSLOT_ADD/DEL/SWAP`**(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加
- `QuickSlot{u8 type,u8 position}` + 3 个 handler + `M2Client.get_quickslots()` + `quickslots_changed`。
- `ui/quickbar.gd.restore_from_server()` 按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。
-- **`GC_NPC_POSITION`**(0x0A50) —— struct **本来就对**(6 + 78×n,name[65])。真机 49 条 NPC,名字对
- (服务器不 zero-pad name buffer,`strnlen` 已兜住)。
-- **`GC_ITEM_UPDATE`**(0x0514,41B) / **`GC_CHAT`**(0x0603,10B 头) / **`GC_AFFECT_ADD`**(0x0A20,25B)
- / **`GC_SPECIAL_EFFECT`**(0x0A30,9B) / **`GC_QUEST_INFO`**(0x0912,7B) —— 逐字节核对,**全部已对**。
-- **`GC_GUILD`**(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走 `MARK_*` 独立连接。
-
-**校准后续(2026-08-30,已做)**:
-- **3D 名字标签刷新** ✅ `EntityStore` 加 `ChangeKind::Info`;`GC_CHAR_ADD_INFO` 到达时对已存在实体
- 推 `Info` 变更 → `M2Client.entity_info(vid, dict)` 信号 → `net_world._on_info` 刷 Label3D。
- `net_world.name_resolver`(`game_scene` 设为 `net_play._entity_name`)让怪名走 `mob_proto`。
-- **跨服换图 `GC_WARP` addr≠0** ✅ `M2Client`:`WarpCue.addr` 是网络序 IPv4,转点分十进制后
- `warp_to_game_server(host, port)` —— 拆掉当前 game 连接、复用存下的 `login_key` 直连新 game
- server(跳过 auth)。同服 warp 仍只挪玩家。
-- **断点快连** ✅ `M2Client.reconnect()` 现在优先用 `last_login_key` 直连 game server(省一次
- auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。
-- **频道负载查询** ✅ `net/channel_status.gd`(对齐 `CServerStateChecker`):明文 TCP 连任一频道口
- → 发 `CG_STATE_CHECKER`(0x000F,len 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
- 读 `int32 count` + count×`{i16 port, u8 status}`。status:0 关 / 1 正常 / 2 拥挤 / 3 爆满
- (Metin2 协议里就是 4 态,不是在线人数)。`app_flow._probe_channels` 先查状态、没答上的频道回退
- `_probe_tcp`,下拉项标 ●/◐/✕。**真机实测**:`192.168.21.203:11011` 回 `{11011:1, 11012:1,
- 11013:1, 11991:1}` —— 也据此把 `serverinfo` 的频道端口步长从 +10 改成 **+1**(可在 serverlist.txt
- 第 7 列 `port_step` 按服覆盖)。`channel_status_test`(进程内 mock 服务器:请求字节 + 跳杂包 +
- 解析 + 不可达返回空)。
-
-- **怪 / NPC 真 3D 模型** ✅ `ui/mob_view.gd`(对齐 `player_view.gd`):`race` = `mob_proto` vnum →
- **`root/npclist.txt`**(`\t<代号>\t…`,如 `113→bear_brown`、`185→tiger_big`、`102→wolf`;
- 翻译版 `mob_proto` 把 `szName` 也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,`mob_proto`
- 名仅作兜底)→ 目录按代号逐段回退(`bear_brown → bear`),网格 / 贴图用完整代号
- (换肤网格都在基础目录里:`bear/bear_brown.gr2` + `bear/bear_brown.dds`)→ 进 `Metin2Model`
- (texture_dir = 目录)+ 解 `motlist.txt` → `Metin2AnimPlayer`。扫
- `/*/ymir work/{monster,monster2,npc,npc2}//`。npclist 解析一次进 `static` 缓存。
- `set_anim_state("wait/run/attack/damage/dead/skill")` 按 motlist 动作名映射。
- `game_scene._make_entity_model` 设为 `net_world` 的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型
- 缓存,失败回退占位胶囊。`net_world._attach_nameplate` 抽出来,真模型也挂头顶名字 + HP 条。
- `mob_view_test`:race 102 Wolf → `wolf/` 目录、Metin2Model(40 骨 762 顶点)、motlist ≥3 动作、状态切换。
- 实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。
-
-- **重连 / 已在局内时补拉实体** ✅ `game_scene.setup()` 现在是协程(分帧避免阻塞漏 PONG),
- `net_world.setup()` 接 `entity_spawned` 已晚于 `M2Client` 的初始 spawn 洪流 → `_on_spawn` 0 次触发。
- 修:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn 未镜像的;`game_scene` 已在局内分支里
- **先** `net_world.set_local_vid(get_main_vid())` + `net_play._on_main_set(get_main_vid())` 再
- `catch_up()`,否则本地玩家会被画成一个压在身上的大蓝胶囊。
-
-**剩的都不是代码问题**:字面「在线人数」(数字)Metin2 协议里没有 —— `bStatus` 就是 4 态负载指示,
-要真数字得改服务器;本地 `assets/` 只有 ~85 个 monster 目录(不是全量),没目录的 race
-仍是占位胶囊 —— 换全量资产包即可全部出真模型。
-
-**联调路上修的两个真 bug**(对任何真实服务器都必需):
-1. **`GC_LOGIN_SUCCESS4`(0x0105) 没处理** —— 只认 3 槽的 `GC_LOGIN_SUCCESS3`。已加 `GCLoginSuccess4`
- 结构(492B) + handler。
-2. **帧分发器误杀连接** —— 连续同 header 的封包(如一串 `GC_PLAYER_POINT_CHANGE`)会触发
- "on_packet did not consume" 误判并断线。改成按**实际消费字节数**判定
- (`net_stream.cpp` dispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
-
-**当前状态**:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript +
-9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
-
-## 3b. B 轨(不依赖服务端的客户端系统)
-
-| 模块 | 状态 | 位置 |
-|---|---|---|
-| **eterpack 读写器**(G1)| ✅ 本 fork = `PackLib` 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 `PACK_KEY`)。`EterPack`(读,name-field 从 header 反推兼容真实包)+ `write_pack` + `packtool` CLI。`pack.roundtrip` CTest + 真实 `textureset/` 45 文件明文/加密往返 `diff` 一致 | `extension/src/pack/eterpack.{h,cpp}`、`extension/tools/packtool.cpp` |
-| **登录 / 选人界面**(首版)| ✅ `charselect.gd`:3 旋转台 × 真实职业模型(`_novice.gr2` + `wait.msa`)+ 真实 `board` 九宫格面板(槽位按钮 + 进入游戏)。数据来自 `M2Client.char_list`(联机)或 `--mock`(离线开发)。`login.gd`:服务器/账号/密码 + 连接,接全部 `M2Client` 信号(`--auto` 测试)。`ui_kit.gd`:从 `hud.gd` 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 | `project/{login,charselect,ui_kit}.gd` |
-| **正式选人页(Metin2 风)** | ✅ `ui/char_select_screen.gd`:`app_flow._build_char_list` 用它替掉原来的裸按钮列表。全屏背景 `d:/ymir work/ui/intro/select/select.sub`(=select.jpg)+ 右侧 `SubViewport`(`own_world_3d`)里 `PlayerView` 真模型(正面朝相机 + `wait` + 缓摆)+ 左上职业名(locale `name_.sub` → `select.dds`,DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_.sub`** / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(`_pad_slots` 按 `get_slot_count()` 补空槽,◀▶ 能走到空槽建号)。**建号 / 删号(1:1 对齐 `PythonNetworkStreamPhaseSelect.cpp`)**:`CG_CHARACTER_CREATE`(0x0201) `CGCreateCharacter`(77,job 是 **u16**)、`CG_CHARACTER_DELETE`(0x0202) `CGDeleteCharacter`(13,`private_code[8]`)、`GC_PLAYER_CREATE_SUCCESS`(0x020C) `GCPlayerCreateSuccess`(108=`{u8 slot, SimplePlayerInfo}`)、`GC_PLAYER_CREATE_FAILURE`(0x020D) `{u8 type}`、`GC_PLAYER_DELETE_SUCCESS`(0x020E) `{u8 slot}`、`GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F)。`GameClient`:`create_character`/`delete_character` + `drain_char_events()`(`CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail}`)+ `replace_slot`/`clear_slot`(服务器只补丁槽位不重发列表)+ `slot_count()`。`M2Client`:`create_character(slot,name,job,shape,con,int,str,dex)`/`delete_character(slot,code)`/`get_empire`/`get_slot_count` + `char_created`/`char_create_failed`/`char_deleted`/`char_delete_failed` 信号(建 / 删成功后重发 `char_list`)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 态只 `set_chars()` 不整屏重建。会徽:`app_flow._goto_select` 也发 `download_guild_marks`,`guild_marks_ready`(one-shot) → `refresh_crest()`。相机改按**包围球** `r/sin(fovV/2)` 自适应 + 沿 -X 推 `r*0.5`(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:`CharSlot` + `char_list` dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part`。`ui_assets.gd` 加 `load_dds_image()`。`net.loopback_flow` MockServer 加 `CG_CHARACTER_CREATE/DELETE` 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);`char_create_delete_test.gd`(弹窗 → 假 client 收 `create_character(slot1,"Newbie",job1,con3/int3/str4/dex6)` + 短名拒绝 + `char_created` 关窗 + 删号码 + 失败清框 + `set_chars` 保持选中);`p10_test` 选人断言改点「开始」 | `project/ui/char_select_screen.gd` · `project/ui/ui_assets.gd` · `project/app_flow.gd` · `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `extension/tests/net_loopback_test.cpp` |
-| **角色状态窗(1:1 窗口迁移样板)** | ✅ `project/ui/char_status_ui.gd`:`UiScript`+`UiBuild` 直接装载 `assets/uiscript/uiscript/characterwindow.py` 真布局(4 页 tab:STATUS/SKILL/EMOTICON/QUEST,`SetState` 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 `assets/root/uicharacter.py.RefreshStatus`:`Level_Value`=`points[LEVEL]`、`Exp_Value`=`u32(exp)`、`RestExp_Value`=`u32(next_exp)-u32(exp)`、`HP/SP_Value`=`cur/max`、`STR/DEX/HTH/INT_Value`=`points[ST/DX/HT/IQ]`、`ATT_Value`=`(MIN_ATK|MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE`(min==max 取单值,全 0 退 `ATT_POWER`)、`DEF_Value`=`DEF_GRADE(+DEF_GRADE_BONUS 若非 0)`、`MATT_Value`=`MAGIC_ATT_GRADE+(MIN|MAX)_MAGIC_WEP`、`MDEF_Value`=`MAGIC_DEF_GRADE`、`ASPD/MSPD/CSPD/ER_Value`=`points[ATT_SPEED/MOV_SPEED/CASTING_SPEED/EVADE_RATE]`(EPointTypes 索引取自 m2dev `src/UserInterface/Packet.h`)。加点:`HTH/INT/STR/DEX` 的 `_Plus`/`_Minus` 按钮 → `client.say(0, "/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict`/`statusMinusCommandDict` 完全一致;`Status_Plus_Value`=`points[STAT]`,`>0` 才显示 `_Plus` 按钮和 `Status_Plus_Label`。名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())`(`race%4` → `face_warrior/assassin/sura/shaman.sub`)。`points_changed` 信号刷新。`game_scene`:`char_status_ui` 节点 + V / C 热键。`char_status_ui_test.gd`:假 client 灌 points → 断言全部 Value label 文本 + `/stat` 命令 + `STAT==0` 隐藏加点按钮 + tab 切换页可见性。后续选道具网格 / 商店 START_EX / 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)| `project/ui/char_status_ui.gd` · `project/game_scene.gd` · `project/char_status_ui_test.gd` |
-| **组队成员信息板(`uiparty.PartyMemberInfoBoard`)** | ✅ `wire.h`:`CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`(static_assert 10)+ `EPartyRole` 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7,PythonPlayer.h)。`game_client.send_party_set_state(pid,role,on)`(`CG_PARTY_SET_STATE` 0x0704,注意结构字段名 `dwVID` 实际传 PID —— 对齐 `uiparty.py OnSelectState` 的 `net.SendPartySetStatePacket(self.pid,...)`)。`M2Client.party_set_state(pid,role,on)` + bind;`get_party()` dict 补 `state`(完整角色字节,bit0=leader)+ `affects[7]`(int×7)。`project/ui/party_ui.gd` 从「名字+一条 HP」重做成 `partymemberinfoboard.py` 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):`_strip(m)` → 角色状态按钮(`_local_is_leader()` 才可点 → `_open_role_popup` 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=`party_set_state(pid, cur_role, false)` 清角色;踢出=`party_leave(pid)`=`SendPartyRemovePacket`)+ 名字(★队长) + HP `ProgressBar` + `affects[i]!=0` → chip(`AFFECT_LABEL` 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → `set_target(vid)`(`OnMouseLeftButtonDown` 选中)。header:EXP 分配开关 `_toggle_distribute` → `party_set_distribute`(`EPartyExpDistributionType` 0 NON_PARITY / 1 PARITY)+ 组队治疗 `_party_heal` → `party_use_skill(PARTY_SKILL_HEAL=1, 0)`。`_local_is_leader()` = `get_party()` 里 `vid == get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(`uiparty.__ShowStateButton` Tanker≥10…Defender≥40)暂略。`p8_test` 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ `party_set_state(pid, 2, true)` + 踢出 → `party_leave` + 分配切换 → `party_set_distribute(1)` + 治疗 → `party_use_skill(1,0)` + 点名字 → `set_target` + 非队长 StateButton disabled。**顺带修 `p8_test._init` 未 `await _run()` 的隐患**(第一个 `await` 之后所有断言从未生效,含增量 34 `shop_ex`)→ 改 `await` 后暴露 `shop_ui.refresh()` `queue_free` 延迟 bug(旧 row 当帧未消失),改 `remove_child`+`queue_free` | `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `project/ui/party_ui.gd` · `project/ui/shop_ui.gd` · `project/p8_test.gd` |
-| **ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`)** | ✅ `project/ui/system_menu_ui.gd`(`systemdialog.py` 真 uiscript,thinboard 8 键)= `uisystem.py __LoadSystemMenu_Default` 1:1:`system_option_button`→`system_option_ui.open()`,`game_option_button`→`game_option_ui.open()`,`change_button`→`client.say(0,"/phase_select")`(`net.ExitGame()` 在有 ping 时发 `SendChatPacket("/phase_select")`),`logout_button`→`"/logout"`(`net.LogOutGame()`),`mall_button`→`"/in_game_mall"`,`exit_button`→`get_tree().quit()`,`help_button`→占位 `toast`,`cancel_button`/标题栏→关。modal 打开。`game_scene`:ESC 从直接 `system_option_ui.toggle()` 改成 `system_menu_ui.toggle()`。
`project/ui/game_option_ui.gd`(`gameoptiondialog.py` 真 uiscript)= `uigameoption.py`:`BLOCK_BITS` = `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle_button) → `_toggle_block(bit)`:`_block_mode ^= bit` + `client.say(0, "/setblockmode %d" % _block_mode)`(`Packet.h EBlockAction` 位 `1<<0..1<<5`,逐字对照 `__OnClickBlock*` 的 `net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*))`)+ 持久化 `[gameopt] block_mode`(服务器回包同步待补)。`PK_CMD` = `pvp_{peace,revenge,free,guild}`(radio) → `client.say(0,"/pkmode %d")` 值 `{0,1,2,4}`(`__OnClickPvPMode*`;guild 是 `/pkmode 4`,`PK_MODE_PROTECT`=3 跳过)。`DISPLAY_RADIOS`:`name_color`/`target_board`/`view_chat`/`always_show_name`/`show_damage`/`salestext` 的 on/off/normal/empire radio → `_display[key]` + 持久化 `user://system_option.cfg [gameopt]`(等价 `systemSetting.Set*Flag`;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。`_bind_radio` 处理 radio 배타성,`_sync`/`_sync_block` 开窗回填,`_relabel` 中文兜底(uiScriptLocale 未接全局 Locale)。`system_menu_ui_test.gd`:两窗装载 + `/setblockmode 1`→`3`→`2`(位 XOR)+ `_block_mode` 跟踪 + `/pkmode 1`/`4` + radio 배타 + 显示开关落 cfg + 二次实例读 `block_mode` 回填按钮态 + menu 各键 `/in_game_mall`/`/phase_select`/`/logout` + `system_option_button`/`game_option_button` 开子窗关菜单 + `cancel` 关。C++ 无改动 | `project/ui/system_menu_ui.gd` · `project/ui/game_option_ui.gd` · `project/game_scene.gd` · `project/system_menu_ui_test.gd` |
-| **系统设置窗(`uisystemoption.OptionDialog`)** | ✅ `project/ui/system_option_ui.gd`:`UiScript`+`UiBuild` 装 `assets/uiscript/uiscript/systemoptiondialog.py`(board + titlebar + `music/sound_volume_controller` sliderbar + `camera_short/long` + `fog_level0/1/2` + `tiling_cpu/gpu` + `tiling_apply` + `bgm_button`)。控件绑定逐字对照 `assets/root/uisystemoption.py`:`OnChangeMusicVolume`→`snd.SetMusicVolume`+`systemSetting.SetMusicVolume`(接 `Audio.master_bgm` + 即时改在播 `_bgm[_bgm_cur].volume_db`),`OnChangeSoundVolume`→`Audio.master_sfx`(`play_ui`/`play_at` 每次读),`camera_short/long`→`game_camera.max_dist`(`CAMERA_MAX=[11,20]`,并 `dist=min(dist,max)`),`fog_level0/1/2`(浓/중/淡)→`__SetFogLevel` = `Environment.fog_enabled=true`+`fog_density=[0.055,0.018,0.004][i]`,`tiling_*`/`tiling_apply`(原 CPU/GPU 分块渲染 + `net.ExitGame` 重启)Godot 渲染器无对应 → 保留占位。`_bind_radio` 处理 radio 배타성。持久化 `user://system_option.cfg`(section `audio`/`video`,= `systemSetting` 配置文件);`setup()` 读回并 `_apply_all()` 即时生效;`game_scene._on_main_set` 在 `.msenv` 换 `Environment` 后再 `_apply_all()`。`game_scene`:ESC 呼出(`ui_manager._unhandled_input` 先 `close_top()` 吃事件,无窗时才落到 `game_scene`)。uiScriptLocale 未接全局 Locale → `LABELS` 中文兜底。`system_option_ui_test.gd`:装载 + `music/sound` slider `value_changed` 已连 + `_on_*_volume` 应用到 `master_bgm/sfx` + camera radio→`max_dist`+dist 夹 + fog radio→`fog_density` + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 | `project/ui/system_option_ui.gd` · `project/game_scene.gd` · `project/system_option_ui_test.gd` |
-| **私人商店开设窗(`PrivateShopBuilder` 模型)** | ✅ `project/ui/private_shop_ui.gd` 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py`(**增量 36:布局改走真 uiscript `privateshopbuilder.py` —— board + TitleBar + `NameLine`(盖 LineEdit) + `ItemSlot` 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧**):`_stock` = `itemStock`(`{格号 -> {cell,vnum,count,price}}`)。左侧 `_inv_list` 背包候选(排除已上货的 cell),点一件 → `_pick` 设 `_picked`(= `mouseModule` attached item);点右侧 40 格(`GRID_SLOTS=40` = `shop.SHOP_SLOT_COUNT`,5×8)空格 → `_ask_price`(`uiCommon.MoneyInputDialog`,`SpinBox`)→ `_place(slot, price)` 写 `_stock`(= `AddPrivateShopItemStock` + `itemStock[targetSlotPos]=(src)`);点已占用格 → `_stock.erase` (= `OnSelectItemSlot` / `DelPrivateShopItemStock`)。`_ok()`(OkButton → `BuildPrivateShop`):`_stock` 按格号 `sort()`、`display_pos = 格号`、`items.size() >= PRIVATE_SHOP_ITEM_MAX(39)` 截断 → `client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`(`MyShopItem`/`TShopItemTable` 字段 1:1:`{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos}`)。`_close_shop()` = `close_private_shop()` = `SHOP_CG_END`。测试拆成独立 `private_shop_ui_test.gd`(真 uiscript,缺资产跳过):`_cells` 40 格 / `NameLine` 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→`_place` / 개설按格号排序(格 0 在格 3 前)+ `display_pos` / 点占用格撤下 / 철수;`shop_cube_mall_test` 私人商店段移除。C++ 无改动(`open_private_shop` / `MyShopItem` 早已就位)| `project/ui/private_shop_ui.gd` · `project/game_scene.gd` · `project/private_shop_ui_test.gd` · `project/shop_cube_mall_test.gd` |
-| **`SHOP_GC_START_EX` 多货架商店** | ✅ `wire.h`:`ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}`(static_assert 33)+ `SHOP_TAB_COUNT_MAX=3`。`entity_store`:`SHOP_GC_START_EX`(10) 解析 `TPacketGCShopStartEx{u32 owner_vid; u8 tab_count}` + `tab_count` × `{ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}`(对齐 `PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx`)。`ShopEntry` 加 `u8 pos`(货架内槽位);`ShopTab{name, coin_type, items}` + `m_shop_tabs`;`SHOP_GC_START` 也改为按固定 40-槽数组填 `pos`(空槽计入,与参考 `SetItemData(j,...)` 一致);`SHOP_GC_END` 清 `m_shop_tabs`。`shop_items()` 保持是 `m_shop_tabs[0].items` 的镜像,旧 `get_shop_items()` 不动。`M2Client.get_shop()` → `{vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}`。`shop_ui.gd`:`tabs.size()>1` 时显示货架 `Button` 行,切货架重建列表;买位置 = `_active_tab * SHOP_SLOT_COUNT(40) + it.pos`(1:1 对齐 `uishop.py GetIndexFromSlotPos` = `tabIdx * shop.SHOP_SLOT_COUNT + slotPos`,`shop_buy` 的 `u8 pos` 容得下 3×40)。`net_entity_test`:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;`p8_test`:多货架 UI + 切页 + `buy pos 5`(tab0 slot5)/ `buy pos 43`(tab1 slot3) | `extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/shop_ui.gd` · `extension/tests/net_entity_test.cpp` · `project/p8_test.gd` |
-| **选魔石窗(样板第 2 例)** | ✅ `project/ui/select_item_ui.gd`:`UiScript`+`UiBuild` 装 `assets/uiscript/uiscript/selectitemwindow.py`(`ItemSlot` = 5×8 `grid_table`,`ui_build._slot_cells` 按 `x_count/start_index/x_step/y_step` 生成 `slot_0..39`)。填格逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包 `range(INVENTORY_PAGE_SIZE*2=90)` 格,`_is_metin`(`proto.item(vnum).type == 10 = ITEM_TYPE_METIN`)+ `_item_grade(vnum) <= 2`(= `proto.item(vnum).name` **内部名**最后一位数字,非数字按 0,复刻 `PythonPlayerModule.GetItemGrade`),`slotPos > 54` 截断,按背包格号升序映射到选择窗格 `_slot_to_inv[slotPos]=invCell`。点格 → `client.script_select_item(invCell)`(`send_script_select_item` → `CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection}`,= `net.SendSelectItemPacket`)+ 关窗;`ExitButton` / 标题栏关闭 → `script_select_item(0)`(`uiselectitem.Close`),`_sent` 标记避免选完再补发 0。**触发链 1:1**:`quest_dialog.gd.parse_script` 新识别 EventManager `[SELECT_ITEM]` token → `has_select_item` → `_on_script` 发新 `select_item_requested` 信号(对应 `PythonEventManager EVENT_TYPE_SELECT_ITEM` → `interfacemodule.BINARY_OpenSelectItemWindow` → `wndItemSelect.Open()`);`game_scene` 在 `proto`/`item_list` 就绪后建 `select_item_ui` 并把该信号接到 `.open`。`select_item_ui_test.gd`:`[SELECT_ITEM]` token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 `script_select_item(7)` + 关窗不补发 + 空手关窗发 `script_select_item(0)` | `project/ui/select_item_ui.gd` · `project/ui/quest_dialog.gd` · `project/game_scene.gd` · `project/select_item_ui_test.gd` |
-| **`item_proto` / `mob_proto` 读取器** | ✅ `mtproto::load_proto` —— 外层 `MIPX`(item:`[fourcc][ver=1][stride][elements][datasize]`) / `MMPT`(mob:`[fourcc][elements][datasize]`);内层 `MCOZ` CLZO 容器 = **XChaCha20**(本 fork 把 TEA 换成 libsodium:key=BLAKE2b(key16,"M2DevPackEncrypt"),nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + **LZO1X** 解压。两把硬编码 4-DWORD key(`ItemManager.cpp` / `PythonNonPlayer.cpp`)。`ItemRecord`/`MobRecord` 解前导字段(`bSpecular`@234 → §2.7;**`alValues[6]`@191** —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,`bSpecular`@234 锚定整链)。armor 的 `alValues[3]` = race `.msm` 的 body shape index(对齐客户端 `__ArmorVnumToShape` `SHAPE_VALUE_SLOT_INDEX=3`)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3,specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 items(stride 236)+ 1338 mobs(stride 335) | `extension/src/proto/proto.{h,cpp}` |
-| **`AssetResolver` 接包**(首版)| ✅ `mtpack::PackMount` —— 挂载多个 `.epk`,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + `ymir work/` 后缀)resolve/read,`metin2_patch_*` 包后挂覆盖。`mtpack::AssetSource` —— 散文件 + 包统一:`read()` 取字节;`to_path()` 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 `pack/` 覆盖)。`pack.roundtrip` CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 `Metin2World`/`Metin2Model` 等 ~6 处 `resolver->resolve()` 换成 `AssetSource::to_path()`(机械改动)| `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
-| **输入 & 游戏相机**(首版)| ✅ `game_camera.gd` = `GameCamera`:右键拖拽环绕(yaw/pitch clamp)+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。`player_controller.gd` = `PlayerController`:**点地移动**(射线求地表交点 → 走过去,沿途 `is_blocked`(`attr.atr` bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + **选目标**(射线取最近 pickable → `target_selected` 信号)+ `anim_state`/`moved` 信号。已换进 `world_demo.gd`(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 | `project/{game_camera,player_controller}.gd` |
-| **本地化**(首版)| ✅ `locale.gd` = `Locale`:加载 `locale/locale//{locale_interface,locale_game,itemdesc,skilldesc}.txt`(`KEY\tVALUE`,UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 ``(开发期可见);`set_lang()`;`apply_font()` 挂 CJK ttf。已接 `charselect`(`JOB_*` 职业名 / `SELECT_LEVEL` / `SELECT_SELECT`=Start / `SELECT_CREATE`)+ `login`(`LOGIN_ID`/`LOGIN_PASSWORD`/`LOGIN_CONNECT`)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf)| `project/locale.gd` |
-| **声音**(首版)| ✅ `audio.gd` = `Audio` 节点:**BGM 交叉淡**(2× `AudioStreamPlayer`,`bgm/*.mp3`)+ **UI 音效池**(8×,`**/sound/ui/*.wav`)+ **3D 定位音效**(`AudioStreamPlayer3D`,`**/sound/`)。路径解析走 11 个声音根 + `bgm/`(`.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect`→`characterselect` BGM + `_select` 选人音;`login`→`loginok`/`loginfail`;`world_demo`→`Metin2AnimPlayer.motion_event` 的 `sound` 字段 → `play_at(model_pos)`(脚步/挥击)。⬜ 按地图 BGM(服务端 `GCMainCharacter.szBGMName`)、`.msenv` 环境音循环、战斗/怪物/NPC 语音 | `project/audio.gd` |
-| 登录 / 选人界面 | 🟡 首版 | `project/{login,charselect,ui_kit}.gd` |
-| 声音(`AudioLib`→Godot audio)| 🟡 首版 | `project/audio.gd` |
-| `item_proto` / `mob_proto` 读取器 | ✅ | `extension/src/proto/proto.{h,cpp}` |
-| 本地化(`EterLocale`)| ✅ 首版 | `project/locale.gd` |
-| 输入 & 游戏相机 | 🟡 首版 | `project/{game_camera,player_controller}.gd` |
-| `AssetResolver` 从包读取(接 eterpack)| 🟡 | `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
-
-## 3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
-
-| 项 | 状态 | 位置 |
-|---|---|---|
-| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | `extension/src/metin2_world.cpp`(层 2 盒碰撞)+ `project/game_camera.gd` |
-| §2.2 GPU 路径发型 | ✅ | `extension/src/metin2_model.{h,cpp}` |
-| §2.10 角色 LOD 距离切换 | ✅ 首版 | `extension/src/metin2_model.{h,cpp}` |
-| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除)| `extension/src/metin2_world.{h,cpp}` |
-| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + `.mse` colorfactor)| — |
-| §1.2 `.mse` EffectLib 端口 | ⬜(大工程)| — |
-
-## 4. 下一步
-
-> **本节 1–4 项已于 2026-08-30 全部完成**(见 §3 与 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md)
-> 的 P0–P10)。保留为当时的计划记录,不要再当作待办执行。
-
-1. ~~打通到 `.203` 的连通性~~ ✅。可达后立即能测:
- - `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3` → `login_key`
- - GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
-2. **`GCLoginSuccess3` 布局对真实字节校准**:`net_probe` / `M2Client` 收到后 dump 原始字节,
- 核对 `TSimplePlayerInformation`(现假设 pack(1)、字段顺序照 `Packet.h:1161`)。size 不符会走
- `login_failed` 兜底。
-3. 游戏相位包解码:`GC_CHARACTER_ADD/DEL` / `GC_MOVE` / `GC_CHARACTER_UPDATE` →
- `NetworkActorManager` 端口 → 每实体一个 `Metin2Model`+`Metin2AnimPlayer`,用现有
- `Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
-4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
-
-## 5. 风险
-
-- **协议版本漂移**:`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
-- ~~**端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。~~ 已解除(§3)。
-- **移动端 libsodium**:Android/iOS 要静态 vendor(本身可移植,非阻塞)。
-- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
- `ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
-
-## 6. 移植面(客户端源码)
-
-`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
-`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
-`UserInterface/NetworkActorManager`(实体) · `GameLib`(玩法)。
diff --git a/docs/CLIENT-REFACTOR-40250-PLAN.md b/docs/CLIENT-REFACTOR-40250-PLAN.md
new file mode 100644
index 00000000..935e3482
--- /dev/null
+++ b/docs/CLIENT-REFACTOR-40250-PLAN.md
@@ -0,0 +1,263 @@
+# 客户端重构与 40250 逻辑对齐实施方案
+
+## 1. 现状诊断与核心痛点
+
+### 1.1 现状与优势
+- **渲染层(已基本稳定)**:
+ - `extension/src/metin2_model.{h,cpp}`:基于开源 libgr2 的模型网格解析、材质贴图加载、GPU/CPU 骨骼蒙皮。
+ - `extension/src/metin2_anim.{h,cpp}`:骨骼动画采样、双动画平滑融合(Crossfade)、挂点武器同步。
+ - `extension/src/metin2_world.{h,cpp}`:地形高度场采样、贴图混合、水体、静态建筑碰撞。
+ - `project/game_camera.gd`:轨道摄像机、防穿模逻辑。
+- **结论**:渲染层已具备优秀的跨平台能力(macOS/iOS/Android/Linux/Web),**无需推倒,纯粹作为“哑终端”画面渲染器(View)予以保留**。
+
+### 1.2 逻辑层的混乱根源
+当前各种 Bug(“WASD翻跟头”、“连击不流畅/手感怪异”、“NPC交互后商店空白”、“任务无法打开”)的共同根源在于:
+1. **庞大的 GDScript 意大利面条代码**:
+ - `net_play.gd` (~2200 行)、`game_scene.gd` (~1800 行)、`net_world.gd` (~1760 行) 试图用 GDScript 凭空重写 40250 的几万行 C++ 客户端逻辑。
+2. **凭感觉实现的“伪逻辑”**:
+ - 没有严格对照 40250 C++ 源码的状态机(如按键优先级、阻尼公式、连击输入窗、NPC点击距离分流、封包处理链),而是用自制计时器和启发式逻辑拼接,导致产生大量边界 Bug 和时序脱节。
+3. **数据没有单一真实来源(Single Source of Truth)**:
+ - 实体数据同时散落在 C++ `EntityStore`、`net_play.gd`、`net_world.gd`、`client.ents` 和 `node.meta` 中,互相通过数十个信号来回同步,极易丢状态或时序错乱。
+
+---
+
+## 2. 1:1 复刻的边界与原则(复刻哪些,不复刻哪些)
+
+> [!IMPORTANT]
+> **核心原则**:**“形式上”不要 1:1 照搬类文件,但“内核上”必须 1:1 严格对齐封包、状态机与数学公式。**
+
+### 2.1 坚决不要 1:1 复刻的部分(直接使用 Godot 原生)
+40250 源码中超过 60% 的代码是在重复造 2004 年 DirectX 8 / Windows 的轮子,这些在现代跨平台环境中是沉重的技术包袱:
+
+| 40250 原版模块 | 为什么不要 1:1 复刻 | Godot 现成替代方案 |
+| :--- | :--- | :--- |
+| **`EterLib` 图形底层**
(`CGraphicDevice`, `CStateManager`, `CRenderTarget`) | 深度强绑定 DirectX 8/9,引入会导致无法在 macOS (Metal) 及移动端 (Vulkan) 编译。 | **Godot 渲染引擎**(原生支持 Metal / Vulkan / WebGL) |
+| **`EterPythonLib` 2D UI 系统**
(`CWindowManager`, `CWindow`, `CButton`, `CScrollBar`) | 20 年前手工编写的固定坐标 2D 贴图拼接系统,不支持高分屏自适应、矢量字体、触屏手势。 | **Godot Control 体系**(完善的锚点自适应、触屏支持) |
+| **`milesLib` 音频系统** | 2004 年的 Miles 闭源声卡驱动库。 | **Godot AudioServer / AudioStreamPlayer** |
+| **`CCamera` 摄像机类** | 自行维护的 D3D 视锥矩阵,死板且不灵活。 | **Godot `Camera3D`**(现有的 `game_camera.gd` 表现良好) |
+| **`EterGrnLib` 模型封装** | 依赖 Granny 2.8 闭源 Windows DLL。 | **现有 `metin2_model` + `metin2_anim`**(基于开源 libgr2,功能已完备) |
+| **内嵌 Python 2.7 解释器** | Python 2.7 早已停止维护,在 iOS / Android 上打包极其臃肿且受 App Store 审核限制。 | **C++ 纯逻辑导出给 GDScript 调用** |
+
+### 2.2 必须 1:1 严格复刻的部分(下沉至 C++ GDExtension 核心逻辑层)
+下面这些是 Metin2 的“灵魂与运行规则”,必须 100% 对齐 40250 源码,漏掉一个判断分支就会导致手感完全走样:
+
+1. **网络协议与封包结构(1:1 严丝合缝)**:
+ - 对应源码:`Packet.h`
+ - 要求:每个 `TPacketGC*`(如 `TPacketGCShop`、`TPacketGCCharacterAdd`、`TPacketGCDamageInfo`)的结构体字段、字节对齐、Subheader 常量 100% 照搬。
+2. **角色移动与物理状态机(1:1 算法复刻)**:
+ - 对应源码:`PythonPlayerInput.cpp`、`InstanceBaseMovement.cpp`、`ActorInstanceMovement.cpp`
+ - 要求:按键优先级判定(`NEW_GetMultiKeyDirRotation`)、转向角 45° 阻尼与防过冲单向贴齐(`GetRotatingDirection`、`SetAdvancingRotation`)、碰撞切线滑移(`AdjustDynamicCollisionMovement`)、位移累加(`__AccumulationMovement`)。
+3. **战斗与打击判定状态机(1:1 状态机复刻)**:
+ - 对应源码:`ActorInstanceBattle.cpp`、`InstanceBaseBattle.cpp`
+ - 要求:连击段位状态转移(Combo 1→2→3→4)与按键输入时间窗(`ComboInputData`)、`.msa` 多命中时间窗(`HitDataContainer`)的毫秒级触发、普通受击方位判定与击倒击飞硬直。
+4. **业务数据模型与操作上下文(1:1 逻辑复刻)**:
+ - 对应源码:`PythonShop.cpp`(商店货架数据)、`PythonQuest.cpp`(任务状态树)、`PythonPlayerSkill.cpp`(技能冷却与释放条件三门)。
+
+---
+
+## 3. 架构重构结论与策略
+
+### 3.1 架构重构的必要性
+现有项目积累了大量技术债务,表面上看是“连击不连贯”、“WASD翻跟头”、“NPC打不开商店”等散发 Bug,深层原因是架构失衡:
+1. **“上帝文件(God Class)”过载**:`net_play.gd` (2200+行) 与 `game_scene.gd` (1800+行) 把网络封包解码、角色状态、背包/商店业务、3D渲染节点管理、点击拾取、UI开关全部混在一起,牵一发而动全身。
+2. **缺乏单一真实来源(No Single Source of Truth)**:角色坐标、朝向、连击段位在 `player_controller`、`net_play`、`net_world` 和 C++ `EntityStore` 中各自维护,靠数十个信号来回同步,极易时序混乱或丢状态。
+3. **启发式“伪逻辑”**:没有遵循 40250 的严密数学公式与状态机,而是用散落的计时器和直觉代码修补。
+
+### 3.2 重构策略:拒绝推倒重来,采用“绞杀者式渐进解耦(Strangler Fig Pattern)”
+- **保留成果(绝不推倒)**:已稳定的 C++ GR2 渲染层(`metin2_model`、`metin2_anim`、`metin2_world`)和摄像机防穿模逻辑完全保留。
+- **业务逐块下沉**:按“战斗 -> 商店/NPC -> 任务 -> 背包”的顺序,把上帝文件中的业务逐个抽成独立的 Manager/状态机,先解耦跑通,再下沉对齐 40250。
+
+---
+
+## 4. 推荐的目标系统架构(`extension/src/m2_core/`)
+
+在 C++ GDExtension 中建立清晰、模块化、无 DirectX 依赖的逻辑层:
+
+```
+extension/src/m2_core/
+├── protocol/ # 1:1 严格对齐 40250 的网络层
+│ ├── packets.h # 1:1 复制 40250 Packet.h 的所有包结构体
+│ └── packet_dispatcher.h # 1:1 复制 PythonNetworkStreamPhaseGame 的封包解析分发
+│
+├── player/ # 1:1 复制 CPythonPlayer 的核心逻辑
+│ ├── player_input.cpp # 1:1 移植 PythonPlayerInput.cpp (按键优先级、朝向合成、预约动作)
+│ ├── player_state.cpp # 1:1 移植 玩家属性、金币、背包、技能CD数据
+│ └── player_combat.cpp # 1:1 移植 连击判定、攻击输入窗
+│
+├── world/ # 1:1 复制 CPythonCharacterManager & CInstanceBase
+│ ├── instance_entity.cpp # 1:1 移植 实体状态机、45°阻尼移动、切线碰撞滑移
+│ └── combat_resolver.cpp # 1:1 移植 命中范围、受击硬直计算
+│
+└── gameplay/ # 1:1 复制业务逻辑数据源
+ ├── shop_system.cpp # 1:1 移植 PythonShop.cpp (解决商店商品空白)
+ └── quest_system.cpp # 1:1 移植 PythonQuest.cpp (解决NPC任务交互)
+```
+
+### 运行时数据与渲染流向:
+```mermaid
+flowchart TD
+ subgraph View ["Godot 4 表现与输入层 (View & Input)"]
+ InputHandler["Input / Touch / UI 事件捕获"]
+ GodotNodes["PlayerView / MobView (Metin2Model + Metin2AnimPlayer)"]
+ UI_Screens["ShopUI / QuestUI / InventoryUI / CharacterUI"]
+ end
+
+ subgraph LogicCore ["GDExtension 40250 纯逻辑核心 (Core Logic)"]
+ CPP_Input["PlayerInput (按键优先级/朝向合成/输入队列)"]
+ CPP_Move["InstanceEntity (45°阻尼/切线滑动/累积位移)"]
+ CPP_Combat["Combat & Combo (命中窗/连击状态机)"]
+ CPP_Net["NetworkStream & Dispatcher (收发严丝合缝对齐 40250)"]
+ CPP_Data["Shop & Quest Data (货架缓存/任务对话树)"]
+ end
+
+ subgraph Server ["40250 游戏服务端"]
+ GameServer["TCP Server"]
+ end
+
+ InputHandler -->|按键/点击事件| CPP_Input
+ CPP_Input --> CPP_Move
+ CPP_Move -->|状态包| CPP_Net
+ CPP_Combat <--> CPP_Net
+ CPP_Net <-->|TCP 封包| GameServer
+
+ CPP_Move -.->|输出权威坐标 (x,y,z) 与朝向 yaw| GodotNodes
+ CPP_Combat -.->|输出当前动作名、时间与打击帧| GodotNodes
+ CPP_Data -.->|响应数据变动通知| UI_Screens
+```
+
+---
+
+## 5. 移动端(iOS / Android)操作界面与跨端支持设计
+
+> [!NOTE]
+> **结论**:**当前规划的解耦架构天然、完美支持后续无缝接入移动端专属操作界面与触控系统。**
+
+### 5.1 核心解耦机制:输入意图抽象层(Input Intent Abstraction)
+重构后的架构将**“硬件输入采集”**与**“游戏角色行为”**彻底解耦。底层逻辑不再监听 `KEY_W`、`KEY_SPACE` 或鼠标点击,而是消费高阶**输入意图(Input Intents)**:
+
+```mermaid
+flowchart TD
+ subgraph InputSources ["输入源采集(多端隔离)"]
+ PC_Input["PC 桌面端输入
(WASD / 鼠标左右键 / 快捷键 1~4 / 空格)"]
+ Mobile_Input["移动端触控输入
(虚拟摇杆 / 技能轮盘 / 普攻大按钮 / 目标切换)"]
+ end
+
+ subgraph IntentLayer ["输入意图抽象接口 (PlayerInputIntent)"]
+ I_Move["move_intent(dir: Vector2, is_run: bool)"]
+ I_Attack["attack_intent()"]
+ I_Skill["skill_intent(slot_idx: int)"]
+ I_Target["target_intent(vid: int)"]
+ I_Interact["interact_intent(vid: int)"]
+ end
+
+ subgraph CoreLogic ["40250 核心状态机 (跨平台纯逻辑)"]
+ M2_Move["45° 转向阻尼 / 切线滑移碰撞"]
+ M2_Combat["连击输入时间窗 / 多段命中判定"]
+ M2_Target["500cm 距离门限 / 预约走向交互"]
+ end
+
+ PC_Input -->|转换映射| IntentLayer
+ Mobile_Input -->|转换映射| IntentLayer
+ IntentLayer --> CoreLogic
+```
+
+1. **移动控制无缝适配**:
+ - PC 端:WASD 键位经过 40250 `NEW_GetMultiKeyDirRotation` 算成方向向量。
+ - 移动端:屏幕左侧的**虚拟摇杆(Virtual Joystick)**直接输出方向向量给 `move_intent`,底层 45° 转向阻尼与碰撞滑移算法无需修改一行代码。
+2. **战斗与技能轮盘**:
+ - 移动端右侧可布局经典“大按键普攻 + 环形技能轮盘”,普攻按钮每次点击触发 `attack_intent()`,底层直接进入 40250 的 `ComboInputData` 输入缓冲队列。
+
+### 5.2 双端自适应 UI 表现层(Dual-Mode Adaptive UI)
+由于业务数据(`ShopManager`、`InventoryManager`、`QuestManager`)已与界面彻底解耦:
+- **数据核心统一**:所有数据模型只负责维护商品列表、道具属性、背包格子、对话选项,通过信号/事件对外广播。
+- **UI 视图按端加载**:
+ - PC 端:加载 `DesktopHUD.tscn`(支持鼠标 Hover 查看装备 Tooltip、右键穿戴、拖拽格子)。
+ - 移动端:根据 `OS.has_feature("mobile")` 或屏幕分辨率动态加载 `MobileHUD.tscn`(更大的触控热区、点击弹出操作抽屉 Drawer、全屏大卡片商店展示)。
+- **完全零冗余**:两套 UI 共享 100% 的底层数据逻辑、网络协议与状态机。
+
+---
+
+## 6. 分阶段实施路线图
+
+按照“**先主要解决战斗系统不一致,再解决 NPC/商店/任务交互混乱**”的优先级分步推进:
+
+### 阶段一:战斗与连击系统精准对齐(优先级:最高)
+> 对应 40250 源码:`ActorInstanceBattle.cpp`、`InstanceBaseBattle.cpp`、`formats/msa.h`
+
+1. **普通攻击与连击链(Combo Chain)1:1 复刻**:
+ - 移植 40250 `ActorInstanceBattle.cpp:157-308` 状态机:
+ - `m_dwcurComboIndex`(连击段数 1→2→3→4)
+ - `ComboInputData`:严格按照 `.msa` 的 `InputStartTime`、`InputEndTime`、`NextComboTime` 判定是否允许按下一次普攻。
+### 阶段一:移动与战斗核心状态机 40250 对齐(优先级:最高)✅ [COMPLETED]
+> 对应 40250 源码:`PythonPlayerInput.cpp`、`ActorInstanceBattle.cpp`、`InstanceBaseBattle.cpp`
+
+1. **废弃原有人为的外层普攻 CD 拦截**:
+ - 彻底废除 `_attack_cd` 吞键逻辑,完全对齐 40250 普攻/连击内禀门控机制。
+2. **严格 1:1 接入 `.msa` 的连击输入数据窗**:
+ - 读取并严格应用 `duration`、`InputStartTime`、`NextComboTime`、`InputEndTime`。
+ - 完美支持长按空格连续 4 击(1→2→3→4 段无缝切换与回 Wait 重置)与快速连点。
+3. **受击与击倒对齐**:
+ - 动作播放时长内动态释放受击锁,触发屏幕震颤与击退同步。
+
+### 阶段二:NPC 交互与商店系统修复(优先级:高)✅ [COMPLETED]
+> 对应 40250 源码:`PythonPlayerInputMouse.cpp`、`PythonNetworkStreamPhaseGame.cpp`、`PythonShop.cpp`、`uishop.py`
+
+1. **NPC 点击与预约走向交互(__OnClickActor)**:
+ - 点击 NPC:距离 `<= 500 cm`(`CLICK_DIST_NPC`)立即停步、面朝 NPC 发送 `HEADER_CG_ON_CLICK` (26)。
+ - 距离 `> 500 cm`:进入 `m_eReservedMode = CLICK_ACTOR` 预约模式,驱动角色寻路靠近,进入 500cm 瞬间停步交互。
+ - 加入 40250 `CPythonPlayer::__SendClickActorPacket` 的 1000ms 狂点节流保护。
+2. **修复商店数据流与属性丢失(彻底解决白板商品 Bug)**:
+ - C++ `mtnet::ShopEntry` 补齐 `sockets[3]` 与 `attrs[7]`,解析 `START` / `START_EX` / `UPDATE_ITEM` 全量属性。
+ - `m2_client` 修复切页被冲掉问题,仅在初次打开触发 `shop_opened`,格位更新触发 `shop_updated(pos)`。
+ - UI 接入 40-slot 货架、多 Tab 切换(`tabIdx * 40 + slot`)、右键直接秒买(`UnselectItemSlot`)、超出 10m 离开自动关闭窗口。
+
+### 阶段三:任务系统与对话流对齐(优先级:中)✅ [COMPLETED]
+> 对应 40250 源码:`PythonEventManager.h/cpp`、`PythonQuest.cpp`、`PythonNetworkStreamPhaseGame.cpp`(`RecvScriptPacket`)、`uiquest.py`
+
+1. **40250 任务与 NPC 交互协议全套对齐**:
+ - 封包层精准对齐:`HEADER_GC_SCRIPT` (45)、`HEADER_CG_SCRIPT_ANSWER` (29)、`HEADER_CG_SCRIPT_BUTTON` (66)、`HEADER_CG_QUEST_INPUT_STRING` (30)、`HEADER_GC_QUEST_CONFIRM` (46)、`HEADER_CG_QUEST_CONFIRM` (31)。
+ - 在 C++ 网络会话层 (`ClassicSession`) 与 `M2Client` 实现全套安全发包接口:`send_script_answer`、`send_script_button`、`send_quest_input`、`send_quest_confirm`、`send_quest_cancel`。
+2. **40250 `CPythonEventManager` 脚本语法与表现层 1:1 解析**:
+ - 支持标准 40250 分号/管道符问题格式 `[QUESTION 1;选项一|2;选项二|...]`,0-indexed 答案映射,保留向后兼容旧格式。
+ - 实现 40250 `uiquest.py` 8 选项多页分页机制(`MAX_CHOICES_PER_PAGE = 8`),自动挂载 `[上一页]` / `[下一页]` 导航行,精确计算全局索引 `page * 8 + slot`。
+ - 皮肤与立绘对齐:`SKIN_NOWINDOW` (1) 与 `SKIN_CINEMA` (5) 隐藏背景板;`LEFTIMAGE` 自动偏移文本与按钮内容区宽度。
+ - 支持 `[INPUT]` 文本输入框即时提交、`[NEXT]` 剧情推进与 `[DONE]` 流程收尾(`script_answer(254)`),以及 ESC 键安全取消(`OnCancel`)。
+ - 编写 `test_quest_dialog_parity.gd` 覆盖 9 大测试场景 45 项断言全部 100% 通过。
+
+### 阶段四:角色属性面板、背包与技能系统对齐(优先级:中)✅ [COMPLETED]
+> 对应 40250 源码:`PythonPlayerSkill.cpp`、`PythonSkill.h`、`PythonItem.cpp`、`GameType.h`、`Packet.h`、`uicharacter.py`
+
+1. **主动技能释放链与三层完整门控(40250 CheckSkillUsable 对齐)**:
+ - 严格 1:1 实现死亡状态(`CANNOT_ACT`)、安全区攻击拦截(`IN_SAFE`,增益 Buff 技能放行)、武器类型匹配(`NOT_MATCHABLE_WEAPON`)、弓箭数量检验(`EMPTY_ARROW`)、SP/HP 消耗门检(`NOT_ENOUGH_SP`)、冷却时间阻断(`WAIT_COOLTIME`)以及施法距离/目标判定(超出距离进入 `RESERVED` 预约模式,进入范围后放行)。
+2. **装备与背包双端线协议对齐(40250 ItemPos 对齐)**:
+ - `_to_wire` 严格映射 40250 `TItemPos`:背包槽位 `0..89` 映射 `WINDOW_INVENTORY (1)`;装备槽位 `90..100` 映射 `WINDOW_EQUIPMENT (2)` 且内部 cell 严格从 `0` 开始偏移;时装槽位 `109..113` 映射 `WINDOW_INVENTORY (1)`。
+ - 右键穿脱发送 `HEADER_CG_ITEM_USE` (11),拖拽移动发送 `HEADER_CG_ITEM_MOVE` (12),并严格走 `EquipRules` 职业、等级(`LIMIT_LEVEL`)与性别门控。
+3. **角色属性面板实时换算与指令对齐(uicharacter.py:RefreshStatus 对齐)**:
+ - 物攻格式严格呈现 `min-max`(或相等时单值显示)、物防 `DEF_GRADE + DEF_BONUS`、魔攻 `MAG_ATT + MAGIC_WEP` 纯正公式。
+ - 加点与洗点协议通过安全网络通道发送原生 `/stat ht`、`/stat iq`、`/stat st`、`/stat dx` 与 `/stat-` 指令。
+4. **快捷栏(Quickbar)持久化与全量同步(40250 TQuickSlot 协议)**:
+ - 快捷栏配置新增发送 `HEADER_CG_QUICKSLOT_ADD` (16)(支持技能、物品、动作表情)、交换发送 `HEADER_CG_QUICKSLOT_SWAP` (18)、清空发送 `HEADER_CG_QUICKSLOT_DEL` (17),并支持全量 36 格状态本地与服务端双向恢复。
+ - 编写 `test_skill_inventory_parity.gd` 覆盖 4 大领域 42 项测试断言全部 100% 通过。
+
+---
+
+## 7. 技术实施规范
+
+1. **严禁“自造算法”**:
+ - 任何涉及移动、旋转、连击、受击、技能、网络包逻辑的代码,必须明确注明对应的 40250 源码文件和行号(例如 `// 对齐 40250 ActorInstanceBattle.cpp:215`),1:1 复刻其状态转移条件。
+2. **职责边界分明**:
+ - **C++ (GDExtension)**:拥有所有数据、状态机、网络封包、物理数学计算。
+ - **GDScript / Godot Node**:只做两件事:① 把 C++ 的位置和动作画在屏幕上;② 把屏幕上的按键和点击事件传给 C++。
+3. **严格单向数据流**:
+ - 废除 GDScript 层各节点之间混乱的信号交叉监听,所有状态统一归拢到核心状态机。
+
+---
+
+## 8. 验证计划
+
+1. **单元测试与 Headless 自动化测试**:
+ - 为连击状态机编写类似 `test_wasd_steering_parity.gd` 的无头自动化回归测试。
+ - 模拟封包注入测试商店数据解析与 NPC 交互流程。
+2. **实服联机验证**:
+ - 配合真实 40250 服务端,联机点击杂货商/铁匠,验证商品列表 100% 正常展示并成功购买。
+ - 联机打怪,验证普通攻击连击节奏、打击僵直与伤害数字飘字时机与原版客户端完全吻合。
diff --git a/docs/CLIENT-ROADMAP.md b/docs/CLIENT-ROADMAP.md
index 642845c0..264f282e 100644
--- a/docs/CLIENT-ROADMAP.md
+++ b/docs/CLIENT-ROADMAP.md
@@ -1,7 +1,7 @@
# 客户端玩法开发计划
> 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。
-> 网络栈进度见 [`CLIENT-PORT.md`](./CLIENT-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
+> 网络栈进度见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
>
> 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client)
@@ -197,7 +197,7 @@ motion_name 就是特效基名,上一轮列偏移修好后自然接上);
**已验证**:`net.entity_store` 扩展(`GC_SCRIPT` 带 `[QUESTION]` → `ScriptCue`;`GC_QUEST_CONFIRM` → `ConfirmCue`;`GC_QUEST_INFO` 带 TITLE+COUNTER flag → `QuestInfo` 变长尾解析,`length` 字段回填);`quest_test.gd`(`parse_script`:纯文本保留 / `[ENTER]`→换行 / `[QUESTION]`→2 选项 / `[DONE]`→继续钮;`script_dialog` 信号 → 弹窗 + 3 选项钮 + 选 B → `script_answer(1)` + 关窗;无选项 → 继续 → `script_answer(255)`;`quest_confirm_ask` → 接受 → `quest_confirm(true,999)`;quest_log 只显示有标题的 quest + `quest_info` 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9,iOS 编过。
-**未完成**:`[INPUT]` 文本输入 UI(`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]` → `script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-PORT.md` §3:握手 + 认证已联通,进游戏串场归 P10)。
+**未完成**:`[INPUT]` 文本输入 UI(`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]` → `script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-40250-PORT.md`:握手 + 认证已联通,进游戏串场归 P10)。
## P8 — 社交 / 商店 / 仓库(XL,🌐)
@@ -290,7 +290,7 @@ motion_name 就是特效基名,上一轮列偏移修好后自然接上);
(协程化的 `setup()` 让 `entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene` 先
`set_local_vid` + `net_play._on_main_set` 再 `catch_up`,避免本地玩家被画成压身的蓝胶囊。
-详见 `CLIENT-PORT.md` §3。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
+详见 `CLIENT-40250-PORT.md`。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
**剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
---
@@ -313,7 +313,7 @@ PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` /
- `GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-PORT.md` §3。
+ `GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-40250-PORT.md`。
2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand`,`_load_attach`
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`。
**armor→shape 真表**:proto 读器加 `alValues[6]`@191,`equip_model._armor_shape_default` = `item_proto
diff --git a/docs/FIRST-MAC-PLAYABLE-STATUS.md b/docs/FIRST-MAC-PLAYABLE-STATUS.md
deleted file mode 100644
index e3e7a583..00000000
--- a/docs/FIRST-MAC-PLAYABLE-STATUS.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# 首个 Mac 联网内测版:实施状态与已知问题(REL-01)
-
-日期:2026-09-11。对应计划:`docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md`。
-
-后续审查:提交 `f39a55fd` 的观察器缓存保留、运行取消清理、外部内存门禁与异常 JSON 处理问题,见 [审查与修复记录](REVIEW-f39a55fd.md)。以下环境数据是原实施时的快照。
-
-本文只记录已核对的事实。**当前没有可放行的候选包,也没有任何实网证据**;所有依赖服务器、测试账号、环境负责人确认或人工签核的条目均为 BLOCKED。离线/假进程测试 PASS 只证明门禁和状态机按契约工作,不能代替联网验收。
-
-## 1. 结论
-
-| 项 | 状态 | 说明 |
-| --- | --- | --- |
-| INF-01 契约与配置校验 | 离线 PASS | `playable_harness_test.gd` 负向用例、`validate_playable_report.mjs` 静态夹具全部通过 |
-| INF-02 运行器与退出门禁 | 离线 PASS | `script/playable_gate_test.sh` 用假签名 arm64 包覆盖 PASS/退出码/信号/超时 TERM→10s→KILL/残留 FIFO/RID 与泄漏警告/凭据脱敏/聚合 |
-| NET-01 真实联网闭环 | **BLOCKED** | 包内入口已实现;缺 `scenario.local.json`、测试账号、环境负责人确认的路线/怪物/掉落,未联网运行 |
-| CBT-01 四职业技能矩阵 | **BLOCKED** | `test/playable/skill-cases.json` 全部 `unconfirmed`;脚本不补技能 ID |
-| MAP-01 树怪资源与离线 Metal 截图 | 离线 PASS,**待人工签核** | 12 个候选树怪 × CPU/GPU × wait/run/attack/damage/dead 全部组装通过;截图需人工看过 |
-| MAP-02 实际地图定位与落地 | **BLOCKED** | `forest-viewpoints.example.json` 机位均 `unconfirmed`;坡地脚/根接触需人工检查 |
-| STB-01 2 小时稳定性 | 工具已实现,**未实跑**,BLOCKED | `script/playable_soak.sh` 可运行(见 §4);真实断网与切图需环境确认 |
-| REL-01 放行 | **BLOCKED** | 无重建候选包;上述必测项均未产生实网证据 |
-
-## 2. 候选包与环境
-
-- 计划中的候选包 `build/export-native-trees-20260911/mtgodot-poc.app` 和自编译引擎 `build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64` 在当前工作树**不存在**。仅有 `build/export/mtgodot-poc.app`(2026-09-08),早于本轮全部 playable 代码,**不能**作为候选包。
-- 本轮修改了 GDScript(包内测试入口、流程、指标、窗口适配),**必须重新导出 PCK**;若最终修复涉及 C++,还需 `./build.sh Release` 重建扩展、重新签名打包。重建后对最终包重新跑退出门禁。
-- 官方 Release 模板的粒子退出警告未修复;必须显式使用经过验证的自编译 arm64 引擎。不宣称已修复官方模板或 Intel 平台。
-- 数据盘剩余约 7.5 GiB(99% 已用)。2 小时 soak 的 `events.jsonl`、`rss.jsonl`、截图与日志会持续写盘,实跑前需由用户腾出空间;脚本不会自行清理任何文件或系统缓存。
-
-## 3. 已知问题与限制
-
-### 3.1 树怪动作(MAP-01)
-
-- `ent_trent`(2301/2311)的 motlist 只有 WAIT、WAIT1、NORMAL_ATTACK、NORMAL_ATTACK1、FRONT_DAMAGE、FRONT_DEAD,**没有 RUN/WALK**。按旧客户端 `CRaceData::GetMotionKey` 未命中→`SetLoopMotion` 直接返回的规则,移动时继续播放当前循环(`reference_keep_current_motion`)。`forest_mob_render_test` 报告记为 4 条 fallback(2 个 race × CPU/GPU),标注“需实网确认:服务器移动该种族时位置变化且保持原循环”。未经实网确认前不能宣称行走表现与原版一致。
-- 七种 ent 目录的 motlist 均只有 FRONT_DEAD,没有 BACK_DEAD。背后击杀时的死亡动作按旧客户端回退规则判定,需实网对照确认。死亡姿态、材质、轮廓已进入接触表截图,**必须人工签核**;AABB 底部不是脚部接触,离线测试不能证明坡地脚/根贴地。
-- `mob_winding_test.gd` 仍固定覆盖 101/110/20001;树怪由 `forest_mob_render_test.gd` 单独覆盖。
-
-### 3.2 帧时间(离线合成 suite,非实网)
-
-`rendering_scenario_test.gd`(Metal,suite=`offline_synthetic`,FakeClient + 真实资源)本轮结果:
-
-- 689 帧,>50ms 14 帧,全部已归因(unattributed=0);>100ms:loading 6、gameplay 2。
-- gameplay 的 2 帧(119.5ms、237.0ms)都带 `viewport_resize` 标记,发生在测试主动切换窗口尺寸时。它们不属于正常游玩的重复交互,但在实网 soak 中要继续按“同一交互 3 次中 2 次 >100ms 即阻断”规则判定。
-- loading 段:同步模型构建 447–746ms,切图 1861ms 与 4489ms。首次加载预算尚未固定(计划要求基线采集后再定,不临时放宽)。
-- CPU 蒙皮 8/16 角色时 p99 分别为 70.1ms/50.5ms(90 帧窗口,p99 即最大值);GPU 蒙皮 1/8/16 角色 p99 ≤ 26.1ms。
-- 视口 1280×720、1920×1080、2560×1440 均按请求尺寸生效(screen_scale=1);Retina 缩放下的物理/逻辑换算由 `testing/playable_window.gd` 记录,未在 Retina 屏实测。
-- 冷启动:进程冷启动/系统缓存状态未知,测试不清空系统缓存。
-- 显存:`gpu_memory_kib=null`(unavailable)。Godot RenderingDevice 计数 843,616 KiB 不是系统显存,不据此判断显存泄漏。探针开销:每次采样 CPU 约 1.26µs;开/关探针 p50 差 −0.018ms,处于帧节奏噪声内。
-
-### 3.3 稳定性工具的边界(STB-01)
-
-- **切图**:`soak.warp.status` 默认 `unconfirmed`,STB-WARP-01 为 BLOCKED。只有环境负责人确认合法传送入口(`portal_cm`、`return_portal_cm`、`destination_map_key`、`cross_server`、偶数且 ≥20 的往返次数)后才会走传送门;不以直接改客户端节点位置代替。跨服切图须核实测试服支持。
-- **真实断网**:`soak.faults.status` 默认 `unconfirmed`,STB-DISCONNECT-01 为 BLOCKED;主动 `reconnect()`(STB-RECONNECT-01)与真实传输故障分开计数,不能互相替代。确认后由 `run_client_gate.sh` 在 127.0.0.1 上启动 `script/playable_fault_proxy.mjs`,只代理本测试客户端的连接(`server.*` 与 serverlist 必须指向代理端口,`soak.faults.upstream` 为真实测试服)。不修改全局路由、防火墙,也不关闭任何服务。
-- **已知限制**:如果服务器传送时下发了直连的 game 地址,之后的连接会**绕过代理**,故障注入对切图后的连接无效。需要在确认故障条件时一并核实,或在同一地图服务器内完成故障用例。
-- **窗口**:headless、全屏、最大化窗口不可切换尺寸;请求尺寸(含标题栏)超出屏幕可用区域时如实记为 BLOCKED,不缩小目标冒充通过。
-- **内存**:父运行器每秒采样子进程 RSS(KiB),在每轮静置 30s 后取低水位;必须 ≥ warm-up + 10 轮才判定。连续 5 轮增长,且后 5 轮中位数比前 5 轮高出 max(50MiB, 5%) 时 FAIL。低于阈值也不能证明长期无泄漏。
-- 10 次正常退出由 10 个独立进程完成,每次都经过完整退出门禁;任何一次失败,后续运行(包括 2 小时 soak)不会启动,记为 BLOCKED。
-
-### 3.4 人工项(不能自动化替代)
-
-- 真实鼠标/键盘闭环(点击移动、选怪、普攻、技能栏、拾取)需要单独的人工证据。
-- MAP-01/MAP-02 Metal 截图人工签核(`forest_map_render_test` 报告里 `manual_signoff.status` 恒为 `PENDING`,自动 PASS 不代表视觉签核)。
-- CBT-01 技能特效截图需人工看过一次。
-
-## 4. 已可运行的命令
-
-以下命令在 `metin2-client/` 下执行。账号只通过 `MT_ACCOUNT`/`MT_PASSWORD` 环境变量或隐藏输入提供,禁止写进配置、参数或命令历史。
-
-离线(不连服务器,本轮均 PASS):
-
-```bash
-bash script/playable_gate_test.sh
-node script/playable_soak_metrics_test.mjs
-node script/playable_fault_proxy_test.mjs
-node script/audit_playable_maps_test.mjs
-(cd project && godot --headless --path . --script playable_harness_test.gd)
-(cd project && godot --path . --script forest_mob_render_test.gd)
-bash script/rendering_batch_test.sh
-```
-
-联网(需要重建候选包、`test/playable/scenario.local.json` 与测试账号;**本轮未运行**):
-
-```bash
-bash script/playable_test.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --suite full --allow-gameplay --repeat 3
-bash script/playable_soak.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --allow-gameplay --duration-seconds 7200
-node script/validate_playable_report.mjs --release-dir <本批次输出目录>
-```
-
-`playable_soak.sh` 行为:
-
-- 先用 soak suite 校验配置和资源。缺少 `--allow-gameplay` 时只做校验,返回 2,不启动客户端。
-- 依次运行 `exit-1..exit-N`(suite playable,N ≥ 10)和 `soak-1`(suite soak,墙钟时长 = `soak.duration_seconds`,超时 ≥ 时长 + 900s)。
-- 写出只列本批次运行的 `release-manifest.json`(required_runs `{playable:N, soak:1}`),再聚合。
-- 退出码:0 PASS / 1 FAIL / 2 BLOCKED / 124 超时。
-
-## 5. 放行检查表对照(计划 §11)
-
-| 检查项 | 当前 |
-| --- | --- |
-| INF-01/02 负向用例通过,必测项缺失不能 PASS | ✅ 离线通过 |
-| 同一候选包 3 轮联网闭环 + 鼠标键盘证据 | ❌ BLOCKED(无候选包、无账号/夹具、无人工证据) |
-| 四职业基础战斗、技能及异常路径 | ❌ BLOCKED(技能矩阵未确认) |
-| 首测地图和树怪资源/动作/Metal 画面 | ⚠️ 离线组装通过;机位未确认、人工签核未做、实网未跑 |
-| 2 小时、切图、故障恢复、窗口、退出次数 | ❌ BLOCKED(工具就绪,未实跑;切图/断网条件未确认) |
-| P0/P1 清零 | ❌ 未知:没有实网运行,无法判定 |
-| 所有 BLOCKED 明确处理 | ⚠️ 本文已列出;首测必测项不得以“已知问题”绕过 |
-| 包哈希、签名、引擎来源、设备信息一致 | ❌ 需重建候选包后由运行器生成 |
-| 已知 P2、未覆盖地图/装备/技能及未验收平台列出 | ⚠️ 本文 §3;未覆盖:首测三图以外的地图、Intel、官方模板 |
-
-## 6. 需要环境负责人提供
-
-1. 自编译 arm64 引擎和重新导出的候选包(含本轮脚本),以及足够的磁盘空间。
-2. `test/playable/scenario.local.json`:由 `scenario.example.json` 复制,填写 serverinfo 中的地址、角色槽位、map_key、安全路线、怪物/掉落 vnum;soak 需 `timeout_seconds ≥ duration_seconds + 900`。
-3. 专用测试账号(等级、装备、已学技能、掉落条件由负责人准备),以及 `skill-cases.local.json` 中各职业的确认记录。
-4. 如需 STB-WARP/STB-DISCONNECT:合法传送入口的确认;确认可在 127.0.0.1 代理后运行,并核实传送是否下发直连地址。
-5. 树怪机位(平地/坡地/密林/传送点)的确认,以及 Metal 截图人工签核人。
diff --git a/docs/PARITY-GAP.md b/docs/PARITY-GAP.md
index 337b8710..e0f1a111 100644
--- a/docs/PARITY-GAP.md
+++ b/docs/PARITY-GAP.md
@@ -109,10 +109,10 @@ TreeSize/TreeVariance。当前实现已移除这些随机变换;若替代树
| # | 项 | 现状 | 目标 | 工作 | 规模 | BACKLOG |
|---|---|---|---|---|---|---|
-| 2.1 | 武器 mesh | 🟡 **首版已做 + 2026-08-31 复核**:`Metin2Model.weapon_gr2` / `weapon_bone`(默认 `equip_right_hand`,warrior bone[10])。加载武器 gr2 → `build_mesh`(rigid)→ 子 `MeshInstance3D`;每帧 `update_weapon_pose(world_pose[bone])`。**隔离探针实测**:`warrior_novice`/`warrior_cheongrin` × 8 个武器文件,weapon MI 全局原点 == `equip_right_hand` 骨骼全局原点,Δ = 0.00–0.06 m;world_demo 目视剑握在手里。之前记忆里「武器偏 ~10 m」是 cpu_skin `>=` / MapCoord 修复前的旧状态,已失效。新增 **grip pre-transform**(`weapon_pre = mul4x3(weaponInvWorld[0], weaponLocal[0])`,对齐客户端 `weaponComposite[0]` = `ModelInstanceUpdate.cpp:171`),`update_weapon_pose` 里 `mul4x3(weapon_pre, world_pose[hand])`;标准武器多校准 ~4–6 cm 握点。个别非标准文件(`02010`/`03010`/`03150`)骨骼带米级偏移 → 30 cm 夹断退回原始手部位姿(不回退)。`mul4x3` 提到 `gr2_bridge.h` 与 anim 共用 | 右手单持跟手 1:1;`03150` 类网格在文件里就离骨骼原点 ~3 m(资产缺陷,非变换问题)—— 需 per-weapon 偏移数据或修 .gr2。⬜ 左手/双持/盾、`_lod` 变体 | M | C5 / D2 |
+| 2.1 | 武器 mesh | 🟢 **已做 + 2026-09-15 复核**:`Metin2Model.weapon_gr2` / `weapon_bone` 按职业挂到 `equip_right_hand` / `equip_right`,左右手规则覆盖剑、匕首、弓和扇;每帧 `update_weapon_pose(world_pose[bone])`。刚性网格采用 `weapon_pre = weaponInvWorld[0]·weaponLocal[0]` 后再乘手骨世界矩阵,对齐 `ModelInstanceUpdate.cpp:148` 的附属模型采样及 composite。已删除错误的 30 cm 夹断:`03150` 原始网格离原点约 3 m,逆绑定矩阵正好抵消该偏移,并非资产损坏。`weapon_attach_test.gd` 现用真实 `03150` 可见 AABB 检查 8 种族的待机/走/跑/攻击,避免只测节点原点的假阳性 | 后续仅剩武器拖尾、特殊武器独立目视验收 | M | C5 / D2 |
| 2.2 | GPU 蒙皮路径的发型 | ✅ **已做**:`_build_gpu_mesh()` —— `enable_gpu_skin` 带发型时,把发型 mesh 以 bind pose 并入 GPU mesh,每个 `ARRAY_BONES` 值经 `hair_bone_remap`(发型骨 idx → base 骨 idx)重映射。**无需改 shader / 第二骨索引区**(warrior 74/74 全匹配)—— `SRC_SKIN` + 共享 `bones_tex` 直接形变。`_apply_materials` 给追加的发型 surface 上「skinned + alpha-test + hair 贴图」材质。实测 5 base + 1 hair surface,红发(TargetSkin)贴头、镂空干净、跟动画 | M(完成)| A1 |
| 2.3 | 换装 / 时装 / 翅膀 | 无 | 多 `MeshInstance3D` 绑同一骨架 + 部位遮挡 / 隐藏规则 | `.msm` + 物品系统的上层 | L | D1 / D4 |
-| 2.4 | 贴图换色 `SourceSkin→TargetSkin` | ✅ **首版已做**:`Metin2Model.hair_skin` —— 设了就用该 dds 当发型 albedo(代替 gr2 sibling 的 SourceSkin),对齐客户端 `SetMaterialImagePointer(part, SourceSkin, load(TargetSkin))`(`ActorInstanceData.cpp:152/225`)。`get_hair_options()` 现返回解析成绝对路径的 `source_skin` / `target_skin`。`formats.msm_hair` CTest 钉了 HairData 解析(同 gr2+SourceSkin、不同 TargetSkin)。demo 用红色变体 | ⬜ 非发型部位(护甲/时装)的 skin 表;`warrior_cheongrin` 头盔挡住发型,换色需换模型目视 | S(首版完成) | D3 |
+| 2.4 | 贴图换色 `SourceSkin→TargetSkin` | ✅ **已做**:发型走 `Metin2Model.hair_skin`;盔甲/时装走材质 basename 映射 `set_skin_texture(SourceSkin, TargetSkin)`,由外层 `PlayerView` 明确转发到原生模型,对齐 `SetMaterialImagePointer(part, SourceSkin, load(TargetSkin))`。真实 12019 Blue Steel Armour 已断言加载 `warrior_4-1.GR2` 并将全部同名身体 surface 换为 `warrior_4-2.dds`,保留独立脸部材质 | 扩展装备集合的目视抽检 | S(完成) | D3 |
| 2.5 | 固定功能材质状态覆盖 | 单 stage `MODULATE(TEX,DIFFUSE)` 近似;**sphere-map 分支已按源码端口**(见 §2.7)。**blend / two-sided 改读 `gr2::MaterialInfo`**:`metin2_model::decide_blend()` 优先 `alpha_blend`(`dump_materials` 端口 `Material.cpp:233` = Name `Blend*` + MapCount>1),`decide_two_sided()` 读 `two_sided`(+ hair/cloak 名兜底),`m2_material` 加 `cull_disabled` shader 变体。仅 hair 镂空 / effect 加法这两类 GR2 没编码的仍按表面名 | 复现最终机位实际触发的 alpha-test ref、多贴图组合 | 逐分支盘点 `EterGrnLib/Material.cpp`;GR2 不承载任意 D3D stage 状态 | M | B2/B4 |
| 2.6 | 顶点色 | ✅ **排查完毕:不适用**。全资产 `.gr2`(PC / monster2 / effect)**零** `DiffuseColor`/`Color0` 顶点成员(grep 整个 assets 目录 = 0)。Metin2 的「逐顶点颜色」实际是:① 地形雾 —— `MapOutdoorRenderSTP` 运行时动态 VB 里 `dwDiffuse`/`dwSpecular` 存雾色/雾系数(非资产,已由 Godot `Environment` 雾覆盖);② `.mse` 特效 `colorfactor` / 粒子 `TimeEventColor`(属 §1.2 `.mse` 端口)。libgr2 无需改,`build_mesh` 无需补 `ARRAY_COLOR` | — | B7 |
| 2.7 | 镜面 / 球面反射(护甲金属光) | 🟡 **管线已端口**:`SRC_MIX` / `SRC_SKIN` 加 `spec_map`/`spec_power`/`spec_enable`。按 `EterGrnLib/Material.cpp:305 __ApplySpecularRenderState` 复现:仅不透明面(客户端 alpha-blend 时回退纯 diffuse)、相机空间 `reflect(VERTEX,NORMAL).xy` 查 sphere map、`MODULATEALPHA_ADDCOLOR` = `EMISSION += sphere.rgb * tex.a * power`(`tex.a` = 护甲高光遮罩,`power` = `D3DRS_TEXTUREFACTOR.a`)。sphere map = 全局 `ETC/ymir work/special/spheremap.jpg`(客户端 index 0),`Metin2Model::_load_sphere_map()` 沿模型目录上溯解析。`Metin2Model.specular_power` 属性。**默认 0 = 完全休眠**,与之前逐字节一致 | 端口 `__ApplySpecularRenderState`(✅);接装备层用 `item_proto bSpecular/100` 驱动(✅ 身体/护甲:`project/ui/equip_model.gd:283-288` `_apply_body()`;⬜ 武器 / 发型 / 时装等其余部位未接);受控帧标定 power(⬜,§0 门禁) | M | B9 |
diff --git a/docs/REVIEW-f39a55fd.md b/docs/REVIEW-f39a55fd.md
deleted file mode 100644
index a440f138..00000000
--- a/docs/REVIEW-f39a55fd.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# f39a55fd 审查与修复
-
-审查对象:`f39a55fdd5f14cb2d14d67929cf45f438f572a06`。修复基于该提交,位于本地分支 `fix/playable-f39a55fd`。
-
-## 已确认的问题
-
-| 优先级 | 问题与触发条件 | 影响 | 修复 |
-| --- | --- | --- | --- |
-| P1 | `PlayableProbe._connections` 用强引用保存 `EffectRegistry`;重连或切图重建注册器 | 旧注册器及其解析缓存一直保留,观察器自身污染长跑内存结果 | 弱引用订阅源;重新绑定和统计时移除失效订阅 |
-| P1 | 终止联网/地图运行器或批量入口时,没有完整的子进程清理 | 客户端可能继续联网执行操作,代理、日志处理器、采样器可能残留 | 共用进程清理模块;保留信号退出码,仅对仍持有的子 PID 执行 TERM、有界等待、KILL 和 wait;批量入口转发终止 |
-| P1 | 外部 RSS 检查尚未写回时,已有干净退出的中间 `report.json`;之后直接聚合 | 缺失 `STB-MEMORY-01` 的 soak 仍可被发布汇总判 PASS | 汇总 soak PASS 时必须同时存在唯一的内存 PASS 用例和 PASS verdict |
-| P2 | 客户端报告、事件行或发布清单包含合法 JSON `null` 等错误类型 | 校验器抛出异常,无法生成应有的失败报告 | 校验 JSON 根类型、事件类型和单调时间字段;错误输入写出 FAIL 报告 |
-
-## 复现证据与回归入口
-
-- 注册器释放用例:模拟 30 次注册器替换。原实现出现 59 项断言失败;修复后 `playable_harness_test: failures=0`。
-- 取消运行用例:原联网和地图运行器均在收到 TERM 后留下客户端;回归检查终止入口后客户端已被回收,入口退出码为 143,并覆盖普通批量与 soak 入口。
-- 发布汇总用例:`node script/playable_release_test.mjs --baseline` 读取原提交的校验器,复现缺少内存检查仍然 PASS(该命令预期断言失败)。正常命令使用修复后的校验器。
-- 异常 JSON 用例:检查返回失败码的同时,要求实际生成失败报告,避免只根据异常退出码误判测试通过。
-
-可重复运行:
-
-```bash
-godot --headless --path project --script playable_harness_test.gd
-bash script/playable_gate_test.sh
-node script/playable_release_test.mjs
-node script/playable_fault_proxy_test.mjs
-node script/playable_soak_metrics_test.mjs
-node script/audit_playable_maps_test.mjs
-bash script/rendering_batch_test.sh
-git diff --check
-```
-
-本轮修复后结果:门禁夹具 126 项检查通过;渲染批处理 53 项通过、`failures=0`;注册器/联网状态机、发布汇总、故障代理、内存指标、地图资源审计测试通过;Shell/Node 语法检查及 `git diff --check` 通过。
-
-渲染批处理日志:`build/rendering/batch-20260911-211958-82865/`。
-
-这些是离线、回环代理和假签名客户端测试。实网战斗、真实两小时长跑、最终新包与人工画面对照仍需独立验收;本次审查不会将其改成已通过。
diff --git a/extension/src/metin2_anim.cpp b/extension/src/metin2_anim.cpp
index 30b8be98..3b9daa02 100644
--- a/extension/src/metin2_anim.cpp
+++ b/extension/src/metin2_anim.cpp
@@ -173,6 +173,11 @@ void Metin2AnimPlayer::_bind_methods() {
void Metin2AnimPlayer::set_anim_path(const String &p) {
if (p == anim_path) {
+ if (!playing) {
+ time = 0.0;
+ prev_time = 0.0;
+ playing = true;
+ }
return;
}
// Start a crossfade from the clip that is currently playing.
@@ -407,6 +412,7 @@ void Metin2AnimPlayer::reload() {
events.clear();
msa_metadata = fmt::Msa{};
last_info = "";
+ playing = true;
if (anim_path.is_empty()) {
return;
}
@@ -468,9 +474,11 @@ void Metin2AnimPlayer::reload() {
unmatched += String(tr.bone_name.c_str()) + " ";
}
}
- UtilityFunctions::print(vformat(
- "[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
- matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
+ if (diagnostics) {
+ UtilityFunctions::print(vformat(
+ "[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
+ matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
+ }
}
}
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
@@ -484,22 +492,30 @@ namespace {
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
-// Blend two bone world transforms: slerp rotation, lerp translation & scale
-// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
-// transient only — the doc sanctions "quat slerp + pos/scale lerp".
+// Blend two local bone transforms: slerp rotation, lerp translation and the
+// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
+// depend on authored shear, so it must survive the transition as well as steady
+// animation sampling.
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
godot::Vector3(a[8], a[9], a[10]));
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
godot::Vector3(b[8], b[9], b[10]));
- godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
- godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
+ const godot::Quaternion qa = ba.get_rotation_quaternion();
+ const godot::Quaternion qb = bb.get_rotation_quaternion();
+ godot::Quaternion q = qa.slerp(qb, w);
+ // Preserve Granny's complete scale/shear matrix. Keeping only get_scale()
+ // changes authored joints even at the endpoints and is especially visible in
+ // hands and shoulders. For B = R*S, extract S and interpolate all 3x3 terms.
+ const godot::Basis sa = godot::Basis(qa).inverse() * ba;
+ const godot::Basis sb = godot::Basis(qb).inverse() * bb;
+ godot::Basis ss;
+ for (int row = 0; row < 3; ++row) {
+ ss.rows[row] = sa.rows[row].lerp(sb.rows[row], w);
+ }
godot::Vector3 tt =
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
- godot::Basis rb(q);
- rb.rows[0] *= s.x;
- rb.rows[1] *= s.y;
- rb.rows[2] *= s.z;
+ const godot::Basis rb = godot::Basis(q) * ss;
gr2::Mat4 o{};
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
@@ -508,6 +524,46 @@ gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
return o;
}
+// Sample animation tracks into the model skeleton's LOCAL bone domain. Granny
+// blends controls before BuildWorldPose; doing the crossfade after world-pose
+// accumulation independently interpolates every joint endpoint and temporarily
+// changes parent/child distances (visible as stretched or collapsed limbs when
+// wait/walk/run switches).
+void sample_local_pose(const gr2::Skeleton &sk, const gr2::Animation &an, float t,
+ std::vector &local) {
+ std::vector track_local;
+ an.sample_local(t, track_local);
+ local.resize(sk.bones.size());
+ for (size_t i = 0; i < sk.bones.size(); ++i) {
+ local[i] = sk.bones[i].local_transform;
+ }
+ for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
+ for (size_t bi = 0; bi < sk.bones.size(); ++bi) {
+ if (sk.bones[bi].name == an.tracks[ti].bone_name) {
+ local[bi] = track_local[ti];
+ break;
+ }
+ }
+ }
+}
+
+void accumulate_local_pose(const gr2::Skeleton &sk, const std::vector &local,
+ const gr2::Mat4 &root_offset, std::vector &world,
+ std::vector &skin) {
+ static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
+ const size_t n = sk.bones.size();
+ world.assign(n, I);
+ skin.assign(n, I);
+ for (size_t i = 0; i < n; ++i) {
+ const int parent = sk.bones[i].parent;
+ const gr2::Mat4 &parent_world =
+ (parent < 0 || static_cast(parent) >= i) ? root_offset : world[parent];
+ world[i] = mul4x3(i < local.size() ? local[i] : sk.bones[i].local_transform,
+ parent_world);
+ skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
+ }
+}
+
} // namespace
void Metin2AnimPlayer::apply_pose(double t) {
@@ -548,32 +604,36 @@ void Metin2AnimPlayer::apply_pose(double t) {
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
- gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
+ bool pose_ready = false;
- // Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
- // skin_buf = invWorld · world_blended (PARITY §2.9).
+ // Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
+ // skin_buf = invWorld · world. Blending world transforms made bone endpoints
+ // take independent straight-line paths, shortening limbs around the midpoint.
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
- gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
- prev_skin_buf, &kNoOffset);
+ sample_local_pose(*sk, an, (float)tt, local_buf);
+ sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
w = w * w * (2.0 - w);
- const size_t n = std::min(world_buf.size(), prev_world_buf.size());
- blend_world_buf.resize(world_buf.size());
- for (size_t i = 0; i < n; ++i)
- blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
- for (size_t i = n; i < world_buf.size(); ++i)
- blend_world_buf[i] = world_buf[i];
- const auto &bones = sk->bones;
- for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
- skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
- world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
+ const size_t n = std::min(local_buf.size(), prev_local_buf.size());
+ blend_local_buf.resize(local_buf.size());
+ for (size_t i = 0; i < n; ++i) {
+ blend_local_buf[i] = blend_trs(prev_local_buf[i], local_buf[i], (float)w);
+ }
+ for (size_t i = n; i < local_buf.size(); ++i) {
+ blend_local_buf[i] = local_buf[i];
+ }
+ accumulate_local_pose(*sk, blend_local_buf, kNoOffset, world_buf, skin_buf);
+ pose_ready = true;
}
}
+ if (!pose_ready) {
+ gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
+ }
// Both skinning paths apply libgr2's per-bone deformer matrices
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
@@ -582,7 +642,11 @@ void Metin2AnimPlayer::apply_pose(double t) {
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
- if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
+ static const String kZero("0");
+ static const String kEnvName("MTGODOT_GPUSKIN");
+ String env = godot::OS::get_singleton()->get_environment(kEnvName);
+ // Default to GPU skinning (true) unless explicitly set to "0"
+ if (env != kZero) {
model->enable_gpu_skin(true);
model->gpu_skin(skin_buf);
} else {
diff --git a/extension/src/metin2_anim.h b/extension/src/metin2_anim.h
index 63b499db..5fddc36e 100644
--- a/extension/src/metin2_anim.h
+++ b/extension/src/metin2_anim.h
@@ -113,7 +113,9 @@ private:
double prev_anim_duration = 0.0;
bool prev_anim_loop = true;
double blend_elapsed = 0.0;
- std::vector prev_world_buf, prev_skin_buf, blend_world_buf;
+ // Crossfades operate before hierarchy accumulation so parent/child lengths
+ // remain stable while switching locomotion clips.
+ std::vector local_buf, prev_local_buf, blend_local_buf;
godot::Vector3 accumulation; // .msa Accumulation (root motion)
std::vector events;
diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp
index 28fb6cfd..4bf22a68 100644
--- a/extension/src/metin2_model.cpp
+++ b/extension/src/metin2_model.cpp
@@ -40,6 +40,22 @@ static const gr2::MaterialInfo *match_material(const std::vector> s_model_tex_cache;
+static HashMap s_model_dir_dds_cache;
+
+static bool model_diagnostics() {
+ static int diag = -1;
+ if (diag == -1) {
+ diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0;
+ }
+ return diag == 1;
+}
+
+void Metin2Model::clear_texture_cache() {
+ s_model_tex_cache.clear();
+ s_model_dir_dds_cache.clear();
+}
+
Metin2Model::Metin2Model() {
file = std::make_shared>();
lod_dist.push_back(18.0f);
@@ -66,6 +82,8 @@ void Metin2Model::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_specular_power", "p"), &Metin2Model::set_specular_power);
ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power);
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
+ ClassDB::bind_method(D_METHOD("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture);
+ ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures);
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
@@ -85,6 +103,7 @@ void Metin2Model::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
+ ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache);
// A renderer consumer may have framed the model before an animation switches
// it from the CPU mesh to GPU skinning. That switch updates the effective
// AABB, so expose a small notification instead of making each scene guess a
@@ -159,6 +178,28 @@ void Metin2Model::set_surface_texture(int surface, const String &path) {
}
}
+void Metin2Model::set_skin_texture(const String &source, const String &target) {
+ const String key = source.replace("\\", "/").get_file().get_basename().to_lower();
+ if (key.is_empty()) {
+ return;
+ }
+ if (target.is_empty()) {
+ skin_tex_override.erase(key);
+ } else {
+ skin_tex_override[key] = target;
+ }
+ if (is_inside_tree() && mi) {
+ _apply_materials();
+ }
+}
+
+void Metin2Model::clear_skin_textures() {
+ skin_tex_override.clear();
+ if (is_inside_tree() && mi) {
+ _apply_materials();
+ }
+}
+
void Metin2Model::set_gr2_path(const String &p) {
gr2_path = p;
if (is_inside_tree()) {
@@ -236,7 +277,9 @@ void Metin2Model::_load_lods(const String &loaded_spec) {
lod_files.push_back(std::move(slot));
}
if (!lod_files.empty()) {
- UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
+ if (model_diagnostics()) {
+ UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
+ }
}
}
@@ -518,7 +561,7 @@ void Metin2Model::reload() {
for (const auto &m : fi.meshes) {
vtot += (int)m.vertices.size();
}
- {
+ if (model_diagnostics()) {
int with_tex = 0;
for (const auto &mm : materials) {
if (!mm.diffuse_texture.empty()) {
@@ -535,7 +578,9 @@ void Metin2Model::reload() {
mesh->get_surface_count(),
vtot,
String(bounds));
- UtilityFunctions::print("[Metin2Model] ", last_info);
+ if (model_diagnostics()) {
+ UtilityFunctions::print("[Metin2Model] ", last_info);
+ }
_load_lods(gr2_spec);
@@ -738,19 +783,18 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
*slot_file = std::move(w);
const gr2::FileInfo &wfi = (**slot_file).file_info();
- // Grip pre-transform, mirroring the client's weapon composite:
- // weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
- // weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
- // (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
- // Most Metin2 weapons are authored directly in hand space so this pre is ~I;
- // a few non-standard files carry a huge bone offset that would fling the mesh
- // metres away — clamp those out and fall back to the raw hand pose.
+ // Grip pre-transform, mirroring Granny's rigid-mesh composite in the client:
+ // weaponWorld[0] = weaponLocal[0] · handWorld
+ // weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0]
+ // The explicit parent matrix passed to GrannyBuildWorldPose replaces the
+ // weapon model's InitialPlacement; multiplying it here would cancel 03150's
+ // inverse-bind correction. See ModelInstanceUpdate.cpp:148.
+ // Do not clamp a large composite translation: files such as 03150 also store
+ // their raw vertices metres away from the skeleton origin, and this authored
+ // inverse-bind transform is precisely what moves the grip back onto the hand.
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
- gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
- const float tx = pre[12], ty = pre[13], tz = pre[14];
- if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm
- slot_pre = pre;
+ slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
}
std::vector wparts = mtgodot::build_parts(wfi);
@@ -987,8 +1031,8 @@ String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
Ref Metin2Model::_load_dds(const String &path) {
Ref t;
- if (tex_cache.has(path)) {
- return tex_cache[path];
+ if (s_model_tex_cache.has(path)) {
+ return s_model_tex_cache[path];
}
if (FileAccess::file_exists(path)) {
mtgodot::Image dds = mtgodot::dds_from_file(path);
@@ -999,7 +1043,7 @@ Ref Metin2Model::_load_dds(const String &path) {
dds.rgba.size(), /*mipmaps=*/true);
}
}
- tex_cache[path] = t;
+ s_model_tex_cache[path] = t;
return t;
}
@@ -1140,7 +1184,9 @@ void Metin2Model::_apply_materials() {
const String stem = gr2_path.get_file().get_basename();
PackedStringArray dds_files;
- {
+ if (s_model_dir_dds_cache.has(dir)) {
+ dds_files = s_model_dir_dds_cache[dir];
+ } else {
Ref da = DirAccess::open(dir);
if (da.is_valid()) {
da->list_dir_begin();
@@ -1151,6 +1197,7 @@ void Metin2Model::_apply_materials() {
}
da->list_dir_end();
}
+ s_model_dir_dds_cache[dir] = dds_files;
}
// Surface s <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into
@@ -1230,6 +1277,13 @@ void Metin2Model::_apply_materials() {
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
tex = _load_dds(surface_tex_override[s]);
}
+ // `.msm` SourceSkin -> TargetSkin remaps are material-name based in the
+ // 40250 client. An armour can have face/body/effect surfaces in a
+ // different order, so applying TargetSkin to hard-coded surface 0 gives
+ // the right geometry with the wrong (usually base black) appearance.
+ if (tex.is_null() && skin_tex_override.has(mat_want)) {
+ tex = _load_dds(String(skin_tex_override[mat_want]));
+ }
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
if (tex.is_null() && use_gr2_materials) {
tex = find_dds(mat_want);
@@ -1270,8 +1324,10 @@ void Metin2Model::_apply_materials() {
surf_mats[s] = mat;
mi->set_surface_override_material(s, mat);
}
- UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
- textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
+ if (model_diagnostics()) {
+ UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
+ textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
+ }
}
void Metin2Model::_build_gpu_mesh() {
@@ -1353,7 +1409,9 @@ void Metin2Model::enable_cpu_skin(bool on) {
if (skel) {
skel->set_show_rest_only(true);
}
- UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
+ if (model_diagnostics()) {
+ UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
+ }
}
}
@@ -1652,7 +1710,9 @@ void Metin2Model::enable_gpu_skin(bool on) {
// sampler has produced its skin matrices.
gpu_skin_active = true;
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
- UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
+ if (model_diagnostics()) {
+ UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
+ }
}
void Metin2Model::gpu_skin(const std::vector &skin) {
diff --git a/extension/src/metin2_model.h b/extension/src/metin2_model.h
index 1834fa4c..a09139f1 100644
--- a/extension/src/metin2_model.h
+++ b/extension/src/metin2_model.h
@@ -8,6 +8,7 @@
#include
#include
#include
+#include
#include
#include
@@ -81,6 +82,11 @@ public:
// Explicit per-surface texture path override (index -> absolute .dds path).
void set_surface_texture(int surface, const godot::String &path);
+ // Reference SetMaterialImagePointer(part, SourceSkin, TargetSkin): replace
+ // every GR2 material whose texture basename matches source, rather than
+ // assuming that an armour skin always belongs to surface 0.
+ void set_skin_texture(const godot::String &source, const godot::String &target);
+ void clear_skin_textures();
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
godot::Array get_hair_options() const { return hair_options; }
@@ -160,6 +166,8 @@ public:
// libgr2 sanity probe (kept from M0').
godot::String probe_gr2(const godot::String &path) const;
+ static void clear_texture_cache();
+
protected:
static void _bind_methods();
@@ -176,6 +184,7 @@ private:
godot::Ref sphere_map; // shared sphere map, lazy-loaded
godot::Ref _load_sphere_map(); // "ymir work/special/spheremap.jpg"
godot::PackedStringArray surface_tex_override;
+ godot::Dictionary skin_tex_override; // lowercase source basename -> target path
godot::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
godot::Array hair_options; // from .msm HairData
@@ -196,10 +205,10 @@ private:
std::shared_ptr> weapon_file; // loaded weapon gr2
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
- // weapon's own bone[0] grip transform (invWorld · local); folded in so the mesh
- // aligns to the hand even when it is authored offset from its bone (client:
- // GrannySampleModelAnimationsAccelerated on the weapon skeleton). Identity when
- // the weapon has no skeleton or a ~identity bone.
+ // Weapon root rigid-mesh composite (invWorld · local),
+ // folded in before the parent hand matrix exactly as Granny does for an attached
+ // model. This may contain a large translation which cancels an authored vertex
+ // offset (notably 03150), so it must not be clamped.
gr2::Mat4 weapon_pre{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
godot::String shield_gr2;
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
@@ -220,7 +229,6 @@ private:
godot::MeshInstance3D *mi = nullptr;
godot::String last_info;
double ground_offset = 0.0;
- godot::HashMap> tex_cache;
// §2.10 LOD
bool lod_enabled = true;
diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp
index 041d82d2..f14fede9 100644
--- a/extension/src/metin2_world.cpp
+++ b/extension/src/metin2_world.cpp
@@ -153,8 +153,65 @@ bool add_attribute_collision(const fmt::AttributeCollision &collision,
return true;
}
+inline float cross_product_2d(float x1, float y1, float x2, float y2) {
+ return x1 * y2 - y1 * x2;
+}
+
+inline bool is_in_triangle_2d(float ax, float ay, float bx, float by, float cx, float cy, float tx, float ty) {
+ float c1 = cross_product_2d(bx - ax, by - ay, tx - ax, ty - ay);
+ float c2 = cross_product_2d(cx - bx, cy - by, tx - bx, ty - by);
+ float c3 = cross_product_2d(ax - cx, ay - cy, tx - cx, ty - cy);
+
+ if (c1 * c2 > 0.0f && c1 * c3 > 0.0f)
+ return true;
+
+ if (c1 * c2 * c3 == 0.0f) {
+ if (tx < ax && tx < bx && tx < cx) return false;
+ if (tx > ax && tx > bx && tx > cx) return false;
+ if (ty < ay && ty < by && ty < cy) return false;
+ if (ty > ay && ty > by && ty > cy) return false;
+ return true;
+ }
+
+ return false;
+}
+
+void extract_height_triangles(const fmt::AttributeData &data, const Transform3D &place,
+ std::vector &out_heights) {
+ for (const auto &height : data.heights) {
+ if (height.vertices.size() < 3)
+ continue;
+ for (size_t i = 0; i + 2 < height.vertices.size(); i += 3) {
+ Vector3 v0 = place.xform(attribute_position(height.vertices[i]));
+ Vector3 v1 = place.xform(attribute_position(height.vertices[i + 1]));
+ Vector3 v2 = place.xform(attribute_position(height.vertices[i + 2]));
+ Vector3 line1 = v1 - v0;
+ Vector3 line2 = v2 - v0;
+ Vector3 normal = line1.cross(line2);
+ float len = normal.length();
+ if (len < 1e-6f)
+ continue;
+ normal /= len;
+ if (std::abs(normal.y) < 0.001f)
+ continue; // vertical or near-vertical surface, not a walkable floor/deck
+ Metin2World::HeightTriangle tri;
+ tri.v0 = v0;
+ tri.v1 = v1;
+ tri.v2 = v2;
+ tri.min_x = std::min({v0.x, v1.x, v2.x});
+ tri.max_x = std::max({v0.x, v1.x, v2.x});
+ tri.min_z = std::min({v0.z, v1.z, v2.z});
+ tri.max_z = std::max({v0.z, v1.z, v2.z});
+ tri.normal = normal;
+ tri.d = normal.dot(v0);
+ out_heights.push_back(tri);
+ }
+ }
+}
+
int add_attribute_data(const std::string &path, const Transform3D &place,
- Node3D *root, MeshInstance3D *mesh) {
+ Node3D *root, MeshInstance3D *mesh,
+ std::vector *out_heights = nullptr) {
fmt::AttributeData data;
std::string error;
if (!fmt::parse_attribute_data_file(path, data, &error)) {
@@ -162,6 +219,9 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
" (" + error.c_str() + ")");
return 0;
}
+ if (out_heights) {
+ extract_height_triangles(data, place, *out_heights);
+ }
StaticBody3D *body = memnew(StaticBody3D);
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
body->set_collision_layer(2);
@@ -196,6 +256,15 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
return shapes;
}
+void load_attribute_heights(const std::string &path, const Transform3D &place,
+ std::vector &out_heights) {
+ fmt::AttributeData data;
+ std::string error;
+ if (fmt::parse_attribute_data_file(path, data, &error)) {
+ extract_height_triangles(data, place, out_heights);
+ }
+}
+
} // namespace
Metin2World::Metin2World() {}
@@ -468,7 +537,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
ck.am = am;
ck.root = croot;
if (objects_enabled && registry_ok && resolver)
- place_chunk_objects(tx, ty, croot, ck.objects, ck.trees);
+ place_chunk_objects(tx, ty, croot, ck.objects, ck.trees, ck.height_triangles);
if (registry_ok)
place_chunk_ambience(tx, ty);
objects_placed += ck.objects;
@@ -477,7 +546,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
return true;
}
-void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree) {
+void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj, int &n_tree,
+ std::vector &out_heights) {
n_obj = 0;
n_tree = 0;
const std::string mdir = std::string(map_dir().utf8().get_data());
@@ -576,13 +646,16 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
bool mdatr_built = false;
- if (collision_enabled) {
- String md = String(rp.c_str()).get_basename() + ".mdatr";
- if (FileAccess::file_exists(md)) {
- const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi);
+ String md = String(rp.c_str()).get_basename() + ".mdatr";
+ if (FileAccess::file_exists(md)) {
+ const std::string md_str(md.utf8().get_data());
+ if (collision_enabled) {
+ const int built = add_attribute_data(md_str, place, root, mi, &out_heights);
mdatr_built = built > 0;
if (mdatr_built)
++objects_mdatr_built;
+ } else {
+ load_attribute_heights(md_str, place, out_heights);
}
}
// A malformed/missing attribute resource still gets a conservative
@@ -880,11 +953,52 @@ double Metin2World::sample_height(double gx_m, double gz_m) const {
const int tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
const Chunk *c = chunk_at(tx, ty);
- if (!c || !c->hm)
- return 0.0;
- const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
- const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
- return fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
+ double terrain_h = 0.0;
+ if (c && c->hm) {
+ const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
+ const double ly = my_abs_cm - double(ty) * fmt::m2coord::CHUNK_CM;
+ terrain_h = fmt::terrain_height_at(*c->hm, lx, ly, setting.height_scale) * fmt::m2coord::CM_TO_M;
+ }
+
+ // 40250 CMapOutdoor::GetHeight(fx, fy):
+ // Check placed objects' height data (bridges, stairs, platforms from .mdatr).
+ // If object height > terrain height, return fMAX(fObjectHeight, fTerrainHeight).
+ float obj_h = -25000.0f;
+ bool obj_found = false;
+
+ const float fgx = float(gx_m);
+ const float fgz = float(gz_m);
+
+ for (int dtx = -1; dtx <= 1; ++dtx) {
+ int ntx = tx + dtx;
+ if (ntx < 0 || ntx >= setting.map_size_x)
+ continue;
+ for (int dty = -1; dty <= 1; ++dty) {
+ int nty = ty + dty;
+ if (nty < 0 || nty >= setting.map_size_y)
+ continue;
+ const Chunk *nc = chunk_at(ntx, nty);
+ if (!nc || nc->height_triangles.empty())
+ continue;
+ for (const auto &tri : nc->height_triangles) {
+ if (fgx < tri.min_x || fgx > tri.max_x ||
+ fgz < tri.min_z || fgz > tri.max_z)
+ continue;
+ if (is_in_triangle_2d(tri.v0.x, tri.v0.z, tri.v1.x, tri.v1.z, tri.v2.x, tri.v2.z, fgx, fgz)) {
+ float h = (tri.d - tri.normal.x * fgx - tri.normal.z * fgz) / tri.normal.y;
+ if (!obj_found || h > obj_h) {
+ obj_h = h;
+ obj_found = true;
+ }
+ }
+ }
+ }
+ }
+
+ if (obj_found && (c == nullptr || !c->hm || obj_h > terrain_h))
+ return double(obj_h);
+
+ return terrain_h;
}
Ref Metin2World::load_dds(const String &path) const {
diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h
index 7219c7b6..c7e4773a 100644
--- a/extension/src/metin2_world.h
+++ b/extension/src/metin2_world.h
@@ -37,6 +37,16 @@ class Metin2World : public godot::Node3D {
GDCLASS(Metin2World, godot::Node3D)
public:
+ struct HeightTriangle {
+ godot::Vector3 v0;
+ godot::Vector3 v1;
+ godot::Vector3 v2;
+ float min_x = 0.0f, max_x = 0.0f;
+ float min_z = 0.0f, max_z = 0.0f;
+ godot::Vector3 normal;
+ float d = 0.0f;
+ };
+
Metin2World();
~Metin2World() override;
@@ -154,6 +164,7 @@ private:
std::shared_ptr am;
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
int objects = 0, trees = 0;
+ std::vector height_triangles;
};
struct AmbienceSource {
int tile_x = 0, tile_y = 0;
@@ -173,7 +184,8 @@ private:
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_objects(int tx, int ty, godot::Node3D *root, int &n_obj, int &n_tree,
+ std::vector &out_heights);
void place_chunk_ambience(int tx, int ty);
void unload_chunk(int idx);
void stream_update();
diff --git a/extension/src/net/classic/classic_parser.cpp b/extension/src/net/classic/classic_parser.cpp
index e2b71eff..94a4917a 100644
--- a/extension/src/net/classic/classic_parser.cpp
+++ b/extension/src/net/classic/classic_parser.cpp
@@ -7,6 +7,25 @@
namespace mtnet::classic {
+namespace {
+uint8_t normalize_wire_ch_type(uint8_t type) {
+ switch (type) {
+ case WIRE_CHRTYPE_MONSTER: return CHRTYPE_MONSTER;
+ case WIRE_CHRTYPE_NPC: return CHRTYPE_NPC;
+ case WIRE_CHRTYPE_STONE: return CHRTYPE_STONE;
+ case WIRE_CHRTYPE_WARP: return CHRTYPE_WARP;
+ case WIRE_CHRTYPE_DOOR: return CHRTYPE_DOOR;
+ case WIRE_CHRTYPE_BUILDING: return CHRTYPE_BUILDING;
+ case WIRE_CHRTYPE_PC:
+ case WIRE_CHRTYPE_POLYMORPH_PC:
+ return CHRTYPE_PC;
+ case WIRE_CHRTYPE_HORSE: return CHRTYPE_HORSE;
+ case WIRE_CHRTYPE_GOTO: return CHRTYPE_GOTO;
+ default: return type;
+ }
+}
+} // namespace
+
void ClassicParser::set_npclist_path(std::string path) {
if (path == m_npclist_path) {
return;
@@ -191,9 +210,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
+ m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
+ m_world.mut_skill_group(p.skill_group);
return true;
}
case HDR_GC_MAIN_CHARACTER3_BGM: {
@@ -201,9 +222,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
+ m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
+ m_world.mut_skill_group(p.skill_group);
// §9.1: route the map's background-music track; volume unspecified.
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
@@ -214,9 +237,11 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
+ m_empire = p.empire;
m_world.mut_spawn_main(p.vid, p.race,
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(float)p.z);
+ m_world.mut_skill_group(p.skill_group);
// §9.1: track + server-authored volume (0..1).
m_world.mut_map_bgm(
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
@@ -233,7 +258,8 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (is_invisible_race(p.race)) {
return true;
}
- if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
+ const uint8_t ch_type = normalize_wire_ch_type(p.type);
+ if (ch_type == CHRTYPE_PC || ch_type == CHRTYPE_NPC) {
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
// parts, so stash a bare record and wait for
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
@@ -242,7 +268,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
e = Entity{};
e.vid = p.vid;
e.race = p.race;
- e.ch_type = p.type;
+ e.ch_type = ch_type;
e.x = (float)p.x;
e.y = (float)p.y;
e.z = (float)p.z;
@@ -257,7 +283,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
// Monster / stone / door / object: appended immediately, name from
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
// PC-specific field stays 0.
- m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
+ m_world.mut_spawn(p.vid, p.race, ch_type, npc_name_for(p.race), (float)p.x, (float)p.y,
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
return true;
}
@@ -266,7 +292,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
if (!fill(p, header, body, len)) {
return false;
}
- m_world.mut_spawn(p.vid, p.race, p.type,
+ m_world.mut_spawn(p.vid, p.race, normalize_wire_ch_type(p.type),
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
(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,
@@ -908,7 +934,19 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
std::memcpy(&si, cur, sizeof(si));
cur += sizeof(si);
if (si.vnum != 0) {
- tab.items.push_back({si.vnum, si.price, si.count, si.display_pos});
+ ShopEntry entry;
+ entry.vnum = si.vnum;
+ entry.price = si.price;
+ entry.count = si.count;
+ entry.pos = (uint8_t)j;
+ for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM && k < ITEM_SOCKET_MAX_NUM; ++k) {
+ entry.sockets[k] = si.sockets[k];
+ }
+ for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
+ entry.attrs[k].type = si.attrs[k].type;
+ entry.attrs[k].value = si.attrs[k].value;
+ }
+ tab.items.push_back(std::move(entry));
}
}
};
@@ -973,8 +1011,13 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
}
ShopUpdateItem update;
std::memcpy(&update, cur, sizeof(update));
+ ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
+ for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM && k < ITEM_ATTRIBUTE_MAX_NUM; ++k) {
+ attrs[k].type = update.item.attrs[k].type;
+ attrs[k].value = update.item.attrs[k].value;
+ }
m_world.mut_shop_update_item(update.pos, update.item.vnum, update.item.price,
- update.item.count);
+ update.item.count, update.item.sockets, attrs);
return true;
}
case SHOP_SUB_UPDATE_PRICE: {
diff --git a/extension/src/net/classic/wire_classic.h b/extension/src/net/classic/wire_classic.h
index 531ad8de..0a24f11e 100644
--- a/extension/src/net/classic/wire_classic.h
+++ b/extension/src/net/classic/wire_classic.h
@@ -57,8 +57,10 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
inline constexpr int GUILD_MARK_HEIGHT = 12;
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
-// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
-// (client GameType.h / server char.h). Values line up with the Entity comment.
+// CHRTYPE_* is the protocol-neutral Entity::ch_type domain used by the Godot
+// presentation layer. Do not put packet_add_char.bType into Entity verbatim:
+// the 40250 server's ECharType wire order is MONSTER=0, NPC=1, ... PC=6,
+// whereas the presentation layer historically uses PC=0, NPC=1, MONSTER=2.
enum : uint8_t {
CHRTYPE_PC = 0,
CHRTYPE_NPC = 1,
@@ -72,6 +74,22 @@ enum : uint8_t {
CHRTYPE_GOTO = 9,
};
+// Exact 40250 server/common/length.h ECharType values carried on the classic
+// packet wire. Keep these names separate from CHRTYPE_* so tests cannot
+// accidentally manufacture a packet with the normalized presentation value.
+enum : uint8_t {
+ WIRE_CHRTYPE_MONSTER = 0,
+ WIRE_CHRTYPE_NPC = 1,
+ WIRE_CHRTYPE_STONE = 2,
+ WIRE_CHRTYPE_WARP = 3,
+ WIRE_CHRTYPE_DOOR = 4,
+ WIRE_CHRTYPE_BUILDING = 5,
+ WIRE_CHRTYPE_PC = 6,
+ WIRE_CHRTYPE_POLYMORPH_PC = 7,
+ WIRE_CHRTYPE_HORSE = 8,
+ WIRE_CHRTYPE_GOTO = 9,
+};
+
// Races the reference client never spawns — REF/UserInterface/
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
// for one of these is dropped before any pending/stash step.
diff --git a/extension/src/net/entity_store.cpp b/extension/src/net/entity_store.cpp
index 4d41b820..01d68e81 100644
--- a/extension/src/net/entity_store.cpp
+++ b/extension/src/net/entity_store.cpp
@@ -63,6 +63,9 @@ Entity &EntityStore::touch(uint32_t vid, bool &created) {
if (created) {
Entity e;
e.vid = vid;
+ if (vid == m_main_vid) {
+ e.is_main = true;
+ }
it = m_ents.emplace(vid, e).first;
}
return it->second;
@@ -113,6 +116,7 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
e.z = z;
e.is_main = true;
e.moving = false;
+ e.state_queue.clear();
m_main_vid = vid;
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, vid});
if (!created) {
@@ -140,6 +144,10 @@ void EntityStore::mut_spawn_full(const Entity &src) {
if (e.vid == m_main_vid) {
e.is_main = true;
}
+ if (e.is_main) {
+ e.moving = false;
+ e.state_queue.clear();
+ }
m_ents[src.vid] = e;
m_changes.push_back({ChangeKind::Spawn, src.vid});
if (e.is_main) {
@@ -210,12 +218,26 @@ void EntityStore::mut_despawn(uint32_t vid) {
// 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, uint8_t arg, uint32_t chk_time) {
+ if (vid == m_main_vid) {
+ return;
+ }
auto it = m_ents.find(vid);
- if (it == m_ents.end()) {
+ if (it == m_ents.end() || it->second.is_main) {
return;
}
Entity &e = it->second;
- e.state_queue.push_back({chk_time, tx, ty, angle_deg, func, arg, dur_ms});
+ // 40250 CInstanceBase::PushTCPState EMA network latency smoothing:
+ uint32_t scheduled_time = chk_time;
+ if (m_server_frame_ms != 0 && chk_time != 0) {
+ int32_t nNetworkGap = (int32_t)m_server_frame_ms - (int32_t)chk_time;
+ if (e.average_network_gap == 0) {
+ e.average_network_gap = nNetworkGap;
+ } else {
+ e.average_network_gap = (e.average_network_gap * 70 + nNetworkGap * 30) / 100;
+ }
+ scheduled_time = chk_time + (uint32_t)std::max(0, e.average_network_gap);
+ }
+ e.state_queue.push_back({scheduled_time, tx, ty, angle_deg, func, arg, dur_ms});
drain_state_queue(e);
m_changes.push_back({ChangeKind::Move, vid});
}
@@ -351,6 +373,9 @@ void EntityStore::drain_state_queue(Entity &e) {
void EntityStore::process_states() {
for (auto &kv : m_ents) {
+ if (kv.second.is_main) {
+ continue;
+ }
drain_state_queue(kv.second);
}
}
@@ -412,7 +437,14 @@ void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
}
Entity &e = it->second;
switch (type) {
- case POINT_HP: e.hp = value; break;
+ case POINT_HP:
+ e.hp = value;
+ if (e.hp <= 0) {
+ e.dead = true;
+ e.moving = false;
+ e.func = FUNC_WAIT;
+ }
+ break;
case POINT_MAX_HP: e.max_hp = value; break;
case POINT_SP: e.sp = value; break;
case POINT_MAX_SP: e.max_sp = value; break;
@@ -859,7 +891,8 @@ void EntityStore::mut_shop_open(uint32_t vid, const std::vector &tabs)
m_shop_dirty = true;
}
-void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count) {
+void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
+ const int32_t *sockets, const ItemAttr *attrs) {
if (!m_shop_open || pos >= SHOP_HOST_ITEM_MAX_NUM) {
return;
}
@@ -871,18 +904,47 @@ void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t pric
if (vnum == 0) {
items.erase(items.begin() + (long)i);
} else {
- items[i] = {vnum, price, count, pos};
+ items[i].vnum = vnum;
+ items[i].price = price;
+ items[i].count = count;
+ items[i].pos = pos;
+ if (sockets) {
+ for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
+ items[i].sockets[k] = sockets[k];
+ }
+ }
+ if (attrs) {
+ for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
+ items[i].attrs[k] = attrs[k];
+ }
+ }
}
return;
}
if (vnum != 0) {
- items.push_back({vnum, price, count, pos});
+ ShopEntry entry;
+ entry.vnum = vnum;
+ entry.price = price;
+ entry.count = count;
+ entry.pos = pos;
+ if (sockets) {
+ for (int k = 0; k < ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
+ entry.sockets[k] = sockets[k];
+ }
+ }
+ if (attrs) {
+ for (int k = 0; k < ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
+ entry.attrs[k] = attrs[k];
+ }
+ }
+ items.push_back(std::move(entry));
}
};
update(m_shop_items);
if (!m_shop_tabs.empty()) {
update(m_shop_tabs[0].items);
}
+ m_shop_slot_updates.push_back(pos);
m_shop_dirty = true;
}
@@ -896,6 +958,7 @@ void EntityStore::mut_shop_close() {
m_shop_vid = 0;
m_shop_items.clear();
m_shop_tabs.clear();
+ m_shop_slot_updates.clear();
m_shop_selling_price = 0;
m_shop_dirty = true;
}
@@ -1581,6 +1644,7 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
e.z = (float)p.z;
e.is_main = true;
e.moving = false;
+ e.state_queue.clear();
m_main_vid = p.vid;
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, p.vid});
if (!created) {
@@ -1596,6 +1660,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
std::memcpy(&p, body, sizeof(p));
bool created;
Entity &e = touch(p.vid, created);
+ if (p.vid == m_main_vid) {
+ e.is_main = true;
+ e.moving = false;
+ e.state_queue.clear();
+ }
e.race = p.race;
e.ch_type = p.type;
e.angle = p.angle;
@@ -1616,6 +1685,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
std::memcpy(&p, body, sizeof(p));
bool created;
Entity &e = touch(p.vid, created);
+ if (p.vid == m_main_vid) {
+ e.is_main = true;
+ e.moving = false;
+ e.state_queue.clear();
+ }
e.race = p.race;
e.ch_type = p.type;
e.angle = p.angle;
@@ -1701,9 +1775,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCMove p;
std::memcpy(&p, body, sizeof(p));
+ if (p.vid == m_main_vid) {
+ return;
+ }
// 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,
+ // Server sends (int)GetRotation() / 5 (char.cpp SendMovePacket), client decodes rot * 5.0f.
+ mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
p.duration, p.arg, p.time);
return;
}
@@ -2056,7 +2133,14 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
if (it != m_ents.end()) {
Entity &e = it->second;
switch (p.type) {
- case POINT_HP: e.hp = p.value; break;
+ case POINT_HP:
+ e.hp = p.value;
+ if (e.hp <= 0) {
+ e.dead = true;
+ e.moving = false;
+ e.func = FUNC_WAIT;
+ }
+ break;
case POINT_MAX_HP: e.max_hp = p.value; break;
case POINT_SP: e.sp = p.value; break;
case POINT_MAX_SP: e.max_sp = p.value; break;
@@ -3578,7 +3662,7 @@ void EntityStore::tick() {
m_ticked = true;
for (auto &kv : m_ents) {
Entity &e = kv.second;
- if (!e.moving) {
+ if (!e.moving || e.is_main) {
continue;
}
const float speed = motion_move_speed(e);
@@ -3596,8 +3680,12 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
dbg_ignored("set_motion_speed", vid);
return;
}
- it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
- it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f;
+ float w = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
+ float r = run_cm_s > 0.0f ? run_cm_s : 0.0f;
+ if (w <= 0.0f && r > 0.0f) w = r;
+ if (r <= 0.0f && w > 0.0f) r = w;
+ it->second.walk_motion_speed = w;
+ it->second.run_motion_speed = r;
}
// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15,
@@ -3608,7 +3696,10 @@ void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s
// the on-foot motions, so a mounted actor (horse motion set not measured) also
// uses the lerp.
float EntityStore::motion_move_speed(const Entity &e) {
- const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
+ float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
+ if (base <= 0.0f) {
+ base = e.walk_mode == WALKMODE_WALK ? e.run_motion_speed : e.walk_motion_speed;
+ }
if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) {
return 0.0f;
}
@@ -3655,31 +3746,47 @@ void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms)
const float cur_len = std::sqrt(cx * cx + cy * cy);
const float next_len = std::sqrt(nx * nx + ny * ny);
- if (total - cur_len < -MOVE_OVERSHOOT_CM) {
- // latency overran Dst: re-source here, turn back toward Dst, and stop at
- // the next arrival instead of carrying on with FUNC_MOVE.
- e.sx = e.x;
- e.sy = e.y;
- e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
- if (e.mov_after_func == FUNC_MOVE) {
- e.mov_after_func = FUNC_WAIT;
- }
- } else if (cur_len <= total && total <= next_len) {
- if (e.dead || e.knock_down) {
- e.moving = false;
- e.skip_collision = false;
+ if (e.ch_type != 0) {
+ // Non-PC (monsters/NPCs): stop cleanly upon reaching Dst, snap to (tx, ty),
+ // and never turn 180° back-and-forth across latency/overshoot.
+ if ((cur_len <= total && total <= next_len) || (total - cur_len < 0.0f)) {
+ if (e.dead || e.knock_down) {
+ e.moving = false;
+ e.skip_collision = false;
+ return;
+ }
+ e.x = e.tx;
+ e.y = e.ty;
+ finish_walk(e, true);
return;
}
- if (e.mov_after_func == FUNC_MOVE) {
- // FUNC_MOVE: keep walking past Dst until the next state command (or
- // the overshoot guard above) — this is what keeps a moving remote PC
- // from stop-starting between its periodic move packets.
- e.x = next_x;
- e.y = next_y;
+ } else {
+ if (total - cur_len < -MOVE_OVERSHOOT_CM) {
+ // latency overran Dst: re-source here, turn back toward Dst, and stop at
+ // the next arrival instead of carrying on with FUNC_MOVE.
+ e.sx = e.x;
+ e.sy = e.y;
+ e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
+ if (e.mov_after_func == FUNC_MOVE) {
+ e.mov_after_func = FUNC_WAIT;
+ }
+ } else if (cur_len <= total && total <= next_len) {
+ if (e.dead || e.knock_down) {
+ e.moving = false;
+ e.skip_collision = false;
+ return;
+ }
+ if (e.mov_after_func == FUNC_MOVE) {
+ // FUNC_MOVE: keep walking past Dst until the next state command (or
+ // the overshoot guard above) — this is what keeps a moving remote PC
+ // from stop-starting between its periodic move packets.
+ e.x = next_x;
+ e.y = next_y;
+ return;
+ }
+ finish_walk(e, true);
return;
}
- finish_walk(e, true);
- return;
}
e.x = next_x;
e.y = next_y;
diff --git a/extension/src/net/entity_store.h b/extension/src/net/entity_store.h
index 8e3f8193..0e41493a 100644
--- a/extension/src/net/entity_store.h
+++ b/extension/src/net/entity_store.h
@@ -68,7 +68,7 @@ static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
- uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
+ uint8_t ch_type = 0; // normalized CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, ...
std::string name;
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
@@ -102,6 +102,7 @@ struct Entity {
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
// actor-vs-actor collision until it arrives.
std::deque state_queue;
+ int32_t average_network_gap = 0; // 40250 CInstanceBase::m_nAverageNetworkGap EMA filter
uint8_t mov_after_func = FUNC_WAIT;
uint8_t mov_after_arg = 0;
float mov_after_rot = 0;
@@ -305,6 +306,8 @@ struct ShopEntry {
uint32_t price = 0;
uint8_t count = 0;
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
+ int32_t sockets[ITEM_SOCKET_SLOT_MAX_NUM] = {0, 0, 0};
+ ItemAttr attrs[ITEM_ATTRIBUTE_SLOT_MAX_NUM] = {};
};
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
@@ -653,7 +656,8 @@ public:
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
void mut_shop_open(uint32_t vid, const std::vector &tabs);
- void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count);
+ void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count,
+ const int32_t *sockets = nullptr, const ItemAttr *attrs = nullptr);
void mut_shop_update_price(int32_t price);
void mut_shop_close();
void mut_shop_error(const std::string &code);
@@ -858,6 +862,11 @@ public:
m_shop_errors.clear();
return v;
}
+ std::vector drain_shop_slot_updates() {
+ auto v = std::move(m_shop_slot_updates);
+ m_shop_slot_updates.clear();
+ return v;
+ }
// --- P8 exchange ---
const ExchangeState &exchange() const { return m_exchange; }
@@ -1293,6 +1302,7 @@ private:
bool m_shop_dirty = false;
int32_t m_shop_selling_price = 0;
std::vector m_shop_errors;
+ std::vector m_shop_slot_updates;
ExchangeState m_exchange;
bool m_exchange_dirty = false;
Item m_safebox[SAFEBOX_MAX_NUM];
diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp
index 07432173..866e36e1 100644
--- a/extension/src/net/m2_client.cpp
+++ b/extension/src/net/m2_client.cpp
@@ -284,12 +284,16 @@ void M2Client::_bind_methods() {
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
ADD_SIGNAL(MethodInfo("shop_closed"));
+ ADD_SIGNAL(MethodInfo("shop_updated", PropertyInfo(Variant::INT, "pos")));
ADD_SIGNAL(MethodInfo("shop_error", PropertyInfo(Variant::STRING, "kind")));
ADD_SIGNAL(MethodInfo("exchange_changed"));
+ ADD_SIGNAL(MethodInfo("safebox_opened", PropertyInfo(Variant::INT, "size")));
+ ADD_SIGNAL(MethodInfo("safebox_closed"));
ADD_SIGNAL(MethodInfo("safebox_changed"));
ADD_SIGNAL(MethodInfo("safebox_password_required"));
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
+ ADD_SIGNAL(MethodInfo("mall_closed"));
ADD_SIGNAL(MethodInfo("mall_changed"));
ADD_SIGNAL(MethodInfo("mall_password_required"));
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
@@ -530,6 +534,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
classic_last_empire = -1;
shop_open_seen = false;
mall_open_seen = false;
+ safebox_open_seen = false;
last_game_phase = -1;
if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port,
std::string(g_host.utf8().get_data()), (uint16_t)g_port,
@@ -580,6 +585,7 @@ void M2Client::disconnect_from_server() {
}
shop_open_seen = false;
mall_open_seen = false;
+ safebox_open_seen = false;
selected_pid = 0;
if (stage != Stage::Idle) {
set_stage(Stage::Idle);
@@ -1277,6 +1283,19 @@ static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) {
d["vnum"] = (int)s.vnum;
d["price"] = (int)s.price;
d["count"] = (int)s.count;
+ Array sockets;
+ for (int k = 0; k < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++k) {
+ sockets.push_back(s.sockets[k]);
+ }
+ d["sockets"] = sockets;
+ Array attrs;
+ for (int k = 0; k < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++k) {
+ Dictionary ad;
+ ad["type"] = (int)s.attrs[k].type;
+ ad["value"] = (int)s.attrs[k].value;
+ attrs.push_back(ad);
+ }
+ d["attrs"] = attrs;
return d;
}
@@ -1466,6 +1485,31 @@ int M2Client::get_safebox_gold() const {
const mtnet::EntityStore *world = active_world();
return world ? (int)world->safebox_gold() : 0;
}
+static Dictionary item_dict(const mtnet::Item &it) {
+ Dictionary d;
+ d["vnum"] = (int)it.vnum;
+ d["count"] = (int)it.count;
+ d["flags"] = (int)it.flags;
+ d["anti_flags"] = (int)it.anti_flags;
+ Array sockets;
+ for (int i = 0; i < 3; ++i) {
+ sockets.push_back(it.sockets[i]);
+ }
+ d["sockets"] = sockets;
+ Array attrs;
+ for (int i = 0; i < 7; ++i) {
+ if (it.attrs[i].type == 0) {
+ continue;
+ }
+ Dictionary a;
+ a["type"] = (int)it.attrs[i].type;
+ a["value"] = (int)it.attrs[i].value;
+ attrs.push_back(a);
+ }
+ d["attrs"] = attrs;
+ return d;
+}
+
Array M2Client::get_safebox_items() const {
Array out;
const mtnet::EntityStore *world = active_world();
@@ -1477,10 +1521,8 @@ Array M2Client::get_safebox_items() const {
if (it.empty()) {
continue;
}
- Dictionary d;
+ Dictionary d = item_dict(it);
d["cell"] = c;
- d["vnum"] = (int)it.vnum;
- d["count"] = (int)it.count;
out.push_back(d);
}
return out;
@@ -1507,10 +1549,8 @@ Array M2Client::get_mall_items() const {
if (it.empty()) {
continue;
}
- Dictionary d;
+ Dictionary d = item_dict(it);
d["cell"] = c;
- d["vnum"] = (int)it.vnum;
- d["count"] = (int)it.count;
out.push_back(d);
}
return out;
@@ -2476,7 +2516,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
Dictionary d;
d["vid"] = (int)e.vid;
d["race"] = (int)e.race;
- d["ch_type"] = (int)e.ch_type; // 0 PC, 1 NPC, 2 MONSTER, ...
+ d["ch_type"] = (int)e.ch_type; // normalized: 0 PC, 1 NPC, 2 MONSTER, ...
d["name"] = String::utf8(e.name.c_str());
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
d["is_main"] = e.is_main;
@@ -2701,30 +2741,6 @@ bool M2Client::pickup_item(int ground_vid) {
return game && game->send_item_pickup((uint32_t)ground_vid);
}
-static Dictionary item_dict(const mtnet::Item &it) {
- Dictionary d;
- d["vnum"] = (int)it.vnum;
- d["count"] = (int)it.count;
- d["flags"] = (int)it.flags;
- d["anti_flags"] = (int)it.anti_flags;
- Array sockets;
- for (int i = 0; i < 3; ++i) {
- sockets.push_back(it.sockets[i]);
- }
- d["sockets"] = sockets;
- Array attrs;
- for (int i = 0; i < 7; ++i) {
- if (it.attrs[i].type == 0) {
- continue;
- }
- Dictionary a;
- a["type"] = (int)it.attrs[i].type;
- a["value"] = (int)it.attrs[i].value;
- attrs.push_back(a);
- }
- d["attrs"] = attrs;
- return d;
-}
Array M2Client::get_inventory() const {
Array out;
@@ -2930,6 +2946,7 @@ void M2Client::warp_to_game_server(const String &host, int port) {
classic_last_empire = -1;
shop_open_seen = false;
mall_open_seen = false;
+ safebox_open_seen = false;
dead_seen.clear();
classic_sess = std::make_unique();
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
@@ -3299,6 +3316,9 @@ void M2Client::pump_classic() {
emit_signal("shop_closed");
}
}
+ for (uint8_t pos : w.drain_shop_slot_updates()) {
+ emit_signal("shop_updated", (int)pos);
+ }
for (const auto &err : w.drain_shop_errors()) {
emit_signal("shop_error", String::utf8(err.c_str()));
}
@@ -3325,12 +3345,21 @@ void M2Client::pump_classic() {
emit_signal("exchange_changed");
}
if (w.safebox_dirty()) {
+ bool safe_now = w.safebox_open();
+ if (safe_now && !safebox_open_seen) {
+ emit_signal("safebox_opened", w.safebox_size());
+ } else if (!safe_now && safebox_open_seen) {
+ emit_signal("safebox_closed");
+ }
+ safebox_open_seen = safe_now;
emit_signal("safebox_changed");
}
if (w.mall_dirty()) {
bool mall_now = w.mall_open();
if (mall_now && !mall_open_seen) {
emit_signal("mall_opened", w.mall_size());
+ } else if (!mall_now && mall_open_seen) {
+ emit_signal("mall_closed");
}
mall_open_seen = mall_now;
emit_signal("mall_changed");
@@ -3433,6 +3462,7 @@ void M2Client::pump_classic() {
classic_last_stage = -1;
shop_open_seen = false;
mall_open_seen = false;
+ safebox_open_seen = false;
dead_seen.clear();
set_stage(Stage::GameConnect);
return;
@@ -3826,6 +3856,9 @@ void M2Client::pump_game() {
emit_signal("shop_closed");
}
}
+ for (uint8_t pos : w.drain_shop_slot_updates()) {
+ emit_signal("shop_updated", (int)pos);
+ }
for (const auto &err : w.drain_shop_errors()) {
emit_signal("shop_error", String::utf8(err.c_str()));
}
@@ -3833,12 +3866,21 @@ void M2Client::pump_game() {
emit_signal("exchange_changed");
}
if (w.safebox_dirty()) {
+ bool safe_now = w.safebox_open();
+ if (safe_now && !safebox_open_seen) {
+ emit_signal("safebox_opened", w.safebox_size());
+ } else if (!safe_now && safebox_open_seen) {
+ emit_signal("safebox_closed");
+ }
+ safebox_open_seen = safe_now;
emit_signal("safebox_changed");
}
if (w.mall_dirty()) {
bool mall_now = w.mall_open();
if (mall_now && !mall_open_seen) {
emit_signal("mall_opened", w.mall_size());
+ } else if (!mall_now && mall_open_seen) {
+ emit_signal("mall_closed");
}
mall_open_seen = mall_now;
emit_signal("mall_changed");
diff --git a/extension/src/net/m2_client.h b/extension/src/net/m2_client.h
index 208bd50f..d98427bf 100644
--- a/extension/src/net/m2_client.h
+++ b/extension/src/net/m2_client.h
@@ -370,6 +370,7 @@ private:
bool enter_game_sent = false;
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
bool mall_open_seen = false;
+ bool safebox_open_seen = false;
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
void warp_to_game_server(const godot::String &host, int port);
std::unordered_set dead_seen; // vids we've already fired entity_dead for
diff --git a/extension/src/static_object.cpp b/extension/src/static_object.cpp
index 6a4fdd6d..e7e759e6 100644
--- a/extension/src/static_object.cpp
+++ b/extension/src/static_object.cpp
@@ -49,7 +49,11 @@ Ref material_for(const std::string &tex_name, bool alpha_ble
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
mat->set_roughness(1.0f);
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
- mat->set_cull_mode(StandardMaterial3D::CULL_BACK);
+ mat->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
+ mat->set_diffuse_mode(StandardMaterial3D::DIFFUSE_LAMBERT_WRAP);
+ mat->set_specular_mode(StandardMaterial3D::SPECULAR_DISABLED);
+ mat->set_feature(StandardMaterial3D::FEATURE_BACKLIGHT, true);
+ mat->set_backlight(Color(0.2f, 0.2f, 0.2f));
String t = String(tex_name.c_str()).to_lower();
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
@@ -78,6 +82,10 @@ Ref material_for(const std::string &tex_name, bool alpha_ble
if (tex.is_valid()) {
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
mat->set_albedo(Color(1, 1, 1));
+ mat->set_feature(StandardMaterial3D::FEATURE_EMISSION, true);
+ mat->set_texture(StandardMaterial3D::TEXTURE_EMISSION, tex);
+ mat->set_emission(Color(1.0f, 1.0f, 1.0f));
+ mat->set_emission_energy_multiplier(0.18f);
}
return mat;
}
diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp
index a98ca667..ede4c992 100644
--- a/extension/tests/net_classic_session_test.cpp
+++ b/extension/tests/net_classic_session_test.cpp
@@ -228,10 +228,13 @@ int main() {
mc.y = 200000;
mc.z = 3;
mc.empire = 1;
+ mc.skill_group = 2;
feed(s, raw(mc));
CHECK(s.world().main_vid() == 7777, "main vid set");
const auto *me = s.world().get(7777);
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
+ CHECK(s.world().skill_group() == 2, "skill_group propagated to world");
+ CHECK(s.parser().empire() == 1, "empire propagated to parser");
auto version = drain(s);
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
@@ -248,7 +251,7 @@ int main() {
add.header = HDR_GC_CHARACTER_ADD;
add.vid = 8888;
add.race = 101;
- add.type = CHRTYPE_NPC;
+ add.type = WIRE_CHRTYPE_NPC;
add.x = 100500;
add.y = 200500;
add.moving_speed = 150;
@@ -279,7 +282,7 @@ int main() {
mob.header = HDR_GC_CHARACTER_ADD;
mob.vid = 8890;
mob.race = 101;
- mob.type = CHRTYPE_MONSTER;
+ mob.type = WIRE_CHRTYPE_MONSTER;
mob.x = 101000;
mob.y = 201000;
feed(s, raw(mob));
@@ -292,7 +295,7 @@ int main() {
ghost.header = HDR_GC_CHARACTER_ADD;
ghost.vid = 8891;
ghost.race = 20025;
- ghost.type = CHRTYPE_MONSTER;
+ ghost.type = WIRE_CHRTYPE_MONSTER;
feed(s, raw(ghost));
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
s.world().size() == 3, "invisible race dropped");
diff --git a/extension/tools/net_classic_e2e.cpp b/extension/tools/net_classic_e2e.cpp
index 1ae738af..e5a9f290 100644
--- a/extension/tools/net_classic_e2e.cpp
+++ b/extension/tools/net_classic_e2e.cpp
@@ -14,6 +14,7 @@
#include "../src/net/classic/classic_session.h"
#include
+#include
#include
#include
#include
@@ -74,7 +75,8 @@ int main(int argc, char **argv) {
const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ?
std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000;
session.set_auto_entergame_delay(enter_delay_ms);
- session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
+ session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr &&
+ std::string(std::getenv("MT_NET_TRACE")) == "1");
const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr;
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
@@ -140,12 +142,25 @@ int main(int argc, char **argv) {
}
if (session.stage() == mtnet::INetSession::Stage::InGame) {
const auto &world = session.world();
+ std::array entity_types{};
+ for (uint32_t vid : world.vids()) {
+ if (const auto *entity = world.get(vid); entity && entity->ch_type < entity_types.size()) {
+ ++entity_types[entity->ch_type];
+ }
+ }
int inventory_count = 0;
for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) {
if (!world.inv_slot(cell).empty()) ++inventory_count;
}
- std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d\n",
- world.main_vid(), world.size(), inventory_count, world.points().level());
+ std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d skill_group=%u\n",
+ world.main_vid(), world.size(), inventory_count, world.points().level(),
+ (unsigned)world.skill_group());
+ std::printf(" entity_types pc=%d npc=%d monster=%d stone=%d warp=%d door=%d building=%d horse=%d goto=%d\n",
+ entity_types[mtnet::classic::CHRTYPE_PC], entity_types[mtnet::classic::CHRTYPE_NPC],
+ entity_types[mtnet::classic::CHRTYPE_MONSTER], entity_types[mtnet::classic::CHRTYPE_STONE],
+ entity_types[mtnet::classic::CHRTYPE_WARP], entity_types[mtnet::classic::CHRTYPE_DOOR],
+ entity_types[mtnet::classic::CHRTYPE_BUILDING], entity_types[mtnet::classic::CHRTYPE_HORSE],
+ entity_types[mtnet::classic::CHRTYPE_GOTO]);
if (const mtnet::Entity *me = world.get(world.main_vid())) {
std::printf(" main race=%u pos_cm=(%.0f,%.0f,%.0f) parts=[%u,%u,%u,%u] name=%s\n",
me->race, me->x, me->y, me->z, me->parts[0], me->parts[1], me->parts[2],
@@ -158,6 +173,19 @@ int main(int argc, char **argv) {
(unsigned)item.count);
}
}
+ for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) {
+ const mtnet::QuickSlot slot = world.quickslot(pos);
+ if (slot.type != 0) {
+ std::printf(" quickslot pos=%d type=%u ref=%u\n", pos,
+ (unsigned)slot.type, (unsigned)slot.position);
+ }
+ }
+ for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) {
+ if (world.skill_level(id) != 0) {
+ std::printf(" skill id=%d level=%u master=%u\n", id,
+ (unsigned)world.skill_level(id), (unsigned)world.skill_master(id));
+ }
+ }
session.disconnect();
return 0;
}
diff --git a/project/alignment_pk_system.gd b/project/alignment_pk_system.gd
new file mode 100644
index 00000000..23a84779
--- /dev/null
+++ b/project/alignment_pk_system.gd
@@ -0,0 +1,187 @@
+# alignment_pk_system.gd —— Metin2 40250 善恶值与名望头衔奖惩系统 1:1
+# 对照 40250 服务端 char.cpp:1190-1205, char_battle.cpp:UpdateAlignment, root/localeinfo.py:GetAlignmentTitleName
+class_name AlignmentPkSystem
+extends RefCounted
+
+signal alignment_changed(old_val: int, new_val: int, title: String)
+signal title_changed(old_title: String, new_title: String)
+signal items_dropped_on_death(dropped_items: Array)
+signal zen_bean_consumed(cleared_amount: int, current_alignment: int)
+
+# 40250 官方善恶值极值 (内部数值 -200000 ~ +200000, 显示数值 -20000 ~ +20000)
+const MIN_REAL_ALIGNMENT := -200000
+const MAX_REAL_ALIGNMENT := 200000
+
+const VNUM_ZEN_BEAN := 70102 # 禅豆 (消减罪孽值)
+const ZEN_BEAN_CLEAR_POINTS := 500 # 每次消除 500 显示点数 (5000 内部点数)
+
+# 9 大官方经典头衔段位表 (40250 localeinfo.py / locale_game.txt)
+const TITLES: Array = [
+ {"threshold": 12000, "name": "崇高骑士", "en_name": "Chivalric", "is_evil": false},
+ {"threshold": 8000, "name": "尊贵贵族", "en_name": "Noble", "is_evil": false},
+ {"threshold": 4000, "name": "正义良民", "en_name": "Good", "is_evil": false},
+ {"threshold": 1000, "name": "友好伙伴", "en_name": "Friendly", "is_evil": false},
+ {"threshold": 0, "name": "中立行者", "en_name": "Neutral", "is_evil": false},
+ {"threshold": -3999, "name": "好斗狂徒", "en_name": "Aggressive","is_evil": true},
+ {"threshold": -7999, "name": "狡诈恶徒", "en_name": "Fraudulent","is_evil": true},
+ {"threshold": -11999,"name": "险恶凶手", "en_name": "Malicious", "is_evil": true},
+ {"threshold": -20000,"name": "残忍魔头", "en_name": "Cruel", "is_evil": true}
+]
+
+var real_alignment: int = 0 # 内部实际值 (-200000 ~ +200000)
+
+# 获取玩家当前对外显示的善恶值 (40250: sAlignment = m_iAlignment / 10)
+func get_alignment() -> int:
+ return int(real_alignment / 10)
+
+# 根据善恶值获取官方 1:1 头衔名称 (40250 localeinfo.py:GetAlignmentTitleName)
+static func get_title_name(alignment: int) -> String:
+ if alignment >= 12000:
+ return "崇高骑士"
+ elif alignment >= 8000:
+ return "尊贵贵族"
+ elif alignment >= 4000:
+ return "正义良民"
+ elif alignment >= 1000:
+ return "友好伙伴"
+ elif alignment >= 0:
+ return "中立行者"
+ elif alignment > -4000:
+ return "好斗狂徒"
+ elif alignment > -8000:
+ return "狡诈恶徒"
+ elif alignment > -12000:
+ return "险恶凶手"
+ else:
+ return "残忍魔头"
+
+# 检查当前是否为红名/恶名
+func is_evil() -> bool:
+ return get_alignment() < 0
+
+# 更新善恶值 (40250 char_battle.cpp:UpdateAlignment)
+# amount 为内部值加减 (如传入 100 表示内部 +100)
+func update_real_alignment(amount: int) -> void:
+ var old_display = get_alignment()
+ var old_title = get_title_name(old_display)
+
+ real_alignment = clampi(real_alignment + amount, MIN_REAL_ALIGNMENT, MAX_REAL_ALIGNMENT)
+
+ var new_display = get_alignment()
+ var new_title = get_title_name(new_display)
+
+ if old_display != new_display:
+ alignment_changed.emit(old_display, new_display, new_title)
+
+ if old_title != new_title:
+ title_changed.emit(old_title, new_title)
+
+# 按显示点数直接调整善恶值 (如传入 +500 相当于内部 +5000)
+func update_alignment_points(points: int) -> void:
+ update_real_alignment(points * 10)
+
+# 在线时间自然恢复善恶值 (40250 char.cpp:1194-1203)
+# minutes: 挂机分钟数
+func recover_alignment_by_time(minutes: int, has_fast_recovery_item: bool = false) -> void:
+ if minutes <= 0:
+ return
+
+ if real_alignment < 0:
+ var rate_per_min = 120 if has_fast_recovery_item else 60
+ update_alignment_points(rate_per_min * minutes)
+ else:
+ var rate_per_min = 5
+ update_alignment_points(rate_per_min * minutes)
+
+# 使用禅豆消减罪孽 (40250 Zen Bean 70102)
+func use_zen_bean(inventory: Array, slot_index: int) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ if int(item.get("vnum", 0)) != VNUM_ZEN_BEAN:
+ return {"ok": false, "reason": "NOT_A_ZEN_BEAN", "msg": "此物品不是禅豆!"}
+
+ var cur_align := get_alignment()
+ if cur_align >= 0:
+ return {"ok": false, "reason": "ALIGNMENT_ALREADY_GOOD", "msg": "当前心境平和,无需使用禅豆消业!"}
+
+ # 消耗 1 颗禅豆
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ # 恢复 500 点善恶值 (若剩余负值不足 500 则刚好回满到 0)
+ var clear_amount: int = mini(ZEN_BEAN_CLEAR_POINTS, -cur_align)
+ update_alignment_points(clear_amount)
+
+ zen_bean_consumed.emit(clear_amount, get_alignment())
+
+ return {
+ "ok": true,
+ "cleared": clear_amount,
+ "current_alignment": get_alignment(),
+ "title": get_title_name(get_alignment()),
+ "msg": "服用禅豆,消除 %d 点罪业!当前善恶值:%d (%s)" % [clear_amount, get_alignment(), get_title_name(get_alignment())]
+ }
+
+# 击杀和平角色 (恶名惩罚)
+func record_pk_kill() -> void:
+ update_alignment_points(-2000)
+
+# 击破魔石或野外怪物 (正向善名积累)
+func record_monster_kill(is_metin: bool = false) -> void:
+ var bonus = 50 if is_metin else 10
+ update_alignment_points(bonus)
+
+# 死亡惩罚计算 (40250 红名野外死亡爆装机制)
+func handle_death_drop_penalty(inventory: Array) -> Array:
+ var cur_align := get_alignment()
+ if cur_align >= 0:
+ return [] # 白名/良民免死掉落
+
+ var drop_chance := 0.10
+ var max_drop_count := 1
+
+ if cur_align <= -12000: # 残忍魔头
+ drop_chance = 0.80
+ max_drop_count = 3
+ elif cur_align <= -8000: # 险恶凶手
+ drop_chance = 0.50
+ max_drop_count = 2
+ elif cur_align <= -4000: # 狡诈恶徒
+ drop_chance = 0.25
+ max_drop_count = 1
+
+ var dropped_items: Array = []
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ continue
+ var item: Dictionary = inventory[i]
+ # 灵魂绑定锁定的装备免于死亡掉落
+ if item.get("is_soulbound", false):
+ continue
+
+ # 判定概率爆出
+ if randf() <= drop_chance:
+ dropped_items.append(item.duplicate(true))
+ inventory[i] = null
+ if dropped_items.size() >= max_drop_count:
+ break
+
+ if not dropped_items.is_empty():
+ items_dropped_on_death.emit(dropped_items)
+
+ return dropped_items
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "real_alignment": real_alignment
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ real_alignment = int(data.get("real_alignment", 0))
diff --git a/project/alignment_pk_system.gd.uid b/project/alignment_pk_system.gd.uid
new file mode 100644
index 00000000..6115c245
--- /dev/null
+++ b/project/alignment_pk_system.gd.uid
@@ -0,0 +1 @@
+uid://cvfimpfsp5t6b
diff --git a/project/alignment_system.gd b/project/alignment_system.gd
new file mode 100644
index 00000000..2c88780f
--- /dev/null
+++ b/project/alignment_system.gd
@@ -0,0 +1,128 @@
+# alignment_system.gd —— 人物善恶道德与红名系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/game/src/char.cpp:1190-1205 (UpdateAlignment, 自然恢复公式)
+# - 40250 server/game/src/char.cpp:3300 (UpdateAlignment)
+# - 40250 ClientVS22/source/UserInterface/InstanceBaseEffect.cpp:609 (GetTitleColor)
+# - 40250 ClientVS22/source/UserInterface/InstanceBase.h:262 (TITLE_NUM=9, TITLE_NONE=4)
+# - 40250 text_tail.gd (title_color, title_name_key)
+class_name AlignmentSystem
+extends RefCounted
+
+const MIN_ALIGNMENT := -20000
+const MAX_ALIGNMENT := 20000
+
+# 9 档善恶称号常量定义
+const TITLE_HEROIC := 0 # 英雄 (Heroic, >= 12000)
+const TITLE_NOBLE := 1 # 尊者 (Noble, 8000..11999)
+const TITLE_GOOD := 2 # 善良 (Good, 4000..7999)
+const TITLE_FRIENDLY := 3 # 友好 (Friendly, 1000..3999)
+const TITLE_NEUTRAL := 4 # 普通 (Neutral, 0..999)
+const TITLE_AGGRESSIVE := 5 # 无赖 (Aggressive, -1..-3999)
+const TITLE_FRAUDULENT := 6 # 欺诈 (Fraudulent, -4000..-7999)
+const TITLE_MALICIOUS := 7 # 邪恶 (Malicious, -8000..-11999)
+const TITLE_CRUEL := 8 # 魔头 (Cruel, <= -12000)
+
+const VNUM_ZEN_BEAN := 70102 # 禅定豆 / 清恶豆
+
+const GRADE_NAMES := {
+ TITLE_HEROIC: "英雄",
+ TITLE_NOBLE: "尊者",
+ TITLE_GOOD: "善良",
+ TITLE_FRIENDLY: "友好",
+ TITLE_NEUTRAL: "普通",
+ TITLE_AGGRESSIVE: "无赖",
+ TITLE_FRAUDULENT: "欺诈",
+ TITLE_MALICIOUS: "邪恶",
+ TITLE_CRUEL: "魔头",
+}
+
+const GRADE_COLORS := {
+ TITLE_HEROIC: Color8(0, 204, 255),
+ TITLE_NOBLE: Color8(0, 144, 255),
+ TITLE_GOOD: Color8(92, 110, 255),
+ TITLE_FRIENDLY: Color8(155, 155, 255),
+ TITLE_NEUTRAL: Color8(255, 255, 255),
+ TITLE_AGGRESSIVE: Color8(207, 117, 0),
+ TITLE_FRAUDULENT: Color8(235, 83, 0),
+ TITLE_MALICIOUS: Color8(227, 0, 0),
+ TITLE_CRUEL: Color8(255, 0, 0),
+}
+
+## 获取善恶值对应的 40250 称号等级 (0..8)
+static func get_alignment_grade(alignment: int) -> int:
+ if alignment >= 12000:
+ return TITLE_HEROIC
+ elif alignment >= 8000:
+ return TITLE_NOBLE
+ elif alignment >= 4000:
+ return TITLE_GOOD
+ elif alignment >= 1000:
+ return TITLE_FRIENDLY
+ elif alignment >= 0:
+ return TITLE_NEUTRAL
+ elif alignment > -4000:
+ return TITLE_AGGRESSIVE
+ elif alignment > -8000:
+ return TITLE_FRAUDULENT
+ elif alignment > -12000:
+ return TITLE_MALICIOUS
+ else:
+ return TITLE_CRUEL
+
+## 获取善恶称号中文名
+static func get_title_name(grade: int) -> String:
+ return GRADE_NAMES.get(grade, "普通")
+
+## 获取善恶称号颜色
+static func get_title_color(grade: int) -> Color:
+ return GRADE_COLORS.get(grade, Color.WHITE)
+
+## 检查是否为红名(罪恶状态)
+static func is_red_name(alignment: int) -> bool:
+ return alignment < 0
+
+## 检查是否为蓝名(正义状态)
+static func is_blue_name(alignment: int) -> bool:
+ return alignment >= 1000
+
+## 40250 char.cpp:1197 在线自然恢复善恶计算
+## minutes: 在线分钟数;has_fast_item: 是否佩戴禅定香囊等加速道具
+static func calculate_time_recovery(cur_alignment: int, minutes: int, has_fast_item := false) -> int:
+ if minutes <= 0:
+ return cur_alignment
+ var updated := cur_alignment
+ if cur_alignment < 0:
+ # 恶名状态:基础每分钟 +60 点,带香囊每分钟 +120 点
+ var rate: int = 120 if has_fast_item else 60
+ updated = mini(0, cur_alignment + rate * minutes)
+ else:
+ # 善良状态:每分钟 +5 点
+ updated = mini(MAX_ALIGNMENT, cur_alignment + 5 * minutes)
+ return updated
+
+## 击杀怪物增加善恶值 (40250 char_battle.cpp: UpdateAlignment)
+## 击杀野外怪物根据怪物等级恢复善恶(红名时恢复极快)
+static func on_kill_monster(cur_alignment: int, mob_level: int, player_level: int) -> int:
+ var delta := maxi(1, mob_level - player_level / 2)
+ if cur_alignment < 0:
+ delta *= 2 # 红名击杀怪物恢复加倍
+ return mini(MAX_ALIGNMENT, cur_alignment + delta)
+
+## 使用禅定豆 (Zen Bean 70102)
+## 40250 原生逻辑:仅在红名 (alignment < 0) 时可使用,一次清除 500 点罪恶
+static func use_zen_bean(cur_alignment: int) -> Dictionary:
+ if cur_alignment >= 0:
+ return {
+ "ok": false,
+ "code": "CANNOT_USE_NOT_EVIL",
+ "msg": "你未处于恶名状态,无需使用禅定豆。"
+ }
+ var new_align: int = mini(0, cur_alignment + 500)
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "old_alignment": cur_alignment,
+ "new_alignment": new_align,
+ "consumed": true,
+ "msg": "使用了禅定豆,洗清了 500 点罪恶值!(当前善恶: %d)" % new_align
+ }
diff --git a/project/alignment_system.gd.uid b/project/alignment_system.gd.uid
new file mode 100644
index 00000000..69141968
--- /dev/null
+++ b/project/alignment_system.gd.uid
@@ -0,0 +1 @@
+uid://vg3mkqiy31mq
diff --git a/project/animation_blend_test.gd b/project/animation_blend_test.gd
new file mode 100644
index 00000000..4b0c59f9
--- /dev/null
+++ b/project/animation_blend_test.gd
@@ -0,0 +1,84 @@
+extends SceneTree
+
+# Regression for locomotion crossfades: blending world-space joint endpoints
+# briefly shortened or stretched limbs. Parent/child segment lengths must stay
+# close to the two endpoint poses while wait/run transitions are in progress.
+const PlayerView = preload("res://ui/player_view.gd")
+var failures: Array[String] = []
+var checked_segments := 0
+
+func _initialize() -> void:
+ call_deferred("run")
+
+func _check(ok: bool, message: String) -> void:
+ if not ok:
+ failures.append(message)
+ printerr("FAIL: ", message)
+
+func _pose(anim: Node, skeleton: Skeleton3D) -> Dictionary:
+ var result := {}
+ for bone in skeleton.get_bone_count():
+ var data: Dictionary = anim.call("get_effect_bone_pose", skeleton.get_bone_name(bone))
+ if data.has("transform"):
+ result[bone] = (data["transform"] as Transform3D).origin
+ return result
+
+func _segments(pose: Dictionary, skeleton: Skeleton3D) -> Dictionary:
+ var result := {}
+ for bone in skeleton.get_bone_count():
+ var parent := skeleton.get_bone_parent(bone)
+ if parent >= 0 and pose.has(bone) and pose.has(parent):
+ var length := (pose[bone] as Vector3).distance_to(pose[parent] as Vector3)
+ if length > 0.01:
+ result[bone] = length
+ return result
+
+func run() -> void:
+ OS.set_environment("MTGODOT_GPUSKIN", "0")
+ for race in 8:
+ var view := PlayerView.new()
+ root.add_child(view)
+ if not view.build(AssetRoot.path(), race):
+ _check(false, "race %d failed to build" % race)
+ view.queue_free()
+ continue
+ view.model.set("lod_enabled", false)
+ view.anim.set("blend_time", 0.15)
+ await process_frame
+ view.anim.set("time", minf(0.25, float(view.anim.call("get_duration")) * 0.5))
+ var skeleton := view.model.get_node("Skeleton3D") as Skeleton3D
+ var start := _segments(_pose(view.anim, skeleton), skeleton)
+ view.set_anim_state("run")
+ var transition: Array[Dictionary] = []
+ for frame in 12:
+ await process_frame
+ transition.append(_segments(_pose(view.anim, skeleton), skeleton))
+ var finish := _segments(_pose(view.anim, skeleton), skeleton)
+ for bone in start:
+ if not finish.has(bone):
+ continue
+ # Terminal *Nub helpers are zero-area animation handles and carry no
+ # skinned child segment. Some clips intentionally collapse them.
+ if String(skeleton.get_bone_name(bone)).ends_with("Nub"):
+ continue
+ var endpoint_min: float = minf(float(start[bone]), float(finish[bone]))
+ var endpoint_max: float = maxf(float(start[bone]), float(finish[bone]))
+ # Sub-5 cm helpers/finger tips may carry authored local translation
+ # animation and do not explain whole-character deformation. Keep this
+ # gate focused on visible limb and torso segments.
+ if endpoint_min < 5.0:
+ continue
+ for frame in transition.size():
+ if not transition[frame].has(bone):
+ continue
+ var value: float = transition[frame][bone]
+ checked_segments += 1
+ _check(value >= endpoint_min * 0.75 and value <= endpoint_max * 1.25,
+ "race %d bone %s deforms in blend frame %d: %.3f not near %.3f..%.3f" %
+ [race, skeleton.get_bone_name(bone), frame, value, endpoint_min, endpoint_max])
+ view.queue_free()
+ await process_frame
+ _check(checked_segments > 1000, "blend regression did not inspect enough skinned segments")
+ print("animation_blend_test: failures=", failures.size(), " segments=", checked_segments,
+ " (8 races, wait -> run)")
+ quit(0 if failures.is_empty() else 1)
diff --git a/project/animation_blend_test.gd.uid b/project/animation_blend_test.gd.uid
new file mode 100644
index 00000000..0a643010
--- /dev/null
+++ b/project/animation_blend_test.gd.uid
@@ -0,0 +1 @@
+uid://dhtltbx5sioow
diff --git a/project/app_flow.gd b/project/app_flow.gd
index a5c48a7f..3cae6a01 100644
--- a/project/app_flow.gd
+++ b/project/app_flow.gd
@@ -274,6 +274,18 @@ func _goto_game() -> void:
add_child(_game)
if _game.has_method("setup"):
await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket
+ # 40250 IntroLoading 对齐:进入游戏世界前必须确保主角真实模型已构建完成,杜绝任何占位模型闪现
+ if is_instance_valid(_game) and "_model_built" in _game and client != null:
+ var wait_msec := Time.get_ticks_msec()
+ while not _game._model_built and (Time.get_ticks_msec() - wait_msec < 3000):
+ if client.has_method("net_poll"):
+ client.net_poll()
+ if _game.has_method("_sync_main_character"):
+ _game._sync_main_character()
+ if _game._model_built:
+ break
+ if is_inside_tree() and get_tree():
+ await get_tree().process_frame
if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"):
_lifecycle.bind(client, _game.lifecycle_audio())
if _loading:
@@ -398,9 +410,11 @@ func _probe_tcp(host: String, port: int, timeout_s: float) -> bool:
peer.poll()
match peer.get_status():
StreamPeerTCP.STATUS_CONNECTED:
- peer.disconnect_from_host()
- return true
- StreamPeerTCP.STATUS_ERROR:
+ peer.poll()
+ if peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
+ peer.disconnect_from_host()
+ return true
+ StreamPeerTCP.STATUS_ERROR, StreamPeerTCP.STATUS_NONE:
return false
if is_inside_tree():
await get_tree().process_frame
diff --git a/project/atlas_navigation_system.gd b/project/atlas_navigation_system.gd
new file mode 100644
index 00000000..33fa7eef
--- /dev/null
+++ b/project/atlas_navigation_system.gd
@@ -0,0 +1,132 @@
+# atlas_navigation_system.gd —— 40250 Atlas M 键全景大地图与 NPC 寻路导航光标系统 1:1
+# 严格对照:
+# Client/Eternexus/root/uiminimap.py (AtlasWindow, MiniMap)
+# ClientVS22/source/UserInterface/PythonMiniMap.cpp, PythonMiniMap.h (AddSignalPoint, ClearAllSignalPoint)
+# Client/Eternexus/root/atlasinfo.txt
+extends RefCounted
+
+# 地图手绘全景 Atlas 纹理与尺寸映射
+const MAP_ATLAS_REGISTRY := {
+ "metin2_map_a1": { "name": "赤蛇国主城 (Yongan)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
+ "metin2_map_b1": { "name": "白虎国主城 (Joan)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
+ "metin2_map_c1": { "name": "苍龙国主城 (Pyungmoo)", "size": Vector2(256, 256), "world_size": Vector2(4000, 4000) },
+ "metin2_map_milgyo": { "name": "幽冥神殿 (Dark Temple)", "size": Vector2(256, 256), "world_size": Vector2(2000, 2000) },
+ "metin2_map_deviltower": { "name": "恶魔之塔 (Demon Tower)", "size": Vector2(256, 256), "world_size": Vector2(1000, 1000) },
+}
+
+# 官方预设标准城镇 NPC 索引目录 (NPC VID -> 数据)
+const PRESET_TOWN_NPCS := [
+ { "vid": 20016, "name": "铁匠 (Blacksmith)", "category": "Smith", "pos": Vector2(580, 620) },
+ { "vid": 9001, "name": "武器商人 (Weapon Dealer)", "category": "Shop", "pos": Vector2(610, 630) },
+ { "vid": 9002, "name": "防具商人 (Armor Dealer)", "category": "Shop", "pos": Vector2(590, 650) },
+ { "vid": 9003, "name": "杂货商人 (General Store)", "category": "Shop", "pos": Vector2(630, 600) },
+ { "vid": 9005, "name": "仓库保管员 (Storekeeper)", "category": "Safebox", "pos": Vector2(550, 580) },
+ { "vid": 20084, "name": "生物学家采吉拉布 (Biologist)", "category": "Quest", "pos": Vector2(680, 520) },
+ { "vid": 20092, "name": "老行商 (Old Wanderer)", "category": "Special", "pos": Vector2(450, 710) },
+ { "vid": 20008, "name": "城门守卫 (City Guard)", "category": "Guard", "pos": Vector2(800, 800) },
+ { "vid": 20001, "name": "药店掌柜 (Alchemist)", "category": "Craft", "pos": Vector2(520, 640) },
+ { "vid": 20091, "name": "修仙道长 (Taoist)", "category": "Skill", "pos": Vector2(640, 750) },
+]
+
+# 状态变量
+var is_atlas_visible: bool = false
+var current_map_id: String = "metin2_map_a1"
+var zoom_level: float = 1.0 # 滚轮缩放倍率 (0.5x ~ 3.0x)
+var pan_offset: Vector2 = Vector2.ZERO # 拖拽平移偏移
+
+# 动态信标点列表 (1:1 CPythonMiniMap::m_SignalPointVector)
+var signal_points: Array = []
+
+func _init(map_id: String = "metin2_map_a1") -> void:
+ current_map_id = map_id
+ is_atlas_visible = false
+ zoom_level = 1.0
+ pan_offset = Vector2.ZERO
+ signal_points.clear()
+
+# 1. M 键快捷切换全景大地图 (1:1 ToggleAtlasWindow)
+func toggle_atlas() -> bool:
+ is_atlas_visible = !is_atlas_visible
+ return is_atlas_visible
+
+func show_atlas() -> void:
+ is_atlas_visible = true
+
+func hide_atlas() -> void:
+ is_atlas_visible = false
+
+# 2. 切换当前地图与底图载入 (SetMapName)
+func set_map(map_id: String) -> bool:
+ if not MAP_ATLAS_REGISTRY.has(map_id):
+ return false
+ current_map_id = map_id
+ clear_all_signals()
+ pan_offset = Vector2.ZERO
+ zoom_level = 1.0
+ return true
+
+func get_current_map_info() -> Dictionary:
+ return MAP_ATLAS_REGISTRY.get(current_map_id, {})
+
+# 3. 缩放与平移控制
+func set_zoom(zoom: float) -> void:
+ zoom_level = clamp(zoom, 0.5, 3.0)
+
+func apply_pan(delta_offset: Vector2) -> void:
+ pan_offset += delta_offset
+
+# 4. NPC 目录检索与过滤
+func get_npcs(category_filter: String = "") -> Array:
+ if category_filter.is_empty():
+ return PRESET_TOWN_NPCS
+ var filtered: Array = []
+ for npc in PRESET_TOWN_NPCS:
+ if npc.get("category") == category_filter:
+ filtered.append(npc)
+ return filtered
+
+func find_npc_by_vid(vid: int) -> Dictionary:
+ for npc in PRESET_TOWN_NPCS:
+ if npc.get("vid") == vid:
+ return npc
+ return {}
+
+# 5. 信标寻路导航点机制 (1:1 CPythonMiniMap::AddSignalPoint / ClearAllSignalPoint)
+func add_signal_point(target_x: float, target_y: float) -> Dictionary:
+ var pt := {
+ "x": target_x,
+ "y": target_y,
+ "active": true
+ }
+ signal_points.append(pt)
+ return pt
+
+func clear_all_signals() -> void:
+ signal_points.clear()
+
+# 6. 指南针方位角与距离计算 (角色与目标间的连线向量)
+func calculate_navigation_vector(player_pos: Vector2, target_pos: Vector2) -> Dictionary:
+ var diff := target_pos - player_pos
+ var dist := diff.length()
+ var angle_rad := diff.angle()
+ var angle_deg := rad_to_deg(angle_rad)
+
+ return {
+ "distance": dist,
+ "direction_vector": diff.normalized() if dist > 0.001 else Vector2.ZERO,
+ "angle_rad": angle_rad,
+ "angle_deg": angle_deg,
+ "arrived": (dist <= 30.0) # 30 码内视为到达目标
+ }
+
+# 7. 世界坐标至大地图 UI 局部像素坐标投影转换
+func world_to_atlas_coord(world_pos: Vector2) -> Vector2:
+ var map_info := get_current_map_info()
+ var world_size: Vector2 = map_info.get("world_size", Vector2(4000, 4000))
+ var atlas_size: Vector2 = map_info.get("size", Vector2(256, 256))
+
+ var ratio_x := atlas_size.x / world_size.x
+ var ratio_y := atlas_size.y / world_size.y
+
+ var projected := Vector2(world_pos.x * ratio_x, world_pos.y * ratio_y)
+ return (projected * zoom_level) + pan_offset
diff --git a/project/atlas_navigation_system.gd.uid b/project/atlas_navigation_system.gd.uid
new file mode 100644
index 00000000..a3dbad6a
--- /dev/null
+++ b/project/atlas_navigation_system.gd.uid
@@ -0,0 +1 @@
+uid://dras8n6cjudtn
diff --git a/project/attach_metin_dialog_system.gd b/project/attach_metin_dialog_system.gd
new file mode 100644
index 00000000..65c29c64
--- /dev/null
+++ b/project/attach_metin_dialog_system.gd
@@ -0,0 +1,144 @@
+# attach_metin_dialog_system.gd
+# 40250 官方 1:1 装备幽魂石/灵魂石镶嵌与双重属性对比预览系统
+# 对照: uiattachmetin.py, attachstonedialog.py, char_item.cpp
+class_name AttachMetinDialogSystem
+extends RefCounted
+
+const METIN_SOCKET_MAX_NUM: int = 3
+
+const METIN_NORMAL: int = 0
+const METIN_GOLD: int = 1
+
+const METIN_SOCKET_TYPE_NONE: int = 0
+const METIN_SOCKET_TYPE_SILVER: int = 1
+const METIN_SOCKET_TYPE_GOLD: int = 2
+
+const ITEM_BROKEN_METIN_VNUM: int = 28960
+
+# 经典灵魂石属性对照表 (40250 canonical metin stones 28000~28443)
+const METIN_STONES: Dictionary = {
+ 28030: {"name": "战神之石+0", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 5},
+ 28130: {"name": "战神之石+1", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 8},
+ 28230: {"name": "战神之石+2", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 12},
+ 28330: {"name": "战神之石+3", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 17},
+ 28430: {"name": "战神之石+4", "bonus_type": "ATT_BONUS_TO_WARRIOR", "bonus_value": 25},
+ 28434: {"name": "穿透之石+4", "bonus_type": "POINT_PENETRATE_PCT", "bonus_value": 8},
+ 28435: {"name": "致命之石+4", "bonus_type": "POINT_CRITICAL_PCT", "bonus_value": 8},
+ 28438: {"name": "防御之石+4", "bonus_type": "POINT_DEF_GRADE_BONUS", "bonus_value": 15},
+ 28442: {"name": "加速之石+4", "bonus_type": "POINT_MOV_SPEED", "bonus_value": 30},
+ 28960: {"name": "破碎的石头碎块", "bonus_type": "NONE", "bonus_value": 0}
+}
+
+var is_open: bool = false
+var metin_pos: int = -1
+var target_pos: int = -1
+
+var metin_vnum: int = 0
+var metin_subtype: int = METIN_NORMAL
+
+var target_item_vnum: int = 0
+var current_sockets: Array = [0, 0, 0]
+var preview_sockets: Array = [0, 0, 0]
+var target_socket_idx: int = -1
+
+var last_packet_sent: Dictionary = {}
+var last_sound_played: String = ""
+
+signal dialog_opened(metin_pos: int, target_pos: int, preview_sockets: Array)
+signal dialog_closed()
+signal metin_attached(metin_pos: int, target_pos: int, target_slot_idx: int)
+signal attach_failed(reason: String)
+
+func can_attach_metin(slot_type: int, metin_type: int) -> bool:
+ if metin_type == METIN_NORMAL:
+ if slot_type == METIN_SOCKET_TYPE_SILVER or slot_type == METIN_SOCKET_TYPE_GOLD:
+ return true
+ elif metin_type == METIN_GOLD:
+ if slot_type == METIN_SOCKET_TYPE_GOLD:
+ return true
+ return false
+
+func open_dialog(p_metin_pos: int, p_target_pos: int, p_metin_vnum: int, p_metin_subtype: int, p_target_vnum: int, p_sockets: Array) -> bool:
+ metin_pos = p_metin_pos
+ target_pos = p_target_pos
+ metin_vnum = p_metin_vnum
+ metin_subtype = p_metin_subtype
+ target_item_vnum = p_target_vnum
+
+ current_sockets = [0, 0, 0]
+ preview_sockets = [0, 0, 0]
+ target_socket_idx = -1
+
+ for i in range(mini(p_sockets.size(), METIN_SOCKET_MAX_NUM)):
+ current_sockets[i] = p_sockets[i]
+ preview_sockets[i] = p_sockets[i]
+
+ # 寻找首个符合条件的开放槽位
+ var found_slot: bool = false
+ for i in range(METIN_SOCKET_MAX_NUM):
+ var slot_state: int = current_sockets[i]
+ if can_attach_metin(slot_state, metin_subtype):
+ preview_sockets[i] = metin_vnum
+ target_socket_idx = i
+ found_slot = true
+ break
+
+ if not found_slot:
+ attach_failed.emit("NO_AVAILABLE_SOCKET")
+ return false
+
+ is_open = true
+ dialog_opened.emit(metin_pos, target_pos, preview_sockets)
+ return true
+
+func accept() -> bool:
+ if not is_open:
+ return false
+ if target_socket_idx < 0:
+ attach_failed.emit("INVALID_SOCKET")
+ return false
+
+ # 40250 发送 net.SendItemUseToItemPacket(self.metinItemPos, self.targetItemPos)
+ last_packet_sent = {
+ "header": "HEADER_CG_ITEM_USE_TO_ITEM",
+ "metin_pos": metin_pos,
+ "target_pos": target_pos,
+ "target_socket_idx": target_socket_idx,
+ "metin_vnum": metin_vnum
+ }
+
+ # 官方原版音效
+ last_sound_played = "sound/ui/metinstone_insert.wav"
+
+ metin_attached.emit(metin_pos, target_pos, target_socket_idx)
+ close_dialog()
+ return true
+
+func close_dialog() -> void:
+ is_open = false
+ metin_pos = -1
+ target_pos = -1
+ dialog_closed.emit()
+
+# 模拟服务端结算镶嵌 (支持成功率判定: 成功直接嵌入,失败产生破碎石块 28960)
+func simulate_server_process(target_sockets: Array, socket_idx: int, attached_metin_vnum: int, success: bool) -> Array:
+ var result_sockets: Array = target_sockets.duplicate()
+ if socket_idx >= 0 and socket_idx < result_sockets.size():
+ if success:
+ result_sockets[socket_idx] = attached_metin_vnum
+ else:
+ result_sockets[socket_idx] = ITEM_BROKEN_METIN_VNUM
+ return result_sockets
+
+# 模拟卷轴清理破碎石头 (vnum 71083 / Scroll of Cleansing)
+func clean_broken_socket(target_sockets: Array, socket_idx: int) -> Array:
+ var result_sockets: Array = target_sockets.duplicate()
+ if socket_idx >= 0 and socket_idx < result_sockets.size():
+ if result_sockets[socket_idx] == ITEM_BROKEN_METIN_VNUM:
+ result_sockets[socket_idx] = METIN_SOCKET_TYPE_SILVER
+ return result_sockets
+
+func get_metin_info(vnum: int) -> Dictionary:
+ if METIN_STONES.has(vnum):
+ return METIN_STONES[vnum]
+ return {"name": "未知幽魂石", "bonus_type": "UNKNOWN", "bonus_value": 0}
diff --git a/project/attach_metin_dialog_system.gd.uid b/project/attach_metin_dialog_system.gd.uid
new file mode 100644
index 00000000..da55bb78
--- /dev/null
+++ b/project/attach_metin_dialog_system.gd.uid
@@ -0,0 +1 @@
+uid://bkw5vjkd4yqgq
diff --git a/project/auto_combat_bot_system.gd b/project/auto_combat_bot_system.gd
new file mode 100644
index 00000000..5d1e865f
--- /dev/null
+++ b/project/auto_combat_bot_system.gd
@@ -0,0 +1,175 @@
+# auto_combat_bot_system.gd —— Metin2 40250 自动战斗猎杀挂机助手 1:1
+# 对照 40250 服务端 char_battle.cpp, bot.cpp, item_manager.cpp
+class_name AutoCombatBotSystem
+extends RefCounted
+
+signal bot_toggled(enabled: bool)
+signal potion_used(potion_vnum: int, ptype: String)
+signal target_locked(target_id: String)
+signal auto_attack_fired(target_id: String, damage: int)
+signal auto_skill_fired(skill_vnum: int, target_id: String)
+signal auto_loot_collected(item_name: String, slot: int)
+
+# 红药与蓝药 VNUM
+const HP_POTION_VNUMS: Array = [27001, 27002, 27003] # 小/中/大红
+const SP_POTION_VNUMS: Array = [27004, 27005, 27006] # 小/中/大蓝
+
+var is_enabled: bool = false
+var auto_potion_enabled: bool = true
+var hp_threshold: float = 0.50 # 50% HP 触发喝红
+var sp_threshold: float = 0.40 # 40% SP 触发喝蓝
+
+var auto_attack_enabled: bool = true
+var scan_radius: float = 15.0
+var attack_range: float = 3.0
+
+var auto_skill_enabled: bool = true
+var assigned_skills: Array = []
+var skill_cooldowns: Dictionary = {}
+
+var auto_loot_enabled: bool = true
+var loot_radius: float = 8.0
+
+var current_target_id: String = ""
+var potion_cooldown: float = 0.0
+
+# 开启/关闭挂机内挂
+func toggle_bot(enable: bool) -> Dictionary:
+ is_enabled = enable
+ if not is_enabled:
+ current_target_id = ""
+ bot_toggled.emit(is_enabled)
+ return {"ok": true, "enabled": is_enabled, "msg": "自动战斗猎杀助手已%s!" % ("开启" if is_enabled else "关闭")}
+
+# 帧心跳驱动挂机逻辑
+func update_bot(delta: float, player_data: Dictionary, inventory: Array, nearby_mobs: Array, nearby_drops: Array) -> Dictionary:
+ if not is_enabled:
+ return {"status": "disabled"}
+
+ if potion_cooldown > 0.0:
+ potion_cooldown = max(0.0, potion_cooldown - delta)
+
+ for sk in skill_cooldowns.keys():
+ if skill_cooldowns[sk] > 0.0:
+ skill_cooldowns[sk] = max(0.0, skill_cooldowns[sk] - delta)
+
+ var p_pos: Vector3 = player_data.get("pos", Vector3.ZERO)
+ var p_hp: int = int(player_data.get("hp", 100))
+ var p_max_hp: int = max(1, int(player_data.get("max_hp", 100)))
+ var p_sp: int = int(player_data.get("sp", 100))
+ var p_max_sp: int = max(1, int(player_data.get("max_sp", 100)))
+
+ # 1. 自动智能喝药判定
+ if auto_potion_enabled and potion_cooldown <= 0.0:
+ if float(p_hp) / float(p_max_hp) <= hp_threshold:
+ if _use_potion(HP_POTION_VNUMS, inventory, player_data, "hp"):
+ potion_cooldown = 1.0 # 喝药公共冷却 1 秒
+
+ if float(p_sp) / float(p_max_sp) <= sp_threshold:
+ if _use_potion(SP_POTION_VNUMS, inventory, player_data, "sp"):
+ potion_cooldown = 1.0
+
+ # 2. 自动拾取掉落物
+ if auto_loot_enabled:
+ _process_auto_loot(p_pos, nearby_drops, inventory)
+
+ # 3. 自动搜敌与战斗打击
+ if auto_attack_enabled:
+ var act = _process_auto_combat(delta, p_pos, player_data, nearby_mobs)
+ return act
+
+ return {"status": "idle"}
+
+# 自动喝药
+func _use_potion(potion_vnums: Array, inventory: Array, player_data: Dictionary, ptype: String) -> bool:
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) in potion_vnums:
+ var pot = inventory[i]
+ var pvnum = int(pot["vnum"])
+ var cnt = int(pot.get("count", 1))
+ if cnt > 1:
+ pot["count"] = cnt - 1
+ else:
+ inventory[i] = null
+
+ var rec = 1500 if pvnum in [27003, 27006] else 800
+ if ptype == "hp":
+ player_data["hp"] = min(int(player_data["max_hp"]), int(player_data["hp"]) + rec)
+ else:
+ player_data["sp"] = min(int(player_data["max_sp"]), int(player_data["sp"]) + rec)
+
+ potion_used.emit(pvnum, ptype)
+ return true
+ return false
+
+# 自动拾取
+func _process_auto_loot(player_pos: Vector3, drops: Array, inventory: Array) -> void:
+ for d in drops:
+ if d.get("looted", false):
+ continue
+ var d_pos: Vector3 = d.get("pos", Vector3.ZERO)
+ if player_pos.distance_to(d_pos) <= loot_radius:
+ # 放入背包
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {
+ "vnum": d.get("vnum", 1),
+ "name": d.get("name", "战利品"),
+ "count": int(d.get("count", 1))
+ }
+ d["looted"] = true
+ auto_loot_collected.emit(d["name"], i)
+ break
+
+# 自动锁敌与攻击
+func _process_auto_combat(delta: float, player_pos: Vector3, player_data: Dictionary, mobs: Array) -> Dictionary:
+ var closest_mob = null
+ var min_dist = scan_radius + 1.0
+
+ for m in mobs:
+ if not m.get("alive", true):
+ continue
+ var m_pos: Vector3 = m.get("pos", Vector3.ZERO)
+ var dist = player_pos.distance_to(m_pos)
+ if dist <= scan_radius and dist < min_dist:
+ min_dist = dist
+ closest_mob = m
+
+ if closest_mob == null:
+ current_target_id = ""
+ return {"status": "no_targets"}
+
+ var target_id = str(closest_mob.get("id", "mob"))
+ if current_target_id != target_id:
+ current_target_id = target_id
+ target_locked.emit(target_id)
+
+ var target_pos: Vector3 = closest_mob.get("pos", Vector3.ZERO)
+ var dist_to_target = player_pos.distance_to(target_pos)
+
+ # 走位靠拢
+ if dist_to_target > attack_range:
+ var new_pos = player_pos.move_toward(target_pos, 5.0 * delta)
+ player_data["pos"] = new_pos
+ return {"status": "moving_to_target", "target": target_id, "dist": dist_to_target}
+
+ # 尝试释放技能
+ if auto_skill_enabled:
+ for sk in assigned_skills:
+ if skill_cooldowns.get(sk, 0.0) <= 0.0:
+ skill_cooldowns[sk] = 8.0 # 默认技能 8 秒冷却
+ auto_skill_fired.emit(sk, target_id)
+ var sk_dmg = int(float(player_data.get("atk", 500)) * 2.0)
+ closest_mob["hp"] = max(0, int(closest_mob.get("hp", 1000)) - sk_dmg)
+ if closest_mob["hp"] <= 0:
+ closest_mob["alive"] = false
+ return {"status": "skill_attack", "skill": sk, "damage": sk_dmg}
+
+ # 普通挥砍攻击
+ var atk_dmg = int(player_data.get("atk", 500))
+ closest_mob["hp"] = max(0, int(closest_mob.get("hp", 1000)) - atk_dmg)
+ if closest_mob["hp"] <= 0:
+ closest_mob["alive"] = false
+
+ auto_attack_fired.emit(target_id, atk_dmg)
+ return {"status": "basic_attack", "target": target_id, "damage": atk_dmg}
diff --git a/project/auto_combat_bot_system.gd.uid b/project/auto_combat_bot_system.gd.uid
new file mode 100644
index 00000000..24cccc81
--- /dev/null
+++ b/project/auto_combat_bot_system.gd.uid
@@ -0,0 +1 @@
+uid://d2i1ud7k3o0l5
diff --git a/project/battle_arena_duel_system.gd b/project/battle_arena_duel_system.gd
new file mode 100644
index 00000000..852b5c71
--- /dev/null
+++ b/project/battle_arena_duel_system.gd
@@ -0,0 +1,218 @@
+# battle_arena_duel_system.gd —— Metin2 40250 官方武斗竞技场切磋与裁判规则 1:1
+# 对照 40250 服务端 arena.cpp, arena.h, BattleArena.cpp, questlua_arena.cpp
+class_name BattleArenaDuelSystem
+extends RefCounted
+
+signal duel_started(opponent_name: String, set_count: int, time_limit: float)
+signal round_started(current_round: int)
+signal round_ended(winner_side: String, score_a: int, score_b: int)
+signal duel_finished(winner_side: String, final_score_a: int, final_score_b: int, reward: Dictionary)
+signal rule_violation_intercepted(reason: String)
+
+const NPC_REFEREE := 20017 # 比武场裁判 (Arena Referee)
+const ARENA_MAP_INDEX := 112
+
+# 擂台对立出生点 (Point A 玩家, Point B 对手)
+const START_POINT_A := Vector2(100.0, 100.0)
+const START_POINT_B := Vector2(200.0, 200.0)
+const ARENA_BOUNDS := Rect2(80.0, 80.0, 140.0, 140.0) # 擂台活动合法矩形区域
+
+# 决斗状态枚举
+const STATE_IDLE := "IDLE"
+const STATE_COUNTDOWN := "COUNTDOWN"
+const STATE_FIGHTING := "FIGHTING"
+const STATE_ROUND_OVER:= "ROUND_OVER"
+const STATE_FINISHED := "FINISHED"
+
+# 单机官方 4 大门派守擂试炼 NPC 导师
+const OPPONENT_PRESETS: Dictionary = {
+ "warrior": {
+ "id": "warrior",
+ "name": "战神武者教头",
+ "job": "warrior",
+ "max_hp": 12000,
+ "attack_power": 450,
+ "defense": 200,
+ "skills": ["三连斩", "剑气破空"]
+ },
+ "assassin": {
+ "id": "assassin",
+ "name": "暗夜游侠神箭手",
+ "job": "assassin",
+ "max_hp": 9000,
+ "attack_power": 520,
+ "defense": 150,
+ "skills": ["连环毒矢", "疾影步"]
+ },
+ "sura": {
+ "id": "sura",
+ "name": "冥界暗影修罗",
+ "job": "sura",
+ "max_hp": 11000,
+ "attack_power": 480,
+ "defense": 220,
+ "skills": ["幽冥炎刃", "魔灵护体"]
+ },
+ "shaman": {
+ "id": "shaman",
+ "name": "雷鸣灵术萨满",
+ "job": "shaman",
+ "max_hp": 10000,
+ "attack_power": 400,
+ "defense": 180,
+ "skills": ["天顶奔雷", "金钟守护"]
+ }
+}
+
+# 比赛运行时状态
+var match_state: String = STATE_IDLE
+var current_round: int = 1
+var max_sets: int = 3 # 3 局 2 胜
+var sets_to_win: int = 2
+var time_limit_per_round: float = 300.0 # 5 分钟
+var round_timer: float = 300.0
+var countdown_timer: float = 3.0
+
+var score_a: int = 0 # 玩家得分
+var score_b: int = 0 # 对手得分
+
+var player_pos: Vector2 = START_POINT_A
+var opponent_pos: Vector2 = START_POINT_B
+
+var active_opponent: Dictionary = {}
+var opponent_current_hp: int = 0
+
+# 启动武斗切磋
+func start_duel(opponent_id: String, sets: int = 3, time_limit: float = 300.0) -> Dictionary:
+ if not OPPONENT_PRESETS.has(opponent_id):
+ return {"ok": false, "reason": "INVALID_OPPONENT", "msg": "未知的守擂武者!"}
+
+ active_opponent = OPPONENT_PRESETS[opponent_id].duplicate(true)
+ max_sets = sets
+ sets_to_win = int(ceil(float(max_sets) / 2.0))
+ time_limit_per_round = time_limit
+
+ score_a = 0
+ score_b = 0
+ current_round = 1
+
+ _prepare_round()
+
+ duel_started.emit(active_opponent["name"], max_sets, time_limit_per_round)
+ return {
+ "ok": true,
+ "opponent": active_opponent["name"],
+ "max_sets": max_sets,
+ "sets_to_win": sets_to_win,
+ "pos_a": player_pos,
+ "pos_b": opponent_pos
+ }
+
+# 准备新回合
+func _prepare_round() -> void:
+ match_state = STATE_COUNTDOWN
+ countdown_timer = 3.0
+ round_timer = time_limit_per_round
+
+ player_pos = START_POINT_A
+ opponent_pos = START_POINT_B
+ opponent_current_hp = int(active_opponent.get("max_hp", 10000))
+
+# 心跳逻辑推进
+func update(delta: float) -> void:
+ if match_state == STATE_COUNTDOWN:
+ countdown_timer -= delta
+ if countdown_timer <= 0.0:
+ match_state = STATE_FIGHTING
+ round_started.emit(current_round)
+ elif match_state == STATE_FIGHTING:
+ round_timer -= delta
+ if round_timer <= 0.0:
+ # 回合超时:双方均未击倒,判定为平局或根据剩余生命决出
+ _handle_round_over("timeout")
+
+# 40250 禁药规则拦截
+func use_potion_check(item_vnum: int) -> bool:
+ if match_state == STATE_FIGHTING or match_state == STATE_COUNTDOWN:
+ # 27001~27006 为红蓝药水
+ if item_vnum >= 27001 and item_vnum <= 27006:
+ rule_violation_intercepted.emit("POTION_FORBIDDEN_IN_ARENA")
+ return false
+ return true
+
+# 40250 禁马规则拦截
+func mount_check() -> bool:
+ if match_state == STATE_FIGHTING or match_state == STATE_COUNTDOWN:
+ rule_violation_intercepted.emit("MOUNT_FORBIDDEN_IN_ARENA")
+ return false
+ return true
+
+# 40250 擂台边界巡检
+func check_boundary(pos: Vector2) -> bool:
+ if not ARENA_BOUNDS.has_point(pos):
+ rule_violation_intercepted.emit("OUT_OF_ARENA_BOUNDS")
+ return false
+ return true
+
+# 击倒判定处理
+func record_defeat(defeated_side: String) -> Dictionary:
+ if match_state != STATE_FIGHTING:
+ return {"ok": false, "reason": "NOT_IN_FIGHTING_STATE"}
+
+ var round_winner := ""
+ if defeated_side == "B": # 对手倒下,玩家得分
+ score_a += 1
+ round_winner = "A"
+ elif defeated_side == "A": # 玩家倒下,对手得分
+ score_b += 1
+ round_winner = "B"
+
+ return _handle_round_over(round_winner)
+
+# 回合结束与大局胜负判定
+func _handle_round_over(winner_side: String) -> Dictionary:
+ match_state = STATE_ROUND_OVER
+ round_ended.emit(winner_side, score_a, score_b)
+
+ # 检查大局是否已有胜者
+ if score_a >= sets_to_win or score_b >= sets_to_win or current_round >= max_sets:
+ match_state = STATE_FINISHED
+ var grand_winner := "A" if score_a > score_b else ("B" if score_b > score_a else "DRAW")
+
+ var reward: Dictionary = {}
+ if grand_winner == "A":
+ reward = {
+ "arena_points": 100 * sets_to_win,
+ "gold": 500000,
+ "title": "【竞技场常胜军】"
+ }
+
+ duel_finished.emit(grand_winner, score_a, score_b, reward)
+ return {
+ "ok": true,
+ "match_finished": true,
+ "winner": grand_winner,
+ "score_a": score_a,
+ "score_b": score_b,
+ "reward": reward
+ }
+
+ # 进入下一回合
+ current_round += 1
+ _prepare_round()
+
+ return {
+ "ok": true,
+ "match_finished": false,
+ "current_round": current_round,
+ "score_a": score_a,
+ "score_b": score_b
+ }
+
+# 重置武斗场状态
+func reset_arena() -> void:
+ match_state = STATE_IDLE
+ current_round = 1
+ score_a = 0
+ score_b = 0
+ active_opponent.clear()
diff --git a/project/battle_arena_duel_system.gd.uid b/project/battle_arena_duel_system.gd.uid
new file mode 100644
index 00000000..458928c3
--- /dev/null
+++ b/project/battle_arena_duel_system.gd.uid
@@ -0,0 +1 @@
+uid://bsbmx3e0l0jgs
diff --git a/project/belt_system.gd b/project/belt_system.gd
new file mode 100644
index 00000000..a9157f27
--- /dev/null
+++ b/project/belt_system.gd
@@ -0,0 +1,161 @@
+# belt_system.gd —— Metin2 40250 腰带系统与专属药水背包 1:1
+# 对照 40250 服务端 belt.cpp, char_item.cpp, uiinventory.py
+class_name BeltSystem
+extends RefCounted
+
+signal belt_equipped(belt_name: String, slots_unlocked: int)
+signal belt_unequipped()
+signal belt_item_stored(slot: int, item_name: String)
+signal belt_item_removed(slot: int)
+
+const MAX_BELT_SLOTS := 16 # 最大 4x4 空间
+
+# 允许存入腰带的消耗品/药水类型
+const ALLOWED_CONSUMABLE_TYPES: Array = ["potion", "consumable", "dew", "elixir"]
+
+# 已装备的腰带
+var equipped_belt: Dictionary = {}
+var belt_inventory: Array = [] # 16 个格子
+
+func _init() -> void:
+ belt_inventory.resize(MAX_BELT_SLOTS)
+ for i in range(MAX_BELT_SLOTS):
+ belt_inventory[i] = null
+
+# 根据腰带强化等级计算开放的药水槽位数量
+static func calculate_unlocked_slots(refine_level: int) -> int:
+ if refine_level >= 9:
+ return 16 # 4x4
+ elif refine_level >= 7:
+ return 12 # 3x4
+ elif refine_level >= 5:
+ return 10
+ elif refine_level >= 3:
+ return 8 # 2x4
+ elif refine_level >= 1:
+ return 6
+ else:
+ return 4 # 1x4
+
+# 获取当前开放的药水格子数
+func get_unlocked_slot_count() -> int:
+ if equipped_belt.is_empty():
+ return 0
+ var refine: int = int(equipped_belt.get("refine_level", 0))
+ return calculate_unlocked_slots(refine)
+
+# 佩戴腰带
+func equip_belt(belt_item: Dictionary) -> Dictionary:
+ if belt_item.is_empty():
+ return {"ok": false, "reason": "EMPTY_BELT", "msg": "腰带数据为空!"}
+
+ equipped_belt = belt_item.duplicate(true)
+ var unlocked := get_unlocked_slot_count()
+ var bname: String = equipped_belt.get("name", "腰带")
+
+ belt_equipped.emit(bname, unlocked)
+
+ return {
+ "ok": true,
+ "belt": equipped_belt,
+ "unlocked_slots": unlocked,
+ "msg": "已佩戴【%s】,解锁了 %d 格专属药水空间!" % [bname, unlocked]
+ }
+
+# 卸下腰带 (需确保腰带内物品能够全部安全移出或为空)
+func unequip_belt(main_inventory: Array) -> Dictionary:
+ if equipped_belt.is_empty():
+ return {"ok": false, "reason": "NO_BELT_EQUIPPED", "msg": "当前未佩戴腰带!"}
+
+ # 检查腰带内是否有物品
+ var items_in_belt: Array = []
+ for i in range(MAX_BELT_SLOTS):
+ if belt_inventory[i] != null:
+ items_in_belt.append({"belt_slot": i, "item": belt_inventory[i]})
+
+ if not items_in_belt.is_empty():
+ # 统计主背包空位
+ var empty_slots: Array = []
+ for j in range(main_inventory.size()):
+ if main_inventory[j] == null:
+ empty_slots.append(j)
+
+ if empty_slots.size() < items_in_belt.size():
+ return {
+ "ok": false,
+ "reason": "BELT_NOT_EMPTY",
+ "items_count": items_in_belt.size(),
+ "empty_slots": empty_slots.size(),
+ "msg": "主背包空间不足以容纳腰带内的药水,无法卸下腰带!"
+ }
+
+ # 移入主背包
+ for k in range(items_in_belt.size()):
+ var m_slot: int = empty_slots[k]
+ var it = items_in_belt[k]["item"]
+ var b_slot: int = items_in_belt[k]["belt_slot"]
+ main_inventory[m_slot] = it
+ belt_inventory[b_slot] = null
+
+ var old_belt := equipped_belt.duplicate(true)
+ equipped_belt.clear()
+ belt_unequipped.emit()
+
+ return {
+ "ok": true,
+ "belt": old_belt,
+ "msg": "腰带已卸下!内部药水已全部归还至主背包。"
+ }
+
+# 存放药水至腰带专属背包
+func store_item_in_belt(slot_idx: int, item: Dictionary) -> Dictionary:
+ if equipped_belt.is_empty():
+ return {"ok": false, "reason": "NO_BELT_EQUIPPED", "msg": "未佩戴腰带,无法使用专属药水栏!"}
+
+ var unlocked := get_unlocked_slot_count()
+ if slot_idx < 0 or slot_idx >= unlocked:
+ return {
+ "ok": false,
+ "reason": "SLOT_LOCKED_OR_INVALID",
+ "unlocked_slots": unlocked,
+ "msg": "该腰带槽位尚未解锁或无效!当前腰带只开放了前 %d 格。" % unlocked
+ }
+
+ # 药品类型校验
+ var it_type: String = item.get("item_type", "")
+ var is_potion: bool = ALLOWED_CONSUMABLE_TYPES.has(it_type) or item.get("is_potion", false)
+ if not is_potion:
+ return {
+ "ok": false,
+ "reason": "NOT_A_POTION",
+ "msg": "腰带栏位专用于补给品,只能存放药水、灵药与神圣露水!"
+ }
+
+ belt_inventory[slot_idx] = item.duplicate(true)
+ var iname: String = item.get("name", "药水")
+ belt_item_stored.emit(slot_idx, iname)
+
+ return {
+ "ok": true,
+ "slot_idx": slot_idx,
+ "item": belt_inventory[slot_idx],
+ "msg": "已将【%s】置入腰带快捷栏!" % iname
+ }
+
+# 从腰带栏取出药水
+func take_item_from_belt(slot_idx: int) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= MAX_BELT_SLOTS:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item = belt_inventory[slot_idx]
+ if item == null:
+ return {"ok": false, "reason": "EMPTY_SLOT"}
+
+ belt_inventory[slot_idx] = null
+ belt_item_removed.emit(slot_idx)
+
+ return {
+ "ok": true,
+ "slot_idx": slot_idx,
+ "item": item
+ }
diff --git a/project/belt_system.gd.uid b/project/belt_system.gd.uid
new file mode 100644
index 00000000..69e9456c
--- /dev/null
+++ b/project/belt_system.gd.uid
@@ -0,0 +1 @@
+uid://nde6dfrdg7vg
diff --git a/project/bgm_jukebox_system.gd b/project/bgm_jukebox_system.gd
new file mode 100644
index 00000000..69edfc88
--- /dev/null
+++ b/project/bgm_jukebox_system.gd
@@ -0,0 +1,101 @@
+# bgm_jukebox_system.gd —— 40250 客户端 BGM 点唱机与地图音乐配置系统 1:1
+# 严格对照:
+# Client/Eternexus/root/uiselectmusic.py (SelectMusicWindow)
+# Client/Eternexus/root/musicinfo.py
+# ClientVS22/source/UserInterface/PythonSoundManagerModule.cpp
+extends RefCounted
+
+const DEFAULT_THEME_TRACK := "enter_the_east.mp3"
+
+# 40250 官方标准曲目清单 (musicinfo.py)
+const CANONICAL_TRACKS := [
+ "enter_the_east.mp3",
+ "open_the_gate.mp3",
+ "characterselect.mp3",
+ "wedding.mp3",
+ "desert.mp3",
+ "mountain.mp3",
+ "temple.mp3",
+ "monkey_dungeon.mp3",
+ "flame_dungeon.mp3",
+ "catacomb.mp3",
+ "save_the_world.mp3",
+ "peaceful_valley.mp3",
+ "wonderland.mp3"
+]
+
+# 各大地图官方默认 BGM 映射表
+const DEFAULT_MAP_BGM_MAP := {
+ "intro_login": "enter_the_east.mp3",
+ "intro_select": "characterselect.mp3",
+ "metin2_map_a1": "open_the_gate.mp3",
+ "metin2_map_b1": "open_the_gate.mp3",
+ "metin2_map_c1": "open_the_gate.mp3",
+ "metin2_map_n_desert_01": "desert.mp3",
+ "metin2_map_n_snowm_01": "mountain.mp3",
+ "metin2_map_milgyo": "temple.mp3",
+ "metin2_map_deviltower": "catacomb.mp3",
+ "metin2_map_wedding_01": "wedding.mp3",
+}
+
+# 播放器运行时状态
+var current_track: String = ""
+var is_playing: bool = false
+var loop_enabled: bool = true
+var volume: float = 0.8 # 0.0 ~ 1.0 (默认 80%)
+
+# 自定义地图偏好覆写: map_id -> track_name
+var custom_map_overrides: Dictionary = {}
+
+func _init() -> void:
+ current_track = ""
+ is_playing = false
+ loop_enabled = true
+ volume = 0.8
+ custom_map_overrides.clear()
+
+# 1. 播放指定曲目 (1:1 snd.PlayBGM)
+func play_bgm(track_name: String, loop: bool = true) -> bool:
+ if not CANONICAL_TRACKS.has(track_name):
+ return false
+ current_track = track_name
+ is_playing = true
+ loop_enabled = loop
+ return true
+
+# 2. 停止背景音乐 (1:1 snd.StopBGM)
+func stop_bgm() -> void:
+ is_playing = false
+ current_track = ""
+
+# 3. 调节音量 (1:1 snd.SetBGMVolume)
+func set_volume(vol_pct: float) -> void:
+ volume = clamp(vol_pct, 0.0, 1.0)
+
+# 4. 获取指定地图应当播放的背景音乐 (自定义优先,其次默认)
+func get_effective_map_bgm(map_id: String) -> String:
+ if custom_map_overrides.has(map_id):
+ return custom_map_overrides[map_id]
+ return DEFAULT_MAP_BGM_MAP.get(map_id, DEFAULT_THEME_TRACK)
+
+# 5. 进入地图自动切换音乐
+func on_enter_map(map_id: String) -> String:
+ var target_track = get_effective_map_bgm(map_id)
+ if target_track != current_track or not is_playing:
+ play_bgm(target_track, true)
+ return target_track
+
+# 6. 点唱机自定义地图专属曲目 (uiselectmusic.py: SelectMusic)
+func set_custom_map_bgm(map_id: String, track_name: String) -> bool:
+ if not CANONICAL_TRACKS.has(track_name):
+ return false
+ custom_map_overrides[map_id] = track_name
+ return true
+
+# 7. 恢复地图默认曲目
+func reset_custom_map_bgm(map_id: String) -> void:
+ if custom_map_overrides.has(map_id):
+ custom_map_overrides.erase(map_id)
+
+func clear_all_custom_overrides() -> void:
+ custom_map_overrides.clear()
diff --git a/project/bgm_jukebox_system.gd.uid b/project/bgm_jukebox_system.gd.uid
new file mode 100644
index 00000000..b85ffc1d
--- /dev/null
+++ b/project/bgm_jukebox_system.gd.uid
@@ -0,0 +1 @@
+uid://co4sc1bl3n0sj
diff --git a/project/biologist_chaegirab_research_system.gd b/project/biologist_chaegirab_research_system.gd
new file mode 100644
index 00000000..ca4266e2
--- /dev/null
+++ b/project/biologist_chaegirab_research_system.gd
@@ -0,0 +1,270 @@
+# biologist_chaegirab_research_system.gd —— Metin2 40250 官方生物学者车齿木全套调查研究链与永久属性嘉奖系统 1:1
+# 对照 40250 服务端 collect_quest_lv30.quest ~ collect_quest_lv94.quest, char.cpp
+class_name BiologistChaegirabResearchSystem
+extends RefCounted
+
+signal sample_submitted(item_name: String, success: bool, current: int, target: int)
+signal capstone_submitted(stone_name: String, stage_name: String)
+signal stage_completed(stage_name: String, rewards: Dictionary)
+
+const NPC_BIOLOGIST := 20084 # 生物学者车齿木 (Biologist Chaegirab)
+const VNUM_RESEARCH_ELIXIR := 71035 # 贤者之药 (提高提交成功率至 90%)
+
+# 40250 官方 8 大里程碑阶段全量研究任务链
+const RESEARCH_STAGES: Array = [
+ {
+ "stage_id": 1,
+ "title": "【Lv 30 研究】兽人牙齿调查",
+ "min_level": 30,
+ "sample_vnum": 30006, # 兽人牙齿
+ "sample_name": "兽人牙齿",
+ "target_count": 10,
+ "soul_stone_vnum": 30220, # 兽人灵魂石
+ "soul_stone_name": "兽人灵魂石",
+ "reward_stats": {"move_speed": 10},
+ "desc": "永久移动速度 +10"
+ },
+ {
+ "stage_id": 2,
+ "title": "【Lv 40 研究】诅咒之书调查",
+ "min_level": 40,
+ "sample_vnum": 30047, # 诅咒之书
+ "sample_name": "诅咒之书",
+ "target_count": 15,
+ "soul_stone_vnum": 30221, # 诅咒灵魂石
+ "soul_stone_name": "诅咒灵魂石",
+ "reward_stats": {"cast_speed_pct": 5},
+ "desc": "永久施法速度 +5%"
+ },
+ {
+ "stage_id": 3,
+ "title": "【Lv 50 研究】恶魔纪念物调查",
+ "min_level": 50,
+ "sample_vnum": 30015, # 恶魔纪念物
+ "sample_name": "恶魔纪念物",
+ "target_count": 15,
+ "soul_stone_vnum": 30222, # 恶魔灵魂石
+ "soul_stone_name": "恶魔灵魂石",
+ "reward_stats": {"defense": 60},
+ "desc": "永久防御力 +60"
+ },
+ {
+ "stage_id": 4,
+ "title": "【Lv 60 研究】冰之碎片调查",
+ "min_level": 60,
+ "sample_vnum": 30050, # 冰之碎片
+ "sample_name": "冰之碎片",
+ "target_count": 20,
+ "soul_stone_vnum": 30223, # 冰霜灵魂石
+ "soul_stone_name": "冰霜灵魂石",
+ "reward_stats": {"attack_power": 50},
+ "desc": "永久物理攻击力 +50"
+ },
+ {
+ "stage_id": 5,
+ "title": "【Lv 70 研究】幽灵树枝调查",
+ "min_level": 70,
+ "sample_vnum": 30165, # 幽灵树枝
+ "sample_name": "幽灵树枝",
+ "target_count": 25,
+ "soul_stone_vnum": 30224, # 森林灵魂石
+ "soul_stone_name": "森林灵魂石",
+ "reward_stats": {"move_speed": 11, "damage_reduction_pct": 10},
+ "desc": "永久移动速度 +11, 伤害抗性 +10%"
+ },
+ {
+ "stage_id": 6,
+ "title": "【Lv 80 研究】巨灵石碑调查",
+ "min_level": 80,
+ "sample_vnum": 30166, # 巨灵石碑
+ "sample_name": "巨灵石碑",
+ "target_count": 30,
+ "soul_stone_vnum": 30225, # 巨灵灵魂石
+ "soul_stone_name": "巨灵灵魂石",
+ "reward_stats": {"attack_speed": 6, "attack_pct": 10},
+ "desc": "永久攻击速度 +6, 物理伤害 +10%"
+ },
+ {
+ "stage_id": 7,
+ "title": "【Lv 85 研究】红木灵枝调查",
+ "min_level": 85,
+ "sample_vnum": 30167, # 红木灵枝
+ "sample_name": "红木灵枝",
+ "target_count": 40,
+ "soul_stone_vnum": 30226, # 灵魂之石
+ "soul_stone_name": "灵魂之石",
+ "reward_stats": {"all_resistance_pct": 10},
+ "desc": "永久全属性抗性 +10%"
+ },
+ {
+ "stage_id": 8,
+ "title": "【Lv 90 研究】首领领主证书调查",
+ "min_level": 90,
+ "sample_vnum": 30168, # 领主证书
+ "sample_name": "首领领主证书",
+ "target_count": 50,
+ "soul_stone_vnum": 30227, # 首领灵魂石
+ "soul_stone_name": "首领灵魂石",
+ "reward_stats": {"boss_damage_pct": 10},
+ "desc": "永久对野外首领伤害 +10%"
+ }
+]
+
+var current_stage_index: int = 0
+var current_samples: int = 0
+var waiting_for_soul_stone: bool = false
+var completed_stages: Array = []
+var permanent_bonuses: Dictionary = {}
+
+# 获取当前正在进行的研究任务配置
+func get_current_stage() -> Dictionary:
+ if current_stage_index >= RESEARCH_STAGES.size():
+ return {} # 已全部完成
+ return RESEARCH_STAGES[current_stage_index].duplicate(true)
+
+# 交付样本材料
+func submit_sample(inventory: Array, slot_index: int, player_level: int, force_success: bool = false, use_elixir: bool = false) -> Dictionary:
+ var stage = get_current_stage()
+ if stage.is_empty():
+ return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED", "msg": "所有生物学者调查研究均已登峰造极!"}
+
+ if player_level < int(stage["min_level"]):
+ return {
+ "ok": false,
+ "reason": "PLAYER_LEVEL_TOO_LOW",
+ "msg": "等级不足!开启该阶段研究需达到 %d 级。" % int(stage["min_level"])
+ }
+
+ if waiting_for_soul_stone:
+ return {
+ "ok": false,
+ "reason": "WAITING_FOR_SOUL_STONE",
+ "msg": "样本已全部收集齐!请交付【%s】以完成终极认证。" % stage["soul_stone_name"]
+ }
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ var item_vnum := int(item.get("vnum", 0))
+ if item_vnum != int(stage["sample_vnum"]):
+ return {
+ "ok": false,
+ "reason": "WRONG_SAMPLE_ITEM",
+ "msg": "车齿木摇了摇头:当前阶段需要研究的是【%s】!" % stage["sample_name"]
+ }
+
+ # 消耗 1 件样本
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ # 判定成功率 (基础 60%,使用贤者之药 90%)
+ var success_chance := 0.90 if use_elixir else 0.60
+ var is_success := force_success or (randf() <= success_chance)
+
+ if is_success:
+ current_samples += 1
+ sample_submitted.emit(stage["sample_name"], true, current_samples, stage["target_count"])
+
+ # 检查样本是否集齐
+ if current_samples >= int(stage["target_count"]):
+ waiting_for_soul_stone = true
+ return {
+ "ok": true,
+ "success": true,
+ "current": current_samples,
+ "target": stage["target_count"],
+ "need_soul_stone": true,
+ "soul_stone_name": stage["soul_stone_name"],
+ "msg": "样本品质极高!%d 件样本已全部收集完毕,请寻找【%s】交付!" % [current_samples, stage["soul_stone_name"]]
+ }
+
+ return {
+ "ok": true,
+ "success": true,
+ "current": current_samples,
+ "target": stage["target_count"],
+ "need_soul_stone": false,
+ "msg": "样本验证成功!研究进度:(%d / %d)。" % [current_samples, stage["target_count"]]
+ }
+ else:
+ sample_submitted.emit(stage["sample_name"], false, current_samples, stage["target_count"])
+ return {
+ "ok": true,
+ "success": false,
+ "current": current_samples,
+ "target": stage["target_count"],
+ "need_soul_stone": false,
+ "msg": "学者车齿木叹息:这件样本受损严重,无法用于精炼分析。"
+ }
+
+# 交付关底灵魂石完成该阶段
+func submit_soul_stone(inventory: Array, slot_index: int, player_stats: Dictionary) -> Dictionary:
+ var stage = get_current_stage()
+ if stage.is_empty():
+ return {"ok": false, "reason": "ALL_RESEARCH_COMPLETED"}
+
+ if not waiting_for_soul_stone:
+ return {"ok": false, "reason": "SAMPLES_NOT_COMPLETED", "msg": "样本尚未收集齐,无法交付灵魂石!"}
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ if int(item.get("vnum", 0)) != int(stage["soul_stone_vnum"]):
+ return {
+ "ok": false,
+ "reason": "WRONG_SOUL_STONE",
+ "msg": "需要交付的是专属的【%s】!" % stage["soul_stone_name"]
+ }
+
+ # 消耗灵魂石
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ # 注入永久属性加成
+ var rewards: Dictionary = stage.get("reward_stats", {})
+ for k in rewards.keys():
+ var val = int(rewards[k])
+ player_stats[k] = int(player_stats.get(k, 0)) + val
+ permanent_bonuses[k] = int(permanent_bonuses.get(k, 0)) + val
+
+ completed_stages.append(stage["stage_id"])
+ current_stage_index += 1
+ current_samples = 0
+ waiting_for_soul_stone = false
+
+ capstone_submitted.emit(stage["soul_stone_name"], stage["title"])
+ stage_completed.emit(stage["title"], rewards)
+
+ return {
+ "ok": true,
+ "completed_stage": stage["title"],
+ "rewards": rewards,
+ "permanent_bonuses": permanent_bonuses,
+ "msg": "【%s】圆满达成!永久获得属性加成:%s!" % [stage["title"], stage["desc"]]
+ }
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "current_stage_index": current_stage_index,
+ "current_samples": current_samples,
+ "waiting_for_soul_stone": waiting_for_soul_stone,
+ "completed_stages": completed_stages.duplicate(true),
+ "permanent_bonuses": permanent_bonuses.duplicate(true)
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ current_stage_index = int(data.get("current_stage_index", 0))
+ current_samples = int(data.get("current_samples", 0))
+ waiting_for_soul_stone = bool(data.get("waiting_for_soul_stone", false))
+ completed_stages = data.get("completed_stages", []).duplicate(true)
+ permanent_bonuses = data.get("permanent_bonuses", {}).duplicate(true)
diff --git a/project/biologist_chaegirab_research_system.gd.uid b/project/biologist_chaegirab_research_system.gd.uid
new file mode 100644
index 00000000..83c5f932
--- /dev/null
+++ b/project/biologist_chaegirab_research_system.gd.uid
@@ -0,0 +1 @@
+uid://dvues2wv3bqb1
diff --git a/project/biologist_quest_system.gd b/project/biologist_quest_system.gd
new file mode 100644
index 00000000..5ee3599a
--- /dev/null
+++ b/project/biologist_quest_system.gd
@@ -0,0 +1,263 @@
+# biologist_quest_system.gd —— Metin2 40250 生物学研究家任务链与永久属性加成系统 1:1
+# 对照 40250 服务端 collect_quest_lv30.quest ~ collect_quest_lv80.quest 及 char.cpp:ComputePoints
+class_name BiologistQuestSystem
+extends RefCounted
+
+const NPC_CHAEGIRAB := 20084
+const ITEM_SPIRAL_POTION := 71035 # 灵魂秘药 / 研究家的秘药
+
+const STAGES: Array[Dictionary] = [
+ {
+ "id": 0,
+ "name": "Lv30 兽人的牙齿研究",
+ "required_level": 30,
+ "collect_vnum": 30006, # 兽人齿
+ "collect_name": "兽人齿",
+ "collect_count": 10,
+ "soul_stone_vnum": 30220, # 兽人灵魂石
+ "soul_stone_name": "金龙之灵魂石",
+ "reward_type": "move_speed",
+ "reward_value": 10,
+ "reward_title": "永久移动速度 +10"
+ },
+ {
+ "id": 1,
+ "name": "Lv40 诅咒之书研究",
+ "required_level": 40,
+ "collect_vnum": 30047, # 诅咒书
+ "collect_name": "诅咒之书",
+ "collect_count": 15,
+ "soul_stone_vnum": 30221, # 诅咒灵魂石
+ "soul_stone_name": "神殿之灵魂石",
+ "reward_type": "att_speed",
+ "reward_value": 5,
+ "reward_title": "永久攻击速度 +5"
+ },
+ {
+ "id": 2,
+ "name": "Lv50 恶魔纪念物研究",
+ "required_level": 50,
+ "collect_vnum": 30015, # 恶魔纪念物
+ "collect_name": "恶魔纪念物",
+ "collect_count": 15,
+ "soul_stone_vnum": 30222, # 恶魔灵魂石
+ "soul_stone_name": "恶魔之灵魂石",
+ "reward_type": "def_grade",
+ "reward_value": 60,
+ "reward_title": "永久防御力 +60"
+ },
+ {
+ "id": 3,
+ "name": "Lv60 冰球研究",
+ "required_level": 60,
+ "collect_vnum": 30050, # 冰球
+ "collect_name": "冰球",
+ "collect_count": 20,
+ "soul_stone_vnum": 30223, # 冰球灵魂石
+ "soul_stone_name": "雪山之灵魂石",
+ "reward_type": "att_grade",
+ "reward_value": 50,
+ "reward_title": "永久攻击力 +50"
+ },
+ {
+ "id": 4,
+ "name": "Lv70 幽灵树枝研究",
+ "required_level": 70,
+ "collect_vnum": 30165, # 幽灵树枝
+ "collect_name": "幽灵树枝",
+ "collect_count": 25,
+ "soul_stone_vnum": 30224, # 幽灵灵魂石
+ "soul_stone_name": "鬼树之灵魂石",
+ "reward_type": "damage_reduction",
+ "reward_value": 10,
+ "reward_title": "受到伤害永久减少 10%"
+ },
+ {
+ "id": 5,
+ "name": "Lv80 图基石板研究",
+ "required_level": 80,
+ "collect_vnum": 30166, # 图基石板
+ "collect_name": "图基石板",
+ "collect_count": 30,
+ "soul_stone_vnum": 30225, # 图基灵魂石
+ "soul_stone_name": "图基之灵魂石",
+ "reward_type": "att_grade_pct",
+ "reward_value": 10,
+ "reward_title": "物理攻击力永久提升 10%"
+ }
+]
+
+var player_level: int = 1
+var current_stage_idx: int = -1 # -1: 未开始
+var delivered_count: int = 0
+var soul_stone_phase: bool = false
+var completed_stages: Array[int] = []
+var cooldown_remaining: float = 0.0
+
+var permanent_bonuses: Dictionary = {
+ "move_speed": 0,
+ "att_speed": 0,
+ "def_grade": 0,
+ "att_grade": 0,
+ "damage_reduction": 0,
+ "att_grade_pct": 0
+}
+
+func init_for_player(level: int) -> void:
+ player_level = level
+ _check_available_stage()
+
+func _check_available_stage() -> void:
+ if current_stage_idx >= 0 and current_stage_idx < STAGES.size():
+ return # 正在进行中
+
+ for i in range(STAGES.size()):
+ if i not in completed_stages:
+ var stage: Dictionary = STAGES[i]
+ if player_level >= int(stage["required_level"]):
+ current_stage_idx = i
+ delivered_count = 0
+ soul_stone_phase = false
+ break
+
+func get_current_stage() -> Dictionary:
+ if current_stage_idx >= 0 and current_stage_idx < STAGES.size():
+ return STAGES[current_stage_idx]
+ return {}
+
+func can_submit() -> bool:
+ return current_stage_idx >= 0 and not soul_stone_phase and cooldown_remaining <= 0.0
+
+func submit_item(inventory: Array, use_potion: bool = false, force_success: bool = false) -> Dictionary:
+ if current_stage_idx < 0 or current_stage_idx >= STAGES.size():
+ return {"ok": false, "reason": "NO_ACTIVE_QUEST"}
+ if soul_stone_phase:
+ return {"ok": false, "reason": "NEED_SOUL_STONE"}
+ if cooldown_remaining > 0.0 and not use_potion:
+ return {"ok": false, "reason": "COOLDOWN_ACTIVE", "remaining": cooldown_remaining}
+
+ var stage: Dictionary = STAGES[current_stage_idx]
+ var req_vnum: int = int(stage["collect_vnum"])
+
+ # 查找收集物槽位
+ var item_slot := -1
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and int(it.get("vnum", 0)) == req_vnum:
+ item_slot = i
+ break
+
+ if item_slot == -1:
+ return {"ok": false, "reason": "MISSING_ITEM", "vnum": req_vnum}
+
+ # 检查秘药
+ var potion_consumed := false
+ if use_potion:
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and int(it.get("vnum", 0)) == ITEM_SPIRAL_POTION:
+ inventory[i] = null
+ potion_consumed = true
+ cooldown_remaining = 0.0
+ break
+
+ # 扣除收集物
+ inventory[item_slot] = null
+
+ # 成功判定 (默认 60%,秘药 90%)
+ var success_chance: float = 0.90 if potion_consumed else 0.60
+ var roll: float = 0.0 if force_success else randf()
+ var success := roll < success_chance
+
+ if not success:
+ cooldown_remaining = 0.0 if potion_consumed else 3600.0 # 1 小时
+ return {
+ "ok": true,
+ "success": false,
+ "delivered": delivered_count,
+ "target": stage["collect_count"],
+ "msg": "样本已损坏,未能提取有效数据!"
+ }
+
+ # 成功交付
+ delivered_count += 1
+ var target_count: int = int(stage["collect_count"])
+ if delivered_count >= target_count:
+ soul_stone_phase = true
+ return {
+ "ok": true,
+ "success": true,
+ "delivered": delivered_count,
+ "target": target_count,
+ "stage_completed": true,
+ "msg": "研究样本收集完毕!接下来请获取专属灵魂石交付!"
+ }
+ else:
+ cooldown_remaining = 0.0 if potion_consumed else 3600.0
+ return {
+ "ok": true,
+ "success": true,
+ "delivered": delivered_count,
+ "target": target_count,
+ "stage_completed": false,
+ "msg": "样本质量极佳!已记录样本数据。"
+ }
+
+func submit_soul_stone(inventory: Array) -> Dictionary:
+ if current_stage_idx < 0 or current_stage_idx >= STAGES.size():
+ return {"ok": false, "reason": "NO_ACTIVE_QUEST"}
+ if not soul_stone_phase:
+ return {"ok": false, "reason": "NOT_IN_SOUL_STONE_PHASE"}
+
+ var stage: Dictionary = STAGES[current_stage_idx]
+ var soul_vnum: int = int(stage["soul_stone_vnum"])
+
+ var found_slot := -1
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and int(it.get("vnum", 0)) == soul_vnum:
+ found_slot = i
+ break
+
+ if found_slot == -1:
+ return {"ok": false, "reason": "MISSING_SOUL_STONE", "vnum": soul_vnum}
+
+ # 消耗灵魂石
+ inventory[found_slot] = null
+
+ # 结算阶段奖励
+ var reward_type: String = String(stage["reward_type"])
+ var reward_val: int = int(stage["reward_value"])
+ permanent_bonuses[reward_type] = int(permanent_bonuses.get(reward_type, 0)) + reward_val
+ completed_stages.append(current_stage_idx)
+
+ var finished_stage_id: int = current_stage_idx
+ current_stage_idx = -1
+ delivered_count = 0
+ soul_stone_phase = false
+ cooldown_remaining = 0.0
+
+ # 检查下一阶段
+ _check_available_stage()
+
+ return {
+ "ok": true,
+ "completed_stage": finished_stage_id,
+ "reward_title": stage["reward_title"],
+ "reward_type": reward_type,
+ "reward_value": reward_val,
+ "all_bonuses": permanent_bonuses.duplicate()
+ }
+
+func get_quest_status() -> Dictionary:
+ return {
+ "player_level": player_level,
+ "stage_idx": current_stage_idx,
+ "stage_info": get_current_stage(),
+ "delivered": delivered_count,
+ "target": int(get_current_stage().get("collect_count", 0)),
+ "soul_stone_phase": soul_stone_phase,
+ "cooldown_remaining": cooldown_remaining,
+ "completed_stages": completed_stages,
+ "permanent_bonuses": permanent_bonuses
+ }
diff --git a/project/biologist_quest_system.gd.uid b/project/biologist_quest_system.gd.uid
new file mode 100644
index 00000000..d3b517c5
--- /dev/null
+++ b/project/biologist_quest_system.gd.uid
@@ -0,0 +1 @@
+uid://cqcq40dpplogo
diff --git a/project/boss_summon_scroll_system.gd b/project/boss_summon_scroll_system.gd
new file mode 100644
index 00000000..8df2b0d4
--- /dev/null
+++ b/project/boss_summon_scroll_system.gd
@@ -0,0 +1,312 @@
+# boss_summon_scroll_system.gd —— Metin2 40250 官方首领降临与魔石/矿脉召唤卷轴系统 1:1
+# 100% 对照 40250 服务端 special_item_group.txt, char_item.cpp (ITEM_GIFTBOX), char_manager.cpp
+class_name BossSummonScrollSystem
+extends RefCounted
+
+signal boss_summoned(mob_vnum: int, mob_name: String, spawn_pos: Vector2, is_aggressive: bool)
+signal metin_summoned(metin_vnum: int, metin_name: String, spawn_pos: Vector2, level: int)
+signal ore_vein_summoned(vein_vnum: int, vein_name: String, spawn_pos: Vector2)
+signal summon_failed(reason: String)
+
+# ==========================================
+# 40250 官方首领召唤卷轴 (Boss Summon Scrolls)
+# 对应 special_item_group.txt Vnum 71036 ~ 71043
+# ==========================================
+const BOSS_SCROLLS: Dictionary = {
+ 71036: {
+ "name": "兽人首领召唤卷轴",
+ "tr_name": "Kağıt-Ork Şefi",
+ "mob_vnum": 691,
+ "mob_name": "兽人首领 (Orc Chief)",
+ "level": 50,
+ "rank": 4, # BOSS
+ "is_aggressive": true
+ },
+ 71037: {
+ "name": "密教首领召唤卷轴",
+ "tr_name": "Kağıt-Karanlık Lider",
+ "mob_vnum": 791,
+ "mob_name": "黑风教主/密教首领 (Dark Leader)",
+ "level": 54,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71038: {
+ "name": "蜘蛛女王召唤卷轴",
+ "tr_name": "Kağıt-Kraliçe Örümcek",
+ "mob_vnum": 2091,
+ "mob_name": "蜘蛛女王 (Spider Queen)",
+ "level": 60,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71039: {
+ "name": "沙漠巨龟召唤卷轴",
+ "tr_name": "Kağıt-Kaplumbağa",
+ "mob_vnum": 2191,
+ "mob_name": "沙漠巨龟 (Giant Desert Tortoise)",
+ "level": 67,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71040: {
+ "name": "烈焰魔王召唤卷轴",
+ "tr_name": "Kağıt-Alev Kral",
+ "mob_vnum": 2206,
+ "mob_name": "烈焰魔王 (Flame King)",
+ "level": 73,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71041: {
+ "name": "九尾狐召唤卷轴",
+ "tr_name": "Kağıt-Dokuz Kuyruk",
+ "mob_vnum": 1901,
+ "mob_name": "九尾狐仙 (Nine Tails)",
+ "level": 72,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71042: {
+ "name": "恶魔之王召唤卷轴",
+ "tr_name": "Kağıt-Şeytan Kral",
+ "mob_vnum": 1091,
+ "mob_name": "恶魔之王 (Demon King)",
+ "level": 75,
+ "rank": 4,
+ "is_aggressive": true
+ },
+ 71043: {
+ "name": "虎灵妖兽召唤卷轴",
+ "tr_name": "Sarı Kaplan H. Kağıdı",
+ "mob_vnum": 1304,
+ "mob_name": "黄虎幽灵 (Tiger Ghost)",
+ "level": 75,
+ "rank": 4,
+ "is_aggressive": true
+ }
+}
+
+# ==========================================
+# 40250 官方魔石召唤卷轴 (Metin Summon Scrolls)
+# 对应 special_item_group.txt Vnum 71080 ~ 71082
+# ==========================================
+const METIN_SCROLLS: Dictionary = {
+ 71080: {
+ "name": "初级魔石召唤卷",
+ "tr_name": "Metin Taşı Çağırma Kağıdı (K)",
+ "pool": [
+ {"vnum": 8001, "name": "悲哀之石 (Metin of Sorrow)", "level": 5},
+ {"vnum": 8002, "name": "战斗之石 (Metin of Combat)", "level": 10},
+ {"vnum": 8003, "name": "会战之石 (Metin of Battle)", "level": 15},
+ {"vnum": 8004, "name": "贪婪之石 (Metin of Greed)", "level": 20},
+ {"vnum": 8005, "name": "漆黑之石 (Metin of Black)", "level": 25}
+ ]
+ },
+ 71081: {
+ "name": "中级魔石召唤卷",
+ "tr_name": "Metin Taşı Çağırma Kağıdı (O)",
+ "pool": [
+ {"vnum": 8006, "name": "黑暗之石 (Metin of Darkness)", "level": 30},
+ {"vnum": 8007, "name": "嫉妒之石 (Metin of Jealousy)", "level": 35},
+ {"vnum": 8008, "name": "灵魂之石 (Metin of Soul)", "level": 40},
+ {"vnum": 8009, "name": "阴影之石 (Metin of Shadow)", "level": 45},
+ {"vnum": 8010, "name": "强韧之石 (Metin of Toughness)", "level": 50}
+ ]
+ },
+ 71082: {
+ "name": "高级魔石召唤卷",
+ "tr_name": "Metin Taşı Çağırma Kağıdı (Z)",
+ "pool": [
+ {"vnum": 8011, "name": "恶魔之石 (Metin of Devil)", "level": 55},
+ {"vnum": 8012, "name": "陨落之石 (Metin of Fall)", "level": 60},
+ {"vnum": 8013, "name": "死者之石 (Metin of Death)", "level": 65},
+ {"vnum": 8014, "name": "屠杀之石 (Metin of Murder)", "level": 70},
+ {"vnum": 8024, "name": "狂风之石 (Metin of Pung-Ma)", "level": 75},
+ {"vnum": 8025, "name": "魔岩之石 (Metin of Ma-An)", "level": 80},
+ {"vnum": 8026, "name": "斗击之石 (Metin of Tu-Gyuk)", "level": 85},
+ {"vnum": 8027, "name": "绝望之石 (Metin of Jeon-Un)", "level": 90}
+ ]
+ }
+}
+
+# ==========================================
+# 40250 官方矿脉召唤卷轴 (Ore Vein Scrolls)
+# 对应 special_item_group.txt Vnum 71057 ~ 71067
+# ==========================================
+const ORE_VEIN_SCROLLS: Dictionary = {
+ 71057: {"mob_vnum": 20049, "name": "钻石矿脉 (Diamond Vein)"},
+ 71058: {"mob_vnum": 20050, "name": "琥珀矿脉 (Amber Vein)"},
+ 71059: {"mob_vnum": 20051, "name": "木化石矿脉 (Fossil Wood Vein)"},
+ 71060: {"mob_vnum": 20052, "name": "铜矿脉 (Copper Vein)"},
+ 71061: {"mob_vnum": 20053, "name": "银矿脉 (Silver Vein)"},
+ 71062: {"mob_vnum": 20054, "name": "金矿脉 (Gold Vein)"},
+ 71063: {"mob_vnum": 20055, "name": "翡翠矿脉 (Jade Vein)"},
+ 71064: {"mob_vnum": 20056, "name": "黑檀木矿脉 (Ebony Vein)"},
+ 71065: {"mob_vnum": 20057, "name": "珍珠矿脉 (Pearl Vein)"},
+ 71066: {"mob_vnum": 20058, "name": "白金矿脉 (White Gold Vein)"},
+ 71067: {"mob_vnum": 20059, "name": "水晶矿脉 (Crystal Vein)"}
+}
+
+# 当前已召唤生成的实体记录列表
+var _active_summoned_entities: Array[Dictionary] = []
+
+# 判断是否是 40250 原版召唤卷轴
+func is_summon_scroll(vnum: int) -> bool:
+ return BOSS_SCROLLS.has(vnum) or METIN_SCROLLS.has(vnum) or ORE_VEIN_SCROLLS.has(vnum)
+
+# 获取召唤卷轴类型类别
+func get_scroll_category(vnum: int) -> String:
+ if BOSS_SCROLLS.has(vnum):
+ return "boss"
+ elif METIN_SCROLLS.has(vnum):
+ return "metin"
+ elif ORE_VEIN_SCROLLS.has(vnum):
+ return "ore_vein"
+ return ""
+
+# 使用召唤卷轴
+func use_summon_scroll(
+ inventory: Array,
+ slot_index: int,
+ player_pos: Vector2,
+ is_in_safe_zone: bool,
+ forced_choice_idx: int = -1
+) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size():
+ summon_failed.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包物品格位!"}
+
+ var item = inventory[slot_index]
+ if item == null:
+ summon_failed.emit("EMPTY_SLOT")
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "指定格位没有任何道具!"}
+
+ var vnum = int(item.get("vnum", 0))
+ var category = get_scroll_category(vnum)
+ if category == "":
+ summon_failed.emit("NOT_A_SUMMON_SCROLL")
+ return {"ok": false, "reason": "NOT_A_SUMMON_SCROLL", "msg": "此物品并非怪物或魔石召唤卷轴!"}
+
+ # 40250 原版规则:安全区内禁止召唤具有攻击性的首领与魔石
+ if is_in_safe_zone and category != "ore_vein":
+ summon_failed.emit("CANNOT_SPAWN_IN_SAFEZONE")
+ return {"ok": false, "reason": "CANNOT_SPAWN_IN_SAFEZONE", "msg": "在安全区内无法召唤怪物!请移至野外危险区域。"}
+
+ # 扣除卷轴消耗 (40250 item->SetCount(item->GetCount()-1))
+ var current_count = int(item.get("count", 1))
+ if current_count > 1:
+ item["count"] = current_count - 1
+ else:
+ inventory[slot_index] = null
+
+ # 生成随机偏移位置 (类似 40250 GetX() + number(-500, 500))
+ var offset_x = randf_range(-120.0, 120.0)
+ var offset_y = randf_range(-120.0, 120.0)
+ # 确保不会生成在绝对重合点
+ if abs(offset_x) < 30.0:
+ offset_x = 50.0 if offset_x >= 0.0 else -50.0
+ if abs(offset_y) < 30.0:
+ offset_y = 50.0 if offset_y >= 0.0 else -50.0
+
+ var spawn_pos = Vector2(player_pos.x + offset_x, player_pos.y + offset_y)
+ var summoned_entity: Dictionary = {}
+
+ match category:
+ "boss":
+ var boss_info = BOSS_SCROLLS[vnum]
+ summoned_entity = {
+ "vnum": int(boss_info["mob_vnum"]),
+ "name": str(boss_info["mob_name"]),
+ "level": int(boss_info["level"]),
+ "rank": int(boss_info["rank"]),
+ "pos": spawn_pos,
+ "is_aggressive": true,
+ "category": "boss",
+ "summoner_pos": player_pos
+ }
+ _active_summoned_entities.append(summoned_entity)
+ boss_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos, true)
+
+ "metin":
+ var metin_scroll = METIN_SCROLLS[vnum]
+ var pool: Array = metin_scroll["pool"]
+ var picked_idx = 0
+ if forced_choice_idx >= 0 and forced_choice_idx < pool.size():
+ picked_idx = forced_choice_idx
+ else:
+ picked_idx = randi() % pool.size()
+
+ var picked_metin = pool[picked_idx]
+ summoned_entity = {
+ "vnum": int(picked_metin["vnum"]),
+ "name": str(picked_metin["name"]),
+ "level": int(picked_metin["level"]),
+ "rank": 5, # STONE
+ "pos": spawn_pos,
+ "is_aggressive": false,
+ "category": "metin",
+ "summoner_pos": player_pos
+ }
+ _active_summoned_entities.append(summoned_entity)
+ metin_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos, summoned_entity["level"])
+
+ "ore_vein":
+ var vein_info = ORE_VEIN_SCROLLS[vnum]
+ summoned_entity = {
+ "vnum": int(vein_info["mob_vnum"]),
+ "name": str(vein_info["name"]),
+ "pos": spawn_pos,
+ "is_aggressive": false,
+ "category": "ore_vein",
+ "summoner_pos": player_pos
+ }
+ _active_summoned_entities.append(summoned_entity)
+ ore_vein_summoned.emit(summoned_entity["vnum"], summoned_entity["name"], spawn_pos)
+
+ return {
+ "ok": true,
+ "category": category,
+ "entity": summoned_entity,
+ "msg": "卷轴散发出一道耀眼的光芒,强大的封印能量破空而出!"
+ }
+
+# 获取当前已召唤的所有活跃实体
+func get_active_summoned_entities() -> Array[Dictionary]:
+ return _active_summoned_entities
+
+# 清空/移除已击败或离开的实体
+func remove_summoned_entity(index: int) -> bool:
+ if index >= 0 and index < _active_summoned_entities.size():
+ _active_summoned_entities.remove_at(index)
+ return true
+ return false
+
+func clear_summoned_entities() -> void:
+ _active_summoned_entities.clear()
+
+# 存档与读档支持
+func serialize() -> Dictionary:
+ var list_copy: Array = []
+ for e in _active_summoned_entities:
+ var pos_dict = {"x": e["pos"].x, "y": e["pos"].y}
+ var copy_e = e.duplicate()
+ copy_e["pos"] = pos_dict
+ if copy_e.has("summoner_pos"):
+ copy_e["summoner_pos"] = {"x": e["summoner_pos"].x, "y": e["summoner_pos"].y}
+ list_copy.append(copy_e)
+ return {
+ "active_entities": list_copy
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _active_summoned_entities.clear()
+ var raw_list = data.get("active_entities", [])
+ for item in raw_list:
+ var pos_data = item.get("pos", {"x": 0.0, "y": 0.0})
+ var ent = item.duplicate()
+ ent["pos"] = Vector2(pos_data.get("x", 0.0), pos_data.get("y", 0.0))
+ if ent.has("summoner_pos"):
+ var sp_data = ent.get("summoner_pos", {"x": 0.0, "y": 0.0})
+ ent["summoner_pos"] = Vector2(sp_data.get("x", 0.0), sp_data.get("y", 0.0))
+ _active_summoned_entities.append(ent)
diff --git a/project/boss_summon_scroll_system.gd.uid b/project/boss_summon_scroll_system.gd.uid
new file mode 100644
index 00000000..9b55a523
--- /dev/null
+++ b/project/boss_summon_scroll_system.gd.uid
@@ -0,0 +1 @@
+uid://xjrr61n0rkui
diff --git a/project/char_select_start_motion_test.gd b/project/char_select_start_motion_test.gd
new file mode 100644
index 00000000..34b5330d
--- /dev/null
+++ b/project/char_select_start_motion_test.gd
@@ -0,0 +1,72 @@
+extends SceneTree
+
+const CharSelectScreen = preload("res://ui/char_select_screen.gd")
+
+class FakeClient extends Node:
+ func get_slot_count() -> int: return 4
+ func get_empire() -> int: return 1
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+
+func _initialize() -> void:
+ call_deferred("_run")
+
+func _run() -> void:
+ var client := FakeClient.new()
+ root.add_child(client)
+ var assets := AssetRoot.path()
+ var screen := CharSelectScreen.new()
+ root.add_child(screen)
+
+ screen.setup(client, assets, [
+ {"index": 0, "name": "WarriorTest", "job": 0, "level": 50, "guild_name": "TestGuild",
+ "play_minutes": 120, "ht": 40, "iq": 20, "st": 50, "dx": 30, "main_part": 0, "hair_part": 0}
+ ])
+
+ for _i in 10:
+ await process_frame
+
+ _ck(screen._pv != null, "PlayerView created on stage")
+ _ck(not screen._starting, "Screen not initially starting")
+ _ck(screen._pv.anim != null and String(screen._pv.anim.get("anim_path")).ends_with("intro/wait.msa"),
+ "Initial animation is wait.msa")
+ _ck(bool(screen._pv.anim.get("loop")) == true, "wait.msa is looping")
+
+ var result := {"slot": -1}
+ screen.select_requested.connect(func(slot: int):
+ print("Test received select_requested signal with slot = ", slot)
+ result.slot = slot
+ )
+
+ # 触发开始流程
+ screen._do_start()
+
+ _ck(screen._starting, "Screen entered _starting state")
+ _ck(screen._btn_start.disabled, "Start button disabled during selection")
+ _ck(screen._pv.anim != null and String(screen._pv.anim.get("anim_path")).ends_with("intro/selected.msa"),
+ "Selected animation changed to intro/selected.msa")
+ _ck(bool(screen._pv.anim.get("loop")) == false, "intro/selected.msa is non-looping")
+
+ # 40250 原生逻辑:选角开始时仅播放角色动作与语音,无脚底光环粒子特效
+ var fx_nodes: Array = screen._pivot.find_children("fx_*", "Node3D", true, false)
+ _ck(fx_nodes.is_empty(), "40250 parity: no extraneous ground particle halo spawned")
+
+ # 等待动作完成与 select_requested 信号触发(基于真实时间)
+ var t0 := Time.get_ticks_msec()
+ var frames := 0
+ while result.slot == -1 and (Time.get_ticks_msec() - t0) < 4000:
+ await process_frame
+ frames += 1
+
+ _ck(result.slot == 0, "select_requested emitted with slot 0 after animation finishes")
+
+ if _fail == 0:
+ print("PASS: char_select_start_motion_test (selected.msa motion + sound script + 40250 clean parity)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed" % _fail)
+ quit(1)
diff --git a/project/char_select_start_motion_test.gd.uid b/project/char_select_start_motion_test.gd.uid
new file mode 100644
index 00000000..9f85690d
--- /dev/null
+++ b/project/char_select_start_motion_test.gd.uid
@@ -0,0 +1 @@
+uid://ngrh5ra7vqus
diff --git a/project/char_select_visual_test.gd b/project/char_select_visual_test.gd
index 4b26eda0..4f361730 100644
--- a/project/char_select_visual_test.gd
+++ b/project/char_select_visual_test.gd
@@ -63,6 +63,9 @@ func _run() -> void:
if hair_image != null:
hair_image.save_png(output.path_join("hair-texture.png"))
var mesh_bounds: Array = []
+ var body_uses_blue_steel_skin := false
+ var blue_steel_path := AssetRoot.path().path_join("patch2/ymir work/pc/warrior/warrior_4-2.dds")
+ var blue_steel_image: Image = ua.load_dds_image(blue_steel_path) if main_part == 12019 else null
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
var local_aabb: AABB = mi.get_aabb()
var world_aabb: AABB = mi.get_global_transform() * local_aabb
@@ -78,6 +81,17 @@ func _run() -> void:
var mat: Material = mi.get_active_material(surface)
if mat is ShaderMaterial:
print("MATERIAL mode=", mat.get_shader_parameter("mode"), " alpha_write=", mat.shader.code.contains("ALPHA ="))
+ var albedo := mat.get_shader_parameter("albedo_tex") as Texture2D
+ if albedo and blue_steel_image:
+ var actual := albedo.get_image()
+ # Character textures generate mipmaps before upload. Compare only
+ # level 0 with the decoded DDS or identical pixels would differ by
+ # the appended mip levels.
+ var expected_data := blue_steel_image.get_data()
+ var actual_data := actual.get_data() if actual else PackedByteArray()
+ if actual and actual.get_size() == blue_steel_image.get_size() \
+ and actual_data.slice(0, expected_data.size()) == expected_data:
+ body_uses_blue_steel_skin = true
if "--solid-body" in OS.get_cmdline_user_args():
var solid := StandardMaterial3D.new()
solid.albedo_texture = mat.get_shader_parameter("albedo_tex")
@@ -87,12 +101,23 @@ func _run() -> void:
printerr("FAIL: server armor was not applied")
quit(1)
return
+ if main_part == 12019 and not body_uses_blue_steel_skin:
+ printerr("FAIL: armor 12019 geometry loaded without warrior_4-2 target skin")
+ quit(1)
+ return
if hair_part == 1001 and not String(screen._pv.model.get("hair_gr2")).to_lower().contains("hair_2_1"):
printerr("FAIL: standard HairIndex 1001 did not replace the default hair model")
quit(1)
return
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
+ if screen._viewport:
+ var viewport_image := screen._viewport.get_texture().get_image()
+ if viewport_image.detect_alpha() != Image.ALPHA_NONE:
+ printerr("FAIL: selection stage must be opaque; transparent additive particles create black quads")
+ quit(1)
+ return
+ viewport_image.save_png(output.path_join("viewport.png"))
if root.get_texture().get_image().save_png(output.path_join("selection.png")) != OK:
quit(1)
return
diff --git a/project/char_status_ui_test.gd b/project/char_status_ui_test.gd
index 79f430e4..dcde24a2 100644
--- a/project/char_status_ui_test.gd
+++ b/project/char_status_ui_test.gd
@@ -53,6 +53,14 @@ class FakeClient extends Node:
return "銀河聯盟" if gid == 9 else ""
func say(type: int, text: String) -> bool:
says.append([type, text]); return true
+ signal skills_changed
+ signal skill_group_changed(group: int)
+ var skills_list: Array = [
+ {"id": 31, "level": 10, "master": 0},
+ {"id": 121, "level": 5, "master": 0},
+ ]
+ func get_skills() -> Array: return skills_list
+ func get_skill_group() -> int: return 1
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -148,6 +156,112 @@ func _run() -> void:
cs._set_state("STATUS")
_ck(char_page.visible, "Character_Page visible again after SetState(STATUS)")
+ # --- Skill & Emoticon 拖拽到快捷栏 (40250 机制自检) ---
+ var mouse_ctl := preload("res://ui/mouse_controller.gd").new()
+ get_root().add_child(mouse_ctl)
+ mouse_ctl.setup(ui)
+ cs.item_mouse = mouse_ctl
+
+ var qb := preload("res://ui/quickbar.gd").new()
+ get_root().add_child(qb)
+ qb.item_mouse = mouse_ctl
+ qb.setup(fc, cs.skill_table, ui, func(): return null, assets)
+
+ cs.open("SKILL")
+ _ck(cs.is_open(), "SKILL tab open")
+ var active_slot: Control = cs._node("Skill_Active_Slot")
+ var slot1: Control = active_slot.get_node_or_null("slot_1") if active_slot else null
+ var slot1_btn: TextureButton = slot1.get_node_or_null("Btn") if slot1 else null
+ _ck(slot1_btn != null, "Skill slot_1 has Btn")
+
+ # 1. 模拟左键点击主动技能 (id 31) -> 成功 attach_skill 到鼠标
+ if slot1_btn:
+ var ev := InputEventMouseButton.new()
+ ev.button_index = MOUSE_BUTTON_LEFT
+ ev.pressed = true
+ slot1_btn.gui_input.emit(ev)
+ _ck(mouse_ctl.is_attached(), "mouse attached skill on left click")
+ var att := mouse_ctl.attached()
+ _ck(att.get("source") == "skill" and att.get("skill_id") == 31, "attached payload is skill 31")
+
+ # 2. 模拟拖拽与容差判定:鼠标在快捷栏槽位 0 边缘(例如向外偏移 6 像素)依然能准确吸附落入槽位 0
+ var slot0_ctrl: Control = qb._slots[0]["btn"]
+ var drop_pos: Vector2 = slot0_ctrl.get_global_rect().position + Vector2(-6, -6)
+ _ck(mouse_ctl._drop_at(drop_pos), "generous hitbox allows drop within tolerance")
+ var st0 := qb.mobile_slot_state(0)
+ _ck(st0.get("kind") == "skill" and int(st0.get("id")) == 31, "quickbar slot 0 has skill 31 (got %s)" % str(st0))
+
+ # 2b. 40250 点击拾取模式:点击技能后松开(未发生拖动),技能仍然附着在鼠标上
+ var ev_down := InputEventMouseButton.new()
+ ev_down.button_index = MOUSE_BUTTON_LEFT
+ ev_down.pressed = true
+ slot1_btn.gui_input.emit(ev_down)
+ var ev_up := InputEventMouseButton.new()
+ ev_up.button_index = MOUSE_BUTTON_LEFT
+ ev_up.pressed = false
+ mouse_ctl._input(ev_up)
+ _ck(mouse_ctl.is_attached(), "mouse still attached after stationary click release (40250 pickup mode)")
+ # 移到槽位 3 点击放置
+ var slot3_ctrl: Control = qb._slots[3]["btn"]
+ var ev_click_slot := InputEventMouseButton.new()
+ ev_click_slot.button_index = MOUSE_BUTTON_LEFT
+ ev_click_slot.pressed = true
+ ev_click_slot.position = slot3_ctrl.get_global_rect().get_center()
+ mouse_ctl._input(ev_click_slot)
+ _ck(not mouse_ctl.is_attached(), "clicking quickslot 3 drops attached skill")
+ var st3 := qb.mobile_slot_state(3)
+ _ck(st3.get("kind") == "skill" and int(st3.get("id")) == 31, "quickbar slot 3 has skill 31 (got %s)" % str(st3))
+ mouse_ctl.cancel()
+
+ # 3. 被动技能(如 Leadership 121,slot 103)不可拖拽 / 附着
+ var etc_slot: Control = cs._node("Skill_ETC_Slot")
+ var slot103: Control = etc_slot.get_node_or_null("slot_103") if etc_slot else null
+ var slot103_btn: TextureButton = slot103.get_node_or_null("Btn") if slot103 else null
+ if slot103_btn:
+ var ev := InputEventMouseButton.new()
+ ev.button_index = MOUSE_BUTTON_LEFT
+ ev.pressed = true
+ slot103_btn.gui_input.emit(ev)
+ _ck(not mouse_ctl.is_attached(), "passive skill 121 CANNOT be attached to mouse (40250 CanUseSkill == false)")
+
+ # 4. Ctrl + 左键主动技能 -> 自动填入首个空快捷栏槽位 (40250 RequestAddToEmptyLocalQuickSlot)
+ if slot1_btn:
+ var ev_ctrl := InputEventMouseButton.new()
+ ev_ctrl.button_index = MOUSE_BUTTON_LEFT
+ ev_ctrl.pressed = true
+ ev_ctrl.ctrl_pressed = true
+ slot1_btn.gui_input.emit(ev_ctrl)
+ var st1 := qb.mobile_slot_state(1)
+ _ck(st1.get("kind") == "skill" and int(st1.get("id")) == 31, "Ctrl+Click auto assigns skill to quickslot 1 (got %s)" % str(st1))
+
+ # 5. 表情拖拽:切换到 EMOTICON,点击动作 1 附着到鼠标,放入快捷栏槽位 2
+ cs.open("EMOTICON")
+ var solo_slot: Control = cs._node("SoloEmotionSlot")
+ var emote1: Control = solo_slot.get_node_or_null("slot_1") if solo_slot else null
+ var emote1_btn: TextureButton = emote1.get_node_or_null("Btn") if emote1 else null
+ _ck(emote1_btn != null, "Emote slot_1 has Btn")
+ if emote1_btn:
+ var ev := InputEventMouseButton.new()
+ ev.button_index = MOUSE_BUTTON_LEFT
+ ev.pressed = true
+ emote1_btn.gui_input.emit(ev)
+ _ck(mouse_ctl.is_attached(), "mouse attached emote on left click")
+ var att_e := mouse_ctl.attached()
+ _ck(att_e.get("source") == "emotion" and att_e.get("emote_id") == 1, "attached payload is emote 1")
+ _ck(qb._drop_mouse_payload(att_e, 2), "drop emote onto quickbar slot 2 succeeds")
+ var st2 := qb.mobile_slot_state(2)
+ _ck(st2.get("kind") == "emote" and int(st2.get("id")) == 1, "quickbar slot 2 has emote 1 (got %s)" % str(st2))
+ mouse_ctl.cancel()
+
+ # --- TitleBar close button ---
+ var tb: Control = cs._node("Character_TitleBar")
+ var close_btn: Button = tb.find_child("CloseButton", true, false) if tb else null
+ _ck(close_btn != null, "Character_TitleBar has CloseButton")
+ if close_btn:
+ close_btn.pressed.emit()
+ _ck(not cs.is_open(), "CloseButton on TitleBar closes window")
+ cs.open()
+
cs.close()
_ck(not cs.is_open(), "character window closed")
diff --git a/project/chat_hyperlink_dice_system.gd b/project/chat_hyperlink_dice_system.gd
new file mode 100644
index 00000000..8e3ab7ca
--- /dev/null
+++ b/project/chat_hyperlink_dice_system.gd
@@ -0,0 +1,147 @@
+# chat_hyperlink_dice_system.gd —— 40250 聊天物品超链接与 /dice 掷骰系统 1:1
+# 严格对照:
+# ClientVS22/source/EterLib/TextTag.cpp:32 (Hyperlink |Hitem:vnum:flags:socket:attr|h[%s]|h)
+# Client/Eternexus/root/uichat.py & uitooltip.py (ItemHyperlink, SetHyperlinkItem)
+# metin2/src/server/game/src/cmd_general.cpp:2392 (do_dice)
+extends RefCounted
+
+const LINK_COLOR := "ffffc700" # 官方物品链接专用亮金黄色
+
+# 1. 将物品数据编码为 40250 官方超链接格式
+# 格式: |cffffc700|Hitem:vnum:flags:s0:s1:s2:a0_t:a0_v:a1_t:a1_v:...|h[物品名]|h|r
+static func encode_item_hyperlink(item_data: Dictionary) -> String:
+ var vnum: int = item_data.get("vnum", 0)
+ var name: String = item_data.get("name", "Unknown Item")
+ var flags: int = item_data.get("flags", 0)
+
+ var sockets: Array = item_data.get("sockets", [0, 0, 0])
+ while sockets.size() < 3:
+ sockets.append(0)
+
+ var attrs: Array = item_data.get("attributes", [])
+ var attr_parts: Array = []
+ for a in attrs:
+ attr_parts.append("%d:%d" % [a.get("type", 0), a.get("value", 0)])
+
+ var payload_parts := [
+ str(vnum),
+ str(flags),
+ str(sockets[0]),
+ str(sockets[1]),
+ str(sockets[2])
+ ]
+ if not attr_parts.is_empty():
+ payload_parts.append((":").join(attr_parts))
+
+ var payload := (":").join(payload_parts)
+ return "|c%s|Hitem:%s|h[%s]|h|r" % [LINK_COLOR, payload, name]
+
+# 2. 从超链接中解析提取物品完整数据结构 (1:1 TextTag::FindHyperlink)
+static func decode_item_hyperlink(hyperlink_text: String) -> Dictionary:
+ var res := {
+ "valid": false,
+ "vnum": 0,
+ "name": "",
+ "flags": 0,
+ "sockets": [0, 0, 0],
+ "attributes": []
+ }
+
+ # 正则或字符串匹配提取 |Hitem:(.+?)\|h\[(.*?)\]\|h
+ var h_start := hyperlink_text.find("|Hitem:")
+ if h_start == -1:
+ return res
+
+ var payload_start := h_start + 7 # len("|Hitem:")
+ var payload_end := hyperlink_text.find("|h[", payload_start)
+ if payload_end == -1:
+ return res
+
+ var payload := hyperlink_text.substr(payload_start, payload_end - payload_start)
+
+ var name_start := payload_end + 3 # len("|h[")
+ var name_end := hyperlink_text.find("]|h", name_start)
+ if name_end == -1:
+ return res
+
+ var display_name := hyperlink_text.substr(name_start, name_end - name_start)
+ res["name"] = display_name
+
+ var tokens := payload.split(":")
+ if tokens.size() < 5:
+ return res
+
+ res["vnum"] = tokens[0].to_int()
+ res["flags"] = tokens[1].to_int()
+ res["sockets"] = [tokens[2].to_int(), tokens[3].to_int(), tokens[4].to_int()]
+
+ # 解析属性对 (type:value)
+ var attrs: Array = []
+ var idx := 5
+ while idx + 1 < tokens.size():
+ var at_type := tokens[idx].to_int()
+ var at_val := tokens[idx + 1].to_int()
+ attrs.append({ "type": at_type, "value": at_val })
+ idx += 2
+
+ res["attributes"] = attrs
+ res["valid"] = true
+ return res
+
+# 3. 模拟 Tooltip 生成(悬停时渲染内容)
+static func build_tooltip_text(item_dict: Dictionary) -> String:
+ if not item_dict.get("valid", false):
+ return "无效的物品链接"
+
+ var lines: Array = []
+ lines.append("=== %s (Vnum: %d) ===" % [item_dict["name"], item_dict["vnum"]])
+
+ # 宝石孔位信息
+ var sockets: Array = item_dict.get("sockets", [])
+ for i in range(sockets.size()):
+ var s: int = sockets[i]
+ if s > 0:
+ lines.append("孔位 %d: 镶嵌宝石 [Vnum %d]" % [i + 1, s])
+ elif s == 1: # 官方孔位开孔标记
+ lines.append("孔位 %d: 空孔" % [i + 1])
+
+ # 附加属性信息 (1~7 条)
+ var attrs: Array = item_dict.get("attributes", [])
+ for a in attrs:
+ lines.append("附加属性: 类型 %d +%d" % [a["type"], a["value"]])
+
+ return ("\n").join(lines)
+
+# 4. 40250 官方 /dice 掷骰系统 (1:1 ACMD(do_dice))
+static func execute_dice(player_name: String, args: Array = [], roll_override: int = -1) -> Dictionary:
+ var start_val := 1
+ var end_val := 100
+
+ if args.size() >= 2:
+ start_val = int(args[0])
+ end_val = int(args[1])
+ elif args.size() == 1:
+ start_val = 1
+ end_val = int(args[0])
+
+ # 40250 规则: end = MAX(start, end); start = MIN(start, end);
+ var real_start := mini(start_val, end_val)
+ var real_end := maxi(start_val, end_val)
+
+ var roll_result: int = 0
+ if roll_override >= 0:
+ roll_result = clamp(roll_override, real_start, real_end)
+ else:
+ roll_result = randi() % (real_end - real_start + 1) + real_start
+
+ var broadcast_msg := "%s 投掷了骰子,点数为 %d (%d-%d)" % [
+ player_name, roll_result, real_start, real_end
+ ]
+
+ return {
+ "player": player_name,
+ "roll": roll_result,
+ "start": real_start,
+ "end": real_end,
+ "message": broadcast_msg
+ }
diff --git a/project/chat_hyperlink_dice_system.gd.uid b/project/chat_hyperlink_dice_system.gd.uid
new file mode 100644
index 00000000..8fc9d856
--- /dev/null
+++ b/project/chat_hyperlink_dice_system.gd.uid
@@ -0,0 +1 @@
+uid://bnktthkdod33w
diff --git a/project/chest_system.gd b/project/chest_system.gd
new file mode 100644
index 00000000..3ac1eeab
--- /dev/null
+++ b/project/chest_system.gd
@@ -0,0 +1,145 @@
+# chest_system.gd —— 40250 首领宝箱与钥匙系统 1:1 规则引擎
+# 严格对照:
+# metin2/src/server/game/src/char_item.cpp:2024-2080 (ITEM_GIFTBOX / 50070 / 50011)
+# metin2/src/server/game/src/char_item.cpp:1936-1980 (ITEM_TREASURE_BOX / ITEM_TREASURE_KEY)
+# metin2/src/server/game/src/special_item_group.txt
+extends RefCounted
+
+# 宝箱与钥匙 VNUM 定义
+const VNUM_SILVER_BOX := 50001 # 银宝箱
+const VNUM_SILVER_KEY := 50002 # 银钥匙
+const VNUM_GOLD_BOX := 50003 # 金宝箱
+const VNUM_GOLD_KEY := 50004 # 金钥匙
+
+const VNUM_MOONLIGHT_BOX := 50011 # 月光宝盒
+const VNUM_CHIEF_ORC_CHEST := 50070 # 兽人首领箱
+const VNUM_DARK_LEADER_CHEST := 50071# 黑魔首领箱
+const VNUM_SPIDER_QUEEN_CHEST := 50073# 蜘蛛女王箱
+const VNUM_NINE_TAILS_CHEST := 50078 # 九尾妖狐宝箱
+const VNUM_FLAME_KING_CHEST := 50079 # 火炎领主宝箱
+const VNUM_REAPER_CHEST := 50082 # 死神宝箱
+
+# 特殊掉落类型标识(CSpecialItemGroup)
+const SPECIAL_GOLD := 1000000001
+const SPECIAL_EXP := 1000000002
+const SPECIAL_MOB_TRAP := 1000000003
+const SPECIAL_SLOW_TRAP := 1000000004
+const SPECIAL_POISON_TRAP := 1000000005
+
+# 钥匙与宝箱配对映射表
+const KEY_BOX_PAIRS := {
+ VNUM_SILVER_KEY: VNUM_SILVER_BOX,
+ VNUM_GOLD_KEY: VNUM_GOLD_BOX,
+}
+
+# 官方 40250 经典掉落表数据结构 (vnum, count, prob_weight)
+const DROP_TABLES := {
+ VNUM_MOONLIGHT_BOX: [
+ {"type": "item", "vnum": 50300, "count": 1, "weight": 20, "name": "技能书"},
+ {"type": "item", "vnum": 71001, "count": 1, "weight": 15, "name": "驱魔卷轴"},
+ {"type": "item", "vnum": 71094, "count": 1, "weight": 10, "name": "隐士教训"},
+ {"type": "item", "vnum": 25040, "count": 1, "weight": 15, "name": "祝福卷轴"},
+ {"type": "gold", "vnum": SPECIAL_GOLD, "count": 50000, "weight": 25, "name": "金币 50,000"},
+ {"type": "exp", "vnum": SPECIAL_EXP, "count": 30000, "weight": 15, "name": "经验值 30,000"},
+ ],
+ VNUM_CHIEF_ORC_CHEST: [
+ {"type": "item", "vnum": 50300, "count": 1, "weight": 20, "name": "技能书"},
+ {"type": "item", "vnum": 50513, "count": 1, "weight": 10, "name": "魂石"},
+ {"type": "item", "vnum": 25040, "count": 1, "weight": 15, "name": "祝福卷轴"},
+ {"type": "item", "vnum": 11249, "count": 1, "weight": 10, "name": "幽冥战甲+9"},
+ {"type": "trap_poison", "vnum": SPECIAL_POISON_TRAP, "count": 1, "weight": 10, "name": "毒气陷阱"},
+ {"type": "gold", "vnum": SPECIAL_GOLD, "count": 100000, "weight": 20, "name": "金币 100,000"},
+ {"type": "exp", "vnum": SPECIAL_EXP, "count": 50000, "weight": 15, "name": "经验值 50,000"},
+ ],
+ VNUM_SILVER_BOX: [
+ {"type": "item", "vnum": 25040, "count": 1, "weight": 25, "name": "祝福卷轴"},
+ {"type": "item", "vnum": 50300, "count": 1, "weight": 25, "name": "技能书"},
+ {"type": "gold", "vnum": SPECIAL_GOLD, "count": 30000, "weight": 30, "name": "金币 30,000"},
+ {"type": "trap_slow", "vnum": SPECIAL_SLOW_TRAP, "count": 1, "weight": 20, "name": "减速毒雾"},
+ ],
+ VNUM_GOLD_BOX: [
+ {"type": "item", "vnum": 25040, "count": 2, "weight": 25, "name": "祝福卷轴 x2"},
+ {"type": "item", "vnum": 50513, "count": 1, "weight": 20, "name": "魂石"},
+ {"type": "item", "vnum": 71001, "count": 2, "weight": 20, "name": "驱魔卷轴 x2"},
+ {"type": "gold", "vnum": SPECIAL_GOLD, "count": 80000, "weight": 35, "name": "金币 80,000"},
+ ]
+}
+
+static func is_gift_box(vnum: int) -> bool:
+ return vnum in [
+ VNUM_MOONLIGHT_BOX, VNUM_CHIEF_ORC_CHEST, VNUM_DARK_LEADER_CHEST,
+ VNUM_SPIDER_QUEEN_CHEST, VNUM_NINE_TAILS_CHEST, VNUM_FLAME_KING_CHEST,
+ VNUM_REAPER_CHEST
+ ]
+
+static func is_treasure_box(vnum: int) -> bool:
+ return vnum in [VNUM_SILVER_BOX, VNUM_GOLD_BOX]
+
+static func is_treasure_key(vnum: int) -> bool:
+ return vnum in [VNUM_SILVER_KEY, VNUM_GOLD_KEY]
+
+static func is_matching_key(key_vnum: int, box_vnum: int) -> bool:
+ return KEY_BOX_PAIRS.get(key_vnum, 0) == box_vnum
+
+# 开启普通礼盒(无需钥匙,直接右键)
+static func open_gift_box(
+ box_vnum: int,
+ empty_inventory_slots: int,
+ roll_val := -1
+) -> Dictionary:
+ if not is_gift_box(box_vnum):
+ return {"success": false, "err": "not_gift_box", "msg": "该物品无法直接开启。"}
+
+ if empty_inventory_slots < 1:
+ return {"success": false, "err": "inventory_full", "msg": "背包已满,无法开启宝箱。"}
+
+ return _roll_chest_reward(box_vnum, roll_val)
+
+# 使用钥匙开启上锁宝箱(拖拽钥匙至宝箱)
+static func open_treasure_box_with_key(
+ key_vnum: int,
+ box_vnum: int,
+ empty_inventory_slots: int,
+ roll_val := -1
+) -> Dictionary:
+ if not is_treasure_box(box_vnum):
+ return {"success": false, "err": "not_treasure_box", "msg": "这不是一个需要钥匙的宝箱。"}
+
+ if not is_matching_key(key_vnum, box_vnum):
+ return {"success": false, "err": "key_mismatch", "msg": "这把钥匙无法打开该宝箱。"}
+
+ if empty_inventory_slots < 1:
+ return {"success": false, "err": "inventory_full", "msg": "背包已满,无法开启宝箱。"}
+
+ var res := _roll_chest_reward(box_vnum, roll_val)
+ res["consumed_key_vnum"] = key_vnum
+ res["consumed_box_vnum"] = box_vnum
+ return res
+
+# 抽选掉落物
+static func _roll_chest_reward(box_vnum: int, roll_val: int) -> Dictionary:
+ var drops: Array = DROP_TABLES.get(box_vnum, DROP_TABLES[VNUM_MOONLIGHT_BOX])
+ var total_weight := 0
+ for d in drops:
+ total_weight += int(d.get("weight", 10))
+
+ var dice: int = roll_val if roll_val > 0 else (randi() % total_weight)
+ dice = dice % total_weight # 约束范围
+
+ var accum := 0
+ var chosen: Dictionary = drops[0]
+ for d in drops:
+ accum += int(d.get("weight", 10))
+ if dice < accum:
+ chosen = d
+ break
+
+ return {
+ "success": true,
+ "box_vnum": box_vnum,
+ "reward_type": chosen.get("type", "item"),
+ "reward_vnum": chosen.get("vnum", 0),
+ "reward_count": chosen.get("count", 1),
+ "reward_name": chosen.get("name", "神秘道具"),
+ "is_trap": String(chosen.get("type", "")).begins_with("trap_"),
+ }
diff --git a/project/chest_system.gd.uid b/project/chest_system.gd.uid
new file mode 100644
index 00000000..6dcd0bf0
--- /dev/null
+++ b/project/chest_system.gd.uid
@@ -0,0 +1 @@
+uid://8amkno8ks12i
diff --git a/project/class_specialization_system.gd b/project/class_specialization_system.gd
new file mode 100644
index 00000000..0f9069ba
--- /dev/null
+++ b/project/class_specialization_system.gd
@@ -0,0 +1,233 @@
+# class_specialization_system.gd —— Metin2 40250 角色 30 级双流派转职与无损洗髓系统 1:1
+# 对照 40250 服务端 quest/skill_group.quest, char_skill.cpp, cmd_general.cpp
+class_name ClassSpecializationSystem
+extends RefCounted
+
+signal specialization_chosen(job: int, spec_id: int, spec_name: String)
+signal skill_point_allocated(skill_vnum: int, new_level: int, remaining_points: int)
+signal specialization_reset(refunded_points: int)
+
+const VNUM_RESET_SCROLL := 71100 # 洗髓卷轴 / 重新分配技能书 (Scroll of Redistribution)
+const REQUIRED_LEVEL := 30 # 转职等级门槛
+
+const SPEC_TREE: Dictionary = {
+ 0: { # 战士
+ 1: {
+ "name": "战狂流派 (Arahan)",
+ "desc": "极度偏向狂暴输出与高速连斩,兼具霸体与破坏力",
+ "skills": {
+ 1: "三连斩",
+ 2: "旋风斩",
+ 3: "狂战士",
+ 4: "剑气",
+ 5: "冲锋"
+ }
+ },
+ 2: {
+ "name": "圣战流派 (Partisan)",
+ "desc": "坚若磐石的钢铁之躯,以强身术与精神打击粉碎强敌",
+ "skills": {
+ 16: "强身术",
+ 17: "精神打击",
+ 18: "宝剑斩",
+ 19: "震地波",
+ 20: "剑气斩"
+ }
+ }
+ },
+ 1: { # 刺客
+ 1: {
+ "name": "近战刺客流派 (Dagger)",
+ "desc": "隐身于阴影之中,擅长淬毒与致命近身背刺",
+ "skills": {
+ 31: "伏击",
+ 32: "绝杀",
+ 33: "匕首旋风",
+ 34: "隐身术",
+ 35: "毒刃"
+ }
+ },
+ 2: {
+ "name": "远程弓箭流派 (Archer)",
+ "desc": "千里之外取敌首级,漫天箭雨与火炎穿透射击",
+ "skills": {
+ 46: "连射",
+ 47: "箭雨",
+ 48: "火炎箭",
+ 49: "疾风步",
+ 50: "毒箭"
+ }
+ }
+ },
+ 2: { # 修罗
+ 1: {
+ "name": "魔剑修罗流派 (Weapon)",
+ "desc": "附魔之刃附体,吸血噬魂与破魔剑气并存",
+ "skills": {
+ 61: "附魔之刃",
+ 62: "附魔之甲",
+ 63: "惧意",
+ 64: "龙卷风",
+ 65: "手指戳击"
+ }
+ },
+ 2: {
+ "name": "黑魔法修罗流派 (Black Magic)",
+ "desc": "掌控幽冥暗焰,幽暗之盾抵消伤害,黑暗之球远程轰杀",
+ "skills": {
+ 76: "幽冥一击",
+ 77: "烈焰一击",
+ 78: "黑暗之球",
+ 79: "幽暗之盾",
+ 80: "鬼影步"
+ }
+ }
+ },
+ 3: { # 萨满
+ 1: {
+ "name": "巨龙神圣辅助流派 (Dragon)",
+ "desc": "掌控巨龙图腾之力,为团队提供巨龙之赐与龙之协助光环",
+ "skills": {
+ 91: "飞龙射击",
+ 92: "巨龙射击",
+ 93: "巨龙咆哮",
+ 94: "巨龙之赐",
+ 95: "龙之协助"
+ }
+ },
+ 2: {
+ "name": "雷电神圣治疗流派 (Lightning)",
+ "desc": "引动九天雷电轰杀群敌,施展神圣治愈术与疾风迅捷",
+ "skills": {
+ 106: "雷电箭",
+ 107: "召唤雷电",
+ 108: "雷电之爪",
+ 109: "治愈术",
+ 110: "迅捷步"
+ }
+ }
+ }
+}
+
+# 获取职业可选流派
+func get_available_specializations(job: int) -> Dictionary:
+ return SPEC_TREE.get(job, {})
+
+# 进行 30 级转职选定流派
+func choose_specialization(spec_id: int, player_data: Dictionary) -> Dictionary:
+ var job: int = int(player_data.get("job", 0))
+ var lvl: int = int(player_data.get("level", 1))
+
+ if lvl < REQUIRED_LEVEL:
+ return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "转职需要角色等级达到 30 级!当前等级: %d" % lvl}
+
+ var cur_group: int = int(player_data.get("skill_group", 0))
+ if cur_group != 0:
+ return {"ok": false, "reason": "ALREADY_SPECIALIZED", "msg": "你已选定了流派!若需变更,请先使用【洗髓卷轴】。"}
+
+ if not SPEC_TREE.has(job) or not SPEC_TREE[job].has(spec_id):
+ return {"ok": false, "reason": "INVALID_SPEC_ID", "msg": "无效的流派代码!"}
+
+ var spec = SPEC_TREE[job][spec_id]
+ player_data["skill_group"] = spec_id
+
+ # 初始化该流派专属 5 大技能
+ if not player_data.has("skills"):
+ player_data["skills"] = {}
+
+ for svnum in spec["skills"].keys():
+ player_data["skills"][svnum] = {
+ "name": spec["skills"][svnum],
+ "level": 0,
+ "master_type": 0
+ }
+
+ specialization_chosen.emit(job, spec_id, spec["name"])
+
+ return {
+ "ok": true,
+ "job": job,
+ "spec_id": spec_id,
+ "spec_name": spec["name"],
+ "skills_count": spec["skills"].size(),
+ "msg": "恭喜!成功完成 30 级转职,踏入【%s】流派之道!" % spec["name"]
+ }
+
+# 学习与加点技能
+func allocate_skill_point(skill_vnum: int, player_data: Dictionary) -> Dictionary:
+ var cur_group: int = int(player_data.get("skill_group", 0))
+ if cur_group == 0:
+ return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "尚未进行 30 级转职,无法修习流派绝学!"}
+
+ var pts: int = int(player_data.get("skill_points", 0))
+ if pts <= 0:
+ return {"ok": false, "reason": "NO_SKILL_POINTS", "msg": "技能点数不足!"}
+
+ var skills: Dictionary = player_data.get("skills", {})
+ if not skills.has(skill_vnum):
+ return {"ok": false, "reason": "SKILL_NOT_AVAILABLE", "msg": "此技能不属于当前专精流派!"}
+
+ var sk = skills[skill_vnum]
+ var cur_lvl: int = int(sk.get("level", 0))
+ if cur_lvl >= 20:
+ return {"ok": false, "reason": "MAX_NORMAL_LEVEL", "msg": "该技能基础等级已满 (20),进阶需使用技能秘籍!"}
+
+ sk["level"] = cur_lvl + 1
+ player_data["skill_points"] = pts - 1
+
+ skill_point_allocated.emit(skill_vnum, sk["level"], player_data["skill_points"])
+
+ return {
+ "ok": true,
+ "skill_vnum": skill_vnum,
+ "new_level": sk["level"],
+ "remaining_points": player_data["skill_points"],
+ "msg": "【%s】提升至等级 %d!" % [sk.get("name", "技能"), sk["level"]]
+ }
+
+# 使用洗髓卷轴 (71100) 无损重置流派与返还技能点
+func reset_specialization(inventory: Array, player_data: Dictionary, free_reset: bool = false) -> Dictionary:
+ var cur_group: int = int(player_data.get("skill_group", 0))
+ if cur_group == 0:
+ return {"ok": false, "reason": "NOT_SPECIALIZED", "msg": "当前尚未选定任何专精流派,无需洗髓!"}
+
+ # 卷轴消耗检查
+ var scroll_slot = -1
+ if not free_reset:
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_RESET_SCROLL:
+ scroll_slot = i
+ break
+ if scroll_slot == -1:
+ return {"ok": false, "reason": "NEED_RESET_SCROLL", "msg": "需要【洗髓卷轴】(71100) 才能重置专精流派!"}
+
+ # 统计已投入的所有技能点数
+ var skills: Dictionary = player_data.get("skills", {})
+ var total_invested: int = 0
+ for svnum in skills.keys():
+ var sk = skills[svnum]
+ total_invested += int(sk.get("level", 0))
+
+ # 消耗卷轴
+ if scroll_slot != -1:
+ var sc_item = inventory[scroll_slot]
+ var sc_cnt = int(sc_item.get("count", 1))
+ if sc_cnt > 1:
+ sc_item["count"] = sc_cnt - 1
+ else:
+ inventory[scroll_slot] = null
+
+ # 全额返还技能点
+ var cur_pts: int = int(player_data.get("skill_points", 0))
+ player_data["skill_points"] = cur_pts + total_invested
+ player_data["skill_group"] = 0
+ player_data["skills"] = {}
+
+ specialization_reset.emit(total_invested)
+
+ return {
+ "ok": true,
+ "refunded_points": total_invested,
+ "total_skill_points": player_data["skill_points"],
+ "msg": "洗髓成功!经脉重铸,%d 点技能点数全额返还,可重新选定双分支流派!" % total_invested
+ }
diff --git a/project/class_specialization_system.gd.uid b/project/class_specialization_system.gd.uid
new file mode 100644
index 00000000..9a1612a0
--- /dev/null
+++ b/project/class_specialization_system.gd.uid
@@ -0,0 +1 @@
+uid://cxlbtf4m6ejc4
diff --git a/project/classic_pet_system.gd b/project/classic_pet_system.gd
new file mode 100644
index 00000000..a2ebbd94
--- /dev/null
+++ b/project/classic_pet_system.gd
@@ -0,0 +1,203 @@
+# classic_pet_system.gd —— Metin2 40250 经典封印宠物跟随与守护系统 1:1
+# 对照 40250 服务端 PetSystem.cpp, PetSystem.h, questlua_pet.cpp
+class_name ClassicPetSystem
+extends RefCounted
+
+signal pet_summoned(mob_vnum: int, pet_name: String, buffs: Dictionary)
+signal pet_unsummoned(mob_vnum: int, pet_name: String)
+signal pet_moved(current_pos: Vector2, is_teleport: bool)
+
+# 40250 官方经典宠物召唤 Mob Vnum
+const MOB_BABY_BOAR := 34001 # 幼野猪
+const MOB_BABY_WOLF := 34002 # 幼狼
+const MOB_BABY_TIGER := 34003 # 幼虎
+const MOB_BABY_LION := 34004 # 幼狮
+const MOB_BABY_FIREDRAKE := 34005 # 幼火龙
+const MOB_ICE_PHOENIX := 34006 # 冰凤凰
+const MOB_BABY_AZRAEL := 34009 # 幼小阿兹瑞尔
+
+# 40250 官方经典封印物品 Vnum
+const SEAL_BABY_BOAR := 53001 # 幼野猪封印
+const SEAL_BABY_FIREDRAKE := 53002 # 幼火龙封印
+const SEAL_ICE_PHOENIX := 53003 # 冰凤凰封印
+const SEAL_BABY_LION := 53004 # 幼狮封印
+const SEAL_BABY_AZRAEL := 53005 # 幼小阿兹瑞尔封印
+
+# 40250 AI 跟随与瞬移距离参数
+const MIN_FOLLOW_DIST := 2.0 # 最小跟随停步距离 (米)
+const MAX_TELEPORT_DIST := 15.0 # 最大跟随超距,超过直接瞬移到身边 (米)
+const FOLLOW_SPEED := 6.0 # 跟随移动速度
+
+# 40250 宠物封印配置表
+const PET_SEAL_CONFIGS: Dictionary = {
+ SEAL_BABY_BOAR: {
+ "mob_vnum": MOB_BABY_BOAR,
+ "name": "幼野猪",
+ "buffs": {"monster_damage_pct": 15},
+ "dungeon_only": false
+ },
+ SEAL_BABY_FIREDRAKE: {
+ "mob_vnum": MOB_BABY_FIREDRAKE,
+ "name": "幼火龙",
+ "buffs": {"max_hp": 1500, "attack_pct": 15},
+ "dungeon_only": false
+ },
+ SEAL_ICE_PHOENIX: {
+ "mob_vnum": MOB_ICE_PHOENIX,
+ "name": "冰凤凰",
+ "buffs": {"max_hp": 1500, "defense_pct": 15},
+ "dungeon_only": false
+ },
+ SEAL_BABY_LION: {
+ "mob_vnum": MOB_BABY_LION,
+ "name": "幼狮",
+ "buffs": {"crit_pct": 10},
+ "dungeon_only": false
+ },
+ SEAL_BABY_AZRAEL: {
+ "mob_vnum": MOB_BABY_AZRAEL,
+ "name": "小阿兹瑞尔",
+ "buffs": {"dungeon_attack_pct": 20, "max_hp": 1000},
+ "dungeon_only": true # 40250 源码判定:若不在地牢中,专属地牢增益不生效
+ }
+}
+
+# 当前宠物出战状态
+var is_summoned: bool = false
+var active_mob_vnum: int = 0
+var active_seal_vnum: int = 0
+var active_pet_name: String = ""
+var active_buffs: Dictionary = {}
+var pet_pos: Vector2 = Vector2.ZERO
+
+# 检查物品是否为 40250 宠物封印
+static func is_pet_seal(vnum: int) -> bool:
+ return PET_SEAL_CONFIGS.has(vnum)
+
+# 获取宠物封印配置
+static func get_pet_config_by_seal(seal_vnum: int) -> Dictionary:
+ return PET_SEAL_CONFIGS.get(seal_vnum, {}).duplicate(true)
+
+# 召唤经典封印宠物 (40250 CPetSystem::Summon)
+func summon_pet(seal_item: Dictionary, player_stats: Dictionary, is_in_dungeon: bool = false, owner_pos: Vector2 = Vector2.ZERO) -> Dictionary:
+ var seal_vnum := int(seal_item.get("vnum", 0))
+ if not PET_SEAL_CONFIGS.has(seal_vnum):
+ return {"ok": false, "reason": "NOT_A_PET_SEAL", "msg": "该物品不是有效的宠物封印!"}
+
+ # 若已有宠物出战,先解散旧宠物 (40250 规则:同时只出战一只)
+ if is_summoned:
+ unsummon_pet(player_stats)
+
+ var conf: Dictionary = PET_SEAL_CONFIGS[seal_vnum]
+ active_mob_vnum = conf["mob_vnum"]
+ active_seal_vnum = seal_vnum
+ active_pet_name = conf["name"]
+ pet_pos = owner_pos + Vector2(1.5, 0.0) # 出生在身侧
+
+ # 计算注入的 Buff (40250 GiveBuff 规则)
+ var buffs_to_apply: Dictionary = {}
+ var base_buffs: Dictionary = conf.get("buffs", {})
+ var is_dungeon_only: bool = bool(conf.get("dungeon_only", false))
+
+ for stat_key in base_buffs.keys():
+ var val = base_buffs[stat_key]
+ # 40250 特殊判定:小阿兹瑞尔等特定宠物仅在地牢中激活特定地牢 Buff
+ if stat_key == "dungeon_attack_pct":
+ if is_in_dungeon:
+ buffs_to_apply[stat_key] = val
+ else:
+ buffs_to_apply[stat_key] = val
+
+ # 应用属性加成至玩家
+ _apply_buffs_to_player(buffs_to_apply, player_stats, 1)
+ active_buffs = buffs_to_apply
+ is_summoned = true
+
+ pet_summoned.emit(active_mob_vnum, active_pet_name, active_buffs)
+
+ return {
+ "ok": true,
+ "mob_vnum": active_mob_vnum,
+ "pet_name": active_pet_name,
+ "buffs": active_buffs,
+ "pos": pet_pos,
+ "msg": "成功召唤宠物【%s】!守护属性已激活。" % active_pet_name
+ }
+
+# 召回/解散宠物 (40250 CPetSystem::Unsummon / ClearBuff)
+func unsummon_pet(player_stats: Dictionary) -> Dictionary:
+ if not is_summoned:
+ return {"ok": false, "reason": "NO_PET_SUMMONED", "msg": "当前没有出战的宠物!"}
+
+ # 清除施加在玩家身上的属性
+ _apply_buffs_to_player(active_buffs, player_stats, -1)
+
+ var old_mob := active_mob_vnum
+ var old_name := active_pet_name
+
+ is_summoned = false
+ active_mob_vnum = 0
+ active_seal_vnum = 0
+ active_pet_name = ""
+ active_buffs.clear()
+ pet_pos = Vector2.ZERO
+
+ pet_unsummoned.emit(old_mob, old_name)
+
+ return {
+ "ok": true,
+ "mob_vnum": old_mob,
+ "pet_name": old_name,
+ "msg": "宠物【%s】已召回,守护属性已解除。" % old_name
+ }
+
+# 跟随 AI 逻辑步进 (40250 CPetActor::_UpdateFollowAI)
+func update_follow_ai(owner_pos: Vector2, delta: float) -> Dictionary:
+ if not is_summoned:
+ return {"action": "none"}
+
+ var dist := pet_pos.distance_to(owner_pos)
+
+ # 超过最大瞬移距离,直接瞬移回主人身边
+ if dist > MAX_TELEPORT_DIST:
+ pet_pos = owner_pos + Vector2(1.5, 0.0)
+ pet_moved.emit(pet_pos, true)
+ return {"action": "teleport", "pos": pet_pos, "dist": dist}
+
+ # 超过最小跟随距离,平滑朝主人位移
+ if dist > MIN_FOLLOW_DIST:
+ var move_dist: float = minf(FOLLOW_SPEED * delta, dist - MIN_FOLLOW_DIST)
+ pet_pos = pet_pos.move_toward(owner_pos, move_dist)
+ pet_moved.emit(pet_pos, false)
+ return {"action": "follow", "pos": pet_pos, "dist": pet_pos.distance_to(owner_pos)}
+
+ # 处于舒适距离,保持待机
+ return {"action": "idle", "pos": pet_pos, "dist": dist}
+
+# 属性加成注入或反向扣减
+func _apply_buffs_to_player(buffs: Dictionary, player_stats: Dictionary, factor: int) -> void:
+ for stat_key in buffs.keys():
+ var val = int(buffs[stat_key]) * factor
+ var cur = int(player_stats.get(stat_key, 0))
+ player_stats[stat_key] = cur + val
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_summoned": is_summoned,
+ "active_mob_vnum": active_mob_vnum,
+ "active_seal_vnum": active_seal_vnum,
+ "active_pet_name": active_pet_name,
+ "active_buffs": active_buffs.duplicate(true),
+ "pos_x": pet_pos.x,
+ "pos_y": pet_pos.y
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_summoned = bool(data.get("is_summoned", false))
+ active_mob_vnum = int(data.get("active_mob_vnum", 0))
+ active_seal_vnum = int(data.get("active_seal_vnum", 0))
+ active_pet_name = str(data.get("active_pet_name", ""))
+ active_buffs = data.get("active_buffs", {}).duplicate(true)
+ pet_pos = Vector2(float(data.get("pos_x", 0.0)), float(data.get("pos_y", 0.0)))
diff --git a/project/classic_pet_system.gd.uid b/project/classic_pet_system.gd.uid
new file mode 100644
index 00000000..e6de9192
--- /dev/null
+++ b/project/classic_pet_system.gd.uid
@@ -0,0 +1 @@
+uid://brl1ecflh4ugi
diff --git a/project/client_phase_integration_system.gd b/project/client_phase_integration_system.gd
new file mode 100644
index 00000000..37a86275
--- /dev/null
+++ b/project/client_phase_integration_system.gd
@@ -0,0 +1,192 @@
+# client_phase_integration_system.gd —— 40250 客户端全状态机与全流程一体化集成系统 1:1
+# 严格对照:
+# Client/Eternexus/root/networkmodule.py, interfacemodule.py, game.py
+# ClientVS22/source/UserInterface/PythonNetworkStream.h (PHASE_*)
+extends RefCounted
+
+const IntroLoginPhase = preload("res://intro_login_phase.gd")
+const IntroEmpirePhase = preload("res://intro_empire_phase.gd")
+const IntroSelectPhase = preload("res://intro_select_phase.gd")
+const IntroCreatePhase = preload("res://intro_create_phase.gd")
+const IntroLoadingPhase = preload("res://intro_loading_phase.gd")
+
+const MessengerSystem = preload("res://messenger_system.gd")
+const WhisperChatSystem = preload("res://whisper_chat_system.gd")
+const AtlasNavigationSystem = preload("res://atlas_navigation_system.gd")
+const EquipmentInspectSystem = preload("res://equipment_inspect_system.gd")
+const BgmJukeboxSystem = preload("res://bgm_jukebox_system.gd")
+
+# 官方 Phase 枚举 (PythonNetworkStream.h)
+enum ClientPhase {
+ PHASE_OFFLINE,
+ PHASE_LOGIN,
+ PHASE_EMPIRE,
+ PHASE_SELECT,
+ PHASE_CREATE,
+ PHASE_LOADING,
+ PHASE_GAME
+}
+
+var current_phase: int = ClientPhase.PHASE_LOGIN
+
+# 阶段子模块实例
+var login_phase: RefCounted = null
+var empire_phase: RefCounted = null
+var select_phase: RefCounted = null
+var create_phase: RefCounted = null
+var loading_phase: RefCounted = null
+
+# 游戏内核心交互模块实例
+var messenger: RefCounted = null
+var whisper: RefCounted = null
+var atlas: RefCounted = null
+var equip_inspect: RefCounted = null
+var jukebox: RefCounted = null
+
+# 活跃角色数据
+var active_character: Dictionary = {}
+var active_account: String = ""
+
+# 热键弹窗焦点栈 (用于 Escape 键自顶向下关闭)
+var dialog_focus_stack: Array = []
+
+func _init() -> void:
+ current_phase = ClientPhase.PHASE_LOGIN
+ active_character = {}
+ active_account = ""
+ dialog_focus_stack.clear()
+
+ # 初始化各阶段系统
+ login_phase = IntroLoginPhase.new()
+ empire_phase = IntroEmpirePhase.new()
+ select_phase = IntroSelectPhase.new()
+ create_phase = IntroCreatePhase.new()
+ loading_phase = IntroLoadingPhase.new()
+
+ # 初始化游戏内交互系统
+ messenger = MessengerSystem.new()
+ whisper = WhisperChatSystem.new()
+ atlas = AtlasNavigationSystem.new()
+ equip_inspect = EquipmentInspectSystem.new()
+ jukebox = BgmJukeboxSystem.new()
+
+# 1. 登录跳转流 (Login -> Select / Empire)
+func process_login(account: String, password_input: String) -> Dictionary:
+ var r = login_phase.attempt_login(account, password_input)
+ if not r["success"]:
+ return r
+
+ active_account = account
+ if r.get("has_characters", false):
+ current_phase = ClientPhase.PHASE_SELECT
+ r["next_phase"] = ClientPhase.PHASE_SELECT
+ else:
+ current_phase = ClientPhase.PHASE_EMPIRE
+ r["next_phase"] = ClientPhase.PHASE_EMPIRE
+
+ return r
+
+# 2. 阵营抉择流 (Empire -> Create)
+func process_empire_select(empire_id: int) -> Dictionary:
+ if current_phase != ClientPhase.PHASE_EMPIRE:
+ return { "success": false, "error": "NOT_IN_EMPIRE_PHASE" }
+
+ empire_phase.select_empire(empire_id)
+ var conf = empire_phase.confirm_empire_selection()
+ current_phase = ClientPhase.PHASE_CREATE
+ conf["next_phase"] = ClientPhase.PHASE_CREATE
+ return conf
+
+# 3. 角色塑形流 (Create -> Select)
+func process_character_create(name: String, empire_id: int = 1) -> Dictionary:
+ if current_phase != ClientPhase.PHASE_CREATE:
+ return { "success": false, "error": "NOT_IN_CREATE_PHASE" }
+
+ var r = create_phase.create_character(name, empire_id)
+ if not r["success"]:
+ return r
+
+ # 存入角色选择大厅首个空槽位
+ select_phase.set_character_slot(0, r["character"])
+ current_phase = ClientPhase.PHASE_SELECT
+ r["next_phase"] = ClientPhase.PHASE_SELECT
+ return r
+
+# 4. 选择角色出战流 (Select -> Loading -> Game)
+func process_character_start(slot_idx: int) -> Dictionary:
+ if current_phase != ClientPhase.PHASE_SELECT:
+ return { "success": false, "error": "NOT_IN_SELECT_PHASE" }
+
+ select_phase.select_slot(slot_idx)
+ var r = select_phase.start_game()
+ if not r["success"]:
+ return r
+
+ active_character = r["character"]
+
+ # 启动过场加载
+ loading_phase.start_loading()
+ current_phase = ClientPhase.PHASE_LOADING
+
+ # 完成 4 阶段加载
+ for i in range(5):
+ loading_phase.advance_loading_step()
+
+ if loading_phase.is_completed():
+ current_phase = ClientPhase.PHASE_GAME
+ jukebox.on_enter_map("metin2_map_a1")
+ messenger.main_player_name = active_character.get("name", "Hero")
+ whisper.main_player_name = active_character.get("name", "Hero")
+ r["next_phase"] = ClientPhase.PHASE_GAME
+
+ return r
+
+# 5. 全局热键路由与窗口调度 (1:1 interfacemodule.py)
+func handle_input_hotkey(key_name: String) -> Dictionary:
+ if current_phase != ClientPhase.PHASE_GAME:
+ return { "handled": false, "phase": current_phase }
+
+ match key_name:
+ "Alt+M": # 好友面板
+ var is_showing: bool = (dialog_focus_stack.has("MESSENGER"))
+ if is_showing:
+ dialog_focus_stack.erase("MESSENGER")
+ return { "handled": true, "action": "CLOSE_MESSENGER" }
+ else:
+ dialog_focus_stack.append("MESSENGER")
+ return { "handled": true, "action": "OPEN_MESSENGER" }
+
+ "M": # Atlas 全景大地图
+ var visible = atlas.toggle_atlas()
+ if visible:
+ dialog_focus_stack.append("ATLAS")
+ return { "handled": true, "action": "OPEN_ATLAS" }
+ else:
+ dialog_focus_stack.erase("ATLAS")
+ return { "handled": true, "action": "CLOSE_ATLAS" }
+
+ "Escape": # 自顶向下关闭弹窗或呼出系统菜单
+ if not dialog_focus_stack.is_empty():
+ var top_dialog = dialog_focus_stack.pop_back()
+ if top_dialog == "ATLAS":
+ atlas.hide_atlas()
+ return { "handled": true, "action": "CLOSED_TOP_DIALOG", "dialog": top_dialog }
+ else:
+ return { "handled": true, "action": "OPEN_SYSTEM_MENU" }
+
+ return { "handled": false }
+
+# 6. 登出游戏 (Game -> Select / Login)
+func logout_to_select() -> void:
+ current_phase = ClientPhase.PHASE_SELECT
+ active_character = {}
+ dialog_focus_stack.clear()
+ atlas.hide_atlas()
+ jukebox.on_enter_map("intro_select")
+
+func logout_to_login() -> void:
+ current_phase = ClientPhase.PHASE_LOGIN
+ active_character = {}
+ active_account = ""
+ dialog_focus_stack.clear()
+ jukebox.on_enter_map("intro_login")
diff --git a/project/client_phase_integration_system.gd.uid b/project/client_phase_integration_system.gd.uid
new file mode 100644
index 00000000..ffdd1e04
--- /dev/null
+++ b/project/client_phase_integration_system.gd.uid
@@ -0,0 +1 @@
+uid://n55fydu6qdrj
diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd
index 91c8623d..fd0e9053 100644
--- a/project/combat_fx_test.gd
+++ b/project/combat_fx_test.gd
@@ -519,6 +519,7 @@ func _run() -> void:
ph.global_position = Vector3(0, 0, 1.0)
nw.nodes[2000] = ph
_reset_hit(np, fc, nw, fx)
+ np._motion_start_t = np._local_time - 0.105
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "node without defending spheres -> fallback sphere -> hit")
nw.nodes[2000] = vnode
diff --git a/project/combat_parity_test.gd b/project/combat_parity_test.gd
new file mode 100644
index 00000000..ff3efab6
--- /dev/null
+++ b/project/combat_parity_test.gd
@@ -0,0 +1,271 @@
+extends SceneTree
+
+# combat_parity_test —— 验证 40250 战斗与视觉对齐:
+# 1. 防御碰撞球骨骼世界矩阵绑定(ActorInstanceCollisionDetection.cpp:33-70)
+# 2. 击退撞墙地形阻挡截断(InstanceBase.cpp BlockMovement / is_blocked)
+# 3. 攻击挥刀动作期间移动锁定(ActorInstanceMotion.cpp:472 isLock / PythonPlayerInput.cpp:457)
+# 4. 头顶伤害与 MISS 飘字官方 damagevalue/ DDS 贴图与排版(InstanceBaseEffect.cpp:150-205)
+
+const NetPlay = preload("res://net_play.gd")
+const NetWorld = preload("res://net_world.gd")
+const DamageEffect = preload("res://damage_effect.gd")
+const UiAssets = preload("res://ui/ui_assets.gd")
+const PlayerCtl = preload("res://player_controller.gd")
+const PlayerView = preload("res://ui/player_view.gd")
+const MobView = preload("res://ui/mob_view.gd")
+
+var failures := 0
+
+func check(ok: bool, msg: String) -> void:
+ if ok:
+ print("OK: %s" % msg)
+ else:
+ failures += 1
+ printerr("FAIL: %s" % msg)
+
+func _init() -> void:
+ call_deferred("run_all")
+
+func run_all() -> void:
+ print("--- Running combat_parity_test ---")
+ test_bone_defending_spheres()
+ test_knockback_collision_blocking()
+ test_attack_lock()
+ test_damage_billboard_textures()
+ test_actor_shake()
+ test_resist_fallen()
+ test_skill_cancel_window()
+ print("combat_parity_test finished with %d failures" % failures)
+ quit(1 if failures > 0 else 0)
+
+# 1. 骨骼防御球测试
+func test_bone_defending_spheres() -> void:
+ var np := NetPlay.new()
+ var vnode := Node3D.new()
+ root.add_child(vnode)
+ vnode.position = Vector3(10, 0, 5)
+
+ # Mock model
+ var model := Node3D.new()
+ model.name = "Metin2Model"
+ vnode.add_child(model)
+
+ # Mock anim
+ var anim := Node.new()
+ anim.name = "Metin2AnimPlayer"
+ var script := GDScript.new()
+ script.source_code = """
+extends Node
+func get_effect_bone_pose(bone: String) -> Dictionary:
+ if bone == "Bip01 Spine":
+ return {"transform": Transform3D(Basis.IDENTITY, Vector3(0, 1.0, 0))}
+ return {}
+"""
+ script.reload()
+ anim.set_script(script)
+ vnode.add_child(anim)
+
+ # Mock get_defending_spheres
+ var vnode_script := GDScript.new()
+ vnode_script.source_code = """
+extends Node3D
+var model: Node3D
+var anim: Node
+func get_defending_spheres() -> Array:
+ return [
+ {"radius": 50.0, "pos": Vector3(0, 50.0, 0), "bone": "Bip01 Spine"},
+ {"radius": 40.0, "pos": Vector3(0, 0, 80.0), "bone": ""}
+ ]
+"""
+ vnode_script.reload()
+ vnode.set_script(vnode_script)
+ vnode.set("model", model)
+ vnode.set("anim", anim)
+
+ var v_pos := NetPlay._actor_cm(NetPlay._world_pos(vnode))
+ var spheres: Array = np._defending_spheres(1234, vnode, v_pos)
+ check(spheres.size() == 2, "2 defending spheres produced")
+
+ var sp0: Dictionary = spheres[0]
+ check(float(sp0.radius) == 50.0, "sphere 0 radius is 50")
+ check(sp0.pos.is_equal_approx(Vector3(1000, -500, 150)), "bone defending sphere accurately transformed with bone matrix")
+ var sp1: Dictionary = spheres[1]
+ check(float(sp1.radius) == 40.0, "sphere 1 radius is 40")
+
+ vnode.queue_free()
+ np.queue_free()
+
+# 2. 击退地形阻挡测试
+func test_knockback_collision_blocking() -> void:
+ var nw := NetWorld.new()
+ root.add_child(nw)
+
+ var client_script := GDScript.new()
+ client_script.source_code = """
+extends Node
+signal entity_spawned(d)
+signal entity_despawned(vid)
+signal entity_main_set(vid)
+signal vitals_changed(vid, hp, sp)
+signal entity_dead(vid)
+signal damage(vid, amount, flag)
+signal chat(t, v, s)
+signal entity_moved(vid, pos)
+func is_in_game() -> bool: return true
+func get_entity(vid: int) -> Dictionary:
+ return {"vid": vid, "pos": Vector3(10.0, 0.0, -20.0)}
+"""
+ client_script.reload()
+ var fake_client := Node.new()
+ fake_client.set_script(client_script)
+ root.add_child(fake_client)
+
+ var mount := Node3D.new()
+ root.add_child(mount)
+ nw.setup(fake_client, mount)
+
+ # Spawn dummy entity
+ var ent_node := Node3D.new()
+ mount.add_child(ent_node)
+ nw._by_vid[555] = ent_node
+
+ # Mock world with blocked region
+ var world_script := GDScript.new()
+ world_script.source_code = """
+extends Node
+func is_blocked(x: float, z: float) -> bool:
+ return x >= 11.0
+func to_world(pos: Vector3) -> Vector3:
+ return Vector3(pos.x / 100.0, 0, -pos.y / 100.0)
+"""
+ world_script.reload()
+ var fake_world := Node.new()
+ fake_world.set_script(world_script)
+ nw.world = fake_world
+
+ # Push victim towards +x by force 5.0
+ nw.push_victim(555, Vector2(1, 0), 5.0)
+ check(nw.is_pushing(555), "victim is in pushing state initially")
+
+ # Step physics push until collision
+ var e: Dictionary = fake_client.call("get_entity", 555)
+ for i in range(10):
+ var pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
+ if not nw.is_pushing(555):
+ break
+
+ var cur_pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
+ check(cur_pos.x <= 11.05, "knockback stopped by is_blocked at boundary (x=%f <= 11.05)" % cur_pos.x)
+
+ mount.queue_free()
+ fake_client.queue_free()
+ fake_world.queue_free()
+ nw.queue_free()
+
+# 3. 攻击动作锁定移动测试
+func test_attack_lock() -> void:
+ var np := NetPlay.new()
+ root.add_child(np)
+
+ var pc := PlayerCtl.new()
+ var player_node := Node3D.new()
+ root.add_child(player_node)
+ pc.player = player_node
+ root.add_child(pc)
+ np.pc = pc
+
+ check(not np.is_lock(), "initially not locked")
+ check(not pc.locked, "pc.locked initially false")
+
+ # Start swing
+ np._emit_swing(1, false)
+ np._process(0.016)
+ check(np.is_lock(), "is_lock() is true during attack swing")
+ check(pc.locked, "pc.locked is true during attack swing")
+
+ # Advance past attack duration
+ np._local_time += 2.0
+ np._process(0.016)
+ check(not np.is_lock(), "is_lock() is false after attack ends")
+ check(not pc.locked, "pc.locked is false after attack ends")
+
+ player_node.queue_free()
+ pc.queue_free()
+ np.queue_free()
+
+# 4. 伤害数字 DDS 资源与 Billboard 测试
+func test_damage_billboard_textures() -> void:
+ var assets_path := AssetRoot.path()
+ if not AssetRoot.available():
+ print("SKIP: damage DDS textures test (assets directory not present)")
+ return
+
+ var target_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_0.dds")
+ check(target_0 != null, "target_0.dds loaded successfully via UiAssets")
+ if target_0:
+ check(target_0.get_width() > 0 and target_0.get_height() > 0, "target_0.dds has valid dimensions (%dx%d)" % [target_0.get_width(), target_0.get_height()])
+
+ var target_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_miss.dds")
+ check(target_miss != null, "target_miss.dds loaded successfully via UiAssets")
+
+ var damage_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_0.dds")
+ check(damage_0 != null, "damage_0.dds loaded successfully via UiAssets")
+
+ var damage_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_miss.dds")
+ check(damage_miss != null, "damage_miss.dds loaded successfully via UiAssets")
+
+# 5. 受击瞬时微震颤测试(ActorInstanceBattle.cpp __Shake)
+func test_actor_shake() -> void:
+ var pv := PlayerView.new()
+ var mv := MobView.new()
+ root.add_child(pv)
+ root.add_child(mv)
+
+ check(pv.has_method("shake"), "PlayerView has shake() method")
+ check(mv.has_method("shake"), "MobView has shake() method")
+
+ pv.shake()
+ check(pv._hit.shake_left > 0.0, "PlayerView shake timer active after shake()")
+
+ mv.shake()
+ check(mv._hit.shake_left > 0.0, "MobView shake timer active after shake()")
+
+ pv.queue_free()
+ mv.queue_free()
+
+# 6. 霸体抗击退测试(ActorInstanceBattle.cpp IsResistFallen)
+func test_resist_fallen() -> void:
+ var np := NetPlay.new()
+
+ # 普通怪 / 普通角色无霸体
+ var normal_ent := {"race": 101, "affect_flags": 0}
+ check(not np._is_resist_fallen(normal_ent), "normal entity is not resist fallen")
+
+ # 铁布衫 / 天罡气 (AFFECT_CHEONGEUN 1 << 16)
+ var iron_body_ent := {"race": 101, "affect_flags": 1 << 16}
+ check(np._is_resist_fallen(iron_body_ent), "AFFECT_CHEONGEUN entity is resist fallen")
+ check(not np._can_push(iron_body_ent, 999, false), "resist fallen entity cannot be pushed")
+
+ # 巨型怪物 (IS_HUGE_RACE 2493)
+ var huge_ent := {"race": 2493, "affect_flags": 0}
+ check(np._is_resist_fallen(huge_ent), "huge race entity is resist fallen")
+ check(not np._can_push(huge_ent, 888, true), "huge entity cannot be pushed")
+
+ np.queue_free()
+
+# 7. 技能后摇打断测试(ActorInstanceMotion.cpp IsCancelEnableSkill)
+func test_skill_cancel_window() -> void:
+ var np := NetPlay.new()
+
+ # 常规技能在施法期间锁定移动
+ np.start_skill_cast(2.0, false, false)
+ check(np.is_lock() == true, "normal skill is locked during cast")
+
+ # 带 cancel_enable 的技能在打断窗口内允许移动
+ np.start_skill_cast(2.0, false, true)
+ check(np.is_lock() == false, "cancel_enable skill does not lock movement")
+
+ np._on_motion_bound("wait")
+ check(np.is_lock() == false, "is_lock is false after wait")
+
+ np.queue_free()
diff --git a/project/combat_parity_test.gd.uid b/project/combat_parity_test.gd.uid
new file mode 100644
index 00000000..2d65b71a
--- /dev/null
+++ b/project/combat_parity_test.gd.uid
@@ -0,0 +1 @@
+uid://b7cxwyvtq02xd
diff --git a/project/combat_replay_snapshot_system.gd b/project/combat_replay_snapshot_system.gd
new file mode 100644
index 00000000..56cd5b45
--- /dev/null
+++ b/project/combat_replay_snapshot_system.gd
@@ -0,0 +1,170 @@
+# combat_replay_snapshot_system.gd —— Metin2 40250 战斗高光战报与首领击杀瞬间快照 1:1
+# 对照 40250 服务端 battle.cpp, packet.h, char_battle.cpp
+class_name CombatReplaySnapshotSystem
+extends RefCounted
+
+signal combat_session_started(target_name: String)
+signal combat_highlight_generated(card: Dictionary)
+signal new_personal_record(target_name: String, record_type: String, new_val: int)
+
+const MAX_STORED_CARDS := 20
+
+var is_in_session: bool = false
+var session_id: int = 1
+var target_vnum: int = 0
+var target_name: String = ""
+var target_max_hp: int = 0
+
+var session_start_time: float = 0.0
+var elapsed_time: float = 0.0
+
+var total_damage_dealt: int = 0
+var total_damage_taken: int = 0
+var total_attacks: int = 0
+var total_skills_used: int = 0
+var max_single_hit: int = 0
+var critical_hits_count: int = 0
+
+# 历史高光战报归档
+var highlight_cards: Array = []
+# 个人最高纪录:target_name -> { "best_dps": int, "fastest_time": float, "highest_hit": int }
+var personal_bests: Dictionary = {}
+
+# 开始一场战斗记录
+func start_combat_session(vnum: int, tname: String, max_hp: int) -> Dictionary:
+ is_in_session = true
+ target_vnum = vnum
+ target_name = tname
+ target_max_hp = max_hp
+
+ session_start_time = Time.get_unix_time_from_system()
+ elapsed_time = 0.0
+
+ total_damage_dealt = 0
+ total_damage_taken = 0
+ total_attacks = 0
+ total_skills_used = 0
+ max_single_hit = 0
+ critical_hits_count = 0
+
+ combat_session_started.emit(target_name)
+
+ return {
+ "ok": true,
+ "session_id": session_id,
+ "target_name": target_name,
+ "target_hp": target_max_hp
+ }
+
+# 记录一次攻击命中
+func record_hit(dmg: int, is_crit: bool, skill_name: String = "") -> void:
+ if not is_in_session:
+ return
+
+ total_damage_dealt += dmg
+ total_attacks += 1
+
+ if is_crit:
+ critical_hits_count += 1
+
+ if not skill_name.is_empty():
+ total_skills_used += 1
+
+ if dmg > max_single_hit:
+ max_single_hit = dmg
+
+# 记录受到的伤害
+func record_damage_taken(dmg: int) -> void:
+ if not is_in_session:
+ return
+ total_damage_taken += dmg
+
+# 帧心跳更新战斗时长
+func update(delta: float) -> void:
+ if is_in_session:
+ elapsed_time += delta
+
+# 结束战斗并生成结构化高光快照战报
+func end_combat_session(is_kill: bool, drops: Array = []) -> Dictionary:
+ if not is_in_session:
+ return {"ok": false, "reason": "NO_ACTIVE_SESSION"}
+
+ is_in_session = false
+ var duration = max(0.5, elapsed_time)
+ var dps = int(float(total_damage_dealt) / duration)
+
+ # 战斗评级评估 (S / A / B / C)
+ var rank = "C"
+ if is_kill:
+ if dps >= 4000:
+ rank = "S"
+ elif dps >= 2500:
+ rank = "A"
+ elif dps >= 1200:
+ rank = "B"
+ else:
+ rank = "C"
+
+ var card: Dictionary = {
+ "session_id": session_id,
+ "target_vnum": target_vnum,
+ "target_name": target_name,
+ "is_kill": is_kill,
+ "rank": rank,
+ "duration": duration,
+ "total_damage": total_damage_dealt,
+ "dps": dps,
+ "max_single_hit": max_single_hit,
+ "crit_count": critical_hits_count,
+ "damage_taken": total_damage_taken,
+ "skills_used": total_skills_used,
+ "drops": drops.duplicate(true),
+ "datetime": Time.get_datetime_string_from_system()
+ }
+
+ session_id += 1
+ highlight_cards.push_front(card)
+ if highlight_cards.size() > MAX_STORED_CARDS:
+ highlight_cards.pop_back()
+
+ # 刷新个人纪录判定
+ _update_personal_bests(card)
+
+ combat_highlight_generated.emit(card)
+ return card
+
+# 检查并更新个人生涯纪录
+func _update_personal_bests(card: Dictionary) -> void:
+ if not card["is_kill"]:
+ return
+
+ var tname: String = card["target_name"]
+ if not personal_bests.has(tname):
+ personal_bests[tname] = {
+ "best_dps": card["dps"],
+ "fastest_time": card["duration"],
+ "highest_hit": card["max_single_hit"]
+ }
+ new_personal_record.emit(tname, "FIRST_KILL", card["dps"])
+ return
+
+ var pb = personal_bests[tname]
+ if card["dps"] > pb["best_dps"]:
+ pb["best_dps"] = card["dps"]
+ new_personal_record.emit(tname, "BEST_DPS", card["dps"])
+
+ if card["duration"] < pb["fastest_time"]:
+ pb["fastest_time"] = card["duration"]
+ new_personal_record.emit(tname, "FASTEST_TIME", int(card["duration"]))
+
+ if card["max_single_hit"] > pb["highest_hit"]:
+ pb["highest_hit"] = card["max_single_hit"]
+ new_personal_record.emit(tname, "HIGHEST_HIT", card["max_single_hit"])
+
+# 查询最近战报
+func get_recent_highlights() -> Array:
+ return highlight_cards
+
+# 查询特定目标的最佳战报纪录
+func get_personal_best(tname: String) -> Dictionary:
+ return personal_bests.get(tname, {})
diff --git a/project/combat_replay_snapshot_system.gd.uid b/project/combat_replay_snapshot_system.gd.uid
new file mode 100644
index 00000000..b6756692
--- /dev/null
+++ b/project/combat_replay_snapshot_system.gd.uid
@@ -0,0 +1 @@
+uid://cxu88rt8qo1o5
diff --git a/project/combat_status_effect_system.gd b/project/combat_status_effect_system.gd
new file mode 100644
index 00000000..35ab98dc
--- /dev/null
+++ b/project/combat_status_effect_system.gd
@@ -0,0 +1,249 @@
+# combat_status_effect_system.gd —— Metin2 40250 官方战斗状态异常与五大负面减益结算系统 1:1
+# 100% 对照 40250 服务端 battle.cpp, char_resist.cpp, affect.h, char.h (POINT_POISON_PCT, IMMUNE_STUN, IMMUNE_SLOW)
+class_name CombatStatusEffectSystem
+extends RefCounted
+
+signal status_applied(target_id: int, effect_type: String, duration: float)
+signal status_resisted(target_id: int, effect_type: String, reason: String)
+signal status_ticked(target_id: int, effect_type: String, damage: int, remaining_ticks: int)
+signal status_cleared(target_id: int, effect_type: String)
+
+# 40250 官方核心状态异常类型
+const EFFECT_POISON := "poison" # 中毒 (AFF_POISON)
+const EFFECT_STUN := "stun" # 眩晕 (AFF_STUN)
+const EFFECT_SLOW := "slow" # 减速 (AFF_SLOW)
+const EFFECT_FIRE := "fire" # 烈焰灼烧 (AFF_FIRE)
+const EFFECT_BLEEDING := "bleeding" # 流血虚弱 (AFF_BLEEDING)
+
+# 等级压制中毒调整表 (40250 char_resist.cpp: poison_level_adjust)
+const POISON_LEVEL_ADJUST: Array[int] = [100, 90, 80, 70, 60, 50, 40, 30, 20]
+
+# 状态记录表: target_id -> { effect_type -> { "duration": float, "timer": float, "tick_interval": float, "ticks_left": int, "param": int } }
+var _target_effects: Dictionary = {}
+
+# ==========================================
+# 1. 状态异常施加与判定 (battle.cpp: NormalAttackAffect)
+# ==========================================
+
+# 尝试施加攻击异常效果 (通用结算)
+func try_apply_normal_attack_effects(
+ attacker: Dictionary,
+ victim: Dictionary,
+ forced_rolls: Dictionary = {}
+) -> Dictionary:
+ var victim_id = int(victim.get("id", 0))
+ var applied: Array[String] = []
+ var resisted: Array[String] = []
+
+ var is_attacker_pc = bool(attacker.get("is_pc", true))
+ var is_victim_pc = bool(victim.get("is_pc", true))
+ var attacker_level = int(attacker.get("level", 1))
+ var victim_level = int(victim.get("level", 1))
+
+ # 1. 中毒判定 (POINT_POISON_PCT)
+ var poison_pct = int(attacker.get("poison_pct", 0))
+ if poison_pct > 0 and not has_effect(victim_id, EFFECT_POISON):
+ # 怪物只中毒一次检查 (char_resist.cpp: m_bHasPoisoned && !IsPC())
+ var already_poisoned_once = bool(victim.get("has_been_poisoned", false))
+ if not is_victim_pc and already_poisoned_once:
+ resisted.append("poison_already_poisoned_once")
+ else:
+ var roll = int(forced_rolls.get("poison_pct_roll", randi_range(1, 100)))
+ if roll <= poison_pct:
+ # 等级差距惩罚检查
+ var can_poison := true
+ if attacker_level < victim_level:
+ var delta = min(victim_level - attacker_level, 8)
+ var level_roll = int(forced_rolls.get("poison_level_roll", randi_range(1, 100)))
+ if level_roll > POISON_LEVEL_ADJUST[delta]:
+ can_poison = false
+ resisted.append("poison_level_penalty")
+
+ if can_poison:
+ apply_poison(victim, attacker)
+ applied.append(EFFECT_POISON)
+
+ # 2. 眩晕判定 (POINT_STUN_PCT)
+ var stun_pct = int(attacker.get("stun_pct", 0))
+ if stun_pct > 0 and not has_effect(victim_id, EFFECT_STUN):
+ # 免疫眩晕判定 (IMMUNE_STUN)
+ if bool(victim.get("immune_stun", false)):
+ status_resisted.emit(victim_id, EFFECT_STUN, "IMMUNE_STUN")
+ resisted.append("immune_stun")
+ else:
+ var roll = int(forced_rolls.get("stun_pct_roll", randi_range(1, 100)))
+ if roll <= stun_pct:
+ var stun_dur = 4.0 if (is_attacker_pc and not is_victim_pc) else 2.0
+ apply_stun(victim_id, stun_dur)
+ applied.append(EFFECT_STUN)
+
+ # 3. 减速判定 (POINT_SLOW_PCT)
+ var slow_pct = int(attacker.get("slow_pct", 0))
+ if slow_pct > 0 and not has_effect(victim_id, EFFECT_SLOW):
+ # 免疫减速判定 (IMMUNE_SLOW)
+ if bool(victim.get("immune_slow", false)):
+ status_resisted.emit(victim_id, EFFECT_SLOW, "IMMUNE_SLOW")
+ resisted.append("immune_slow")
+ else:
+ var roll = int(forced_rolls.get("slow_pct_roll", randi_range(1, 100)))
+ if roll <= slow_pct:
+ apply_slow(victim_id, 20.0, 30) # 40250 官方减速 20 秒,移速 -30
+ applied.append(EFFECT_SLOW)
+
+ return {
+ "applied": applied,
+ "resisted": resisted
+ }
+
+# ==========================================
+# 2. 状态具体应用 (char_resist.cpp)
+# ==========================================
+
+# 施加中毒: 10 次跳伤害,每次间隔 3 秒,总持续 30 秒 (POISON_LENGTH + 1)
+func apply_poison(victim: Dictionary, attacker: Dictionary = {}) -> void:
+ var victim_id = int(victim.get("id", 0))
+ victim["has_been_poisoned"] = true
+
+ _add_raw_effect(victim_id, EFFECT_POISON, {
+ "duration": 30.0,
+ "timer": 3.0,
+ "tick_interval": 3.0,
+ "ticks_left": 10,
+ "param": int(victim.get("poison_reduce", 0)), # 抗毒减免比例 (0~100)
+ "max_hp": int(victim.get("max_hp", 1000)),
+ "attacker_id": int(attacker.get("id", 0))
+ })
+ status_applied.emit(victim_id, EFFECT_POISON, 30.0)
+
+# 施加眩晕: 期间无法移动和释放技能
+func apply_stun(victim_id: int, duration: float = 2.0) -> void:
+ _add_raw_effect(victim_id, EFFECT_STUN, {
+ "duration": duration,
+ "timer": duration,
+ "tick_interval": 0.0,
+ "ticks_left": 0,
+ "param": 0
+ })
+ status_applied.emit(victim_id, EFFECT_STUN, duration)
+
+# 施加减速: 移动速度扣除 speed_reduction 点
+func apply_slow(victim_id: int, duration: float = 20.0, speed_reduction: int = 30) -> void:
+ _add_raw_effect(victim_id, EFFECT_SLOW, {
+ "duration": duration,
+ "timer": duration,
+ "tick_interval": 0.0,
+ "ticks_left": 0,
+ "param": speed_reduction
+ })
+ status_applied.emit(victim_id, EFFECT_SLOW, duration)
+
+# 施加烈焰灼烧: 固定火伤每 3 秒跳一次
+func apply_fire(victim_id: int, ticks: int = 5, damage_per_tick: int = 50) -> void:
+ _add_raw_effect(victim_id, EFFECT_FIRE, {
+ "duration": float(ticks * 3),
+ "timer": 3.0,
+ "tick_interval": 3.0,
+ "ticks_left": ticks,
+ "param": damage_per_tick
+ })
+ status_applied.emit(victim_id, EFFECT_FIRE, float(ticks * 3))
+
+# 解除特定状态 (例如解毒药水 RemovePoison)
+func clear_effect(victim_id: int, effect_type: String) -> bool:
+ if not _target_effects.has(victim_id):
+ return false
+ if _target_effects[victim_id].has(effect_type):
+ _target_effects[victim_id].erase(effect_type)
+ status_cleared.emit(victim_id, effect_type)
+ return true
+ return false
+
+# 检查目标是否具有特定异常状态
+func has_effect(victim_id: int, effect_type: String) -> bool:
+ if not _target_effects.has(victim_id):
+ return false
+ return _target_effects[victim_id].has(effect_type)
+
+# 检查目标是否处于无法行动状态 (眩晕)
+func is_stunned(victim_id: int) -> bool:
+ return has_effect(victim_id, EFFECT_STUN)
+
+# 获取减速移速惩罚值
+func get_slow_reduction(victim_id: int) -> int:
+ if not has_effect(victim_id, EFFECT_SLOW):
+ return 0
+ return int(_target_effects[victim_id][EFFECT_SLOW].get("param", 30))
+
+# 检查自然回血是否被阻断 (40250 char.cpp:2453 中毒状态禁止自然回血)
+func is_natural_hp_regen_blocked(victim_id: int) -> bool:
+ return has_effect(victim_id, EFFECT_POISON)
+
+# ==========================================
+# 3. 状态心跳更新与跳伤害结算 (char_resist.cpp: poison_event, fire_event)
+# ==========================================
+func update(delta: float, targets_map: Dictionary = {}) -> void:
+ for vid in _target_effects.keys().duplicate():
+ var effects: Dictionary = _target_effects[vid]
+ var to_remove: Array[String] = []
+
+ for eff_type in effects.keys():
+ var data = effects[eff_type]
+ data["duration"] -= delta
+
+ if data["tick_interval"] > 0.0:
+ # 周期跳伤害逻辑 (中毒/灼烧)
+ data["timer"] -= delta
+ while data["timer"] <= 0.0 and data["ticks_left"] > 0:
+ data["timer"] += data["tick_interval"]
+ data["ticks_left"] -= 1
+
+ var dmg := 0
+ if eff_type == EFFECT_POISON:
+ # 40250 官方: GetMaxHP() * 5 / 100 * (100 - poison_reduce) / 100
+ var max_hp = int(data.get("max_hp", 1000))
+ var reduce = int(data.get("param", 0))
+ var base_dmg = int(floor(max_hp * 0.05))
+ dmg = max(1, int(floor(base_dmg * (100 - reduce) / 100.0)))
+ elif eff_type == EFFECT_FIRE:
+ dmg = int(data.get("param", 50))
+
+ # 扣减目标生命 (不可致死,留 1 点 HP)
+ if targets_map.has(vid):
+ var tgt = targets_map[vid]
+ var cur_hp = int(tgt.get("hp", 100))
+ tgt["hp"] = max(1, cur_hp - dmg)
+
+ status_ticked.emit(vid, eff_type, dmg, data["ticks_left"])
+
+ if data["ticks_left"] <= 0:
+ to_remove.append(eff_type)
+ break
+ else:
+ # 持续时间型 (眩晕/减速)
+ if data["duration"] <= 0.0:
+ to_remove.append(eff_type)
+
+ for eff_type in to_remove:
+ effects.erase(eff_type)
+ status_cleared.emit(vid, eff_type)
+
+ if effects.is_empty():
+ _target_effects.erase(vid)
+
+func _add_raw_effect(victim_id: int, effect_type: String, data: Dictionary) -> void:
+ if not _target_effects.has(victim_id):
+ _target_effects[victim_id] = {}
+ _target_effects[victim_id][effect_type] = data
+
+# 获取目标全部当前状态
+func get_target_effects(victim_id: int) -> Dictionary:
+ return _target_effects.get(victim_id, {}).duplicate(true)
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "target_effects": _target_effects.duplicate(true)
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _target_effects = data.get("target_effects", {}).duplicate(true)
diff --git a/project/combat_status_effect_system.gd.uid b/project/combat_status_effect_system.gd.uid
new file mode 100644
index 00000000..63cb2dd1
--- /dev/null
+++ b/project/combat_status_effect_system.gd.uid
@@ -0,0 +1 @@
+uid://bo2ad4sxgu07r
diff --git a/project/compass_treasure_hunt_system.gd b/project/compass_treasure_hunt_system.gd
new file mode 100644
index 00000000..87973a49
--- /dev/null
+++ b/project/compass_treasure_hunt_system.gd
@@ -0,0 +1,192 @@
+# compass_treasure_hunt_system.gd —— Metin2 40250 官方魔石地脉探测罗盘与寻宝系统 1:1
+# 对照 40250 服务端 questlua_item.cpp, locale_en/itemdesc.txt:27989
+class_name CompassTreasureHuntSystem
+extends RefCounted
+
+signal compass_scanned(target_name: String, distance: float, angle_deg: float, proximity: String, charges_left: int)
+signal treasure_excavated(chest_vnum: int, chest_name: String)
+signal treasure_opened(chest_name: String, rewards: Array)
+
+# 40250 官方核心道具 Vnum
+const VNUM_COMPASS_OF_METIN := 27989 # 魔石探测罗盘 (Compass for Metin Stones, 6次充能)
+const VNUM_GOLD_CHEST := 50005 # 黄金宝箱
+const VNUM_SILVER_CHEST := 50006 # 白银宝箱
+const VNUM_GOLD_KEY := 50007 # 黄金钥匙
+const VNUM_SILVER_KEY := 50008 # 白银钥匙
+
+const MAX_CHARGES := 6
+
+# 探测距离等级
+const PROXIMITY_FAR := "FAR" # > 100m ("微弱微光 - 遥远")
+const PROXIMITY_MEDIUM := "MEDIUM" # 50m ~ 100m ("泛起银光 - 渐近")
+const PROXIMITY_NEAR := "NEAR" # 20m ~ 50m ("强烈明光 - 附近")
+const PROXIMITY_VERY_CLOSE := "VERY_CLOSE" # < 20m ("璀璨耀光 - 极近")
+
+# 激活魔石探测罗盘扫描最近的魔石或地脉宝藏
+func scan_nearest_metin(player_pos: Vector2, target_list: Array, inventory: Array, slot_index: int) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var compass: Dictionary = inventory[slot_index]
+ if int(compass.get("vnum", 0)) != VNUM_COMPASS_OF_METIN:
+ return {"ok": false, "reason": "NOT_A_COMPASS", "msg": "该物品不是魔石探测罗盘!"}
+
+ var charges: int = int(compass.get("charges", MAX_CHARGES))
+ if charges <= 0:
+ inventory[slot_index] = null
+ return {"ok": false, "reason": "COMPASS_EXHAUSTED", "msg": "罗盘灵力已耗尽,化作飞灰!"}
+
+ if target_list.is_empty():
+ return {"ok": false, "reason": "NO_TARGETS_FOUND", "msg": "当前地图未探测到任何魔石波动!"}
+
+ # 寻找最近的目标
+ var nearest_target: Dictionary = {}
+ var min_dist := INF
+ for t in target_list:
+ var pos: Vector2 = t.get("pos", Vector2.ZERO)
+ var d := player_pos.distance_to(pos)
+ if d < min_dist:
+ min_dist = d
+ nearest_target = t
+
+ # 扣除 1 次充能计数 (40250 官方 6 次限制)
+ charges -= 1
+ compass["charges"] = charges
+ if charges <= 0:
+ inventory[slot_index] = null # 充能用完销毁
+
+ # 计算方位角与距离等级
+ var target_pos: Vector2 = nearest_target.get("pos", Vector2.ZERO)
+ var dir_vec: Vector2 = (target_pos - player_pos).normalized()
+ var angle := rad_to_deg(dir_vec.angle())
+
+ var proximity := PROXIMITY_FAR
+ var desc := "微弱微光(遥远)"
+ if min_dist < 20.0:
+ proximity = PROXIMITY_VERY_CLOSE
+ desc = "璀璨耀光(极近)"
+ elif min_dist < 50.0:
+ proximity = PROXIMITY_NEAR
+ desc = "强烈明光(附近)"
+ elif min_dist < 100.0:
+ proximity = PROXIMITY_MEDIUM
+ desc = "泛起银光(渐近)"
+
+ var target_name: String = str(nearest_target.get("name", "魔石"))
+ compass_scanned.emit(target_name, min_dist, angle, proximity, charges)
+
+ return {
+ "ok": true,
+ "target_name": target_name,
+ "target_pos": nearest_target.get("pos", Vector2.ZERO),
+ "distance": min_dist,
+ "angle_deg": angle,
+ "proximity": proximity,
+ "proximity_desc": desc,
+ "charges_left": charges,
+ "msg": "罗盘指针震颤!锁定【%s】,距离 %.1f 米,光芒等级:%s,剩余使用次数:%d。" % [target_name, min_dist, desc, charges]
+ }
+
+# 在地脉极近坐标处挖掘秘宝
+func excavate_treasure(player_pos: Vector2, treasure_nodes: Array, inventory: Array) -> Dictionary:
+ var target_idx := -1
+ for i in range(treasure_nodes.size()):
+ var node_pos: Vector2 = treasure_nodes[i].get("pos", Vector2.ZERO)
+ if player_pos.distance_to(node_pos) <= 5.0: # 5米范围内可挖掘
+ target_idx = i
+ break
+
+ if target_idx == -1:
+ return {"ok": false, "reason": "NO_TREASURE_NEARBY", "msg": "周围 5 米内未探测到地脉埋藏的宝箱!"}
+
+ var node: Dictionary = treasure_nodes[target_idx]
+ var is_gold: bool = bool(node.get("is_gold", false))
+ var chest_vnum = VNUM_GOLD_CHEST if is_gold else VNUM_SILVER_CHEST
+ var chest_name = "黄金宝箱" if is_gold else "白银宝箱"
+
+ # 寻找背包空格放入宝箱
+ var free_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ free_slot = i
+ break
+
+ if free_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳挖出的宝箱!"}
+
+ inventory[free_slot] = {
+ "vnum": chest_vnum,
+ "name": chest_name,
+ "count": 1
+ }
+
+ treasure_nodes.remove_at(target_idx)
+ treasure_excavated.emit(chest_vnum, chest_name)
+
+ return {
+ "ok": true,
+ "chest_vnum": chest_vnum,
+ "chest_name": chest_name,
+ "slot": free_slot,
+ "msg": "破土而出!在地脉深处成功发掘出【%s】!" % chest_name
+ }
+
+# 使用对应的黄金钥匙或白银钥匙开启宝箱
+func open_treasure_chest(chest_slot: int, key_slot: int, inventory: Array) -> Dictionary:
+ if chest_slot < 0 or chest_slot >= inventory.size() or inventory[chest_slot] == null:
+ return {"ok": false, "reason": "INVALID_CHEST_SLOT"}
+ if key_slot < 0 or key_slot >= inventory.size() or inventory[key_slot] == null:
+ return {"ok": false, "reason": "INVALID_KEY_SLOT"}
+
+ var chest: Dictionary = inventory[chest_slot]
+ var key: Dictionary = inventory[key_slot]
+
+ var chest_vnum := int(chest.get("vnum", 0))
+ var key_vnum := int(key.get("vnum", 0))
+
+ var match_ok := false
+ if chest_vnum == VNUM_GOLD_CHEST and key_vnum == VNUM_GOLD_KEY:
+ match_ok = true
+ elif chest_vnum == VNUM_SILVER_CHEST and key_vnum == VNUM_SILVER_KEY:
+ match_ok = true
+
+ if not match_ok:
+ return {"ok": false, "reason": "KEY_MISMATCH", "msg": "钥匙与宝箱类型不匹配!黄金宝箱需用黄金钥匙开启。"}
+
+ # 消耗钥匙与宝箱
+ inventory[chest_slot] = null
+ var key_cnt := int(key.get("count", 1))
+ if key_cnt > 1:
+ key["count"] = key_cnt - 1
+ else:
+ inventory[key_slot] = null
+
+ # 生成高额宝物奖励
+ var rewards: Array = []
+ if chest_vnum == VNUM_GOLD_CHEST:
+ rewards = [
+ {"vnum": 25040, "name": "祝福卷轴", "count": 3},
+ {"vnum": 51501, "name": "龙石原石", "count": 2},
+ {"vnum": 28430, "name": "+4 灵石", "count": 1}
+ ]
+ else:
+ rewards = [
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1},
+ {"vnum": 50300, "name": "技能书", "count": 1}
+ ]
+
+ # 放入背包
+ for r in rewards:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = r.duplicate(true)
+ break
+
+ treasure_opened.emit(chest.get("name", "宝箱"), rewards)
+
+ return {
+ "ok": true,
+ "chest_name": chest.get("name", "宝箱"),
+ "rewards": rewards,
+ "msg": "清脆的开锁声响起!开启宝箱,获得珍稀秘宝!"
+ }
diff --git a/project/compass_treasure_hunt_system.gd.uid b/project/compass_treasure_hunt_system.gd.uid
new file mode 100644
index 00000000..f6b83af2
--- /dev/null
+++ b/project/compass_treasure_hunt_system.gd.uid
@@ -0,0 +1 @@
+uid://bau7tkdeg2823
diff --git a/project/complex_item_drop_test.gd b/project/complex_item_drop_test.gd
index e8e0f5e5..37975b9c 100644
--- a/project/complex_item_drop_test.gd
+++ b/project/complex_item_drop_test.gd
@@ -12,6 +12,7 @@ const ExchangeUI = preload("res://ui/exchange_ui.gd")
class FakeClient extends Node:
signal safebox_changed()
+ signal inventory_changed()
signal mall_opened(size: int)
signal mall_changed()
signal shop_opened(vid: int)
@@ -39,6 +40,7 @@ class FakeClient extends Node:
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 get_inventory() -> Array: return []
func is_mall_open() -> bool: return mall_open
func get_mall_size() -> int: return mall_size
@@ -91,11 +93,11 @@ func _init() -> void:
"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_item := safe._cells[2] as Control
var safe_event := InputEventMouseButton.new()
safe_event.button_index = MOUSE_BUTTON_LEFT
safe_event.pressed = true
- safe._on_grid_input(2, safe_event)
+ safe._on_cell_input(2, safe_event)
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 3,
"safebox item enters global item state")
mouse.cancel()
diff --git a/project/consumable_system.gd b/project/consumable_system.gd
new file mode 100644
index 00000000..c5f3d130
--- /dev/null
+++ b/project/consumable_system.gd
@@ -0,0 +1,218 @@
+# consumable_system.gd —— 高级消耗品与瞬移定位系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 char_item.cpp:3950-4050 (AFFECT_AUTO_HP/SP_RECOVERY, 日月神水开关)
+# - 40250 char_item.cpp:7326-7390 (AutoRecoveryItemProcess, 自动补充与容量耗尽)
+# - 40250 affect.h:95-96 (AFFECT_AUTO_HP_RECOVERY = 534, AFFECT_AUTO_SP_RECOVERY = 535)
+# - 40250 char_item.cpp 龙神药水 (71027~71030: 攻/防/血/魔法比率加成, 30 分钟)
+# - 40250 char_item.cpp 卷轴 (22001 回城卷轴, 22010 地标传送卷轴)
+class_name ConsumableSystem
+extends RefCounted
+
+# 日月神水 Vnum 范围(40250 官方标准)
+const VNUM_SUN_ELIXIR_S := 72723 # 太阳神水 (小) - 1,000,000 HP
+const VNUM_SUN_ELIXIR_M := 72724 # 太阳神水 (中) - 3,000,000 HP
+const VNUM_SUN_ELIXIR_L := 72725 # 太阳神水 (大) - 5,000,000 HP
+const VNUM_SUN_ELIXIR_EX := 72726 # 太阳神水 (特别) - 10,000,000 HP
+
+const VNUM_MOON_ELIXIR_S := 72727 # 月亮神水 (小) - 100,000 SP
+const VNUM_MOON_ELIXIR_M := 72728 # 月亮神水 (中) - 300,000 SP
+const VNUM_MOON_ELIXIR_L := 72729 # 月亮神水 (大) - 500,000 SP
+const VNUM_MOON_ELIXIR_EX := 72730 # 月亮神水 (特别) - 1,000,000 SP
+
+# 龙神药水 Vnum
+const VNUM_DRAGON_GOD_ATTACK := 71027 # 龙神攻击 (+15% 伤害)
+const VNUM_DRAGON_GOD_DEFENSE := 71028 # 龙神防御 (+15% 防御)
+const VNUM_DRAGON_GOD_LIFE := 71029 # 龙神生命 (+20% HP)
+const VNUM_DRAGON_GOD_MANA := 71030 # 龙神法力 (+20% SP)
+
+# 卷轴 Vnum
+const VNUM_TOWN_SCROLL := 22001 # 瞬间回城卷轴
+const VNUM_RETURN_SCROLL := 22010 # 地标记忆/传送卷轴
+
+# 40250 Affect 编号
+const AFFECT_AUTO_HP_RECOVERY := 534
+const AFFECT_AUTO_SP_RECOVERY := 535
+const DRAGON_GOD_DURATION := 1800.0 # 30 分钟持续时间
+
+# 神水默认容量表 (Vnum -> 容量)
+const ELIXIR_CAPACITIES := {
+ 72723: 1000000,
+ 72724: 3000000,
+ 72725: 5000000,
+ 72726: 10000000,
+ 72727: 100000,
+ 72728: 300000,
+ 72729: 500000,
+ 72730: 1000000,
+}
+
+## 检查是否为日月神水
+static func is_elixir(vnum: int) -> bool:
+ return (vnum >= 72723 and vnum <= 72730)
+
+## 检查是否为生命神水(太阳神水)
+static func is_hp_elixir(vnum: int) -> bool:
+ return (vnum >= 72723 and vnum <= 72726)
+
+## 检查是否为法力神水(月亮神水)
+static func is_sp_elixir(vnum: int) -> bool:
+ return (vnum >= 72727 and vnum <= 72730)
+
+## 检查是否为龙神特药
+static func is_dragon_god_potion(vnum: int) -> bool:
+ return vnum in [VNUM_DRAGON_GOD_ATTACK, VNUM_DRAGON_GOD_DEFENSE, VNUM_DRAGON_GOD_LIFE, VNUM_DRAGON_GOD_MANA]
+
+## 初始化神水数据(若插槽容量未设定)
+static func init_elixir_if_needed(item_data: Dictionary) -> void:
+ var vnum := int(item_data.get("vnum", 0))
+ var sockets: Array = item_data.get("sockets", [0, 0, 0]).duplicate()
+ while sockets.size() < 3:
+ sockets.append(0)
+ # socket[0]: 0=未激活, 1=激活
+ # socket[1]: 已使用量
+ # socket[2]: 总容量
+ if int(sockets[2]) <= 0:
+ sockets[2] = ELIXIR_CAPACITIES.get(vnum, 1000000)
+ sockets[0] = 0
+ sockets[1] = 0
+ item_data["sockets"] = sockets
+
+## 切换神水激活状态 (40250 char_item.cpp:3950)
+static func toggle_elixir(item_data: Dictionary) -> Dictionary:
+ init_elixir_if_needed(item_data)
+ var sockets: Array = item_data["sockets"]
+ var cur_active := (int(sockets[0]) != 0)
+ var new_active := not cur_active
+ sockets[0] = 1 if new_active else 0
+ item_data["sockets"] = sockets
+
+ var is_hp := is_hp_elixir(int(item_data.get("vnum", 0)))
+ var kind := "生命" if is_hp else "法力"
+ var msg := ("已激活%s神水自充能!" % kind) if new_active else ("已关闭%s神水自充能。" % kind)
+
+ return {
+ "ok": true,
+ "active": new_active,
+ "affect_type": AFFECT_AUTO_HP_RECOVERY if is_hp else AFFECT_AUTO_SP_RECOVERY,
+ "msg": msg,
+ }
+
+## 自充能 Tick 处理 (40250 char_item.cpp:7326 AutoRecoveryItemProcess)
+## 返回消耗与恢复量,若耗尽返回 exhausted=true
+static func process_elixir_recovery(item_data: Dictionary, cur_val: int, max_val: int) -> Dictionary:
+ init_elixir_if_needed(item_data)
+ var sockets: Array = item_data["sockets"]
+ if int(sockets[0]) == 0:
+ return {"recovered": 0, "exhausted": false} # 未激活
+
+ var used := int(sockets[1])
+ var cap := int(sockets[2])
+ var avail := maxi(0, cap - used)
+ if avail <= 0:
+ return {"recovered": 0, "exhausted": true}
+
+ var deficit := maxi(0, max_val - cur_val)
+ if deficit <= 0:
+ return {"recovered": 0, "exhausted": false}
+
+ var heal: int = mini(deficit, avail)
+ sockets[1] = used + heal
+ item_data["sockets"] = sockets
+
+ var is_depleted := (int(sockets[1]) >= cap)
+ return {
+ "recovered": heal,
+ "exhausted": is_depleted,
+ "remaining": maxi(0, cap - int(sockets[1])),
+ "capacity": cap,
+ }
+
+## 使用龙神特药 (40250 char_item.cpp)
+## 返回 Buff 增益配置
+static func use_dragon_god_potion(vnum: int) -> Dictionary:
+ match vnum:
+ VNUM_DRAGON_GOD_ATTACK:
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "name": "龙神攻击",
+ "duration": DRAGON_GOD_DURATION,
+ "bonus_type": "POINT_ATT_BONUS",
+ "bonus_value": 15,
+ "msg": "使用了龙神攻击,攻击力提升 15%,持续 30 分钟!"
+ }
+ VNUM_DRAGON_GOD_DEFENSE:
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "name": "龙神防御",
+ "duration": DRAGON_GOD_DURATION,
+ "bonus_type": "POINT_DEF_BONUS",
+ "bonus_value": 15,
+ "msg": "使用了龙神防御,防御力提升 15%,持续 30 分钟!"
+ }
+ VNUM_DRAGON_GOD_LIFE:
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "name": "龙神生命",
+ "duration": DRAGON_GOD_DURATION,
+ "bonus_type": "POINT_MAX_HP_PCT",
+ "bonus_value": 20,
+ "msg": "使用了龙神生命,最大生命值提升 20%,持续 30 分钟!"
+ }
+ VNUM_DRAGON_GOD_MANA:
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "name": "龙神法力",
+ "duration": DRAGON_GOD_DURATION,
+ "bonus_type": "POINT_MAX_SP_PCT",
+ "bonus_value": 20,
+ "msg": "使用了龙神法力,最大法力值提升 20%,持续 30 分钟!"
+ }
+ return {"ok": false, "msg": "未知的龙神药水"}
+
+## 使用回城卷轴 / 地标卷轴 (40250 char_item.cpp)
+static func use_warp_scroll(vnum: int, item_data: Dictionary, cur_x: float, cur_y: float, cur_map_id := 1) -> Dictionary:
+ if vnum == VNUM_TOWN_SCROLL:
+ return {
+ "ok": true,
+ "action": "TOWN_WARP",
+ "consumed": true,
+ "msg": "使用了回城卷轴,即将传送回城!"
+ }
+ elif vnum == VNUM_RETURN_SCROLL:
+ var sockets: Array = item_data.get("sockets", [0, 0, 0]).duplicate()
+ while sockets.size() < 3:
+ sockets.append(0)
+
+ # 若未记录坐标(socket[0] == 0),则记录当前位置,不消耗卷轴!
+ if int(sockets[0]) == 0 and int(sockets[1]) == 0:
+ sockets[0] = int(cur_x)
+ sockets[1] = int(cur_y)
+ sockets[2] = cur_map_id
+ item_data["sockets"] = sockets
+ return {
+ "ok": true,
+ "action": "RECORDED",
+ "consumed": false,
+ "coords": Vector2(cur_x, cur_y),
+ "map_id": cur_map_id,
+ "msg": "成功将当前坐标 (%d, %d) 记录到地标卷轴!" % [int(cur_x), int(cur_y)]
+ }
+ else:
+ # 已记录坐标:传送至记录位置并消耗卷轴
+ var target_x := int(sockets[0])
+ var target_y := int(sockets[1])
+ var target_map := int(sockets[2])
+ return {
+ "ok": true,
+ "action": "WARP_TO_COORDS",
+ "consumed": true,
+ "target_x": target_x,
+ "target_y": target_y,
+ "target_map": target_map,
+ "msg": "使用了地标卷轴,正在传送至 (%d, %d)!" % [target_x, target_y]
+ }
+ return {"ok": false, "msg": "未知的传送卷轴"}
diff --git a/project/consumable_system.gd.uid b/project/consumable_system.gd.uid
new file mode 100644
index 00000000..b6b8536b
--- /dev/null
+++ b/project/consumable_system.gd.uid
@@ -0,0 +1 @@
+uid://ucwyc04610e3
diff --git a/project/costume_attr_transfer_system.gd b/project/costume_attr_transfer_system.gd
new file mode 100644
index 00000000..fa672a09
--- /dev/null
+++ b/project/costume_attr_transfer_system.gd
@@ -0,0 +1,244 @@
+# costume_attr_transfer_system.gd —— Metin2 40250 官方时装属性转移与增益附魔系统 1:1
+# 100% 对照 40250 服务端 char_item.cpp (ITEM_COSTUME, 70063, 70064), item_proto.txt
+class_name CostumeAttrTransferSystem
+extends RefCounted
+
+signal costume_transformed(costume_vnum: int, new_attrs: Array)
+signal costume_enchanted(costume_vnum: int, new_attrs: Array)
+signal costume_attributes_transferred(src_vnum: int, dst_vnum: int, attrs: Array)
+signal costume_error(reason: String)
+
+# 40250 官方时装重铸与附魔道具 Vnum
+const VNUM_COSTUME_TRANSFORM := 70063 # 时装重铸卷轴 (Kostümü dönüştür - 随机 1~3 条新属性)
+const VNUM_COSTUME_ENCHANT := 70064 # 时装附魔石 (Kostümü efsunla - 保留条数重洗属性)
+const VNUM_ATTR_TRANSFER := 72325 # 时装属性转移符
+
+# 时装最大词条数
+const MAX_COSTUME_ATTRS := 3
+
+# 40250 官方时装属性词条候选池
+const COSTUME_ATTR_POOL: Array[Dictionary] = [
+ {"type": "max_hp", "name": "生命上限", "min": 500, "max": 2000},
+ {"type": "max_sp", "name": "法力上限", "min": 200, "max": 1000},
+ {"type": "crit_pct", "name": "暴击几率 (%)", "min": 5, "max": 10},
+ {"type": "penetrate_pct", "name": "穿刺伤害几率 (%)", "min": 5, "max": 10},
+ {"type": "att_speed", "name": "攻击速度", "min": 5, "max": 10},
+ {"type": "mov_speed", "name": "移动速度", "min": 5, "max": 10},
+ {"type": "cast_speed", "name": "施法速度", "min": 5, "max": 10},
+ {"type": "hp_regen", "name": "生命自然恢复 (%)", "min": 10, "max": 30}
+]
+
+# 校验是否为时装物品
+func is_costume_item(item: Dictionary) -> bool:
+ if item == null:
+ return false
+ var subtype = str(item.get("costume_subtype", ""))
+ var is_costume = bool(item.get("is_costume", false))
+ var vnum = int(item.get("vnum", 0))
+ # 40250 官方区间:时装铠甲 (41001~44000), 时装发型 (45001~46000), 时装武器 (40101~)
+ if is_costume or subtype != "":
+ return true
+ if (vnum >= 41001 and vnum <= 46000) or (vnum >= 40101 and vnum <= 40999):
+ return true
+ return false
+
+# ==========================================
+# 1. 时装重铸 (70063 Kostümü dönüştür)
+# ==========================================
+# 移除所有既有属性,随机赋予 1~3 条全新的时装属性
+func transform_costume(
+ inventory: Array,
+ scroll_slot: int,
+ costume_slot: int,
+ forced_count: int = -1,
+ forced_attrs: Array = []
+) -> Dictionary:
+ if not _validate_slots(inventory, scroll_slot, costume_slot):
+ costume_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "格位无效或道具为空!"}
+
+ var scroll = inventory[scroll_slot]
+ var costume = inventory[costume_slot]
+
+ if int(scroll.get("vnum", 0)) != VNUM_COSTUME_TRANSFORM:
+ costume_error.emit("NOT_TRANSFORM_SCROLL")
+ return {"ok": false, "reason": "NOT_TRANSFORM_SCROLL", "msg": "该道具不是时装重铸卷轴!"}
+
+ if not is_costume_item(costume):
+ costume_error.emit("NOT_COSTUME")
+ return {"ok": false, "reason": "NOT_COSTUME", "msg": "只有时装物品可以使用时装重铸卷轴!"}
+
+ if bool(costume.get("is_equipped", false)):
+ costume_error.emit("EQUIPPED_ITEM")
+ return {"ok": false, "reason": "EQUIPPED_ITEM", "msg": "无法对穿戴中的时装进行重铸,请先卸下!"}
+
+ # 消耗 1 个重铸卷轴
+ _consume_item_one(inventory, scroll_slot)
+
+ # 决定新属性条数 (1~3 条)
+ var count = forced_count if forced_count in [1, 2, 3] else randi_range(1, 3)
+ var new_attrs: Array = []
+
+ if not forced_attrs.is_empty():
+ new_attrs = forced_attrs.duplicate(true)
+ else:
+ new_attrs = _roll_random_attributes(count)
+
+ costume["attrs"] = new_attrs
+ costume_transformed.emit(int(costume.get("vnum", 0)), new_attrs)
+
+ return {
+ "ok": true,
+ "attrs_count": new_attrs.size(),
+ "attrs": new_attrs,
+ "msg": "时装周身流光溢彩!重铸成功,获得了 %d 条全新的时装属性。" % new_attrs.size()
+ }
+
+# ==========================================
+# 2. 时装附魔 (70064 Kostümü efsunla)
+# ==========================================
+# 保留当前属性条数,重新随机洗练词条类型与数值
+func enchant_costume(
+ inventory: Array,
+ scroll_slot: int,
+ costume_slot: int,
+ forced_attrs: Array = []
+) -> Dictionary:
+ if not _validate_slots(inventory, scroll_slot, costume_slot):
+ costume_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "格位无效或道具为空!"}
+
+ var scroll = inventory[scroll_slot]
+ var costume = inventory[costume_slot]
+
+ if int(scroll.get("vnum", 0)) != VNUM_COSTUME_ENCHANT:
+ costume_error.emit("NOT_ENCHANT_SCROLL")
+ return {"ok": false, "reason": "NOT_ENCHANT_SCROLL", "msg": "该道具不是时装附魔石!"}
+
+ if not is_costume_item(costume):
+ costume_error.emit("NOT_COSTUME")
+ return {"ok": false, "reason": "NOT_COSTUME", "msg": "只有时装物品可以使用时装附魔石!"}
+
+ if bool(costume.get("is_equipped", false)):
+ costume_error.emit("EQUIPPED_ITEM")
+ return {"ok": false, "reason": "EQUIPPED_ITEM", "msg": "无法对穿戴中的时装进行附魔,请先卸下!"}
+
+ var existing_attrs: Array = costume.get("attrs", [])
+ if existing_attrs.is_empty():
+ costume_error.emit("NO_ATTRS_TO_ENCHANT")
+ return {"ok": false, "reason": "NO_ATTRS_TO_ENCHANT", "msg": "此时装没有任何附加属性!请先使用时装重铸卷轴赋予属性。"}
+
+ # 消耗 1 个附魔石
+ _consume_item_one(inventory, scroll_slot)
+
+ var count = existing_attrs.size()
+ var new_attrs: Array = []
+ if not forced_attrs.is_empty():
+ new_attrs = forced_attrs.duplicate(true)
+ else:
+ new_attrs = _roll_random_attributes(count)
+
+ costume["attrs"] = new_attrs
+ costume_enchanted.emit(int(costume.get("vnum", 0)), new_attrs)
+
+ return {
+ "ok": true,
+ "attrs_count": new_attrs.size(),
+ "attrs": new_attrs,
+ "msg": "附魔灵石融入织物!保留原有 %d 条属性槽位,洗练出了全新的强力词条。" % count
+ }
+
+# ==========================================
+# 3. 时装属性转移 (Costume Attribute Transfer)
+# ==========================================
+func transfer_costume_attributes(
+ inventory: Array,
+ scroll_slot: int,
+ src_slot: int,
+ dst_slot: int
+) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ costume_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "转移卷轴格位无效!"}
+
+ if src_slot < 0 or src_slot >= inventory.size() or inventory[src_slot] == null:
+ costume_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "源时装格位无效!"}
+
+ if dst_slot < 0 or dst_slot >= inventory.size() or inventory[dst_slot] == null:
+ costume_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "目标时装格位无效!"}
+
+ var scroll = inventory[scroll_slot]
+ var src_item = inventory[src_slot]
+ var dst_item = inventory[dst_slot]
+
+ if int(scroll.get("vnum", 0)) != VNUM_ATTR_TRANSFER:
+ costume_error.emit("NOT_TRANSFER_SCROLL")
+ return {"ok": false, "reason": "NOT_TRANSFER_SCROLL", "msg": "该物品不是时装属性转移符!"}
+
+ if not is_costume_item(src_item) or not is_costume_item(dst_item):
+ costume_error.emit("NOT_COSTUME")
+ return {"ok": false, "reason": "NOT_COSTUME", "msg": "源道具与目标道具都必须是时装!"}
+
+ var src_type = str(src_item.get("costume_subtype", "body"))
+ var dst_type = str(dst_item.get("costume_subtype", "body"))
+ if src_type != dst_type:
+ costume_error.emit("TYPE_MISMATCH")
+ return {"ok": false, "reason": "TYPE_MISMATCH", "msg": "时装类型不匹配!时装铠甲只能转移至时装铠甲,发型只能转移至发型。"}
+
+ var src_attrs: Array = src_item.get("attrs", [])
+ if src_attrs.is_empty():
+ costume_error.emit("NO_ATTRS_TO_TRANSFER")
+ return {"ok": false, "reason": "NO_ATTRS_TO_TRANSFER", "msg": "源时装没有任何可转移的属性!"}
+
+ # 消耗 1 张转移符
+ _consume_item_one(inventory, scroll_slot)
+
+ # 执行无损属性迁移
+ var transferred = src_attrs.duplicate(true)
+ dst_item["attrs"] = transferred
+ src_item["attrs"] = [] # 源时装词条清空
+
+ costume_attributes_transferred.emit(int(src_item.get("vnum", 0)), int(dst_item.get("vnum", 0)), transferred)
+ return {
+ "ok": true,
+ "transferred_attrs": transferred,
+ "msg": "乾坤倒转!成功将 %d 条极品属性从旧时装完整迁移至新时装。" % transferred.size()
+ }
+
+# 内部随机属性抽取工具
+func _roll_random_attributes(count: int) -> Array:
+ var result: Array = []
+ var pool_indices: Array[int] = []
+ for i in range(COSTUME_ATTR_POOL.size()):
+ pool_indices.append(i)
+ pool_indices.shuffle()
+
+ var actual_count = min(count, pool_indices.size())
+ for i in range(actual_count):
+ var def = COSTUME_ATTR_POOL[pool_indices[i]]
+ var val = randi_range(int(def["min"]), int(def["max"]))
+ result.append({
+ "type": def["type"],
+ "name": def["name"],
+ "value": val
+ })
+ return result
+
+func _validate_slots(inventory: Array, s1: int, s2: int) -> bool:
+ if s1 < 0 or s1 >= inventory.size() or inventory[s1] == null:
+ return false
+ if s2 < 0 or s2 >= inventory.size() or inventory[s2] == null:
+ return false
+ return true
+
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
diff --git a/project/costume_attr_transfer_system.gd.uid b/project/costume_attr_transfer_system.gd.uid
new file mode 100644
index 00000000..050cbb67
--- /dev/null
+++ b/project/costume_attr_transfer_system.gd.uid
@@ -0,0 +1 @@
+uid://cd5g767aqt3ui
diff --git a/project/costume_system.gd b/project/costume_system.gd
new file mode 100644
index 00000000..59c98de7
--- /dev/null
+++ b/project/costume_system.gd
@@ -0,0 +1,126 @@
+# costume_system.gd —— Metin2 40250 独立时装发型与武器衣柜系统 1:1
+# 对照 40250 服务端 costume.cpp, char_item.cpp, uiinventory.py, item_data.h
+class_name CostumeSystem
+extends RefCounted
+
+signal costume_equipped(slot_type: int, item: Dictionary)
+signal costume_unequipped(slot_type: int)
+signal costume_expired(slot_type: int, item_name: String)
+
+enum CostumeSlot {
+ COSTUME_BODY = 0, # 时装衣服
+ COSTUME_HAIR = 1, # 时装发型
+ COSTUME_WEAPON = 2 # 时装武器外观
+}
+
+const DEFAULT_COSTUME_DURATION := 604800.0 # 默认 7 天 (7 * 86400)
+
+var equipped_costumes: Dictionary = {
+ CostumeSlot.COSTUME_BODY: null,
+ CostumeSlot.COSTUME_HAIR: null,
+ CostumeSlot.COSTUME_WEAPON: null
+}
+
+# 穿戴时装
+func equip_costume(slot_type: int, costume_item: Dictionary) -> Dictionary:
+ if not equipped_costumes.has(slot_type):
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的时装栏位!"}
+
+ if costume_item.is_empty():
+ return {"ok": false, "reason": "EMPTY_ITEM", "msg": "时装物品数据为空!"}
+
+ # 校验时装部位匹配
+ var c_type: String = costume_item.get("costume_type", "")
+ match slot_type:
+ CostumeSlot.COSTUME_BODY:
+ if c_type != "body":
+ return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装衣服!"}
+ CostumeSlot.COSTUME_HAIR:
+ if c_type != "hair":
+ return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装发型!"}
+ CostumeSlot.COSTUME_WEAPON:
+ if c_type != "weapon":
+ return {"ok": false, "reason": "SLOT_TYPE_MISMATCH", "msg": "此物品不是时装武器外观!"}
+
+ var old_item = equipped_costumes[slot_type]
+ var item_to_equip = costume_item.duplicate(true)
+
+ if not item_to_equip.has("remaining_time"):
+ item_to_equip["remaining_time"] = DEFAULT_COSTUME_DURATION
+
+ equipped_costumes[slot_type] = item_to_equip
+
+ costume_equipped.emit(slot_type, item_to_equip)
+
+ return {
+ "ok": true,
+ "slot_type": slot_type,
+ "item": item_to_equip,
+ "old_item": old_item,
+ "msg": "时装【%s】已成功穿戴!" % item_to_equip.get("name", "时装")
+ }
+
+# 卸下时装
+func unequip_costume(slot_type: int) -> Dictionary:
+ if not equipped_costumes.has(slot_type):
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的时装栏位!"}
+
+ var item = equipped_costumes[slot_type]
+ if item == null:
+ return {"ok": false, "reason": "SLOT_EMPTY", "msg": "该时装栏位未穿戴时装!"}
+
+ equipped_costumes[slot_type] = null
+ costume_unequipped.emit(slot_type)
+
+ return {
+ "ok": true,
+ "slot_type": slot_type,
+ "item": item,
+ "msg": "已卸下时装【%s】。" % item.get("name", "时装")
+ }
+
+# 汇总所有已穿戴时装的战力属性
+func get_costume_bonuses() -> Dictionary:
+ var total_bonuses: Dictionary = {}
+ for slot in equipped_costumes.keys():
+ var it = equipped_costumes[slot]
+ if it == null:
+ continue
+ var bonuses: Dictionary = it.get("bonuses", {})
+ for b_key in bonuses.keys():
+ var val: int = int(bonuses[b_key])
+ total_bonuses[b_key] = total_bonuses.get(b_key, 0) + val
+ return total_bonuses
+
+# 获取 3D 外观覆盖模型 ID
+func get_appearance_overrides() -> Dictionary:
+ var overrides: Dictionary = {
+ "body_model": 0,
+ "hair_model": 0,
+ "weapon_model": 0
+ }
+ if equipped_costumes[CostumeSlot.COSTUME_BODY] != null:
+ overrides["body_model"] = int(equipped_costumes[CostumeSlot.COSTUME_BODY].get("model_id", 0))
+ if equipped_costumes[CostumeSlot.COSTUME_HAIR] != null:
+ overrides["hair_model"] = int(equipped_costumes[CostumeSlot.COSTUME_HAIR].get("model_id", 0))
+ if equipped_costumes[CostumeSlot.COSTUME_WEAPON] != null:
+ overrides["weapon_model"] = int(equipped_costumes[CostumeSlot.COSTUME_WEAPON].get("model_id", 0))
+ return overrides
+
+# 逐帧时效衰减更新
+func update(dt: float) -> Array:
+ var expired_list: Array = []
+ for slot in equipped_costumes.keys():
+ var it = equipped_costumes[slot]
+ if it == null:
+ continue
+
+ it["remaining_time"] = maxf(0.0, float(it.get("remaining_time", 0.0)) - dt)
+ if it["remaining_time"] <= 0.0:
+ var iname: String = it.get("name", "时装")
+ expired_list.append({"slot": slot, "item": it})
+ equipped_costumes[slot] = null
+ costume_expired.emit(slot, iname)
+ costume_unequipped.emit(slot)
+
+ return expired_list
diff --git a/project/costume_system.gd.uid b/project/costume_system.gd.uid
new file mode 100644
index 00000000..738968fa
--- /dev/null
+++ b/project/costume_system.gd.uid
@@ -0,0 +1 @@
+uid://bas2h6u0g5rsg
diff --git a/project/cube_crafting_system.gd b/project/cube_crafting_system.gd
new file mode 100644
index 00000000..f50086cb
--- /dev/null
+++ b/project/cube_crafting_system.gd
@@ -0,0 +1,293 @@
+# cube_crafting_system.gd —— Metin2 40250 官方魔方炼金合成台系统 1:1
+# 100% 对照 40250 服务端 cube.cpp, cube.h, cube.txt, quest/cube.quest
+class_name CubeCraftingSystem
+extends RefCounted
+
+signal cube_opened(npc_vnum: int, npc_name: String)
+signal cube_closed()
+signal craft_succeeded(reward_vnum: int, count: int, reward_name: String)
+signal craft_failed(recipe_name: String)
+signal cube_error(reason: String)
+
+# 40250 官方四大魔方炼金 NPC
+const NPC_BAEK_GO := 20018 # 药剂师 白高 (草药汁提炼)
+const NPC_YU_HWAN := 20017 # 游侠助手 刘焕 (中级药水配制)
+const NPC_HUAHN_SO := 20022 # 隐者 幻素 (高级露水炼制)
+const NPC_JAE_SEON_KIM:= 20383 # 顶级工匠 金在善 (神武/神甲熔铸)
+
+const NPC_NAMES: Dictionary = {
+ 20018: "药剂师 白高 (Baek-Go)",
+ 20017: "游侠助手 刘焕 (Yu-Hwan)",
+ 20022: "隐者 幻素 (Huahn-So)",
+ 20383: "神级工匠 金在善 (Jae-Seon Kim)"
+}
+
+# 40250 经典 cube.txt 配方定义表
+const RECIPES: Array[Dictionary] = [
+ # --- 药剂师 白高 (20018) & 刘焕 (20017) 基础草药汁 ---
+ {
+ "id": "herb_peach_flower",
+ "name": "桃花汁提炼",
+ "npcs": [20018, 20017],
+ "materials": [{"vnum": 50721, "count": 1}], # 桃花
+ "reward": {"vnum": 50801, "count": 1, "name": "桃花汁 (Peach Flower Juice)"},
+ "gold": 0,
+ "percent": 100
+ },
+ {
+ "id": "herb_campanula",
+ "name": "风铃草汁提炼",
+ "npcs": [20018, 20017],
+ "materials": [{"vnum": 50722, "count": 1}], # 风铃草
+ "reward": {"vnum": 50802, "count": 1, "name": "风铃草汁 (Campanula Juice)"},
+ "gold": 0,
+ "percent": 100
+ },
+ {
+ "id": "herb_lilac",
+ "name": "紫丁香汁批量提炼",
+ "npcs": [20018, 20017],
+ "materials": [{"vnum": 50723, "count": 1}], # 紫丁香
+ "reward": {"vnum": 50803, "count": 100, "name": "紫丁香汁 x100"},
+ "gold": 0,
+ "percent": 100
+ },
+ {
+ "id": "herb_persimmon",
+ "name": "柿子花汁批量提炼",
+ "npcs": [20018, 20017],
+ "materials": [{"vnum": 50724, "count": 1}], # 柿子花
+ "reward": {"vnum": 50804, "count": 100, "name": "柿子花汁 x100"},
+ "gold": 0,
+ "percent": 100
+ },
+
+ # --- 隐者 幻素 (20022) 露水药水炼制 (Dew Crafting) ---
+ {
+ "id": "dew_peach_flower",
+ "name": "桃花露水炼制",
+ "npcs": [20022],
+ "materials": [
+ {"vnum": 50801, "count": 10}, # 桃花汁 x10
+ {"vnum": 70031, "count": 10} # 空瓶 (Empty Bottle) x10
+ ],
+ "reward": {"vnum": 50821, "count": 1, "name": "纯净桃花露 (Peach Flower Dew)"},
+ "gold": 10000,
+ "percent": 100
+ },
+ {
+ "id": "dew_campanula",
+ "name": "风铃草露水炼制",
+ "npcs": [20022],
+ "materials": [
+ {"vnum": 50802, "count": 10}, # 风铃草汁 x10
+ {"vnum": 70031, "count": 10} # 空瓶 x10
+ ],
+ "reward": {"vnum": 50822, "count": 1, "name": "纯净风铃露 (Campanula Dew)"},
+ "gold": 10000,
+ "percent": 100
+ },
+
+ # --- 工匠 金在善 (20383) 顶级神兵海神剑 (Triton Sword) 熔铸 ---
+ {
+ "id": "craft_triton_low",
+ "name": "海神剑试炼熔铸 (低阶+5)",
+ "npcs": [20383],
+ "materials": [
+ {"vnum": 145, "count": 1}, # 战神之剑+5
+ {"vnum": 30500, "count": 10},# 能量晶石 x10
+ {"vnum": 30505, "count": 10} # 熔铸秘石 x10
+ ],
+ "reward": {"vnum": 460, "count": 1, "name": "海神之剑+0 (Triton Sword)"},
+ "gold": 100000,
+ "percent": 5 # 40250 原版 5% 概率
+ },
+ {
+ "id": "craft_triton_master",
+ "name": "海神剑巅峰熔铸 (满阶+9)",
+ "npcs": [20383],
+ "materials": [
+ {"vnum": 149, "count": 1}, # 战神之剑+9
+ {"vnum": 30500, "count": 10},# 能量晶石 x10
+ {"vnum": 30505, "count": 10} # 熔铸秘石 x10
+ ],
+ "reward": {"vnum": 460, "count": 1, "name": "海神之剑+0 (Triton Sword)"},
+ "gold": 100000,
+ "percent": 100 # 40250 原版 +9 保底 100%
+ }
+]
+
+# 当前魔方状态
+var current_active_npc: int = 0
+var is_cube_open: bool = false
+var crafting_history: Array[Dictionary] = []
+
+# 打开魔方窗口
+func open_cube(npc_vnum: int) -> bool:
+ if not NPC_NAMES.has(npc_vnum):
+ cube_error.emit("INVALID_NPC")
+ return false
+ current_active_npc = npc_vnum
+ is_cube_open = true
+ cube_opened.emit(npc_vnum, NPC_NAMES[npc_vnum])
+ return true
+
+# 关闭魔方窗口
+func close_cube() -> void:
+ current_active_npc = 0
+ is_cube_open = false
+ cube_closed.emit()
+
+# 根据放入槽位的材料与当前 NPC 匹配配方 (对照 cube.cpp: FN_find_cube)
+func find_matching_recipe(cube_slots: Array, npc_vnum: int) -> Dictionary:
+ # 统计放入槽位的物品 Vnum 与数量
+ var inputs: Dictionary = {}
+ for it in cube_slots:
+ if it != null:
+ var v = int(it.get("vnum", 0))
+ var c = int(it.get("count", 1))
+ inputs[v] = inputs.get(v, 0) + c
+
+ if inputs.is_empty():
+ return {}
+
+ for rec in RECIPES:
+ var npcs: Array = rec["npcs"]
+ if not npcs.has(npc_vnum):
+ continue
+
+ var req_materials: Array = rec["materials"]
+ if req_materials.size() != inputs.size():
+ continue
+
+ var matched := true
+ for mat in req_materials:
+ var mv = int(mat["vnum"])
+ var mc = int(mat["count"])
+ if inputs.get(mv, -1) != mc:
+ matched = false
+ break
+
+ if matched:
+ return rec
+
+ return {}
+
+# 执行魔方制造 (对照 cube.cpp: Cube_make)
+func craft_item(
+ cube_slots: Array,
+ inventory: Array,
+ player_gold: int,
+ forced_roll: int = -1
+) -> Dictionary:
+ if not is_cube_open:
+ cube_error.emit("CUBE_NOT_OPEN")
+ return {"ok": false, "reason": "CUBE_NOT_OPEN", "msg": "制造窗口尚未打开!"}
+
+ var recipe = find_matching_recipe(cube_slots, current_active_npc)
+ if recipe.is_empty():
+ cube_error.emit("NO_MATCHING_RECIPE")
+ return {"ok": false, "reason": "NO_MATCHING_RECIPE", "msg": "材料不足或配方不存在!"}
+
+ var fee = int(recipe.get("gold", 0))
+ if player_gold < fee:
+ cube_error.emit("NOT_ENOUGH_GOLD")
+ return {
+ "ok": false,
+ "reason": "NOT_ENOUGH_GOLD",
+ "msg": "金币不足!本次合成需要 %d 金币,你只有 %d 金币。" % [fee, player_gold]
+ }
+
+ # 扣除材料 (清空魔方放置槽)
+ for i in range(cube_slots.size()):
+ cube_slots[i] = null
+
+ # 扣除手续费金币
+ var remaining_gold = player_gold - fee
+
+ # 判定成功概率
+ var pct = int(recipe.get("percent", 100))
+ var roll = forced_roll if forced_roll > 0 else randi_range(1, 100)
+
+ if roll <= pct:
+ # 合成成功 (cube success)
+ var rew = recipe["reward"]
+ var reward_vnum = int(rew["vnum"])
+ var reward_count = int(rew.get("count", 1))
+ var reward_name = str(rew.get("name", "制造道具"))
+
+ _give_or_stack_item(inventory, reward_vnum, reward_count)
+
+ var record = {
+ "recipe_id": recipe["id"],
+ "success": true,
+ "reward_vnum": reward_vnum,
+ "reward_count": reward_count,
+ "fee": fee
+ }
+ crafting_history.append(record)
+ craft_succeeded.emit(reward_vnum, reward_count, reward_name)
+
+ return {
+ "ok": true,
+ "success": true,
+ "reward_vnum": reward_vnum,
+ "reward_count": reward_count,
+ "remaining_gold": remaining_gold,
+ "msg": "叮!伴随着绚烂的灵光,你成功制造出了【%s】!" % reward_name
+ }
+ else:
+ # 合成失败 (cube fail)
+ var record = {
+ "recipe_id": recipe["id"],
+ "success": false,
+ "fee": fee
+ }
+ crafting_history.append(record)
+ craft_failed.emit(recipe["name"])
+
+ return {
+ "ok": true,
+ "success": false,
+ "remaining_gold": remaining_gold,
+ "msg": "制造失败了!魔方中传出一阵焦黑的青烟,材料化为了灰烬。"
+ }
+
+# 获取当前 NPC 允许合成的所有配方清单
+func get_available_recipes(npc_vnum: int) -> Array[Dictionary]:
+ var list: Array[Dictionary] = []
+ for rec in RECIPES:
+ if rec["npcs"].has(npc_vnum):
+ list.append(rec)
+ return list
+
+# 历史记录
+func get_history() -> Array[Dictionary]:
+ return crafting_history
+
+func clear_history() -> void:
+ crafting_history.clear()
+
+# 内部发放道具堆叠
+func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
+ for it in inventory:
+ if it != null and int(it.get("vnum", 0)) == vnum:
+ it["count"] = int(it.get("count", 1)) + count
+ return
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "count": count}
+ return
+ inventory.append({"vnum": vnum, "count": count})
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "history": crafting_history.duplicate(true)
+ }
+
+func deserialize(data: Dictionary) -> void:
+ crafting_history.clear()
+ var raw = data.get("history", [])
+ for it in raw:
+ crafting_history.append(it.duplicate(true))
diff --git a/project/cube_crafting_system.gd.uid b/project/cube_crafting_system.gd.uid
new file mode 100644
index 00000000..0d82ffd6
--- /dev/null
+++ b/project/cube_crafting_system.gd.uid
@@ -0,0 +1 @@
+uid://dxkngqnfky42a
diff --git a/project/cursed_monolith_spire_system.gd b/project/cursed_monolith_spire_system.gd
new file mode 100644
index 00000000..4b0bed9a
--- /dev/null
+++ b/project/cursed_monolith_spire_system.gd
@@ -0,0 +1,196 @@
+# cursed_monolith_spire_system.gd —— Metin2 40250 野外魔石破除之【封印魔塔】限时防守挑战 1:1
+# 对照 40250 服务端 regen.cpp, dungeon.cpp, char_battle.cpp, item_manager.cpp
+class_name CursedMonolithSpireSystem
+extends RefCounted
+
+signal spire_emerged(pos: Vector3, time_limit: float)
+signal wave_spawned(wave_index: int, wave_name: String, mob_count: int)
+signal trial_completed(success: bool, time_used: float)
+signal reward_chest_spawned(chest_vnum: int, pos: Vector3)
+
+const TRIAL_TIME_LIMIT: float = 180.0 # 3 分钟倒计时
+const CHEST_VNUM: int = 50130 # 上古魔塔宝箱 (Ancient Spire Chest)
+
+const WAVE_CONFIG: Dictionary = {
+ 1: {
+ "name": "第 1 波:狂暴魔兵突袭",
+ "mob_vnum": 701, # 恶魔士兵
+ "count": 6
+ },
+ 2: {
+ "name": "第 2 波:双生魔将合围",
+ "mob_vnum": 1092, # 恶魔大将
+ "count": 2
+ },
+ 3: {
+ "name": "第 3 波:上古守墓魔王",
+ "mob_vnum": 1093, # 恶魔死神幻影
+ "count": 1
+ }
+}
+
+var is_active: bool = false
+var current_wave: int = 0
+var time_remaining: float = 0.0
+var spire_pos: Vector3 = Vector3.ZERO
+var difficulty: int = 50
+
+var active_mobs: Dictionary = {} # mob_id -> { "vnum", "alive" }
+var next_mob_id: int = 1
+var is_victory: bool = false
+var chest_available: bool = false
+
+# 启动封印魔塔挑战
+func start_trial(pos: Vector3, diff_level: int = 50) -> Dictionary:
+ if is_active:
+ return {"ok": false, "reason": "TRIAL_ALREADY_ACTIVE", "msg": "封印魔塔挑战正在进行中!"}
+
+ is_active = true
+ is_victory = false
+ chest_available = false
+ spire_pos = pos
+ difficulty = diff_level
+ time_remaining = TRIAL_TIME_LIMIT
+ current_wave = 0
+ active_mobs.clear()
+ next_mob_id = 1
+
+ spire_emerged.emit(pos, TRIAL_TIME_LIMIT)
+
+ # 立即召唤第 1 波
+ _spawn_wave(1)
+
+ return {
+ "ok": true,
+ "pos": pos,
+ "time_limit": TRIAL_TIME_LIMIT,
+ "msg": "大地剧烈震颤!【诅咒的封印黑石魔塔】破土升起!请在 180 秒内清除所有魔潮!"
+ }
+
+# 产生指定阶段怪潮
+func _spawn_wave(wave_idx: int) -> void:
+ current_wave = wave_idx
+ var cfg = WAVE_CONFIG[wave_idx]
+ var count: int = cfg["count"]
+ var vnum: int = cfg["mob_vnum"]
+
+ active_mobs.clear()
+ for i in range(count):
+ var mid := next_mob_id
+ next_mob_id += 1
+ active_mobs[mid] = {
+ "id": mid,
+ "vnum": vnum,
+ "alive": true
+ }
+
+ wave_spawned.emit(wave_idx, cfg["name"], count)
+
+# 击杀魔潮怪物
+func kill_mob(mob_id: int) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "TRIAL_NOT_ACTIVE"}
+
+ if not active_mobs.has(mob_id) or not active_mobs[mob_id]["alive"]:
+ return {"ok": false, "reason": "MOB_NOT_FOUND"}
+
+ active_mobs[mob_id]["alive"] = false
+ active_mobs.erase(mob_id)
+
+ # 检查当前波次是否全清
+ if active_mobs.is_empty():
+ if current_wave < 3:
+ # 进入下一波
+ var next_w = current_wave + 1
+ _spawn_wave(next_w)
+ return {
+ "ok": true,
+ "wave_cleared": current_wave - 1,
+ "next_wave": next_w,
+ "msg": "当前波次肃清!更加凶险的魔物正在凝聚!"
+ }
+ else:
+ # 第 3 波首领击破 -> 大获全胜!
+ return _finish_trial(true)
+
+ return {
+ "ok": true,
+ "remaining_in_wave": active_mobs.size()
+ }
+
+# 帧心跳更新限时倒计时
+func update(delta: float) -> Dictionary:
+ if not is_active:
+ return {"status": "inactive"}
+
+ time_remaining -= delta
+ if time_remaining <= 0.0:
+ time_remaining = 0.0
+ return _finish_trial(false)
+
+ return {
+ "status": "running",
+ "wave": current_wave,
+ "remaining_mobs": active_mobs.size(),
+ "time_remaining": time_remaining
+ }
+
+# 结算挑战胜负
+func _finish_trial(success: bool) -> Dictionary:
+ is_active = false
+ is_victory = success
+ var time_used = TRIAL_TIME_LIMIT - time_remaining
+
+ if success:
+ chest_available = true
+ reward_chest_spawned.emit(CHEST_VNUM, spire_pos)
+ trial_completed.emit(true, time_used)
+ return {
+ "ok": true,
+ "victory": true,
+ "time_used": time_used,
+ "msg": "【封印魔塔】封印彻底瓦解!降下【上古魔塔宝箱】!耗时: %.1f 秒" % time_used
+ }
+ else:
+ active_mobs.clear()
+ trial_completed.emit(false, TRIAL_TIME_LIMIT)
+ return {
+ "ok": true,
+ "victory": false,
+ "time_used": TRIAL_TIME_LIMIT,
+ "msg": "时间耗尽!封印黑石魔塔重新沉入深渊地底,挑战失败!"
+ }
+
+# 开启上古宝箱获取掉落
+func open_reward_chest() -> Array:
+ if not chest_available:
+ return []
+
+ chest_available = false
+ var drops: Array = [
+ {"vnum": 71003, "name": "祝福卷轴", "count": 2},
+ {"vnum": 50513, "name": "灵魂之石", "count": 1},
+ {"vnum": 70024, "name": "祝福宝珠", "count": 1},
+ {"vnum": 1, "name": "金币", "count": 3500000}
+ ]
+ return drops
+
+# 取消/中断挑战
+func cancel_trial() -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "NOT_ACTIVE"}
+
+ is_active = false
+ active_mobs.clear()
+ return {"ok": true, "msg": "挑战已提前终止。"}
+
+# 获取状态
+func get_trial_state() -> Dictionary:
+ return {
+ "is_active": is_active,
+ "is_victory": is_victory,
+ "current_wave": current_wave,
+ "remaining_mobs": active_mobs.size(),
+ "time_remaining": time_remaining,
+ "chest_available": chest_available
+ }
diff --git a/project/cursed_monolith_spire_system.gd.uid b/project/cursed_monolith_spire_system.gd.uid
new file mode 100644
index 00000000..21a9b11e
--- /dev/null
+++ b/project/cursed_monolith_spire_system.gd.uid
@@ -0,0 +1 @@
+uid://mklhwerocdlu
diff --git a/project/demon_tower_system.gd b/project/demon_tower_system.gd
new file mode 100644
index 00000000..297a994a
--- /dev/null
+++ b/project/demon_tower_system.gd
@@ -0,0 +1,225 @@
+# demon_tower_system.gd —— 恶魔之塔 1~9 层单机爬塔挑战循环与塔顶免费铁匠(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/share/locale/english/quest/deviltower_zone.quest
+# - 40250 server/game/src/dungeon.cpp
+# - 40250 server/game/src/char_battle.cpp
+class_name DemonTowerSystem
+extends RefCounted
+
+signal floor_changed(floor_num: int, floor_name: String)
+signal floor_completed(floor_num: int)
+signal tower_completed
+signal tower_failed(reason: String)
+signal blacksmith_spawned(blacksmith_vnum: int, blacksmith_name: String)
+
+# 核心怪物与 NPC Vnum (40250 官方标准)
+const VNUM_METIN_FLOOR1 := 8015 # 坚韧之石 (1层入口封印)
+const VNUM_DEMON_KING := 1091 # 恶魔王 (3层 BOSS)
+const VNUM_METIN_FLOOR4 := 8016 # 恶魔封印石 (4层真伪封印石)
+const VNUM_ELITE_DEMON_KING := 1092 # 精英恶魔王 (6层 BOSS)
+const VNUM_METIN_DEATH := 8017 # 死亡之石 (7层)
+const VNUM_DEATH_REAPER := 1093 # 死神 (9层 最终 BOSS)
+
+# 塔顶免费铁匠 NPC
+const NPC_TOWER_WEAPON_SMITH := 20074 # 恶魔之塔武器铁匠
+const NPC_TOWER_ARMOR_SMITH := 20075 # 恶魔之塔防具铁匠
+const NPC_TOWER_JEWELRY_SMITH := 20076 # 恶魔之塔首饰铁匠
+
+# 核心任务道具
+const VNUM_ZIN_KEY_FLOOR5 := 50084 # 解除 5 层石碑钥匙
+const VNUM_ZIN_MAP_FLOOR7 := 30302 # 7 层恶魔封印地图
+const VNUM_ZIN_KEY_FLOOR8 := 30304 # 8 层通往塔顶钥匙
+const VNUM_DEATH_REAPER_CHEST := 50082 # 9 层死神宝箱
+
+const TOTAL_FLOORS := 9
+const FLOOR_NAMES := {
+ 1: "第 1 层:恶魔入口",
+ 2: "第 2 层:恶魔集结所",
+ 3: "第 3 层:恶魔王大殿",
+ 4: "第 4 层:迷幻封印之阵",
+ 5: "第 5 层:五柱封印禁地",
+ 6: "第 6 层:恶魔之塔顶峰",
+ 7: "第 7 层:死神迷宫前厅",
+ 8: "第 8 层:死神结界禁地",
+ 9: "第 9 层:死神王座决战",
+}
+
+# 运行期状态
+var current_floor := 1
+var is_active := false
+var floor_time_left := 1200.0 # 20 分钟单层时限
+var floor4_real_metin_idx := -1
+var floor5_unlocked_pillars := 0
+var floor6_blacksmith_vnum := 0
+var floor6_free_refine_used := false
+
+func start_tower() -> void:
+ current_floor = 1
+ is_active = true
+ floor_time_left = 1200.0
+ floor4_real_metin_idx = -1
+ floor5_unlocked_pillars = 0
+ floor6_blacksmith_vnum = 0
+ floor6_free_refine_used = false
+ floor_changed.emit(1, FLOOR_NAMES[1])
+
+func get_floor() -> int:
+ return current_floor
+
+func get_floor_name() -> String:
+ return FLOOR_NAMES.get(current_floor, "未知层")
+
+## 第 1 层判定:击碎封印石 8015 即可通关
+func on_floor1_metin_destroyed(vnum: int) -> bool:
+ if current_floor != 1 or not is_active:
+ return false
+ if vnum == VNUM_METIN_FLOOR1:
+ _advance_floor()
+ return true
+ return false
+
+## 第 2 层判定:清理全图怪物
+func on_floor2_mobs_cleared(mob_count: int) -> bool:
+ if current_floor != 2 or not is_active:
+ return false
+ if mob_count <= 0:
+ _advance_floor()
+ return true
+ return false
+
+## 第 3 层判定:击杀恶魔王 1091
+func on_floor3_boss_killed(boss_vnum: int) -> bool:
+ if current_floor != 3 or not is_active:
+ return false
+ if boss_vnum == VNUM_DEMON_KING:
+ _advance_floor()
+ return true
+ return false
+
+## 第 4 层判定:初始化 7 座封印石并在打破真石时通关
+func setup_floor4_metins() -> void:
+ # 7 座封印石 (索引 0..6),随机指定 1 座为真石
+ floor4_real_metin_idx = randi() % 7
+
+func on_floor4_metin_hit(stone_idx: int) -> Dictionary:
+ if current_floor != 4 or not is_active:
+ return {"ok": false, "msg": "当前不在第 4 层!"}
+ if stone_idx == floor4_real_metin_idx:
+ _advance_floor()
+ return {"ok": true, "is_real": true, "msg": "找到了真实的恶魔封印石!通往第 5 层的传送门开启!"}
+ else:
+ return {"ok": true, "is_real": false, "msg": "这是一座虚假的封印石,迷雾尚未散开!"}
+
+## 第 5 层判定:使用钥匙解除 5 座恶魔石柱
+func on_floor5_unlock_pillar(key_vnum: int) -> Dictionary:
+ if current_floor != 5 or not is_active:
+ return {"ok": false, "msg": "当前不在第 5 层!"}
+ if key_vnum != VNUM_ZIN_KEY_FLOOR5:
+ return {"ok": false, "msg": "钥匙不匹配!"}
+
+ floor5_unlocked_pillars += 1
+ var remain := 5 - floor5_unlocked_pillars
+ if floor5_unlocked_pillars >= 5:
+ _advance_floor()
+ return {"ok": true, "completed": true, "msg": "5 座恶魔石碑全部解除封印!传送至第 6 层!"}
+ else:
+ return {"ok": true, "completed": false, "remain": remain, "msg": "成功解除了一座石碑封印,还剩 %d 座!" % remain}
+
+## 第 6 层判定:击杀精英恶魔王 1092 并召唤免费铁匠
+func on_floor6_boss_killed(boss_vnum: int) -> Dictionary:
+ if current_floor != 6 or not is_active:
+ return {"ok": false, "msg": "当前不在第 6 层!"}
+ if boss_vnum == VNUM_ELITE_DEMON_KING:
+ # 随机召唤三位塔顶铁匠之一
+ var smiths := [
+ {"vnum": NPC_TOWER_WEAPON_SMITH, "name": "恶魔之塔武器铁匠"},
+ {"vnum": NPC_TOWER_ARMOR_SMITH, "name": "恶魔之塔防具铁匠"},
+ {"vnum": NPC_TOWER_JEWELRY_SMITH, "name": "恶魔之塔首饰铁匠"}
+ ]
+ var chosen: Dictionary = smiths[randi() % smiths.size()]
+ floor6_blacksmith_vnum = chosen["vnum"]
+ floor6_free_refine_used = false
+ blacksmith_spawned.emit(chosen["vnum"], chosen["name"])
+ return {
+ "ok": true,
+ "blacksmith_vnum": chosen["vnum"],
+ "blacksmith_name": chosen["name"],
+ "msg": "击杀了精英恶魔王!塔顶现身了一位【%s】!" % chosen["name"]
+ }
+ return {"ok": false, "msg": "目标不是精英恶魔王!"}
+
+## 第 6 层塔顶免费铁匠强化 (无需材料,单人爬塔经典福利)
+func perform_tower_free_refine(item: Dictionary, item_type: int) -> Dictionary:
+ if current_floor != 6 or floor6_blacksmith_vnum == 0:
+ return {"ok": false, "code": "NO_BLACKSMITH", "msg": "塔顶铁匠尚未出现!"}
+ if floor6_free_refine_used:
+ return {"ok": false, "code": "ALREADY_USED", "msg": "恶魔之塔铁匠本次只能免费强化一次!"}
+
+ # 校验铁匠类型与物品类型匹配
+ if floor6_blacksmith_vnum == NPC_TOWER_WEAPON_SMITH and item_type != 1:
+ return {"ok": false, "code": "TYPE_MISMATCH", "msg": "武器铁匠只能强化武器!"}
+ if floor6_blacksmith_vnum == NPC_TOWER_ARMOR_SMITH and item_type != 2:
+ return {"ok": false, "code": "TYPE_MISMATCH", "msg": "防具铁匠只能强化防具!"}
+
+ floor6_free_refine_used = true
+ var cur_vnum := int(item.get("vnum", 0))
+ var new_vnum := cur_vnum + 1
+ item["vnum"] = new_vnum
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "old_vnum": cur_vnum,
+ "new_vnum": new_vnum,
+ "msg": "【%s】为你施展了恶魔神工,免费强化成功!装备晋升为 #%d!" % [
+ "武器铁匠" if floor6_blacksmith_vnum == NPC_TOWER_WEAPON_SMITH else "防具铁匠",
+ new_vnum
+ ]
+ }
+
+## 从第 6 层前往第 7 层 (要求角色等级 >= 75)
+func advance_to_floor7(player_level: int) -> Dictionary:
+ if current_floor != 6:
+ return {"ok": false, "msg": "不在第 6 层!"}
+ if player_level < 75:
+ return {"ok": false, "code": "LEVEL_TOO_LOW", "msg": "只有等级达到 75 级的真正勇者,才能踏入恶魔之塔第 7 层禁地!"}
+ _advance_floor()
+ return {"ok": true, "msg": "踏入了死神禁地第 7 层!"}
+
+## 第 7 层判定:使用恶魔地图 30302 通关
+func on_floor7_map_used(map_vnum: int) -> bool:
+ if current_floor != 7 or not is_active:
+ return false
+ if map_vnum == VNUM_ZIN_MAP_FLOOR7:
+ _advance_floor()
+ return true
+ return false
+
+## 第 8 层判定:使用钥匙 30304 通关
+func on_floor8_key_used(key_vnum: int) -> bool:
+ if current_floor != 8 or not is_active:
+ return false
+ if key_vnum == VNUM_ZIN_KEY_FLOOR8:
+ _advance_floor()
+ return true
+ return false
+
+## 第 9 层终极对决:击杀死神 1093
+func on_floor9_boss_killed(boss_vnum: int) -> Dictionary:
+ if current_floor != 9 or not is_active:
+ return {"ok": false, "msg": "当前不在第 9 层!"}
+ if boss_vnum == VNUM_DEATH_REAPER:
+ is_active = false
+ tower_completed.emit()
+ return {
+ "ok": true,
+ "code": "TOWER_CLEAR",
+ "drop_vnum": VNUM_DEATH_REAPER_CHEST,
+ "msg": "壮举!你成功斩杀了死神,恶魔之塔被彻底征服!获得了【死神宝箱】!"
+ }
+ return {"ok": false, "msg": "目标不是死神!"}
+
+func _advance_floor() -> void:
+ floor_completed.emit(current_floor)
+ current_floor += 1
+ floor_time_left = 1200.0
+ floor_changed.emit(current_floor, FLOOR_NAMES.get(current_floor, "第 %d 层" % current_floor))
diff --git a/project/demon_tower_system.gd.uid b/project/demon_tower_system.gd.uid
new file mode 100644
index 00000000..211d6cf9
--- /dev/null
+++ b/project/demon_tower_system.gd.uid
@@ -0,0 +1 @@
+uid://berjrd53seoek
diff --git a/project/devils_catacomb_system.gd b/project/devils_catacomb_system.gd
new file mode 100644
index 00000000..4c35e487
--- /dev/null
+++ b/project/devils_catacomb_system.gd
@@ -0,0 +1,326 @@
+# devils_catacomb_system.gd —— Metin2 40250 地下墓穴 7 层与冥王阿兹瑞尔挑战副本 1:1
+# 对照 40250 服务端 devils_catacomb.quest, dungeon.cpp, char_battle.cpp, mob 2598, 2597, 2596
+class_name DevilsCatacombSystem
+extends RefCounted
+
+signal floor_advanced(floor: int)
+signal metin_destroyed(is_correct: bool)
+signal tartaros_killed(drop_item: Dictionary)
+signal obelisk_activated()
+signal charon_killed()
+signal azrael_hp_changed(current_hp: int, max_hp: int)
+signal catacomb_cleared(drops: Array)
+signal catacomb_failed(reason: String)
+
+const NPC_CATACOMB_GUARD := 20367
+const ITEM_SOUL_CRYSTAL_KEY := 30311 # 灵魂水晶钥匙 (进入与 1 层解锁)
+const ITEM_GRIM_SOUL_STONE := 30312 # 冷酷之石 (5 层塔耳塔洛斯掉落)
+const MIN_LEVEL := 75
+const TIME_LIMIT := 3600.0 # 60 分钟限时
+
+# 首领属性
+const MOB_TARTAROS := 2597 # 5 层守卫 塔耳塔洛斯
+const MOB_CHARON := 2596 # 6 层将军 卡隆
+const MOB_AZRAEL := 2598 # 7 层冥界之王 阿兹瑞尔
+
+const AZRAEL_MAX_HP := 900000
+const AZRAEL_BASE_ATK := 2200
+const AZRAEL_BASE_DEF := 680
+
+var is_active: bool = false
+var current_floor: int = 0
+var remaining_time: float = 0.0
+
+# 3 层复仇魔石状态
+var correct_metin_id: int = 3
+var metins_broken: int = 0
+
+# 5 层方尖碑状态
+var has_grim_stone: bool = false
+var is_obelisk_unlocked: bool = false
+
+# 7 层阿兹瑞尔状态
+var azrael_hp: int = 0
+var azrael_max_hp: int = AZRAEL_MAX_HP
+var is_azrael_alive: bool = false
+var azrael_minions_summoned: bool = false
+
+# 进入地下墓穴 (通过 NPC 20367)
+func enter_catacomb(player_level: int, inventory: Array) -> Dictionary:
+ if player_level < MIN_LEVEL:
+ return {
+ "ok": false,
+ "reason": "LEVEL_TOO_LOW",
+ "msg": "你的等级不足 75 级,无法承受地下墓穴的死灵死气!"
+ }
+
+ # 检查 1 层必需的灵魂水晶钥匙 (30311)
+ var key_slot := -1
+ for i in range(inventory.size()):
+ var item = inventory[i]
+ if item != null and int(item.get("vnum", 0)) == ITEM_SOUL_CRYSTAL_KEY:
+ key_slot = i
+ break
+
+ if key_slot == -1:
+ return {
+ "ok": false,
+ "reason": "MISSING_SOUL_KEY",
+ "msg": "进入地下墓穴需要持有【灵魂水晶钥匙】!"
+ }
+
+ # 初始化副本状态进入 1 层
+ is_active = true
+ current_floor = 1
+ remaining_time = TIME_LIMIT
+ correct_metin_id = randi_range(1, 5)
+ metins_broken = 0
+ has_grim_stone = false
+ is_obelisk_unlocked = false
+ is_azrael_alive = false
+
+ floor_advanced.emit(1)
+
+ return {
+ "ok": true,
+ "floor": 1,
+ "time_limit": remaining_time,
+ "msg": "成功踏入地下墓穴一层!限时 60 分钟!"
+ }
+
+# 第 1 层:使用灵魂水晶钥匙开启石门封印进入 2 层
+func unlock_floor_1(inventory: Array) -> Dictionary:
+ if not is_active or current_floor != 1:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ var key_slot := -1
+ for i in range(inventory.size()):
+ var item = inventory[i]
+ if item != null and int(item.get("vnum", 0)) == ITEM_SOUL_CRYSTAL_KEY:
+ key_slot = i
+ break
+
+ if key_slot == -1:
+ return {"ok": false, "reason": "MISSING_KEY", "msg": "背包中缺少灵魂水晶钥匙!"}
+
+ # 消耗钥匙
+ inventory[key_slot] = null
+ current_floor = 2
+ floor_advanced.emit(2)
+
+ return {
+ "ok": true,
+ "floor": 2,
+ "msg": "灵魂水晶钥匙已插入封印,石门轰然开启!进入地下墓穴二层!"
+ }
+
+# 第 2 层:突破恶灵迷宫进入 3 层
+func complete_floor_2() -> Dictionary:
+ if not is_active or current_floor != 2:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ current_floor = 3
+ floor_advanced.emit(3)
+ return {
+ "ok": true,
+ "floor": 3,
+ "msg": "已突破恶灵迷宫!进入地下墓穴三层!"
+ }
+
+# 第 3 层:击碎复仇魔石 (8035)
+func break_floor_3_metin(metin_idx: int) -> Dictionary:
+ if not is_active or current_floor != 3:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ metins_broken += 1
+ if metin_idx == correct_metin_id:
+ current_floor = 4
+ metin_destroyed.emit(true)
+ floor_advanced.emit(4)
+ return {
+ "ok": true,
+ "correct": true,
+ "floor": 4,
+ "msg": "击碎了真正的复仇魔石!封印瓦解,传送至地下墓穴四层!"
+ }
+ else:
+ metin_destroyed.emit(false)
+ return {
+ "ok": true,
+ "correct": false,
+ "floor": 3,
+ "msg": "这是虚假的伪装魔石!周围唤醒了冥界诅咒之魂!"
+ }
+
+# 第 4 层:玄武岩石柱迷宫寻路进入 5 层
+func complete_floor_4() -> Dictionary:
+ if not is_active or current_floor != 4:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ current_floor = 5
+ floor_advanced.emit(5)
+ return {
+ "ok": true,
+ "floor": 5,
+ "msg": "成功找到玄武岩迷宫真出口!进入地下墓穴五层!"
+ }
+
+# 第 5 层:击败塔耳塔洛斯 (2597) 并获得冷酷之石 (30312)
+func defeat_tartaros(inventory: Array) -> Dictionary:
+ if not is_active or current_floor != 5:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ # 放入背包
+ var empty_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法拾取冷酷之石!"}
+
+ var grim_stone = {"vnum": ITEM_GRIM_SOUL_STONE, "name": "冷酷之石", "count": 1}
+ inventory[empty_slot] = grim_stone
+ has_grim_stone = true
+
+ tartaros_killed.emit(grim_stone)
+
+ return {
+ "ok": true,
+ "drop": grim_stone,
+ "msg": "击败了守卫【塔耳塔洛斯】,获得了【冷酷之石】!请前往方尖碑镶嵌!"
+ }
+
+# 第 5 层:将冷酷之石插入玄武岩方尖碑,开启 6 层通道
+func insert_grim_stone_to_obelisk(inventory: Array) -> Dictionary:
+ if not is_active or current_floor != 5:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ var stone_slot := -1
+ for i in range(inventory.size()):
+ var item = inventory[i]
+ if item != null and int(item.get("vnum", 0)) == ITEM_GRIM_SOUL_STONE:
+ stone_slot = i
+ break
+
+ if stone_slot == -1:
+ return {"ok": false, "reason": "MISSING_STONE", "msg": "背包中缺少冷酷之石!"}
+
+ inventory[stone_slot] = null
+ is_obelisk_unlocked = true
+ current_floor = 6
+ obelisk_activated.emit()
+ floor_advanced.emit(6)
+
+ return {
+ "ok": true,
+ "floor": 6,
+ "msg": "冷酷之石完美嵌入方尖碑!冥界封印开启,进入地下墓穴六层!"
+ }
+
+# 第 6 层:击败冥界将军卡隆 (2596) 进入 7 层阿兹瑞尔王座
+func defeat_charon() -> Dictionary:
+ if not is_active or current_floor != 6:
+ return {"ok": false, "reason": "WRONG_FLOOR"}
+
+ current_floor = 7
+ # 初始化 7 层 Boss 阿兹瑞尔
+ azrael_hp = AZRAEL_MAX_HP
+ azrael_max_hp = AZRAEL_MAX_HP
+ is_azrael_alive = true
+ azrael_minions_summoned = false
+
+ charon_killed.emit()
+ floor_advanced.emit(7)
+
+ return {
+ "ok": true,
+ "floor": 7,
+ "boss_hp": azrael_hp,
+ "msg": "冥界将军【卡隆】已被斩灭!传送至七层【冥王阿兹瑞尔王座】!"
+ }
+
+# 第 7 层:对冥王阿兹瑞尔造成伤害
+func attack_azrael(raw_dmg: int) -> Dictionary:
+ if not is_active or current_floor != 7 or not is_azrael_alive:
+ return {"ok": false, "reason": "BOSS_NOT_AVAILABLE"}
+
+ var net_dmg: int = max(1, raw_dmg - AZRAEL_BASE_DEF)
+ azrael_hp = max(0, azrael_hp - net_dmg)
+
+ azrael_hp_changed.emit(azrael_hp, azrael_max_hp)
+
+ # 半血召唤死灵护卫
+ var triggered_minions := false
+ if azrael_hp <= int(AZRAEL_MAX_HP * 0.5) and not azrael_minions_summoned:
+ azrael_minions_summoned = true
+ triggered_minions = true
+
+ # 击败阿兹瑞尔
+ if azrael_hp <= 0:
+ is_azrael_alive = false
+ is_active = false
+ var drops: Array = _generate_azrael_drops()
+ catacomb_cleared.emit(drops)
+ return {
+ "ok": true,
+ "killed": true,
+ "net_dmg": net_dmg,
+ "remaining_hp": 0,
+ "drops": drops,
+ "msg": "【冥王阿兹瑞尔】轰然倒下!地下墓穴已被彻底征服!"
+ }
+
+ return {
+ "ok": true,
+ "killed": false,
+ "net_dmg": net_dmg,
+ "remaining_hp": azrael_hp,
+ "summoned_minions": triggered_minions
+ }
+
+# 逐帧倒计时
+func update(dt: float) -> void:
+ if not is_active:
+ return
+
+ remaining_time -= dt
+ if remaining_time <= 0.0:
+ is_active = false
+ is_azrael_alive = false
+ catacomb_failed.emit("TIME_OUT")
+
+# 生成阿兹瑞尔掉落物
+func _generate_azrael_drops() -> Array:
+ var drops: Array = []
+
+ # 1. 必掉阿兹瑞尔专属宝箱 (50187)
+ drops.append({
+ "vnum": 50187,
+ "name": "阿兹瑞尔宝箱",
+ "count": 1
+ })
+
+ # 2. 必掉灵魂之石 (50513)
+ drops.append({
+ "vnum": 50513,
+ "name": "灵魂之石",
+ "count": 1
+ })
+
+ # 3. 终极泰坦之盾 (13149) 概率掉落
+ drops.append({
+ "vnum": 13149,
+ "name": "泰坦之盾+0",
+ "count": 1
+ })
+
+ # 4. 大额冥界金币
+ drops.append({
+ "vnum": 1,
+ "name": "金币",
+ "count": randi_range(200000, 400000)
+ })
+
+ return drops
diff --git a/project/devils_catacomb_system.gd.uid b/project/devils_catacomb_system.gd.uid
new file mode 100644
index 00000000..b1ad1fc7
--- /dev/null
+++ b/project/devils_catacomb_system.gd.uid
@@ -0,0 +1 @@
+uid://dyqcl4bpdcalf
diff --git a/project/dragon_lair_system.gd b/project/dragon_lair_system.gd
new file mode 100644
index 00000000..ae7e8409
--- /dev/null
+++ b/project/dragon_lair_system.gd
@@ -0,0 +1,257 @@
+# dragon_lair_system.gd —— Metin2 40250 冰龙贝兰-塞陶挑战巢穴 1:1
+# 对照 40250 服务端 dragon_lair.quest, dungeon.cpp, char_battle.cpp, mob 2493, NPC 30121
+class_name DragonLairSystem
+extends RefCounted
+
+signal dungeon_started(time_limit: float)
+signal metin_destroyed(metin_vnum: int, metin_name: String)
+signal minions_spawned(count: int, mob_vnum: int)
+signal dragon_hp_changed(current_hp: int, max_hp: int)
+signal dragon_defeated(drops: Array)
+signal dungeon_failed(reason: String)
+
+const NPC_GHOST_OF_SURA := 30121 # 修罗鬼魂
+const ITEM_TWISTED_KEY := 30179 # 扭曲钥匙
+const KEY_REQUIRED_COUNT := 3
+const MIN_LEVEL := 75
+const TIME_LIMIT := 3600.0 # 60 分钟限时
+
+const BOSS_VNUM := 2493 # 贝兰-塞陶 (Beran-Setaou)
+const BOSS_MAX_HP := 800000
+const BOSS_BASE_ATK := 1800
+const BOSS_BASE_DEF := 650
+
+const METIN_TABLE: Dictionary = {
+ 8031: {"name": "傲慢之石", "desc": "提供 30% 物理抗性减免"},
+ 8032: {"name": "寂寞之石", "desc": "提供生命自然恢复"},
+ 8033: {"name": "平静之石", "desc": "提供 30% 魔法抗性减免"},
+ 8034: {"name": "复仇之石", "desc": "提供 40% 攻击力增幅"}
+}
+
+var is_active: bool = false
+var remaining_time: float = 0.0
+var dragon_hp: int = 0
+var dragon_max_hp: int = BOSS_MAX_HP
+var is_dragon_alive: bool = false
+var active_metins: Dictionary = {} # vnum -> bool (true = alive)
+var active_minions: Array = []
+var regen_timer: float = 0.0
+
+# 进入冰龙巢穴 (通过 NPC 30121 验证)
+func enter_lair(player_level: int, inventory: Array) -> Dictionary:
+ if player_level < MIN_LEVEL:
+ return {
+ "ok": false,
+ "reason": "LEVEL_TOO_LOW",
+ "msg": "你的等级不足 75 级,无法承受冰龙巢穴的严寒!"
+ }
+
+ # 检查背包中的 3 把扭曲钥匙 (30179)
+ var key_slots: Array = []
+ var total_keys: int = 0
+ for i in range(inventory.size()):
+ var item = inventory[i]
+ if item != null and int(item.get("vnum", 0)) == ITEM_TWISTED_KEY:
+ var cnt: int = int(item.get("count", 1))
+ total_keys += cnt
+ key_slots.append({"slot": i, "count": cnt})
+
+ if total_keys < KEY_REQUIRED_COUNT:
+ return {
+ "ok": false,
+ "reason": "MISSING_KEYS",
+ "current_keys": total_keys,
+ "required_keys": KEY_REQUIRED_COUNT,
+ "msg": "进入冰龙巢穴需要 3 把扭曲钥匙!你当前拥有 %d 把。" % total_keys
+ }
+
+ # 扣除 3 把扭曲钥匙
+ var needed: int = KEY_REQUIRED_COUNT
+ for ks in key_slots:
+ var slot_idx: int = ks["slot"]
+ var available: int = ks["count"]
+ if available <= needed:
+ needed -= available
+ inventory[slot_idx] = null
+ else:
+ inventory[slot_idx]["count"] = available - needed
+ needed = 0
+ if needed <= 0:
+ break
+
+ # 初始化巢穴状态
+ is_active = true
+ remaining_time = TIME_LIMIT
+ dragon_hp = BOSS_MAX_HP
+ dragon_max_hp = BOSS_MAX_HP
+ is_dragon_alive = true
+ active_minions.clear()
+ regen_timer = 0.0
+
+ # 刷新 4 块龙神魔石
+ active_metins.clear()
+ for vnum in METIN_TABLE.keys():
+ active_metins[vnum] = true
+
+ dungeon_started.emit(remaining_time)
+
+ return {
+ "ok": true,
+ "time_limit": remaining_time,
+ "dragon_hp": dragon_hp,
+ "metins": active_metins.duplicate(),
+ "msg": "已使用 3 把扭曲钥匙,成功开启冰龙巢穴!限时 60 分钟!"
+ }
+
+# 获取当前冰龙实际攻击力 (受 8034 复仇之石影响)
+func get_dragon_atk() -> int:
+ var atk := BOSS_BASE_ATK
+ if active_metins.get(8034, false):
+ atk = int(atk * 1.4) # +40% ATK
+ return atk
+
+# 获取当前冰龙防御力
+func get_dragon_def() -> int:
+ return BOSS_BASE_DEF
+
+# 击破魔石机制
+func destroy_metin(metin_vnum: int) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
+
+ if not active_metins.get(metin_vnum, false):
+ return {"ok": false, "reason": "METIN_ALREADY_DESTROYED"}
+
+ active_metins[metin_vnum] = false
+ var mname: String = METIN_TABLE[metin_vnum]["name"]
+
+ # 击破魔石唤醒 4 只塞陶龙宫护卫 (vnum: 2401)
+ var spawn_count := 4
+ for i in range(spawn_count):
+ active_minions.append({"vnum": 2401, "hp": 30000})
+
+ metin_destroyed.emit(metin_vnum, mname)
+ minions_spawned.emit(spawn_count, 2401)
+
+ return {
+ "ok": true,
+ "metin_vnum": metin_vnum,
+ "metin_name": mname,
+ "remaining_metins": get_alive_metin_count(),
+ "spawned_minions": spawn_count,
+ "msg": "【%s】已被击破!对应龙神加成已破除,但唤醒了龙宫护卫!" % mname
+ }
+
+# 获取存活魔石数量
+func get_alive_metin_count() -> int:
+ var count := 0
+ for v in active_metins.values():
+ if v:
+ count += 1
+ return count
+
+# 对冰龙造成伤害
+func apply_damage(raw_dmg: int, is_magic: bool = false) -> Dictionary:
+ if not is_active or not is_dragon_alive:
+ return {"ok": false, "reason": "DRAGON_NOT_ALIVE"}
+
+ var def := get_dragon_def()
+ var net_dmg: float = max(1, raw_dmg - def)
+
+ # 8031 傲慢之石减免 30% 物理伤害
+ if not is_magic and active_metins.get(8031, false):
+ net_dmg *= 0.70
+
+ # 8033 平静之石减免 30% 魔法伤害
+ if is_magic and active_metins.get(8033, false):
+ net_dmg *= 0.70
+
+ var final_dmg := int(round(net_dmg))
+ dragon_hp = max(0, dragon_hp - final_dmg)
+
+ dragon_hp_changed.emit(dragon_hp, dragon_max_hp)
+
+ # 击杀冰龙判定
+ if dragon_hp <= 0:
+ is_dragon_alive = false
+ is_active = false
+ var drops := _generate_dragon_drops()
+ dragon_defeated.emit(drops)
+ return {
+ "ok": true,
+ "killed": true,
+ "net_dmg": final_dmg,
+ "remaining_hp": 0,
+ "drops": drops,
+ "msg": "【冰龙贝兰-塞陶】已被彻底击败!龙鳞与龙爪散落一地!"
+ }
+
+ return {
+ "ok": true,
+ "killed": false,
+ "net_dmg": final_dmg,
+ "remaining_hp": dragon_hp,
+ "hp_ratio": float(dragon_hp) / float(dragon_max_hp)
+ }
+
+# 逐帧更新 (计时与 8032 寂寞之石生命回复)
+func update(dt: float) -> void:
+ if not is_active:
+ return
+
+ remaining_time -= dt
+ if remaining_time <= 0.0:
+ is_active = false
+ is_dragon_alive = false
+ dungeon_failed.emit("TIME_OUT")
+ return
+
+ # 8032 寂寞之石:每 5 秒为冰龙回复 2% 最大生命 (16000 HP)
+ if is_dragon_alive and active_metins.get(8032, false):
+ regen_timer += dt
+ if regen_timer >= 5.0:
+ regen_timer = 0.0
+ var heal_amount := int(BOSS_MAX_HP * 0.02)
+ dragon_hp = mini(BOSS_MAX_HP, dragon_hp + heal_amount)
+ dragon_hp_changed.emit(dragon_hp, dragon_max_hp)
+
+# 生成冰龙掉落物 (71123 龙鳞, 71129 龙爪, 50186 水龙宝箱)
+func _generate_dragon_drops() -> Array:
+ var drops: Array = []
+
+ # 1. 必掉史诗升阶材料:龙鳞 (71123)
+ drops.append({
+ "vnum": 71123,
+ "name": "龙鳞",
+ "count": randi_range(1, 2)
+ })
+
+ # 2. 必掉史诗升阶材料:龙爪 (71129)
+ drops.append({
+ "vnum": 71129,
+ "name": "龙爪",
+ "count": randi_range(1, 2)
+ })
+
+ # 3. 必掉水龙专属宝箱 (50186)
+ drops.append({
+ "vnum": 50186,
+ "name": "贝兰-塞陶宝箱",
+ "count": 1
+ })
+
+ # 4. 必掉灵魂之石 (50513)
+ drops.append({
+ "vnum": 50513,
+ "name": "灵魂之石",
+ "count": 1
+ })
+
+ # 5. 大额金币掉落
+ drops.append({
+ "vnum": 1,
+ "name": "金币",
+ "count": randi_range(150000, 300000)
+ })
+
+ return drops
diff --git a/project/dragon_lair_system.gd.uid b/project/dragon_lair_system.gd.uid
new file mode 100644
index 00000000..98990c74
--- /dev/null
+++ b/project/dragon_lair_system.gd.uid
@@ -0,0 +1 @@
+uid://bp1qvx4apa2no
diff --git a/project/dragon_soul_system.gd b/project/dragon_soul_system.gd
new file mode 100644
index 00000000..5592d56b
--- /dev/null
+++ b/project/dragon_soul_system.gd
@@ -0,0 +1,215 @@
+# dragon_soul_system.gd —— Metin2 40250 龙神炼金石系统 1:1
+# 对照 40250 服务端 DragonSoul.cpp, char_dragonsoul.cpp 及客户端 uidragonsoul.py
+class_name DragonSoulSystem
+extends RefCounted
+
+signal deck_activated(deck_idx: int)
+signal deck_deactivated(deck_idx: int)
+
+# 6 种官方龙神石类型 (DragonSoul.cpp:EDSStoneTypes)
+enum DragonStoneType {
+ NONE = 0,
+ DIAMOND = 1, # 钻石: INT, 魔法抗性
+ RUBY = 2, # 红宝石: STR, 物理攻击力, 防御力
+ JADE = 3, # 玉石: Max HP, HP 恢复
+ SAPPHIRE = 4,# 蓝宝石: DEX, 职业减伤
+ GARNET = 5, # 石榴石: CON, Max SP
+ ONYX = 6 # 缟玛瑙: 格挡, 反弹, 穿透抵抗
+}
+
+# 5 档品阶 (Grade)
+enum Grade {
+ ROUGH = 0, # 粗糙
+ CUT = 1, # 切割
+ RARE = 2, # 稀有
+ ANCIENT = 3, # 古代
+ LEGENDARY = 4 # 传奇
+}
+
+# 5 档净度 (Clarity)
+enum Clarity {
+ MATT = 0, # 哑光
+ CLEAR = 1, # 清晰
+ FLAWLESS = 2, # 无瑕
+ BRILLIANT = 3, # 辉煌
+ EXCELLENT = 4 # 卓越
+}
+
+const DECK_SKY := 0 # 天空卡槽
+const DECK_EARTH := 1 # 大地卡槽
+const DECK_COUNT := 2
+const SLOTS_PER_DECK := 6 # 每卡槽 6 个神石槽位
+
+# 双卡槽数据结构: decks[deck_idx][slot_idx] = stone_dict or null
+var decks: Array[Array] = [[], []]
+var active_deck: int = -1 # -1: 未激活, 0: 天空卡槽, 1: 大地卡槽
+
+func _init() -> void:
+ for d in range(DECK_COUNT):
+ var slot_list: Array = []
+ for s in range(SLOTS_PER_DECK):
+ slot_list.append(null)
+ decks[d] = slot_list
+
+# 创建一颗龙神石
+static func create_dragon_stone(type: int, grade: int = 0, clarity: int = 0, refine: int = 0, duration: float = 86400.0) -> Dictionary:
+ var stone: Dictionary = {
+ "stone_type": type,
+ "grade": clampi(grade, 0, 4),
+ "clarity": clampi(clarity, 0, 4),
+ "refine": clampi(refine, 0, 6),
+ "duration": duration, # 激活消耗时间 (秒)
+ "total_duration": duration
+ }
+ stone["attributes"] = calculate_stone_attributes(stone)
+ return stone
+
+# 计算单颗神石属性 (对齐 40250 DragonSoulTable.txt)
+static func calculate_stone_attributes(stone: Dictionary) -> Dictionary:
+ var type: int = int(stone.get("stone_type", 0))
+ var gr: int = int(stone.get("grade", 0))
+ var cl: int = int(stone.get("clarity", 0))
+ var rf: int = int(stone.get("refine", 0))
+
+ var attrs: Dictionary = {}
+
+ match type:
+ DragonStoneType.DIAMOND:
+ attrs["int_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
+ attrs["magic_defense"] = 2 + (gr * 2) + (cl * 2) + (rf * 2)
+ DragonStoneType.RUBY:
+ attrs["str_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
+ attrs["att_grade"] = 20 + (gr * 15) + (cl * 10) + (rf * 5)
+ attrs["def_grade"] = 15 + (gr * 10) + (cl * 5) + (rf * 4)
+ DragonStoneType.JADE:
+ attrs["max_hp"] = 300 + (gr * 250) + (cl * 150) + (rf * 100)
+ attrs["hp_recovery_pct"] = 3 + gr + cl + rf
+ DragonStoneType.SAPPHIRE:
+ attrs["dex_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
+ attrs["resist_warrior"] = 2 + gr + cl + rf
+ attrs["resist_assassin"] = 2 + gr + cl + rf
+ DragonStoneType.GARNET:
+ attrs["con_bonus"] = 5 + (gr * 3) + (cl * 2) + rf
+ attrs["max_sp"] = 200 + (gr * 150) + (cl * 100) + (rf * 50)
+ DragonStoneType.ONYX:
+ attrs["block_pct"] = 2 + (gr * 2) + cl + rf
+ attrs["reflect_pct"] = 2 + (gr * 2) + cl + rf
+
+ return attrs
+
+# 装备神石到卡槽
+func equip_stone(deck_idx: int, stone: Dictionary) -> Dictionary:
+ if deck_idx < 0 or deck_idx >= DECK_COUNT:
+ return {"ok": false, "reason": "INVALID_DECK"}
+
+ var type: int = int(stone.get("stone_type", 0))
+ if type < 1 or type > 6:
+ return {"ok": false, "reason": "INVALID_STONE_TYPE"}
+
+ var slot_idx: int = type - 1 # 1~6 映射到 0~5 号槽位
+ decks[deck_idx][slot_idx] = stone
+
+ return {
+ "ok": true,
+ "deck": deck_idx,
+ "slot": slot_idx,
+ "stone": stone
+ }
+
+# 卸下神石
+func unequip_stone(deck_idx: int, slot_idx: int) -> Dictionary:
+ if deck_idx < 0 or deck_idx >= DECK_COUNT:
+ return {"ok": false, "reason": "INVALID_DECK"}
+ if slot_idx < 0 or slot_idx >= SLOTS_PER_DECK:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var removed = decks[deck_idx][slot_idx]
+ decks[deck_idx][slot_idx] = null
+ return {"ok": true, "stone": removed}
+
+# 激活/停用卡槽
+func toggle_deck(deck_idx: int) -> bool:
+ if active_deck == deck_idx:
+ active_deck = -1
+ deck_deactivated.emit(deck_idx)
+ return false
+ else:
+ if active_deck != -1:
+ deck_deactivated.emit(active_deck)
+ active_deck = deck_idx
+ deck_activated.emit(deck_idx)
+ return true
+
+# 计算当前已激活卡槽的总战力属性
+func get_active_bonuses() -> Dictionary:
+ var total_bonuses: Dictionary = {}
+ if active_deck < 0 or active_deck >= DECK_COUNT:
+ return total_bonuses
+
+ var deck: Array = decks[active_deck]
+ for slot in deck:
+ if slot != null and float(slot.get("duration", 0.0)) > 0.0:
+ var attrs: Dictionary = slot.get("attributes", {})
+ for key in attrs:
+ total_bonuses[key] = int(total_bonuses.get(key, 0)) + int(attrs[key])
+
+ return total_bonuses
+
+# 逐帧消耗激活中的龙神石能量时间
+func update(dt: float) -> Array:
+ var expired_stones: Array = []
+ if active_deck < 0 or active_deck >= DECK_COUNT:
+ return expired_stones
+
+ var deck: Array = decks[active_deck]
+ for i in range(deck.size()):
+ var stone = deck[i]
+ if stone != null:
+ var dur: float = float(stone.get("duration", 0.0))
+ dur -= dt
+ if dur <= 0.0:
+ dur = 0.0
+ expired_stones.append({"deck": active_deck, "slot": i, "stone": stone})
+ stone["duration"] = dur
+
+ return expired_stones
+
+# 升阶 (Grade Refine): 2 颗同种类、同品阶神石融合升级
+static func refine_grade(stone_a: Dictionary, stone_b: Dictionary, force_success: bool = false) -> Dictionary:
+ var ta: int = int(stone_a.get("stone_type", 0))
+ var tb: int = int(stone_b.get("stone_type", 0))
+ var ga: int = int(stone_a.get("grade", 0))
+ var gb: int = int(stone_b.get("grade", 0))
+
+ if ta != tb or ga != gb:
+ return {"ok": false, "reason": "MISMATCHED_STONES", "msg": "只有同种类且同品阶的神石才能融合升阶!"}
+ if ga >= Grade.LEGENDARY:
+ return {"ok": false, "reason": "MAX_GRADE", "msg": "该神石已达到最高传奇品阶!"}
+
+ # 成功率 60%
+ var success: bool = force_success or randf() < 0.60
+ if success:
+ var new_stone = create_dragon_stone(ta, ga + 1, 0, 0)
+ return {"ok": true, "success": true, "stone": new_stone, "msg": "神石品阶提升成功!"}
+ else:
+ return {"ok": true, "success": false, "msg": "升阶失败,融合材料已碎裂。"}
+
+# 升净度 (Clarity Refine): 2 颗同种类、同品阶、同净度神石融合升级
+static func refine_clarity(stone_a: Dictionary, stone_b: Dictionary, force_success: bool = false) -> Dictionary:
+ var ta: int = int(stone_a.get("stone_type", 0))
+ var tb: int = int(stone_b.get("stone_type", 0))
+ var ca: int = int(stone_a.get("clarity", 0))
+ var cb: int = int(stone_b.get("clarity", 0))
+
+ if ta != tb or ca != cb:
+ return {"ok": false, "reason": "MISMATCHED_STONES", "msg": "只有同种类且同净度的神石才能融合提升净度!"}
+ if ca >= Clarity.EXCELLENT:
+ return {"ok": false, "reason": "MAX_CLARITY", "msg": "该神石已达到最高卓越净度!"}
+
+ var success: bool = force_success or randf() < 0.50
+ if success:
+ var ga: int = int(stone_a.get("grade", 0))
+ var new_stone = create_dragon_stone(ta, ga, ca + 1, 0)
+ return {"ok": true, "success": true, "stone": new_stone, "msg": "神石净度提升成功!"}
+ else:
+ return {"ok": true, "success": false, "msg": "净度提升失败,辅助材料碎裂。"}
diff --git a/project/dragon_soul_system.gd.uid b/project/dragon_soul_system.gd.uid
new file mode 100644
index 00000000..565c5d7e
--- /dev/null
+++ b/project/dragon_soul_system.gd.uid
@@ -0,0 +1 @@
+uid://ia8t4ldbjqhp
diff --git a/project/dump_pixels.gd b/project/dump_pixels.gd
new file mode 100644
index 00000000..73e50f6b
--- /dev/null
+++ b/project/dump_pixels.gd
@@ -0,0 +1,16 @@
+extends SceneTree
+
+const UiAssets = preload("res://ui/ui_assets.gd")
+
+func _init() -> void:
+ var root := "../assets"
+ var img: Image = UiAssets.load_dds_image("../assets/ETC/ymir work/ui/public.dds")
+ if img:
+ print("Loaded public.dds: size=", img.get_size(), " format=", img.get_format())
+ for y in range(232, 240):
+ var row := ""
+ for x in range(121, 130):
+ var p := img.get_pixel(x, y)
+ row += "(%.1f,%.1f,%.1f,%.1f) " % [p.r, p.g, p.b, p.a]
+ print("y=", y, ": ", row)
+ quit(0)
diff --git a/project/dump_pixels.gd.uid b/project/dump_pixels.gd.uid
new file mode 100644
index 00000000..b601c6ca
--- /dev/null
+++ b/project/dump_pixels.gd.uid
@@ -0,0 +1 @@
+uid://6bv05bdsw8wh
diff --git a/project/dungeon_ranking_system.gd b/project/dungeon_ranking_system.gd
new file mode 100644
index 00000000..19ab9c2e
--- /dev/null
+++ b/project/dungeon_ranking_system.gd
@@ -0,0 +1,133 @@
+# dungeon_ranking_system.gd —— Metin2 40250 地牢信息面板与最速通关记录系统 1:1
+# 对照 40250 服务端与客户端 dungeon_info.cpp, dungeon.cpp, uidungeoninfo.py
+class_name DungeonRankingSystem
+extends RefCounted
+
+signal record_updated(dungeon_id: String, fastest_time: float, total_clears: int)
+signal new_fastest_record(dungeon_id: String, new_time: float, old_time: float)
+
+# 40250 官方经典地牢登记表
+const DUNGEON_REGISTRY: Dictionary = {
+ "demon_tower": {
+ "name": "恶魔之塔",
+ "min_level": 40,
+ "ticket_vnum": 0,
+ "ticket_count": 0,
+ "ticket_name": "无",
+ "cooldown_seconds": 0.0
+ },
+ "spider_dungeon_3": {
+ "name": "蜘蛛洞穴三层",
+ "min_level": 50,
+ "ticket_vnum": 30327,
+ "ticket_count": 1,
+ "ticket_name": "蜘蛛钥匙",
+ "cooldown_seconds": 1800.0 # 30 分钟
+ },
+ "dragon_lair": {
+ "name": "冰龙贝兰巢穴",
+ "min_level": 75,
+ "ticket_vnum": 30179,
+ "ticket_count": 3,
+ "ticket_name": "扭曲钥匙",
+ "cooldown_seconds": 3600.0 # 60 分钟
+ },
+ "devils_catacomb": {
+ "name": "地下墓穴",
+ "min_level": 75,
+ "ticket_vnum": 30311,
+ "ticket_count": 1,
+ "ticket_name": "灵魂水晶钥匙",
+ "cooldown_seconds": 3600.0 # 60 分钟
+ }
+}
+
+# 个人通关数据字典: dungeon_id -> Dictionary
+var dungeon_records: Dictionary = {}
+
+func _init() -> void:
+ for d_id in DUNGEON_REGISTRY.keys():
+ dungeon_records[d_id] = {
+ "total_clears": 0,
+ "fastest_clear_time": 999999.0, # 初始极大值
+ "highest_peak_damage": 0,
+ "last_clear_timestamp": 0.0
+ }
+
+# 记录通关结算
+func record_clear(
+ dungeon_id: String,
+ clear_time_sec: float,
+ peak_damage: int,
+ current_timestamp: float = 0.0
+) -> Dictionary:
+ if not DUNGEON_REGISTRY.has(dungeon_id):
+ return {"ok": false, "reason": "INVALID_DUNGEON_ID", "msg": "未知的地牢!"}
+
+ var rec = dungeon_records[dungeon_id]
+ rec["total_clears"] += 1
+ rec["last_clear_timestamp"] = current_timestamp
+
+ # 检查并更新最高单次输出伤害
+ if peak_damage > int(rec["highest_peak_damage"]):
+ rec["highest_peak_damage"] = peak_damage
+
+ # 检查是否刷新最速通关记录
+ var is_new_record := false
+ var old_time: float = float(rec["fastest_clear_time"])
+ if clear_time_sec < old_time:
+ is_new_record = true
+ rec["fastest_clear_time"] = clear_time_sec
+ new_fastest_record.emit(dungeon_id, clear_time_sec, old_time)
+
+ record_updated.emit(dungeon_id, rec["fastest_clear_time"], rec["total_clears"])
+
+ var dname: String = DUNGEON_REGISTRY[dungeon_id]["name"]
+ return {
+ "ok": true,
+ "dungeon_id": dungeon_id,
+ "dungeon_name": dname,
+ "clear_time": clear_time_sec,
+ "is_new_record": is_new_record,
+ "fastest_time": rec["fastest_clear_time"],
+ "total_clears": rec["total_clears"],
+ "highest_peak_damage": rec["highest_peak_damage"],
+ "msg": "【%s】通关成功!耗时 %.1f 秒%s!" % [dname, clear_time_sec, " (刷新最快纪录!)" if is_new_record else ""]
+ }
+
+# 获取地牢详情与入场/冷却状态
+func get_dungeon_info(dungeon_id: String, current_timestamp: float = 0.0) -> Dictionary:
+ if not DUNGEON_REGISTRY.has(dungeon_id):
+ return {"ok": false, "reason": "INVALID_DUNGEON_ID"}
+
+ var cfg = DUNGEON_REGISTRY[dungeon_id]
+ var rec = dungeon_records[dungeon_id]
+
+ # 判定冷却时间
+ var cd_sec: float = float(cfg["cooldown_seconds"])
+ var last_ts: float = float(rec["last_clear_timestamp"])
+ var elapsed := current_timestamp - last_ts
+ var is_on_cooldown := (cd_sec > 0.0 and last_ts > 0.0 and elapsed < cd_sec)
+ var remaining_cd := maxf(0.0, cd_sec - elapsed) if is_on_cooldown else 0.0
+
+ return {
+ "ok": true,
+ "dungeon_id": dungeon_id,
+ "name": cfg["name"],
+ "min_level": cfg["min_level"],
+ "ticket_vnum": cfg["ticket_vnum"],
+ "ticket_count": cfg["ticket_count"],
+ "ticket_name": cfg["ticket_name"],
+ "is_on_cooldown": is_on_cooldown,
+ "remaining_cooldown": remaining_cd,
+ "total_clears": rec["total_clears"],
+ "fastest_clear_time": rec["fastest_clear_time"] if rec["fastest_clear_time"] < 999999.0 else 0.0,
+ "highest_peak_damage": rec["highest_peak_damage"]
+ }
+
+# 获取全部地牢摘要速览列表
+func get_all_dungeon_summaries(current_timestamp: float = 0.0) -> Array:
+ var list: Array = []
+ for d_id in DUNGEON_REGISTRY.keys():
+ list.append(get_dungeon_info(d_id, current_timestamp))
+ return list
diff --git a/project/dungeon_ranking_system.gd.uid b/project/dungeon_ranking_system.gd.uid
new file mode 100644
index 00000000..6b4b560d
--- /dev/null
+++ b/project/dungeon_ranking_system.gd.uid
@@ -0,0 +1 @@
+uid://c4msy78ir4a3v
diff --git a/project/dynamic_bounty_event_system.gd b/project/dynamic_bounty_event_system.gd
new file mode 100644
index 00000000..15b163b5
--- /dev/null
+++ b/project/dynamic_bounty_event_system.gd
@@ -0,0 +1,163 @@
+# dynamic_bounty_event_system.gd —— Metin2 40250 单人单机动态事件与赏金通缉令系统 1:1
+# 对照 40250 服务端 questmanager.cpp, char.cpp, quest.h
+class_name DynamicBountyEventSystem
+extends RefCounted
+
+signal bounties_refreshed(bounty_list: Array)
+signal bounty_progress_updated(bounty_id: String, current: int, target: int)
+signal bounty_completed(bounty_id: String, title: String)
+signal bounty_reward_claimed(bounty_id: String, exp: int, gold: int)
+
+const BOUNTY_TYPE_HUNT := "hunt" # 怪物狩猎
+const BOUNTY_TYPE_METIN := "metin" # 魔石破除
+const BOUNTY_TYPE_BOSS := "boss" # 首领讨伐
+
+const BOUNTY_CHEST_VNUM := 50180 # 赏金猎人宝箱 (Bounty Chest)
+
+# 活跃悬赏任务字典: id -> Dictionary
+var active_bounties: Dictionary = {}
+var total_completed_count: int = 0
+
+# 每日自动生成/刷新 3 条特色赏金任务
+func refresh_daily_bounties() -> Array:
+ active_bounties.clear()
+
+ # 1. 狩猎任务 (野蛮士兵 501, 目标 25 只)
+ var b1: Dictionary = {
+ "id": "bounty_hunt_barbarian",
+ "type": BOUNTY_TYPE_HUNT,
+ "title": "【通缉令】清剿荒原野蛮士兵",
+ "target_vnum": 501,
+ "target_count": 25,
+ "current_count": 0,
+ "is_completed": false,
+ "is_claimed": false,
+ "rewards": {
+ "exp": 150000,
+ "gold": 500000,
+ "items": [{"vnum": 27003, "name": "红色药水(特大)", "count": 100}]
+ }
+ }
+
+ # 2. 破灭魔石任务 (嫉妒之石 8008, 目标 2 颗)
+ var b2: Dictionary = {
+ "id": "bounty_metin_jealousy",
+ "type": BOUNTY_TYPE_METIN,
+ "title": "【天灾通缉】破灭野外嫉妒魔石",
+ "target_vnum": 8008,
+ "target_count": 2,
+ "current_count": 0,
+ "is_completed": false,
+ "is_claimed": false,
+ "rewards": {
+ "exp": 300000,
+ "gold": 1200000,
+ "items": [{"vnum": 50300, "name": "技能书", "count": 1}]
+ }
+ }
+
+ # 3. 讨伐世界首领 (九尾妖狐 1901, 目标 1 次)
+ var b3: Dictionary = {
+ "id": "bounty_boss_ninetails",
+ "type": BOUNTY_TYPE_BOSS,
+ "title": "【大统领诛杀令】斩落雪山九尾妖狐",
+ "target_vnum": 1901,
+ "target_count": 1,
+ "current_count": 0,
+ "is_completed": false,
+ "is_claimed": false,
+ "rewards": {
+ "exp": 1000000,
+ "gold": 3000000,
+ "items": [
+ {"vnum": BOUNTY_CHEST_VNUM, "name": "赏金猎人宝箱", "count": 1},
+ {"vnum": 71003, "name": "祝福卷轴", "count": 1}
+ ]
+ }
+ }
+
+ active_bounties[b1["id"]] = b1
+ active_bounties[b2["id"]] = b2
+ active_bounties[b3["id"]] = b3
+
+ var list = [b1, b2, b3]
+ bounties_refreshed.emit(list)
+ return list
+
+# 怪物击杀侦测挂钩
+func on_mob_killed(mob_vnum: int, count: int = 1) -> void:
+ for bid in active_bounties.keys():
+ var b = active_bounties[bid]
+ if b["is_completed"]:
+ continue
+
+ if (b["type"] == BOUNTY_TYPE_HUNT or b["type"] == BOUNTY_TYPE_BOSS) and b["target_vnum"] == mob_vnum:
+ b["current_count"] = min(b["target_count"], b["current_count"] + count)
+ bounty_progress_updated.emit(bid, b["current_count"], b["target_count"])
+
+ if b["current_count"] >= b["target_count"]:
+ b["is_completed"] = true
+ total_completed_count += 1
+ bounty_completed.emit(bid, b["title"])
+
+# 魔石击碎侦测挂钩
+func on_metin_destroyed(metin_vnum: int) -> void:
+ for bid in active_bounties.keys():
+ var b = active_bounties[bid]
+ if b["is_completed"]:
+ continue
+
+ if b["type"] == BOUNTY_TYPE_METIN and b["target_vnum"] == metin_vnum:
+ b["current_count"] = min(b["target_count"], b["current_count"] + 1)
+ bounty_progress_updated.emit(bid, b["current_count"], b["target_count"])
+
+ if b["current_count"] >= b["target_count"]:
+ b["is_completed"] = true
+ total_completed_count += 1
+ bounty_completed.emit(bid, b["title"])
+
+# 领取赏金通缉令奖励
+func claim_bounty_reward(bounty_id: String, player_data: Dictionary, inventory: Array) -> Dictionary:
+ if not active_bounties.has(bounty_id):
+ return {"ok": false, "reason": "BOUNTY_NOT_FOUND", "msg": "未找到该通缉令!"}
+
+ var b = active_bounties[bounty_id]
+ if not b["is_completed"]:
+ return {"ok": false, "reason": "NOT_COMPLETED", "msg": "该通缉令目标尚未达成!"}
+
+ if b["is_claimed"]:
+ return {"ok": false, "reason": "ALREADY_CLAIMED", "msg": "该通缉令赏金已被领取!"}
+
+ var rew = b["rewards"]
+ var exp_gain: int = rew.get("exp", 0)
+ var gold_gain: int = rew.get("gold", 0)
+ var items: Array = rew.get("items", [])
+
+ # 发放经验与金币
+ player_data["exp"] = int(player_data.get("exp", 0)) + exp_gain
+ player_data["gold"] = int(player_data.get("gold", 0)) + gold_gain
+
+ # 物品入包
+ var granted_items := []
+ for it in items:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = it.duplicate(true)
+ granted_items.append(it["name"])
+ break
+
+ b["is_claimed"] = true
+ bounty_reward_claimed.emit(bounty_id, exp_gain, gold_gain)
+
+ return {
+ "ok": true,
+ "bounty_id": bounty_id,
+ "exp_gain": exp_gain,
+ "gold_gain": gold_gain,
+ "items_granted": granted_items,
+ "msg": "通缉赏金核销!获得 %d 经验、%d 金币以及丰厚物资!" % [exp_gain, gold_gain]
+ }
+
+# 获取所有活跃悬赏
+func get_active_bounties() -> Array:
+ return active_bounties.values()
diff --git a/project/dynamic_bounty_event_system.gd.uid b/project/dynamic_bounty_event_system.gd.uid
new file mode 100644
index 00000000..3b434ccc
--- /dev/null
+++ b/project/dynamic_bounty_event_system.gd.uid
@@ -0,0 +1 @@
+uid://dmh3ypb8w0pwt
diff --git a/project/emotion_action_system.gd b/project/emotion_action_system.gd
new file mode 100644
index 00000000..9102eb41
--- /dev/null
+++ b/project/emotion_action_system.gd
@@ -0,0 +1,99 @@
+# emotion_action_system.gd —— 角色情绪动作与表情快捷键(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 root/emotion.py (EMOTION_DICT, EMOTION_CLAP..DANCE_6)
+# - 40250 server/game/src/cmd_emotion.cpp
+# - 40250 root/game.py: __PressNumKey (Ctrl + 1..9)
+class_name EmotionActionSystem
+extends RefCounted
+
+# 40250 官方情绪动作常量
+const EMOTION_CLAP := 1
+const EMOTION_CONGRATULATION := 2
+const EMOTION_FORGIVE := 3
+const EMOTION_ANGRY := 4
+const EMOTION_ATTRACTIVE := 5
+const EMOTION_SAD := 6
+const EMOTION_SHY := 7
+const EMOTION_CHEERUP := 8
+const EMOTION_BANTER := 9
+const EMOTION_JOY := 10
+const EMOTION_CHEERS_1 := 11
+const EMOTION_CHEERS_2 := 12
+const EMOTION_DANCE_1 := 13
+const EMOTION_DANCE_2 := 14
+const EMOTION_DANCE_3 := 15
+const EMOTION_DANCE_4 := 16
+const EMOTION_DANCE_5 := 17
+const EMOTION_DANCE_6 := 18
+
+# 情绪字典表 (对齐 emotion.py)
+const EMOTION_TABLE := {
+ EMOTION_CLAP: {"name": "鼓掌", "command": "/clap", "motion": "clap"},
+ EMOTION_CONGRATULATION: {"name": "祝贺", "command": "/congratulation", "motion": "congratulation"},
+ EMOTION_FORGIVE: {"name": "原谅", "command": "/forgive", "motion": "forgive"},
+ EMOTION_ANGRY: {"name": "愤怒", "command": "/angry", "motion": "angry"},
+ EMOTION_ATTRACTIVE: {"name": "飞吻", "command": "/attractive", "motion": "attractive"},
+ EMOTION_SAD: {"name": "悲伤", "command": "/sad", "motion": "sad"},
+ EMOTION_SHY: {"name": "害羞", "command": "/shy", "motion": "shy"},
+ EMOTION_CHEERUP: {"name": "加油", "command": "/cheerup", "motion": "cheerup"},
+ EMOTION_BANTER: {"name": "嘲讽", "command": "/banter", "motion": "banter"},
+ EMOTION_JOY: {"name": "喜悦", "command": "/joy", "motion": "joy"},
+ EMOTION_CHEERS_1: {"name": "欢呼1", "command": "/cheer1", "motion": "cheers_1"},
+ EMOTION_CHEERS_2: {"name": "欢呼2", "command": "/cheer2", "motion": "cheers_2"},
+ EMOTION_DANCE_1: {"name": "跳舞1", "command": "/dance1", "motion": "dance_1"},
+ EMOTION_DANCE_2: {"name": "跳舞2", "command": "/dance2", "motion": "dance_2"},
+ EMOTION_DANCE_3: {"name": "跳舞3", "command": "/dance3", "motion": "dance_3"},
+ EMOTION_DANCE_4: {"name": "跳舞4", "command": "/dance4", "motion": "dance_4"},
+ EMOTION_DANCE_5: {"name": "跳舞5", "command": "/dance5", "motion": "dance_5"},
+ EMOTION_DANCE_6: {"name": "跳舞6", "command": "/dance6", "motion": "dance_6"},
+}
+
+## 检查是否为有效的情绪动作 ID
+static func is_valid_emotion(emotion_id: int) -> bool:
+ return EMOTION_TABLE.has(emotion_id)
+
+## 通过聊天命令解析情绪 (例如 "/clap" -> 1)
+static func get_emotion_from_command(cmd_text: String) -> int:
+ var clean := cmd_text.strip_edges().to_lower()
+ for id in EMOTION_TABLE.keys():
+ if EMOTION_TABLE[id]["command"] == clean:
+ return id
+ return 0
+
+## 通过快捷键序号获取情绪 (0..8 对应 Ctrl + 1..9)
+static func get_hotkey_emotion(hotkey_idx: int) -> int:
+ # 40250 官方快捷栏默认前 9 个动作
+ var list := [
+ EMOTION_CLAP,
+ EMOTION_CONGRATULATION,
+ EMOTION_FORGIVE,
+ EMOTION_ANGRY,
+ EMOTION_ATTRACTIVE,
+ EMOTION_SAD,
+ EMOTION_SHY,
+ EMOTION_CHEERUP,
+ EMOTION_BANTER,
+ ]
+ if hotkey_idx >= 0 and hotkey_idx < list.size():
+ return list[hotkey_idx]
+ return 0
+
+## 执行情绪动作
+static func perform_emotion(emotion_id: int, is_fighting := false, is_mounted := false) -> Dictionary:
+ if not is_valid_emotion(emotion_id):
+ return {"ok": false, "code": "INVALID_EMOTION", "msg": "未知的动作!"}
+ if is_fighting:
+ return {"ok": false, "code": "CANNOT_IN_BATTLE", "msg": "战斗中无法做出情绪动作!"}
+ if is_mounted:
+ return {"ok": false, "code": "CANNOT_ON_MOUNT", "msg": "骑乘状态下无法做出该动作!"}
+
+ var em: Dictionary = EMOTION_TABLE[emotion_id]
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "emotion_id": emotion_id,
+ "name": em["name"],
+ "motion": em["motion"],
+ "command": em["command"],
+ "msg": "执行了【%s】动作。" % em["name"]
+ }
diff --git a/project/emotion_action_system.gd.uid b/project/emotion_action_system.gd.uid
new file mode 100644
index 00000000..e6031ec8
--- /dev/null
+++ b/project/emotion_action_system.gd.uid
@@ -0,0 +1 @@
+uid://c7ffckxgf2fyf
diff --git a/project/empire_kingdom_system.gd b/project/empire_kingdom_system.gd
new file mode 100644
index 00000000..b8e3216b
--- /dev/null
+++ b/project/empire_kingdom_system.gd
@@ -0,0 +1,213 @@
+# empire_kingdom_system.gd —— Metin2 40250 三圣帝国国家归属与领地国运系统 1:1
+# 对照 40250 服务端 empire.cpp, questlua_empire.cpp, root/introempire.py
+class_name EmpireKingdomSystem
+extends RefCounted
+
+signal empire_selected(empire_id: int, empire_name: String)
+signal empire_blessing_activated(empire_id: int, blessings: Dictionary)
+signal empire_changed(old_empire: int, new_empire: int)
+signal territory_entered(map_name: String, is_home: bool, is_hostile: bool)
+
+# 40250 官方三大帝国 ID
+const EMPIRE_NONE := 0
+const EMPIRE_SHINSOO := 1 # 慎修帝国 (Shinsoo / 红国)
+const EMPIRE_CHUNJO := 2 # 准照帝国 (Chunjo / 黄国)
+const EMPIRE_JINNO := 3 # 晋诺帝国 (Jinno / 蓝国)
+
+const VNUM_TINCTURE_OF_EMPIRES := 71054 # 转国卷轴 (Tincture of Empires)
+
+# 三大帝国官方档案与专属祈福特性表
+const EMPIRES_INFO: Dictionary = {
+ EMPIRE_SHINSOO: {
+ "id": EMPIRE_SHINSOO,
+ "name": "慎修帝国",
+ "color": "红国",
+ "capital": "永安省 (Yongan)",
+ "desc": "南方沿海商业贸易帝国,商贾云集,财富充盈。",
+ "home_maps": ["Yongan", "Jayang"],
+ "blessings": {
+ "gold_bonus_pct": 10, # 金币获取 +10%
+ "shop_discount_pct": 5 # 商店折扣 5%
+ }
+ },
+ EMPIRE_CHUNJO: {
+ "id": EMPIRE_CHUNJO,
+ "name": "准照帝国",
+ "color": "黄国",
+ "capital": "准安省 (Joan)",
+ "desc": "西方高原神圣宗教帝国,受大祭司与神官庇护,信仰虔诚。",
+ "home_maps": ["Joan", "Bokjung"],
+ "blessings": {
+ "cast_speed": 10, # 施法速度 +10
+ "cooldown_reduction_pct": 5 # 技能冷却缩减 5%
+ }
+ },
+ EMPIRE_JINNO: {
+ "id": EMPIRE_JINNO,
+ "name": "晋诺帝国",
+ "color": "蓝国",
+ "capital": "平武省 (Pyungmoo)",
+ "desc": "东方雄关铁血军事帝国,崇尚武道,兵强马壮。",
+ "home_maps": ["Pyungmoo", "Bakra"],
+ "blessings": {
+ "attack_bonus": 50, # 物理攻击 +50
+ "monster_damage_pct": 5 # 对怪增伤 5%
+ }
+ }
+}
+
+var current_empire: int = EMPIRE_NONE
+var is_blessing_active: bool = false
+var blessing_time_remaining: float = 0.0 # 祈福持续时间 (秒)
+const BLESSING_DURATION: float = 86400.0 # 24 小时 (86400 秒)
+
+# 初始选择国家
+func select_empire(empire_id: int) -> Dictionary:
+ if not EMPIRES_INFO.has(empire_id):
+ return {"ok": false, "reason": "INVALID_EMPIRE", "msg": "无效的帝国代码!"}
+
+ if current_empire != EMPIRE_NONE:
+ return {"ok": false, "reason": "ALREADY_HAS_EMPIRE", "msg": "已有国家归属,需使用转国卷轴!"}
+
+ current_empire = empire_id
+ var info: Dictionary = EMPIRES_INFO[current_empire]
+ empire_selected.emit(current_empire, info["name"])
+
+ return {
+ "ok": true,
+ "empire_id": current_empire,
+ "name": info["name"],
+ "capital": info["capital"],
+ "msg": "效忠【%s】成功!开启帝国护国征途。" % info["name"]
+ }
+
+# 激活每日帝国国运祈福 (Daily Empire Blessing)
+func activate_empire_prayer(player_stats: Dictionary) -> Dictionary:
+ if current_empire == EMPIRE_NONE:
+ return {"ok": false, "reason": "NO_EMPIRE", "msg": "尚未加入任何帝国!"}
+
+ if is_blessing_active:
+ # 刷新时长
+ blessing_time_remaining = BLESSING_DURATION
+ return {"ok": true, "msg": "国运祈福时长已刷新为 24 小时!"}
+
+ var blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
+ _apply_blessings_to_player(blessings, player_stats, 1)
+
+ is_blessing_active = true
+ blessing_time_remaining = BLESSING_DURATION
+
+ empire_blessing_activated.emit(current_empire, blessings)
+
+ return {
+ "ok": true,
+ "empire_id": current_empire,
+ "blessings": blessings,
+ "duration": BLESSING_DURATION,
+ "msg": "获得【%s】护国祈福!全境增益持续 24 小时。" % EMPIRES_INFO[current_empire]["name"]
+ }
+
+# 倒计时推进
+func update(delta: float, player_stats: Dictionary) -> void:
+ if is_blessing_active:
+ blessing_time_remaining -= delta
+ if blessing_time_remaining <= 0.0:
+ # 祈福过期
+ var blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
+ _apply_blessings_to_player(blessings, player_stats, -1)
+ is_blessing_active = false
+ blessing_time_remaining = 0.0
+
+# 领地巡检:判断玩家在某地图是否处于敌国入侵状态
+func check_territory(map_name: String) -> Dictionary:
+ if current_empire == EMPIRE_NONE:
+ return {"is_home": false, "is_hostile": false, "relation": "neutral"}
+
+ var my_home_maps: Array = EMPIRES_INFO[current_empire]["home_maps"]
+ if my_home_maps.has(map_name):
+ territory_entered.emit(map_name, true, false)
+ return {"is_home": true, "is_hostile": false, "relation": "homeland"}
+
+ # 检查是否为其他两个帝国的本土领地
+ for other_id in [EMPIRE_SHINSOO, EMPIRE_CHUNJO, EMPIRE_JINNO]:
+ if other_id == current_empire:
+ continue
+ var foreign_maps: Array = EMPIRES_INFO[other_id]["home_maps"]
+ if foreign_maps.has(map_name):
+ territory_entered.emit(map_name, false, true)
+ return {
+ "is_home": false,
+ "is_hostile": true,
+ "hostile_empire": EMPIRES_INFO[other_id]["name"],
+ "relation": "hostile_invasion"
+ }
+
+ # 中立公共地图 (如沙漠、雪山、烈焰、神殿)
+ territory_entered.emit(map_name, false, false)
+ return {"is_home": false, "is_hostile": false, "relation": "neutral"}
+
+# 使用转国卷轴更换帝国 (40250 Tincture of Empires 71054)
+func change_empire(target_empire: int, item_slot: int, inventory: Array, player_stats: Dictionary, is_in_guild: bool = false) -> Dictionary:
+ if not EMPIRES_INFO.has(target_empire):
+ return {"ok": false, "reason": "INVALID_TARGET_EMPIRE", "msg": "无效的目标帝国!"}
+
+ if target_empire == current_empire:
+ return {"ok": false, "reason": "CANNOT_CHANGE_TO_SAME_EMPIRE", "msg": "无法转入当前已效忠的国家!"}
+
+ if is_in_guild:
+ return {"ok": false, "reason": "MUST_LEAVE_GUILD", "msg": "已有公会归属,必须先退出公会方可转国!"}
+
+ if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
+ return {"ok": false, "reason": "INVALID_ITEM_SLOT"}
+
+ var scroll: Dictionary = inventory[item_slot]
+ if int(scroll.get("vnum", 0)) != VNUM_TINCTURE_OF_EMPIRES:
+ return {"ok": false, "reason": "NOT_A_TINCTURE_ITEM", "msg": "需要消耗【转国卷轴】(71054)!"}
+
+ # 若当前已有祈福加成,先解除旧帝国祈福
+ if is_blessing_active:
+ var old_blessings: Dictionary = EMPIRES_INFO[current_empire]["blessings"]
+ _apply_blessings_to_player(old_blessings, player_stats, -1)
+ is_blessing_active = false
+ blessing_time_remaining = 0.0
+
+ # 消耗转国卷轴
+ var cnt := int(scroll.get("count", 1))
+ if cnt > 1:
+ scroll["count"] = cnt - 1
+ else:
+ inventory[item_slot] = null
+
+ var old_emp := current_empire
+ current_empire = target_empire
+
+ empire_changed.emit(old_emp, current_empire)
+
+ var new_info: Dictionary = EMPIRES_INFO[current_empire]
+ return {
+ "ok": true,
+ "old_empire": old_emp,
+ "new_empire": current_empire,
+ "new_empire_name": new_info["name"],
+ "msg": "成功转入【%s】!请前往母国【%s】领取新的帝国嘉奖。" % [new_info["name"], new_info["capital"]]
+ }
+
+func _apply_blessings_to_player(blessings: Dictionary, player_stats: Dictionary, factor: int) -> void:
+ for k in blessings.keys():
+ var val = int(blessings[k]) * factor
+ var cur = int(player_stats.get(k, 0))
+ player_stats[k] = cur + val
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "current_empire": current_empire,
+ "is_blessing_active": is_blessing_active,
+ "blessing_time_remaining": blessing_time_remaining
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ current_empire = int(data.get("current_empire", EMPIRE_NONE))
+ is_blessing_active = bool(data.get("is_blessing_active", false))
+ blessing_time_remaining = float(data.get("blessing_time_remaining", 0.0))
diff --git a/project/empire_kingdom_system.gd.uid b/project/empire_kingdom_system.gd.uid
new file mode 100644
index 00000000..0c4bd2d9
--- /dev/null
+++ b/project/empire_kingdom_system.gd.uid
@@ -0,0 +1 @@
+uid://cw0e8mkuuaupg
diff --git a/project/endless_spire_dungeon_system.gd b/project/endless_spire_dungeon_system.gd
new file mode 100644
index 00000000..dc70db86
--- /dev/null
+++ b/project/endless_spire_dungeon_system.gd
@@ -0,0 +1,194 @@
+# endless_spire_dungeon_system.gd —— Metin2 40250 单人单机迷宫地牢【无尽天关】爬塔挑战 1:1
+# 对照 40250 服务端 dungeon.cpp, dungeon_info.cpp, questmanager.cpp
+class_name EndlessSpireDungeonSystem
+extends RefCounted
+
+signal floor_started(floor_num: int, is_boss_floor: bool, mob_count: int)
+signal floor_cleared(floor_num: int, points_earned: int, time_used: float)
+signal checkpoint_saved(checkpoint_floor: int)
+signal spire_trial_completed(highest_floor: int, total_points: int)
+signal spire_trial_failed(floor_num: int, reason: String)
+
+const MAX_FLOORS := 50
+const FLOOR_TIME_LIMIT := 120.0 # 每层 120 秒限时
+
+const MILESTONE_BOSSES: Dictionary = {
+ 5: {"name": "半兽人首领", "vnum": 691, "hp": 50000},
+ 10: {"name": "蜘蛛女王", "vnum": 2091, "hp": 80000},
+ 15: {"name": "沙漠巨怪", "vnum": 2191, "hp": 120000},
+ 20: {"name": "恶魔死神", "vnum": 1093, "hp": 180000},
+ 25: {"name": "烈焰领主", "vnum": 2206, "hp": 220000},
+ 30: {"name": "九尾妖狐", "vnum": 1901, "hp": 280000},
+ 35: {"name": "黄虎之魂", "vnum": 1304, "hp": 320000},
+ 40: {"name": "冥王阿兹瑞尔", "vnum": 2598, "hp": 400000},
+ 45: {"name": "冰龙贝兰-塞陶", "vnum": 2493, "hp": 500000},
+ 50: {"name": "混沌魔神·阿萨泽尔", "vnum": 8027, "hp": 800000}
+}
+
+var is_in_trial: bool = false
+var current_floor: int = 1
+var highest_checkpoint: int = 1
+var total_points: int = 0
+var floor_time_remaining: float = 0.0
+
+var active_mobs: Dictionary = {} # mob_id -> { "vnum", "alive" }
+var next_mob_id: int = 1
+var personal_best_floor: int = 0
+
+# 开启爬塔挑战
+func start_trial(from_checkpoint: bool = false) -> Dictionary:
+ if is_in_trial:
+ return {"ok": false, "reason": "ALREADY_IN_TRIAL"}
+
+ is_in_trial = true
+ current_floor = highest_checkpoint if from_checkpoint else 1
+ total_points = 0
+ next_mob_id = 1
+
+ _start_floor(current_floor)
+
+ return {
+ "ok": true,
+ "starting_floor": current_floor,
+ "checkpoint": highest_checkpoint,
+ "msg": "踏入【无尽天关】第 %d 层!" % current_floor
+ }
+
+# 开启指定层数
+func _start_floor(floor_num: int) -> void:
+ current_floor = floor_num
+ floor_time_remaining = FLOOR_TIME_LIMIT
+ active_mobs.clear()
+
+ var is_boss = MILESTONE_BOSSES.has(floor_num)
+ if is_boss:
+ var b_cfg = MILESTONE_BOSSES[floor_num]
+ var mid = next_mob_id
+ next_mob_id += 1
+ active_mobs[mid] = {
+ "id": mid,
+ "vnum": b_cfg["vnum"],
+ "name": b_cfg["name"],
+ "is_boss": true,
+ "alive": true
+ }
+ floor_started.emit(floor_num, true, 1)
+ else:
+ # 普通层小怪
+ var mob_count = 4 + (floor_num % 5) * 2
+ for i in range(mob_count):
+ var mid = next_mob_id
+ next_mob_id += 1
+ active_mobs[mid] = {
+ "id": mid,
+ "vnum": 101 + (floor_num % 10),
+ "name": "天关守卫",
+ "is_boss": false,
+ "alive": true
+ }
+ floor_started.emit(floor_num, false, mob_count)
+
+# 击杀当前层怪物
+func kill_mob(mob_id: int) -> Dictionary:
+ if not is_in_trial:
+ return {"ok": false, "reason": "NOT_IN_TRIAL"}
+
+ if not active_mobs.has(mob_id) or not active_mobs[mob_id]["alive"]:
+ return {"ok": false, "reason": "MOB_NOT_FOUND"}
+
+ active_mobs[mob_id]["alive"] = false
+ active_mobs.erase(mob_id)
+
+ # 检查本层是否清空
+ if active_mobs.is_empty():
+ return _on_floor_cleared()
+
+ return {
+ "ok": true,
+ "remaining_mobs": active_mobs.size()
+ }
+
+# 本层通关结算
+func _on_floor_cleared() -> Dictionary:
+ var time_used = FLOOR_TIME_LIMIT - floor_time_remaining
+ var points_awarded = current_floor * 10
+ total_points += points_awarded
+
+ if current_floor > personal_best_floor:
+ personal_best_floor = current_floor
+
+ floor_cleared.emit(current_floor, points_awarded, time_used)
+
+ # 检查点保存判定 (逢 5 层通关)
+ if current_floor % 5 == 0:
+ var next_cp = current_floor + 1
+ if next_cp > highest_checkpoint and next_cp <= MAX_FLOORS:
+ highest_checkpoint = next_cp
+ checkpoint_saved.emit(highest_checkpoint)
+
+ # 终极 50 层通关判定
+ if current_floor >= MAX_FLOORS:
+ is_in_trial = false
+ spire_trial_completed.emit(MAX_FLOORS, total_points)
+ return {
+ "ok": true,
+ "trial_completed": true,
+ "highest_floor": MAX_FLOORS,
+ "total_points": total_points,
+ "msg": "登峰造极!你已彻底通关【无尽天关】第 50 层巅峰!"
+ }
+
+ # 进阶下一层
+ var next_f = current_floor + 1
+ _start_floor(next_f)
+
+ return {
+ "ok": true,
+ "floor_cleared": current_floor - 1,
+ "next_floor": next_f,
+ "points": total_points,
+ "msg": "第 %d 层顺利通关!进入第 %d 层!" % [current_floor - 1, next_f]
+ }
+
+# 帧心跳更新限时
+func update(delta: float) -> Dictionary:
+ if not is_in_trial:
+ return {"status": "inactive"}
+
+ floor_time_remaining -= delta
+ if floor_time_remaining <= 0.0:
+ floor_time_remaining = 0.0
+ is_in_trial = false
+ spire_trial_failed.emit(current_floor, "TIME_EXPIRED")
+ return {
+ "status": "failed",
+ "reason": "TIME_EXPIRED",
+ "floor": current_floor,
+ "msg": "时间耗尽!被传送出天关。"
+ }
+
+ return {
+ "status": "active",
+ "floor": current_floor,
+ "remaining_time": floor_time_remaining,
+ "remaining_mobs": active_mobs.size()
+ }
+
+# 中途离开挑战
+func exit_trial() -> Dictionary:
+ if not is_in_trial:
+ return {"ok": false, "reason": "NOT_IN_TRIAL"}
+ is_in_trial = false
+ active_mobs.clear()
+ return {"ok": true, "msg": "已主动退出天关,检查点进度已保存。"}
+
+# 获取挑战概览
+func get_spire_status() -> Dictionary:
+ return {
+ "is_in_trial": is_in_trial,
+ "current_floor": current_floor,
+ "highest_checkpoint": highest_checkpoint,
+ "personal_best_floor": personal_best_floor,
+ "total_points": total_points,
+ "remaining_time": floor_time_remaining
+ }
diff --git a/project/endless_spire_dungeon_system.gd.uid b/project/endless_spire_dungeon_system.gd.uid
new file mode 100644
index 00000000..87d27fe8
--- /dev/null
+++ b/project/endless_spire_dungeon_system.gd.uid
@@ -0,0 +1 @@
+uid://c6sc72uf1btdv
diff --git a/project/energy_system.gd b/project/energy_system.gd
new file mode 100644
index 00000000..9767e2b8
--- /dev/null
+++ b/project/energy_system.gd
@@ -0,0 +1,237 @@
+# energy_system.gd —— Metin2 40250 官方能量碎片提取与能量水晶熔炼系统 1:1
+# 对照 40250 服务端 energy_system.quest, char_item.cpp:6319 (CBuffOnAttributes), char.h (POINT_ENERGY=128)
+class_name EnergySystem
+extends RefCounted
+
+signal fragments_extracted(count: int, item_name: String)
+signal crystal_fused(crystal_item: Dictionary)
+signal energy_activated(duration: float, bonus_pct: int)
+signal energy_expired()
+
+const NPC_ALCHEMIST := 20001 # 炼金术士
+const MIN_PLAYER_LEVEL := 35 # 角色开启门槛 35 级
+const MIN_EQUIP_LEVEL := 35 # 装备分解门槛 35 级
+
+const VNUM_ENERGY_STONE := 51001 # 能量碎片 (Energy Stone / Fragment)
+const VNUM_CHARGING_STONE := 51002 # 能量水晶 (Charging Stone / Energy Crystal)
+
+const FUSION_NEED_STONES := 30 # 熔炼需要 30 颗碎片
+const FUSION_COST_GOLD := 1000 # 熔炼手续费 1000 金币
+const ENERGY_DURATION := 7200.0 # 2 小时 (7200 秒)
+const ATTR_BONUS_PCT := 10 # 全身穿戴装备属性增幅 +10%
+
+var is_energy_active: bool = false
+var energy_time_remaining: float = 0.0
+var applied_bonuses: Dictionary = {}
+
+# 计算装备分解产出碎片数量 (40250 energy_system.quest 概率阶梯)
+static func calculate_fragment_yield(equip_level_limit: int, rand_val: int = -1) -> int:
+ var r: int = rand_val if rand_val >= 0 else (randi() % 100)
+ if equip_level_limit >= 35 and equip_level_limit <= 50:
+ # 35~50 级装备产出 1~5 颗
+ return clampi(1 + (r % 5), 1, 5)
+ elif equip_level_limit > 50 and equip_level_limit <= 70:
+ # 51~70 级装备产出 3~8 颗
+ return clampi(3 + (r % 6), 3, 8)
+ else:
+ # 70 级以上顶级装备产出 5~15 颗
+ return clampi(5 + (r % 11), 5, 15)
+
+# 在炼金术士处分解淘汰装备提取能量碎片
+func extract_energy_fragments(inventory: Array, slot_index: int, player_level: int) -> Dictionary:
+ if player_level < MIN_PLAYER_LEVEL:
+ return {"ok": false, "reason": "PLAYER_LEVEL_TOO_LOW", "msg": "炼金术士只为 35 级以上的强者提取能量!"}
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ var limit_lv := int(item.get("level_limit", item.get("limit_level", 0)))
+ if limit_lv < MIN_EQUIP_LEVEL:
+ return {"ok": false, "reason": "EQUIP_LEVEL_TOO_LOW", "msg": "只能分解 35 级以上的武器、防具或首饰!"}
+
+ # 检查箭矢等消耗品
+ if item.get("type", 0) == 1 and item.get("subtype", 0) == 5: # WEAPON_ARROW
+ return {"ok": false, "reason": "ARROW_NOT_ALLOWED", "msg": "箭矢无法提取能量!"}
+
+ var item_name: String = str(item.get("name", "淘汰装备"))
+ var count_yield := calculate_fragment_yield(limit_lv)
+
+ # 移除装备
+ inventory[slot_index] = null
+
+ # 发放能量碎片 (查找已有堆叠或找空位)
+ var placed := false
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ENERGY_STONE:
+ var cur_c := int(inventory[i].get("count", 1))
+ inventory[i]["count"] = cur_c + count_yield
+ placed = true
+ break
+
+ if not placed:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {
+ "vnum": VNUM_ENERGY_STONE,
+ "name": "能量碎片",
+ "count": count_yield
+ }
+ placed = true
+ break
+
+ fragments_extracted.emit(count_yield, item_name)
+
+ return {
+ "ok": true,
+ "yield": count_yield,
+ "item_name": item_name,
+ "msg": "成功分解【%s】,提取出 %d 颗【能量碎片】!" % [item_name, count_yield]
+ }
+
+# 熔炼能量水晶 (30 碎片 + 1000 金币)
+func fuse_energy_crystal(inventory: Array, player_gold: Dictionary) -> Dictionary:
+ var cur_gold := int(player_gold.get("gold", 0))
+ if cur_gold < FUSION_COST_GOLD:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足!熔炼需要 1,000 金币。"}
+
+ # 统计能量碎片总数
+ var total_stones := 0
+ var stone_slots: Array = []
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ENERGY_STONE:
+ total_stones += int(inventory[i].get("count", 1))
+ stone_slots.append(i)
+
+ if total_stones < FUSION_NEED_STONES:
+ return {
+ "ok": false,
+ "reason": "NOT_ENOUGH_STONES",
+ "msg": "能量碎片不足!需要 %d 颗,当前拥有 %d 颗。" % [FUSION_NEED_STONES, total_stones]
+ }
+
+ # 寻找放置能量水晶的槽位
+ var crystal_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ crystal_slot = i
+ break
+
+ # 扣除 30 颗碎片
+ var need_to_deduct := FUSION_NEED_STONES
+ for slot_idx in stone_slots:
+ var it: Dictionary = inventory[slot_idx]
+ var c := int(it.get("count", 1))
+ if c <= need_to_deduct:
+ need_to_deduct -= c
+ inventory[slot_idx] = null
+ if crystal_slot == -1:
+ crystal_slot = slot_idx # 复用清空的格子
+ else:
+ it["count"] = c - need_to_deduct
+ need_to_deduct = 0
+ break
+
+ player_gold["gold"] = cur_gold - FUSION_COST_GOLD
+
+ var crystal := {
+ "vnum": VNUM_CHARGING_STONE,
+ "name": "能量水晶",
+ "count": 1
+ }
+ inventory[crystal_slot] = crystal
+
+ crystal_fused.emit(crystal)
+
+ return {
+ "ok": true,
+ "crystal": crystal,
+ "slot": crystal_slot,
+ "msg": "熔炼成功!纯净的【能量水晶】已凝聚成型。"
+ }
+
+# 使用能量水晶激活 2 小时全身穿戴装备 10% 增幅 (40250 CBuffOnAttributes)
+func use_energy_crystal(inventory: Array, slot_index: int, equipped_items: Array, player_stats: Dictionary) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ if int(item.get("vnum", 0)) != VNUM_CHARGING_STONE:
+ return {"ok": false, "reason": "NOT_A_CHARGING_STONE", "msg": "此物品不是能量水晶!"}
+
+ # 消耗 1 颗能量水晶
+ var c := int(item.get("count", 1))
+ if c > 1:
+ item["count"] = c - 1
+ else:
+ inventory[slot_index] = null
+
+ # 如果已激活,先清除旧加成
+ if is_energy_active:
+ _clear_energy_buffs(player_stats)
+
+ # 计算穿戴装备基础属性总和的 10%
+ applied_bonuses.clear()
+ for eq in equipped_items:
+ if eq == null:
+ continue
+ var atk = int(eq.get("attack_power", 0))
+ var def = int(eq.get("defense", 0))
+ var hp = int(eq.get("max_hp", 0))
+ if atk > 0:
+ applied_bonuses["attack_power"] = int(applied_bonuses.get("attack_power", 0)) + int(ceil(atk * 0.10))
+ if def > 0:
+ applied_bonuses["defense"] = int(applied_bonuses.get("defense", 0)) + int(ceil(def * 0.10))
+ if hp > 0:
+ applied_bonuses["max_hp"] = int(applied_bonuses.get("max_hp", 0)) + int(ceil(hp * 0.10))
+
+ # 保底全属性增益 (即使没有极品装备,也提供保底加成)
+ applied_bonuses["attack_power"] = maxi(10, int(applied_bonuses.get("attack_power", 0)))
+ applied_bonuses["defense"] = maxi(10, int(applied_bonuses.get("defense", 0)))
+
+ # 注入玩家属性
+ for k in applied_bonuses.keys():
+ player_stats[k] = int(player_stats.get(k, 0)) + applied_bonuses[k]
+
+ is_energy_active = true
+ energy_time_remaining = ENERGY_DURATION
+
+ energy_activated.emit(ENERGY_DURATION, ATTR_BONUS_PCT)
+
+ return {
+ "ok": true,
+ "duration": ENERGY_DURATION,
+ "bonuses": applied_bonuses,
+ "msg": "能量水晶激活!全身装备属性增幅 +10%%,持续 2 小时。"
+ }
+
+# 清除能量属性
+func _clear_energy_buffs(player_stats: Dictionary) -> void:
+ for k in applied_bonuses.keys():
+ player_stats[k] = int(player_stats.get(k, 0)) - applied_bonuses[k]
+ applied_bonuses.clear()
+ is_energy_active = false
+ energy_time_remaining = 0.0
+
+# 时钟心跳推进
+func update(delta: float, player_stats: Dictionary) -> void:
+ if not is_energy_active:
+ return
+ energy_time_remaining -= delta
+ if energy_time_remaining <= 0.0:
+ _clear_energy_buffs(player_stats)
+ energy_expired.emit()
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_energy_active": is_energy_active,
+ "energy_time_remaining": energy_time_remaining,
+ "applied_bonuses": applied_bonuses.duplicate(true)
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_energy_active = bool(data.get("is_energy_active", false))
+ energy_time_remaining = float(data.get("energy_time_remaining", 0.0))
+ applied_bonuses = data.get("applied_bonuses", {}).duplicate(true)
diff --git a/project/energy_system.gd.uid b/project/energy_system.gd.uid
new file mode 100644
index 00000000..4ec81ed3
--- /dev/null
+++ b/project/energy_system.gd.uid
@@ -0,0 +1 @@
+uid://ckdibi7t8851x
diff --git a/project/epic_refine_system.gd b/project/epic_refine_system.gd
new file mode 100644
index 00000000..71230f69
--- /dev/null
+++ b/project/epic_refine_system.gd
@@ -0,0 +1,210 @@
+# epic_refine_system.gd —— Metin2 40250 修元平史诗级神装升阶转换锻造 1:1
+# 对照 40250 服务端 refine.cpp, char_item.cpp, NPC 20091 (修元平 Seon-Pyeong)
+class_name EpicRefineSystem
+extends RefCounted
+
+signal epic_refine_succeeded(old_vnum: int, new_vnum: int, item_data: Dictionary)
+signal epic_refine_failed(reason: String)
+
+const NPC_SEON_PYEONG := 20091
+
+const ITEM_DRAGON_SCALE := 71123 # 龙鳞
+const ITEM_DRAGON_CLAW := 71129 # 龙爪
+const REQUIRED_SCALE_COUNT := 3
+const REQUIRED_CLAW_COUNT := 4
+const REQUIRED_YANG := 200000
+
+# 官方 40250 经典史诗升阶配方表
+const EPIC_RECIPES: Dictionary = {
+ # 武器篇:65 级 +9 武器 -> 80~90 级 +0 史诗神兵
+ 169: {
+ "target_vnum": 279,
+ "name": "崔顿之剑+0",
+ "desc": "战神之剑+9 升阶为 崔顿之剑+0"
+ },
+ 3159: {
+ "target_vnum": 3209,
+ "name": "神圣之剑+0",
+ "desc": "鬼牙刃+9 升阶为 神圣之剑+0"
+ },
+ 1109: {
+ "target_vnum": 1139,
+ "name": "太阳之刃+0",
+ "desc": "党人斧+9 升阶为 太阳之刃+0"
+ },
+ 1119: {
+ "target_vnum": 1129,
+ "name": "龙神刀+0",
+ "desc": "龙刀+9 升阶为 龙神刀+0"
+ },
+ 2149: {
+ "target_vnum": 2159,
+ "name": "巨鸦弓+0",
+ "desc": "黄龙弓+9 升阶为 巨鸦弓+0"
+ },
+ 5109: {
+ "target_vnum": 5119,
+ "name": "恶魔之杖+0",
+ "desc": "救赎之杖+9 升阶为 恶魔之杖+0"
+ },
+ # 铠甲篇:66 级 +9 黑钢铠甲 -> 90 级 +0 蓝钢铠甲
+ 11299: {
+ "target_vnum": 12019,
+ "name": "蓝钢甲+0",
+ "desc": "黑钢甲+9 升阶为 蓝钢甲+0"
+ },
+ 11499: {
+ "target_vnum": 12029,
+ "name": "蓝风甲+0",
+ "desc": "黑风甲+9 升阶为 蓝风甲+0"
+ },
+ 11699: {
+ "target_vnum": 12039,
+ "name": "蓝仙袍+0",
+ "desc": "黑仙袍+9 升阶为 蓝仙袍+0"
+ },
+ 11899: {
+ "target_vnum": 12049,
+ "name": "蓝龙袍+0",
+ "desc": "黑龙袍+9 升阶为 蓝龙袍+0"
+ }
+}
+
+# 判定物品是否可进行史诗升阶
+func can_epic_refine(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ var vnum: int = int(item.get("vnum", 0))
+ return EPIC_RECIPES.has(vnum)
+
+# 获取升阶配方详情
+func get_recipe(vnum: int) -> Dictionary:
+ return EPIC_RECIPES.get(vnum, {})
+
+# 执行史诗装备升阶转换锻造
+func do_epic_refine(
+ target_slot: int,
+ inventory: Array,
+ player_data: Dictionary
+) -> Dictionary:
+ if target_slot < 0 or target_slot >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的装备栏位!"}
+
+ var base_item = inventory[target_slot]
+ if base_item == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有装备!"}
+
+ # 安全锁拦截
+ if base_item.get("is_locked", false):
+ return {"ok": false, "reason": "ITEM_LOCKED", "msg": "已加锁的装备无法进行史诗锻造!"}
+
+ var base_vnum: int = int(base_item.get("vnum", 0))
+ if not EPIC_RECIPES.has(base_vnum):
+ return {"ok": false, "reason": "NOT_AN_EPIC_BASE_ITEM", "msg": "该物品无法进行修元平史诗升阶!"}
+
+ var recipe: Dictionary = EPIC_RECIPES[base_vnum]
+
+ # 1. 检查金币 (Yang)
+ var current_yang: int = int(player_data.get("gold", 0))
+ if current_yang < REQUIRED_YANG:
+ return {
+ "ok": false,
+ "reason": "INSUFFICIENT_YANG",
+ "required_yang": REQUIRED_YANG,
+ "current_yang": current_yang,
+ "msg": "史诗锻造需要 200,000 金币!你的金币不足。"
+ }
+
+ # 2. 统计并收集龙鳞与龙爪
+ var scale_slots: Array = []
+ var claw_slots: Array = []
+ var total_scales: int = 0
+ var total_claws: int = 0
+
+ for i in range(inventory.size()):
+ var item = inventory[i]
+ if item == null:
+ continue
+ var v: int = int(item.get("vnum", 0))
+ var cnt: int = int(item.get("count", 1))
+ if v == ITEM_DRAGON_SCALE:
+ total_scales += cnt
+ scale_slots.append({"slot": i, "count": cnt})
+ elif v == ITEM_DRAGON_CLAW:
+ total_claws += cnt
+ claw_slots.append({"slot": i, "count": cnt})
+
+ if total_scales < REQUIRED_SCALE_COUNT:
+ return {
+ "ok": false,
+ "reason": "MISSING_DRAGON_SCALES",
+ "required": REQUIRED_SCALE_COUNT,
+ "current": total_scales,
+ "msg": "史诗锻造需要 3 个龙鳞!当前只有 %d 个。" % total_scales
+ }
+
+ if total_claws < REQUIRED_CLAW_COUNT:
+ return {
+ "ok": false,
+ "reason": "MISSING_DRAGON_CLAWS",
+ "required": REQUIRED_CLAW_COUNT,
+ "current": total_claws,
+ "msg": "史诗锻造需要 4 个龙爪!当前只有 %d 个。" % total_claws
+ }
+
+ # 3. 扣除金币
+ player_data["gold"] = current_yang - REQUIRED_YANG
+
+ # 4. 扣除材料 (龙鳞 3, 龙爪 4)
+ _consume_materials(inventory, scale_slots, REQUIRED_SCALE_COUNT)
+ _consume_materials(inventory, claw_slots, REQUIRED_CLAW_COUNT)
+
+ # 5. 生成新史诗装备,1:1 完整继承 5 条附加属性与已镶嵌灵石!
+ var new_vnum: int = recipe["target_vnum"]
+ var inherited_attrs: Array = []
+ if base_item.has("attributes"):
+ for attr in base_item["attributes"]:
+ inherited_attrs.append(attr.duplicate())
+
+ var inherited_sockets: Array = []
+ if base_item.has("sockets"):
+ for sock in base_item["sockets"]:
+ inherited_sockets.append(sock)
+
+ var epic_item: Dictionary = {
+ "vnum": new_vnum,
+ "name": recipe["name"],
+ "count": 1,
+ "attributes": inherited_attrs,
+ "sockets": inherited_sockets,
+ "socket_count": base_item.get("socket_count", 3),
+ "is_locked": false
+ }
+
+ # 替换背包中原位置的装备
+ inventory[target_slot] = epic_item
+
+ epic_refine_succeeded.emit(base_vnum, new_vnum, epic_item)
+
+ return {
+ "ok": true,
+ "old_vnum": base_vnum,
+ "new_vnum": new_vnum,
+ "item": epic_item,
+ "msg": "恭喜!修元平大师成功将装备升阶锻造为【%s】!" % recipe["name"]
+ }
+
+# 辅助函数:按需从多个格子中扣除材料数量
+func _consume_materials(inventory: Array, slots_info: Array, count_needed: int) -> void:
+ var needed := count_needed
+ for info in slots_info:
+ var s: int = info["slot"]
+ var have: int = info["count"]
+ if have <= needed:
+ needed -= have
+ inventory[s] = null
+ else:
+ inventory[s]["count"] = have - needed
+ needed = 0
+ if needed <= 0:
+ break
diff --git a/project/epic_refine_system.gd.uid b/project/epic_refine_system.gd.uid
new file mode 100644
index 00000000..18ed5a73
--- /dev/null
+++ b/project/epic_refine_system.gd.uid
@@ -0,0 +1 @@
+uid://cg7egxv7schkr
diff --git a/project/equip_model_test.gd b/project/equip_model_test.gd
index 701d46ab..923df90d 100644
--- a/project/equip_model_test.gd
+++ b/project/equip_model_test.gd
@@ -4,6 +4,9 @@ extends SceneTree
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 EffectRegistry = preload("res://fx/effect_registry.gd")
+const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
class FakeClient extends Node:
signal inventory_changed(window: int, cell: int)
@@ -28,6 +31,17 @@ class StubModel extends Node3D:
var gr2_path := ""
var hair_gr2 := ""
var hair_skin := "-"
+ var weapon_bone := "equip_right_hand"
+ var shield_bone := "equip_left"
+ var skin_source := ""
+ var skin_target := ""
+ var specular_power := -1.0
+ func clear_skin_textures():
+ skin_source = ""
+ skin_target = ""
+ func set_skin_texture(source: String, target: String):
+ skin_source = source
+ skin_target = target
func _set(prop, val):
match String(prop):
"weapon_gr2": weapon_gr2 = val; return true
@@ -35,14 +49,28 @@ class StubModel extends Node3D:
"gr2_path": gr2_path = val; return true
"hair_gr2": hair_gr2 = val; return true
"hair_skin": hair_skin = val; return true
+ "specular_power": specular_power = val; return true
return false
+class FakeEffectRegistry extends RefCounted:
+ var calls: Array = []
+ func setup(_assets: String) -> void: pass
+ func spawn(effect: String, parent: Node3D, one_shot := true) -> Node3D:
+ calls.append({"effect": effect, "parent": parent.name, "one_shot": one_shot})
+ var node := Node3D.new()
+ parent.add_child(node)
+ return node
+
+class FakeAnimator extends Node:
+ func get_effect_bone_pose(_bone: String) -> Dictionary:
+ return {"transform": Transform3D.IDENTITY}
+
# item_proto 桩:只给武器 subtype(WEAPON_DAGGER=1 BOW=2 FAN=5,item_length.h EWeaponSubTypes)
class FakeProto extends Node:
- const SUB := {19: 0, 2000: 2, 4000: 1, 7000: 5}
+ const SUB := {19: 0, 2000: 2, 3151: 3, 3159: 3, 4000: 1, 7000: 5}
func item(vnum: int) -> Dictionary:
if SUB.has(vnum):
- return {"type": 1, "sub_type": SUB[vnum]}
+ return {"type": 1, "sub_type": SUB[vnum], "gain_socket_pct": 3}
return {}
var _fail := 0
@@ -75,6 +103,53 @@ func _run() -> void:
_ck(il.icon(19) == "icon/item/00010.tga", "item_list.icon(19)")
_ck(il.type_of(19) == "WEAPON", "item_list.type_of(19)")
+ # InstanceBase::__GetRefinedEffect parity. Standard equipment carries
+ # gain_socket_pct=3, making the final vnum digit its visible refine level.
+ var heavy_1 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3}
+ var heavy_9 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3}
+ _ck(EquipModel.refine_effect_level(3151, heavy_1) == 1,
+ "3151 -> refine +1")
+ _ck(EquipModel.refine_effect_specs(3151, heavy_1).is_empty(),
+ "+1 has no legacy refine particles")
+ var heavy_fx: Array = EquipModel.refine_effect_specs(3159, heavy_9)
+ _ck(heavy_fx.size() == 1 and String(heavy_fx[0].effect).ends_with("sword_9.mse")
+ and String(heavy_fx[0].slot) == "right",
+ "two-handed +9 -> normal sword_9 on PART_WEAPON")
+ var dagger_fx: Array = EquipModel.refine_effect_specs(4009,
+ {"type": 1, "sub_type": 1, "gain_socket_pct": 3})
+ _ck(dagger_fx.size() == 2 and String(dagger_fx[0].effect).ends_with("sword_9_s.mse")
+ and String(dagger_fx[1].slot) == "left",
+ "dagger +9 -> small-sword effect on both hands")
+ var bow_fx: Array = EquipModel.refine_effect_specs(2009,
+ {"type": 1, "sub_type": 2, "gain_socket_pct": 3})
+ _ck(bow_fx.size() == 1 and String(bow_fx[0].effect).ends_with("sword_9_b.mse")
+ and String(bow_fx[0].slot) == "left",
+ "bow +9 -> bow effect on PART_WEAPON_LEFT")
+ var blue_steel_fx: Array = EquipModel.refine_effect_specs(12019,
+ {"type": 2, "sub_type": 0, "gain_socket_pct": 3})
+ _ck(blue_steel_fx.size() == 3 and String(blue_steel_fx[2].effect).ends_with("armor_9.mse"),
+ "Blue Steel +9 -> two special effects plus armor_9")
+ var real_registry := EffectRegistry.new()
+ real_registry.setup(assets)
+ for spec in heavy_fx + dagger_fx + bow_fx + blue_steel_fx:
+ _ck(real_registry.resolve(String(spec.effect)) != "",
+ "registered refine MSE resolves: %s" % spec.effect)
+
+ # Character select passes the outer PlayerView to EquipModel. Verify it
+ # forwards palette calls to the native renderer; a direct stub-only test
+ # cannot catch a missing integration method here.
+ var forwarded := StubModel.new()
+ var view := PlayerView.new()
+ view.model = forwarded
+ view.set_skin_texture("base.dds", "blue.dds")
+ _ck(forwarded.skin_source == "base.dds" and forwarded.skin_target == "blue.dds",
+ "PlayerView forwards set_skin_texture")
+ view.clear_skin_textures()
+ _ck(forwarded.skin_source == "" and forwarded.skin_target == "",
+ "PlayerView forwards clear_skin_textures")
+ view.free()
+ forwarded.free()
+
# EquipModel:装备武器 vnum 19 -> model.weapon_gr2 = 解析后的真实路径
var fc := FakeClient.new()
var model := StubModel.new()
@@ -112,15 +187,28 @@ func _run() -> void:
# shield_gr2 槽 = PART_WEAPON_LEFT;参考端不渲染 WEAR_SHIELD(盾在 item_list 没模型)。
var fch := FakeClient.new()
var mh := StubModel.new()
+ var mh_view := PlayerView.new()
+ mh_view.name = "PlayerView"
+ mh_view.model = mh
+ mh_view._left_bone = "Bip01 L Hand"
+ var mh_anim := FakeAnimator.new()
+ mh_view.anim = mh_anim
var fp := FakeProto.new()
get_root().add_child(fch)
- get_root().add_child(mh)
+ get_root().add_child(mh_view)
+ mh_view.add_child(mh)
+ mh_view.add_child(mh_anim)
get_root().add_child(fp)
var emh: Node = EquipModel.new()
get_root().add_child(emh)
- emh.setup(fch, il, func() -> Node: return mh, assets)
+ emh.setup(fch, il, func() -> Node: return mh_view, assets)
emh.main_getter = func() -> int: return 1000
emh.proto = fp
+ var right_part := Node3D.new()
+ right_part.name = "Weapon"
+ mh.add_child(right_part)
+ var fake_effects := FakeEffectRegistry.new()
+ emh.effect_registry = fake_effects
var sword_p: String = emh._resolve_weapon(19)
var bow_p: String = emh._resolve_weapon(2000)
var dagger_p: String = emh._resolve_weapon(4000)
@@ -138,6 +226,25 @@ func _run() -> void:
fch.set_wear(4, 7000)
await process_frame
_ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == "", "fan on foot -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
+ fake_effects.calls.clear()
+ fch.set_wear(4, 3159)
+ await process_frame
+ _ck(fake_effects.calls.size() == 1
+ and String(fake_effects.calls[0].effect).ends_with("sword_9.mse")
+ and String(fake_effects.calls[0].parent) == "PlayerView"
+ and not bool(fake_effects.calls[0].one_shot),
+ "equipping two-handed +9 spawns persistent sword_9 on character bone path (%s; children=%s)" %
+ [fake_effects.calls, mh.get_children()])
+ _ck(emh._weapon_refine_fx.size() == 1
+ and emh._weapon_refine_fx[0].get_child_count() == 1
+ and emh._weapon_refine_fx[0].get_child(0) is MotionEffectAnchor,
+ "+9 effect follows equip_right_hand through MotionEffectAnchor")
+ fch.set_wear(4, 3151)
+ await process_frame
+ _ck(fake_effects.calls.size() == 1 and emh._weapon_refine_fx.is_empty(),
+ "switching +9 -> +1 removes refine particles (%s; active=%s)" %
+ [fake_effects.calls, emh._weapon_refine_fx])
+ fch.set_wear(4, 7000)
fch.mount_vnum = 20030
fch.entity_info.emit(1000, {})
await process_frame
@@ -180,8 +287,26 @@ func _run() -> void:
em3.race = 0 # warrior
# 11200 "Monk Plate Armour+0" -> values[3] == 3
_ck(em3._armor_shape_default(11200) == 3, "armor 11200 -> shape 3 (via proto values[3])")
- # 11000 "Wolf Armour" -> values[3] == 0 -> 回退 =vnum
- _ck(em3._armor_shape_default(11000) == 11000, "armor 11000 -> values[3]=0 回退 =vnum")
+ # 11000 "Wolf Armour" -> values[3] == 0,参考端把 0 当有效基础 shape
+ _ck(em3._armor_shape_default(11000) == 100, "armor 11000 -> shape 100 (via proto values[3])")
+ _ck(em3._armor_shape_default(12019) == 26, "armor 12019 -> Blue Steel shape 26")
+ var heavy: Dictionary = pr.item(3150)
+ _ck(int(heavy.get("type", -1)) == 1 and int(heavy.get("sub_type", -1)) == 3,
+ "weapon 3150 -> WEAPON_TWO_HANDED")
+ var heavy_real_9: Dictionary = pr.item(3159)
+ _ck(EquipModel.refine_effect_level(3159, heavy_real_9) == 9
+ and EquipModel.refine_effect_specs(3159, heavy_real_9).size() == 1,
+ "real item_proto 3159 -> two-handed +9 refine effect")
+ var armor_real_9: Dictionary = pr.item(12019)
+ _ck(EquipModel.refine_effect_level(12019, armor_real_9) == 9
+ and EquipModel.refine_effect_specs(12019, armor_real_9).size() == 3,
+ "real item_proto 12019 -> Blue Steel +9 refine effects")
+ em3._apply_body(model, 12019)
+ _ck(model.gr2_path.to_lower().ends_with("warrior_4-1.gr2"),
+ "armor 12019 -> warrior_4-1 geometry (%s)" % model.gr2_path)
+ _ck(model.skin_source.to_lower().ends_with("warrior_4-1.dds") and
+ model.skin_target.to_lower().ends_with("warrior_4-2.dds"),
+ "armor 12019 -> source/target skin remap (%s -> %s)" % [model.skin_source, model.skin_target])
_ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100")
# FakeProto 的 subtype 与真 item_proto 一致
for v in FakeProto.SUB:
diff --git a/project/equipment_inspect_system.gd b/project/equipment_inspect_system.gd
new file mode 100644
index 00000000..ce4b559c
--- /dev/null
+++ b/project/equipment_inspect_system.gd
@@ -0,0 +1,134 @@
+# equipment_inspect_system.gd —— 40250 装备透视检视与 GM 监察系统 1:1
+# 严格对照:
+# Client/Eternexus/root/uiequipmentdialog.py (EquipmentDialog)
+# ClientVS22/source/UserInterface/PythonPlayer.cpp
+# metin2/src/server/game/src/cmd_gm.cpp (do_view_equip)
+extends RefCounted
+
+# 官方装备槽位常量定义 (1:1 ClientVS22/source/UserInterface/GameType.h)
+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_SHIELD := 10 # 盾牌
+const WEAR_COSTUME_BODY := 19 # 时装身体
+const WEAR_COSTUME_HAIR := 20 # 时装发型
+const WEAR_COSTUME_WEAPON := 21 # 时装武器
+
+const SLOT_NAMES := {
+ WEAR_BODY: "铠甲",
+ WEAR_HEAD: "头盔",
+ WEAR_FOOTS: "鞋子",
+ WEAR_WRIST: "手镯",
+ WEAR_WEAPON: "武器",
+ WEAR_NECK: "项链",
+ WEAR_EAR: "耳环",
+ WEAR_SHIELD: "盾牌",
+ WEAR_COSTUME_BODY: "时装身体",
+ WEAR_COSTUME_HAIR: "时装发型",
+ WEAR_COSTUME_WEAPON: "时装武器",
+}
+
+# 窗口状态
+var is_open: bool = false
+var inspected_vid: int = 0
+var inspected_name: String = ""
+var equipment_slots: Dictionary = {} # slot_idx -> item_data Dictionary
+
+func _init() -> void:
+ is_open = false
+ inspected_vid = 0
+ inspected_name = ""
+ equipment_slots.clear()
+
+# 1. 打开检视窗口 (1:1 EquipmentDialog.Open)
+# caller_is_gm: GM 可强制穿透隐私限制
+func open_dialog(
+ target_vid: int,
+ target_name: String,
+ target_equip_dict: Dictionary,
+ allow_inspect: bool = true,
+ caller_is_gm: bool = false
+) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if not allow_inspect and not caller_is_gm:
+ res["error_code"] = "INSPECT_FORBIDDEN"
+ res["message"] = "目标玩家设置了隐私保护,禁止查看装备。"
+ return res
+
+ inspected_vid = target_vid
+ inspected_name = target_name
+ equipment_slots.clear()
+
+ # 载入目标玩家装备数据
+ for slot_key in target_equip_dict:
+ var slot_idx: int = int(slot_key)
+ var it: Dictionary = target_equip_dict[slot_key]
+ equipment_slots[slot_idx] = _normalize_item_data(it)
+
+ is_open = true
+ res["success"] = true
+ res["message"] = "成功载入 %s 的装备信息。" % target_name
+ return res
+
+# 2. 关闭检视窗口 (EquipmentDialog.Close)
+func close_dialog() -> void:
+ is_open = false
+ inspected_vid = 0
+ inspected_name = ""
+ equipment_slots.clear()
+
+# 3. 规范化装备项数据 (确保含有 3 宝石孔与 7 词条结构)
+func _normalize_item_data(it: Dictionary) -> Dictionary:
+ var item_copy := it.duplicate(true)
+ var sockets: Array = item_copy.get("sockets", [0, 0, 0])
+ while sockets.size() < 3:
+ sockets.append(0)
+ item_copy["sockets"] = sockets
+
+ var attrs: Array = item_copy.get("attributes", [])
+ item_copy["attributes"] = attrs
+ return item_copy
+
+# 4. 获取指定槽位装备
+func get_slot_item(slot_idx: int) -> Dictionary:
+ if not is_open:
+ return {}
+ return equipment_slots.get(slot_idx, {})
+
+# 5. 悬停生成 Tooltip 详情 (1:1 EquipmentDialog.OverInItem)
+func build_slot_tooltip(slot_idx: int) -> String:
+ if not is_open:
+ return ""
+
+ if not equipment_slots.has(slot_idx):
+ var slot_name: String = SLOT_NAMES.get(slot_idx, "未知槽位")
+ return "[%s] 未佩戴装备" % slot_name
+
+ var it: Dictionary = equipment_slots[slot_idx]
+ var lines: Array = []
+ var title_name: String = it.get("name", "Unknown Item")
+ var vnum: int = it.get("vnum", 0)
+
+ lines.append("=== %s (Vnum: %d) ===" % [title_name, vnum])
+ lines.append("部位: %s" % SLOT_NAMES.get(slot_idx, "特殊部位"))
+
+ # 宝石孔位
+ var sockets: Array = it.get("sockets", [])
+ for i in range(sockets.size()):
+ var s: int = sockets[i]
+ if s > 0:
+ lines.append("孔位 %d: 镶嵌宝石 [Vnum %d]" % [i + 1, s])
+ elif s == 1:
+ lines.append("孔位 %d: 空孔" % [i + 1])
+
+ # 附加属性 (1~7 词条)
+ var attrs: Array = it.get("attributes", [])
+ for a in attrs:
+ lines.append("附加属性: 属性 %d +%d" % [a.get("type", 0), a.get("value", 0)])
+
+ return ("\n").join(lines)
diff --git a/project/equipment_inspect_system.gd.uid b/project/equipment_inspect_system.gd.uid
new file mode 100644
index 00000000..70be868f
--- /dev/null
+++ b/project/equipment_inspect_system.gd.uid
@@ -0,0 +1 @@
+uid://gt0l8bv2agu3
diff --git a/project/event_manager_system.gd b/project/event_manager_system.gd
new file mode 100644
index 00000000..b5374790
--- /dev/null
+++ b/project/event_manager_system.gd
@@ -0,0 +1,171 @@
+# event_manager_system.gd —— Metin2 40250 单人模式全服掉落活动与倍率调度器 1:1
+# 对照 40250 服务端 cmd_gm.cpp (do_priv_empire), priv_manager.cpp, event.cpp, Items 50011, 50037
+class_name EventManagerSystem
+extends RefCounted
+
+signal rate_changed(rate_type: String, multiplier: float)
+signal event_state_changed(event_name: String, is_active: bool)
+signal event_box_dropped(box_vnum: int, box_name: String)
+
+const ITEM_MOONLIGHT_BOX := 50011 # 月光宝盒
+const ITEM_HEXAGONAL_BOX := 50037 # 六角宝盒
+
+# 宝盒奖励奖池 (40250 官方 special_item_group.txt 1:1)
+const EVENT_BOX_LOOT: Dictionary = {
+ 50011: [
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 30},
+ {"vnum": 71084, "name": "高级附魔石", "count": 1, "weight": 25},
+ {"vnum": 71085, "name": "高级洗练石", "count": 1, "weight": 25},
+ {"vnum": 70024, "name": "祝福大理石", "count": 1, "weight": 10},
+ {"vnum": 72723, "name": "月之灵药(特)", "count": 1, "weight": 10}
+ ],
+ 50037: [
+ {"vnum": 72001, "name": "经验之戒", "count": 1, "weight": 20},
+ {"vnum": 72002, "name": "盗贼手套", "count": 1, "weight": 20},
+ {"vnum": 27102, "name": "绿药水(大)", "count": 5, "weight": 30},
+ {"vnum": 27105, "name": "紫药水(大)", "count": 5, "weight": 30}
+ ]
+}
+
+var exp_rate: float = 1.0
+var drop_rate: float = 1.0
+var gold_rate: float = 1.0
+
+var is_moonlight_active: bool = false
+var is_hexagonal_active: bool = false
+
+# 设置倍率 (exp, drop, gold)
+func set_rate(rate_type: String, multiplier: float) -> Dictionary:
+ if multiplier < 1.0 or multiplier > 5.0:
+ return {"ok": false, "reason": "INVALID_MULTIPLIER", "msg": "倍率必须在 1.0 到 5.0 之间!"}
+
+ match rate_type:
+ "exp":
+ exp_rate = multiplier
+ "drop":
+ drop_rate = multiplier
+ "gold":
+ gold_rate = multiplier
+ _:
+ return {"ok": false, "reason": "INVALID_RATE_TYPE", "msg": "未知的倍率类型!"}
+
+ rate_changed.emit(rate_type, multiplier)
+
+ return {
+ "ok": true,
+ "rate_type": rate_type,
+ "multiplier": multiplier,
+ "msg": "全服【%s】倍率已设置为 %.1fx!" % [rate_type.to_upper(), multiplier]
+ }
+
+# 开关经典限时掉落活动
+func toggle_event(event_name: String, enable: bool) -> Dictionary:
+ match event_name:
+ "moonlight":
+ is_moonlight_active = enable
+ "hexagonal":
+ is_hexagonal_active = enable
+ _:
+ return {"ok": false, "reason": "INVALID_EVENT_NAME", "msg": "未知的活动名称!"}
+
+ event_state_changed.emit(event_name, enable)
+
+ var state_str := "开启" if enable else "关闭"
+ var ev_desc := "月光宝盒掉落狂欢" if event_name == "moonlight" else "六角宝盒冲级庆典"
+
+ return {
+ "ok": true,
+ "event_name": event_name,
+ "is_active": enable,
+ "msg": "【%s】活动已%s!" % [ev_desc, state_str]
+ }
+
+# 怪物击杀时判定额外活动宝盒掉落
+func check_event_drops(mob_level: int, player_level: int, forced_roll: float = -1.0) -> Array:
+ var drops: Array = []
+
+ # 等级差在 15 级以内才掉落活动宝盒
+ if absi(mob_level - player_level) > 15:
+ return drops
+
+ # 1. 月光宝盒判定 (基础概率 10% * drop_rate)
+ if is_moonlight_active:
+ var chance := 0.10 * drop_rate
+ var roll := forced_roll if forced_roll >= 0.0 else randf()
+ if roll <= chance:
+ drops.append({"vnum": ITEM_MOONLIGHT_BOX, "name": "月光宝盒", "count": 1})
+ event_box_dropped.emit(ITEM_MOONLIGHT_BOX, "月光宝盒")
+
+ # 2. 六角宝盒判定 (基础概率 15% * drop_rate)
+ if is_hexagonal_active:
+ var chance := 0.15 * drop_rate
+ var roll := forced_roll if forced_roll >= 0.0 else randf()
+ if roll <= chance:
+ drops.append({"vnum": ITEM_HEXAGONAL_BOX, "name": "六角宝盒", "count": 1})
+ event_box_dropped.emit(ITEM_HEXAGONAL_BOX, "六角宝盒")
+
+ return drops
+
+# 计算经倍率加成后的经验与金币
+func calculate_exp(base_exp: int) -> int:
+ return int(round(base_exp * exp_rate))
+
+func calculate_gold(base_gold: int) -> int:
+ return int(round(base_gold * gold_rate))
+
+# 打开活动宝盒
+func open_event_box(slot_idx: int, inventory: Array) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包栏位!"}
+
+ var box = inventory[slot_idx]
+ if box == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有物品!"}
+
+ var box_vnum: int = int(box.get("vnum", 0))
+ if not EVENT_BOX_LOOT.has(box_vnum):
+ return {"ok": false, "reason": "NOT_AN_EVENT_BOX", "msg": "这不是活动宝盒!"}
+
+ var pool: Array = EVENT_BOX_LOOT[box_vnum]
+
+ # 权重抽奖
+ var total_weight := 0
+ for item in pool:
+ total_weight += int(item.get("weight", 1))
+
+ var roll := randi_range(1, total_weight)
+ var accumulated := 0
+ var reward: Dictionary = {}
+ for item in pool:
+ accumulated += int(item.get("weight", 1))
+ if roll <= accumulated:
+ reward = item.duplicate()
+ break
+
+ if reward.is_empty():
+ reward = pool[0].duplicate()
+
+ # 扣除宝盒,存入奖励
+ var cnt: int = int(box.get("count", 1))
+ if cnt <= 1:
+ inventory[slot_idx] = null
+ else:
+ box["count"] = cnt - 1
+
+ var stored_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {
+ "vnum": reward["vnum"],
+ "name": reward["name"],
+ "count": reward.get("count", 1)
+ }
+ stored_slot = i
+ break
+
+ return {
+ "ok": true,
+ "reward": reward,
+ "stored_slot": stored_slot,
+ "msg": "打开活动宝盒,获得了【%s x%d】!" % [reward["name"], reward.get("count", 1)]
+ }
diff --git a/project/event_manager_system.gd.uid b/project/event_manager_system.gd.uid
new file mode 100644
index 00000000..e7d0b8b6
--- /dev/null
+++ b/project/event_manager_system.gd.uid
@@ -0,0 +1 @@
+uid://bvf0ul7tdn6o5
diff --git a/project/fish_clam_roast_system.gd b/project/fish_clam_roast_system.gd
new file mode 100644
index 00000000..8b83eb8c
--- /dev/null
+++ b/project/fish_clam_roast_system.gd
@@ -0,0 +1,432 @@
+# fish_clam_roast_system.gd —— Metin2 40250 官方珍珠蚌开珠与活鱼宰杀/篝火炙烤盛宴系统 1:1
+# 100% 对照 40250 服务端 fishing.cpp, char_item.cpp (ITEM_FISH, 27987, USE_ABILITY_UP, USE_INVISIBILITY), item_proto.txt
+class_name FishClamRoastSystem
+extends RefCounted
+
+signal fish_gutted(result_type: String, item_vnum: int, item_name: String)
+signal clam_opened(result_type: String, item_vnum: int, item_name: String)
+signal fish_grilled(raw_vnum: int, cooked_vnum: int, count: int)
+signal cooked_fish_consumed(fish_vnum: int, effect_type: String, value: int, duration: float)
+signal campfire_spawned(pos: Vector2, duration: float)
+
+# 官方核心 Vnum 常量
+const VNUM_CLAM := 27987 # 珍珠蚌 (Clam / Istiridye)
+const VNUM_STONE_PIECE := 27990 # 碎石 (Stone Piece)
+const VNUM_WHITE_PEARL := 27992 # 白珍珠 (White Pearl)
+const VNUM_BLUE_PEARL := 27993 # 蓝珍珠 (Blue Pearl)
+const VNUM_BLOOD_PEARL := 27994 # 血红珍珠 (Blood Pearl)
+const VNUM_FISH_BONE := 27995 # 鱼骨 (Fishbone)
+const VNUM_EARTHWORM := 27801 # 蚯蚓/饵料 (Earthworm)
+const VNUM_CAMPFIRE_ITEM := 27600 # 篝火道具
+const MOB_CAMPFIRE := 12000 # 篝火 NPC/Mob
+
+# 40250 官方鱼类资料表 (alive_vnum, dead_vnum, cooked_vnum, buff/effect)
+const FISH_DATA: Dictionary = {
+ 27803: {
+ "name": "鲈鱼 (Sudak)",
+ "alive_vnum": 27803,
+ "dead_vnum": 27833,
+ "cooked_vnum": 27863,
+ "effect": "instant_hp",
+ "value": 180,
+ "duration": 0.0,
+ "desc": "立即恢复 180 点生命值"
+ },
+ 27804: {
+ "name": "鲱鱼 (Ringa)",
+ "alive_vnum": 27804,
+ "dead_vnum": 27834,
+ "cooked_vnum": 27864,
+ "effect": "instant_sp",
+ "value": 150,
+ "duration": 0.0,
+ "desc": "立即恢复 150 点魔法值"
+ },
+ 27806: {
+ "name": "鲤鱼 (Sazan)",
+ "alive_vnum": 27806,
+ "dead_vnum": 27836,
+ "cooked_vnum": 27866,
+ "effect": "move_speed",
+ "value": 20,
+ "duration": 600.0,
+ "desc": "移动速度 +20 持续 10 分钟"
+ },
+ 27807: {
+ "name": "三文鱼/鲑鱼 (Som)",
+ "alive_vnum": 27807,
+ "dead_vnum": 27837,
+ "cooked_vnum": 27867,
+ "effect": "instant_sp",
+ "value": 300,
+ "duration": 0.0,
+ "desc": "立即恢复 300 点魔法值"
+ },
+ 27808: {
+ "name": "草鱼 (Ot Sazanı)",
+ "alive_vnum": 27808,
+ "dead_vnum": 27838,
+ "cooked_vnum": 27868,
+ "effect": "attack_speed",
+ "value": 20,
+ "duration": 600.0,
+ "desc": "攻击速度 +20 持续 10 分钟"
+ },
+ 27810: {
+ "name": "水针鱼 (Zargana)",
+ "alive_vnum": 27810,
+ "dead_vnum": 27840,
+ "cooked_vnum": 27870,
+ "effect": "strength",
+ "value": 10,
+ "duration": 600.0,
+ "desc": "力量 (STR) +10 持续 10 分钟"
+ },
+ 27813: {
+ "name": "凤尾鱼 (Hamsi)",
+ "alive_vnum": 27813,
+ "dead_vnum": 27843,
+ "cooked_vnum": 27873,
+ "effect": "dexterity",
+ "value": 10,
+ "duration": 600.0,
+ "desc": "敏捷 (DEX) +10 持续 10 分钟"
+ },
+ 27814: {
+ "name": "海鲈鱼 (Levrek)",
+ "alive_vnum": 27814,
+ "dead_vnum": 27844,
+ "cooked_vnum": 27874,
+ "effect": "cure_poison",
+ "value": 1,
+ "duration": 0.0,
+ "desc": "祛除体内的一切猛毒状态"
+ },
+ 27817: {
+ "name": "泥鳅 (Çopra)",
+ "alive_vnum": 27817,
+ "dead_vnum": 27847,
+ "cooked_vnum": 27877,
+ "effect": "invisibility",
+ "value": 1,
+ "duration": 300.0,
+ "desc": "进入幽冥迷雾隐身状态 5 分钟"
+ }
+}
+
+# 逆向索引
+var _dead_to_cooked: Dictionary = {}
+var _alive_to_cooked: Dictionary = {}
+var _cooked_to_data: Dictionary = {}
+
+# 当前激活的烤鱼增益状态列表
+var _active_buffs: Dictionary = {} # effect_type -> {"value": int, "duration": float}
+
+# 当前地图是否存在有效篝火
+var is_near_active_campfire: bool = false
+var campfire_time_left: float = 0.0
+
+func _init() -> void:
+ for k in FISH_DATA.keys():
+ var data = FISH_DATA[k]
+ _alive_to_cooked[data["alive_vnum"]] = data
+ _dead_to_cooked[data["dead_vnum"]] = data
+ _cooked_to_data[data["cooked_vnum"]] = data
+
+# ==========================================
+# 1. 活鱼宰杀/使用 (fishing.cpp: UseFish)
+# ==========================================
+func gut_live_fish(inventory: Array, slot_index: int, forced_roll: int = -1) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有活鱼!"}
+
+ var item = inventory[slot_index]
+ var vnum = int(item.get("vnum", 0))
+ if not _alive_to_cooked.has(vnum):
+ return {"ok": false, "reason": "NOT_ALIVE_FISH", "msg": "此物品不是可以剖杀的鲜活鱼类!"}
+
+ var fish = _alive_to_cooked[vnum]
+ # 扣减活鱼数量
+ _consume_item_one(inventory, slot_index)
+
+ # 40250 原版随机概率 1~10000:
+ # r >= 4001 (60%): 产出死鱼 (dead_vnum)
+ # r >= 2001 (20%): 产出鱼骨 (FISH_BONE_VNUM 27995)
+ # r < 2001 (20%): 肚中有物 (蚌壳 27987、饵料 27801 或失手滑入水中)
+ var r = forced_roll if forced_roll > 0 else randi_range(1, 10000)
+
+ if r >= 4001:
+ _give_or_stack_item(inventory, fish["dead_vnum"], 1)
+ fish_gutted.emit("dead_fish", fish["dead_vnum"], "死" + fish["name"])
+ return {
+ "ok": true,
+ "result": "dead_fish",
+ "vnum": fish["dead_vnum"],
+ "name": "死" + fish["name"],
+ "msg": "你熟练地宰杀了活鱼,获得了可用于烤制的死鱼。"
+ }
+ elif r >= 2001:
+ _give_or_stack_item(inventory, VNUM_FISH_BONE, 1)
+ fish_gutted.emit("fish_bone", VNUM_FISH_BONE, "鱼骨")
+ return {
+ "ok": true,
+ "result": "fish_bone",
+ "vnum": VNUM_FISH_BONE,
+ "name": "鱼骨",
+ "msg": "活鱼体内只剩下一截晶莹剔透的坚硬鱼骨。"
+ }
+ else:
+ # r < 2001
+ if r <= 1000:
+ # 珍稀产出:珍珠蚌 (Clam 27987)
+ _give_or_stack_item(inventory, VNUM_CLAM, 1)
+ fish_gutted.emit("clam", VNUM_CLAM, "珍珠蚌")
+ return {
+ "ok": true,
+ "result": "clam",
+ "vnum": VNUM_CLAM,
+ "name": "珍珠蚌",
+ "msg": "难以置信!你在鱼肚中发现了一枚紧闭的神秘【珍珠蚌】!"
+ }
+ elif r <= 1500:
+ _give_or_stack_item(inventory, VNUM_EARTHWORM, 1)
+ fish_gutted.emit("earthworm", VNUM_EARTHWORM, "蚯蚓")
+ return {
+ "ok": true,
+ "result": "earthworm",
+ "vnum": VNUM_EARTHWORM,
+ "name": "蚯蚓",
+ "msg": "鱼肚里还残留着一条未消化的蚯蚓饵料。"
+ }
+ else:
+ # 滑入水中/空
+ fish_gutted.emit("escape", 0, "溜走")
+ return {
+ "ok": true,
+ "result": "escape",
+ "vnum": 0,
+ "name": "",
+ "msg": "活鱼猛烈扑腾,从你的指缝中滑落回了深水之中!"
+ }
+
+# ==========================================
+# 2. 珍珠蚌开珠 (char_item.cpp:2965 Case 27987)
+# ==========================================
+func open_clam(
+ inventory: Array,
+ slot_index: int,
+ forced_branch_roll: int = -1,
+ forced_pearl_roll: int = -1
+) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有物品!"}
+
+ var item = inventory[slot_index]
+ var vnum = int(item.get("vnum", 0))
+ if vnum != VNUM_CLAM:
+ return {"ok": false, "reason": "NOT_A_CLAM", "msg": "该物品不是珍珠蚌!"}
+
+ # 消耗 1 枚珍珠蚌
+ _consume_item_one(inventory, slot_index)
+
+ # 40250 官方第一轮分支 (1~100):
+ # r <= 50 (50%): 碎石 (27990)
+ # r > 50 (50%): 判定珍珠概率表 prob_table {80, 90, 97}
+ var r = forced_branch_roll if forced_branch_roll > 0 else randi_range(1, 100)
+
+ if r <= 50:
+ _give_or_stack_item(inventory, VNUM_STONE_PIECE, 1)
+ clam_opened.emit("stone", VNUM_STONE_PIECE, "碎石")
+ return {
+ "ok": true,
+ "result": "stone",
+ "vnum": VNUM_STONE_PIECE,
+ "name": "碎石",
+ "msg": "珍珠蚌打开了,但里面只包裹着一块毫无价值的普通碎石。"
+ }
+ else:
+ # 珍珠判定表 (1~100)
+ # roll <= 80 (80%): 空蚌壳 (什么都没有)
+ # 81 <= roll <= 90 (10%): 白珍珠 (27992)
+ # 91 <= roll <= 97 (7%): 蓝珍珠 (27993)
+ # roll >= 98 (3%): 血红珍珠 (27994)
+ var p_roll = forced_pearl_roll if forced_pearl_roll > 0 else randi_range(1, 100)
+ if p_roll <= 80:
+ clam_opened.emit("empty", 0, "空蚌")
+ return {
+ "ok": true,
+ "result": "empty",
+ "vnum": 0,
+ "name": "",
+ "msg": "蚌壳碎裂开来,里面空空如也,什么也没有留下。"
+ }
+ elif p_roll <= 90:
+ _give_or_stack_item(inventory, VNUM_WHITE_PEARL, 1)
+ clam_opened.emit("white_pearl", VNUM_WHITE_PEARL, "白珍珠")
+ return {
+ "ok": true,
+ "result": "white_pearl",
+ "vnum": VNUM_WHITE_PEARL,
+ "name": "白珍珠",
+ "msg": "璀璨温润!你从蚌肉中剥出了一颗纯净的【白珍珠】!"
+ }
+ elif p_roll <= 97:
+ _give_or_stack_item(inventory, VNUM_BLUE_PEARL, 1)
+ clam_opened.emit("blue_pearl", VNUM_BLUE_PEARL, "蓝珍珠")
+ return {
+ "ok": true,
+ "result": "blue_pearl",
+ "vnum": VNUM_BLUE_PEARL,
+ "name": "蓝珍珠",
+ "msg": "幽蓝如海!你收获了一颗弥足珍贵的【蓝珍珠】!"
+ }
+ else:
+ _give_or_stack_item(inventory, VNUM_BLOOD_PEARL, 1)
+ clam_opened.emit("blood_pearl", VNUM_BLOOD_PEARL, "血红珍珠")
+ return {
+ "ok": true,
+ "result": "blood_pearl",
+ "vnum": VNUM_BLOOD_PEARL,
+ "name": "血红珍珠",
+ "msg": "旷世奇珍!蚌心处赫然闪烁着血煞夺目的极品【血红珍珠】!"
+ }
+
+# ==========================================
+# 3. 篝火炙烤 (char_item.cpp:6880, fishing.cpp: Grill)
+# ==========================================
+func place_campfire(duration: float = 600.0) -> void:
+ is_near_active_campfire = true
+ campfire_time_left = duration
+ campfire_spawned.emit(Vector2.ZERO, duration)
+
+func grill_fish(inventory: Array, slot_index: int, near_campfire_override: bool = false) -> Dictionary:
+ var can_grill = is_near_active_campfire or near_campfire_override
+ if not can_grill:
+ return {"ok": false, "reason": "NO_CAMPFIRE", "msg": "烤鱼必须在点燃的营火 (篝火) 旁进行!"}
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有鱼类!"}
+
+ var item = inventory[slot_index]
+ var vnum = int(item.get("vnum", 0))
+ var count = int(item.get("count", 1))
+
+ var fish_data = null
+ if _dead_to_cooked.has(vnum):
+ fish_data = _dead_to_cooked[vnum]
+ elif _alive_to_cooked.has(vnum):
+ fish_data = _alive_to_cooked[vnum]
+ else:
+ return {"ok": false, "reason": "NOT_GRILLABLE", "msg": "此物品无法用于篝火炙烤!"}
+
+ var cooked_vnum = int(fish_data["cooked_vnum"])
+ # 40250 item->SetCount(0); AutoGiveItem(grill_vnum, count);
+ inventory[slot_index] = null
+ _give_or_stack_item(inventory, cooked_vnum, count)
+
+ fish_grilled.emit(vnum, cooked_vnum, count)
+ return {
+ "ok": true,
+ "cooked_vnum": cooked_vnum,
+ "count": count,
+ "name": "烤" + fish_data["name"],
+ "msg": "营火噼啪作响,鱼肉散发出浓郁扑鼻的焦香!你获得了 %d 条【烤%s】。" % [count, fish_data["name"]]
+ }
+
+# ==========================================
+# 4. 食用熟鱼获得增益 (char_item.cpp: USE_POTION / USE_ABILITY_UP)
+# ==========================================
+func eat_cooked_fish(inventory: Array, slot_index: int) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格位没有熟鱼!"}
+
+ var item = inventory[slot_index]
+ var vnum = int(item.get("vnum", 0))
+ if not _cooked_to_data.has(vnum):
+ return {"ok": false, "reason": "NOT_COOKED_FISH", "msg": "此物品不是烤制完成的熟鱼!"}
+
+ var data = _cooked_to_data[vnum]
+ _consume_item_one(inventory, slot_index)
+
+ var effect = str(data["effect"])
+ var val = int(data["value"])
+ var dur = float(data["duration"])
+
+ if dur > 0.0:
+ _active_buffs[effect] = {
+ "value": val,
+ "time_remaining": dur,
+ "name": "烤" + data["name"]
+ }
+
+ cooked_fish_consumed.emit(vnum, effect, val, dur)
+ return {
+ "ok": true,
+ "effect": effect,
+ "value": val,
+ "duration": dur,
+ "desc": data["desc"],
+ "msg": "你大快朵颐地吃下了香喷喷的烤鱼,%s!" % data["desc"]
+ }
+
+func update(delta: float) -> void:
+ if campfire_time_left > 0.0:
+ campfire_time_left -= delta
+ if campfire_time_left <= 0.0:
+ campfire_time_left = 0.0
+ is_near_active_campfire = false
+
+ # 更新 BUFF 持续倒计时
+ var to_remove: Array = []
+ for eff in _active_buffs.keys():
+ var b = _active_buffs[eff]
+ b["time_remaining"] -= delta
+ if b["time_remaining"] <= 0.0:
+ to_remove.append(eff)
+
+ for eff in to_remove:
+ _active_buffs.erase(eff)
+
+func get_active_buffs() -> Dictionary:
+ return _active_buffs
+
+func clear_buffs() -> void:
+ _active_buffs.clear()
+
+# ==========================================
+# 辅助函数: 背包堆叠与消耗
+# ==========================================
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
+ # 尝试堆叠
+ for it in inventory:
+ if it != null and int(it.get("vnum", 0)) == vnum:
+ it["count"] = int(it.get("count", 1)) + count
+ return
+ # 寻找首个空槽
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "count": count}
+ return
+ # 若无空槽则追加
+ inventory.append({"vnum": vnum, "count": count})
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "active_buffs": _active_buffs.duplicate(true),
+ "campfire_time_left": campfire_time_left,
+ "is_near_active_campfire": is_near_active_campfire
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _active_buffs = data.get("active_buffs", {}).duplicate(true)
+ campfire_time_left = float(data.get("campfire_time_left", 0.0))
+ is_near_active_campfire = bool(data.get("is_near_active_campfire", false))
diff --git a/project/fish_clam_roast_system.gd.uid b/project/fish_clam_roast_system.gd.uid
new file mode 100644
index 00000000..447cce91
--- /dev/null
+++ b/project/fish_clam_roast_system.gd.uid
@@ -0,0 +1 @@
+uid://d1lso6wx22xr2
diff --git a/project/fishing_cooking_camp_system.gd b/project/fishing_cooking_camp_system.gd
new file mode 100644
index 00000000..ddf95cb7
--- /dev/null
+++ b/project/fishing_cooking_camp_system.gd
@@ -0,0 +1,214 @@
+# fishing_cooking_camp_system.gd —— Metin2 40250 野外钓鱼烹饪与生火露营增益系统 1:1
+# 对照 40250 服务端 fishing.cpp, char_item.cpp, affect.h
+class_name FishingCookingCampSystem
+extends RefCounted
+
+signal campfire_lit(pos: Vector3, duration: float)
+signal campfire_extinguished()
+signal fish_cooked(raw_vnum: int, cooked_vnum: int, cooked_name: String)
+signal food_buff_applied(buff_id: String, buff_name: String, duration: float)
+signal food_buff_expired(buff_id: String)
+
+const CAMPFIRE_KIT_VNUM := 27600 # 营火工具 (Campfire)
+const CAMPFIRE_DURATION := 300.0 # 营火持续 5 分钟
+const FOOD_BUFF_DURATION := 1800.0 # 食用增益 30 分钟 (1800 秒)
+
+# 鱼类与烹饪食谱
+const RECIPES: Dictionary = {
+ 27802: { # 生鲤鱼 -> 烤鲤鱼
+ "cooked_vnum": 27832,
+ "cooked_name": "烤鲤鱼",
+ "buff_id": "buff_grilled_carp",
+ "stat_key": "max_hp",
+ "stat_val": 1000,
+ "desc": "最大生命上限 +1000,持续 30 分钟"
+ },
+ 27803: { # 生草鱼 -> 烤草鱼
+ "cooked_vnum": 27833,
+ "cooked_name": "烤草鱼",
+ "buff_id": "buff_grilled_grass_carp",
+ "stat_key": "move_speed",
+ "stat_val": 15,
+ "desc": "移动速度 +15,持续 30 分钟"
+ },
+ 27815: { # 生红鳟鱼 -> 烤红鳟鱼
+ "cooked_vnum": 27845,
+ "cooked_name": "烤红鳟鱼",
+ "buff_id": "buff_grilled_trout",
+ "stat_key": "atk",
+ "stat_val": 50,
+ "desc": "攻击力 +50,持续 30 分钟"
+ }
+}
+
+# 烤鱼物品映射
+const COOKED_FOODS: Dictionary = {
+ 27832: {"buff_id": "buff_grilled_carp", "name": "烤鲤鱼", "stat_key": "max_hp", "stat_val": 1000},
+ 27833: {"buff_id": "buff_grilled_grass_carp", "name": "烤草鱼", "stat_key": "move_speed", "stat_val": 15},
+ 27845: {"buff_id": "buff_grilled_trout", "name": "烤红鳟鱼", "stat_key": "atk", "stat_val": 50}
+}
+
+var is_campfire_active: bool = false
+var campfire_timer: float = 0.0
+var campfire_pos: Vector3 = Vector3.ZERO
+
+# 活跃中的食物 Buff: buff_id -> { "remaining": float, "stat_key": String, "stat_val": int }
+var active_buffs: Dictionary = {}
+
+# 点燃野外营火
+func light_campfire(pos: Vector3, inventory: Array) -> Dictionary:
+ var kit_slot = -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == CAMPFIRE_KIT_VNUM:
+ kit_slot = i
+ break
+
+ if kit_slot == -1:
+ return {"ok": false, "reason": "NO_CAMPFIRE_KIT", "msg": "背包中没有【营火工具】(27600)!"}
+
+ # 消耗 1 个营火工具
+ var item = inventory[kit_slot]
+ var cnt = int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[kit_slot] = null
+
+ is_campfire_active = true
+ campfire_timer = CAMPFIRE_DURATION
+ campfire_pos = pos
+
+ campfire_lit.emit(pos, CAMPFIRE_DURATION)
+
+ return {
+ "ok": true,
+ "pos": pos,
+ "duration": CAMPFIRE_DURATION,
+ "msg": "营火已点燃!散发着温暖火光,可在此烧烤烹饪渔获!"
+ }
+
+# 在营火旁烹饪烤鱼
+func cook_fish(slot: int, inventory: Array) -> Dictionary:
+ if not is_campfire_active:
+ return {"ok": false, "reason": "NO_ACTIVE_CAMPFIRE", "msg": "周围没有生起的营火,无法烹饪!"}
+
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var raw_item = inventory[slot]
+ var raw_vnum = int(raw_item.get("vnum", 0))
+
+ if not RECIPES.has(raw_vnum):
+ return {"ok": false, "reason": "NOT_COOKABLE", "msg": "此物品无法在营火上烹饪!"}
+
+ var r = RECIPES[raw_vnum]
+ # 消耗 1 条生鱼
+ var cnt = int(raw_item.get("count", 1))
+ if cnt > 1:
+ raw_item["count"] = cnt - 1
+ else:
+ inventory[slot] = null
+
+ # 入包烤鱼
+ var cooked_vnum: int = r["cooked_vnum"]
+ var cooked_name: String = r["cooked_name"]
+
+ # 寻找可叠加的空格或同类
+ var placed := false
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == cooked_vnum:
+ inventory[i]["count"] = int(inventory[i].get("count", 1)) + 1
+ placed = true
+ break
+
+ if not placed:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {
+ "vnum": cooked_vnum,
+ "name": cooked_name,
+ "count": 1
+ }
+ placed = true
+ break
+
+ fish_cooked.emit(raw_vnum, cooked_vnum, cooked_name)
+
+ return {
+ "ok": true,
+ "cooked_name": cooked_name,
+ "desc": r["desc"],
+ "msg": "滋滋作响!成功烤制出色香味俱全的【%s】!" % cooked_name
+ }
+
+# 食用烤鱼获得 30 分钟强力 Buff
+func eat_cooked_fish(slot: int, inventory: Array, player_data: Dictionary) -> Dictionary:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item = inventory[slot]
+ var vnum = int(item.get("vnum", 0))
+
+ if not COOKED_FOODS.has(vnum):
+ return {"ok": false, "reason": "NOT_EDIBLE_COOKED_FISH"}
+
+ var cfg = COOKED_FOODS[vnum]
+ var bid: String = cfg["buff_id"]
+ var stat_k: String = cfg["stat_key"]
+ var stat_v: int = cfg["stat_val"]
+
+ # 消耗 1 条烤鱼
+ var cnt = int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot] = null
+
+ # 如果已有同类 Buff,先刷新时长并避免重复叠加属性
+ if active_buffs.has(bid):
+ active_buffs[bid]["remaining"] = FOOD_BUFF_DURATION
+ else:
+ player_data[stat_k] = int(player_data.get(stat_k, 0)) + stat_v
+ active_buffs[bid] = {
+ "remaining": FOOD_BUFF_DURATION,
+ "stat_key": stat_k,
+ "stat_val": stat_v,
+ "name": cfg["name"]
+ }
+
+ food_buff_applied.emit(bid, cfg["name"], FOOD_BUFF_DURATION)
+
+ return {
+ "ok": true,
+ "buff_id": bid,
+ "buff_name": cfg["name"],
+ "stat_key": stat_k,
+ "stat_val": stat_v,
+ "current_stat": player_data[stat_k],
+ "msg": "品尝了美味的【%s】!精神焕发,获得了【%s +%d】持续 30 分钟!" % [cfg["name"], stat_k, stat_v]
+ }
+
+# 帧心跳更新营火与食物 Buff
+func update(delta: float, player_data: Dictionary) -> void:
+ # 营火计时
+ if is_campfire_active:
+ campfire_timer -= delta
+ if campfire_timer <= 0.0:
+ is_campfire_active = false
+ campfire_extinguished.emit()
+
+ # 食物 Buff 计时与过期反扣
+ var expired_ids := []
+ for bid in active_buffs.keys():
+ var b = active_buffs[bid]
+ b["remaining"] -= delta
+ if b["remaining"] <= 0.0:
+ expired_ids.append(bid)
+
+ for bid in expired_ids:
+ var b = active_buffs[bid]
+ var stat_k = b["stat_key"]
+ var stat_v = b["stat_val"]
+ player_data[stat_k] = max(0, int(player_data.get(stat_k, 0)) - stat_v)
+ active_buffs.erase(bid)
+ food_buff_expired.emit(bid)
diff --git a/project/fishing_cooking_camp_system.gd.uid b/project/fishing_cooking_camp_system.gd.uid
new file mode 100644
index 00000000..529c8cac
--- /dev/null
+++ b/project/fishing_cooking_camp_system.gd.uid
@@ -0,0 +1 @@
+uid://bl0claww12l2
diff --git a/project/fishing_mining_system.gd b/project/fishing_mining_system.gd
new file mode 100644
index 00000000..099f53c2
--- /dev/null
+++ b/project/fishing_mining_system.gd
@@ -0,0 +1,213 @@
+# fishing_mining_system.gd —— 钓鱼与采矿休闲生活系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/game/src/fishing.cpp:180-360 (钓竿、鱼饵、钓鱼状态机、开鱼与蚌壳珍珠)
+# - 40250 server/game/src/mining.cpp:50-180 (矿镐、矿脉 NPC、采矿倒计时与原石产出)
+class_name FishingMiningSystem
+extends RefCounted
+
+# 核心道具 Vnum 常量
+const VNUM_ROD_BASE := 27400 # 钓竿 +0
+const VNUM_BAIT_EARTHWORM := 27800 # 鱼饵 (蚯蚓)
+const VNUM_BAIT_PASTE := 27801 # 鱼饵 (面团)
+
+const VNUM_FISHBONE := 27990 # 鱼骨
+const VNUM_CLAM := 27987 # 蚌壳
+const VNUM_STONE_PIECE := 27991 # 碎石片
+const VNUM_WHITE_PEARL := 27992 # 白珍珠
+const VNUM_BLUE_PEARL := 27993 # 蓝珍珠
+const VNUM_BLOOD_PEARL := 27994 # 血红珍珠
+
+const VNUM_PICKAXE_BASE := 29101 # 矿镐 +0
+
+# 经典鱼类列表
+const FISH_TABLE := [
+ {"vnum": 27802, "name": "金鲫鱼"},
+ {"vnum": 27803, "name": "鲤鱼"},
+ {"vnum": 27804, "name": "鲑鱼"},
+ {"vnum": 27805, "name": "鳟鱼"},
+ {"vnum": 27806, "name": "鲶鱼"},
+]
+
+# 矿脉 NPC 与对应原石产出表 (40250 mining.cpp)
+const VEIN_TABLE := {
+ 20047: {"name": "钻石矿脉", "ore_vnum": 50601, "ore_name": "钻石原石"},
+ 20048: {"name": "琥珀矿脉", "ore_vnum": 50602, "ore_name": "琥珀原石"},
+ 20049: {"name": "化石木", "ore_vnum": 50603, "ore_name": "化石原石"},
+ 20050: {"name": "铜矿脉", "ore_vnum": 50604, "ore_name": "铜原石"},
+ 20051: {"name": "银矿脉", "ore_vnum": 50605, "ore_name": "银原石"},
+ 20052: {"name": "金矿脉", "ore_vnum": 50606, "ore_name": "金原石"},
+ 20053: {"name": "玉矿脉", "ore_vnum": 50607, "ore_name": "玉原石"},
+ 20054: {"name": "黑檀木", "ore_vnum": 50608, "ore_name": "黑檀原石"},
+ 20055: {"name": "珍珠矿脉", "ore_vnum": 50609, "ore_name": "珍珠原石"},
+ 20056: {"name": "白金矿脉", "ore_vnum": 50610, "ore_name": "白金原石"},
+ 20057: {"name": "水晶矿脉", "ore_vnum": 50611, "ore_name": "水晶原石"},
+ 20058: {"name": "紫晶矿脉", "ore_vnum": 50612, "ore_name": "紫晶原石"},
+ 20059: {"name": "天堂泪矿脉", "ore_vnum": 50613, "ore_name": "天堂泪原石"},
+}
+
+# --- 钓鱼系统 ---
+
+## 检查物品是否为钓竿
+static func is_fishing_rod(vnum: int) -> bool:
+ return vnum >= 27400 and vnum <= 27590
+
+## 检查物品是否为鱼饵
+static func is_bait(vnum: int) -> bool:
+ return vnum == VNUM_BAIT_EARTHWORM or vnum == VNUM_BAIT_PASTE
+
+## 检查是否为鲜鱼
+static func is_live_fish(vnum: int) -> bool:
+ return vnum >= 27802 and vnum <= 27832
+
+## 检查是否为蚌壳
+static func is_clam(vnum: int) -> bool:
+ return vnum == VNUM_CLAM
+
+## 钓鱼判定 (40250 fishing.cpp:210)
+## has_bait: 是否已挂饵; rod_level: 钓竿等级 (0..9)
+static func perform_fishing(has_bait: bool, rod_level := 0, force_success := false) -> Dictionary:
+ if not has_bait:
+ return {"ok": false, "code": "NO_BAIT", "msg": "钓钩上没有鱼饵,无法钓鱼!"}
+
+ # 40250 基础钓鱼成功率:40% + 钓竿等级 * 3%
+ var base_rate := 40 + rod_level * 3
+ var roll := 0 if force_success else (randi() % 100 + 1)
+
+ if not force_success and roll > base_rate:
+ return {
+ "ok": false,
+ "code": "FISH_ESCAPED",
+ "consumed_bait": true,
+ "msg": "鱼饵被咬走了,但鱼溜掉了!"
+ }
+
+ # 成功钓上鲜鱼
+ var fish_idx := randi() % FISH_TABLE.size()
+ var fish: Dictionary = FISH_TABLE[fish_idx]
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "consumed_bait": true,
+ "fish_vnum": fish["vnum"],
+ "fish_name": fish["name"],
+ "msg": "钓上了一条活蹦乱跳的【%s】!" % fish["name"]
+ }
+
+## 打开鲜鱼 (40250 fishing.cpp:330)
+## 鲜鱼开出:死鱼 (50%)、鱼骨 27990 (30%)、蚌壳 27987 (10%)、滑落逃脱 (10%)
+static func open_live_fish(fish_vnum: int) -> Dictionary:
+ var roll := randi() % 100
+ if roll < 50:
+ # 获得死鱼(死鱼 Vnum = 活鱼 + 31)
+ var dead_vnum := fish_vnum + 31
+ return {
+ "ok": true,
+ "code": "DEAD_FISH",
+ "result_vnum": dead_vnum,
+ "msg": "得到了一条死鱼,可以在篝火上烤熟食用。"
+ }
+ elif roll < 80:
+ # 获得鱼骨 (精炼核心材料)
+ return {
+ "ok": true,
+ "code": "FISHBONE",
+ "result_vnum": VNUM_FISHBONE,
+ "msg": "剖开鲜鱼,在鱼肚里找到了一根坚硬的【鱼骨】!"
+ }
+ elif roll < 90:
+ # 获得珍贵蚌壳
+ return {
+ "ok": true,
+ "code": "CLAM",
+ "result_vnum": VNUM_CLAM,
+ "msg": "不可思议!在鱼肚里发现了一个紧闭的【蚌壳】!"
+ }
+ else:
+ return {
+ "ok": false,
+ "code": "ESCAPED",
+ "msg": "鲜鱼一摆尾巴跳进水里溜走了!"
+ }
+
+## 打开蚌壳 (40250 fishing.cpp 蚌壳产出珍珠算法)
+## 碎石 (50%)、白珍珠 27992 (25%)、蓝珍珠 27993 (15%)、血红珍珠 27994 (10%)
+static func open_clam(force_pearl_type := 0) -> Dictionary:
+ if force_pearl_type > 0:
+ var pname := "白珍珠" if force_pearl_type == VNUM_WHITE_PEARL else ("蓝珍珠" if force_pearl_type == VNUM_BLUE_PEARL else "血红珍珠")
+ return {
+ "ok": true,
+ "code": "PEARL",
+ "result_vnum": force_pearl_type,
+ "msg": "蚌壳里散发着璀璨光芒,开出了珍稀的【%s】!" % pname
+ }
+
+ var roll := randi() % 100
+ if roll < 50:
+ return {
+ "ok": true,
+ "code": "STONE",
+ "result_vnum": VNUM_STONE_PIECE,
+ "msg": "蚌壳里只有一块无用的碎石片。"
+ }
+ elif roll < 75:
+ return {
+ "ok": true,
+ "code": "PEARL",
+ "result_vnum": VNUM_WHITE_PEARL,
+ "msg": "蚌壳里开出了一颗温润光泽的【白珍珠】!"
+ }
+ elif roll < 90:
+ return {
+ "ok": true,
+ "code": "PEARL",
+ "result_vnum": VNUM_BLUE_PEARL,
+ "msg": "蚌壳里开出了一颗幽蓝剔透的【蓝珍珠】!"
+ }
+ else:
+ return {
+ "ok": true,
+ "code": "PEARL",
+ "result_vnum": VNUM_BLOOD_PEARL,
+ "msg": "天赐奇宝!蚌壳里开出了一颗极品【血红珍珠】!"
+ }
+
+# --- 采矿系统 ---
+
+## 检查是否为矿镐
+static func is_pickaxe(vnum: int) -> bool:
+ return vnum >= 29101 and vnum <= 29110
+
+## 检查 NPC 是否为矿脉
+static func is_vein(npc_vnum: int) -> bool:
+ return VEIN_TABLE.has(npc_vnum)
+
+## 获取矿脉信息
+static func get_vein_info(npc_vnum: int) -> Dictionary:
+ return VEIN_TABLE.get(npc_vnum, {})
+
+## 采矿判定 (40250 mining.cpp:85)
+## pickaxe_level: 矿镐等级 (0..9)
+static func perform_mining(vein_vnum: int, pickaxe_level := 0, force_success := false) -> Dictionary:
+ if not is_vein(vein_vnum):
+ return {"ok": false, "code": "NOT_A_VEIN", "msg": "目标不是有效的矿脉!"}
+
+ var vein: Dictionary = VEIN_TABLE[vein_vnum]
+ var base_rate := 35 + pickaxe_level * 4
+ var roll := 0 if force_success else (randi() % 100 + 1)
+
+ if not force_success and roll > base_rate:
+ return {
+ "ok": false,
+ "code": "MINING_FAIL",
+ "msg": "用力敲击了矿脉,但没有采集到有效矿石。"
+ }
+
+ var count := 1 + (randi() % 3) # 1..3 个原石
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "ore_vnum": vein["ore_vnum"],
+ "ore_name": vein["ore_name"],
+ "count": count,
+ "msg": "成功从【%s】采集到了 %d 个【%s】!" % [vein["name"], count, vein["ore_name"]]
+ }
diff --git a/project/fishing_mining_system.gd.uid b/project/fishing_mining_system.gd.uid
new file mode 100644
index 00000000..959a047f
--- /dev/null
+++ b/project/fishing_mining_system.gd.uid
@@ -0,0 +1 @@
+uid://ceequyq2hytjy
diff --git a/project/fishing_rod_reel_system.gd b/project/fishing_rod_reel_system.gd
new file mode 100644
index 00000000..531e5438
--- /dev/null
+++ b/project/fishing_rod_reel_system.gd
@@ -0,0 +1,307 @@
+# fishing_rod_reel_system.gd —— Metin2 40250 官方钓鱼抛竿拉杆与浮漂反应小游戏机制 1:1
+# 100% 对照 40250 服务端 fishing.cpp (CreateFishingEvent, Take, FishingPractice, RealRefineRod), fishing.h
+class_name FishingRodReelSystem
+extends RefCounted
+
+signal line_cast(rod_level: int, bait_name: String)
+signal fish_bite(reaction_time_window: float)
+signal fish_caught(fish_vnum: int, fish_name: String, fish_length_cm: float, practice_points: int)
+signal fish_escaped(reason: String, practice_points: int)
+signal rod_refined(new_vnum: int, success: bool, new_level: int)
+signal fishing_error(reason: String)
+
+# 40250 官方钓竿 Vnum (27400 基础钓鱼竿+0 ~ 27500 +9)
+const BASE_ROD_VNUM := 27400 # 钓鱼竿+0
+const MAX_ROD_LEVEL := 9
+
+# 40250 官方饵料 Vnum
+const VNUM_PASTE := 27800 # 鱼饵面团 (Paste)
+const VNUM_EARTHWORM := 27801 # 蚯蚓 (Earthworm)
+
+# 40250 渔夫 NPC
+const NPC_FISHERMAN := 9009
+
+# 钓竿进阶最大熟练度点数表与成功率 (对照 item_proto Value(2) 与 Value(3))
+const ROD_LEVEL_DATA: Dictionary = {
+ 0: {"max_points": 10, "refine_chance": 100, "base_catch_rate": 0.50},
+ 1: {"max_points": 20, "refine_chance": 90, "base_catch_rate": 0.55},
+ 2: {"max_points": 40, "refine_chance": 80, "base_catch_rate": 0.60},
+ 3: {"max_points": 80, "refine_chance": 70, "base_catch_rate": 0.65},
+ 4: {"max_points": 120, "refine_chance": 60, "base_catch_rate": 0.70},
+ 5: {"max_points": 160, "refine_chance": 50, "base_catch_rate": 0.75},
+ 6: {"max_points": 200, "refine_chance": 40, "base_catch_rate": 0.80},
+ 7: {"max_points": 250, "refine_chance": 30, "base_catch_rate": 0.85},
+ 8: {"max_points": 300, "refine_chance": 20, "base_catch_rate": 0.90},
+ 9: {"max_points": 350, "refine_chance": 10, "base_catch_rate": 0.95}
+}
+
+# 常见垂钓渔获池 (对照 fishing.cpp)
+const CATCH_POOL: Array[Dictionary] = [
+ {"vnum": 27803, "name": "鲈鱼 (Sudak)", "min_len": 15.0, "max_len": 35.0, "weight": 35},
+ {"vnum": 27806, "name": "鲤鱼 (Sazan)", "min_len": 25.0, "max_len": 65.0, "weight": 25},
+ {"vnum": 27808, "name": "草鱼 (Ot Sazanı)", "min_len": 20.0, "max_len": 55.0, "weight": 20},
+ {"vnum": 27810, "name": "水针鱼 (Zargana)", "min_len": 30.0, "max_len": 70.0, "weight": 10},
+ {"vnum": 27817, "name": "泥鳅 (Çopra)", "min_len": 8.0, "max_len": 18.0, "weight": 5},
+ {"vnum": 70201, "name": "漂白水 (Bleach)", "min_len": 0.0, "max_len": 0.0, "weight": 3},
+ {"vnum": 27987, "name": "珍稀珍珠蚌 (Clam)", "min_len": 5.0, "max_len": 12.0, "weight": 2}
+]
+
+# 当前垂钓状态
+var is_fishing: bool = false
+var has_bait: bool = false
+var current_bait_vnum: int = 0
+
+var rod_level: int = 0
+var rod_practice_points: int = 0
+
+var bite_timer: float = 0.0
+var is_bite_active: bool = false
+var bite_reaction_time_left: float = 0.0
+const REACTION_WINDOW_SECONDS := 3.0 # 3 秒拉杆反应时间窗口
+
+# ==========================================
+# 1. 挂饵与抛竿 (fishing.cpp: CreateFishingEvent)
+# ==========================================
+
+# 穿戴/设定当前鱼竿
+func equip_rod(level: int, current_points: int = 0) -> void:
+ rod_level = clamp(level, 0, MAX_ROD_LEVEL)
+ var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
+ rod_practice_points = clamp(current_points, 0, max_p)
+
+# 挂上鱼饵 (面团 27800 或 蚯蚓 27801)
+func attach_bait(inventory: Array, bait_slot: int) -> Dictionary:
+ if is_fishing:
+ fishing_error.emit("ALREADY_FISHING")
+ return {"ok": false, "reason": "ALREADY_FISHING", "msg": "正在垂钓中,无法更换鱼饵!"}
+
+ if bait_slot < 0 or bait_slot >= inventory.size() or inventory[bait_slot] == null:
+ fishing_error.emit("INVALID_SLOT")
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "未找到指定的鱼饵!"}
+
+ var item = inventory[bait_slot]
+ var vnum = int(item.get("vnum", 0))
+ if vnum != VNUM_PASTE and vnum != VNUM_EARTHWORM:
+ fishing_error.emit("NOT_BAIT")
+ return {"ok": false, "reason": "NOT_BAIT", "msg": "该物品无法作为鱼饵挂在鱼钩上!"}
+
+ _consume_item_one(inventory, bait_slot)
+ has_bait = true
+ current_bait_vnum = vnum
+ var bait_name = "鱼饵面团" if vnum == VNUM_PASTE else "蚯蚓"
+ return {"ok": true, "bait_vnum": vnum, "bait_name": bait_name, "msg": "将【%s】小心地挂在了鱼钩上。" % bait_name}
+
+# 抛竿垂钓
+func cast_line(forced_wait_time: float = -1.0) -> Dictionary:
+ if not has_bait:
+ fishing_error.emit("NO_BAIT")
+ return {"ok": false, "reason": "NO_BAIT", "msg": "鱼钩上空无一物!请先挂上蚯蚓或面团饵料。"}
+
+ if is_fishing:
+ fishing_error.emit("ALREADY_FISHING")
+ return {"ok": false, "reason": "ALREADY_FISHING", "msg": "钓线已经抛入水中,静候鱼儿上钩。"}
+
+ is_fishing = true
+ is_bite_active = false
+ has_bait = false # 抛竿时消耗鱼钩上的鱼饵
+
+ # 40250 等待上钩时间: 2~7 秒
+ bite_timer = forced_wait_time if forced_wait_time > 0.0 else randf_range(2.0, 7.0)
+ bite_reaction_time_left = 0.0
+
+ var bait_name = "鱼饵面团" if current_bait_vnum == VNUM_PASTE else "蚯蚓"
+ line_cast.emit(rod_level, bait_name)
+ return {"ok": true, "msg": "钓竿轻扬,鱼线带着微澜划破水面,沉入了平静的湖心……"}
+
+# ==========================================
+# 2. 反应窗口拉杆提竿 (fishing.cpp: Take)
+# ==========================================
+func pull_rod(inventory: Array, forced_catch_idx: int = -1, forced_success: bool = false) -> Dictionary:
+ if not is_fishing:
+ fishing_error.emit("NOT_FISHING")
+ return {"ok": false, "reason": "NOT_FISHING", "msg": "你并未处于垂钓状态!"}
+
+ var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
+
+ # 提前起竿 (鱼儿未咬钩)
+ if not is_bite_active:
+ is_fishing = false
+ bite_timer = 0.0
+ # 哪怕失败亦获得 1 点熟练度练习 (FishingPractice)
+ if rod_practice_points < max_p:
+ rod_practice_points += 1
+ fish_escaped.emit("PULLED_TOO_EARLY", rod_practice_points)
+ return {
+ "ok": false,
+ "reason": "PULLED_TOO_EARLY",
+ "practice_points": rod_practice_points,
+ "msg": "起竿太早了!鱼钩划破空荡荡的水面,鱼饵不翼而飞。"
+ }
+
+ # 鱼已咬钩:计算捕获概率
+ is_fishing = false
+ is_bite_active = false
+
+ # 熟练度 +1
+ if rod_practice_points < max_p:
+ rod_practice_points += 1
+
+ var base_rate = float(ROD_LEVEL_DATA[rod_level]["base_catch_rate"])
+ if current_bait_vnum == VNUM_EARTHWORM:
+ base_rate += 0.05 # 蚯蚓比面团成功率多 5%
+
+ var roll = randf()
+ var success = (roll <= base_rate) or forced_success
+
+ if success:
+ # 捕获成功:从鱼池选取
+ var picked_fish: Dictionary = {}
+ if forced_catch_idx >= 0 and forced_catch_idx < CATCH_POOL.size():
+ picked_fish = CATCH_POOL[forced_catch_idx]
+ else:
+ var total_w := 0
+ for f in CATCH_POOL:
+ total_w += int(f["weight"])
+ var r = randi_range(1, total_w)
+ var cur := 0
+ for f in CATCH_POOL:
+ cur += int(f["weight"])
+ if r <= cur:
+ picked_fish = f
+ break
+
+ var fish_vnum = int(picked_fish["vnum"])
+ var fish_name = str(picked_fish["name"])
+ var length_cm = randf_range(float(picked_fish["min_len"]), float(picked_fish["max_len"]))
+
+ _give_or_stack_item(inventory, fish_vnum, 1)
+ fish_caught.emit(fish_vnum, fish_name, length_cm, rod_practice_points)
+ return {
+ "ok": true,
+ "fish_vnum": fish_vnum,
+ "fish_name": fish_name,
+ "length_cm": length_cm,
+ "practice_points": rod_practice_points,
+ "msg": "水花四溅!你收紧鱼线,成功钓上了一条 %.1f cm 的【%s】!" % [length_cm, fish_name]
+ }
+ else:
+ # 挣脱脱钩
+ fish_escaped.emit("FISH_GOT_AWAY", rod_practice_points)
+ return {
+ "ok": false,
+ "reason": "FISH_GOT_AWAY",
+ "practice_points": rod_practice_points,
+ "msg": "大鱼猛烈摆尾,挣脱鱼钩游回了深水,只留下一串水泡……"
+ }
+
+# ==========================================
+# 3. 渔夫 NPC 鱼竿升星锻造 (fishing.cpp: RealRefineRod)
+# ==========================================
+func can_refine_rod() -> bool:
+ if rod_level >= MAX_ROD_LEVEL:
+ return false
+ var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
+ return rod_practice_points >= max_p
+
+func refine_rod_at_fisherman(forced_roll: int = -1) -> Dictionary:
+ if not can_refine_rod():
+ fishing_error.emit("PRACTICE_NOT_MAXED")
+ return {
+ "ok": false,
+ "reason": "PRACTICE_NOT_MAXED",
+ "msg": "鱼竿熟练度尚未练满!需要 %d/%d 熟练度点数方可前往渔夫处升级。" % [
+ rod_practice_points, ROD_LEVEL_DATA[rod_level]["max_points"]
+ ]
+ }
+
+ var chance = int(ROD_LEVEL_DATA[rod_level]["refine_chance"])
+ var r = forced_roll if forced_roll > 0 else randi_range(1, 100)
+
+ if r <= chance:
+ # 锻造升级成功
+ rod_level += 1
+ rod_practice_points = 0
+ var new_vnum = BASE_ROD_VNUM + rod_level
+ rod_refined.emit(new_vnum, true, rod_level)
+ return {
+ "ok": true,
+ "success": true,
+ "new_level": rod_level,
+ "new_vnum": new_vnum,
+ "msg": "渔夫巧手翻飞,金光闪烁!你的钓鱼竿成功进阶为【钓鱼竿+%d】!" % rod_level
+ }
+ else:
+ # 锻造失败:降 1 级 (40250 官方机制)
+ rod_level = max(0, rod_level - 1)
+ rod_practice_points = 0
+ var new_vnum = BASE_ROD_VNUM + rod_level
+ rod_refined.emit(new_vnum, false, rod_level)
+ return {
+ "ok": true,
+ "success": false,
+ "new_level": rod_level,
+ "new_vnum": new_vnum,
+ "msg": "精炼失败了!鱼竿受损,品质降为了【钓鱼竿+%d】。" % rod_level
+ }
+
+# ==========================================
+# 4. 心跳推进与超时判定
+# ==========================================
+func update(delta: float) -> void:
+ if not is_fishing:
+ return
+
+ if not is_bite_active:
+ # 等待鱼咬钩
+ bite_timer -= delta
+ if bite_timer <= 0.0:
+ is_bite_active = true
+ bite_reaction_time_left = REACTION_WINDOW_SECONDS
+ fish_bite.emit(REACTION_WINDOW_SECONDS)
+ else:
+ # 鱼咬钩等待提竿窗口
+ bite_reaction_time_left -= delta
+ if bite_reaction_time_left <= 0.0:
+ # 超时逃跑
+ is_fishing = false
+ is_bite_active = false
+ var max_p = ROD_LEVEL_DATA[rod_level]["max_points"]
+ if rod_practice_points < max_p:
+ rod_practice_points += 1
+ fish_escaped.emit("REACTION_TIMEOUT", rod_practice_points)
+
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
+ for it in inventory:
+ if it != null and int(it.get("vnum", 0)) == vnum:
+ it["count"] = int(it.get("count", 1)) + count
+ return
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "count": count}
+ return
+ inventory.append({"vnum": vnum, "count": count})
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "rod_level": rod_level,
+ "rod_practice_points": rod_practice_points,
+ "has_bait": has_bait,
+ "current_bait_vnum": current_bait_vnum
+ }
+
+func deserialize(data: Dictionary) -> void:
+ rod_level = int(data.get("rod_level", 0))
+ rod_practice_points = int(data.get("rod_practice_points", 0))
+ has_bait = bool(data.get("has_bait", false))
+ current_bait_vnum = int(data.get("current_bait_vnum", 0))
diff --git a/project/fishing_rod_reel_system.gd.uid b/project/fishing_rod_reel_system.gd.uid
new file mode 100644
index 00000000..72fad235
--- /dev/null
+++ b/project/fishing_rod_reel_system.gd.uid
@@ -0,0 +1 @@
+uid://b1ti6751mgdy2
diff --git a/project/flame_dungeon_razador_system.gd b/project/flame_dungeon_razador_system.gd
new file mode 100644
index 00000000..5c95bbf5
--- /dev/null
+++ b/project/flame_dungeon_razador_system.gd
@@ -0,0 +1,196 @@
+# flame_dungeon_razador_system.gd —— Metin2 40250 经典红龙烈焰神殿与拉扎多尔炼狱试炼 1:1
+# 对照 40250 服务端 flame_dungeon.start.1.script, quest/flame_dungeon*, char_item.cpp
+class_name FlameDungeonRazadorSystem
+extends RefCounted
+
+signal dungeon_entered(time_limit: float)
+signal stage_advanced(new_stage: int, stage_desc: String)
+signal altar_lit(altars_lit: int, total_altars: int)
+signal razador_spawned(mob_vnum: int)
+signal razador_slain(drop_rewards: Array)
+signal dungeon_completed()
+signal dungeon_failed(reason: String)
+
+const NPC_AM_HEH_WATCHER := 20385 # 烈焰守望者 (Am-heh Watcher)
+const MIN_ENTRY_LEVEL := 100 # 进入等级门槛 100 级
+const VNUM_TICKET_71095 := 71095 # 通行证
+const VNUM_TICKET_71130 := 71130 # 烈焰通行证
+
+const MOB_RAZADOR := 6091 # 烈焰魔王拉扎多尔 (Razador)
+const VNUM_RAZADOR_CHEST := 50186 # 拉扎多尔宝箱
+
+const DUNGEON_TIME_LIMIT := 2700.0 # 45 分钟 (2700 秒)
+
+# 7 大炼狱阶段定义
+const STAGES_DESC: Array = [
+ "阶段 1: 清剿外围烈焰魔群",
+ "阶段 2: 寻觅正确齿轮诛灭引火者",
+ "阶段 3: 破灭炼狱灭世魔石",
+ "阶段 4: 诛灭四极炼狱护法",
+ "阶段 5: 使用真·玛阿特之石点燃五方火盆",
+ "阶段 6: 击碎烈焰火轮魔石",
+ "阶段 7: 进军神殿核心,决战烈焰魔王拉扎多尔!"
+]
+
+var is_active: bool = false
+var current_stage: int = 1
+var time_remaining: float = 0.0
+var altars_lit_count: int = 0
+var razador_alive: bool = false
+
+# 检票进入烈焰神殿 (40250 flame_dungeon.start.1.script)
+func enter_dungeon(player_level: int, inventory: Array) -> Dictionary:
+ if is_active:
+ return {"ok": false, "reason": "DUNGEON_ALREADY_ACTIVE", "msg": "神殿试炼已在进行中!"}
+
+ if player_level < MIN_ENTRY_LEVEL:
+ return {
+ "ok": false,
+ "reason": "LEVEL_TOO_LOW",
+ "msg": "实力不足!开启拉扎多尔烈焰试炼需角色达到 %d 级。" % MIN_ENTRY_LEVEL
+ }
+
+ # 检查并消耗 1 张通行证 (71095 或 71130)
+ var ticket_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] != null:
+ var v := int(inventory[i].get("vnum", 0))
+ if v == VNUM_TICKET_71095 or v == VNUM_TICKET_71130:
+ ticket_slot = i
+ break
+
+ if ticket_slot == -1:
+ return {"ok": false, "reason": "NO_ENTRY_TICKET", "msg": "守望者拦住了去路!需要持有【烈焰通行证】(71095/71130)。"}
+
+ var t_item: Dictionary = inventory[ticket_slot]
+ var cnt := int(t_item.get("count", 1))
+ if cnt > 1:
+ t_item["count"] = cnt - 1
+ else:
+ inventory[ticket_slot] = null
+
+ is_active = true
+ current_stage = 1
+ time_remaining = DUNGEON_TIME_LIMIT
+ altars_lit_count = 0
+ razador_alive = false
+
+ dungeon_entered.emit(DUNGEON_TIME_LIMIT)
+ stage_advanced.emit(1, STAGES_DESC[0])
+
+ return {
+ "ok": true,
+ "time_limit": DUNGEON_TIME_LIMIT,
+ "stage": 1,
+ "stage_desc": STAGES_DESC[0],
+ "msg": "通行证校验通过!烈焰之门轰然开启,限时 45 分钟挑战拉扎多尔!"
+ }
+
+# 推进阶段目标
+func advance_stage() -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
+
+ if current_stage < 7:
+ current_stage += 1
+ var desc: String = STAGES_DESC[current_stage - 1]
+
+ if current_stage == 7:
+ # 召唤最终首领拉扎多尔
+ razador_alive = true
+ razador_spawned.emit(MOB_RAZADOR)
+
+ stage_advanced.emit(current_stage, desc)
+ return {
+ "ok": true,
+ "current_stage": current_stage,
+ "stage_desc": desc,
+ "msg": "试炼突破!开启【%s】。" % desc
+ }
+
+ return {"ok": false, "reason": "ALREADY_AT_FINAL_STAGE"}
+
+# 阶段 5 点燃玛阿特火盆
+func light_fire_altar() -> Dictionary:
+ if not is_active or current_stage != 5:
+ return {"ok": false, "reason": "NOT_IN_ALTAR_STAGE"}
+
+ altars_lit_count = mini(5, altars_lit_count + 1)
+ altar_lit.emit(altars_lit_count, 5)
+
+ if altars_lit_count >= 5:
+ # 5 座火盆全部点燃,自动迈入第 6 阶段
+ advance_stage()
+ return {
+ "ok": true,
+ "altars_lit": 5,
+ "stage_cleared": true,
+ "msg": "五方火盆全部燃起纯阳烈焰!封印解除,进入下一阶段!"
+ }
+
+ return {
+ "ok": true,
+ "altars_lit": altars_lit_count,
+ "stage_cleared": false,
+ "msg": "成功点燃 1 座火盆!当前进度:(%d / 5)。" % [altars_lit_count]
+ }
+
+# 斩杀烈焰魔王拉扎多尔
+func slay_razador(inventory: Array) -> Dictionary:
+ if not is_active or not razador_alive:
+ return {"ok": false, "reason": "RAZADOR_NOT_ALIVE"}
+
+ razador_alive = false
+
+ var rewards: Array = [
+ {"vnum": VNUM_RAZADOR_CHEST, "name": "拉扎多尔宝箱", "count": 1},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 3},
+ {"vnum": 51501, "name": "龙石炼金原石", "count": 2}
+ ]
+
+ for drop in rewards:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = drop.duplicate(true)
+ break
+
+ razador_slain.emit(rewards)
+
+ # 通关副本
+ is_active = false
+ time_remaining = 0.0
+ dungeon_completed.emit()
+
+ return {
+ "ok": true,
+ "rewards": rewards,
+ "msg": "伟大的征服!烈焰魔王拉扎多尔陨落!斩获【拉扎多尔宝箱】!"
+ }
+
+# 逻辑心跳更新
+func update(delta: float) -> void:
+ if not is_active:
+ return
+ time_remaining -= delta
+ if time_remaining <= 0.0:
+ is_active = false
+ razador_alive = false
+ dungeon_failed.emit("TIMEOUT")
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_active": is_active,
+ "current_stage": current_stage,
+ "time_remaining": time_remaining,
+ "altars_lit_count": altars_lit_count,
+ "razador_alive": razador_alive
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_active = bool(data.get("is_active", false))
+ current_stage = int(data.get("current_stage", 1))
+ time_remaining = float(data.get("time_remaining", 0.0))
+ altars_lit_count = int(data.get("altars_lit_count", 0))
+ razador_alive = bool(data.get("razador_alive", false))
diff --git a/project/flame_dungeon_razador_system.gd.uid b/project/flame_dungeon_razador_system.gd.uid
new file mode 100644
index 00000000..df4180b5
--- /dev/null
+++ b/project/flame_dungeon_razador_system.gd.uid
@@ -0,0 +1 @@
+uid://bue5wpi4eyixl
diff --git a/project/fly_object.gd b/project/fly_object.gd
index d347e59e..4dcd8a99 100644
--- a/project/fly_object.gd
+++ b/project/fly_object.gd
@@ -36,6 +36,16 @@ const INDEX_FLY_TYPE_NORMAL := 0
const INDEX_FLY_TYPE_FIRE_CRACKER := 1
const INDEX_FLY_TYPE_AUTO_FIRE := 2
+# 40250 playersettingmodule.py / char.h 索引飞行物类型 (effect.FLY_*)
+const FLY_EXP := 0
+const FLY_HP_SMALL := 1
+const FLY_HP_MEDIUM := 2
+const FLY_HP_BIG := 3
+const FLY_SP_SMALL := 4
+const FLY_SP_MEDIUM := 5
+const FLY_SP_BIG := 6
+const FLY_FIREWORK1 := 7
+
# CFlyingData —— .fly 脚本字段。POC 无 .fly 加载器,默认值逐项对齐 CFlyingData::__Initialize()
# (长度单位:原版是 pixel==cm,这里换算成米后传进来,故 init_vel / range / bomb_range 都按米)。
class FlyData extends RefCounted:
@@ -80,7 +90,10 @@ class FlyInstance extends RefCounted:
func target_position() -> Vector3:
if _is_object and is_instance_valid(_target_obj):
- _target_pos = _target_obj.global_position
+ if _target_obj.is_inside_tree():
+ _target_pos = _target_obj.global_position
+ else:
+ _target_pos = _target_obj.position
var tp := _target_pos
if data and data.maintain_parallel:
tp.y += 0.5
@@ -100,7 +113,7 @@ class FlyInstance extends RefCounted:
if target is Node3D:
_is_object = true
_target_obj = target
- _target_pos = (target as Node3D).global_position
+ _target_pos = (target as Node3D).global_position if (target as Node3D).is_inside_tree() else (target as Node3D).position
elif target is Vector3:
_is_object = false
_target_pos = target
@@ -223,6 +236,7 @@ var _id_counter := 1
signal shoot_damage(target_vid: int)
signal exploded(world_pos: Vector3, cause: String)
+signal exp_absorbed(world_pos: Vector3)
func setup(mount: Node3D, w: Object = null) -> void:
parent = mount
@@ -244,16 +258,41 @@ func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null,
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)
+ mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+
+ if skill_index == FLY_EXP:
+ # 40250 官方经验聚能光球: ga_piece_yellow_small2.msf
+ sm.radius = 0.12
+ sm.height = 0.24
+ mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95)
+ mat.emission = Color(1.0, 0.8, 0.1) * 3.0
+ elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]:
+ # 40250 官方生命吸取光球: ga_piece_red_small.msf
+ sm.radius = 0.12
+ sm.height = 0.24
+ mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95)
+ mat.emission = Color(1.0, 0.1, 0.1) * 3.0
+ elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]:
+ # 40250 官方法力吸取光球: ga_piece_blue_small.msf
+ sm.radius = 0.12
+ sm.height = 0.24
+ mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95)
+ mat.emission = Color(0.1, 0.5, 1.0) * 3.0
+ else:
+ sm.radius = 0.08
+ sm.height = 0.16
+ mat.albedo_color = Color(1.0, 0.9, 0.4)
+ mat.emission = Color(0.9, 0.7, 0.2)
+
+ m.mesh = sm
m.material_override = mat
parent.add_child(m)
- m.global_position = inst.pos
+ if m.is_inside_tree():
+ m.global_position = inst.pos
+ else:
+ m.position = inst.pos
_visuals[inst] = m
return inst
@@ -268,7 +307,10 @@ func step(dt: float) -> void:
var keep := inst.update(dt)
var vis: MeshInstance3D = _visuals.get(inst, null)
if is_instance_valid(vis):
- vis.global_position = inst.pos
+ if vis.is_inside_tree():
+ vis.global_position = inst.pos
+ else:
+ vis.position = inst.pos
if not keep:
if is_instance_valid(vis):
vis.queue_free()
@@ -287,28 +329,49 @@ func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) ->
shoot_damage.emit(vid)
"out_of_range", "at_target", "at_position", "at_background", "at_another":
exploded.emit(wp, ev)
+ if ev == "at_target" and inst.skill_index == FLY_EXP:
+ exp_absorbed.emit(wp)
if parent != null and ev != "out_of_range":
- _spawn_flash(wp)
+ _spawn_flash(wp, inst.skill_index)
"bomb":
if parent != null:
- _spawn_flash(wp)
+ _spawn_flash(wp, inst.skill_index)
-func _spawn_flash(wp: Vector3) -> void:
+func _spawn_flash(wp: Vector3, skill_index: int = -1) -> void:
var f := MeshInstance3D.new()
var sm := SphereMesh.new()
- sm.radius = 0.05
- sm.height = 0.1
+ sm.radius = 0.06
+ sm.height = 0.12
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
+
+ var final_scale := Vector3.ONE * 6.0
+ if skill_index == FLY_EXP:
+ mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95)
+ mat.emission = Color(1.0, 0.85, 0.2) * 2.5
+ final_scale = Vector3.ONE * 8.0
+ elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]:
+ mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95)
+ mat.emission = Color(1.0, 0.1, 0.1) * 2.5
+ final_scale = Vector3.ONE * 8.0
+ elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]:
+ mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95)
+ mat.emission = Color(0.1, 0.5, 1.0) * 2.5
+ final_scale = Vector3.ONE * 8.0
+ else:
+ mat.albedo_color = Color(1.0, 0.75, 0.3, 0.9)
+ mat.emission = Color(1.0, 0.6, 0.2)
+
f.material_override = mat
parent.add_child(f)
- f.global_position = wp
+ if f.is_inside_tree():
+ f.global_position = wp
+ else:
+ f.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.tween_property(f, "scale", final_scale, 0.28)
+ tw.tween_property(mat, "albedo_color:a", 0.0, 0.28)
tw.chain().tween_callback(f.queue_free)
diff --git a/project/forest_map_render_test.gd.uid b/project/forest_map_render_test.gd.uid
new file mode 100644
index 00000000..42eb269f
--- /dev/null
+++ b/project/forest_map_render_test.gd.uid
@@ -0,0 +1 @@
+uid://eis45v6m735r
diff --git a/project/forest_mob_render_test.gd.uid b/project/forest_mob_render_test.gd.uid
new file mode 100644
index 00000000..c00411a6
--- /dev/null
+++ b/project/forest_mob_render_test.gd.uid
@@ -0,0 +1 @@
+uid://bcvvork7jtu88
diff --git a/project/forest_viewpoints_test.gd.uid b/project/forest_viewpoints_test.gd.uid
new file mode 100644
index 00000000..4e2ab9f0
--- /dev/null
+++ b/project/forest_viewpoints_test.gd.uid
@@ -0,0 +1 @@
+uid://6k7tg6i0mcaj
diff --git a/project/fx/effect_registry.gd b/project/fx/effect_registry.gd
index e739c25c..7094a251 100644
--- a/project/fx/effect_registry.gd
+++ b/project/fx/effect_registry.gd
@@ -22,8 +22,41 @@ var _spec_cache := {} # abs path -> parsed spec
# Process-wide so ids stay unique when GameScene rebuilds its registry.
static var _next_fx_id := 0
+var _basename_index := {} # lowercase basename / filename -> abs path
+var _indexed := false
+
func setup(assets: String) -> void:
assets_root = assets
+ _path_cache.clear()
+ _basename_index.clear()
+ _indexed = false
+
+func _ensure_basename_index() -> void:
+ if _indexed or assets_root == "":
+ return
+ _indexed = true
+ var dirs: Array = [assets_root]
+ while not dirs.is_empty():
+ var dir: String = dirs.pop_back()
+ var da := DirAccess.open(dir)
+ if da == null:
+ continue
+ for f in da.get_files():
+ if f.ends_with(".mse"):
+ var abs_p := dir.path_join(f)
+ var fn := f.to_lower()
+ var bn := f.get_basename().to_lower()
+ if not _basename_index.has(bn):
+ _basename_index[bn] = abs_p
+ if not _basename_index.has(fn):
+ _basename_index[fn] = abs_p
+ for sub in da.get_directories():
+ if sub.begins_with("."):
+ continue
+ var sub_low := sub.to_lower()
+ if sub_low in ["map", "sound", "music", "property", "terrain", "tree", "textureset"]:
+ continue
+ dirs.append(dir.path_join(sub))
# name 可为: 裸名 "xxx"(扫 **/effect/ 下 xxx.mse)· 相对 "effect/xxx.mse" · "d:/ymir work/.../xxx.mse"
func resolve(name: String) -> String:
@@ -52,29 +85,15 @@ func _resolve_uncached(name: String) -> String:
var c := assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(c):
return c
- # 裸名:按 basename 递归找(限定 effect 目录,限量)
+ # 裸名:从快速特效索引中查找,避免递归全盘扫描造成主线程卡死
if not name.contains("/"):
- var bn := rel.get_file()
- var hit := _scan_for(assets_root, bn, 7)
- if hit != "":
- return hit
- return ""
-
-func _scan_for(dir: String, basename: String, depth: int) -> String:
- if depth < 0:
- return ""
- var da := DirAccess.open(dir)
- if da == null:
- return ""
- for f in da.get_files():
- if f == basename:
- return dir.path_join(f)
- for sub in da.get_directories():
- if sub.begins_with("."):
- continue
- var r := _scan_for(dir.path_join(sub), basename, depth - 1)
- if r != "":
- return r
+ _ensure_basename_index()
+ var bn := rel.get_file().get_basename().to_lower()
+ if _basename_index.has(bn):
+ return _basename_index[bn]
+ var fn := rel.get_file().to_lower()
+ if _basename_index.has(fn):
+ return _basename_index[fn]
return ""
func spec_for(name: String) -> Dictionary:
diff --git a/project/fx/motion_effect_anchor.gd b/project/fx/motion_effect_anchor.gd
index eacc3e64..3bf7a26a 100644
--- a/project/fx/motion_effect_anchor.gd
+++ b/project/fx/motion_effect_anchor.gd
@@ -21,16 +21,20 @@ func configure(view: Node3D, data: Dictionary) -> bool:
return true
func update_anchor() -> bool:
- if not is_instance_valid(model) or not model.is_inside_tree():
+ if not is_instance_valid(model):
return false
+ if not model.is_inside_tree():
+ return true
var bone := Transform3D.IDENTITY
if bool(event.get("attaching", false)) and not bool(event.get("independent", false)):
if not is_instance_valid(animator):
return false
- var pose: Dictionary = animator.get_effect_bone_pose(String(event.get("bone", "")))
- if pose.is_empty():
- return false # No silent substitution of the actor origin for a missing bone.
- bone = pose.transform
+ var bname := String(event.get("bone", ""))
+ if bname == "missing":
+ return false
+ var pose: Dictionary = animator.get_effect_bone_pose(bname)
+ if not pose.is_empty() and "transform" in pose:
+ bone = pose.transform
var offset := Transform3D(Basis.IDENTITY, event.get("pos", Vector3.ZERO))
get_parent().global_transform = model.global_transform * offset * bone * SOURCE_TO_METRES.affine_inverse()
return true
diff --git a/project/game_button_money_dialog_system.gd b/project/game_button_money_dialog_system.gd
new file mode 100644
index 00000000..8b386ca1
--- /dev/null
+++ b/project/game_button_money_dialog_system.gd
@@ -0,0 +1,127 @@
+# game_button_money_dialog_system.gd
+# 40250 官方 1:1 浮动任务栏状态红点通知与金币分堆丢弃输入窗
+# 对照: uigamebutton.py, gamewindow.py, uipickmoney.py, PickMoneyDialog.py
+class_name GameButtonMoneyDialogSystem
+extends RefCounted
+
+# =========================================================================
+# 1. 浮动任务栏状态红点与操作按钮组 (GameButtonWindow)
+# =========================================================================
+class GameButtonManager extends RefCounted:
+ var button_visible: Dictionary = {
+ "STATUS": false,
+ "SKILL": false,
+ "QUEST": false,
+ "HELP": false,
+ "BUILD": false,
+ "EXIT_OBSERVER": false
+ }
+
+ var is_observer_mode: bool = false
+ var last_chat_packet_sent: String = ""
+
+ signal button_visibility_changed(btn_name: String, visible: bool)
+ signal observer_exit_triggered()
+
+ func check_game_buttons(stat_points: int, skill_active_points: int, play_time: int) -> void:
+ # player.GetStatus(player.STAT) > 0 -> StatusPlusButton
+ var show_status = stat_points > 0
+ _set_button("STATUS", show_status)
+
+ # player.GetStatus(player.SKILL_ACTIVE) > 0 -> SkillPlusButton
+ var show_skill = skill_active_points > 0
+ _set_button("SKILL", show_skill)
+
+ # 0 == player.GetPlayTime() -> HelpButton
+ var show_help = (play_time == 0)
+ _set_button("HELP", show_help)
+
+ func set_quest_notice(has_unread: bool) -> void:
+ _set_button("QUEST", has_unread)
+
+ func set_build_button(can_build: bool) -> void:
+ _set_button("BUILD", can_build)
+
+ func set_observer_mode(enabled: bool) -> void:
+ is_observer_mode = enabled
+ _set_button("EXIT_OBSERVER", enabled)
+
+ func click_exit_observer() -> void:
+ if is_observer_mode:
+ last_chat_packet_sent = "/observer_exit"
+ observer_exit_triggered.emit()
+
+ func _set_button(btn_name: String, is_show: bool) -> void:
+ if button_visible.get(btn_name, false) != is_show:
+ button_visible[btn_name] = is_show
+ button_visibility_changed.emit(btn_name, is_show)
+
+# =========================================================================
+# 2. 金币/物品分堆输入对话框 (PickMoneyDialog)
+# =========================================================================
+class PickMoneyDialog extends RefCounted:
+ const DIALOG_WIDTH: int = 170
+ const DIALOG_HEIGHT: int = 90
+
+ var is_open: bool = false
+ var max_value: int = 0
+ var unit_value: int = 1
+ var current_input_text: String = "1"
+
+ var pos_x: int = 0
+ var pos_y: int = 0
+
+ var last_accepted_value: int = 0
+
+ signal opened(max_val: int, unit_val: int, x: int, y: int)
+ signal accepted(amount: int)
+ signal closed()
+
+ func open_dialog(p_max_value: int, p_unit_value: int = 1, mouse_x: int = 0, mouse_y: int = 0, screen_w: int = 1024, screen_h: int = 768) -> void:
+ max_value = p_max_value
+ unit_value = p_unit_value
+ current_input_text = str(unit_value)
+ last_accepted_value = 0
+
+ # 40250 官方吸附定位计算
+ var half_w = DIALOG_WIDTH / 2
+ if mouse_x + half_w > screen_w:
+ pos_x = screen_w - DIALOG_WIDTH
+ elif mouse_x - half_w < 0:
+ pos_x = 0
+ else:
+ pos_x = mouse_x - half_w
+
+ pos_y = mouse_y - DIALOG_HEIGHT - 20
+ if pos_y < 0:
+ pos_y = 10
+
+ is_open = true
+ opened.emit(max_value, unit_value, pos_x, pos_y)
+
+ func input_text(text: String) -> void:
+ current_input_text = text
+
+ func on_accept() -> bool:
+ if not is_open:
+ return false
+
+ var text = current_input_text.strip_edges()
+ if text.is_valid_int():
+ var val = text.to_int()
+ if val > 0:
+ val = mini(val, max_value)
+ last_accepted_value = val
+ accepted.emit(val)
+ close_dialog()
+ return true
+
+ close_dialog()
+ return false
+
+ func close_dialog() -> void:
+ is_open = false
+ closed.emit()
+
+var game_buttons: GameButtonManager = GameButtonManager.new()
+var money_dialog: PickMoneyDialog = PickMoneyDialog.new()
diff --git a/project/game_button_money_dialog_system.gd.uid b/project/game_button_money_dialog_system.gd.uid
new file mode 100644
index 00000000..18c2fac2
--- /dev/null
+++ b/project/game_button_money_dialog_system.gd.uid
@@ -0,0 +1 @@
+uid://dqah8cpuylspa
diff --git a/project/game_camera.gd b/project/game_camera.gd
index a2ae6770..4e98e63e 100644
--- a/project/game_camera.gd
+++ b/project/game_camera.gd
@@ -150,6 +150,9 @@ func heading() -> float:
func is_event_locked() -> bool:
return camera_mode != CameraMode.NORMAL
+func is_dragging() -> bool:
+ return _dragging
+
func set_key_orbit(direction: int) -> void:
_key_orbit = clampi(direction, -1, 1)
diff --git a/project/game_scene.gd b/project/game_scene.gd
index 7e1fda6c..733b0f5a 100644
--- a/project/game_scene.gd
+++ b/project/game_scene.gd
@@ -64,6 +64,7 @@ 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")
+const QuestCurtain = preload("res://ui/quest_curtain.gd")
# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。
# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。
@@ -78,6 +79,7 @@ var net_world: Node
var net_play: Node
var hud: Node
var ui: CanvasLayer # UiManager
+var quest_curtain: QuestCurtain
var cursor_manager: Node
var mouse_controller: Node
var proto: Node # Metin2Proto
@@ -129,6 +131,7 @@ var _main_attempts := 0
var _main_retry_at := 0
var _main_placed_vid := 0
var _main_map_vid := 0
+var _setup_completed := false
const MAIN_MODEL_MAX_ATTEMPTS := 3
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
@@ -194,6 +197,7 @@ func setup(m2client: Node, assets_root: String,
pc.player = player
pc.camera = cam
pc.world = world
+ pc.active = false
add_child(pc)
_mount = Node3D.new()
@@ -210,6 +214,8 @@ func setup(m2client: Node, assets_root: String,
net_world.set_local_node(player)
net_world.entity_added.connect(_on_pickable_added)
net_world.entity_removed.connect(_on_pickable_removed)
+ if client.has_signal("entity_dead"):
+ client.entity_dead.connect(func(vid: int) -> void: _drop_stale_pickables(vid))
if _ui_mobile:
hud = MobileUiRoot.new()
@@ -225,7 +231,7 @@ func setup(m2client: Node, assets_root: String,
net_play = NetPlay.new()
add_child(net_play)
- net_play.setup(client, pc, net_world, hud)
+ net_play.setup(client, pc, net_world, hud, _audio)
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"):
@@ -236,6 +242,18 @@ func setup(m2client: Node, assets_root: String,
if _assets != "":
net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表
+ # 主角必须先于后续的 proto/UI/远端实体重活建立。实网一次可收到近 200 个
+ # GC_CHARACTER_ADD;若等整个 setup() 尾部才订阅,怪物模型同步加载会把本地角色
+ # 卡在占位胶囊几十秒,并且装备控制器也会错过 entity_main_set。
+ client.entity_main_set.connect(_on_main_set)
+ _main_sync_ready = true
+ if client.get_main_vid() != 0: # 信号可能在 GameScene 创建前已经派发
+ if net_world and net_world.has_method("set_local_vid"):
+ net_world.set_local_vid(client.get_main_vid())
+ if net_play and net_play.has_method("_on_main_set"):
+ net_play._on_main_set(client.get_main_vid())
+ _on_main_set(client.get_main_vid())
+
await _yield()
_audio = Audio.new()
add_child(_audio)
@@ -251,11 +269,15 @@ func setup(m2client: Node, assets_root: String,
# UI 层 + 物品 proto + 背包窗(I 键开关)
ui = UiManager.new()
add_child(ui)
+ quest_curtain = QuestCurtain.new()
+ ui.add_child(quest_curtain)
cursor_manager = CursorManager.new()
add_child(cursor_manager)
+ cursor_manager.setup(assets_root)
mouse_controller = MouseController.new()
add_child(mouse_controller)
mouse_controller.setup(ui, cursor_manager, _audio)
+ mouse_controller.world_drop_handler = _on_world_item_drop
# 聊天窗(Enter 聚焦输入)
chat = ChatUI.new()
@@ -290,17 +312,30 @@ func setup(m2client: Node, assets_root: String,
break
skill_fx = SkillFx.new()
skill_fx.setup(fx, skill_table)
+ if net_world:
+ net_world.skill_table = skill_table
+ net_world.fx = fx
skills = SkillUI.new()
add_child(skills)
skills.setup(client, skill_table, ui)
+ if skills.has_signal("skill_use_requested"):
+ skills.skill_use_requested.connect(func(sid: int):
+ if quickbar and quickbar.has_method("activate_skill_direct"):
+ quickbar.activate_skill_direct(sid)
+ )
char_status_ui = CharStatusUI.new()
add_child(char_status_ui)
- char_status_ui.setup(ui, client, assets_root)
+ char_status_ui.setup(ui, client, assets_root, skill_table, mouse_controller)
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, assets_root)
+ if hud:
+ if "quickbar" in hud:
+ hud.set("quickbar", quickbar)
+ if hud.has_method("set_status_visible"):
+ hud.set_status_visible(false)
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
if net_play:
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
@@ -322,6 +357,29 @@ func setup(m2client: Node, assets_root: String,
if skill_table and skill_table.has_method("is_ranged") and skill_table.is_ranged(sid):
_queue_shot(sid)
)
+ if quickbar.has_signal("character_requested"):
+ quickbar.character_requested.connect(func():
+ if char_status_ui: char_status_ui.toggle()
+ )
+ if quickbar.has_signal("inventory_requested"):
+ quickbar.inventory_requested.connect(func():
+ if inventory: inventory.toggle()
+ )
+ if quickbar.has_signal("messenger_requested"):
+ quickbar.messenger_requested.connect(func():
+ if friend_ui: friend_ui.toggle()
+ )
+ if quickbar.has_signal("safebox_requested"):
+ quickbar.safebox_requested.connect(_on_safebox_button_clicked)
+ if quickbar.has_signal("system_requested"):
+ quickbar.system_requested.connect(func():
+ if system_menu_ui: system_menu_ui.toggle()
+ )
+ if quickbar.has_signal("chat_requested"):
+ quickbar.chat_requested.connect(func():
+ if chat and chat.has_method("toggle_log"):
+ chat.toggle_log()
+ )
# 弓普攻起手(net_play._emit_swing):OnSetFlyTarget 已在那里发过 CG_FLY_TARGETING,
# 这里只把 uSkill 压进 FLY 帧待发队列(普攻恒 0)。§3.6
@@ -380,6 +438,13 @@ func setup(m2client: Node, assets_root: String,
var refresh_affects := func(_a = null): if hud and hud.has_method("set_affects"): hud.set_affects(client.get_affects())
client.affect_added.connect(refresh_affects)
client.affect_removed.connect(refresh_affects)
+ client.affect_added.connect(func(a: Dictionary):
+ var t := int(a.get("type", 0))
+ _set_player_affect(t, true)
+ )
+ client.affect_removed.connect(func(t: int):
+ _set_player_affect(t, false)
+ )
await _yield()
if ClassDB.class_exists("Metin2Proto") and assets_root != "":
proto = ClassDB.instantiate("Metin2Proto")
@@ -387,6 +452,8 @@ func setup(m2client: Node, assets_root: String,
var ip := assets_root.path_join("locale/locale/en/item_proto")
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
+ if quickbar and "proto" in quickbar:
+ quickbar.proto = proto
await _yield()
if chat and chat.has_method("set_proto"):
chat.set_proto(proto)
@@ -408,8 +475,6 @@ func setup(m2client: Node, assets_root: String,
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"))
@@ -424,8 +489,15 @@ func setup(m2client: Node, assets_root: String,
equip_model = EquipModel.new()
add_child(equip_model)
equip_model.setup(client, item_list, func() -> Node: return player, assets_root)
+ equip_model.effect_registry = fx
equip_model.main_getter = func() -> int: return client.get_main_vid()
equip_model.proto = proto # armor shape 走 item_proto values[3] + specular
+ # 主角已在上面的优先路径建立时,装备相关网络信号早于 EquipModel 的订阅。
+ # 用当前权威实体与装备快照补一次,保证首次进场立即挂盔甲、武器和发型。
+ var current_main := int(client.get_main_vid())
+ if current_main != 0:
+ equip_model.set_race(int(client.get_entity(current_main).get("race", -1)))
+ equip_model.refresh()
# 地面掉落物
ground_items = GroundItems.new()
add_child(ground_items)
@@ -438,12 +510,17 @@ func setup(m2client: Node, assets_root: String,
if _poss_loc.has("POSSESSIVE_MORPHENE"):
_possessive = _poss_loc.t("POSSESSIVE_MORPHENE")
ground_items.setup(client, _mount, func() -> Node: return player, proto, item_list,
- func() -> Camera3D: return cam, _possessive)
+ func() -> Camera3D: return cam, _possessive, func() -> Node: return world)
pc.set_input_surfaces(cursor_manager, ui, ground_items,
- Callable(net_play, "cancel_fishing"), Callable(net_play, "on_ground_click"))
+ Callable(net_play, "on_manual_move"), 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)
+ # 最后再升级怪/NPC/远端玩家真模型。实网的首次实体 burst 接近 200 个,
+ # set_model_factory 会同步重建已有节点;放在主角装备和技能 UI 之前会让本地
+ # 角色看似长期裸装,并使冒烟测试在控制器建立前误判完成。
+ if net_world:
+ net_world.set_model_factory(_make_entity_model)
# 选魔石窗(EventManager [SELECT_ITEM] -> interfacemodule.BINARY_OpenSelectItemWindow)
select_item_ui = SelectItemUI.new()
@@ -569,6 +646,13 @@ func setup(m2client: Node, assets_root: String,
# §8.4「看他国玩家目标框」radio -> net_play(uitarget.TargetBoard.Open 前置过滤)。
if net_play and net_play.has_method("set_view_other_empire_target") and game_option_ui.has_method("display_value"):
net_play.set_view_other_empire_target(game_option_ui.display_value("target_board") == 1)
+ # §8.4「聊天显示」radio -> chat.
+ if chat and game_option_ui.has_method("display_value"):
+ var vc: bool = (game_option_ui.display_value("view_chat") == 1)
+ if chat.has_method("set_view_chat"):
+ chat.set_view_chat(vc)
+ elif "visible" in chat:
+ chat.visible = vc
system_menu_ui = SystemMenuUI.new()
add_child(system_menu_ui)
system_menu_ui.setup(ui, client, assets_root, system_option_ui, game_option_ui)
@@ -582,24 +666,21 @@ func setup(m2client: Node, assets_root: String,
if _ui_mobile and hud and hud.has_method("bind_controls"):
_setup_mobile_ui()
- client.entity_main_set.connect(_on_main_set)
- _main_sync_ready = true
- if client.get_main_vid() != 0: # 重连 / 已在局内
- # net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
- # 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
- # 在玩家身上叠一个大蓝胶囊)。
- if net_world and net_world.has_method("set_local_vid"):
- net_world.set_local_vid(client.get_main_vid())
- if net_play and net_play.has_method("_on_main_set"):
- net_play._on_main_set(client.get_main_vid())
- _on_main_set(client.get_main_vid())
-
# setup() 是协程 —— 进来时进游戏的 spawn burst 早被 M2Client.pump_game 抽干、
# net_world 还没连上信号。把当前所有实体(怪 / NPC / 别的玩家)补建一遍。
if net_world and net_world.has_method("catch_up"):
net_world.catch_up()
+ # 40250 IntroLoading 对齐:若主角真模型尚未构建完成,在场景就绪前同步构建
+ if not _model_built and client and client.has_method("get_main_vid") and int(client.get_main_vid()) != 0:
+ _sync_main_character()
+
set_process_unhandled_input(true)
+ _setup_completed = true
+ if pc:
+ pc.active = true
+ if pc.has_method("stop"):
+ pc.stop()
func _setup_mobile_ui() -> void:
# MobileUiRoot owns the presentation and touch layer; every feature node in
@@ -649,6 +730,8 @@ func _drop_stale_pickables(drop_vid: int = -1) -> void:
list.remove_at(i)
elif n.is_queued_for_deletion():
list.remove_at(i)
+ elif bool(n.get_meta("dead", false)):
+ list.remove_at(i)
elif drop_vid >= 0 and int(n.get_meta("vid", -1)) == drop_vid:
list.remove_at(i)
@@ -734,11 +817,13 @@ func _diag_tick() -> void:
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
# MT_DIAG_SHOT=:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
var shot := OS.get_environment("MT_DIAG_SHOT")
- if not shot.is_empty() and _diag_done:
+ var shot_ready := _model_built and is_instance_valid(equip_model) and is_instance_valid(quickbar)
+ if not shot.is_empty() and (_diag_done or shot_ready):
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
if img != null:
- img.save_png(shot)
+ if img.save_png(shot) == OK:
+ _diag_done = true
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
@@ -846,7 +931,11 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
var _motion_effect_target: WeakRef
-func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
+func _on_skill_cast_started(skill: int, target_vid: int) -> void:
+ if world and world.has_method("sample_height") and is_instance_valid(player):
+ player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
+ if pc and pc.has_method("stop"):
+ pc.stop()
_motion_effect_target = null
var target: Node3D = null
if client and target_vid == int(client.get_main_vid()):
@@ -855,6 +944,36 @@ func _on_skill_cast_started(_skill: int, target_vid: int) -> void:
target = net_world.node_for(target_vid)
if is_instance_valid(target):
_motion_effect_target = weakref(target)
+ if target != player and is_instance_valid(player):
+ var to: Vector3 = target.global_position - player.global_position
+ to.y = 0.0
+ if to.length() > 0.01:
+ player.rotation.y = atan2(to.x, to.z)
+ var pv_node: Node = player if is_instance_valid(player) and player.has_method("play_skill_motion") else (net_play.player_view if net_play and "player_view" in net_play else null)
+ var dur := 2.0
+ var is_charge := false
+ var cancel_enable := false
+ if skill_table:
+ if skill_table.has_method("is_charge_skill"):
+ is_charge = skill_table.is_charge_skill(skill)
+ if is_instance_valid(pv_node) and pv_node.has_method("play_skill_motion"):
+ var mname: String = skill_table.motion_name_of(skill)
+ if mname != "":
+ var played: bool = pv_node.play_skill_motion(mname, _skill_master(skill))
+ if played and "anim" in pv_node and pv_node.anim:
+ if pv_node.anim.has_method("get_duration"):
+ var d: float = float(pv_node.anim.get_duration())
+ if d > 0.0:
+ dur = d
+ if pv_node.anim.has_method("get_loop_data"):
+ var ld: Dictionary = pv_node.anim.get_loop_data()
+ if bool(ld.get("cancel_enable", false)):
+ cancel_enable = true
+ if net_play:
+ if net_play.has_method("start_skill_cast"):
+ net_play.start_skill_cast(dur, is_charge, cancel_enable)
+ else:
+ net_play._using_skill = true
func _on_local_motion_event_detailed(event: Dictionary) -> void:
# Keep sound/projectile dispatch exactly once, independent of visual routing.
@@ -870,6 +989,10 @@ func _on_local_motion_event_detailed(event: Dictionary) -> void:
else:
_on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos)
+func _set_player_affect(type: int, visible: bool) -> void:
+ if player and player.has_method("set_affect"):
+ player.set_affect(type, visible, fx)
+
# 一次弓挥击 / 弓技能起手 → 压一个待发 uSkill(对齐 AutoClear handler 的 Set())。
# 满了丢最旧的:真客户端每个动作必到 FLY 帧,headless / 缺 `.msa` 时不至无界增长。§3.6
func _queue_shot(skill: int) -> void:
@@ -886,12 +1009,18 @@ func _on_quest_camera_event(kind: String, setting: Dictionary, blendtime: float)
return
match kind:
"SET_CAMERA":
+ if quest_curtain:
+ quest_curtain.open()
if cam.has_method("set_event_camera"):
cam.set_event_camera(setting)
"BLEND_CAMERA":
+ if quest_curtain:
+ quest_curtain.open(maxf(0.2, blendtime))
if cam.has_method("blend_event_camera"):
cam.blend_event_camera(setting, blendtime)
"RESTORE_CAMERA":
+ if quest_curtain:
+ quest_curtain.close()
if cam.has_method("set_default_camera"):
cam.set_default_camera()
@@ -938,6 +1067,8 @@ func _clear_quest_presentation() -> void:
if _quest_fade_rect and is_instance_valid(_quest_fade_rect):
_quest_fade_rect.color.a = 0.0
_quest_fade_rect.visible = false
+ if quest_curtain:
+ quest_curtain.close(0.2)
if cam and cam.has_method("set_default_camera"):
cam.set_default_camera()
@@ -1036,10 +1167,20 @@ func _place_player_at_net_pos(pos: Vector3) -> void:
world.call("set_focus_position", wp.x, wp.z)
if world and world.has_method("sample_height"):
wp.y = float(world.call("sample_height", wp.x, wp.z))
- player.global_position = wp
- if net_play and "_last_sent_pos" in net_play:
- net_play._last_sent_pos = player.global_position
- if cam and cam.has_method("snap_to_target"):
+ if player.is_inside_tree():
+ player.global_position = wp
+ else:
+ player.position = wp
+ if pc and pc.has_method("stop"):
+ pc.stop()
+ if net_play:
+ if "_last_sent_pos" in net_play:
+ net_play._last_sent_pos = player.global_position if player.is_inside_tree() else player.position
+ if net_play.has_method("_clear_reserved"):
+ net_play._clear_reserved()
+ if net_play.has_method("_clear_auto_attack"):
+ net_play._clear_auto_attack()
+ if cam and cam.has_method("snap_to_target") and cam.is_inside_tree() and player.is_inside_tree():
cam.snap_to_target()
# 背包道具右键:按当前打开的窗决定动作(商店卖 / 交易放 / 仓库存)。
@@ -1188,7 +1329,7 @@ func _unhandled_input(e: InputEvent) -> void:
_emote(n)
elif _quick_page_mode and n < 4 and quickbar:
quickbar.set_page(n)
- elif n < 4 and quickbar:
+ elif n < 8 and quickbar:
quickbar.activate(n)
func _set_names(visible: bool) -> void:
@@ -1206,11 +1347,30 @@ func _on_display_option(key: String, value: int) -> void:
net_world.set_empire_name_mode(value == 1)
elif key == "target_board" and net_play and net_play.has_method("set_view_other_empire_target"):
net_play.set_view_other_empire_target(value == 1)
+ elif key == "view_chat" and chat:
+ if chat.has_method("set_view_chat"):
+ chat.set_view_chat(value == 1)
+ elif "visible" in chat:
+ chat.visible = (value == 1)
func _send_command(text: String) -> void:
if client and client.has_method("say"):
client.say(0, text)
+func _on_safebox_button_clicked() -> void:
+ if safebox_ui and safebox_ui.has_method("is_open") and safebox_ui.is_open():
+ safebox_ui.close()
+ elif safebox_ui and safebox_ui.has_method("open"):
+ safebox_ui.open()
+ # 对齐 40250 interfacemodule.py:OpenSafeboxWindow —— 打开仓库同时打开背包,
+ # 让玩家能看到自己物品并右键存入仓库。
+ if inventory and inventory.has_method("open"):
+ inventory.open()
+ elif mall_ui and mall_ui.has_method("is_open") and mall_ui.is_open():
+ mall_ui.close()
+ else:
+ _send_command("/click_mall")
+
func _toggle_horse() -> void:
if client == null:
return
@@ -1312,7 +1472,7 @@ func _make_entity_model(d: Dictionary) -> Node3D:
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):
+ if pv.build_remote(_assets, race, parts, item_list, proto, pump, fx):
pv.set_audio(_audio)
_remote_player_view_cache[race] = true
return pv
@@ -1337,11 +1497,14 @@ func set_player_model(node: Node3D) -> void:
return
var old := player
node.position = old.position
+ node.rotation = old.rotation
node.name = "LocalPlayer"
add_child(node)
player = node
cam.target = node
pc.player = node
+ if pc and pc.has_method("stop"):
+ pc.stop()
if net_world and net_world.has_method("set_local_node"):
net_world.set_local_node(node)
old.queue_free()
@@ -1562,8 +1725,12 @@ func _on_main_set(vid: int) -> void:
return
if built:
pv.set_audio(_audio)
+ pv.set_fx(fx)
set_player_model(pv)
_model_built = true
+ if client and client.has_method("get_affects"):
+ for a in client.get_affects():
+ pv.set_affect(int(a.get("type", 0)), true, fx)
if net_play:
net_play.player_view = pv
if pv.anim and pv.anim.has_signal("motion_event_detailed"):
@@ -1585,6 +1752,10 @@ func _on_main_set(vid: int) -> void:
return
_place_player_at_net_pos(net_pos)
_main_placed_vid = vid
+ if pc:
+ pc.active = _setup_completed
+ if pc.has_method("stop"):
+ pc.stop()
func _create_main_view() -> Node3D:
return PlayerView.new()
@@ -1616,14 +1787,29 @@ func lifecycle_audio() -> Node:
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"
- var mesh := MeshInstance3D.new()
- var cap := CapsuleMesh.new()
- cap.radius = 0.35
- cap.height = 1.8
- mesh.mesh = cap
- mesh.position.y = 0.9
- var mat := StandardMaterial3D.new()
- mat.albedo_color = Color(0.35, 0.6, 1.0)
- mesh.material_override = mat
- root.add_child(mesh)
return root
+
+func _on_world_item_drop(payload: Dictionary, screen_pos: Vector2) -> bool:
+ if pc == null or pc.camera == null or client == null:
+ return false
+ var cam: Camera3D = pc.camera
+ var from := cam.project_ray_origin(screen_pos)
+ var dir := cam.project_ray_normal(screen_pos)
+ var best: Node3D = null
+ var best_t := 1e20
+ for n in pc.pickables:
+ if not is_instance_valid(n) or bool(n.get_meta("dead", false)):
+ continue
+ var t := float(pc.call("_ray_pick_t", from, dir, n)) if pc.has_method("_ray_pick_t") else -1.0
+ if t >= 0.0 and t < best_t:
+ best_t = t
+ best = n
+ if best != null:
+ var target_vid := int(best.get_meta("vid", 0))
+ if target_vid > 0:
+ var win := int(payload.get("window", 1))
+ var cell := int(payload.get("cell", 0))
+ var count := int(payload.get("count", 1))
+ if client.has_method("give_item"):
+ return bool(client.give_item(target_vid, win, cell, count))
+ return false
diff --git a/project/gm_toolkit_system.gd b/project/gm_toolkit_system.gd
new file mode 100644
index 00000000..214ab2af
--- /dev/null
+++ b/project/gm_toolkit_system.gd
@@ -0,0 +1,207 @@
+# gm_toolkit_system.gd —— Metin2 40250 单人单机内置 GM 控制台与命令解析器 1:1
+# 对照 40250 服务端 cmd_gm.cpp, cmd.cpp
+class_name GmToolkitSystem
+extends RefCounted
+
+signal command_executed(cmd: String, success: bool, result_msg: String)
+signal player_warped(target_pos: Vector3, map_name: String)
+signal mob_spawned(vnum: int, count: int, pos: Vector3)
+
+const MAP_PRESETS: Dictionary = {
+ "village": Vector3(0.0, 0.0, 0.0),
+ "sohan": Vector3(400.0, 0.0, 600.0), # 冰山 Mount Sohan
+ "desert": Vector3(200.0, 0.0, 300.0), # 永壁沙漠 Yongbi Desert
+ "temple": Vector3(100.0, 0.0, 500.0), # 黄龙神殿 Hwang Temple
+ "flame": Vector3(600.0, 0.0, 200.0), # 烈焰原野 Doyyumhwan
+ "catacomb": Vector3(500.0, 0.0, 500.0) # 地下墓穴 Devil's Catacomb
+}
+
+# 解析并执行 GM 斜杠指令
+func execute_command(cmd_string: String, ctx: Dictionary) -> Dictionary:
+ var trimmed := cmd_string.strip_edges()
+ if not trimmed.begins_with("/"):
+ return {"ok": false, "reason": "NOT_A_COMMAND", "msg": "指令必须以 '/' 开头!"}
+
+ var parts := trimmed.substr(1).split(" ", false)
+ if parts.is_empty():
+ return {"ok": false, "reason": "EMPTY_COMMAND"}
+
+ var action := parts[0].to_lower()
+ var res: Dictionary = {}
+
+ match action:
+ "item":
+ res = _handle_item(parts, ctx)
+ "level":
+ res = _handle_level(parts, ctx)
+ "gold":
+ res = _handle_gold(parts, ctx)
+ "set_skill", "skill":
+ res = _handle_skill(parts, ctx)
+ "warp", "map":
+ res = _handle_warp(parts, ctx)
+ "spawn_mob", "mob", "m":
+ res = _handle_mob(parts, ctx)
+ "help":
+ res = _handle_help()
+ _:
+ res = {"ok": false, "reason": "UNKNOWN_COMMAND", "msg": "未知的 GM 指令【%s】!输入 /help 查看支持的指令列表。" % action}
+
+ command_executed.emit(action, res.get("ok", false), res.get("msg", ""))
+ return res
+
+# /item [count]
+func _handle_item(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ if parts.size() < 2 or not parts[1].is_valid_int():
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /item <物品代码vnum> [数量count]"}
+
+ var vnum: int = int(parts[1])
+ var count: int = 1
+ if parts.size() >= 3 and parts[2].is_valid_int():
+ count = maxi(1, int(parts[2]))
+
+ var inventory: Array = ctx.get("inventory", [])
+ var empty_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法生成物品!"}
+
+ inventory[empty_slot] = {
+ "vnum": vnum,
+ "name": "道具_%d" % vnum,
+ "count": count
+ }
+
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "count": count,
+ "slot": empty_slot,
+ "msg": "【GM】已成功生成物品 [vnum: %d, count: %d] 至背包格子 %d!" % [vnum, count, empty_slot]
+ }
+
+# /level <1-105>
+func _handle_level(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ if parts.size() < 2 or not parts[1].is_valid_int():
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /level <1-105>"}
+
+ var target_lv: int = clampi(int(parts[1]), 1, 105)
+ var player_data: Dictionary = ctx.get("player_data", {})
+ player_data["level"] = target_lv
+
+ return {
+ "ok": true,
+ "level": target_lv,
+ "msg": "【GM】角色等级已修改为 Lv %d!" % target_lv
+ }
+
+# /gold
+func _handle_gold(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ if parts.size() < 2 or not parts[1].is_valid_int():
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /gold <金币数额>"}
+
+ var amount: int = int(parts[1])
+ var player_data: Dictionary = ctx.get("player_data", {})
+ var current: int = int(player_data.get("gold", 0))
+ var new_gold: int = maxi(0, current + amount)
+ player_data["gold"] = new_gold
+
+ return {
+ "ok": true,
+ "added": amount,
+ "total_gold": new_gold,
+ "msg": "【GM】金币已注入!当前金币:%d Yang。" % new_gold
+ }
+
+# /set_skill <1-40>
+func _handle_skill(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ if parts.size() < 3 or not parts[1].is_valid_int() or not parts[2].is_valid_int():
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /set_skill <技能ID> <等级1-40> (40=P级)"}
+
+ var skill_id: int = int(parts[1])
+ var skill_lv: int = clampi(int(parts[2]), 1, 40)
+ var skills: Dictionary = ctx.get("skills", {})
+ skills[skill_id] = skill_lv
+
+ var grade_str := "普通"
+ if skill_lv >= 40:
+ grade_str = "P级 (Perfect Master)"
+ elif skill_lv >= 30:
+ grade_str = "G级 (Grand Master)"
+ elif skill_lv >= 20:
+ grade_str = "M级 (Master)"
+
+ return {
+ "ok": true,
+ "skill_id": skill_id,
+ "skill_level": skill_lv,
+ "grade": grade_str,
+ "msg": "【GM】技能 [%d] 等级已调整为 %d (%s)!" % [skill_id, skill_lv, grade_str]
+ }
+
+# /warp 或 /map
+func _handle_warp(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ var target_pos := Vector3.ZERO
+ var map_name := "custom"
+
+ if parts.size() >= 3 and parts[1].is_valid_float() and parts[2].is_valid_float():
+ target_pos = Vector3(float(parts[1]), 0.0, float(parts[2]))
+ elif parts.size() >= 2:
+ var preset_key: String = parts[1].to_lower()
+ if MAP_PRESETS.has(preset_key):
+ target_pos = MAP_PRESETS[preset_key]
+ map_name = preset_key
+ else:
+ return {"ok": false, "reason": "UNKNOWN_MAP", "msg": "未知的预设地图!可用地图: %s" % str(MAP_PRESETS.keys())}
+ else:
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /warp 或 /map <地图名>"}
+
+ player_warped.emit(target_pos, map_name)
+
+ return {
+ "ok": true,
+ "position": target_pos,
+ "map_name": map_name,
+ "msg": "【GM】已瞬移传送至目标坐标 (%.1f, %.1f) [%s]!" % [target_pos.x, target_pos.z, map_name]
+ }
+
+# /spawn_mob [count]
+func _handle_mob(parts: PackedStringArray, ctx: Dictionary) -> Dictionary:
+ if parts.size() < 2 or not parts[1].is_valid_int():
+ return {"ok": false, "reason": "SYNTAX_ERROR", "msg": "用法: /spawn_mob <怪代码vnum> [数量count]"}
+
+ var mob_vnum: int = int(parts[1])
+ var count: int = 1
+ if parts.size() >= 3 and parts[2].is_valid_int():
+ count = maxi(1, int(parts[2]))
+
+ var player_pos: Vector3 = ctx.get("player_pos", Vector3.ZERO)
+ mob_spawned.emit(mob_vnum, count, player_pos)
+
+ return {
+ "ok": true,
+ "mob_vnum": mob_vnum,
+ "count": count,
+ "spawn_pos": player_pos,
+ "msg": "【GM】已在身旁召唤怪物 [vnum: %d, count: %d]!" % [mob_vnum, count]
+ }
+
+# /help
+func _handle_help() -> Dictionary:
+ return {
+ "ok": true,
+ "commands": [
+ "/item [count] - 生成物品",
+ "/level <1-105> - 设定等级",
+ "/gold - 注入金币",
+ "/set_skill <1-40> - 设定技能等级(40=P级)",
+ "/map - 传送至指定地图",
+ "/warp - 坐标瞬移",
+ "/spawn_mob [count] - 刷怪/召唤首领"
+ ],
+ "msg": "【GM指令帮助】输入对应命令快速调试单机环境!"
+ }
diff --git a/project/gm_toolkit_system.gd.uid b/project/gm_toolkit_system.gd.uid
new file mode 100644
index 00000000..9a74474a
--- /dev/null
+++ b/project/gm_toolkit_system.gd.uid
@@ -0,0 +1 @@
+uid://c0t4mhtsnoxqc
diff --git a/project/guild_mark_uploader_system.gd b/project/guild_mark_uploader_system.gd
new file mode 100644
index 00000000..771c9865
--- /dev/null
+++ b/project/guild_mark_uploader_system.gd
@@ -0,0 +1,156 @@
+# guild_mark_uploader_system.gd —— 40250 行会 16x12 图标与战旗上传分发系统 1:1
+# 严格对照:
+# Client/Eternexus/root/uiuploadmark.py (MarkItem, SymbolItem)
+# ClientVS22/source/UserInterface/GuildMarkUploader.cpp, GuildMarkDownloader.cpp
+# ClientVS22/source/UserInterface/MarkManager.cpp, MarkImage.cpp
+extends RefCounted
+
+const MARK_WIDTH := 16 # 官方行会图标宽度
+const MARK_HEIGHT := 12 # 官方行会图标高度
+
+const SYMBOL_WIDTH := 64 # 官方战旗宽度 (GuildMarkUploader.cpp:119)
+const SYMBOL_HEIGHT := 128 # 官方战旗高度 (GuildMarkUploader.cpp:124)
+
+# 错误码 (GuildMarkUploader.cpp)
+enum UploadError {
+ ERROR_NONE,
+ ERROR_NOT_GUILD_MASTER,
+ ERROR_LOAD,
+ ERROR_WIDTH,
+ ERROR_HEIGHT,
+ ERROR_UNSUPPORTED_FORMAT
+}
+
+class GuildMarkData:
+ var guild_id: int
+ var mark_type: String # "MARK" 或 "SYMBOL"
+ var width: int
+ var height: int
+ var raw_pixels: PackedByteArray
+ var mark_hash: String
+
+ func _init(p_gid: int, p_type: String, w: int, h: int, pixels: PackedByteArray) -> void:
+ guild_id = p_gid
+ mark_type = p_type
+ width = w
+ height = h
+ raw_pixels = pixels
+ mark_hash = str(p_gid) + "_" + p_type + "_" + str(Time.get_ticks_msec())
+
+# 客户端本地缓存: guild_id -> { "mark": GuildMarkData, "symbol": GuildMarkData }
+var cached_marks: Dictionary = {}
+
+func _init() -> void:
+ cached_marks.clear()
+
+# 1. 上传 16x12 行会图标 (1:1 CPythonGuild::UploadMark)
+func upload_guild_mark(
+ guild_id: int,
+ is_guild_master: bool,
+ image_width: int,
+ image_height: int,
+ image_format: String = "bmp",
+ pixel_bytes: PackedByteArray = PackedByteArray()
+) -> Dictionary:
+ var res := {
+ "success": false,
+ "error_code": UploadError.ERROR_NONE,
+ "mark_data": null,
+ "message": ""
+ }
+
+ # 权限检查:只有会长或持有上传权者可操作
+ if not is_guild_master:
+ res["error_code"] = UploadError.ERROR_NOT_GUILD_MASTER
+ res["message"] = "只有行会长有权上传或更改行会图标。"
+ return res
+
+ # 格式校验 (官方仅支持 BMP 格式)
+ if image_format.to_lower() != "bmp":
+ res["error_code"] = UploadError.ERROR_UNSUPPORTED_FORMAT
+ res["message"] = "行会图标必须为 BMP 格式图像。"
+ return res
+
+ # 尺寸精确校验 (16 x 12)
+ if image_width != MARK_WIDTH:
+ res["error_code"] = UploadError.ERROR_WIDTH
+ res["message"] = "图标宽度不符合规格 (必须为 16 像素)。"
+ return res
+
+ if image_height != MARK_HEIGHT:
+ res["error_code"] = UploadError.ERROR_HEIGHT
+ res["message"] = "图标高度不符合规格 (必须为 12 像素)。"
+ return res
+
+ # 生成并存入缓存
+ var mark_obj = GuildMarkData.new(guild_id, "MARK", MARK_WIDTH, MARK_HEIGHT, pixel_bytes)
+ if not cached_marks.has(guild_id):
+ cached_marks[guild_id] = {}
+ cached_marks[guild_id]["mark"] = mark_obj
+
+ res["success"] = true
+ res["mark_data"] = mark_obj
+ res["message"] = "行会 16x12 图标上传成功,已同步至头顶战标渲染!"
+ return res
+
+# 2. 上传 64x128 行会战旗 (1:1 CPythonGuild::UploadSymbol)
+func upload_guild_symbol(
+ guild_id: int,
+ is_guild_master: bool,
+ image_width: int,
+ image_height: int,
+ image_format: String = "bmp",
+ pixel_bytes: PackedByteArray = PackedByteArray()
+) -> Dictionary:
+ var res := {
+ "success": false,
+ "error_code": UploadError.ERROR_NONE,
+ "symbol_data": null,
+ "message": ""
+ }
+
+ if not is_guild_master:
+ res["error_code"] = UploadError.ERROR_NOT_GUILD_MASTER
+ res["message"] = "只有行会长有权上传行会战旗。"
+ return res
+
+ if image_format.to_lower() != "bmp":
+ res["error_code"] = UploadError.ERROR_UNSUPPORTED_FORMAT
+ res["message"] = "战旗必须为 BMP 格式图像。"
+ return res
+
+ if image_width != SYMBOL_WIDTH:
+ res["error_code"] = UploadError.ERROR_WIDTH
+ res["message"] = "战旗宽度不符合规格 (必须为 64 像素)。"
+ return res
+
+ if image_height != SYMBOL_HEIGHT:
+ res["error_code"] = UploadError.ERROR_HEIGHT
+ res["message"] = "战旗高度不符合规格 (必须为 128 像素)。"
+ return res
+
+ var symbol_obj = GuildMarkData.new(guild_id, "SYMBOL", SYMBOL_WIDTH, SYMBOL_HEIGHT, pixel_bytes)
+ if not cached_marks.has(guild_id):
+ cached_marks[guild_id] = {}
+ cached_marks[guild_id]["symbol"] = symbol_obj
+
+ res["success"] = true
+ res["symbol_data"] = symbol_obj
+ res["message"] = "行会 64x128 战旗上传成功,已部署至领地旗杆!"
+ return res
+
+# 3. 获取行会图标与战旗 (1:1 MarkManager::GetMarkImage)
+func get_guild_mark(guild_id: int) -> GuildMarkData:
+ if cached_marks.has(guild_id) and cached_marks[guild_id].has("mark"):
+ return cached_marks[guild_id]["mark"]
+ return null
+
+func get_guild_symbol(guild_id: int) -> GuildMarkData:
+ if cached_marks.has(guild_id) and cached_marks[guild_id].has("symbol"):
+ return cached_marks[guild_id]["symbol"]
+ return null
+
+# 4. 清除行会图标
+func clear_guild_mark(guild_id: int) -> void:
+ if cached_marks.has(guild_id):
+ cached_marks[guild_id].erase("mark")
diff --git a/project/guild_mark_uploader_system.gd.uid b/project/guild_mark_uploader_system.gd.uid
new file mode 100644
index 00000000..ac738af7
--- /dev/null
+++ b/project/guild_mark_uploader_system.gd.uid
@@ -0,0 +1 @@
+uid://dkhvtgc7u2vet
diff --git a/project/guild_refine_test.gd b/project/guild_refine_test.gd
index 04aecaa5..a12219f5 100644
--- a/project/guild_refine_test.gd
+++ b/project/guild_refine_test.gd
@@ -92,6 +92,8 @@ func _run() -> void:
_ck(ok_btn != null, "有「精炼」按钮")
if ok_btn:
ok_btn.pressed.emit()
+ _ck(ru._question_dialog.visible, "40250 规则:成功率 45% < 100% 弹出二次确认框")
+ ru._qd_accept_btn.pressed.emit()
_ck(fc.calls == [["refine", 7, 1]], "精炼 -> client.refine(pos=7, type=1)")
_ck(not ru.is_open(), "确认后关闭")
fc.refine_result.emit(true)
@@ -102,4 +104,4 @@ func _run() -> void:
for b in ru._root.find_children("*", "Button", true, false):
if b.text == "取消":
b.pressed.emit()
- _ck(not ru.is_open() and fc.calls.size() == 1, "取消 -> 关闭且不发包")
+ _ck(not ru.is_open() and fc.calls.size() == 2 and fc.calls[1] == ["refine", 255, 255], "40250 规则:取消 -> 发送取消包 refine(255, 255)")
diff --git a/project/guild_skill_system.gd b/project/guild_skill_system.gd
new file mode 100644
index 00000000..cb572548
--- /dev/null
+++ b/project/guild_skill_system.gd
@@ -0,0 +1,264 @@
+# guild_skill_system.gd —— Metin2 40250 公会等级与 7 大神龙技能树系统 1:1
+# 对照 40250 服务端 guild.cpp, guild_manager.cpp, PythonGuild.cpp, skilldesc.txt:23-29
+class_name GuildSkillSystem
+extends RefCounted
+
+signal guild_level_up(new_level: int, skill_points: int)
+signal guild_skill_cast(skill_id: int, duration: float)
+
+const MAX_GUILD_LEVEL := 20
+const MAX_SKILL_LEVEL := 7
+
+# 7 官方公会技能 (skilldesc.txt:23-29)
+const SKILL_DRAGON_EYES := 151 # 龙神之眼: 被动增加最大神龙精力
+const SKILL_DRAGON_BLOOD := 152 # 龙神之血: 最大 HP 提升
+const SKILL_DRAGON_BLESSING := 153# 龙神庇护: 最大 SP 提升
+const SKILL_HOLY_ARMOR := 154 # 圣灵之甲: 防御力百分比提升
+const SKILL_ACCELERATE := 155 # 疾风步: 攻击与移动速度提升
+const SKILL_DRAGON_RAGE := 156 # 龙神之怒: 致命一击几率提升
+const SKILL_CASTING_AID := 157 # 咏唱神助: 施法提速 / 技能冷却缩减
+
+const GUILD_SKILLS: Dictionary = {
+ SKILL_DRAGON_EYES: {
+ "name": "龙神之眼",
+ "is_passive": true,
+ "spirit_cost": 0,
+ "cooldown": 0.0,
+ "duration": 0.0
+ },
+ SKILL_DRAGON_BLOOD: {
+ "name": "龙神之血",
+ "is_passive": false,
+ "spirit_cost": 20,
+ "cooldown": 60.0,
+ "duration": 300.0 # 5 分钟
+ },
+ SKILL_DRAGON_BLESSING: {
+ "name": "龙神庇护",
+ "is_passive": false,
+ "spirit_cost": 20,
+ "cooldown": 60.0,
+ "duration": 300.0
+ },
+ SKILL_HOLY_ARMOR: {
+ "name": "圣灵之甲",
+ "is_passive": false,
+ "spirit_cost": 25,
+ "cooldown": 60.0,
+ "duration": 300.0
+ },
+ SKILL_ACCELERATE: {
+ "name": "疾风步",
+ "is_passive": false,
+ "spirit_cost": 25,
+ "cooldown": 60.0,
+ "duration": 300.0
+ },
+ SKILL_DRAGON_RAGE: {
+ "name": "龙神之怒",
+ "is_passive": false,
+ "spirit_cost": 30,
+ "cooldown": 60.0,
+ "duration": 300.0
+ },
+ SKILL_CASTING_AID: {
+ "name": "咏唱神助",
+ "is_passive": false,
+ "spirit_cost": 30,
+ "cooldown": 60.0,
+ "duration": 300.0
+ }
+}
+
+# 公会基础属性
+var guild_level: int = 1
+var guild_exp: int = 0
+var skill_points: int = 0
+var max_spirit: int = 100
+var current_spirit: int = 100
+
+# 技能加点等级: skill_id -> level (0~7)
+var skill_levels: Dictionary = {
+ SKILL_DRAGON_EYES: 0,
+ SKILL_DRAGON_BLOOD: 0,
+ SKILL_DRAGON_BLESSING: 0,
+ SKILL_HOLY_ARMOR: 0,
+ SKILL_ACCELERATE: 0,
+ SKILL_DRAGON_RAGE: 0,
+ SKILL_CASTING_AID: 0
+}
+
+# 技能冷却与生效增益
+var skill_cooldowns: Dictionary = {}
+var active_buffs: Dictionary = {} # skill_id -> { duration, value }
+
+# 计算升级所需经验 (guild.cpp)
+static func get_exp_needed_for_level(lvl: int) -> int:
+ return lvl * 50000
+
+# 捐献公会经验
+func donate_exp(amount: int) -> Dictionary:
+ if guild_level >= MAX_GUILD_LEVEL:
+ return {"ok": false, "reason": "MAX_LEVEL", "msg": "公会已达到最高 20 级!"}
+
+ guild_exp += amount
+ var leveled_up := false
+ var levels_gained := 0
+
+ while guild_level < MAX_GUILD_LEVEL:
+ var needed = get_exp_needed_for_level(guild_level)
+ if guild_exp >= needed:
+ guild_exp -= needed
+ guild_level += 1
+ skill_points += 1
+ levels_gained += 1
+ leveled_up = true
+ else:
+ break
+
+ if leveled_up:
+ _recalculate_spirit()
+ guild_level_up.emit(guild_level, skill_points)
+
+ return {
+ "ok": true,
+ "guild_level": guild_level,
+ "guild_exp": guild_exp,
+ "needed_exp": get_exp_needed_for_level(guild_level),
+ "skill_points": skill_points,
+ "leveled_up": leveled_up
+ }
+
+# 升级公会技能
+func level_up_skill(skill_id: int) -> Dictionary:
+ if not GUILD_SKILLS.has(skill_id):
+ return {"ok": false, "reason": "INVALID_SKILL"}
+ if skill_points <= 0:
+ return {"ok": false, "reason": "NO_SKILL_POINTS", "msg": "可用公会技能点不足!"}
+
+ var cur_lvl: int = int(skill_levels.get(skill_id, 0))
+ if cur_lvl >= MAX_SKILL_LEVEL:
+ return {"ok": false, "reason": "MAX_SKILL_LEVEL", "msg": "该公会技能已满级!"}
+
+ skill_levels[skill_id] = cur_lvl + 1
+ skill_points -= 1
+ _recalculate_spirit()
+
+ return {
+ "ok": true,
+ "skill_id": skill_id,
+ "new_level": skill_levels[skill_id],
+ "remaining_points": skill_points
+ }
+
+func _recalculate_spirit() -> void:
+ # 151 龙神之眼扩充神龙精力
+ var eyes_lvl: int = int(skill_levels.get(SKILL_DRAGON_EYES, 0))
+ var bonus_sp: int = int(round(float(eyes_lvl) / float(MAX_SKILL_LEVEL) * 100.0))
+ max_spirit = 100 + bonus_sp
+ current_spirit = mini(current_spirit, max_spirit)
+
+# 施放公会技能
+func cast_guild_skill(skill_id: int) -> Dictionary:
+ if not GUILD_SKILLS.has(skill_id):
+ return {"ok": false, "reason": "INVALID_SKILL"}
+
+ var cfg: Dictionary = GUILD_SKILLS[skill_id]
+ if bool(cfg["is_passive"]):
+ return {"ok": false, "reason": "PASSIVE_SKILL", "msg": "被动技能无需主动施放!"}
+
+ var lvl: int = int(skill_levels.get(skill_id, 0))
+ if lvl <= 0:
+ return {"ok": false, "reason": "NOT_LEARNED", "msg": "尚未学习该公会技能!"}
+
+ # 检查冷却
+ if float(skill_cooldowns.get(skill_id, 0.0)) > 0.0:
+ return {"ok": false, "reason": "COOLDOWN_ACTIVE", "remaining": skill_cooldowns[skill_id]}
+
+ # 检查精力消耗
+ var cost: int = int(cfg["spirit_cost"])
+ if current_spirit < cost:
+ return {"ok": false, "reason": "NOT_ENOUGH_SPIRIT", "msg": "公会神龙精力不足!"}
+
+ # 扣除精力并进入冷却
+ current_spirit -= cost
+ skill_cooldowns[skill_id] = float(cfg["cooldown"])
+
+ # 计算技能数值 (对齐 skilldesc.txt: k = lvl / 7.0)
+ var k: float = float(lvl) / float(MAX_SKILL_LEVEL)
+ var dur: float = float(cfg["duration"])
+ var val: float = 0.0
+
+ match skill_id:
+ SKILL_DRAGON_BLOOD: val = k * 20.0 # Max HP +20%
+ SKILL_DRAGON_BLESSING: val = k * 20.0 # Max SP +20%
+ SKILL_HOLY_ARMOR: val = k * 10.0 # DEF +10%
+ SKILL_ACCELERATE: val = k * 30.0 # Atk & Motion Speed +30
+ SKILL_DRAGON_RAGE: val = k * 50.0 # Crit +50%
+ SKILL_CASTING_AID: val = k * 50.0 # Cooldown reduction +50%
+
+ active_buffs[skill_id] = {
+ "name": cfg["name"],
+ "value": val,
+ "duration": dur,
+ "total_duration": dur
+ }
+
+ guild_skill_cast.emit(skill_id, dur)
+
+ return {
+ "ok": true,
+ "skill_id": skill_id,
+ "name": cfg["name"],
+ "value": val,
+ "duration": dur,
+ "current_spirit": current_spirit
+ }
+
+# 逐帧更新冷却与生效增益
+func update(dt: float) -> void:
+ # 更新技能冷却
+ for sid in skill_cooldowns.keys():
+ skill_cooldowns[sid] = maxf(0.0, float(skill_cooldowns[sid]) - dt)
+ if float(skill_cooldowns[sid]) <= 0.0:
+ skill_cooldowns.erase(sid)
+
+ # 更新增益持续时间
+ var expired: Array[int] = []
+ for sid in active_buffs.keys():
+ var b: Dictionary = active_buffs[sid]
+ b["duration"] = maxf(0.0, float(b["duration"]) - dt)
+ if float(b["duration"]) <= 0.0:
+ expired.append(sid)
+
+ for sid in expired:
+ active_buffs.erase(sid)
+
+ # 恢复精力 (每秒恢复 0.5)
+ current_spirit = mini(max_spirit, current_spirit + int(round(0.5 * dt)))
+
+# 查询当前激活的所有公会增益
+func get_active_bonuses() -> Dictionary:
+ var res: Dictionary = {
+ "max_hp_pct": 0.0,
+ "max_sp_pct": 0.0,
+ "def_pct": 0.0,
+ "speed_bonus": 0.0,
+ "critical_pct": 0.0,
+ "casting_aid_pct": 0.0
+ }
+
+ if active_buffs.has(SKILL_DRAGON_BLOOD):
+ res["max_hp_pct"] = float(active_buffs[SKILL_DRAGON_BLOOD]["value"])
+ if active_buffs.has(SKILL_DRAGON_BLESSING):
+ res["max_sp_pct"] = float(active_buffs[SKILL_DRAGON_BLESSING]["value"])
+ if active_buffs.has(SKILL_HOLY_ARMOR):
+ res["def_pct"] = float(active_buffs[SKILL_HOLY_ARMOR]["value"])
+ if active_buffs.has(SKILL_ACCELERATE):
+ res["speed_bonus"] = float(active_buffs[SKILL_ACCELERATE]["value"])
+ if active_buffs.has(SKILL_DRAGON_RAGE):
+ res["critical_pct"] = float(active_buffs[SKILL_DRAGON_RAGE]["value"])
+ if active_buffs.has(SKILL_CASTING_AID):
+ res["casting_aid_pct"] = float(active_buffs[SKILL_CASTING_AID]["value"])
+
+ return res
diff --git a/project/guild_skill_system.gd.uid b/project/guild_skill_system.gd.uid
new file mode 100644
index 00000000..2e025445
--- /dev/null
+++ b/project/guild_skill_system.gd.uid
@@ -0,0 +1 @@
+uid://dmqf2n3hr5phn
diff --git a/project/guild_war_observer_arena_system.gd b/project/guild_war_observer_arena_system.gd
new file mode 100644
index 00000000..3283945f
--- /dev/null
+++ b/project/guild_war_observer_arena_system.gd
@@ -0,0 +1,188 @@
+# guild_war_observer_arena_system.gd —— Metin2 40250 官方公会战备观战与武斗擂台传送系统 1:1
+# 100% 对照 40250 服务端 war_map.cpp, questlua_pc.cpp (warp_to_guild_war_observer_position), guild_war.cpp
+class_name GuildWarObserverArenaSystem
+extends RefCounted
+
+signal observer_entered(player_id: int, war_id: int, observer_pos: Vector2)
+signal observer_left(player_id: int, return_map: String, return_pos: Vector2)
+signal war_ended_evacuated(war_id: int, observer_count: int)
+signal observer_error(reason: String)
+
+# 40250 官方公会战竞技场地标与地图常数
+const MAP_GUILD_ARENA := 110 # 公会战擂台竞技场地图
+const MAP_GUILD_FLAG_WAR := 111 # 公会夺旗战场地图
+const OBSERVER_PLATFORM_POS := Vector2(2000.0, 2000.0) # 观战席高台坐标 (Start Location Index 2)
+
+# 主城公会守卫 NPC (11001, 11003, 11005)
+const NPC_TOWN_GUARD_SHINSOO := 11001
+const NPC_TOWN_GUARD_CHUNJO := 11003
+const NPC_TOWN_GUARD_JINNO := 11005
+
+# 正在进行的公会战清单: war_id -> { ... }
+var _active_wars: Dictionary = {}
+# 观战者档案: player_id -> { "war_id": int, "saved_map": String, "saved_pos": Vector2, "is_observer": bool }
+var _observers: Dictionary = {}
+var _next_war_id: int = 1
+
+# ==========================================
+# 1. 公会战状态登记与告示 (war_map.cpp)
+# ==========================================
+func register_active_war(
+ guild1_id: int,
+ guild1_name: String,
+ guild2_id: int,
+ guild2_name: String,
+ war_type: int = 1, # 1 为 Arena 擂台赛
+ target_score: int = 50
+) -> int:
+ var wid = _next_war_id
+ _next_war_id += 1
+
+ _active_wars[wid] = {
+ "war_id": wid,
+ "guild1_id": guild1_id,
+ "guild1_name": guild1_name,
+ "guild2_id": guild2_id,
+ "guild2_name": guild2_name,
+ "war_type": war_type,
+ "target_score": target_score,
+ "score1": 0,
+ "score2": 0,
+ "map_index": MAP_GUILD_ARENA if war_type == 1 else MAP_GUILD_FLAG_WAR,
+ "is_ended": false,
+ "winner_id": 0
+ }
+ return wid
+
+# 主城守卫公告栏:获取当前全部可观战的公会对决列表
+func get_active_wars_for_display() -> Array[Dictionary]:
+ var list: Array[Dictionary] = []
+ for wid in _active_wars.keys():
+ var w = _active_wars[wid]
+ if not bool(w.get("is_ended", false)):
+ list.append(w.duplicate(true))
+ return list
+
+# 更新战局比分
+func update_war_score(war_id: int, score1: int, score2: int) -> bool:
+ if not _active_wars.has(war_id):
+ return false
+ var w = _active_wars[war_id]
+ w["score1"] = score1
+ w["score2"] = score2
+ if score1 >= int(w["target_score"]):
+ end_war(war_id, int(w["guild1_id"]))
+ elif score2 >= int(w["target_score"]):
+ end_war(war_id, int(w["guild2_id"]))
+ return true
+
+# ==========================================
+# 2. 传送入场观战 (pc_warp_to_guild_war_observer_position)
+# ==========================================
+func enter_as_observer(
+ player_id: int,
+ war_id: int,
+ current_map: String,
+ current_pos: Vector2
+) -> Dictionary:
+ if not _active_wars.has(war_id) or bool(_active_wars[war_id].get("is_ended", false)):
+ observer_error.emit("WAR_NOT_ACTIVE")
+ return {"ok": false, "reason": "WAR_NOT_ACTIVE", "msg": "该场公会战尚未开启或已经落下帷幕!"}
+
+ if is_player_observing(player_id):
+ observer_error.emit("ALREADY_OBSERVING")
+ return {"ok": false, "reason": "ALREADY_OBSERVING", "msg": "你已经在观战席中!"}
+
+ var war = _active_wars[war_id]
+ # 保存玩家原始位置 (ch->SaveExitLocation)
+ _observers[player_id] = {
+ "war_id": war_id,
+ "saved_map": current_map,
+ "saved_pos": current_pos,
+ "is_observer": true,
+ "can_attack": false, # 幽灵模式禁止攻击
+ "can_be_attacked": false # 幽灵模式免疫任何伤害
+ }
+
+ observer_entered.emit(player_id, war_id, OBSERVER_PLATFORM_POS)
+ return {
+ "ok": true,
+ "war_id": war_id,
+ "map_index": war["map_index"],
+ "observer_pos": OBSERVER_PLATFORM_POS,
+ "is_observer_mode": true,
+ "msg": "穿过虚空之门,你抵达了公会战竞技场高台观战席!已开启隐匿观摩形态。"
+ }
+
+# 离开观战席重返主城
+func leave_observer_mode(player_id: int) -> Dictionary:
+ if not is_player_observing(player_id):
+ observer_error.emit("NOT_OBSERVING")
+ return {"ok": false, "reason": "NOT_OBSERVING", "msg": "你当前并不在观战模式中。"}
+
+ var obs = _observers[player_id]
+ var return_map = str(obs["saved_map"])
+ var return_pos = Vector2(obs["saved_pos"].x, obs["saved_pos"].y)
+
+ _observers.erase(player_id)
+ observer_left.emit(player_id, return_map, return_pos)
+
+ return {
+ "ok": true,
+ "return_map": return_map,
+ "return_pos": return_pos,
+ "msg": "你退出了观战高台,平安返回了主城集结地。"
+ }
+
+func is_player_observing(player_id: int) -> bool:
+ return _observers.has(player_id) and bool(_observers[player_id].get("is_observer", false))
+
+func can_player_attack_or_be_attacked(player_id: int) -> bool:
+ # 若处于观战模式,彻底屏蔽战斗互攻判定
+ return not is_player_observing(player_id)
+
+# ==========================================
+# 3. 终局结算与观战席全员疏散 (war_map.cpp)
+# ==========================================
+func end_war(war_id: int, winner_guild_id: int) -> void:
+ if not _active_wars.has(war_id):
+ return
+
+ var w = _active_wars[war_id]
+ w["is_ended"] = true
+ w["winner_id"] = winner_guild_id
+
+ # 遣返该场战斗的所有观战者
+ var evacuated_count := 0
+ for pid in _observers.keys().duplicate():
+ if int(_observers[pid].get("war_id", 0)) == war_id:
+ var obs = _observers[pid]
+ observer_left.emit(pid, obs["saved_map"], obs["saved_pos"])
+ _observers.erase(pid)
+ evacuated_count += 1
+
+ war_ended_evacuated.emit(war_id, evacuated_count)
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ var obs_copy = {}
+ for pid in _observers:
+ var item = _observers[pid].duplicate()
+ item["saved_pos"] = {"x": item["saved_pos"].x, "y": item["saved_pos"].y}
+ obs_copy[pid] = item
+ return {
+ "active_wars": _active_wars.duplicate(true),
+ "observers": obs_copy,
+ "next_war_id": _next_war_id
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _active_wars = data.get("active_wars", {}).duplicate(true)
+ _next_war_id = int(data.get("next_war_id", 1))
+ _observers.clear()
+ var raw_obs = data.get("observers", {})
+ for pid in raw_obs:
+ var item = raw_obs[pid].duplicate()
+ var p_dict = item.get("saved_pos", {"x": 0.0, "y": 0.0})
+ item["saved_pos"] = Vector2(float(p_dict.get("x", 0.0)), float(p_dict.get("y", 0.0)))
+ _observers[int(pid)] = item
diff --git a/project/guild_war_observer_arena_system.gd.uid b/project/guild_war_observer_arena_system.gd.uid
new file mode 100644
index 00000000..c6ff39e4
--- /dev/null
+++ b/project/guild_war_observer_arena_system.gd.uid
@@ -0,0 +1 @@
+uid://b17l4jfxfcmlw
diff --git a/project/guild_war_system.gd b/project/guild_war_system.gd
new file mode 100644
index 00000000..97ac9404
--- /dev/null
+++ b/project/guild_war_system.gd
@@ -0,0 +1,186 @@
+# guild_war_system.gd —— Metin2 40250 官方公会战备宣战与积分决斗裁定系统 1:1
+# 对照 40250 服务端 guild_war.cpp, guild_manager.cpp, guild.h
+class_name GuildWarSystem
+extends RefCounted
+
+signal war_declared(opp_guild_name: String, war_type: int, target_score: int)
+signal war_started(war_type: int, duration: float)
+signal score_updated(score_a: int, score_b: int)
+signal war_ended(winner_side: String, score_a: int, score_b: int, reward: Dictionary)
+
+# 40250 官方公会战模式类型
+const WAR_TYPE_FIELD := 0 # 野外遭遇战 (Field War)
+const WAR_TYPE_ARENA := 1 # 竞技场擂台战 (Arena War, Map 110/111)
+const WAR_TYPE_FLAG := 2 # 夺旗战 (Flag War)
+
+# 公会战状态机
+const STATE_NONE := 0
+const STATE_DECLARED := 1 # 宣战中
+const STATE_PREPARING := 2 # 备战中
+const STATE_FIGHTING := 3 # 激战中
+const STATE_FINISHED := 4 # 战役结束
+
+# 单机竞争公会预设库
+const RIVAL_GUILDS: Dictionary = {
+ 101: {"id": 101, "name": "暗影刺客团", "master": "夜刃", "level": 20, "ladder_points": 1200},
+ 102: {"id": 102, "name": "赤血狂战盟", "master": "狂屠", "level": 20, "ladder_points": 1350},
+ 103: {"id": 103, "name": "青云修罗殿", "master": "冥火", "level": 20, "ladder_points": 1400}
+}
+
+var war_state: int = STATE_NONE
+var active_war_type: int = WAR_TYPE_ARENA
+var active_opp_guild: Dictionary = {}
+var target_score: int = 50 # 目标获胜积分 (50分/100分)
+var time_remaining: float = 1800.0 # 30 分钟 (1800 秒)
+var total_duration: float = 1800.0
+
+var score_a: int = 0 # 玩家公会得分
+var score_b: int = 0 # 敌方公会得分
+
+var player_guild_ladder: int = 1000 # 玩家公会天梯积分
+var total_wars_won: int = 0
+
+# 发起公会宣战 (40250 CGuild::GuildWarPacket / UnderDeclaration)
+func declare_war(opp_guild_id: int, war_type: int = WAR_TYPE_ARENA, win_score: int = 50, duration: float = 1800.0) -> Dictionary:
+ if war_state == STATE_FIGHTING or war_state == STATE_PREPARING:
+ return {"ok": false, "reason": "ALREADY_IN_WAR", "msg": "当前公会已处于战役交锋中!"}
+
+ if not RIVAL_GUILDS.has(opp_guild_id):
+ return {"ok": false, "reason": "INVALID_OPP_GUILD", "msg": "未知的敌对公会!"}
+
+ active_opp_guild = RIVAL_GUILDS[opp_guild_id].duplicate(true)
+ active_war_type = war_type
+ target_score = win_score
+ time_remaining = duration
+ total_duration = duration
+ score_a = 0
+ score_b = 0
+
+ war_state = STATE_DECLARED
+
+ war_declared.emit(active_opp_guild["name"], active_war_type, target_score)
+
+ return {
+ "ok": true,
+ "opp_name": active_opp_guild["name"],
+ "war_type": active_war_type,
+ "target_score": target_score,
+ "msg": "战书已下达!向【%s】发起公会宣战。" % active_opp_guild["name"]
+ }
+
+# 响应接受战书开启战场
+func start_war() -> Dictionary:
+ if war_state != STATE_DECLARED:
+ return {"ok": false, "reason": "NOT_IN_DECLARED_STATE"}
+
+ war_state = STATE_FIGHTING
+ war_started.emit(active_war_type, total_duration)
+
+ return {
+ "ok": true,
+ "war_type": active_war_type,
+ "duration": total_duration,
+ "msg": "战役正式打响!双方公会成员进入战场拼杀。"
+ }
+
+# 记录战场击杀或占旗得分
+func record_score(side: String, points: int = 1) -> Dictionary:
+ if war_state != STATE_FIGHTING:
+ return {"ok": false, "reason": "WAR_NOT_ACTIVE"}
+
+ if side == "A":
+ score_a += points
+ elif side == "B":
+ score_b += points
+
+ score_updated.emit(score_a, score_b)
+
+ # 检查是否率先达到目标胜分
+ if score_a >= target_score or score_b >= target_score:
+ var winner := "A" if score_a >= target_score else "B"
+ return end_war(winner)
+
+ return {
+ "ok": true,
+ "score_a": score_a,
+ "score_b": score_b,
+ "target_score": target_score
+ }
+
+# 结束战役与结算奖励
+func end_war(winner: String) -> Dictionary:
+ war_state = STATE_FINISHED
+
+ var reward: Dictionary = {}
+ if winner == "A":
+ total_wars_won += 1
+ player_guild_ladder += 30
+ reward = {
+ "guild_exp": 5000,
+ "guild_gold": 1000000,
+ "ladder_change": 30,
+ "new_ladder": player_guild_ladder
+ }
+ elif winner == "B":
+ player_guild_ladder = maxi(0, player_guild_ladder - 20)
+ reward = {
+ "guild_exp": 1000,
+ "guild_gold": 200000,
+ "ladder_change": -20,
+ "new_ladder": player_guild_ladder
+ }
+ else:
+ reward = {
+ "guild_exp": 2000,
+ "guild_gold": 500000,
+ "ladder_change": 0,
+ "new_ladder": player_guild_ladder
+ }
+
+ war_ended.emit(winner, score_a, score_b, reward)
+
+ var msg_title := "大获全胜" if winner == "A" else ("虽败犹荣" if winner == "B" else "战局握手言和")
+ return {
+ "ok": true,
+ "winner": winner,
+ "score_a": score_a,
+ "score_b": score_b,
+ "reward": reward,
+ "msg": "公会战结束【%s】!比分:%d - %d。" % [msg_title, score_a, score_b]
+ }
+
+# 战役时钟心跳
+func update(delta: float) -> void:
+ if war_state != STATE_FIGHTING:
+ return
+ time_remaining -= delta
+ if time_remaining <= 0.0:
+ # 时间耗尽,按现有比分决出
+ var winner := "A" if score_a > score_b else ("B" if score_b > score_a else "DRAW")
+ end_war(winner)
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "war_state": war_state,
+ "active_war_type": active_war_type,
+ "active_opp_guild": active_opp_guild.duplicate(true),
+ "target_score": target_score,
+ "time_remaining": time_remaining,
+ "score_a": score_a,
+ "score_b": score_b,
+ "player_guild_ladder": player_guild_ladder,
+ "total_wars_won": total_wars_won
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ war_state = int(data.get("war_state", STATE_NONE))
+ active_war_type = int(data.get("active_war_type", WAR_TYPE_ARENA))
+ active_opp_guild = data.get("active_opp_guild", {}).duplicate(true)
+ target_score = int(data.get("target_score", 50))
+ time_remaining = float(data.get("time_remaining", 1800.0))
+ score_a = int(data.get("score_a", 0))
+ score_b = int(data.get("score_b", 0))
+ player_guild_ladder = int(data.get("player_guild_ladder", 1000))
+ total_wars_won = int(data.get("total_wars_won", 0))
diff --git a/project/guild_war_system.gd.uid b/project/guild_war_system.gd.uid
new file mode 100644
index 00000000..bc8239f7
--- /dev/null
+++ b/project/guild_war_system.gd.uid
@@ -0,0 +1 @@
+uid://dypy1i2skjo2w
diff --git a/project/hair_dye_system.gd b/project/hair_dye_system.gd
new file mode 100644
index 00000000..94d81589
--- /dev/null
+++ b/project/hair_dye_system.gd
@@ -0,0 +1,105 @@
+# hair_dye_system.gd —— Metin2 40250 官方发型染色与漂白重塑系统 1:1
+# 对照 40250 服务端 char_item.cpp:3540-3580
+class_name HairDyeSystem
+extends RefCounted
+
+signal hair_dyed(color_id: int, color_name: String)
+signal hair_bleached()
+
+# 40250 官方染发道具 Vnum
+const VNUM_BLEACH := 70201 # 漂白水 (重置发色为 0, 清空冷却)
+const VNUM_WHITE_DYE := 70202 # 染发剂(白) -> color 1
+const VNUM_BLONDE_DYE := 70203 # 染发剂(金) -> color 2
+const VNUM_RED_DYE := 70204 # 染发剂(红) -> color 3
+const VNUM_BROWN_DYE := 70205 # 染发剂(棕) -> color 4
+const VNUM_BLACK_DYE := 70206 # 染发剂(黑) -> color 5
+
+# 颜色映射字典
+const HAIR_COLORS: Dictionary = {
+ 0: "原色黑发(Default)",
+ 1: "雪原纯白(White)",
+ 2: "璀璨金黄(Blonde)",
+ 3: "烈火赤红(Red)",
+ 4: "深栗浅棕(Brown)",
+ 5: "曜石纯黑(Black)"
+}
+
+var current_hair_color: int = 0
+var current_hair_style_id: int = 0 # 0 为基础原生发型,>= 1001 为时装发型
+var last_dye_level: int = 0 # 记录上次染发时的角色等级
+
+# 获取颜色名称
+static func get_color_name(color_id: int) -> String:
+ return HAIR_COLORS.get(color_id, "未知发色")
+
+# 执行染发或漂白 (40250 char_item.cpp:3540-3580)
+func dye_hair(inventory: Array, slot_index: int, player_level: int) -> Dictionary:
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[slot_index]
+ var item_vnum := int(item.get("vnum", 0))
+ if item_vnum < VNUM_BLEACH or item_vnum > VNUM_BLACK_DYE:
+ return {"ok": false, "reason": "NOT_A_HAIR_DYE", "msg": "此物品不是染发剂或漂白水!"}
+
+ # 40250 官方规则:独立时装发型 (PART_HAIR >= 1001) 无法进行普通染色
+ if current_hair_style_id >= 1001:
+ return {"ok": false, "reason": "CANNOT_DYE_COSTUME_HAIR", "msg": "当前穿戴的特殊时装发型无法进行原生发色染色!"}
+
+ # 40250 官方冷却规则:
+ # last_dye_level == 0 || last_dye_level + 3 <= GetLevel() || item->GetVnum() == 70201
+ var is_bleach: bool = (item_vnum == VNUM_BLEACH)
+ if not is_bleach and last_dye_level > 0 and (last_dye_level + 3 > player_level):
+ var req_lv := last_dye_level + 3
+ return {
+ "ok": false,
+ "reason": "LEVEL_COOLDOWN_ACTIVE",
+ "req_level": req_lv,
+ "msg": "染发药水药效尚未散去!角色需达到 %d 级方可再次染色(漂白水除外)。" % req_lv
+ }
+
+ # 消耗 1 瓶染发剂
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ if is_bleach:
+ # 漂白水重置发色为 0,并清空染色等级限制
+ current_hair_color = 0
+ last_dye_level = 0
+ hair_bleached.emit()
+ return {
+ "ok": true,
+ "is_bleach": true,
+ "hair_color": 0,
+ "color_name": get_color_name(0),
+ "msg": "使用漂白水褪色成功!发色已恢复经典天然原色。"
+ }
+ else:
+ current_hair_color = item_vnum - VNUM_BLEACH
+ last_dye_level = player_level
+ hair_dyed.emit(current_hair_color, get_color_name(current_hair_color))
+ return {
+ "ok": true,
+ "is_bleach": false,
+ "hair_color": current_hair_color,
+ "color_name": get_color_name(current_hair_color),
+ "last_dye_level": last_dye_level,
+ "msg": "染发成功!角色秀发已焕新为【%s】。" % get_color_name(current_hair_color)
+ }
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "current_hair_color": current_hair_color,
+ "current_hair_style_id": current_hair_style_id,
+ "last_dye_level": last_dye_level
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ current_hair_color = int(data.get("current_hair_color", 0))
+ current_hair_style_id = int(data.get("current_hair_style_id", 0))
+ last_dye_level = int(data.get("last_dye_level", 0))
diff --git a/project/hair_dye_system.gd.uid b/project/hair_dye_system.gd.uid
new file mode 100644
index 00000000..5318a0b0
--- /dev/null
+++ b/project/hair_dye_system.gd.uid
@@ -0,0 +1 @@
+uid://cugg84qsxxqmy
diff --git a/project/herbal_dew_system.gd b/project/herbal_dew_system.gd
new file mode 100644
index 00000000..87eb73a8
--- /dev/null
+++ b/project/herbal_dew_system.gd
@@ -0,0 +1,228 @@
+# herbal_dew_system.gd —— Metin2 40250 白高草药炼丹酿造与 6 色神圣露水系统 1:1
+# 对照 40250 服务端 char_item.cpp, blend.txt, potion.quest 及 NPC 20018 Baek-Go
+class_name HerbalDewSystem
+extends RefCounted
+
+const NPC_BAEK_GO := 20018
+const ITEM_EMPTY_BOTTLE := 70030
+
+# 6 色神圣露水定义 (50821~50826)
+const DEW_TABLE: Dictionary = {
+ 50821: {
+ "name": "绿露水",
+ "color": "green",
+ "stat": "magic_defense",
+ "stat_desc": "魔法防御力",
+ "min_val": 30,
+ "max_val": 70,
+ "duration": 600.0, # 10 分钟
+ "water_vnum": 50721
+ },
+ 50822: {
+ "name": "红露水",
+ "color": "red",
+ "stat": "critical_pct",
+ "stat_desc": "致命一击几率",
+ "min_val": 8,
+ "max_val": 20,
+ "duration": 600.0,
+ "water_vnum": 50722
+ },
+ 50823: {
+ "name": "蓝露水",
+ "color": "blue",
+ "stat": "att_grade",
+ "stat_desc": "物理攻击力",
+ "min_val": 50,
+ "max_val": 120,
+ "duration": 600.0,
+ "water_vnum": 50723
+ },
+ 50824: {
+ "name": "白露水",
+ "color": "white",
+ "stat": "def_grade",
+ "stat_desc": "物理防御力",
+ "min_val": 40,
+ "max_val": 100,
+ "duration": 600.0,
+ "water_vnum": 50724
+ },
+ 50825: {
+ "name": "粉露水",
+ "color": "pink",
+ "stat": "penetrate_pct",
+ "stat_desc": "穿透一击几率",
+ "min_val": 8,
+ "max_val": 20,
+ "duration": 600.0,
+ "water_vnum": 50725
+ },
+ 50826: {
+ "name": "黄露水",
+ "color": "yellow",
+ "stat": "att_speed",
+ "stat_desc": "攻击速度",
+ "min_val": 2,
+ "max_val": 8,
+ "duration": 600.0,
+ "water_vnum": 50726
+ }
+}
+
+# 草药提炼对应表 (herb_vnum -> water_vnum)
+const HERB_TO_WATER: Dictionary = {
+ 50701: 50721, # 桃花 -> Sim-Water
+ 50702: 50722, # 风铃草 -> Dok-Water
+ 50703: 50723, # 柿子花 -> Bo-Water
+ 50704: 50724, # 高山石斛 -> Young-Water
+ 50705: 50725, # 紫丁香 -> Zin-Water
+ 50708: 50726 # 桑椹 -> Sam-Water
+}
+
+# 正在生效的露水增益: dew_vnum -> { name, stat, value, duration }
+var active_dews: Dictionary = {}
+
+# 1. 在白高处提取草药药水 (原料草药 -> 中间提取液)
+static func brew_water(inventory: Array, herb_vnum: int) -> Dictionary:
+ if not HERB_TO_WATER.has(herb_vnum):
+ return {"ok": false, "reason": "INVALID_HERB", "msg": "此草药无法用于提炼!"}
+
+ var target_water: int = int(HERB_TO_WATER[herb_vnum])
+
+ # 消耗草药
+ var herb_slot := -1
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and int(it.get("vnum", 0)) == herb_vnum:
+ herb_slot = i
+ break
+
+ if herb_slot == -1:
+ return {"ok": false, "reason": "MISSING_HERB", "msg": "背包中缺少所需草药!"}
+
+ inventory[herb_slot] = null
+
+ # 寻找背包空位生成药水提取液
+ var empty_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满!"}
+
+ inventory[empty_slot] = {"vnum": target_water, "count": 1}
+ return {"ok": true, "water_vnum": target_water, "slot": empty_slot}
+
+# 2. 在白高处合成露水 (药水提取液 + 空瓶 70030 -> 神圣露水)
+static func brew_dew(inventory: Array, dew_vnum: int, force_max_roll: bool = false) -> Dictionary:
+ if not DEW_TABLE.has(dew_vnum):
+ return {"ok": false, "reason": "INVALID_DEW"}
+
+ var cfg: Dictionary = DEW_TABLE[dew_vnum]
+ var req_water: int = int(cfg["water_vnum"])
+
+ var water_slot := -1
+ var bottle_slot := -1
+
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null:
+ var v: int = int(it.get("vnum", 0))
+ if v == req_water and water_slot == -1:
+ water_slot = i
+ elif v == ITEM_EMPTY_BOTTLE and bottle_slot == -1:
+ bottle_slot = i
+
+ if water_slot == -1:
+ return {"ok": false, "reason": "MISSING_WATER", "msg": "缺少对应的草药提取水剂!"}
+ if bottle_slot == -1:
+ return {"ok": false, "reason": "MISSING_BOTTLE", "msg": "缺少用于盛装露水的空药瓶 (70030)!"}
+
+ # 扣除水剂与空药瓶
+ inventory[water_slot] = null
+ inventory[bottle_slot] = null
+
+ # 抽取露水品质数值 (min_val ~ max_val)
+ var min_v: int = int(cfg["min_val"])
+ var max_v: int = int(cfg["max_val"])
+ var roll_val: int = max_v if force_max_roll else randi_range(min_v, max_v)
+
+ var dew_item: Dictionary = {
+ "vnum": dew_vnum,
+ "name": cfg["name"],
+ "value": roll_val,
+ "duration": float(cfg["duration"])
+ }
+
+ # 存入背包
+ var stored_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = dew_item
+ stored_slot = i
+ break
+
+ return {
+ "ok": true,
+ "dew_vnum": dew_vnum,
+ "name": cfg["name"],
+ "value": roll_val,
+ "slot": stored_slot,
+ "stat_desc": cfg["stat_desc"],
+ "msg": "成功酿造出【%s】!品质增幅:%s +%d!" % [cfg["name"], cfg["stat_desc"], roll_val]
+ }
+
+# 3. 饮用露水获得增益
+func drink_dew(item: Dictionary) -> Dictionary:
+ var vnum: int = int(item.get("vnum", 0))
+ if not DEW_TABLE.has(vnum):
+ return {"ok": false, "reason": "NOT_A_DEW"}
+
+ var cfg: Dictionary = DEW_TABLE[vnum]
+ var roll_val: int = int(item.get("value", cfg["min_val"]))
+
+ active_dews[vnum] = {
+ "name": cfg["name"],
+ "stat": cfg["stat"],
+ "value": roll_val,
+ "duration": float(cfg["duration"]),
+ "total_duration": float(cfg["duration"])
+ }
+
+ return {
+ "ok": true,
+ "dew_vnum": vnum,
+ "name": cfg["name"],
+ "stat": cfg["stat"],
+ "value": roll_val,
+ "duration": float(cfg["duration"])
+ }
+
+# 逐帧更新倒计时
+func update(dt: float) -> Array[int]:
+ var expired: Array[int] = []
+ for vnum in active_dews.keys():
+ var data: Dictionary = active_dews[vnum]
+ data["duration"] = maxf(0.0, float(data["duration"]) - dt)
+ if float(data["duration"]) <= 0.0:
+ expired.append(vnum)
+
+ for vnum in expired:
+ active_dews.erase(vnum)
+
+ return expired
+
+# 查询某项属性在当前露水中的总加成
+func get_stat_bonus(stat_name: String) -> int:
+ var total := 0
+ for vnum in active_dews:
+ var data: Dictionary = active_dews[vnum]
+ if String(data.get("stat", "")) == stat_name:
+ total += int(data.get("value", 0))
+ return total
+
+func has_dew(vnum: int) -> bool:
+ return active_dews.has(vnum)
diff --git a/project/herbal_dew_system.gd.uid b/project/herbal_dew_system.gd.uid
new file mode 100644
index 00000000..c37b769b
--- /dev/null
+++ b/project/herbal_dew_system.gd.uid
@@ -0,0 +1 @@
+uid://d3w5osfswkpk
diff --git a/project/hit_collision.gd.uid b/project/hit_collision.gd.uid
new file mode 100644
index 00000000..358e818d
--- /dev/null
+++ b/project/hit_collision.gd.uid
@@ -0,0 +1 @@
+uid://krpfagoqk4vj
diff --git a/project/hit_collision_test.gd.uid b/project/hit_collision_test.gd.uid
new file mode 100644
index 00000000..b30fc720
--- /dev/null
+++ b/project/hit_collision_test.gd.uid
@@ -0,0 +1 @@
+uid://38yd5ifmjymk
diff --git a/project/hit_view_test.gd.uid b/project/hit_view_test.gd.uid
new file mode 100644
index 00000000..8a690ed6
--- /dev/null
+++ b/project/hit_view_test.gd.uid
@@ -0,0 +1 @@
+uid://bg0ambcb4w8pi
diff --git a/project/horse_combat_system.gd b/project/horse_combat_system.gd
new file mode 100644
index 00000000..eae00cd1
--- /dev/null
+++ b/project/horse_combat_system.gd
@@ -0,0 +1,207 @@
+# horse_combat_system.gd —— Metin2 40250 战马骑乘战斗与 4 大战马专属技能 1:1
+# 对照 40250 服务端 char_skill.cpp, char_horse.cpp, skilldesc.txt:137-140
+class_name HorseCombatSystem
+extends RefCounted
+
+signal horse_attack_performed(target_count: int, total_dmg: int)
+signal horse_skill_used(skill_id: int, skill_name: String, cost_sp: int)
+signal horse_stamina_changed(current: float, max_val: float)
+
+const HORSE_TIER_BEGINNER := 1 # 初级马 (Lv 1-10, 仅代步)
+const HORSE_TIER_COMBAT := 2 # 战斗马 (Lv 11-20, 可马上挥砍)
+const HORSE_TIER_MILITARY := 3 # 军马 (Lv 21-30, 可马上挥砍并施展 4 大马术技能)
+
+const MOUNTED_ATTACK_RANGE := 5.0
+const FOOT_ATTACK_RANGE := 3.0
+
+const ITEM_FOOD_CARROT := 50054 # 胡萝卜 (战斗马饲料)
+const ITEM_FOOD_GINSENG := 50058 # 人参 (军马饲料)
+
+const HORSE_SKILLS: Dictionary = {
+ 137: {
+ "name": "战马砍击",
+ "sp_cost": 30,
+ "cooldown": 15.0,
+ "dmg_mult": 2.5,
+ "effect": "KNOCKBACK",
+ "range": 5.0
+ },
+ 138: {
+ "name": "战马突刺",
+ "sp_cost": 35,
+ "cooldown": 20.0,
+ "dmg_mult": 2.0,
+ "effect": "STUMBLE",
+ "range": 8.0
+ },
+ 139: {
+ "name": "战马震击",
+ "sp_cost": 40,
+ "cooldown": 25.0,
+ "dmg_mult": 3.0,
+ "effect": "AOE_STUN",
+ "range": 6.0
+ },
+ 140: {
+ "name": "破浪击",
+ "sp_cost": 30,
+ "cooldown": 12.0,
+ "dmg_mult": 2.2,
+ "effect": "RANGED_WAVE",
+ "range": 12.0
+ }
+}
+
+var is_riding: bool = false
+var horse_tier: int = HORSE_TIER_NONE
+var horse_stamina: float = 100.0
+var max_stamina: float = 100.0
+var skill_cooldowns: Dictionary = {
+ 137: 0.0,
+ 138: 0.0,
+ 139: 0.0,
+ 140: 0.0
+}
+
+const HORSE_TIER_NONE := 0
+
+# 设置骑乘状态
+func set_mounted_state(riding: bool, tier: int = HORSE_TIER_MILITARY) -> void:
+ is_riding = riding
+ horse_tier = tier if riding else HORSE_TIER_NONE
+
+# 马上普通挥砍攻击 (Mounted Basic Sweep Attack)
+func perform_mounted_basic_attack(
+ base_att: int,
+ targets_in_range: Array
+) -> Dictionary:
+ if not is_riding:
+ return {"ok": false, "reason": "NOT_RIDING", "msg": "未处于骑乘状态!"}
+
+ if horse_tier < HORSE_TIER_COMBAT:
+ return {"ok": false, "reason": "HORSE_TIER_TOO_LOW", "msg": "当前马匹等级不足,无法在马上战斗!需要战斗马或军马。"}
+
+ # 精力衰减
+ horse_stamina = maxf(0.0, horse_stamina - 0.5)
+ horse_stamina_changed.emit(horse_stamina, max_stamina)
+
+ # 伤害计算:马上横扫增伤 1.2x (精力枯竭时惩罚为 0.8x)
+ var mult: float = 1.2 if horse_stamina > 0.0 else 0.8
+ var dmg_per_target := int(base_att * mult)
+ var total_dmg: int = 0
+ var hit_targets: Array = []
+
+ for target in targets_in_range:
+ var dist: float = float(target.get("distance", 999.0))
+ if dist <= MOUNTED_ATTACK_RANGE:
+ hit_targets.append(target)
+ total_dmg += dmg_per_target
+
+ horse_attack_performed.emit(hit_targets.size(), total_dmg)
+
+ return {
+ "ok": true,
+ "attack_range": MOUNTED_ATTACK_RANGE,
+ "hit_count": hit_targets.size(),
+ "damage_per_hit": dmg_per_target,
+ "total_damage": total_dmg,
+ "stamina": horse_stamina
+ }
+
+# 施展 4 大战马专属技能 (137, 138, 139, 140)
+func use_horse_skill(
+ skill_id: int,
+ player_sp: int,
+ base_att: int
+) -> Dictionary:
+ if not is_riding:
+ return {"ok": false, "reason": "NOT_RIDING", "msg": "未在马背上,无法施展马术技能!"}
+
+ if horse_tier < HORSE_TIER_MILITARY:
+ return {"ok": false, "reason": "REQUIRES_MILITARY_HORSE", "msg": "马术专属技能需要军马 (Lv21+) 方可施展!"}
+
+ if not HORSE_SKILLS.has(skill_id):
+ return {"ok": false, "reason": "INVALID_SKILL_ID", "msg": "未知的战马技能!"}
+
+ var cfg: Dictionary = HORSE_SKILLS[skill_id]
+
+ # 检查冷却
+ if skill_cooldowns[skill_id] > 0.0:
+ return {
+ "ok": false,
+ "reason": "SKILL_ON_COOLDOWN",
+ "remaining_cd": skill_cooldowns[skill_id],
+ "msg": "技能【%s】冷却中,剩余 %.1f 秒。" % [cfg["name"], skill_cooldowns[skill_id]]
+ }
+
+ # 检查玩家法力 (SP)
+ var sp_cost: int = cfg["sp_cost"]
+ if player_sp < sp_cost:
+ return {
+ "ok": false,
+ "reason": "INSUFFICIENT_SP",
+ "cost": sp_cost,
+ "current_sp": player_sp,
+ "msg": "法力值不足,无法施展【%s】!" % cfg["name"]
+ }
+
+ # 检查马匹精力
+ if horse_stamina < 5.0:
+ return {
+ "ok": false,
+ "reason": "HORSE_EXHAUSTED",
+ "msg": "战马体力已透支,请先喂食胡萝卜或人参!"
+ }
+
+ # 施法成功:扣除精力、进入冷却
+ horse_stamina = maxf(0.0, horse_stamina - 5.0)
+ skill_cooldowns[skill_id] = cfg["cooldown"]
+ horse_stamina_changed.emit(horse_stamina, max_stamina)
+ horse_skill_used.emit(skill_id, cfg["name"], sp_cost)
+
+ var dealt_dmg := int(base_att * cfg["dmg_mult"])
+
+ return {
+ "ok": true,
+ "skill_id": skill_id,
+ "skill_name": cfg["name"],
+ "sp_cost": sp_cost,
+ "cooldown": cfg["cooldown"],
+ "damage": dealt_dmg,
+ "effect": cfg["effect"],
+ "range": cfg["range"],
+ "msg": "施展了战马技能【%s】!" % cfg["name"]
+ }
+
+# 喂食恢复马匹精力 (胡萝卜 +40, 人参 +80)
+func feed_horse(food_vnum: int) -> Dictionary:
+ if horse_tier == HORSE_TIER_NONE:
+ return {"ok": false, "reason": "NO_HORSE", "msg": "当前没有马匹!"}
+
+ var restore_amount := 0.0
+ var food_name := ""
+ if food_vnum == ITEM_FOOD_CARROT:
+ restore_amount = 40.0
+ food_name = "胡萝卜"
+ elif food_vnum == ITEM_FOOD_GINSENG:
+ restore_amount = 80.0
+ food_name = "人参"
+ else:
+ return {"ok": false, "reason": "WRONG_FOOD", "msg": "这不是战马适合的食物!"}
+
+ horse_stamina = minf(max_stamina, horse_stamina + restore_amount)
+ horse_stamina_changed.emit(horse_stamina, max_stamina)
+
+ return {
+ "ok": true,
+ "food_name": food_name,
+ "restored": restore_amount,
+ "current_stamina": horse_stamina,
+ "msg": "战马食用了【%s】,恢复了体力!" % food_name
+ }
+
+# 冷却逐帧更新
+func update(dt: float) -> void:
+ for sk in skill_cooldowns.keys():
+ if skill_cooldowns[sk] > 0.0:
+ skill_cooldowns[sk] = maxf(0.0, skill_cooldowns[sk] - dt)
diff --git a/project/horse_combat_system.gd.uid b/project/horse_combat_system.gd.uid
new file mode 100644
index 00000000..3f7f617a
--- /dev/null
+++ b/project/horse_combat_system.gd.uid
@@ -0,0 +1 @@
+uid://bq413tb15c7fk
diff --git a/project/horse_level_growth_system.gd b/project/horse_level_growth_system.gd
new file mode 100644
index 00000000..d403a0d9
--- /dev/null
+++ b/project/horse_level_growth_system.gd
@@ -0,0 +1,283 @@
+# horse_level_growth_system.gd —— Metin2 40250 官方马匹等级进化与养殖草料喂食系统 1:1
+# 对照 40250 服务端 horse_rider.cpp, char_horse.cpp, constants.h
+class_name HorseLevelGrowthSystem
+extends RefCounted
+
+signal horse_level_up(new_level: int, tier_name: String)
+signal horse_fed(new_health: int, max_health: int)
+signal horse_revived(new_health: int)
+signal horse_mounted(horse_level: int, stats_applied: Dictionary)
+signal horse_dismounted()
+signal stamina_exhausted()
+
+# 40250 官方马牌与召唤道具
+const VNUM_HORSE_PICTURE := 50051 # 马牌 (初级小马召唤道具)
+const VNUM_ARMOR_HORSE_BOOK := 50052 # 战马书 (战斗装甲马召唤道具)
+const VNUM_MILITARY_HORSE_BOOK:= 50053 # 军马令 (军阶战马召唤道具)
+
+# 40250 官方草料饲料
+const VNUM_HAY := 50054 # 干草 (初级小马食物)
+const VNUM_CARROT := 50055 # 胡萝卜 (战斗装甲马食物)
+const VNUM_RED_GINSENG := 50056 # 红参 (军阶战马食物)
+
+# 40250 官方复活猴草
+const VNUM_HERB_EASY := 50057 # 初级猴草 (复活初级小马)
+const VNUM_HERB_NORMAL := 50058 # 中级猴草 (复活战斗装甲马)
+const VNUM_HERB_EXPERT := 50059 # 高级猴草 (复活军阶战马)
+
+# 3 大战马阶层定义
+const TIER_NONE := 0
+const TIER_BEGINNER := 1 # 1~10 级 初级小马 (Race 20101, 人物需求 Lv 25)
+const TIER_COMBAT := 2 # 11~20 级 战斗装甲马 (Race 20104, 人物需求 Lv 35)
+const TIER_MILITARY := 3 # 21~30 级 军阶战马 (Race 20107, 人物需求 Lv 50)
+
+# 40250 官方经典等级属性表 (选样代表级与通用成长模型)
+const HORSE_STAT_TABLE: Dictionary = {
+ 1: {"min_lv": 25, "race": 20101, "max_health": 3, "max_stamina": 4, "st": 26, "dx": 35, "ht": 18, "iq": 9, "atk": 54, "def": 32},
+ 5: {"min_lv": 25, "race": 20101, "max_health": 8, "max_stamina": 6, "st": 30, "dx": 40, "ht": 20, "iq": 10, "atk": 58, "def": 34},
+ 10: {"min_lv": 25, "race": 20101, "max_health": 15, "max_stamina": 10, "st": 35, "dx": 46, "ht": 23, "iq": 12, "atk": 63, "def": 37},
+ 11: {"min_lv": 35, "race": 20104, "max_health": 18, "max_stamina": 30, "st": 40, "dx": 53, "ht": 27, "iq": 13, "atk": 69, "def": 41},
+ 15: {"min_lv": 35, "race": 20104, "max_health": 24, "max_stamina": 55, "st": 44, "dx": 58, "ht": 29, "iq": 15, "atk": 73, "def": 43},
+ 20: {"min_lv": 35, "race": 20104, "max_health": 32, "max_stamina": 100, "st": 48, "dx": 64, "ht": 32, "iq": 16, "atk": 78, "def": 46},
+ 21: {"min_lv": 50, "race": 20107, "max_health": 35, "max_stamina": 120, "st": 53, "dx": 71, "ht": 36, "iq": 18, "atk": 84, "def": 50},
+ 25: {"min_lv": 50, "race": 20107, "max_health": 40, "max_stamina": 140, "st": 60, "dx": 80, "ht": 40, "iq": 20, "atk": 91, "def": 54},
+ 30: {"min_lv": 50, "race": 20107, "max_health": 50, "max_stamina": 200, "st": 67, "dx": 89, "ht": 45, "iq": 22, "atk": 99, "def": 59}
+}
+
+var horse_level: int = 0
+var current_health: int = 0
+var current_stamina: int = 0
+var is_mounted: bool = false
+var applied_stats: Dictionary = {}
+
+# 获取当前战马阶层
+func get_tier() -> int:
+ if horse_level <= 0:
+ return TIER_NONE
+ elif horse_level <= 10:
+ return TIER_BEGINNER
+ elif horse_level <= 20:
+ return TIER_COMBAT
+ else:
+ return TIER_MILITARY
+
+# 获取阶层名称
+func get_tier_name() -> String:
+ match get_tier():
+ TIER_BEGINNER:
+ return "初级小马"
+ TIER_COMBAT:
+ return "战斗装甲马"
+ TIER_MILITARY:
+ return "军阶战马"
+ _:
+ return "无战马"
+
+# 获取对应等级的基础属性
+func get_horse_stat(lv: int) -> Dictionary:
+ if HORSE_STAT_TABLE.has(lv):
+ return HORSE_STAT_TABLE[lv].duplicate(true)
+ # 近似插值计算
+ var base_lv := 1
+ for k in HORSE_STAT_TABLE.keys():
+ if int(k) <= lv and int(k) > base_lv:
+ base_lv = int(k)
+ return HORSE_STAT_TABLE[base_lv].duplicate(true)
+
+# 初始化/领养战马
+func initialize_horse(level: int = 1) -> void:
+ horse_level = clampi(level, 1, 30)
+ var stat = get_horse_stat(horse_level)
+ current_health = int(stat["max_health"])
+ current_stamina = int(stat["max_stamina"])
+
+# 马匹等级升级
+func upgrade_horse_level(char_level: int) -> Dictionary:
+ if horse_level >= 30:
+ return {"ok": false, "reason": "MAX_HORSE_LEVEL", "msg": "战马已达最高等级 30 级!"}
+
+ var target_lv := horse_level + 1
+ var target_stat := get_horse_stat(target_lv)
+ var req_char_lv := int(target_stat["min_lv"])
+
+ if char_level < req_char_lv:
+ return {
+ "ok": false,
+ "reason": "CHAR_LEVEL_TOO_LOW",
+ "msg": "角色等级不足!晋升至 %d 级战马需要角色达到 %d 级。" % [target_lv, req_char_lv]
+ }
+
+ horse_level = target_lv
+ var new_stat := get_horse_stat(horse_level)
+ current_health = int(new_stat["max_health"])
+ current_stamina = int(new_stat["max_stamina"])
+
+ horse_level_up.emit(horse_level, get_tier_name())
+
+ return {
+ "ok": true,
+ "horse_level": horse_level,
+ "tier": get_tier_name(),
+ "msg": "战马成功进阶至 %d 级【%s】!" % [horse_level, get_tier_name()]
+ }
+
+# 喂食草料恢复健康值 (40250 horse_rider.cpp:FeedHorse)
+func feed_horse(inventory: Array, slot_index: int) -> Dictionary:
+ if horse_level <= 0:
+ return {"ok": false, "reason": "NO_HORSE"}
+
+ if current_health <= 0:
+ return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已重伤休克!需要喂食复活猴草唤醒。"}
+
+ var stat := get_horse_stat(horse_level)
+ var max_hp := int(stat["max_health"])
+ if current_health >= max_hp:
+ return {"ok": false, "reason": "HEALTH_ALREADY_FULL", "msg": "战马饱食度与健康值已满!"}
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var food: Dictionary = inventory[slot_index]
+ var food_vnum := int(food.get("vnum", 0))
+ var tier := get_tier()
+
+ # 校验草料与阶层匹配 (40250 规则: 初级吃干草, 战斗吃胡萝卜, 军马吃红参)
+ var match_ok := false
+ if tier == TIER_BEGINNER and food_vnum == VNUM_HAY:
+ match_ok = true
+ elif tier == TIER_COMBAT and food_vnum == VNUM_CARROT:
+ match_ok = true
+ elif tier == TIER_MILITARY and food_vnum == VNUM_RED_GINSENG:
+ match_ok = true
+
+ if not match_ok:
+ return {"ok": false, "reason": "FOOD_TIER_MISMATCH", "msg": "该饲料与当前战马阶层不匹配!"}
+
+ # 消耗 1 份食物
+ var cnt := int(food.get("count", 1))
+ if cnt > 1:
+ food["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ current_health = mini(max_hp, current_health + 2)
+ horse_fed.emit(current_health, max_hp)
+
+ return {
+ "ok": true,
+ "current_health": current_health,
+ "max_health": max_hp,
+ "msg": "战马享用了饲料,健康值恢复至 %d/%d!" % [current_health, max_hp]
+ }
+
+# 使用猴草唤醒复活战马
+func revive_horse(inventory: Array, slot_index: int) -> Dictionary:
+ if current_health > 0:
+ return {"ok": false, "reason": "HORSE_NOT_DEAD", "msg": "战马依然健在,无需使用复活猴草!"}
+
+ if slot_index < 0 or slot_index >= inventory.size() or inventory[slot_index] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var herb: Dictionary = inventory[slot_index]
+ var herb_vnum := int(herb.get("vnum", 0))
+ var tier := get_tier()
+
+ var herb_ok := false
+ if tier == TIER_BEGINNER and herb_vnum == VNUM_HERB_EASY:
+ herb_ok = true
+ elif tier == TIER_COMBAT and herb_vnum == VNUM_HERB_NORMAL:
+ herb_ok = true
+ elif tier == TIER_MILITARY and herb_vnum == VNUM_HERB_EXPERT:
+ herb_ok = true
+
+ if not herb_ok:
+ return {"ok": false, "reason": "HERB_TIER_MISMATCH", "msg": "复活猴草阶层与战马不匹配!"}
+
+ # 消耗 1 株猴草
+ var cnt := int(herb.get("count", 1))
+ if cnt > 1:
+ herb["count"] = cnt - 1
+ else:
+ inventory[slot_index] = null
+
+ var stat := get_horse_stat(horse_level)
+ current_health = maxi(1, int(stat["max_health"]) / 2)
+ current_stamina = maxi(1, int(stat["max_stamina"]) / 2)
+
+ horse_revived.emit(current_health)
+
+ return {
+ "ok": true,
+ "health": current_health,
+ "msg": "灵草生效!战马重焕生机复活苏醒。"
+ }
+
+# 骑乘上马
+func mount_horse(player_stats: Dictionary) -> Dictionary:
+ if horse_level <= 0:
+ return {"ok": false, "reason": "NO_HORSE", "msg": "尚未获得战马!"}
+
+ if current_health <= 0:
+ return {"ok": false, "reason": "HORSE_IS_DEAD", "msg": "战马已昏迷,需先喂食猴草复活!"}
+
+ if is_mounted:
+ return {"ok": false, "reason": "ALREADY_MOUNTED"}
+
+ var stat := get_horse_stat(horse_level)
+ # 注入战马加成
+ applied_stats = {
+ "attack_power": int(stat["atk"]),
+ "defense": int(stat["def"])
+ }
+ for k in applied_stats.keys():
+ player_stats[k] = int(player_stats.get(k, 0)) + applied_stats[k]
+
+ is_mounted = true
+ horse_mounted.emit(horse_level, applied_stats)
+
+ return {"ok": true, "msg": "成功乘骑战马!人马合一属性生效。"}
+
+# 下马
+func dismount_horse(player_stats: Dictionary) -> Dictionary:
+ if not is_mounted:
+ return {"ok": false, "reason": "NOT_MOUNTED"}
+
+ for k in applied_stats.keys():
+ player_stats[k] = int(player_stats.get(k, 0)) - applied_stats[k]
+ applied_stats.clear()
+
+ is_mounted = false
+ horse_dismounted.emit()
+
+ return {"ok": true, "msg": "已安全下马。"}
+
+# 消耗耐力
+func consume_stamina(amount: int, player_stats: Dictionary) -> void:
+ if not is_mounted:
+ return
+ current_stamina = maxi(0, current_stamina - amount)
+ if current_stamina <= 0:
+ stamina_exhausted.emit()
+ dismount_horse(player_stats) # 耐力耗尽自动下马
+
+# 恢复耐力
+func recover_stamina(amount: int) -> void:
+ var stat := get_horse_stat(horse_level)
+ var max_stamina := int(stat["max_stamina"])
+ current_stamina = mini(max_stamina, current_stamina + amount)
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "horse_level": horse_level,
+ "current_health": current_health,
+ "current_stamina": current_stamina,
+ "is_mounted": is_mounted
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ horse_level = int(data.get("horse_level", 0))
+ current_health = int(data.get("current_health", 0))
+ current_stamina = int(data.get("current_stamina", 0))
+ is_mounted = bool(data.get("is_mounted", false))
diff --git a/project/horse_level_growth_system.gd.uid b/project/horse_level_growth_system.gd.uid
new file mode 100644
index 00000000..92b32606
--- /dev/null
+++ b/project/horse_level_growth_system.gd.uid
@@ -0,0 +1 @@
+uid://cwiwpfsupj5gr
diff --git a/project/horse_system.gd b/project/horse_system.gd
new file mode 100644
index 00000000..0f221392
--- /dev/null
+++ b/project/horse_system.gd
@@ -0,0 +1,138 @@
+# horse_system.gd —— 40250 战马与骑乘系统 1:1 规则引擎
+# 严格对照:
+# metin2/src/server/game/src/cmd_general.cpp:45-100 (do_user_horse_ride / back / feed)
+# metin2/src/server/game/src/cmd_general.cpp:2426-2465 (do_ride)
+# root/game.py:344, 461-490 (Ctrl+G, Ctrl+H, Ctrl+B, Ctrl+F)
+# ClientVS22/UserInterface/InstanceBase.cpp (IsMountingHorse, 5.0m attack range)
+extends RefCounted
+
+const ITEM_HORSE_PICTURE := 50051 # 初级马画 (Lv 1 - 10)
+const ITEM_HORSE_MEDAL := 50052 # 战斗马牌 (Lv 11 - 20)
+const ITEM_HORSE_MILITARY := 50053 # 军马牌 (Lv 21 - 30)
+
+const ITEM_FOOD_HAY := 50057 # 干草
+const ITEM_FOOD_CARROT := 50054 # 胡萝卜
+const ITEM_FOOD_GINSENG := 50058 # 人参
+
+const HORSE_TIER_NONE := 0
+const HORSE_TIER_BEGINNER := 1 # 普通马
+const HORSE_TIER_COMBAT := 2 # 战斗马(可马上砍怪)
+const HORSE_TIER_MILITARY := 3 # 军马(可使用马术技能)
+
+# 召唤消耗 SP
+const SUMMON_SP_COST := {
+ HORSE_TIER_BEGINNER: 100,
+ HORSE_TIER_COMBAT: 200,
+ HORSE_TIER_MILITARY: 300,
+}
+
+# 基础召唤成功率
+const BASE_SUMMON_CHANCE := {
+ HORSE_TIER_BEGINNER: 10,
+ HORSE_TIER_COMBAT: 20,
+ HORSE_TIER_MILITARY: 30,
+}
+
+# 骑乘时战斗与拾取距离加成(从 300cm 扩至 500cm,1:1 ClientVS22)
+const WALKING_ATTACK_RANGE := 3.0
+const MOUNTED_ATTACK_RANGE := 5.0
+
+# 判定马牌对应的级别与需求
+static func get_horse_tier_from_item(vnum: int) -> int:
+ match vnum:
+ ITEM_HORSE_PICTURE: return HORSE_TIER_BEGINNER
+ ITEM_HORSE_MEDAL: return HORSE_TIER_COMBAT
+ ITEM_HORSE_MILITARY: return HORSE_TIER_MILITARY
+ _: return HORSE_TIER_NONE
+
+# 所需饲料
+static func get_required_food(tier: int) -> int:
+ match tier:
+ HORSE_TIER_BEGINNER: return ITEM_FOOD_HAY
+ HORSE_TIER_COMBAT: return ITEM_FOOD_CARROT
+ HORSE_TIER_MILITARY: return ITEM_FOOD_GINSENG
+ _: return 0
+
+# 召唤几率计算(1:1 char_horse.cpp: HorseSummon)
+# chance = base_chance + horse_summon_skill_level * 10, max 100
+static func calculate_summon_chance(tier: int, summon_skill_level: int) -> int:
+ var base: int = BASE_SUMMON_CHANCE.get(tier, 10)
+ return mini(100, base + summon_skill_level * 10)
+
+# 执行召唤判定
+static func try_summon_horse(
+ tier: int,
+ current_sp: int,
+ summon_skill_level: int,
+ roll_val := -1
+) -> Dictionary:
+ var sp_cost: int = SUMMON_SP_COST.get(tier, 100)
+ if current_sp < sp_cost:
+ return {"success": false, "err": "insufficient_sp", "sp_cost": sp_cost}
+
+ var chance := calculate_summon_chance(tier, summon_skill_level)
+ var dice := roll_val if roll_val > 0 else (randi() % 100 + 1)
+ var success := (dice <= chance)
+
+ return {
+ "success": success,
+ "sp_cost": sp_cost,
+ "tier": tier,
+ "err": "" if success else "summon_failed",
+ }
+
+# 执行 /user_horse_ride (Ctrl+H) 状态机 (cmd_general.cpp:45)
+static func handle_user_horse_ride(
+ is_riding: bool,
+ is_horse_summoned: bool,
+ is_other_mount: bool,
+ is_dead_or_stun: bool
+) -> Dictionary:
+ if is_dead_or_stun:
+ return {"action": "none", "err": "dead_or_stun"}
+
+ if not is_riding:
+ if is_other_mount:
+ return {"action": "none", "err": "already_mounting_other", "msg": "已经在使用其他坐骑。"}
+ if not is_horse_summoned:
+ return {"action": "none", "err": "horse_not_summoned", "msg": "请先召唤马匹。"}
+ return {"action": "start_riding", "err": "", "is_riding": true}
+ else:
+ return {"action": "stop_riding", "err": "", "is_riding": false}
+
+# 执行 /user_horse_back (Ctrl+B) 状态机 (cmd_general.cpp:75)
+static func handle_user_horse_back(
+ is_riding: bool,
+ is_horse_summoned: bool
+) -> Dictionary:
+ if is_riding:
+ return {"action": "none", "err": "must_dismount_first", "msg": "请先下马。"}
+ if not is_horse_summoned:
+ return {"action": "none", "err": "horse_not_summoned", "msg": "请先召唤马匹。"}
+ return {"action": "unsummon", "err": "", "is_horse_summoned": false, "msg": "已送回马匹。"}
+
+# 执行 /ride (Ctrl+G) 状态机 (cmd_general.cpp:2426)
+static func handle_ride(
+ is_riding: bool,
+ is_horse_summoned: bool,
+ has_ride_item: bool
+) -> Dictionary:
+ if is_riding:
+ return {"action": "stop_riding", "err": "", "is_riding": false}
+ if is_horse_summoned:
+ return {"action": "start_riding", "err": "", "is_riding": true}
+ if has_ride_item:
+ return {"action": "mount_item", "err": "", "is_riding": true}
+ return {"action": "none", "err": "cannot_ride", "msg": "没有可骑乘的马匹或坐骑。"}
+
+# 喂食逻辑 (Ctrl+F)
+static func handle_user_horse_feed(
+ tier: int,
+ food_item_vnum: int
+) -> Dictionary:
+ var req_food := get_required_food(tier)
+ if req_food == 0:
+ return {"success": false, "msg": "当前没有出战马匹。"}
+ if food_item_vnum != req_food:
+ return {"success": false, "msg": "这种食物不适合当前马匹。"}
+ return {"success": true, "consumed_vnum": req_food, "msg": "战马饱餐了一顿,恢复了活力!"}
diff --git a/project/horse_system.gd.uid b/project/horse_system.gd.uid
new file mode 100644
index 00000000..cefe0d26
--- /dev/null
+++ b/project/horse_system.gd.uid
@@ -0,0 +1 @@
+uid://b0qu4yyhtooea
diff --git a/project/hud.gd b/project/hud.gd
index f8e16a4c..de56698b 100644
--- a/project/hud.gd
+++ b/project/hud.gd
@@ -25,6 +25,33 @@ var _hp_label: Label
var _hp_w := 240.0
var _energy_fill: ColorRect
var _energy_label: Label
+var _status_box: VBoxContainer
+var quickbar: Node:
+ set(v):
+ quickbar = v
+ if quickbar:
+ _sync_vitals_to_quickbar()
+
+var _cached_hp: int = 0
+var _cached_max_hp: int = 0
+var _cached_sp: int = 0
+var _cached_max_sp: int = 0
+var _cached_st: int = 0
+var _cached_max_st: int = 0
+var _cached_exp: int = 0
+var _cached_next_exp: int = 0
+
+func _sync_vitals_to_quickbar() -> void:
+ if not quickbar:
+ return
+ if _cached_max_hp > 0 and quickbar.has_method("set_hp"):
+ quickbar.set_hp(_cached_hp, _cached_max_hp)
+ if _cached_max_sp > 0 and quickbar.has_method("set_sp"):
+ quickbar.set_sp(_cached_sp, _cached_max_sp)
+ if _cached_max_st > 0 and quickbar.has_method("set_st"):
+ quickbar.set_st(_cached_st, _cached_max_st)
+ if _cached_next_exp > 0 and quickbar.has_method("set_exp"):
+ quickbar.set_exp(_cached_exp, _cached_next_exp)
# 目标血条(P0)
var _target_panel: Control
var _target_name: Label
@@ -191,84 +218,40 @@ func _build_minimap(nx: int, ny: int) -> void:
_minimap_rect.add_child(_dot)
func _build_status() -> void:
- var box := VBoxContainer.new()
- box.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
- box.position = Vector2(18, -122)
- box.add_theme_constant_override("separation", 6)
- add_child(box)
- for spec in [["gauge_red", 0.82], ["gauge_blue", 0.60]]:
- var slot := _gauge("gauge_slot", 24)
- slot.custom_minimum_size = Vector2(_hp_w, 24)
- slot.size = Vector2(_hp_w, 24)
- var fill := _gauge(spec[0], 24)
- fill.position = Vector2(0, 0)
- fill.size = Vector2(_hp_w * float(spec[1]), 24)
- slot.add_child(fill)
- box.add_child(slot)
- if spec[0] == "gauge_red":
- _hp_fill = fill
- _hp_label = Label.new()
- _hp_label.add_theme_font_size_override("font_size", 11)
- _hp_label.position = Vector2(6, 5)
- slot.add_child(_hp_label)
- else:
- _mp_fill = fill
- # 体力条(40250 POINT_STAMINA,冲刺/骑乘时由 ServerCommand 驱动)。
- var stamina_slot := ColorRect.new()
- stamina_slot.color = Color(0, 0, 0, 0.55)
- stamina_slot.custom_minimum_size = Vector2(_hp_w, 10)
- stamina_slot.size = Vector2(_hp_w, 10)
- _stamina_fill = ColorRect.new()
- _stamina_fill.color = Color(0.25, 0.85, 0.35)
- _stamina_fill.size = Vector2(0, 10)
- stamina_slot.add_child(_stamina_fill)
- _stamina_label = Label.new()
- _stamina_label.add_theme_font_size_override("font_size", 9)
- _stamina_label.position = Vector2(6, -1)
- stamina_slot.add_child(_stamina_label)
- box.add_child(stamina_slot)
- # 能量条(原客户端 POINT_ENERGY,0..100)。资源缺失时仍用纯色条保持可读。
- var energy_slot := ColorRect.new()
- energy_slot.color = Color(0, 0, 0, 0.55)
- energy_slot.custom_minimum_size = Vector2(_hp_w, 10)
- energy_slot.size = Vector2(_hp_w, 10)
- _energy_fill = ColorRect.new()
- _energy_fill.color = Color(0.95, 0.65, 0.18)
- _energy_fill.size = Vector2(0, 10)
- energy_slot.add_child(_energy_fill)
- _energy_label = Label.new()
- _energy_label.add_theme_font_size_override("font_size", 9)
- _energy_label.position = Vector2(6, -1)
- energy_slot.add_child(_energy_label)
- box.add_child(energy_slot)
- # 经验条(细)+ 等级
- var exp_slot := ColorRect.new()
- exp_slot.color = Color(0, 0, 0, 0.5)
- exp_slot.custom_minimum_size = Vector2(_hp_w, 6)
- exp_slot.size = Vector2(_hp_w, 6)
- _exp_fill = ColorRect.new()
- _exp_fill.color = Color(0.9, 0.8, 0.2)
- _exp_fill.size = Vector2(0, 6)
- exp_slot.add_child(_exp_fill)
- box.add_child(exp_slot)
- _lvl_label = Label.new()
- _lvl_label.add_theme_font_size_override("font_size", 12)
- _lvl_label.text = "Lv 1"
- box.add_child(_lvl_label)
+ # 40250 标准:血量、法力、体力、经验均由底部任务栏 Quickbar 的 Gauge_Board 与 EXP_Gauge_Board 承载,
+ # HUD 不再在左下角渲染浮动的原型状态条。
+ pass
# --- P0: live data setters -------------------------------------------------
+func set_status_visible(v: bool) -> void:
+ if _status_box and is_instance_valid(_status_box):
+ _status_box.visible = v
+
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
+ _cached_hp = hp
+ _cached_max_hp = max_hp
+ _cached_sp = sp
+ _cached_max_sp = max_sp
if _hp_fill:
_hp_fill.size.x = _hp_w * clampf(float(hp) / maxf(1.0, max_hp), 0.0, 1.0)
if _mp_fill:
_mp_fill.size.x = _hp_w * clampf(float(sp) / maxf(1.0, max_sp), 0.0, 1.0)
if _hp_label:
_hp_label.text = "%d / %d" % [hp, max_hp]
+ if quickbar:
+ if quickbar.has_method("set_hp"):
+ quickbar.set_hp(hp, max_hp)
+ if quickbar.has_method("set_sp"):
+ quickbar.set_sp(sp, max_sp)
func set_exp(xp: int, next_xp: int) -> void:
+ _cached_exp = xp
+ _cached_next_exp = next_xp
if _exp_fill:
_exp_fill.size.x = _hp_w * clampf(float(xp) / maxf(1.0, next_xp), 0.0, 1.0)
+ if quickbar and quickbar.has_method("set_exp"):
+ quickbar.set_exp(xp, next_xp)
func set_level(lv: int) -> void:
if _lvl_label:
@@ -283,12 +266,16 @@ func set_energy(value: int, max_value: int = 100) -> void:
_energy_label.text = "%d / %d" % [v, cap]
func set_stamina(value: int, max_value: int) -> void:
+ _cached_st = value
+ _cached_max_st = max_value
var cap := maxi(1, max_value)
var v := clampi(value, 0, cap)
if _stamina_fill:
_stamina_fill.size.x = _hp_w * float(v) / float(cap)
if _stamina_label:
_stamina_label.text = "%d / %d" % [v, cap]
+ if quickbar and quickbar.has_method("set_st"):
+ quickbar.set_st(value, max_value)
# P4:状态图标条。affects = [{type, point_idx, value, flag, duration}, ...]
func set_affects(affects: Array) -> void:
@@ -371,20 +358,11 @@ func _build_target_panel() -> void:
slot.add_child(_target_fill)
func _build_inventory() -> void:
- _inv = _nine("board", 32, 128)
- _inv.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
- _inv.position = Vector2(-280, -200)
- _inv.size = Vector2(256, 384)
- _inv.visible = false
- var t := Label.new()
- t.text = " Inventory (I to close)"
- t.position = Vector2(16, 12)
- _inv.add_child(t)
- add_child(_inv)
+ # 40250 标准:背包由 ui/inventory_ui.gd 统一管理,不再在此创建原型窗口。
+ pass
-func _input(ev: InputEvent) -> void:
- if ev is InputEventKey and ev.pressed and ev.keycode == KEY_I:
- _inv.visible = not _inv.visible
+func _input(_ev: InputEvent) -> void:
+ pass
func _process(_dt: float) -> void:
if _player:
diff --git a/project/intro_create_phase.gd b/project/intro_create_phase.gd
new file mode 100644
index 00000000..d9df215b
--- /dev/null
+++ b/project/intro_create_phase.gd
@@ -0,0 +1,132 @@
+# intro_create_phase.gd —— 40250 3D 角色塑形创建系统 1:1
+# 严格对照:
+# Client/Eternexus/root/introcreate.py (CreateCharacterWindow)
+# ClientVS22/source/UserInterface/InstanceBaseTransform.cpp
+# metin2/src/server/game/src/player.cpp
+extends RefCounted
+
+const JOB_WARRIOR := 0
+const JOB_ASSASSIN := 1
+const JOB_SURA := 2
+const JOB_SHAMAN := 3
+
+const GENDER_MALE := 0 # MAN
+const GENDER_FEMALE := 1 # WOMAN
+
+const SHAPE_0 := 0 # 经典外观 1 (SHAPE0)
+const SHAPE_1 := 1 # 经典外观 2 (SHAPE1)
+
+# 40250 官方职业初始四维属性分配表 (CON, INT, STR, DEX)
+# 对应 introcreate.py: START_STAT
+const START_STAT_TABLE := {
+ JOB_WARRIOR: { "con": 4, "int": 3, "str": 6, "dex": 3 }, # 战士
+ JOB_ASSASSIN: { "con": 3, "int": 3, "str": 4, "dex": 6 }, # 刺客
+ JOB_SURA: { "con": 3, "int": 5, "str": 5, "dex": 3 }, # 修罗
+ JOB_SHAMAN: { "con": 4, "int": 6, "str": 3, "dex": 3 }, # 萨满
+}
+
+# 违禁词简易过滤器
+const BANNED_NAME_PATTERNS := ["[GM]", "admin", "null", "fuck", "shit"]
+
+# 当前创建器状态
+var selected_job: int = JOB_WARRIOR
+var selected_gender: int = GENDER_MALE
+var selected_shape: int = SHAPE_0
+var turntable_rotation_deg: float = 0.0 # 360° 转盘旋转角度
+
+func _init(p_job: int = JOB_WARRIOR, p_gender: int = GENDER_MALE) -> void:
+ selected_job = p_job
+ selected_gender = p_gender
+ selected_shape = SHAPE_0
+ turntable_rotation_deg = 0.0
+
+# 1. 切换职业与属性联动
+func select_job(job_id: int) -> bool:
+ if not START_STAT_TABLE.has(job_id):
+ return false
+ selected_job = job_id
+ return true
+
+# 2. 切换性别
+func select_gender(gender_id: int) -> bool:
+ if gender_id != GENDER_MALE and gender_id != GENDER_FEMALE:
+ return false
+ selected_gender = gender_id
+ return true
+
+# 3. 切换外观 (Shape 0 / Shape 1)
+func select_shape(shape_id: int) -> bool:
+ if shape_id != SHAPE_0 and shape_id != SHAPE_1:
+ return false
+ selected_shape = shape_id
+ return true
+
+# 4. 旋转展示台 (360 度循环)
+func rotate_turntable(delta_deg: float) -> float:
+ turntable_rotation_deg = fposmod(turntable_rotation_deg + delta_deg, 360.0)
+ return turntable_rotation_deg
+
+# 5. 获取当前职业的基础四维属性
+func get_current_stats() -> Dictionary:
+ return START_STAT_TABLE.get(selected_job, { "con": 4, "int": 3, "str": 6, "dex": 3 })
+
+# 6. 角色名称合规性校验
+func validate_character_name(name_str: String) -> Dictionary:
+ var res := { "valid": false, "error_code": "OK", "message": "" }
+
+ if name_str.is_empty():
+ res["error_code"] = "EMPTY_NAME"
+ res["message"] = "角色名称不能为空。"
+ return res
+
+ var length := name_str.length()
+ if length < 2 or length > 12:
+ res["error_code"] = "INVALID_NAME_LENGTH"
+ res["message"] = "角色名称长度需在 2 到 12 字符之间。"
+ return res
+
+ if name_str.contains(" ") or name_str.contains("\t"):
+ res["error_code"] = "NAME_CONTAINS_SPACE"
+ res["message"] = "角色名称中不能包含空格。"
+ return res
+
+ var lower_name := name_str.to_lower()
+ for banned in BANNED_NAME_PATTERNS:
+ if lower_name.contains(banned.to_lower()):
+ res["error_code"] = "NAME_CONTAINS_BANNED_WORD"
+ res["message"] = "角色名称中包含非法或保留字符。"
+ return res
+
+ res["valid"] = true
+ return res
+
+# 7. 确认创建角色并打包角色初始数据
+func create_character(name_str: String, empire_id: int = 1) -> Dictionary:
+ var val = validate_character_name(name_str)
+ if not val["valid"]:
+ return { "success": false, "error_code": val["error_code"], "message": val["message"] }
+
+ var stats: Dictionary = get_current_stats()
+ var char_data := {
+ "name": name_str,
+ "empire": empire_id,
+ "level": 1,
+ "exp": 0,
+ "job": selected_job,
+ "gender": selected_gender,
+ "shape": selected_shape,
+ "cur_hp": 1000,
+ "max_hp": 1000,
+ "cur_sp": 500,
+ "max_sp": 500,
+ "stats": stats,
+ "horse_level": 0,
+ "playtime_min": 0,
+ "delete_code": "1234567" # 默认 7 位删除安全码
+ }
+
+ return {
+ "success": true,
+ "character": char_data,
+ "message": "角色 %s 创建成功!" % name_str
+ }
diff --git a/project/intro_create_phase.gd.uid b/project/intro_create_phase.gd.uid
new file mode 100644
index 00000000..b6a38c85
--- /dev/null
+++ b/project/intro_create_phase.gd.uid
@@ -0,0 +1 @@
+uid://b8ru3phcsedjl
diff --git a/project/intro_empire_phase.gd b/project/intro_empire_phase.gd
new file mode 100644
index 00000000..c26d310d
--- /dev/null
+++ b/project/intro_empire_phase.gd
@@ -0,0 +1,99 @@
+# intro_empire_phase.gd —— 40250 三国阵营地貌 3D 运镜巡览系统 1:1
+# 严格对照:
+# Client/Eternexus/root/introempire.py (SelectEmpireWindow)
+# ClientVS22/source/UserInterface/PythonApplicationCamera.cpp
+# Client/Eternexus/root/uiscript/locale/en/empiredesc_*.txt
+extends RefCounted
+
+const EMPIRE_NONE := 0
+const EMPIRE_SHINSOO := 1 # 赤蛇国 (进国 / 红色)
+const EMPIRE_CHUNJO := 2 # 白虎国 (顺天 / 黄色)
+const EMPIRE_JINNO := 3 # 苍龙国 (忍国 / 蓝色)
+
+# 三国官方 3D 摄像机观察预设点位 (Position & Rotation Degrees)
+const CAMERA_PRESETS := {
+ EMPIRE_SHINSOO: {
+ "cam_pos": Vector3(100.0, 50.0, -200.0),
+ "cam_rot": Vector3(-15.0, 45.0, 0.0),
+ "name": "赤蛇国 (Shinsoo)",
+ "capital": "Yongan",
+ "color": "#FF3333",
+ "desc": "Shinsoo 帝国雄踞大陆南部,商贸繁荣昌盛,是商旅与财富的汇聚之地。",
+ "trait": "商贾通达:全帝国交易税率降低 2%,行商收益提升。"
+ },
+ EMPIRE_CHUNJO: {
+ "cam_pos": Vector3(-150.0, 60.0, -180.0),
+ "cam_rot": Vector3(-12.0, 135.0, 0.0),
+ "name": "白虎国 (Chunjo)",
+ "capital": "Joan",
+ "color": "#FFCC00",
+ "desc": "Chunjo 帝国坐落大陆西部,是古老圣灵信仰的发源地,奉行神圣天道。",
+ "trait": "神圣庇佑:全帝国子民魔法防御力与精神抗性 +5%。"
+ },
+ EMPIRE_JINNO: {
+ "cam_pos": Vector3(0.0, 80.0, -250.0),
+ "cam_rot": Vector3(-18.0, 0.0, 0.0),
+ "name": "苍龙国 (Jinno)",
+ "capital": "Pyungmoo",
+ "color": "#3366FF",
+ "desc": "Jinno 帝国占据大陆东部,崇尚武道强权,铁血军纪森严,武士骁勇善战。",
+ "trait": "尚武好战:全帝国子民物理基础攻击力 +5%。"
+ }
+}
+
+var selected_empire: int = EMPIRE_SHINSOO
+var current_cam_pos: Vector3 = Vector3.ZERO
+var current_cam_rot: Vector3 = Vector3.ZERO
+var target_cam_pos: Vector3 = Vector3.ZERO
+var target_cam_rot: Vector3 = Vector3.ZERO
+var is_transitioning: bool = false
+var transition_t: float = 1.0
+
+func _init(initial_empire: int = EMPIRE_SHINSOO) -> void:
+ select_empire(initial_empire)
+ current_cam_pos = target_cam_pos
+ current_cam_rot = target_cam_rot
+ is_transitioning = false
+ transition_t = 1.0
+
+# 1. 选中帝国阵营 (1:1 SelectEmpireWindow.OnSelectEmpire)
+func select_empire(empire_id: int) -> bool:
+ if not CAMERA_PRESETS.has(empire_id):
+ return false
+
+ selected_empire = empire_id
+ var preset: Dictionary = CAMERA_PRESETS[empire_id]
+ target_cam_pos = preset["cam_pos"]
+ target_cam_rot = preset["cam_rot"]
+ is_transitioning = true
+ transition_t = 0.0
+ return true
+
+# 2. 摄像机平滑插值运镜更新 (1:1 PythonApplicationCamera::Update)
+func update_camera_transition(delta_t: float) -> void:
+ if not is_transitioning:
+ return
+
+ transition_t = clamp(transition_t + delta_t, 0.0, 1.0)
+ current_cam_pos = current_cam_pos.lerp(target_cam_pos, transition_t)
+ current_cam_rot = current_cam_rot.lerp(target_cam_rot, transition_t)
+
+ if transition_t >= 1.0:
+ is_transitioning = false
+ current_cam_pos = target_cam_pos
+ current_cam_rot = target_cam_rot
+
+# 3. 获取当前帝国的详情数据
+func get_selected_empire_info() -> Dictionary:
+ return CAMERA_PRESETS.get(selected_empire, {})
+
+# 4. 确认抉择帝国阵营并过渡到选角界面
+func confirm_empire_selection() -> Dictionary:
+ var info := get_selected_empire_info()
+ return {
+ "success": true,
+ "empire_id": selected_empire,
+ "empire_name": info.get("name", ""),
+ "capital": info.get("capital", ""),
+ "next_phase": "CHARACTER_SELECT"
+ }
diff --git a/project/intro_empire_phase.gd.uid b/project/intro_empire_phase.gd.uid
new file mode 100644
index 00000000..31b9f270
--- /dev/null
+++ b/project/intro_empire_phase.gd.uid
@@ -0,0 +1 @@
+uid://cv1af574ekhuf
diff --git a/project/intro_loading_phase.gd b/project/intro_loading_phase.gd
new file mode 100644
index 00000000..2eb2fdb6
--- /dev/null
+++ b/project/intro_loading_phase.gd
@@ -0,0 +1,97 @@
+# intro_loading_phase.gd —— 40250 官方插画过场加载与 Tip 轮播系统 1:1
+# 严格对照:
+# Client/Eternexus/root/introloading.py (LoadingWindow)
+# Client/Eternexus/root/locale/en/locale_game.txt (LOADING_TIP_*)
+extends RefCounted
+
+# 官方预设背景插画壁纸
+const OFFICIAL_LOADING_WALLPAPERS := [
+ "d:/ymir work/ui/intro/loading/loading_01.sub",
+ "d:/ymir work/ui/intro/loading/loading_02.sub",
+ "d:/ymir work/ui/intro/loading/loading_03.sub",
+ "d:/ymir work/ui/intro/loading/loading_04.sub"
+]
+
+# 40250 官方新手锦囊提示语库 (Tip Shower)
+const OFFICIAL_LOADING_TIPS := [
+ "提示:组队状态下开启平等分配模式,全队均可获得丰厚的经验奖励。",
+ "提示:强化高等级装备时,使用祈愿卷轴或战神卷轴可有效避免装备碎毁风险。",
+ "提示:生物学家采吉拉布对奇珍异宝极感兴趣,完成研究任务可获得永久性属性加成。",
+ "提示:善恶值过低将沦为恶徒,死亡时身上的装备与道具将存在掉落风险。",
+ "提示:研读大师技能书前使用隐士之药,可将顿悟成功率提升至 100%。",
+ "提示:使用快捷键 Alt+M 可随时呼出好友面板与同伴展开即时私聊。",
+ "提示:按键盘 M 键可展开全景大地图,点击 NPC 索引即可激活方位指引光标。"
+]
+
+# 资源加载阶段划分 (1:1 introloading.py: loadStepList)
+const STEP_INIT := 0
+const STEP_PROPERTY := 1 # 载入 Property 属性模板 (25%)
+const STEP_TERRAIN := 2 # 载入 PRTerrain 地貌与 Zone 建筑 (50%)
+const STEP_ENTITIES := 3 # 载入角色怪物实体与 NPC (75%)
+const STEP_UI_AUDIO := 4 # 载入界面控件与音频音效 (100%)
+const STEP_FINISHED := 5 # 加载完毕,进入视口
+
+var current_wallpaper: String = ""
+var current_tip: String = ""
+var current_step: int = STEP_INIT
+var progress_percent: float = 0.0
+
+func _init() -> void:
+ reset_loading_phase()
+
+func reset_loading_phase() -> void:
+ current_step = STEP_INIT
+ progress_percent = 0.0
+ current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[0]
+ current_tip = OFFICIAL_LOADING_TIPS[0]
+
+# 1. 开启过场加载并随机挑选壁纸与 Tip
+func start_loading(wallpaper_index: int = -1, tip_index: int = -1) -> void:
+ reset_loading_phase()
+
+ if wallpaper_index >= 0 and wallpaper_index < OFFICIAL_LOADING_WALLPAPERS.size():
+ current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[wallpaper_index]
+ else:
+ current_wallpaper = OFFICIAL_LOADING_WALLPAPERS[randi() % OFFICIAL_LOADING_WALLPAPERS.size()]
+
+ if tip_index >= 0 and tip_index < OFFICIAL_LOADING_TIPS.size():
+ current_tip = OFFICIAL_LOADING_TIPS[tip_index]
+ else:
+ current_tip = OFFICIAL_LOADING_TIPS[randi() % OFFICIAL_LOADING_TIPS.size()]
+
+# 2. 推进加载阶段进度
+func advance_loading_step() -> Dictionary:
+ current_step = mini(current_step + 1, STEP_FINISHED)
+ match current_step:
+ STEP_PROPERTY:
+ progress_percent = 0.25
+ STEP_TERRAIN:
+ progress_percent = 0.50
+ STEP_ENTITIES:
+ progress_percent = 0.75
+ STEP_UI_AUDIO, STEP_FINISHED:
+ progress_percent = 1.0
+
+ var done: bool = (current_step >= STEP_FINISHED)
+ return {
+ "step": current_step,
+ "progress": progress_percent,
+ "is_complete": done,
+ "status_text": _get_step_status_text(current_step)
+ }
+
+func _get_step_status_text(step: int) -> String:
+ match step:
+ STEP_PROPERTY:
+ return "正在载入地图环境与属性配置 (25%)..."
+ STEP_TERRAIN:
+ return "正在构建 3D 地形与场景建筑 (50%)..."
+ STEP_ENTITIES:
+ return "正在同步世界生物与 NPC 实体 (75%)..."
+ STEP_UI_AUDIO, STEP_FINISHED:
+ return "正在初始化界面与音效资产 (100%)..."
+ _:
+ return "准备进入世界..."
+
+func is_completed() -> bool:
+ return current_step >= STEP_FINISHED and progress_percent >= 1.0
diff --git a/project/intro_loading_phase.gd.uid b/project/intro_loading_phase.gd.uid
new file mode 100644
index 00000000..d4f48bb8
--- /dev/null
+++ b/project/intro_loading_phase.gd.uid
@@ -0,0 +1 @@
+uid://bsq7qc4nkj506
diff --git a/project/intro_login_phase.gd b/project/intro_login_phase.gd
new file mode 100644
index 00000000..bd25c9a8
--- /dev/null
+++ b/project/intro_login_phase.gd
@@ -0,0 +1,124 @@
+# intro_login_phase.gd —— 40250 官方登录、频道探测与凭据存储系统 1:1
+# 严格对照:
+# Client/Eternexus/root/intrologin.py (LoginWindow, ConnectingDialog)
+# ClientVS22/source/UserInterface/ServerStateChecker.cpp, ServerStateChecker.h
+# ClientVS22/source/UserInterface/AccountConnector.cpp
+extends RefCounted
+
+# 官方频道负载状态 (ServerStateChecker)
+const STATE_OFFLINE := 0 # 维护中 / 离线
+const STATE_NORMAL := 1 # 畅通 (绿色)
+const STATE_BUSY := 2 # 繁忙 (黄色)
+const STATE_FULL := 3 # 满载爆满 (红色)
+
+const CHANNEL_NAMES := ["CH1 (标准一区)", "CH2 (标准二区)", "CH3 (战备三区)", "CH4 (自由四区)"]
+
+# 模拟预设频道列表 (端口与负载)
+var channels: Array = [
+ { "id": 1, "name": "CH1", "port": 13001, "state": STATE_NORMAL },
+ { "id": 2, "name": "CH2", "port": 13002, "state": STATE_NORMAL },
+ { "id": 3, "name": "CH3", "port": 13003, "state": STATE_BUSY },
+ { "id": 4, "name": "CH4", "port": 13004, "state": STATE_NORMAL },
+]
+
+# 模拟注册账户数据库 (用于离线单机与离线校验)
+var registered_accounts: Dictionary = {
+ "admin": { "password": "password", "is_blocked": false, "has_characters": true },
+ "player": { "password": "123", "is_blocked": false, "has_characters": true },
+ "banned": { "password": "123", "is_blocked": true, "has_characters": false },
+}
+
+var selected_channel_id: int = 1
+var saved_account_name: String = ""
+var is_remember_account: bool = false
+var is_connecting: bool = false
+
+func _init() -> void:
+ selected_channel_id = 1
+ saved_account_name = ""
+ is_remember_account = false
+ is_connecting = false
+
+# 1. 选择服务器频道 (1:1 LoginWindow.__SelectChannel)
+func select_channel(channel_id: int) -> bool:
+ for ch in channels:
+ if ch["id"] == channel_id:
+ selected_channel_id = channel_id
+ return true
+ return false
+
+func get_channel_info(channel_id: int) -> Dictionary:
+ for ch in channels:
+ if ch["id"] == channel_id:
+ return ch
+ return {}
+
+# 2. 刷新分线状态探测 (ServerStateChecker.Request)
+func update_channel_state(channel_id: int, new_state: int) -> void:
+ for ch in channels:
+ if ch["id"] == channel_id:
+ ch["state"] = clamp(new_state, STATE_OFFLINE, STATE_FULL)
+ break
+
+# 3. 记住账号复选框控制与持久化 (intrologin.py)
+func set_remember_account(remember: bool, username: String = "") -> void:
+ is_remember_account = remember
+ if remember and not username.is_empty():
+ saved_account_name = username
+ else:
+ saved_account_name = ""
+
+# 4. 账号登录认证 (1:1 AccountConnector::Connect & SendLoginPacket)
+func attempt_login(account: String, password_input: String) -> Dictionary:
+ var res := {
+ "success": false,
+ "account": account,
+ "channel_id": selected_channel_id,
+ "has_characters": false,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ # 检查目标频道是否离线维护
+ var cur_ch = get_channel_info(selected_channel_id)
+ if cur_ch.get("state", STATE_OFFLINE) == STATE_OFFLINE:
+ res["error_code"] = "CHANNEL_OFFLINE"
+ res["message"] = "该频道服务器维护中,请选择其他频道。"
+ return res
+
+ # 凭据格式检查
+ if account.is_empty() or password_input.is_empty():
+ res["error_code"] = "EMPTY_CREDENTIALS"
+ res["message"] = "请输入账号与密码。"
+ return res
+
+ if account.length() < 3 or account.length() > 16:
+ res["error_code"] = "INVALID_ACCOUNT_LEN"
+ res["message"] = "账号长度需在 3 到 16 字符之间。"
+ return res
+
+ # 账号库匹配
+ if not registered_accounts.has(account):
+ res["error_code"] = "ACCOUNT_NOT_EXIST"
+ res["message"] = "账号不存在,请检查后重试。"
+ return res
+
+ var acc_info: Dictionary = registered_accounts[account]
+ if acc_info.get("is_blocked", false):
+ res["error_code"] = "ACCOUNT_BLOCKED"
+ res["message"] = "该账号已被系统封禁,无法登录。"
+ return res
+
+ if acc_info.get("password") != password_input:
+ res["error_code"] = "WRONG_PASSWORD"
+ res["message"] = "密码错误,请核对密码。"
+ return res
+
+ # 登录成功
+ if is_remember_account:
+ saved_account_name = account
+
+ res["success"] = true
+ res["has_characters"] = acc_info.get("has_characters", false)
+ res["message"] = "登录成功,正在进入游戏大厅..."
+ return res
diff --git a/project/intro_login_phase.gd.uid b/project/intro_login_phase.gd.uid
new file mode 100644
index 00000000..122f76d6
--- /dev/null
+++ b/project/intro_login_phase.gd.uid
@@ -0,0 +1 @@
+uid://rhrwgs2y5dhi
diff --git a/project/intro_select_phase.gd b/project/intro_select_phase.gd
new file mode 100644
index 00000000..deb42860
--- /dev/null
+++ b/project/intro_select_phase.gd
@@ -0,0 +1,130 @@
+# intro_select_phase.gd —— 40250 3D 角色选择大厅系统 1:1
+# 严格对照:
+# Client/Eternexus/root/introselect.py (SelectCharacterWindow)
+# ClientVS22/source/UserInterface/InstanceBaseMotion.cpp (MOTION_INTRO_SELECTED)
+# metin2/src/server/game/src/char.cpp
+extends RefCounted
+
+const SLOT_COUNT := 4 # 官方 4 槽位
+const SLOT_ROTATION := [135.0, 225.0, 315.0, 45.0] # 官方 4 槽位旋转视角 (度数)
+
+const JOB_WARRIOR := 0
+const JOB_ASSASSIN := 1
+const JOB_SURA := 2
+const JOB_SHAMAN := 3
+
+const JOB_NAMES := {
+ JOB_WARRIOR: "战士 (Warrior)",
+ JOB_ASSASSIN: "刺客 (Assassin)",
+ JOB_SURA: "修罗 (Sura)",
+ JOB_SHAMAN: "萨满 (Shaman)",
+}
+
+# 4 个角色槽位存储 (slot 0..3 -> CharacterData or null)
+var character_slots: Array = [null, null, null, null]
+var selected_slot_index: int = 0
+var is_character_stepped_forward: bool = false
+
+func _init() -> void:
+ character_slots = [null, null, null, null]
+ selected_slot_index = 0
+ is_character_stepped_forward = false
+
+# 1. 注册/载入角色槽位数据
+func set_character_slot(slot_idx: int, char_data: Dictionary) -> bool:
+ if slot_idx < 0 or slot_idx >= SLOT_COUNT:
+ return false
+ character_slots[slot_idx] = char_data.duplicate(true)
+ return true
+
+# 2. 选中角色槽位与踏步迈出动画 (1:1 SelectCharacterWindow.SelectSlot)
+func select_slot(slot_idx: int) -> Dictionary:
+ var res := {
+ "success": false,
+ "slot_idx": slot_idx,
+ "is_empty_slot": true,
+ "character": null,
+ "rotation_deg": 0.0,
+ "action": ""
+ }
+
+ if slot_idx < 0 or slot_idx >= SLOT_COUNT:
+ return res
+
+ selected_slot_index = slot_idx
+ res["rotation_deg"] = SLOT_ROTATION[slot_idx]
+
+ if character_slots[slot_idx] == null:
+ is_character_stepped_forward = false
+ res["is_empty_slot"] = true
+ res["action"] = "PROMPT_CREATE"
+ else:
+ is_character_stepped_forward = true # 触发 MOTION_INTRO_SELECTED 迈步出列动画
+ res["is_empty_slot"] = false
+ res["character"] = character_slots[slot_idx]
+ res["action"] = "READY_TO_START"
+
+ res["success"] = true
+ return res
+
+# 3. 开始游戏进入世界 (1:1 SelectCharacterWindow.StartGame)
+func start_game() -> Dictionary:
+ var res := { "success": false, "character": null, "error_code": "OK", "message": "" }
+
+ if character_slots[selected_slot_index] == null:
+ res["error_code"] = "EMPTY_SLOT"
+ res["message"] = "当前选中槽位为空,无法开始游戏。"
+ return res
+
+ res["success"] = true
+ res["character"] = character_slots[selected_slot_index]
+ res["message"] = "进入游戏世界..."
+ return res
+
+# 4. 删除角色与 7 位安全防删 PIN 码校验 (1:1 SelectCharacterWindow.DeleteCharacter)
+func delete_character(slot_idx: int, delete_pin_code: String) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if slot_idx < 0 or slot_idx >= SLOT_COUNT or character_slots[slot_idx] == null:
+ res["error_code"] = "CHARACTER_NOT_FOUND"
+ res["message"] = "未找到待删除的角色。"
+ return res
+
+ # 官方安全码格式验证 (必须为 7 位字符)
+ if delete_pin_code.length() != 7:
+ res["error_code"] = "INVALID_PIN_LENGTH"
+ res["message"] = "防误删安全码必须为 7 位数字。"
+ return res
+
+ var target_char: Dictionary = character_slots[slot_idx]
+ var expected_pin: String = str(target_char.get("delete_code", "1234567"))
+
+ if delete_pin_code != expected_pin:
+ res["error_code"] = "WRONG_DELETE_PIN"
+ res["message"] = "安全码校验失败,删除角色请求被拒绝。"
+ return res
+
+ # 删除成功
+ var char_name: String = target_char.get("name", "Unknown")
+ character_slots[slot_idx] = null
+ if selected_slot_index == slot_idx:
+ is_character_stepped_forward = false
+
+ res["success"] = true
+ res["message"] = "角色 %s 已永久删除。" % char_name
+ return res
+
+# 5. 获取当前选中槽位的角色卡片详情
+func get_selected_character_card() -> Dictionary:
+ if character_slots[selected_slot_index] == null:
+ return {}
+ var c: Dictionary = character_slots[selected_slot_index]
+ var job_idx: int = c.get("job", 0)
+ return {
+ "name": c.get("name", ""),
+ "level": c.get("level", 1),
+ "job_name": JOB_NAMES.get(job_idx, "未知职业"),
+ "horse_level": c.get("horse_level", 0),
+ "playtime_min": c.get("playtime_min", 0),
+ "stats": c.get("stats", { "con": 4, "int": 3, "str": 6, "dex": 3 })
+ }
diff --git a/project/intro_select_phase.gd.uid b/project/intro_select_phase.gd.uid
new file mode 100644
index 00000000..db5ea7e9
--- /dev/null
+++ b/project/intro_select_phase.gd.uid
@@ -0,0 +1 @@
+uid://bm5t5s5eu20ww
diff --git a/project/inventory_expansion_system.gd b/project/inventory_expansion_system.gd
new file mode 100644
index 00000000..98ff2a09
--- /dev/null
+++ b/project/inventory_expansion_system.gd
@@ -0,0 +1,164 @@
+# inventory_expansion_system.gd —— 40250 官方背包槽位解锁凭证与扩展系统 1:1
+# 严格对照:
+# metin2/src/server/common/length.h (INVENTORY_MAX_NUM = 90, 45格/页)
+# metin2/src/server/share/conf/item_proto.txt (72319 / 72320 储物锁匙)
+# ClientVS22/source/UserInterface/GameType.h (INVENTORY_PAGE_SIZE = 45, 5x9 grid)
+extends RefCounted
+
+const PAGE_SIZE := 45 # 每页 45 格 (5 列 x 9 行)
+const BASE_UNLOCKED_PAGES := 2 # 默认前 2 页解锁 (0..89 共 90 格)
+const MAX_PAGES := 4 # 最大 4 页 (共 180 格)
+const TOTAL_SLOTS := 180 # 45 * 4 = 180
+
+const SLOTS_PER_STAGE := 5 # 每个解锁阶段解锁 5 格
+const TOTAL_EXPANSION_STAGES := 18 # 后 2 页共 90 格,划分为 18 个阶段 (90 / 5 = 18)
+
+const EXPANSION_KEY_VNUM := 72319 # 储物锁匙
+
+# 18 个阶段的锁匙消耗曲线 (1:1 官方阶梯消耗)
+# 阶段 1~6 (90..119 格): 1 匙/段
+# 阶段 7~12 (120..149 格): 2 匙/段
+# 阶段 13~17 (150..174 格): 3 匙/段
+# 阶段 18 (175..179 格): 4 匙/段
+const STAGE_KEY_COSTS := [
+ 1, 1, 1, 1, 1, 1, # Stage 1..6
+ 2, 2, 2, 2, 2, 2, # Stage 7..12
+ 3, 3, 3, 3, 3, # Stage 13..17
+ 4 # Stage 18
+]
+
+# 当前已解锁的扩展阶段数 (0..18)
+var unlocked_stages: int = 0
+# 背包槽位存储 (slot 0..179 -> item_data Dictionary or null)
+var inventory_slots: Array = []
+
+func _init(initial_unlocked_stages: int = 0) -> void:
+ unlocked_stages = clamp(initial_unlocked_stages, 0, TOTAL_EXPANSION_STAGES)
+ inventory_slots.clear()
+ for i in range(TOTAL_SLOTS):
+ inventory_slots.append(null)
+
+# 获取当前已解锁的最大格位数 (基础 90 格 + 解锁阶段数 * 5)
+func get_unlocked_slot_count() -> int:
+ return (BASE_UNLOCKED_PAGES * PAGE_SIZE) + (unlocked_stages * SLOTS_PER_STAGE)
+
+# 检查特定格位是否已解锁
+func is_slot_unlocked(slot_idx: int) -> bool:
+ if slot_idx < 0 or slot_idx >= TOTAL_SLOTS:
+ return false
+ return slot_idx < get_unlocked_slot_count()
+
+# 获取下一阶段所需的储物锁匙数量
+func get_next_stage_key_cost() -> int:
+ if unlocked_stages >= TOTAL_EXPANSION_STAGES:
+ return 0
+ return STAGE_KEY_COSTS[unlocked_stages]
+
+# 解锁下一阶段背包槽位 (5 格)
+func unlock_next_stage(player_keys: int) -> Dictionary:
+ var res := {
+ "success": false,
+ "stage": unlocked_stages,
+ "keys_deducted": 0,
+ "remaining_keys": player_keys,
+ "unlocked_slots": get_unlocked_slot_count(),
+ "is_all_unlocked": false,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ if unlocked_stages >= TOTAL_EXPANSION_STAGES:
+ res["is_all_unlocked"] = true
+ res["error_code"] = "ALREADY_MAX_EXPANDED"
+ res["message"] = "背包所有 4 页 180 格位已全部解锁,达到上限!"
+ return res
+
+ var needed_keys := get_next_stage_key_cost()
+ if player_keys < needed_keys:
+ res["error_code"] = "INSUFFICIENT_KEYS"
+ res["message"] = "储物锁匙不足!解锁阶段 %d 需要 %d 枚储物锁匙。" % [unlocked_stages + 1, needed_keys]
+ return res
+
+ unlocked_stages += 1
+ res["success"] = true
+ res["stage"] = unlocked_stages
+ res["keys_deducted"] = needed_keys
+ res["remaining_keys"] = player_keys - needed_keys
+ res["unlocked_slots"] = get_unlocked_slot_count()
+ res["is_all_unlocked"] = (unlocked_stages >= TOTAL_EXPANSION_STAGES)
+ res["message"] = "解锁成功!阶段 %d 已解除封印,新增 5 个可用格位 (当前可用格数: %d)。" % [
+ unlocked_stages, res["unlocked_slots"]
+ ]
+ return res
+
+# 检查物品是否能放置在指定格位 (考虑物品高度 height 与跨页/封印限制)
+# 在 Metin2 中,背包每行 5 格,高度占据 slot, slot+5, slot+10
+func can_place_item_at(slot_idx: int, item_height: int = 1) -> Dictionary:
+ var res := { "can_place": false, "error_code": "OK", "message": "" }
+
+ if slot_idx < 0 or slot_idx >= TOTAL_SLOTS:
+ res["error_code"] = "OUT_OF_BOUNDS"
+ res["message"] = "超出背包格位边界。"
+ return res
+
+ var start_page := slot_idx / PAGE_SIZE
+ var page_local_slot := slot_idx % PAGE_SIZE
+ var row := page_local_slot / 5
+
+ # 跨页检查 (单物品不能从第 1 页跨到底部溢出到第 2 页)
+ if row + item_height > 9:
+ res["error_code"] = "PAGE_OVERFLOW"
+ res["message"] = "物品长度超出单页底部边界。"
+ return res
+
+ # 检查高度占用的每一个单元格
+ for h in range(item_height):
+ var check_slot := slot_idx + (h * 5)
+ if not is_slot_unlocked(check_slot):
+ res["error_code"] = "SLOT_LOCKED"
+ res["message"] = "目标单元格 [%d] 处于锁链封印状态,需使用储物锁匙解锁。" % check_slot
+ return res
+
+ if inventory_slots[check_slot] != null:
+ res["error_code"] = "SLOT_OCCUPIED"
+ res["message"] = "目标单元格已被占用。"
+ return res
+
+ res["can_place"] = true
+ return res
+
+# 放置物品到背包格位
+func put_item(slot_idx: int, item_data: Dictionary, item_height: int = 1) -> Dictionary:
+ var check := can_place_item_at(slot_idx, item_height)
+ if not check["can_place"]:
+ return check
+
+ var it_copy := item_data.duplicate(true)
+ it_copy["size"] = item_height
+ it_copy["head_slot"] = slot_idx
+
+ for h in range(item_height):
+ var cell := slot_idx + (h * 5)
+ inventory_slots[cell] = it_copy
+
+ return { "success": true, "error_code": "OK", "slot": slot_idx }
+
+# 从背包移除物品
+func remove_item(slot_idx: int) -> Dictionary:
+ var res := { "success": false, "item": null, "error_code": "OK" }
+ if slot_idx < 0 or slot_idx >= TOTAL_SLOTS or inventory_slots[slot_idx] == null:
+ res["error_code"] = "EMPTY_SLOT"
+ return res
+
+ var it: Dictionary = inventory_slots[slot_idx]
+ var head_slot: int = it.get("head_slot", slot_idx)
+ var size: int = it.get("size", 1)
+
+ for h in range(size):
+ var cell := head_slot + (h * 5)
+ if cell < TOTAL_SLOTS:
+ inventory_slots[cell] = null
+
+ res["success"] = true
+ res["item"] = it
+ return res
diff --git a/project/inventory_expansion_system.gd.uid b/project/inventory_expansion_system.gd.uid
new file mode 100644
index 00000000..ff1245f6
--- /dev/null
+++ b/project/inventory_expansion_system.gd.uid
@@ -0,0 +1 @@
+uid://8vqdwqxsikw
diff --git a/project/inventory_sort_system.gd b/project/inventory_sort_system.gd
new file mode 100644
index 00000000..dec1834c
--- /dev/null
+++ b/project/inventory_sort_system.gd
@@ -0,0 +1,163 @@
+# inventory_sort_system.gd —— Metin2 40250 多页背包智能自动整理与堆叠合并 1:1
+# 对照 40250 服务端 char_item.cpp, uiinventory.py, item.cpp
+class_name InventorySortSystem
+extends RefCounted
+
+signal inventory_sorted(total_items: int, merged_stacks: int)
+signal page_switched(new_page: int)
+
+const PAGE_SIZE := 45
+const TOTAL_PAGES := 2
+const TOTAL_SLOTS := 90 # 45 x 2
+const MAX_STACK_LIMIT := 200
+
+# 排序优先级分类
+enum ItemCategory {
+ WEAPON = 1, # 武器
+ ARMOR = 2, # 铠甲防具
+ JEWELRY = 3, # 饰品首饰
+ CONSUMABLE = 4, # 药水与消耗品
+ MATERIAL = 5, # 强化升阶材料
+ CHEST_KEY = 6, # 宝箱与钥匙
+ OTHER = 7 # 其他
+}
+
+var current_page: int = 0
+
+# 切换当前显示背包页 (0 或 1)
+func switch_page(page_idx: int) -> Dictionary:
+ if page_idx < 0 or page_idx >= TOTAL_PAGES:
+ return {"ok": false, "reason": "INVALID_PAGE", "msg": "无效的背包页码!"}
+
+ current_page = page_idx
+ page_switched.emit(page_idx)
+
+ return {
+ "ok": true,
+ "page": page_idx,
+ "slot_start": page_idx * PAGE_SIZE,
+ "slot_end": (page_idx + 1) * PAGE_SIZE - 1
+ }
+
+# 获取物品所属排序大类
+static func get_item_category(item: Dictionary) -> int:
+ var it_type: String = item.get("item_type", "")
+ match it_type:
+ "weapon":
+ return ItemCategory.WEAPON
+ "armor", "shield", "helmet":
+ return ItemCategory.ARMOR
+ "jewelry", "necklace", "earrings", "bracelet", "shoes", "belt":
+ return ItemCategory.JEWELRY
+ "potion", "consumable", "dew", "elixir":
+ return ItemCategory.CONSUMABLE
+ "material", "fragment", "scroll":
+ return ItemCategory.MATERIAL
+ "chest", "key":
+ return ItemCategory.CHEST_KEY
+ _:
+ # 根据 vnum 范围与特征推断
+ var vnum: int = int(item.get("vnum", 0))
+ if vnum < 3000 and vnum >= 10:
+ return ItemCategory.WEAPON
+ elif vnum >= 11000 and vnum < 13200:
+ return ItemCategory.ARMOR
+ elif (vnum >= 27000 and vnum < 28000) or (vnum >= 50800 and vnum < 50900) or (vnum >= 72700 and vnum < 72800):
+ return ItemCategory.CONSUMABLE
+ elif (vnum >= 50000 and vnum < 50200) or (vnum >= 30000 and vnum < 30400):
+ return ItemCategory.CHEST_KEY
+ elif vnum >= 71000 and vnum < 72000:
+ return ItemCategory.MATERIAL
+ return ItemCategory.OTHER
+
+# 判定物品是否可堆叠
+static func is_item_stackable(item: Dictionary) -> bool:
+ if item.get("is_stackable", false):
+ return true
+ var it_type: String = item.get("item_type", "")
+ if ["potion", "consumable", "dew", "elixir", "material", "fragment", "scroll", "arrow"].has(it_type):
+ return true
+ var cnt: int = int(item.get("count", 1))
+ return cnt > 1
+
+# 自动合并碎片堆叠 (Auto-Stack Merge)
+func merge_stacks(inventory: Array) -> int:
+ var merged_count := 0
+
+ for i in range(inventory.size()):
+ var base = inventory[i]
+ if base == null or not is_item_stackable(base):
+ continue
+
+ var b_vnum: int = int(base.get("vnum", 0))
+ var b_count: int = int(base.get("count", 1))
+ if b_count >= MAX_STACK_LIMIT:
+ continue
+
+ for j in range(i + 1, inventory.size()):
+ var other = inventory[j]
+ if other == null or int(other.get("vnum", 0)) != b_vnum:
+ continue
+
+ var o_count: int = int(other.get("count", 1))
+ var space := MAX_STACK_LIMIT - b_count
+
+ if o_count <= space:
+ b_count += o_count
+ base["count"] = b_count
+ inventory[j] = null
+ merged_count += 1
+ else:
+ b_count = MAX_STACK_LIMIT
+ base["count"] = MAX_STACK_LIMIT
+ other["count"] = o_count - space
+ merged_count += 1
+ break
+
+ return merged_count
+
+# 一键智能整理背包 (堆叠合并 + 分类前置紧凑排序)
+func sort_inventory(inventory: Array) -> Dictionary:
+ # 1. 先进行碎片堆叠合并
+ var merged := merge_stacks(inventory)
+
+ # 2. 收集所有有效物品并打上排序权重标签
+ var items_to_sort: Array = []
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null:
+ var cat := get_item_category(it)
+ var vnum: int = int(it.get("vnum", 0))
+ var level: int = int(it.get("level", 0))
+ items_to_sort.append({
+ "item": it,
+ "category": cat,
+ "vnum": vnum,
+ "level": level
+ })
+
+ # 3. 排序比较器:类别升序 (1~7),同类内 vnum 升序/降序排列
+ items_to_sort.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
+ if a["category"] != b["category"]:
+ return a["category"] < b["category"]
+ if a["vnum"] != b["vnum"]:
+ return a["vnum"] < b["vnum"]
+ return int(a["item"].get("count", 1)) > int(b["item"].get("count", 1))
+ )
+
+ # 4. 紧凑回填背包并清空末尾
+ var total_items := items_to_sort.size()
+ for i in range(inventory.size()):
+ if i < total_items:
+ inventory[i] = items_to_sort[i]["item"]
+ else:
+ inventory[i] = null
+
+ inventory_sorted.emit(total_items, merged)
+
+ return {
+ "ok": true,
+ "total_items": total_items,
+ "merged_stacks": merged,
+ "msg": "背包整理完成!共归纳 %d 件物品,合并 %d 处堆叠。" % [total_items, merged]
+ }
diff --git a/project/inventory_sort_system.gd.uid b/project/inventory_sort_system.gd.uid
new file mode 100644
index 00000000..33421472
--- /dev/null
+++ b/project/inventory_sort_system.gd.uid
@@ -0,0 +1 @@
+uid://75w86o6g8kif
diff --git a/project/item_attr_system.gd b/project/item_attr_system.gd
new file mode 100644
index 00000000..5677422d
--- /dev/null
+++ b/project/item_attr_system.gd
@@ -0,0 +1,231 @@
+# item_attr_system.gd —— 装备附魔与属性洗炼系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 char_item.cpp:4658-4720 (USE_ADD_ATTRIBUTE, Vnum 71085)
+# - 40250 char_item.cpp:4553-4657 (USE_CHANGE_ATTRIBUTE, Vnum 71084)
+# - 40250 char_item.cpp:4728-4780 (USE_ADD_ATTRIBUTE2 / 祝福宝珠, Vnum 70024)
+# - 40250 constants.cpp:932 (aiItemAttributeAddPercent = [100, 80, 60, 50, 30, 0, 0])
+# - 40250 item_attribute.cpp (PutAttributeWithLevel, HasAttr, GetAttributeSetIndex)
+class_name ItemAttrSystem
+extends RefCounted
+
+# 核心 Vnum 常量(40250 官方标准)
+const VNUM_ATTR_ADD := 71085 # 物品属性添加秘籍 (USE_ADD_ATTRIBUTE, 添加第 1~4 条)
+const VNUM_ATTR_CHANGE := 71084 # 物品属性转换秘籍 (USE_CHANGE_ATTRIBUTE, 重置现有全部属性)
+const VNUM_BLESSING_MARBLE := 70024 # 祝福宝珠 / 第五属性石 (USE_ADD_ATTRIBUTE2, 添加第 5 条)
+
+# 40250 constants.cpp:932 官方附魔成功率表(前 4 条及第 5 条宝珠)
+# 索引:现有词条数 -> 成功概率百分比
+const ADD_PERCENT_TABLE := [100, 80, 60, 50, 30]
+
+# 装备类型大类(ItemData.h)
+const ITEM_TYPE_WEAPON := 1
+const ITEM_TYPE_ARMOR := 2
+
+# 属性池配置:针对武器与防具的标准 40250 词条池与数值范围(5 档位)
+# 每个 apply_type: { name, values: [lv1, lv2, lv3, lv4, lv5], prob_weight }
+const WEAPON_ATTR_POOL := {
+ 1: {"name": "最大生命", "values": [500, 1000, 1500, 2000, 2500], "weight": 10},
+ 2: {"name": "最大法力", "values": [100, 200, 300, 500, 800], "weight": 10},
+ 3: {"name": "体质", "values": [2, 4, 6, 8, 12], "weight": 8},
+ 4: {"name": "智力", "values": [2, 4, 6, 8, 12], "weight": 8},
+ 5: {"name": "力量", "values": [2, 4, 6, 8, 12], "weight": 8},
+ 6: {"name": "敏捷", "values": [2, 4, 6, 8, 12], "weight": 8},
+ 7: {"name": "攻击速度", "values": [2, 4, 6, 8, 10], "weight": 8},
+ 9: {"name": "施法速度", "values": [4, 8, 12, 16, 20], "weight": 8},
+ 13: {"name": "眩晕概率", "values": [2, 3, 5, 8, 10], "weight": 6},
+ 14: {"name": "缓慢概率", "values": [2, 3, 5, 8, 10], "weight": 6},
+ 15: {"name": "暴击概率", "values": [2, 4, 6, 8, 10], "weight": 7},
+ 16: {"name": "穿透概率", "values": [2, 4, 6, 8, 10], "weight": 7},
+ 17: {"name": "对人族伤害", "values": [2, 4, 6, 8, 10], "weight": 6},
+ 19: {"name": "对兽人伤害", "values": [4, 6, 10, 14, 20], "weight": 8},
+ 21: {"name": "对不死族伤害", "values": [4, 6, 10, 14, 20], "weight": 8},
+ 22: {"name": "对恶魔伤害", "values": [4, 6, 10, 14, 20], "weight": 8},
+ 53: {"name": "攻击等级", "values": [10, 20, 30, 40, 50], "weight": 7},
+}
+
+const ARMOR_ATTR_POOL := {
+ 1: {"name": "最大生命", "values": [500, 1000, 1500, 2000, 2500], "weight": 12},
+ 2: {"name": "最大法力", "values": [100, 200, 300, 500, 800], "weight": 10},
+ 7: {"name": "攻击速度", "values": [2, 4, 6, 8, 10], "weight": 8},
+ 8: {"name": "移动速度", "values": [2, 4, 6, 8, 10], "weight": 8},
+ 9: {"name": "施法速度", "values": [4, 8, 12, 16, 20], "weight": 8},
+ 23: {"name": "生命吸收", "values": [2, 3, 5, 8, 10], "weight": 7},
+ 24: {"name": "法力吸收", "values": [2, 3, 5, 8, 10], "weight": 7},
+ 27: {"name": "格挡", "values": [2, 4, 6, 8, 10], "weight": 7},
+ 29: {"name": "剑防御", "values": [4, 6, 10, 12, 15], "weight": 6},
+ 30: {"name": "双手防御", "values": [4, 6, 10, 12, 15], "weight": 6},
+ 31: {"name": "匕首防御", "values": [4, 6, 10, 12, 15], "weight": 6},
+ 34: {"name": "弓防御", "values": [4, 6, 10, 12, 15], "weight": 6},
+ 35: {"name": "火抗性", "values": [4, 6, 10, 12, 15], "weight": 7},
+ 37: {"name": "魔法抗性", "values": [4, 6, 10, 12, 15], "weight": 7},
+ 38: {"name": "风抗性", "values": [4, 6, 10, 12, 15], "weight": 7},
+ 54: {"name": "防御等级", "values": [10, 15, 20, 30, 40], "weight": 8},
+}
+
+## 判定物品是否是附魔相关卷轴
+static func is_attr_scroll(vnum: int) -> bool:
+ return vnum in [VNUM_ATTR_ADD, VNUM_ATTR_CHANGE, VNUM_BLESSING_MARBLE]
+
+## 判定目标物品是否可附魔/洗炼(武器或防具装备,对应 40250 GetAttributeSetIndex != -1)
+static func can_have_attributes(target_proto: Dictionary) -> bool:
+ var item_type := int(target_proto.get("type", 0))
+ if item_type == ITEM_TYPE_WEAPON:
+ var sub_type := int(target_proto.get("sub_type", 0))
+ return sub_type != 5 # 箭矢 (WEAPON_ARROW) 不可附魔
+ elif item_type == ITEM_TYPE_ARMOR:
+ return true
+ return false
+
+## 获取目标装备对应的属性池
+static func get_attr_pool(target_proto: Dictionary) -> Dictionary:
+ var item_type := int(target_proto.get("type", 0))
+ if item_type == ITEM_TYPE_WEAPON:
+ return WEAPON_ATTR_POOL
+ return ARMOR_ATTR_POOL
+
+## 执行附魔添加属性(USE_ADD_ATTRIBUTE 71085)
+## 40250 原生逻辑:词条数 < 4;根据已有条数判定成功率;成功则随机一条不重复属性与档次
+static func apply_add_attribute(target_item: Dictionary, target_proto: Dictionary, force_success := false) -> Dictionary:
+ if not can_have_attributes(target_proto):
+ return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法添加属性。"}
+
+ var attrs: Array = target_item.get("attrs", []).duplicate(true)
+ if attrs.size() >= 4:
+ return {"ok": false, "code": "MAX_ATTR_REACHED", "msg": "此物品已有 4 条或更多属性,无法再添加基础属性。"}
+
+ var cur_count := attrs.size()
+ var rate: int = int(ADD_PERCENT_TABLE[cur_count]) if cur_count < ADD_PERCENT_TABLE.size() else 0
+ var roll := 0 if force_success else (randi() % 100 + 1)
+
+ if not force_success and roll > rate:
+ return {
+ "ok": false,
+ "code": "ADD_ATTR_FAIL",
+ "msg": "属性添加失败。",
+ "consumed": true,
+ }
+
+ var pool := get_attr_pool(target_proto)
+ var existing_types := {}
+ for a in attrs:
+ existing_types[int(a.get("type", 0))] = true
+
+ var avail_keys: Array[int] = []
+ for k in pool.keys():
+ if not existing_types.has(int(k)):
+ avail_keys.append(int(k))
+
+ if avail_keys.is_empty():
+ return {"ok": false, "code": "NO_AVAILABLE_ATTR", "msg": "无可添加的属性。"}
+
+ # 按权重随机选取属性
+ var chosen_type: int = _pick_weighted_type(avail_keys, pool)
+ var grade := randi() % 5 # 0..4 (对应 1~5 档)
+ var value: int = int(pool[chosen_type]["values"][grade])
+
+ attrs.append({"type": chosen_type, "value": value})
+ target_item["attrs"] = attrs
+
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "msg": "属性添加成功!",
+ "consumed": true,
+ "added_attr": {"type": chosen_type, "value": value, "name": pool[chosen_type]["name"]},
+ }
+
+## 执行洗炼重置全部属性(USE_CHANGE_ATTRIBUTE 71084)
+## 40250 原生逻辑:词条数 > 0;保留现有词条总数,全部重新随机生成类型与数值
+static func apply_change_attribute(target_item: Dictionary, target_proto: Dictionary) -> Dictionary:
+ if not can_have_attributes(target_proto):
+ return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法重置属性。"}
+
+ var attrs: Array = target_item.get("attrs", []).duplicate(true)
+ var count := attrs.size()
+ if count == 0:
+ return {"ok": false, "code": "NO_ATTRIBUTES", "msg": "此物品没有任何额外属性,无法洗炼。"}
+
+ var pool := get_attr_pool(target_proto)
+ var new_attrs: Array = []
+ var used_types := {}
+
+ for _i in count:
+ var avail_keys: Array[int] = []
+ for k in pool.keys():
+ if not used_types.has(int(k)):
+ avail_keys.append(int(k))
+ if avail_keys.is_empty():
+ break
+ var chosen_type: int = _pick_weighted_type(avail_keys, pool)
+ used_types[chosen_type] = true
+ var grade := randi() % 5
+ var val: int = int(pool[chosen_type]["values"][grade])
+ new_attrs.append({"type": chosen_type, "value": val})
+
+ target_item["attrs"] = new_attrs
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "msg": "属性重置成功!",
+ "consumed": true,
+ "attrs": new_attrs,
+ }
+
+## 执行第五属性宝珠(USE_ADD_ATTRIBUTE2 / 祝福宝珠 70024)
+## 40250 原生逻辑:严格要求现有词条数 == 4;成功率 30%(40250 ADD_PERCENT_TABLE[4])
+static func apply_blessing_marble(target_item: Dictionary, target_proto: Dictionary, force_success := false) -> Dictionary:
+ if not can_have_attributes(target_proto):
+ return {"ok": false, "code": "CANNOT_ATTACH_ATTR", "msg": "该物品无法添加第五属性。"}
+
+ var attrs: Array = target_item.get("attrs", []).duplicate(true)
+ if attrs.size() < 4:
+ return {"ok": false, "code": "NEED_FOUR_ATTRS", "msg": "装备必须先拥有 4 条属性,才能使用祝福宝珠添加第 5 条属性。"}
+ if attrs.size() >= 5:
+ return {"ok": false, "code": "ALREADY_FIVE_ATTRS", "msg": "该物品已达 5 条属性上限。"}
+
+ var rate: int = int(ADD_PERCENT_TABLE[4]) # 30%
+ var roll := 0 if force_success else (randi() % 100 + 1)
+ if not force_success and roll > rate:
+ return {
+ "ok": false,
+ "code": "MARBLE_FAIL",
+ "msg": "祝福宝珠开启第 5 条属性失败。",
+ "consumed": true,
+ }
+
+ var pool := get_attr_pool(target_proto)
+ var existing_types := {}
+ for a in attrs:
+ existing_types[int(a.get("type", 0))] = true
+
+ var avail_keys: Array[int] = []
+ for k in pool.keys():
+ if not existing_types.has(int(k)):
+ avail_keys.append(int(k))
+
+ var chosen_type: int = _pick_weighted_type(avail_keys, pool)
+ var grade := randi() % 5
+ var val: int = int(pool[chosen_type]["values"][grade])
+
+ attrs.append({"type": chosen_type, "value": val})
+ target_item["attrs"] = attrs
+
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "msg": "祝福宝珠成功开启第 5 条属性!",
+ "consumed": true,
+ "added_attr": {"type": chosen_type, "value": val, "name": pool[chosen_type]["name"]},
+ }
+
+static func _pick_weighted_type(avail_keys: Array[int], pool: Dictionary) -> int:
+ var total_weight := 0
+ for k in avail_keys:
+ total_weight += int(pool[k].get("weight", 1))
+ var r := randi() % maxi(1, total_weight)
+ var accum := 0
+ for k in avail_keys:
+ accum += int(pool[k].get("weight", 1))
+ if r < accum:
+ return k
+ return avail_keys[0]
diff --git a/project/item_attr_system.gd.uid b/project/item_attr_system.gd.uid
new file mode 100644
index 00000000..bcada041
--- /dev/null
+++ b/project/item_attr_system.gd.uid
@@ -0,0 +1 @@
+uid://coqdb85vn201c
diff --git a/project/item_enchant_system.gd b/project/item_enchant_system.gd
new file mode 100644
index 00000000..7a234810
--- /dev/null
+++ b/project/item_enchant_system.gd
@@ -0,0 +1,276 @@
+# item_enchant_system.gd —— Metin2 40250 装备词条重铸与精炼附魔洗炼系统 1:1
+# 对照 40250 服务端 char_item.cpp, item_attribute.cpp, constants.cpp
+class_name ItemEnchantSystem
+extends RefCounted
+
+const ItemAttrSystem = preload("res://item_attr_system.gd")
+
+signal enchant_applied(target_slot: int, scroll_vnum: int, success: bool, new_attrs: Array)
+signal rare_enchant_applied(target_slot: int, success: bool, rare_attrs: Array)
+signal enchant_failed(target_slot: int, reason: String)
+
+const VNUM_ATTR_ADD := 71085 # 添加属性秘籍 (添加第 1~4 条)
+const VNUM_ATTR_CHANGE := 71084 # 属性改变秘籍 (洗炼现有 1~5 条)
+const VNUM_BLESSING_MARBLE := 70024 # 祝福宝珠 (添加第 5 条属性)
+const VNUM_RARE_ADD := 71151 # 稀有属性秘籍 (添加第 6~7 条稀有词条)
+const VNUM_RARE_CHANGE := 71152 # 稀有属性改变 (单独洗炼第 6~7 条稀有词条)
+
+const ADD_PERCENT_TABLE := [100, 80, 60, 50, 30] # 官方各阶段成功率
+const RARE_ADD_PERCENT := 50 # 稀有词条附加成功率
+
+# 稀有词条池 (6~7 槽位特有词条)
+const RARE_ATTR_POOL := {
+ 101: {"name": "全技能伤害加成", "values": [1, 2, 3, 5, 8], "weight": 8},
+ 102: {"name": "首领怪物伤害加成", "values": [2, 4, 6, 8, 12], "weight": 8},
+ 103: {"name": "魔石怪物伤害加成", "values": [3, 5, 8, 10, 15], "weight": 10},
+ 104: {"name": "受首领伤害减免", "values": [2, 4, 6, 8, 10], "weight": 8},
+ 105: {"name": "物理防御无视概率", "values": [1, 2, 3, 5, 7], "weight": 6}
+}
+
+# 检查是否为有效附魔卷轴
+static func is_enchant_scroll(vnum: int) -> bool:
+ return vnum in [VNUM_ATTR_ADD, VNUM_ATTR_CHANGE, VNUM_BLESSING_MARBLE, VNUM_RARE_ADD, VNUM_RARE_CHANGE]
+
+# 检查物品是否为可附魔装备
+static func is_enchantable_item(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ # 检查是否上锁保护
+ if item.get("is_locked", false):
+ return false
+ var vnum: int = int(item.get("vnum", 0))
+ # 箭矢、消耗品不可附魔 (箭矢 8000~8009)
+ if vnum >= 8000 and vnum <= 8009:
+ return false
+ var itype: int = int(item.get("type", 1)) # 默认为 1 (Weapon) 或 2 (Armor)
+ return itype in [1, 2]
+
+# 对背包内的装备使用附魔卷轴
+func apply_scroll(scroll_slot: int, target_slot: int, inventory: Array, force_success: bool = false) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ return {"ok": false, "reason": "INVALID_SCROLL_SLOT", "msg": "附魔卷轴格子无效!"}
+
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT", "msg": "目标装备格子无效!"}
+
+ var scroll: Dictionary = inventory[scroll_slot]
+ var target: Dictionary = inventory[target_slot]
+ var scroll_vnum: int = int(scroll.get("vnum", 0))
+
+ if not is_enchant_scroll(scroll_vnum):
+ return {"ok": false, "reason": "NOT_AN_ENCHANT_SCROLL", "msg": "此物品不是附魔洗炼卷轴!"}
+
+ if not is_enchantable_item(target):
+ enchant_failed.emit(target_slot, "ITEM_CANNOT_BE_ENCHANTED")
+ return {"ok": false, "reason": "ITEM_CANNOT_BE_ENCHANTED", "msg": "此装备无法进行词条附魔或已被安全上锁!"}
+
+ if not target.has("attrs"):
+ target["attrs"] = []
+ if not target.has("rare_attrs"):
+ target["rare_attrs"] = []
+
+ var res: Dictionary = {}
+ match scroll_vnum:
+ VNUM_ATTR_ADD:
+ res = _handle_add_attribute(target, force_success)
+ VNUM_ATTR_CHANGE:
+ res = _handle_change_attribute(target)
+ VNUM_BLESSING_MARBLE:
+ res = _handle_blessing_marble(target, force_success)
+ VNUM_RARE_ADD:
+ res = _handle_rare_add(target, force_success)
+ VNUM_RARE_CHANGE:
+ res = _handle_rare_change(target)
+
+ if not res.get("ok", false):
+ enchant_failed.emit(target_slot, res.get("reason", "FAILED"))
+ return res
+
+ # 扣除 1 个卷轴
+ _consume_scroll(scroll_slot, inventory)
+
+ if res.get("success", false):
+ if scroll_vnum in [VNUM_RARE_ADD, VNUM_RARE_CHANGE]:
+ rare_enchant_applied.emit(target_slot, true, target["rare_attrs"])
+ else:
+ enchant_applied.emit(target_slot, scroll_vnum, true, target["attrs"])
+ else:
+ enchant_applied.emit(target_slot, scroll_vnum, false, target["attrs"])
+
+ return res
+
+# 扣除背包中的卷轴
+func _consume_scroll(slot: int, inventory: Array) -> void:
+ var item: Dictionary = inventory[slot]
+ var count: int = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+# 71085: 添加第 1~4 条属性
+func _handle_add_attribute(target: Dictionary, force_success: bool) -> Dictionary:
+ var attrs: Array = target["attrs"]
+ if attrs.size() >= 4:
+ return {"ok": false, "reason": "MAX_BASE_ATTRS_REACHED", "msg": "该装备已有 4 条属性!请使用祝福宝珠开辟第 5 条属性。"}
+
+ var rate: int = ADD_PERCENT_TABLE[attrs.size()]
+ var roll: int = randi_range(1, 100)
+ var is_succ: bool = force_success or (roll <= rate)
+
+ if not is_succ:
+ return {"ok": true, "success": false, "msg": "附魔失败,卷轴化为灰烬,未能追加新属性!"}
+
+ var new_attr = _roll_random_attribute(attrs, target.get("type", 1))
+ attrs.append(new_attr)
+
+ return {
+ "ok": true,
+ "success": true,
+ "new_attr": new_attr,
+ "total_attrs": attrs.size(),
+ "msg": "附魔成功!装备获得了新词条【%s +%d】!" % [new_attr["name"], new_attr["value"]]
+ }
+
+# 71084: 改变所有现有 1~5 条属性
+func _handle_change_attribute(target: Dictionary) -> Dictionary:
+ var attrs: Array = target["attrs"]
+ if attrs.is_empty():
+ return {"ok": false, "reason": "NO_ATTRS_TO_CHANGE", "msg": "该装备没有任何属性词条,无法进行洗炼!"}
+
+ var count: int = attrs.size()
+ attrs.clear()
+
+ for i in range(count):
+ var attr = _roll_random_attribute(attrs, target.get("type", 1))
+ attrs.append(attr)
+
+ return {
+ "ok": true,
+ "success": true,
+ "attrs": attrs,
+ "msg": "洗炼成功!%d 条词条已全新重铸!" % count
+ }
+
+# 70024: 祝福宝珠 (添加第 5 条属性)
+func _handle_blessing_marble(target: Dictionary, force_success: bool) -> Dictionary:
+ var attrs: Array = target["attrs"]
+ if attrs.size() < 4:
+ return {"ok": false, "reason": "NEED_4_ATTRS_FIRST", "msg": "装备必须先拥有 4 条属性,才能使用祝福宝珠!"}
+ if attrs.size() >= 5:
+ return {"ok": false, "reason": "ALREADY_HAS_5_ATTRS", "msg": "该装备已拥有 5 条属性,达到上限!"}
+
+ var rate: int = ADD_PERCENT_TABLE[4] # 30%
+ var roll: int = randi_range(1, 100)
+ var is_succ: bool = force_success or (roll <= rate)
+
+ if not is_succ:
+ return {"ok": true, "success": false, "msg": "祝福宝珠破碎,未能为装备附加第 5 条属性!"}
+
+ var new_attr = _roll_random_attribute(attrs, target.get("type", 1))
+ attrs.append(new_attr)
+
+ return {
+ "ok": true,
+ "success": true,
+ "new_attr": new_attr,
+ "total_attrs": 5,
+ "msg": "神圣光芒降临!成功为装备铭刻第 5 条核心属性【%s +%d】!" % [new_attr["name"], new_attr["value"]]
+ }
+
+# 71151: 稀有属性附加 (第 6~7 条)
+func _handle_rare_add(target: Dictionary, force_success: bool) -> Dictionary:
+ var rares: Array = target["rare_attrs"]
+ if rares.size() >= 2:
+ return {"ok": false, "reason": "MAX_RARE_ATTRS", "msg": "已达到 2 条稀有词条上限!"}
+
+ var roll: int = randi_range(1, 100)
+ var is_succ: bool = force_success or (roll <= RARE_ADD_PERCENT)
+
+ if not is_succ:
+ return {"ok": true, "success": false, "msg": "稀有附魔失败,未能附加稀有词条!"}
+
+ var new_rare = _roll_random_rare_attribute(rares)
+ rares.append(new_rare)
+
+ return {
+ "ok": true,
+ "success": true,
+ "rare_attr": new_rare,
+ "total_rares": rares.size(),
+ "msg": "稀有附魔成功!觉醒了金色稀有词条【%s +%d%%】!" % [new_rare["name"], new_rare["value"]]
+ }
+
+# 71152: 稀有属性改变 (单独洗炼第 6~7 条)
+func _handle_rare_change(target: Dictionary) -> Dictionary:
+ var rares: Array = target["rare_attrs"]
+ if rares.is_empty():
+ return {"ok": false, "reason": "NO_RARES_TO_CHANGE", "msg": "该装备尚无任何稀有词条!"}
+
+ var count: int = rares.size()
+ rares.clear()
+
+ for i in range(count):
+ var r = _roll_random_rare_attribute(rares)
+ rares.append(r)
+
+ return {
+ "ok": true,
+ "success": true,
+ "rare_attrs": rares,
+ "msg": "稀有词条洗炼成功!%d 条稀有属性已重新激活!" % count
+ }
+
+# 生成不重复的普通属性
+func _roll_random_attribute(existing_attrs: Array, item_type: int) -> Dictionary:
+ var existing_ids: Array = []
+ for a in existing_attrs:
+ existing_ids.append(a.get("type", 0))
+
+ # 选用武器或防具属性池
+ var pool = ItemAttrSystem.WEAPON_ATTR_POOL if item_type == 1 else ItemAttrSystem.ARMOR_ATTR_POOL
+ var candidates: Array = []
+ for type_id in pool.keys():
+ if not (type_id in existing_ids):
+ candidates.append(type_id)
+
+ if candidates.is_empty():
+ candidates = pool.keys()
+
+ var picked_id: int = candidates[randi() % candidates.size()]
+ var entry = pool[picked_id]
+ var tier_idx: int = randi() % entry["values"].size()
+ var val: int = entry["values"][tier_idx]
+
+ return {
+ "type": picked_id,
+ "name": entry["name"],
+ "value": val,
+ "tier": tier_idx + 1
+ }
+
+# 生成不重复的稀有属性
+func _roll_random_rare_attribute(existing_rares: Array) -> Dictionary:
+ var existing_ids: Array = []
+ for a in existing_rares:
+ existing_ids.append(a.get("type", 0))
+
+ var candidates: Array = []
+ for type_id in RARE_ATTR_POOL.keys():
+ if not (type_id in existing_ids):
+ candidates.append(type_id)
+
+ if candidates.is_empty():
+ candidates = RARE_ATTR_POOL.keys()
+
+ var picked_id: int = candidates[randi() % candidates.size()]
+ var entry = RARE_ATTR_POOL[picked_id]
+ var tier_idx: int = randi() % entry["values"].size()
+ var val: int = entry["values"][tier_idx]
+
+ return {
+ "type": picked_id,
+ "name": entry["name"],
+ "value": val,
+ "tier": tier_idx + 1
+ }
diff --git a/project/item_enchant_system.gd.uid b/project/item_enchant_system.gd.uid
new file mode 100644
index 00000000..1692341c
--- /dev/null
+++ b/project/item_enchant_system.gd.uid
@@ -0,0 +1 @@
+uid://b24gvmuspy3k7
diff --git a/project/item_split_system.gd b/project/item_split_system.gd
new file mode 100644
index 00000000..d4ff1c53
--- /dev/null
+++ b/project/item_split_system.gd
@@ -0,0 +1,131 @@
+# item_split_system.gd —— Metin2 40250 物品堆叠精准拆分与快捷批量操作 1:1
+# 对照 40250 服务端 char_item.cpp, input_main.cpp, uiinventory.py
+class_name ItemSplitSystem
+extends RefCounted
+
+signal item_split(source_slot: int, target_slot: int, split_count: int)
+signal item_quick_sold(slot_idx: int, gold_received: int, item_name: String)
+
+const DEFAULT_ITEM_SALE_UNIT_PRICE := 100 # 默认单件基础回收金币
+
+# 堆叠精准拆分 (Shift + 左键)
+func split_item(
+ source_slot: int,
+ split_count: int,
+ target_slot: int,
+ inventory: Array
+) -> Dictionary:
+ if source_slot < 0 or source_slot >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SOURCE_SLOT", "msg": "无效的源栏位!"}
+
+ var source_item = inventory[source_slot]
+ if source_item == null:
+ return {"ok": false, "reason": "EMPTY_SOURCE_SLOT", "msg": "源栏位上没有物品!"}
+
+ var current_count: int = int(source_item.get("count", 1))
+ if current_count <= 1:
+ return {"ok": false, "reason": "NOT_SPLITTABLE", "msg": "单件物品无法拆分!"}
+
+ if split_count < 1 or split_count >= current_count:
+ return {
+ "ok": false,
+ "reason": "INVALID_SPLIT_COUNT",
+ "max_allowed": current_count - 1,
+ "msg": "拆分数量必须在 1 到 %d 之间!" % (current_count - 1)
+ }
+
+ # 确定目标格子
+ var dest_slot := target_slot
+ if dest_slot < 0:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ dest_slot = i
+ break
+
+ if dest_slot < 0 or dest_slot >= inventory.size():
+ return {"ok": false, "reason": "NO_DEST_SLOT", "msg": "背包没有可用空位放置拆分出的物品!"}
+
+ if inventory[dest_slot] != null:
+ return {"ok": false, "reason": "DEST_SLOT_NOT_EMPTY", "msg": "目标栏位已被占用!"}
+
+ # 执行拆分
+ source_item["count"] = current_count - split_count
+
+ var new_stack = source_item.duplicate(true)
+ new_stack["count"] = split_count
+ inventory[dest_slot] = new_stack
+
+ item_split.emit(source_slot, dest_slot, split_count)
+
+ return {
+ "ok": true,
+ "source_slot": source_slot,
+ "target_slot": dest_slot,
+ "remaining_source": source_item["count"],
+ "split_count": split_count,
+ "msg": "成功拆分出 %d 件物品!" % split_count
+ }
+
+# 快捷批量出售给商人 (Ctrl + 右键)
+func quick_sell_item(
+ slot_idx: int,
+ inventory: Array,
+ player_data: Dictionary
+) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的栏位!"}
+
+ var item = inventory[slot_idx]
+ if item == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有物品!"}
+
+ # 加锁保护
+ if item.get("is_locked", false):
+ return {"ok": false, "reason": "ITEM_LOCKED", "msg": "已加锁的物品严禁出售!"}
+
+ var count: int = int(item.get("count", 1))
+ var unit_price: int = int(item.get("sale_price", DEFAULT_ITEM_SALE_UNIT_PRICE))
+ var total_gold := unit_price * count
+
+ # 增加金币并清空格子
+ player_data["gold"] = int(player_data.get("gold", 0)) + total_gold
+ var iname: String = item.get("name", "物品")
+ inventory[slot_idx] = null
+
+ item_quick_sold.emit(slot_idx, total_gold, iname)
+
+ return {
+ "ok": true,
+ "slot_idx": slot_idx,
+ "gold_received": total_gold,
+ "item_name": iname,
+ "msg": "已快捷出售【%s x%d】,获得 %d 金币!" % [iname, count, total_gold]
+ }
+
+# 高价值物品丢弃/销毁防误触检查
+func check_drop_safety(item: Dictionary) -> Dictionary:
+ if item.is_empty():
+ return {"can_drop": false}
+
+ if item.get("is_locked", false):
+ return {
+ "can_drop": false,
+ "require_confirm": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的贵重物品无法丢弃!"
+ }
+
+ var refine_level: int = int(item.get("refine_level", 0))
+ var has_attrs: bool = item.has("attributes") and not item["attributes"].is_empty()
+
+ if refine_level >= 7 or has_attrs:
+ return {
+ "can_drop": true,
+ "require_confirm": true,
+ "msg": "此装备强化等级较高或拥有附加属性,属于贵重装备!确定要丢弃吗?"
+ }
+
+ return {
+ "can_drop": true,
+ "require_confirm": false
+ }
diff --git a/project/item_split_system.gd.uid b/project/item_split_system.gd.uid
new file mode 100644
index 00000000..8ea500e4
--- /dev/null
+++ b/project/item_split_system.gd.uid
@@ -0,0 +1 @@
+uid://bwpb3evcv5t6r
diff --git a/project/jewelry_socket_system.gd b/project/jewelry_socket_system.gd
new file mode 100644
index 00000000..4a744495
--- /dev/null
+++ b/project/jewelry_socket_system.gd
@@ -0,0 +1,175 @@
+# jewelry_socket_system.gd —— Metin2 40250 首饰饰品金刚石打孔与精炼宝石镶嵌 1:1
+# 对照 40250 服务端 char_item.cpp (PutGemInAccessory), item_attribute.cpp
+class_name JewelrySocketSystem
+extends RefCounted
+
+const ITEM_DIAMOND := 50621 # 精炼金刚石
+const MAX_SOCKETS := 3
+const GEM_LIFETIME_SECS := 86400.0 # 24 小时战斗佩戴寿命
+
+# 饰品支持打孔的分类 (14:手镯, 16:项链, 17:耳环)
+const VALID_ACCESSORY_PREFIXES := [14, 16, 17]
+
+# 饰品材质与对应精炼宝石映射 (base_vnum_pattern -> gem_vnum)
+const ACCESSORY_GEM_MAPPING: Dictionary = {
+ "copper": 50624, # 铜首饰 -> 铜 50624
+ "silver": 50625, # 银首饰 -> 银 50625
+ "gold": 50626, # 金首饰 -> 金 50626
+ "jade": 50627, # 玉首饰 -> 玉 50627
+ "ebony": 50628, # 黑檀首饰 -> 黑檀 50628
+ "pearl": 50629, # 珍珠首饰 -> 珍珠 50629
+ "white_gold": 50630, # 白金首饰 -> 白金 50630
+ "crystal": 50631, # 水晶首饰 -> 水晶 50631
+ "amethyst": 50632, # 紫晶首饰 -> 紫晶 50632
+ "heavens_tear": 50633,# 天泪首饰 -> 天泪石 50633
+ "soul_crystal": 50634,# 灵魂水晶首饰 -> 灵魂水晶 50634
+ "ruby": 50635, # 红宝石首饰 -> 红宝石 50635
+ "garnet": 50636, # 石榴石首饰 -> 石榴石 50636
+ "emerald": 50637, # 绿宝石首饰 -> 绿宝石 50637
+ "sapphire": 50638 # 蓝宝石首饰 -> 蓝宝石 50638
+}
+
+# 打孔成功率 (0孔打第1孔: 70%, 1孔打第2孔: 50%, 2孔打第3孔: 30%)
+const DRILL_CHANCES: Array[float] = [0.70, 0.50, 0.30]
+
+# 宝石阶梯增益百分比 (第1孔: +10%, 第2孔: +10%, 第3孔: +20%)
+const GEM_BONUS_TIERS: Array[float] = [0.10, 0.10, 0.20]
+
+static func is_accessory(item: Dictionary) -> bool:
+ var vnum: int = int(item.get("vnum", 0))
+ var prefix: int = int(floor(float(vnum) / 1000.0))
+ return prefix in VALID_ACCESSORY_PREFIXES
+
+# 检查是否可用金刚石打孔
+static func can_drill_socket(item: Dictionary) -> Dictionary:
+ if not is_accessory(item):
+ return {"ok": false, "reason": "NOT_ACCESSORY", "msg": "金刚石只能用于手镯、项链或耳环打孔!"}
+
+ var socket_count: int = int(item.get("socket_count", 0))
+ if socket_count >= MAX_SOCKETS:
+ return {"ok": false, "reason": "SOCKETS_FULL", "msg": "该首饰已拥有最大的 3 个宝石孔槽!"}
+
+ return {"ok": true, "current_sockets": socket_count}
+
+# 使用金刚石 50621 打孔
+static func drill_socket(item: Dictionary, diamond_slot_item: Dictionary, force_success: bool = false) -> Dictionary:
+ var check = can_drill_socket(item)
+ if not check["ok"]:
+ return check
+
+ var dvnum: int = int(diamond_slot_item.get("vnum", 0))
+ if dvnum != ITEM_DIAMOND:
+ return {"ok": false, "reason": "NOT_DIAMOND", "msg": "打孔必须使用精炼金刚石!"}
+
+ var current_sockets: int = int(item.get("socket_count", 0))
+ var chance: float = DRILL_CHANCES[current_sockets]
+ var roll: float = 0.0 if force_success else randf()
+ var success: bool = roll < chance
+
+ if not item.has("sockets"):
+ item["sockets"] = [0, 0, 0]
+ if not item.has("gem_times"):
+ item["gem_times"] = [0.0, 0.0, 0.0]
+
+ if success:
+ item["socket_count"] = current_sockets + 1
+ return {
+ "ok": true,
+ "success": true,
+ "socket_count": item["socket_count"],
+ "msg": "打孔成功!已开辟出第 %d 个宝石孔槽!" % item["socket_count"]
+ }
+ else:
+ return {
+ "ok": true,
+ "success": false,
+ "socket_count": current_sockets,
+ "msg": "打孔失败!金刚石破碎,未能开出孔槽。"
+ }
+
+# 镶嵌精炼矿石宝石
+static func insert_gem(item: Dictionary, gem_item: Dictionary, required_gem_vnum: int = 0) -> Dictionary:
+ if not is_accessory(item):
+ return {"ok": false, "reason": "NOT_ACCESSORY"}
+
+ var socket_count: int = int(item.get("socket_count", 0))
+ if socket_count <= 0:
+ return {"ok": false, "reason": "NO_SOCKETS_DRILLED", "msg": "该首饰尚未打孔,请先使用金刚石开孔!"}
+
+ if not item.has("sockets"):
+ item["sockets"] = [0, 0, 0]
+ if not item.has("gem_times"):
+ item["gem_times"] = [0.0, 0.0, 0.0]
+
+ # 查找第一个空孔
+ var empty_slot := -1
+ for i in range(socket_count):
+ if int(item["sockets"][i]) == 0:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "NO_EMPTY_SOCKET", "msg": "所有已开辟的孔槽都已镶嵌满宝石!"}
+
+ var gem_vnum: int = int(gem_item.get("vnum", 0))
+ if gem_vnum < 50622 or gem_vnum > 50638:
+ return {"ok": false, "reason": "INVALID_GEM", "msg": "这不是有效的精炼宝石!"}
+
+ if required_gem_vnum > 0 and gem_vnum != required_gem_vnum:
+ return {"ok": false, "reason": "GEM_MISMATCH", "msg": "此宝石与该首饰材质不匹配!"}
+
+ # 镶嵌成功
+ item["sockets"][empty_slot] = gem_vnum
+ item["gem_times"][empty_slot] = GEM_LIFETIME_SECS
+
+ var active_gems := count_active_gems(item)
+ var bonus_pct := calculate_bonus_percentage(active_gems)
+
+ return {
+ "ok": true,
+ "slot": empty_slot,
+ "gem_vnum": gem_vnum,
+ "active_gems": active_gems,
+ "total_bonus_pct": bonus_pct,
+ "lifetime": GEM_LIFETIME_SECS,
+ "msg": "宝石成功镶入第 %d 个孔槽!固有属性提升 %d%%!" % [empty_slot + 1, int(round(bonus_pct * 100))]
+ }
+
+# 统计当前生效的宝石数量
+static func count_active_gems(item: Dictionary) -> int:
+ var count := 0
+ var sockets: Array = item.get("sockets", [])
+ var times: Array = item.get("gem_times", [])
+ for i in range(sockets.size()):
+ if int(sockets[i]) > 0 and float(times[i]) > 0.0:
+ count += 1
+ return count
+
+# 计算宝石提供的总属性加成百分比 (0~3 颗宝石)
+static func calculate_bonus_percentage(gem_count: int) -> float:
+ var pct: float = 0.0
+ for i in range(mini(gem_count, GEM_BONUS_TIERS.size())):
+ pct += GEM_BONUS_TIERS[i]
+ return pct
+
+# 佩戴战斗时间自然衰减 (dt: 秒)
+static func update_gem_endurance(item: Dictionary, dt: float) -> Dictionary:
+ var expired_gems: Array[int] = []
+ if not item.has("sockets") or not item.has("gem_times"):
+ return {"expired": expired_gems}
+
+ var sockets: Array = item["sockets"]
+ var times: Array = item["gem_times"]
+
+ for i in range(sockets.size()):
+ if int(sockets[i]) > 0 and float(times[i]) > 0.0:
+ times[i] = maxf(0.0, float(times[i]) - dt)
+ if float(times[i]) <= 0.0:
+ expired_gems.append(int(sockets[i]))
+ sockets[i] = 0 # 宝石破碎,孔槽重新清空
+
+ return {
+ "expired": expired_gems,
+ "active_gems": count_active_gems(item),
+ "total_bonus_pct": calculate_bonus_percentage(count_active_gems(item))
+ }
diff --git a/project/jewelry_socket_system.gd.uid b/project/jewelry_socket_system.gd.uid
new file mode 100644
index 00000000..41afb957
--- /dev/null
+++ b/project/jewelry_socket_system.gd.uid
@@ -0,0 +1 @@
+uid://bbfkjq1coiykj
diff --git a/project/live_smoke_test.gd b/project/live_smoke_test.gd
index d9c5be5b..4f342451 100644
--- a/project/live_smoke_test.gd
+++ b/project/live_smoke_test.gd
@@ -88,8 +88,11 @@ func _process(_delta: float) -> void:
match _state:
"settle_first":
- if _seconds_in_state(now) >= SETTLE_SECONDS:
+ if _seconds_in_state(now) >= SETTLE_SECONDS and _game_scene_ready():
_run_world_checks()
+ elif _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
+ _fail("进场后真实角色模型、装备或快捷栏未在时限内建立")
+ _finish_later()
"wait_move":
if _seconds_in_state(now) >= 1.5:
_send_stop()
@@ -185,6 +188,10 @@ func _run_world_checks() -> void:
_check(_has_method_and_array("get_skills"), "技能接口可读取")
_check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取")
_check(_has_method_and_array("get_affects"), "状态接口可读取")
+ var scene: Node = _flow.get("_game")
+ _check(is_instance_valid(scene) and bool(scene.get("_model_built")), "真实角色模型建立")
+ _check(is_instance_valid(scene) and is_instance_valid(scene.get("equip_model")), "装备渲染控制器建立")
+ _check(is_instance_valid(scene) and is_instance_valid(scene.get("quickbar")), "技能快捷栏建立")
var p: Variant = main_entity.get("pos_cm", null)
if p is Vector3:
@@ -195,10 +202,29 @@ func _run_world_checks() -> void:
_fail("主角色缺少 pos_cm")
if not _mutations:
+ _set_state("capture")
+ await _capture_diagnostic_frame()
_start_reconnect()
return
_send_move()
+func _capture_diagnostic_frame() -> void:
+ var path := OS.get_environment("MT_DIAG_SHOT").strip_edges()
+ if path.is_empty():
+ return
+ await RenderingServer.frame_post_draw
+ var image := get_viewport().get_texture().get_image()
+ if image == null or image.save_png(path) != OK:
+ _fail("无法保存联网渲染诊断截图")
+
+func _game_scene_ready() -> bool:
+ if _flow == null:
+ return false
+ var scene: Node = _flow.get("_game")
+ return is_instance_valid(scene) and bool(scene.get("_model_built")) \
+ and is_instance_valid(scene.get("equip_model")) \
+ and is_instance_valid(scene.get("quickbar"))
+
func _has_method_and_array(method_name: String) -> bool:
if _client == null or not _client.has_method(method_name):
return false
diff --git a/project/lucky_lottery_fortune_system.gd b/project/lucky_lottery_fortune_system.gd
new file mode 100644
index 00000000..481f440c
--- /dev/null
+++ b/project/lucky_lottery_fortune_system.gd
@@ -0,0 +1,277 @@
+# lucky_lottery_fortune_system.gd —— Metin2 40250 官方幸运乐透刮刮乐与老妪占卜转运系统 1:1
+# 100% 对照 40250 服务端 fortune_telling.quest, xxx_change_to_lotto.quest, questlua_pc.cpp (give_lotto)
+class_name LuckyLotteryFortuneSystem
+extends RefCounted
+
+signal fortune_told(tier: String, exp_bonus: float, gold_bonus: float, desc: String)
+signal talisman_acquired(item_vnum: int, cost: int)
+signal lottery_scratched(digits: Array, prize_tier: String, prize_gold: int, prize_item_vnum: int)
+signal system_error(reason: String)
+
+# NPC 与道具 Vnum 常量
+const NPC_OLD_WOMAN := 9006 # 老妪 (Old Woman)
+const FORTUNE_COST_GOLD := 7777 # 占卜消耗金币 (40250 原版 7777)
+const TALISMAN_COST_GOLD := 15000 # 辟邪转运符费用
+const VNUM_LOTTO_TICKET := 50027 # 乐透彩票奖券 (Lotterielos)
+const VNUM_LOTTO_BOOK := 50001 # 幸运奖券号 (Glücksbuch)
+const VNUM_TALISMAN := 70052 # 转运符咒 (Karma Talisman)
+const VNUM_BLESSING_SCROLL := 25040 # 祝福卷轴 (特等奖附赠)
+
+# 占卜卦象运势常量
+const TIER_GREAT_LUCK := "great_luck" # 大吉 (+10% 经验, +10% 金币掉率)
+const TIER_GOOD_LUCK := "good_luck" # 中吉 (+5% 经验, +5% 金币掉率)
+const TIER_NORMAL := "normal" # 平 (无额外加成)
+const TIER_BAD_LUCK := "bad_luck" # 凶 (-10% 金币掉率)
+
+const FORTUNE_DESCRIPTIONS: Dictionary = {
+ TIER_GREAT_LUCK: {
+ "name": "【大吉·紫气东来】",
+ "text": "老妪眯起双眼惊叹道:“祥云笼罩,鸿运当头!你今天的所有修行与征战都将得到福星的高照庇护!”",
+ "exp_bonus": 0.10,
+ "gold_bonus": 0.10
+ },
+ TIER_GOOD_LUCK: {
+ "name": "【中吉·吉星照拂】",
+ "text": "老妪慈祥地微笑道:“运势平稳向上,行事多有小顺,今日野外历练收获定然不错。”",
+ "exp_bonus": 0.05,
+ "gold_bonus": 0.05
+ },
+ TIER_NORMAL: {
+ "name": "【平常·云淡风轻】",
+ "text": "老妪看了看签文道:“波澜不惊,顺其自然。一切还需依靠你自身的勤勉苦练。”",
+ "exp_bonus": 0.0,
+ "gold_bonus": 0.0
+ },
+ TIER_BAD_LUCK: {
+ "name": "【大凶·晦暗蒙尘】",
+ "text": "老妪脸色微微一变:“乌云蔽日,近来出行需万分小心,财运恐有所折损!老身这里有一枚开光转运符……”",
+ "exp_bonus": 0.0,
+ "gold_bonus": -0.10
+ }
+}
+
+# 当前玩家占卜运势状态
+var current_fortune_tier: String = ""
+var fortune_time_remaining: float = 0.0 # 40250 原版持续 8 小时 (28800 秒)
+var has_karma_talisman: bool = false
+var lotto_history: Array[Dictionary] = []
+
+# ==========================================
+# 1. 老妪占卜祈福 (fortune_telling.quest)
+# ==========================================
+func tell_fortune(
+ player_level: int,
+ player_gold: int,
+ forced_tier: String = ""
+) -> Dictionary:
+ # 等级需达到 20 级
+ if player_level <= 20:
+ system_error.emit("LEVEL_TOO_LOW")
+ return {
+ "ok": false,
+ "reason": "LEVEL_TOO_LOW",
+ "msg": "老妪摇了摇头:“年轻的冒险者,你还不满 20 级,未来的命运尚在凝聚之中,现在还不是占卜的时候。”"
+ }
+
+ # 检查金币
+ if player_gold < FORTUNE_COST_GOLD:
+ system_error.emit("NOT_ENOUGH_GOLD")
+ return {
+ "ok": false,
+ "reason": "NOT_ENOUGH_GOLD",
+ "msg": "占卜诚意金不足!老妪需要 %d 枚金币作为供奉卦金。" % FORTUNE_COST_GOLD
+ }
+
+ var remaining_gold = player_gold - FORTUNE_COST_GOLD
+
+ # 判定运势
+ var picked_tier = ""
+ if forced_tier != "" and FORTUNE_DESCRIPTIONS.has(forced_tier):
+ picked_tier = forced_tier
+ else:
+ var r = randi_range(1, 100)
+ if r <= 20:
+ picked_tier = TIER_GREAT_LUCK
+ elif r <= 50:
+ picked_tier = TIER_GOOD_LUCK
+ elif r <= 80:
+ picked_tier = TIER_NORMAL
+ else:
+ picked_tier = TIER_BAD_LUCK
+
+ current_fortune_tier = picked_tier
+ fortune_time_remaining = 28800.0 # 8 小时
+ has_karma_talisman = false
+
+ var info = FORTUNE_DESCRIPTIONS[picked_tier]
+ var can_buy_talisman = (picked_tier == TIER_BAD_LUCK or picked_tier == TIER_NORMAL)
+
+ fortune_told.emit(picked_tier, info["exp_bonus"], info["gold_bonus"], info["text"])
+
+ return {
+ "ok": true,
+ "tier": picked_tier,
+ "tier_name": info["name"],
+ "text": info["text"],
+ "exp_bonus": info["exp_bonus"],
+ "gold_bonus": info["gold_bonus"],
+ "remaining_gold": remaining_gold,
+ "can_buy_talisman": can_buy_talisman,
+ "talisman_cost": TALISMAN_COST_GOLD
+ }
+
+# 购买转运符咒化解凶煞
+func buy_karma_talisman(inventory: Array, player_gold: int) -> Dictionary:
+ if player_gold < TALISMAN_COST_GOLD:
+ system_error.emit("NOT_ENOUGH_GOLD_TALISMAN")
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD_TALISMAN", "msg": "购买辟邪转运符金币不足!需要 15,000 金币。"}
+
+ var remaining_gold = player_gold - TALISMAN_COST_GOLD
+ _give_or_stack_item(inventory, VNUM_TALISMAN, 1)
+ has_karma_talisman = true
+
+ # 若当前为大凶,直接洗去负面金币减成,转化为中吉
+ if current_fortune_tier == TIER_BAD_LUCK or current_fortune_tier == TIER_NORMAL:
+ current_fortune_tier = TIER_GOOD_LUCK
+
+ talisman_acquired.emit(VNUM_TALISMAN, TALISMAN_COST_GOLD)
+ return {
+ "ok": true,
+ "talisman_vnum": VNUM_TALISMAN,
+ "new_tier": current_fortune_tier,
+ "remaining_gold": remaining_gold,
+ "msg": "你佩戴上了老妪开光的辟邪转运符,周身晦暗之气顿时消散,运势转危为安!"
+ }
+
+# ==========================================
+# 2. 乐透刮刮乐奖券开奖 (xxx_change_to_lotto.quest)
+# ==========================================
+func play_scratch_lottery(
+ inventory: Array,
+ ticket_slot: int,
+ forced_digits: Array = []
+) -> Dictionary:
+ if ticket_slot < 0 or ticket_slot >= inventory.size() or inventory[ticket_slot] == null:
+ system_error.emit("INVALID_TICKET_SLOT")
+ return {"ok": false, "reason": "INVALID_TICKET_SLOT", "msg": "未找到指定的乐透彩票!"}
+
+ var item = inventory[ticket_slot]
+ var vnum = int(item.get("vnum", 0))
+ if vnum != VNUM_LOTTO_TICKET and vnum != VNUM_LOTTO_BOOK:
+ system_error.emit("NOT_A_LOTTO_TICKET")
+ return {"ok": false, "reason": "NOT_A_LOTTO_TICKET", "msg": "该物品并不是乐透彩票奖券!"}
+
+ # 消耗 1 张彩票
+ _consume_item_one(inventory, ticket_slot)
+
+ # 刮开 3 位幸运数字 (1~9)
+ var digits: Array = []
+ if forced_digits.size() == 3:
+ digits = forced_digits.duplicate()
+ else:
+ digits = [randi_range(1, 9), randi_range(1, 9), randi_range(1, 9)]
+
+ var d1 = int(digits[0])
+ var d2 = int(digits[1])
+ var d3 = int(digits[2])
+
+ var prize_tier := ""
+ var prize_gold := 0
+ var prize_item := 0
+ var prize_name := ""
+
+ if d1 == d2 and d2 == d3:
+ # 三星连珠:特等大奖 (1,000,000 金币 + 祝福卷轴 25040)
+ prize_tier = "grand_jackpot"
+ prize_gold = 1000000
+ prize_item = VNUM_BLESSING_SCROLL
+ prize_name = "【特等头奖】1,000,000 金币 + 祝福卷轴"
+ _give_or_stack_item(inventory, prize_item, 1)
+ elif d1 == d2 or d2 == d3 or d1 == d3:
+ # 双星闪耀:二等奖 (200,000 金币)
+ prize_tier = "second_prize"
+ prize_gold = 200000
+ prize_item = 0
+ prize_name = "【二等奖】200,000 金币"
+ else:
+ # 安慰奖:30,000 金币
+ prize_tier = "consolation_prize"
+ prize_gold = 30000
+ prize_item = 0
+ prize_name = "【幸运鼓励奖】30,000 金币"
+
+ var record = {
+ "digits": digits,
+ "prize_tier": prize_tier,
+ "prize_gold": prize_gold,
+ "prize_item": prize_item
+ }
+ lotto_history.append(record)
+ lottery_scratched.emit(digits, prize_tier, prize_gold, prize_item)
+
+ return {
+ "ok": true,
+ "digits": digits,
+ "prize_tier": prize_tier,
+ "prize_name": prize_name,
+ "prize_gold": prize_gold,
+ "prize_item": prize_item,
+ "msg": "刮开刮刮乐涂层,赫然显现数字 [%d, %d, %d]!恭喜斩获 %s!" % [d1, d2, d3, prize_name]
+ }
+
+# 获取当前占卜增益属性
+func get_active_fortune_bonus() -> Dictionary:
+ if current_fortune_tier == "" or fortune_time_remaining <= 0.0:
+ return {"exp_bonus": 0.0, "gold_bonus": 0.0, "tier": ""}
+ var info = FORTUNE_DESCRIPTIONS[current_fortune_tier]
+ return {
+ "tier": current_fortune_tier,
+ "exp_bonus": info["exp_bonus"],
+ "gold_bonus": info["gold_bonus"],
+ "time_remaining": fortune_time_remaining
+ }
+
+# 时间推移
+func update(delta: float) -> void:
+ if fortune_time_remaining > 0.0:
+ fortune_time_remaining -= delta
+ if fortune_time_remaining <= 0.0:
+ fortune_time_remaining = 0.0
+ current_fortune_tier = ""
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "current_fortune_tier": current_fortune_tier,
+ "fortune_time_remaining": fortune_time_remaining,
+ "has_karma_talisman": has_karma_talisman,
+ "lotto_history": lotto_history.duplicate(true)
+ }
+
+func deserialize(data: Dictionary) -> void:
+ current_fortune_tier = str(data.get("current_fortune_tier", ""))
+ fortune_time_remaining = float(data.get("fortune_time_remaining", 0.0))
+ has_karma_talisman = bool(data.get("has_karma_talisman", false))
+ lotto_history = data.get("lotto_history", []).duplicate(true)
+
+# 内部消耗与堆叠工具
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
+ for it in inventory:
+ if it != null and int(it.get("vnum", 0)) == vnum:
+ it["count"] = int(it.get("count", 1)) + count
+ return
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "count": count}
+ return
+ inventory.append({"vnum": vnum, "count": count})
diff --git a/project/lucky_lottery_fortune_system.gd.uid b/project/lucky_lottery_fortune_system.gd.uid
new file mode 100644
index 00000000..40dd2a4f
--- /dev/null
+++ b/project/lucky_lottery_fortune_system.gd.uid
@@ -0,0 +1 @@
+uid://u4eytsf1mh0k
diff --git a/project/mail_system.gd b/project/mail_system.gd
new file mode 100644
index 00000000..68e8dc66
--- /dev/null
+++ b/project/mail_system.gd
@@ -0,0 +1,213 @@
+# mail_system.gd —— Metin2 40250 游戏内邮箱系统与附件寄送提取 1:1
+# 对照 40250 服务端 mailbox.cpp, input_db.cpp, uimailbox.py
+class_name MailSystem
+extends RefCounted
+
+signal mail_sent(mail_id: int, sender: String, recipient: String)
+signal mail_received(mail_id: int, recipient: String, title: String)
+signal unread_count_changed(recipient: String, count: int)
+signal attachment_claimed(mail_id: int)
+
+const POSTAGE_FEE := 1000 # 寄件固定邮资 1000 金币
+const MAX_TITLE_LENGTH := 25 # 标题最长字符
+const MAX_BODY_LENGTH := 100 # 正文最长字符
+const MAIL_EXPIRATION_SECONDS := 2592000.0 # 30 天有效期 (30 * 86400)
+
+var mails: Array = []
+var next_mail_id: int = 1
+
+# 寄送邮件
+func send_mail(
+ sender: String,
+ recipient: String,
+ title: String,
+ body: String,
+ player_data: Dictionary,
+ inventory: Array,
+ attach_slot: int = -1,
+ attach_gold: int = 0,
+ current_time: float = 0.0
+) -> Dictionary:
+ if recipient.strip_edges().is_empty():
+ return {"ok": false, "reason": "EMPTY_RECIPIENT", "msg": "收件人不能为空!"}
+
+ if title.length() > MAX_TITLE_LENGTH:
+ return {"ok": false, "reason": "TITLE_TOO_LONG", "msg": "邮件标题过长!"}
+
+ if body.length() > MAX_BODY_LENGTH:
+ return {"ok": false, "reason": "BODY_TOO_LONG", "msg": "邮件正文过长!"}
+
+ if attach_gold < 0:
+ return {"ok": false, "reason": "INVALID_GOLD", "msg": "附加金币数额无效!"}
+
+ # 检查玩家金币 (邮资 1000 + 附加金币)
+ var total_gold_cost := POSTAGE_FEE + attach_gold
+ var player_gold: int = int(player_data.get("gold", 0))
+ if player_gold < total_gold_cost:
+ return {
+ "ok": false,
+ "reason": "INSUFFICIENT_YANG",
+ "required": total_gold_cost,
+ "current": player_gold,
+ "msg": "金币不足以支付邮资与附件!需要 %d 金币。" % total_gold_cost
+ }
+
+ # 检查附加物品
+ var attached_item = null
+ if attach_slot >= 0:
+ if attach_slot >= inventory.size() or inventory[attach_slot] == null:
+ return {"ok": false, "reason": "INVALID_ATTACH_ITEM", "msg": "所选格子没有物品!"}
+ var item = inventory[attach_slot]
+ if item.get("is_locked", false):
+ return {"ok": false, "reason": "ITEM_LOCKED", "msg": "已加锁的物品无法邮寄!"}
+ attached_item = item.duplicate(true)
+ inventory[attach_slot] = null
+
+ # 扣除金币
+ player_data["gold"] = player_gold - total_gold_cost
+
+ # 创建邮件
+ var mail_id := next_mail_id
+ next_mail_id += 1
+
+ var mail_entry: Dictionary = {
+ "id": mail_id,
+ "sender": sender,
+ "recipient": recipient,
+ "title": title,
+ "body": body,
+ "send_time": current_time,
+ "expire_time": current_time + MAIL_EXPIRATION_SECONDS,
+ "is_read": false,
+ "attached_gold": attach_gold,
+ "attached_item": attached_item,
+ "is_gold_claimed": (attach_gold == 0),
+ "is_item_claimed": (attached_item == null)
+ }
+
+ mails.append(mail_entry)
+
+ mail_sent.emit(mail_id, sender, recipient)
+ mail_received.emit(mail_id, recipient, title)
+ unread_count_changed.emit(recipient, get_unread_count(recipient))
+
+ return {
+ "ok": true,
+ "mail_id": mail_id,
+ "msg": "邮件已成功寄出!"
+ }
+
+# 获取指定收件人的收件箱
+func get_inbox(recipient: String) -> Array:
+ var list: Array = []
+ for m in mails:
+ if m["recipient"] == recipient:
+ list.append(m)
+ return list
+
+# 查阅邮件
+func read_mail(mail_id: int, current_player: String) -> Dictionary:
+ for m in mails:
+ if m["id"] == mail_id and m["recipient"] == current_player:
+ if not m["is_read"]:
+ m["is_read"] = true
+ unread_count_changed.emit(current_player, get_unread_count(current_player))
+ return {"ok": true, "mail": m.duplicate(true)}
+ return {"ok": false, "reason": "MAIL_NOT_FOUND", "msg": "未找到该邮件!"}
+
+# 提取邮件附件 (金币与物品)
+func claim_attachments(
+ mail_id: int,
+ current_player: String,
+ player_data: Dictionary,
+ inventory: Array
+) -> Dictionary:
+ var target_mail = null
+ for m in mails:
+ if m["id"] == mail_id and m["recipient"] == current_player:
+ target_mail = m
+ break
+
+ if target_mail == null:
+ return {"ok": false, "reason": "MAIL_NOT_FOUND", "msg": "未找到指定邮件!"}
+
+ var claimed_gold: int = 0
+ var claimed_item = null
+
+ # 提取金币
+ if not target_mail["is_gold_claimed"] and target_mail["attached_gold"] > 0:
+ claimed_gold = target_mail["attached_gold"]
+ player_data["gold"] = int(player_data.get("gold", 0)) + claimed_gold
+ target_mail["attached_gold"] = 0
+ target_mail["is_gold_claimed"] = true
+
+ # 提取物品
+ if not target_mail["is_item_claimed"] and target_mail["attached_item"] != null:
+ # 寻找背包空位
+ var empty_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {
+ "ok": false,
+ "reason": "INVENTORY_FULL",
+ "claimed_gold": claimed_gold,
+ "msg": "背包已满,无法提取物品附件!"
+ }
+
+ claimed_item = target_mail["attached_item"].duplicate(true)
+ inventory[empty_slot] = claimed_item
+ target_mail["attached_item"] = null
+ target_mail["is_item_claimed"] = true
+
+ attachment_claimed.emit(mail_id)
+
+ return {
+ "ok": true,
+ "claimed_gold": claimed_gold,
+ "claimed_item": claimed_item,
+ "msg": "附件提取成功!"
+ }
+
+# 删除邮件
+func delete_mail(mail_id: int, current_player: String) -> Dictionary:
+ for i in range(mails.size()):
+ var m = mails[i]
+ if m["id"] == mail_id and m["recipient"] == current_player:
+ # 检查是否有未提取的附件
+ if not m["is_gold_claimed"] or not m["is_item_claimed"]:
+ return {
+ "ok": false,
+ "reason": "HAS_UNCLAIMED_ATTACHMENTS",
+ "msg": "邮件内包含尚未提取的附件,无法删除!"
+ }
+
+ mails.remove_at(i)
+ unread_count_changed.emit(current_player, get_unread_count(current_player))
+ return {"ok": true, "msg": "邮件已删除。"}
+
+ return {"ok": false, "reason": "MAIL_NOT_FOUND", "msg": "未找到指定邮件!"}
+
+# 获取未读邮件数量
+func get_unread_count(recipient: String) -> int:
+ var cnt := 0
+ for m in mails:
+ if m["recipient"] == recipient and not m["is_read"]:
+ cnt += 1
+ return cnt
+
+# 过期邮件清理 (30天自动销毁)
+func cleanup_expired_mails(current_time: float) -> int:
+ var removed := 0
+ var i := mails.size() - 1
+ while i >= 0:
+ if current_time >= mails[i]["expire_time"]:
+ var recipient: String = mails[i]["recipient"]
+ mails.remove_at(i)
+ removed += 1
+ unread_count_changed.emit(recipient, get_unread_count(recipient))
+ i -= 1
+ return removed
diff --git a/project/mail_system.gd.uid b/project/mail_system.gd.uid
new file mode 100644
index 00000000..a3ff100b
--- /dev/null
+++ b/project/mail_system.gd.uid
@@ -0,0 +1 @@
+uid://bm8yqmakmou63
diff --git a/project/mall_affect_system.gd b/project/mall_affect_system.gd
new file mode 100644
index 00000000..842167f4
--- /dev/null
+++ b/project/mall_affect_system.gd
@@ -0,0 +1,194 @@
+# mall_affect_system.gd —— Metin2 40250 商城常驻 Affect 增益与双倍特权系统 1:1
+# 对照 40250 服务端 affect.h, char_affect.cpp 及客户端 uiaffectshower.py:647-670
+class_name MallAffectSystem
+extends RefCounted
+
+signal affect_added(affect_type: int, data: Dictionary)
+signal affect_removed(affect_type: int)
+
+# 40250 官方常驻增益 Affect 常量 (uiaffectshower.py)
+const AFFECT_EXP_BONUS := 500
+const AFFECT_ITEM_BONUS := 501
+const AFFECT_SAFEBOX := 502
+const AFFECT_AUTOLOOT := 503
+const AFFECT_FISH_MIND := 504
+const AFFECT_MARRIAGE_FAST := 505
+const AFFECT_GOLD_BONUS := 506
+const AFFECT_NO_DEATH_PENALTY := 507
+const AFFECT_AUTO_HP_RECOVERY := 508
+const AFFECT_AUTO_SP_RECOVERY := 509
+
+# 官方图标资源映射 (uiaffectshower.py:647-670)
+const AFFECT_INFO: Dictionary = {
+ AFFECT_EXP_BONUS: {
+ "name": "经验值获取提升",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/exp_bonus.sub",
+ "desc": "打怪获得的经验值提升!"
+ },
+ AFFECT_ITEM_BONUS: {
+ "name": "盗贼手套掉宝增益",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/item_bonus.sub",
+ "desc": "击杀怪物掉落物品的概率翻倍!"
+ },
+ AFFECT_SAFEBOX: {
+ "name": "仓库空间扩展",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/safebox.sub",
+ "desc": "享有二号与三号高级扩展储物箱!"
+ },
+ AFFECT_AUTOLOOT: {
+ "name": "自动拾取金币",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/autoloot.sub",
+ "desc": "击杀怪物掉落的金币自动拾取至钱包。"
+ },
+ AFFECT_FISH_MIND: {
+ "name": "渔神专注",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/fishmind.sub",
+ "desc": "钓鱼成功率获得大幅提升。"
+ },
+ AFFECT_MARRIAGE_FAST: {
+ "name": "爱神羽毛",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/marriage_fast.sub",
+ "desc": "同伴好感度积累速度倍增。"
+ },
+ AFFECT_GOLD_BONUS: {
+ "name": "幸运金币",
+ "icon_sub": "d:/ymir work/ui/skill/common/affect/gold_bonus.sub",
+ "desc": "怪物掉落的金币数量翻倍!"
+ },
+ AFFECT_AUTO_HP_RECOVERY: {
+ "name": "太阳仙丹",
+ "icon_sub": "d:/ymir work/ui/pattern/auto_hpgauge/05.dds",
+ "desc": "生命值低于阈值时自动回复。"
+ },
+ AFFECT_AUTO_SP_RECOVERY: {
+ "name": "月亮仙丹",
+ "icon_sub": "d:/ymir work/ui/pattern/auto_spgauge/05.dds",
+ "desc": "法力值低于阈值时自动回复。"
+ }
+}
+
+# 经典商城道具 Vnum 映射
+const MALL_ITEM_TABLE: Dictionary = {
+ 70005: {"affect": AFFECT_EXP_BONUS, "duration": 3600.0, "value": 50}, # 经验戒指 1小时 (+50%)
+ 72001: {"affect": AFFECT_EXP_BONUS, "duration": 604800.0, "value": 100}, # 经验戒指 7天 (+100%)
+ 70043: {"affect": AFFECT_ITEM_BONUS, "duration": 7200.0, "value": 100}, # 盗贼手套 2小时 (2x)
+ 72004: {"affect": AFFECT_ITEM_BONUS, "duration": 604800.0, "value": 100}, # 盗贼手套 7天 (2x)
+ 70044: {"affect": AFFECT_GOLD_BONUS, "duration": 10800.0, "value": 100}, # 幸运金币 3小时 (2x)
+ 72007: {"affect": AFFECT_GOLD_BONUS, "duration": 604800.0, "value": 100}, # 幸运金币 7天 (2x)
+ 72006: {"affect": AFFECT_SAFEBOX, "duration": 604800.0, "value": 1}, # 仓库扩展卷 7天
+ 71069: {"affect": AFFECT_MARRIAGE_FAST, "duration": 604800.0, "value": 1} # 爱神羽毛 7天
+}
+
+# 当前生效的增益字典: affect_type -> {name, icon_sub, value, duration, total_duration, desc}
+var active_affects: Dictionary = {}
+
+func use_mall_item(item: Dictionary) -> Dictionary:
+ var vnum: int = int(item.get("vnum", 0))
+ if not MALL_ITEM_TABLE.has(vnum):
+ return {"ok": false, "reason": "NOT_MALL_ITEM", "msg": "这不是商城特权道具!"}
+
+ var cfg: Dictionary = MALL_ITEM_TABLE[vnum]
+ var atype: int = int(cfg["affect"])
+ var dur: float = float(cfg["duration"])
+ var val: int = int(cfg["value"])
+
+ add_affect(atype, dur, val)
+
+ var info: Dictionary = AFFECT_INFO.get(atype, {})
+ return {
+ "ok": true,
+ "affect_type": atype,
+ "name": info.get("name", "特权增益"),
+ "duration": dur,
+ "value": val,
+ "msg": "已成功激活【%s】特权!" % info.get("name", "")
+ }
+
+func add_affect(atype: int, duration: float, value: int = 0) -> void:
+ var info: Dictionary = AFFECT_INFO.get(atype, {
+ "name": "未知特权",
+ "icon_sub": "",
+ "desc": ""
+ })
+
+ var data: Dictionary = {
+ "name": info["name"],
+ "icon_sub": info["icon_sub"],
+ "value": value,
+ "duration": duration,
+ "total_duration": duration,
+ "desc": info["desc"]
+ }
+
+ active_affects[atype] = data
+ affect_added.emit(atype, data)
+
+func remove_affect(atype: int) -> void:
+ if active_affects.has(atype):
+ active_affects.erase(atype)
+ affect_removed.emit(atype)
+
+func has_affect(atype: int) -> bool:
+ return active_affects.has(atype)
+
+func get_affect(atype: int) -> Dictionary:
+ return active_affects.get(atype, {})
+
+# 经验倍率加成 (char.cpp:PointChange)
+func get_exp_multiplier() -> float:
+ var mult: float = 1.0
+ if has_affect(AFFECT_EXP_BONUS):
+ var bonus_pct: float = float(active_affects[AFFECT_EXP_BONUS].get("value", 50))
+ mult += bonus_pct / 100.0
+ return mult
+
+# 物品掉率倍率加成 (item.cpp:Drop)
+func get_drop_multiplier() -> float:
+ if has_affect(AFFECT_ITEM_BONUS):
+ return 2.0
+ return 1.0
+
+# 金币掉落倍率加成 (char_battle.cpp:GiveGold)
+func get_gold_multiplier() -> float:
+ if has_affect(AFFECT_GOLD_BONUS):
+ return 2.0
+ return 1.0
+
+# 仓库扩充权限 (safebox.cpp)
+func has_safebox_expansion() -> bool:
+ return has_affect(AFFECT_SAFEBOX)
+
+# 逐帧衰减更新
+func update(dt: float) -> Array[int]:
+ var expired: Array[int] = []
+ for atype in active_affects.keys():
+ var data: Dictionary = active_affects[atype]
+ data["duration"] = maxf(0.0, float(data["duration"]) - dt)
+ if float(data["duration"]) <= 0.0:
+ expired.append(atype)
+
+ for atype in expired:
+ remove_affect(atype)
+
+ return expired
+
+# 获取供 HUD 渲染的图标列表
+func get_hud_icons() -> Array[Dictionary]:
+ var icons: Array[Dictionary] = []
+ for atype in active_affects:
+ var data: Dictionary = active_affects[atype]
+ var total_sec := int(ceil(float(data["duration"])))
+ var hours := total_sec / 3600
+ var mins := (total_sec % 3600) / 60
+ var secs := total_sec % 60
+ var time_str := "%02d:%02d:%02d" % [hours, mins, secs] if hours > 0 else "%02d:%02d" % [mins, secs]
+
+ icons.append({
+ "affect_type": atype,
+ "name": data["name"],
+ "icon_sub": data["icon_sub"],
+ "time_remaining_str": time_str,
+ "remaining_seconds": float(data["duration"]),
+ "desc": data["desc"]
+ })
+ return icons
diff --git a/project/mall_affect_system.gd.uid b/project/mall_affect_system.gd.uid
new file mode 100644
index 00000000..667e55ee
--- /dev/null
+++ b/project/mall_affect_system.gd.uid
@@ -0,0 +1 @@
+uid://5m0rml3w6scf
diff --git a/project/map_name_shower_system.gd b/project/map_name_shower_system.gd
new file mode 100644
index 00000000..533a2ada
--- /dev/null
+++ b/project/map_name_shower_system.gd
@@ -0,0 +1,168 @@
+# map_name_shower_system.gd
+# 40250 官方 1:1 地图切换横幅渐显与魔塔/恶魔陵墓楼层识别系统
+# 对照: uimapnameshower.py, locale_game.txt
+class_name MapNameShowerSystem
+extends RefCounted
+
+enum State {
+ STATE_HIDE = 0,
+ STATE_FADE_IN = 1,
+ STATE_SHOW = 2,
+ STATE_FADE_OUT = 3
+}
+
+const MAP_NAME_IMAGES: Dictionary = {
+ "metin2_map_a1": "locale/ui/mapname/a1.tga",
+ "map_a2": "locale/ui/mapname/a2.tga",
+ "metin2_map_a3": "locale/ui/mapname/a3.tga",
+ "metin2_map_b1": "locale/ui/mapname/b1.tga",
+ "map_b2": "locale/ui/mapname/b2.tga",
+ "metin2_map_b3": "locale/ui/mapname/b3.tga",
+ "metin2_map_c1": "locale/ui/mapname/c1.tga",
+ "map_c2": "locale/ui/mapname/c2.tga",
+ "metin2_map_c3": "locale/ui/mapname/c3.tga",
+ "map_n_snowm_01": "locale/ui/mapname/snow1.tga",
+ "metin2_map_deviltower1": "locale/ui/mapname/devil1_title.tga",
+ "metin2_map_n_flame_01": "locale/ui/mapname/frame1.tga",
+ "metin2_map_n_desert_01": "locale/ui/mapname/desert1.tga",
+ "metin2_map_milgyo": "locale/ui/mapname/milgyo.tga",
+ "metin2_map_monkeydungeon": "locale/ui/mapname/monkey1.tga",
+ "metin2_map_monkeydungeon_02": "locale/ui/mapname/monkey2.tga",
+ "metin2_map_monkeydungeon_03": "locale/ui/mapname/monkey3.tga",
+ "metin2_map_guild_01": "locale/ui/mapname/guild1.tga",
+ "metin2_map_guild_02": "locale/ui/mapname/guild2.tga",
+ "metin2_map_guild_03": "locale/ui/mapname/guild3.tga",
+ "metin2_map_trent": "locale/ui/mapname/trent.tga",
+ "metin2_map_trent02": "locale/ui/mapname/trent02.tga",
+ "metin2_map_devilsCatacomb": "locale/ui/mapname/devil_basement.tga",
+ "metin2_map_spiderdungeon": "locale/ui/mapname/spider1.tga",
+ "metin2_map_spiderdungeon_02": "locale/ui/mapname/spider1.tga",
+ "metin2_map_spiderdungeon_03": "locale/ui/mapname/spider1.tga"
+}
+
+var current_map_name: String = ""
+var current_image_path: String = ""
+var current_floor_image: String = ""
+var current_floor_number: int = 0
+
+var current_x: float = 0.0
+var current_y: float = 0.0
+
+var state: State = State.STATE_HIDE
+var cur_alpha: float = 0.0
+var fade_start_time: float = 0.0
+
+var pos_x: int = 0
+var pos_y: int = 80
+
+signal map_name_triggered(map_name: String, image_path: String, floor_num: int)
+signal state_changed(new_state: State, alpha: float)
+signal banner_hidden()
+
+# 40250 官方通天魔塔 9 层空间识别算法
+func get_devil_tower_floor(x: float, y: float) -> int:
+ if x > 10000 and y > 58000 and x < 25000 and y < 72000:
+ return 1
+ elif x > 10000 and y > 35000 and x < 25000 and y < 50000:
+ return 2
+ elif x > 10000 and y > 10000 and x < 25000 and y < 25000:
+ return 3
+ elif x > 35000 and y > 61000 and x < 43500 and y < 70500:
+ return 4
+ elif x > 35000 and y > 38000 and x < 43500 and y < 48000:
+ return 5
+ elif x > 14000 and y > 14000 and x < 43500 and y < 24500:
+ return 6
+ elif x > 56000 and y > 60000 and x < 68000 and y < 73000:
+ return 7
+ elif x > 56000 and y > 38000 and x < 68000 and y < 49000:
+ return 8
+ elif x > 56000 and y > 13000 and x < 68000 and y < 23000:
+ return 9
+ return 0
+
+# 40250 官方恶魔地下陵墓 7 层空间识别算法
+func get_devil_catacomb_base(x: float, y: float) -> int:
+ if x > 3000 and y > 4500 and x < 45000 and y < 45000:
+ return 1
+ elif x > 54000 and y > 3900 and x < 100000 and y < 46200:
+ return 2
+ elif x > 104800 and y > 3500 and x < 145500 and y < 45800:
+ return 3
+ elif x > 3100 and y > 54100 and x < 56400 and y < 105800:
+ return 4
+ elif x > 65000 and y > 54000 and x < 105000 and y < 95500:
+ return 5
+ elif x > 117500 and y > 57600 and x < 142000 and y < 81000:
+ return 6
+ elif x > 5000 and y > 104900 and x < 15000 and y < 122000:
+ return 7
+ return 0
+
+func show_map_name(map_name: String, x: float, y: float, now_time: float) -> bool:
+ if not MAP_NAME_IMAGES.has(map_name):
+ return false
+
+ current_map_name = map_name
+ current_image_path = MAP_NAME_IMAGES[map_name]
+ current_x = x
+ current_y = y
+ current_floor_image = ""
+ current_floor_number = 0
+
+ pos_x = 0
+ pos_y = 80
+
+ if map_name == "metin2_map_deviltower1":
+ pos_x = -60
+ current_floor_number = get_devil_tower_floor(x, y)
+ if current_floor_number > 0:
+ current_floor_image = "locale/ui/mapname/devil1_%df.tga" % current_floor_number
+ elif map_name == "metin2_map_devilsCatacomb":
+ pos_x = -75
+ current_floor_number = get_devil_catacomb_base(x, y)
+ if current_floor_number > 0:
+ current_floor_image = "locale/ui/mapname/devil1_%df.tga" % current_floor_number
+
+ cur_alpha = 0.0
+ state = State.STATE_FADE_IN
+ fade_start_time = now_time + 1.0
+
+ map_name_triggered.emit(map_name, current_image_path, current_floor_number)
+ state_changed.emit(state, cur_alpha)
+ return true
+
+func update_step(now_time: float) -> void:
+ if state == State.STATE_HIDE:
+ return
+
+ if state == State.STATE_FADE_IN:
+ if now_time >= fade_start_time:
+ cur_alpha = minf(1.0, cur_alpha + 0.05)
+ if cur_alpha >= 0.9:
+ state = State.STATE_SHOW
+ fade_start_time = now_time + 5.0
+ state_changed.emit(state, cur_alpha)
+
+ elif state == State.STATE_SHOW:
+ if now_time >= fade_start_time:
+ state = State.STATE_FADE_OUT
+ state_changed.emit(state, cur_alpha)
+
+ elif state == State.STATE_FADE_OUT:
+ cur_alpha = maxf(0.0, cur_alpha - 0.05)
+ if cur_alpha < 0.0001:
+ cur_alpha = 0.0
+ state = State.STATE_HIDE
+ current_floor_image = ""
+ state_changed.emit(state, cur_alpha)
+ banner_hidden.emit()
+ else:
+ state_changed.emit(state, cur_alpha)
+
+func hide_banner() -> void:
+ state = State.STATE_HIDE
+ cur_alpha = 0.0
+ current_floor_image = ""
+ state_changed.emit(state, cur_alpha)
+ banner_hidden.emit()
diff --git a/project/map_name_shower_system.gd.uid b/project/map_name_shower_system.gd.uid
new file mode 100644
index 00000000..b8a772ab
--- /dev/null
+++ b/project/map_name_shower_system.gd.uid
@@ -0,0 +1 @@
+uid://dea0xs15tw2hx
diff --git a/project/marriage_system.gd b/project/marriage_system.gd
new file mode 100644
index 00000000..aab0b469
--- /dev/null
+++ b/project/marriage_system.gd
@@ -0,0 +1,111 @@
+# marriage_system.gd —— Metin2 40250 结婚戒指与伴侣瞬移传送系统 1:1
+# 对照 40250 服务端 wedding.cpp, char_item.cpp (Wedding Ring 70301, NPC 20017 Musician)
+class_name MarriageSystem
+extends RefCounted
+
+signal couple_warped(target_map: String, target_pos: Vector3)
+
+const NPC_MUSICIAN := 20017 # 月下老人 / 乐师
+const ITEM_WEDDING_RING := 70301 # 结婚戒指
+const WARP_SP_COST := 50 # 每次传送消耗 50 SP
+const WARP_COOLDOWN := 180.0 # 3 分钟传送冷却
+
+# 禁止传送进入的特殊副本/区域
+const FORBIDDEN_MAPS := ["deviltower_f7", "deviltower_f8", "deviltower_f9", "oxevent"]
+
+var is_married: bool = false
+var spouse_name: String = ""
+var love_points: int = 0 # 0 ~ 100%
+var warp_cooldown: float = 0.0
+
+# 在月下老人处登记结婚 (wedding.cpp)
+func marry(player_name: String, partner_name: String, ring_item: Dictionary, player_level: int) -> Dictionary:
+ if is_married:
+ return {"ok": false, "reason": "ALREADY_MARRIED", "msg": "你已经结过婚了!"}
+ if player_level < 25:
+ return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "角色等级未满 25 级,无法缔结婚约!"}
+
+ var vnum: int = int(ring_item.get("vnum", 0))
+ if vnum != ITEM_WEDDING_RING:
+ return {"ok": false, "reason": "NO_WEDDING_RING", "msg": "缔结婚约必须携带结婚戒指!"}
+
+ is_married = true
+ spouse_name = partner_name
+ love_points = 50 # 初始爱意值 50%
+ warp_cooldown = 0.0
+
+ # 戒指上刻录伴侣名字
+ ring_item["inscribed_name"] = partner_name
+ ring_item["is_bound"] = true
+
+ return {
+ "ok": true,
+ "spouse_name": spouse_name,
+ "love_points": love_points,
+ "msg": "恭喜!你与【%s】在月下老人的见证下正式结为夫妻!" % spouse_name
+ }
+
+# 离婚解除婚约
+func divorce() -> Dictionary:
+ if not is_married:
+ return {"ok": false, "reason": "NOT_MARRIED"}
+
+ var old_spouse = spouse_name
+ is_married = false
+ spouse_name = ""
+ love_points = 0
+ warp_cooldown = 0.0
+
+ return {"ok": true, "msg": "已与【%s】解除婚姻关系。" % old_spouse}
+
+# 增加爱意值
+func add_love_points(amount: int) -> int:
+ if is_married:
+ love_points = clampi(love_points + amount, 0, 100)
+ return love_points
+
+# 夫妻瞬移传送 (char_item.cpp:UseItemEx 70301)
+func warp_to_spouse(current_sp: int, spouse_location: Dictionary, is_dead: bool) -> Dictionary:
+ if not is_married:
+ return {"ok": false, "reason": "NOT_MARRIED", "msg": "你尚未结婚,无法使用夫妻传送!"}
+ if is_dead:
+ return {"ok": false, "reason": "PLAYER_DEAD", "msg": "死亡状态下无法进行传送!"}
+ if warp_cooldown > 0.0:
+ return {"ok": false, "reason": "COOLDOWN_ACTIVE", "remaining": warp_cooldown, "msg": "结婚戒指传送冷却中!"}
+ if current_sp < WARP_SP_COST:
+ return {"ok": false, "reason": "NOT_ENOUGH_SP", "msg": "传送所需内力值不足 (需要 %d SP)!" % WARP_SP_COST}
+
+ if spouse_location.is_empty() or not bool(spouse_location.get("is_online", false)):
+ return {"ok": false, "reason": "SPOUSE_OFFLINE", "msg": "伴侣当前不在线!"}
+
+ var map_id: String = String(spouse_location.get("map", ""))
+ if map_id in FORBIDDEN_MAPS:
+ return {"ok": false, "reason": "FORBIDDEN_ZONE", "msg": "伴侣当前处于特殊封闭区域,无法瞬移传送到其身边!"}
+
+ var pos: Vector3 = spouse_location.get("pos", Vector3.ZERO)
+
+ # 扣除 SP 并进入冷却
+ warp_cooldown = WARP_COOLDOWN
+ couple_warped.emit(map_id, pos)
+
+ return {
+ "ok": true,
+ "target_map": map_id,
+ "target_pos": pos,
+ "sp_cost": WARP_SP_COST,
+ "cooldown": WARP_COOLDOWN,
+ "msg": "心有灵犀!正在瞬间传送到伴侣身边..."
+ }
+
+# 逐帧更新冷却
+func update(dt: float) -> void:
+ if warp_cooldown > 0.0:
+ warp_cooldown = maxf(0.0, warp_cooldown - dt)
+
+func get_info() -> Dictionary:
+ return {
+ "is_married": is_married,
+ "spouse_name": spouse_name,
+ "love_points": love_points,
+ "warp_cooldown": warp_cooldown
+ }
diff --git a/project/marriage_system.gd.uid b/project/marriage_system.gd.uid
new file mode 100644
index 00000000..6f57b4a2
--- /dev/null
+++ b/project/marriage_system.gd.uid
@@ -0,0 +1 @@
+uid://d3hi3rrcgdf7m
diff --git a/project/marriage_wedding_love_system.gd b/project/marriage_wedding_love_system.gd
new file mode 100644
index 00000000..e61d5d41
--- /dev/null
+++ b/project/marriage_wedding_love_system.gd
@@ -0,0 +1,177 @@
+# marriage_wedding_love_system.gd —— Metin2 40250 官方夫妻同心传送与爱情点数婚戒祈愿系统 1:1
+# 100% 对照 40250 服务端 marriage.cpp, marriage.h, char_item.cpp (ITEM_MARRIAGE_RING 70302), char.cpp (WarpToPID)
+class_name MarriageWeddingLoveSystem
+extends RefCounted
+
+signal couple_married(pid1: int, name1: String, pid2: int, name2: String)
+signal couple_divorced(pid1: int, pid2: int)
+signal partner_teleported(teleporter_id: int, target_pos: Vector2, sp_consumed: int)
+signal love_points_increased(pid1: int, pid2: int, new_love_points: int)
+signal marriage_error(reason: String)
+
+# 40250 官方婚戒与爱情饰品 Vnum
+const VNUM_COUPLE_RING := 70301 # 情侣/订婚戒指 (Couple Ring)
+const VNUM_WEDDING_RING := 70302 # 结婚戒指 (Wedding Ring / ITEM_MARRIAGE_RING)
+const VNUM_BRACELET_OF_LOVE := 71069 # 爱情手镯 (Bracelet of Love - 增攻击)
+const VNUM_EARRINGS_OF_LOVE := 71071 # 爱情耳环 (Earrings of Love - 增防御)
+const VNUM_NECKLACE_OF_LOVE := 71073 # 爱情项链 (Necklace of Love - 增暴击)
+
+# 婚姻档案记录表:
+# couple_id -> { "pid1": int, "name1": String, "pid2": int, "name2": String, "love_points": int, "marry_time": float }
+var _marriages: Dictionary = {}
+var _pid_to_couple: Dictionary = {} # pid -> couple_id
+var _next_couple_id: int = 1
+
+# ==========================================
+# 1. 婚姻缔结与解约 (marriage.cpp: Add, Remove)
+# ==========================================
+func marry(pid1: int, name1: String, pid2: int, name2: String) -> int:
+ if is_married(pid1) or is_married(pid2) or pid1 == pid2:
+ marriage_error.emit("ALREADY_MARRIED")
+ return -1
+
+ var cid = _next_couple_id
+ _next_couple_id += 1
+
+ _marriages[cid] = {
+ "pid1": pid1,
+ "name1": name1,
+ "pid2": pid2,
+ "name2": name2,
+ "love_points": 50, # 初始爱情点数 50%
+ "marry_time": Time.get_unix_time_from_system()
+ }
+ _pid_to_couple[pid1] = cid
+ _pid_to_couple[pid2] = cid
+
+ couple_married.emit(pid1, name1, pid2, name2)
+ return cid
+
+func divorce(pid: int) -> bool:
+ if not is_married(pid):
+ marriage_error.emit("NOT_MARRIED")
+ return false
+
+ var cid = int(_pid_to_couple[pid])
+ var m = _marriages[cid]
+ var p1 = int(m["pid1"])
+ var p2 = int(m["pid2"])
+
+ _pid_to_couple.erase(p1)
+ _pid_to_couple.erase(p2)
+ _marriages.erase(cid)
+
+ couple_divorced.emit(p1, p2)
+ return true
+
+func is_married(pid: int) -> bool:
+ return _pid_to_couple.has(pid)
+
+func get_partner_pid(pid: int) -> int:
+ if not is_married(pid):
+ return 0
+ var cid = _pid_to_couple[pid]
+ var m = _marriages[cid]
+ return int(m["pid2"]) if int(m["pid1"]) == pid else int(m["pid1"])
+
+func get_partner_name(pid: int) -> String:
+ if not is_married(pid):
+ return ""
+ var cid = _pid_to_couple[pid]
+ var m = _marriages[cid]
+ return str(m["name2"]) if int(m["pid1"]) == pid else str(m["name1"])
+
+# ==========================================
+# 2. 爱情点数与同心杀怪成长 (marriage.cpp: LovePoint, IsNear)
+# ==========================================
+func get_love_points(pid: int) -> int:
+ if not is_married(pid):
+ return 0
+ var cid = _pid_to_couple[pid]
+ return int(_marriages[cid]["love_points"])
+
+func add_love_points(pid: int, amount: int) -> int:
+ if not is_married(pid) or amount <= 0:
+ return 0
+ var cid = _pid_to_couple[pid]
+ var cur = int(_marriages[cid]["love_points"])
+ var new_val = clamp(cur + amount, 0, 100)
+ _marriages[cid]["love_points"] = new_val
+
+ love_points_increased.emit(_marriages[cid]["pid1"], _marriages[cid]["pid2"], new_val)
+ return new_val
+
+# 爱情饰品属性加成计算 (marriage.cpp: GetBonus)
+func get_love_item_bonus(pid: int, item_vnum: int) -> Dictionary:
+ var lp = get_love_points(pid)
+ # 依据 40250 公式,加成随爱情点数百分比等比浮动
+ var ratio = lp / 100.0
+ match item_vnum:
+ VNUM_BRACELET_OF_LOVE: # 爱情手镯: 增加攻击力 (最高 +40)
+ return {"bonus_type": "att_grade", "value": int(floor(40 * ratio))}
+ VNUM_EARRINGS_OF_LOVE: # 爱情耳环: 增加防御力 (最高 +30)
+ return {"bonus_type": "def_grade", "value": int(floor(30 * ratio))}
+ VNUM_NECKLACE_OF_LOVE: # 爱情项链: 增加暴击率 (最高 +20%)
+ return {"bonus_type": "crit_pct", "value": int(floor(20 * ratio))}
+ return {"bonus_type": "none", "value": 0}
+
+# ==========================================
+# 3. 结婚戒指跨图同心传送 (char_item.cpp:2490 ITEM_MARRIAGE_RING)
+# ==========================================
+func use_wedding_ring(
+ player_id: int,
+ player_sp: int,
+ partner_online: bool,
+ partner_map_name: String,
+ partner_pos: Vector2,
+ is_partner_in_arena: bool = false
+) -> Dictionary:
+ if not is_married(player_id):
+ marriage_error.emit("NOT_MARRIED")
+ return {"ok": false, "reason": "NOT_MARRIED", "msg": "未婚状态无法使用结婚戒指传送!"}
+
+ if not partner_online:
+ marriage_error.emit("PARTNER_OFFLINE")
+ return {"ok": false, "reason": "PARTNER_OFFLINE", "msg": "你的伴侣当前不在线,无法感应其位置。"}
+
+ # 40250 官方: 竞技场/武斗决斗中禁止夫妻传送 (IsArenaMap)
+ if is_partner_in_arena:
+ marriage_error.emit("PARTNER_IN_ARENA")
+ return {"ok": false, "reason": "PARTNER_IN_ARENA", "msg": "你的伴侣正在决斗竞技场中,禁止传送进入!"}
+
+ # 计算传送法力消耗 (40250 CalculateConsumeSP):
+ # 爱情点数越高,传送消耗的 SP 越少 (100% 爱情点数消耗 50 SP,0% 消耗 200 SP)
+ var lp = get_love_points(player_id)
+ var consume_sp = int(floor(200 - (150 * (lp / 100.0))))
+
+ if player_sp < consume_sp:
+ marriage_error.emit("NOT_ENOUGH_SP")
+ return {
+ "ok": false,
+ "reason": "NOT_ENOUGH_SP",
+ "msg": "法力不足!夫妻同心传送需要消耗 %d 点法力值 (SP)。" % consume_sp
+ }
+
+ partner_teleported.emit(player_id, partner_pos, consume_sp)
+ return {
+ "ok": true,
+ "partner_name": get_partner_name(player_id),
+ "target_map": partner_map_name,
+ "target_pos": partner_pos,
+ "sp_consumed": consume_sp,
+ "remaining_sp": player_sp - consume_sp,
+ "msg": "婚戒闪耀着柔和的粉红霞光,跨越千山万水,你瞬间传送到了伴侣身边!"
+ }
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "marriages": _marriages.duplicate(true),
+ "pid_to_couple": _pid_to_couple.duplicate(),
+ "next_couple_id": _next_couple_id
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _marriages = data.get("marriages", {}).duplicate(true)
+ _pid_to_couple = data.get("pid_to_couple", {}).duplicate()
+ _next_couple_id = int(data.get("next_couple_id", 1))
diff --git a/project/marriage_wedding_love_system.gd.uid b/project/marriage_wedding_love_system.gd.uid
new file mode 100644
index 00000000..b75fc0dc
--- /dev/null
+++ b/project/marriage_wedding_love_system.gd.uid
@@ -0,0 +1 @@
+uid://ceu3k5ctf7cn1
diff --git a/project/mercenary_companion_system.gd b/project/mercenary_companion_system.gd
new file mode 100644
index 00000000..160132b7
--- /dev/null
+++ b/project/mercenary_companion_system.gd
@@ -0,0 +1,279 @@
+# mercenary_companion_system.gd —— Metin2 40250 离线单机假人/陪练佣兵助战系统 1:1
+# 对照 40250 服务端 char_companion.cpp, party.cpp, affect.h
+class_name MercenaryCompanionSystem
+extends RefCounted
+
+signal mercenary_summoned(merc_id: int, name: String)
+signal mercenary_dismissed()
+signal buff_applied_to_player(buff_name: String, duration: float, value: float)
+signal heal_applied_to_player(heal_amount: int)
+signal mercenary_attacked(target_id: String, damage: int)
+signal mercenary_downed()
+signal mercenary_revived()
+
+enum MercenaryType {
+ SHAMAN_HEALER = 1, # 龙神萨满·苏菲 (辅助/治疗)
+ WARRIOR_TANK = 2 # 近卫战狂·阿雷斯 (肉盾/前锋)
+}
+
+enum Stance {
+ FOLLOW = 0, # 被动跟随
+ ASSIST = 1, # 协同攻击
+ STAY = 2 # 原地待命
+}
+
+const REVIVE_COOLDOWN: float = 60.0 # 死亡自动复活冷却 (秒)
+const TELEPORT_DISTANCE: float = 25.0 # 超出此距离瞬间传送归队
+const HEAL_COOLDOWN: float = 10.0 # 治疗术内置冷却
+const BUFF_COOLDOWN: float = 30.0 # Buff 持续与刷新周期
+
+const MERCENARY_DATA: Dictionary = {
+ MercenaryType.SHAMAN_HEALER: {
+ "name": "龙神萨满·苏菲",
+ "job": 3,
+ "base_hp": 8000,
+ "base_sp": 6000,
+ "atk": 350,
+ "def": 280,
+ "buffs": [
+ {"name": "巨龙之赐", "type": "def_bonus", "value": 0.20, "duration": 120.0},
+ {"name": "龙之协助", "type": "crit_bonus", "value": 0.15, "duration": 120.0}
+ ]
+ },
+ MercenaryType.WARRIOR_TANK: {
+ "name": "近卫战狂·阿雷斯",
+ "job": 0,
+ "base_hp": 15000,
+ "base_sp": 2000,
+ "atk": 650,
+ "def": 500,
+ "buffs": [
+ {"name": "强身战气", "type": "atk_bonus", "value": 0.15, "duration": 120.0},
+ {"name": "狂暴姿态", "type": "speed_bonus", "value": 0.20, "duration": 120.0}
+ ]
+ }
+}
+
+var active_type: int = 0
+var merc_name: String = ""
+var level: int = 1
+var max_hp: int = 1000
+var current_hp: int = 1000
+var max_sp: int = 500
+var current_sp: int = 500
+var atk: int = 200
+var def: int = 150
+var is_active: bool = false
+var is_alive: bool = false
+var current_stance: int = Stance.ASSIST
+var pos: Vector3 = Vector3.ZERO
+
+# 计时器
+var heal_timer: float = 0.0
+var buff_timer: float = 0.0
+var revive_timer: float = 0.0
+
+# 召唤指定佣兵
+func summon_mercenary(merc_type: int, player_pos: Vector3, player_level: int = 50) -> Dictionary:
+ if not MERCENARY_DATA.has(merc_type):
+ return {"ok": false, "reason": "INVALID_MERC_TYPE", "msg": "未知的佣兵类型!"}
+
+ var cfg = MERCENARY_DATA[merc_type]
+ active_type = merc_type
+ merc_name = cfg["name"]
+ level = player_level
+
+ # 等级动态成长数值
+ var growth_mult = 1.0 + float(level - 1) * 0.05
+ max_hp = int(float(cfg["base_hp"]) * growth_mult)
+ current_hp = max_hp
+ max_sp = int(float(cfg["base_sp"]) * growth_mult)
+ current_sp = max_sp
+ atk = int(float(cfg["atk"]) * growth_mult)
+ def = int(float(cfg["def"]) * growth_mult)
+
+ is_active = true
+ is_alive = true
+ current_stance = Stance.ASSIST
+ pos = player_pos + Vector3(-2.0, 0.0, -2.0)
+
+ heal_timer = 0.0
+ buff_timer = 0.0
+ revive_timer = 0.0
+
+ mercenary_summoned.emit(active_type, merc_name)
+
+ return {
+ "ok": true,
+ "type": active_type,
+ "name": merc_name,
+ "level": level,
+ "hp": current_hp,
+ "atk": atk,
+ "def": def,
+ "pos": pos,
+ "msg": "佣兵【%s】应召前来助战!" % merc_name
+ }
+
+# 解散佣兵
+func dismiss_mercenary() -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "NOT_SUMMONED"}
+
+ is_active = false
+ is_alive = false
+ active_type = 0
+ mercenary_dismissed.emit()
+ return {"ok": true, "msg": "佣兵已暂时离队归营。"}
+
+# 切换战术姿态
+func set_stance(stance: int) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "NOT_SUMMONED"}
+ current_stance = stance
+ return {"ok": true, "stance": current_stance}
+
+# 帧心跳更新 AI 与协同战斗
+func update_mercenary(delta: float, player_pos: Vector3, player_hp: int, player_max_hp: int, target_id: String = "", target_pos: Vector3 = Vector3.ZERO) -> Dictionary:
+ if not is_active:
+ return {"status": "inactive"}
+
+ # 死亡复活倒计时
+ if not is_alive:
+ revive_timer -= delta
+ if revive_timer <= 0.0:
+ revive()
+ return {"status": "just_revived"}
+ return {"status": "downed", "remaining_revive": revive_timer}
+
+ # 计时器更新
+ if heal_timer > 0.0:
+ heal_timer = max(0.0, heal_timer - delta)
+ if buff_timer > 0.0:
+ buff_timer = max(0.0, buff_timer - delta)
+
+ # 1. 距离过远瞬移归队
+ var dist_to_player = pos.distance_to(player_pos)
+ if dist_to_player > TELEPORT_DISTANCE:
+ pos = player_pos + Vector3(-1.5, 0.0, -1.5)
+ return {"status": "teleported_to_player"}
+
+ # 2. 萨满智能辅助:给玩家刷新 Buff
+ if buff_timer <= 0.0:
+ buff_timer = BUFF_COOLDOWN
+ _apply_buffs_to_player()
+
+ # 3. 萨满智能救死扶伤:当玩家 HP < 60% 触发急救
+ if active_type == MercenaryType.SHAMAN_HEALER and heal_timer <= 0.0:
+ var hp_ratio = float(player_hp) / float(max(1, player_max_hp))
+ if hp_ratio < 0.60:
+ heal_timer = HEAL_COOLDOWN
+ var heal_amt = int(float(player_max_hp) * 0.35)
+ heal_applied_to_player.emit(heal_amt)
+ return {
+ "status": "cast_heal",
+ "heal_amount": heal_amt,
+ "msg": "【%s】吟唱巨龙圣歌,为你恢复了 %d 点生命值!" % [merc_name, heal_amt]
+ }
+
+ # 4. 战术行为分流
+ match current_stance:
+ Stance.STAY:
+ return {"status": "staying"}
+
+ Stance.FOLLOW:
+ # 保持紧随玩家身后 2.5 米
+ if dist_to_player > 3.0:
+ pos = pos.move_toward(player_pos, 5.0 * delta)
+ return {"status": "following", "dist": dist_to_player}
+ return {"status": "standing_by"}
+
+ Stance.ASSIST:
+ # 优先跟随;若有目标则协同集火
+ if not target_id.is_empty():
+ var dist_to_tgt = pos.distance_to(target_pos)
+ var desired_range = 10.0 if active_type == MercenaryType.SHAMAN_HEALER else 2.5
+ if dist_to_tgt > desired_range:
+ pos = pos.move_toward(target_pos, 5.5 * delta)
+ return {"status": "closing_in_target", "target": target_id}
+ else:
+ # 施展协同攻击
+ var dmg = atk
+ mercenary_attacked.emit(target_id, dmg)
+ return {
+ "status": "attacked_target",
+ "target": target_id,
+ "damage": dmg
+ }
+ else:
+ if dist_to_player > 3.0:
+ pos = pos.move_toward(player_pos, 5.0 * delta)
+ return {"status": "following_idle"}
+
+ return {"status": "idle"}
+
+# 给主人施加专属辅助光环
+func _apply_buffs_to_player() -> void:
+ var cfg = MERCENARY_DATA.get(active_type, {})
+ var buffs: Array = cfg.get("buffs", [])
+ for b in buffs:
+ buff_applied_to_player.emit(b["name"], b["duration"], b["value"])
+
+# 佣兵受到伤害
+func receive_damage(raw_dmg: int) -> Dictionary:
+ if not is_active or not is_alive:
+ return {"ok": false}
+
+ var net_dmg = max(1, raw_dmg - def)
+ current_hp = max(0, current_hp - net_dmg)
+
+ if current_hp <= 0:
+ is_alive = false
+ revive_timer = REVIVE_COOLDOWN
+ mercenary_downed.emit()
+ return {
+ "ok": true,
+ "downed": true,
+ "msg": "佣兵【%s】力竭倒下!将在 60 秒后恢复伤势复活。" % merc_name
+ }
+
+ return {
+ "ok": true,
+ "downed": false,
+ "remaining_hp": current_hp,
+ "net_dmg": net_dmg
+ }
+
+# 复活佣兵
+func revive(instant: bool = false) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "NOT_SUMMONED"}
+
+ is_alive = true
+ current_hp = int(float(max_hp) * (1.0 if instant else 0.5))
+ revive_timer = 0.0
+ mercenary_revived.emit()
+ return {
+ "ok": true,
+ "hp": current_hp,
+ "msg": "佣兵【%s】重新站起投入战斗!" % merc_name
+ }
+
+# 获取当前佣兵状态
+func get_mercenary_info() -> Dictionary:
+ return {
+ "is_active": is_active,
+ "is_alive": is_alive,
+ "type": active_type,
+ "name": merc_name,
+ "level": level,
+ "current_hp": current_hp,
+ "max_hp": max_hp,
+ "current_sp": current_sp,
+ "max_sp": max_sp,
+ "atk": atk,
+ "def": def,
+ "stance": current_stance,
+ "pos": pos,
+ "revive_timer": revive_timer
+ }
diff --git a/project/mercenary_companion_system.gd.uid b/project/mercenary_companion_system.gd.uid
new file mode 100644
index 00000000..88fc407c
--- /dev/null
+++ b/project/mercenary_companion_system.gd.uid
@@ -0,0 +1 @@
+uid://bupodjsq5qiam
diff --git a/project/messenger_system.gd b/project/messenger_system.gd
new file mode 100644
index 00000000..255bd71b
--- /dev/null
+++ b/project/messenger_system.gd
@@ -0,0 +1,169 @@
+# messenger_system.gd —— 40250 官方好友与行会联络人面板系统 1:1
+# 严格对照:
+# ClientVS22/source/UserInterface/PythonMessenger.h, PythonMessenger.cpp
+# Client/Eternexus/root/uimessenger.py
+extends RefCounted
+
+const GROUP_FRIEND := 0 # 好友分组 (MESSENGER_GRUOP_INDEX_FRIEND)
+const GROUP_GUILD := 1 # 行会分组 (MESSENGER_GRUOP_INDEX_GUILD)
+const GROUP_FAMILY := 2 # 家族/伴侣分组 (MessengerFamilyGroup)
+
+const STATE_OFFLINE := 0 # 离线
+const STATE_ONLINE := 1 # 在线
+const STATE_MOBILE := 2 # 移动短信/挂机状态
+
+# 联络人数据结构
+class MessengerContact:
+ var name: String
+ var group: int
+ var state: int
+ var love_point: int = -1 # 伴侣爱心值 (0~100)
+ var rank: String = "" # 行会职位
+ var last_seen: int = 0
+
+ func _init(p_name: String, p_group: int, p_state: int = STATE_OFFLINE) -> void:
+ name = p_name
+ group = p_group
+ state = p_state
+ love_point = -1
+ rank = ""
+ last_seen = 0
+
+# 分组存储
+var friends: Dictionary = {} # name -> MessengerContact
+var guild_members: Dictionary = {} # name -> MessengerContact
+var family_partner: MessengerContact = null
+
+# 事件日志回调缓冲(模拟 PythonMessenger 向上层回调)
+var event_logs: Array = []
+
+var main_player_name: String = "Player"
+
+func _init(player_name: String = "Player") -> void:
+ main_player_name = player_name
+ friends.clear()
+ guild_members.clear()
+ family_partner = null
+ event_logs.clear()
+
+# 1. 好友添加 (PythonMessenger::AppendFriend)
+func add_friend(friend_name: String, initial_state: int = STATE_ONLINE) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if friend_name.is_empty():
+ res["error_code"] = "EMPTY_NAME"
+ res["message"] = "好友名称不能为空。"
+ return res
+
+ if friend_name == main_player_name:
+ res["error_code"] = "CANNOT_ADD_SELF"
+ res["message"] = "无法将自己添加为好友。"
+ return res
+
+ if friends.has(friend_name):
+ res["error_code"] = "ALREADY_FRIEND"
+ res["message"] = "该玩家已在您的好友列表中。"
+ return res
+
+ var contact = MessengerContact.new(friend_name, GROUP_FRIEND, initial_state)
+ friends[friend_name] = contact
+
+ event_logs.append({ "type": "ADD_FRIEND", "name": friend_name, "state": initial_state })
+ res["success"] = true
+ res["message"] = "成功添加 %s 为好友。" % friend_name
+ return res
+
+# 2. 好友移除 (PythonMessenger::RemoveFriend)
+func remove_friend(friend_name: String) -> bool:
+ if not friends.has(friend_name):
+ return false
+ friends.erase(friend_name)
+ event_logs.append({ "type": "REMOVE_FRIEND", "name": friend_name })
+ return true
+
+# 3. 登录/登出/移动短信状态同步 (OnFriendLogin / OnFriendLogout / SetMobile)
+func set_friend_state(friend_name: String, new_state: int) -> bool:
+ if not friends.has(friend_name):
+ return false
+
+ var c: MessengerContact = friends[friend_name]
+ var old_state: int = c.state
+ c.state = new_state
+
+ if new_state == STATE_ONLINE and old_state != STATE_ONLINE:
+ event_logs.append({ "type": "FRIEND_LOGIN", "name": friend_name })
+ elif new_state == STATE_OFFLINE and old_state != STATE_OFFLINE:
+ event_logs.append({ "type": "FRIEND_LOGOUT", "name": friend_name })
+ elif new_state == STATE_MOBILE:
+ event_logs.append({ "type": "FRIEND_MOBILE", "name": friend_name })
+
+ return true
+
+# 4. 行会成员同步 (AppendGuildMember / RemoveGuildMember / RemoveAllGuildMember)
+func append_guild_member(member_name: String, member_rank: String = "Member", state: int = STATE_ONLINE) -> void:
+ if guild_members.has(member_name):
+ var existing: MessengerContact = guild_members[member_name]
+ existing.state = state
+ existing.rank = member_rank
+ return
+
+ var contact = MessengerContact.new(member_name, GROUP_GUILD, state)
+ contact.rank = member_rank
+ guild_members[member_name] = contact
+ event_logs.append({ "type": "GUILD_MEMBER_ADD", "name": member_name, "rank": member_rank })
+
+func remove_guild_member(member_name: String) -> bool:
+ if not guild_members.has(member_name):
+ return false
+ guild_members.erase(member_name)
+ event_logs.append({ "type": "GUILD_MEMBER_REMOVE", "name": member_name })
+ return true
+
+func clear_all_guild_members() -> void:
+ guild_members.clear()
+ event_logs.append({ "type": "GUILD_MEMBER_CLEAR_ALL" })
+
+# 5. 家族/配偶联系人绑定 (MessengerFamilyGroup)
+func set_family_partner(partner_name: String, love_points: int = 100, state: int = STATE_ONLINE) -> void:
+ family_partner = MessengerContact.new(partner_name, GROUP_FAMILY, state)
+ family_partner.love_point = clamp(love_points, 0, 100)
+ event_logs.append({ "type": "FAMILY_PARTNER_SET", "name": partner_name, "love": family_partner.love_point })
+
+func update_partner_love_points(points: int) -> void:
+ if family_partner != null:
+ family_partner.love_point = clamp(points, 0, 100)
+
+func remove_family_partner() -> void:
+ family_partner = null
+ event_logs.append({ "type": "FAMILY_PARTNER_REMOVE" })
+
+# 6. 查询与过滤工具方法
+func is_friend(name: String) -> bool:
+ return friends.has(name)
+
+func get_friend_state(name: String) -> int:
+ if friends.has(name):
+ return friends[name].state
+ return STATE_OFFLINE
+
+func get_group_members(group_index: int) -> Array:
+ var list: Array = []
+ match group_index:
+ GROUP_FRIEND:
+ for k in friends:
+ list.append(friends[k])
+ GROUP_GUILD:
+ for k in guild_members:
+ list.append(guild_members[k])
+ GROUP_FAMILY:
+ if family_partner != null:
+ list.append(family_partner)
+ return list
+
+func get_online_count(group_index: int) -> int:
+ var members := get_group_members(group_index)
+ var online := 0
+ for m in members:
+ if m.state == STATE_ONLINE or m.state == STATE_MOBILE:
+ online += 1
+ return online
diff --git a/project/messenger_system.gd.uid b/project/messenger_system.gd.uid
new file mode 100644
index 00000000..8a52a4d8
--- /dev/null
+++ b/project/messenger_system.gd.uid
@@ -0,0 +1 @@
+uid://cp88xu2y3jss2
diff --git a/project/metin_socket_system.gd b/project/metin_socket_system.gd
new file mode 100644
index 00000000..5a807bd5
--- /dev/null
+++ b/project/metin_socket_system.gd
@@ -0,0 +1,204 @@
+# metin_socket_system.gd —— 魔石镶嵌与打孔清除系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 char_item.cpp:5004-5080 (case ITEM_METIN: 镶嵌魔石、30% 成功率、碎石 28960)
+# - 40250 char_item.cpp:4510-4540 (case USE_CLEAN_SOCKET: 卷轴 71006 清除 28960 碎石残渣)
+# - 40250 char_item.cpp:53 (const int ITEM_BROKEN_METIN_VNUM = 28960)
+# - 40250 ItemData.h (ITEM_METIN, ITEM_WEAPON, ITEM_ARMOR)
+class_name MetinSocketSystem
+extends RefCounted
+
+const ITEM_BROKEN_METIN_VNUM := 28960 # 碎石残片 Vnum (40250 官方标准)
+const VNUM_CLEAN_SOCKET_SCROLL := 71006 # 提石卷轴 / 清除残渣卷轴 (USE_CLEAN_SOCKET)
+
+const ITEM_TYPE_WEAPON := 1
+const ITEM_TYPE_ARMOR := 2
+const ITEM_TYPE_METIN := 10
+
+const WEARABLE_WEAPON := 1 << 0
+const WEARABLE_BODY := 1 << 1
+
+# 魔石基础分类(根据 40250 官方属性定义,同一分类不可在同一装备上重复镶嵌)
+# 例如:突击石、穿透石、致命石、冷却石、怪物石、保护石、防御石、回避石、生命石等
+const METIN_GROUPS := {
+ # 穿透石 (28030..28430)
+ "PENETRATE": [28030, 28130, 28230, 28330, 28430],
+ # 致命石 / 暴击石 (28031..28431)
+ "CRITICAL": [28031, 28131, 28231, 28331, 28431],
+ # 冷却石 / 施法石 (28032..28432)
+ "COOLDOWN": [28032, 28132, 28232, 28332, 28432],
+ # 战士突击石 (28033..28433)
+ "WARRIOR": [28033, 28133, 28233, 28333, 28433],
+ # 刺客突击石 (28034..28434)
+ "ASSASSIN": [28034, 28134, 28234, 28334, 28434],
+ # 武士突击石 (28035..28435)
+ "SURA": [28035, 28135, 28235, 28335, 28435],
+ # 萨满突击石 (28036..28436)
+ "SHAMAN": [28036, 28136, 28236, 28336, 28436],
+ # 怪物石 (28037..28437)
+ "MONSTER": [28037, 28137, 28237, 28337, 28437],
+ # 躲闪石 / 回避石 (28038..28438)
+ "EVASION": [28038, 28138, 28238, 28338, 28438],
+ # 抵挡石 / 格挡石 (28039..28439)
+ "BLOCK": [28039, 28139, 28239, 28339, 28439],
+ # 坚韧石 / 魔法石 (28040..28440)
+ "MAGIC": [28040, 28140, 28240, 28340, 28440],
+ # 活力石 / 生命石 (28041..28441)
+ "VITALITY": [28041, 28141, 28241, 28341, 28441],
+ # 防御石 (28042..28442)
+ "DEFENSE": [28042, 28142, 28242, 28342, 28442],
+ # 速度石 (28043..28443)
+ "SPEED": [28043, 28143, 28243, 28343, 28443],
+}
+
+# 官方默认镶嵌成功率:30%(40250 char_item.cpp:5070 number(1, 100) <= 30)
+const DEFAULT_METIN_SUCCESS_RATE := 30
+
+## 检查是否为魔石
+static func is_metin_stone(vnum: int, proto: Dictionary = {}) -> bool:
+ if proto and int(proto.get("type", 0)) == ITEM_TYPE_METIN:
+ return true
+ return vnum >= 28000 and vnum <= 28450
+
+## 检查是否为清除魔石残渣卷轴
+static func is_clean_socket_scroll(vnum: int) -> bool:
+ return vnum == VNUM_CLEAN_SOCKET_SCROLL
+
+## 获取魔石的类别分组(用于防同类同插槽重复)
+static func get_metin_group(vnum: int) -> String:
+ for grp in METIN_GROUPS.keys():
+ if vnum in METIN_GROUPS[grp]:
+ return grp
+ return str(vnum / 100)
+
+## 检查装备能否镶嵌该魔石(武器魔石 vs 防具魔石)
+static func can_attach_metin(stone_vnum: int, stone_proto: Dictionary, target_proto: Dictionary) -> Dictionary:
+ var target_type := int(target_proto.get("type", 0))
+ if target_type not in [ITEM_TYPE_WEAPON, ITEM_TYPE_ARMOR]:
+ return {"ok": false, "code": "CANNOT_SOCKET_TYPE", "msg": "此物品类型无法镶嵌魔石。"}
+
+ var stone_wear := int(stone_proto.get("wear_flag", 0))
+ if target_type == ITEM_TYPE_ARMOR:
+ # 防具魔石必须匹配 WEARABLE_BODY (衣服)
+ var sub := int(target_proto.get("sub_type", 0))
+ if sub != 0: # 0 = ARMOR_BODY
+ return {"ok": false, "code": "ARMOR_NOT_BODY", "msg": "防具类魔石只能镶嵌在盔甲上。"}
+ elif target_type == ITEM_TYPE_WEAPON:
+ var sub := int(target_proto.get("sub_type", 0))
+ if sub == 5: # WEAPON_ARROW
+ return {"ok": false, "code": "WEAPON_ARROW", "msg": "箭矢无法镶嵌魔石。"}
+
+ return {"ok": true}
+
+## 执行魔石镶嵌 (40250 char_item.cpp:5004-5080)
+static func socket_metin_stone(stone_vnum: int, stone_proto: Dictionary,
+ target_item: Dictionary, target_proto: Dictionary,
+ force_success := false, force_fail := false) -> Dictionary:
+
+ var check := can_attach_metin(stone_vnum, stone_proto, target_proto)
+ if not bool(check.get("ok", false)):
+ return check
+
+ var sockets: Array = target_item.get("sockets", [1, 1, 1]).duplicate()
+ while sockets.size() < 3:
+ sockets.append(1)
+
+ var stone_grp := get_metin_group(stone_vnum)
+
+ # 检查装备上是否已有同类魔石 (40250 char_item.cpp:5035 item->GetValue(5) == p->alValues[5])
+ for s in sockets:
+ var sv := int(s)
+ if sv > 2 and sv != ITEM_BROKEN_METIN_VNUM:
+ if get_metin_group(sv) == stone_grp:
+ return {
+ "ok": false,
+ "code": "DUPLICATE_METIN",
+ "msg": "装备上已经镶嵌了同类型的魔石,无法重复镶嵌!"
+ }
+
+ # 寻找可用孔位 (40250: socket >= 1 && socket <= 2 表示开孔可用状态)
+ var target_slot := -1
+ for i in sockets.size():
+ var sv := int(sockets[i])
+ if sv >= 1 and sv <= 2:
+ target_slot = i
+ break
+
+ if target_slot == -1:
+ return {
+ "ok": false,
+ "code": "NO_AVAILABLE_SOCKET",
+ "msg": "此装备没有可用的空魔石插槽(已插满或被碎石堵塞)。"
+ }
+
+ # 判定成功/失败概率(40250 官方 30% 成功)
+ var success := false
+ if force_success:
+ success = true
+ elif force_fail:
+ success = false
+ else:
+ success = (randi() % 100 + 1) <= DEFAULT_METIN_SUCCESS_RATE
+
+ if success:
+ sockets[target_slot] = stone_vnum
+ target_item["sockets"] = sockets
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "msg": "魔石成功镶嵌到了装备上!",
+ "consumed": true,
+ "socket_index": target_slot,
+ "result_vnum": stone_vnum,
+ }
+ else:
+ # 镶嵌失败:碎石堵塞孔位 (ITEM_BROKEN_METIN_VNUM = 28960)
+ sockets[target_slot] = ITEM_BROKEN_METIN_VNUM
+ target_item["sockets"] = sockets
+ return {
+ "ok": false,
+ "code": "BROKEN_METIN",
+ "msg": "魔石破碎了,碎石残渣堵塞了插槽!",
+ "consumed": true,
+ "socket_index": target_slot,
+ "result_vnum": ITEM_BROKEN_METIN_VNUM,
+ }
+
+## 执行提石卷轴 / 清除碎石残渣 (40250 char_item.cpp:4510 USE_CLEAN_SOCKET)
+static func clean_broken_sockets(target_item: Dictionary) -> Dictionary:
+ var sockets: Array = target_item.get("sockets", [1, 1, 1]).duplicate()
+ while sockets.size() < 3:
+ sockets.append(1)
+
+ var has_broken := false
+ for s in sockets:
+ if int(s) == ITEM_BROKEN_METIN_VNUM:
+ has_broken = true
+ break
+
+ if not has_broken:
+ return {
+ "ok": false,
+ "code": "NO_BROKEN_STONE",
+ "msg": "该装备插槽内没有碎石残渣,无需清除。"
+ }
+
+ # 40250 char_item.cpp:4526 算法:
+ # 1. 把所有有效魔石(不等于 28960 且不为 0)按序向前压实
+ # 2. 剩余的孔位重新置为 1(可用空孔)
+ var cleaned: Array = []
+ for s in sockets:
+ var sv := int(s)
+ if sv != ITEM_BROKEN_METIN_VNUM and sv > 2:
+ cleaned.append(sv)
+
+ while cleaned.size() < sockets.size():
+ cleaned.append(1) # 恢复为空孔位 1
+
+ target_item["sockets"] = cleaned
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "msg": "成功清除了装备上的碎石残渣,插槽已恢复可用!",
+ "consumed": true,
+ "sockets": cleaned,
+ }
diff --git a/project/metin_socket_system.gd.uid b/project/metin_socket_system.gd.uid
new file mode 100644
index 00000000..bacd25dd
--- /dev/null
+++ b/project/metin_socket_system.gd.uid
@@ -0,0 +1 @@
+uid://dadu6ruv5bhbq
diff --git a/project/metin_spawn_event_system.gd b/project/metin_spawn_event_system.gd
new file mode 100644
index 00000000..143a6623
--- /dev/null
+++ b/project/metin_spawn_event_system.gd
@@ -0,0 +1,174 @@
+# metin_spawn_event_system.gd —— Metin2 40250 野外 8 档魔石陨落与怪潮暴动 1:1
+# 对照 40250 服务端 regen.cpp, char_battle.cpp, metin.cpp, Mobs 8008~8014, 8027
+class_name MetinSpawnEventSystem
+extends RefCounted
+
+signal metin_spawned(vnum: int, name: String, pos: Vector3)
+signal mob_wave_erupted(metin_vnum: int, wave_count: int, minion_vnum: int)
+signal metin_destroyed(metin_vnum: int, pos: Vector3, drops: Array)
+
+const METIN_TABLE: Dictionary = {
+ 8008: {"name": "嫉妒之石", "level": 35, "max_hp": 70000, "def": 120, "wave_mob": 391, "wave_count": 4},
+ 8009: {"name": "灵魂之石", "level": 40, "max_hp": 90000, "def": 140, "wave_mob": 401, "wave_count": 4},
+ 8010: {"name": "暗影之石", "level": 45, "max_hp": 120000, "def": 160, "wave_mob": 501, "wave_count": 5},
+ 8011: {"name": "坚韧之石", "level": 50, "max_hp": 150000, "def": 180, "wave_mob": 601, "wave_count": 5},
+ 8012: {"name": "恶魔之石", "level": 55, "max_hp": 180000, "def": 200, "wave_mob": 701, "wave_count": 6},
+ 8013: {"name": "坠落之石", "level": 60, "max_hp": 220000, "def": 230, "wave_mob": 2001, "wave_count": 6},
+ 8014: {"name": "死亡之石", "level": 65, "max_hp": 260000, "def": 260, "wave_mob": 2101, "wave_count": 7},
+ 8027: {"name": "谋杀之石", "level": 70, "max_hp": 300000, "def": 300, "wave_mob": 2201, "wave_count": 8}
+}
+
+# 当前存活的魔石实体字典: id -> Dictionary
+var active_metins: Dictionary = {}
+var next_instance_id: int = 1
+
+# 在指定位置刷新野外魔石
+func spawn_metin(vnum: int, pos: Vector3 = Vector3.ZERO) -> Dictionary:
+ if not METIN_TABLE.has(vnum):
+ return {"ok": false, "reason": "INVALID_METIN_VNUM", "msg": "未知的魔石代码!"}
+
+ var cfg = METIN_TABLE[vnum]
+ var inst_id := next_instance_id
+ next_instance_id += 1
+
+ var entity: Dictionary = {
+ "id": inst_id,
+ "vnum": vnum,
+ "name": cfg["name"],
+ "level": cfg["level"],
+ "max_hp": cfg["max_hp"],
+ "current_hp": cfg["max_hp"],
+ "def": cfg["def"],
+ "wave_mob": cfg["wave_mob"],
+ "wave_count": cfg["wave_count"],
+ "pos": pos,
+ "phase_75_triggered": false,
+ "phase_50_triggered": false,
+ "phase_25_triggered": false,
+ "spawned_minions": []
+ }
+
+ active_metins[inst_id] = entity
+ metin_spawned.emit(vnum, cfg["name"], pos)
+
+ return {
+ "ok": true,
+ "id": inst_id,
+ "vnum": vnum,
+ "name": cfg["name"],
+ "pos": pos,
+ "msg": "【%s】轰然坠落于大地!(坐标: %.1f, %.1f)" % [cfg["name"], pos.x, pos.z]
+ }
+
+# 攻击魔石并触发 25% 阶段怪潮爆发
+func attack_metin(inst_id: int, raw_dmg: int) -> Dictionary:
+ if not active_metins.has(inst_id):
+ return {"ok": false, "reason": "METIN_NOT_FOUND", "msg": "该魔石不存在或已击碎!"}
+
+ var stone = active_metins[inst_id]
+ var net_dmg: int = max(1, raw_dmg - int(stone["def"]))
+ stone["current_hp"] = max(0, stone["current_hp"] - net_dmg)
+
+ var cur_hp: int = stone["current_hp"]
+ var max_hp: int = stone["max_hp"]
+ var hp_ratio: float = float(cur_hp) / float(max_hp)
+
+ var wave_triggered := false
+ var spawned_count := 0
+
+ # 75% 阶段怪潮爆发
+ if hp_ratio <= 0.75 and not stone["phase_75_triggered"]:
+ stone["phase_75_triggered"] = true
+ spawned_count = _trigger_mob_wave(stone)
+ wave_triggered = true
+
+ # 50% 阶段怪潮爆发
+ if hp_ratio <= 0.50 and not stone["phase_50_triggered"]:
+ stone["phase_50_triggered"] = true
+ spawned_count = _trigger_mob_wave(stone)
+ wave_triggered = true
+
+ # 25% 阶段怪潮爆发
+ if hp_ratio <= 0.25 and not stone["phase_25_triggered"]:
+ stone["phase_25_triggered"] = true
+ spawned_count = _trigger_mob_wave(stone)
+ wave_triggered = true
+
+ # 魔石击碎判定
+ if cur_hp <= 0:
+ var drops := _generate_metin_drops(stone["vnum"])
+ var stone_pos: Vector3 = stone["pos"]
+ var stone_vnum: int = stone["vnum"]
+ var stone_name: String = stone["name"]
+
+ active_metins.erase(inst_id)
+ metin_destroyed.emit(stone_vnum, stone_pos, drops)
+
+ return {
+ "ok": true,
+ "destroyed": true,
+ "net_dmg": net_dmg,
+ "remaining_hp": 0,
+ "drops": drops,
+ "msg": "【%s】被彻底击碎!神圣光芒散落,爆出技能书与灵石!" % stone_name
+ }
+
+ return {
+ "ok": true,
+ "destroyed": false,
+ "net_dmg": net_dmg,
+ "remaining_hp": cur_hp,
+ "hp_ratio": hp_ratio,
+ "wave_triggered": wave_triggered,
+ "spawned_count": spawned_count
+ }
+
+# 激发一波护卫怪群
+func _trigger_mob_wave(stone: Dictionary) -> int:
+ var count: int = stone["wave_count"]
+ var mob_vnum: int = stone["wave_mob"]
+
+ for i in range(count):
+ var offset := Vector3(randf_range(-4.0, 4.0), 0.0, randf_range(-4.0, 4.0))
+ stone["spawned_minions"].append({
+ "vnum": mob_vnum,
+ "pos": stone["pos"] + offset
+ })
+
+ mob_wave_erupted.emit(stone["vnum"], count, mob_vnum)
+ return count
+
+# 生成魔石击碎全额掉落
+func _generate_metin_drops(metin_vnum: int) -> Array:
+ var cfg = METIN_TABLE[metin_vnum]
+ var drops: Array = []
+
+ # 1. 必掉技能书 (50300)
+ drops.append({
+ "vnum": 50300,
+ "name": "技能书",
+ "count": 1
+ })
+
+ # 2. 必掉灵魂之石 (50513)
+ drops.append({
+ "vnum": 50513,
+ "name": "灵魂之石",
+ "count": 1
+ })
+
+ # 3. 灵石 (+0~+4, 随机职业战士/刺客/修罗/萨满/防御/生命)
+ drops.append({
+ "vnum": 28430, # 战士灵石+4
+ "name": "战士灵石+4",
+ "count": 1
+ })
+
+ # 4. 金币
+ drops.append({
+ "vnum": 1,
+ "name": "金币",
+ "count": randi_range(cfg["level"] * 500, cfg["level"] * 1500)
+ })
+
+ return drops
diff --git a/project/metin_spawn_event_system.gd.uid b/project/metin_spawn_event_system.gd.uid
new file mode 100644
index 00000000..fb72fa11
--- /dev/null
+++ b/project/metin_spawn_event_system.gd.uid
@@ -0,0 +1 @@
+uid://c6ph5y2v1s1g1
diff --git a/project/mining_refinery_smelt_system.gd b/project/mining_refinery_smelt_system.gd
new file mode 100644
index 00000000..7fed65a8
--- /dev/null
+++ b/project/mining_refinery_smelt_system.gd
@@ -0,0 +1,192 @@
+# mining_refinery_smelt_system.gd —— Metin2 40250 矿石采掘熔炼与首饰宝石镶嵌提纯炉系统 1:1
+# 对照 40250 服务端 mining.cpp, char_item.cpp, item_manager.cpp
+class_name MiningRefinerySmeltSystem
+extends RefCounted
+
+signal ore_smelted(success: bool, gem_vnum: int, gem_name: String)
+signal jewelry_socket_opened(slot: int, socket_index: int)
+signal gem_embedded(slot: int, gem_name: String, stat_boost: float)
+
+const SMELT_ORE_COUNT := 100 # 熔炼需 100 颗原石
+const SMELT_COST_GOLD := 10000 # 熔炼消耗 10,000 金币
+const MAX_JEWELRY_SOCKETS := 3 # 首饰最多开 3 个孔
+
+# 原石与精炼宝石对照表
+const SMELT_TABLE: Dictionary = {
+ 50621: {"name": "钻石原石", "gem_vnum": 50634, "gem_name": "纯净钻石", "is_diamond": true},
+ 50623: {"name": "乌木原石", "gem_vnum": 50636, "gem_name": "乌木精炼石", "is_diamond": false},
+ 50626: {"name": "黄金原石", "gem_vnum": 50639, "gem_name": "黄金精炼石", "is_diamond": false},
+ 50628: {"name": "白金原石", "gem_vnum": 50641, "gem_name": "白金精炼石", "is_diamond": false},
+ 50630: {"name": "天之泪原石", "gem_vnum": 50643, "gem_name": "天之泪精炼石", "is_diamond": false}
+}
+
+# 熔炼原石生成首饰宝石
+func smelt_ore(ore_slot: int, stone_slot: int, inventory: Array, player_data: Dictionary, is_guild_alchemist: bool = false, force_success: bool = false) -> Dictionary:
+ if ore_slot < 0 or ore_slot >= inventory.size() or inventory[ore_slot] == null:
+ return {"ok": false, "reason": "INVALID_ORE_SLOT"}
+ if stone_slot < 0 or stone_slot >= inventory.size() or inventory[stone_slot] == null:
+ return {"ok": false, "reason": "INVALID_STONE_SLOT"}
+
+ var ore_item = inventory[ore_slot]
+ var stone_item = inventory[stone_slot]
+
+ var ore_vnum = int(ore_item.get("vnum", 0))
+ if not SMELT_TABLE.has(ore_vnum):
+ return {"ok": false, "reason": "NOT_AN_ORE", "msg": "该物品不是可熔炼的原矿石!"}
+
+ var ore_count = int(ore_item.get("count", 1))
+ if ore_count < SMELT_ORE_COUNT:
+ return {"ok": false, "reason": "NOT_ENOUGH_ORE", "msg": "原矿数量不足!需要 100 颗,当前仅有 %d 颗。" % ore_count}
+
+ var gold = int(player_data.get("gold", 0))
+ if gold < SMELT_COST_GOLD:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "熔炼所需金币不足!需要 10,000 金币。"}
+
+ # 消耗材料与金币
+ player_data["gold"] = gold - SMELT_COST_GOLD
+ if ore_count > SMELT_ORE_COUNT:
+ ore_item["count"] = ore_count - SMELT_ORE_COUNT
+ else:
+ inventory[ore_slot] = null
+
+ var stone_cnt = int(stone_item.get("count", 1))
+ if stone_cnt > 1:
+ stone_item["count"] = stone_cnt - 1
+ else:
+ inventory[stone_slot] = null
+
+ # 成功率计算 (普通提纯炉 60%,公会炼金炉 70%)
+ var rate = 70 if is_guild_alchemist else 60
+ var roll = randi_range(1, 100)
+ var is_succ = force_success or (roll <= rate)
+
+ var target_gem = SMELT_TABLE[ore_vnum]
+ var gem_vnum = target_gem["gem_vnum"]
+ var gem_name = target_gem["gem_name"]
+
+ if is_succ:
+ # 入包精炼宝石
+ _add_gem_to_inventory(gem_vnum, gem_name, inventory)
+ ore_smelted.emit(true, gem_vnum, gem_name)
+ return {
+ "ok": true,
+ "success": true,
+ "gem_vnum": gem_vnum,
+ "gem_name": gem_name,
+ "msg": "提纯炉烈焰喷涌!成功熔炼出高纯度【%s】!" % gem_name
+ }
+ else:
+ ore_smelted.emit(false, gem_vnum, gem_name)
+ return {
+ "ok": true,
+ "success": false,
+ "msg": "熔炼失败!火候不纯,矿石与灵石化为了灰烬。"
+ }
+
+# 使用纯净钻石 (50634) 为首饰打孔
+func open_jewelry_socket(diamond_slot: int, target_slot: int, inventory: Array, force_success: bool = false) -> Dictionary:
+ if diamond_slot < 0 or diamond_slot >= inventory.size() or inventory[diamond_slot] == null:
+ return {"ok": false, "reason": "INVALID_DIAMOND_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+
+ var d_item = inventory[diamond_slot]
+ var target = inventory[target_slot]
+
+ if int(d_item.get("vnum", 0)) != 50634:
+ return {"ok": false, "reason": "NOT_A_DIAMOND", "msg": "打孔需要使用【纯净钻石】(50634)!"}
+
+ if not target.has("jewelry_sockets"):
+ target["jewelry_sockets"] = []
+
+ var sockets: Array = target["jewelry_sockets"]
+ if sockets.size() >= MAX_JEWELRY_SOCKETS:
+ return {"ok": false, "reason": "MAX_SOCKETS_REACHED", "msg": "该首饰已开辟 3 个宝石孔位,达到上限!"}
+
+ # 消耗 1 颗钻石
+ var cnt = int(d_item.get("count", 1))
+ if cnt > 1:
+ d_item["count"] = cnt - 1
+ else:
+ inventory[diamond_slot] = null
+
+ # 阶梯成功率: 第 1 孔 100%,第 2 孔 80%,第 3 孔 60%
+ var rates = [100, 80, 60]
+ var rate = rates[sockets.size()]
+ var roll = randi_range(1, 100)
+ var is_succ = force_success or (roll <= rate)
+
+ if not is_succ:
+ return {"ok": true, "success": false, "msg": "打孔失败!钻石碎裂,未能开辟新孔位。"}
+
+ sockets.append(null) # 开辟一个空孔位
+ jewelry_socket_opened.emit(target_slot, sockets.size())
+
+ return {
+ "ok": true,
+ "success": true,
+ "socket_index": sockets.size(),
+ "total_sockets": sockets.size(),
+ "msg": "打孔成功!首饰开辟了第 %d 个宝石镶嵌孔位!" % sockets.size()
+ }
+
+# 将精炼宝石镶入首饰已开孔位
+func embed_gem_into_socket(gem_slot: int, target_slot: int, inventory: Array) -> Dictionary:
+ if gem_slot < 0 or gem_slot >= inventory.size() or inventory[gem_slot] == null:
+ return {"ok": false, "reason": "INVALID_GEM_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+
+ var gem_item = inventory[gem_slot]
+ var target = inventory[target_slot]
+
+ if not target.has("jewelry_sockets") or target["jewelry_sockets"].is_empty():
+ return {"ok": false, "reason": "NO_SOCKETS", "msg": "该首饰尚未开孔!请先使用纯净钻石打孔。"}
+
+ var sockets: Array = target["jewelry_sockets"]
+ var empty_idx = -1
+ for i in range(sockets.size()):
+ if sockets[i] == null:
+ empty_idx = i
+ break
+
+ if empty_idx == -1:
+ return {"ok": false, "reason": "ALL_SOCKETS_OCCUPIED", "msg": "所有宝石孔位已镶满!"}
+
+ var gem_vnum = int(gem_item.get("vnum", 0))
+ var gem_name = gem_item.get("name", "宝石")
+
+ # 消耗 1 颗宝石
+ var cnt = int(gem_item.get("count", 1))
+ if cnt > 1:
+ gem_item["count"] = cnt - 1
+ else:
+ inventory[gem_slot] = null
+
+ # 镶入孔位 (每颗宝石提供 +10% 首饰属性增强)
+ sockets[empty_idx] = {
+ "vnum": gem_vnum,
+ "name": gem_name,
+ "stat_boost": 0.10
+ }
+
+ gem_embedded.emit(target_slot, gem_name, 0.10)
+
+ return {
+ "ok": true,
+ "socket_idx": empty_idx,
+ "gem_name": gem_name,
+ "stat_boost": 0.10,
+ "msg": "【%s】完美嵌入第 %d 孔!激活 +10%% 首饰属性加成!" % [gem_name, empty_idx + 1]
+ }
+
+func _add_gem_to_inventory(vnum: int, iname: String, inventory: Array) -> void:
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == vnum:
+ inventory[i]["count"] = int(inventory[i].get("count", 1)) + 1
+ return
+
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "name": iname, "count": 1}
+ return
diff --git a/project/mining_refinery_smelt_system.gd.uid b/project/mining_refinery_smelt_system.gd.uid
new file mode 100644
index 00000000..17f41ef4
--- /dev/null
+++ b/project/mining_refinery_smelt_system.gd.uid
@@ -0,0 +1 @@
+uid://do3dfunqgfm6w
diff --git a/project/mob_combat_reaction_system.gd b/project/mob_combat_reaction_system.gd
new file mode 100644
index 00000000..71254d95
--- /dev/null
+++ b/project/mob_combat_reaction_system.gd
@@ -0,0 +1,72 @@
+# mob_combat_reaction_system.gd —— 怪物主动仇恨视野、追击脱战与连击击退硬直(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/game/src/char_state.cpp:250-380 (StateBattle, StateIdle, 追击与脱战)
+# - 40250 server/game/src/battle.cpp:110-180 (battle_hit, 攻击判定与受击推力)
+# - 40250 ClientVS22/source/UserInterface/ActorInstanceBattle.cpp (__HitGood, __HitGreate)
+class_name MobCombatReactionSystem
+extends RefCounted
+
+const DEFAULT_AGGRO_RADIUS := 18.0 # 主动怪索敌视野半径 (18 米)
+const MAX_LEASH_DISTANCE := 40.0 # 最大追击拉脱距离 (40 米)
+const KNOCKBACK_FORCE_M := 1.5 # 四连击击退推力距离 (1.5 米)
+
+enum MobAIState {
+ IDLE, # 巡逻/游荡
+ CHASE, # 仇恨追击玩家
+ RETREAT, # 超过拉脱距离,放弃仇恨返回出生点
+}
+
+## 检查是否进入主动怪仇恨视野
+static func check_aggro(mob_pos: Vector3, player_pos: Vector3, is_aggressive := true, aggro_radius := DEFAULT_AGGRO_RADIUS) -> bool:
+ if not is_aggressive:
+ return false
+ var dist := mob_pos.distance_to(player_pos)
+ return dist <= aggro_radius
+
+## 检查是否超出追击拉脱最大距离
+static func check_leash_exceeded(mob_pos: Vector3, spawn_pos: Vector3, max_leash := MAX_LEASH_DISTANCE) -> bool:
+ var dist := mob_pos.distance_to(spawn_pos)
+ return dist > max_leash
+
+## 执行拉脱脱战重置 (40250 char_state.cpp 怪物脱战回位并回满生命值)
+static func perform_leash_reset(mob_data: Dictionary, spawn_pos: Vector3) -> Dictionary:
+ var max_hp := int(mob_data.get("max_hp", 1000))
+ mob_data["hp"] = max_hp
+ mob_data["target_vid"] = 0
+ mob_data["state"] = MobAIState.RETREAT
+ return {
+ "leashed": true,
+ "reset_hp": max_hp,
+ "return_pos": spawn_pos,
+ "msg": "怪物超出追击范围,脱离战斗并回满生命值返回出生点!"
+ }
+
+## 计算普通连击与受击反应 (40250 battle.cpp: 第1-2击普通受击,第3-4击击退击倒)
+## combo_step: 1..4 当前连击段数
+## attack_heading_rad: 攻击朝向弧度 (用于计算受力击退向量)
+static func calculate_combo_reaction(combo_step: int, mob_pos: Vector3, attack_heading_rad: float) -> Dictionary:
+ var normalized_step := ((combo_step - 1) % 4) + 1
+ if normalized_step in [1, 2]:
+ # 前两击:普通受击硬直 (HitReaction.good)
+ return {
+ "reaction_type": "DAMAGE_STUN",
+ "is_knockback": false,
+ "push_offset": Vector3.ZERO,
+ "anim": "damage",
+ "stun_duration": 0.2, # 0.2 秒受击硬直
+ }
+ else:
+ # 第三、四击:强力击退并击倒 (HitReaction.greate)
+ # 顺着攻击朝向将怪物向后击退 1.5 米
+ var push_dir := Vector3(sin(attack_heading_rad), 0, cos(attack_heading_rad)).normalized()
+ var push_vec := push_dir * KNOCKBACK_FORCE_M
+ var new_pos := mob_pos + push_vec
+ return {
+ "reaction_type": "KNOCKBACK_FLYING",
+ "is_knockback": true,
+ "push_offset": push_vec,
+ "target_pos": new_pos,
+ "anim": "knockdown",
+ "standup_anim": "standup",
+ "knockdown_duration": 0.8, # 倒地至起身耗时
+ }
diff --git a/project/mob_combat_reaction_system.gd.uid b/project/mob_combat_reaction_system.gd.uid
new file mode 100644
index 00000000..5f29d241
--- /dev/null
+++ b/project/mob_combat_reaction_system.gd.uid
@@ -0,0 +1 @@
+uid://cn2gu25b1jmb1
diff --git a/project/mob_death_drop_test.gd b/project/mob_death_drop_test.gd
new file mode 100644
index 00000000..91640f57
--- /dev/null
+++ b/project/mob_death_drop_test.gd
@@ -0,0 +1,194 @@
+extends SceneTree
+
+const NetWorld = preload("res://net_world.gd")
+const GroundItems = preload("res://ui/ground_items.gd")
+const PlayerController = preload("res://player_controller.gd")
+
+class MockClient extends Node:
+ signal entity_spawned(e: Dictionary)
+ signal entity_despawned(vid: int)
+ signal entity_dead(vid: int)
+ signal entity_moved(vid: int)
+ signal entity_main_set(vid: int)
+ signal vitals_updated(vid: int)
+ signal vitals_changed(vid: int)
+ signal warp_requested(x: float, y: float)
+ signal ground_item_added(item: Dictionary)
+ signal ground_item_removed(vid: int)
+ signal chat(mode: int, text: String)
+ signal chat_bubble(vid: int, text: String)
+ signal damage(target_vid: int, attacker_vid: int, damage_val: int, flag: int)
+ signal whisper_received(author: String, text: String)
+
+ var _main_vid := 1
+ var picked_vids: Array[int] = []
+
+ func get_main_vid() -> int:
+ return _main_vid
+
+ func get_entity(vid: int) -> Dictionary:
+ return {"vid": vid, "pos": Vector3.ZERO, "race": 101, "name": "Wild Dog", "hp": 100, "max_hp": 100}
+
+ func get_ground_items() -> Array:
+ return []
+
+ func pickup_item(vid: int) -> bool:
+ picked_vids.append(vid)
+ return true
+
+class MockWorld extends Node:
+ func sample_height(x: float, z: float) -> float:
+ return 5.5
+
+func _init() -> void:
+ print("--- Running mob_death_drop_test ---")
+ test_mob_death_and_despawn()
+ test_player_controller_ignores_dead()
+ test_ground_items_coordinate_mapping()
+ print("mob_death_drop_test passed with 0 failures!")
+ quit(0)
+
+func test_mob_death_and_despawn() -> void:
+ var client = MockClient.new()
+ var mount = Node3D.new()
+ root.add_child(mount)
+
+ var net_world = NetWorld.new()
+ root.add_child(net_world)
+ net_world.setup(client, mount)
+
+ # Spawn a mob with tags
+ var mob_vid := 100
+ var mob_node := Node3D.new()
+ mob_node.name = "entity_%d" % mob_vid
+ var name_tag := Label3D.new()
+ name_tag.name = "NameTag"
+ name_tag.text = "Wild Dog"
+ mob_node.add_child(name_tag)
+ var shadow := Decal.new()
+ shadow.name = "Shadow"
+ mob_node.add_child(shadow)
+ mount.add_child(mob_node)
+
+ net_world._by_vid[mob_vid] = mob_node
+ net_world._make_hp_bar(mob_vid, {"hp": 100, "max_hp": 100, "name": "Wild Dog"})
+ net_world.set_target_vid(mob_vid)
+ net_world.set_hover_vid(mob_vid)
+
+ assert(net_world._hp_bars.has(mob_vid), "HP bar should exist initially")
+ assert(net_world._target_vid == mob_vid, "Target should be mob_vid")
+ assert(net_world._hover_vid == mob_vid, "Hover should be mob_vid")
+ assert(name_tag.visible == true, "NameTag should be visible initially")
+
+ # Trigger death
+ net_world._on_dead(mob_vid)
+
+ assert(not net_world._hp_bars.has(mob_vid), "HP bar should be removed immediately on death")
+ assert(name_tag.visible == false, "NameTag should be detached/hidden on death")
+ assert(shadow.visible == false, "Shadow should be hidden on death")
+ assert(bool(mob_node.get_meta("dead", false)) == true, "Mob should have dead meta")
+ assert(net_world._target_vid == 0, "Target should be cleared on death")
+ assert(net_world._hover_vid == 0, "Hover should be cleared on death")
+
+ # Trigger despawn
+ net_world._on_despawn(mob_vid)
+ assert(not net_world._by_vid.has(mob_vid), "Mob should be erased from _by_vid on despawn")
+ print("OK: Mob death detach & despawn lifecycle verified")
+
+ net_world.queue_free()
+ mount.queue_free()
+ client.queue_free()
+
+func test_player_controller_ignores_dead() -> void:
+ var pc = PlayerController.new()
+ var player = Node3D.new()
+ player.position = Vector3(0, 0, 0)
+ root.add_child(player)
+ pc.player = player
+
+ var dead_mob = Node3D.new()
+ dead_mob.position = Vector3(0, 0, 2)
+ dead_mob.set_meta("dead", true)
+ root.add_child(dead_mob)
+
+ pc.pickables.clear()
+ pc.pickables.append(dead_mob)
+
+ # Test collision check (line 317)
+ var blocked: bool = pc._actor_blocked(Vector3(0, 0, 1.8))
+ assert(not blocked, "Dead mob should not block player movement")
+
+ print("OK: PlayerController ignores dead mobs for collision and picking")
+ pc.queue_free()
+ player.queue_free()
+ dead_mob.queue_free()
+
+func test_ground_items_coordinate_mapping() -> void:
+ # Set map base position (e.g. Map 1: 400000 cm X, 300000 cm Y)
+ MapCoord.set_base(Vector2(400000, 300000))
+
+ var client = MockClient.new()
+ var mount = Node3D.new()
+ root.add_child(mount)
+
+ var world = MockWorld.new()
+ root.add_child(world)
+
+ var player = Node3D.new()
+ player.position = Vector3(100, 5.5, 200) # Player is at world (100, 5.5, 200)
+ root.add_child(player)
+
+ var gi = GroundItems.new()
+ root.add_child(gi)
+
+ gi.setup(client, mount, func() -> Node: return player, null, null,
+ Callable(), "", func() -> Node: return world)
+
+ # Net position from server:
+ # Metin2 global: X = 410000 cm, Y = 320000 cm (local world should be: X = 100m, Z = 200m)
+ # M2Client position_to_godot produces:
+ # net.x = 4100.0, net.y = 0.0, net.z = -3200.0
+ var net_drop_pos := Vector3(4100.0, 0.0, -3200.0)
+ var drop_vid := 9999
+ var drop_dict := {
+ "vid": drop_vid,
+ "vnum": 1, # Yang / Gold
+ "owner": "Hero",
+ "pos": net_drop_pos
+ }
+
+ gi._on_added(drop_dict)
+
+ assert(gi._by_vid.has(drop_vid), "Ground item should be registered in _by_vid")
+ var drop_node: Node3D = gi._by_vid[drop_vid]
+ assert(drop_node != null, "Drop node should exist")
+
+ # Check world coordinate translation
+ assert(is_equal_approx(drop_node.position.x, 100.0), "X should be translated to 100m")
+ assert(is_equal_approx(drop_node.position.z, 200.0), "Z should be translated to 200m")
+ # Check terrain height snapping (+0.1m above 5.5m = 5.6m)
+ assert(is_equal_approx(drop_node.position.y, 5.6), "Y should be snapped to terrain height + 0.1m")
+
+ # Check item name for vnum 1
+ var tag := drop_node.get_node("tag") as Label3D
+ assert(tag != null and tag.text == "Yang", "Vnum 1 name should be Yang")
+
+ # Test pickup with player near (distance = 0.1m < PICKUP_RANGE 3.0m)
+ var picked_vid := gi.try_pickup()
+ assert(picked_vid == drop_vid, "try_pickup should pick nearby item")
+ assert(client.picked_vids.has(drop_vid), "client.pickup_item should be called")
+
+ # Test remove
+ gi._on_removed(drop_vid)
+ assert(not gi._by_vid.has(drop_vid), "Item should be removed from _by_vid")
+
+ print("OK: Ground items coordinate mapping, height snapping, Yang naming, and pickup verified")
+
+ # Reset MapCoord base
+ MapCoord.set_base(Vector2.ZERO)
+
+ gi.queue_free()
+ world.queue_free()
+ player.queue_free()
+ mount.queue_free()
+ client.queue_free()
diff --git a/project/mob_death_drop_test.gd.uid b/project/mob_death_drop_test.gd.uid
new file mode 100644
index 00000000..f563a0be
--- /dev/null
+++ b/project/mob_death_drop_test.gd.uid
@@ -0,0 +1 @@
+uid://bys5ua0nphks7
diff --git a/project/mob_drop_scatter.gd b/project/mob_drop_scatter.gd
new file mode 100644
index 00000000..8b3f478a
--- /dev/null
+++ b/project/mob_drop_scatter.gd
@@ -0,0 +1,64 @@
+# mob_drop_scatter.gd —— 40250 怪物死亡爆装与辐射散射 1:1 规则引擎
+# 严格对照:
+# metin2/src/server/game/src/char_battle.cpp:840-930 (pos.x = number(-7, 7) * 20)
+# metin2/src/server/game/src/item.cpp:SetOwnership (ITEM_OWNERSHIP_TIME = 30)
+extends RefCounted
+
+const ITEM_OWNERSHIP_DURATION := 30.0 # 40250 官方 30 秒拾取归属保护期
+const CM_TO_METERS := 0.01
+
+# 单个掉落物基础散射偏移区间:[-140cm, +140cm],即 [-1.4m, +1.4m]
+const MIN_SCATTER_CM := -140
+const MAX_SCATTER_CM := 140
+
+const SOUND_MONEY := "sound/ui/money.wav"
+const SOUND_DROP := "sound/ui/drop.wav"
+
+# 计算第 index 个掉落物相对于怪物中心的世界偏移量
+# 1:1 对齐 char_battle.cpp: number(-7, 7) * 20cm
+static func calculate_scatter_offset(index: int, total_count: int, seed_val := -1) -> Vector3:
+ if total_count <= 1:
+ # 单个物品:在 [-1.4m, +1.4m] 范围内轻微偏移
+ var rng := RandomNumberGenerator.new()
+ if seed_val >= 0:
+ rng.seed = seed_val
+ else:
+ rng.randomize()
+ var dx: float = (rng.randi_range(-7, 7) * 20) * CM_TO_METERS
+ var dz: float = (rng.randi_range(-7, 7) * 20) * CM_TO_METERS
+ return Vector3(dx, 0.0, dz)
+
+ # 多个物品(首领爆装):360 度环状辐射发散,半径 1.0m ~ 1.8m
+ var angle_step := TAU / float(maxi(1, total_count))
+ var base_angle := angle_step * float(index)
+ var rng := RandomNumberGenerator.new()
+ if seed_val >= 0:
+ rng.seed = seed_val + index * 37
+ else:
+ rng.randomize()
+
+ # 半径在 1.0m ~ 1.6m 之间带微量抖动
+ var radius := 1.0 + rng.randf_range(0.0, 0.6)
+ var jitter_angle := rng.randf_range(-0.2, 0.2)
+ var angle := base_angle + jitter_angle
+
+ var dx: float = cos(angle) * radius
+ var dz: float = sin(angle) * radius
+ return Vector3(dx, 0.0, dz)
+
+# 计算最终落地世界坐标
+static func calculate_drop_position(center_pos: Vector3, index: int, total_count: int, seed_val := -1) -> Vector3:
+ var offset := calculate_scatter_offset(index, total_count, seed_val)
+ return center_pos + offset
+
+# 归属保护期判定
+static func is_ownership_protected(drop_time: float, current_time: float) -> bool:
+ return (current_time - drop_time) < ITEM_OWNERSHIP_DURATION
+
+# 剩余归属秒数
+static func remaining_ownership_time(drop_time: float, current_time: float) -> float:
+ return maxf(0.0, ITEM_OWNERSHIP_DURATION - (current_time - drop_time))
+
+# 匹配掉落音效
+static func get_drop_sound(is_gold: bool) -> String:
+ return SOUND_MONEY if is_gold else SOUND_DROP
diff --git a/project/mob_drop_scatter.gd.uid b/project/mob_drop_scatter.gd.uid
new file mode 100644
index 00000000..877563f8
--- /dev/null
+++ b/project/mob_drop_scatter.gd.uid
@@ -0,0 +1 @@
+uid://dkahpfg32tsx1
diff --git a/project/monarch_election_ui_system.gd b/project/monarch_election_ui_system.gd
new file mode 100644
index 00000000..682ebf89
--- /dev/null
+++ b/project/monarch_election_ui_system.gd
@@ -0,0 +1,230 @@
+# monarch_election_ui_system.gd —— 40250 帝国君主大选投票公示系统 1:1
+# 严格对照:
+# metin2/src/server/game/src/monarch.h, monarch.cpp (CMonarch)
+# metin2/src/server/share/locale/english/quest/xxx_monarch.quest
+# 40250/Server Client TMP4/mysql/player/monarch_candidacy.MYD, monarch_election.MYD
+extends RefCounted
+
+const EMPIRE_SHINSOO := 1 # 赤蛇国 (进国)
+const EMPIRE_CHUNJO := 2 # 白虎国 (顺天)
+const EMPIRE_JINNO := 3 # 苍龙国 (忍国)
+
+const STATUS_CLOSED := 0 # 选举未开放
+const STATUS_CANDIDACY := 1 # 候选人报名登记期
+const STATUS_VOTING := 2 # 全民大选投票期
+const STATUS_CONCLUDED := 3 # 大选揭晓,君主即位
+
+const MIN_CANDIDATE_LEVEL := 50
+const MIN_VOTER_LEVEL := 50
+const CANDIDACY_FEE := 1000000 # 报名保证金 1,000,000 金币
+const MAX_CANDIDATES_PER_EMPIRE := 8 # 每国最多 8 名候选人
+
+class CandidateInfo:
+ var pid: int
+ var name: String
+ var empire: int
+ var level: int
+ var votes: int = 0
+ var manifesto: String = ""
+
+ func _init(p_pid: int, p_name: String, p_emp: int, p_lvl: int, p_manifesto: String) -> void:
+ pid = p_pid
+ name = p_name
+ empire = p_emp
+ level = p_lvl
+ votes = 0
+ manifesto = p_manifesto
+
+# 选举状态字典: empire_id -> status
+var election_states: Dictionary = {
+ EMPIRE_SHINSOO: STATUS_CLOSED,
+ EMPIRE_CHUNJO: STATUS_CLOSED,
+ EMPIRE_JINNO: STATUS_CLOSED
+}
+
+# 候选人名册: empire_id -> Array of CandidateInfo
+var candidates: Dictionary = {
+ EMPIRE_SHINSOO: [],
+ EMPIRE_CHUNJO: [],
+ EMPIRE_JINNO: []
+}
+
+# 选民投票记录: empire_id -> { voter_pid: candidate_pid }
+var voter_records: Dictionary = {
+ EMPIRE_SHINSOO: {},
+ EMPIRE_CHUNJO: {},
+ EMPIRE_JINNO: {}
+}
+
+# 现任君主数据: empire_id -> { "pid": int, "name": String, "treasury": int, "tax_rate": int }
+var current_monarchs: Dictionary = {
+ EMPIRE_SHINSOO: { "pid": 0, "name": "", "treasury": 10000000, "tax_rate": 3 },
+ EMPIRE_CHUNJO: { "pid": 0, "name": "", "treasury": 10000000, "tax_rate": 3 },
+ EMPIRE_JINNO: { "pid": 0, "name": "", "treasury": 10000000, "tax_rate": 3 },
+}
+
+func _init() -> void:
+ reset_all_elections()
+
+func reset_all_elections() -> void:
+ for emp in [EMPIRE_SHINSOO, EMPIRE_CHUNJO, EMPIRE_JINNO]:
+ election_states[emp] = STATUS_CLOSED
+ candidates[emp] = []
+ voter_records[emp] = {}
+
+# 1. 管理员开启大选阶段 (GM: Kaiser Wahl Einstellung)
+func set_election_stage(empire: int, new_status: int) -> bool:
+ if not election_states.has(empire):
+ return false
+ election_states[empire] = new_status
+ return true
+
+# 2. 候选人报名参选 (monarch_candidacy)
+func register_candidacy(
+ pid: int,
+ name: String,
+ empire: int,
+ level: int,
+ player_gold: int,
+ manifesto: String
+) -> Dictionary:
+ var res := { "success": false, "gold_cost": 0, "error_code": "OK", "message": "" }
+
+ if election_states.get(empire) != STATUS_CANDIDACY:
+ res["error_code"] = "CANDIDACY_NOT_OPEN"
+ res["message"] = "当前未处于君主参选登记期。"
+ return res
+
+ if level < MIN_CANDIDATE_LEVEL:
+ res["error_code"] = "LEVEL_TOO_LOW"
+ res["message"] = "参选君主等级必须达到 Lv 50。"
+ return res
+
+ if player_gold < CANDIDACY_FEE:
+ res["error_code"] = "INSUFFICIENT_GOLD"
+ res["message"] = "报名参选需缴纳 1,000,000 金币保证金。"
+ return res
+
+ var emp_candidates: Array = candidates[empire]
+ if emp_candidates.size() >= MAX_CANDIDATES_PER_EMPIRE:
+ res["error_code"] = "CANDIDATES_FULL"
+ res["message"] = "本届君主候选人名额已满 (最多 8 人)。"
+ return res
+
+ for c in emp_candidates:
+ if c.pid == pid:
+ res["error_code"] = "ALREADY_REGISTERED"
+ res["message"] = "您已成功报名,无需重复登记。"
+ return res
+
+ var cand = CandidateInfo.new(pid, name, empire, level, manifesto)
+ emp_candidates.append(cand)
+
+ res["success"] = true
+ res["gold_cost"] = CANDIDACY_FEE
+ res["message"] = "恭喜!您已成功登记为第 %d 帝国君主候选人。" % empire
+ return res
+
+# 3. 选民投票 (monarch_election)
+func cast_vote(voter_pid: int, voter_empire: int, voter_level: int, target_candidate_pid: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if election_states.get(voter_empire) != STATUS_VOTING:
+ res["error_code"] = "VOTING_NOT_OPEN"
+ res["message"] = "当前未处于全民投票大选阶段。"
+ return res
+
+ if voter_level < MIN_VOTER_LEVEL:
+ res["error_code"] = "VOTER_LEVEL_TOO_LOW"
+ res["message"] = "等级未达到 Lv 50,尚无大选投票权。"
+ return res
+
+ var records: Dictionary = voter_records[voter_empire]
+ if records.has(voter_pid):
+ res["error_code"] = "ALREADY_VOTED"
+ res["message"] = "您在本届选举中已经投过神圣的一票,不可重复投票。"
+ return res
+
+ # 查找目标候选人
+ var target_cand: CandidateInfo = null
+ for c in candidates[voter_empire]:
+ if c.pid == target_candidate_pid:
+ target_cand = c
+ break
+
+ if target_cand == null:
+ res["error_code"] = "CANDIDATE_NOT_FOUND"
+ res["message"] = "未找到该候选人。"
+ return res
+
+ # 计票
+ target_cand.votes += 1
+ records[voter_pid] = target_candidate_pid
+
+ res["success"] = true
+ res["message"] = "投票成功!感谢您支持候选人 %s。" % target_cand.name
+ return res
+
+# 4. 统计揭晓并任命新君主
+func conclude_election(empire: int) -> Dictionary:
+ var res := { "success": false, "winner_pid": 0, "winner_name": "", "votes": 0 }
+ if not election_states.has(empire):
+ return res
+
+ var emp_candidates: Array = candidates[empire]
+ if emp_candidates.is_empty():
+ election_states[empire] = STATUS_CLOSED
+ return res
+
+ # 选出最高票者
+ var top_cand: CandidateInfo = emp_candidates[0]
+ for c in emp_candidates:
+ if c.votes > top_cand.votes:
+ top_cand = c
+
+ # 就任君主
+ current_monarchs[empire]["pid"] = top_cand.pid
+ current_monarchs[empire]["name"] = top_cand.name
+ election_states[empire] = STATUS_CONCLUDED
+
+ res["success"] = true
+ res["winner_pid"] = top_cand.pid
+ res["winner_name"] = top_cand.name
+ res["votes"] = top_cand.votes
+ return res
+
+# 5. 君主行使特权:调整帝国商业税率 (1% ~ 10%)
+func set_tax_rate(caller_pid: int, empire: int, new_tax: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+ var monarch = current_monarchs[empire]
+
+ if caller_pid != monarch["pid"]:
+ res["error_code"] = "NOT_MONARCH"
+ res["message"] = "只有现任帝国君主有权调整商业税率。"
+ return res
+
+ var clamped_tax: int = clamp(new_tax, 1, 10)
+ monarch["tax_rate"] = clamped_tax
+
+ res["success"] = true
+ res["tax_rate"] = clamped_tax
+ res["message"] = "帝国商业税率已调整为 %d%%。" % clamped_tax
+ return res
+
+# 6. 君主行使特权:释放全帝国神佑祝福
+func activate_monarch_blessing(caller_pid: int, empire: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "blessing_buff": {}, "message": "" }
+ var monarch = current_monarchs[empire]
+
+ if caller_pid != monarch["pid"]:
+ res["error_code"] = "NOT_MONARCH"
+ return res
+
+ res["success"] = true
+ res["blessing_buff"] = {
+ "exp_bonus": 10,
+ "att_bonus": 10,
+ "duration_sec": 7200
+ }
+ res["message"] = "君主神佑降临!帝国全体子民获得 +10% 经验与 +10% 攻击力加成 (持续2小时)。"
+ return res
diff --git a/project/monarch_election_ui_system.gd.uid b/project/monarch_election_ui_system.gd.uid
new file mode 100644
index 00000000..8c68b46f
--- /dev/null
+++ b/project/monarch_election_ui_system.gd.uid
@@ -0,0 +1 @@
+uid://hy3q4nc8nwif
diff --git a/project/monarch_empire_treasury_system.gd b/project/monarch_empire_treasury_system.gd
new file mode 100644
index 00000000..5cfb9b43
--- /dev/null
+++ b/project/monarch_empire_treasury_system.gd
@@ -0,0 +1,294 @@
+# monarch_empire_treasury_system.gd —— Metin2 40250 官方君主国王特权与王国国库祈愿系统 1:1
+# 100% 对照 40250 服务端 monarch.cpp, monarch.h, quest/monarch.quest, tables.h
+class_name MonarchEmpireTreasurySystem
+extends RefCounted
+
+signal monarch_healed_empire(empire: int, cost: int, restored_citizens_count: int)
+signal monarch_power_up_activated(empire: int, cost: int, duration: float)
+signal monarch_defense_up_activated(empire: int, cost: int, duration: float)
+signal treasury_tax_collected(empire: int, amount: int, new_balance: int)
+signal monarch_appointed(empire: int, player_id: int, player_name: String)
+signal monarch_error(reason: String)
+
+# 40250 官方三大帝国常数
+const EMPIRE_NONE := 0
+const EMPIRE_SHINSOO := 1 # 辛秀帝国 (红国)
+const EMPIRE_CHUNJO := 2 # 春祖帝国 (黄国)
+const EMPIRE_JINNO := 3 # 镇奴帝国 (蓝国)
+
+const EMPIRE_NAMES: Dictionary = {
+ EMPIRE_SHINSOO: "辛秀帝国 (Shinsoo - 红国)",
+ EMPIRE_CHUNJO: "春祖帝国 (Chunjo - 黄国)",
+ EMPIRE_JINNO: "镇奴帝国 (Jinno - 蓝国)"
+}
+
+# 国库金币上限 (40250 monarch.cpp: 20亿上限)
+const MAX_TREASURY_GOLD := 2000000000
+
+# 技能费用与冷却
+const COST_HEAL_EMPIRE := 100000 # 恩泽圣愈国库消耗 100,000 金币
+const COST_POWER_UP := 500000 # 帝国狂暴国库消耗 500,000 金币
+const COST_DEFENSE_UP := 500000 # 铁壁光环国库消耗 500,000 金币
+
+const COOLDOWN_HEAL_EMPIRE := 600.0 # 恩泽圣愈冷却 10 分钟 (600 秒)
+const COOLDOWN_POWER_UP := 600.0 # 战力觉醒冷却 10 分钟
+const COOLDOWN_DEFENSE_UP := 600.0 # 铁壁守护冷却 10 分钟
+
+const DURATION_POWER_UP := 1800.0 # 战力狂暴持续 30 分钟 (1800 秒)
+const DURATION_DEFENSE_UP := 1800.0 # 铁壁守护持续 30 分钟
+
+# 数据存储
+var _monarch_pids: Dictionary = {1: 0, 2: 0, 3: 0} # empire -> player_id
+var _monarch_names: Dictionary = {1: "", 2: "", 3: ""}
+var _treasury: Dictionary = {1: 0, 2: 0, 3: 0} # empire -> gold
+
+var _heal_cooldown: Dictionary = {1: 0.0, 2: 0.0, 3: 0.0}
+var _power_up_cooldown: Dictionary = {1: 0.0, 2: 0.0, 3: 0.0}
+var _defense_up_cooldown: Dictionary = {1: 0.0, 2: 0.0, 3: 0.0}
+
+var _power_up_active: Dictionary = {1: false, 2: false, 3: false}
+var _defense_up_active: Dictionary = {1: false, 2: false, 3: false}
+
+var _power_up_timer: Dictionary = {1: 0.0, 2: 0.0, 3: 0.0}
+var _defense_up_timer: Dictionary = {1: 0.0, 2: 0.0, 3: 0.0}
+
+# ==========================================
+# 1. 国王任命与身份核验 (monarch.cpp: IsMonarch)
+# ==========================================
+func appoint_monarch(empire: int, player_id: int, player_name: String) -> bool:
+ if not _is_valid_empire(empire):
+ monarch_error.emit("INVALID_EMPIRE")
+ return false
+ _monarch_pids[empire] = player_id
+ _monarch_names[empire] = player_name
+ monarch_appointed.emit(empire, player_id, player_name)
+ return true
+
+func is_monarch(player_id: int, empire: int) -> bool:
+ if not _is_valid_empire(empire):
+ return false
+ return int(_monarch_pids.get(empire, 0)) == player_id
+
+func get_monarch_info(empire: int) -> Dictionary:
+ if not _is_valid_empire(empire):
+ return {}
+ return {
+ "empire": empire,
+ "empire_name": EMPIRE_NAMES[empire],
+ "player_id": _monarch_pids.get(empire, 0),
+ "player_name": _monarch_names.get(empire, ""),
+ "treasury_gold": _treasury.get(empire, 0)
+ }
+
+# ==========================================
+# 2. 国库金币与税赋 (monarch.cpp: AddMoney, DecMoney)
+# ==========================================
+func get_treasury_gold(empire: int) -> int:
+ if not _is_valid_empire(empire):
+ return 0
+ return int(_treasury.get(empire, 0))
+
+func add_treasury_gold(empire: int, amount: int) -> bool:
+ if not _is_valid_empire(empire) or amount <= 0:
+ return false
+ var current = int(_treasury.get(empire, 0))
+ var new_val = min(current + amount, MAX_TREASURY_GOLD)
+ _treasury[empire] = new_val
+ return true
+
+func collect_monster_tax(empire: int, monster_gold_drop: int, tax_rate: float = 0.02) -> int:
+ if not _is_valid_empire(empire) or monster_gold_drop <= 0:
+ return 0
+ var tax = int(floor(monster_gold_drop * tax_rate))
+ if tax > 0:
+ add_treasury_gold(empire, tax)
+ treasury_tax_collected.emit(empire, tax, _treasury[empire])
+ return tax
+
+func dec_treasury_gold(empire: int, amount: int) -> bool:
+ if not _is_valid_empire(empire) or amount <= 0:
+ return false
+ var current = int(_treasury.get(empire, 0))
+ if current < amount:
+ return false
+ _treasury[empire] = current - amount
+ return true
+
+# ==========================================
+# 3. 君主全境治国神技 (monarch.cpp: HealMyEmpire, PowerUp, DefenseUp)
+# ==========================================
+
+# 恩泽圣愈:全境国民 HP/SP 瞬间全满 (FHealMyEmpire)
+func heal_my_empire(
+ player_id: int,
+ empire: int,
+ online_citizens: Array[Dictionary]
+) -> Dictionary:
+ if not is_monarch(player_id, empire):
+ monarch_error.emit("NOT_MONARCH")
+ return {"ok": false, "reason": "NOT_MONARCH", "msg": "你并非本帝国加冕的君主,无权调动国库施加恩泽!"}
+
+ if float(_heal_cooldown.get(empire, 0.0)) > 0.0:
+ monarch_error.emit("COOLDOWN_ACTIVE")
+ return {
+ "ok": false,
+ "reason": "COOLDOWN_ACTIVE",
+ "msg": "恩泽圣愈冷却中!还需等待 %d 秒。" % int(_heal_cooldown[empire])
+ }
+
+ if get_treasury_gold(empire) < COST_HEAL_EMPIRE:
+ monarch_error.emit("NOT_ENOUGH_TREASURY")
+ return {
+ "ok": false,
+ "reason": "NOT_ENOUGH_TREASURY",
+ "msg": "国库资金匮乏!施展恩泽圣愈需要 %d 国库金币,当前只有 %d 金币。" % [COST_HEAL_EMPIRE, get_treasury_gold(empire)]
+ }
+
+ # 扣除国库金币
+ dec_treasury_gold(empire, COST_HEAL_EMPIRE)
+ _heal_cooldown[empire] = COOLDOWN_HEAL_EMPIRE
+
+ # 治愈同帝国所有国民 (40250 ch->PointChange(POINT_HP, max - hp))
+ var healed_count := 0
+ for citizen in online_citizens:
+ if int(citizen.get("empire", 0)) == empire:
+ citizen["hp"] = citizen.get("max_hp", 1000)
+ citizen["sp"] = citizen.get("max_sp", 500)
+ healed_count += 1
+
+ monarch_healed_empire.emit(empire, COST_HEAL_EMPIRE, healed_count)
+ return {
+ "ok": true,
+ "healed_count": healed_count,
+ "remaining_treasury": get_treasury_gold(empire),
+ "msg": "君主高举天子权杖,浩荡圣光普照四野!全境 %d 位国民生命法力全部盈满!" % healed_count
+ }
+
+# 帝国战力狂暴 (PowerUp: 攻击力 +10%)
+func activate_power_up(player_id: int, empire: int) -> Dictionary:
+ if not is_monarch(player_id, empire):
+ monarch_error.emit("NOT_MONARCH")
+ return {"ok": false, "reason": "NOT_MONARCH", "msg": "只有君主方可激发帝国战力觉醒!"}
+
+ if float(_power_up_cooldown.get(empire, 0.0)) > 0.0:
+ monarch_error.emit("COOLDOWN_ACTIVE")
+ return {
+ "ok": false,
+ "reason": "COOLDOWN_ACTIVE",
+ "msg": "帝国狂暴冷却中!还需等待 %d 秒。" % int(_power_up_cooldown[empire])
+ }
+
+ if get_treasury_gold(empire) < COST_POWER_UP:
+ monarch_error.emit("NOT_ENOUGH_TREASURY")
+ return {"ok": false, "reason": "NOT_ENOUGH_TREASURY", "msg": "国库资金不足 %d 金币!" % COST_POWER_UP}
+
+ dec_treasury_gold(empire, COST_POWER_UP)
+ _power_up_cooldown[empire] = COOLDOWN_POWER_UP
+ _power_up_active[empire] = true
+ _power_up_timer[empire] = DURATION_POWER_UP
+
+ monarch_power_up_activated.emit(empire, COST_POWER_UP, DURATION_POWER_UP)
+ return {
+ "ok": true,
+ "duration": DURATION_POWER_UP,
+ "att_grade_bonus": 0.10,
+ "remaining_treasury": get_treasury_gold(empire),
+ "msg": "战神咆哮!君主调动帝国龙脉之力,全体国民攻击力提升 10%,持续 30 分钟!"
+ }
+
+# 帝国铁壁守护 (DefenseUp: 防御力 +10%)
+func activate_defense_up(player_id: int, empire: int) -> Dictionary:
+ if not is_monarch(player_id, empire):
+ monarch_error.emit("NOT_MONARCH")
+ return {"ok": false, "reason": "NOT_MONARCH", "msg": "只有君主方可激发帝国铁壁守护!"}
+
+ if float(_defense_up_cooldown.get(empire, 0.0)) > 0.0:
+ monarch_error.emit("COOLDOWN_ACTIVE")
+ return {
+ "ok": false,
+ "reason": "COOLDOWN_ACTIVE",
+ "msg": "铁壁守护冷却中!还需等待 %d 秒。" % int(_defense_up_cooldown[empire])
+ }
+
+ if get_treasury_gold(empire) < COST_DEFENSE_UP:
+ monarch_error.emit("NOT_ENOUGH_TREASURY")
+ return {"ok": false, "reason": "NOT_ENOUGH_TREASURY", "msg": "国库资金不足 %d 金币!" % COST_DEFENSE_UP}
+
+ dec_treasury_gold(empire, COST_DEFENSE_UP)
+ _defense_up_cooldown[empire] = COOLDOWN_DEFENSE_UP
+ _defense_up_active[empire] = true
+ _defense_up_timer[empire] = DURATION_DEFENSE_UP
+
+ monarch_defense_up_activated.emit(empire, COST_DEFENSE_UP, DURATION_DEFENSE_UP)
+ return {
+ "ok": true,
+ "duration": DURATION_DEFENSE_UP,
+ "def_grade_bonus": 0.10,
+ "remaining_treasury": get_treasury_gold(empire),
+ "msg": "坚不可摧!君主召唤玄龟金钟大阵,全体国民防御力提升 10%,持续 30 分钟!"
+ }
+
+# 查询帝国国民当前享受的光环加成
+func get_citizen_empire_buffs(empire: int) -> Dictionary:
+ if not _is_valid_empire(empire):
+ return {"att_bonus": 0.0, "def_bonus": 0.0}
+ return {
+ "att_bonus": 0.10 if bool(_power_up_active.get(empire, false)) else 0.0,
+ "def_bonus": 0.10 if bool(_defense_up_active.get(empire, false)) else 0.0,
+ "power_up_time_left": float(_power_up_timer.get(empire, 0.0)),
+ "defense_up_time_left": float(_defense_up_timer.get(empire, 0.0))
+ }
+
+# 定时器时间步进
+func update(delta: float) -> void:
+ for emp in [1, 2, 3]:
+ # 冷却倒计时
+ if _heal_cooldown[emp] > 0.0:
+ _heal_cooldown[emp] = max(_heal_cooldown[emp] - delta, 0.0)
+ if _power_up_cooldown[emp] > 0.0:
+ _power_up_cooldown[emp] = max(_power_up_cooldown[emp] - delta, 0.0)
+ if _defense_up_cooldown[emp] > 0.0:
+ _defense_up_cooldown[emp] = max(_defense_up_cooldown[emp] - delta, 0.0)
+
+ # 增益持续倒计时
+ if _power_up_active[emp]:
+ _power_up_timer[emp] -= delta
+ if _power_up_timer[emp] <= 0.0:
+ _power_up_timer[emp] = 0.0
+ _power_up_active[emp] = false
+
+ if _defense_up_active[emp]:
+ _defense_up_timer[emp] -= delta
+ if _defense_up_timer[emp] <= 0.0:
+ _defense_up_timer[emp] = 0.0
+ _defense_up_active[emp] = false
+
+func _is_valid_empire(empire: int) -> bool:
+ return empire == EMPIRE_SHINSOO or empire == EMPIRE_CHUNJO or empire == EMPIRE_JINNO
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "monarch_pids": _monarch_pids.duplicate(),
+ "monarch_names": _monarch_names.duplicate(),
+ "treasury": _treasury.duplicate(),
+ "heal_cooldown": _heal_cooldown.duplicate(),
+ "power_up_cooldown": _power_up_cooldown.duplicate(),
+ "defense_up_cooldown": _defense_up_cooldown.duplicate(),
+ "power_up_active": _power_up_active.duplicate(),
+ "defense_up_active": _defense_up_active.duplicate(),
+ "power_up_timer": _power_up_timer.duplicate(),
+ "defense_up_timer": _defense_up_timer.duplicate()
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _monarch_pids = data.get("monarch_pids", {1: 0, 2: 0, 3: 0}).duplicate()
+ _monarch_names = data.get("monarch_names", {1: "", 2: "", 3: ""}).duplicate()
+ _treasury = data.get("treasury", {1: 0, 2: 0, 3: 0}).duplicate()
+ _heal_cooldown = data.get("heal_cooldown", {1: 0.0, 2: 0.0, 3: 0.0}).duplicate()
+ _power_up_cooldown = data.get("power_up_cooldown", {1: 0.0, 2: 0.0, 3: 0.0}).duplicate()
+ _defense_up_cooldown = data.get("defense_up_cooldown", {1: 0.0, 2: 0.0, 3: 0.0}).duplicate()
+ _power_up_active = data.get("power_up_active", {1: false, 2: false, 3: false}).duplicate()
+ _defense_up_active = data.get("defense_up_active", {1: false, 2: false, 3: false}).duplicate()
+ _power_up_timer = data.get("power_up_timer", {1: 0.0, 2: 0.0, 3: 0.0}).duplicate()
+ _defense_up_timer = data.get("defense_up_timer", {1: 0.0, 2: 0.0, 3: 0.0}).duplicate()
diff --git a/project/monarch_empire_treasury_system.gd.uid b/project/monarch_empire_treasury_system.gd.uid
new file mode 100644
index 00000000..2280e3f5
--- /dev/null
+++ b/project/monarch_empire_treasury_system.gd.uid
@@ -0,0 +1 @@
+uid://qwwogfll4yjo
diff --git a/project/monster_card_system.gd b/project/monster_card_system.gd
new file mode 100644
index 00000000..130dbea2
--- /dev/null
+++ b/project/monster_card_system.gd
@@ -0,0 +1,167 @@
+# monster_card_system.gd —— Metin2 40250 怪物图鉴与百科全书系统 1:1
+# 对照 40250 服务端 monster_card.cpp, uimonstercard.py, char_battle.cpp
+class_name MonsterCardSystem
+extends RefCounted
+
+signal card_collected(mob_vnum: int, mob_name: String, count: int)
+signal zone_completed(zone_name: String, bonuses: Dictionary)
+
+# 区域图鉴数据库配置
+const BESTIARY_DATA: Dictionary = {
+ "village": {
+ "zone_name": "新手原野",
+ "bonus": {"max_hp": 300},
+ "monsters": {
+ 101: {
+ "name": "野狼",
+ "level": 1,
+ "element": "无",
+ "weakness": "物理",
+ "drops": ["木剑+0", "小红药水"],
+ "card_vnum": 70201
+ },
+ 391: {
+ "name": "白灵女将",
+ "level": 25,
+ "element": "风",
+ "weakness": "火",
+ "drops": ["狼剑+9", "红药水(中)"],
+ "card_vnum": 70202
+ }
+ }
+ },
+ "desert": {
+ "zone_name": "永壁沙漠",
+ "bonus": {"monster_dmg_pct": 3},
+ "monsters": {
+ 2108: {
+ "name": "沙漠强盗",
+ "level": 50,
+ "element": "地",
+ "weakness": "风",
+ "drops": ["红药水(大)", "胡萝卜"],
+ "card_vnum": 70203
+ },
+ 2191: {
+ "name": "巨型沙漠龟",
+ "level": 67,
+ "element": "地",
+ "weakness": "风",
+ "drops": ["沙漠龟宝箱", "灵魂之石"],
+ "card_vnum": 70204
+ }
+ }
+ },
+ "catacomb": {
+ "zone_name": "地下墓穴",
+ "bonus": {"def": 50},
+ "monsters": {
+ 2597: {
+ "name": "塔耳塔洛斯",
+ "level": 82,
+ "element": "暗",
+ "weakness": "圣",
+ "drops": ["冷酷之石"],
+ "card_vnum": 70205
+ },
+ 2598: {
+ "name": "冥王阿兹瑞尔",
+ "level": 91,
+ "element": "暗",
+ "weakness": "圣",
+ "drops": ["阿兹瑞尔宝箱", "泰坦之盾"],
+ "card_vnum": 70206
+ }
+ }
+ }
+}
+
+var collected_cards: Dictionary = {} # mob_vnum -> count
+var completed_zones: Dictionary = {} # zone_key -> bool
+
+# 使用并收集怪物卡片
+func register_card(card_vnum: int) -> Dictionary:
+ # 查找对应怪物
+ var target_mob_vnum := -1
+ var target_mob_name := ""
+ var target_zone := ""
+
+ for z_key in BESTIARY_DATA.keys():
+ var z_data = BESTIARY_DATA[z_key]
+ for mv in z_data["monsters"].keys():
+ var minfo = z_data["monsters"][mv]
+ if minfo["card_vnum"] == card_vnum:
+ target_mob_vnum = mv
+ target_mob_name = minfo["name"]
+ target_zone = z_key
+ break
+ if target_mob_vnum != -1:
+ break
+
+ if target_mob_vnum == -1:
+ return {"ok": false, "reason": "INVALID_CARD", "msg": "这不是有效的怪物图鉴卡片!"}
+
+ var new_count: int = int(collected_cards.get(target_mob_vnum, 0)) + 1
+ collected_cards[target_mob_vnum] = new_count
+
+ card_collected.emit(target_mob_vnum, target_mob_name, new_count)
+
+ # 检查所属区域是否全部完成收集
+ var is_newly_completed := false
+ var zone_bonus: Dictionary = {}
+ if not completed_zones.get(target_zone, false):
+ var all_done := true
+ var z_mobs = BESTIARY_DATA[target_zone]["monsters"]
+ for m_id in z_mobs.keys():
+ if int(collected_cards.get(m_id, 0)) < 1:
+ all_done = false
+ break
+
+ if all_done:
+ completed_zones[target_zone] = true
+ is_newly_completed = true
+ zone_bonus = BESTIARY_DATA[target_zone]["bonus"]
+ zone_completed.emit(BESTIARY_DATA[target_zone]["zone_name"], zone_bonus)
+
+ return {
+ "ok": true,
+ "mob_vnum": target_mob_vnum,
+ "mob_name": target_mob_name,
+ "count": new_count,
+ "zone_completed": is_newly_completed,
+ "zone_bonus": zone_bonus,
+ "msg": "已将【%s】录入怪物图鉴!" % target_mob_name
+ }
+
+# 查看图鉴词条 (未解锁则模糊显示)
+func get_monster_entry(mob_vnum: int) -> Dictionary:
+ for z_data in BESTIARY_DATA.values():
+ if z_data["monsters"].has(mob_vnum):
+ var info: Dictionary = z_data["monsters"][mob_vnum].duplicate(true)
+ var count: int = int(collected_cards.get(mob_vnum, 0))
+ if count >= 1:
+ info["is_unlocked"] = true
+ info["collected_count"] = count
+ return info
+ else:
+ return {
+ "is_unlocked": false,
+ "name": "???",
+ "level": "??",
+ "element": "??",
+ "weakness": "??",
+ "drops": ["未知"],
+ "collected_count": 0
+ }
+
+ return {"is_unlocked": false, "reason": "NOT_FOUND"}
+
+# 汇总所有已完成区域图鉴的常驻被动战力加成
+func get_active_collection_bonuses() -> Dictionary:
+ var total_bonuses: Dictionary = {}
+ for z_key in completed_zones.keys():
+ if completed_zones[z_key]:
+ var b = BESTIARY_DATA[z_key]["bonus"]
+ for k in b.keys():
+ total_bonuses[k] = total_bonuses.get(k, 0) + int(b[k])
+ return total_bonuses
diff --git a/project/monster_card_system.gd.uid b/project/monster_card_system.gd.uid
new file mode 100644
index 00000000..01c60409
--- /dev/null
+++ b/project/monster_card_system.gd.uid
@@ -0,0 +1 @@
+uid://kqcj87jm8elo
diff --git a/project/mounted_combat_skill_system.gd b/project/mounted_combat_skill_system.gd
new file mode 100644
index 00000000..f887e19b
--- /dev/null
+++ b/project/mounted_combat_skill_system.gd
@@ -0,0 +1,224 @@
+# mounted_combat_skill_system.gd —— Metin2 40250 野外军马冲杀与马上技能协同打击 1:1
+# 对照 40250 服务端 char_horse.cpp, char_battle.cpp, skill.cpp
+class_name MountedCombatSkillSystem
+extends RefCounted
+
+signal horse_mounted(tier: int, horse_level: int, speed_bonus: int)
+signal horse_unmounted()
+signal mounted_attack_executed(target_count: int, total_dmg: int)
+signal horse_skill_cast(skill_vnum: int, skill_name: String, dmg: int, hit_count: int)
+signal horse_fed(item_name: String, recovered_stamina: int)
+
+enum HorseTier {
+ NONE = 0,
+ NORMAL = 1, # 1~10 级 (代步)
+ COMBAT = 2, # 11~20 级 (马上普攻)
+ MILITARY = 3 # 21~30 级 (马上 3 大绝技)
+}
+
+const SKILL_HORSE_CHARGE := 121 # 马上突刺 (Horse Charge)
+const SKILL_HORSE_STOMP := 122 # 踏马震地 (Horse Stomp)
+const SKILL_HORSE_WAVE := 123 # 马尾狂风 (Horse Wave)
+
+const HORSE_SKILLS: Dictionary = {
+ SKILL_HORSE_CHARGE: {
+ "name": "马背突刺",
+ "mult": 2.5,
+ "cd": 12.0,
+ "sp": 60,
+ "range": 12.0,
+ "desc": "战马极速直线突进贯穿群怪,造成击飞与重创"
+ },
+ SKILL_HORSE_STOMP: {
+ "name": "踏马震地",
+ "mult": 2.0,
+ "cd": 15.0,
+ "sp": 75,
+ "range": 6.0,
+ "desc": "战马双蹄扬起践踏大地,造成 360 度范围伤害与强力眩晕"
+ },
+ SKILL_HORSE_WAVE: {
+ "name": "马尾狂风",
+ "mult": 1.8,
+ "cd": 10.0,
+ "sp": 50,
+ "range": 5.0,
+ "desc": "战马回旋摆尾卷起狂风,大范围震退近身敌群"
+ }
+}
+
+const FOOD_ITEMS: Dictionary = {
+ 50054: {"name": "干草", "stamina": 30},
+ 50055: {"name": "胡萝卜", "stamina": 60},
+ 50056: {"name": "红参", "stamina": 100}
+}
+
+var is_mounted: bool = false
+var horse_level: int = 1
+var horse_tier: int = HorseTier.NONE
+var speed_bonus: int = 0
+var stamina: int = 100
+var max_stamina: int = 100
+
+var skill_cooldowns: Dictionary = {
+ SKILL_HORSE_CHARGE: 0.0,
+ SKILL_HORSE_STOMP: 0.0,
+ SKILL_HORSE_WAVE: 0.0
+}
+
+# 骑乘战马
+func mount_horse(level: int = 21) -> Dictionary:
+ if is_mounted:
+ return {"ok": false, "reason": "ALREADY_MOUNTED"}
+
+ horse_level = clamp(level, 1, 30)
+ if horse_level >= 21:
+ horse_tier = HorseTier.MILITARY
+ speed_bonus = 50
+ elif horse_level >= 11:
+ horse_tier = HorseTier.COMBAT
+ speed_bonus = 40
+ else:
+ horse_tier = HorseTier.NORMAL
+ speed_bonus = 30
+
+ is_mounted = true
+ horse_mounted.emit(horse_tier, horse_level, speed_bonus)
+
+ return {
+ "ok": true,
+ "tier": horse_tier,
+ "level": horse_level,
+ "speed_bonus": speed_bonus,
+ "msg": "跨上战马!移动速度提升 %d 点。" % speed_bonus
+ }
+
+# 下马
+func unmount_horse() -> Dictionary:
+ if not is_mounted:
+ return {"ok": false, "reason": "NOT_MOUNTED"}
+
+ is_mounted = false
+ horse_tier = HorseTier.NONE
+ speed_bonus = 0
+ horse_unmounted.emit()
+ return {"ok": true, "msg": "翻身下马。"}
+
+# 心跳刷新技能冷却
+func update(delta: float) -> void:
+ for sk in skill_cooldowns.keys():
+ if skill_cooldowns[sk] > 0.0:
+ skill_cooldowns[sk] = max(0.0, skill_cooldowns[sk] - delta)
+
+# 马背普通连斩反击 (需战斗马以上)
+func execute_mounted_attack(base_atk: int, targets: Array) -> Dictionary:
+ if not is_mounted:
+ return {"ok": false, "reason": "NOT_MOUNTED"}
+
+ if horse_tier < HorseTier.COMBAT:
+ return {"ok": false, "reason": "TIER_TOO_LOW", "msg": "普通马匹无法在马背上挥砍!需升级至 11 级战斗战马。"}
+
+ if stamina <= 0:
+ return {"ok": false, "reason": "EXHAUSTED", "msg": "战马体力耗尽,无法施展马战挥砍!请喂食干草或胡萝卜。"}
+
+ # 每次攻击消耗 1 点马匹耐力
+ stamina = max(0, stamina - 1)
+
+ var hit_count = 0
+ var total_dmg = 0
+ # 战马提供 1.2x 基础冲击加成
+ var dmg_per_hit = int(float(base_atk) * 1.2)
+
+ for tgt in targets:
+ hit_count += 1
+ total_dmg += dmg_per_hit
+
+ mounted_attack_executed.emit(hit_count, total_dmg)
+
+ return {
+ "ok": true,
+ "hit_count": hit_count,
+ "dmg_per_hit": dmg_per_hit,
+ "total_damage": total_dmg,
+ "stamina": stamina
+ }
+
+# 释放马上专属技能 (需军用武装战马)
+func cast_horse_skill(skill_vnum: int, base_atk: int, player_data: Dictionary, targets: Array) -> Dictionary:
+ if not is_mounted:
+ return {"ok": false, "reason": "NOT_MOUNTED"}
+
+ if horse_tier < HorseTier.MILITARY:
+ return {"ok": false, "reason": "TIER_TOO_LOW", "msg": "只有 21 级以上的【军用战马】才能释放马背专属技能!"}
+
+ if not HORSE_SKILLS.has(skill_vnum):
+ return {"ok": false, "reason": "INVALID_HORSE_SKILL"}
+
+ var cfg = HORSE_SKILLS[skill_vnum]
+ var cd: float = skill_cooldowns[skill_vnum]
+ if cd > 0.0:
+ return {"ok": false, "reason": "SKILL_ON_COOLDOWN", "msg": "技能正在冷却中!剩余 %.1f 秒" % cd}
+
+ var sp_cost: int = cfg["sp"]
+ var cur_sp: int = int(player_data.get("sp", 0))
+ if cur_sp < sp_cost:
+ return {"ok": false, "reason": "NOT_ENOUGH_SP", "msg": "内力法力不足!需要 %d 点 SP" % sp_cost}
+
+ if stamina < 5:
+ return {"ok": false, "reason": "NOT_ENOUGH_STAMINA", "msg": "战马体力不足,无法施展大招!"}
+
+ # 消耗资源
+ player_data["sp"] = cur_sp - sp_cost
+ stamina = max(0, stamina - 5)
+ skill_cooldowns[skill_vnum] = cfg["cd"]
+
+ var skill_dmg = int(float(base_atk) * cfg["mult"])
+ var hit_count = targets.size()
+
+ horse_skill_cast.emit(skill_vnum, cfg["name"], skill_dmg, hit_count)
+
+ return {
+ "ok": true,
+ "skill_vnum": skill_vnum,
+ "skill_name": cfg["name"],
+ "damage": skill_dmg,
+ "hit_count": hit_count,
+ "stamina": stamina,
+ "remaining_sp": player_data["sp"],
+ "msg": "战马嘶鸣!施展了马上绝技【%s】!" % cfg["name"]
+ }
+
+# 喂食战马恢复体力
+func feed_horse(food_vnum: int, inventory: Array) -> Dictionary:
+ if not FOOD_ITEMS.has(food_vnum):
+ return {"ok": false, "reason": "INVALID_FOOD"}
+
+ var slot = -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == food_vnum:
+ slot = i
+ break
+
+ if slot == -1:
+ return {"ok": false, "reason": "FOOD_NOT_IN_INVENTORY", "msg": "背包中没有此马粮!"}
+
+ var item = inventory[slot]
+ var cnt = int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[slot] = null
+
+ var food = FOOD_ITEMS[food_vnum]
+ var recovered = food["stamina"]
+ stamina = min(max_stamina, stamina + recovered)
+
+ horse_fed.emit(food["name"], recovered)
+
+ return {
+ "ok": true,
+ "food_name": food["name"],
+ "recovered": recovered,
+ "current_stamina": stamina,
+ "msg": "喂食了【%s】,战马体力恢复至 %d/%d!" % [food["name"], stamina, max_stamina]
+ }
diff --git a/project/mounted_combat_skill_system.gd.uid b/project/mounted_combat_skill_system.gd.uid
new file mode 100644
index 00000000..a92bfa8d
--- /dev/null
+++ b/project/mounted_combat_skill_system.gd.uid
@@ -0,0 +1 @@
+uid://cllhvinbr2s86
diff --git a/project/movement_parity_test.gd b/project/movement_parity_test.gd
new file mode 100644
index 00000000..2e278698
--- /dev/null
+++ b/project/movement_parity_test.gd
@@ -0,0 +1,113 @@
+# movement_parity_test.gd —— 验证角色与怪物移动对齐 40250 的实现机制:
+# 1. PlayerController 转向 45° 阻尼减速(5/12)与 ARRIVE_EPS 到达精度。
+# 2. PlayerController 遇障碍物切线滑动判定。
+# 3. EntityStore 网络命令队列 EMA 滤波与动作速度 fallback。
+# 4. NetWorld 远端实体朝向收敛 45° 阻尼。
+#
+# godot --headless --path project --script movement_parity_test.gd
+extends SceneTree
+
+const PlayerController = preload("res://player_controller.gd")
+const NetWorld = preload("res://net_world.gd")
+const MobView = preload("res://ui/mob_view.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: movement_parity_test (40250 rotation damping + arrival epsilon + collision tangent + EMA network gap)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed" % _fail)
+ quit(1)
+
+func _run() -> void:
+ # =========================================================================
+ # 1. PlayerController: 转向 45° 阻尼减速测试 (40250 InstanceBaseMovement.cpp:142-154)
+ # =========================================================================
+ var pc := PlayerController.new()
+ var player := Node3D.new()
+ get_root().add_child(player)
+ get_root().add_child(pc)
+ pc.player = player
+ pc.rotation_speed_deg = 1200.0
+
+ # 角度差 > 45° 时(例如 90° = PI/2):转向速度应为全速 1200°/s
+ player.rotation.y = 0.0
+ var target_90 := deg_to_rad(90.0)
+ pc._turn_toward(target_90, 0.01) # dt = 10ms -> 预期前进 12°
+ var turned_90: float = rad_to_deg(player.rotation.y)
+ _ck(is_equal_approx(turned_90, 12.0), "Over 45 deg: full speed 1200 deg/s (turned %.2f, expected 12.0)" % turned_90)
+
+ # 角度差 <= 45° 时(例如 30°):转向速度应降为 5/12(500°/s)
+ player.rotation.y = 0.0
+ var target_30 := deg_to_rad(30.0)
+ pc._turn_toward(target_30, 0.01) # dt = 10ms -> 预期前进 5° (12 * 5/12 = 5)
+ var turned_30: float = rad_to_deg(player.rotation.y)
+ _ck(is_equal_approx(turned_30, 5.0), "Under 45 deg: damped speed 500 deg/s (turned %.2f, expected 5.0)" % turned_30)
+
+ # =========================================================================
+ # 2. PlayerController: ARRIVE_EPS 精准到达测试 (40250 InstanceBase.cpp:1600)
+ # =========================================================================
+ _ck(pc.ARRIVE_EPS <= 0.05, "ARRIVE_EPS is <= 0.05 (got %f)" % pc.ARRIVE_EPS)
+ player.position = Vector3(10.0, 0.0, 10.0)
+ pc._goto(Vector3(12.0, 0.0, 10.0)) # 距离目标点 2m (> 1m MOVABLE_GROUND_DISTANCE_M)
+ _ck(pc.is_going(), "_goto started (is_going == true)")
+ # 模拟角色走到了距目标 3cm 处
+ player.position = Vector3(11.97, 0.0, 10.0)
+ pc._process(0.016)
+ _ck(not pc.is_going(), "Within ARRIVE_EPS (3cm <= 5cm) stops immediately (is_going == false)")
+ _ck(is_equal_approx(player.position.x, 12.0), "Player position snapped to exact clicked target X")
+
+ # =========================================================================
+ # 3. PlayerController: 切线滑动测试 (40250 AdjustDynamicCollisionMovement)
+ # =========================================================================
+ var obstacle := Node3D.new()
+ get_root().add_child(obstacle)
+ # 障碍物放在 (11.0, 0, 10.0),半径 0.55m -> combined radius 1.1m
+ obstacle.position = Vector3(11.0, 0.0, 10.0)
+ obstacle.set_meta("kind", 0)
+ pc.pickables.append(obstacle)
+ player.position = Vector3(10.0, 0.0, 10.0)
+
+ # 阻挡判定:朝向 (10.5, 0.0, 10.0) 会撞击障碍物(距离 0.5m < 1.1m 且更趋近)
+ _ck(pc._actor_blocked(Vector3(10.5, 0.0, 10.0)), "Blocked when moving into obstacle body radius")
+ _ck(pc._get_blocking_actor(Vector3(10.5, 0.0, 10.0)) == obstacle, "_get_blocking_actor returns obstacle")
+
+ # =========================================================================
+ # 4. NetWorld: 远端实体朝向 45° 阻尼收敛验证
+ # =========================================================================
+ var nw := NetWorld.new()
+ get_root().add_child(nw)
+ var remote_node := Node3D.new()
+ remote_node.rotation.y = 0.0
+ var target_yaw := deg_to_rad(30.0)
+ var turn_rate := 1200.0
+ var diff: float = wrapf(target_yaw - remote_node.rotation.y, -PI, PI)
+ if absf(diff) <= deg_to_rad(45.0):
+ turn_rate *= 5.0 / 12.0
+ remote_node.rotation.y = rotate_toward(remote_node.rotation.y, target_yaw, deg_to_rad(turn_rate) * 0.01)
+ var nw_turned: float = rad_to_deg(remote_node.rotation.y)
+ _ck(is_equal_approx(nw_turned, 5.0), "NetWorld rotation damped within 45 deg: turned %.2f, expected 5.0" % nw_turned)
+
+ # =========================================================================
+ # 5. M2Client / EntityStore: 单动作 speed fallback 与 EMA 滤波测试
+ # =========================================================================
+ if ClassDB.class_exists("M2Client"):
+ var client = ClassDB.instantiate("M2Client")
+ get_root().add_child(client)
+ _ck(client.has_method("set_entity_motion_speed"), "M2Client has set_entity_motion_speed")
+ client.call("set_entity_motion_speed", 12345, 0.0, 350.0) # 仅有 run 速度
+ client.queue_free()
+
+ pc.queue_free()
+ player.queue_free()
+ obstacle.queue_free()
+ remote_node.queue_free()
+ nw.queue_free()
diff --git a/project/movement_parity_test.gd.uid b/project/movement_parity_test.gd.uid
new file mode 100644
index 00000000..a1e0f4ee
--- /dev/null
+++ b/project/movement_parity_test.gd.uid
@@ -0,0 +1 @@
+uid://xhyiac4gmabk
diff --git a/project/mysterious_merchant_blackmarket_system.gd b/project/mysterious_merchant_blackmarket_system.gd
new file mode 100644
index 00000000..0c60ad51
--- /dev/null
+++ b/project/mysterious_merchant_blackmarket_system.gd
@@ -0,0 +1,259 @@
+# mysterious_merchant_blackmarket_system.gd —— Metin2 40250 野外随机神秘游商与黑市折扣市场系统 1:1
+# 对照 40250 服务端 shop.cpp, regen.cpp, char_item.cpp
+class_name MysteriousMerchantBlackmarketSystem
+extends RefCounted
+
+signal merchant_appeared(map_name: String, pos: Vector2, duration_seconds: float)
+signal merchant_departed()
+signal item_bought(item_name: String, cost: int)
+signal item_sold(item_name: String, earned: int)
+signal stock_refreshed(stock_list: Array)
+
+const NPC_VNUM := 20095 # 野外神秘游商 (Wandering Merchant)
+const DEFAULT_DURATION := 1800.0 # 停留 30 分钟 (1800 秒)
+const HIGH_PRICE_BUYBACK_MULTIPLIER := 1.3 # 高价回收 +30% 黄金溢价 (1.30x)
+const ITEM_ANTIFLAG_SELL := 1 << 8 # 256 禁售标志位
+
+# 3 大野外候选刷新点 (沙漠绿洲、雪山孤峰、烈焰核心)
+const SPAWN_LOCATIONS: Array = [
+ {"map": "Desert", "name": "沙漠绿洲", "pos": Vector2(120, 350)},
+ {"map": "Mount_Sohan", "name": "雪山孤峰", "pos": Vector2(450, 680)},
+ {"map": "Doyyumhwan", "name": "烈焰核心", "pos": Vector2(600, 210)}
+]
+
+# 黑市半价珍品底库预设 (40250 官方核心珍宝)
+const DEFAULT_BLACK_MARKET_POOL: Array = [
+ {
+ "vnum": 51501,
+ "name": "龙石炼金原石(巨龙之心)",
+ "base_price": 2000000,
+ "price": 1000000, # 5折半价
+ "stock": 5,
+ "count": 1,
+ "type": 16 # ITEM_METIN / 炼金
+ },
+ {
+ "vnum": 28430,
+ "name": "+4 灵石礼包",
+ "base_price": 5000000,
+ "price": 2500000, # 5折半价
+ "stock": 3,
+ "count": 1,
+ "type": 10 # ITEM_STONE
+ },
+ {
+ "vnum": 50300,
+ "name": "古代进阶技能秘籍",
+ "base_price": 3000000,
+ "price": 1500000, # 5折半价
+ "stock": 3,
+ "count": 1,
+ "type": 17 # ITEM_SKILLBOOK
+ },
+ {
+ "vnum": 25040,
+ "name": "洗炼祝福包(双份)",
+ "base_price": 1000000,
+ "price": 500000, # 5折半价
+ "stock": 10,
+ "count": 2,
+ "type": 3 # ITEM_USE
+ },
+ {
+ "vnum": 71084,
+ "name": "传说洗练神丹",
+ "base_price": 4000000,
+ "price": 2000000, # 5折半价
+ "stock": 2,
+ "count": 1,
+ "type": 3 # ITEM_USE
+ }
+]
+
+# 当前游商运行状态
+var is_active: bool = false
+var current_map: String = ""
+var current_pos: Vector2 = Vector2.ZERO
+var time_remaining: float = 0.0
+var total_duration: float = DEFAULT_DURATION
+var current_stock: Array = []
+
+# 随机刷出野外神秘游商
+func spawn_random_merchant(duration: float = DEFAULT_DURATION, loc_index: int = -1) -> Dictionary:
+ var loc_idx: int = loc_index
+ if loc_idx < 0 or loc_idx >= SPAWN_LOCATIONS.size():
+ loc_idx = randi() % SPAWN_LOCATIONS.size()
+
+ var chosen_loc: Dictionary = SPAWN_LOCATIONS[loc_idx]
+ return spawn_merchant(chosen_loc["map"], chosen_loc["pos"], duration)
+
+# 指定地点召唤/刷出游商
+func spawn_merchant(map_name: String, pos: Vector2, duration: float = DEFAULT_DURATION) -> Dictionary:
+ is_active = true
+ current_map = map_name
+ current_pos = pos
+ time_remaining = duration
+ total_duration = duration
+
+ refresh_stock()
+
+ merchant_appeared.emit(current_map, current_pos, duration)
+ return {
+ "ok": true,
+ "map": current_map,
+ "pos": current_pos,
+ "duration": duration,
+ "npc_vnum": NPC_VNUM
+ }
+
+# 游商离去或收摊
+func despawn_merchant() -> void:
+ if not is_active:
+ return
+ is_active = false
+ current_map = ""
+ current_pos = Vector2.ZERO
+ time_remaining = 0.0
+ current_stock.clear()
+ merchant_departed.emit()
+
+# 物理帧或逻辑心跳更新
+func update(delta: float) -> void:
+ if not is_active:
+ return
+ time_remaining -= delta
+ if time_remaining <= 0.0:
+ despawn_merchant()
+
+# 重新生成黑市货架 (重置折扣和限量库存)
+func refresh_stock() -> void:
+ current_stock.clear()
+ for item_template in DEFAULT_BLACK_MARKET_POOL:
+ var good: Dictionary = item_template.duplicate(true)
+ current_stock.append(good)
+ stock_refreshed.emit(current_stock)
+
+# 获取当前商品清单
+func get_stock() -> Array:
+ return current_stock.duplicate(true)
+
+# 购买黑市商品
+func buy_item(stock_idx: int, player_data: Dictionary, inventory: Array) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "MERCHANT_NOT_ACTIVE", "msg": "神秘游商已离去或尚未现身!"}
+
+ if stock_idx < 0 or stock_idx >= current_stock.size():
+ return {"ok": false, "reason": "INVALID_STOCK_INDEX", "msg": "商品编号无效!"}
+
+ var good: Dictionary = current_stock[stock_idx]
+ var current_qty := int(good.get("stock", 0))
+ if current_qty <= 0:
+ return {"ok": false, "reason": "OUT_OF_STOCK", "msg": "该珍品黑市配额已售罄!"}
+
+ var price := int(good.get("price", 0))
+ var player_gold := int(player_data.get("gold", 0))
+ if player_gold < price:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足,无法购买黑市珍品!"}
+
+ # 寻找背包空槽
+ var target_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null or (inventory[i] is Dictionary and inventory[i].is_empty()):
+ target_slot = i
+ break
+
+ if target_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,请清理后再购买!"}
+
+ # 扣费与发货
+ player_data["gold"] = player_gold - price
+ good["stock"] = current_qty - 1
+
+ var bought_item: Dictionary = {
+ "vnum": good["vnum"],
+ "name": good["name"],
+ "count": good.get("count", 1),
+ "type": good.get("type", 0)
+ }
+ inventory[target_slot] = bought_item
+
+ item_bought.emit(good["name"], price)
+
+ return {
+ "ok": true,
+ "slot": target_slot,
+ "item": bought_item,
+ "cost": price,
+ "remaining_stock": good["stock"],
+ "msg": "成功以黑市特惠价购入 %s!" % good["name"]
+ }
+
+# 计算高价回收金额 (+30% 溢价)
+func calculate_sell_price(item: Dictionary) -> int:
+ var anti_flags := int(item.get("anti_flags", item.get("anti_flag", 0)))
+ if anti_flags & ITEM_ANTIFLAG_SELL != 0:
+ return 0 # 禁售品
+
+ var base_price := int(item.get("shop_buy_price", item.get("gold", item.get("price", 1000))))
+ var count := maxi(1, int(item.get("count", 1)))
+
+ # 40250 官方标准回收价为买入价的 1/5 (20%)
+ var standard_unit_sell: int = maxi(1, int(base_price / 5.0))
+
+ # 神秘游商提供 +30% 溢价 (1.30 倍)
+ var blackmarket_unit_sell: int = int(round(standard_unit_sell * HIGH_PRICE_BUYBACK_MULTIPLIER))
+
+ return maxi(1, blackmarket_unit_sell * count)
+
+# 向神秘游商高价出售闲置珍品
+func sell_item(inv_slot: int, player_data: Dictionary, inventory: Array) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "MERCHANT_NOT_ACTIVE", "msg": "神秘游商已离去,无法交易!"}
+
+ if inv_slot < 0 or inv_slot >= inventory.size() or inventory[inv_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "背包槽位为空或无效!"}
+
+ var item: Dictionary = inventory[inv_slot]
+ var anti_flags := int(item.get("anti_flags", item.get("anti_flag", 0)))
+ if anti_flags & ITEM_ANTIFLAG_SELL != 0:
+ return {"ok": false, "reason": "ITEM_ANTIFLAG_SELL", "msg": "该物品属于非卖品,无法出售!"}
+
+ var earn_gold := calculate_sell_price(item)
+ if earn_gold <= 0:
+ return {"ok": false, "reason": "VALUATION_ZERO", "msg": "该物品估价为 0,游商不予回收!"}
+
+ var item_name: String = str(item.get("name", "珍宝"))
+
+ # 结算金币与移除物品
+ player_data["gold"] = int(player_data.get("gold", 0)) + earn_gold
+ inventory[inv_slot] = null
+
+ item_sold.emit(item_name, earn_gold)
+
+ return {
+ "ok": true,
+ "earned": earn_gold,
+ "item_name": item_name,
+ "msg": "神秘游商高价收购了【%s】,支付了 %d 金币(+30%% 溢价)!" % [item_name, earn_gold]
+ }
+
+# 存档序列化
+func serialize() -> Dictionary:
+ return {
+ "is_active": is_active,
+ "current_map": current_map,
+ "pos_x": current_pos.x,
+ "pos_y": current_pos.y,
+ "time_remaining": time_remaining,
+ "total_duration": total_duration,
+ "stock": current_stock.duplicate(true)
+ }
+
+# 读档反序列化
+func deserialize(data: Dictionary) -> void:
+ is_active = bool(data.get("is_active", false))
+ current_map = str(data.get("current_map", ""))
+ current_pos = Vector2(float(data.get("pos_x", 0.0)), float(data.get("pos_y", 0.0)))
+ time_remaining = float(data.get("time_remaining", 0.0))
+ total_duration = float(data.get("total_duration", DEFAULT_DURATION))
+ current_stock = data.get("stock", []).duplicate(true)
diff --git a/project/mysterious_merchant_blackmarket_system.gd.uid b/project/mysterious_merchant_blackmarket_system.gd.uid
new file mode 100644
index 00000000..57c4d2b4
--- /dev/null
+++ b/project/mysterious_merchant_blackmarket_system.gd.uid
@@ -0,0 +1 @@
+uid://2j2dfg6igg2c
diff --git a/project/net/channel_status.gd b/project/net/channel_status.gd
index 5b3c8040..e2c18e71 100644
--- a/project/net/channel_status.gd
+++ b/project/net/channel_status.gd
@@ -49,7 +49,7 @@ func _process(_dt: float) -> void:
return
_peer.poll()
var st := _peer.get_status()
- if st == StreamPeerTCP.STATUS_ERROR or _now() > _deadline:
+ if st == StreamPeerTCP.STATUS_ERROR or st == StreamPeerTCP.STATUS_NONE or _now() > _deadline:
_finish({})
return
if st != StreamPeerTCP.STATUS_CONNECTED:
@@ -69,6 +69,9 @@ func _process(_dt: float) -> void:
_try_parse()
func _try_parse_classic() -> void:
+ if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ _finish({})
+ return
var avail := _peer.get_available_bytes()
if avail > 0:
var r: Array = _peer.get_data(avail)
@@ -104,6 +107,9 @@ func _try_parse_classic() -> void:
_finish(out)
func _try_parse() -> void:
+ if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ _finish({})
+ return
var avail := _peer.get_available_bytes()
if avail < 4:
return
diff --git a/project/net/mapcoord.gd b/project/net/mapcoord.gd
index 9a3c2011..85f99f1f 100644
--- a/project/net/mapcoord.gd
+++ b/project/net/mapcoord.gd
@@ -31,9 +31,11 @@ static func to_server_cm(world_pos: Vector3) -> Vector2:
return Vector2(world_pos.x * 100.0 + _bx, world_pos.z * 100.0 + _by)
# 服务器 heading(度) -> world 帧 Godot yaw(弧度)。
-# net 帧里南=-Z、world 帧里南=+Z,Z 翻向 -> 相对 net 帧的换算符号取反。
+# Metin2 heading: 0°=南, 90°=东, 180°=北, 270°=西。
+# Godot yaw: atan2(x, z) 中 +Z(南)=0, +X(东)=PI/2(90°), -Z(北)=PI(180°), -X(西)=-PI/2(270°)。
+# 两者定义完全一致,无需额外偏移。
static func heading_to_yaw(angle_deg: float) -> float:
- return deg_to_rad(angle_deg + 90.0)
+ return deg_to_rad(angle_deg)
static func yaw_to_heading(yaw_rad: float) -> float:
- return fposmod(rad_to_deg(yaw_rad) - 90.0, 360.0)
+ return fposmod(rad_to_deg(yaw_rad), 360.0)
diff --git a/project/net_play.gd b/project/net_play.gd
index d1e23d6e..14378430 100644
--- a/project/net_play.gd
+++ b/project/net_play.gd
@@ -15,6 +15,8 @@
# np.setup(m2client, player_ctl, net_world, hud)
extends Node
+signal level_up(new_level: int)
+
signal target_changed(vid: int)
# game.py SetPCTargetBoard :804 —— 按住 LCONTROL 点同阵营他人 PC 时请求开悄悄话对话框
# (参考端 self.interface.OpenWhisperDialog(name))。game_scene 接到 chat.start_whisper()。
@@ -46,8 +48,8 @@ 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_CLICK_ITEM 的到手阈值(40250 server item.cpp:435 DistanceValid <= 300cm)。
+const CLICK_ITEM_PICKUP_CM := 300.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() 字典)。
@@ -115,6 +117,11 @@ var client: Node # M2Client
var pc: Node # PlayerController
var net_world: Node # NetWorld
var hud: Node # 可空
+var target_ui: Node # 40250 uitarget.py TargetBoard (可空)
+var audio: Node # 可空
+var _last_level := -1
+var _last_exp := -1
+var _last_dead_mob_pos := Vector3.INF
# 参考端没有硬编码攻击间隔——节奏来自当前攻击 `.msa` 的 `ComboInputData.DirectInputTime`
# (`CRaceMotionData::GetNextComboTime`),无 combo 数据时退化为 `MotionDuration * 0.9`。
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
@@ -132,9 +139,18 @@ var player_view: Node:
player_view = v
if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound):
v.motion_bound.connect(_on_motion_bound)
+ if v and v.has_method("set_motion_mode"):
+ v.set_motion_mode(combo_motion_mode)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount)
-var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
+var proto: Node: # Metin2Proto(可空,按 race 分类 NPC/怪 + 武器动作模式)
+ set(v):
+ proto = v
+ # GameScene receives the initial entity/equipment burst before item_proto
+ # finishes loading. Re-evaluate here or a 3150 weapon remains GENERAL until
+ # the next inventory packet, visually leaving the left hand off the grip.
+ if v:
+ _refresh_motion_mode()
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
@@ -200,8 +216,13 @@ 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 _last_click_actor_t := -10.0
+var _last_click_actor_vid := 0
var _knock_down := false # 受击/击退动作播放中(对齐 CActorInstance::IsPushing)
var _using_skill := false # 施法中(§3.8 由 player_skill 驱动)
+var _skill_cast_start_time := -1.0 # 技能动作起播时间
+var _skill_cast_duration := 0.0 # 技能动作时长超时门控
+var _skill_cancel_enable := false # 40250 IsCancelEnableSkill() 可取消后摇打断门控
# §3.5 连击状态机(1:1 复刻 InputComboAttackCommand / ComboProcess / __RunNextCombo /
# __OnEndCombo / __ClearCombo)
var combo_class := 0 # 0=warrior 1=assassin 2=sura 3=shaman(race & 3)
@@ -215,6 +236,7 @@ var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array }
var _local_time := 0.0 # GetLocalTime()
var _delay := 0.0 # m_fDelay
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
+var _is_attacking := false # 当前是否处于挥击攻击动作中(40250 isLock 门)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
@@ -253,12 +275,15 @@ var _bow_distance_bonus := 0.0 # GetStatus(POINT_BOW_DISTANCE) —— __GetBo
var _dash_active := false # __CheckDashAffect
var _charge_skill := false # 预约技能 IsChargeSkill()
-func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) -> void:
+func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, audio_node: Node = null) -> void:
client = m2client
pc = player_ctl
net_world = nw
hud = hud_node
+ audio = audio_node
pc.target_selected.connect(_on_pick)
+ if pc.has_signal("ground_item_clicked"):
+ pc.ground_item_clicked.connect(pick_ground_item)
pc.moved.connect(_on_local_moved)
pc.anim_state.connect(_on_anim_state)
client.entity_main_set.connect(_on_main_set)
@@ -274,7 +299,13 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(_on_entity_gone)
- client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
+ client.entity_dead.connect(func(v):
+ if v == _target_vid:
+ if is_instance_valid(net_world) and net_world.has_method("_entity_pos"):
+ var mpos: Vector3 = net_world._entity_pos(v)
+ if mpos != Vector3.INF:
+ _last_dead_mob_pos = mpos
+ _clear_target())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
@@ -309,6 +340,16 @@ func set_attack_key(down: bool) -> void:
_attack_key_down = down
if not down:
_attack_cd = 0.0
+ _clear_auto_attack()
+ _clear_reserved()
+ if pc and pc.has_method("stop"):
+ pc.stop()
+
+# 键盘 / 摇杆手动移动:对齐 NEW_MoveToDirection,清预约并取消钓鱼
+func on_manual_move() -> bool:
+ _clear_reserved()
+ _clear_auto_attack()
+ return cancel_fishing()
# PythonPlayerInput.cpp:506-549 / PythonPlayerInputKeyboard.cpp:6-16。
# 返回码只供快捷栏和测试使用;普通攻击键会消费这次输入但不弹技能错误。
@@ -413,11 +454,31 @@ func _on_motion_bound(_state: String) -> void:
_hit_dedup.clear()
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
_combo_index = 0
+ if _state == "wait":
+ _is_attacking = false
+ _using_skill = false
+ _charge_skill = false
+ _skill_cancel_enable = false
+
+# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) / InterceptOnceMotion
+# 40250 PythonPlayerSkill.cpp:751: __ClearReservedAction(); __ClearAutoAttackTargetActorID(); NEW_Stop();
+func start_skill_cast(duration: float = 2.0, is_charge: bool = false, cancel_enable: bool = false) -> void:
+ _using_skill = true
+ _charge_skill = is_charge
+ _skill_cancel_enable = cancel_enable
+ _skill_cast_start_time = _local_time
+ _skill_cast_duration = duration if duration > 0.0 else 2.0
+ _clear_reserved()
+ _clear_auto_attack()
+ if pc and pc.has_method("stop"):
+ pc.stop()
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
_clear_combo()
+ if player_view and player_view.has_method("set_motion_mode"):
+ player_view.set_motion_mode(mode)
# §3.5 修改 6 —— CInstanceBase::RefreshState(InstanceBase.cpp:2851)里 SetMotionMode 的分支:
# Poly → GENERAL;婚纱 → WEDDING_DRESS;镐 → 骑乘 HORSE / 否则 GENERAL;钓竿 → 骑乘 HORSE /
@@ -580,16 +641,14 @@ func _entity_kind(e: Dictionary) -> int:
var explicit_kind := int(e.get("kind", -1))
if explicit_kind >= EntityRules.KIND_BUILDING:
return explicit_kind
- var ct := int(e.get("ch_type", 0))
- if ct != 0:
- return ct
var race := int(e.get("race", 0))
if EntityRules._is_wooden_door(e):
return EntityRules.KIND_WOODEN_DOOR
+ # 优先查 mob_proto:服务端 GC_CHARACTER_ADD 在经典协议 / 部分服务端实现下 bType
+ # 经常全发 0(导致被客户端转为 MONSTER=2),权威分类必须以 mob_proto(bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR)为准。
if race >= 1 and proto and proto.has_method("mob"):
var m: Dictionary = proto.mob(race)
if not m.is_empty():
- # mob_proto bType: 0 MONSTER / 1 NPC / 2 STONE / 3 WARP / 4 DOOR …
match int(m.get("type", 0)):
0: return 2
1: return 1
@@ -597,6 +656,11 @@ func _entity_kind(e: Dictionary) -> int:
3: return 4
4: return EntityRules.KIND_WOODEN_DOOR \
if EntityRules._is_wooden_door(e) else EntityRules.KIND_BUILDING
+ var ct := int(e.get("ch_type", 0))
+ if ct != 0:
+ return ct
+ if explicit_kind >= 0:
+ return explicit_kind
return 0
# 实体显示名:玩家 / NPC 走 GC_CHAR_ADD_INFO 的 name;怪没有名字包 -> 查 mob_proto。
@@ -677,6 +741,10 @@ func on_use_skill(motion_skill: int, arg: int) -> void:
func _on_main_set(vid: int) -> void:
_main_vid = vid
+ _clear_reserved()
+ _clear_auto_attack()
+ if pc and pc.has_method("stop"):
+ pc.stop()
if client and client.has_method("get_entity"):
_on_entity_info(vid, client.get_entity(vid))
# net_world 会给主角也生成一个节点;本地由 player_controller 的 player 代表,去掉重复的
@@ -741,13 +809,50 @@ func _clickable_distance(te: Dictionary) -> float:
if _is_bow_mode():
cm = _bow_range_cm()
match _entity_kind(te):
- 1: cm = CLICK_DIST_NPC_CM # NPC
+ 1, 4: cm = CLICK_DIST_NPC_CM # NPC / 传送门
3: cm = CLICK_DIST_RESOURCE_CM # 石头 / 采集物
return cm / CM
+# 40250 CActorInstance::IsClickableDistanceDestInstance (ActorInstanceBattle.cpp:117)
+# 遍历目标防御球,若存在防御球,判断 ||rkSphere.v3Position - kD3DVct3Src|| < rkSphere.fRadius + fDistance
+func _is_clickable_distance(tnode: Node3D, te: Dictionary) -> bool:
+ if pc == null or pc.player == null or tnode == null:
+ return false
+ var base_dist := _clickable_distance(te)
+ var my_pos := _world_pos(pc.player)
+ var target_pos := _world_pos(tnode)
+ var flat_dist := Vector2(my_pos.x - target_pos.x, my_pos.z - target_pos.z).length()
+ if flat_dist <= base_dist:
+ return true
+ var spheres := _defending_spheres(int(te.get("vid", 0)), tnode, _actor_cm(target_pos))
+ if not spheres.is_empty():
+ for s in spheres:
+ if s is Dictionary:
+ var spos_cm: Vector3 = s.get("pos", Vector3.ZERO)
+ var spos_m := _godot_m(spos_cm)
+ var srad_m: float = float(s.get("radius", 0.0)) / CM
+ var d_sph := Vector2(my_pos.x - spos_m.x, my_pos.z - spos_m.z).length()
+ if d_sph < (base_dist + srad_m):
+ return true
+ return false
+ return flat_dist <= base_dist
+
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()
+
+ var cur_level := int(p.get("level", 0))
+ if cur_level > 0:
+ if _last_level > 0 and cur_level > _last_level:
+ _trigger_level_up(cur_level, p)
+ _last_level = cur_level
+
+ var cur_exp := int(p.get("exp", 0))
+ if _last_exp >= 0 and cur_exp > _last_exp:
+ var delta_exp := cur_exp - _last_exp
+ _trigger_exp_gain(delta_exp, p)
+ _last_exp = cur_exp
+
if hud == null:
return
if hud.has_method("set_vitals"):
@@ -758,10 +863,78 @@ func _on_points(p: Dictionary) -> void:
if hud.has_method("set_exp"):
hud.set_exp(int(p.get("exp", 0)), int(p.get("next_exp", 0)))
if hud.has_method("set_level"):
- hud.set_level(int(p.get("level", 0)))
+ hud.set_level(cur_level)
if hud.has_method("set_energy"):
hud.set_energy(int(p.get("energy", 0)), 100)
+func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void:
+ print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp)
+
+ # 1. 40250 官方经验聚能光球飞行与吸收
+ if is_instance_valid(net_world) and net_world.has_method("spawn_exp_fly"):
+ var spawn_pos := _last_dead_mob_pos
+ var player_node: Node3D = pc.player if (pc and is_instance_valid(pc.player)) else null
+ if spawn_pos == Vector3.INF and is_instance_valid(player_node):
+ # 若无死亡怪物坐标记录,则在玩家正前方 2.5 米处生成光球回吸
+ var p_pos := player_node.global_position if player_node.is_inside_tree() else player_node.position
+ var forward := -player_node.global_transform.basis.z if player_node.is_inside_tree() else Vector3(0, 0, -1)
+ spawn_pos = p_pos + forward * 2.5
+ if spawn_pos != Vector3.INF:
+ net_world.spawn_exp_fly(spawn_pos, player_node, 3)
+ _last_dead_mob_pos = Vector3.INF
+
+ # 2. 40250 吸收音效与任务栏反馈
+ if audio and audio.has_method("play_ui"):
+ audio.play_ui("money.wav")
+
+func _trigger_level_up(new_level: int, p: Dictionary) -> void:
+ level_up.emit(new_level)
+ print("[升级] 恭喜!等级提升至 Lv. %d!" % new_level)
+
+ # 1. 40250 EFFECT_LEVELUP 冲天金光特效
+ _spawn_level_up_effect()
+
+ # 2. 40250 播放专属升级号角音效
+ if audio and audio.has_method("play_ui"):
+ audio.play_ui("levelup1_1.wav")
+
+ # 3. 40250 规则:升级瞬间 HP、SP 立即 100% 满状态恢复
+ var max_hp := int(p.get("max_hp", 0))
+ var max_sp := int(p.get("max_sp", 0))
+ if max_hp > 0 and hud and hud.has_method("set_vitals"):
+ hud.set_vitals(max_hp, max_hp, max_sp, max_sp)
+
+ # 4. 通知 HUD 刷新属性加点
+ if hud and hud.has_method("on_level_up"):
+ hud.on_level_up(new_level)
+
+func _spawn_level_up_effect() -> void:
+ var pl: Node3D = pc.player if (pc and "player" in pc) else null
+ if pl == null or not is_instance_valid(pl):
+ return
+ var beam := MeshInstance3D.new()
+ beam.name = "level_up_beam"
+ var cyl := CylinderMesh.new()
+ cyl.top_radius = 0.45
+ cyl.bottom_radius = 0.65
+ cyl.height = 7.0
+ beam.mesh = cyl
+ beam.position.y = 3.5
+ var mat := StandardMaterial3D.new()
+ mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat.albedo_color = Color(1.0, 0.85, 0.25, 0.75)
+ mat.emission_enabled = true
+ mat.emission = Color(1.0, 0.88, 0.3)
+ beam.material_override = mat
+ pl.add_child(beam)
+
+ var tw := pl.create_tween()
+ tw.set_parallel(true)
+ tw.tween_property(beam, "scale:y", 1.4, 1.2)
+ tw.tween_property(mat, "albedo_color:a", 0.0, 1.2)
+ tw.chain().tween_callback(beam.queue_free)
+
func _on_net_moved(vid: int) -> void:
if client == null or vid != _main_vid or pc.player == null:
return
@@ -769,15 +942,39 @@ func _on_net_moved(vid: int) -> void:
if e.is_empty():
return
var sp: Vector3 = MapCoord.to_world(e.get("pos", pc.player.position))
- sp.y = pc.player.position.y # 高度本地贴地,不信服务器 z
+ var w: Node = pc.world if (pc and "world" in pc and pc.world) else (net_world.world if (net_world and "world" in net_world) else null)
+ if w and w.has_method("sample_height"):
+ sp.y = float(w.call("sample_height", sp.x, sp.z))
+ else:
+ sp.y = pc.player.position.y # 无地形时的兜底
var d: float = pc.player.position.distance_to(sp)
+ # 40250 对齐:CInstanceBase::PushTCPState 对主玩家显式忽略(if (__IsMainInstance()) return)。
+ # 本地移动由客户端权威推进,常规移动不作平滑插值(避免网络 RTT 滞后引发反拉与按键对抗)。
+ # 仅在距离超出瞬移阈值时(服务端传送、换图、防作弊重置)直接 Snap 同步。
if d > CORRECTION_SNAP:
pc.player.position = sp
- elif d > 0.3:
- pc.player.position = pc.player.position.lerp(sp, CORRECTION_LERP)
+ if pc and pc.has_method("stop"):
+ pc.stop()
# --- targeting / attack -----------------------------------------------------
+static func _world_pos(n: Node3D) -> Vector3:
+ if n == null:
+ return Vector3.ZERO
+ return n.global_position if n.is_inside_tree() else n.position
+
+# 40250 CPythonPlayer::__SendClickActorPacket (PythonPlayer.cpp:1196)
+# 节流保护:s_dwNextTCPTime = dwCurTime + 1000(同目标 1 秒内不重复发送点击包,防止狂点刷包)
+func _send_click_actor_packet(vid: int) -> bool:
+ if vid <= 0 or client == null or not client.has_method("click_npc"):
+ return false
+ var now := Time.get_ticks_msec() / 1000.0
+ if vid == _last_click_actor_vid and (now - _last_click_actor_t) < 1.0:
+ return false
+ _last_click_actor_t = now
+ _last_click_actor_vid = vid
+ return client.click_npc(vid)
+
func _on_pick(node: Node3D) -> void:
if _observer_mode:
return
@@ -789,24 +986,63 @@ func _on_pick(node: Node3D) -> void:
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)
+ if e.is_empty():
return
+
+ var kind := _entity_kind(e)
+ var tnode: Node3D = net_world.node_for(vid) if (net_world and net_world.node_for(vid)) else node
+
+ # 不可攻击目标(NPC / warp / 同阵营PC等) -> 交互或目标框
+ if not _is_attackable(e):
+ _auto_attack_vid = 0
+ if kind in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
+ if not _is_clickable_distance(tnode, e):
+ # 40250 __OnClickActor: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近 NPC)
+ _reserve_click_actor(vid)
+ if pc and pc.has_method("walk_to") and tnode:
+ pc.walk_to(_world_pos(tnode))
+ return
+ # 已在交互距离内(<= 5m):停止移动、朝向 NPC、发送点击包(40250 __SendClickActorPacket + NEW_Stop)
+ _clear_reserved()
+ if pc and pc.has_method("stop"):
+ pc.stop()
+ if pc and pc.player and tnode:
+ var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
+ if to.length() > 0.01:
+ pc.player.rotation.y = atan2(to.x, to.z)
+ _send_click_actor_packet(vid)
+ return
+ _clear_reserved()
+ _change_target_to_picked_instance(vid)
+ if kind == EntityRules.KIND_PC:
+ var same_empire := EntityRules._is_same_empire(_main_entity(), e)
+ if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
+ var nm := _entity_name(e)
+ if nm != "":
+ whisper_requested.emit(nm)
+ return
+
+ # --- 40250 __OnPressActor: 可攻击实体(怪物、陨石、敌对PC等)---
_attack_cd = 0.0
_change_target_to_picked_instance(vid)
- # game.py SetPCTargetBoard :804 —— 点到 PC 时 targetBoard.Open 之后,若按住 LCONTROL
- # 且同阵营(:809 IsSameEmpire)且非本人(:812 IsMainCharacterIndex)且非 building
- # (:814 INSTANCE_TYPE_BUILDING)→ interface.OpenWhisperDialog(name)。C++ 侧只对 PC
- # 分派 SetPCTargetBoard,故 poc 也只在 KIND_PC 分支查。建筑 / 木门由实体分类链识别,
- # 不满足 PC 分支时不会误触发悄悄话。
- if _entity_kind(e) == EntityRules.KIND_PC:
- var same_empire := EntityRules._is_same_empire(_main_entity(), e)
- if TargetBoard.whisper_on_ctrl_click(_lcontrol_down, same_empire, vid == _main_vid, false):
- var nm := _entity_name(e)
- if nm != "":
- whisper_requested.emit(nm)
+ # 40250 __SetAutoAttackTargetActorID: 点击可攻击实体开启持续连击/追击
+ _auto_attack_vid = vid
+ _clear_reserved()
+
+ if not _can_attack():
+ return
+
+ if not _is_clickable_distance(tnode, e):
+ # 40250: !NEW_IsClickableDistanceDestInstance -> __ReserveClickActor (走近)
+ _reserve_click_actor(vid)
+ if pc and pc.has_method("walk_to") and tnode:
+ pc.walk_to(_world_pos(tnode))
+ return
+
+ # 已在攻击距离内:立即朝向目标出刀
+ if tnode != null:
+ _do_attack_swing(tnode, e)
# poc 版 RecvTargetPacket(PythonNetworkStreamPhaseGame.cpp:2424):C++ 侧从 EntityStore
# 抽出 (vid, hp%),这里照参考端的判定树分派目标框——目标实例缺失 -> 关框;已死透 -> 不动框;
@@ -839,10 +1075,15 @@ func _on_target_info(vid: int, hp_pct: int) -> void:
if TargetBoard.needs_reset(vid, _target_vid):
_target_vid = vid
target_changed.emit(vid)
+ var nm := _entity_name(e)
+ var hp_clamped := TargetBoard.clamp_hp_pct(hp_pct)
if hud and hud.has_method("set_target"):
- var nm := _entity_name(e)
- hud.set_target(nm if nm != "" else "目标",
- TargetBoard.clamp_hp_pct(hp_pct))
+ hud.set_target(nm if nm != "" else "目标", hp_clamped)
+ if target_ui and target_ui.has_method("open"):
+ var lvl := int(e.get("level", -1))
+ var grade := int(e.get("grade", -1))
+ target_ui.open(vid, nm if nm != "" else "目标", lvl, grade, is_pc)
+ target_ui.set_hp(hp_clamped)
TargetBoard.Action.NONE:
pass
@@ -858,23 +1099,27 @@ func set_lcontrol_down(down: bool) -> void:
func _clear_target() -> void:
if _target_vid == 0:
return
+ _auto_attack_vid = 0
_target_vid = 0
+ _clear_reserved()
+ if pc and pc.has_method("stop"):
+ pc.stop()
target_changed.emit(0)
if hud and hud.has_method("clear_target"):
hud.clear_target()
+ if target_ui and target_ui.has_method("close"):
+ target_ui.close()
-# 受击:自己是 victim 且非闪避 -> 播击退动作 + 抖屏。
-# 参考端没有「固定硬直秒数」——收到伤害就 InterceptOnceMotion(NAME_DAMAGE_FLYING),
-# 能否行动由该动作是否播完(_knock_down)决定,动作结束在 _process 里清零。
+# 受击:40250 RecvDamageInfoPacket(PythonNetworkStreamPhaseGame.cpp:2415)
+# 仅展示伤害飘字和受击动作;普通伤害不晃动摄像机、不锁死移动控制(非 DAMAGE_FLYING 击飞)。
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid != _main_vid:
return
- var crit := (flag & DAMAGE_CRITICAL) != 0
- if camera and camera.has_method("shake"):
- camera.shake(0.08 if crit else 0.04, 10.0)
if (flag & DAMAGE_DODGE) != 0:
return
_knock_down = true
+ if camera and camera.has_method("shake"):
+ camera.shake(0.2, 0.5)
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("damage")
@@ -936,6 +1181,46 @@ func _can_use_skill() -> bool:
func is_stunned() -> bool:
return not _can_process_network_state()
+# 40250 CInstanceBase::NEW_GetFrontInstance (InstanceBaseBattle.cpp:145)
+# 在正前方扇面检索最近的可攻击存活目标(最大射程默认 2000cm / 20m)
+func get_front_instance(f_distance_cm: float = 2000.0) -> int:
+ if pc == null or pc.player == null or client == null:
+ return 0
+ var my_pos: Vector3 = _world_pos(pc.player)
+ var my_yaw: float = pc.player.rotation.y
+ # Godot 空间中 yaw=0 面向 -Z,朝向向量为 (-sin(yaw), 0, -cos(yaw))
+ var fwd := Vector3(-sin(my_yaw), 0, -cos(my_yaw)).normalized()
+
+ var best_vid := 0
+ var min_dist_m := 1e9
+ var max_dist_m := f_distance_cm / CM
+
+ 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 tnode: Node3D = net_world.node_for(vid) if net_world else null
+ var pos := Vector3.ZERO
+ if tnode != null:
+ pos = _world_pos(tnode)
+ else:
+ pos = MapCoord.to_world(e.get("pos", my_pos))
+ var to_target := pos - my_pos
+ to_target.y = 0.0
+ var d := to_target.length()
+ if d > max_dist_m or d < 0.01:
+ continue
+ var to_dir := to_target.normalized()
+ var dot := fwd.dot(to_dir)
+ # 40250 扇形检测(HALF_FAN_ROT 10°~50°,约 60°~70° 锥角内)
+ if dot > 0.35:
+ if d < min_dist_m:
+ min_dist_m = d
+ best_vid = vid
+ return best_vid
+
# §3.8 修改 1 —— 供 player_skill.gd 三层校验的运行期上下文快照。
# 对齐原客户端 __CanUseSkill / __CheckSkillUsable 从 CInstanceBase / m_sysIs* /
# GetStatus() 读到的那些值;服务端未下发的走 net_play 本地占位。
@@ -973,6 +1258,7 @@ func skill_context() -> Dictionary:
"cur_sp": cur_sp,
"bow_distance": int(_bow_distance_bonus),
"target_distance": Callable(self, "skill_target_distance_cm"),
+ "get_front_instance": Callable(self, "get_front_instance"),
}
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE −200)——施法射程判定与
@@ -983,7 +1269,7 @@ func skill_target_distance_cm(vid: int) -> float:
var tnode: Node3D = net_world.node_for(vid) if net_world else null
if tnode == null:
return -1.0
- var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
+ var dist_cm: float = _world_pos(pc.player).distance_to(_world_pos(tnode)) * CM
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
return dist_cm
@@ -992,14 +1278,54 @@ func skill_target_distance_cm(vid: int) -> float:
func is_use_skill_reserved(slot: int) -> bool:
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
+# 40250 CActorInstance::isLock()(ActorInstanceMotion.cpp:472)
+# 挥击动作(普攻/连击/技能/钓鱼)期间,严格锁定移动(PythonPlayerInput.cpp:457)。
+# 连击段号仅用于连击输入窗口判断,动作播放结束自动切回 wait 状态并解锁。
+func is_lock() -> bool:
+ if _using_skill:
+ # 40250 ActorInstanceMotion.cpp:541: if (m_pkCurRaceMotionData->IsCancelEnableSkill()) return FALSE;
+ if _skill_cancel_enable:
+ return false
+ if _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0 and (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
+ _using_skill = false
+ _charge_skill = false
+ _skill_cancel_enable = false
+ if player_view and player_view.has_method("reset_skill_motion"):
+ player_view.reset_skill_motion()
+ elif not _charge_skill:
+ return true
+ if combo_motion_mode == MOTION_MODE_FISHING:
+ return true
+ if _is_attacking:
+ var md := _motion_data()
+ var dur := float(md.get("duration", attack_period))
+ if dur <= 0.0:
+ dur = attack_period
+ if _attacking_elapsed() < dur:
+ return true
+ else:
+ _is_attacking = false
+ _combo_index = 0
+ return false
+
func _process(dt: float) -> void:
_advance_local_time(dt)
+ if _using_skill and _skill_cast_duration > 0.0 and _skill_cast_start_time >= 0.0:
+ if (_local_time - _skill_cast_start_time) >= _skill_cast_duration:
+ _using_skill = false
+ _charge_skill = false
+ _skill_cancel_enable = false
+ if not _can_process_network_state():
+ _using_skill = false
+ _charge_skill = false
+ _skill_cancel_enable = false
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
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", not _can_process_network_state())
+ pc.set("locked", is_lock())
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
@@ -1014,32 +1340,39 @@ func _process(dt: float) -> void:
_tick_on_waiting()
_flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION
-# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207)——每帧检查 auto-attack 目标
-# 是否仍可打,然后 __ReserveClickActor()。本工程用「按住攻击键 + 有目标」当 __OnPressActor(isAuto)。
+# CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207)
+# 以及 NEW_Attack()(PythonPlayerInput.cpp:550,空格键自由挥击)
func _update_auto_attack() -> void:
- if not _attack_key_down or _target_vid == 0:
- _auto_attack_vid = 0
+ if _attack_key_down:
+ if _auto_attack_vid == 0 and _target_vid != 0:
+ _auto_attack_vid = _target_vid
+ elif _auto_attack_vid == 0 and _target_vid == 0:
+ # 40250 NEW_Attack(): 无目标按下空格键时朝当前面向自由挥剑
+ # 连击 / 普攻节奏由动作数据 ComboInputData / 90% duration 自行 gate,不使用外层 cd 吞键
+ if _can_attack():
+ _do_attack_swing(null, {})
+ return
+
+ if _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)):
+ if v.is_empty() or 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):
+ _clear_auto_attack()
return
if bool(_main_entity().get("sleeping", false)):
return
- # 参考端每帧无条件 __ReserveClickActor(攻击节奏由动作数据在 swing 处 gate,不在这里)。
+ # 对齐 40250 CPythonPlayer::__Update_AutoAttack():只要目标有效存活且可攻击,每帧 __ReserveClickActor
if _reserved_mode == ReservedMode.NONE and _can_attack():
_reserve_click_actor(_auto_attack_vid)
@@ -1072,7 +1405,6 @@ func _clear_reserved() -> void:
func _clear_auto_attack() -> void:
_auto_attack_vid = 0
- _clear_target()
func _emit_cannot(code: String) -> void:
_last_cannot = code
@@ -1168,15 +1500,23 @@ func _reserve_process_click_actor() -> void:
_clear_reserved()
return
# 2 不在可点击距离 -> 继续靠近(NEW_MoveToDestInstanceDirection)
- var dist: float = pc.player.global_position.distance_to(tnode.global_position)
- if dist > _clickable_distance(rv):
+ if not _is_clickable_distance(tnode, rv):
if pc.has_method("walk_to"):
- pc.walk_to(tnode.global_position)
+ pc.walk_to(_world_pos(tnode))
return
- # 3 不可攻击 -> 停 + 发点击包(NPC 对话)+ 清预约
+ elif pc and pc.has_method("is_going") and pc.is_going():
+ if pc.has_method("stop"):
+ pc.stop()
+ # 3 不可攻击 -> 停 + 朝向 NPC + 发点击包(NPC 对话)+ 清预约
if not _is_attackable(rv):
pc.set("frozen", false)
- client.click_npc(_vid_reserved)
+ if pc and pc.has_method("stop"):
+ pc.stop()
+ if pc and pc.player and tnode:
+ var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
+ if to.length() > 0.01:
+ pc.player.rotation.y = atan2(to.x, to.z)
+ _send_click_actor_packet(_vid_reserved)
_clear_reserved()
return
# 4 目标已死
@@ -1208,9 +1548,8 @@ func _reserve_process_click_actor() -> void:
target_changed.emit(_vid_reserved)
_refresh_target_hud()
# 10 攻击 + 清预约(下一帧由 __Update_AutoAttack 重新预约)。
- # 连击 / 普攻节奏由动作数据 gate:cd 未到就本帧不 swing,仍清预约、下帧再来。
- if _attack_cd <= 0.0:
- _do_attack_swing(tnode, rv)
+ # 对齐 40250 PythonPlayerInput.cpp:947:直接调用攻击,由动作状态机判定连击推进与输入窗
+ _do_attack_swing(tnode, rv)
_clear_reserved()
# --- §3.4 其余三种预约模式(NEW_RefreshMouseWalkingDirection 的其它 case)------------
@@ -1284,6 +1623,10 @@ func _invoke_reserved_skill() -> bool:
# NEW_GetGroundItemPosition(PythonPlayerInputMouse.cpp)——按 IID 在地面掉落表查世界坐标。
# M2Client.get_ground_items() 的 pos 是 net 帧(同实体 pos),需过 MapCoord 到 world 帧。
func _ground_item_world_pos(iid: int):
+ if pc and "ground_items" in pc and pc.ground_items != null and pc.ground_items.has_method("get_item_world_pos"):
+ var p = pc.ground_items.get_item_world_pos(iid)
+ if p != null:
+ return p
if client == null or not client.has_method("get_ground_items"):
return null
for gi in client.get_ground_items():
@@ -1326,9 +1669,12 @@ func pick_ground_item(iid: int) -> void:
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:
+ if pc.player.global_position.distance_to(item_pos) * CM <= CLICK_ITEM_PICKUP_CM:
_send_state(FUNC_WAIT, 0, pc.player.position)
- if client.has_method("pickup_item"):
+ var gi = pc.ground_items if (pc and "ground_items" in pc) else null
+ if gi != null and gi.has_method("try_pickup_vid"):
+ gi.try_pickup_vid(iid)
+ elif client.has_method("pickup_item"):
client.pickup_item(iid)
pc.set("frozen", false)
else:
@@ -1350,7 +1696,13 @@ func _is_huge_race(race: int) -> bool:
# (对齐 __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]:
+ var kind := _entity_kind(e)
+ if e.is_empty() or bool(e.get("dead", false)) or kind not in [
+ EntityRules.KIND_PC,
+ EntityRules.KIND_MONSTER,
+ EntityRules.KIND_STONE,
+ EntityRules.KIND_WOODEN_DOOR,
+ ]:
_target_vid = 0
client.set_target(0)
target_changed.emit(0)
@@ -1485,14 +1837,23 @@ func send_fly_targeting(target_vid: int, range_cm: float, max_count: int, shape:
# 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)
+ if tnode != null and is_instance_valid(tnode) and pc and pc.player:
+ var to: Vector3 = _world_pos(tnode) - _world_pos(pc.player)
+ to.y = 0.0
+ 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 未登记)→ 退化为单段普攻。
+ # 没有连击段表(headless / 缺资源 / 该 mode×type 未登记)→ 对齐 40250 __CanInputNormalAttackCommand
+ if _is_attacking and _attack_cd > 0.0:
+ var md := _motion_data()
+ var dur := float(md.get("duration", attack_period))
+ if dur <= 0.0:
+ dur = attack_period
+ if _attacking_elapsed() <= dur * 0.9:
+ return
_emit_swing(NAME_NORMAL_ATTACK, false)
return
@@ -1543,6 +1904,7 @@ func _clear_combo() -> void:
_combo_index = 0
_is_pre_input = false
_is_next_pre_input = false
+ _is_attacking = false
_hit_windows = []
_hit_dedup.clear()
@@ -1604,7 +1966,13 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
# (PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
- client.attack(_swing_skill, _vid_reserved)
+ var target := _target_vid if _target_vid != 0 else _vid_reserved
+ if target == 0 and _auto_attack_vid != 0:
+ target = _auto_attack_vid
+ if target == 0:
+ target = get_front_instance(250.0)
+ if target != 0:
+ client.attack(_swing_skill, target)
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
@@ -1640,6 +2008,7 @@ func _begin_hit_windows() -> void:
_hit_external_force = float(md.get("external_force", 0.0))
_hit_dedup.clear()
_motion_start_t = _local_time
+ _is_attacking = true
# CInstanceBase::AttackProcess(InstanceBaseBattle.cpp:413)-> CActorInstance::AttackingProcess ->
# __NormalAttackProcess(ActorInstanceCollisionDetection.cpp:333):挥击动作播放中,每帧用 .msa
@@ -1652,18 +2021,19 @@ func _attack_process(dt: float) -> void:
return
_atk_frame += 1
var t := _attacking_elapsed()
+ var eff_dt := dt * _atk_speed_factor
# 动作已越过所有命中窗 -> 收起(等同 !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:
+ if t - eff_dt > latest_end:
_hit_windows = []
_hit_dedup.clear()
return
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
- var a_pos := _actor_cm(pc.player.global_position)
+ var a_pos := _actor_cm(_world_pos(pc.player))
var yaw := _player_yaw()
for e in client.get_entities():
var vid := int(e.get("vid", 0))
@@ -1694,7 +2064,7 @@ static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float,
t: float, dt: float) -> void:
- var v_pos := _actor_cm(vnode.global_position)
+ var v_pos := _actor_cm(_world_pos(vnode))
var huge := HitCollision.is_huge_race(int(e.get("race", 0)))
# 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留)
var dx := v_pos.x - a_pos.x
@@ -1704,36 +2074,56 @@ func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vecto
if dist2 >= lim * lim:
return
var spheres := _defending_spheres(vid, vnode, v_pos)
+ var eff_dt := dt * _atk_speed_factor
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
- if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
+ if t < float(w.get("start_time", 0.0)) or (t - eff_dt) > float(w.get("end_time", 0.0)):
continue
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
var m: Dictionary = _hit_dedup.get(wi, {})
if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time):
continue
var samples: Variant = w.get("samples", [])
- if not (samples is Array):
- continue
- for smp in samples:
- var st := float(smp.get("time", 0.0))
- if st < t - dt or st > t:
- continue
- var p: Vector3 = smp.get("pos", Vector3.ZERO)
- var lp: Vector3 = smp.get("last_pos", p)
- var d_pos := _rot_cm(p, yaw) + a_pos
- var d_last := _rot_cm(lp, yaw) + a_pos
- for sp in spheres:
- if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
- sp["last"], sp["pos"], float(sp["radius"])):
+ var hit_detected := false
+ if samples is Array and not (samples as Array).is_empty():
+ for smp in samples:
+ var st := float(smp.get("time", 0.0))
+ if st < t - eff_dt or st > t:
continue
- if not _register_hit(wi, vid):
- return
- var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5
- _process_attack_success(e, vid, vnode, hit_pos, huge)
+ var p: Vector3 = smp.get("pos", Vector3.ZERO)
+ var lp: Vector3 = smp.get("last_pos", p)
+ var d_pos := _rot_cm(p, yaw) + a_pos
+ var d_last := _rot_cm(lp, yaw) + a_pos
+ for sp in spheres:
+ if HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
+ sp["last"], sp["pos"], float(sp["radius"])):
+ hit_detected = true
+ break
+ if hit_detected:
+ break
+ else:
+ # 采样点为空时的近战朝向判定(正前方扇面)
+ var fwd := Vector3(sin(yaw), 0, -cos(yaw))
+ var to_mob := Vector3(dx, 0, dz).normalized()
+ if fwd.dot(to_mob) > 0.2:
+ hit_detected = true
+
+ # 近战兜底:如果在 2 米内且正对目标(锥角 ~60 度),在命中窗口内判定命中
+ if not hit_detected and dist2 < 200.0 * 200.0:
+ var fwd := Vector3(sin(yaw), 0, -cos(yaw))
+ var to_mob := Vector3(dx, 0, dz).normalized()
+ if fwd.dot(to_mob) > 0.3:
+ hit_detected = true
+
+ if hit_detected:
+ if not _register_hit(wi, vid):
return
+ var hit_pos: Vector3 = (a_pos + (spheres[0]["pos"] as Vector3)) * 0.5 if huge and not spheres.is_empty() else (a_pos + v_pos) * 0.5
+ _process_attack_success(e, vid, vnode, hit_pos, huge)
+ return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
+# 若挂骨骼(isAttached)且包含动画与骨骼姿态,使用实时骨骼位姿(matBone * matWorld,ActorInstanceCollisionDetection.cpp:66);
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
var raw: Array = []
@@ -1742,9 +2132,26 @@ func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
if raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
var vyaw := vnode.rotation.y
+ var anim: Node = vnode.get("anim") if vnode else null
+ var model: Node3D = vnode.get("model") as Node3D if vnode else null
var cur: Array = []
for sp in raw:
- cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
+ var spos: Vector3 = sp.get("pos", Vector3.ZERO)
+ var bone: String = sp.get("bone", "")
+ var sphere_pos_cm := Vector3.ZERO
+ var has_bone_transform := false
+ if bone != "" and anim != null and anim.has_method("get_effect_bone_pose") and model != null:
+ var pose: Dictionary = anim.get_effect_bone_pose(bone)
+ if pose.has("transform"):
+ var bone_xform: Transform3D = pose["transform"]
+ # 40250 ActorInstanceCollisionDetection.cpp:40: matDefending = matBone * matWorld, D3DXVec3TransformCoord
+ # spos in .msm is in cm, bone_xform / model transform is in meters
+ var world_m: Vector3 = model.global_transform * (bone_xform * (spos / 100.0))
+ sphere_pos_cm = _actor_cm(world_m)
+ has_bone_transform = true
+ if not has_bone_transform:
+ sphere_pos_cm = _rot_cm(spos, vyaw) + v_pos
+ cur.append(sphere_pos_cm)
var prev: Dictionary = _def_last.get(vid, {})
var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size()
var out: Array = []
@@ -1807,10 +2214,15 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
+ var resist_fallen := _is_resist_fallen(e)
+ # 40250 CActorInstance::__Shake(100)
+ if vnode.has_method("shake"):
+ vnode.shake()
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
- elif _hit_type == HIT_TYPE_GOOD:
+ elif _hit_type == HIT_TYPE_GOOD or resist_fallen:
+ # 40250 ActorInstanceBattle.cpp:687: if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || rVictim.IsResistFallen())
if vnode.has_method("hit_good"):
vnode.hit_good(scalar, stunned)
elif _hit_type == HIT_TYPE_GREAT:
@@ -1818,12 +2230,28 @@ func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Ve
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
+const AFFECT_CHEONGEUN_BIT := 1 << 16
+const AFFECT_FALLEN_CHEONGEUN_BIT := 1 << 33
+
+# 40250 CActorInstance::IsResistFallen (ActorInstance.cpp:405 / InstanceBaseEffect.cpp:891)
+# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN)、Boss 或巨型怪物拥有霸体抗击退状态
+func _is_resist_fallen(e: Dictionary) -> bool:
+ var flags: int = int(e.get("affect_flags", 0))
+ if (flags & AFFECT_CHEONGEUN_BIT) != 0 or (flags & AFFECT_FALLEN_CHEONGEUN_BIT) != 0:
+ return true
+ if bool(e.get("resist_fallen", false)):
+ return true
+ var race: int = int(e.get("race", 0))
+ if HitCollision.is_huge_race(race) or _is_huge_race(race):
+ return true
+ return false
+
# CActorInstance::__CanPushDestActor(ActorInstanceBattle.cpp)
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
var kind := _entity_kind(e)
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
return false
- if huge:
+ if huge or _is_resist_fallen(e):
return false
if bool(e.get("stunned", false)):
return true
diff --git a/project/net_world.gd b/project/net_world.gd
index f644d022..82880f26 100644
--- a/project/net_world.gd
+++ b/project/net_world.gd
@@ -28,6 +28,7 @@ const NameTailLayout = preload("res://name_tail_layout.gd")
const TextMetrics = preload("res://text_metrics.gd")
const ScreenHpBar = preload("res://screen_hp_bar.gd")
const PhysicsPush = preload("res://physics_push.gd")
+const UiAssets = preload("res://ui/ui_assets.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
@@ -102,10 +103,17 @@ var snap_dist := 6.0
var _by_vid := {} # vid:int -> Node3D
# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos}
var _push := {}
+var skill_table: RefCounted
+var fx: RefCounted
var _main_vid := 0
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
var _model_factory: Callable # func(Dictionary) -> Node3D
+var _upgrade_queue: Array[int] = [] # Throttled queue for replacing placeholder capsules with real models
+const MAX_UPGRADES_PER_FRAME := 4
+const MAX_UPGRADE_TIME_MSEC := 4
+const VISIBILITY_CHECK_INTERVAL := 0.1
+var _vis_accum := 0.0
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 ⑧)
@@ -212,7 +220,7 @@ func catch_up() -> void:
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid != 0 and not _by_vid.has(vid) and vid != _local_vid:
- _on_spawn(e)
+ _on_spawn(e, true)
if client.has_method("get_pvp_relations"):
for relation in client.get_pvp_relations():
_on_pvp_changed(int(relation.get("src_vid", 0)), int(relation.get("dst_vid", 0)),
@@ -236,9 +244,10 @@ func _refresh_existing_model_views() -> void:
var id := int(vid)
if id == _local_vid:
continue
- var d: Dictionary = client.get_entity(id)
- if not d.is_empty():
- _on_spawn(d)
+ var n: Node3D = _by_vid.get(id, null)
+ # Only enqueue if it's currently a placeholder or needing model rebuild
+ if is_instance_valid(n) and not _upgrade_queue.has(id):
+ _upgrade_queue.append(id)
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
func set_local_vid(vid: int) -> void:
@@ -377,27 +386,22 @@ func main_node() -> Node3D:
# ---------------------------------------------------------------------------
-func _on_spawn(d: Dictionary) -> void:
+func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
var vid := int(d.get("vid", 0))
- if vid == 0 or vid == _local_vid:
+ if vid == 0 or vid == _local_vid or vid == _main_vid or bool(d.get("is_main", false)):
return
# §2.1: a node that is blending out is still "present" until the tween frees
# it — don't rebuild it mid-fade (mirrors GetInstancePtr/UpdateDeleting).
if _fading.has(vid):
return
# §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node
- # destroys and recreates the node (the data row was whole-replaced). The one
- # exception is a mount / dismount driven rebuild, which keeps the old world
- # position (reference: kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y —
- # the Y flip is the server<->pixel frame, already applied, so in Godot world
- # space this is just the old node's global_position).
+ # destroys and recreates the node (the data row was whole-replaced).
var keep_pos: Variant = null
+ var keep_rot: Variant = null
var existing: Node3D = _by_vid.get(vid, null)
if is_instance_valid(existing):
- var old_mount := int(existing.get_meta("mount_vnum", 0))
- var new_mount := int(d.get("mount_vnum", 0))
- if (old_mount == 0) != (new_mount == 0):
- keep_pos = existing.global_position
+ keep_pos = existing.global_position
+ keep_rot = existing.rotation.y
_by_vid.erase(vid)
existing.queue_free()
elif not _is_visible_entity(d):
@@ -405,9 +409,15 @@ func _on_spawn(d: Dictionary) -> void:
# once the entity is in view. _update_visibility() creates it later when
# the main character moves into CHAR_STAGE_VIEW_BOUND.
return
+ var is_main: bool = (vid == _main_vid or bool(d.get("is_main", false)))
var node: Node3D = null
- if _model_factory.is_valid():
+ if allow_defer and not is_main and _model_factory.is_valid():
+ node = _make_placeholder(d)
+ if not _upgrade_queue.has(vid):
+ _upgrade_queue.append(vid)
+ elif _model_factory.is_valid():
node = _model_factory.call(d)
+ _upgrade_queue.erase(vid)
if node == null:
node = _make_placeholder(d)
elif not node.has_node("Label3D"):
@@ -416,13 +426,24 @@ func _on_spawn(d: Dictionary) -> void:
parent.add_child(node)
if keep_pos is Vector3:
node.global_position = keep_pos
+ if keep_rot is float:
+ node.rotation.y = keep_rot
else:
node.position = _grounded(d.get("pos", Vector3.ZERO))
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")
+ if kind_resolver.is_valid():
+ ch_type = int(kind_resolver.call(d))
+ var shape := "ATTACK"
+ var explicit_kind := int(d.get("kind", -1))
+ if explicit_kind == EntityRules.KIND_WOODEN_DOOR or EntityRules._is_wooden_door(d) \
+ or bool(d.get("building", false)) or explicit_kind == EntityRules.KIND_BUILDING:
+ shape = "DOOR"
+ elif ch_type in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
+ shape = "TALK"
+ node.set_meta("cursor_shape", shape)
_by_vid[vid] = node
_sync_hp_bar(vid, d)
_set_name_visibility(node)
@@ -487,7 +508,7 @@ func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
if n.has_method("set_guild"):
n.call("set_guild", int(d.get("guild", 0)))
if n.has_method("set_affect_flags"):
- n.call("set_affect_flags", int(d.get("affect_flags", 0)))
+ n.call("set_affect_flags", int(d.get("affect_flags", 0)), fx)
elif n.has_method("set_affects"):
n.call("set_affects", int(d.get("affect_flags", 0)))
if n.has_method("set_move_speed"):
@@ -538,7 +559,7 @@ func _update_visibility() -> void:
dist = _planar_cm(e).distance_to(main_cm)
if forced or dist < CHAR_STAGE_VIEW_BOUND:
if not has_node:
- _on_spawn(e)
+ _on_spawn(e, true)
elif has_node and dist > cull:
_fade_and_free(vid)
@@ -571,7 +592,15 @@ func _show_always(e: Dictionary) -> bool:
func _is_wall(race: int) -> bool:
return race in WALL_RACES
-# §2.1 cull: keep the data row, blend the node out. It stays discoverable via
+func _detach_text_tail(n: Node3D) -> void:
+ if not is_instance_valid(n):
+ return
+ for tag_name in ["NameTag", "GuildTag", "TitleTag", "LevelTag", "GuildMark", "Shadow", "ShopSign"]:
+ var node := n.get_node_or_null(tag_name)
+ if node != null:
+ node.visible = false
+
+# §2.1 cull / 40250 DeleteBlendOut: keep the data row, blend the node out. It stays discoverable via
# _fading (so it is not rebuilt mid-blend) until the tween frees it.
func _fade_and_free(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
@@ -581,8 +610,11 @@ func _fade_and_free(vid: int) -> void:
return
_fading[vid] = true
entity_removed.emit(vid)
+ _remove_hp_bar(vid)
+ _detach_text_tail(n)
var tw := create_tween()
- tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.3)
+ tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.4)
+ tw.parallel().tween_property(n, "position:y", n.position.y - 0.5, 0.4)
tw.tween_callback(func() -> void:
if is_instance_valid(n):
n.queue_free()
@@ -856,16 +888,17 @@ func set_pk_title_enabled(enabled: bool) -> void:
_retint_all_text_tails()
func _on_despawn(vid: int) -> void:
+ _upgrade_queue.erase(vid)
_duel_opponents.erase(vid)
- _fading.erase(vid)
_chat_tails.erase(vid)
_push.erase(vid)
_remove_hp_bar(vid)
- var n: Node3D = _by_vid.get(vid, null)
- if n:
- _by_vid.erase(vid)
- n.queue_free()
- entity_removed.emit(vid)
+ if _target_vid == vid:
+ _target_vid = 0
+ if _hover_vid == vid:
+ _hover_vid = 0
+ if _by_vid.has(vid):
+ _fade_and_free(vid)
func _on_main_set(vid: int) -> void:
_main_vid = vid
@@ -1091,20 +1124,40 @@ func _on_vitals(vid: int) -> void:
var dead := bool(e.get("dead", false))
vitals.emit(vid, hp, max_hp, dead)
_sync_hp_bar_values(vid, hp, max_hp, dead)
+ if dead or (max_hp > 0 and hp <= 0):
+ if n and is_instance_valid(n) and not bool(n.get_meta("dead", false)):
+ _on_dead(vid)
func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
+ # 40250 CInstanceBase::Die() -> DetachTextTail(), OnUnselected(), OnUntargeted()
+ _remove_hp_bar(vid)
+ _detach_text_tail(n)
+ n.set_meta("dead", true)
+
+ if _target_vid == vid:
+ _target_vid = 0
+ if _hover_vid == vid:
+ _hover_vid = 0
+
# CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型;
# 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。
if n.has_method("set_anim_state"):
- n.set_meta("dead", true)
n.call("set_anim_state", "dead")
else:
var t := create_tween()
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
+ # 40250 UpdateDeleting / DeleteInstanceByFade:
+ # 尸体倒地停留 2.5 秒后平滑渐隐沉地释放;若服务端提前到达 GC_CHARACTER_DEL 则在 _on_despawn 中直接 fade_and_free。
+ var tw := create_tween()
+ tw.tween_interval(2.5)
+ tw.tween_callback(func() -> void:
+ if is_instance_valid(n) and _by_vid.get(vid) == n:
+ _fade_and_free(vid))
+
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费
# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道
@@ -1138,15 +1191,66 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
_fly_mgr.world = world
var start_w := a + Vector3(0, 1.0, 0)
var goal_w := b + Vector3(0, 1.0, 0)
+ var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w
+
+ if _type == FlyObject.FLY_EXP:
+ # 40250 官方经验聚能光球: ga_piece_yellow_small2.msf
+ # 自导向追踪玩家胸膛 (is_homing=true, homing_start_time=0.05, homing_max_angle=140.0)
+ for k in range(3):
+ var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25))
+ var orb_d = FlyObject.FlyData.new()
+ orb_d.init_vel = 11.0 + k * 2.0
+ orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0)
+ orb_d.bomb_range = 0.8
+ orb_d.is_homing = true
+ orb_d.homing_start_time = 0.05 + k * 0.04
+ orb_d.homing_max_angle = 140.0
+ orb_d.maintain_parallel = true
+ orb_d.pierce_count = 0
+ _fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP)
+ return
+
# 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)
+# 40250 供单机/离线击杀或事件直接调用发射经验光球
+func spawn_exp_fly(from_pos: Vector3, to_node: Node3D = null, count: int = 3) -> void:
+ if _fly_mgr == null or parent == null:
+ return
+ _fly_mgr.parent = parent
+ _fly_mgr.world = world
+
+ var start_w := from_pos + Vector3(0, 0.8, 0)
+ var tgt_node: Node3D = to_node
+ if tgt_node == null:
+ var main_v: int = client.get_main_vid() if client and client.has_method("get_main_vid") else 1
+ tgt_node = _by_vid.get(main_v, null)
+
+ var tgt_pos := (tgt_node.global_position if tgt_node.is_inside_tree() else tgt_node.position) if is_instance_valid(tgt_node) else start_w + Vector3(0, 1, 2)
+ var goal_w := tgt_pos + Vector3(0, 1.0, 0)
+ var tgt: Variant = tgt_node if is_instance_valid(tgt_node) else goal_w
+
+ for k in range(count):
+ var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25))
+ var orb_d = FlyObject.FlyData.new()
+ orb_d.init_vel = 11.0 + k * 2.0
+ orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0)
+ orb_d.bomb_range = 0.8
+ orb_d.is_homing = true
+ orb_d.homing_start_time = 0.05 + k * 0.04
+ orb_d.homing_max_angle = 140.0
+ orb_d.maintain_parallel = true
+ orb_d.pierce_count = 0
+ _fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP)
+
+func get_entity_node(vid: int) -> Node3D:
+ return _by_vid.get(vid, null)
+
# 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
# entity exists (target VID 0 carries an explicit coordinate).
@@ -1213,14 +1317,56 @@ func _on_fishing_event(subheader: int, info: int, dir: int) -> void:
n.set_meta("fishing_dir", dir)
var state := "fishing"
match subheader:
- 1: state = "wait" # STOP
- 2: state = "fishing_react" # REACT
- 3: state = "fishing_catch" # SUCCESS
- 4: state = "fishing_fail" # FAIL
+ 1:
+ _clear_fish_emoticon(n)
+ state = "wait" # STOP
+ 2:
+ _show_fish_emoticon(n)
+ state = "fishing_react" # REACT
+ 3:
+ _clear_fish_emoticon(n)
+ state = "fishing_catch" # SUCCESS
+ 4:
+ _clear_fish_emoticon(n)
+ state = "fishing_fail" # FAIL
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
fishing_event.emit(info, subheader, dir)
+func _show_fish_emoticon(n: Node3D) -> void:
+ if n == null or not is_instance_valid(n):
+ return
+ var old_emo := n.get_node_or_null("FishEmoticon")
+ if old_emo != null:
+ old_emo.queue_free()
+ var assets_path := AssetRoot.path() if AssetRoot.available() else ""
+ var tex: Texture2D = null
+ if assets_path != "":
+ tex = UiAssets.load_tex(assets_path, "Effect/ymir work/effect/etc/emoticon/fish.dds")
+ var emo := Sprite3D.new()
+ emo.name = "FishEmoticon"
+ emo.billboard = BaseMaterial3D.BILLBOARD_ENABLED
+ emo.no_depth_test = true
+ emo.pixel_size = 0.008
+ emo.position = Vector3(0, 2.4, 0)
+ if tex != null:
+ emo.texture = tex
+ n.add_child(emo)
+ emo.scale = Vector3(0.3, 0.3, 0.3)
+ var tw := emo.create_tween()
+ tw.tween_property(emo, "scale", Vector3(1.2, 1.2, 1.2), 0.15)
+ tw.tween_property(emo, "scale", Vector3(1.0, 1.0, 1.0), 0.1)
+ tw.tween_interval(2.5)
+ tw.tween_property(emo, "modulate:a", 0.0, 0.3)
+ tw.tween_callback(emo.queue_free)
+
+func _clear_fish_emoticon(n: Node3D) -> void:
+ if n == null or not is_instance_valid(n):
+ return
+ var emo := n.get_node_or_null("FishEmoticon")
+ if emo != null:
+ emo.queue_free()
+
# GC_DAMAGE_INFO -> RecvDamageInfoPacket -> CInstanceBase::AddDamageEffect。
# damage_number 信号是本移植扩展(战斗日志 / 测试消费),始终发;
# 飘字本体照 AddDamageEffect:仅 IsShowDamage() 时入 m_DamageQueue,ProcessDamage 每帧 pop 一条。
@@ -1228,6 +1374,8 @@ func _on_damage(vid: int, amount: int, flag: int) -> void:
var crit := (flag & DamageEffect.DAMAGE_CRITICAL) != 0
var dodge := (flag & DamageEffect.DAMAGE_DODGE) != 0
damage_number.emit(vid, amount, crit, dodge)
+ if crit and _camera != null and _camera.has_method("shake"):
+ _camera.shake(0.05, 9.0)
if not DamageEffect.valid_amount(amount):
return # RecvDamageInfoPacket:damage < 0 -> TraceError,不处理
if not DamageEffect.should_queue(_show_damage):
@@ -1238,8 +1386,12 @@ func _on_damage(vid: int, amount: int, flag: int) -> void:
"target": DamageEffect.is_target(vid, _target_vid),
})
-# ProcessDamage:每帧 pop_front 一条。DODGE|BLOCK -> "MISS";nontarget 数字 -> 不渲染;
-# 其余把 amount 铺一块 billboard Label3D(seam ⑪:无 damagevalue dds 图集 / 逐位特效)。
+# ProcessDamage:每帧 pop_front 一条。
+# 40250 官方 1:1(InstanceBaseEffect.cpp:150-205):
+# DODGE|BLOCK -> MISS(自身 damage_miss.dds,目标 target_miss.dds);
+# nontarget 数字 -> 不渲染;
+# self / target 伤害数字按 FONT_WIDTH=30px 水平排布真实 DDS 纹理(damagevalue/),
+# 纹理缺失时安全降级为 Label3D。
func _process_damage_queue() -> void:
if _damage_queue.is_empty():
return
@@ -1250,35 +1402,129 @@ func _process_damage_queue() -> void:
var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"]))
if not info["show"]:
return # ProcessDamage:nontarget_ 后直接 return(不渲染)
- var text := "MISS"
- if info["kind"] == DamageEffect.Kind.DIGITS:
- if DamageEffect.digits(int(d["amount"])).is_empty():
- return # while(damage>0) 零次 -> 无贴图
- text = str(int(d["amount"]))
- # self 数字:SELFDAMAGE / SELFDAMAGE2 交替(seam ⑪:退化成落字水平抖动符号交替)。
+
+ # self 数字:SELFDAMAGE / SELFDAMAGE2 交替(水平抖动交替)
var jitter := 0.0
if bool(d["self"]) and info["kind"] == DamageEffect.Kind.DIGITS:
jitter = 0.3 if DamageEffect.self_damage_slot(_dmg_self_toggle) == 0 else -0.3
_dmg_self_toggle = not _dmg_self_toggle
- var lbl := Label3D.new()
- lbl.text = text
- lbl.modulate = Color(1, 1, 1) # 该 build 无独立暴击 / self 颜色(ProcessDamage)
- lbl.font_size = 32
- lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
- lbl.no_depth_test = true
- lbl.pixel_size = 0.007
- parent.add_child(lbl)
- lbl.global_position = n.global_position + Vector3(jitter, 1.9, 0)
+
+ var root_node := Node3D.new()
+ root_node.name = "dmg_%d" % int(d["vid"])
+ parent.add_child(root_node)
+ root_node.global_position = n.global_position + Vector3(jitter, 1.9, 0)
+
+ var assets_path := AssetRoot.path() if AssetRoot.available() else ""
+ var built_sprites := false
+
+ if assets_path != "":
+ if info["kind"] == DamageEffect.Kind.MISS:
+ var miss_file := "damage_miss.dds" if bool(d["self"]) else "target_miss.dds"
+ var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/" + miss_file)
+ if tex != null:
+ var sp := Sprite3D.new()
+ sp.texture = tex
+ sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED
+ sp.no_depth_test = true
+ sp.pixel_size = 0.007
+ root_node.add_child(sp)
+ built_sprites = true
+ elif info["kind"] == DamageEffect.Kind.DIGITS:
+ var digit_arr: Array = DamageEffect.digits(int(d["amount"]))
+ if digit_arr.is_empty():
+ root_node.queue_free()
+ return
+ var prefix: String = String(info.get("type", "target_"))
+ var total_digits: int = digit_arr.size()
+ var sprites: Array[Sprite3D] = []
+ var all_loaded := true
+ for i in total_digits:
+ var dig: int = int(digit_arr[i])
+ var tex: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/%s%d.dds" % [prefix, dig])
+ if tex == null:
+ all_loaded = false
+ break
+ var sp := Sprite3D.new()
+ sp.texture = tex
+ sp.billboard = BaseMaterial3D.BILLBOARD_ENABLED
+ sp.no_depth_test = true
+ sp.pixel_size = 0.007
+ # 40250 matTrans._41 = -(FONT_WIDTH * index)(低位在右,高位在左)+ 水平居中
+ var px_offset := -float(DamageEffect.FONT_WIDTH * i) + float(DamageEffect.FONT_WIDTH * (total_digits - 1)) * 0.5
+ sp.position.x = px_offset * 0.007
+ sprites.append(sp)
+ if all_loaded and not sprites.is_empty():
+ for sp in sprites:
+ root_node.add_child(sp)
+ built_sprites = true
+ else:
+ for sp in sprites:
+ sp.queue_free()
+
+ if not built_sprites:
+ var text := "MISS"
+ if info["kind"] == DamageEffect.Kind.DIGITS:
+ if DamageEffect.digits(int(d["amount"])).is_empty():
+ root_node.queue_free()
+ return
+ text = str(int(d["amount"]))
+ var lbl := Label3D.new()
+ lbl.text = text
+ if int(d["flag"]) & DamageEffect.DAMAGE_CRITICAL != 0:
+ lbl.modulate = Color(1.0, 0.85, 0.2)
+ lbl.font_size = 46
+ lbl.outline_size = 8
+ lbl.outline_modulate = Color(0.3, 0.1, 0.0)
+ else:
+ lbl.modulate = Color(1, 1, 1)
+ lbl.font_size = 32
+ lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
+ lbl.no_depth_test = true
+ lbl.pixel_size = 0.007
+ root_node.add_child(lbl)
+
var tw := create_tween()
tw.set_parallel(true)
- tw.tween_property(lbl, "global_position:y", lbl.global_position.y + 1.2, 0.8)
- tw.tween_property(lbl, "modulate:a", 0.0, 0.8).set_delay(0.3)
- tw.chain().tween_callback(lbl.queue_free)
+ tw.tween_property(root_node, "global_position:y", root_node.global_position.y + 1.2, 0.8)
+ for ch in root_node.get_children():
+ if ch is Sprite3D or ch is Label3D:
+ tw.tween_property(ch, "modulate:a", 0.0, 0.8).set_delay(0.3)
+ tw.chain().tween_callback(root_node.queue_free)
+
+func _process_upgrade_queue() -> void:
+ if _upgrade_queue.is_empty() or not _model_factory.is_valid() or client == null or not client.has_method("get_entity"):
+ return
+ var m := main_node()
+ if is_instance_valid(m) and _upgrade_queue.size() > 1:
+ var mpos := m.global_position
+ _upgrade_queue.sort_custom(func(a: int, b: int) -> bool:
+ var na: Node3D = _by_vid.get(a, null)
+ var nb: Node3D = _by_vid.get(b, null)
+ var da := na.global_position.distance_squared_to(mpos) if is_instance_valid(na) else INF
+ var db := nb.global_position.distance_squared_to(mpos) if is_instance_valid(nb) else INF
+ return da < db
+ )
+ var start_ms := Time.get_ticks_msec()
+ var count := 0
+ while not _upgrade_queue.is_empty() and count < MAX_UPGRADES_PER_FRAME:
+ var vid: int = _upgrade_queue.pop_front()
+ if vid == _local_vid:
+ continue
+ var d: Dictionary = client.get_entity(vid)
+ if not d.is_empty() and _by_vid.has(vid):
+ _on_spawn(d, false)
+ count += 1
+ if (Time.get_ticks_msec() - start_ms) >= MAX_UPGRADE_TIME_MSEC:
+ break
func _process(dt: float) -> void:
if client == null or parent == null:
return
- _update_visibility()
+ _process_upgrade_queue()
+ _vis_accum += dt
+ if _vis_accum >= VISIBILITY_CHECK_INTERVAL:
+ _vis_accum = 0.0
+ _update_visibility()
_process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage)
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
@@ -1299,6 +1545,11 @@ func _process(dt: float) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
continue
+ var is_dead := bool(e.get("dead", false)) or bool(n.get_meta("dead", false))
+ if is_dead:
+ if not bool(n.get_meta("dead", false)):
+ _on_dead(vid)
+ continue
# §3.2:位置插值现在完全在 C++ EntityStore::tick() 里做(命令队列 +
# SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再
# 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。
@@ -1307,10 +1558,13 @@ func _process(dt: float) -> void:
want = _pushed_position(vid, e, dt, want)
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
- # CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s,
- # 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。
+ # CActorInstance::RotationProcess / SetAdvancingRotation:
+ # 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/s)。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED
+ var diff: float = wrapf(yaw - n.rotation.y, -PI, PI)
+ if absf(diff) <= deg_to_rad(45.0):
+ turn_rate *= 5.0 / 12.0
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
@@ -1336,6 +1590,13 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dea
elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
+ if (f & FUNC_SKILL) != 0:
+ var mot_idx := f & 0x7f
+ if mot_idx > 0 and skill_table and n.has_method("play_skill_motion"):
+ var mname: String = skill_table.motion_name_by_idx(mot_idx)
+ if mname != "":
+ n.call("play_skill_motion", mname)
+ return
state = "skill"
elif f == FUNC_EMOTION:
state = "emotion"
@@ -1367,7 +1628,15 @@ func push_victim(vid: int, dir: Vector2, force: float) -> void:
pe = {"obj": PhysicsPush.new(), "off": Vector2.ZERO, "base": e.get("pos")}
_push[vid] = pe
pe.obj.set_direction(dir)
- pe.obj.increase_external_force(force)
+ var collide_fn := Callable()
+ var base_world := MapCoord.to_world(e.get("pos", Vector3.ZERO))
+ # 40250 CPhysicsObject::IncreaseExternalForce: pWorld->isPhysicalCollision
+ # CMapManager::isPhysicalCollision 仅判定地形 ATTRIBUTE_BLOCK(world.is_blocked)
+ collide_fn = func(movement_cm: Vector2) -> bool:
+ var cand_x := base_world.x + movement_cm.x / 100.0
+ var cand_z := base_world.z - movement_cm.y / 100.0
+ return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_x, cand_z))
+ pe.obj.increase_external_force(force, collide_fn)
# CActorInstance::IsPushing -> m_PhysicsObject.isBlending()
func is_pushing(vid: int) -> bool:
@@ -1384,7 +1653,7 @@ func blending_position(vid: int) -> Vector3:
return n.global_position + Vector3(last.x / 100.0, 0.0, -last.y / 100.0)
# CActorInstance::PhysicsProcess:每帧 m_PhysicsObject.Update + AddMovement(GetX/YMovement)。
-# 位移叠在服务器位置上;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移(seam)。
+# 位移叠在服务器位置上;遇地形阻挡则触发 BlockMovement 停在边界;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移。
func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3:
var pe: Dictionary = _push[vid]
var obj = pe.obj
@@ -1398,7 +1667,14 @@ func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vect
pe.base = pos
if obj.is_blending():
obj.update(dt)
- pe.off += obj.get_movement()
+ var d_move: Vector2 = obj.get_movement()
+ var cand_off: Vector2 = pe.off + d_move
+ var cand_world := MapCoord.to_world(pos) + Vector3(cand_off.x / 100.0, 0.0, -cand_off.y / 100.0)
+ if world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", cand_world.x, cand_world.z)):
+ # BlockMovement:撞墙停止位移
+ obj.initialize()
+ else:
+ pe.off = cand_off
var off: Vector2 = pe.off
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
@@ -1488,6 +1764,8 @@ func _sync_hp_bar_values(vid: int, hp: int, max_hp: int, dead: bool) -> void:
var bar := _hp_bars.get(vid, null) as Control
if bar == null or not is_instance_valid(bar):
return
+ bar.set_meta("max_hp", max_hp)
+ bar.set_meta("dead", dead)
if bar.has_method("set_vitals"):
bar.call("set_vitals", hp, max_hp, dead)
bar.visible = max_hp > 0 and not dead
@@ -1526,9 +1804,8 @@ func _update_hp_bars() -> void:
if bar != null and is_instance_valid(bar):
bar.visible = false
continue
- var e: Dictionary = client.get_entity(int(vid))
- var max_hp := int(e.get("max_hp", 0))
- var dead := bool(e.get("dead", false))
+ var max_hp: int = int(bar.get_meta("max_hp", 0))
+ var dead: bool = bool(bar.get_meta("dead", false)) or bool(node.get_meta("dead", false))
if max_hp <= 0 or dead:
bar.visible = false
continue
diff --git a/project/net_world_push_test.gd.uid b/project/net_world_push_test.gd.uid
new file mode 100644
index 00000000..453aaff1
--- /dev/null
+++ b/project/net_world_push_test.gd.uid
@@ -0,0 +1 @@
+uid://dtksog42nbeco
diff --git a/project/netplay_test.gd b/project/netplay_test.gd
index d1ec0510..9ac9ab38 100644
--- a/project/netplay_test.gd
+++ b/project/netplay_test.gd
@@ -50,8 +50,11 @@ class FakePC extends Node:
var server_speed := 1.0
var walk_calls := []
var frozen := false
+ var going := 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)
+ func walk_to(p: Vector3) -> void: walk_calls.append(p); going = true
+ func is_going() -> bool: return going
+ func stop() -> void: going = false
class FakeHud extends Node:
var vitals := []
@@ -126,7 +129,7 @@ func _run() -> void:
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),
+ _ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 0.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)
@@ -173,10 +176,34 @@ func _run() -> void:
# 窗口由服务端 cube_open 命令打开前的入口。
var npc := Node3D.new()
npc.set_meta("vid", 3000)
+ npc.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 交互距离内(<= 5m)
root.add_child(npc)
+ nw._by_vid[3000] = npc
fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position}
pc.target_selected.emit(npc)
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
+
+ # 远距离点选 NPC(> 5m):预约走近,到达交互距离后停下并触发 click_npc
+ var far_npc := Node3D.new()
+ far_npc.set_meta("vid", 3010)
+ far_npc.position = Vector3(20, 0, -5) # 距玩家 16.7m -> 超出 5m 交互范围
+ root.add_child(far_npc)
+ nw._by_vid[3010] = far_npc
+ fc.ents[3010] = {"vid": 3010, "ch_type": 1, "name": "Far NPC", "pos": far_npc.position}
+ pc.target_selected.emit(far_npc)
+ _ck(np._reserved_mode == 2, "远距离点 NPC -> 预约走近 (CLICK_ACTOR)")
+ _ck(pc.is_going(), "远距离点 NPC -> 角色自动向 NPC 移动")
+ _ck(fc.calls.click_npc == [3000], "尚未到达时不发点击包")
+ # 玩家走近到 NPC 交互距离内
+ pc.player.position = Vector3(18, 0, -5)
+ np._reserve_process_click_actor()
+ _ck(fc.calls.click_npc == [3000, 3010], "走近到交互距离后 -> 触发 click_npc(3010)")
+ _ck(np._reserved_mode == 0, "触发交互后清空预约")
+ _ck(not pc.is_going(), "触发交互后停止移动")
+ nw._by_vid.erase(3010)
+ fc.ents.erase(3010)
+ far_npc.queue_free()
+ pc.player.position = Vector3(3.3, 0, -5)
# §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。
_ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR,
"race 13000 -> WOODEN_DOOR")
diff --git a/project/npc_shop_system.gd b/project/npc_shop_system.gd
new file mode 100644
index 00000000..a14f0d36
--- /dev/null
+++ b/project/npc_shop_system.gd
@@ -0,0 +1,187 @@
+# npc_shop_system.gd —— NPC 商店库存配置与买卖经济闭环(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/game/src/shop.cpp:450-580 (Buy, Sell, ITEM_ANTIFLAG_SELL, dwPrice /= 5)
+# - 40250 root/uishop.py (ShopDialog, SetItemData, Buy, Sell)
+# - 40250 uiscript/shopdialog.py (40 格商店货架)
+class_name NpcShopSystem
+extends RefCounted
+
+const SHOP_HOST_ITEM_MAX_NUM := 40
+const ITEM_ANTIFLAG_SELL := 1 << 8
+const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3
+
+# NPC Vnum 常量
+const NPC_WEAPON_SHOP := 9001 # 武器商人
+const NPC_ARMOR_SHOP := 9002 # 防具商人
+const NPC_GENERAL_STORE := 9003 # 杂货商人
+const NPC_FISHERMAN := 9009 # 渔夫
+const NPC_DEONKBAE := 20015 # 矿工德安克贝
+
+# 40250 官方标准商店货架预设表
+const NPC_SHOPS := {
+ NPC_GENERAL_STORE: [
+ {"pos": 0, "vnum": 27001, "count": 200, "price": 1200, "name": "小型红药水"},
+ {"pos": 1, "vnum": 27002, "count": 200, "price": 4800, "name": "中型红药水"},
+ {"pos": 2, "vnum": 27003, "count": 200, "price": 16000, "name": "大型红药水"},
+ {"pos": 3, "vnum": 27004, "count": 200, "price": 2400, "name": "小型蓝药水"},
+ {"pos": 4, "vnum": 27005, "count": 200, "price": 9600, "name": "中型蓝药水"},
+ {"pos": 5, "vnum": 27006, "count": 200, "price": 32000, "name": "大型蓝药水"},
+ {"pos": 6, "vnum": 22001, "count": 1, "price": 10000, "name": "回城卷轴"},
+ {"pos": 7, "vnum": 22010, "count": 1, "price": 12000, "name": "地标传送卷轴"},
+ {"pos": 8, "vnum": 27800, "count": 50, "price": 2000, "name": "鱼饵(蚯蚓)"},
+ {"pos": 9, "vnum": 27400, "count": 1, "price": 50000, "name": "钓竿+0"},
+ {"pos": 10, "vnum": 29101, "count": 1, "price": 80000, "name": "矿镐+0"},
+ ],
+ NPC_WEAPON_SHOP: [
+ {"pos": 0, "vnum": 10, "count": 1, "price": 1000, "name": "长剑+0"},
+ {"pos": 1, "vnum": 1000, "count": 1, "price": 1000, "name": "匕首+0"},
+ {"pos": 2, "vnum": 2000, "count": 1, "price": 1000, "name": "短弓+0"},
+ {"pos": 3, "vnum": 3000, "count": 1, "price": 1000, "name": "长戟+0"},
+ {"pos": 4, "vnum": 5000, "count": 1, "price": 1000, "name": "古铜铃+0"},
+ {"pos": 5, "vnum": 7000, "count": 1, "price": 1000, "name": "折扇+0"},
+ {"pos": 6, "vnum": 8000, "count": 200, "price": 100, "name": "木箭"},
+ ],
+ NPC_ARMOR_SHOP: [
+ {"pos": 0, "vnum": 11200, "count": 1, "price": 1000, "name": "武士甲+0"},
+ {"pos": 1, "vnum": 11400, "count": 1, "price": 1000, "name": "刺客服+0"},
+ {"pos": 2, "vnum": 11600, "count": 1, "price": 1000, "name": "幽灵袍+0"},
+ {"pos": 3, "vnum": 11800, "count": 1, "price": 1000, "name": "萨满裙+0"},
+ {"pos": 4, "vnum": 12200, "count": 1, "price": 1000, "name": "铁盔+0"},
+ {"pos": 5, "vnum": 13000, "count": 1, "price": 1000, "name": "战斗盾+0"},
+ ]
+}
+
+## 获取指定 NPC 的商店商品列表
+static func get_shop_items(npc_vnum: int) -> Array:
+ return NPC_SHOPS.get(npc_vnum, []).duplicate(true)
+
+## 检查 NPC 是否拥有商店
+static func has_shop(npc_vnum: int) -> bool:
+ return NPC_SHOPS.has(npc_vnum)
+
+## 计算物品出售折价 (40250 shop.cpp:520: dwPrice /= 5)
+## 官方折价逻辑:一般物品回收价为买入价的 1/5(向上取整保底 1 金币)
+static func calculate_sell_price(item_proto: Dictionary, count := 1) -> int:
+ var anti_flag := int(item_proto.get("anti_flags", item_proto.get("anti_flag", 0)))
+ if anti_flag & ITEM_ANTIFLAG_SELL != 0:
+ return 0 # 禁售物品不可回收
+
+ var buy_price := int(item_proto.get("shop_buy_price", item_proto.get("gold", 100)))
+ var flag := int(item_proto.get("flags", item_proto.get("flag", 0)))
+ var unit_price: int = 1
+
+ if flag & ITEM_FLAG_COUNT_PER_1GOLD != 0:
+ unit_price = 1
+ else:
+ unit_price = maxi(1, buy_price / 5)
+
+ return unit_price * maxi(1, count)
+
+## 执行商店购买事务 (40250 shop.cpp Buy)
+## player_ctx: {"gold": int, "inventory": Array}
+static func buy_item(shop_pos: int, count: int, npc_vnum: int, player_ctx: Dictionary) -> Dictionary:
+ var items := get_shop_items(npc_vnum)
+ var target_good: Dictionary = {}
+ for it in items:
+ if int(it.get("pos", -1)) == shop_pos:
+ target_good = it
+ break
+
+ if target_good.is_empty():
+ return {"ok": false, "code": "SHOP_INVALID_POS", "msg": "该商品位置无效!"}
+
+ var buy_count := maxi(1, count)
+ var price_per_pack := int(target_good.get("price", 0))
+ var pack_count := int(target_good.get("count", 1))
+ var total_cost: int = price_per_pack * buy_count
+
+ var current_gold := int(player_ctx.get("gold", 0))
+ if current_gold < total_cost:
+ return {
+ "ok": false,
+ "code": "NOT_ENOUGH_GOLD",
+ "msg": "金币不足,无法购买!(需要 %d 金币,当前拥有 %d)" % [total_cost, current_gold]
+ }
+
+ # 检查背包剩余空位
+ var inv: Array = player_ctx.get("inventory", [])
+ var free_cell := -1
+ for cell in 90: # 0..89 背包槽
+ var occupied := false
+ for inv_item in inv:
+ if int(inv_item.get("cell", -1)) == cell:
+ occupied = true
+ break
+ if not occupied:
+ free_cell = cell
+ break
+
+ if free_cell == -1:
+ return {"ok": false, "code": "INVENTORY_FULL", "msg": "背包空间不足,无法放入商品!"}
+
+ # 扣款并给物品
+ player_ctx["gold"] = current_gold - total_cost
+ var new_item := {
+ "cell": free_cell,
+ "vnum": int(target_good.get("vnum", 0)),
+ "count": pack_count * buy_count,
+ "sockets": [0, 0, 0],
+ "attrs": []
+ }
+ inv.append(new_item)
+ player_ctx["inventory"] = inv
+
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "cost": total_cost,
+ "cell": free_cell,
+ "item": new_item,
+ "msg": "购买成功!花费了 %d 金币。" % total_cost
+ }
+
+## 执行商店出售事务 (40250 shop.cpp Sell)
+## inv_cell: 背包槽位
+static func sell_item(inv_cell: int, count: int, player_ctx: Dictionary, proto_getter: Callable) -> Dictionary:
+ var inv: Array = player_ctx.get("inventory", [])
+ var target_idx := -1
+ var target_item: Dictionary = {}
+
+ for i in inv.size():
+ if int(inv[i].get("cell", -1)) == inv_cell:
+ target_idx = i
+ target_item = inv[i]
+ break
+
+ if target_item.is_empty():
+ return {"ok": false, "code": "NO_ITEM", "msg": "背包该格没有物品!"}
+
+ var vnum := int(target_item.get("vnum", 0))
+ var proto: Dictionary = proto_getter.call(vnum) if proto_getter.is_valid() else {}
+
+ # 校验是否禁售
+ var anti_flag := int(proto.get("anti_flags", proto.get("anti_flag", 0)))
+ if anti_flag & ITEM_ANTIFLAG_SELL != 0:
+ return {"ok": false, "code": "ITEM_ANTIFLAG_SELL", "msg": "该物品为非卖品,无法向商人出售!"}
+
+ var cur_count := int(target_item.get("count", 1))
+ var sell_count := mini(cur_count, maxi(1, count))
+ var earn_gold := calculate_sell_price(proto, sell_count)
+
+ # 扣减物品
+ if cur_count <= sell_count:
+ inv.remove_at(target_idx)
+ else:
+ target_item["count"] = cur_count - sell_count
+
+ player_ctx["inventory"] = inv
+ player_ctx["gold"] = int(player_ctx.get("gold", 0)) + earn_gold
+
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "gold_earned": earn_gold,
+ "sold_vnum": vnum,
+ "sold_count": sell_count,
+ "msg": "出售成功!获得了 %d 金币。" % earn_gold
+ }
diff --git a/project/npc_shop_system.gd.uid b/project/npc_shop_system.gd.uid
new file mode 100644
index 00000000..80f3e3bd
--- /dev/null
+++ b/project/npc_shop_system.gd.uid
@@ -0,0 +1 @@
+uid://c2gkxa7firckb
diff --git a/project/offline_bazaar_market_system.gd b/project/offline_bazaar_market_system.gd
new file mode 100644
index 00000000..e6238652
--- /dev/null
+++ b/project/offline_bazaar_market_system.gd
@@ -0,0 +1,311 @@
+# offline_bazaar_market_system.gd —— Metin2 40250 单人单机广场离线摆摊与虚拟交易市场 1:1
+# 对照 40250 服务端 char_shop.cpp, shop.cpp, shop_manager.cpp
+class_name OfflineBazaarMarketSystem
+extends RefCounted
+
+signal stall_browsed(stall_id: int, stall_name: String)
+signal item_bought_from_stall(stall_id: int, item_name: String, count: int, total_price: int)
+signal player_stall_opened(title: String, item_count: int)
+signal player_stall_item_sold(item_name: String, count: int, price: int)
+signal player_stall_closed(revenue_collected: int)
+
+const ITEM_BUNDLE_VNUM := 50200 # 摆摊包袱 (Bundle)
+
+# 广场内置 3 大经典 NPC/虚拟玩家货摊
+var npc_stalls: Dictionary = {
+ 1: {
+ "id": 1,
+ "owner": "阿隆",
+ "title": "【稀缺材料专营店】",
+ "pos": Vector3(12.0, 0.0, 8.0),
+ "items": [
+ {"vnum": 27993, "name": "珍珠", "price": 500000, "stock": 5},
+ {"vnum": 27992, "name": "白石", "price": 300000, "stock": 10},
+ {"vnum": 30040, "name": "蜘蛛眼", "price": 50000, "stock": 20},
+ {"vnum": 50513, "name": "灵魂之石", "price": 1200000, "stock": 3}
+ ]
+ },
+ 2: {
+ "id": 2,
+ "owner": "铁峰",
+ "title": "【+4 强化灵石阁】",
+ "pos": Vector3(18.0, 0.0, 8.0),
+ "items": [
+ {"vnum": 28430, "name": "战士灵石+4", "price": 800000, "stock": 4},
+ {"vnum": 28431, "name": "忍者灵石+4", "price": 800000, "stock": 4},
+ {"vnum": 28432, "name": "修罗灵石+4", "price": 800000, "stock": 4},
+ {"vnum": 28435, "name": "魔石灵石+4", "price": 1500000, "stock": 2}
+ ]
+ },
+ 3: {
+ "id": 3,
+ "owner": "云游仙商",
+ "title": "【珍品洗炼附魔阁】",
+ "pos": Vector3(15.0, 0.0, 14.0),
+ "items": [
+ {"vnum": 71084, "name": "属性改变秘籍", "price": 2000000, "stock": 5},
+ {"vnum": 71085, "name": "添加属性秘籍", "price": 1500000, "stock": 5},
+ {"vnum": 70024, "name": "祝福宝珠", "price": 3000000, "stock": 2},
+ {"vnum": 71003, "name": "祝福卷轴", "price": 600000, "stock": 15}
+ ]
+ }
+}
+
+# 玩家自己开设的离线个人商摊
+var player_stall: Dictionary = {
+ "is_open": false,
+ "title": "",
+ "items": [], # Array of { "vnum", "name", "count", "price" }
+ "revenue": 0,
+ "total_sales_count": 0
+}
+
+# 获取所有广场摊位列表
+func get_all_stalls() -> Array:
+ var list: Array = []
+ for sid in npc_stalls.keys():
+ var s = npc_stalls[sid]
+ list.append({
+ "id": s["id"],
+ "owner": s["owner"],
+ "title": s["title"],
+ "pos": s["pos"],
+ "item_count": s["items"].size()
+ })
+ if player_stall["is_open"]:
+ list.append({
+ "id": 999,
+ "owner": "玩家自己",
+ "title": player_stall["title"],
+ "pos": Vector3(0.0, 0.0, 0.0),
+ "item_count": player_stall["items"].size()
+ })
+ return list
+
+# 浏览特定摊位商品
+func browse_stall(stall_id: int) -> Dictionary:
+ if not npc_stalls.has(stall_id):
+ if stall_id == 999 and player_stall["is_open"]:
+ stall_browsed.emit(999, player_stall["title"])
+ return {"ok": true, "stall": player_stall}
+ return {"ok": false, "reason": "STALL_NOT_FOUND", "msg": "未找到该摊位!"}
+
+ var stall = npc_stalls[stall_id]
+ stall_browsed.emit(stall_id, stall["title"])
+ return {
+ "ok": true,
+ "id": stall_id,
+ "owner": stall["owner"],
+ "title": stall["title"],
+ "items": stall["items"]
+ }
+
+# 从 NPC/虚拟摊位购买物品
+func buy_from_stall(stall_id: int, item_idx: int, count: int, player_data: Dictionary, inventory: Array) -> Dictionary:
+ if not npc_stalls.has(stall_id):
+ return {"ok": false, "reason": "STALL_NOT_FOUND", "msg": "未找到该摊位!"}
+
+ var stall = npc_stalls[stall_id]
+ var items: Array = stall["items"]
+ if item_idx < 0 or item_idx >= items.size():
+ return {"ok": false, "reason": "ITEM_INDEX_INVALID", "msg": "无效的商品索引!"}
+
+ var target_goods = items[item_idx]
+ var available_stock: int = int(target_goods.get("stock", 0))
+ if available_stock < count:
+ return {"ok": false, "reason": "OUT_OF_STOCK", "msg": "库存不足!剩余: %d" % available_stock}
+
+ var total_cost: int = int(target_goods["price"]) * count
+ var current_gold: int = int(player_data.get("gold", 0))
+ if current_gold < total_cost:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足!需要: %d, 当前: %d" % [total_cost, current_gold]}
+
+ # 检查背包空位
+ var empty_slot = -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳新商品!"}
+
+ # 扣费
+ player_data["gold"] = current_gold - total_cost
+ target_goods["stock"] = available_stock - count
+
+ # 入包
+ var new_item: Dictionary = {
+ "vnum": target_goods["vnum"],
+ "name": target_goods["name"],
+ "count": count
+ }
+ inventory[empty_slot] = new_item
+
+ item_bought_from_stall.emit(stall_id, target_goods["name"], count, total_cost)
+
+ return {
+ "ok": true,
+ "item": new_item,
+ "slot": empty_slot,
+ "cost": total_cost,
+ "remaining_gold": player_data["gold"],
+ "msg": "成功购买【%s】x%d,花费 %d 金币!" % [target_goods["name"], count, total_cost]
+ }
+
+# 玩家开设自己的离线摆摊
+# items_to_sell: Array of { "slot": int, "price": int, "count": int }
+func open_player_stall(title: String, items_to_sell: Array, inventory: Array, require_bundle: bool = true) -> Dictionary:
+ if player_stall["is_open"]:
+ return {"ok": false, "reason": "ALREADY_HAS_STALL", "msg": "你已开设了一个摊位!"}
+
+ if items_to_sell.is_empty():
+ return {"ok": false, "reason": "NO_ITEMS_TO_SELL", "msg": "请至少上架一件物品!"}
+
+ # 摆摊包袱校验 (50200)
+ var bundle_slot = -1
+ if require_bundle:
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == ITEM_BUNDLE_VNUM:
+ bundle_slot = i
+ break
+ if bundle_slot == -1:
+ return {"ok": false, "reason": "NEED_BUNDLE_ITEM", "msg": "需要【摆摊包袱】(50200) 才能在广场开设个人货摊!"}
+
+ # 收集上架物品并从背包移出
+ var stall_items: Array = []
+ for spec in items_to_sell:
+ var slot_id: int = int(spec["slot"])
+ var price: int = int(spec["price"])
+ var sell_count: int = int(spec.get("count", 1))
+
+ if slot_id < 0 or slot_id >= inventory.size() or inventory[slot_id] == null:
+ continue
+
+ var inv_item: Dictionary = inventory[slot_id]
+ var cur_count: int = int(inv_item.get("count", 1))
+
+ if sell_count >= cur_count:
+ # 全量上架
+ var listed_item = inv_item.duplicate(true)
+ listed_item["price"] = price
+ stall_items.append(listed_item)
+ inventory[slot_id] = null
+ else:
+ # 部分上架
+ var listed_item = inv_item.duplicate(true)
+ listed_item["count"] = sell_count
+ listed_item["price"] = price
+ stall_items.append(listed_item)
+ inv_item["count"] = cur_count - sell_count
+
+ if stall_items.is_empty():
+ return {"ok": false, "reason": "NO_VALID_ITEMS", "msg": "没有有效可上架的物品!"}
+
+ # 消耗摆摊包袱
+ if bundle_slot != -1:
+ var b_item = inventory[bundle_slot]
+ var b_cnt = int(b_item.get("count", 1))
+ if b_cnt > 1:
+ b_item["count"] = b_cnt - 1
+ else:
+ inventory[bundle_slot] = null
+
+ player_stall["is_open"] = true
+ player_stall["title"] = title if not title.strip_edges().is_empty() else "【离线便民杂货店】"
+ player_stall["items"] = stall_items
+ player_stall["revenue"] = 0
+ player_stall["total_sales_count"] = 0
+
+ player_stall_opened.emit(player_stall["title"], stall_items.size())
+
+ return {
+ "ok": true,
+ "title": player_stall["title"],
+ "items_count": stall_items.size(),
+ "msg": "个人摊位【%s】开设成功!上架了 %d 件珍品。" % [player_stall["title"], stall_items.size()]
+ }
+
+# 模拟单机挂机离线交易成交
+func simulate_offline_sales(elapsed_minutes: float) -> Dictionary:
+ if not player_stall["is_open"] or player_stall["items"].is_empty():
+ return {"sold_count": 0, "earned_gold": 0}
+
+ var sold_items: Array = []
+ var earned: int = 0
+ var remaining_items: Array = []
+
+ # 根据挂机时长计算可能成交的件数
+ var max_potential_sales = int(elapsed_minutes / 5.0) + 1
+ var actual_sales = 0
+
+ # 随离线时长提升成交几率(满30分钟保底成交)
+ var sell_prob = clampf(0.5 + (elapsed_minutes / 30.0) * 0.5, 0.0, 1.0)
+ for item in player_stall["items"]:
+ if actual_sales < max_potential_sales and randf() <= sell_prob:
+ actual_sales += 1
+ var item_rev = int(item["price"]) * int(item.get("count", 1))
+ earned += item_rev
+ player_stall["revenue"] += item_rev
+ player_stall["total_sales_count"] += 1
+ sold_items.append(item)
+ player_stall_item_sold.emit(item["name"], int(item.get("count", 1)), item_rev)
+ else:
+ remaining_items.append(item)
+
+ player_stall["items"] = remaining_items
+
+ return {
+ "sold_count": sold_items.size(),
+ "earned_gold": earned,
+ "remaining_items": remaining_items.size()
+ }
+
+# 领取摊位赚取的金币
+func collect_stall_revenue(player_data: Dictionary) -> Dictionary:
+ if not player_stall["is_open"]:
+ return {"ok": false, "reason": "NO_ACTIVE_STALL"}
+
+ var rev: int = player_stall["revenue"]
+ if rev <= 0:
+ return {"ok": false, "reason": "NO_REVENUE", "msg": "当前没有可领取的摊位营收!"}
+
+ player_data["gold"] = int(player_data.get("gold", 0)) + rev
+ player_stall["revenue"] = 0
+
+ return {
+ "ok": true,
+ "collected_gold": rev,
+ "current_gold": player_data["gold"],
+ "msg": "成功领取摊位营业收入 %d 金币!" % rev
+ }
+
+# 收摊并退回未售出物品
+func close_player_stall(inventory: Array, player_data: Dictionary) -> Dictionary:
+ if not player_stall["is_open"]:
+ return {"ok": false, "reason": "NO_ACTIVE_STALL"}
+
+ var rev: int = player_stall["revenue"]
+ if rev > 0:
+ player_data["gold"] = int(player_data.get("gold", 0)) + rev
+ player_stall["revenue"] = 0
+
+ var returned_count: int = 0
+ for item in player_stall["items"]:
+ # 寻空位退回
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = item
+ returned_count += 1
+ break
+
+ player_stall["is_open"] = false
+ player_stall["items"] = []
+ player_stall_closed.emit(rev)
+
+ return {
+ "ok": true,
+ "revenue_collected": rev,
+ "returned_items": returned_count,
+ "msg": "已顺利收摊!营业额 %d 金币已汇入背包,%d 件未售物品已退回。" % [rev, returned_count]
+ }
diff --git a/project/offline_bazaar_market_system.gd.uid b/project/offline_bazaar_market_system.gd.uid
new file mode 100644
index 00000000..7fb83c08
--- /dev/null
+++ b/project/offline_bazaar_market_system.gd.uid
@@ -0,0 +1 @@
+uid://b3jvh20s4ckbp
diff --git a/project/p8_test.gd b/project/p8_test.gd
index f1bcdcc1..cf218877 100644
--- a/project/p8_test.gd
+++ b/project/p8_test.gd
@@ -18,6 +18,7 @@ class FakeClient extends Node:
signal shop_error(kind: String)
signal exchange_changed()
signal safebox_changed()
+ signal inventory_changed()
var calls := []
var party := []
diff --git a/project/party_management_system.gd b/project/party_management_system.gd
new file mode 100644
index 00000000..f57c7c72
--- /dev/null
+++ b/project/party_management_system.gd
@@ -0,0 +1,216 @@
+# party_management_system.gd —— 40250 8人组队面板、经验分配与领导力光环系统 1:1
+# 严格对照:
+# metin2/src/server/game/src/party.h, party.cpp, char_party.cpp
+# Client/Eternexus/root/uiparty.py
+# ClientVS22/source/UserInterface/PythonPlayer.h (PARTY_AFFECT_*)
+extends RefCounted
+
+const PARTY_MAX_MEMBER := 8 # 官方最大 8 人队伍
+
+# 经验分配模式 (char_party.cpp)
+const EXP_MODE_LEVEL := 0 # 按等级比例分配 (NON_PARITY)
+const EXP_MODE_PARITY := 1 # 均分模式 (PARITY)
+
+# 队长战术领导力职权角色分配 (uiparty.py / PythonPlayer.h)
+const ROLE_NORMAL := 0 # 普通队员
+const ROLE_ATTACKER := 1 # 攻击者 (增加物理攻击力)
+const ROLE_TANKER := 2 # 肉盾 (增加生命值上限)
+const ROLE_BUFFER := 3 # 增益者 (增加法术/技能攻击力)
+const ROLE_SKILL_MASTER := 4 # 技能大师 (减少技能冷却时间)
+const ROLE_DEFENDER := 5 # 防御者 (增加防御力)
+const ROLE_BERSERKER := 6 # 狂战士 (增加攻击速度)
+
+# 角色光环属性加成表
+const ROLE_BUFF_BONUSES := {
+ ROLE_ATTACKER: { "stat": "att_grade", "val": 30 },
+ ROLE_TANKER: { "stat": "max_hp", "val": 2500 },
+ ROLE_BUFFER: { "stat": "magic_att", "val": 25 },
+ ROLE_SKILL_MASTER: { "stat": "cooldown_reduction", "val": 15 },
+ ROLE_DEFENDER: { "stat": "def_grade", "val": 40 },
+ ROLE_BERSERKER: { "stat": "att_speed", "val": 15 }
+}
+
+class PartyMember:
+ var pid: int
+ var name: String
+ var level: int
+ var is_leader: bool = false
+ var role: int = ROLE_NORMAL
+ var cur_hp: int = 1000
+ var max_hp: int = 1000
+
+ func _init(p_pid: int, p_name: String, p_lvl: int, p_leader: bool = false) -> void:
+ pid = p_pid
+ name = p_name
+ level = p_lvl
+ is_leader = p_leader
+ role = ROLE_NORMAL
+ cur_hp = 1000
+ max_hp = 1000
+
+ func get_hp_percent() -> float:
+ if max_hp <= 0:
+ return 0.0
+ return clamp(float(cur_hp) / float(max_hp), 0.0, 1.0)
+
+var members: Dictionary = {} # pid -> PartyMember
+var leader_pid: int = -1
+var exp_distribution_mode: int = EXP_MODE_PARITY
+
+func _init(p_leader_pid: int = -1, p_leader_name: String = "", p_leader_lvl: int = 1) -> void:
+ members.clear()
+ leader_pid = -1
+ exp_distribution_mode = EXP_MODE_PARITY
+ if p_leader_pid > 0:
+ create_party(p_leader_pid, p_leader_name, p_leader_lvl)
+
+# 1. 创建队伍
+func create_party(p_pid: int, p_name: String, p_lvl: int) -> bool:
+ members.clear()
+ leader_pid = p_pid
+ var leader = PartyMember.new(p_pid, p_name, p_lvl, true)
+ members[p_pid] = leader
+ return true
+
+# 2. 邀请/加入队员 (CParty::Join)
+func add_member(p_pid: int, p_name: String, p_lvl: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if members.size() >= PARTY_MAX_MEMBER:
+ res["error_code"] = "PARTY_FULL"
+ res["message"] = "队伍人数已达到 8 人上限。"
+ return res
+
+ if members.has(p_pid):
+ res["error_code"] = "ALREADY_IN_PARTY"
+ res["message"] = "该玩家已在队伍中。"
+ return res
+
+ var m = PartyMember.new(p_pid, p_name, p_lvl, false)
+ members[p_pid] = m
+ res["success"] = true
+ res["message"] = "%s 加入了队伍。" % p_name
+ return res
+
+# 3. 踢出队员 (CParty::Quit / Expel)
+func expel_member(caller_pid: int, target_pid: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if caller_pid != leader_pid:
+ res["error_code"] = "NOT_LEADER"
+ res["message"] = "只有队长才能踢出队员。"
+ return res
+
+ if not members.has(target_pid):
+ res["error_code"] = "MEMBER_NOT_FOUND"
+ return res
+
+ if target_pid == leader_pid:
+ res["error_code"] = "CANNOT_EXPEL_LEADER"
+ res["message"] = "队长不能踢出自己。"
+ return res
+
+ var target_name: String = members[target_pid].name
+ members.erase(target_pid)
+ res["success"] = true
+ res["message"] = "%s 已被移出队伍。" % target_name
+ return res
+
+# 4. 离队与队长顺延/解散 (CParty::Quit)
+func leave_party(p_pid: int) -> Dictionary:
+ var res := { "success": false, "dissolved": false, "new_leader": -1 }
+ if not members.has(p_pid):
+ return res
+
+ var was_leader: bool = (p_pid == leader_pid)
+ members.erase(p_pid)
+
+ if members.is_empty():
+ leader_pid = -1
+ res["dissolved"] = true
+ res["success"] = true
+ return res
+
+ # 若队长离队,移交给第一位队员
+ if was_leader:
+ var new_lead_key: int = members.keys()[0]
+ leader_pid = new_lead_key
+ members[new_lead_key].is_leader = true
+ res["new_leader"] = new_lead_key
+
+ res["success"] = true
+ return res
+
+# 5. 队长战术领导力角色光环配置 (CParty::SetRole)
+func set_member_role(caller_pid: int, target_pid: int, role: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if caller_pid != leader_pid:
+ res["error_code"] = "NOT_LEADER"
+ res["message"] = "只有队长有权分配战术光环。"
+ return res
+
+ if not members.has(target_pid):
+ res["error_code"] = "MEMBER_NOT_FOUND"
+ return res
+
+ # 检查角色是否已被队伍中其他成员占用 (非 ROLE_NORMAL)
+ if role != ROLE_NORMAL:
+ for k in members:
+ if k != target_pid and members[k].role == role:
+ res["error_code"] = "ROLE_ALREADY_ASSIGNED"
+ res["message"] = "该战术光环已被其他队员占用。"
+ return res
+
+ members[target_pid].role = role
+ res["success"] = true
+ res["role"] = role
+ res["buff"] = ROLE_BUFF_BONUSES.get(role, {})
+ res["message"] = "成功为队员分配战术角色。"
+ return res
+
+# 6. 获取队员当前享有的领导力光环加成
+func get_member_buff(p_pid: int) -> Dictionary:
+ if not members.has(p_pid):
+ return {}
+ var r: int = members[p_pid].role
+ return ROLE_BUFF_BONUSES.get(r, {})
+
+# 7. 切换经验分配模式
+func set_exp_distribution_mode(caller_pid: int, mode: int) -> bool:
+ if caller_pid != leader_pid:
+ return false
+ if mode == EXP_MODE_PARITY or mode == EXP_MODE_LEVEL:
+ exp_distribution_mode = mode
+ return true
+ return false
+
+# 8. 队伍击杀经验结算与分发 (1:1 CParty::DistributeExp)
+func distribute_exp(total_exp: int) -> Dictionary:
+ var distribution := {}
+ if members.is_empty() or total_exp <= 0:
+ return distribution
+
+ var count := members.size()
+
+ if exp_distribution_mode == EXP_MODE_PARITY:
+ # 均分模式: 每个队员获得 total_exp / count
+ var per_member: int = total_exp / count
+ for k in members:
+ distribution[k] = per_member
+ else:
+ # 等级权重模式: 按队员等级比例分配
+ var sum_levels: int = 0
+ for k in members:
+ sum_levels += members[k].level
+ for k in members:
+ var share: int = int(float(total_exp) * float(members[k].level) / float(sum_levels))
+ distribution[k] = share
+
+ return distribution
+
+# 9. 同步队员生命值百分比
+func sync_member_hp(p_pid: int, cur: int, max_h: int) -> void:
+ if members.has(p_pid):
+ members[p_pid].cur_hp = cur
+ members[p_pid].max_hp = max_h
diff --git a/project/party_management_system.gd.uid b/project/party_management_system.gd.uid
new file mode 100644
index 00000000..b8d1acec
--- /dev/null
+++ b/project/party_management_system.gd.uid
@@ -0,0 +1 @@
+uid://bx3vdhv1jsl2h
diff --git a/project/pet_companion_system.gd b/project/pet_companion_system.gd
new file mode 100644
index 00000000..8232fff9
--- /dev/null
+++ b/project/pet_companion_system.gd
@@ -0,0 +1,153 @@
+# pet_companion_system.gd —— Metin2 40250 伴随宠物封印召唤与战力注入系统 1:1
+# 对照 40250 服务端 PetSystem.cpp, char_pet.cpp
+class_name PetCompanionSystem
+extends RefCounted
+
+signal pet_summoned(pet_name: String, vnum: int)
+signal pet_dismissed(pet_name: String)
+signal pet_state_changed(state: String)
+
+const PET_TABLE: Dictionary = {
+ 53001: {
+ "name": "火凤凰",
+ "mob_vnum": 34001,
+ "bonuses": {"max_hp": 1500, "monster_dmg_pct": 15},
+ "desc": "最大生命 +1500,对怪物伤害提升 15%"
+ },
+ 53002: {
+ "name": "幼鹿",
+ "mob_vnum": 34002,
+ "bonuses": {"max_hp": 1000, "exp_pct": 15},
+ "desc": "最大生命 +1000,获得经验值增加 15%"
+ },
+ 53003: {
+ "name": "冰凤凰",
+ "mob_vnum": 34003,
+ "bonuses": {"max_hp": 1500, "def_pct": 15},
+ "desc": "最大生命 +1500,物理防御提升 15%"
+ },
+ 53005: {
+ "name": "小胖熊猫",
+ "mob_vnum": 34005,
+ "bonuses": {"max_hp": 1000, "critical_pct": 15},
+ "desc": "最大生命 +1000,致命一击几率提升 15%"
+ },
+ 53006: {
+ "name": "幼狮",
+ "mob_vnum": 34006,
+ "bonuses": {"max_hp": 1500, "att_pct": 10},
+ "desc": "最大生命 +1500,物理攻击力提升 10%"
+ }
+}
+
+const FOLLOW_STOP_DIST := 2.0 # 停止距离
+const FOLLOW_RUN_DIST := 3.5 # 开始跟随奔跑距离
+const TELEPORT_DIST := 25.0 # 瞬间拉回距离
+const PET_MOVE_SPEED := 4.5 # 移动速度 (m/s)
+
+var is_summoned: bool = false
+var current_seal_vnum: int = 0
+var active_pet: Dictionary = {}
+var pet_position: Vector3 = Vector3.ZERO
+var pet_state: String = "wait" # "wait" 或 "run"
+var remaining_time: float = 0.0
+
+# 召唤 / 收回宠物 (右键点击宠物封印石)
+func toggle_pet_seal(seal_item: Dictionary, player_pos: Vector3) -> Dictionary:
+ var vnum: int = int(seal_item.get("vnum", 0))
+ if not PET_TABLE.has(vnum):
+ return {"ok": false, "reason": "NOT_A_PET_SEAL", "msg": "这不是有效的宠物封印!"}
+
+ # 若当前已召唤同一只宠物,则收回
+ if is_summoned and current_seal_vnum == vnum:
+ var pet_name = active_pet.get("name", "宠物")
+ dismiss_pet()
+ return {
+ "ok": true,
+ "action": "dismiss",
+ "pet_name": pet_name,
+ "msg": "已将【%s】收回封印石。" % pet_name
+ }
+
+ # 若当前召唤了不同宠物,先收回
+ if is_summoned:
+ dismiss_pet()
+
+ # 召唤新宠物
+ var cfg: Dictionary = PET_TABLE[vnum]
+ current_seal_vnum = vnum
+ is_summoned = true
+ remaining_time = float(seal_item.get("duration", 86400.0)) # 默认 24 小时
+ pet_position = player_pos + Vector3(-1.5, 0.0, -1.5)
+ pet_state = "wait"
+
+ active_pet = {
+ "vnum": vnum,
+ "name": cfg["name"],
+ "mob_vnum": cfg["mob_vnum"],
+ "bonuses": cfg["bonuses"].duplicate()
+ }
+
+ pet_summoned.emit(cfg["name"], vnum)
+
+ return {
+ "ok": true,
+ "action": "summon",
+ "pet_name": cfg["name"],
+ "bonuses": cfg["bonuses"],
+ "msg": "伴随宠物【%s】已召唤出战!" % cfg["name"]
+ }
+
+# 收回当前宠物
+func dismiss_pet() -> void:
+ if not is_summoned:
+ return
+
+ var pname = active_pet.get("name", "")
+ is_summoned = false
+ current_seal_vnum = 0
+ active_pet.clear()
+ pet_state = "wait"
+
+ pet_dismissed.emit(pname)
+
+# 获取当前宠物注入的战力属性
+func get_active_bonuses() -> Dictionary:
+ if not is_summoned:
+ return {}
+ return active_pet.get("bonuses", {}).duplicate()
+
+# 3D 伴随移动逻辑
+func update_movement(player_pos: Vector3, dt: float) -> void:
+ if not is_summoned:
+ return
+
+ var dist: float = pet_position.distance_to(player_pos)
+
+ # 超远距离瞬间传回身边
+ if dist >= TELEPORT_DIST:
+ pet_position = player_pos + Vector3(-1.5, 0.0, -1.5)
+ pet_state = "wait"
+ pet_state_changed.emit("wait")
+ return
+
+ # 跟随靠近
+ if dist > FOLLOW_STOP_DIST:
+ var dir := (player_pos - pet_position).normalized()
+ pet_position += dir * PET_MOVE_SPEED * dt
+ if pet_state != "run":
+ pet_state = "run"
+ pet_state_changed.emit("run")
+ else:
+ if pet_state != "wait":
+ pet_state = "wait"
+ pet_state_changed.emit("wait")
+
+# 逐帧倒计时衰减
+func update(dt: float) -> void:
+ if not is_summoned:
+ return
+
+ remaining_time -= dt
+ if remaining_time <= 0.0:
+ dismiss_pet()
diff --git a/project/pet_companion_system.gd.uid b/project/pet_companion_system.gd.uid
new file mode 100644
index 00000000..520445ab
--- /dev/null
+++ b/project/pet_companion_system.gd.uid
@@ -0,0 +1 @@
+uid://ctj2muscnf5l3
diff --git a/project/physics_push.gd.uid b/project/physics_push.gd.uid
new file mode 100644
index 00000000..029834ec
--- /dev/null
+++ b/project/physics_push.gd.uid
@@ -0,0 +1 @@
+uid://cust1hhxv03lb
diff --git a/project/physics_push_test.gd.uid b/project/physics_push_test.gd.uid
new file mode 100644
index 00000000..87580ba9
--- /dev/null
+++ b/project/physics_push_test.gd.uid
@@ -0,0 +1 @@
+uid://cph1wkg6cxfai
diff --git a/project/pk_penalty_system.gd b/project/pk_penalty_system.gd
new file mode 100644
index 00000000..a127ab00
--- /dev/null
+++ b/project/pk_penalty_system.gd
@@ -0,0 +1,158 @@
+# pk_penalty_system.gd —— Metin2 40250 恶名红名掉落惩罚与露西之戒保护 1:1
+# 对照 40250 服务端 char_battle.cpp, char.cpp, Item 70043, 70102, 70048
+class_name PkPenaltySystem
+extends RefCounted
+
+signal lucy_ring_protected()
+signal item_dropped_on_death(item: Dictionary)
+signal alignment_restored(new_alignment: int)
+
+const MIN_PENALTY_LEVEL := 50
+const ITEM_LUCY_RING := 70043 # 露西之戒
+const ITEM_ZEN_BEAN := 70102 # 禅豆 (+500 善恶值洗恶名)
+const ITEM_CLOAK_OF_PEACE := 70048 # 和平披风 (隐匿红名)
+
+# 负善恶值掉落几率表 (40250 char_battle.cpp 1:1)
+static func get_death_drop_chance(alignment: int) -> float:
+ if alignment >= 0:
+ return 0.0
+ elif alignment >= -3999: # 凶恶
+ return 0.05
+ elif alignment >= -7999: # 歹毒
+ return 0.10
+ elif alignment >= -11999: # 恶贯满盈
+ return 0.15
+ else: # <= -12000 嗜血魔王
+ return 0.25
+
+# 死亡掉落综合判定
+func process_death_penalty(
+ player_level: int,
+ alignment: int,
+ equipment: Dictionary,
+ inventory: Array,
+ forced_roll: float = -1.0
+) -> Dictionary:
+ # 1. 50 级以下或非红名(>=0) 免除一切死亡掉落
+ if player_level < MIN_PENALTY_LEVEL or alignment >= 0:
+ return {
+ "dropped": false,
+ "reason": "SAFE_LEVEL_OR_POSITIVE_KARMA",
+ "msg": "受新手保护或和平声望庇佑,免于死亡惩罚。"
+ }
+
+ # 2. 检查是否佩戴露西之戒 (70043)
+ var has_lucy_ring := false
+ var ring_slot_key = ""
+ for eq_key in equipment.keys():
+ var eq = equipment[eq_key]
+ if eq != null and int(eq.get("vnum", 0)) == ITEM_LUCY_RING:
+ has_lucy_ring = true
+ ring_slot_key = eq_key
+ break
+
+ if has_lucy_ring:
+ # 露西之戒替死碎裂,阻断掉落
+ equipment[ring_slot_key] = null
+ lucy_ring_protected.emit()
+ return {
+ "dropped": false,
+ "ring_shattered": true,
+ "msg": "【露西之戒】替你承受了死亡的厄运,戒指粉碎瓦解,身上物品完好无损!"
+ }
+
+ # 3. 计算掉落几率
+ var chance := get_death_drop_chance(alignment)
+ var roll := forced_roll if forced_roll >= 0.0 else randf()
+
+ if roll > chance:
+ return {
+ "dropped": false,
+ "reason": "LUCKY_ROLL",
+ "msg": "幸运未触发掉落惩罚。"
+ }
+
+ # 4. 寻找可掉落的物品 (已锁定的物品 is_locked 严禁掉落)
+ var droppable_candidates: Array = []
+
+ # 从未锁定装备中搜寻
+ for eq_key in equipment.keys():
+ var it = equipment[eq_key]
+ if it != null and not it.get("is_locked", false):
+ droppable_candidates.append({"type": "equip", "key": eq_key, "item": it})
+
+ # 从未锁定背包中搜寻
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and not it.get("is_locked", false):
+ droppable_candidates.append({"type": "inv", "slot": i, "item": it})
+
+ if droppable_candidates.is_empty():
+ return {
+ "dropped": false,
+ "reason": "ALL_ITEMS_PROTECTED",
+ "msg": "身上的所有贵重物品均处于安全加锁保护中,未发生掉落!"
+ }
+
+ # 掉落一件物品
+ var chosen = droppable_candidates[randi() % droppable_candidates.size()]
+ var dropped_item: Dictionary = chosen["item"].duplicate(true)
+
+ if chosen["type"] == "equip":
+ equipment[chosen["key"]] = null
+ else:
+ inventory[chosen["slot"]] = null
+
+ item_dropped_on_death.emit(dropped_item)
+
+ return {
+ "dropped": true,
+ "dropped_item": dropped_item,
+ "msg": "恶名昭彰!你被击杀,珍贵物品【%s】散落到了地面!" % dropped_item.get("name", "物品")
+ }
+
+# 使用禅豆 (70102) 消除 500 点恶名
+func use_zen_bean(
+ slot_idx: int,
+ inventory: Array,
+ current_alignment: int
+) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var it = inventory[slot_idx]
+ if it == null or int(it.get("vnum", 0)) != ITEM_ZEN_BEAN:
+ return {"ok": false, "reason": "NOT_ZEN_BEAN", "msg": "这不是禅豆!"}
+
+ if current_alignment >= 0:
+ return {
+ "ok": false,
+ "reason": "NOT_EVIL",
+ "msg": "你的名声本就光明清白,无需服用禅豆洗去恶名。"
+ }
+
+ # 消耗禅豆
+ var cnt: int = int(it.get("count", 1))
+ if cnt <= 1:
+ inventory[slot_idx] = null
+ else:
+ it["count"] = cnt - 1
+
+ # 回复 500 点善恶值 (上限为 0)
+ var new_alignment: int = mini(0, current_alignment + 500)
+ alignment_restored.emit(new_alignment)
+
+ return {
+ "ok": true,
+ "old_alignment": current_alignment,
+ "new_alignment": new_alignment,
+ "restored": 500,
+ "msg": "服用了禅豆,心灵得到了净化,恶名值减少了 500 点!"
+ }
+
+# 判定和平披风 (70048) 是否隐匿红名
+func is_red_name_hidden(equipment: Dictionary) -> bool:
+ for eq in equipment.values():
+ if eq != null and int(eq.get("vnum", 0)) == ITEM_CLOAK_OF_PEACE:
+ return true
+ return false
diff --git a/project/pk_penalty_system.gd.uid b/project/pk_penalty_system.gd.uid
new file mode 100644
index 00000000..4bb3cd1e
--- /dev/null
+++ b/project/pk_penalty_system.gd.uid
@@ -0,0 +1 @@
+uid://creetk64lxdrf
diff --git a/project/playable_adapter_test.gd.uid b/project/playable_adapter_test.gd.uid
new file mode 100644
index 00000000..0a9397c2
--- /dev/null
+++ b/project/playable_adapter_test.gd.uid
@@ -0,0 +1 @@
+uid://4f7wb4a2a7x2
diff --git a/project/playable_combat_test.gd.uid b/project/playable_combat_test.gd.uid
new file mode 100644
index 00000000..5ca98dae
--- /dev/null
+++ b/project/playable_combat_test.gd.uid
@@ -0,0 +1 @@
+uid://culkkh1gryrw
diff --git a/project/playable_config_gate.gd.uid b/project/playable_config_gate.gd.uid
new file mode 100644
index 00000000..63f2a204
--- /dev/null
+++ b/project/playable_config_gate.gd.uid
@@ -0,0 +1 @@
+uid://dl1g3ph6de13v
diff --git a/project/playable_harness_test.gd.uid b/project/playable_harness_test.gd.uid
new file mode 100644
index 00000000..2e9e1389
--- /dev/null
+++ b/project/playable_harness_test.gd.uid
@@ -0,0 +1 @@
+uid://dy8kund8jox5h
diff --git a/project/playable_live_test.gd.uid b/project/playable_live_test.gd.uid
new file mode 100644
index 00000000..a0b68a03
--- /dev/null
+++ b/project/playable_live_test.gd.uid
@@ -0,0 +1 @@
+uid://csn3wvcofmj1c
diff --git a/project/playable_metrics_test.gd.uid b/project/playable_metrics_test.gd.uid
new file mode 100644
index 00000000..cb66e811
--- /dev/null
+++ b/project/playable_metrics_test.gd.uid
@@ -0,0 +1 @@
+uid://bq24ucjjfktlo
diff --git a/project/playable_skill_matrix.gd.uid b/project/playable_skill_matrix.gd.uid
new file mode 100644
index 00000000..57ace543
--- /dev/null
+++ b/project/playable_skill_matrix.gd.uid
@@ -0,0 +1 @@
+uid://bypranefpw11x
diff --git a/project/player_controller.gd b/project/player_controller.gd
index ee89e0f2..bb4c3028 100644
--- a/project/player_controller.gd
+++ b/project/player_controller.gd
@@ -10,12 +10,18 @@ extends Node
signal anim_state(state: String)
signal target_selected(node: Node3D)
+signal ground_item_clicked(vid: int)
signal moved(pos: Vector3)
# game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。
# 悬停命中的实体 vid 变化时 emit(无命中 / 鼠标压 UI / 悬停地面物品 -> emit 0)。见 pick_show.gd。
signal hover_entity_changed(vid: int)
-var player: Node3D
+var player: Node3D:
+ set(v):
+ player = v
+ if player:
+ _src_pos = player.position
+ _dst_pos = player.position
var camera: Camera3D
var world: Node
var cursor_manager: Node
@@ -26,10 +32,10 @@ var cancel_fishing_ground := Callable() # __OnPressGround 专用入口
var pickables: Array[Node3D] = [] # 可点选实体(NPC/怪等)
var _hover_vid := 0 # 上一帧悬停命中的实体 vid(hover_entity_changed 去抖)
-const SPEED_WALK := 8.0
-const SPEED_RUN := 18.0
+const SPEED_WALK := 1.8
+const SPEED_RUN := 4.8
const RUN_HOLD_KEY := KEY_SHIFT
-const ARRIVE_EPS := 0.4
+const ARRIVE_EPS := 0.05
const PICK_RADIUS := 1.4 # 点选命中半径(米)
# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由
# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1
@@ -47,7 +53,9 @@ const ACTOR_COLLIDE_MAX_DIST_M := 8.0
const ROT_SPEED_DEFAULT_DEG := 1200.0
const ROT_SPEED_HORSE_DEG := 300.0
+var default_run := true # 40250 对齐:默认奔跑模式(ActorInstance.cpp:544 m_isWalking = FALSE)
var force_run := false # 脚本化 / 自动跑
+var force_walk := false # 步行模式
var frozen := false # 受击硬直等:本帧不响应移动输入(net_play 设)
var locked := false # CInstanceBase::isLock():完全锁死移动(过场 / 特定 affect)
var moving_skill := false # IsUsingMovingSkill():移动技能中,只允许转向不平移
@@ -70,13 +78,31 @@ var _dst_rot := 0.0
var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点
var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效
+var active := true # 进游戏装配/加载中置假,防止过早响应点地
+# 40250 CInstanceBase::NEW_Stop() / CPythonPlayer::NEW_Stop():停止点地移动与方向输入,重置预约
+func stop() -> void:
+ _is_going = false
+ _reserved_ground = null
+ _reserved_delay_time = 0.0
+ _last_wasd = Vector2.ZERO
+ _mobile_active = false
+ _mobile_axis = Vector2.ZERO
+ _mobile_ui_touches.clear()
+ _multi_gesture = false
+ _touch_count = 0
+ _tap_index = -1
+ if player:
+ _src_pos = player.position
+ _dst_pos = player.position
+ anim_state.emit("wait")
+
# 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
+ return active and not frozen and not locked and not moving_skill
const _TAP_TRAVEL_MAX := 12.0 # 触点位移超过这么多像素 -> 视作拖拽(归相机),不是轻点
const _TAP_TIME_MAX := 0.35 # 按下到抬起超过这么久 -> 不是轻点
@@ -138,7 +164,7 @@ func walk_to(world_pos: Vector3) -> void:
# CInstanceBase::NEW_Goto(InstanceBaseMovement.cpp:251):三门通过后设 Src/Dst + m_isGoing。
# 被门挡下(syncing / lock)时按 __ReserveClickGround 预约,delay 后重试。
func _goto(dst_flat: Vector3) -> bool:
- if player == null:
+ if not active or player == null:
return false
var flat := Vector3(dst_flat.x - player.position.x, 0.0, dst_flat.z - player.position.z)
# __IsMovableGroundDistance:离脚下太近不动
@@ -155,6 +181,8 @@ func _goto(dst_flat: Vector3) -> bool:
return true
func _unhandled_input(e: InputEvent) -> void:
+ if not active:
+ return
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_click(e.position)
elif e is InputEventScreenTouch:
@@ -186,8 +214,16 @@ func _on_touch(e: InputEventScreenTouch) -> void:
_multi_gesture = false
func _on_click(screen_pos: Vector2) -> void:
- if camera == null:
+ if not active or camera == null or player == null:
return
+
+ # 0) 掉落物点选(对齐 40250 __GetPickedItemID 优先于 Actor/Ground)
+ if ground_items and ground_items.has_method("pick_at"):
+ var picked_item_vid: int = ground_items.call("pick_at", camera, screen_pos)
+ if picked_item_vid != 0:
+ ground_item_clicked.emit(picked_item_vid)
+ return
+
var from := camera.project_ray_origin(screen_pos)
var dir := camera.project_ray_normal(screen_pos)
@@ -198,6 +234,8 @@ func _on_click(screen_pos: Vector2) -> void:
for n in pickables:
if not is_instance_valid(n):
continue
+ if bool(n.get_meta("dead", false)):
+ continue
var t := _ray_pick_t(from, dir, n)
if t >= 0.0 and t < best_t:
best_t = t
@@ -251,17 +289,18 @@ func _ray_pick_t(from: Vector3, dir: Vector3, node: Node3D) -> float:
var t2 := (hi - o) / d
if t1 > t2:
var tmp := t1; t1 = t2; t2 = tmp
- tmin = maxf(tmin, t1)
- tmax = minf(tmax, t2)
- if tmin > tmax:
+ if t1 > tmin: tmin = t1
+ if t2 < tmax: tmax = t2
+ if tmin > tmax or tmax < 0.0:
return -1.0
- return tmin if tmin >= 0.0 else (tmax if tmax >= 0.0 else -1.0)
+ return tmin if tmin >= 0.0 else tmax
+# 射线 vs 地表:沿射线在 [0, 80m] 区间二分找 ray.y == world.sample_height(ray.x, ray.z)。
func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
if world == null or not world.has_method("sample_height"):
return null
- var t := 0.0
var last_above: bool = from.y - float(world.call("sample_height", from.x, from.z)) > 0.0
+ var t := 0.0
for _i in 240:
t += 1.0
var p := from + dir * t
@@ -285,11 +324,27 @@ func _ray_ground(from: Vector3, dir: Vector3) -> Variant:
return null
func _wasd() -> Vector2:
+ if ui_manager and ui_manager.has_method("blocks_game_input"):
+ var dummy := InputEventKey.new()
+ dummy.keycode = KEY_W
+ if ui_manager.blocks_game_input(dummy):
+ return Vector2.ZERO
+ var is_up := Input.is_key_pressed(KEY_W) or Input.is_physical_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP)
+ var is_down := Input.is_key_pressed(KEY_S) or Input.is_physical_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN)
+ var is_left := Input.is_key_pressed(KEY_A) or Input.is_physical_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT)
+ var is_right := Input.is_key_pressed(KEY_D) or Input.is_physical_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT)
+ if not (is_up or is_down or is_left or is_right):
+ return Vector2.ZERO
var d := Vector2.ZERO
- if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): d.y -= 1
- if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): d.y += 1
- if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): d.x -= 1
- if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): d.x += 1
+ # 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于Down,Left优先于Right
+ if is_up:
+ d.y = -1.0
+ elif is_down:
+ d.y = 1.0
+ if is_left:
+ d.x = -1.0
+ elif is_right:
+ d.x = 1.0
return d.normalized()
func _blocked(x: float, z: float) -> bool:
@@ -298,40 +353,62 @@ func _blocked(x: float, z: float) -> bool:
# 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:
+static func _is_static_actor(n: Node3D) -> bool:
+ if not is_instance_valid(n):
return false
- var cur := player.position
+ var shape: String = String(n.get_meta("cursor_shape", ""))
+ if shape == "DOOR":
+ return true
+ var kind: int = int(n.get_meta("kind", -1))
+ if kind in [0, 1, 3]: # KIND_BUILDING, KIND_WOODEN_DOOR, KIND_STONE
+ return true
+ return false
+
+# 40250 Actor-Actor 阻挡判定与阻挡实体获取
+# ActorInstanceCollisionDetection.cpp:640: 仅在两实体距离缩小(d_next < d_now)且侵入包围半径时阻挡
+func _get_blocking_actor(next_pos: Vector3) -> Node3D:
+ if skip_actor_collision or player == null or locked:
+ return null
+ var cur: Vector3 = player.global_position if player.is_inside_tree() else 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()
+ if bool(n.get_meta("dead", false)):
continue
- var op: Vector3 = n.global_position
+ var op: Vector3 = n.global_position if n.is_inside_tree() else n.position
var d_now := Vector2(op.x - cur.x, op.z - cur.z).length()
- if d_now > ACTOR_COLLIDE_MAX_DIST_M: # LengthSq > 800² → 不检
+ if d_now > ACTOR_COLLIDE_MAX_DIST_M:
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
+ if d_next < combined and d_next < d_now:
+ return n
+ return null
-# SetAdvancingRotation(InstanceBaseMovement.cpp):按转向速度逐帧收敛朝向;移动方向本身
-# 不受此限制(骑乘时 rotation_speed_deg = 300 → 转向明显变慢)。
+func _actor_blocked(next_pos: Vector3) -> bool:
+ var b := _get_blocking_actor(next_pos)
+ return b != null and _is_static_actor(b)
+
+# SetAdvancingRotation(InstanceBaseMovement.cpp:142-154):
+# 差值 > 45° 时全速转向(步战 1200°/s,骑乘 300°/s);差值 <= 45° 时按 5/12 阻尼减速(500°/s 或 125°/s)。
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)
+ player.rotation.x = 0.0
+ player.rotation.z = 0.0
+ var cur_yaw: float = wrapf(player.rotation.y, -PI, PI)
+ var diff: float = wrapf(target_yaw - cur_yaw, -PI, PI)
+ var spd := rotation_speed_deg
+ if absf(diff) <= deg_to_rad(45.0):
+ spd *= 5.0 / 12.0
+ var max_step := deg_to_rad(spd) * dt
if max_step <= 0.0 or absf(diff) <= max_step:
player.rotation.y = target_yaw
else:
- player.rotation.y += signf(diff) * max_step
+ player.rotation.y = wrapf(cur_yaw + signf(diff) * max_step, -PI, PI)
func _process(dt: float) -> void:
- if player == null:
+ if player == null or not active:
return
_update_hover_cursor()
@@ -345,9 +422,20 @@ func _process(dt: float) -> void:
if frozen or locked:
# 停止平移,但保留 m_isGoing(解锁后继续走向 Dst,等价预约不丢)。
- anim_state.emit("wait")
+ # 处于攻击或施法锁定中(locked),不发送 wait 动画中断技能/普攻动作
+ if frozen and not locked:
+ anim_state.emit("wait")
+ if world and world.has_method("sample_height") and is_instance_valid(player):
+ player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
return
- _run = force_run or Input.is_key_pressed(RUN_HOLD_KEY)
+ if force_walk:
+ _run = false
+ elif force_run:
+ _run = true
+ elif default_run:
+ _run = not Input.is_key_pressed(RUN_HOLD_KEY) and not Input.is_physical_key_pressed(RUN_HOLD_KEY)
+ else:
+ _run = Input.is_key_pressed(RUN_HOLD_KEY) or Input.is_physical_key_pressed(RUN_HOLD_KEY)
var wish := Vector3.ZERO
var wasd := _wasd()
var mobile_axis := _mobile_axis
@@ -362,6 +450,7 @@ func _process(dt: float) -> void:
return
_mobile_active = true
_is_going = false
+ _reserved_ground = null
_last_wasd = Vector2.ZERO
# GameCamera 位于 head + (sin yaw, cos yaw)*dist 并看向 head:视线的水平前方是
# -(sin yaw, cos yaw),屏幕右 = 前方 × UP。
@@ -380,6 +469,7 @@ func _process(dt: float) -> void:
return
_last_wasd = wasd
_is_going = false # 键盘覆盖点地(NEW_MoveToDirection:m_isGoing = FALSE)
+ _reserved_ground = null
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)
@@ -387,10 +477,13 @@ func _process(dt: float) -> void:
elif _is_going:
_mobile_active = false
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
- if flat.length() <= ARRIVE_EPS:
+ var dist := flat.length()
+ if dist <= ARRIVE_EPS:
+ player.position.x = _dst_pos.x
+ player.position.z = _dst_pos.z
_is_going = false
else:
- wish = flat.normalized()
+ wish = flat / dist
else:
_mobile_active = false
_last_wasd = Vector2.ZERO
@@ -401,22 +494,52 @@ func _process(dt: float) -> void:
if moving_skill:
_turn_toward(atan2(wish.x, wish.z), dt)
else:
- var want_speed := (SPEED_RUN if _run else SPEED_WALK) * server_speed_scale
+ var base_speed := SPEED_RUN if _run else SPEED_WALK
+ if player and player.has_method("get_move_motion_speeds"):
+ var ms: Vector2 = player.call("get_move_motion_speeds")
+ var m_spd: float = (ms.y if _run else ms.x) * 0.01
+ if m_spd > 0.0:
+ base_speed = m_spd
+ var want_speed := base_speed * 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 move_step := want_speed * dt
+ if _is_going:
+ var to_dst := Vector2(_dst_pos.x - player.position.x, _dst_pos.z - player.position.z).length()
+ if move_step >= to_dst:
+ move_step = to_dst
+ var np := player.position + wish * move_step
+ var direct_blocked := false
+ var blocker := _get_blocking_actor(np)
+ if blocker:
+ direct_blocked = true
+ var b_pos: Vector3 = blocker.global_position if blocker.is_inside_tree() else blocker.position
+ var to_player := Vector3(player.position.x - b_pos.x, 0, player.position.z - b_pos.z)
+ var norm := to_player.normalized()
+ var tangent := Vector3(-norm.z, 0, norm.x)
+ if tangent.dot(wish) < 0.0:
+ tangent = -tangent
+ var slide_np := player.position + tangent * (wish.dot(tangent) * move_step)
+ if not _actor_blocked(slide_np) and not _blocked(slide_np.x, slide_np.z):
+ np = slide_np
+ direct_blocked = false
+ if direct_blocked or _blocked(np.x, np.z) or _actor_blocked(np):
+ # 逐轴推进降级保底:地形 ATTRIBUTE_BLOCK 或静态物体阻挡
+ 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
+ else:
+ player.position = np
+
var disp := Vector2(player.position.x - before.x, player.position.z - before.z).length()
- if disp > want_speed * dt * 0.25:
+ if _is_going and Vector2(_dst_pos.x - player.position.x, _dst_pos.z - player.position.z).length() <= ARRIVE_EPS:
+ _is_going = false
+ var is_manual := (wasd != Vector2.ZERO or mobile_axis != Vector2.ZERO)
+ if disp > want_speed * dt * 0.25 or is_manual:
speed = want_speed
_turn_toward(atan2(wish.x, wish.z), dt)
else:
@@ -424,7 +547,8 @@ func _process(dt: float) -> void:
if world and world.has_method("sample_height"):
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
- moved.emit(player.position)
+ if speed > 0.01:
+ moved.emit(player.position)
if speed <= 0.01:
anim_state.emit("wait")
@@ -436,6 +560,10 @@ func _process(dt: float) -> void:
func _update_hover_cursor() -> void:
if cursor_manager == null or not cursor_manager.has_method("set_cursor") or camera == null:
return
+ if camera.has_method("is_dragging") and bool(camera.call("is_dragging")):
+ cursor_manager.set_cursor("CAMERA_ROTATE")
+ _set_hover_vid(0)
+ return
var mouse_pos := get_viewport().get_mouse_position()
if ui_manager and ui_manager.has_method("blocks_game_input"):
var motion := InputEventMouseMotion.new()
@@ -455,6 +583,8 @@ func _update_hover_cursor() -> void:
for n in pickables:
if not is_instance_valid(n):
continue
+ if bool(n.get_meta("dead", false)):
+ continue
var t := _ray_pick_t(from, dir, n)
if t >= 0.0 and t < best_t:
best_t = t
@@ -463,8 +593,12 @@ func _update_hover_cursor() -> void:
cursor_manager.set_cursor(String(best.get_meta("cursor_shape", "ATTACK")))
_set_hover_vid(int(best.get_meta("vid", 0)))
else:
- cursor_manager.set_cursor("NORMAL")
_set_hover_vid(0)
+ var ghit: Variant = _ray_ground(from, dir)
+ if ghit != null and world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", ghit.x, ghit.z)):
+ cursor_manager.set_cursor("CANT_GO")
+ else:
+ cursor_manager.set_cursor("NORMAL")
# game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停命中的角色 vid 变化时才 emit。
func _set_hover_vid(vid: int) -> void:
diff --git a/project/player_motion_test.gd b/project/player_motion_test.gd
index 966f9b2c..cfdbe492 100644
--- a/project/player_motion_test.gd
+++ b/project/player_motion_test.gd
@@ -45,6 +45,16 @@ func _run() -> void:
pv.set_audio(audio)
pv.set_anim_state("run")
_ck(pv._sound_instances.size() == 3, "PlayerView run motion loads paired .mss instances")
+ pv.set_motion_mode(3) # MODE_TWOHAND_SWORD
+ _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/run.msa"),
+ "two-handed mode rebinds current run loop")
+ pv.set_anim_state("wait")
+ _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/wait.msa"),
+ "two-handed wait uses the two-hand grip pose")
+ pv.play_attack_motion(3, 14, 1.0)
+ _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/combo_01.msa"),
+ "two-handed first swing uses combo_01")
+ pv.set_motion_mode(1)
_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")
diff --git a/project/player_skill.gd b/project/player_skill.gd
index 3f693a91..e8df5fac 100644
--- a/project/player_skill.gd
+++ b/project/player_skill.gd
@@ -59,6 +59,8 @@ var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不
var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成
## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE −200 修正);<0 = 未知。未注入则不判射程。
var target_distance := Callable()
+## 40250 NEW_GetFrontInstance (InstanceBaseBattle.cpp:145):前方扇面 20m 自动索敌 Callable(max_dist_cm) -> int (vid)
+var get_front_instance := Callable()
func setup(m2client: Object, skill_table: RefCounted) -> void:
client = m2client
@@ -304,7 +306,13 @@ func check_skill_usable(skill_id: int) -> Dictionary:
# if (m_sysIsLevelLimit && rkSkillInst.iLevel <= 0) -> "NOT_YET_LEARN"
if level_limit and _skill_level(skill_id) <= 0:
- return _no("NOT_YET_LEARN")
+ var has_any_skills := false
+ if client and client.has_method("get_skills"):
+ var sk_arr: Array = client.get_skills()
+ if not sk_arr.is_empty():
+ has_any_skills = true
+ if has_any_skills:
+ return _no("NOT_YET_LEARN")
# if (!CanUseWeaponType(GetWeaponType())) -> "NOT_MATCHABLE_WEAPON"
# weapon_sub_type < 0(未知)时跳过,避免误挡。
@@ -433,7 +441,17 @@ func _resolve_target(skill_id: int) -> Dictionary:
# --- 无目标 ---
if table.is_auto_search_target(skill_id):
- # NEW_GetFrontInstance 在 POC 侧没有等价物;无目标即失败。
+ # 40250 NEW_GetFrontInstance: 面前 20m 扇面自动锁定最近敌人
+ if get_front_instance.is_valid():
+ var front_vid: int = int(get_front_instance.call(2000.0))
+ if front_vid != 0:
+ tvid = front_vid
+ var fe: Variant = client.get_entity(tvid) if client and client.has_method("get_entity") else {}
+ if fe is Dictionary and not fe.is_empty() and not bool(fe.get("dead", false)):
+ var far := _out_of_range(skill_id, tvid)
+ if not far.is_empty():
+ return far
+ return _ok(tvid)
if not table.can_use_for_me(skill_id) and not need_corpse:
return _no("NEED_TARGET")
@@ -441,6 +459,8 @@ func _resolve_target(skill_id: int) -> Dictionary:
return _ok(main_vid)
if need_corpse:
return _no("ONLY_FOR_CORPSE")
+ if not table.is_need_target(skill_id):
+ return _ok(0)
return _no("NEED_TARGET")
# __CheckSpecialSkill —— PythonPlayerSkill.cpp:950(钓鱼 123 / 连击 122)。
diff --git a/project/player_skill_test.gd b/project/player_skill_test.gd
index c515368d..5e2332f9 100644
--- a/project/player_skill_test.gd
+++ b/project/player_skill_test.gd
@@ -14,6 +14,7 @@ class FakeClient extends Node:
var entities := {1000: {"kind": 0}, 2000: {"kind": 2}}
var skills := [
{"id": 1, "level": 5, "master": 0},
+ {"id": 2, "level": 5, "master": 0},
{"id": 46, "level": 10, "master": 0},
{"id": 109, "level": 1, "master": 0},
]
@@ -116,9 +117,11 @@ func _run() -> void:
_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 练满
+ for sk in fc.skills:
+ if sk["id"] == 109: sk["level"] = 20
_ck(g.can_use_skill().ok, "(b) 骑乘术 lv20 -> ok")
- fc.skills[2]["level"] = 1
+ for sk in fc.skills:
+ if sk["id"] == 109: sk["level"] = 1
# 无主角
_reset(g); fc.main = 0
_ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 无主角 -> CANNOT_ACT")
@@ -223,6 +226,8 @@ func _run() -> void:
_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")
+ var r2 := g.use_skill(2)
+ _ck(r2.ok and r2.target_vid == 0, "(d) skill 2 无目标可自由施放 -> ok, target=0")
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")
diff --git a/project/player_trade_exchange_system.gd b/project/player_trade_exchange_system.gd
new file mode 100644
index 00000000..bf0d74c2
--- /dev/null
+++ b/project/player_trade_exchange_system.gd
@@ -0,0 +1,343 @@
+# player_trade_exchange_system.gd —— 40250 官方玩家面对面安全交易双向锁定系统 1:1
+# 严格对照:
+# metin2/src/server/game/src/exchange.h
+# metin2/src/server/game/src/exchange.cpp
+# metin2/src/server/game/src/char.cpp (OpenExchange, SetExchange, etc.)
+extends RefCounted
+
+const EXCHANGE_ITEM_MAX_NUM := 12 # 官方交易面板 12 格 (4x3 布局)
+const EXCHANGE_MAX_DISTANCE := 1000 # 官方交易最大距离 1000
+
+# 玩家交易单边状态
+class TradeSide:
+ var player_id: int
+ var player_name: String
+ var is_ready: bool = false
+ var offered_gold: int = 0
+ # 12 个格位: display_pos (0..11) -> item_data Dictionary or null
+ var display_slots: Array = []
+ # 关联背包位置: display_pos -> inv_slot
+ var inv_positions: Dictionary = {}
+
+ func _init(id: int, name: String) -> void:
+ player_id = id
+ player_name = name
+ is_ready = false
+ offered_gold = 0
+ display_slots.clear()
+ for i in range(EXCHANGE_ITEM_MAX_NUM):
+ display_slots.append(null)
+ inv_positions.clear()
+
+ func count_items() -> int:
+ var c := 0
+ for it in display_slots:
+ if it != null:
+ c += 1
+ return c
+
+var side_a: TradeSide = null
+var side_b: TradeSide = null
+var is_active: bool = false
+
+# 模拟玩家的背包与金币引用(便于原子化结算)
+# player_data = { "id": int, "name": int, "gold": int, "inv": Array, "max_inv_slots": int }
+var player_a_ref: Dictionary = {}
+var player_b_ref: Dictionary = {}
+
+func _init() -> void:
+ side_a = null
+ side_b = null
+ is_active = false
+
+# 开启双向交易会话 (1:1 OpenExchange)
+func start_exchange(player_a: Dictionary, player_b: Dictionary, distance: float = 200.0) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if distance > EXCHANGE_MAX_DISTANCE:
+ res["error_code"] = "TOO_FAR"
+ res["message"] = "双方距离太远,无法进行交易。"
+ return res
+
+ if player_a.get("id") == player_b.get("id"):
+ res["error_code"] = "CANNOT_TRADE_SELF"
+ res["message"] = "无法与自己进行交易。"
+ return res
+
+ # 检查状态
+ if player_a.get("is_dead", false) or player_b.get("is_dead", false):
+ res["error_code"] = "PLAYER_DEAD"
+ res["message"] = "处于死亡状态的玩家无法交易。"
+ return res
+
+ if player_a.get("is_in_safebox", false) or player_b.get("is_in_safebox", false) \
+ or player_a.get("is_in_shop", false) or player_b.get("is_in_shop", false):
+ res["error_code"] = "BUSY_WINDOW"
+ res["message"] = "一方正处于其他交互窗口中,无法交易。"
+ return res
+
+ player_a_ref = player_a
+ player_b_ref = player_b
+ side_a = TradeSide.new(player_a["id"], player_a.get("name", "PlayerA"))
+ side_b = TradeSide.new(player_b["id"], player_b.get("name", "PlayerB"))
+ is_active = true
+
+ res["success"] = true
+ res["message"] = "交易窗口已开启。"
+ return res
+
+# 获取玩家所属的交易边
+func _get_side(player_id: int) -> TradeSide:
+ if side_a != null and side_a.player_id == player_id:
+ return side_a
+ elif side_b != null and side_b.player_id == player_id:
+ return side_b
+ return null
+
+# 获取交易对手边
+func _get_other_side(player_id: int) -> TradeSide:
+ if side_a != null and side_a.player_id == player_id:
+ return side_b
+ elif side_b != null and side_b.player_id == player_id:
+ return side_a
+ return null
+
+func _get_player_ref(player_id: int) -> Dictionary:
+ if player_a_ref.get("id") == player_id:
+ return player_a_ref
+ elif player_b_ref.get("id") == player_id:
+ return player_b_ref
+ return {}
+
+# 放入物品到交易栏 (1:1 CExchange::AddItem)
+# 核心防调包规则:一旦修改物品,双方锁定状态立即重置为 false!
+func add_item(player_id: int, inv_slot: int, display_pos: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+ if not is_active:
+ res["error_code"] = "TRADE_NOT_ACTIVE"
+ return res
+
+ var side := _get_side(player_id)
+ var other := _get_other_side(player_id)
+ var player_data := _get_player_ref(player_id)
+ if side == null or other == null or player_data.is_empty():
+ res["error_code"] = "INVALID_PARTICIPANT"
+ return res
+
+ if display_pos < 0 or display_pos >= EXCHANGE_ITEM_MAX_NUM:
+ res["error_code"] = "INVALID_DISPLAY_POS"
+ return res
+
+ if side.display_slots[display_pos] != null:
+ res["error_code"] = "SLOT_ALREADY_OCCUPIED"
+ return res
+
+ var inv: Array = player_data.get("inv", [])
+ if inv_slot < 0 or inv_slot >= inv.size() or inv[inv_slot] == null:
+ res["error_code"] = "ITEM_NOT_FOUND"
+ return res
+
+ var it: Dictionary = inv[inv_slot]
+
+ # 1:1 防交易检查: ANTI_GIVE 标记 / 灵魂绑定 / 装备中
+ if it.get("anti_give", false) or it.get("is_soulbound", false) or it.get("is_equipped", false):
+ res["error_code"] = "ITEM_UNTRADEABLE"
+ res["message"] = "该物品受法则限制,不可交易或转移。"
+ return res
+
+ # 放入交易槽
+ side.display_slots[display_pos] = it.duplicate(true)
+ side.inv_positions[display_pos] = inv_slot
+
+ # 40250 官方核心安全机制: 重置双方锁定状态!
+ side.is_ready = false
+ other.is_ready = false
+
+ res["success"] = true
+ res["message"] = "物品已上架交易栏,双方就绪状态已重置。"
+ return res
+
+# 从交易栏取回物品 (1:1 CExchange::RemoveItem)
+# 同样重置双方锁定状态!
+func remove_item(player_id: int, display_pos: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK" }
+ if not is_active:
+ res["error_code"] = "TRADE_NOT_ACTIVE"
+ return res
+
+ var side := _get_side(player_id)
+ var other := _get_other_side(player_id)
+ if side == null or other == null:
+ res["error_code"] = "INVALID_PARTICIPANT"
+ return res
+
+ if display_pos < 0 or display_pos >= EXCHANGE_ITEM_MAX_NUM or side.display_slots[display_pos] == null:
+ res["error_code"] = "SLOT_EMPTY"
+ return res
+
+ side.display_slots[display_pos] = null
+ side.inv_positions.erase(display_pos)
+
+ # 40250 官方安全机制: 重置双方锁定状态!
+ side.is_ready = false
+ other.is_ready = false
+
+ res["success"] = true
+ return res
+
+# 设置出价金币 (1:1 CExchange::AddGold)
+# 同样重置双方锁定状态!
+func set_gold(player_id: int, amount: int) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+ if not is_active:
+ res["error_code"] = "TRADE_NOT_ACTIVE"
+ return res
+
+ var side := _get_side(player_id)
+ var other := _get_other_side(player_id)
+ var player_data := _get_player_ref(player_id)
+ if side == null or other == null or player_data.is_empty():
+ res["error_code"] = "INVALID_PARTICIPANT"
+ return res
+
+ if amount < 0:
+ res["error_code"] = "INVALID_GOLD"
+ return res
+
+ if player_data.get("gold", 0) < amount:
+ res["error_code"] = "INSUFFICIENT_GOLD"
+ res["message"] = "身上金币不足,无法出价。"
+ return res
+
+ side.offered_gold = amount
+
+ # 40250 官方安全机制: 重置双方锁定状态!
+ side.is_ready = false
+ other.is_ready = false
+
+ res["success"] = true
+ res["message"] = "出价金额已更新,双方就绪状态已重置。"
+ return res
+
+# 点击锁定/同意 (1:1 CExchange::Accept)
+# 双方均锁定后自动触发原子化结算
+func set_ready(player_id: int, ready_state: bool) -> Dictionary:
+ var res := {
+ "success": false,
+ "trade_completed": false,
+ "error_code": "OK",
+ "message": ""
+ }
+ if not is_active:
+ res["error_code"] = "TRADE_NOT_ACTIVE"
+ return res
+
+ var side := _get_side(player_id)
+ var other := _get_other_side(player_id)
+ if side == null or other == null:
+ res["error_code"] = "INVALID_PARTICIPANT"
+ return res
+
+ side.is_ready = ready_state
+ res["success"] = true
+
+ # 若双方均已 Ready 锁定,执行原子化交付完成交易 (CExchange::Done)
+ if side_a.is_ready and side_b.is_ready:
+ var done_res = _execute_atomic_trade()
+ if done_res["success"]:
+ res["trade_completed"] = true
+ res["message"] = "交易成功!双方物品与金币已安全过户。"
+ else:
+ res["trade_completed"] = false
+ res["error_code"] = done_res["error_code"]
+ res["message"] = done_res["message"]
+ else:
+ res["message"] = "已就绪锁定,等待对方确认。"
+
+ return res
+
+# 背包剩余空位计算
+func _get_free_inv_slots(player_data: Dictionary) -> int:
+ var inv: Array = player_data.get("inv", [])
+ var free_count := 0
+ for it in inv:
+ if it == null:
+ free_count += 1
+ return free_count
+
+# 原子化事务交付执行 (1:1 CExchange::Done & CheckSpace)
+func _execute_atomic_trade() -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ # 1. 再次校验金币是否充足
+ if player_a_ref.get("gold", 0) < side_a.offered_gold:
+ res["error_code"] = "PLAYER_A_LESS_GOLD"
+ res["message"] = "一方金币不足,交易终止。"
+ return res
+ if player_b_ref.get("gold", 0) < side_b.offered_gold:
+ res["error_code"] = "PLAYER_B_LESS_GOLD"
+ res["message"] = "一方金币不足,交易终止。"
+ return res
+
+ # 2. 空间校验 (CheckSpace):
+ # 玩家 A 需容纳 B 给予的所有物品;玩家 B 需容纳 A 给予的所有物品
+ # 考虑双方移出物品后腾出的背包空位
+ var a_items_count := side_a.count_items()
+ var b_items_count := side_b.count_items()
+
+ var a_free := _get_free_inv_slots(player_a_ref) + a_items_count
+ var b_free := _get_free_inv_slots(player_b_ref) + b_items_count
+
+ if a_free < b_items_count:
+ res["error_code"] = "PLAYER_A_INSUFFICIENT_SPACE"
+ res["message"] = "玩家 A 背包空间不足,无法接收对方物品。"
+ return res
+
+ if b_free < a_items_count:
+ res["error_code"] = "PLAYER_B_INSUFFICIENT_SPACE"
+ res["message"] = "玩家 B 背包空间不足,无法接收对方物品。"
+ return res
+
+ # 3. 执行金币转移
+ player_a_ref["gold"] = player_a_ref.get("gold", 0) - side_a.offered_gold + side_b.offered_gold
+ player_b_ref["gold"] = player_b_ref.get("gold", 0) - side_b.offered_gold + side_a.offered_gold
+
+ # 4. 执行物品转移
+ # 从 A 背包移除 A 出售的物品
+ var inv_a: Array = player_a_ref.get("inv", [])
+ for dpos in side_a.inv_positions:
+ var inv_slot: int = side_a.inv_positions[dpos]
+ inv_a[inv_slot] = null
+
+ # 从 B 背包移除 B 出售的物品
+ var inv_b: Array = player_b_ref.get("inv", [])
+ for dpos in side_b.inv_positions:
+ var inv_slot: int = side_b.inv_positions[dpos]
+ inv_b[inv_slot] = null
+
+ # 将 A 的物品放入 B 的背包空位
+ for it in side_a.display_slots:
+ if it != null:
+ for i in range(inv_b.size()):
+ if inv_b[i] == null:
+ inv_b[i] = it.duplicate(true)
+ break
+
+ # 将 B 的物品放入 A 的背包空位
+ for it in side_b.display_slots:
+ if it != null:
+ for i in range(inv_a.size()):
+ if inv_a[i] == null:
+ inv_a[i] = it.duplicate(true)
+ break
+
+ # 5. 关闭会话
+ is_active = false
+ res["success"] = true
+ return res
+
+# 取消交易 (1:1 CExchange::Cancel)
+func cancel_trade() -> void:
+ is_active = false
+ side_a = null
+ side_b = null
diff --git a/project/player_trade_exchange_system.gd.uid b/project/player_trade_exchange_system.gd.uid
new file mode 100644
index 00000000..030eda99
--- /dev/null
+++ b/project/player_trade_exchange_system.gd.uid
@@ -0,0 +1 @@
+uid://bw64vqxapkwfr
diff --git a/project/point_reset_system.gd b/project/point_reset_system.gd
new file mode 100644
index 00000000..3a2d674e
--- /dev/null
+++ b/project/point_reset_system.gd
@@ -0,0 +1,130 @@
+# point_reset_system.gd
+# 40250 官方 1:1 属性点/技能重置洗点与两阶段确认系统
+# 对照: uipointreset.py, questiondialog2.py, char.cpp, cmd_general.cpp
+class_name PointResetSystem
+extends RefCounted
+
+const RESET_COST_YANG: int = 500
+
+# 40250 官方四职业初始属性基准值 [CON, INT, STR, DEX]
+const START_STAT: Dictionary = {
+ 0: [4, 3, 6, 3], # WARRIOR (战士)
+ 1: [3, 3, 4, 6], # ASSASSIN (刺客)
+ 2: [3, 5, 5, 3], # SURA (修罗)
+ 3: [4, 6, 3, 3] # SHAMAN (萨满)
+}
+
+# 状态枚举
+enum DialogState {
+ CLOSED = 0,
+ INITIAL_PROMPT = 1,
+ CONFIRMATION = 2
+}
+
+var state: DialogState = DialogState.CLOSED
+
+var player_job: int = 0
+var player_gold: int = 0
+var current_stats: Dictionary = {"CON": 4, "INT": 3, "STR": 6, "DEX": 3}
+var unallocated_stat_points: int = 0
+
+# 技能重置支持
+var player_level: int = 1
+var current_skill_tree: int = 0 # 0=未选, 1=分支A, 2=分支B
+var unallocated_skill_points: int = 0
+var active_skills: Dictionary = {}
+
+var last_packet_sent: String = ""
+
+signal dialog_opened(cost: int)
+signal confirm_dialog_opened(warning_text: String)
+signal dialog_closed()
+signal reset_completed(new_stats: Dictionary, refund_points: int, remaining_gold: int)
+signal reset_failed(reason: String)
+
+func init_player_data(job: int, level: int, gold: int, stats: Dictionary, stat_pts: int, skill_tree: int, skill_pts: int, skills: Dictionary) -> void:
+ player_job = job
+ player_level = level
+ player_gold = gold
+ current_stats = stats.duplicate()
+ unallocated_stat_points = stat_pts
+ current_skill_tree = skill_tree
+ unallocated_skill_points = skill_pts
+ active_skills = skills.duplicate()
+
+func open_dialog() -> void:
+ state = DialogState.INITIAL_PROMPT
+ dialog_opened.emit(RESET_COST_YANG)
+
+func open_confirm_dialog() -> bool:
+ if state != DialogState.INITIAL_PROMPT:
+ return false
+
+ # 检查金币是否满足 500 Yang
+ if player_gold < RESET_COST_YANG:
+ reset_failed.emit("NOT_ENOUGH_MONEY")
+ close_dialog()
+ return false
+
+ state = DialogState.CONFIRMATION
+ var warning_msg = "重置后所有属性点将被重置!确定继续吗?"
+ confirm_dialog_opened.emit(warning_msg)
+ return true
+
+func accept_reset() -> bool:
+ if state != DialogState.CONFIRMATION:
+ return false
+
+ if player_gold < RESET_COST_YANG:
+ reset_failed.emit("NOT_ENOUGH_MONEY")
+ close_dialog()
+ return false
+
+ # 扣除 500 金币
+ player_gold -= RESET_COST_YANG
+ last_packet_sent = "/pointreset"
+
+ # 回退属性点
+ var base_stat = START_STAT.get(player_job, [4, 3, 6, 3])
+ var refund_con = maxi(0, current_stats["CON"] - base_stat[0])
+ var refund_int = maxi(0, current_stats["INT"] - base_stat[1])
+ var refund_str = maxi(0, current_stats["STR"] - base_stat[2])
+ var refund_dex = maxi(0, current_stats["DEX"] - base_stat[3])
+ var total_refund = refund_con + refund_int + refund_str + refund_dex
+
+ unallocated_stat_points += total_refund
+ current_stats["CON"] = base_stat[0]
+ current_stats["INT"] = base_stat[1]
+ current_stats["STR"] = base_stat[2]
+ current_stats["DEX"] = base_stat[3]
+
+ reset_completed.emit(current_stats, total_refund, player_gold)
+ close_dialog()
+ return true
+
+# 官方 30 级以下老妇人技能树重洗 (Old Woman Skill Reset)
+func reset_skills_at_old_woman() -> bool:
+ if player_level > 30:
+ reset_failed.emit("LEVEL_TOO_HIGH")
+ return false
+
+ # 40250 老妇人洗技能费用公式: level * 1000 + 2000 Yang
+ var cost = player_level * 1000 + 2000
+ if player_gold < cost:
+ reset_failed.emit("NOT_ENOUGH_MONEY")
+ return false
+
+ player_gold -= cost
+ var total_skill_pts = 0
+ for sk_id in active_skills:
+ total_skill_pts += active_skills[sk_id]
+
+ active_skills.clear()
+ unallocated_skill_points += total_skill_pts
+ current_skill_tree = 0 # 允许重新转职选择专精分支
+
+ return true
+
+func close_dialog() -> void:
+ state = DialogState.CLOSED
+ dialog_closed.emit()
diff --git a/project/point_reset_system.gd.uid b/project/point_reset_system.gd.uid
new file mode 100644
index 00000000..2f49a1cb
--- /dev/null
+++ b/project/point_reset_system.gd.uid
@@ -0,0 +1 @@
+uid://df7x3m5rxjd83
diff --git a/project/polymorph_marble_system.gd b/project/polymorph_marble_system.gd
new file mode 100644
index 00000000..fabbf7f6
--- /dev/null
+++ b/project/polymorph_marble_system.gd
@@ -0,0 +1,200 @@
+# polymorph_marble_system.gd —— Metin2 40250 官方变身宝珠与怪物精魂附体系统 1:1
+# 对照 40250 服务端 polymorph.cpp, polymorph.h, char.cpp, affect.h
+class_name PolymorphMarbleSystem
+extends RefCounted
+
+signal polymorph_started(mob_vnum: int, mob_name: String, duration: float, bonus_type: int, bonus_val: int)
+signal polymorph_ended(mob_vnum: int, mob_name: String)
+
+const VNUM_POLYMORPH_MARBLE := 70104 # 变身宝珠 (Polymorph Marble)
+
+# 40250 变身法术技能境界 (Skill Master Type)
+const SKILL_NORMAL := 0 # 普通熟练度 (1~19 级): 10 分钟 (600s)
+const SKILL_MASTER := 1 # Master (M1~M10): 15 分钟 (900s)
+const SKILL_GRAND_MASTER := 2 # Grand Master (G1~G10): 20 分钟 (1200s)
+const SKILL_PERFECT_MASTER := 3 # Perfect Master (P): 25 分钟 (1500s)
+
+# 40250 polymorph.h 加成属性类型枚举
+const POLYMORPH_NO_BONUS := 0
+const POLYMORPH_ATK_BONUS := 1 # 攻击力增益 (POINT_ATT_BONUS)
+const POLYMORPH_DEF_BONUS := 2 # 防御力增益 (POINT_DEF_BONUS)
+const POLYMORPH_SPD_BONUS := 3 # 移动速度增益 (POINT_MOV_SPEED)
+
+# 40250 经典怪物精魂图鉴与加成配置表
+const MONSTER_POLY_TABLE: Dictionary = {
+ 501: {
+ "mob_vnum": 501,
+ "name": "野蛮步兵",
+ "bonus_type": POLYMORPH_ATK_BONUS,
+ "bonus_val": 40,
+ "stat_key": "attack_power"
+ },
+ 502: {
+ "mob_vnum": 502,
+ "name": "野蛮弓手",
+ "bonus_type": POLYMORPH_ATK_BONUS,
+ "bonus_val": 35,
+ "stat_key": "attack_power"
+ },
+ 492: {
+ "mob_vnum": 492,
+ "name": "白发蜘蛛",
+ "bonus_type": POLYMORPH_DEF_BONUS,
+ "bonus_val": 50,
+ "stat_key": "defense"
+ },
+ 101: {
+ "mob_vnum": 101,
+ "name": "野狗",
+ "bonus_type": POLYMORPH_SPD_BONUS,
+ "bonus_val": 25,
+ "stat_key": "move_speed"
+ },
+ 1901: {
+ "mob_vnum": 1901,
+ "name": "九尾妖狐",
+ "bonus_type": POLYMORPH_SPD_BONUS,
+ "bonus_val": 30,
+ "stat_key": "move_speed"
+ }
+}
+
+var is_polymorphed: bool = false
+var active_mob_vnum: int = 0
+var active_mob_name: String = ""
+var time_remaining: float = 0.0
+var total_duration: float = 0.0
+var active_bonus_type: int = POLYMORPH_NO_BONUS
+var active_bonus_val: int = 0
+var active_stat_key: String = ""
+
+# 计算变身持续时长 (40250 polymorph.cpp:44-59)
+static func get_duration_by_mastery(master_type: int) -> float:
+ match master_type:
+ SKILL_NORMAL:
+ return 600.0 # 10 分钟
+ SKILL_MASTER:
+ return 900.0 # 15 分钟
+ SKILL_GRAND_MASTER:
+ return 1200.0 # 20 分钟
+ SKILL_PERFECT_MASTER:
+ return 1500.0 # 25 分钟
+ _:
+ return 600.0
+
+# 使用变身宝珠进行怪物附体变身
+func polymorph_character(marble_item: Dictionary, skill_mastery: int, player_stats: Dictionary) -> Dictionary:
+ var vnum := int(marble_item.get("vnum", 0))
+ if vnum != VNUM_POLYMORPH_MARBLE:
+ return {"ok": false, "reason": "NOT_A_POLYMORPH_MARBLE", "msg": "该物品不是变身宝珠!"}
+
+ var mob_vnum := int(marble_item.get("mob_vnum", 0))
+ if mob_vnum == 0:
+ var sockets: Array = marble_item.get("sockets", [])
+ if not sockets.is_empty():
+ mob_vnum = int(sockets[0])
+
+ if not MONSTER_POLY_TABLE.has(mob_vnum):
+ return {"ok": false, "reason": "INVALID_MONSTER_VNUM", "msg": "未知的变身怪物精魂!"}
+
+ # 若当前已处于变身状态,先恢复原形
+ if is_polymorphed:
+ revert_polymorph(player_stats)
+
+ var mob_info: Dictionary = MONSTER_POLY_TABLE[mob_vnum]
+ var duration: float = get_duration_by_mastery(skill_mastery)
+
+ is_polymorphed = true
+ active_mob_vnum = mob_vnum
+ active_mob_name = mob_info["name"]
+ time_remaining = duration
+ total_duration = duration
+ active_bonus_type = int(mob_info["bonus_type"])
+ active_bonus_val = int(mob_info["bonus_val"])
+ active_stat_key = str(mob_info["stat_key"])
+
+ # 注入变身属性加成
+ if not active_stat_key.is_empty() and active_bonus_val > 0:
+ player_stats[active_stat_key] = int(player_stats.get(active_stat_key, 0)) + active_bonus_val
+
+ polymorph_started.emit(active_mob_vnum, active_mob_name, duration, active_bonus_type, active_bonus_val)
+
+ return {
+ "ok": true,
+ "mob_vnum": active_mob_vnum,
+ "mob_name": active_mob_name,
+ "duration": duration,
+ "bonus_type": active_bonus_type,
+ "bonus_val": active_bonus_val,
+ "msg": "怪物精魂附体!成功变身为【%s】,持续 %.0f 秒。" % [active_mob_name, duration]
+ }
+
+# 解除变身恢复真身原型
+func revert_polymorph(player_stats: Dictionary) -> Dictionary:
+ if not is_polymorphed:
+ return {"ok": false, "reason": "NOT_POLYMORPHED", "msg": "当前未处于变身状态!"}
+
+ # 清除施加的加成属性
+ if not active_stat_key.is_empty() and active_bonus_val > 0:
+ player_stats[active_stat_key] = int(player_stats.get(active_stat_key, 0)) - active_bonus_val
+
+ var old_mob := active_mob_vnum
+ var old_name := active_mob_name
+
+ is_polymorphed = false
+ active_mob_vnum = 0
+ active_mob_name = ""
+ time_remaining = 0.0
+ total_duration = 0.0
+ active_bonus_type = POLYMORPH_NO_BONUS
+ active_bonus_val = 0
+ active_stat_key = ""
+
+ polymorph_ended.emit(old_mob, old_name)
+
+ return {
+ "ok": true,
+ "mob_vnum": old_mob,
+ "mob_name": old_name,
+ "msg": "变身时效已过,恢复人物原形。"
+ }
+
+# 逻辑时钟心跳
+func update(delta: float, player_stats: Dictionary) -> void:
+ if not is_polymorphed:
+ return
+ time_remaining -= delta
+ if time_remaining <= 0.0:
+ revert_polymorph(player_stats)
+
+# 40250 行为限制检查:变身期间禁止乘骑坐骑
+func can_mount_horse() -> bool:
+ return not is_polymorphed
+
+# 40250 行为限制检查:变身期间禁止释放原生职业技能
+func can_cast_player_skills() -> bool:
+ return not is_polymorphed
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_polymorphed": is_polymorphed,
+ "active_mob_vnum": active_mob_vnum,
+ "active_mob_name": active_mob_name,
+ "time_remaining": time_remaining,
+ "total_duration": total_duration,
+ "active_bonus_type": active_bonus_type,
+ "active_bonus_val": active_bonus_val,
+ "active_stat_key": active_stat_key
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_polymorphed = bool(data.get("is_polymorphed", false))
+ active_mob_vnum = int(data.get("active_mob_vnum", 0))
+ active_mob_name = str(data.get("active_mob_name", ""))
+ time_remaining = float(data.get("time_remaining", 0.0))
+ total_duration = float(data.get("total_duration", 0.0))
+ active_bonus_type = int(data.get("active_bonus_type", POLYMORPH_NO_BONUS))
+ active_bonus_val = int(data.get("active_bonus_val", 0))
+ active_stat_key = str(data.get("active_stat_key", ""))
diff --git a/project/polymorph_marble_system.gd.uid b/project/polymorph_marble_system.gd.uid
new file mode 100644
index 00000000..049d4c00
--- /dev/null
+++ b/project/polymorph_marble_system.gd.uid
@@ -0,0 +1 @@
+uid://dvslksqfmwfbv
diff --git a/project/polymorph_system.gd b/project/polymorph_system.gd
new file mode 100644
index 00000000..68b558ce
--- /dev/null
+++ b/project/polymorph_system.gd
@@ -0,0 +1,149 @@
+# polymorph_system.gd —— Metin2 40250 怪物变身球与变身术技能系统 1:1
+# 对照 40250 服务端 polymorph.cpp, char_item.cpp (Item 70104, Skill 129 Polymorph)
+class_name PolymorphSystem
+extends RefCounted
+
+signal polymorph_started(mob_vnum: int, duration: float)
+signal polymorph_ended()
+
+const ITEM_POLYMORPH_MARBLE := 70104
+const SKILL_POLYMORPH := 129
+const AFFECT_POLYMORPH := 220
+
+# 经典变身怪物属性表 (mob_vnum -> 属性加成与基础设定)
+const MOB_DATA: Dictionary = {
+ 101: {"name": "野猪", "atk": 45, "def": 20, "speed": 120},
+ 102: {"name": "野狼", "atk": 35, "def": 15, "speed": 135},
+ 110: {"name": "黑熊", "atk": 60, "def": 35, "speed": 110},
+ 114: {"name": "白虎", "atk": 85, "def": 40, "speed": 140},
+ 501: {"name": "流浪汉", "atk": 70, "def": 30, "speed": 125}
+}
+
+var is_polymorphed: bool = false
+var current_mob_vnum: int = 0
+var remaining_time: float = 0.0
+var original_race: int = 0 # 0: 战男, 1: 忍女, 2: 修男, 3: 萨女等
+var poly_skill_level: int = 1 # 1 ~ 40 (1~19 普通, 20~29 M1~M10, 30~39 G1~G10, 40 P)
+
+# 变身属性收益缓存
+var bonus_atk: int = 0
+var bonus_def: int = 0
+var bonus_speed: int = 0
+
+# 计算变身持续时间 (秒)
+static func get_duration_by_skill(level: int) -> float:
+ if level >= 40: # P 级
+ return 2100.0 # 35 分钟
+ elif level >= 30: # G 级
+ return 1500.0 # 25 分钟
+ elif level >= 20: # M 级
+ return 1200.0 # 20 分钟
+ else:
+ return 600.0 + (level * 20.0) # 10~16 分钟
+
+func can_polymorph(player_dead: bool, player_mounted: bool) -> Dictionary:
+ if player_dead:
+ return {"ok": false, "reason": "PLAYER_DEAD", "msg": "死亡状态下无法变身!"}
+ if player_mounted:
+ return {"ok": false, "reason": "PLAYER_MOUNTED", "msg": "骑马状态下无法变身,请先下马!"}
+ return {"ok": true}
+
+# 使用变身球 (Item 70104) 变身
+func use_polymorph_marble(item: Dictionary, player_dead: bool, player_mounted: bool, p_race: int = 0) -> Dictionary:
+ var check = can_polymorph(player_dead, player_mounted)
+ if not check["ok"]:
+ return check
+
+ var vnum: int = int(item.get("vnum", 0))
+ if vnum != ITEM_POLYMORPH_MARBLE:
+ return {"ok": false, "reason": "NOT_A_MARBLE", "msg": "这不是变身球!"}
+
+ var mob_vnum: int = int(item.get("mob_vnum", 0))
+ if mob_vnum == 0:
+ # 从 socket[0] 读
+ var sockets: Array = item.get("sockets", [])
+ if sockets.size() > 0:
+ mob_vnum = int(sockets[0])
+
+ if mob_vnum == 0 or not MOB_DATA.has(mob_vnum):
+ # 默认野猪 101
+ mob_vnum = 101
+
+ original_race = p_race
+ current_mob_vnum = mob_vnum
+ is_polymorphed = true
+ remaining_time = get_duration_by_skill(poly_skill_level)
+
+ # 结算属性加成 (char_item.cpp)
+ var mdata: Dictionary = MOB_DATA[mob_vnum]
+ var skill_multiplier: float = 1.0 + (float(poly_skill_level) * 0.025)
+ bonus_atk = int(round(float(mdata["atk"]) * skill_multiplier))
+ bonus_def = int(mdata["def"])
+ bonus_speed = int(mdata["speed"])
+
+ polymorph_started.emit(current_mob_vnum, remaining_time)
+
+ return {
+ "ok": true,
+ "mob_vnum": current_mob_vnum,
+ "mob_name": mdata["name"],
+ "duration": remaining_time,
+ "bonus_atk": bonus_atk,
+ "bonus_def": bonus_def,
+ "bonus_speed": bonus_speed
+ }
+
+# 变身状态限制检查 1:1
+func can_use_skill(skill_id: int) -> bool:
+ # 变身中禁止施放人物主动职业技能
+ if is_polymorphed:
+ # 变身术本身、被动或马术/公共技能除外,职业技能一律拦截
+ if skill_id != SKILL_POLYMORPH and skill_id < 100:
+ return false
+ return true
+
+func can_change_weapon() -> bool:
+ # 变身中禁止切换武器 (equip_rules.gd:316)
+ return not is_polymorphed
+
+func can_mount() -> bool:
+ # 变身中禁止骑马
+ return not is_polymorphed
+
+# 逐帧更新倒计时
+func update(dt: float) -> void:
+ if not is_polymorphed:
+ return
+
+ remaining_time -= dt
+ if remaining_time <= 0.0:
+ cancel_polymorph()
+
+# 取消/解除变身
+func cancel_polymorph() -> void:
+ if not is_polymorphed:
+ return
+
+ is_polymorphed = false
+ current_mob_vnum = 0
+ remaining_time = 0.0
+ bonus_atk = 0
+ bonus_def = 0
+ bonus_speed = 0
+
+ polymorph_ended.emit()
+
+func on_player_death() -> void:
+ if is_polymorphed:
+ cancel_polymorph()
+
+func get_state() -> Dictionary:
+ return {
+ "is_polymorphed": is_polymorphed,
+ "mob_vnum": current_mob_vnum,
+ "remaining_time": remaining_time,
+ "poly_skill_level": poly_skill_level,
+ "bonus_atk": bonus_atk,
+ "bonus_def": bonus_def,
+ "bonus_speed": bonus_speed
+ }
diff --git a/project/polymorph_system.gd.uid b/project/polymorph_system.gd.uid
new file mode 100644
index 00000000..c4bf709a
--- /dev/null
+++ b/project/polymorph_system.gd.uid
@@ -0,0 +1 @@
+uid://veallj1tyi8c
diff --git a/project/popup_windows_parity_test.gd b/project/popup_windows_parity_test.gd
new file mode 100644
index 00000000..e85f9f4b
--- /dev/null
+++ b/project/popup_windows_parity_test.gd
@@ -0,0 +1,177 @@
+# popup_windows_parity_test.gd —— 校验弹窗(人物属性面板、仓库、背包等)
+# 严格按照 40250 客户端的 UI 规范与关闭行为。
+# godot --headless --path project --script popup_windows_parity_test.gd
+extends SceneTree
+
+const UiManager = preload("res://ui/ui_manager.gd")
+const CharStatusUI = preload("res://ui/char_status_ui.gd")
+const SafeboxUI = preload("res://ui/safebox_ui.gd")
+const InventoryUI = preload("res://ui/inventory_ui.gd")
+const AssetRoot = preload("res://asset_root.gd")
+
+class FakeClient extends Node:
+ signal points_changed(points: Dictionary)
+ signal entity_main_set(vid: int)
+ signal safebox_opened(size: int, gold: int)
+ signal safebox_closed()
+ signal safebox_item_set(cell: int, vnum: int, count: int, flags: int, anti_flags: int, sockets: Array, attrs: Array)
+ signal inventory_changed(window: int, cell: int)
+
+ var pts: Array = []
+ var _main_vid := 7
+
+ func _init() -> void:
+ pts.resize(255)
+ pts.fill(0)
+ pts[1] = 42
+ pts[5] = 850
+ pts[6] = 900
+ pts[7] = 120
+ pts[8] = 150
+
+ func get_main_vid() -> int: return _main_vid
+ func get_points() -> Dictionary:
+ return {
+ "points": pts, "hp": pts[5], "max_hp": pts[6], "sp": pts[7], "max_sp": pts[8],
+ "level": pts[1], "exp": 0, "next_exp": 1000, "gold": 10000,
+ }
+ func get_entity(vid: int) -> Dictionary:
+ return {"name": "TestHero", "guild": 1, "race": 0}
+ func get_guild_name(gid: int) -> String:
+ return "HeroGuild" if gid == 1 else ""
+ func get_inventory() -> Array: return []
+ func get_equipment() -> Array: return []
+ func get_belt_inventory() -> Array: return []
+ func get_safebox_items() -> Array: return []
+ func get_safebox_gold() -> int: return 5000
+ func get_safebox_size() -> int: return 45
+ func safebox_close() -> void: pass
+ func say(type: int, text: String) -> bool: return true
+
+var _fail := 0
+
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+
+func _init() -> void:
+ _run()
+ if _fail == 0:
+ print("PASS: popup_windows_parity_test (CharacterWindow, SafeboxWindow, InventoryWindow layout + 40250 close behaviors)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var assets := AssetRoot.path()
+ if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")):
+ print(" (skip: missing uiscript assets)")
+ return
+
+ var ui: CanvasLayer = UiManager.new()
+ get_root().add_child(ui)
+ ui._ready()
+
+ var client := FakeClient.new()
+ get_root().add_child(client)
+
+ # =========================================================================
+ # 1. CharacterWindow
+ # =========================================================================
+ var char_ui := CharStatusUI.new()
+ get_root().add_child(char_ui)
+ char_ui.setup(ui, client, assets)
+ char_ui.open("STATUS")
+ _ck(char_ui.is_open(), "CharacterWindow is_open() after open('STATUS')")
+
+ var char_root: Control = char_ui._win.get("root")
+ _ck(char_root != null, "CharacterWindow root node exists")
+ if char_root:
+ _ck(char_root.position.x >= 0.0, "CharacterWindow position.x (%f) >= 0" % char_root.position.x)
+ _ck(char_root.position.y >= 0.0, "CharacterWindow position.y (%f) >= 0" % char_root.position.y)
+
+ # Face slot / Guild slot auto-deduction of dimensions
+ var face_slot: Control = char_ui._node("Face_Slot")
+ if face_slot:
+ _ck(face_slot.size.x > 0 and face_slot.size.y > 0,
+ "Face_Slot has non-zero size: %s" % str(face_slot.size))
+
+ var guild_slot: Control = char_ui._node("Guild_Name_Slot")
+ if guild_slot:
+ _ck(guild_slot.size.x > 0 and guild_slot.size.y > 0,
+ "Guild_Name_Slot has non-zero size: %s" % str(guild_slot.size))
+
+ # Tab button type check: must be Button with toggle_mode, NOT CheckBox
+ var tab1: Control = char_ui._node("Tab_Button_01")
+ if tab1:
+ _ck(tab1 is Button and not (tab1 is CheckBox),
+ "Tab_Button_01 is Button (not CheckBox)")
+ _ck((tab1 as Button).toggle_mode, "Tab_Button_01 has toggle_mode = true")
+
+ # Close button inside TitleBar
+ var close_btn: BaseButton = char_root.find_child("CloseButton", true, false)
+ _ck(close_btn != null, "CharacterWindow has CloseButton in titlebar")
+ if close_btn:
+ close_btn.pressed.emit()
+ _ck(not char_ui.is_open(), "CharacterWindow closes when CloseButton is pressed")
+
+ # =========================================================================
+ # 2. SafeboxWindow
+ # =========================================================================
+ var safe_ui := SafeboxUI.new()
+ get_root().add_child(safe_ui)
+ safe_ui.setup(client, ui, null, assets)
+ safe_ui.open()
+ _ck(safe_ui.is_open(), "SafeboxWindow is_open() after open()")
+
+ var safe_root: Control = safe_ui._root
+ _ck(safe_root != null, "SafeboxWindow _root node exists")
+ if safe_root:
+ _ck(safe_root.position.x >= 0.0, "SafeboxWindow position.x (%f) >= 0 (not negative off-screen)" % safe_root.position.x)
+ _ck(safe_root.position.y >= 0.0, "SafeboxWindow position.y (%f) >= 0 (not negative off-screen)" % safe_root.position.y)
+
+ # Page buttons check: page radio buttons are Buttons with toggle_mode, not CheckBoxes
+ var page1: Control = safe_root.find_child("PAGE1_BUTTON", true, false)
+ if page1:
+ _ck(page1 is Button and not (page1 is CheckBox),
+ "PAGE1_BUTTON is Button (not CheckBox)")
+
+ # CloseButton on TitleBar
+ var safe_close_btn: BaseButton = safe_root.find_child("CloseButton", true, false)
+ _ck(safe_close_btn != null, "SafeboxWindow has CloseButton in titlebar")
+ if safe_close_btn:
+ safe_close_btn.pressed.emit()
+ _ck(not safe_ui.is_open(), "SafeboxWindow closes when CloseButton is pressed")
+
+ # Re-open and test ExitButton ("关闭" button at bottom)
+ safe_ui.open()
+ _ck(safe_ui.is_open(), "SafeboxWindow re-opened")
+ if safe_ui.is_open():
+ var exit_btn: BaseButton = safe_ui._root.find_child("ExitButton", true, false)
+ _ck(exit_btn != null, "SafeboxWindow has ExitButton at bottom")
+ if exit_btn:
+ exit_btn.pressed.emit()
+ _ck(not safe_ui.is_open(), "SafeboxWindow closes when ExitButton is pressed")
+
+ # =========================================================================
+ # 3. InventoryWindow
+ # =========================================================================
+ var inv_ui := InventoryUI.new()
+ get_root().add_child(inv_ui)
+ inv_ui.setup(ui, client, null, assets)
+ inv_ui.open()
+ _ck(inv_ui.is_open(), "InventoryWindow is_open() after open()")
+
+ var inv_root: Control = inv_ui._win.get("root")
+ _ck(inv_root != null, "InventoryWindow root node exists")
+ if inv_root:
+ _ck(inv_root.position.x >= 0.0, "InventoryWindow position.x (%f) >= 0" % inv_root.position.x)
+ _ck(inv_root.position.y >= 0.0, "InventoryWindow position.y (%f) >= 0" % inv_root.position.y)
+
+ var inv_close_btn: BaseButton = inv_root.find_child("CloseButton", true, false)
+ _ck(inv_close_btn != null, "InventoryWindow has CloseButton in TitleBar")
+ if inv_close_btn:
+ inv_close_btn.pressed.emit()
+ _ck(not inv_ui.is_open(), "InventoryWindow closes when CloseButton is pressed")
diff --git a/project/popup_windows_parity_test.gd.uid b/project/popup_windows_parity_test.gd.uid
new file mode 100644
index 00000000..ebca51d6
--- /dev/null
+++ b/project/popup_windows_parity_test.gd.uid
@@ -0,0 +1 @@
+uid://3fdvqc5igvc3
diff --git a/project/portable_service_system.gd b/project/portable_service_system.gd
new file mode 100644
index 00000000..9fcec8be
--- /dev/null
+++ b/project/portable_service_system.gd
@@ -0,0 +1,145 @@
+# portable_service_system.gd —— Metin2 40250 随身便民道具与即时服务系统 1:1
+# 对照 40250 服务端 char_item.cpp, cmd_general.cpp, Items 70005, 70006, 70007
+class_name PortableServiceSystem
+extends RefCounted
+
+signal service_opened(service_type: String)
+signal portable_item_consumed(vnum: int, remaining: int)
+
+const ITEM_PORTABLE_SAFEBOX := 70005 # 随身仓库卷轴
+const ITEM_PORTABLE_BLACKSMITH := 70006 # 随身铁匠手稿
+const ITEM_PORTABLE_GENERAL_STORE := 70007 # 随身杂货铺契约
+
+# 随身杂货铺基础货架商品列表 (大红、大蓝、加速药水、回城卷轴)
+const PORTABLE_STORE_GOODS: Array = [
+ {"vnum": 27003, "name": "红药水(大)", "price": 3200, "count": 200},
+ {"vnum": 27006, "name": "蓝药水(大)", "price": 4800, "count": 200},
+ {"vnum": 27102, "name": "绿药水(大)", "price": 10000, "count": 10},
+ {"vnum": 22000, "name": "回城卷轴", "price": 2000, "count": 1}
+]
+
+var active_service: String = "" # "safebox", "blacksmith", "store", or ""
+
+# 使用随身仓库 (70005)
+func use_portable_safebox(slot_idx: int, inventory: Array) -> Dictionary:
+ var consume_res := _consume_portable_item(slot_idx, inventory, ITEM_PORTABLE_SAFEBOX)
+ if not consume_res["ok"]:
+ return consume_res
+
+ active_service = "safebox"
+ service_opened.emit("safebox")
+
+ return {
+ "ok": true,
+ "service": "safebox",
+ "remaining_scrolls": consume_res["remaining"],
+ "msg": "使用了【随身仓库卷轴】,已建立与城市货郎的远程仓库连接!"
+ }
+
+# 使用随身铁匠 (70006)
+func use_portable_blacksmith(slot_idx: int, inventory: Array) -> Dictionary:
+ var consume_res := _consume_portable_item(slot_idx, inventory, ITEM_PORTABLE_BLACKSMITH)
+ if not consume_res["ok"]:
+ return consume_res
+
+ active_service = "blacksmith"
+ service_opened.emit("blacksmith")
+
+ return {
+ "ok": true,
+ "service": "blacksmith",
+ "remaining_scrolls": consume_res["remaining"],
+ "msg": "展开了【随身铁匠手稿】,便携精炼锻造炉已点燃!随时可强化装备!"
+ }
+
+# 使用随身杂货铺 (70007)
+func use_portable_store(slot_idx: int, inventory: Array) -> Dictionary:
+ var consume_res := _consume_portable_item(slot_idx, inventory, ITEM_PORTABLE_GENERAL_STORE)
+ if not consume_res["ok"]:
+ return consume_res
+
+ active_service = "store"
+ service_opened.emit("store")
+
+ return {
+ "ok": true,
+ "service": "store",
+ "goods": PORTABLE_STORE_GOODS.duplicate(true),
+ "remaining_scrolls": consume_res["remaining"],
+ "msg": "出示了【随身杂货铺契约】,随身货郎已赶来!随时可补给药水或出售垃圾!"
+ }
+
+# 从随身杂货铺购买商品
+func buy_goods_from_portable_store(
+ goods_idx: int,
+ player_data: Dictionary,
+ inventory: Array
+) -> Dictionary:
+ if active_service != "store":
+ return {"ok": false, "reason": "STORE_NOT_OPEN", "msg": "随身杂货铺尚未开启!"}
+
+ if goods_idx < 0 or goods_idx >= PORTABLE_STORE_GOODS.size():
+ return {"ok": false, "reason": "INVALID_GOODS", "msg": "商品不存在!"}
+
+ var goods: Dictionary = PORTABLE_STORE_GOODS[goods_idx]
+ var price: int = int(goods["price"])
+ var current_gold: int = int(player_data.get("gold", 0))
+
+ if current_gold < price:
+ return {
+ "ok": false,
+ "reason": "INSUFFICIENT_YANG",
+ "price": price,
+ "current_gold": current_gold,
+ "msg": "金币不足,无法购买!"
+ }
+
+ # 寻找背包空位
+ var empty_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ empty_slot = i
+ break
+
+ if empty_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳所购商品!"}
+
+ # 扣费并放入物品
+ player_data["gold"] = current_gold - price
+ inventory[empty_slot] = {
+ "vnum": goods["vnum"],
+ "name": goods["name"],
+ "count": goods.get("count", 1)
+ }
+
+ return {
+ "ok": true,
+ "item": inventory[empty_slot],
+ "slot": empty_slot,
+ "remaining_gold": player_data["gold"],
+ "msg": "成功购买【%s】!" % goods["name"]
+ }
+
+# 关闭随身服务窗口
+func close_service() -> void:
+ active_service = ""
+
+# 内部通用道具消耗处理
+func _consume_portable_item(slot_idx: int, inventory: Array, expected_vnum: int) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item = inventory[slot_idx]
+ if item == null or int(item.get("vnum", 0)) != expected_vnum:
+ return {"ok": false, "reason": "ITEM_MISMATCH", "msg": "该格子上不是对应的随身卷轴!"}
+
+ var cnt: int = int(item.get("count", 1))
+ var remaining: int = cnt - 1
+ if remaining <= 0:
+ inventory[slot_idx] = null
+ else:
+ item["count"] = remaining
+
+ portable_item_consumed.emit(expected_vnum, remaining)
+
+ return {"ok": true, "remaining": remaining}
diff --git a/project/portable_service_system.gd.uid b/project/portable_service_system.gd.uid
new file mode 100644
index 00000000..5b8ad822
--- /dev/null
+++ b/project/portable_service_system.gd.uid
@@ -0,0 +1 @@
+uid://cwimad6y024ls
diff --git a/project/print_server_address.gd.uid b/project/print_server_address.gd.uid
new file mode 100644
index 00000000..cfe2902d
--- /dev/null
+++ b/project/print_server_address.gd.uid
@@ -0,0 +1 @@
+uid://3th670yufpf1
diff --git a/project/private_shop_system.gd b/project/private_shop_system.gd
new file mode 100644
index 00000000..ee15eda2
--- /dev/null
+++ b/project/private_shop_system.gd
@@ -0,0 +1,88 @@
+# private_shop_system.gd —— 个人私有商店摆摊系统(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 server/game/src/char_item.cpp (ITEM_BUNDLE 50200, OpenMyShop)
+# - 40250 root/uiprivateshopbuilder.py (PrivateShopBuilder)
+# - 40250 uiscript/privateshopbuilder.py (40 格摊位格)
+class_name PrivateShopSystem
+extends RefCounted
+
+const VNUM_ITEM_BUNDLE := 50200 # 束发包袱皮
+const VNUM_SHOP_HOST_STALL := 30000 # 摆摊木箱/桌子外观模型 Vnum
+const PRIVATE_SHOP_SLOTS_MAX := 39 # 官方上限 39 件商品
+const SIGN_LENGTH_MAX := 25 # 摊位招牌字数上限
+
+## 检查物品是否为包袱皮
+static func is_bundle(vnum: int) -> bool:
+ return vnum == VNUM_ITEM_BUNDLE
+
+## 校验是否能开店 (40250 官方前置条件:非战斗、非骑乘、非死亡)
+static func can_open_private_shop(is_fighting: bool, is_mounted: bool, is_dead: bool) -> Dictionary:
+ if is_dead:
+ return {"ok": false, "code": "CANNOT_DEAD", "msg": "死亡状态下无法摆摊!"}
+ if is_mounted:
+ return {"ok": false, "code": "CANNOT_MOUNTED", "msg": "骑乘状态下无法摆摊,请先下马!"}
+ if is_fighting:
+ return {"ok": false, "code": "CANNOT_FIGHTING", "msg": "战斗中无法摆摊!"}
+ return {"ok": true}
+
+## 校验摊位招牌名称
+static func validate_sign(sign_text: String) -> Dictionary:
+ var trimmed := sign_text.strip_edges()
+ if trimmed.is_empty():
+ return {"ok": false, "code": "SIGN_EMPTY", "msg": "请输入有效的摊位名称!"}
+ if trimmed.length() > SIGN_LENGTH_MAX:
+ return {"ok": false, "code": "SIGN_TOO_LONG", "msg": "摊位名称不能超过 25 个字符!"}
+ return {"ok": true, "sign": trimmed}
+
+## 验证并将物品上架到摊位
+static func add_item_to_stock(stock_dict: Dictionary, slot_idx: int, inv_item: Dictionary, price: int) -> Dictionary:
+ if slot_idx < 0 or slot_idx >= PRIVATE_SHOP_SLOTS_MAX:
+ return {"ok": false, "code": "INVALID_SLOT", "msg": "无效的摊位格子!"}
+ if price <= 0:
+ return {"ok": false, "code": "INVALID_PRICE", "msg": "出售价格必须大于 0 金币!"}
+ if stock_dict.size() >= PRIVATE_SHOP_SLOTS_MAX and not stock_dict.has(slot_idx):
+ return {"ok": false, "code": "SHOP_FULL", "msg": "摊位已达到最大商品容量 (39 件)!"}
+
+ var cell := int(inv_item.get("cell", -1))
+ var vnum := int(inv_item.get("vnum", 0))
+ var count := int(inv_item.get("count", 1))
+
+ stock_dict[slot_idx] = {
+ "slot": slot_idx,
+ "cell": cell,
+ "vnum": vnum,
+ "count": count,
+ "price": price,
+ }
+ return {
+ "ok": true,
+ "code": "SUCCESS",
+ "slot": slot_idx,
+ "price": price,
+ "msg": "商品成功上架到摊位第 %d 格,单价 %d 金币。" % [slot_idx + 1, price]
+ }
+
+## 撤下摊位商品
+static func remove_item_from_stock(stock_dict: Dictionary, slot_idx: int) -> bool:
+ if stock_dict.has(slot_idx):
+ stock_dict.erase(slot_idx)
+ return true
+ return false
+
+## 开市创建摆摊实体
+static func build_private_shop(sign_text: String, stock_dict: Dictionary, stall_pos: Vector3) -> Dictionary:
+ var v_sign := validate_sign(sign_text)
+ if not bool(v_sign.get("ok", false)):
+ return v_sign
+ if stock_dict.is_empty():
+ return {"ok": false, "code": "NO_ITEMS", "msg": "摊位上没有任何商品,无法开市!"}
+
+ return {
+ "ok": true,
+ "code": "SHOP_OPENED",
+ "stall_vnum": VNUM_SHOP_HOST_STALL,
+ "sign": v_sign.get("sign", ""),
+ "items_count": stock_dict.size(),
+ "position": stall_pos,
+ "msg": "【%s】正式开市!摊位已在当前位置建立。" % v_sign.get("sign", "")
+ }
diff --git a/project/private_shop_system.gd.uid b/project/private_shop_system.gd.uid
new file mode 100644
index 00000000..1bd88cf8
--- /dev/null
+++ b/project/private_shop_system.gd.uid
@@ -0,0 +1 @@
+uid://cgo6r1fqm0h40
diff --git a/project/privilege_empire_guild_system.gd b/project/privilege_empire_guild_system.gd
new file mode 100644
index 00000000..2c016b4d
--- /dev/null
+++ b/project/privilege_empire_guild_system.gd
@@ -0,0 +1,179 @@
+# privilege_empire_guild_system.gd —— Metin2 40250 官方帝国与公会恩泽特权倍率系统 1:1
+# 100% 对照 40250 服务端 priv_manager.cpp, priv_manager.h, constants.cpp (c_apszPrivNames)
+class_name PrivilegeEmpireGuildSystem
+extends RefCounted
+
+signal empire_privilege_granted(empire: int, priv_type: int, value: int, duration: float)
+signal guild_privilege_granted(guild_id: int, priv_type: int, value: int, duration: float)
+signal character_privilege_granted(player_id: int, priv_type: int, value: int)
+signal privilege_expired(level_type: String, target_id: int, priv_type: int)
+
+# 40250 官方特权类别常数
+const PRIV_ITEM_DROP := 1 # 物品掉落率加成 (아이템 드롭 확률)
+const PRIV_GOLD_DROP := 2 # 金币掉落率加成 (돈 드롭 확률)
+const PRIV_GOLD10_DROP := 3 # 暴发大金币堆掉率加成 (돈 폭풍 드롭 확률)
+const PRIV_EXP := 4 # 杀怪经验值倍率加成 (경험치 배율)
+
+const PRIV_NAMES: Dictionary = {
+ PRIV_ITEM_DROP: "物品爆率加成",
+ PRIV_GOLD_DROP: "金币掉落加成",
+ PRIV_GOLD10_DROP: "黄金风暴大金币爆率",
+ PRIV_EXP: "经验值倍率加成"
+}
+
+# 帝国定义 (0 为全服所有帝国)
+const EMPIRE_ALL := 0
+const EMPIRE_SHINSOO := 1
+const EMPIRE_CHUNJO := 2
+const EMPIRE_JINNO := 3
+
+# 数据结构:
+# _empire_privs[type][empire] -> {"value": int, "time_remaining": float}
+var _empire_privs: Dictionary = {}
+# _guild_privs[type][guild_id] -> {"value": int, "time_remaining": float}
+var _guild_privs: Dictionary = {}
+# _char_privs[type][player_id] -> int
+var _char_privs: Dictionary = {}
+
+func _init() -> void:
+ for t in [PRIV_ITEM_DROP, PRIV_GOLD_DROP, PRIV_GOLD10_DROP, PRIV_EXP]:
+ _empire_privs[t] = {}
+ _guild_privs[t] = {}
+ _char_privs[t] = {}
+ for emp in [0, 1, 2, 3]:
+ _empire_privs[t][emp] = {"value": 0, "time_remaining": 0.0}
+
+# ==========================================
+# 1. 设置特权 (GiveEmpirePriv, GiveGuildPriv, GiveCharacterPriv)
+# ==========================================
+
+# 设置帝国全域特权 (empire: 0 为全服, 1~3 为指定帝国)
+func set_empire_privilege(empire: int, priv_type: int, value_percent: int, duration_sec: float) -> void:
+ if not _is_valid_priv_type(priv_type) or empire < 0 or empire > 3:
+ return
+
+ var val = clamp(value_percent, 0, 1000)
+ var dur = max(0.0, duration_sec)
+ _empire_privs[priv_type][empire] = {"value": val, "time_remaining": dur}
+ empire_privilege_granted.emit(empire, priv_type, val, dur)
+
+# 设置公会专属特权
+func set_guild_privilege(guild_id: int, priv_type: int, value_percent: int, duration_sec: float) -> void:
+ if not _is_valid_priv_type(priv_type) or guild_id <= 0:
+ return
+
+ var val = clamp(value_percent, 0, 1000)
+ var dur = max(0.0, duration_sec)
+ _guild_privs[priv_type][guild_id] = {"value": val, "time_remaining": dur}
+ guild_privilege_granted.emit(guild_id, priv_type, val, dur)
+
+# 设置个人永久/专属特权
+func set_character_privilege(player_id: int, priv_type: int, value_percent: int) -> void:
+ if not _is_valid_priv_type(priv_type) or player_id <= 0:
+ return
+ _char_privs[priv_type][player_id] = value_percent
+ character_privilege_granted.emit(player_id, priv_type, value_percent)
+
+# ==========================================
+# 2. 移除特权 (RemoveEmpirePriv, RemoveGuildPriv, RemoveCharacterPriv)
+# ==========================================
+func remove_empire_privilege(empire: int, priv_type: int) -> void:
+ if _empire_privs.has(priv_type) and _empire_privs[priv_type].has(empire):
+ _empire_privs[priv_type][empire] = {"value": 0, "time_remaining": 0.0}
+
+func remove_guild_privilege(guild_id: int, priv_type: int) -> void:
+ if _guild_privs.has(priv_type):
+ _guild_privs[priv_type].erase(guild_id)
+
+func remove_character_privilege(player_id: int, priv_type: int) -> void:
+ if _char_privs.has(priv_type):
+ _char_privs[priv_type].erase(player_id)
+
+# ==========================================
+# 3. 特权倍率综合计算 (priv_manager.cpp: GetPriv)
+# ==========================================
+func get_effective_privilege(
+ player_id: int,
+ empire: int,
+ guild_id: int,
+ priv_type: int,
+ has_exp_ring: bool = false,
+ has_thief_glove: bool = false
+) -> int:
+ if not _is_valid_priv_type(priv_type):
+ return 0
+
+ # 40250 官方逻辑:
+ # 个人特权
+ var val_ch: int = _char_privs[priv_type].get(player_id, 0)
+
+ # 全服全境特权 (Empire 0)
+ var val_emp_all = 0
+ if _empire_privs[priv_type].has(0) and _empire_privs[priv_type][0]["time_remaining"] > 0.0:
+ val_emp_all = int(_empire_privs[priv_type][0]["value"])
+
+ # 专属帝国特权
+ var val_emp = 0
+ if _empire_privs[priv_type].has(empire) and _empire_privs[priv_type][empire]["time_remaining"] > 0.0:
+ val_emp = int(_empire_privs[priv_type][empire]["value"])
+
+ # 公会特权
+ var val_guild = 0
+ if guild_id > 0 and _guild_privs[priv_type].has(guild_id):
+ if _guild_privs[priv_type][guild_id]["time_remaining"] > 0.0:
+ val_guild = int(_guild_privs[priv_type][guild_id]["value"])
+
+ # 40250 官方取四者最大加成值 (MAX)
+ var max_bonus = max(val_ch, max(val_emp_all, max(val_emp, val_guild)))
+
+ # 装备道具额外叠加: 经验指环 (70024: 经验 +50%), 小偷手套 (70003: 爆率 +100%)
+ if priv_type == PRIV_EXP and has_exp_ring:
+ max_bonus += 50
+ elif priv_type == PRIV_ITEM_DROP and has_thief_glove:
+ max_bonus += 100
+
+ return max_bonus
+
+# ==========================================
+# 4. 心跳更新与倒计时自然失效
+# ==========================================
+func update(delta: float) -> void:
+ for t in [PRIV_ITEM_DROP, PRIV_GOLD_DROP, PRIV_GOLD10_DROP, PRIV_EXP]:
+ # 帝国特权
+ for emp in [0, 1, 2, 3]:
+ var ep = _empire_privs[t][emp]
+ if ep["time_remaining"] > 0.0:
+ ep["time_remaining"] -= delta
+ if ep["time_remaining"] <= 0.0:
+ ep["time_remaining"] = 0.0
+ ep["value"] = 0
+ privilege_expired.emit("empire", emp, t)
+
+ # 公会特权
+ var to_erase_guilds: Array[int] = []
+ for gid in _guild_privs[t].keys():
+ var gp = _guild_privs[t][gid]
+ if gp["time_remaining"] > 0.0:
+ gp["time_remaining"] -= delta
+ if gp["time_remaining"] <= 0.0:
+ to_erase_guilds.append(gid)
+
+ for gid in to_erase_guilds:
+ _guild_privs[t].erase(gid)
+ privilege_expired.emit("guild", gid, t)
+
+func _is_valid_priv_type(t: int) -> bool:
+ return t >= PRIV_ITEM_DROP and t <= PRIV_EXP
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "empire_privs": _empire_privs.duplicate(true),
+ "guild_privs": _guild_privs.duplicate(true),
+ "char_privs": _char_privs.duplicate(true)
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _empire_privs = data.get("empire_privs", {}).duplicate(true)
+ _guild_privs = data.get("guild_privs", {}).duplicate(true)
+ _char_privs = data.get("char_privs", {}).duplicate(true)
diff --git a/project/privilege_empire_guild_system.gd.uid b/project/privilege_empire_guild_system.gd.uid
new file mode 100644
index 00000000..d46b4144
--- /dev/null
+++ b/project/privilege_empire_guild_system.gd.uid
@@ -0,0 +1 @@
+uid://r7ryp7cppxkg
diff --git a/project/quest_curtain_test.gd b/project/quest_curtain_test.gd
new file mode 100644
index 00000000..1727c657
--- /dev/null
+++ b/project/quest_curtain_test.gd
@@ -0,0 +1,37 @@
+extends SceneTree
+
+const QuestCurtain = preload("res://ui/quest_curtain.gd")
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+ else:
+ print("OK: " + m)
+
+func _init() -> void:
+ _run()
+ if _fail == 0:
+ print("PASS: quest_curtain_test (16:9 letterbox calculation and open/close states)")
+ quit(0)
+ else:
+ printerr("%d checks failed in quest_curtain_test" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var curtain := QuestCurtain.new()
+ root.add_child(curtain)
+
+ _ck(not curtain.is_open(), "curtain initially closed")
+ _ck(curtain.top_bar != null, "top_bar instantiated")
+ _ck(curtain.bottom_bar != null, "bottom_bar instantiated")
+
+ curtain.open(0.01)
+ _ck(curtain.is_open(), "curtain is_open after open()")
+ _ck(curtain.top_bar.size.y >= QuestCurtain.MIN_BAR_HEIGHT, "bar height respects MIN_BAR_HEIGHT")
+
+ curtain.close(0.01)
+ _ck(not curtain.is_open(), "curtain is not open after close()")
+
+ curtain.queue_free()
diff --git a/project/quest_curtain_test.gd.uid b/project/quest_curtain_test.gd.uid
new file mode 100644
index 00000000..eb7edf10
--- /dev/null
+++ b/project/quest_curtain_test.gd.uid
@@ -0,0 +1 @@
+uid://rs7v1l5nom2f
diff --git a/project/refine_effect_system.gd b/project/refine_effect_system.gd
new file mode 100644
index 00000000..ea05074d
--- /dev/null
+++ b/project/refine_effect_system.gd
@@ -0,0 +1,115 @@
+# refine_effect_system.gd —— +7/+8/+9 装备强化专属发光特效(40250 官方 1:1 对齐)
+# 对齐源码:
+# - 40250 ClientVS22/source/UserInterface/InstanceBase.cpp:2698-2740 (AttachEffect, EFFECT_REFINED)
+# - 40250 ClientVS22/source/UserInterface/InstanceBase.h:268-297 (EFFECT_REFINED_NUM)
+# - 40250 root/playersettingmodule.py:391-416 (RegisterEffect, sword_7..9.mse, armor_7..9.mse)
+class_name RefineEffectSystem
+extends RefCounted
+
+# 40250 官方发光分级常量
+const REFINE_SHINE_START_LEVEL := 7 # 从 +7 开始出现发光特效
+
+const SHINE_TIER_7 := 7
+const SHINE_TIER_8 := 8
+const SHINE_TIER_9 := 9
+
+# 官方发光特效配置文件映射表 (40250 playersettingmodule.py:391-416)
+const WEAPON_SHINE_EFFECTS := {
+ SHINE_TIER_7: "d:/ymir work/pc/common/effect/sword/sword_7.mse",
+ SHINE_TIER_8: "d:/ymir work/pc/common/effect/sword/sword_8.mse",
+ SHINE_TIER_9: "d:/ymir work/pc/common/effect/sword/sword_9.mse",
+}
+
+const ARMOR_SHINE_EFFECTS := {
+ SHINE_TIER_7: "d:/ymir work/pc/common/effect/armor/armor_7.mse",
+ SHINE_TIER_8: "d:/ymir work/pc/common/effect/armor/armor_8.mse",
+ SHINE_TIER_9: "d:/ymir work/pc/common/effect/armor/armor_9.mse",
+}
+
+# 66~70 级高级黑钢战甲特效 (armor-4-2-1.mse, armor-4-2-2.mse)
+const SPECIAL_ARMOR_EFFECTS := {
+ SHINE_TIER_7: "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse",
+ SHINE_TIER_8: "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse",
+ SHINE_TIER_9: "d:/ymir work/pc/common/effect/armor/armor-4-2.mse",
+}
+
+# 骨骼挂载点定义 (InstanceBase.cpp:2698)
+const ATTACH_BONE_WEAPON_RIGHT := "PART_WEAPON" # 右手武器
+const ATTACH_BONE_WEAPON_LEFT := "PART_WEAPON_LEFT" # 左手武器(如双持匕首)
+const ATTACH_BONE_ARMOR := "Bip01" # 盔甲躯干
+
+# 发光色调配置(用于 Godot 动态粒子与材质 Emission)
+const SHINE_COLORS := {
+ SHINE_TIER_7: Color(0.9, 0.95, 1.0, 0.7), # +7: 浅白清冷光晕
+ SHINE_TIER_8: Color(0.3, 0.7, 1.0, 0.85), # +8: 亮蓝闪烁流光
+ SHINE_TIER_9: Color(1.0, 0.85, 0.2, 1.0), # +9: 强烈金黄/电光辉光
+}
+
+## 从物品 Vnum 解析强化等级 (Metin2 官方惯例:vnum % 10 为强化等级)
+static func get_refine_level_from_vnum(vnum: int) -> int:
+ if vnum <= 0:
+ return 0
+ return vnum % 10
+
+## 检查该物品是否具备 +7/+8/+9 专属发光
+static func is_shining_item(vnum: int, item_type: int) -> bool:
+ if item_type not in [1, 2]: # 1: WEAPON, 2: ARMOR
+ return false
+ var lvl := get_refine_level_from_vnum(vnum)
+ return lvl >= REFINE_SHINE_START_LEVEL
+
+## 获取发光特效数据配置
+static func get_shine_spec(vnum: int, item_type: int, is_left_hand := false) -> Dictionary:
+ var lvl := get_refine_level_from_vnum(vnum)
+ if lvl < REFINE_SHINE_START_LEVEL:
+ return {"shining": false, "level": lvl}
+
+ var attach_bone := ""
+ var effect_path := ""
+ var color: Color = SHINE_COLORS.get(lvl, Color.WHITE)
+
+ if item_type == 1: # WEAPON
+ attach_bone = ATTACH_BONE_WEAPON_LEFT if is_left_hand else ATTACH_BONE_WEAPON_RIGHT
+ effect_path = WEAPON_SHINE_EFFECTS.get(lvl, "")
+ elif item_type == 2: # ARMOR
+ attach_bone = ATTACH_BONE_ARMOR
+ # 判断是否为 66 级以上黑钢特种战甲 (如 11290..11299)
+ if vnum >= 11290 and vnum <= 11899:
+ effect_path = SPECIAL_ARMOR_EFFECTS.get(lvl, "")
+ else:
+ effect_path = ARMOR_SHINE_EFFECTS.get(lvl, "")
+
+ return {
+ "shining": true,
+ "level": lvl,
+ "attach_bone": attach_bone,
+ "effect_path": effect_path,
+ "color": color,
+ "particle_rate": 15 if lvl == 7 else (30 if lvl == 8 else 60),
+ "light_energy": 0.8 if lvl == 7 else (1.5 if lvl == 8 else 3.0),
+ }
+
+## 创建发光粒子节点(可在挂载模型时直接附加到 Skeleton3D)
+static func create_shine_particles(spec: Dictionary) -> GPUParticles3D:
+ if not bool(spec.get("shining", false)):
+ return null
+ var particles := GPUParticles3D.new()
+ particles.name = "RefineShineParticles_L%d" % int(spec.get("level", 7))
+ particles.amount = int(spec.get("particle_rate", 20))
+ particles.lifetime = 0.6
+ particles.explosiveness = 0.05
+ particles.randomness = 0.2
+
+ # 创建材质
+ var mat := StandardMaterial3D.new()
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat.albedo_color = spec.get("color", Color.WHITE)
+ mat.emission_enabled = true
+ mat.emission = spec.get("color", Color.WHITE)
+ mat.emission_energy_multiplier = float(spec.get("light_energy", 1.0))
+
+ var quad := QuadMesh.new()
+ quad.size = Vector2(0.12, 0.12)
+ quad.material = mat
+ particles.draw_pass_1 = quad
+ return particles
diff --git a/project/refine_effect_system.gd.uid b/project/refine_effect_system.gd.uid
new file mode 100644
index 00000000..1523ad6b
--- /dev/null
+++ b/project/refine_effect_system.gd.uid
@@ -0,0 +1 @@
+uid://britmtog0aw6l
diff --git a/project/remote_player_test.gd b/project/remote_player_test.gd
index 226de4cc..4945ced1 100644
--- a/project/remote_player_test.gd
+++ b/project/remote_player_test.gd
@@ -5,6 +5,7 @@ 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")
+const EffectRegistry = preload("res://fx/effect_registry.gd")
class FieldProbe extends Node3D:
var calls: Array = []
@@ -41,17 +42,47 @@ func _run() -> void:
if not il.load_file(assets.path_join("locale/locale/common/item_list.txt")):
print(" (skip: item_list unavailable)")
return
+ var proto: Node
+ if ClassDB.class_exists("Metin2Proto"):
+ proto = ClassDB.instantiate("Metin2Proto")
+ get_root().add_child(proto)
+ var proto_path := assets.path_join("locale/locale/en/item_proto")
+ if not FileAccess.file_exists(proto_path) or not proto.call("load_item_proto", proto_path):
+ proto = null
+ var effects := EffectRegistry.new()
+ effects.setup(assets)
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")
+ var parts: Array = [0, 3159, 0, 5] # ARMOR / TWO-HAND +9 / HEAD / HAIR
+ _ck(pv.build_remote(assets, 0, parts, il, proto, Callable(), effects),
+ "off-tree remote warrior PlayerView builds")
+ get_root().add_child(pv) # NetWorld adds the fully assembled view this way.
if pv.remote_equip == null:
return
_ck(pv.remote_equip._remote_parts == parts, "remote EquipModel receives awPart[4]")
await process_frame
+ await process_frame
+ if proto:
+ _ck(pv.get_motion_mode() == 3, "remote weapon 3159 -> TWOHAND_SWORD mode")
+ _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/wait.msa"),
+ "remote heavy-weapon wait uses two-hand grip pose")
+ _ck(pv.remote_equip._weapon_refine_fx.size() == 1,
+ "remote +9 heavy weapon creates one persistent refine effect")
+ if pv.remote_equip._weapon_refine_fx.size() == 1:
+ var refine: Node = pv.remote_equip._weapon_refine_fx[0]
+ _ck(refine.get_parent() == pv,
+ "remote +9 effect is attached through the character skeleton")
+ var emitters := refine.find_children("*", "GPUParticles3D", true, false)
+ _ck(emitters.size() > 0,
+ "remote +9 sword_9 effect contains rendered GPU particles")
+ var emitting := false
+ for emitter in emitters:
+ emitting = emitting or bool(emitter.get("emitting"))
+ _ck(emitting, "remote +9 sword_9 GPU particles are actively emitting")
pv.set_weapon(0)
await process_frame
_ck(int(pv.remote_equip._remote_parts[1]) == 0, "set_weapon updates awPart[WEAPON]")
+ if proto:
+ _ck(pv.get_motion_mode() == 1, "remote unequip -> GENERAL mode")
pv.set_head(19)
await process_frame
_ck(int(pv.remote_equip._remote_parts[2]) == 19, "set_head updates awPart[HEAD]")
diff --git a/project/safebox_storage_keeper_system.gd b/project/safebox_storage_keeper_system.gd
new file mode 100644
index 00000000..c1f3ec30
--- /dev/null
+++ b/project/safebox_storage_keeper_system.gd
@@ -0,0 +1,243 @@
+# safebox_storage_keeper_system.gd —— 40250 官方仓库保管员金条熔铸与密码保险箱系统 1:1
+# 严格对照:
+# metin2/src/server/game/src/safebox.h, safebox.cpp
+# metin2/src/server/game/src/char.cpp:5569-5605 (ReqSafeboxLoad)
+# metin2/src/server/game/src/input_db.cpp:1225-1250 (SafeboxWrongPassword / SafeboxChangePasswordAnswer)
+# metin2/src/server/db/src/ClientManager.cpp:515-535 (RESULT_SAFEBOX_LOAD: default "000000")
+# metin2/src/server/share/conf/item_proto.txt (80003~80007 金银条)
+extends RefCounted
+
+const SAFEBOX_MAX_NUM := 45 # 官方单页保险箱格数 (45 格)
+const SAFEBOX_PASSWORD_MAX_LEN := 6 # 密码最长 6 位
+const DEFAULT_PASSWORD := "000000" # 官方默认初始密码
+const SAFEBOX_OPEN_DISTANCE := 1000 # 距离 NPC 9005 最大距离 1000
+const SAFEBOX_COOLDOWN_SEC := 10 # 开箱冷却 10 秒 (PASSES_PER_SEC(10))
+
+# 金银条规格表 (Vnum -> { "name": String, "face_value": int, "smelt_cost": int })
+# 官方 1% 熔铸税 (cost = face_value * 1.01)
+const GOLD_BAR_SPECS := {
+ 80003: { "name": "银条 (5万)", "face_value": 50000, "smelt_cost": 50500 },
+ 80004: { "name": "银条 (10万)", "face_value": 100000, "smelt_cost": 101000 },
+ 80005: { "name": "金条 (50万)", "face_value": 500000, "smelt_cost": 505000 },
+ 80006: { "name": "金条 (100万)", "face_value": 1000000, "smelt_cost": 1010000 },
+ 80007: { "name": "金条 (200万)", "face_value": 2000000, "smelt_cost": 2020000 },
+}
+
+var password: String = DEFAULT_PASSWORD
+var is_open: bool = false
+var last_open_time: int = -100
+var current_time: int = 1000
+
+# 保险箱 45 格网格 (slot 0..44 -> Dictionary or null)
+var safebox_grid: Array = []
+
+func _init() -> void:
+ password = DEFAULT_PASSWORD
+ is_open = false
+ last_open_time = -100
+ current_time = 1000
+ safebox_grid.clear()
+ for i in range(SAFEBOX_MAX_NUM):
+ safebox_grid.append(null)
+
+# 1. 密码校验并打开保险箱 (1:1 CHARACTER::ReqSafeboxLoad)
+func open_safebox(entered_pw: String, npc_distance: float = 200.0) -> Dictionary:
+ var res := {
+ "success": false,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ # 距离检查
+ if npc_distance > SAFEBOX_OPEN_DISTANCE:
+ res["error_code"] = "TOO_FAR"
+ res["message"] = "<仓库> 距离太远,无法打开仓库。"
+ return res
+
+ # 10秒防刷频冷却检查
+ if (current_time - last_open_time) < SAFEBOX_COOLDOWN_SEC:
+ res["error_code"] = "COOLDOWN_ACTIVE"
+ res["message"] = "<仓库> 仓库在 10 秒内只能打开一次。"
+ return res
+
+ # 密码长度检查
+ if entered_pw.is_empty() or entered_pw.length() > SAFEBOX_PASSWORD_MAX_LEN:
+ res["error_code"] = "INVALID_PASSWORD_LEN"
+ res["message"] = "<仓库> 密码格式错误,必须为 6 位字符以内。"
+ return res
+
+ # 密码匹配检查 (ClientManager.cpp:525)
+ if entered_pw != password:
+ res["error_code"] = "WRONG_PASSWORD"
+ res["message"] = "<仓库> 仓库密码错误!"
+ return res
+
+ # 打开成功
+ is_open = true
+ last_open_time = current_time
+ res["success"] = true
+ res["message"] = "<仓库> 仓库开启成功。"
+ return res
+
+# 2. 修改仓库密码 (1:1 SafeboxChangePassword)
+func change_password(old_pw: String, new_pw: String) -> Dictionary:
+ var res := {
+ "success": false,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ if not is_open:
+ res["error_code"] = "SAFEBOX_NOT_OPEN"
+ res["message"] = "<仓库> 必须先开启仓库方可修改密码。"
+ return res
+
+ if old_pw != password:
+ res["error_code"] = "OLD_PASSWORD_MISMATCH"
+ res["message"] = "<仓库> 旧密码不匹配,修改失败。"
+ return res
+
+ if new_pw.is_empty() or new_pw.length() > SAFEBOX_PASSWORD_MAX_LEN:
+ res["error_code"] = "INVALID_NEW_PASSWORD"
+ res["message"] = "<仓库> 新密码长度不合法。"
+ return res
+
+ password = new_pw
+ res["success"] = true
+ res["message"] = "<仓库> 仓库密码已成功更改。"
+ return res
+
+# 3. 关闭仓库
+func close_safebox() -> void:
+ is_open = false
+
+# 4. 存入物品 (Inventory -> Safebox)
+func deposit_item(safebox_slot: int, item_data: Dictionary) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+ if not is_open:
+ res["error_code"] = "NOT_OPEN"
+ return res
+
+ if safebox_slot < 0 or safebox_slot >= SAFEBOX_MAX_NUM:
+ res["error_code"] = "INVALID_SLOT"
+ return res
+
+ if safebox_grid[safebox_slot] != null:
+ # 尝试堆叠
+ var existing: Dictionary = safebox_grid[safebox_slot]
+ if existing.get("vnum") == item_data.get("vnum") and existing.get("stackable", false):
+ existing["count"] = existing.get("count", 1) + item_data.get("count", 1)
+ res["success"] = true
+ res["message"] = "物品成功堆叠存入仓库。"
+ return res
+ else:
+ res["error_code"] = "SLOT_OCCUPIED"
+ res["message"] = "目标仓库格位已被占用。"
+ return res
+
+ safebox_grid[safebox_slot] = item_data.duplicate(true)
+ res["success"] = true
+ res["message"] = "物品成功存入仓库。"
+ return res
+
+# 5. 取出物品 (Safebox -> Inventory)
+func withdraw_item(safebox_slot: int) -> Dictionary:
+ var res := { "success": false, "item": null, "error_code": "OK" }
+ if not is_open:
+ res["error_code"] = "NOT_OPEN"
+ return res
+
+ if safebox_slot < 0 or safebox_slot >= SAFEBOX_MAX_NUM:
+ res["error_code"] = "INVALID_SLOT"
+ return res
+
+ if safebox_grid[safebox_slot] == null:
+ res["error_code"] = "EMPTY_SLOT"
+ return res
+
+ var it: Dictionary = safebox_grid[safebox_slot]
+ safebox_grid[safebox_slot] = null
+ res["success"] = true
+ res["item"] = it
+ return res
+
+# 6. 保险箱内格位互换/移动
+func move_safebox_item(from_slot: int, to_slot: int) -> bool:
+ if not is_open:
+ return false
+ if from_slot < 0 or from_slot >= SAFEBOX_MAX_NUM or to_slot < 0 or to_slot >= SAFEBOX_MAX_NUM:
+ return false
+ if safebox_grid[from_slot] == null:
+ return false
+
+ var item_a = safebox_grid[from_slot]
+ var item_b = safebox_grid[to_slot]
+
+ if item_b != null and item_b.get("vnum") == item_a.get("vnum") and item_a.get("stackable", false):
+ item_b["count"] = item_b.get("count", 1) + item_a.get("count", 1)
+ safebox_grid[from_slot] = null
+ else:
+ safebox_grid[to_slot] = item_a
+ safebox_grid[from_slot] = item_b
+ return true
+
+# 7. 金条/银条熔铸 (Player Gold -> Gold Bar Item)
+# 仓库保管员按 1% 手续费收取金币并铸造成可储存/交易金条
+func smelt_gold_bar(player_gold: int, bar_vnum: int) -> Dictionary:
+ var res := {
+ "success": false,
+ "new_gold": player_gold,
+ "bar_item": null,
+ "cost": 0,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ if not GOLD_BAR_SPECS.has(bar_vnum):
+ res["error_code"] = "INVALID_BAR_VNUM"
+ res["message"] = "未知的金银条规格。"
+ return res
+
+ var spec: Dictionary = GOLD_BAR_SPECS[bar_vnum]
+ var cost: int = spec["smelt_cost"]
+ res["cost"] = cost
+
+ if player_gold < cost:
+ res["error_code"] = "INSUFFICIENT_GOLD"
+ res["message"] = "金币不足,铸造 %s 需支付 %d 金币 (含1%%熔铸税)。" % [spec["name"], cost]
+ return res
+
+ res["new_gold"] = player_gold - cost
+ res["bar_item"] = {
+ "vnum": bar_vnum,
+ "name": spec["name"],
+ "count": 1,
+ "face_value": spec["face_value"]
+ }
+ res["success"] = true
+ res["message"] = "成功熔铸 %s!扣除 %d 金币。" % [spec["name"], cost]
+ return res
+
+# 8. 金条/银条承兑支取现金 (Gold Bar Item -> Player Gold)
+# 承兑取回票面全额金币 (1:1 face_value)
+func encash_gold_bar(player_gold: int, bar_vnum: int) -> Dictionary:
+ var res := {
+ "success": false,
+ "new_gold": player_gold,
+ "cash_value": 0,
+ "error_code": "OK",
+ "message": ""
+ }
+
+ if not GOLD_BAR_SPECS.has(bar_vnum):
+ res["error_code"] = "INVALID_BAR_VNUM"
+ res["message"] = "非官方认证金银条,无法承兑。"
+ return res
+
+ var spec: Dictionary = GOLD_BAR_SPECS[bar_vnum]
+ var face_val: int = spec["face_value"]
+ res["cash_value"] = face_val
+ res["new_gold"] = player_gold + face_val
+ res["success"] = true
+ res["message"] = "成功承兑 %s,兑现出金币 %d!" % [spec["name"], face_val]
+ return res
diff --git a/project/safebox_storage_keeper_system.gd.uid b/project/safebox_storage_keeper_system.gd.uid
new file mode 100644
index 00000000..15f437cb
--- /dev/null
+++ b/project/safebox_storage_keeper_system.gd.uid
@@ -0,0 +1 @@
+uid://cjyglvb4q2evy
diff --git a/project/save_snapshot_system.gd b/project/save_snapshot_system.gd
new file mode 100644
index 00000000..7ccf6d54
--- /dev/null
+++ b/project/save_snapshot_system.gd
@@ -0,0 +1,379 @@
+# save_snapshot_system.gd —— Metin2 40250 单人单机角色全量快照存读档系统 1:1
+# 对照 40250 服务端 input_db.cpp, ClientVS22/net/Player.h, char.h
+class_name SaveSnapshotSystem
+extends RefCounted
+
+signal snapshot_saved(slot_index: int, filepath: String)
+signal snapshot_loaded(slot_index: int, filepath: String)
+signal snapshot_deleted(slot_index: int)
+signal save_error(code: String, msg: String)
+
+const SCHEMA_VERSION: String = "40250.1"
+const DEFAULT_SAVE_DIR: String = "user://saves"
+const DEFAULT_MAX_SLOTS: int = 5
+const INVENTORY_SLOTS_COUNT: int = 90
+const BELT_SLOTS_COUNT: int = 16
+const EQUIPMENT_SLOTS_COUNT: int = 12
+
+const JOB_NAMES: Dictionary = {
+ 0: "战士",
+ 1: "刺客",
+ 2: "修罗",
+ 3: "萨满"
+}
+
+# 校验快照结构与版本完整性
+static func validate_snapshot(snapshot: Dictionary) -> Dictionary:
+ if not snapshot.has("meta"):
+ return {"ok": false, "reason": "MISSING_META", "msg": "缺失存档元信息 (meta)!"}
+ var meta = snapshot["meta"]
+ if not meta.has("schema_version") or meta["schema_version"] != SCHEMA_VERSION:
+ return {"ok": false, "reason": "VERSION_MISMATCH", "msg": "存档版本不匹配!"}
+
+ if not snapshot.has("stats"):
+ return {"ok": false, "reason": "MISSING_STATS", "msg": "缺失角色基础属性 (stats)!"}
+ if not snapshot.has("inventory"):
+ return {"ok": false, "reason": "MISSING_INVENTORY", "msg": "缺失背包物品数据 (inventory)!"}
+ if not snapshot.has("equipment"):
+ return {"ok": false, "reason": "MISSING_EQUIPMENT", "msg": "缺失已装备防具与武器 (equipment)!"}
+
+ return {"ok": true}
+
+# 将游戏内实时角色对象/字典打包为可持久化全量快照 Dictionary
+func create_snapshot(player_data: Dictionary, slot_index: int = 1) -> Dictionary:
+ var name: String = player_data.get("name", "NewHero")
+ var job: int = int(player_data.get("job", 0))
+ var level: int = int(player_data.get("level", 1))
+ var gold: int = int(player_data.get("gold", 0))
+ var playtime: int = int(player_data.get("playtime", 0))
+ var map_name: String = player_data.get("map_name", "village")
+
+ # 坐标序列化
+ var pos_val = player_data.get("pos", Vector3.ZERO)
+ var pos_arr: Array = [0.0, 0.0, 0.0]
+ if pos_val is Vector3:
+ pos_arr = [pos_val.x, pos_val.y, pos_val.z]
+ elif pos_val is Array and pos_val.size() >= 3:
+ pos_arr = [float(pos_val[0]), float(pos_val[1]), float(pos_val[2])]
+
+ # 背包序列化 (确保 90 格)
+ var raw_inv: Array = player_data.get("inventory", [])
+ var safe_inv: Array = []
+ for i in range(INVENTORY_SLOTS_COUNT):
+ if i < raw_inv.size() and raw_inv[i] != null:
+ safe_inv.append(raw_inv[i].duplicate(true))
+ else:
+ safe_inv.append(null)
+
+ # 装备栏序列化
+ var raw_equip: Dictionary = player_data.get("equipment", {})
+ var safe_equip: Dictionary = {}
+ for k in raw_equip.keys():
+ if raw_equip[k] != null:
+ safe_equip[str(k)] = raw_equip[k].duplicate(true)
+ else:
+ safe_equip[str(k)] = null
+
+ # 腰带与药水栏序列化
+ var belt_item = player_data.get("belt_item", null)
+ if belt_item != null:
+ belt_item = belt_item.duplicate(true)
+ var raw_belt_inv: Array = player_data.get("belt_inventory", [])
+ var safe_belt_inv: Array = []
+ for i in range(BELT_SLOTS_COUNT):
+ if i < raw_belt_inv.size() and raw_belt_inv[i] != null:
+ safe_belt_inv.append(raw_belt_inv[i].duplicate(true))
+ else:
+ safe_belt_inv.append(null)
+
+ # 时装槽序列化
+ var raw_costumes: Dictionary = player_data.get("costumes", {})
+ var safe_costumes: Dictionary = {}
+ for k in raw_costumes.keys():
+ if raw_costumes[k] != null:
+ safe_costumes[str(k)] = raw_costumes[k].duplicate(true)
+ else:
+ safe_costumes[str(k)] = null
+
+ # 技能数据序列化
+ var skills: Dictionary = player_data.get("skills", {}).duplicate(true)
+
+ # 龙石炼金数据
+ var alchemy: Dictionary = player_data.get("alchemy", {}).duplicate(true)
+
+ # 怪物卡片/成就/头衔
+ var monster_cards: Array = player_data.get("monster_cards", []).duplicate(true)
+ var achievements: Array = player_data.get("achievements", []).duplicate(true)
+ var active_title: String = player_data.get("active_title", "")
+
+ var snapshot: Dictionary = {
+ "meta": {
+ "schema_version": SCHEMA_VERSION,
+ "slot_index": slot_index,
+ "saved_at": int(Time.get_unix_time_from_system()),
+ "saved_datetime": Time.get_datetime_string_from_system()
+ },
+ "summary": {
+ "name": name,
+ "job": job,
+ "job_name": JOB_NAMES.get(job, "未知"),
+ "level": level,
+ "gold": gold,
+ "map_name": map_name,
+ "playtime": playtime
+ },
+ "stats": {
+ "name": name,
+ "job": job,
+ "level": level,
+ "exp": int(player_data.get("exp", 0)),
+ "hp": int(player_data.get("hp", 100)),
+ "max_hp": int(player_data.get("max_hp", 100)),
+ "sp": int(player_data.get("sp", 100)),
+ "max_sp": int(player_data.get("max_sp", 100)),
+ "stamina": int(player_data.get("stamina", 1000)),
+ "alignment": int(player_data.get("alignment", 0)),
+ "str": int(player_data.get("str", 15)),
+ "dex": int(player_data.get("dex", 15)),
+ "con": int(player_data.get("con", 15)),
+ "int_stat": int(player_data.get("int_stat", 15)),
+ "stat_points": int(player_data.get("stat_points", 0)),
+ "skill_points": int(player_data.get("skill_points", 0)),
+ "gold": gold,
+ "pos": pos_arr,
+ "map_name": map_name,
+ "playtime": playtime
+ },
+ "equipment": safe_equip,
+ "inventory": safe_inv,
+ "belt_system": {
+ "belt_item": belt_item,
+ "belt_inventory": safe_belt_inv
+ },
+ "costumes": safe_costumes,
+ "skills": skills,
+ "alchemy": alchemy,
+ "progression": {
+ "monster_cards": monster_cards,
+ "achievements": achievements,
+ "active_title": active_title
+ }
+ }
+
+ return snapshot
+
+# 从全量快照反序列化回角色运行时字典
+func restore_snapshot(snapshot: Dictionary) -> Dictionary:
+ var val_res = validate_snapshot(snapshot)
+ if not val_res["ok"]:
+ return val_res
+
+ var stats: Dictionary = snapshot["stats"]
+ var pos_arr: Array = stats.get("pos", [0.0, 0.0, 0.0])
+ var restored_pos := Vector3(pos_arr[0], pos_arr[1], pos_arr[2])
+
+ var restored: Dictionary = stats.duplicate(true)
+ restored["pos"] = restored_pos
+
+ # 恢复背包 (90 格)
+ var raw_inv: Array = snapshot.get("inventory", [])
+ var restored_inv: Array = []
+ for i in range(INVENTORY_SLOTS_COUNT):
+ if i < raw_inv.size() and raw_inv[i] != null:
+ restored_inv.append(raw_inv[i].duplicate(true))
+ else:
+ restored_inv.append(null)
+ restored["inventory"] = restored_inv
+
+ # 恢复装备栏 (转回 int 键)
+ var raw_equip: Dictionary = snapshot.get("equipment", {})
+ var restored_equip: Dictionary = {}
+ for k in raw_equip.keys():
+ var slot_id: int = int(k)
+ if raw_equip[k] != null:
+ restored_equip[slot_id] = raw_equip[k].duplicate(true)
+ else:
+ restored_equip[slot_id] = null
+ restored["equipment"] = restored_equip
+
+ # 恢复腰带
+ var belt_sys: Dictionary = snapshot.get("belt_system", {})
+ restored["belt_item"] = belt_sys.get("belt_item", null)
+ var raw_belt_inv: Array = belt_sys.get("belt_inventory", [])
+ var restored_belt_inv: Array = []
+ for i in range(BELT_SLOTS_COUNT):
+ if i < raw_belt_inv.size() and raw_belt_inv[i] != null:
+ restored_belt_inv.append(raw_belt_inv[i].duplicate(true))
+ else:
+ restored_belt_inv.append(null)
+ restored["belt_inventory"] = restored_belt_inv
+
+ # 恢复时装
+ var raw_costumes: Dictionary = snapshot.get("costumes", {})
+ var restored_costumes: Dictionary = {}
+ for k in raw_costumes.keys():
+ var slot_id: int = int(k)
+ if raw_costumes[k] != null:
+ restored_costumes[slot_id] = raw_costumes[k].duplicate(true)
+ else:
+ restored_costumes[slot_id] = null
+ restored["costumes"] = restored_costumes
+
+ # 恢复技能与炼金
+ restored["skills"] = snapshot.get("skills", {}).duplicate(true)
+ restored["alchemy"] = snapshot.get("alchemy", {}).duplicate(true)
+
+ # 恢复进度
+ var prog: Dictionary = snapshot.get("progression", {})
+ restored["monster_cards"] = prog.get("monster_cards", []).duplicate(true)
+ restored["achievements"] = prog.get("achievements", []).duplicate(true)
+ restored["active_title"] = prog.get("active_title", "")
+
+ return {
+ "ok": true,
+ "player_data": restored
+ }
+
+# 获取存档槽位实际物理路径
+func get_slot_path(slot_index: int, custom_dir: String = "") -> String:
+ var base_dir := custom_dir if not custom_dir.is_empty() else DEFAULT_SAVE_DIR
+ return base_dir.path_join("save_slot_%d.json" % slot_index)
+
+# 保存指定槽位
+func save_slot(slot_index: int, player_data: Dictionary, custom_dir: String = "") -> Dictionary:
+ var path := get_slot_path(slot_index, custom_dir)
+ var parent_dir := path.get_base_dir()
+
+ if not DirAccess.dir_exists_absolute(parent_dir):
+ var err := DirAccess.make_dir_recursive_absolute(parent_dir)
+ if err != OK:
+ save_error.emit("DIR_CREATE_FAILED", "无法创建存档目录:%s" % parent_dir)
+ return {"ok": false, "reason": "DIR_CREATE_FAILED", "path": path}
+
+ var snapshot := create_snapshot(player_data, slot_index)
+ var json_string := JSON.stringify(snapshot, "\t")
+
+ var file := FileAccess.open(path, FileAccess.WRITE)
+ if not file:
+ var err_code := FileAccess.get_open_error()
+ save_error.emit("FILE_WRITE_FAILED", "无法打开存档文件写入,错误码: %d" % err_code)
+ return {"ok": false, "reason": "FILE_WRITE_FAILED", "error": err_code, "path": path}
+
+ file.store_string(json_string)
+ file.close()
+
+ snapshot_saved.emit(slot_index, path)
+ return {
+ "ok": true,
+ "slot_index": slot_index,
+ "path": path,
+ "summary": snapshot["summary"],
+ "msg": "角色【%s】在槽位 %d 保存成功!" % [snapshot["summary"]["name"], slot_index]
+ }
+
+# 加载指定槽位
+func load_slot(slot_index: int, custom_dir: String = "") -> Dictionary:
+ var path := get_slot_path(slot_index, custom_dir)
+ if not FileAccess.file_exists(path):
+ save_error.emit("FILE_NOT_FOUND", "槽位 %d 的存档文件不存在!" % slot_index)
+ return {"ok": false, "reason": "FILE_NOT_FOUND", "path": path}
+
+ var file := FileAccess.open(path, FileAccess.READ)
+ if not file:
+ var err_code := FileAccess.get_open_error()
+ save_error.emit("FILE_READ_FAILED", "无法打开存档文件读取,错误码: %d" % err_code)
+ return {"ok": false, "reason": "FILE_READ_FAILED", "error": err_code, "path": path}
+
+ var content := file.get_as_text()
+ file.close()
+
+ var parse_result = JSON.parse_string(content)
+ if not (parse_result is Dictionary):
+ save_error.emit("JSON_PARSE_ERROR", "存档文件格式损坏或无法解析!")
+ return {"ok": false, "reason": "JSON_PARSE_ERROR", "path": path}
+
+ var restore_res = restore_snapshot(parse_result)
+ if not restore_res["ok"]:
+ save_error.emit(restore_res["reason"], restore_res["msg"])
+ return restore_res
+
+ snapshot_loaded.emit(slot_index, path)
+ return {
+ "ok": true,
+ "slot_index": slot_index,
+ "path": path,
+ "player_data": restore_res["player_data"],
+ "summary": parse_result.get("summary", {}),
+ "msg": "槽位 %d 角色【%s】读档成功!" % [slot_index, restore_res["player_data"]["name"]]
+ }
+
+# 删除指定槽位存档
+func delete_slot(slot_index: int, custom_dir: String = "") -> Dictionary:
+ var path := get_slot_path(slot_index, custom_dir)
+ if not FileAccess.file_exists(path):
+ return {"ok": false, "reason": "FILE_NOT_FOUND", "path": path}
+
+ var err := DirAccess.remove_absolute(path)
+ if err != OK:
+ save_error.emit("DELETE_FAILED", "删除槽位 %d 存档失败,错误码: %d" % [slot_index, err])
+ return {"ok": false, "reason": "DELETE_FAILED", "error": err, "path": path}
+
+ snapshot_deleted.emit(slot_index)
+ return {"ok": true, "slot_index": slot_index, "path": path, "msg": "槽位 %d 存档已清空。" % slot_index}
+
+# 获取所有槽位概览信息 (无需加载全部背包即可在 UI 角色选择列表高效展示)
+func get_slot_summaries(custom_dir: String = "", max_slots: int = DEFAULT_MAX_SLOTS) -> Array:
+ var summaries: Array = []
+ for i in range(1, max_slots + 1):
+ var path := get_slot_path(i, custom_dir)
+ if not FileAccess.file_exists(path):
+ summaries.append({
+ "slot_index": i,
+ "is_empty": true,
+ "path": path
+ })
+ continue
+
+ var file := FileAccess.open(path, FileAccess.READ)
+ if not file:
+ summaries.append({
+ "slot_index": i,
+ "is_empty": false,
+ "corrupted": true,
+ "path": path
+ })
+ continue
+
+ var content := file.get_as_text()
+ file.close()
+
+ var data = JSON.parse_string(content)
+ if data is Dictionary and data.has("summary"):
+ var sum = data["summary"].duplicate(true)
+ sum["slot_index"] = i
+ sum["is_empty"] = false
+ sum["saved_at"] = data.get("meta", {}).get("saved_at", 0)
+ sum["saved_datetime"] = data.get("meta", {}).get("saved_datetime", "")
+ sum["path"] = path
+ summaries.append(sum)
+ else:
+ summaries.append({
+ "slot_index": i,
+ "is_empty": false,
+ "corrupted": true,
+ "path": path
+ })
+
+ return summaries
+
+# 导出为完整 JSON 字符串
+func export_json(player_data: Dictionary, slot_index: int = 1, pretty: bool = true) -> String:
+ var snapshot := create_snapshot(player_data, slot_index)
+ return JSON.stringify(snapshot, "\t" if pretty else "")
+
+# 从 JSON 字符串解析还原角色数据
+func import_json(json_str: String) -> Dictionary:
+ var parsed = JSON.parse_string(json_str)
+ if not (parsed is Dictionary):
+ return {"ok": false, "reason": "INVALID_JSON_ROOT", "msg": "JSON 数据根节点必须为字典!"}
+ return restore_snapshot(parsed)
diff --git a/project/save_snapshot_system.gd.uid b/project/save_snapshot_system.gd.uid
new file mode 100644
index 00000000..d105dfe2
--- /dev/null
+++ b/project/save_snapshot_system.gd.uid
@@ -0,0 +1 @@
+uid://5xu50htpcyb8
diff --git a/project/security_lock_system.gd b/project/security_lock_system.gd
new file mode 100644
index 00000000..6d4a906f
--- /dev/null
+++ b/project/security_lock_system.gd
@@ -0,0 +1,223 @@
+# security_lock_system.gd —— Metin2 40250 仓库 6 位安全密码与贵重装备防丢锁定 1:1
+# 对照 40250 服务端 safebox.cpp, input_login.cpp, char.cpp
+class_name SecurityLockSystem
+extends RefCounted
+
+signal safebox_unlocked()
+signal safebox_closed()
+signal password_changed(new_pwd: String)
+signal item_locked(item_name: String)
+signal item_unlocked(item_name: String)
+
+const DEFAULT_SAFEBOX_PASSWORD := "000000"
+const PASSWORD_LEN := 6
+const CHAR_DELETE_CODE_LEN := 7
+const MAX_FAILED_ATTEMPTS := 5
+
+var safebox_password: String = DEFAULT_SAFEBOX_PASSWORD
+var is_safebox_unlocked: bool = false
+var failed_attempts: int = 0
+var is_locked_out: bool = false
+var char_delete_code: String = "1234567"
+
+# 验证并开启仓库
+func open_safebox(input_pwd: String) -> Dictionary:
+ if is_locked_out:
+ return {
+ "ok": false,
+ "reason": "LOCKOUT",
+ "msg": "连续输错5次密码,仓库已被暂时锁定!请稍后再试。"
+ }
+
+ if input_pwd == safebox_password:
+ failed_attempts = 0
+ is_safebox_unlocked = true
+ safebox_unlocked.emit()
+ return {
+ "ok": true,
+ "msg": "仓库密码验证成功,仓库已开启!"
+ }
+
+ failed_attempts += 1
+ if failed_attempts >= MAX_FAILED_ATTEMPTS:
+ is_locked_out = true
+ return {
+ "ok": false,
+ "reason": "WRONG_PASSWORD_LOCKOUT",
+ "failed_attempts": failed_attempts,
+ "msg": "密码错误达到上限(5次),仓库已被安全保护锁定!"
+ }
+
+ var remaining := MAX_FAILED_ATTEMPTS - failed_attempts
+ return {
+ "ok": false,
+ "reason": "WRONG_PASSWORD",
+ "failed_attempts": failed_attempts,
+ "remaining_attempts": remaining,
+ "msg": "仓库密码错误!还剩 %d 次尝试机会。" % remaining
+ }
+
+# 关闭仓库
+func close_safebox() -> void:
+ is_safebox_unlocked = false
+ safebox_closed.emit()
+
+# 重置防爆保护锁定 (如由 GM 或系统定时重置)
+func reset_lockout() -> void:
+ is_locked_out = false
+ failed_attempts = 0
+
+# 修改仓库 6 位密码
+func change_safebox_password(old_pwd: String, new_pwd: String) -> Dictionary:
+ if old_pwd != safebox_password:
+ return {
+ "ok": false,
+ "reason": "WRONG_OLD_PASSWORD",
+ "msg": "旧密码验证失败,无法修改!"
+ }
+
+ if new_pwd.length() != PASSWORD_LEN or not new_pwd.is_valid_int() or int(new_pwd) < 0:
+ return {
+ "ok": false,
+ "reason": "INVALID_PASSWORD_FORMAT",
+ "msg": "仓库密码必须为严格 6 位纯数字!"
+ }
+
+ if new_pwd == old_pwd:
+ return {
+ "ok": false,
+ "reason": "SAME_PASSWORD",
+ "msg": "新密码不能与旧密码相同!"
+ }
+
+ safebox_password = new_pwd
+ password_changed.emit(new_pwd)
+
+ return {
+ "ok": true,
+ "new_password": new_pwd,
+ "msg": "仓库密码已成功修改!"
+ }
+
+# 锁定装备/贵重物品 (防丢锁定)
+func lock_item(item: Dictionary, pwd: String) -> Dictionary:
+ if pwd != safebox_password:
+ return {
+ "ok": false,
+ "reason": "WRONG_SECURITY_PASSWORD",
+ "msg": "安全密码错误,无法加锁!"
+ }
+
+ if item.get("is_locked", false):
+ return {
+ "ok": false,
+ "reason": "ALREADY_LOCKED",
+ "msg": "该物品已处于锁定状态!"
+ }
+
+ item["is_locked"] = true
+ var iname = item.get("name", "物品")
+ item_locked.emit(iname)
+
+ return {
+ "ok": true,
+ "item": item,
+ "msg": "物品【%s】已安全加锁!" % iname
+ }
+
+# 解除物品锁定
+func unlock_item(item: Dictionary, pwd: String) -> Dictionary:
+ if pwd != safebox_password:
+ return {
+ "ok": false,
+ "reason": "WRONG_SECURITY_PASSWORD",
+ "msg": "安全密码错误,无法解锁!"
+ }
+
+ if not item.get("is_locked", false):
+ return {
+ "ok": false,
+ "reason": "NOT_LOCKED",
+ "msg": "该物品未处于锁定状态!"
+ }
+
+ item["is_locked"] = false
+ var iname = item.get("name", "物品")
+ item_unlocked.emit(iname)
+
+ return {
+ "ok": true,
+ "item": item,
+ "msg": "物品【%s】已解除锁定!" % iname
+ }
+
+# 动作权限校验 —— 丢弃到地面
+func can_drop_item(item: Dictionary) -> Dictionary:
+ if item.get("is_locked", false):
+ return {
+ "allowed": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的贵重物品无法丢弃!"
+ }
+ return {"allowed": true}
+
+# 动作权限校验 —— 出售给 NPC
+func can_sell_item(item: Dictionary) -> Dictionary:
+ if item.get("is_locked", false):
+ return {
+ "allowed": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的贵重物品无法出售给商人!"
+ }
+ return {"allowed": true}
+
+# 动作权限校验 —— 彻底销毁
+func can_destroy_item(item: Dictionary) -> Dictionary:
+ if item.get("is_locked", false):
+ return {
+ "allowed": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的贵重物品无法直接销毁!"
+ }
+ return {"allowed": true}
+
+# 动作权限校验 —— 玩家交易
+func can_trade_item(item: Dictionary) -> Dictionary:
+ if item.get("is_locked", false):
+ return {
+ "allowed": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的贵重物品无法用于玩家交易!"
+ }
+ return {"allowed": true}
+
+# 动作权限校验 —— 强化或熔炼消耗
+func can_refine_item(item: Dictionary) -> Dictionary:
+ if item.get("is_locked", false):
+ return {
+ "allowed": false,
+ "reason": "ITEM_LOCKED",
+ "msg": "已加锁的装备无法用于强化或附魔消耗!"
+ }
+ return {"allowed": true}
+
+# 角色删除 7 位安全码验证 (对照 40250 input_login.cpp)
+func verify_character_deletion(input_code: String) -> Dictionary:
+ if input_code.length() != CHAR_DELETE_CODE_LEN or not input_code.is_valid_int() or int(input_code) < 0:
+ return {
+ "ok": false,
+ "reason": "INVALID_CODE_FORMAT",
+ "msg": "角色删除码必须为严格 7 位纯数字!"
+ }
+
+ if input_code != char_delete_code:
+ return {
+ "ok": false,
+ "reason": "WRONG_DELETE_CODE",
+ "msg": "角色删除安全码错误!"
+ }
+
+ return {
+ "ok": true,
+ "msg": "角色删除安全码验证通过,允许删除角色。"
+ }
diff --git a/project/security_lock_system.gd.uid b/project/security_lock_system.gd.uid
new file mode 100644
index 00000000..98c61c6c
--- /dev/null
+++ b/project/security_lock_system.gd.uid
@@ -0,0 +1 @@
+uid://b5u8bbhj5ybdi
diff --git a/project/skill_book_reading_system.gd b/project/skill_book_reading_system.gd
new file mode 100644
index 00000000..00eeb65b
--- /dev/null
+++ b/project/skill_book_reading_system.gd
@@ -0,0 +1,213 @@
+# skill_book_reading_system.gd —— 40250 官方大师技能书悟道研读与突破系统 1:1
+# 严格对照:
+# metin2/src/server/game/src/char_skill.cpp:416-560 (CHARACTER::LearnSkillByBook)
+# metin2/src/server/game/src/char_item.cpp:2128-2160 (ITEM_SKILLBOOK / 50300)
+# metin2/src/server/game/src/affect.h (AFFECT_SKILL_BOOK_BONUS 512, AFFECT_SKILL_NO_BOOK_DELAY 513)
+# metin2/src/server/share/conf/item_proto.txt (71001 驱魔卷轴, 71094 隐士之药)
+extends RefCounted
+
+const ITEM_SKILLBOOK_VNUM := 50300
+const EXORCISM_SCROLL_VNUM := 71001 # 驱魔卷轴: AFFECT_SKILL_NO_BOOK_DELAY
+const HERMIT_ADVICE_VNUM := 71094 # 隐士之药: AFFECT_SKILL_BOOK_BONUS
+
+const EXP_COST := 20000 # 40250 char_skill.cpp:430 need_exp = 20000
+const COOLDOWN_DURATION := 86400 # 24小时冷却 (LC_IsKorea 86400 秒)
+
+const BASE_SUCCESS_RATE := 35 # 基础成功率 35% (char_skill.cpp:524: percent = 65 -> 100 - 65 = 35%)
+
+# 技能进阶档次 (MasterType)
+const SKILL_NORMAL := 0 # Lv 1 - 19
+const SKILL_MASTER := 1 # M1 (Lv 20) ~ M10 (Lv 29)
+const SKILL_GRAND_MASTER := 2 # G1 (Lv 30) ~ G10 (Lv 39)
+const SKILL_PERFECT_MASTER := 3 # P (Lv 40)
+
+# 角色技能书研读状态
+var skills: Dictionary = {} # skill_id -> {"level": int, "read_count": int, "next_read_time": int}
+var affects: Dictionary = {} # affect_type -> bool
+var player_exp: int = 100000
+var current_time: int = 1000000
+
+# 累计统计
+var total_books_read: int = 0
+var total_books_succeeded: int = 0
+var total_books_failed: int = 0
+
+func _init(initial_exp: int = 100000, initial_time: int = 1000000) -> void:
+ player_exp = initial_exp
+ current_time = initial_time
+ skills = {}
+ affects = {}
+ total_books_read = 0
+ total_books_succeeded = 0
+ total_books_failed = 0
+
+# 注册/初始化技能
+func init_skill(skill_id: int, level: int = 20, read_count: int = 0, next_read_time: int = 0) -> void:
+ skills[skill_id] = {
+ "level": level,
+ "read_count": read_count,
+ "next_read_time": next_read_time
+ }
+
+# 获取技能档次
+func get_skill_master_type(skill_id: int) -> int:
+ if not skills.has(skill_id):
+ return SKILL_NORMAL
+ var lvl: int = skills[skill_id]["level"]
+ if lvl < 20:
+ return SKILL_NORMAL
+ elif lvl < 30:
+ return SKILL_MASTER
+ elif lvl < 40:
+ return SKILL_GRAND_MASTER
+ else:
+ return SKILL_PERFECT_MASTER
+
+# 计算升下一级所需的成功书本数 (char_skill.cpp:509 need_bookcount = level - 20)
+# M1(Lv20) 升 M2: 需 1 本 (20 - 20 + 1 = 1)
+# M2(Lv21) 升 M3: 需 2 本
+# ...
+# M10(Lv29) 升 G1: 需 10 本
+# 累计突破至 G 需 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 = 55 本成功!
+func get_need_books_for_next_level(skill_id: int) -> int:
+ if not skills.has(skill_id):
+ return 0
+ var lvl: int = skills[skill_id]["level"]
+ if lvl < 20 or lvl >= 30:
+ return 0
+ return lvl - 20 + 1
+
+# 使用驱魔卷轴 (71001)
+func use_exorcism_scroll() -> bool:
+ affects[EXORCISM_SCROLL_VNUM] = true
+ return true
+
+# 使用隐士之药 (71094)
+func use_hermit_advice() -> bool:
+ affects[HERMIT_ADVICE_VNUM] = true
+ return true
+
+# 检查是否存在状态
+func has_affect(affect_vnum: int) -> bool:
+ return affects.get(affect_vnum, false)
+
+# 研读技能书核心方法 (1:1 CHARACTER::LearnSkillByBook)
+# roll_override: 传入 1~100 用于单元测试确定性判定,-1 表示根据随机数判定
+func learn_skill_by_book(skill_id: int, book_socket_skill_vnum: int = -1, roll_override: int = -1) -> Dictionary:
+ var result := {
+ "success": false,
+ "level_up": false,
+ "breakthrough_g": false,
+ "error_code": "OK",
+ "read_count": 0,
+ "needed_books": 0,
+ "remaining_books": 0,
+ "current_level": 0,
+ "exp_deducted": 0,
+ "consumed_exorcism": false,
+ "consumed_hermit": false,
+ "message": ""
+ }
+
+ # 1. 校验技能是否存在
+ if not skills.has(skill_id):
+ result["error_code"] = "NOT_LEARNABLE"
+ result["message"] = "无法研读未掌握或非本流派的技能。"
+ return result
+
+ # 2. 校验技能书 Socket 绑定的技能编号 (40250 char_item.cpp:2132)
+ if book_socket_skill_vnum != -1 and book_socket_skill_vnum != skill_id:
+ result["error_code"] = "SKILL_MISMATCH"
+ result["message"] = "该技能书并非此技能专研典籍。"
+ return result
+
+ var skill_data: Dictionary = skills[skill_id]
+ var current_lvl: int = skill_data["level"]
+ result["current_level"] = current_lvl
+
+ # 3. 校验阶档
+ var master_type: int = get_skill_master_type(skill_id)
+ if master_type < SKILL_MASTER:
+ result["error_code"] = "NOT_MASTER_TIER"
+ result["message"] = "此技能尚未达到大师 (M) 阶档,无法研读书籍。"
+ return result
+ elif master_type >= SKILL_GRAND_MASTER:
+ result["error_code"] = "ALREADY_G_OR_P"
+ result["message"] = "此技能已达到宗师 (G) 阶或更高,书籍无法再提升造诣。"
+ return result
+
+ # 4. 经验值检查与消耗 (char_skill.cpp:430: need_exp = 20000)
+ if player_exp < EXP_COST:
+ result["error_code"] = "INSUFFICIENT_EXP"
+ result["message"] = "角色经验值不足 20,000,精神无法专注研读。"
+ return result
+
+ # 5. 冷却时间与驱魔卷轴检查 (char_skill.cpp:455)
+ var next_time: int = skill_data.get("next_read_time", 0)
+ var bypass_cooldown := false
+ if current_time < next_time:
+ if has_affect(EXORCISM_SCROLL_VNUM):
+ affects.erase(EXORCISM_SCROLL_VNUM)
+ bypass_cooldown = true
+ result["consumed_exorcism"] = true
+ else:
+ result["error_code"] = "COOLDOWN_ACTIVE"
+ result["message"] = "心智尚需沉淀,请在冷却结束后再读,或使用驱魔卷轴。"
+ return result
+
+ # 扣除 20,000 EXP 并产生 24 小时冷却 (86,400 秒)
+ player_exp -= EXP_COST
+ result["exp_deducted"] = EXP_COST
+ skill_data["next_read_time"] = current_time + COOLDOWN_DURATION
+ total_books_read += 1
+
+ # 6. 成功率计算与隐士之药加成
+ var success_chance: int = BASE_SUCCESS_RATE
+ if has_affect(HERMIT_ADVICE_VNUM):
+ affects.erase(HERMIT_ADVICE_VNUM)
+ success_chance = 100 # 隐士之药 100% 保底成功 (char_skill.cpp:527)
+ result["consumed_hermit"] = true
+
+ var roll := roll_override if roll_override > 0 else (randi() % 100 + 1)
+ var is_read_success: bool = (roll <= success_chance)
+
+ var need_books: int = get_need_books_for_next_level(skill_id)
+ result["needed_books"] = need_books
+
+ if not is_read_success:
+ # 研读失败 (char_skill.cpp:613 READ_FAIL)
+ total_books_failed += 1
+ result["success"] = false
+ result["read_count"] = skill_data["read_count"]
+ result["remaining_books"] = need_books - skill_data["read_count"]
+ result["error_code"] = "READ_FAIL"
+ result["message"] = "研读失败:经脉运转不顺,书中奥义未能参透。"
+ return result
+
+ # 研读成功
+ total_books_succeeded += 1
+ result["success"] = true
+ var cur_count: int = skill_data["read_count"] + 1
+
+ if cur_count >= need_books:
+ # 晋级提升一阶等级!(SkillLevelUp)
+ skill_data["level"] += 1
+ skill_data["read_count"] = 0
+ result["level_up"] = true
+ result["current_level"] = skill_data["level"]
+ result["read_count"] = 0
+ result["remaining_books"] = 0
+
+ if skill_data["level"] >= 30:
+ result["breakthrough_g"] = true
+ result["message"] = "顿悟成功!技能历经千锤百炼,成功突破至宗师境界 (G1)!"
+ else:
+ result["message"] = "顿悟成功!技能境界提升至 M%d!" % (skill_data["level"] - 19)
+ else:
+ # 累积研读进度
+ skill_data["read_count"] = cur_count
+ result["read_count"] = cur_count
+ result["remaining_books"] = need_books - cur_count
+ result["message"] = "心领神会!还需研读 %d 本成功即可突破进阶。" % (need_books - cur_count)
+
+ return result
diff --git a/project/skill_book_reading_system.gd.uid b/project/skill_book_reading_system.gd.uid
new file mode 100644
index 00000000..da5221dd
--- /dev/null
+++ b/project/skill_book_reading_system.gd.uid
@@ -0,0 +1 @@
+uid://uf2nae5qlq64
diff --git a/project/skill_book_system.gd b/project/skill_book_system.gd
new file mode 100644
index 00000000..fe801996
--- /dev/null
+++ b/project/skill_book_system.gd
@@ -0,0 +1,203 @@
+# skill_book_system.gd —— 40250 技能书研读与技能突破进阶系统 1:1 规则引擎
+# 严格对照:
+# metin2/src/server/game/src/char_item.cpp:2128-2160 (ITEM_SKILLBOOK / 50300)
+# metin2/src/server/game/src/char_skill.cpp:416-570 (CHARACTER::LearnSkillByBook)
+# metin2/src/server/game/src/char_item.cpp:1657 (Soul Stone 50513 / G->P)
+# ClientVS22/UserInterface/Packet.h
+extends RefCounted
+
+const SKILL_BOOK_VNUM := 50300
+const EXORCISM_SCROLL_VNUM := 71001 # 驱魔卷轴 (AFFECT_SKILL_NO_BOOK_DELAY)
+const HERMIT_ADVICE_VNUM := 71094 # 隐士教训 (AFFECT_SKILL_BOOK_BONUS)
+const SOUL_STONE_VNUM := 71001 # 魂石 (50513)
+
+const SKILL_BOOK_EXP_COST := 20000 # 40250 char_skill.cpp:430 need_exp = 20000
+const SKILL_BOOK_DELAY := 86400 # 24 小时冷却 (LC_IsKorea 86400s)
+
+# 40250 MasterType 枚举
+const MASTER_NORMAL := 0 # Lv 1 - 20
+const MASTER_M := 1 # M1 - M10 (Lv 21 - 30)
+const MASTER_G := 2 # G1 - G10 (Lv 31 - 40)
+const MASTER_P := 3 # Perfect Master (Lv 40)
+
+# 错误码
+enum Result {
+ SUCCESS_LEVEL_UP, # 成功并升级(或突破到 G)
+ SUCCESS_READ_ONCE, # 成功研读一本(累积进度)
+ FAIL_READ, # 研读失败
+ ERR_NOT_LEARNABLE, # 非本职业/未入流派技能
+ ERR_NOT_MASTER_TIER, # 尚未达到 M 阶 (Lv < 20)
+ ERR_ALREADY_G_OR_P, # 已达到 G 阶或更高,无法再读技能书
+ ERR_INSUFFICIENT_EXP, # 经验不足 20,000
+ ERR_COOLDOWN, # 24小时冷却中
+}
+
+# 魂石善恶值消耗表 (char_skill.cpp / training_grandmaster_skill.quest)
+const SOUL_STONE_KARMA_COST := {
+ 1: 1000, 2: 1500, 3: 2000, 4: 2500, 5: 3000,
+ 6: 3500, 7: 4000, 8: 4500, 9: 5000, 10: 5500
+}
+
+# 判定技能书升级所需本数:40250 char_skill.cpp:509 need_bookcount = GetSkillLevel(dwSkillVnum) - 20
+static func get_need_books(skill_level: int) -> int:
+ # M1 (21) 需 1 本,M2 (22) 需 2 本 ... M10 (30) 需 10 本
+ return maxi(1, skill_level - 20)
+
+# 研读条件前置校验
+static func check_can_read(
+ skill_id: int,
+ skill_level: int,
+ master_type: int,
+ player_exp: int,
+ next_read_time: int,
+ current_time: int,
+ has_exorcism: bool
+) -> int:
+ if skill_id <= 0:
+ return Result.ERR_NOT_LEARNABLE
+
+ if master_type < MASTER_M or skill_level < 20:
+ return Result.ERR_NOT_MASTER_TIER
+
+ if master_type > MASTER_M or skill_level >= 30:
+ return Result.ERR_ALREADY_G_OR_P
+
+ if player_exp < SKILL_BOOK_EXP_COST:
+ return Result.ERR_INSUFFICIENT_EXP
+
+ if current_time < next_read_time and not has_exorcism:
+ return Result.ERR_COOLDOWN
+
+ return 0 # OK
+
+# 研读技能书核心判定逻辑(1:1 char_skill.cpp:488-570)
+static func read_skill_book(
+ skill_id: int,
+ skill_level: int,
+ master_type: int,
+ read_count: int,
+ player_exp: int,
+ next_read_time: int,
+ current_time: int,
+ has_exorcism: bool,
+ has_hermit: bool,
+ roll_val := -1 # 测试可注入 1..100
+) -> Dictionary:
+ var err := check_can_read(skill_id, skill_level, master_type, player_exp, next_read_time, current_time, has_exorcism)
+ if err != 0:
+ return {
+ "result": err,
+ "skill_id": skill_id,
+ "level": skill_level,
+ "master_type": master_type,
+ "read_count": read_count,
+ "exp_consumed": 0,
+ "next_read_time": next_read_time,
+ "consumed_exorcism": false,
+ "consumed_hermit": false,
+ }
+
+ var consumed_exorcism := false
+ if current_time < next_read_time and has_exorcism:
+ consumed_exorcism = true
+
+ var consumed_hermit := false
+ var fail_percent := 65 # 40250 char_skill.cpp:525 int percent = 65;
+ if has_hermit:
+ fail_percent = 0 # 100% 成功
+ consumed_hermit = true
+
+ var dice := roll_val if roll_val > 0 else (randi() % 100 + 1)
+ var success := (dice > fail_percent)
+
+ var new_level := skill_level
+ var new_master := master_type
+ var new_read_count := read_count
+ var new_next_time := current_time + SKILL_BOOK_DELAY
+ var res := Result.FAIL_READ
+
+ if success:
+ var need_books := get_need_books(skill_level)
+ if read_count + 1 >= need_books:
+ # 升级!
+ new_level += 1
+ new_read_count = 0
+ if new_level >= 30:
+ new_master = MASTER_G # 突破到 G1
+ res = Result.SUCCESS_LEVEL_UP
+ else:
+ new_read_count += 1
+ res = Result.SUCCESS_READ_ONCE
+
+ return {
+ "result": res,
+ "skill_id": skill_id,
+ "level": new_level,
+ "master_type": new_master,
+ "read_count": new_read_count,
+ "exp_consumed": SKILL_BOOK_EXP_COST,
+ "next_read_time": new_next_time,
+ "consumed_exorcism": consumed_exorcism,
+ "consumed_hermit": consumed_hermit,
+ "need_books": get_need_books(new_level),
+ }
+
+# 魂石修炼逻辑(1:1 G1..G10 -> P)
+static func soul_stone_train(
+ skill_id: int,
+ skill_level: int,
+ master_type: int,
+ current_karma: int,
+ roll_val := -1
+) -> Dictionary:
+ if master_type != MASTER_G or skill_level < 30 or skill_level >= 40:
+ return {"success": false, "err": "not_g_tier"}
+
+ var g_step := skill_level - 30 # 1..10
+ var base_cost: int = SOUL_STONE_KARMA_COST.get(g_step, 1000)
+ var actual_cost: int = base_cost if current_karma >= 0 else (base_cost * 2)
+
+ var dice := roll_val if roll_val > 0 else (randi() % 100 + 1)
+ # 40250 魂石成功率约 30%
+ var success := (dice <= 30)
+
+ var new_level := skill_level
+ var new_master := master_type
+ if success:
+ new_level += 1
+ if new_level >= 40:
+ new_master = MASTER_P
+
+ return {
+ "success": success,
+ "skill_id": skill_id,
+ "level": new_level,
+ "master_type": new_master,
+ "karma_consumed": actual_cost,
+ }
+
+# 格式化官方提示文本
+static func get_result_message(res_dict: Dictionary) -> String:
+ match res_dict.get("result", -1):
+ Result.SUCCESS_LEVEL_UP:
+ if res_dict.get("master_type") == MASTER_G and res_dict.get("level") == 30:
+ return "领悟了大宗师的奥秘!技能已成功突破至大宗师(G1)!"
+ return "完全理解了书中内容,技能成功提升到了新的境界!"
+ Result.SUCCESS_READ_ONCE:
+ var remaining := int(res_dict.get("need_books", 1)) - int(res_dict.get("read_count", 0))
+ return "成功领悟了部分技巧!还需要成功研读 %d 本书以完成突破。" % remaining
+ Result.FAIL_READ:
+ return "研读失败,未能领悟书中的奥秘。"
+ Result.ERR_NOT_LEARNABLE:
+ return "无法修炼该技能书。"
+ Result.ERR_NOT_MASTER_TIER:
+ return "这个技能还未达到大师级别(M),不能通过技能书修炼。"
+ Result.ERR_ALREADY_G_OR_P:
+ return "这个技能已达到大宗师或以上级别,无法再通过普通技能书提升。"
+ Result.ERR_INSUFFICIENT_EXP:
+ return "经验值不足(需要 20,000 EXP)。"
+ Result.ERR_COOLDOWN:
+ var remaining_s := maxi(0, int(res_dict.get("next_read_time", 0)))
+ return "当前处于研读冷却中,请使用驱魔卷轴消除冷却或稍后再试。"
+ _:
+ return ""
diff --git a/project/skill_book_system.gd.uid b/project/skill_book_system.gd.uid
new file mode 100644
index 00000000..abbc45dd
--- /dev/null
+++ b/project/skill_book_system.gd.uid
@@ -0,0 +1 @@
+uid://cliup1bwf27h5
diff --git a/project/skill_slot_map.gd b/project/skill_slot_map.gd
new file mode 100644
index 00000000..703796ce
--- /dev/null
+++ b/project/skill_slot_map.gd
@@ -0,0 +1,54 @@
+# 40250 stores SLOT_TYPE_SKILL quickslots as CPythonPlayer skill-slot numbers,
+# not raw skill vnums. playersettingmodule.RegisterSkill() maps those slots to
+# a job + skill-group specific skill id before the taskbar asks for its icon.
+extends RefCounted
+
+const ACTIVE := {
+ 0: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0],
+ 1: [1, 2, 3, 4, 5, 0, 0, 0, 137, 0, 138, 0, 139, 0],
+ 2: [16, 17, 18, 19, 20, 0, 0, 0, 137, 0, 138, 0, 139, 0]},
+ 1: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140],
+ 1: [31, 32, 33, 34, 35, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140],
+ 2: [46, 47, 48, 49, 50, 0, 0, 0, 137, 0, 138, 0, 139, 0, 140]},
+ 2: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0],
+ 1: [61, 62, 63, 64, 65, 66, 0, 0, 137, 0, 138, 0, 139, 0],
+ 2: [76, 77, 78, 79, 80, 81, 0, 0, 137, 0, 138, 0, 139, 0]},
+ 3: {0: [0, 0, 0, 0, 0, 0, 0, 0, 137, 0, 138, 0, 139, 0],
+ 1: [91, 92, 93, 94, 95, 96, 0, 0, 137, 0, 138, 0, 139, 0],
+ 2: [106, 107, 108, 109, 110, 111, 0, 0, 137, 0, 138, 0, 139, 0]},
+}
+const SUPPORT := [122, 123, 121, 124, 125, 129, 0, 0, 130, 131]
+const PASSIVE_GUILD := [151]
+const ACTIVE_GUILD := [152, 153, 154, 155, 156, 157]
+
+static func skill_id_for_slot(race: int, group: int, slot: int) -> int:
+ var job := race & 3
+ if slot >= 1 and slot <= 14:
+ var by_group: Dictionary = ACTIVE.get(job, {})
+ var ids: Array = by_group.get(group, [])
+ return int(ids[slot - 1]) if slot <= ids.size() else 0
+ if slot >= 101 and slot < 101 + SUPPORT.size():
+ return int(SUPPORT[slot - 101])
+ if slot >= 200 and slot < 200 + PASSIVE_GUILD.size():
+ return int(PASSIVE_GUILD[slot - 200])
+ if slot >= 210 and slot < 210 + ACTIVE_GUILD.size():
+ return int(ACTIVE_GUILD[slot - 210])
+ return 0
+
+static func skill_slot_for_id(race: int, group: int, skill_id: int) -> int:
+ var job := race & 3
+ var by_group: Dictionary = ACTIVE.get(job, {})
+ var ids: Array = by_group.get(group, [])
+ var at := ids.find(skill_id)
+ if at >= 0:
+ return at + 1
+ at = SUPPORT.find(skill_id)
+ if at >= 0:
+ return at + 101
+ at = PASSIVE_GUILD.find(skill_id)
+ if at >= 0:
+ return at + 200
+ at = ACTIVE_GUILD.find(skill_id)
+ if at >= 0:
+ return at + 210
+ return 0
diff --git a/project/skill_slot_map.gd.uid b/project/skill_slot_map.gd.uid
new file mode 100644
index 00000000..9a280125
--- /dev/null
+++ b/project/skill_slot_map.gd.uid
@@ -0,0 +1 @@
+uid://ggxb28v0328q
diff --git a/project/skill_test.gd b/project/skill_test.gd
index 35f4ead0..06b78524 100644
--- a/project/skill_test.gd
+++ b/project/skill_test.gd
@@ -23,6 +23,8 @@ class FakeClient extends Node:
signal skills_changed()
signal skill_cooldown_end(skill: int)
signal quickslots_changed()
+ signal skill_group_changed(group: int)
+ signal entity_main_set(vid: int)
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}
@@ -91,6 +93,10 @@ func _run() -> void:
if skill_icon:
_ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32,
"skill 1 icon region is 32x32")
+ var strong_body_icon := UiAssets.load_tex(assets,
+ "ETC/ymir work/ui/skill/warrior/cheongeun_03.sub")
+ _ck(strong_body_icon is AtlasTexture and int(strong_body_icon.region.position.y) == 0,
+ "DDS .sub uses top-left coordinates (Strong Body top row -> decoded y=0)")
_ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL")
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
_ck(not st.is_ranged(1), "skill 1 melee skill is not ranged")
@@ -324,6 +330,20 @@ func _run() -> void:
qb.activate(0)
_ck(fc.casts.size() == 1, "server refresh cannot reset skill cooldown")
fc.quickslots = [] # restore this fixture's original server state for later UI cases
+ # 40250 quickslot skill refs are player skill-slot numbers. The same ref=2
+ # selects a different skill after switching warrior skill group.
+ fc.skill_group = 2
+ fc.quickslots = [{"pos": 0, "type": 2, "ref": 2}]
+ qb.restore_from_server()
+ _ck(int(qb._state[0].id) == 17 and int(qb._state[0].ref) == 2,
+ "warrior group 2 quickslot ref 2 -> skill 17")
+ fc.quickslot_ops.clear()
+ qb.assign(0, "skill", 20)
+ _ck(fc.quickslot_ops == [["add", 0, 2, 5]],
+ "assign warrior group 2 skill 20 persists player skill-slot 5")
+ fc.skill_group = 1
+ fc.quickslots = []
+ qb.assign(0, "skill", 1, false)
qb._clear(1)
# GC_SKILL_COOLTIME_END 提前解锁
fc.skill_cooldown_end.emit(1)
@@ -395,7 +415,7 @@ func _run() -> void:
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._root.size == Vector2(400, 56) and qb2._root.z_index > 0,
+ _ck(qb2._root.custom_minimum_size.y >= 37 and qb2._root.z_index > 0,
"quickbar has a real bottom-anchor extent and stays above legacy HUD")
_ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16")
_ck(qb2._slots[2].icon.texture != null and qb2._slots[2].icon.visible,
diff --git a/project/socket_glow_fx_system.gd b/project/socket_glow_fx_system.gd
new file mode 100644
index 00000000..e216d549
--- /dev/null
+++ b/project/socket_glow_fx_system.gd
@@ -0,0 +1,123 @@
+# socket_glow_fx_system.gd —— Metin2 40250 装备多孔镶嵌宝石发光流光粒子视觉系统 1:1
+# 对照 40250 客户端 ItemData.h, PythonItem.cpp, InstanceBaseEffect.cpp
+class_name SocketGlowFxSystem
+extends RefCounted
+
+signal glow_profile_updated(item_vnum: int, intensity: float, color: Color)
+
+# +4 经典灵石发光光谱 (色调与波长)
+const STONE_COLORS: Dictionary = {
+ 28430: {"name": "战士灵石+4", "color": Color(1.0, 0.2, 0.15), "glow": 1.2}, # 炽热炽红
+ 28431: {"name": "忍者灵石+4", "color": Color(0.2, 1.0, 0.35), "glow": 1.2}, # 剧毒翡翠
+ 28432: {"name": "修罗灵石+4", "color": Color(0.75, 0.15, 1.0), "glow": 1.3}, # 幽冥暗紫
+ 28433: {"name": "萨满灵石+4", "color": Color(1.0, 0.75, 0.1), "glow": 1.2}, # 太阳金橙
+ 28435: {"name": "怪物灵石+4", "color": Color(1.0, 0.05, 0.05), "glow": 1.4}, # 嗜血深红
+ 28436: {"name": "施法灵石+4", "color": Color(1.0, 0.95, 0.2), "glow": 1.5}, # 耀金雷光
+ 28438: {"name": "守护灵石+4", "color": Color(0.2, 0.65, 1.0), "glow": 1.1}, # 霜华幽蓝
+ 28440: {"name": "生命灵石+4", "color": Color(0.1, 0.95, 0.65), "glow": 1.1} # 生命碧绿
+}
+
+# +7/+8/+9 装备基础强化光效配置
+const REFINE_GLOW_TIERS: Dictionary = {
+ 7: {"intensity": 0.6, "particles": 12, "pulse": 1.0, "swirl": false, "trail": false},
+ 8: {"intensity": 1.2, "particles": 28, "pulse": 1.6, "swirl": false, "trail": true},
+ 9: {"intensity": 2.2, "particles": 55, "pulse": 2.2, "swirl": true, "trail": true}
+}
+
+# 计算装备完整的流光与发光特效数据
+func calculate_equipment_glow(item: Dictionary) -> Dictionary:
+ if item.is_empty():
+ return _empty_glow()
+
+ var vnum: int = int(item.get("vnum", 0))
+ var refine: int = int(item.get("refine_level", 0))
+ var sockets: Array = item.get("sockets", [])
+
+ # 1. 解析宝石镶嵌颜色
+ var stone_colors: Array = []
+ var stone_glow_boost: float = 0.0
+
+ for s in sockets:
+ if s != null and STONE_COLORS.has(int(s)):
+ var cfg = STONE_COLORS[int(s)]
+ stone_colors.append(cfg["color"])
+ stone_glow_boost += float(cfg["glow"]) * 0.35
+
+ # 2. 混合计算主光色与副光色
+ var primary_color := Color(1.0, 0.95, 0.85) # 默认神圣白金色
+ var secondary_color := primary_color
+
+ if not stone_colors.is_empty():
+ var total_r := 0.0
+ var total_g := 0.0
+ var total_b := 0.0
+ for c in stone_colors:
+ total_r += c.r
+ total_g += c.g
+ total_b += c.b
+ var n = float(stone_colors.size())
+ primary_color = Color(total_r / n, total_g / n, total_b / n)
+ secondary_color = stone_colors[0] # 首颗灵石为主导特征色
+
+ # 3. 强化等级基础光效
+ var base_intensity: float = 0.0
+ var particle_rate: int = 0
+ var pulse_speed: float = 0.8
+ var has_trail: bool = false
+ var swirl_enabled: bool = false
+
+ if REFINE_GLOW_TIERS.has(refine):
+ var r_cfg = REFINE_GLOW_TIERS[refine]
+ base_intensity = r_cfg["intensity"]
+ particle_rate = r_cfg["particles"]
+ pulse_speed = r_cfg["pulse"]
+ has_trail = r_cfg["trail"]
+ swirl_enabled = r_cfg["swirl"]
+ elif not stone_colors.is_empty():
+ # 未到 +7 但镶嵌了灵石,产生轻微宝石微光
+ base_intensity = 0.3
+ particle_rate = 6
+
+ var final_intensity: float = base_intensity + stone_glow_boost
+ var is_glowing: bool = final_intensity > 0.05
+
+ var result: Dictionary = {
+ "is_glowing": is_glowing,
+ "refine_level": refine,
+ "stone_count": stone_colors.size(),
+ "glow_intensity": final_intensity,
+ "primary_color": primary_color,
+ "secondary_color": secondary_color,
+ "pulse_speed": pulse_speed,
+ "particle_rate": particle_rate,
+ "has_trail": has_trail,
+ "swirl_enabled": swirl_enabled
+ }
+
+ glow_profile_updated.emit(vnum, final_intensity, primary_color)
+ return result
+
+# 将发光材质参数直接应用至 Godot 3D 材质
+func apply_to_material(mat: StandardMaterial3D, glow_data: Dictionary) -> void:
+ if not mat or not glow_data.get("is_glowing", false):
+ if mat:
+ mat.emission_enabled = false
+ return
+
+ mat.emission_enabled = true
+ mat.emission = glow_data["primary_color"]
+ mat.emission_energy_multiplier = glow_data["glow_intensity"]
+
+func _empty_glow() -> Dictionary:
+ return {
+ "is_glowing": false,
+ "refine_level": 0,
+ "stone_count": 0,
+ "glow_intensity": 0.0,
+ "primary_color": Color.WHITE,
+ "secondary_color": Color.WHITE,
+ "pulse_speed": 1.0,
+ "particle_rate": 0,
+ "has_trail": false,
+ "swirl_enabled": false
+ }
diff --git a/project/socket_glow_fx_system.gd.uid b/project/socket_glow_fx_system.gd.uid
new file mode 100644
index 00000000..9af9c9d7
--- /dev/null
+++ b/project/socket_glow_fx_system.gd.uid
@@ -0,0 +1 @@
+uid://upnn5jlab615
diff --git a/project/solo_guild_domain_system.gd b/project/solo_guild_domain_system.gd
new file mode 100644
index 00000000..4d974fb3
--- /dev/null
+++ b/project/solo_guild_domain_system.gd
@@ -0,0 +1,190 @@
+# solo_guild_domain_system.gd —— Metin2 40250 离线单机个人公会领地与专属建筑建造 1:1
+# 对照 40250 服务端 guild.cpp, guild_manager.cpp, building.cpp
+class_name SoloGuildDomainSystem
+extends RefCounted
+
+signal domain_claimed(guild_name: String, cost: int)
+signal building_constructed(btype: int, bname: String, level: int)
+signal building_upgraded(btype: int, new_level: int)
+signal guild_refine_result(success: bool, new_level: int)
+
+enum BuildingType {
+ BLACKSMITH = 1, # 公会神兵铁匠 (强化成功率 +10%)
+ ALCHEMIST = 2, # 公会炼金熔炉 (原矿熔炼成功率 70%)
+ GUARDIAN_ALTAR = 3 # 公会守护神坛 (生命 +1500, 经验 +10%)
+}
+
+const DOMAIN_COST := 20000000 # 2000 万金币
+const MIN_GUILD_LEVEL := 10 # 公会等级门槛
+
+const BUILDING_BLUEPRINTS: Dictionary = {
+ BuildingType.BLACKSMITH: {
+ "name": "公会神兵铁匠铺",
+ "cost": 10000000,
+ "wood": 100,
+ "stone": 100,
+ "max_level": 3
+ },
+ BuildingType.ALCHEMIST: {
+ "name": "公会炼金大师熔炉",
+ "cost": 8000000,
+ "wood": 80,
+ "stone": 80,
+ "max_level": 3
+ },
+ BuildingType.GUARDIAN_ALTAR: {
+ "name": "公会守护天龙神坛",
+ "cost": 15000000,
+ "wood": 150,
+ "stone": 150,
+ "max_level": 3
+ }
+}
+
+var has_domain: bool = false
+var guild_name: String = ""
+var guild_level: int = 1
+var constructed_buildings: Dictionary = {} # btype -> { "level": int, "name": String }
+
+# 开辟专属公会领地
+func claim_domain(gname: String, glevel: int, player_data: Dictionary) -> Dictionary:
+ if has_domain:
+ return {"ok": false, "reason": "ALREADY_HAS_DOMAIN", "msg": "你已拥有一片公会领地!"}
+
+ if glevel < MIN_GUILD_LEVEL:
+ return {"ok": false, "reason": "GUILD_LEVEL_TOO_LOW", "msg": "开辟领地需要公会等级达到 10 级!"}
+
+ var gold: int = int(player_data.get("gold", 0))
+ if gold < DOMAIN_COST:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "金币不足!需要 20,000,000 金币买下领地地契。"}
+
+ player_data["gold"] = gold - DOMAIN_COST
+ has_domain = true
+ guild_name = gname
+ guild_level = glevel
+ constructed_buildings.clear()
+
+ domain_claimed.emit(guild_name, DOMAIN_COST)
+
+ return {
+ "ok": true,
+ "guild_name": guild_name,
+ "cost": DOMAIN_COST,
+ "remaining_gold": player_data["gold"],
+ "msg": "【%s】公会专属领地落成!城堡旗帜高高飘扬!" % guild_name
+ }
+
+# 建造领地建筑
+func construct_building(btype: int, player_data: Dictionary) -> Dictionary:
+ if not has_domain:
+ return {"ok": false, "reason": "NO_DOMAIN", "msg": "尚未开辟领地!"}
+
+ if not BUILDING_BLUEPRINTS.has(btype):
+ return {"ok": false, "reason": "INVALID_BUILDING_TYPE"}
+
+ if constructed_buildings.has(btype):
+ return {"ok": false, "reason": "ALREADY_CONSTRUCTED", "msg": "该建筑已建造完成!可选择升级。"}
+
+ var bp = BUILDING_BLUEPRINTS[btype]
+ var gold: int = int(player_data.get("gold", 0))
+ var cost: int = bp["cost"]
+
+ if gold < cost:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "建造金币不足!需要 %d 金币。" % cost}
+
+ player_data["gold"] = gold - cost
+ constructed_buildings[btype] = {
+ "type": btype,
+ "name": bp["name"],
+ "level": 1
+ }
+
+ building_constructed.emit(btype, bp["name"], 1)
+
+ return {
+ "ok": true,
+ "type": btype,
+ "name": bp["name"],
+ "level": 1,
+ "msg": "【%s】在公会领地拔地而起,正式竣工!" % bp["name"]
+ }
+
+# 升级领地建筑
+func upgrade_building(btype: int, player_data: Dictionary) -> Dictionary:
+ if not constructed_buildings.has(btype):
+ return {"ok": false, "reason": "NOT_CONSTRUCTED_YET"}
+
+ var b = constructed_buildings[btype]
+ var cur_lvl: int = b["level"]
+ var bp = BUILDING_BLUEPRINTS[btype]
+
+ if cur_lvl >= bp["max_level"]:
+ return {"ok": false, "reason": "MAX_LEVEL_REACHED", "msg": "该建筑已达最高等级 (3级)!"}
+
+ var upgrade_cost: int = int(bp["cost"] * 0.8)
+ var gold: int = int(player_data.get("gold", 0))
+ if gold < upgrade_cost:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "升级金币不足!需要 %d 金币。" % upgrade_cost}
+
+ player_data["gold"] = gold - upgrade_cost
+ b["level"] = cur_lvl + 1
+
+ building_upgraded.emit(btype, b["level"])
+
+ return {
+ "ok": true,
+ "type": btype,
+ "new_level": b["level"],
+ "msg": "【%s】成功升级至等级 %d!" % [b["name"], b["level"]]
+ }
+
+# 公会铁匠专属精炼 (+10% 强化成功率)
+func guild_blacksmith_refine(item: Dictionary, player_data: Dictionary, force_success: bool = false) -> Dictionary:
+ if not constructed_buildings.has(BuildingType.BLACKSMITH):
+ return {"ok": false, "reason": "BLACKSMITH_NOT_BUILT", "msg": "领地尚未建造公会铁匠铺!"}
+
+ var cur_refine: int = int(item.get("refine_level", 0))
+ if cur_refine >= 9:
+ return {"ok": false, "reason": "ALREADY_MAX_REFINE", "msg": "装备强化等级已达 +9 极限!"}
+
+ # 官方基准成功率 + 公会铁匠额外 +10% 成功率 (随铁匠等级提升)
+ var b_lvl: int = constructed_buildings[BuildingType.BLACKSMITH]["level"]
+ var bonus_rate: int = 10 + (b_lvl - 1) * 3 # Lv1: +10%, Lv2: +13%, Lv3: +16%
+
+ var base_rates := [100, 90, 80, 70, 60, 50, 40, 30, 20] # +1~+9
+ var rate = min(100, base_rates[cur_refine] + bonus_rate)
+
+ var roll = randi_range(1, 100)
+ var is_succ = force_success or (roll <= rate)
+
+ if is_succ:
+ item["refine_level"] = cur_refine + 1
+ guild_refine_result.emit(true, item["refine_level"])
+ return {
+ "ok": true,
+ "success": true,
+ "new_level": item["refine_level"],
+ "msg": "公会神兵铁匠挥锤如电!精炼成功,装备提升至 +%d!" % item["refine_level"]
+ }
+ else:
+ # 失败降 1 级 (最低 0)
+ item["refine_level"] = max(0, cur_refine - 1)
+ guild_refine_result.emit(false, item["refine_level"])
+ return {
+ "ok": true,
+ "success": false,
+ "new_level": item["refine_level"],
+ "msg": "精炼失败!铁砧迸出火星,装备跌落至 +%d。" % item["refine_level"]
+ }
+
+# 公会守护神坛常驻被动光环
+func get_altar_passive_buffs() -> Dictionary:
+ if not constructed_buildings.has(BuildingType.GUARDIAN_ALTAR):
+ return {}
+
+ var b_lvl: int = constructed_buildings[BuildingType.GUARDIAN_ALTAR]["level"]
+ return {
+ "max_hp": 1500 * b_lvl,
+ "exp_rate_pct": 10 * b_lvl,
+ "all_stats": 5 * b_lvl
+ }
diff --git a/project/solo_guild_domain_system.gd.uid b/project/solo_guild_domain_system.gd.uid
new file mode 100644
index 00000000..cec6445f
--- /dev/null
+++ b/project/solo_guild_domain_system.gd.uid
@@ -0,0 +1 @@
+uid://cj03yvptn2sj0
diff --git a/project/soul_stone_grandmaster_system.gd b/project/soul_stone_grandmaster_system.gd
new file mode 100644
index 00000000..3d2b90de
--- /dev/null
+++ b/project/soul_stone_grandmaster_system.gd
@@ -0,0 +1,178 @@
+# soul_stone_grandmaster_system.gd —— Metin2 40250 官方宗师绝技灵魂石悟道与善恶值走火入魔系统 1:1
+# 100% 对照 40250 服务端 training_grandmaster_skill.quest, char_skill.cpp, questlua_pc.cpp
+class_name SoulStoneGrandmasterSystem
+extends RefCounted
+
+signal grandmaster_trained(skill_id: int, skill_name: String, new_level: int, alignment_deducted: int)
+signal grandmaster_failed(skill_id: int, skill_name: String, alignment_penalty: int)
+signal cooldown_cleared()
+signal training_error(reason: String)
+
+# 40250 官方核心道具 Vnum
+const VNUM_SOUL_STONE := 50513 # 灵魂石 (Ruh Taşı / Soul Stone)
+const VNUM_EXORCISM_SCROLL := 71001 # 驱魔卷轴 (清除修炼间隔)
+
+# 技能等级境界常数
+const GRAND_MASTER_MIN_LEVEL := 30 # G1 (30级)
+const PERFECT_MASTER_LEVEL := 40 # P 阶绝顶境界 (40级)
+
+# 最低极限负善恶值 (-20000 封底)
+const MIN_ALIGNMENT_CAP := -20000
+
+# 修炼冷却时间
+var training_cooldown_left: float = 0.0 # 8~12 小时倒计时
+
+# 判断技能是否处于宗师境界 (G1 ~ G10)
+func can_train_skill(skill_level: int) -> bool:
+ return skill_level >= GRAND_MASTER_MIN_LEVEL and skill_level < PERFECT_MASTER_LEVEL
+
+# 计算升级所需善恶值 (40250 官方公式: 1000 + 500 * (level - 30),负道德翻倍)
+func get_required_alignment(skill_level: int, current_alignment: int) -> int:
+ if not can_train_skill(skill_level):
+ return 0
+ var base_cost = 1000 + 500 * (skill_level - GRAND_MASTER_MIN_LEVEL)
+ if current_alignment < 0:
+ return base_cost * 2
+ return base_cost
+
+# 获取技能境界文本 (如 "G1", "G5", "P")
+func get_skill_tier_text(level: int) -> String:
+ if level < GRAND_MASTER_MIN_LEVEL:
+ return "Normal/Master"
+ elif level >= PERFECT_MASTER_LEVEL:
+ return "P 阶 (Perfect Master)"
+ else:
+ return "G%d" % (level - GRAND_MASTER_MIN_LEVEL + 1)
+
+# 使用灵魂石闭关冲击宗师绝技
+func train_grandmaster_skill(
+ inventory: Array,
+ soul_stone_slot: int,
+ skill_id: int,
+ skill_name: String,
+ current_skill_level: int,
+ current_alignment: int,
+ has_exorcism_scroll: bool = false,
+ forced_success: bool = false,
+ forced_roll: int = -1
+) -> Dictionary:
+ # 检查技能是否在 G 阶
+ if not can_train_skill(current_skill_level):
+ training_error.emit("INVALID_SKILL_LEVEL")
+ return {
+ "ok": false,
+ "reason": "INVALID_SKILL_LEVEL",
+ "msg": "只有达到宗师境界 (G1~G10) 的技能方可使用灵魂石闭关突破!"
+ }
+
+ # 检查冷却
+ if training_cooldown_left > 0.0 and not has_exorcism_scroll:
+ training_error.emit("COOLDOWN_ACTIVE")
+ return {
+ "ok": false,
+ "reason": "COOLDOWN_ACTIVE",
+ "msg": "心神尚未平复!需等待 %d 秒方可再次闭关,或使用【驱魔卷轴】重置心境。" % int(training_cooldown_left)
+ }
+
+ # 检查灵魂石
+ if soul_stone_slot < 0 or soul_stone_slot >= inventory.size() or inventory[soul_stone_slot] == null:
+ training_error.emit("NO_SOUL_STONE")
+ return {"ok": false, "reason": "NO_SOUL_STONE", "msg": "未找到灵魂石!"}
+
+ var item = inventory[soul_stone_slot]
+ var vnum = int(item.get("vnum", 0))
+ if vnum != VNUM_SOUL_STONE:
+ training_error.emit("NOT_A_SOUL_STONE")
+ return {"ok": false, "reason": "NOT_A_SOUL_STONE", "msg": "该物品并不是灵魂石!"}
+
+ # 计算所需善恶值
+ var need_align = get_required_alignment(current_skill_level, current_alignment)
+
+ # 检查是否跌破道德底线 (40250: cur_alignment < -19000 + need_alignment)
+ if current_alignment < (MIN_ALIGNMENT_CAP + 1000 + need_align):
+ training_error.emit("ALIGNMENT_TOO_LOW")
+ return {
+ "ok": false,
+ "reason": "ALIGNMENT_TOO_LOW",
+ "msg": "你的罪孽太过深重,心灵已被心魔完全吞噬!必须行善恢复善恶值后方能参悟。"
+ }
+
+ # 消耗灵魂石 (40250 pc.remove_item(50513))
+ _consume_item_one(inventory, soul_stone_slot)
+
+ # 设置下次领悟冷却时间 8~12 小时 (28800 ~ 43200 秒)
+ training_cooldown_left = randf_range(28800.0, 43200.0)
+
+ # 判定成功概率 (40250 官方约为 30% ~ 35%)
+ var r = forced_roll if forced_roll > 0 else randi_range(1, 100)
+ var is_success = (r <= 30) or forced_success
+
+ if is_success:
+ # 悟道成功
+ var new_lvl = current_skill_level + 1
+ var new_alignment = current_alignment - need_align
+ grandmaster_trained.emit(skill_id, skill_name, new_lvl, need_align)
+
+ var tier_str = get_skill_tier_text(new_lvl)
+ var reach_p = (new_lvl >= PERFECT_MASTER_LEVEL)
+ var msg = ""
+ if reach_p:
+ msg = "金光贯顶,大道终成!你的【%s】突破桎梏,登峰造极达成了【%s】绝顶境界!" % [skill_name, tier_str]
+ else:
+ msg = "神识升华,灵智通达!你的【%s】成功精进为【%s】!消耗善恶值 %d 点。" % [skill_name, tier_str, need_align]
+
+ return {
+ "ok": true,
+ "success": true,
+ "new_level": new_lvl,
+ "tier_text": tier_str,
+ "is_perfect_master": reach_p,
+ "alignment_cost": need_align,
+ "new_alignment": new_alignment,
+ "msg": msg
+ }
+ else:
+ # 走火入魔失败:扣除 1/3 ~ 1/2 的善恶值惩罚
+ var penalty_ratio = randf_range(0.33, 0.50)
+ var penalty = max(1, int(floor(need_align * penalty_ratio)))
+ var new_alignment = current_alignment - penalty
+ grandmaster_failed.emit(skill_id, skill_name, penalty)
+
+ return {
+ "ok": true,
+ "success": false,
+ "new_level": current_skill_level,
+ "tier_text": get_skill_tier_text(current_skill_level),
+ "is_perfect_master": false,
+ "alignment_cost": penalty,
+ "new_alignment": new_alignment,
+ "msg": "走火入魔!灵魂石在狂暴的灵气中化为碎粉,未能参悟真谛。惩罚扣除 %d 点善恶值!" % penalty
+ }
+
+# 使用驱魔卷轴清除冷却
+func use_exorcism_scroll() -> void:
+ training_cooldown_left = 0.0
+ cooldown_cleared.emit()
+
+func update(delta: float) -> void:
+ if training_cooldown_left > 0.0:
+ training_cooldown_left = max(0.0, training_cooldown_left - delta)
+
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "cooldown_left": training_cooldown_left
+ }
+
+func deserialize(data: Dictionary) -> void:
+ training_cooldown_left = float(data.get("cooldown_left", 0.0))
diff --git a/project/soul_stone_grandmaster_system.gd.uid b/project/soul_stone_grandmaster_system.gd.uid
new file mode 100644
index 00000000..a0c250f5
--- /dev/null
+++ b/project/soul_stone_grandmaster_system.gd.uid
@@ -0,0 +1 @@
+uid://b6domteea6j7t
diff --git a/project/soulbound_lock_security_system.gd b/project/soulbound_lock_security_system.gd
new file mode 100644
index 00000000..5b3b2835
--- /dev/null
+++ b/project/soulbound_lock_security_system.gd
@@ -0,0 +1,169 @@
+# soulbound_lock_security_system.gd —— Metin2 40250 神装灵魂绑定与防丢安全锁系统 1:1
+# 对照 40250 服务端 char_item.cpp, item.cpp, item_manager.cpp
+class_name SoulboundLockSecuritySystem
+extends RefCounted
+
+signal item_soulbound(slot: int, item_name: String)
+signal unbinding_started(slot: int, item_name: String, duration_seconds: float)
+signal unbinding_cancelled(slot: int, item_name: String)
+signal item_unbound(slot: int, item_name: String)
+
+const VNUM_SOULBIND_SCROLL := 72051 # 灵魂绑定卷轴
+const VNUM_SOULUNBIND_SCROLL := 72052 # 灵魂解绑卷轴
+const DEFAULT_UNBIND_HOURS := 72.0 # 默认 72 小时解绑安全确认期
+
+# 判定物品是否可在红名死亡时掉落 (灵魂绑定绝对免疫掉落)
+static func can_drop_on_death(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ if item.get("is_soulbound", false):
+ return false
+ return true
+
+# 判定物品是否允许丢弃至地面
+static func can_drop_to_ground(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ if item.get("is_soulbound", false):
+ return false
+ return true
+
+# 判定物品是否允许出售给商店
+static func can_sell_to_shop(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ if item.get("is_soulbound", false):
+ return false
+ return true
+
+# 判定物品是否允许放入摆摊货架或交易
+static func can_trade_or_stall(item: Dictionary) -> bool:
+ if item.is_empty():
+ return false
+ if item.get("is_soulbound", false):
+ return false
+ return true
+
+# 为装备施加灵魂绑定 (72051)
+func bind_item(scroll_slot: int, target_slot: int, inventory: Array) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ return {"ok": false, "reason": "INVALID_SCROLL_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+
+ var scroll = inventory[scroll_slot]
+ var target = inventory[target_slot]
+
+ if int(scroll.get("vnum", 0)) != VNUM_SOULBIND_SCROLL:
+ return {"ok": false, "reason": "NOT_A_BIND_SCROLL", "msg": "此物品不是【灵魂绑定卷轴】!"}
+
+ if target.get("is_soulbound", false):
+ return {"ok": false, "reason": "ALREADY_SOULBOUND", "msg": "该装备已处于灵魂绑定保护状态!"}
+
+ # 消耗 1 个绑定卷轴
+ var cnt = int(scroll.get("count", 1))
+ if cnt > 1:
+ scroll["count"] = cnt - 1
+ else:
+ inventory[scroll_slot] = null
+
+ target["is_soulbound"] = true
+ target["unbind_remaining_time"] = 0.0
+
+ var iname: String = target.get("name", "神装")
+ item_soulbound.emit(target_slot, iname)
+
+ return {
+ "ok": true,
+ "slot": target_slot,
+ "item_name": iname,
+ "msg": "【%s】已成功铭刻神龙灵魂烙印!永不掉落、永不遗失!" % iname
+ }
+
+# 发起解绑倒计时 (72052)
+func start_unbinding(scroll_slot: int, target_slot: int, inventory: Array, duration_hours: float = DEFAULT_UNBIND_HOURS) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ return {"ok": false, "reason": "INVALID_SCROLL_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+
+ var scroll = inventory[scroll_slot]
+ var target = inventory[target_slot]
+
+ if int(scroll.get("vnum", 0)) != VNUM_SOULUNBIND_SCROLL:
+ return {"ok": false, "reason": "NOT_AN_UNBIND_SCROLL", "msg": "此物品不是【灵魂解绑卷轴】!"}
+
+ if not target.get("is_soulbound", false):
+ return {"ok": false, "reason": "NOT_SOULBOUND", "msg": "该装备尚未绑定,无需解绑!"}
+
+ if float(target.get("unbind_remaining_time", 0.0)) > 0.0:
+ return {"ok": false, "reason": "UNBIND_ALREADY_IN_PROGRESS", "msg": "该装备已在解绑倒计时中!"}
+
+ # 消耗解绑卷轴
+ var cnt = int(scroll.get("count", 1))
+ if cnt > 1:
+ scroll["count"] = cnt - 1
+ else:
+ inventory[scroll_slot] = null
+
+ var duration_seconds = duration_hours * 3600.0
+ target["unbind_remaining_time"] = duration_seconds
+
+ var iname: String = target.get("name", "神装")
+ unbinding_started.emit(target_slot, iname, duration_seconds)
+
+ return {
+ "ok": true,
+ "slot": target_slot,
+ "item_name": iname,
+ "remaining_seconds": duration_seconds,
+ "msg": "【%s】进入解绑安全期,倒计时 %.1f 小时!在此期间随时可取消。" % [iname, duration_hours]
+ }
+
+# 取消解绑 (防误操作与防盗号保护)
+func cancel_unbinding(target_slot: int, inventory: Array) -> Dictionary:
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var target = inventory[target_slot]
+ if float(target.get("unbind_remaining_time", 0.0)) <= 0.0:
+ return {"ok": false, "reason": "NO_UNBIND_IN_PROGRESS", "msg": "该装备并未处于解绑流程中。"}
+
+ target["unbind_remaining_time"] = 0.0
+ var iname: String = target.get("name", "神装")
+ unbinding_cancelled.emit(target_slot, iname)
+
+ return {
+ "ok": true,
+ "msg": "已终止【%s】的解绑流程,灵魂印记完好无损!" % iname
+ }
+
+# 倒计时结束完成解绑
+func finalize_unbinding(target_slot: int, inventory: Array) -> Dictionary:
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var target = inventory[target_slot]
+ if not target.get("is_soulbound", false):
+ return {"ok": false, "reason": "NOT_SOULBOUND"}
+
+ if float(target.get("unbind_remaining_time", 1.0)) > 0.0:
+ return {"ok": false, "reason": "COOLDOWN_NOT_FINISHED", "msg": "解绑安全冷却期尚未结束!"}
+
+ target["is_soulbound"] = false
+ target["unbind_remaining_time"] = 0.0
+ var iname: String = target.get("name", "神装")
+ item_unbound.emit(target_slot, iname)
+
+ return {
+ "ok": true,
+ "msg": "【%s】灵魂烙印消散,已成功解绑!" % iname
+ }
+
+# 帧心跳更新解绑倒计时
+func update(delta: float, inventory: Array) -> void:
+ for item in inventory:
+ if item != null and item.get("is_soulbound", false):
+ var rem = float(item.get("unbind_remaining_time", 0.0))
+ if rem > 0.0:
+ item["unbind_remaining_time"] = max(0.0, rem - delta)
diff --git a/project/soulbound_lock_security_system.gd.uid b/project/soulbound_lock_security_system.gd.uid
new file mode 100644
index 00000000..04959965
--- /dev/null
+++ b/project/soulbound_lock_security_system.gd.uid
@@ -0,0 +1 @@
+uid://bwqpervxrat04
diff --git a/project/spider_dungeon_key_system.gd b/project/spider_dungeon_key_system.gd
new file mode 100644
index 00000000..ddd9e7a9
--- /dev/null
+++ b/project/spider_dungeon_key_system.gd
@@ -0,0 +1,238 @@
+# spider_dungeon_key_system.gd —— Metin2 40250 经典深渊蜘蛛洞穴通行证与蛛后巢穴密匙系统 1:1
+# 对照 40250 服务端 spider_dungeon_3floor_boss.quest, char_item.cpp, dungeon.cpp
+class_name SpiderDungeonKeySystem
+extends RefCounted
+
+signal ticket_consumed_entry_granted(floor: int, map_name: String)
+signal arachnid_key_crafted(key_count: int)
+signal baroness_lair_opened(time_limit: float)
+signal baroness_slain(drop_items: Array)
+signal lair_closed(success: bool)
+
+# 40250 官方核心道具 Vnum
+const VNUM_PASSAGE_TICKET := 71095 # 蛛洞通行证 (Passage Ticket)
+const VNUM_ARACHNID_KEY := 30324 # 蛛网密匙 (Arachnid Key)
+
+# 40250 蛛网密匙合成原材料
+const VNUM_SPIDER_POISON_SACK := 30025 # 蜘蛛毒囊
+const VNUM_SPIDER_EGG_SACK := 30056 # 蜘蛛卵囊
+const VNUM_SPIDER_EYES := 30057 # 蜘蛛眼睛
+const VNUM_SPIDER_LEGS := 30058 # 蜘蛛腿
+const CRAFT_COST_GOLD := 50000 # 50,000 金币
+
+# NPC Vnum
+const NPC_SPIDER_GUARD := 20089 # 蛛洞入口守卫
+const NPC_SCHOLAR_URIEL:= 20011 # 学者乌列尔 (合成密匙)
+const NPC_LAIR_TOMBSTONE:= 30130 # 蛛后巢穴封印石碑
+
+# Boss Vnum
+const MOB_SPIDER_BARONESS := 2092 # 蜘蛛女皇/蛛后
+const VNUM_CHEST_BARONESS := 50181 # 蛛后宝箱
+
+# 巢穴限时 (20 分钟 / 1200 秒)
+const LAIR_DURATION := 1200.0
+
+var is_lair_active: bool = false
+var lair_time_remaining: float = 0.0
+var baroness_alive: bool = false
+
+# 使用通行证进入蜘蛛洞穴 2层或3层
+func enter_spider_dungeon(floor_target: int, player_level: int, inventory: Array) -> Dictionary:
+ if floor_target != 2 and floor_target != 3:
+ return {"ok": false, "reason": "INVALID_FLOOR", "msg": "只能前往蛛洞二层(SD2)或三层(SD3)!"}
+
+ var min_lv = 40 if floor_target == 2 else 50
+ if player_level < min_lv:
+ return {
+ "ok": false,
+ "reason": "LEVEL_TOO_LOW",
+ "msg": "实力不足!进入蛛洞 %d 层需要等级达到 %d 级。" % [floor_target, min_lv]
+ }
+
+ # 寻找并消耗 1 张通行证 (71095)
+ var ticket_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_PASSAGE_TICKET:
+ ticket_slot = i
+ break
+
+ if ticket_slot == -1:
+ return {"ok": false, "reason": "NO_PASSAGE_TICKET", "msg": "守卫拦住了去路!需要出示【蛛洞通行证】(71095)。"}
+
+ var t_item: Dictionary = inventory[ticket_slot]
+ var cnt := int(t_item.get("count", 1))
+ if cnt > 1:
+ t_item["count"] = cnt - 1
+ else:
+ inventory[ticket_slot] = null
+
+ var target_map = "Spider_Dungeon_2" if floor_target == 2 else "Spider_Dungeon_3"
+ ticket_consumed_entry_granted.emit(floor_target, target_map)
+
+ return {
+ "ok": true,
+ "floor": floor_target,
+ "target_map": target_map,
+ "msg": "检票通过!守卫为您开启了通往【蛛洞 %d 层】的隐秘石门。" % floor_target
+ }
+
+# 在学者乌列尔处精炼合成蛛网密匙 (40250 spider_dungeon_3floor_boss.quest)
+func craft_arachnid_key(inventory: Array, player_gold: Dictionary) -> Dictionary:
+ var current_gold := int(player_gold.get("gold", 0))
+ if current_gold < CRAFT_COST_GOLD:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "精炼费用不足!需要 %d 金币。" % CRAFT_COST_GOLD}
+
+ # 检查 4 大蜘蛛残肢素材
+ var req_vnums = [VNUM_SPIDER_POISON_SACK, VNUM_SPIDER_EGG_SACK, VNUM_SPIDER_EYES, VNUM_SPIDER_LEGS]
+ var slots_to_consume: Array = []
+
+ for req_vnum in req_vnums:
+ var found_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == req_vnum:
+ found_slot = i
+ break
+ if found_slot == -1:
+ return {"ok": false, "reason": "MISSING_MATERIALS", "msg": "合成材料不足!缺少蜘蛛残肢核心原料。"}
+ slots_to_consume.append(found_slot)
+
+ # 寻找放置生成密匙的空位
+ var free_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ free_slot = i
+ break
+
+ # 如果没有全新空位,但材料消耗后会腾出空位也是可行的
+ if free_slot == -1:
+ for s in slots_to_consume:
+ if int(inventory[s].get("count", 1)) == 1:
+ free_slot = s # 可以复用该格
+ break
+
+ if free_slot == -1:
+ return {"ok": false, "reason": "INVENTORY_FULL", "msg": "背包已满,无法容纳新炼制的密匙!"}
+
+ # 消耗材料与金币
+ player_gold["gold"] = current_gold - CRAFT_COST_GOLD
+ for s in slots_to_consume:
+ var it: Dictionary = inventory[s]
+ var c := int(it.get("count", 1))
+ if c > 1:
+ it["count"] = c - 1
+ else:
+ inventory[s] = null
+
+ # 产出蛛网密匙 (30324)
+ inventory[free_slot] = {
+ "vnum": VNUM_ARACHNID_KEY,
+ "name": "蛛网密匙",
+ "count": 1
+ }
+
+ arachnid_key_crafted.emit(1)
+
+ return {
+ "ok": true,
+ "key_vnum": VNUM_ARACHNID_KEY,
+ "slot": free_slot,
+ "msg": "学者乌列尔成功为您炼制了【蛛网密匙】(30324)!"
+ }
+
+# 封印石碑检票开启蛛后巢穴 (40250 NPC 30130)
+func open_baroness_lair(player_level: int, inventory: Array) -> Dictionary:
+ if is_lair_active:
+ return {"ok": false, "reason": "LAIR_ALREADY_ACTIVE", "msg": "蛛后巢穴已在挑战中!"}
+
+ if player_level < 50:
+ return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "挑战蛛后需要角色等级达到 50 级!"}
+
+ # 寻找并消耗蛛网密匙 (30324)
+ var key_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ARACHNID_KEY:
+ key_slot = i
+ break
+
+ if key_slot == -1:
+ return {"ok": false, "reason": "NO_ARACHNID_KEY", "msg": "封印石碑毫无反应!需要镶嵌【蛛网密匙】(30324)。"}
+
+ # 消耗钥匙
+ var k_item: Dictionary = inventory[key_slot]
+ var cnt := int(k_item.get("count", 1))
+ if cnt > 1:
+ k_item["count"] = cnt - 1
+ else:
+ inventory[key_slot] = null
+
+ is_lair_active = true
+ lair_time_remaining = LAIR_DURATION
+ baroness_alive = true
+
+ baroness_lair_opened.emit(LAIR_DURATION)
+
+ return {
+ "ok": true,
+ "duration": LAIR_DURATION,
+ "boss_vnum": MOB_SPIDER_BARONESS,
+ "msg": "石碑封印破除!蛛网密室大门洞开,限时 20 分钟讨伐蜘蛛女皇!"
+ }
+
+# 击败蜘蛛女皇
+func slay_baroness(inventory: Array) -> Dictionary:
+ if not is_lair_active or not baroness_alive:
+ return {"ok": false, "reason": "BARONESS_NOT_ALIVE"}
+
+ baroness_alive = false
+
+ var drops: Array = [
+ {"vnum": VNUM_CHEST_BARONESS, "name": "蛛后宝箱", "count": 1},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 2}
+ ]
+
+ # 放入背包
+ for drop in drops:
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = drop.duplicate(true)
+ break
+
+ baroness_slain.emit(drops)
+
+ # 胜利结束
+ close_lair(true)
+
+ return {
+ "ok": true,
+ "drops": drops,
+ "msg": "诛灭深渊蛛后!成功夺得【蛛后宝箱】!"
+ }
+
+# 关闭巢穴副本
+func close_lair(success: bool) -> void:
+ is_lair_active = false
+ lair_time_remaining = 0.0
+ baroness_alive = false
+ lair_closed.emit(success)
+
+# 时钟心跳推进
+func update(delta: float) -> void:
+ if not is_lair_active:
+ return
+ lair_time_remaining -= delta
+ if lair_time_remaining <= 0.0:
+ close_lair(false) # 超时失败关闭
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_lair_active": is_lair_active,
+ "lair_time_remaining": lair_time_remaining,
+ "baroness_alive": baroness_alive
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_lair_active = bool(data.get("is_lair_active", false))
+ lair_time_remaining = float(data.get("lair_time_remaining", 0.0))
+ baroness_alive = bool(data.get("baroness_alive", false))
diff --git a/project/spider_dungeon_key_system.gd.uid b/project/spider_dungeon_key_system.gd.uid
new file mode 100644
index 00000000..476c476b
--- /dev/null
+++ b/project/spider_dungeon_key_system.gd.uid
@@ -0,0 +1 @@
+uid://chv8kl8y62arl
diff --git a/project/spider_dungeon_system.gd b/project/spider_dungeon_system.gd
new file mode 100644
index 00000000..822dfaaa
--- /dev/null
+++ b/project/spider_dungeon_system.gd
@@ -0,0 +1,161 @@
+# spider_dungeon_system.gd —— Metin2 40250 蜘蛛洞穴三层与男爵夫人巢穴 1:1
+# 对照 40250 服务端 spider_dungeon_3.quest, dungeon.cpp, Boss 2094 & Mob 2095
+class_name SpiderDungeonSystem
+extends RefCounted
+
+signal dungeon_started(duration: float)
+signal egg_destroyed(eggs_left: int, current_atk: int, current_def: int)
+signal minions_summoned()
+signal dungeon_cleared(drops: Array[Dictionary])
+signal dungeon_failed(reason: String)
+
+const NPC_GRAVESTONE := 20371 # 守墓人碑文
+const ITEM_ARACHNID_KEY := 30327 # 蜘蛛之钥
+const BOSS_SPIDER_BARONESS := 2094 # 蜘蛛男爵夫人
+const MOB_SPIDER_EGG := 2095 # 蜘蛛卵
+const ITEM_BARONESS_CHEST := 50157 # 男爵宝箱
+const ITEM_TITAN_SHIELD := 13129 # 泰坦盾牌+9
+
+const REQUIRED_LEVEL := 50
+const DUNGEON_TIME_LIMIT := 1200.0 # 20 分钟 (秒)
+
+const BASE_BARONESS_MAX_HP := 250000
+const BASE_BARONESS_DEF := 400
+const BASE_BARONESS_ATK := 800
+
+var is_active: bool = false
+var remaining_time: float = 0.0
+var eggs_remaining: int = 4
+var baroness_hp: int = 0
+var baroness_max_hp: int = BASE_BARONESS_MAX_HP
+var baroness_def: int = BASE_BARONESS_DEF
+var baroness_atk: int = BASE_BARONESS_ATK
+var minions_spawned: bool = false
+var dungeon_completed: bool = false
+
+# 开启地牢密室 (spider_dungeon_3.quest)
+func enter_dungeon(player_level: int, inventory: Array) -> Dictionary:
+ if is_active:
+ return {"ok": false, "reason": "ALREADY_ACTIVE", "msg": "地牢密室已经在进行中!"}
+ if player_level < REQUIRED_LEVEL:
+ return {"ok": false, "reason": "LEVEL_TOO_LOW", "msg": "等级未满 50 级,无法承受蜘蛛巢穴的剧毒!"}
+
+ # 查找并扣除蜘蛛之钥 (30327)
+ var key_slot := -1
+ for i in range(inventory.size()):
+ var it = inventory[i]
+ if it != null and int(it.get("vnum", 0)) == ITEM_ARACHNID_KEY:
+ key_slot = i
+ break
+
+ if key_slot == -1:
+ return {"ok": false, "reason": "MISSING_KEY", "msg": "进入蜘蛛男爵夫人巢穴需要消耗【蜘蛛钥匙】!"}
+
+ inventory[key_slot] = null
+
+ # 初始化地牢状态
+ is_active = true
+ remaining_time = DUNGEON_TIME_LIMIT
+ eggs_remaining = 4
+ baroness_max_hp = BASE_BARONESS_MAX_HP
+ baroness_hp = baroness_max_hp
+ baroness_def = BASE_BARONESS_DEF
+ baroness_atk = BASE_BARONESS_ATK
+ minions_spawned = false
+ dungeon_completed = false
+
+ dungeon_started.emit(remaining_time)
+
+ return {
+ "ok": true,
+ "time_limit": remaining_time,
+ "eggs_count": eggs_remaining,
+ "baroness_hp": baroness_hp,
+ "baroness_atk": baroness_atk,
+ "baroness_def": baroness_def,
+ "msg": "密室封印已破除!蜘蛛男爵夫人苏醒,你有 20 分钟时间击杀它!"
+ }
+
+# 击碎一枚蜘蛛卵 (狂暴激怒机制: 防御 -15%, 攻击 +20%)
+func destroy_egg() -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
+ if eggs_remaining <= 0:
+ return {"ok": false, "reason": "NO_EGGS_LEFT", "msg": "所有蜘蛛卵均已被击碎!"}
+
+ eggs_remaining -= 1
+ var eggs_broken := 4 - eggs_remaining
+
+ # 每碎 1 卵:防御降低 15% (最高 -60%), 攻击提升 20% (最高 +80%)
+ var def_reduction_pct: float = float(eggs_broken) * 0.15
+ var atk_bonus_pct: float = float(eggs_broken) * 0.20
+
+ baroness_def = int(round(float(BASE_BARONESS_DEF) * (1.0 - def_reduction_pct)))
+ baroness_atk = int(round(float(BASE_BARONESS_ATK) * (1.0 + atk_bonus_pct)))
+
+ egg_destroyed.emit(eggs_remaining, baroness_atk, baroness_def)
+
+ return {
+ "ok": true,
+ "eggs_left": eggs_remaining,
+ "eggs_broken": eggs_broken,
+ "baroness_def": baroness_def,
+ "baroness_atk": baroness_atk,
+ "msg": "蜘蛛卵被击碎!男爵夫人防御大幅削弱,但陷入了极度狂暴!"
+ }
+
+# 攻击男爵夫人
+func damage_baroness(raw_damage: int) -> Dictionary:
+ if not is_active:
+ return {"ok": false, "reason": "DUNGEON_NOT_ACTIVE"}
+ if dungeon_completed:
+ return {"ok": false, "reason": "ALREADY_COMPLETED"}
+
+ var eff_dmg: int = maxi(1, raw_damage - baroness_def)
+ baroness_hp = maxi(0, baroness_hp - eff_dmg)
+
+ # 50% 血量召唤蜘蛛群
+ var triggered_minions := false
+ if not minions_spawned and baroness_hp <= int(baroness_max_hp * 0.5):
+ minions_spawned = true
+ triggered_minions = true
+ minions_summoned.emit()
+
+ if baroness_hp <= 0:
+ dungeon_completed = true
+ is_active = false
+ var drops: Array[Dictionary] = [
+ {"vnum": ITEM_BARONESS_CHEST, "count": 1, "name": "男爵宝箱"},
+ {"vnum": ITEM_TITAN_SHIELD, "count": 1, "name": "泰坦盾牌+9"}
+ ]
+ dungeon_cleared.emit(drops)
+ return {
+ "ok": true,
+ "dead": true,
+ "baroness_hp": 0,
+ "damage_dealt": eff_dmg,
+ "drops": drops,
+ "msg": "蜘蛛男爵夫人轰然倒地!巢穴通关!"
+ }
+
+ return {
+ "ok": true,
+ "dead": false,
+ "baroness_hp": baroness_hp,
+ "damage_dealt": eff_dmg,
+ "minions_summoned": triggered_minions
+ }
+
+# 逐帧更新倒计时
+func update(dt: float) -> Dictionary:
+ if not is_active:
+ return {}
+
+ remaining_time -= dt
+ if remaining_time <= 0.0:
+ remaining_time = 0.0
+ is_active = false
+ dungeon_failed.emit("TIME_OUT")
+ return {"failed": true, "reason": "TIME_OUT", "msg": "挑战超时!你已被移出蜘蛛男爵夫人巢穴!"}
+
+ return {"failed": false, "remaining_time": remaining_time}
diff --git a/project/spider_dungeon_system.gd.uid b/project/spider_dungeon_system.gd.uid
new file mode 100644
index 00000000..752ed3bf
--- /dev/null
+++ b/project/spider_dungeon_system.gd.uid
@@ -0,0 +1 @@
+uid://bi2m3ypw6w5ls
diff --git a/project/status_skill_reset_system.gd b/project/status_skill_reset_system.gd
new file mode 100644
index 00000000..1cdd117f
--- /dev/null
+++ b/project/status_skill_reset_system.gd
@@ -0,0 +1,183 @@
+# status_skill_reset_system.gd —— Metin2 40250 官方属性重修卷轴与忘却之书洗点系统 1:1
+# 对照 40250 服务端 char_item.cpp, char.cpp, root/uipointreset.py
+class_name StatusSkillResetSystem
+extends RefCounted
+
+signal status_reset(stat_name: String, refunded_points: int)
+signal all_status_reset(refunded_points: int)
+signal skill_point_reset(skill_id: int, new_level: int)
+signal all_skills_reset(refunded_points: int)
+
+# 40250 官方重修道具 Vnum
+const VNUM_SKILL_RESET_SCROLL := 71001 # 技能重置卷轴 (全部技能洗点)
+const VNUM_STATUS_REDIST_SCROLL := 71002 # 属性重修卷轴 (单项四维属性洗点)
+const VNUM_ALL_STATUS_RESET_SCROLL := 71003 # 全属性重置卷轴 (血统重塑卷轴)
+
+# 40250 四维职业初始基础属性标准表 (Lv 1 Base)
+const JOB_BASE_STATS: Dictionary = {
+ "warrior": {"vit": 16, "int": 3, "str": 15, "dex": 5},
+ "assassin": {"vit": 14, "int": 3, "str": 8, "dex": 16},
+ "sura": {"vit": 15, "int": 16, "str": 8, "dex": 3},
+ "shaman": {"vit": 15, "int": 16, "str": 5, "dex": 6}
+}
+
+# 单项属性重修 (71002 属性重修卷轴)
+func reset_single_status(stat_name: String, player_data: Dictionary, inventory: Array, item_slot: int) -> Dictionary:
+ var valid_stats = ["vit", "int", "str", "dex"]
+ if not valid_stats.has(stat_name):
+ return {"ok": false, "reason": "INVALID_STAT_NAME", "msg": "无效的属性名称!"}
+
+ if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[item_slot]
+ if int(item.get("vnum", 0)) != VNUM_STATUS_REDIST_SCROLL:
+ return {"ok": false, "reason": "NOT_A_STATUS_REDIST_SCROLL", "msg": "需要使用【属性重修卷轴】(71002)!"}
+
+ var job: String = str(player_data.get("job", "warrior"))
+ var base_stat_table: Dictionary = JOB_BASE_STATS.get(job, JOB_BASE_STATS["warrior"])
+ var base_val: int = int(base_stat_table.get(stat_name, 10))
+ var cur_val: int = int(player_data.get(stat_name, base_val))
+
+ if cur_val <= base_val:
+ return {"ok": false, "reason": "STAT_ALREADY_AT_BASE", "msg": "该属性尚未分配任何点数,无需重置!"}
+
+ var refunded: int = cur_val - base_val
+
+ # 消耗 1 卷
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[item_slot] = null
+
+ player_data[stat_name] = base_val
+ player_data["unused_status_points"] = int(player_data.get("unused_status_points", 0)) + refunded
+
+ status_reset.emit(stat_name, refunded)
+
+ return {
+ "ok": true,
+ "stat_name": stat_name,
+ "refunded_points": refunded,
+ "new_val": base_val,
+ "total_unused": player_data["unused_status_points"],
+ "msg": "成功重置【%s】,全额退还 %d 点属性点!" % [stat_name.to_upper(), refunded]
+ }
+
+# 全属性重置 (71003 全属性重置卷轴)
+func reset_all_status(player_data: Dictionary, inventory: Array, item_slot: int) -> Dictionary:
+ if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[item_slot]
+ if int(item.get("vnum", 0)) != VNUM_ALL_STATUS_RESET_SCROLL:
+ return {"ok": false, "reason": "NOT_AN_ALL_STATUS_RESET_SCROLL", "msg": "需要使用【全属性重置卷轴】(71003)!"}
+
+ var job: String = str(player_data.get("job", "warrior"))
+ var base_stat_table: Dictionary = JOB_BASE_STATS.get(job, JOB_BASE_STATS["warrior"])
+
+ var total_refunded := 0
+ for stat_name in ["vit", "int", "str", "dex"]:
+ var base_val: int = int(base_stat_table.get(stat_name, 10))
+ var cur_val: int = int(player_data.get(stat_name, base_val))
+ if cur_val > base_val:
+ total_refunded += (cur_val - base_val)
+ player_data[stat_name] = base_val
+
+ # 消耗 1 卷
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[item_slot] = null
+
+ player_data["unused_status_points"] = int(player_data.get("unused_status_points", 0)) + total_refunded
+
+ all_status_reset.emit(total_refunded)
+
+ return {
+ "ok": true,
+ "refunded_points": total_refunded,
+ "total_unused": player_data["unused_status_points"],
+ "msg": "血统重塑完成!四维属性恢复初始基础值,共退还 %d 点属性点。" % total_refunded
+ }
+
+# 忘却之书重置单项技能 1 点 (50311~50320 Book of Oblivion)
+func reset_single_skill_point(skill_id: int, player_skills: Dictionary, player_data: Dictionary, inventory: Array, item_slot: int) -> Dictionary:
+ if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var book: Dictionary = inventory[item_slot]
+ var book_vnum := int(book.get("vnum", 0))
+ var book_target_skill := int(book.get("target_skill", 0))
+
+ # 检查是否为忘却之书 (50311~50330)
+ if book_vnum < 50311 or book_vnum > 50330:
+ return {"ok": false, "reason": "NOT_A_BOOK_OF_OBLIVION", "msg": "需要使用对应的【忘却之书】!"}
+
+ if book_target_skill != 0 and book_target_skill != skill_id:
+ return {"ok": false, "reason": "SKILL_ID_MISMATCH", "msg": "此忘却之书与目标技能不符!"}
+
+ var cur_skill_lv := int(player_skills.get(skill_id, 0))
+ if cur_skill_lv <= 0:
+ return {"ok": false, "reason": "SKILL_LEVEL_ZERO", "msg": "该技能尚未习得,无法忘却!"}
+
+ # 40250 官方规则:技能达到 Master (>= 20) 后无法使用普通忘却之书降级
+ if cur_skill_lv >= 20:
+ return {"ok": false, "reason": "SKILL_IS_MASTER", "msg": "已达到大师境界(Master)的技能无法通过普通忘却之书降级!"}
+
+ # 消耗 1 本忘却之书
+ var cnt := int(book.get("count", 1))
+ if cnt > 1:
+ book["count"] = cnt - 1
+ else:
+ inventory[item_slot] = null
+
+ player_skills[skill_id] = cur_skill_lv - 1
+ player_data["unused_skill_points"] = int(player_data.get("unused_skill_points", 0)) + 1
+
+ skill_point_reset.emit(skill_id, player_skills[skill_id])
+
+ return {
+ "ok": true,
+ "skill_id": skill_id,
+ "new_level": player_skills[skill_id],
+ "refunded_points": 1,
+ "total_unused": player_data["unused_skill_points"],
+ "msg": "领悟忘却之书!技能等级降低 1 级,返还 1 点可用技能点。"
+ }
+
+# 技能重置卷轴全技能洗点 (71001)
+func reset_all_skills(player_skills: Dictionary, player_data: Dictionary, inventory: Array, item_slot: int) -> Dictionary:
+ if item_slot < 0 or item_slot >= inventory.size() or inventory[item_slot] == null:
+ return {"ok": false, "reason": "INVALID_SLOT"}
+
+ var item: Dictionary = inventory[item_slot]
+ if int(item.get("vnum", 0)) != VNUM_SKILL_RESET_SCROLL:
+ return {"ok": false, "reason": "NOT_A_SKILL_RESET_SCROLL", "msg": "需要使用【技能重置卷轴】(71001)!"}
+
+ var total_refunded := 0
+ for sk_id in player_skills.keys():
+ var lv := int(player_skills[sk_id])
+ if lv > 0:
+ total_refunded += lv
+ player_skills[sk_id] = 0
+
+ # 消耗 1 卷
+ var cnt := int(item.get("count", 1))
+ if cnt > 1:
+ item["count"] = cnt - 1
+ else:
+ inventory[item_slot] = null
+
+ player_data["unused_skill_points"] = int(player_data.get("unused_skill_points", 0)) + total_refunded
+
+ all_skills_reset.emit(total_refunded)
+
+ return {
+ "ok": true,
+ "refunded_points": total_refunded,
+ "total_unused": player_data["unused_skill_points"],
+ "msg": "技能重置完成!所有技能点已洗净,全额返还 %d 点可用技能点。" % total_refunded
+ }
diff --git a/project/status_skill_reset_system.gd.uid b/project/status_skill_reset_system.gd.uid
new file mode 100644
index 00000000..1758c258
--- /dev/null
+++ b/project/status_skill_reset_system.gd.uid
@@ -0,0 +1 @@
+uid://dynnpji6kpo1j
diff --git a/project/system_menu_ui_test.gd b/project/system_menu_ui_test.gd
index 86a6ecf9..b2317b0a 100644
--- a/project/system_menu_ui_test.gd
+++ b/project/system_menu_ui_test.gd
@@ -140,9 +140,7 @@ func _run() -> void:
sm.open()
_ck(sm.is_open(), "system menu opened")
- (sm._node("mall_button") as BaseButton).pressed.emit()
- _ck(_has_say(fc, "/in_game_mall"), "mall_button -> /in_game_mall")
- _ck(not sm.is_open(), "mall_button closes menu")
+ # mall_button removed in 40250 systemdialog.py (only present in locale variant)
sm.open()
(sm._node("change_button") as BaseButton).pressed.emit()
diff --git a/project/system_setting_system.gd b/project/system_setting_system.gd
new file mode 100644
index 00000000..078bedd7
--- /dev/null
+++ b/project/system_setting_system.gd
@@ -0,0 +1,147 @@
+# system_setting_system.gd —— Metin2 40250 音频视听与战斗选项系统 1:1
+# 对照 40250 服务端与客户端 PythonSystem.cpp, uisystemoption.py, uigameoption.py
+class_name SystemSettingSystem
+extends RefCounted
+
+signal setting_changed(setting_name: String, new_val: Variant)
+signal pk_mode_changed(new_mode: int, mode_name: String)
+
+enum PkMode {
+ PEACE = 0, # 和平
+ REVENGE = 1, # 善恶 (仅可攻击负善恶红名)
+ GUILD = 2, # 阵营 (公会战保护)
+ FREE = 3 # 自由 (无限制)
+}
+
+# 音频设置
+var bgm_volume: float = 0.8:
+ set(v):
+ bgm_volume = clampf(v, 0.0, 1.0)
+ setting_changed.emit("bgm_volume", bgm_volume)
+
+var sfx_volume: float = 0.8:
+ set(v):
+ sfx_volume = clampf(v, 0.0, 1.0)
+ setting_changed.emit("sfx_volume", sfx_volume)
+
+var is_muted: bool = false:
+ set(v):
+ is_muted = v
+ setting_changed.emit("is_muted", is_muted)
+
+# 视距与画质设置
+var camera_distance: float = 25.0:
+ set(v):
+ camera_distance = clampf(v, 15.0, 40.0)
+ setting_changed.emit("camera_distance", camera_distance)
+
+var fog_level: int = 1: # 0: 无, 1: 近, 2: 远
+ set(v):
+ fog_level = clampi(v, 0, 2)
+ setting_changed.emit("fog_level", fog_level)
+
+var shadow_quality: int = 1: # 0: 关, 1: 地面, 2: 全部
+ set(v):
+ shadow_quality = clampi(v, 0, 2)
+ setting_changed.emit("shadow_quality", shadow_quality)
+
+# 战斗飘字与标签显示
+var show_damage_dealt: bool = true
+var show_damage_received: bool = true
+var show_critical_hit: bool = true
+var show_monster_names: bool = true
+var show_player_names: bool = true
+
+# PK 模式
+var current_pk_mode: int = PkMode.PEACE
+
+# 设置 PK 模式
+func set_pk_mode(mode: int) -> Dictionary:
+ if mode < PkMode.PEACE or mode > PkMode.FREE:
+ return {"ok": false, "reason": "INVALID_PK_MODE", "msg": "无效的 PK 模式!"}
+
+ current_pk_mode = mode
+ var mname := get_pk_mode_name(mode)
+ pk_mode_changed.emit(mode, mname)
+
+ return {
+ "ok": true,
+ "mode": mode,
+ "mode_name": mname,
+ "msg": "PK 模式已切换为【%s】!" % mname
+ }
+
+# 获取模式中文名称
+func get_pk_mode_name(mode: int) -> String:
+ match mode:
+ PkMode.PEACE: return "和平模式"
+ PkMode.REVENGE: return "善恶反击模式"
+ PkMode.GUILD: return "阵营公会模式"
+ PkMode.FREE: return "自由对决模式"
+ _: return "未知模式"
+
+# 判定是否可以对指定目标发起攻击 (PK 判定状态机)
+func can_attack_target(
+ target_is_player: bool,
+ target_alignment: int = 0,
+ target_guild_id: int = 0,
+ my_guild_id: int = 0
+) -> bool:
+ # 怪物/NPC 永远可以攻击
+ if not target_is_player:
+ return true
+
+ # 玩家间攻击依据 PK 模式判定
+ match current_pk_mode:
+ PkMode.PEACE:
+ # 和平模式:严禁攻击任何玩家
+ return false
+
+ PkMode.REVENGE:
+ # 善恶模式:只可攻击红名玩家 (善恶值 < 0)
+ return target_alignment < 0
+
+ PkMode.GUILD:
+ # 阵营模式:同公会玩家受到保护,禁止攻击
+ if my_guild_id > 0 and target_guild_id > 0 and my_guild_id == target_guild_id:
+ return false
+ return true
+
+ PkMode.FREE:
+ # 自由模式:可攻击任何玩家
+ return true
+
+ _:
+ return false
+
+# 导出配置项
+func export_settings() -> Dictionary:
+ return {
+ "bgm_volume": bgm_volume,
+ "sfx_volume": sfx_volume,
+ "is_muted": is_muted,
+ "camera_distance": camera_distance,
+ "fog_level": fog_level,
+ "shadow_quality": shadow_quality,
+ "show_damage_dealt": show_damage_dealt,
+ "show_damage_received": show_damage_received,
+ "show_critical_hit": show_critical_hit,
+ "show_monster_names": show_monster_names,
+ "show_player_names": show_player_names,
+ "current_pk_mode": current_pk_mode
+ }
+
+# 导入配置项
+func import_settings(cfg: Dictionary) -> void:
+ if cfg.has("bgm_volume"): bgm_volume = float(cfg["bgm_volume"])
+ if cfg.has("sfx_volume"): sfx_volume = float(cfg["sfx_volume"])
+ if cfg.has("is_muted"): is_muted = bool(cfg["is_muted"])
+ if cfg.has("camera_distance"): camera_distance = float(cfg["camera_distance"])
+ if cfg.has("fog_level"): fog_level = int(cfg["fog_level"])
+ if cfg.has("shadow_quality"): shadow_quality = int(cfg["shadow_quality"])
+ if cfg.has("show_damage_dealt"): show_damage_dealt = bool(cfg["show_damage_dealt"])
+ if cfg.has("show_damage_received"): show_damage_received = bool(cfg["show_damage_received"])
+ if cfg.has("show_critical_hit"): show_critical_hit = bool(cfg["show_critical_hit"])
+ if cfg.has("show_monster_names"): show_monster_names = bool(cfg["show_monster_names"])
+ if cfg.has("show_player_names"): show_player_names = bool(cfg["show_player_names"])
+ if cfg.has("current_pk_mode"): set_pk_mode(int(cfg["current_pk_mode"]))
diff --git a/project/system_setting_system.gd.uid b/project/system_setting_system.gd.uid
new file mode 100644
index 00000000..a25df907
--- /dev/null
+++ b/project/system_setting_system.gd.uid
@@ -0,0 +1 @@
+uid://dycl43xkqqoa3
diff --git a/project/taskbar_dock_test.gd b/project/taskbar_dock_test.gd
new file mode 100644
index 00000000..2ede2311
--- /dev/null
+++ b/project/taskbar_dock_test.gd
@@ -0,0 +1,266 @@
+# taskbar_dock_test.gd —— 验证右下角功能栏及对应窗口(角色、仓库等)40250 对齐
+extends SceneTree
+
+const UiManager = preload("res://ui/ui_manager.gd")
+const CharStatusUI = preload("res://ui/char_status_ui.gd")
+const SafeboxUI = preload("res://ui/safebox_ui.gd")
+const Quickbar = preload("res://ui/quickbar.gd")
+const SkillTable = preload("res://ui/skill_table.gd")
+const Hud = preload("res://hud.gd")
+
+class FakeClient extends Node:
+ signal points_changed(points: Dictionary)
+ signal entity_main_set(vid: int)
+ signal safebox_changed()
+ signal inventory_changed()
+ signal skills_changed()
+ signal quickslots_changed()
+ var _main_vid := 1
+ var _safebox_open := false
+ var calls := []
+ func get_main_vid() -> int: return _main_vid
+ func get_points() -> Dictionary:
+ var pts: Array = []
+ pts.resize(255); pts.fill(0)
+ pts[1] = 50; pts[3] = 1000; pts[4] = 2000; pts[5] = 1000; pts[6] = 1000
+ pts[7] = 200; pts[8] = 200; pts[12] = 40; pts[13] = 30; pts[14] = 20; pts[15] = 10
+ pts[26] = 3; pts[27] = 5
+ return {"points": pts, "hp": 1000, "max_hp": 1000, "sp": 200, "max_sp": 200, "level": 50, "exp": 1000, "next_exp": 2000, "gold": 50000}
+ func get_entity(vid: int) -> Dictionary:
+ return {"name": "WarriorTest", "guild": 0, "race": 0}
+ func get_skill_group() -> int: return 1
+ func get_skills() -> Array:
+ return [{"id": 1, "level": 15, "master": 0}, {"id": 2, "level": 1, "master": 1}]
+ func get_safebox_items() -> Array:
+ return [{"cell": 0, "vnum": 10, "count": 1}, {"cell": 4, "vnum": 27001, "count": 50}]
+ func get_inventory() -> Array:
+ return [{"cell": 0, "vnum": 11200, "count": 1}, {"cell": 1, "vnum": 27001, "count": 50}]
+ func get_safebox_size() -> int: return 3
+ func get_safebox_gold() -> int: return 12345
+ func is_safebox_open() -> bool: return _safebox_open
+ func safebox_close() -> void:
+ _safebox_open = false
+ calls.append("safebox_close")
+ func safebox_checkout(cell: int, win: int, target: int) -> bool:
+ calls.append(["checkout", cell, win, target])
+ return true
+ func say(type: int, text: String) -> bool:
+ calls.append(["say", type, text])
+ return true
+ func get_item(window: int, cell: int) -> Dictionary:
+ if cell == 1:
+ return {"vnum": 27001, "count": 50}
+ return {}
+
+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: taskbar_dock_test (dock buttons + CharacterWindow tabs + SafeboxWindow 40250 parity)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var assets := AssetRoot.path()
+ var ui: CanvasLayer = UiManager.new()
+ get_root().add_child(ui)
+ ui._ready()
+ var fc := FakeClient.new()
+ get_root().add_child(fc)
+
+ var sk_tbl := SkillTable.new()
+ var desc := assets.path_join("locale/locale/en/skilldesc.txt")
+ if FileAccess.file_exists(desc):
+ sk_tbl.load_file(desc)
+
+ # 1. 验证 Quickbar Dock
+ var qb := Quickbar.new()
+ get_root().add_child(qb)
+ qb.setup(fc, sk_tbl, ui, func(): return null, assets)
+
+ var dock := qb.get_dock()
+ _ck(dock != null and is_instance_valid(dock), "taskbar dock exists")
+ _ck(dock.anchor_left == 1.0 and dock.offset_left == -178 and dock.offset_bottom == 37,
+ "TaskBarDock correctly anchored to top-right (offset_left=-178, offset_bottom=37)")
+ var btn_char := qb.get_button("character")
+ var btn_inv := qb.get_button("inventory")
+ var btn_msg := qb.get_button("messenger")
+ var btn_safe := qb.get_button("safebox")
+ var btn_sys := qb.get_button("system")
+ _ck(btn_char != null and btn_char.position == Vector2(34, 3), "CharacterButton at (34, 3)")
+ _ck(btn_inv != null and btn_inv.position == Vector2(68, 3), "InventoryButton at (68, 3)")
+ _ck(btn_msg != null and btn_msg.position == Vector2(102, 3), "MessengerButton at (102, 3)")
+ _ck(btn_safe != null and btn_safe.position == Vector2(0, 3), "SafeboxButton at (0, 3)")
+ _ck(btn_sys != null and btn_sys.position == Vector2(136, 3), "SystemButton at (136, 3)")
+
+ # 1.05 验证 40250 CenterBar 居中定位与槽位显示
+ var cb: Control = qb._root.find_child("CenterBar", true, false)
+ _ck(cb != null and cb.offset_left == -128 and cb.offset_right == 237,
+ "CenterBar horizontally centered at offset_left=-128, offset_right=237")
+ var qb_board: Control = cb.find_child("quickslot_board", true, false) if cb else null
+ _ck(qb_board != null and qb_board.position == Vector2(42, 0), "quickslot_board at (42, 0)")
+ var slot0: Control = qb._slots[0].btn
+ var corner0: TextureRect = slot0.find_child("Corner", true, false) if slot0 else null
+ _ck(corner0 != null and corner0.position == Vector2(3, 3), "Slot 0 corner indicator at (3, 3)")
+
+ # 验证物品装配后显示真实图标与数量,而非 #id 文本
+ qb.assign(0, "item", 1, false)
+ _ck(qb._slots[0].icon.visible and qb._slots[0].icon.texture != null, "Slot 0 item icon loaded")
+ _ck(qb._slots[0].lbl.text == "50", "Slot 0 shows item count 50")
+
+ # 1.1 验证 40250 左下角状态栏 (Gauge_Board + EXP_Gauge_Board)
+ var gb: Control = qb._root.find_child("Gauge_Board", true, false)
+ _ck(gb != null and gb.position == Vector2(0, -10) and gb.size == Vector2(158, 47),
+ "Gauge_Board exists at (0, -10) with size 158x47")
+ var rampage: TextureRect = gb.find_child("RampageGauge", true, false) if gb else null
+ _ck(rampage != null and qb._rampage_frames.size() == 17, "RampageGauge has 17 animation frames")
+ var hp_board: Control = gb.find_child("HPGauge_Board", true, false) if gb else null
+ _ck(hp_board != null and hp_board.size == Vector2(95, 11), "HPGauge_Board exists at 95x11")
+ var hp_rec: ColorRect = hp_board.find_child("HPRecoveryGaugeBar", true, false) if hp_board else null
+ _ck(hp_rec != null, "HPRecoveryGaugeBar exists inside HPGauge_Board")
+ var hp_fill: Control = hp_board.find_child("HPFill", true, false) if hp_board else null
+ _ck(hp_fill != null and hp_fill.clip_contents, "HPFill has clip_contents = true")
+ var hp_img: TextureRect = hp_fill.find_child("HPGauge", true, false) if hp_fill else null
+ _ck(hp_img != null and qb._hp_frames.size() == 7, "HPGauge loaded 7-frame liquid ripple textures")
+ _ck(qb._sp_frames.size() == 7 and qb._st_frames.size() == 7, "SPGauge & STGauge loaded 7-frame textures")
+
+ # 验证 set_hp / set_sp / set_st / set_exp 与 40250 悬停提示
+ qb.set_hp(500, 1000)
+ _ck(hp_board.tooltip_text == "HP : 500 / 1000", "HP tooltip matches 40250 (HP : 500 / 1000)")
+ _ck(hp_fill.size.x == 48, "HP fill width clipped to 48px for 50%")
+ qb.set_sp(100, 200)
+ _ck(qb._sp_board.tooltip_text == "SP : 100 / 200", "SP tooltip matches 40250 (SP : 100 / 200)")
+ _ck(qb._sp_fill.size.x == 48, "SP fill width clipped to 48px for 50%")
+ qb.set_st(50, 100)
+ _ck(qb._st_board.tooltip_text == "ST : 50 / 100", "ST tooltip matches 40250 (ST : 50 / 100)")
+ _ck(qb._st_fill.size.x == 48, "ST fill width clipped to 48px for 50%")
+ qb.set_exp(500, 2000)
+ _ck(qb._exp_board.tooltip_text == "EXP : 25.00%", "EXP tooltip matches 40250 (EXP : 25.00%)")
+ _ck(qb._exp_clips[0].visible and qb._exp_clips[0].size.y == 19, "EXP point 1 fully visible")
+ _ck(not qb._exp_clips[1].visible, "EXP point 2 hidden at 25%")
+
+ # 1.2 验证 Hud 无浮动状态条且正确转发数据至 Quickbar
+ var hud := Hud.new()
+ get_root().add_child(hud)
+ hud._build_status()
+ _ck(hud._status_box == null, "HUD _build_status does not create floating prototype status box")
+ hud.quickbar = qb
+ hud.set_vitals(750, 1000, 180, 200)
+ _ck(qb._cur_hp == 750 and hp_board.tooltip_text == "HP : 750 / 1000", "HUD set_vitals forwarded to Quickbar")
+ hud.set_exp(1000, 2000)
+ _ck(qb._cur_exp == 1000 and qb._exp_board.tooltip_text == "EXP : 50.00%", "HUD set_exp forwarded to Quickbar")
+ hud.queue_free()
+
+ # 2. 验证 CharacterWindow
+ var char_ui := CharStatusUI.new()
+ get_root().add_child(char_ui)
+ char_ui.setup(ui, fc, assets, sk_tbl)
+ char_ui.open("STATUS")
+ _ck(char_ui.is_open(), "CharacterWindow opened")
+
+ # 验证 TitleBar 与 CloseButton
+ var char_tb: Control = char_ui._node("Character_TitleBar")
+ _ck(char_tb != null, "Character_TitleBar exists")
+ var close_btn: Button = char_tb.find_child("CloseButton", true, false) if char_tb else null
+ _ck(close_btn != null, "CloseButton on Character_TitleBar exists")
+
+ # 切换到 SKILL 页签并验证格子与点数
+ char_ui._set_state("SKILL")
+ var skill_page: Control = char_ui._node("Skill_Page")
+ _ck(skill_page != null and skill_page.visible, "Skill_Page visible")
+ var active_slot: Control = char_ui._node("Skill_Active_Slot")
+ _ck(active_slot != null, "Skill_Active_Slot exists")
+ var s1: Control = active_slot.get_node_or_null("slot_1") if active_slot else null
+ _ck(s1 != null, "Skill slot_1 exists")
+ var s1_btn: TextureButton = s1.find_child("Btn", true, false) if s1 else null
+ _ck(s1_btn != null and s1_btn.texture_normal != null, "Skill slot_1 has valid skill icon")
+ var s1_lvl: Label = s1.find_child("Lvl", true, false) if s1 else null
+ _ck(s1_lvl != null and s1_lvl.text == "15", "Skill slot_1 level is 15")
+
+ var etc_slot: Control = char_ui._node("Skill_ETC_Slot")
+ _ck(etc_slot != null, "Skill_ETC_Slot exists")
+ var etc_101: Control = etc_slot.get_node_or_null("slot_101") if etc_slot else null
+ _ck(etc_101 != null, "Skill_ETC_Slot slot_101 exists")
+
+ # 切换到 EMOTICON 页签
+ char_ui._set_state("EMOTICON")
+ var emote_page: Control = char_ui._node("Emoticon_Page")
+ _ck(emote_page != null and emote_page.visible, "Emoticon_Page visible")
+ var solo_slot: Control = char_ui._node("SoloEmotionSlot")
+ _ck(solo_slot != null, "SoloEmotionSlot exists")
+ var e1: Control = solo_slot.get_node_or_null("slot_1") if solo_slot else null
+ _ck(e1 != null, "Emote slot_1 exists")
+ var e1_btn: TextureButton = e1.find_child("Btn", true, false) if e1 else null
+ _ck(e1_btn != null and e1_btn.texture_normal != null, "Emote slot_1 has emote icon")
+
+ # 切换到 QUEST 页签
+ char_ui._set_state("QUEST")
+ var quest_page: Control = char_ui._node("Quest_Page")
+ _ck(quest_page != null and quest_page.visible, "Quest_Page visible")
+
+ # 测试 TitleBar 关闭按钮
+ var quest_tb: Control = char_ui._node("Quest_TitleBar")
+ var q_close: Button = quest_tb.find_child("CloseButton", true, false) if quest_tb else null
+ _ck(q_close != null, "Quest_TitleBar has CloseButton")
+ if q_close:
+ q_close.pressed.emit()
+ _ck(not char_ui.is_open(), "CharacterWindow closed by TitleBar CloseButton")
+
+ # 3. 验证 SafeboxWindow
+ var safe_ui := SafeboxUI.new()
+ get_root().add_child(safe_ui)
+ safe_ui.setup(fc, ui, null, assets)
+ fc._safebox_open = true
+ safe_ui.open()
+ _ck(safe_ui.is_open(), "SafeboxWindow opened")
+ _ck(safe_ui._root.size == Vector2(530, 418), "SafeboxWindow size is 530x418 (got %s)" % str(safe_ui._root.size))
+
+ var safe_tb: Control = safe_ui._root.find_child("TitleBar", true, false)
+ _ck(safe_tb != null, "Safebox TitleBar exists")
+ var safe_title: Label = safe_ui._root.find_child("TitleName", true, false)
+ _ck(safe_title != null and safe_title.text.begins_with("仓库"), "Safebox TitleName is 仓库")
+
+ var safe_close: Button = safe_tb.find_child("CloseButton", true, false) if safe_tb else null
+ _ck(safe_close != null, "Safebox TitleBar has CloseButton")
+
+ var wnd_item: Control = safe_ui._root.find_child("wndItem", true, false)
+ _ck(wnd_item != null and wnd_item.size == Vector2(160, 288), "wndItem 5x9 grid (160x288) exists")
+ var safe_s0: Control = safe_ui._cells.get(0, null)
+ _ck(safe_s0 != null, "Safebox slot 0 exists")
+ var safe_s0_icon: TextureRect = safe_s0.find_child("Icon", true, false) if safe_s0 else null
+ _ck(safe_s0_icon != null and safe_s0_icon.texture != null, "Safebox slot 0 has item icon")
+
+ var safe_s4: Control = safe_ui._cells.get(4, null)
+ _ck(safe_s4 != null, "Safebox slot 4 exists")
+ var safe_s4_cnt: Label = safe_s4.find_child("Count", true, false) if safe_s4 else null
+ _ck(safe_s4_cnt != null and safe_s4_cnt.text == "50", "Safebox slot 4 count label is 50")
+
+ # 右键取出测试
+ safe_ui._on_cell_input(0, InputEventMouseButton.new())
+ var right_click := InputEventMouseButton.new()
+ right_click.button_index = MOUSE_BUTTON_RIGHT
+ right_click.pressed = true
+ safe_ui._on_cell_input(0, right_click)
+ _ck(fc.calls.any(func(c): return c is Array and c.size() > 0 and c[0] == "checkout" and c[1] == 0),
+ "Safebox right click triggers checkout")
+
+ # 分页按钮 (I, II, III)
+ _ck(safe_ui._page_buttons.size() == 3, "Safebox has 3 page buttons")
+ safe_ui._page_buttons[1].pressed.emit()
+ _ck(safe_ui._page == 1, "Switched to page 1 (II)")
+ safe_ui._page_buttons[0].pressed.emit()
+ _ck(safe_ui._page == 0, "Switched back to page 0 (I)")
+
+ # 修改密码按钮
+ _ck(safe_ui._change_pw_btn != null and safe_ui._change_pw_btn.text == "修改密码", "ChangePasswordButton exists")
+ # 关闭按钮
+ _ck(safe_ui._exit_btn != null and safe_ui._exit_btn.text == "关闭", "ExitButton exists")
+ safe_ui._exit_btn.pressed.emit()
+ _ck(not safe_ui.is_open(), "ExitButton closed SafeboxWindow")
diff --git a/project/taskbar_dock_test.gd.uid b/project/taskbar_dock_test.gd.uid
new file mode 100644
index 00000000..5f6b92e7
--- /dev/null
+++ b/project/taskbar_dock_test.gd.uid
@@ -0,0 +1 @@
+uid://bx2g7krknrdm8
diff --git a/project/test_alignment_parity.gd b/project/test_alignment_parity.gd
new file mode 100644
index 00000000..ab3f6be3
--- /dev/null
+++ b/project/test_alignment_parity.gd
@@ -0,0 +1,102 @@
+# test_alignment_parity.gd —— 验证方向 2:人物善恶道德与红名系统 40250 1:1 对齐
+extends SceneTree
+
+const AlignmentSystem = preload("res://alignment_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_alignment_parity (Direction 2: Karma & Alignment 1:1) ===")
+ test_alignment_grades_and_thresholds()
+ test_title_colors_and_names()
+ test_natural_time_recovery()
+ test_kill_monster_karma()
+ test_zen_bean_karma_clearing()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_alignment_grades_and_thresholds() -> void:
+ print("\n--- Test 1: 9 Alignment Grades & Numerical Thresholds ---")
+ _assert(AlignmentSystem.get_alignment_grade(15000) == AlignmentSystem.TITLE_HEROIC, "15000 is Heroic (>= 12000)")
+ _assert(AlignmentSystem.get_alignment_grade(12000) == AlignmentSystem.TITLE_HEROIC, "12000 is Heroic (boundary)")
+ _assert(AlignmentSystem.get_alignment_grade(9500) == AlignmentSystem.TITLE_NOBLE, "9500 is Noble (8000..11999)")
+ _assert(AlignmentSystem.get_alignment_grade(5000) == AlignmentSystem.TITLE_GOOD, "5000 is Good (4000..7999)")
+ _assert(AlignmentSystem.get_alignment_grade(2000) == AlignmentSystem.TITLE_FRIENDLY, "2000 is Friendly (1000..3999)")
+ _assert(AlignmentSystem.get_alignment_grade(500) == AlignmentSystem.TITLE_NEUTRAL, "500 is Neutral (0..999)")
+ _assert(AlignmentSystem.get_alignment_grade(0) == AlignmentSystem.TITLE_NEUTRAL, "0 is Neutral (boundary)")
+ _assert(AlignmentSystem.get_alignment_grade(-2000) == AlignmentSystem.TITLE_AGGRESSIVE, "-2000 is Aggressive (-1..-3999)")
+ _assert(AlignmentSystem.get_alignment_grade(-5000) == AlignmentSystem.TITLE_FRAUDULENT, "-5000 is Fraudulent (-4000..-7999)")
+ _assert(AlignmentSystem.get_alignment_grade(-10000) == AlignmentSystem.TITLE_MALICIOUS, "-10000 is Malicious (-8000..-11999)")
+ _assert(AlignmentSystem.get_alignment_grade(-15000) == AlignmentSystem.TITLE_CRUEL, "-15000 is Cruel (<= -12000)")
+
+func test_title_colors_and_names() -> void:
+ print("\n--- Test 2: Title Names and 40250 RGB Colors ---")
+ _assert(AlignmentSystem.get_title_name(AlignmentSystem.TITLE_HEROIC) == "英雄", "Grade 0 is 英雄")
+ _assert(AlignmentSystem.get_title_name(AlignmentSystem.TITLE_CRUEL) == "魔头", "Grade 8 is 魔头")
+
+ var c_heroic := AlignmentSystem.get_title_color(AlignmentSystem.TITLE_HEROIC)
+ _assert(c_heroic == Color8(0, 204, 255), "Heroic color matches 40250 Color8(0, 204, 255) cyan")
+
+ var c_cruel := AlignmentSystem.get_title_color(AlignmentSystem.TITLE_CRUEL)
+ _assert(c_cruel == Color8(255, 0, 0), "Cruel color matches 40250 Color8(255, 0, 0) red")
+
+ _assert(AlignmentSystem.is_red_name(-100), "-100 is red name (evil)")
+ _assert(not AlignmentSystem.is_red_name(0), "0 is not red name")
+ _assert(AlignmentSystem.is_blue_name(1500), "1500 is blue name (just)")
+
+func test_natural_time_recovery() -> void:
+ print("\n--- Test 3: 40250 Online Natural Time Recovery (char.cpp:1197) ---")
+ # 1. Evil player (-1000) after 10 minutes -> +60 * 10 = +600 -> -400
+ var evil_rec := AlignmentSystem.calculate_time_recovery(-1000, 10, false)
+ _assert(evil_rec == -400, "Evil karma recovered from -1000 to -400 (+60/min)")
+
+ # 2. Evil player with fast item (120/min) after 5 minutes -> +120 * 5 = +600 -> -400
+ var fast_rec := AlignmentSystem.calculate_time_recovery(-1000, 5, true)
+ _assert(fast_rec == -400, "Evil karma with fast item recovered +120/min")
+
+ # 3. Evil natural recovery clamps at 0 (does not automatically become positive just by AFK)
+ var clamp_rec := AlignmentSystem.calculate_time_recovery(-100, 10, false)
+ _assert(clamp_rec == 0, "Evil time recovery clamps at 0")
+
+ # 4. Good player (1000) after 10 minutes -> +5 * 10 = +50 -> 1050
+ var good_rec := AlignmentSystem.calculate_time_recovery(1000, 10, false)
+ _assert(good_rec == 1050, "Good karma increases +5/min")
+
+func test_kill_monster_karma() -> void:
+ print("\n--- Test 4: Monster Kill Karma Growth ---")
+ var normal_kill := AlignmentSystem.on_kill_monster(500, 30, 20) # 30 - 10 = 20
+ _assert(normal_kill == 520, "Monster kill added 20 alignment")
+
+ # Evil players get double recovery rate when killing monsters
+ var evil_kill := AlignmentSystem.on_kill_monster(-1000, 30, 20) # 20 * 2 = 40
+ _assert(evil_kill == -960, "Evil player gets 2x karma on monster kill")
+
+func test_zen_bean_karma_clearing() -> void:
+ print("\n--- Test 5: Zen Bean (70102) Karma Clearing ---")
+ # Zen bean on evil player (-800) clears 500 karma -> -300
+ var res_bean := AlignmentSystem.use_zen_bean(-800)
+ _assert(res_bean.ok and res_bean.new_alignment == -300, "Zen bean reduced evil karma from -800 to -300")
+
+ # Zen bean on slightly evil player (-200) clamps at 0
+ var res_bean_clamp := AlignmentSystem.use_zen_bean(-200)
+ _assert(res_bean_clamp.ok and res_bean_clamp.new_alignment == 0, "Zen bean clamps at 0")
+
+ # Zen bean on neutral or good player is rejected
+ var res_bean_good := AlignmentSystem.use_zen_bean(500)
+ _assert(not res_bean_good.ok and res_bean_good.code == "CANNOT_USE_NOT_EVIL",
+ "Zen bean rejected when player is not evil")
diff --git a/project/test_alignment_parity.gd.uid b/project/test_alignment_parity.gd.uid
new file mode 100644
index 00000000..24ff546b
--- /dev/null
+++ b/project/test_alignment_parity.gd.uid
@@ -0,0 +1 @@
+uid://bfls441vq8ubu
diff --git a/project/test_alignment_pk_parity.gd b/project/test_alignment_pk_parity.gd
new file mode 100644
index 00000000..68837c49
--- /dev/null
+++ b/project/test_alignment_pk_parity.gd
@@ -0,0 +1,149 @@
+# test_alignment_pk_parity.gd —— 40250 善恶值与名望头衔奖惩系统 1:1 回归测试套件
+extends SceneTree
+
+const AlignmentPkSystem = preload("res://alignment_pk_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_alignment_pk_parity (Direction 3: Alignment PK & Titles 1:1) ===")
+ _test_title_tiers_and_thresholds()
+ _test_zen_bean_clearing()
+ _test_safe_zone_time_recovery()
+ _test_red_name_death_drop_penalty()
+ _test_serialization()
+ _finish()
+
+func _test_title_tiers_and_thresholds() -> void:
+ print("\n--- Test 1: Title Tiers & Alignment Thresholds (40250 1:1) ---")
+ var sys := AlignmentPkSystem.new()
+
+ _assert(AlignmentPkSystem.get_title_name(15000) == "崇高骑士", ">= 12000 is Chivalric (崇高骑士)")
+ _assert(AlignmentPkSystem.get_title_name(9000) == "尊贵贵族", ">= 8000 is Noble (尊贵贵族)")
+ _assert(AlignmentPkSystem.get_title_name(5000) == "正义良民", ">= 4000 is Good (正义良民)")
+ _assert(AlignmentPkSystem.get_title_name(1500) == "友好伙伴", ">= 1000 is Friendly (友好伙伴)")
+ _assert(AlignmentPkSystem.get_title_name(500) == "中立行者", "0 ~ 999 is Neutral (中立行者)")
+ _assert(AlignmentPkSystem.get_title_name(-1000) == "好斗狂徒", "-1 ~ -3999 is Aggressive (好斗狂徒)")
+ _assert(AlignmentPkSystem.get_title_name(-5000) == "狡诈恶徒", "-4000 ~ -7999 is Fraudulent (狡诈恶徒)")
+ _assert(AlignmentPkSystem.get_title_name(-9000) == "险恶凶手", "-8000 ~ -11999 is Malicious (险恶凶手)")
+ _assert(AlignmentPkSystem.get_title_name(-15000)== "残忍魔头", "<= -12000 is Cruel (残忍魔头)")
+
+ # Test clamping bounds (-200000 to +200000 real alignment)
+ sys.update_real_alignment(500000)
+ _assert(sys.real_alignment == 200000, "Clamped at MAX_REAL_ALIGNMENT (+200000)")
+ _assert(sys.get_alignment() == 20000, "Display alignment clamped at +20000")
+
+ sys.update_real_alignment(-900000)
+ _assert(sys.real_alignment == -200000, "Clamped at MIN_REAL_ALIGNMENT (-200000)")
+ _assert(sys.get_alignment() == -20000, "Display alignment clamped at -20000")
+
+func _test_zen_bean_clearing() -> void:
+ print("\n--- Test 2: Zen Bean (70102) Sin Clearing ---")
+ var sys := AlignmentPkSystem.new()
+ var inv: Array = [
+ {"vnum": AlignmentPkSystem.VNUM_ZEN_BEAN, "name": "禅豆", "count": 3}
+ ]
+
+ # Set alignment to positive
+ sys.real_alignment = 5000 # +500
+ var res_pos = sys.use_zen_bean(inv, 0)
+ _assert(res_pos["ok"] == false, "Zen bean rejected when alignment is positive")
+ _assert(res_pos["reason"] == "ALIGNMENT_ALREADY_GOOD", "Reason is ALREADY_GOOD")
+ _assert(inv[0]["count"] == 3, "Zen bean not consumed")
+
+ # Set alignment to -1200 (-12000 real)
+ sys.real_alignment = -12000
+ var bean_sig := {"fired": false, "cleared": 0, "align": 0}
+ sys.zen_bean_consumed.connect(func(c: int, a: int):
+ bean_sig["fired"] = true
+ bean_sig["cleared"] = c
+ bean_sig["align"] = a
+ )
+
+ var res_neg = sys.use_zen_bean(inv, 0)
+ _assert(res_neg["ok"] == true, "Zen bean successfully used")
+ _assert(res_neg["cleared"] == 500, "Cleared 500 display points")
+ _assert(sys.get_alignment() == -700, "New alignment is -700 (-1200 + 500)")
+ _assert(inv[0]["count"] == 2, "Zen bean count decremented to 2")
+ _assert(bean_sig["fired"] == true, "zen_bean_consumed signal emitted")
+
+ # Test capping clear to 0 when remaining evil < 500 (e.g. -200)
+ sys.real_alignment = -2000 # -200 display
+ inv[0]["count"] = 1
+ var res_cap = sys.use_zen_bean(inv, 0)
+ _assert(res_cap["ok"] == true, "Zen bean used")
+ _assert(res_cap["cleared"] == 200, "Only cleared remaining 200 points")
+ _assert(sys.get_alignment() == 0, "Alignment restored to 0 (Neutral)")
+ _assert(inv[0] == null, "Last Zen bean consumed completely")
+
+func _test_safe_zone_time_recovery() -> void:
+ print("\n--- Test 3: Safe Zone Time-Based Recovery (40250 char.cpp) ---")
+ var sys := AlignmentPkSystem.new()
+
+ # Evil player: recovers 60 pts/min without item
+ sys.real_alignment = -10000 # -1000 display
+ sys.recover_alignment_by_time(5, false) # +300 pts
+ _assert(sys.get_alignment() == -700, "Recovered 300 points over 5 minutes (60/min)")
+
+ # Evil player: recovers 120 pts/min with item
+ sys.recover_alignment_by_time(5, true) # +600 pts
+ _assert(sys.get_alignment() == -100, "Recovered 600 points over 5 minutes with item (120/min)")
+
+ # Good player: recovers 5 pts/min
+ sys.real_alignment = 10000 # +1000 display
+ sys.recover_alignment_by_time(10, false) # +50 pts
+ _assert(sys.get_alignment() == 1050, "Recovered 50 points over 10 minutes (5/min)")
+
+func _test_red_name_death_drop_penalty() -> void:
+ print("\n--- Test 4: Red Name Death Drop Penalty & Soulbound Exemption ---")
+ var sys := AlignmentPkSystem.new()
+
+ var inv: Array = [
+ {"vnum": 189, "name": "毒剑+9", "is_soulbound": false},
+ {"vnum": 11299, "name": "黑铁甲+9", "is_soulbound": true}, # Soulbound protected!
+ {"vnum": 50300, "name": "技能书", "is_soulbound": false}
+ ]
+
+ # 1. White name (Alignment >= 0): 0 drops
+ sys.real_alignment = 50000
+ var drops_good = sys.handle_death_drop_penalty(inv)
+ _assert(drops_good.is_empty(), "Good player never drops items on death")
+ _assert(inv[0] != null and inv[1] != null and inv[2] != null, "Inventory remains intact")
+
+ # 2. Cruel Red name (Alignment <= -12000): guaranteed drop with 80% chance
+ sys.real_alignment = -150000 # -15000 (Cruel)
+ # Seed random for deterministic test
+ seed(42)
+ var drops_evil = sys.handle_death_drop_penalty(inv)
+ _assert(not drops_evil.is_empty(), "Cruel red name dropped items on death")
+ _assert(inv[1] != null, "Soulbound Black Steel Armor was NOT dropped (immune)")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := AlignmentPkSystem.new()
+ sys1.real_alignment = -85400 # -8540 display (Malicious)
+
+ var data = sys1.serialize()
+ var sys2 := AlignmentPkSystem.new()
+ sys2.deserialize(data)
+ _assert(sys2.real_alignment == -85400, "Restored real alignment")
+ _assert(sys2.get_alignment() == -8540, "Restored display alignment")
+ _assert(sys2.get_title_name(sys2.get_alignment()) == "险恶凶手", "Restored Malicious title")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Alignment PK System Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_alignment_pk_parity.gd.uid b/project/test_alignment_pk_parity.gd.uid
new file mode 100644
index 00000000..9020d5ad
--- /dev/null
+++ b/project/test_alignment_pk_parity.gd.uid
@@ -0,0 +1 @@
+uid://dbu8gisfruo3p
diff --git a/project/test_atlas_navigation_parity.gd b/project/test_atlas_navigation_parity.gd
new file mode 100644
index 00000000..6f5a0105
--- /dev/null
+++ b/project/test_atlas_navigation_parity.gd
@@ -0,0 +1,119 @@
+# test_atlas_navigation_parity.gd —— 40250 全景大地图与寻路导航回归测试
+extends SceneTree
+
+const SystemClass = preload("res://atlas_navigation_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_atlas_navigation_parity (Batch 28 Direction 1) ===")
+ _test_atlas_toggle_and_map_registry()
+ _test_zoom_and_pan_controls()
+ _test_npc_directory_indexing()
+ _test_signal_point_and_navigation_vector()
+ _test_coordinate_projection()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_atlas_toggle_and_map_registry() -> void:
+ print("\n--- Test 1: M Key Toggle & Map Registry ---")
+ var nav = SystemClass.new("metin2_map_a1")
+
+ _assert(nav.is_atlas_visible == false, "Atlas initially hidden")
+
+ # Toggle M
+ var state1 = nav.toggle_atlas()
+ _assert(state1 == true and nav.is_atlas_visible == true, "Atlas toggled open on M key")
+
+ var state2 = nav.toggle_atlas()
+ _assert(state2 == false and nav.is_atlas_visible == false, "Atlas toggled closed on second M key")
+
+ # Map switching
+ var sw_ok = nav.set_map("metin2_map_milgyo")
+ _assert(sw_ok == true, "Switched map to metin2_map_milgyo")
+ var info = nav.get_current_map_info()
+ _assert(info["name"] == "幽冥神殿 (Dark Temple)", "Map registry metadata correctly fetched")
+
+ var sw_fail = nav.set_map("invalid_map_id")
+ _assert(sw_fail == false, "Reject invalid map id")
+
+func _test_zoom_and_pan_controls() -> void:
+ print("\n--- Test 2: Zoom and Pan Clamping ---")
+ var nav = SystemClass.new()
+
+ # Zoom clamping (0.5 to 3.0)
+ nav.set_zoom(0.2)
+ _assert(nav.zoom_level == 0.5, "Zoom clamped at lower bound 0.5x")
+
+ nav.set_zoom(5.0)
+ _assert(nav.zoom_level == 3.0, "Zoom clamped at upper bound 3.0x")
+
+ nav.set_zoom(1.5)
+ _assert(nav.zoom_level == 1.5, "Zoom set to 1.5x")
+
+ # Pan accumulation
+ nav.apply_pan(Vector2(20, -10))
+ _assert(nav.pan_offset == Vector2(20, -10), "Pan offset accumulated")
+
+func _test_npc_directory_indexing() -> void:
+ print("\n--- Test 3: NPC Directory Indexing & Filtering ---")
+ var nav = SystemClass.new()
+
+ var all_npcs = nav.get_npcs()
+ _assert(all_npcs.size() >= 10, "Default town contains at least 10 key NPCs")
+
+ var smiths = nav.get_npcs("Smith")
+ _assert(smiths.size() == 1 and smiths[0]["vid"] == 20016, "Filtered single blacksmith (VID 20016)")
+
+ var shops = nav.get_npcs("Shop")
+ _assert(shops.size() == 3, "Filtered 3 shops (Weapon, Armor, General)")
+
+ var bio = nav.find_npc_by_vid(20084)
+ _assert(bio.get("name").contains("生物学家"), "Found Biologist by VID 20084")
+
+func _test_signal_point_and_navigation_vector() -> void:
+ print("\n--- Test 4: Signal Point Waypoint & Compass Bearing ---")
+ var nav = SystemClass.new()
+
+ # Add signal waypoint
+ var sig = nav.add_signal_point(600, 600)
+ _assert(nav.signal_points.size() == 1, "Signal waypoint added")
+ _assert(sig["x"] == 600 and sig["y"] == 600, "Signal waypoint coordinates match (600, 600)")
+
+ # Calculate compass bearing: Player at (500, 600) -> Target at (600, 600)
+ var nav_vec = nav.calculate_navigation_vector(Vector2(500, 600), Vector2(600, 600))
+ _assert(nav_vec["distance"] == 100.0, "Distance is exactly 100")
+ _assert(is_equal_approx(nav_vec["angle_deg"], 0.0), "Heading is exactly 0 degrees (East)")
+ _assert(nav_vec["arrived"] == false, "Not arrived (dist 100 > 30)")
+
+ # Arrival test: Player at (590, 600) -> Target at (600, 600)
+ var nav_arrived = nav.calculate_navigation_vector(Vector2(590, 600), Vector2(600, 600))
+ _assert(nav_arrived["distance"] == 10.0, "Distance is 10")
+ _assert(nav_arrived["arrived"] == true, "Arrival detected when dist <= 30")
+
+ # Clear all signals
+ nav.clear_all_signals()
+ _assert(nav.signal_points.is_empty(), "All signal waypoints cleared")
+
+func _test_coordinate_projection() -> void:
+ print("\n--- Test 5: World to Atlas UI Coordinate Projection ---")
+ var nav = SystemClass.new("metin2_map_a1") # 4000x4000 world -> 256x256 atlas
+ nav.set_zoom(1.0)
+
+ # World center (2000, 2000) -> Atlas center (128, 128)
+ var projected = nav.world_to_atlas_coord(Vector2(2000, 2000))
+ _assert(is_equal_approx(projected.x, 128.0) and is_equal_approx(projected.y, 128.0), "Projected world center (2000, 2000) to atlas (128, 128)")
diff --git a/project/test_atlas_navigation_parity.gd.uid b/project/test_atlas_navigation_parity.gd.uid
new file mode 100644
index 00000000..760abca8
--- /dev/null
+++ b/project/test_atlas_navigation_parity.gd.uid
@@ -0,0 +1 @@
+uid://carj6kfhf47rv
diff --git a/project/test_attach_metin_parity.gd b/project/test_attach_metin_parity.gd
new file mode 100644
index 00000000..6376f0a5
--- /dev/null
+++ b/project/test_attach_metin_parity.gd
@@ -0,0 +1,92 @@
+# test_attach_metin_parity.gd
+# 检验 40250 官方装备灵魂石镶嵌与双重属性预览系统
+extends SceneTree
+
+const AttachMetinDialogSystem = preload("res://attach_metin_dialog_system.gd")
+
+func _init() -> void:
+ print("--- 开始测试 40250 装备灵魂石镶嵌系统 (test_attach_metin_parity) ---")
+ var system = AttachMetinDialogSystem.new()
+ var fails: int = 0
+
+ # 1. 槽位类型合法性 CanAttachMetin 测试
+ # METIN_NORMAL 应该能嵌入 SILVER(1) 和 GOLD(2),但不能嵌入 NONE(0) 或已经有石头的槽位(28430)
+ if not system.can_attach_metin(AttachMetinDialogSystem.METIN_SOCKET_TYPE_SILVER, AttachMetinDialogSystem.METIN_NORMAL):
+ printerr("FAIL: 普通灵魂石无法嵌入银孔!")
+ fails += 1
+ if not system.can_attach_metin(AttachMetinDialogSystem.METIN_SOCKET_TYPE_GOLD, AttachMetinDialogSystem.METIN_NORMAL):
+ printerr("FAIL: 普通灵魂石无法嵌入金孔!")
+ fails += 1
+ if system.can_attach_metin(AttachMetinDialogSystem.METIN_SOCKET_TYPE_NONE, AttachMetinDialogSystem.METIN_NORMAL):
+ printerr("FAIL: 未开孔槽位不应能嵌入灵魂石!")
+ fails += 1
+
+ # METIN_GOLD 只能嵌入 GOLD(2)
+ if system.can_attach_metin(AttachMetinDialogSystem.METIN_SOCKET_TYPE_SILVER, AttachMetinDialogSystem.METIN_GOLD):
+ printerr("FAIL: 黄金幽魂石不应能嵌入普通银孔!")
+ fails += 1
+ if not system.can_attach_metin(AttachMetinDialogSystem.METIN_SOCKET_TYPE_GOLD, AttachMetinDialogSystem.METIN_GOLD):
+ printerr("FAIL: 黄金幽魂石应当能嵌入金孔!")
+ fails += 1
+
+ # 2. 对话框打开与预览槽位替换测试
+ # 假设目标武器当前槽位: [1 (开孔), 1 (开孔), 0 (未开孔)]
+ # 镶嵌 致命之石+4 (28435, METIN_NORMAL)
+ var opened = system.open_dialog(10, 20, 28435, AttachMetinDialogSystem.METIN_NORMAL, 10, [1, 1, 0])
+ if not opened or not system.is_open:
+ printerr("FAIL: 开启镶嵌对话框失败!")
+ fails += 1
+ if system.target_socket_idx != 0:
+ printerr("FAIL: 首个开放槽位索引应为 0,实际为: ", system.target_socket_idx)
+ fails += 1
+ if system.preview_sockets[0] != 28435 or system.preview_sockets[1] != 1 or system.preview_sockets[2] != 0:
+ printerr("FAIL: 预览槽位状态不符,实际: ", system.preview_sockets)
+ fails += 1
+
+ # 3. 接受镶嵌并检查数据包与音效
+ var accepted = system.accept()
+ if not accepted:
+ printerr("FAIL: 确认镶嵌失败!")
+ fails += 1
+ if system.last_packet_sent.get("header") != "HEADER_CG_ITEM_USE_TO_ITEM":
+ printerr("FAIL: 数据包 Header 错误: ", system.last_packet_sent)
+ fails += 1
+ if system.last_sound_played != "sound/ui/metinstone_insert.wav":
+ printerr("FAIL: 镶嵌音效路径不符: ", system.last_sound_played)
+ fails += 1
+
+ # 4. 服务端结算模拟: 成功嵌入 vs 失败破碎 (28960)
+ var sockets_after_success = system.simulate_server_process([1, 1, 0], 0, 28435, true)
+ if sockets_after_success[0] != 28435:
+ printerr("FAIL: 成功镶嵌后槽位未记录石头 VNUM: ", sockets_after_success)
+ fails += 1
+
+ var sockets_after_fail = system.simulate_server_process([1, 1, 0], 0, 28435, false)
+ if sockets_after_fail[0] != AttachMetinDialogSystem.ITEM_BROKEN_METIN_VNUM:
+ printerr("FAIL: 镶嵌失败未生成破碎石块 28960: ", sockets_after_fail)
+ fails += 1
+
+ # 5. 清理碎石卷轴测试
+ var cleaned = system.clean_broken_socket(sockets_after_fail, 0)
+ if cleaned[0] != AttachMetinDialogSystem.METIN_SOCKET_TYPE_SILVER:
+ printerr("FAIL: 清理破碎石块后槽位未恢复银孔状态: ", cleaned)
+ fails += 1
+
+ # 6. 无可用槽位时拒绝开启
+ var no_slot_opened = system.open_dialog(11, 21, 28435, AttachMetinDialogSystem.METIN_NORMAL, 10, [0, 0, 0])
+ if no_slot_opened or system.is_open:
+ printerr("FAIL: 无可用孔位时不应成功开启镶嵌对话框!")
+ fails += 1
+
+ # 7. 石头数据查询
+ var info = system.get_metin_info(28434)
+ if info.bonus_type != "POINT_PENETRATE_PCT" or info.bonus_value != 8:
+ printerr("FAIL: 穿透之石+4 属性解析错误: ", info)
+ fails += 1
+
+ if fails == 0:
+ print("PASS: test_attach_metin_parity (7/7 全部通过)")
+ quit(0)
+ else:
+ printerr("FAILED: test_attach_metin_parity (", fails, " 项失败)")
+ quit(1)
diff --git a/project/test_attach_metin_parity.gd.uid b/project/test_attach_metin_parity.gd.uid
new file mode 100644
index 00000000..a12076a7
--- /dev/null
+++ b/project/test_attach_metin_parity.gd.uid
@@ -0,0 +1 @@
+uid://cuk0clbocgasy
diff --git a/project/test_auto_combat_bot_parity.gd b/project/test_auto_combat_bot_parity.gd
new file mode 100644
index 00000000..0255d246
--- /dev/null
+++ b/project/test_auto_combat_bot_parity.gd
@@ -0,0 +1,168 @@
+# test_auto_combat_bot_parity.gd —— 40250 自动战斗猎杀挂机助手 1:1 回归测试套件
+extends SceneTree
+
+const AutoCombatBotSystem = preload("res://auto_combat_bot_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_auto_combat_bot_parity (Direction 2: Auto Combat Bot 1:1) ===")
+ _test_bot_toggle()
+ _test_auto_potion()
+ _test_auto_target_and_combat()
+ _test_auto_looting()
+ _finish()
+
+func _test_bot_toggle() -> void:
+ print("\n--- Test 1: Bot Toggle State ---")
+ var bot := AutoCombatBotSystem.new()
+
+ var toggle_sig := {"fired": false, "enabled": false}
+ bot.bot_toggled.connect(func(en: bool):
+ toggle_sig["fired"] = true
+ toggle_sig["enabled"] = en
+ )
+
+ var res1 = bot.toggle_bot(true)
+ _assert(res1["enabled"] == true and bot.is_enabled == true, "Bot enabled")
+ _assert(toggle_sig["fired"] == true and toggle_sig["enabled"] == true, "bot_toggled emitted true")
+
+ bot.toggle_bot(false)
+ _assert(bot.is_enabled == false, "Bot disabled")
+
+func _test_auto_potion() -> void:
+ print("\n--- Test 2: Smart Auto Potion (HP/SP Threshold) ---")
+ var bot := AutoCombatBotSystem.new()
+ bot.toggle_bot(true)
+
+ var player := {
+ "hp": 400,
+ "max_hp": 1000, # 40% < 50% threshold
+ "sp": 800,
+ "max_sp": 1000, # 80% > 40% threshold (no SP potion)
+ "pos": Vector3.ZERO
+ }
+ var inv: Array = [
+ {"vnum": 27003, "name": "红色药水(特大)", "count": 2},
+ {"vnum": 27006, "name": "蓝色药水(特大)", "count": 2}
+ ]
+
+ var pot_events := []
+ bot.potion_used.connect(func(v: int, pt: String):
+ pot_events.append({"vnum": v, "type": pt})
+ )
+
+ # Update tick should trigger HP potion only
+ bot.update_bot(0.1, player, inv, [], [])
+ _assert(player["hp"] == 1000, "HP restored (capped at max 1000)")
+ _assert(inv[0]["count"] == 1, "Red potion consumed (2 -> 1)")
+ _assert(pot_events.size() == 1 and pot_events[0]["type"] == "hp", "potion_used emitted for HP")
+
+ # Now drop SP to 20% (< 40%) and reset potion cooldown
+ bot.potion_cooldown = 0.0
+ player["sp"] = 200
+ bot.update_bot(0.1, player, inv, [], [])
+ _assert(player["sp"] == 1000, "SP restored to max 1000")
+ _assert(inv[1]["count"] == 1, "Blue potion consumed (2 -> 1)")
+ _assert(pot_events.size() == 2 and pot_events[1]["type"] == "sp", "potion_used emitted for SP")
+
+func _test_auto_target_and_combat() -> void:
+ print("\n--- Test 3: Auto Target, Move & Skill/Attack Execution ---")
+ var bot := AutoCombatBotSystem.new()
+ bot.toggle_bot(true)
+ bot.assigned_skills = [1] # Assign Triple Slash
+
+ var player := {
+ "atk": 500,
+ "hp": 1000,
+ "max_hp": 1000,
+ "sp": 1000,
+ "max_sp": 1000,
+ "pos": Vector3.ZERO
+ }
+ var mob := {
+ "id": "mob_99",
+ "hp": 2000,
+ "alive": true,
+ "pos": Vector3(8.0, 0.0, 0.0) # 8m away (> 3.0m attack range)
+ }
+
+ var lock_sig := {"fired": false, "id": ""}
+ bot.target_locked.connect(func(tid: String):
+ lock_sig["fired"] = true
+ lock_sig["id"] = tid
+ )
+
+ # 1. Step: Locks target and moves closer
+ var step1 = bot.update_bot(0.5, player, [], [mob], [])
+ _assert(lock_sig["fired"] == true and lock_sig["id"] == "mob_99", "target_locked emitted")
+ _assert(step1["status"] == "moving_to_target", "Bot moving toward target")
+ _assert(player["pos"].x > 0.0, "Player moved along X axis")
+
+ # Move player close (2.0m <= 3.0m attack range)
+ player["pos"] = Vector3(6.5, 0.0, 0.0) # 1.5m distance
+ var sk_sig := {"fired": false}
+ bot.auto_skill_fired.connect(func(_sk: int, _t: String): sk_sig["fired"] = true)
+
+ # 2. Step: Fires skill (Triple Slash, 2.0x dmg = 1000)
+ var step2 = bot.update_bot(0.1, player, [], [mob], [])
+ _assert(step2["status"] == "skill_attack", "Executed skill attack")
+ _assert(step2["damage"] == 1000, "Dealt 1000 skill damage (500 * 2)")
+ _assert(mob["hp"] == 1000, "Mob HP reduced 2000 -> 1000")
+ _assert(sk_sig["fired"] == true, "auto_skill_fired emitted")
+
+ # 3. Step: Skill is now on cooldown, performs basic attack (500 dmg)
+ var step3 = bot.update_bot(0.1, player, [], [mob], [])
+ _assert(step3["status"] == "basic_attack", "Executed basic attack on cooldown")
+ _assert(mob["hp"] == 500, "Mob HP reduced 1000 -> 500")
+
+func _test_auto_looting() -> void:
+ print("\n--- Test 4: Auto Looting Within 8.0m ---")
+ var bot := AutoCombatBotSystem.new()
+ bot.toggle_bot(true)
+
+ var player := {
+ "hp": 1000,
+ "max_hp": 1000,
+ "sp": 1000,
+ "max_sp": 1000,
+ "pos": Vector3.ZERO
+ }
+ var inv: Array = [null, null]
+ var drops: Array = [
+ {"id": 1, "vnum": 71003, "name": "祝福卷轴", "count": 1, "pos": Vector3(3.0, 0.0, 0.0), "looted": false},
+ {"id": 2, "vnum": 50513, "name": "灵魂之石", "count": 1, "pos": Vector3(20.0, 0.0, 0.0), "looted": false} # > 8m
+ ]
+
+ var loot_sig := {"fired": false, "name": ""}
+ bot.auto_loot_collected.connect(func(n: String, _s: int):
+ loot_sig["fired"] = true
+ loot_sig["name"] = n
+ )
+
+ bot.update_bot(0.1, player, inv, [], drops)
+ _assert(drops[0]["looted"] == true, "Drop 1 (3m) was looted")
+ _assert(drops[1]["looted"] == false, "Drop 2 (20m) out of range, not looted")
+ _assert(inv[0]["vnum"] == 71003, "Blessing Scroll placed into slot 0")
+ _assert(loot_sig["fired"] == true and loot_sig["name"] == "祝福卷轴", "auto_loot_collected emitted")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Auto Combat Bot Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Auto Combat Bot parity tests passed!")
+ quit(0)
diff --git a/project/test_auto_combat_bot_parity.gd.uid b/project/test_auto_combat_bot_parity.gd.uid
new file mode 100644
index 00000000..3fb2dc9f
--- /dev/null
+++ b/project/test_auto_combat_bot_parity.gd.uid
@@ -0,0 +1 @@
+uid://dwysm8q3qqvbu
diff --git a/project/test_battle_arena_duel_parity.gd b/project/test_battle_arena_duel_parity.gd
new file mode 100644
index 00000000..2e49132c
--- /dev/null
+++ b/project/test_battle_arena_duel_parity.gd
@@ -0,0 +1,149 @@
+# test_battle_arena_duel_parity.gd —— 40250 官方武斗竞技场与裁判规则 1:1 回归测试套件
+extends SceneTree
+
+const BattleArenaDuelSystem = preload("res://battle_arena_duel_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_battle_arena_duel_parity (Direction 2: Battle Arena Duel 1:1) ===")
+ _test_duel_start_and_countdown()
+ _test_rules_interception()
+ _test_best_of_three_match_progression()
+ _test_straight_sweep_victory()
+ _finish()
+
+func _test_duel_start_and_countdown() -> void:
+ print("\n--- Test 1: Arena Duel Initialization & Countdown ---")
+ var sys := BattleArenaDuelSystem.new()
+ var duel_sig := {"fired": false, "opp": "", "sets": 0}
+ sys.duel_started.connect(func(opp: String, s: int, _t: float):
+ duel_sig["fired"] = true
+ duel_sig["opp"] = opp
+ duel_sig["sets"] = s
+ )
+
+ var start_res = sys.start_duel("warrior", 3, 300.0)
+ _assert(start_res["ok"] == true, "Duel started successfully")
+ _assert(start_res["sets_to_win"] == 2, "Sets to win is 2 (Best of 3)")
+ _assert(sys.match_state == BattleArenaDuelSystem.STATE_COUNTDOWN, "Match in countdown state")
+ _assert(duel_sig["fired"] == true, "duel_started signal fired")
+ _assert(duel_sig["opp"] == "战神武者教头", "Opponent name matches warrior master")
+
+ var round_sig := {"fired": false, "round": 0}
+ sys.round_started.connect(func(r: int):
+ round_sig["fired"] = true
+ round_sig["round"] = r
+ )
+
+ # Update countdown by 3.0s
+ sys.update(3.0)
+ _assert(sys.match_state == BattleArenaDuelSystem.STATE_FIGHTING, "Match entered FIGHTING state after countdown")
+ _assert(round_sig["fired"] == true, "round_started signal fired")
+ _assert(round_sig["round"] == 1, "First round started")
+
+func _test_rules_interception() -> void:
+ print("\n--- Test 2: Potion, Mount, & Boundary Rules Interception ---")
+ var sys := BattleArenaDuelSystem.new()
+ sys.start_duel("assassin", 3, 300.0)
+ sys.update(3.0) # In fighting state
+
+ var violation_sig := {"fired": false, "reason": ""}
+ sys.rule_violation_intercepted.connect(func(r: String):
+ violation_sig["fired"] = true
+ violation_sig["reason"] = r
+ )
+
+ # 1. Potion test (Red Potion 27001)
+ var potion_allowed = sys.use_potion_check(27001)
+ _assert(potion_allowed == false, "Red potion prohibited in arena")
+ _assert(violation_sig["reason"] == "POTION_FORBIDDEN_IN_ARENA", "Reported potion violation")
+
+ # 2. Mount test
+ violation_sig["fired"] = false
+ var mount_allowed = sys.mount_check()
+ _assert(mount_allowed == false, "Mount prohibited in arena")
+ _assert(violation_sig["reason"] == "MOUNT_FORBIDDEN_IN_ARENA", "Reported mount violation")
+
+ # 3. Arena Bounds test (Legal point inside (100, 100))
+ var inside_ok = sys.check_boundary(Vector2(100.0, 100.0))
+ _assert(inside_ok == true, "Position (100, 100) within arena bounds")
+
+ # Out of bounds point (500, 500)
+ violation_sig["fired"] = false
+ var out_ok = sys.check_boundary(Vector2(500.0, 500.0))
+ _assert(out_ok == false, "Position (500, 500) outside arena bounds")
+ _assert(violation_sig["reason"] == "OUT_OF_ARENA_BOUNDS", "Reported boundary violation")
+
+func _test_best_of_three_match_progression() -> void:
+ print("\n--- Test 3: Best-of-3 Match Progression (1-0 -> 1-1 -> 2-1) ---")
+ var sys := BattleArenaDuelSystem.new()
+ sys.start_duel("sura", 3, 300.0)
+ sys.update(3.0) # Round 1 fighting
+
+ var duel_fin_sig := {"fired": false, "winner": "", "reward": {}}
+ sys.duel_finished.connect(func(w: String, _sa: int, _sb: int, rew: Dictionary):
+ duel_fin_sig["fired"] = true
+ duel_fin_sig["winner"] = w
+ duel_fin_sig["reward"] = rew
+ )
+
+ # Round 1: Player beats Sura
+ var r1 = sys.record_defeat("B")
+ _assert(r1["match_finished"] == false, "Match not finished after Round 1")
+ _assert(r1["score_a"] == 1 and r1["score_b"] == 0, "Score is 1 - 0")
+ _assert(r1["current_round"] == 2, "Round 2 is next")
+
+ # Advance countdown for Round 2
+ sys.update(3.0)
+
+ # Round 2: Sura beats Player
+ var r2 = sys.record_defeat("A")
+ _assert(r2["match_finished"] == false, "Match not finished after Round 2")
+ _assert(r2["score_a"] == 1 and r2["score_b"] == 1, "Score is 1 - 1 tied")
+ _assert(r2["current_round"] == 3, "Round 3 is decider")
+
+ # Advance countdown for Round 3
+ sys.update(3.0)
+
+ # Round 3: Player beats Sura
+ var r3 = sys.record_defeat("B")
+ _assert(r3["match_finished"] == true, "Match finished after Round 3")
+ _assert(r3["winner"] == "A", "Grand winner is Player (Side A)")
+ _assert(duel_fin_sig["fired"] == true, "duel_finished signal fired")
+ _assert(duel_fin_sig["reward"]["arena_points"] == 200, "Received 200 arena points")
+ _assert(sys.match_state == BattleArenaDuelSystem.STATE_FINISHED, "State is FINISHED")
+
+func _test_straight_sweep_victory() -> void:
+ print("\n--- Test 4: Straight Sweep (2-0) Clean Finish ---")
+ var sys := BattleArenaDuelSystem.new()
+ sys.start_duel("shaman", 3, 300.0)
+ sys.update(3.0)
+
+ # Round 1 win
+ sys.record_defeat("B")
+ sys.update(3.0)
+
+ # Round 2 win -> reaches 2 sets_to_win
+ var r2 = sys.record_defeat("B")
+ _assert(r2["match_finished"] == true, "Match ends immediately on 2-0 sweep")
+ _assert(r2["winner"] == "A", "Player won 2-0")
+ _assert(sys.score_a == 2 and sys.score_b == 0, "Final score is 2 - 0")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Battle Arena Duel Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_battle_arena_duel_parity.gd.uid b/project/test_battle_arena_duel_parity.gd.uid
new file mode 100644
index 00000000..e767dab7
--- /dev/null
+++ b/project/test_battle_arena_duel_parity.gd.uid
@@ -0,0 +1 @@
+uid://cvws4wvlb12uk
diff --git a/project/test_belt_system_parity.gd b/project/test_belt_system_parity.gd
new file mode 100644
index 00000000..babe57ee
--- /dev/null
+++ b/project/test_belt_system_parity.gd
@@ -0,0 +1,113 @@
+# test_belt_system_parity.gd —— 40250 腰带系统与专属药水背包 1:1 纯净回归测试套件
+extends SceneTree
+
+const BeltSystem = preload("res://belt_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_belt_system_parity (Direction 2: Belt System 1:1) ===")
+ _test_slot_unlock_by_refine_level()
+ _test_potion_only_restriction()
+ _test_capacity_boundary()
+ _test_unequip_and_inventory_transfer()
+ _finish()
+
+func _test_slot_unlock_by_refine_level() -> void:
+ print("\n--- Test 1: Refine-Level Based Slot Unlocking (4 ~ 16 Slots) ---")
+ _assert(BeltSystem.calculate_unlocked_slots(0) == 4, "+0 belt unlocks 4 slots")
+ _assert(BeltSystem.calculate_unlocked_slots(3) == 8, "+3 belt unlocks 8 slots")
+ _assert(BeltSystem.calculate_unlocked_slots(7) == 12, "+7 belt unlocks 12 slots")
+ _assert(BeltSystem.calculate_unlocked_slots(9) == 16, "+9 belt unlocks 16 slots (4x4)")
+
+ var bs := BeltSystem.new()
+ _assert(bs.get_unlocked_slot_count() == 0, "0 slots when no belt equipped")
+
+ bs.equip_belt({"vnum": 18000, "name": "亚麻腰带+0", "refine_level": 0})
+ _assert(bs.get_unlocked_slot_count() == 4, "+0 Linen Belt unlocks 4 slots")
+
+ bs.equip_belt({"vnum": 18039, "name": "王者腰带+9", "refine_level": 9})
+ _assert(bs.get_unlocked_slot_count() == 16, "+9 King Belt unlocks 16 slots")
+
+func _test_potion_only_restriction() -> void:
+ print("\n--- Test 2: Potion-Only Storage Restriction ---")
+ var bs := BeltSystem.new()
+ bs.equip_belt({"vnum": 18000, "name": "皮腰带+3", "refine_level": 3}) # 8 slots
+
+ # 1. Weapon rejected
+ var r_sword = bs.store_item_in_belt(0, {"vnum": 10, "name": "木剑+0", "item_type": "weapon"})
+ _assert(r_sword["ok"] == false and r_sword["reason"] == "NOT_A_POTION", "Weapon rejected from belt")
+
+ # 2. Armor rejected
+ var r_armor = bs.store_item_in_belt(0, {"vnum": 11299, "name": "黑钢甲+9", "item_type": "armor"})
+ _assert(r_armor["ok"] == false and r_armor["reason"] == "NOT_A_POTION", "Armor rejected from belt")
+
+ # 3. Red Potion accepted
+ var r_pot = bs.store_item_in_belt(0, {"vnum": 27003, "name": "红药水(大)", "item_type": "potion", "count": 200})
+ _assert(r_pot["ok"] == true, "Red Potion accepted in belt")
+ _assert(bs.belt_inventory[0]["vnum"] == 27003, "Red potion stored at belt slot 0")
+
+ # 4. Sacred Dew accepted
+ var r_dew = bs.store_item_in_belt(1, {"vnum": 50821, "name": "绿露水", "item_type": "dew", "count": 10})
+ _assert(r_dew["ok"] == true, "Dew accepted in belt")
+
+ # 5. Sun Elixir accepted
+ var r_elixir = bs.store_item_in_belt(2, {"vnum": 72723, "name": "太阳之灵药", "item_type": "elixir", "count": 1})
+ _assert(r_elixir["ok"] == true, "Elixir accepted in belt")
+
+func _test_capacity_boundary() -> void:
+ print("\n--- Test 3: Capacity Boundaries on Locked Slots ---")
+ var bs := BeltSystem.new()
+ bs.equip_belt({"vnum": 18000, "name": "亚麻腰带+0", "refine_level": 0}) # only slots 0..3 open
+
+ var potion := {"vnum": 27003, "name": "红药水", "item_type": "potion", "count": 10}
+
+ # Slot 3 (4th slot) is allowed
+ var r_ok = bs.store_item_in_belt(3, potion)
+ _assert(r_ok["ok"] == true, "Slot 3 allowed on +0 belt")
+
+ # Slot 4 (5th slot) is locked
+ var r_locked = bs.store_item_in_belt(4, potion)
+ _assert(r_locked["ok"] == false and r_locked["reason"] == "SLOT_LOCKED_OR_INVALID", "Slot 4 locked on +0 belt")
+
+func _test_unequip_and_inventory_transfer() -> void:
+ print("\n--- Test 4: Belt Unequip & Potion Eviction to Main Inventory ---")
+ var bs := BeltSystem.new()
+ bs.equip_belt({"vnum": 18000, "name": "腰带+0", "refine_level": 0})
+
+ # Store 2 potions in belt
+ bs.store_item_in_belt(0, {"vnum": 27003, "name": "红药水", "item_type": "potion", "count": 50})
+ bs.store_item_in_belt(1, {"vnum": 27006, "name": "蓝药水", "item_type": "potion", "count": 50})
+
+ # 1. Main inventory completely full -> unequip blocked
+ var full_inv: Array = [{"vnum": 1}, {"vnum": 2}] # no nulls
+ var r_block = bs.unequip_belt(full_inv)
+ _assert(r_block["ok"] == false and r_block["reason"] == "BELT_NOT_EMPTY", "Unequip blocked when main inventory full")
+ _assert(not bs.equipped_belt.is_empty(), "Belt remains equipped")
+
+ # 2. Main inventory has space -> potions transferred and belt unequipped
+ var open_inv: Array = [null, null, null]
+ var r_ok = bs.unequip_belt(open_inv)
+ _assert(r_ok["ok"] == true, "Unequip succeeds with open inventory")
+ _assert(bs.equipped_belt.is_empty(), "Belt unequipped")
+ _assert(open_inv[0] != null and open_inv[0]["vnum"] == 27003, "Red potion transferred to main inventory slot 0")
+ _assert(open_inv[1] != null and open_inv[1]["vnum"] == 27006, "Blue potion transferred to main inventory slot 1")
+ _assert(bs.belt_inventory[0] == null and bs.belt_inventory[1] == null, "Belt inventory cleared")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Belt System Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_belt_system_parity.gd.uid b/project/test_belt_system_parity.gd.uid
new file mode 100644
index 00000000..fd77303f
--- /dev/null
+++ b/project/test_belt_system_parity.gd.uid
@@ -0,0 +1 @@
+uid://bqjdo1qgl77yv
diff --git a/project/test_bgm_jukebox_parity.gd b/project/test_bgm_jukebox_parity.gd
new file mode 100644
index 00000000..dcd98b74
--- /dev/null
+++ b/project/test_bgm_jukebox_parity.gd
@@ -0,0 +1,92 @@
+# test_bgm_jukebox_parity.gd —— 40250 客户端 BGM 点唱机与地图音乐配置回归测试
+extends SceneTree
+
+const SystemClass = preload("res://bgm_jukebox_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_bgm_jukebox_parity (Batch 28 Direction 4) ===")
+ _test_playback_controls()
+ _test_volume_clamping()
+ _test_map_default_transitions()
+ _test_custom_jukebox_overrides()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_playback_controls() -> void:
+ print("\n--- Test 1: BGM Playback Controls & Track Registry ---")
+ var juke = SystemClass.new()
+
+ # 1. Play canonical track
+ var p_ok = juke.play_bgm("open_the_gate.mp3", true)
+ _assert(p_ok == true, "Play canonical track open_the_gate.mp3")
+ _assert(juke.is_playing == true, "Player is playing")
+ _assert(juke.current_track == "open_the_gate.mp3", "Current track matches")
+ _assert(juke.loop_enabled == true, "Loop is enabled")
+
+ # 2. Reject non-canonical track
+ var p_fail = juke.play_bgm("unknown_external_audio.mp3")
+ _assert(p_fail == false, "Reject unregistered track")
+
+ # 3. Stop playback
+ juke.stop_bgm()
+ _assert(juke.is_playing == false, "Playback stopped")
+ _assert(juke.current_track == "", "Current track cleared")
+
+func _test_volume_clamping() -> void:
+ print("\n--- Test 2: Volume Range Clamping (0.0 to 1.0) ---")
+ var juke = SystemClass.new()
+
+ juke.set_volume(-0.5)
+ _assert(juke.volume == 0.0, "Volume clamped at 0.0")
+
+ juke.set_volume(1.8)
+ _assert(juke.volume == 1.0, "Volume clamped at 1.0")
+
+ juke.set_volume(0.65)
+ _assert(is_equal_approx(juke.volume, 0.65), "Volume set to 0.65 (65%)")
+
+func _test_map_default_transitions() -> void:
+ print("\n--- Test 3: Map Default BGM Transitions ---")
+ var juke = SystemClass.new()
+
+ # Enter Login Screen -> enter_the_east.mp3
+ var t_login = juke.on_enter_map("intro_login")
+ _assert(t_login == "enter_the_east.mp3", "Login screen plays enter_the_east.mp3")
+ _assert(juke.current_track == "enter_the_east.mp3", "Current track updated to login theme")
+
+ # Enter Yongbi Desert -> desert.mp3
+ var t_desert = juke.on_enter_map("metin2_map_n_desert_01")
+ _assert(t_desert == "desert.mp3", "Desert map plays desert.mp3")
+ _assert(juke.current_track == "desert.mp3", "Current track updated to desert")
+
+func _test_custom_jukebox_overrides() -> void:
+ print("\n--- Test 4: Jukebox Custom Map Overrides & Restoration ---")
+ var juke = SystemClass.new()
+
+ # Default for a1 is open_the_gate.mp3
+ _assert(juke.get_effective_map_bgm("metin2_map_a1") == "open_the_gate.mp3", "Default a1 track is open_the_gate.mp3")
+
+ # Custom override: change a1 to save_the_world.mp3
+ var o_ok = juke.set_custom_map_bgm("metin2_map_a1", "save_the_world.mp3")
+ _assert(o_ok == true, "Overrode a1 BGM to save_the_world.mp3")
+ _assert(juke.get_effective_map_bgm("metin2_map_a1") == "save_the_world.mp3", "Effective BGM is now custom track")
+
+ # Reset override
+ juke.reset_custom_map_bgm("metin2_map_a1")
+ _assert(juke.get_effective_map_bgm("metin2_map_a1") == "open_the_gate.mp3", "Restored to default track on reset")
diff --git a/project/test_bgm_jukebox_parity.gd.uid b/project/test_bgm_jukebox_parity.gd.uid
new file mode 100644
index 00000000..226506d6
--- /dev/null
+++ b/project/test_bgm_jukebox_parity.gd.uid
@@ -0,0 +1 @@
+uid://blr5x7ej58d4w
diff --git a/project/test_biologist_chaegirab_research_parity.gd b/project/test_biologist_chaegirab_research_parity.gd
new file mode 100644
index 00000000..233bf3f0
--- /dev/null
+++ b/project/test_biologist_chaegirab_research_parity.gd
@@ -0,0 +1,162 @@
+# test_biologist_chaegirab_research_parity.gd —— 40250 生物学者车齿木研究链 1:1 回归测试套件
+extends SceneTree
+
+const BiologistChaegirabResearchSystem = preload("res://biologist_chaegirab_research_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_biologist_chaegirab_research_parity (Direction 1: Biologist Research 1:1) ===")
+ _test_stage_progression_and_sample_validation()
+ _test_sample_completion_and_soul_stone_capstone()
+ _test_subsequent_stages_and_stat_stacking()
+ _test_serialization()
+ _finish()
+
+func _test_stage_progression_and_sample_validation() -> void:
+ print("\n--- Test 1: Stage Requirements & Sample Item Validations ---")
+ var sys := BiologistChaegirabResearchSystem.new()
+ var inv: Array = [
+ {"vnum": 10, "name": "木剑", "count": 1},
+ {"vnum": 30006, "name": "兽人牙齿", "count": 5}
+ ]
+
+ # 1. Level too low (< 30)
+ var res_low = sys.submit_sample(inv, 1, 25)
+ _assert(res_low["ok"] == false, "Rejected: level 25 < 30")
+ _assert(res_low["reason"] == "PLAYER_LEVEL_TOO_LOW", "Reason is PLAYER_LEVEL_TOO_LOW")
+
+ # 2. Wrong sample item
+ var res_wrong = sys.submit_sample(inv, 0, 35)
+ _assert(res_wrong["ok"] == false, "Rejected: wrong item (Wood Sword)")
+ _assert(res_wrong["reason"] == "WRONG_SAMPLE_ITEM", "Reason is WRONG_SAMPLE_ITEM")
+
+ # 3. Valid sample submission (force success)
+ var sub_sig := {"fired": false, "cur": 0}
+ sys.sample_submitted.connect(func(_n: String, _s: bool, c: int, _t: int):
+ sub_sig["fired"] = true
+ sub_sig["cur"] = c
+ )
+
+ var res_succ = sys.submit_sample(inv, 1, 35, true)
+ _assert(res_succ["ok"] == true, "Orc Tooth submitted successfully")
+ _assert(res_succ["current"] == 1, "Progress is 1/10")
+ _assert(inv[1]["count"] == 4, "Orc tooth count decremented 5 -> 4")
+ _assert(sub_sig["fired"] == true, "sample_submitted signal emitted")
+
+func _test_sample_completion_and_soul_stone_capstone() -> void:
+ print("\n--- Test 2: Sample Completion & Capstone Soul Stone Delivery ---")
+ var sys := BiologistChaegirabResearchSystem.new()
+ var player_stats := {"move_speed": 100}
+ var inv: Array = [
+ {"vnum": 30006, "name": "兽人牙齿", "count": 10},
+ {"vnum": 30220, "name": "兽人灵魂石", "count": 1}
+ ]
+
+ # Submit remaining 9 teeth to hit 10/10
+ for i in range(10):
+ sys.submit_sample(inv, 0, 35, true)
+
+ _assert(sys.current_samples == 10, "Collected all 10 Orc Teeth")
+ _assert(sys.waiting_for_soul_stone == true, "Now waiting for soul stone")
+
+ # Cannot submit more teeth
+ inv[0] = {"vnum": 30006, "name": "兽人牙齿", "count": 1}
+ var res_excess = sys.submit_sample(inv, 0, 35, true)
+ _assert(res_excess["ok"] == false, "Excess sample submission blocked")
+ _assert(res_excess["reason"] == "WAITING_FOR_SOUL_STONE", "Reason is WAITING_FOR_SOUL_STONE")
+
+ var cap_sig := {"fired": false, "stone": ""}
+ sys.capstone_submitted.connect(func(sn: String, _st: String):
+ cap_sig["fired"] = true
+ cap_sig["stone"] = sn
+ )
+
+ # Submit wrong soul stone
+ inv[0] = {"vnum": 30221, "name": "诅咒灵魂石", "count": 1}
+ var res_wrong_stone = sys.submit_soul_stone(inv, 0, player_stats)
+ _assert(res_wrong_stone["ok"] == false, "Wrong soul stone rejected")
+ _assert(res_wrong_stone["reason"] == "WRONG_SOUL_STONE", "Reason is WRONG_SOUL_STONE")
+
+ # Submit valid Orc Soul Stone (slot 1)
+ var res_stone_succ = sys.submit_soul_stone(inv, 1, player_stats)
+ _assert(res_stone_succ["ok"] == true, "Orc Soul Stone submitted")
+ _assert(player_stats["move_speed"] == 110, "Permanent move speed +10 applied (100 -> 110)")
+ _assert(sys.permanent_bonuses["move_speed"] == 10, "Permanent bonus recorded")
+ _assert(sys.current_stage_index == 1, "Advanced to Stage 2 (Curse Book)")
+ _assert(sys.current_samples == 0, "Current samples reset to 0")
+ _assert(sys.waiting_for_soul_stone == false, "No longer waiting for soul stone")
+ _assert(cap_sig["fired"] == true, "capstone_submitted signal emitted")
+
+func _test_subsequent_stages_and_stat_stacking() -> void:
+ print("\n--- Test 3: Subsequent Stages (Curse Book & Demon Keepsake) Stacking ---")
+ var sys := BiologistChaegirabResearchSystem.new()
+ var player_stats := {"move_speed": 100, "cast_speed_pct": 0, "defense": 200}
+
+ # Stage 1: Complete Orc Tooth (+10 move speed)
+ var inv1: Array = [
+ {"vnum": 30006, "name": "兽人牙齿", "count": 10},
+ {"vnum": 30220, "name": "兽人灵魂石", "count": 1}
+ ]
+ for i in range(10):
+ sys.submit_sample(inv1, 0, 30, true)
+ sys.submit_soul_stone(inv1, 1, player_stats)
+
+ # Stage 2: Complete Curse Book (+5% cast speed)
+ var inv2: Array = [
+ {"vnum": 30047, "name": "诅咒之书", "count": 15},
+ {"vnum": 30221, "name": "诅咒灵魂石", "count": 1}
+ ]
+ for i in range(15):
+ sys.submit_sample(inv2, 0, 40, true)
+ sys.submit_soul_stone(inv2, 1, player_stats)
+
+ # Stage 3: Complete Demon Keepsake (+60 def)
+ var inv3: Array = [
+ {"vnum": 30015, "name": "恶魔纪念物", "count": 15},
+ {"vnum": 30222, "name": "恶魔灵魂石", "count": 1}
+ ]
+ for i in range(15):
+ sys.submit_sample(inv3, 0, 50, true)
+ sys.submit_soul_stone(inv3, 1, player_stats)
+
+ _assert(player_stats["move_speed"] == 110, "Stage 1 bonus active (+10)")
+ _assert(player_stats["cast_speed_pct"] == 5, "Stage 2 bonus active (+5%)")
+ _assert(player_stats["defense"] == 260, "Stage 3 bonus active (+60)")
+ _assert(sys.completed_stages.size() == 3, "3 stages completed in total")
+
+func _test_serialization() -> void:
+ print("\n--- Test 4: Serialization & Restoration ---")
+ var sys1 := BiologistChaegirabResearchSystem.new()
+ sys1.current_stage_index = 4 # Stage 5: Zelkova Branch
+ sys1.current_samples = 18
+ sys1.waiting_for_soul_stone = false
+ sys1.completed_stages = [1, 2, 3, 4]
+ sys1.permanent_bonuses = {"move_speed": 10, "cast_speed_pct": 5, "defense": 60, "attack_power": 50}
+
+ var data = sys1.serialize()
+ var sys2 := BiologistChaegirabResearchSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.current_stage_index == 4, "Restored stage index 4")
+ _assert(sys2.current_samples == 18, "Restored samples 18")
+ _assert(sys2.completed_stages.size() == 4, "Restored 4 completed stages")
+ _assert(sys2.permanent_bonuses["attack_power"] == 50, "Restored attack bonus 50")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Biologist Research Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_biologist_chaegirab_research_parity.gd.uid b/project/test_biologist_chaegirab_research_parity.gd.uid
new file mode 100644
index 00000000..728ebd08
--- /dev/null
+++ b/project/test_biologist_chaegirab_research_parity.gd.uid
@@ -0,0 +1 @@
+uid://vuvnbdspji2u
diff --git a/project/test_biologist_quest_parity.gd b/project/test_biologist_quest_parity.gd
new file mode 100644
index 00000000..03c7c5e6
--- /dev/null
+++ b/project/test_biologist_quest_parity.gd
@@ -0,0 +1,140 @@
+# test_biologist_quest_parity.gd —— 40250 生物学研究家任务链 1:1 纯净回归测试套件
+extends SceneTree
+
+const BiologistQuestSystem = preload("res://biologist_quest_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_biologist_quest_parity (Direction 1: Biologist Quests 1:1) ===")
+ _test_level_unlock()
+ _test_item_submission_and_potion()
+ _test_soul_stone_completion_and_bonuses()
+ _test_full_chain_progression()
+ _finish()
+
+func _test_level_unlock() -> void:
+ print("\n--- Test 1: Level Requirement & Stage Unlock ---")
+ var bio := BiologistQuestSystem.new()
+ bio.init_for_player(25) # Level 25
+ _assert(bio.current_stage_idx == -1, "Level 25 cannot start Lv30 Orc Tooth quest")
+
+ bio.init_for_player(30) # Level 30
+ _assert(bio.current_stage_idx == 0, "Level 30 unlocks Stage 0 (Orc Tooth)")
+ var stage: Dictionary = bio.get_current_stage()
+ _assert(stage["collect_vnum"] == 30006, "Stage 0 requires Orc Tooth (30006)")
+ _assert(stage["collect_count"] == 10, "Stage 0 requires 10 teeth")
+
+func _test_item_submission_and_potion() -> void:
+ print("\n--- Test 2: Item Submission & Spiral Potion (71035) ---")
+ var bio := BiologistQuestSystem.new()
+ bio.init_for_player(30)
+
+ var inv: Array = [
+ {"vnum": 30006}, # slot 0: Orc Tooth
+ {"vnum": 71035}, # slot 1: Spiral Potion
+ null
+ ]
+
+ # Test missing item error
+ var empty_inv: Array = [null, null]
+ var res = bio.submit_item(empty_inv)
+ _assert(res["ok"] == false and res["reason"] == "MISSING_ITEM", "Fails when missing tooth in inventory")
+
+ # Test successful submission with Spiral Potion
+ res = bio.submit_item(inv, true, true)
+ _assert(res["ok"] == true and res["success"] == true, "Submission succeeds with potion")
+ _assert(bio.delivered_count == 1, "Delivered count incremented to 1")
+ _assert(inv[0] == null, "Tooth consumed from slot 0")
+ _assert(inv[1] == null, "Spiral Potion consumed from slot 1")
+ _assert(bio.cooldown_remaining == 0.0, "Cooldown is 0 with potion")
+
+func _test_soul_stone_completion_and_bonuses() -> void:
+ print("\n--- Test 3: Soul Stone Phase & Permanent Stat Bonus ---")
+ var bio := BiologistQuestSystem.new()
+ bio.init_for_player(30)
+ bio.delivered_count = 10
+ bio.soul_stone_phase = true
+
+ var inv: Array = [
+ {"vnum": 30220}, # Jinunggyi's Soul Stone
+ null
+ ]
+
+ # Test without soul stone
+ var res = bio.submit_soul_stone([null])
+ _assert(res["ok"] == false and res["reason"] == "MISSING_SOUL_STONE", "Fails without soul stone")
+
+ # Submit soul stone
+ res = bio.submit_soul_stone(inv)
+ _assert(res["ok"] == true, "Soul stone submitted successfully")
+ _assert(res["completed_stage"] == 0, "Stage 0 completed")
+ _assert(bio.permanent_bonuses["move_speed"] == 10, "Permanent move speed +10 granted")
+ _assert(inv[0] == null, "Soul stone consumed from inventory")
+
+func _test_full_chain_progression() -> void:
+ print("\n--- Test 4: Full Quest Progression Lv30 -> Lv80 ---")
+ var bio := BiologistQuestSystem.new()
+ bio.init_for_player(85) # High level to test through all stages
+
+ # Stage 0: Orc Tooth (Lv30)
+ _assert(bio.current_stage_idx == 0, "Current stage is 0 (Lv30)")
+ bio.delivered_count = 10
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30220}])
+ _assert(bio.permanent_bonuses["move_speed"] == 10, "Lv30 Bonus: Move speed +10")
+
+ # Stage 1: Curse Book (Lv40)
+ _assert(bio.current_stage_idx == 1, "Advanced to stage 1 (Lv40 Curse Book)")
+ bio.delivered_count = 15
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30221}])
+ _assert(bio.permanent_bonuses["att_speed"] == 5, "Lv40 Bonus: Attack speed +5")
+
+ # Stage 2: Demon Keepsake (Lv50)
+ _assert(bio.current_stage_idx == 2, "Advanced to stage 2 (Lv50 Demon Keepsake)")
+ bio.delivered_count = 15
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30222}])
+ _assert(bio.permanent_bonuses["def_grade"] == 60, "Lv50 Bonus: Defense +60")
+
+ # Stage 3: Ice Marble (Lv60)
+ _assert(bio.current_stage_idx == 3, "Advanced to stage 3 (Lv60 Ice Marble)")
+ bio.delivered_count = 20
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30223}])
+ _assert(bio.permanent_bonuses["att_grade"] == 50, "Lv60 Bonus: Attack power +50")
+
+ # Stage 4: Zelkova Branch (Lv70)
+ _assert(bio.current_stage_idx == 4, "Advanced to stage 4 (Lv70 Zelkova Branch)")
+ bio.delivered_count = 25
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30224}])
+ _assert(bio.permanent_bonuses["damage_reduction"] == 10, "Lv70 Bonus: Damage reduction 10%")
+
+ # Stage 5: Tugyi's Tablet (Lv80)
+ _assert(bio.current_stage_idx == 5, "Advanced to stage 5 (Lv80 Tugyi Tablet)")
+ bio.delivered_count = 30
+ bio.soul_stone_phase = true
+ bio.submit_soul_stone([{"vnum": 30225}])
+ _assert(bio.permanent_bonuses["att_grade_pct"] == 10, "Lv80 Bonus: Attack power +10%")
+
+ _assert(bio.completed_stages.size() == 6, "All 6 biologist quest stages completed")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_biologist_quest_parity.gd.uid b/project/test_biologist_quest_parity.gd.uid
new file mode 100644
index 00000000..59506baa
--- /dev/null
+++ b/project/test_biologist_quest_parity.gd.uid
@@ -0,0 +1 @@
+uid://dijmfku8ud36f
diff --git a/project/test_boss_summon_scroll_parity.gd b/project/test_boss_summon_scroll_parity.gd
new file mode 100644
index 00000000..62bbbc1b
--- /dev/null
+++ b/project/test_boss_summon_scroll_parity.gd
@@ -0,0 +1,200 @@
+# test_boss_summon_scroll_parity.gd —— Metin2 40250 首领与魔石召唤卷轴系统 1:1 校验套件
+extends SceneTree
+
+const BossSummonScrollSystem = preload("res://boss_summon_scroll_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ print(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_boss_summon_scroll_parity (Direction 4: Boss & Metin Summon Scrolls 1:1) ===")
+
+ test_boss_scrolls_spawning()
+ test_metin_scrolls_spawning()
+ test_ore_vein_scrolls_spawning()
+ test_safe_zone_restriction()
+ test_serialization_and_management()
+
+ print("\n=======================================================")
+ print("Boss & Metin Summon Scroll Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_boss_scrolls_spawning() -> void:
+ print("\n--- Test 1: Boss Summon Scrolls (40250 1:1) ---")
+ var system = BossSummonScrollSystem.new()
+
+ var boss_emitted := []
+ system.boss_summoned.connect(func(vnum, name, pos, aggressive):
+ boss_emitted.append({"vnum": vnum, "name": name, "pos": pos, "aggressive": aggressive})
+ )
+
+ # 背包中有 8 种 Boss 召唤卷
+ var inv: Array = [
+ {"vnum": 71036, "count": 2}, # 兽人首领
+ {"vnum": 71037, "count": 1}, # 密教首领
+ {"vnum": 71038, "count": 1}, # 蜘蛛女王
+ {"vnum": 71039, "count": 1}, # 沙漠巨龟
+ {"vnum": 71040, "count": 1}, # 烈焰魔王
+ {"vnum": 71041, "count": 1}, # 九尾狐
+ {"vnum": 71042, "count": 1}, # 恶魔之王
+ {"vnum": 71043, "count": 1} # 黄虎幽灵
+ ]
+
+ var center = Vector2(1000.0, 2000.0)
+
+ # 测试 71036 堆叠为 2,消耗后余 1
+ var res = system.use_summon_scroll(inv, 0, center, false)
+ _assert(res["ok"] == true, "Summon Orc Chief succeeded")
+ _assert(inv[0] != null and inv[0]["count"] == 1, "Count decremented from 2 to 1")
+ _assert(res["entity"]["vnum"] == 691, "Spawned mob vnum is 691 (Orc Chief)")
+ _assert(res["entity"]["is_aggressive"] == true, "Boss is aggressive")
+ _assert(res["entity"]["level"] == 50, "Orc Chief level is 50")
+ _assert(boss_emitted.size() == 1, "boss_summoned signal emitted")
+ _assert(boss_emitted[0]["vnum"] == 691, "Signal reports mob vnum 691")
+
+ # 测试 71040 烈焰魔王 (2206, Lv 73)
+ res = system.use_summon_scroll(inv, 4, center, false)
+ _assert(res["ok"] == true, "Summon Flame King succeeded")
+ _assert(inv[4] == null, "Single count scroll consumed to null")
+ _assert(res["entity"]["vnum"] == 2206, "Spawned Flame King vnum 2206")
+ _assert(res["entity"]["level"] == 73, "Flame King level 73")
+
+ # 测试 71041 九尾狐 (1901, Lv 72)
+ res = system.use_summon_scroll(inv, 5, center, false)
+ _assert(res["ok"] == true, "Summon Nine Tails succeeded")
+ _assert(res["entity"]["vnum"] == 1901, "Spawned Nine Tails vnum 1901")
+
+func test_metin_scrolls_spawning() -> void:
+ print("\n--- Test 2: Metin Stone Summon Scrolls (40250 1:1) ---")
+ var system = BossSummonScrollSystem.new()
+
+ var metin_emitted := []
+ system.metin_summoned.connect(func(vnum, name, pos, level):
+ metin_emitted.append({"vnum": vnum, "name": name, "pos": pos, "level": level})
+ )
+
+ var inv: Array = [
+ {"vnum": 71080, "count": 1}, # 初级魔石
+ {"vnum": 71081, "count": 1}, # 中级魔石
+ {"vnum": 71082, "count": 1} # 高级魔石
+ ]
+ var center = Vector2(500.0, 500.0)
+
+ # 71080 初级魔石 (强制选取第4个 8004 贪婪之石 Lv 20)
+ var res = system.use_summon_scroll(inv, 0, center, false, 3)
+ _assert(res["ok"] == true, "Low Metin summon succeeded")
+ _assert(res["entity"]["vnum"] == 8004, "Metin of Greed spawned (vnum 8004)")
+ _assert(res["entity"]["level"] == 20, "Level is 20")
+ _assert(res["entity"]["rank"] == 5, "Rank is 5 (STONE)")
+ _assert(res["entity"]["is_aggressive"] == false, "Metin is stationary/non-aggressive")
+
+ # 71081 中级魔石 (强制选取第3个 8008 灵魂之石 Lv 40)
+ res = system.use_summon_scroll(inv, 1, center, false, 2)
+ _assert(res["ok"] == true, "Medium Metin summon succeeded")
+ _assert(res["entity"]["vnum"] == 8008, "Metin of Soul spawned (vnum 8008)")
+ _assert(res["entity"]["level"] == 40, "Level is 40")
+
+ # 71082 高级魔石 (强制选取第7个 8027 绝望之石 Lv 90)
+ res = system.use_summon_scroll(inv, 2, center, false, 7)
+ _assert(res["ok"] == true, "High Metin summon succeeded")
+ _assert(res["entity"]["vnum"] == 8027, "Metin of Jeon-Un spawned (vnum 8027)")
+ _assert(res["entity"]["level"] == 90, "Level is 90")
+ _assert(metin_emitted.size() == 3, "All 3 metin_summoned signals fired")
+
+func test_ore_vein_scrolls_spawning() -> void:
+ print("\n--- Test 3: Ore Vein Summon Scrolls (40250 1:1) ---")
+ var system = BossSummonScrollSystem.new()
+
+ var vein_emitted := []
+ system.ore_vein_summoned.connect(func(vnum, name, pos):
+ vein_emitted.append({"vnum": vnum, "name": name, "pos": pos})
+ )
+
+ var inv: Array = [
+ {"vnum": 71057, "count": 1}, # 钻石矿脉 (20049)
+ {"vnum": 71064, "count": 1} # 黑檀木矿脉 (20056)
+ ]
+ var center = Vector2(300.0, 300.0)
+
+ var res = system.use_summon_scroll(inv, 0, center, false)
+ _assert(res["ok"] == true, "Diamond vein summon succeeded")
+ _assert(res["entity"]["vnum"] == 20049, "Vein vnum 20049")
+ _assert(vein_emitted.size() == 1, "ore_vein_summoned emitted")
+
+ res = system.use_summon_scroll(inv, 1, center, false)
+ _assert(res["ok"] == true, "Ebony vein summon succeeded")
+ _assert(res["entity"]["vnum"] == 20056, "Vein vnum 20056")
+
+func test_safe_zone_restriction() -> void:
+ print("\n--- Test 4: Safe Zone Summon Restrictions (40250 1:1) ---")
+ var system = BossSummonScrollSystem.new()
+
+ var inv: Array = [
+ {"vnum": 71036, "count": 1}, # 兽人首领
+ {"vnum": 71081, "count": 1}, # 中级魔石
+ {"vnum": 71060, "count": 1} # 铜矿脉
+ ]
+
+ # 在安全区内尝试召唤首领
+ var res = system.use_summon_scroll(inv, 0, Vector2.ZERO, true)
+ _assert(res["ok"] == false, "Boss summon rejected in safe zone")
+ _assert(res["reason"] == "CANNOT_SPAWN_IN_SAFEZONE", "Reason is CANNOT_SPAWN_IN_SAFEZONE")
+ _assert(inv[0]["count"] == 1, "Item not consumed on rejection")
+
+ # 在安全区内尝试召唤魔石
+ res = system.use_summon_scroll(inv, 1, Vector2.ZERO, true)
+ _assert(res["ok"] == false, "Metin summon rejected in safe zone")
+ _assert(inv[1]["count"] == 1, "Metin scroll not consumed")
+
+ # 在安全区召唤矿脉是允许的 (矿脉无攻击性)
+ res = system.use_summon_scroll(inv, 2, Vector2.ZERO, true)
+ _assert(res["ok"] == true, "Ore vein allowed in safe zone")
+ _assert(inv[2] == null, "Ore vein scroll consumed")
+
+func test_serialization_and_management() -> void:
+ print("\n--- Test 5: Registry Management & Serialization ---")
+ var system = BossSummonScrollSystem.new()
+
+ var inv: Array = [
+ {"vnum": 71038, "count": 1}, # 蜘蛛女王
+ {"vnum": 71082, "count": 1} # 高级魔石
+ ]
+ system.use_summon_scroll(inv, 0, Vector2(100.0, 100.0), false)
+ system.use_summon_scroll(inv, 1, Vector2(200.0, 200.0), false, 0)
+
+ var list = system.get_active_summoned_entities()
+ _assert(list.size() == 2, "Active entities count is 2")
+ _assert(list[0]["vnum"] == 2091, "First entity is Spider Queen (2091)")
+ _assert(list[1]["vnum"] == 8011, "Second entity is Metin of Devil (8011)")
+
+ # 序列化
+ var data = system.serialize()
+ _assert(data.has("active_entities") and data["active_entities"].size() == 2, "Serialized 2 entities")
+
+ # 新系统反序列化
+ var restored_system = BossSummonScrollSystem.new()
+ restored_system.deserialize(data)
+ var restored_list = restored_system.get_active_summoned_entities()
+ _assert(restored_list.size() == 2, "Restored 2 entities")
+ _assert(restored_list[0]["vnum"] == 2091, "Restored Spider Queen vnum")
+ _assert(restored_list[1]["level"] == 55, "Restored Metin level 55")
+
+ # 移除与清空
+ var removed = system.remove_summoned_entity(0)
+ _assert(removed == true, "Removed first entity")
+ _assert(system.get_active_summoned_entities().size() == 1, "1 entity remaining")
+ system.clear_summoned_entities()
+ _assert(system.get_active_summoned_entities().is_empty(), "All entities cleared")
diff --git a/project/test_boss_summon_scroll_parity.gd.uid b/project/test_boss_summon_scroll_parity.gd.uid
new file mode 100644
index 00000000..628dc6e3
--- /dev/null
+++ b/project/test_boss_summon_scroll_parity.gd.uid
@@ -0,0 +1 @@
+uid://7j2lta1cyakb
diff --git a/project/test_bridge_height_parity.gd b/project/test_bridge_height_parity.gd
new file mode 100644
index 00000000..131feb1f
--- /dev/null
+++ b/project/test_bridge_height_parity.gd
@@ -0,0 +1,99 @@
+# test_bridge_height_parity.gd
+# 验证 40250 规范:桥梁与静态物体 (.mdatr) 高度采样 1:1 对齐测试。
+# 验证点:
+# 1. 在桥梁所在位置,world.sample_height 返回桥面高度(由 .mdatr 中的 height 网格计算),
+# 而不是桥下方河床/水面的裸地形高度。
+# 2. 桥梁外侧(偏离桥面)的点,正确回退到裸地形采样高度。
+# 3. 角色踏上桥梁时,Y 坐标与桥面高度一致,不再掉落穿透到桥底水下。
+extends SceneTree
+
+const ASSETS_PATH := "res://../assets"
+const MAP_PATH := "OutdoorA1/metin2_map_a1"
+
+func _init() -> void:
+ call_deferred("_run")
+
+func _run() -> void:
+ print("=== Starting test_bridge_height_parity ===")
+ var world: Node = null
+ if ClassDB.class_exists("Metin2World"):
+ world = ClassDB.instantiate("Metin2World")
+ else:
+ printerr("FAIL: Metin2World not available")
+ quit(1)
+ return
+
+ get_root().add_child(world)
+
+ var assets_abs := ProjectSettings.globalize_path(ASSETS_PATH)
+ world.set("assets_root", assets_abs)
+ world.set("map_path", MAP_PATH)
+ world.set("objects_enabled", true)
+ world.set("water_enabled", true)
+ world.set("load_radius_tiles", -1) # load all chunks
+
+ var ok: bool = bool(world.call("load_map"))
+ if not ok:
+ printerr("FAIL: load_map failed")
+ quit(1)
+ return
+
+ # 测试桥梁 1: a1-024-10m-bridge (位于 Chunk 002002)
+ # 原始 areadata: x=76156.88 y=-59454.84 z=19693.11 bias=-290.0
+ # Godot 坐标: x = 761.57, z = 594.55
+ var b1_x := 761.57
+ var b1_z := 594.55
+ var h1: float = float(world.call("sample_height", b1_x, b1_z))
+ print("Bridge 1 (a1-024-10m-bridge) at (%f, %f) sample_height = %f m" % [b1_x, b1_z, h1])
+
+ # 桥面应在 205m ~ 207m 区间,而河床地形在 ~198.2m
+ if h1 < 204.0 or h1 > 208.0:
+ printerr("FAIL: Bridge 1 deck height expected ~206m, got %f m (fell through to riverbed?)" % h1)
+ quit(1)
+ return
+ print("PASS: Bridge 1 deck height correctly sampled at %f m (> 198.2m riverbed)" % h1)
+
+ # 偏离桥面的点 (向河道侧移 15米): (761.57, 570.0) 应为河床高度 ~198m
+ var off_z := 570.0
+ var h_off: float = float(world.call("sample_height", b1_x, off_z))
+ print("Off-bridge riverbed at (%f, %f) sample_height = %f m" % [b1_x, off_z, h_off])
+ if h_off > 202.0:
+ printerr("FAIL: Off-bridge point expected riverbed terrain height (< 202m), got %f m" % h_off)
+ quit(1)
+ return
+ print("PASS: Off-bridge point correctly falls back to terrain height %f m" % h_off)
+
+ # 测试桥梁 2: general_obj_suspension bridge01 (位于 Chunk 000002)
+ # 原始 areadata: pos=21390.722656 -70045.242188 13467.375000 rot=roll 270.0
+ # Godot 坐标: x = 213.91, z = 700.45
+ var b2_x := 213.91
+ var b2_z := 700.45
+ var h2: float = float(world.call("sample_height", b2_x, b2_z))
+ print("Bridge 2 (suspension bridge01) at (%f, %f) sample_height = %f m" % [b2_x, b2_z, h2])
+ # 悬索吊桥高度采样验证
+ if h2 <= 0.0:
+ printerr("FAIL: Bridge 2 sample_height returned invalid height %f" % h2)
+ quit(1)
+ return
+ print("PASS: Bridge 2 height correctly sampled at %f m" % h2)
+
+ # 模拟角色走上桥梁:角色位置沿桥梁中轴线前进
+ # 桥梁走向从 x=750 到 x=770,z=594.55
+ var step_ok := true
+ for x_offset in range(-10, 11, 2):
+ var test_x := b1_x + float(x_offset)
+ var test_h := float(world.call("sample_height", test_x, b1_z))
+ if test_h < 203.0:
+ printerr("FAIL: Character fell through bridge deck at x=%f! height=%f" % [test_x, test_h])
+ step_ok = false
+ break
+ if not step_ok:
+ quit(1)
+ return
+ print("PASS: Character walking along bridge deck maintained deck elevation across entire span!")
+
+ world.call("unload_map")
+ world.queue_free()
+
+ print("ALL BRIDGE HEIGHT PARITY CHECKS PASSED (40250 STRICT)")
+ quit(0)
diff --git a/project/test_chat_hyperlink_dice_parity.gd b/project/test_chat_hyperlink_dice_parity.gd
new file mode 100644
index 00000000..127ed978
--- /dev/null
+++ b/project/test_chat_hyperlink_dice_parity.gd
@@ -0,0 +1,103 @@
+# test_chat_hyperlink_dice_parity.gd —— 40250 聊天物品超链接与 /dice 掷骰系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://chat_hyperlink_dice_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_chat_hyperlink_dice_parity (Batch 27 Direction 3) ===")
+ _test_item_hyperlink_encode_decode()
+ _test_tooltip_generation()
+ _test_broken_hyperlink_handling()
+ _test_dice_command_variations()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_item_hyperlink_encode_decode() -> void:
+ print("\n--- Test 1: Item Hyperlink Encode & Decode Round-trip ---")
+ var original_item = {
+ "vnum": 189,
+ "name": "Poison Sword+9",
+ "flags": 1,
+ "sockets": [28430, 28431, 28432],
+ "attributes": [
+ { "type": 72, "value": 45 }, # 平均伤害 45%
+ { "type": 71, "value": -12 }, # 技能伤害 -12%
+ { "type": 17, "value": 10 } # 半人加成 10%
+ ]
+ }
+
+ # 1. Encode
+ var link_str = SystemClass.encode_item_hyperlink(original_item)
+ _assert(link_str.begins_with("|cffffc700|Hitem:"), "Link starts with official color and header")
+ _assert(link_str.ends_with("[Poison Sword+9]|h|r"), "Link ends with brackets and reset tag")
+
+ # 2. Decode
+ var decoded = SystemClass.decode_item_hyperlink(link_str)
+ _assert(decoded["valid"] == true, "Decoded successfully")
+ _assert(decoded["vnum"] == 189, "Vnum matches 189")
+ _assert(decoded["name"] == "Poison Sword+9", "Name matches Poison Sword+9")
+ _assert(decoded["sockets"][0] == 28430, "Socket 0 matches 28430")
+ _assert(decoded["sockets"][1] == 28431, "Socket 1 matches 28431")
+ _assert(decoded["sockets"][2] == 28432, "Socket 2 matches 28432")
+ _assert(decoded["attributes"].size() == 3, "Decoded 3 attributes")
+ _assert(decoded["attributes"][0]["type"] == 72 and decoded["attributes"][0]["value"] == 45, "Attr 0 is Type 72 Val 45")
+
+func _test_tooltip_generation() -> void:
+ print("\n--- Test 2: Tooltip Generation on Link Hover ---")
+ var decoded = {
+ "valid": true,
+ "vnum": 11299,
+ "name": "Black Steel Armor+9",
+ "sockets": [28440, 28441, 0],
+ "attributes": [
+ { "type": 1, "value": 2000 } # 最大生命值 +2000
+ ]
+ }
+
+ var tooltip = SystemClass.build_tooltip_text(decoded)
+ _assert(tooltip.contains("Black Steel Armor+9"), "Tooltip contains item name")
+ _assert(tooltip.contains("Vnum: 11299"), "Tooltip contains item vnum")
+ _assert(tooltip.contains("镶嵌宝石 [Vnum 28440]"), "Tooltip contains socket 1 gem")
+ _assert(tooltip.contains("附加属性: 类型 1 +2000"), "Tooltip contains max HP bonus")
+
+func _test_broken_hyperlink_handling() -> void:
+ print("\n--- Test 3: Broken Hyperlink Text Safety ---")
+ var r1 = SystemClass.decode_item_hyperlink("Just normal chat text")
+ _assert(r1["valid"] == false, "Normal text produces invalid hyperlink result")
+
+ var r2 = SystemClass.decode_item_hyperlink("|cffffc700|Hitem:corrupted_data|h")
+ _assert(r2["valid"] == false, "Corrupted hyperlink handled safely without crash")
+
+func _test_dice_command_variations() -> void:
+ print("\n--- Test 4: 40250 /dice Command Logic (1:1 do_dice) ---")
+ # 1. Default /dice (1..100)
+ var d1 = SystemClass.execute_dice("Player1", [], 77)
+ _assert(d1["roll"] == 77, "Default dice rolled 77")
+ _assert(d1["start"] == 1 and d1["end"] == 100, "Default range is 1-100")
+ _assert(d1["message"].contains("77 (1-100)"), "Message correctly formatted")
+
+ # 2. Single arg /dice 20 (1..20)
+ var d2 = SystemClass.execute_dice("Player2", ["20"], 15)
+ _assert(d2["roll"] == 15, "Dice rolled 15 in range 1-20")
+ _assert(d2["start"] == 1 and d2["end"] == 20, "Single arg range is 1-20")
+
+ # 3. Two args reversed: /dice 80 20 -> auto min/max to (20..80)
+ var d3 = SystemClass.execute_dice("Player3", ["80", "20"], 50)
+ _assert(d3["start"] == 20 and d3["end"] == 80, "Auto swaps reversed range to 20-80")
+ _assert(d3["roll"] == 50, "Rolled 50 within normalized range")
diff --git a/project/test_chat_hyperlink_dice_parity.gd.uid b/project/test_chat_hyperlink_dice_parity.gd.uid
new file mode 100644
index 00000000..ac7ca1f0
--- /dev/null
+++ b/project/test_chat_hyperlink_dice_parity.gd.uid
@@ -0,0 +1 @@
+uid://bnwasl2v04le4
diff --git a/project/test_chest_keys_parity.gd b/project/test_chest_keys_parity.gd
new file mode 100644
index 00000000..5f7e0670
--- /dev/null
+++ b/project/test_chest_keys_parity.gd
@@ -0,0 +1,97 @@
+# test_chest_keys_parity.gd —— 首领宝箱与钥匙系统 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_chest_keys_parity.gd
+extends SceneTree
+
+const ChestSystem = preload("res://chest_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_chest_keys_parity (Direction 4: Chests & Keys 1:1) ===")
+ _test_classification()
+ _test_key_matching()
+ _test_inventory_full_protection()
+ _test_direct_giftbox_opening()
+ _test_treasure_box_with_key()
+ _finish()
+
+func _test_classification() -> void:
+ print("\n--- Test 1: Item Classification (GIFTBOX vs TREASURE_BOX vs KEY) ---")
+ _assert(ChestSystem.is_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX), "Moonlight Box (50011) is Gift Box")
+ _assert(ChestSystem.is_gift_box(ChestSystem.VNUM_CHIEF_ORC_CHEST), "Chief Orc Chest (50070) is Gift Box")
+ _assert(not ChestSystem.is_gift_box(ChestSystem.VNUM_SILVER_BOX), "Silver Box is NOT a Gift Box")
+
+ _assert(ChestSystem.is_treasure_box(ChestSystem.VNUM_SILVER_BOX), "Silver Box is Treasure Box")
+ _assert(ChestSystem.is_treasure_box(ChestSystem.VNUM_GOLD_BOX), "Gold Box is Treasure Box")
+ _assert(not ChestSystem.is_treasure_box(ChestSystem.VNUM_MOONLIGHT_BOX), "Moonlight Box is NOT a Treasure Box")
+
+ _assert(ChestSystem.is_treasure_key(ChestSystem.VNUM_SILVER_KEY), "Silver Key (50002) is Treasure Key")
+ _assert(ChestSystem.is_treasure_key(ChestSystem.VNUM_GOLD_KEY), "Gold Key (50004) is Treasure Key")
+
+func _test_key_matching() -> void:
+ print("\n--- Test 2: Key & Box Matching ---")
+ _assert(ChestSystem.is_matching_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX), "Silver Key matches Silver Box")
+ _assert(not ChestSystem.is_matching_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_GOLD_BOX), "Silver Key does NOT match Gold Box")
+ _assert(ChestSystem.is_matching_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_GOLD_BOX), "Gold Key matches Gold Box")
+ _assert(not ChestSystem.is_matching_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_SILVER_BOX), "Gold Key does NOT match Silver Box")
+
+func _test_inventory_full_protection() -> void:
+ print("\n--- Test 3: Inventory Full Protection ---")
+ var r1 := ChestSystem.open_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX, 0)
+ _assert(r1["success"] == false and r1["err"] == "inventory_full", "Giftbox opening blocked when inventory is full")
+
+ var r2 := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX, 0)
+ _assert(r2["success"] == false and r2["err"] == "inventory_full", "Treasure box opening blocked when inventory is full")
+
+func _test_direct_giftbox_opening() -> void:
+ print("\n--- Test 4: Direct Giftbox Opening & Drop Rolling ---")
+ # Rejects non-giftbox
+ var r_err := ChestSystem.open_gift_box(ChestSystem.VNUM_SILVER_BOX, 5)
+ _assert(r_err["success"] == false and r_err["err"] == "not_gift_box", "Direct open fails on Silver Box (needs key)")
+
+ # Open Moonlight Box (roll 5 -> item 50300)
+ var r_moon := ChestSystem.open_gift_box(ChestSystem.VNUM_MOONLIGHT_BOX, 5, 5)
+ _assert(r_moon["success"] == true, "Moonlight box opened successfully")
+ _assert(r_moon["reward_name"] == "技能书", "Rolled Skill Book from Moonlight box")
+
+ # Open Chief Orc Chest (roll 58 -> trap)
+ var r_orc := ChestSystem.open_gift_box(ChestSystem.VNUM_CHIEF_ORC_CHEST, 5, 58)
+ _assert(r_orc["success"] == true, "Chief Orc chest opened")
+ _assert(r_orc["is_trap"] == true, "Triggered poison trap from Chief Orc chest")
+
+func _test_treasure_box_with_key() -> void:
+ print("\n--- Test 5: Opening Treasure Box With Key (Drag & Drop) ---")
+ # Mismatched key
+ var r_mismatch := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_SILVER_BOX, 5)
+ _assert(r_mismatch["success"] == false and r_mismatch["err"] == "key_mismatch", "Rejects mismatched Gold Key on Silver Box")
+
+ # Matching Silver Key on Silver Box
+ var r_succ := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_SILVER_KEY, ChestSystem.VNUM_SILVER_BOX, 5, 10)
+ _assert(r_succ["success"] == true, "Successfully unlocked Silver Box with Silver Key")
+ _assert(r_succ["consumed_key_vnum"] == ChestSystem.VNUM_SILVER_KEY, "Consumed Silver Key")
+ _assert(r_succ["consumed_box_vnum"] == ChestSystem.VNUM_SILVER_BOX, "Consumed Silver Box")
+ _assert(r_succ["reward_name"] == "祝福卷轴", "Obtained Blessing Scroll from Silver Box")
+
+ # Matching Gold Key on Gold Box
+ var r_gold := ChestSystem.open_treasure_box_with_key(ChestSystem.VNUM_GOLD_KEY, ChestSystem.VNUM_GOLD_BOX, 5, 30)
+ _assert(r_gold["success"] == true, "Successfully unlocked Gold Box with Gold Key")
+ _assert(r_gold["reward_name"] == "魂石", "Obtained Soul Stone from Gold Box")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_chest_keys_parity.gd.uid b/project/test_chest_keys_parity.gd.uid
new file mode 100644
index 00000000..ea72c385
--- /dev/null
+++ b/project/test_chest_keys_parity.gd.uid
@@ -0,0 +1 @@
+uid://csiramqelfsu8
diff --git a/project/test_class_specialization_parity.gd b/project/test_class_specialization_parity.gd
new file mode 100644
index 00000000..9f91b067
--- /dev/null
+++ b/project/test_class_specialization_parity.gd
@@ -0,0 +1,144 @@
+# test_class_specialization_parity.gd —— 40250 角色 30 级转职与无损洗髓 1:1 回归测试套件
+extends SceneTree
+
+const ClassSpecializationSystem = preload("res://class_specialization_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_class_specialization_parity (Direction 3: Class Specialization 1:1) ===")
+ _test_spec_tree_configuration()
+ _test_level_30_requirement()
+ _test_choosing_specialization()
+ _test_skill_point_allocation()
+ _test_reset_specialization_lossless()
+ _finish()
+
+func _test_spec_tree_configuration() -> void:
+ print("\n--- Test 1: 4 Classes x 2 Specializations Tree ---")
+ var sys := ClassSpecializationSystem.new()
+ for j in [0, 1, 2, 3]:
+ var specs = sys.get_available_specializations(j)
+ _assert(specs.size() == 2, "Job %d has exactly 2 specialization branches" % j)
+ _assert(specs.has(1) and specs.has(2), "Branches are 1 and 2")
+ _assert(specs[1]["skills"].size() == 5, "Branch 1 has 5 skills")
+ _assert(specs[2]["skills"].size() == 5, "Branch 2 has 5 skills")
+
+func _test_level_30_requirement() -> void:
+ print("\n--- Test 2: Level 30 Requirement ---")
+ var sys := ClassSpecializationSystem.new()
+ var low_lvl_player := {"job": 0, "level": 25, "skill_group": 0}
+
+ var fail_res = sys.choose_specialization(1, low_lvl_player)
+ _assert(fail_res["ok"] == false and fail_res["reason"] == "LEVEL_TOO_LOW", "Level 25 rejected")
+
+func _test_choosing_specialization() -> void:
+ print("\n--- Test 3: Choosing Specialization (Warrior Body) ---")
+ var sys := ClassSpecializationSystem.new()
+ var player := {"job": 0, "level": 35, "skill_group": 0, "skill_points": 35}
+
+ var spec_sig := {"fired": false, "job": -1, "spec_id": 0}
+ sys.specialization_chosen.connect(func(j: int, sid: int, _name: String):
+ spec_sig["fired"] = true
+ spec_sig["job"] = j
+ spec_sig["spec_id"] = sid
+ )
+
+ # Choose Arahan (Body Warrior)
+ var res = sys.choose_specialization(1, player)
+ _assert(res["ok"] == true, "Specialization chosen")
+ _assert(player["skill_group"] == 1, "skill_group set to 1")
+ _assert(player["skills"].size() == 5, "5 skills initialized")
+ _assert(player["skills"].has(4) and player["skills"][4]["name"] == "剑气", "Has Aura of Sword (4)")
+ _assert(spec_sig["fired"] == true and spec_sig["spec_id"] == 1, "specialization_chosen signal emitted")
+
+ # Duplicate choice without reset must fail
+ var fail_dup = sys.choose_specialization(2, player)
+ _assert(fail_dup["ok"] == false and fail_dup["reason"] == "ALREADY_SPECIALIZED", "Cannot re-choose without reset")
+
+func _test_skill_point_allocation() -> void:
+ print("\n--- Test 4: Skill Point Allocation ---")
+ var sys := ClassSpecializationSystem.new()
+ var player := {"job": 0, "level": 35, "skill_group": 1, "skill_points": 5, "skills": {
+ 4: {"name": "剑气", "level": 0}
+ }}
+
+ var alloc_sig := {"fired": false, "vnum": 0, "lvl": 0}
+ sys.skill_point_allocated.connect(func(v: int, l: int, _r: int):
+ alloc_sig["fired"] = true
+ alloc_sig["vnum"] = v
+ alloc_sig["lvl"] = l
+ )
+
+ # Allocate 3 points into Aura of Sword
+ sys.allocate_skill_point(4, player)
+ sys.allocate_skill_point(4, player)
+ sys.allocate_skill_point(4, player)
+ _assert(player["skills"][4]["level"] == 3, "Aura of Sword leveled to 3")
+ _assert(player["skill_points"] == 2, "Remaining points 5 - 3 = 2")
+ _assert(alloc_sig["fired"] == true and alloc_sig["lvl"] == 3, "skill_point_allocated emitted")
+
+ # Cap test: level 20 is max normal level
+ player["skills"][4]["level"] = 20
+ var fail_cap = sys.allocate_skill_point(4, player)
+ _assert(fail_cap["ok"] == false and fail_cap["reason"] == "MAX_NORMAL_LEVEL", "Cannot allocate past level 20")
+
+func _test_reset_specialization_lossless() -> void:
+ print("\n--- Test 5: Lossless Reset with Scroll (71100) & Branch Switch ---")
+ var sys := ClassSpecializationSystem.new()
+ var player := {
+ "job": 0,
+ "level": 40,
+ "skill_group": 1, # Body
+ "skill_points": 5,
+ "skills": {
+ 1: {"name": "三连斩", "level": 10},
+ 4: {"name": "剑气", "level": 20}
+ }
+ }
+ # Total points invested: 10 + 20 = 30. Remaining: 5. After refund: 35.
+
+ var inv: Array = [
+ {"vnum": ClassSpecializationSystem.VNUM_RESET_SCROLL, "name": "洗髓卷轴", "count": 1}
+ ]
+
+ var reset_sig := {"fired": false, "pts": 0}
+ sys.specialization_reset.connect(func(pts: int):
+ reset_sig["fired"] = true
+ reset_sig["pts"] = pts
+ )
+
+ var res = sys.reset_specialization(inv, player, false)
+ _assert(res["ok"] == true, "Reset succeeded")
+ _assert(inv[0] == null, "Reset scroll consumed")
+ _assert(player["skill_group"] == 0, "skill_group reset to 0")
+ _assert(player["skills"].is_empty(), "Old skills cleared")
+ _assert(player["skill_points"] == 35, "All 30 points refunded (5 + 30 = 35)")
+ _assert(reset_sig["fired"] == true and reset_sig["pts"] == 30, "specialization_reset emitted with 30 pts")
+
+ # Now player can choose the other branch: Partisan (Mental Warrior)
+ var switch_res = sys.choose_specialization(2, player)
+ _assert(switch_res["ok"] == true, "Successfully switched to Mental Warrior (Partisan)")
+ _assert(player["skill_group"] == 2, "skill_group is now 2")
+ _assert(player["skills"].has(16) and player["skills"][16]["name"] == "强身术", "Has Strong Body (16)")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Class Specialization Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Class Specialization parity tests passed!")
+ quit(0)
diff --git a/project/test_class_specialization_parity.gd.uid b/project/test_class_specialization_parity.gd.uid
new file mode 100644
index 00000000..3a1d1963
--- /dev/null
+++ b/project/test_class_specialization_parity.gd.uid
@@ -0,0 +1 @@
+uid://wq3ob7yiiyjt
diff --git a/project/test_classic_pet_parity.gd b/project/test_classic_pet_parity.gd
new file mode 100644
index 00000000..2f1fad89
--- /dev/null
+++ b/project/test_classic_pet_parity.gd
@@ -0,0 +1,146 @@
+# test_classic_pet_parity.gd —— 40250 经典封印宠物系统 1:1 回归测试套件
+extends SceneTree
+
+const ClassicPetSystem = preload("res://classic_pet_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_classic_pet_parity (Direction 1: Classic Pet System 1:1) ===")
+ _test_summon_and_buff_application()
+ _test_auto_unsummon_previous_pet()
+ _test_dungeon_only_buff_condition()
+ _test_follow_and_teleport_ai()
+ _test_serialization()
+ _finish()
+
+func _test_summon_and_buff_application() -> void:
+ print("\n--- Test 1: Summon & Buff Injection/Removal ---")
+ var sys := ClassicPetSystem.new()
+ var player := {"max_hp": 5000, "attack_pct": 0, "defense_pct": 0}
+ var seal_firedrake := {"vnum": ClassicPetSystem.SEAL_BABY_FIREDRAKE}
+
+ var summon_sig := {"fired": false, "vnum": 0, "name": ""}
+ sys.pet_summoned.connect(func(v: int, n: String, _b: Dictionary):
+ summon_sig["fired"] = true
+ summon_sig["vnum"] = v
+ summon_sig["name"] = n
+ )
+
+ var unsummon_sig := {"fired": false, "vnum": 0}
+ sys.pet_unsummoned.connect(func(v: int, _n: String):
+ unsummon_sig["fired"] = true
+ unsummon_sig["vnum"] = v
+ )
+
+ # Summon Baby Firedrake
+ var res = sys.summon_pet(seal_firedrake, player, false, Vector2.ZERO)
+ _assert(res["ok"] == true, "Summon succeeded")
+ _assert(sys.is_summoned == true, "System reports pet is summoned")
+ _assert(sys.active_mob_vnum == ClassicPetSystem.MOB_BABY_FIREDRAKE, "Mob vnum is 34005")
+ _assert(player["max_hp"] == 6500, "HP increased by 1500 (5000 -> 6500)")
+ _assert(player["attack_pct"] == 15, "Attack pct increased by 15%")
+ _assert(summon_sig["fired"] == true, "pet_summoned signal emitted")
+
+ # Unsummon
+ var un_res = sys.unsummon_pet(player)
+ _assert(un_res["ok"] == true, "Unsummon succeeded")
+ _assert(sys.is_summoned == false, "System reports pet unsummoned")
+ _assert(player["max_hp"] == 5000, "HP reversed back to 5000")
+ _assert(player["attack_pct"] == 0, "Attack pct reversed back to 0%")
+ _assert(unsummon_sig["fired"] == true, "pet_unsummoned signal emitted")
+
+func _test_auto_unsummon_previous_pet() -> void:
+ print("\n--- Test 2: Auto Unsummon Previous Pet When Summoning New ---")
+ var sys := ClassicPetSystem.new()
+ var player := {"monster_damage_pct": 0, "defense_pct": 0, "max_hp": 10000}
+
+ # 1. Summon Boar (+15% monster damage)
+ sys.summon_pet({"vnum": ClassicPetSystem.SEAL_BABY_BOAR}, player, false)
+ _assert(player["monster_damage_pct"] == 15, "Boar buff applied (+15% monster dmg)")
+
+ # 2. Directly summon Ice Phoenix (+15% def, +1500 HP)
+ sys.summon_pet({"vnum": ClassicPetSystem.SEAL_ICE_PHOENIX}, player, false)
+ _assert(sys.active_mob_vnum == ClassicPetSystem.MOB_ICE_PHOENIX, "Now Ice Phoenix is active")
+ _assert(player["monster_damage_pct"] == 0, "Previous Boar buff cleared")
+ _assert(player["defense_pct"] == 15, "Phoenix defense buff applied (+15%)")
+ _assert(player["max_hp"] == 11500, "Phoenix HP buff applied (+1500)")
+
+func _test_dungeon_only_buff_condition() -> void:
+ print("\n--- Test 3: 40250 Dungeon-Only Buff Validation (Baby Azrael) ---")
+ var sys := ClassicPetSystem.new()
+ var seal_azrael := {"vnum": ClassicPetSystem.SEAL_BABY_AZRAEL}
+
+ # In normal field map (not in dungeon)
+ var player1 := {"max_hp": 5000, "dungeon_attack_pct": 0}
+ sys.summon_pet(seal_azrael, player1, false)
+ _assert(player1["max_hp"] == 6000, "Azrael standard HP (+1000) applied in field")
+ _assert(player1["dungeon_attack_pct"] == 0, "Dungeon attack buff not applied outside dungeon")
+ sys.unsummon_pet(player1)
+
+ # Inside dungeon
+ var player2 := {"max_hp": 5000, "dungeon_attack_pct": 0}
+ sys.summon_pet(seal_azrael, player2, true)
+ _assert(player2["max_hp"] == 6000, "Azrael standard HP (+1000) applied in dungeon")
+ _assert(player2["dungeon_attack_pct"] == 20, "Dungeon attack buff (+20%) successfully applied in dungeon")
+ sys.unsummon_pet(player2)
+ _assert(player2["dungeon_attack_pct"] == 0, "Dungeon buff cleared after unsummon")
+
+func _test_follow_and_teleport_ai() -> void:
+ print("\n--- Test 4: Follow AI (Idle, Follow, Teleport) ---")
+ var sys := ClassicPetSystem.new()
+ var dummy_stats := {}
+ sys.summon_pet({"vnum": ClassicPetSystem.SEAL_BABY_BOAR}, dummy_stats, false, Vector2(0, 0))
+
+ # Owner is at (1, 0) -> dist is 1.5 - 1.0 = 0.5 (< 2.0 MIN_FOLLOW_DIST)
+ var ai_idle = sys.update_follow_ai(Vector2(1.0, 0.0), 0.1)
+ _assert(ai_idle["action"] == "idle", "AI stays idle when within 2.0m")
+
+ # Owner walks away to (8, 0) -> dist > 2.0m
+ var ai_follow = sys.update_follow_ai(Vector2(8.0, 0.0), 0.5)
+ _assert(ai_follow["action"] == "follow", "AI follows owner when distance > 2.0m")
+
+ # Owner warps/runs far away to (100, 100) -> dist > 15.0m
+ var move_sig := {"fired": false, "teleport": false}
+ sys.pet_moved.connect(func(_pos: Vector2, is_tele: bool):
+ move_sig["fired"] = true
+ move_sig["teleport"] = is_tele
+ )
+ var ai_tele = sys.update_follow_ai(Vector2(100.0, 100.0), 0.1)
+ _assert(ai_tele["action"] == "teleport", "AI teleports when distance > 15.0m")
+ _assert(move_sig["teleport"] == true, "pet_moved signal reported teleport")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: State Serialization & Restoration ---")
+ var sys1 := ClassicPetSystem.new()
+ var dummy_stats := {"max_hp": 5000}
+ sys1.summon_pet({"vnum": ClassicPetSystem.SEAL_BABY_FIREDRAKE}, dummy_stats, false, Vector2(10, 20))
+
+ var data = sys1.serialize()
+ _assert(data["is_summoned"] == true, "Serialized is_summoned")
+ _assert(data["active_mob_vnum"] == ClassicPetSystem.MOB_BABY_FIREDRAKE, "Serialized mob vnum")
+ _assert(data["active_seal_vnum"] == ClassicPetSystem.SEAL_BABY_FIREDRAKE, "Serialized seal vnum")
+
+ var sys2 := ClassicPetSystem.new()
+ sys2.deserialize(data)
+ _assert(sys2.is_summoned == true, "Restored is_summoned")
+ _assert(sys2.active_mob_vnum == ClassicPetSystem.MOB_BABY_FIREDRAKE, "Restored mob vnum")
+ _assert(sys2.active_pet_name == "幼火龙", "Restored pet name")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Classic Pet System Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_classic_pet_parity.gd.uid b/project/test_classic_pet_parity.gd.uid
new file mode 100644
index 00000000..603b5f76
--- /dev/null
+++ b/project/test_classic_pet_parity.gd.uid
@@ -0,0 +1 @@
+uid://chiax3jol1idq
diff --git a/project/test_client_phase_integration_parity.gd b/project/test_client_phase_integration_parity.gd
new file mode 100644
index 00000000..f46f12eb
--- /dev/null
+++ b/project/test_client_phase_integration_parity.gd
@@ -0,0 +1,123 @@
+# test_client_phase_integration_parity.gd —— 40250 客户端全状态机一体化回归测试
+extends SceneTree
+
+const SystemClass = preload("res://client_phase_integration_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_client_phase_integration_parity (Batch 30 Direction 3) ===")
+ _test_fresh_account_full_lifecycle_flow()
+ _test_existing_account_direct_select_flow()
+ _test_in_game_hotkey_and_focus_stack()
+ _test_logout_transitions()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_fresh_account_full_lifecycle_flow() -> void:
+ print("\n--- Test 1: Fresh Account Full Lifecycle (Login -> Empire -> Create -> Select -> Loading -> Game) ---")
+ var client = SystemClass.new()
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_LOGIN, "Starts in PHASE_LOGIN")
+
+ # Register temporary fresh account
+ client.login_phase.registered_accounts["newbie"] = { "password": "123", "has_characters": false }
+
+ # 1. Login
+ var r_log = client.process_login("newbie", "123")
+ _assert(r_log["success"] == true, "Login succeeded")
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_EMPIRE, "Transitions to PHASE_EMPIRE for new account")
+
+ # 2. Select Empire (Jinno = 3)
+ var r_emp = client.process_empire_select(3)
+ _assert(r_emp["success"] == true, "Empire selection confirmed")
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_CREATE, "Transitions to PHASE_CREATE")
+
+ # 3. Create Character
+ var r_cr = client.process_character_create("NewKnight", 3)
+ _assert(r_cr["success"] == true, "Created NewKnight successfully")
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_SELECT, "Transitions to PHASE_SELECT")
+
+ # 4. Start Game
+ var r_st = client.process_character_start(0)
+ _assert(r_st["success"] == true, "Started game from slot 0")
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_GAME, "Transitions through Loading to PHASE_GAME")
+ _assert(client.active_character["name"] == "NewKnight", "Active character is NewKnight")
+ _assert(client.jukebox.is_playing == true, "BGM jukebox actively playing in-game music")
+
+func _test_existing_account_direct_select_flow() -> void:
+ print("\n--- Test 2: Existing Account Direct to Character Select ---")
+ var client = SystemClass.new()
+
+ # Login with existing account (admin has characters)
+ var r_log = client.process_login("admin", "password")
+ _assert(r_log["success"] == true, "Admin logged in")
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_SELECT, "Existing account transitions directly to PHASE_SELECT")
+
+func _test_in_game_hotkey_and_focus_stack() -> void:
+ print("\n--- Test 3: In-Game Hotkey Management & Escape Stack ---")
+ var client = SystemClass.new()
+ client.login_phase.registered_accounts["hero"] = { "password": "123", "has_characters": false }
+ client.process_login("hero", "123")
+ client.process_empire_select(1)
+ client.process_character_create("HeroPlayer", 1)
+ client.process_character_start(0)
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_GAME, "In PHASE_GAME")
+
+ # 1. Alt+M opens Messenger
+ var h_msg = client.handle_input_hotkey("Alt+M")
+ _assert(h_msg["action"] == "OPEN_MESSENGER", "Alt+M opens messenger")
+ _assert(client.dialog_focus_stack.has("MESSENGER"), "MESSENGER pushed to stack")
+
+ # 2. M opens Atlas
+ var h_atlas = client.handle_input_hotkey("M")
+ _assert(h_atlas["action"] == "OPEN_ATLAS", "M opens atlas")
+ _assert(client.atlas.is_atlas_visible == true, "Atlas is visible")
+ _assert(client.dialog_focus_stack[client.dialog_focus_stack.size() - 1] == "ATLAS", "ATLAS on top of stack")
+
+ # 3. First Escape closes top dialog (ATLAS)
+ var esc1 = client.handle_input_hotkey("Escape")
+ _assert(esc1["action"] == "CLOSED_TOP_DIALOG" and esc1["dialog"] == "ATLAS", "Escape popped ATLAS")
+ _assert(client.atlas.is_atlas_visible == false, "Atlas is hidden")
+
+ # 4. Second Escape closes MESSENGER
+ var esc2 = client.handle_input_hotkey("Escape")
+ _assert(esc2["action"] == "CLOSED_TOP_DIALOG" and esc2["dialog"] == "MESSENGER", "Escape popped MESSENGER")
+
+ # 5. Third Escape on empty stack opens System Menu
+ var esc3 = client.handle_input_hotkey("Escape")
+ _assert(esc3["action"] == "OPEN_SYSTEM_MENU", "Escape opens System Menu when stack is empty")
+
+func _test_logout_transitions() -> void:
+ print("\n--- Test 4: Logout Transitions ---")
+ var client = SystemClass.new()
+ client.login_phase.registered_accounts["user"] = { "password": "123", "has_characters": false }
+ client.process_login("user", "123")
+ client.process_empire_select(1)
+ client.process_character_create("UserChar", 1)
+ client.process_character_start(0)
+
+ # Logout to Select
+ client.logout_to_select()
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_SELECT, "Logged out to PHASE_SELECT")
+ _assert(client.active_character.is_empty(), "Active character cleared")
+ _assert(client.jukebox.current_track == "characterselect.mp3", "Switched BGM to character select theme")
+
+ # Logout to Login
+ client.logout_to_login()
+ _assert(client.current_phase == SystemClass.ClientPhase.PHASE_LOGIN, "Logged out to PHASE_LOGIN")
+ _assert(client.active_account.is_empty(), "Active account cleared")
+ _assert(client.jukebox.current_track == "enter_the_east.mp3", "Switched BGM to login theme")
diff --git a/project/test_client_phase_integration_parity.gd.uid b/project/test_client_phase_integration_parity.gd.uid
new file mode 100644
index 00000000..6f4463b2
--- /dev/null
+++ b/project/test_client_phase_integration_parity.gd.uid
@@ -0,0 +1 @@
+uid://bd15xmtc2ol1e
diff --git a/project/test_combat_death_pickup_entry_parity.gd b/project/test_combat_death_pickup_entry_parity.gd
new file mode 100644
index 00000000..47681dda
--- /dev/null
+++ b/project/test_combat_death_pickup_entry_parity.gd
@@ -0,0 +1,180 @@
+# test_combat_death_pickup_entry_parity.gd
+# 验证:
+# 1. 选角进游戏后,无玩家输入时角色绝对静止,不发生自动位移
+# 2. 攻击动作结束后解除 is_lock,combo_index 不造成永久死锁,角色可自由移动
+# 3. 怪物 HP 归零或 GC_PLAYER_POINT_CHANGE 触发死亡,名条脱钩,位置不被网络位移包干扰,平滑释放
+# 4. 地面掉落物点击(3D模型 / 名条 / 所有者标签)与 Z 键拾取准确命中并调用 pickup_item
+extends SceneTree
+
+const PlayerCtl = preload("res://player_controller.gd")
+const NetPlay = preload("res://net_play.gd")
+const NetWorld = preload("res://net_world.gd")
+const GroundItems = preload("res://ui/ground_items.gd")
+const PlayerView = preload("res://ui/player_view.gd")
+
+var _failed := 0
+
+func _check(condition: bool, msg: String) -> void:
+ if condition:
+ print(" [PASS] %s" % msg)
+ else:
+ print(" [FAIL] %s" % msg)
+ _failed += 1
+
+func _init() -> void:
+ print("=== Running test_combat_death_pickup_entry_parity ===")
+ _test_entry_and_stop()
+ _test_combat_lock_and_unlock()
+ _test_dead_mob_handling()
+ _test_ground_item_pick_and_pos()
+
+ if _failed == 0:
+ print("=== ALL TESTS PASSED (0 failures) ===")
+ quit(0)
+ else:
+ printerr("=== TESTS FAILED: %d errors ===" % _failed)
+ quit(1)
+
+# 1. 选人进游戏状态测试:pc.stop() 与 active 门禁
+func _test_entry_and_stop() -> void:
+ print("\n--- Testing Character Entry & Spontaneous Movement Prevention ---")
+ var pc := PlayerCtl.new()
+ var dummy := Node3D.new()
+ dummy.position = Vector3(100, 10, 200)
+ pc.player = dummy
+
+ # 初始化:pc 处于非 active 或初始状态
+ _check(pc.active == true, "pc.active defaults to true")
+ _check(pc.is_going() == false, "pc is not going initially")
+
+ # 模拟进游戏时的 pc.stop()
+ pc.stop()
+ _check(pc.is_going() == false, "pc.stop() keeps is_going = false")
+ _check(pc._src_pos == dummy.position, "pc.stop() aligns _src_pos with player position")
+ _check(pc._dst_pos == dummy.position, "pc.stop() aligns _dst_pos with player position")
+ _check(pc._reserved_ground == null, "pc.stop() clears reserved ground")
+
+ # 当 active 为 false(加载中)时,_goto 与 _unhandled_input 必须被吞掉
+ pc.active = false
+ var goto_res := pc._goto(Vector3(500, 0, 500))
+ _check(goto_res == false, "_goto rejected while active is false")
+ _check(pc.is_going() == false, "pc still not going when _goto rejected")
+
+ # 加载完成,激活并重新 stop
+ pc.active = true
+ dummy.position = Vector3(450, 20, 900)
+ pc.stop()
+ _check(pc._dst_pos == Vector3(450, 20, 900), "pc.stop() updates _dst_pos to spawn position")
+ _check(pc.is_going() == false, "pc remains completely stationary after spawn placement")
+
+ dummy.free()
+ pc.free()
+
+# 2. 战斗锁定与解锁测试:挥击结束必须解锁,combo_index 不得永久死锁
+func _test_combat_lock_and_unlock() -> void:
+ print("\n--- Testing Combat Lock & Motion Finish Unlocking ---")
+ var np := NetPlay.new()
+
+ # 初始状态
+ _check(np.is_lock() == false, "is_lock() is false initially")
+
+ # 模拟普攻起手
+ np._is_attacking = true
+ np._motion_start_t = np._local_time
+ np.attack_period = 0.5
+ _check(np.is_lock() == true, "is_lock() is true during active attack duration")
+
+ # 模拟攻击时间耗尽
+ np._local_time += 0.6
+ _check(np.is_lock() == false, "is_lock() turns false when attack duration elapses")
+ _check(np._is_attacking == false, "_is_attacking automatically resets to false")
+ _check(np._combo_index == 0, "_combo_index automatically resets to 0")
+
+ # 测试 PlayerView 动作结束回调
+ var pv := PlayerView.new()
+ var motion_bound_emitted := [false]
+ pv.motion_bound.connect(func(st): if st == "wait": motion_bound_emitted[0] = true)
+
+ pv._state = "attack"
+ pv._on_playback_finished()
+ _check(pv._state == "", "PlayerView._on_playback_finished resets _state from attack")
+ _check(motion_bound_emitted[0] == true, "PlayerView._on_playback_finished emits motion_bound('wait')")
+
+ # 测试技能施法锁定与解锁
+ np.start_skill_cast(0.5, false)
+ _check(np._using_skill == true, "np._using_skill is true after start_skill_cast")
+ _check(np.is_lock() == true, "is_lock() is true during active skill cast")
+ _check(np._can_attack() == false, "_can_attack() is false while using skill")
+ _check(np._can_use_skill() == false, "_can_use_skill() is false while using skill")
+
+ # 模拟技能动作播完切回 wait
+ np._on_motion_bound("wait")
+ _check(np._using_skill == false, "_using_skill reset to false on motion_bound('wait')")
+ _check(np.is_lock() == false, "is_lock() is false after motion_bound('wait')")
+ _check(np._can_attack() == true, "_can_attack() restored after skill finish")
+ _check(np._can_use_skill() == true, "_can_use_skill() restored after skill finish")
+
+ # 模拟技能动作超时门控(例如无动画或 headless 模式)
+ np.start_skill_cast(0.5, false)
+ _check(np.is_lock() == true, "is_lock() is true for new skill cast")
+ np._local_time += 0.6
+ _check(np.is_lock() == false, "is_lock() auto-unlocks after skill duration timeout")
+ _check(np._using_skill == false, "_using_skill auto-resets after skill duration timeout")
+
+ # 模拟冲锋技能(charge skill)不锁移动
+ np.start_skill_cast(0.5, true)
+ _check(np.is_lock() == false, "is_lock() does not lock movement during charge skill")
+ np._on_motion_bound("wait")
+
+ pv.free()
+ np.free()
+
+# 3. 怪物死亡测试:HP<=0 置 dead,名条脱离,_process 不受网络位移包干扰
+func _test_dead_mob_handling() -> void:
+ print("\n--- Testing Dead Mob Persistence & Despawn ---")
+ var nw := NetWorld.new()
+ var mob := Node3D.new()
+ mob.position = Vector3(10, 0, 10)
+ nw._by_vid[1001] = mob
+
+ var tag := Label3D.new()
+ tag.name = "NameTag"
+ mob.add_child(tag)
+
+ nw._on_dead(1001)
+ _check(bool(mob.get_meta("dead", false)) == true, "mob node marked with dead meta")
+ _check(tag.visible == false, "text tail NameTag hidden on death")
+
+ mob.free()
+ nw.free()
+
+# 4. 掉落物拾取测试:名条拾取、模型拾取、精准世界坐标
+func _test_ground_item_pick_and_pos() -> void:
+ print("\n--- Testing Ground Item Pick & World Pos ---")
+ var gi := GroundItems.new()
+ var drop_node := Node3D.new()
+ drop_node.position = Vector3(50, 0, 50)
+ drop_node.name = "drop_2001"
+ gi.add_child(drop_node)
+ gi._by_vid[2001] = drop_node
+
+ var tag := Label3D.new()
+ tag.name = "tag"
+ tag.position = Vector3(0, 0.75, 0)
+ drop_node.add_child(tag)
+
+ # 测试 get_item_world_pos
+ var wpos = gi.get_item_world_pos(2001)
+ _check(wpos is Vector3 and wpos.is_equal_approx(Vector3(50, 0, 50)), "get_item_world_pos returns exact position")
+
+ # 测试 mock client 拾取触发
+ var picked_vid := 0
+ var mock_client := Node.new()
+ gi.client = mock_client
+ # 动态添加 pickup_item 方法
+ mock_client.set_script(GDScript.new())
+ mock_client.set_meta("last_picked", 0)
+
+ drop_node.free()
+ mock_client.free()
+ gi.free()
diff --git a/project/test_combat_death_pickup_entry_parity.gd.uid b/project/test_combat_death_pickup_entry_parity.gd.uid
new file mode 100644
index 00000000..fdb5886e
--- /dev/null
+++ b/project/test_combat_death_pickup_entry_parity.gd.uid
@@ -0,0 +1 @@
+uid://djmr0facljis2
diff --git a/project/test_combat_replay_snapshot_parity.gd b/project/test_combat_replay_snapshot_parity.gd
new file mode 100644
index 00000000..29bb884c
--- /dev/null
+++ b/project/test_combat_replay_snapshot_parity.gd
@@ -0,0 +1,140 @@
+# test_combat_replay_snapshot_parity.gd —— 40250 战斗高光战报与首领击杀快照 1:1 回归测试套件
+extends SceneTree
+
+const CombatReplaySnapshotSystem = preload("res://combat_replay_snapshot_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_combat_replay_snapshot_parity (Direction 4: Combat Highlights 1:1) ===")
+ _test_combat_session_tracking()
+ _test_highlight_card_generation_and_rating()
+ _test_personal_bests_and_records()
+ _test_history_buffer_cap()
+ _finish()
+
+func _test_combat_session_tracking() -> void:
+ print("\n--- Test 1: Session Tracking & Hit Metrics ---")
+ var cr := CombatReplaySnapshotSystem.new()
+
+ var start_sig := {"fired": false, "name": ""}
+ cr.combat_session_started.connect(func(n: String):
+ start_sig["fired"] = true
+ start_sig["name"] = n
+ )
+
+ var s_res = cr.start_combat_session(1901, "九尾妖狐", 280000)
+ _assert(s_res["ok"] == true, "Session started")
+ _assert(cr.is_in_session == true, "is_in_session is true")
+ _assert(start_sig["fired"] == true and start_sig["name"] == "九尾妖狐", "combat_session_started emitted")
+
+ # Record attacks
+ cr.record_hit(1500, false)
+ cr.record_hit(3000, true) # Crit
+ cr.record_hit(8500, true, "三连斩") # Skill crit, highest hit so far
+ cr.record_damage_taken(650)
+
+ _assert(cr.total_damage_dealt == 13000, "Total damage dealt is 13000 (1500 + 3000 + 8500)")
+ _assert(cr.total_attacks == 3, "Total attacks is 3")
+ _assert(cr.critical_hits_count == 2, "2 critical hits")
+ _assert(cr.total_skills_used == 1, "1 skill used")
+ _assert(cr.max_single_hit == 8500, "Highest single hit tracked as 8500")
+ _assert(cr.total_damage_taken == 650, "Damage taken tracked as 650")
+
+func _test_highlight_card_generation_and_rating() -> void:
+ print("\n--- Test 2: Highlight Card Generation & S-Rank Rating ---")
+ var cr := CombatReplaySnapshotSystem.new()
+ cr.start_combat_session(1304, "黄虎领主", 320000)
+
+ var card_sig := {"fired": false, "rank": ""}
+ cr.combat_highlight_generated.connect(func(c: Dictionary):
+ card_sig["fired"] = true
+ card_sig["rank"] = c["rank"]
+ )
+
+ # Simulate 10 seconds of combat with 50,000 total damage (DPS = 5000 -> S Rank)
+ cr.update(10.0)
+ cr.record_hit(20000, true, "旋风斩")
+ cr.record_hit(30000, true, "剑气斩")
+
+ var drops: Array = [{"vnum": 50513, "name": "灵魂之石", "count": 1}]
+ var card = cr.end_combat_session(true, drops)
+
+ _assert(cr.is_in_session == false, "Session ended")
+ _assert(card["target_name"] == "黄虎领主", "Card target name matches")
+ _assert(card["total_damage"] == 50000, "Card total damage is 50000")
+ _assert(card["dps"] == 5000, "DPS is 5000 (50000 / 10s)")
+ _assert(card["rank"] == "S", "Awarded S Rank (DPS >= 4000)")
+ _assert(card["drops"].size() == 1, "Drops included in highlight card")
+ _assert(card_sig["fired"] == true and card_sig["rank"] == "S", "combat_highlight_generated signal fired")
+
+func _test_personal_bests_and_records() -> void:
+ print("\n--- Test 3: Personal Best Records & Signals ---")
+ var cr := CombatReplaySnapshotSystem.new()
+
+ var record_sig := {"fired": false, "type": "", "val": 0}
+ cr.new_personal_record.connect(func(_t: String, rtype: String, val: int):
+ record_sig["fired"] = true
+ record_sig["type"] = rtype
+ record_sig["val"] = val
+ )
+
+ # 1st Kill: Nine Tails, 10s, 30000 dmg, DPS = 3000, max hit = 10000
+ cr.start_combat_session(1901, "九尾妖狐", 280000)
+ cr.update(10.0)
+ cr.record_hit(10000, true)
+ cr.record_hit(20000, true)
+ cr.end_combat_session(true)
+
+ _assert(record_sig["fired"] == true and record_sig["type"] == "FIRST_KILL", "First kill record emitted")
+ var pb1 = cr.get_personal_best("九尾妖狐")
+ _assert(pb1["best_dps"] == 3000, "Recorded best DPS 3000")
+ _assert(pb1["highest_hit"] == 20000, "Recorded highest hit 20000")
+
+ # 2nd Kill: Nine Tails, faster kill (5s), DPS = 8000, max hit = 35000
+ record_sig["fired"] = false
+ cr.start_combat_session(1901, "九尾妖狐", 280000)
+ cr.update(5.0)
+ cr.record_hit(35000, true, "大绝招")
+ cr.record_hit(5000, false)
+ cr.end_combat_session(true)
+
+ _assert(record_sig["fired"] == true, "New personal record emitted")
+ var pb2 = cr.get_personal_best("九尾妖狐")
+ _assert(pb2["best_dps"] == 8000, "Best DPS updated to 8000")
+ _assert(pb2["highest_hit"] == 35000, "Highest hit updated to 35000")
+ _assert(pb2["fastest_time"] == 5.0, "Fastest time updated to 5.0s")
+
+func _test_history_buffer_cap() -> void:
+ print("\n--- Test 4: History Buffer Cap (Max 20 Cards) ---")
+ var cr := CombatReplaySnapshotSystem.new()
+ for i in range(25):
+ cr.start_combat_session(1, "Mob_%d" % i, 100)
+ cr.update(1.0)
+ cr.record_hit(100, false)
+ cr.end_combat_session(true)
+
+ var recents = cr.get_recent_highlights()
+ _assert(recents.size() == CombatReplaySnapshotSystem.MAX_STORED_CARDS, "History capped at max 20 cards")
+ _assert(recents[0]["target_name"] == "Mob_24", "Most recent card is at front")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Combat Replay Snapshot Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Combat Replay Snapshot parity tests passed!")
+ quit(0)
diff --git a/project/test_combat_replay_snapshot_parity.gd.uid b/project/test_combat_replay_snapshot_parity.gd.uid
new file mode 100644
index 00000000..b9a99e14
--- /dev/null
+++ b/project/test_combat_replay_snapshot_parity.gd.uid
@@ -0,0 +1 @@
+uid://c10cjpkxxmlcn
diff --git a/project/test_combat_status_effect_parity.gd b/project/test_combat_status_effect_parity.gd
new file mode 100644
index 00000000..ee2740b1
--- /dev/null
+++ b/project/test_combat_status_effect_parity.gd
@@ -0,0 +1,133 @@
+# test_combat_status_effect_parity.gd —— Metin2 40250 战斗状态异常与负面减益 1:1 测试套件
+extends SceneTree
+
+const CombatStatusEffectSystem = preload("res://combat_status_effect_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_combat_status_effect_parity (Direction 2: Combat Status Effects 1:1) ===")
+
+ test_stun_and_immunity()
+ test_slow_and_immunity()
+ test_poison_mechanics_and_regen_block()
+ test_fire_damage_ticks()
+ test_cure_and_serialization()
+
+ print("\n=======================================================")
+ print("Combat Status Effect Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_stun_and_immunity() -> void:
+ print("\n--- Test 1: Stun & IMMUNE_STUN (40250 1:1) ---")
+ var system = CombatStatusEffectSystem.new()
+
+ var attacker = {"id": 1, "is_pc": true, "stun_pct": 20}
+ var victim_normal = {"id": 101, "is_pc": false, "immune_stun": false}
+ var victim_shielded = {"id": 102, "is_pc": false, "immune_stun": true}
+
+ # 1. 普通怪物被击中眩晕 (PvE 4秒)
+ var res = system.try_apply_normal_attack_effects(attacker, victim_normal, {"stun_pct_roll": 10})
+ _assert(res["applied"].has(CombatStatusEffectSystem.EFFECT_STUN), "Applied stun to normal monster")
+ _assert(system.is_stunned(101) == true, "Target 101 is stunned")
+
+ # 2. 携带免疫眩晕盾牌的目标 (IMMUNE_STUN) -> 100% 抵抗
+ res = system.try_apply_normal_attack_effects(attacker, victim_shielded, {"stun_pct_roll": 5})
+ _assert(not res["applied"].has(CombatStatusEffectSystem.EFFECT_STUN), "Stun avoided")
+ _assert(res["resisted"].has("immune_stun"), "Resisted via immune_stun")
+ _assert(system.is_stunned(102) == false, "Target 102 is not stunned")
+
+func test_slow_and_immunity() -> void:
+ print("\n--- Test 2: Slow & IMMUNE_SLOW (40250 1:1) ---")
+ var system = CombatStatusEffectSystem.new()
+
+ var attacker = {"id": 1, "slow_pct": 15}
+ var victim_normal = {"id": 201, "immune_slow": false}
+ var victim_immune = {"id": 202, "immune_slow": true}
+
+ # 1. 成功施加减速
+ var res = system.try_apply_normal_attack_effects(attacker, victim_normal, {"slow_pct_roll": 10})
+ _assert(res["applied"].has(CombatStatusEffectSystem.EFFECT_SLOW), "Applied slow")
+ _assert(system.get_slow_reduction(201) == 30, "Speed reduced by 30 points")
+
+ # 2. 免疫减速抵抗
+ res = system.try_apply_normal_attack_effects(attacker, victim_immune, {"slow_pct_roll": 5})
+ _assert(res["resisted"].has("immune_slow"), "Resisted via immune_slow")
+ _assert(system.get_slow_reduction(202) == 0, "No speed reduction")
+
+func test_poison_mechanics_and_regen_block() -> void:
+ print("\n--- Test 3: Poison Damage, HP Regen Block & Monster Single Poison (40250 1:1) ---")
+ var system = CombatStatusEffectSystem.new()
+
+ var attacker = {"id": 1, "is_pc": true, "level": 50, "poison_pct": 100}
+ var monster = {"id": 301, "is_pc": false, "level": 50, "max_hp": 2000, "hp": 2000, "has_been_poisoned": false}
+
+ # 1. 成功施加中毒
+ var res = system.try_apply_normal_attack_effects(attacker, monster, {"poison_pct_roll": 50})
+ _assert(res["applied"].has(CombatStatusEffectSystem.EFFECT_POISON), "Applied poison")
+ _assert(system.has_effect(301, CombatStatusEffectSystem.EFFECT_POISON) == true, "Monster has poison effect")
+ _assert(system.is_natural_hp_regen_blocked(301) == true, "Poison blocks natural HP regen")
+ _assert(monster["has_been_poisoned"] == true, "Monster recorded has_been_poisoned")
+
+ # 2. 心跳更新 3 秒触发跳毒 (5% of 2000 = 100 伤害)
+ var targets_map = {301: monster}
+ system.update(3.0, targets_map)
+ _assert(monster["hp"] == 1900, "HP reduced from 2000 to 1900 after first poison tick")
+
+ # 3. 怪物在解除中毒后不再被第二次中毒 (40250 char_resist.cpp: m_bHasPoisoned && !IsPC())
+ system.clear_effect(301, CombatStatusEffectSystem.EFFECT_POISON)
+ res = system.try_apply_normal_attack_effects(attacker, monster, {"poison_pct_roll": 10})
+ _assert(not res["applied"].has(CombatStatusEffectSystem.EFFECT_POISON), "Monster rejected second poison")
+ _assert(res["resisted"].has("poison_already_poisoned_once"), "Resisted as monster poisoned once")
+
+func test_fire_damage_ticks() -> void:
+ print("\n--- Test 4: Fire Burn Damage Ticks (40250 1:1) ---")
+ var system = CombatStatusEffectSystem.new()
+
+ var victim = {"id": 401, "hp": 500}
+ var targets_map = {401: victim}
+
+ # 施加烈焰灼烧: 3 次跳伤,每次 60 点
+ system.apply_fire(401, 3, 60)
+ _assert(system.has_effect(401, CombatStatusEffectSystem.EFFECT_FIRE) == true, "Has fire effect")
+
+ # 经过 3 秒
+ system.update(3.0, targets_map)
+ _assert(victim["hp"] == 440, "HP dropped by 60 to 440")
+
+ # 经过 6 秒 (跳完剩余 2 次,共 180 伤害,生命为 500 - 180 = 320)
+ system.update(6.0, targets_map)
+ _assert(victim["hp"] == 320, "HP dropped to 320")
+ _assert(system.has_effect(401, CombatStatusEffectSystem.EFFECT_FIRE) == false, "Fire effect expired")
+
+func test_cure_and_serialization() -> void:
+ print("\n--- Test 5: Curing & State Serialization ---")
+ var system = CombatStatusEffectSystem.new()
+ system.apply_stun(501, 4.0)
+ var victim = {"id": 502, "max_hp": 1000, "hp": 1000}
+ system.apply_poison(victim)
+
+ _assert(system.has_effect(502, CombatStatusEffectSystem.EFFECT_POISON) == true, "Poison active")
+ # 使用解毒药剂清除中毒
+ var cleared = system.clear_effect(502, CombatStatusEffectSystem.EFFECT_POISON)
+ _assert(cleared == true, "Poison cleared via antidote")
+ _assert(system.has_effect(502, CombatStatusEffectSystem.EFFECT_POISON) == false, "No longer poisoned")
+
+ var data = system.serialize()
+ var restored = CombatStatusEffectSystem.new()
+ restored.deserialize(data)
+ _assert(restored.is_stunned(501) == true, "Restored stun effect")
diff --git a/project/test_combat_status_effect_parity.gd.uid b/project/test_combat_status_effect_parity.gd.uid
new file mode 100644
index 00000000..57e689e7
--- /dev/null
+++ b/project/test_combat_status_effect_parity.gd.uid
@@ -0,0 +1 @@
+uid://dq2028ngo6up5
diff --git a/project/test_combo_continuous_flow.gd b/project/test_combo_continuous_flow.gd
new file mode 100644
index 00000000..b5c32328
--- /dev/null
+++ b/project/test_combo_continuous_flow.gd
@@ -0,0 +1,210 @@
+extends SceneTree
+const NetPlay = preload("res://net_play.gd")
+
+# test_combo_continuous_flow.gd
+# 验证 40250 连击状态机:长按空格、连点空格与目标追击时的连击链(1->2->3->4)顺畅推进,
+# 绝无因外层 _attack_cd 吞键或卡死在第 1 段的现象。
+
+class FakeClient extends Node:
+ var ents := {}
+ var attacks := []
+ var moves := []
+ var _in_game := true
+ signal damage(vid: int, amount: int, flag: int)
+ signal target_info(vid: int, hp_pct: int)
+ signal entity_despawned(vid: int)
+ signal entity_dead(vid: int)
+ signal combo_changed(enabled: bool)
+ signal observer_mode_changed(enabled: bool)
+ signal entity_main_set(vid: int)
+ signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int)
+ signal points_changed()
+ signal vitals_changed()
+
+ func is_in_game() -> bool: return _in_game
+ func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
+ func get_entities() -> Array: return ents.values()
+ func attack(skill: int, vid: int) -> bool:
+ attacks.append([skill, vid])
+ return true
+ func set_target(_vid: int) -> void: pass
+ func move(func_type: int, arg: int, rot: float, x: int, y: int) -> void:
+ moves.append([func_type, arg, rot, x, y])
+
+class FakePlayerController extends Node:
+ var player: Node3D = null
+ var active := true
+ var frozen := false
+ var locked := false
+ var moving_skill := false
+ var pickables: Array[Node3D] = []
+ signal target_selected(node: Node3D)
+ signal moved(is_moving: bool)
+ signal anim_state(s: String)
+ func can_move() -> bool: return active and not frozen and not locked
+ func is_going() -> bool: return false
+ func stop() -> void: pass
+ func walk_to(_pos: Vector3) -> void: pass
+
+class FakePlayerView extends Node3D:
+ var states: Array[String] = []
+ var attack_motions: Array = []
+ var anim: FakeAnim = FakeAnim.new()
+ var attack_md := {}
+ signal motion_bound(state: String)
+
+ func play_attack_motion(mode: int, index: int, speed: float) -> void:
+ attack_motions.append([mode, index, speed])
+ states.append("attack")
+ if not attack_md.is_empty():
+ anim.md = attack_md
+
+ func set_anim_state(s: String) -> void:
+ states.append(s)
+
+ func is_in_hit_reaction() -> bool:
+ return false
+
+class FakeAnim:
+ var md := {}
+ func get_motion_data() -> Dictionary: return md
+
+var _fail := 0
+
+func _ck(cond: bool, msg: String) -> void:
+ if cond:
+ print(" [PASS] " + msg)
+ else:
+ _fail += 1
+ printerr(" [FAIL] " + msg)
+
+func _init() -> void:
+ print("=== Running test_combo_continuous_flow ===")
+ await _run_tests()
+ if _fail == 0:
+ print("\n=== ALL COMBO TESTS PASSED (0 failures) ===\n")
+ quit(0)
+ else:
+ printerr("\n=== %d COMBO TEST(S) FAILED ===\n" % _fail)
+ quit(1)
+
+func _run_tests() -> void:
+ var fc := FakeClient.new()
+ fc.ents[1000] = {"vid": 1000, "kind": 0, "name": "Hero", "dead": false, "in_safe": false}
+ fc.ents[2000] = {"vid": 2000, "kind": 2, "name": "WildDog", "dead": false, "in_safe": false}
+
+ var pcn := Node3D.new()
+ get_root().add_child(pcn)
+ pcn.position = Vector3.ZERO
+
+ var pc := FakePlayerController.new()
+ pc.player = pcn
+
+ var pv := FakePlayerView.new()
+ get_root().add_child(pv)
+
+ var np: Node = NetPlay.new()
+ get_root().add_child(np)
+ np.setup(fc, pc, null, null)
+ np.player_view = pv
+ np._main_vid = 1000
+
+ # 注册 4 段单手剑 Combo 段表 (motions: 14, 15, 16, 17)
+ var K: int = (np.MOTION_MODE_ONEHAND_SWORD << 16) | 0
+ np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
+ np.combo_class = 0
+ np.combo_motion_mode = np.MOTION_MODE_ONEHAND_SWORD
+
+ # 配置带有 ComboInputData 的 motion 属性 (典型单手剑连击:长 0.8s, 输入窗 0.15~0.5s, 0.25s 切下一段)
+ pv.attack_md = {
+ "has_combo_input": true,
+ "duration": 0.8,
+ "pre_input_time": 0.15,
+ "direct_input_time": 0.25,
+ "input_limit_time": 0.50,
+ "next_combo": 0.25,
+ }
+ pv.anim.md = pv.attack_md
+ var _unused_md := {
+ "has_combo_input": true,
+ "duration": 0.8,
+ "pre_input_time": 0.15,
+ "direct_input_time": 0.25,
+ "input_limit_time": 0.50,
+ "next_combo": 0.25,
+ }
+
+ await process_frame
+
+ print("\n--- Test 1: Space Key Hold Down Continuous 4-Hit Combo Chain ---")
+ # 玩家长按空格键
+ np.set_attack_key(true)
+
+ # 帧 1:发起第 1 段 (段号 14)
+ np._process(0.016)
+ _ck(np._combo_index == 1, "Frame 1: Space hold triggers Combo 1 (got index %d)" % np._combo_index)
+ _ck(pv.attack_motions.size() >= 1 and pv.attack_motions[-1][1] == 14, "Combo 1 plays motion 14")
+
+ # 时间推进到 0.10s (在 pre_input_time 0.15 之前):保持在段 1,且未挂起预输入
+ np._local_time = np._motion_start_t + 0.10
+ np._process(0.016)
+ _ck(np._combo_index == 1 and not np._is_pre_input, "t=0.10s (<0.15s): still in combo 1, no pre-input")
+
+ # 时间推进到 0.20s (在 [0.15s, 0.25s] 预输入窗内):因为空格仍按着,成功挂起预输入
+ np._local_time = np._motion_start_t + 0.20
+ np._process(0.016)
+ _ck(np._combo_index == 1 and np._is_pre_input, "t=0.20s in [0.15, 0.25]: pre-input successfully latched")
+
+ # 时间推进到 0.26s (>0.25s NextComboTime):ComboProcess 到点,自动推进到第 2 段 (段号 15)!
+ np._local_time = np._motion_start_t + 0.26
+ np._process(0.016)
+ _ck(np._combo_index == 2 and not np._is_pre_input, "t=0.26s (>0.25s): ComboProcess advances to Combo 2 (index %d)" % np._combo_index)
+ _ck(pv.attack_motions.size() >= 2 and pv.attack_motions[-1][1] == 15, "Combo 2 plays motion 15")
+
+ # 时间推进到段 2 的 0.20s 预输入窗
+ np._local_time = np._motion_start_t + 0.20
+ np._process(0.016)
+ _ck(np._combo_index == 2 and np._is_pre_input, "Combo 2 t=0.20s: pre-input latched for Combo 3")
+
+ # 时间推进到段 2 的 0.26s -> 自动切入第 3 段 (段号 16)!
+ np._local_time = np._motion_start_t + 0.26
+ np._process(0.016)
+ _ck(np._combo_index == 3 and not np._is_pre_input, "Combo 3 triggered (index %d, motion 16)" % np._combo_index)
+
+ # 时间推进到段 3 的 0.20s -> 预输入
+ np._local_time = np._motion_start_t + 0.20
+ np._process(0.016)
+ _ck(np._combo_index == 3 and np._is_pre_input, "Combo 3 t=0.20s: pre-input latched for Combo 4")
+
+ # 时间推进到段 3 的 0.26s -> 自动切入最后一段 (段 4,段号 17)!
+ np._local_time = np._motion_start_t + 0.26
+ np._process(0.016)
+ _ck(np._combo_index == 4 and not np._is_pre_input, "Combo 4 triggered (index %d, motion 17, last segment)" % np._combo_index)
+
+ # 段 4 播放完毕 (0.8s),回到 wait,重置 combo 索引
+ np._local_time = np._motion_start_t + 0.85
+ pv.anim.md = {"has_combo_input": false, "duration": 2.0}
+ pv.motion_bound.emit("wait")
+ _ck(np._combo_index == 0, "Motion finished back to wait -> combo_index resets to 0")
+
+ # 下一帧因为空格一直按住,立即无缝开启下一轮 Combo 1
+ np._process(0.016)
+ _ck(np._combo_index == 1, "Next frame: Space still held down seamlessly restarts Combo 1")
+
+ # 松开空格
+ np.set_attack_key(false)
+
+ print("\n--- Test 2: Rapid Space Key Tapping ---")
+ # 模拟连续按空格(每次按下抬起)
+ np._clear_combo()
+ np.set_attack_key(true)
+ np._process(0.016)
+ _ck(np._combo_index == 1, "Tap 1: starts Combo 1")
+ np.set_attack_key(false)
+
+ # 在直接输入窗内 (t=0.30s > 0.25s) 再次按空格
+ np._local_time = np._motion_start_t + 0.30
+ np.set_attack_key(true)
+ np._process(0.016)
+ _ck(np._combo_index == 2, "Tap 2 at t=0.30s (>NextComboTime) immediately advances to Combo 2")
+ np.set_attack_key(false)
diff --git a/project/test_combo_continuous_flow.gd.uid b/project/test_combo_continuous_flow.gd.uid
new file mode 100644
index 00000000..44fb7827
--- /dev/null
+++ b/project/test_combo_continuous_flow.gd.uid
@@ -0,0 +1 @@
+uid://ybydxusj7kf1
diff --git a/project/test_compass_treasure_hunt_parity.gd b/project/test_compass_treasure_hunt_parity.gd
new file mode 100644
index 00000000..b68f4184
--- /dev/null
+++ b/project/test_compass_treasure_hunt_parity.gd
@@ -0,0 +1,145 @@
+# test_compass_treasure_hunt_parity.gd —— 40250 魔石地脉探测罗盘与寻宝系统 1:1 回归测试套件
+extends SceneTree
+
+const CompassTreasureHuntSystem = preload("res://compass_treasure_hunt_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_compass_treasure_hunt_parity (Direction 4: Compass Treasure Hunt 1:1) ===")
+ _test_compass_scanning_and_charges()
+ _test_excavation_at_dowsing_node()
+ _test_key_opening()
+ _finish()
+
+func _test_compass_scanning_and_charges() -> void:
+ print("\n--- Test 1: Compass Scanning & 6-Charge Depletion ---")
+ var sys := CompassTreasureHuntSystem.new()
+ var inv: Array = [
+ {"vnum": CompassTreasureHuntSystem.VNUM_COMPASS_OF_METIN, "name": "魔石探测罗盘", "charges": 6}
+ ]
+ var player_pos := Vector2(0, 0)
+ var metins: Array = [
+ {"name": "战斗魔石", "pos": Vector2(300, 300)},
+ {"name": "嫉妒魔石", "pos": Vector2(10, 10)}, # dist ~14.14m (VERY_CLOSE)
+ {"name": "阴影魔石", "pos": Vector2(150, 20)}
+ ]
+
+ var scan_sig := {"fired": false, "target": "", "dist": 0.0, "prox": "", "charges": 0}
+ sys.compass_scanned.connect(func(t: String, d: float, _a: float, p: String, c: int):
+ scan_sig["fired"] = true
+ scan_sig["target"] = t
+ scan_sig["dist"] = d
+ scan_sig["prox"] = p
+ scan_sig["charges"] = c
+ )
+
+ # Scan 1
+ var res1 = sys.scan_nearest_metin(player_pos, metins, inv, 0)
+ _assert(res1["ok"] == true, "Compass scan 1 succeeded")
+ _assert(res1["target_name"] == "嫉妒魔石", "Nearest metin is 嫉妒魔石")
+ _assert(res1["proximity"] == CompassTreasureHuntSystem.PROXIMITY_VERY_CLOSE, "Proximity is VERY_CLOSE (< 20m)")
+ _assert(res1["charges_left"] == 5, "Charges left is 5")
+ _assert(scan_sig["fired"] == true, "compass_scanned signal emitted")
+
+ # Deplete remaining 5 charges
+ for i in range(4):
+ sys.scan_nearest_metin(player_pos, metins, inv, 0)
+
+ _assert(inv[0]["charges"] == 1, "1 charge remaining before last use")
+
+ # 6th scan consumes the compass completely
+ var res6 = sys.scan_nearest_metin(player_pos, metins, inv, 0)
+ _assert(res6["ok"] == true, "6th scan succeeded")
+ _assert(res6["charges_left"] == 0, "0 charges left")
+ _assert(inv[0] == null, "Compass destroyed after 6 charges (slot 0 is null)")
+
+func _test_excavation_at_dowsing_node() -> void:
+ print("\n--- Test 2: Treasure Excavation Nearby (< 5m) ---")
+ var sys := CompassTreasureHuntSystem.new()
+ var inv: Array = [null, null, null]
+ var nodes: Array = [
+ {"pos": Vector2(50, 50), "is_gold": true},
+ {"pos": Vector2(200, 200), "is_gold": false}
+ ]
+
+ # 1. Player is far away at (0, 0)
+ var res_far = sys.excavate_treasure(Vector2(0, 0), nodes, inv)
+ _assert(res_far["ok"] == false, "Excavation rejected when > 5m")
+ _assert(res_far["reason"] == "NO_TREASURE_NEARBY", "Reason is NO_TREASURE_NEARBY")
+
+ var exc_sig := {"fired": false, "vnum": 0, "name": ""}
+ sys.treasure_excavated.connect(func(v: int, n: String):
+ exc_sig["fired"] = true
+ exc_sig["vnum"] = v
+ exc_sig["name"] = n
+ )
+
+ # 2. Player approaches to (51, 50) -> dist is 1m <= 5m
+ var res_succ = sys.excavate_treasure(Vector2(51, 50), nodes, inv)
+ _assert(res_succ["ok"] == true, "Excavation succeeded")
+ _assert(inv[0] != null and inv[0]["vnum"] == CompassTreasureHuntSystem.VNUM_GOLD_CHEST, "Golden Chest placed into slot 0")
+ _assert(exc_sig["fired"] == true, "treasure_excavated signal emitted")
+ _assert(nodes.size() == 1, "Excavated node removed from map")
+
+func _test_key_opening() -> void:
+ print("\n--- Test 3: Golden/Silver Key Chest Opening ---")
+ var sys := CompassTreasureHuntSystem.new()
+ var inv: Array = [
+ {"vnum": CompassTreasureHuntSystem.VNUM_GOLD_CHEST, "name": "黄金宝箱"},
+ {"vnum": CompassTreasureHuntSystem.VNUM_SILVER_KEY, "name": "白银钥匙", "count": 1},
+ {"vnum": CompassTreasureHuntSystem.VNUM_GOLD_KEY, "name": "黄金钥匙", "count": 1},
+ null, null, null
+ ]
+
+ # 1. Open Golden Chest with Silver Key -> mismatch
+ var res_mismatch = sys.open_treasure_chest(0, 1, inv)
+ _assert(res_mismatch["ok"] == false, "Key mismatch rejected")
+ _assert(res_mismatch["reason"] == "KEY_MISMATCH", "Reason is KEY_MISMATCH")
+
+ var open_sig := {"fired": false, "rewards": []}
+ sys.treasure_opened.connect(func(_n: String, rew: Array):
+ open_sig["fired"] = true
+ open_sig["rewards"] = rew
+ )
+
+ # 2. Open Golden Chest with Golden Key (slot 0 & slot 2)
+ var res_succ = sys.open_treasure_chest(0, 2, inv)
+ var chest_remains = false
+ var key_remains = false
+ for it in inv:
+ if it != null:
+ if int(it.get("vnum", 0)) == CompassTreasureHuntSystem.VNUM_GOLD_CHEST:
+ chest_remains = true
+ elif int(it.get("vnum", 0)) == CompassTreasureHuntSystem.VNUM_GOLD_KEY:
+ key_remains = true
+ _assert(not chest_remains, "Chest consumed")
+ _assert(not key_remains, "Key consumed")
+ _assert(open_sig["fired"] == true, "treasure_opened signal emitted")
+ _assert(open_sig["rewards"].size() == 3, "Received 3 rare rewards")
+
+ # Check blessing scroll in inventory
+ var scroll_found := false
+ for it in inv:
+ if it != null and int(it.get("vnum", 0)) == 25040:
+ scroll_found = true
+ break
+ _assert(scroll_found == true, "Blessing Scroll found in rewards")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Compass & Treasure Hunt Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_compass_treasure_hunt_parity.gd.uid b/project/test_compass_treasure_hunt_parity.gd.uid
new file mode 100644
index 00000000..34c5d8f8
--- /dev/null
+++ b/project/test_compass_treasure_hunt_parity.gd.uid
@@ -0,0 +1 @@
+uid://k8bgm61rlj2t
diff --git a/project/test_consumables_parity.gd b/project/test_consumables_parity.gd
new file mode 100644
index 00000000..b1e1effa
--- /dev/null
+++ b/project/test_consumables_parity.gd
@@ -0,0 +1,126 @@
+# test_consumables_parity.gd —— 验证方向 4:日月神水自充能、龙神特药与传送卷轴 40250 1:1 对齐
+extends SceneTree
+
+const ConsumableSystem = preload("res://consumable_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_consumables_parity (Direction 4: Consumables & Scrolls 1:1) ===")
+ test_elixir_identification()
+ test_elixir_toggle_active()
+ test_elixir_recovery_and_exhaustion()
+ test_dragon_god_potions()
+ test_town_and_return_scrolls()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_elixir_identification() -> void:
+ print("\n--- Test 1: Sun & Moon Elixirs Identification ---")
+ _assert(ConsumableSystem.is_elixir(72723), "72723 (Sun Elixir S) is an elixir")
+ _assert(ConsumableSystem.is_hp_elixir(72723), "72723 is an HP elixir")
+ _assert(not ConsumableSystem.is_sp_elixir(72723), "72723 is not an SP elixir")
+
+ _assert(ConsumableSystem.is_elixir(72728), "72728 (Moon Elixir M) is an elixir")
+ _assert(ConsumableSystem.is_sp_elixir(72728), "72728 is an SP elixir")
+ _assert(not ConsumableSystem.is_hp_elixir(72728), "72728 is not an HP elixir")
+
+ _assert(not ConsumableSystem.is_elixir(27001), "27001 Red Potion is not an elixir")
+ _assert(ConsumableSystem.is_dragon_god_potion(71027), "71027 is Dragon God Potion")
+ _assert(not ConsumableSystem.is_dragon_god_potion(72723), "Elixir is not Dragon God Potion")
+
+func test_elixir_toggle_active() -> void:
+ print("\n--- Test 2: Sun/Moon Elixir Toggle Active (40250 char_item.cpp:3950) ---")
+ var elixir_hp := {"vnum": 72723, "sockets": [0, 0, 0]}
+
+ # Initially inactive
+ var toggle1 := ConsumableSystem.toggle_elixir(elixir_hp)
+ _assert(toggle1.ok and toggle1.active, "1st click activates HP elixir")
+ _assert(elixir_hp.sockets[0] == 1, "Socket 0 is set to 1 (Active)")
+ _assert(toggle1.affect_type == ConsumableSystem.AFFECT_AUTO_HP_RECOVERY, "Affect type is AFFECT_AUTO_HP_RECOVERY (534)")
+ _assert(elixir_hp.sockets[2] == 1000000, "Initialized capacity to 1,000,000 HP")
+
+ # 2nd click deactivates
+ var toggle2 := ConsumableSystem.toggle_elixir(elixir_hp)
+ _assert(toggle2.ok and not toggle2.active, "2nd click deactivates HP elixir")
+ _assert(elixir_hp.sockets[0] == 0, "Socket 0 is set to 0 (Inactive)")
+
+func test_elixir_recovery_and_exhaustion() -> void:
+ print("\n--- Test 3: Auto Recovery Tick & Capacity Depletion (40250 char_item.cpp:7326) ---")
+ # Elixir with 500 capacity, initially active
+ var small_elixir := {"vnum": 72723, "sockets": [1, 0, 500]}
+
+ # Player at 800/1000 HP (needs 200)
+ var tick1 := ConsumableSystem.process_elixir_recovery(small_elixir, 800, 1000)
+ _assert(tick1.recovered == 200, "Recovered 200 HP to full")
+ _assert(not tick1.exhausted, "Elixir not exhausted yet")
+ _assert(small_elixir.sockets[1] == 200, "Used amount increased to 200")
+ _assert(tick1.remaining == 300, "Remaining capacity is 300")
+
+ # Player at 1000/1000 HP (full HP)
+ var tick_full := ConsumableSystem.process_elixir_recovery(small_elixir, 1000, 1000)
+ _assert(tick_full.recovered == 0, "No recovery when already at full HP")
+ _assert(small_elixir.sockets[1] == 200, "Used amount unchanged")
+
+ # Player at 600/1000 HP (needs 400, but only 300 left in elixir)
+ var tick_deplete := ConsumableSystem.process_elixir_recovery(small_elixir, 600, 1000)
+ _assert(tick_deplete.recovered == 300, "Recovered all remaining 300 HP")
+ _assert(tick_deplete.exhausted, "Elixir is now exhausted (used >= capacity)")
+ _assert(small_elixir.sockets[1] == 500, "Used amount reached full 500")
+
+func test_dragon_god_potions() -> void:
+ print("\n--- Test 4: Dragon God Potions Buff Specs (71027..71030) ---")
+ var p_att := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_ATTACK)
+ _assert(p_att.ok and p_att.bonus_type == "POINT_ATT_BONUS" and p_att.bonus_value == 15,
+ "Dragon God Attack gives +15% attack bonus")
+ _assert(p_att.duration == 1800.0, "Dragon God Attack lasts 1800 seconds (30 minutes)")
+
+ var p_def := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_DEFENSE)
+ _assert(p_def.ok and p_def.bonus_type == "POINT_DEF_BONUS" and p_def.bonus_value == 15,
+ "Dragon God Defense gives +15% defense bonus")
+
+ var p_life := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_LIFE)
+ _assert(p_life.ok and p_life.bonus_type == "POINT_MAX_HP_PCT" and p_life.bonus_value == 20,
+ "Dragon God Life gives +20% HP bonus")
+
+ var p_mana := ConsumableSystem.use_dragon_god_potion(ConsumableSystem.VNUM_DRAGON_GOD_MANA)
+ _assert(p_mana.ok and p_mana.bonus_type == "POINT_MAX_SP_PCT" and p_mana.bonus_value == 20,
+ "Dragon God Mana gives +20% SP bonus")
+
+func test_town_and_return_scrolls() -> void:
+ print("\n--- Test 5: Town Scroll (22001) & Return Scroll (22010) ---")
+ # Town Scroll 22001
+ var dummy_item := {"vnum": ConsumableSystem.VNUM_TOWN_SCROLL}
+ var res_town := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_TOWN_SCROLL, dummy_item, 100.0, 200.0)
+ _assert(res_town.ok and res_town.action == "TOWN_WARP" and res_town.consumed,
+ "Town scroll triggers town warp and is consumed")
+
+ # Return Scroll 22010 (Empty) -> Records current coordinate (1234, 5678)
+ var return_scroll := {"vnum": ConsumableSystem.VNUM_RETURN_SCROLL, "sockets": [0, 0, 0]}
+ var res_rec := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_RETURN_SCROLL, return_scroll, 1234.0, 5678.0, 2)
+ _assert(res_rec.ok and res_rec.action == "RECORDED", "Empty return scroll records coordinate")
+ _assert(not res_rec.consumed, "Recording coordinate does not consume the scroll")
+ _assert(return_scroll.sockets[0] == 1234 and return_scroll.sockets[1] == 5678 and return_scroll.sockets[2] == 2,
+ "Recorded coordinates saved into sockets [1234, 5678, map 2]")
+
+ # Return Scroll 22010 (Recorded) -> Warps and is consumed
+ var res_warp := ConsumableSystem.use_warp_scroll(ConsumableSystem.VNUM_RETURN_SCROLL, return_scroll, 999.0, 888.0, 1)
+ _assert(res_warp.ok and res_warp.action == "WARP_TO_COORDS", "Recorded return scroll warps to saved location")
+ _assert(res_warp.consumed, "Using recorded return scroll consumes the scroll")
+ _assert(res_warp.target_x == 1234 and res_warp.target_y == 5678 and res_warp.target_map == 2,
+ "Target coordinates match recorded values")
diff --git a/project/test_consumables_parity.gd.uid b/project/test_consumables_parity.gd.uid
new file mode 100644
index 00000000..31002b43
--- /dev/null
+++ b/project/test_consumables_parity.gd.uid
@@ -0,0 +1 @@
+uid://cqioew3chvlla
diff --git a/project/test_costume_attr_transfer_parity.gd b/project/test_costume_attr_transfer_parity.gd
new file mode 100644
index 00000000..5578e389
--- /dev/null
+++ b/project/test_costume_attr_transfer_parity.gd
@@ -0,0 +1,136 @@
+# test_costume_attr_transfer_parity.gd —— Metin2 40250 时装属性转移与增益附魔 1:1 测试套件
+extends SceneTree
+
+const CostumeAttrTransferSystem = preload("res://costume_attr_transfer_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_costume_attr_transfer_parity (Direction 3: Costume Attr Transfer 1:1) ===")
+
+ test_costume_transform_70063()
+ test_costume_enchant_70064()
+ test_costume_attribute_transfer()
+ test_rejections_and_safety()
+
+ print("\n=======================================================")
+ print("Costume Attr Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_costume_transform_70063() -> void:
+ print("\n--- Test 1: Costume Transform 70063 (40250 1:1) ---")
+ var system = CostumeAttrTransferSystem.new()
+
+ var transformed_signals := []
+ system.costume_transformed.connect(func(vnum, attrs):
+ transformed_signals.append({"vnum": vnum, "attrs": attrs})
+ )
+
+ var inv: Array = [
+ {"vnum": 70063, "count": 2}, # 重铸卷轴 x2 (slot 0)
+ {"vnum": 41001, "costume_subtype": "body", "attrs": []} # 基础时装铠甲 (slot 1)
+ ]
+
+ # 重铸时装,强制赋予 3 条属性
+ var custom_attrs = [
+ {"type": "max_hp", "name": "生命上限", "value": 2000},
+ {"type": "crit_pct", "name": "暴击几率", "value": 10},
+ {"type": "att_speed", "name": "攻击速度", "value": 8}
+ ]
+ var res = system.transform_costume(inv, 0, 1, 3, custom_attrs)
+ _assert(res["ok"] == true, "Transformed costume successfully")
+ _assert(res["attrs_count"] == 3, "Acquired 3 attributes")
+ _assert(inv[1]["attrs"].size() == 3, "Costume holds 3 attributes in inventory")
+ _assert(inv[1]["attrs"][0]["value"] == 2000, "First attribute is 2000 HP")
+ _assert(inv[0]["count"] == 1, "Scroll count decremented from 2 to 1")
+ _assert(transformed_signals.size() == 1, "costume_transformed signal fired")
+
+func test_costume_enchant_70064() -> void:
+ print("\n--- Test 2: Costume Enchant 70064 (40250 1:1) ---")
+ var system = CostumeAttrTransferSystem.new()
+
+ var inv: Array = [
+ {"vnum": 70064, "count": 1}, # 附魔石 (slot 0)
+ {"vnum": 41001, "costume_subtype": "body", "attrs": [
+ {"type": "max_hp", "value": 500},
+ {"type": "max_sp", "value": 200}
+ ]}, # 带有 2 条词条的时装 (slot 1)
+ {"vnum": 41002, "costume_subtype": "body", "attrs": []} # 0 词条时装 (slot 2)
+ ]
+
+ # 1. 尝试对 0 词条时装附魔 -> 拦截
+ var res = system.enchant_costume(inv, 0, 2)
+ _assert(res["ok"] == false, "Rejected enchanting costume with 0 attributes")
+ _assert(res["reason"] == "NO_ATTRS_TO_ENCHANT", "Reason is NO_ATTRS_TO_ENCHANT")
+
+ # 2. 对 2 词条时装附魔 -> 成功保留 2 词条结构并刷新
+ var new_custom = [
+ {"type": "crit_pct", "name": "暴击几率", "value": 10},
+ {"type": "penetrate_pct", "name": "穿刺伤害几率", "value": 10}
+ ]
+ res = system.enchant_costume(inv, 0, 1, new_custom)
+ _assert(res["ok"] == true, "Enchanted costume successfully")
+ _assert(res["attrs_count"] == 2, "Preserved 2 attributes count")
+ _assert(inv[1]["attrs"][0]["type"] == "crit_pct", "New first attribute is crit_pct")
+ _assert(inv[0] == null, "Consumed enchant scroll to null")
+
+func test_costume_attribute_transfer() -> void:
+ print("\n--- Test 3: Costume Attribute Transfer 72325 (40250 1:1) ---")
+ var system = CostumeAttrTransferSystem.new()
+
+ var inv: Array = [
+ {"vnum": 72325, "count": 1}, # 转移符 (slot 0)
+ {"vnum": 41001, "costume_subtype": "body", "attrs": [
+ {"type": "max_hp", "value": 2000},
+ {"type": "crit_pct", "value": 10}
+ ]}, # 旧时装铠甲 (slot 1)
+ {"vnum": 41050, "costume_subtype": "body", "attrs": []}, # 崭新神圣铠甲 (slot 2)
+ {"vnum": 45001, "costume_subtype": "hair", "attrs": []} # 时装发型 (slot 3, 异类)
+ ]
+
+ # 1. 铠甲转移给发型 -> 异类拦截
+ var res = system.transfer_costume_attributes(inv, 0, 1, 3)
+ _assert(res["ok"] == false, "Rejected mismatch transfer (body to hair)")
+ _assert(res["reason"] == "TYPE_MISMATCH", "Reason is TYPE_MISMATCH")
+
+ # 2. 铠甲转移给铠甲 -> 成功
+ res = system.transfer_costume_attributes(inv, 0, 1, 2)
+ _assert(res["ok"] == true, "Transfer body to body succeeded")
+ _assert(inv[2]["attrs"].size() == 2, "Destination costume received 2 attributes")
+ _assert(inv[2]["attrs"][0]["value"] == 2000, "Transferred 2000 HP")
+ _assert(inv[1]["attrs"].is_empty(), "Source costume attributes cleared")
+ _assert(inv[0] == null, "Consumed transfer scroll")
+
+func test_rejections_and_safety() -> void:
+ print("\n--- Test 4: Safety Rejections (Equipped & Non-costume) ---")
+ var system = CostumeAttrTransferSystem.new()
+
+ var inv: Array = [
+ {"vnum": 70063, "count": 1},
+ {"vnum": 149, "attrs": []}, # 普通武器战神之剑+9 (slot 1, 非时装)
+ {"vnum": 41001, "costume_subtype": "body", "is_equipped": true, "attrs": []} # 穿戴中时装 (slot 2)
+ ]
+
+ # 1. 非时装物品 -> 拦截
+ var res = system.transform_costume(inv, 0, 1)
+ _assert(res["ok"] == false, "Rejected non-costume item")
+ _assert(res["reason"] == "NOT_COSTUME", "Reason is NOT_COSTUME")
+
+ # 2. 穿戴中时装 -> 拦截
+ res = system.transform_costume(inv, 0, 2)
+ _assert(res["ok"] == false, "Rejected equipped costume")
+ _assert(res["reason"] == "EQUIPPED_ITEM", "Reason is EQUIPPED_ITEM")
diff --git a/project/test_costume_attr_transfer_parity.gd.uid b/project/test_costume_attr_transfer_parity.gd.uid
new file mode 100644
index 00000000..427dd320
--- /dev/null
+++ b/project/test_costume_attr_transfer_parity.gd.uid
@@ -0,0 +1 @@
+uid://bfnliwq4oxrju
diff --git a/project/test_costume_system_parity.gd b/project/test_costume_system_parity.gd
new file mode 100644
index 00000000..36668215
--- /dev/null
+++ b/project/test_costume_system_parity.gd
@@ -0,0 +1,146 @@
+# test_costume_system_parity.gd —— 40250 独立时装衣柜系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const CostumeSystem = preload("res://costume_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_costume_system_parity (Direction 1: Costume System 1:1) ===")
+ _test_slot_type_validation()
+ _test_costume_equip_and_unequip()
+ _test_stat_stacking_and_appearance()
+ _test_costume_expiration()
+ _finish()
+
+func _test_slot_type_validation() -> void:
+ print("\n--- Test 1: Slot Type & Item Category Validation ---")
+ var cs := CostumeSystem.new()
+
+ var body_costume := {"vnum": 41001, "name": "沙漠战袍", "costume_type": "body", "model_id": 101}
+ var hair_costume := {"vnum": 45001, "name": "头巾发型", "costume_type": "hair", "model_id": 201}
+
+ # Invalid slot
+ var r_bad = cs.equip_costume(99, body_costume)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "INVALID_SLOT", "Invalid slot rejected")
+
+ # Put body costume into hair slot
+ var r_mismatch1 = cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_HAIR, body_costume)
+ _assert(r_mismatch1["ok"] == false and r_mismatch1["reason"] == "SLOT_TYPE_MISMATCH", "Body costume rejected in Hair slot")
+
+ # Put hair costume into weapon slot
+ var r_mismatch2 = cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_WEAPON, hair_costume)
+ _assert(r_mismatch2["ok"] == false and r_mismatch2["reason"] == "SLOT_TYPE_MISMATCH", "Hair costume rejected in Weapon slot")
+
+func _test_costume_equip_and_unequip() -> void:
+ print("\n--- Test 2: Equip & Unequip Operations ---")
+ var cs := CostumeSystem.new()
+ var body_item := {
+ "vnum": 41001,
+ "name": "黑骑士战袍",
+ "costume_type": "body",
+ "model_id": 105,
+ "bonuses": {"max_hp": 1000}
+ }
+
+ var equip_signal := {"fired": false, "slot": -1}
+ cs.costume_equipped.connect(func(s: int, _it: Dictionary):
+ equip_signal["fired"] = true
+ equip_signal["slot"] = s
+ )
+
+ var unequip_signal := {"fired": false, "slot": -1}
+ cs.costume_unequipped.connect(func(s: int):
+ unequip_signal["fired"] = true
+ unequip_signal["slot"] = s
+ )
+
+ # Equip
+ var r_eq = cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_BODY, body_item)
+ _assert(r_eq["ok"] == true, "Body costume equipped successfully")
+ _assert(cs.equipped_costumes[CostumeSystem.CostumeSlot.COSTUME_BODY] != null, "Body slot occupied")
+ _assert(equip_signal["fired"] == true and equip_signal["slot"] == CostumeSystem.CostumeSlot.COSTUME_BODY, "costume_equipped signal fired")
+
+ # Unequip
+ var r_uneq = cs.unequip_costume(CostumeSystem.CostumeSlot.COSTUME_BODY)
+ _assert(r_uneq["ok"] == true, "Body costume unequipped")
+ _assert(cs.equipped_costumes[CostumeSystem.CostumeSlot.COSTUME_BODY] == null, "Body slot now null")
+ _assert(unequip_signal["fired"] == true and unequip_signal["slot"] == CostumeSystem.CostumeSlot.COSTUME_BODY, "costume_unequipped signal fired")
+
+ # Unequip empty slot
+ var r_empty = cs.unequip_costume(CostumeSystem.CostumeSlot.COSTUME_BODY)
+ _assert(r_empty["ok"] == false and r_empty["reason"] == "SLOT_EMPTY", "Unequipping empty slot rejected")
+
+func _test_stat_stacking_and_appearance() -> void:
+ print("\n--- Test 3: Stat Stacking & 3D Appearance Overrides ---")
+ var cs := CostumeSystem.new()
+
+ var body := {"vnum": 41001, "costume_type": "body", "model_id": 101, "bonuses": {"max_hp": 1000, "att_pct": 5}}
+ var hair := {"vnum": 45001, "costume_type": "hair", "model_id": 202, "bonuses": {"max_hp": 500, "cast_speed": 10}}
+ var weapon := {"vnum": 49001, "costume_type": "weapon", "model_id": 303, "bonuses": {"monster_dmg_pct": 10}}
+
+ cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_BODY, body)
+ cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_HAIR, hair)
+ cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_WEAPON, weapon)
+
+ # Verify Stat Stacking
+ var bonuses = cs.get_costume_bonuses()
+ _assert(bonuses["max_hp"] == 1500, "Stacked Max HP is 1500 (1000 + 500)")
+ _assert(bonuses["att_pct"] == 5, "ATK is +5%")
+ _assert(bonuses["cast_speed"] == 10, "Casting Speed is +10")
+ _assert(bonuses["monster_dmg_pct"] == 10, "Monster Dmg is +10%")
+
+ # Verify 3D Model Overrides
+ var models = cs.get_appearance_overrides()
+ _assert(models["body_model"] == 101, "Body model is 101")
+ _assert(models["hair_model"] == 202, "Hair model is 202")
+ _assert(models["weapon_model"] == 303, "Weapon model is 303")
+
+func _test_costume_expiration() -> void:
+ print("\n--- Test 4: Duration Decay & Expiration Auto-Unequip ---")
+ var cs := CostumeSystem.new()
+
+ var temp_body := {
+ "vnum": 41002,
+ "name": "限时战袍",
+ "costume_type": "body",
+ "remaining_time": 10.0,
+ "bonuses": {"max_hp": 800}
+ }
+ cs.equip_costume(CostumeSystem.CostumeSlot.COSTUME_BODY, temp_body)
+
+ var expire_signal := {"fired": false, "name": ""}
+ cs.costume_expired.connect(func(_s: int, iname: String):
+ expire_signal["fired"] = true
+ expire_signal["name"] = iname
+ )
+
+ # Update 5.0 seconds -> still active
+ cs.update(5.0)
+ _assert(cs.equipped_costumes[CostumeSystem.CostumeSlot.COSTUME_BODY] != null, "Still active after 5s")
+ _assert(cs.get_costume_bonuses()["max_hp"] == 800, "Bonus HP still active")
+
+ # Update 6.0 seconds -> expired!
+ var expired = cs.update(6.0)
+ _assert(expired.size() == 1, "1 item expired")
+ _assert(cs.equipped_costumes[CostumeSystem.CostumeSlot.COSTUME_BODY] == null, "Auto-unequipped on expiration")
+ _assert(expire_signal["fired"] == true and expire_signal["name"] == "限时战袍", "costume_expired signal fired")
+ _assert(cs.get_costume_bonuses().is_empty(), "Bonuses cleared after expiration")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Costume System Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_costume_system_parity.gd.uid b/project/test_costume_system_parity.gd.uid
new file mode 100644
index 00000000..5533c5de
--- /dev/null
+++ b/project/test_costume_system_parity.gd.uid
@@ -0,0 +1 @@
+uid://dcffixkinh5pd
diff --git a/project/test_cube_crafting_parity.gd b/project/test_cube_crafting_parity.gd
new file mode 100644
index 00000000..cdb32e90
--- /dev/null
+++ b/project/test_cube_crafting_parity.gd
@@ -0,0 +1,158 @@
+# test_cube_crafting_parity.gd —— Metin2 40250 官方魔方炼金合成台 1:1 测试套件
+extends SceneTree
+
+const CubeCraftingSystem = preload("res://cube_crafting_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_cube_crafting_parity (Direction 3: Cube Crafting System 1:1) ===")
+
+ test_baek_go_herb_crafting()
+ test_huahn_so_dew_crafting()
+ test_jae_seon_kim_triton_sword()
+ test_recipe_and_npc_mismatch()
+ test_history_and_serialization()
+
+ print("\n=======================================================")
+ print("Cube Crafting Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_baek_go_herb_crafting() -> void:
+ print("\n--- Test 1: Baek-Go Herb Juice Crafting (40250 1:1) ---")
+ var system = CubeCraftingSystem.new()
+
+ var opened_emitted := []
+ system.cube_opened.connect(func(npc, name):
+ opened_emitted.append({"npc": npc, "name": name})
+ )
+
+ # 打开白高魔方界面
+ _assert(system.open_cube(20018) == true, "Opened cube at Baek-Go (20018)")
+ _assert(opened_emitted.size() == 1, "cube_opened emitted")
+
+ # 放入 1 朵桃花 (50721)
+ var slots: Array = [{"vnum": 50721, "count": 1}]
+ var inv: Array = []
+ var res = system.craft_item(slots, inv, 0)
+ _assert(res["ok"] == true and res["success"] == true, "Crafted Peach Flower Juice")
+ _assert(res["reward_vnum"] == 50801, "Reward vnum 50801")
+ _assert(slots[0] == null, "Cube slot material consumed")
+ _assert(inv.size() == 1 and inv[0]["vnum"] == 50801, "Inventory received juice")
+
+ # 放入 1 朵紫丁香 (50723) -> 批量产出 100 瓶药水 (50803)
+ slots = [{"vnum": 50723, "count": 1}]
+ res = system.craft_item(slots, inv, 0)
+ _assert(res["ok"] == true, "Crafted Lilac Juice batch")
+ _assert(res["reward_count"] == 100, "Yielded 100 count")
+ system.close_cube()
+ _assert(system.is_cube_open == false, "Closed cube")
+
+func test_huahn_so_dew_crafting() -> void:
+ print("\n--- Test 2: Huahn-So Dew Crafting & Gold Fee (40250 1:1) ---")
+ var system = CubeCraftingSystem.new()
+ system.open_cube(20022) # 幻素
+
+ # 材料: 桃花汁 x10 (50801) + 空瓶 x10 (70031)
+ var slots: Array = [
+ {"vnum": 50801, "count": 10},
+ {"vnum": 70031, "count": 10}
+ ]
+ var inv: Array = []
+
+ # 金币不足拦截 (需要 10,000 金币,玩家只有 5,000)
+ var res = system.craft_item(slots, inv, 5000)
+ _assert(res["ok"] == false, "Rejected due to insufficient gold")
+ _assert(res["reason"] == "NOT_ENOUGH_GOLD", "Reason is NOT_ENOUGH_GOLD")
+ _assert(slots[0]["count"] == 10, "Materials not consumed on gold error")
+
+ # 金币充足,成功合成
+ res = system.craft_item(slots, inv, 20000)
+ _assert(res["ok"] == true and res["success"] == true, "Crafted Peach Flower Dew (50821)")
+ _assert(res["remaining_gold"] == 10000, "Deducted 10,000 gold fee")
+ _assert(res["reward_vnum"] == 50821, "Received Dew item 50821")
+
+func test_jae_seon_kim_triton_sword() -> void:
+ print("\n--- Test 3: Jae-Seon Kim Triton Sword Crafting (40250 1:1) ---")
+ var system = CubeCraftingSystem.new()
+ system.open_cube(20383) # 金在善
+
+ # 1. 战神之剑+5 (145) + 30500 x10 + 30505 x10 (成功率 5%)
+ var slots: Array = [
+ {"vnum": 145, "count": 1},
+ {"vnum": 30500, "count": 10},
+ {"vnum": 30505, "count": 10}
+ ]
+ var inv: Array = []
+
+ # 模拟失败 (roll = 50 > 5)
+ var res = system.craft_item(slots, inv, 150000, 50)
+ _assert(res["ok"] == true and res["success"] == false, "Low tier Triton craft failed")
+ _assert(res["remaining_gold"] == 50000, "Fee deducted even on failure")
+ _assert(inv.is_empty(), "No reward given on failure")
+
+ # 2. 战神之剑+9 (149) + 30500 x10 + 30505 x10 (保底 100% 成功)
+ slots = [
+ {"vnum": 149, "count": 1},
+ {"vnum": 30500, "count": 10},
+ {"vnum": 30505, "count": 10}
+ ]
+ res = system.craft_item(slots, inv, 100000, 10)
+ _assert(res["ok"] == true and res["success"] == true, "Master tier +9 Triton craft succeeded 100%")
+ _assert(res["reward_vnum"] == 460, "Reward is Triton Sword+0 (460)")
+ _assert(inv.size() == 1 and inv[0]["vnum"] == 460, "Triton Sword added to inventory")
+
+func test_recipe_and_npc_mismatch() -> void:
+ print("\n--- Test 4: Recipe & NPC Mismatch Handling ---")
+ var system = CubeCraftingSystem.new()
+ system.open_cube(20018) # 白高
+
+ # 放入海神之剑的材料到白高面前 -> 配方不匹配
+ var slots: Array = [
+ {"vnum": 149, "count": 1},
+ {"vnum": 30500, "count": 10},
+ {"vnum": 30505, "count": 10}
+ ]
+ var inv: Array = []
+ var res = system.craft_item(slots, inv, 100000)
+ _assert(res["ok"] == false, "Baek-Go cannot craft Triton Sword")
+ _assert(res["reason"] == "NO_MATCHING_RECIPE", "Reason is NO_MATCHING_RECIPE")
+
+ # 窗口关闭时调用 -> 拦截
+ system.close_cube()
+ res = system.craft_item(slots, inv, 100000)
+ _assert(res["ok"] == false, "Rejected when cube closed")
+ _assert(res["reason"] == "CUBE_NOT_OPEN", "Reason is CUBE_NOT_OPEN")
+
+func test_history_and_serialization() -> void:
+ print("\n--- Test 5: Crafting History & Serialization ---")
+ var system = CubeCraftingSystem.new()
+ system.open_cube(20018)
+ var slots: Array = [{"vnum": 50721, "count": 1}]
+ var inv: Array = []
+ system.craft_item(slots, inv, 0)
+
+ var hist = system.get_history()
+ _assert(hist.size() == 1, "Recorded 1 crafting history")
+ _assert(hist[0]["recipe_id"] == "herb_peach_flower", "Recorded recipe herb_peach_flower")
+
+ var data = system.serialize()
+ var restored = CubeCraftingSystem.new()
+ restored.deserialize(data)
+ _assert(restored.get_history().size() == 1, "Restored 1 history record")
+ system.clear_history()
+ _assert(system.get_history().is_empty(), "Cleared history")
diff --git a/project/test_cube_crafting_parity.gd.uid b/project/test_cube_crafting_parity.gd.uid
new file mode 100644
index 00000000..0a28ab62
--- /dev/null
+++ b/project/test_cube_crafting_parity.gd.uid
@@ -0,0 +1 @@
+uid://c48cl6a1t5f5v
diff --git a/project/test_cursed_monolith_spire_parity.gd b/project/test_cursed_monolith_spire_parity.gd
new file mode 100644
index 00000000..ac536d9e
--- /dev/null
+++ b/project/test_cursed_monolith_spire_parity.gd
@@ -0,0 +1,157 @@
+# test_cursed_monolith_spire_parity.gd —— 40250 封印魔塔限时防守挑战 1:1 回归测试套件
+extends SceneTree
+
+const CursedMonolithSpireSystem = preload("res://cursed_monolith_spire_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_cursed_monolith_spire_parity (Direction 2: Cursed Monolith Spire 1:1) ===")
+ _test_start_trial_and_waves()
+ _test_full_victory_and_chest()
+ _test_timeout_defeat()
+ _finish()
+
+func _test_start_trial_and_waves() -> void:
+ print("\n--- Test 1: Spire Emergence & Multi-Wave Progression ---")
+ var spire := CursedMonolithSpireSystem.new()
+
+ var emerge_sig := {"fired": false, "time": 0.0}
+ spire.spire_emerged.connect(func(_p: Vector3, t: float):
+ emerge_sig["fired"] = true
+ emerge_sig["time"] = t
+ )
+
+ var wave_sig := {"fired": false, "wave": 0, "count": 0}
+ spire.wave_spawned.connect(func(w: int, _n: String, c: int):
+ wave_sig["fired"] = true
+ wave_sig["wave"] = w
+ wave_sig["count"] = c
+ )
+
+ var res = spire.start_trial(Vector3(100, 0, 100), 50)
+ _assert(res["ok"] == true, "Trial started")
+ _assert(spire.is_active == true, "Spire trial is active")
+ _assert(emerge_sig["fired"] == true and emerge_sig["time"] == 180.0, "spire_emerged signal emitted with 180s")
+ _assert(wave_sig["fired"] == true and wave_sig["wave"] == 1 and wave_sig["count"] == 6, "Wave 1 spawned 6 mobs")
+ _assert(spire.active_mobs.size() == 6, "6 active mobs in wave 1")
+
+ # Starting another trial while active must fail
+ var fail_dup = spire.start_trial(Vector3.ZERO)
+ _assert(fail_dup["ok"] == false and fail_dup["reason"] == "TRIAL_ALREADY_ACTIVE", "Cannot start duplicate trial")
+
+ # Clear wave 1 (kill all 6 mobs)
+ var mob_ids = spire.active_mobs.keys().duplicate()
+ for i in range(mob_ids.size()):
+ spire.kill_mob(mob_ids[i])
+
+ # Wave 2 should have automatically triggered (2 mobs)
+ _assert(spire.current_wave == 2, "Transitioned to Wave 2")
+ _assert(spire.active_mobs.size() == 2, "Wave 2 has 2 mobs")
+
+func _test_full_victory_and_chest() -> void:
+ print("\n--- Test 2: Full Victory & Ancient Chest Looting ---")
+ var spire := CursedMonolithSpireSystem.new()
+ spire.start_trial(Vector3.ZERO, 60)
+
+ var victory_sig := {"fired": false, "success": false, "time": 0.0}
+ spire.trial_completed.connect(func(s: bool, t: float):
+ victory_sig["fired"] = true
+ victory_sig["success"] = s
+ victory_sig["time"] = t
+ )
+
+ var chest_sig := {"fired": false, "vnum": 0}
+ spire.reward_chest_spawned.connect(func(v: int, _p: Vector3):
+ chest_sig["fired"] = true
+ chest_sig["vnum"] = v
+ )
+
+ # Clear wave 1
+ var w1_ids = spire.active_mobs.keys().duplicate()
+ for mid in w1_ids:
+ spire.kill_mob(mid)
+
+ # Clear wave 2
+ var w2_ids = spire.active_mobs.keys().duplicate()
+ for mid in w2_ids:
+ spire.kill_mob(mid)
+
+ _assert(spire.current_wave == 3, "Reached Wave 3 (Ancient Guardian)")
+ _assert(spire.active_mobs.size() == 1, "Wave 3 has 1 boss")
+
+ # Advance 20 seconds of game time
+ spire.update(20.0)
+
+ # Kill final boss
+ var boss_id = spire.active_mobs.keys()[0]
+ var boss_kill = spire.kill_mob(boss_id)
+ _assert(boss_kill["ok"] == true and boss_kill["victory"] == true, "Boss defeated, victory achieved")
+ _assert(spire.is_active == false and spire.is_victory == true, "Trial finished in victory")
+ _assert(victory_sig["fired"] == true and victory_sig["success"] == true, "trial_completed emitted victory")
+ _assert(chest_sig["fired"] == true and chest_sig["vnum"] == 50130, "reward_chest_spawned emitted 50130")
+ _assert(spire.chest_available == true, "Chest available for looting")
+
+ # Open Chest
+ var drops = spire.open_reward_chest()
+ _assert(drops.size() == 4, "Chest gave 4 rewards")
+ _assert(spire.chest_available == false, "Chest emptied")
+
+ var has_scroll := false
+ var has_soul_stone := false
+ var has_marble := false
+ var has_gold := false
+
+ for d in drops:
+ if d["vnum"] == 71003: has_scroll = true
+ elif d["vnum"] == 50513: has_soul_stone = true
+ elif d["vnum"] == 70024: has_marble = true
+ elif d["vnum"] == 1 and d["count"] >= 3000000: has_gold = true
+
+ _assert(has_scroll, "Reward contains Blessing Scroll (71003)")
+ _assert(has_soul_stone, "Reward contains Soul Stone (50513)")
+ _assert(has_marble, "Reward contains Blessing Marble (70024)")
+ _assert(has_gold, "Reward contains 3.5M gold")
+
+ # Second open attempt gives nothing
+ var empty = spire.open_reward_chest()
+ _assert(empty.is_empty(), "Cannot open chest twice")
+
+func _test_timeout_defeat() -> void:
+ print("\n--- Test 3: Timeout Defeat (180s expired) ---")
+ var spire := CursedMonolithSpireSystem.new()
+ spire.start_trial(Vector3.ZERO, 50)
+
+ var defeat_sig := {"fired": false, "success": true}
+ spire.trial_completed.connect(func(s: bool, _t: float):
+ defeat_sig["fired"] = true
+ defeat_sig["success"] = s
+ )
+
+ # Simulate 185 seconds passing without clearing mobs
+ var tick = spire.update(185.0)
+ _assert(tick["victory"] == false, "Defeat on timeout")
+ _assert(spire.is_active == false and spire.is_victory == false, "Trial marked as inactive defeat")
+ _assert(defeat_sig["fired"] == true and defeat_sig["success"] == false, "trial_completed emitted defeat")
+ _assert(spire.chest_available == false, "No chest awarded on defeat")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Cursed Monolith Spire Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Cursed Monolith Spire parity tests passed!")
+ quit(0)
diff --git a/project/test_cursed_monolith_spire_parity.gd.uid b/project/test_cursed_monolith_spire_parity.gd.uid
new file mode 100644
index 00000000..c8228a6e
--- /dev/null
+++ b/project/test_cursed_monolith_spire_parity.gd.uid
@@ -0,0 +1 @@
+uid://db1h7dcg2utjx
diff --git a/project/test_death_restart_parity.gd b/project/test_death_restart_parity.gd
new file mode 100644
index 00000000..cdfb6d07
--- /dev/null
+++ b/project/test_death_restart_parity.gd
@@ -0,0 +1,142 @@
+# test_death_restart_parity.gd —— 死亡结算与复活面板 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_death_restart_parity.gd
+extends SceneTree
+
+const DeathUI = preload("res://ui/death_ui.gd")
+
+class MockClient extends Node:
+ signal entity_dead(vid: int)
+ signal vitals_changed(vid: int)
+ signal phase_changed(phase: String)
+ var sent_chat_commands: Array[String] = []
+ var entity_data := {"hp": 0, "dead": true}
+
+ func get_main_vid() -> int: return 1
+ func get_entity(vid: int) -> Dictionary:
+ return entity_data if vid == 1 else {}
+ func say(type: int, text: String) -> bool:
+ sent_chat_commands.append(text)
+ return true
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_death_restart_parity (Direction 4: RestartDialog 1:1) ===")
+ _test_dialog_layout()
+ _test_10s_cooldown_countdown()
+ _test_restart_commands()
+ _test_auto_close_on_revive()
+ _finish()
+
+func _test_dialog_layout() -> void:
+ print("\n--- Test 1: 40250 200x88 Geometry & Button Placement ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var death_ui := DeathUI.new()
+ get_root().add_child(death_ui)
+ death_ui.setup(client, get_root())
+
+ _assert(death_ui.is_open() == false, "Restart dialog initially closed")
+ death_ui.show_dialog()
+ _assert(death_ui.is_open() == true, "Restart dialog opened")
+ _assert(death_ui._board.size == Vector2(200, 88), "Board size is 200x88 (restartdialog.py)")
+ _assert(death_ui._btn_restart_here.position == Vector2(10, 17), "Restart here button position is (10, 17)")
+ _assert(death_ui._btn_restart_town.position == Vector2(10, 47), "Restart town button position is (10, 47)")
+
+ death_ui.queue_free()
+ client.queue_free()
+
+func _test_10s_cooldown_countdown() -> void:
+ print("\n--- Test 2: 10s Restart Here Cooldown Protection (cmd_general.cpp) ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var death_ui := DeathUI.new()
+ get_root().add_child(death_ui)
+ death_ui.setup(client, get_root())
+ death_ui.show_dialog()
+
+ _assert(death_ui._btn_restart_here.disabled == true, "Restart here button initially disabled")
+ _assert("10 秒" in death_ui._btn_restart_here.text, "Displays initial 10s cooldown")
+
+ # Advance 5.0 seconds
+ death_ui._process(5.0)
+ _assert(death_ui._btn_restart_here.disabled == true, "Still disabled at 5 seconds")
+ _assert("5 秒" in death_ui._btn_restart_here.text, "Countdown shows 5 seconds remaining")
+
+ # Advance remaining 5.0 seconds
+ death_ui._process(5.1)
+ _assert(death_ui._btn_restart_here.disabled == false, "Enabled once countdown reaches 0")
+ _assert(death_ui._btn_restart_here.text == "原地复活", "Text becomes '原地复活' with no cooldown suffix")
+
+ death_ui.queue_free()
+ client.queue_free()
+
+func _test_restart_commands() -> void:
+ print("\n--- Test 3: Restart Chat Commands (/restart_town & /restart_here) ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var death_ui := DeathUI.new()
+ get_root().add_child(death_ui)
+ death_ui.setup(client, get_root())
+ death_ui.show_dialog()
+
+ # Restart town button is clickable immediately
+ death_ui._btn_restart_town.pressed.emit()
+ _assert(client.sent_chat_commands.size() == 1, "Town button sent command")
+ _assert(client.sent_chat_commands[0] == "/restart_town", "Sent command is '/restart_town'")
+
+ # Restart here button while in cooldown should NOT send packet
+ death_ui._here_cooldown_remaining = 8.0
+ death_ui._on_restart_here_pressed()
+ _assert(client.sent_chat_commands.size() == 1, "No command sent while restart_here in cooldown")
+
+ # Restart here button after cooldown sends /restart_here
+ death_ui._here_cooldown_remaining = 0.0
+ death_ui._on_restart_here_pressed()
+ _assert(client.sent_chat_commands.size() == 2, "Restart here sent command after cooldown")
+ _assert(client.sent_chat_commands[1] == "/restart_here", "Sent command is '/restart_here'")
+
+ death_ui.queue_free()
+ client.queue_free()
+
+func _test_auto_close_on_revive() -> void:
+ print("\n--- Test 4: Auto-Close on Revive ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var death_ui := DeathUI.new()
+ get_root().add_child(death_ui)
+ death_ui.setup(client, get_root())
+ death_ui.show_dialog()
+ _assert(death_ui.is_open() == true, "Death dialog open")
+
+ # Simulate player revived (HP > 0, dead = false)
+ client.entity_data = {"hp": 100, "dead": false}
+ client.vitals_changed.emit(1)
+
+ _assert(death_ui.is_open() == false, "Death dialog automatically hides when player is revived")
+
+ death_ui.queue_free()
+ client.queue_free()
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_death_restart_parity.gd.uid b/project/test_death_restart_parity.gd.uid
new file mode 100644
index 00000000..c2cb69dc
--- /dev/null
+++ b/project/test_death_restart_parity.gd.uid
@@ -0,0 +1 @@
+uid://dukwekgepb3hj
diff --git a/project/test_demon_tower_parity.gd b/project/test_demon_tower_parity.gd
new file mode 100644
index 00000000..33e94d1b
--- /dev/null
+++ b/project/test_demon_tower_parity.gd
@@ -0,0 +1,140 @@
+# test_demon_tower_parity.gd —— 验证方向 1:恶魔之塔 1~9 层单机爬塔循环与塔顶免费铁匠 40250 1:1 对齐
+extends SceneTree
+
+const DemonTowerSystem = preload("res://demon_tower_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_demon_tower_parity (Direction 1: Demon Tower 1~9 Parity 1:1) ===")
+ test_floors_1_to_3()
+ test_floor_4_puzzle()
+ test_floor_5_seals()
+ test_floor_6_blacksmith_and_free_refine()
+ test_floors_7_to_9_reaper()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_floors_1_to_3() -> void:
+ print("\n--- Test 1: Floors 1 to 3 Progression ---")
+ var tower := DemonTowerSystem.new()
+ tower.start_tower()
+ _assert(tower.get_floor() == 1, "Tower started on Floor 1")
+ _assert("恶魔入口" in tower.get_floor_name(), "Floor 1 name matches")
+
+ # Floor 1: Metin 8015
+ _assert(not tower.on_floor1_metin_destroyed(9999), "Wrong metin does not advance")
+ _assert(tower.on_floor1_metin_destroyed(DemonTowerSystem.VNUM_METIN_FLOOR1), "Destroying Metin 8015 advances to Floor 2")
+ _assert(tower.get_floor() == 2, "Now on Floor 2")
+
+ # Floor 2: Clear mobs
+ _assert(not tower.on_floor2_mobs_cleared(10), "Floor 2 remains while mobs remain")
+ _assert(tower.on_floor2_mobs_cleared(0), "Clearing mobs advances to Floor 3")
+ _assert(tower.get_floor() == 3, "Now on Floor 3")
+
+ # Floor 3: Demon King 1091
+ _assert(tower.on_floor3_boss_killed(DemonTowerSystem.VNUM_DEMON_KING), "Killing Demon King 1091 advances to Floor 4")
+ _assert(tower.get_floor() == 4, "Now on Floor 4")
+
+func test_floor_4_puzzle() -> void:
+ print("\n--- Test 2: Floor 4 7-Metin Puzzle ---")
+ var tower := DemonTowerSystem.new()
+ tower.current_floor = 4
+ tower.is_active = true
+ tower.setup_floor4_metins()
+
+ var real_idx := tower.floor4_real_metin_idx
+ _assert(real_idx >= 0 and real_idx < 7, "Real metin index is between 0 and 6")
+
+ var fake_idx := (real_idx + 1) % 7
+ var res_fake := tower.on_floor4_metin_hit(fake_idx)
+ _assert(res_fake.ok and not res_fake.is_real, "Hitting fake metin gives clue, does not advance")
+ _assert(tower.get_floor() == 4, "Still on Floor 4")
+
+ var res_real := tower.on_floor4_metin_hit(real_idx)
+ _assert(res_real.ok and res_real.is_real, "Hitting real metin advances to Floor 5")
+ _assert(tower.get_floor() == 5, "Now on Floor 5")
+
+func test_floor_5_seals() -> void:
+ print("\n--- Test 3: Floor 5 5-Stone Pillar Seals ---")
+ var tower := DemonTowerSystem.new()
+ tower.current_floor = 5
+ tower.is_active = true
+
+ # Unlocking with wrong key fails
+ var res_bad := tower.on_floor5_unlock_pillar(12345)
+ _assert(not res_bad.ok, "Wrong key fails")
+
+ # Unlocking pillars 1..4
+ for i in 4:
+ var res := tower.on_floor5_unlock_pillar(DemonTowerSystem.VNUM_ZIN_KEY_FLOOR5)
+ _assert(res.ok and not res.completed, "Pillar %d unlocked" % (i + 1))
+
+ # Unlocking 5th pillar completes floor 5
+ var res5 := tower.on_floor5_unlock_pillar(DemonTowerSystem.VNUM_ZIN_KEY_FLOOR5)
+ _assert(res5.ok and res5.completed, "5th pillar unlocked, floor completed")
+ _assert(tower.get_floor() == 6, "Now on Floor 6")
+
+func test_floor_6_blacksmith_and_free_refine() -> void:
+ print("\n--- Test 4: Floor 6 Elite Demon King & Free Blacksmith ---")
+ var tower := DemonTowerSystem.new()
+ tower.current_floor = 6
+ tower.is_active = true
+
+ # Kill Elite Demon King 1092
+ var res_boss := tower.on_floor6_boss_killed(DemonTowerSystem.VNUM_ELITE_DEMON_KING)
+ _assert(res_boss.ok, "Elite Demon King killed")
+ _assert(tower.floor6_blacksmith_vnum in [20074, 20075, 20076], "Tower Blacksmith spawned")
+
+ # Free Refine with Blacksmith
+ tower.floor6_blacksmith_vnum = DemonTowerSystem.NPC_TOWER_WEAPON_SMITH
+ var sword := {"vnum": 18} # Sword +8
+ var res_refine := tower.perform_tower_free_refine(sword, 1)
+ _assert(res_refine.ok and res_refine.new_vnum == 19, "Sword+8 free refined to Sword+9 without materials")
+
+ # Second attempt rejected (only 1 free refine permitted per run)
+ var res_refine2 := tower.perform_tower_free_refine(sword, 1)
+ _assert(not res_refine2.ok and res_refine2.code == "ALREADY_USED", "Second free refine attempt rejected")
+
+ # Advance to floor 7 level gate (requires level 75)
+ var res_adv_low := tower.advance_to_floor7(70)
+ _assert(not res_adv_low.ok and res_adv_low.code == "LEVEL_TOO_LOW", "Level < 75 rejected from Floor 7")
+
+ var res_adv_ok := tower.advance_to_floor7(75)
+ _assert(res_adv_ok.ok, "Level 75 advances to Floor 7")
+ _assert(tower.get_floor() == 7, "Now on Floor 7")
+
+func test_floors_7_to_9_reaper() -> void:
+ print("\n--- Test 5: Floors 7 to 9 & Death Reaper Defeat ---")
+ var tower := DemonTowerSystem.new()
+ tower.current_floor = 7
+ tower.is_active = true
+
+ # Floor 7 Map 30302
+ _assert(tower.on_floor7_map_used(DemonTowerSystem.VNUM_ZIN_MAP_FLOOR7), "Using Zin Map 30302 advances to Floor 8")
+ _assert(tower.get_floor() == 8, "Now on Floor 8")
+
+ # Floor 8 Key 30304
+ _assert(tower.on_floor8_key_used(DemonTowerSystem.VNUM_ZIN_KEY_FLOOR8), "Using Zin Key 30304 advances to Floor 9")
+ _assert(tower.get_floor() == 9, "Now on Floor 9")
+
+ # Floor 9 Final Boss: Death Reaper 1093
+ var res_reaper := tower.on_floor9_boss_killed(DemonTowerSystem.VNUM_DEATH_REAPER)
+ _assert(res_reaper.ok and res_reaper.code == "TOWER_CLEAR", "Death Reaper defeated, tower cleared")
+ _assert(res_reaper.drop_vnum == DemonTowerSystem.VNUM_DEATH_REAPER_CHEST, "Rewards Death Reaper Chest (50082)")
+ _assert(not tower.is_active, "Tower challenge marked completed")
diff --git a/project/test_demon_tower_parity.gd.uid b/project/test_demon_tower_parity.gd.uid
new file mode 100644
index 00000000..7795707e
--- /dev/null
+++ b/project/test_demon_tower_parity.gd.uid
@@ -0,0 +1 @@
+uid://fdoa2j14nlxf
diff --git a/project/test_devils_catacomb_parity.gd b/project/test_devils_catacomb_parity.gd
new file mode 100644
index 00000000..33f86a62
--- /dev/null
+++ b/project/test_devils_catacomb_parity.gd
@@ -0,0 +1,223 @@
+# test_devils_catacomb_parity.gd —— 40250 地下墓穴与冥王阿兹瑞尔 1:1 纯净回归测试套件
+extends SceneTree
+
+const DevilsCatacombSystem = preload("res://devils_catacomb_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_devils_catacomb_parity (Direction 1: Devil's Catacomb 1:1) ===")
+ _test_entry_requirements()
+ _test_floor_1_to_4_progression()
+ _test_floor_5_tartaros_and_obelisk()
+ _test_floor_6_charon()
+ _test_floor_7_azrael_battle_and_drops()
+ _test_catacomb_timeout()
+ _finish()
+
+func _test_entry_requirements() -> void:
+ print("\n--- Test 1: Entry Requirements (Lv75 & Soul Key 30311) ---")
+ var dcs := DevilsCatacombSystem.new()
+ var inv: Array = [
+ {"vnum": 30311, "name": "灵魂水晶钥匙", "count": 1}
+ ]
+
+ # Level < 75 rejected
+ var r1 = dcs.enter_catacomb(70, inv)
+ _assert(r1["ok"] == false and r1["reason"] == "LEVEL_TOO_LOW", "Level < 75 rejected")
+
+ # Missing key
+ var r2 = dcs.enter_catacomb(75, [null])
+ _assert(r2["ok"] == false and r2["reason"] == "MISSING_SOUL_KEY", "Missing soul crystal key rejected")
+
+ # Valid entry
+ var r3 = dcs.enter_catacomb(75, inv)
+ _assert(r3["ok"] == true, "Entry succeeds with Lv 75 and Soul Crystal Key")
+ _assert(dcs.is_active == true, "Catacomb is active")
+ _assert(dcs.current_floor == 1, "Starts on Floor 1")
+ _assert(dcs.remaining_time == 3600.0, "60-minute countdown initialized")
+
+func _test_floor_1_to_4_progression() -> void:
+ print("\n--- Test 2: Floor 1 to 4 Progression (Key, Maze, Real Metin) ---")
+ var dcs := DevilsCatacombSystem.new()
+ var inv: Array = [
+ {"vnum": 30311, "name": "灵魂水晶钥匙", "count": 1}
+ ]
+ dcs.enter_catacomb(75, inv)
+
+ var floor_signal := {"fired": false, "floor": 0}
+ dcs.floor_advanced.connect(func(f: int):
+ floor_signal["fired"] = true
+ floor_signal["floor"] = f
+ )
+
+ # 1. Floor 1: Unlock seal using 30311 key
+ var r1 = dcs.unlock_floor_1(inv)
+ _assert(r1["ok"] == true and r1["floor"] == 2, "Unlocked Floor 1 -> Advanced to Floor 2")
+ _assert(inv[0] == null, "Soul crystal key consumed")
+ _assert(floor_signal["fired"] == true and floor_signal["floor"] == 2, "floor_advanced signal fired for floor 2")
+
+ # 2. Floor 2: Complete maze
+ var r2 = dcs.complete_floor_2()
+ _assert(r2["ok"] == true and r2["floor"] == 3, "Maze completed -> Advanced to Floor 3")
+
+ # 3. Floor 3: Break fake vs real Metin (8035)
+ dcs.correct_metin_id = 4 # Force correct ID to 4 for deterministic test
+
+ var metin_signal := {"fired": false, "correct": false}
+ dcs.metin_destroyed.connect(func(corr: bool):
+ metin_signal["fired"] = true
+ metin_signal["correct"] = corr
+ )
+
+ # Break fake metin 1
+ var r_fake = dcs.break_floor_3_metin(1)
+ _assert(r_fake["ok"] == true and r_fake["correct"] == false, "Fake metin detected")
+ _assert(dcs.current_floor == 3, "Stays on floor 3 after fake metin")
+ _assert(metin_signal["fired"] == true and metin_signal["correct"] == false, "metin_destroyed signal fired false")
+
+ # Break real metin 4
+ var r_real = dcs.break_floor_3_metin(4)
+ _assert(r_real["ok"] == true and r_real["correct"] == true, "Real metin destroyed")
+ _assert(dcs.current_floor == 4, "Advanced to Floor 4")
+ _assert(metin_signal["correct"] == true, "metin_destroyed signal fired true")
+
+ # 4. Floor 4: Complete pillar maze
+ var r4 = dcs.complete_floor_4()
+ _assert(r4["ok"] == true and r4["floor"] == 5, "Floor 4 completed -> Advanced to Floor 5")
+
+func _test_floor_5_tartaros_and_obelisk() -> void:
+ print("\n--- Test 3: Floor 5 Tartaros Defeat & Obelisk Activation ---")
+ var dcs := DevilsCatacombSystem.new()
+ dcs.enter_catacomb(75, [{"vnum": 30311}])
+ dcs.current_floor = 5 # Jump to floor 5
+
+ var inv: Array = [null]
+ var tartaros_signal := {"fired": false}
+ dcs.tartaros_killed.connect(func(_drop: Dictionary):
+ tartaros_signal["fired"] = true
+ )
+
+ # Defeat Tartaros
+ var r_tar = dcs.defeat_tartaros(inv)
+ _assert(r_tar["ok"] == true, "Tartaros defeated")
+ _assert(inv[0] != null and inv[0]["vnum"] == 30312, "Grim Soul Stone (30312) dropped into inventory")
+ _assert(tartaros_signal["fired"] == true, "tartaros_killed signal fired")
+
+ # Insert stone into obelisk
+ var obelisk_signal := {"fired": false}
+ dcs.obelisk_activated.connect(func():
+ obelisk_signal["fired"] = true
+ )
+
+ var r_ob = dcs.insert_grim_stone_to_obelisk(inv)
+ _assert(r_ob["ok"] == true and r_ob["floor"] == 6, "Obelisk activated -> Advanced to Floor 6")
+ _assert(inv[0] == null, "Grim Soul Stone consumed by obelisk")
+ _assert(obelisk_signal["fired"] == true, "obelisk_activated signal fired")
+
+func _test_floor_6_charon() -> void:
+ print("\n--- Test 4: Floor 6 Charon Defeat ---")
+ var dcs := DevilsCatacombSystem.new()
+ dcs.enter_catacomb(75, [{"vnum": 30311}])
+ dcs.current_floor = 6
+
+ var charon_signal := {"fired": false}
+ dcs.charon_killed.connect(func(): charon_signal["fired"] = true)
+
+ var r = dcs.defeat_charon()
+ _assert(r["ok"] == true and r["floor"] == 7, "Charon defeated -> Advanced to Floor 7")
+ _assert(charon_signal["fired"] == true, "charon_killed signal fired")
+ _assert(dcs.is_azrael_alive == true, "Azrael is now active on floor 7")
+ _assert(dcs.azrael_hp == 900000, "Azrael initial HP is 900,000")
+
+func _test_floor_7_azrael_battle_and_drops() -> void:
+ print("\n--- Test 5: Floor 7 Azrael Battle, Minion Summon & Drops ---")
+ var dcs := DevilsCatacombSystem.new()
+ dcs.enter_catacomb(75, [{"vnum": 30311}])
+ dcs.current_floor = 6
+ dcs.defeat_charon() # Transitions to floor 7
+
+ var azrael_hp_signal := {"fired": false, "hp": 0}
+ dcs.azrael_hp_changed.connect(func(hp: int, _max: int):
+ azrael_hp_signal["fired"] = true
+ azrael_hp_signal["hp"] = hp
+ )
+
+ # 1. Normal damage: Raw 1680 - DEF 680 = 1000 net dmg
+ var r1 = dcs.attack_azrael(1680)
+ _assert(r1["ok"] == true and r1["net_dmg"] == 1000, "Raw 1680 - 680 DEF = 1000 net dmg")
+ _assert(dcs.azrael_hp == 899000, "Azrael HP reduced to 899,000")
+ _assert(azrael_hp_signal["fired"] == true, "azrael_hp_changed signal fired")
+
+ # 2. Cross 50% HP threshold (450,000 HP) -> summons minions
+ # Current HP: 899,000. Deal 450,000 net dmg -> raw 450,680
+ var r2 = dcs.attack_azrael(450680)
+ _assert(dcs.azrael_hp == 449000, "Azrael HP below 50% (449,000)")
+ _assert(r2["summoned_minions"] == true, "Azrael summoned undead minions at <= 50% HP")
+
+ # 3. Lethal damage kills Azrael
+ var clear_signal := {"fired": false, "drops": []}
+ dcs.catacomb_cleared.connect(func(drops: Array):
+ clear_signal["fired"] = true
+ clear_signal["drops"] = drops
+ )
+
+ var r3 = dcs.attack_azrael(1000000)
+ _assert(r3["ok"] == true and r3["killed"] == true, "Azrael killed")
+ _assert(dcs.is_active == false, "Catacomb completed")
+ _assert(clear_signal["fired"] == true, "catacomb_cleared signal fired")
+
+ # Verify drops
+ var drops: Array = r3["drops"]
+ var has_chest := false
+ var has_soul_stone := false
+ var has_shield := false
+ var has_gold := false
+
+ for d in drops:
+ if d["vnum"] == 50187: # Azrael Chest
+ has_chest = true
+ elif d["vnum"] == 50513: # Soul Stone
+ has_soul_stone = true
+ elif d["vnum"] == 13149: # Titan Shield
+ has_shield = true
+ elif d["vnum"] == 1:
+ has_gold = true
+
+ _assert(has_chest, "Guaranteed Azrael Chest (50187) dropped")
+ _assert(has_soul_stone, "Guaranteed Soul Stone (50513) dropped")
+ _assert(has_shield, "Titan Shield (13149) dropped")
+ _assert(has_gold, "Yang dropped")
+
+func _test_catacomb_timeout() -> void:
+ print("\n--- Test 6: Catacomb Timeout Failure ---")
+ var dcs := DevilsCatacombSystem.new()
+ dcs.enter_catacomb(75, [{"vnum": 30311}])
+
+ var fail_signal := {"fired": false, "reason": ""}
+ dcs.catacomb_failed.connect(func(r: String):
+ fail_signal["fired"] = true
+ fail_signal["reason"] = r
+ )
+
+ dcs.update(3601.0)
+ _assert(fail_signal["fired"] == true and fail_signal["reason"] == "TIME_OUT", "Timeout failure emitted")
+ _assert(dcs.is_active == false, "Catacomb deactivated on timeout")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Devil's Catacomb Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_devils_catacomb_parity.gd.uid b/project/test_devils_catacomb_parity.gd.uid
new file mode 100644
index 00000000..1a01d545
--- /dev/null
+++ b/project/test_devils_catacomb_parity.gd.uid
@@ -0,0 +1 @@
+uid://b58pa84vnihp6
diff --git a/project/test_dragon_lair_parity.gd b/project/test_dragon_lair_parity.gd
new file mode 100644
index 00000000..68bcac88
--- /dev/null
+++ b/project/test_dragon_lair_parity.gd
@@ -0,0 +1,185 @@
+# test_dragon_lair_parity.gd —— 40250 冰龙贝兰-塞陶巢穴 1:1 纯净回归测试套件
+extends SceneTree
+
+const DragonLairSystem = preload("res://dragon_lair_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_dragon_lair_parity (Direction 1: Dragon Lair Beran-Setaou 1:1) ===")
+ _test_entry_and_key_consumption()
+ _test_metin_buffs_and_debuffs()
+ _test_metin_hp_regen()
+ _test_dragon_defeat_and_drops()
+ _test_dungeon_timeout()
+ _finish()
+
+func _test_entry_and_key_consumption() -> void:
+ print("\n--- Test 1: Entry Requirements & Key Consumption (30179 x3) ---")
+ var dls := DragonLairSystem.new()
+ var inv: Array = [
+ {"vnum": 30179, "name": "扭曲钥匙", "count": 2},
+ {"vnum": 30179, "name": "扭曲钥匙", "count": 1},
+ null
+ ]
+
+ # Level 70 rejected
+ var r1 = dls.enter_lair(70, inv)
+ _assert(r1["ok"] == false and r1["reason"] == "LEVEL_TOO_LOW", "Level < 75 rejected")
+
+ # Level 75 but lacking keys
+ var empty_inv: Array = [null]
+ var r2 = dls.enter_lair(75, empty_inv)
+ _assert(r2["ok"] == false and r2["reason"] == "MISSING_KEYS", "Missing keys rejected")
+
+ # Level 75 with 3 keys across 2 stacks succeeds
+ var r3 = dls.enter_lair(75, inv)
+ _assert(r3["ok"] == true, "Entry succeeds with Lv 75 and 3 keys")
+ _assert(inv[0] == null and inv[1] == null, "3 keys across both slots consumed")
+ _assert(dls.is_active == true, "Dungeon is active")
+ _assert(dls.remaining_time == 3600.0, "60-minute countdown initialized")
+ _assert(dls.get_alive_metin_count() == 4, "4 Metin stones initialized")
+ _assert(dls.dragon_hp == 800000, "Dragon HP initialized to 800,000")
+
+func _test_metin_buffs_and_debuffs() -> void:
+ print("\n--- Test 2: Metin Stone Buffs & Debuff Removal (8031, 8033, 8034) ---")
+ var dls := DragonLairSystem.new()
+ dls.enter_lair(80, [{"vnum": 30179, "count": 3}])
+
+ # 1. 8034 (复仇之石) ATK buff (+40%): 1800 * 1.4 = 2520
+ _assert(dls.get_dragon_atk() == 2520, "Dragon ATK has +40% buff (2520) while 8034 alive")
+
+ var metin_signal := {"fired": false, "vnum": 0, "name": ""}
+ dls.metin_destroyed.connect(func(v: int, n: String):
+ metin_signal["fired"] = true
+ metin_signal["vnum"] = v
+ metin_signal["name"] = n
+ )
+
+ var minion_signal := {"fired": false, "count": 0}
+ dls.minions_spawned.connect(func(cnt: int, _mob: int):
+ minion_signal["fired"] = true
+ minion_signal["count"] = cnt
+ )
+
+ # Destroy 8034
+ var des_res = dls.destroy_metin(8034)
+ _assert(des_res["ok"] == true, "8034 destroyed")
+ _assert(metin_signal["fired"] == true and metin_signal["vnum"] == 8034, "metin_destroyed signal fired")
+ _assert(minion_signal["fired"] == true and minion_signal["count"] == 4, "minions_spawned signal fired with 4 minions")
+ _assert(dls.get_dragon_atk() == 1800, "Dragon ATK dropped to base 1800 after 8034 destroyed")
+
+ # 2. 8031 (傲慢之石) Physical Resistance (-30% damage taken)
+ # Raw dmg: 1650. DEF: 650. Net: 1000. With 30% reduction: 700.
+ var phys_res = dls.apply_damage(1650, false)
+ _assert(phys_res["net_dmg"] == 700, "Physical damage reduced by 30% (700 net) while 8031 alive")
+
+ # Destroy 8031
+ dls.destroy_metin(8031)
+ var phys_res2 = dls.apply_damage(1650, false)
+ _assert(phys_res2["net_dmg"] == 1000, "Physical damage unreduced (1000 net) after 8031 destroyed")
+
+ # 3. 8033 (平静之石) Magic Resistance (-30% magic damage taken)
+ var mag_res = dls.apply_damage(1650, true)
+ _assert(mag_res["net_dmg"] == 700, "Magic damage reduced by 30% (700 net) while 8033 alive")
+
+ # Destroy 8033
+ dls.destroy_metin(8033)
+ var mag_res2 = dls.apply_damage(1650, true)
+ _assert(mag_res2["net_dmg"] == 1000, "Magic damage unreduced (1000 net) after 8033 destroyed")
+
+func _test_metin_hp_regen() -> void:
+ print("\n--- Test 3: 8032 Metin HP Regeneration ---")
+ var dls := DragonLairSystem.new()
+ dls.enter_lair(80, [{"vnum": 30179, "count": 3}])
+
+ # Deal 50,000 net damage (damage boss from 800,000 to 750,000)
+ dls.destroy_metin(8031) # remove physical reduction for easy calculation
+ dls.apply_damage(50650, false) # 50650 - 650 DEF = 50000 net dmg
+ _assert(dls.dragon_hp == 750000, "Dragon HP reduced to 750,000")
+
+ # Advance 5.0 seconds -> 8032 heals 2% (16,000 HP) -> HP 766,000
+ dls.update(5.0)
+ _assert(dls.dragon_hp == 766000, "8032 regenerated 16,000 HP (+2%) at 5s tick")
+
+ # Destroy 8032 -> advance 5.0s -> no healing occurs
+ dls.destroy_metin(8032)
+ dls.update(5.0)
+ _assert(dls.dragon_hp == 766000, "No regen occurs after 8032 destroyed")
+
+func _test_dragon_defeat_and_drops() -> void:
+ print("\n--- Test 4: Dragon Defeat & Epic Drop Generation ---")
+ var dls := DragonLairSystem.new()
+ dls.enter_lair(80, [{"vnum": 30179, "count": 3}])
+
+ var kill_signal := {"fired": false, "drops": []}
+ dls.dragon_defeated.connect(func(drops: Array):
+ kill_signal["fired"] = true
+ kill_signal["drops"] = drops
+ )
+
+ # Deal lethal damage
+ dls.destroy_metin(8031)
+ var res = dls.apply_damage(1000000, false)
+ _assert(res["ok"] == true and res["killed"] == true, "Beran-Setaou killed")
+ _assert(dls.is_dragon_alive == false, "is_dragon_alive is false")
+ _assert(dls.is_active == false, "Dungeon completed")
+ _assert(kill_signal["fired"] == true, "dragon_defeated signal fired")
+
+ var drops: Array = res["drops"]
+ var has_scale := false
+ var has_claw := false
+ var has_chest := false
+ var has_soul_stone := false
+ var has_gold := false
+
+ for d in drops:
+ if d["vnum"] == 71123: # Dragon Scale
+ has_scale = true
+ elif d["vnum"] == 71129: # Dragon Claw
+ has_claw = true
+ elif d["vnum"] == 50186: # Beran-Setaou Chest
+ has_chest = true
+ elif d["vnum"] == 50513: # Soul Stone
+ has_soul_stone = true
+ elif d["vnum"] == 1:
+ has_gold = true
+
+ _assert(has_scale, "Guaranteed Dragon Scale (71123) dropped")
+ _assert(has_claw, "Guaranteed Dragon Claw (71129) dropped")
+ _assert(has_chest, "Guaranteed Beran-Setaou Chest (50186) dropped")
+ _assert(has_soul_stone, "Guaranteed Soul Stone (50513) dropped")
+ _assert(has_gold, "Yang dropped")
+
+func _test_dungeon_timeout() -> void:
+ print("\n--- Test 5: Dungeon Timeout Failure ---")
+ var dls := DragonLairSystem.new()
+ dls.enter_lair(80, [{"vnum": 30179, "count": 3}])
+
+ var fail_signal := {"fired": false, "reason": ""}
+ dls.dungeon_failed.connect(func(reason: String):
+ fail_signal["fired"] = true
+ fail_signal["reason"] = reason
+ )
+
+ dls.update(3601.0)
+ _assert(fail_signal["fired"] == true and fail_signal["reason"] == "TIME_OUT", "Timeout signal emitted")
+ _assert(dls.is_active == false, "Dungeon closed on timeout")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Dragon Lair Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_dragon_lair_parity.gd.uid b/project/test_dragon_lair_parity.gd.uid
new file mode 100644
index 00000000..f4698100
--- /dev/null
+++ b/project/test_dragon_lair_parity.gd.uid
@@ -0,0 +1 @@
+uid://d4dwx8og8rh5m
diff --git a/project/test_dragon_soul_parity.gd b/project/test_dragon_soul_parity.gd
new file mode 100644
index 00000000..47c00313
--- /dev/null
+++ b/project/test_dragon_soul_parity.gd
@@ -0,0 +1,122 @@
+# test_dragon_soul_parity.gd —— 40250 龙神炼金石系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const DragonSoulSystem = preload("res://dragon_soul_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_dragon_soul_parity (Direction 1: Dragon Soul Alchemy 1:1) ===")
+ _test_stone_creation_and_attributes()
+ _test_grade_and_clarity_refinement()
+ _test_deck_equipping_and_switching()
+ _test_active_bonuses_and_duration_decay()
+ _finish()
+
+func _test_stone_creation_and_attributes() -> void:
+ print("\n--- Test 1: Stone Creation & 40250 Attribute Scaling ---")
+ # Diamond: Rough, Matt, +0
+ var diamond := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.DIAMOND, 0, 0, 0)
+ var attrs: Dictionary = diamond["attributes"]
+ _assert(attrs["int_bonus"] == 5, "Diamond +0 int_bonus is 5")
+ _assert(attrs["magic_defense"] == 2, "Diamond +0 magic_defense is 2")
+
+ # Ruby: Legendary (Grade 4), Excellent (Clarity 4), +6 Refine
+ var ruby := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, 4, 4, 6)
+ attrs = ruby["attributes"]
+ # str_bonus: 5 + (4*3) + (4*2) + 6 = 31
+ _assert(attrs["str_bonus"] == 31, "Legendary Ruby STR bonus is 31")
+ # att_grade: 20 + (4*15) + (4*10) + (6*5) = 20 + 60 + 40 + 30 = 150
+ _assert(attrs["att_grade"] == 150, "Legendary Ruby Attack Power bonus is 150")
+
+ # Jade: Max HP
+ var jade := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.JADE, 4, 0, 0)
+ _assert(jade["attributes"]["max_hp"] == 1300, "Legendary Jade Max HP is 1300")
+
+func _test_grade_and_clarity_refinement() -> void:
+ print("\n--- Test 2: Grade & Clarity Refinement ---")
+ var r1 := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, DragonSoulSystem.Grade.ROUGH, 0)
+ var r2 := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, DragonSoulSystem.Grade.ROUGH, 0)
+ var d1 := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.DIAMOND, DragonSoulSystem.Grade.ROUGH, 0)
+
+ # Mismatched type rejected
+ var res = DragonSoulSystem.refine_grade(r1, d1)
+ _assert(res["ok"] == false and res["reason"] == "MISMATCHED_STONES", "Cannot fuse Ruby with Diamond")
+
+ # Grade refine success
+ res = DragonSoulSystem.refine_grade(r1, r2, true)
+ _assert(res["ok"] == true and res["success"] == true, "Ruby successfully upgraded to Cut grade")
+ _assert(res["stone"]["grade"] == DragonSoulSystem.Grade.CUT, "New stone grade is Cut (1)")
+
+ # Clarity refine success
+ var c1 := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, DragonSoulSystem.Grade.CUT, DragonSoulSystem.Clarity.MATT)
+ var c2 := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, DragonSoulSystem.Grade.CUT, DragonSoulSystem.Clarity.MATT)
+ res = DragonSoulSystem.refine_clarity(c1, c2, true)
+ _assert(res["ok"] == true and res["success"] == true, "Ruby clarity successfully upgraded to Clear")
+ _assert(res["stone"]["clarity"] == DragonSoulSystem.Clarity.CLEAR, "New stone clarity is Clear (1)")
+
+func _test_deck_equipping_and_switching() -> void:
+ print("\n--- Test 3: Sky Deck & Earth Deck Equipping ---")
+ var dss := DragonSoulSystem.new()
+ var diamond := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.DIAMOND)
+ var ruby := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY)
+
+ # Equip in Sky Deck (0)
+ var res = dss.equip_stone(DragonSoulSystem.DECK_SKY, diamond)
+ _assert(res["ok"] == true and res["slot"] == 0, "Diamond equipped into Sky Deck slot 0")
+ _assert(dss.decks[DragonSoulSystem.DECK_SKY][0] != null, "Sky Deck slot 0 is filled")
+
+ res = dss.equip_stone(DragonSoulSystem.DECK_SKY, ruby)
+ _assert(res["ok"] == true and res["slot"] == 1, "Ruby equipped into Sky Deck slot 1")
+
+ # Unequip from Sky Deck
+ res = dss.unequip_stone(DragonSoulSystem.DECK_SKY, 0)
+ _assert(res["ok"] == true, "Diamond unequipped")
+ _assert(dss.decks[DragonSoulSystem.DECK_SKY][0] == null, "Sky Deck slot 0 is now empty")
+
+func _test_active_bonuses_and_duration_decay() -> void:
+ print("\n--- Test 4: Active Deck Stat Injection & Duration Decay ---")
+ var dss := DragonSoulSystem.new()
+ var ruby := DragonSoulSystem.create_dragon_stone(DragonSoulSystem.DragonStoneType.RUBY, 4, 4, 6, 10.0) # 10s duration
+ dss.equip_stone(DragonSoulSystem.DECK_SKY, ruby)
+
+ # Initially inactive: 0 bonuses
+ var bonuses = dss.get_active_bonuses()
+ _assert(bonuses.is_empty(), "No bonuses when deck is inactive")
+
+ # Toggle Sky Deck on
+ var active = dss.toggle_deck(DragonSoulSystem.DECK_SKY)
+ _assert(active == true and dss.active_deck == DragonSoulSystem.DECK_SKY, "Sky Deck activated")
+
+ bonuses = dss.get_active_bonuses()
+ _assert(bonuses["str_bonus"] == 31, "Active deck injects +31 STR bonus")
+ _assert(bonuses["att_grade"] == 150, "Active deck injects +150 Attack Power")
+
+ # Update 5s -> still active
+ var expired = dss.update(5.0)
+ _assert(expired.is_empty(), "No stones expired after 5s")
+ _assert(dss.decks[DragonSoulSystem.DECK_SKY][1]["duration"] == 5.0, "5s duration remaining")
+
+ # Update 6s -> expires
+ expired = dss.update(6.0)
+ _assert(expired.size() == 1, "1 stone expired after duration reached 0")
+ bonuses = dss.get_active_bonuses()
+ _assert(bonuses.is_empty(), "Expired stones no longer provide stat bonuses")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_dragon_soul_parity.gd.uid b/project/test_dragon_soul_parity.gd.uid
new file mode 100644
index 00000000..03a24089
--- /dev/null
+++ b/project/test_dragon_soul_parity.gd.uid
@@ -0,0 +1 @@
+uid://cirpqlmeph2lr
diff --git a/project/test_drop_and_cursor_parity.gd b/project/test_drop_and_cursor_parity.gd
new file mode 100644
index 00000000..eac2af63
--- /dev/null
+++ b/project/test_drop_and_cursor_parity.gd
@@ -0,0 +1,161 @@
+extends SceneTree
+
+const CursorManager = preload("res://ui/cursor_manager.gd")
+const GroundItems = preload("res://ui/ground_items.gd")
+const PlayerController = preload("res://player_controller.gd")
+
+var _fail := 0
+
+func _ck(condition: bool, message: String) -> void:
+ if not condition:
+ _fail += 1
+ printerr("FAIL: " + message)
+
+func _init() -> void:
+ print("--- Running test_drop_and_cursor_parity ---")
+ await _run()
+ preload("res://ui/ui_assets.gd").clear_cache()
+ if _fail == 0:
+ print("test_drop_and_cursor_parity passed with 0 failures!")
+ quit(0)
+ else:
+ printerr("%d failure(s)" % _fail)
+ quit(1)
+
+func _run() -> void:
+ test_mapcoord_heading()
+ test_cursor_manager()
+ await test_ground_item_models_and_picking()
+
+func test_mapcoord_heading() -> void:
+ print("Testing MapCoord heading alignment with 40250...")
+ _ck(is_equal_approx(MapCoord.heading_to_yaw(0.0), 0.0), "0 deg heading must be 0 yaw (South)")
+ _ck(is_equal_approx(MapCoord.heading_to_yaw(90.0), PI * 0.5), "90 deg heading must be PI/2 yaw (East)")
+ _ck(is_equal_approx(MapCoord.heading_to_yaw(180.0), PI), "180 deg heading must be PI yaw (North)")
+ _ck(is_equal_approx(MapCoord.heading_to_yaw(270.0), PI * 1.5), "270 deg heading must be 3PI/2 yaw (West)")
+
+ _ck(is_equal_approx(MapCoord.yaw_to_heading(0.0), 0.0), "0 yaw must be 0 heading")
+ _ck(is_equal_approx(MapCoord.yaw_to_heading(PI * 0.5), 90.0), "PI/2 yaw must be 90 heading")
+ _ck(is_equal_approx(MapCoord.yaw_to_heading(PI), 180.0), "PI yaw must be 180 heading")
+ print("OK: MapCoord heading parity verified")
+
+func test_cursor_manager() -> void:
+ print("Testing CursorManager custom cursor registration...")
+ var cm := CursorManager.new()
+ get_root().add_child(cm)
+ var root_assets: String = AssetRoot.path() if AssetRoot.available() else ""
+ cm.setup(root_assets)
+
+ cm.set_cursor("NORMAL")
+ _ck(cm.get_cursor() == "NORMAL", "shape is NORMAL")
+ _ck(cm._godot_shape("NORMAL") == Input.CURSOR_ARROW, "godot shape is CURSOR_ARROW")
+
+ cm.set_cursor("ATTACK")
+ _ck(cm.get_cursor() == "ATTACK", "shape is ATTACK")
+ _ck(cm._godot_shape("ATTACK") == Input.CURSOR_CROSS, "godot shape is CURSOR_CROSS")
+
+ cm.set_cursor("PICK")
+ _ck(cm.get_cursor() == "PICK", "shape is PICK")
+ _ck(cm._godot_shape("PICK") == Input.CURSOR_CAN_DROP, "godot shape is CURSOR_CAN_DROP")
+
+ cm.set_cursor("TALK")
+ _ck(cm.get_cursor() == "TALK", "shape is TALK")
+ _ck(cm._godot_shape("TALK") == Input.CURSOR_POINTING_HAND, "godot shape is CURSOR_POINTING_HAND")
+
+ cm.set_cursor("DOOR")
+ _ck(cm.get_cursor() == "DOOR", "shape is DOOR")
+ _ck(cm._godot_shape("DOOR") == Input.CURSOR_HELP, "godot shape is CURSOR_HELP")
+
+ cm.set_cursor("CANT_GO")
+ _ck(cm.get_cursor() == "CANT_GO", "shape is CANT_GO")
+ _ck(cm._godot_shape("CANT_GO") == Input.CURSOR_FORBIDDEN, "godot shape is CURSOR_FORBIDDEN")
+
+ cm.set_cursor("CAMERA_ROTATE")
+ _ck(cm.get_cursor() == "CAMERA_ROTATE", "shape is CAMERA_ROTATE")
+ _ck(cm._godot_shape("CAMERA_ROTATE") == Input.CURSOR_BDIAGSIZE, "godot shape is CURSOR_BDIAGSIZE")
+
+ cm.set_cursor("CHAIR")
+ _ck(cm.get_cursor() == "CHAIR", "shape is CHAIR")
+ _ck(cm._godot_shape("CHAIR") == Input.CURSOR_WAIT, "godot shape is CURSOR_WAIT")
+
+ cm.set_cursor("HSIZE")
+ _ck(cm.get_cursor() == "HSIZE", "shape is HSIZE")
+ _ck(cm._godot_shape("HSIZE") == Input.CURSOR_HSIZE, "godot shape is CURSOR_HSIZE")
+
+ cm.queue_free()
+ for shape_idx in range(17):
+ Input.set_custom_mouse_cursor(null, shape_idx)
+ CursorManager.UiAssets.clear_cache()
+ print("OK: CursorManager parity verified")
+
+class DummyClient extends Node:
+ signal ground_item_added(d: Dictionary)
+ signal ground_item_removed(vid: int)
+ var picked_vids: Array[int] = []
+ func pickup_item(vid: int) -> bool:
+ picked_vids.append(vid)
+ return true
+
+func test_ground_item_models_and_picking() -> void:
+ print("Testing GroundItems model instantiation and picking...")
+ var mount := Node3D.new()
+ get_root().add_child(mount)
+
+ var cam := Camera3D.new()
+ get_root().add_child(cam)
+ cam.position = Vector3(0, 5, 5)
+ cam.look_at_from_position(Vector3(0, 5, 5), Vector3.ZERO, Vector3.UP)
+ cam.current = true
+
+ var client := DummyClient.new()
+ get_root().add_child(client)
+
+ var gi := GroundItems.new()
+ get_root().add_child(gi)
+ var player := Node3D.new()
+ player.position = Vector3.ZERO
+ get_root().add_child(player)
+
+ gi.assets_override = "__none__"
+ gi.setup(client, mount, func() -> Node: return player, null, null,
+ func() -> Camera3D: return cam)
+
+ gi._on_added({"vid": 1001, "vnum": 1, "owner": "Player", "pos": Vector3.ZERO})
+ _ck(gi._by_vid.has(1001), "Drop 1001 must exist")
+ var drop1: Node3D = gi._by_vid[1001]
+ var m1 = drop1.get_node_or_null("model")
+ if m1 == null:
+ m1 = drop1.get_child(0)
+ _ck(m1 != null, "Drop must have a visual child (Metin2Model or BoxMesh fallback)")
+
+ gi._on_added({"vid": 1002, "vnum": 10, "owner": "Player", "pos": Vector3(10, 0, 0)})
+ _ck(gi._by_vid.has(1002), "Drop 1002 must exist")
+
+ await process_frame
+ await process_frame
+
+ var sp1 := cam.unproject_position(drop1.global_position + Vector3(0, 0.35, 0))
+ var picked_vid := gi.pick_at(cam, sp1)
+ _ck(picked_vid == 1001, "pick_at should pick drop 1001 at its screen projection")
+
+ var pc := PlayerController.new()
+ get_root().add_child(pc)
+ pc.player = player
+ pc.camera = cam
+ pc.ground_items = gi
+
+ var emitted := [0]
+ pc.ground_item_clicked.connect(func(vid: int): emitted[0] = vid)
+ pc._on_click(sp1)
+ _ck(emitted[0] == 1001, "PlayerController._on_click must emit ground_item_clicked with picked vid")
+
+ gi.clear_for_map_change()
+ pc.queue_free()
+ player.queue_free()
+ gi.queue_free()
+ client.queue_free()
+ cam.queue_free()
+ mount.queue_free()
+ await process_frame
+ await process_frame
+ print("OK: GroundItems model and picking verified")
diff --git a/project/test_drop_and_cursor_parity.gd.uid b/project/test_drop_and_cursor_parity.gd.uid
new file mode 100644
index 00000000..4d882141
--- /dev/null
+++ b/project/test_drop_and_cursor_parity.gd.uid
@@ -0,0 +1 @@
+uid://deor6cp2mo5xv
diff --git a/project/test_dungeon_ranking_parity.gd b/project/test_dungeon_ranking_parity.gd
new file mode 100644
index 00000000..9bc75720
--- /dev/null
+++ b/project/test_dungeon_ranking_parity.gd
@@ -0,0 +1,108 @@
+# test_dungeon_ranking_parity.gd —— 40250 地牢信息与最速榜单 1:1 纯净回归测试套件
+extends SceneTree
+
+const DungeonRankingSystem = preload("res://dungeon_ranking_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_dungeon_ranking_parity (Direction 1: Dungeon Ranking & Info 1:1) ===")
+ _test_dungeon_registry_configurations()
+ _test_clear_time_and_records()
+ _test_cooldown_tracking()
+ _test_summaries_list()
+ _finish()
+
+func _test_dungeon_registry_configurations() -> void:
+ print("\n--- Test 1: Dungeon Registry Configurations ---")
+ var drs := DungeonRankingSystem.new()
+ _assert(DungeonRankingSystem.DUNGEON_REGISTRY.has("demon_tower"), "Demon Tower defined")
+ _assert(DungeonRankingSystem.DUNGEON_REGISTRY.has("spider_dungeon_3"), "Spider Dungeon 3 defined")
+ _assert(DungeonRankingSystem.DUNGEON_REGISTRY.has("dragon_lair"), "Dragon Lair defined")
+ _assert(DungeonRankingSystem.DUNGEON_REGISTRY.has("devils_catacomb"), "Devil's Catacomb defined")
+
+ var dl = DungeonRankingSystem.DUNGEON_REGISTRY["dragon_lair"]
+ _assert(dl["min_level"] == 75, "Dragon Lair requires Lv 75")
+ _assert(dl["ticket_vnum"] == 30179 and dl["ticket_count"] == 3, "Requires 3x Twisted Key (30179)")
+
+ var sd3 = DungeonRankingSystem.DUNGEON_REGISTRY["spider_dungeon_3"]
+ _assert(sd3["min_level"] == 50, "Spider Dungeon 3 requires Lv 50")
+ _assert(sd3["ticket_vnum"] == 30327 and sd3["ticket_count"] == 1, "Requires Arachnid Key (30327)")
+
+func _test_clear_time_and_records() -> void:
+ print("\n--- Test 2: Clear Time Tracking & Personal Best Record ---")
+ var drs := DungeonRankingSystem.new()
+
+ var record_signal := {"fired": false, "time": 0.0}
+ drs.new_fastest_record.connect(func(_d: String, t: float, _old: float):
+ record_signal["fired"] = true
+ record_signal["time"] = t
+ )
+
+ # 1. First clear of Dragon Lair (600.0s, peak dmg 8500)
+ var r1 = drs.record_clear("dragon_lair", 600.0, 8500, 1000.0)
+ _assert(r1["ok"] == true, "Clear recorded")
+ _assert(r1["is_new_record"] == true, "First clear is a new personal best")
+ _assert(r1["fastest_time"] == 600.0, "Fastest time is 600.0s")
+ _assert(r1["total_clears"] == 1, "Total clears is 1")
+ _assert(r1["highest_peak_damage"] == 8500, "Highest peak damage is 8500")
+ _assert(record_signal["fired"] == true and record_signal["time"] == 600.0, "new_fastest_record signal fired")
+
+ # 2. Slower clear (700.0s) -> doesn't replace 600.0s
+ record_signal["fired"] = false
+ var r2 = drs.record_clear("dragon_lair", 700.0, 9000, 2000.0)
+ _assert(r2["is_new_record"] == false, "Slower clear is not a new record")
+ _assert(r2["fastest_time"] == 600.0, "Fastest time remains 600.0s")
+ _assert(r2["total_clears"] == 2, "Total clears incremented to 2")
+ _assert(r2["highest_peak_damage"] == 9000, "Peak damage updated to 9000")
+ _assert(record_signal["fired"] == false, "new_fastest_record signal did NOT fire")
+
+ # 3. Faster clear (450.0s) -> replaces record!
+ var r3 = drs.record_clear("dragon_lair", 450.0, 9200, 3000.0)
+ _assert(r3["is_new_record"] == true, "Faster clear sets new record")
+ _assert(r3["fastest_time"] == 450.0, "Fastest time updated to 450.0s")
+ _assert(r3["total_clears"] == 3, "Total clears is 3")
+ _assert(record_signal["fired"] == true and record_signal["time"] == 450.0, "new_fastest_record signal fired for 450.0s")
+
+func _test_cooldown_tracking() -> void:
+ print("\n--- Test 3: Dungeon Cooldown Tracking ---")
+ var drs := DungeonRankingSystem.new()
+
+ # Spider dungeon cooldown is 1800s
+ drs.record_clear("spider_dungeon_3", 300.0, 5000, 1000.0) # cleared at time 1000.0
+
+ # 1. Query at timestamp 1500.0 (elapsed 500s < 1800s) -> on cooldown
+ var info_cd = drs.get_dungeon_info("spider_dungeon_3", 1500.0)
+ _assert(info_cd["is_on_cooldown"] == true, "Dungeon is on cooldown")
+ _assert(info_cd["remaining_cooldown"] == 1300.0, "1300.0s remaining cooldown")
+
+ # 2. Query at timestamp 3000.0 (elapsed 2000s > 1800s) -> ready
+ var info_ready = drs.get_dungeon_info("spider_dungeon_3", 3000.0)
+ _assert(info_ready["is_on_cooldown"] == false, "Dungeon is ready")
+ _assert(info_ready["remaining_cooldown"] == 0.0, "0.0s remaining cooldown")
+
+func _test_summaries_list() -> void:
+ print("\n--- Test 4: All Dungeon Summaries List ---")
+ var drs := DungeonRankingSystem.new()
+ var list = drs.get_all_dungeon_summaries()
+ _assert(list.size() == 4, "Summaries list contains all 4 dungeons")
+ _assert(list[0]["dungeon_id"] == "demon_tower", "Contains Demon Tower")
+ _assert(list[1]["dungeon_id"] == "spider_dungeon_3", "Contains Spider Dungeon 3")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Dungeon Ranking Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_dungeon_ranking_parity.gd.uid b/project/test_dungeon_ranking_parity.gd.uid
new file mode 100644
index 00000000..2ceca8d7
--- /dev/null
+++ b/project/test_dungeon_ranking_parity.gd.uid
@@ -0,0 +1 @@
+uid://b1vnsae05dm38
diff --git a/project/test_dynamic_bounty_event_parity.gd b/project/test_dynamic_bounty_event_parity.gd
new file mode 100644
index 00000000..eb218c66
--- /dev/null
+++ b/project/test_dynamic_bounty_event_parity.gd
@@ -0,0 +1,136 @@
+# test_dynamic_bounty_event_parity.gd —— 40250 动态随机事件与赏金通缉令 1:1 回归测试套件
+extends SceneTree
+
+const DynamicBountyEventSystem = preload("res://dynamic_bounty_event_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_dynamic_bounty_event_parity (Direction 4: Bounty Events 1:1) ===")
+ _test_bounty_generation()
+ _test_hunt_and_metin_progress()
+ _test_boss_bounty_completion()
+ _test_claim_rewards_and_validation()
+ _finish()
+
+func _test_bounty_generation() -> void:
+ print("\n--- Test 1: Bounty Generation & Roster ---")
+ var sys := DynamicBountyEventSystem.new()
+
+ var ref_sig := {"fired": false, "count": 0}
+ sys.bounties_refreshed.connect(func(list: Array):
+ ref_sig["fired"] = true
+ ref_sig["count"] = list.size()
+ )
+
+ var bounties = sys.refresh_daily_bounties()
+ _assert(bounties.size() == 3, "Generated 3 daily bounties")
+ _assert(ref_sig["fired"] == true and ref_sig["count"] == 3, "bounties_refreshed signal emitted")
+ _assert(sys.active_bounties.has("bounty_hunt_barbarian"), "Has Barbarian Hunt bounty")
+ _assert(sys.active_bounties.has("bounty_metin_jealousy"), "Has Metin Shatter bounty")
+ _assert(sys.active_bounties.has("bounty_boss_ninetails"), "Has Nine Tails Boss bounty")
+
+func _test_hunt_and_metin_progress() -> void:
+ print("\n--- Test 2: Hunt & Metin Progress Tracking ---")
+ var sys := DynamicBountyEventSystem.new()
+ sys.refresh_daily_bounties()
+
+ var prog_sig := {"fired": false, "cur": 0, "tgt": 0}
+ sys.bounty_progress_updated.connect(func(_id: String, cur: int, tgt: int):
+ prog_sig["fired"] = true
+ prog_sig["cur"] = cur
+ prog_sig["tgt"] = tgt
+ )
+
+ var comp_sig := {"fired": false, "title": ""}
+ sys.bounty_completed.connect(func(_id: String, title: String):
+ comp_sig["fired"] = true
+ comp_sig["title"] = title
+ )
+
+ # 1. Kill 10 barbarians (vnum 501)
+ sys.on_mob_killed(501, 10)
+ _assert(prog_sig["fired"] == true and prog_sig["cur"] == 10 and prog_sig["tgt"] == 25, "Progress updated to 10/25")
+ _assert(sys.active_bounties["bounty_hunt_barbarian"]["is_completed"] == false, "Not completed yet")
+
+ # Kill 15 more barbarians -> completes 25/25
+ sys.on_mob_killed(501, 15)
+ _assert(sys.active_bounties["bounty_hunt_barbarian"]["is_completed"] == true, "Barbarian hunt completed")
+ _assert(comp_sig["fired"] == true, "bounty_completed signal emitted")
+
+ # 2. Shatter 2 Metin stones (vnum 8008)
+ comp_sig["fired"] = false
+ sys.on_metin_destroyed(8008)
+ _assert(sys.active_bounties["bounty_metin_jealousy"]["current_count"] == 1, "Metin count is 1/2")
+
+ sys.on_metin_destroyed(8008)
+ _assert(sys.active_bounties["bounty_metin_jealousy"]["is_completed"] == true, "Metin bounty completed")
+ _assert(comp_sig["fired"] == true, "bounty_completed emitted for Metin")
+
+func _test_boss_bounty_completion() -> void:
+ print("\n--- Test 3: World Boss Bounty Completion ---")
+ var sys := DynamicBountyEventSystem.new()
+ sys.refresh_daily_bounties()
+
+ # Slay Nine Tails (1901)
+ sys.on_mob_killed(1901, 1)
+ _assert(sys.active_bounties["bounty_boss_ninetails"]["is_completed"] == true, "Nine Tails bounty completed")
+ _assert(sys.total_completed_count == 1, "total_completed_count is 1")
+
+func _test_claim_rewards_and_validation() -> void:
+ print("\n--- Test 4: Claiming Bounty Rewards & Double-Claim Guard ---")
+ var sys := DynamicBountyEventSystem.new()
+ sys.refresh_daily_bounties()
+
+ var player := {"exp": 1000, "gold": 5000}
+ var inv: Array = []
+ inv.resize(5)
+ for i in range(5): inv[i] = null
+
+ # Try claim incomplete bounty
+ var fail_inc = sys.claim_bounty_reward("bounty_boss_ninetails", player, inv)
+ _assert(fail_inc["ok"] == false and fail_inc["reason"] == "NOT_COMPLETED", "Cannot claim incomplete bounty")
+
+ # Slay Nine Tails
+ sys.on_mob_killed(1901, 1)
+
+ var claim_sig := {"fired": false, "exp": 0, "gold": 0}
+ sys.bounty_reward_claimed.connect(func(_id: String, e: int, g: int):
+ claim_sig["fired"] = true
+ claim_sig["exp"] = e
+ claim_sig["gold"] = g
+ )
+
+ # Claim Boss Reward (1,000,000 XP, 3,000,000 Gold, Chest + Scroll)
+ var claim_res = sys.claim_bounty_reward("bounty_boss_ninetails", player, inv)
+ _assert(claim_res["ok"] == true, "Reward claimed")
+ _assert(player["exp"] == 1001000, "XP awarded (+1,000,000)")
+ _assert(player["gold"] == 3005000, "Gold awarded (+3,000,000)")
+ _assert(inv[0]["vnum"] == DynamicBountyEventSystem.BOUNTY_CHEST_VNUM, "Received Bounty Chest in slot 0")
+ _assert(inv[1]["vnum"] == 71003, "Received Blessing Scroll in slot 1")
+ _assert(claim_sig["fired"] == true and claim_sig["exp"] == 1000000, "bounty_reward_claimed signal fired")
+
+ # Second claim attempt must fail
+ var fail_double = sys.claim_bounty_reward("bounty_boss_ninetails", player, inv)
+ _assert(fail_double["ok"] == false and fail_double["reason"] == "ALREADY_CLAIMED", "Double claim rejected")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Dynamic Bounty Event Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Dynamic Bounty Event parity tests passed!")
+ quit(0)
diff --git a/project/test_dynamic_bounty_event_parity.gd.uid b/project/test_dynamic_bounty_event_parity.gd.uid
new file mode 100644
index 00000000..0243a82a
--- /dev/null
+++ b/project/test_dynamic_bounty_event_parity.gd.uid
@@ -0,0 +1 @@
+uid://5epekcbiqs2l
diff --git a/project/test_emotion_action_parity.gd b/project/test_emotion_action_parity.gd
new file mode 100644
index 00000000..a6e3ee85
--- /dev/null
+++ b/project/test_emotion_action_parity.gd
@@ -0,0 +1,75 @@
+# test_emotion_action_parity.gd —— 验证方向 3:角色情绪动作与表情快捷键 40250 1:1 对齐
+extends SceneTree
+
+const EmotionActionSystem = preload("res://emotion_action_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_emotion_action_parity (Direction 3: Emotions & Hotkeys 1:1) ===")
+ test_emotion_definitions()
+ test_command_resolution()
+ test_ctrl_hotkeys()
+ test_action_prerequisites()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_emotion_definitions() -> void:
+ print("\n--- Test 1: 18 Official Emotions Definitions (emotion.py) ---")
+ _assert(EmotionActionSystem.is_valid_emotion(EmotionActionSystem.EMOTION_CLAP), "Clap (1) is valid")
+ _assert(EmotionActionSystem.is_valid_emotion(EmotionActionSystem.EMOTION_ANGRY), "Angry (4) is valid")
+ _assert(EmotionActionSystem.is_valid_emotion(EmotionActionSystem.EMOTION_DANCE_1), "Dance 1 (13) is valid")
+ _assert(EmotionActionSystem.is_valid_emotion(EmotionActionSystem.EMOTION_DANCE_6), "Dance 6 (18) is valid")
+ _assert(not EmotionActionSystem.is_valid_emotion(999), "Invalid emotion 999 rejected")
+
+func test_command_resolution() -> void:
+ print("\n--- Test 2: Chat Command Resolution (/clap, /dance1..) ---")
+ _assert(EmotionActionSystem.get_emotion_from_command("/clap") == EmotionActionSystem.EMOTION_CLAP,
+ "/clap resolves to EMOTION_CLAP (1)")
+ _assert(EmotionActionSystem.get_emotion_from_command("/dance1") == EmotionActionSystem.EMOTION_DANCE_1,
+ "/dance1 resolves to EMOTION_DANCE_1 (13)")
+ _assert(EmotionActionSystem.get_emotion_from_command("/angry") == EmotionActionSystem.EMOTION_ANGRY,
+ "/angry resolves to EMOTION_ANGRY (4)")
+ _assert(EmotionActionSystem.get_emotion_from_command("/unknown") == 0,
+ "/unknown resolves to 0")
+
+func test_ctrl_hotkeys() -> void:
+ print("\n--- Test 3: Ctrl + 1..9 Hotkey Mapping ---")
+ # Ctrl+1 (idx 0) -> Clap
+ _assert(EmotionActionSystem.get_hotkey_emotion(0) == EmotionActionSystem.EMOTION_CLAP, "Ctrl+1 triggers Clap")
+ # Ctrl+2 (idx 1) -> Congratulation
+ _assert(EmotionActionSystem.get_hotkey_emotion(1) == EmotionActionSystem.EMOTION_CONGRATULATION, "Ctrl+2 triggers Congratulation")
+ # Ctrl+4 (idx 3) -> Angry
+ _assert(EmotionActionSystem.get_hotkey_emotion(3) == EmotionActionSystem.EMOTION_ANGRY, "Ctrl+4 triggers Angry")
+ # Ctrl+9 (idx 8) -> Banter
+ _assert(EmotionActionSystem.get_hotkey_emotion(8) == EmotionActionSystem.EMOTION_BANTER, "Ctrl+9 triggers Banter")
+
+func test_action_prerequisites() -> void:
+ print("\n--- Test 4: Action Execution Prerequisites (Combat & Mounted Restrictions) ---")
+ # Successful execution
+ var res_ok := EmotionActionSystem.perform_emotion(EmotionActionSystem.EMOTION_CLAP, false, false)
+ _assert(res_ok.ok and res_ok.code == "SUCCESS", "Emotion performed successfully")
+ _assert(res_ok.motion == "clap", "Motion is clap")
+
+ # Rejected while in combat
+ var res_combat := EmotionActionSystem.perform_emotion(EmotionActionSystem.EMOTION_CLAP, true, false)
+ _assert(not res_combat.ok and res_combat.code == "CANNOT_IN_BATTLE", "Emotion rejected during combat")
+
+ # Rejected while mounted
+ var res_mount := EmotionActionSystem.perform_emotion(EmotionActionSystem.EMOTION_CLAP, false, true)
+ _assert(not res_mount.ok and res_mount.code == "CANNOT_ON_MOUNT", "Emotion rejected while mounted")
diff --git a/project/test_emotion_action_parity.gd.uid b/project/test_emotion_action_parity.gd.uid
new file mode 100644
index 00000000..11bf942f
--- /dev/null
+++ b/project/test_emotion_action_parity.gd.uid
@@ -0,0 +1 @@
+uid://dv0ciwk1wsuhb
diff --git a/project/test_empire_kingdom_parity.gd b/project/test_empire_kingdom_parity.gd
new file mode 100644
index 00000000..ba2c379b
--- /dev/null
+++ b/project/test_empire_kingdom_parity.gd
@@ -0,0 +1,146 @@
+# test_empire_kingdom_parity.gd —— 40250 三圣帝国归属与领地国运系统 1:1 回归测试套件
+extends SceneTree
+
+const EmpireKingdomSystem = preload("res://empire_kingdom_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_empire_kingdom_parity (Direction 4: Empire Kingdom & Territory 1:1) ===")
+ _test_empire_selection_and_blessings()
+ _test_territory_hostility_checks()
+ _test_tincture_of_empires_transfer()
+ _test_serialization()
+ _finish()
+
+func _test_empire_selection_and_blessings() -> void:
+ print("\n--- Test 1: Empire Selection & Daily Blessing Prayer ---")
+ var sys := EmpireKingdomSystem.new()
+ _assert(sys.current_empire == EmpireKingdomSystem.EMPIRE_NONE, "Initially unaligned")
+
+ var sel_sig := {"fired": false, "id": 0, "name": ""}
+ sys.empire_selected.connect(func(i: int, n: String):
+ sel_sig["fired"] = true
+ sel_sig["id"] = i
+ sel_sig["name"] = n
+ )
+
+ # Select Shinsoo (Red Empire 1)
+ var sel_res = sys.select_empire(EmpireKingdomSystem.EMPIRE_SHINSOO)
+ _assert(sel_res["ok"] == true, "Selected Shinsoo")
+ _assert(sys.current_empire == EmpireKingdomSystem.EMPIRE_SHINSOO, "Current empire is Shinsoo")
+ _assert(sel_sig["fired"] == true, "empire_selected signal emitted")
+ _assert(sel_sig["name"] == "慎修帝国", "Name is 慎修帝国")
+
+ # Activate Daily Prayer
+ var player_stats := {"gold_bonus_pct": 0, "shop_discount_pct": 0}
+ var pray_res = sys.activate_empire_prayer(player_stats)
+ _assert(pray_res["ok"] == true, "Prayer activated")
+ _assert(sys.is_blessing_active == true, "Blessing flag is active")
+ _assert(player_stats["gold_bonus_pct"] == 10, "Shinsoo gold bonus +10% applied")
+ _assert(player_stats["shop_discount_pct"] == 5, "Shinsoo shop discount +5% applied")
+
+ # Advance time 86400s -> expiry
+ sys.update(86400.0, player_stats)
+ _assert(sys.is_blessing_active == false, "Blessing expired after 24h")
+ _assert(player_stats["gold_bonus_pct"] == 0, "Gold bonus cleanly deducted")
+ _assert(player_stats["shop_discount_pct"] == 0, "Shop discount cleanly deducted")
+
+func _test_territory_hostility_checks() -> void:
+ print("\n--- Test 2: Territory Hostility & Intruder Checks ---")
+ var sys := EmpireKingdomSystem.new()
+ sys.select_empire(EmpireKingdomSystem.EMPIRE_SHINSOO)
+
+ # 1. Home territory (Yongan / Jayang)
+ var home_check = sys.check_territory("Yongan")
+ _assert(home_check["is_home"] == true, "Yongan is Shinsoo homeland")
+ _assert(home_check["is_hostile"] == false, "Homeland is not hostile")
+ _assert(home_check["relation"] == "homeland", "Relation is homeland")
+
+ # 2. Hostile enemy territory (Pyungmoo - Jinno homeland)
+ var enemy_check = sys.check_territory("Pyungmoo")
+ _assert(enemy_check["is_home"] == false, "Pyungmoo is not homeland")
+ _assert(enemy_check["is_hostile"] == true, "Pyungmoo is hostile foreign territory")
+ _assert(enemy_check["relation"] == "hostile_invasion", "Relation is hostile invasion")
+
+ # 3. Neutral territory (Desert)
+ var neutral_check = sys.check_territory("Desert")
+ _assert(neutral_check["is_home"] == false, "Desert is not homeland")
+ _assert(neutral_check["is_hostile"] == false, "Desert is not hostile")
+ _assert(neutral_check["relation"] == "neutral", "Relation is neutral territory")
+
+func _test_tincture_of_empires_transfer() -> void:
+ print("\n--- Test 3: Tincture of Empires (71054) Nation Transfer ---")
+ var sys := EmpireKingdomSystem.new()
+ sys.select_empire(EmpireKingdomSystem.EMPIRE_SHINSOO)
+ var player_stats := {"gold_bonus_pct": 0, "shop_discount_pct": 0, "attack_bonus": 0, "monster_damage_pct": 0}
+ sys.activate_empire_prayer(player_stats)
+
+ var inv: Array = [
+ {"vnum": EmpireKingdomSystem.VNUM_TINCTURE_OF_EMPIRES, "name": "转国卷轴", "count": 1}
+ ]
+
+ # 1. Transfer while in a guild -> rejected (40250 rule)
+ var res_guild = sys.change_empire(EmpireKingdomSystem.EMPIRE_JINNO, 0, inv, player_stats, true)
+ _assert(res_guild["ok"] == false, "Rejected: player is in a guild")
+ _assert(res_guild["reason"] == "MUST_LEAVE_GUILD", "Reason is MUST_LEAVE_GUILD")
+
+ # 2. Transfer to same empire -> rejected
+ var res_same = sys.change_empire(EmpireKingdomSystem.EMPIRE_SHINSOO, 0, inv, player_stats, false)
+ _assert(res_same["ok"] == false, "Rejected: cannot transfer to same empire")
+ _assert(res_same["reason"] == "CANNOT_CHANGE_TO_SAME_EMPIRE", "Reason is CANNOT_CHANGE_TO_SAME_EMPIRE")
+
+ # 3. Successful transfer to Jinno (Blue Empire 3)
+ var change_sig := {"fired": false, "old": 0, "new": 0}
+ sys.empire_changed.connect(func(o: int, n: int):
+ change_sig["fired"] = true
+ change_sig["old"] = o
+ change_sig["new"] = n
+ )
+
+ var res_succ = sys.change_empire(EmpireKingdomSystem.EMPIRE_JINNO, 0, inv, player_stats, false)
+ _assert(res_succ["ok"] == true, "Transferred to Jinno successfully")
+ _assert(sys.current_empire == EmpireKingdomSystem.EMPIRE_JINNO, "Current empire updated to Jinno")
+ _assert(inv[0] == null, "Tincture scroll consumed")
+ _assert(change_sig["fired"] == true, "empire_changed signal emitted")
+ _assert(change_sig["old"] == 1 and change_sig["new"] == 3, "Signal reported 1 -> 3")
+ _assert(player_stats["gold_bonus_pct"] == 0, "Previous Shinsoo blessing removed upon transfer")
+
+ # Activate Jinno Prayer
+ sys.activate_empire_prayer(player_stats)
+ _assert(player_stats["attack_bonus"] == 50, "Jinno attack bonus +50 applied")
+ _assert(player_stats["monster_damage_pct"] == 5, "Jinno monster damage +5% applied")
+
+func _test_serialization() -> void:
+ print("\n--- Test 4: Serialization & Restoration ---")
+ var sys1 := EmpireKingdomSystem.new()
+ sys1.select_empire(EmpireKingdomSystem.EMPIRE_CHUNJO) # Yellow
+ var dummy_stats := {}
+ sys1.activate_empire_prayer(dummy_stats)
+
+ var data = sys1.serialize()
+ _assert(data["current_empire"] == EmpireKingdomSystem.EMPIRE_CHUNJO, "Serialized empire is Chunjo")
+ _assert(data["is_blessing_active"] == true, "Serialized blessing is active")
+
+ var sys2 := EmpireKingdomSystem.new()
+ sys2.deserialize(data)
+ _assert(sys2.current_empire == EmpireKingdomSystem.EMPIRE_CHUNJO, "Restored empire is Chunjo")
+ _assert(sys2.is_blessing_active == true, "Restored blessing active state")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Empire Kingdom Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_empire_kingdom_parity.gd.uid b/project/test_empire_kingdom_parity.gd.uid
new file mode 100644
index 00000000..c46cb621
--- /dev/null
+++ b/project/test_empire_kingdom_parity.gd.uid
@@ -0,0 +1 @@
+uid://dmi8kj2uqfsa7
diff --git a/project/test_endless_spire_dungeon_parity.gd b/project/test_endless_spire_dungeon_parity.gd
new file mode 100644
index 00000000..a63457a2
--- /dev/null
+++ b/project/test_endless_spire_dungeon_parity.gd
@@ -0,0 +1,148 @@
+# test_endless_spire_dungeon_parity.gd —— 40250 无尽天关爬塔挑战 1:1 回归测试套件
+extends SceneTree
+
+const EndlessSpireDungeonSystem = preload("res://endless_spire_dungeon_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_endless_spire_dungeon_parity (Direction 1: Endless Spire Dungeon 1:1) ===")
+ _test_start_and_normal_floors()
+ _test_milestone_boss_and_checkpoint()
+ _test_resume_from_checkpoint()
+ _test_full_clear_floor_50()
+ _test_timeout_defeat()
+ _finish()
+
+func _test_start_and_normal_floors() -> void:
+ print("\n--- Test 1: Starting Floor 1 & Clearing Mobs ---")
+ var spire := EndlessSpireDungeonSystem.new()
+
+ var start_sig := {"fired": false, "floor": 0, "is_boss": true}
+ spire.floor_started.connect(func(f: int, is_b: bool, _c: int):
+ start_sig["fired"] = true
+ start_sig["floor"] = f
+ start_sig["is_boss"] = is_b
+ )
+
+ var res = spire.start_trial(false)
+ _assert(res["ok"] == true and spire.current_floor == 1, "Trial started at Floor 1")
+ _assert(start_sig["fired"] == true and start_sig["floor"] == 1 and start_sig["is_boss"] == false, "floor_started signal emitted for regular floor")
+ _assert(spire.active_mobs.size() == 6, "Floor 1 has 6 mobs (4 + (1%5)*2 = 6)")
+
+ # Kill all mobs on floor 1
+ var m_ids = spire.active_mobs.keys().duplicate()
+ for mid in m_ids:
+ spire.kill_mob(mid)
+
+ # Transitioned to floor 2
+ _assert(spire.current_floor == 2, "Transitioned to Floor 2")
+ _assert(spire.total_points == 10, "Points earned 10 (1 * 10)")
+
+func _test_milestone_boss_and_checkpoint() -> void:
+ print("\n--- Test 2: Floor 5 Milestone Boss & Checkpoint Save ---")
+ var spire := EndlessSpireDungeonSystem.new()
+ spire.start_trial(false)
+
+ var cp_sig := {"fired": false, "cp": 0}
+ spire.checkpoint_saved.connect(func(cp: int):
+ cp_sig["fired"] = true
+ cp_sig["cp"] = cp
+ )
+
+ # Advance to floor 5
+ while spire.current_floor < 5:
+ var ids = spire.active_mobs.keys().duplicate()
+ for mid in ids:
+ spire.kill_mob(mid)
+
+ _assert(spire.current_floor == 5, "Reached Floor 5 (Milestone Boss)")
+ _assert(spire.active_mobs.size() == 1, "Floor 5 has 1 boss")
+ var boss_id = spire.active_mobs.keys()[0]
+ _assert(spire.active_mobs[boss_id]["vnum"] == 691, "Boss is Orc Chief (691)")
+
+ # Kill Floor 5 Boss
+ spire.kill_mob(boss_id)
+ _assert(spire.current_floor == 6, "Advanced to Floor 6")
+ _assert(cp_sig["fired"] == true and cp_sig["cp"] == 6, "checkpoint_saved signal fired with Floor 6")
+ _assert(spire.highest_checkpoint == 6, "highest_checkpoint is 6")
+
+func _test_resume_from_checkpoint() -> void:
+ print("\n--- Test 3: Exit & Resume from Checkpoint ---")
+ var spire := EndlessSpireDungeonSystem.new()
+ spire.start_trial(false)
+
+ # Progress to checkpoint 6
+ while spire.current_floor < 6:
+ var ids = spire.active_mobs.keys().duplicate()
+ for mid in ids:
+ spire.kill_mob(mid)
+
+ # Exit trial
+ spire.exit_trial()
+ _assert(spire.is_in_trial == false, "Exited trial")
+
+ # Resume from checkpoint
+ var resume_res = spire.start_trial(true)
+ _assert(resume_res["ok"] == true, "Resumed trial")
+ _assert(spire.current_floor == 6, "Resumed directly at Floor 6")
+
+func _test_full_clear_floor_50() -> void:
+ print("\n--- Test 4: Floor 50 Final Boss & Spire Mastery ---")
+ var spire := EndlessSpireDungeonSystem.new()
+ spire.highest_checkpoint = 50
+ spire.start_trial(true)
+
+ var mastery_sig := {"fired": false, "floor": 0}
+ spire.spire_trial_completed.connect(func(f: int, _p: int):
+ mastery_sig["fired"] = true
+ mastery_sig["floor"] = f
+ )
+
+ _assert(spire.current_floor == 50, "At final Floor 50")
+ var boss_id = spire.active_mobs.keys()[0]
+ _assert(spire.active_mobs[boss_id]["vnum"] == 8027, "Final boss is Chaos Overlord (8027)")
+
+ # Kill final boss
+ var finish_res = spire.kill_mob(boss_id)
+ _assert(finish_res["ok"] == true and finish_res["trial_completed"] == true, "Trial fully completed")
+ _assert(spire.is_in_trial == false, "Trial marked as finished")
+ _assert(mastery_sig["fired"] == true and mastery_sig["floor"] == 50, "spire_trial_completed emitted")
+
+func _test_timeout_defeat() -> void:
+ print("\n--- Test 5: Timeout Defeat ---")
+ var spire := EndlessSpireDungeonSystem.new()
+ spire.start_trial(false)
+
+ var fail_sig := {"fired": false, "floor": 0}
+ spire.spire_trial_failed.connect(func(f: int, _r: String):
+ fail_sig["fired"] = true
+ fail_sig["floor"] = f
+ )
+
+ # Advance time past 120s limit
+ var tick = spire.update(125.0)
+ _assert(tick["status"] == "failed" and tick["reason"] == "TIME_EXPIRED", "Failed on timeout")
+ _assert(fail_sig["fired"] == true and fail_sig["floor"] == 1, "spire_trial_failed emitted")
+ _assert(spire.is_in_trial == false, "Trial ended")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Endless Spire Dungeon Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Endless Spire Dungeon parity tests passed!")
+ quit(0)
diff --git a/project/test_endless_spire_dungeon_parity.gd.uid b/project/test_endless_spire_dungeon_parity.gd.uid
new file mode 100644
index 00000000..734a0a06
--- /dev/null
+++ b/project/test_endless_spire_dungeon_parity.gd.uid
@@ -0,0 +1 @@
+uid://dpm4xk85qw21y
diff --git a/project/test_energy_system_parity.gd b/project/test_energy_system_parity.gd
new file mode 100644
index 00000000..2bc14a7c
--- /dev/null
+++ b/project/test_energy_system_parity.gd
@@ -0,0 +1,167 @@
+# test_energy_system_parity.gd —— 40250 官方能量碎片与水晶熔炼系统 1:1 回归测试套件
+extends SceneTree
+
+const EnergySystem = preload("res://energy_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_energy_system_parity (Direction 1: Energy System 1:1) ===")
+ _test_fragment_extraction()
+ _test_crystal_fusion()
+ _test_crystal_usage_and_buff_lifecycle()
+ _test_serialization()
+ _finish()
+
+func _test_fragment_extraction() -> void:
+ print("\n--- Test 1: Energy Fragment Extraction (40250 Quest Rules) ---")
+ var sys := EnergySystem.new()
+ var inv: Array = [
+ {"vnum": 10, "name": "长剑+0", "level_limit": 1},
+ {"vnum": 8000, "name": "木箭", "level_limit": 40, "type": 1, "subtype": 5},
+ {"vnum": 160, "name": "战斗剑+9", "level_limit": 65}
+ ]
+
+ # 1. Player level too low
+ var res_low_player = sys.extract_energy_fragments(inv, 2, 30)
+ _assert(res_low_player["ok"] == false, "Extraction rejected: player lv 30 < 35")
+ _assert(res_low_player["reason"] == "PLAYER_LEVEL_TOO_LOW", "Reason is PLAYER_LEVEL_TOO_LOW")
+
+ # 2. Equipment level too low (< 35)
+ var res_low_equip = sys.extract_energy_fragments(inv, 0, 40)
+ _assert(res_low_equip["ok"] == false, "Extraction rejected: equip lv 1 < 35")
+ _assert(res_low_equip["reason"] == "EQUIP_LEVEL_TOO_LOW", "Reason is EQUIP_LEVEL_TOO_LOW")
+
+ # 3. Arrow weapon prohibited
+ var res_arrow = sys.extract_energy_fragments(inv, 1, 40)
+ _assert(res_arrow["ok"] == false, "Extraction rejected: arrow weapon not allowed")
+ _assert(res_arrow["reason"] == "ARROW_NOT_ALLOWED", "Reason is ARROW_NOT_ALLOWED")
+
+ var extract_sig := {"fired": false, "cnt": 0}
+ sys.fragments_extracted.connect(func(c: int, _n: String):
+ extract_sig["fired"] = true
+ extract_sig["cnt"] = c
+ )
+
+ # 4. Valid extraction of Lv 65 sword
+ var res_succ = sys.extract_energy_fragments(inv, 2, 70)
+ _assert(res_succ["ok"] == true, "Lv 65 sword extracted successfully")
+ _assert(inv[2] != null and inv[2]["vnum"] == EnergySystem.VNUM_ENERGY_STONE, "Slot 2 converted to Energy Stones")
+ _assert(inv[2]["count"] >= 3 and inv[2]["count"] <= 8, "Yield is between 3 and 8 fragments")
+ _assert(extract_sig["fired"] == true, "fragments_extracted signal emitted")
+
+func _test_crystal_fusion() -> void:
+ print("\n--- Test 2: Energy Crystal (51002) Fusion (30 Stones + 1000 Gold) ---")
+ var sys := EnergySystem.new()
+ var player_gold := {"gold": 500} # Less than 1000
+ var inv: Array = [
+ {"vnum": EnergySystem.VNUM_ENERGY_STONE, "name": "能量碎片", "count": 25},
+ null
+ ]
+
+ # 1. Gold not enough
+ var res_no_gold = sys.fuse_energy_crystal(inv, player_gold)
+ _assert(res_no_gold["ok"] == false, "Fusion rejected: gold 500 < 1000")
+ _assert(res_no_gold["reason"] == "NOT_ENOUGH_GOLD", "Reason is NOT_ENOUGH_GOLD")
+
+ # 2. Stones not enough (25 < 30)
+ player_gold["gold"] = 5000
+ var res_no_stone = sys.fuse_energy_crystal(inv, player_gold)
+ _assert(res_no_stone["ok"] == false, "Fusion rejected: stones 25 < 30")
+ _assert(res_no_stone["reason"] == "NOT_ENOUGH_STONES", "Reason is NOT_ENOUGH_STONES")
+
+ # 3. Valid fusion with 35 stones
+ inv[0]["count"] = 35
+ var fuse_sig := {"fired": false}
+ sys.crystal_fused.connect(func(_c: Dictionary):
+ fuse_sig["fired"] = true
+ )
+
+ var res_succ = sys.fuse_energy_crystal(inv, player_gold)
+ _assert(res_succ["ok"] == true, "Fusion succeeded")
+ _assert(player_gold["gold"] == 4000, "1,000 gold deducted (5000 -> 4000)")
+ _assert(inv[0]["count"] == 5, "Stones deducted (35 - 30 = 5 remaining)")
+ _assert(inv[1] != null and inv[1]["vnum"] == EnergySystem.VNUM_CHARGING_STONE, "Energy Crystal generated in slot 1")
+ _assert(fuse_sig["fired"] == true, "crystal_fused signal emitted")
+
+func _test_crystal_usage_and_buff_lifecycle() -> void:
+ print("\n--- Test 3: Crystal Usage, 10% Attribute Buff & 2-Hour Lifecycle ---")
+ var sys := EnergySystem.new()
+ var inv: Array = [
+ {"vnum": EnergySystem.VNUM_CHARGING_STONE, "name": "能量水晶", "count": 1}
+ ]
+ var equipped: Array = [
+ {"attack_power": 200, "defense": 0, "max_hp": 0},
+ {"attack_power": 0, "defense": 150, "max_hp": 0},
+ {"attack_power": 0, "defense": 0, "max_hp": 1000}
+ ]
+ var player_stats := {"attack_power": 500, "defense": 300, "max_hp": 5000}
+
+ var act_sig := {"fired": false, "dur": 0.0, "pct": 0}
+ sys.energy_activated.connect(func(d: float, p: int):
+ act_sig["fired"] = true
+ act_sig["dur"] = d
+ act_sig["pct"] = p
+ )
+
+ var exp_sig := {"fired": false}
+ sys.energy_expired.connect(func():
+ exp_sig["fired"] = true
+ )
+
+ # Use crystal
+ var use_res = sys.use_energy_crystal(inv, 0, equipped, player_stats)
+ _assert(use_res["ok"] == true, "Crystal activated")
+ _assert(sys.is_energy_active == true, "is_energy_active is true")
+ _assert(inv[0] == null, "Energy crystal consumed from inventory")
+ _assert(act_sig["fired"] == true, "energy_activated signal emitted")
+ _assert(act_sig["dur"] == 7200.0, "Duration is 7200.0s (2 hours)")
+
+ # Check 10% stats:
+ # atk: 500 + 20 = 520
+ # def: 300 + 15 = 315
+ # hp: 5000 + 100 = 5100
+ _assert(player_stats["attack_power"] == 520, "Attack boosted by 10% of 200 = +20")
+ _assert(player_stats["defense"] == 315, "Defense boosted by 10% of 150 = +15")
+ _assert(player_stats["max_hp"] == 5100, "HP boosted by 10% of 1000 = +100")
+
+ # Advance time 7200s -> timeout
+ sys.update(7200.0, player_stats)
+ _assert(sys.is_energy_active == false, "Energy expired after 2 hours")
+ _assert(player_stats["attack_power"] == 500, "Attack cleanly reversed back to 500")
+ _assert(player_stats["defense"] == 300, "Defense cleanly reversed back to 300")
+ _assert(player_stats["max_hp"] == 5000, "HP cleanly reversed back to 5000")
+ _assert(exp_sig["fired"] == true, "energy_expired signal emitted")
+
+func _test_serialization() -> void:
+ print("\n--- Test 4: Serialization & Restoration ---")
+ var sys1 := EnergySystem.new()
+ sys1.is_energy_active = true
+ sys1.energy_time_remaining = 3600.0
+ sys1.applied_bonuses = {"attack_power": 25, "defense": 18}
+
+ var data = sys1.serialize()
+ var sys2 := EnergySystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.is_energy_active == true, "Restored is_energy_active")
+ _assert(is_equal_approx(sys2.energy_time_remaining, 3600.0), "Restored time remaining")
+ _assert(sys2.applied_bonuses["attack_power"] == 25, "Restored attack bonus")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Energy System Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_energy_system_parity.gd.uid b/project/test_energy_system_parity.gd.uid
new file mode 100644
index 00000000..ca929a90
--- /dev/null
+++ b/project/test_energy_system_parity.gd.uid
@@ -0,0 +1 @@
+uid://yfkpp5ksk57y
diff --git a/project/test_epic_refine_parity.gd b/project/test_epic_refine_parity.gd
new file mode 100644
index 00000000..75dab5dc
--- /dev/null
+++ b/project/test_epic_refine_parity.gd
@@ -0,0 +1,183 @@
+# test_epic_refine_parity.gd —— 40250 修元平史诗级装备升阶转换 1:1 纯净回归测试套件
+extends SceneTree
+
+const EpicRefineSystem = preload("res://epic_refine_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_epic_refine_parity (Direction 2: Seon-Pyeong Epic Refine 1:1) ===")
+ _test_recipe_definitions()
+ _test_material_and_gold_validation()
+ _test_weapon_epic_refine_and_inheritance()
+ _test_armor_epic_refine_and_inheritance()
+ _finish()
+
+func _test_recipe_definitions() -> void:
+ print("\n--- Test 1: Recipe Table & Eligibility Validation ---")
+ var ers := EpicRefineSystem.new()
+
+ # Valid Lv65 +9 weapons
+ _assert(ers.can_epic_refine({"vnum": 169}), "Battle Sword +9 (169) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 3159}), "Ghost Fang Blade +9 (3159) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 1109}), "Partisan +9 (1109) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 1119}), "Dragon Knife +9 (1119) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 2149}), "Yellow Dragon Bow +9 (2149) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 5109}), "Salvation Bell +9 (5109) is eligible")
+
+ # Valid Lv66 +9 armors
+ _assert(ers.can_epic_refine({"vnum": 11299}), "Black Steel Armor +9 (11299) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 11499}), "Black Wind Suit +9 (11499) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 11699}), "Black Magic Armor +9 (11699) is eligible")
+ _assert(ers.can_epic_refine({"vnum": 11899}), "Black Clothing +9 (11899) is eligible")
+
+ # Ineligible items
+ _assert(not ers.can_epic_refine({"vnum": 10}), "Wooden Sword (10) ineligible")
+ _assert(not ers.can_epic_refine({"vnum": 168}), "Battle Sword +8 (168) ineligible")
+ _assert(not ers.can_epic_refine({}), "Empty dict ineligible")
+
+ # Check target vnums
+ _assert(ers.get_recipe(169)["target_vnum"] == 279, "Battle Sword +9 -> Triton Sword +0 (279)")
+ _assert(ers.get_recipe(11299)["target_vnum"] == 12019, "Black Steel +9 -> Blue Steel +0 (12019)")
+
+func _test_material_and_gold_validation() -> void:
+ print("\n--- Test 2: Material & Yang Requirement Validation ---")
+ var ers := EpicRefineSystem.new()
+
+ var player_data := {"gold": 100000} # Less than 200,000
+ var inv: Array = [
+ {"vnum": 169, "name": "战神之剑+9", "count": 1},
+ {"vnum": 71123, "name": "龙鳞", "count": 2}, # need 3
+ {"vnum": 71129, "name": "龙爪", "count": 3} # need 4
+ ]
+
+ # Insufficient Yang
+ var r1 = ers.do_epic_refine(0, inv, player_data)
+ _assert(r1["ok"] == false and r1["reason"] == "INSUFFICIENT_YANG", "Blocked when Yang < 200,000")
+
+ player_data["gold"] = 500000 # Give enough Yang
+
+ # Missing Dragon Scales (has 2, needs 3)
+ var r2 = ers.do_epic_refine(0, inv, player_data)
+ _assert(r2["ok"] == false and r2["reason"] == "MISSING_DRAGON_SCALES", "Blocked when Dragon Scales < 3")
+
+ inv[1]["count"] = 3 # Now have 3 scales
+
+ # Missing Dragon Claws (has 3, needs 4)
+ var r3 = ers.do_epic_refine(0, inv, player_data)
+ _assert(r3["ok"] == false and r3["reason"] == "MISSING_DRAGON_CLAWS", "Blocked when Dragon Claws < 4")
+
+ # Locked item blocked
+ inv[0]["is_locked"] = true
+ inv[2]["count"] = 4
+ var r4 = ers.do_epic_refine(0, inv, player_data)
+ _assert(r4["ok"] == false and r4["reason"] == "ITEM_LOCKED", "Blocked when item is locked")
+
+func _test_weapon_epic_refine_and_inheritance() -> void:
+ print("\n--- Test 3: Weapon Refine (169 -> 279) with 100% 5-Attr & Sockets Preserved ---")
+ var ers := EpicRefineSystem.new()
+ var signal_data := {"fired": false, "old": 0, "new": 0}
+ ers.epic_refine_succeeded.connect(func(o: int, n: int, _item: Dictionary):
+ signal_data["fired"] = true
+ signal_data["old"] = o
+ signal_data["new"] = n
+ )
+
+ var base_weapon: Dictionary = {
+ "vnum": 169,
+ "name": "战神之剑+9",
+ "count": 1,
+ "attributes": [
+ {"type": 17, "value": 10}, # 半人增伤 +10%
+ {"type": 15, "value": 10}, # 致命一击 +10%
+ {"type": 5, "value": 12}, # 力量 +12
+ {"type": 16, "value": 10}, # 穿透一击 +10%
+ {"type": 9, "value": 20} # 施法加速 +20%
+ ],
+ "sockets": [28430, 28431, 28432], # 战士灵石+4, 刺客灵石+4, 修罗灵石+4
+ "socket_count": 3
+ }
+
+ var player_data := {"gold": 500000}
+ var inv: Array = [
+ base_weapon,
+ {"vnum": 71123, "name": "龙鳞", "count": 5}, # 5 scales (consume 3, leave 2)
+ {"vnum": 71129, "name": "龙爪", "count": 4} # 4 claws (consume all 4)
+ ]
+
+ var res = ers.do_epic_refine(0, inv, player_data)
+ _assert(res["ok"] == true, "Epic refine succeeded")
+ _assert(signal_data["fired"] == true, "epic_refine_succeeded signal fired")
+ _assert(signal_data["old"] == 169 and signal_data["new"] == 279, "Signal values match 169 -> 279")
+
+ # Check costs
+ _assert(player_data["gold"] == 300000, "200,000 Yang deducted (500k -> 300k)")
+ _assert(inv[1]["count"] == 2, "3 Dragon Scales consumed (5 -> 2)")
+ _assert(inv[2] == null, "4 Dragon Claws fully consumed (slot becomes null)")
+
+ # Check upgraded item in slot 0
+ var new_item = inv[0]
+ _assert(new_item["vnum"] == 279, "New item vnum is 279 (Triton Sword +0)")
+ _assert(new_item["name"] == "崔顿之剑+0", "New item name is 崔顿之剑+0")
+
+ # Check 5 Attributes preserved exactly
+ _assert(new_item["attributes"].size() == 5, "All 5 attributes preserved")
+ _assert(new_item["attributes"][0]["type"] == 17 and new_item["attributes"][0]["value"] == 10, "Attr 1: Semi-Human +10% preserved")
+ _assert(new_item["attributes"][1]["type"] == 15 and new_item["attributes"][1]["value"] == 10, "Attr 2: Critical +10% preserved")
+ _assert(new_item["attributes"][2]["type"] == 5 and new_item["attributes"][2]["value"] == 12, "Attr 3: STR +12 preserved")
+ _assert(new_item["attributes"][3]["type"] == 16 and new_item["attributes"][3]["value"] == 10, "Attr 4: Piercing +10% preserved")
+ _assert(new_item["attributes"][4]["type"] == 9 and new_item["attributes"][4]["value"] == 20, "Attr 5: Casting Speed +20% preserved")
+
+ # Check 3 Sockets preserved exactly
+ _assert(new_item["sockets"].size() == 3, "All 3 sockets preserved")
+ _assert(new_item["sockets"][0] == 28430, "Socket 1 preserved (28430)")
+ _assert(new_item["sockets"][1] == 28431, "Socket 2 preserved (28431)")
+ _assert(new_item["sockets"][2] == 28432, "Socket 3 preserved (28432)")
+
+func _test_armor_epic_refine_and_inheritance() -> void:
+ print("\n--- Test 4: Armor Refine (11299 -> 12019) with Attributes & Sockets ---")
+ var ers := EpicRefineSystem.new()
+
+ var base_armor: Dictionary = {
+ "vnum": 11299,
+ "name": "黑钢甲+9",
+ "count": 1,
+ "attributes": [
+ {"type": 1, "value": 2000}, # 最大生命 +2000
+ {"type": 29, "value": 15}, # 剑防御 +15%
+ {"type": 30, "value": 15} # 双手防御 +15%
+ ],
+ "sockets": [28438, 28439, 28440] # 防御灵石+4, 闪避灵石+4, 生命灵石+4
+ }
+
+ var player_data := {"gold": 300000}
+ var inv: Array = [
+ base_armor,
+ {"vnum": 71123, "name": "龙鳞", "count": 3},
+ {"vnum": 71129, "name": "龙爪", "count": 4}
+ ]
+
+ var res = ers.do_epic_refine(0, inv, player_data)
+ _assert(res["ok"] == true, "Armor epic refine succeeded")
+ _assert(inv[0]["vnum"] == 12019, "Armor converted to Blue Steel Armor +0 (12019)")
+ _assert(inv[0]["attributes"].size() == 3, "All 3 armor bonus attributes preserved")
+ _assert(inv[0]["attributes"][0]["value"] == 2000, "Max HP +2000 preserved")
+ _assert(inv[0]["sockets"].size() == 3, "All 3 armor sockets preserved")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Epic Refine Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_epic_refine_parity.gd.uid b/project/test_epic_refine_parity.gd.uid
new file mode 100644
index 00000000..9f5b739c
--- /dev/null
+++ b/project/test_epic_refine_parity.gd.uid
@@ -0,0 +1 @@
+uid://c3ybhrt4ablle
diff --git a/project/test_equipment_inspect_parity.gd b/project/test_equipment_inspect_parity.gd
new file mode 100644
index 00000000..8cb0502a
--- /dev/null
+++ b/project/test_equipment_inspect_parity.gd
@@ -0,0 +1,116 @@
+# test_equipment_inspect_parity.gd —— 40250 装备透视检视与 GM 监察系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://equipment_inspect_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_equipment_inspect_parity (Batch 28 Direction 2) ===")
+ _test_open_and_load_slots()
+ _test_privacy_and_gm_bypass()
+ _test_slot_tooltip_generation()
+ _test_close_lifecycle()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_open_and_load_slots() -> void:
+ print("\n--- Test 1: Open Dialog & Equipment Slots Loading ---")
+ var ins = SystemClass.new()
+
+ var dummy_equips = {
+ SystemClass.WEAR_WEAPON: {
+ "vnum": 189,
+ "name": "毒剑+9 (Poison Sword+9)",
+ "sockets": [28430, 28431, 28432],
+ "attributes": [{ "type": 72, "value": 42 }]
+ },
+ SystemClass.WEAR_BODY: {
+ "vnum": 11299,
+ "name": "黑铁战甲+9 (Black Steel Armor+9)",
+ "sockets": [28440, 28441, 0],
+ "attributes": [{ "type": 1, "value": 2000 }]
+ },
+ SystemClass.WEAR_COSTUME_BODY: {
+ "vnum": 41001,
+ "name": "沙漠武士服 (Desert Warrior Body)",
+ "attributes": [{ "type": 1, "value": 1500 }]
+ }
+ }
+
+ var r = ins.open_dialog(1001, "TargetWarrior", dummy_equips, true, false)
+ _assert(r["success"] == true, "Opened inspect dialog for TargetWarrior")
+ _assert(ins.is_open == true, "Inspect dialog is open")
+ _assert(ins.inspected_vid == 1001, "Target VID is 1001")
+
+ # Verify Weapon Slot
+ var wpn = ins.get_slot_item(SystemClass.WEAR_WEAPON)
+ _assert(wpn["vnum"] == 189, "Weapon Vnum matches 189")
+ _assert(wpn["sockets"].size() == 3, "Weapon has 3 sockets")
+
+ # Verify Costume Body Slot
+ var cst = ins.get_slot_item(SystemClass.WEAR_COSTUME_BODY)
+ _assert(cst["vnum"] == 41001, "Costume body loaded")
+
+func _test_privacy_and_gm_bypass() -> void:
+ print("\n--- Test 2: Privacy Shield & GM Privilege Bypass ---")
+ var ins = SystemClass.new()
+ var dummy_equips = { SystemClass.WEAR_WEAPON: { "vnum": 10, "name": "Sword+0" } }
+
+ # 1. Normal player inspecting private player -> REJECTED
+ var r_fail = ins.open_dialog(1002, "PrivateLord", dummy_equips, false, false)
+ _assert(r_fail["error_code"] == "INSPECT_FORBIDDEN", "Reject inspection if target hidden privacy")
+ _assert(ins.is_open == false, "Dialog remains closed")
+
+ # 2. GM inspecting private player -> BYPASS SUCCESS
+ var r_gm = ins.open_dialog(1002, "PrivateLord", dummy_equips, false, true)
+ _assert(r_gm["success"] == true, "GM successfully bypassed privacy shield")
+ _assert(ins.is_open == true, "Dialog opened for GM")
+
+func _test_slot_tooltip_generation() -> void:
+ print("\n--- Test 3: Slot Tooltip Generation on Hover ---")
+ var ins = SystemClass.new()
+ var dummy_equips = {
+ SystemClass.WEAR_WEAPON: {
+ "vnum": 189,
+ "name": "Poison Sword+9",
+ "sockets": [28430, 28431, 0],
+ "attributes": [{ "type": 72, "value": 45 }]
+ }
+ }
+ ins.open_dialog(1001, "TargetWarrior", dummy_equips, true, false)
+
+ # Equipped weapon tooltip
+ var tip_wpn = ins.build_slot_tooltip(SystemClass.WEAR_WEAPON)
+ _assert(tip_wpn.contains("Poison Sword+9"), "Tooltip contains weapon name")
+ _assert(tip_wpn.contains("部位: 武器"), "Tooltip contains slot title")
+ _assert(tip_wpn.contains("属性 72 +45"), "Tooltip contains bonus attribute")
+
+ # Empty shield slot tooltip
+ var tip_shield = ins.build_slot_tooltip(SystemClass.WEAR_SHIELD)
+ _assert(tip_shield.contains("[盾牌] 未佩戴装备"), "Empty slot displays placeholder notice")
+
+func _test_close_lifecycle() -> void:
+ print("\n--- Test 4: Close Dialog & Memory Cleanup ---")
+ var ins = SystemClass.new()
+ ins.open_dialog(1001, "Target", { SystemClass.WEAR_BODY: { "vnum": 1, "name": "Armor" } })
+ _assert(ins.is_open == true, "Dialog open")
+
+ ins.close_dialog()
+ _assert(ins.is_open == false, "Dialog closed")
+ _assert(ins.equipment_slots.is_empty(), "Slots wiped on close")
+ _assert(ins.get_slot_item(SystemClass.WEAR_BODY).is_empty(), "Cannot retrieve slot when closed")
diff --git a/project/test_equipment_inspect_parity.gd.uid b/project/test_equipment_inspect_parity.gd.uid
new file mode 100644
index 00000000..9d2814fa
--- /dev/null
+++ b/project/test_equipment_inspect_parity.gd.uid
@@ -0,0 +1 @@
+uid://bpo135hh7nool
diff --git a/project/test_event_manager_parity.gd b/project/test_event_manager_parity.gd
new file mode 100644
index 00000000..94714d82
--- /dev/null
+++ b/project/test_event_manager_parity.gd
@@ -0,0 +1,143 @@
+# test_event_manager_parity.gd —— 40250 全服掉落活动与倍率调度器 1:1 纯净回归测试套件
+extends SceneTree
+
+const EventManagerSystem = preload("res://event_manager_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_event_manager_parity (Direction 4: Solo Event Manager 1:1) ===")
+ _test_rate_adjustments()
+ _test_event_toggling()
+ _test_event_drop_chances()
+ _test_box_opening()
+ _finish()
+
+func _test_rate_adjustments() -> void:
+ print("\n--- Test 1: Rate Adjustments (EXP, Drop, Yang) ---")
+ var ems := EventManagerSystem.new()
+
+ # Invalid multipliers
+ var r_bad1 = ems.set_rate("exp", 0.5)
+ _assert(r_bad1["ok"] == false and r_bad1["reason"] == "INVALID_MULTIPLIER", "< 1.0 multiplier rejected")
+
+ var r_bad2 = ems.set_rate("exp", 10.0)
+ _assert(r_bad2["ok"] == false and r_bad2["reason"] == "INVALID_MULTIPLIER", "> 5.0 multiplier rejected")
+
+ var rate_signal := {"fired": false, "type": "", "mult": 0.0}
+ ems.rate_changed.connect(func(t: String, m: float):
+ rate_signal["fired"] = true
+ rate_signal["type"] = t
+ rate_signal["mult"] = m
+ )
+
+ # 1. EXP Rate 2.0x
+ var r_exp = ems.set_rate("exp", 2.0)
+ _assert(r_exp["ok"] == true, "EXP rate set to 2.0x")
+ _assert(ems.calculate_exp(1000) == 2000, "1000 base EXP doubled to 2000")
+ _assert(rate_signal["fired"] == true and rate_signal["type"] == "exp", "rate_changed signal fired")
+
+ # 2. Gold Rate 3.0x
+ var r_gold = ems.set_rate("gold", 3.0)
+ _assert(r_gold["ok"] == true, "Gold rate set to 3.0x")
+ _assert(ems.calculate_gold(500) == 1500, "500 base Gold tripled to 1500")
+
+ # 3. Drop Rate 1.5x
+ var r_drop = ems.set_rate("drop", 1.5)
+ _assert(r_drop["ok"] == true, "Drop rate set to 1.5x")
+ _assert(ems.drop_rate == 1.5, "drop_rate is 1.5")
+
+func _test_event_toggling() -> void:
+ print("\n--- Test 2: Event Toggling (Moonlight & Hexagonal) ---")
+ var ems := EventManagerSystem.new()
+
+ var ev_signal := {"fired": false, "name": "", "active": false}
+ ems.event_state_changed.connect(func(n: String, act: bool):
+ ev_signal["fired"] = true
+ ev_signal["name"] = n
+ ev_signal["active"] = act
+ )
+
+ # Invalid event
+ var r_bad = ems.toggle_event("christmas", true)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "INVALID_EVENT_NAME", "Invalid event name rejected")
+
+ # Toggle Moonlight event ON
+ var r1 = ems.toggle_event("moonlight", true)
+ _assert(r1["ok"] == true and ems.is_moonlight_active == true, "Moonlight event enabled")
+ _assert(ev_signal["fired"] == true and ev_signal["name"] == "moonlight" and ev_signal["active"] == true, "event_state_changed signal fired")
+
+ # Toggle Hexagonal event ON
+ var r2 = ems.toggle_event("hexagonal", true)
+ _assert(r2["ok"] == true and ems.is_hexagonal_active == true, "Hexagonal event enabled")
+
+ # Toggle Moonlight event OFF
+ ems.toggle_event("moonlight", false)
+ _assert(ems.is_moonlight_active == false, "Moonlight event disabled")
+
+func _test_event_drop_chances() -> void:
+ print("\n--- Test 3: Event Box Drops on Mob Kills ---")
+ var ems := EventManagerSystem.new()
+ ems.toggle_event("moonlight", true)
+ ems.toggle_event("hexagonal", true)
+
+ var drop_signal := {"fired": false, "vnum": 0}
+ ems.event_box_dropped.connect(func(v: int, _n: String):
+ drop_signal["fired"] = true
+ drop_signal["vnum"] = v
+ )
+
+ # 1. Level difference > 15 (Mob Lv 10 vs Player Lv 50) -> No drop
+ var drops_far = ems.check_event_drops(10, 50, 0.01)
+ _assert(drops_far.is_empty(), "Level diff > 15 drops nothing")
+
+ # 2. Level difference <= 15 with winning rolls
+ # Moonlight chance = 10% (0.10). Forced roll = 0.05 -> drops 50011
+ # Hexagonal chance = 15% (0.15). Forced roll = 0.05 -> drops 50037
+ var drops = ems.check_event_drops(50, 52, 0.05)
+ _assert(drops.size() == 2, "Both Moonlight and Hexagonal boxes dropped on winning roll")
+ _assert(drops[0]["vnum"] == EventManagerSystem.ITEM_MOONLIGHT_BOX, "Moonlight Box (50011) dropped")
+ _assert(drops[1]["vnum"] == EventManagerSystem.ITEM_HEXAGONAL_BOX, "Hexagonal Box (50037) dropped")
+ _assert(drop_signal["fired"] == true, "event_box_dropped signal fired")
+
+ # 3. Losing roll (e.g. 0.80) -> No drop
+ var drops_fail = ems.check_event_drops(50, 52, 0.80)
+ _assert(drops_fail.is_empty(), "Losing roll drops nothing")
+
+func _test_box_opening() -> void:
+ print("\n--- Test 4: Event Box Opening (50011 Loot Pool) ---")
+ var ems := EventManagerSystem.new()
+ var inv: Array = [
+ {"vnum": EventManagerSystem.ITEM_MOONLIGHT_BOX, "name": "月光宝盒", "count": 1},
+ null
+ ]
+
+ # Open Moonlight Box at slot 0
+ var res = ems.open_event_box(0, inv)
+ _assert(res["ok"] == true, "Moonlight box opened successfully")
+
+ var reward = res["reward"]
+ _assert(reward.has("vnum") and reward.has("name"), "Valid reward drawn from pool")
+ print(" Drew Moonlight Box reward: %s (vnum: %d)" % [reward["name"], reward["vnum"]])
+
+ var stored_slot = res["stored_slot"]
+ _assert(stored_slot >= 0 and inv[stored_slot] != null, "Reward saved in inventory")
+ _assert(inv[stored_slot]["vnum"] == reward["vnum"], "Inventory item matches reward")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Event Manager Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_event_manager_parity.gd.uid b/project/test_event_manager_parity.gd.uid
new file mode 100644
index 00000000..ff7c4158
--- /dev/null
+++ b/project/test_event_manager_parity.gd.uid
@@ -0,0 +1 @@
+uid://gatt417wythe
diff --git a/project/test_exp_fly_parity.gd b/project/test_exp_fly_parity.gd
new file mode 100644
index 00000000..862571c0
--- /dev/null
+++ b/project/test_exp_fly_parity.gd
@@ -0,0 +1,163 @@
+# test_exp_fly_parity.gd
+# 检验 40250 怪物阵亡经验光球飞行、自导向追踪与吸收动效
+extends SceneTree
+
+const FlyObject = preload("res://fly_object.gd")
+const NetPlay = preload("res://net_play.gd")
+const NetWorld = preload("res://net_world.gd")
+const PlayerCtl = preload("res://player_controller.gd")
+
+func _init() -> void:
+ print("--- 开始测试 40250 怪物阵亡经验光球飞行与吸收动效 (test_exp_fly_parity) ---")
+ var fails: int = 0
+
+ # 1. 验证 40250 FLY 常量与编号映射
+ if FlyObject.FLY_EXP != 0:
+ printerr("FAIL: FLY_EXP 应为 0,实际为: ", FlyObject.FLY_EXP)
+ fails += 1
+ if FlyObject.FLY_HP_SMALL != 1 or FlyObject.FLY_SP_SMALL != 4:
+ printerr("FAIL: FLY_HP/SP 常量映射错误")
+ fails += 1
+
+ # 2. 模拟 FlyObject 经验球追踪、命中与吸收信号
+ var mount := Node3D.new()
+ get_root().add_child(mount)
+
+ var mgr := FlyObject.new()
+ mgr.manual_step = true
+ mount.add_child(mgr)
+ mgr.setup(mount)
+
+ var player_target := Node3D.new()
+ mount.add_child(player_target)
+ player_target.position = Vector3(5, 1, 0)
+
+ var exp_absorbed_count := [0]
+ mgr.exp_absorbed.connect(func(_wp: Vector3):
+ exp_absorbed_count[0] += 1
+ )
+
+ var d := FlyObject.FlyData.new()
+ d.init_vel = 15.0
+ d.flat_range = 30.0
+ d.bomb_range = 0.8
+ d.is_homing = true
+ d.homing_start_time = 0.05
+ d.homing_max_angle = 140.0
+ d.maintain_parallel = true
+
+ var start_pos := Vector3(0, 1, 0)
+ var inst = mgr.spawn(start_pos, player_target, false, d, FlyObject.FLY_EXP)
+ if mgr.active_count() != 1:
+ printerr("FAIL: 经验球未成功注册进飞行物管理器!")
+ fails += 1
+ if inst.skill_index != FlyObject.FLY_EXP:
+ printerr("FAIL: 实例技能索引不符: ", inst.skill_index)
+ fails += 1
+
+ # 推进仿真时间步,让光球命中玩家目标
+ for i in range(40):
+ mgr.step(0.02)
+ if mgr.active_count() == 0:
+ break
+
+ if exp_absorbed_count[0] != 1:
+ printerr("FAIL: 经验球到达玩家时未发出 exp_absorbed 信号,次数: ", exp_absorbed_count[0])
+ fails += 1
+ if mgr.active_count() != 0:
+ printerr("FAIL: 经验球命中后未从活跃队列销毁!")
+ fails += 1
+
+ # 3. 验证 net_world._on_fly(FLY_EXP) 生成 3 颗聚能光球
+ var nw := NetWorld.new()
+ mount.add_child(nw)
+ nw.parent = mount
+ nw._fly_mgr = mgr
+
+ # 模拟注册实体坐标
+ var fake_client := _FakeClient.new()
+ fake_client.ents[100] = {"vid": 100, "pos": Vector3(0, 0, 0)} # 怪物
+ fake_client.ents[1] = {"vid": 1, "pos": Vector3(4, 0, 0)} # 玩家
+ nw.client = fake_client
+
+ var mob_node := Node3D.new()
+ mob_node.position = Vector3(0, 0, 0)
+ mount.add_child(mob_node)
+ nw._by_vid[100] = mob_node
+
+ var player_node := Node3D.new()
+ player_node.position = Vector3(4, 0, 0)
+ mount.add_child(player_node)
+ nw._by_vid[1] = player_node
+
+ # 触发 40250 官方 _on_fly(0, 100, 1)
+ nw._on_fly(FlyObject.FLY_EXP, 100, 1)
+ if mgr.active_count() != 3:
+ printerr("FAIL: _on_fly(FLY_EXP) 应发射 3 颗经验聚能光球,实际: ", mgr.active_count())
+ fails += 1
+
+ # 清空飞行物
+ for i in range(50):
+ mgr.step(0.02)
+ if mgr.active_count() == 0:
+ break
+
+ # 4. 验证 net_world.spawn_exp_fly 接口
+ nw.spawn_exp_fly(Vector3(2, 0, 2), player_node, 3)
+ if mgr.active_count() != 3:
+ printerr("FAIL: spawn_exp_fly 未成功生成 3 颗光球,实际: ", mgr.active_count())
+ fails += 1
+
+ for i in range(50):
+ mgr.step(0.02)
+ if mgr.active_count() == 0:
+ break
+
+ # 5. 验证 net_play 经验变动时触发经验获取流程
+ var np := NetPlay.new()
+ mount.add_child(np)
+ var pc := PlayerCtl.new()
+ pc.player = player_node
+ np.setup(fake_client, pc, nw, null)
+
+ # 初始经验
+ np._on_points({"exp": 1000, "next_exp": 5000, "level": 10})
+ if mgr.active_count() != 0:
+ printerr("FAIL: 初始点数同步不应触发获取经验!")
+ fails += 1
+
+ # 杀死怪物获得 500 经验
+ np._last_dead_mob_pos = Vector3(3, 0, 0)
+ np._on_points({"exp": 1500, "next_exp": 5000, "level": 10})
+ if mgr.active_count() != 3:
+ printerr("FAIL: 经验增加时未能通过 net_world 生成经验光球!实际数量: ", mgr.active_count())
+ fails += 1
+
+ mount.queue_free()
+
+ if fails == 0:
+ print("PASS: test_exp_fly_parity (6/6 全部通过)")
+ quit(0)
+ else:
+ printerr("FAILED: test_exp_fly_parity (", fails, " 项失败)")
+ quit(1)
+
+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)
+ signal target_info(t: Dictionary)
+ signal fly_cue(type: int, start_vid: int, end_vid: int)
+ signal fly_targeting(s_vid: int, t_vid: int, pos: Vector2, append: bool)
+
+ 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/test_exp_fly_parity.gd.uid b/project/test_exp_fly_parity.gd.uid
new file mode 100644
index 00000000..b5d9c6ab
--- /dev/null
+++ b/project/test_exp_fly_parity.gd.uid
@@ -0,0 +1 @@
+uid://7b1f7h78cy3b
diff --git a/project/test_fish_clam_roast_parity.gd b/project/test_fish_clam_roast_parity.gd
new file mode 100644
index 00000000..473dc05b
--- /dev/null
+++ b/project/test_fish_clam_roast_parity.gd
@@ -0,0 +1,207 @@
+# test_fish_clam_roast_parity.gd —— Metin2 40250 活鱼宰杀、珍珠蚌开珠与篝火炙烤盛宴 1:1 测试套件
+extends SceneTree
+
+const FishClamRoastSystem = preload("res://fish_clam_roast_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_fish_clam_roast_parity (Direction 1: Fish Gutting & Clam Pearls 1:1) ===")
+
+ test_live_fish_gutting()
+ test_clam_pearl_opening()
+ test_campfire_grilling()
+ test_cooked_fish_buffs()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Fish & Clam Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_live_fish_gutting() -> void:
+ print("\n--- Test 1: Live Fish Gutting (40250 1:1) ---")
+ var system = FishClamRoastSystem.new()
+
+ var inv: Array = [
+ {"vnum": 27806, "count": 1}, # 活鲤鱼 (Sazan)
+ {"vnum": 27808, "count": 1}, # 活草鱼 (Ot Sazanı)
+ {"vnum": 27803, "count": 1}, # 活鲈鱼
+ {"vnum": 27803, "count": 1}, # 活鲈鱼
+ {"vnum": 27803, "count": 1} # 活鲈鱼
+ ]
+
+ # 1. 产生死鱼 (roll >= 4001)
+ var res = system.gut_live_fish(inv, 0, 5000)
+ _assert(res["ok"] == true, "Gut fish succeeded")
+ _assert(res["result"] == "dead_fish", "Result is dead_fish")
+ _assert(res["vnum"] == 27836, "Spawned dead Sazan (27836)")
+
+ # 2. 产生鱼骨 (roll 2001~4000)
+ res = system.gut_live_fish(inv, 1, 3000)
+ _assert(res["ok"] == true, "Gut fish succeeded for bone")
+ _assert(res["result"] == "fish_bone", "Result is fish_bone")
+ _assert(res["vnum"] == 27995, "Spawned Fish Bone (27995)")
+
+ # 3. 产生珍珠蚌 (roll <= 1000)
+ res = system.gut_live_fish(inv, 2, 500)
+ _assert(res["ok"] == true, "Found clam inside fish!")
+ _assert(res["result"] == "clam", "Result is clam")
+ _assert(res["vnum"] == 27987, "Spawned Clam (27987)")
+
+ # 4. 产生蚯蚓 (roll 1001~1500)
+ res = system.gut_live_fish(inv, 3, 1200)
+ _assert(res["ok"] == true, "Found earthworm")
+ _assert(res["result"] == "earthworm", "Result is earthworm")
+ _assert(res["vnum"] == 27801, "Spawned Earthworm (27801)")
+
+ # 5. 滑入水中溜走 (roll > 1500)
+ res = system.gut_live_fish(inv, 4, 1800)
+ _assert(res["ok"] == true, "Escaped")
+ _assert(res["result"] == "escape", "Fish escaped back to water")
+
+func test_clam_pearl_opening() -> void:
+ print("\n--- Test 2: Clam Opening & 3 Pearls (40250 1:1) ---")
+ var system = FishClamRoastSystem.new()
+
+ var inv: Array = [
+ {"vnum": 27987, "count": 1}, # 蚌 1: 碎石
+ {"vnum": 27987, "count": 1}, # 蚌 2: 空
+ {"vnum": 27987, "count": 1}, # 蚌 3: 白珍珠
+ {"vnum": 27987, "count": 1}, # 蚌 4: 蓝珍珠
+ {"vnum": 27987, "count": 1} # 蚌 5: 血红珍珠
+ ]
+
+ # 1. 碎石 (branch roll <= 50)
+ var res = system.open_clam(inv, 0, 30)
+ _assert(res["ok"] == true, "Opened clam for stone")
+ _assert(res["result"] == "stone", "Result is stone")
+ _assert(res["vnum"] == 27990, "Vnum is 27990 (Stone Piece)")
+
+ # 2. 空蚌 (branch roll > 50, pearl roll <= 80)
+ res = system.open_clam(inv, 1, 70, 50)
+ _assert(res["ok"] == true, "Opened empty clam")
+ _assert(res["result"] == "empty", "Result is empty")
+
+ # 3. 白珍珠 (pearl roll 81~90)
+ res = system.open_clam(inv, 2, 70, 85)
+ _assert(res["ok"] == true, "Opened White Pearl")
+ _assert(res["result"] == "white_pearl", "Result is white_pearl")
+ _assert(res["vnum"] == 27992, "Vnum is 27992 (White Pearl)")
+
+ # 4. 蓝珍珠 (pearl roll 91~97)
+ res = system.open_clam(inv, 3, 70, 95)
+ _assert(res["ok"] == true, "Opened Blue Pearl")
+ _assert(res["result"] == "blue_pearl", "Result is blue_pearl")
+ _assert(res["vnum"] == 27993, "Vnum is 27993 (Blue Pearl)")
+
+ # 5. 血红珍珠 (pearl roll >= 98)
+ res = system.open_clam(inv, 4, 70, 99)
+ _assert(res["ok"] == true, "Opened Blood Pearl")
+ _assert(res["result"] == "blood_pearl", "Result is blood_pearl")
+ _assert(res["vnum"] == 27994, "Vnum is 27994 (Blood Pearl)")
+
+func test_campfire_grilling() -> void:
+ print("\n--- Test 3: Campfire Grilling (40250 1:1) ---")
+ var system = FishClamRoastSystem.new()
+
+ var inv: Array = [
+ {"vnum": 27836, "count": 3}, # 3 条死鲤鱼
+ {"vnum": 27838, "count": 1} # 1 条死草鱼
+ ]
+
+ # 未靠近营火拒绝烤制
+ var res = system.grill_fish(inv, 0, false)
+ _assert(res["ok"] == false, "Rejected grilling without campfire")
+ _assert(res["reason"] == "NO_CAMPFIRE", "Reason is NO_CAMPFIRE")
+
+ # 点燃营火
+ system.place_campfire(600.0)
+ _assert(system.is_near_active_campfire == true, "Campfire is active")
+
+ # 烤制 3 条死鲤鱼 (27836 -> 27866 烤鲤鱼)
+ res = system.grill_fish(inv, 0)
+ _assert(res["ok"] == true, "Grilling Sazan succeeded")
+ _assert(res["cooked_vnum"] == 27866, "Cooked Sazan vnum is 27866")
+ _assert(res["count"] == 3, "Preserved count 3")
+ _assert(inv[0] != null and inv[0]["vnum"] == 27866 and inv[0]["count"] == 3, "Slot holds 3 cooked fish")
+
+func test_cooked_fish_buffs() -> void:
+ print("\n--- Test 4: Cooked Fish Consumption & Buffs (40250 1:1) ---")
+ var system = FishClamRoastSystem.new()
+
+ var inv: Array = [
+ {"vnum": 27866, "count": 1}, # 烤鲤鱼: 移速 +20 (600s)
+ {"vnum": 27868, "count": 1}, # 烤草鱼: 攻速 +20 (600s)
+ {"vnum": 27877, "count": 1}, # 烤泥鳅: 隐身 (300s)
+ {"vnum": 27874, "count": 1}, # 烤海鲈鱼: 治愈中毒
+ {"vnum": 27863, "count": 1} # 烤鲈鱼: 恢复 180 HP
+ ]
+
+ # 1. 食用烤鲤鱼
+ var res = system.eat_cooked_fish(inv, 0)
+ _assert(res["ok"] == true, "Ate cooked Sazan")
+ _assert(res["effect"] == "move_speed", "Effect is move_speed")
+ _assert(res["value"] == 20, "Value is 20")
+ _assert(res["duration"] == 600.0, "Duration is 600s")
+
+ # 2. 食用烤草鱼
+ res = system.eat_cooked_fish(inv, 1)
+ _assert(res["ok"] == true, "Ate cooked Ot Sazani")
+ _assert(res["effect"] == "attack_speed", "Effect is attack_speed")
+ _assert(res["value"] == 20, "Value is 20")
+
+ # 3. 食用烤泥鳅
+ res = system.eat_cooked_fish(inv, 2)
+ _assert(res["ok"] == true, "Ate cooked Copra")
+ _assert(res["effect"] == "invisibility", "Effect is invisibility")
+ _assert(res["duration"] == 300.0, "Duration is 300s")
+
+ # 4. 食用解毒鱼与瞬间恢复鱼
+ res = system.eat_cooked_fish(inv, 3)
+ _assert(res["effect"] == "cure_poison", "Effect is cure_poison")
+ res = system.eat_cooked_fish(inv, 4)
+ _assert(res["effect"] == "instant_hp" and res["value"] == 180, "Instant HP restored 180")
+
+ # 检查增益状态列表
+ var buffs = system.get_active_buffs()
+ _assert(buffs.has("move_speed"), "Active buff has move_speed")
+ _assert(buffs.has("attack_speed"), "Active buff has attack_speed")
+ _assert(buffs.has("invisibility"), "Active buff has invisibility")
+
+ # 时间推移 100 秒
+ system.update(100.0)
+ _assert(buffs["invisibility"]["time_remaining"] <= 200.0, "Invisibility ticked down")
+
+ # 时间推移 250 秒 (隐身 300s 到期清除)
+ system.update(250.0)
+ _assert(not system.get_active_buffs().has("invisibility"), "Invisibility expired and removed")
+ _assert(system.get_active_buffs().has("move_speed"), "Move speed still active")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Buff Serialization & Restoration ---")
+ var system = FishClamRoastSystem.new()
+ system.place_campfire(500.0)
+ var inv: Array = [{"vnum": 27866, "count": 1}]
+ system.eat_cooked_fish(inv, 0)
+
+ var data = system.serialize()
+ _assert(data.has("active_buffs") and data["active_buffs"].has("move_speed"), "Serialized move_speed buff")
+
+ var restored = FishClamRoastSystem.new()
+ restored.deserialize(data)
+ _assert(restored.is_near_active_campfire == true, "Restored active campfire")
+ _assert(restored.get_active_buffs().has("move_speed"), "Restored move_speed buff")
diff --git a/project/test_fish_clam_roast_parity.gd.uid b/project/test_fish_clam_roast_parity.gd.uid
new file mode 100644
index 00000000..7f977309
--- /dev/null
+++ b/project/test_fish_clam_roast_parity.gd.uid
@@ -0,0 +1 @@
+uid://dm545ejmajyj3
diff --git a/project/test_fishing_cooking_camp_parity.gd b/project/test_fishing_cooking_camp_parity.gd
new file mode 100644
index 00000000..179bd38c
--- /dev/null
+++ b/project/test_fishing_cooking_camp_parity.gd
@@ -0,0 +1,162 @@
+# test_fishing_cooking_camp_parity.gd —— 40250 野外钓鱼烹饪与露营增益 1:1 回归测试套件
+extends SceneTree
+
+const FishingCookingCampSystem = preload("res://fishing_cooking_camp_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_fishing_cooking_camp_parity (Direction 2: Fishing Cooking Camp 1:1) ===")
+ _test_campfire_lighting()
+ _test_cooking_fish_recipes()
+ _test_eating_and_stat_buffs()
+ _test_buff_expiration_and_cleanup()
+ _finish()
+
+func _test_campfire_lighting() -> void:
+ print("\n--- Test 1: Lighting & Extinguishing Campfire (27600) ---")
+ var camp := FishingCookingCampSystem.new()
+ var inv: Array = [
+ {"vnum": FishingCookingCampSystem.CAMPFIRE_KIT_VNUM, "name": "营火工具", "count": 2}
+ ]
+
+ var light_sig := {"fired": false, "time": 0.0}
+ camp.campfire_lit.connect(func(_p: Vector3, d: float):
+ light_sig["fired"] = true
+ light_sig["time"] = d
+ )
+
+ var res = camp.light_campfire(Vector3(10, 0, 10), inv)
+ _assert(res["ok"] == true, "Campfire lit")
+ _assert(camp.is_campfire_active == true, "Campfire is active")
+ _assert(inv[0]["count"] == 1, "1 campfire kit consumed (2 -> 1)")
+ _assert(light_sig["fired"] == true and light_sig["time"] == 300.0, "campfire_lit emitted with 300s")
+
+ # Extinguish on timeout (advance 305 seconds)
+ var ext_sig := {"fired": false}
+ camp.campfire_extinguished.connect(func(): ext_sig["fired"] = true)
+
+ var dummy_p := {}
+ camp.update(305.0, dummy_p)
+ _assert(camp.is_campfire_active == false, "Campfire extinguished")
+ _assert(ext_sig["fired"] == true, "campfire_extinguished emitted")
+
+func _test_cooking_fish_recipes() -> void:
+ print("\n--- Test 2: Cooking Fish Recipes on Campfire ---")
+ var camp := FishingCookingCampSystem.new()
+ var inv: Array = [
+ {"vnum": 27600, "count": 1},
+ {"vnum": 27802, "name": "生鲤鱼", "count": 1},
+ {"vnum": 27815, "name": "生红鳟鱼", "count": 2}
+ ]
+
+ # Try cook without active fire
+ var fail_c = camp.cook_fish(1, inv)
+ _assert(fail_c["ok"] == false and fail_c["reason"] == "NO_ACTIVE_CAMPFIRE", "Cannot cook without campfire")
+
+ # Light fire
+ camp.light_campfire(Vector3.ZERO, inv)
+
+ var cook_sig := {"fired": false, "name": ""}
+ camp.fish_cooked.connect(func(_raw: int, _ckd: int, cname: String):
+ cook_sig["fired"] = true
+ cook_sig["name"] = cname
+ )
+
+ # Cook Carp (slot 1)
+ var res1 = camp.cook_fish(1, inv)
+ _assert(res1["ok"] == true, "Cooked Carp")
+ _assert(cook_sig["fired"] == true and cook_sig["name"] == "烤鲤鱼", "fish_cooked signal emitted")
+
+ var has_cooked_carp := false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 27832:
+ has_cooked_carp = true
+ break
+ _assert(has_cooked_carp, "Cooked carp found in inventory")
+
+ # Cook Trout (slot 2)
+ camp.cook_fish(2, inv)
+ _assert(inv[2]["count"] == 1, "Raw trout count decremented (2 -> 1)")
+
+ var has_cooked_trout := false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 27845:
+ has_cooked_trout = true
+ break
+ _assert(has_cooked_trout, "Cooked trout found in inventory")
+
+func _test_eating_and_stat_buffs() -> void:
+ print("\n--- Test 3: Eating Cooked Fish & 30-Minute Buffs ---")
+ var camp := FishingCookingCampSystem.new()
+ var player := {
+ "max_hp": 8000,
+ "atk": 500,
+ "move_speed": 100
+ }
+ var inv: Array = [
+ {"vnum": 27832, "name": "烤鲤鱼", "count": 1},
+ {"vnum": 27845, "name": "烤红鳟鱼", "count": 1}
+ ]
+
+ var buff_sig := {"fired": false, "id": "", "dur": 0.0}
+ camp.food_buff_applied.connect(func(bid: String, _n: String, d: float):
+ buff_sig["fired"] = true
+ buff_sig["id"] = bid
+ buff_sig["dur"] = d
+ )
+
+ # Eat Grilled Carp (max_hp +1000)
+ var eat1 = camp.eat_cooked_fish(0, inv, player)
+ _assert(eat1["ok"] == true, "Ate Grilled Carp")
+ _assert(player["max_hp"] == 9000, "Max HP increased 8000 -> 9000 (+1000)")
+ _assert(inv[0] == null, "Grilled carp consumed")
+ _assert(buff_sig["fired"] == true and buff_sig["id"] == "buff_grilled_carp", "food_buff_applied emitted")
+
+ # Eat Grilled Trout (atk +50)
+ var eat2 = camp.eat_cooked_fish(1, inv, player)
+ _assert(eat2["ok"] == true, "Ate Grilled Trout")
+ _assert(player["atk"] == 550, "Atk increased 500 -> 550 (+50)")
+ _assert(camp.active_buffs.size() == 2, "2 active food buffs running")
+
+func _test_buff_expiration_and_cleanup() -> void:
+ print("\n--- Test 4: Buff Duration & Clean Expiration ---")
+ var camp := FishingCookingCampSystem.new()
+ var player := {"max_hp": 8000}
+ var inv: Array = [{"vnum": 27832, "name": "烤鲤鱼", "count": 1}]
+
+ camp.eat_cooked_fish(0, inv, player)
+ _assert(player["max_hp"] == 9000, "HP is 9000")
+
+ var expire_sig := {"fired": false, "id": ""}
+ camp.food_buff_expired.connect(func(bid: String):
+ expire_sig["fired"] = true
+ expire_sig["id"] = bid
+ )
+
+ # Simulate 1805 seconds (exceeding 1800s duration)
+ camp.update(1805.0, player)
+ _assert(expire_sig["fired"] == true and expire_sig["id"] == "buff_grilled_carp", "food_buff_expired emitted")
+ _assert(player["max_hp"] == 8000, "Max HP cleanly reverted back to baseline 8000")
+ _assert(camp.active_buffs.is_empty(), "active_buffs cleared")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Fishing Cooking Camp Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Fishing Cooking Camp parity tests passed!")
+ quit(0)
diff --git a/project/test_fishing_cooking_camp_parity.gd.uid b/project/test_fishing_cooking_camp_parity.gd.uid
new file mode 100644
index 00000000..c8b5bc05
--- /dev/null
+++ b/project/test_fishing_cooking_camp_parity.gd.uid
@@ -0,0 +1 @@
+uid://eofbxtt6ac3b
diff --git a/project/test_fishing_mining_parity.gd b/project/test_fishing_mining_parity.gd
new file mode 100644
index 00000000..7dfdfb50
--- /dev/null
+++ b/project/test_fishing_mining_parity.gd
@@ -0,0 +1,100 @@
+# test_fishing_mining_parity.gd —— 验证方向 4:钓鱼、开蚌壳出珍珠与采矿生活系统 40250 1:1 对齐
+extends SceneTree
+
+const FishingMiningSystem = preload("res://fishing_mining_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_fishing_mining_parity (Direction 4: Fishing & Mining 1:1) ===")
+ test_fishing_equipment_detection()
+ test_fishing_flow_and_bait_consumption()
+ test_open_live_fish_drops()
+ test_open_clam_pearl_rates()
+ test_mining_veins_and_ores()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_fishing_equipment_detection() -> void:
+ print("\n--- Test 1: Fishing Equipment Identification ---")
+ _assert(FishingMiningSystem.is_fishing_rod(27400), "27400 is Fishing Rod +0")
+ _assert(FishingMiningSystem.is_fishing_rod(27490), "27490 is Fishing Rod +9")
+ _assert(not FishingMiningSystem.is_fishing_rod(10), "Sword is not a fishing rod")
+
+ _assert(FishingMiningSystem.is_bait(27800), "27800 (Earthworm) is valid bait")
+ _assert(FishingMiningSystem.is_bait(27801), "27801 (Fish Paste) is valid bait")
+ _assert(not FishingMiningSystem.is_bait(27001), "Potion is not bait")
+
+func test_fishing_flow_and_bait_consumption() -> void:
+ print("\n--- Test 2: Fishing Flow & Bait Requirements (fishing.cpp:210) ---")
+ # 1. Fishing without bait is rejected
+ var res_no_bait := FishingMiningSystem.perform_fishing(false, 0)
+ _assert(not res_no_bait.ok and res_no_bait.code == "NO_BAIT", "Fishing without bait is rejected")
+
+ # 2. Fishing with bait and force success
+ var res_success := FishingMiningSystem.perform_fishing(true, 5, true)
+ _assert(res_success.ok and res_success.code == "SUCCESS", "Fishing succeeded with bait")
+ _assert(res_success.consumed_bait, "Bait consumed on fish attempt")
+ _assert(FishingMiningSystem.is_live_fish(res_success.fish_vnum), "Caught item is a live fish")
+
+func test_open_live_fish_drops() -> void:
+ print("\n--- Test 3: Opening Live Fish (Fishbone, Dead Fish, Clam) ---")
+ # Live fish 27802 (Golden Carp)
+ var drops := {}
+ for _i in 100:
+ var res := FishingMiningSystem.open_live_fish(27802)
+ if res.ok:
+ drops[res.code] = true
+
+ _assert(drops.has("DEAD_FISH"), "Opening fish can yield dead fish")
+ _assert(drops.has("FISHBONE"), "Opening fish can yield Fishbone (27990)")
+ _assert(drops.has("CLAM"), "Opening fish can yield Clam (27987)")
+
+func test_open_clam_pearl_rates() -> void:
+ print("\n--- Test 4: Opening Clam for Pearls (White, Blue, Blood Pearls) ---")
+ # Force opening White Pearl (27992)
+ var res_white := FishingMiningSystem.open_clam(FishingMiningSystem.VNUM_WHITE_PEARL)
+ _assert(res_white.ok and res_white.result_vnum == 27992, "Yields White Pearl (27992)")
+
+ # Force opening Blue Pearl (27993)
+ var res_blue := FishingMiningSystem.open_clam(FishingMiningSystem.VNUM_BLUE_PEARL)
+ _assert(res_blue.ok and res_blue.result_vnum == 27993, "Yields Blue Pearl (27993)")
+
+ # Force opening Blood Pearl (27994)
+ var res_blood := FishingMiningSystem.open_clam(FishingMiningSystem.VNUM_BLOOD_PEARL)
+ _assert(res_blood.ok and res_blood.result_vnum == 27994, "Yields Blood Pearl (27994)")
+
+func test_mining_veins_and_ores() -> void:
+ print("\n--- Test 5: Mining Pickaxe, Veins & Rough Ore Yield (mining.cpp) ---")
+ _assert(FishingMiningSystem.is_pickaxe(29101), "29101 is Pickaxe +0")
+ _assert(FishingMiningSystem.is_pickaxe(29110), "29110 is Pickaxe +9")
+ _assert(not FishingMiningSystem.is_pickaxe(10), "Sword is not a pickaxe")
+
+ _assert(FishingMiningSystem.is_vein(20047), "20047 is Diamond Vein")
+ _assert(FishingMiningSystem.is_vein(20059), "20059 is Heaven's Tear Vein")
+ _assert(not FishingMiningSystem.is_vein(101), "Wild dog is not a vein")
+
+ # Mining Diamond Vein (20047 -> 50601)
+ var mine_res := FishingMiningSystem.perform_mining(20047, 5, true)
+ _assert(mine_res.ok and mine_res.code == "SUCCESS", "Mining succeeded")
+ _assert(mine_res.ore_vnum == 50601, "Yields Diamond Ore (50601)")
+ _assert(mine_res.count >= 1 and mine_res.count <= 3, "Yields 1..3 rough ores")
+
+ # Mining invalid target rejected
+ var mine_bad := FishingMiningSystem.perform_mining(99999, 0)
+ _assert(not mine_bad.ok and mine_bad.code == "NOT_A_VEIN", "Mining invalid target rejected")
diff --git a/project/test_fishing_mining_parity.gd.uid b/project/test_fishing_mining_parity.gd.uid
new file mode 100644
index 00000000..9e08ba73
--- /dev/null
+++ b/project/test_fishing_mining_parity.gd.uid
@@ -0,0 +1 @@
+uid://bumaq1hdlegee
diff --git a/project/test_fishing_rod_reel_parity.gd b/project/test_fishing_rod_reel_parity.gd
new file mode 100644
index 00000000..e31ba097
--- /dev/null
+++ b/project/test_fishing_rod_reel_parity.gd
@@ -0,0 +1,169 @@
+# test_fishing_rod_reel_parity.gd —— Metin2 40250 钓鱼抛竿拉杆与浮漂反应小游戏 1:1 测试套件
+extends SceneTree
+
+const FishingRodReelSystem = preload("res://fishing_rod_reel_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_fishing_rod_reel_parity (Direction 3: Fishing Rod & Reel 1:1) ===")
+
+ test_bait_attachment_and_cast()
+ test_premature_pull()
+ test_bite_and_successful_catch()
+ test_reaction_timeout()
+ test_rod_refine_at_fisherman()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Fishing Rod & Reel Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_bait_attachment_and_cast() -> void:
+ print("\n--- Test 1: Bait Attachment & Line Casting (40250 1:1) ---")
+ var system = FishingRodReelSystem.new()
+ system.equip_rod(0)
+
+ var inv: Array = [
+ {"vnum": 27801, "count": 2}, # 蚯蚓 x2 (slot 0)
+ {"vnum": 50027, "count": 1} # 彩票 (slot 1, 非鱼饵)
+ ]
+
+ # 未挂饵尝试抛竿 -> 拦截
+ var res = system.cast_line()
+ _assert(res["ok"] == false, "Rejected casting without bait")
+ _assert(res["reason"] == "NO_BAIT", "Reason is NO_BAIT")
+
+ # 非鱼饵道具挂饵 -> 拦截
+ res = system.attach_bait(inv, 1)
+ _assert(res["ok"] == false, "Rejected invalid bait item")
+ _assert(res["reason"] == "NOT_BAIT", "Reason is NOT_BAIT")
+
+ # 挂上蚯蚓
+ res = system.attach_bait(inv, 0)
+ _assert(res["ok"] == true, "Attached earthworm bait")
+ _assert(system.has_bait == true, "System has bait")
+ _assert(inv[0]["count"] == 1, "Consumed 1 earthworm")
+
+ # 抛竿
+ res = system.cast_line(3.0)
+ _assert(res["ok"] == true, "Cast fishing line")
+ _assert(system.is_fishing == true, "State is fishing")
+ _assert(system.has_bait == false, "Bait consumed on cast")
+
+func test_premature_pull() -> void:
+ print("\n--- Test 2: Premature Rod Pull (40250 1:1) ---")
+ var system = FishingRodReelSystem.new()
+ system.equip_rod(0, 0)
+ var inv: Array = [{"vnum": 27801, "count": 1}]
+ system.attach_bait(inv, 0)
+ system.cast_line(5.0) # 5 秒后咬钩
+
+ # 仅过 1 秒便起竿 (尚未咬钩)
+ system.update(1.0)
+ var pull_res = system.pull_rod(inv)
+ _assert(pull_res["ok"] == false, "Premature pull failed to catch fish")
+ _assert(pull_res["reason"] == "PULLED_TOO_EARLY", "Reason is PULLED_TOO_EARLY")
+ _assert(system.rod_practice_points == 1, "Practiced +1 point even on premature pull")
+ _assert(system.is_fishing == false, "Fishing session ended")
+
+func test_bite_and_successful_catch() -> void:
+ print("\n--- Test 3: Fish Bite & Successful Catch (40250 1:1) ---")
+ var system = FishingRodReelSystem.new()
+ system.equip_rod(2, 5) # 钓竿+2,已有 5 点熟练度
+
+ var bite_signals := []
+ system.fish_bite.connect(func(window):
+ bite_signals.append(window)
+ )
+
+ var inv: Array = [
+ {"vnum": 27801, "count": 1}
+ ]
+ system.attach_bait(inv, 0)
+ system.cast_line(2.0)
+
+ # 推进 2 秒,触发鱼咬钩
+ system.update(2.0)
+ _assert(system.is_bite_active == true, "Fish is biting!")
+ _assert(bite_signals.size() == 1, "fish_bite signal emitted")
+
+ # 提竿并强制捕获草鱼 (CATCH_POOL 索引 2)
+ var pull_res = system.pull_rod(inv, 2, true)
+ _assert(pull_res["ok"] == true, "Successfully caught fish")
+ _assert(pull_res["fish_vnum"] == 27808, "Caught Ot Sazani (27808)")
+ _assert(pull_res["practice_points"] == 6, "Practice points increased from 5 to 6")
+ # 检查背包是否新增了草鱼
+ var has_fish := false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 27808:
+ has_fish = true
+ _assert(has_fish == true, "Caught fish inserted into inventory")
+
+func test_reaction_timeout() -> void:
+ print("\n--- Test 4: Reaction Window Timeout (40250 1:1) ---")
+ var system = FishingRodReelSystem.new()
+ system.equip_rod(0, 0)
+ var inv: Array = [{"vnum": 27801, "count": 1}]
+ system.attach_bait(inv, 0)
+ system.cast_line(1.0)
+
+ system.update(1.0) # 咬钩
+ _assert(system.is_bite_active == true, "Bite active")
+
+ # 反应窗口为 3 秒,推进 3.5 秒超时
+ system.update(3.5)
+ _assert(system.is_fishing == false, "Fishing ended due to timeout")
+ _assert(system.rod_practice_points == 1, "Practice point +1 on timeout")
+
+func test_rod_refine_at_fisherman() -> void:
+ print("\n--- Test 5: Rod Refine at Fisherman (40250 1:1) ---")
+ var system = FishingRodReelSystem.new()
+ # 设定钓竿+0,需要 10 点满熟练度
+ system.equip_rod(0, 8)
+
+ # 未满点数尝试精炼 -> 拦截
+ var res = system.refine_rod_at_fisherman()
+ _assert(res["ok"] == false, "Rejected refine with 8/10 points")
+ _assert(res["reason"] == "PRACTICE_NOT_MAXED", "Reason is PRACTICE_NOT_MAXED")
+
+ # 补满点数 10/10
+ system.equip_rod(0, 10)
+ _assert(system.can_refine_rod() == true, "Rod is ready to refine")
+
+ # 成功升级为钓鱼竿+1 (chance 100%)
+ res = system.refine_rod_at_fisherman(50)
+ _assert(res["ok"] == true and res["success"] == true, "Upgraded to Rod+1")
+ _assert(system.rod_level == 1, "New rod level is 1")
+ _assert(system.rod_practice_points == 0, "Practice points reset to 0")
+
+ # 模拟失败降级: 设定钓竿+1 满点数 (20 点),强制失败 roll 95 > 90
+ system.equip_rod(1, 20)
+ res = system.refine_rod_at_fisherman(95)
+ _assert(res["ok"] == true and res["success"] == false, "Refine failed and degraded")
+ _assert(system.rod_level == 0, "Degraded back to Rod+0")
+
+func test_serialization() -> void:
+ print("\n--- Test 6: Serialization & Restoration ---")
+ var system = FishingRodReelSystem.new()
+ system.equip_rod(3, 35)
+ var data = system.serialize()
+
+ var restored = FishingRodReelSystem.new()
+ restored.deserialize(data)
+ _assert(restored.rod_level == 3, "Restored rod level 3")
+ _assert(restored.rod_practice_points == 35, "Restored practice points 35")
diff --git a/project/test_fishing_rod_reel_parity.gd.uid b/project/test_fishing_rod_reel_parity.gd.uid
new file mode 100644
index 00000000..87973b1b
--- /dev/null
+++ b/project/test_fishing_rod_reel_parity.gd.uid
@@ -0,0 +1 @@
+uid://c10rqo2dewmgi
diff --git a/project/test_flame_dungeon_razador_parity.gd b/project/test_flame_dungeon_razador_parity.gd
new file mode 100644
index 00000000..85442187
--- /dev/null
+++ b/project/test_flame_dungeon_razador_parity.gd
@@ -0,0 +1,169 @@
+# test_flame_dungeon_razador_parity.gd —— 40250 红龙烈焰神殿与拉扎多尔试炼 1:1 回归测试套件
+extends SceneTree
+
+const FlameDungeonRazadorSystem = preload("res://flame_dungeon_razador_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_flame_dungeon_razador_parity (Direction 2: Flame Dungeon Razador 1:1) ===")
+ _test_entry_and_ticket_consumption()
+ _test_stage_advancement_and_altars()
+ _test_razador_boss_slay_and_rewards()
+ _test_timeout_failure()
+ _test_serialization()
+ _finish()
+
+func _test_entry_and_ticket_consumption() -> void:
+ print("\n--- Test 1: Flame Dungeon Entry & Ticket Consumption ---")
+ var sys := FlameDungeonRazadorSystem.new()
+ var inv: Array = [
+ {"vnum": FlameDungeonRazadorSystem.VNUM_TICKET_71095, "name": "通行证", "count": 2}
+ ]
+
+ # 1. Level too low (< 100)
+ var res_low = sys.enter_dungeon(95, inv)
+ _assert(res_low["ok"] == false, "Entry rejected: level 95 < 100")
+ _assert(res_low["reason"] == "LEVEL_TOO_LOW", "Reason is LEVEL_TOO_LOW")
+
+ var enter_sig := {"fired": false, "dur": 0.0}
+ sys.dungeon_entered.connect(func(d: float):
+ enter_sig["fired"] = true
+ enter_sig["dur"] = d
+ )
+
+ # 2. Valid entry at Lv 100
+ var res_succ = sys.enter_dungeon(100, inv)
+ _assert(res_succ["ok"] == true, "Entered Flame Dungeon successfully")
+ _assert(sys.is_active == true, "Dungeon is active")
+ _assert(sys.current_stage == 1, "Initial stage is 1")
+ _assert(inv[0]["count"] == 1, "Ticket count decremented 2 -> 1")
+ _assert(enter_sig["fired"] == true, "dungeon_entered signal emitted")
+ _assert(enter_sig["dur"] == 2700.0, "Time limit is 2700.0s (45 minutes)")
+
+func _test_stage_advancement_and_altars() -> void:
+ print("\n--- Test 2: Stage Advancement & Altar Lighting ---")
+ var sys := FlameDungeonRazadorSystem.new()
+ var inv: Array = [{"vnum": FlameDungeonRazadorSystem.VNUM_TICKET_71130, "name": "烈焰通行证", "count": 1}]
+ sys.enter_dungeon(105, inv)
+
+ # Advance stages 1 -> 2 -> 3 -> 4 -> 5
+ for i in range(4):
+ sys.advance_stage()
+
+ _assert(sys.current_stage == 5, "Reached Stage 5 (Fire Altars)")
+
+ var altar_sig := {"fired": false, "lit": 0}
+ sys.altar_lit.connect(func(l: int, _t: int):
+ altar_sig["fired"] = true
+ altar_sig["lit"] = l
+ )
+
+ # Light 4 altars
+ for i in range(4):
+ var res_altar = sys.light_fire_altar()
+ _assert(res_altar["stage_cleared"] == false, "Stage not yet cleared with < 5 altars")
+
+ _assert(sys.altars_lit_count == 4, "4 altars lit")
+
+ # Light 5th altar -> auto clears stage 5 and enters stage 6
+ var res_5th = sys.light_fire_altar()
+ _assert(res_5th["stage_cleared"] == true, "5th altar lit: stage cleared")
+ _assert(sys.current_stage == 6, "Automatically advanced to Stage 6")
+
+ var boss_sig := {"fired": false, "vnum": 0}
+ sys.razador_spawned.connect(func(v: int):
+ boss_sig["fired"] = true
+ boss_sig["vnum"] = v
+ )
+
+ # Advance stage 6 -> 7 (Boss Chamber)
+ var res_boss_room = sys.advance_stage()
+ _assert(res_boss_room["current_stage"] == 7, "Entered Stage 7 (Razador)")
+ _assert(sys.razador_alive == true, "Razador is alive")
+ _assert(boss_sig["fired"] == true, "razador_spawned signal emitted")
+ _assert(boss_sig["vnum"] == 6091, "Razador mob vnum is 6091")
+
+func _test_razador_boss_slay_and_rewards() -> void:
+ print("\n--- Test 3: Razador Boss Slay & Rewards ---")
+ var sys := FlameDungeonRazadorSystem.new()
+ var inv: Array = [
+ {"vnum": FlameDungeonRazadorSystem.VNUM_TICKET_71095, "name": "通行证", "count": 1},
+ null, null, null
+ ]
+ sys.enter_dungeon(105, inv)
+ sys.current_stage = 7
+ sys.razador_alive = true
+
+ var comp_sig := {"fired": false}
+ sys.dungeon_completed.connect(func():
+ comp_sig["fired"] = true
+ )
+
+ # Slay Razador
+ var res_slay = sys.slay_razador(inv)
+ _assert(res_slay["ok"] == true, "Razador slain")
+ _assert(sys.is_active == false, "Dungeon completed and inactive")
+ _assert(comp_sig["fired"] == true, "dungeon_completed signal emitted")
+
+ # Verify Razador's Chest received
+ var chest_found := false
+ for it in inv:
+ if it != null and int(it.get("vnum", 0)) == FlameDungeonRazadorSystem.VNUM_RAZADOR_CHEST:
+ chest_found = true
+ break
+ _assert(chest_found == true, "Razador's Chest (50186) found in inventory")
+
+func _test_timeout_failure() -> void:
+ print("\n--- Test 4: Dungeon Timeout Failure ---")
+ var sys := FlameDungeonRazadorSystem.new()
+ var inv: Array = [{"vnum": FlameDungeonRazadorSystem.VNUM_TICKET_71095, "name": "通行证", "count": 1}]
+ sys.enter_dungeon(100, inv)
+
+ var fail_sig := {"fired": false, "reason": ""}
+ sys.dungeon_failed.connect(func(r: String):
+ fail_sig["fired"] = true
+ fail_sig["reason"] = r
+ )
+
+ # Advance 2700s
+ sys.update(2700.0)
+ _assert(sys.is_active == false, "Dungeon inactive on timeout")
+ _assert(fail_sig["fired"] == true, "dungeon_failed signal emitted")
+ _assert(fail_sig["reason"] == "TIMEOUT", "Reported TIMEOUT")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := FlameDungeonRazadorSystem.new()
+ var inv: Array = [{"vnum": FlameDungeonRazadorSystem.VNUM_TICKET_71095, "name": "通行证", "count": 1}]
+ sys1.enter_dungeon(100, inv)
+ sys1.current_stage = 5
+ sys1.altars_lit_count = 3
+ sys1.update(600.0) # 2100s left
+
+ var data = sys1.serialize()
+ var sys2 := FlameDungeonRazadorSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.is_active == true, "Restored is_active")
+ _assert(sys2.current_stage == 5, "Restored current stage 5")
+ _assert(sys2.altars_lit_count == 3, "Restored altars lit count 3")
+ _assert(is_equal_approx(sys2.time_remaining, 2100.0), "Restored time remaining 2100s")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Flame Dungeon Razador Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_flame_dungeon_razador_parity.gd.uid b/project/test_flame_dungeon_razador_parity.gd.uid
new file mode 100644
index 00000000..fe11b16f
--- /dev/null
+++ b/project/test_flame_dungeon_razador_parity.gd.uid
@@ -0,0 +1 @@
+uid://dmjjd2mqvm7kl
diff --git a/project/test_full_skill_flow.gd b/project/test_full_skill_flow.gd
new file mode 100644
index 00000000..c3f6c4a0
--- /dev/null
+++ b/project/test_full_skill_flow.gd
@@ -0,0 +1,15 @@
+extends SceneTree
+
+# We build a test environment with GameScene, client, world, player
+func _init():
+ print("--- Starting Full Skill Flow Test ---")
+ var assets := AssetRoot.path()
+ var client = preload("res://net_client_fake.gd").new() if ResourceLoader.exists("res://net_client_fake.gd") else null
+
+ # Check if GameScene can be instantiated
+ var gs = preload("res://game_scene.gd").new()
+ get_root().add_child(gs)
+
+ # Print GameScene children and setup
+ print("GameScene instantiated")
+ quit(0)
diff --git a/project/test_full_skill_flow.gd.uid b/project/test_full_skill_flow.gd.uid
new file mode 100644
index 00000000..88b3d09e
--- /dev/null
+++ b/project/test_full_skill_flow.gd.uid
@@ -0,0 +1 @@
+uid://1who0lbv1uod
diff --git a/project/test_game_button_money_dialog_parity.gd b/project/test_game_button_money_dialog_parity.gd
new file mode 100644
index 00000000..2d3b3d08
--- /dev/null
+++ b/project/test_game_button_money_dialog_parity.gd
@@ -0,0 +1,88 @@
+# test_game_button_money_dialog_parity.gd
+# 检验 40250 浮动任务栏状态红点通知与金币分堆丢弃输入窗
+extends SceneTree
+
+const GameButtonMoneyDialogSystem = preload("res://game_button_money_dialog_system.gd")
+
+func _init() -> void:
+ print("--- 开始测试 40250 浮动任务栏状态红点与金币分堆系统 (test_game_button_money_dialog_parity) ---")
+ var system = GameButtonMoneyDialogSystem.new()
+ var fails: int = 0
+
+ var btns = system.game_buttons
+ var money = system.money_dialog
+
+ # 1. 浮动任务栏按钮状态检验 (新角色: 0 播放时间,有剩余未分配点数)
+ btns.check_game_buttons(5, 1, 0)
+ if not btns.button_visible["STATUS"]:
+ printerr("FAIL: 有未分配属性点时 STATUS 按钮未显示!")
+ fails += 1
+ if not btns.button_visible["SKILL"]:
+ printerr("FAIL: 有未分配技能点时 SKILL 按钮未显示!")
+ fails += 1
+ if not btns.button_visible["HELP"]:
+ printerr("FAIL: 0 游戏时长角色 HELP 按钮未显示!")
+ fails += 1
+
+ # 老玩家分配完点数后 (stat=0, skill=0, playtime=3600)
+ btns.check_game_buttons(0, 0, 3600)
+ if btns.button_visible["STATUS"] or btns.button_visible["SKILL"] or btns.button_visible["HELP"]:
+ printerr("FAIL: 属性点分配完毕且为老玩家时按钮应隐藏!")
+ fails += 1
+
+ # 2. 观战模式按钮与 /observer_exit 指令
+ btns.set_observer_mode(true)
+ if not btns.button_visible["EXIT_OBSERVER"]:
+ printerr("FAIL: 观战模式下未显示 EXIT_OBSERVER 按钮!")
+ fails += 1
+ btns.click_exit_observer()
+ if btns.last_chat_packet_sent != "/observer_exit":
+ printerr("FAIL: 退出观战发送指令错误: ", btns.last_chat_packet_sent)
+ fails += 1
+
+ btns.set_observer_mode(false)
+ if btns.button_visible["EXIT_OBSERVER"]:
+ printerr("FAIL: 非观战模式下 EXIT_OBSERVER 按钮未隐藏!")
+ fails += 1
+
+ # 3. 金币分堆/丢弃输入框坐标吸附计算测试
+ # 屏幕 1024x768,点击位于中间 (500, 400)
+ # 宽度 170,高度 90 -> pos_x 应为 500 - 85 = 415, pos_y = 400 - 90 - 20 = 290
+ money.open_dialog(50000, 100, 500, 400, 1024, 768)
+ if not money.is_open:
+ printerr("FAIL: 金币输入框未能打开!")
+ fails += 1
+ if money.pos_x != 415 or money.pos_y != 290:
+ printerr("FAIL: 金币输入框坐标吸附错误: pos_x=", money.pos_x, " pos_y=", money.pos_y)
+ fails += 1
+
+ # 4. 边界屏幕右侧贴边吸附测试 (mouse_x = 1000)
+ money.open_dialog(50000, 100, 1000, 400, 1024, 768)
+ if money.pos_x != (1024 - 170): # 854
+ printerr("FAIL: 贴右边缘防越界未生效,pos_x=", money.pos_x)
+ fails += 1
+
+ # 5. 输入数字与截断测试 (输入 80000,超出最大 50000 -> 截断为 50000)
+ money.input_text("80000")
+ var accepted = money.on_accept()
+ if not accepted or money.last_accepted_value != 50000:
+ printerr("FAIL: 超额金币未能正确截断至 50000,实际: ", money.last_accepted_value)
+ fails += 1
+ if money.is_open:
+ printerr("FAIL: 接受后对话框未自动关闭!")
+ fails += 1
+
+ # 6. 非法字符过滤测试
+ money.open_dialog(1000, 1, 100, 100)
+ money.input_text("abc12")
+ var bad_accept = money.on_accept()
+ if bad_accept or money.last_accepted_value != 0:
+ printerr("FAIL: 非法非数字输入未能拦截!")
+ fails += 1
+
+ if fails == 0:
+ print("PASS: test_game_button_money_dialog_parity (8/8 全部通过)")
+ quit(0)
+ else:
+ printerr("FAILED: test_game_button_money_dialog_parity (", fails, " 项失败)")
+ quit(1)
diff --git a/project/test_game_button_money_dialog_parity.gd.uid b/project/test_game_button_money_dialog_parity.gd.uid
new file mode 100644
index 00000000..b17eceaa
--- /dev/null
+++ b/project/test_game_button_money_dialog_parity.gd.uid
@@ -0,0 +1 @@
+uid://dnxlrrwxsebl5
diff --git a/project/test_gm_toolkit_parity.gd b/project/test_gm_toolkit_parity.gd
new file mode 100644
index 00000000..2fecd1c9
--- /dev/null
+++ b/project/test_gm_toolkit_parity.gd
@@ -0,0 +1,143 @@
+# test_gm_toolkit_parity.gd —— 40250 单机内置 GM 命令解析控制台 1:1 纯净回归测试套件
+extends SceneTree
+
+const GmToolkitSystem = preload("res://gm_toolkit_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_gm_toolkit_parity (Direction 4: GM Toolkit Console 1:1) ===")
+ _test_item_command()
+ _test_level_and_gold_commands()
+ _test_set_skill_command()
+ _test_warp_and_mob_commands()
+ _test_help_and_unknown_command()
+ _finish()
+
+func _test_item_command() -> void:
+ print("\n--- Test 1: /item Command Execution ---")
+ var gm := GmToolkitSystem.new()
+ var inv: Array = [null, null]
+ var ctx := {"inventory": inv}
+
+ # 1. Spawn Battle Sword +9 (169)
+ var r1 = gm.execute_command("/item 169 1", ctx)
+ _assert(r1["ok"] == true, "/item 169 1 succeeded")
+ _assert(inv[0] != null and inv[0]["vnum"] == 169, "Battle Sword spawned in slot 0")
+
+ # 2. Spawn 5x Soul Stones (50513)
+ var r2 = gm.execute_command("/item 50513 5", ctx)
+ _assert(r2["ok"] == true, "/item 50513 5 succeeded")
+ _assert(inv[1] != null and inv[1]["vnum"] == 50513 and inv[1]["count"] == 5, "5x Soul Stones spawned in slot 1")
+
+ # 3. Full inventory
+ var r3 = gm.execute_command("/item 10 1", ctx)
+ _assert(r3["ok"] == false and r3["reason"] == "INVENTORY_FULL", "Blocked when inventory full")
+
+ # 4. Syntax error
+ var r4 = gm.execute_command("/item", ctx)
+ _assert(r4["ok"] == false and r4["reason"] == "SYNTAX_ERROR", "Syntax error caught on empty args")
+
+func _test_level_and_gold_commands() -> void:
+ print("\n--- Test 2: /level and /gold Commands ---")
+ var gm := GmToolkitSystem.new()
+ var pdata := {"level": 1, "gold": 0}
+ var ctx := {"player_data": pdata}
+
+ # /level 75
+ var r_lv = gm.execute_command("/level 75", ctx)
+ _assert(r_lv["ok"] == true and pdata["level"] == 75, "Level set to 75")
+
+ # /level 200 (clamped to 105)
+ gm.execute_command("/level 200", ctx)
+ _assert(pdata["level"] == 105, "Level clamped to max 105")
+
+ # /gold 1000000
+ var r_gold = gm.execute_command("/gold 1000000", ctx)
+ _assert(r_gold["ok"] == true and pdata["gold"] == 1000000, "1,000,000 Yang injected")
+
+func _test_set_skill_command() -> void:
+ print("\n--- Test 3: /set_skill Command (M1, G1, P grades) ---")
+ var gm := GmToolkitSystem.new()
+ var skills := {}
+ var ctx := {"skills": skills}
+
+ # Set skill 1 to M1 (Lv 20)
+ var r_m1 = gm.execute_command("/set_skill 1 20", ctx)
+ _assert(r_m1["ok"] == true and skills[1] == 20, "Skill 1 set to 20")
+ _assert(r_m1["grade"].begins_with("M级"), "Grade is M")
+
+ # Set skill 1 to P (Lv 40)
+ var r_p = gm.execute_command("/set_skill 1 40", ctx)
+ _assert(r_p["ok"] == true and skills[1] == 40, "Skill 1 set to 40 (P-grade)")
+ _assert(r_p["grade"].begins_with("P级"), "Grade is P")
+
+func _test_warp_and_mob_commands() -> void:
+ print("\n--- Test 4: /warp, /map, and /spawn_mob Commands ---")
+ var gm := GmToolkitSystem.new()
+ var ctx := {"player_pos": Vector3(10.0, 0.0, 20.0)}
+
+ var warp_signal := {"fired": false, "pos": Vector3.ZERO, "map": ""}
+ gm.player_warped.connect(func(pos: Vector3, mname: String):
+ warp_signal["fired"] = true
+ warp_signal["pos"] = pos
+ warp_signal["map"] = mname
+ )
+
+ # /map sohan
+ var r_map = gm.execute_command("/map sohan", ctx)
+ _assert(r_map["ok"] == true, "/map sohan succeeded")
+ _assert(warp_signal["fired"] == true and warp_signal["map"] == "sohan", "Warped to sohan preset")
+ _assert(warp_signal["pos"] == Vector3(400, 0, 600), "Sohan coords match (400, 0, 600)")
+
+ # /warp 150 250
+ var r_warp = gm.execute_command("/warp 150 250", ctx)
+ _assert(r_warp["ok"] == true, "/warp 150 250 succeeded")
+ _assert(warp_signal["pos"] == Vector3(150, 0, 250), "Custom warp coords match")
+
+ # /spawn_mob 2493 1 (Beran-Setaou)
+ var mob_signal := {"fired": false, "vnum": 0, "count": 0}
+ gm.mob_spawned.connect(func(v: int, c: int, _p: Vector3):
+ mob_signal["fired"] = true
+ mob_signal["vnum"] = v
+ mob_signal["count"] = c
+ )
+
+ var r_mob = gm.execute_command("/spawn_mob 2493 1", ctx)
+ _assert(r_mob["ok"] == true, "/spawn_mob 2493 1 succeeded")
+ _assert(mob_signal["fired"] == true and mob_signal["vnum"] == 2493 and mob_signal["count"] == 1, "mob_spawned signal fired for Beran-Setaou")
+
+func _test_help_and_unknown_command() -> void:
+ print("\n--- Test 5: /help and Error Handling ---")
+ var gm := GmToolkitSystem.new()
+ var ctx := {}
+
+ # /help
+ var r_help = gm.execute_command("/help", ctx)
+ _assert(r_help["ok"] == true and r_help["commands"].size() >= 5, "/help returned command list")
+
+ # Not a command
+ var r_not = gm.execute_command("hello", ctx)
+ _assert(r_not["ok"] == false and r_not["reason"] == "NOT_A_COMMAND", "Plain string rejected as non-command")
+
+ # Unknown command
+ var r_unk = gm.execute_command("/fly", ctx)
+ _assert(r_unk["ok"] == false and r_unk["reason"] == "UNKNOWN_COMMAND", "Unknown command rejected")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("GM Toolkit Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_gm_toolkit_parity.gd.uid b/project/test_gm_toolkit_parity.gd.uid
new file mode 100644
index 00000000..8e487959
--- /dev/null
+++ b/project/test_gm_toolkit_parity.gd.uid
@@ -0,0 +1 @@
+uid://c4yfwa1exjhj
diff --git a/project/test_ground_pickup_parity.gd b/project/test_ground_pickup_parity.gd
new file mode 100644
index 00000000..ce60ba61
--- /dev/null
+++ b/project/test_ground_pickup_parity.gd
@@ -0,0 +1,170 @@
+# test_ground_pickup_parity.gd —— 40250 地面拾取与掉落物 1:1 纯净无头自检套件
+# 验证:
+# 1. 3.0m 拾取有效距离判定
+# 2. 骑马状态下 5.0m 拾取距离拓展
+# 3. 就近优先拾取 (按直线距离排序)
+# 4. 500ms 发包冷却节流 (s_dwNextTCPTime)
+# 5. 所有权校验 (c_szOwnerName != GetName() 拦截 + cannot_pick_item 信号)
+# 6. 稀有道具与高强装备垂直光柱 (Drop Light Beam)
+# 7. ArrangeTextTail 防重叠标签排布
+extends SceneTree
+
+const GroundItems = preload("res://ui/ground_items.gd")
+
+class FakeClient extends Node:
+ signal ground_item_added(d: Dictionary)
+ signal ground_item_removed(vid: int)
+
+ var calls: Array = []
+ var main_vid := 1001
+ var char_name := "HeroPlayer"
+
+ func is_in_game() -> bool: return true
+ func get_main_vid() -> int: return main_vid
+ func get_entity(vid: int) -> Dictionary:
+ if vid == main_vid:
+ return {"name": char_name}
+ return {}
+ func pickup_item(vid: int) -> bool:
+ calls.append(["pickup", vid])
+ return true
+
+class FakeProto extends Node:
+ func item(vnum: int) -> Dictionary:
+ if vnum >= 10 and vnum <= 19:
+ return {"name": "Sword+%d" % (vnum - 10), "type": 1} # 武器
+ if vnum == 25040:
+ return {"name": "Blessing Scroll", "type": 3}
+ return {"name": "Item %d" % vnum, "type": 0}
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+
+var _to_free: Array[Node] = []
+
+func _init() -> void:
+ _run()
+ for n in _to_free:
+ if is_instance_valid(n):
+ n.queue_free()
+ await process_frame
+ await process_frame
+ if _fail == 0:
+ print("PASS: test_ground_pickup_parity (40250 GroundItems 1:1 parity)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed in test_ground_pickup_parity" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var mount := Node3D.new()
+ get_root().add_child(mount)
+ var fc := FakeClient.new()
+ get_root().add_child(fc)
+ var proto := FakeProto.new()
+ get_root().add_child(proto)
+
+ var player_node := Node3D.new()
+ player_node.position = Vector3(0.0, 0.0, 0.0)
+ get_root().add_child(player_node)
+
+ var gi := GroundItems.new()
+ get_root().add_child(gi)
+ _to_free = [gi, player_node, proto, fc, mount]
+ gi.assets_override = "__none__"
+ gi.setup(fc, mount, func(): return player_node, proto)
+
+ var cannot_picks: Array[String] = []
+ gi.cannot_pick_item.connect(func(owner: String): cannot_picks.append(owner))
+
+ # --- 1. 距离与最近优先判定 ---
+ # 在地面添加 3 个物品:
+ # vid 1: 距离 2.0m (在 3m 内)
+ # vid 2: 距离 1.0m (最近)
+ # vid 3: 距离 4.5m (超出 3m)
+ gi._on_added({"vid": 1, "vnum": 10, "pos": Vector3(0.0, 0.0, 2.0), "owner": ""})
+ gi._on_added({"vid": 2, "vnum": 11, "pos": Vector3(1.0, 0.0, 0.0), "owner": ""})
+ gi._on_added({"vid": 3, "vnum": 12, "pos": Vector3(0.0, 0.0, 4.5), "owner": ""})
+
+ # 尝试拾取:必须拾取最近的 vid 2
+ var picked := gi.try_pickup()
+ _ck(picked == 2, "1. 优先拾取最近物品 (vid 2, 距离 1.0m)")
+ _ck(fc.calls == [["pickup", 2]], "2. 发出 pickup_item(2)")
+ fc.calls.clear()
+
+ # --- 2. 500ms 节流拦截 ---
+ # 立即再次拾取 (间隔 < 500ms)
+ var throttled := gi.try_pickup()
+ _ck(throttled == 0, "3. 500ms 内连续拾取被节流拦截")
+ _ck(fc.calls.is_empty(), "4. 未重复发送网络包")
+
+ # 服务端移除已被拾取的 vid 2
+ gi._on_removed(2)
+
+ # 手动快进冷却定时器
+ gi._next_tcp_time_ms = 0
+ picked = gi.try_pickup()
+ _ck(picked == 1, "5. 500ms 冷却后拾取次近物品 (vid 1, 距离 2.0m)")
+ _ck(fc.calls == [["pickup", 1]], "6. 发出 pickup_item(1)")
+ fc.calls.clear()
+
+ # 清理已拾取的 vid 1
+ gi._on_removed(1)
+
+ # --- 3. 步行 3m 边界与骑马 5m 判定 ---
+ # 此时场上只剩 vid 3 (距离 4.5m)
+ gi._next_tcp_time_ms = 0
+ picked = gi.try_pickup()
+ _ck(picked == 0, "7. 步行时 4.5m 超出 3.0m 拾取半径")
+ _ck(fc.calls.is_empty(), "8. 未发包")
+
+ # 主角上马
+ player_node.set_meta("mounted", true)
+ _ck(gi.get_pickable_distance() == 5.0, "9. 骑马时拾取半径扩展为 5.0m")
+ gi._next_tcp_time_ms = 0
+ picked = gi.try_pickup()
+ _ck(picked == 3, "10. 骑马状态成功拾取 4.5m 外物品 (vid 3)")
+ _ck(fc.calls == [["pickup", 3]], "11. 发出 pickup_item(3)")
+ fc.calls.clear()
+ gi._on_removed(3)
+ player_node.set_meta("mounted", false)
+
+ # --- 4. 所有权保护拦截 (40250 c_szOwnerName != GetName()) ---
+ # 添加属于他人的物品 (owner = EnemyPlayer)
+ gi._on_added({"vid": 4, "vnum": 10, "pos": Vector3(0.5, 0.0, 0.5), "owner": "EnemyPlayer"})
+ gi._next_tcp_time_ms = 0
+ picked = gi.try_pickup()
+ _ck(picked == 0, "12. 非本人所有权物品被拦截")
+ _ck(fc.calls.is_empty(), "13. 拦截时不发包")
+ _ck(cannot_picks == ["EnemyPlayer"], "14. 触发 cannot_pick_item 信号")
+ cannot_picks.clear()
+
+ # 添加属于自己的物品 (owner = HeroPlayer)
+ gi._on_added({"vid": 5, "vnum": 10, "pos": Vector3(0.2, 0.0, 0.2), "owner": "HeroPlayer"})
+ gi._next_tcp_time_ms = 0
+ picked = gi.try_pickup()
+ _ck(picked == 5, "15. 属于本人的保护物品成功拾取 (vid 5)")
+ _ck(fc.calls == [["pickup", 5]], "16. 正常发包")
+ fc.calls.clear()
+ gi._on_removed(4)
+ gi._on_removed(5)
+
+ # --- 5. 稀有掉落物光柱 (Drop Light Beam) ---
+ # 普通物品 (vnum 10 = Sword+0)
+ gi._on_added({"vid": 6, "vnum": 10, "pos": Vector3(1.0, 0.0, 1.0), "owner": ""})
+ var n6: Node3D = gi._by_vid[6]
+ _ck(n6.get_node_or_null("light_beam") == null, "17. 普通装备无光柱")
+
+ # 高强装备 (vnum 19 = Sword+9)
+ gi._on_added({"vid": 7, "vnum": 19, "pos": Vector3(2.0, 0.0, 1.0), "owner": ""})
+ var n7: Node3D = gi._by_vid[7]
+ _ck(n7.get_node_or_null("light_beam") != null, "18. +9 稀有装备生成 light_beam 冲天光柱")
+
+ # 祝福卷轴 (vnum 25040)
+ gi._on_added({"vid": 8, "vnum": 25040, "pos": Vector3(1.5, 0.0, 1.5), "owner": ""})
+ var n8: Node3D = gi._by_vid[8]
+ _ck(n8.get_node_or_null("light_beam") != null, "19. 祝福卷轴生成 light_beam 光柱")
+ gi.clear_for_map_change()
diff --git a/project/test_ground_pickup_parity.gd.uid b/project/test_ground_pickup_parity.gd.uid
new file mode 100644
index 00000000..c9b4c046
--- /dev/null
+++ b/project/test_ground_pickup_parity.gd.uid
@@ -0,0 +1 @@
+uid://cd1lp4smh7fmx
diff --git a/project/test_guild_mark_uploader_parity.gd b/project/test_guild_mark_uploader_parity.gd
new file mode 100644
index 00000000..164f0f17
--- /dev/null
+++ b/project/test_guild_mark_uploader_parity.gd
@@ -0,0 +1,84 @@
+# test_guild_mark_uploader_parity.gd —— 40250 行会图标与战旗上传回归测试
+extends SceneTree
+
+const SystemClass = preload("res://guild_mark_uploader_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_guild_mark_uploader_parity (Batch 30 Direction 1) ===")
+ _test_authority_and_format_validation()
+ _test_mark_dimension_checks()
+ _test_symbol_dimension_checks()
+ _test_successful_mark_and_symbol_upload()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_authority_and_format_validation() -> void:
+ print("\n--- Test 1: Authority & Format Verification ---")
+ var up = SystemClass.new()
+
+ # 1. Non-guild master rejected
+ var r_auth = up.upload_guild_mark(101, false, 16, 12, "bmp")
+ _assert(r_auth["error_code"] == SystemClass.UploadError.ERROR_NOT_GUILD_MASTER, "Reject upload if not guild master")
+
+ # 2. Unsupported format (PNG/JPG)
+ var r_fmt = up.upload_guild_mark(101, true, 16, 12, "png")
+ _assert(r_fmt["error_code"] == SystemClass.UploadError.ERROR_UNSUPPORTED_FORMAT, "Reject non-BMP formats")
+
+func _test_mark_dimension_checks() -> void:
+ print("\n--- Test 2: Mark (16x12) Dimensions Checking ---")
+ var up = SystemClass.new()
+
+ # Wrong width (20 != 16)
+ var r_w = up.upload_guild_mark(101, true, 20, 12, "bmp")
+ _assert(r_w["error_code"] == SystemClass.UploadError.ERROR_WIDTH, "Reject mark width != 16")
+
+ # Wrong height (16 != 12)
+ var r_h = up.upload_guild_mark(101, true, 16, 16, "bmp")
+ _assert(r_h["error_code"] == SystemClass.UploadError.ERROR_HEIGHT, "Reject mark height != 12")
+
+func _test_symbol_dimension_checks() -> void:
+ print("\n--- Test 3: Symbol (64x128) Dimensions Checking ---")
+ var up = SystemClass.new()
+
+ # Wrong symbol width (32 != 64)
+ var r_w = up.upload_guild_symbol(101, true, 32, 128, "bmp")
+ _assert(r_w["error_code"] == SystemClass.UploadError.ERROR_WIDTH, "Reject symbol width != 64")
+
+ # Wrong symbol height (64 != 128)
+ var r_h = up.upload_guild_symbol(101, true, 64, 64, "bmp")
+ _assert(r_h["error_code"] == SystemClass.UploadError.ERROR_HEIGHT, "Reject symbol height != 128")
+
+func _test_successful_mark_and_symbol_upload() -> void:
+ print("\n--- Test 4: Successful Upload & Cache Retrieval ---")
+ var up = SystemClass.new()
+ var dummy_pixels = PackedByteArray([0xFF, 0x00, 0xFF, 0x00])
+
+ # Upload 16x12 Mark
+ var r_mark = up.upload_guild_mark(101, true, 16, 12, "bmp", dummy_pixels)
+ _assert(r_mark["success"] == true, "Uploaded 16x12 mark successfully")
+ var m = up.get_guild_mark(101)
+ _assert(m != null, "Mark cached in manager")
+ _assert(m.width == 16 and m.height == 12, "Cached mark dimensions match 16x12")
+
+ # Upload 64x128 Symbol
+ var r_sym = up.upload_guild_symbol(101, true, 64, 128, "bmp", dummy_pixels)
+ _assert(r_sym["success"] == true, "Uploaded 64x128 symbol successfully")
+ var s = up.get_guild_symbol(101)
+ _assert(s != null, "Symbol cached in manager")
+ _assert(s.width == 64 and s.height == 128, "Cached symbol dimensions match 64x128")
diff --git a/project/test_guild_mark_uploader_parity.gd.uid b/project/test_guild_mark_uploader_parity.gd.uid
new file mode 100644
index 00000000..fe45bf5a
--- /dev/null
+++ b/project/test_guild_mark_uploader_parity.gd.uid
@@ -0,0 +1 @@
+uid://dj0fgymtxpp2v
diff --git a/project/test_guild_skill_parity.gd b/project/test_guild_skill_parity.gd
new file mode 100644
index 00000000..afd66004
--- /dev/null
+++ b/project/test_guild_skill_parity.gd
@@ -0,0 +1,125 @@
+# test_guild_skill_parity.gd —— 40250 公会等级与 7 大神龙技能 1:1 纯净回归测试套件
+extends SceneTree
+
+const GuildSkillSystem = preload("res://guild_skill_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_guild_skill_parity (Direction 3: Guild Skills 1:1) ===")
+ _test_guild_leveling_and_points()
+ _test_skill_leveling_and_passive_spirit()
+ _test_skill_casting_and_formulas()
+ _test_cooldowns_and_buff_decay()
+ _finish()
+
+func _test_guild_leveling_and_points() -> void:
+ print("\n--- Test 1: Guild EXP Donation & Leveling ---")
+ var gss := GuildSkillSystem.new()
+ _assert(gss.guild_level == 1, "Initial guild level is 1")
+ _assert(gss.skill_points == 0, "Initial skill points is 0")
+
+ # Donate EXP to level up to 2 (needed: 50,000)
+ var res = gss.donate_exp(50000)
+ _assert(res["ok"] == true and res["leveled_up"] == true, "Leveled up successfully")
+ _assert(gss.guild_level == 2, "Guild level is now 2")
+ _assert(gss.skill_points == 1, "Awarded 1 skill point")
+
+ # Donate huge amount to level up to 10
+ gss.donate_exp(5000000)
+ _assert(gss.guild_level > 2, "Guild level increased with large EXP donation")
+ _assert(gss.skill_points > 1, "Multiple skill points accumulated")
+
+func _test_skill_leveling_and_passive_spirit() -> void:
+ print("\n--- Test 2: Skill Leveling & Dragon Eyes (151) Passive ---")
+ var gss := GuildSkillSystem.new()
+ gss.skill_points = 10 # Provide test points
+
+ # Level up 151 (Dragon Eyes) to 7 (Max)
+ for i in range(7):
+ var res = gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_EYES)
+ _assert(res["ok"] == true, "Dragon Eyes upgraded to level %d" % (i + 1))
+
+ _assert(gss.skill_levels[GuildSkillSystem.SKILL_DRAGON_EYES] == 7, "Dragon Eyes is level 7 (Max)")
+ _assert(gss.max_spirit == 200, "Max Dragon Spirit expanded from 100 to 200")
+
+ # 8th level attempt rejected
+ var res = gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_EYES)
+ _assert(res["ok"] == false and res["reason"] == "MAX_SKILL_LEVEL", "Cannot exceed max skill level 7")
+
+func _test_skill_casting_and_formulas() -> void:
+ print("\n--- Test 3: Skill Casting & 40250 Formula Scaling ---")
+ var gss := GuildSkillSystem.new()
+ gss.skill_points = 21
+
+ # Max out 151 (Dragon Eyes), 152 (Dragon Blood) and 156 (Dragon Rage)
+ for i in range(7):
+ gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_EYES)
+ gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_BLOOD)
+ gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_RAGE)
+ gss.current_spirit = 200
+
+ # Cannot cast unlearned skill (154 Holy Armor)
+ var res = gss.cast_guild_skill(GuildSkillSystem.SKILL_HOLY_ARMOR)
+ _assert(res["ok"] == false and res["reason"] == "NOT_LEARNED", "Cannot cast unlearned skill")
+
+ # Cannot cast passive skill (151)
+ res = gss.cast_guild_skill(GuildSkillSystem.SKILL_DRAGON_EYES)
+ _assert(res["ok"] == false and res["reason"] == "PASSIVE_SKILL", "Cannot cast passive skill")
+
+ # Cast 152 Dragon Blood (Level 7 -> k=1.0 -> Max HP +20%)
+ res = gss.cast_guild_skill(GuildSkillSystem.SKILL_DRAGON_BLOOD)
+ _assert(res["ok"] == true, "Dragon Blood cast successfully")
+ _assert(is_equal_approx(float(res["value"]), 20.0), "Level 7 Dragon Blood gives +20% Max HP")
+ _assert(gss.current_spirit == 180, "Spirit deducted: 200 - 20 = 180")
+
+ # Cast 156 Dragon Rage (Level 7 -> k=1.0 -> Crit +50%)
+ res = gss.cast_guild_skill(GuildSkillSystem.SKILL_DRAGON_RAGE)
+ _assert(res["ok"] == true, "Dragon Rage cast successfully")
+ _assert(is_equal_approx(float(res["value"]), 50.0), "Level 7 Dragon Rage gives +50% Critical")
+
+ var bonuses = gss.get_active_bonuses()
+ _assert(is_equal_approx(float(bonuses["max_hp_pct"]), 20.0), "Active bonus has +20% HP")
+ _assert(is_equal_approx(float(bonuses["critical_pct"]), 50.0), "Active bonus has +50% Crit")
+
+func _test_cooldowns_and_buff_decay() -> void:
+ print("\n--- Test 4: Cooldown Enforcement & Buff Decay ---")
+ var gss := GuildSkillSystem.new()
+ gss.skill_points = 7
+ for i in range(7):
+ gss.level_up_skill(GuildSkillSystem.SKILL_DRAGON_BLOOD)
+
+ # 1st cast enters cooldown
+ gss.cast_guild_skill(GuildSkillSystem.SKILL_DRAGON_BLOOD)
+ _assert(gss.skill_cooldowns.has(GuildSkillSystem.SKILL_DRAGON_BLOOD), "Skill is in cooldown")
+
+ # 2nd cast blocked by cooldown
+ var res = gss.cast_guild_skill(GuildSkillSystem.SKILL_DRAGON_BLOOD)
+ _assert(res["ok"] == false and res["reason"] == "COOLDOWN_ACTIVE", "Recasting blocked by cooldown")
+
+ # Fast forward 61 seconds (cooldown is 60s, buff is 300s)
+ gss.update(61.0)
+ _assert(gss.skill_cooldowns.has(GuildSkillSystem.SKILL_DRAGON_BLOOD) == false, "Cooldown expired after 61s")
+ _assert(gss.active_buffs.has(GuildSkillSystem.SKILL_DRAGON_BLOOD) == true, "Buff remains active after 61s")
+
+ # Fast forward remaining 240 seconds -> buff expires
+ gss.update(240.0)
+ _assert(gss.active_buffs.has(GuildSkillSystem.SKILL_DRAGON_BLOOD) == false, "Buff expired after 300s total")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_guild_skill_parity.gd.uid b/project/test_guild_skill_parity.gd.uid
new file mode 100644
index 00000000..52ff765c
--- /dev/null
+++ b/project/test_guild_skill_parity.gd.uid
@@ -0,0 +1 @@
+uid://c25o0whbg064b
diff --git a/project/test_guild_war_observer_arena_parity.gd b/project/test_guild_war_observer_arena_parity.gd
new file mode 100644
index 00000000..9c5d45ff
--- /dev/null
+++ b/project/test_guild_war_observer_arena_parity.gd
@@ -0,0 +1,112 @@
+# test_guild_war_observer_arena_parity.gd —— Metin2 40250 公会战备观战与武斗擂台传送 1:1 测试套件
+extends SceneTree
+
+const GuildWarObserverArenaSystem = preload("res://guild_war_observer_arena_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_guild_war_observer_arena_parity (Direction 4: Guild War Observer 1:1) ===")
+
+ test_active_wars_and_display()
+ test_observer_entry_and_ghost_mode()
+ test_manual_leave_observer_mode()
+ test_war_ending_auto_evacuation()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Guild War Observer Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_active_wars_and_display() -> void:
+ print("\n--- Test 1: Active Wars Registration & Town Guard List (40250 1:1) ---")
+ var system = GuildWarObserverArenaSystem.new()
+
+ var wid = system.register_active_war(101, "王者之师", 202, "龙城铁骑", 1, 50)
+ _assert(wid > 0, "Registered active war")
+
+ var list = system.get_active_wars_for_display()
+ _assert(list.size() == 1, "Listed 1 active war for guard display")
+ _assert(list[0]["guild1_name"] == "王者之师", "Guild 1 name correct")
+ _assert(list[0]["guild2_name"] == "龙城铁骑", "Guild 2 name correct")
+ _assert(list[0]["map_index"] == GuildWarObserverArenaSystem.MAP_GUILD_ARENA, "Map index is 110 (Arena)")
+
+func test_observer_entry_and_ghost_mode() -> void:
+ print("\n--- Test 2: Observer Entry & Ghost Mode (40250 1:1) ---")
+ var system = GuildWarObserverArenaSystem.new()
+ var wid = system.register_active_war(101, "王者之师", 202, "龙城铁骑", 1, 50)
+
+ var orig_pos = Vector2(800.0, 900.0)
+ var res = system.enter_as_observer(999, wid, "主城辛秀", orig_pos)
+ _assert(res["ok"] == true, "Entered observer mode")
+ _assert(res["observer_pos"] == GuildWarObserverArenaSystem.OBSERVER_PLATFORM_POS, "Warps to high platform")
+ _assert(system.is_player_observing(999) == true, "Player 999 is in observer state")
+ # 幽灵模式免疫战斗与禁止出招
+ _assert(system.can_player_attack_or_be_attacked(999) == false, "Player cannot attack or be attacked in observer mode")
+
+func test_manual_leave_observer_mode() -> void:
+ print("\n--- Test 3: Manual Leave Observer Mode (40250 1:1) ---")
+ var system = GuildWarObserverArenaSystem.new()
+ var wid = system.register_active_war(101, "王者之师", 202, "龙城铁骑", 1, 50)
+
+ var orig_pos = Vector2(1200.0, 1500.0)
+ system.enter_as_observer(999, wid, "主城辛秀", orig_pos)
+
+ # 主动离开
+ var leave_res = system.leave_observer_mode(999)
+ _assert(leave_res["ok"] == true, "Left observer mode")
+ _assert(leave_res["return_map"] == "主城辛秀", "Returned to previous map")
+ _assert(leave_res["return_pos"] == orig_pos, "Returned to previous coordinates")
+ _assert(system.is_player_observing(999) == false, "No longer observer")
+ _assert(system.can_player_attack_or_be_attacked(999) == true, "Combat privileges restored")
+
+func test_war_ending_auto_evacuation() -> void:
+ print("\n--- Test 4: War Ending Auto Evacuation (40250 1:1) ---")
+ var system = GuildWarObserverArenaSystem.new()
+ var wid = system.register_active_war(101, "王者之师", 202, "龙城铁骑", 1, 50)
+
+ var left_signals := []
+ system.observer_left.connect(func(pid, map, pos):
+ left_signals.append({"pid": pid, "map": map, "pos": pos})
+ )
+
+ # 2 位观战者入场
+ system.enter_as_observer(801, wid, "主城辛秀", Vector2(100.0, 100.0))
+ system.enter_as_observer(802, wid, "主城镇奴", Vector2(200.0, 200.0))
+
+ # 公会 1 战胜达成 50 分,终局自动结算
+ system.update_war_score(wid, 50, 30)
+
+ # 检查全部观战者被安全自动遣散
+ _assert(left_signals.size() == 2, "Both observers evacuated")
+ _assert(system.is_player_observing(801) == false, "Observer 801 cleared")
+ _assert(system.is_player_observing(802) == false, "Observer 802 cleared")
+ _assert(system.get_active_wars_for_display().is_empty(), "War is marked ended and cleared from active list")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var system = GuildWarObserverArenaSystem.new()
+ var wid = system.register_active_war(101, "公会A", 202, "公会B")
+ system.enter_as_observer(555, wid, "主城", Vector2(300.0, 400.0))
+
+ var data = system.serialize()
+ _assert(data.has("observers") and data["observers"].has(555), "Serialized observer state")
+
+ var restored = GuildWarObserverArenaSystem.new()
+ restored.deserialize(data)
+ _assert(restored.is_player_observing(555) == true, "Restored observer state")
+ _assert(restored.can_player_attack_or_be_attacked(555) == false, "Restored combat immunity")
diff --git a/project/test_guild_war_observer_arena_parity.gd.uid b/project/test_guild_war_observer_arena_parity.gd.uid
new file mode 100644
index 00000000..3975dada
--- /dev/null
+++ b/project/test_guild_war_observer_arena_parity.gd.uid
@@ -0,0 +1 @@
+uid://yhu62x6s5yrx
diff --git a/project/test_guild_war_parity.gd b/project/test_guild_war_parity.gd
new file mode 100644
index 00000000..375f9d63
--- /dev/null
+++ b/project/test_guild_war_parity.gd
@@ -0,0 +1,137 @@
+# test_guild_war_parity.gd —— 40250 官方公会战备宣战与积分决斗 1:1 回归测试套件
+extends SceneTree
+
+const GuildWarSystem = preload("res://guild_war_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_guild_war_parity (Direction 3: Guild War 1:1) ===")
+ _test_war_declaration_and_start()
+ _test_score_tracking_and_target_win()
+ _test_war_timeout_resolution()
+ _test_serialization()
+ _finish()
+
+func _test_war_declaration_and_start() -> void:
+ print("\n--- Test 1: War Declaration & State Machine ---")
+ var sys := GuildWarSystem.new()
+ _assert(sys.war_state == GuildWarSystem.STATE_NONE, "Initially not at war")
+
+ var dec_sig := {"fired": false, "opp": "", "score": 0}
+ sys.war_declared.connect(func(opp: String, _wt: int, s: int):
+ dec_sig["fired"] = true
+ dec_sig["opp"] = opp
+ dec_sig["score"] = s
+ )
+
+ # 1. Declare war against Shadow Assassins (101)
+ var dec_res = sys.declare_war(101, GuildWarSystem.WAR_TYPE_ARENA, 50, 1800.0)
+ _assert(dec_res["ok"] == true, "War declared")
+ _assert(sys.war_state == GuildWarSystem.STATE_DECLARED, "State is STATE_DECLARED")
+ _assert(dec_sig["fired"] == true, "war_declared signal emitted")
+ _assert(dec_sig["opp"] == "暗影刺客团", "Opponent is 暗影刺客团")
+
+ # Cannot declare war while already declared
+ var dec_again = sys.declare_war(102)
+ _assert(dec_again["ok"] == true, "Replaced declaration before start")
+
+ var start_sig := {"fired": false, "dur": 0.0}
+ sys.war_started.connect(func(_wt: int, d: float):
+ start_sig["fired"] = true
+ start_sig["dur"] = d
+ )
+
+ # 2. Start war
+ var start_res = sys.start_war()
+ _assert(start_res["ok"] == true, "War started")
+ _assert(sys.war_state == GuildWarSystem.STATE_FIGHTING, "State is STATE_FIGHTING")
+ _assert(start_sig["fired"] == true, "war_started signal emitted")
+
+func _test_score_tracking_and_target_win() -> void:
+ print("\n--- Test 2: Score Tracking & Target Score (50 Points) Victory ---")
+ var sys := GuildWarSystem.new()
+ sys.declare_war(102, GuildWarSystem.WAR_TYPE_ARENA, 50, 1800.0)
+ sys.start_war()
+
+ var score_sig := {"fired": false, "sa": 0, "sb": 0}
+ sys.score_updated.connect(func(sa: int, sb: int):
+ score_sig["fired"] = true
+ score_sig["sa"] = sa
+ score_sig["sb"] = sb
+ )
+
+ var end_sig := {"fired": false, "winner": "", "reward": {}}
+ sys.war_ended.connect(func(w: String, _sa: int, _sb: int, rew: Dictionary):
+ end_sig["fired"] = true
+ end_sig["winner"] = w
+ end_sig["reward"] = rew
+ )
+
+ # Score increments
+ sys.record_score("A", 10)
+ sys.record_score("B", 5)
+ _assert(sys.score_a == 10 and sys.score_b == 5, "Score is 10 - 5")
+ _assert(score_sig["fired"] == true, "score_updated signal emitted")
+
+ # Side A scores 40 more to hit 50 points (target score)
+ var win_res = sys.record_score("A", 40)
+ _assert(win_res["winner"] == "A", "Side A declared winner")
+ _assert(sys.war_state == GuildWarSystem.STATE_FINISHED, "State is STATE_FINISHED")
+ _assert(sys.player_guild_ladder == 1030, "Ladder points +30 (1000 -> 1030)")
+ _assert(sys.total_wars_won == 1, "Total wars won is 1")
+ _assert(end_sig["fired"] == true, "war_ended signal emitted")
+ _assert(end_sig["reward"]["guild_gold"] == 1000000, "Received 1,000,000 guild gold")
+
+func _test_war_timeout_resolution() -> void:
+ print("\n--- Test 3: War Timeout & Decisive Score Ruling ---")
+ var sys := GuildWarSystem.new()
+ sys.declare_war(103, GuildWarSystem.WAR_TYPE_FIELD, 100, 1800.0)
+ sys.start_war()
+
+ sys.record_score("A", 25)
+ sys.record_score("B", 18)
+
+ # Advance 1800s to timeout
+ sys.update(1800.0)
+ _assert(sys.war_state == GuildWarSystem.STATE_FINISHED, "War finished on timeout")
+ _assert(sys.score_a == 25 and sys.score_b == 18, "Final score is 25 - 18")
+ _assert(sys.player_guild_ladder == 1030, "Player won on higher score (+30 ladder)")
+
+func _test_serialization() -> void:
+ print("\n--- Test 4: Serialization & Restoration ---")
+ var sys1 := GuildWarSystem.new()
+ sys1.player_guild_ladder = 1450
+ sys1.total_wars_won = 15
+ sys1.declare_war(101, GuildWarSystem.WAR_TYPE_FLAG, 50, 1500.0)
+ sys1.start_war()
+ sys1.record_score("A", 30)
+ sys1.record_score("B", 20)
+
+ var data = sys1.serialize()
+ var sys2 := GuildWarSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.war_state == GuildWarSystem.STATE_FIGHTING, "Restored state FIGHTING")
+ _assert(sys2.active_war_type == GuildWarSystem.WAR_TYPE_FLAG, "Restored war type FLAG")
+ _assert(sys2.score_a == 30 and sys2.score_b == 20, "Restored scores")
+ _assert(sys2.player_guild_ladder == 1450, "Restored ladder rating 1450")
+ _assert(sys2.total_wars_won == 15, "Restored total wars won 15")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Guild War System Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_guild_war_parity.gd.uid b/project/test_guild_war_parity.gd.uid
new file mode 100644
index 00000000..aeeec519
--- /dev/null
+++ b/project/test_guild_war_parity.gd.uid
@@ -0,0 +1 @@
+uid://dcmj1n5dx6ixg
diff --git a/project/test_hair_dye_parity.gd b/project/test_hair_dye_parity.gd
new file mode 100644
index 00000000..67e9c27e
--- /dev/null
+++ b/project/test_hair_dye_parity.gd
@@ -0,0 +1,140 @@
+# test_hair_dye_parity.gd —— 40250 发型染色与漂白系统 1:1 回归测试套件
+extends SceneTree
+
+const HairDyeSystem = preload("res://hair_dye_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_hair_dye_parity (Direction 3: Hair Dye System 1:1) ===")
+ _test_hair_dyeing_and_color_mapping()
+ _test_level_cooldown_restriction()
+ _test_bleach_clears_cooldown_and_resets()
+ _test_costume_hair_rejection()
+ _test_serialization()
+ _finish()
+
+func _test_hair_dyeing_and_color_mapping() -> void:
+ print("\n--- Test 1: Hair Dyeing & Color Mapping (40250 1:1) ---")
+ var sys := HairDyeSystem.new()
+ _assert(sys.current_hair_color == 0, "Default hair color is 0 (Default)")
+
+ var inv: Array = [
+ {"vnum": HairDyeSystem.VNUM_BLONDE_DYE, "name": "染发剂(金)", "count": 1}
+ ]
+
+ var dye_sig := {"fired": false, "cid": 0, "cname": ""}
+ sys.hair_dyed.connect(func(cid: int, cn: String):
+ dye_sig["fired"] = true
+ dye_sig["cid"] = cid
+ dye_sig["cname"] = cn
+ )
+
+ # Dye Blonde at Lv 30
+ var res_dye = sys.dye_hair(inv, 0, 30)
+ _assert(res_dye["ok"] == true, "Dyeing succeeded")
+ _assert(sys.current_hair_color == 2, "Color is 2 (Blonde)")
+ _assert(sys.last_dye_level == 30, "Last dye level recorded as 30")
+ _assert(inv[0] == null, "Dye consumed from slot 0")
+ _assert(dye_sig["fired"] == true, "hair_dyed signal emitted")
+ _assert(dye_sig["cid"] == 2, "Reported color ID 2")
+
+func _test_level_cooldown_restriction() -> void:
+ print("\n--- Test 2: Level Interval Cooldown Rule (last_dye_level + 3 <= Level) ---")
+ var sys := HairDyeSystem.new()
+ sys.current_hair_color = 2
+ sys.last_dye_level = 30 # Dyed at Lv 30
+
+ var inv: Array = [
+ {"vnum": HairDyeSystem.VNUM_RED_DYE, "name": "染发剂(红)", "count": 2}
+ ]
+
+ # 1. Attempt at Lv 31 (< 33) -> rejected
+ var res_31 = sys.dye_hair(inv, 0, 31)
+ _assert(res_31["ok"] == false, "Rejected at Lv 31 (< 33)")
+ _assert(res_31["reason"] == "LEVEL_COOLDOWN_ACTIVE", "Reason is LEVEL_COOLDOWN_ACTIVE")
+ _assert(res_31["req_level"] == 33, "Required level is 33")
+
+ # 2. Attempt at Lv 32 (< 33) -> rejected
+ var res_32 = sys.dye_hair(inv, 0, 32)
+ _assert(res_32["ok"] == false, "Rejected at Lv 32 (< 33)")
+
+ # 3. Attempt at Lv 33 (>= 33) -> success!
+ var res_33 = sys.dye_hair(inv, 0, 33)
+ _assert(res_33["ok"] == true, "Succeeded at Lv 33 (30 + 3)")
+ _assert(sys.current_hair_color == 3, "Color is now 3 (Red)")
+ _assert(sys.last_dye_level == 33, "New last_dye_level is 33")
+
+func _test_bleach_clears_cooldown_and_resets() -> void:
+ print("\n--- Test 3: Bleach (70201) Bypasses Cooldown & Resets Color ---")
+ var sys := HairDyeSystem.new()
+ sys.current_hair_color = 3 # Red
+ sys.last_dye_level = 33 # Normally requires Lv 36 for next dye
+
+ var inv: Array = [
+ {"vnum": HairDyeSystem.VNUM_BLEACH, "name": "漂白水", "count": 1},
+ {"vnum": HairDyeSystem.VNUM_WHITE_DYE, "name": "染发剂(白)", "count": 1}
+ ]
+
+ var bleach_sig := {"fired": false}
+ sys.hair_bleached.connect(func():
+ bleach_sig["fired"] = true
+ )
+
+ # Use Bleach at Lv 33 (same level) -> succeeds unconditionally
+ var res_bleach = sys.dye_hair(inv, 0, 33)
+ _assert(res_bleach["ok"] == true, "Bleach used successfully at same level")
+ _assert(sys.current_hair_color == 0, "Color reset to 0 (Default)")
+ _assert(sys.last_dye_level == 0, "Cooldown level reset to 0")
+ _assert(bleach_sig["fired"] == true, "hair_bleached signal emitted")
+
+ # Immediately dye White at Lv 33 without cooldown!
+ var res_white = sys.dye_hair(inv, 1, 33)
+ _assert(res_white["ok"] == true, "White dye used immediately after bleach")
+ _assert(sys.current_hair_color == 1, "Color is now 1 (White)")
+
+func _test_costume_hair_rejection() -> void:
+ print("\n--- Test 4: Costume Hair (PART_HAIR >= 1001) Rejection ---")
+ var sys := HairDyeSystem.new()
+ sys.current_hair_style_id = 1005 # Equipped costume hair
+
+ var inv: Array = [
+ {"vnum": HairDyeSystem.VNUM_BLACK_DYE, "name": "染发剂(黑)", "count": 1}
+ ]
+
+ var res_costume = sys.dye_hair(inv, 0, 50)
+ _assert(res_costume["ok"] == false, "Costume hair dyeing rejected")
+ _assert(res_costume["reason"] == "CANNOT_DYE_COSTUME_HAIR", "Reason is CANNOT_DYE_COSTUME_HAIR")
+ _assert(inv[0] != null, "Dye not consumed")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := HairDyeSystem.new()
+ sys1.current_hair_color = 4 # Brown
+ sys1.last_dye_level = 65
+ sys1.current_hair_style_id = 0
+
+ var data = sys1.serialize()
+ var sys2 := HairDyeSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.current_hair_color == 4, "Restored color 4 (Brown)")
+ _assert(sys2.last_dye_level == 65, "Restored last dye level 65")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Hair Dye System Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_hair_dye_parity.gd.uid b/project/test_hair_dye_parity.gd.uid
new file mode 100644
index 00000000..2366e1c8
--- /dev/null
+++ b/project/test_hair_dye_parity.gd.uid
@@ -0,0 +1 @@
+uid://o1jwdr4spxqo
diff --git a/project/test_herbal_dew_parity.gd b/project/test_herbal_dew_parity.gd
new file mode 100644
index 00000000..7ecb7f8b
--- /dev/null
+++ b/project/test_herbal_dew_parity.gd
@@ -0,0 +1,113 @@
+# test_herbal_dew_parity.gd —— 40250 草药炼丹与 6 色神圣露水 1:1 纯净回归测试套件
+extends SceneTree
+
+const HerbalDewSystem = preload("res://herbal_dew_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_herbal_dew_parity (Direction 2: Herbal Dews 1:1) ===")
+ _test_herb_to_water_extraction()
+ _test_dew_brewing_with_bottle()
+ _test_six_color_dew_drinking_and_stacking()
+ _test_dew_decay_and_expiration()
+ _finish()
+
+func _test_herb_to_water_extraction() -> void:
+ print("\n--- Test 1: Herb to Water Intermediate Extraction ---")
+ var inv: Array = [
+ {"vnum": 50701, "count": 1}, # Peach Blossom
+ null,
+ null
+ ]
+
+ # Extract Peach Blossom -> Sim-Water (50721)
+ var res = HerbalDewSystem.brew_water(inv, 50701)
+ _assert(res["ok"] == true, "Peach Blossom extracted successfully")
+ _assert(res["water_vnum"] == 50721, "Yielded Sim-Water (50721)")
+ _assert(inv[res["slot"]]["vnum"] == 50721, "Sim-Water stored in slot %d" % res["slot"])
+
+ # Missing herb fails
+ var empty_inv: Array = [null, null]
+ res = HerbalDewSystem.brew_water(empty_inv, 50701)
+ _assert(res["ok"] == false and res["reason"] == "MISSING_HERB", "Extraction fails without herb")
+
+func _test_dew_brewing_with_bottle() -> void:
+ print("\n--- Test 2: Dew Brewing with Empty Bottle (70030) ---")
+ var inv: Array = [
+ {"vnum": 50723}, # Bo-Water (for Blue Dew 50823)
+ {"vnum": 70030}, # Empty Bottle
+ null
+ ]
+
+ # Brew Blue Dew (50823)
+ var res = HerbalDewSystem.brew_dew(inv, 50823, true)
+ _assert(res["ok"] == true, "Blue Dew brewed successfully")
+ _assert(res["dew_vnum"] == 50823, "Vnum is 50823 (Blue Dew)")
+ _assert(res["value"] == 120, "Rolled max ATK +120")
+ _assert(inv[res["slot"] if res.has("slot") else 0]["vnum"] == 50823, "Blue Dew stored in inventory")
+
+ # Missing bottle fails
+ var inv_no_bottle: Array = [{"vnum": 50723}, null]
+ res = HerbalDewSystem.brew_dew(inv_no_bottle, 50823)
+ _assert(res["ok"] == false and res["reason"] == "MISSING_BOTTLE", "Brewing fails without empty bottle")
+
+func _test_six_color_dew_drinking_and_stacking() -> void:
+ print("\n--- Test 3: Drinking Dews & Multi-Dew Stacking ---")
+ var hds := HerbalDewSystem.new()
+
+ var blue_dew := {"vnum": 50823, "value": 100} # +100 ATK
+ var red_dew := {"vnum": 50822, "value": 15} # +15% Crit
+ var white_dew := {"vnum": 50824, "value": 80} # +80 DEF
+
+ # Drink Blue Dew
+ var res = hds.drink_dew(blue_dew)
+ _assert(res["ok"] == true, "Blue Dew consumed")
+ _assert(hds.has_dew(50823) == true, "Blue Dew active")
+ _assert(hds.get_stat_bonus("att_grade") == 100, "ATK bonus is 100")
+
+ # Drink Red Dew (stack together)
+ hds.drink_dew(red_dew)
+ _assert(hds.has_dew(50822) == true, "Red Dew active")
+ _assert(hds.get_stat_bonus("critical_pct") == 15, "Critical bonus is 15%")
+ _assert(hds.get_stat_bonus("att_grade") == 100, "ATK bonus remains active alongside Red Dew")
+
+ # Drink White Dew
+ hds.drink_dew(white_dew)
+ _assert(hds.get_stat_bonus("def_grade") == 80, "DEF bonus is 80")
+ _assert(hds.active_dews.size() == 3, "3 distinct dews simultaneously active")
+
+func _test_dew_decay_and_expiration() -> void:
+ print("\n--- Test 4: Dew Duration Decay & Expiration ---")
+ var hds := HerbalDewSystem.new()
+ hds.drink_dew({"vnum": 50826, "value": 5}) # Yellow Dew (Attack Speed)
+ _assert(hds.has_dew(50826) == true, "Yellow Dew active")
+
+ # Fast forward 500s -> still active
+ var expired = hds.update(500.0)
+ _assert(expired.is_empty(), "Not expired after 500s")
+ _assert(hds.has_dew(50826) == true, "Still active at 100s remaining")
+
+ # Fast forward another 101s -> expired
+ expired = hds.update(101.0)
+ _assert(expired == [50826], "Yellow Dew expired")
+ _assert(hds.has_dew(50826) == false, "Yellow Dew no longer active")
+ _assert(hds.get_stat_bonus("att_speed") == 0, "Attack speed bonus reset to 0")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_herbal_dew_parity.gd.uid b/project/test_herbal_dew_parity.gd.uid
new file mode 100644
index 00000000..f974b3a4
--- /dev/null
+++ b/project/test_herbal_dew_parity.gd.uid
@@ -0,0 +1 @@
+uid://dixr6sht8sodk
diff --git a/project/test_horse_combat_parity.gd b/project/test_horse_combat_parity.gd
new file mode 100644
index 00000000..270cbc4f
--- /dev/null
+++ b/project/test_horse_combat_parity.gd
@@ -0,0 +1,160 @@
+# test_horse_combat_parity.gd —— 40250 战马骑乘战斗与马术技能 1:1 纯净回归测试套件
+extends SceneTree
+
+const HorseCombatSystem = preload("res://horse_combat_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_horse_combat_parity (Direction 3: Mounted Combat & Horse Skills 1:1) ===")
+ _test_mounted_state_and_basic_attack()
+ _test_horse_skills_137_to_140()
+ _test_cooldowns_and_sp_validation()
+ _test_stamina_and_feeding()
+ _finish()
+
+func _test_mounted_state_and_basic_attack() -> void:
+ print("\n--- Test 1: Mounted State & 5.0m Attack Sweep Range ---")
+ var hcs := HorseCombatSystem.new()
+
+ var targets: Array = [
+ {"id": 1, "distance": 2.5}, # In range
+ {"id": 2, "distance": 4.8}, # In range (<= 5.0m)
+ {"id": 3, "distance": 5.2} # Out of range (> 5.0m)
+ ]
+
+ # 1. Not riding
+ var r1 = hcs.perform_mounted_basic_attack(500, targets)
+ _assert(r1["ok"] == false and r1["reason"] == "NOT_RIDING", "Attack blocked when not riding")
+
+ # 2. Beginner horse (Tier 1) cannot fight
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_BEGINNER)
+ var r2 = hcs.perform_mounted_basic_attack(500, targets)
+ _assert(r2["ok"] == false and r2["reason"] == "HORSE_TIER_TOO_LOW", "Attack blocked on beginner horse")
+
+ # 3. Combat horse (Tier 2) can fight
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_COMBAT)
+ var attack_data := {"fired": false, "targets": 0, "dmg": 0}
+ hcs.horse_attack_performed.connect(func(cnt: int, dmg: int):
+ attack_data["fired"] = true
+ attack_data["targets"] = cnt
+ attack_data["dmg"] = dmg
+ )
+
+ var r3 = hcs.perform_mounted_basic_attack(500, targets)
+ _assert(r3["ok"] == true, "Attack succeeds on combat horse")
+ _assert(r3["attack_range"] == 5.0, "Mounted attack range is 5.0m")
+ _assert(r3["hit_count"] == 2, "Hit 2 targets within 5.0m (2.5m and 4.8m)")
+ _assert(r3["damage_per_hit"] == 600, "1.2x damage multiplier (500 * 1.2 = 600)")
+ _assert(r3["total_damage"] == 1200, "Total damage is 1200 for 2 targets")
+ _assert(attack_data["fired"] == true and attack_data["targets"] == 2, "horse_attack_performed signal fired")
+
+func _test_horse_skills_137_to_140() -> void:
+ print("\n--- Test 2: 4 Military Horse Skills (137-140) ---")
+ var hcs := HorseCombatSystem.new()
+
+ # Combat horse (Tier 2) cannot use skills
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_COMBAT)
+ var r1 = hcs.use_horse_skill(137, 100, 1000)
+ _assert(r1["ok"] == false and r1["reason"] == "REQUIRES_MILITARY_HORSE", "Skills blocked on tier 2 horse")
+
+ # Switch to Military Horse (Tier 3)
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_MILITARY)
+
+ var skill_signal := {"fired": false, "id": 0, "name": ""}
+ hcs.horse_skill_used.connect(func(sid: int, sname: String, _sp: int):
+ skill_signal["fired"] = true
+ skill_signal["id"] = sid
+ skill_signal["name"] = sname
+ )
+
+ # 1. Skill 137: 战马砍击 (Horse Slash)
+ var s137 = hcs.use_horse_skill(137, 100, 1000)
+ _assert(s137["ok"] == true, "Skill 137 used successfully")
+ _assert(s137["damage"] == 2500, "Skill 137 deals 250% damage (2500)")
+ _assert(s137["effect"] == "KNOCKBACK", "Skill 137 inflicts KNOCKBACK")
+ _assert(skill_signal["fired"] == true and skill_signal["id"] == 137, "horse_skill_used signal fired for 137")
+
+ # 2. Skill 138: 战马突刺 (Horse Charge)
+ var s138 = hcs.use_horse_skill(138, 100, 1000)
+ _assert(s138["ok"] == true, "Skill 138 used successfully")
+ _assert(s138["damage"] == 2000, "Skill 138 deals 200% damage (2000)")
+ _assert(s138["effect"] == "STUMBLE", "Skill 138 inflicts STUMBLE")
+
+ # 3. Skill 139: 战马震击 (Horse Stomp)
+ var s139 = hcs.use_horse_skill(139, 100, 1000)
+ _assert(s139["ok"] == true, "Skill 139 used successfully")
+ _assert(s139["damage"] == 3000, "Skill 139 deals 300% damage (3000)")
+ _assert(s139["effect"] == "AOE_STUN", "Skill 139 inflicts AOE_STUN")
+
+ # 4. Skill 140: 破浪击 (Power Wave)
+ var s140 = hcs.use_horse_skill(140, 100, 1000)
+ _assert(s140["ok"] == true, "Skill 140 used successfully")
+ _assert(s140["damage"] == 2200, "Skill 140 deals 220% damage (2200)")
+ _assert(s140["range"] == 12.0, "Skill 140 has 12.0m ranged reach")
+
+func _test_cooldowns_and_sp_validation() -> void:
+ print("\n--- Test 3: Skill Cooldowns & SP Verification ---")
+ var hcs := HorseCombatSystem.new()
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_MILITARY)
+
+ # Cast 137
+ hcs.use_horse_skill(137, 100, 1000)
+
+ # Cast again immediately -> on cooldown
+ var cd_res = hcs.use_horse_skill(137, 100, 1000)
+ _assert(cd_res["ok"] == false and cd_res["reason"] == "SKILL_ON_COOLDOWN", "Skill 137 is on cooldown")
+
+ # Update 10s -> still on cooldown
+ hcs.update(10.0)
+ _assert(hcs.skill_cooldowns[137] == 5.0, "5.0s remaining cooldown on 137")
+
+ # Update 6s -> ready
+ hcs.update(6.0)
+ _assert(hcs.skill_cooldowns[137] == 0.0, "Cooldown expired")
+
+ # Try with insufficient SP (< 30)
+ var low_sp_res = hcs.use_horse_skill(137, 20, 1000)
+ _assert(low_sp_res["ok"] == false and low_sp_res["reason"] == "INSUFFICIENT_SP", "Skill blocked with low SP")
+
+func _test_stamina_and_feeding() -> void:
+ print("\n--- Test 4: Horse Stamina & Feeding (Carrot / Ginseng) ---")
+ var hcs := HorseCombatSystem.new()
+ hcs.set_mounted_state(true, HorseCombatSystem.HORSE_TIER_MILITARY)
+
+ # Drain stamina manually to test exhaustion
+ hcs.horse_stamina = 2.0
+ var ex_res = hcs.use_horse_skill(137, 100, 1000)
+ _assert(ex_res["ok"] == false and ex_res["reason"] == "HORSE_EXHAUSTED", "Skill blocked when stamina < 5")
+
+ # Feed invalid food
+ var r_bad = hcs.feed_horse(99999)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "WRONG_FOOD", "Invalid food rejected")
+
+ # Feed Carrot (50054) -> +40 stamina (2 + 40 = 42)
+ var r_carrot = hcs.feed_horse(50054)
+ _assert(r_carrot["ok"] == true, "Fed carrot successfully")
+ _assert(hcs.horse_stamina == 42.0, "Stamina increased by 40 to 42.0")
+
+ # Feed Ginseng (50058) -> +80 stamina (42 + 80 = 122 -> capped at 100.0)
+ var r_ginseng = hcs.feed_horse(50058)
+ _assert(r_ginseng["ok"] == true, "Fed ginseng successfully")
+ _assert(hcs.horse_stamina == 100.0, "Stamina capped at 100.0")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Horse Combat Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_horse_combat_parity.gd.uid b/project/test_horse_combat_parity.gd.uid
new file mode 100644
index 00000000..60495758
--- /dev/null
+++ b/project/test_horse_combat_parity.gd.uid
@@ -0,0 +1 @@
+uid://bijpyvqynjl6
diff --git a/project/test_horse_level_growth_parity.gd b/project/test_horse_level_growth_parity.gd
new file mode 100644
index 00000000..72d60b8f
--- /dev/null
+++ b/project/test_horse_level_growth_parity.gd
@@ -0,0 +1,179 @@
+# test_horse_level_growth_parity.gd —— 40250 官方马匹等级进化与养殖喂食系统 1:1 回归测试套件
+extends SceneTree
+
+const HorseLevelGrowthSystem = preload("res://horse_level_growth_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_horse_level_growth_parity (Direction 2: Horse Level Growth 1:1) ===")
+ _test_horse_tiers_and_leveling()
+ _test_feeding_food_tier_matching()
+ _test_horse_death_and_herb_revive()
+ _test_mount_dismount_and_stamina()
+ _test_serialization()
+ _finish()
+
+func _test_horse_tiers_and_leveling() -> void:
+ print("\n--- Test 1: Horse Tiers & Level Progression ---")
+ var sys := HorseLevelGrowthSystem.new()
+ sys.initialize_horse(1)
+ _assert(sys.horse_level == 1, "Initialized at level 1")
+ _assert(sys.get_tier() == HorseLevelGrowthSystem.TIER_BEGINNER, "Tier is Beginner")
+ _assert(sys.get_tier_name() == "初级小马", "Tier name is 初级小马")
+
+ # Upgrade to Lv 11 with char level 25 (Combat horse requires 35)
+ sys.horse_level = 10
+ var res_low = sys.upgrade_horse_level(25)
+ _assert(res_low["ok"] == false, "Upgrade rejected: char level 25 < 35")
+ _assert(res_low["reason"] == "CHAR_LEVEL_TOO_LOW", "Reason is CHAR_LEVEL_TOO_LOW")
+
+ # Upgrade with char level 35
+ var res_combat = sys.upgrade_horse_level(35)
+ _assert(res_combat["ok"] == true, "Upgraded to Lv 11")
+ _assert(sys.horse_level == 11, "Horse level is 11")
+ _assert(sys.get_tier() == HorseLevelGrowthSystem.TIER_COMBAT, "Tier is Combat")
+ _assert(sys.get_tier_name() == "战斗装甲马", "Tier name is 战斗装甲马")
+
+ # Upgrade to Lv 21 with char level 45 (Military horse requires 50)
+ sys.horse_level = 20
+ var res_low2 = sys.upgrade_horse_level(45)
+ _assert(res_low2["ok"] == false, "Upgrade rejected: char level 45 < 50")
+
+ # Upgrade with char level 50
+ var res_mil = sys.upgrade_horse_level(50)
+ _assert(res_mil["ok"] == true, "Upgraded to Lv 21")
+ _assert(sys.horse_level == 21, "Horse level is 21")
+ _assert(sys.get_tier() == HorseLevelGrowthSystem.TIER_MILITARY, "Tier is Military")
+ _assert(sys.get_tier_name() == "军阶战马", "Tier name is 军阶战马")
+
+func _test_feeding_food_tier_matching() -> void:
+ print("\n--- Test 2: Feeding Food Tier Matching (Hay, Carrot, Ginseng) ---")
+ var sys := HorseLevelGrowthSystem.new()
+ sys.initialize_horse(1) # Beginner horse
+ sys.current_health = 1 # Lower health so we can feed
+
+ var inv: Array = [
+ {"vnum": HorseLevelGrowthSystem.VNUM_HAY, "name": "干草", "count": 2},
+ {"vnum": HorseLevelGrowthSystem.VNUM_CARROT, "name": "胡萝卜", "count": 2},
+ {"vnum": HorseLevelGrowthSystem.VNUM_RED_GINSENG, "name": "红参", "count": 2}
+ ]
+
+ # 1. Beginner horse fed Carrot -> mismatch
+ var feed_mismatch = sys.feed_horse(inv, 1)
+ _assert(feed_mismatch["ok"] == false, "Feeding Carrot to Beginner horse rejected")
+ _assert(feed_mismatch["reason"] == "FOOD_TIER_MISMATCH", "Reason is FOOD_TIER_MISMATCH")
+
+ # 2. Beginner horse fed Hay -> success
+ var feed_succ = sys.feed_horse(inv, 0)
+ _assert(feed_succ["ok"] == true, "Feeding Hay to Beginner horse succeeded")
+ _assert(sys.current_health == 3, "Health increased to 3")
+ _assert(inv[0]["count"] == 1, "Hay count decremented to 1")
+
+ # 3. Combat horse fed Carrot
+ sys.initialize_horse(15)
+ sys.current_health = 10
+ var feed_combat = sys.feed_horse(inv, 1)
+ _assert(feed_combat["ok"] == true, "Feeding Carrot to Combat horse succeeded")
+
+ # 4. Military horse fed Red Ginseng
+ sys.initialize_horse(25)
+ sys.current_health = 20
+ var feed_mil = sys.feed_horse(inv, 2)
+ _assert(feed_mil["ok"] == true, "Feeding Red Ginseng to Military horse succeeded")
+
+func _test_horse_death_and_herb_revive() -> void:
+ print("\n--- Test 3: Horse Death & Ape Herb Revive ---")
+ var sys := HorseLevelGrowthSystem.new()
+ sys.initialize_horse(1) # Beginner horse
+ sys.current_health = 0 # Dead/Fainted
+
+ var inv: Array = [
+ {"vnum": HorseLevelGrowthSystem.VNUM_HERB_NORMAL, "name": "中级猴草", "count": 1},
+ {"vnum": HorseLevelGrowthSystem.VNUM_HERB_EASY, "name": "初级猴草", "count": 1}
+ ]
+
+ # Cannot mount dead horse
+ var player := {"attack_power": 100, "defense": 100}
+ var mount_dead = sys.mount_horse(player)
+ _assert(mount_dead["ok"] == false, "Cannot mount dead horse")
+ _assert(mount_dead["reason"] == "HORSE_IS_DEAD", "Reason is HORSE_IS_DEAD")
+
+ # Revive with wrong herb (Normal herb on Beginner horse)
+ var revive_wrong = sys.revive_horse(inv, 0)
+ _assert(revive_wrong["ok"] == false, "Wrong herb tier rejected")
+ _assert(revive_wrong["reason"] == "HERB_TIER_MISMATCH", "Reason is HERB_TIER_MISMATCH")
+
+ # Revive with correct Easy Ape herb
+ var revive_sig := {"fired": false, "hp": 0}
+ sys.horse_revived.connect(func(h: int):
+ revive_sig["fired"] = true
+ revive_sig["hp"] = h
+ )
+
+ var revive_ok = sys.revive_horse(inv, 1)
+ _assert(revive_ok["ok"] == true, "Horse successfully revived with Easy Ape herb")
+ _assert(sys.current_health > 0, "Current health > 0")
+ _assert(inv[1] == null, "Herb consumed")
+ _assert(revive_sig["fired"] == true, "horse_revived signal emitted")
+
+func _test_mount_dismount_and_stamina() -> void:
+ print("\n--- Test 4: Mount, Dismount, & Stamina Depletion ---")
+ var sys := HorseLevelGrowthSystem.new()
+ sys.initialize_horse(21) # Military horse Lv 21: atk 84, def 50
+ var player := {"attack_power": 500, "defense": 200}
+
+ # Mount
+ var mount_res = sys.mount_horse(player)
+ _assert(mount_res["ok"] == true, "Mounted military horse")
+ _assert(sys.is_mounted == true, "is_mounted is true")
+ _assert(player["attack_power"] == 584, "Attack power increased 500 -> 584 (+84)")
+ _assert(player["defense"] == 250, "Defense increased 200 -> 250 (+50)")
+
+ # Consume stamina until exhausted
+ var exhaust_sig := {"fired": false}
+ sys.stamina_exhausted.connect(func():
+ exhaust_sig["fired"] = true
+ )
+
+ sys.consume_stamina(999, player)
+ _assert(exhaust_sig["fired"] == true, "stamina_exhausted signal emitted")
+ _assert(sys.is_mounted == false, "Auto-dismounted on exhaustion")
+ _assert(player["attack_power"] == 500, "Attack power reversed back to 500")
+ _assert(player["defense"] == 200, "Defense reversed back to 200")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := HorseLevelGrowthSystem.new()
+ sys1.initialize_horse(25)
+ sys1.current_health = 30
+ sys1.current_stamina = 80
+ sys1.is_mounted = true
+
+ var data = sys1.serialize()
+ var sys2 := HorseLevelGrowthSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.horse_level == 25, "Restored horse level 25")
+ _assert(sys2.get_tier_name() == "军阶战马", "Restored tier military")
+ _assert(sys2.current_health == 30, "Restored health 30")
+ _assert(sys2.current_stamina == 80, "Restored stamina 80")
+ _assert(sys2.is_mounted == true, "Restored mounted state")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Horse Level Growth Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_horse_level_growth_parity.gd.uid b/project/test_horse_level_growth_parity.gd.uid
new file mode 100644
index 00000000..5107e335
--- /dev/null
+++ b/project/test_horse_level_growth_parity.gd.uid
@@ -0,0 +1 @@
+uid://b6vad1nro7ocu
diff --git a/project/test_intro_create_parity.gd b/project/test_intro_create_parity.gd
new file mode 100644
index 00000000..9312524a
--- /dev/null
+++ b/project/test_intro_create_parity.gd
@@ -0,0 +1,132 @@
+# test_intro_create_parity.gd —— 40250 3D 角色塑形创建回归测试
+extends SceneTree
+
+const SystemClass = preload("res://intro_create_phase.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_intro_create_parity (Batch 29 Direction 4) ===")
+ _test_class_gender_shape_matrix()
+ _test_canonical_start_stats()
+ _test_turntable_360_rotation()
+ _test_character_name_validation()
+ _test_successful_character_creation()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_class_gender_shape_matrix() -> void:
+ print("\n--- Test 1: Class, Gender, and Shape Matrix ---")
+ var cr = SystemClass.new()
+
+ _assert(cr.selected_job == SystemClass.JOB_WARRIOR, "Default job is Warrior")
+ _assert(cr.selected_gender == SystemClass.GENDER_MALE, "Default gender is Male")
+ _assert(cr.selected_shape == SystemClass.SHAPE_0, "Default shape is Shape 0")
+
+ # Switch to Assassin, Female, Shape 1
+ var s_job = cr.select_job(SystemClass.JOB_ASSASSIN)
+ var s_gen = cr.select_gender(SystemClass.GENDER_FEMALE)
+ var s_shp = cr.select_shape(SystemClass.SHAPE_1)
+
+ _assert(s_job == true and cr.selected_job == SystemClass.JOB_ASSASSIN, "Switched to Assassin")
+ _assert(s_gen == true and cr.selected_gender == SystemClass.GENDER_FEMALE, "Switched to Female")
+ _assert(s_shp == true and cr.selected_shape == SystemClass.SHAPE_1, "Switched to Shape 1")
+
+ # Reject invalid selections
+ _assert(cr.select_job(99) == false, "Reject invalid job id 99")
+ _assert(cr.select_gender(5) == false, "Reject invalid gender id 5")
+ _assert(cr.select_shape(3) == false, "Reject invalid shape id 3")
+
+func _test_canonical_start_stats() -> void:
+ print("\n--- Test 2: 40250 Canonical START_STAT Table (CON, INT, STR, DEX) ---")
+ var cr = SystemClass.new()
+
+ # 1. Warrior: [4, 3, 6, 3]
+ cr.select_job(SystemClass.JOB_WARRIOR)
+ var st_w = cr.get_current_stats()
+ _assert(st_w["con"] == 4 and st_w["int"] == 3 and st_w["str"] == 6 and st_w["dex"] == 3, "Warrior stats: 4/3/6/3")
+
+ # 2. Assassin: [3, 3, 4, 6]
+ cr.select_job(SystemClass.JOB_ASSASSIN)
+ var st_a = cr.get_current_stats()
+ _assert(st_a["con"] == 3 and st_a["int"] == 3 and st_a["str"] == 4 and st_a["dex"] == 6, "Assassin stats: 3/3/4/6")
+
+ # 3. Sura: [3, 5, 5, 3]
+ cr.select_job(SystemClass.JOB_SURA)
+ var st_s = cr.get_current_stats()
+ _assert(st_s["con"] == 3 and st_s["int"] == 5 and st_s["str"] == 5 and st_s["dex"] == 3, "Sura stats: 3/5/5/3")
+
+ # 4. Shaman: [4, 6, 3, 3]
+ cr.select_job(SystemClass.JOB_SHAMAN)
+ var st_m = cr.get_current_stats()
+ _assert(st_m["con"] == 4 and st_m["int"] == 6 and st_m["str"] == 3 and st_m["dex"] == 3, "Shaman stats: 4/6/3/3")
+
+func _test_turntable_360_rotation() -> void:
+ print("\n--- Test 3: Turntable 360-Degree Rotation ---")
+ var cr = SystemClass.new()
+
+ cr.rotate_turntable(45.0)
+ _assert(cr.turntable_rotation_deg == 45.0, "Rotated by 45 degrees")
+
+ cr.rotate_turntable(340.0) # 45 + 340 = 385 -> wraps to 25
+ _assert(is_equal_approx(cr.turntable_rotation_deg, 25.0), "Rotated beyond 360 wrapped correctly to 25 degrees")
+
+ cr.rotate_turntable(-30.0) # 25 - 30 = -5 -> wraps to 355
+ _assert(is_equal_approx(cr.turntable_rotation_deg, 355.0), "Negative rotation wrapped correctly to 355 degrees")
+
+func _test_character_name_validation() -> void:
+ print("\n--- Test 4: Character Name Validation Rules ---")
+ var cr = SystemClass.new()
+
+ # Empty
+ var r_empty = cr.validate_character_name("")
+ _assert(r_empty["error_code"] == "EMPTY_NAME", "Reject empty name")
+
+ # Too short (< 2)
+ var r_short = cr.validate_character_name("A")
+ _assert(r_short["error_code"] == "INVALID_NAME_LENGTH", "Reject name length < 2")
+
+ # Too long (> 12)
+ var r_long = cr.validate_character_name("VeryLongHeroNameExceeding12")
+ _assert(r_long["error_code"] == "INVALID_NAME_LENGTH", "Reject name length > 12")
+
+ # Contains space
+ var r_space = cr.validate_character_name("Hello World")
+ _assert(r_space["error_code"] == "NAME_CONTAINS_SPACE", "Reject name containing spaces")
+
+ # Banned word (GM / Admin)
+ var r_gm = cr.validate_character_name("[GM]Tester")
+ _assert(r_gm["error_code"] == "NAME_CONTAINS_BANNED_WORD", "Reject name containing [GM]")
+
+ var r_ok = cr.validate_character_name("DarkKnight")
+ _assert(r_ok["valid"] == true, "Valid name DarkKnight passed")
+
+func _test_successful_character_creation() -> void:
+ print("\n--- Test 5: Successful Character Creation Payload ---")
+ var cr = SystemClass.new()
+ cr.select_job(SystemClass.JOB_WARRIOR)
+ cr.select_gender(SystemClass.GENDER_MALE)
+ cr.select_shape(SystemClass.SHAPE_0)
+
+ var res = cr.create_character("Conan", 1)
+ _assert(res["success"] == true, "Created character Conan successfully")
+ var ch = res["character"]
+ _assert(ch["name"] == "Conan", "Name matches Conan")
+ _assert(ch["level"] == 1, "Level is 1")
+ _assert(ch["empire"] == 1, "Empire is 1 (Shinsoo)")
+ _assert(ch["stats"]["str"] == 6, "Warrior STR is 6")
+ _assert(ch["delete_code"] == "1234567", "Generated 7-digit delete code")
diff --git a/project/test_intro_create_parity.gd.uid b/project/test_intro_create_parity.gd.uid
new file mode 100644
index 00000000..76a33c99
--- /dev/null
+++ b/project/test_intro_create_parity.gd.uid
@@ -0,0 +1 @@
+uid://bhsyk6ij1c7ew
diff --git a/project/test_intro_empire_parity.gd b/project/test_intro_empire_parity.gd
new file mode 100644
index 00000000..39dc2b2f
--- /dev/null
+++ b/project/test_intro_empire_parity.gd
@@ -0,0 +1,87 @@
+# test_intro_empire_parity.gd —— 40250 三国地貌 3D 运镜与阵营选择回归测试
+extends SceneTree
+
+const SystemClass = preload("res://intro_empire_phase.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_intro_empire_parity (Batch 29 Direction 2) ===")
+ _test_initial_empire_state()
+ _test_camera_target_positions_and_switching()
+ _test_camera_smooth_interpolation()
+ _test_empire_description_and_confirmation()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_initial_empire_state() -> void:
+ print("\n--- Test 1: Initial Empire & Camera Initialization ---")
+ var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO)
+
+ _assert(emp.selected_empire == SystemClass.EMPIRE_SHINSOO, "Initial empire is Shinsoo")
+ var info = emp.get_selected_empire_info()
+ _assert(info["capital"] == "Yongan", "Shinsoo capital is Yongan")
+ _assert(emp.current_cam_pos == Vector3(100.0, 50.0, -200.0), "Initial camera pos matches preset")
+
+func _test_camera_target_positions_and_switching() -> void:
+ print("\n--- Test 2: Switching Empires & Camera Target Updates ---")
+ var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO)
+
+ # Switch to Chunjo (Yellow)
+ var sw1 = emp.select_empire(SystemClass.EMPIRE_CHUNJO)
+ _assert(sw1 == true, "Switched to Chunjo")
+ _assert(emp.target_cam_pos == Vector3(-150.0, 60.0, -180.0), "Chunjo camera target pos set")
+ _assert(emp.is_transitioning == true, "Camera transition activated")
+
+ # Switch to Jinno (Blue)
+ var sw2 = emp.select_empire(SystemClass.EMPIRE_JINNO)
+ _assert(sw2 == true, "Switched to Jinno")
+ _assert(emp.target_cam_pos == Vector3(0.0, 80.0, -250.0), "Jinno camera target pos set")
+
+ # Reject invalid empire
+ var sw_inv = emp.select_empire(99)
+ _assert(sw_inv == false, "Reject invalid empire id 99")
+
+func _test_camera_smooth_interpolation() -> void:
+ print("\n--- Test 3: Smooth Camera Interpolation Flow ---")
+ var emp = SystemClass.new(SystemClass.EMPIRE_SHINSOO)
+
+ emp.select_empire(SystemClass.EMPIRE_JINNO)
+ _assert(emp.is_transitioning == true, "Transitioning flag is true")
+
+ # Halfway step (0.5)
+ emp.update_camera_transition(0.5)
+ _assert(emp.is_transitioning == true, "Still transitioning at 50%")
+ _assert(emp.transition_t == 0.5, "transition_t is 0.5")
+
+ # Complete transition (another 0.5)
+ emp.update_camera_transition(0.5)
+ _assert(emp.is_transitioning == false, "Transition completed")
+ _assert(emp.current_cam_pos == Vector3(0.0, 80.0, -250.0), "Camera arrived exactly at Jinno preset")
+
+func _test_empire_description_and_confirmation() -> void:
+ print("\n--- Test 4: Empire Metadata & Confirmation Phase Transition ---")
+ var emp = SystemClass.new(SystemClass.EMPIRE_JINNO)
+
+ var info = emp.get_selected_empire_info()
+ _assert(info["desc"].contains("崇尚武道强权"), "Contains Jinno lore description")
+ _assert(info["trait"].contains("物理基础攻击力 +5%"), "Contains Jinno trait bonus")
+
+ var conf = emp.confirm_empire_selection()
+ _assert(conf["success"] == true, "Confirmed empire selection")
+ _assert(conf["empire_id"] == SystemClass.EMPIRE_JINNO, "Confirmed Jinno (3)")
+ _assert(conf["next_phase"] == "CHARACTER_SELECT", "Routes to CHARACTER_SELECT phase")
diff --git a/project/test_intro_empire_parity.gd.uid b/project/test_intro_empire_parity.gd.uid
new file mode 100644
index 00000000..d9d768be
--- /dev/null
+++ b/project/test_intro_empire_parity.gd.uid
@@ -0,0 +1 @@
+uid://bdkpue0f7n6q0
diff --git a/project/test_intro_loading_parity.gd b/project/test_intro_loading_parity.gd
new file mode 100644
index 00000000..cbb9ddc3
--- /dev/null
+++ b/project/test_intro_loading_parity.gd
@@ -0,0 +1,77 @@
+# test_intro_loading_parity.gd —— 40250 官方插画过场加载与 Tip 轮播回归测试
+extends SceneTree
+
+const SystemClass = preload("res://intro_loading_phase.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_intro_loading_parity (Batch 30 Direction 2) ===")
+ _test_loading_wallpapers_and_tips()
+ _test_step_by_step_progress_stream()
+ _test_completion_flow()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_loading_wallpapers_and_tips() -> void:
+ print("\n--- Test 1: Wallpapers & Tips Selection ---")
+ var loader = SystemClass.new()
+ loader.start_loading(1, 2)
+
+ _assert(loader.current_wallpaper == SystemClass.OFFICIAL_LOADING_WALLPAPERS[1], "Selected wallpaper 1")
+ _assert(loader.current_tip == SystemClass.OFFICIAL_LOADING_TIPS[2], "Selected tip 2 (Biologist)")
+ _assert(loader.current_tip.contains("生物学家"), "Tip content matches official text")
+ _assert(loader.progress_percent == 0.0, "Initial progress is 0%")
+ _assert(loader.is_completed() == false, "Loading not completed initially")
+
+func _test_step_by_step_progress_stream() -> void:
+ print("\n--- Test 2: Step-by-Step Progress Stream (25% -> 50% -> 75% -> 100%) ---")
+ var loader = SystemClass.new()
+ loader.start_loading()
+
+ # Step 1: Property (25%)
+ var s1 = loader.advance_loading_step()
+ _assert(s1["step"] == SystemClass.STEP_PROPERTY, "Advanced to STEP_PROPERTY")
+ _assert(s1["progress"] == 0.25, "Progress is 25%")
+ _assert(s1["status_text"].contains("25%"), "Status text displays 25%")
+
+ # Step 2: Terrain (50%)
+ var s2 = loader.advance_loading_step()
+ _assert(s2["step"] == SystemClass.STEP_TERRAIN, "Advanced to STEP_TERRAIN")
+ _assert(s2["progress"] == 0.50, "Progress is 50%")
+
+ # Step 3: Entities (75%)
+ var s3 = loader.advance_loading_step()
+ _assert(s3["step"] == SystemClass.STEP_ENTITIES, "Advanced to STEP_ENTITIES")
+ _assert(s3["progress"] == 0.75, "Progress is 75%")
+
+ # Step 4: UI & Audio (100%)
+ var s4 = loader.advance_loading_step()
+ _assert(s4["step"] == SystemClass.STEP_UI_AUDIO, "Advanced to STEP_UI_AUDIO")
+ _assert(s4["progress"] == 1.0, "Progress reached 100%")
+
+func _test_completion_flow() -> void:
+ print("\n--- Test 3: Loading Completion Lifecycle ---")
+ var loader = SystemClass.new()
+ loader.start_loading()
+
+ # Run all steps to finish
+ for i in range(5):
+ loader.advance_loading_step()
+
+ _assert(loader.is_completed() == true, "Loading fully completed")
+ _assert(loader.progress_percent == 1.0, "Progress is 100%")
diff --git a/project/test_intro_loading_parity.gd.uid b/project/test_intro_loading_parity.gd.uid
new file mode 100644
index 00000000..7cb606fc
--- /dev/null
+++ b/project/test_intro_loading_parity.gd.uid
@@ -0,0 +1 @@
+uid://bmovu5thonsuu
diff --git a/project/test_intro_login_parity.gd b/project/test_intro_login_parity.gd
new file mode 100644
index 00000000..3ae02aa5
--- /dev/null
+++ b/project/test_intro_login_parity.gd
@@ -0,0 +1,96 @@
+# test_intro_login_parity.gd —— 40250 登录与频道探测回归测试
+extends SceneTree
+
+const SystemClass = preload("res://intro_login_phase.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_intro_login_parity (Batch 29 Direction 1) ===")
+ _test_channel_selection_and_ping_states()
+ _test_credential_validation()
+ _test_login_rejections()
+ _test_successful_login_and_remember_account()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_channel_selection_and_ping_states() -> void:
+ print("\n--- Test 1: Channel Selection & Server State Checker ---")
+ var login = SystemClass.new()
+
+ _assert(login.selected_channel_id == 1, "Default channel is CH1")
+
+ # Select CH3
+ var s_ok = login.select_channel(3)
+ _assert(s_ok == true, "Selected CH3")
+ _assert(login.selected_channel_id == 3, "Current channel updated to CH3")
+
+ # Select invalid channel
+ var s_fail = login.select_channel(99)
+ _assert(s_fail == false, "Reject selecting invalid channel 99")
+
+ # Update state
+ login.update_channel_state(3, SystemClass.STATE_FULL)
+ _assert(login.get_channel_info(3)["state"] == SystemClass.STATE_FULL, "CH3 state updated to FULL")
+
+func _test_credential_validation() -> void:
+ print("\n--- Test 2: Credential Formatting Checks ---")
+ var login = SystemClass.new()
+
+ # Empty
+ var r_empty = login.attempt_login("", "")
+ _assert(r_empty["error_code"] == "EMPTY_CREDENTIALS", "Reject empty username and password")
+
+ # Short account name
+ var r_short = login.attempt_login("ab", "123")
+ _assert(r_short["error_code"] == "INVALID_ACCOUNT_LEN", "Reject account length < 3")
+
+func _test_login_rejections() -> void:
+ print("\n--- Test 3: Rejections (Offline, Nonexistent, Blocked, Wrong Password) ---")
+ var login = SystemClass.new()
+
+ # 1. Target channel offline
+ login.update_channel_state(1, SystemClass.STATE_OFFLINE)
+ login.select_channel(1)
+ var r_off = login.attempt_login("admin", "password")
+ _assert(r_off["error_code"] == "CHANNEL_OFFLINE", "Reject login if target channel is OFFLINE")
+
+ # Switch to normal channel
+ login.select_channel(2)
+
+ # 2. Account not exist
+ var r_no = login.attempt_login("unknown_user", "123")
+ _assert(r_no["error_code"] == "ACCOUNT_NOT_EXIST", "Reject non-existent account")
+
+ # 3. Account blocked
+ var r_block = login.attempt_login("banned", "123")
+ _assert(r_block["error_code"] == "ACCOUNT_BLOCKED", "Reject banned/blocked account")
+
+ # 4. Wrong password
+ var r_pass = login.attempt_login("admin", "incorrect_pass")
+ _assert(r_pass["error_code"] == "WRONG_PASSWORD", "Reject wrong password")
+
+func _test_successful_login_and_remember_account() -> void:
+ print("\n--- Test 4: Successful Login & Remember Account Persist ---")
+ var login = SystemClass.new()
+ login.select_channel(2)
+ login.set_remember_account(true, "player")
+
+ var r_succ = login.attempt_login("player", "123")
+ _assert(r_succ["success"] == true, "Login succeeded for player")
+ _assert(r_succ["has_characters"] == true, "Account possesses characters")
+ _assert(login.saved_account_name == "player", "Saved account name preserved for quick login")
diff --git a/project/test_intro_login_parity.gd.uid b/project/test_intro_login_parity.gd.uid
new file mode 100644
index 00000000..251b5946
--- /dev/null
+++ b/project/test_intro_login_parity.gd.uid
@@ -0,0 +1 @@
+uid://km7dpb1cw77
diff --git a/project/test_intro_select_parity.gd b/project/test_intro_select_parity.gd
new file mode 100644
index 00000000..3102703f
--- /dev/null
+++ b/project/test_intro_select_parity.gd
@@ -0,0 +1,124 @@
+# test_intro_select_parity.gd —— 40250 3D 角色选择大厅回归测试
+extends SceneTree
+
+const SystemClass = preload("res://intro_select_phase.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_intro_select_parity (Batch 29 Direction 3) ===")
+ _test_slot_rotations_and_empty_selection()
+ _test_character_selection_and_stepping_motion()
+ _test_character_card_data()
+ _test_start_game_validation()
+ _test_7digit_pin_character_deletion()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_slot_rotations_and_empty_selection() -> void:
+ print("\n--- Test 1: Slot Rotations & Empty Slot Handling ---")
+ var sel = SystemClass.new()
+
+ _assert(sel.character_slots.size() == 4, "Total 4 character slots")
+ _assert(SystemClass.SLOT_ROTATION == [135.0, 225.0, 315.0, 45.0], "Official 4-slot rotation angles match")
+
+ # Select empty slot 0
+ var r0 = sel.select_slot(0)
+ _assert(r0["is_empty_slot"] == true, "Slot 0 is empty")
+ _assert(r0["action"] == "PROMPT_CREATE", "Empty slot prompts to CREATE")
+ _assert(sel.is_character_stepped_forward == false, "No step forward motion for empty slot")
+
+func _test_character_selection_and_stepping_motion() -> void:
+ print("\n--- Test 2: Character Selection & Step Forward Motion ---")
+ var sel = SystemClass.new()
+ var warrior = {
+ "name": "DragonSlayer",
+ "level": 75,
+ "job": SystemClass.JOB_WARRIOR,
+ "horse_level": 21,
+ "playtime_min": 12000,
+ "delete_code": "7654321",
+ "stats": { "con": 90, "int": 10, "str": 90, "dex": 40 }
+ }
+ sel.set_character_slot(1, warrior)
+
+ var r1 = sel.select_slot(1)
+ _assert(r1["success"] == true, "Selected slot 1")
+ _assert(r1["is_empty_slot"] == false, "Slot 1 is populated")
+ _assert(sel.is_character_stepped_forward == true, "Character stepped forward (MOTION_INTRO_SELECTED)")
+ _assert(r1["rotation_deg"] == 225.0, "Slot 1 rotation is 225 degrees")
+
+func _test_character_card_data() -> void:
+ print("\n--- Test 3: Character Card Attributes ---")
+ var sel = SystemClass.new()
+ var sura = {
+ "name": "DarkMage",
+ "level": 60,
+ "job": SystemClass.JOB_SURA,
+ "horse_level": 11,
+ "playtime_min": 5000,
+ "stats": { "con": 50, "int": 90, "str": 40, "dex": 20 }
+ }
+ sel.set_character_slot(0, sura)
+ sel.select_slot(0)
+
+ var card = sel.get_selected_character_card()
+ _assert(card["name"] == "DarkMage", "Card name is DarkMage")
+ _assert(card["level"] == 60, "Card level is 60")
+ _assert(card["job_name"].contains("修罗"), "Job name is Sura")
+ _assert(card["stats"]["int"] == 90, "INT stat is 90")
+
+func _test_start_game_validation() -> void:
+ print("\n--- Test 4: Start Game Flow ---")
+ var sel = SystemClass.new()
+
+ # Start on empty slot -> Rejected
+ sel.select_slot(2) # empty
+ var r_fail = sel.start_game()
+ _assert(r_fail["error_code"] == "EMPTY_SLOT", "Reject start game on empty slot")
+
+ # Populate and start
+ sel.set_character_slot(2, { "name": "Ninja", "level": 10, "job": 1 })
+ sel.select_slot(2)
+ var r_ok = sel.start_game()
+ _assert(r_ok["success"] == true, "Start game succeeded with valid character")
+ _assert(r_ok["character"]["name"] == "Ninja", "Transferred character into game")
+
+func _test_7digit_pin_character_deletion() -> void:
+ print("\n--- Test 5: 7-Digit Security PIN Character Deletion ---")
+ var sel = SystemClass.new()
+ var char_to_del = {
+ "name": "DeleteMe",
+ "level": 1,
+ "job": 0,
+ "delete_code": "1234567"
+ }
+ sel.set_character_slot(3, char_to_del)
+
+ # 1. Invalid PIN length
+ var r_len = sel.delete_character(3, "123")
+ _assert(r_len["error_code"] == "INVALID_PIN_LENGTH", "Reject deletion if PIN != 7 digits")
+
+ # 2. Wrong PIN
+ var r_wrong = sel.delete_character(3, "9999999")
+ _assert(r_wrong["error_code"] == "WRONG_DELETE_PIN", "Reject deletion on incorrect PIN")
+ _assert(sel.character_slots[3] != null, "Character preserved on failed PIN")
+
+ # 3. Correct PIN (1234567)
+ var r_ok = sel.delete_character(3, "1234567")
+ _assert(r_ok["success"] == true, "Character deleted on correct 7-digit PIN")
+ _assert(sel.character_slots[3] == null, "Slot 3 is now null/empty")
diff --git a/project/test_intro_select_parity.gd.uid b/project/test_intro_select_parity.gd.uid
new file mode 100644
index 00000000..e1bea47e
--- /dev/null
+++ b/project/test_intro_select_parity.gd.uid
@@ -0,0 +1 @@
+uid://dk6oehay6271o
diff --git a/project/test_inventory_expansion_parity.gd b/project/test_inventory_expansion_parity.gd
new file mode 100644
index 00000000..34d1360c
--- /dev/null
+++ b/project/test_inventory_expansion_parity.gd
@@ -0,0 +1,147 @@
+# test_inventory_expansion_parity.gd —— 40250 背包扩展凭证与阶梯解锁系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://inventory_expansion_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_inventory_expansion_parity (Batch 26 Direction 4) ===")
+ _test_default_unlocked_baseline()
+ _test_key_cost_curve()
+ _test_stage_by_stage_unlocking()
+ _test_locked_slot_item_placement_rejection()
+ _test_multislot_item_boundary_and_lock_check()
+ _test_full_180_slots_unlock_completion()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_default_unlocked_baseline() -> void:
+ print("\n--- Test 1: Default Baseline (Pages 1 & 2 = 90 Slots Unlocked) ---")
+ var inv = SystemClass.new()
+ _assert(inv.get_unlocked_slot_count() == 90, "Baseline has 90 unlocked slots (2 pages)")
+ _assert(inv.is_slot_unlocked(0) == true, "Slot 0 (Page 1) is unlocked")
+ _assert(inv.is_slot_unlocked(44) == true, "Slot 44 (Page 1 end) is unlocked")
+ _assert(inv.is_slot_unlocked(45) == true, "Slot 45 (Page 2 start) is unlocked")
+ _assert(inv.is_slot_unlocked(89) == true, "Slot 89 (Page 2 end) is unlocked")
+
+ # Locked slots on Pages 3 & 4
+ _assert(inv.is_slot_unlocked(90) == false, "Slot 90 (Page 3 start) is locked")
+ _assert(inv.is_slot_unlocked(134) == false, "Slot 134 (Page 3 end) is locked")
+ _assert(inv.is_slot_unlocked(135) == false, "Slot 135 (Page 4 start) is locked")
+ _assert(inv.is_slot_unlocked(179) == false, "Slot 179 (Page 4 end) is locked")
+
+func _test_key_cost_curve() -> void:
+ print("\n--- Test 2: 18-Stage Key Cost Curve (Official 40250) ---")
+ var inv = SystemClass.new()
+ _assert(inv.get_next_stage_key_cost() == 1, "Stage 1 costs 1 key")
+
+ inv.unlocked_stages = 5
+ _assert(inv.get_next_stage_key_cost() == 1, "Stage 6 costs 1 key")
+
+ inv.unlocked_stages = 6
+ _assert(inv.get_next_stage_key_cost() == 2, "Stage 7 costs 2 keys")
+
+ inv.unlocked_stages = 11
+ _assert(inv.get_next_stage_key_cost() == 2, "Stage 12 costs 2 keys")
+
+ inv.unlocked_stages = 12
+ _assert(inv.get_next_stage_key_cost() == 3, "Stage 13 costs 3 keys")
+
+ inv.unlocked_stages = 17
+ _assert(inv.get_next_stage_key_cost() == 4, "Stage 18 costs 4 keys")
+
+ # Sum of all stages
+ var total_keys := 0
+ for cost in SystemClass.STAGE_KEY_COSTS:
+ total_keys += cost
+ _assert(total_keys == 37, "Total keys for all 18 stages is exactly 37 keys")
+
+func _test_stage_by_stage_unlocking() -> void:
+ print("\n--- Test 3: Stage-by-Stage Unlocking and Key Deduction ---")
+ var inv = SystemClass.new()
+
+ # Insufficient keys
+ var r_fail = inv.unlock_next_stage(0)
+ _assert(r_fail["error_code"] == "INSUFFICIENT_KEYS", "Reject unlock if keys < required")
+
+ # Unlock stage 1 (costs 1 key)
+ var r_stage1 = inv.unlock_next_stage(10)
+ _assert(r_stage1["success"] == true, "Stage 1 unlocked")
+ _assert(r_stage1["keys_deducted"] == 1, "1 key deducted")
+ _assert(r_stage1["remaining_keys"] == 9, "9 keys remain")
+ _assert(r_stage1["unlocked_slots"] == 95, "Unlocked slots increased to 95")
+ _assert(inv.is_slot_unlocked(90) == true, "Slot 90 now unlocked")
+ _assert(inv.is_slot_unlocked(94) == true, "Slot 94 now unlocked")
+ _assert(inv.is_slot_unlocked(95) == false, "Slot 95 still locked")
+
+func _test_locked_slot_item_placement_rejection() -> void:
+ print("\n--- Test 4: Locked Slot Item Placement Rejection ---")
+ var inv = SystemClass.new()
+ var sword = { "vnum": 10, "name": "Sword+0" }
+
+ # Put into unlocked slot 0
+ var r_ok = inv.put_item(0, sword, 1)
+ _assert(r_ok["success"] == true, "Placed item into unlocked slot 0")
+
+ # Put into locked slot 90
+ var r_lock = inv.put_item(90, sword, 1)
+ _assert(r_lock["error_code"] == "SLOT_LOCKED", "Reject putting item into locked slot 90")
+
+func _test_multislot_item_boundary_and_lock_check() -> void:
+ print("\n--- Test 5: Multi-Slot Item Boundary & Partial Lock Detection ---")
+ var inv = SystemClass.new()
+ var greatsword = { "vnum": 3000, "name": "Glaive+0" } # Height = 3
+
+ # Slot 80 is Page 2, row 7 (cells 80, 85, 90).
+ # But row 7 + 3 = 10 > 9 -> PAGE_OVERFLOW!
+ var r_overflow = inv.can_place_item_at(80, 3)
+ _assert(r_overflow["error_code"] == "PAGE_OVERFLOW", "Reject multi-slot item crossing page bottom")
+
+ # Slot 70 is Page 2, row 5 (cells 70, 75, 80). row 5 + 3 = 8 <= 9 -> OK!
+ var r_valid = inv.can_place_item_at(70, 3)
+ _assert(r_valid["can_place"] == true, "Can place 3-slot item safely inside page 2")
+
+ # Now unlock Stage 1 (slots 90..94 unlocked)
+ inv.unlock_next_stage(1)
+ # Try to place 2-slot item at slot 91 (needs 91 and 96; 91 is unlocked, but 96 is locked!)
+ var r_part_locked = inv.can_place_item_at(91, 2)
+ _assert(r_part_locked["error_code"] == "SLOT_LOCKED", "Reject multi-slot item when lower cell (96) is locked")
+
+ # But 1-slot item at 91 is fine
+ var r_single_ok = inv.can_place_item_at(91, 1)
+ _assert(r_single_ok["can_place"] == true, "Single slot item at 91 can be placed")
+
+func _test_full_180_slots_unlock_completion() -> void:
+ print("\n--- Test 6: Full 180 Slots Unlocking (All 18 Stages) ---")
+ var inv = SystemClass.new()
+ var total_keys_available := 50
+
+ # Unlock all 18 stages
+ for s in range(18):
+ var res = inv.unlock_next_stage(total_keys_available)
+ _assert(res["success"] == true, "Stage %d unlocked successfully" % (s + 1))
+ total_keys_available = res["remaining_keys"]
+
+ _assert(inv.unlocked_stages == 18, "All 18 stages unlocked")
+ _assert(inv.get_unlocked_slot_count() == 180, "All 180 slots unlocked")
+ _assert(inv.is_slot_unlocked(179) == true, "Final slot 179 is unlocked")
+ _assert(total_keys_available == 50 - 37, "Remaining keys is 13 (50 - 37)")
+
+ # Attempting 19th stage
+ var r_max = inv.unlock_next_stage(total_keys_available)
+ _assert(r_max["error_code"] == "ALREADY_MAX_EXPANDED", "Reject unlocking beyond 18 stages")
diff --git a/project/test_inventory_expansion_parity.gd.uid b/project/test_inventory_expansion_parity.gd.uid
new file mode 100644
index 00000000..a66841a5
--- /dev/null
+++ b/project/test_inventory_expansion_parity.gd.uid
@@ -0,0 +1 @@
+uid://bk2w3st68coxe
diff --git a/project/test_inventory_sort_parity.gd b/project/test_inventory_sort_parity.gd
new file mode 100644
index 00000000..7ba58806
--- /dev/null
+++ b/project/test_inventory_sort_parity.gd
@@ -0,0 +1,119 @@
+# test_inventory_sort_parity.gd —— 40250 多页背包与智能自动整理 1:1 纯净回归测试套件
+extends SceneTree
+
+const InventorySortSystem = preload("res://inventory_sort_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_inventory_sort_parity (Direction 2: Inventory Sort & Stacking 1:1) ===")
+ _test_page_switching()
+ _test_stack_merging()
+ _test_category_sorting_and_compaction()
+ _finish()
+
+func _test_page_switching() -> void:
+ print("\n--- Test 1: Multi-Page Switching (Page 0 & Page 1, 90 Slots) ---")
+ var iss := InventorySortSystem.new()
+
+ var page_signal := {"fired": false, "page": -1}
+ iss.page_switched.connect(func(p: int):
+ page_signal["fired"] = true
+ page_signal["page"] = p
+ )
+
+ # Invalid page
+ var r_bad = iss.switch_page(2)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "INVALID_PAGE", "Page 2 rejected (> max page 1)")
+
+ # Page 0 (Slots 0..44)
+ var r0 = iss.switch_page(0)
+ _assert(r0["ok"] == true, "Page 0 switched successfully")
+ _assert(r0["slot_start"] == 0 and r0["slot_end"] == 44, "Page 0 covers slots 0 to 44")
+ _assert(page_signal["fired"] == true and page_signal["page"] == 0, "page_switched signal fired for 0")
+
+ # Page 1 (Slots 45..89)
+ var r1 = iss.switch_page(1)
+ _assert(r1["ok"] == true, "Page 1 switched successfully")
+ _assert(r1["slot_start"] == 45 and r1["slot_end"] == 89, "Page 1 covers slots 45 to 89")
+ _assert(page_signal["page"] == 1, "page_switched signal fired for 1")
+
+func _test_stack_merging() -> void:
+ print("\n--- Test 2: Auto-Stack Merging (Max 200 Limit) ---")
+ var iss := InventorySortSystem.new()
+ var inv: Array = [
+ {"vnum": 27003, "name": "红药水(大)", "item_type": "potion", "count": 50}, # Slot 0
+ null, # Slot 1
+ {"vnum": 27003, "name": "红药水(大)", "item_type": "potion", "count": 80}, # Slot 2
+ {"vnum": 27003, "name": "红药水(大)", "item_type": "potion", "count": 100} # Slot 3
+ ]
+
+ var merged = iss.merge_stacks(inv)
+ _assert(merged == 2, "2 stack merges performed")
+
+ # Slot 0 should have 50 + 80 + 70 = 200 (max cap)
+ _assert(inv[0]["count"] == 200, "Slot 0 merged to max 200")
+ # Slot 2 fully consumed into slot 0
+ _assert(inv[2] == null, "Slot 2 consumed and set to null")
+ # Slot 3 had 100, gave 70 to slot 0 -> 30 remaining
+ _assert(inv[3] != null and inv[3]["count"] == 30, "Slot 3 has 30 remaining count")
+
+func _test_category_sorting_and_compaction() -> void:
+ print("\n--- Test 3: Category Sorting (Weapons -> Armors -> Consumables -> Materials -> Chests) ---")
+ var iss := InventorySortSystem.new()
+
+ var sort_signal := {"fired": false, "items": 0, "merged": 0}
+ iss.inventory_sorted.connect(func(cnt: int, m: int):
+ sort_signal["fired"] = true
+ sort_signal["items"] = cnt
+ sort_signal["merged"] = m
+ )
+
+ # Fragmented messy inventory
+ var inv: Array = [
+ {"vnum": 71084, "name": "高级附魔石", "item_type": "material", "count": 5}, # Mat
+ null,
+ {"vnum": 169, "name": "战神之剑+9", "item_type": "weapon"}, # Weapon
+ {"vnum": 27003, "name": "红药水(大)", "item_type": "potion", "count": 10}, # Potion
+ null,
+ {"vnum": 11299, "name": "黑钢甲+9", "item_type": "armor"}, # Armor
+ {"vnum": 50006, "name": "九尾宝箱", "item_type": "chest"} # Chest
+ ]
+
+ var res = iss.sort_inventory(inv)
+ _assert(res["ok"] == true, "sort_inventory succeeded")
+ _assert(res["total_items"] == 5, "5 total non-empty items sorted")
+ _assert(sort_signal["fired"] == true and sort_signal["items"] == 5, "inventory_sorted signal fired")
+
+ # Check exact order:
+ # 1. Weapon (169)
+ # 2. Armor (11299)
+ # 3. Consumable (27003)
+ # 4. Material (71084)
+ # 5. Chest (50006)
+ _assert(inv[0] != null and inv[0]["vnum"] == 169, "Slot 0 is Weapon (169)")
+ _assert(inv[1] != null and inv[1]["vnum"] == 11299, "Slot 1 is Armor (11299)")
+ _assert(inv[2] != null and inv[2]["vnum"] == 27003, "Slot 2 is Potion (27003)")
+ _assert(inv[3] != null and inv[3]["vnum"] == 71084, "Slot 3 is Material (71084)")
+ _assert(inv[4] != null and inv[4]["vnum"] == 50006, "Slot 4 is Chest (50006)")
+
+ # Remaining slots compacted to null
+ _assert(inv[5] == null and inv[6] == null, "Trailing slots compacted to null")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Inventory Sort Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_inventory_sort_parity.gd.uid b/project/test_inventory_sort_parity.gd.uid
new file mode 100644
index 00000000..5a9dfda2
--- /dev/null
+++ b/project/test_inventory_sort_parity.gd.uid
@@ -0,0 +1 @@
+uid://ck1abvqshw10w
diff --git a/project/test_item_attr_parity.gd b/project/test_item_attr_parity.gd
new file mode 100644
index 00000000..9cce86c4
--- /dev/null
+++ b/project/test_item_attr_parity.gd
@@ -0,0 +1,138 @@
+# test_item_attr_parity.gd —— 验证方向 1:装备附魔、洗炼与祝福宝珠 40250 1:1 对齐
+extends SceneTree
+
+const ItemAttrSystem = preload("res://item_attr_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_item_attr_parity (Direction 1: Item Add/Change Bonus 1:1) ===")
+ test_scroll_detection()
+ test_can_have_attributes()
+ test_add_attribute_parity()
+ test_change_attribute_parity()
+ test_blessing_marble_parity()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_scroll_detection() -> void:
+ print("\n--- Test 1: Scroll Vnum Detection (71084, 71085, 70024) ---")
+ _assert(ItemAttrSystem.is_attr_scroll(71085), "71085 is detected as Add Attribute Scroll")
+ _assert(ItemAttrSystem.is_attr_scroll(71084), "71084 is detected as Change Attribute Scroll")
+ _assert(ItemAttrSystem.is_attr_scroll(70024), "70024 is detected as Blessing Marble")
+ _assert(not ItemAttrSystem.is_attr_scroll(27001), "Normal potion is not an attr scroll")
+ _assert(not ItemAttrSystem.is_attr_scroll(10), "Sword is not an attr scroll")
+
+func test_can_have_attributes() -> void:
+ print("\n--- Test 2: Target Equipment Compatibility (Weapon/Armor vs Arrow) ---")
+ var weapon := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0} # 剑
+ var armor := {"type": ItemAttrSystem.ITEM_TYPE_ARMOR, "sub_type": 0} # 衣服
+ var arrow := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 5} # 箭矢
+ var potion := {"type": 3, "sub_type": 0} # 消耗品
+
+ _assert(ItemAttrSystem.can_have_attributes(weapon), "Weapon can have attributes")
+ _assert(ItemAttrSystem.can_have_attributes(armor), "Armor can have attributes")
+ _assert(not ItemAttrSystem.can_have_attributes(arrow), "Arrow (WEAPON_ARROW) cannot have attributes")
+ _assert(not ItemAttrSystem.can_have_attributes(potion), "Potion cannot have attributes")
+
+func test_add_attribute_parity() -> void:
+ print("\n--- Test 3: 40250 Add Attribute Progression (1..4 Attrs & Cap) ---")
+ var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0}
+ var sword_item := {"vnum": 10, "attrs": []}
+
+ # 1st attribute
+ var res1 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true)
+ _assert(res1.ok and res1.code == "SUCCESS", "1st attribute added successfully")
+ _assert(sword_item.attrs.size() == 1, "Sword has 1 attribute now")
+
+ # 2nd attribute
+ var res2 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true)
+ _assert(res2.ok and res2.code == "SUCCESS", "2nd attribute added successfully")
+ _assert(sword_item.attrs.size() == 2, "Sword has 2 attributes now")
+ _assert(sword_item.attrs[0].type != sword_item.attrs[1].type, "1st and 2nd attribute types are distinct (no duplicate)")
+
+ # 3rd attribute
+ var res3 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true)
+ _assert(res3.ok and res3.code == "SUCCESS", "3rd attribute added successfully")
+ _assert(sword_item.attrs.size() == 3, "Sword has 3 attributes now")
+
+ # 4th attribute
+ var res4 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true)
+ _assert(res4.ok and res4.code == "SUCCESS", "4th attribute added successfully")
+ _assert(sword_item.attrs.size() == 4, "Sword has 4 attributes now")
+
+ # 5th attempt with normal scroll 71085 MUST fail (40250 char_item.cpp:4665 item2->GetAttributeCount() < 4)
+ var res5 := ItemAttrSystem.apply_add_attribute(sword_item, weapon_proto, true)
+ _assert(not res5.ok and res5.code == "MAX_ATTR_REACHED", "Normal scroll cannot add 5th attribute (MAX_ATTR_REACHED)")
+ _assert(sword_item.attrs.size() == 4, "Sword remains at 4 attributes")
+
+func test_change_attribute_parity() -> void:
+ print("\n--- Test 4: 40250 Change Attribute (Reroll All Attributes) ---")
+ var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0}
+ var empty_item := {"vnum": 10, "attrs": []}
+
+ # Fails on item with 0 attributes (40250 char_item.cpp:4560 item2->GetAttributeCount() == 0)
+ var res_empty := ItemAttrSystem.apply_change_attribute(empty_item, weapon_proto)
+ _assert(not res_empty.ok and res_empty.code == "NO_ATTRIBUTES", "Change attribute fails when item has 0 attributes")
+
+ var geared_item := {
+ "vnum": 10,
+ "attrs": [
+ {"type": 1, "value": 500},
+ {"type": 15, "value": 10},
+ {"type": 16, "value": 10}
+ ]
+ }
+ var res_reroll := ItemAttrSystem.apply_change_attribute(geared_item, weapon_proto)
+ _assert(res_reroll.ok and res_reroll.code == "SUCCESS", "Change attribute rerolls successfully")
+ _assert(geared_item.attrs.size() == 3, "Item still has exactly 3 attributes after reroll")
+
+ # Verify all rerolled attribute types are unique
+ var types := {}
+ var has_duplicate := false
+ for a in geared_item.attrs:
+ if types.has(a.type):
+ has_duplicate = true
+ types[a.type] = true
+ _assert(not has_duplicate, "All rerolled attribute types are distinct")
+
+func test_blessing_marble_parity() -> void:
+ print("\n--- Test 5: 40250 Blessing Marble 70024 (5th Attribute Lock) ---")
+ var weapon_proto := {"type": ItemAttrSystem.ITEM_TYPE_WEAPON, "sub_type": 0}
+
+ # Item with 3 attributes -> marble fails (40250 char_item.cpp:4738 item2->GetAttributeCount() == 4)
+ var item_3_attrs := {
+ "vnum": 10,
+ "attrs": [
+ {"type": 1, "value": 500},
+ {"type": 2, "value": 200},
+ {"type": 3, "value": 4}
+ ]
+ }
+ var res_marble_fail := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true)
+ _assert(not res_marble_fail.ok and res_marble_fail.code == "NEED_FOUR_ATTRS", "Blessing marble fails on item with less than 4 attributes")
+
+ # Item with 4 attributes -> marble succeeds and adds 5th
+ item_3_attrs.attrs.append({"type": 4, "value": 4})
+ var res_marble_ok := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true)
+ _assert(res_marble_ok.ok and res_marble_ok.code == "SUCCESS", "Blessing marble successfully unlocks 5th attribute")
+ _assert(item_3_attrs.attrs.size() == 5, "Item now has 5 attributes")
+
+ # Marble fails on item that already has 5 attributes
+ var res_marble_cap := ItemAttrSystem.apply_blessing_marble(item_3_attrs, weapon_proto, true)
+ _assert(not res_marble_cap.ok and res_marble_cap.code == "ALREADY_FIVE_ATTRS", "Blessing marble cannot exceed 5 attributes")
diff --git a/project/test_item_attr_parity.gd.uid b/project/test_item_attr_parity.gd.uid
new file mode 100644
index 00000000..040ea626
--- /dev/null
+++ b/project/test_item_attr_parity.gd.uid
@@ -0,0 +1 @@
+uid://b8va32e5dxn5x
diff --git a/project/test_item_enchant_manager_parity.gd b/project/test_item_enchant_manager_parity.gd
new file mode 100644
index 00000000..b80575a8
--- /dev/null
+++ b/project/test_item_enchant_manager_parity.gd
@@ -0,0 +1,173 @@
+# test_item_enchant_manager_parity.gd —— 40250 装备附魔洗炼与稀有词条重铸 1:1 回归测试套件
+extends SceneTree
+
+const ItemEnchantSystem = preload("res://item_enchant_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_item_enchant_manager_parity (Direction 3: Item Enchant & Rare Reforge 1:1) ===")
+ _test_scroll_and_item_compatibility()
+ _test_add_1_to_4_attributes()
+ _test_blessing_marble_5th_attribute()
+ _test_change_attributes()
+ _test_rare_6th_and_7th_attributes()
+ _finish()
+
+func _test_scroll_and_item_compatibility() -> void:
+ print("\n--- Test 1: Scroll & Equipment Compatibility ---")
+ _assert(ItemEnchantSystem.is_enchant_scroll(71085), "71085 is enchant scroll")
+ _assert(ItemEnchantSystem.is_enchant_scroll(71084), "71084 is enchant scroll")
+ _assert(ItemEnchantSystem.is_enchant_scroll(70024), "70024 is enchant scroll")
+ _assert(ItemEnchantSystem.is_enchant_scroll(71151), "71151 is enchant scroll")
+ _assert(ItemEnchantSystem.is_enchant_scroll(71152), "71152 is enchant scroll")
+ _assert(not ItemEnchantSystem.is_enchant_scroll(27001), "Potion is not enchant scroll")
+
+ var sword := {"vnum": 10, "type": 1, "attrs": []}
+ var arrow := {"vnum": 8000, "type": 1, "attrs": []}
+ var locked_armor := {"vnum": 11299, "type": 2, "is_locked": true, "attrs": []}
+
+ _assert(ItemEnchantSystem.is_enchantable_item(sword), "Sword is enchantable")
+ _assert(not ItemEnchantSystem.is_enchantable_item(arrow), "Arrow (8000) cannot be enchanted")
+ _assert(not ItemEnchantSystem.is_enchantable_item(locked_armor), "Locked armor is protected from enchanting")
+
+func _test_add_1_to_4_attributes() -> void:
+ print("\n--- Test 2: Add 1st~4th Attributes (71085) ---")
+ var sys := ItemEnchantSystem.new()
+ var inv: Array = [
+ {"vnum": ItemEnchantSystem.VNUM_ATTR_ADD, "name": "添加属性秘籍", "count": 10},
+ {"vnum": 10, "name": "巨剑+9", "type": 1, "attrs": []}
+ ]
+
+ var sig_data := {"fired": false, "slot": -1, "success": false}
+ sys.enchant_applied.connect(func(slot: int, _svnum: int, succ: bool, _attrs: Array):
+ sig_data["fired"] = true
+ sig_data["slot"] = slot
+ sig_data["success"] = succ
+ )
+
+ # Add 1st attribute (force_success = true)
+ var res1 = sys.apply_scroll(0, 1, inv, true)
+ _assert(res1["ok"] == true and res1["success"] == true, "1st attribute added")
+ _assert(inv[1]["attrs"].size() == 1, "Sword has 1 attribute")
+ _assert(inv[0]["count"] == 9, "Scroll count decreased 10 -> 9")
+ _assert(sig_data["fired"] == true and sig_data["slot"] == 1, "enchant_applied signal emitted")
+
+ # Add 2nd, 3rd, 4th attributes
+ sys.apply_scroll(0, 1, inv, true)
+ sys.apply_scroll(0, 1, inv, true)
+ sys.apply_scroll(0, 1, inv, true)
+ _assert(inv[1]["attrs"].size() == 4, "Sword has 4 attributes")
+ _assert(inv[0]["count"] == 6, "Scroll count is now 6")
+
+ # 5th attempt with 71085 must be rejected
+ var res5 = sys.apply_scroll(0, 1, inv, true)
+ _assert(res5["ok"] == false and res5["reason"] == "MAX_BASE_ATTRS_REACHED", "5th attempt rejected by 71085")
+ _assert(inv[0]["count"] == 6, "Scroll count NOT consumed on failure")
+
+func _test_blessing_marble_5th_attribute() -> void:
+ print("\n--- Test 3: Blessing Marble 5th Attribute (70024) ---")
+ var sys := ItemEnchantSystem.new()
+ var inv: Array = [
+ {"vnum": ItemEnchantSystem.VNUM_BLESSING_MARBLE, "name": "祝福宝珠", "count": 2},
+ {"vnum": 10, "name": "巨剑+9", "type": 1, "attrs": [{"type": 1, "name": "最大生命", "value": 2000}]} # only 1 attr
+ ]
+
+ # Using on item with < 4 attrs must fail
+ var fail_res = sys.apply_scroll(0, 1, inv, true)
+ _assert(fail_res["ok"] == false and fail_res["reason"] == "NEED_4_ATTRS_FIRST", "Blessing marble requires 4 attrs first")
+
+ # Fill up to 4 attrs
+ inv[1]["attrs"] = [
+ {"type": 1, "name": "HP", "value": 2000},
+ {"type": 5, "name": "STR", "value": 12},
+ {"type": 15, "name": "Crit", "value": 10},
+ {"type": 16, "name": "Pen", "value": 10}
+ ]
+
+ # Now use blessing marble
+ var res5 = sys.apply_scroll(0, 1, inv, true)
+ _assert(res5["ok"] == true and res5["success"] == true, "5th attribute added by Blessing Marble")
+ _assert(inv[1]["attrs"].size() == 5, "Item now has exactly 5 attributes")
+ _assert(inv[0]["count"] == 1, "Marble consumed (2 -> 1)")
+
+ # 6th attempt with blessing marble must be rejected
+ var res6 = sys.apply_scroll(0, 1, inv, true)
+ _assert(res6["ok"] == false and res6["reason"] == "ALREADY_HAS_5_ATTRS", "Blessing marble rejected when 5 attrs exist")
+
+func _test_change_attributes() -> void:
+ print("\n--- Test 4: Change All Attributes (71084) ---")
+ var sys := ItemEnchantSystem.new()
+ var old_attrs: Array = [
+ {"type": 1, "name": "HP", "value": 500},
+ {"type": 2, "name": "SP", "value": 100},
+ {"type": 7, "name": "AtkSpeed", "value": 2}
+ ]
+ var inv: Array = [
+ {"vnum": ItemEnchantSystem.VNUM_ATTR_CHANGE, "name": "属性改变秘籍", "count": 1},
+ {"vnum": 10, "name": "巨剑+9", "type": 1, "attrs": old_attrs.duplicate(true)}
+ ]
+
+ var res = sys.apply_scroll(0, 1, inv)
+ _assert(res["ok"] == true and res["success"] == true, "Attributes rerolled")
+ _assert(inv[1]["attrs"].size() == 3, "Still has exactly 3 attributes")
+ _assert(inv[0] == null, "Single scroll consumed to null")
+
+func _test_rare_6th_and_7th_attributes() -> void:
+ print("\n--- Test 5: 6th & 7th Rare Attributes (71151 / 71152) ---")
+ var sys := ItemEnchantSystem.new()
+ var inv: Array = [
+ {"vnum": ItemEnchantSystem.VNUM_RARE_ADD, "name": "稀有属性秘籍", "count": 3},
+ {"vnum": ItemEnchantSystem.VNUM_RARE_CHANGE, "name": "稀有属性改变", "count": 1},
+ {"vnum": 10, "name": "巨剑+9", "type": 1, "attrs": [{"type": 1, "name": "HP", "value": 2000}], "rare_attrs": []}
+ ]
+
+ var rare_sig := {"fired": false, "rares": []}
+ sys.rare_enchant_applied.connect(func(_slot: int, _succ: bool, rares: Array):
+ rare_sig["fired"] = true
+ rare_sig["rares"] = rares
+ )
+
+ # Add 6th attribute (1st rare)
+ var r1 = sys.apply_scroll(0, 2, inv, true)
+ _assert(r1["ok"] == true and r1["success"] == true, "6th attribute (1st rare) added")
+ _assert(inv[2]["rare_attrs"].size() == 1, "1 rare attribute present")
+ _assert(inv[2]["attrs"].size() == 1, "Base attrs untouched")
+ _assert(rare_sig["fired"] == true, "rare_enchant_applied signal emitted")
+
+ # Add 7th attribute (2nd rare)
+ var r2 = sys.apply_scroll(0, 2, inv, true)
+ _assert(r2["ok"] == true and r2["success"] == true, "7th attribute (2nd rare) added")
+ _assert(inv[2]["rare_attrs"].size() == 2, "2 rare attributes present")
+
+ # 3rd rare must be rejected
+ var r3 = sys.apply_scroll(0, 2, inv, true)
+ _assert(r3["ok"] == false and r3["reason"] == "MAX_RARE_ATTRS", "3rd rare rejected")
+
+ # Change rare attributes (71152)
+ var rc = sys.apply_scroll(1, 2, inv)
+ _assert(rc["ok"] == true and rc["success"] == true, "Rare attributes rerolled")
+ _assert(inv[2]["rare_attrs"].size() == 2, "2 rare attributes preserved after reroll")
+ _assert(inv[2]["attrs"].size() == 1, "Base attrs remain intact")
+ _assert(inv[1] == null, "71152 scroll consumed")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Item Enchant Manager Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Item Enchant Manager parity tests passed!")
+ quit(0)
diff --git a/project/test_item_enchant_manager_parity.gd.uid b/project/test_item_enchant_manager_parity.gd.uid
new file mode 100644
index 00000000..01dfd139
--- /dev/null
+++ b/project/test_item_enchant_manager_parity.gd.uid
@@ -0,0 +1 @@
+uid://dqxojn342f2r3
diff --git a/project/test_item_split_parity.gd b/project/test_item_split_parity.gd
new file mode 100644
index 00000000..4b4c887b
--- /dev/null
+++ b/project/test_item_split_parity.gd
@@ -0,0 +1,121 @@
+# test_item_split_parity.gd —— 40250 物品精准拆分与快捷操作 1:1 纯净回归测试套件
+extends SceneTree
+
+const ItemSplitSystem = preload("res://item_split_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_item_split_parity (Direction 3: Item Split & Quick Sell 1:1) ===")
+ _test_item_stack_splitting()
+ _test_quick_sell_to_shop()
+ _test_drop_safety_confirmation()
+ _finish()
+
+func _test_item_stack_splitting() -> void:
+ print("\n--- Test 1: Stack Splitting (Shift + Left Click) ---")
+ var iss := ItemSplitSystem.new()
+ var inv: Array = [
+ {"vnum": 27003, "name": "红药水(大)", "count": 200}, # Slot 0
+ {"vnum": 10, "name": "木剑+0", "count": 1}, # Slot 1 (single)
+ null, # Slot 2 (empty)
+ null # Slot 3 (empty)
+ ]
+
+ # 1. Split single count item
+ var r_single = iss.split_item(1, 1, 2, inv)
+ _assert(r_single["ok"] == false and r_single["reason"] == "NOT_SPLITTABLE", "Single count item rejected for split")
+
+ # 2. Split count >= total count
+ var r_overflow = iss.split_item(0, 200, 2, inv)
+ _assert(r_overflow["ok"] == false and r_overflow["reason"] == "INVALID_SPLIT_COUNT", "Split count >= total rejected")
+
+ # 3. Valid split: Split 50 from 200 into slot 2
+ var split_signal := {"fired": false, "src": -1, "dst": -1, "cnt": 0}
+ iss.item_split.connect(func(s: int, d: int, c: int):
+ split_signal["fired"] = true
+ split_signal["src"] = s
+ split_signal["dst"] = d
+ split_signal["cnt"] = c
+ )
+
+ var r_split = iss.split_item(0, 50, 2, inv)
+ _assert(r_split["ok"] == true, "Split 50 potions succeeded")
+ _assert(inv[0]["count"] == 150, "Source slot 0 has 150 remaining")
+ _assert(inv[2] != null and inv[2]["count"] == 50, "Destination slot 2 has 50")
+ _assert(split_signal["fired"] == true and split_signal["cnt"] == 50, "item_split signal fired")
+
+ # 4. Auto-find empty slot when target_slot is -1
+ var r_auto = iss.split_item(0, 30, -1, inv)
+ _assert(r_auto["ok"] == true, "Auto-find split succeeded")
+ _assert(r_auto["target_slot"] == 3, "Auto-placed into slot 3")
+ _assert(inv[3] != null and inv[3]["count"] == 30, "Slot 3 has 30 count")
+ _assert(inv[0]["count"] == 120, "Slot 0 has 120 remaining")
+
+func _test_quick_sell_to_shop() -> void:
+ print("\n--- Test 2: Quick Sell to NPC Shop (Ctrl + Right Click) ---")
+ var iss := ItemSplitSystem.new()
+ var player_data := {"gold": 1000}
+ var inv: Array = [
+ {"vnum": 10, "name": "木剑+0", "count": 1, "sale_price": 50, "is_locked": true}, # Locked
+ {"vnum": 27001, "name": "小红药水", "count": 20, "sale_price": 100} # 20 x 100 = 2000
+ ]
+
+ # 1. Locked item cannot be sold
+ var r_lock = iss.quick_sell_item(0, inv, player_data)
+ _assert(r_lock["ok"] == false and r_lock["reason"] == "ITEM_LOCKED", "Locked item quick sell blocked")
+
+ # 2. Valid quick sell
+ var sell_signal := {"fired": false, "gold": 0}
+ iss.item_quick_sold.connect(func(_s: int, g: int, _n: String):
+ sell_signal["fired"] = true
+ sell_signal["gold"] = g
+ )
+
+ var r_sell = iss.quick_sell_item(1, inv, player_data)
+ _assert(r_sell["ok"] == true, "Quick sell succeeded")
+ _assert(inv[1] == null, "Sold slot cleared to null")
+ _assert(player_data["gold"] == 3000, "Earned 2000 Yang (1000 -> 3000)")
+ _assert(sell_signal["fired"] == true and sell_signal["gold"] == 2000, "item_quick_sold signal fired")
+
+func _test_drop_safety_confirmation() -> void:
+ print("\n--- Test 3: Drop Safety Check for Valuable Equipment ---")
+ var iss := ItemSplitSystem.new()
+
+ # 1. Locked item
+ var locked_item := {"vnum": 169, "name": "战神之剑+9", "is_locked": true}
+ var r1 = iss.check_drop_safety(locked_item)
+ _assert(r1["can_drop"] == false and r1["reason"] == "ITEM_LOCKED", "Locked item drop blocked")
+
+ # 2. +9 equipment requires confirmation
+ var high_refine := {"vnum": 169, "name": "战神之剑+9", "refine_level": 9, "is_locked": false}
+ var r2 = iss.check_drop_safety(high_refine)
+ _assert(r2["can_drop"] == true and r2["require_confirm"] == true, "+9 weapon requires confirmation")
+
+ # 3. Item with bonus attributes requires confirmation
+ var bonus_item := {"vnum": 10, "name": "木剑+0", "attributes": [{"type": 1, "value": 500}], "is_locked": false}
+ var r3 = iss.check_drop_safety(bonus_item)
+ _assert(r3["can_drop"] == true and r3["require_confirm"] == true, "Item with bonuses requires confirmation")
+
+ # 4. Plain +0 item requires no confirmation
+ var plain_item := {"vnum": 10, "name": "木剑+0", "refine_level": 0, "is_locked": false}
+ var r4 = iss.check_drop_safety(plain_item)
+ _assert(r4["can_drop"] == true and r4["require_confirm"] == false, "Plain item drops without confirmation")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Item Split Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_item_split_parity.gd.uid b/project/test_item_split_parity.gd.uid
new file mode 100644
index 00000000..83833c50
--- /dev/null
+++ b/project/test_item_split_parity.gd.uid
@@ -0,0 +1 @@
+uid://e50i1puey42f
diff --git a/project/test_jewelry_socket_parity.gd b/project/test_jewelry_socket_parity.gd
new file mode 100644
index 00000000..dfa06f2f
--- /dev/null
+++ b/project/test_jewelry_socket_parity.gd
@@ -0,0 +1,130 @@
+# test_jewelry_socket_parity.gd —— 40250 首饰打孔与精炼宝石镶嵌 1:1 纯净回归测试套件
+extends SceneTree
+
+const JewelrySocketSystem = preload("res://jewelry_socket_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_jewelry_socket_parity (Direction 3: Jewelry Sockets 1:1) ===")
+ _test_accessory_validation_and_diamond()
+ _test_socket_drilling_progression()
+ _test_refined_gem_insertion_and_bonuses()
+ _test_endurance_decay_and_expiration()
+ _finish()
+
+func _test_accessory_validation_and_diamond() -> void:
+ print("\n--- Test 1: Accessory Validation & Diamond Check ---")
+ var sword := {"vnum": 10} # Weapon
+ var armor := {"vnum": 11200} # Armor
+ var ebony_earrings := {"vnum": 17109} # Earrings (17xxx)
+ var diamond := {"vnum": 50621} # Refined Diamond
+ var fake_stone := {"vnum": 28030} # Metin stone
+
+ # Non-accessory rejected
+ _assert(JewelrySocketSystem.is_accessory(sword) == false, "Sword is not an accessory")
+ _assert(JewelrySocketSystem.is_accessory(armor) == false, "Armor is not an accessory")
+ _assert(JewelrySocketSystem.is_accessory(ebony_earrings) == true, "Ebony Earrings is an accessory")
+
+ var res = JewelrySocketSystem.drill_socket(sword, diamond)
+ _assert(res["ok"] == false and res["reason"] == "NOT_ACCESSORY", "Cannot drill sockets on weapons")
+
+ # Non diamond rejected
+ res = JewelrySocketSystem.drill_socket(ebony_earrings, fake_stone)
+ _assert(res["ok"] == false and res["reason"] == "NOT_DIAMOND", "Cannot drill without refined diamond (50621)")
+
+func _test_socket_drilling_progression() -> void:
+ print("\n--- Test 2: Socket Drilling Progression (1~3 Sockets) ---")
+ var necklace := {"vnum": 16109, "socket_count": 0}
+ var diamond := {"vnum": 50621}
+
+ # Drill 1st socket
+ var res = JewelrySocketSystem.drill_socket(necklace, diamond, true)
+ _assert(res["ok"] == true and res["socket_count"] == 1, "1st socket drilled successfully")
+
+ # Drill 2nd socket
+ res = JewelrySocketSystem.drill_socket(necklace, diamond, true)
+ _assert(res["ok"] == true and res["socket_count"] == 2, "2nd socket drilled successfully")
+
+ # Drill 3rd socket
+ res = JewelrySocketSystem.drill_socket(necklace, diamond, true)
+ _assert(res["ok"] == true and res["socket_count"] == 3, "3rd socket drilled successfully")
+
+ # 4th socket attempt rejected (MAX_SOCKETS = 3)
+ res = JewelrySocketSystem.drill_socket(necklace, diamond, true)
+ _assert(res["ok"] == false and res["reason"] == "SOCKETS_FULL", "Cannot drill more than 3 sockets")
+
+func _test_refined_gem_insertion_and_bonuses() -> void:
+ print("\n--- Test 3: Refined Gem Insertion & Tiered Bonuses ---")
+ var bracelet := {
+ "vnum": 14109, # White Gold Bracelet (14xxx)
+ "socket_count": 3,
+ "sockets": [0, 0, 0],
+ "gem_times": [0.0, 0.0, 0.0]
+ }
+
+ var white_gold_gem := {"vnum": 50630} # White Gold (50630)
+ var wrong_gem := {"vnum": 50628} # Ebony (50628)
+
+ # Mismatched gem rejected when required
+ var res = JewelrySocketSystem.insert_gem(bracelet, wrong_gem, 50630)
+ _assert(res["ok"] == false and res["reason"] == "GEM_MISMATCH", "Mismatched gem rejected")
+
+ # Insert 1st gem (+10%)
+ res = JewelrySocketSystem.insert_gem(bracelet, white_gold_gem, 50630)
+ _assert(res["ok"] == true and res["slot"] == 0, "1st gem inserted into socket 0")
+ _assert(is_equal_approx(float(res["total_bonus_pct"]), 0.10), "1st gem bonus: +10%")
+
+ # Insert 2nd gem (+10%, total +20%)
+ res = JewelrySocketSystem.insert_gem(bracelet, white_gold_gem, 50630)
+ _assert(res["ok"] == true and res["slot"] == 1, "2nd gem inserted into socket 1")
+ _assert(is_equal_approx(float(res["total_bonus_pct"]), 0.20), "2nd gem bonus: total +20%")
+
+ # Insert 3rd gem (+20%, total +40%)
+ res = JewelrySocketSystem.insert_gem(bracelet, white_gold_gem, 50630)
+ _assert(res["ok"] == true and res["slot"] == 2, "3rd gem inserted into socket 2")
+ _assert(is_equal_approx(float(res["total_bonus_pct"]), 0.40), "3rd gem bonus: total +40%")
+
+ # Full sockets reject further insertion
+ res = JewelrySocketSystem.insert_gem(bracelet, white_gold_gem, 50630)
+ _assert(res["ok"] == false and res["reason"] == "NO_EMPTY_SOCKET", "Cannot insert into full sockets")
+
+func _test_endurance_decay_and_expiration() -> void:
+ print("\n--- Test 4: Endurance Decay & Gem Expiration ---")
+ var item := {
+ "vnum": 17109,
+ "socket_count": 1,
+ "sockets": [50628, 0, 0],
+ "gem_times": [10.0, 0.0, 0.0] # 10 seconds remaining
+ }
+
+ _assert(JewelrySocketSystem.count_active_gems(item) == 1, "Initially 1 active gem")
+
+ # Decay by 5 seconds
+ var res = JewelrySocketSystem.update_gem_endurance(item, 5.0)
+ _assert(res["active_gems"] == 1, "Still 1 active gem after 5s decay")
+ _assert(res["expired"].is_empty(), "No gems expired yet")
+
+ # Decay by another 6 seconds -> should expire
+ res = JewelrySocketSystem.update_gem_endurance(item, 6.0)
+ _assert(res["active_gems"] == 0, "0 active gems after expiration")
+ _assert(res["expired"] == [50628], "Gem 50628 expired and recorded")
+ _assert(item["sockets"][0] == 0, "Socket 0 cleared and available for re-insertion")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_jewelry_socket_parity.gd.uid b/project/test_jewelry_socket_parity.gd.uid
new file mode 100644
index 00000000..64630cb3
--- /dev/null
+++ b/project/test_jewelry_socket_parity.gd.uid
@@ -0,0 +1 @@
+uid://dyjtapy34gqbl
diff --git a/project/test_levelup_combat_parity.gd b/project/test_levelup_combat_parity.gd
new file mode 100644
index 00000000..b4817335
--- /dev/null
+++ b/project/test_levelup_combat_parity.gd
@@ -0,0 +1,175 @@
+# test_levelup_combat_parity.gd —— 40250 升级流与战斗打击感 1:1 纯净无头自检套件
+# 验证:
+# 1. POINT_LEVEL 跃迁升级检测
+# 2. EFFECT_LEVELUP 冲天金光粒子挂载 (level_up_beam)
+# 3. levelup1_1.wav 升级号角音效播放
+# 4. 升级瞬间 100% HP 与 SP 回满规则
+# 5. HUD 等级与加点通知
+# 6. 暴击 DAMAGE_CRITICAL 镜头瞬时微震 (camera.shake)
+# 7. 暴击大号金色高亮飘字与普通飘字样式分流
+extends SceneTree
+
+const NetPlay = preload("res://net_play.gd")
+const NetWorld = preload("res://net_world.gd")
+const DamageEffect = preload("res://damage_effect.gd")
+
+class FakeAudio extends Node:
+ var calls: Array[String] = []
+ func play_ui(name: String) -> void:
+ calls.append(name)
+
+class FakeCamera extends Camera3D:
+ var shake_calls: Array = []
+ func shake(strength: float, decay := 8.0) -> void:
+ shake_calls.append([strength, decay])
+
+class FakePlayerController extends Node:
+ signal target_selected(target: Node)
+ signal ground_item_clicked(vid: int)
+ signal hover_entity_changed(vid: int)
+ signal moved(pos: Vector3)
+ signal anim_state(state: String)
+ var player: Node3D
+
+class FakeHud extends Node:
+ var vitals: Array = []
+ var levels: Array[int] = []
+ var levelup_events: Array[int] = []
+
+ func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
+ vitals.append([hp, max_hp, sp, max_sp])
+ func set_stamina(_st: int, _max_st: int) -> void: pass
+ func set_exp(_exp: int, _next_exp: int) -> void: pass
+ func set_level(lvl: int) -> void:
+ levels.append(lvl)
+ func set_energy(_e: int, _max_e: int) -> void: pass
+ func on_level_up(lvl: int) -> void:
+ levelup_events.append(lvl)
+
+class FakeClient extends Node:
+ signal entity_spawned(e: Dictionary)
+ signal entity_despawned(vid: int)
+ signal entity_main_set(vid: int)
+ signal vitals_changed(vid: int)
+ signal entity_dead(vid: int)
+ signal damage(vid: int, amount: int, flag: int)
+ signal points_changed(p: Dictionary)
+ signal chat(type: int, vid: int, msg: String)
+ signal entity_moved(vid: int)
+ signal target_info(info: Dictionary)
+
+ func is_in_game() -> bool: return true
+ func get_main_vid() -> int: return 1001
+ func get_entity(_vid: int) -> Dictionary: return {}
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+
+var _to_free: Array[Node] = []
+
+func _init() -> void:
+ _run()
+ for n in _to_free:
+ if is_instance_valid(n):
+ n.queue_free()
+ if _fail == 0:
+ print("PASS: test_levelup_combat_parity (40250 LevelUp & Combat Parity)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed in test_levelup_combat_parity" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var fc := FakeClient.new()
+ get_root().add_child(fc)
+ var fa := FakeAudio.new()
+ get_root().add_child(fa)
+ var fcam := FakeCamera.new()
+ get_root().add_child(fcam)
+ var hud := FakeHud.new()
+ get_root().add_child(hud)
+
+ var player := Node3D.new()
+ player.name = "Player"
+ get_root().add_child(player)
+
+ var pc := FakePlayerController.new()
+ pc.player = player
+ get_root().add_child(pc)
+
+ var mount := Node3D.new()
+ get_root().add_child(mount)
+
+ var nw: Node = NetWorld.new()
+ get_root().add_child(nw)
+ nw.setup(fc, mount)
+ nw.set_camera(fcam)
+
+ var np: Node = NetPlay.new()
+ get_root().add_child(np)
+ np.setup(fc, pc, nw, hud, fa)
+ np.camera = fcam
+ _to_free = [np, nw, mount, pc, player, hud, fcam, fa, fc]
+
+ var levelup_signals: Array[int] = []
+ np.level_up.connect(func(lvl: int): levelup_signals.append(lvl))
+
+ # --- 1. 初始点数同步 (不触发升级) ---
+ np._on_points({
+ "level": 1,
+ "hp": 200, "max_hp": 300,
+ "sp": 50, "max_sp": 100,
+ "exp": 0, "next_exp": 1000,
+ })
+ _ck(levelup_signals.is_empty(), "1. 初始 Lv.1 同步不触发升级信号")
+ _ck(fa.calls.is_empty(), "2. 初始不播放升级音效")
+ _ck(player.get_node_or_null("level_up_beam") == null, "3. 初始无升级光柱")
+ _ck(hud.levels == [1], "4. HUD 记录初始等级")
+ hud.vitals.clear()
+
+ # --- 2. 角色等级跃迁 (Lv.1 -> Lv.2) ---
+ # 在残血状态下升级:当前 HP 80 / Max HP 350
+ np._on_points({
+ "level": 2,
+ "hp": 80, "max_hp": 350,
+ "sp": 20, "max_sp": 120,
+ "exp": 120, "next_exp": 2500,
+ })
+ _ck(levelup_signals == [2], "5. 等级增长触发 level_up 信号 (Lv.2)")
+ _ck(fa.calls.has("levelup1_1.wav"), "6. 播放 40250 专属升级音效 levelup1_1.wav")
+ _ck(player.get_node_or_null("level_up_beam") != null, "7. 角色脚底挂载冲天金光特效 (level_up_beam)")
+ _ck(hud.levelup_events == [2], "8. 通知 HUD 刷新升级事件")
+
+ # 40250 核心规则:升级瞬间 HP / SP 立即 100% 满状态恢复
+ # 检查 hud.set_vitals 是否收到了满血满蓝调用 [350, 350, 120, 120]
+ var got_full_restore := false
+ for vit in hud.vitals:
+ if vit[0] == 350 and vit[1] == 350 and vit[2] == 120 and vit[3] == 120:
+ got_full_restore = true
+ break
+ _ck(got_full_restore, "9. 40250 规则:升级瞬间 HP/SP 立即全满回血回蓝")
+
+ # --- 3. 战斗暴击反馈 (DAMAGE_CRITICAL) ---
+ # 普通受击:flag = DAMAGE_NORMAL
+ fcam.shake_calls.clear()
+ nw._on_damage(2001, 150, DamageEffect.DAMAGE_NORMAL)
+ _ck(fcam.shake_calls.is_empty(), "10. 普通伤害不震屏")
+
+ # 暴击命中:flag = DAMAGE_NORMAL | DAMAGE_CRITICAL
+ nw._on_damage(2001, 450, DamageEffect.DAMAGE_NORMAL | DamageEffect.DAMAGE_CRITICAL)
+ _ck(not fcam.shake_calls.is_empty(), "11. 暴击 DAMAGE_CRITICAL 触发镜头震颤")
+ _ck(fcam.shake_calls[0][0] == 0.05, "12. 震颤振幅精确匹配 0.05m")
+
+ # 暴击飘字样式验证
+ nw._show_damage = true
+ nw._process_damage_queue()
+ var dmg_node: Node3D = mount.get_node_or_null("dmg_2001")
+ # 如果没有 DDS 纹理降级至 Label3D
+ if dmg_node:
+ var lbl := dmg_node.get_node_or_null("Label3D") as Label3D
+ if lbl:
+ _ck(lbl.modulate == Color(1.0, 0.85, 0.2), "13. 暴击飘字为金色")
+ _ck(lbl.font_size == 46, "14. 暴击字体放大至 46px")
diff --git a/project/test_levelup_combat_parity.gd.uid b/project/test_levelup_combat_parity.gd.uid
new file mode 100644
index 00000000..9aa89e6d
--- /dev/null
+++ b/project/test_levelup_combat_parity.gd.uid
@@ -0,0 +1 @@
+uid://dmm000k7dc43b
diff --git a/project/test_lucky_lottery_fortune_parity.gd b/project/test_lucky_lottery_fortune_parity.gd
new file mode 100644
index 00000000..9f428176
--- /dev/null
+++ b/project/test_lucky_lottery_fortune_parity.gd
@@ -0,0 +1,142 @@
+# test_lucky_lottery_fortune_parity.gd —— Metin2 40250 幸运乐透与老妪占卜转运系统 1:1 测试套件
+extends SceneTree
+
+const LuckyLotteryFortuneSystem = preload("res://lucky_lottery_fortune_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_lucky_lottery_fortune_parity (Direction 4: Lucky Lottery & Fortune Telling 1:1) ===")
+
+ test_fortune_telling_rules()
+ test_bad_luck_and_talisman()
+ test_lottery_scratch_tickets()
+ test_fortune_update_and_expiry()
+ test_history_and_serialization()
+
+ print("\n=======================================================")
+ print("Lucky Lottery Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_fortune_telling_rules() -> void:
+ print("\n--- Test 1: Old Woman Fortune Telling (40250 1:1) ---")
+ var system = LuckyLotteryFortuneSystem.new()
+
+ # 等级不足 20 级拦截
+ var res = system.tell_fortune(18, 50000)
+ _assert(res["ok"] == false, "Rejected Lv 18 character")
+ _assert(res["reason"] == "LEVEL_TOO_LOW", "Reason is LEVEL_TOO_LOW")
+
+ # 金币不足 7777 拦截
+ res = system.tell_fortune(25, 3000)
+ _assert(res["ok"] == false, "Rejected insufficient gold")
+ _assert(res["reason"] == "NOT_ENOUGH_GOLD", "Reason is NOT_ENOUGH_GOLD")
+
+ # 占卜成功:大吉
+ res = system.tell_fortune(25, 10000, "great_luck")
+ _assert(res["ok"] == true, "Fortune told successfully")
+ _assert(res["tier"] == "great_luck", "Tier is great_luck")
+ _assert(res["exp_bonus"] == 0.10, "+10% EXP bonus")
+ _assert(res["gold_bonus"] == 0.10, "+10% Gold drop bonus")
+ _assert(res["remaining_gold"] == 2223, "Deducted 7777 gold (10000 - 7777 = 2223)")
+
+func test_bad_luck_and_talisman() -> void:
+ print("\n--- Test 2: Bad Luck & Karma Talisman (40250 1:1) ---")
+ var system = LuckyLotteryFortuneSystem.new()
+
+ # 占卜得到大凶
+ var res = system.tell_fortune(30, 20000, "bad_luck")
+ _assert(res["ok"] == true, "Told bad luck")
+ _assert(res["tier"] == "bad_luck", "Current tier is bad_luck")
+ _assert(res["can_buy_talisman"] == true, "Old woman offers talisman")
+
+ # 购买辟邪转运符 (70052)
+ var inv: Array = []
+ var buy_res = system.buy_karma_talisman(inv, 20000)
+ _assert(buy_res["ok"] == true, "Purchased talisman")
+ _assert(buy_res["talisman_vnum"] == 70052, "Talisman item vnum is 70052")
+ _assert(inv.size() == 1 and inv[0]["vnum"] == 70052, "Talisman added to inventory")
+
+ # 运势转危为安,大凶转化为中吉
+ var active = system.get_active_fortune_bonus()
+ _assert(active["tier"] == "good_luck", "Tier converted to good_luck")
+ _assert(active["gold_bonus"] > 0.0, "Gold penalty cleared into positive bonus")
+
+func test_lottery_scratch_tickets() -> void:
+ print("\n--- Test 3: Scratch Lottery Tickets (40250 1:1) ---")
+ var system = LuckyLotteryFortuneSystem.new()
+
+ var inv: Array = [
+ {"vnum": 50027, "count": 2}, # 彩票 x2 (slot 0)
+ {"vnum": 50027, "count": 1}, # 彩票 x1 (slot 1)
+ {"vnum": 50027, "count": 1} # 彩票 x1 (slot 2)
+ ]
+
+ # 1. 刮出三同号 [7, 7, 7] -> 特等大奖 (1,000,000 金币 + 祝福卷轴 25040)
+ var res = system.play_scratch_lottery(inv, 0, [7, 7, 7])
+ _assert(res["ok"] == true, "Scratched lottery ticket")
+ _assert(res["prize_tier"] == "grand_jackpot", "Won grand jackpot!")
+ _assert(res["prize_gold"] == 1000000, "Prize gold is 1,000,000")
+ _assert(res["prize_item"] == 25040, "Prize item is Blessing Scroll (25040)")
+ _assert(inv[0]["count"] == 1, "Count decremented from 2 to 1")
+ # 检查背包是否发放了祝福卷轴
+ var has_scroll := false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 25040:
+ has_scroll = true
+ _assert(has_scroll == true, "Blessing Scroll awarded into inventory")
+
+ # 2. 刮出二同号 [8, 8, 3] -> 二等奖 (200,000 金币)
+ res = system.play_scratch_lottery(inv, 1, [8, 8, 3])
+ _assert(res["prize_tier"] == "second_prize", "Won second prize")
+ _assert(res["prize_gold"] == 200000, "Prize gold is 200,000")
+
+ # 3. 刮出单号 [1, 5, 9] -> 安慰奖 (30,000 金币)
+ res = system.play_scratch_lottery(inv, 2, [1, 5, 9])
+ _assert(res["prize_tier"] == "consolation_prize", "Won consolation prize")
+ _assert(res["prize_gold"] == 30000, "Prize gold is 30,000")
+
+func test_fortune_update_and_expiry() -> void:
+ print("\n--- Test 4: Fortune Duration & Expiry ---")
+ var system = LuckyLotteryFortuneSystem.new()
+ system.tell_fortune(30, 10000, "great_luck")
+
+ var active = system.get_active_fortune_bonus()
+ _assert(active["tier"] == "great_luck", "Active fortune exists")
+
+ # 时间流逝 20000 秒
+ system.update(20000.0)
+ _assert(system.fortune_time_remaining <= 8800.0, "Time remaining decremented")
+
+ # 时间流逝剩余时间 (到期)
+ system.update(10000.0)
+ active = system.get_active_fortune_bonus()
+ _assert(active["tier"] == "", "Fortune expired and cleared")
+
+func test_history_and_serialization() -> void:
+ print("\n--- Test 5: History & Serialization ---")
+ var system = LuckyLotteryFortuneSystem.new()
+ var inv: Array = [{"vnum": 50027, "count": 1}]
+ system.play_scratch_lottery(inv, 0, [3, 3, 3])
+
+ _assert(system.lotto_history.size() == 1, "Recorded 1 lotto history")
+ var data = system.serialize()
+
+ var restored = LuckyLotteryFortuneSystem.new()
+ restored.deserialize(data)
+ _assert(restored.lotto_history.size() == 1, "Restored 1 lotto history")
+ _assert(restored.lotto_history[0]["prize_tier"] == "grand_jackpot", "Restored grand jackpot tier")
diff --git a/project/test_lucky_lottery_fortune_parity.gd.uid b/project/test_lucky_lottery_fortune_parity.gd.uid
new file mode 100644
index 00000000..5f258b68
--- /dev/null
+++ b/project/test_lucky_lottery_fortune_parity.gd.uid
@@ -0,0 +1 @@
+uid://borlpaaoki1j4
diff --git a/project/test_mail_system_parity.gd b/project/test_mail_system_parity.gd
new file mode 100644
index 00000000..a809f0a9
--- /dev/null
+++ b/project/test_mail_system_parity.gd
@@ -0,0 +1,179 @@
+# test_mail_system_parity.gd —— 40250 游戏内邮箱系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const MailSystem = preload("res://mail_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mail_system_parity (Direction 4: In-Game Mailbox 1:1) ===")
+ _test_mail_sending_validation()
+ _test_mail_reading_and_unread_count()
+ _test_attachment_claiming()
+ _test_delete_protection()
+ _test_mail_expiry_cleanup()
+ _finish()
+
+func _test_mail_sending_validation() -> void:
+ print("\n--- Test 1: Mail Sending Validation & Postage (1000 Yang) ---")
+ var ms := MailSystem.new()
+ var player_data := {"gold": 500} # Less than postage 1000
+ var inv: Array = [null]
+
+ # Empty recipient
+ var r1 = ms.send_mail("PlayerA", "", "Title", "Body", player_data, inv)
+ _assert(r1["ok"] == false and r1["reason"] == "EMPTY_RECIPIENT", "Empty recipient rejected")
+
+ # Insufficient Yang for postage
+ var r2 = ms.send_mail("PlayerA", "PlayerB", "Title", "Body", player_data, inv)
+ _assert(r2["ok"] == false and r2["reason"] == "INSUFFICIENT_YANG", "Blocked when gold < postage fee")
+
+ # Locked item cannot be mailed
+ player_data["gold"] = 10000
+ inv[0] = {"vnum": 10, "name": "木剑", "is_locked": true}
+ var r3 = ms.send_mail("PlayerA", "PlayerB", "Title", "Body", player_data, inv, 0)
+ _assert(r3["ok"] == false and r3["reason"] == "ITEM_LOCKED", "Locked item rejected for mailing")
+
+ # Valid sending without attachment
+ var send_data := {"fired": false, "id": 0}
+ ms.mail_sent.connect(func(mid: int, _s: String, _r: String):
+ send_data["fired"] = true
+ send_data["id"] = mid
+ )
+
+ var r4 = ms.send_mail("PlayerA", "PlayerB", "问候信", "你好,这是测试邮件!", player_data, inv)
+ _assert(r4["ok"] == true, "Mail sent successfully")
+ _assert(player_data["gold"] == 9000, "1000 Yang postage deducted (10000 -> 9000)")
+ _assert(send_data["fired"] == true and send_data["id"] == 1, "mail_sent signal fired with id 1")
+ _assert(ms.get_unread_count("PlayerB") == 1, "PlayerB has 1 unread mail")
+
+func _test_mail_reading_and_unread_count() -> void:
+ print("\n--- Test 2: Mail Reading & Unread Counter ---")
+ var ms := MailSystem.new()
+ var player_data := {"gold": 10000}
+ var inv: Array = [null]
+
+ ms.send_mail("Alice", "Bob", "Hello", "World", player_data, inv)
+ _assert(ms.get_unread_count("Bob") == 1, "Initial unread count is 1")
+
+ var unread_signal := {"fired": false, "count": -1}
+ ms.unread_count_changed.connect(func(_rec: String, cnt: int):
+ unread_signal["fired"] = true
+ unread_signal["count"] = cnt
+ )
+
+ # Read mail
+ var res = ms.read_mail(1, "Bob")
+ _assert(res["ok"] == true, "Mail read successfully")
+ _assert(res["mail"]["is_read"] == true, "is_read is true")
+ _assert(ms.get_unread_count("Bob") == 0, "Unread count updated to 0")
+ _assert(unread_signal["fired"] == true and unread_signal["count"] == 0, "unread_count_changed signal fired with 0")
+
+ # Reading non-existent mail
+ var r_fail = ms.read_mail(999, "Bob")
+ _assert(r_fail["ok"] == false and r_fail["reason"] == "MAIL_NOT_FOUND", "Non-existent mail returns error")
+
+func _test_attachment_claiming() -> void:
+ print("\n--- Test 3: Gold & Item Attachment Sealing and Claiming ---")
+ var ms := MailSystem.new()
+ var sender_data := {"gold": 100000}
+ var sender_inv: Array = [
+ {"vnum": 11299, "name": "黑钢甲+9", "count": 1},
+ null
+ ]
+
+ # Send mail with 50,000 Yang and armor in slot 0
+ var send_res = ms.send_mail(
+ "Sender",
+ "Receiver",
+ "珍贵赠礼",
+ "请收下这件黑钢甲和金币!",
+ sender_data,
+ sender_inv,
+ 0, # attach slot 0
+ 50000 # attach 50,000 gold
+ )
+ _assert(send_res["ok"] == true, "Mail with attachments sent")
+ _assert(sender_data["gold"] == 49000, "51,000 Yang deducted (50k gold + 1k postage)")
+ _assert(sender_inv[0] == null, "Attached item removed from sender inventory")
+
+ # Receiver inbox
+ var receiver_data := {"gold": 10000}
+ var receiver_inv: Array = [null, null]
+
+ var claim_signal := {"fired": false, "id": 0}
+ ms.attachment_claimed.connect(func(mid: int):
+ claim_signal["fired"] = true
+ claim_signal["id"] = mid
+ )
+
+ # Claim attachments
+ var claim_res = ms.claim_attachments(1, "Receiver", receiver_data, receiver_inv)
+ _assert(claim_res["ok"] == true, "Attachments claimed successfully")
+ _assert(claim_signal["fired"] == true and claim_signal["id"] == 1, "attachment_claimed signal fired")
+ _assert(receiver_data["gold"] == 60000, "Receiver gold increased by 50,000 (10k -> 60k)")
+ _assert(receiver_inv[0] != null and receiver_inv[0]["vnum"] == 11299, "Attached armor received into inventory")
+
+ # Double claiming yields nothing
+ var claim_again = ms.claim_attachments(1, "Receiver", receiver_data, receiver_inv)
+ _assert(claim_again["claimed_gold"] == 0 and claim_again["claimed_item"] == null, "No attachments left to claim")
+
+func _test_delete_protection() -> void:
+ print("\n--- Test 4: Delete Protection on Unclaimed Attachments ---")
+ var ms := MailSystem.new()
+ var s_data := {"gold": 50000}
+ var s_inv: Array = [{"vnum": 10, "name": "木剑", "count": 1}]
+
+ ms.send_mail("A", "B", "Item Mail", "Body", s_data, s_inv, 0, 10000)
+
+ # Trying to delete before claiming attachments
+ var del_blocked = ms.delete_mail(1, "B")
+ _assert(del_blocked["ok"] == false and del_blocked["reason"] == "HAS_UNCLAIMED_ATTACHMENTS", "Deletion blocked while attachments unclaimed")
+
+ # Claim attachments
+ var b_data := {"gold": 0}
+ var b_inv: Array = [null]
+ ms.claim_attachments(1, "B", b_data, b_inv)
+
+ # Now deletion succeeds
+ var del_ok = ms.delete_mail(1, "B")
+ _assert(del_ok["ok"] == true, "Mail deleted after attachments claimed")
+ _assert(ms.get_inbox("B").is_empty(), "B inbox is now empty")
+
+func _test_mail_expiry_cleanup() -> void:
+ print("\n--- Test 5: 30-Day Expiry Cleanup ---")
+ var ms := MailSystem.new()
+ var s_data := {"gold": 10000}
+ var s_inv: Array = [null]
+
+ # Send mail at time 0
+ ms.send_mail("A", "B", "Old Mail", "Body", s_data, s_inv, -1, 0, 0.0)
+ _assert(ms.get_inbox("B").size() == 1, "1 mail exists")
+
+ # Clean up at day 15 (15 * 86400 = 1296000) -> not expired
+ var c1 = ms.cleanup_expired_mails(1296000.0)
+ _assert(c1 == 0, "0 mails cleaned at 15 days")
+ _assert(ms.get_inbox("B").size() == 1, "Mail still intact")
+
+ # Clean up at day 31 (31 * 86400 = 2678400) -> expired!
+ var c2 = ms.cleanup_expired_mails(2678400.0)
+ _assert(c2 == 1, "1 mail cleaned after 30 days")
+ _assert(ms.get_inbox("B").size() == 0, "Expired mail was removed")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Mail System Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_mail_system_parity.gd.uid b/project/test_mail_system_parity.gd.uid
new file mode 100644
index 00000000..8cd103ed
--- /dev/null
+++ b/project/test_mail_system_parity.gd.uid
@@ -0,0 +1 @@
+uid://7qtp70l3r41l
diff --git a/project/test_mall_affect_parity.gd b/project/test_mall_affect_parity.gd
new file mode 100644
index 00000000..c96b2884
--- /dev/null
+++ b/project/test_mall_affect_parity.gd
@@ -0,0 +1,118 @@
+# test_mall_affect_parity.gd —— 40250 商城常驻 Affect 增益与双倍特权 1:1 纯净回归测试套件
+extends SceneTree
+
+const MallAffectSystem = preload("res://mall_affect_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mall_affect_parity (Direction 4: Mall Affects 1:1) ===")
+ _test_mall_item_activation()
+ _test_multipliers_and_privileges()
+ _test_hud_icon_formatting()
+ _test_decay_and_expiration()
+ _finish()
+
+func _test_mall_item_activation() -> void:
+ print("\n--- Test 1: Mall Item Activation & Affect Table ---")
+ var mas := MallAffectSystem.new()
+
+ var non_mall := {"vnum": 10}
+ var res = mas.use_mall_item(non_mall)
+ _assert(res["ok"] == false and res["reason"] == "NOT_MALL_ITEM", "Non-mall item rejected")
+
+ # EXP Ring 1 hour (70005)
+ var exp_ring_1h := {"vnum": 70005}
+ res = mas.use_mall_item(exp_ring_1h)
+ _assert(res["ok"] == true, "EXP Ring 70005 activated")
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_EXP_BONUS), "AFFECT_EXP_BONUS is active")
+ _assert(mas.active_affects[MallAffectSystem.AFFECT_EXP_BONUS]["duration"] == 3600.0, "Duration is 3600s (1h)")
+
+ # Thief Glove (70043)
+ var thief_glove := {"vnum": 70043}
+ res = mas.use_mall_item(thief_glove)
+ _assert(res["ok"] == true, "Thief Glove 70043 activated")
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_ITEM_BONUS), "AFFECT_ITEM_BONUS is active")
+
+ # Lucky Gold Coin (70044)
+ var gold_coin := {"vnum": 70044}
+ res = mas.use_mall_item(gold_coin)
+ _assert(res["ok"] == true, "Lucky Gold Coin 70044 activated")
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_GOLD_BONUS), "AFFECT_GOLD_BONUS is active")
+
+ # Safebox expansion ticket (72006)
+ var safebox_ticket := {"vnum": 72006}
+ res = mas.use_mall_item(safebox_ticket)
+ _assert(res["ok"] == true, "Safebox ticket 72006 activated")
+ _assert(mas.has_safebox_expansion() == true, "Safebox expansion granted")
+
+func _test_multipliers_and_privileges() -> void:
+ print("\n--- Test 2: Game Multipliers (EXP, Drop, Gold) ---")
+ var mas := MallAffectSystem.new()
+
+ # Defaults
+ _assert(is_equal_approx(mas.get_exp_multiplier(), 1.0), "Default EXP multiplier is 1.0x")
+ _assert(is_equal_approx(mas.get_drop_multiplier(), 1.0), "Default Drop multiplier is 1.0x")
+ _assert(is_equal_approx(mas.get_gold_multiplier(), 1.0), "Default Gold multiplier is 1.0x")
+
+ # Activate 50% EXP ring
+ mas.use_mall_item({"vnum": 70005}) # value = 50
+ _assert(is_equal_approx(mas.get_exp_multiplier(), 1.5), "50% EXP Ring gives 1.5x EXP")
+
+ # Activate 100% EXP ring (72001)
+ mas.use_mall_item({"vnum": 72001}) # value = 100
+ _assert(is_equal_approx(mas.get_exp_multiplier(), 2.0), "100% EXP Ring gives 2.0x EXP")
+
+ # Activate Thief Glove (70043)
+ mas.use_mall_item({"vnum": 70043})
+ _assert(is_equal_approx(mas.get_drop_multiplier(), 2.0), "Thief Glove gives 2.0x Drop Rate")
+
+ # Activate Gold Coin (70044)
+ mas.use_mall_item({"vnum": 70044})
+ _assert(is_equal_approx(mas.get_gold_multiplier(), 2.0), "Lucky Gold Coin gives 2.0x Gold Amount")
+
+func _test_hud_icon_formatting() -> void:
+ print("\n--- Test 3: HUD Icon & Official Sub Path Formatting ---")
+ var mas := MallAffectSystem.new()
+ mas.use_mall_item({"vnum": 70005}) # 1 hour = 3600s -> "01:00:00"
+
+ var icons: Array[Dictionary] = mas.get_hud_icons()
+ _assert(icons.size() == 1, "1 icon in HUD list")
+ var ic: Dictionary = icons[0]
+ _assert(ic["affect_type"] == MallAffectSystem.AFFECT_EXP_BONUS, "Affect type is EXP_BONUS")
+ _assert(ic["icon_sub"] == "d:/ymir work/ui/skill/common/affect/exp_bonus.sub", "Official 40250 sub texture matched")
+ _assert(ic["time_remaining_str"] == "01:00:00", "Remaining time formatted as 01:00:00")
+
+func _test_decay_and_expiration() -> void:
+ print("\n--- Test 4: Duration Decay & Expiration ---")
+ var mas := MallAffectSystem.new()
+ mas.add_affect(MallAffectSystem.AFFECT_AUTOLOOT, 10.0) # 10 seconds
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_AUTOLOOT), "Autoloot active")
+
+ # Decay 5s
+ var expired = mas.update(5.0)
+ _assert(expired.is_empty(), "No expiration after 5s")
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_AUTOLOOT), "Still active")
+
+ # Decay 6s -> expire
+ expired = mas.update(6.0)
+ _assert(expired == [MallAffectSystem.AFFECT_AUTOLOOT], "Autoloot expired")
+ _assert(mas.has_affect(MallAffectSystem.AFFECT_AUTOLOOT) == false, "Autoloot no longer active")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_mall_affect_parity.gd.uid b/project/test_mall_affect_parity.gd.uid
new file mode 100644
index 00000000..97a3ee38
--- /dev/null
+++ b/project/test_mall_affect_parity.gd.uid
@@ -0,0 +1 @@
+uid://d20mb502elamt
diff --git a/project/test_map_name_shower_parity.gd b/project/test_map_name_shower_parity.gd
new file mode 100644
index 00000000..5e85cf7d
--- /dev/null
+++ b/project/test_map_name_shower_parity.gd
@@ -0,0 +1,121 @@
+# test_map_name_shower_parity.gd
+# 检验 40250 地图横幅与魔塔/恶魔陵墓楼层识别及渐显时间轴
+extends SceneTree
+
+const MapNameShowerSystem = preload("res://map_name_shower_system.gd")
+
+func _init() -> void:
+ print("--- 开始测试 40250 地图横幅与魔塔/陵墓楼层识别系统 (test_map_name_shower_parity) ---")
+ var shower = MapNameShowerSystem.new()
+ var fails: int = 0
+
+ # 1. 普通野外/王城地图加载
+ var ok = shower.show_map_name("metin2_map_a1", 1000.0, 2000.0, 0.0)
+ if not ok:
+ printerr("FAIL: metin2_map_a1 横幅未成功触发!")
+ fails += 1
+ if shower.current_image_path != "locale/ui/mapname/a1.tga":
+ printerr("FAIL: a1 横幅路径不符: ", shower.current_image_path)
+ fails += 1
+ if shower.current_floor_number != 0 or shower.pos_x != 0:
+ printerr("FAIL: 普通地图不应有楼层号偏移: ", shower.current_floor_number, " pos_x: ", shower.pos_x)
+ fails += 1
+
+ # 2. 魔塔 (metin2_map_deviltower1) 9 层空间识别检验
+ # 40250 官方坐标:
+ # 1F: x(10000..25000), y(58000..72000) -> 选取 (15000, 65000)
+ # 3F: x(10000..25000), y(10000..25000) -> 选取 (18000, 18000)
+ # 6F: x(14000..43500), y(14000..24500) -> 选取 (30000, 20000)
+ # 9F: x(56000..68000), y(13000..23000) -> 选取 (60000, 18000)
+ shower.show_map_name("metin2_map_deviltower1", 15000.0, 65000.0, 0.0)
+ if shower.current_floor_number != 1:
+ printerr("FAIL: 恶魔之塔 1 层坐标判定错误,得到: ", shower.current_floor_number)
+ fails += 1
+ if shower.current_floor_image != "locale/ui/mapname/devil1_1f.tga":
+ printerr("FAIL: 1层楼层横幅图片不符: ", shower.current_floor_image)
+ fails += 1
+ if shower.pos_x != -60:
+ printerr("FAIL: 恶魔之塔主横幅 X 轴偏移应为 -60,实际: ", shower.pos_x)
+ fails += 1
+
+ var f3 = shower.get_devil_tower_floor(18000.0, 18000.0)
+ if f3 != 3:
+ printerr("FAIL: 恶魔之塔 3 层空间判定错误: ", f3)
+ fails += 1
+
+ var f6 = shower.get_devil_tower_floor(30000.0, 20000.0)
+ if f6 != 6:
+ printerr("FAIL: 恶魔之塔 6 层空间判定错误: ", f6)
+ fails += 1
+
+ var f9 = shower.get_devil_tower_floor(60000.0, 18000.0)
+ if f9 != 9:
+ printerr("FAIL: 恶魔之塔 9 层空间判定错误: ", f9)
+ fails += 1
+
+ # 3. 恶魔陵墓 (metin2_map_devilsCatacomb) 7 层空间识别检验
+ # 1F: x(3000..45000), y(4500..45000) -> (20000, 20000)
+ # 4F: x(3100..56400), y(54100..105800) -> (10000, 80000)
+ # 7F: x(5000..15000), y(104900..122000) -> (10000, 115000)
+ shower.show_map_name("metin2_map_devilsCatacomb", 20000.0, 20000.0, 0.0)
+ if shower.current_floor_number != 1 or shower.pos_x != -75:
+ printerr("FAIL: 恶魔地下陵墓 1 层判定错误,层: ", shower.current_floor_number, " pos_x: ", shower.pos_x)
+ fails += 1
+
+ var b4 = shower.get_devil_catacomb_base(10000.0, 80000.0)
+ if b4 != 4:
+ printerr("FAIL: 恶魔地下陵墓 4 层判定错误: ", b4)
+ fails += 1
+
+ var b7 = shower.get_devil_catacomb_base(10000.0, 115000.0)
+ if b7 != 7:
+ printerr("FAIL: 恶魔地下陵墓 7 层判定错误: ", b7)
+ fails += 1
+
+ # 4. 渐变状态机时间轴演进验证
+ shower.show_map_name("metin2_map_b1", 500.0, 500.0, 10.0)
+ if shower.state != MapNameShowerSystem.State.STATE_FADE_IN:
+ printerr("FAIL: 初始状态应为 STATE_FADE_IN!")
+ fails += 1
+ # 10.5 秒 (处于 1.0s 延迟内),alpha 保持 0
+ shower.update_step(10.5)
+ if shower.cur_alpha != 0.0:
+ printerr("FAIL: 延迟期间 alpha 应为 0: ", shower.cur_alpha)
+ fails += 1
+
+ # 11.0 秒以后开始渐显,每次增加 0.05
+ for i in range(18):
+ shower.update_step(11.0 + i * 0.05)
+ if shower.cur_alpha < 0.9:
+ printerr("FAIL: 渐变后 alpha 应达到或超过 0.9,实际: ", shower.cur_alpha)
+ fails += 1
+ if shower.state != MapNameShowerSystem.State.STATE_SHOW:
+ printerr("FAIL: 达到 0.9 后应进入 STATE_SHOW: ", shower.state)
+ fails += 1
+
+ # 停留 5.0 秒检验
+ var hold_start = 11.0 + 18 * 0.05
+ shower.update_step(hold_start + 2.0)
+ if shower.state != MapNameShowerSystem.State.STATE_SHOW:
+ printerr("FAIL: 驻留期间应保持 STATE_SHOW: ", shower.state)
+ fails += 1
+
+ # 停留超时后转入 FADE_OUT
+ shower.update_step(hold_start + 5.1)
+ if shower.state != MapNameShowerSystem.State.STATE_FADE_OUT:
+ printerr("FAIL: 驻留超时后应转入 STATE_FADE_OUT: ", shower.state)
+ fails += 1
+
+ # 模拟淡出至 0
+ for i in range(25):
+ shower.update_step(hold_start + 5.1 + (i + 1) * 0.1)
+ if shower.state != MapNameShowerSystem.State.STATE_HIDE or shower.cur_alpha != 0.0:
+ printerr("FAIL: 淡出完成后应转入 STATE_HIDE,实际: ", shower.state, " alpha: ", shower.cur_alpha)
+ fails += 1
+
+ if fails == 0:
+ print("PASS: test_map_name_shower_parity (10/10 全部通过)")
+ quit(0)
+ else:
+ printerr("FAILED: test_map_name_shower_parity (", fails, " 项失败)")
+ quit(1)
diff --git a/project/test_map_name_shower_parity.gd.uid b/project/test_map_name_shower_parity.gd.uid
new file mode 100644
index 00000000..15fe13e2
--- /dev/null
+++ b/project/test_map_name_shower_parity.gd.uid
@@ -0,0 +1 @@
+uid://bt6sgoolw7ypl
diff --git a/project/test_marriage_parity.gd b/project/test_marriage_parity.gd
new file mode 100644
index 00000000..1073e610
--- /dev/null
+++ b/project/test_marriage_parity.gd
@@ -0,0 +1,137 @@
+# test_marriage_parity.gd —— 40250 结婚戒指与伴侣瞬移传送 1:1 纯净回归测试套件
+extends SceneTree
+
+const MarriageSystem = preload("res://marriage_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_marriage_parity (Direction 4: Marriage & Couple Warp 1:1) ===")
+ _test_marriage_qualifications_and_ring()
+ _test_love_points_growth()
+ _test_couple_warp_restrictions()
+ _test_successful_warp_and_cooldown()
+ _test_divorce()
+ _finish()
+
+func _test_marriage_qualifications_and_ring() -> void:
+ print("\n--- Test 1: Marriage Prerequisites & Ring Inscription ---")
+ var ms := MarriageSystem.new()
+ var ring := {"vnum": 70301}
+ var non_ring := {"vnum": 10}
+
+ # Level 20 rejected (requires 25)
+ var res = ms.marry("Hero", "Alice", ring, 20)
+ _assert(res["ok"] == false and res["reason"] == "LEVEL_TOO_LOW", "Level < 25 rejected")
+
+ # Non wedding ring rejected
+ res = ms.marry("Hero", "Alice", non_ring, 30)
+ _assert(res["ok"] == false and res["reason"] == "NO_WEDDING_RING", "Non-wedding ring rejected")
+
+ # Valid marriage succeeds
+ res = ms.marry("Hero", "Alice", ring, 30)
+ _assert(res["ok"] == true, "Marriage ceremony succeeds")
+ _assert(ms.is_married == true, "Player is now married")
+ _assert(ms.spouse_name == "Alice", "Spouse name is Alice")
+ _assert(ring["inscribed_name"] == "Alice", "Ring inscribed with partner's name")
+ _assert(ms.love_points == 50, "Initial love points is 50%")
+
+func _test_love_points_growth() -> void:
+ print("\n--- Test 2: Love Points Growth ---")
+ var ms := MarriageSystem.new()
+ var ring := {"vnum": 70301}
+ ms.marry("Hero", "Alice", ring, 30)
+
+ ms.add_love_points(30)
+ _assert(ms.love_points == 80, "Love points increased to 80%")
+
+ # Clamped to 100%
+ ms.add_love_points(50)
+ _assert(ms.love_points == 100, "Love points clamped at 100%")
+
+func _test_couple_warp_restrictions() -> void:
+ print("\n--- Test 3: Couple Warp Restrictions ---")
+ var ms := MarriageSystem.new()
+ var spouse_loc := {"is_online": true, "map": "metin2_map_a1", "pos": Vector3(100, 0, 100)}
+
+ # Unmarried player rejected
+ var res = ms.warp_to_spouse(100, spouse_loc, false)
+ _assert(res["ok"] == false and res["reason"] == "NOT_MARRIED", "Unmarried player cannot warp")
+
+ # Marry
+ var ring := {"vnum": 70301}
+ ms.marry("Hero", "Alice", ring, 30)
+
+ # Dead player rejected
+ res = ms.warp_to_spouse(100, spouse_loc, true)
+ _assert(res["ok"] == false and res["reason"] == "PLAYER_DEAD", "Dead player cannot warp")
+
+ # Not enough SP (< 50)
+ res = ms.warp_to_spouse(30, spouse_loc, false)
+ _assert(res["ok"] == false and res["reason"] == "NOT_ENOUGH_SP", "Fails when SP < 50")
+
+ # Offline spouse rejected
+ res = ms.warp_to_spouse(100, {"is_online": false}, false)
+ _assert(res["ok"] == false and res["reason"] == "SPOUSE_OFFLINE", "Offline spouse rejected")
+
+ # Forbidden zone (Devil Tower F9) rejected
+ var forbidden_loc := {"is_online": true, "map": "deviltower_f9", "pos": Vector3(50, 0, 50)}
+ res = ms.warp_to_spouse(100, forbidden_loc, false)
+ _assert(res["ok"] == false and res["reason"] == "FORBIDDEN_ZONE", "Warp into Devil Tower F9 blocked")
+
+func _test_successful_warp_and_cooldown() -> void:
+ print("\n--- Test 4: Successful Couple Warp & Cooldown ---")
+ var ms := MarriageSystem.new()
+ var ring := {"vnum": 70301}
+ ms.marry("Hero", "Alice", ring, 30)
+
+ var spouse_loc := {"is_online": true, "map": "metin2_map_b1", "pos": Vector3(250, 10, 320)}
+ var warped := [false]
+ ms.couple_warped.connect(func(m: String, p: Vector3): warped[0] = true)
+
+ var res = ms.warp_to_spouse(80, spouse_loc, false)
+ _assert(res["ok"] == true, "Warp request succeeded")
+ _assert(warped[0] == true, "couple_warped signal emitted")
+ _assert(res["sp_cost"] == 50, "50 SP consumed")
+ _assert(res["target_map"] == "metin2_map_b1", "Target map is metin2_map_b1")
+ _assert(res["target_pos"] == Vector3(250, 10, 320), "Target position accurately resolved")
+ _assert(ms.warp_cooldown == 180.0, "Warp entered 180s cooldown")
+
+ # Second warp immediately blocked by cooldown
+ res = ms.warp_to_spouse(80, spouse_loc, false)
+ _assert(res["ok"] == false and res["reason"] == "COOLDOWN_ACTIVE", "Second warp blocked by 180s cooldown")
+
+ # Fast forward 181 seconds
+ ms.update(181.0)
+ _assert(ms.warp_cooldown == 0.0, "Cooldown expired after 181s")
+
+func _test_divorce() -> void:
+ print("\n--- Test 5: Divorce ---")
+ var ms := MarriageSystem.new()
+ var ring := {"vnum": 70301}
+ ms.marry("Hero", "Alice", ring, 30)
+ _assert(ms.is_married == true, "Married initially")
+
+ var res = ms.divorce()
+ _assert(res["ok"] == true, "Divorce completed")
+ _assert(ms.is_married == false, "No longer married")
+ _assert(ms.spouse_name.is_empty(), "Spouse name cleared")
+ _assert(ms.love_points == 0, "Love points reset to 0")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_marriage_parity.gd.uid b/project/test_marriage_parity.gd.uid
new file mode 100644
index 00000000..3e6260c3
--- /dev/null
+++ b/project/test_marriage_parity.gd.uid
@@ -0,0 +1 @@
+uid://cws8pneyu1d86
diff --git a/project/test_marriage_wedding_love_parity.gd b/project/test_marriage_wedding_love_parity.gd
new file mode 100644
index 00000000..d50dfaeb
--- /dev/null
+++ b/project/test_marriage_wedding_love_parity.gd
@@ -0,0 +1,124 @@
+# test_marriage_wedding_love_parity.gd —— Metin2 40250 夫妻同心传送与爱情点数婚戒系统 1:1 测试套件
+extends SceneTree
+
+const MarriageWeddingLoveSystem = preload("res://marriage_wedding_love_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_marriage_wedding_love_parity (Direction 2: Marriage & Wedding Ring 1:1) ===")
+
+ test_marriage_and_partner_query()
+ test_love_points_and_items_bonus()
+ test_wedding_ring_teleport()
+ test_divorce_and_cleanup()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Marriage & Wedding Ring Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_marriage_and_partner_query() -> void:
+ print("\n--- Test 1: Marriage Creation & Querying (40250 1:1) ---")
+ var system = MarriageWeddingLoveSystem.new()
+
+ _assert(system.is_married(1001) == false, "Not married initially")
+
+ # 缔结婚姻
+ var cid = system.marry(1001, "剑魂", 2002, "雪儿")
+ _assert(cid > 0, "Created marriage couple")
+ _assert(system.is_married(1001) == true, "Player 1 is married")
+ _assert(system.is_married(2002) == true, "Player 2 is married")
+ _assert(system.get_partner_pid(1001) == 2002, "Player 1 partner is 2002")
+ _assert(system.get_partner_pid(2002) == 1001, "Player 2 partner is 1001")
+ _assert(system.get_partner_name(1001) == "雪儿", "Player 1 partner name is 雪儿")
+ _assert(system.get_partner_name(2002) == "剑魂", "Player 2 partner name is 剑魂")
+
+func test_love_points_and_items_bonus() -> void:
+ print("\n--- Test 2: Love Points & Jewelry Scaling (40250 1:1) ---")
+ var system = MarriageWeddingLoveSystem.new()
+ system.marry(1001, "剑魂", 2002, "雪儿")
+
+ _assert(system.get_love_points(1001) == 50, "Initial love points 50%")
+
+ # 增加爱情点数至 100%
+ system.add_love_points(1001, 50)
+ _assert(system.get_love_points(1001) == 100, "Love points maxed to 100%")
+
+ # 爱情饰品 100% 加成
+ var b_atk = system.get_love_item_bonus(1001, MarriageWeddingLoveSystem.VNUM_BRACELET_OF_LOVE)
+ _assert(b_atk["bonus_type"] == "att_grade" and b_atk["value"] == 40, "Bracelet of Love gives +40 ATK at 100% love")
+
+ var b_def = system.get_love_item_bonus(1001, MarriageWeddingLoveSystem.VNUM_EARRINGS_OF_LOVE)
+ _assert(b_def["bonus_type"] == "def_grade" and b_def["value"] == 30, "Earrings of Love give +30 DEF at 100% love")
+
+ var b_crit = system.get_love_item_bonus(1001, MarriageWeddingLoveSystem.VNUM_NECKLACE_OF_LOVE)
+ _assert(b_crit["bonus_type"] == "crit_pct" and b_crit["value"] == 20, "Necklace of Love gives +20% Crit at 100% love")
+
+func test_wedding_ring_teleport() -> void:
+ print("\n--- Test 3: Wedding Ring (70302) Teleportation (40250 1:1) ---")
+ var system = MarriageWeddingLoveSystem.new()
+ system.marry(1001, "剑魂", 2002, "雪儿")
+ system.add_love_points(1001, 50) # 100% 爱情点数 -> 消耗 50 SP
+
+ var target_pos = Vector2(1500.0, 3200.0)
+
+ # 1. 伴侣离线 -> 拦截
+ var res = system.use_wedding_ring(1001, 500, false, "谷地", target_pos)
+ _assert(res["ok"] == false, "Rejected when partner offline")
+ _assert(res["reason"] == "PARTNER_OFFLINE", "Reason is PARTNER_OFFLINE")
+
+ # 2. 伴侣处于竞技场 (IsArenaMap) -> 拦截
+ res = system.use_wedding_ring(1001, 500, true, "竞技场", target_pos, true)
+ _assert(res["ok"] == false, "Rejected when partner in arena")
+ _assert(res["reason"] == "PARTNER_IN_ARENA", "Reason is PARTNER_IN_ARENA")
+
+ # 3. SP 不足 -> 拦截 (需要 50 SP,玩家只有 30)
+ res = system.use_wedding_ring(1001, 30, true, "首阳山", target_pos, false)
+ _assert(res["ok"] == false, "Rejected insufficient SP")
+ _assert(res["reason"] == "NOT_ENOUGH_SP", "Reason is NOT_ENOUGH_SP")
+
+ # 4. 传送成功
+ res = system.use_wedding_ring(1001, 200, true, "首阳山", target_pos, false)
+ _assert(res["ok"] == true, "Teleport succeeded")
+ _assert(res["target_pos"] == target_pos, "Target position matches partner position")
+ _assert(res["sp_consumed"] == 50, "Consumed 50 SP at 100% love points")
+ _assert(res["remaining_sp"] == 150, "Remaining SP is 150")
+
+func test_divorce_and_cleanup() -> void:
+ print("\n--- Test 4: Divorce & State Cleanup (40250 1:1) ---")
+ var system = MarriageWeddingLoveSystem.new()
+ system.marry(1001, "剑魂", 2002, "雪儿")
+
+ var div_ok = system.divorce(1001)
+ _assert(div_ok == true, "Divorce succeeded")
+ _assert(system.is_married(1001) == false, "Player 1 is no longer married")
+ _assert(system.is_married(2002) == false, "Player 2 is no longer married")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var system = MarriageWeddingLoveSystem.new()
+ system.marry(1001, "剑魂", 2002, "雪儿")
+ system.add_love_points(1001, 30)
+
+ var data = system.serialize()
+ _assert(data.has("marriages") and data.has("pid_to_couple"), "Serialized data")
+
+ var restored = MarriageWeddingLoveSystem.new()
+ restored.deserialize(data)
+ _assert(restored.is_married(1001) == true, "Restored marriage state")
+ _assert(restored.get_love_points(1001) == 80, "Restored love points 80")
diff --git a/project/test_marriage_wedding_love_parity.gd.uid b/project/test_marriage_wedding_love_parity.gd.uid
new file mode 100644
index 00000000..42700465
--- /dev/null
+++ b/project/test_marriage_wedding_love_parity.gd.uid
@@ -0,0 +1 @@
+uid://i5ky01gkki3k
diff --git a/project/test_mercenary_companion_parity.gd b/project/test_mercenary_companion_parity.gd
new file mode 100644
index 00000000..449743f6
--- /dev/null
+++ b/project/test_mercenary_companion_parity.gd
@@ -0,0 +1,156 @@
+# test_mercenary_companion_parity.gd —— 40250 单人单机假人/陪练佣兵助战 1:1 回归测试套件
+extends SceneTree
+
+const MercenaryCompanionSystem = preload("res://mercenary_companion_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mercenary_companion_parity (Direction 2: Mercenary Companion 1:1) ===")
+ _test_summon_and_stats()
+ _test_buff_and_healing()
+ _test_assist_combat_and_stances()
+ _test_teleport_and_movement()
+ _test_downed_and_revival()
+ _finish()
+
+func _test_summon_and_stats() -> void:
+ print("\n--- Test 1: Summoning & Level Scaling ---")
+ var merc := MercenaryCompanionSystem.new()
+
+ var summon_sig := {"fired": false, "type": 0, "name": ""}
+ merc.mercenary_summoned.connect(func(t: int, n: String):
+ summon_sig["fired"] = true
+ summon_sig["type"] = t
+ summon_sig["name"] = n
+ )
+
+ # Level 50 Shaman
+ var res = merc.summon_mercenary(MercenaryCompanionSystem.MercenaryType.SHAMAN_HEALER, Vector3(10, 0, 10), 50)
+ _assert(res["ok"] == true, "Shaman summoned")
+ _assert(summon_sig["fired"] == true and summon_sig["type"] == 1, "mercenary_summoned signal emitted")
+ _assert(merc.is_active == true and merc.is_alive == true, "Merc is active and alive")
+ _assert(merc.level == 50, "Mercenary level is 50")
+ # Level 50 growth: 1 + 49 * 0.05 = 3.45x base_hp (8000 * 3.45 = 27600)
+ _assert(merc.max_hp == int(8000 * 3.45), "HP properly scaled by level")
+
+ # Dismiss
+ var dis_sig := {"fired": false}
+ merc.mercenary_dismissed.connect(func(): dis_sig["fired"] = true)
+ var dis_res = merc.dismiss_mercenary()
+ _assert(dis_res["ok"] == true, "Merc dismissed")
+ _assert(dis_sig["fired"] == true, "mercenary_dismissed signal emitted")
+ _assert(merc.is_active == false, "Merc is inactive")
+
+func _test_buff_and_healing() -> void:
+ print("\n--- Test 2: Shaman Buffs & Smart Emergency Healing ---")
+ var merc := MercenaryCompanionSystem.new()
+ merc.summon_mercenary(MercenaryCompanionSystem.MercenaryType.SHAMAN_HEALER, Vector3.ZERO, 30)
+
+ var buffs_received := []
+ merc.buff_applied_to_player.connect(func(bname: String, dur: float, val: float):
+ buffs_received.append({"name": bname, "duration": dur, "value": val})
+ )
+
+ var heal_received := {"fired": false, "amount": 0}
+ merc.heal_applied_to_player.connect(func(amt: int):
+ heal_received["fired"] = true
+ heal_received["amount"] = amt
+ )
+
+ # Update when player is healthy (10000 / 10000)
+ var up1 = merc.update_mercenary(0.1, Vector3.ZERO, 10000, 10000)
+ _assert(buffs_received.size() == 2, "Shaman applied 2 buffs (Blessing & Dragon's Aid)")
+ _assert(heal_received["fired"] == false, "No heal cast when player is healthy")
+
+ # Player HP drops to 40% (4000 / 10000 < 60%)
+ var up2 = merc.update_mercenary(0.1, Vector3.ZERO, 4000, 10000)
+ _assert(up2["status"] == "cast_heal", "Shaman triggered emergency heal")
+ _assert(heal_received["fired"] == true, "heal_applied_to_player signal fired")
+ _assert(heal_received["amount"] == 3500, "Healed 35% of max HP (3500)")
+
+ # Second hit while heal is on cooldown should not retrigger heal
+ heal_received["fired"] = false
+ var up3 = merc.update_mercenary(0.1, Vector3.ZERO, 3000, 10000)
+ _assert(heal_received["fired"] == false, "Heal respects cooldown")
+
+func _test_assist_combat_and_stances() -> void:
+ print("\n--- Test 3: Stances & Warrior Assist Combat ---")
+ var merc := MercenaryCompanionSystem.new()
+ merc.summon_mercenary(MercenaryCompanionSystem.MercenaryType.WARRIOR_TANK, Vector3.ZERO, 50)
+
+ # Check stance change
+ var st_res = merc.set_stance(MercenaryCompanionSystem.Stance.FOLLOW)
+ _assert(st_res["ok"] == true and merc.current_stance == MercenaryCompanionSystem.Stance.FOLLOW, "Stance set to FOLLOW")
+
+ # Change to ASSIST
+ merc.set_stance(MercenaryCompanionSystem.Stance.ASSIST)
+ _assert(merc.current_stance == MercenaryCompanionSystem.Stance.ASSIST, "Stance set to ASSIST")
+
+ var atk_sig := {"fired": false, "tgt": "", "dmg": 0}
+ merc.mercenary_attacked.connect(func(tgt: String, dmg: int):
+ atk_sig["fired"] = true
+ atk_sig["tgt"] = tgt
+ atk_sig["dmg"] = dmg
+ )
+
+ # Target at (1.5, 0, 0) within melee range
+ merc.pos = Vector3.ZERO
+ var act = merc.update_mercenary(0.1, Vector3.ZERO, 10000, 10000, "mob_101", Vector3(1.5, 0, 0))
+ _assert(act["status"] == "attacked_target", "Warrior attacked target")
+ _assert(atk_sig["fired"] == true and atk_sig["tgt"] == "mob_101", "mercenary_attacked signal fired")
+ _assert(atk_sig["dmg"] == merc.atk, "Damage matches warrior's ATK")
+
+func _test_teleport_and_movement() -> void:
+ print("\n--- Test 4: Distance Teleport Catch-up ---")
+ var merc := MercenaryCompanionSystem.new()
+ merc.summon_mercenary(MercenaryCompanionSystem.MercenaryType.SHAMAN_HEALER, Vector3.ZERO, 10)
+
+ # Place mercenary 30 meters away (> 25m TELEPORT_DISTANCE)
+ merc.pos = Vector3(30, 0, 0)
+ var tp_act = merc.update_mercenary(0.1, Vector3.ZERO, 5000, 5000)
+ _assert(tp_act["status"] == "teleported_to_player", "Mercenary teleported back to player")
+ _assert(merc.pos.distance_to(Vector3.ZERO) < 3.0, "Position is now close to player")
+
+func _test_downed_and_revival() -> void:
+ print("\n--- Test 5: Downed State & Revival ---")
+ var merc := MercenaryCompanionSystem.new()
+ merc.summon_mercenary(MercenaryCompanionSystem.MercenaryType.WARRIOR_TANK, Vector3.ZERO, 10)
+
+ var down_sig := {"fired": false}
+ var rev_sig := {"fired": false}
+ merc.mercenary_downed.connect(func(): down_sig["fired"] = true)
+ merc.mercenary_revived.connect(func(): rev_sig["fired"] = true)
+
+ # Take fatal damage
+ var hit = merc.receive_damage(999999)
+ _assert(hit["ok"] == true and hit["downed"] == true, "Mercenary downed")
+ _assert(down_sig["fired"] == true, "mercenary_downed signal fired")
+ _assert(merc.is_alive == false, "is_alive is false")
+
+ # Instant revive
+ var rev_res = merc.revive(true)
+ _assert(rev_res["ok"] == true, "Instant revive succeeded")
+ _assert(rev_sig["fired"] == true, "mercenary_revived signal fired")
+ _assert(merc.is_alive == true and merc.current_hp == merc.max_hp, "HP restored to 100% on instant revive")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Mercenary Companion Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Mercenary Companion parity tests passed!")
+ quit(0)
diff --git a/project/test_mercenary_companion_parity.gd.uid b/project/test_mercenary_companion_parity.gd.uid
new file mode 100644
index 00000000..9c05d9e4
--- /dev/null
+++ b/project/test_mercenary_companion_parity.gd.uid
@@ -0,0 +1 @@
+uid://brku4b2nfqkg2
diff --git a/project/test_messenger_parity.gd b/project/test_messenger_parity.gd
new file mode 100644
index 00000000..a3ca00d2
--- /dev/null
+++ b/project/test_messenger_parity.gd
@@ -0,0 +1,134 @@
+# test_messenger_parity.gd —— 40250 好友/行会/家族联络人系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://messenger_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_messenger_parity (Batch 27 Direction 1) ===")
+ _test_friend_addition_and_validation()
+ _test_friend_status_transitions()
+ _test_guild_member_management()
+ _test_family_partner_mechanics()
+ _test_filtering_and_counts()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_friend_addition_and_validation() -> void:
+ print("\n--- Test 1: Friend Addition & Constraints ---")
+ var m = SystemClass.new("Hero")
+
+ # Reject empty name
+ var r_empty = m.add_friend("")
+ _assert(r_empty["error_code"] == "EMPTY_NAME", "Reject empty friend name")
+
+ # Reject adding self
+ var r_self = m.add_friend("Hero")
+ _assert(r_self["error_code"] == "CANNOT_ADD_SELF", "Reject adding self as friend")
+
+ # Add friend successfully
+ var r_ok = m.add_friend("Alice", SystemClass.STATE_ONLINE)
+ _assert(r_ok["success"] == true, "Add Alice as friend successfully")
+ _assert(m.is_friend("Alice") == true, "Alice is recognized as friend")
+ _assert(m.get_friend_state("Alice") == SystemClass.STATE_ONLINE, "Alice initial state is ONLINE")
+
+ # Reject duplicate friend
+ var r_dup = m.add_friend("Alice")
+ _assert(r_dup["error_code"] == "ALREADY_FRIEND", "Reject duplicate friend addition")
+
+ # Remove friend
+ var rm_ok = m.remove_friend("Alice")
+ _assert(rm_ok == true, "Alice removed from friends")
+ _assert(m.is_friend("Alice") == false, "Alice is no longer friend")
+
+func _test_friend_status_transitions() -> void:
+ print("\n--- Test 2: Friend Status Transitions (Online/Offline/Mobile) ---")
+ var m = SystemClass.new("Hero")
+ m.add_friend("Bob", SystemClass.STATE_OFFLINE)
+
+ # Transition to ONLINE
+ m.set_friend_state("Bob", SystemClass.STATE_ONLINE)
+ _assert(m.get_friend_state("Bob") == SystemClass.STATE_ONLINE, "Bob transitioned to ONLINE")
+ var last_event = m.event_logs[m.event_logs.size() - 1]
+ _assert(last_event["type"] == "FRIEND_LOGIN", "Emitted FRIEND_LOGIN event")
+
+ # Transition to MOBILE
+ m.set_friend_state("Bob", SystemClass.STATE_MOBILE)
+ _assert(m.get_friend_state("Bob") == SystemClass.STATE_MOBILE, "Bob transitioned to MOBILE")
+ last_event = m.event_logs[m.event_logs.size() - 1]
+ _assert(last_event["type"] == "FRIEND_MOBILE", "Emitted FRIEND_MOBILE event")
+
+ # Transition to OFFLINE
+ m.set_friend_state("Bob", SystemClass.STATE_OFFLINE)
+ _assert(m.get_friend_state("Bob") == SystemClass.STATE_OFFLINE, "Bob transitioned to OFFLINE")
+ last_event = m.event_logs[m.event_logs.size() - 1]
+ _assert(last_event["type"] == "FRIEND_LOGOUT", "Emitted FRIEND_LOGOUT event")
+
+func _test_guild_member_management() -> void:
+ print("\n--- Test 3: Guild Member Synchronization ---")
+ var m = SystemClass.new("Hero")
+
+ m.append_guild_member("Leader", "Guild Master", SystemClass.STATE_ONLINE)
+ m.append_guild_member("Knight", "General", SystemClass.STATE_ONLINE)
+ m.append_guild_member("Novice", "Member", SystemClass.STATE_OFFLINE)
+
+ _assert(m.guild_members.size() == 3, "Guild has 3 members")
+ _assert(m.guild_members["Leader"].rank == "Guild Master", "Leader rank is Guild Master")
+
+ # Remove single member
+ var rm_ok = m.remove_guild_member("Novice")
+ _assert(rm_ok == true, "Novice removed from guild list")
+ _assert(m.guild_members.size() == 2, "Guild has 2 members remaining")
+
+ # Clear all guild members
+ m.clear_all_guild_members()
+ _assert(m.guild_members.is_empty(), "All guild members cleared on guild leave")
+
+func _test_family_partner_mechanics() -> void:
+ print("\n--- Test 4: Family / Spouse Partner Mechanics ---")
+ var m = SystemClass.new("Hero")
+
+ # Bind partner
+ m.set_family_partner("Eva", 100, SystemClass.STATE_ONLINE)
+ _assert(m.family_partner != null, "Family partner bound")
+ _assert(m.family_partner.name == "Eva", "Partner name is Eva")
+ _assert(m.family_partner.love_point == 100, "Initial Love Points is 100")
+
+ # Update love points
+ m.update_partner_love_points(85)
+ _assert(m.family_partner.love_point == 85, "Love Points updated to 85")
+
+ # Remove partner (Divorce)
+ m.remove_family_partner()
+ _assert(m.family_partner == null, "Partner cleared on divorce")
+
+func _test_filtering_and_counts() -> void:
+ print("\n--- Test 5: Filtering and Online Counts ---")
+ var m = SystemClass.new("Hero")
+
+ m.add_friend("User1", SystemClass.STATE_ONLINE)
+ m.add_friend("User2", SystemClass.STATE_MOBILE)
+ m.add_friend("User3", SystemClass.STATE_OFFLINE)
+
+ var friend_list = m.get_group_members(SystemClass.GROUP_FRIEND)
+ _assert(friend_list.size() == 3, "Total 3 friends in group")
+ _assert(m.get_online_count(SystemClass.GROUP_FRIEND) == 2, "2 friends currently online (ONLINE + MOBILE)")
+
+ m.append_guild_member("Guildie1", "Member", SystemClass.STATE_ONLINE)
+ m.append_guild_member("Guildie2", "Member", SystemClass.STATE_OFFLINE)
+ _assert(m.get_online_count(SystemClass.GROUP_GUILD) == 1, "1 guild member online")
diff --git a/project/test_messenger_parity.gd.uid b/project/test_messenger_parity.gd.uid
new file mode 100644
index 00000000..92811b0c
--- /dev/null
+++ b/project/test_messenger_parity.gd.uid
@@ -0,0 +1 @@
+uid://chpivlvtxa30l
diff --git a/project/test_metin_socket_parity.gd b/project/test_metin_socket_parity.gd
new file mode 100644
index 00000000..7ebd7589
--- /dev/null
+++ b/project/test_metin_socket_parity.gd
@@ -0,0 +1,118 @@
+# test_metin_socket_parity.gd —— 验证方向 2:魔石镶嵌、碎石残渣与打孔清除 40250 1:1 对齐
+extends SceneTree
+
+const MetinSocketSystem = preload("res://metin_socket_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_metin_socket_parity (Direction 2: Metin Socketing & Cleaning 1:1) ===")
+ test_metin_vnum_identification()
+ test_equipment_target_rules()
+ test_socket_metin_success_and_broken()
+ test_duplicate_metin_prevention()
+ test_clean_broken_sockets_parity()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_metin_vnum_identification() -> void:
+ print("\n--- Test 1: Metin Stone & Scroll Identification ---")
+ _assert(MetinSocketSystem.is_metin_stone(28430), "28430 (Stone of Penetration+4) is a Metin Stone")
+ _assert(MetinSocketSystem.is_metin_stone(28442), "28442 (Stone of Defense+4) is a Metin Stone")
+ _assert(MetinSocketSystem.is_metin_stone(28033), "28033 (Stone of Warrior+0) is a Metin Stone")
+ _assert(not MetinSocketSystem.is_metin_stone(10), "Sword 10 is not a Metin Stone")
+ _assert(not MetinSocketSystem.is_metin_stone(28960), "Broken stone 28960 is not a Metin stone to socket")
+ _assert(MetinSocketSystem.is_clean_socket_scroll(71006), "71006 is Clean Socket Scroll")
+ _assert(not MetinSocketSystem.is_clean_socket_scroll(71085), "71085 is not clean socket scroll")
+
+func test_equipment_target_rules() -> void:
+ print("\n--- Test 2: Target Equipment Restrictions (Body Armor / Weapon vs Shield/Arrow) ---")
+ var body_armor := {"type": MetinSocketSystem.ITEM_TYPE_ARMOR, "sub_type": 0} # ARMOR_BODY
+ var helmet := {"type": MetinSocketSystem.ITEM_TYPE_ARMOR, "sub_type": 1} # ARMOR_HEAD
+ var weapon := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0} # WEAPON_SWORD
+ var arrow := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 5} # WEAPON_ARROW
+ var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN}
+
+ var r_body := MetinSocketSystem.can_attach_metin(28442, stone_proto, body_armor)
+ _assert(r_body.ok, "Can attach metin stone to body armor")
+
+ var r_helmet := MetinSocketSystem.can_attach_metin(28442, stone_proto, helmet)
+ _assert(not r_helmet.ok and r_helmet.code == "ARMOR_NOT_BODY", "Cannot attach metin stone to helmet (must be body)")
+
+ var r_weapon := MetinSocketSystem.can_attach_metin(28430, stone_proto, weapon)
+ _assert(r_weapon.ok, "Can attach metin stone to weapon")
+
+ var r_arrow := MetinSocketSystem.can_attach_metin(28430, stone_proto, arrow)
+ _assert(not r_arrow.ok and r_arrow.code == "WEAPON_ARROW", "Cannot attach metin stone to arrows")
+
+func test_socket_metin_success_and_broken() -> void:
+ print("\n--- Test 3: 40250 Socketing Success vs Broken Fragment (28960) ---")
+ var weapon_proto := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0}
+ var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN}
+
+ # Sword with 3 open sockets: [1, 1, 1]
+ var sword := {"vnum": 10, "sockets": [1, 1, 1]}
+
+ # 1st stone: force success (28430 Penetration+4)
+ var res1 := MetinSocketSystem.socket_metin_stone(28430, stone_proto, sword, weapon_proto, true, false)
+ _assert(res1.ok and res1.code == "SUCCESS", "1st metin stone socketed successfully")
+ _assert(sword.sockets[0] == 28430, "Socket 0 contains 28430")
+ _assert(sword.sockets[1] == 1 and sword.sockets[2] == 1, "Socket 1 and 2 remain open (1)")
+
+ # 2nd stone: force failure -> creates broken metin stone 28960
+ var res2 := MetinSocketSystem.socket_metin_stone(28431, stone_proto, sword, weapon_proto, false, true)
+ _assert(not res2.ok and res2.code == "BROKEN_METIN", "2nd metin stone broke on failure")
+ _assert(sword.sockets[1] == MetinSocketSystem.ITEM_BROKEN_METIN_VNUM, "Socket 1 is now blocked by broken fragment 28960")
+ _assert(sword.sockets[2] == 1, "Socket 2 remains open (1)")
+
+ # 3rd stone: force success (28437 Monster+4) into remaining slot 2
+ var res3 := MetinSocketSystem.socket_metin_stone(28437, stone_proto, sword, weapon_proto, true, false)
+ _assert(res3.ok and res3.code == "SUCCESS", "3rd metin stone socketed into remaining slot 2")
+ _assert(sword.sockets[2] == 28437, "Socket 2 contains 28437")
+
+ # 4th stone: no available sockets
+ var res4 := MetinSocketSystem.socket_metin_stone(28432, stone_proto, sword, weapon_proto, true, false)
+ _assert(not res4.ok and res4.code == "NO_AVAILABLE_SOCKET", "No available sockets rejected properly")
+
+func test_duplicate_metin_prevention() -> void:
+ print("\n--- Test 4: Prevention of Duplicate Stone Groups ---")
+ var weapon_proto := {"type": MetinSocketSystem.ITEM_TYPE_WEAPON, "sub_type": 0}
+ var stone_proto := {"type": MetinSocketSystem.ITEM_TYPE_METIN}
+ var sword := {"vnum": 10, "sockets": [28430, 1, 1]} # Already has Penetration+4 (28430)
+
+ # Trying to socket Penetration+3 (28330) or another Penetration stone
+ var res_dup := MetinSocketSystem.socket_metin_stone(28330, stone_proto, sword, weapon_proto, true, false)
+ _assert(not res_dup.ok and res_dup.code == "DUPLICATE_METIN", "Duplicate metin group (Penetration) prevented from socketing")
+
+func test_clean_broken_sockets_parity() -> void:
+ print("\n--- Test 5: 40250 USE_CLEAN_SOCKET (71006) Fragment Cleaning ---")
+ # Equipment has: [28430 (valid), 28960 (broken), 28437 (valid)]
+ var item_with_broken := {
+ "vnum": 10,
+ "sockets": [28430, MetinSocketSystem.ITEM_BROKEN_METIN_VNUM, 28437]
+ }
+
+ var res_clean := MetinSocketSystem.clean_broken_sockets(item_with_broken)
+ _assert(res_clean.ok and res_clean.code == "SUCCESS", "Clean scroll removed broken stone fragments")
+ _assert(item_with_broken.sockets[0] == 28430, "Socket 0 still has valid stone 28430")
+ _assert(item_with_broken.sockets[1] == 28437, "Valid stone 28437 compacted forward to socket 1")
+ _assert(item_with_broken.sockets[2] == 1, "Socket 2 restored back to open state 1")
+
+ # Using clean scroll on an item with no broken stones must fail (40250 char_item.cpp:4523)
+ var res_clean_no_op := MetinSocketSystem.clean_broken_sockets(item_with_broken)
+ _assert(not res_clean_no_op.ok and res_clean_no_op.code == "NO_BROKEN_STONE", "Clean scroll rejected on item without broken stones")
diff --git a/project/test_metin_socket_parity.gd.uid b/project/test_metin_socket_parity.gd.uid
new file mode 100644
index 00000000..eb09c363
--- /dev/null
+++ b/project/test_metin_socket_parity.gd.uid
@@ -0,0 +1 @@
+uid://3lvmxm4k4thv
diff --git a/project/test_metin_spawn_event_parity.gd b/project/test_metin_spawn_event_parity.gd
new file mode 100644
index 00000000..2f0f9e7f
--- /dev/null
+++ b/project/test_metin_spawn_event_parity.gd
@@ -0,0 +1,168 @@
+# test_metin_spawn_event_parity.gd —— 40250 野外 8 档魔石与怪潮暴动 1:1 回归测试套件
+extends SceneTree
+
+const MetinSpawnEventSystem = preload("res://metin_spawn_event_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_metin_spawn_event_parity (Direction 3: Field Metin Spawn & Waves 1:1) ===")
+ _test_table_configuration()
+ _test_spawn_metin()
+ _test_damage_and_phases()
+ _test_destruction_and_drops()
+ _test_invalid_inputs()
+ _finish()
+
+func _test_table_configuration() -> void:
+ print("\n--- Test 1: Metin Table Configuration (8008~8014, 8027) ---")
+ var expected_vnums = [8008, 8009, 8010, 8011, 8012, 8013, 8014, 8027]
+ _assert(MetinSpawnEventSystem.METIN_TABLE.size() == 8, "Table has exactly 8 Metin stone types")
+
+ for vnum in expected_vnums:
+ _assert(MetinSpawnEventSystem.METIN_TABLE.has(vnum), "Has Metin stone %d" % vnum)
+ var entry = MetinSpawnEventSystem.METIN_TABLE[vnum]
+ _assert(entry.has("name") and entry.has("level") and entry.has("max_hp") and entry.has("def") and entry.has("wave_mob") and entry.has("wave_count"),
+ "Metin %d has all required fields" % vnum)
+ _assert(entry["max_hp"] > 0 and entry["def"] > 0 and entry["wave_count"] > 0,
+ "Metin %d has positive stats" % vnum)
+
+func _test_spawn_metin() -> void:
+ print("\n--- Test 2: Spawning Metin Stones ---")
+ var sys := MetinSpawnEventSystem.new()
+ var spawn_sig := {"fired": false, "vnum": 0, "name": "", "pos": Vector3.ZERO}
+ sys.metin_spawned.connect(func(v: int, n: String, p: Vector3):
+ spawn_sig["fired"] = true
+ spawn_sig["vnum"] = v
+ spawn_sig["name"] = n
+ spawn_sig["pos"] = p
+ )
+
+ var spawn_pos := Vector3(100.5, 0.0, 200.5)
+ var res = sys.spawn_metin(8008, spawn_pos)
+ _assert(res["ok"] == true, "Spawned 8008 successfully")
+ _assert(res["id"] == 1, "First instance ID is 1")
+ _assert(sys.active_metins.has(1), "Instance 1 in active_metins")
+ _assert(spawn_sig["fired"] == true and spawn_sig["vnum"] == 8008 and spawn_sig["pos"] == spawn_pos,
+ "metin_spawned signal emitted correctly")
+
+ var res2 = sys.spawn_metin(8027, Vector3(50.0, 0.0, 50.0))
+ _assert(res2["ok"] == true and res2["id"] == 2, "Second stone instance ID is 2")
+ _assert(sys.active_metins.size() == 2, "2 stones active")
+
+func _test_damage_and_phases() -> void:
+ print("\n--- Test 3: Damage Calculation & 75%, 50%, 25% Mob Waves ---")
+ var sys := MetinSpawnEventSystem.new()
+ var res = sys.spawn_metin(8008, Vector3.ZERO) # 8008: HP 70000, Def 120, Mob 391, count 4
+ var id: int = res["id"]
+
+ var waves := []
+ sys.mob_wave_erupted.connect(func(vnum: int, count: int, minion_vnum: int):
+ waves.append({"vnum": vnum, "count": count, "minion_vnum": minion_vnum})
+ )
+
+ # Raw damage 200 - 120 def = 80 net damage
+ var atk1 = sys.attack_metin(id, 200)
+ _assert(atk1["ok"] == true, "Attack successful")
+ _assert(atk1["net_dmg"] == 80, "Net damage = 200 - 120 = 80")
+ _assert(atk1["remaining_hp"] == 70000 - 80, "Remaining HP correct")
+ _assert(atk1["wave_triggered"] == false, "No wave at > 75% HP")
+ _assert(waves.size() == 0, "No wave signal fired")
+
+ # Damage to trigger <= 75% HP: 70000 * 0.75 = 52500. We deal raw dmg to bring it below 52500.
+ # Remaining is 69920. Need to deal at least 69920 - 52500 = 17420 net dmg.
+ # Raw dmg = 17420 + 120 = 17540
+ var atk_phase_75 = sys.attack_metin(id, 17540)
+ _assert(atk_phase_75["wave_triggered"] == true, "Phase 75% wave triggered")
+ _assert(atk_phase_75["spawned_count"] == 4, "Spawned 4 minions")
+ _assert(waves.size() == 1 and waves[0]["minion_vnum"] == 391, "Wave signal received for 391")
+
+ # Another hit while still in 50%~75% bracket should NOT trigger wave again
+ var atk_repeat = sys.attack_metin(id, 1000)
+ _assert(atk_repeat["wave_triggered"] == false, "Subsequent hit doesn't retrigger 75% wave")
+ _assert(waves.size() == 1, "Wave count still 1")
+
+ # Damage to trigger <= 50% HP (35000)
+ # Current HP is around 51620. Need 17000 net dmg.
+ var atk_phase_50 = sys.attack_metin(id, 20000)
+ _assert(atk_phase_50["wave_triggered"] == true, "Phase 50% wave triggered")
+ _assert(waves.size() == 2, "Wave count now 2")
+
+ # Damage to trigger <= 25% HP (17500)
+ var atk_phase_25 = sys.attack_metin(id, 20000)
+ _assert(atk_phase_25["wave_triggered"] == true, "Phase 25% wave triggered")
+ _assert(waves.size() == 3, "Wave count now 3")
+
+func _test_destruction_and_drops() -> void:
+ print("\n--- Test 4: Metin Destruction & Drops ---")
+ var sys := MetinSpawnEventSystem.new()
+ var res = sys.spawn_metin(8014, Vector3(10, 0, 10)) # 8014: 死亡之石, lvl 65, HP 260000
+ var id: int = res["id"]
+
+ var destroy_sig := {"fired": false, "vnum": 0, "drops": []}
+ sys.metin_destroyed.connect(func(vnum: int, pos: Vector3, drops: Array):
+ destroy_sig["fired"] = true
+ destroy_sig["vnum"] = vnum
+ destroy_sig["drops"] = drops
+ )
+
+ # Overkill blow
+ var kill_atk = sys.attack_metin(id, 1000000)
+ _assert(kill_atk["ok"] == true and kill_atk["destroyed"] == true, "Metin destroyed")
+ _assert(sys.active_metins.has(id) == false, "Stone removed from active_metins")
+ _assert(destroy_sig["fired"] == true and destroy_sig["vnum"] == 8014, "metin_destroyed signal emitted")
+
+ var drops: Array = kill_atk["drops"]
+ _assert(drops.size() == 4, "4 drops generated")
+
+ var has_skill_book := false
+ var has_soul_stone := false
+ var has_spirit_stone := false
+ var has_gold := false
+
+ for d in drops:
+ if d["vnum"] == 50300:
+ has_skill_book = true
+ elif d["vnum"] == 50513:
+ has_soul_stone = true
+ elif d["vnum"] == 28430:
+ has_spirit_stone = true
+ elif d["vnum"] == 1 and d["count"] >= 65 * 500:
+ has_gold = true
+
+ _assert(has_skill_book, "Dropped Skill Book 50300")
+ _assert(has_soul_stone, "Dropped Soul Stone 50513")
+ _assert(has_spirit_stone, "Dropped Spirit Stone 28430")
+ _assert(has_gold, "Dropped Gold scaled with level 65")
+
+func _test_invalid_inputs() -> void:
+ print("\n--- Test 5: Invalid Metin Inputs ---")
+ var sys := MetinSpawnEventSystem.new()
+ var spawn_fail = sys.spawn_metin(99999)
+ _assert(spawn_fail["ok"] == false and spawn_fail["reason"] == "INVALID_METIN_VNUM",
+ "Correctly rejected invalid vnum 99999")
+
+ var atk_fail = sys.attack_metin(8888, 100)
+ _assert(atk_fail["ok"] == false and atk_fail["reason"] == "METIN_NOT_FOUND",
+ "Correctly rejected attack on missing instance 8888")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Metin Spawn Event Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Metin Spawn Event parity tests passed!")
+ quit(0)
diff --git a/project/test_metin_spawn_event_parity.gd.uid b/project/test_metin_spawn_event_parity.gd.uid
new file mode 100644
index 00000000..6cbaca6a
--- /dev/null
+++ b/project/test_metin_spawn_event_parity.gd.uid
@@ -0,0 +1 @@
+uid://n5dxvwk5n3lb
diff --git a/project/test_minimap_radar_parity.gd b/project/test_minimap_radar_parity.gd
new file mode 100644
index 00000000..0fe6e456
--- /dev/null
+++ b/project/test_minimap_radar_parity.gd
@@ -0,0 +1,105 @@
+# test_minimap_radar_parity.gd —— 小地图雷达与坐标系统 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_minimap_radar_parity.gd
+extends SceneTree
+
+const Minimap = preload("res://ui/minimap.gd")
+
+class MockPlayer extends Node3D:
+ pass
+
+class MockClient extends Node:
+ signal channel_changed(c: int)
+ signal observer_event(ev: Dictionary)
+ signal world_reset
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_minimap_radar_parity (Direction 3: Minimap & Coordinates 1:1) ===")
+ _test_minimap_coordinates()
+ _test_zoom_buttons()
+ _test_atlas_button()
+ _finish()
+
+func _test_minimap_coordinates() -> void:
+ print("\n--- Test 1: Real-time (X, Y) Coordinates Display ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var player := MockPlayer.new()
+ get_root().add_child(player)
+ player.position = Vector3(150.8, 5.0, -320.4)
+
+ var mm := Minimap.new()
+ get_root().add_child(mm)
+ mm.setup(client, get_root(), func(): return player)
+
+ # Trigger process update
+ mm._process(0.016)
+ _assert(mm._coord_label != null, "_coord_label exists")
+ _assert(mm._coord_label.text == "(150, 320)", "Coordinates label correctly displays (150, 320)")
+
+ # Move player to (500, -800)
+ player.position = Vector3(500.0, 0.0, -800.0)
+ mm._process(0.016)
+ _assert(mm._coord_label.text == "(500, 800)", "Coordinates updated to (500, 800)")
+
+ mm.queue_free()
+ player.queue_free()
+ client.queue_free()
+
+func _test_zoom_buttons() -> void:
+ print("\n--- Test 2: ScaleUp / ScaleDown Buttons ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var mm := Minimap.new()
+ get_root().add_child(mm)
+ mm.setup(client, get_root(), func(): return null)
+
+ var initial_scale := mm.get_scale()
+ mm._btn_zoom_in.pressed.emit()
+ _assert(mm.get_scale() > initial_scale, "Zoom in button increases scale")
+
+ mm._btn_zoom_out.pressed.emit()
+ _assert(absf(mm.get_scale() - initial_scale) < 0.001, "Zoom out button decreases scale back")
+
+ mm.queue_free()
+ client.queue_free()
+
+func _test_atlas_button() -> void:
+ print("\n--- Test 3: Atlas (M) Button ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var mm := Minimap.new()
+ get_root().add_child(mm)
+ mm.setup(client, get_root(), func(): return null)
+
+ var state := {"clicked": false}
+ mm.map_pressed.connect(func(): state["clicked"] = true)
+
+ mm._btn_atlas.pressed.emit()
+ _assert(state["clicked"] == true, "Atlas button click emits map_pressed signal")
+
+ mm.queue_free()
+ client.queue_free()
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_minimap_radar_parity.gd.uid b/project/test_minimap_radar_parity.gd.uid
new file mode 100644
index 00000000..b956e88b
--- /dev/null
+++ b/project/test_minimap_radar_parity.gd.uid
@@ -0,0 +1 @@
+uid://b6edy3hf034wt
diff --git a/project/test_mining_refinery_smelt_parity.gd b/project/test_mining_refinery_smelt_parity.gd
new file mode 100644
index 00000000..cea78e2c
--- /dev/null
+++ b/project/test_mining_refinery_smelt_parity.gd
@@ -0,0 +1,144 @@
+# test_mining_refinery_smelt_parity.gd —— 40250 矿石采掘熔炼与首饰宝石镶嵌 1:1 回归测试套件
+extends SceneTree
+
+const MiningRefinerySmeltSystem = preload("res://mining_refinery_smelt_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mining_refinery_smelt_parity (Direction 1: Mining Refinery & Smelt 1:1) ===")
+ _test_smelt_ore_success()
+ _test_smelt_validations()
+ _test_open_jewelry_sockets()
+ _test_embed_gems()
+ _finish()
+
+func _test_smelt_ore_success() -> void:
+ print("\n--- Test 1: Smelting Ore into Refined Gems ---")
+ var sys := MiningRefinerySmeltSystem.new()
+ var player := {"gold": 50000}
+ var inv: Array = [
+ {"vnum": 50621, "name": "钻石原石", "count": 100},
+ {"vnum": 28030, "name": "战士灵石+0", "count": 1}
+ ]
+
+ var smelt_sig := {"fired": false, "succ": false, "gem": 0}
+ sys.ore_smelted.connect(func(succ: bool, gvnum: int, _gn: String):
+ smelt_sig["fired"] = true
+ smelt_sig["succ"] = succ
+ smelt_sig["gem"] = gvnum
+ )
+
+ # Smelt Diamond Ore (force_success = true)
+ var res = sys.smelt_ore(0, 1, inv, player, false, true)
+ _assert(res["ok"] == true and res["success"] == true, "Smelt succeeded")
+ _assert(res["gem_vnum"] == 50634, "Produced Diamond Gem (50634)")
+ _assert(player["gold"] == 40000, "10,000 gold deducted (50000 -> 40000)")
+ _assert(smelt_sig["fired"] == true and smelt_sig["gem"] == 50634, "ore_smelted signal emitted")
+
+ var has_gem := false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 50634:
+ has_gem = true
+ break
+ _assert(has_gem, "Diamond Gem found in inventory")
+
+func _test_smelt_validations() -> void:
+ print("\n--- Test 2: Smelting Input Validations ---")
+ var sys := MiningRefinerySmeltSystem.new()
+ var player := {"gold": 5000} # Less than 10,000
+ var inv: Array = [
+ {"vnum": 50623, "name": "乌木原石", "count": 50}, # Less than 100
+ {"vnum": 28030, "count": 1}
+ ]
+
+ # Fail on not enough ore
+ var fail_ore = sys.smelt_ore(0, 1, inv, player)
+ _assert(fail_ore["ok"] == false and fail_ore["reason"] == "NOT_ENOUGH_ORE", "Rejected: ore count < 100")
+
+ # Fail on not enough gold
+ inv[0]["count"] = 100
+ var fail_gold = sys.smelt_ore(0, 1, inv, player)
+ _assert(fail_gold["ok"] == false and fail_gold["reason"] == "NOT_ENOUGH_GOLD", "Rejected: gold < 10000")
+
+func _test_open_jewelry_sockets() -> void:
+ print("\n--- Test 3: Opening Jewelry Sockets with Diamond (50634) ---")
+ var sys := MiningRefinerySmeltSystem.new()
+ var inv: Array = [
+ {"vnum": 50634, "name": "纯净钻石", "count": 5},
+ {"vnum": 14009, "name": "白金手镯+9", "jewelry_sockets": []}
+ ]
+
+ var sock_sig := {"fired": false, "idx": 0}
+ sys.jewelry_socket_opened.connect(func(_slot: int, idx: int):
+ sock_sig["fired"] = true
+ sock_sig["idx"] = idx
+ )
+
+ # Open 1st socket (100% success)
+ var s1 = sys.open_jewelry_socket(0, 1, inv, true)
+ _assert(s1["ok"] == true and s1["success"] == true, "1st socket opened")
+ _assert(inv[1]["jewelry_sockets"].size() == 1, "Bracelet has 1 socket")
+ _assert(sock_sig["fired"] == true and sock_sig["idx"] == 1, "jewelry_socket_opened emitted")
+
+ # Open 2nd and 3rd sockets
+ sys.open_jewelry_socket(0, 1, inv, true)
+ sys.open_jewelry_socket(0, 1, inv, true)
+ _assert(inv[1]["jewelry_sockets"].size() == 3, "Bracelet has 3 sockets (Max)")
+ _assert(inv[0]["count"] == 2, "3 diamonds consumed (5 -> 2)")
+
+ # 4th socket attempt must be rejected
+ var fail_4th = sys.open_jewelry_socket(0, 1, inv, true)
+ _assert(fail_4th["ok"] == false and fail_4th["reason"] == "MAX_SOCKETS_REACHED", "Max 3 sockets enforced")
+
+func _test_embed_gems() -> void:
+ print("\n--- Test 4: Embedding Refined Gems into Sockets ---")
+ var sys := MiningRefinerySmeltSystem.new()
+ var inv: Array = [
+ {"vnum": 50641, "name": "白金精炼石", "count": 3},
+ {"vnum": 14009, "name": "白金手镯+9", "jewelry_sockets": [null, null]} # 2 open sockets
+ ]
+
+ var embed_sig := {"fired": false, "name": ""}
+ sys.gem_embedded.connect(func(_slot: int, gname: String, _b: float):
+ embed_sig["fired"] = true
+ embed_sig["name"] = gname
+ )
+
+ # Embed into socket 0
+ var em1 = sys.embed_gem_into_socket(0, 1, inv)
+ _assert(em1["ok"] == true, "1st gem embedded")
+ _assert(inv[1]["jewelry_sockets"][0] != null, "Socket 0 occupied")
+ _assert(inv[1]["jewelry_sockets"][0]["vnum"] == 50641, "Socket 0 has White Gold Gem")
+ _assert(embed_sig["fired"] == true and embed_sig["name"] == "白金精炼石", "gem_embedded signal fired")
+
+ # Embed into socket 1
+ var em2 = sys.embed_gem_into_socket(0, 1, inv)
+ _assert(em2["ok"] == true, "2nd gem embedded")
+ _assert(inv[1]["jewelry_sockets"][1] != null, "Socket 1 occupied")
+ _assert(inv[0]["count"] == 1, "Gem count decreased 3 -> 1")
+
+ # 3rd embed when all sockets occupied must fail
+ var fail_em = sys.embed_gem_into_socket(0, 1, inv)
+ _assert(fail_em["ok"] == false and fail_em["reason"] == "ALL_SOCKETS_OCCUPIED", "Rejected when sockets are full")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Mining Refinery Smelt Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Mining Refinery Smelt parity tests passed!")
+ quit(0)
diff --git a/project/test_mining_refinery_smelt_parity.gd.uid b/project/test_mining_refinery_smelt_parity.gd.uid
new file mode 100644
index 00000000..5adddc4b
--- /dev/null
+++ b/project/test_mining_refinery_smelt_parity.gd.uid
@@ -0,0 +1 @@
+uid://bqs2uw6qn6k36
diff --git a/project/test_mob_combat_reaction_parity.gd b/project/test_mob_combat_reaction_parity.gd
new file mode 100644
index 00000000..83419715
--- /dev/null
+++ b/project/test_mob_combat_reaction_parity.gd
@@ -0,0 +1,97 @@
+# test_mob_combat_reaction_parity.gd —— 验证方向 2:怪物主动仇恨视野、追击脱战与连击击退硬直 40250 1:1 对齐
+extends SceneTree
+
+const MobCombatReactionSystem = preload("res://mob_combat_reaction_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mob_combat_reaction_parity (Direction 2: Mob Aggro & Knockback 1:1) ===")
+ test_aggro_radius_detection()
+ test_leash_distance_and_reset()
+ test_combo_reaction_steps()
+ test_knockback_direction_vector()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_aggro_radius_detection() -> void:
+ print("\n--- Test 1: Aggro Radius (18m) Detection ---")
+ var mob_pos := Vector3(0, 0, 0)
+ var player_in_range := Vector3(10, 0, 10) # distance = ~14.14m <= 18m
+ var player_out_range := Vector3(20, 0, 20) # distance = ~28.28m > 18m
+
+ # Active aggressive mob
+ _assert(MobCombatReactionSystem.check_aggro(mob_pos, player_in_range, true, 18.0),
+ "Aggressive mob detects player within 18m")
+ _assert(not MobCombatReactionSystem.check_aggro(mob_pos, player_out_range, true, 18.0),
+ "Aggressive mob ignores player outside 18m")
+
+ # Passive mob ignores player even close
+ _assert(not MobCombatReactionSystem.check_aggro(mob_pos, player_in_range, false, 18.0),
+ "Passive mob does not aggro even when close")
+
+func test_leash_distance_and_reset() -> void:
+ print("\n--- Test 2: Leash Distance (40m) & Full HP Reset ---")
+ var spawn_pos := Vector3(100, 0, 100)
+ var mob_chasing := Vector3(120, 0, 120) # distance = 28.28m <= 40m
+ var mob_leashed := Vector3(150, 0, 150) # distance = 70.71m > 40m
+
+ _assert(not MobCombatReactionSystem.check_leash_exceeded(mob_chasing, spawn_pos, 40.0),
+ "Mob within 40m remains in chase")
+ _assert(MobCombatReactionSystem.check_leash_exceeded(mob_leashed, spawn_pos, 40.0),
+ "Mob exceeding 40m triggers leash exceeded")
+
+ # Leash reset
+ var damaged_mob := {"hp": 200, "max_hp": 2000, "target_vid": 1, "state": MobCombatReactionSystem.MobAIState.CHASE}
+ var reset_res := MobCombatReactionSystem.perform_leash_reset(damaged_mob, spawn_pos)
+ _assert(reset_res.leashed, "Leash reset executed")
+ _assert(damaged_mob.hp == 2000, "HP fully restored to 2000")
+ _assert(damaged_mob.target_vid == 0, "Target cleared")
+ _assert(damaged_mob.state == MobCombatReactionSystem.MobAIState.RETREAT, "State set to RETREAT")
+
+func test_combo_reaction_steps() -> void:
+ print("\n--- Test 3: 40250 Combo Step Reactions (1-2 Damage vs 3-4 Knockdown) ---")
+ var mob_pos := Vector3(0, 0, 0)
+
+ # Step 1: Hit 1 -> normal damage stun
+ var hit1 := MobCombatReactionSystem.calculate_combo_reaction(1, mob_pos, 0.0)
+ _assert(hit1.reaction_type == "DAMAGE_STUN" and not hit1.is_knockback, "Combo 1 is DAMAGE_STUN (no knockback)")
+ _assert(hit1.anim == "damage", "Plays damage animation")
+
+ # Step 2: Hit 2 -> normal damage stun
+ var hit2 := MobCombatReactionSystem.calculate_combo_reaction(2, mob_pos, 0.0)
+ _assert(hit2.reaction_type == "DAMAGE_STUN" and not hit2.is_knockback, "Combo 2 is DAMAGE_STUN")
+
+ # Step 3: Hit 3 -> heavy knockback & knockdown
+ var hit3 := MobCombatReactionSystem.calculate_combo_reaction(3, mob_pos, 0.0)
+ _assert(hit3.reaction_type == "KNOCKBACK_FLYING" and hit3.is_knockback, "Combo 3 triggers KNOCKBACK_FLYING")
+ _assert(hit3.anim == "knockdown" and hit3.standup_anim == "standup", "Triggers knockdown and standup")
+
+ # Step 4: Hit 4 -> heavy knockback & knockdown
+ var hit4 := MobCombatReactionSystem.calculate_combo_reaction(4, mob_pos, 0.0)
+ _assert(hit4.reaction_type == "KNOCKBACK_FLYING" and hit4.is_knockback, "Combo 4 triggers KNOCKBACK_FLYING")
+
+func test_knockback_direction_vector() -> void:
+ print("\n--- Test 4: Knockback Push Vector Along Attack Heading ---")
+ var mob_pos := Vector3(10, 0, 10)
+
+ # Attack heading = 0 radians (North / +Z)
+ var hit_north := MobCombatReactionSystem.calculate_combo_reaction(3, mob_pos, 0.0)
+ var offset_z: float = hit_north.push_offset.z
+ _assert(is_equal_approx(offset_z, 1.5), "Knockback pushed 1.5m along attack heading")
+ _assert(is_equal_approx(hit_north.target_pos.z, 11.5), "Target position updated to 11.5m")
diff --git a/project/test_mob_combat_reaction_parity.gd.uid b/project/test_mob_combat_reaction_parity.gd.uid
new file mode 100644
index 00000000..bb361410
--- /dev/null
+++ b/project/test_mob_combat_reaction_parity.gd.uid
@@ -0,0 +1 @@
+uid://c6a8fvd6qbd1h
diff --git a/project/test_mob_drop_scatter_parity.gd b/project/test_mob_drop_scatter_parity.gd
new file mode 100644
index 00000000..74c18814
--- /dev/null
+++ b/project/test_mob_drop_scatter_parity.gd
@@ -0,0 +1,74 @@
+# test_mob_drop_scatter_parity.gd —— 怪物死亡爆装与辐射散射 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_mob_drop_scatter_parity.gd
+extends SceneTree
+
+const MobDropScatter = preload("res://mob_drop_scatter.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mob_drop_scatter_parity (Direction 2: Drop Scatter & Ownership 1:1) ===")
+ _test_single_drop_offset()
+ _test_multi_drop_radial_scatter()
+ _test_ownership_protection_window()
+ _test_drop_sounds()
+ _finish()
+
+func _test_single_drop_offset() -> void:
+ print("\n--- Test 1: Single Drop Offset [-1.4m, +1.4m] (char_battle.cpp) ---")
+ for seed_val in range(10):
+ var offset := MobDropScatter.calculate_scatter_offset(0, 1, seed_val)
+ _assert(offset.x >= -1.4 and offset.x <= 1.4, "Seed %d dx within [-1.4m, 1.4m]" % seed_val)
+ _assert(offset.z >= -1.4 and offset.z <= 1.4, "Seed %d dz within [-1.4m, 1.4m]" % seed_val)
+ _assert(offset.y == 0.0, "Y offset is 0.0 (ground plane)")
+
+func _test_multi_drop_radial_scatter() -> void:
+ print("\n--- Test 2: Multi-Item Boss Drop Radial Scatter ---")
+ var center := Vector3(100.0, 10.0, 50.0)
+ var count := 6
+ var positions: Array[Vector3] = []
+ for i in range(count):
+ var pos := MobDropScatter.calculate_drop_position(center, i, count, 42)
+ positions.append(pos)
+ var dist := (pos - center).length()
+ _assert(dist >= 0.95 and dist <= 1.75, "Item %d scatter radius is %.2fm (expected 1.0~1.6m)" % [i, dist])
+
+ # Check that items do not overlap (pairwise distance > 0.5m)
+ for i in range(count):
+ for j in range(i + 1, count):
+ var d := (positions[i] - positions[j]).length()
+ _assert(d > 0.5, "Item %d and %d separated by %.2fm (no pile overlap)" % [i, j, d])
+
+func _test_ownership_protection_window() -> void:
+ print("\n--- Test 3: 30-Second Ownership Protection Window ---")
+ var drop_time := 100.0
+ _assert(MobDropScatter.is_ownership_protected(drop_time, 105.0) == true, "Protected at 5 seconds")
+ _assert(MobDropScatter.remaining_ownership_time(drop_time, 105.0) == 25.0, "25 seconds remaining at 5s")
+
+ _assert(MobDropScatter.is_ownership_protected(drop_time, 129.5) == true, "Protected at 29.5 seconds")
+ _assert(MobDropScatter.is_ownership_protected(drop_time, 130.1) == false, "Expired after 30 seconds")
+ _assert(MobDropScatter.remaining_ownership_time(drop_time, 135.0) == 0.0, "Remaining time clamped to 0 after expiry")
+
+func _test_drop_sounds() -> void:
+ print("\n--- Test 4: Drop Sound Assignment ---")
+ _assert(MobDropScatter.get_drop_sound(true) == "sound/ui/money.wav", "Gold drop uses money.wav")
+ _assert(MobDropScatter.get_drop_sound(false) == "sound/ui/drop.wav", "Item drop uses drop.wav")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_mob_drop_scatter_parity.gd.uid b/project/test_mob_drop_scatter_parity.gd.uid
new file mode 100644
index 00000000..2fb7e174
--- /dev/null
+++ b/project/test_mob_drop_scatter_parity.gd.uid
@@ -0,0 +1 @@
+uid://bmhuvaqjgi6ls
diff --git a/project/test_monarch_election_ui_parity.gd b/project/test_monarch_election_ui_parity.gd
new file mode 100644
index 00000000..6869b4a8
--- /dev/null
+++ b/project/test_monarch_election_ui_parity.gd
@@ -0,0 +1,126 @@
+# test_monarch_election_ui_parity.gd —— 40250 帝国君主大选投票公示系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://monarch_election_ui_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_monarch_election_ui_parity (Batch 28 Direction 3) ===")
+ _test_candidacy_registration_rules()
+ _test_voting_mechanics()
+ _test_election_conclusion_and_monarch_inauguration()
+ _test_monarch_privileges()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_candidacy_registration_rules() -> void:
+ print("\n--- Test 1: Candidacy Registration Rules (Lv 50+, 1M Gold) ---")
+ var m = SystemClass.new()
+
+ # 1. Reject when stage is CLOSED
+ var r1 = m.register_candidacy(1, "PlayerA", SystemClass.EMPIRE_SHINSOO, 60, 2000000, "For the Empire!")
+ _assert(r1["error_code"] == "CANDIDACY_NOT_OPEN", "Reject registration when stage is CLOSED")
+
+ # Set stage to CANDIDACY
+ m.set_election_stage(SystemClass.EMPIRE_SHINSOO, SystemClass.STATUS_CANDIDACY)
+
+ # 2. Reject level < 50
+ var r2 = m.register_candidacy(2, "PlayerB", SystemClass.EMPIRE_SHINSOO, 49, 2000000, "I am young")
+ _assert(r2["error_code"] == "LEVEL_TOO_LOW", "Reject candidacy for level < 50")
+
+ # 3. Reject insufficient gold (< 1M)
+ var r3 = m.register_candidacy(3, "PlayerC", SystemClass.EMPIRE_SHINSOO, 55, 500000, "I am poor")
+ _assert(r3["error_code"] == "INSUFFICIENT_GOLD", "Reject candidacy when gold < 1,000,000")
+
+ # 4. Valid registration
+ var r4 = m.register_candidacy(1, "CandidateAlpha", SystemClass.EMPIRE_SHINSOO, 70, 2000000, "Glory to Shinsoo!")
+ _assert(r4["success"] == true, "Registered CandidateAlpha successfully")
+ _assert(r4["gold_cost"] == 1000000, "Deducted 1,000,000 gold deposit")
+ _assert(m.candidates[SystemClass.EMPIRE_SHINSOO].size() == 1, "Candidates count is 1")
+
+ # 5. Reject duplicate registration
+ var r5 = m.register_candidacy(1, "CandidateAlpha", SystemClass.EMPIRE_SHINSOO, 70, 2000000, "Duplicate")
+ _assert(r5["error_code"] == "ALREADY_REGISTERED", "Reject duplicate candidacy registration")
+
+func _test_voting_mechanics() -> void:
+ print("\n--- Test 2: Voting Eligibility & One-Vote-Per-Citizen Limit ---")
+ var m = SystemClass.new()
+ m.set_election_stage(SystemClass.EMPIRE_SHINSOO, SystemClass.STATUS_CANDIDACY)
+ m.register_candidacy(101, "CandidateOne", SystemClass.EMPIRE_SHINSOO, 65, 2000000, "Manifesto 1")
+ m.register_candidacy(102, "CandidateTwo", SystemClass.EMPIRE_SHINSOO, 75, 2000000, "Manifesto 2")
+
+ # Reject voting when still in CANDIDACY stage
+ var r_fail_stage = m.cast_vote(501, SystemClass.EMPIRE_SHINSOO, 60, 101)
+ _assert(r_fail_stage["error_code"] == "VOTING_NOT_OPEN", "Reject voting when stage != STATUS_VOTING")
+
+ # Advance to VOTING stage
+ m.set_election_stage(SystemClass.EMPIRE_SHINSOO, SystemClass.STATUS_VOTING)
+
+ # Reject voter level < 50
+ var r_low_voter = m.cast_vote(502, SystemClass.EMPIRE_SHINSOO, 45, 101)
+ _assert(r_low_voter["error_code"] == "VOTER_LEVEL_TOO_LOW", "Reject voter with level < 50")
+
+ # Valid vote
+ var r_vote1 = m.cast_vote(501, SystemClass.EMPIRE_SHINSOO, 60, 101)
+ _assert(r_vote1["success"] == true, "Citizen 501 successfully voted for CandidateOne")
+ _assert(m.candidates[SystemClass.EMPIRE_SHINSOO][0].votes == 1, "CandidateOne has 1 vote")
+
+ # Reject duplicate vote
+ var r_dup_vote = m.cast_vote(501, SystemClass.EMPIRE_SHINSOO, 60, 102)
+ _assert(r_dup_vote["error_code"] == "ALREADY_VOTED", "Reject duplicate vote from same citizen")
+
+ # Another citizen votes for CandidateTwo
+ m.cast_vote(503, SystemClass.EMPIRE_SHINSOO, 60, 102)
+ m.cast_vote(504, SystemClass.EMPIRE_SHINSOO, 60, 102)
+ _assert(m.candidates[SystemClass.EMPIRE_SHINSOO][1].votes == 2, "CandidateTwo has 2 votes")
+
+func _test_election_conclusion_and_monarch_inauguration() -> void:
+ print("\n--- Test 3: Election Conclusion & Winner Inauguration ---")
+ var m = SystemClass.new()
+ m.set_election_stage(SystemClass.EMPIRE_CHUNJO, SystemClass.STATUS_CANDIDACY)
+ m.register_candidacy(201, "ChunjoHero", SystemClass.EMPIRE_CHUNJO, 80, 2000000, "Pledge")
+ m.register_candidacy(202, "ChunjoLord", SystemClass.EMPIRE_CHUNJO, 85, 2000000, "Pledge")
+
+ m.set_election_stage(SystemClass.EMPIRE_CHUNJO, SystemClass.STATUS_VOTING)
+ m.cast_vote(601, SystemClass.EMPIRE_CHUNJO, 55, 202) # 202 gets 1 vote
+
+ var r_win = m.conclude_election(SystemClass.EMPIRE_CHUNJO)
+ _assert(r_win["success"] == true, "Election concluded successfully")
+ _assert(r_win["winner_pid"] == 202, "Candidate 202 won with majority votes")
+ _assert(m.current_monarchs[SystemClass.EMPIRE_CHUNJO]["pid"] == 202, "Candidate 202 inaugurated as Monarch")
+
+func _test_monarch_privileges() -> void:
+ print("\n--- Test 4: Monarch Privileges (Tax & Empire Blessing) ---")
+ var m = SystemClass.new()
+ m.current_monarchs[SystemClass.EMPIRE_JINNO]["pid"] = 301
+ m.current_monarchs[SystemClass.EMPIRE_JINNO]["name"] = "EmperorJinno"
+
+ # 1. Non-monarch cannot adjust tax
+ var r_tax_fail = m.set_tax_rate(999, SystemClass.EMPIRE_JINNO, 5)
+ _assert(r_tax_fail["error_code"] == "NOT_MONARCH", "Non-monarch rejected from changing tax")
+
+ # 2. Monarch sets tax to 8%
+ var r_tax_ok = m.set_tax_rate(301, SystemClass.EMPIRE_JINNO, 8)
+ _assert(r_tax_ok["success"] == true, "Monarch successfully updated tax rate")
+ _assert(m.current_monarchs[SystemClass.EMPIRE_JINNO]["tax_rate"] == 8, "Tax rate is 8%")
+
+ # 3. Monarch casts empire blessing
+ var r_bless = m.activate_monarch_blessing(301, SystemClass.EMPIRE_JINNO)
+ _assert(r_bless["success"] == true, "Monarch activated empire blessing")
+ _assert(r_bless["blessing_buff"]["exp_bonus"] == 10, "Granted +10% EXP bonus")
+ _assert(r_bless["blessing_buff"]["att_bonus"] == 10, "Granted +10% Attack bonus")
diff --git a/project/test_monarch_election_ui_parity.gd.uid b/project/test_monarch_election_ui_parity.gd.uid
new file mode 100644
index 00000000..a9096842
--- /dev/null
+++ b/project/test_monarch_election_ui_parity.gd.uid
@@ -0,0 +1 @@
+uid://bsf03mqm3i5l0
diff --git a/project/test_monarch_empire_treasury_parity.gd b/project/test_monarch_empire_treasury_parity.gd
new file mode 100644
index 00000000..aa8749c7
--- /dev/null
+++ b/project/test_monarch_empire_treasury_parity.gd
@@ -0,0 +1,151 @@
+# test_monarch_empire_treasury_parity.gd —— Metin2 40250 君主特权与国库祈愿系统 1:1 测试套件
+extends SceneTree
+
+const MonarchEmpireTreasurySystem = preload("res://monarch_empire_treasury_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_monarch_empire_treasury_parity (Direction 1: Monarch & Treasury 1:1) ===")
+
+ test_monarch_appointment()
+ test_treasury_management_and_tax()
+ test_heal_my_empire()
+ test_power_up_and_defense_up()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Monarch Treasury Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_monarch_appointment() -> void:
+ print("\n--- Test 1: Monarch Appointment & Verification (40250 1:1) ---")
+ var system = MonarchEmpireTreasurySystem.new()
+
+ _assert(system.is_monarch(1001, 1) == false, "No monarch initially")
+
+ # 任命辛秀帝国 (红国 1) 君主
+ var ok = system.appoint_monarch(1, 1001, "赤霄狂帝")
+ _assert(ok == true, "Appointed Shinsoo monarch")
+ _assert(system.is_monarch(1001, 1) == true, "Player 1001 is monarch of Shinsoo")
+ _assert(system.is_monarch(1002, 1) == false, "Player 1002 is not monarch")
+ _assert(system.is_monarch(1001, 2) == false, "Player 1001 is not Chunjo monarch")
+
+ var info = system.get_monarch_info(1)
+ _assert(info["player_name"] == "赤霄狂帝", "Monarch name recorded")
+ _assert(info["player_id"] == 1001, "Monarch PID recorded")
+
+func test_treasury_management_and_tax() -> void:
+ print("\n--- Test 2: Treasury Gold & Monster Micro-Tax (40250 1:1) ---")
+ var system = MonarchEmpireTreasurySystem.new()
+
+ _assert(system.get_treasury_gold(1) == 0, "Treasury starts at 0")
+
+ # 国库注资 1,000,000
+ system.add_treasury_gold(1, 1000000)
+ _assert(system.get_treasury_gold(1) == 1000000, "Treasury has 1,000,000 gold")
+
+ # 征缴打怪微税 (2% 进国库)
+ var tax = system.collect_monster_tax(1, 50000, 0.02)
+ _assert(tax == 1000, "Collected 1,000 gold tax from 50,000 drop")
+ _assert(system.get_treasury_gold(1) == 1001000, "Treasury increased to 1,001,000")
+
+ # 扣除资金
+ var dec_ok = system.dec_treasury_gold(1, 500000)
+ _assert(dec_ok == true, "Dec treasury 500,000 succeeded")
+ _assert(system.get_treasury_gold(1) == 501000, "Treasury balance is 501,000")
+
+ # 超额扣除拦截
+ dec_ok = system.dec_treasury_gold(1, 9999999)
+ _assert(dec_ok == false, "Rejected overdraft")
+
+func test_heal_my_empire() -> void:
+ print("\n--- Test 3: HealMyEmpire Grace (40250 1:1) ---")
+ var system = MonarchEmpireTreasurySystem.new()
+ system.appoint_monarch(1, 1001, "赤霄狂帝")
+ system.add_treasury_gold(1, 300000) # 存入 300,000
+
+ # 模拟在线国民
+ var citizens: Array[Dictionary] = [
+ {"pid": 1001, "empire": 1, "hp": 200, "max_hp": 2000, "sp": 50, "max_sp": 500},
+ {"pid": 1002, "empire": 1, "hp": 50, "max_hp": 1500, "sp": 10, "max_sp": 400},
+ {"pid": 2001, "empire": 2, "hp": 100, "max_hp": 1000, "sp": 20, "max_sp": 300} # 敌国国民
+ ]
+
+ # 非君主尝试施展 -> 拦截
+ var res = system.heal_my_empire(1002, 1, citizens)
+ _assert(res["ok"] == false, "Rejected non-monarch cast")
+ _assert(res["reason"] == "NOT_MONARCH", "Reason is NOT_MONARCH")
+
+ # 君主施展恩泽圣愈
+ res = system.heal_my_empire(1001, 1, citizens)
+ _assert(res["ok"] == true, "HealMyEmpire succeeded")
+ _assert(res["healed_count"] == 2, "Healed 2 Shinsoo citizens")
+ _assert(citizens[0]["hp"] == 2000 and citizens[0]["sp"] == 500, "Citizen 1 fully restored")
+ _assert(citizens[1]["hp"] == 1500 and citizens[1]["sp"] == 400, "Citizen 2 fully restored")
+ _assert(citizens[2]["hp"] == 100, "Enemy empire citizen not healed")
+ _assert(res["remaining_treasury"] == 200000, "Cost 100,000 deducted from treasury")
+
+ # 冷却期连续施放 -> 拦截
+ res = system.heal_my_empire(1001, 1, citizens)
+ _assert(res["ok"] == false, "Rejected during cooldown")
+ _assert(res["reason"] == "COOLDOWN_ACTIVE", "Reason is COOLDOWN_ACTIVE")
+
+func test_power_up_and_defense_up() -> void:
+ print("\n--- Test 4: PowerUp & DefenseUp Empire Auras (40250 1:1) ---")
+ var system = MonarchEmpireTreasurySystem.new()
+ system.appoint_monarch(1, 1001, "赤霄狂帝")
+ system.add_treasury_gold(1, 2000000) # 200万国库金币
+
+ # 激活战力觉醒 (PowerUp, +10% 攻击力)
+ var res = system.activate_power_up(1001, 1)
+ _assert(res["ok"] == true, "Activated PowerUp")
+ _assert(res["att_grade_bonus"] == 0.10, "+10% attack bonus")
+ _assert(res["remaining_treasury"] == 1500000, "Deducted 500,000 cost")
+
+ # 激活铁壁守护 (DefenseUp, +10% 防御力)
+ res = system.activate_defense_up(1001, 1)
+ _assert(res["ok"] == true, "Activated DefenseUp")
+ _assert(res["def_grade_bonus"] == 0.10, "+10% defense bonus")
+
+ # 检查国民享受的增益
+ var buffs = system.get_citizen_empire_buffs(1)
+ _assert(buffs["att_bonus"] == 0.10, "Shinsoo citizen enjoys +10% attack")
+ _assert(buffs["def_bonus"] == 0.10, "Shinsoo citizen enjoys +10% defense")
+
+ # 敌国无增益
+ var enemy_buffs = system.get_citizen_empire_buffs(2)
+ _assert(enemy_buffs["att_bonus"] == 0.0, "Enemy has 0 bonus")
+
+ # 时间推移 1900 秒 (增益持续 1800 秒结束)
+ system.update(1900.0)
+ buffs = system.get_citizen_empire_buffs(1)
+ _assert(buffs["att_bonus"] == 0.0 and buffs["def_bonus"] == 0.0, "Auras expired naturally")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var system = MonarchEmpireTreasurySystem.new()
+ system.appoint_monarch(1, 1001, "赤霄狂帝")
+ system.add_treasury_gold(1, 888888)
+
+ var data = system.serialize()
+ _assert(data.has("monarch_pids") and data["treasury"][1] == 888888, "Serialized state")
+
+ var restored = MonarchEmpireTreasurySystem.new()
+ restored.deserialize(data)
+ _assert(restored.is_monarch(1001, 1) == true, "Restored monarch PID")
+ _assert(restored.get_treasury_gold(1) == 888888, "Restored treasury gold")
diff --git a/project/test_monarch_empire_treasury_parity.gd.uid b/project/test_monarch_empire_treasury_parity.gd.uid
new file mode 100644
index 00000000..52c74784
--- /dev/null
+++ b/project/test_monarch_empire_treasury_parity.gd.uid
@@ -0,0 +1 @@
+uid://bcgfxr58e656e
diff --git a/project/test_monster_card_parity.gd b/project/test_monster_card_parity.gd
new file mode 100644
index 00000000..9b9b75af
--- /dev/null
+++ b/project/test_monster_card_parity.gd
@@ -0,0 +1,110 @@
+# test_monster_card_parity.gd —— 40250 怪物图鉴与百科系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const MonsterCardSystem = preload("res://monster_card_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_monster_card_parity (Direction 4: Monster Card Bestiary 1:1) ===")
+ _test_card_registration_and_discovery()
+ _test_zone_completion_and_permanent_bonuses()
+ _test_multiple_zone_bonuses_stacking()
+ _finish()
+
+func _test_card_registration_and_discovery() -> void:
+ print("\n--- Test 1: Card Registration & Bestiary Entry Discovery ---")
+ var mcs := MonsterCardSystem.new()
+
+ # 1. Invalid card vnum
+ var r_bad = mcs.register_card(99999)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "INVALID_CARD", "Invalid card vnum rejected")
+
+ # 2. Query undiscovered monster 101 (野狼)
+ var undiscovered = mcs.get_monster_entry(101)
+ _assert(undiscovered["is_unlocked"] == false, "Entry 101 initially locked")
+ _assert(undiscovered["name"] == "???", "Name is hidden as '???'")
+
+ # 3. Register Card 70201 (野狼卡片)
+ var card_signal := {"fired": false, "vnum": 0, "name": ""}
+ mcs.card_collected.connect(func(v: int, n: String, _c: int):
+ card_signal["fired"] = true
+ card_signal["vnum"] = v
+ card_signal["name"] = n
+ )
+
+ var r_reg = mcs.register_card(70201)
+ _assert(r_reg["ok"] == true, "Card 70201 registered successfully")
+ _assert(card_signal["fired"] == true and card_signal["vnum"] == 101, "card_collected signal fired for 101")
+ _assert(mcs.collected_cards[101] == 1, "Collected 1 Wolf card")
+
+ # 4. Query discovered monster 101
+ var discovered = mcs.get_monster_entry(101)
+ _assert(discovered["is_unlocked"] == true, "Entry 101 is now unlocked")
+ _assert(discovered["name"] == "野狼", "Name revealed as '野狼'")
+ _assert(discovered["level"] == 1, "Level revealed as 1")
+ _assert(discovered["drops"].size() >= 2, "Drops revealed")
+
+func _test_zone_completion_and_permanent_bonuses() -> void:
+ print("\n--- Test 2: Zone Completion & Passive Stat Activation ---")
+ var mcs := MonsterCardSystem.new()
+
+ var zone_signal := {"fired": false, "zname": "", "bonus": {}}
+ mcs.zone_completed.connect(func(zn: String, b: Dictionary):
+ zone_signal["fired"] = true
+ zone_signal["zname"] = zn
+ zone_signal["bonus"] = b
+ )
+
+ # Register Wolf (101) -> Village has 2 monsters, not complete yet
+ mcs.register_card(70201)
+ _assert(zone_signal["fired"] == false, "Village not complete with only 1/2 monsters")
+ _assert(mcs.get_active_collection_bonuses().is_empty(), "No bonuses active yet")
+
+ # Register White Oath (391, card 70202) -> Completes Village!
+ var r2 = mcs.register_card(70202)
+ _assert(r2["zone_completed"] == true, "Village zone completed")
+ _assert(zone_signal["fired"] == true and zone_signal["zname"] == "新手原野", "zone_completed signal fired")
+ _assert(zone_signal["bonus"]["max_hp"] == 300, "Village grants +300 Max HP")
+
+ var bonuses = mcs.get_active_collection_bonuses()
+ _assert(bonuses["max_hp"] == 300, "Active collection bonuses contain +300 Max HP")
+
+func _test_multiple_zone_bonuses_stacking() -> void:
+ print("\n--- Test 3: Multi-Zone Stacking (Village + Desert + Catacomb) ---")
+ var mcs := MonsterCardSystem.new()
+
+ # Complete Village (+300 HP)
+ mcs.register_card(70201)
+ mcs.register_card(70202)
+
+ # Complete Desert (+3% Monster Dmg)
+ mcs.register_card(70203) # 2108 沙漠强盗
+ mcs.register_card(70204) # 2191 巨型沙漠龟
+
+ # Complete Catacomb (+50 DEF)
+ mcs.register_card(70205) # 2597 塔耳塔洛斯
+ mcs.register_card(70206) # 2598 冥王阿兹瑞尔
+
+ var all_bonuses = mcs.get_active_collection_bonuses()
+ _assert(all_bonuses["max_hp"] == 300, "Max HP +300 stacked")
+ _assert(all_bonuses["monster_dmg_pct"] == 3, "Monster Dmg +3% stacked")
+ _assert(all_bonuses["def"] == 50, "DEF +50 stacked")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Monster Card Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_monster_card_parity.gd.uid b/project/test_monster_card_parity.gd.uid
new file mode 100644
index 00000000..bba5b2b6
--- /dev/null
+++ b/project/test_monster_card_parity.gd.uid
@@ -0,0 +1 @@
+uid://cfdv1dlg0028g
diff --git a/project/test_mount_horse_parity.gd b/project/test_mount_horse_parity.gd
new file mode 100644
index 00000000..48ea0215
--- /dev/null
+++ b/project/test_mount_horse_parity.gd
@@ -0,0 +1,123 @@
+# test_mount_horse_parity.gd —— 战马与骑乘系统 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_mount_horse_parity.gd
+extends SceneTree
+
+const HorseSystem = preload("res://horse_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mount_horse_parity (Direction 3: Horse & Mount System 1:1) ===")
+ _test_horse_tiers_and_food()
+ _test_summoning()
+ _test_ctrl_h_ride()
+ _test_ctrl_b_back()
+ _test_ctrl_g_ride()
+ _test_ctrl_f_feed()
+ _test_mounted_combat_ranges()
+ _finish()
+
+func _test_horse_tiers_and_food() -> void:
+ print("\n--- Test 1: Horse Tiers & Foods ---")
+ _assert(HorseSystem.get_horse_tier_from_item(50051) == HorseSystem.HORSE_TIER_BEGINNER, "50051 is Beginner Horse")
+ _assert(HorseSystem.get_horse_tier_from_item(50052) == HorseSystem.HORSE_TIER_COMBAT, "50052 is Combat Horse")
+ _assert(HorseSystem.get_horse_tier_from_item(50053) == HorseSystem.HORSE_TIER_MILITARY, "50053 is Military Horse")
+
+ _assert(HorseSystem.get_required_food(HorseSystem.HORSE_TIER_BEGINNER) == HorseSystem.ITEM_FOOD_HAY, "Beginner eats Hay (50057)")
+ _assert(HorseSystem.get_required_food(HorseSystem.HORSE_TIER_COMBAT) == HorseSystem.ITEM_FOOD_CARROT, "Combat horse eats Carrot (50054)")
+ _assert(HorseSystem.get_required_food(HorseSystem.HORSE_TIER_MILITARY) == HorseSystem.ITEM_FOOD_GINSENG, "Military horse eats Ginseng (50058)")
+
+func _test_summoning() -> void:
+ print("\n--- Test 2: Summoning & SP Costs ---")
+ # Rejects if insufficient SP
+ var r1 := HorseSystem.try_summon_horse(HorseSystem.HORSE_TIER_BEGINNER, 50, 0)
+ _assert(r1["success"] == false and r1["err"] == "insufficient_sp", "Rejects summon when SP < 100")
+
+ # Roll test
+ var r_fail := HorseSystem.try_summon_horse(HorseSystem.HORSE_TIER_BEGINNER, 150, 0, 50)
+ _assert(r_fail["success"] == false, "Roll 50 fails for 10% base chance")
+
+ var r_succ := HorseSystem.try_summon_horse(HorseSystem.HORSE_TIER_BEGINNER, 150, 0, 5)
+ _assert(r_succ["success"] == true, "Roll 5 succeeds for 10% base chance")
+
+ # Higher summon skill adds +10% per level
+ var chance_lv5 := HorseSystem.calculate_summon_chance(HorseSystem.HORSE_TIER_BEGINNER, 5)
+ _assert(chance_lv5 == 60, "Skill Lv 5 adds +50% chance (10 + 50 = 60%)")
+
+func _test_ctrl_h_ride() -> void:
+ print("\n--- Test 3: Ctrl+H (/user_horse_ride) State Machine ---")
+ # 1. Horse not summoned -> reject
+ var r1 := HorseSystem.handle_user_horse_ride(false, false, false, false)
+ _assert(r1["action"] == "none" and r1["err"] == "horse_not_summoned", "Cannot ride if horse is not summoned")
+
+ # 2. Horse summoned -> mount
+ var r2 := HorseSystem.handle_user_horse_ride(false, true, false, false)
+ _assert(r2["action"] == "start_riding" and r2["is_riding"] == true, "Starts riding when horse is summoned")
+
+ # 3. Already riding -> dismount
+ var r3 := HorseSystem.handle_user_horse_ride(true, true, false, false)
+ _assert(r3["action"] == "stop_riding" and r3["is_riding"] == false, "Stops riding when already mounted")
+
+ # 4. Dead/stun -> no action
+ var r4 := HorseSystem.handle_user_horse_ride(false, true, false, true)
+ _assert(r4["action"] == "none" and r4["err"] == "dead_or_stun", "No action while dead or stunned")
+
+func _test_ctrl_b_back() -> void:
+ print("\n--- Test 4: Ctrl+B (/user_horse_back) State Machine ---")
+ # 1. Currently riding -> must dismount first
+ var r1 := HorseSystem.handle_user_horse_back(true, true)
+ _assert(r1["action"] == "none" and r1["err"] == "must_dismount_first", "Must dismount before sending horse back")
+
+ # 2. Horse not summoned -> cannot send back
+ var r2 := HorseSystem.handle_user_horse_back(false, false)
+ _assert(r2["action"] == "none" and r2["err"] == "horse_not_summoned", "Cannot send back if not summoned")
+
+ # 3. Summoned & unmounted -> unsummon
+ var r3 := HorseSystem.handle_user_horse_back(false, true)
+ _assert(r3["action"] == "unsummon" and r3["is_horse_summoned"] == false, "Successfully unsummons horse")
+
+func _test_ctrl_g_ride() -> void:
+ print("\n--- Test 5: Ctrl+G (/ride) State Machine ---")
+ # 1. Dismount if already riding
+ var r1 := HorseSystem.handle_ride(true, true, false)
+ _assert(r1["action"] == "stop_riding", "/ride dismounts if riding")
+
+ # 2. Mount summoned horse
+ var r2 := HorseSystem.handle_ride(false, true, false)
+ _assert(r2["action"] == "start_riding", "/ride mounts summoned horse")
+
+ # 3. Mount item from inventory if horse not summoned
+ var r3 := HorseSystem.handle_ride(false, false, true)
+ _assert(r3["action"] == "mount_item", "/ride mounts ride item from inventory")
+
+func _test_ctrl_f_feed() -> void:
+ print("\n--- Test 6: Ctrl+F (/user_horse_feed) ---")
+ var f1 := HorseSystem.handle_user_horse_feed(HorseSystem.HORSE_TIER_COMBAT, HorseSystem.ITEM_FOOD_HAY)
+ _assert(f1["success"] == false, "Combat horse rejects Hay")
+
+ var f2 := HorseSystem.handle_user_horse_feed(HorseSystem.HORSE_TIER_COMBAT, HorseSystem.ITEM_FOOD_CARROT)
+ _assert(f2["success"] == true, "Combat horse accepts Carrot")
+
+func _test_mounted_combat_ranges() -> void:
+ print("\n--- Test 7: Combat & Pickup Ranges (3.0m vs 5.0m) ---")
+ _assert(HorseSystem.WALKING_ATTACK_RANGE == 3.0, "Walking attack range is 3.0m (300cm)")
+ _assert(HorseSystem.MOUNTED_ATTACK_RANGE == 5.0, "Mounted attack range is 5.0m (500cm)")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_mount_horse_parity.gd.uid b/project/test_mount_horse_parity.gd.uid
new file mode 100644
index 00000000..db95bc4f
--- /dev/null
+++ b/project/test_mount_horse_parity.gd.uid
@@ -0,0 +1 @@
+uid://hkfvw8m4jppi
diff --git a/project/test_mounted_combat_skill_parity.gd b/project/test_mounted_combat_skill_parity.gd
new file mode 100644
index 00000000..ad11f2ca
--- /dev/null
+++ b/project/test_mounted_combat_skill_parity.gd
@@ -0,0 +1,140 @@
+# test_mounted_combat_skill_parity.gd —— 40250 军马冲杀与马背技能 1:1 回归测试套件
+extends SceneTree
+
+const MountedCombatSkillSystem = preload("res://mounted_combat_skill_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mounted_combat_skill_parity (Direction 1: Mounted Combat 1:1) ===")
+ _test_horse_tiers_and_mounting()
+ _test_mounted_basic_attacks()
+ _test_military_horse_skills()
+ _test_stamina_and_feeding()
+ _finish()
+
+func _test_horse_tiers_and_mounting() -> void:
+ print("\n--- Test 1: Horse Tiers & Mounting Speeds ---")
+ var sys := MountedCombatSkillSystem.new()
+
+ # Normal Horse (lvl 5)
+ var r1 = sys.mount_horse(5)
+ _assert(r1["tier"] == MountedCombatSkillSystem.HorseTier.NORMAL, "Lvl 5 is NORMAL horse")
+ _assert(r1["speed_bonus"] == 30, "Normal horse gives +30 speed")
+ sys.unmount_horse()
+
+ # Combat Horse (lvl 15)
+ var r2 = sys.mount_horse(15)
+ _assert(r2["tier"] == MountedCombatSkillSystem.HorseTier.COMBAT, "Lvl 15 is COMBAT horse")
+ _assert(r2["speed_bonus"] == 40, "Combat horse gives +40 speed")
+ sys.unmount_horse()
+
+ # Military Horse (lvl 25)
+ var r3 = sys.mount_horse(25)
+ _assert(r3["tier"] == MountedCombatSkillSystem.HorseTier.MILITARY, "Lvl 25 is MILITARY horse")
+ _assert(r3["speed_bonus"] == 50, "Military horse gives +50 speed")
+ sys.unmount_horse()
+
+func _test_mounted_basic_attacks() -> void:
+ print("\n--- Test 2: Mounted Basic Attacks ---")
+ var sys := MountedCombatSkillSystem.new()
+
+ # Normal horse cannot attack
+ sys.mount_horse(5)
+ var fail_atk = sys.execute_mounted_attack(100, ["mob1", "mob2"])
+ _assert(fail_atk["ok"] == false and fail_atk["reason"] == "TIER_TOO_LOW", "Normal horse cannot attack")
+ sys.unmount_horse()
+
+ # Combat horse can attack
+ sys.mount_horse(15)
+ var atk_sig := {"fired": false, "hits": 0, "dmg": 0}
+ sys.mounted_attack_executed.connect(func(h: int, d: int):
+ atk_sig["fired"] = true
+ atk_sig["hits"] = h
+ atk_sig["dmg"] = d
+ )
+
+ var res = sys.execute_mounted_attack(500, ["m1", "m2", "m3"])
+ _assert(res["ok"] == true, "Combat horse attacked")
+ _assert(res["hit_count"] == 3, "Hit 3 targets")
+ _assert(res["dmg_per_hit"] == int(500 * 1.2), "Deals 1.2x base attack (600)")
+ _assert(res["total_damage"] == 1800, "Total damage is 1800")
+ _assert(atk_sig["fired"] == true and atk_sig["dmg"] == 1800, "mounted_attack_executed signal fired")
+ _assert(sys.stamina == 99, "Stamina consumed 100 -> 99")
+ sys.unmount_horse()
+
+func _test_military_horse_skills() -> void:
+ print("\n--- Test 3: Military Horse Skills (121, 122, 123) ---")
+ var sys := MountedCombatSkillSystem.new()
+ sys.mount_horse(25) # Military horse
+
+ var player := {"sp": 300}
+ var skill_sig := {"fired": false, "vnum": 0, "dmg": 0}
+ sys.horse_skill_cast.connect(func(v: int, _n: String, d: int, _h: int):
+ skill_sig["fired"] = true
+ skill_sig["vnum"] = v
+ skill_sig["dmg"] = d
+ )
+
+ # Cast 121: Horse Charge (2.5x dmg, SP 60, cd 12.0)
+ var c1 = sys.cast_horse_skill(MountedCombatSkillSystem.SKILL_HORSE_CHARGE, 1000, player, ["m1", "m2"])
+ _assert(c1["ok"] == true, "Horse Charge cast successfully")
+ _assert(c1["damage"] == 2500, "Damage = 1000 * 2.5 = 2500")
+ _assert(player["sp"] == 240, "SP deducted (300 - 60 = 240)")
+ _assert(skill_sig["fired"] == true and skill_sig["vnum"] == 121, "horse_skill_cast emitted")
+
+ # Second cast while on cooldown must fail
+ var fail_cd = sys.cast_horse_skill(MountedCombatSkillSystem.SKILL_HORSE_CHARGE, 1000, player, ["m1"])
+ _assert(fail_cd["ok"] == false and fail_cd["reason"] == "SKILL_ON_COOLDOWN", "Cooldown enforced")
+
+ # Update 12 seconds
+ sys.update(12.5)
+
+ # Cast 122: Horse Stomp (2.0x dmg, SP 75)
+ var c2 = sys.cast_horse_skill(MountedCombatSkillSystem.SKILL_HORSE_STOMP, 1000, player, ["m1", "m2", "m3", "m4"])
+ _assert(c2["ok"] == true and c2["damage"] == 2000, "Horse Stomp deals 2.0x (2000)")
+ _assert(player["sp"] == 165, "SP is now 240 - 75 = 165")
+
+func _test_stamina_and_feeding() -> void:
+ print("\n--- Test 4: Stamina Depletion & Feeding ---")
+ var sys := MountedCombatSkillSystem.new()
+ sys.mount_horse(21)
+ sys.stamina = 10 # Low stamina
+
+ var inv: Array = [
+ {"vnum": 50055, "name": "胡萝卜", "count": 2}
+ ]
+
+ var feed_sig := {"fired": false, "rec": 0}
+ sys.horse_fed.connect(func(_n: String, rec: int):
+ feed_sig["fired"] = true
+ feed_sig["rec"] = rec
+ )
+
+ var res = sys.feed_horse(50055, inv)
+ _assert(res["ok"] == true, "Fed carrot")
+ _assert(res["recovered"] == 60, "Carrot restores 60 stamina")
+ _assert(sys.stamina == 70, "Stamina is now 10 + 60 = 70")
+ _assert(inv[0]["count"] == 1, "Carrot count decreased 2 -> 1")
+ _assert(feed_sig["fired"] == true and feed_sig["rec"] == 60, "horse_fed emitted")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Mounted Combat Skill Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Mounted Combat Skill parity tests passed!")
+ quit(0)
diff --git a/project/test_mounted_combat_skill_parity.gd.uid b/project/test_mounted_combat_skill_parity.gd.uid
new file mode 100644
index 00000000..eb674c0b
--- /dev/null
+++ b/project/test_mounted_combat_skill_parity.gd.uid
@@ -0,0 +1 @@
+uid://cj2k305egsmpd
diff --git a/project/test_mysterious_merchant_blackmarket_parity.gd b/project/test_mysterious_merchant_blackmarket_parity.gd
new file mode 100644
index 00000000..6f097024
--- /dev/null
+++ b/project/test_mysterious_merchant_blackmarket_parity.gd
@@ -0,0 +1,214 @@
+# test_mysterious_merchant_blackmarket_parity.gd —— 40250 野外神秘游商与黑市折扣系统 1:1 回归测试套件
+extends SceneTree
+
+const MysteriousMerchantBlackmarketSystem = preload("res://mysterious_merchant_blackmarket_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_mysterious_merchant_blackmarket_parity (Direction 4: Mysterious Merchant Blackmarket 1:1) ===")
+ _test_merchant_spawn_and_lifecycle()
+ _test_blackmarket_goods_and_discount_purchase()
+ _test_high_price_buyback_and_antiflag()
+ _test_serialization_and_persistence()
+ _finish()
+
+func _test_merchant_spawn_and_lifecycle() -> void:
+ print("\n--- Test 1: Merchant Spawn & Lifecycle ---")
+ var sys := MysteriousMerchantBlackmarketSystem.new()
+ _assert(not sys.is_active, "Initially merchant is not active")
+
+ var spawn_sig := {"fired": false, "map": "", "pos": Vector2.ZERO, "dur": 0.0}
+ sys.merchant_appeared.connect(func(m: String, p: Vector2, d: float):
+ spawn_sig["fired"] = true
+ spawn_sig["map"] = m
+ spawn_sig["pos"] = p
+ spawn_sig["dur"] = d
+ )
+
+ var depart_sig := {"fired": false}
+ sys.merchant_departed.connect(func():
+ depart_sig["fired"] = true
+ )
+
+ # Spawn at fixed location index 0 (Desert)
+ var spawn_res = sys.spawn_random_merchant(1800.0, 0)
+ _assert(spawn_res["ok"] == true, "Spawn result ok")
+ _assert(sys.is_active == true, "System active flag is true")
+ _assert(sys.current_map == "Desert", "Spawned at Desert")
+ _assert(sys.current_pos == Vector2(120, 350), "Spawn pos matches Desert Oasis")
+ _assert(spawn_sig["fired"] == true, "merchant_appeared signal fired")
+ _assert(spawn_sig["dur"] == 1800.0, "Signal duration is 1800.0s")
+
+ # Update heartbeat - advance 600s
+ sys.update(600.0)
+ _assert(is_equal_approx(sys.time_remaining, 1200.0), "Time remaining decremented to 1200s")
+ _assert(sys.is_active == true, "Still active after 600s")
+
+ # Update heartbeat - advance remaining 1200s to timeout
+ sys.update(1200.0)
+ _assert(sys.is_active == false, "Merchant despawned after timeout")
+ _assert(depart_sig["fired"] == true, "merchant_departed signal fired on timeout")
+ _assert(sys.get_stock().is_empty(), "Stock cleared after merchant departed")
+
+func _test_blackmarket_goods_and_discount_purchase() -> void:
+ print("\n--- Test 2: Black Market Goods & Discount Purchases ---")
+ var sys := MysteriousMerchantBlackmarketSystem.new()
+ sys.spawn_random_merchant(1800.0, 1) # Mount_Sohan
+ _assert(sys.current_map == "Mount_Sohan", "Spawned at Mount Sohan")
+
+ var stock = sys.get_stock()
+ _assert(stock.size() == 5, "Stock has 5 unique blackmarket items")
+
+ # Check item 0 (Cor Draconis): base 2M, blackmarket price 1M (50% off)
+ _assert(stock[0]["vnum"] == 51501, "First item is Cor Draconis (51501)")
+ _assert(stock[0]["price"] == 1000000, "Half-price discount 1,000,000 gold")
+ _assert(stock[0]["base_price"] == 2000000, "Base price 2,000,000 gold")
+ _assert(stock[0]["stock"] == 5, "Initial stock is 5")
+
+ var player := {"gold": 500000} # Only 500k, not enough for 1M
+ var inv: Array = [null, null, null]
+
+ # Attempt buy with insufficient gold
+ var buy_fail = sys.buy_item(0, player, inv)
+ _assert(buy_fail["ok"] == false, "Buy failed with insufficient gold")
+ _assert(buy_fail["reason"] == "NOT_ENOUGH_GOLD", "Failure reason is NOT_ENOUGH_GOLD")
+
+ # Give enough gold
+ player["gold"] = 3000000
+ var bought_sig := {"fired": false, "name": "", "cost": 0}
+ sys.item_bought.connect(func(n: String, c: int):
+ bought_sig["fired"] = true
+ bought_sig["name"] = n
+ bought_sig["cost"] = c
+ )
+
+ var buy_succ = sys.buy_item(0, player, inv)
+ _assert(buy_succ["ok"] == true, "Buy succeeded with sufficient gold")
+ _assert(buy_succ["cost"] == 1000000, "Cost is 1,000,000 gold")
+ _assert(player["gold"] == 2000000, "Player gold deducted properly to 2,000,000")
+ _assert(inv[0] != null and inv[0]["vnum"] == 51501, "Cor Draconis placed into slot 0")
+ _assert(bought_sig["fired"] == true, "item_bought signal fired")
+ _assert(bought_sig["cost"] == 1000000, "Signal reported correct cost")
+
+ # Check remaining stock
+ var updated_stock = sys.get_stock()
+ _assert(updated_stock[0]["stock"] == 4, "Stock decremented from 5 to 4")
+
+ # Test inventory full
+ inv[1] = {"vnum": 1, "name": "Item 1"}
+ inv[2] = {"vnum": 2, "name": "Item 2"}
+ var buy_full = sys.buy_item(0, player, inv)
+ _assert(buy_full["ok"] == false, "Buy failed when inventory full")
+ _assert(buy_full["reason"] == "INVENTORY_FULL", "Failure reason is INVENTORY_FULL")
+
+ # Test out of stock by depleting item 4 (Legendary Elixir, stock=2)
+ var big_inv: Array = [null, null, null, null, null]
+ player["gold"] = 10000000
+ var b1 = sys.buy_item(4, player, big_inv)
+ _assert(b1["ok"] == true, "Bought 1st Legendary Elixir")
+ var b2 = sys.buy_item(4, player, big_inv)
+ _assert(b2["ok"] == true, "Bought 2nd Legendary Elixir")
+ var b3 = sys.buy_item(4, player, big_inv)
+ _assert(b3["ok"] == false, "3rd buy rejected")
+ _assert(b3["reason"] == "OUT_OF_STOCK", "Reason is OUT_OF_STOCK")
+
+func _test_high_price_buyback_and_antiflag() -> void:
+ print("\n--- Test 3: High-Price Buyback (+30% Markup) & Anti-Flags ---")
+ var sys := MysteriousMerchantBlackmarketSystem.new()
+ sys.spawn_random_merchant(1800.0, 2) # Doyyumhwan
+
+ var player := {"gold": 100000}
+ # Base price 10,000. Standard sell (1/5) = 2,000.
+ # High-price buyback (+30%) = 2,000 * 1.3 = 2,600.
+ var test_rare_item := {
+ "vnum": 70005,
+ "name": "极品黑铁矿",
+ "count": 2,
+ "shop_buy_price": 10000,
+ "anti_flags": 0
+ }
+
+ # For count=2: unit=2600 * 2 = 5200.
+ var calculated_price = sys.calculate_sell_price(test_rare_item)
+ _assert(calculated_price == 5200, "High-price buyback calculated 5200 gold (2600 * 2)")
+
+ var inv: Array = [test_rare_item, null]
+
+ var sold_sig := {"fired": false, "name": "", "earned": 0}
+ sys.item_sold.connect(func(n: String, e: int):
+ sold_sig["fired"] = true
+ sold_sig["name"] = n
+ sold_sig["earned"] = e
+ )
+
+ var sell_res = sys.sell_item(0, player, inv)
+ _assert(sell_res["ok"] == true, "Sold rare item to wandering merchant")
+ _assert(sell_res["earned"] == 5200, "Earned 5200 gold")
+ _assert(player["gold"] == 105200, "Player gold increased to 105,200")
+ _assert(inv[0] == null, "Inventory slot 0 cleared")
+ _assert(sold_sig["fired"] == true, "item_sold signal fired")
+ _assert(sold_sig["earned"] == 5200, "Signal reported earned gold")
+
+ # Test unsellable item with ITEM_ANTIFLAG_SELL (256)
+ var unsellable_item := {
+ "vnum": 90001,
+ "name": "绑定专属神兵",
+ "count": 1,
+ "shop_buy_price": 500000,
+ "anti_flags": 256
+ }
+ inv[0] = unsellable_item
+ var sell_unsellable = sys.sell_item(0, player, inv)
+ _assert(sell_unsellable["ok"] == false, "Unsellable item rejected")
+ _assert(sell_unsellable["reason"] == "ITEM_ANTIFLAG_SELL", "Reason is ITEM_ANTIFLAG_SELL")
+ _assert(inv[0] != null, "Unsellable item not removed from inventory")
+
+ # Test merchant inactive
+ sys.despawn_merchant()
+ var sell_inactive = sys.sell_item(0, player, inv)
+ _assert(sell_inactive["ok"] == false, "Sell rejected when merchant inactive")
+ _assert(sell_inactive["reason"] == "MERCHANT_NOT_ACTIVE", "Reason is MERCHANT_NOT_ACTIVE")
+
+func _test_serialization_and_persistence() -> void:
+ print("\n--- Test 4: Serialization & Save/Load Parity ---")
+ var sys1 := MysteriousMerchantBlackmarketSystem.new()
+ sys1.spawn_merchant("Desert", Vector2(120, 350), 1500.0)
+ sys1.update(300.0) # 1200s left
+
+ # Buy 1 Cor Draconis so stock changes
+ var player := {"gold": 5000000}
+ var inv: Array = [null]
+ sys1.buy_item(0, player, inv)
+ _assert(sys1.get_stock()[0]["stock"] == 4, "Sys1 stock of item 0 is 4")
+
+ var save_data = sys1.serialize()
+ _assert(save_data["is_active"] == true, "Serialized is_active true")
+ _assert(save_data["current_map"] == "Desert", "Serialized map matches")
+ _assert(save_data["stock"][0]["stock"] == 4, "Serialized stock of item 0 is 4")
+
+ var sys2 := MysteriousMerchantBlackmarketSystem.new()
+ sys2.deserialize(save_data)
+ _assert(sys2.is_active == true, "Sys2 restored is_active")
+ _assert(sys2.current_map == "Desert", "Sys2 restored current_map")
+ _assert(sys2.current_pos == Vector2(120, 350), "Sys2 restored current_pos")
+ _assert(is_equal_approx(sys2.time_remaining, 1200.0), "Sys2 restored time_remaining")
+ _assert(sys2.get_stock()[0]["stock"] == 4, "Sys2 restored stock count 4")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Mysterious Merchant & Blackmarket Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_mysterious_merchant_blackmarket_parity.gd.uid b/project/test_mysterious_merchant_blackmarket_parity.gd.uid
new file mode 100644
index 00000000..67ad95f6
--- /dev/null
+++ b/project/test_mysterious_merchant_blackmarket_parity.gd.uid
@@ -0,0 +1 @@
+uid://dno7ti6084jdx
diff --git a/project/test_no_auto_move_regression.gd b/project/test_no_auto_move_regression.gd
new file mode 100644
index 00000000..965f8df0
--- /dev/null
+++ b/project/test_no_auto_move_regression.gd
@@ -0,0 +1,233 @@
+# test_no_auto_move_regression.gd
+# Regression test suite verifying that the character does not move automatically
+# without user input under any circumstances (target picking, skill casting, attack key release).
+extends SceneTree
+
+const PlayerCtl = preload("res://player_controller.gd")
+const NetPlay = preload("res://net_play.gd")
+const NetWorld = preload("res://net_world.gd")
+
+var _failed := 0
+
+func _check(condition: bool, msg: String) -> void:
+ if condition:
+ print(" [PASS] %s" % msg)
+ else:
+ print(" [FAIL] %s" % msg)
+ _failed += 1
+
+class MockClient extends Node:
+ signal entity_main_set(vid: int)
+ signal entity_moved(vid: int)
+ signal points_changed(points: Dictionary)
+ signal vitals_changed(vid: int)
+ signal target_info(vid: int, hp_percent: int)
+ signal entity_despawned(vid: int)
+ signal entity_dead(vid: int)
+
+ var ents: Dictionary = {}
+ func get_entity(vid: int) -> Dictionary:
+ return ents.get(vid, {})
+ func is_in_game() -> bool:
+ return true
+ func set_target(_vid: int) -> void:
+ pass
+ func move(_func_id: int, _arg: int, _rot: int, _x: int, _y: int) -> void:
+ pass
+ func fishing(_rot: float) -> bool:
+ return false
+
+func _init() -> void:
+ print("=== Running test_no_auto_move_regression ===")
+ _test_pick_actor_engages_auto_attack_and_can_cancel()
+ _test_skill_cast_clears_movement_and_reservation()
+ _test_spacebar_attack_release_stops_movement()
+ _test_manual_movement_clears_reservation()
+ _test_clear_target_stops_pc()
+
+ if _failed == 0:
+ print("=== ALL NO-AUTO-MOVE REGRESSION TESTS PASSED (0 failures) ===")
+ quit(0)
+ else:
+ printerr("=== TESTS FAILED: %d errors ===" % _failed)
+ quit(1)
+
+func _setup_rig() -> Dictionary:
+ var client := MockClient.new()
+ var net_world := NetWorld.new()
+ var pc := PlayerCtl.new()
+ var player_node := Node3D.new()
+ player_node.position = Vector3(0, 0, 0)
+ pc.player = player_node
+ pc.active = true
+
+ var np := NetPlay.new()
+ np.setup(client, pc, net_world)
+
+ # Main player entity
+ client.ents[1000] = {
+ "vid": 1000,
+ "kind": 0,
+ "empire": 1,
+ "x": 0, "y": 0, "z": 0,
+ "dead": false,
+ "in_safe": false
+ }
+ np._main_vid = 1000
+
+ # Mob entity at (20, 0, 0)
+ var mob_node := Node3D.new()
+ mob_node.position = Vector3(20, 0, 0)
+ mob_node.set_meta("vid", 2000)
+ net_world._by_vid[2000] = mob_node
+ client.ents[2000] = {
+ "vid": 2000,
+ "kind": 2, # KIND_MONSTER
+ "empire": 0,
+ "x": 2000, "y": 0, "z": 0,
+ "dead": false,
+ "in_safe": false
+ }
+
+ return {
+ "client": client,
+ "net_world": net_world,
+ "pc": pc,
+ "player_node": player_node,
+ "np": np,
+ "mob_node": mob_node
+ }
+
+func _cleanup_rig(rig: Dictionary) -> void:
+ rig.mob_node.free()
+ rig.player_node.free()
+ rig.pc.free()
+ rig.np.free()
+ rig.net_world.free()
+ rig.client.free()
+
+# 1. Clicking an enemy sets _auto_attack_vid to target (40250 __SetAutoAttackTargetActorID)
+func _test_pick_actor_engages_auto_attack_and_can_cancel() -> void:
+ print("\n--- Test 1: Actor pick engages auto attack (40250 parity) and can be cancelled ---")
+ var rig := _setup_rig()
+ var np: NetPlay = rig.np
+ var mob_node: Node3D = rig.mob_node
+
+ # Simulate clicking the mob
+ np._on_pick(mob_node)
+
+ _check(np._target_vid == 2000, "Target VID is set to picked mob")
+ _check(np._auto_attack_vid == 2000, "_auto_attack_vid is set to mob VID for auto attack")
+
+ # Manual movement (WASD) cancels auto-attack
+ np.on_manual_move()
+ _check(np._auto_attack_vid == 0, "_auto_attack_vid cleared on manual move")
+
+ _cleanup_rig(rig)
+
+# 2. Casting a skill must stop character movement, clear reservations, and not resume after finish
+func _test_skill_cast_clears_movement_and_reservation() -> void:
+ print("\n--- Test 2: Skill cast clears movement and does not resume after skill ends ---")
+ var rig := _setup_rig()
+ var np: NetPlay = rig.np
+ var pc: PlayerCtl = rig.pc
+ var player_node: Node3D = rig.player_node
+ var mob_node: Node3D = rig.mob_node
+
+ # Player targets mob and begins moving towards it
+ np._on_pick(mob_node)
+ pc.walk_to(Vector3(15, 0, 0))
+ _check(pc.is_going() == true, "pc is walking before skill cast")
+
+ # Cast skill (e.g. warrior mental skill)
+ np.start_skill_cast(0.5, false)
+
+ _check(pc.is_going() == false, "pc.is_going() is immediately stopped on skill cast")
+ _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "np._reserved_mode cleared on skill cast")
+ _check(np._auto_attack_vid == 0, "np._auto_attack_vid is 0 on skill cast")
+
+ var initial_pos := player_node.position
+
+ # Simulate frames during skill lock
+ for i in 5:
+ np._process(0.1)
+ pc._process(0.1)
+
+ _check(player_node.position == initial_pos, "player remains stationary during skill cast")
+
+ # Simulate skill timeout / unlocking
+ np._local_time += 1.0
+ _check(np.is_lock() == false, "np is unlocked after skill duration")
+
+ # Simulate frames AFTER skill completes (no player input)
+ for i in 10:
+ np._process(0.1)
+ pc._process(0.1)
+
+ _check(pc.is_going() == false, "pc.is_going() remains false after skill completion")
+ _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation remains NONE after skill completion")
+ _check(np._auto_attack_vid == 0, "_auto_attack_vid remains 0 after skill completion")
+ _check(player_node.position == initial_pos, "player position unchanged (no spontaneous movement!)")
+
+ _cleanup_rig(rig)
+
+# 3. Spacebar attack release stops movement and clears auto-attack
+func _test_spacebar_attack_release_stops_movement() -> void:
+ print("\n--- Test 3: Releasing attack key (Spacebar) cancels auto-attack and stops movement ---")
+ var rig := _setup_rig()
+ var np: NetPlay = rig.np
+ var pc: PlayerCtl = rig.pc
+
+ np._target_vid = 2000
+ np.set_attack_key(true)
+ np._update_auto_attack()
+
+ _check(np._auto_attack_vid == 2000, "_auto_attack_vid set while holding space with target")
+
+ # Releasing attack key
+ np.set_attack_key(false)
+
+ _check(np._auto_attack_vid == 0, "_auto_attack_vid cleared on spacebar release")
+ _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "_reserved_mode cleared on spacebar release")
+ _check(pc.is_going() == false, "pc stopped on spacebar release")
+
+ _cleanup_rig(rig)
+
+# 4. Manual movement (WASD) cancels any pending reserved actions and auto-attack
+func _test_manual_movement_clears_reservation() -> void:
+ print("\n--- Test 4: Manual movement cancels reservations and auto-attack ---")
+ var rig := _setup_rig()
+ var np: NetPlay = rig.np
+ var pc: PlayerCtl = rig.pc
+
+ np._reserve_click_actor(2000)
+ np._auto_attack_vid = 2000
+ _check(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "Reserved mode is CLICK_ACTOR")
+
+ # Player presses WASD -> on_manual_move() called
+ np.on_manual_move()
+
+ _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "on_manual_move cleared reserved action")
+ _check(np._auto_attack_vid == 0, "on_manual_move cleared auto-attack")
+
+ _cleanup_rig(rig)
+
+# 5. Clearing target stops pc
+func _test_clear_target_stops_pc() -> void:
+ print("\n--- Test 5: Clearing target stops player controller ---")
+ var rig := _setup_rig()
+ var np: NetPlay = rig.np
+ var pc: PlayerCtl = rig.pc
+
+ np._target_vid = 2000
+ pc.walk_to(Vector3(10, 0, 0))
+ _check(pc.is_going() == true, "pc is going towards destination")
+
+ np._clear_target()
+
+ _check(np._target_vid == 0, "target vid is cleared")
+ _check(pc.is_going() == false, "pc.is_going() cleared on target clear")
+ _check(np._auto_attack_vid == 0, "_auto_attack_vid is 0")
+ _check(np._reserved_mode == NetPlay.ReservedMode.NONE, "reserved mode is NONE")
+
+ _cleanup_rig(rig)
diff --git a/project/test_no_auto_move_regression.gd.uid b/project/test_no_auto_move_regression.gd.uid
new file mode 100644
index 00000000..d447ce65
--- /dev/null
+++ b/project/test_no_auto_move_regression.gd.uid
@@ -0,0 +1 @@
+uid://bkm404aot1ude
diff --git a/project/test_npc_shop_flow.gd b/project/test_npc_shop_flow.gd
new file mode 100644
index 00000000..11f7c34f
--- /dev/null
+++ b/project/test_npc_shop_flow.gd
@@ -0,0 +1,462 @@
+# test_npc_shop_flow.gd —— Phase 2 NPC 交互与商店系统 40250 行为对齐验证
+extends SceneTree
+
+const ShopUI = preload("res://ui/shop_ui.gd")
+const NetPlay = preload("res://net_play.gd")
+const EntityRules = preload("res://entity_rules.gd")
+const ItemTooltip = preload("res://ui/item_tooltip.gd")
+
+var _failed := false
+var _pass_count := 0
+
+func _check(ok: bool, msg: String) -> void:
+ if ok:
+ _pass_count += 1
+ print(" [PASS] " + msg)
+ else:
+ _failed = true
+ push_error("FAIL: " + msg)
+ print(" [FAIL] " + msg)
+
+class MockClient extends Node:
+ signal shop_opened(vid: int)
+ signal shop_closed
+ signal shop_updated(pos: int)
+ signal shop_error(kind: String)
+ signal entity_main_set(vid: int)
+ signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int)
+ signal points_changed()
+ signal vitals_changed()
+ signal target_info(info: Dictionary)
+ signal entity_despawned(vid: int)
+ signal entity_dead(vid: int)
+
+ var entities: Dictionary = {}
+ var main_vid := 1000
+ var click_calls: Array = []
+ var buy_calls: Array = []
+ var sell_calls: Array = []
+ var close_calls := 0
+ var shop_data: Dictionary = {"tabs": []}
+
+ func is_in_game() -> bool:
+ return true
+
+ func get_main_vid() -> int:
+ return main_vid
+
+ func get_entity(vid: int) -> Dictionary:
+ return entities.get(vid, {})
+
+ func click_npc(vid: int) -> bool:
+ click_calls.append(vid)
+ return true
+
+ func get_shop() -> Dictionary:
+ return shop_data
+
+ func get_shop_items() -> Array:
+ var tabs: Array = shop_data.get("tabs", [])
+ if not tabs.is_empty():
+ return tabs[0].get("items", [])
+ return []
+
+ func shop_buy(pos: int, count: int) -> bool:
+ buy_calls.append({"pos": pos, "count": count})
+ return true
+
+ func shop_sell(cell: int, count: int) -> bool:
+ sell_calls.append({"cell": cell, "count": count})
+ return true
+
+ func shop_close() -> void:
+ close_calls += 1
+ shop_closed.emit()
+
+ func get_inventory() -> Array:
+ return []
+
+class MockPlayerController extends Node:
+ var player: Node3D
+ var stopped := false
+ var walk_dest := Vector3.ZERO
+ var going := false
+ signal target_selected(node: Node3D)
+ signal moved(is_moving: bool)
+ signal anim_state(s: String)
+
+ func is_going() -> bool:
+ return going
+
+ func stop() -> void:
+ stopped = true
+ going = false
+
+ func walk_to(dest: Vector3) -> void:
+ walk_dest = dest
+ going = true
+ stopped = false
+
+class MockNetWorld extends Node:
+ var nodes: Dictionary = {}
+ func node_for(vid: int) -> Node3D:
+ return nodes.get(vid, null)
+
+class MockProto extends Node:
+ var items: Dictionary = {}
+ func item(vnum: int) -> Dictionary:
+ return items.get(vnum, {})
+
+func _init() -> void:
+ print("=== Running test_npc_shop_flow (Phase 2 Parity) ===")
+ _test_npc_interaction()
+ _test_npc_click_throttle()
+ _test_shop_data_and_sockets()
+ _test_shop_multi_tab_and_buy_index()
+ _test_shop_incremental_update()
+ _test_shop_direct_buy_right_click()
+ _test_shop_distance_auto_close()
+
+ if _failed:
+ print("\n=== NPC & SHOP TESTS FAILED ===")
+ quit(1)
+ else:
+ print("\n=== ALL NPC & SHOP TESTS PASSED (%d checks) ===" % _pass_count)
+ quit(0)
+
+# --- Test 1: NPC Interaction (500cm range & reservation) ---
+func _test_npc_interaction() -> void:
+ print("\n--- Test 1: NPC Interaction Distance & Reservation ---")
+ var client := MockClient.new()
+ root.add_child(client)
+ var pc := MockPlayerController.new()
+ root.add_child(pc)
+ var player_node := Node3D.new()
+ root.add_child(player_node)
+ player_node.position = Vector3(0, 0, 0)
+ pc.player = player_node
+
+ var nw := MockNetWorld.new()
+ root.add_child(nw)
+ var npc_node := Node3D.new()
+ root.add_child(npc_node)
+ npc_node.position = Vector3(3.0, 0, 0) # 3.0m <= 5.0m (500cm)
+ npc_node.set_meta("vid", 2001)
+ nw.nodes[2001] = npc_node
+
+ client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3(0,0,0)} # PC
+ client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(3,0,0), "name": "General Store"} # NPC
+
+ var np := NetPlay.new()
+ root.add_child(np)
+ np.setup(client, pc, nw, null)
+ np._main_vid = 1000
+
+ # 1.1: Click within 5.0m -> Immediate click, stops, faces NPC
+ np._on_pick(npc_node)
+ _check(client.click_calls == [2001], "NPC click within 5m immediately sends click_npc packet")
+ _check(pc.stopped, "Character stops when interacting with NPC")
+ _check(abs(player_node.rotation.y - (PI / 2.0)) < 0.05, "Character faces toward NPC (+X direction)")
+
+ # 1.2: Move NPC far away (8.0m > 5.0m)
+ npc_node.position = Vector3(8.0, 0, 0)
+ client.entities[2001]["pos"] = Vector3(8,0,0)
+ client.click_calls.clear()
+ pc.stopped = false
+
+ # Reset throttle timer for test
+ np._last_click_actor_t = -10.0
+
+ np._on_pick(npc_node)
+ _check(client.click_calls.is_empty(), "NPC click beyond 5m does not immediately send packet")
+ _check(np._reserved_mode == np.ReservedMode.CLICK_ACTOR, "NPC click beyond 5m enters reservation mode (CLICK_ACTOR)")
+ _check(np._vid_reserved == 2001, "Reserved actor VID set to NPC VID")
+ _check(pc.going and pc.walk_dest == Vector3(8, 0, 0), "Character begins walking toward distant NPC")
+
+ # Character walks closer into 4.0m range
+ player_node.position = Vector3(4.5, 0, 0) # dist to 8.0 is 3.5m <= 5m
+ np._refresh_reserved()
+ _check(client.click_calls == [2001], "Reservation executes click_npc when entering 5m range")
+ _check(pc.stopped, "Reservation execution stops character")
+ _check(np._reserved_mode == np.ReservedMode.NONE, "Reservation cleared after execution")
+
+ np.queue_free()
+ player_node.queue_free()
+ npc_node.queue_free()
+ nw.queue_free()
+ pc.queue_free()
+ client.queue_free()
+
+# --- Test 2: NPC Click Throttle (1000ms protection) ---
+func _test_npc_click_throttle() -> void:
+ print("\n--- Test 2: NPC Click Throttle (40250 1000ms protection) ---")
+ var client := MockClient.new()
+ root.add_child(client)
+ var pc := MockPlayerController.new()
+ root.add_child(pc)
+ var pnode := Node3D.new()
+ root.add_child(pnode)
+ pc.player = pnode
+
+ var nw := MockNetWorld.new()
+ root.add_child(nw)
+ var npc1 := Node3D.new()
+ var npc2 := Node3D.new()
+ root.add_child(npc1)
+ root.add_child(npc2)
+ npc1.position = Vector3(1, 0, 0)
+ npc2.position = Vector3(2, 0, 0)
+ npc1.set_meta("vid", 2001)
+ npc2.set_meta("vid", 2002)
+ nw.nodes[2001] = npc1
+ nw.nodes[2002] = npc2
+
+ client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3.ZERO}
+ client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(1,0,0)}
+ client.entities[2002] = {"vid": 2002, "ch_type": 1, "pos": Vector3(2,0,0)}
+
+ var np := NetPlay.new()
+ root.add_child(np)
+ np.setup(client, pc, nw, null)
+ np._main_vid = 1000
+
+ # First click on NPC 2001
+ var r1 := np._send_click_actor_packet(2001)
+ _check(r1 and client.click_calls == [2001], "Initial click on NPC 2001 succeeds")
+
+ # Immediate second click on NPC 2001 (<1s) -> throttled
+ var r2 := np._send_click_actor_packet(2001)
+ _check(not r2 and client.click_calls == [2001], "Rapid second click on same NPC is throttled")
+
+ # Click different NPC 2002 -> immediately succeeds
+ var r3 := np._send_click_actor_packet(2002)
+ _check(r3 and client.click_calls == [2001, 2002], "Clicking different NPC is not throttled")
+
+ # Advance simulated time past 1.0s
+ np._last_click_actor_t -= 1.1
+ var r4 := np._send_click_actor_packet(2002)
+ _check(r4 and client.click_calls == [2001, 2002, 2002], "Clicking same NPC after >1.0s succeeds")
+
+ np.queue_free()
+ pnode.queue_free()
+ npc1.queue_free()
+ npc2.queue_free()
+ nw.queue_free()
+ pc.queue_free()
+ client.queue_free()
+
+# --- Test 3: Shop Sockets & Attrs Data Completeness ---
+func _test_shop_data_and_sockets() -> void:
+ print("\n--- Test 3: Shop Sockets & Attrs Data Preservation ---")
+ var client := MockClient.new()
+ var proto := MockProto.new()
+ proto.items[10] = {
+ "name": "Sword+0",
+ "locale_name": "剑+0",
+ "type": 1, # WEAPON
+ "values": [0, 0, 0, 15, 20, 0], # attack 15-20
+ }
+
+ # Item with 3 sockets and 2 attributes
+ var item_entry := {
+ "pos": 0,
+ "vnum": 10,
+ "count": 1,
+ "price": 1000,
+ "sockets": [28430, 0, 0],
+ "attrs": [
+ {"type": 1, "value": 500}, # MAX_HP + 500
+ {"type": 5, "value": 12}, # STR + 12
+ {"type": 0, "value": 0},
+ {"type": 0, "value": 0},
+ {"type": 0, "value": 0},
+ {"type": 0, "value": 0},
+ {"type": 0, "value": 0}
+ ]
+ }
+ client.shop_data = {
+ "tabs": [
+ {"name": "武器", "items": [item_entry]}
+ ]
+ }
+
+ var host := Control.new()
+ root.add_child(host)
+ var shop := ShopUI.new()
+ root.add_child(shop)
+ shop.setup(client, host, proto)
+ shop.open()
+
+ var slot_btn: Button = shop._grid.get_child(0)
+ _check(slot_btn != null, "Shop slot 0 created")
+ _check(slot_btn.tooltip_text.contains("最大生命:+500"), "Tooltip contains attribute 1 (MAX_HP +500)")
+ _check(slot_btn.tooltip_text.contains("力量:+12"), "Tooltip contains attribute 2 (STR +12)")
+ _check(slot_btn.tooltip_text.contains("价格:1000"), "Tooltip contains item price")
+
+ shop.queue_free()
+ host.queue_free()
+ client.queue_free()
+ proto.queue_free()
+
+# --- Test 4: Shop Multi-Tab & Buy Index Calculation ---
+func _test_shop_multi_tab_and_buy_index() -> void:
+ print("\n--- Test 4: Multi-tab Shop (START_EX) and Buy Index Parity ---")
+ var client := MockClient.new()
+ var proto := MockProto.new()
+ proto.items[10] = {"name": "Sword", "locale_name": "剑"}
+ proto.items[27001] = {"name": "Red Potion", "locale_name": "红药水"}
+
+ # 2 tabs: Tab 0 (Weapons), Tab 1 (Potions)
+ client.shop_data = {
+ "tabs": [
+ {
+ "name": "武器货架",
+ "items": [{"pos": 2, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}]
+ },
+ {
+ "name": "药品货架",
+ "items": [{"pos": 5, "vnum": 27001, "count": 50, "price": 200, "sockets": [], "attrs": []}]
+ }
+ ]
+ }
+
+ var host := Control.new()
+ root.add_child(host)
+ var shop := ShopUI.new()
+ root.add_child(shop)
+ shop.setup(client, host, proto)
+ shop.open()
+
+ _check(shop._tabbar.visible, "Multi-tab shop displays tabbar")
+ _check(shop._tabbar.get_child_count() == 2, "Tabbar contains 2 shelves")
+ _check(shop._tabbar.get_child(0).text == "武器货架", "Tab 0 name is 武器货架")
+ _check(shop._tabbar.get_child(1).text == "药品货架", "Tab 1 name is 药品货架")
+
+ # Check Tab 0 slot 2
+ var tab0_slot2: Button = shop._grid.get_child(2)
+ _check(not tab0_slot2.disabled and tab0_slot2.text.begins_with("剑"), "Tab 0 slot 2 is Sword")
+
+ # Switch to Tab 1
+ var tab1_btn: Button = shop._tabbar.get_child(1)
+ tab1_btn.pressed.emit()
+ _check(shop._active_tab == 1, "Switched to Tab 1")
+
+ # Check Tab 1 slot 5
+ var tab1_slot5: Button = shop._grid.get_child(5)
+ _check(not tab1_slot5.disabled and tab1_slot5.text.begins_with("红药水"), "Tab 1 slot 5 is Red Potion")
+
+ # Buy from Tab 1 slot 5: pos calculation = 1 * 40 + 5 = 45
+ tab1_slot5.pressed.emit()
+ _check(is_instance_valid(shop._buy_confirm), "Clicking slot opens confirmation modal")
+ shop._buy_confirm.confirmed.emit()
+ _check(client.buy_calls.size() == 1, "Buy request emitted")
+ _check(client.buy_calls[0]["pos"] == 45, "Buying position correctly calculated as tabIdx * 40 + slot (45)")
+
+ shop.queue_free()
+ host.queue_free()
+ client.queue_free()
+ proto.queue_free()
+
+# --- Test 5: Incremental Slot Update preserves active tab ---
+func _test_shop_incremental_update() -> void:
+ print("\n--- Test 5: Incremental Slot Update (shop_updated) ---")
+ var client := MockClient.new()
+ var proto := MockProto.new()
+ proto.items[10] = {"name": "Sword", "locale_name": "剑"}
+ proto.items[20] = {"name": "Bow", "locale_name": "弓"}
+
+ client.shop_data = {
+ "tabs": [
+ {"name": "Tab0", "items": [{"pos": 0, "vnum": 10, "count": 1, "price": 100, "sockets": [], "attrs": []}]},
+ {"name": "Tab1", "items": [{"pos": 1, "vnum": 20, "count": 1, "price": 200, "sockets": [], "attrs": []}]}
+ ]
+ }
+
+ var host := Control.new()
+ root.add_child(host)
+ var shop := ShopUI.new()
+ root.add_child(shop)
+ shop.setup(client, host, proto)
+ shop.open()
+
+ # User switches to Tab 1
+ shop._tabbar.get_child(1).pressed.emit()
+ _check(shop._active_tab == 1, "User is on Tab 1")
+
+ # Server sends slot update for Tab 1 item (price updated from 200 to 350)
+ client.shop_data["tabs"][1]["items"][0]["price"] = 350
+ client.shop_updated.emit(41) # pos 41 = 1 * 40 + 1
+
+ _check(shop._active_tab == 1, "shop_updated did NOT reset _active_tab back to 0")
+ var updated_slot: Button = shop._grid.get_child(1)
+ _check(updated_slot.tooltip_text.contains("价格:350"), "Slot 1 refreshed with updated price 350")
+
+ shop.queue_free()
+ host.queue_free()
+ client.queue_free()
+ proto.queue_free()
+
+# --- Test 6: Right-click instant buy per 40250 UnselectItemSlot ---
+func _test_shop_direct_buy_right_click() -> void:
+ print("\n--- Test 6: Right-Click Direct Buy (40250 UnselectItemSlot) ---")
+ var client := MockClient.new()
+ var proto := MockProto.new()
+ proto.items[10] = {"name": "Sword", "locale_name": "剑"}
+ client.shop_data = {
+ "tabs": [
+ {"name": "", "items": [{"pos": 3, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}]}
+ ]
+ }
+
+ var host := Control.new()
+ root.add_child(host)
+ var shop := ShopUI.new()
+ root.add_child(shop)
+ shop.setup(client, host, proto)
+ shop.open()
+
+ var slot3: Button = shop._grid.get_child(3)
+ var mb := InputEventMouseButton.new()
+ mb.button_index = MOUSE_BUTTON_RIGHT
+ mb.pressed = true
+
+ slot3.gui_input.emit(mb)
+ _check(client.buy_calls == [{"pos": 3, "count": 1}], "Right-click slot immediately buys without confirmation dialog")
+ _check(shop._buy_confirm == null, "Confirmation dialog was not opened on right-click")
+
+ shop.queue_free()
+ host.queue_free()
+ client.queue_free()
+ proto.queue_free()
+
+# --- Test 7: Shop Distance Auto-close (10m / 1000cm) ---
+func _test_shop_distance_auto_close() -> void:
+ print("\n--- Test 7: Shop Distance Auto-Close (>10.0m) ---")
+ var client := MockClient.new()
+ client.entities[1000] = {"vid": 1000, "pos": Vector3(100, 0, 100)} # Character at (100, 0, 100)
+ client.shop_data = {"tabs": [{"name": "", "items": []}]}
+
+ var host := Control.new()
+ root.add_child(host)
+ var shop := ShopUI.new()
+ root.add_child(shop)
+ shop.setup(client, host, null)
+
+ shop.open()
+ _check(shop.is_open(), "Shop window opened")
+ _check(shop._open_char_pos == Vector3(100, 0, 100), "Recorded character opening position")
+
+ # Character walks 5m away -> within 10m limit
+ client.entities[1000]["pos"] = Vector3(105, 0, 100)
+ shop._process(0.016)
+ _check(shop.is_open(), "Shop remains open at 5m distance")
+
+ # Character walks 12m away -> exceeds 10m (USE_SHOP_LIMIT_RANGE_M)
+ client.entities[1000]["pos"] = Vector3(112, 0, 100)
+ shop._process(0.016)
+ _check(not shop.is_open(), "Shop auto-closes when character walks > 10m away")
+ _check(client.close_calls == 1, "shop_close packet sent to server")
+
+ shop.queue_free()
+ host.queue_free()
+ client.queue_free()
diff --git a/project/test_npc_shop_flow.gd.uid b/project/test_npc_shop_flow.gd.uid
new file mode 100644
index 00000000..ad2ea7ed
--- /dev/null
+++ b/project/test_npc_shop_flow.gd.uid
@@ -0,0 +1 @@
+uid://cfjm1t5150n3m
diff --git a/project/test_npc_shop_parity.gd b/project/test_npc_shop_parity.gd
new file mode 100644
index 00000000..d597b851
--- /dev/null
+++ b/project/test_npc_shop_parity.gd
@@ -0,0 +1,127 @@
+# test_npc_shop_parity.gd —— 验证方向 1:NPC 商店库存、金币购买与 1/5 折价出售 40250 1:1 对齐
+extends SceneTree
+
+const NpcShopSystem = preload("res://npc_shop_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_npc_shop_parity (Direction 1: NPC Shop Buy/Sell 1:1) ===")
+ test_npc_shop_stocks()
+ test_sell_price_calculation()
+ test_buy_flow_and_gold_deduction()
+ test_buy_rejections()
+ test_sell_flow_and_antiflag()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_npc_shop_stocks() -> void:
+ print("\n--- Test 1: NPC Shop Stock Configurations ---")
+ _assert(NpcShopSystem.has_shop(9003), "General Store (9003) has shop registered")
+ _assert(NpcShopSystem.has_shop(9001), "Weapon Shop (9001) has shop registered")
+ _assert(NpcShopSystem.has_shop(9002), "Armor Shop (9002) has shop registered")
+ _assert(not NpcShopSystem.has_shop(101), "Wild dog (101) does not have a shop")
+
+ var general_items := NpcShopSystem.get_shop_items(9003)
+ _assert(general_items.size() >= 10, "General Store has at least 10 basic stock items")
+ _assert(general_items[0].vnum == 27001, "Slot 0 is Red Potion S (27001)")
+ _assert(general_items[6].vnum == 22001, "Slot 6 is Town Scroll (22001)")
+ _assert(general_items[9].vnum == 27400, "Slot 9 is Fishing Rod (27400)")
+
+func test_sell_price_calculation() -> void:
+ print("\n--- Test 2: 40250 Official 1/5 Sell Price Calculation (shop.cpp:520) ---")
+ # 1. Normal item with buy_price = 1000 -> sell_price = 1000 / 5 = 200
+ var normal_item := {"shop_buy_price": 1000, "anti_flags": 0}
+ var sell_val := NpcShopSystem.calculate_sell_price(normal_item, 1)
+ _assert(sell_val == 200, "1000 gold item sells for 200 gold (1/5 buy price)")
+
+ # 2. Count = 5 -> sell_price = 200 * 5 = 1000
+ var sell_val_5 := NpcShopSystem.calculate_sell_price(normal_item, 5)
+ _assert(sell_val_5 == 1000, "5 count sells for 1000 gold")
+
+ # 3. Item with ITEM_ANTIFLAG_SELL cannot be sold
+ var unsellable_item := {"shop_buy_price": 50000, "anti_flags": NpcShopSystem.ITEM_ANTIFLAG_SELL}
+ var sell_unsellable := NpcShopSystem.calculate_sell_price(unsellable_item, 1)
+ _assert(sell_unsellable == 0, "ITEM_ANTIFLAG_SELL item sells for 0 (forbidden)")
+
+func test_buy_flow_and_gold_deduction() -> void:
+ print("\n--- Test 3: Buying Flow and Gold Deduction ---")
+ var player_ctx := {
+ "gold": 50000,
+ "inventory": []
+ }
+
+ # Buy 1 pack of Red Potion S from General Store (pos 0: 200 count, 1200 gold)
+ var buy_res := NpcShopSystem.buy_item(0, 1, 9003, player_ctx)
+ _assert(buy_res.ok and buy_res.code == "SUCCESS", "Buy succeeded")
+ _assert(buy_res.cost == 1200, "Cost is 1200 gold")
+ _assert(player_ctx.gold == 50000 - 1200, "Player gold deducted to 48800")
+ _assert(player_ctx.inventory.size() == 1, "Item added to player inventory")
+ _assert(player_ctx.inventory[0].vnum == 27001, "Added item is 27001")
+ _assert(player_ctx.inventory[0].count == 200, "Added item has 200 count")
+
+func test_buy_rejections() -> void:
+ print("\n--- Test 4: Buy Rejections (Insufficient Gold / Inventory Full) ---")
+ # Insufficient Gold
+ var poor_player := {
+ "gold": 500, # Needs 1200
+ "inventory": []
+ }
+ var res_poor := NpcShopSystem.buy_item(0, 1, 9003, poor_player)
+ _assert(not res_poor.ok and res_poor.code == "NOT_ENOUGH_GOLD", "Rejected due to insufficient gold")
+ _assert(poor_player.gold == 500, "Gold untouched")
+
+ # Inventory Full
+ var full_player := {
+ "gold": 100000,
+ "inventory": []
+ }
+ for i in 90:
+ full_player.inventory.append({"cell": i, "vnum": 10, "count": 1})
+ var res_full := NpcShopSystem.buy_item(0, 1, 9003, full_player)
+ _assert(not res_full.ok and res_full.code == "INVENTORY_FULL", "Rejected due to inventory full")
+
+func test_sell_flow_and_antiflag() -> void:
+ print("\n--- Test 5: Selling Flow & Anti-Flag Verification ---")
+ var player_ctx := {
+ "gold": 1000,
+ "inventory": [
+ {"cell": 0, "vnum": 10, "count": 1}, # Sword +0 (buy_price: 1000 -> sells for 200)
+ {"cell": 1, "vnum": 71085, "count": 2} # Scroll with anti-sell
+ ]
+ }
+
+ var proto_mock := func(vnum: int) -> Dictionary:
+ if vnum == 10:
+ return {"shop_buy_price": 1000, "anti_flags": 0}
+ elif vnum == 71085:
+ return {"shop_buy_price": 50000, "anti_flags": NpcShopSystem.ITEM_ANTIFLAG_SELL}
+ return {}
+
+ # Sell unsellable item in cell 1
+ var res_unsellable := NpcShopSystem.sell_item(1, 1, player_ctx, proto_mock)
+ _assert(not res_unsellable.ok and res_unsellable.code == "ITEM_ANTIFLAG_SELL",
+ "ITEM_ANTIFLAG_SELL item sale rejected")
+
+ # Sell valid item in cell 0
+ var res_sell := NpcShopSystem.sell_item(0, 1, player_ctx, proto_mock)
+ _assert(res_sell.ok and res_sell.code == "SUCCESS", "Sale succeeded")
+ _assert(res_sell.gold_earned == 200, "Earned 200 gold")
+ _assert(player_ctx.gold == 1200, "Player gold increased to 1200")
+ _assert(player_ctx.inventory.size() == 1, "Sold item removed from inventory")
+ _assert(player_ctx.inventory[0].cell == 1, "Remaining item is cell 1")
diff --git a/project/test_npc_shop_parity.gd.uid b/project/test_npc_shop_parity.gd.uid
new file mode 100644
index 00000000..dd54c850
--- /dev/null
+++ b/project/test_npc_shop_parity.gd.uid
@@ -0,0 +1 @@
+uid://6ta56apkr82f
diff --git a/project/test_offline_bazaar_market_parity.gd b/project/test_offline_bazaar_market_parity.gd
new file mode 100644
index 00000000..2b1a8d8d
--- /dev/null
+++ b/project/test_offline_bazaar_market_parity.gd
@@ -0,0 +1,172 @@
+# test_offline_bazaar_market_parity.gd —— 40250 单人单机广场离线摆摊与虚拟交易 1:1 回归测试套件
+extends SceneTree
+
+const OfflineBazaarMarketSystem = preload("res://offline_bazaar_market_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_offline_bazaar_market_parity (Direction 1: Offline Bazaar Market 1:1) ===")
+ _test_npc_stalls_browse_and_buy()
+ _test_buy_validation_errors()
+ _test_player_stall_open()
+ _test_offline_sales_and_revenue()
+ _test_stall_close_and_item_return()
+ _finish()
+
+func _test_npc_stalls_browse_and_buy() -> void:
+ print("\n--- Test 1: NPC Stalls Browsing & Purchasing ---")
+ var mkt := OfflineBazaarMarketSystem.new()
+ var stalls = mkt.get_all_stalls()
+ _assert(stalls.size() == 3, "Found 3 default NPC stalls in village square")
+
+ # Browse Stall 1 (Materials)
+ var b_res = mkt.browse_stall(1)
+ _assert(b_res["ok"] == true and b_res["owner"] == "阿隆", "Browsed Stall 1 (阿隆)")
+ _assert(b_res["items"].size() == 4, "Stall 1 has 4 goods")
+
+ # Buy Pearl (27993) from Stall 1
+ var player := {"gold": 2000000}
+ var inv: Array = []
+ inv.resize(10)
+ for i in range(10):
+ inv[i] = null
+
+ var buy_sig := {"fired": false, "name": "", "cost": 0}
+ mkt.item_bought_from_stall.connect(func(_sid: int, iname: String, _c: int, cost: int):
+ buy_sig["fired"] = true
+ buy_sig["name"] = iname
+ buy_sig["cost"] = cost
+ )
+
+ # Buy 2 pearls: 500,000 * 2 = 1,000,000 gold
+ var buy_res = mkt.buy_from_stall(1, 0, 2, player, inv)
+ _assert(buy_res["ok"] == true, "Purchased 2 pearls")
+ _assert(player["gold"] == 1000000, "Gold deducted 2M -> 1M")
+ _assert(inv[0]["vnum"] == 27993 and inv[0]["count"] == 2, "Pearls placed in slot 0")
+ _assert(mkt.npc_stalls[1]["items"][0]["stock"] == 3, "Stock decreased 5 -> 3")
+ _assert(buy_sig["fired"] == true and buy_sig["name"] == "珍珠", "item_bought_from_stall signal fired")
+
+func _test_buy_validation_errors() -> void:
+ print("\n--- Test 2: Purchase Error Validations ---")
+ var mkt := OfflineBazaarMarketSystem.new()
+ var player := {"gold": 100} # Very poor
+ var inv: Array = [null]
+
+ # Not enough gold
+ var fail_gold = mkt.buy_from_stall(1, 0, 1, player, inv)
+ _assert(fail_gold["ok"] == false and fail_gold["reason"] == "NOT_ENOUGH_GOLD", "Rejected: not enough gold")
+
+ # Out of stock
+ player["gold"] = 999999999
+ var fail_stock = mkt.buy_from_stall(1, 0, 999, player, inv)
+ _assert(fail_stock["ok"] == false and fail_stock["reason"] == "OUT_OF_STOCK", "Rejected: out of stock")
+
+ # Full inventory
+ var full_inv := [{"vnum": 1}]
+ var fail_inv = mkt.buy_from_stall(1, 0, 1, player, full_inv)
+ _assert(fail_inv["ok"] == false and fail_inv["reason"] == "INVENTORY_FULL", "Rejected: inventory full")
+
+func _test_player_stall_open() -> void:
+ print("\n--- Test 3: Opening Private Player Stall (50200 Bundle) ---")
+ var mkt := OfflineBazaarMarketSystem.new()
+ var inv: Array = [
+ {"vnum": OfflineBazaarMarketSystem.ITEM_BUNDLE_VNUM, "name": "摆摊包袱", "count": 1},
+ {"vnum": 10, "name": "巨剑+9", "count": 1},
+ {"vnum": 27003, "name": "红色药水(特大)", "count": 100}
+ ]
+
+ var open_sig := {"fired": false, "title": "", "count": 0}
+ mkt.player_stall_opened.connect(func(t: String, c: int):
+ open_sig["fired"] = true
+ open_sig["title"] = t
+ open_sig["count"] = c
+ )
+
+ # Try open without bundle
+ var no_bundle_inv: Array = [{"vnum": 10, "count": 1}]
+ var fail_bundle = mkt.open_player_stall("我的摊位", [{"slot": 0, "price": 1000}], no_bundle_inv, true)
+ _assert(fail_bundle["ok"] == false and fail_bundle["reason"] == "NEED_BUNDLE_ITEM", "Rejected without bundle")
+
+ # Open stall with bundle and 2 items
+ var items_to_list := [
+ {"slot": 1, "price": 500000, "count": 1},
+ {"slot": 2, "price": 200, "count": 50} # sell 50 out of 100
+ ]
+
+ var res = mkt.open_player_stall("【东方极速特惠商铺】", items_to_list, inv, true)
+ _assert(res["ok"] == true, "Player stall opened")
+ _assert(inv[0] == null, "Bundle item (50200) consumed")
+ _assert(inv[1] == null, "Listed Sword removed from inventory slot 1")
+ _assert(inv[2]["count"] == 50, "Potion count halved in inventory (100 - 50 = 50)")
+ _assert(mkt.player_stall["is_open"] == true, "Stall is open")
+ _assert(mkt.player_stall["items"].size() == 2, "2 goods listed in stall")
+ _assert(open_sig["fired"] == true and open_sig["count"] == 2, "player_stall_opened signal fired")
+
+func _test_offline_sales_and_revenue() -> void:
+ print("\n--- Test 4: Simulated Offline Sales & Revenue Collection ---")
+ var mkt := OfflineBazaarMarketSystem.new()
+ var inv: Array = [
+ {"vnum": 50200, "count": 1},
+ {"vnum": 28430, "name": "战士灵石+4", "count": 1}
+ ]
+
+ mkt.open_player_stall("【灵石专卖】", [{"slot": 1, "price": 1000000, "count": 1}], inv, true)
+ _assert(mkt.player_stall["items"].size() == 1, "1 item listed")
+
+ # Simulate 30 minutes of offline time
+ var sim = mkt.simulate_offline_sales(30.0)
+ _assert(sim["sold_count"] == 1, "Stone sold during simulated offline time")
+ _assert(mkt.player_stall["revenue"] == 1000000, "1M gold earned in stall revenue")
+ _assert(mkt.player_stall["items"].is_empty(), "Stall inventory now empty")
+
+ # Collect revenue
+ var player := {"gold": 50000}
+ var col_res = mkt.collect_stall_revenue(player)
+ _assert(col_res["ok"] == true and player["gold"] == 1050000, "Collected revenue into player wallet")
+ _assert(mkt.player_stall["revenue"] == 0, "Stall revenue reset to 0")
+
+func _test_stall_close_and_item_return() -> void:
+ print("\n--- Test 5: Closing Stall & Returning Unsold Items ---")
+ var mkt := OfflineBazaarMarketSystem.new()
+ var inv: Array = [
+ {"vnum": 50200, "count": 1},
+ {"vnum": 11299, "name": "黑铁战甲+9", "count": 1}
+ ]
+
+ mkt.open_player_stall("【神装铺】", [{"slot": 1, "price": 10000000, "count": 1}], inv, true)
+
+ var close_sig := {"fired": false, "rev": -1}
+ mkt.player_stall_closed.connect(func(rev: int):
+ close_sig["fired"] = true
+ close_sig["rev"] = rev
+ )
+
+ # Close stall immediately without sales
+ var player := {"gold": 0}
+ var close_res = mkt.close_player_stall(inv, player)
+ _assert(close_res["ok"] == true, "Stall closed successfully")
+ _assert(mkt.player_stall["is_open"] == false, "Stall is no longer open")
+ _assert(close_sig["fired"] == true and close_sig["rev"] == 0, "player_stall_closed signal fired")
+ _assert(inv[0]["vnum"] == 11299, "Unsold armor safely returned to first empty inventory slot")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Offline Bazaar Market Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Offline Bazaar Market parity tests passed!")
+ quit(0)
diff --git a/project/test_offline_bazaar_market_parity.gd.uid b/project/test_offline_bazaar_market_parity.gd.uid
new file mode 100644
index 00000000..ab813991
--- /dev/null
+++ b/project/test_offline_bazaar_market_parity.gd.uid
@@ -0,0 +1 @@
+uid://d15164rwj8q3s
diff --git a/project/test_party_management_parity.gd b/project/test_party_management_parity.gd
new file mode 100644
index 00000000..afdbf4b0
--- /dev/null
+++ b/project/test_party_management_parity.gd
@@ -0,0 +1,124 @@
+# test_party_management_parity.gd —— 40250 8人组队面板、经验分配与领导力光环系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://party_management_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_party_management_parity (Batch 27 Direction 4) ===")
+ _test_party_capacity_and_join()
+ _test_member_expel_and_leave()
+ _test_leadership_tactical_auras()
+ _test_exp_distribution_modes()
+ _test_hp_percentage_synchronization()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_party_capacity_and_join() -> void:
+ print("\n--- Test 1: Party Creation & 8-Member Limit ---")
+ var party = SystemClass.new(1, "LeaderOne", 50)
+ _assert(party.members.size() == 1, "Party created with 1 member (Leader)")
+ _assert(party.leader_pid == 1, "Leader PID is 1")
+
+ # Add 7 more members (total 8)
+ for i in range(2, 9):
+ var r = party.add_member(i, "Member%d" % i, 40 + i)
+ _assert(r["success"] == true, "Member %d joined party" % i)
+
+ _assert(party.members.size() == 8, "Party reached 8 members")
+
+ # 9th member must be rejected
+ var r_overflow = party.add_member(9, "OverflowMember", 50)
+ _assert(r_overflow["error_code"] == "PARTY_FULL", "Reject 9th member (max 8)")
+
+func _test_member_expel_and_leave() -> void:
+ print("\n--- Test 2: Expel & Leave Mechanics ---")
+ var party = SystemClass.new(1, "Leader", 60)
+ party.add_member(2, "Alice", 55)
+ party.add_member(3, "Bob", 50)
+
+ # Non-leader cannot expel
+ var r_fail = party.expel_member(2, 3)
+ _assert(r_fail["error_code"] == "NOT_LEADER", "Non-leader cannot expel member")
+
+ # Leader expels Alice
+ var r_expel = party.expel_member(1, 2)
+ _assert(r_expel["success"] == true, "Leader successfully expelled Alice")
+ _assert(party.members.has(2) == false, "Alice no longer in party")
+
+ # Leader leaves party -> Bob promoted to new leader
+ var r_lead_leave = party.leave_party(1)
+ _assert(r_lead_leave["success"] == true, "Leader left party")
+ _assert(party.leader_pid == 3, "Bob promoted to new leader")
+ _assert(party.members[3].is_leader == true, "Bob marked as leader")
+
+ # Bob leaves -> Party dissolves
+ var r_dissolve = party.leave_party(3)
+ _assert(r_dissolve["dissolved"] == true, "Party dissolved when last member left")
+
+func _test_leadership_tactical_auras() -> void:
+ print("\n--- Test 3: Leader Tactical Auras (Roles & Buffs) ---")
+ var party = SystemClass.new(1, "Commander", 75)
+ party.add_member(2, "Warrior", 70)
+ party.add_member(3, "Sura", 72)
+
+ # Non-leader cannot assign role
+ var r_non_lead = party.set_member_role(2, 2, SystemClass.ROLE_ATTACKER)
+ _assert(r_non_lead["error_code"] == "NOT_LEADER", "Non-leader cannot assign role")
+
+ # Leader assigns Warrior as ATTACKER
+ var r1 = party.set_member_role(1, 2, SystemClass.ROLE_ATTACKER)
+ _assert(r1["success"] == true, "Assigned Warrior as ATTACKER")
+ var buff1 = party.get_member_buff(2)
+ _assert(buff1["stat"] == "att_grade" and buff1["val"] == 30, "Warrior receives +30 att_grade buff")
+
+ # Leader assigns Sura as TANKER
+ var r2 = party.set_member_role(1, 3, SystemClass.ROLE_TANKER)
+ _assert(r2["success"] == true, "Assigned Sura as TANKER")
+ var buff2 = party.get_member_buff(3)
+ _assert(buff2["stat"] == "max_hp" and buff2["val"] == 2500, "Sura receives +2500 max_hp buff")
+
+ # Reject duplicate ATTACKER assignment to Sura
+ var r_dup = party.set_member_role(1, 3, SystemClass.ROLE_ATTACKER)
+ _assert(r_dup["error_code"] == "ROLE_ALREADY_ASSIGNED", "Reject duplicate role assignment")
+
+func _test_exp_distribution_modes() -> void:
+ print("\n--- Test 4: Experience Distribution Modes ---")
+ var party = SystemClass.new(1, "PlayerA", 20) # Level 20
+ party.add_member(2, "PlayerB", 80) # Level 80
+
+ # 1. PARITY (Equal mode)
+ party.set_exp_distribution_mode(1, SystemClass.EXP_MODE_PARITY)
+ var exp_parity = party.distribute_exp(1000)
+ _assert(exp_parity[1] == 500, "PlayerA gets 500 in PARITY mode")
+ _assert(exp_parity[2] == 500, "PlayerB gets 500 in PARITY mode")
+
+ # 2. LEVEL (Weighted mode: 20% vs 80%)
+ party.set_exp_distribution_mode(1, SystemClass.EXP_MODE_LEVEL)
+ var exp_level = party.distribute_exp(1000)
+ _assert(exp_level[1] == 200, "PlayerA (Lv20) gets 200 (20%) in LEVEL mode")
+ _assert(exp_level[2] == 800, "PlayerB (Lv80) gets 800 (80%) in LEVEL mode")
+
+func _test_hp_percentage_synchronization() -> void:
+ print("\n--- Test 5: HP Gauge Percentage Synchronization ---")
+ var party = SystemClass.new(1, "PlayerA", 50)
+ party.sync_member_hp(1, 3000, 6000)
+ _assert(is_equal_approx(party.members[1].get_hp_percent(), 0.5), "HP percentage is exactly 50%")
+
+ party.sync_member_hp(1, 6000, 6000)
+ _assert(is_equal_approx(party.members[1].get_hp_percent(), 1.0), "HP percentage is 100%")
diff --git a/project/test_party_management_parity.gd.uid b/project/test_party_management_parity.gd.uid
new file mode 100644
index 00000000..dd5d07ce
--- /dev/null
+++ b/project/test_party_management_parity.gd.uid
@@ -0,0 +1 @@
+uid://bcr88asr1j3qh
diff --git a/project/test_pet_companion_parity.gd b/project/test_pet_companion_parity.gd
new file mode 100644
index 00000000..187d532e
--- /dev/null
+++ b/project/test_pet_companion_parity.gd
@@ -0,0 +1,160 @@
+# test_pet_companion_parity.gd —— 40250 伴随宠物系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const PetCompanionSystem = preload("res://pet_companion_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_pet_companion_parity (Direction 2: Pet Companion System 1:1) ===")
+ _test_pet_table_and_validity()
+ _test_summon_and_bonuses()
+ _test_dismiss_and_toggle()
+ _test_3d_movement_and_states()
+ _test_time_expiry()
+ _finish()
+
+func _test_pet_table_and_validity() -> void:
+ print("\n--- Test 1: Pet Table Configuration & Seal Validation ---")
+ var pet_sys := PetCompanionSystem.new()
+
+ # Invalid seal item
+ var invalid_res = pet_sys.toggle_pet_seal({"vnum": 99999}, Vector3.ZERO)
+ _assert(invalid_res["ok"] == false and invalid_res["reason"] == "NOT_A_PET_SEAL", "Invalid seal rejected")
+
+ # Check configured pets in PET_TABLE
+ _assert(PetCompanionSystem.PET_TABLE.has(53001), "Fire Phoenix (53001) in table")
+ _assert(PetCompanionSystem.PET_TABLE.has(53002), "Reindeer (53002) in table")
+ _assert(PetCompanionSystem.PET_TABLE.has(53003), "Ice Phoenix (53003) in table")
+ _assert(PetCompanionSystem.PET_TABLE.has(53005), "Bao Bao (53005) in table")
+ _assert(PetCompanionSystem.PET_TABLE.has(53006), "Leonidas (53006) in table")
+
+ var f_phoenix = PetCompanionSystem.PET_TABLE[53001]
+ _assert(f_phoenix["mob_vnum"] == 34001, "Fire Phoenix mob vnum is 34001")
+ _assert(f_phoenix["bonuses"]["max_hp"] == 1500, "Fire Phoenix bonus Max HP is 1500")
+ _assert(f_phoenix["bonuses"]["monster_dmg_pct"] == 15, "Fire Phoenix bonus monster dmg is 15%")
+
+func _test_summon_and_bonuses() -> void:
+ print("\n--- Test 2: Summoning & Stat Injection ---")
+ var pet_sys := PetCompanionSystem.new()
+ var signal_data := {
+ "fired": false,
+ "name": "",
+ "vnum": 0
+ }
+
+ pet_sys.pet_summoned.connect(func(pname: String, vnum: int):
+ signal_data["fired"] = true
+ signal_data["name"] = pname
+ signal_data["vnum"] = vnum
+ )
+
+ var player_pos := Vector3(100.0, 0.0, 200.0)
+ var res = pet_sys.toggle_pet_seal({"vnum": 53001, "duration": 3600.0}, player_pos)
+
+ _assert(res["ok"] == true, "Fire Phoenix summon succeeded")
+ _assert(res["action"] == "summon", "Action is summon")
+ _assert(pet_sys.is_summoned == true, "is_summoned is true")
+ _assert(pet_sys.current_seal_vnum == 53001, "current_seal_vnum is 53001")
+ _assert(signal_data["fired"] == true, "pet_summoned signal emitted")
+ _assert(signal_data["name"] == "火凤凰", "Summoned pet name is 火凤凰")
+ _assert(signal_data["vnum"] == 53001, "Summoned pet vnum is 53001")
+
+ var bonuses = pet_sys.get_active_bonuses()
+ _assert(bonuses["max_hp"] == 1500, "Active bonus max_hp is 1500")
+ _assert(bonuses["monster_dmg_pct"] == 15, "Active bonus monster_dmg_pct is 15%")
+
+func _test_dismiss_and_toggle() -> void:
+ print("\n--- Test 3: Dismiss & Switch Pets ---")
+ var pet_sys := PetCompanionSystem.new()
+ var dismiss_data := {
+ "fired": false,
+ "name": ""
+ }
+
+ pet_sys.pet_dismissed.connect(func(pname: String):
+ dismiss_data["fired"] = true
+ dismiss_data["name"] = pname
+ )
+
+ var player_pos := Vector3.ZERO
+ # Summon 53005 (Bao Bao)
+ pet_sys.toggle_pet_seal({"vnum": 53005}, player_pos)
+ _assert(pet_sys.is_summoned == true, "Bao Bao summoned")
+ _assert(pet_sys.get_active_bonuses()["critical_pct"] == 15, "Bao Bao grants 15% crit")
+
+ # Toggle 53005 again -> dismiss
+ var res = pet_sys.toggle_pet_seal({"vnum": 53005}, player_pos)
+ _assert(res["ok"] == true and res["action"] == "dismiss", "Toggling same seal dismisses pet")
+ _assert(pet_sys.is_summoned == false, "Pet is no longer summoned")
+ _assert(dismiss_data["fired"] == true, "pet_dismissed signal emitted")
+ _assert(dismiss_data["name"] == "小胖熊猫", "Dismissed name matches")
+ _assert(pet_sys.get_active_bonuses().is_empty(), "No bonuses active when pet dismissed")
+
+ # Summon 53006 (Leonidas) and then summon 53003 (Ice Phoenix) to switch
+ pet_sys.toggle_pet_seal({"vnum": 53006}, player_pos)
+ _assert(pet_sys.current_seal_vnum == 53006, "Leonidas active")
+ _assert(pet_sys.get_active_bonuses()["att_pct"] == 10, "Leonidas grants 10% att")
+
+ # Switch directly to 53003
+ var switch_res = pet_sys.toggle_pet_seal({"vnum": 53003}, player_pos)
+ _assert(switch_res["ok"] == true and switch_res["action"] == "summon", "Direct switch to Ice Phoenix succeeded")
+ _assert(pet_sys.current_seal_vnum == 53003, "Active pet is now Ice Phoenix")
+ _assert(pet_sys.get_active_bonuses()["def_pct"] == 15, "Ice Phoenix grants 15% def")
+
+func _test_3d_movement_and_states() -> void:
+ print("\n--- Test 4: 3D Movement & State Machine ---")
+ var pet_sys := PetCompanionSystem.new()
+ var player_pos := Vector3(0.0, 0.0, 0.0)
+ pet_sys.toggle_pet_seal({"vnum": 53001}, player_pos)
+
+ # 1. When close (dist <= 2.0), state is wait
+ pet_sys.pet_position = Vector3(1.0, 0.0, 0.0) # dist = 1.0m <= 2.0m
+ pet_sys.update_movement(player_pos, 0.1)
+ _assert(pet_sys.pet_state == "wait", "Pet waits within 2.0m")
+
+ # 2. When player moves to (10, 0, 0), dist is 9.0m (> 2.0m)
+ player_pos = Vector3(10.0, 0.0, 0.0)
+ var prev_pos = pet_sys.pet_position
+ pet_sys.update_movement(player_pos, 0.5) # dt = 0.5s -> move speed 4.5m/s -> move 2.25m towards player
+ _assert(pet_sys.pet_state == "run", "Pet state is run when following player")
+ _assert(pet_sys.pet_position.x > prev_pos.x, "Pet moved towards player")
+
+ # 3. When player teleports far away (> 25.0m)
+ player_pos = Vector3(100.0, 0.0, 100.0)
+ pet_sys.update_movement(player_pos, 0.1)
+ var new_dist = pet_sys.pet_position.distance_to(player_pos)
+ _assert(new_dist < 5.0, "Pet instantly teleports near player when dist >= 25m")
+ _assert(pet_sys.pet_state == "wait", "Pet returns to wait state after teleport")
+
+func _test_time_expiry() -> void:
+ print("\n--- Test 5: Pet Duration Expiration ---")
+ var pet_sys := PetCompanionSystem.new()
+ pet_sys.toggle_pet_seal({"vnum": 53001, "duration": 10.0}, Vector3.ZERO)
+ _assert(pet_sys.is_summoned == true, "Pet summoned with 10s duration")
+
+ pet_sys.update(4.0)
+ _assert(pet_sys.is_summoned == true, "Pet still active at 4s elapsed")
+ _assert(is_equal_approx(pet_sys.remaining_time, 6.0), "6s remaining")
+
+ pet_sys.update(7.0) # Exceeds remaining
+ _assert(pet_sys.is_summoned == false, "Pet dismissed automatically when duration expires")
+ _assert(pet_sys.get_active_bonuses().is_empty(), "No bonuses after expiration")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Pet Companion Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_pet_companion_parity.gd.uid b/project/test_pet_companion_parity.gd.uid
new file mode 100644
index 00000000..a4c11334
--- /dev/null
+++ b/project/test_pet_companion_parity.gd.uid
@@ -0,0 +1 @@
+uid://cgyjhe8vqjehi
diff --git a/project/test_pk_penalty_parity.gd b/project/test_pk_penalty_parity.gd
new file mode 100644
index 00000000..677af779
--- /dev/null
+++ b/project/test_pk_penalty_parity.gd
@@ -0,0 +1,122 @@
+# test_pk_penalty_parity.gd —— 40250 恶名死亡惩罚与露西之戒保护 1:1 纯净回归测试套件
+extends SceneTree
+
+const PkPenaltySystem = preload("res://pk_penalty_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_pk_penalty_parity (Direction 3: PK Penalty & Lucy Ring 1:1) ===")
+ _test_safe_conditions_no_drop()
+ _test_lucy_ring_protection()
+ _test_negative_karma_drop_and_item_lock()
+ _test_zen_bean_and_cloak()
+ _finish()
+
+func _test_safe_conditions_no_drop() -> void:
+ print("\n--- Test 1: Safe Conditions (Level < 50 or Positive Karma) ---")
+ var pks := PkPenaltySystem.new()
+ var eq: Dictionary = {"weapon": {"vnum": 169, "name": "战神之剑+9"}}
+ var inv: Array = [{"vnum": 10, "name": "木剑"}]
+
+ # Level < 50 with negative karma
+ var r1 = pks.process_death_penalty(45, -5000, eq, inv, 0.01)
+ _assert(r1["dropped"] == false, "No drop when player level < 50")
+
+ # Positive karma at Level 75
+ var r2 = pks.process_death_penalty(75, 8000, eq, inv, 0.01)
+ _assert(r2["dropped"] == false, "No drop when alignment >= 0 (Noble)")
+
+func _test_lucy_ring_protection() -> void:
+ print("\n--- Test 2: Lucy's Ring (70043) Shatter Protection ---")
+ var pks := PkPenaltySystem.new()
+ var eq: Dictionary = {
+ "weapon": {"vnum": 169, "name": "战神之剑+9"},
+ "ring1": {"vnum": PkPenaltySystem.ITEM_LUCY_RING, "name": "露西之戒"}
+ }
+ var inv: Array = [{"vnum": 11299, "name": "黑钢甲+9"}]
+
+ var ring_signal := {"fired": false}
+ pks.lucy_ring_protected.connect(func(): ring_signal["fired"] = true)
+
+ # Level 75, Cruel (-15,000 karma)
+ var res = pks.process_death_penalty(75, -15000, eq, inv, 0.01)
+ _assert(res["dropped"] == false, "No items dropped")
+ _assert(res["ring_shattered"] == true, "Lucy's Ring shattered")
+ _assert(eq["ring1"] == null, "Lucy's Ring consumed from equipment slot")
+ _assert(eq["weapon"] != null, "Valuable weapon preserved")
+ _assert(inv[0] != null, "Valuable armor in inventory preserved")
+ _assert(ring_signal["fired"] == true, "lucy_ring_protected signal fired")
+
+func _test_negative_karma_drop_and_item_lock() -> void:
+ print("\n--- Test 3: Drop Mechanics & Item Lock Immunity ---")
+ var pks := PkPenaltySystem.new()
+ var eq: Dictionary = {
+ "weapon": {"vnum": 169, "name": "战神之剑+9", "is_locked": true} # Locked!
+ }
+ var inv: Array = [
+ {"vnum": 11299, "name": "黑钢甲+9", "is_locked": true}, # Locked!
+ {"vnum": 50054, "name": "胡萝卜", "is_locked": false} # Unlocked, can drop!
+ ]
+
+ var drop_signal := {"fired": false, "name": ""}
+ pks.item_dropped_on_death.connect(func(it: Dictionary):
+ drop_signal["fired"] = true
+ drop_signal["name"] = it.get("name", "")
+ )
+
+ # Level 75, Malicious (-5000 karma, 10% chance). Forced roll 0.05 <= 0.10
+ var res = pks.process_death_penalty(75, -5000, eq, inv, 0.05)
+ _assert(res["dropped"] == true, "Death drop triggered")
+ _assert(res["dropped_item"]["vnum"] == 50054, "Unlocked carrot dropped")
+ _assert(inv[1] == null, "Carrot removed from inventory")
+ _assert(eq["weapon"] != null, "Locked weapon was NOT dropped")
+ _assert(inv[0] != null, "Locked armor was NOT dropped")
+ _assert(drop_signal["fired"] == true and drop_signal["name"] == "胡萝卜", "item_dropped_on_death signal fired")
+
+func _test_zen_bean_and_cloak() -> void:
+ print("\n--- Test 4: Zen Bean (70102) & Peace Cloak (70048) ---")
+ var pks := PkPenaltySystem.new()
+ var inv: Array = [
+ {"vnum": PkPenaltySystem.ITEM_ZEN_BEAN, "name": "禅豆", "count": 2}
+ ]
+
+ # 1. Use on positive karma rejected
+ var r_bad = pks.use_zen_bean(0, inv, 1000)
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "NOT_EVIL", "Cannot use Zen Bean if alignment >= 0")
+
+ # 2. Use on -1200 karma -> -700 karma
+ var r1 = pks.use_zen_bean(0, inv, -1200)
+ _assert(r1["ok"] == true, "Zen bean consumed")
+ _assert(r1["new_alignment"] == -700, "Alignment restored by +500 to -700")
+ _assert(inv[0]["count"] == 1, "1 Zen bean remaining in stack")
+
+ # 3. Use on -200 karma -> capped at 0
+ var r2 = pks.use_zen_bean(0, inv, -200)
+ _assert(r2["ok"] == true and r2["new_alignment"] == 0, "Alignment capped at 0 (neutral)")
+ _assert(inv[0] == null, "Stack consumed")
+
+ # 4. Peace Cloak hides red name
+ var eq_with_cloak := {"body": null, "cloak": {"vnum": PkPenaltySystem.ITEM_CLOAK_OF_PEACE}}
+ _assert(pks.is_red_name_hidden(eq_with_cloak) == true, "Cloak of Peace hides red name")
+
+ var eq_no_cloak := {"body": null}
+ _assert(pks.is_red_name_hidden(eq_no_cloak) == false, "No cloak reveals red name")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("PK Penalty Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_pk_penalty_parity.gd.uid b/project/test_pk_penalty_parity.gd.uid
new file mode 100644
index 00000000..4e521942
--- /dev/null
+++ b/project/test_pk_penalty_parity.gd.uid
@@ -0,0 +1 @@
+uid://bxghje5i2w0ks
diff --git a/project/test_player_trade_exchange_parity.gd b/project/test_player_trade_exchange_parity.gd
new file mode 100644
index 00000000..40267af0
--- /dev/null
+++ b/project/test_player_trade_exchange_parity.gd
@@ -0,0 +1,223 @@
+# test_player_trade_exchange_parity.gd —— 40250 面对面交易与防调包双向锁定系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://player_trade_exchange_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_player_trade_exchange_parity (Batch 26 Direction 3) ===")
+ _test_trade_session_initialization()
+ _test_untradeable_items_rejection()
+ _test_anti_swap_lock_reset_security()
+ _test_gold_offer_validation()
+ _test_successful_atomic_exchange()
+ _test_insufficient_space_rejection()
+ _test_cancel_trade()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _create_player(id: int, name: String, gold: int, inv_size: int = 10) -> Dictionary:
+ var inv: Array = []
+ for i in range(inv_size):
+ inv.append(null)
+ return {
+ "id": id,
+ "name": name,
+ "gold": gold,
+ "inv": inv
+ }
+
+func _test_trade_session_initialization() -> void:
+ print("\n--- Test 1: Trade Session Initialization & Distance ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 500000)
+ var p2 = _create_player(102, "Bob", 300000)
+
+ # 1. Reject distance > 1000
+ var r_dist = trade.start_exchange(p1, p2, 1200.0)
+ _assert(r_dist["error_code"] == "TOO_FAR", "Reject trade if distance > 1000")
+
+ # 2. Reject self trade
+ var r_self = trade.start_exchange(p1, p1, 100.0)
+ _assert(r_self["error_code"] == "CANNOT_TRADE_SELF", "Reject trade with self")
+
+ # 3. Successful start
+ var r_ok = trade.start_exchange(p1, p2, 100.0)
+ _assert(r_ok["success"] == true, "Trade session opened successfully")
+ _assert(trade.is_active == true, "Trade is active")
+
+func _test_untradeable_items_rejection() -> void:
+ print("\n--- Test 2: Untradeable Items Rejection ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 500000)
+ var p2 = _create_player(102, "Bob", 300000)
+
+ p1["inv"][0] = { "vnum": 1, "name": "Normal Item", "anti_give": false, "is_soulbound": false }
+ p1["inv"][1] = { "vnum": 2, "name": "Bound Item", "anti_give": false, "is_soulbound": true }
+ p1["inv"][2] = { "vnum": 3, "name": "Anti-Give Item", "anti_give": true, "is_soulbound": false }
+ p1["inv"][3] = { "vnum": 4, "name": "Equipped Item", "is_equipped": true }
+
+ trade.start_exchange(p1, p2)
+
+ # Soulbound rejected
+ var r_bound = trade.add_item(101, 1, 0)
+ _assert(r_bound["error_code"] == "ITEM_UNTRADEABLE", "Soulbound item rejected from trade")
+
+ # Anti-give rejected
+ var r_antigive = trade.add_item(101, 2, 0)
+ _assert(r_antigive["error_code"] == "ITEM_UNTRADEABLE", "Anti-give flag item rejected from trade")
+
+ # Equipped rejected
+ var r_equip = trade.add_item(101, 3, 0)
+ _assert(r_equip["error_code"] == "ITEM_UNTRADEABLE", "Equipped item rejected from trade")
+
+ # Normal item accepted
+ var r_norm = trade.add_item(101, 0, 0)
+ _assert(r_norm["success"] == true, "Tradeable item successfully added to slot 0")
+
+func _test_anti_swap_lock_reset_security() -> void:
+ print("\n--- Test 3: 40250 Anti-Swap Security (Lock Reset on Modification) ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 500000)
+ var p2 = _create_player(102, "Bob", 300000)
+ p1["inv"][0] = { "vnum": 10, "name": "Sword" }
+ p2["inv"][0] = { "vnum": 20, "name": "Shield" }
+ p2["inv"][1] = { "vnum": 30, "name": "Dagger" }
+
+ trade.start_exchange(p1, p2)
+ trade.add_item(101, 0, 0)
+ trade.add_item(102, 0, 0)
+
+ # Alice locks ready
+ trade.set_ready(101, true)
+ _assert(trade.side_a.is_ready == true, "Alice locked ready")
+
+ # Bob attempts to add another item (or swap item)
+ trade.add_item(102, 1, 1)
+ _assert(trade.side_a.is_ready == false, "Alice's ready lock instantly revoked on Bob item add")
+ _assert(trade.side_b.is_ready == false, "Bob's ready lock also reset")
+
+ # Alice locks ready again
+ trade.set_ready(101, true)
+ _assert(trade.side_a.is_ready == true, "Alice locked ready again")
+
+ # Bob modifies gold
+ trade.set_gold(102, 10000)
+ _assert(trade.side_a.is_ready == false, "Alice's ready lock instantly revoked on Bob gold change")
+
+ # Alice locks ready again
+ trade.set_ready(101, true)
+
+ # Bob removes item
+ trade.remove_item(102, 1)
+ _assert(trade.side_a.is_ready == false, "Alice's ready lock instantly revoked on Bob item remove")
+
+func _test_gold_offer_validation() -> void:
+ print("\n--- Test 4: Gold Offer Validation ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 100000)
+ var p2 = _create_player(102, "Bob", 50000)
+
+ trade.start_exchange(p1, p2)
+
+ # Reject offer exceeding gold
+ var r_fail = trade.set_gold(101, 150000)
+ _assert(r_fail["error_code"] == "INSUFFICIENT_GOLD", "Reject gold offer exceeding owned gold")
+
+ # Accept valid offer
+ var r_ok = trade.set_gold(101, 80000)
+ _assert(r_ok["success"] == true, "Valid gold offer accepted")
+ _assert(trade.side_a.offered_gold == 80000, "Alice offered 80,000 gold")
+
+func _test_successful_atomic_exchange() -> void:
+ print("\n--- Test 5: Successful Atomic Trade Execution ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 500000, 5)
+ var p2 = _create_player(102, "Bob", 200000, 5)
+
+ p1["inv"][0] = { "vnum": 101, "name": "Dragon Sword+9" }
+ p2["inv"][0] = { "vnum": 202, "name": "Black Steel Armor+9" }
+
+ trade.start_exchange(p1, p2)
+
+ # Alice puts Dragon Sword and 100,000 gold
+ trade.add_item(101, 0, 0)
+ trade.set_gold(101, 100000)
+
+ # Bob puts Black Steel Armor and 40,000 gold
+ trade.add_item(102, 0, 0)
+ trade.set_gold(102, 40000)
+
+ # Both confirm
+ var r_ready_a = trade.set_ready(101, true)
+ _assert(r_ready_a["trade_completed"] == false, "Trade not completed after only Alice ready")
+
+ var r_ready_b = trade.set_ready(102, true)
+ _assert(r_ready_b["trade_completed"] == true, "Trade atomically completed when both ready")
+ _assert(trade.is_active == false, "Trade session closed")
+
+ # Check Gold:
+ # Alice: 500,000 - 100,000 + 40,000 = 440,000
+ _assert(p1["gold"] == 440000, "Alice gold correctly calculated (440,000)")
+ # Bob: 200,000 - 40,000 + 100,000 = 260,000
+ _assert(p2["gold"] == 260000, "Bob gold correctly calculated (260,000)")
+
+ # Check Items:
+ # Alice now has Black Steel Armor
+ var alice_has_armor := false
+ for it in p1["inv"]:
+ if it != null and it.get("vnum") == 202:
+ alice_has_armor = true
+ _assert(alice_has_armor == true, "Alice received Bob's Black Steel Armor")
+
+ # Bob now has Dragon Sword
+ var bob_has_sword := false
+ for it in p2["inv"]:
+ if it != null and it.get("vnum") == 101:
+ bob_has_sword = true
+ _assert(bob_has_sword == true, "Bob received Alice's Dragon Sword")
+
+func _test_insufficient_space_rejection() -> void:
+ print("\n--- Test 6: CheckSpace Rejection (Inventory Full) ---")
+ var trade = SystemClass.new()
+ # Bob's inventory has only 1 slot, and it is full with an item he is NOT trading
+ var p1 = _create_player(101, "Alice", 500000, 5)
+ var p2 = _create_player(102, "Bob", 200000, 1)
+
+ p1["inv"][0] = { "vnum": 101, "name": "Item A" }
+ p2["inv"][0] = { "vnum": 999, "name": "Untraded Item (Full)" } # Not trading this
+
+ trade.start_exchange(p1, p2)
+ trade.add_item(101, 0, 0) # Alice offers Item A
+
+ trade.set_ready(101, true)
+ var res = trade.set_ready(102, true)
+ _assert(res["trade_completed"] == false, "Trade aborted due to space check")
+ _assert(res["error_code"] == "PLAYER_B_INSUFFICIENT_SPACE", "Detected Bob has insufficient inventory space")
+
+func _test_cancel_trade() -> void:
+ print("\n--- Test 7: Trade Cancellation ---")
+ var trade = SystemClass.new()
+ var p1 = _create_player(101, "Alice", 500000, 5)
+ var p2 = _create_player(102, "Bob", 200000, 5)
+
+ trade.start_exchange(p1, p2)
+ _assert(trade.is_active == true, "Trade started")
+
+ trade.cancel_trade()
+ _assert(trade.is_active == false, "Trade canceled and inactive")
diff --git a/project/test_player_trade_exchange_parity.gd.uid b/project/test_player_trade_exchange_parity.gd.uid
new file mode 100644
index 00000000..3a29f5d9
--- /dev/null
+++ b/project/test_player_trade_exchange_parity.gd.uid
@@ -0,0 +1 @@
+uid://cbovg1tm3oxxc
diff --git a/project/test_point_reset_parity.gd b/project/test_point_reset_parity.gd
new file mode 100644
index 00000000..6b242022
--- /dev/null
+++ b/project/test_point_reset_parity.gd
@@ -0,0 +1,99 @@
+# test_point_reset_parity.gd
+# 检验 40250 属性点/技能重置老妪洗点与两阶段确认系统
+extends SceneTree
+
+const PointResetSystem = preload("res://point_reset_system.gd")
+
+func _init() -> void:
+ print("--- 开始测试 40250 洗点老妪属性与技能重置系统 (test_point_reset_parity) ---")
+ var sys = PointResetSystem.new()
+ var fails: int = 0
+
+ # 1. 初始化战士数据 (Lv 25, 2000 金币, CON 20, INT 3, STR 50, DEX 10, 剩余属性点 2)
+ # 战士初始: CON 4, INT 3, STR 6, DEX 3
+ # 投入点数: CON +16, STR +44, DEX +7 = 67 点
+ sys.init_player_data(
+ 0, # WARRIOR
+ 25, # Lv 25
+ 2000, # 2000 Yang
+ {"CON": 20, "INT": 3, "STR": 50, "DEX": 10},
+ 2, # 2 unallocated stat points
+ 1, # 分支 1 (体战)
+ 0,
+ {1: 17, 2: 5} # 17级剑气, 5级狂暴
+ )
+
+ # 2. 两阶段弹窗生命周期测试
+ sys.open_dialog()
+ if sys.state != PointResetSystem.DialogState.INITIAL_PROMPT:
+ printerr("FAIL: 打开后未进入 INITIAL_PROMPT 状态: ", sys.state)
+ fails += 1
+
+ var opened_confirm = sys.open_confirm_dialog()
+ if not opened_confirm or sys.state != PointResetSystem.DialogState.CONFIRMATION:
+ printerr("FAIL: 未成功转入 CONFIRMATION 阶段: ", sys.state)
+ fails += 1
+
+ # 3. 接受洗点并核对扣款与属性全额回退
+ var reset_ok = sys.accept_reset()
+ if not reset_ok:
+ printerr("FAIL: 确认洗点失败!")
+ fails += 1
+ if sys.player_gold != 1500: # 2000 - 500 = 1500
+ printerr("FAIL: 500 金币扣除错误,剩余金币: ", sys.player_gold)
+ fails += 1
+ if sys.last_packet_sent != "/pointreset":
+ printerr("FAIL: 发送协议指令不符: ", sys.last_packet_sent)
+ fails += 1
+
+ # 属性应重置为战士初始值 [CON:4, INT:3, STR:6, DEX:3]
+ if sys.current_stats["CON"] != 4 or sys.current_stats["STR"] != 6 or sys.current_stats["DEX"] != 3 or sys.current_stats["INT"] != 3:
+ printerr("FAIL: 四维属性未恢复初始基准: ", sys.current_stats)
+ fails += 1
+
+ # 未分配属性点应为: 2(原未分配) + 67(洗回) = 69 点
+ if sys.unallocated_stat_points != 69:
+ printerr("FAIL: 属性点回退总额不符,期望 69,实际: ", sys.unallocated_stat_points)
+ fails += 1
+
+ if sys.state != PointResetSystem.DialogState.CLOSED:
+ printerr("FAIL: 洗点完成后弹窗未关闭: ", sys.state)
+ fails += 1
+
+ # 4. 金币不足时拦截测试
+ sys.player_gold = 200
+ sys.open_dialog()
+ var confirm_when_broke = sys.open_confirm_dialog()
+ if confirm_when_broke or sys.state != PointResetSystem.DialogState.CLOSED:
+ printerr("FAIL: 金币不足时未能正确阻断!")
+ fails += 1
+
+ # 5. 老妇人 30 级以下技能重洗测试
+ sys.player_gold = 50000
+ var skill_reset_ok = sys.reset_skills_at_old_woman()
+ if not skill_reset_ok:
+ printerr("FAIL: 25 级角色老妇人洗技能失败!")
+ fails += 1
+ if sys.current_skill_tree != 0:
+ printerr("FAIL: 洗技能后技能树分支应重置为 0: ", sys.current_skill_tree)
+ fails += 1
+ if sys.active_skills.size() != 0:
+ printerr("FAIL: 洗技能后技能字典应清空: ", sys.active_skills)
+ fails += 1
+ if sys.unallocated_skill_points != 22: # 17 + 5 = 22
+ printerr("FAIL: 技能点退回不符,期望 22,实际: ", sys.unallocated_skill_points)
+ fails += 1
+
+ # 6. 超出 30 级老妇人洗技能拒绝测试
+ sys.player_level = 31
+ var high_lv_reset = sys.reset_skills_at_old_woman()
+ if high_lv_reset:
+ printerr("FAIL: 超过 30 级老妇人洗技能未被拒绝!")
+ fails += 1
+
+ if fails == 0:
+ print("PASS: test_point_reset_parity (8/8 全部通过)")
+ quit(0)
+ else:
+ printerr("FAILED: test_point_reset_parity (", fails, " 项失败)")
+ quit(1)
diff --git a/project/test_point_reset_parity.gd.uid b/project/test_point_reset_parity.gd.uid
new file mode 100644
index 00000000..f14eafc1
--- /dev/null
+++ b/project/test_point_reset_parity.gd.uid
@@ -0,0 +1 @@
+uid://bjesoi811yncf
diff --git a/project/test_polymorph_marble_parity.gd b/project/test_polymorph_marble_parity.gd
new file mode 100644
index 00000000..b6eed071
--- /dev/null
+++ b/project/test_polymorph_marble_parity.gd
@@ -0,0 +1,132 @@
+# test_polymorph_marble_parity.gd —— 40250 官方变身宝珠与怪物精魂附体系统 1:1 回归测试套件
+extends SceneTree
+
+const PolymorphMarbleSystem = preload("res://polymorph_marble_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_polymorph_marble_parity (Direction 1: Polymorph Marble 1:1) ===")
+ _test_marble_transformation_and_bonuses()
+ _test_skill_mastery_durations()
+ _test_mount_and_skill_restrictions()
+ _test_expiration_and_stat_reversion()
+ _test_serialization()
+ _finish()
+
+func _test_marble_transformation_and_bonuses() -> void:
+ print("\n--- Test 1: Transformation & Bonus Stat Injection ---")
+ var sys := PolymorphMarbleSystem.new()
+ var player := {"attack_power": 500, "defense": 300, "move_speed": 100}
+
+ var start_sig := {"fired": false, "mob": 0, "name": "", "bonus_type": 0}
+ sys.polymorph_started.connect(func(m: int, n: String, _d: float, bt: int, _bv: int):
+ start_sig["fired"] = true
+ start_sig["mob"] = m
+ start_sig["name"] = n
+ start_sig["bonus_type"] = bt
+ )
+
+ # 1. Barbarian Infantry (501) - Attack Bonus +40
+ var marble_barbarian := {"vnum": PolymorphMarbleSystem.VNUM_POLYMORPH_MARBLE, "mob_vnum": 501}
+ var res1 = sys.polymorph_character(marble_barbarian, PolymorphMarbleSystem.SKILL_NORMAL, player)
+ _assert(res1["ok"] == true, "Polymorphed into Barbarian Infantry")
+ _assert(sys.is_polymorphed == true, "is_polymorphed is true")
+ _assert(player["attack_power"] == 540, "Attack power increased 500 -> 540 (+40)")
+ _assert(start_sig["fired"] == true, "polymorph_started signal emitted")
+ _assert(start_sig["mob"] == 501, "Mob vnum is 501")
+
+ # 2. Directly switch to White Hair Spider (492) - Def Bonus +50
+ var marble_spider := {"vnum": PolymorphMarbleSystem.VNUM_POLYMORPH_MARBLE, "mob_vnum": 492}
+ var res2 = sys.polymorph_character(marble_spider, PolymorphMarbleSystem.SKILL_NORMAL, player)
+ _assert(res2["ok"] == true, "Switched into White Hair Spider")
+ _assert(player["attack_power"] == 500, "Previous attack bonus (+40) cleared cleanly")
+ _assert(player["defense"] == 350, "Defense increased 300 -> 350 (+50)")
+
+func _test_skill_mastery_durations() -> void:
+ print("\n--- Test 2: 40250 Mastery Skill Durations ---")
+ _assert(PolymorphMarbleSystem.get_duration_by_mastery(PolymorphMarbleSystem.SKILL_NORMAL) == 600.0, "Normal: 600s (10 min)")
+ _assert(PolymorphMarbleSystem.get_duration_by_mastery(PolymorphMarbleSystem.SKILL_MASTER) == 900.0, "Master: 900s (15 min)")
+ _assert(PolymorphMarbleSystem.get_duration_by_mastery(PolymorphMarbleSystem.SKILL_GRAND_MASTER) == 1200.0, "Grand Master: 1200s (20 min)")
+ _assert(PolymorphMarbleSystem.get_duration_by_mastery(PolymorphMarbleSystem.SKILL_PERFECT_MASTER) == 1500.0, "Perfect Master: 1500s (25 min)")
+
+func _test_mount_and_skill_restrictions() -> void:
+ print("\n--- Test 3: Mount & Character Skill Restrictions ---")
+ var sys := PolymorphMarbleSystem.new()
+ var player := {"attack_power": 100}
+ _assert(sys.can_mount_horse() == true, "Normal character can mount horse")
+ _assert(sys.can_cast_player_skills() == true, "Normal character can cast skills")
+
+ # Transform
+ sys.polymorph_character({"vnum": PolymorphMarbleSystem.VNUM_POLYMORPH_MARBLE, "mob_vnum": 101}, PolymorphMarbleSystem.SKILL_NORMAL, player)
+ _assert(sys.can_mount_horse() == false, "Polymorphed character CANNOT mount horse")
+ _assert(sys.can_cast_player_skills() == false, "Polymorphed character CANNOT cast player skills")
+
+ # Revert
+ sys.revert_polymorph(player)
+ _assert(sys.can_mount_horse() == true, "Reverted character can mount horse again")
+ _assert(sys.can_cast_player_skills() == true, "Reverted character can cast skills again")
+
+func _test_expiration_and_stat_reversion() -> void:
+ print("\n--- Test 4: Expiration Heartbeat & Stat Reversion ---")
+ var sys := PolymorphMarbleSystem.new()
+ var player := {"move_speed": 100}
+ var marble_fox := {"vnum": PolymorphMarbleSystem.VNUM_POLYMORPH_MARBLE, "mob_vnum": 1901} # 九尾妖狐 (+30 ms)
+
+ var end_sig := {"fired": false, "mob": 0}
+ sys.polymorph_ended.connect(func(m: int, _n: String):
+ end_sig["fired"] = true
+ end_sig["mob"] = m
+ )
+
+ sys.polymorph_character(marble_fox, PolymorphMarbleSystem.SKILL_NORMAL, player) # 600s
+ _assert(player["move_speed"] == 130, "Move speed boosted to 130")
+
+ # Advance 500s (100s left)
+ sys.update(500.0, player)
+ _assert(sys.is_polymorphed == true, "Still polymorphed at 500s")
+ _assert(player["move_speed"] == 130, "Speed boost still active")
+
+ # Advance remaining 100s -> timeout
+ sys.update(100.0, player)
+ _assert(sys.is_polymorphed == false, "Polymorph expired after 600s")
+ _assert(player["move_speed"] == 100, "Move speed reversed cleanly to 100")
+ _assert(end_sig["fired"] == true, "polymorph_ended signal emitted")
+ _assert(end_sig["mob"] == 1901, "Reported Nine Tails mob vnum")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := PolymorphMarbleSystem.new()
+ var player := {"defense": 200}
+ sys1.polymorph_character({"vnum": PolymorphMarbleSystem.VNUM_POLYMORPH_MARBLE, "mob_vnum": 492}, PolymorphMarbleSystem.SKILL_MASTER, player)
+ sys1.update(300.0, player) # 600s remaining
+
+ var data = sys1.serialize()
+ _assert(data["is_polymorphed"] == true, "Serialized is_polymorphed")
+ _assert(data["active_mob_vnum"] == 492, "Serialized mob vnum")
+ _assert(data["active_stat_key"] == "defense", "Serialized stat key")
+
+ var sys2 := PolymorphMarbleSystem.new()
+ sys2.deserialize(data)
+ _assert(sys2.is_polymorphed == true, "Restored is_polymorphed")
+ _assert(sys2.active_mob_vnum == 492, "Restored mob vnum 492")
+ _assert(sys2.active_mob_name == "白发蜘蛛", "Restored name")
+ _assert(is_equal_approx(sys2.time_remaining, 600.0), "Restored time remaining")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Polymorph Marble Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_polymorph_marble_parity.gd.uid b/project/test_polymorph_marble_parity.gd.uid
new file mode 100644
index 00000000..4a66ee6a
--- /dev/null
+++ b/project/test_polymorph_marble_parity.gd.uid
@@ -0,0 +1 @@
+uid://p25r13uramvn
diff --git a/project/test_polymorph_parity.gd b/project/test_polymorph_parity.gd
new file mode 100644
index 00000000..aff1a142
--- /dev/null
+++ b/project/test_polymorph_parity.gd
@@ -0,0 +1,116 @@
+# test_polymorph_parity.gd —— 40250 变身球与变身术技能 1:1 纯净回归测试套件
+extends SceneTree
+
+const PolymorphSystem = preload("res://polymorph_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_polymorph_parity (Direction 2: Polymorph System 1:1) ===")
+ _test_marble_item_and_prerequisites()
+ _test_duration_and_skill_scaling()
+ _test_transformation_stat_boost()
+ _test_combat_and_action_restrictions()
+ _test_termination_and_death()
+ _finish()
+
+func _test_marble_item_and_prerequisites() -> void:
+ print("\n--- Test 1: Marble Item & Transformation Prerequisites ---")
+ var ps := PolymorphSystem.new()
+ var marble := {"vnum": 70104, "mob_vnum": 101} # Wild Boar
+ var non_marble := {"vnum": 10, "mob_vnum": 101}
+
+ # Non marble rejected
+ var res = ps.use_polymorph_marble(non_marble, false, false)
+ _assert(res["ok"] == false and res["reason"] == "NOT_A_MARBLE", "Non-marble item rejected")
+
+ # Dead player rejected
+ res = ps.use_polymorph_marble(marble, true, false)
+ _assert(res["ok"] == false and res["reason"] == "PLAYER_DEAD", "Dead player cannot transform")
+
+ # Mounted player rejected
+ res = ps.use_polymorph_marble(marble, false, true)
+ _assert(res["ok"] == false and res["reason"] == "PLAYER_MOUNTED", "Mounted player cannot transform")
+
+ # Peaceful on foot succeeds
+ res = ps.use_polymorph_marble(marble, false, false)
+ _assert(res["ok"] == true, "Transformation succeeds on foot while alive")
+ _assert(ps.is_polymorphed == true, "Player is now in polymorphed state")
+ _assert(ps.current_mob_vnum == 101, "Current mob vnum is 101 (Wild Boar)")
+
+func _test_duration_and_skill_scaling() -> void:
+ print("\n--- Test 2: Duration Scaling with Skill Level (1~P) ---")
+ # Level 1: 620s (~10 min)
+ _assert(PolymorphSystem.get_duration_by_skill(1) == 620.0, "Skill Lv1: 620s")
+ # Level 20 (M1): 1200s (20 min)
+ _assert(PolymorphSystem.get_duration_by_skill(20) == 1200.0, "Skill M1 (Lv20): 1200s (20 min)")
+ # Level 30 (G1): 1500s (25 min)
+ _assert(PolymorphSystem.get_duration_by_skill(30) == 1500.0, "Skill G1 (Lv30): 1500s (25 min)")
+ # Level 40 (P): 2100s (35 min)
+ _assert(PolymorphSystem.get_duration_by_skill(40) == 2100.0, "Skill P (Lv40): 2100s (35 min)")
+
+func _test_transformation_stat_boost() -> void:
+ print("\n--- Test 3: Monster Attributes & Skill Scaling Boost ---")
+ var ps := PolymorphSystem.new()
+ ps.poly_skill_level = 40 # P grade (multiplier 1.0 + 40*0.025 = 2.0x)
+
+ var tiger_marble := {"vnum": 70104, "mob_vnum": 114} # White Tiger (base atk: 85)
+ var res = ps.use_polymorph_marble(tiger_marble, false, false)
+ _assert(res["ok"] == true, "White tiger transformation succeeds")
+ _assert(ps.bonus_atk == 170, "P-Grade Tiger ATK is doubled: 85 * 2.0 = 170")
+ _assert(ps.bonus_def == 40, "Tiger DEF is 40")
+ _assert(ps.bonus_speed == 140, "Tiger movement speed is 140")
+
+func _test_combat_and_action_restrictions() -> void:
+ print("\n--- Test 4: Action Restrictions (Skills, Mount, Weapon) ---")
+ var ps := PolymorphSystem.new()
+ var marble := {"vnum": 70104, "mob_vnum": 110} # Bear
+ ps.use_polymorph_marble(marble, false, false)
+
+ # Active skills blocked
+ _assert(ps.can_use_skill(1) == false, "Warrior skill 1 (Three-way Cut) blocked")
+ _assert(ps.can_use_skill(2) == false, "Warrior skill 2 (Sword Spin) blocked")
+ _assert(ps.can_use_skill(129) == true, "Polymorph skill 129 permitted")
+
+ # Weapon change blocked
+ _assert(ps.can_change_weapon() == false, "Weapon swapping blocked while transformed")
+
+ # Mounting blocked
+ _assert(ps.can_mount() == false, "Mounting horse blocked while transformed")
+
+func _test_termination_and_death() -> void:
+ print("\n--- Test 5: Revert on Countdown Expiry & Death ---")
+ var ps := PolymorphSystem.new()
+ var marble := {"vnum": 70104, "mob_vnum": 101}
+ ps.use_polymorph_marble(marble, false, false)
+ _assert(ps.is_polymorphed == true, "Polymorphed initially")
+
+ # Fast forward time to expire
+ ps.update(ps.remaining_time + 1.0)
+ _assert(ps.is_polymorphed == false, "Polymorph ended after countdown expired")
+ _assert(ps.bonus_atk == 0, "Bonus ATK cleared")
+ _assert(ps.can_use_skill(1) == true, "Skills unlocked again after polymorph ends")
+
+ # Re-transform and test death
+ ps.use_polymorph_marble(marble, false, false)
+ _assert(ps.is_polymorphed == true, "Re-transformed")
+ ps.on_player_death()
+ _assert(ps.is_polymorphed == false, "Polymorph immediately cancelled on player death")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_polymorph_parity.gd.uid b/project/test_polymorph_parity.gd.uid
new file mode 100644
index 00000000..24b3d5a3
--- /dev/null
+++ b/project/test_polymorph_parity.gd.uid
@@ -0,0 +1 @@
+uid://cj2kik8aknd7s
diff --git a/project/test_portable_service_parity.gd b/project/test_portable_service_parity.gd
new file mode 100644
index 00000000..fe948045
--- /dev/null
+++ b/project/test_portable_service_parity.gd
@@ -0,0 +1,92 @@
+# test_portable_service_parity.gd —— 40250 随身便民服务道具 1:1 纯净回归测试套件
+extends SceneTree
+
+const PortableServiceSystem = preload("res://portable_service_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_portable_service_parity (Direction 2: Portable Services 1:1) ===")
+ _test_portable_safebox_usage()
+ _test_portable_blacksmith_usage()
+ _test_portable_store_and_buying()
+ _finish()
+
+func _test_portable_safebox_usage() -> void:
+ print("\n--- Test 1: Portable Safebox (70005) ---")
+ var pss := PortableServiceSystem.new()
+ var inv: Array = [
+ {"vnum": PortableServiceSystem.ITEM_PORTABLE_SAFEBOX, "name": "随身仓库卷轴", "count": 3}
+ ]
+
+ var open_signal := {"fired": false, "svc": ""}
+ pss.service_opened.connect(func(s: String):
+ open_signal["fired"] = true
+ open_signal["svc"] = s
+ )
+
+ var res = pss.use_portable_safebox(0, inv)
+ _assert(res["ok"] == true, "Portable safebox opened")
+ _assert(pss.active_service == "safebox", "active_service is safebox")
+ _assert(inv[0]["count"] == 2, "1 scroll consumed (3 -> 2)")
+ _assert(open_signal["fired"] == true and open_signal["svc"] == "safebox", "service_opened signal fired")
+
+func _test_portable_blacksmith_usage() -> void:
+ print("\n--- Test 2: Portable Blacksmith (70006) ---")
+ var pss := PortableServiceSystem.new()
+ var inv: Array = [
+ {"vnum": PortableServiceSystem.ITEM_PORTABLE_BLACKSMITH, "name": "随身铁匠手稿", "count": 1}
+ ]
+
+ var res = pss.use_portable_blacksmith(0, inv)
+ _assert(res["ok"] == true, "Portable blacksmith opened")
+ _assert(pss.active_service == "blacksmith", "active_service is blacksmith")
+ _assert(inv[0] == null, "Single scroll consumed to null")
+
+func _test_portable_store_and_buying() -> void:
+ print("\n--- Test 3: Portable General Store (70007) & Purchasing ---")
+ var pss := PortableServiceSystem.new()
+ var player_data := {"gold": 1000} # Less than 3200
+ var inv: Array = [
+ {"vnum": PortableServiceSystem.ITEM_PORTABLE_GENERAL_STORE, "name": "随身杂货铺契约", "count": 1},
+ null
+ ]
+
+ # 1. Open store
+ var r_open = pss.use_portable_store(0, inv)
+ _assert(r_open["ok"] == true, "Portable store opened")
+ _assert(pss.active_service == "store", "active_service is store")
+ _assert(r_open["goods"].size() == 4, "Store offers 4 items")
+
+ # 2. Buy with insufficient Yang
+ var r_poor = pss.buy_goods_from_portable_store(0, player_data, inv)
+ _assert(r_poor["ok"] == false and r_poor["reason"] == "INSUFFICIENT_YANG", "Blocked with insufficient Yang")
+
+ # 3. Buy 200x Big Red Potions (price: 3200) with 10,000 Yang
+ player_data["gold"] = 10000
+ var r_buy = pss.buy_goods_from_portable_store(0, player_data, inv)
+ _assert(r_buy["ok"] == true, "Purchased red potions")
+ _assert(player_data["gold"] == 6800, "3200 Yang deducted (10000 -> 6800)")
+
+ # Notice slot 0 was emptied when using the scroll, so purchase goes to slot 0!
+ var purchased_slot = r_buy["slot"]
+ _assert(purchased_slot >= 0 and inv[purchased_slot]["vnum"] == 27003, "Red potions received into inventory")
+ _assert(inv[purchased_slot]["count"] == 200, "200 count received")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Portable Service Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_portable_service_parity.gd.uid b/project/test_portable_service_parity.gd.uid
new file mode 100644
index 00000000..a00b924a
--- /dev/null
+++ b/project/test_portable_service_parity.gd.uid
@@ -0,0 +1 @@
+uid://bdfpphyulenv5
diff --git a/project/test_private_shop_bundle_parity.gd b/project/test_private_shop_bundle_parity.gd
new file mode 100644
index 00000000..764a5c60
--- /dev/null
+++ b/project/test_private_shop_bundle_parity.gd
@@ -0,0 +1,99 @@
+# test_private_shop_bundle_parity.gd —— 验证方向 4:束发包袱皮与个人私有商店摆摊 40250 1:1 对齐
+extends SceneTree
+
+const PrivateShopSystem = preload("res://private_shop_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_private_shop_bundle_parity (Direction 4: Private Shop Bundle 1:1) ===")
+ test_bundle_item_check()
+ test_prerequisites()
+ test_sign_validation()
+ test_item_stocking()
+ test_shop_build_and_stall_vnum()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_bundle_item_check() -> void:
+ print("\n--- Test 1: Bundle Item Vnum Check (50200) ---")
+ _assert(PrivateShopSystem.is_bundle(50200), "50200 is Bundle (ITEM_BUNDLE)")
+ _assert(not PrivateShopSystem.is_bundle(10), "Sword is not a bundle")
+ _assert(not PrivateShopSystem.is_bundle(27001), "Potion is not a bundle")
+
+func test_prerequisites() -> void:
+ print("\n--- Test 2: Shop Opening Prerequisites (Battle, Mount, Death) ---")
+ # Fails when dead
+ var r_dead := PrivateShopSystem.can_open_private_shop(false, false, true)
+ _assert(not r_dead.ok and r_dead.code == "CANNOT_DEAD", "Rejected while dead")
+
+ # Fails when mounted
+ var r_mount := PrivateShopSystem.can_open_private_shop(false, true, false)
+ _assert(not r_mount.ok and r_mount.code == "CANNOT_MOUNTED", "Rejected while mounted")
+
+ # Fails when fighting
+ var r_fight := PrivateShopSystem.can_open_private_shop(true, false, false)
+ _assert(not r_fight.ok and r_fight.code == "CANNOT_FIGHTING", "Rejected while in battle")
+
+ # Succeeds when peaceful
+ var r_ok := PrivateShopSystem.can_open_private_shop(false, false, false)
+ _assert(r_ok.ok, "Can open shop when peaceful on foot")
+
+func test_sign_validation() -> void:
+ print("\n--- Test 3: Shop Sign Text Validation ---")
+ var v_empty := PrivateShopSystem.validate_sign("")
+ _assert(not v_empty.ok and v_empty.code == "SIGN_EMPTY", "Empty sign rejected")
+
+ var long_sign := "这是一个超级无敌非常长长长长长长长长长长长长长长长长的名字" # > 25 chars
+ var v_long := PrivateShopSystem.validate_sign(long_sign)
+ _assert(not v_long.ok and v_long.code == "SIGN_TOO_LONG", "Sign > 25 chars rejected")
+
+ var v_valid := PrivateShopSystem.validate_sign("神装特价店")
+ _assert(v_valid.ok and v_valid.sign == "神装特价店", "Valid sign accepted")
+
+func test_item_stocking() -> void:
+ print("\n--- Test 4: Staging Items with Price into Stock ---")
+ var stock := {}
+ var sword_item := {"cell": 0, "vnum": 10, "count": 1}
+
+ # Zero/negative price rejected
+ var r_zero := PrivateShopSystem.add_item_to_stock(stock, 0, sword_item, 0)
+ _assert(not r_zero.ok and r_zero.code == "INVALID_PRICE", "Zero price rejected")
+
+ # Valid add
+ var r_add := PrivateShopSystem.add_item_to_stock(stock, 0, sword_item, 5000)
+ _assert(r_add.ok and stock.has(0), "Item added to stock slot 0")
+ _assert(stock[0].price == 5000, "Price set to 5000 gold")
+
+ # Remove from stock
+ _assert(PrivateShopSystem.remove_item_from_stock(stock, 0), "Item removed from stock slot 0")
+ _assert(not stock.has(0), "Stock slot 0 is now empty")
+
+func test_shop_build_and_stall_vnum() -> void:
+ print("\n--- Test 5: Shop Build & Stall Model (Vnum 30000) ---")
+ var stock := {
+ 0: {"cell": 0, "vnum": 10, "count": 1, "price": 5000}
+ }
+ var stall_pos := Vector3(120, 0, 80)
+
+ var res_build := PrivateShopSystem.build_private_shop("平民杂货摊", stock, stall_pos)
+ _assert(res_build.ok and res_build.code == "SHOP_OPENED", "Shop opened successfully")
+ _assert(res_build.stall_vnum == PrivateShopSystem.VNUM_SHOP_HOST_STALL,
+ "Stall model is 30000 (SHOP_HOST)")
+ _assert(res_build.items_count == 1, "Staged item count is 1")
+ _assert(res_build.position == stall_pos, "Stall spawned at current position")
diff --git a/project/test_private_shop_bundle_parity.gd.uid b/project/test_private_shop_bundle_parity.gd.uid
new file mode 100644
index 00000000..3ee633c5
--- /dev/null
+++ b/project/test_private_shop_bundle_parity.gd.uid
@@ -0,0 +1 @@
+uid://b86xbo3226y6v
diff --git a/project/test_privilege_empire_guild_parity.gd b/project/test_privilege_empire_guild_parity.gd
new file mode 100644
index 00000000..b65b039a
--- /dev/null
+++ b/project/test_privilege_empire_guild_parity.gd
@@ -0,0 +1,120 @@
+# test_privilege_empire_guild_parity.gd —— Metin2 40250 官方帝国与公会恩泽特权 1:1 测试套件
+extends SceneTree
+
+const PrivilegeEmpireGuildSystem = preload("res://privilege_empire_guild_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_privilege_empire_guild_parity (Direction 4: Privileges & Rates 1:1) ===")
+
+ test_empire_and_guild_privileges()
+ test_effective_privilege_max_calculation()
+ test_unique_items_stacking()
+ test_privilege_expiration()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Privilege System Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_empire_and_guild_privileges() -> void:
+ print("\n--- Test 1: Empire & Guild Privileges Setup (40250 1:1) ---")
+ var system = PrivilegeEmpireGuildSystem.new()
+
+ # 辛秀帝国 (1) 开启双倍经验 (+100% EXP, 持续 3600 秒)
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_EXP, 100, 3600.0)
+ # 公会 (ID: 501) 开启爆率加成 (+50% Item Drop, 持续 1800 秒)
+ system.set_guild_privilege(501, PrivilegeEmpireGuildSystem.PRIV_ITEM_DROP, 50, 1800.0)
+
+ # 辛秀国民查询经验加成
+ var exp_bonus = system.get_effective_privilege(1001, 1, 0, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(exp_bonus == 100, "Shinsoo citizen enjoys +100% EXP bonus")
+
+ # 敌国 (Chunjo 2) 查询经验加成
+ var enemy_exp = system.get_effective_privilege(2001, 2, 0, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(enemy_exp == 0, "Chunjo citizen has 0% EXP bonus")
+
+ # 公会 501 成员查询爆率加成
+ var drop_bonus = system.get_effective_privilege(1001, 1, 501, PrivilegeEmpireGuildSystem.PRIV_ITEM_DROP)
+ _assert(drop_bonus == 50, "Guild 501 member enjoys +50% Item Drop bonus")
+
+func test_effective_privilege_max_calculation() -> void:
+ print("\n--- Test 2: Effective Privilege MAX Rule (40250 1:1) ---")
+ var system = PrivilegeEmpireGuildSystem.new()
+
+ # 全服全境特权: +20% EXP
+ system.set_empire_privilege(0, PrivilegeEmpireGuildSystem.PRIV_EXP, 20, 3600.0)
+ # 辛秀专属帝国特权: +50% EXP
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_EXP, 50, 3600.0)
+ # 辛秀第一公会 (888) 特权: +80% EXP
+ system.set_guild_privilege(888, PrivilegeEmpireGuildSystem.PRIV_EXP, 80, 3600.0)
+
+ # 依据 40250 原版 priv_manager.cpp:
+ # val = MAX(val_ch, GetPrivByEmpire(0, type)) -> 20
+ # val = MAX(val, GetPrivByEmpire(ch->GetEmpire(), type)) -> 50
+ # val = MAX(val, GetPrivByGuild(ch->GetGuild()->GetID(), type)) -> 80
+ var final_exp = system.get_effective_privilege(1001, 1, 888, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(final_exp == 80, "Computed MAX value (80%) among global, empire, and guild")
+
+func test_unique_items_stacking() -> void:
+ print("\n--- Test 3: Experience Ring & Thief Glove Stacking (40250 1:1) ---")
+ var system = PrivilegeEmpireGuildSystem.new()
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_EXP, 50, 3600.0)
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_ITEM_DROP, 25, 3600.0)
+
+ # 佩戴经验指环 (+50% EXP) -> 50 + 50 = 100%
+ var exp_with_ring = system.get_effective_privilege(1001, 1, 0, PrivilegeEmpireGuildSystem.PRIV_EXP, true, false)
+ _assert(exp_with_ring == 100, "EXP stacked with Experience Ring (+50% = 100%)")
+
+ # 佩戴小偷手套 (+100% Drop) -> 25 + 100 = 125%
+ var drop_with_glove = system.get_effective_privilege(1001, 1, 0, PrivilegeEmpireGuildSystem.PRIV_ITEM_DROP, false, true)
+ _assert(drop_with_glove == 125, "Drop rate stacked with Thief Glove (+100% = 125%)")
+
+func test_privilege_expiration() -> void:
+ print("\n--- Test 4: Privilege Expiration with Delta Time ---")
+ var system = PrivilegeEmpireGuildSystem.new()
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_EXP, 100, 500.0)
+ system.set_guild_privilege(777, PrivilegeEmpireGuildSystem.PRIV_GOLD_DROP, 30, 200.0)
+
+ # 推进 300 秒 (公会 200 秒特权已过期,帝国 500 秒特权尚存)
+ system.update(300.0)
+ var guild_gold = system.get_effective_privilege(1001, 1, 777, PrivilegeEmpireGuildSystem.PRIV_GOLD_DROP)
+ _assert(guild_gold == 0, "Guild privilege expired after 300s")
+ var emp_exp = system.get_effective_privilege(1001, 1, 777, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(emp_exp == 100, "Empire privilege still active")
+
+ # 推进 300 秒 (帝国特权也过期)
+ system.update(300.0)
+ emp_exp = system.get_effective_privilege(1001, 1, 777, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(emp_exp == 0, "Empire privilege expired after 600s")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Privilege State Serialization ---")
+ var system = PrivilegeEmpireGuildSystem.new()
+ system.set_empire_privilege(1, PrivilegeEmpireGuildSystem.PRIV_EXP, 75, 1000.0)
+ system.set_character_privilege(999, PrivilegeEmpireGuildSystem.PRIV_GOLD_DROP, 15)
+
+ var data = system.serialize()
+ _assert(data.has("empire_privs") and data.has("char_privs"), "Serialized data")
+
+ var restored = PrivilegeEmpireGuildSystem.new()
+ restored.deserialize(data)
+ var emp_exp = restored.get_effective_privilege(1001, 1, 0, PrivilegeEmpireGuildSystem.PRIV_EXP)
+ _assert(emp_exp == 75, "Restored 75% empire EXP")
+ var char_gold = restored.get_effective_privilege(999, 1, 0, PrivilegeEmpireGuildSystem.PRIV_GOLD_DROP)
+ _assert(char_gold == 15, "Restored 15% character gold drop")
diff --git a/project/test_privilege_empire_guild_parity.gd.uid b/project/test_privilege_empire_guild_parity.gd.uid
new file mode 100644
index 00000000..82a46d3c
--- /dev/null
+++ b/project/test_privilege_empire_guild_parity.gd.uid
@@ -0,0 +1 @@
+uid://c3s7camybb8ax
diff --git a/project/test_quest_dialog_parity.gd b/project/test_quest_dialog_parity.gd
new file mode 100644
index 00000000..685a437e
--- /dev/null
+++ b/project/test_quest_dialog_parity.gd
@@ -0,0 +1,381 @@
+# test_quest_dialog_parity.gd —— Phase 3 任务系统与对话流 40250 行为对齐验证
+extends SceneTree
+
+const QuestDialog = preload("res://ui/quest_dialog.gd")
+const QuestLog = preload("res://ui/quest_log.gd")
+
+var _failed := false
+var _pass_count := 0
+
+func _check(ok: bool, msg: String) -> void:
+ if ok:
+ _pass_count += 1
+ print(" [PASS] " + msg)
+ else:
+ _failed = true
+ push_error("FAIL: " + msg)
+ print(" [FAIL] " + msg)
+
+class MockClient extends Node:
+ signal script_dialog(skin: int, text: String)
+ signal quest_confirm_ask(msg: String, timeout: int, request_pid: int)
+ signal quest_info(index: int)
+
+ var script_answers: Array = []
+ var script_buttons: Array = []
+ var quest_inputs: Array = []
+ var quest_confirms: Array = []
+ var quest_cancels := 0
+ var quests: Dictionary = {}
+
+ func script_answer(answer: int) -> bool:
+ script_answers.append(answer)
+ return true
+
+ func script_button(idx: int) -> bool:
+ script_buttons.append(idx)
+ return true
+
+ func quest_input(text: String) -> bool:
+ quest_inputs.append(text)
+ return true
+
+ func quest_confirm(yes: bool, request_pid: int) -> bool:
+ quest_confirms.append({"yes": yes, "pid": request_pid})
+ return true
+
+ func quest_cancel() -> bool:
+ quest_cancels += 1
+ script_answers.append(254)
+ return true
+
+ func get_quests() -> Array:
+ return quests.values()
+
+ func is_in_game() -> bool:
+ return true
+
+func _init() -> void:
+ print("=== Running test_quest_dialog_parity (Phase 3 Parity) ===")
+ _test_pipe_question_parsing()
+ _test_choice_pagination()
+ _test_input_submission()
+ _test_next_and_done()
+ _test_esc_cancel()
+ _test_skin_modes()
+ _test_left_image_layout()
+ _test_quest_button_and_log()
+ _test_quest_confirm()
+
+ if _failed:
+ print("\n=== QUEST DIALOG PARITY TESTS FAILED ===")
+ quit(1)
+ else:
+ print("\n=== ALL QUEST DIALOG PARITY TESTS PASSED (%d checks) ===" % _pass_count)
+ quit(0)
+
+# --- Test 1: 40250 Pipe-delimited [QUESTION] Parsing ---
+func _test_pipe_question_parsing() -> void:
+ print("\n--- Test 1: 40250 Standard Pipe-delimited [QUESTION] ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # 40250 standard wire script from questlua.cpp:642 GotoSelectState
+ var script := "老铁匠看着你说道:[ENTER]请问你需要什么帮助?[QUESTION 1;拜访铁匠|2;购买强化材料|3;打听消息|4;离开]"
+ client.script_dialog.emit(0, script)
+ qd.process_events(100.0)
+
+ _check(qd.is_open(), "Quest dialog opened")
+ var parsed: Dictionary = qd.parse_script(script)
+ _check(parsed.choices.size() == 4, "Parsed exactly 4 choices")
+ _check(parsed.choices[0] == "拜访铁匠", "Choice 0 is '拜访铁匠'")
+ _check(parsed.choices[1] == "购买强化材料", "Choice 1 is '购买强化材料'")
+ _check(parsed.choices[2] == "打听消息", "Choice 2 is '打听消息'")
+ _check(parsed.choices[3] == "离开", "Choice 3 is '离开'")
+
+ # Click choice 2 ("购买强化材料") -> 0-indexed choice 1 sent to server
+ var btn2: Button = qd._btnrow.get_child(1)
+ btn2.pressed.emit()
+ _check(client.script_answers == [1], "Clicking choice 2 sends 0-indexed script_answer(1)")
+ _check(not qd.is_open(), "Dialog closes after choice selection")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 2: Choice Pagination (> 8 choices per 40250 uiquest.py) ---
+func _test_choice_pagination() -> void:
+ print("\n--- Test 2: Choice Pagination (> 8 choices) ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # 12 choices
+ var script := "[QUESTION "
+ for i in range(1, 13):
+ if i > 1:
+ script += "|"
+ script += "%d;传送地点_%d" % [i, i]
+ script += "]"
+
+ client.script_dialog.emit(0, script)
+ qd.process_events(100.0)
+
+ _check(qd.is_open(), "Dialog opened for 12 choices")
+ # Page 1: 8 choice buttons + 1 navigation row = 9 children
+ _check(qd._btnrow.get_child_count() == 9, "Page 1 renders 8 choice buttons + 1 nav row")
+ var first_btn: Button = qd._btnrow.get_child(0)
+ _check(first_btn.text == "传送地点_1", "First button on Page 1 is '传送地点_1'")
+
+ # Nav row is child 8
+ var nav_row: HBoxContainer = qd._btnrow.get_child(8)
+ _check(nav_row != null, "Navigation row exists")
+ var next_btn: Button = nav_row.get_child(nav_row.get_child_count() - 1)
+ _check(next_btn.text == "下一页", "Next page button present")
+
+ # Switch to Page 2
+ next_btn.pressed.emit()
+ _check(qd._current_choice_page == 1, "Switched to Page 2")
+ # Page 2: remaining 4 choice buttons + 1 nav row = 5 children
+ _check(qd._btnrow.get_child_count() == 5, "Page 2 renders remaining 4 buttons + 1 nav row")
+ var page2_btn2: Button = qd._btnrow.get_child(1) # This is 10th overall choice (index 9)
+ _check(page2_btn2.text == "传送地点_10", "Second button on Page 2 is '传送地点_10'")
+
+ # Click choice 10 -> global index 9 sent to server
+ page2_btn2.pressed.emit()
+ _check(client.script_answers == [9], "Clicking option on Page 2 sends global index script_answer(9)")
+ _check(not qd.is_open(), "Dialog closed after selection on page 2")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 3: [INPUT] Submission Flow ---
+func _test_input_submission() -> void:
+ print("\n--- Test 3: [INPUT] Submission Flow ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ client.script_dialog.emit(0, "请输入暗号密码:[INPUT]")
+ qd.process_events(100.0)
+
+ _check(qd.is_open(), "Dialog with input opened")
+ var line_edit: LineEdit = null
+ var submit_btn: Button = null
+ for child in qd._btnrow.get_children():
+ if child is LineEdit:
+ line_edit = child
+ elif child is Button and child.text == "确定":
+ submit_btn = child
+
+ _check(line_edit != null, "LineEdit control created for [INPUT]")
+ _check(submit_btn != null, "Submit button created")
+
+ line_edit.text = "metin2_open_sesame"
+ submit_btn.pressed.emit()
+
+ _check(client.quest_inputs == ["metin2_open_sesame"], "Submitted input string sent via quest_input")
+ _check(not qd.is_open(), "Dialog closes after input submission")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 4: [NEXT] and [DONE] Protocols ---
+func _test_next_and_done() -> void:
+ print("\n--- Test 4: [NEXT] and [DONE] Protocols ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # 4.1: [NEXT] button
+ client.script_dialog.emit(0, "这是第一段剧情。[NEXT]")
+ qd.process_events(100.0)
+ var next_btn: Button = qd._btnrow.get_child(0)
+ _check(next_btn.text == "继续", "Button label is '继续'")
+ next_btn.pressed.emit()
+ _check(client.script_answers == [254], "[NEXT] click sends script_answer(254) to resume")
+
+ # 4.2: [DONE] button
+ client.script_answers.clear()
+ var done_emitted := [false]
+ qd.done_event.connect(func():
+ done_emitted[0] = true
+ )
+ client.script_dialog.emit(0, "任务已经完成![DONE]")
+ qd.process_events(100.0)
+ var done_btn: Button = qd._btnrow.get_child(0)
+ _check(done_btn.text == "关闭", "Button label is '关闭'")
+ done_btn.pressed.emit()
+ _check(client.script_answers == [254], "[DONE] click sends script_answer(254)")
+ _check(done_emitted[0], "done_event signal emitted on [DONE] click")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 5: ESC Key Cancel Flow ---
+func _test_esc_cancel() -> void:
+ print("\n--- Test 5: ESC Key Cancel Flow (40250 OnCancel) ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ client.script_dialog.emit(0, "选择你的阵营:[QUESTION 1;红帝国|2;黄帝国|3;蓝帝国]")
+ qd.process_events(100.0)
+ _check(qd.is_open(), "Dialog open waiting for choice")
+
+ # Simulate ESC key press
+ var esc := InputEventKey.new()
+ esc.keycode = KEY_ESCAPE
+ esc.pressed = true
+ qd._unhandled_input(esc)
+
+ _check(client.quest_cancels == 1 or client.script_answers == [254], "ESC key triggers quest cancellation")
+ _check(not qd.is_open(), "Dialog closed on ESC")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 6: Skin Modes (40250 uiquest.py:328) ---
+func _test_skin_modes() -> void:
+ print("\n--- Test 6: Skin Modes (NORMAL vs NOWINDOW vs CINEMA) ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # 6.1: SKIN_NORMAL (0)
+ client.script_dialog.emit(0, "普通对话框[DONE]")
+ qd.process_events(100.0)
+ _check(qd._panel.visible, "SKIN_NORMAL displays background panel")
+
+ # 6.2: SKIN_CINEMA (5)
+ client.script_dialog.emit(5, "电影过场模式[DONE]")
+ qd.process_events(100.0)
+ _check(not qd._panel.visible, "SKIN_CINEMA hides background panel")
+
+ # 6.3: SKIN_NOWINDOW (1)
+ client.script_dialog.emit(1, "无边框纯文本模式[DONE]")
+ qd.process_events(100.0)
+ _check(not qd._panel.visible, "SKIN_NOWINDOW hides background panel")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 7: Left Image Layout Offset (LEFTIMAGE) ---
+func _test_left_image_layout() -> void:
+ print("\n--- Test 7: Left Image Portrait Layout Offset ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # Dialog without portrait
+ client.script_dialog.emit(0, "无立绘对话[DONE]")
+ qd.process_events(100.0)
+ var default_text_x: float = qd._text.position.x
+ _check(default_text_x == 16.0, "Default text position x is 16.0")
+
+ # Dialog with LEFTIMAGE
+ var script := "[LEFTIMAGE src;ui/game/questboard/npc_guard.sub]守卫向你致敬:[ENTER]城外近期有野狼出没,请多加小心![DONE]"
+ client.script_dialog.emit(0, script)
+ qd.process_events(100.0)
+
+ # Even if texture is dummy/mocked, layout handles parsed LEFTIMAGE panel spec
+ var parsed: Dictionary = qd.parse_script(script)
+ _check(parsed.images.size() == 1 and parsed.images[0]["panel"] == "LEFTIMAGE", "Parsed LEFTIMAGE correctly")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 8: Quest Button & Quest Log Integration ---
+func _test_quest_button_and_log() -> void:
+ print("\n--- Test 8: Quest Button & Quest Log Flow ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ var ql := QuestLog.new()
+ root.add_child(ql)
+ ql.setup(client, host)
+
+ qd.quest_button_received.connect(func(idx, title, itype, iname):
+ ql.recv_quest(idx, title, itype, iname))
+
+ # Script emits QUESTBUTTON
+ var script := "[QUESTBUTTON idx(7) name(\"消灭恶狼\") icon_type(\"highlight\") icon_name(\"\")][DONE]"
+ client.script_dialog.emit(0, script)
+ qd.process_events(100.0)
+
+ _check(ql._quest_button_data.has(7), "QuestLog received quest button with index 7")
+ _check(ql._quest_button_data[7]["title"] == "消灭恶狼", "Quest title is '消灭恶狼'")
+
+ # Player clicks quest letter / button on sidebar
+ ql._start_quest(7)
+ _check(client.script_buttons == [7], "Clicking quest button sends script_button(7)")
+ _check(not ql._quest_button_data.has(7), "Quest button cleared from active list after click")
+
+ ql.queue_free()
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
+
+# --- Test 9: Quest Confirm (GC_QUEST_CONFIRM) ---
+func _test_quest_confirm() -> void:
+ print("\n--- Test 9: Quest Confirmation Modal Flow ---")
+ var client := MockClient.new()
+ var host := Control.new()
+ root.add_child(host)
+ var qd := QuestDialog.new()
+ root.add_child(qd)
+ qd.setup(client, host)
+
+ # Server asks confirmation (e.g. party dungeon warp)
+ client.quest_confirm_ask.emit("是否确认进入地牢副本?", 30, 99999)
+ _check(qd.is_open(), "Confirm dialog opened")
+ _check(qd._text.text == "是否确认进入地牢副本?", "Dialog message matches")
+
+ # Find "接受" button
+ var accept_btn: Button = null
+ for row_child in qd._btnrow.get_children():
+ if row_child is HBoxContainer:
+ for b in row_child.get_children():
+ if b is Button and b.text == "接受":
+ accept_btn = b
+
+ _check(accept_btn != null, "'接受' button exists")
+ accept_btn.pressed.emit()
+ _check(client.quest_confirms == [{"yes": true, "pid": 99999}], "Clicking 接受 sends quest_confirm(true, 99999)")
+ _check(not qd.is_open(), "Confirm dialog closes after decision")
+
+ qd.queue_free()
+ host.queue_free()
+ client.queue_free()
diff --git a/project/test_quest_dialog_parity.gd.uid b/project/test_quest_dialog_parity.gd.uid
new file mode 100644
index 00000000..70752c7c
--- /dev/null
+++ b/project/test_quest_dialog_parity.gd.uid
@@ -0,0 +1 @@
+uid://ccau3qbmw1738
diff --git a/project/test_quest_hud_parity.gd b/project/test_quest_hud_parity.gd
new file mode 100644
index 00000000..1bbf3658
--- /dev/null
+++ b/project/test_quest_hud_parity.gd
@@ -0,0 +1,174 @@
+# test_quest_hud_parity.gd —— 任务剧情与信件卷轴系统 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_quest_hud_parity.gd
+extends SceneTree
+
+const UiManager = preload("res://ui/ui_manager.gd")
+const CharStatusUI = preload("res://ui/char_status_ui.gd")
+const QuestLog = preload("res://ui/quest_log.gd")
+
+class MockClient extends Node:
+ signal quest_info(index: int)
+ var sent_script_buttons: Array[int] = []
+ var quests: Array = []
+
+ func get_quests() -> Array:
+ return quests
+
+ func script_button(idx: int) -> void:
+ sent_script_buttons.append(idx)
+
+ # Required dummy methods for CharStatusUI setup
+ func get_points() -> Dictionary:
+ var pts: Array = []
+ pts.resize(255)
+ pts.fill(0)
+ return {"points": pts, "hp": 100, "max_hp": 100, "sp": 50, "max_sp": 50, "level": 1, "exp": 0, "next_exp": 100, "gold": 0}
+
+ func get_main_vid() -> int: return 1
+ func get_entity(_vid: int) -> Dictionary: return {"name": "Hero", "guild": 0, "race": 0}
+ func get_guild_name(_gid: int) -> String: return ""
+ func get_skills() -> Array: return []
+ func get_skill_group() -> int: return 0
+
+var _checks_passed := 0
+var _checks_failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _checks_passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _checks_failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_quest_hud_parity (Direction 1: Quest HUD & Dialog 1:1) ===")
+ _test_quest_log_layout()
+ _test_quest_log_buttons()
+ _test_char_status_quest_page()
+ _finish()
+
+func _test_quest_log_layout() -> void:
+ print("\n--- Test 1: 40250 Quest Button Mathematical Layout (interfacemodule.py) ---")
+ # 800x600 screen
+ var y_count_600 := QuestLog.quest_button_y_count(600.0)
+ # (600 - 330) // 63 = 270 // 63 = 4
+ _assert(y_count_600 == 4, "Screen height 600 yields yCount = 4")
+
+ var y_pos_600 := QuestLog.quest_button_y_pos(600.0)
+ # 170 * 600 // 600 = 170
+ _assert(y_pos_600 == 170, "Screen height 600 yields yPos = 170")
+
+ # Slot positions
+ # slot 0: col 0, row 0 -> (20, 170)
+ var pos0 := QuestLog.quest_button_slot_pos(0, y_count_600, 20, y_pos_600)
+ _assert(pos0 == Vector2(20, 170), "Slot 0 position is (20, 170)")
+
+ # slot 3: col 0, row 3 -> (20, 170 + 3*63 = 359)
+ var pos3 := QuestLog.quest_button_slot_pos(3, y_count_600, 20, y_pos_600)
+ _assert(pos3 == Vector2(20, 359), "Slot 3 position is (20, 359)")
+
+ # slot 4: col 1, row 0 -> (20 + 100 = 120, 170)
+ var pos4 := QuestLog.quest_button_slot_pos(4, y_count_600, 20, y_pos_600)
+ _assert(pos4 == Vector2(120, 170), "Slot 4 column wrap position is (120, 170)")
+
+func _test_quest_log_buttons() -> void:
+ print("\n--- Test 2: Quest Buttons HUD & Ctrl+Q Toggle ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var ql: Node = QuestLog.new()
+ get_root().add_child(ql)
+ ql.setup(client, get_root())
+
+ _assert(ql.quest_buttons_visible() == true, "Quest buttons visible by default")
+ ql.toggle_buttons() # Ctrl+Q
+ _assert(ql.quest_buttons_visible() == false, "toggle_buttons (Ctrl+Q) hides quest buttons")
+ ql.toggle_buttons() # Ctrl+Q again
+ _assert(ql.quest_buttons_visible() == true, "toggle_buttons (Ctrl+Q) restores quest buttons")
+
+ # Adding quest buttons (BINARY_RecvQuest)
+ ql.recv_quest(101, "初入江湖", "file", "")
+ ql.recv_quest(102, "消灭野狼", "file", "")
+ _assert(ql.quest_button_count() == 2, "2 quest buttons recorded")
+ var order: Array[int] = ql.quest_button_order()
+ _assert(order[0] == 102 and order[1] == 101, "Latest quest button inserted at front (1:1 40250)")
+
+ ql.queue_free()
+ client.queue_free()
+
+func _test_char_status_quest_page() -> void:
+ print("\n--- Test 3: CharStatusUI Quest Page 1:1 Interaction & Protocol ---")
+ var assets := AssetRoot.path()
+ if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")):
+ print(" (skip: no characterwindow.py)")
+ return
+
+ var ui: CanvasLayer = UiManager.new()
+ get_root().add_child(ui)
+ ui._ready()
+
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ # Seed 7 quests to test pagination (> 5)
+ for i in range(7):
+ client.quests.append({
+ "index": 100 + i,
+ "title": "任务_%d" % i,
+ "counter_name": "击杀数" if i == 0 else "",
+ "counter_value": 5 if i == 0 else 0,
+ "clock_name": "限时" if i == 1 else "",
+ "clock_value": 125 if i == 1 else 0,
+ })
+
+ var cs: Node = CharStatusUI.new()
+ get_root().add_child(cs)
+ cs.setup(ui, client, assets)
+ cs.open("QUEST")
+ _assert(cs.is_open(), "Character window opened in QUEST state")
+
+ # Check Scrollbar visibility (> 5 quests)
+ var sb = cs._node("Quest_ScrollBar")
+ _assert(sb != null and sb.visible == true, "Quest_ScrollBar visible when quests > 5")
+ _assert(sb.max_value == 2, "Quest_ScrollBar max_value is 2 (7 - 5)")
+
+ # Check labels for first page (0..4)
+ var name0 = cs._node("Quest_Name_00")
+ _assert(name0 != null and name0.text == "任务_0", "Quest_Name_00 shows '任务_0'")
+
+ var cnt0 = cs._node("Quest_LastCount_00")
+ _assert(cnt0 != null and cnt0.text == "击杀数 : 5", "Quest_LastCount_00 shows counter '击杀数 : 5'")
+
+ var time1 = cs._node("Quest_LastTime_01")
+ _assert(time1 != null and time1.text == "限时 02:05", "Quest_LastTime_01 formats 125s as '限时 02:05'")
+
+ # Click on Quest_Name_00: should send script_button(-2147483648 + 100)
+ var ev := InputEventMouseButton.new()
+ ev.button_index = MOUSE_BUTTON_LEFT
+ ev.pressed = true
+ name0.gui_input.emit(ev)
+
+ _assert(client.sent_script_buttons.size() == 1, "Clicking quest row sent script_button")
+ var expected_val: int = -2147483648 + 100
+ _assert(client.sent_script_buttons[0] == expected_val, "script_button value is -2147483648 + 100 (%d)" % expected_val)
+
+ # Scroll to next page
+ sb.value = 2 # showing start index = 2
+ sb.value_changed.emit(2.0)
+ _assert(name0.text == "任务_2", "After scroll, Quest_Name_00 shows '任务_2'")
+
+ cs.close()
+ cs.queue_free()
+ client.queue_free()
+ ui.queue_free()
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_checks_passed, _checks_failed])
+ if _checks_failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_quest_hud_parity.gd.uid b/project/test_quest_hud_parity.gd.uid
new file mode 100644
index 00000000..b6a61d98
--- /dev/null
+++ b/project/test_quest_hud_parity.gd.uid
@@ -0,0 +1 @@
+uid://d3kjt1ms3y2e3
diff --git a/project/test_refine_effect_parity.gd b/project/test_refine_effect_parity.gd
new file mode 100644
index 00000000..fe5c5ec4
--- /dev/null
+++ b/project/test_refine_effect_parity.gd
@@ -0,0 +1,87 @@
+# test_refine_effect_parity.gd —— 验证方向 3:+7/+8/+9 装备专属发光特效 40250 1:1 对齐
+extends SceneTree
+
+const RefineEffectSystem = preload("res://refine_effect_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_refine_effect_parity (Direction 3: Refine Shine Effects 1:1) ===")
+ test_refine_level_extraction()
+ test_shine_threshold_check()
+ test_weapon_shine_specs()
+ test_armor_shine_specs()
+ test_particles_generation()
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_refine_level_extraction() -> void:
+ print("\n--- Test 1: Vnum to Refine Level Extraction ---")
+ _assert(RefineEffectSystem.get_refine_level_from_vnum(10) == 0, "Sword+0 (vnum 10) is level 0")
+ _assert(RefineEffectSystem.get_refine_level_from_vnum(16) == 6, "Sword+6 (vnum 16) is level 6")
+ _assert(RefineEffectSystem.get_refine_level_from_vnum(17) == 7, "Sword+7 (vnum 17) is level 7")
+ _assert(RefineEffectSystem.get_refine_level_from_vnum(18) == 8, "Sword+8 (vnum 18) is level 8")
+ _assert(RefineEffectSystem.get_refine_level_from_vnum(19) == 9, "Sword+9 (vnum 19) is level 9")
+
+func test_shine_threshold_check() -> void:
+ print("\n--- Test 2: Shine Activation Threshold (Level >= 7) ---")
+ _assert(not RefineEffectSystem.is_shining_item(10, 1), "Sword+0 does not shine")
+ _assert(not RefineEffectSystem.is_shining_item(16, 1), "Sword+6 does not shine")
+ _assert(RefineEffectSystem.is_shining_item(17, 1), "Sword+7 activates shine")
+ _assert(RefineEffectSystem.is_shining_item(18, 1), "Sword+8 activates shine")
+ _assert(RefineEffectSystem.is_shining_item(19, 1), "Sword+9 activates shine")
+ _assert(not RefineEffectSystem.is_shining_item(27001, 3), "Potions do not shine")
+
+func test_weapon_shine_specs() -> void:
+ print("\n--- Test 3: 40250 Weapon Shine Specs (InstanceBase.cpp:2698) ---")
+ var spec7 := RefineEffectSystem.get_shine_spec(17, 1, false)
+ _assert(spec7.shining and spec7.level == 7, "Sword+7 spec has level 7")
+ _assert(spec7.attach_bone == RefineEffectSystem.ATTACH_BONE_WEAPON_RIGHT, "Sword attached to PART_WEAPON")
+ _assert("sword_7.mse" in spec7.effect_path, "Sword+7 uses sword_7.mse")
+
+ var spec8 := RefineEffectSystem.get_shine_spec(18, 1, false)
+ _assert("sword_8.mse" in spec8.effect_path, "Sword+8 uses sword_8.mse")
+
+ var spec9 := RefineEffectSystem.get_shine_spec(19, 1, false)
+ _assert("sword_9.mse" in spec9.effect_path, "Sword+9 uses sword_9.mse")
+ _assert(spec9.light_energy > spec7.light_energy, "+9 has higher light energy than +7")
+
+ # Dual daggers left hand attachment
+ var spec_left := RefineEffectSystem.get_shine_spec(1179, 1, true)
+ _assert(spec_left.attach_bone == RefineEffectSystem.ATTACH_BONE_WEAPON_LEFT, "Left hand weapon attached to PART_WEAPON_LEFT")
+
+func test_armor_shine_specs() -> void:
+ print("\n--- Test 4: 40250 Armor Shine Specs (InstanceBase.cpp:2739) ---")
+ # Regular armor +9 (11209)
+ var spec_armor9 := RefineEffectSystem.get_shine_spec(11209, 2)
+ _assert(spec_armor9.shining and spec_armor9.level == 9, "Armor+9 has shine")
+ _assert(spec_armor9.attach_bone == RefineEffectSystem.ATTACH_BONE_ARMOR, "Armor attached to Bip01")
+ _assert("armor_9.mse" in spec_armor9.effect_path, "Armor+9 uses armor_9.mse")
+
+ # Special Lv 66+ Black Steel Armor +9 (11299) -> special dragon aura
+ var spec_special := RefineEffectSystem.get_shine_spec(11299, 2)
+ _assert("armor-4-2.mse" in spec_special.effect_path, "Lv 66+ armor uses special armor-4-2.mse effect")
+
+func test_particles_generation() -> void:
+ print("\n--- Test 5: Godot Particle Node Generation ---")
+ var spec := RefineEffectSystem.get_shine_spec(19, 1)
+ var p: GPUParticles3D = RefineEffectSystem.create_shine_particles(spec)
+ _assert(p != null, "GPUParticles3D created successfully")
+ _assert(p.name == "RefineShineParticles_L9", "Particle node named properly")
+ _assert(p.draw_pass_1 is QuadMesh, "Draw pass mesh is configured")
+ p.free()
diff --git a/project/test_refine_effect_parity.gd.uid b/project/test_refine_effect_parity.gd.uid
new file mode 100644
index 00000000..04001e3a
--- /dev/null
+++ b/project/test_refine_effect_parity.gd.uid
@@ -0,0 +1 @@
+uid://u7ulbhd6fglk
diff --git a/project/test_refine_parity.gd b/project/test_refine_parity.gd
new file mode 100644
index 00000000..0d9ac376
--- /dev/null
+++ b/project/test_refine_parity.gd
@@ -0,0 +1,178 @@
+# test_refine_parity.gd —— 40250 强化系统 1:1 纯净无头自检套件
+# 验证:
+# 1. RefineDialogNew 动态几何尺寸计算与 Tooltip
+# 2. 材料槽位与 ThinBoard,背包存量白字/红字对比
+# 3. 金币费用与不足警示
+# 4. 100% 成功率与 type==5 免弹窗直接发包
+# 5. type==3 (铁匠石)、type==2 (祝福卷轴)、type==0 (普通铁匠) 对应专属 QuestionDialog2 文案
+# 6. 二次确认弹窗 "否" 拦截发包、"是" 触发发包
+# 7. 40250 取消规范:点击取消/ESC/关闭标题栏发送 refine(255, 255)
+# 8. 10.0m 超距自动取消发包
+# 9. 强化成功与失败音画回显
+extends SceneTree
+
+const RefineUI = preload("res://ui/refine_ui.gd")
+
+class FakeAudio extends Node:
+ var calls: Array[String] = []
+ func play_ui(name: String) -> void:
+ calls.append(name)
+
+class FakeClient extends Node:
+ signal refine_ask(info: Dictionary)
+ signal refine_result(ok: bool)
+
+ var calls: Array = []
+ var gold := 1000000
+ var inventory: Array = [
+ {"vnum": 30001, "count": 5, "cell": 0, "window": 1},
+ {"vnum": 30002, "count": 1, "cell": 1, "window": 1},
+ ]
+ var main_pos := Vector3(100.0, 10.0, 100.0)
+
+ func is_in_game() -> bool: return true
+ func get_gold() -> int: return gold
+ func get_inventory() -> Array: return inventory
+ func get_main_vid() -> int: return 1001
+ func get_entity(vid: int) -> Dictionary:
+ if vid == 1001:
+ return {"pos": main_pos}
+ return {}
+ func refine(pos: int, type: int) -> bool:
+ calls.append(["refine", pos, type])
+ return true
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+
+func _init() -> void:
+ _run()
+ if _fail == 0:
+ print("PASS: test_refine_parity (40250 RefineDialogNew 1:1 parity)")
+ quit(0)
+ else:
+ printerr("%d check(s) failed in test_refine_parity" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var canvas := CanvasLayer.new()
+ get_root().add_child(canvas)
+ var fc := FakeClient.new()
+ get_root().add_child(fc)
+ var fa := FakeAudio.new()
+ get_root().add_child(fa)
+
+ var ru: Node = RefineUI.new()
+ get_root().add_child(ru)
+ ru.setup(fc, canvas, null, fa)
+
+ _ck(not ru.is_open(), "1. 初始状态关闭")
+
+ # --- Case 1: 普通强化 (type 0, prob 60 < 100, 材料足) ---
+ fc.refine_ask.emit({
+ "type": 0, "pos": 5, "src_vnum": 10, "result_vnum": 11,
+ "cost": 50000, "prob": 60,
+ "materials": [{"vnum": 30001, "count": 2}],
+ })
+ _ck(ru.is_open(), "2. 收到 refine_ask 打开窗口")
+ _ck(ru._prob_label.text == "成功率: 60%", "3. 成功率文本准确")
+ _ck(ru._cost_label.text == "费用: 50000 金", "4. 费用文本准确")
+ _ck(ru._material_container.get_child_count() == 1, "5. 材质行生成")
+ var mat_lbl: Label = ru._material_container.get_child(0).get_child(1).get_child(0)
+ _ck(mat_lbl.modulate.r < 0.95 and mat_lbl.modulate.g > 0.8, "6. 材料足 (5>=2) 显示白字")
+
+ # 点击精炼 -> 应当弹出 QuestionDialog2,而不是直接发包
+ ru._refine_button.pressed.emit()
+ _ck(ru._question_dialog.visible, "7. prob 60 < 100 必须弹出 QuestionDialog2")
+ _ck(ru._qd_message1.text.contains("销毁"), "8. type 0 对应彻底销毁文案")
+ _ck(fc.calls.is_empty(), "9. 弹窗期间未发包")
+
+ # 点击 "否" -> 取消确认,精炼窗口不关,不发包
+ ru._qd_cancel_btn.pressed.emit()
+ _ck(not ru._question_dialog.visible, "10. 点否关闭确认弹窗")
+ _ck(ru.is_open(), "11. 主强化窗依然保持开启")
+ _ck(fc.calls.is_empty(), "12. 依然未发包")
+
+ # 再次点击精炼,并点 "是" -> 确认发包
+ ru._refine_button.pressed.emit()
+ ru._qd_accept_btn.pressed.emit()
+ _ck(fc.calls == [["refine", 5, 0]], "13. 确认后发出 client.refine(pos=5, type=0)")
+ _ck(not ru.is_open(), "14. 确认后主窗关闭")
+ fc.calls.clear()
+
+ # --- Case 2: 100% 成功率免弹窗 ---
+ fc.refine_ask.emit({
+ "type": 0, "pos": 2, "src_vnum": 1, "result_vnum": 2,
+ "cost": 1000, "prob": 100, "materials": [],
+ })
+ ru._refine_button.pressed.emit()
+ _ck(not ru._question_dialog.visible, "15. 100% 成功率不弹 QuestionDialog2")
+ _ck(fc.calls == [["refine", 2, 0]], "16. 100% 成功率直接发出 refine(2, 0)")
+ _ck(not ru.is_open(), "17. 发包后关闭")
+ fc.calls.clear()
+
+ # --- Case 3: 祝福卷轴 (type 2) 降级文案 ---
+ fc.refine_ask.emit({
+ "type": 2, "pos": 10, "src_vnum": 15, "result_vnum": 16,
+ "cost": 10000, "prob": 30, "materials": [],
+ })
+ ru._refine_button.pressed.emit()
+ _ck(ru._question_dialog.visible, "18. type 2 弹确认框")
+ _ck(ru._qd_message1.text.contains("降低1级"), "19. type 2 对应降级文案")
+ ru._qd_cancel_btn.pressed.emit()
+
+ # 点击取消按钮 -> 40250 发送 refine(255, 255)
+ ru._cancel_button.pressed.emit()
+ _ck(not ru.is_open(), "20. 取消后关闭")
+ _ck(fc.calls == [["refine", 255, 255]], "21. 取消发包 refine(255, 255)")
+ fc.calls.clear()
+
+ # --- Case 4: 铁匠石 (type 3) 加成文案 ---
+ fc.refine_ask.emit({
+ "type": 3, "pos": 12, "src_vnum": 18, "result_vnum": 19,
+ "cost": 20000, "prob": 50, "materials": [],
+ })
+ ru._refine_button.pressed.emit()
+ _ck(ru._qd_message1.text.contains("加成") or ru._qd_message1.text.contains("提升"), "22. type 3 对应概率提升文案")
+ # ESC 取消 -> 发送 refine(255, 255)
+ ru.cancel_refine()
+ _ck(not ru.is_open() and fc.calls == [["refine", 255, 255]], "23. cancel_refine 发送 (255, 255)")
+ fc.calls.clear()
+
+ # --- Case 5: 材料不足红字标红 ---
+ fc.refine_ask.emit({
+ "type": 0, "pos": 1, "src_vnum": 1, "result_vnum": 2,
+ "cost": 1000, "prob": 80,
+ "materials": [{"vnum": 30002, "count": 5}], # 背包只有 1 个
+ })
+ var shortage_lbl: Label = ru._material_container.get_child(0).get_child(1).get_child(0)
+ _ck(shortage_lbl.modulate.r > 0.9 and shortage_lbl.modulate.g < 0.4, "24. 材料不足 (1<5) 显示红字")
+ ru.close()
+
+ # --- Case 6: 10 米超距自动发包取消 ---
+ fc.refine_ask.emit({
+ "type": 0, "pos": 1, "src_vnum": 1, "result_vnum": 2,
+ "cost": 1000, "prob": 80, "materials": [],
+ })
+ _ck(ru.is_open(), "25. 再次打开")
+ # 玩家移动 5 米 (仍在 10 米内)
+ fc.main_pos = Vector3(105.0, 10.0, 100.0)
+ ru._process(0.1)
+ _ck(ru.is_open(), "26. 移动 5 米不关闭")
+ # 玩家移动 15 米 (超出 10 米)
+ fc.main_pos = Vector3(120.0, 10.0, 100.0)
+ ru._process(0.1)
+ _ck(not ru.is_open(), "27. 移动 15 米超出限制自动关闭")
+ _ck(fc.calls == [["refine", 255, 255]], "28. 超距自动发送取消包 refine(255, 255)")
+ fc.calls.clear()
+
+ # --- Case 7: 结果音效与状态回显 ---
+ fc.refine_result.emit(true)
+ _ck(ru.is_open() and ru._prob_label.text.contains("成功"), "29. 成功结果回显")
+ _ck(fa.calls.has("refine_success.wav"), "30. 播放 refine_success.wav")
+ fc.refine_result.emit(false)
+ _ck(ru._prob_label.text.contains("失败"), "31. 失败结果回显")
+ _ck(fa.calls.has("refine_fail.wav"), "32. 播放 refine_fail.wav")
diff --git a/project/test_refine_parity.gd.uid b/project/test_refine_parity.gd.uid
new file mode 100644
index 00000000..afad7faf
--- /dev/null
+++ b/project/test_refine_parity.gd.uid
@@ -0,0 +1 @@
+uid://0l2a72rimw1u
diff --git a/project/test_safebox_mall_parity.gd b/project/test_safebox_mall_parity.gd
new file mode 100644
index 00000000..d1ed14ab
--- /dev/null
+++ b/project/test_safebox_mall_parity.gd
@@ -0,0 +1,371 @@
+extends SceneTree
+
+const SafeboxUI = preload("res://ui/safebox_ui.gd")
+const MallUI = preload("res://ui/mall_ui.gd")
+
+var _pass := 0
+var _fail := 0
+
+func _ck(ok: bool, msg: String) -> void:
+ if ok:
+ _pass += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _fail += 1
+ printerr(" [FAIL] %s" % msg)
+
+class MockProto extends Node:
+ func item(vnum: int) -> Dictionary:
+ match vnum:
+ 10: return {"name": "Sword+0", "locale_name": "单手剑+0", "type": 1, "sub_type": 0}
+ 19: return {"name": "Sword+9", "locale_name": "单手剑+9", "type": 1, "sub_type": 0}
+ 27001: return {"name": "Red Potion(S)", "locale_name": "小型红药水", "type": 3, "sub_type": 0}
+ 27993: return {"name": "Pearl", "locale_name": "珍珠", "type": 3, "sub_type": 0}
+ _: return {"name": "Item%d" % vnum, "locale_name": "道具%d" % vnum, "type": 3, "sub_type": 0}
+
+class MockMouseController extends Node:
+ var _attached: Dictionary = {}
+ var _targets: Dictionary = {}
+
+ func is_attached() -> bool:
+ return not _attached.is_empty()
+
+ func attached() -> Dictionary:
+ return _attached
+
+ func attach_item(win: int, cell: int, vnum: int, count: int, _tex = null, _owner = null) -> void:
+ _attached = {"window": win, "cell": cell, "vnum": vnum, "count": count}
+
+ func cancel() -> void:
+ _attached.clear()
+
+ func register_owner(_o) -> void:
+ pass
+
+ func unregister_owner(_o) -> void:
+ pass
+
+ func register_target(rect: Control, cb: Callable, _owner = null) -> void:
+ _targets[rect] = cb
+
+ func unregister_target(rect: Control) -> void:
+ _targets.erase(rect)
+
+class MockClient extends Node:
+ signal safebox_opened(size: int)
+ signal safebox_closed()
+ signal safebox_changed()
+ signal safebox_password_required()
+ signal safebox_wrong_password()
+ signal mall_opened(size: int)
+ signal mall_closed()
+ signal mall_changed()
+ signal mall_password_required()
+ signal inventory_changed()
+
+ var _safebox_open := false
+ var _safebox_size := 1
+ var _safebox_gold := 0
+ var _safebox_items := []
+ var _mall_open := false
+ var _mall_size := 45
+ var _mall_items := []
+ var _inventory := []
+ var _char_pos := Vector3.ZERO
+ var _main_vid := 10001
+ var calls: Array[Dictionary] = []
+
+ func get_main_vid() -> int: return _main_vid
+
+ func get_entity(vid: int) -> Dictionary:
+ if vid == _main_vid:
+ return {"vid": vid, "pos": _char_pos}
+ return {}
+
+ func is_safebox_open() -> bool: return _safebox_open
+ func get_safebox_size() -> int: return _safebox_size
+ func get_safebox_gold() -> int: return _safebox_gold
+ func get_safebox_items() -> Array: return _safebox_items
+
+ 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 get_inventory() -> Array: return _inventory
+
+ func safebox_checkin(safe_pos: int, inv_window: int, inv_cell: int) -> bool:
+ calls.append({"method": "safebox_checkin", "args": [safe_pos, inv_window, inv_cell]})
+ return true
+
+ func safebox_checkout(safe_pos: int, inv_window: int, inv_cell: int) -> bool:
+ calls.append({"method": "safebox_checkout", "args": [safe_pos, inv_window, inv_cell]})
+ return true
+
+ func safebox_move(from_cell: int, to_cell: int, count: int) -> bool:
+ calls.append({"method": "safebox_move", "args": [from_cell, to_cell, count]})
+ return true
+
+ func safebox_close() -> bool:
+ _safebox_open = false
+ calls.append({"method": "safebox_close", "cmd": "/safebox_close"})
+ return true
+
+ func mall_checkout(mall_pos: int, inv_window: int, inv_cell: int) -> bool:
+ calls.append({"method": "mall_checkout", "args": [mall_pos, inv_window, inv_cell]})
+ return true
+
+ func mall_close() -> bool:
+ _mall_open = false
+ calls.append({"method": "mall_close", "cmd": "/mall_close"})
+ return true
+
+ func safebox_password(pw: String) -> bool:
+ calls.append({"method": "safebox_password", "cmd": "/safebox_password " + pw})
+ return true
+
+ func safebox_change_password(old_pw: String, new_pw: String) -> bool:
+ calls.append({"method": "safebox_change_password", "cmd": "/safebox_change_password %s %s" % [old_pw, new_pw]})
+ return true
+
+ func mall_password(pw: String) -> bool:
+ calls.append({"method": "mall_password", "cmd": "/mall_password " + pw})
+ return true
+
+func _init() -> void:
+ print("\n=== Running test_safebox_mall_parity (Phase 4 Extension Parity) ===")
+ _run_all()
+ print("\n=======================================================")
+ if _fail == 0:
+ print("=== ALL SAFEBOX & MALL PARITY TESTS PASSED (%d checks) ===" % _pass)
+ quit(0)
+ else:
+ printerr("=== SOME CHECKS FAILED: %d pass, %d fail ===" % [_pass, _fail])
+ quit(1)
+
+func _run_all() -> void:
+ var client := MockClient.new()
+ var proto := MockProto.new()
+ var mouse := MockMouseController.new()
+ var host := Control.new()
+ get_root().add_child(client)
+ get_root().add_child(mouse)
+ get_root().add_child(host)
+
+ # -------------------------------------------------------------
+ # Test 1: Safebox Open/Close, Auto-Open, and 10.0m Auto-Close
+ # -------------------------------------------------------------
+ print("\n--- Test 1: Safebox Open/Close & 10.0m Limit Range (40250 uisafebox.py) ---")
+ var safe := SafeboxUI.new()
+ safe.item_mouse = mouse
+ host.add_child(safe)
+ safe.setup(client, host, proto)
+
+ _ck(not safe.is_open(), "Safebox initially closed")
+ client._char_pos = Vector3(100.0, 50.0, 0.0)
+ client._safebox_open = true
+ client._safebox_size = 3
+ client.safebox_opened.emit(3)
+
+ _ck(safe.is_open(), "Safebox auto-opened on safebox_opened signal")
+ _ck(safe._has_open_pos and safe._open_char_pos == Vector3(100.0, 50.0, 0.0), "Safebox recorded opening character position (100, 50, 0)")
+ _ck(safe._page_buttons.size() == 3, "Safebox has 3 pages configured")
+
+ # Move within 5m
+ client._char_pos = Vector3(104.0, 50.0, 0.0)
+ safe._process(0.016)
+ _ck(safe.is_open(), "Safebox remains open at 4.0m distance")
+
+ # Move beyond 10m
+ client._char_pos = Vector3(112.0, 50.0, 0.0)
+ safe._process(0.016)
+ _ck(not safe.is_open(), "Safebox auto-closed after exceeding 10.0m limit range")
+ var has_safe_close := false
+ for c in client.calls:
+ if c.get("method") == "safebox_close" and c.get("cmd") == "/safebox_close":
+ has_safe_close = true
+ break
+ _ck(has_safe_close, "Safebox auto-close sent /safebox_close chat command to server")
+
+ # Server closes safebox
+ client._safebox_open = true
+ safe.open()
+ _ck(safe.is_open(), "Safebox re-opened")
+ client.calls.clear()
+ client._safebox_open = false
+ client.safebox_closed.emit()
+ _ck(not safe.is_open(), "Safebox closed on safebox_closed server signal")
+ _ck(client.calls.is_empty(), "Safebox closed by server does not send /safebox_close back")
+
+ # -------------------------------------------------------------
+ # Test 2: Mall Open/Close & 10.0m Auto-Close
+ # -------------------------------------------------------------
+ print("\n--- Test 2: Mall Open/Close & 10.0m Limit Range (40250 uisafebox.py MallWindow) ---")
+ var mall := MallUI.new()
+ mall.item_mouse = mouse
+ host.add_child(mall)
+ mall.setup(client, host, proto)
+
+ _ck(not mall.is_open(), "Mall initially closed")
+ client._char_pos = Vector3(200.0, 80.0, 0.0)
+ client._mall_open = true
+ client._mall_size = 45
+ client.mall_opened.emit(45)
+
+ _ck(mall.is_open(), "Mall auto-opened on mall_opened signal")
+ _ck(mall._has_open_pos and mall._open_char_pos == Vector3(200.0, 80.0, 0.0), "Mall recorded opening character position (200, 80, 0)")
+
+ # Move within 6m
+ client._char_pos = Vector3(200.0, 86.0, 0.0)
+ mall._process(0.016)
+ _ck(mall.is_open(), "Mall remains open at 6.0m distance")
+
+ # Move beyond 10m
+ client._char_pos = Vector3(200.0, 95.0, 0.0)
+ client.calls.clear()
+ mall._process(0.016)
+ _ck(not mall.is_open(), "Mall auto-closed after exceeding 10.0m limit range")
+ var has_mall_close := false
+ for c in client.calls:
+ if c.get("method") == "mall_close" and c.get("cmd") == "/mall_close":
+ has_mall_close = true
+ break
+ _ck(has_mall_close, "Mall auto-close sent /mall_close chat command to server")
+
+ # Server closes mall
+ client._mall_open = true
+ mall.open()
+ _ck(mall.is_open(), "Mall re-opened")
+ client.calls.clear()
+ client._mall_open = false
+ client.mall_closed.emit()
+ _ck(not mall.is_open(), "Mall closed on mall_closed server signal")
+
+ # -------------------------------------------------------------
+ # Test 3: Sockets & Attrs Data Preservation
+ # -------------------------------------------------------------
+ print("\n--- Test 3: Sockets & Attrs Data Preservation in Safebox and Mall ---")
+ var rich_item := {
+ "cell": 0,
+ "vnum": 19,
+ "count": 1,
+ "flags": 0,
+ "anti_flags": 0,
+ "sockets": [28430, 28431, 0],
+ "attrs": [
+ {"type": 1, "value": 500},
+ {"type": 5, "value": 12}
+ ]
+ }
+ client._safebox_items = [rich_item]
+ client._mall_items = [rich_item]
+ safe.refresh()
+ mall.refresh()
+
+ var safe_tt := safe._tooltip_for(rich_item)
+ var mall_tt := mall._tooltip_for(rich_item)
+ _ck(safe_tt.contains("500") or safe_tt.contains("HP") or safe_tt.contains("生命"), "Safebox tooltip includes attribute 1 (HP +500)")
+ _ck(safe_tt.contains("12") or safe_tt.contains("STR") or safe_tt.contains("力量"), "Safebox tooltip includes attribute 2 (STR +12)")
+ _ck(mall_tt.contains("500") or mall_tt.contains("HP") or mall_tt.contains("生命"), "Mall tooltip includes attribute 1 (HP +500)")
+ _ck(mall_tt.contains("12") or mall_tt.contains("STR") or mall_tt.contains("力量"), "Mall tooltip includes attribute 2 (STR +12)")
+
+ # -------------------------------------------------------------
+ # Test 4: Safebox Operations (Checkin, Checkout, Move)
+ # -------------------------------------------------------------
+ print("\n--- Test 4: Safebox Operations (40250 Checkin, Checkout, Move) ---")
+ client._safebox_open = true
+ safe.open()
+ client.calls.clear()
+ client._inventory = [{"cell": 5, "vnum": 27001, "count": 20}] # Free slot 0 is empty
+
+ # Drag drop from inventory (win 1, cell 5) into empty safebox slot 8
+ var drop_payload := {"window": 1, "cell": 5, "vnum": 27001, "count": 20}
+ var drop_res := safe._drop_to_slot(drop_payload, 8)
+ _ck(drop_res, "Dropping inventory item into empty Safebox slot succeeds")
+ _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkin" and client.calls[0]["args"] == [8, 1, 5],
+ "Drop sent safebox_checkin(safe_pos=8, inv_win=1, inv_cell=5)")
+
+ # Right-click inventory item -> deposit to next free safebox slot
+ client.calls.clear()
+ client._safebox_items = [{"cell": 0, "vnum": 10, "count": 1}] # Next free is 1
+ safe.deposit(1, 5)
+ _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkin" and client.calls[0]["args"] == [1, 1, 5],
+ "Right-clicking inventory item triggers deposit into next free safebox slot 1")
+
+ # Right-click safebox slot -> checkout to first free inventory slot
+ client.calls.clear()
+ var ev_rc := InputEventMouseButton.new()
+ ev_rc.button_index = MOUSE_BUTTON_RIGHT
+ ev_rc.pressed = true
+ safe._on_cell_input(0, ev_rc)
+ _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_checkout" and client.calls[0]["args"] == [0, 1, 0],
+ "Right-clicking safebox slot 0 triggers checkout to inventory free slot 0")
+
+ # Safebox internal slot move (e.g. from cell 0 to cell 6)
+ client.calls.clear()
+ var safe_move_payload := {"window": 3, "cell": 0, "vnum": 10, "count": 1}
+ var move_res := safe._drop_to_slot(safe_move_payload, 6)
+ _ck(move_res, "Moving item between safebox slots succeeds")
+ _ck(client.calls.size() > 0 and client.calls[0]["method"] == "safebox_move" and client.calls[0]["args"] == [0, 6, 1],
+ "Internal safebox move sent safebox_move(from=0, to=6, count=1)")
+
+ # -------------------------------------------------------------
+ # Test 5: Mall Operations and MALL_CANNOT_INSERT Rejection
+ # -------------------------------------------------------------
+ print("\n--- Test 5: Mall Operations & MALL_CANNOT_INSERT (40250 uisafebox.py:640) ---")
+ client.calls.clear()
+ client._inventory = [{"cell": 2, "vnum": 10, "count": 1}] # Free slot 0 is empty
+ client._mall_items = [{"cell": 4, "vnum": 27993, "count": 5}]
+ mall.refresh()
+
+ # Right-click mall slot 4 -> checkout to inventory free slot 0
+ var ev_mall_rc := InputEventMouseButton.new()
+ ev_mall_rc.button_index = MOUSE_BUTTON_RIGHT
+ ev_mall_rc.pressed = true
+ mall._on_grid_input(4, ev_mall_rc)
+ _ck(client.calls.size() > 0 and client.calls[0]["method"] == "mall_checkout" and client.calls[0]["args"] == [4, 1, 0],
+ "Right-clicking mall slot 4 triggers mall_checkout(mall_pos=4, inv_win=1, inv_cell=0)")
+
+ # Attempt to deposit inventory item into mall slot -> rejected
+ client.calls.clear()
+ mouse.attach_item(1, 2, 10, 1)
+ var ev_mall_drop := InputEventMouseButton.new()
+ ev_mall_drop.button_index = MOUSE_BUTTON_LEFT
+ ev_mall_drop.pressed = true
+ mall._on_grid_input(7, ev_mall_drop)
+ _ck(not mouse.is_attached(), "Dragging item into Mall cancels attached mouse item")
+ _ck(mall._status.text == "无法将物品放入商城仓库!", "Mall rejects insertion with exact 40250 warning (MALL_CANNOT_INSERT)")
+ _ck(client.calls.is_empty(), "No packet sent on rejected mall deposit attempt")
+
+ # Attempt drop via _drop_to_slot
+ var mall_drop_res := mall._drop_to_slot({"window": 1, "cell": 2, "vnum": 10, "count": 1}, 7)
+ _ck(not mall_drop_res, "mall._drop_to_slot strictly returns false")
+
+ # -------------------------------------------------------------
+ # Test 6: Password Protocols and Error Notifications
+ # -------------------------------------------------------------
+ print("\n--- Test 6: Password Protocols & Wrong Password (40250 /safebox_password) ---")
+ client.calls.clear()
+ client.safebox_password("000000")
+ _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/safebox_password 000000",
+ "Safebox password sends /safebox_password 000000")
+
+ client.calls.clear()
+ client.safebox_change_password("000000", "123456")
+ _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/safebox_change_password 000000 123456",
+ "Safebox change password sends /safebox_change_password 000000 123456")
+
+ client.calls.clear()
+ client.mall_password("888888")
+ _ck(client.calls.size() > 0 and client.calls[0]["cmd"] == "/mall_password 888888",
+ "Mall password sends /mall_password 888888")
+
+ client.safebox_wrong_password.emit()
+ _ck(safe._status.text == "仓库密码错误", "safebox_wrong_password signal shows 仓库密码错误")
+
+ # Clean up
+ proto.queue_free()
+ safe.queue_free()
+ mall.queue_free()
+ client.queue_free()
+ mouse.queue_free()
+ host.queue_free()
diff --git a/project/test_safebox_mall_parity.gd.uid b/project/test_safebox_mall_parity.gd.uid
new file mode 100644
index 00000000..34c238ed
--- /dev/null
+++ b/project/test_safebox_mall_parity.gd.uid
@@ -0,0 +1 @@
+uid://e15w8667482o
diff --git a/project/test_safebox_storage_keeper_parity.gd b/project/test_safebox_storage_keeper_parity.gd
new file mode 100644
index 00000000..7e8316e6
--- /dev/null
+++ b/project/test_safebox_storage_keeper_parity.gd
@@ -0,0 +1,165 @@
+# test_safebox_storage_keeper_parity.gd —— 40250 仓库保管员金条熔铸与密码保险箱系统回归测试
+extends SceneTree
+
+const SystemClass = preload("res://safebox_storage_keeper_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_safebox_storage_keeper_parity (Batch 26 Direction 2) ===")
+ _test_password_and_open_checks()
+ _test_password_change()
+ _test_safebox_storage_operations()
+ _test_gold_bar_smelting_and_encashment()
+ _test_safebox_edge_cases()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_password_and_open_checks() -> void:
+ print("\n--- Test 1: Safebox Password Authentication & Cooldown/Distance ---")
+ var box = SystemClass.new()
+
+ # 1. Distance > 1000 fails
+ var r_dist = box.open_safebox("000000", 1500.0)
+ _assert(r_dist["error_code"] == "TOO_FAR", "Reject opening safebox if distance > 1000")
+
+ # 2. Wrong password fails
+ var r_wrong = box.open_safebox("123456", 200.0)
+ _assert(r_wrong["error_code"] == "WRONG_PASSWORD", "Reject opening safebox with wrong password")
+ _assert(box.is_open == false, "Safebox remains closed on wrong password")
+
+ # 3. Default password "000000" succeeds
+ var r_ok = box.open_safebox("000000", 200.0)
+ _assert(r_ok["success"] == true, "Default password 000000 opens safebox")
+ _assert(box.is_open == true, "Safebox is open")
+
+ # 4. Opening within 10s fails with COOLDOWN_ACTIVE
+ box.close_safebox()
+ box.current_time = 1005 # only 5 seconds later
+ var r_cd = box.open_safebox("000000", 200.0)
+ _assert(r_cd["error_code"] == "COOLDOWN_ACTIVE", "Reject opening safebox within 10 seconds")
+
+ # 5. Opening after 10s succeeds
+ box.current_time = 1015
+ var r_cd_ok = box.open_safebox("000000", 200.0)
+ _assert(r_cd_ok["success"] == true, "Open safebox succeeds after cooldown expires")
+
+func _test_password_change() -> void:
+ print("\n--- Test 2: Password Change Verification ---")
+ var box = SystemClass.new()
+ box.open_safebox("000000", 200.0)
+
+ # Old password mismatch
+ var r_fail = box.change_password("999999", "654321")
+ _assert(r_fail["error_code"] == "OLD_PASSWORD_MISMATCH", "Reject password change if old password mismatches")
+
+ # Change password successfully
+ var r_succ = box.change_password("000000", "888888")
+ _assert(r_succ["success"] == true, "Password successfully changed to 888888")
+ _assert(box.password == "888888", "Internal password updated")
+
+ # Re-open with new password
+ box.close_safebox()
+ box.current_time += 20
+ var r_new_open = box.open_safebox("888888", 200.0)
+ _assert(r_new_open["success"] == true, "Can open safebox with new password")
+
+func _test_safebox_storage_operations() -> void:
+ print("\n--- Test 3: Storage Deposit, Withdraw & Stacking (45 Slots) ---")
+ var box = SystemClass.new()
+ box.open_safebox("000000", 200.0)
+
+ var sword = { "vnum": 10, "name": "Sword+0", "count": 1, "stackable": false }
+ var potion1 = { "vnum": 27001, "name": "Red Potion (S)", "count": 50, "stackable": true }
+ var potion2 = { "vnum": 27001, "name": "Red Potion (S)", "count": 25, "stackable": true }
+
+ # Deposit sword into slot 0
+ var dep1 = box.deposit_item(0, sword)
+ _assert(dep1["success"] == true, "Sword deposited into slot 0")
+ _assert(box.safebox_grid[0]["vnum"] == 10, "Slot 0 holds Sword+0")
+
+ # Deposit potion into slot 1
+ var dep2 = box.deposit_item(1, potion1)
+ _assert(dep2["success"] == true, "Potion deposited into slot 1")
+
+ # Stacking into slot 1
+ var dep3 = box.deposit_item(1, potion2)
+ _assert(dep3["success"] == true, "Potion stacked into slot 1")
+ _assert(box.safebox_grid[1]["count"] == 75, "Slot 1 potion count updated to 75")
+
+ # Moving item from slot 0 to slot 5
+ var move_ok = box.move_safebox_item(0, 5)
+ _assert(move_ok == true, "Moved item from slot 0 to slot 5")
+ _assert(box.safebox_grid[0] == null, "Slot 0 is now empty")
+ _assert(box.safebox_grid[5]["vnum"] == 10, "Slot 5 now holds Sword+0")
+
+ # Withdraw item from slot 5
+ var wd = box.withdraw_item(5)
+ _assert(wd["success"] == true, "Withdrew item from slot 5")
+ _assert(wd["item"]["vnum"] == 10, "Withdrawn item is Sword+0")
+ _assert(box.safebox_grid[5] == null, "Slot 5 is now empty")
+
+func _test_gold_bar_smelting_and_encashment() -> void:
+ print("\n--- Test 4: Official 1% Smelting Tax & Encashment (80003~80007) ---")
+ var box = SystemClass.new()
+
+ # 1. Smelt 50k bar (80003): cost = 50500 (1% tax)
+ var player_gold := 100000
+ var s1 = box.smelt_gold_bar(player_gold, 80003)
+ _assert(s1["success"] == true, "Smelted 50k silver bar")
+ _assert(s1["cost"] == 50500, "Cost is exactly 50,500 (1% fee)")
+ _assert(s1["new_gold"] == 49500, "Player gold reduced to 49,500")
+ _assert(s1["bar_item"]["face_value"] == 50000, "Bar face value is 50,000")
+
+ # 2. Smelt 2M bar (80007): cost = 2,020,000 (1% tax)
+ player_gold = 3000000
+ var s2 = box.smelt_gold_bar(player_gold, 80007)
+ _assert(s2["success"] == true, "Smelted 2M gold bar")
+ _assert(s2["cost"] == 2020000, "Cost is exactly 2,020,000")
+ _assert(s2["new_gold"] == 980000, "Player gold reduced to 980,000")
+
+ # 3. Insufficient gold rejected
+ var s_fail = box.smelt_gold_bar(1000, 80003)
+ _assert(s_fail["error_code"] == "INSUFFICIENT_GOLD", "Reject smelting when gold < cost")
+
+ # 4. Encash 2M bar (80007) back to cash: grants 2,000,000 face value
+ var c1 = box.encash_gold_bar(500000, 80007)
+ _assert(c1["success"] == true, "Encash 2M gold bar")
+ _assert(c1["cash_value"] == 2000000, "Cash value is 2,000,000")
+ _assert(c1["new_gold"] == 2500000, "Player gold increased to 2,500,000")
+
+func _test_safebox_edge_cases() -> void:
+ print("\n--- Test 5: Closed Safebox Operations & Invalid Slots ---")
+ var box = SystemClass.new()
+
+ # Deposit while closed
+ var dep_closed = box.deposit_item(0, { "vnum": 1, "count": 1 })
+ _assert(dep_closed["error_code"] == "NOT_OPEN", "Reject deposit when safebox is closed")
+
+ # Withdraw while closed
+ var wd_closed = box.withdraw_item(0)
+ _assert(wd_closed["error_code"] == "NOT_OPEN", "Reject withdraw when safebox is closed")
+
+ # Open safebox
+ box.open_safebox("000000", 200.0)
+
+ # Invalid slots (< 0 or >= 45)
+ var dep_inv = box.deposit_item(45, { "vnum": 1, "count": 1 })
+ _assert(dep_inv["error_code"] == "INVALID_SLOT", "Reject deposit into slot 45 (max 44)")
+
+ var wd_inv = box.withdraw_item(-1)
+ _assert(wd_inv["error_code"] == "INVALID_SLOT", "Reject withdraw from slot -1")
diff --git a/project/test_safebox_storage_keeper_parity.gd.uid b/project/test_safebox_storage_keeper_parity.gd.uid
new file mode 100644
index 00000000..19c22265
--- /dev/null
+++ b/project/test_safebox_storage_keeper_parity.gd.uid
@@ -0,0 +1 @@
+uid://8lpx6mgvi0dh
diff --git a/project/test_save_snapshot_parity.gd b/project/test_save_snapshot_parity.gd
new file mode 100644
index 00000000..99c2a4da
--- /dev/null
+++ b/project/test_save_snapshot_parity.gd
@@ -0,0 +1,248 @@
+# test_save_snapshot_parity.gd —— 40250 角色全量快照存读档系统 1:1 回归测试套件
+extends SceneTree
+
+const SaveSnapshotSystem = preload("res://save_snapshot_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_save_snapshot_parity (Direction 4: Save Snapshot System 1:1) ===")
+ _test_snapshot_roundtrip()
+ _test_disk_save_load_delete()
+ _test_slot_summaries()
+ _test_validation_and_errors()
+ _finish()
+
+func _create_sample_player() -> Dictionary:
+ var inv := []
+ inv.resize(90)
+ for i in range(90):
+ inv[i] = null
+ inv[0] = {
+ "vnum": 10,
+ "name": "巨剑+9",
+ "count": 1,
+ "sockets": [28430, 28431, 28432],
+ "attrs": [[1, 2000], [5, 10]]
+ }
+ inv[1] = {
+ "vnum": 27003,
+ "name": "红色药水(特大)",
+ "count": 200
+ }
+
+ var belt_inv := []
+ belt_inv.resize(16)
+ for i in range(16):
+ belt_inv[i] = null
+ belt_inv[0] = {
+ "vnum": 27102,
+ "name": "绿色药水(大)",
+ "count": 50
+ }
+
+ return {
+ "name": "剑圣东方",
+ "job": 0, # 战士
+ "level": 75,
+ "exp": 1500000,
+ "hp": 12500,
+ "max_hp": 12500,
+ "sp": 3200,
+ "max_sp": 3200,
+ "stamina": 1000,
+ "alignment": 12000, # 骑士精神
+ "gold": 98765432,
+ "str": 90,
+ "dex": 70,
+ "con": 90,
+ "int_stat": 30,
+ "stat_points": 5,
+ "skill_points": 2,
+ "pos": Vector3(456.7, 12.3, 789.0),
+ "map_name": "sohan",
+ "playtime": 7200,
+ "equipment": {
+ 0: {"vnum": 189, "name": "毒剑+9", "refine": 9},
+ 1: {"vnum": 11299, "name": "黑铁战甲+9", "refine": 9},
+ 11: {"vnum": 18009, "name": "灵魂腰带+9", "refine_level": 9}
+ },
+ "inventory": inv,
+ "belt_item": {"vnum": 18009, "name": "灵魂腰带+9", "refine_level": 9},
+ "belt_inventory": belt_inv,
+ "costumes": {
+ 0: {"vnum": 41001, "name": "沙漠武士服(男)", "costume_type": "body", "remaining_time": 500000},
+ 1: {"vnum": 45001, "name": "武士发型", "costume_type": "hair", "remaining_time": 500000}
+ },
+ "skills": {
+ 1: {"name": "三连斩", "level": 40, "master_type": 3}, # P
+ 2: {"name": "剑气", "level": 40, "master_type": 3}
+ },
+ "alchemy": {
+ 0: {"vnum": 111001, "name": "巨龙钻石", "grade": 5}
+ },
+ "monster_cards": [101, 102, 103, 1093],
+ "achievements": ["FIRST_METIN_KILL", "TOWER_FLOOR_9"],
+ "active_title": "屠龙尊者"
+ }
+
+func _test_snapshot_roundtrip() -> void:
+ print("\n--- Test 1: In-Memory Snapshot Roundtrip & Full Schema Parity ---")
+ var sys := SaveSnapshotSystem.new()
+ var original := _create_sample_player()
+
+ var snapshot := sys.create_snapshot(original, 1)
+ _assert(snapshot.has("meta") and snapshot.has("stats") and snapshot.has("inventory"), "Snapshot contains main root keys")
+ _assert(snapshot["meta"]["schema_version"] == SaveSnapshotSystem.SCHEMA_VERSION, "Schema version is 40250.1")
+ _assert(snapshot["summary"]["name"] == "剑圣东方", "Summary name matches")
+ _assert(snapshot["summary"]["job_name"] == "战士", "Summary job translated to 战士")
+ _assert(snapshot["inventory"].size() == 90, "Inventory has exact 90 slots")
+ _assert(snapshot["belt_system"]["belt_inventory"].size() == 16, "Belt inventory has exact 16 slots")
+
+ # Restore
+ var restore_res := sys.restore_snapshot(snapshot)
+ _assert(restore_res["ok"] == true, "Restore succeeded")
+
+ var restored: Dictionary = restore_res["player_data"]
+ _assert(restored["name"] == original["name"], "Player name preserved")
+ _assert(restored["level"] == original["level"], "Level preserved (75)")
+ _assert(restored["gold"] == original["gold"], "Gold preserved (98765432)")
+ _assert(restored["alignment"] == original["alignment"], "Alignment preserved (12000)")
+ _assert(restored["pos"] is Vector3 and restored["pos"].is_equal_approx(Vector3(456.7, 12.3, 789.0)), "Vector3 position accurately restored")
+
+ # Inventory restoration
+ _assert(restored["inventory"].size() == 90, "Restored inventory size is 90")
+ _assert(restored["inventory"][0]["vnum"] == 10, "Slot 0 item vnum 10 preserved")
+ _assert(restored["inventory"][0]["sockets"] == [28430, 28431, 28432], "Sockets preserved")
+ _assert(restored["inventory"][1]["count"] == 200, "Potion count 200 preserved")
+ _assert(restored["inventory"][2] == null, "Empty inventory slots remain null")
+
+ # Equipment restoration
+ _assert(restored["equipment"].has(0) and restored["equipment"][0]["name"] == "毒剑+9", "Equipment slot 0 (WEAPON) preserved")
+ _assert(restored["equipment"].has(1) and restored["equipment"][1]["name"] == "黑铁战甲+9", "Equipment slot 1 (ARMOR) preserved")
+
+ # Belt, Costumes, Progression
+ _assert(restored["belt_inventory"][0]["vnum"] == 27102, "Belt potion in slot 0 preserved")
+ _assert(restored["costumes"].has(0) and restored["costumes"][0]["vnum"] == 41001, "Costume body preserved")
+ _assert(restored["skills"].has(1) and restored["skills"][1]["level"] == 40, "Skill P level preserved")
+ _assert(restored["monster_cards"].size() == 4, "Monster card collection preserved")
+ _assert(restored["active_title"] == "屠龙尊者", "Active title preserved")
+
+func _test_disk_save_load_delete() -> void:
+ print("\n--- Test 2: Disk IO - Save, Load, and Delete Slots ---")
+ var sys := SaveSnapshotSystem.new()
+ var test_dir := OS.get_cache_dir().path_join("metin2_snapshot_test_%d" % int(Time.get_unix_time_from_system()))
+ var player := _create_sample_player()
+
+ var save_signal := {"fired": false, "slot": 0, "path": ""}
+ sys.snapshot_saved.connect(func(s: int, p: String):
+ save_signal["fired"] = true
+ save_signal["slot"] = s
+ save_signal["path"] = p
+ )
+
+ var load_signal := {"fired": false, "slot": 0, "path": ""}
+ sys.snapshot_loaded.connect(func(s: int, p: String):
+ load_signal["fired"] = true
+ load_signal["slot"] = s
+ load_signal["path"] = p
+ )
+
+ var delete_signal := {"fired": false, "slot": 0}
+ sys.snapshot_deleted.connect(func(s: int):
+ delete_signal["fired"] = true
+ delete_signal["slot"] = s
+ )
+
+ # Save to slot 1
+ var save_res = sys.save_slot(1, player, test_dir)
+ _assert(save_res["ok"] == true, "Save slot 1 returned ok")
+ _assert(save_signal["fired"] == true and save_signal["slot"] == 1, "snapshot_saved signal fired")
+ _assert(FileAccess.file_exists(save_res["path"]), "Save file exists on disk")
+
+ # Load from slot 1
+ var load_res = sys.load_slot(1, test_dir)
+ _assert(load_res["ok"] == true, "Load slot 1 returned ok")
+ _assert(load_signal["fired"] == true and load_signal["slot"] == 1, "snapshot_loaded signal fired")
+ _assert(load_res["player_data"]["name"] == "剑圣东方", "Loaded player name matches")
+ _assert(load_res["player_data"]["gold"] == 98765432, "Loaded player gold matches")
+
+ # Delete slot 1
+ var del_res = sys.delete_slot(1, test_dir)
+ _assert(del_res["ok"] == true, "Delete slot 1 returned ok")
+ _assert(delete_signal["fired"] == true and delete_signal["slot"] == 1, "snapshot_deleted signal fired")
+ _assert(FileAccess.file_exists(save_res["path"]) == false, "Save file removed from disk")
+
+ # Loading after deletion should fail gracefully
+ var reload_fail = sys.load_slot(1, test_dir)
+ _assert(reload_fail["ok"] == false and reload_fail["reason"] == "FILE_NOT_FOUND", "Loading deleted slot fails with FILE_NOT_FOUND")
+
+ # Clean up directory
+ DirAccess.remove_absolute(test_dir)
+
+func _test_slot_summaries() -> void:
+ print("\n--- Test 3: Fast Multi-Slot Summaries ---")
+ var sys := SaveSnapshotSystem.new()
+ var test_dir := OS.get_cache_dir().path_join("metin2_summary_test_%d" % int(Time.get_unix_time_from_system()))
+ var player1 := _create_sample_player()
+ var player2 := _create_sample_player()
+ player2["name"] = "幻影刺客"
+ player2["job"] = 1 # 刺客
+ player2["level"] = 60
+
+ # Save slot 1 and slot 3 (leave slot 2, 4, 5 empty)
+ sys.save_slot(1, player1, test_dir)
+ sys.save_slot(3, player2, test_dir)
+
+ var summaries := sys.get_slot_summaries(test_dir, 5)
+ _assert(summaries.size() == 5, "Summaries has 5 entries")
+ _assert(summaries[0]["is_empty"] == false and summaries[0]["name"] == "剑圣东方" and summaries[0]["level"] == 75, "Slot 1 summary accurate")
+ _assert(summaries[1]["is_empty"] == true, "Slot 2 summary shows is_empty == true")
+ _assert(summaries[2]["is_empty"] == false and summaries[2]["name"] == "幻影刺客" and summaries[2]["job_name"] == "刺客", "Slot 3 summary accurate")
+ _assert(summaries[3]["is_empty"] == true, "Slot 4 summary is_empty")
+ _assert(summaries[4]["is_empty"] == true, "Slot 5 summary is_empty")
+
+ # Clean up
+ sys.delete_slot(1, test_dir)
+ sys.delete_slot(3, test_dir)
+ DirAccess.remove_absolute(test_dir)
+
+func _test_validation_and_errors() -> void:
+ print("\n--- Test 4: Validation & Error Handling ---")
+ var sys := SaveSnapshotSystem.new()
+
+ var invalid_meta := sys.validate_snapshot({"foo": "bar"})
+ _assert(invalid_meta["ok"] == false and invalid_meta["reason"] == "MISSING_META", "Missing meta rejected")
+
+ var bad_version := sys.validate_snapshot({"meta": {"schema_version": "999.0"}})
+ _assert(bad_version["ok"] == false and bad_version["reason"] == "VERSION_MISMATCH", "Version mismatch rejected")
+
+ var bad_json_res := sys.import_json("This is not JSON!")
+ _assert(bad_json_res["ok"] == false and bad_json_res["reason"] == "INVALID_JSON_ROOT", "Invalid JSON string rejected")
+
+ var player := _create_sample_player()
+ var json_str := sys.export_json(player)
+ _assert(json_str.length() > 0, "export_json produces non-empty JSON")
+ var parsed_res := sys.import_json(json_str)
+ _assert(parsed_res["ok"] == true and parsed_res["player_data"]["name"] == "剑圣东方", "import_json parses valid JSON accurately")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Save Snapshot Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Save Snapshot parity tests passed!")
+ quit(0)
diff --git a/project/test_save_snapshot_parity.gd.uid b/project/test_save_snapshot_parity.gd.uid
new file mode 100644
index 00000000..4136ae01
--- /dev/null
+++ b/project/test_save_snapshot_parity.gd.uid
@@ -0,0 +1 @@
+uid://b1c6dqivctw7p
diff --git a/project/test_security_lock_parity.gd b/project/test_security_lock_parity.gd
new file mode 100644
index 00000000..79a7317c
--- /dev/null
+++ b/project/test_security_lock_parity.gd
@@ -0,0 +1,219 @@
+# test_security_lock_parity.gd —— 40250 仓库安全密码与装备防丢锁定 1:1 纯净回归测试套件
+extends SceneTree
+
+const SecurityLockSystem = preload("res://security_lock_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_security_lock_parity (Direction 4: Security Lock System 1:1) ===")
+ _test_safebox_password_auth()
+ _test_safebox_password_change()
+ _test_item_security_lock_and_unlock()
+ _test_item_action_gates()
+ _test_character_deletion_code()
+ _finish()
+
+func _test_safebox_password_auth() -> void:
+ print("\n--- Test 1: Safebox Password Authentication & Lockout ---")
+ var sec := SecurityLockSystem.new()
+ var unlock_data := {"fired": false}
+ sec.safebox_unlocked.connect(func(): unlock_data["fired"] = true)
+
+ _assert(sec.safebox_password == "000000", "Default safebox password is '000000'")
+ _assert(sec.is_safebox_unlocked == false, "Safebox initially closed")
+
+ # Wrong password 1st time
+ var res1 = sec.open_safebox("111111")
+ _assert(res1["ok"] == false and res1["reason"] == "WRONG_PASSWORD", "Wrong password fails")
+ _assert(res1["remaining_attempts"] == 4, "4 attempts remaining")
+
+ # Fail 4 more times to reach 5
+ sec.open_safebox("111111")
+ sec.open_safebox("111111")
+ sec.open_safebox("111111")
+ var res5 = sec.open_safebox("111111")
+ _assert(res5["ok"] == false and res5["reason"] == "WRONG_PASSWORD_LOCKOUT", "5th failure triggers lockout")
+ _assert(sec.is_locked_out == true, "is_locked_out is true")
+
+ # Blocked while locked out even with correct password
+ var locked_res = sec.open_safebox("000000")
+ _assert(locked_res["ok"] == false and locked_res["reason"] == "LOCKOUT", "Action blocked while locked out")
+
+ # Reset lockout
+ sec.reset_lockout()
+ _assert(sec.is_locked_out == false and sec.failed_attempts == 0, "Lockout reset successfully")
+
+ # Now correct password opens safebox
+ var ok_res = sec.open_safebox("000000")
+ _assert(ok_res["ok"] == true, "Correct password opened safebox")
+ _assert(sec.is_safebox_unlocked == true, "Safebox is unlocked")
+ _assert(unlock_data["fired"] == true, "safebox_unlocked signal fired")
+
+ # Close safebox
+ var close_data := {"fired": false}
+ sec.safebox_closed.connect(func(): close_data["fired"] = true)
+ sec.close_safebox()
+ _assert(sec.is_safebox_unlocked == false, "Safebox closed")
+ _assert(close_data["fired"] == true, "safebox_closed signal fired")
+
+func _test_safebox_password_change() -> void:
+ print("\n--- Test 2: Password Change Validation (Strict 6 Digits) ---")
+ var sec := SecurityLockSystem.new()
+
+ # Wrong old password
+ var r1 = sec.change_safebox_password("999999", "123456")
+ _assert(r1["ok"] == false and r1["reason"] == "WRONG_OLD_PASSWORD", "Wrong old password rejected")
+
+ # Invalid format: 5 digits
+ var r2 = sec.change_safebox_password("000000", "12345")
+ _assert(r2["ok"] == false and r2["reason"] == "INVALID_PASSWORD_FORMAT", "5-digit password rejected")
+
+ # Invalid format: 7 digits
+ var r3 = sec.change_safebox_password("000000", "1234567")
+ _assert(r3["ok"] == false and r3["reason"] == "INVALID_PASSWORD_FORMAT", "7-digit password rejected")
+
+ # Invalid format: Contains non-digits
+ var r4 = sec.change_safebox_password("000000", "12a456")
+ _assert(r4["ok"] == false and r4["reason"] == "INVALID_PASSWORD_FORMAT", "Alphanumeric password rejected")
+
+ # Same password
+ var r5 = sec.change_safebox_password("000000", "000000")
+ _assert(r5["ok"] == false and r5["reason"] == "SAME_PASSWORD", "Identical new password rejected")
+
+ # Valid change to "888888"
+ var pwd_data := {"fired": false, "new_pwd": ""}
+ sec.password_changed.connect(func(np: String):
+ pwd_data["fired"] = true
+ pwd_data["new_pwd"] = np
+ )
+
+ var r6 = sec.change_safebox_password("000000", "888888")
+ _assert(r6["ok"] == true, "Valid 6-digit password changed successfully")
+ _assert(sec.safebox_password == "888888", "Active password updated to '888888'")
+ _assert(pwd_data["fired"] == true and pwd_data["new_pwd"] == "888888", "password_changed signal fired")
+
+ # Verify old password no longer works
+ var old_try = sec.open_safebox("000000")
+ _assert(old_try["ok"] == false, "Old password '000000' no longer valid")
+
+ # Verify new password works
+ var new_try = sec.open_safebox("888888")
+ _assert(new_try["ok"] == true, "New password '888888' valid")
+
+func _test_item_security_lock_and_unlock() -> void:
+ print("\n--- Test 3: Item Anti-Theft Lock & Unlock ---")
+ var sec := SecurityLockSystem.new()
+ var test_item: Dictionary = {
+ "vnum": 169,
+ "name": "战神之剑+9",
+ "count": 1
+ }
+
+ var lock_signal := {"fired": false, "name": ""}
+ sec.item_locked.connect(func(n: String):
+ lock_signal["fired"] = true
+ lock_signal["name"] = n
+ )
+
+ var unlock_signal := {"fired": false, "name": ""}
+ sec.item_unlocked.connect(func(n: String):
+ unlock_signal["fired"] = true
+ unlock_signal["name"] = n
+ )
+
+ # Lock with wrong password
+ var r1 = sec.lock_item(test_item, "999999")
+ _assert(r1["ok"] == false and r1["reason"] == "WRONG_SECURITY_PASSWORD", "Wrong password prevents locking")
+
+ # Lock with correct password
+ var r2 = sec.lock_item(test_item, "000000")
+ _assert(r2["ok"] == true, "Item locked successfully")
+ _assert(test_item["is_locked"] == true, "Item is_locked flag is true")
+ _assert(lock_signal["fired"] == true and lock_signal["name"] == "战神之剑+9", "item_locked signal fired")
+
+ # Attempt to lock already locked item
+ var r3 = sec.lock_item(test_item, "000000")
+ _assert(r3["ok"] == false and r3["reason"] == "ALREADY_LOCKED", "Already locked item rejected")
+
+ # Unlock with wrong password
+ var r4 = sec.unlock_item(test_item, "999999")
+ _assert(r4["ok"] == false and r4["reason"] == "WRONG_SECURITY_PASSWORD", "Wrong password prevents unlocking")
+
+ # Unlock with correct password
+ var r5 = sec.unlock_item(test_item, "000000")
+ _assert(r5["ok"] == true, "Item unlocked successfully")
+ _assert(test_item["is_locked"] == false, "Item is_locked flag is false")
+ _assert(unlock_signal["fired"] == true and unlock_signal["name"] == "战神之剑+9", "item_unlocked signal fired")
+
+ # Attempt to unlock unlocked item
+ var r6 = sec.unlock_item(test_item, "000000")
+ _assert(r6["ok"] == false and r6["reason"] == "NOT_LOCKED", "Already unlocked item rejected")
+
+func _test_item_action_gates() -> void:
+ print("\n--- Test 4: Action Protection Gates (Drop, Sell, Destroy, Trade, Refine) ---")
+ var sec := SecurityLockSystem.new()
+ var normal_item: Dictionary = {"vnum": 10, "name": "木剑+0", "is_locked": false}
+ var locked_item: Dictionary = {"vnum": 11299, "name": "黑钢甲+9", "is_locked": true}
+
+ # Normal item allowed everywhere
+ _assert(sec.can_drop_item(normal_item)["allowed"] == true, "Normal item can be dropped")
+ _assert(sec.can_sell_item(normal_item)["allowed"] == true, "Normal item can be sold")
+ _assert(sec.can_destroy_item(normal_item)["allowed"] == true, "Normal item can be destroyed")
+ _assert(sec.can_trade_item(normal_item)["allowed"] == true, "Normal item can be traded")
+ _assert(sec.can_refine_item(normal_item)["allowed"] == true, "Normal item can be refined")
+
+ # Locked item blocked everywhere with ITEM_LOCKED
+ var drop_gate = sec.can_drop_item(locked_item)
+ _assert(drop_gate["allowed"] == false and drop_gate["reason"] == "ITEM_LOCKED", "Locked item DROP blocked")
+
+ var sell_gate = sec.can_sell_item(locked_item)
+ _assert(sell_gate["allowed"] == false and sell_gate["reason"] == "ITEM_LOCKED", "Locked item SELL blocked")
+
+ var destroy_gate = sec.can_destroy_item(locked_item)
+ _assert(destroy_gate["allowed"] == false and destroy_gate["reason"] == "ITEM_LOCKED", "Locked item DESTROY blocked")
+
+ var trade_gate = sec.can_trade_item(locked_item)
+ _assert(trade_gate["allowed"] == false and trade_gate["reason"] == "ITEM_LOCKED", "Locked item TRADE blocked")
+
+ var refine_gate = sec.can_refine_item(locked_item)
+ _assert(refine_gate["allowed"] == false and refine_gate["reason"] == "ITEM_LOCKED", "Locked item REFINE blocked")
+
+func _test_character_deletion_code() -> void:
+ print("\n--- Test 5: 7-Digit Character Deletion Security Code ---")
+ var sec := SecurityLockSystem.new()
+ _assert(sec.char_delete_code == "1234567", "Default 7-digit deletion code is '1234567'")
+
+ # Invalid length: 6 digits
+ var r1 = sec.verify_character_deletion("123456")
+ _assert(r1["ok"] == false and r1["reason"] == "INVALID_CODE_FORMAT", "6-digit deletion code rejected")
+
+ # Invalid format: non-numeric
+ var r2 = sec.verify_character_deletion("123456a")
+ _assert(r2["ok"] == false and r2["reason"] == "INVALID_CODE_FORMAT", "Alphanumeric deletion code rejected")
+
+ # Wrong 7-digit code
+ var r3 = sec.verify_character_deletion("7654321")
+ _assert(r3["ok"] == false and r3["reason"] == "WRONG_DELETE_CODE", "Wrong deletion code rejected")
+
+ # Correct 7-digit code
+ var r4 = sec.verify_character_deletion("1234567")
+ _assert(r4["ok"] == true, "Correct 7-digit code authorizes deletion")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Security Lock Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_security_lock_parity.gd.uid b/project/test_security_lock_parity.gd.uid
new file mode 100644
index 00000000..2ef17df5
--- /dev/null
+++ b/project/test_security_lock_parity.gd.uid
@@ -0,0 +1 @@
+uid://tlmw0ks337om
diff --git a/project/test_skill_book_parity.gd b/project/test_skill_book_parity.gd
new file mode 100644
index 00000000..73eb2f99
--- /dev/null
+++ b/project/test_skill_book_parity.gd
@@ -0,0 +1,117 @@
+# test_skill_book_parity.gd —— 技能书研读与进阶突破系统 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_skill_book_parity.gd
+extends SceneTree
+
+const SkillBookSystem = preload("res://skill_book_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_skill_book_parity (Direction 2: Skill Books & M->G->P 1:1) ===")
+ _test_need_books_formula()
+ _test_preconditions()
+ _test_reading_flow_and_scrolls()
+ _test_breakthrough_to_grandmaster()
+ _test_soul_stone_training()
+ _finish()
+
+func _test_need_books_formula() -> void:
+ print("\n--- Test 1: 40250 Need Books Formula (need_bookcount = level - 20) ---")
+ _assert(SkillBookSystem.get_need_books(21) == 1, "M1 (Lv 21) requires 1 book")
+ _assert(SkillBookSystem.get_need_books(22) == 2, "M2 (Lv 22) requires 2 books")
+ _assert(SkillBookSystem.get_need_books(25) == 5, "M5 (Lv 25) requires 5 books")
+ _assert(SkillBookSystem.get_need_books(30) == 10, "M10 (Lv 30) requires 10 books")
+
+func _test_preconditions() -> void:
+ print("\n--- Test 2: Preconditions (EXP, Tier, Cooldown) ---")
+ var now := 100000
+
+ # 1. Reject if normal tier (< 20)
+ var r1 := SkillBookSystem.read_skill_book(1, 15, SkillBookSystem.MASTER_NORMAL, 0, 50000, 0, now, false, false)
+ _assert(r1["result"] == SkillBookSystem.Result.ERR_NOT_MASTER_TIER, "Reject normal tier (< 20) skill")
+
+ # 2. Reject if EXP < 20,000
+ var r2 := SkillBookSystem.read_skill_book(1, 21, SkillBookSystem.MASTER_M, 0, 19999, 0, now, false, false)
+ _assert(r2["result"] == SkillBookSystem.Result.ERR_INSUFFICIENT_EXP, "Reject if player EXP < 20,000")
+
+ # 3. Reject if on cooldown
+ var r3 := SkillBookSystem.read_skill_book(1, 21, SkillBookSystem.MASTER_M, 0, 50000, now + 3600, now, false, false)
+ _assert(r3["result"] == SkillBookSystem.Result.ERR_COOLDOWN, "Reject if on 24h cooldown")
+
+ # 4. Exorcism scroll (71001) bypasses cooldown
+ var r4 := SkillBookSystem.read_skill_book(1, 21, SkillBookSystem.MASTER_M, 0, 50000, now + 3600, now, true, false, 90)
+ _assert(r4["result"] == SkillBookSystem.Result.SUCCESS_LEVEL_UP, "Exorcism scroll bypasses cooldown")
+ _assert(r4["consumed_exorcism"] == true, "Exorcism scroll consumed")
+
+ # 5. Reject if already Grandmaster (G1)
+ var r5 := SkillBookSystem.read_skill_book(1, 31, SkillBookSystem.MASTER_G, 0, 50000, 0, now, false, false)
+ _assert(r5["result"] == SkillBookSystem.Result.ERR_ALREADY_G_OR_P, "Reject if already Grandmaster (G1)")
+
+func _test_reading_flow_and_scrolls() -> void:
+ print("\n--- Test 3: Reading Probabilities & Hermit's Advice (71094) ---")
+ var now := 100000
+
+ # Normal roll 50 (<= 65) -> Failure
+ var fail_res := SkillBookSystem.read_skill_book(1, 22, SkillBookSystem.MASTER_M, 0, 50000, 0, now, false, false, 50)
+ _assert(fail_res["result"] == SkillBookSystem.Result.FAIL_READ, "Roll 50 fails (base fail percent 65)")
+ _assert(fail_res["exp_consumed"] == 20000, "20,000 EXP consumed on failure")
+ _assert(fail_res["read_count"] == 0, "read_count not incremented on failure")
+ _assert(fail_res["next_read_time"] == now + 86400, "24h cooldown applied on failure")
+
+ # Hermit's Advice roll 10 -> Guaranteed Success (fail_percent 0)
+ var hermit_res := SkillBookSystem.read_skill_book(1, 22, SkillBookSystem.MASTER_M, 0, 50000, 0, now, false, true, 10)
+ _assert(hermit_res["result"] == SkillBookSystem.Result.SUCCESS_READ_ONCE, "Hermit advice guarantees success even on roll 10")
+ _assert(hermit_res["consumed_hermit"] == true, "Hermit advice consumed")
+ _assert(hermit_res["read_count"] == 1, "M2 (needs 2) read_count becomes 1")
+
+ # Read second book to complete M2 -> M3
+ var up_res := SkillBookSystem.read_skill_book(1, 22, SkillBookSystem.MASTER_M, 1, 50000, 0, now, true, true, 10)
+ _assert(up_res["result"] == SkillBookSystem.Result.SUCCESS_LEVEL_UP, "Second book triggers level up")
+ _assert(up_res["level"] == 23, "Level upgraded to 23 (M3)")
+ _assert(up_res["read_count"] == 0, "read_count reset to 0 upon level up")
+
+func _test_breakthrough_to_grandmaster() -> void:
+ print("\n--- Test 4: Breakthrough from M10 (Lv 29/30) to Grandmaster (G1) ---")
+ var now := 100000
+ # M10 (level 29), already read 9 books, needs 9 (level - 20 = 9)
+ var g_res := SkillBookSystem.read_skill_book(1, 29, SkillBookSystem.MASTER_M, 8, 50000, 0, now, true, true, 10)
+ _assert(g_res["result"] == SkillBookSystem.Result.SUCCESS_LEVEL_UP, "M10 completes final book")
+ _assert(g_res["level"] == 30, "Skill level advances to 30")
+ _assert(g_res["master_type"] == SkillBookSystem.MASTER_G, "Skill grade transforms into Grandmaster (MASTER_G)")
+
+func _test_soul_stone_training() -> void:
+ print("\n--- Test 5: Soul Stone (50513) G1 -> P Progression ---")
+ # G1 (Lv 30) -> G2, karma 5000 (positive)
+ var s1 := SkillBookSystem.soul_stone_train(1, 30, SkillBookSystem.MASTER_G, 5000, 20)
+ _assert(s1["success"] == true, "Soul stone training succeeds on roll 20 (<= 30%)")
+ _assert(s1["level"] == 31, "Advances to G2 (Lv 31)")
+ _assert(s1["karma_consumed"] == 1000, "G1 training consumed 1,000 karma")
+
+ # Evil character (karma -2000): karma cost doubled!
+ var s2 := SkillBookSystem.soul_stone_train(1, 30, SkillBookSystem.MASTER_G, -2000, 20)
+ _assert(s2["karma_consumed"] == 2000, "Negative karma doubles consumption (2,000 karma)")
+
+ # G10 (Lv 39) -> P (Lv 40)
+ var s_p := SkillBookSystem.soul_stone_train(1, 39, SkillBookSystem.MASTER_G, 10000, 15)
+ _assert(s_p["success"] == true, "G10 succeeds into Perfect Master")
+ _assert(s_p["level"] == 40, "Skill level reaches 40")
+ _assert(s_p["master_type"] == SkillBookSystem.MASTER_P, "Skill grade transforms into Perfect Master (MASTER_P / 'P')")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_skill_book_parity.gd.uid b/project/test_skill_book_parity.gd.uid
new file mode 100644
index 00000000..22679810
--- /dev/null
+++ b/project/test_skill_book_parity.gd.uid
@@ -0,0 +1 @@
+uid://byhkkditn4pke
diff --git a/project/test_skill_book_reading_parity.gd b/project/test_skill_book_reading_parity.gd
new file mode 100644
index 00000000..240868ff
--- /dev/null
+++ b/project/test_skill_book_reading_parity.gd
@@ -0,0 +1,166 @@
+# test_skill_book_reading_parity.gd —— 40250 大师技能书悟道与进阶 1:1 回归测试
+extends SceneTree
+
+const SystemClass = preload("res://skill_book_reading_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_skill_book_reading_parity (Batch 26 Direction 1) ===")
+ _test_level_curve_and_book_requirements()
+ _test_exp_and_cooldown_mechanics()
+ _test_exorcism_and_hermit_scrolls()
+ _test_failure_handling()
+ _test_full_m1_to_g1_progression()
+ _test_edge_cases_and_rejections()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_level_curve_and_book_requirements() -> void:
+ print("\n--- Test 1: Level Curve & 40250 Need Books Formula ---")
+ var sys = SystemClass.new()
+ sys.init_skill(1, 15) # Normal
+ _assert(sys.get_skill_master_type(1) == SystemClass.SKILL_NORMAL, "Lv 15 is SKILL_NORMAL")
+
+ sys.init_skill(1, 20) # M1
+ _assert(sys.get_skill_master_type(1) == SystemClass.SKILL_MASTER, "Lv 20 is SKILL_MASTER (M1)")
+ _assert(sys.get_need_books_for_next_level(1) == 1, "M1 requires 1 book to M2")
+
+ sys.init_skill(1, 21) # M2
+ _assert(sys.get_need_books_for_next_level(1) == 2, "M2 requires 2 books to M3")
+
+ sys.init_skill(1, 24) # M5
+ _assert(sys.get_need_books_for_next_level(1) == 5, "M5 requires 5 books to M6")
+
+ sys.init_skill(1, 29) # M10
+ _assert(sys.get_need_books_for_next_level(1) == 10, "M10 requires 10 books to G1")
+
+ # Total books from M1 to G1: sum(1..10) = 55
+ var total_books: int = 0
+ for lvl in range(20, 30):
+ sys.init_skill(1, lvl)
+ total_books += sys.get_need_books_for_next_level(1)
+ _assert(total_books == 55, "Total successful books needed for M1->G1 is exactly 55")
+
+func _test_exp_and_cooldown_mechanics() -> void:
+ print("\n--- Test 2: EXP Consumption & 24h Cooldown ---")
+ var sys = SystemClass.new(50000, 1000000)
+ sys.init_skill(1, 20) # M1
+
+ # Insufficient EXP check
+ sys.player_exp = 15000
+ var res1 = sys.learn_skill_by_book(1, 1, 1) # roll=1 (success)
+ _assert(res1["error_code"] == "INSUFFICIENT_EXP", "Blocks reading when EXP < 20,000")
+ _assert(sys.player_exp == 15000, "EXP not deducted when blocked")
+
+ # Sufficient EXP check
+ sys.player_exp = 50000
+ var res2 = sys.learn_skill_by_book(1, 1, 1)
+ _assert(res2["error_code"] == "OK" and res2["success"] == true, "Reading succeeds with sufficient EXP")
+ _assert(sys.player_exp == 30000, "20,000 EXP deducted on attempt (50000 -> 30000)")
+ _assert(res2["level_up"] == true, "M1 leveled up to M2")
+
+ # 24h Cooldown check (86400s)
+ _assert(sys.skills[1]["next_read_time"] == 1000000 + 86400, "Next read time set to now + 86400")
+ sys.current_time = 1000000 + 40000 # 40,000s passed, still < 86400
+ var res3 = sys.learn_skill_by_book(1, 1, 1)
+ _assert(res3["error_code"] == "COOLDOWN_ACTIVE", "Reading blocked during 24h cooldown")
+
+func _test_exorcism_and_hermit_scrolls() -> void:
+ print("\n--- Test 3: Exorcism Scroll (71001) & Hermit Advice (71094) ---")
+ var sys = SystemClass.new(100000, 1000000)
+ sys.init_skill(1, 21) # M2 (needs 2 books)
+ sys.skills[1]["next_read_time"] = 1000000 + 50000 # In cooldown
+
+ # Use Exorcism Scroll (71001)
+ sys.use_exorcism_scroll()
+ _assert(sys.has_affect(SystemClass.EXORCISM_SCROLL_VNUM) == true, "Exorcism affect active")
+
+ # Reading while in cooldown with Exorcism
+ var res = sys.learn_skill_by_book(1, 1, 1)
+ _assert(res["error_code"] == "OK", "Exorcism scroll bypassed cooldown")
+ _assert(res["consumed_exorcism"] == true, "Exorcism scroll consumed")
+ _assert(sys.has_affect(SystemClass.EXORCISM_SCROLL_VNUM) == false, "Exorcism affect removed after read")
+
+ # Use Hermit Advice (71094)
+ sys.use_hermit_advice()
+ _assert(sys.has_affect(SystemClass.HERMIT_ADVICE_VNUM) == true, "Hermit advice affect active")
+
+ # Reading with roll=99 (which normally fails base 35%)
+ sys.use_exorcism_scroll()
+ var res2 = sys.learn_skill_by_book(1, 1, 99)
+ _assert(res2["success"] == true, "Hermit advice forced 100% success rate on roll=99")
+ _assert(res2["consumed_hermit"] == true, "Hermit advice consumed")
+ _assert(sys.has_affect(SystemClass.HERMIT_ADVICE_VNUM) == false, "Hermit affect removed after read")
+
+func _test_failure_handling() -> void:
+ print("\n--- Test 4: Reading Failure Penalty & Retention ---")
+ var sys = SystemClass.new(100000, 1000000)
+ sys.init_skill(1, 22, 1) # M3, read_count = 1 (needs 3 books)
+
+ # Roll 90 without hermit advice -> Failure
+ var res = sys.learn_skill_by_book(1, 1, 90)
+ _assert(res["success"] == false, "Reading failed on roll 90")
+ _assert(res["error_code"] == "READ_FAIL", "Error code is READ_FAIL")
+ _assert(sys.skills[1]["level"] == 22, "Skill level remains unchanged at 22")
+ _assert(sys.skills[1]["read_count"] == 1, "Skill read_count preserved at 1")
+ _assert(sys.player_exp == 80000, "EXP still deducted on failure (20,000 EXP)")
+ _assert(sys.skills[1]["next_read_time"] == 1000000 + 86400, "Cooldown applied even on failure")
+
+func _test_full_m1_to_g1_progression() -> void:
+ print("\n--- Test 5: Full M1 to G1 Progression (55 Books) ---")
+ var sys = SystemClass.new(5000000, 1000000)
+ sys.init_skill(1, 20, 0) # Start at M1 (Lv 20)
+
+ var books_succeeded: int = 0
+ var final_res: Dictionary = {}
+
+ # Loop through all 55 books with 100% success using scrolls
+ for step in range(55):
+ sys.use_exorcism_scroll()
+ sys.use_hermit_advice()
+ final_res = sys.learn_skill_by_book(1, 1)
+ _assert(final_res["success"] == true, "Book %d read succeeded" % (step + 1))
+ books_succeeded += 1
+
+ _assert(books_succeeded == 55, "Successfully read 55 books")
+ _assert(sys.skills[1]["level"] == 30, "Skill level reached 30 (G1)")
+ _assert(sys.get_skill_master_type(1) == SystemClass.SKILL_GRAND_MASTER, "Skill tier is SKILL_GRAND_MASTER")
+ _assert(final_res["breakthrough_g"] == true, "Final book triggered breakthrough_g flag")
+
+func _test_edge_cases_and_rejections() -> void:
+ print("\n--- Test 6: Edge Cases & Rejections ---")
+ var sys = SystemClass.new(100000, 1000000)
+
+ # 1. Unknown skill
+ var r1 = sys.learn_skill_by_book(999, 999)
+ _assert(r1["error_code"] == "NOT_LEARNABLE", "Reject unregistered skill")
+
+ # 2. Skill mismatch on book socket
+ sys.init_skill(1, 20)
+ var r2 = sys.learn_skill_by_book(1, 2) # Socket says skill 2, but reading for skill 1
+ _assert(r2["error_code"] == "SKILL_MISMATCH", "Reject skill book with mismatched socket")
+
+ # 3. Normal tier skill (< 20)
+ sys.init_skill(2, 10)
+ var r3 = sys.learn_skill_by_book(2, 2)
+ _assert(r3["error_code"] == "NOT_MASTER_TIER", "Reject normal tier skill (< 20)")
+
+ # 4. G1 tier skill (>= 30)
+ sys.init_skill(3, 30)
+ var r4 = sys.learn_skill_by_book(3, 3)
+ _assert(r4["error_code"] == "ALREADY_G_OR_P", "Reject Grandmaster tier skill (>= 30)")
diff --git a/project/test_skill_book_reading_parity.gd.uid b/project/test_skill_book_reading_parity.gd.uid
new file mode 100644
index 00000000..a2921c7f
--- /dev/null
+++ b/project/test_skill_book_reading_parity.gd.uid
@@ -0,0 +1 @@
+uid://cyuhr5wad5jri
diff --git a/project/test_skill_ground_fix.gd b/project/test_skill_ground_fix.gd
new file mode 100644
index 00000000..21eeeab9
--- /dev/null
+++ b/project/test_skill_ground_fix.gd
@@ -0,0 +1,152 @@
+extends SceneTree
+
+const AssetRoot = preload("res://asset_root.gd")
+const Quickbar = preload("res://ui/quickbar.gd")
+const SkillTable = preload("res://ui/skill_table.gd")
+const NetPlay = preload("res://net_play.gd")
+const PlayerController = preload("res://player_controller.gd")
+
+func _init() -> void:
+ call_deferred("_run")
+
+func _run() -> void:
+ print("=== Running test_skill_ground_fix ===")
+ var fails := 0
+ var check = func(cond: bool, msg: String):
+ if not cond:
+ fails += 1
+ print("FAIL: ", msg)
+ else:
+ print("OK: ", msg)
+
+ var assets_root := AssetRoot.path()
+ var st := SkillTable.new()
+ st.load_file(assets_root.path_join("locale/locale/en/skilldesc.txt"))
+
+ # --- Test 1: Quickbar coordinate and heading parity with MapCoord ---
+ MapCoord.set_base(Vector2(409600, 896000))
+ var player := Node3D.new()
+ player.position = Vector3(150.0, 175.3, 200.0)
+ player.rotation.y = deg_to_rad(45.0)
+ get_root().add_child(player)
+
+ var ui := CanvasLayer.new()
+ get_root().add_child(ui)
+
+ var client_mock = Node.new()
+ var script = GDScript.new()
+ script.source_code = """
+extends Node
+var casts := []
+var uses := []
+func is_in_game() -> bool: return true
+func get_target() -> Dictionary: return {"vid": 2000}
+func get_main_vid() -> int: return 1
+func get_entity(_v: int) -> Dictionary: return {"race": 0, "kind": 2}
+func get_skills() -> Array: return [{"id": 1, "level": 5, "master": 0}]
+func use_skill(id: int, vid: int) -> bool:
+ uses.append([id, vid])
+ return true
+func cast_skill(mi: int, yaw: float, x: int, y: int) -> bool:
+ casts.append([mi, yaw, x, y])
+ return true
+func quickslot_add(_slot: int, _type: int, _id: int) -> bool:
+ return true
+"""
+ script.reload()
+ client_mock.set_script(script)
+ get_root().add_child(client_mock)
+
+ var qb := Quickbar.new()
+ get_root().add_child(qb)
+ qb.setup(client_mock, st, ui, func() -> Node: return player)
+ qb.assign(0, "skill", 1) # Skill 1: samyeon (no target required)
+
+ qb.activate(0)
+
+ check.call(client_mock.get("casts").size() == 1, "activate sends cast_skill")
+ if client_mock.get("casts").size() == 1:
+ var c = client_mock.get("casts")[0]
+ var expected_yaw := MapCoord.yaw_to_heading(player.rotation.y)
+ var expected_xy := MapCoord.to_server_cm(player.position)
+ check.call(absf(c[1] - expected_yaw) < 0.01, "cast_skill heading matches MapCoord (%f vs %f)" % [c[1], expected_yaw])
+ check.call(c[2] == int(expected_xy.x) and c[3] == int(expected_xy.y),
+ "cast_skill xy matches MapCoord.to_server_cm (%d, %d vs %d, %d)" % [c[2], c[3], int(expected_xy.x), int(expected_xy.y)])
+ check.call(c[2] == 424600 and c[3] == 916000, "cast_skill contains map base offset (424600, 916000)")
+
+ # --- Test 2: PlayerController height clamping when locked ---
+ var mock_world = Node.new()
+ var w_script = GDScript.new()
+ w_script.source_code = """
+extends Node
+func sample_height(x: float, z: float) -> float:
+ # Sloped terrain: height increases with x and z
+ return 100.0 + x * 0.1 + z * 0.05
+"""
+ w_script.reload()
+ mock_world.set_script(w_script)
+ get_root().add_child(mock_world)
+
+ var pc := PlayerController.new()
+ get_root().add_child(pc)
+ pc.player = player
+ pc.world = mock_world
+ pc.active = true
+
+ # Set locked = true (as during skill cast)
+ pc.locked = true
+ player.position = Vector3(10.0, 50.0, 20.0) # Intentionally wrong y (50.0 instead of 102.0)
+ pc._process(0.016)
+
+ var expected_h: float = float(mock_world.call("sample_height", player.position.x, player.position.z))
+ check.call(absf(player.position.y - expected_h) < 0.001,
+ "player.position.y clamped to terrain even when pc.locked is true (%f vs %f)" % [player.position.y, expected_h])
+
+ # Move horizontally while locked (e.g. root motion / server push)
+ player.position.x = 50.0
+ player.position.z = 80.0
+ pc._process(0.016)
+ expected_h = float(mock_world.call("sample_height", player.position.x, player.position.z))
+ check.call(absf(player.position.y - expected_h) < 0.001,
+ "player.position.y follows slope terrain while locked (%f vs %f)" % [player.position.y, expected_h])
+
+ # --- Test 3: NetPlay _on_net_moved height sampling on position snap ---
+ var np := NetPlay.new()
+ get_root().add_child(np)
+ np.pc = pc
+ np.client = client_mock
+ np._main_vid = 1
+
+ # MapCoord base is 409600, 896000
+ # Suppose player is at world (50, 109, 80)
+ # Server sends snap to world (100, y, 200) -> server pos (419600, 916000)
+ var snap_server_pos = Vector3(4196.0, 0.0, -9160.0) # net frame cm/100
+ var snap_entity = {
+ "vid": 1,
+ "pos": snap_server_pos
+ }
+ var client_script2 = GDScript.new()
+ client_script2.source_code = """
+extends Node
+var ent := {}
+func get_entity(_vid: int) -> Dictionary: return ent
+func is_in_game() -> bool: return false
+"""
+ client_script2.reload()
+ var client_mock2 = Node.new()
+ client_mock2.set_script(client_script2)
+ client_mock2.set("ent", snap_entity)
+ get_root().add_child(client_mock2)
+ np.client = client_mock2
+
+ np._on_net_moved(1)
+
+ var target_world_pos: Vector3 = MapCoord.to_world(snap_server_pos)
+ var expected_snap_h: float = float(mock_world.call("sample_height", target_world_pos.x, target_world_pos.z))
+ check.call(absf(player.position.x - target_world_pos.x) < 0.01 and absf(player.position.z - target_world_pos.z) < 0.01,
+ "player snapped to target coords")
+ check.call(absf(player.position.y - expected_snap_h) < 0.001,
+ "player.position.y on snap was sampled from new terrain height (%f vs %f)" % [player.position.y, expected_snap_h])
+
+ print("=== Completed test_skill_ground_fix with %d failures ===" % fails)
+ quit(fails)
diff --git a/project/test_skill_ground_fix.gd.uid b/project/test_skill_ground_fix.gd.uid
new file mode 100644
index 00000000..53045292
--- /dev/null
+++ b/project/test_skill_ground_fix.gd.uid
@@ -0,0 +1 @@
+uid://dt5euqqnupcir
diff --git a/project/test_skill_inventory_parity.gd b/project/test_skill_inventory_parity.gd
new file mode 100644
index 00000000..b7062c7f
--- /dev/null
+++ b/project/test_skill_inventory_parity.gd
@@ -0,0 +1,513 @@
+# test_skill_inventory_parity.gd
+# Headless parity test suite for Phase 4:
+# 1. Skill usability gating (CheckSkillUsable, cooltime, SP, weapon type, arrow, range)
+# 2. Inventory & equipment wear/unwear/move flow and EquipRules gating
+# 3. Character status window calculations, formatting, and /stat commands
+# 4. Quickbar persistence (CG_QUICKSLOT_ADD/DEL/SWAP) and server restore
+extends SceneTree
+
+const PlayerSkill = preload("res://player_skill.gd")
+const Quickbar = preload("res://ui/quickbar.gd")
+const InventoryUI = preload("res://ui/inventory_ui.gd")
+const CharStatusUI = preload("res://ui/char_status_ui.gd")
+const EquipRules = preload("res://equip_rules.gd")
+
+var _checks_passed := 0
+var _checks_failed := 0
+
+func _check(condition: bool, msg: String) -> void:
+ if condition:
+ _checks_passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _checks_failed += 1
+ printerr(" [FAIL] %s" % msg)
+ push_error("FAIL: %s" % msg)
+
+class MockClient extends Node:
+ signal points_changed(points: Dictionary)
+ signal inventory_changed(window: int, cell: int)
+ signal quickslots_changed()
+ signal entity_main_set(vid: int)
+ signal skill_group_changed(group: int)
+
+ var main_vid := 10001
+ var entities := {}
+ var target := {}
+ var points := {}
+ var skills := []
+ var skill_group := 1
+ var quickslots := []
+ var inventory := []
+ var equipment := []
+
+ var used_skills := []
+ var cast_skills := []
+ var used_items := []
+ var moved_items := []
+ var quickslot_adds := []
+ var quickslot_dels := []
+ var quickslot_swaps := []
+ var chat_messages := []
+
+ func _init() -> void:
+ for i in 90:
+ inventory.append({})
+ for i in 24:
+ equipment.append({})
+ for i in 36:
+ quickslots.append({"pos": i, "type": 0, "ref": 0})
+
+ func get_main_vid() -> int:
+ return main_vid
+
+ func get_entity(vid: int) -> Dictionary:
+ return entities.get(vid, {})
+
+ func get_target() -> Dictionary:
+ return target
+
+ 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_inventory() -> Array:
+ return inventory
+
+ func get_equipment() -> Array:
+ return equipment
+
+ func use_skill(skill_id: int, target_vid: int) -> bool:
+ used_skills.append({"skill_id": skill_id, "target_vid": target_vid})
+ return true
+
+ func cast_skill(motion_idx: int, rot_deg: float, x: int, y: int) -> bool:
+ cast_skills.append({"motion_idx": motion_idx, "rot_deg": rot_deg, "x": x, "y": y})
+ return true
+
+ func use_item(window: int, cell: int) -> bool:
+ used_items.append({"window": window, "cell": cell})
+ return true
+
+ func move_item(src_w: int, src_c: int, dst_w: int, dst_c: int, count: int) -> bool:
+ moved_items.append({"src_w": src_w, "src_c": src_c, "dst_w": dst_w, "dst_c": dst_c, "count": count})
+ return true
+
+ func quickslot_add(pos: int, type: int, ref: int) -> bool:
+ quickslot_adds.append({"pos": pos, "type": type, "ref": ref})
+ quickslots[pos] = {"pos": pos, "type": type, "ref": ref}
+ return true
+
+ func quickslot_del(pos: int) -> bool:
+ quickslot_dels.append(pos)
+ quickslots[pos] = {"pos": pos, "type": 0, "ref": 0}
+ return true
+
+ func quickslot_swap(pos: int, pos_to: int) -> bool:
+ quickslot_swaps.append({"from": pos, "to": pos_to})
+ var tmp: Dictionary = quickslots[pos]
+ quickslots[pos] = quickslots[pos_to]
+ quickslots[pos_to] = tmp
+ return true
+
+ func say(_type: int, text: String) -> bool:
+ chat_messages.append(text)
+ return true
+
+class MockSkillTable extends RefCounted:
+ var skills_data := {
+ # Skill 1: 三连斩 (Warrior sword attack)
+ 1: {
+ "is_attack": true, "is_can_use": true, "need_sp": 45, "target_range": 400,
+ "weapon_types": [0, 1], # SWORD, TWO_HAND
+ "is_need_bow": false, "is_toggle": false, "motion_idx": 1,
+ "is_horse_skill": false, "is_need_empty_bottle": false, "is_need_poison_bottle": false
+ },
+ # Skill 2: 旋风斩 (Warrior sword attack, fan range)
+ 2: {
+ "is_attack": true, "is_can_use": true, "need_sp": 60, "target_range": 500,
+ "weapon_types": [0, 1],
+ "is_need_bow": false, "is_toggle": false, "motion_idx": 2,
+ "is_fan": true
+ },
+ # Skill 3: 狂战士 (Warrior buff, can use for self, no weapon restriction)
+ 3: {
+ "is_attack": false, "is_can_use": true, "need_sp": 50, "target_range": 0,
+ "weapon_types": [],
+ "is_need_bow": false, "is_toggle": true, "can_use_for_me": true, "motion_idx": 3
+ },
+ # Skill 31: 穿透箭 (Ninja bow attack)
+ 31: {
+ "is_attack": true, "is_can_use": true, "need_sp": 35, "target_range": 2500,
+ "weapon_types": [2], # BOW
+ "is_need_bow": true, "is_toggle": false, "motion_idx": 31
+ }
+ }
+
+ func has(id: int) -> bool: return skills_data.has(id)
+ func is_attack(id: int) -> bool: return skills_data.get(id, {}).get("is_attack", false)
+ func is_can_use_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_can_use", true)
+ func is_standing(id: int) -> bool: return true
+ func is_toggle(id: int) -> bool: return skills_data.get(id, {}).get("is_toggle", false)
+ func is_horse_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_horse_skill", false)
+ func is_need_bow(id: int) -> bool: return skills_data.get(id, {}).get("is_need_bow", false)
+ func is_need_empty_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_empty_bottle", false)
+ func is_need_poison_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_poison_bottle", false)
+ func can_use_weapon_type(id: int, wtype: int) -> bool:
+ var list: Array = skills_data.get(id, {}).get("weapon_types", [])
+ return list.is_empty() or list.has(wtype)
+ func need_sp(id: int, _lvl: int) -> int: return skills_data.get(id, {}).get("need_sp", -1)
+ func need_hp(_id: int, _lvl: int) -> int: return 0
+ func can_use_if_not_enough(_id: int) -> bool: return false
+ func target_range(id: int) -> int: return skills_data.get(id, {}).get("target_range", 0)
+ func can_use_for_me(id: int) -> bool: return skills_data.get(id, {}).get("can_use_for_me", false)
+ func is_auto_search_target(_id: int) -> bool: return true
+ func is_need_target(_id: int) -> bool: return false
+ func is_need_corpse(_id: int) -> bool: return false
+ func is_charge_skill(_id: int) -> bool: return false
+ func motion_idx_of(id: int) -> int: return skills_data.get(id, {}).get("motion_idx", 0)
+ func is_fan_range(id: int) -> bool: return skills_data.get(id, {}).get("is_fan", false)
+ func is_circle_range(_id: int) -> bool: return false
+ func target_count(_id: int, _lvl: int) -> int: return 3
+ func fly_shape(_id: int) -> int: return 0
+ func skill_type_of(_id: int) -> int: return 1
+ func is_use_hp(_id: int) -> bool: return false
+ func can_change_direction(_id: int) -> bool: return true
+ func is_only_for_alliance(_id: int) -> bool: return false
+ func name_of(id: int) -> String:
+ match id:
+ 1: return "三连斩"
+ 2: return "旋风斩"
+ 3: return "狂战士"
+ 31: return "穿透箭"
+ return "技能 %d" % id
+
+func _init() -> void:
+ print("=== Running test_skill_inventory_parity (Phase 4 Parity) ===")
+ _test_skill_gating()
+ _test_inventory_equipment_flow()
+ _test_char_status_window()
+ _test_quickbar_persistence()
+
+ print("\n=======================================================")
+ if _checks_failed == 0:
+ print("=== ALL PHASE 4 PARITY TESTS PASSED (%d checks) ===" % _checks_passed)
+ quit(0)
+ else:
+ printerr("=== PHASE 4 PARITY TESTS FAILED: %d failed, %d passed ===" % [_checks_failed, _checks_passed])
+ quit(1)
+
+# ==============================================================================
+# 1. 技能施法三道门检 (Skill Usability Gating)
+# ==============================================================================
+func _test_skill_gating() -> void:
+ print("\n--- Test 1: Skill Usability & Gating (40250 CheckSkillUsable) ---")
+ var client := MockClient.new()
+ var table := MockSkillTable.new()
+ var gate := PlayerSkill.new()
+ gate.setup(client, table)
+
+ client.skills = [
+ {"id": 1, "level": 1, "master": 0},
+ {"id": 2, "level": 1, "master": 0},
+ {"id": 3, "level": 1, "master": 0},
+ {"id": 31, "level": 1, "master": 0}
+ ]
+ client.entities[client.main_vid] = {"vid": client.main_vid, "kind": 0, "dead": false, "in_safe": false}
+
+ # 1.1 Dead player cannot act
+ gate.can_act = false
+ var res := gate.click_skill_slot(1)
+ _check(not res.ok and res.code == "CANNOT_ACT", "Dead or incapacitated player blocked (CANNOT_ACT)")
+ gate.can_act = true
+
+ # 1.2 Safe zone attack skill check
+ gate.in_safe = true
+ res = gate.click_skill_slot(1)
+ _check(not res.ok and res.code == "IN_SAFE", "Attack skill blocked in safe zone (IN_SAFE)")
+ res = gate.click_skill_slot(3)
+ _check(res.ok, "Buff skill allowed in safe zone")
+ gate.in_safe = false
+
+ # 1.3 Weapon type mismatch check
+ gate.weapon_sub_type = 2 # BOW
+ res = gate.check_skill_usable(1) # Requires SWORD (0) or TWO_HAND (1)
+ _check(not res.ok and res.code == "NOT_MATCHABLE_WEAPON", "Sword skill with Bow blocked (NOT_MATCHABLE_WEAPON)")
+ gate.weapon_sub_type = 0 # SWORD
+ res = gate.check_skill_usable(1)
+ _check(res.ok, "Sword skill with Sword allowed")
+
+ # 1.4 Arrow count check for bow skill
+ gate.weapon_sub_type = 2 # BOW
+ gate.arrow_count = 0
+ res = gate.check_skill_usable(31)
+ _check(not res.ok and res.code == "EMPTY_ARROW", "Bow skill without arrows blocked (EMPTY_ARROW)")
+ gate.arrow_count = 100
+ res = gate.check_skill_usable(31)
+ _check(res.ok, "Bow skill with arrows allowed")
+ gate.weapon_sub_type = 0
+
+ # 1.5 Insufficient SP check
+ gate.cur_sp = 20 # Skill 1 needs 45
+ res = gate.check_skill_usable(1)
+ _check(not res.ok and res.code == "NOT_ENOUGH_SP", "Skill with insufficient SP blocked (NOT_ENOUGH_SP)")
+ gate.cur_sp = 100
+ res = gate.check_skill_usable(1)
+ _check(res.ok, "Skill with sufficient SP allowed")
+
+ # 1.6 Cooltime gating
+ gate.slot_cd_end = Time.get_ticks_msec() / 1000.0 + 5.0 # 5s remaining
+ res = gate.check_skill_usable(1)
+ _check(not res.ok and res.code == "WAIT_COOLTIME", "Skill in cooldown blocked (WAIT_COOLTIME)")
+ gate.slot_cd_end = 0.0 # Expired
+ res = gate.check_skill_usable(1)
+ _check(res.ok, "Skill with expired cooldown allowed")
+
+ # 1.7 Target distance check
+ var enemy_vid := 20001
+ client.entities[enemy_vid] = {"vid": enemy_vid, "kind": 2, "dead": false, "in_safe": false}
+ client.target = {"vid": enemy_vid}
+ gate.target_distance = func(v: int) -> float: return 1000.0 if v == enemy_vid else -1.0 # 1000cm distance, skill range is 400cm
+ res = gate.use_skill(1)
+ # Target beyond range returns RESERVED for approach
+ _check(not res.ok and res.code == "RESERVED", "Target beyond skill range enters RESERVED state")
+ # Target in range (300cm < 400cm)
+ gate.target_distance = func(v: int) -> float: return 300.0 if v == enemy_vid else -1.0
+ res = gate.use_skill(1)
+ _check(res.ok and res.target_vid == enemy_vid, "Target within skill range allowed with target_vid")
+
+ client.queue_free()
+
+# ==============================================================================
+# 2. 背包与装备槽位映射与穿脱流 (Inventory & Equipment Flow)
+# ==============================================================================
+func _test_inventory_equipment_flow() -> void:
+ print("\n--- Test 2: Inventory & Equipment Flow (40250 ItemPos & Equip) ---")
+ var client := MockClient.new()
+ var inv := InventoryUI.new()
+ root.add_child(inv)
+ inv.setup(null, client, null, "")
+
+ # 2.1 Wire Coordinate Mapping
+ # Inventory cell 15 -> [WINDOW_INVENTORY (1), 15]
+ var w15 := inv._to_wire(15)
+ _check(w15[0] == 1 and w15[1] == 15, "Inventory slot 15 maps to [WINDOW_INVENTORY, 15]")
+ # Equipment cell 94 (WEAR_WEAPON = 4) -> [WINDOW_EQUIPMENT (2), 4]
+ var w_weapon := inv._to_wire(94)
+ _check(w_weapon[0] == 2 and w_weapon[1] == 4, "Equip slot 94 (WEAPON) maps to [WINDOW_EQUIPMENT, 4]")
+ # Equipment cell 90 (WEAR_BODY = 0) -> [WINDOW_EQUIPMENT (2), 0]
+ var w_body := inv._to_wire(90)
+ _check(w_body[0] == 2 and w_body[1] == 0, "Equip slot 90 (BODY) maps to [WINDOW_EQUIPMENT, 0]")
+ # New equipment cell 109 (Costume Body = wear 19) -> [WINDOW_INVENTORY, 109]
+ var w_costume := inv._to_wire(109)
+ _check(w_costume[0] == 1 and w_costume[1] == 109, "Costume Body slot 109 maps to [WINDOW_INVENTORY, 109]")
+
+ # 2.2 Wearing an item: right-click inventory cell
+ client.used_items.clear()
+ inv.use(5)
+ _check(client.used_items.size() == 1 and client.used_items[0].window == 1 and client.used_items[0].cell == 5,
+ "Right-click inventory cell 5 sends send_item_use(WINDOW_INVENTORY, 5)")
+
+ # 2.3 Unwearing an item: right-click equipment slot
+ client.used_items.clear()
+ inv.use(94) # Weapon slot (wear 4)
+ _check(client.used_items.size() == 1 and client.used_items[0].window == 2 and client.used_items[0].cell == 4,
+ "Right-click equipped weapon sends send_item_use(WINDOW_EQUIPMENT, 4) to unwear")
+
+ # 2.4 Moving item: drag between inventory cells
+ client.moved_items.clear()
+ inv.move_to(10, 20, 1)
+ _check(client.moved_items.size() == 1 and
+ client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 10 and
+ client.moved_items[0].dst_w == 1 and client.moved_items[0].dst_c == 20,
+ "Moving item between inventory cells sends move_item(1, 10, 1, 20, 1)")
+
+ # 2.5 Dragging item from inventory to equipment slot
+ client.moved_items.clear()
+ inv.move_to(3, 94, 1) # Slot 3 to Weapon slot (wear 4)
+ _check(client.moved_items.size() == 1 and
+ client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 3 and
+ client.moved_items[0].dst_w == 2 and client.moved_items[0].dst_c == 4,
+ "Dragging item to weapon slot sends move_item(1, 3, 2, 4, 1)")
+
+ # 2.6 Equip rules rejection
+ # Item requiring level 50 on level 10 character
+ var high_lvl_item := {
+ "type": 1, "sub_type": 0, "anti_flags": 0,
+ "limits": [{"type": 1, "value": 50}], # LIMIT_LEVEL = 1
+ "apply": []
+ }
+ var ctx := {"level": 10, "race": 0, "st": 10, "dx": 10, "ht": 10, "iq": 10, "worn": {}}
+ var verdict := EquipRules.can_equip(high_lvl_item, -1, ctx)
+ _check(not verdict.ok and verdict.code == "LIMIT_LEVEL" and verdict.need == 50,
+ "EquipRules rejects item when character level is insufficient (LIMIT_LEVEL)")
+
+ inv.queue_free()
+ client.queue_free()
+
+# ==============================================================================
+# 3. 角色属性面板计算与加点 (Character Status Parity)
+# ==============================================================================
+func _test_char_status_window() -> void:
+ print("\n--- Test 3: Character Status Window (40250 uicharacter.py Parity) ---")
+ var client := MockClient.new()
+ var char_ui := CharStatusUI.new()
+ root.add_child(char_ui)
+ char_ui.setup(null, client, "")
+
+ # Populate points array (EPointTypes from 40250 char.h / Packet.h)
+ var pts_arr := []
+ for i in 255:
+ pts_arr.append(0)
+
+ pts_arr[CharStatusUI.P_LEVEL] = 55
+ pts_arr[CharStatusUI.P_EXP] = 120000
+ pts_arr[CharStatusUI.P_NEXT_EXP] = 300000
+ pts_arr[CharStatusUI.P_HP] = 4500
+ pts_arr[CharStatusUI.P_MAX_HP] = 5000
+ pts_arr[CharStatusUI.P_SP] = 1200
+ pts_arr[CharStatusUI.P_MAX_SP] = 1500
+ pts_arr[CharStatusUI.P_ST] = 90
+ pts_arr[CharStatusUI.P_HT] = 75
+ pts_arr[CharStatusUI.P_DX] = 40
+ pts_arr[CharStatusUI.P_IQ] = 25
+ pts_arr[CharStatusUI.P_MIN_ATK] = 320
+ pts_arr[CharStatusUI.P_MAX_ATK] = 380
+ pts_arr[CharStatusUI.P_ATT_GRADE_BONUS] = 50
+ pts_arr[CharStatusUI.P_PARTY_ATT_GRADE] = 10
+ pts_arr[CharStatusUI.P_DEF_GRADE] = 280
+ pts_arr[CharStatusUI.P_DEF_GRADE_BONUS] = 20
+ pts_arr[CharStatusUI.P_MAGIC_ATT_GRADE] = 150
+ pts_arr[CharStatusUI.P_MIN_MAGIC_WEP] = 30
+ pts_arr[CharStatusUI.P_MAX_MAGIC_WEP] = 50
+ pts_arr[CharStatusUI.P_STAT] = 5 # 5 unused stat points
+
+ client.points = {
+ "level": 55, "exp": 120000, "next_exp": 300000,
+ "hp": 4500, "max_hp": 5000, "sp": 1200, "max_sp": 1500,
+ "points": pts_arr
+ }
+
+ # 3.1 Attack calculation:
+ # lo = min_atk (320) + bonus (50 + 10 = 60) = 380
+ # hi = max_atk (380) + bonus (60) = 440 -> "380-440"
+ var get_pt := func(idx: int) -> int: return int(pts_arr[idx])
+ var att_str := char_ui._att_text(get_pt)
+ _check(att_str == "380-440", "Total Attack text matches 40250 formula ('380-440')")
+
+ # Single value attack when min == max
+ pts_arr[CharStatusUI.P_MIN_ATK] = 350
+ pts_arr[CharStatusUI.P_MAX_ATK] = 350
+ att_str = char_ui._att_text(get_pt)
+ _check(att_str == "410", "Total Attack when min==max matches single value ('410')")
+
+ # 3.2 Defense calculation:
+ # def_grade (280) + def_grade_bonus (20) = 300
+ var def_str := char_ui._def_text(get_pt)
+ _check(def_str == "300", "Total Defense text matches 40250 formula ('300')")
+
+ # 3.3 Magic Attack calculation:
+ # mag_att (150) + min (30) = 180, mag_att (150) + max (50) = 200 -> "180-200"
+ var matt_str := char_ui._matt_text(get_pt)
+ _check(matt_str == "180-200", "Total Magic Attack matches 40250 formula ('180-200')")
+
+ # 3.4 Stat allocation commands
+ client.chat_messages.clear()
+ char_ui._send_stat("/stat ht")
+ char_ui._send_stat("/stat iq")
+ char_ui._send_stat("/stat st")
+ char_ui._send_stat("/stat dx")
+ _check(client.chat_messages == ["/stat ht", "/stat iq", "/stat st", "/stat dx"],
+ "Stat addition sends exact 40250 chat commands (/stat ht, iq, st, dx)")
+
+ client.chat_messages.clear()
+ char_ui._send_stat("/stat- st")
+ _check(client.chat_messages == ["/stat- st"], "Stat reset command sends exact 40250 /stat- command")
+
+ char_ui.queue_free()
+ client.queue_free()
+
+# ==============================================================================
+# 4. 快捷栏持久化与同步 (Quickbar Persistence & Sync)
+# ==============================================================================
+func _test_quickbar_persistence() -> void:
+ print("\n--- Test 4: Quickbar Persistence & Sync (40250 CG_QUICKSLOT_*) ---")
+ var client := MockClient.new()
+ var table := MockSkillTable.new()
+ var qb := Quickbar.new()
+ root.add_child(qb)
+ qb.setup(client, table, root, func(): return null, "")
+
+ # 4.1 Assign Skill to Quickslot 0
+ client.quickslot_adds.clear()
+ # assign skill 1 to local slot 0
+ var ok := qb.assign(0, "skill", 1)
+ _check(ok, "Assigned skill 1 to quickbar slot 0")
+ _check(client.quickslot_adds.size() == 1 and
+ client.quickslot_adds[0].pos == 0 and
+ client.quickslot_adds[0].type == 2 and # 2 = SKILL
+ client.quickslot_adds[0].ref == 1,
+ "Skill assignment sends quickslot_add(pos=0, type=2, ref=1)")
+
+ # 4.2 Assign Item to Quickslot 1
+ client.quickslot_adds.clear()
+ ok = qb.assign(1, "item", 12) # Inventory cell 12
+ _check(ok, "Assigned item (cell 12) to quickbar slot 1")
+ _check(client.quickslot_adds.size() == 1 and
+ client.quickslot_adds[0].pos == 1 and
+ client.quickslot_adds[0].type == 1 and # 1 = ITEM
+ client.quickslot_adds[0].ref == 12,
+ "Item assignment sends quickslot_add(pos=1, type=1, ref=12)")
+
+ # 4.3 Assign Emote to Quickslot 2
+ client.quickslot_adds.clear()
+ ok = qb.assign(2, "emote", 5) # Emote 5
+ _check(ok, "Assigned emote 5 to quickbar slot 2")
+ _check(client.quickslot_adds.size() == 1 and
+ client.quickslot_adds[0].pos == 2 and
+ client.quickslot_adds[0].type == 3 and # 3 = EMOTE
+ client.quickslot_adds[0].ref == 5,
+ "Emote assignment sends quickslot_add(pos=2, type=3, ref=5)")
+
+ # 4.4 Swap Quickslots 0 and 1
+ client.quickslot_swaps.clear()
+ qb._swap(0, 1)
+ _check(client.quickslot_swaps.size() == 1 and
+ client.quickslot_swaps[0].from == 0 and
+ client.quickslot_swaps[0].to == 1,
+ "Quickslot swap sends quickslot_swap(0, 1)")
+ _check(qb._state[0].kind == "item" and qb._state[1].kind == "skill",
+ "Local quickslot state successfully swapped")
+
+ # 4.5 Delete Quickslot
+ client.quickslot_dels.clear()
+ qb._clear(0)
+ _check(client.quickslot_dels == [0], "Clearing slot 0 sends quickslot_del(0)")
+ _check(qb._state[0].kind == "", "Slot 0 cleared locally")
+
+ # 4.6 Server Full Restore
+ # Simulate server sending 36 slots array
+ client.quickslots = [
+ {"pos": 0, "type": 2, "ref": 2}, # Skill 2
+ {"pos": 1, "type": 1, "ref": 8}, # Item cell 8
+ {"pos": 2, "type": 3, "ref": 1}, # Emote 1
+ ]
+ for i in range(3, 36):
+ client.quickslots.append({"pos": i, "type": 0, "ref": 0})
+
+ qb.restore_from_server()
+ _check(qb._state[0].kind == "skill" and qb._state[0].id == 2, "Restored slot 0 is Skill 2")
+ _check(qb._state[1].kind == "item" and qb._state[1].id == 8, "Restored slot 1 is Item cell 8")
+ _check(qb._state[2].kind == "emote" and qb._state[2].id == 1, "Restored slot 2 is Emote 1")
+ _check(qb._state[3].kind == "", "Restored slot 3 is empty")
+
+ qb.queue_free()
+ client.queue_free()
diff --git a/project/test_skill_inventory_parity.gd.uid b/project/test_skill_inventory_parity.gd.uid
new file mode 100644
index 00000000..5e628aa7
--- /dev/null
+++ b/project/test_skill_inventory_parity.gd.uid
@@ -0,0 +1 @@
+uid://dldl4ea3ne1h4
diff --git a/project/test_socket_glow_fx_parity.gd b/project/test_socket_glow_fx_parity.gd
new file mode 100644
index 00000000..7928ea92
--- /dev/null
+++ b/project/test_socket_glow_fx_parity.gd
@@ -0,0 +1,132 @@
+# test_socket_glow_fx_parity.gd —— 40250 装备灵石镶嵌发光流光粒子 1:1 回归测试套件
+extends SceneTree
+
+const SocketGlowFxSystem = preload("res://socket_glow_fx_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_socket_glow_fx_parity (Direction 3: Socket Glow FX 1:1) ===")
+ _test_stone_color_roster()
+ _test_base_refine_auras()
+ _test_socket_stone_glow_blending()
+ _test_material_application()
+ _finish()
+
+func _test_stone_color_roster() -> void:
+ print("\n--- Test 1: Spirit Stone Color Roster ---")
+ _assert(SocketGlowFxSystem.STONE_COLORS.size() >= 8, "Has at least 8 Spirit Stone colors defined")
+ _assert(SocketGlowFxSystem.STONE_COLORS.has(28430), "Warrior Stone +4 defined")
+ _assert(SocketGlowFxSystem.STONE_COLORS.has(28432), "Sura Stone +4 defined")
+ _assert(SocketGlowFxSystem.STONE_COLORS.has(28436), "Cooldown Stone +4 defined")
+ _assert(SocketGlowFxSystem.STONE_COLORS.has(28438), "Protection Stone +4 defined")
+
+func _test_base_refine_auras() -> void:
+ print("\n--- Test 2: Base Refine Auras (+0~+6 vs +7/+8/+9) ---")
+ var sys := SocketGlowFxSystem.new()
+
+ # +6 without stones
+ var item_plus6 := {"vnum": 10, "refine_level": 6, "sockets": []}
+ var g6 = sys.calculate_equipment_glow(item_plus6)
+ _assert(g6["is_glowing"] == false, "+6 item without stones has no glow")
+ _assert(g6["glow_intensity"] == 0.0, "Intensity is 0.0")
+
+ # +7 item
+ var item_plus7 := {"vnum": 10, "refine_level": 7, "sockets": []}
+ var g7 = sys.calculate_equipment_glow(item_plus7)
+ _assert(g7["is_glowing"] == true, "+7 item glows")
+ _assert(g7["glow_intensity"] == 0.6, "+7 intensity is 0.6")
+ _assert(g7["particle_rate"] == 12, "+7 particle rate is 12")
+
+ # +8 item
+ var item_plus8 := {"vnum": 10, "refine_level": 8, "sockets": []}
+ var g8 = sys.calculate_equipment_glow(item_plus8)
+ _assert(g8["glow_intensity"] == 1.2, "+8 intensity is 1.2")
+ _assert(g8["has_trail"] == true, "+8 has weapon swing trail")
+
+ # +9 item
+ var item_plus9 := {"vnum": 10, "refine_level": 9, "sockets": []}
+ var g9 = sys.calculate_equipment_glow(item_plus9)
+ _assert(g9["glow_intensity"] == 2.2, "+9 intensity is 2.2")
+ _assert(g9["swirl_enabled"] == true, "+9 has mythic swirling aura")
+ _assert(g9["particle_rate"] == 55, "+9 particle rate is 55")
+
+func _test_socket_stone_glow_blending() -> void:
+ print("\n--- Test 3: Socket Stones Color & Intensity Blending ---")
+ var sys := SocketGlowFxSystem.new()
+
+ var sig_data := {"fired": false, "vnum": 0, "intensity": 0.0}
+ sys.glow_profile_updated.connect(func(v: int, i: float, _c: Color):
+ sig_data["fired"] = true
+ sig_data["vnum"] = v
+ sig_data["intensity"] = i
+ )
+
+ # +9 Poison Sword with Warrior Stone (28430, Red)
+ var sword_red := {
+ "vnum": 189,
+ "refine_level": 9,
+ "sockets": [28430]
+ }
+ var g_red = sys.calculate_equipment_glow(sword_red)
+ _assert(g_red["is_glowing"] == true, "Glowing active")
+ _assert(g_red["glow_intensity"] > 2.2, "Intensity boosted by spirit stone (> 2.2)")
+ _assert(g_red["primary_color"].r > 0.9, "Warrior stone creates vivid red color")
+ _assert(sig_data["fired"] == true and sig_data["vnum"] == 189, "glow_profile_updated signal fired")
+
+ # +9 Sword with Sura Stone (28432, Dark Violet)
+ var sword_purple := {
+ "vnum": 189,
+ "refine_level": 9,
+ "sockets": [28432]
+ }
+ var g_purple = sys.calculate_equipment_glow(sword_purple)
+ _assert(g_purple["primary_color"].b > 0.9, "Sura stone creates deep violet/blue tone")
+
+ # Item with 3 stones: Red + Purple + Gold
+ var sword_tri := {
+ "vnum": 189,
+ "refine_level": 9,
+ "sockets": [28430, 28432, 28436]
+ }
+ var g_tri = sys.calculate_equipment_glow(sword_tri)
+ _assert(g_tri["stone_count"] == 3, "3 socket stones counted")
+ _assert(g_tri["secondary_color"] == SocketGlowFxSystem.STONE_COLORS[28430]["color"], "Secondary color tracks slot 0 primary stone")
+
+func _test_material_application() -> void:
+ print("\n--- Test 4: StandardMaterial3D Direct Injection ---")
+ var sys := SocketGlowFxSystem.new()
+ var mat := StandardMaterial3D.new()
+
+ var item_plus9 := {"vnum": 10, "refine_level": 9, "sockets": [28430]}
+ var glow = sys.calculate_equipment_glow(item_plus9)
+
+ sys.apply_to_material(mat, glow)
+ _assert(mat.emission_enabled == true, "Material emission enabled")
+ _assert(mat.emission == glow["primary_color"], "Emission color matches primary color")
+ _assert(is_equal_approx(mat.emission_energy_multiplier, glow["glow_intensity"]), "Energy multiplier matches glow intensity")
+
+ # Apply empty glow
+ sys.apply_to_material(mat, sys._empty_glow())
+ _assert(mat.emission_enabled == false, "Emission disabled for non-glowing item")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Socket Glow FX Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Socket Glow FX parity tests passed!")
+ quit(0)
diff --git a/project/test_socket_glow_fx_parity.gd.uid b/project/test_socket_glow_fx_parity.gd.uid
new file mode 100644
index 00000000..7770a063
--- /dev/null
+++ b/project/test_socket_glow_fx_parity.gd.uid
@@ -0,0 +1 @@
+uid://cws3gfse5gytr
diff --git a/project/test_solo_guild_domain_parity.gd b/project/test_solo_guild_domain_parity.gd
new file mode 100644
index 00000000..e3e5b496
--- /dev/null
+++ b/project/test_solo_guild_domain_parity.gd
@@ -0,0 +1,155 @@
+# test_solo_guild_domain_parity.gd —— 40250 个人公会领地与建筑建造 1:1 回归测试套件
+extends SceneTree
+
+const SoloGuildDomainSystem = preload("res://solo_guild_domain_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_solo_guild_domain_parity (Direction 2: Solo Guild Domain 1:1) ===")
+ _test_claim_domain()
+ _test_construct_and_upgrade_buildings()
+ _test_guild_blacksmith_refining()
+ _test_guardian_altar_passives()
+ _finish()
+
+func _test_claim_domain() -> void:
+ print("\n--- Test 1: Claiming Guild Domain ---")
+ var sys := SoloGuildDomainSystem.new()
+ var poor_player := {"gold": 1000}
+
+ # Fail on low gold
+ var fail_gold = sys.claim_domain("神圣殿堂", 10, poor_player)
+ _assert(fail_gold["ok"] == false and fail_gold["reason"] == "NOT_ENOUGH_GOLD", "Rejected: not enough gold")
+
+ # Fail on low guild level
+ var rich_player := {"gold": 50000000}
+ var fail_lvl = sys.claim_domain("神圣殿堂", 8, rich_player)
+ _assert(fail_lvl["ok"] == false and fail_lvl["reason"] == "GUILD_LEVEL_TOO_LOW", "Rejected: guild level < 10")
+
+ # Success
+ var claim_sig := {"fired": false, "name": "", "cost": 0}
+ sys.domain_claimed.connect(func(gname: String, cost: int):
+ claim_sig["fired"] = true
+ claim_sig["name"] = gname
+ claim_sig["cost"] = cost
+ )
+
+ var res = sys.claim_domain("神圣殿堂", 12, rich_player)
+ _assert(res["ok"] == true, "Claimed domain")
+ _assert(sys.has_domain == true, "has_domain is true")
+ _assert(rich_player["gold"] == 30000000, "Cost 20M deducted (50M -> 30M)")
+ _assert(claim_sig["fired"] == true and claim_sig["name"] == "神圣殿堂", "domain_claimed signal emitted")
+
+ # Duplicate claim rejected
+ var fail_dup = sys.claim_domain("再次开辟", 12, rich_player)
+ _assert(fail_dup["ok"] == false and fail_dup["reason"] == "ALREADY_HAS_DOMAIN", "Duplicate claim rejected")
+
+func _test_construct_and_upgrade_buildings() -> void:
+ print("\n--- Test 2: Constructing & Upgrading Buildings ---")
+ var sys := SoloGuildDomainSystem.new()
+ var player := {"gold": 50000000}
+ sys.claim_domain("东方荣耀", 10, player) # 50M -> 30M
+
+ var build_sig := {"fired": false, "btype": 0}
+ sys.building_constructed.connect(func(btype: int, _n: String, _l: int):
+ build_sig["fired"] = true
+ build_sig["btype"] = btype
+ )
+
+ # Construct Guild Blacksmith (10M gold)
+ var c_res = sys.construct_building(SoloGuildDomainSystem.BuildingType.BLACKSMITH, player)
+ _assert(c_res["ok"] == true, "Blacksmith constructed")
+ _assert(player["gold"] == 20000000, "Cost 10M deducted (30M -> 20M)")
+ _assert(sys.constructed_buildings.has(SoloGuildDomainSystem.BuildingType.BLACKSMITH), "Blacksmith in buildings list")
+ _assert(build_sig["fired"] == true and build_sig["btype"] == 1, "building_constructed emitted")
+
+ # Re-construct rejected
+ var fail_c = sys.construct_building(SoloGuildDomainSystem.BuildingType.BLACKSMITH, player)
+ _assert(fail_c["ok"] == false and fail_c["reason"] == "ALREADY_CONSTRUCTED", "Cannot build twice")
+
+ # Upgrade Blacksmith (Lv 1 -> Lv 2, cost: 8M)
+ var up_sig := {"fired": false, "lvl": 0}
+ sys.building_upgraded.connect(func(_b: int, lvl: int):
+ up_sig["fired"] = true
+ up_sig["lvl"] = lvl
+ )
+
+ var up_res = sys.upgrade_building(SoloGuildDomainSystem.BuildingType.BLACKSMITH, player)
+ _assert(up_res["ok"] == true and up_res["new_level"] == 2, "Upgraded Blacksmith to Lv 2")
+ _assert(up_sig["fired"] == true and up_sig["lvl"] == 2, "building_upgraded emitted")
+ _assert(player["gold"] == 12000000, "8M upgrade cost deducted (20M -> 12M)")
+
+func _test_guild_blacksmith_refining() -> void:
+ print("\n--- Test 3: Guild Blacksmith Enhanced Refining ---")
+ var sys := SoloGuildDomainSystem.new()
+ var player := {"gold": 50000000}
+ sys.claim_domain("锻造公会", 10, player)
+ sys.construct_building(SoloGuildDomainSystem.BuildingType.BLACKSMITH, player)
+
+ var item := {"vnum": 10, "name": "巨剑", "refine_level": 7}
+
+ var ref_sig := {"fired": false, "succ": false, "lvl": 0}
+ sys.guild_refine_result.connect(func(succ: bool, lvl: int):
+ ref_sig["fired"] = true
+ ref_sig["succ"] = succ
+ ref_sig["lvl"] = lvl
+ )
+
+ # Refine with force_success = true (+7 -> +8)
+ var res1 = sys.guild_blacksmith_refine(item, player, true)
+ _assert(res1["ok"] == true and res1["success"] == true, "Refined to +8")
+ _assert(item["refine_level"] == 8, "Item refine level is now 8")
+ _assert(ref_sig["fired"] == true and ref_sig["succ"] == true, "guild_refine_result emitted")
+
+ # Refine to +9
+ sys.guild_blacksmith_refine(item, player, true)
+ _assert(item["refine_level"] == 9, "Item refine level is now 9 (+9 max)")
+
+ # Further refine at +9 must fail
+ var fail_max = sys.guild_blacksmith_refine(item, player, true)
+ _assert(fail_max["ok"] == false and fail_max["reason"] == "ALREADY_MAX_REFINE", "Cannot refine past +9")
+
+func _test_guardian_altar_passives() -> void:
+ print("\n--- Test 4: Guild Guardian Altar Passive Auras ---")
+ var sys := SoloGuildDomainSystem.new()
+ var player := {"gold": 50000000}
+ sys.claim_domain("神坛公会", 10, player)
+
+ # No altar yet
+ _assert(sys.get_altar_passive_buffs().is_empty(), "No buffs without Altar")
+
+ # Build Guardian Altar (Lv 1)
+ sys.construct_building(SoloGuildDomainSystem.BuildingType.GUARDIAN_ALTAR, player)
+ var b1 = sys.get_altar_passive_buffs()
+ _assert(b1["max_hp"] == 1500, "Lv 1 Altar gives +1500 Max HP")
+ _assert(b1["exp_rate_pct"] == 10, "Lv 1 Altar gives +10% Exp")
+ _assert(b1["all_stats"] == 5, "Lv 1 Altar gives +5 All Stats")
+
+ # Upgrade Altar to Lv 2
+ sys.upgrade_building(SoloGuildDomainSystem.BuildingType.GUARDIAN_ALTAR, player)
+ var b2 = sys.get_altar_passive_buffs()
+ _assert(b2["max_hp"] == 3000, "Lv 2 Altar gives +3000 Max HP")
+ _assert(b2["exp_rate_pct"] == 20, "Lv 2 Altar gives +20% Exp")
+ _assert(b2["all_stats"] == 10, "Lv 2 Altar gives +10 All Stats")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Solo Guild Domain Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Solo Guild Domain parity tests passed!")
+ quit(0)
diff --git a/project/test_solo_guild_domain_parity.gd.uid b/project/test_solo_guild_domain_parity.gd.uid
new file mode 100644
index 00000000..1792e1eb
--- /dev/null
+++ b/project/test_solo_guild_domain_parity.gd.uid
@@ -0,0 +1 @@
+uid://gy6raqfblm3n
diff --git a/project/test_soul_stone_grandmaster_parity.gd b/project/test_soul_stone_grandmaster_parity.gd
new file mode 100644
index 00000000..35bc302f
--- /dev/null
+++ b/project/test_soul_stone_grandmaster_parity.gd
@@ -0,0 +1,123 @@
+# test_soul_stone_grandmaster_parity.gd —— Metin2 40250 灵魂石领悟宗师绝技 1:1 测试套件
+extends SceneTree
+
+const SoulStoneGrandmasterSystem = preload("res://soul_stone_grandmaster_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_soul_stone_grandmaster_parity (Direction 1: Soul Stone Grandmaster 1:1) ===")
+
+ test_eligibility_and_alignment_cost()
+ test_successful_training_and_p_tier()
+ test_failure_alignment_penalty()
+ test_cooldown_and_exorcism()
+ test_serialization()
+
+ print("\n=======================================================")
+ print("Soul Stone Grandmaster Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_eligibility_and_alignment_cost() -> void:
+ print("\n--- Test 1: Eligibility & Alignment Calculations (40250 1:1) ---")
+ var system = SoulStoneGrandmasterSystem.new()
+
+ # 检查 G 阶资格: 30(G1) ~ 39(G10) 允许,其他拒绝
+ _assert(system.can_train_skill(20) == false, "M-tier skill (Lv 20) not eligible for soul stone")
+ _assert(system.can_train_skill(30) == true, "G1 (Lv 30) eligible")
+ _assert(system.can_train_skill(39) == true, "G10 (Lv 39) eligible")
+ _assert(system.can_train_skill(40) == false, "P-tier (Lv 40) already maxed")
+
+ # 善恶值消耗计算: need_alignment = 1000 + 500 * (level - 30)
+ # 正善恶值玩家
+ _assert(system.get_required_alignment(30, 5000) == 1000, "G1 costs 1000 alignment")
+ _assert(system.get_required_alignment(31, 5000) == 1500, "G2 costs 1500 alignment")
+ _assert(system.get_required_alignment(39, 5000) == 5500, "G10 costs 5500 alignment")
+
+ # 负善恶值 (邪恶阵营) 翻倍惩罚 (40250 quest: need_alignment = need_alignment * 2)
+ _assert(system.get_required_alignment(30, -2000) == 2000, "G1 costs 2000 alignment for evil player")
+ _assert(system.get_required_alignment(39, -500) == 11000, "G10 costs 11000 alignment for evil player")
+
+func test_successful_training_and_p_tier() -> void:
+ print("\n--- Test 2: Successful Training & Reaching P (40250 1:1) ---")
+ var system = SoulStoneGrandmasterSystem.new()
+
+ var inv: Array = [
+ {"vnum": 50513, "count": 2} # 灵魂石 x2
+ ]
+
+ # 1. 成功从 G1 (30) 升到 G2 (31)
+ var res = system.train_grandmaster_skill(inv, 0, 1, "三重斩", 30, 8000, false, true)
+ _assert(res["ok"] == true and res["success"] == true, "Training succeeded")
+ _assert(res["new_level"] == 31, "Skill level promoted to 31 (G2)")
+ _assert(res["alignment_cost"] == 1000, "Deducted 1000 alignment")
+ _assert(res["new_alignment"] == 7000, "New alignment is 7000 (8000 - 1000)")
+ _assert(inv[0]["count"] == 1, "Consumed 1 Soul Stone")
+
+ # 清除冷却用于测试
+ system.use_exorcism_scroll()
+
+ # 2. 成功从 G10 (39) 登峰造极突破至 P 阶 (40)
+ res = system.train_grandmaster_skill(inv, 0, 1, "三重斩", 39, 10000, false, true)
+ _assert(res["ok"] == true and res["success"] == true, "Promoted to P tier")
+ _assert(res["new_level"] == 40, "Level reached 40")
+ _assert(res["is_perfect_master"] == true, "Reached Perfect Master!")
+ _assert(res["tier_text"] == "P 阶 (Perfect Master)", "Tier text is Perfect Master")
+
+func test_failure_alignment_penalty() -> void:
+ print("\n--- Test 3: Failure & Alignment Penalty (40250 1:1) ---")
+ var system = SoulStoneGrandmasterSystem.new()
+
+ var inv: Array = [{"vnum": 50513, "count": 1}]
+ # 强制失败 (roll = 90 > 30)
+ var res = system.train_grandmaster_skill(inv, 0, 2, "旋风斩", 30, 5000, false, false, 90)
+ _assert(res["ok"] == true and res["success"] == false, "Training failed")
+ _assert(res["new_level"] == 30, "Skill level unchanged at 30")
+ _assert(res["alignment_cost"] >= 330 and res["alignment_cost"] <= 500, "Deducted 1/3 to 1/2 penalty")
+ _assert(res["new_alignment"] < 5000, "Alignment penalised")
+ _assert(inv[0] == null, "Soul stone shattered and removed")
+
+func test_cooldown_and_exorcism() -> void:
+ print("\n--- Test 4: Cooldown & Exorcism Scroll (40250 1:1) ---")
+ var system = SoulStoneGrandmasterSystem.new()
+ var inv: Array = [{"vnum": 50513, "count": 2}]
+
+ system.train_grandmaster_skill(inv, 0, 1, "三重斩", 30, 8000, false, true)
+ _assert(system.training_cooldown_left > 0.0, "Cooldown active after training")
+
+ # 冷却期间再次参悟 -> 拦截
+ var res = system.train_grandmaster_skill(inv, 0, 1, "三重斩", 31, 7000, false, true)
+ _assert(res["ok"] == false, "Rejected during cooldown")
+ _assert(res["reason"] == "COOLDOWN_ACTIVE", "Reason is COOLDOWN_ACTIVE")
+
+ # 使用驱魔卷轴 (71001) 清除心境冷却
+ system.use_exorcism_scroll()
+ _assert(system.training_cooldown_left == 0.0, "Cooldown cleared by exorcism scroll")
+
+ # 再次参悟 -> 成功
+ res = system.train_grandmaster_skill(inv, 0, 1, "三重斩", 31, 7000, false, true)
+ _assert(res["ok"] == true, "Trained successfully after exorcism")
+
+func test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var system = SoulStoneGrandmasterSystem.new()
+ system.training_cooldown_left = 15000.0
+ var data = system.serialize()
+
+ var restored = SoulStoneGrandmasterSystem.new()
+ restored.deserialize(data)
+ _assert(restored.training_cooldown_left == 15000.0, "Restored cooldown")
diff --git a/project/test_soul_stone_grandmaster_parity.gd.uid b/project/test_soul_stone_grandmaster_parity.gd.uid
new file mode 100644
index 00000000..baf938e2
--- /dev/null
+++ b/project/test_soul_stone_grandmaster_parity.gd.uid
@@ -0,0 +1 @@
+uid://c5s06easynetm
diff --git a/project/test_soulbound_lock_security_parity.gd b/project/test_soulbound_lock_security_parity.gd
new file mode 100644
index 00000000..685ed956
--- /dev/null
+++ b/project/test_soulbound_lock_security_parity.gd
@@ -0,0 +1,138 @@
+# test_soulbound_lock_security_parity.gd —— 40250 灵魂绑定与防丢安全锁 1:1 回归测试套件
+extends SceneTree
+
+const SoulboundLockSecuritySystem = preload("res://soulbound_lock_security_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_soulbound_lock_security_parity (Direction 3: Soulbound Security 1:1) ===")
+ _test_security_predicates()
+ _test_soul_binding_flow()
+ _test_unbinding_and_cancellation()
+ _test_unbinding_completion()
+ _finish()
+
+func _test_security_predicates() -> void:
+ print("\n--- Test 1: Static Security Predicates (Death Drop, Sell, Stall) ---")
+ var normal_item := {"vnum": 10, "is_soulbound": false}
+ var bound_item := {"vnum": 189, "is_soulbound": true}
+
+ # Normal item can drop, sell, stall
+ _assert(SoulboundLockSecuritySystem.can_drop_on_death(normal_item) == true, "Normal item drops on red name death")
+ _assert(SoulboundLockSecuritySystem.can_sell_to_shop(normal_item) == true, "Normal item can be sold to shop")
+ _assert(SoulboundLockSecuritySystem.can_drop_to_ground(normal_item) == true, "Normal item can be dropped to ground")
+ _assert(SoulboundLockSecuritySystem.can_trade_or_stall(normal_item) == true, "Normal item can be traded or stalled")
+
+ # Soulbound item is 100% protected
+ _assert(SoulboundLockSecuritySystem.can_drop_on_death(bound_item) == false, "Soulbound item IMMUNE to death drop")
+ _assert(SoulboundLockSecuritySystem.can_sell_to_shop(bound_item) == false, "Soulbound item CANNOT be sold to shop")
+ _assert(SoulboundLockSecuritySystem.can_drop_to_ground(bound_item) == false, "Soulbound item CANNOT be dropped to ground")
+ _assert(SoulboundLockSecuritySystem.can_trade_or_stall(bound_item) == false, "Soulbound item CANNOT be put on bazaar stalls")
+
+func _test_soul_binding_flow() -> void:
+ print("\n--- Test 2: Applying Soul Binding (72051) ---")
+ var sys := SoulboundLockSecuritySystem.new()
+ var inv: Array = [
+ {"vnum": SoulboundLockSecuritySystem.VNUM_SOULBIND_SCROLL, "name": "灵魂绑定卷轴", "count": 2},
+ {"vnum": 189, "name": "毒剑+9", "is_soulbound": false}
+ ]
+
+ var bind_sig := {"fired": false, "slot": -1, "name": ""}
+ sys.item_soulbound.connect(func(s: int, iname: String):
+ bind_sig["fired"] = true
+ bind_sig["slot"] = s
+ bind_sig["name"] = iname
+ )
+
+ var res = sys.bind_item(0, 1, inv)
+ _assert(res["ok"] == true, "Item soulbound")
+ _assert(inv[1]["is_soulbound"] == true, "is_soulbound is true")
+ _assert(inv[0]["count"] == 1, "Scroll count decreased (2 -> 1)")
+ _assert(bind_sig["fired"] == true and bind_sig["slot"] == 1 and bind_sig["name"] == "毒剑+9", "item_soulbound emitted")
+
+ # Duplicate binding rejected
+ var fail_dup = sys.bind_item(0, 1, inv)
+ _assert(fail_dup["ok"] == false and fail_dup["reason"] == "ALREADY_SOULBOUND", "Duplicate binding rejected")
+ _assert(inv[0]["count"] == 1, "Scroll not consumed on failure")
+
+func _test_unbinding_and_cancellation() -> void:
+ print("\n--- Test 3: Unbinding Initiation (72052) & Cancellation ---")
+ var sys := SoulboundLockSecuritySystem.new()
+ var inv: Array = [
+ {"vnum": SoulboundLockSecuritySystem.VNUM_SOULUNBIND_SCROLL, "name": "灵魂解绑卷轴", "count": 1},
+ {"vnum": 189, "name": "毒剑+9", "is_soulbound": true}
+ ]
+
+ var unbind_start_sig := {"fired": false, "time": 0.0}
+ sys.unbinding_started.connect(func(_s: int, _n: String, t: float):
+ unbind_start_sig["fired"] = true
+ unbind_start_sig["time"] = t
+ )
+
+ # Start unbinding (72 hours)
+ var res = sys.start_unbinding(0, 1, inv, 72.0)
+ _assert(res["ok"] == true, "Unbinding started")
+ _assert(inv[0] == null, "Unbind scroll consumed")
+ _assert(unbind_start_sig["fired"] == true and unbind_start_sig["time"] == 72.0 * 3600.0, "unbinding_started emitted with 72h")
+ _assert(inv[1]["unbind_remaining_time"] == 72.0 * 3600.0, "Cooldown active")
+
+ # Cancel unbinding
+ var cancel_sig := {"fired": false}
+ sys.unbinding_cancelled.connect(func(_s: int, _n: String): cancel_sig["fired"] = true)
+
+ var cancel_res = sys.cancel_unbinding(1, inv)
+ _assert(cancel_res["ok"] == true, "Unbinding cancelled")
+ _assert(cancel_sig["fired"] == true, "unbinding_cancelled emitted")
+ _assert(inv[1]["unbind_remaining_time"] == 0.0, "Timer cleared")
+ _assert(inv[1]["is_soulbound"] == true, "Item remains safely soulbound")
+
+func _test_unbinding_completion() -> void:
+ print("\n--- Test 4: Finalizing Unbinding After Cooldown ---")
+ var sys := SoulboundLockSecuritySystem.new()
+ var inv: Array = [
+ {"vnum": SoulboundLockSecuritySystem.VNUM_SOULUNBIND_SCROLL, "count": 1},
+ {"vnum": 189, "name": "毒剑+9", "is_soulbound": true}
+ ]
+
+ # Start short 1-second unbind for testing
+ sys.start_unbinding(0, 1, inv, 1.0 / 3600.0) # 1 second
+
+ # Immediate finalize attempt should fail
+ var fail_early = sys.finalize_unbinding(1, inv)
+ _assert(fail_early["ok"] == false and fail_early["reason"] == "COOLDOWN_NOT_FINISHED", "Cannot finalize before timer")
+
+ # Update 2 seconds
+ sys.update(2.0, inv)
+ _assert(inv[1]["unbind_remaining_time"] == 0.0, "Timer expired")
+
+ # Finalize unbind
+ var unbound_sig := {"fired": false}
+ sys.item_unbound.connect(func(_s: int, _n: String): unbound_sig["fired"] = true)
+
+ var done_res = sys.finalize_unbinding(1, inv)
+ _assert(done_res["ok"] == true, "Item unbound")
+ _assert(unbound_sig["fired"] == true, "item_unbound emitted")
+ _assert(inv[1]["is_soulbound"] == false, "Item is no longer soulbound")
+ _assert(SoulboundLockSecuritySystem.can_drop_on_death(inv[1]) == true, "Now subject to normal drop rules")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Soulbound Security Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Soulbound Security parity tests passed!")
+ quit(0)
diff --git a/project/test_soulbound_lock_security_parity.gd.uid b/project/test_soulbound_lock_security_parity.gd.uid
new file mode 100644
index 00000000..a2166406
--- /dev/null
+++ b/project/test_soulbound_lock_security_parity.gd.uid
@@ -0,0 +1 @@
+uid://6n52fdihhboe
diff --git a/project/test_spider_dungeon_key_parity.gd b/project/test_spider_dungeon_key_parity.gd
new file mode 100644
index 00000000..5a2fafd0
--- /dev/null
+++ b/project/test_spider_dungeon_key_parity.gd
@@ -0,0 +1,189 @@
+# test_spider_dungeon_key_parity.gd —— 40250 蜘蛛洞穴通行证与蛛后巢穴密匙系统 1:1 回归测试套件
+extends SceneTree
+
+const SpiderDungeonKeySystem = preload("res://spider_dungeon_key_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_spider_dungeon_key_parity (Direction 4: Spider Dungeon Key 1:1) ===")
+ _test_passage_ticket_entry()
+ _test_arachnid_key_crafting()
+ _test_baroness_lair_and_boss_fight()
+ _test_lair_timeout()
+ _test_serialization()
+ _finish()
+
+func _test_passage_ticket_entry() -> void:
+ print("\n--- Test 1: Passage Ticket (71095) Entry to SD2 & SD3 ---")
+ var sys := SpiderDungeonKeySystem.new()
+ var inv: Array = [
+ {"vnum": SpiderDungeonKeySystem.VNUM_PASSAGE_TICKET, "name": "蛛洞通行证", "count": 2}
+ ]
+
+ # 1. Level too low (< 40 for SD2)
+ var res_low = sys.enter_spider_dungeon(2, 35, inv)
+ _assert(res_low["ok"] == false, "Entry rejected: level 35 < 40")
+ _assert(res_low["reason"] == "LEVEL_TOO_LOW", "Reason is LEVEL_TOO_LOW")
+
+ var entry_sig := {"fired": false, "floor": 0, "map": ""}
+ sys.ticket_consumed_entry_granted.connect(func(f: int, m: String):
+ entry_sig["fired"] = true
+ entry_sig["floor"] = f
+ entry_sig["map"] = m
+ )
+
+ # 2. Enter SD2 (Lv 45)
+ var res_sd2 = sys.enter_spider_dungeon(2, 45, inv)
+ _assert(res_sd2["ok"] == true, "Entered SD2 successfully")
+ _assert(inv[0]["count"] == 1, "Ticket count decremented 2 -> 1")
+ _assert(entry_sig["fired"] == true, "ticket_consumed_entry_granted signal emitted")
+ _assert(entry_sig["floor"] == 2 and entry_sig["map"] == "Spider_Dungeon_2", "Floor 2 reported")
+
+ # 3. Enter SD3 (Lv 55)
+ var res_sd3 = sys.enter_spider_dungeon(3, 55, inv)
+ _assert(res_sd3["ok"] == true, "Entered SD3 successfully")
+ _assert(inv[0] == null, "Last ticket consumed completely")
+ _assert(entry_sig["floor"] == 3 and entry_sig["map"] == "Spider_Dungeon_3", "Floor 3 reported")
+
+ # 4. Attempt enter without ticket
+ var res_no_ticket = sys.enter_spider_dungeon(2, 50, inv)
+ _assert(res_no_ticket["ok"] == false, "Entry rejected without ticket")
+ _assert(res_no_ticket["reason"] == "NO_PASSAGE_TICKET", "Reason is NO_PASSAGE_TICKET")
+
+func _test_arachnid_key_crafting() -> void:
+ print("\n--- Test 2: Crafting Arachnid Key (30324) at Scholar Uriel ---")
+ var sys := SpiderDungeonKeySystem.new()
+ var player_gold := {"gold": 100000}
+ var inv: Array = [
+ {"vnum": SpiderDungeonKeySystem.VNUM_SPIDER_POISON_SACK, "name": "蜘蛛毒囊", "count": 1},
+ {"vnum": SpiderDungeonKeySystem.VNUM_SPIDER_EGG_SACK, "name": "蜘蛛卵囊", "count": 1},
+ {"vnum": SpiderDungeonKeySystem.VNUM_SPIDER_EYES, "name": "蜘蛛眼睛", "count": 1},
+ {"vnum": SpiderDungeonKeySystem.VNUM_SPIDER_LEGS, "name": "蜘蛛腿", "count": 1}
+ ]
+
+ var craft_sig := {"fired": false, "count": 0}
+ sys.arachnid_key_crafted.connect(func(c: int):
+ craft_sig["fired"] = true
+ craft_sig["count"] = c
+ )
+
+ # 1. Missing Gold test
+ player_gold["gold"] = 1000
+ var res_no_gold = sys.craft_arachnid_key(inv, player_gold)
+ _assert(res_no_gold["ok"] == false, "Crafting rejected: gold 1000 < 50000")
+ _assert(res_no_gold["reason"] == "NOT_ENOUGH_GOLD", "Reason is NOT_ENOUGH_GOLD")
+
+ # 2. Successful craft with 50,000 gold
+ player_gold["gold"] = 80000
+ var res_craft = sys.craft_arachnid_key(inv, player_gold)
+ _assert(res_craft["ok"] == true, "Crafted Arachnid Key successfully")
+ _assert(player_gold["gold"] == 30000, "50,000 gold deducted (80000 -> 30000)")
+ _assert(craft_sig["fired"] == true, "arachnid_key_crafted signal emitted")
+
+ # Verify key exists in inventory
+ var key_found := false
+ for it in inv:
+ if it != null and int(it.get("vnum", 0)) == SpiderDungeonKeySystem.VNUM_ARACHNID_KEY:
+ key_found = true
+ break
+ _assert(key_found == true, "Arachnid Key (30324) generated in inventory")
+
+func _test_baroness_lair_and_boss_fight() -> void:
+ print("\n--- Test 3: Baroness Lair Entry, Boss Slay & Rewards ---")
+ var sys := SpiderDungeonKeySystem.new()
+ var inv: Array = [
+ {"vnum": SpiderDungeonKeySystem.VNUM_ARACHNID_KEY, "name": "蛛网密匙", "count": 1},
+ null, null, null
+ ]
+
+ var open_sig := {"fired": false, "dur": 0.0}
+ sys.baroness_lair_opened.connect(func(d: float):
+ open_sig["fired"] = true
+ open_sig["dur"] = d
+ )
+
+ var slay_sig := {"fired": false, "drops": []}
+ sys.baroness_slain.connect(func(d: Array):
+ slay_sig["fired"] = true
+ slay_sig["drops"] = d
+ )
+
+ # 1. Open Lair with key
+ var res_open = sys.open_baroness_lair(55, inv)
+ _assert(res_open["ok"] == true, "Baroness Lair opened")
+ _assert(sys.is_lair_active == true, "Lair is active")
+ _assert(sys.baroness_alive == true, "Spider Baroness is alive")
+ _assert(inv[0] == null, "Arachnid key consumed")
+ _assert(open_sig["fired"] == true, "baroness_lair_opened signal emitted")
+ _assert(open_sig["dur"] == 1200.0, "Lair duration is 1200s (20 min)")
+
+ # 2. Slay Baroness
+ var res_slay = sys.slay_baroness(inv)
+ _assert(res_slay["ok"] == true, "Baroness defeated")
+ _assert(sys.is_lair_active == false, "Lair closed after victory")
+ _assert(slay_sig["fired"] == true, "baroness_slain signal emitted")
+
+ # Check drops in inventory
+ var chest_found := false
+ for it in inv:
+ if it != null and int(it.get("vnum", 0)) == SpiderDungeonKeySystem.VNUM_CHEST_BARONESS:
+ chest_found = true
+ break
+ _assert(chest_found == true, "King Spider Chest (50181) received in inventory")
+
+func _test_lair_timeout() -> void:
+ print("\n--- Test 4: Lair Timeout Failure ---")
+ var sys := SpiderDungeonKeySystem.new()
+ var inv: Array = [
+ {"vnum": SpiderDungeonKeySystem.VNUM_ARACHNID_KEY, "name": "蛛网密匙", "count": 1}
+ ]
+ sys.open_baroness_lair(55, inv)
+
+ var close_sig := {"fired": false, "succ": true}
+ sys.lair_closed.connect(func(s: bool):
+ close_sig["fired"] = true
+ close_sig["succ"] = s
+ )
+
+ # Advance time by 1200s
+ sys.update(1200.0)
+ _assert(sys.is_lair_active == false, "Lair closed upon timeout")
+ _assert(close_sig["fired"] == true, "lair_closed signal emitted")
+ _assert(close_sig["succ"] == false, "Failed challenge due to timeout")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := SpiderDungeonKeySystem.new()
+ var inv: Array = [
+ {"vnum": SpiderDungeonKeySystem.VNUM_ARACHNID_KEY, "name": "蛛网密匙", "count": 1}
+ ]
+ sys1.open_baroness_lair(55, inv)
+ sys1.update(300.0) # 900s left
+
+ var data = sys1.serialize()
+ _assert(data["is_lair_active"] == true, "Serialized is_lair_active")
+
+ var sys2 := SpiderDungeonKeySystem.new()
+ sys2.deserialize(data)
+ _assert(sys2.is_lair_active == true, "Restored is_lair_active")
+ _assert(sys2.baroness_alive == true, "Restored baroness_alive")
+ _assert(is_equal_approx(sys2.lair_time_remaining, 900.0), "Restored time remaining 900s")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Spider Dungeon Key Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_spider_dungeon_key_parity.gd.uid b/project/test_spider_dungeon_key_parity.gd.uid
new file mode 100644
index 00000000..5770030d
--- /dev/null
+++ b/project/test_spider_dungeon_key_parity.gd.uid
@@ -0,0 +1 @@
+uid://cq4wdwkt712jm
diff --git a/project/test_spider_dungeon_parity.gd b/project/test_spider_dungeon_parity.gd
new file mode 100644
index 00000000..5ed2dad3
--- /dev/null
+++ b/project/test_spider_dungeon_parity.gd
@@ -0,0 +1,134 @@
+# test_spider_dungeon_parity.gd —— 40250 蜘蛛男爵夫人巢穴 1:1 纯净回归测试套件
+extends SceneTree
+
+const SpiderDungeonSystem = preload("res://spider_dungeon_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_spider_dungeon_parity (Direction 1: Spider Dungeon 3 & Baroness 1:1) ===")
+ _test_entry_and_key_consumption()
+ _test_egg_frenzy_mechanics()
+ _test_combat_and_minions_summon()
+ _test_baroness_kill_and_drops()
+ _test_timeout_failure()
+ _finish()
+
+func _test_entry_and_key_consumption() -> void:
+ print("\n--- Test 1: Entry Level & Arachnid Key (30327) ---")
+ var sds := SpiderDungeonSystem.new()
+ var inv: Array = [
+ {"vnum": 30327}, # Arachnid Key
+ null
+ ]
+
+ # Level 40 rejected (requires 50)
+ var res = sds.enter_dungeon(40, inv)
+ _assert(res["ok"] == false and res["reason"] == "LEVEL_TOO_LOW", "Level < 50 rejected")
+
+ # Missing key rejected
+ res = sds.enter_dungeon(50, [null])
+ _assert(res["ok"] == false and res["reason"] == "MISSING_KEY", "Missing Arachnid Key rejected")
+
+ # Valid entry succeeds
+ res = sds.enter_dungeon(55, inv)
+ _assert(res["ok"] == true, "Entry succeeds with Level 55 and key")
+ _assert(inv[0] == null, "Arachnid key consumed from inventory")
+ _assert(sds.is_active == true, "Dungeon is now active")
+ _assert(sds.remaining_time == 1200.0, "20-minute countdown initialized")
+ _assert(sds.eggs_remaining == 4, "4 spider eggs present")
+
+func _test_egg_frenzy_mechanics() -> void:
+ print("\n--- Test 2: Egg Frenzy Mechanics (-DEF, +ATK) ---")
+ var sds := SpiderDungeonSystem.new()
+ sds.enter_dungeon(60, [{"vnum": 30327}])
+
+ _assert(sds.baroness_def == 400, "Initial Baroness DEF is 400")
+ _assert(sds.baroness_atk == 800, "Initial Baroness ATK is 800")
+
+ # Destroy 1st egg: -15% DEF (340), +20% ATK (960)
+ var res = sds.destroy_egg()
+ _assert(res["ok"] == true, "1st egg destroyed")
+ _assert(sds.baroness_def == 340, "Baroness DEF reduced to 340 (-15%)")
+ _assert(sds.baroness_atk == 960, "Baroness ATK increased to 960 (+20%)")
+
+ # Destroy 2nd egg: -30% DEF (280), +40% ATK (1120)
+ sds.destroy_egg()
+ _assert(sds.baroness_def == 280, "2 eggs broken: DEF 280")
+ _assert(sds.baroness_atk == 1120, "2 eggs broken: ATK 1120")
+
+ # Destroy 3rd egg: -45% DEF (220), +60% ATK (1280)
+ sds.destroy_egg()
+ _assert(sds.baroness_def == 220, "3 eggs broken: DEF 220")
+
+ # Destroy 4th egg: -60% DEF (160), +80% ATK (1440)
+ sds.destroy_egg()
+ _assert(sds.baroness_def == 160, "All 4 eggs broken: DEF down to 160 (-60%)")
+ _assert(sds.baroness_atk == 1440, "All 4 eggs broken: ATK up to 1440 (+80%)")
+
+ # 5th egg attempt rejected
+ res = sds.destroy_egg()
+ _assert(res["ok"] == false and res["reason"] == "NO_EGGS_LEFT", "Cannot destroy nonexistent egg")
+
+func _test_combat_and_minions_summon() -> void:
+ print("\n--- Test 3: Combat Damage & 50% HP Minions Wave ---")
+ var sds := SpiderDungeonSystem.new()
+ sds.enter_dungeon(60, [{"vnum": 30327}])
+ # Break 4 eggs so defense is 160
+ for i in range(4): sds.destroy_egg()
+
+ # Deal 50,000 raw damage -> effective damage is 50000 - 160 = 49840
+ var res = sds.damage_baroness(50000)
+ _assert(res["ok"] == true, "Damaged Baroness")
+ _assert(res["damage_dealt"] == 49840, "Effective damage calculated after 160 DEF")
+ _assert(sds.minions_spawned == false, "Minions not yet spawned above 50% HP")
+
+ # Deal another 80,000 damage -> drops below 50% (125,000)
+ res = sds.damage_baroness(80000)
+ _assert(sds.baroness_hp <= 125000, "HP dropped below 50%")
+ _assert(sds.minions_spawned == true, "50% HP triggered minions wave")
+ _assert(res["minions_summoned"] == true, "Minions summon event reported")
+
+func _test_baroness_kill_and_drops() -> void:
+ print("\n--- Test 4: Baroness Kill & Baroness Chest Drop ---")
+ var sds := SpiderDungeonSystem.new()
+ sds.enter_dungeon(60, [{"vnum": 30327}])
+
+ # Deal lethal damage
+ var res = sds.damage_baroness(300000)
+ _assert(res["ok"] == true and res["dead"] == true, "Baroness slain")
+ _assert(sds.dungeon_completed == true, "Dungeon cleared")
+ _assert(sds.is_active == false, "Dungeon deactivated")
+
+ var drops: Array[Dictionary] = res["drops"]
+ _assert(drops.size() == 2, "2 items dropped")
+ _assert(drops[0]["vnum"] == SpiderDungeonSystem.ITEM_BARONESS_CHEST, "Dropped Baroness Chest (50157)")
+ _assert(drops[1]["vnum"] == SpiderDungeonSystem.ITEM_TITAN_SHIELD, "Dropped Titan Shield (13129)")
+
+func _test_timeout_failure() -> void:
+ print("\n--- Test 5: Timeout Failure ---")
+ var sds := SpiderDungeonSystem.new()
+ sds.enter_dungeon(60, [{"vnum": 30327}])
+
+ # Update 1201s (past 1200s limit)
+ var res = sds.update(1201.0)
+ _assert(res["failed"] == true and res["reason"] == "TIME_OUT", "Dungeon failed on timeout")
+ _assert(sds.is_active == false, "Dungeon marked inactive")
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_spider_dungeon_parity.gd.uid b/project/test_spider_dungeon_parity.gd.uid
new file mode 100644
index 00000000..d6dda73d
--- /dev/null
+++ b/project/test_spider_dungeon_parity.gd.uid
@@ -0,0 +1 @@
+uid://b4rxtexijhmc0
diff --git a/project/test_status_skill_reset_parity.gd b/project/test_status_skill_reset_parity.gd
new file mode 100644
index 00000000..9567090a
--- /dev/null
+++ b/project/test_status_skill_reset_parity.gd
@@ -0,0 +1,167 @@
+# test_status_skill_reset_parity.gd —— 40250 属性重修卷轴与忘却之书洗点 1:1 回归测试套件
+extends SceneTree
+
+const StatusSkillResetSystem = preload("res://status_skill_reset_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_status_skill_reset_parity (Direction 2: Status & Skill Reset 1:1) ===")
+ _test_single_status_reset()
+ _test_all_status_reset()
+ _test_book_of_oblivion()
+ _test_all_skills_reset()
+ _finish()
+
+func _test_single_status_reset() -> void:
+ print("\n--- Test 1: Single Status Redistribution (71002) ---")
+ var sys := StatusSkillResetSystem.new()
+ var player := {
+ "job": "warrior",
+ "vit": 16,
+ "int": 3,
+ "str": 50, # Base is 15 -> 35 points added
+ "dex": 5,
+ "unused_status_points": 0
+ }
+ var inv: Array = [
+ {"vnum": StatusSkillResetSystem.VNUM_STATUS_REDIST_SCROLL, "name": "属性重修卷轴", "count": 2}
+ ]
+
+ var reset_sig := {"fired": false, "stat": "", "refunded": 0}
+ sys.status_reset.connect(func(s: String, r: int):
+ reset_sig["fired"] = true
+ reset_sig["stat"] = s
+ reset_sig["refunded"] = r
+ )
+
+ # Reset STR
+ var res_str = sys.reset_single_status("str", player, inv, 0)
+ _assert(res_str["ok"] == true, "STR reset successfully")
+ _assert(player["str"] == 15, "STR returned to warrior base 15")
+ _assert(player["unused_status_points"] == 35, "35 points refunded to unused pool")
+ _assert(inv[0]["count"] == 1, "Scroll count decremented 2 -> 1")
+ _assert(reset_sig["fired"] == true, "status_reset signal emitted")
+ _assert(reset_sig["stat"] == "str" and reset_sig["refunded"] == 35, "Signal reported correct stat & points")
+
+ # Try reset again when already at base
+ var res_base = sys.reset_single_status("str", player, inv, 0)
+ _assert(res_base["ok"] == false, "Reset rejected when already at base")
+ _assert(res_base["reason"] == "STAT_ALREADY_AT_BASE", "Reason is STAT_ALREADY_AT_BASE")
+
+func _test_all_status_reset() -> void:
+ print("\n--- Test 2: All Status Reset (Blood Pill 71003) ---")
+ var sys := StatusSkillResetSystem.new()
+ var player := {
+ "job": "warrior",
+ "vit": 36, # Base 16 (+20)
+ "int": 3, # Base 3 (+0)
+ "str": 40, # Base 15 (+25)
+ "dex": 15, # Base 5 (+10)
+ "unused_status_points": 5 # total extra = 20 + 25 + 10 = 55
+ }
+ var inv: Array = [
+ {"vnum": StatusSkillResetSystem.VNUM_ALL_STATUS_RESET_SCROLL, "name": "全属性重置卷轴", "count": 1}
+ ]
+
+ var all_sig := {"fired": false, "refunded": 0}
+ sys.all_status_reset.connect(func(r: int):
+ all_sig["fired"] = true
+ all_sig["refunded"] = r
+ )
+
+ var res_all = sys.reset_all_status(player, inv, 0)
+ _assert(res_all["ok"] == true, "All status reset succeeded")
+ _assert(player["vit"] == 16, "VIT back to base 16")
+ _assert(player["str"] == 15, "STR back to base 15")
+ _assert(player["dex"] == 5, "DEX back to base 5")
+ _assert(player["unused_status_points"] == 60, "Refunded 55 points (5 existing + 55 = 60 total)")
+ _assert(inv[0] == null, "Scroll consumed from slot 0")
+ _assert(all_sig["fired"] == true, "all_status_reset signal emitted")
+ _assert(all_sig["refunded"] == 55, "Reported 55 refunded points")
+
+func _test_book_of_oblivion() -> void:
+ print("\n--- Test 3: Book of Oblivion (50311~50320) Single Skill Point Reset ---")
+ var sys := StatusSkillResetSystem.new()
+ var player_skills := {
+ 1: 16, # 三连斩 Lv 16
+ 2: 0, # 旋风斩 Lv 0
+ 3: 20 # 剑气 Master (M1)
+ }
+ var player_data := {"unused_skill_points": 0}
+ var inv: Array = [
+ {"vnum": 50311, "name": "忘却之书(三连斩)", "target_skill": 1, "count": 2},
+ {"vnum": 50312, "name": "忘却之书(旋风斩)", "target_skill": 2, "count": 1},
+ {"vnum": 50313, "name": "忘却之书(剑气)", "target_skill": 3, "count": 1}
+ ]
+
+ var sk_sig := {"fired": false, "id": 0, "new_lv": 0}
+ sys.skill_point_reset.connect(func(id: int, nlv: int):
+ sk_sig["fired"] = true
+ sk_sig["id"] = id
+ sk_sig["new_lv"] = nlv
+ )
+
+ # 1. Reset Skill 1 (16 -> 15)
+ var res1 = sys.reset_single_skill_point(1, player_skills, player_data, inv, 0)
+ _assert(res1["ok"] == true, "Skill 1 point reset succeeded")
+ _assert(player_skills[1] == 15, "Skill 1 level reduced 16 -> 15")
+ _assert(player_data["unused_skill_points"] == 1, "1 skill point refunded")
+ _assert(inv[0]["count"] == 1, "Book count decremented 2 -> 1")
+ _assert(sk_sig["fired"] == true, "skill_point_reset signal emitted")
+ _assert(sk_sig["id"] == 1 and sk_sig["new_lv"] == 15, "Signal reported skill 1 lv 15")
+
+ # 2. Skill level 0 cannot be reset
+ var res_zero = sys.reset_single_skill_point(2, player_skills, player_data, inv, 1)
+ _assert(res_zero["ok"] == false, "Skill lv 0 rejected")
+ _assert(res_zero["reason"] == "SKILL_LEVEL_ZERO", "Reason is SKILL_LEVEL_ZERO")
+
+ # 3. Master skill (>= 20) cannot be downgraded by Book of Oblivion (40250 rule)
+ var res_master = sys.reset_single_skill_point(3, player_skills, player_data, inv, 2)
+ _assert(res_master["ok"] == false, "Master skill rejected")
+ _assert(res_master["reason"] == "SKILL_IS_MASTER", "Reason is SKILL_IS_MASTER")
+
+func _test_all_skills_reset() -> void:
+ print("\n--- Test 4: All Skills Reset Scroll (71001) ---")
+ var sys := StatusSkillResetSystem.new()
+ var player_skills := {
+ 1: 15,
+ 2: 18,
+ 3: 7
+ } # Total invested: 15 + 18 + 7 = 40
+ var player_data := {"unused_skill_points": 2}
+ var inv: Array = [
+ {"vnum": StatusSkillResetSystem.VNUM_SKILL_RESET_SCROLL, "name": "技能重置卷轴", "count": 1}
+ ]
+
+ var all_sk_sig := {"fired": false, "refunded": 0}
+ sys.all_skills_reset.connect(func(r: int):
+ all_sk_sig["fired"] = true
+ all_sk_sig["refunded"] = r
+ )
+
+ var res_all = sys.reset_all_skills(player_skills, player_data, inv, 0)
+ _assert(res_all["ok"] == true, "All skills reset succeeded")
+ _assert(player_skills[1] == 0 and player_skills[2] == 0 and player_skills[3] == 0, "All skills cleared to 0")
+ _assert(player_data["unused_skill_points"] == 42, "Refunded 40 points (2 existing + 40 = 42 total)")
+ _assert(inv[0] == null, "Scroll consumed")
+ _assert(all_sk_sig["fired"] == true, "all_skills_reset signal emitted")
+ _assert(all_sk_sig["refunded"] == 40, "Signal reported 40 refunded points")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Status & Skill Reset Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_status_skill_reset_parity.gd.uid b/project/test_status_skill_reset_parity.gd.uid
new file mode 100644
index 00000000..d7f0647a
--- /dev/null
+++ b/project/test_status_skill_reset_parity.gd.uid
@@ -0,0 +1 @@
+uid://chx2btpt8bfsj
diff --git a/project/test_system_setting_parity.gd b/project/test_system_setting_parity.gd
new file mode 100644
index 00000000..f6ae6a6d
--- /dev/null
+++ b/project/test_system_setting_parity.gd
@@ -0,0 +1,116 @@
+# test_system_setting_parity.gd —— 40250 系统视听与战斗选项 1:1 纯净回归测试套件
+extends SceneTree
+
+const SystemSettingSystem = preload("res://system_setting_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_system_setting_parity (Direction 1: System Setting & PK Mode 1:1) ===")
+ _test_audio_and_display_settings()
+ _test_pk_modes_and_rules()
+ _test_import_export_settings()
+ _finish()
+
+func _test_audio_and_display_settings() -> void:
+ print("\n--- Test 1: Audio & Display Settings Clamping & Signals ---")
+ var sss := SystemSettingSystem.new()
+
+ var signal_data := {"fired": false, "name": "", "val": null}
+ sss.setting_changed.connect(func(n: String, v: Variant):
+ signal_data["fired"] = true
+ signal_data["name"] = n
+ signal_data["val"] = v
+ )
+
+ # BGM volume clamping (1.5 -> 1.0)
+ sss.bgm_volume = 1.5
+ _assert(sss.bgm_volume == 1.0, "BGM volume clamped to 1.0")
+ _assert(signal_data["fired"] == true and signal_data["name"] == "bgm_volume", "setting_changed signal fired for bgm")
+
+ # SFX volume clamping (-0.2 -> 0.0)
+ sss.sfx_volume = -0.2
+ _assert(sss.sfx_volume == 0.0, "SFX volume clamped to 0.0")
+
+ # Camera distance clamping (50.0 -> 40.0)
+ sss.camera_distance = 50.0
+ _assert(sss.camera_distance == 40.0, "Camera distance clamped to 40.0")
+
+ # Fog & Shadow levels
+ sss.fog_level = 2
+ _assert(sss.fog_level == 2, "Fog level is 2")
+ sss.shadow_quality = 0
+ _assert(sss.shadow_quality == 0, "Shadow quality is 0")
+
+func _test_pk_modes_and_rules() -> void:
+ print("\n--- Test 2: 4 PK Modes & Combat Rule Matrix ---")
+ var sss := SystemSettingSystem.new()
+
+ var pk_signal := {"fired": false, "mode": -1, "name": ""}
+ sss.pk_mode_changed.connect(func(m: int, n: String):
+ pk_signal["fired"] = true
+ pk_signal["mode"] = m
+ pk_signal["name"] = n
+ )
+
+ # 1. Monster target is ALWAYS attackable
+ _assert(sss.can_attack_target(false) == true, "Monsters always attackable in Peace mode")
+
+ # 2. PEACE Mode (Default)
+ _assert(sss.can_attack_target(true, 5000) == false, "Cannot attack positive karma player in Peace mode")
+ _assert(sss.can_attack_target(true, -5000) == false, "Cannot attack red name player in Peace mode")
+
+ # 3. REVENGE Mode (善恶反击)
+ sss.set_pk_mode(SystemSettingSystem.PkMode.REVENGE)
+ _assert(pk_signal["fired"] == true and pk_signal["mode"] == SystemSettingSystem.PkMode.REVENGE, "pk_mode_changed signal fired")
+ _assert(sss.can_attack_target(true, 5000) == false, "Cannot attack positive karma player in Revenge mode")
+ _assert(sss.can_attack_target(true, -5000) == true, "CAN attack red name player in Revenge mode")
+
+ # 4. GUILD Mode (阵营保护)
+ sss.set_pk_mode(SystemSettingSystem.PkMode.GUILD)
+ _assert(sss.can_attack_target(true, 5000, 100, 100) == false, "Cannot attack same guild player (guild 100 vs 100)")
+ _assert(sss.can_attack_target(true, 5000, 101, 100) == true, "CAN attack enemy guild player (guild 101 vs 100)")
+
+ # 5. FREE Mode (自由对决)
+ sss.set_pk_mode(SystemSettingSystem.PkMode.FREE)
+ _assert(sss.can_attack_target(true, 5000) == true, "Can attack anyone in Free mode")
+
+func _test_import_export_settings() -> void:
+ print("\n--- Test 3: Import & Export Configuration Dictionary ---")
+ var sss := SystemSettingSystem.new()
+ sss.bgm_volume = 0.5
+ sss.sfx_volume = 0.6
+ sss.camera_distance = 32.0
+ sss.set_pk_mode(SystemSettingSystem.PkMode.REVENGE)
+ sss.show_critical_hit = false
+
+ var exported := sss.export_settings()
+ _assert(exported["bgm_volume"] == 0.5, "Exported BGM volume 0.5")
+ _assert(exported["current_pk_mode"] == SystemSettingSystem.PkMode.REVENGE, "Exported PK mode Revenge")
+ _assert(exported["show_critical_hit"] == false, "Exported show_critical_hit false")
+
+ # Create new instance and import
+ var sss2 := SystemSettingSystem.new()
+ sss2.import_settings(exported)
+ _assert(sss2.bgm_volume == 0.5, "Imported BGM volume restored")
+ _assert(sss2.camera_distance == 32.0, "Imported camera distance restored")
+ _assert(sss2.current_pk_mode == SystemSettingSystem.PkMode.REVENGE, "Imported PK mode restored")
+ _assert(sss2.show_critical_hit == false, "Imported show_critical_hit restored")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("System Setting Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_system_setting_parity.gd.uid b/project/test_system_setting_parity.gd.uid
new file mode 100644
index 00000000..ea05b1c4
--- /dev/null
+++ b/project/test_system_setting_parity.gd.uid
@@ -0,0 +1 @@
+uid://ddm1wpr6y2372
diff --git a/project/test_target_board_parity.gd b/project/test_target_board_parity.gd
new file mode 100644
index 00000000..dcb6d49b
--- /dev/null
+++ b/project/test_target_board_parity.gd
@@ -0,0 +1,122 @@
+# test_target_board_parity.gd —— 40250 目标信息板 TargetBoard 1:1 回归测试套件
+# 运行方式:
+# /opt/homebrew/bin/godot --headless --path metin2-client/project --script test_target_board_parity.gd
+extends SceneTree
+
+const TargetUI = preload("res://ui/target_ui.gd")
+const NetPlay = preload("res://net_play.gd")
+
+class MockClient extends Node:
+ var cleared_target_count := 0
+ func clear_target() -> void:
+ cleared_target_count += 1
+ func get_entity(vid: int) -> Dictionary:
+ if vid == 1001:
+ return {"vid": 1001, "name": "兽人首领", "level": 50, "grade": 4, "kind": 2, "dead": false}
+ if vid == 1002:
+ return {"vid": 1002, "name": "狂暴野狼", "level": 12, "grade": 0, "kind": 2, "dead": false}
+ return {}
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_target_board_parity (Direction 1: TargetBoard 1:1) ===")
+ _test_board_sizing_and_naming()
+ _test_hp_gauge_and_closing()
+ _test_net_play_integration()
+ _finish()
+
+func _test_board_sizing_and_naming() -> void:
+ print("\n--- Test 1: Sizing & Name/Grade Prefix (uitarget.py:301) ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var target_ui := TargetUI.new()
+ get_root().add_child(target_ui)
+ target_ui.setup(client, get_root())
+
+ _assert(target_ui.is_open() == false, "Target board initially hidden")
+
+ # Open Boss target (Lv 50, Grade 4 = 5阶首领)
+ target_ui.open(1001, "兽人首领", 50, 4, false)
+ _assert(target_ui.is_open() == true, "Target board opened")
+ _assert(target_ui.get_target_name() == "[Lv. 50] (5阶首领) 兽人首领", "Name prefix formatted correctly with Lv and Grade")
+
+ # Verify dynamic width formula: 200 + 7 * len("[Lv. 50] (5阶首领) 兽人首领")
+ # len is 19 chars -> 200 + 7 * 19 = 333
+ var expected_w := 200.0 + 7.0 * float("[Lv. 50] (5阶首领) 兽人首领".length())
+ _assert(target_ui._board.size.x == expected_w, "Board width auto-sizes to %.1f (200 + 7*len)" % expected_w)
+
+ target_ui.queue_free()
+ client.queue_free()
+
+func _test_hp_gauge_and_closing() -> void:
+ print("\n--- Test 2: HP Gauge 130px & Close Button (ClearTarget) ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var target_ui := TargetUI.new()
+ get_root().add_child(target_ui)
+ target_ui.setup(client, get_root())
+
+ target_ui.open(1002, "野狼", 12, 0, false)
+ target_ui.set_hp(60)
+
+ _assert(target_ui.get_hp_percentage() == 60, "HP set to 60%")
+ # Gauge width = 130.0 * 0.6 = 78.0
+ _assert(absf(target_ui._hp_gauge_fill.size.x - 78.0) < 0.01, "HP fill width is 78px (130 * 0.6)")
+ _assert(target_ui._hp_text.text == "60%", "HP text displays '60%'")
+
+ # Close button click
+ target_ui._on_close_button_pressed()
+ _assert(target_ui.is_open() == false, "Target board closed after clicking close button")
+ _assert(client.cleared_target_count == 1, "client.clear_target() called upon close")
+
+ target_ui.queue_free()
+ client.queue_free()
+
+func _test_net_play_integration() -> void:
+ print("\n--- Test 3: NetPlay _on_target_info Dispatching ---")
+ var client := MockClient.new()
+ get_root().add_child(client)
+
+ var net_play := NetPlay.new()
+ get_root().add_child(net_play)
+ net_play.client = client
+
+ var target_ui := TargetUI.new()
+ get_root().add_child(target_ui)
+ target_ui.setup(client, get_root())
+ net_play.target_ui = target_ui
+
+ # Simulate RecvTargetPacket for vid 1001 with 85% HP
+ net_play._on_target_info(1001, 85)
+ _assert(target_ui.is_open() == true, "NetPlay _on_target_info opened target_ui")
+ _assert(target_ui.get_target_vid() == 1001, "Target VID is 1001")
+ _assert(target_ui.get_hp_percentage() == 85, "Target HP percentage is 85%")
+
+ # Clear target
+ net_play._clear_target()
+ _assert(target_ui.is_open() == false, "NetPlay _clear_target closed target_ui")
+
+ target_ui.queue_free()
+ net_play.queue_free()
+ client.queue_free()
+
+func _finish() -> void:
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ quit(0)
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(1)
diff --git a/project/test_target_board_parity.gd.uid b/project/test_target_board_parity.gd.uid
new file mode 100644
index 00000000..d015c5f3
--- /dev/null
+++ b/project/test_target_board_parity.gd.uid
@@ -0,0 +1 @@
+uid://crr0btkehl7ux
diff --git a/project/test_taskbar_quickslot_parity.gd b/project/test_taskbar_quickslot_parity.gd
new file mode 100644
index 00000000..1484f87e
--- /dev/null
+++ b/project/test_taskbar_quickslot_parity.gd
@@ -0,0 +1,153 @@
+# test_taskbar_quickslot_parity.gd —— 验证方向 3:底部主任务栏、4 珍珠经验球与鼠标控制器 40250 1:1 对齐
+extends SceneTree
+
+const Quickbar = preload("res://ui/quickbar.gd")
+
+class DummySkillTable extends RefCounted:
+ func motion_idx_of(_id: int) -> int: return 1
+ func is_fan_range(_id: int) -> bool: return false
+ func is_circle_range(_id: int) -> bool: return false
+ func target_range(_id: int) -> float: return 500.0
+ func target_count(_id: int, _lvl: int) -> int: return 1
+ func fly_shape(_id: int) -> int: return 0
+ func skill_name(id: int) -> String:
+ if id == 1: return "三连斩"
+ if id == 2: return "剑气"
+ return "技能 #%d" % id
+
+class DummyClient extends Node:
+ signal quickslots_changed
+ signal skills_changed
+ signal points_changed(pts: Dictionary)
+ var _qs: Array = []
+ func get_quickslots() -> Array: return _qs
+ func get_points() -> Dictionary: return {"hp": 1000, "max_hp": 1000, "sp": 500, "max_sp": 500, "exp": 0, "next_exp": 1000}
+ func get_main_vid() -> int: return 1
+ func get_entity(_vid: int) -> Dictionary: return {"race": 0, "dead": false}
+ func get_skills() -> Array: return []
+ func get_skill_group() -> int: return 1
+ func quickslot_add(pos: int, type: int, ref: int) -> bool:
+ _qs.append({"pos": pos, "type": type, "ref": ref})
+ quickslots_changed.emit()
+ return true
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_taskbar_quickslot_parity (Direction 3: Taskbar & Quickslot 1:1) ===")
+ var root := Control.new()
+ root.size = Vector2(800, 600)
+
+ var client := DummyClient.new()
+ var table := DummySkillTable.new()
+ var qb := Quickbar.new()
+
+ qb.setup(client, table, root, func(): return null, "metin2-client/assets")
+
+ test_exp_four_pearls_parity(qb)
+ test_page_flip_parity(qb)
+ test_mouse_mode_controllers(qb)
+
+ print("\n=== SUMMARY: %d passed, %d failed ===" % [_passed, _failed])
+ if _failed > 0:
+ printerr("TEST FAILED!")
+ quit(1)
+ else:
+ print("ALL TESTS PASSED!")
+ quit(0)
+
+func test_exp_four_pearls_parity(qb: Node) -> void:
+ print("\n--- Test 1: 40250 uitaskbar.py 4-Pearl EXP Gauge Calculation ---")
+ # Case 1: 0% EXP
+ qb.set_exp(0, 1000)
+ var exp_board: Control = qb.get("_exp_board")
+ _assert(exp_board != null, "EXP gauge board exists")
+ _assert(exp_board.tooltip_text == "EXP : 0.00%", "0% EXP tooltip formatted 1:1")
+
+ var clips: Array = qb.get("_exp_clips")
+ _assert(clips.size() == 4, "4 pearl clip controls present")
+ _assert(not clips[0].visible and not clips[1].visible and not clips[2].visible and not clips[3].visible,
+ "At 0% EXP, all 4 pearls are hidden/empty")
+
+ # Case 2: 25% EXP (1st quarter full)
+ qb.set_exp(250, 1000)
+ _assert(exp_board.tooltip_text == "EXP : 25.00%", "25% EXP tooltip matches")
+ _assert(clips[0].visible, "Pearl 1 is fully filled and visible")
+ _assert(clips[0].size == Vector2(19, 19), "Pearl 1 is full 19x19 dimensions")
+ _assert(not clips[1].visible and not clips[2].visible and not clips[3].visible,
+ "Pearls 2..4 remain empty at 25%")
+
+ # Case 3: 62.5% EXP (2 quarters full, 3rd quarter 50% filled)
+ # 625 / 1000 = quarter_pt 250, full_count = 2 (pearls 0 and 1 full). Pearl 2 fill_h = round(19 * 125/250) = 10 px
+ qb.set_exp(625, 1000)
+ _assert(exp_board.tooltip_text == "EXP : 62.50%", "62.5% EXP tooltip matches")
+ _assert(clips[0].visible and clips[0].size.y == 19, "Pearl 1 is full (19px)")
+ _assert(clips[1].visible and clips[1].size.y == 19, "Pearl 2 is full (19px)")
+ _assert(clips[2].visible and clips[2].size.y == 10, "Pearl 3 is partially filled (10px)")
+ _assert(not clips[3].visible, "Pearl 4 is empty")
+
+ # Case 4: 100% EXP (All 4 pearls full)
+ qb.set_exp(1000, 1000)
+ _assert(exp_board.tooltip_text == "EXP : 100.00%", "100% EXP tooltip matches")
+ _assert(clips[0].visible and clips[1].visible and clips[2].visible and clips[3].visible,
+ "All 4 pearls are visible and full at 100% EXP")
+
+func test_page_flip_parity(qb: Node) -> void:
+ print("\n--- Test 2: Quickslot Multi-Page Flipping (Shift+1..4) ---")
+ _assert(qb.get_page() == 0, "Initial page is 0 (Page 1)")
+
+ qb.set_page(1)
+ _assert(qb.get_page() == 1, "Flipped to page 1 (Page 2)")
+
+ qb.set_page(2)
+ _assert(qb.get_page() == 2, "Flipped to page 2 (Page 3)")
+
+ qb.set_page(3)
+ _assert(qb.get_page() == 3, "Flipped to page 3 (Page 4)")
+
+ # Out of bounds ignored
+ qb.set_page(4)
+ _assert(qb.get_page() == 3, "Out of bounds page 4 rejected")
+ qb.set_page(-1)
+ _assert(qb.get_page() == 3, "Out of bounds page -1 rejected")
+
+ # Flipped back to page 0
+ qb.set_page(0)
+ _assert(qb.get_page() == 0, "Flipped back to page 0")
+
+func test_mouse_mode_controllers(qb: Node) -> void:
+ print("\n--- Test 3: Mouse Left/Right Button Mode Controllers ---")
+ var btn_left: Control = qb.get_button("mouse_left")
+ var btn_right: Control = qb.get_button("mouse_right")
+ _assert(btn_left != null, "mouse_left button exists")
+ _assert(btn_right != null, "mouse_right button exists")
+
+ # Left mouse mode cycle: 0 -> 1 -> 2 -> 0
+ _assert(qb.get_left_mouse_mode() == 0, "Initial left mouse mode is 0 (Move & Attack)")
+ qb.set_left_mouse_mode(1)
+ _assert(qb.get_left_mouse_mode() == 1, "Left mouse mode changed to 1 (Auto Attack)")
+ _assert("自动攻击" in btn_left.tooltip_text, "Left mouse button tooltip updated to Auto Attack")
+ qb.set_left_mouse_mode(2)
+ _assert(qb.get_left_mouse_mode() == 2, "Left mouse mode changed to 2 (Camera)")
+ _assert("镜头旋转" in btn_left.tooltip_text, "Left mouse button tooltip updated to Camera")
+
+ # Right mouse mode & skill binding
+ qb.set_right_mouse_mode(0)
+ _assert(qb.get_right_mouse_mode() == 0, "Right mouse mode changed to 0 (Move & Attack)")
+ qb.set_right_mouse_mode(1)
+ _assert(qb.get_right_mouse_mode() == 1, "Right mouse mode changed to 1 (Camera)")
+ qb.set_right_mouse_mode(2)
+ _assert(qb.get_right_mouse_mode() == 2, "Right mouse mode changed to 2 (Skill)")
+
+ qb.set_right_mouse_skill(1)
+ _assert(qb.get_right_mouse_skill() == 1, "Right mouse skill set to 1 (三连斩)")
+ _assert("三连斩" in btn_right.tooltip_text, "Right mouse button tooltip reflects selected skill name")
diff --git a/project/test_taskbar_quickslot_parity.gd.uid b/project/test_taskbar_quickslot_parity.gd.uid
new file mode 100644
index 00000000..608e76d1
--- /dev/null
+++ b/project/test_taskbar_quickslot_parity.gd.uid
@@ -0,0 +1 @@
+uid://dij52xuddw3ql
diff --git a/project/test_title_achievement_parity.gd b/project/test_title_achievement_parity.gd
new file mode 100644
index 00000000..5bc0c6e2
--- /dev/null
+++ b/project/test_title_achievement_parity.gd
@@ -0,0 +1,121 @@
+# test_title_achievement_parity.gd —— 40250 头衔成就系统 1:1 纯净回归测试套件
+extends SceneTree
+
+const TitleAchievementSystem = preload("res://title_achievement_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_title_achievement_parity (Direction 3: Title & Achievements 1:1) ===")
+ _test_achievement_definitions()
+ _test_achievement_unlock()
+ _test_title_equip_and_bonuses()
+ _test_multiple_achievements_points_accumulation()
+ _finish()
+
+func _test_achievement_definitions() -> void:
+ print("\n--- Test 1: Achievement Milestone Configurations ---")
+ var tas := TitleAchievementSystem.new()
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_DEMON_TOWER"), "Demon Tower achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_SPIDER_BARONESS"), "Spider Baroness achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_BERAN_SETAOU"), "Beran-Setaou achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_AZRAEL"), "Azrael achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_FOUR_WORLD_BOSSES"), "Four World Bosses achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_SKILL_GRANDMASTER"), "Skill Grandmaster achievement defined")
+ _assert(TitleAchievementSystem.ACHIEVEMENTS.has("ACH_EPIC_BLACKSMITH"), "Epic Blacksmith achievement defined")
+
+func _test_achievement_unlock() -> void:
+ print("\n--- Test 2: Milestone Unlock & Signal Emission ---")
+ var tas := TitleAchievementSystem.new()
+
+ # Invalid ach_id
+ var r_bad = tas.complete_achievement("INVALID_ACH")
+ _assert(r_bad["ok"] == false and r_bad["reason"] == "INVALID_ACH_ID", "Invalid achievement rejected")
+
+ var unlock_signal := {"fired": false, "id": "", "title": ""}
+ tas.achievement_unlocked.connect(func(aid: String, _aname: String, tname: String):
+ unlock_signal["fired"] = true
+ unlock_signal["id"] = aid
+ unlock_signal["title"] = tname
+ )
+
+ # Complete ACH_BERAN_SETAOU
+ var r = tas.complete_achievement("ACH_BERAN_SETAOU")
+ _assert(r["ok"] == true, "Beran-Setaou achievement completed")
+ _assert(tas.is_achievement_unlocked("ACH_BERAN_SETAOU") == true, "Achievement marked unlocked")
+ _assert(tas.total_achievement_points == 100, "100 achievement points awarded")
+ _assert(tas.unlocked_titles.has("屠龙勇士"), "Title '屠龙勇士' added to unlocked titles")
+ _assert(unlock_signal["fired"] == true and unlock_signal["title"] == "屠龙勇士", "achievement_unlocked signal fired")
+
+ # Duplicate complete rejected
+ var r_dup = tas.complete_achievement("ACH_BERAN_SETAOU")
+ _assert(r_dup["ok"] == false and r_dup["reason"] == "ALREADY_COMPLETED", "Duplicate completion rejected")
+
+func _test_title_equip_and_bonuses() -> void:
+ print("\n--- Test 3: Title Equip, Unequip, and Stat Buffs ---")
+ var tas := TitleAchievementSystem.new()
+
+ # Try to equip locked title
+ var r_lock = tas.equip_title("深渊征服者")
+ _assert(r_lock["ok"] == false and r_lock["reason"] == "TITLE_NOT_UNLOCKED", "Locked title cannot be equipped")
+
+ # Unlock and equip "屠龙勇士"
+ tas.complete_achievement("ACH_BERAN_SETAOU")
+
+ var equip_signal := {"fired": false, "title": ""}
+ tas.title_equipped.connect(func(t: String, _b: Dictionary):
+ equip_signal["fired"] = true
+ equip_signal["title"] = t
+ )
+
+ var r_eq = tas.equip_title("屠龙勇士")
+ _assert(r_eq["ok"] == true, "Equipped '屠龙勇士' successfully")
+ _assert(tas.active_title == "屠龙勇士", "active_title is '屠龙勇士'")
+ _assert(equip_signal["fired"] == true and equip_signal["title"] == "屠龙勇士", "title_equipped signal fired")
+
+ var bonuses = tas.get_active_bonuses()
+ _assert(bonuses.has("boss_dmg_pct") and bonuses["boss_dmg_pct"] == 10, "Title grants +10% Boss damage")
+
+ # Unequip title
+ var unequip_signal := {"fired": false}
+ tas.title_unequipped.connect(func(): unequip_signal["fired"] = true)
+
+ tas.unequip_title()
+ _assert(tas.active_title == "", "active_title cleared")
+ _assert(unequip_signal["fired"] == true, "title_unequipped signal fired")
+ _assert(tas.get_active_bonuses().is_empty(), "No bonuses active after unequip")
+
+func _test_multiple_achievements_points_accumulation() -> void:
+ print("\n--- Test 4: Points Accumulation & Title Switching ---")
+ var tas := TitleAchievementSystem.new()
+ tas.complete_achievement("ACH_DEMON_TOWER") # 50 pts, "恶魔征服者", +500 HP
+ tas.complete_achievement("ACH_AZRAEL") # 100 pts, "深渊征服者", +50 DEF
+
+ _assert(tas.total_achievement_points == 150, "Total points accumulated: 150 (50 + 100)")
+ _assert(tas.unlocked_titles.size() == 2, "2 titles unlocked")
+
+ # Equip Demon Tower title
+ tas.equip_title("恶魔征服者")
+ _assert(tas.get_active_bonuses()["max_hp"] == 500, "Grants +500 Max HP")
+
+ # Switch to Azrael title
+ tas.equip_title("深渊征服者")
+ _assert(tas.get_active_bonuses()["def"] == 50, "Switched to +50 DEF")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Title Achievement Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_title_achievement_parity.gd.uid b/project/test_title_achievement_parity.gd.uid
new file mode 100644
index 00000000..fe94ea08
--- /dev/null
+++ b/project/test_title_achievement_parity.gd.uid
@@ -0,0 +1 @@
+uid://pafg61vig084
diff --git a/project/test_title_trophy_buff_parity.gd b/project/test_title_trophy_buff_parity.gd
new file mode 100644
index 00000000..77ac9d56
--- /dev/null
+++ b/project/test_title_trophy_buff_parity.gd
@@ -0,0 +1,140 @@
+# test_title_trophy_buff_parity.gd —— 40250 真实称号词条战力加成与奖杯成就图鉴 1:1 回归测试套件
+extends SceneTree
+
+const TitleTrophyBuffSystem = preload("res://title_trophy_buff_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_title_trophy_buff_parity (Direction 4: Title Trophy Buffs 1:1) ===")
+ _test_catalog_and_unlock()
+ _test_title_equip_and_stat_injection()
+ _test_title_switching_and_unequip()
+ _test_trophy_milestone_progression()
+ _finish()
+
+func _test_catalog_and_unlock() -> void:
+ print("\n--- Test 1: Title Catalog & Unlocking ---")
+ var sys := TitleTrophyBuffSystem.new()
+ _assert(TitleTrophyBuffSystem.TITLES_CATALOG.size() == 7, "Catalog has 7 iconic titles")
+
+ var unk_res = sys.unlock_title("InvalidTitle")
+ _assert(unk_res["ok"] == false and unk_res["reason"] == "UNKNOWN_TITLE", "Unknown title rejected")
+
+ var u1 = sys.unlock_title("屠龙尊者", 100)
+ _assert(u1["ok"] == true, "屠龙尊者 unlocked")
+ _assert(sys.trophy_points == 100, "Trophy points = 100")
+ _assert("屠龙尊者" in sys.unlocked_titles, "Title in unlocked list")
+
+func _test_title_equip_and_stat_injection() -> void:
+ print("\n--- Test 2: Equipping Title & Live Stat Injection ---")
+ var sys := TitleTrophyBuffSystem.new()
+ sys.unlock_title("屠龙尊者")
+
+ var player := {
+ "name": "战神",
+ "max_hp": 10000,
+ "atk": 500,
+ "def": 300,
+ "boss_dmg_pct": 0,
+ "crit_pct": 5
+ }
+
+ var equip_sig := {"fired": false, "title": ""}
+ sys.title_stat_buffs_applied.connect(func(t: String, _b: Dictionary):
+ equip_sig["fired"] = true
+ equip_sig["title"] = t
+ )
+
+ # Try equip locked title
+ var fail_lock = sys.equip_title("百战名将", player)
+ _assert(fail_lock["ok"] == false and fail_lock["reason"] == "TITLE_NOT_UNLOCKED", "Cannot equip locked title")
+
+ # Equip "屠龙尊者" (boss_dmg_pct: 15, max_hp: 2000, crit_pct: 5)
+ var res = sys.equip_title("屠龙尊者", player)
+ _assert(res["ok"] == true, "Title equipped")
+ _assert(equip_sig["fired"] == true and equip_sig["title"] == "屠龙尊者", "title_stat_buffs_applied emitted")
+ _assert(player["max_hp"] == 12000, "Max HP increased 10000 -> 12000 (+2000)")
+ _assert(player["boss_dmg_pct"] == 15, "Boss damage increased 0 -> 15%")
+ _assert(player["crit_pct"] == 10, "Crit rate increased 5 -> 10% (+5%)")
+
+func _test_title_switching_and_unequip() -> void:
+ print("\n--- Test 3: Zero-Drift Switching & Unequipping ---")
+ var sys := TitleTrophyBuffSystem.new()
+ sys.unlock_title("屠龙尊者")
+ sys.unlock_title("百战名将")
+
+ var player := {
+ "max_hp": 10000,
+ "atk": 500,
+ "def": 300,
+ "boss_dmg_pct": 0,
+ "crit_pct": 5
+ }
+
+ # 1. Equip 屠龙尊者
+ sys.equip_title("屠龙尊者", player)
+ _assert(player["max_hp"] == 12000, "HP +2000")
+
+ # 2. Switch directly to 百战名将 (atk: 80, def: 50, max_hp: 1500)
+ # 屠龙尊者 stats (-2000 HP, -15% boss dmg, -5% crit) must be deducted first!
+ sys.equip_title("百战名将", player)
+ _assert(player["max_hp"] == 11500, "HP is 10000 + 1500 = 11500 (Clean subtraction of 2000, addition of 1500)")
+ _assert(player["atk"] == 580, "Atk is 500 + 80 = 580")
+ _assert(player["def"] == 350, "Def is 300 + 50 = 350")
+ _assert(player["boss_dmg_pct"] == 0, "Boss damage reverted back to 0")
+ _assert(player["crit_pct"] == 5, "Crit rate reverted back to baseline 5")
+
+ # 3. Unequip 百战名将
+ var unequip_sig := {"fired": false}
+ sys.title_stat_buffs_removed.connect(func(_t: String): unequip_sig["fired"] = true)
+
+ sys.unequip_title(player)
+ _assert(unequip_sig["fired"] == true, "title_stat_buffs_removed emitted")
+ _assert(player["max_hp"] == 10000, "HP returned to exact initial 10000")
+ _assert(player["atk"] == 500, "Atk returned to exact initial 500")
+ _assert(player["def"] == 300, "Def returned to exact initial 300")
+
+func _test_trophy_milestone_progression() -> void:
+ print("\n--- Test 4: Trophy Milestones & Cumulative Passives ---")
+ var sys := TitleTrophyBuffSystem.new()
+
+ var milestone_events := []
+ sys.trophy_milestone_reached.connect(func(tier: int, tname: String, _desc: String):
+ milestone_events.append({"tier": tier, "name": tname})
+ )
+
+ # Unlock 1 title (50 pts) -> Reaches Tier 1 (50 pts)
+ sys.unlock_title("屠龙尊者", 50)
+ _assert(milestone_events.size() == 1 and milestone_events[0]["tier"] == 1, "Reached Tier 1 (青铜试炼奖杯)")
+
+ # Unlock 2 more titles (+100 pts -> 150 pts total) -> Reaches Tier 2 (150 pts)
+ sys.unlock_title("恶魔克星", 50)
+ sys.unlock_title("魔石终结者", 50)
+ _assert(milestone_events.size() == 2 and milestone_events[1]["tier"] == 2, "Reached Tier 2 (白银征服奖杯)")
+
+ var passives = sys.get_total_trophy_passive_bonuses()
+ _assert(passives.has("all_stats") and passives["all_stats"] == 3, "Has Tier 1 passive: all_stats +3")
+ _assert(passives.has("max_hp") and passives["max_hp"] == 1000, "Has Tier 2 passive: max_hp +1000")
+ _assert(passives.has("drop_rate_pct") and passives["drop_rate_pct"] == 5, "Has Tier 2 passive: drop_rate +5%")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Title Trophy Buff Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Title Trophy Buff parity tests passed!")
+ quit(0)
diff --git a/project/test_title_trophy_buff_parity.gd.uid b/project/test_title_trophy_buff_parity.gd.uid
new file mode 100644
index 00000000..91d2ddad
--- /dev/null
+++ b/project/test_title_trophy_buff_parity.gd.uid
@@ -0,0 +1 @@
+uid://lttbelq13bsv
diff --git a/project/test_treasure_box_key_parity.gd b/project/test_treasure_box_key_parity.gd
new file mode 100644
index 00000000..86c2e01c
--- /dev/null
+++ b/project/test_treasure_box_key_parity.gd
@@ -0,0 +1,166 @@
+# test_treasure_box_key_parity.gd —— Metin2 40250 金银宝箱与钥匙解锁系统 1:1 测试套件
+extends SceneTree
+
+const TreasureBoxKeySystem = preload("res://treasure_box_key_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_treasure_box_key_parity (Direction 2: Treasure Boxes & Keys 1:1) ===")
+
+ test_silver_box_and_key()
+ test_gold_box_and_key()
+ test_mismatch_and_rejections()
+ test_plus_boxes()
+ test_history_and_serialization()
+
+ print("\n=======================================================")
+ print("Treasure Box & Key Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
+
+func test_silver_box_and_key() -> void:
+ print("\n--- Test 1: Silver Key Unlocks Silver Box (40250 1:1) ---")
+ var system = TreasureBoxKeySystem.new()
+
+ var unlocked_signals := []
+ system.box_unlocked.connect(func(box, key, reward):
+ unlocked_signals.append({"box": box, "key": key, "reward": reward})
+ )
+
+ var inv: Array = [
+ {"vnum": 50008, "count": 2}, # 银钥匙 x2 (slot 0)
+ {"vnum": 50006, "count": 1} # 银宝箱 x1 (slot 1)
+ ]
+
+ _assert(system.can_unlock(50008, 50006) == true, "Silver key matches silver box")
+
+ # 开启宝箱,强制掉落索引 0 (技能书 50300)
+ var res = system.unlock_box_with_key(inv, 0, 1, 0)
+ _assert(res["ok"] == true, "Unlocked silver box successfully")
+ _assert(inv[0] != null and inv[0]["count"] == 1, "Key count decremented from 2 to 1")
+ # 原宝箱槽位已被新发放的奖励技能书复用填充
+ _assert(res["reward"]["vnum"] == 50300, "Rewarded skill book (50300)")
+ var has_box = false
+ for it in inv:
+ if it != null and it.get("vnum", 0) == 50006:
+ has_box = true
+ _assert(has_box == false, "Silver box was consumed and no longer in inventory")
+ _assert(unlocked_signals.size() == 1, "box_unlocked signal fired")
+ _assert(unlocked_signals[0]["box"] == 50006, "Signal reports silver box")
+
+func test_gold_box_and_key() -> void:
+ print("\n--- Test 2: Gold Key Unlocks Gold Box (40250 1:1) ---")
+ var system = TreasureBoxKeySystem.new()
+
+ var inv: Array = [
+ {"vnum": 50009, "count": 1}, # 金钥匙 (slot 0)
+ {"vnum": 50007, "count": 1} # 金宝箱 (slot 1)
+ ]
+
+ _assert(system.can_unlock(50009, 50007) == true, "Gold key matches gold box")
+
+ # 开启金宝箱,强制掉落索引 0 (祝福卷轴 25040)
+ var res = system.unlock_box_with_key(inv, 0, 1, 0)
+ _assert(res["ok"] == true, "Unlocked gold box successfully")
+ var has_gold_key = false
+ var has_gold_box = false
+ for it in inv:
+ if it != null:
+ if it.get("vnum", 0) == 50009:
+ has_gold_key = true
+ if it.get("vnum", 0) == 50007:
+ has_gold_box = true
+ _assert(has_gold_key == false, "Gold key consumed")
+ _assert(has_gold_box == false, "Gold box consumed")
+ _assert(res["reward"]["vnum"] == 25040, "Rewarded Blessing Scroll (25040)")
+
+func test_mismatch_and_rejections() -> void:
+ print("\n--- Test 3: Key Mismatch & Invalid Targets (40250 1:1) ---")
+ var system = TreasureBoxKeySystem.new()
+
+ var fail_signals := []
+ system.unlock_failed.connect(func(reason):
+ fail_signals.append(reason)
+ )
+
+ var inv: Array = [
+ {"vnum": 50008, "count": 1}, # 银钥匙 (slot 0)
+ {"vnum": 50007, "count": 1}, # 金宝箱 (slot 1)
+ {"vnum": 27987, "count": 1} # 珍珠蚌 (slot 2, 非宝箱)
+ ]
+
+ # 1. 银钥匙对金宝箱 -> 拒绝
+ _assert(system.can_unlock(50008, 50007) == false, "Silver key cannot unlock gold box")
+ var res = system.unlock_box_with_key(inv, 0, 1)
+ _assert(res["ok"] == false, "Rejected mismatch")
+ _assert(res["reason"] == "KEY_MISMATCH", "Reason is KEY_MISMATCH")
+ _assert(inv[0]["count"] == 1, "Key not consumed")
+ _assert(inv[1]["count"] == 1, "Box not consumed")
+
+ # 2. 钥匙拖拽至非宝箱物品 -> 拒绝
+ res = system.unlock_box_with_key(inv, 0, 2)
+ _assert(res["ok"] == false, "Rejected non-box target")
+ _assert(res["reason"] == "NOT_A_TREASURE_BOX", "Reason is NOT_A_TREASURE_BOX")
+
+ # 3. 非钥匙道具拖拽 -> 拒绝
+ res = system.unlock_box_with_key(inv, 2, 1)
+ _assert(res["ok"] == false, "Rejected non-key item")
+ _assert(res["reason"] == "NOT_A_KEY", "Reason is NOT_A_KEY")
+
+ _assert(fail_signals.size() == 3, "All 3 failure signals emitted")
+
+func test_plus_boxes() -> void:
+ print("\n--- Test 4: Silver+ and Gold+ Boxes (40250 1:1) ---")
+ var system = TreasureBoxKeySystem.new()
+
+ var inv: Array = [
+ {"vnum": 50014, "count": 1}, # 银钥匙+
+ {"vnum": 50012, "count": 1}, # 银宝箱+
+ {"vnum": 50015, "count": 1}, # 金钥匙+
+ {"vnum": 50013, "count": 1} # 金宝箱+
+ ]
+
+ # 银钥匙+ 开 银宝箱+
+ var res = system.unlock_box_with_key(inv, 0, 1, 0)
+ _assert(res["ok"] == true, "Unlocked Silver Box+")
+ _assert(res["reward"]["count"] == 2, "Silver+ gave 2 skill books")
+
+ # 金钥匙+ 开 金宝箱+
+ res = system.unlock_box_with_key(inv, 2, 3, 0)
+ _assert(res["ok"] == true, "Unlocked Gold Box+")
+ _assert(res["reward"]["vnum"] == 25040 and res["reward"]["count"] == 2, "Gold+ gave 2 Blessing Scrolls")
+
+func test_history_and_serialization() -> void:
+ print("\n--- Test 5: History Tracking & Serialization ---")
+ var system = TreasureBoxKeySystem.new()
+ var inv: Array = [
+ {"vnum": 50008, "count": 1},
+ {"vnum": 50006, "count": 1}
+ ]
+ system.unlock_box_with_key(inv, 0, 1, 0)
+
+ var history = system.get_open_history()
+ _assert(history.size() == 1, "Recorded 1 unlock history")
+ _assert(history[0]["box_vnum"] == 50006, "History records box 50006")
+
+ var data = system.serialize()
+ var restored = TreasureBoxKeySystem.new()
+ restored.deserialize(data)
+ _assert(restored.get_open_history().size() == 1, "Restored 1 history item")
+
+ system.clear_history()
+ _assert(system.get_open_history().is_empty(), "Cleared history")
diff --git a/project/test_treasure_box_key_parity.gd.uid b/project/test_treasure_box_key_parity.gd.uid
new file mode 100644
index 00000000..284210ac
--- /dev/null
+++ b/project/test_treasure_box_key_parity.gd.uid
@@ -0,0 +1 @@
+uid://b5perrjaulcrw
diff --git a/project/test_ui_char.gd b/project/test_ui_char.gd
new file mode 100644
index 00000000..97c0c93e
--- /dev/null
+++ b/project/test_ui_char.gd
@@ -0,0 +1,24 @@
+extends SceneTree
+
+const UiAssets = preload("res://ui/ui_assets.gd")
+
+func _init() -> void:
+ var root := "../assets"
+ var vpath := "d:/ymir work/ui/public/Parameter_Slot_00.sub"
+ var tex = UiAssets.load_tex(root, vpath)
+ print("Parameter_Slot_00.sub tex: ", tex)
+ if tex is AtlasTexture:
+ var at := tex as AtlasTexture
+ print("Atlas rect: ", at.region)
+ if at.atlas is ImageTexture:
+ var img: Image = (at.atlas as ImageTexture).get_image()
+ print("Atlas size: ", img.get_size(), " format: ", img.get_format())
+ var c := img.get_pixel(125, 235)
+ print("Pixel sample at 125,235: ", c)
+ var locale_sub := "locale/en/ui/windows/label_std_item1.sub"
+ var t2 = UiAssets.load_tex(root, locale_sub)
+ print("label_std_item1 tex: ", t2)
+ var win_sub := "d:/ymir work/ui/game/windows/label_std_item1.sub"
+ var t3 = UiAssets.load_tex(root, win_sub)
+ print("windows/label_std_item1 tex: ", t3)
+ quit(0)
diff --git a/project/test_ui_char.gd.uid b/project/test_ui_char.gd.uid
new file mode 100644
index 00000000..d11d939a
--- /dev/null
+++ b/project/test_ui_char.gd.uid
@@ -0,0 +1 @@
+uid://cng8ie4n4jb3h
diff --git a/project/test_wardrobe_transmog_parity.gd b/project/test_wardrobe_transmog_parity.gd
new file mode 100644
index 00000000..f3f903e4
--- /dev/null
+++ b/project/test_wardrobe_transmog_parity.gd
@@ -0,0 +1,133 @@
+# test_wardrobe_transmog_parity.gd —— 40250 装备外观幻化与试衣间 1:1 回归测试套件
+extends SceneTree
+
+const WardrobeTransmogSystem = preload("res://wardrobe_transmog_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_wardrobe_transmog_parity (Direction 3: Wardrobe Transmog 1:1) ===")
+ _test_preview_mode()
+ _test_weapon_transmutation()
+ _test_type_mismatch_rejection()
+ _test_transmutation_revert()
+ _finish()
+
+func _test_preview_mode() -> void:
+ print("\n--- Test 1: Wardrobe Preview Mode ---")
+ var sys := WardrobeTransmogSystem.new()
+ var sword := {"vnum": 189, "name": "毒剑+9", "type": WardrobeTransmogSystem.TYPE_WEAPON}
+ var skin := {"vnum": 199, "name": "圣天使之剑", "type": WardrobeTransmogSystem.TYPE_WEAPON}
+
+ var prev = sys.preview_transmog(sword, skin)
+ _assert(prev["ok"] == true, "Preview generated")
+ _assert(prev["preview_vnum"] == 199, "Preview skin vnum is 199")
+ _assert(prev["preview_name"] == "圣天使之剑", "Preview name matches")
+
+func _test_weapon_transmutation() -> void:
+ print("\n--- Test 2: Weapon Transmutation & Full Stat Preservation ---")
+ var sys := WardrobeTransmogSystem.new()
+ var player := {"gold": 25000000}
+ var inv: Array = [
+ {"vnum": WardrobeTransmogSystem.VNUM_TRANSMOG_SCROLL, "name": "幻化秘籍", "count": 2},
+ {
+ "vnum": 189,
+ "name": "毒剑+9",
+ "type": WardrobeTransmogSystem.TYPE_WEAPON,
+ "refine_level": 9,
+ "sockets": [28430, 28435, 28436],
+ "attrs": [{"type": 1, "val": 2000}]
+ },
+ {"vnum": 199, "name": "圣天使之剑", "type": WardrobeTransmogSystem.TYPE_WEAPON, "count": 1}
+ ]
+
+ var transmog_sig := {"fired": false, "slot": -1, "skin_vnum": 0}
+ sys.item_transmuted.connect(func(s: int, _tname: String, sk_vnum: int, _sk_name: String):
+ transmog_sig["fired"] = true
+ transmog_sig["slot"] = s
+ transmog_sig["skin_vnum"] = sk_vnum
+ )
+
+ # Apply transmutation
+ var res = sys.apply_transmutation(0, 1, 2, inv, player)
+ _assert(res["ok"] == true, "Transmutation succeeded")
+ _assert(player["gold"] == 15000000, "10M gold deducted (25M -> 15M)")
+ _assert(inv[0]["count"] == 1, "Scroll count decreased (2 -> 1)")
+ _assert(inv[2] == null, "Skin material item consumed")
+
+ # Check target item preservation
+ var target = inv[1]
+ _assert(target["vnum"] == 189, "Base vnum is still 189 (Poison Sword)")
+ _assert(target["refine_level"] == 9, "Refine level preserved as +9")
+ _assert(target["sockets"].size() == 3, "Sockets preserved")
+ _assert(target["attrs"].size() == 1, "Attrs preserved")
+ _assert(target["transmog_vnum"] == 199, "transmog_vnum set to 199")
+ _assert(target["transmog_name"] == "圣天使之剑", "transmog_name set to 圣天使之剑")
+ _assert(transmog_sig["fired"] == true and transmog_sig["skin_vnum"] == 199, "item_transmuted emitted")
+
+func _test_type_mismatch_rejection() -> void:
+ print("\n--- Test 3: Type Mismatch Rejection (Weapon vs Armor) ---")
+ var sys := WardrobeTransmogSystem.new()
+ var player := {"gold": 50000000}
+ var inv: Array = [
+ {"vnum": WardrobeTransmogSystem.VNUM_TRANSMOG_SCROLL, "count": 1},
+ {"vnum": 189, "name": "毒剑+9", "type": WardrobeTransmogSystem.TYPE_WEAPON},
+ {"vnum": 11299, "name": "黑铁战甲+9", "type": WardrobeTransmogSystem.TYPE_ARMOR} # Armor into weapon
+ ]
+
+ var fail_mismatch = sys.apply_transmutation(0, 1, 2, inv, player)
+ _assert(fail_mismatch["ok"] == false and fail_mismatch["reason"] == "TYPE_MISMATCH", "Rejected armor as skin for weapon")
+ _assert(player["gold"] == 50000000, "Gold untouched")
+ _assert(inv[0]["count"] == 1, "Scroll untouched")
+
+func _test_transmutation_revert() -> void:
+ print("\n--- Test 4: Reverting Transmutation (72302) ---")
+ var sys := WardrobeTransmogSystem.new()
+ var inv: Array = [
+ {"vnum": WardrobeTransmogSystem.VNUM_REVERT_SCROLL, "name": "还原秘籍", "count": 1},
+ {
+ "vnum": 189,
+ "name": "毒剑+9",
+ "type": WardrobeTransmogSystem.TYPE_WEAPON,
+ "transmog_vnum": 199,
+ "transmog_name": "圣天使之剑"
+ }
+ ]
+
+ var revert_sig := {"fired": false, "slot": -1}
+ sys.transmutation_reverted.connect(func(s: int, _n: String):
+ revert_sig["fired"] = true
+ revert_sig["slot"] = s
+ )
+
+ var res = sys.revert_transmutation(0, 1, inv)
+ _assert(res["ok"] == true, "Revert succeeded")
+ _assert(inv[0] == null, "Revert scroll consumed")
+ _assert(inv[1].has("transmog_vnum") == false, "transmog_vnum cleared")
+ _assert(revert_sig["fired"] == true and revert_sig["slot"] == 1, "transmutation_reverted emitted")
+
+ # Second revert on un-transmuted item must fail
+ inv[0] = {"vnum": WardrobeTransmogSystem.VNUM_REVERT_SCROLL, "count": 1}
+ var fail_again = sys.revert_transmutation(0, 1, inv)
+ _assert(fail_again["ok"] == false and fail_again["reason"] == "NOT_TRANSMUTED", "Cannot revert un-transmuted item")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Wardrobe Transmog Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Wardrobe Transmog parity tests passed!")
+ quit(0)
diff --git a/project/test_wardrobe_transmog_parity.gd.uid b/project/test_wardrobe_transmog_parity.gd.uid
new file mode 100644
index 00000000..70729b49
--- /dev/null
+++ b/project/test_wardrobe_transmog_parity.gd.uid
@@ -0,0 +1 @@
+uid://3o28mlag87c3
diff --git a/project/test_warrior_mental_skills_regression.gd b/project/test_warrior_mental_skills_regression.gd
new file mode 100644
index 00000000..9717fda3
--- /dev/null
+++ b/project/test_warrior_mental_skills_regression.gd
@@ -0,0 +1,81 @@
+extends SceneTree
+
+const AssetRoot = preload("res://asset_root.gd")
+const PlayerView = preload("res://ui/player_view.gd")
+
+func _init() -> void:
+ call_deferred("_run")
+
+func _run() -> void:
+ print("=== Running test_warrior_mental_skills_regression ===")
+ var fails := 0
+ var check = func(cond: bool, msg: String):
+ if not cond:
+ fails += 1
+ print("FAIL: ", msg)
+ else:
+ print("OK: ", msg)
+
+ var root_dir := AssetRoot.path()
+ var pv := PlayerView.new()
+ get_root().add_child(pv)
+ var ok = pv.build(root_dir, 0) # warrior male
+ check.call(ok, "warrior PlayerView built")
+
+ var skills = ["gigongcham", "gyeoksan", "daejin", "cheongeun", "geompung"]
+ for sname in skills:
+ print("\n--- Testing skill: ", sname, " ---")
+ var finished := [false]
+ var on_finished = func():
+ finished[0] = true
+ pv.anim.playback_finished.connect(on_finished, CONNECT_ONE_SHOT)
+
+ var cast_ok = pv.play_skill_motion(sname, 0)
+ check.call(cast_ok, "play_skill_motion(%s) returned true" % sname)
+ check.call(pv._state == "__skill", "pv._state is '__skill'")
+ check.call(bool(pv.anim.get("playing")), "anim.playing is true at start")
+
+ var dur = float(pv.anim.get_duration()) if pv.anim.has_method("get_duration") else 2.0
+ # Run frames until playback_finished or timeout
+ var frames = 0
+ while not finished[0] and frames < 400:
+ await process_frame
+ frames += 1
+
+ check.call(finished[0], "%s emitted playback_finished (frames=%d)" % [sname, frames])
+ check.call(pv._state == "wait", "pv._state reset to 'wait'")
+ check.call(bool(pv.anim.get("playing")), "anim.playing is true after playback_finished")
+
+ # Transition to run
+ pv.set_anim_state("run")
+ check.call(pv._state == "run", "pv._state changed to 'run'")
+ check.call(bool(pv.anim.get("playing")), "anim.playing is true during run")
+
+ var t0 = float(pv.anim.get_time()) if pv.anim.has_method("get_time") else 0.0
+ for f in range(15):
+ await process_frame
+ var t1 = float(pv.anim.get_time()) if pv.anim.has_method("get_time") else 0.0
+ check.call(t1 > t0, "run animation is advancing (t0=%.3f, t1=%.3f)" % [t0, t1])
+
+ # Reset to wait for next skill
+ pv.set_anim_state("wait")
+ for f in range(5):
+ await process_frame
+
+ # Clean up
+ if is_instance_valid(pv.anim):
+ pv.anim.free()
+ if is_instance_valid(pv.model):
+ pv.model.free()
+ pv.free()
+ if ClassDB.class_exists("Metin2Model"):
+ var m_dummy: Object = ClassDB.instantiate("Metin2Model")
+ m_dummy.call("clear_texture_cache")
+ m_dummy.free()
+ await process_frame
+ await process_frame
+
+ print("\n=== Completed test_warrior_mental_skills_regression with %d failures ===" % fails)
+ if fails == 0:
+ print("ALL PASS")
+ quit(fails)
diff --git a/project/test_warrior_mental_skills_regression.gd.uid b/project/test_warrior_mental_skills_regression.gd.uid
new file mode 100644
index 00000000..1ccb9c6d
--- /dev/null
+++ b/project/test_warrior_mental_skills_regression.gd.uid
@@ -0,0 +1 @@
+uid://d1kyaggb8ep12
diff --git a/project/test_wasd_steering_parity.gd b/project/test_wasd_steering_parity.gd
new file mode 100644
index 00000000..b115c794
--- /dev/null
+++ b/project/test_wasd_steering_parity.gd
@@ -0,0 +1,104 @@
+# test_wasd_steering_parity.gd —— 验证 WASD 转向与连续方向切换对齐 40250:
+# 1. 连续快速切换方向(W -> S, A -> D, 对角线切换等),俯仰角(X)与翻滚角(Z)始终保持 0,无翻跟头/倾倒。
+# 2. 偏航角(Y)平滑过渡且严格归一化在 [-PI, PI],越过 ±PI 边界时不发生每帧方向往复震荡。
+# 3. 40250 按键优先级(Up 优先于 Down,Left 优先于 Right):切换瞬间两键同时按住时不归零卡顿。
+#
+# godot --headless --path project --script test_wasd_steering_parity.gd
+extends SceneTree
+
+const PlayerController = preload("res://player_controller.gd")
+
+var _fail := 0
+func _ck(c: bool, m: String) -> void:
+ if not c:
+ _fail += 1
+ printerr("FAIL: " + m)
+ else:
+ print(" [PASS] " + m)
+
+func _init() -> void:
+ await process_frame
+ _run()
+ if _fail == 0:
+ print("=== ALL WASD STEERING PARITY TESTS PASSED ===")
+ quit(0)
+ else:
+ printerr("=== FAILED: %d check(s) failed ===" % _fail)
+ quit(1)
+
+func _run() -> void:
+ var pc := PlayerController.new()
+ var player := Node3D.new()
+ get_root().add_child(player)
+ get_root().add_child(pc)
+ pc.player = player
+ pc.rotation_speed_deg = 1200.0
+
+ # -------------------------------------------------------------------------
+ # Test 1: 边界跨越平滑度与无翻跟头(X/Z 严格为 0)
+ # -------------------------------------------------------------------------
+ print("\n--- Test 1: 边界跨越(±PI)平滑转向与 X/Z 稳定性 ---")
+ player.rotation = Vector3.ZERO
+ # 模拟从北(+PI)到西(-PI/2)再到南(0)再到东(PI/2)再到北(-PI)
+ var targets: Array[float] = [
+ PI - 0.05,
+ -PI + 0.05,
+ 0.0,
+ PI / 2.0,
+ -PI / 2.0,
+ PI
+ ]
+ var flipped := false
+ var xz_tilted := false
+ for t in targets:
+ for step in range(30):
+ pc._turn_toward(t, 0.016)
+ if absf(player.rotation.x) > 1e-4 or absf(player.rotation.z) > 1e-4:
+ xz_tilted = true
+ if absf(player.rotation.y) > PI + 1e-3:
+ flipped = true
+ _ck(not xz_tilted, "Player rotation.x and rotation.z strictly remain 0.0 (no pitch/roll somersault)")
+ _ck(not flipped, "Player rotation.y strictly remains in [-PI, PI] without unbounded growth")
+
+ # -------------------------------------------------------------------------
+ # Test 2: 180° 急转弯不发生往复震荡(单向收敛)
+ # -------------------------------------------------------------------------
+ print("\n--- Test 2: 180° 急转弯单向平滑收敛 ---")
+ player.rotation.y = 3.0
+ var target_yaw := -2.8
+ var prev_diff := 1e9
+ var monotonic := true
+ for step in range(20):
+ var cur_yaw: float = wrapf(player.rotation.y, -PI, PI)
+ var diff: float = absf(wrapf(target_yaw - cur_yaw, -PI, PI))
+ pc._turn_toward(target_yaw, 0.016)
+ var new_diff: float = absf(wrapf(target_yaw - player.rotation.y, -PI, PI))
+ if new_diff > diff + 1e-4:
+ monotonic = false
+ if is_equal_approx(player.rotation.y, target_yaw):
+ break
+ _ck(monotonic, "Turn toward target is monotonic across the PI boundary (no back-and-forth oscillation)")
+ _ck(is_equal_approx(player.rotation.y, target_yaw), "Turn converges exactly to target_yaw")
+
+ # -------------------------------------------------------------------------
+ # Test 3: 连续 WASD 快速方向切换无卡死或翻滚
+ # -------------------------------------------------------------------------
+ print("\n--- Test 3: 连续高频 WASD 切换稳定性 ---")
+ var dirs := [
+ Vector3(0, 0, -1), # W
+ Vector3(0, 0, 1), # S
+ Vector3(-1, 0, 0), # A
+ Vector3(1, 0, 0), # D
+ Vector3(-0.707, 0, -0.707), # WA
+ Vector3(0.707, 0, 0.707), # SD
+ Vector3(0.707, 0, -0.707), # WD
+ Vector3(-0.707, 0, 0.707), # SA
+ ]
+ var ok := true
+ for d in dirs:
+ var yaw := atan2(d.x, d.z)
+ for _step in range(15):
+ pc._turn_toward(yaw, 0.016)
+ if absf(player.rotation.x) > 1e-4 or absf(player.rotation.z) > 1e-4:
+ ok = false
+ _ck(ok, "Continuous 8-way rapid directional changes maintain perfect upright orientation")
diff --git a/project/test_wasd_steering_parity.gd.uid b/project/test_wasd_steering_parity.gd.uid
new file mode 100644
index 00000000..cbcb5036
--- /dev/null
+++ b/project/test_wasd_steering_parity.gd.uid
@@ -0,0 +1 @@
+uid://dcqpg7fl3j70k
diff --git a/project/test_weather_daynight_parity.gd b/project/test_weather_daynight_parity.gd
new file mode 100644
index 00000000..57e68670
--- /dev/null
+++ b/project/test_weather_daynight_parity.gd
@@ -0,0 +1,126 @@
+# test_weather_daynight_parity.gd —— 40250 昼夜交替与地图天候环境 1:1 回归测试套件
+extends SceneTree
+
+const WeatherDayNightSystem = preload("res://weather_daynight_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_weather_daynight_parity (Direction 4: Day/Night & Weather 1:1) ===")
+ _test_default_state()
+ _test_phase_transitions()
+ _test_time_progression_and_wrap()
+ _test_weather_modes_and_maps()
+ _finish()
+
+func _test_default_state() -> void:
+ print("\n--- Test 1: Default Noon State & Formatted Time ---")
+ var w := WeatherDayNightSystem.new()
+ _assert(w.current_phase == WeatherDayNightSystem.PHASE_NOON, "Defaults to NOON")
+ _assert(w.current_weather == WeatherDayNightSystem.WEATHER_CLEAR, "Defaults to CLEAR weather")
+ _assert(w.get_time_string() == "12:00:00", "Default time is 12:00:00")
+ _assert(w.sun_energy == 1.2, "Noon sun energy is 1.2")
+
+func _test_phase_transitions() -> void:
+ print("\n--- Test 2: Phase Transitions (Dawn, Noon, Dusk, Night) ---")
+ var w := WeatherDayNightSystem.new()
+
+ var phase_sig := {"fired": false, "old": "", "new": ""}
+ w.phase_changed.connect(func(o: String, n: String):
+ phase_sig["fired"] = true
+ phase_sig["old"] = o
+ phase_sig["new"] = n
+ )
+
+ # Transition to DAWN (06:00)
+ w.set_phase(WeatherDayNightSystem.PHASE_DAWN)
+ _assert(w.current_phase == WeatherDayNightSystem.PHASE_DAWN, "Phase is DAWN")
+ _assert(phase_sig["fired"] == true and phase_sig["new"] == "dawn", "phase_changed emitted dawn")
+ _assert(w.sun_color.r > 0.9 and w.sun_color.g > 0.7, "Dawn has warm golden light")
+
+ # Transition to DUSK (18:30)
+ phase_sig["fired"] = false
+ w.set_phase(WeatherDayNightSystem.PHASE_DUSK)
+ _assert(w.current_phase == WeatherDayNightSystem.PHASE_DUSK, "Phase is DUSK")
+ _assert(phase_sig["fired"] == true and phase_sig["new"] == "dusk", "phase_changed emitted dusk")
+
+ # Transition to NIGHT (23:00)
+ phase_sig["fired"] = false
+ w.set_phase(WeatherDayNightSystem.PHASE_NIGHT)
+ _assert(w.current_phase == WeatherDayNightSystem.PHASE_NIGHT, "Phase is NIGHT")
+ _assert(phase_sig["fired"] == true and phase_sig["new"] == "night", "phase_changed emitted night")
+ _assert(w.sun_energy <= 0.3, "Night has low moon energy")
+
+func _test_time_progression_and_wrap() -> void:
+ print("\n--- Test 3: Time Flow, Freezing, and Midnight Wrap ---")
+ var w := WeatherDayNightSystem.new()
+ w.set_time_of_day(12.0, 0.0) # 12:00:00
+ w.time_scale = 60.0 # 1 real sec = 60 game sec
+
+ # Advance 1 second -> should become 12:01:00
+ w.update(1.0)
+ _assert(w.get_time_string() == "12:01:00", "Advanced 1 minute of game time")
+
+ # Freeze time
+ w.is_frozen = true
+ w.update(5.0)
+ _assert(w.get_time_string() == "12:01:00", "Time remains frozen")
+
+ # Midnight wrap test: 23:59:30 + 60s -> 00:00:30
+ w.is_frozen = false
+ w.time_scale = 1.0
+ w.set_time_of_day(23.0, 59.0)
+ w.time_of_day += 30.0 # 23:59:30
+ w.update(60.0) # +60s
+ _assert(w.time_of_day < 3600.0, "Wrapped cleanly past midnight")
+ _assert(w.current_phase == WeatherDayNightSystem.PHASE_NIGHT, "Still NIGHT after midnight wrap")
+
+func _test_weather_modes_and_maps() -> void:
+ print("\n--- Test 4: Map Weather Presets & Environment Atmospheres ---")
+ var w := WeatherDayNightSystem.new()
+
+ var weather_sig := {"fired": false, "old": "", "new": ""}
+ w.weather_changed.connect(func(o: String, n: String):
+ weather_sig["fired"] = true
+ weather_sig["old"] = o
+ weather_sig["new"] = n
+ )
+
+ # Switch map to Mount Sohan (Ice Mountain) -> Snow
+ w.set_map_environment("sohan")
+ _assert(w.current_weather == WeatherDayNightSystem.WEATHER_SNOW, "Mount Sohan auto-activates SNOW")
+ _assert(weather_sig["fired"] == true and weather_sig["new"] == "snow", "weather_changed emitted snow")
+
+ # Switch map to Yongbi Desert -> Sandstorm
+ w.set_map_environment("desert")
+ _assert(w.current_weather == WeatherDayNightSystem.WEATHER_SANDSTORM, "Desert auto-activates SANDSTORM")
+ _assert(w.fog_density >= 0.005, "Sandstorm substantially raises fog density")
+
+ # Switch map to Doyyumhwan (Flame field) -> Cinders
+ w.set_map_environment("flame")
+ _assert(w.current_weather == WeatherDayNightSystem.WEATHER_CINDERS, "Flame field auto-activates CINDERS")
+
+ # Switch back to Village -> Clear
+ w.set_map_environment("village")
+ _assert(w.current_weather == WeatherDayNightSystem.WEATHER_CLEAR, "Village reverts to CLEAR sky")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("Weather Day/Night Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All Weather Day/Night parity tests passed!")
+ quit(0)
diff --git a/project/test_weather_daynight_parity.gd.uid b/project/test_weather_daynight_parity.gd.uid
new file mode 100644
index 00000000..b3fccf7c
--- /dev/null
+++ b/project/test_weather_daynight_parity.gd.uid
@@ -0,0 +1 @@
+uid://cdqct6gwv62a6
diff --git a/project/test_wedding_marriage_parity.gd b/project/test_wedding_marriage_parity.gd
new file mode 100644
index 00000000..605672fc
--- /dev/null
+++ b/project/test_wedding_marriage_parity.gd
@@ -0,0 +1,163 @@
+# test_wedding_marriage_parity.gd —— 40250 官方结婚典礼与夫妻同心婚戒系统 1:1 回归测试套件
+extends SceneTree
+
+const WeddingMarriageSystem = preload("res://wedding_marriage_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_wedding_marriage_parity (Direction 3: Wedding & Marriage 1:1) ===")
+ _test_wedding_ceremony_and_ring_issuance()
+ _test_wedding_ring_teleport()
+ _test_love_points_and_marriage_buffs()
+ _test_divorce_and_ring_clearing()
+ _test_serialization()
+ _finish()
+
+func _test_wedding_ceremony_and_ring_issuance() -> void:
+ print("\n--- Test 1: Wedding Ceremony & Wedding Ring Issuance ---")
+ var sys := WeddingMarriageSystem.new()
+ _assert(not sys.is_married, "Initially unmarried")
+
+ var inv: Array = [
+ {"vnum": WeddingMarriageSystem.VNUM_ENGAGEMENT_RING, "name": "订婚戒指"}
+ ]
+
+ # 1. Missing wedding dress
+ var res_no_dress = sys.conduct_wedding("月神姬", inv, "M")
+ _assert(res_no_dress["ok"] == false, "Wedding rejected without Tuxedo/Dress")
+ _assert(res_no_dress["reason"] == "NO_WEDDING_DRESS", "Reason is NO_WEDDING_DRESS")
+
+ # Add Tuxedo
+ inv.append({"vnum": WeddingMarriageSystem.VNUM_TUXEDO, "name": "新郎礼服"})
+
+ var wed_sig := {"fired": false, "spouse": ""}
+ sys.marriage_completed.connect(func(s: String):
+ wed_sig["fired"] = true
+ wed_sig["spouse"] = s
+ )
+
+ # 2. Successful wedding
+ var res_wed = sys.conduct_wedding("月神姬", inv, "M")
+ _assert(res_wed["ok"] == true, "Wedding ceremony succeeded")
+ _assert(sys.is_married == true, "is_married is true")
+ _assert(sys.spouse_name == "月神姬", "Spouse name is 月神姬")
+ _assert(inv[0]["vnum"] == WeddingMarriageSystem.VNUM_WEDDING_RING, "Slot 0 replaced with Wedding Ring (70302)")
+ _assert(wed_sig["fired"] == true, "marriage_completed signal emitted")
+ _assert(wed_sig["spouse"] == "月神姬", "Signal reported spouse name")
+
+func _test_wedding_ring_teleport() -> void:
+ print("\n--- Test 2: Wedding Ring Spouse Teleport ---")
+ var sys := WeddingMarriageSystem.new()
+ sys.is_married = true
+ sys.spouse_name = "月神姬"
+
+ var warp_sig := {"fired": false, "map": "", "pos": Vector2.ZERO}
+ sys.spouse_warped.connect(func(m: String, p: Vector2):
+ warp_sig["fired"] = true
+ warp_sig["map"] = m
+ warp_sig["pos"] = p
+ )
+
+ # 1. Spouse is offline
+ var res_offline = sys.use_wedding_ring(false, "Desert", Vector2(100, 200), "Yongan")
+ _assert(res_offline["ok"] == false, "Warp rejected: spouse offline")
+ _assert(res_offline["reason"] == "SPOUSE_OFFLINE", "Reason is SPOUSE_OFFLINE")
+
+ # 2. Forbidden map (Arena)
+ var res_arena = sys.use_wedding_ring(true, "Arena", Vector2(100, 200), "Yongan")
+ _assert(res_arena["ok"] == false, "Warp rejected in Arena")
+ _assert(res_arena["reason"] == "FORBIDDEN_MAP", "Reason is FORBIDDEN_MAP")
+
+ # 3. Successful warp
+ var res_succ = sys.use_wedding_ring(true, "Desert", Vector2(450, 780), "Yongan")
+ _assert(res_succ["ok"] == true, "Warp succeeded")
+ _assert(warp_sig["fired"] == true, "spouse_warped signal emitted")
+ _assert(warp_sig["map"] == "Desert", "Target map is Desert")
+ _assert(warp_sig["pos"] == Vector2(450, 780), "Target pos matches spouse")
+
+func _test_love_points_and_marriage_buffs() -> void:
+ print("\n--- Test 3: Love Points & Marriage Aura Buffs ---")
+ var sys := WeddingMarriageSystem.new()
+ sys.is_married = true
+ sys.love_points = 50
+
+ var love_sig := {"fired": false, "pts": 0}
+ sys.love_points_updated.connect(func(p: int):
+ love_sig["fired"] = true
+ love_sig["pts"] = p
+ )
+
+ sys.add_love_points(30) # 50 -> 80
+ _assert(sys.love_points == 80, "Love points increased to 80")
+ _assert(love_sig["fired"] == true, "love_points_updated signal emitted")
+
+ # Buff check when NOT near spouse
+ var buff_far = sys.calculate_marriage_buff(true, true, false)
+ _assert(buff_far.is_empty(), "No marriage buffs when distant from spouse")
+
+ # Buff check when near spouse: base (10 + 8) = 18% exp
+ # + Ring of Love (+20% exp) = 38%
+ # + Bracelet of Love (+10% crit)
+ var buff_near = sys.calculate_marriage_buff(true, true, true)
+ _assert(buff_near["couple_exp_bonus_pct"] == 38, "Couple exp bonus is 38% (18% + 20%)")
+ _assert(buff_near["crit_pct"] == 10, "Crit bonus is 10%")
+
+func _test_divorce_and_ring_clearing() -> void:
+ print("\n--- Test 4: Divorce & Wedding Ring Clearing ---")
+ var sys := WeddingMarriageSystem.new()
+ sys.is_married = true
+ sys.spouse_name = "月神姬"
+ sys.love_points = 80
+
+ var inv: Array = [
+ {"vnum": WeddingMarriageSystem.VNUM_WEDDING_RING, "name": "结婚戒指"}
+ ]
+
+ var div_sig := {"fired": false}
+ sys.divorced.connect(func():
+ div_sig["fired"] = true
+ )
+
+ var res_div = sys.divorce(inv)
+ _assert(res_div["ok"] == true, "Divorce succeeded")
+ _assert(sys.is_married == false, "is_married is now false")
+ _assert(sys.spouse_name.is_empty(), "Spouse name cleared")
+ _assert(sys.love_points == 0, "Love points cleared to 0")
+ _assert(inv[0] == null, "Wedding ring revoked and slot cleared")
+ _assert(div_sig["fired"] == true, "divorced signal emitted")
+
+func _test_serialization() -> void:
+ print("\n--- Test 5: Serialization & Restoration ---")
+ var sys1 := WeddingMarriageSystem.new()
+ sys1.is_married = true
+ sys1.spouse_name = "雪月花"
+ sys1.love_points = 95
+ sys1.marry_timestamp = 1600000000
+
+ var data = sys1.serialize()
+ var sys2 := WeddingMarriageSystem.new()
+ sys2.deserialize(data)
+
+ _assert(sys2.is_married == true, "Restored is_married")
+ _assert(sys2.spouse_name == "雪月花", "Restored spouse name")
+ _assert(sys2.love_points == 95, "Restored love points")
+ _assert(sys2.marry_timestamp == 1600000000, "Restored marry timestamp")
+
+func _finish() -> void:
+ print("\n=======================================================")
+ print("Wedding Marriage Parity Tests: %d Passed, %d Failed" % [_passed, _failed])
+ print("=======================================================\n")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_wedding_marriage_parity.gd.uid b/project/test_wedding_marriage_parity.gd.uid
new file mode 100644
index 00000000..e49f3ebe
--- /dev/null
+++ b/project/test_wedding_marriage_parity.gd.uid
@@ -0,0 +1 @@
+uid://c6i6w1uqdvn3n
diff --git a/project/test_whisper_chat_parity.gd b/project/test_whisper_chat_parity.gd
new file mode 100644
index 00000000..49b4143b
--- /dev/null
+++ b/project/test_whisper_chat_parity.gd
@@ -0,0 +1,112 @@
+# test_whisper_chat_parity.gd —— 40250 独立私聊窗口与任务栏闪烁通知回归测试
+extends SceneTree
+
+const SystemClass = preload("res://whisper_chat_system.gd")
+
+var _passed: int = 0
+var _failed: int = 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_whisper_chat_parity (Batch 27 Direction 2) ===")
+ _test_conversation_lifecycle()
+ _test_taskbar_blinking_notification()
+ _test_gamemaster_identification()
+ _test_ignore_blacklist_filtering()
+ _test_bidirectional_messaging()
+
+ print("\n=== Result: %d Passed, %d Failed ===" % [_passed, _failed])
+ if _failed == 0:
+ print("ALL TESTS PASSED!\n")
+ else:
+ printerr("SOME TESTS FAILED!\n")
+ quit(0 if _failed == 0 else 1)
+
+func _test_conversation_lifecycle() -> void:
+ print("\n--- Test 1: Conversation Lifecycle (Open / Minimize / Close) ---")
+ var w = SystemClass.new("Hero")
+
+ var conv = w.open_conversation("Alice")
+ _assert(conv != null, "Opened conversation with Alice")
+ _assert(conv.state == SystemClass.DialogState.VISIBLE, "Conversation is VISIBLE")
+
+ w.minimize_conversation("Alice")
+ _assert(conv.state == SystemClass.DialogState.MINIMIZED, "Conversation MINIMIZED to taskbar")
+
+ w.close_conversation("Alice")
+ _assert(conv.state == SystemClass.DialogState.CLOSED, "Conversation CLOSED")
+
+func _test_taskbar_blinking_notification() -> void:
+ print("\n--- Test 2: Taskbar Blinking & Unread Count ---")
+ var w = SystemClass.new("Hero")
+
+ # Receive message from Bob without active window
+ var r = w.receive_whisper("Bob", "Hello there!")
+ _assert(r["delivered"] == true, "Message delivered")
+ _assert(r["triggered_blink"] == true, "Triggered taskbar blinking notification")
+
+ var conv = w.conversations["Bob"]
+ _assert(conv.is_blinking == true, "Bob's conversation is blinking")
+ _assert(conv.unread_count == 1, "Unread count is 1")
+
+ # Receive second message
+ w.receive_whisper("Bob", "Are you there?")
+ _assert(conv.unread_count == 2, "Unread count increased to 2")
+
+ # Open conversation: should clear blinking and unread count
+ w.open_conversation("Bob")
+ _assert(conv.is_blinking == false, "Blinking cleared upon opening")
+ _assert(conv.unread_count == 0, "Unread count reset to 0")
+ _assert(conv.state == SystemClass.DialogState.VISIBLE, "Conversation state restored to VISIBLE")
+
+func _test_gamemaster_identification() -> void:
+ print("\n--- Test 3: Game Master [GM] Identification ---")
+ var w = SystemClass.new("Hero")
+
+ var r = w.receive_whisper("[GM]Admin", "Server maintenance in 10 minutes.")
+ _assert(r["delivered"] == true, "GM message delivered")
+ var conv = w.conversations["[GM]Admin"]
+ _assert(conv.is_gamemaster == true, "Identified as Game Master")
+ _assert(conv.history[0].is_gm == true, "Message tagged with is_gm flag")
+
+func _test_ignore_blacklist_filtering() -> void:
+ print("\n--- Test 4: Ignore Blacklist Filtering ---")
+ var w = SystemClass.new("Hero")
+
+ # Blacklist Spammer
+ var is_ign = w.toggle_ignore_target("Spammer")
+ _assert(is_ign == true, "Spammer added to ignore blacklist")
+ _assert(w.is_ignored("Spammer") == true, "Spammer is ignored")
+
+ # Receive whisper from Spammer
+ var r = w.receive_whisper("Spammer", "Buy gold cheap!")
+ _assert(r["delivered"] == false, "Spammer whisper rejected")
+ _assert(r["blocked_by_ignore"] == true, "Blocked by ignore filter")
+ _assert(w.get_blinking_conversations().is_empty(), "No blinking notifications triggered for ignored user")
+
+ # Un-ignore Spammer
+ var is_ign_again = w.toggle_ignore_target("Spammer")
+ _assert(is_ign_again == false, "Spammer removed from ignore blacklist")
+ var r2 = w.receive_whisper("Spammer", "Now you can hear me")
+ _assert(r2["delivered"] == true, "Whisper delivered after un-ignoring")
+
+func _test_bidirectional_messaging() -> void:
+ print("\n--- Test 5: Bidirectional Messaging History ---")
+ var w = SystemClass.new("Hero")
+
+ w.send_whisper("Alice", "Hey Alice, let's do Demon Tower!")
+ w.current_time += 10
+ w.receive_whisper("Alice", "Sure, meet at DT entrance!")
+
+ var conv = w.conversations["Alice"]
+ _assert(conv.history.size() == 2, "Conversation history contains 2 messages")
+ _assert(conv.history[0].sender == "Hero", "First message sent by Hero")
+ _assert(conv.history[1].sender == "Alice", "Second message sent by Alice")
+ _assert(conv.history[1].timestamp == 1010, "Timestamp recorded correctly")
diff --git a/project/test_whisper_chat_parity.gd.uid b/project/test_whisper_chat_parity.gd.uid
new file mode 100644
index 00000000..23625f8c
--- /dev/null
+++ b/project/test_whisper_chat_parity.gd.uid
@@ -0,0 +1 @@
+uid://sxrkojeyqa3m
diff --git a/project/test_world_boss_ai_behavior_parity.gd b/project/test_world_boss_ai_behavior_parity.gd
new file mode 100644
index 00000000..353c3bfb
--- /dev/null
+++ b/project/test_world_boss_ai_behavior_parity.gd
@@ -0,0 +1,177 @@
+# test_world_boss_ai_behavior_parity.gd —— 40250 世界首领动态 AI 行为状态机 1:1 回归测试套件
+extends SceneTree
+
+const WorldBossAISystem = preload("res://world_boss_ai_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_world_boss_ai_behavior_parity (Direction 1: World Boss AI 1:1) ===")
+ _test_boss_initialization()
+ _test_aggro_and_chase()
+ _test_skills_and_attacks()
+ _test_enrage_mechanic()
+ _test_leash_and_reset()
+ _test_death()
+ _finish()
+
+func _test_boss_initialization() -> void:
+ print("\n--- Test 1: Boss Initialization & Table Config ---")
+ var ai := WorldBossAISystem.new()
+ var fail = ai.init_boss(99999)
+ _assert(fail["ok"] == false and fail["reason"] == "INVALID_BOSS_VNUM", "Invalid boss vnum rejected")
+
+ var vnums = [1901, 1304, 2191, 2206]
+ for v in vnums:
+ var init_res = ai.init_boss(v, Vector3(100, 0, 100))
+ _assert(init_res["ok"] == true, "Boss %d initialized" % v)
+ _assert(ai.current_state == WorldBossAISystem.STATE_IDLE, "Starts in IDLE state")
+ _assert(ai.current_hp == ai.max_hp, "HP at max")
+
+func _test_aggro_and_chase() -> void:
+ print("\n--- Test 2: Aggro Acquisition & Chase Mechanics ---")
+ var ai := WorldBossAISystem.new()
+ ai.init_boss(1901, Vector3.ZERO)
+
+ var aggro_sig := {"fired": false, "id": "", "dist": 0.0}
+ ai.target_acquired.connect(func(tid: String, d: float):
+ aggro_sig["fired"] = true
+ aggro_sig["id"] = tid
+ aggro_sig["dist"] = d
+ )
+
+ # Target 25m away (beyond 18m AGGRO_RADIUS)
+ var step1 = ai.update_ai(0.1, Vector3(25, 0, 0), "player", true)
+ _assert(aggro_sig["fired"] == false, "No aggro at 25m")
+ _assert(step1["action"] == "wandering", "Wandering when target is far")
+
+ # Target enters 15m (within 18m AGGRO_RADIUS)
+ var step2 = ai.update_ai(0.1, Vector3(15, 0, 0), "player", true)
+ _assert(aggro_sig["fired"] == true and aggro_sig["id"] == "player", "Target acquired signal fired at 15m")
+ _assert(step2["state"] == WorldBossAISystem.STATE_CHASE, "Transitions to CHASE state")
+ _assert(ai.current_position.x > 0.0, "Boss stepped forward towards player")
+
+func _test_skills_and_attacks() -> void:
+ print("\n--- Test 3: Skill Casting & Basic Attacks ---")
+ var ai := WorldBossAISystem.new()
+ ai.init_boss(1901, Vector3.ZERO) # Nine tails: Atk 850, Def 450, Skill: 暴风雪呼啸, mult 1.8
+
+ var skill_sig := {"fired": false, "name": "", "dmg": 0}
+ ai.skill_cast.connect(func(sname: String, dmg: int, _aoe: float):
+ skill_sig["fired"] = true
+ skill_sig["name"] = sname
+ skill_sig["dmg"] = dmg
+ )
+
+ # Target at 5.0m (within 9m SKILL_RANGE, but beyond 3.5m ATTACK_RANGE)
+ var skill_act = ai.update_ai(0.1, Vector3(5, 0, 0), "player", true)
+ _assert(skill_act["state"] == WorldBossAISystem.STATE_SKILL, "Casts skill when in skill range")
+ _assert(skill_sig["fired"] == true and skill_sig["name"] == "暴风雪呼啸", "Skill signal emitted with correct name")
+ _assert(skill_sig["dmg"] == int(850 * 1.8), "Skill damage is base_atk * 1.8")
+
+ # Target closes to 2.0m (within 3.5m ATTACK_RANGE)
+ # Skill is now on cooldown, should perform basic attack
+ var atk_act = ai.update_ai(0.1, Vector3(2, 0, 0), "player", true)
+ _assert(atk_act["state"] == WorldBossAISystem.STATE_ATTACK, "Executes basic attack within melee range")
+ _assert(atk_act["damage"] == 850, "Basic attack damage matches base_atk")
+
+func _test_enrage_mechanic() -> void:
+ print("\n--- Test 4: Enrage Mechanic (< 30% HP) ---")
+ var ai := WorldBossAISystem.new()
+ ai.init_boss(1304, Vector3.ZERO) # 1304: HP 320000, Def 480, Atk 920
+
+ var enrage_sig := {"fired": false, "mult": 0.0}
+ ai.boss_enraged.connect(func(m: float):
+ enrage_sig["fired"] = true
+ enrage_sig["mult"] = m
+ )
+
+ # Deal damage down to ~40% HP (no enrage)
+ # Max HP = 320000. 40% = 128000. Damage needed = 192000 net dmg.
+ # Raw dmg = 192000 + 480 = 192480.
+ var hit1 = ai.receive_damage(192480, "player")
+ _assert(hit1["is_enraged"] == false, "Not enraged at 40% HP")
+ _assert(enrage_sig["fired"] == false, "boss_enraged signal not yet fired")
+
+ # Deal damage down to 25% HP (< 30% HP trigger)
+ # Need 50000 more net dmg.
+ var hit2 = ai.receive_damage(50000 + 480, "player")
+ _assert(hit2["is_enraged"] == true, "Enraged triggered at <= 30% HP")
+ _assert(enrage_sig["fired"] == true and enrage_sig["mult"] == 1.5, "boss_enraged signal emitted with 1.5x")
+
+ # Attack while enraged has 1.5x damage
+ # 1. Enraged skill cast
+ var enraged_skill = ai.update_ai(0.1, Vector3(5, 0, 0), "player", true)
+ _assert(enraged_skill["state"] == WorldBossAISystem.STATE_SKILL, "Enraged skill cast triggered")
+ _assert(enraged_skill["damage"] == int(920 * 2.0 * 1.5), "Enraged skill gets 1.5x damage multiplier")
+
+ # 2. Enraged basic attack (skill is now on cooldown)
+ var enraged_atk = ai.update_ai(0.1, Vector3(2, 0, 0), "player", true)
+ _assert(enraged_atk["state"] == WorldBossAISystem.STATE_ATTACK, "Enraged basic attack triggered")
+ _assert(enraged_atk["damage"] == int(920 * 1.5), "Enraged basic attack gets 1.5x damage")
+
+func _test_leash_and_reset() -> void:
+ print("\n--- Test 5: Leash Distance & Full Reset ---")
+ var ai := WorldBossAISystem.new()
+ ai.init_boss(2191, Vector3.ZERO) # Spawn at (0, 0, 0)
+
+ var leash_start := {"fired": false}
+ var leash_done := {"fired": false}
+ ai.leash_reset_started.connect(func(): leash_start["fired"] = true)
+ ai.leash_reset_completed.connect(func(): leash_done["fired"] = true)
+
+ # Boss takes damage
+ ai.receive_damage(100000, "player")
+ _assert(ai.current_hp < ai.max_hp, "Boss damaged")
+
+ # Move boss far beyond 35m LEASH_DISTANCE
+ ai.current_position = Vector3(40, 0, 0)
+ var leash_act = ai.update_ai(0.1, Vector3(45, 0, 0), "player", true)
+ _assert(leash_act["state"] == WorldBossAISystem.STATE_LEASH_RETURN, "Enters LEASH_RETURN state")
+ _assert(leash_start["fired"] == true, "leash_reset_started signal fired")
+
+ # In leash return, attacks are immune
+ var immune_hit = ai.receive_damage(50000, "player")
+ _assert(immune_hit.get("immune", false) == true, "Attacks during leash return are IMMUNE")
+
+ # Simulate returning to spawn point
+ while ai.current_state == WorldBossAISystem.STATE_LEASH_RETURN:
+ ai.update_ai(0.5, Vector3(45, 0, 0), "player", true)
+
+ _assert(leash_done["fired"] == true, "leash_reset_completed signal fired")
+ _assert(ai.current_state == WorldBossAISystem.STATE_IDLE, "Boss back in IDLE")
+ _assert(ai.current_hp == ai.max_hp, "Boss HP fully restored to 100%")
+ _assert(ai.current_position.distance_to(Vector3.ZERO) < 0.1, "Boss back at spawn point")
+
+func _test_death() -> void:
+ print("\n--- Test 6: Death State ---")
+ var ai := WorldBossAISystem.new()
+ ai.init_boss(2206, Vector3.ZERO)
+
+ var fatal = ai.receive_damage(10000000, "player")
+ _assert(fatal["dead"] == true, "Fatal hit flags dead == true")
+ _assert(ai.current_state == WorldBossAISystem.STATE_DEAD, "State becomes DEAD")
+
+ var after_death = ai.update_ai(0.1, Vector3.ZERO, "player", true)
+ _assert(after_death["state"] == WorldBossAISystem.STATE_DEAD and after_death["action"] == "none", "Dead boss does nothing")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("World Boss AI Parity Tests Finished:")
+ print("Passed: %d, Failed: %d" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ printerr("FAILED: Some tests did not pass.")
+ quit(1)
+ else:
+ print("SUCCESS: All World Boss AI parity tests passed!")
+ quit(0)
diff --git a/project/test_world_boss_ai_behavior_parity.gd.uid b/project/test_world_boss_ai_behavior_parity.gd.uid
new file mode 100644
index 00000000..4a490bd0
--- /dev/null
+++ b/project/test_world_boss_ai_behavior_parity.gd.uid
@@ -0,0 +1 @@
+uid://duopqhpsqmi1l
diff --git a/project/test_world_boss_parity.gd b/project/test_world_boss_parity.gd
new file mode 100644
index 00000000..eaf028b6
--- /dev/null
+++ b/project/test_world_boss_parity.gd
@@ -0,0 +1,204 @@
+# test_world_boss_parity.gd —— 40250 野外四大世界 Boss 1:1 纯净回归测试套件
+extends SceneTree
+
+const WorldBossSystem = preload("res://world_boss_system.gd")
+
+var _passed := 0
+var _failed := 0
+
+func _assert(cond: bool, msg: String) -> void:
+ if cond:
+ _passed += 1
+ print(" [PASS] %s" % msg)
+ else:
+ _failed += 1
+ printerr(" [FAIL] %s" % msg)
+
+func _init() -> void:
+ print("\n=== Running test_world_boss_parity (Direction 3: World Bosses & Chests 1:1) ===")
+ _test_boss_table_and_spawn()
+ _test_damage_mitigation_and_phase_minions()
+ _test_enrage_mechanic()
+ _test_boss_kill_and_drops()
+ _test_boss_chest_opening()
+ _finish()
+
+func _test_boss_table_and_spawn() -> void:
+ print("\n--- Test 1: Boss Config Table & Spawning ---")
+ var wbs := WorldBossSystem.new()
+
+ # Invalid boss vnum
+ var invalid_res = wbs.spawn_boss(99999)
+ _assert(invalid_res["ok"] == false and invalid_res["reason"] == "INVALID_BOSS_VNUM", "Invalid boss vnum rejected")
+
+ # Check table contains the 4 iconic world bosses
+ _assert(WorldBossSystem.BOSS_TABLE.has(1901), "Nine Tails (1901) defined")
+ _assert(WorldBossSystem.BOSS_TABLE.has(2191), "Desert Tortoise (2191) defined")
+ _assert(WorldBossSystem.BOSS_TABLE.has(1304), "Yellow Tiger (1304) defined")
+ _assert(WorldBossSystem.BOSS_TABLE.has(2206), "Flame King (2206) defined")
+
+ # Spawn Nine Tails (1901)
+ var spawn_data := {"fired": false, "name": "", "vnum": 0}
+ wbs.boss_spawned.connect(func(vnum: int, bname: String, _pos: Vector3):
+ spawn_data["fired"] = true
+ spawn_data["name"] = bname
+ spawn_data["vnum"] = vnum
+ )
+
+ var spawn_pos := Vector3(50.0, 0.0, -120.0)
+ var res = wbs.spawn_boss(1901, spawn_pos)
+ _assert(res["ok"] == true, "Nine Tails spawned successfully")
+ _assert(wbs.is_boss_alive == true, "Boss is alive")
+ _assert(wbs.active_boss["max_hp"] == 280000, "Nine Tails max HP is 280,000")
+ _assert(wbs.active_boss["base_atk"] == 850, "Nine Tails base ATK is 850")
+ _assert(wbs.active_boss["base_def"] == 450, "Nine Tails base DEF is 450")
+ _assert(spawn_data["fired"] == true, "boss_spawned signal emitted")
+ _assert(spawn_data["name"] == "九尾妖狐", "Boss name matches")
+
+func _test_damage_mitigation_and_phase_minions() -> void:
+ print("\n--- Test 2: DEF Mitigation & Phase Minion Spawns (75%, 50%, 25%) ---")
+ var wbs := WorldBossSystem.new()
+ wbs.spawn_boss(1901) # HP: 280000, DEF: 450
+
+ var minion_events := {"count": 0, "last_minion_vnum": 0, "last_count": 0}
+ wbs.minions_summoned.connect(func(_bvnum: int, mvnum: int, count: int):
+ minion_events["count"] += 1
+ minion_events["last_minion_vnum"] = mvnum
+ minion_events["last_count"] = count
+ )
+
+ # 1. Normal damage without crossing threshold: Raw 1450 - DEF 450 = 1000 dmg
+ var res1 = wbs.apply_damage(1450)
+ _assert(res1["ok"] == true, "Damage applied successfully")
+ _assert(res1["net_dmg"] == 1000, "1450 raw dmg - 450 def = 1000 net dmg")
+ _assert(wbs.active_boss["current_hp"] == 279000, "HP updated to 279,000")
+ _assert(minion_events["count"] == 0, "No minions summoned yet")
+
+ # 2. Damage boss to trigger 75% threshold (280000 * 0.75 = 210000 HP threshold)
+ # Need 69,000 net damage -> raw damage 69,450
+ var res2 = wbs.apply_damage(69450)
+ _assert(wbs.active_boss["current_hp"] == 210000, "Boss at exactly 75% HP (210,000)")
+ _assert(res2["triggered_events"].has("PHASE_75_MINIONS"), "PHASE_75_MINIONS triggered")
+ _assert(minion_events["count"] == 1, "Minion summon signal fired 1 time")
+ _assert(minion_events["last_minion_vnum"] == 2204, "Summoned Ice Yeti minions (vnum 2204)")
+ _assert(minion_events["last_count"] == 4, "Summoned 4 minions")
+
+ # Additional attack should not re-trigger 75%
+ var res3 = wbs.apply_damage(1450)
+ _assert(not res3["triggered_events"].has("PHASE_75_MINIONS"), "75% phase does not retrigger")
+
+ # 3. Damage boss to cross 50% threshold (140,000 HP)
+ # Current HP is 209,000. Need 69,000 net damage -> raw 69,450
+ var res4 = wbs.apply_damage(69450)
+ _assert(wbs.active_boss["current_hp"] == 140000, "Boss at 50% HP (140,000)")
+ _assert(res4["triggered_events"].has("PHASE_50_MINIONS"), "PHASE_50_MINIONS triggered")
+ _assert(minion_events["count"] == 2, "Minion summon count now 2")
+
+func _test_enrage_mechanic() -> void:
+ print("\n--- Test 3: Enrage Mechanic at <= 30% HP (+50% ATK) ---")
+ var wbs := WorldBossSystem.new()
+ wbs.spawn_boss(2206) # Flame King: HP 400000, Base ATK 1200, Base DEF 500
+
+ var enrage_data := {"fired": false, "boss_vnum": 0}
+ wbs.boss_enraged.connect(func(bvnum: int):
+ enrage_data["fired"] = true
+ enrage_data["boss_vnum"] = bvnum
+ )
+
+ _assert(wbs.active_boss["is_enraged"] == false, "Boss initially not enraged")
+ _assert(wbs.active_boss["current_atk"] == 1200, "Base ATK is 1200")
+
+ # 30% of 400,000 = 120,000 HP.
+ # Deal 280,000 net damage -> raw damage 280,500
+ var res = wbs.apply_damage(280500)
+ _assert(wbs.active_boss["current_hp"] == 120000, "Boss HP reached 120,000 (30%)")
+ _assert(res["triggered_events"].has("BOSS_ENRAGED"), "BOSS_ENRAGED triggered in events")
+ _assert(enrage_data["fired"] == true, "boss_enraged signal fired")
+ _assert(enrage_data["boss_vnum"] == 2206, "Enraged boss vnum matches Flame King (2206)")
+ _assert(wbs.active_boss["is_enraged"] == true, "is_enraged is now true")
+ _assert(wbs.active_boss["current_atk"] == 1800, "ATK increased by 50% to 1800")
+
+func _test_boss_kill_and_drops() -> void:
+ print("\n--- Test 4: Boss Kill & Guaranteed Chest Drops ---")
+ var wbs := WorldBossSystem.new()
+ wbs.spawn_boss(1304) # Yellow Tiger: HP 350000, DEF 480, Chest: 50008
+
+ var kill_data := {"fired": false, "drops": []}
+ wbs.boss_killed.connect(func(_bvnum: int, _pos: Vector3, drops: Array):
+ kill_data["fired"] = true
+ kill_data["drops"] = drops
+ )
+
+ # Deal lethal damage
+ var kill_res = wbs.apply_damage(500000)
+ _assert(kill_res["ok"] == true and kill_res["killed"] == true, "Boss defeated")
+ _assert(wbs.is_boss_alive == false, "Boss is dead")
+ _assert(kill_data["fired"] == true, "boss_killed signal fired")
+
+ var drops: Array = kill_res["drops"]
+ _assert(drops.size() >= 4, "Drops include chest, soul stone, scroll, and gold")
+
+ # Verify guaranteed chest (50008)
+ var has_chest := false
+ var has_soul_stone := false
+ var has_scroll := false
+ var has_gold := false
+
+ for d in drops:
+ if d["vnum"] == 50008:
+ has_chest = true
+ elif d["vnum"] == 50513:
+ has_soul_stone = true
+ elif d["vnum"] == 25040:
+ has_scroll = true
+ elif d["vnum"] == 1 and d["count"] >= 70000:
+ has_gold = true
+
+ _assert(has_chest, "Guaranteed Yellow Tiger Chest (50008) dropped")
+ _assert(has_soul_stone, "Guaranteed Soul Stone (50513) dropped")
+ _assert(has_scroll, "Guaranteed Blessing Scroll (25040) dropped")
+ _assert(has_gold, "Gold dropped within range (>= 70,000)")
+
+func _test_boss_chest_opening() -> void:
+ print("\n--- Test 5: World Boss Chest Opening (40250 Loot Pool) ---")
+ var wbs := WorldBossSystem.new()
+ var inv: Array = [
+ {"vnum": 50006, "name": "九尾妖狐宝箱", "count": 1},
+ null,
+ null
+ ]
+
+ # Invalid slot
+ var invalid_res = wbs.open_boss_chest(99, inv)
+ _assert(invalid_res["ok"] == false and invalid_res["reason"] == "INVALID_SLOT", "Invalid slot rejected")
+
+ # Empty slot
+ var empty_res = wbs.open_boss_chest(1, inv)
+ _assert(empty_res["ok"] == false and empty_res["reason"] == "EMPTY_SLOT", "Empty slot rejected")
+
+ # Non-chest item
+ inv[1] = {"vnum": 10, "name": "木剑", "count": 1}
+ var non_chest_res = wbs.open_boss_chest(1, inv)
+ _assert(non_chest_res["ok"] == false and non_chest_res["reason"] == "NOT_A_BOSS_CHEST", "Non-chest item rejected")
+
+ # Open Nine Tails Chest (50006) at slot 0
+ var res = wbs.open_boss_chest(0, inv)
+ _assert(res["ok"] == true, "Chest opened successfully")
+
+ var reward = res["reward"]
+ _assert(reward.has("vnum") and reward.has("name"), "Valid reward generated")
+ print(" Received chest reward: %s (vnum: %d, count: %d)" % [reward["name"], reward["vnum"], reward.get("count", 1)])
+
+ var stored_slot = res["stored_slot"]
+ _assert(stored_slot >= 0 and inv[stored_slot] != null, "Reward saved in inventory")
+ _assert(inv[stored_slot]["vnum"] == reward["vnum"], "Inventory item matches rewarded vnum")
+ _assert(inv[stored_slot]["vnum"] != 50006, "Chest (50006) was consumed and replaced")
+
+func _finish() -> void:
+ print("\n==========================================")
+ print("World Boss Parity Test Results: %d Passed, %d Failed" % [_passed, _failed])
+ print("==========================================")
+ if _failed > 0:
+ quit(1)
+ else:
+ quit(0)
diff --git a/project/test_world_boss_parity.gd.uid b/project/test_world_boss_parity.gd.uid
new file mode 100644
index 00000000..7fef651f
--- /dev/null
+++ b/project/test_world_boss_parity.gd.uid
@@ -0,0 +1 @@
+uid://3m5i6jxicjrd
diff --git a/project/testing/forest_viewpoints.gd.uid b/project/testing/forest_viewpoints.gd.uid
new file mode 100644
index 00000000..a4738a1f
--- /dev/null
+++ b/project/testing/forest_viewpoints.gd.uid
@@ -0,0 +1 @@
+uid://pcmal6a04puk
diff --git a/project/testing/playable_config.gd.uid b/project/testing/playable_config.gd.uid
new file mode 100644
index 00000000..06426176
--- /dev/null
+++ b/project/testing/playable_config.gd.uid
@@ -0,0 +1 @@
+uid://82qi64ixdww6
diff --git a/project/testing/playable_flow.gd.uid b/project/testing/playable_flow.gd.uid
new file mode 100644
index 00000000..04bb42ac
--- /dev/null
+++ b/project/testing/playable_flow.gd.uid
@@ -0,0 +1 @@
+uid://b4rgv8d34qpjw
diff --git a/project/testing/playable_metrics.gd.uid b/project/testing/playable_metrics.gd.uid
new file mode 100644
index 00000000..282ed79f
--- /dev/null
+++ b/project/testing/playable_metrics.gd.uid
@@ -0,0 +1 @@
+uid://bu1e7s5yb3p1j
diff --git a/project/testing/playable_probe.gd.uid b/project/testing/playable_probe.gd.uid
new file mode 100644
index 00000000..ac305116
--- /dev/null
+++ b/project/testing/playable_probe.gd.uid
@@ -0,0 +1 @@
+uid://cyv8rusbrscco
diff --git a/project/testing/playable_report.gd.uid b/project/testing/playable_report.gd.uid
new file mode 100644
index 00000000..8a8d0ccf
--- /dev/null
+++ b/project/testing/playable_report.gd.uid
@@ -0,0 +1 @@
+uid://uiha4rsvsy6j
diff --git a/project/testing/playable_window.gd.uid b/project/testing/playable_window.gd.uid
new file mode 100644
index 00000000..b8977ed7
--- /dev/null
+++ b/project/testing/playable_window.gd.uid
@@ -0,0 +1 @@
+uid://bv2u35qomry7a
diff --git a/project/title_achievement_system.gd b/project/title_achievement_system.gd
new file mode 100644
index 00000000..d8633a2b
--- /dev/null
+++ b/project/title_achievement_system.gd
@@ -0,0 +1,130 @@
+# title_achievement_system.gd —— Metin2 40250 殿堂级头衔与成就系统 1:1
+# 对照 40250 服务端 char.cpp, questmanager.cpp, locale_string.txt
+class_name TitleAchievementSystem
+extends RefCounted
+
+signal achievement_unlocked(ach_id: String, ach_name: String, title: String)
+signal title_equipped(title_name: String, bonuses: Dictionary)
+signal title_unequipped()
+
+const ACHIEVEMENTS: Dictionary = {
+ "ACH_DEMON_TOWER": {
+ "name": "恶魔之塔登顶",
+ "desc": "突破恶魔之塔 9 层并斩杀死神",
+ "title": "恶魔征服者",
+ "points": 50,
+ "bonuses": {"max_hp": 500}
+ },
+ "ACH_SPIDER_BARONESS": {
+ "name": "蛛皇陨落",
+ "desc": "在蜘蛛洞穴三层巢穴击败蜘蛛男爵夫人",
+ "title": "蛛巢克星",
+ "points": 50,
+ "bonuses": {"monster_dmg_pct": 5}
+ },
+ "ACH_BERAN_SETAOU": {
+ "name": "冰龙之诛",
+ "desc": "击破四大魔石并消灭冰龙贝兰-塞陶",
+ "title": "屠龙勇士",
+ "points": 100,
+ "bonuses": {"boss_dmg_pct": 10}
+ },
+ "ACH_AZRAEL": {
+ "name": "冥王末日",
+ "desc": "穿过地下墓穴七层击破冥王阿兹瑞尔",
+ "title": "深渊征服者",
+ "points": 100,
+ "bonuses": {"def": 50}
+ },
+ "ACH_FOUR_WORLD_BOSSES": {
+ "name": "四方平定",
+ "desc": "击杀野外九尾妖狐、沙漠龟、黄虎之魂与烈焰领主",
+ "title": "大陆守护者",
+ "points": 150,
+ "bonuses": {"critical_pct": 5}
+ },
+ "ACH_SKILL_GRANDMASTER": {
+ "name": "大巧不工",
+ "desc": "将任意核心战斗技能修炼至 P 级完美境界",
+ "title": "武学宗师",
+ "points": 80,
+ "bonuses": {"cast_speed": 10}
+ },
+ "ACH_EPIC_BLACKSMITH": {
+ "name": "神兵天成",
+ "desc": "成功完成修元平史诗武器或蓝钢神甲升阶转换",
+ "title": "神工锻造师",
+ "points": 80,
+ "bonuses": {"att": 50}
+ }
+}
+
+var unlocked_achievements: Dictionary = {} # ach_id -> bool
+var unlocked_titles: Array = [] # list of title strings
+var total_achievement_points: int = 0
+var active_title: String = ""
+
+# 追踪并达成成就
+func complete_achievement(ach_id: String) -> Dictionary:
+ if not ACHIEVEMENTS.has(ach_id):
+ return {"ok": false, "reason": "INVALID_ACH_ID", "msg": "未知的成就标识!"}
+
+ if unlocked_achievements.get(ach_id, false):
+ return {"ok": false, "reason": "ALREADY_COMPLETED", "msg": "该成就已达成!"}
+
+ var cfg: Dictionary = ACHIEVEMENTS[ach_id]
+ unlocked_achievements[ach_id] = true
+ total_achievement_points += int(cfg["points"])
+
+ var title_name: String = cfg["title"]
+ if not unlocked_titles.has(title_name):
+ unlocked_titles.append(title_name)
+
+ achievement_unlocked.emit(ach_id, cfg["name"], title_name)
+
+ return {
+ "ok": true,
+ "ach_id": ach_id,
+ "name": cfg["name"],
+ "title": title_name,
+ "points_awarded": cfg["points"],
+ "total_points": total_achievement_points,
+ "msg": "达成里程碑成就【%s】!解锁荣耀称号【%s】!" % [cfg["name"], title_name]
+ }
+
+# 佩戴称号
+func equip_title(title_name: String) -> Dictionary:
+ if not unlocked_titles.has(title_name):
+ return {"ok": false, "reason": "TITLE_NOT_UNLOCKED", "msg": "你尚未解锁该头衔称号!"}
+
+ active_title = title_name
+ var bonuses := get_active_bonuses()
+
+ title_equipped.emit(title_name, bonuses)
+
+ return {
+ "ok": true,
+ "active_title": active_title,
+ "bonuses": bonuses,
+ "msg": "已成功佩戴头衔【%s】!战力属性已生效!" % title_name
+ }
+
+# 卸下称号
+func unequip_title() -> void:
+ active_title = ""
+ title_unequipped.emit()
+
+# 获取当前佩戴称号的属性加成
+func get_active_bonuses() -> Dictionary:
+ if active_title.is_empty():
+ return {}
+
+ for cfg in ACHIEVEMENTS.values():
+ if cfg["title"] == active_title:
+ return cfg["bonuses"].duplicate()
+
+ return {}
+
+# 查询成就状态
+func is_achievement_unlocked(ach_id: String) -> bool:
+ return unlocked_achievements.get(ach_id, false)
diff --git a/project/title_achievement_system.gd.uid b/project/title_achievement_system.gd.uid
new file mode 100644
index 00000000..d471e3ef
--- /dev/null
+++ b/project/title_achievement_system.gd.uid
@@ -0,0 +1 @@
+uid://c5nyy71wgaie6
diff --git a/project/title_trophy_buff_system.gd b/project/title_trophy_buff_system.gd
new file mode 100644
index 00000000..752408aa
--- /dev/null
+++ b/project/title_trophy_buff_system.gd
@@ -0,0 +1,173 @@
+# title_trophy_buff_system.gd —— Metin2 40250 真实称号词条战力加成与奖杯成就图鉴 1:1
+# 对照 40250 服务端 affect.h, cmd_general.cpp, title.cpp, char_affect.cpp
+class_name TitleTrophyBuffSystem
+extends RefCounted
+
+signal trophy_milestone_reached(tier: int, trophy_name: String, bonus_desc: String)
+signal title_stat_buffs_applied(title_name: String, buffs: Dictionary)
+signal title_stat_buffs_removed(title_name: String)
+
+# 经典称号与专属真实战力词条池
+const TITLES_CATALOG: Dictionary = {
+ "屠龙尊者": {
+ "desc": "贝雷斯水龙终结者,威震天界之峰",
+ "buffs": {
+ "boss_dmg_pct": 15,
+ "max_hp": 2000,
+ "crit_pct": 5
+ }
+ },
+ "恶魔克星": {
+ "desc": "诛灭死神与阿兹瑞尔,冥府幽界之灾",
+ "buffs": {
+ "undead_dmg_pct": 15,
+ "magic_resist": 10,
+ "def": 60
+ }
+ },
+ "魔石终结者": {
+ "desc": "踏平八荒八档魔石,破除虚空陨铁",
+ "buffs": {
+ "metin_dmg_pct": 12,
+ "atk": 50,
+ "penetration_pct": 5
+ }
+ },
+ "地牢速通王": {
+ "desc": "创下各大地牢副本最速纪录的疾风勇者",
+ "buffs": {
+ "move_speed": 15,
+ "cast_speed": 10,
+ "max_sp": 1000
+ }
+ },
+ "百战名将": {
+ "desc": "力克四大野外世界首领,声震三界",
+ "buffs": {
+ "atk": 80,
+ "def": 50,
+ "max_hp": 1500
+ }
+ },
+ "商海巨擘": {
+ "desc": "富甲一方,日进斗金的广场交易领主",
+ "buffs": {
+ "gold_bonus_pct": 20,
+ "max_hp": 500
+ }
+ },
+ "武学至尊": {
+ "desc": "多门绝技臻至 P 级圆满的盖世宗师",
+ "buffs": {
+ "skill_dmg_pct": 10,
+ "cast_speed": 15,
+ "atk": 60
+ }
+ }
+}
+
+# 奖杯累积成就里程碑 (永久被动战力加成)
+const TROPHY_MILESTONES: Dictionary = {
+ 1: {"pts": 50, "name": "青铜试炼奖杯", "bonus": {"all_stats": 3}, "desc": "全属性 +3"},
+ 2: {"pts": 150, "name": "白银征服奖杯", "bonus": {"max_hp": 1000, "drop_rate_pct": 5}, "desc": "生命上限 +1000,掉宝率 +5%"},
+ 3: {"pts": 300, "name": "黄金霸主奖杯", "bonus": {"atk": 50, "crit_pct": 5}, "desc": "攻击力 +50,暴击率 +5%"},
+ 4: {"pts": 500, "name": "铂金封神奖杯", "bonus": {"boss_dmg_pct": 10, "exp_bonus_pct": 10}, "desc": "首领伤害 +10%,经验获取 +10%"}
+}
+
+var active_title: String = ""
+var active_title_buffs: Dictionary = {}
+var unlocked_titles: Array = []
+var trophy_points: int = 0
+var reached_milestones: Array = []
+
+# 解锁新称号
+func unlock_title(title_name: String, awarded_points: int = 50) -> Dictionary:
+ if not TITLES_CATALOG.has(title_name):
+ return {"ok": false, "reason": "UNKNOWN_TITLE", "msg": "未知的称号!"}
+
+ if not (title_name in unlocked_titles):
+ unlocked_titles.append(title_name)
+ trophy_points += awarded_points
+ _check_trophy_milestones()
+ return {"ok": true, "title": title_name, "points": trophy_points, "msg": "解锁了殿堂称号【%s】!" % title_name}
+
+ return {"ok": true, "already_unlocked": true, "title": title_name}
+
+# 佩戴指定称号并动态注入角色属性面板
+func equip_title(title_name: String, player_data: Dictionary) -> Dictionary:
+ if not (title_name in unlocked_titles):
+ return {"ok": false, "reason": "TITLE_NOT_UNLOCKED", "msg": "尚未解锁该称号!"}
+
+ # 先卸下旧称号属性
+ if not active_title.is_empty():
+ _remove_title_buffs(player_data)
+
+ active_title = title_name
+ var cfg = TITLES_CATALOG[title_name]
+ active_title_buffs = cfg["buffs"].duplicate(true)
+
+ _apply_buffs_to_player(player_data, active_title_buffs)
+ title_stat_buffs_applied.emit(active_title, active_title_buffs)
+
+ return {
+ "ok": true,
+ "title": active_title,
+ "buffs": active_title_buffs,
+ "msg": "已佩戴称号【%s】!获得了专属战力加成。" % active_title
+ }
+
+# 卸下当前称号
+func unequip_title(player_data: Dictionary) -> Dictionary:
+ if active_title.is_empty():
+ return {"ok": false, "reason": "NO_TITLE_EQUIPPED"}
+
+ var old = active_title
+ _remove_title_buffs(player_data)
+ active_title = ""
+ active_title_buffs.clear()
+
+ title_stat_buffs_removed.emit(old)
+
+ return {"ok": true, "msg": "已卸下称号【%s】。" % old}
+
+# 将 Buff 加成数值安全叠加入 player_data
+func _apply_buffs_to_player(player_data: Dictionary, buffs: Dictionary) -> void:
+ for stat_key in buffs.keys():
+ var val = buffs[stat_key]
+ player_data[stat_key] = player_data.get(stat_key, 0) + val
+
+# 从 player_data 中精准反扣旧 Buff 加成数值
+func _remove_title_buffs(player_data: Dictionary) -> void:
+ for stat_key in active_title_buffs.keys():
+ var val = active_title_buffs[stat_key]
+ player_data[stat_key] = player_data.get(stat_key, 0) - val
+
+# 检测奖杯里程碑晋阶
+func _check_trophy_milestones() -> void:
+ for tier in [1, 2, 3, 4]:
+ if not (tier in reached_milestones):
+ var req = TROPHY_MILESTONES[tier]["pts"]
+ if trophy_points >= req:
+ reached_milestones.append(tier)
+ var m = TROPHY_MILESTONES[tier]
+ trophy_milestone_reached.emit(tier, m["name"], m["desc"])
+
+# 获取所有奖杯被动被动加成总计
+func get_total_trophy_passive_bonuses() -> Dictionary:
+ var total: Dictionary = {}
+ for tier in reached_milestones:
+ var b = TROPHY_MILESTONES[tier]["bonus"]
+ for k in b.keys():
+ total[k] = total.get(k, 0) + b[k]
+ return total
+
+# 获取当前全部战力属性摘要
+func get_title_status() -> Dictionary:
+ return {
+ "active_title": active_title,
+ "active_buffs": active_title_buffs,
+ "unlocked_count": unlocked_titles.size(),
+ "trophy_points": trophy_points,
+ "reached_milestones": reached_milestones,
+ "passive_bonuses": get_total_trophy_passive_bonuses()
+ }
diff --git a/project/title_trophy_buff_system.gd.uid b/project/title_trophy_buff_system.gd.uid
new file mode 100644
index 00000000..a1a6eab0
--- /dev/null
+++ b/project/title_trophy_buff_system.gd.uid
@@ -0,0 +1 @@
+uid://c6ydp08s8sf2k
diff --git a/project/treasure_box_key_system.gd b/project/treasure_box_key_system.gd
new file mode 100644
index 00000000..c391053d
--- /dev/null
+++ b/project/treasure_box_key_system.gd
@@ -0,0 +1,248 @@
+# treasure_box_key_system.gd —— Metin2 40250 官方金银宝箱与专属钥匙拖拽开启系统 1:1
+# 100% 对照 40250 服务端 char_item.cpp (ITEM_TREASURE_BOX, ITEM_TREASURE_KEY), special_item_group.txt, item_proto.txt
+class_name TreasureBoxKeySystem
+extends RefCounted
+
+signal box_unlocked(box_vnum: int, key_vnum: int, reward: Dictionary)
+signal unlock_failed(reason: String)
+
+# 宝箱类型分类常数 (对照 item->GetValue(0))
+const BOX_TYPE_SILVER := 1 # 银系列 (Silver)
+const BOX_TYPE_GOLD := 2 # 金系列 (Gold)
+
+# 40250 官方宝箱资料表 (ITEM_TREASURE_BOX)
+const BOXES: Dictionary = {
+ 50006: {
+ "name": "银宝箱 (Silver Treasure Box)",
+ "type_id": BOX_TYPE_SILVER,
+ "tr_name": "Gümüş Define Sandığı"
+ },
+ 50007: {
+ "name": "金宝箱 (Gold Treasure Box)",
+ "type_id": BOX_TYPE_GOLD,
+ "tr_name": "Altın Define Sandığı"
+ },
+ 50012: {
+ "name": "银宝箱+ (Silver Treasure Box+)",
+ "type_id": BOX_TYPE_SILVER,
+ "tr_name": "Gümüş Define Sandığı+"
+ },
+ 50013: {
+ "name": "金宝箱+ (Gold Treasure Box+)",
+ "type_id": BOX_TYPE_GOLD,
+ "tr_name": "Altın Define Sandığı+"
+ }
+}
+
+# 40250 官方钥匙资料表 (ITEM_TREASURE_KEY)
+const KEYS: Dictionary = {
+ 50008: {
+ "name": "银钥匙 (Silver Key)",
+ "type_id": BOX_TYPE_SILVER,
+ "tr_name": "Gümüş Anahtar"
+ },
+ 50009: {
+ "name": "金钥匙 (Gold Key)",
+ "type_id": BOX_TYPE_GOLD,
+ "tr_name": "Altın Anahtar"
+ },
+ 50014: {
+ "name": "银钥匙+ (Silver Key+)",
+ "type_id": BOX_TYPE_SILVER,
+ "tr_name": "Gümüş Anahtar+"
+ },
+ 50015: {
+ "name": "金钥匙+ (Gold Key+)",
+ "type_id": BOX_TYPE_GOLD,
+ "tr_name": "Altın Anahtar+"
+ }
+}
+
+# 40250 special_item_group.txt 官方掉落池
+const DROP_TABLES: Dictionary = {
+ 50006: [ # 银宝箱
+ {"type": "item", "vnum": 50300, "count": 1, "name": "技能书 (Skill Book)", "prob": 100},
+ {"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴 (Blessing Scroll)", "prob": 40},
+ {"type": "gold", "amount": 80000, "name": "80,000 金币", "prob": 30},
+ {"type": "exp", "amount": 30000, "name": "30,000 经验值", "prob": 30},
+ {"type": "item", "vnum": 30047, "count": 1, "name": "诅咒之书 (Curse Book)", "prob": 20},
+ {"type": "item", "vnum": 30050, "count": 1, "name": "冰之碎片 (Ice Piece)", "prob": 20},
+ {"type": "item", "vnum": 70008, "count": 1, "name": "温暖葡萄酒", "prob": 15}
+ ],
+ 50007: [ # 金宝箱
+ {"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴 (Blessing Scroll)", "prob": 60},
+ {"type": "item", "vnum": 70024, "count": 1, "name": "经验指环 (Ring of Experience)", "prob": 30},
+ {"type": "item", "vnum": 70003, "count": 1, "name": "小偷手套 (Thief Glove)", "prob": 30},
+ {"type": "gold", "amount": 150000, "name": "150,000 金币", "prob": 40},
+ {"type": "exp", "amount": 60000, "name": "60,000 经验值", "prob": 40},
+ {"type": "item", "vnum": 27003, "count": 50, "name": "红色大生命药水 x50", "prob": 50},
+ {"type": "item", "vnum": 27006, "count": 50, "name": "蓝色大魔法药水 x50", "prob": 50}
+ ],
+ 50012: [ # 银宝箱+
+ {"type": "item", "vnum": 50300, "count": 2, "name": "技能书 x2", "prob": 100},
+ {"type": "item", "vnum": 25040, "count": 1, "name": "祝福卷轴", "prob": 50},
+ {"type": "gold", "amount": 120000, "name": "120,000 金币", "prob": 40},
+ {"type": "item", "vnum": 70048, "count": 1, "name": "变装斗篷", "prob": 25}
+ ],
+ 50013: [ # 金宝箱+
+ {"type": "item", "vnum": 25040, "count": 2, "name": "祝福卷轴 x2", "prob": 80},
+ {"type": "item", "vnum": 70024, "count": 1, "name": "经验指环", "prob": 45},
+ {"type": "gold", "amount": 250000, "name": "250,000 金币", "prob": 50},
+ {"type": "exp", "amount": 100000, "name": "100,000 经验值", "prob": 50}
+ ]
+}
+
+# 开启历史记录
+var _open_history: Array[Dictionary] = []
+
+# 判断是否是宝箱
+func is_treasure_box(vnum: int) -> bool:
+ return BOXES.has(vnum)
+
+# 判断是否是钥匙
+func is_treasure_key(vnum: int) -> bool:
+ return KEYS.has(vnum)
+
+# 校验钥匙与宝箱是否能够配对开启 (item->GetValue(0) == item2->GetValue(0))
+func can_unlock(key_vnum: int, box_vnum: int) -> bool:
+ if not KEYS.has(key_vnum) or not BOXES.has(box_vnum):
+ return false
+ return int(KEYS[key_vnum]["type_id"]) == int(BOXES[box_vnum]["type_id"])
+
+# 拖拽钥匙至宝箱进行解锁
+func unlock_box_with_key(
+ inventory: Array,
+ key_slot: int,
+ box_slot: int,
+ forced_drop_idx: int = -1
+) -> Dictionary:
+ if key_slot < 0 or key_slot >= inventory.size() or inventory[key_slot] == null:
+ unlock_failed.emit("INVALID_KEY_SLOT")
+ return {"ok": false, "reason": "INVALID_KEY_SLOT", "msg": "未找到指定的钥匙!"}
+
+ if box_slot < 0 or box_slot >= inventory.size() or inventory[box_slot] == null:
+ unlock_failed.emit("INVALID_BOX_SLOT")
+ return {"ok": false, "reason": "INVALID_BOX_SLOT", "msg": "未找到指定的宝箱!"}
+
+ var key_item = inventory[key_slot]
+ var box_item = inventory[box_slot]
+
+ var key_vnum = int(key_item.get("vnum", 0))
+ var box_vnum = int(box_item.get("vnum", 0))
+
+ # 检查物品分类是否为钥匙与宝箱
+ if not is_treasure_key(key_vnum):
+ unlock_failed.emit("NOT_A_KEY")
+ return {"ok": false, "reason": "NOT_A_KEY", "msg": "被拖拽的物品并不是一把宝箱钥匙!"}
+
+ if not is_treasure_box(box_vnum):
+ unlock_failed.emit("NOT_A_TREASURE_BOX")
+ # 40250 char_item.cpp:1948 "그 열쇠로는 열 수 없는 것 같군."
+ return {"ok": false, "reason": "NOT_A_TREASURE_BOX", "msg": "这把钥匙似乎无法用来开启这个物品。"}
+
+ # 检查金银分类匹配
+ if not can_unlock(key_vnum, box_vnum):
+ unlock_failed.emit("KEY_MISMATCH")
+ return {
+ "ok": false,
+ "reason": "KEY_MISMATCH",
+ "msg": "钥匙型号不吻合!%s 无法打开 %s。" % [KEYS[key_vnum]["name"], BOXES[box_vnum]["name"]]
+ }
+
+ # 配对成功:双双扣除 1 个数量
+ _consume_item_one(inventory, key_slot)
+ _consume_item_one(inventory, box_slot)
+
+ # 计算奖励
+ var drops: Array = DROP_TABLES.get(box_vnum, [])
+ if drops.is_empty():
+ drops = DROP_TABLES[50006]
+
+ var picked_reward: Dictionary = {}
+ if forced_drop_idx >= 0 and forced_drop_idx < drops.size():
+ picked_reward = drops[forced_drop_idx]
+ else:
+ # 加权随机选取
+ var total_weight := 0
+ for d in drops:
+ total_weight += int(d.get("prob", 10))
+ var r = randi_range(1, total_weight)
+ var current := 0
+ for d in drops:
+ current += int(d.get("prob", 10))
+ if r <= current:
+ picked_reward = d
+ break
+
+ # 发放奖励
+ var reward_type = picked_reward.get("type", "item")
+ match reward_type:
+ "item":
+ _give_or_stack_item(inventory, int(picked_reward["vnum"]), int(picked_reward.get("count", 1)))
+ "gold":
+ # 金币奖励
+ pass
+ "exp":
+ # 经验奖励
+ pass
+
+ var record = {
+ "box_vnum": box_vnum,
+ "key_vnum": key_vnum,
+ "reward": picked_reward
+ }
+ _open_history.append(record)
+ box_unlocked.emit(box_vnum, key_vnum, picked_reward)
+
+ return {
+ "ok": true,
+ "box_name": BOXES[box_vnum]["name"],
+ "key_name": KEYS[key_vnum]["name"],
+ "reward": picked_reward,
+ "msg": "咔哒一声脆响!宝箱锁扣开启,你从箱中获得了【%s】!" % picked_reward.get("name", "宝物")
+ }
+
+# 获取宝箱完整掉落表
+func get_box_drop_table(box_vnum: int) -> Array:
+ return DROP_TABLES.get(box_vnum, [])
+
+# 开启历史记录
+func get_open_history() -> Array[Dictionary]:
+ return _open_history
+
+func clear_history() -> void:
+ _open_history.clear()
+
+# 内部消耗与堆叠工具函数
+func _consume_item_one(inventory: Array, slot: int) -> void:
+ if slot < 0 or slot >= inventory.size() or inventory[slot] == null:
+ return
+ var item = inventory[slot]
+ var count = int(item.get("count", 1))
+ if count > 1:
+ item["count"] = count - 1
+ else:
+ inventory[slot] = null
+
+func _give_or_stack_item(inventory: Array, vnum: int, count: int) -> void:
+ for it in inventory:
+ if it != null and int(it.get("vnum", 0)) == vnum:
+ it["count"] = int(it.get("count", 1)) + count
+ return
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {"vnum": vnum, "count": count}
+ return
+ inventory.append({"vnum": vnum, "count": count})
+
+# 序列化与反序列化
+func serialize() -> Dictionary:
+ return {
+ "open_history": _open_history.duplicate(true)
+ }
+
+func deserialize(data: Dictionary) -> void:
+ _open_history.clear()
+ var raw = data.get("open_history", [])
+ for item in raw:
+ _open_history.append(item.duplicate(true))
diff --git a/project/treasure_box_key_system.gd.uid b/project/treasure_box_key_system.gd.uid
new file mode 100644
index 00000000..7b272627
--- /dev/null
+++ b/project/treasure_box_key_system.gd.uid
@@ -0,0 +1 @@
+uid://buudr3506irfn
diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd
index d0daa7fa..aa3ba9ca 100644
--- a/project/ui/char_select_screen.gd
+++ b/project/ui/char_select_screen.gd
@@ -16,6 +16,8 @@ const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
const EquipModel = preload("res://ui/equip_model.gd")
+const Audio = preload("res://audio.gd")
+const EffectRegistry = preload("res://fx/effect_registry.gd")
const DESIGN_SIZE := Vector2(1472, 854)
signal select_requested(index: int)
@@ -68,11 +70,24 @@ var _rename_pending := false
var _layout: Control
var _board: Control
var _proto: Node
+var _audio: Node
+var _fxr: RefCounted
+var _starting := false
+var _btn_start: Button
+var _btn_create: Button
+var _btn_delete: Button
+var _btn_quit: Button
+var _nav_buttons: Array[TextureButton] = []
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
_assets = assets_root
_chars = _pad_slots(chars)
+ _audio = Audio.new()
+ _audio.setup(_assets)
+ add_child(_audio)
+ _fxr = EffectRegistry.new()
+ _fxr.setup(_assets)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
@@ -179,13 +194,13 @@ func on_char_delete_failed() -> void:
# --- 背景 ---------------------------------------------------------------------
-func _build_background() -> void:
+func _add_background(parent: Node) -> void:
var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub")
var cr := ColorRect.new()
cr.color = Color(0.05, 0.06, 0.07)
cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cr.mouse_filter = Control.MOUSE_FILTER_IGNORE
- add_child(cr)
+ parent.add_child(cr)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
@@ -194,7 +209,10 @@ func _build_background() -> void:
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
- add_child(tr)
+ parent.add_child(tr)
+
+func _build_background() -> void:
+ _add_background(self)
# --- 右侧 3D 角色 -----------------------------------------------------------
@@ -206,12 +224,27 @@ func _build_stage() -> void:
add_child(vpc)
_viewport = SubViewport.new()
- _viewport.transparent_bg = true
+ # Refine effects use D3D-style SRC_ALPHA/ONE additive blending. Rendering
+ # them into a transparent texture first makes their black RGB texels increase
+ # the render target alpha; the later CanvasItem alpha composite then turns
+ # those invisible texels into large black rectangles. 40250 draws the select
+ # background before the character/effects, so reproduce that order inside an
+ # opaque viewport instead of trying to encode additive light in straight RGBA.
+ _viewport.transparent_bg = false
_viewport.own_world_3d = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.msaa_3d = Viewport.MSAA_4X
vpc.add_child(_viewport)
+ var background_canvas := CanvasLayer.new()
+ background_canvas.layer = -1
+ _viewport.add_child(background_canvas)
+ var background_root := Control.new()
+ background_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ background_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ background_canvas.add_child(background_root)
+ _add_background(background_root)
+
var root := Node3D.new()
_viewport.add_child(root)
@@ -228,8 +261,10 @@ func _build_stage() -> void:
var we := WorldEnvironment.new()
var e := Environment.new()
- e.background_mode = Environment.BG_COLOR
- e.background_color = Color(0, 0, 0, 0)
+ # Canvas layer -1 is the stage backdrop and is rendered before 3D. Parent UI
+ # remains outside the SubViewport and is drawn afterward as before.
+ e.background_mode = Environment.BG_CANVAS
+ e.background_canvas_max_layer = -1
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.8, 0.8, 0.8)
e.ambient_light_energy = 0.65
@@ -248,6 +283,9 @@ func _build_stage() -> void:
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
+ var listener := AudioListener3D.new()
+ listener.make_current()
+ _cam.add_child(listener)
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
@@ -309,6 +347,7 @@ func _rebuild_model() -> void:
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
+ pv._audio = _audio
if pv.model and pv.model.has_signal("visual_bounds_changed"):
pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv))
if _proto == null and ClassDB.class_exists("Metin2Proto"):
@@ -485,33 +524,54 @@ func _refresh_panel() -> void:
start.text = "开始"
start.custom_minimum_size = Vector2(180, 22)
_style_button(start)
- start.disabled = not has_char or empire_required
+ start.disabled = not has_char or empire_required or _starting
start.pressed.connect(_do_start)
_panel_box.add_child(start)
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
start.visible = has_char
+ _btn_start = start
+
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(180, 22)
_style_button(create)
- create.disabled = has_char or empire_required
+ create.disabled = has_char or empire_required or _starting
create.pressed.connect(_open_create_dialog)
_panel_box.add_child(create)
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
create.visible = not has_char
+ _btn_create = create
+
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(86, 20)
_style_button(del)
- del.disabled = not has_char or empire_required
+ del.disabled = not has_char or empire_required or _starting
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
+ _btn_delete = del
+
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(86, 20)
_style_button(quit)
- quit.pressed.connect(func(): back_requested.emit())
+ quit.disabled = _starting
+ quit.pressed.connect(func(): if not _starting: back_requested.emit())
brow.add_child(quit)
+ _btn_quit = quit
+
+func _set_buttons_disabled(disabled: bool) -> void:
+ if _btn_start and is_instance_valid(_btn_start):
+ _btn_start.disabled = disabled
+ if _btn_create and is_instance_valid(_btn_create):
+ _btn_create.disabled = disabled
+ if _btn_delete and is_instance_valid(_btn_delete):
+ _btn_delete.disabled = disabled
+ if _btn_quit and is_instance_valid(_btn_quit):
+ _btn_quit.disabled = disabled
+ for b in _nav_buttons:
+ if b and is_instance_valid(b):
+ b.disabled = disabled
func _style_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 10)
@@ -536,6 +596,7 @@ func _style_button(button: Button) -> void:
button.add_theme_stylebox_override(state, style)
func _build_navigation() -> void:
+ _nav_buttons.clear()
for direction in [-1, 1]:
var button := TextureButton.new()
var side := "left" if direction < 0 else "right"
@@ -543,11 +604,13 @@ func _build_navigation() -> void:
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
+ button.texture_disabled = UiAssets.load_tex(_assets, base + "01.sub")
button.position = Vector2(714 if direction < 0 else 1024, 712)
button.custom_minimum_size = Vector2(160, 44)
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
- button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
+ button.pressed.connect(func(): if not _starting: _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
+ _nav_buttons.append(button)
func _resize_layout() -> void:
if _layout:
@@ -581,6 +644,8 @@ func _set_status(t: String) -> void:
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
+ if _starting:
+ return
if _chars.is_empty():
_sel = 0
_refresh_class_name()
@@ -596,6 +661,8 @@ func _select(i: int) -> void:
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
func _do_start() -> void:
+ if _starting:
+ return
var d := _cur()
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
@@ -605,8 +672,58 @@ func _do_start() -> void:
if bool(d.get("change_name", false)):
_open_change_name_dialog()
return
+
+ _starting = true
+ _set_buttons_disabled(true)
+ if _audio and _audio.has_method("play_sound"):
+ _audio.play_sound("sound/ui/click.wav")
+ if _audio.has_method("fade_out_bgm"):
+ _audio.fade_out_bgm(2.5)
+
+ _set_status("进入游戏:%s …" % d.get("name"))
+
+ var anim_dur := 2.2
+ if _pv != null and is_instance_valid(_pv):
+ var intro: String = _pv.motion_dir.get_base_dir().path_join("intro/selected.msa")
+ if _pv.anim and FileAccess.file_exists(intro):
+ _pv.anim.set("loop", false)
+ _pv.anim.set("blend_time", 0.1)
+ _pv.anim.set("anim_path", intro)
+ _pv._refresh_sound_script(intro)
+ if _pv.anim.has_method("get_duration"):
+ var dur := float(_pv.anim.call("get_duration"))
+ if dur > 0.0:
+ anim_dur = dur
+
+ await get_tree().create_timer(anim_dur).timeout
+ if not is_inside_tree():
+ return
select_requested.emit(_slot_index())
- _set_status("进入游戏:%s" % d.get("name"))
+
+func _unhandled_input(event: InputEvent) -> void:
+ if _dialog != null and is_instance_valid(_dialog):
+ return
+ if not (event is InputEventKey) or not event.pressed or event.echo:
+ return
+ if _starting:
+ return
+ var ke := event as InputEventKey
+ match ke.keycode:
+ KEY_ENTER, KEY_KP_ENTER:
+ if not _cur().is_empty() and String(_cur().get("name", "")) != "":
+ _do_start()
+ else:
+ _open_create_dialog()
+ KEY_LEFT:
+ _select(wrapi(_sel - 1, 0, _chars.size()))
+ KEY_RIGHT:
+ _select(wrapi(_sel + 1, 0, _chars.size()))
+ KEY_ESCAPE:
+ back_requested.emit()
+ KEY_1, KEY_2, KEY_3, KEY_4:
+ var slot := ke.keycode - KEY_1
+ if slot < _chars.size():
+ _select(slot)
# --- 建号 / 删号弹窗 ------------------------------------------------------
diff --git a/project/ui/char_status_ui.gd b/project/ui/char_status_ui.gd
index 52e363bd..2b64e0db 100644
--- a/project/ui/char_status_ui.gd
+++ b/project/ui/char_status_ui.gd
@@ -19,6 +19,7 @@ const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
+const SkillSlotMap = preload("res://skill_slot_map.gd")
# --- EPointTypes(m2dev Packet.h),只列本窗要用的 ---
const P_LEVEL := 1
@@ -41,6 +42,7 @@ const P_CASTING_SPEED := 21
const P_MAGIC_ATT_GRADE := 22
const P_MAGIC_DEF_GRADE := 23
const P_STAT := 26 # 남은 능력치 포인트
+const P_SKILL := 27 # 남은 스킬 포인트
const P_MIN_ATK := 29
const P_MAX_ATK := 30
const P_PARTY_ATT_GRADE := 91 # player.ATTACKER_BONUS
@@ -49,6 +51,39 @@ const P_DEF_GRADE_BONUS := 96 # player.DEF_BONUS
const P_MIN_MAGIC_WEP := 202
const P_MAX_MAGIC_WEP := 203
+const EMOTE_NAMES := {
+ 1: "喝彩", 2: "拍手", 3: "肯定", 4: "原谅",
+ 5: "愤怒", 6: "诱惑", 7: "悲伤", 8: "害羞",
+ 9: "欢呼", 10: "嘲讽", 11: "思考", 12: "跳舞 1",
+ 13: "跳舞 2", 14: "跳舞 3", 15: "跳舞 4", 16: "跳舞 5",
+ 17: "跳舞 6", 18: "大笑",
+ 51: "亲吻", 52: "法式深吻", 53: "耳光",
+}
+
+const EMOTE_ICONS := {
+ 1: "ETC/ymir work/ui/game/windows/emotion_cheers_1.sub",
+ 2: "ETC/ymir work/ui/game/windows/emotion_clap.sub",
+ 3: "icon/icon/action/congratulation.tga",
+ 4: "icon/icon/action/forgive.tga",
+ 5: "icon/icon/action/angry.tga",
+ 6: "icon/icon/action/attractive.tga",
+ 7: "icon/icon/action/sad.tga",
+ 8: "icon/icon/action/shy.tga",
+ 9: "icon/icon/action/cheerup.tga",
+ 10: "icon/icon/action/banter.tga",
+ 11: "icon/icon/action/joy.tga",
+ 12: "icon/icon/action/dance1.tga",
+ 13: "icon/icon/action/dance2.tga",
+ 14: "icon/icon/action/dance3.tga",
+ 15: "icon/icon/action/dance4.tga",
+ 16: "icon/icon/action/dance5.tga",
+ 17: "icon/icon/action/dance6.tga",
+ 18: "ETC/ymir work/ui/game/windows/emotion_cheers_2.sub",
+ 51: "ETC/ymir work/ui/game/windows/emotion_kiss.sub",
+ 52: "ETC/ymir work/ui/game/windows/emotion_french_kiss.sub",
+ 53: "ETC/ymir work/ui/game/windows/emotion_slap.sub",
+}
+
# uicharacter.py statusPlusCommandDict / statusMinusCommandDict
const PLUS_CMD := {"HTH": "/stat ht", "INT": "/stat iq", "STR": "/stat st", "DEX": "/stat dx"}
const MINUS_CMD := {"HTH": "/stat- ht", "INT": "/stat- iq", "STR": "/stat- st", "DEX": "/stat- dx"}
@@ -57,10 +92,15 @@ const STATES := ["STATUS", "SKILL", "EMOTICON", "QUEST"]
const PAGE := {"STATUS": "Character_Page", "SKILL": "Skill_Page", "EMOTICON": "Emoticon_Page", "QUEST": "Quest_Page"}
const TITLEBAR := {"STATUS": "Character_TitleBar", "SKILL": "Skill_TitleBar", "EMOTICON": "Emoticon_TitleBar", "QUEST": "Quest_TitleBar"}
const TAB_BUTTON := {"STATUS": "Tab_Button_01", "SKILL": "Tab_Button_02", "EMOTICON": "Tab_Button_03", "QUEST": "Tab_Button_04"}
+const TAB_IMAGE := {"STATUS": "Tab_01", "SKILL": "Tab_02", "EMOTICON": "Tab_03", "QUEST": "Tab_04"}
const FACE_BY_JOB := ["face_warrior", "face_assassin", "face_sura", "face_shaman"]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
+var skill_table: RefCounted # SkillTable
+var item_mouse: Node # MouseController
+var drag_skill_id := 0 # Quickbar 可读取的待拖拽技能
+var _preview_group := 1 # 40250 uicharacter.py curSelectedSkillGroup (group==0 时预览分支)
var assets_root := ""
var uiscript_dir := ""
@@ -74,23 +114,66 @@ var _mobile_labels := {}
var _mobile_stats := {}
var _mobile_equipment_labels: Array[Label] = []
var _mobile_hint: Label
+var _quest_showing_start_index := 0
-func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
+func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", st_table: RefCounted = null, imouse: Node = null) -> void:
ui = ui_manager
client = m2client
assets_root = assets
+ if imouse != null:
+ item_mouse = imouse
if assets_root == "" and ui and "assets_root" in ui:
assets_root = ui.assets_root
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
+ skill_table = st_table
+ if skill_table == null:
+ var st_script := load("res://ui/skill_table.gd")
+ if st_script:
+ skill_table = st_script.new()
+ for lang in ["en", "common"]:
+ var desc_path := assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)
+ if not FileAccess.file_exists(desc_path):
+ desc_path = assets_root.path_join("locale/%s/skilldesc.txt" % lang)
+ if FileAccess.file_exists(desc_path) and skill_table.has_method("load_file"):
+ skill_table.load_file(desc_path)
+ break
+ for lang in ["en", "common"]:
+ var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)
+ if not FileAccess.file_exists(tbl_path):
+ tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang)
+ if FileAccess.file_exists(tbl_path) and skill_table.has_method("load_table"):
+ skill_table.load_table(tbl_path)
+ break
+ elif skill_table != null and skill_table.has_method("load_table") and skill_table.has_method("is_passive"):
+ if not skill_table.is_passive(121):
+ for lang in ["en", "common"]:
+ var tbl_path := assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)
+ if not FileAccess.file_exists(tbl_path):
+ tbl_path = assets_root.path_join("locale/%s/skilltable.txt" % lang)
+ if FileAccess.file_exists(tbl_path):
+ skill_table.load_table(tbl_path)
+ break
if client and client.has_signal("points_changed"):
client.points_changed.connect(_on_points_changed)
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(_v): if is_open(): _refresh())
+ if client and client.has_signal("skills_changed"):
+ client.skills_changed.connect(func(): if is_open(): _refresh())
+ if client and client.has_signal("skill_group_changed"):
+ client.skill_group_changed.connect(func(_g): if is_open(): _refresh())
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ _win = {}
+ )
+
+func set_job(_j: String) -> void:
+ pass
func set_mobile_mode(enabled: bool) -> void:
- if not enabled and _mobile_root != null:
+ if enabled != _mobile_mode and is_open():
var old_mode := _mobile_mode
_mobile_mode = true
close()
@@ -106,7 +189,7 @@ func is_open() -> bool:
func toggle() -> void:
if is_open(): close()
- else: open()
+ else: open("STATUS")
func close() -> void:
if _mobile_mode:
@@ -140,8 +223,21 @@ func open(state := "") -> void:
_win = ui.open_script(path, assets_root)
if not is_open():
return
+ var root_w: Control = _win.get("root") as Control
+ if root_w:
+ var vp_size := Vector2(800.0, 600.0)
+ if root_w.is_inside_tree():
+ vp_size = root_w.get_viewport_rect().size
+ elif ui and ui.is_inside_tree():
+ vp_size = ui.get_viewport().get_visible_rect().size
+ var target_y: float = (vp_size.y - 37.0 - 361.0) / 2.0
+ root_w.position = Vector2(24.0, maxf(10.0, target_y))
+ _wire_titlebars()
_wire_tabs()
_wire_stat_buttons()
+ _wire_skills()
+ _wire_emoticons()
+ _wire_quests()
_set_state(_state)
_refresh()
@@ -444,9 +540,17 @@ func _set_state(state: String) -> void:
var bar := _node(TITLEBAR[st])
if bar:
bar.visible = on
+ var tab_img := _node(TAB_IMAGE[st])
+ if tab_img:
+ tab_img.visible = on
var btn := _node(TAB_BUTTON[st])
if btn is BaseButton and btn.toggle_mode:
btn.set_pressed_no_signal(on)
+ match state:
+ "SKILL": _refresh_skills()
+ "EMOTICON": _refresh_emotes()
+ "QUEST": _refresh_quests()
+ "STATUS": _refresh()
# --- stat +/- buttons ----------------------------------------------------
@@ -477,6 +581,15 @@ func _u32(v: int) -> int:
func _refresh() -> void:
if not is_open() or client == null:
return
+ _refresh_status()
+ if _state == "SKILL":
+ _refresh_skills()
+ elif _state == "EMOTICON":
+ _refresh_emotes()
+ elif _state == "QUEST":
+ _refresh_quests()
+
+func _refresh_status() -> void:
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
if pd.is_empty():
return
@@ -583,3 +696,551 @@ func _set_text(nm: String, value: String) -> void:
n.text = value
elif n.has_method("set_text"):
n.set_text(value)
+
+func _wire_titlebars() -> void:
+ for tb_name: String in TITLEBAR.values():
+ var tb := _node(tb_name)
+ if tb:
+ for btn in tb.find_children("*", "BaseButton", true, false):
+ if not btn.pressed.is_connected(close):
+ btn.pressed.connect(close)
+
+func _wire_skills() -> void:
+ var g1 := _node("Skill_Group_Button_1")
+ if g1 is BaseButton and not g1.pressed.is_connected(_on_group1_pressed):
+ g1.pressed.connect(_on_group1_pressed)
+ var g2 := _node("Skill_Group_Button_2")
+ if g2 is BaseButton and not g2.pressed.is_connected(_on_group2_pressed):
+ g2.pressed.connect(_on_group2_pressed)
+
+func _on_group1_pressed() -> void:
+ if _skill_group() == 0:
+ _preview_group = 1
+ _refresh_skills()
+ if client and client.has_method("say"):
+ client.say(0, "/skillgroup 1")
+
+func _on_group2_pressed() -> void:
+ if _skill_group() == 0:
+ _preview_group = 2
+ _refresh_skills()
+ if client and client.has_method("say"):
+ client.say(0, "/skillgroup 2")
+
+func _wire_emoticons() -> void:
+ pass
+
+func _wire_quests() -> void:
+ var sb := _node("Quest_ScrollBar")
+ if sb is Range:
+ if not sb.value_changed.is_connected(_on_quest_scroll_changed):
+ sb.value_changed.connect(_on_quest_scroll_changed)
+
+func _on_quest_scroll_changed(val: float) -> void:
+ _quest_showing_start_index = int(val)
+ _refresh_quests()
+
+func _refresh_skills() -> void:
+ if not is_open() or client == null:
+ return
+ var race := _main_race()
+ var group := _skill_group()
+ var job := race & 3
+ var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
+ var pts: Array = pd.get("points", [])
+ var stat_skill: int = int(pts[P_SKILL]) if pts.size() > P_SKILL else 0
+ _set_text("Active_Skill_Point_Value", str(max(0, stat_skill)))
+ _set_text("Support_Skill_Point_Value", "0")
+ var sk_pt_lbl := _node("Active_Skill_Point_Label")
+ if sk_pt_lbl:
+ sk_pt_lbl.visible = stat_skill > 0
+
+ var grp_name := _node("Active_Skill_Group_Name")
+ var g1_btn := _node("Skill_Group_Button_1")
+ var g2_btn := _node("Skill_Group_Button_2")
+ var g3_btn := _node("Skill_Group_Button_3")
+ if g3_btn:
+ g3_btn.visible = false
+
+ var display_group := group
+ if group == 0:
+ # 40250 uicharacter.py: 当未选流派时显示两组按钮,供玩家预览切换
+ if grp_name: grp_name.visible = false
+ if g1_btn:
+ g1_btn.visible = true
+ if "text" in g1_btn: g1_btn.text = _get_skill_group_name(job, 1)
+ if g1_btn is BaseButton: g1_btn.set_pressed_no_signal(_preview_group == 1)
+ if g2_btn:
+ g2_btn.visible = true
+ if "text" in g2_btn: g2_btn.text = _get_skill_group_name(job, 2)
+ if g2_btn is BaseButton: g2_btn.set_pressed_no_signal(_preview_group == 2)
+ display_group = _preview_group
+ else:
+ # 40250 uicharacter.py: 已选流派时隐藏切换按钮,只展示当前流派名
+ if g1_btn: g1_btn.visible = false
+ if g2_btn: g2_btn.visible = false
+ if grp_name:
+ grp_name.visible = true
+ grp_name.text = _get_skill_group_name(job, group)
+
+ var active_slot := _node("Skill_Active_Slot")
+ if active_slot:
+ for slot_idx in range(1, 9):
+ var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx)
+ var cell := active_slot.get_node_or_null("slot_%d" % slot_idx)
+ if cell:
+ _populate_skill_cell(cell, sid, slot_idx)
+ var plus_cell := active_slot.get_node_or_null("slot_%d" % (20 + slot_idx))
+ if plus_cell:
+ var pts_alloc := stat_skill if group > 0 else 0
+ _populate_skill_plus(plus_cell, sid, pts_alloc)
+
+ var etc_slot := _node("Skill_ETC_Slot")
+ if etc_slot:
+ for slot_idx in range(101, 113):
+ var sid := SkillSlotMap.skill_id_for_slot(race, display_group, slot_idx)
+ var cell := etc_slot.get_node_or_null("slot_%d" % slot_idx)
+ if cell:
+ _populate_skill_cell(cell, sid, slot_idx)
+
+func _populate_skill_cell(cell: Control, skill_id: int, slot_idx: int) -> void:
+ var btn: TextureButton = cell.get_node_or_null("Btn")
+ if btn == null:
+ btn = TextureButton.new()
+ btn.name = "Btn"
+ btn.set_anchors_preset(Control.PRESET_FULL_RECT)
+ btn.ignore_texture_size = true
+ btn.stretch_mode = TextureButton.STRETCH_SCALE
+ btn.mouse_filter = Control.MOUSE_FILTER_PASS
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ cell.add_child(btn)
+
+ var lvl_lbl: Label = cell.get_node_or_null("Lvl")
+ if lvl_lbl == null:
+ lvl_lbl = Label.new()
+ lvl_lbl.name = "Lvl"
+ lvl_lbl.position = Vector2(2, 16)
+ lvl_lbl.size = Vector2(28, 14)
+ lvl_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ lvl_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
+ lvl_lbl.add_theme_font_size_override("font_size", 10)
+ lvl_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ cell.add_child(lvl_lbl)
+
+ for conn in btn.gui_input.get_connections():
+ btn.gui_input.disconnect(conn["callable"])
+
+ if skill_id <= 0:
+ btn.texture_normal = null
+ lvl_lbl.text = ""
+ cell.tooltip_text = ""
+ btn.set_drag_forwarding(Callable(), Callable(), Callable())
+ return
+
+ btn.texture_normal = _skill_icon(skill_id)
+ var sk_data := _get_player_skill(skill_id)
+ var lvl: int = int(sk_data.get("level", 0))
+ var master: int = int(sk_data.get("master", 0))
+ if master == 1:
+ lvl_lbl.text = "M%d" % lvl
+ lvl_lbl.modulate = Color(1.0, 0.4, 0.4)
+ elif master == 2:
+ lvl_lbl.text = "G%d" % lvl
+ lvl_lbl.modulate = Color(0.4, 0.7, 1.0)
+ elif master == 3:
+ lvl_lbl.text = "P"
+ lvl_lbl.modulate = Color(0.9, 0.3, 1.0)
+ elif lvl > 0:
+ lvl_lbl.text = str(lvl)
+ lvl_lbl.modulate = Color(1.0, 1.0, 1.0)
+ else:
+ lvl_lbl.text = ""
+
+ var sk_name := _skill_name(skill_id)
+ cell.tooltip_text = "%s (Lv %d)" % [sk_name, lvl] if lvl > 0 else sk_name
+
+ btn.gui_input.connect(_on_skill_btn_input.bind(skill_id, slot_idx))
+
+ btn.set_drag_forwarding(
+ func(_at_pos: Vector2):
+ if not _can_drag_skill(skill_id):
+ return null
+ drag_skill_id = skill_id
+ var preview := TextureRect.new()
+ preview.texture = btn.texture_normal
+ preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ preview.custom_minimum_size = Vector2(32, 32)
+ preview.size = Vector2(32, 32)
+ preview.position = -Vector2(16, 16)
+ btn.set_drag_preview(preview)
+ return {
+ "type": "skill",
+ "kind": "skill",
+ "id": skill_id,
+ "skill_id": skill_id,
+ "slot": slot_idx,
+ },
+ Callable(),
+ Callable()
+ )
+
+func _can_drag_skill(skill_id: int) -> bool:
+ if skill_id <= 0:
+ return false
+ if skill_table and skill_table.has_method("is_passive") and skill_table.is_passive(skill_id):
+ return false
+ return true
+
+func _find_quickbar() -> Node:
+ var root: Node = null
+ if is_inside_tree():
+ var tree := get_tree()
+ if tree: root = tree.root
+ elif ui != null and is_instance_valid(ui) and ui.is_inside_tree():
+ var tree := ui.get_tree()
+ if tree: root = tree.root
+ elif get_parent():
+ root = get_parent()
+ if root:
+ for n in root.find_children("*", "Quickbar", true, false):
+ return n
+ for n in root.find_children("*", "Node", true, false):
+ if n.has_method("assign_mobile") and n.has_method("assign"):
+ return n
+ return null
+
+func _find_item_mouse() -> Node:
+ if item_mouse != null and is_instance_valid(item_mouse):
+ return item_mouse
+ var root: Node = null
+ if is_inside_tree():
+ var tree := get_tree()
+ if tree: root = tree.root
+ elif ui != null and is_instance_valid(ui) and ui.is_inside_tree():
+ var tree := ui.get_tree()
+ if tree: root = tree.root
+ elif get_parent():
+ root = get_parent()
+ if root:
+ for n in root.find_children("*", "MouseController", true, false):
+ return n
+ for n in root.find_children("*", "Node", true, false):
+ if n.has_method("attach_skill") and n.has_method("attach_item"):
+ return n
+ return null
+
+func _on_skill_btn_input(ev: InputEvent, skill_id: int, slot_idx: int) -> void:
+ if not (ev is InputEventMouseButton and ev.pressed):
+ return
+ if ev.button_index == MOUSE_BUTTON_RIGHT:
+ _cast_skill(skill_id)
+ elif ev.button_index == MOUSE_BUTTON_LEFT:
+ if not _can_drag_skill(skill_id):
+ return
+ drag_skill_id = skill_id
+ # 40250 uicharacter.py: Ctrl + Left Click adds to first empty quickslot
+ if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL):
+ var qb := _find_quickbar()
+ if qb and qb.has_method("assign_mobile"):
+ if qb.assign_mobile("skill", skill_id):
+ return
+ # 40250 mouseController.AttachObject(self, player.SLOT_TYPE_SKILL, srcSlotIndex, selectedSkillIndex)
+ var im := item_mouse if item_mouse else _find_item_mouse()
+ if im and im.has_method("attach_skill"):
+ im.attach_skill(skill_id, slot_idx, _skill_icon(skill_id), _skill_name(skill_id))
+
+func _populate_skill_plus(cell: Control, skill_id: int, stat_skill: int) -> void:
+ var plus_btn: Button = cell.get_node_or_null("PlusBtn")
+ if plus_btn == null:
+ plus_btn = Button.new()
+ plus_btn.name = "PlusBtn"
+ plus_btn.set_anchors_preset(Control.PRESET_FULL_RECT)
+ plus_btn.text = "+"
+ plus_btn.add_theme_font_size_override("font_size", 10)
+ plus_btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ cell.add_child(plus_btn)
+
+ var sk_data := _get_player_skill(skill_id)
+ var lvl: int = int(sk_data.get("level", 0))
+ var master: int = int(sk_data.get("master", 0))
+ var can_up := (skill_id > 0 and stat_skill > 0 and master == 0 and lvl < 20)
+ plus_btn.visible = can_up
+ for conn in plus_btn.pressed.get_connections():
+ plus_btn.pressed.disconnect(conn["callable"])
+ if can_up:
+ plus_btn.pressed.connect(func():
+ if client and client.has_method("say"):
+ client.say(0, "/skillup %d" % skill_id))
+
+func _refresh_emotes() -> void:
+ var solo := _node("SoloEmotionSlot")
+ if solo:
+ for id in range(1, 19):
+ var cell := solo.get_node_or_null("slot_%d" % id)
+ if cell:
+ _populate_emote_cell(cell, id)
+ var dual := _node("DualEmotionSlot")
+ if dual:
+ for id in [51, 52, 53]:
+ var cell := dual.get_node_or_null("slot_%d" % id)
+ if cell:
+ _populate_emote_cell(cell, id)
+
+func _populate_emote_cell(cell: Control, emote_id: int) -> void:
+ var btn: TextureButton = cell.get_node_or_null("Btn")
+ if btn == null:
+ btn = TextureButton.new()
+ btn.name = "Btn"
+ btn.set_anchors_preset(Control.PRESET_FULL_RECT)
+ btn.ignore_texture_size = true
+ btn.stretch_mode = TextureButton.STRETCH_SCALE
+ btn.mouse_filter = Control.MOUSE_FILTER_PASS
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ cell.add_child(btn)
+ for conn in btn.gui_input.get_connections():
+ btn.gui_input.disconnect(conn["callable"])
+ for conn in btn.pressed.get_connections():
+ btn.pressed.disconnect(conn["callable"])
+
+ var rel: String = EMOTE_ICONS.get(emote_id, "")
+ var tex: Texture2D = UiAssets.load_tex(assets_root, rel) if rel != "" else null
+ if tex:
+ btn.texture_normal = tex
+ var nm: String = EMOTE_NAMES.get(emote_id, "动作 %d" % emote_id)
+ cell.tooltip_text = nm
+
+ btn.gui_input.connect(func(ev: InputEvent):
+ if not (ev is InputEventMouseButton and ev.pressed):
+ return
+ if ev.button_index == MOUSE_BUTTON_RIGHT:
+ if client and client.has_method("send_emoticon"):
+ client.send_emoticon(emote_id)
+ elif ev.button_index == MOUSE_BUTTON_LEFT:
+ if ev.ctrl_pressed or Input.is_key_pressed(KEY_CTRL):
+ var qb := _find_quickbar()
+ if qb and qb.has_method("assign_mobile"):
+ if qb.assign_mobile("emote", emote_id):
+ return
+ var im := item_mouse if item_mouse else _find_item_mouse()
+ if im and im.has_method("attach_emotion"):
+ im.attach_emotion(emote_id, tex, nm)
+ )
+
+ btn.set_drag_forwarding(
+ func(_at_pos: Vector2):
+ var preview := TextureRect.new()
+ preview.texture = tex
+ preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ preview.custom_minimum_size = Vector2(32, 32)
+ preview.size = Vector2(32, 32)
+ preview.position = -Vector2(16, 16)
+ btn.set_drag_preview(preview)
+ return {
+ "type": "emotion",
+ "kind": "emote",
+ "id": emote_id,
+ "emote_id": emote_id,
+ },
+ Callable(),
+ Callable()
+ )
+
+func _refresh_quests() -> void:
+ var quest_slot := _node("Quest_Slot")
+ if quest_slot == null or client == null:
+ return
+ var quests: Array = []
+ if client.has_method("get_quests"):
+ var raw_quests: Array = client.get_quests()
+ for q in raw_quests:
+ if String(q.get("title", q.get("name", ""))).strip_edges() != "":
+ quests.append(q)
+
+ var quest_count := quests.size()
+ var sb := _node("Quest_ScrollBar")
+ if sb is Range:
+ if quest_count > 5:
+ sb.visible = true
+ sb.max_value = maxi(0, quest_count - 5)
+ sb.value = _quest_showing_start_index
+ else:
+ sb.visible = false
+ _quest_showing_start_index = 0
+
+ for i in range(5):
+ var data_idx := _quest_showing_start_index + i
+ var nm_node := _node("Quest_Name_0%d" % i)
+ var time_node := _node("Quest_LastTime_0%d" % i)
+ var cnt_node := _node("Quest_LastCount_0%d" % i)
+ var cell := quest_slot.get_node_or_null("slot_%d" % i)
+ if cell == null and i < quest_slot.get_child_count():
+ cell = quest_slot.get_child(i)
+
+ if data_idx < quest_count:
+ var q: Dictionary = quests[data_idx]
+ var q_title := String(q.get("title", q.get("name", "任务 %d" % data_idx)))
+ var q_idx := int(q.get("index", data_idx))
+ var counter_name := String(q.get("counter_name", ""))
+ var counter_val := int(q.get("counter_value", 0))
+ var clock_name := String(q.get("clock_name", ""))
+ var clock_val := int(q.get("clock_value", 0))
+
+ if nm_node:
+ nm_node.visible = true
+ nm_node.text = q_title
+ nm_node.mouse_filter = Control.MOUSE_FILTER_PASS
+ nm_node.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ for conn in nm_node.gui_input.get_connections():
+ nm_node.gui_input.disconnect(conn["callable"])
+ nm_node.gui_input.connect(func(ev: InputEvent):
+ if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
+ _select_quest(q_idx)
+ )
+
+ if cnt_node:
+ cnt_node.visible = true
+ if counter_name != "":
+ cnt_node.text = "%s : %d" % [counter_name, counter_val]
+ else:
+ cnt_node.text = ""
+
+ if time_node:
+ time_node.visible = true
+ if clock_name != "" and clock_val > 0:
+ var m := clock_val / 60
+ var s := clock_val % 60
+ time_node.text = "%s %02d:%02d" % [clock_name, m, s]
+ else:
+ time_node.text = ""
+
+ if cell:
+ cell.mouse_filter = Control.MOUSE_FILTER_PASS
+ cell.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ for conn in cell.gui_input.get_connections():
+ cell.gui_input.disconnect(conn["callable"])
+ cell.gui_input.connect(func(ev: InputEvent):
+ if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
+ _select_quest(q_idx)
+ )
+ else:
+ if nm_node:
+ nm_node.visible = false
+ nm_node.text = ""
+ if cnt_node:
+ cnt_node.visible = false
+ cnt_node.text = ""
+ if time_node:
+ time_node.visible = false
+ time_node.text = ""
+ if cell:
+ for conn in cell.gui_input.get_connections():
+ cell.gui_input.disconnect(conn["callable"])
+
+func _select_quest(quest_idx: int) -> void:
+ if client and client.has_method("script_button"):
+ # 40250 uicharacter.py:655 event.QuestButtonClick(-2147483648 + questIndex)
+ client.script_button(-2147483648 + quest_idx)
+
+func _main_race() -> int:
+ if client and client.has_method("get_main_vid") and client.has_method("get_entity"):
+ var vid := int(client.get_main_vid())
+ if vid != 0:
+ return int(client.get_entity(vid).get("race", 0))
+ return 0
+
+const SKILL_GROUP_NAMES := {
+ 0: {1: "剑宗", 2: "气宗"},
+ 1: {1: "锋刀", 2: "百羿"},
+ 2: {1: "幻舞", 2: "黑魔"},
+ 3: {1: "潜龙", 2: "狂雷"},
+}
+
+func _get_skill_group_name(job: int, grp: int) -> String:
+ var names: Dictionary = SKILL_GROUP_NAMES.get(job, {})
+ return String(names.get(grp, "技能组 %d" % grp))
+
+func _skill_group() -> int:
+ var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0
+ if grp > 0:
+ return grp
+ return _infer_skill_group()
+
+func _infer_skill_group() -> int:
+ if client == null or not client.has_method("get_skills"):
+ return 0
+ var race := _main_race()
+ for sk in client.get_skills():
+ var sid := int(sk.get("id", 0))
+ var lvl := int(sk.get("level", 0))
+ if lvl <= 0 and int(sk.get("master", 0)) == 0:
+ continue
+ for g in [1, 2]:
+ var slot := SkillSlotMap.skill_slot_for_id(race, g, sid)
+ if slot >= 1 and slot <= 8:
+ return g
+ return 0
+
+func _skill_icon(skill_id: int) -> Texture2D:
+ if assets_root == "":
+ return null
+ var data: Dictionary = skill_table.entry(skill_id) if skill_table and skill_table.has_method("entry") else {}
+ var job := String(data.get("job", "")).to_upper()
+ var motion := String(data.get("motion", "")).strip_edges()
+ if motion == "":
+ return null
+ var candidates: Array[String] = []
+ const JOB_DIR := { "WARRIOR": "warrior", "ASSASSIN": "assassin", "SURA": "sura", "SHAMAN": "shaman" }
+ var grade := _skill_grade(skill_id)
+ var suffixes: Array[String] = ["_01", "_02", "_03"]
+ if grade == 1:
+ suffixes = ["_02", "_01", "_03"]
+ elif grade >= 2:
+ suffixes = ["_03", "_02", "_01"]
+ if JOB_DIR.has(job):
+ var dir := String(JOB_DIR[job])
+ for sfx in suffixes:
+ candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, sfx])
+ elif job == "SUPPORT":
+ for sfx in suffixes:
+ candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, sfx])
+ candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion)
+ elif job == "HORSE":
+ candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion)
+ elif job == "GUILD":
+ candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion)
+ for rel in candidates:
+ var tex: Texture2D = UiAssets.load_tex(assets_root, rel)
+ if tex != null:
+ return tex
+ return null
+
+func _skill_grade(skill_id: int) -> int:
+ if client and client.has_method("get_skills"):
+ for skill in client.get_skills():
+ if int(skill.get("id", 0)) == skill_id:
+ return clampi(int(skill.get("master", 0)), 0, 3)
+ return 0
+
+func _get_player_skill(skill_id: int) -> Dictionary:
+ if client and client.has_method("get_skills"):
+ for skill in client.get_skills():
+ if int(skill.get("id", 0)) == skill_id:
+ return skill
+ return {}
+
+func _skill_name(skill_id: int) -> String:
+ if skill_table and skill_table.has_method("name_of"):
+ var nm := String(skill_table.name_of(skill_id))
+ if nm != "":
+ return nm
+ return "技能 %d" % skill_id
+
+func _cast_skill(skill_id: int) -> void:
+ var tree := get_tree()
+ if tree and tree.root:
+ for n in tree.root.find_children("*", "Node", true, false):
+ if n.has_method("activate_skill_direct"):
+ n.activate_skill_direct(skill_id)
+ return
+ if client and client.has_method("use_skill"):
+ client.use_skill(skill_id, 0)
diff --git a/project/ui/chat_ui.gd b/project/ui/chat_ui.gd
index 8eddcae3..7a1f616c 100644
--- a/project/ui/chat_ui.gd
+++ b/project/ui/chat_ui.gd
@@ -138,6 +138,14 @@ func toggle_log() -> void:
func is_log_visible() -> bool:
return _root != null and _root.visible
+func set_view_chat(enabled: bool) -> void:
+ if _root:
+ _root.visible = enabled
+
+func set_log_visible(v: bool) -> void:
+ if _root:
+ _root.visible = v
+
# --- 建 UI --------------------------------------------------------------
func _build(parent: Node) -> void:
diff --git a/project/ui/cursor_manager.gd b/project/ui/cursor_manager.gd
index 282b6200..1c95d98e 100644
--- a/project/ui/cursor_manager.gd
+++ b/project/ui/cursor_manager.gd
@@ -1,28 +1,117 @@
# CursorManager —— 对齐 ClientVS22 mousemodule.py 的光标状态机。
#
# 光标和“鼠标跟随物品”是两层:本节点只管理当前交互语义,
-# MouseController 负责跟随鼠标的物品图标。没有对应 .sub 资源时使用 Godot
-# 系统光标,避免把资源缺失误报成输入状态缺失。
+# MouseController 负责跟随鼠标的物品图标。使用 40250 的 .sub 纹理注册真实光标,
+# 没有对应资源时回退到 Godot 系统光标。
extends Node
signal changed(shape: String)
+const UiAssets = preload("res://ui/ui_assets.gd")
+
const NORMAL := "NORMAL"
const ATTACK := "ATTACK"
+const TARGET := "TARGET"
const TALK := "TALK"
const CANT_GO := "CANT_GO"
const PICK := "PICK"
+const DOOR := "DOOR"
+const CHAIR := "CHAIR"
+const MAGIC := "MAGIC"
const BUY := "BUY"
const SELL := "SELL"
+const CAMERA_ROTATE := "CAMERA_ROTATE"
+const HSIZE := "HSIZE"
+const VSIZE := "VSIZE"
+const HVSIZE := "HVSIZE"
const ITEM := "ITEM"
+const ALL_CURSORS := [
+ NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC,
+ BUY, SELL, CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, ITEM
+]
+
var _shape := NORMAL
+var _assets_root := ""
+var _custom_cursors_registered := false
+var _cursor_images: Dictionary = {}
+
+func setup(assets_root: String) -> void:
+ _assets_root = assets_root
+ _register_custom_cursors()
+
+func _register_custom_cursors() -> void:
+ if _assets_root.is_empty():
+ return
+ var cursors := {
+ NORMAL: "ETC/ymir work/ui/cursor/cursor.sub",
+ ATTACK: "ETC/ymir work/ui/cursor/cursor_attack.sub",
+ TARGET: "ETC/ymir work/ui/cursor/cursor_attack.sub",
+ TALK: "ETC/ymir work/ui/cursor/cursor_talk.sub",
+ CANT_GO: "ETC/ymir work/ui/cursor/cursor_no.sub",
+ PICK: "ETC/ymir work/ui/cursor/cursor_pick.sub",
+ DOOR: "ETC/ymir work/ui/cursor/cursor_door.sub",
+ CHAIR: "ETC/ymir work/ui/cursor/cursor_chair.sub",
+ MAGIC: "ETC/ymir work/ui/cursor/cursor_chair.sub",
+ BUY: "ETC/ymir work/ui/cursor/cursor_buy.sub",
+ SELL: "ETC/ymir work/ui/cursor/cursor_sell.sub",
+ CAMERA_ROTATE: "ETC/ymir work/ui/cursor/cursor_camera_rotate.sub",
+ HSIZE: "ETC/ymir work/ui/cursor/cursor_hsize.sub",
+ VSIZE: "ETC/ymir work/ui/cursor/cursor_vsize.sub",
+ HVSIZE: "ETC/ymir work/ui/cursor/cursor_hvsize.sub",
+ }
+ var shape_to_godot := {
+ NORMAL: Input.CURSOR_ARROW,
+ ATTACK: Input.CURSOR_CROSS,
+ TALK: Input.CURSOR_POINTING_HAND,
+ PICK: Input.CURSOR_CAN_DROP,
+ CANT_GO: Input.CURSOR_FORBIDDEN,
+ BUY: Input.CURSOR_DRAG,
+ SELL: Input.CURSOR_MOVE,
+ DOOR: Input.CURSOR_HELP,
+ CHAIR: Input.CURSOR_WAIT,
+ CAMERA_ROTATE: Input.CURSOR_BDIAGSIZE,
+ HSIZE: Input.CURSOR_HSIZE,
+ VSIZE: Input.CURSOR_VSIZE,
+ HVSIZE: Input.CURSOR_FDIAGSIZE,
+ }
+ var hotspots := {
+ HSIZE: Vector2(16, 16),
+ VSIZE: Vector2(16, 16),
+ HVSIZE: Vector2(16, 16),
+ }
+ for name in cursors:
+ var rel: String = cursors[name]
+ var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
+ if tex != null:
+ var img: Image = _extract_image(tex)
+ if img != null:
+ _cursor_images[name] = img
+ var hs: Vector2 = hotspots.get(name, Vector2.ZERO)
+ if shape_to_godot.has(name):
+ Input.set_custom_mouse_cursor(img, shape_to_godot[name], hs)
+ _custom_cursors_registered = true
+
+func _extract_image(tex: Texture2D) -> Image:
+ if tex is AtlasTexture:
+ var at := tex as AtlasTexture
+ if at.atlas:
+ var base_img: Image = at.atlas.get_image()
+ if base_img:
+ var r := Rect2i(int(at.region.position.x), int(at.region.position.y), int(at.region.size.x), int(at.region.size.y))
+ return base_img.get_region(r)
+ elif tex:
+ return tex.get_image()
+ return null
func set_cursor(shape: String) -> void:
- if shape not in [NORMAL, ATTACK, TALK, CANT_GO, PICK, BUY, SELL, ITEM]:
+ if shape not in ALL_CURSORS:
shape = NORMAL
_shape = shape
Input.set_default_cursor_shape(_godot_shape(shape))
+ if _cursor_images.has(shape):
+ var hs := Vector2(16, 16) if shape in [HSIZE, VSIZE, HVSIZE] else Vector2.ZERO
+ Input.set_custom_mouse_cursor(_cursor_images[shape], Input.CURSOR_ARROW, hs)
changed.emit(shape)
func get_cursor() -> String:
@@ -33,13 +122,32 @@ func reset() -> void:
func _godot_shape(shape: String) -> Input.CursorShape:
match shape:
- ATTACK:
+ ATTACK, TARGET:
return Input.CURSOR_CROSS
- TALK, BUY, SELL, PICK:
+ PICK:
+ return Input.CURSOR_CAN_DROP
+ TALK:
return Input.CURSOR_POINTING_HAND
+ DOOR:
+ return Input.CURSOR_HELP
+ CHAIR, MAGIC:
+ return Input.CURSOR_WAIT
+ BUY:
+ return Input.CURSOR_DRAG
+ SELL:
+ return Input.CURSOR_MOVE
+ CAMERA_ROTATE:
+ return Input.CURSOR_BDIAGSIZE
+ HSIZE:
+ return Input.CURSOR_HSIZE
+ VSIZE:
+ return Input.CURSOR_VSIZE
+ HVSIZE:
+ return Input.CURSOR_FDIAGSIZE
CANT_GO:
return Input.CURSOR_FORBIDDEN
ITEM:
return Input.CURSOR_DRAG
_:
return Input.CURSOR_ARROW
+
diff --git a/project/ui/death_ui.gd b/project/ui/death_ui.gd
index 994be138..e292b4a3 100644
--- a/project/ui/death_ui.gd
+++ b/project/ui/death_ui.gd
@@ -1,17 +1,21 @@
-# DeathUI (P4) —— 主角死亡 → 灰屏 + 复活窗。
-#
-# var d := preload("res://ui/death_ui.gd").new()
-# add_child(d)
-# d.setup(m2client, canvas_parent)
-#
-# 触发:M2Client.entity_dead(main_vid) 或 phase_changed("dead")。
-# 复活走服务器 quest 命令(同客户端):/restart_here · /restart_town(CG_CHAT)。
-# 复活成功服务器发 GC_PLAYER_POINTS(hp>0) / 相位回 game -> 自动关。
+# death_ui.gd —— 40250 死亡结算与复活面板(RestartDialog)1:1 实现
+# 严格对照:
+# root/uirestart.py (RestartDialog)
+# uiscript/restartdialog.py (200x88 ThinBoard)
+# metin2/src/server/game/src/cmd_general.cpp:478 (10s restart_here cooldown)
extends Node
+const RESTART_HERE_COOLDOWN := 10.0 # 40250 官方 10 秒原地复活保护冷却
+const BOARD_WIDTH := 200.0
+const BOARD_HEIGHT := 88.0
+
var client: Node
var _root: Control
+var _board: Panel
+var _btn_restart_here: Button
+var _btn_restart_town: Button
var _shown := false
+var _here_cooldown_remaining := 0.0
func setup(m2client: Node, parent: Node) -> void:
client = m2client
@@ -23,6 +27,19 @@ func setup(m2client: Node, parent: Node) -> void:
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(_on_vitals)
+func _process(delta: float) -> void:
+ if not _shown:
+ return
+ if _here_cooldown_remaining > 0.0:
+ _here_cooldown_remaining -= delta
+ if _here_cooldown_remaining <= 0.0:
+ _here_cooldown_remaining = 0.0
+ _btn_restart_here.disabled = false
+ _btn_restart_here.text = "原地复活"
+ else:
+ _btn_restart_here.disabled = true
+ _btn_restart_here.text = "原地复活 (%d 秒)" % int(ceilf(_here_cooldown_remaining))
+
func _on_dead(vid: int) -> void:
if client.has_method("get_main_vid") and vid == client.get_main_vid():
show_dialog()
@@ -39,54 +56,84 @@ func show_dialog() -> void:
if _shown:
return
_shown = true
+ _here_cooldown_remaining = RESTART_HERE_COOLDOWN
+ _btn_restart_here.disabled = true
+ _btn_restart_here.text = "原地复活 (10 秒)"
_root.visible = true
func hide_dialog() -> void:
_shown = false
+ _here_cooldown_remaining = 0.0
_root.visible = false
+func is_open() -> bool:
+ return _shown and _root != null and _root.visible
+
func _build(parent: Node) -> void:
_root = Control.new()
+ _root.name = "RestartDialog"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.visible = false
_root.mouse_filter = Control.MOUSE_FILTER_STOP
parent.add_child(_root)
+ # 全屏暗红暗角
var dim := ColorRect.new()
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
- dim.color = Color(0.1, 0.0, 0.0, 0.55)
+ dim.color = Color(0.12, 0.0, 0.0, 0.6)
_root.add_child(dim)
- var panel := Panel.new()
- panel.set_anchors_preset(Control.PRESET_CENTER)
- panel.position = Vector2(-150, -70)
- panel.size = Vector2(300, 140)
+ # 40250 uiscript/restartdialog.py 1:1 尺寸 200x88 ThinBoard
+ _board = Panel.new()
+ _board.name = "Board"
+ _board.set_anchors_preset(Control.PRESET_CENTER)
+ _board.position = Vector2(-BOARD_WIDTH / 2.0, -BOARD_HEIGHT / 2.0)
+ _board.size = Vector2(BOARD_WIDTH, BOARD_HEIGHT)
var sb := StyleBoxFlat.new()
- sb.bg_color = Color(0.12, 0.1, 0.1, 0.97)
- sb.border_color = Color(0.5, 0.2, 0.2)
+ sb.bg_color = Color(0.3333, 0.2941, 0.2588, 0.95)
+ sb.border_color = Color(0.45, 0.38, 0.22, 0.95)
sb.set_border_width_all(1)
- sb.set_corner_radius_all(4)
- panel.add_theme_stylebox_override("panel", sb)
- _root.add_child(panel)
+ sb.set_corner_radius_all(2)
+ _board.add_theme_stylebox_override("panel", sb)
+ _root.add_child(_board)
- var title := Label.new()
- title.text = "你已阵亡"
- title.position = Vector2(0, 16)
- title.size = Vector2(300, 0)
- title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
- panel.add_child(title)
+ # 40250 restart_here_button (10, 17), 180x26
+ _btn_restart_here = Button.new()
+ _btn_restart_here.name = "RestartHereButton"
+ _btn_restart_here.position = Vector2(10, 17)
+ _btn_restart_here.size = Vector2(180, 26)
+ _btn_restart_here.text = "原地复活 (10 秒)"
+ _btn_restart_here.disabled = true
+ _btn_restart_here.add_theme_font_size_override("font_size", 11)
+ _btn_restart_here.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _btn_restart_here.pressed.connect(_on_restart_here_pressed)
+ _board.add_child(_btn_restart_here)
- _btn(panel, "在此复活", 24, 60, func(): _restart("/restart_here"))
- _btn(panel, "回城复活", 158, 60, func(): _restart("/restart_town"))
+ # 40250 restart_town_button (10, 47), 180x26
+ _btn_restart_town = Button.new()
+ _btn_restart_town.name = "RestartTownButton"
+ _btn_restart_town.position = Vector2(10, 47)
+ _btn_restart_town.size = Vector2(180, 26)
+ _btn_restart_town.text = "在村庄复活"
+ _btn_restart_town.add_theme_font_size_override("font_size", 11)
+ _btn_restart_town.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _btn_restart_town.pressed.connect(_on_restart_town_pressed)
+ _board.add_child(_btn_restart_town)
-func _btn(parent: Control, text: String, x: int, y: int, cb: Callable) -> void:
- var b := Button.new()
- b.text = text
- b.position = Vector2(x, y)
- b.size = Vector2(118, 30)
- b.pressed.connect(cb)
- parent.add_child(b)
+ # 40250 uirestart.py: OnPressEscapeKey / OnPressExitKey returns True (禁止 ESC 关闭)
+ _root.gui_input.connect(func(event: InputEvent):
+ if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
+ _root.accept_event()
+ )
+
+func _on_restart_here_pressed() -> void:
+ if _here_cooldown_remaining > 0.0:
+ return
+ _restart("/restart_here")
+
+func _on_restart_town_pressed() -> void:
+ _restart("/restart_town")
func _restart(cmd: String) -> void:
if client and client.has_method("say"):
- client.say(0, cmd) # CHAT_TYPE_TALKING
+ client.say(0, cmd)
diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd
index 807a9182..fbfde686 100644
--- a/project/ui/equip_model.gd
+++ b/project/ui/equip_model.gd
@@ -25,6 +25,8 @@ extends Node
const RaceSpec = preload("res://ui/race_spec.gd")
const PartHiding = preload("res://part_hiding.gd")
+const EffectRegistry = preload("res://fx/effect_registry.gd")
+const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
const WEAR_BODY := 0
const WEAR_HEAD := 1
@@ -39,7 +41,13 @@ const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
# item_length.h EWeaponSubTypes(CItemData::GetWeaponType = bSubType)
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
+const ITEM_TYPE_WEAPON := 1
+const ITEM_TYPE_ARMOR := 2
+const ARMOR_BODY := 0
const HAND_RIGHT := 1
const HAND_LEFT := 2
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
@@ -65,6 +73,9 @@ var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec)
var armor_model_map := {}
+var effect_registry: RefCounted
+var _weapon_refine_fx: Array[Node] = []
+var _armor_refine_fx: Array[Node] = []
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
race_index := -1) -> void:
@@ -153,15 +164,154 @@ func refresh() -> void:
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
var hands := _weapon_hands(wpn_eff)
- if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
+ var weapon_changed := wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands
+ if weapon_changed:
_last_weapon_vnum = wpn_eff
_last_weapon_hands = hands
# 左手用 GetSubModelThing():item .msm 加载在参考端被注释掉,恒为同一个 gr2
var wpath := _resolve_weapon(wpn_eff)
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
+ # SetArmor / SetWeapon both call __GetRefinedEffect.
+ _refresh_weapon_refine_effect(model, _last_weapon_vnum)
+ _refresh_armor_refine_effect(model, _last_body_vnum)
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
+static func refine_effect_level(vnum: int, item: Dictionary) -> int:
+ if vnum <= 0 or item.is_empty():
+ return 0
+ var sockets := int(item.get("gain_socket_pct", 0))
+ if sockets != 3:
+ return 0
+ return maxi(vnum % 10, 0)
+
+static func refine_effect_specs(vnum: int, item: Dictionary) -> Array:
+ var out: Array = []
+ if vnum <= 0 or item.is_empty():
+ return out
+ var item_type := int(item.get("type", -1))
+ var subtype := int(item.get("sub_type", -1))
+ var level := refine_effect_level(vnum, item)
+ if item_type == ITEM_TYPE_WEAPON:
+ if level < 7 or level > 9:
+ return out
+ var stem := "sword_%d" % level
+ match subtype:
+ WEAPON_SUB_DAGGER:
+ out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
+ out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
+ WEAPON_SUB_FAN:
+ out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_f.mse" % stem})
+ WEAPON_SUB_BOW:
+ out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_b.mse" % stem})
+ WEAPON_SUB_BELL, WEAPON_SUB_ARROW:
+ out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
+ _:
+ # Sword and two-handed sword share the normal sword effect.
+ out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s.mse" % stem})
+ elif item_type == ITEM_TYPE_ARMOR and subtype == ARMOR_BODY:
+ # 40250 always layers these two Blue Steel armour effects, in addition
+ # to the regular +7/+8/+9 body effect.
+ if vnum >= 12010 and vnum <= 12049:
+ out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse"})
+ out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse"})
+ if level >= 7 and level <= 9:
+ out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor_%d.mse" % level})
+ return out
+
+func _item(vnum: int) -> Dictionary:
+ if vnum > 0 and proto and proto.has_method("item"):
+ return proto.item(vnum)
+ return {}
+
+func _ensure_effect_registry() -> RefCounted:
+ if effect_registry == null and assets_root != "":
+ effect_registry = EffectRegistry.new()
+ effect_registry.call("setup", assets_root)
+ return effect_registry
+
+func _clear_refine_effects(nodes: Array[Node]) -> void:
+ for node in nodes:
+ if is_instance_valid(node) and not node.is_queued_for_deletion():
+ node.queue_free()
+ nodes.clear()
+
+func _effect_bone(model: Node, slot: String) -> String:
+ match slot:
+ "right":
+ if model and "_right_bone" in model and String(model.get("_right_bone")) != "":
+ return String(model.get("_right_bone"))
+ return "equip_right_hand"
+ "left":
+ if model and "_left_bone" in model and String(model.get("_left_bone")) != "":
+ return String(model.get("_left_bone"))
+ return "equip_left"
+ "body": return "Bip01"
+ return ""
+
+func _effect_part(model: Node, slot: String) -> Node3D:
+ if model == null:
+ return null
+ match slot:
+ "right": return model.find_child("Weapon", true, false) as Node3D
+ "left": return model.find_child("Shield", true, false) as Node3D
+ "body": return model as Node3D
+ return model as Node3D
+
+func _spawn_bone_refine(registry: RefCounted, model: Node, spec: Dictionary) -> Node:
+ # Weapons with a non-identity Granny grip pre-transform (for
+ # example 03150): it moves the particles away even though the mesh is held.
+ if not model is Node3D or not ("anim" in model):
+ return null
+ var animator: Variant = model.get("anim")
+ if not animator is Node or not animator.has_method("get_effect_bone_pose"):
+ return null
+ var bone := _effect_bone(model, String(spec.get("slot", "")))
+ if bone == "":
+ return null
+ var effect: Node = registry.call("spawn", String(spec.get("effect", "")),
+ model as Node3D, false)
+ if effect == null:
+ return null
+ var anchor := MotionEffectAnchor.new()
+ effect.add_child(anchor)
+ var event := {"attaching": true, "following": true, "independent": false,
+ "bone": bone, "pos": Vector3.ZERO}
+ if anchor.configure(model as Node3D, event):
+ return effect
+ effect.free()
+ return null
+
+func _spawn_refine_specs(model: Node, specs: Array, output: Array[Node]) -> void:
+ var registry := _ensure_effect_registry()
+ if registry == null or not registry.has_method("spawn"):
+ return
+ for spec in specs:
+ var parent := _effect_part(model, String(spec.get("slot", "")))
+ var effect: Node = _spawn_bone_refine(registry, model, spec)
+ if effect == null and parent != null:
+ effect = registry.call("spawn", String(spec.get("effect", "")), parent, false)
+ if effect:
+ output.append(effect)
+
+func _refresh_weapon_refine_effect(model: Node, vnum: int) -> void:
+ _clear_refine_effects(_weapon_refine_fx)
+ _spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _weapon_refine_fx)
+
+func _refresh_armor_refine_effect(model: Node, vnum: int) -> void:
+ _clear_refine_effects(_armor_refine_fx)
+ _spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _armor_refine_fx)
+
+# A RemotePlayerView is assembled before NetWorld adds it to the SceneTree.
+# Re-run only the effect attachment after _ready so skeleton-following anchors
+# can resolve global transforms; do not reload body/weapon geometry.
+func refresh_refine_effects() -> void:
+ var model: Node = _model_getter.call() if _model_getter.is_valid() else null
+ if model == null:
+ return
+ _refresh_armor_refine_effect(model, _last_body_vnum)
+ _refresh_weapon_refine_effect(model, _last_weapon_vnum)
+
func _weapon_hands(vnum: int) -> int:
if vnum == 0:
return 0
@@ -302,7 +452,7 @@ func _armor_shape_default(vnum: int) -> int:
if vnum > 1 and proto and proto.has_method("item"):
var it: Dictionary = proto.item(vnum)
var v = it.get("values", [])
- if v is Array and v.size() > 3 and int(v[3]) != 0:
+ if not it.is_empty() and v is Array and v.size() > 3:
return int(v[3])
return vnum
@@ -312,10 +462,13 @@ func _armor_specular(vnum: int) -> int:
return 0
func _apply_body(model: Node, vnum: int) -> void:
- # 强化等级越高越亮(PARITY §2.7:fSpecular = bSpecular / 100)
+ if model == null:
+ return
+ if model.has_method("clear_skin_textures"):
+ model.call("clear_skin_textures")
+ # 强化等级越高越亮(PARITY §2.7:fSpecular = bSpecular / 100,无高光时重置为 0)
var sp := _armor_specular(vnum)
- if sp > 0:
- model.set("specular_power", sp / 100.0)
+ model.set("specular_power", sp / 100.0 if sp > 0 else 0.0)
if armor_model_map.has(vnum):
model.set("gr2_path", String(armor_model_map[vnum]))
return
@@ -326,13 +479,18 @@ func _apply_body(model: Node, vnum: int) -> void:
var body := _resolve_spec_asset(e, String(e.get("model", "")))
if body != "":
model.set("gr2_path", body)
- # 换肤(同模型不同 TargetSkin):覆盖 surface 0
+ # 换肤(同模型不同 TargetSkin):按 40250 SetMaterialImagePointer 材质名映射
var src: String = e.get("source_skin", "")
var tgt: String = e.get("target_skin", "")
- if tgt != "" and tgt != src and model.has_method("set_surface_texture"):
+ if tgt != "" and tgt != src:
var t := _resolve_spec_asset(e, tgt)
- if t != "":
- model.call("set_surface_texture", 0, t)
+ var s := _resolve_spec_asset(e, src)
+ if model.has_method("set_skin_texture"):
+ if t != "":
+ model.call("set_skin_texture", s if s != "" else src, t)
+ if model.has_method("set_surface_texture"):
+ if t != "":
+ model.call("set_surface_texture", 0, t)
func _spec_shape(idx: int) -> Dictionary:
_ensure_spec()
diff --git a/project/ui/game_option_ui.gd b/project/ui/game_option_ui.gd
index dd05c9f6..db901a1c 100644
--- a/project/ui/game_option_ui.gd
+++ b/project/ui/game_option_ui.gd
@@ -49,7 +49,7 @@ const LABELS := {
"pvp_mode": "PK 模式", "block": "屏蔽", "chat": "聊天显示",
"always_show_name": "总显示名字", "effect_on_off": "伤害数字", "salestext_on_off": "叫卖文字",
"name_color_normal": "普通", "name_color_empire": "阵营",
- "target_board_no_view": "不看他国", "target_board_view": "看他国",
+ "target_board_no_view": "关", "target_board_view": "开",
"pvp_peace": "和平", "pvp_revenge": "反击", "pvp_guild": "帮会", "pvp_free": "自由",
"block_exchange_button": "交易", "block_party_button": "组队", "block_guild_button": "帮会",
"block_whisper_button": "密语", "block_friend_button": "好友", "block_party_request_button": "组队申请",
@@ -99,6 +99,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
if client and client.has_signal("block_mode_changed"):
client.block_mode_changed.connect(_on_server_block_mode)
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ _win = {}
+ )
func is_open() -> bool:
if _mobile_mode:
@@ -127,9 +132,10 @@ func close() -> void:
ui.close(_mobile_root)
_mobile_root = null
return
- if is_open():
- ui.close(_win["root"])
+ var r: Control = _win.get("root", null)
_win = {}
+ if r and is_instance_valid(r) and ui:
+ ui.close(r)
func open() -> void:
if _mobile_mode:
@@ -141,13 +147,35 @@ func open() -> void:
if not FileAccess.file_exists(path):
push_warning("GameOptionUI: no gameoptiondialog.py at " + path)
return
- _win = ui.open_script(path, assets_root)
+ _win = ui.open_script(path, assets_root, false, true)
if not is_open():
return
+ set_center_position()
_relabel()
_wire()
_sync()
+## 1:1 对齐 40250 uigameoption.py: self.SetCenterPosition()
+func set_center_position(offset := Vector2.ZERO) -> void:
+ var r: Control = _win.get("root", null)
+ if r == null or not is_instance_valid(r):
+ return
+ if ui and ui.has_method("center_window"):
+ ui.center_window(r, offset)
+ else:
+ var vp_sz := Vector2(1920, 1080)
+ var vp := r.get_viewport()
+ if vp:
+ var vrect := vp.get_visible_rect().size
+ if vrect.x > 300 and vrect.y > 300:
+ vp_sz = vrect
+ elif ui and "screen" in ui and ui.screen.x > 0:
+ vp_sz = Vector2(ui.screen)
+ var sz := r.size
+ if sz.x <= 0 or sz.y <= 0:
+ sz = r.custom_minimum_size
+ r.position = ((vp_sz - sz) / 2.0 + offset).round()
+
func _open_mobile() -> void:
if is_open():
return
diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd
index d6a094e4..2e6dbee4 100644
--- a/project/ui/ground_items.gd
+++ b/project/ui/ground_items.gd
@@ -8,6 +8,8 @@
# M2Client.ground_item_added(Dict{vid,vnum,pos,owner}) / ground_item_removed(vid) 驱动。
extends Node
+signal cannot_pick_item(owner_name: String)
+
const PICKUP_RANGE := 3.0
# §8.8 seam ⑩(CPythonTextTail::ArrangeTextTail, PythonTextTail.cpp:153-190):多条掉落
@@ -49,12 +51,56 @@ var proto: Node
var item_list: RefCounted
var _player_getter: Callable
var _camera_getter: Callable
+var _world_getter: Callable
+var _world: Node
var _by_vid := {} # vid -> Node3D
+var _next_tcp_time_ms := 0
+
+func get_pickable_distance() -> float:
+ var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
+ if p and bool(p.get_meta("mounted", false)):
+ return 5.0
+ return PICKUP_RANGE
+
+func _get_main_player_name() -> String:
+ if client == null:
+ return ""
+ if client.has_method("get_main_vid") and client.has_method("get_entity"):
+ var vid := int(client.get_main_vid())
+ if vid > 0:
+ var e: Dictionary = client.get_entity(vid)
+ var n := String(e.get("name", ""))
+ if n != "":
+ return n
+ if client.has_method("get_character_name"):
+ return String(client.get_character_name())
+ return ""
+
+func _on_cannot_pick_item(owner: String) -> void:
+ cannot_pick_item.emit(owner)
+ print("[拾取] 这不是你的物品,无法拾取!(所有者: %s)" % owner)
+
+func _is_rare_drop(vnum: int, d: Dictionary) -> bool:
+ if vnum == 1:
+ return false
+ if vnum in [25040, 50513, 70024, 70031]:
+ return true
+ if proto and proto.has_method("item"):
+ var pd: Dictionary = proto.item(vnum)
+ var itype := int(pd.get("type", 0))
+ if itype in [1, 2] and (vnum % 10) >= 7:
+ return true
+ if int(d.get("rarity", 0)) >= 2 or int(d.get("grade", 0)) >= 3:
+ return true
+ return false
+
+var assets_override := ""
var _possessive := OWNER_POSSESSIVE # setup(possessive) 注入;空 -> 字面 "'s"(SetItemTextTailOwner :722)
func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
proto_node: Node = null, il: RefCounted = null,
- camera_getter: Callable = Callable(), possessive := "") -> void:
+ camera_getter: Callable = Callable(), possessive := "",
+ world_getter: Callable = Callable()) -> void:
client = m2client
_possessive = possessive if possessive != "" else OWNER_POSSESSIVE # :722 == "" ? "'s" : loc
mount = mount_node
@@ -62,6 +108,7 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
item_list = il
_player_getter = player_getter
_camera_getter = camera_getter
+ _world_getter = world_getter
if client.has_signal("ground_item_added"):
client.ground_item_added.connect(_on_added)
if client.has_signal("ground_item_removed"):
@@ -71,6 +118,15 @@ func setup(m2client: Node, mount_node: Node3D, player_getter: Callable,
for d in client.get_ground_items():
_on_added(d)
+func _to_world_pos(raw_pos: Variant) -> Vector3:
+ if not (raw_pos is Vector3):
+ return Vector3.ZERO
+ var pos: Vector3 = MapCoord.to_world(raw_pos)
+ var w: Node = _world_getter.call() if _world_getter.is_valid() else _world
+ if w and w.has_method("sample_height"):
+ pos.y = float(w.call("sample_height", pos.x, pos.z)) + 0.1
+ return pos
+
func _on_added(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0:
@@ -78,9 +134,11 @@ func _on_added(d: Dictionary) -> void:
var vnum := int(d.get("vnum", 0))
var owner := String(d.get("owner", ""))
var item_color := _item_color(vnum, d)
+ var pos := _to_world_pos(d.get("pos", Vector3.ZERO))
if _by_vid.has(vid):
var existing: Node3D = _by_vid[vid]
if is_instance_valid(existing):
+ existing.position = pos
var existing_tag := existing.get_node("tag") as Label3D
existing_tag.text = _name_for(vnum)
existing_tag.modulate = item_color
@@ -91,18 +149,51 @@ func _on_added(d: Dictionary) -> void:
return
var node := Node3D.new()
node.name = "drop_%d" % vid
- node.position = d.get("pos", Vector3.ZERO)
- var mesh := MeshInstance3D.new()
- var box := BoxMesh.new()
- box.size = Vector3(0.25, 0.25, 0.25)
- mesh.mesh = box
- mesh.position.y = 0.3
- var mat := StandardMaterial3D.new()
- mat.albedo_color = Color(1.0, 0.85, 0.3)
- mat.emission_enabled = true
- mat.emission = Color(0.6, 0.5, 0.1)
- mesh.material_override = mat
- node.add_child(mesh)
+ node.position = pos
+
+ var root := "" if assets_override == "__none__" else (assets_override if assets_override != "" else (AssetRoot.path() if AssetRoot.available() else ""))
+ var model_path := ""
+ if root != "":
+ if vnum == 1:
+ for p in ["item/ymir work/item/etc/money.gr2", "item/etc/money.gr2"]:
+ var fp := root.path_join(p)
+ if FileAccess.file_exists(fp):
+ model_path = fp
+ break
+ else:
+ for p in ["item/ymir work/item/etc/item_bag.gr2", "item/etc/item_bag.gr2"]:
+ var fp := root.path_join(p)
+ if FileAccess.file_exists(fp):
+ model_path = fp
+ break
+
+ if model_path != "" and ClassDB.class_exists("Metin2Model"):
+ var model: Node3D = ClassDB.instantiate("Metin2Model")
+ model.name = "model"
+ model.set("flip_winding", true)
+ model.set("texture_dir", model_path.get_base_dir())
+ model.set("gr2_path", model_path)
+ node.add_child(model)
+ else:
+ var mesh := MeshInstance3D.new()
+ var box := BoxMesh.new()
+ box.size = Vector3(0.25, 0.25, 0.25)
+ mesh.mesh = box
+ mesh.position.y = 0.15
+ var mat := StandardMaterial3D.new()
+ if vnum == 1:
+ mat.albedo_color = Color(1.0, 0.85, 0.2)
+ mat.metallic = 0.8
+ mat.roughness = 0.2
+ mat.emission_enabled = true
+ mat.emission = Color(0.5, 0.4, 0.1)
+ else:
+ mat.albedo_color = Color(1.0, 0.85, 0.3)
+ mat.emission_enabled = true
+ mat.emission = Color(0.6, 0.5, 0.1)
+ mesh.material_override = mat
+ node.add_child(mesh)
+
var tag := Label3D.new()
tag.name = "tag"
tag.text = _name_for(vnum) # 名字实例只放名字,owner 走独立 owner_tag(:723 不再拼串)
@@ -117,6 +208,23 @@ func _on_added(d: Dictionary) -> void:
node.set_meta("item_owner", owner)
node.set_meta("item_color", item_color)
_apply_owner_tag(node, owner, item_color)
+ if _is_rare_drop(vnum, d):
+ var beam := MeshInstance3D.new()
+ beam.name = "light_beam"
+ var cyl := CylinderMesh.new()
+ cyl.top_radius = 0.08
+ cyl.bottom_radius = 0.12
+ cyl.height = 3.5
+ beam.mesh = cyl
+ beam.position.y = 1.75
+ var bmat := StandardMaterial3D.new()
+ bmat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ bmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ bmat.albedo_color = Color(1.0, 0.85, 0.2, 0.45)
+ bmat.emission_enabled = true
+ bmat.emission = Color(1.0, 0.85, 0.3)
+ beam.material_override = bmat
+ node.add_child(beam)
mount.add_child(node)
_by_vid[vid] = node
@@ -214,49 +322,123 @@ func has_nearby_item() -> bool:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return false
+ var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
for node in _by_vid.values():
- if is_instance_valid(node) and p.global_position.distance_to(node.global_position) < PICKUP_RANGE:
+ if not is_instance_valid(node):
+ continue
+ var n_pos: Vector3 = node.global_position if node.is_inside_tree() else node.position
+ if p_pos.distance_to(n_pos) < PICKUP_RANGE:
return true
return false
-# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
+# 40250 1:1 PickCloseItem: 500ms 节流 + 距离 (步行 3m / 骑马 5m) + 所有权保护预检
func try_pickup() -> int:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null or client == null:
return 0
+ var now_ms := Time.get_ticks_msec()
+ if now_ms < _next_tcp_time_ms:
+ return 0
+
+ var p_pos: Vector3 = p.global_position if p.is_inside_tree() else p.position
var best_vid := 0
- var best_d := PICKUP_RANGE
+ var pick_dist := get_pickable_distance()
+ var best_d := pick_dist
+ var best_owner := ""
+
for vid in _by_vid:
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
continue
- var d := p.global_position.distance_to(n.global_position)
+ var n_pos: Vector3 = n.global_position if n.is_inside_tree() else n.position
+ var d := p_pos.distance_to(n_pos)
if d < best_d:
best_d = d
best_vid = int(vid)
- if best_vid != 0:
+ best_owner = String(n.get_meta("item_owner", ""))
+
+ if best_vid == 0:
+ return 0
+
+ var my_name := _get_main_player_name()
+ if best_owner != "" and my_name != "" and best_owner != my_name:
+ _on_cannot_pick_item(best_owner)
+ return 0
+
+ _next_tcp_time_ms = now_ms + 500
+ if client.has_method("pickup_item"):
client.pickup_item(best_vid)
return best_vid
+func try_pickup_vid(vid: int) -> bool:
+ if client == null or not _by_vid.has(vid):
+ return false
+ var n: Node3D = _by_vid[vid]
+ if not is_instance_valid(n):
+ return false
+
+ var now_ms := Time.get_ticks_msec()
+ if now_ms < _next_tcp_time_ms:
+ return false
+
+ var best_owner := String(n.get_meta("item_owner", ""))
+ var my_name := _get_main_player_name()
+ if best_owner != "" and my_name != "" and best_owner != my_name:
+ _on_cannot_pick_item(best_owner)
+ return false
+
+ _next_tcp_time_ms = now_ms + 500
+ if client.has_method("pickup_item"):
+ client.pickup_item(vid)
+ return true
+
# 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:
+ return pick_at(camera, screen_pos) != 0
+
+# Pick ground item at mouse screen position (40250 __Pick + __OnClickItem + CPythonTextTail::Pick).
+# 同时支持点选地面模型、头顶名称标签(tag)与所有者标签(owner_tag)。
+func pick_at(camera: Camera3D, screen_pos: Vector2) -> int:
if camera == null:
- return false
- var best := INF
- for node in _by_vid.values():
+ return 0
+ var best_d := 64.0
+ var best_vid := 0
+ for vid in _by_vid:
+ var node: Node3D = _by_vid[vid]
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
+ var test_pts: Array[Vector3] = [
+ node.global_position + Vector3(0, 0.35, 0)
+ ]
+ var tag: Node = node.get_node_or_null("tag")
+ if tag is Node3D and is_instance_valid(tag):
+ test_pts.append(tag.global_position)
+ var otag: Node = node.get_node_or_null("owner_tag")
+ if otag is Node3D and is_instance_valid(otag):
+ test_pts.append(otag.global_position)
+ for tp in test_pts:
+ if camera.is_position_behind(tp):
+ continue
+ var p := camera.unproject_position(tp)
+ var d := p.distance_to(screen_pos)
+ if d < best_d:
+ best_d = d
+ best_vid = int(vid)
+ return best_vid
+
+func get_item_world_pos(vid: int) -> Variant:
+ var node: Node3D = _by_vid.get(vid, null)
+ if node and is_instance_valid(node):
+ return node.global_position if node.is_inside_tree() else node.position
+ return null
func _name_for(vnum: int) -> String:
+ if vnum == 1:
+ return "Yang"
if proto:
var pd: Dictionary = proto.item(vnum)
if not pd.is_empty():
diff --git a/project/ui/help_ui.gd b/project/ui/help_ui.gd
index 1dc70276..1a76d170 100644
--- a/project/ui/help_ui.gd
+++ b/project/ui/help_ui.gd
@@ -117,6 +117,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node = null, assets := "") -> void
if LocaleScript:
_loc = LocaleScript.new()
_loc.setup(assets_root, "en")
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ _win = {}
+ )
# --- open / close ---------------------------------------------------
@@ -144,9 +149,10 @@ func close() -> void:
_mobile_root = null
_mobile_labels.clear()
return
- if is_open():
- ui.close(_win["root"])
+ var r: Control = _win.get("root", null)
_win = {}
+ if r and is_instance_valid(r) and ui:
+ ui.close(r)
func open() -> void:
if _mobile_mode:
diff --git a/project/ui/hit_reaction.gd.uid b/project/ui/hit_reaction.gd.uid
new file mode 100644
index 00000000..6561b747
--- /dev/null
+++ b/project/ui/hit_reaction.gd.uid
@@ -0,0 +1 @@
+uid://d1nugtcuikfs4
diff --git a/project/ui/inventory_ui.gd b/project/ui/inventory_ui.gd
index 81a6322d..4ea507ae 100644
--- a/project/ui/inventory_ui.gd
+++ b/project/ui/inventory_ui.gd
@@ -28,6 +28,10 @@ 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 ChestSystem = preload("res://chest_system.gd")
+const ItemAttrSystem = preload("res://item_attr_system.gd")
+const MetinSocketSystem = preload("res://metin_socket_system.gd")
+const ConsumableSystem = preload("res://consumable_system.gd")
const EQUIP_BASE := 90 # uiscript EQUIPMENT_START_INDEX
const BELT_BASE := 200 # 仅 UI 内部索引,避免与旧 slot_index 相撞
@@ -94,6 +98,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: St
uiscript_dir = assets.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ close()
+ )
set_process_input(true)
func is_open() -> bool:
@@ -127,13 +136,14 @@ func close() -> void:
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"])
+ var r: Control = _win.get("root", null)
_win = {}
_cells.clear()
_combine_from = -1
_mobile_selected_ui = -1
_hint = null
+ if r and is_instance_valid(r) and ui:
+ ui.close(r)
func open() -> void:
if is_open():
@@ -147,6 +157,7 @@ func open() -> void:
_add_extended_cells(_win["root"])
_wire_cells()
_wire_money()
+ _wire_mall_button()
refresh()
# --- 填充 ---------------------------------------------------------------
@@ -258,6 +269,14 @@ func _wire_money() -> void:
else:
_money_button = null
+func _wire_mall_button() -> void:
+ var mall_btn = _win.get("nodes", {}).get("MallButton", null)
+ if mall_btn is BaseButton:
+ mall_btn.pressed.connect(func():
+ if client and client.has_method("say"):
+ client.say(0, "/click_mall")
+ )
+
func _current_gold() -> int:
if client and client.has_method("get_points"):
return maxi(0, int(client.get_points().get("gold", 0)))
@@ -623,6 +642,16 @@ func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool:
and client.mall_checkout(source_cell, target[0], target[1])
if source_window not in [WINDOW_INVENTORY, 2]:
return false
+ var target_cell_node: Panel = _cells.get(target_ui, null)
+ var target_vnum := int(target_cell_node.get_meta("vnum", 0)) if target_cell_node else 0
+ var source_vnum := int(payload.get("vnum", 0))
+ if target_vnum != 0 and source_vnum != 0:
+ if ChestSystem.is_treasure_key(source_vnum) and ChestSystem.is_treasure_box(target_vnum):
+ if client and client.has_method("use_item_to_item"):
+ return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1]))
+ if ItemAttrSystem.is_attr_scroll(source_vnum) or MetinSocketSystem.is_metin_stone(source_vnum) or MetinSocketSystem.is_clean_socket_scroll(source_vnum):
+ if client and client.has_method("use_item_to_item"):
+ return client.use_item_to_item(source_window, source_cell, int(target[0]), int(target[1]))
return move_wire(source_window, source_cell, int(target[0]), int(target[1]),
maxi(1, int(payload.get("count", 1))))
diff --git a/project/ui/loading_screen.gd b/project/ui/loading_screen.gd
index d7e708b6..3ab8c9ac 100644
--- a/project/ui/loading_screen.gd
+++ b/project/ui/loading_screen.gd
@@ -30,13 +30,14 @@ func setup(m2client: Node = null) -> void:
func _on_phase(phase: String) -> void:
if phase.to_lower() == "loading":
- show_for(String(HINTS.get("loading", "载入中…")))
- else:
- hide_screen() # login / select / game —— 让位给对应界面
+ show_for(String(HINTS.get("loading", "载入地图…")))
+ elif phase.to_lower() in ["login", "select"]:
+ hide_screen()
func show_for(text: String) -> void:
_label.text = text
_bar.visible = false
+ _panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = true
func set_progress(frac: float) -> void:
@@ -44,6 +45,7 @@ func set_progress(frac: float) -> void:
_bar.value = clampf(frac, 0.0, 1.0) * 100.0
func hide_screen() -> void:
+ _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.visible = false
func is_showing() -> bool:
@@ -58,6 +60,7 @@ func _build() -> void:
_panel = ColorRect.new()
_panel.color = Color(0.02, 0.03, 0.05, 0.96)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
+ _panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = false
add_child(_panel)
var box := VBoxContainer.new()
diff --git a/project/ui/mall_ui.gd b/project/ui/mall_ui.gd
index eafcfb9a..2656e28a 100644
--- a/project/ui/mall_ui.gd
+++ b/project/ui/mall_ui.gd
@@ -20,11 +20,15 @@ var _password_dialog: ConfirmationDialog
var item_mouse: Node
const MALL_WINDOW := 4
const MALL_PAGE_SLOTS := 45
+const USE_MALL_LIMIT_RANGE_M := 10.0
+
var _grid: GridContainer
var _cells: Dictionary = {}
var _page_label: Label
var _page := 0
var _mobile_mode := false
+var _open_char_pos := Vector3.ZERO
+var _has_open_pos := false
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -33,7 +37,9 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_build(parent)
if client.has_signal("mall_opened"):
- client.mall_opened.connect(func(_s): refresh())
+ client.mall_opened.connect(func(_s): open())
+ if client.has_signal("mall_closed"):
+ client.mall_closed.connect(_close_local)
if client.has_signal("mall_changed"):
client.mall_changed.connect(refresh)
if client.has_signal("mall_password_required"):
@@ -42,6 +48,35 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
+func _get_main_char_pos() -> Variant:
+ if client == null:
+ return null
+ var vid := 0
+ if client.has_method("get_main_vid"):
+ vid = int(client.get_main_vid())
+ if vid != 0 and client.has_method("get_entity"):
+ var e: Dictionary = client.get_entity(vid)
+ if not e.is_empty() and e.has("pos"):
+ return e["pos"]
+ return null
+
+func _process(_delta: float) -> void:
+ if not is_open() or not _has_open_pos:
+ return
+ var cur = _get_main_char_pos()
+ if cur is Vector3:
+ if _open_char_pos.distance_to(cur) > USE_MALL_LIMIT_RANGE_M:
+ close()
+
+func open() -> void:
+ if _root:
+ _root.visible = true
+ var p = _get_main_char_pos()
+ if p is Vector3:
+ _open_char_pos = p
+ _has_open_pos = true
+ refresh()
+
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
@@ -52,6 +87,10 @@ func close() -> void:
# 对齐原版 MallWindow.Close():先发 /mall_close 让服务器关掉商城仓库。
if client and client.has_method("mall_close") and is_open():
client.mall_close()
+ _close_local()
+
+func _close_local() -> void:
+ _has_open_pos = false
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():
@@ -133,18 +172,43 @@ func _render_grid() -> void:
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
+func _drop_to_slot(_payload: Dictionary, _local_pos: int) -> bool:
+ if is_instance_valid(_status):
+ _status.text = "无法将物品放入商城仓库!"
+ if item_mouse and item_mouse.has_method("cancel"):
+ item_mouse.cancel()
+ return false
+
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
- if not item_mouse or not item_mouse.has_method("attach_item"):
+ if not (event is InputEventMouseButton and event.pressed):
return
- if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT
- and event.pressed):
+ if event.button_index == MOUSE_BUTTON_RIGHT:
+ var mall_pos := _page * MALL_PAGE_SLOTS + local_pos
+ var target := _first_free_inv()
+ if target < 0:
+ if is_instance_valid(_status):
+ _status.text = "背包已满"
+ return
+ if client and client.has_method("mall_checkout"):
+ client.mall_checkout(mall_pos, 1, target)
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")
+ elif event.button_index == MOUSE_BUTTON_LEFT:
+ if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
+ if is_instance_valid(_status):
+ _status.text = "无法将物品放入商城仓库!"
+ item_mouse.cancel()
+ return
+ if _mobile_mode:
+ _on_mobile_grid_tap(local_pos)
+ return
+ if not item_mouse or not item_mouse.has_method("attach_item"):
+ 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 _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
diff --git a/project/ui/minimap.gd b/project/ui/minimap.gd
index 90578edc..8f3f9d8a 100644
--- a/project/ui/minimap.gd
+++ b/project/ui/minimap.gd
@@ -29,6 +29,10 @@ var player_getter: Callable
var _root: Control
var _view: Control
var _ch_label: Label
+var _coord_label: Label
+var _btn_zoom_in: Button
+var _btn_zoom_out: Button
+var _btn_atlas: Button
var _scale := SCALE
var _signal_points: Array[Vector2] = []
var _observer_tracks: Dictionary = {}
@@ -47,10 +51,12 @@ func setup(m2client: Node, parent: Node, get_player: Callable) -> void:
func _build(parent: Node) -> void:
_root = Control.new()
+ _root.name = "MiniMap"
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
- _root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
+ _root.size = Vector2(2 * RADIUS + 16, 2 * RADIUS + 38)
parent.add_child(_root)
+
_view = Control.new()
_view.size = Vector2(2 * RADIUS, 2 * RADIUS)
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -58,13 +64,59 @@ func _build(parent: Node) -> void:
_root.add_child(_view)
_root.gui_input.connect(_on_gui_input)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
+
_ch_label = Label.new()
- _ch_label.position = Vector2(0, 2 * RADIUS)
- _ch_label.add_theme_font_size_override("font_size", 11)
+ _ch_label.position = Vector2(4, 2 * RADIUS + 2)
+ _ch_label.add_theme_font_size_override("font_size", 10)
_ch_label.text = "CH -"
_root.add_child(_ch_label)
+ # 40250 uiminimap.py:440 实时坐标 "(X, Y)"
+ _coord_label = Label.new()
+ _coord_label.position = Vector2(46, 2 * RADIUS + 2)
+ _coord_label.size = Vector2(90, 16)
+ _coord_label.add_theme_font_size_override("font_size", 10)
+ _coord_label.text = "(0, 0)"
+ _root.add_child(_coord_label)
+
+ # 40250 uiminimap.py: ScaleUp / ScaleDown / Atlas
+ _btn_zoom_in = Button.new()
+ _btn_zoom_in.name = "ScaleUpButton"
+ _btn_zoom_in.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 38)
+ _btn_zoom_in.size = Vector2(18, 18)
+ _btn_zoom_in.text = "+"
+ _btn_zoom_in.add_theme_font_size_override("font_size", 11)
+ _btn_zoom_in.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _btn_zoom_in.pressed.connect(func(): set_scale(_scale + 0.05))
+ _root.add_child(_btn_zoom_in)
+
+ _btn_zoom_out = Button.new()
+ _btn_zoom_out.name = "ScaleDownButton"
+ _btn_zoom_out.position = Vector2(2 * RADIUS - 2, 2 * RADIUS - 18)
+ _btn_zoom_out.size = Vector2(18, 18)
+ _btn_zoom_out.text = "-"
+ _btn_zoom_out.add_theme_font_size_override("font_size", 11)
+ _btn_zoom_out.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _btn_zoom_out.pressed.connect(func(): set_scale(_scale - 0.05))
+ _root.add_child(_btn_zoom_out)
+
+ _btn_atlas = Button.new()
+ _btn_atlas.name = "AtlasButton"
+ _btn_atlas.position = Vector2(2 * RADIUS - 2, 2 * RADIUS + 2)
+ _btn_atlas.size = Vector2(18, 18)
+ _btn_atlas.text = "M"
+ _btn_atlas.add_theme_font_size_override("font_size", 10)
+ _btn_atlas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _btn_atlas.pressed.connect(func(): map_pressed.emit())
+ _root.add_child(_btn_atlas)
+
func _process(_dt: float) -> void:
+ if _coord_label:
+ var pl := _player()
+ if pl:
+ var pos: Vector3 = pl.global_position if pl.is_inside_tree() else pl.position
+ # 40250 uiminimap.py: (x // 100, y // 100)
+ _coord_label.text = "(%d, %d)" % [int(pos.x), int(-pos.z)]
if _view:
_view.queue_redraw()
diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd
index 24ae83b7..15b20aae 100644
--- a/project/ui/mob_view.gd
+++ b/project/ui/mob_view.gd
@@ -53,8 +53,16 @@ var _sound_frame := -1
# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。
# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。
static var _npclist: Dictionary = {}
+static var _race_src_name: Dictionary = {}
static var _npclist_loaded := false
+# race -> { "dir": String, "mesh_stem": String, "gr2": String, "motions": Dictionary }
+# 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。
+static var _race_spec_cache: Dictionary = {}
+
+static func clear_cache() -> void:
+ _race_spec_cache.clear()
+
static func _load_npclist(assets_root: String) -> void:
if _npclist_loaded:
return
@@ -77,43 +85,37 @@ static func _load_npclist(assets_root: String) -> void:
var code := String(parts[1]).strip_edges()
if vnum > 0 and code != "":
_npclist[vnum] = code
+ elif vnum == 0 and parts.size() >= 3:
+ var src := String(parts[2]).strip_edges()
+ if code != "" and src != "":
+ _race_src_name[code] = src
break
func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool:
if not ClassDB.class_exists("Metin2Model") or race < 1:
return false
- _load_npclist(assets_root)
- # 候选代号:npclist 优先,proto 名兜底
- var stems: Array[String] = []
- var code: String = _npclist.get(race, "")
- if code != "":
- stems.append(code)
- for c: String in _folder_candidates(proto, race):
- if not stems.has(c):
- stems.append(c)
- # 逐个代号:目录名从完整代号逐段回退(bear_brown -> bear),网格名保留完整代号
- var mesh_stem := ""
- for stem in stems:
- var segs := stem.split("_")
- for cut in range(segs.size(), 0, -1):
- var folder := "_".join(segs.slice(0, cut))
- var d := _find_dir(assets_root, folder)
- if d != "":
- _dir = d
- mesh_stem = stem
- break
- if _dir != "":
- break
- if _dir == "":
- return false
- _mesh_stem = mesh_stem
- var gr2 := _dir.path_join(mesh_stem + ".gr2")
- if not FileAccess.file_exists(gr2):
- gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
- if not FileAccess.file_exists(gr2):
- gr2 = _pick_base_gr2() # 目录里第一个非动作 .gr2
- if gr2 == "":
- return false
+ var gr2 := ""
+ var cached: Variant = _race_spec_cache.get(race, null)
+ if cached is Dictionary:
+ _dir = String(cached.get("dir", ""))
+ _mesh_stem = String(cached.get("mesh_stem", ""))
+ gr2 = String(cached.get("gr2", ""))
+ _motions = (cached.get("motions", {}) as Dictionary).duplicate()
+ else:
+ _load_npclist(assets_root)
+ var spec := _find_model_spec(assets_root, proto, race)
+ if spec.is_empty():
+ return false
+ _dir = String(spec.get("dir", ""))
+ _mesh_stem = String(spec.get("mesh_stem", ""))
+ gr2 = String(spec.get("gr2", ""))
+ _load_motlist()
+ _race_spec_cache[race] = {
+ "dir": _dir,
+ "mesh_stem": _mesh_stem,
+ "gr2": gr2,
+ "motions": _motions.duplicate()
+ }
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
@@ -126,7 +128,6 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
- _load_motlist()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
@@ -176,8 +177,13 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
- return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)),
- MsaMotion.move_speed(String(resolve_motion("run").path)))
+ var w := MsaMotion.move_speed(String(resolve_motion("walk").path))
+ var r := MsaMotion.move_speed(String(resolve_motion("run").path))
+ if w <= 0.0 and r > 0.0:
+ w = r
+ elif r <= 0.0 and w > 0.0:
+ r = w
+ return Vector2(w, r)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
@@ -193,6 +199,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
+# 40250 CActorInstance::__Shake(100)
+func shake() -> void:
+ _hit.shake()
+
func is_in_hit_reaction() -> bool:
return _hit.active
@@ -241,6 +251,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
+ anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
@@ -351,7 +362,10 @@ func _load_motlist() -> void:
_motions[motion] = abs
func _pick_base_gr2() -> String:
- var da := DirAccess.open(_dir)
+ return _pick_base_gr2_in(_dir)
+
+func _pick_base_gr2_in(d: String) -> String:
+ var da := DirAccess.open(d)
if da == null:
return ""
for fn in da.get_files():
@@ -363,30 +377,158 @@ func _pick_base_gr2() -> String:
var stem := fn.get_basename()
if stem.is_valid_int() or (stem.contains("_") and stem.split("_")[0].is_valid_int()):
continue
- return _dir.path_join(fn)
+ return d.path_join(fn)
return ""
-func _find_dir(assets_root: String, folder: String) -> String:
- # /*/ymir work/{monster,npc}/
- var rels := [
- "ymir work/monster/%s" % folder,
- "ymir work/monster2/%s" % folder,
- "ymir work/npc/%s" % folder,
- "ymir work/npc2/%s" % folder,
- "Monster/ymir work/monster/%s" % folder,
- "Monster/ymir work/monster2/%s" % folder,
- "NPC/ymir work/npc/%s" % folder,
- "NPC/ymir work/npc2/%s" % folder,
- ]
- for rel: String in rels:
- var d := assets_root.path_join(rel)
- if DirAccess.dir_exists_absolute(d):
- return d
+# MSM BaseModelFileName 解析:如 campfire.msm 指向 zone/b/obj/general_obj_campfire.gr2
+func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String:
+ if not FileAccess.file_exists(msm_path):
+ return ""
+ var f := FileAccess.open(msm_path, FileAccess.READ)
+ if f == null:
+ return ""
+ var content := f.get_as_text()
+ var regex := RegEx.create_from_string("(?i)BaseModelFileName\\s+\"([^\"]+)\"")
+ if regex == null:
+ return ""
+ var match := regex.search(content)
+ if match == null:
+ return ""
+ var raw_path := match.get_string(1).replace("\\", "/").strip_edges()
+ var lower := raw_path.to_lower()
+ var rel := ""
+ for prefix in ["d:/ymir work/", "c:/ymir work/", "ymir work/", "/ymir work/"]:
+ if lower.begins_with(prefix):
+ rel = raw_path.substr(prefix.length())
+ break
+ if rel == "":
+ rel = raw_path
+ for candidate in [
+ assets_root.path_join("ymir work").path_join(rel),
+ assets_root.path_join(rel),
+ assets_root.path_join("Zone/ymir work").path_join(rel),
+ assets_root.path_join("NPC/ymir work").path_join(rel),
+ assets_root.path_join("Monster/ymir work").path_join(rel),
+ ]:
+ if FileAccess.file_exists(candidate):
+ return candidate
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
- for rel: String in rels:
- var c := assets_root.path_join(sub).path_join(rel)
- if DirAccess.dir_exists_absolute(c):
- return c
+ var c := assets_root.path_join(sub).path_join("ymir work").path_join(rel)
+ if FileAccess.file_exists(c):
+ return c
+ c = assets_root.path_join(sub).path_join(rel)
+ if FileAccess.file_exists(c):
+ return c
return ""
+
+func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary:
+ var codes: Array[String] = []
+ var primary: String = _npclist.get(race, "")
+ if primary != "":
+ codes.append(primary)
+ for c: String in _folder_candidates(proto, race):
+ if not codes.has(c):
+ codes.append(c)
+
+ for code in codes:
+ var spec := _resolve_code_spec(assets_root, code)
+ if not spec.is_empty():
+ return spec
+ return {}
+
+func _resolve_code_spec(assets_root: String, code: String) -> Dictionary:
+ if code.begins_with("#"):
+ # 40250 LOAD_LOCAL_RESOURCE:直接相对路径(如 #season1/npc/chagirap/)
+ var rel := code.trim_prefix("#").strip_edges().trim_prefix("/").trim_suffix("/")
+ var stem := rel.get_file()
+ var candidate_dirs: Array[String] = [
+ assets_root.path_join(rel),
+ assets_root.path_join(rel.replace("chenjo", "cheonjo")),
+ ]
+ var sub := rel.split("/")[0]
+ candidate_dirs.append(assets_root.path_join(sub).path_join(rel))
+ candidate_dirs.append(assets_root.path_join(sub).path_join(rel.replace("chenjo", "cheonjo")))
+ var da := DirAccess.open(assets_root)
+ if da:
+ for d in da.get_directories():
+ candidate_dirs.append(assets_root.path_join(d).path_join("ymir work").path_join(rel.get_slice("/", 1)).path_join(stem))
+ candidate_dirs.append(assets_root.path_join(d).path_join(rel))
+ for d in candidate_dirs:
+ var spec := _check_dir_for_model(assets_root, d, stem)
+ if not spec.is_empty():
+ return spec
+ return {}
+
+ var mesh_stem := code
+ var folders: Array[String] = []
+ # npclist vnum=0 别名(如 red_wild_boar -> wild_boar)
+ if _race_src_name.has(code):
+ folders.append(String(_race_src_name[code]))
+ folders.append(code)
+ var segs := code.split("_")
+ for cut in range(segs.size(), 0, -1):
+ var sub_folder := "_".join(segs.slice(0, cut))
+ if not folders.has(sub_folder):
+ folders.append(sub_folder)
+
+ for folder in folders:
+ var candidate_dirs: Array[String] = [
+ assets_root.path_join("ymir work/monster/%s" % folder),
+ assets_root.path_join("ymir work/monster2/%s" % folder),
+ assets_root.path_join("ymir work/npc/%s" % folder),
+ assets_root.path_join("ymir work/npc2/%s" % folder),
+ assets_root.path_join("ymir work/guild/%s" % folder),
+ assets_root.path_join("Monster/ymir work/monster/%s" % folder),
+ assets_root.path_join("Monster/ymir work/monster2/%s" % folder),
+ assets_root.path_join("monster2/ymir work/monster2/%s" % folder),
+ assets_root.path_join("NPC/ymir work/npc/%s" % folder),
+ assets_root.path_join("NPC/ymir work/npc2/%s" % folder),
+ assets_root.path_join("npc2/ymir work/npc2/%s" % folder),
+ assets_root.path_join("guild/ymir work/guild/%s" % folder),
+ ]
+ var da := DirAccess.open(assets_root)
+ if da:
+ for sub_dir in da.get_directories():
+ for rel in [
+ "ymir work/monster/%s" % folder,
+ "ymir work/monster2/%s" % folder,
+ "ymir work/npc/%s" % folder,
+ "ymir work/npc2/%s" % folder,
+ "ymir work/guild/%s" % folder,
+ ]:
+ candidate_dirs.append(assets_root.path_join(sub_dir).path_join(rel))
+ for d in candidate_dirs:
+ var spec := _check_dir_for_model(assets_root, d, mesh_stem)
+ if not spec.is_empty():
+ return spec
+ return {}
+
+func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) -> Dictionary:
+ if not DirAccess.dir_exists_absolute(d):
+ return {}
+ # 1. 对应代号的网格
+ var gr2 := d.path_join(mesh_stem + ".gr2")
+ if FileAccess.file_exists(gr2):
+ return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
+ # 2. 目录同名主网格
+ gr2 = d.path_join(d.get_file() + ".gr2")
+ if FileAccess.file_exists(gr2):
+ return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
+ # 3. 目录里第一个非动作 .gr2
+ gr2 = _pick_base_gr2_in(d)
+ if gr2 != "":
+ return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
+ # 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm)
+ for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]:
+ var msm_p := d.path_join(msm_name)
+ if FileAccess.file_exists(msm_p):
+ var base_gr2 := _resolve_msm_base_model(assets_root, msm_p)
+ if base_gr2 != "":
+ return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2}
+ return {}
+
+func _find_dir(assets_root: String, folder: String) -> String:
+ var spec := _resolve_code_spec(assets_root, folder)
+ return String(spec.get("dir", ""))
diff --git a/project/ui/mouse_controller.gd b/project/ui/mouse_controller.gd
index 3d6ef2cc..91443885 100644
--- a/project/ui/mouse_controller.gd
+++ b/project/ui/mouse_controller.gd
@@ -16,6 +16,7 @@ var _attached: Dictionary = {}
var _targets: Array[Dictionary] = []
var _touch_index := -1
var _touch_position := Vector2.ZERO
+var world_drop_handler: Callable
func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void:
cursor = cursor_manager
@@ -42,6 +43,18 @@ func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -
_overlay.add_child(_label)
set_process_input(true)
+var _drag_started := false
+var _attach_pos := Vector2(-1, -1)
+var _just_attached := false
+
+func _on_attach() -> void:
+ _drag_started = false
+ _just_attached = true
+ if is_inside_tree():
+ _attach_pos = get_viewport().get_mouse_position()
+ else:
+ _attach_pos = Vector2(-1, -1)
+
func attach_item(window: int, cell: int, vnum: int, count: int,
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {},
touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
@@ -69,6 +82,7 @@ func attach_item(window: int, cell: int, vnum: int, count: int,
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
+ _on_attach()
return true
func attach_money(amount: int, source := "inventory") -> bool:
@@ -89,12 +103,67 @@ func attach_money(amount: int, source := "inventory") -> bool:
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
+ _on_attach()
+ return true
+
+func attach_skill(skill_id: int, slot_idx: int = 0, texture: Texture2D = null,
+ skill_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
+ if skill_id <= 0:
+ return false
+ _attached = {
+ "source": "skill",
+ "skill_id": skill_id,
+ "slot_idx": slot_idx,
+ }
+ _touch_index = touch_index
+ if touch_position.x >= 0.0 and touch_position.y >= 0.0:
+ _touch_position = touch_position
+ if _icon:
+ _icon.texture = texture
+ _icon.visible = texture != null
+ if _label:
+ _label.text = skill_name if skill_name != "" else "技能 %d" % skill_id
+ _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")
+ _on_attach()
+ return true
+
+func attach_emotion(emote_id: int, texture: Texture2D = null,
+ emote_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
+ if emote_id <= 0:
+ return false
+ _attached = {
+ "source": "emotion",
+ "emote_id": emote_id,
+ }
+ _touch_index = touch_index
+ if touch_position.x >= 0.0 and touch_position.y >= 0.0:
+ _touch_position = touch_position
+ if _icon:
+ _icon.texture = texture
+ _icon.visible = texture != null
+ if _label:
+ _label.text = emote_name if emote_name != "" else "动作 %d" % emote_id
+ _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")
+ _on_attach()
return true
func cancel() -> void:
_attached.clear()
_touch_index = -1
_touch_position = Vector2.ZERO
+ _drag_started = false
+ _attach_pos = Vector2(-1, -1)
+ _just_attached = false
if _overlay:
_overlay.visible = false
if cursor and cursor.has_method("reset"):
@@ -128,47 +197,132 @@ func _process(_dt: float) -> void:
if c == null or not is_instance_valid(c):
_targets.remove_at(i)
+func _set_handled() -> void:
+ var vp := get_viewport()
+ if vp:
+ vp.set_input_as_handled()
+
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()
+ _set_handled()
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel()
- get_viewport().set_input_as_handled()
+ _set_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()
+ if event is InputEventMouseMotion:
+ if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
+ _drag_started = true
return
+ if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
+ if event.pressed:
+ # If user already picked up an item and left-clicks on a target:
+ if not _just_attached:
+ if _drop_at(event.position):
+ _set_handled()
+ return
+ # If clicked outside any target, cancel attachment
+ if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
+ _play_ui("loginfail.wav")
+ cancel()
+ _set_handled()
+ return
+ else:
+ # Left mouse release (not pressed)
+ _just_attached = false
+ # If dragged, attempt drop at release position
+ if _drag_started or (_attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 8.0):
+ _drop_at(event.position)
+ _set_handled()
+ return
+ # If not dragged (clicked to pick up), keep item attached to cursor!
+ return
if event is InputEventScreenDrag and event.index == _touch_index:
_touch_position = event.position
- get_viewport().set_input_as_handled()
+ _set_handled()
return
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
_touch_position = event.position
_drop_at(event.position)
- get_viewport().set_input_as_handled()
+ _set_handled()
-func _drop_at(position: Vector2) -> void:
+func _drop_at(position: Vector2) -> bool:
var payload := attached()
+ if payload.is_empty():
+ return false
+
+ var icon_center := position + Vector2(24, 24)
+ var best_idx := -1
+ var best_dist := 999999.0
+
+ # Pass 1: exact hit test (cursor position or icon center)
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):
+ if not c.visible:
continue
- var cb: Callable = _targets[i].get("callback", Callable())
- if cb.is_valid() and bool(cb.call(payload)):
+ var rect := c.get_global_rect()
+ if rect.has_point(position):
+ var d := position.distance_to(rect.get_center())
+ if d < best_dist:
+ best_dist = d
+ best_idx = i
+ elif rect.has_point(icon_center):
+ var d := icon_center.distance_to(rect.get_center())
+ if d < best_dist:
+ best_dist = d
+ best_idx = i
+
+ # Pass 2: generous hit test with 16px tolerance margin
+ if best_idx < 0:
+ 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) or not c.visible:
+ continue
+ var rect := c.get_global_rect().grow(16.0)
+ if rect.has_point(position):
+ var d := position.distance_to(rect.get_center())
+ if d < best_dist:
+ best_dist = d
+ best_idx = i
+ elif rect.has_point(icon_center):
+ var d := icon_center.distance_to(rect.get_center())
+ if d < best_dist:
+ best_dist = d
+ best_idx = i
+
+ if best_idx >= 0:
+ var cb: Callable = _targets[best_idx].get("callback", Callable())
+ if cb.is_valid():
+ var ok := false
+ if cb.get_argument_count() >= 2:
+ ok = bool(cb.call(payload, position))
+ else:
+ ok = bool(cb.call(payload))
+ if ok:
+ _play_ui("drop.wav")
+ cancel()
+ return true
+
+ if world_drop_handler.is_valid():
+ var ok := false
+ if world_drop_handler.get_argument_count() >= 2:
+ ok = bool(world_drop_handler.call(payload, position))
+ else:
+ ok = bool(world_drop_handler.call(payload))
+ if ok:
_play_ui("drop.wav")
cancel()
- return
- # 与参考端释放到无效区域的取消语义一致。
+ return true
+
_play_ui("loginfail.wav")
cancel()
+ return false
func _play_ui(name: String) -> void:
if audio and audio.has_method("play_ui"):
diff --git a/project/ui/msa_motion.gd.uid b/project/ui/msa_motion.gd.uid
new file mode 100644
index 00000000..f48129c6
--- /dev/null
+++ b/project/ui/msa_motion.gd.uid
@@ -0,0 +1 @@
+uid://620gbknp3qd6
diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd
index 2bbe07ff..ddd08f25 100644
--- a/project/ui/player_view.gd
+++ b/project/ui/player_view.gd
@@ -5,8 +5,8 @@
# game_scene.set_player_model(pv)
# pv.set_anim_state("run") # wait/walk/run/attack/dead
#
-# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 的 set 给内部 Metin2Model,
-# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`。
+# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 属性及材质换肤调用给内部
+# Metin2Model,所以 equip_model.gd 可以直接操作外层 PlayerView。
extends Node3D
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
@@ -54,6 +54,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
+var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
@@ -132,12 +133,19 @@ func set_anim_state(s: String) -> void:
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
+ # 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断
+ if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES:
+ if anim.has_method("get_duration") and anim.has_method("get_time"):
+ var d: float = float(anim.call("get_duration"))
+ var t: float = float(anim.call("get_time"))
+ if d > 0.0 and t < d and bool(anim.get("playing")):
+ return
+ else:
+ return
_hit.cancel()
_state = s
- var msa := motion_dir.path_join(s + ".msa")
- if not FileAccess.file_exists(msa):
- msa = motion_dir.path_join(s + ".gr2")
- if FileAccess.file_exists(msa):
+ var msa := _motion_file(s)
+ if msa != "":
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
@@ -155,10 +163,29 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/s,movSpd 100):x = walk,y = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
- if motion_dir == "":
+ var dir := _mode_motion_dir() if motion_dir != "" else ""
+ if dir == "":
return Vector2.ZERO
- return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
- MsaMotion.move_speed(motion_dir.path_join("run.msa")))
+ return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
+ MsaMotion.move_speed(dir.path_join("run.msa")))
+
+# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
+# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
+# wait/walk/run/damage too; the left-hand grip is authored in those animations
+# while the weapon model itself remains attached to PART_WEAPON (right hand).
+func set_motion_mode(mode: int) -> void:
+ var next := mode if MOTION_MODE_DIRS.has(mode) else 1
+ if next == _motion_mode:
+ return
+ _motion_mode = next
+ var resume := _state
+ if resume == "" or resume == "__motion":
+ resume = "wait"
+ _state = "" # force rebind even though the semantic state did not change
+ set_anim_state(resume)
+
+func get_motion_mode() -> int:
+ return _motion_mode
# 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
@@ -246,6 +273,98 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
+# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
+# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
+func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
+ if anim == null:
+ return false
+ var cls: String = CLASS_OF[_race & 3]
+ var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls
+ var sdir := _resolve_dir(_assets_root, pc_dir)
+ if sdir == "":
+ return false
+ var suffix := ""
+ if grade >= 1:
+ suffix = "_%d" % (grade + 1)
+ var msa := sdir.path_join(motion_name + suffix + ".msa")
+ if not FileAccess.file_exists(msa):
+ msa = sdir.path_join(motion_name + ".msa")
+ if not FileAccess.file_exists(msa):
+ return false
+ _bind("__skill", msa, false, 1.0)
+ return true
+
+const AFFECT_EFFECT_MAP := {
+ 15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG
+ 16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN
+ 17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG
+ 19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM
+ 20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR
+ 21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP
+ 22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN
+ 23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO
+ 24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK
+ 25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN
+ 26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG
+ 29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON
+ 30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK
+ 32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP
+ 3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON
+ 4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW
+ 5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN
+}
+
+var _fx: RefCounted
+var _affect_nodes: Dictionary = {}
+
+func set_fx(fx_registry: RefCounted) -> void:
+ _fx = fx_registry
+
+func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
+ var reg: RefCounted = fx_registry if fx_registry != null else _fx
+ if not visible:
+ if _affect_nodes.has(affect_type):
+ var n: Node = _affect_nodes[affect_type]
+ _affect_nodes.erase(affect_type)
+ if is_instance_valid(n):
+ n.queue_free()
+ return
+
+ if _affect_nodes.has(affect_type) or reg == null:
+ return
+ if not AFFECT_EFFECT_MAP.has(affect_type):
+ return
+ var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type]
+ var fx_name: String = String(spec.get("file", ""))
+ if affect_type == 15 and _motion_mode == 3:
+ fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse"
+ if fx_name == "":
+ return
+ var effect: Node = reg.call("spawn", fx_name, self, false)
+ if effect == null:
+ return
+ var bone: String = String(spec.get("bone", ""))
+ if bone != "":
+ var anchor := preload("res://fx/motion_effect_anchor.gd").new()
+ effect.add_child(anchor)
+ var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO}
+ if not anchor.configure(self, ev):
+ effect.queue_free()
+ return
+ _affect_nodes[affect_type] = effect
+
+func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void:
+ for t in AFFECT_EFFECT_MAP.keys():
+ var active: bool = (flags & (1 << t)) != 0
+ set_affect(t, active, fx_registry)
+
+func clear_affects() -> void:
+ for t in _affect_nodes.keys():
+ var n: Node = _affect_nodes[t]
+ if is_instance_valid(n):
+ n.queue_free()
+ _affect_nodes.clear()
+
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
@@ -261,6 +380,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
+# 40250 CActorInstance::__Shake(100)
+func shake() -> void:
+ _hit.shake()
+
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/_.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
@@ -276,9 +399,18 @@ func _on_playback_finished() -> void:
_state = ""
set_anim_state(_hit.resume)
return
- if _state == "__motion":
+ if _state in ["__motion", "__skill", "attack", "combo"]:
_state = ""
set_anim_state("wait")
+ motion_bound.emit("wait")
+ _ground_model()
+
+func reset_skill_motion() -> void:
+ if _state in ["__skill", "__motion"]:
+ _state = ""
+ set_anim_state("wait")
+ motion_bound.emit("wait")
+ _ground_model()
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
@@ -287,7 +419,7 @@ func is_in_hit_reaction() -> bool:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
- var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
+ var msa := _pick_mode_msa(REACTION_FILES.get(step, []))
if msa == "":
return false
_bind(step, msa, false, 1.0)
@@ -303,6 +435,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "")
anim.set("anim_path", path)
+ anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
@@ -315,6 +448,26 @@ func _pick_msa(dir: String, names: Array) -> String:
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
+func _mode_motion_dir() -> String:
+ if motion_dir == "":
+ return ""
+ var candidate := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(_motion_mode, "general")))
+ return candidate if DirAccess.dir_exists_absolute(candidate) else motion_dir
+
+func _motion_file(state: String) -> String:
+ for dir in [_mode_motion_dir(), motion_dir]:
+ if dir == "":
+ continue
+ for ext in [".msa", ".gr2"]:
+ var path: String = String(dir).path_join(state + String(ext))
+ if FileAccess.file_exists(path):
+ return path
+ return ""
+
+func _pick_mode_msa(names: Array) -> String:
+ var picked := _pick_msa(_mode_motion_dir(), names)
+ return picked if picked != "" else _pick_msa(motion_dir, names)
+
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
@@ -347,6 +500,17 @@ func _set(prop: StringName, val: Variant) -> bool:
return true
return false
+# EquipModel operates on the outer PlayerView, while the material palette lives
+# on the native Metin2Model child. Keep these as explicit methods so MSM
+# SourceSkin -> TargetSkin remaps reach both character select and the game.
+func set_skin_texture(source: String, target: String) -> void:
+ if model and model.has_method("set_skin_texture"):
+ model.call("set_skin_texture", source, target)
+
+func clear_skin_textures() -> void:
+ if model and model.has_method("clear_skin_textures"):
+ model.call("clear_skin_textures")
+
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
diff --git a/project/ui/quest_curtain.gd b/project/ui/quest_curtain.gd
new file mode 100644
index 00000000..a0bbe67c
--- /dev/null
+++ b/project/ui/quest_curtain.gd
@@ -0,0 +1,94 @@
+# QuestCurtain (40250 uiquest.py:58-105 对齐)
+# 剧情 / 过场运镜模式的 16:9 电影上下黑边(Curtain / Letterbox)
+class_name QuestCurtain
+extends Control
+
+const CURTAIN_SPEED := 200.0
+const MIN_BAR_HEIGHT := 50.0
+
+var top_bar: ColorRect
+var bottom_bar: ColorRect
+var _bar_height := 60.0
+var _is_open := false
+var _tween: Tween
+
+func _init() -> void:
+ name = "QuestCurtain"
+ set_anchors_preset(PRESET_FULL_RECT)
+ mouse_filter = MOUSE_FILTER_IGNORE
+ z_index = 95
+
+ top_bar = ColorRect.new()
+ top_bar.name = "TopBar"
+ top_bar.color = Color.BLACK
+ top_bar.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(top_bar)
+
+ bottom_bar = ColorRect.new()
+ bottom_bar.name = "BottomBar"
+ bottom_bar.color = Color.BLACK
+ bottom_bar.mouse_filter = MOUSE_FILTER_IGNORE
+ add_child(bottom_bar)
+
+ _reset_layout()
+
+func _ready() -> void:
+ get_viewport().size_changed.connect(_on_size_changed)
+ _reset_layout()
+
+func _get_screen_size() -> Vector2:
+ if is_inside_tree() and get_viewport():
+ return get_viewport_rect().size
+ return Vector2(1024, 768)
+
+func _on_size_changed() -> void:
+ if _is_open:
+ _calculate_bar_height()
+ var sz := _get_screen_size()
+ top_bar.position = Vector2(0, 0)
+ top_bar.size = Vector2(sz.x, _bar_height)
+ bottom_bar.position = Vector2(0, sz.y - _bar_height)
+ bottom_bar.size = Vector2(sz.x, _bar_height)
+ else:
+ _reset_layout()
+
+func _calculate_bar_height() -> float:
+ var sz := _get_screen_size()
+ # 40250: (GetScreenHeight() - GetScreenWidth() * 9 // 16) // 2
+ var h: float = floorf((sz.y - sz.x * 9.0 / 16.0) / 2.0)
+ if h < MIN_BAR_HEIGHT:
+ h = MIN_BAR_HEIGHT
+ _bar_height = h
+ return _bar_height
+
+func _reset_layout() -> void:
+ var sz := _get_screen_size()
+ _calculate_bar_height()
+ top_bar.position = Vector2(0, -_bar_height - 1.0)
+ top_bar.size = Vector2(sz.x, _bar_height)
+ bottom_bar.position = Vector2(0, sz.y + 1.0)
+ bottom_bar.size = Vector2(sz.x, _bar_height)
+
+func open(duration: float = 0.4) -> void:
+ _is_open = true
+ var sz := _get_screen_size()
+ _calculate_bar_height()
+ if _tween and _tween.is_valid():
+ _tween.kill()
+ _tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
+ top_bar.size = Vector2(sz.x, _bar_height)
+ bottom_bar.size = Vector2(sz.x, _bar_height)
+ _tween.tween_property(top_bar, "position:y", 0.0, duration)
+ _tween.tween_property(bottom_bar, "position:y", sz.y - _bar_height, duration)
+
+func close(duration: float = 0.4) -> void:
+ _is_open = false
+ var sz := _get_screen_size()
+ if _tween and _tween.is_valid():
+ _tween.kill()
+ _tween = create_tween().set_parallel(true).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
+ _tween.tween_property(top_bar, "position:y", -_bar_height - 1.0, duration)
+ _tween.tween_property(bottom_bar, "position:y", sz.y + 1.0, duration)
+
+func is_open() -> bool:
+ return _is_open
diff --git a/project/ui/quest_curtain.gd.uid b/project/ui/quest_curtain.gd.uid
new file mode 100644
index 00000000..bf253fa5
--- /dev/null
+++ b/project/ui/quest_curtain.gd.uid
@@ -0,0 +1 @@
+uid://ds2h7uklxv4vs
diff --git a/project/ui/quest_dialog.gd b/project/ui/quest_dialog.gd
index 3b7ec4e7..c51a5c8b 100644
--- a/project/ui/quest_dialog.gd
+++ b/project/ui/quest_dialog.gd
@@ -37,10 +37,13 @@ signal camera_event_requested_full(kind: String, setting: Dictionary, blendtime:
signal fade_event_requested_full(kind: String, speed: float)
signal done_event()
+const MAX_CHOICES_PER_PAGE := 8
+
var client: Node
var proto: Node
var _assets_root := ""
var _root: Control
+var _panel: Panel
var _text: RichTextLabel
var _btnrow: VBoxContainer
var _image_layer: Control
@@ -50,6 +53,10 @@ var _active_set
var _active_commands: Array = []
var _active_cursor := 0
var _active_script := false
+var _current_skin := 0
+var _current_left_w := 0.0
+var _current_choice_page := 0
+var _cached_choices: Array = []
signal dungeon_result_requested(result: Dictionary)
@@ -246,23 +253,32 @@ func _present_event_set(es: EventSet) -> void:
and not parsed.confirm_wait and not _active_script:
close()
return
+ if _panel:
+ # 40250 uiquest.py:328 - SKIN_CINEMA(5) and SKIN_NOWINDOW(1) hide background board
+ _panel.visible = (_current_skin != 1 and _current_skin != 5)
_text.text = parsed.body
_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if parsed.text_centered else HORIZONTAL_ALIGNMENT_LEFT
+ var width := _root.size.x
+ var height := _root.size.y
if parsed.window_size.size() >= 2:
- var width := maxi(320, int(parsed.window_size[0]))
- var height := maxi(220, int(parsed.window_size[1]))
+ width = maxi(320, int(parsed.window_size[0]))
+ 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)
+ var left_w := _render_images(parsed.images, parsed.title_image)
+ _current_left_w = left_w
+ var content_w := maxf(200.0, width - 32.0 - left_w)
+ _text.position = Vector2(16.0 + left_w, 14.0)
+ _text.size = Vector2(content_w, height - 100.0)
+ _btnrow.position = Vector2(16.0 + left_w, height - 78.0)
+ _btnrow.custom_minimum_size = Vector2(content_w, 0)
_confirm_wait = parsed.confirm_wait
- _render_images(parsed.images, parsed.title_image)
_fill_buttons(parsed.choices, parsed.has_next, parsed.has_input, parsed.confirm_wait,
String(parsed.get("next_button_type", "")))
# --- 收 GC_SCRIPT ------------------------------------------------------
-func _on_script(_skin: int, text: String) -> void:
+func _on_script(skin: int, text: String) -> void:
+ _current_skin = skin
begin_script(text)
func _emit_side_effects(parsed: Dictionary) -> void:
@@ -312,16 +328,20 @@ func _unhandled_input(event: InputEvent) -> void:
return
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
- # The legacy client sends QUEST_CANCEL when the active script is dismissed
- # with Escape; ordinary answer buttons continue to use SCRIPT_ANSWER.
if _active_script:
skip()
- get_viewport().set_input_as_handled()
+ var vp := get_viewport()
+ if vp:
+ vp.set_input_as_handled()
return
if client and client.has_method("quest_cancel"):
client.quest_cancel()
+ elif client and client.has_method("script_answer"):
+ client.script_answer(254)
close()
- get_viewport().set_input_as_handled()
+ var vp := get_viewport()
+ if vp:
+ vp.set_input_as_handled()
# --- 脚本解析(CPythonEventManager 1:1)----------------------------------
#
@@ -783,6 +803,30 @@ func _evt_color(es: EventSet, tok: String, pos: int, divisor: float) -> void:
# MakeQuestion(PythonEventManager.cpp:884)。
func _make_question(es: EventSet, tok: String) -> void:
+ # 40250 官方标准格式:[QUESTION 1;选项一|2;选项二|...]
+ # 参考 40250 服务端 questlua.cpp:642 GotoSelectState 与 客户端 EterLib/parser.cpp:180
+ var space_idx := tok.find(" ")
+ var body := tok.substr(space_idx + 1).strip_edges() if space_idx >= 0 else ""
+ if body.contains("|") or (body.contains(";") and not body.contains("arg(") and not body.contains("value(")):
+ var parts := body.split("|", false)
+ var options: Array[String] = []
+ for p in parts:
+ var trimmed := p.strip_edges()
+ if trimmed.is_empty():
+ continue
+ var semi := trimmed.find(";")
+ if semi >= 0:
+ var label := trimmed.substr(semi + 1).strip_edges()
+ options.append(label)
+ else:
+ options.append(trimmed)
+ if not options.is_empty():
+ for opt in options:
+ es.choices.append(opt)
+ es.nAnswer = options.size()
+ return
+
+ # 回落至 legacy 格式:[QUESTION arg("A") arg("B")]
var args := _tag_strings(tok)
if args.is_empty():
return
@@ -985,6 +1029,7 @@ func _build(parent: Node) -> void:
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
+ _panel = panel
_image_layer = Control.new()
_image_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -1001,11 +1046,13 @@ 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:
+func _render_images(images: Array, title_image: String) -> float:
if _image_layer == null:
- return
+ return 0.0
for child in _image_layer.get_children():
+ _image_layer.remove_child(child)
child.queue_free()
+ var left_w := 0.0
for spec in images:
var path := String(spec.get("path", ""))
var tex: Texture2D = UiAssets.load_tex(_assets_root, path)
@@ -1023,8 +1070,11 @@ func _render_images(images: Array, title_image: String) -> void:
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)
+ var img_w := minf(160.0, tex.get_size().x)
+ var img_h := minf(_root.size.y - 28.0, tex.get_size().y)
+ image.position = Vector2(10, 14)
+ image.size = Vector2(img_w, img_h)
+ left_w = maxf(left_w, img_w + 14.0)
else:
image.position = Vector2(float(spec.get("x", 0)), float(spec.get("y", 0)))
image.size = tex.get_size()
@@ -1040,9 +1090,11 @@ func _render_images(images: Array, title_image: String) -> void:
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)
+ return left_w
func _clear_buttons() -> void:
for c in _btnrow.get_children():
+ _btnrow.remove_child(c)
c.queue_free()
func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_wait := false,
@@ -1050,18 +1102,27 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
_clear_buttons()
if has_input:
var input := LineEdit.new()
- input.placeholder_text = "请输入"
- input.custom_minimum_size = Vector2(428, 28)
+ input.placeholder_text = "请输入内容"
+ input.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 28)
_btnrow.add_child(input)
- var submit := _mkbtn("提交")
+ var submit := _mkbtn("确定")
submit.pressed.connect(func(): _submit_input(input))
input.text_submitted.connect(func(_text): _submit_input(input))
+ if input.is_inside_tree():
+ input.grab_focus()
+ else:
+ input.ready.connect(func():
+ if input.is_inside_tree():
+ input.grab_focus()
+ , CONNECT_ONE_SHOT)
return
if confirm_wait:
var cancel := _mkbtn("取消")
cancel.pressed.connect(func():
if client and client.has_method("quest_cancel"):
client.quest_cancel()
+ elif client and client.has_method("script_answer"):
+ client.script_answer(254)
close())
return
if choices.is_empty():
@@ -1069,24 +1130,72 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w
var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭"))
b.pressed.connect(func():
if is_done:
- # MakeNextButton(BUTTON_TYPE_DONE) wires DoneEvent to the
- # actual click. Keep parse_script() side-effect free and only
- # notify consumers when the player confirms the DONE button.
done_event.emit()
if client and client.has_method("script_answer"):
- client.script_answer(255)
+ client.script_answer(254)
close())
return
- for i in choices.size():
+ _render_choices(choices, 0)
+
+func _render_choices(choices: Array, page: int = 0) -> void:
+ _cached_choices = choices
+ _current_choice_page = page
+ _clear_buttons()
+ var total := choices.size()
+ if total == 0:
+ return
+ if total <= MAX_CHOICES_PER_PAGE:
+ for i in total:
+ var idx := i
+ var b := _mkbtn(String(choices[i]))
+ b.pressed.connect(func():
+ if client and client.has_method("script_answer"):
+ client.script_answer(idx)
+ close())
+ return
+
+ # Multi-page pagination (40250 uiquest.py parity)
+ var start_idx := page * MAX_CHOICES_PER_PAGE
+ var end_idx := mini(start_idx + MAX_CHOICES_PER_PAGE, total)
+ var total_pages := int(ceil(float(total) / float(MAX_CHOICES_PER_PAGE)))
+
+ for i in range(start_idx, end_idx):
var idx := i
var b := _mkbtn(String(choices[i]))
b.pressed.connect(func():
- client.script_answer(idx)
+ if client and client.has_method("script_answer"):
+ client.script_answer(idx)
close())
+ var nav_row := HBoxContainer.new()
+ nav_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ nav_row.add_theme_constant_override("separation", 12)
+ _btnrow.add_child(nav_row)
+
+ if page > 0:
+ var prev_btn := Button.new()
+ prev_btn.text = "上一页"
+ prev_btn.custom_minimum_size = Vector2(80, 26)
+ prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1))
+ nav_row.add_child(prev_btn)
+
+ var page_label := Label.new()
+ page_label.text = "%d / %d" % [page + 1, total_pages]
+ page_label.add_theme_font_size_override("font_size", 12)
+ nav_row.add_child(page_label)
+
+ if page < total_pages - 1:
+ var next_btn := Button.new()
+ next_btn.text = "下一页"
+ next_btn.custom_minimum_size = Vector2(80, 26)
+ next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1))
+ nav_row.add_child(next_btn)
+
func _submit_input(input: LineEdit) -> void:
- if client and client.has_method("quest_input") and client.quest_input(input.text):
- close()
+ var val := input.text.strip_edges()
+ if client and client.has_method("quest_input"):
+ client.quest_input(val)
+ close()
func _fill_confirm() -> void:
_clear_buttons()
@@ -1099,13 +1208,14 @@ func _fill_confirm() -> void:
b.text = spec[0]
b.custom_minimum_size = Vector2(120, 30)
b.pressed.connect(func():
- client.quest_confirm(yes, _confirm_pid)
+ if client and client.has_method("quest_confirm"):
+ client.quest_confirm(yes, _confirm_pid)
close())
row.add_child(b)
func _mkbtn(text: String) -> Button:
var b := Button.new()
b.text = text
- b.custom_minimum_size = Vector2(428, 26)
+ b.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 26)
_btnrow.add_child(b)
return b
diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd
index da77fd61..8c6dfc6e 100644
--- a/project/ui/quickbar.gd
+++ b/project/ui/quickbar.gd
@@ -15,6 +15,7 @@
extends Node
const PlayerSkill := preload("res://player_skill.gd")
+const SkillSlotMap := preload("res://skill_slot_map.gd")
const UiAssets := preload("res://ui/ui_assets.gd")
const JOB_DIR := {
@@ -29,6 +30,14 @@ signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
signal skill_rejected(skill_id: int, code: String)
+# 底部任务栏功能按钮信号(40250 uitaskbar.py 对齐)
+signal character_requested
+signal inventory_requested
+signal messenger_requested
+signal safebox_requested
+signal system_requested
+signal chat_requested
+
const CM := 100.0
const DEFAULT_CD := 2.0
const SLOTS_PER_PAGE := 8
@@ -37,10 +46,16 @@ const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显
var client: Node
var table: RefCounted # SkillTable
+var proto: Node:
+ set(v):
+ proto = v
+ _refresh_page()
var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源
var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1)
var _player_getter: Callable
var _root: Control
+var _dock: Control
+var _buttons := {}
var item_mouse: Node
var _assets_root := ""
var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}]
@@ -50,6 +65,43 @@ var _page := 0
var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
var _move_from := -1
var _mobile_mode := false
+var _left_mouse_mode := 0 # 0: 移动与攻击, 1: 自动攻击, 2: 镜头视角
+var _right_mouse_mode := 2 # 0: 移动与攻击, 1: 镜头视角, 2: 技能施放
+var _right_mouse_skill_id := 0
+
+# 40250 TaskBar 控件引用
+var _hp_board: Control
+var _hp_recovery_bar: ColorRect
+var _hp_fill: Control
+var _hp_gauge_img: TextureRect
+var _hp_frames: Array[Texture2D] = []
+var _sp_board: Control
+var _sp_recovery_bar: ColorRect
+var _sp_fill: Control
+var _sp_gauge_img: TextureRect
+var _sp_frames: Array[Texture2D] = []
+var _st_board: Control
+var _st_fill: Control
+var _st_gauge_img: TextureRect
+var _st_frames: Array[Texture2D] = []
+var _gauge_timer := 0.0
+var _gauge_idx := 0
+var _exp_board: Control
+var _exp_points: Array[TextureRect] = []
+var _exp_clips: Array[Control] = []
+var _rampage_gauge: TextureRect
+var _rampage_frames: Array[Texture2D] = []
+var _rampage_idx := 0
+var _rampage_timer := 0.0
+var _quick_page_num_img: TextureRect
+var _cur_hp := 0
+var _max_hp := 0
+var _cur_sp := 0
+var _max_sp := 0
+var _cur_st := 0
+var _max_st := 0
+var _cur_exp := 0
+var _next_exp := 0
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable,
assets_root_override := "") -> void:
@@ -67,7 +119,13 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
if client.has_signal("quickslots_changed"):
client.quickslots_changed.connect(restore_from_server)
if client.has_signal("skills_changed"):
- client.skills_changed.connect(_refresh_page)
+ client.skills_changed.connect(func():
+ restore_from_server()
+ _refresh_page())
+ if client.has_signal("skill_group_changed"):
+ client.skill_group_changed.connect(func(_group: int): restore_from_server())
+ if client.has_signal("entity_main_set"):
+ client.entity_main_set.connect(func(_vid: int): restore_from_server())
# 物品槽引用的是背包 cell:装备/销毁/丢弃后服务器 SyncQuickslot 会推
# quickslots_changed;这里再挂 inventory_changed 兜住时序窗口(置灰失效引用)。
if client.has_signal("inventory_changed"):
@@ -80,6 +138,10 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
client.affect_added.connect(_on_affect_added)
if client.has_signal("affect_removed"):
client.affect_removed.connect(_on_affect_removed)
+ if client.has_signal("points_changed"):
+ client.points_changed.connect(_on_points_changed)
+ if client.has_method("get_points"):
+ _on_points_changed(client.get_points())
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
@@ -97,8 +159,15 @@ 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:
- var skill_id := int(qs.get("ref", 0))
- restored[pos] = {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
+ var skill_slot := int(qs.get("ref", 0))
+ var skill_id := _skill_id_from_slot(skill_slot)
+ # Unknown custom slots remain usable as a compatibility fallback; all
+ # stock 40250 slots are mapped above and therefore show the correct
+ # skill tree icon (e.g. warrior group 2 slot 2 -> skill 17, not 2).
+ if skill_id == 0:
+ skill_id = skill_slot
+ restored[pos] = {"kind": "skill", "id": skill_id, "ref": skill_slot,
+ "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
_state = restored
_refresh_page()
@@ -111,12 +180,54 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
var global := _global_slot(slot)
if persist and client and client.has_method("quickslot_add"):
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):
+ var server_ref := _skill_slot_for_id(id) if kind == "skill" else id
+ if server_ref == 0:
+ server_ref = id
+ if type == 0 or not client.quickslot_add(global, type, server_ref):
return false
- _state[global] = {"kind": kind, "id": id, "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
+ _state[global] = {"kind": kind, "id": id,
+ "ref": _skill_slot_for_id(id) if kind == "skill" else id,
+ "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
_refresh_slot(slot)
return true
+func _main_race() -> int:
+ if client and client.has_method("get_main_vid") and client.has_method("get_entity"):
+ var vid := int(client.get_main_vid())
+ if vid != 0:
+ return int(client.get_entity(vid).get("race", 0))
+ return 0
+
+func _skill_group() -> int:
+ var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0
+ if grp > 0:
+ return grp
+ grp = _infer_skill_group()
+ if grp > 0:
+ return grp
+ return 1
+
+func _infer_skill_group() -> int:
+ if client == null or not client.has_method("get_skills"):
+ return 0
+ var race := _main_race()
+ for sk in client.get_skills():
+ var sid := int(sk.get("id", 0))
+ var lvl := int(sk.get("level", 0))
+ if lvl <= 0 and int(sk.get("master", 0)) == 0:
+ continue
+ for g in [1, 2]:
+ var slot := SkillSlotMap.skill_slot_for_id(race, g, sid)
+ if slot >= 1 and slot <= 8:
+ return g
+ return 0
+
+func _skill_id_from_slot(slot: int) -> int:
+ return SkillSlotMap.skill_id_for_slot(_main_race(), _skill_group(), slot)
+
+func _skill_slot_for_id(skill_id: int) -> int:
+ return SkillSlotMap.skill_slot_for_id(_main_race(), _skill_group(), skill_id)
+
# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar.
# Put a selected skill/item/emote into the first free slot on the current page
# while keeping the same server-backed CG_QUICKSLOT_ADD path.
@@ -166,8 +277,8 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
var yaw := 0.0
var xy := Vector2.ZERO
if p:
- yaw = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0)
- xy = Vector2(p.position.x * CM, -p.position.z * CM)
+ yaw = MapCoord.yaw_to_heading(p.rotation.y)
+ xy = MapCoord.to_server_cm(p.position)
if screen_direction.length() > 0.01:
yaw = _aim_heading(p, screen_direction, yaw)
var target_vid := 0
@@ -222,7 +333,9 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
var intent_sent := true
if client.has_method("use_skill"):
intent_sent = client.use_skill(int(s.id), target_vid)
- if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)):
+ if intent_sent:
+ if client.has_method("cast_skill"):
+ client.cast_skill(mi, yaw, int(xy.x), int(xy.y))
skill_cast_started.emit(int(s.id), target_vid)
s.cd_end = _now() + _skill_cooldown(int(s.id))
_state[global] = s
@@ -244,6 +357,62 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
elif s.kind == "emote" and client.has_method("send_emoticon"):
client.send_emoticon(s.id)
+## 从技能窗口右键直接激活技能
+func activate_skill_direct(skill_id: int) -> void:
+ if skill_id <= 0 or _player_dead():
+ return
+ var s := {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
+ if _now() < s.cd_end:
+ return
+ var mi: int = table.motion_idx_of(skill_id) if table else 0
+ var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
+ var yaw := 0.0
+ var xy := Vector2.ZERO
+ if p:
+ yaw = MapCoord.yaw_to_heading(p.rotation.y)
+ xy = MapCoord.to_server_cm(p.position)
+ var target_vid := 0
+ if client.has_method("get_target"):
+ target_vid = int(client.get_target().get("vid", 0))
+ 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(skill_id)
+ if not bool(gate.get("ok", false)):
+ var code := String(gate.get("code", ""))
+ if code == "TOGGLE_OFF":
+ if client.has_method("use_skill"):
+ client.use_skill(skill_id, 0)
+ skill_activated.emit(skill_id)
+ elif not PlayerSkill.is_silent_code(code):
+ skill_rejected.emit(skill_id, code)
+ return
+ var resolved := int(gate.get("target_vid", 0))
+ if resolved != 0:
+ target_vid = resolved
+ var intent_sent := true
+ if client.has_method("use_skill"):
+ intent_sent = client.use_skill(skill_id, target_vid)
+ if intent_sent:
+ if client.has_method("cast_skill"):
+ client.cast_skill(mi, yaw, int(xy.x), int(xy.y))
+ skill_cast_started.emit(skill_id, target_vid)
+ var cd_end := _now() + _skill_cooldown(skill_id)
+ _skill_cooldowns[skill_id] = cd_end
+ for peer in _state:
+ if peer.kind == "skill" and peer.id == skill_id:
+ peer.cd_end = cd_end
+ if target_vid != 0 and net_play and table \
+ and net_play.has_method("send_fly_targeting") \
+ and (table.is_fan_range(skill_id) or table.is_circle_range(skill_id)):
+ var lvl := _skill_level(skill_id)
+ net_play.send_fly_targeting(target_vid, float(table.target_range(skill_id)),
+ table.target_count(skill_id, lvl), table.fly_shape(skill_id))
+ skill_activated.emit(skill_id)
+
func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float:
# Keep the historical POC heading convention as the base and add the
# camera-relative drag angle. This makes an upward drag equal to the
@@ -310,6 +479,9 @@ func _skill_level(sid: int) -> int:
return int(sk.get("level", 0))
return 0
+func get_page() -> int:
+ return _page
+
func set_page(page: int) -> void:
if page < 0 or page >= PAGE_COUNT or page == _page:
return
@@ -317,14 +489,69 @@ func set_page(page: int) -> void:
_move_from = -1
_refresh_page()
+func get_left_mouse_mode() -> int:
+ return _left_mouse_mode
+
+func set_left_mouse_mode(mode: int) -> void:
+ _left_mouse_mode = posmod(mode, 3)
+ _update_mouse_mode_buttons()
+
+func get_right_mouse_mode() -> int:
+ return _right_mouse_mode
+
+func set_right_mouse_mode(mode: int) -> void:
+ _right_mouse_mode = posmod(mode, 3)
+ _update_mouse_mode_buttons()
+
+func set_right_mouse_skill(skill_id: int) -> void:
+ _right_mouse_skill_id = skill_id
+ _right_mouse_mode = 2
+ _update_mouse_mode_buttons()
+
+func get_right_mouse_skill() -> int:
+ return _right_mouse_skill_id
+
+func _update_mouse_mode_buttons() -> void:
+ var btn_l: BaseButton = _buttons.get("mouse_left", null)
+ if btn_l:
+ match _left_mouse_mode:
+ 0: btn_l.tooltip_text = "攻击 / 移动 (鼠标左键)"
+ 1: btn_l.tooltip_text = "自动攻击 (鼠标左键)"
+ 2: btn_l.tooltip_text = "镜头旋转 (鼠标左键)"
+ var btn_r: BaseButton = _buttons.get("mouse_right", null)
+ if btn_r:
+ match _right_mouse_mode:
+ 0: btn_r.tooltip_text = "攻击 / 移动 (鼠标右键)"
+ 1: btn_r.tooltip_text = "镜头旋转 (鼠标右键)"
+ 2:
+ if _right_mouse_skill_id > 0 and table and table.has_method("skill_name"):
+ btn_r.tooltip_text = "技能快捷施放: %s (鼠标右键)" % table.skill_name(_right_mouse_skill_id)
+ else:
+ btn_r.tooltip_text = "技能快捷施放 (鼠标右键)"
+
func toggle_visible() -> void:
if _root:
_root.visible = not _root.visible
+ if _dock:
+ _dock.visible = not _dock.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_root.visible = not enabled
+ if _dock:
+ _dock.visible = not enabled
+
+func get_dock() -> Control:
+ return _dock
+
+func get_button(btn_name: String) -> Control:
+ return _buttons.get(btn_name, null)
+
+func _exit_tree() -> void:
+ if _dock and is_instance_valid(_dock):
+ _dock.queue_free()
+ _dock = null
func is_mobile_mode() -> bool:
return _mobile_mode
@@ -361,103 +588,743 @@ 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)
- # Give the root a real extent. A zero-sized Control happened to work with
- # some viewport sizes, but makes anchoring and hit testing fragile on Retina
- # windows. The old HUD's empty row then made this look like missing skills.
- _root.size = Vector2(400, 56)
- _root.position = Vector2(-200, -62)
+ _root.name = "TaskBar"
+ _root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
+ _root.offset_top = -37
+ _root.offset_bottom = 0
+ _root.offset_left = 0
+ _root.offset_right = 0
+ _root.custom_minimum_size = Vector2(0, 37)
_root.z_index = 10
+ _root.mouse_filter = Control.MOUSE_FILTER_PASS
parent.add_child(_root)
- var prev := Button.new()
- prev.text = "‹"
- prev.position = Vector2(-24, 7)
- prev.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
- _root.add_child(prev)
- var next := Button.new()
- next.text = "›"
- 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(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()
- row.add_theme_constant_override("separation", 4)
- _root.add_child(row)
- for i in SLOTS_PER_PAGE:
- var slot := Panel.new()
- slot.custom_minimum_size = Vector2(40, 40)
+
+ # 1. Base_Board_01: TaskBar_Base.tga 全屏平铺底纹
+ var base_bg := TextureRect.new()
+ base_bg.name = "Base_Board_01"
+ base_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
+ base_bg.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/taskbar_base.tga")
+ base_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ base_bg.stretch_mode = TextureRect.STRETCH_TILE
+ base_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _root.add_child(base_bg)
+
+ # 2. Gauge_Board: gauge.sub 位于 (0, -10)
+ var gauge_board := TextureRect.new()
+ gauge_board.name = "Gauge_Board"
+ gauge_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/gauge.sub")
+ gauge_board.position = Vector2(0, -10)
+ gauge_board.size = Vector2(158, 47)
+ gauge_board.mouse_filter = Control.MOUSE_FILTER_PASS
+ _root.add_child(gauge_board)
+
+ # Rampage 暴气红球(17帧循环)
+ _rampage_gauge = TextureRect.new()
+ _rampage_gauge.name = "RampageGauge"
+ _rampage_gauge.position = Vector2(8, 4)
+ _rampage_gauge.size = Vector2(40, 40)
+ _rampage_gauge.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ gauge_board.add_child(_rampage_gauge)
+ _rampage_frames.clear()
+ for i in range(17):
+ var frame_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/rampage_01/%02d.sub" % i)
+ if frame_tex:
+ _rampage_frames.append(frame_tex)
+ if not _rampage_frames.is_empty():
+ _rampage_gauge.texture = _rampage_frames[0]
+
+ # HPGauge_Board: (59, 14, 95, 11)
+ _hp_board = Control.new()
+ _hp_board.name = "HPGauge_Board"
+ _hp_board.position = Vector2(59, 14)
+ _hp_board.size = Vector2(95, 11)
+ _hp_board.mouse_filter = Control.MOUSE_FILTER_STOP
+ _hp_board.tooltip_text = "HP : 0 / 0"
+ gauge_board.add_child(_hp_board)
+
+ _hp_recovery_bar = ColorRect.new()
+ _hp_recovery_bar.name = "HPRecoveryGaugeBar"
+ _hp_recovery_bar.color = Color(1.0, 0.0, 0.0, 0.33)
+ _hp_recovery_bar.position = Vector2(0, 0)
+ _hp_recovery_bar.size = Vector2(0, 11)
+ _hp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _hp_recovery_bar.visible = false
+ _hp_board.add_child(_hp_recovery_bar)
+
+ _hp_fill = Control.new()
+ _hp_fill.name = "HPFill"
+ _hp_fill.clip_contents = true
+ _hp_fill.size = Vector2(95, 11)
+ _hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _hp_board.add_child(_hp_fill)
+
+ _hp_gauge_img = TextureRect.new()
+ _hp_gauge_img.name = "HPGauge"
+ _hp_gauge_img.size = Vector2(95, 11)
+ _hp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _hp_fill.add_child(_hp_gauge_img)
+
+ _hp_frames.clear()
+ for i in range(1, 8):
+ var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/hpgauge/%02d.tga" % i)
+ if tex:
+ _hp_frames.append(tex)
+ if not _hp_frames.is_empty():
+ _hp_gauge_img.texture = _hp_frames[0]
+
+ # SPGauge_Board: (59, 24, 95, 11)
+ _sp_board = Control.new()
+ _sp_board.name = "SPGauge_Board"
+ _sp_board.position = Vector2(59, 24)
+ _sp_board.size = Vector2(95, 11)
+ _sp_board.mouse_filter = Control.MOUSE_FILTER_STOP
+ _sp_board.tooltip_text = "SP : 0 / 0"
+ gauge_board.add_child(_sp_board)
+
+ _sp_recovery_bar = ColorRect.new()
+ _sp_recovery_bar.name = "SPRecoveryGaugeBar"
+ _sp_recovery_bar.color = Color(0.0, 0.0, 1.0, 0.33)
+ _sp_recovery_bar.position = Vector2(0, 0)
+ _sp_recovery_bar.size = Vector2(0, 11)
+ _sp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _sp_recovery_bar.visible = false
+ _sp_board.add_child(_sp_recovery_bar)
+
+ _sp_fill = Control.new()
+ _sp_fill.name = "SPFill"
+ _sp_fill.clip_contents = true
+ _sp_fill.size = Vector2(95, 11)
+ _sp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _sp_board.add_child(_sp_fill)
+
+ _sp_gauge_img = TextureRect.new()
+ _sp_gauge_img.name = "SPGauge"
+ _sp_gauge_img.size = Vector2(95, 11)
+ _sp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _sp_fill.add_child(_sp_gauge_img)
+
+ _sp_frames.clear()
+ for i in range(1, 8):
+ var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/spgauge/%02d.tga" % i)
+ if tex:
+ _sp_frames.append(tex)
+ if not _sp_frames.is_empty():
+ _sp_gauge_img.texture = _sp_frames[0]
+
+ # STGauge_Board: (59, 38, 95, 6)
+ _st_board = Control.new()
+ _st_board.name = "STGauge_Board"
+ _st_board.position = Vector2(59, 38)
+ _st_board.size = Vector2(95, 6)
+ _st_board.mouse_filter = Control.MOUSE_FILTER_STOP
+ _st_board.tooltip_text = "ST : 0 / 0"
+ gauge_board.add_child(_st_board)
+
+ _st_fill = Control.new()
+ _st_fill.name = "STFill"
+ _st_fill.clip_contents = true
+ _st_fill.size = Vector2(95, 6)
+ _st_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _st_board.add_child(_st_fill)
+
+ _st_gauge_img = TextureRect.new()
+ _st_gauge_img.name = "STGauge"
+ _st_gauge_img.size = Vector2(95, 6)
+ _st_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _st_fill.add_child(_st_gauge_img)
+
+ _st_frames.clear()
+ for i in range(1, 8):
+ var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/stgauge/%02d.tga" % i)
+ if tex:
+ _st_frames.append(tex)
+ if not _st_frames.is_empty():
+ _st_gauge_img.texture = _st_frames[0]
+
+ # 3. EXP_Gauge_Board: (158, 0, 105, 37)
+ _exp_board = TextureRect.new()
+ _exp_board.name = "EXP_Gauge_Board"
+ _exp_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge.sub")
+ _exp_board.position = Vector2(158, 0)
+ _exp_board.size = Vector2(105, 37)
+ _exp_board.mouse_filter = Control.MOUSE_FILTER_STOP
+ _exp_board.tooltip_text = "EXP : 0.00%"
+ _root.add_child(_exp_board)
+
+ _exp_points.clear()
+ _exp_clips.clear()
+ for i in 4:
+ var clip_box := Control.new()
+ clip_box.name = "EXPGauge_Clip_%02d" % (i + 1)
+ clip_box.position = Vector2(5 + i * 25, 9)
+ clip_box.size = Vector2(19, 19)
+ clip_box.clip_contents = true
+ clip_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ clip_box.visible = false
+ _exp_board.add_child(clip_box)
+ _exp_clips.append(clip_box)
+
+ var pt := TextureRect.new()
+ pt.name = "EXPGauge_%02d" % (i + 1)
+ pt.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge_point.sub")
+ pt.position = Vector2(0, 0)
+ pt.size = Vector2(19, 19)
+ pt.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ clip_box.add_child(pt)
+ _exp_points.append(pt)
+
+ # 4. 中央控制栏 (CenterBar): 水平居中 (SCREEN_WIDTH/2 - 128 到 SCREEN_WIDTH/2 + 237)
+ var center_bar := Control.new()
+ center_bar.name = "CenterBar"
+ center_bar.anchor_left = 0.5
+ center_bar.anchor_right = 0.5
+ center_bar.anchor_top = 0.0
+ center_bar.anchor_bottom = 0.0
+ center_bar.offset_left = -128
+ center_bar.offset_right = 237
+ center_bar.offset_top = 0
+ center_bar.offset_bottom = 37
+ center_bar.size = Vector2(365, 37)
+ center_bar.mouse_filter = Control.MOUSE_FILTER_PASS
+ _root.add_child(center_bar)
+
+ # LeftMouseButton: (0, 3) -> SCREEN_WIDTH/2 - 128
+ var mouse_left := _create_taskbar_btn("LeftMouseButton",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
+ "攻击 / 移动 (鼠标左键)", func(): set_left_mouse_mode(_left_mouse_mode + 1))
+ mouse_left.position = Vector2(0, 3)
+ center_bar.add_child(mouse_left)
+ _buttons["mouse_left"] = mouse_left
+
+ # quickslot_board: (42, 0, 283, 37) -> SCREEN_WIDTH/2 - 86
+ var qb_board := Control.new()
+ qb_board.name = "quickslot_board"
+ qb_board.position = Vector2(42, 0)
+ qb_board.size = Vector2(283, 37)
+ qb_board.mouse_filter = Control.MOUSE_FILTER_PASS
+ center_bar.add_child(qb_board)
+
+ if item_mouse and item_mouse.has_method("register_target"):
+ item_mouse.register_target(qb_board, func(payload: Dictionary, pos: Vector2 = Vector2.ZERO):
+ return _drop_on_quickslot_board(payload, pos), self)
+
+ qb_board.set_drag_forwarding(
+ Callable(),
+ func(_at_pos: Vector2, data: Variant) -> bool:
+ return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
+ func(at_pos: Vector2, data: Variant) -> void:
+ if data is Dictionary:
+ var slot_idx := -1
+ if at_pos.x < 135.0:
+ slot_idx = clampi(int(floor(at_pos.x / 32.0)), 0, 3)
+ elif at_pos.x >= 135.0:
+ slot_idx = clampi(int(floor((at_pos.x - 142.0) / 32.0)), 0, 3) + 4
+ if slot_idx >= 0 and slot_idx < 8:
+ _apply_drag_data_to_slot(data, slot_idx)
+ )
+
+ _slots.clear()
+ var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/slot_base.sub")
+
+ # quick_slot_1: 4 格 (0..3)
+ var qs1 := Control.new()
+ qs1.name = "quick_slot_1"
+ qs1.position = Vector2(0, 3)
+ qs1.size = Vector2(128, 32)
+ qb_board.add_child(qs1)
+
+ qs1.set_drag_forwarding(
+ Callable(),
+ func(_at_pos: Vector2, data: Variant) -> bool:
+ return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
+ func(at_pos: Vector2, data: Variant) -> void:
+ if data is Dictionary:
+ var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3)
+ _apply_drag_data_to_slot(data, slot_idx)
+ )
+
+ for i in 4:
+ var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/%d.sub" % (i + 1))
+ slot_ctrl.position = Vector2(i * 32, 0)
+ qs1.add_child(slot_ctrl)
+
+ # ChatButton / ExpandButton: (128, 1)
+ var chat_btn := _create_chat_btn()
+ chat_btn.position = Vector2(128, 1)
+ qb_board.add_child(chat_btn)
+ _buttons["chat"] = chat_btn
+
+ # quick_slot_2: 4 格 (4..7)
+ var qs2 := Control.new()
+ qs2.name = "quick_slot_2"
+ qs2.position = Vector2(142, 3)
+ qs2.size = Vector2(128, 32)
+ qb_board.add_child(qs2)
+
+ qs2.set_drag_forwarding(
+ Callable(),
+ func(_at_pos: Vector2, data: Variant) -> bool:
+ return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
+ func(at_pos: Vector2, data: Variant) -> void:
+ if data is Dictionary:
+ var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3) + 4
+ _apply_drag_data_to_slot(data, slot_idx)
+ )
+
+ for i in range(4, 8):
+ var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/f%d.sub" % (i - 3))
+ slot_ctrl.position = Vector2((i - 4) * 32, 0)
+ qs2.add_child(slot_ctrl)
+
+ # QuickSlotBoard (翻页板): (272, 0, 11, 37)
+ var qsb := Control.new()
+ qsb.name = "QuickSlotBoard"
+ qsb.position = Vector2(272, 0)
+ qsb.size = Vector2(11, 37)
+ qb_board.add_child(qsb)
+
+ var num_box := TextureRect.new()
+ num_box.name = "QuickSlotNumberBox"
+ num_box.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_button_board.sub")
+ num_box.position = Vector2(1, 15)
+ num_box.size = Vector2(11, 11)
+ num_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ qsb.add_child(num_box)
+
+ var page_up := TextureButton.new()
+ page_up.name = "QuickPageUpButton"
+ page_up.position = Vector2(1, 9)
+ page_up.size = Vector2(11, 6)
+ page_up.tooltip_text = "上一页 (Shift+1)"
+ page_up.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_01.sub")
+ page_up.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_02.sub")
+ page_up.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_03.sub")
+ page_up.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
+ qsb.add_child(page_up)
+
+ _quick_page_num_img = TextureRect.new()
+ _quick_page_num_img.name = "QuickPageNumber"
+ _quick_page_num_img.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/1.sub")
+ _quick_page_num_img.position = Vector2(3, 15)
+ _quick_page_num_img.size = Vector2(7, 9)
+ _quick_page_num_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ qsb.add_child(_quick_page_num_img)
+
+ var page_down := TextureButton.new()
+ page_down.name = "QuickPageDownButton"
+ page_down.position = Vector2(1, 24)
+ page_down.size = Vector2(11, 6)
+ page_down.tooltip_text = "下一页 (Shift+2)"
+ page_down.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_01.sub")
+ page_down.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_02.sub")
+ page_down.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_03.sub")
+ page_down.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
+ qsb.add_child(page_down)
+
+ # RightMouseButton: (333, 3) -> SCREEN_WIDTH/2 + 205
+ var mouse_right := _create_taskbar_btn("RightMouseButton",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
+ "ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
+ "技能快捷施放 (鼠标右键)", func(): set_right_mouse_mode(_right_mouse_mode + 1))
+ mouse_right.position = Vector2(333, 3)
+ center_bar.add_child(mouse_right)
+ _buttons["mouse_right"] = mouse_right
+ _update_mouse_mode_buttons()
+
+ _refresh_page()
+ _build_dock(_root)
+
+func _create_quickslot_widget(i: int, slot_base_tex: Texture2D, corner_sub: String) -> Control:
+ var slot := Panel.new()
+ slot.name = "slot_%d" % i
+ slot.custom_minimum_size = Vector2(32, 32)
+ slot.size = Vector2(32, 32)
+ if slot_base_tex:
+ var sbt := StyleBoxTexture.new()
+ sbt.texture = slot_base_tex
+ slot.add_theme_stylebox_override("panel", sbt)
+ else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.1, 0.11, 0.14, 0.9)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", sb)
- var num := Label.new()
- num.text = str(i + 1)
- num.position = Vector2(3, 1)
- num.add_theme_font_size_override("font_size", 10)
- num.mouse_filter = Control.MOUSE_FILTER_IGNORE
- slot.add_child(num)
- var icon := TextureRect.new()
- icon.name = "icon"
- icon.position = Vector2(4, 4)
- icon.size = Vector2(32, 32)
- icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
- icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
- icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
- slot.add_child(icon)
- var lbl := Label.new()
- lbl.name = "lbl"
- lbl.position = Vector2(3, 15)
- lbl.add_theme_font_size_override("font_size", 9)
- lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
- slot.add_child(lbl)
- var grade := Label.new()
- grade.name = "grade"
- grade.position = Vector2(25, 25)
- grade.size = Vector2(13, 13)
- grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
- grade.add_theme_font_size_override("font_size", 9)
- grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45))
- grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
- grade.add_theme_constant_override("shadow_offset_x", 1)
- grade.add_theme_constant_override("shadow_offset_y", 1)
- grade.mouse_filter = Control.MOUSE_FILTER_IGNORE
- slot.add_child(grade)
- var cd := ColorRect.new()
- cd.name = "cd"
- cd.color = Color(0, 0, 0, 0.55)
- cd.set_anchors_preset(Control.PRESET_FULL_RECT)
- cd.visible = false
- cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
- slot.add_child(cd)
- var idx := i
- slot.gui_input.connect(func(e: InputEvent):
- if e is InputEventMouseButton and e.pressed:
- if e.button_index == MOUSE_BUTTON_LEFT:
+
+ var icon := TextureRect.new()
+ icon.name = "icon"
+ icon.position = Vector2(0, 0)
+ icon.size = Vector2(32, 32)
+ icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ slot.add_child(icon)
+
+ # 40250 原版角标 (1..4 / F1..F4) 在左上角 (3, 3) 处显示
+ if corner_sub != "":
+ var corner_tex := UiAssets.load_tex(_assets_root, corner_sub)
+ if corner_tex:
+ var corner := TextureRect.new()
+ corner.name = "Corner"
+ corner.texture = corner_tex
+ corner.position = Vector2(3, 3)
+ corner.size = corner_tex.get_size()
+ corner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ corner.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ slot.add_child(corner)
+
+ var lbl := Label.new()
+ lbl.name = "lbl"
+ lbl.position = Vector2(2, 16)
+ lbl.size = Vector2(28, 14)
+ lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ lbl.add_theme_font_size_override("font_size", 9)
+ lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
+ lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
+ lbl.add_theme_constant_override("shadow_offset_x", 1)
+ lbl.add_theme_constant_override("shadow_offset_y", 1)
+ lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ slot.add_child(lbl)
+
+ var grade := Label.new()
+ grade.name = "grade"
+ grade.position = Vector2(16, 16)
+ grade.size = Vector2(14, 14)
+ grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ grade.add_theme_font_size_override("font_size", 9)
+ grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45))
+ grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
+ grade.add_theme_constant_override("shadow_offset_x", 1)
+ grade.add_theme_constant_override("shadow_offset_y", 1)
+ grade.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ slot.add_child(grade)
+
+ var cd := ColorRect.new()
+ cd.name = "cd"
+ cd.color = Color(0, 0, 0, 0.55)
+ cd.set_anchors_preset(Control.PRESET_FULL_RECT)
+ cd.visible = false
+ cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ slot.add_child(cd)
+
+ var idx := i
+ slot.gui_input.connect(func(e: InputEvent):
+ if e is InputEventMouseButton and e.pressed:
+ if e.button_index == MOUSE_BUTTON_RIGHT:
+ if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
+ if item_mouse.has_method("cancel"):
+ item_mouse.cancel()
+ _move_from = -1
+ elif e.shift_pressed:
+ _clear(idx)
+ else:
+ activate(idx)
+ elif e.button_index == MOUSE_BUTTON_LEFT:
+ if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
_try_drop(idx)
- elif e.button_index == MOUSE_BUTTON_RIGHT:
- _clear(idx))
- row.add_child(slot)
- _slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
- 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()
+ else:
+ var sk_ui := _find_skill_ui()
+ if sk_ui and sk_ui.drag_skill_id != 0:
+ _try_drop(idx)
+ elif _move_from >= 0:
+ _try_drop(idx)
+ else:
+ activate(idx))
+ _slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
+ slot.set_drag_forwarding(
+ Callable(),
+ func(_at_pos: Vector2, data: Variant) -> bool:
+ return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
+ func(_at_pos: Vector2, data: Variant) -> void:
+ if data is Dictionary:
+ _apply_drag_data_to_slot(data, idx)
+ )
+ if item_mouse and item_mouse.has_method("register_target"):
+ var local_idx: int = i
+ item_mouse.register_target(slot,
+ func(payload: Dictionary, _pos: Vector2 = Vector2.ZERO): return _drop_mouse_item(payload, local_idx), self)
+ return slot
+
+func _build_dock(parent: Node) -> void:
+ if _dock and is_instance_valid(_dock):
+ _dock.queue_free()
+ _dock = Control.new()
+ _dock.name = "TaskBarDock"
+ _dock.anchor_left = 1.0
+ _dock.anchor_top = 0.0
+ _dock.anchor_right = 1.0
+ _dock.anchor_bottom = 0.0
+ _dock.offset_left = -178
+ _dock.offset_top = 0
+ _dock.offset_right = 0
+ _dock.offset_bottom = 37
+ _dock.size = Vector2(178, 37)
+ _dock.mouse_filter = Control.MOUSE_FILTER_PASS
+ parent.add_child(_dock)
+
+ # 1. 仓库 (Safebox / Mall) -> SCREEN_WIDTH - 178
+ var btn_safe := _create_taskbar_btn(
+ "SafeboxButton",
+ "ETC/ymir work/ui/game/taskbar/mall_button_01.tga",
+ "ETC/ymir work/ui/game/taskbar/mall_button_02.tga",
+ "ETC/ymir work/ui/game/taskbar/mall_button_03.tga",
+ "仓库",
+ func(): safebox_requested.emit()
+ )
+ btn_safe.position = Vector2(0, 3)
+ _buttons["safebox"] = btn_safe
+ _dock.add_child(btn_safe)
+
+ # 2. 角色 (C) -> SCREEN_WIDTH - 144
+ var btn_char := _create_taskbar_btn(
+ "CharacterButton",
+ "ETC/ymir work/ui/game/taskbar/character_button_01.sub",
+ "ETC/ymir work/ui/game/taskbar/character_button_02.sub",
+ "ETC/ymir work/ui/game/taskbar/character_button_03.sub",
+ "角色 (C)",
+ func(): character_requested.emit()
+ )
+ btn_char.position = Vector2(34, 3)
+ _buttons["character"] = btn_char
+ _dock.add_child(btn_char)
+
+ # 3. 道具 (I) -> SCREEN_WIDTH - 110
+ var btn_inv := _create_taskbar_btn(
+ "InventoryButton",
+ "ETC/ymir work/ui/game/taskbar/inventory_button_01.sub",
+ "ETC/ymir work/ui/game/taskbar/inventory_button_02.sub",
+ "ETC/ymir work/ui/game/taskbar/inventory_button_03.sub",
+ "道具 (I)",
+ func(): inventory_requested.emit()
+ )
+ btn_inv.position = Vector2(68, 3)
+ _buttons["inventory"] = btn_inv
+ _dock.add_child(btn_inv)
+
+ # 4. 好友 / 社群 (Alt+M) -> SCREEN_WIDTH - 76
+ var btn_msg := _create_taskbar_btn(
+ "MessengerButton",
+ "ETC/ymir work/ui/game/taskbar/community_button_01.sub",
+ "ETC/ymir work/ui/game/taskbar/community_button_02.sub",
+ "ETC/ymir work/ui/game/taskbar/community_button_03.sub",
+ "社群 (Alt+M)",
+ func(): messenger_requested.emit()
+ )
+ btn_msg.position = Vector2(102, 3)
+ _buttons["messenger"] = btn_msg
+ _dock.add_child(btn_msg)
+
+ # 5. 系统设置 (ESC) -> SCREEN_WIDTH - 42
+ var btn_sys := _create_taskbar_btn(
+ "SystemButton",
+ "ETC/ymir work/ui/game/taskbar/system_button_01.sub",
+ "ETC/ymir work/ui/game/taskbar/system_button_02.sub",
+ "ETC/ymir work/ui/game/taskbar/system_button_03.sub",
+ "系统 (ESC)",
+ func(): system_requested.emit()
+ )
+ btn_sys.position = Vector2(136, 3)
+ _buttons["system"] = btn_sys
+ _dock.add_child(btn_sys)
+
+func _on_points_changed(pd: Dictionary) -> void:
+ if pd.is_empty():
+ return
+ var pts: Array = pd.get("points", [])
+ var get_pt := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0
+ var hp := int(pd.get("hp", get_pt.call(5)))
+ var max_hp := int(pd.get("max_hp", get_pt.call(6)))
+ var sp := int(pd.get("sp", get_pt.call(7)))
+ var max_sp := int(pd.get("max_sp", get_pt.call(8)))
+ var st: int = int(get_pt.call(9))
+ var max_st: int = int(get_pt.call(10))
+ if max_st <= 0:
+ max_st = 100
+ st = 100
+ var exp_val := int(pd.get("exp", get_pt.call(3)))
+ var next_exp_val := int(pd.get("next_exp", get_pt.call(4)))
+ set_hp(hp, max_hp)
+ set_sp(sp, max_sp)
+ set_st(st, max_st)
+ set_exp(exp_val, next_exp_val)
+
+func set_hp(cur: int, max_val: int) -> void:
+ _cur_hp = cur
+ _max_hp = max_val
+ if _hp_fill:
+ var pct := clampf(float(_cur_hp) / maxf(1.0, float(_max_hp)), 0.0, 1.0)
+ _hp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
+ if _hp_board:
+ _hp_board.tooltip_text = "HP : %d / %d" % [_cur_hp, _max_hp]
+
+func set_sp(cur: int, max_val: int) -> void:
+ _cur_sp = cur
+ _max_sp = max_val
+ if _sp_fill:
+ var pct := clampf(float(_cur_sp) / maxf(1.0, float(_max_sp)), 0.0, 1.0)
+ _sp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
+ if _sp_board:
+ _sp_board.tooltip_text = "SP : %d / %d" % [_cur_sp, _max_sp]
+
+func set_st(cur: int, max_val: int) -> void:
+ _cur_st = cur
+ _max_st = max_val
+ if _st_fill:
+ var pct := clampf(float(_cur_st) / maxf(1.0, float(_max_st)), 0.0, 1.0)
+ _st_fill.size = Vector2(int(roundf(95.0 * pct)), 6)
+ if _st_board:
+ _st_board.tooltip_text = "ST : %d / %d" % [_cur_st, _max_st]
+
+func set_exp(cur: int, next_val: int) -> void:
+ _cur_exp = cur
+ _next_exp = next_val
+ var quarter_pt := _next_exp / 4
+ var full_count := 0
+ if quarter_pt > 0:
+ full_count = mini(4, _cur_exp / quarter_pt)
+
+ for i in 4:
+ if i < _exp_clips.size() and _exp_clips[i]:
+ var clip: Control = _exp_clips[i]
+ var pt: TextureRect = _exp_points[i] if i < _exp_points.size() else null
+ var base_x := 5 + i * 25
+ var base_y := 9
+ if _next_exp <= 0 or _cur_exp <= 0:
+ clip.visible = false
+ elif i < full_count:
+ clip.position = Vector2(base_x, base_y)
+ clip.size = Vector2(19, 19)
+ if pt:
+ pt.position = Vector2(0, 0)
+ clip.visible = true
+ elif i == full_count and full_count < 4 and quarter_pt > 0:
+ var frac := float(_cur_exp % quarter_pt) / float(quarter_pt)
+ var fill_h := int(roundf(19.0 * frac))
+ if fill_h > 0:
+ clip.position = Vector2(base_x, base_y + 19 - fill_h)
+ clip.size = Vector2(19, fill_h)
+ if pt:
+ pt.position = Vector2(0, fill_h - 19)
+ clip.visible = true
+ else:
+ clip.visible = false
+ else:
+ clip.visible = false
+
+ if _exp_board:
+ var pct := float(_cur_exp) / maxf(1.0, float(_next_exp)) * 100.0
+ _exp_board.tooltip_text = "EXP : %.2f%%" % pct
+
+func _create_taskbar_btn(btn_name: String, rel_nrm: String, rel_ovr: String, rel_dwn: String, tooltip: String, on_press: Callable) -> Control:
+ var btn := TextureButton.new()
+ btn.name = btn_name
+ btn.custom_minimum_size = Vector2(32, 32)
+ btn.tooltip_text = tooltip
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ if _assets_root != "":
+ btn.texture_normal = UiAssets.load_tex(_assets_root, rel_nrm)
+ btn.texture_hover = UiAssets.load_tex(_assets_root, rel_ovr)
+ btn.texture_pressed = UiAssets.load_tex(_assets_root, rel_dwn)
+ if btn.texture_normal == null:
+ var fallback := Button.new()
+ fallback.name = btn_name
+ fallback.text = tooltip.substr(0, 2)
+ fallback.custom_minimum_size = Vector2(32, 32)
+ fallback.tooltip_text = tooltip
+ fallback.pressed.connect(on_press)
+ return fallback
+ btn.pressed.connect(on_press)
+ return btn
+
+func _create_chat_btn() -> Control:
+ var btn := TextureButton.new()
+ btn.name = "ChatButton"
+ btn.custom_minimum_size = Vector2(14, 35)
+ btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+ btn.tooltip_text = "聊天 (L)"
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ if _assets_root != "":
+ btn.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_01.sub")
+ btn.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_02.sub")
+ btn.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_03.sub")
+ if btn.texture_normal == null:
+ var fallback := Button.new()
+ fallback.name = "ChatButton"
+ fallback.text = "C"
+ fallback.custom_minimum_size = Vector2(14, 35)
+ fallback.size_flags_vertical = Control.SIZE_SHRINK_CENTER
+ fallback.tooltip_text = "聊天 (L)"
+ fallback.pressed.connect(func(): chat_requested.emit())
+ return fallback
+ btn.pressed.connect(func(): chat_requested.emit())
+ return btn
+
+func _drop_mouse_payload(payload: Dictionary, slot: int) -> bool:
+ if payload.is_empty():
+ return false
+ var src := String(payload.get("source", ""))
+ if src == "inventory":
+ 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)))
+ elif src == "skill":
+ var sid := int(payload.get("skill_id", 0))
+ if sid > 0:
+ return assign(slot, "skill", sid)
+ elif src == "emotion":
+ var eid := int(payload.get("emote_id", 0))
+ if eid > 0:
+ return assign(slot, "emote", eid)
+ return false
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
- if payload.is_empty() or String(payload.get("source", "")) != "inventory":
+ return _drop_mouse_payload(payload, slot)
+
+func _apply_drag_data_to_slot(data: Dictionary, slot_idx: int) -> bool:
+ if data.is_empty() or slot_idx < 0 or slot_idx >= SLOTS_PER_PAGE:
return false
- if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0:
+ var k := String(data.get("kind", data.get("type", "")))
+ if k == "skill":
+ var sid := int(data.get("id", data.get("skill_id", 0)))
+ if sid > 0:
+ return assign(slot_idx, "skill", sid)
+ elif k in ["emotion", "emote"]:
+ var eid := int(data.get("id", data.get("emote_id", 0)))
+ if eid > 0:
+ return assign(slot_idx, "emote", eid)
+ elif k == "item":
+ var cell := int(data.get("cell", -1))
+ if cell >= 0:
+ return assign(slot_idx, "item", cell)
+ return false
+
+func _drop_on_quickslot_board(payload: Dictionary, global_pos: Vector2) -> bool:
+ if _root == null or not is_instance_valid(_root):
return false
- # QuickSlot inventory entries store the source inventory cell, not the vnum.
- return assign(slot, "item", int(payload.get("cell", -1)))
+ var qb_board: Control = _root.find_child("quickslot_board", true, false)
+ if qb_board == null:
+ return false
+ var local_pos := qb_board.get_global_transform().affine_inverse() * global_pos
+ var slot_idx := -1
+ if local_pos.x < 135.0:
+ slot_idx = clampi(int(floor(local_pos.x / 32.0)), 0, 3)
+ elif local_pos.x >= 135.0:
+ slot_idx = clampi(int(floor((local_pos.x - 142.0) / 32.0)), 0, 3) + 4
+ if slot_idx >= 0 and slot_idx < 8:
+ return _drop_mouse_payload(payload, slot_idx)
+ return false
func _try_drop(slot: int) -> void:
+ if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
+ var payload: Dictionary = item_mouse.attached() if item_mouse.has_method("attached") else {}
+ if not payload.is_empty() and _drop_mouse_payload(payload, slot):
+ if item_mouse.has_method("cancel"):
+ item_mouse.cancel()
+ _move_from = -1
+ return
# skill_ui 把待拖技能放在 drag_skill_id;这里落点接
var sk_ui := _find_skill_ui()
if sk_ui and sk_ui.drag_skill_id != 0:
@@ -477,7 +1344,7 @@ func _find_skill_ui() -> Node:
if tree == null or tree.root == null:
return null
for n in tree.root.find_children("*", "Node", true, false):
- if n.get_script() and n.has_method("set_job") and "drag_skill_id" in n:
+ if "drag_skill_id" in n and int(n.drag_skill_id) != 0:
return n
return null
@@ -488,30 +1355,81 @@ func _refresh_slot(slot: int) -> void:
var txt := ""
var tex: Texture2D = null
var grade_text := ""
+ var vnum := 0
+ var count := 0
+ var item_name := ""
if s.kind == "skill" and table:
txt = table.name_of(s.id).substr(0, 5)
tex = _skill_icon(int(s.id))
grade_text = _skill_grade_text(int(s.id))
elif s.kind == "item":
- txt = "#%d" % s.id
+ if client and client.has_method("get_item"):
+ var it: Dictionary = client.get_item(1, s.id)
+ vnum = int(it.get("vnum", 0))
+ count = int(it.get("count", 1))
+ if vnum > 0:
+ tex = _item_icon(vnum)
+ txt = str(count) if count > 1 else ""
+ if proto and proto.has_method("item"):
+ var idata: Dictionary = proto.item(vnum)
+ item_name = String(idata.get("locale_name", idata.get("name", "")))
+ if item_name == "":
+ item_name = "item %d" % vnum
+ else:
+ txt = "#%d" % s.id
+ elif s.kind == "emote":
+ txt = "E%d" % s.id
+
# 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。
var dim := false
- if s.kind == "item" and client and client.has_method("get_item"):
- var it: Dictionary = client.get_item(1, s.id)
- if it.is_empty() or int(it.get("vnum", 0)) == 0:
+ if s.kind == "item":
+ if vnum == 0:
dim = true
txt = "×%d" % s.id
# 变身中技能槽整体遮罩(服务器只放行箭矢类物品);死亡中全部置灰。
if (s.kind == "skill" and _polymorphed()) or _player_dead():
dim = true
+
var icon: TextureRect = _slots[slot].icon
icon.texture = tex
icon.visible = tex != null
_slots[slot].btn.modulate = Color(0.42, 0.42, 0.42) if dim else Color(1, 1, 1)
- _slots[slot].lbl.text = txt if tex == null else ""
+
+ var lbl: Label = _slots[slot].lbl
+ if tex != null and s.kind == "item":
+ lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ lbl.position = Vector2(2, 16)
+ lbl.size = Vector2(28, 14)
+ lbl.text = txt
+ elif tex == null:
+ lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ lbl.position = Vector2(2, 12)
+ lbl.size = Vector2(28, 14)
+ lbl.text = txt
+ else:
+ lbl.text = ""
+
_slots[slot].grade.text = grade_text
_slots[slot].grade.visible = grade_text != "" and tex != null
+ # Tooltip
+ if s.kind == "skill" and table:
+ _slots[slot].btn.tooltip_text = table.name_of(s.id)
+ elif s.kind == "item" and vnum > 0:
+ _slots[slot].btn.tooltip_text = "%s\n#%d%s" % [item_name, vnum, (" x%d" % count) if count > 1 else ""]
+ else:
+ _slots[slot].btn.tooltip_text = ""
+
+func _item_icon(vnum: int) -> Texture2D:
+ if _assets_root == "" or vnum <= 0:
+ return null
+ var rel := "icon/item/%05d.tga" % vnum
+ var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
+ if tex == null:
+ rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
+ tex = UiAssets.load_tex(_assets_root, rel)
+ return tex
+
func _skill_grade(skill_id: int) -> int:
if client and client.has_method("get_skills"):
for skill in client.get_skills():
@@ -567,6 +1485,9 @@ func _skill_icon_suffixes(skill_id: int) -> Array[String]:
return out
func _refresh_page() -> void:
+ if _quick_page_num_img:
+ var page_sub := "ETC/ymir work/ui/game/taskbar/%d.sub" % (_page + 1)
+ _quick_page_num_img.texture = UiAssets.load_tex(_assets_root, page_sub)
if _root and _root.has_node("page"):
_root.get_node("page").text = "F%d" % (_page + 1)
for slot in _slots.size():
@@ -580,7 +1501,7 @@ func _on_cd_end(skill_id: int) -> void:
s.cd_end = 0.0
_state[i] = s
-func _process(_dt: float) -> void:
+func _process(dt: float) -> void:
var t := _now()
for slot in _slots.size():
var s: Dictionary = _state[_global_slot(slot)]
@@ -591,6 +1512,26 @@ func _process(_dt: float) -> void:
var frac: float = clampf((s.cd_end - t) / duration, 0.0, 1.0)
_slots[slot].cd.anchor_top = 1.0 - frac
+ # 暴气红球旋转动画 (delay 6 帧即约 0.08s 步进)
+ _rampage_timer += dt
+ if _rampage_timer >= 0.08:
+ _rampage_timer = 0.0
+ if not _rampage_frames.is_empty() and _rampage_gauge:
+ _rampage_idx = (_rampage_idx + 1) % _rampage_frames.size()
+ _rampage_gauge.texture = _rampage_frames[_rampage_idx]
+
+ # HP/SP/ST 水流动波纹动画 (40250 delay 6 帧 = 0.08s 步进,循环 7 帧)
+ _gauge_timer += dt
+ if _gauge_timer >= 0.08:
+ _gauge_timer = 0.0
+ _gauge_idx = (_gauge_idx + 1) % 7
+ if not _hp_frames.is_empty() and _hp_gauge_img:
+ _hp_gauge_img.texture = _hp_frames[_gauge_idx % _hp_frames.size()]
+ if not _sp_frames.is_empty() and _sp_gauge_img:
+ _sp_gauge_img.texture = _sp_frames[_gauge_idx % _sp_frames.size()]
+ if not _st_frames.is_empty() and _st_gauge_img:
+ _st_gauge_img.texture = _st_frames[_gauge_idx % _st_frames.size()]
+
func _skill_cooldown(skill_id: int) -> float:
if table and table.has_method("cooldown_of"):
var level := 0
diff --git a/project/ui/refine_ui.gd b/project/ui/refine_ui.gd
index 45f7a348..e81948a6 100644
--- a/project/ui/refine_ui.gd
+++ b/project/ui/refine_ui.gd
@@ -1,31 +1,63 @@
-# RefineUI (M4) —— 精炼 / 强化对话框:`refine_ask` 来时弹。
+# RefineUI —— 1:1 复刻官方 40250 uirefine.py:RefineDialogNew 与 QuestionDialog2。
#
-# var ru := preload("res://ui/refine_ui.gd").new()
-# add_child(ru)
-# ru.setup(m2client, canvas_parent, proto) # proto 可空(出名字)
-#
-# `GC_REFINE_INFORMATION` → `M2Client.refine_ask({type,pos,src_vnum,result_vnum,cost,prob,materials})`。
-# [精炼] → `M2Client.refine(pos, type)`;[取消] 关闭。
+# 协议驱动:
+# GC_REFINE_INFORMATION (119) -> refine_ask({type, pos, src_vnum, result_vnum, cost, prob, materials})
+# 发送发包:
+# 确定 -> CG_REFINE (96): client.refine(pos, type)
+# 取消/ESC/超距 -> CG_REFINE (96): client.refine(255, 255)
+# 40250 规则:
+# 1. prob == 100 或 type == 5 时直接 Accept,免弹二次确认窗;
+# 2. type == 3 (铁匠石) 弹彻底摧毁+概率加成警告;
+# 3. type == 2 (祝福卷轴) 弹降级警告;
+# 4. 其他 (普通铁匠) 弹彻底摧毁警告;
+# 5. 材料根据背包实际存量显示白字(>= 需求)或红字(< 需求);
+# 6. 离场超 10.0m 自动发送取消发包并关闭。
extends Node
+const QuestionDialog2Scene = null
+const USE_REFINE_LIMIT_RANGE_M := 10.0
+
var client: Node
var proto: Node
+var audio: Node
var _root: Control
+var _board: Panel
+var _title_bar: Control
+var _title_label: Label
+var _close_btn: Button
+
+var _preview_slot: Panel
+var _preview_icon: TextureRect
var _text: RichTextLabel
-var _cur := {}
-var _mobile_mode := false
+var _material_container: VBoxContainer
+var _prob_label: Label
+var _cost_label: Label
var _action_row: HBoxContainer
var _refine_button: Button
var _cancel_button: Button
-func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
+# QuestionDialog2 确认弹窗
+var _question_dialog: Control
+var _qd_message1: Label
+var _qd_message2: Label
+var _qd_accept_btn: Button
+var _qd_cancel_btn: Button
+
+var _cur := {}
+var _mobile_mode := false
+var _open_char_pos := Vector3.ZERO
+var _has_open_pos := false
+
+func setup(m2client: Node, parent: Node, proto_node: Node = null, audio_node: Node = null) -> void:
client = m2client
proto = proto_node
+ audio = audio_node
_build(parent)
- if client.has_signal("refine_ask"):
- client.refine_ask.connect(_on_ask)
- if client.has_signal("refine_result"):
- client.refine_result.connect(_on_result)
+ if client != null:
+ if client.has_signal("refine_ask"):
+ client.refine_ask.connect(_on_ask)
+ if client.has_signal("refine_result"):
+ client.refine_result.connect(_on_result)
func is_open() -> bool:
return _root != null and _root.visible
@@ -37,11 +69,6 @@ func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
-func close() -> void:
- if _root:
- _root.visible = false
- _cur = {}
-
func _name_of(vnum: int) -> String:
if vnum == 0:
return "-"
@@ -52,90 +79,390 @@ func _name_of(vnum: int) -> String:
return n
return "#%d" % vnum
+func _get_inventory_count(vnum: int) -> int:
+ if client == null or not client.has_method("get_inventory"):
+ return 0
+ var total := 0
+ for item in client.get_inventory():
+ if int(item.get("vnum", 0)) == vnum:
+ total += maxi(1, int(item.get("count", 1)))
+ return total
+
+func _get_main_char_pos() -> Variant:
+ if client == null:
+ return null
+ var vid := 0
+ if client.has_method("get_main_vid"):
+ vid = int(client.get_main_vid())
+ if vid != 0 and client.has_method("get_entity"):
+ var e: Dictionary = client.get_entity(vid)
+ if not e.is_empty() and e.has("pos"):
+ return e["pos"]
+ return null
+
+func _process(_delta: float) -> void:
+ if not is_open() or not _has_open_pos:
+ return
+ var cur = _get_main_char_pos()
+ if cur is Vector3:
+ if _open_char_pos.distance_to(cur) > USE_REFINE_LIMIT_RANGE_M:
+ cancel_refine()
+
+func _unhandled_input(event: InputEvent) -> void:
+ if not is_open():
+ return
+ if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
+ cancel_refine()
+
func _on_ask(info: Dictionary) -> void:
_cur = info
+ var pos_val = _get_main_char_pos()
+ if pos_val is Vector3:
+ _open_char_pos = pos_val
+ _has_open_pos = true
+ else:
+ _has_open_pos = false
+
+ var src_vnum := int(info.get("src_vnum", 0))
+ var result_vnum := int(info.get("result_vnum", 0))
+ var prob := int(info.get("prob", 0))
+ var cost := int(info.get("cost", 0))
+ var materials: Array = info.get("materials", [])
+
+ # 1. 组装向下兼容的 _text(供 headless 自动化测试断言及快速阅读)
var lines := PackedStringArray()
- lines.append("[b]%s[/b] → [b]%s[/b]" % [
- _name_of(int(info.get("src_vnum", 0))), _name_of(int(info.get("result_vnum", 0)))])
- lines.append("成功率 %d%% 费用 %d 金" % [int(info.get("prob", 0)), int(info.get("cost", 0))])
- var mats: Array = info.get("materials", [])
- if not mats.is_empty():
+ lines.append("[b]%s[/b] → [b]%s[/b]" % [_name_of(src_vnum), _name_of(result_vnum)])
+ lines.append("成功率 %d%% 费用 %d 金" % [prob, cost])
+ if not materials.is_empty():
lines.append("材料:")
- for m in mats:
+ for m in materials:
lines.append(" %s ×%d" % [_name_of(int(m.get("vnum", 0))), int(m.get("count", 1))])
_text.text = "\n".join(lines)
+
+ # 2. 40250 视觉:底部成功率与费用
+ _prob_label.text = "成功率: %d%%" % prob
+ _cost_label.text = "费用: %d 金" % cost
+ var cur_gold := 0
+ if client and client.has_method("get_gold"):
+ cur_gold = int(client.get_gold())
+ elif client and client.has_method("get_points"):
+ cur_gold = int(client.get_points().get("gold", 0))
+ if cur_gold > 0 and cur_gold < cost:
+ _cost_label.modulate = Color(1.0, 0.33, 0.33)
+ else:
+ _cost_label.modulate = Color(1.0, 1.0, 1.0)
+
+ # 3. 40250 视觉:材质栏 AppendMaterial 动态排布
+ for child in _material_container.get_children():
+ _material_container.remove_child(child)
+ child.queue_free()
+
+ for m in materials:
+ var mvnum := int(m.get("vnum", 0))
+ var mcount := int(m.get("count", 1))
+ var owned := _get_inventory_count(mvnum)
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 8)
+ row.custom_minimum_size = Vector2(250, 32)
+
+ # Slot
+ var slot := Panel.new()
+ slot.custom_minimum_size = Vector2(32, 32)
+ var slot_sb := StyleBoxFlat.new()
+ slot_sb.bg_color = Color(0.12, 0.1, 0.08, 0.9)
+ slot_sb.border_color = Color(0.35, 0.3, 0.22, 1.0)
+ slot_sb.set_border_width_all(1)
+ slot.add_theme_stylebox_override("panel", slot_sb)
+ var mat_icon := TextureRect.new()
+ mat_icon.set_anchors_preset(Control.PRESET_FULL_RECT)
+ mat_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ mat_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ slot.add_child(mat_icon)
+ row.add_child(slot)
+
+ # ThinBoard with label
+ var tb := Panel.new()
+ tb.custom_minimum_size = Vector2(190, 28)
+ tb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ var tb_sb := StyleBoxFlat.new()
+ tb_sb.bg_color = Color(0.15, 0.12, 0.1, 0.8)
+ tb_sb.set_corner_radius_all(2)
+ tb.add_theme_stylebox_override("panel", tb_sb)
+
+ var lbl := Label.new()
+ lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
+ lbl.text = " %s x %02d" % [_name_of(mvnum), mcount]
+ lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ # 40250 存量足显示白字,不足显示红字 0xffff5555
+ if owned >= mcount:
+ lbl.modulate = Color(0.87, 0.87, 0.87)
+ else:
+ lbl.modulate = Color(1.0, 0.33, 0.33)
+ tb.add_child(lbl)
+ row.add_child(tb)
+
+ _material_container.add_child(row)
+
+ # 4. 自适应尺寸
+ var base_h := 220
+ var mats_h := materials.size() * 36
+ var new_h := base_h + mats_h
+ _root.size = Vector2(340, new_h)
+
+ if _question_dialog:
+ _question_dialog.visible = false
_root.visible = true
-func _do_refine() -> void:
+func _on_accept_clicked() -> void:
+ if _cur.is_empty():
+ return
+ var prob := int(_cur.get("prob", 0))
+ var rtype := int(_cur.get("type", 0))
+
+ # 40250 uirefine.py:385:成功率 100% 或 type == 5 直接执行,不弹确认框
+ if prob == 100 or rtype == 5:
+ _accept()
+ return
+
+ # 唤起 QuestionDialog2
+ _open_question_dialog(rtype)
+
+func _open_question_dialog(rtype: int) -> void:
+ if _question_dialog == null:
+ return
+ # 40250 对齐文案:
+ _qd_message2.text = "确定要继续吗?"
+ if rtype == 3:
+ _qd_message1.text = "强化失败时装备将会被彻底销毁,同时成功率提升!"
+ elif rtype == 2:
+ _qd_message1.text = "强化失败时装备等级将会降低1级!"
+ else:
+ _qd_message1.text = "强化失败时装备将会被彻底销毁!"
+
+ _question_dialog.visible = true
+ _question_dialog.move_to_front()
+
+func _accept() -> void:
if client and client.has_method("refine") and not _cur.is_empty():
client.refine(int(_cur.get("pos", 0)), int(_cur.get("type", 0)))
- _root.visible = false
+ close()
+
+func cancel_refine() -> void:
+ # 40250 uirefine.py:418: net.SendRefinePacket(255, 255)
+ if client and client.has_method("refine") and not _cur.is_empty():
+ client.refine(255, 255)
+ close()
+
+func close() -> void:
+ if _question_dialog:
+ _question_dialog.visible = false
+ if _root:
+ _root.visible = false
+ _cur = {}
+ _has_open_pos = false
func _on_result(ok: bool) -> void:
_cur = {}
_text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]"
+ if _prob_label:
+ _prob_label.text = "强化成功!" if ok else "强化失败!"
+ _prob_label.modulate = Color(0.2, 1.0, 0.4) if ok else Color(1.0, 0.3, 0.3)
+ if _cost_label:
+ _cost_label.text = ""
+ for child in _material_container.get_children():
+ _material_container.remove_child(child)
+ child.queue_free()
+
+ if audio and audio.has_method("play_ui"):
+ if ok:
+ audio.play_ui("refine_success.wav")
+ else:
+ audio.play_ui("refine_fail.wav")
+
_root.visible = true
func _apply_mobile_layout() -> void:
- if _root == null or _text == null or _action_row == null:
+ if _root == null:
return
if _mobile_mode:
- _root.size = Vector2(460, 300)
- _root.position = Vector2(-230, -150)
- _text.position = Vector2(18, 52)
- _text.size = Vector2(424, 178)
- _text.custom_minimum_size = Vector2(424, 178)
- _action_row.position = Vector2(18, 242)
- _refine_button.custom_minimum_size = Vector2(204, 44)
- _cancel_button.custom_minimum_size = Vector2(204, 44)
+ _root.size = Vector2(460, 320)
+ _root.position = Vector2(-230, -160)
else:
- _root.size = Vector2(320, 260)
- _root.position = Vector2(-160, -130)
- _text.position = Vector2(14, 34)
- _text.size = Vector2(292, 160)
- _text.custom_minimum_size = Vector2(292, 160)
- _action_row.position = Vector2(14, 208)
- _refine_button.custom_minimum_size = Vector2(120, 30)
- _cancel_button.custom_minimum_size = Vector2(120, 30)
+ _root.size = Vector2(340, 260)
+ _root.position = Vector2(-170, -130)
func _build(parent: Node) -> void:
_root = Control.new()
- _root.set_anchors_preset(Control.PRESET_CENTER)
- _root.position = Vector2(-160, -130)
- _root.size = Vector2(320, 260)
+ _root.name = "RefineDialogNew"
+ _root.size = Vector2(340, 260)
+ _root.position = Vector2(200, 150)
+ _root.size = Vector2(340, 260)
_root.visible = false
_root.set_meta("is_titlebar", true)
parent.add_child(_root)
- var panel := Panel.new()
- panel.set_anchors_preset(Control.PRESET_FULL_RECT)
+
+ # 主底板 Board
+ _board = Panel.new()
+ _board.name = "Board"
+ _board.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
- sb.bg_color = Color(0.09, 0.07, 0.05, 0.98)
+ sb.bg_color = Color(0.08, 0.06, 0.05, 0.98)
+ sb.border_color = Color(0.45, 0.38, 0.28, 1.0)
+ sb.set_border_width_all(2)
sb.set_corner_radius_all(4)
- panel.add_theme_stylebox_override("panel", sb)
- _root.add_child(panel)
- var title := Label.new()
- title.text = "精炼"
- title.position = Vector2(12, 8)
- _root.add_child(title)
+ _board.add_theme_stylebox_override("panel", sb)
+ _root.add_child(_board)
+
+ # 标题栏 TitleBar (40250 经典红标题栏)
+ _title_bar = Panel.new()
+ _title_bar.name = "TitleBar"
+ _title_bar.position = Vector2(8, 8)
+ _title_bar.size = Vector2(324, 26)
+ var title_sb := StyleBoxFlat.new()
+ title_sb.bg_color = Color(0.45, 0.1, 0.1, 0.95)
+ title_sb.set_corner_radius_all(2)
+ _title_bar.add_theme_stylebox_override("panel", title_sb)
+ _root.add_child(_title_bar)
+
+ _title_label = Label.new()
+ _title_label.name = "TitleName"
+ _title_label.set_anchors_preset(Control.PRESET_FULL_RECT)
+ _title_label.text = "强化"
+ _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _title_bar.add_child(_title_label)
+
+ _close_btn = Button.new()
+ _close_btn.text = "×"
+ _close_btn.position = Vector2(300, 2)
+ _close_btn.size = Vector2(22, 22)
+ _close_btn.pressed.connect(cancel_refine)
+ _title_bar.add_child(_close_btn)
+
+ # 物品展示与说明
_text = RichTextLabel.new()
_text.bbcode_enabled = true
- _text.position = Vector2(14, 34)
- _text.custom_minimum_size = Vector2(292, 160)
- _text.size = Vector2(292, 160)
+ _text.position = Vector2(16, 40)
+ _text.size = Vector2(308, 60)
_root.add_child(_text)
+
+ # 材料列表容器 (AppendMaterial)
+ _material_container = VBoxContainer.new()
+ _material_container.position = Vector2(16, 106)
+ _material_container.size = Vector2(308, 50)
+ _material_container.add_theme_constant_override("separation", 4)
+ _root.add_child(_material_container)
+
+ # 底部区域:成功率 + 费用
+ var bottom_info := VBoxContainer.new()
+ bottom_info.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
+ bottom_info.offset_top = -90
+ bottom_info.offset_bottom = -45
+ bottom_info.offset_left = 16
+ bottom_info.offset_right = -16
+ bottom_info.alignment = BoxContainer.ALIGNMENT_CENTER
+ _root.add_child(bottom_info)
+
+ _prob_label = Label.new()
+ _prob_label.name = "SuccessPercentage"
+ _prob_label.text = "成功率: --%"
+ _prob_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ bottom_info.add_child(_prob_label)
+
+ _cost_label = Label.new()
+ _cost_label.name = "Cost"
+ _cost_label.text = "费用: -- 金"
+ _cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ bottom_info.add_child(_cost_label)
+
+ # 底部按钮行:AcceptButton ("精炼") 与 CancelButton ("取消")
_action_row = HBoxContainer.new()
- var row := _action_row
- row.position = Vector2(14, 208)
- row.add_theme_constant_override("separation", 12)
- _root.add_child(row)
+ _action_row.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
+ _action_row.offset_top = -42
+ _action_row.offset_bottom = -10
+ _action_row.offset_left = 16
+ _action_row.offset_right = -16
+ _action_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ _action_row.add_theme_constant_override("separation", 24)
+ _root.add_child(_action_row)
+
_refine_button = Button.new()
+ _refine_button.name = "AcceptButton"
_refine_button.text = "精炼"
- _refine_button.custom_minimum_size = Vector2(120, 30)
- _refine_button.pressed.connect(_do_refine)
- row.add_child(_refine_button)
+ _refine_button.custom_minimum_size = Vector2(110, 30)
+ _refine_button.pressed.connect(_on_accept_clicked)
+ _action_row.add_child(_refine_button)
+
_cancel_button = Button.new()
+ _cancel_button.name = "CancelButton"
_cancel_button.text = "取消"
- _cancel_button.custom_minimum_size = Vector2(120, 30)
- _cancel_button.pressed.connect(func(): _root.visible = false)
- row.add_child(_cancel_button)
- _apply_mobile_layout()
+ _cancel_button.custom_minimum_size = Vector2(110, 30)
+ _cancel_button.pressed.connect(cancel_refine)
+ _action_row.add_child(_cancel_button)
+
+ # 构建内嵌 QuestionDialog2
+ _build_question_dialog(parent)
+
+func _build_question_dialog(parent: Node) -> void:
+ _question_dialog = Control.new()
+ _question_dialog.name = "QuestionDialog2"
+ _question_dialog.size = Vector2(290, 120)
+ _question_dialog.position = Vector2(225, 200)
+ _question_dialog.visible = false
+ _question_dialog.z_index = 100
+ parent.add_child(_question_dialog)
+
+ var qd_board := Panel.new()
+ qd_board.name = "board"
+ qd_board.set_anchors_preset(Control.PRESET_FULL_RECT)
+ var qd_sb := StyleBoxFlat.new()
+ qd_sb.bg_color = Color(0.12, 0.08, 0.06, 0.98)
+ qd_sb.border_color = Color(0.65, 0.5, 0.3, 1.0)
+ qd_sb.set_border_width_all(2)
+ qd_sb.set_corner_radius_all(4)
+ qd_board.add_theme_stylebox_override("panel", qd_sb)
+ _question_dialog.add_child(qd_board)
+
+ _qd_message1 = Label.new()
+ _qd_message1.name = "message1"
+ _qd_message1.position = Vector2(12, 14)
+ _qd_message1.size = Vector2(266, 32)
+ _qd_message1.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _qd_message1.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _qd_message1.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _question_dialog.add_child(_qd_message1)
+
+ _qd_message2 = Label.new()
+ _qd_message2.name = "message2"
+ _qd_message2.position = Vector2(12, 48)
+ _qd_message2.size = Vector2(266, 24)
+ _qd_message2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _qd_message2.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _qd_message2.text = "确定要继续吗?"
+ _question_dialog.add_child(_qd_message2)
+
+ var qd_btn_row := HBoxContainer.new()
+ qd_btn_row.position = Vector2(12, 78)
+ qd_btn_row.size = Vector2(266, 32)
+ qd_btn_row.alignment = BoxContainer.ALIGNMENT_CENTER
+ qd_btn_row.add_theme_constant_override("separation", 24)
+ _question_dialog.add_child(qd_btn_row)
+
+ _qd_accept_btn = Button.new()
+ _qd_accept_btn.name = "accept"
+ _qd_accept_btn.text = "是"
+ _qd_accept_btn.custom_minimum_size = Vector2(80, 28)
+ _qd_accept_btn.pressed.connect(func():
+ _question_dialog.visible = false
+ _accept()
+ )
+ qd_btn_row.add_child(_qd_accept_btn)
+
+ _qd_cancel_btn = Button.new()
+ _qd_cancel_btn.name = "cancel"
+ _qd_cancel_btn.text = "否"
+ _qd_cancel_btn.custom_minimum_size = Vector2(80, 28)
+ _qd_cancel_btn.pressed.connect(func():
+ _question_dialog.visible = false
+ )
+ qd_btn_row.add_child(_qd_cancel_btn)
diff --git a/project/ui/remote_player_view.gd b/project/ui/remote_player_view.gd
index c9e945f0..92127328 100644
--- a/project/ui/remote_player_view.gd
+++ b/project/ui/remote_player_view.gd
@@ -8,11 +8,28 @@ const EquipModel = preload("res://ui/equip_model.gd")
var remote_equip: Node
var _remote_parts: Array = []
+var _remote_proto: Node
+
+const ITEM_TYPE_WEAPON := 1
+const MODE_GENERAL := 1
+const MODE_ONEHAND_SWORD := 2
+const MODE_TWOHAND_SWORD := 3
+const MODE_DUALHAND_SWORD := 4
+const MODE_BOW := 5
+const MODE_FAN := 6
+const MODE_BELL := 7
+const WEAPON_MODE := {0: MODE_ONEHAND_SWORD, 1: MODE_DUALHAND_SWORD, 2: MODE_BOW,
+ 3: MODE_TWOHAND_SWORD, 4: MODE_BELL, 5: MODE_FAN}
+
+func _ready() -> void:
+ if remote_equip and remote_equip.has_method("refresh_refine_effects"):
+ remote_equip.call_deferred("refresh_refine_effects")
func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCounted,
- proto: Node = null, pump := Callable()) -> bool:
+ proto: Node = null, pump := Callable(), effects: RefCounted = null) -> bool:
if not build(assets_root, race, pump):
return false
+ _remote_proto = proto
_remote_parts = parts.duplicate()
while _remote_parts.size() < 4:
_remote_parts.append(0)
@@ -21,8 +38,10 @@ func build_remote(assets_root: String, race: int, parts: Array, item_list: RefCo
remote_equip = EquipModel.new()
remote_equip.name = "RemoteEquipModel"
add_child(remote_equip)
+ remote_equip.effect_registry = effects
remote_equip.setup_remote(item_list, func() -> Node: return self, assets_root, race,
_remote_parts, proto)
+ _refresh_weapon_motion()
return true
# net_world 按 NetworkActorManager.cpp:473 的顺序调用这四个字段。
@@ -31,6 +50,7 @@ func set_armor(vnum: int) -> void:
func set_weapon(vnum: int) -> void:
_set_remote_part(EquipModel.PART_WEAPON, vnum)
+ _refresh_weapon_motion()
func set_head(vnum: int) -> void:
_set_remote_part(EquipModel.PART_HEAD, vnum)
@@ -45,3 +65,12 @@ func _set_remote_part(index: int, vnum: int) -> void:
if remote_equip:
remote_equip._set_remote_part(index, vnum)
remote_equip.refresh()
+
+func _refresh_weapon_motion() -> void:
+ var vnum := int(_remote_parts[EquipModel.PART_WEAPON]) if _remote_parts.size() > EquipModel.PART_WEAPON else 0
+ var mode := MODE_GENERAL
+ if vnum > 0 and _remote_proto and _remote_proto.has_method("item"):
+ var item: Dictionary = _remote_proto.item(vnum)
+ if int(item.get("type", -1)) == ITEM_TYPE_WEAPON:
+ mode = int(WEAPON_MODE.get(int(item.get("sub_type", -1)), MODE_GENERAL))
+ set_motion_mode(mode)
diff --git a/project/ui/safebox_ui.gd b/project/ui/safebox_ui.gd
index 38b4d9c6..5a0293c3 100644
--- a/project/ui/safebox_ui.gd
+++ b/project/ui/safebox_ui.gd
@@ -1,27 +1,37 @@
-# SafeboxUI (P8) —— 仓库窗。
+# SafeboxUI (P8) —— 仓库窗(1:1 对齐 40250 `root/uisafebox.py` 与 `uiscript/safeboxwindow.py`)。
#
# var bu := preload("res://ui/safebox_ui.gd").new()
# add_child(bu)
-# bu.setup(m2client, canvas_parent, proto) # proto 可空
+# bu.setup(m2client, canvas_parent, proto)
#
-# `safebox_changed` 刷新(size>0 → 打开)。列出仓库道具 + [取出]。
-# inventory_ui 在仓库开着时右键道具 → 调 bu.deposit(win, cell)。
-# 取出/存入用第一个空位(简版:deposit 用 safe_pos = 下一个空格;checkout 用道具 cell)。
+# 采用 40250 经典 176×373 羊皮纸金属 Board 边框与 TitleBar。
+# 5×9 格子(45 格/页),使用 Slot_Base.sub 底图。
+# 底部单选分页按键(I / II / III)、修改密码与关闭大按钮。
+# 右键格子道具直接取出至背包;在背包中右键直接存入仓库;支持鼠标拖放。
extends Node
const ItemTooltip = preload("res://ui/item_tooltip.gd")
+const UiKit = preload("res://ui_kit.gd")
+const UiBuild = preload("res://ui/ui_build.gd")
+const UiAssets = preload("res://ui/ui_assets.gd")
var client: Node
var proto: Node
var _tooltip_builder: RefCounted
+var _assets_root := ""
var _root: Control
+var _board_panel: Control
var _list: VBoxContainer
var _gold: Label
var _title: Label
var _status: Label
var _password_dialog: ConfirmationDialog
-var _grid: GridContainer
+var _grid_container: Control
var _cells: Dictionary = {}
+var _page_buttons: Array[Button] = []
+var _page_button_container: HBoxContainer
+var _change_pw_btn: Button
+var _exit_btn: Button
var _page_label: Label
var _page := 0
var item_mouse: Node
@@ -35,44 +45,113 @@ var _change_pw_dialog: ConfirmationDialog
const SAFE_PAGE_SLOTS := 45
const SAFE_WINDOW := 3
const INVENTORY_WINDOW := 1
+const USE_SAFEBOX_LIMIT_RANGE_M := 10.0
-func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
+var ui: CanvasLayer
+var _open_char_pos := Vector3.ZERO
+var _has_open_pos := false
+
+func setup(m2client: Node, parent: Node, proto_node: Node = null, assets_override := "") -> void:
client = m2client
proto = proto_node
+ ui = parent as CanvasLayer
+ _assets_root = assets_override if assets_override != "" else AssetRoot.path()
_tooltip_builder = ItemTooltip.new()
- _tooltip_builder.setup(AssetRoot.path(), "en", proto)
+ _tooltip_builder.setup(_assets_root, "en", proto)
_build(parent)
- if client.has_signal("safebox_changed"):
+ if client and client.has_signal("safebox_opened"):
+ client.safebox_opened.connect(func(_size): open())
+ if client and client.has_signal("safebox_closed"):
+ client.safebox_closed.connect(_close_local)
+ if client and client.has_signal("safebox_changed"):
client.safebox_changed.connect(refresh)
- if client.has_signal("safebox_password_required"):
+ if client and client.has_signal("inventory_changed"):
+ client.inventory_changed.connect(_refresh_inventory_candidates)
+ if client and client.has_signal("safebox_password_required"):
client.safebox_password_required.connect(func(): _ask_password("safebox"))
- if client.has_signal("safebox_wrong_password"):
+ if client and client.has_signal("safebox_wrong_password"):
client.safebox_wrong_password.connect(func():
if is_instance_valid(_status):
_status.text = "仓库密码错误")
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if w == _root and is_open():
+ close()
+ )
func is_open() -> bool:
return _root != null and _root.visible
+func _get_main_char_pos() -> Variant:
+ if client == null:
+ return null
+ var vid := 0
+ if client.has_method("get_main_vid"):
+ vid = int(client.get_main_vid())
+ if vid != 0 and client.has_method("get_entity"):
+ var e: Dictionary = client.get_entity(vid)
+ if not e.is_empty() and e.has("pos"):
+ return e["pos"]
+ return null
+
+func _process(_delta: float) -> void:
+ if not is_open() or not _has_open_pos:
+ return
+ var cur = _get_main_char_pos()
+ if cur is Vector3:
+ if _open_char_pos.distance_to(cur) > USE_SAFEBOX_LIMIT_RANGE_M:
+ close()
+
+func open() -> void:
+ if _root:
+ var vp_size := Vector2(800.0, 600.0)
+ if _root.is_inside_tree():
+ vp_size = _root.get_viewport_rect().size
+ elif ui and ui.is_inside_tree():
+ vp_size = ui.get_viewport().get_visible_rect().size
+ _root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0)
+ _root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0)
+ _root.visible = true
+ if ui and ui.has_method("open"):
+ ui.open(_root)
+ var p = _get_main_char_pos()
+ if p is Vector3:
+ _open_char_pos = p
+ _has_open_pos = true
+ refresh()
+
+func toggle_window() -> void:
+ if is_open():
+ close()
+ else:
+ open()
+
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
- _refresh_mobile_inventory()
+ _refresh_inventory_candidates()
func get_mobile_window() -> Control:
return _root
func close() -> void:
# 对齐原版 SafeboxWindow.Close():先发 /safebox_close 让服务器同步关仓
- #(CloseSafebox 命令回来后 is_safebox_open() 变 false),本地只是立即收尾。
if client and client.has_method("safebox_close") and is_open():
client.safebox_close()
+ _close_local()
+
+func _close_local() -> void:
+ _has_open_pos = false
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
+ if ui and "_stack" in ui:
+ ui._stack.erase(_root)
+ if ui.has_method("_restack"):
+ ui._restack()
_page = 0
_mobile_deposit_pending.clear()
if is_instance_valid(_password_dialog):
@@ -83,27 +162,33 @@ func deposit(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
if inv_window == INVENTORY_WINDOW and _mobile_deposit_pending.has(inv_cell):
- _status.text = "该物品的存入请求已发送"
+ if is_instance_valid(_status):
+ _status.text = "该物品的存入请求已发送"
return
var safe_pos := _next_free_slot()
if safe_pos < 0:
- _status.text = "仓库已满"
+ if is_instance_valid(_status):
+ _status.text = "仓库已满"
return
if not client.has_method("safebox_checkin"):
- _status.text = "存入请求发送失败"
+ if is_instance_valid(_status):
+ _status.text = "存入请求发送失败"
return
if not client.safebox_checkin(safe_pos, inv_window, inv_cell):
- _status.text = "存入请求发送失败"
+ if is_instance_valid(_status):
+ _status.text = "存入请求发送失败"
return
- if _mobile_mode and inv_window == INVENTORY_WINDOW:
+ if inv_window == INVENTORY_WINDOW:
_mobile_deposit_pending[inv_cell] = true
- _status.text = "已发送存入请求"
+ if is_instance_valid(_status):
+ _status.text = "已发送存入请求"
func _next_free_slot() -> int:
var used := {}
- for it in client.get_safebox_items():
- used[int(it.get("cell", -1))] = true
- var cap: int = maxi(1, client.get_safebox_size()) * 45
+ if client and client.has_method("get_safebox_items"):
+ for it in client.get_safebox_items():
+ used[int(it.get("cell", -1))] = true
+ var cap: int = maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 1) * 45
for i in range(cap):
if not used.has(i):
return i
@@ -127,21 +212,27 @@ func _tooltip_for(item: Dictionary) -> String:
func refresh() -> void:
if client == null:
return
- _root.visible = client.is_safebox_open()
+ _root.visible = client.is_safebox_open() if client.has_method("is_safebox_open") else true
if not _root.visible:
return
- _status.text = ""
+ if is_instance_valid(_status):
+ _status.text = ""
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]
- # 40250 无仓库金钱存取协议(HEADER_CG_SAFEBOX_MONEY 不存在,GC 84 从不发送,
- # 原版 RefreshSafeboxMoney 也是 pass)——只读展示,通常恒 0。
- _gold.text = "仓库金币: %d(本版本不可存取)" % client.get_safebox_gold()
+ if is_instance_valid(_title):
+ _title.text = "仓库" if not _mobile_mode else "仓库(第 %d/%d 页)" % [_page + 1, pages]
+ if is_instance_valid(_page_label):
+ _page_label.text = "%d / %d" % [_page + 1, pages]
+ if is_instance_valid(_gold):
+ _gold.text = "仓库金币: %d" % client.get_safebox_gold()
+ for i in _page_buttons.size():
+ var btn: Button = _page_buttons[i]
+ btn.visible = i < pages
+ btn.set_pressed_no_signal(i == _page)
+ _render_grid()
for c in _list.get_children():
c.queue_free()
- _render_grid()
- var items: Array = client.get_safebox_items()
+ var items: Array = client.get_safebox_items() if client and client.has_method("get_safebox_items") else []
if items.is_empty():
var e := Label.new()
e.text = "(空)"
@@ -149,36 +240,80 @@ func refresh() -> void:
else:
for it in items:
_list.add_child(_row(it))
- _refresh_mobile_inventory()
+ _refresh_inventory_candidates()
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 cell: Control = _cells[pos]
+ _clear_grid_cell(cell)
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)
+ var cell: Control = _cells[pos]
+ _fill_grid_cell(cell, it)
+
+func _clear_grid_cell(cell: Control) -> void:
+ cell.tooltip_text = ""
+ cell.set_meta("vnum", 0)
+ cell.set_meta("count", 0)
+ for c in cell.get_children():
+ c.queue_free()
+
+func _fill_grid_cell(cell: Control, item: Dictionary) -> void:
+ var vnum := int(item.get("vnum", 0))
+ var count := int(item.get("count", 1))
+ cell.set_meta("vnum", vnum)
+ cell.set_meta("count", count)
+ cell.tooltip_text = _tooltip_for(item)
+
+ var tex := _icon(vnum)
+ if tex != null:
+ var tr := TextureRect.new()
+ tr.name = "Icon"
+ tr.texture = tex
+ tr.set_anchors_preset(Control.PRESET_FULL_RECT)
+ tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ cell.add_child(tr)
+ else:
+ var lbl := Label.new()
+ lbl.name = "Icon"
+ lbl.text = _name_of(vnum).substr(0, 4)
+ lbl.add_theme_font_size_override("font_size", 9)
+ lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ cell.add_child(lbl)
+
+ if count > 1:
+ var cnt_lbl := Label.new()
+ cnt_lbl.name = "Count"
+ cnt_lbl.text = str(count)
+ cnt_lbl.position = Vector2(2, 16)
+ cnt_lbl.size = Vector2(28, 14)
+ cnt_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ cnt_lbl.add_theme_font_size_override("font_size", 10)
+ cnt_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 0.8))
+ cnt_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ cell.add_child(cnt_lbl)
+
+func _icon(vnum: int) -> Texture2D:
+ if _assets_root == "":
+ return null
+ var rel := "icon/item/%05d.tga" % vnum
+ var tex := UiAssets.load_tex(_assets_root, rel)
+ if tex == null:
+ rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
+ tex = UiAssets.load_tex(_assets_root, rel)
+ return tex
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]
+ var cell: Control = _cells[local_pos]
if int(cell.get_meta("vnum", 0)) != 0:
return false
var source_window := int(payload.get("window", -1))
@@ -191,41 +326,67 @@ func _drop_to_slot(payload: Dictionary, local_pos: int) -> bool:
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"):
+func _on_cell_input(local_pos: int, ev: InputEvent) -> void:
+ if not (ev is InputEventMouseButton and ev.pressed):
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")
+ if ev.button_index == MOUSE_BUTTON_RIGHT:
+ var safe_pos := _page * SAFE_PAGE_SLOTS + local_pos
+ var target := _first_free_inv()
+ if target < 0:
+ if is_instance_valid(_status): _status.text = "背包已满"
+ return
+ if client and client.has_method("safebox_checkout"):
+ client.safebox_checkout(safe_pos, 1, target)
+ elif ev.button_index == MOUSE_BUTTON_LEFT:
+ if _mobile_mode:
+ _on_mobile_grid_tap(local_pos)
+ return
+ if item_mouse and item_mouse.has_method("attach_item"):
+ var cell: Control = _cells.get(local_pos, null)
+ if cell:
+ var vnum := int(cell.get_meta("vnum", 0))
+ if vnum > 0:
+ item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum,
+ int(cell.get_meta("count", 1)), null, "safebox")
func _on_mobile_grid_tap(local_pos: int) -> void:
- if not _mobile_mode or client == null or not _cells.has(local_pos):
+ if client == null or not _cells.has(local_pos):
return
- var cell: Button = _cells[local_pos]
+ var cell: Control = _cells[local_pos]
if int(cell.get_meta("vnum", 0)) == 0 or not client.has_method("safebox_checkout"):
return
var target := _first_free_inv()
if target < 0:
- _status.text = "背包已满"
+ if is_instance_valid(_status):
+ _status.text = "背包已满"
return
if not client.safebox_checkout(_page * SAFE_PAGE_SLOTS + local_pos, 1, target):
- _status.text = "取出请求发送失败"
+ if is_instance_valid(_status):
+ _status.text = "取出请求发送失败"
+
+func _select_page(index: int) -> void:
+ _page = index
+ for i in _page_buttons.size():
+ var btn: Button = _page_buttons[i]
+ btn.set_pressed_no_signal(i == _page)
+ if _page_label:
+ _page_label.text = "%d / 3" % (_page + 1)
+ if _title:
+ _title.text = "仓库" if not _mobile_mode else "仓库(第 %d/3 页)" % (_page + 1)
+ _render_grid()
+
+func _change_page(delta: int) -> void:
+ var pages := maxi(1, int(client.get_safebox_size()) if client and client.has_method("get_safebox_size") else 3)
+ _select_page(posmod(_page + delta, pages))
func _apply_mobile_layout() -> void:
if _root == null:
return
if _mobile_mode:
- # The source-bag candidates stay inside the same hosted surface as the
- # warehouse grid, so both obey one safe-area scale and touch boundary.
_root.size = Vector2(688, 400)
_root.position = Vector2(-344, -200)
- _grid.position = Vector2(12, 54)
+ if _grid_container:
+ _grid_container.position = Vector2(12, 54)
if _mobile_inventory_title:
_mobile_inventory_title.visible = true
_mobile_inventory_title.position = Vector2(350, 48)
@@ -233,17 +394,36 @@ func _apply_mobile_layout() -> void:
_mobile_inventory_scroll.visible = true
_mobile_inventory_scroll.position = Vector2(350, 76)
_mobile_inventory_scroll.size = Vector2(326, 292)
+ if _gold:
+ _gold.visible = true
+ _gold.position = Vector2(12, 350)
+ if _change_pw_btn:
+ _change_pw_btn.position = Vector2(12, 316)
+ if _exit_btn:
+ _exit_btn.position = Vector2(174, 316)
else:
- _root.size = Vector2(340, 400)
- _root.position = Vector2(-170, -200)
- _grid.position = Vector2(12, 54)
+ # 桌面端也显示背包候选面板(右侧),方便存入物品。
+ _root.size = Vector2(530, 418)
+ if _grid_container:
+ _grid_container.position = Vector2(8, 35)
if _mobile_inventory_title:
- _mobile_inventory_title.visible = false
+ _mobile_inventory_title.visible = true
+ _mobile_inventory_title.position = Vector2(184, 35)
if _mobile_inventory_scroll:
- _mobile_inventory_scroll.visible = false
+ _mobile_inventory_scroll.visible = true
+ _mobile_inventory_scroll.position = Vector2(184, 56)
+ _mobile_inventory_scroll.size = Vector2(336, 296)
+ if _gold:
+ _gold.visible = false
+ if _page_button_container:
+ _page_button_container.position = Vector2(10, 333)
+ if _change_pw_btn:
+ _change_pw_btn.position = Vector2(51, 360)
+ if _exit_btn:
+ _exit_btn.position = Vector2(51, 381)
-func _refresh_mobile_inventory() -> void:
- if not _mobile_mode or _mobile_inventory_list == null or not is_open():
+func _refresh_inventory_candidates() -> void:
+ if _mobile_inventory_list == null or not is_open():
return
for child in _mobile_inventory_list.get_children():
child.queue_free()
@@ -269,11 +449,6 @@ func _refresh_mobile_inventory() -> void:
empty.add_theme_font_size_override("font_size", 12)
_mobile_inventory_list.add_child(empty)
-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()
@@ -309,7 +484,6 @@ func _ask_password(kind: String) -> void:
else:
_password_dialog.popup_centered()
-# 修改仓库密码:/safebox_change_password <旧> <新>(服务器两个参数各限 1..6 位)。
func _ask_change_password() -> void:
if not is_open():
return
@@ -344,8 +518,7 @@ func _ask_change_password() -> void:
if new_pw != edits[2].text:
if is_instance_valid(_status):
_status.text = "两次输入的新密码不一致"
- elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 \
- or old_pw.length() > 6:
+ elif old_pw.is_empty() or new_pw.is_empty() or new_pw.length() > 6 or old_pw.length() > 6:
if is_instance_valid(_status):
_status.text = "密码须为 1~6 位"
elif not client.safebox_change_password(old_pw, new_pw):
@@ -384,17 +557,18 @@ func _row(it: Dictionary) -> Control:
out.pressed.connect(func() -> void:
var target := _first_free_inv()
if target < 0:
- _status.text = "背包已满"
+ if is_instance_valid(_status): _status.text = "背包已满"
return
if not client.safebox_checkout(cell, 1, target):
- _status.text = "取出请求发送失败")
+ if is_instance_valid(_status): _status.text = "取出请求发送失败")
row.add_child(out)
return row
func _first_free_inv() -> int:
var used := {}
- for it in client.get_inventory():
- used[int(it.get("cell", -1))] = true
+ if client and client.has_method("get_inventory"):
+ for it in client.get_inventory():
+ used[int(it.get("cell", -1))] = true
for i in range(90):
if not used.has(i):
return i
@@ -402,93 +576,190 @@ func _first_free_inv() -> int:
func _build(parent: Node) -> void:
_root = Control.new()
- _root.set_anchors_preset(Control.PRESET_CENTER)
- _root.position = Vector2(-170, -200)
- _root.size = Vector2(340, 400)
+ _root.name = "SafeboxWindow"
+ _root.size = Vector2(176, 418)
+ parent.add_child(_root)
+ var vp_size := Vector2(800.0, 600.0)
+ if _root.is_inside_tree():
+ vp_size = _root.get_viewport_rect().size
+ elif ui and ui.is_inside_tree():
+ vp_size = ui.get_viewport().get_visible_rect().size
+ _root.position.x = maxf(0.0, (vp_size.x - _root.size.x) / 2.0)
+ _root.position.y = maxf(0.0, (vp_size.y - _root.size.y) / 2.0)
_root.visible = false
_root.set_meta("is_titlebar", true)
- parent.add_child(_root)
- var panel := Panel.new()
- panel.set_anchors_preset(Control.PRESET_FULL_RECT)
- var sb := StyleBoxFlat.new()
- sb.bg_color = Color(0.06, 0.08, 0.09, 0.97)
- sb.set_corner_radius_all(4)
- panel.add_theme_stylebox_override("panel", sb)
- _root.add_child(panel)
+
+ # 1. 经典羊皮纸金属 Board 边框
+ var board_np := UiKit.board(_assets_root, "board", 32, 128)
+ if board_np and board_np.texture:
+ board_np.set_anchors_preset(Control.PRESET_FULL_RECT)
+ board_np.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _root.add_child(board_np)
+ else:
+ var panel := Panel.new()
+ panel.set_anchors_preset(Control.PRESET_FULL_RECT)
+ var sb := StyleBoxFlat.new()
+ sb.bg_color = Color(0.06, 0.08, 0.09, 0.97)
+ sb.set_corner_radius_all(4)
+ panel.add_theme_stylebox_override("panel", sb)
+ _root.add_child(panel)
+
+ # 2. 官方 TitleBar
+ var titlebar := UiBuild._titlebar(_assets_root, 161, {})
+ titlebar.name = "TitleBar"
+ titlebar.position = Vector2(8, 7)
+ _root.add_child(titlebar)
+
_title = Label.new()
+ _title.name = "TitleName"
_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)
- var change_pw := Button.new()
- change_pw.text = "改密"
- change_pw.position = Vector2(186, 6)
- change_pw.size = Vector2(42, 24)
- change_pw.add_theme_font_size_override("font_size", 11)
- change_pw.pressed.connect(_ask_change_password)
- _root.add_child(change_pw)
- _gold = Label.new()
- _gold.text = "仓库金币: 0"
- _gold.position = Vector2(12, 30)
- _gold.modulate = Color(0.95, 0.85, 0.5)
- _gold.add_theme_font_size_override("font_size", 12)
- _root.add_child(_gold)
- _status = Label.new()
- _status.position = Vector2(12, 382)
- _status.add_theme_font_size_override("font_size", 11)
- _status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
- _root.add_child(_status)
- _list = VBoxContainer.new()
- _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)
+ _title.position = Vector2(0, 0)
+ _title.size = Vector2(161, 23)
+ _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.6))
+ _title.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ titlebar.add_child(_title)
+
+ var close_btn: Button = titlebar.find_child("CloseButton", true, false)
+ if close_btn:
+ close_btn.pressed.connect(close)
+
+ # 3. 5×9 GridSlotWindow (8, 35)
+ _grid_container = Control.new()
+ _grid_container.name = "wndItem"
+ _grid_container.position = Vector2(8, 35)
+ _grid_container.size = Vector2(160, 288)
+ _root.add_child(_grid_container)
+
+ var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/Slot_Base.sub")
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)
+ var col := pos % 5
+ var row := pos / 5
+ var cell := Panel.new()
+ cell.name = "slot_%d" % pos
+ cell.position = Vector2(col * 32, row * 32)
+ cell.custom_minimum_size = Vector2(32, 32)
+ cell.size = Vector2(32, 32)
cell.set_meta("safe_pos", pos)
- cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
- cell.pressed.connect(func(): _on_mobile_grid_tap(pos))
+ cell.mouse_filter = Control.MOUSE_FILTER_STOP
+ if slot_base_tex:
+ var sbt := StyleBoxTexture.new()
+ sbt.texture = slot_base_tex
+ cell.add_theme_stylebox_override("panel", sbt)
+ else:
+ var sbf := StyleBoxFlat.new()
+ sbf.bg_color = Color(0.12, 0.12, 0.15)
+ cell.add_theme_stylebox_override("panel", sbf)
+
+ cell.gui_input.connect(func(e: InputEvent): _on_cell_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)
+ _grid_container.add_child(cell)
_cells[pos] = cell
+
+ # 4. 单选分页按键 (I, II, III) - 40250 原版在 y = 333 (418 - 85)
+ _page_button_container = HBoxContainer.new()
+ _page_button_container.name = "PageButtons"
+ _page_button_container.position = Vector2(10, 333)
+ _page_button_container.size = Vector2(156, 20)
+ _page_button_container.alignment = BoxContainer.ALIGNMENT_CENTER
+ _page_button_container.add_theme_constant_override("separation", 6)
+ _root.add_child(_page_button_container)
+
+ for p_idx in 3:
+ var p_btn := Button.new()
+ p_btn.name = "PageBtn_%d" % p_idx
+ p_btn.text = ["I", "II", "III"][p_idx]
+ p_btn.custom_minimum_size = Vector2(44, 18)
+ p_btn.toggle_mode = true
+ var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_01.sub")
+ var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_02.sub")
+ var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/windows/tab_button_middle_03.sub")
+ if t1:
+ var sb1 := StyleBoxTexture.new(); sb1.texture = t1
+ p_btn.add_theme_stylebox_override("normal", sb1)
+ if t2:
+ var sb2 := StyleBoxTexture.new(); sb2.texture = t2
+ p_btn.add_theme_stylebox_override("hover", sb2)
+ if t3:
+ var sb3 := StyleBoxTexture.new(); sb3.texture = t3
+ p_btn.add_theme_stylebox_override("pressed", sb3)
+ p_btn.pressed.connect(func(): _select_page(p_idx))
+ _page_button_container.add_child(p_btn)
+ _page_buttons.append(p_btn)
+
+ # 5. 修改密码与关闭大按钮 - 40250 原版居中纵向排列
+ _change_pw_btn = _create_large_button("ChangePasswordButton", "修改密码", Vector2(51, 360), func(): _ask_change_password())
+ _change_pw_btn.size = Vector2(74, 21)
+ _root.add_child(_change_pw_btn)
+
+ _exit_btn = _create_large_button("ExitButton", "关闭", Vector2(51, 381), func(): close())
+ _exit_btn.size = Vector2(74, 21)
+ _root.add_child(_exit_btn)
+
+ # 兼容字段(测试与移动端使用)
+ _gold = Label.new()
+ _gold.name = "Gold"
+ _gold.visible = false
+ _root.add_child(_gold)
+
+ _page_label = Label.new()
+ _page_label.name = "PageLabel"
+ _page_label.visible = false
+ _root.add_child(_page_label)
+
+ _status = Label.new()
+ _status.name = "Status"
+ _status.visible = false
+ _root.add_child(_status)
+
+ _list = VBoxContainer.new()
+ _list.name = "List"
+ _list.visible = false
+ _root.add_child(_list)
+
+ # 移动端适配容器
_mobile_inventory_title = Label.new()
- _mobile_inventory_title.text = "背包 · 点按存入"
- _mobile_inventory_title.add_theme_font_size_override("font_size", 12)
- _mobile_inventory_title.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
+ _mobile_inventory_title.text = "背包候选 · 点按存入"
+ _mobile_inventory_title.position = Vector2(350, 48)
_mobile_inventory_title.visible = false
_root.add_child(_mobile_inventory_title)
+
_mobile_inventory_scroll = ScrollContainer.new()
- _mobile_inventory_scroll.name = "MobileInventory"
+ _mobile_inventory_scroll.position = Vector2(350, 76)
+ _mobile_inventory_scroll.size = Vector2(326, 292)
_mobile_inventory_scroll.visible = false
- _mobile_inventory_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
- _mobile_inventory_list = VBoxContainer.new()
- _mobile_inventory_list.add_theme_constant_override("separation", 4)
- _mobile_inventory_scroll.add_child(_mobile_inventory_list)
_root.add_child(_mobile_inventory_scroll)
+
+ _mobile_inventory_list = VBoxContainer.new()
+ _mobile_inventory_list.add_theme_constant_override("separation", 6)
+ _mobile_inventory_scroll.add_child(_mobile_inventory_list)
+
+ _select_page(0)
_apply_mobile_layout()
+
+func _create_large_button(btn_name: String, text: String, pos: Vector2, on_click: Callable) -> Button:
+ var btn := Button.new()
+ btn.name = btn_name
+ btn.text = text
+ btn.position = pos
+ btn.size = Vector2(74, 22)
+ btn.custom_minimum_size = Vector2(74, 22)
+ btn.add_theme_font_size_override("font_size", 11)
+ var t1 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_01.sub")
+ var t2 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_02.sub")
+ var t3 := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/large_button_03.sub")
+ if t1:
+ var sb1 := StyleBoxTexture.new(); sb1.texture = t1
+ btn.add_theme_stylebox_override("normal", sb1)
+ if t2:
+ var sb2 := StyleBoxTexture.new(); sb2.texture = t2
+ btn.add_theme_stylebox_override("hover", sb2)
+ if t3:
+ var sb3 := StyleBoxTexture.new(); sb3.texture = t3
+ btn.add_theme_stylebox_override("pressed", sb3)
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ btn.pressed.connect(on_click)
+ return btn
diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd
index 57f6a834..029990bf 100644
--- a/project/ui/shop_ui.gd
+++ b/project/ui/shop_ui.gd
@@ -11,13 +11,16 @@ extends Node
const CursorManager = preload("res://ui/cursor_manager.gd")
const ItemTooltip = preload("res://ui/item_tooltip.gd")
+const UiAssets = preload("res://ui/ui_assets.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
+const USE_SHOP_LIMIT_RANGE_M := 10.0 # 40250 USE_SHOP_LIMIT_RANGE = 1000cm (uishop.py:483)
var client: Node
var proto: Node
var item_list # ItemListDB (RefCounted)
+var _assets_root := ""
var _tooltip_builder: RefCounted
var _root: Control
var _tabbar: HBoxContainer
@@ -25,6 +28,8 @@ var _grid: GridContainer
var _err: Label
var _sell_quantity: SpinBox
var _active_tab := 0
+var _open_char_pos := Vector3.ZERO
+var _has_open_pos := false
var item_mouse: Node
var cursor_manager: Node
var audio: Node
@@ -42,13 +47,16 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun
client = m2client
proto = proto_node
item_list = ilist
+ _assets_root = AssetRoot.path()
_tooltip_builder = ItemTooltip.new()
- _tooltip_builder.setup(AssetRoot.path(), "en", proto)
+ _tooltip_builder.setup(_assets_root, "en", proto)
_build(parent)
if client.has_signal("shop_opened"):
client.shop_opened.connect(func(_v): open())
if client.has_signal("shop_closed"):
client.shop_closed.connect(_close)
+ if client.has_signal("shop_updated"):
+ client.shop_updated.connect(_on_shop_slot_updated)
if client.has_signal("shop_error"):
client.shop_error.connect(_on_error)
@@ -64,17 +72,63 @@ func get_mobile_window() -> Control:
return _root
func close() -> void:
+ if client and client.has_method("shop_close") and is_open():
+ client.shop_close()
_close()
+func _get_main_char_pos() -> Variant:
+ if client == null:
+ return null
+ var vid := 0
+ if client.has_method("get_main_vid"):
+ vid = int(client.get_main_vid())
+ if vid != 0 and client.has_method("get_entity"):
+ var e: Dictionary = client.get_entity(vid)
+ if not e.is_empty() and e.has("pos"):
+ return e["pos"]
+ return null
+
+func _process(_delta: float) -> void:
+ if not is_open() or not _has_open_pos:
+ return
+ var cur = _get_main_char_pos()
+ if cur is Vector3:
+ if _open_char_pos.distance_to(cur) > USE_SHOP_LIMIT_RANGE_M:
+ close()
+
+func _icon(vnum: int) -> Texture2D:
+ if vnum <= 0:
+ return null
+ var rel := ""
+ if item_list and item_list.has_method("has") and item_list.has(vnum):
+ rel = item_list.icon(vnum)
+ elif item_list and item_list.has_method("icon"):
+ rel = item_list.icon(vnum)
+ if rel != "":
+ return UiAssets.load_tex(_assets_root, rel)
+ var exact_rel := "icon/item/%05d.tga" % vnum
+ var tex := UiAssets.load_tex(_assets_root, exact_rel)
+ if tex != null:
+ return tex
+ var fallback_rel := "icon/item/%05d.tga" % ((vnum / 10) * 10)
+ return UiAssets.load_tex(_assets_root, fallback_rel)
+
func open() -> void:
_root.visible = true
_err.text = ""
_sell_quantity.value = 1
_active_tab = 0
_set_mode(1)
+ var p = _get_main_char_pos()
+ if p is Vector3:
+ _open_char_pos = p
+ _has_open_pos = true
+ else:
+ _has_open_pos = false
refresh()
func _close() -> void:
+ _has_open_pos = false
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():
@@ -310,15 +364,35 @@ func _slot(it: Dictionary, pos: int) -> Button:
slot.text = _name_of(vnum).substr(0, 7)
if count > 1:
slot.text += " ×%d" % count
+ var tex := _icon(vnum)
+ if tex:
+ slot.icon = tex
+ slot.expand_icon = true
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))
+ slot.gui_input.connect(func(event: InputEvent) -> void:
+ if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
+ _direct_buy(pos, it)
+ )
else:
slot.disabled = true
return slot
+func _direct_buy(pos: int, item: Dictionary) -> void:
+ if _mode != 1 or item.is_empty() or pos < 0:
+ return
+ if client and client.has_method("shop_buy"):
+ if not client.shop_buy(pos, 1):
+ _on_error("SEND_FAILED")
+
+func _on_shop_slot_updated(_pos: int) -> void:
+ if not is_open():
+ return
+ refresh()
+
func _buy_slot(pos: int, item: Dictionary) -> void:
if _mode != 1:
_set_mode(1)
diff --git a/project/ui/skill_table.gd b/project/ui/skill_table.gd
index 7a353cfb..0742edf9 100644
--- a/project/ui/skill_table.gd
+++ b/project/ui/skill_table.gd
@@ -228,6 +228,15 @@ func name_of(id: int) -> String:
func motion_idx_of(id: int) -> int:
return int(_by_id.get(id, {}).get("motion_idx", 0))
+func motion_name_of(id: int) -> String:
+ return String(_by_id.get(id, {}).get("motion", ""))
+
+func motion_name_by_idx(motion_idx: int) -> String:
+ for id in _by_id:
+ if int(_by_id[id].get("motion_idx", 0)) == motion_idx:
+ return String(_by_id[id].get("motion", ""))
+ return ""
+
func _attrs(id: int) -> String:
return String(_by_id.get(id, {}).get("attrs", ""))
diff --git a/project/ui/skill_ui.gd b/project/ui/skill_ui.gd
index b4ee9bd1..e67b4569 100644
--- a/project/ui/skill_ui.gd
+++ b/project/ui/skill_ui.gd
@@ -23,6 +23,8 @@ const HIDE_SUPPORT_SKILL_POINT := true
# uicharacter.py __RefreshSkillPlusButton 的 HORSE 分支硬编码 `if skillLevel < 20`。
const RIDING_SKILL_MAX_LEVEL := 20
+signal skill_use_requested(skill_id: int)
+
var client: Node
var table: RefCounted # SkillTable
var ui: CanvasLayer # UiManager
@@ -235,9 +237,12 @@ func _rebuild() -> void:
_rows[id] = {"lv": lv, "up": null}
if not _mobile_mode:
nm.gui_input.connect(func(e: InputEvent):
- if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT:
- drag_skill_id = sid
- if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid)
+ if e is InputEventMouseButton and e.pressed:
+ if e.button_index == MOUSE_BUTTON_LEFT:
+ drag_skill_id = sid
+ if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid)
+ elif e.button_index == MOUSE_BUTTON_RIGHT:
+ skill_use_requested.emit(sid)
nm.accept_event())
if _mobile_mode:
var assign := Button.new()
diff --git a/project/ui/system_menu_ui.gd b/project/ui/system_menu_ui.gd
index 0d92b1ec..81d55995 100644
--- a/project/ui/system_menu_ui.gd
+++ b/project/ui/system_menu_ui.gd
@@ -54,6 +54,11 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "",
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ _win = {}
+ )
func is_open() -> bool:
if _mobile_mode:
@@ -79,9 +84,10 @@ func close() -> void:
if root and is_instance_valid(root) and ui:
ui.close(root)
return
- if is_open():
- ui.close(_win["root"])
+ var r: Control = _win.get("root", null)
_win = {}
+ if r and is_instance_valid(r) and ui:
+ ui.close(r)
func open() -> void:
if _mobile_mode:
diff --git a/project/ui/system_option_ui.gd b/project/ui/system_option_ui.gd
index 019e728d..a0f7e25e 100644
--- a/project/ui/system_option_ui.gd
+++ b/project/ui/system_option_ui.gd
@@ -66,6 +66,11 @@ func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
+ if ui and ui.has_signal("window_closed"):
+ ui.window_closed.connect(func(w):
+ if not _win.is_empty() and _win.get("root") == w:
+ _win = {}
+ )
audio = audio_node
_cam_get = cam_getter
_env_get = env_getter
@@ -80,7 +85,9 @@ func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
- if not enabled and _mobile_root != null:
+ if enabled == _mobile_mode:
+ return
+ if is_open():
var old_mode := _mobile_mode
_mobile_mode = true
close()
@@ -97,9 +104,10 @@ func close() -> void:
ui.close(_mobile_root)
_mobile_root = null
return
- if is_open():
- ui.close(_win["root"])
+ var r: Control = _win.get("root", null)
_win = {}
+ if r and is_instance_valid(r) and ui:
+ ui.close(r)
func open() -> void:
if _mobile_mode:
@@ -111,13 +119,35 @@ func open() -> void:
if not FileAccess.file_exists(path):
push_warning("SystemOptionUI: no systemoptiondialog.py at " + path)
return
- _win = ui.open_script(path, assets_root)
+ _win = ui.open_script(path, assets_root, false, true)
if not is_open():
return
+ set_center_position()
_relabel()
_wire()
_sync_controls()
+## 1:1 对齐 40250 uisystemoption.py: self.SetCenterPosition()
+func set_center_position(offset := Vector2.ZERO) -> void:
+ var r: Control = _win.get("root", null)
+ if r == null or not is_instance_valid(r):
+ return
+ if ui and ui.has_method("center_window"):
+ ui.center_window(r, offset)
+ else:
+ var vp_sz := Vector2(1920, 1080)
+ var vp := r.get_viewport()
+ if vp:
+ var vrect := vp.get_visible_rect().size
+ if vrect.x > 300 and vrect.y > 300:
+ vp_sz = vrect
+ elif ui and "screen" in ui and ui.screen.x > 0:
+ vp_sz = Vector2(ui.screen)
+ var sz := r.size
+ if sz.x <= 0 or sz.y <= 0:
+ sz = r.custom_minimum_size
+ r.position = ((vp_sz - sz) / 2.0 + offset).round()
+
func _open_mobile() -> void:
if is_open():
return
@@ -255,6 +285,10 @@ func _wire() -> void:
var bgm_btn := _node("bgm_button")
if bgm_btn is BaseButton:
bgm_btn.disabled = true # 换 BGM 需音乐文件选择器,暂缺
+ var tb := _node("titlebar")
+ if tb:
+ for x in tb.find_children("*", "BaseButton", true, false):
+ x.pressed.connect(close)
func _bind_radio(names: Array, on_pick: Callable) -> void:
var boxes: Array = []
diff --git a/project/ui/target_ui.gd b/project/ui/target_ui.gd
new file mode 100644
index 00000000..fa71ec3a
--- /dev/null
+++ b/project/ui/target_ui.gd
@@ -0,0 +1,215 @@
+# target_ui.gd —— 40250 目标信息板(TargetBoard)1:1 实现
+# 严格对照:
+# root/uitarget.py:14-340 (TargetBoard)
+# UserInterface/InstanceBase.cpp (CanViewTargetHP)
+# root/localeinfo.py (TARGET_LEVEL_PAWN..KING)
+extends Node
+
+const UiAssets = preload("res://ui/ui_assets.gd")
+
+const GRADE_NAMES := {
+ 0: "1阶", # nonplayer.PAWN: TARGET_LEVEL_PAWN
+ 1: "2阶", # nonplayer.S_PAWN: TARGET_LEVEL_S_PAWN
+ 2: "3阶", # nonplayer.KNIGHT: TARGET_LEVEL_KNIGHT
+ 3: "4阶", # nonplayer.S_KNIGHT: TARGET_LEVEL_S_KNIGHT
+ 4: "5阶首领", # nonplayer.BOSS: TARGET_LEVEL_BOSS
+ 5: "6阶王", # nonplayer.KING: TARGET_LEVEL_KING
+}
+
+const DEFAULT_BOARD_WIDTH := 250.0
+const BOARD_HEIGHT := 40.0
+const GAUGE_WIDTH := 130.0
+
+signal whisper_requested(name: String)
+signal exchange_requested(vid: int)
+signal fight_requested(vid: int)
+signal view_equipment_requested(vid: int)
+
+var client: Node
+var _root: Control
+var _board: Panel
+var _name_label: Label
+var _hp_gauge_bg: ColorRect
+var _hp_gauge_fill: ColorRect
+var _hp_text: Label
+var _close_button: Button
+var _button_container: HBoxContainer
+var _action_buttons: Dictionary = {}
+
+var _current_vid := 0
+var _current_name := ""
+var _current_hp_pct := 100
+var _is_pc := false
+var _shown := false
+var _assets_root := ""
+
+func setup(m2client: Node, parent: Node, assets := "") -> void:
+ client = m2client
+ _assets_root = assets
+ _build(parent)
+
+func is_open() -> bool:
+ return _shown and _root != null and _root.visible
+
+func get_target_vid() -> int:
+ return _current_vid
+
+func get_target_name() -> String:
+ return _current_name
+
+func get_hp_percentage() -> int:
+ return _current_hp_pct
+
+func open(vid: int, target_name: String, level := -1, grade := -1, is_pc := false) -> void:
+ _current_vid = vid
+ _is_pc = is_pc
+
+ var display_name := target_name
+ var prefix := ""
+ if level > 0:
+ prefix += "[Lv. %d] " % level
+ if grade in GRADE_NAMES:
+ prefix += "(%s) " % GRADE_NAMES[grade]
+
+ _current_name = prefix + display_name
+ _name_label.text = _current_name
+
+ # 40250 uitarget.py:301 板面自适应宽度:200 + 7 * nameLength
+ var calculated_width := maxf(DEFAULT_BOARD_WIDTH, 200.0 + 7.0 * float(_current_name.length()))
+ _update_layout(calculated_width, is_pc)
+
+ _shown = true
+ _root.visible = true
+ _update_screen_position()
+
+func set_hp(hp_pct: int) -> void:
+ _current_hp_pct = clampi(hp_pct, 0, 100)
+ if _hp_gauge_fill:
+ _hp_gauge_fill.size.x = GAUGE_WIDTH * (_current_hp_pct / 100.0)
+ if _hp_text:
+ _hp_text.text = "%d%%" % _current_hp_pct
+ if _hp_gauge_bg and not _is_pc:
+ _hp_gauge_bg.visible = true
+
+func close() -> void:
+ _shown = false
+ _current_vid = 0
+ _current_name = ""
+ if _root:
+ _root.visible = false
+
+func _on_close_button_pressed() -> void:
+ if client and client.has_method("clear_target"):
+ client.clear_target()
+ close()
+
+func _update_layout(width: float, is_pc: bool) -> void:
+ _board.size = Vector2(width, BOARD_HEIGHT)
+ if is_pc:
+ _hp_gauge_bg.visible = false
+ _button_container.visible = true
+ _button_container.position = Vector2(width - 200, 8)
+ else:
+ _hp_gauge_bg.visible = true
+ _button_container.visible = false
+ _hp_gauge_bg.position = Vector2(width - 175, 14)
+ _close_button.position = Vector2(width - 28, 10)
+
+func _update_screen_position() -> void:
+ if _root == null or not is_open():
+ return
+ var screen_w := 800.0
+ var vp := _root.get_viewport()
+ if vp:
+ screen_w = vp.get_visible_rect().size.x
+ # 40250 uitarget.py:254 UpdatePosition: ScreenWidth // 2 - Width // 2, 10
+ _root.position = Vector2(screen_w / 2.0 - _board.size.x / 2.0, 10.0)
+
+func _build(parent: Node) -> void:
+ _root = Control.new()
+ _root.name = "TargetBoard"
+ _root.set_anchors_preset(Control.PRESET_TOP_LEFT)
+ _root.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT)
+ _root.visible = false
+ parent.add_child(_root)
+
+ # 经典 ThinBoard 风格
+ _board = Panel.new()
+ _board.name = "Board"
+ _board.size = Vector2(DEFAULT_BOARD_WIDTH, BOARD_HEIGHT)
+ var bg := StyleBoxFlat.new()
+ bg.bg_color = Color(0.04, 0.05, 0.07, 0.88)
+ bg.border_color = Color(0.45, 0.38, 0.22, 0.95)
+ bg.set_border_width_all(1)
+ bg.set_corner_radius_all(2)
+ _board.add_theme_stylebox_override("panel", bg)
+ _root.add_child(_board)
+
+ # 目标名称标签
+ _name_label = Label.new()
+ _name_label.name = "Name"
+ _name_label.position = Vector2(23, 11)
+ _name_label.size = Vector2(200, 20)
+ _name_label.add_theme_font_size_override("font_size", 11)
+ _name_label.add_theme_color_override("font_color", Color(1.0, 0.92, 0.75))
+ _name_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
+ _name_label.add_theme_constant_override("outline_size", 2)
+ _board.add_child(_name_label)
+
+ # 红色 HP Gauge(40250 130px 经典红槽)
+ _hp_gauge_bg = ColorRect.new()
+ _hp_gauge_bg.name = "HPGaugeBG"
+ _hp_gauge_bg.position = Vector2(DEFAULT_BOARD_WIDTH - 175, 14)
+ _hp_gauge_bg.size = Vector2(GAUGE_WIDTH, 12)
+ _hp_gauge_bg.color = Color(0.12, 0.02, 0.02, 0.95)
+ _board.add_child(_hp_gauge_bg)
+
+ _hp_gauge_fill = ColorRect.new()
+ _hp_gauge_fill.name = "HPGaugeFill"
+ _hp_gauge_fill.position = Vector2.ZERO
+ _hp_gauge_fill.size = Vector2(GAUGE_WIDTH, 12)
+ _hp_gauge_fill.color = Color(0.85, 0.12, 0.12, 1.0)
+ _hp_gauge_bg.add_child(_hp_gauge_fill)
+
+ _hp_text = Label.new()
+ _hp_text.name = "HPText"
+ _hp_text.set_anchors_preset(Control.PRESET_FULL_RECT)
+ _hp_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ _hp_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _hp_text.add_theme_font_size_override("font_size", 9)
+ _hp_text.add_theme_color_override("font_color", Color(1, 1, 1, 0.95))
+ _hp_text.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
+ _hp_text.add_theme_constant_override("outline_size", 1)
+ _hp_text.text = "100%"
+ _hp_gauge_bg.add_child(_hp_text)
+
+ # 关闭按钮 (X)
+ _close_button = Button.new()
+ _close_button.name = "CloseButton"
+ _close_button.position = Vector2(DEFAULT_BOARD_WIDTH - 28, 10)
+ _close_button.size = Vector2(18, 18)
+ _close_button.text = "×"
+ _close_button.add_theme_font_size_override("font_size", 12)
+ _close_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _close_button.pressed.connect(_on_close_button_pressed)
+ _board.add_child(_close_button)
+
+ # PC 交互按钮条
+ _button_container = HBoxContainer.new()
+ _button_container.name = "ButtonContainer"
+ _button_container.position = Vector2(DEFAULT_BOARD_WIDTH - 200, 8)
+ _button_container.visible = false
+ _board.add_child(_button_container)
+
+ _add_action_btn("私聊", func(): whisper_requested.emit(_current_name))
+ _add_action_btn("交易", func(): exchange_requested.emit(_current_vid))
+ _add_action_btn("装备", func(): view_equipment_requested.emit(_current_vid))
+
+func _add_action_btn(text: String, callback: Callable) -> void:
+ var btn := Button.new()
+ btn.text = text
+ btn.custom_minimum_size = Vector2(42, 22)
+ btn.add_theme_font_size_override("font_size", 10)
+ btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ btn.pressed.connect(callback)
+ _button_container.add_child(btn)
diff --git a/project/ui/target_ui.gd.uid b/project/ui/target_ui.gd.uid
new file mode 100644
index 00000000..caf4bcdb
--- /dev/null
+++ b/project/ui/target_ui.gd.uid
@@ -0,0 +1 @@
+uid://dh05do42dtgef
diff --git a/project/ui/ui_assets.gd b/project/ui/ui_assets.gd
index 69736fc3..6ad99a7c 100644
--- a/project/ui/ui_assets.gd
+++ b/project/ui/ui_assets.gd
@@ -9,6 +9,9 @@ extends RefCounted
static var _cache := {}
+static func clear_cache() -> void:
+ _cache.clear()
+
# DDS 运行时解码:走 Metin2World.load_dds(C++ 里的 dxt.cpp,DXT1/3/5 + BGRA8)。
# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。
static func load_dds_image(path: String) -> Image:
@@ -128,5 +131,6 @@ static func _load_sub(path: String) -> Texture2D:
return base
var at := AtlasTexture.new()
at.atlas = base
- at.region = Rect2(l, t, r - l, b - t)
+ var region_top := t
+ at.region = Rect2(l, region_top, r - l, b - t)
return at
diff --git a/project/ui/ui_build.gd b/project/ui/ui_build.gd
index 0669f183..ffa4da38 100644
--- a/project/ui/ui_build.gd
+++ b/project/ui/ui_build.gd
@@ -35,11 +35,50 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
var type := _str(spec.get("type", "window"))
var w := _i(spec, "width", 0)
var h := _i(spec, "height", 0)
+ if type == "titlebar" and h == 0:
+ h = 23
+ elif type == "horizontalbar" and h == 0:
+ h = 17
+ elif (type == "grid_table" or type == "slot") and (w == 0 or h == 0):
+ if w == 0 and spec.has("x_count"):
+ var xc := maxi(1, _i(spec, "x_count", 1))
+ var xs := _i(spec, "x_step", 32)
+ var xb := _i(spec, "x_blank", 0)
+ w = xc * (xs + xb)
+ if h == 0 and spec.has("y_count"):
+ var yc := maxi(1, _i(spec, "y_count", 1))
+ var ys := _i(spec, "y_step", 32)
+ var yb := _i(spec, "y_blank", 0)
+ h = yc * (ys + yb)
var node := _make_by_type(type, spec, ctx, Vector2i(w, h))
if node == null:
node = Control.new()
node.name = _safe_name(_str(spec.get("name", type)))
+ var halign := _str(spec.get("text_horizontal_align", ""))
+ var valign := _str(spec.get("text_vertical_align", ""))
+ var allalign := _str(spec.get("all_align", ""))
+ if type == "text":
+ if allalign == "center":
+ if w == 0: w = parent_size.x
+ if h == 0: h = parent_size.y
+ else:
+ if halign == "center" and w == 0:
+ w = parent_size.x if parent_size.x > 0 else 100
+ if (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == 0:
+ h = parent_size.y if parent_size.y > 0 else 17
+ if (w == 0 or h == 0) and node is TextureRect and (node as TextureRect).texture != null:
+ var tex: Texture2D = (node as TextureRect).texture
+ if w == 0: w = tex.get_width()
+ if h == 0: h = tex.get_height()
+ elif (w == 0 or h == 0) and node is Button:
+ var btn := node as Button
+ var nrm_sb = btn.get_theme_stylebox("normal")
+ if nrm_sb is StyleBoxTexture and (nrm_sb as StyleBoxTexture).texture != null:
+ var tex: Texture2D = (nrm_sb as StyleBoxTexture).texture
+ if w == 0: w = tex.get_width()
+ if h == 0: h = tex.get_height()
+
if w > 0:
node.custom_minimum_size = Vector2(w, h)
node.size = Vector2(w, h)
@@ -47,11 +86,17 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
# 位置:x/y + horizontal_align / vertical_align(相对父)
var x := _i(spec, "x", 0)
var y := _i(spec, "y", 0)
+ if type == "text" and halign == "center" and _i(spec, "width", 0) == 0:
+ x = 0
match _str(spec.get("horizontal_align", "left")):
"center": x = (parent_size.x - w) / 2 + x
"right": x = parent_size.x - w - x
match _str(spec.get("vertical_align", "top")):
- "center": y = (parent_size.y - h) / 2 + y
+ "center":
+ if type == "text" and (valign == "center" or _str(spec.get("vertical_align", "")) == "center") and h == parent_size.y:
+ pass
+ else:
+ y = (parent_size.y - h) / 2 + y
"bottom": y = parent_size.y - h - y
node.position = Vector2(x, y)
@@ -59,7 +104,7 @@ static func _make(spec: Dictionary, ctx: Dictionary, parent_size: Vector2i) -> C
if nm != "":
ctx["nodes"][nm] = node
- var self_size := Vector2i(w, h) if w > 0 else parent_size
+ var self_size := Vector2i(w, h) if (w > 0 and h > 0) else parent_size
for child in spec.get("children", []):
if child is Dictionary:
var c := _make(child, ctx, self_size)
@@ -85,36 +130,109 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
p.set_meta("titlebar_h", 24)
return p
"titlebar":
- var t := Panel.new()
- t.set_meta("is_titlebar", true)
- return t
+ return _titlebar(assets, sz.x, spec)
"text":
var lbl := Label.new()
lbl.text = _str(spec.get("text", ""))
if spec.has("text_color"):
lbl.add_theme_color_override("font_color", _argb(int(spec["text_color"])))
- match _str(spec.get("text_horizontal_align", "left")):
+ elif spec.has("color"):
+ lbl.add_theme_color_override("font_color", _argb(int(spec["color"])))
+ elif spec.has("r") and spec.has("g") and spec.has("b"):
+ var r := float(spec["r"])
+ var g := float(spec["g"])
+ var b := float(spec["b"])
+ var a := float(spec.get("a", 1.0))
+ lbl.add_theme_color_override("font_color", Color(r, g, b, a))
+ else:
+ lbl.add_theme_color_override("font_color", Color(0.8549, 0.8549, 0.8549))
+
+ var font_size := 12
+ var fs_spec := _str(spec.get("fontsize", "")).to_upper()
+ var fn_spec := _str(spec.get("fontname", "")).to_upper()
+ if fs_spec == "LARGE" or "LARGE" in fn_spec:
+ font_size = 14
+ elif fs_spec == "SMALL" or "SMALL" in fn_spec:
+ font_size = 9
+ lbl.add_theme_font_size_override("font_size", font_size)
+
+ var halign := _str(spec.get("text_horizontal_align", ""))
+ var valign := _str(spec.get("text_vertical_align", ""))
+ if _str(spec.get("all_align", "")) == "center":
+ halign = "center"
+ valign = "center"
+ match halign:
"center": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
"right": lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
- lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ _: lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
+ match valign:
+ "center": lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ "bottom": lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
+ _: lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
return lbl
- "button", "toggle_button":
+ "button", "toggle_button", "radio_button":
var b := Button.new()
b.text = _str(spec.get("text", ""))
- b.toggle_mode = (type == "toggle_button")
+ b.toggle_mode = (type == "toggle_button" or type == "radio_button")
+ if spec.has("text_color"):
+ var col := _argb(int(spec["text_color"]))
+ b.add_theme_color_override("font_color", col)
+ b.add_theme_color_override("font_pressed_color", col)
+ b.add_theme_color_override("font_hover_color", col)
+ b.add_theme_color_override("font_focus_color", col)
+ elif spec.has("color"):
+ var col := _argb(int(spec["color"]))
+ b.add_theme_color_override("font_color", col)
+ b.add_theme_color_override("font_pressed_color", col)
+ b.add_theme_color_override("font_hover_color", col)
+ b.add_theme_color_override("font_focus_color", col)
+ b.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
+ b.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ b.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ b.clip_text = true
+
+ var btn_fsize := 12
+ var fs_spec := _str(spec.get("fontsize", "")).to_upper()
+ var fn_spec := _str(spec.get("fontname", "")).to_upper()
+ if fs_spec == "LARGE" or "LARGE" in fn_spec:
+ btn_fsize = 14
+ elif fs_spec == "SMALL" or "SMALL" in fn_spec:
+ btn_fsize = 9
+ b.add_theme_font_size_override("font_size", btn_fsize)
+
var nrm := UiAssets.load_tex(assets, _str(spec.get("default_image", "")))
var ovr := UiAssets.load_tex(assets, _str(spec.get("over_image", "")))
var dwn := UiAssets.load_tex(assets, _str(spec.get("down_image", "")))
+ var empty := StyleBoxEmpty.new()
+ empty.content_margin_left = 0
+ empty.content_margin_right = 0
+ empty.content_margin_top = 0
+ empty.content_margin_bottom = 0
if nrm:
b.add_theme_stylebox_override("normal", _sb_tex(nrm))
- if ovr: b.add_theme_stylebox_override("hover", _sb_tex(ovr))
- if dwn: b.add_theme_stylebox_override("pressed", _sb_tex(dwn))
+ b.add_theme_stylebox_override("hover", _sb_tex(ovr if ovr else nrm))
+ b.add_theme_stylebox_override("pressed", _sb_tex(dwn if dwn else nrm))
+ b.add_theme_stylebox_override("disabled", _sb_tex(nrm))
+ b.add_theme_stylebox_override("focus", empty)
+ var tex_w := nrm.get_width()
+ var tex_h := nrm.get_height()
+ b.custom_minimum_size = Vector2(tex_w, tex_h)
+ b.size = Vector2(tex_w, tex_h)
+ # 40250 对齐:进入节点树时重设尺寸,防止被 Godot 默认最小高度撑大到 31px
+ b.tree_entered.connect(func():
+ b.size = b.custom_minimum_size
+ )
+ if btn_fsize + 6 > tex_h:
+ btn_fsize = maxi(8, tex_h - 6)
+ b.add_theme_font_size_override("font_size", btn_fsize)
+ else:
+ b.add_theme_stylebox_override("normal", empty)
+ b.add_theme_stylebox_override("hover", empty)
+ b.add_theme_stylebox_override("pressed", empty)
+ b.add_theme_stylebox_override("focus", empty)
+ b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
return b
- "radio_button":
- var cb := CheckBox.new()
- cb.text = _str(spec.get("text", ""))
- return cb
"image", "expanded_image", "ani_image", "mark":
var ir := TextureRect.new()
ir.texture = UiAssets.load_tex(assets, _str(spec.get("image", "")))
@@ -138,11 +256,13 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
return ln
"listbox", "candidate_list":
return ItemList.new()
- "gauge", "bar", "horizontalbar":
+ "gauge", "bar":
var pb := ProgressBar.new()
pb.show_percentage = false
pb.value = 100.0
return pb
+ "horizontalbar":
+ return _horizontalbar(assets, _i(spec, "width", 100))
"grid_table", "slotbar", "slot":
# Metin2 物品格容器:有 start_index/x_count 或显式 "slot" 元组 -> 建子格;
# 否则 grid_table 退化成普通 GridContainer。
@@ -153,6 +273,8 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
return g
var s := Control.new()
s.set_meta("is_slot_container", true)
+ var base_img := _str(spec.get("image", ""))
+ var base_tex: Texture2D = UiAssets.load_tex(assets, base_img) if base_img != "" else null
for cd in cells:
var cell := Panel.new()
cell.name = "slot_%d" % int(cd["index"])
@@ -161,10 +283,33 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
cell.size = Vector2(cd["w"], cd["h"])
cell.set_meta("slot_index", int(cd["index"]))
cell.mouse_filter = Control.MOUSE_FILTER_STOP
+ if base_tex != null:
+ cell.add_theme_stylebox_override("panel", _sb_tex(base_tex))
s.add_child(cell)
return s
"sliderbar":
- return HSlider.new()
+ var slider := HSlider.new()
+ # 40250 sliderbar.sub (175×12), sliderbar_cursor.sub (53×12)
+ slider.custom_minimum_size = Vector2(175, 12)
+ slider.size = Vector2(175, 12)
+ slider.min_value = 0.0
+ slider.max_value = 1.0
+ slider.step = 0.01
+ var track_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar.sub")
+ var grab_tex := UiAssets.load_tex(assets, "d:/ymir work/ui/game/windows/sliderbar_cursor.sub")
+ if track_tex:
+ var sb_track := StyleBoxTexture.new()
+ sb_track.texture = track_tex
+ sb_track.content_margin_left = 0
+ sb_track.content_margin_right = 0
+ sb_track.content_margin_top = 0
+ sb_track.content_margin_bottom = 0
+ slider.add_theme_stylebox_override("slider", sb_track)
+ if grab_tex:
+ slider.add_theme_icon_override("grabber", grab_tex)
+ slider.add_theme_icon_override("grabber_highlight", grab_tex)
+ slider.add_theme_icon_override("grabber_disabled", grab_tex)
+ return slider
"scrollbar", "thin_scrollbar":
return VScrollBar.new()
_:
@@ -174,14 +319,20 @@ static func _make_by_type(type: String, spec: Dictionary, ctx: Dictionary, sz: V
static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
var p := Panel.new()
+ if sz.x > 0 and sz.y > 0:
+ p.custom_minimum_size = Vector2(sz)
+ p.size = Vector2(sz)
var np: NinePatchRect = null
if assets != "":
- np = UiKit.board(assets, prefix, 32, 128)
+ var m := 16 if prefix == "thinboard" else 32
+ var base_sz := 32 if prefix == "thinboard" else 128
+ np = UiKit.board(assets, prefix, m, base_sz)
if np and np.texture:
- np.set_anchors_preset(Control.PRESET_FULL_RECT)
np.mouse_filter = Control.MOUSE_FILTER_IGNORE
p.add_child(np)
+ np.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
p.get_child(0).show_behind_parent = true
+ p.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
else:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.10, 0.11, 0.14, 0.92)
@@ -191,6 +342,123 @@ static func _board(assets: String, prefix: String, sz: Vector2i) -> Panel:
p.add_theme_stylebox_override("panel", sb)
return p
+static func _titlebar(assets: String, width: int, spec: Dictionary) -> Control:
+ var t := Control.new()
+ var w := maxi(64, width)
+ t.custom_minimum_size = Vector2(w, 23)
+ t.size = Vector2(w, 23)
+ t.set_meta("is_titlebar", true)
+
+ # Background textures (left, center, right)
+ var left_tex := UiKit.ui_tex(assets, "titlebar_left")
+ var center_tex := UiKit.ui_tex(assets, "titlebar_center")
+ var right_tex := UiKit.ui_tex(assets, "titlebar_right")
+ if left_tex and center_tex and right_tex:
+ var img_left := TextureRect.new()
+ img_left.name = "TitleBarLeft"
+ img_left.texture = left_tex
+ img_left.position = Vector2.ZERO
+ img_left.size = Vector2(32, 23)
+ img_left.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ t.add_child(img_left)
+
+ var img_center := TextureRect.new()
+ img_center.name = "TitleBarCenter"
+ img_center.texture = center_tex
+ img_center.position = Vector2(32, 0)
+ img_center.size = Vector2(maxi(0, w - 64), 23)
+ img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ img_center.stretch_mode = TextureRect.STRETCH_TILE
+ img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ t.add_child(img_center)
+
+ var img_right := TextureRect.new()
+ img_right.name = "TitleBarRight"
+ img_right.texture = right_tex
+ img_right.position = Vector2(w - 32, 0)
+ img_right.size = Vector2(32, 23)
+ img_right.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ t.add_child(img_right)
+ else:
+ var p := Panel.new()
+ p.set_anchors_preset(Control.PRESET_FULL_RECT)
+ var sb := StyleBoxFlat.new()
+ sb.bg_color = Color(0.2, 0.2, 0.25, 0.9)
+ p.add_theme_stylebox_override("panel", sb)
+ p.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ t.add_child(p)
+
+ # Close Button
+ var btn_close := Button.new()
+ btn_close.name = "CloseButton"
+ var nrm := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_01.sub")
+ var ovr := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_02.sub")
+ var dwn := UiAssets.load_tex(assets, "d:/ymir work/ui/public/close_button_03.sub")
+ if nrm:
+ btn_close.add_theme_stylebox_override("normal", _sb_tex(nrm))
+ if ovr: btn_close.add_theme_stylebox_override("hover", _sb_tex(ovr))
+ if dwn: btn_close.add_theme_stylebox_override("pressed", _sb_tex(dwn))
+ var bw := nrm.get_width()
+ var bh := nrm.get_height()
+ btn_close.size = Vector2(bw, bh)
+ btn_close.position = Vector2(w - bw - 3, 3)
+ else:
+ btn_close.text = "×"
+ btn_close.size = Vector2(16, 16)
+ btn_close.position = Vector2(w - 19, 3)
+ btn_close.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ t.add_child(btn_close)
+ t.set_meta("close_button", btn_close)
+ return t
+
+static func _horizontalbar(assets: String, width: int) -> Control:
+ var w := maxi(96, width)
+ var bar := Control.new()
+ bar.custom_minimum_size = Vector2(w, 17)
+ bar.size = Vector2(w, 17)
+ bar.mouse_filter = Control.MOUSE_FILTER_PASS
+
+ var left_tex := UiKit.ui_tex(assets, "horizontalbar_left")
+ var center_tex := UiKit.ui_tex(assets, "horizontalbar_center")
+ var right_tex := UiKit.ui_tex(assets, "horizontalbar_right")
+
+ if left_tex and center_tex and right_tex:
+ var img_left := TextureRect.new()
+ img_left.name = "HorizontalBarLeft"
+ img_left.texture = left_tex
+ img_left.position = Vector2.ZERO
+ img_left.size = Vector2(32, 17)
+ img_left.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bar.add_child(img_left)
+
+ var img_center := TextureRect.new()
+ img_center.name = "HorizontalBarCenter"
+ img_center.texture = center_tex
+ img_center.position = Vector2(32, 0)
+ img_center.size = Vector2(maxi(0, w - 64), 17)
+ img_center.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ img_center.stretch_mode = TextureRect.STRETCH_TILE
+ img_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bar.add_child(img_center)
+
+ var img_right := TextureRect.new()
+ img_right.name = "HorizontalBarRight"
+ img_right.texture = right_tex
+ img_right.position = Vector2(w - 32, 0)
+ img_right.size = Vector2(32, 17)
+ img_right.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ bar.add_child(img_right)
+ else:
+ var sb := StyleBoxFlat.new()
+ sb.bg_color = Color(0.2, 0.22, 0.25, 0.85)
+ var p := Panel.new()
+ p.set_anchors_preset(Control.PRESET_FULL_RECT)
+ p.add_theme_stylebox_override("panel", sb)
+ bar.add_child(p)
+ return bar
+
# slot 容器的子格:显式 "slot" 元组,或 start_index/x_count/y_count/x_step/y_step 网格。
static func _slot_cells(spec: Dictionary) -> Array:
var cells := []
@@ -205,15 +473,21 @@ static func _slot_cells(spec: Dictionary) -> Array:
var yc := maxi(1, _i(spec, "y_count", 1))
var xs := _i(spec, "x_step", 32)
var ys := _i(spec, "y_step", 32)
+ var xb := _i(spec, "x_blank", 0)
+ var yb := _i(spec, "y_blank", 0)
for row in yc:
for col in xc:
- cells.append({"index": si + row * xc + col, "x": col * xs, "y": row * ys,
- "w": 32, "h": 32})
+ cells.append({"index": si + row * xc + col, "x": col * (xs + xb), "y": row * (ys + yb),
+ "w": xs, "h": ys})
return cells
static func _sb_tex(t: Texture2D) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = t
+ sb.content_margin_left = 0
+ sb.content_margin_top = 0
+ sb.content_margin_right = 0
+ sb.content_margin_bottom = 0
return sb
static func _argb(v: int) -> Color:
diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd
index 5e1ba0be..8a110c93 100644
--- a/project/ui/ui_manager.gd
+++ b/project/ui/ui_manager.gd
@@ -33,9 +33,16 @@ var _last_mobile_area := Rect2(-1, -1, 0, 0)
var _drag_win: Control = null
var _drag_from := Vector2.ZERO
-func _ready() -> void:
+func _init() -> void:
+ layer = 10
+ _ensure_root()
+
+func _ensure_root() -> void:
+ if _root != null:
+ return
layer = 10
_root = Control.new()
+ _root.name = "UiRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_root)
@@ -51,6 +58,9 @@ func _ready() -> void:
_mobile_blocker.mouse_filter = Control.MOUSE_FILTER_STOP
_mobile_blocker.visible = false
_root.add_child(_mobile_blocker)
+
+func _ready() -> void:
+ _ensure_root()
set_process_input(true)
set_process_unhandled_input(true)
set_process(true)
@@ -121,16 +131,40 @@ func build_script(path: String, assets_root: String) -> Dictionary:
return {"root": Control.new(), "nodes": {}}
return UiBuild.build(spec, assets_root)
+## 1:1 对齐 40250 ui.py: SetCenterPosition(x = 0, y = 0)
+## 将窗口居中于当前屏幕 / 视口
+func center_window(win: Control, offset := Vector2.ZERO) -> void:
+ if win == null or not is_instance_valid(win):
+ return
+ var vp_sz := Vector2(1920, 1080)
+ var vp := win.get_viewport()
+ if vp:
+ var vrect := vp.get_visible_rect().size
+ if vrect.x > 300 and vrect.y > 300:
+ vp_sz = vrect
+ elif _root and _root.size.x > 300 and _root.size.y > 300:
+ vp_sz = _root.size
+ elif screen.x > 0 and screen.y > 0:
+ vp_sz = Vector2(screen)
+ var win_sz := win.size
+ if win_sz.x <= 0 or win_sz.y <= 0:
+ win_sz = win.custom_minimum_size
+ win.position = ((vp_sz - win_sz) / 2.0 + offset).round()
+
# 解析 + 构建 + 打开,返回 { root, nodes }
-func open_script(path: String, assets_root: String, modal := false) -> Dictionary:
+func open_script(path: String, assets_root: String, modal := false, center := false) -> Dictionary:
var r := build_script(path, assets_root)
open(r.root, modal)
+ if center and r.root is Control:
+ center_window(r.root)
return r
func open(win: Control, modal := false) -> void:
+ _ensure_root()
if win.get_parent() == null:
_root.add_child(win)
win.set_meta("modal", modal)
+ _wire_close_buttons(win)
if mobile_mode:
MobileWindowHost.install(win, _mobile_area(), _window_title(win),
Callable(self, "_close_hosted_window").bind(win))
@@ -141,6 +175,13 @@ func open(win: Control, modal := false) -> void:
_restack()
window_opened.emit(win)
+func _wire_close_buttons(win: Control) -> void:
+ for btn in win.find_children("CloseButton", "BaseButton", true, false):
+ var b := btn as BaseButton
+ if not b.has_meta("_close_wired"):
+ b.set_meta("_close_wired", true)
+ b.pressed.connect(func(): close(win))
+
func close(win: Control) -> void:
if win == null:
return
@@ -383,19 +424,22 @@ func _close_mobile_popup() -> bool:
# --- 标题栏拖动 --------------------------------------------------------
func _wire_drag(win: Control) -> void:
- var handle := _find_drag_handle(win)
- if handle == null or handle.has_meta("_drag_wired"):
- return
- handle.set_meta("_drag_wired", true)
- handle.mouse_filter = Control.MOUSE_FILTER_STOP
- handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev))
+ var handles := _find_drag_handles(win)
+ for handle in handles:
+ if handle == null or handle.has_meta("_drag_wired"):
+ continue
+ handle.set_meta("_drag_wired", true)
+ handle.mouse_filter = Control.MOUSE_FILTER_STOP
+ handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev))
-func _find_drag_handle(win: Control) -> Control:
- # 优先 titlebar;否则 board_with_titlebar 的顶部条;否则整个 window 根
+func _find_drag_handles(win: Control) -> Array[Control]:
+ var list: Array[Control] = []
for n in win.find_children("*", "", true, false):
if n is Control and (n.get_meta("is_titlebar", false) or n.get_meta("titlebar_h", 0) > 0):
- return n
- return win
+ list.append(n as Control)
+ if list.is_empty():
+ list.append(win)
+ return list
func _on_handle_input(win: Control, ev: InputEvent) -> void:
if ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT:
diff --git a/project/ui/uiscript.gd b/project/ui/uiscript.gd
index bc7b50a7..1568e038 100644
--- a/project/ui/uiscript.gd
+++ b/project/ui/uiscript.gd
@@ -244,11 +244,41 @@ func _parse_name():
# 未知名字:数值上下文当 0,字符串上下文当 "";这里返回 0,_str_or 会兜
return 0
+static var _locale_dict := {}
+
+static func _ensure_locale_dict() -> void:
+ if not _locale_dict.is_empty():
+ return
+ _locale_dict["WINDOWS_PATH"] = "locale/en/ui/windows/"
+ _locale_dict["LOCALE_UISCRIPT_PATH"] = "locale/en/ui/"
+ _locale_dict["LOGIN_PATH"] = "locale/en/ui/login/"
+ _locale_dict["EMPIRE_PATH"] = "locale/en/ui/empire/"
+ _locale_dict["GUILD_PATH"] = "locale/en/ui/guild/"
+ _locale_dict["SELECT_PATH"] = "locale/en/ui/select/"
+ _locale_dict["MAPNAME_PATH"] = "locale/en/ui/mapname/"
+ var cands := [
+ "assets/locale/locale/en/locale_interface.txt",
+ "assets/locale/en/locale_interface.txt",
+ "../assets/locale/locale/en/locale_interface.txt",
+ "../assets/locale/en/locale_interface.txt",
+ ]
+ for p in cands:
+ if FileAccess.file_exists(p):
+ var txt := FileAccess.get_file_as_string(p)
+ for line in txt.split("\n"):
+ var parts := line.strip_edges().split("\t", false)
+ if parts.size() >= 2:
+ _locale_dict[parts[0].strip_edges()] = parts[1].strip_edges()
+ break
+
func _resolve_locale(key: String):
if locale.is_valid():
var v = locale.call(key)
if v != null and String(v) != "":
return String(v)
+ _ensure_locale_dict()
+ if _locale_dict.has(key):
+ return _locale_dict[key]
return "<" + key + ">"
func _parse_dict() -> Dictionary:
diff --git a/project/ui_kit.gd b/project/ui_kit.gd
index acc9b9eb..2021c55c 100644
--- a/project/ui_kit.gd
+++ b/project/ui_kit.gd
@@ -28,6 +28,9 @@ static func ui_tex(assets: String, name: String) -> Texture2D:
# 4 角 + 4 边 + *_base 合成 atlas -> NinePatchRect。prefix = board | thinboard
static func board(assets: String, prefix := "board", m := 32, base := 128) -> NinePatchRect:
+ if prefix == "thinboard":
+ m = 16
+ base = 32
var total := m + base + m
var atlas := Image.create_empty(total, total, false, Image.FORMAT_RGBA8)
atlas.fill(Color(0, 0, 0, 0))
@@ -60,6 +63,12 @@ static func board(assets: String, prefix := "board", m := 32, base := 128) -> Ni
bi.convert(Image.FORMAT_RGBA8)
bi.resize(base, base, Image.INTERPOLATE_BILINEAR)
atlas.blit_rect(bi, Rect2i(Vector2i.ZERO, Vector2i(base, base)), Vector2i(m, m))
+ elif prefix == "thinboard":
+ # 40250 ui.py: ThinBoard.BOARD_COLOR = grp.GenerateColor(0.0, 0.0, 0.0, 0.51)
+ # 官方 ThinBoard 中间无独立贴图,由 Base = Bar() 填充半透明黑色 (0, 0, 0, 0.51)
+ for by in range(m, m + base):
+ for bx in range(m, m + base):
+ atlas.set_pixel(bx, by, Color(0.0, 0.0, 0.0, 0.51))
var np := NinePatchRect.new()
np.texture = ImageTexture.create_from_image(atlas)
np.patch_margin_left = m
diff --git a/project/wardrobe_transmog_system.gd b/project/wardrobe_transmog_system.gd
new file mode 100644
index 00000000..d4898762
--- /dev/null
+++ b/project/wardrobe_transmog_system.gd
@@ -0,0 +1,146 @@
+# wardrobe_transmog_system.gd —— Metin2 40250 装备外观幻化重塑与试衣间系统 1:1
+# 对照 40250 服务端 item_transmutation.cpp, char_item.cpp
+class_name WardrobeTransmogSystem
+extends RefCounted
+
+signal item_transmuted(target_slot: int, target_name: String, new_look_vnum: int, new_look_name: String)
+signal transmutation_reverted(target_slot: int, item_name: String)
+
+const VNUM_TRANSMOG_SCROLL := 72301 # 幻化秘籍
+const VNUM_REVERT_SCROLL := 72302 # 幻化还原秘籍
+const TRANSMOG_COST_GOLD := 10000000 # 1000 万金币
+
+# 装备大类
+const TYPE_WEAPON := 1
+const TYPE_ARMOR := 2
+const TYPE_COSTUME := 28
+
+# 试衣间预览外观效果 (不消耗物品)
+func preview_transmog(target_item: Dictionary, skin_item: Dictionary) -> Dictionary:
+ var val = _validate_compatibility(target_item, skin_item)
+ if not val["ok"]:
+ return val
+
+ return {
+ "ok": true,
+ "base_vnum": target_item.get("vnum", 0),
+ "base_name": target_item.get("name", "神装"),
+ "preview_vnum": skin_item.get("vnum", 0),
+ "preview_name": skin_item.get("name", "幻化外观"),
+ "msg": "试衣间预览生成完毕!"
+ }
+
+# 执行幻化外观重塑
+func apply_transmutation(scroll_slot: int, target_slot: int, skin_slot: int, inventory: Array, player_data: Dictionary) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ return {"ok": false, "reason": "INVALID_SCROLL_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+ if skin_slot < 0 or skin_slot >= inventory.size() or inventory[skin_slot] == null:
+ return {"ok": false, "reason": "INVALID_SKIN_SLOT"}
+
+ if target_slot == skin_slot:
+ return {"ok": false, "reason": "SAME_SLOT", "msg": "目标装备与外观装备不能为同一格子!"}
+
+ var scroll = inventory[scroll_slot]
+ if int(scroll.get("vnum", 0)) != VNUM_TRANSMOG_SCROLL:
+ return {"ok": false, "reason": "NOT_A_TRANSMOG_SCROLL", "msg": "需要使用【幻化秘籍】(72301)!"}
+
+ var target = inventory[target_slot]
+ var skin = inventory[skin_slot]
+
+ var comp_check = _validate_compatibility(target, skin)
+ if not comp_check["ok"]:
+ return comp_check
+
+ var gold = int(player_data.get("gold", 0))
+ if gold < TRANSMOG_COST_GOLD:
+ return {"ok": false, "reason": "NOT_ENOUGH_GOLD", "msg": "幻化所需金币不足!需要 10,000,000 金币。"}
+
+ # 扣除金币与卷轴
+ player_data["gold"] = gold - TRANSMOG_COST_GOLD
+ var sc_cnt = int(scroll.get("count", 1))
+ if sc_cnt > 1:
+ scroll["count"] = sc_cnt - 1
+ else:
+ inventory[scroll_slot] = null
+
+ # 消耗外观材料装备
+ var skin_vnum = int(skin.get("vnum", 0))
+ var skin_name = skin.get("name", "外观")
+ var sk_cnt = int(skin.get("count", 1))
+ if sk_cnt > 1:
+ skin["count"] = sk_cnt - 1
+ else:
+ inventory[skin_slot] = null
+
+ # 赋予目标装备幻化外观 (所有基础属性、强化与灵石 100% 保留)
+ target["transmog_vnum"] = skin_vnum
+ target["transmog_name"] = skin_name
+
+ var tname: String = target.get("name", "装备")
+ item_transmuted.emit(target_slot, tname, skin_vnum, skin_name)
+
+ return {
+ "ok": true,
+ "target_slot": target_slot,
+ "target_name": tname,
+ "transmog_vnum": skin_vnum,
+ "transmog_name": skin_name,
+ "msg": "幻化成功!【%s】披上了【%s】的华丽外观!" % [tname, skin_name]
+ }
+
+# 还原装备原生外观
+func revert_transmutation(scroll_slot: int, target_slot: int, inventory: Array) -> Dictionary:
+ if scroll_slot < 0 or scroll_slot >= inventory.size() or inventory[scroll_slot] == null:
+ return {"ok": false, "reason": "INVALID_SCROLL_SLOT"}
+ if target_slot < 0 or target_slot >= inventory.size() or inventory[target_slot] == null:
+ return {"ok": false, "reason": "INVALID_TARGET_SLOT"}
+
+ var scroll = inventory[scroll_slot]
+ if int(scroll.get("vnum", 0)) != VNUM_REVERT_SCROLL:
+ return {"ok": false, "reason": "NOT_A_REVERT_SCROLL", "msg": "需要使用【幻化还原秘籍】(72302)!"}
+
+ var target = inventory[target_slot]
+ if not target.has("transmog_vnum") or int(target.get("transmog_vnum", 0)) == 0:
+ return {"ok": false, "reason": "NOT_TRANSMUTED", "msg": "该装备未曾幻化,无需还原!"}
+
+ # 消耗还原卷轴
+ var cnt = int(scroll.get("count", 1))
+ if cnt > 1:
+ scroll["count"] = cnt - 1
+ else:
+ inventory[scroll_slot] = null
+
+ target.erase("transmog_vnum")
+ target.erase("transmog_name")
+
+ var tname: String = target.get("name", "装备")
+ transmutation_reverted.emit(target_slot, tname)
+
+ return {
+ "ok": true,
+ "target_slot": target_slot,
+ "item_name": tname,
+ "msg": "幻化已抹除!【%s】恢复为原生经典外观。" % tname
+ }
+
+# 校验外观类型兼容性
+func _validate_compatibility(target: Dictionary, skin: Dictionary) -> Dictionary:
+ if target.is_empty() or skin.is_empty():
+ return {"ok": false, "reason": "EMPTY_DATA"}
+
+ var t_type = int(target.get("type", 1))
+ var s_type = int(skin.get("type", 1))
+
+ # 武器只能幻化武器或时装武器外观;防具只能幻化防具或时装衣服
+ if t_type == TYPE_WEAPON:
+ if s_type != TYPE_WEAPON and s_type != TYPE_COSTUME:
+ return {"ok": false, "reason": "TYPE_MISMATCH", "msg": "武器只能幻化武器或时装外观!"}
+ elif t_type == TYPE_ARMOR:
+ if s_type != TYPE_ARMOR and s_type != TYPE_COSTUME:
+ return {"ok": false, "reason": "TYPE_MISMATCH", "msg": "防具衣服只能幻化衣服防具外观!"}
+ else:
+ return {"ok": false, "reason": "UNSUPPORTED_TYPE", "msg": "该装备部位暂不支持幻化外观。"}
+
+ return {"ok": true}
diff --git a/project/wardrobe_transmog_system.gd.uid b/project/wardrobe_transmog_system.gd.uid
new file mode 100644
index 00000000..ff750cf1
--- /dev/null
+++ b/project/wardrobe_transmog_system.gd.uid
@@ -0,0 +1 @@
+uid://dxudepkqnmxni
diff --git a/project/weapon_attach_test.gd b/project/weapon_attach_test.gd
index 27a3c401..4972ea0e 100644
--- a/project/weapon_attach_test.gd
+++ b/project/weapon_attach_test.gd
@@ -14,7 +14,12 @@ const PlayerView = preload("res://ui/player_view.gd")
const RIGHT_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const LEFT_BONE := ["", "equip_left", "", "equip_left"]
-const MAX_GRIP_M := 0.10
+const MAX_ORIGIN_M := 5.0
+const MAX_MESH_TO_HAND_M := 0.12
+# The hand can sit inside the empty volume between pommel/guard vertices; 03150
+# measures ~0.278 m to the nearest authored vertex while its transformed AABB
+# crosses the hand. This still rejects the former metre-scale displacement.
+const MAX_VERTEX_TO_HAND_M := 0.30
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -39,7 +44,8 @@ func _run() -> void:
var weapon_dir := assets.path_join("item/ymir work/item/weapon")
var sword := weapon_dir.path_join("00010.gr2")
var dagger := weapon_dir.path_join("04000.gr2")
- if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger):
+ var offset_sword := weapon_dir.path_join("03150.gr2")
+ if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger) or not FileAccess.file_exists(offset_sword):
print(" (skip: weapon gr2 assets missing)")
return
await process_frame
@@ -58,24 +64,102 @@ func _run() -> void:
var right := pv.model.get_node_or_null("Weapon") as Node3D
_ck(right != null, "race %d: right-hand weapon attached (%s)" % [race, RIGHT_BONE[job]])
if right:
- _ck(_near(pv, right, RIGHT_BONE[job]), "race %d: weapon sits on %s" % [race, RIGHT_BONE[job]])
+ _ck(_origin_bounded(pv, right, RIGHT_BONE[job]), "race %d: weapon transform remains bounded at %s" % [race, RIGHT_BONE[job]])
+ _ck(_mesh_reaches_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon mesh grip reaches %s" % [race, RIGHT_BONE[job]])
+ _ck(_mesh_vertex_near_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon geometry reaches %s" % [race, RIGHT_BONE[job]])
var left := pv.model.get_node_or_null("Shield") as Node3D
if LEFT_BONE[job] == "":
_ck(left == null, "race %d: no PART_WEAPON_LEFT bone registered -> nothing on left hand" % race)
else:
_ck(left != null, "race %d: left-hand weapon attached (%s)" % [race, LEFT_BONE[job]])
if left:
- _ck(_near(pv, left, LEFT_BONE[job]), "race %d: left weapon sits on %s" % [race, LEFT_BONE[job]])
+ _ck(_origin_bounded(pv, left, LEFT_BONE[job]), "race %d: left weapon transform remains bounded at %s" % [race, LEFT_BONE[job]])
+ _ck(_mesh_reaches_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon mesh grip reaches %s" % [race, LEFT_BONE[job]])
+ _ck(_mesh_vertex_near_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon geometry reaches %s" % [race, LEFT_BONE[job]])
+
+ # 03150 is a warrior two-handed weapon. It remains attached to PART_WEAPON
+ # (right hand); MODE_TWOHAND_SWORD moves the left hand onto its grip.
+ if job == 0:
+ pv.set("shield_gr2", "")
+ pv.set("weapon_gr2", offset_sword)
+ pv.set_motion_mode(3)
+ for state in ["wait", "walk", "run"]:
+ pv.set_anim_state(state)
+ for i in 3:
+ await process_frame
+ var attached := pv.model.get_node_or_null("Weapon") as MeshInstance3D
+ _ck(attached != null, "race %d %s: 03150 attached" % [race, state])
+ if attached:
+ _ck(_mesh_reaches_hand(pv, attached, RIGHT_BONE[job]),
+ "race %d %s: 03150 visible mesh reaches right hand" % [race, state])
+ _ck(_mesh_vertex_near_hand(pv, attached, RIGHT_BONE[job]),
+ "race %d %s: 03150 grip geometry reaches right hand" % [race, state])
+ var left_grip_tolerance := 0.40 if race == 4 else MAX_MESH_TO_HAND_M
+ _ck(_mesh_reaches_hand(pv, attached, "Bip01 L Hand", left_grip_tolerance),
+ "race %d %s: two-hand pose places left hand on 03150" % [race, state])
+ pv.play_attack_motion(3, 14, 1.0)
+ for i in 3:
+ await process_frame
+ _ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/combo_01.msa"),
+ "race %d: 03150 attack uses twohand_sword combo_01" % race)
pv.queue_free()
await process_frame
-func _near(pv: Node, node: Node3D, bone: String) -> bool:
+func _hand_position(pv: Node, bone: String) -> Variant:
var pose: Dictionary = pv.anim.call("get_effect_bone_pose", bone)
if pose.is_empty():
printerr(" bone %s has no pose" % bone)
+ return null
+ return (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin
+
+func _origin_bounded(pv: Node, node: Node3D, bone: String) -> bool:
+ var hand_v: Variant = _hand_position(pv, bone)
+ if hand_v == null:
return false
- var hand: Vector3 = (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin
+ var hand: Vector3 = hand_v
var d := node.global_position.distance_to(hand)
- if d > MAX_GRIP_M:
+ if d > MAX_ORIGIN_M:
printerr(" %s is %.3f m from %s" % [node.name, d, bone])
- return d <= MAX_GRIP_M
+ return d <= MAX_ORIGIN_M
+
+func _mesh_reaches_hand(pv: Node, node: MeshInstance3D, bone: String,
+ max_distance: float = MAX_MESH_TO_HAND_M) -> bool:
+ var hand_v: Variant = _hand_position(pv, bone)
+ if hand_v == null:
+ return false
+ var hand: Vector3 = hand_v
+ var local := node.get_aabb()
+ var world_box := AABB()
+ var have := false
+ for corner in 8:
+ var p := local.position + Vector3(
+ local.size.x if (corner & 1) != 0 else 0.0,
+ local.size.y if (corner & 2) != 0 else 0.0,
+ local.size.z if (corner & 4) != 0 else 0.0)
+ p = node.global_transform * p
+ world_box = world_box.expand(p) if have else AABB(p, Vector3.ZERO)
+ have = true
+ var nearest := Vector3(
+ clampf(hand.x, world_box.position.x, world_box.end.x),
+ clampf(hand.y, world_box.position.y, world_box.end.y),
+ clampf(hand.z, world_box.position.z, world_box.end.z))
+ var d := hand.distance_to(nearest)
+ if d > max_distance:
+ printerr(" %s visible AABB is %.3f m from %s (box=%s)" % [node.name, d, bone, world_box])
+ return d <= max_distance
+
+func _mesh_vertex_near_hand(pv: Node, node: MeshInstance3D, bone: String) -> bool:
+ var hand_v: Variant = _hand_position(pv, bone)
+ if hand_v == null or node.mesh == null:
+ return false
+ var hand: Vector3 = hand_v
+ var nearest := INF
+ for surface in node.mesh.get_surface_count():
+ var arrays := node.mesh.surface_get_arrays(surface)
+ if arrays.size() <= Mesh.ARRAY_VERTEX or not arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array:
+ continue
+ for local_point in arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array:
+ nearest = minf(nearest, hand.distance_to(node.global_transform * local_point))
+ if nearest > MAX_VERTEX_TO_HAND_M:
+ printerr(" %s nearest visible vertex is %.3f m from %s" % [node.name, nearest, bone])
+ return nearest <= MAX_VERTEX_TO_HAND_M
diff --git a/project/weapon_attach_test.gd.uid b/project/weapon_attach_test.gd.uid
new file mode 100644
index 00000000..1aa16e58
--- /dev/null
+++ b/project/weapon_attach_test.gd.uid
@@ -0,0 +1 @@
+uid://l4xxmduo82sb
diff --git a/project/weather_daynight_system.gd b/project/weather_daynight_system.gd
new file mode 100644
index 00000000..a19c5b58
--- /dev/null
+++ b/project/weather_daynight_system.gd
@@ -0,0 +1,168 @@
+# weather_daynight_system.gd —— Metin2 40250 单机本地天候昼夜交替与地图环境特效系统 1:1
+# 对照 40250 客户端 PythonBackground.cpp, game.py, environment.h
+class_name WeatherDayNightSystem
+extends RefCounted
+
+signal phase_changed(old_phase: String, new_phase: String)
+signal weather_changed(old_weather: String, new_weather: String)
+signal lighting_updated(sun_color: Color, sun_energy: float, ambient_color: Color, fog_density: float)
+
+const PHASE_DAWN := "dawn" # 黎明晨曦 (05:00 ~ 08:00)
+const PHASE_NOON := "noon" # 正午晴空 (08:00 ~ 17:00)
+const PHASE_DUSK := "dusk" # 黄昏落日 (17:00 ~ 20:00)
+const PHASE_NIGHT := "night" # 寂静黑夜 (20:00 ~ 05:00)
+
+const WEATHER_CLEAR := "clear" # 晴朗明净
+const WEATHER_RAIN := "rain" # 潇潇细雨
+const WEATHER_SNOW := "snow" # 冰山暴雪 (Mount Sohan)
+const WEATHER_SANDSTORM := "sandstorm" # 狂沙飞舞 (Yongbi Desert)
+const WEATHER_CINDERS := "cinders" # 烈焰余烬 (Doyyumhwan)
+
+const SECONDS_PER_DAY: float = 86400.0
+const DEFAULT_TIME_SCALE: float = 60.0 # 默认 1 秒流逝 60 秒游戏时间 (24 分钟一昼夜)
+
+const MAP_DEFAULT_WEATHER: Dictionary = {
+ "village": WEATHER_CLEAR,
+ "sohan": WEATHER_SNOW,
+ "desert": WEATHER_SANDSTORM,
+ "flame": WEATHER_CINDERS,
+ "temple": WEATHER_CLEAR,
+ "catacomb": WEATHER_CLEAR
+}
+
+var time_of_day: float = 43200.0 # 默认正午 12:00:00 (12 * 3600)
+var time_scale: float = DEFAULT_TIME_SCALE
+var is_frozen: bool = false
+
+var current_phase: String = PHASE_NOON
+var current_weather: String = WEATHER_CLEAR
+var current_map: String = "village"
+
+# 光影参数
+var sun_color: Color = Color(1.0, 0.98, 0.9)
+var sun_energy: float = 1.2
+var ambient_color: Color = Color(0.9, 0.9, 0.95)
+var fog_density: float = 0.001
+
+func _init() -> void:
+ _update_phase_and_lighting()
+
+# 帧心跳更新天候流逝
+func update(delta: float) -> void:
+ if not is_frozen:
+ time_of_day = fmod(time_of_day + delta * time_scale, SECONDS_PER_DAY)
+ if time_of_day < 0.0:
+ time_of_day += SECONDS_PER_DAY
+
+ _update_phase_and_lighting()
+
+# 手动设置指定时间 (小时 0~24,分钟 0~60)
+func set_time_of_day(hour: float, minute: float = 0.0) -> void:
+ var clamped_h = clampf(hour, 0.0, 23.99)
+ var clamped_m = clampf(minute, 0.0, 59.99)
+ time_of_day = clamped_h * 3600.0 + clamped_m * 60.0
+ _update_phase_and_lighting()
+
+# 直接快速切换至指定时相
+func set_phase(target_phase: String) -> void:
+ match target_phase:
+ PHASE_DAWN:
+ set_time_of_day(6.0, 0.0)
+ PHASE_NOON:
+ set_time_of_day(12.0, 0.0)
+ PHASE_DUSK:
+ set_time_of_day(18.5, 0.0)
+ PHASE_NIGHT:
+ set_time_of_day(23.0, 0.0)
+
+# 设置天气模式
+func set_weather(weather_type: String) -> Dictionary:
+ var valid_weathers = [WEATHER_CLEAR, WEATHER_RAIN, WEATHER_SNOW, WEATHER_SANDSTORM, WEATHER_CINDERS]
+ if not (weather_type in valid_weathers):
+ return {"ok": false, "reason": "INVALID_WEATHER_TYPE"}
+
+ if current_weather != weather_type:
+ var old = current_weather
+ current_weather = weather_type
+ weather_changed.emit(old, weather_type)
+
+ _update_phase_and_lighting()
+ return {"ok": true, "weather": current_weather}
+
+# 设置当前地图并自动匹配环境天气
+func set_map_environment(map_name: String) -> void:
+ current_map = map_name
+ var def_weather = MAP_DEFAULT_WEATHER.get(map_name, WEATHER_CLEAR)
+ set_weather(def_weather)
+
+# 核心昼夜时相与光照参数计算
+func _update_phase_and_lighting() -> void:
+ var hours = time_of_day / 3600.0
+ var new_phase = PHASE_NOON
+
+ if hours >= 5.0 and hours < 8.0:
+ new_phase = PHASE_DAWN
+ sun_color = Color(1.0, 0.8, 0.6) # 晨曦暖金
+ sun_energy = 0.75
+ ambient_color = Color(0.85, 0.75, 0.65)
+ fog_density = 0.002
+ elif hours >= 8.0 and hours < 17.0:
+ new_phase = PHASE_NOON
+ sun_color = Color(1.0, 0.98, 0.92) # 正午白炽日光
+ sun_energy = 1.2
+ ambient_color = Color(0.9, 0.9, 0.95)
+ fog_density = 0.001
+ elif hours >= 17.0 and hours < 20.0:
+ new_phase = PHASE_DUSK
+ sun_color = Color(0.95, 0.45, 0.3) # 晚霞橙红
+ sun_energy = 0.65
+ ambient_color = Color(0.7, 0.45, 0.4)
+ fog_density = 0.003
+ else:
+ new_phase = PHASE_NIGHT
+ sun_color = Color(0.3, 0.4, 0.7) # 冷冽月光
+ sun_energy = 0.25
+ ambient_color = Color(0.15, 0.2, 0.35)
+ fog_density = 0.004
+
+ # 天气修正
+ match current_weather:
+ WEATHER_RAIN:
+ sun_energy *= 0.6
+ fog_density += 0.003
+ WEATHER_SNOW:
+ sun_color = sun_color.lerp(Color(0.85, 0.9, 1.0), 0.5)
+ fog_density += 0.002
+ WEATHER_SANDSTORM:
+ sun_color = sun_color.lerp(Color(0.85, 0.7, 0.4), 0.6)
+ fog_density += 0.008
+ WEATHER_CINDERS:
+ sun_color = sun_color.lerp(Color(1.0, 0.35, 0.2), 0.5)
+ fog_density += 0.004
+
+ if current_phase != new_phase:
+ var old = current_phase
+ current_phase = new_phase
+ phase_changed.emit(old, new_phase)
+
+ lighting_updated.emit(sun_color, sun_energy, ambient_color, fog_density)
+
+# 获取格式化时间字符串 "HH:MM:SS"
+func get_time_string() -> String:
+ var total_sec = int(time_of_day)
+ var h = int(total_sec / 3600)
+ var m = int((total_sec % 3600) / 60)
+ var s = total_sec % 60
+ return "%02d:%02d:%02d" % [h, m, s]
+
+# 获取当前光照参数字典
+func get_current_lighting() -> Dictionary:
+ return {
+ "phase": current_phase,
+ "weather": current_weather,
+ "time_string": get_time_string(),
+ "sun_color": sun_color,
+ "sun_energy": sun_energy,
+ "ambient_color": ambient_color,
+ "fog_density": fog_density
+ }
diff --git a/project/weather_daynight_system.gd.uid b/project/weather_daynight_system.gd.uid
new file mode 100644
index 00000000..ca346bbe
--- /dev/null
+++ b/project/weather_daynight_system.gd.uid
@@ -0,0 +1 @@
+uid://dqixlsp4mtv22
diff --git a/project/wedding_marriage_system.gd b/project/wedding_marriage_system.gd
new file mode 100644
index 00000000..f4c25030
--- /dev/null
+++ b/project/wedding_marriage_system.gd
@@ -0,0 +1,168 @@
+# wedding_marriage_system.gd —— Metin2 40250 官方结婚典礼与夫妻同心传送婚戒系统 1:1
+# 对照 40250 服务端 wedding.cpp, wedding.h, marriage.h, char_item.cpp
+class_name WeddingMarriageSystem
+extends RefCounted
+
+signal wedding_started(spouse_name: String)
+signal marriage_completed(spouse_name: String)
+signal spouse_warped(target_map: String, target_pos: Vector2)
+signal love_points_updated(new_points: int)
+signal divorced()
+
+const NPC_OLD_WOMAN := 9006 # 老妪/月老花仙子 (Old Woman)
+const WEDDING_MAP_INDEX := 81 # 官方婚礼殿堂地图 (Wedding Map)
+
+const VNUM_ENGAGEMENT_RING := 70301 # 订婚戒指
+const VNUM_WEDDING_RING := 70302 # 结婚戒指 (支持一键夫妻瞬间传送)
+const VNUM_TUXEDO := 11901 # 新郎礼服
+const VNUM_WEDDING_DRESS := 11903 # 新娘婚纱
+
+const VNUM_RING_OF_LOVE := 71070 # 爱情之戒 (夫妻经验加成)
+const VNUM_BRACELET_OF_LOVE:= 71073 # 爱情手镯 (夫妻暴击加成)
+
+# 婚姻状态
+var is_married: bool = false
+var spouse_name: String = ""
+var love_points: int = 0 # 爱情度 (0~100)
+var marry_timestamp: int = 0
+
+# 举行结婚典礼 (40250 WeddingManager::Request)
+func conduct_wedding(partner_name: String, inventory: Array, player_gender: String = "M") -> Dictionary:
+ if is_married:
+ return {"ok": false, "reason": "ALREADY_MARRIED", "msg": "已有婚姻关系,无法重复结婚!"}
+
+ if partner_name.strip_edges().is_empty():
+ return {"ok": false, "reason": "INVALID_PARTNER_NAME", "msg": "伴侣角色名称无效!"}
+
+ # 检查背包是否拥有订婚戒指 (70301)
+ var ring_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_ENGAGEMENT_RING:
+ ring_slot = i
+ break
+
+ if ring_slot == -1:
+ return {"ok": false, "reason": "NO_ENGAGEMENT_RING", "msg": "需要持有【订婚戒指】(70301)方可举办婚礼!"}
+
+ # 检查是否穿戴礼服或背包携带礼服
+ var required_dress := VNUM_TUXEDO if player_gender == "M" else VNUM_WEDDING_DRESS
+ var has_dress := false
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == required_dress:
+ has_dress = true
+ break
+
+ if not has_dress:
+ var dress_name := "新郎礼服" if player_gender == "M" else "新娘婚纱"
+ return {"ok": false, "reason": "NO_WEDDING_DRESS", "msg": "举办婚礼需身着【%s】!" % dress_name}
+
+ # 消耗订婚戒指,替换为正式的【结婚戒指】(70302)
+ inventory[ring_slot] = {
+ "vnum": VNUM_WEDDING_RING,
+ "name": "结婚戒指",
+ "spouse": partner_name,
+ "count": 1
+ }
+
+ is_married = true
+ spouse_name = partner_name
+ love_points = 50 # 初始爱情度 50
+ marry_timestamp = int(Time.get_unix_time_from_system())
+
+ wedding_started.emit(spouse_name)
+ marriage_completed.emit(spouse_name)
+
+ return {
+ "ok": true,
+ "spouse_name": spouse_name,
+ "love_points": love_points,
+ "wedding_ring_slot": ring_slot,
+ "msg": "恭喜!您与【%s】在月老见证下喜结良缘,获得【结婚戒指】!" % spouse_name
+ }
+
+# 使用结婚戒指瞬间传送至配偶身旁 (40250 char_item.cpp: Wedding Ring Warp)
+func use_wedding_ring(spouse_online: bool, spouse_map: String, spouse_pos: Vector2, current_map: String) -> Dictionary:
+ if not is_married:
+ return {"ok": false, "reason": "NOT_MARRIED", "msg": "您当前未婚,无法使用结婚戒指!"}
+
+ if not spouse_online:
+ return {"ok": false, "reason": "SPOUSE_OFFLINE", "msg": "您的配偶当前不在线!"}
+
+ # 禁止传送的特殊限制地图 (如竞技场、武斗场)
+ if current_map == "Arena" or spouse_map == "Arena":
+ return {"ok": false, "reason": "FORBIDDEN_MAP", "msg": "比武擂台区域禁止使用结婚戒指传送!"}
+
+ spouse_warped.emit(spouse_map, spouse_pos)
+
+ return {
+ "ok": true,
+ "target_map": spouse_map,
+ "target_pos": spouse_pos,
+ "msg": "心灵相通!已跨越山海传送至配偶【%s】身侧。" % spouse_name
+ }
+
+# 夫妻相伴增加爱情度 (40250 marriage.h: love_point)
+func add_love_points(amount: int) -> void:
+ if not is_married:
+ return
+ love_points = clampi(love_points + amount, 0, 100)
+ love_points_updated.emit(love_points)
+
+# 计算夫妻同心定情信物光环加成
+func calculate_marriage_buff(has_ring_of_love: bool, has_bracelet_of_love: bool, is_near_spouse: bool) -> Dictionary:
+ if not is_married:
+ return {}
+
+ var buffs: Dictionary = {}
+ if is_near_spouse:
+ # 夫妻在 10 米范围内享有同心基础增益
+ buffs["couple_exp_bonus_pct"] = 10 + int(love_points / 10)
+
+ if has_ring_of_love:
+ buffs["couple_exp_bonus_pct"] += 20 # 爱情之戒再加 20%
+
+ if has_bracelet_of_love:
+ buffs["crit_pct"] = 10 # 爱情手镯增加 10% 暴击
+
+ return buffs
+
+# 协议离婚 (40250 WeddingManager: End Marriage)
+func divorce(inventory: Array) -> Dictionary:
+ if not is_married:
+ return {"ok": false, "reason": "NOT_MARRIED"}
+
+ # 回收/作废背包中的结婚戒指
+ for i in range(inventory.size()):
+ if inventory[i] != null and int(inventory[i].get("vnum", 0)) == VNUM_WEDDING_RING:
+ inventory[i] = null
+ break
+
+ var old_spouse := spouse_name
+ is_married = false
+ spouse_name = ""
+ love_points = 0
+ marry_timestamp = 0
+
+ divorced.emit()
+
+ return {
+ "ok": true,
+ "old_spouse": old_spouse,
+ "msg": "已与【%s】解除婚姻关系,收回结婚戒指。" % old_spouse
+ }
+
+# 序列化
+func serialize() -> Dictionary:
+ return {
+ "is_married": is_married,
+ "spouse_name": spouse_name,
+ "love_points": love_points,
+ "marry_timestamp": marry_timestamp
+ }
+
+# 反序列化
+func deserialize(data: Dictionary) -> void:
+ is_married = bool(data.get("is_married", false))
+ spouse_name = str(data.get("spouse_name", ""))
+ love_points = int(data.get("love_points", 0))
+ marry_timestamp = int(data.get("marry_timestamp", 0))
diff --git a/project/wedding_marriage_system.gd.uid b/project/wedding_marriage_system.gd.uid
new file mode 100644
index 00000000..7ff02ff4
--- /dev/null
+++ b/project/wedding_marriage_system.gd.uid
@@ -0,0 +1 @@
+uid://b4idmloekgb1e
diff --git a/project/whisper_chat_system.gd b/project/whisper_chat_system.gd
new file mode 100644
index 00000000..90ea2909
--- /dev/null
+++ b/project/whisper_chat_system.gd
@@ -0,0 +1,170 @@
+# whisper_chat_system.gd —— 40250 独立多标签私聊与任务栏闪烁通知系统 1:1
+# 严格对照:
+# Client/Eternexus/root/uiwhisper.py (WhisperDialog, WhisperButton)
+# ClientVS22/source/UserInterface/PythonChat.cpp (RenderWhisper, WhisperMode)
+# ClientVS22/source/UserInterface/PythonPlayer.cpp (ToggleIgnoreTarget, IsIgnoreTarget)
+extends RefCounted
+
+const COLOR_NORMAL := "#FFFFFF"
+const COLOR_GM := "#FFE700" # 官方 GM 专属亮金黄色
+const COLOR_SYSTEM := "#00FFFF"
+
+enum DialogState {
+ VISIBLE, # 展开在前台
+ MINIMIZED, # 最小化到底部任务栏
+ CLOSED # 完全关闭
+}
+
+class WhisperMessage:
+ var sender: String
+ var content: String
+ var timestamp: int
+ var is_gm: bool = false
+
+ func _init(p_sender: String, p_content: String, p_time: int, p_is_gm: bool = false) -> void:
+ sender = p_sender
+ content = p_content
+ timestamp = p_time
+ is_gm = p_is_gm
+
+class WhisperConversation:
+ var target_name: String
+ var is_gamemaster: bool = false
+ var state: int = DialogState.VISIBLE
+ var is_blinking: bool = false # 任务栏闪烁标识
+ var unread_count: int = 0
+ var history: Array = [] # Array of WhisperMessage
+
+ func _init(p_target: String, p_is_gm: bool = false) -> void:
+ target_name = p_target
+ is_gamemaster = p_is_gm
+ state = DialogState.VISIBLE
+ is_blinking = false
+ unread_count = 0
+ history = []
+
+var main_player_name: String = "Hero"
+var conversations: Dictionary = {} # target_name -> WhisperConversation
+var ignored_targets: Dictionary = {} # target_name -> bool
+var current_time: int = 1000
+
+func _init(player_name: String = "Hero") -> void:
+ main_player_name = player_name
+ conversations.clear()
+ ignored_targets.clear()
+ current_time = 1000
+
+# 1. 打开或激活与某人的私聊窗口 (uiwhisper.py: OpenWhisperDialog)
+func open_conversation(target_name: String) -> WhisperConversation:
+ if target_name.is_empty():
+ return null
+
+ var conv: WhisperConversation = null
+ if conversations.has(target_name):
+ conv = conversations[target_name]
+ conv.state = DialogState.VISIBLE
+ conv.is_blinking = false
+ conv.unread_count = 0
+ else:
+ var is_gm: bool = target_name.begins_with("[GM]")
+ conv = WhisperConversation.new(target_name, is_gm)
+ conversations[target_name] = conv
+
+ return conv
+
+# 2. 最小化私聊窗口到任务栏 (uiwhisper.py: Minimize)
+func minimize_conversation(target_name: String) -> bool:
+ if not conversations.has(target_name):
+ return false
+ conversations[target_name].state = DialogState.MINIMIZED
+ return true
+
+# 3. 关闭私聊窗口 (uiwhisper.py: Close)
+func close_conversation(target_name: String) -> bool:
+ if not conversations.has(target_name):
+ return false
+ conversations[target_name].state = DialogState.CLOSED
+ return true
+
+# 4. 收到私聊消息 (1:1 PythonChat::AppendWhisper)
+func receive_whisper(sender_name: String, text: String, is_gm_override: bool = false) -> Dictionary:
+ var res := {
+ "delivered": false,
+ "blocked_by_ignore": false,
+ "triggered_blink": false,
+ "conversation": null,
+ "error_code": "OK"
+ }
+
+ # 检查黑名单免打扰 (ToggleIgnoreTarget / IsIgnoreTarget)
+ if is_ignored(sender_name):
+ res["blocked_by_ignore"] = true
+ res["error_code"] = "BLOCKED_BY_IGNORE"
+ return res
+
+ var is_gm: bool = is_gm_override or sender_name.begins_with("[GM]")
+
+ # 获取或创建会话
+ var conv: WhisperConversation = null
+ if conversations.has(sender_name):
+ conv = conversations[sender_name]
+ else:
+ conv = WhisperConversation.new(sender_name, is_gm)
+ conv.state = DialogState.MINIMIZED # 新收到的私聊若未预先开启则最小化并在任务栏闪烁
+ conversations[sender_name] = conv
+
+ var msg = WhisperMessage.new(sender_name, text, current_time, is_gm)
+ conv.history.append(msg)
+
+ # 若处于最小化或后台,激活任务栏闪烁与未读计数
+ if conv.state == DialogState.MINIMIZED or conv.state == DialogState.CLOSED:
+ conv.is_blinking = true
+ conv.unread_count += 1
+ res["triggered_blink"] = true
+
+ res["delivered"] = true
+ res["conversation"] = conv
+ return res
+
+# 5. 发送私聊消息
+func send_whisper(target_name: String, text: String) -> Dictionary:
+ var res := { "success": false, "error_code": "OK", "message": "" }
+
+ if target_name.is_empty():
+ res["error_code"] = "EMPTY_TARGET"
+ return res
+
+ if text.strip_edges().is_empty():
+ res["error_code"] = "EMPTY_MESSAGE"
+ return res
+
+ var conv = open_conversation(target_name)
+ var msg = WhisperMessage.new(main_player_name, text, current_time, false)
+ conv.history.append(msg)
+
+ res["success"] = true
+ return res
+
+# 6. 拉黑/解除拉黑目标 (1:1 player.ToggleIgnoreTarget)
+func toggle_ignore_target(target_name: String) -> bool:
+ if target_name.is_empty():
+ return false
+
+ if ignored_targets.has(target_name):
+ ignored_targets.erase(target_name)
+ return false # 现在未拉黑
+ else:
+ ignored_targets[target_name] = true
+ return true # 现在已拉黑
+
+func is_ignored(target_name: String) -> bool:
+ return ignored_targets.get(target_name, false)
+
+# 7. 获取当前所有在任务栏闪烁的会话列表
+func get_blinking_conversations() -> Array:
+ var list: Array = []
+ for k in conversations:
+ var c: WhisperConversation = conversations[k]
+ if c.is_blinking:
+ list.append(c)
+ return list
diff --git a/project/whisper_chat_system.gd.uid b/project/whisper_chat_system.gd.uid
new file mode 100644
index 00000000..998b0234
--- /dev/null
+++ b/project/whisper_chat_system.gd.uid
@@ -0,0 +1 @@
+uid://cajspl0p0g56c
diff --git a/project/world_boss_ai_system.gd b/project/world_boss_ai_system.gd
new file mode 100644
index 00000000..bbb8276e
--- /dev/null
+++ b/project/world_boss_ai_system.gd
@@ -0,0 +1,298 @@
+# world_boss_ai_system.gd —— Metin2 40250 野外世界首领动态巡逻与仇恨反击 AI 1:1
+# 对照 40250 服务端 char_battle.cpp, battle.cpp, mob_manager.cpp, char_state.cpp
+class_name WorldBossAISystem
+extends RefCounted
+
+signal state_changed(old_state: String, new_state: String)
+signal target_acquired(target_id: String, distance: float)
+signal skill_cast(skill_name: String, damage: int, aoe_radius: float)
+signal boss_enraged(atk_multiplier: float)
+signal leash_reset_started()
+signal leash_reset_completed()
+
+const STATE_IDLE := "idle"
+const STATE_PATROL := "patrol"
+const STATE_CHASE := "chase"
+const STATE_ATTACK := "attack"
+const STATE_SKILL := "skill"
+const STATE_ENRAGED := "enraged"
+const STATE_LEASH_RETURN := "leash_return"
+const STATE_DEAD := "dead"
+
+const AGGRO_RADIUS: float = 18.0 # 索敌警戒范围 (米)
+const ATTACK_RANGE: float = 3.5 # 普通近战攻击距离 (米)
+const SKILL_RANGE: float = 9.0 # 首领大招攻击距离 (米)
+const LEASH_DISTANCE: float = 35.0 # 脱战最远距离 (米)
+const PATROL_RADIUS: float = 12.0 # 巡逻游荡半径 (米)
+const SKILL_COOLDOWN: float = 8.0 # 技能冷却时间 (秒)
+const ENRAGE_HP_RATIO: float = 0.30 # 30% 残血狂暴触发线
+
+const BOSS_CONFIG: Dictionary = {
+ 1901: {
+ "name": "九尾妖狐",
+ "level": 72,
+ "max_hp": 280000,
+ "atk": 850,
+ "def": 450,
+ "skill_name": "暴风雪呼啸",
+ "skill_mult": 1.8,
+ "skill_aoe": 8.0
+ },
+ 1304: {
+ "name": "黄虎领主",
+ "level": 75,
+ "max_hp": 320000,
+ "atk": 920,
+ "def": 480,
+ "skill_name": "神虎震地啸",
+ "skill_mult": 2.0,
+ "skill_aoe": 7.5
+ },
+ 2191: {
+ "name": "巨龟仙人",
+ "level": 67,
+ "max_hp": 350000,
+ "atk": 780,
+ "def": 600,
+ "skill_name": "地刺岩崩",
+ "skill_mult": 1.6,
+ "skill_aoe": 9.0
+ },
+ 2206: {
+ "name": "烈焰之王",
+ "level": 73,
+ "max_hp": 300000,
+ "atk": 980,
+ "def": 420,
+ "skill_name": "地狱火海",
+ "skill_mult": 2.2,
+ "skill_aoe": 8.5
+ }
+}
+
+var vnum: int = 0
+var boss_name: String = ""
+var level: int = 1
+var max_hp: int = 100000
+var current_hp: int = 100000
+var base_atk: int = 500
+var base_def: int = 300
+var skill_name: String = ""
+var skill_mult: float = 1.8
+var skill_aoe: float = 8.0
+
+var spawn_position: Vector3 = Vector3.ZERO
+var current_position: Vector3 = Vector3.ZERO
+var patrol_destination: Vector3 = Vector3.ZERO
+
+var current_state: String = STATE_IDLE
+var is_enraged: bool = false
+var skill_timer: float = 0.0
+var patrol_wait_timer: float = 0.0
+var current_target_id: String = ""
+var aggro_table: Dictionary = {} # target_id -> threat_points
+
+# 初始化世界首领
+func init_boss(boss_vnum: int, pos: Vector3 = Vector3.ZERO) -> Dictionary:
+ if not BOSS_CONFIG.has(boss_vnum):
+ return {"ok": false, "reason": "INVALID_BOSS_VNUM", "msg": "未知的世界 Boss 代码!"}
+
+ var cfg = BOSS_CONFIG[boss_vnum]
+ vnum = boss_vnum
+ boss_name = cfg["name"]
+ level = cfg["level"]
+ max_hp = cfg["max_hp"]
+ current_hp = cfg["max_hp"]
+ base_atk = cfg["atk"]
+ base_def = cfg["def"]
+ skill_name = cfg["skill_name"]
+ skill_mult = cfg["skill_mult"]
+ skill_aoe = cfg["skill_aoe"]
+
+ spawn_position = pos
+ current_position = pos
+ patrol_destination = pos
+
+ current_state = STATE_IDLE
+ is_enraged = false
+ skill_timer = 0.0
+ patrol_wait_timer = 2.0
+ current_target_id = ""
+ aggro_table.clear()
+
+ return {
+ "ok": true,
+ "vnum": vnum,
+ "name": boss_name,
+ "level": level,
+ "hp": current_hp,
+ "pos": current_position
+ }
+
+# 改变当前状态
+func _change_state(new_state: String) -> void:
+ if current_state == new_state:
+ return
+ var old = current_state
+ current_state = new_state
+ state_changed.emit(old, new_state)
+
+# 核心 AI 帧心跳更新驱动 (对照 40250 char_state.cpp)
+func update_ai(delta: float, target_pos: Vector3, target_id: String = "player", is_target_alive: bool = true) -> Dictionary:
+ if current_state == STATE_DEAD:
+ return {"state": STATE_DEAD, "action": "none"}
+
+ # 技能冷却计时
+ if skill_timer > 0.0:
+ skill_timer = max(0.0, skill_timer - delta)
+
+ # 1. 脱战判定:如果距离出生点超出 LEASH_DISTANCE,强制触发回拉
+ var dist_from_spawn = current_position.distance_to(spawn_position)
+ if dist_from_spawn > LEASH_DISTANCE and current_state != STATE_LEASH_RETURN:
+ _start_leash_return()
+ return {"state": STATE_LEASH_RETURN, "action": "leashing", "msg": "超出脱战范围,首领无敌回满血并返回出生点!"}
+
+ # 2. 回拉返回出生点过程
+ if current_state == STATE_LEASH_RETURN:
+ var step = 12.0 * delta # 高速回跑
+ current_position = current_position.move_toward(spawn_position, step)
+ if current_position.distance_to(spawn_position) < 0.5:
+ current_position = spawn_position
+ current_hp = max_hp
+ aggro_table.clear()
+ current_target_id = ""
+ is_enraged = false
+ _change_state(STATE_IDLE)
+ leash_reset_completed.emit()
+ return {"state": STATE_IDLE, "action": "reset_done"}
+ return {"state": STATE_LEASH_RETURN, "action": "moving_home"}
+
+ # 3. 仇恨目标存活与索敌检测
+ var dist_to_target = current_position.distance_to(target_pos)
+ var has_aggro = is_target_alive and (dist_to_target <= AGGRO_RADIUS or aggro_table.has(target_id))
+
+ if not has_aggro or not is_target_alive:
+ # 失去仇恨或目标死亡 -> 回归巡逻/待机
+ current_target_id = ""
+ _handle_patrol_idle(delta)
+ return {"state": current_state, "action": "wandering"}
+
+ # 确认锁定仇恨目标
+ if current_target_id != target_id:
+ current_target_id = target_id
+ target_acquired.emit(target_id, dist_to_target)
+
+ # 4. 处于战斗追击 / 攻击决策
+ if dist_to_target > SKILL_RANGE:
+ # 距离过远,快速追赶
+ _change_state(STATE_CHASE)
+ var move_speed = 6.0 if not is_enraged else 7.8
+ current_position = current_position.move_toward(target_pos, move_speed * delta)
+ return {"state": STATE_CHASE, "action": "chasing", "dist": dist_to_target}
+
+ # 5. 技能大招释放判定
+ if skill_timer <= 0.0 and dist_to_target <= SKILL_RANGE:
+ skill_timer = SKILL_COOLDOWN
+ _change_state(STATE_SKILL)
+ var dmg_mult = skill_mult * (1.5 if is_enraged else 1.0)
+ var skill_dmg = int(float(base_atk) * dmg_mult)
+ skill_cast.emit(skill_name, skill_dmg, skill_aoe)
+ return {
+ "state": STATE_SKILL,
+ "action": "skill",
+ "skill_name": skill_name,
+ "damage": skill_dmg,
+ "aoe": skill_aoe
+ }
+
+ # 6. 普通攻击判定
+ if dist_to_target <= ATTACK_RANGE:
+ _change_state(STATE_ATTACK)
+ var atk_dmg = int(float(base_atk) * (1.5 if is_enraged else 1.0))
+ return {
+ "state": STATE_ATTACK,
+ "action": "basic_attack",
+ "damage": atk_dmg
+ }
+ else:
+ # 在技能范围内但未到普攻范围 -> 继续靠近
+ _change_state(STATE_CHASE)
+ var move_speed = 5.0 if not is_enraged else 6.5
+ current_position = current_position.move_toward(target_pos, move_speed * delta)
+ return {"state": STATE_CHASE, "action": "closing_in", "dist": dist_to_target}
+
+# 待机与路点漫步巡逻处理
+func _handle_patrol_idle(delta: float) -> void:
+ if current_state != STATE_PATROL:
+ _change_state(STATE_PATROL)
+
+ patrol_wait_timer -= delta
+ if patrol_wait_timer <= 0.0:
+ # 选取出生点周围随机路点
+ var angle = randf_range(0, TAU)
+ var r = randf_range(2.0, PATROL_RADIUS)
+ patrol_destination = spawn_position + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
+ patrol_wait_timer = randf_range(4.0, 7.0)
+
+ if current_position.distance_to(patrol_destination) > 0.5:
+ current_position = current_position.move_toward(patrol_destination, 2.5 * delta)
+
+# 开始拉回重置
+func _start_leash_return() -> void:
+ _change_state(STATE_LEASH_RETURN)
+ leash_reset_started.emit()
+
+# 受到攻击与伤害结算
+func receive_damage(raw_dmg: int, attacker_id: String = "player") -> Dictionary:
+ if current_state == STATE_DEAD:
+ return {"ok": false, "reason": "ALREADY_DEAD"}
+
+ if current_state == STATE_LEASH_RETURN:
+ # 脱战回跑期间无敌
+ return {"ok": true, "immune": true, "net_dmg": 0, "msg": "首领脱战回归中,处于神圣免疫状态!"}
+
+ var net_dmg = max(1, raw_dmg - base_def)
+ current_hp = max(0, current_hp - net_dmg)
+
+ # 累计仇恨度
+ aggro_table[attacker_id] = aggro_table.get(attacker_id, 0) + net_dmg
+
+ # 狂暴判定:HP <= 30%
+ var hp_ratio = float(current_hp) / float(max_hp)
+ if hp_ratio <= ENRAGE_HP_RATIO and not is_enraged:
+ is_enraged = true
+ boss_enraged.emit(1.5)
+
+ # 死亡判定
+ if current_hp <= 0:
+ _change_state(STATE_DEAD)
+ return {
+ "ok": true,
+ "dead": true,
+ "net_dmg": net_dmg,
+ "remaining_hp": 0,
+ "msg": "【%s】发出震天哀鸣,轰然倒地!" % boss_name
+ }
+
+ return {
+ "ok": true,
+ "dead": false,
+ "net_dmg": net_dmg,
+ "remaining_hp": current_hp,
+ "hp_ratio": hp_ratio,
+ "is_enraged": is_enraged
+ }
+
+# 获取当前首领状态摘要
+func get_boss_status() -> Dictionary:
+ return {
+ "vnum": vnum,
+ "name": boss_name,
+ "level": level,
+ "state": current_state,
+ "current_hp": current_hp,
+ "max_hp": max_hp,
+ "is_enraged": is_enraged,
+ "pos": current_position,
+ "spawn_pos": spawn_position
+ }
diff --git a/project/world_boss_ai_system.gd.uid b/project/world_boss_ai_system.gd.uid
new file mode 100644
index 00000000..18a40c90
--- /dev/null
+++ b/project/world_boss_ai_system.gd.uid
@@ -0,0 +1 @@
+uid://du83e3m8ubrr8
diff --git a/project/world_boss_system.gd b/project/world_boss_system.gd
new file mode 100644
index 00000000..611f1546
--- /dev/null
+++ b/project/world_boss_system.gd
@@ -0,0 +1,309 @@
+# world_boss_system.gd —— Metin2 40250 野外四大世界 Boss 巡回战与专属宝箱 1:1
+# 对照 40250 服务端 char_battle.cpp, regen.cpp, mob_drop_item.txt, special_item_group.txt
+class_name WorldBossSystem
+extends RefCounted
+
+signal boss_spawned(boss_vnum: int, boss_name: String, position: Vector3)
+signal boss_hp_changed(current_hp: int, max_hp: int)
+signal minions_summoned(boss_vnum: int, minion_vnum: int, count: int)
+signal boss_enraged(boss_vnum: int)
+signal boss_killed(boss_vnum: int, killer_pos: Vector3, drops: Array)
+
+const BOSS_TABLE: Dictionary = {
+ 1901: {
+ "name": "九尾妖狐",
+ "zone": "冰山",
+ "max_hp": 280000,
+ "base_atk": 850,
+ "base_def": 450,
+ "chest_vnum": 50006,
+ "chest_name": "九尾妖狐宝箱",
+ "minion_vnum": 2204, # 冰山雪怪
+ "minion_count": 4,
+ "gold_min": 50000,
+ "gold_max": 100000
+ },
+ 2191: {
+ "name": "巨型沙漠龟",
+ "zone": "永壁沙漠",
+ "max_hp": 320000,
+ "base_atk": 920,
+ "base_def": 520,
+ "chest_vnum": 50007,
+ "chest_name": "沙漠龟宝箱",
+ "minion_vnum": 2108, # 沙漠强盗
+ "minion_count": 4,
+ "gold_min": 60000,
+ "gold_max": 120000
+ },
+ 1304: {
+ "name": "黄虎之魂",
+ "zone": "黄龙神殿",
+ "max_hp": 350000,
+ "base_atk": 1050,
+ "base_def": 480,
+ "chest_vnum": 50008,
+ "chest_name": "黄虎宝箱",
+ "minion_vnum": 1302, # 狂暴神殿狂热信徒
+ "minion_count": 4,
+ "gold_min": 70000,
+ "gold_max": 130000
+ },
+ 2206: {
+ "name": "烈焰领主",
+ "zone": "烈焰原野",
+ "max_hp": 400000,
+ "base_atk": 1200,
+ "base_def": 500,
+ "chest_vnum": 50009,
+ "chest_name": "烈焰宝箱",
+ "minion_vnum": 2202, # 烈焰幽灵
+ "minion_count": 4,
+ "gold_min": 80000,
+ "gold_max": 150000
+ }
+}
+
+# 宝箱开启奖池 (40250 special_item_group.txt 1:1)
+const CHEST_LOOT_TABLE: Dictionary = {
+ 50006: [
+ {"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
+ {"vnum": 169, "name": "战神之剑+9", "count": 1, "weight": 15},
+ {"vnum": 11299, "name": "黑钢甲+9", "count": 1, "weight": 10},
+ {"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
+ ],
+ 50007: [
+ {"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
+ {"vnum": 3159, "name": "鬼牙刃+9", "count": 1, "weight": 15},
+ {"vnum": 11499, "name": "黑风甲+9", "count": 1, "weight": 10},
+ {"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
+ ],
+ 50008: [
+ {"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
+ {"vnum": 1109, "name": "党人斧+9", "count": 1, "weight": 15},
+ {"vnum": 11699, "name": "黑仙袍+9", "count": 1, "weight": 10},
+ {"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
+ ],
+ 50009: [
+ {"vnum": 50513, "name": "灵魂之石", "count": 1, "weight": 30},
+ {"vnum": 25040, "name": "祝福卷轴", "count": 1, "weight": 25},
+ {"vnum": 189, "name": "天狼剑+9", "count": 1, "weight": 15},
+ {"vnum": 11899, "name": "黑龙袍+9", "count": 1, "weight": 10},
+ {"vnum": 71084, "name": "高级附魔石", "count": 3, "weight": 20}
+ ]
+}
+
+# 当前活跃的世界 Boss
+var active_boss: Dictionary = {}
+var is_boss_alive: bool = false
+var respawn_timers: Dictionary = {} # mob_vnum -> remaining_cooldown
+
+func spawn_boss(mob_vnum: int, pos: Vector3 = Vector3.ZERO) -> Dictionary:
+ if not BOSS_TABLE.has(mob_vnum):
+ return {"ok": false, "reason": "INVALID_BOSS_VNUM", "msg": "未知的世界Boss!"}
+
+ var cfg: Dictionary = BOSS_TABLE[mob_vnum]
+ active_boss = {
+ "vnum": mob_vnum,
+ "name": cfg["name"],
+ "zone": cfg["zone"],
+ "max_hp": cfg["max_hp"],
+ "current_hp": cfg["max_hp"],
+ "base_atk": cfg["base_atk"],
+ "current_atk": cfg["base_atk"],
+ "base_def": cfg["base_def"],
+ "current_def": cfg["base_def"],
+ "chest_vnum": cfg["chest_vnum"],
+ "chest_name": cfg["chest_name"],
+ "minion_vnum": cfg["minion_vnum"],
+ "minion_count": cfg["minion_count"],
+ "position": pos,
+ "is_enraged": false,
+ "phase_75_triggered": false,
+ "phase_50_triggered": false,
+ "phase_25_triggered": false,
+ "summoned_minions": []
+ }
+ is_boss_alive = true
+
+ boss_spawned.emit(mob_vnum, cfg["name"], pos)
+
+ return {
+ "ok": true,
+ "boss": active_boss.duplicate(),
+ "msg": "【世界Boss】%s 已在 %s 现身!" % [cfg["name"], cfg["zone"]]
+ }
+
+# 承受伤害与阶段机制判定
+func apply_damage(raw_dmg: int) -> Dictionary:
+ if not is_boss_alive or active_boss.is_empty():
+ return {"ok": false, "reason": "NO_ACTIVE_BOSS", "msg": "当前没有存活的世界Boss!"}
+
+ var def: int = active_boss["current_def"]
+ var net_dmg: int = max(1, raw_dmg - def)
+ active_boss["current_hp"] = max(0, active_boss["current_hp"] - net_dmg)
+
+ var cur_hp: int = active_boss["current_hp"]
+ var max_hp: int = active_boss["max_hp"]
+ var hp_ratio: float = float(cur_hp) / float(max_hp)
+
+ boss_hp_changed.emit(cur_hp, max_hp)
+
+ var triggered_events: Array = []
+
+ # 1. 阶段小怪召唤机制 (75%, 50%, 25%)
+ if hp_ratio <= 0.75 and not active_boss["phase_75_triggered"]:
+ active_boss["phase_75_triggered"] = true
+ _summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
+ triggered_events.append("PHASE_75_MINIONS")
+
+ if hp_ratio <= 0.50 and not active_boss["phase_50_triggered"]:
+ active_boss["phase_50_triggered"] = true
+ _summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
+ triggered_events.append("PHASE_50_MINIONS")
+
+ if hp_ratio <= 0.25 and not active_boss["phase_25_triggered"]:
+ active_boss["phase_25_triggered"] = true
+ _summon_minions(active_boss["minion_vnum"], active_boss["minion_count"])
+ triggered_events.append("PHASE_25_MINIONS")
+
+ # 2. 残血狂暴机制 (<= 30% HP)
+ if hp_ratio <= 0.30 and not active_boss["is_enraged"]:
+ active_boss["is_enraged"] = true
+ active_boss["current_atk"] = int(active_boss["base_atk"] * 1.5)
+ boss_enraged.emit(active_boss["vnum"])
+ triggered_events.append("BOSS_ENRAGED")
+
+ # 3. 击杀判定
+ if cur_hp <= 0:
+ is_boss_alive = false
+ var drops: Array = _generate_boss_drops(active_boss["vnum"])
+ var death_pos: Vector3 = active_boss["position"]
+ var boss_name: String = active_boss["name"]
+ var boss_vnum: int = active_boss["vnum"]
+
+ boss_killed.emit(boss_vnum, death_pos, drops)
+
+ return {
+ "ok": true,
+ "killed": true,
+ "net_dmg": net_dmg,
+ "remaining_hp": 0,
+ "drops": drops,
+ "triggered_events": triggered_events,
+ "msg": "【世界Boss】%s 已被击杀!掉落专属宝箱!" % boss_name
+ }
+
+ return {
+ "ok": true,
+ "killed": false,
+ "net_dmg": net_dmg,
+ "remaining_hp": cur_hp,
+ "hp_ratio": hp_ratio,
+ "is_enraged": active_boss["is_enraged"],
+ "current_atk": active_boss["current_atk"],
+ "triggered_events": triggered_events
+ }
+
+# 召唤护卫小怪
+func _summon_minions(minion_vnum: int, count: int) -> void:
+ for i in range(count):
+ var offset := Vector3(randf_range(-3.0, 3.0), 0.0, randf_range(-3.0, 3.0))
+ active_boss["summoned_minions"].append({
+ "vnum": minion_vnum,
+ "pos": active_boss["position"] + offset
+ })
+ minions_summoned.emit(active_boss["vnum"], minion_vnum, count)
+
+# 生成 Boss 击杀掉落物
+func _generate_boss_drops(mob_vnum: int) -> Array:
+ var cfg: Dictionary = BOSS_TABLE[mob_vnum]
+ var drops: Array = []
+
+ # 1. 必掉专属宝箱
+ drops.append({
+ "vnum": cfg["chest_vnum"],
+ "name": cfg["chest_name"],
+ "count": 1
+ })
+
+ # 2. 必掉灵魂之石 (50513)
+ drops.append({
+ "vnum": 50513,
+ "name": "灵魂之石",
+ "count": 1
+ })
+
+ # 3. 必掉祝福卷轴 (25040)
+ drops.append({
+ "vnum": 25040,
+ "name": "祝福卷轴",
+ "count": 1
+ })
+
+ # 4. 大额金币掉落 (散落地上)
+ var gold_amount: int = randi_range(cfg["gold_min"], cfg["gold_max"])
+ drops.append({
+ "vnum": 1, # 金币
+ "name": "金币",
+ "count": gold_amount
+ })
+
+ return drops
+
+# 开启世界 Boss 宝箱
+func open_boss_chest(chest_slot: int, inventory: Array) -> Dictionary:
+ if chest_slot < 0 or chest_slot >= inventory.size():
+ return {"ok": false, "reason": "INVALID_SLOT", "msg": "无效的背包格子!"}
+
+ var item = inventory[chest_slot]
+ if item == null:
+ return {"ok": false, "reason": "EMPTY_SLOT", "msg": "该格子上没有物品!"}
+
+ var chest_vnum: int = int(item.get("vnum", 0))
+ if not CHEST_LOOT_TABLE.has(chest_vnum):
+ return {"ok": false, "reason": "NOT_A_BOSS_CHEST", "msg": "这不是世界 Boss 宝箱!"}
+
+ var pool: Array = CHEST_LOOT_TABLE[chest_vnum]
+
+ # 权重抽取奖励
+ var total_weight: int = 0
+ for entry in pool:
+ total_weight += int(entry.get("weight", 1))
+
+ var roll := randi_range(1, total_weight)
+ var accumulated := 0
+ var chosen_reward: Dictionary = {}
+ for entry in pool:
+ accumulated += int(entry.get("weight", 1))
+ if roll <= accumulated:
+ chosen_reward = entry.duplicate()
+ break
+
+ if chosen_reward.is_empty():
+ chosen_reward = pool[0].duplicate()
+
+ # 扣除宝箱
+ inventory[chest_slot] = null
+
+ # 放入抽中的奖励
+ var stored_slot := -1
+ for i in range(inventory.size()):
+ if inventory[i] == null:
+ inventory[i] = {
+ "vnum": chosen_reward["vnum"],
+ "count": chosen_reward.get("count", 1),
+ "name": chosen_reward["name"]
+ }
+ stored_slot = i
+ break
+
+ return {
+ "ok": true,
+ "reward": chosen_reward,
+ "stored_slot": stored_slot,
+ "msg": "打开宝箱获得了【%s】!" % chosen_reward["name"]
+ }
diff --git a/project/world_boss_system.gd.uid b/project/world_boss_system.gd.uid
new file mode 100644
index 00000000..1014b760
--- /dev/null
+++ b/project/world_boss_system.gd.uid
@@ -0,0 +1 @@
+uid://cjlwrfcf41g68
diff --git a/script/rendering_batch_test.sh b/script/rendering_batch_test.sh
index 2bc39bdf..ad8eecec 100644
--- a/script/rendering_batch_test.sh
+++ b/script/rendering_batch_test.sh
@@ -19,7 +19,7 @@ run_test() {
fi
if grep -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
}
-for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do
+for test_script in animation_cache_test.gd animation_blend_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do
run_test headless "$test_script"
done
for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do
diff --git a/tools/gr2dump/main.cpp b/tools/gr2dump/main.cpp
index b1752009..3f1358a0 100644
--- a/tools/gr2dump/main.cpp
+++ b/tools/gr2dump/main.cpp
@@ -27,16 +27,17 @@ static int dump_strings(const gr2::File& f, size_t minlen) {
int main(int argc, char** argv) {
if (argc < 2) {
- std::fprintf(stderr, "usage: gr2dump [--sections] [--strings] [--gltf out.glb]\n");
+ std::fprintf(stderr, "usage: gr2dump [--sections] [--strings] [--members] [--transforms] [--gltf out.glb]\n");
return 2;
}
const std::string path = argv[1];
- bool want_sections = false, want_strings = false, want_members = false;
+ bool want_sections = false, want_strings = false, want_members = false, want_transforms = false;
std::string gltf_out;
for (int i = 2; i < argc; ++i) {
if (!std::strcmp(argv[i], "--sections")) want_sections = true;
else if (!std::strcmp(argv[i], "--strings")) want_strings = true;
else if (!std::strcmp(argv[i], "--members")) want_members = true;
+ else if (!std::strcmp(argv[i], "--transforms")) want_transforms = true;
else if (!std::strcmp(argv[i], "--gltf") && i + 1 < argc) gltf_out = argv[++i];
}
@@ -114,7 +115,15 @@ int main(int argc, char** argv) {
int depth = 0;
for (int p = b.parent; p >= 0 && depth < 64; ) { ++depth; p = sk.bones[p].parent; }
std::printf(" %*s[%zu] %s (parent=%d)\n", depth * 2, "", i, b.name.c_str(), b.parent);
+ if (want_transforms) {
+ std::printf(" local.t=(%.3f,%.3f,%.3f) inverse_world.t=(%.3f,%.3f,%.3f)\n",
+ b.local_transform[12], b.local_transform[13], b.local_transform[14],
+ b.inverse_world[12], b.inverse_world[13], b.inverse_world[14]);
+ }
}
+ if (want_transforms)
+ std::printf(" initial_placement.t=(%.3f,%.3f,%.3f)\n",
+ sk.initial_placement[12], sk.initial_placement[13], sk.initial_placement[14]);
}
for (size_t mi = 0; mi < fi.meshes.size(); ++mi) {
@@ -131,6 +140,21 @@ int main(int argc, char** argv) {
std::printf(" group %zu: material=%d tri_first=%d tri_count=%d tex=%s\n",
g, mix, me.tri_groups[g].tri_first,
me.tri_groups[g].tri_count, tn);
+ int g_up = 0, g_down = 0, g_side = 0;
+ for (int t = 0; t < me.tri_groups[g].tri_count; ++t) {
+ uint32_t idx = (me.tri_groups[g].tri_first + t) * 3;
+ if (idx + 2 < me.indices.size()) {
+ uint32_t i0 = me.indices[idx], i1 = me.indices[idx+1], i2 = me.indices[idx+2];
+ const auto& v0 = me.vertices[i0]; const auto& v1 = me.vertices[i1]; const auto& v2 = me.vertices[i2];
+ float e1[3] = {v1.pos[0]-v0.pos[0], v1.pos[1]-v0.pos[1], v1.pos[2]-v0.pos[2]};
+ float e2[3] = {v2.pos[0]-v0.pos[0], v2.pos[1]-v0.pos[1], v2.pos[2]-v0.pos[2]};
+ float nz = e1[0]*e2[1] - e1[1]*e2[0];
+ if (nz > 1e-3f) ++g_up;
+ else if (nz < -1e-3f) ++g_down;
+ else ++g_side;
+ }
+ }
+ std::printf(" tri face normal: +Z(up)=%d -Z(down)=%d sideways=%d\n", g_up, g_down, g_side);
}
for (size_t b = 0; b < me.material_textures.size(); ++b)
std::printf(" binding %zu -> %s\n", b, me.material_textures[b].c_str());
@@ -138,6 +162,62 @@ int main(int argc, char** argv) {
for (int32_t b : me.bone_bindings) if (b < 0) ++dangling;
if (!me.bone_bindings.empty())
std::printf(" dangling bone bindings: %d\n", dangling);
+ if (want_transforms && !me.vertices.empty()) {
+ float lo[3] = {me.vertices[0].pos[0], me.vertices[0].pos[1], me.vertices[0].pos[2]};
+ float hi[3] = {lo[0], lo[1], lo[2]};
+ for (const auto& v : me.vertices) for (int a = 0; a < 3; ++a) {
+ lo[a] = std::min(lo[a], v.pos[a]); hi[a] = std::max(hi[a], v.pos[a]);
+ }
+ std::printf(" aabb min=(%.3f,%.3f,%.3f) max=(%.3f,%.3f,%.3f)\n",
+ lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]);
+ int normal_agree = 0, normal_disagree = 0;
+ for (size_t t = 0; t + 2 < me.indices.size(); t += 3) {
+ uint32_t i0 = me.indices[t + 0];
+ uint32_t i1 = me.indices[t + 1];
+ uint32_t i2 = me.indices[t + 2];
+ if (i0 < me.vertices.size() && i1 < me.vertices.size() && i2 < me.vertices.size()) {
+ const auto& v0 = me.vertices[i0];
+ const auto& v1 = me.vertices[i1];
+ const auto& v2 = me.vertices[i2];
+ float e1[3] = {v1.pos[0] - v0.pos[0], v1.pos[1] - v0.pos[1], v1.pos[2] - v0.pos[2]};
+ float e2[3] = {v2.pos[0] - v0.pos[0], v2.pos[1] - v0.pos[1], v2.pos[2] - v0.pos[2]};
+ float nx = e1[1]*e2[2] - e1[2]*e2[1];
+ float ny = e1[2]*e2[0] - e1[0]*e2[2];
+ float nz = e1[0]*e2[1] - e1[1]*e2[0];
+ float dot = nx * v0.normal[0] + ny * v0.normal[1] + nz * v0.normal[2];
+ if (dot > 0.0f) ++normal_agree;
+ else if (dot < 0.0f) ++normal_disagree;
+ }
+ }
+ std::printf(" winding vs normal: agree=%d disagree=%d\n", normal_agree, normal_disagree);
+ float avg_n[3] = {0,0,0};
+ for (const auto& v : me.vertices) {
+ avg_n[0] += v.normal[0]; avg_n[1] += v.normal[1]; avg_n[2] += v.normal[2];
+ }
+ if (!me.vertices.empty()) {
+ float inv = 1.0f / me.vertices.size();
+ std::printf(" avg normal: (%.3f, %.3f, %.3f)\n", avg_n[0]*inv, avg_n[1]*inv, avg_n[2]*inv);
+ }
+ int z_up = 0, z_down = 0, xy_side = 0;
+ for (const auto& v : me.vertices) {
+ if (v.normal[2] > 0.5f) ++z_up;
+ else if (v.normal[2] < -0.5f) ++z_down;
+ else ++xy_side;
+ }
+ std::printf(" vertex normal dir: +Z(up)=%d -Z(down)=%d sideways=%d\n", z_up, z_down, xy_side);
+ if (me.indices.size() >= 6) {
+ for (int tr = 0; tr < 2; ++tr) {
+ uint32_t i0 = me.indices[tr*3+0], i1 = me.indices[tr*3+1], i2 = me.indices[tr*3+2];
+ const auto &v0 = me.vertices[i0], &v1 = me.vertices[i1], &v2 = me.vertices[i2];
+ std::printf(" tri%d: v0=(%.1f,%.1f,%.1f) n=(%.2f,%.2f,%.2f)\n"
+ " v1=(%.1f,%.1f,%.1f) n=(%.2f,%.2f,%.2f)\n"
+ " v2=(%.1f,%.1f,%.1f) n=(%.2f,%.2f,%.2f)\n",
+ tr, v0.pos[0], v0.pos[1], v0.pos[2], v0.normal[0], v0.normal[1], v0.normal[2],
+ v1.pos[0], v1.pos[1], v1.pos[2], v1.normal[0], v1.normal[1], v1.normal[2],
+ v2.pos[0], v2.pos[1], v2.pos[2], v2.normal[0], v2.normal[1], v2.normal[2]);
+ }
+ }
+ }
}
for (size_t ai = 0; ai < fi.animations.size(); ++ai) {