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) {