Compare commits
9
Commits
374d4165d8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d217b313 | ||
|
|
1db9e9a129 | ||
|
|
400e3e8ea5 | ||
|
|
85ce75e707 | ||
|
|
474a709f69 | ||
|
|
1933a5ceda | ||
|
|
96c29d2bb7 | ||
|
|
cc5573ddc1 | ||
|
|
f39a55fdd5 |
+10
@@ -37,6 +37,16 @@ compile_commands.json
|
|||||||
*.a
|
*.a
|
||||||
.env.live-smoke.local
|
.env.live-smoke.local
|
||||||
|
|
||||||
|
# playable network tests: local scenario/fixture configs, credential files and
|
||||||
|
# run outputs never enter git. Only scenario.example.json and the static gate
|
||||||
|
# fixtures in test/playable are committed. Run outputs live under /build/playable.
|
||||||
|
/test/playable/*.local.json
|
||||||
|
/test/playable/*.local.txt
|
||||||
|
/test/playable/runs/
|
||||||
|
*.credentials
|
||||||
|
*.credentials.json
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# clangd
|
# clangd
|
||||||
/.cache/
|
/.cache/
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -6,11 +6,11 @@ window = {
|
|||||||
"name" : "SystemDialog",
|
"name" : "SystemDialog",
|
||||||
"style" : ("float",),
|
"style" : ("float",),
|
||||||
|
|
||||||
"x" : (SCREEN_WIDTH - 200) /2,
|
"x" : SCREEN_WIDTH/2 - 100,
|
||||||
"y" : (SCREEN_HEIGHT - 288) /2,
|
"y" : SCREEN_HEIGHT/2 - 114,
|
||||||
|
|
||||||
"width" : 200,
|
"width" : 200,
|
||||||
"height" : 288,
|
"height" : 258,
|
||||||
|
|
||||||
"children" :
|
"children" :
|
||||||
(
|
(
|
||||||
@@ -22,7 +22,7 @@ window = {
|
|||||||
"y" : 0,
|
"y" : 0,
|
||||||
|
|
||||||
"width" : 200,
|
"width" : 200,
|
||||||
"height" : 288,
|
"height" : 258,
|
||||||
|
|
||||||
"children" :
|
"children" :
|
||||||
(
|
(
|
||||||
@@ -39,27 +39,13 @@ window = {
|
|||||||
"over_image" : ROOT + "XLarge_Button_02.sub",
|
"over_image" : ROOT + "XLarge_Button_02.sub",
|
||||||
"down_image" : ROOT + "XLarge_Button_03.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",
|
"name" : "system_option_button",
|
||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 87,
|
"y" : 57,
|
||||||
|
|
||||||
"text" : uiScriptLocale.SYSTEMOPTION_TITLE,
|
"text" : uiScriptLocale.SYSTEMOPTION_TITLE,
|
||||||
|
|
||||||
@@ -72,7 +58,7 @@ window = {
|
|||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 117,
|
"y" : 87,
|
||||||
|
|
||||||
"text" : uiScriptLocale.GAMEOPTION_TITLE,
|
"text" : uiScriptLocale.GAMEOPTION_TITLE,
|
||||||
|
|
||||||
@@ -85,7 +71,7 @@ window = {
|
|||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 147,
|
"y" : 117,
|
||||||
|
|
||||||
"text" : uiScriptLocale.SYSTEM_CHANGE,
|
"text" : uiScriptLocale.SYSTEM_CHANGE,
|
||||||
|
|
||||||
@@ -98,7 +84,7 @@ window = {
|
|||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 177,
|
"y" : 147,
|
||||||
|
|
||||||
"text" : uiScriptLocale.SYSTEM_LOGOUT,
|
"text" : uiScriptLocale.SYSTEM_LOGOUT,
|
||||||
|
|
||||||
@@ -111,7 +97,7 @@ window = {
|
|||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 217,
|
"y" : 177,
|
||||||
|
|
||||||
"text" : uiScriptLocale.SYSTEM_EXIT,
|
"text" : uiScriptLocale.SYSTEM_EXIT,
|
||||||
|
|
||||||
@@ -124,7 +110,7 @@ window = {
|
|||||||
"type" : "button",
|
"type" : "button",
|
||||||
|
|
||||||
"x" : 10,
|
"x" : 10,
|
||||||
"y" : 247,
|
"y" : 217,
|
||||||
|
|
||||||
"text" : uiScriptLocale.CANCEL,
|
"text" : uiScriptLocale.CANCEL,
|
||||||
|
|
||||||
|
|||||||
+150
-7
@@ -355,8 +355,10 @@
|
|||||||
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush`。`_emit_swing()` 改为**只有拿不到
|
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush`。`_emit_swing()` 改为**只有拿不到
|
||||||
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`。`_flush_victim_list()`(对齐
|
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`。`_flush_victim_list()`(对齐
|
||||||
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`。
|
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`。
|
||||||
几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、
|
~~几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`、
|
||||||
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。
|
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。~~
|
||||||
|
**增量 130 已替换**为 `.msa` 刀尖扫掠 Z 圆柱 vs `.msm` 防御球的 `detect_z_cylinder` 判定(见 §3.5 修改 4 /
|
||||||
|
C.5 增量 130)。
|
||||||
`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
|
`combat_fx_test.gd` 加 `FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
|
||||||
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
|
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
|
||||||
`combo_motion_mode` 映射。
|
`combo_motion_mode` 映射。
|
||||||
@@ -700,7 +702,7 @@
|
|||||||
新增 `_attack_ctx()` 从 `client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
|
新增 `_attack_ctx()` 从 `client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
|
||||||
`get_party()` 装配 ctx(observer / main_vid / pk_mode / duel_mode / duel_opponents /
|
`get_party()` 装配 ctx(observer / main_vid / pk_mode / duel_mode / duel_opponents /
|
||||||
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
|
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
|
||||||
`_can_shot`、`_hit_geometry`)无签名变化。
|
`_can_shot`、`_hit_geometry`;增量 130 起后者并入 `_normal_attack_process`)无签名变化。
|
||||||
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言);`combat_fx_test` /
|
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical,25 组断言);`combat_fx_test` /
|
||||||
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type` → `_entity_kind` 归 PC,
|
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type` → `_entity_kind` 归 PC,
|
||||||
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
|
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
|
||||||
@@ -2352,9 +2354,23 @@ OnHit(uSkill, victimActor, isSendPacket):
|
|||||||
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
|
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
|
||||||
`_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
|
`_attack_process`。`_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
|
||||||
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。
|
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`。
|
||||||
几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
|
~~几何判定是 **近似**:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
|
||||||
`±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
|
`±60°`、`(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
|
||||||
sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd` 加 `FakeNetWorld` +
|
sphere 数据(同 §3.1 actor 碰撞一起留桩)。~~
|
||||||
|
**增量 130 升级为逐行判定**:删除 `_hit_deduped` / `_hit_geometry`,新增 `_normal_attack_process()`
|
||||||
|
对齐 `__NormalAttackProcess`:
|
||||||
|
- 距离门 `v3Distance = (dX, dZ, dZ)`:出货代码平面 Y 不参与、高度差算两次,原样保留;上限 300 cm,
|
||||||
|
`IS_HUGE_RACE` 为 500 cm。
|
||||||
|
- 命中窗 `[start,end]` 与 `[t-dt, t]` 相交后查 `m_HitDataMap`:COMBO 同窗不再判,其余等 `fInvisibleTime`。
|
||||||
|
- 窗内每个采样的刀尖 `last_pos → pos` 按攻击者朝向转到世界 cm,作为半径 20 的动态 Z 圆柱,
|
||||||
|
对受击方每个防御球做 `HitCollision.detect_z_cylinder()`(逐行移植
|
||||||
|
`DetectCollisionDynamicZCylinderVSDynamicZCylinder` + `IntersectLineSegments`)。
|
||||||
|
- 碰上后 `_register_hit()` → `_process_attack_success()`。
|
||||||
|
- `_register_hit` 修正为 `map::insert` 语义:已有目标不覆盖冷却,插入后超上限才返回 FALSE。
|
||||||
|
- 防御球取 `.msm` 的 `CollisionType 3`(`HitCollision.parse_msm_defending`):PC 读
|
||||||
|
`root/msm/<class>_<m|w>.msm`,怪物读模型目录 `.msm`。上一帧球心缓存在 `_def_last`。
|
||||||
|
- 判定时间改为 `_attacking_elapsed() = (_local_time - _motion_start_t) * _atk_speed_factor`
|
||||||
|
(`GetAttackingElapsedTime`);`_local_time` 由 `_advance_local_time()` 先扣 `m_fDelay` 再推进。`combat_fx_test.gd` 加 `FakeNetWorld` +
|
||||||
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
|
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
|
||||||
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`,
|
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`,
|
||||||
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
|
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
|
||||||
@@ -2395,8 +2411,10 @@ OnHit(uSkill, victimActor, isSendPacket):
|
|||||||
修改 6 完成(增量 84:`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
|
修改 6 完成(增量 84:`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
|
||||||
切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85:`_clickable_distance()` 弓箭
|
切 `combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85:`_clickable_distance()` 弓箭
|
||||||
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
|
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
|
||||||
全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
|
全绿。~~仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
|
||||||
服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
|
服务端合法性校准。~~ —— 增量 130 已落地:Z 圆柱 vs `.msm` 防御球,以及 `__ProcessDataAttackSuccess`
|
||||||
|
的完整效果(见修改 4 / §3.7 / C.5 增量 130)。仍待:服务端合法性校准;防御球当前是模型本地近似,
|
||||||
|
未跟骨骼矩阵走。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2512,6 +2530,35 @@ handler 由 `__OnPressActor` 通过
|
|||||||
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
|
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
|
||||||
全绿。
|
全绿。
|
||||||
|
|
||||||
|
**增量 130(受击方反应 1:1,2026-09-12)**
|
||||||
|
|
||||||
|
- 新增 `project/ui/hit_reaction.gd`,由 PlayerView / MobView 共用。`good()` / `greate()` / `stone()`
|
||||||
|
逐分支对齐 `__HitGood` / `__HitGreate` / `__HitStone`:
|
||||||
|
- 击倒中不再换受击动作,Greate 在起身中也不换。
|
||||||
|
- 晕眩时:Good → `Die`(死亡动作由服务端驱动,seam);Greate 只播 `DAMAGE_FLYING(_BACK)`,
|
||||||
|
并置 `m_isRealDead`,不起身。
|
||||||
|
- `isLock`(普攻 / 连击 / 技能 / 表情 / 钓鱼等)只抖动不换动作;Greate 在 `IsUsingSkill` 时也只抖。
|
||||||
|
- `scalar = dot(攻击方朝向, 受击方朝向)`:小于 0 走正面 `DAMAGE` / `DAMAGE_FLYING → STAND_UP`;
|
||||||
|
否则先试 `_BACK` 版,缺资源退回正面版。
|
||||||
|
- 动作链 = `InterceptOnceMotion` + `PushOnceMotion` + 链尾 `PushLoopMotion`,回到受击前的
|
||||||
|
wait / walk / run。
|
||||||
|
- `__Shake(100)`:100 ms 内模型位置每帧偏移 ±`rand()%10` cm,结束复位。
|
||||||
|
- `InsertDelay(fStiffenTime)`:`time_scale = 0` 冻结,到期恢复该动作的 `fSpeedRatio`;冻结期间新绑定的
|
||||||
|
动作同样保持冻结。
|
||||||
|
- PC 受击文件按 `playersettingmodule` 注册表取,同名多份等权随机:
|
||||||
|
- `damage` / `damage_1`;BACK 版 `damage_2` / `damage_3`
|
||||||
|
- `damage_flying` / `back_damage_flying`
|
||||||
|
- `falling_stand` / `back_falling_stand`
|
||||||
|
- 怪物在 `STATE_MOTIONS` 新增 `BACK_DAMAGE` / `FRONT_KNOCKDOWN` / `BACK_KNOCKDOWN` / `FRONT_STANDUP` /
|
||||||
|
`BACK_STANDUP`。
|
||||||
|
- `is_in_hit_reaction()` 改为返回 `_hit.active`:整条链播完才清 `net_play._knock_down`。原来的
|
||||||
|
`_state == "damage"` 分支并入链尾处理。
|
||||||
|
- 击退:`net_world.push_victim()` 用 `physics_push.gd`(逐行移植 `CPhysicsObject` +
|
||||||
|
`CEaseOutInterpolation`),每帧叠加位移。
|
||||||
|
- `blending_position()` = `GetBlendingPosition`,供 `OnHit` 的 `CG_SYNC_POSITION` 使用。
|
||||||
|
- 推动期间服务端位置变化:以新位置为基准继续叠加。
|
||||||
|
- 推动结束后服务端位置一变,即丢弃偏移。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.8 技能缺少统一合法性检查
|
### 3.8 技能缺少统一合法性检查
|
||||||
@@ -10145,3 +10192,99 @@ dx² + dy² > s_fLimitDistance → 不播
|
|||||||
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
|
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
|
||||||
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
|
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### C.5 批次记录 · 增量 130(W2 / §3.5 · §3.7 战斗与 40250 对齐:命中判定、命中效果、受击反应、攻速、连击类型、技能包)
|
||||||
|
|
||||||
|
```text
|
||||||
|
批次:增量 130 —— 逐项修正与 40250 参考端战斗实现的差异(2026-09-12)。每项先写失败断言再改实现。
|
||||||
|
|
||||||
|
1. 连击类型:
|
||||||
|
net_play.gd 监听 client.combo_changed,由 _on_combo_changed() 处理,对齐
|
||||||
|
CPythonPlayer::SetComboSkillFlag:
|
||||||
|
· 查连击技能 122 的等级;缺槽或等级 <= 0 直接返回,不改 combo type。
|
||||||
|
· 否则 _combo_type = enabled ? MIN(level, 2) : 0。
|
||||||
|
· 不在本地伪造技能等级。
|
||||||
|
|
||||||
|
2. 命中判定:
|
||||||
|
删除 _hit_deduped / _hit_geometry(reach + 正面 ±60° 近似)。
|
||||||
|
· 新增 project/hit_collision.gd,逐行移植:
|
||||||
|
IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments /
|
||||||
|
AdjustNearestPoints / DetectCollisionDynamicZCylinderVSDynamicZCylinder;
|
||||||
|
另含 .msm CollisionType 3 防御球解析与 IS_HUGE_RACE。
|
||||||
|
· net_play._normal_attack_process() 流程:
|
||||||
|
(dX, dZ, dZ) 距离门 300 / 500 cm → 命中窗相交 → m_HitDataMap →
|
||||||
|
刀尖采样半径 20 的 Z 圆柱 vs 受击方防御球(上一帧球心存 _def_last)。
|
||||||
|
· PlayerView 取 root/msm/<class>_<m|w>.msm,MobView 取模型目录 .msm,都经 get_defending_spheres() 提供。
|
||||||
|
· 没有 .msm 的占位节点退化为单球 HIT_FALLBACK_SPHERE(seam)。
|
||||||
|
|
||||||
|
3. _register_hit:
|
||||||
|
改为 map::insert 语义:已登记的目标不刷新冷却,插入后超上限才返回 FALSE。
|
||||||
|
|
||||||
|
4. 攻速:
|
||||||
|
_atk_speed_factor(bAttackSpeed/100,>1100 → 0)同时用于两处:
|
||||||
|
· _attacking_elapsed() = (_local_time - _motion_start_t) * fSpeedRatio
|
||||||
|
· PlayerView.play_attack_motion(mode, index, speed_ratio) 的 time_scale
|
||||||
|
play_attack_motion 按 MOTION_MODE 目录绑定 attack(_1) / combo_0N,模式目录缺该段时回退 general。
|
||||||
|
|
||||||
|
5. 连击清零:
|
||||||
|
删除本地连击超时清空。PlayerView / MobView 新增 motion_bound(state) 信号(__SetMotion 尾),
|
||||||
|
由 net_play._on_motion_bound() 处理:
|
||||||
|
· 新动作带 AttackingData → 清 m_HitDataMap。
|
||||||
|
· 连击中换到无 ComboInputData 的动作 → _combo_index = 0。
|
||||||
|
|
||||||
|
6. __ProcessDataAttackSuccess(_process_attack_success):
|
||||||
|
· InsertDelay(fStiffenTime):攻击者写 _delay(_advance_local_time 先扣),视图 time_scale 冻结;
|
||||||
|
受击方同样 insert_delay,并记 m_fInvisibleTime(_victim_invisible_until,期间 AttackingProcess 跳过它)。
|
||||||
|
· 击退门 __CanPushDestActor(_can_push):
|
||||||
|
建筑 / 门 / 石头 / NPC / 巨型不推;晕眩必推;否则要求 owner 为主角且 owner 时间 <= 3 s。
|
||||||
|
通过后 net_world.push_victim() 推动(physics_push.gd 逐行移植 CPhysicsObject + CEaseOutInterpolation)。
|
||||||
|
OnHit 时目标仍在推动中 → GetBlendingPosition 进 CG_SYNC_POSITION 列表。
|
||||||
|
· 命中特效 blow_1_low.mse:
|
||||||
|
建筑 / 门放在攻击者身前 30 cm、不转向;巨型放在命中点;其余放在受击方。
|
||||||
|
game_scene 装配 net_play.fx = fx、fx_parent = Entities。
|
||||||
|
· 受击反应:石头 / 门 → hit_stone;HIT_TYPE_GOOD → hit_good;HIT_TYPE_GREAT → hit_greate;
|
||||||
|
scalar = cos(攻击方 yaw − 受击方 yaw)。状态机 ui/hit_reaction.gd 见 §3.7 增量 130。
|
||||||
|
· 最后 _on_hit:SetTarget + CG_ATTACK。
|
||||||
|
|
||||||
|
7. 技能包:
|
||||||
|
ClassicSession::send_use_skill 删除夹带的 CG_FLY_TARGETING,只发 TPacketCGUseSkill(对齐
|
||||||
|
SendUseSkillPacket)。飞行目标仍由施法方的 CG_ADD_FLY_TARGETING / OnSetFlyTarget 发送。
|
||||||
|
net_classic_session_test 新增 "use skill wire: no extra fly targeting" 断言。
|
||||||
|
|
||||||
|
8. 既有测试随行为调整:
|
||||||
|
受击 damage 按 playersettingmodule 注册为 damage / damage_1 等权随机。
|
||||||
|
· gpu_pose_bounds_test:CPU / GPU 两个视图绑定前用同一 seed,保证选中同一段。
|
||||||
|
· race_motion_assembly_test:damage 允许任一注册变体。
|
||||||
|
这两个测试在本批次改动后曾失败(CPU / GPU 选到不同段、断言写死 damage.msa),调整后通过。
|
||||||
|
|
||||||
|
回归(godot --headless --path project --script <test>.gd):
|
||||||
|
新增 PASS:hit_collision_test、physics_push_test、hit_view_test、net_world_push_test。
|
||||||
|
PASS:combat_fx_test、gamescene_test、netplay_test、skill_test、player_move_test、input_key_test、
|
||||||
|
weapon_attach_test、mob_view_test、player_motion_test、fly_test、remote_player_test、
|
||||||
|
net_world_vis_test、forest_mob_render_test、p2b_test、playable_harness_test、playable_combat_test、
|
||||||
|
gpu_pose_bounds_test、race_motion_assembly_test。
|
||||||
|
已知失败(与本批次无关):equip_model_test "armor 11000 -> values[3]=0 回退 =vnum"(夹具期望过期)。
|
||||||
|
forest_map_render_test 是 extends Node 的 MT_TEST_MODE 入口,不能用 --script 直跑,本批次未运行。
|
||||||
|
ctest --test-dir build:22/23 PASS。net.classic_session 仍是既有 25 条失败
|
||||||
|
(quest / duel / messenger / exchange / guild / safebox / mall / sync / ground item / despawn),
|
||||||
|
都与 use_skill 无关;新增的 use skill 断言通过。
|
||||||
|
|
||||||
|
验收边界:
|
||||||
|
已验证(离线):Z 圆柱几何、.msm 防御球解析、命中表 / 上限、攻速缩放、连击类型与清零、
|
||||||
|
硬直 / 击退 / 同步位置 / 特效挂载、受击动作链 / 抖动 / 冻结、技能包线格式。
|
||||||
|
未验证:真实服务器下的命中与击退手感、真机视觉。
|
||||||
|
seam:
|
||||||
|
· 击退没有地形 / 角色碰撞,也无 AdjustCollisionWithOtherObjects、溅射、IsResistFallen。
|
||||||
|
· owner 时间取客户端观测时刻。
|
||||||
|
· 防御球按模型本地坐标、不跟骨骼矩阵;占位节点退化为单球。
|
||||||
|
· 远端角色不跑 AttackProcess;晕眩 → Die 不在本地播。
|
||||||
|
· 攻击 / 受击动作变体等权随机;受击动作固定取 general 目录。
|
||||||
|
· 抖动用模型位置偏移,InsertDelay 用 time_scale 冻结。
|
||||||
|
· 受击链期间的循环动作请求延后到链尾。
|
||||||
|
· 推动结束后服务端位置一变即丢弃偏移。
|
||||||
|
· 本地 GC 伤害包仍直接走 set_anim_state("damage")。
|
||||||
|
改动 C++ 后已重建 mtgodot 扩展;脚本改动需要重新导出 PCK 才进包。
|
||||||
|
|
||||||
|
下一批次:
|
||||||
|
击退世界碰撞与 IsResistFallen;防御球跟骨骼矩阵;远端 AttackProcess;真实服务器战斗验收。
|
||||||
|
```
|
||||||
|
|||||||
+26
-8
File diff suppressed because one or more lines are too long
@@ -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)** | • 桥梁、楼梯、门廊台阶悬空或掉落穿透<br>• 水陆交界处判断错误(干地游泳或踩深水不减速/不可钓鱼)<br>• 地下城/山洞进出门廊穿透掉落 | • `CMapOutdoor::GetHeight` 查询 `.mdatr` 中的 `heights` 网格多边形,与裸高度图取 `fMAX`<br>• `attr.atr` 中的 `ATTRIBUTE_WATER` 与水面多边形绝对高度差<br>• `CDungeonBlock::GetObjectHeight` | `GameLib/MapOutdoor.cpp`<br>`EterLib/AttributeInstance.cpp`<br>`GameLib/AreaTerrain.cpp` |
|
||||||
|
| **2. 物理与移动 (Physics & Locomotion)** | • 遇到陡坡时直接穿山或以相同速度瞬移上山<br>• 被怪物或技能击退(Knockback)时穿透墙体飞出地图<br>• 自动寻路走直线卡死在栅栏/岩石缝隙 | • `ActorInstanceSync.cpp` 坡度倾角与重力滑落向量计算<br>• `ActorInstanceCollisionDetection.cpp` 击退时沿障碍物法线切向滑动<br>• `CMapOutdoor::is_blocked` 的栅格阻挡光栅化与逐轴滑动降级 | `GameLib/ActorInstanceSync.cpp`<br>`GameLib/ActorInstanceCollisionDetection.cpp` |
|
||||||
|
| **3. 动作与挂点 (Animation & Attachment)** | • 武器/盾牌朝向偏转或拿反、脱离手部<br>• 骑马时双手未握缰绳或双腿穿透马鞍<br>• 更换头盔/发型时头部穿模或发色异常 | • `CItemData::GetItemScale` 与骨骼挂点矩阵 `m_matBone` 变换<br>• `InstanceBaseMotion.cpp` 的骑乘姿势 override (`COMBO_TYPE_HORSE`)<br>• `CRaceData::SetHair` 的 MSM 顶点隐藏机制与 Alpha Blending 管道 | `GameLib/ActorInstanceAttach.cpp`<br>`UserInterface/InstanceBaseMotion.cpp`<br>`GameLib/RaceData.cpp` |
|
||||||
|
| **4. 战斗与射弹 (Combat & Flying)** | • 弓箭/法球穿透墙体或直接打入地底<br>• 普攻挥刀动作还没劈下来伤害数值已提前跳出<br>• 怪物死后不立刻倒地或死亡姿势嵌入地面 | • `CFlyingInstance::Update` 中的 `BackgroundHit` 碰撞(与地面/建筑交点)<br>• `.msa` 中的 `MotionEvent`(命中帧 `HIT` 严格帧事件驱动)<br>• `ActorInstanceBlend.cpp` 死亡动作的地面投影与骨骼终帧冻结 | `GameLib/FlyingInstance.cpp`<br>`GameLib/ActorInstanceBattle.cpp`<br>`GameLib/ActorInstanceBlend.cpp` |
|
||||||
|
| **5. 交互与拾取 (Picking & Decal)** | • 鼠标点击桥面、屋顶或 NPC 拾取点偏移到桥下<br>• 地面范围技能光圈(Decal)漏到桥底或悬空 | • `CMapOutdoor::GetPickingPointWithRay` 射线先求交物体再求交地形<br>• `CTerrainDecal` 的多边形表面投影裁切 | `GameLib/MapOutdoor.cpp`<br>`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 测试套件),任何一次打包或构建前运行,确保旧缺陷绝不回退。
|
||||||
@@ -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 <host> <port> <id> <pw>`,打印 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<MyShopItem>)`(打包头+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`**(`<vnum>\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`。扫
|
|
||||||
`<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/`。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 旋转台 × 真实职业模型(`<class>_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_<class>.sub` → `select.dds`,DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_<a/b/c>.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()`。<br>`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/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt`(`KEY\tVALUE`,UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 `<KEY>`(开发期可见);`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/<rel>`)。路径解析走 11 个声音根 + `bgm/`(`.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect`→`characterselect` BGM + `<class>_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`(玩法)。
|
|
||||||
@@ -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` 图形底层**<br>(`CGraphicDevice`, `CStateManager`, `CRenderTarget`) | 深度强绑定 DirectX 8/9,引入会导致无法在 macOS (Metal) 及移动端 (Vulkan) 编译。 | **Godot 渲染引擎**(原生支持 Metal / Vulkan / WebGL) |
|
||||||
|
| **`EterPythonLib` 2D UI 系统**<br>(`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 桌面端输入<br>(WASD / 鼠标左右键 / 快捷键 1~4 / 空格)"]
|
||||||
|
Mobile_Input["移动端触控输入<br>(虚拟摇杆 / 技能轮盘 / 普攻大按钮 / 目标切换)"]
|
||||||
|
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% 正常展示并成功购买。
|
||||||
|
- 联机打怪,验证普通攻击连击节奏、打击僵直与伤害数字飘字时机与原版客户端完全吻合。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 客户端玩法开发计划
|
# 客户端玩法开发计划
|
||||||
|
|
||||||
> 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。
|
> 把 [`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 月+
|
> 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+
|
||||||
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client)
|
> 标记:🌐 需服务器端到端 · 💻 离线可做(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 编过。
|
**已验证**:`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,🌐)
|
## P8 — 社交 / 商店 / 仓库(XL,🌐)
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ motion_name 就是特效基名,上一轮列偏移修好后自然接上);
|
|||||||
(协程化的 `setup()` 让 `entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene` 先
|
(协程化的 `setup()` 让 `entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene` 先
|
||||||
`set_local_vid` + `net_play._on_main_set` 再 `catch_up`,避免本地玩家被画成压身的蓝胶囊。
|
`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 资产。
|
**剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -313,7 +313,7 @@ PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列
|
|||||||
|
|
||||||
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
|
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
|
||||||
之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` /
|
之前技能窗全空) + `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`
|
2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand`,`_load_attach`
|
||||||
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`。
|
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`。
|
||||||
**armor→shape 真表**:proto 读器加 `alValues[6]`@191,`equip_model._armor_shape_default` = `item_proto
|
**armor→shape 真表**:proto 读器加 `alValues[6]`@191,`equip_model._armor_shape_default` = `item_proto
|
||||||
|
|||||||
+2
-2
@@ -109,10 +109,10 @@ TreeSize/TreeVariance。当前实现已移除这些随机变换;若替代树
|
|||||||
|
|
||||||
| # | 项 | 现状 | 目标 | 工作 | 规模 | BACKLOG |
|
| # | 项 | 现状 | 目标 | 工作 | 规模 | 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.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.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.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.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 |
|
| 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 |
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ void Metin2AnimPlayer::_bind_methods() {
|
|||||||
|
|
||||||
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
||||||
if (p == anim_path) {
|
if (p == anim_path) {
|
||||||
|
if (!playing) {
|
||||||
|
time = 0.0;
|
||||||
|
prev_time = 0.0;
|
||||||
|
playing = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Start a crossfade from the clip that is currently playing.
|
// Start a crossfade from the clip that is currently playing.
|
||||||
@@ -407,6 +412,7 @@ void Metin2AnimPlayer::reload() {
|
|||||||
events.clear();
|
events.clear();
|
||||||
msa_metadata = fmt::Msa{};
|
msa_metadata = fmt::Msa{};
|
||||||
last_info = "";
|
last_info = "";
|
||||||
|
playing = true;
|
||||||
if (anim_path.is_empty()) {
|
if (anim_path.is_empty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -468,11 +474,13 @@ void Metin2AnimPlayer::reload() {
|
|||||||
unmatched += String(tr.bone_name.c_str()) + " ";
|
unmatched += String(tr.bone_name.c_str()) + " ";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (diagnostics) {
|
||||||
UtilityFunctions::print(vformat(
|
UtilityFunctions::print(vformat(
|
||||||
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
|
||||||
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
||||||
String(an.name.c_str()), duration, (int)an.tracks.size(),
|
String(an.name.c_str()), duration, (int)an.tracks.size(),
|
||||||
(int)anim_file->file_info().animations.size());
|
(int)anim_file->file_info().animations.size());
|
||||||
@@ -484,22 +492,30 @@ namespace {
|
|||||||
|
|
||||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
||||||
|
|
||||||
// Blend two bone world transforms: slerp rotation, lerp translation & scale
|
// Blend two local bone transforms: slerp rotation, lerp translation and the
|
||||||
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
|
// complete scale/shear basis (PARITY §2.9). Several warrior/sura armour bones
|
||||||
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
|
// 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) {
|
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::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::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::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::Vector3(b[8], b[9], b[10]));
|
||||||
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
|
const godot::Quaternion qa = ba.get_rotation_quaternion();
|
||||||
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
|
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 tt =
|
||||||
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
||||||
godot::Basis rb(q);
|
const godot::Basis rb = godot::Basis(q) * ss;
|
||||||
rb.rows[0] *= s.x;
|
|
||||||
rb.rows[1] *= s.y;
|
|
||||||
rb.rows[2] *= s.z;
|
|
||||||
gr2::Mat4 o{};
|
gr2::Mat4 o{};
|
||||||
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
|
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;
|
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;
|
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<gr2::Mat4> &local) {
|
||||||
|
std::vector<gr2::Mat4> 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<gr2::Mat4> &local,
|
||||||
|
const gr2::Mat4 &root_offset, std::vector<gr2::Mat4> &world,
|
||||||
|
std::vector<gr2::Mat4> &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<size_t>(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
|
} // namespace
|
||||||
|
|
||||||
void Metin2AnimPlayer::apply_pose(double t) {
|
void Metin2AnimPlayer::apply_pose(double t) {
|
||||||
@@ -548,31 +604,35 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
|||||||
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
|
// 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.
|
// 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 };
|
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
|
// Crossfade in LOCAL bone space, then accumulate the hierarchy and rebuild
|
||||||
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
// 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) {
|
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
|
||||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
||||||
if (!pfi.animations.empty()) {
|
if (!pfi.animations.empty()) {
|
||||||
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
sample_local_pose(*sk, an, (float)tt, local_buf);
|
||||||
prev_skin_buf, &kNoOffset);
|
sample_local_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_local_buf);
|
||||||
double w = blend_elapsed / blend_time;
|
double w = blend_elapsed / blend_time;
|
||||||
// ease-in on the incoming clip, matching the client's
|
// ease-in on the incoming clip, matching the client's
|
||||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
// 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.
|
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
|
||||||
w = w * w * (2.0 - w);
|
w = w * w * (2.0 - w);
|
||||||
const size_t n = std::min(world_buf.size(), prev_world_buf.size());
|
const size_t n = std::min(local_buf.size(), prev_local_buf.size());
|
||||||
blend_world_buf.resize(world_buf.size());
|
blend_local_buf.resize(local_buf.size());
|
||||||
for (size_t i = 0; i < n; ++i)
|
for (size_t i = 0; i < n; ++i) {
|
||||||
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
|
blend_local_buf[i] = blend_trs(prev_local_buf[i], local_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
|
|
||||||
}
|
}
|
||||||
|
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
|
// Both skinning paths apply libgr2's per-bone deformer matrices
|
||||||
@@ -582,7 +642,11 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
|||||||
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
||||||
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
|
// 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).
|
// 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->enable_gpu_skin(true);
|
||||||
model->gpu_skin(skin_buf);
|
model->gpu_skin(skin_buf);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ private:
|
|||||||
double prev_anim_duration = 0.0;
|
double prev_anim_duration = 0.0;
|
||||||
bool prev_anim_loop = true;
|
bool prev_anim_loop = true;
|
||||||
double blend_elapsed = 0.0;
|
double blend_elapsed = 0.0;
|
||||||
std::vector<gr2::Mat4> 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<gr2::Mat4> local_buf, prev_local_buf, blend_local_buf;
|
||||||
|
|
||||||
godot::Vector3 accumulation; // .msa Accumulation (root motion)
|
godot::Vector3 accumulation; // .msa Accumulation (root motion)
|
||||||
std::vector<fmt::MotionEvent> events;
|
std::vector<fmt::MotionEvent> events;
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ static const gr2::MaterialInfo *match_material(const std::vector<gr2::MaterialIn
|
|||||||
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname);
|
static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||||
static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname);
|
static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname);
|
||||||
|
|
||||||
|
static HashMap<String, Ref<ImageTexture>> s_model_tex_cache;
|
||||||
|
static HashMap<String, PackedStringArray> 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() {
|
Metin2Model::Metin2Model() {
|
||||||
file = std::make_shared<std::optional<gr2::File>>();
|
file = std::make_shared<std::optional<gr2::File>>();
|
||||||
lod_dist.push_back(18.0f);
|
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("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("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_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("reload"), &Metin2Model::reload);
|
||||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||||
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
|
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("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("get_shield_bone"), &Metin2Model::get_shield_bone);
|
||||||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
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
|
// 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
|
// 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
|
// 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) {
|
void Metin2Model::set_gr2_path(const String &p) {
|
||||||
gr2_path = p;
|
gr2_path = p;
|
||||||
if (is_inside_tree()) {
|
if (is_inside_tree()) {
|
||||||
@@ -236,9 +277,11 @@ void Metin2Model::_load_lods(const String &loaded_spec) {
|
|||||||
lod_files.push_back(std::move(slot));
|
lod_files.push_back(std::move(slot));
|
||||||
}
|
}
|
||||||
if (!lod_files.empty()) {
|
if (!lod_files.empty()) {
|
||||||
|
if (model_diagnostics()) {
|
||||||
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr double kLodFadeDur = 0.18;
|
constexpr double kLodFadeDur = 0.18;
|
||||||
@@ -518,7 +561,7 @@ void Metin2Model::reload() {
|
|||||||
for (const auto &m : fi.meshes) {
|
for (const auto &m : fi.meshes) {
|
||||||
vtot += (int)m.vertices.size();
|
vtot += (int)m.vertices.size();
|
||||||
}
|
}
|
||||||
{
|
if (model_diagnostics()) {
|
||||||
int with_tex = 0;
|
int with_tex = 0;
|
||||||
for (const auto &mm : materials) {
|
for (const auto &mm : materials) {
|
||||||
if (!mm.diffuse_texture.empty()) {
|
if (!mm.diffuse_texture.empty()) {
|
||||||
@@ -535,7 +578,9 @@ void Metin2Model::reload() {
|
|||||||
mesh->get_surface_count(),
|
mesh->get_surface_count(),
|
||||||
vtot,
|
vtot,
|
||||||
String(bounds));
|
String(bounds));
|
||||||
|
if (model_diagnostics()) {
|
||||||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||||||
|
}
|
||||||
|
|
||||||
_load_lods(gr2_spec);
|
_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);
|
*slot_file = std::move(w);
|
||||||
const gr2::FileInfo &wfi = (**slot_file).file_info();
|
const gr2::FileInfo &wfi = (**slot_file).file_info();
|
||||||
|
|
||||||
// Grip pre-transform, mirroring the client's weapon composite:
|
// Grip pre-transform, mirroring Granny's rigid-mesh composite in the client:
|
||||||
// weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
|
// weaponWorld[0] = weaponLocal[0] · handWorld
|
||||||
// weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
|
// weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0]
|
||||||
// (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
|
// The explicit parent matrix passed to GrannyBuildWorldPose replaces the
|
||||||
// Most Metin2 weapons are authored directly in hand space so this pre is ~I;
|
// weapon model's InitialPlacement; multiplying it here would cancel 03150's
|
||||||
// a few non-standard files carry a huge bone offset that would fling the mesh
|
// inverse-bind correction. See ModelInstanceUpdate.cpp:148.
|
||||||
// metres away — clamp those out and fall back to the raw hand pose.
|
// 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()) {
|
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
|
||||||
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
|
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
|
||||||
gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
slot_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
|
std::vector<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
|
||||||
@@ -987,8 +1031,8 @@ String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) {
|
|||||||
|
|
||||||
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||||
Ref<ImageTexture> t;
|
Ref<ImageTexture> t;
|
||||||
if (tex_cache.has(path)) {
|
if (s_model_tex_cache.has(path)) {
|
||||||
return tex_cache[path];
|
return s_model_tex_cache[path];
|
||||||
}
|
}
|
||||||
if (FileAccess::file_exists(path)) {
|
if (FileAccess::file_exists(path)) {
|
||||||
mtgodot::Image dds = mtgodot::dds_from_file(path);
|
mtgodot::Image dds = mtgodot::dds_from_file(path);
|
||||||
@@ -999,7 +1043,7 @@ Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
|||||||
dds.rgba.size(), /*mipmaps=*/true);
|
dds.rgba.size(), /*mipmaps=*/true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tex_cache[path] = t;
|
s_model_tex_cache[path] = t;
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,7 +1184,9 @@ void Metin2Model::_apply_materials() {
|
|||||||
const String stem = gr2_path.get_file().get_basename();
|
const String stem = gr2_path.get_file().get_basename();
|
||||||
|
|
||||||
PackedStringArray dds_files;
|
PackedStringArray dds_files;
|
||||||
{
|
if (s_model_dir_dds_cache.has(dir)) {
|
||||||
|
dds_files = s_model_dir_dds_cache[dir];
|
||||||
|
} else {
|
||||||
Ref<DirAccess> da = DirAccess::open(dir);
|
Ref<DirAccess> da = DirAccess::open(dir);
|
||||||
if (da.is_valid()) {
|
if (da.is_valid()) {
|
||||||
da->list_dir_begin();
|
da->list_dir_begin();
|
||||||
@@ -1151,6 +1197,7 @@ void Metin2Model::_apply_materials() {
|
|||||||
}
|
}
|
||||||
da->list_dir_end();
|
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
|
// 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()) {
|
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
|
||||||
tex = _load_dds(surface_tex_override[s]);
|
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)
|
// gr2 material binding -> texture (default; set use_gr2_materials=false to skip)
|
||||||
if (tex.is_null() && use_gr2_materials) {
|
if (tex.is_null() && use_gr2_materials) {
|
||||||
tex = find_dds(mat_want);
|
tex = find_dds(mat_want);
|
||||||
@@ -1270,9 +1324,11 @@ void Metin2Model::_apply_materials() {
|
|||||||
surf_mats[s] = mat;
|
surf_mats[s] = mat;
|
||||||
mi->set_surface_override_material(s, mat);
|
mi->set_surface_override_material(s, mat);
|
||||||
}
|
}
|
||||||
|
if (model_diagnostics()) {
|
||||||
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||||
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Metin2Model::_build_gpu_mesh() {
|
void Metin2Model::_build_gpu_mesh() {
|
||||||
if (!mi || !file || !*file) {
|
if (!mi || !file || !*file) {
|
||||||
@@ -1353,9 +1409,11 @@ void Metin2Model::enable_cpu_skin(bool on) {
|
|||||||
if (skel) {
|
if (skel) {
|
||||||
skel->set_show_rest_only(true);
|
skel->set_show_rest_only(true);
|
||||||
}
|
}
|
||||||
|
if (model_diagnostics()) {
|
||||||
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||||
if (cpu_mesh.is_null() || !file || !*file) {
|
if (cpu_mesh.is_null() || !file || !*file) {
|
||||||
@@ -1652,8 +1710,10 @@ void Metin2Model::enable_gpu_skin(bool on) {
|
|||||||
// sampler has produced its skin matrices.
|
// sampler has produced its skin matrices.
|
||||||
gpu_skin_active = true;
|
gpu_skin_active = true;
|
||||||
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
||||||
|
if (model_diagnostics()) {
|
||||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||||
if (!gpu_skin_active || bones_img.is_null()) {
|
if (!gpu_skin_active || bones_img.is_null()) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <godot_cpp/templates/hash_map.hpp>
|
#include <godot_cpp/templates/hash_map.hpp>
|
||||||
#include <godot_cpp/variant/array.hpp>
|
#include <godot_cpp/variant/array.hpp>
|
||||||
#include <godot_cpp/variant/aabb.hpp>
|
#include <godot_cpp/variant/aabb.hpp>
|
||||||
|
#include <godot_cpp/variant/dictionary.hpp>
|
||||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||||
#include <godot_cpp/variant/string.hpp>
|
#include <godot_cpp/variant/string.hpp>
|
||||||
|
|
||||||
@@ -81,6 +82,11 @@ public:
|
|||||||
|
|
||||||
// Explicit per-surface texture path override (index -> absolute .dds path).
|
// Explicit per-surface texture path override (index -> absolute .dds path).
|
||||||
void set_surface_texture(int surface, const godot::String &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}, ...].
|
// When gr2_path is a .msm: [{index, model (abs .gr2), target_skin}, ...].
|
||||||
godot::Array get_hair_options() const { return hair_options; }
|
godot::Array get_hair_options() const { return hair_options; }
|
||||||
@@ -160,6 +166,8 @@ public:
|
|||||||
// libgr2 sanity probe (kept from M0').
|
// libgr2 sanity probe (kept from M0').
|
||||||
godot::String probe_gr2(const godot::String &path) const;
|
godot::String probe_gr2(const godot::String &path) const;
|
||||||
|
|
||||||
|
static void clear_texture_cache();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
static void _bind_methods();
|
static void _bind_methods();
|
||||||
|
|
||||||
@@ -176,6 +184,7 @@ private:
|
|||||||
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
|
godot::Ref<godot::Texture2D> sphere_map; // shared sphere map, lazy-loaded
|
||||||
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
|
godot::Ref<godot::Texture2D> _load_sphere_map(); // "ymir work/special/spheremap.jpg"
|
||||||
godot::PackedStringArray surface_tex_override;
|
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::String resolved_gr2_dir; // dir of the actually-loaded .gr2 (for .msm)
|
||||||
godot::Array hair_options; // from .msm HairData
|
godot::Array hair_options; // from .msm HairData
|
||||||
|
|
||||||
@@ -196,10 +205,10 @@ private:
|
|||||||
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
|
std::shared_ptr<std::optional<gr2::File>> weapon_file; // loaded weapon gr2
|
||||||
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
|
godot::MeshInstance3D *weapon_mi = nullptr; // child of this node
|
||||||
int weapon_bone_idx = -1; // base skeleton index of weapon_bone
|
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
|
// Weapon root rigid-mesh composite (invWorld · local),
|
||||||
// aligns to the hand even when it is authored offset from its bone (client:
|
// folded in before the parent hand matrix exactly as Granny does for an attached
|
||||||
// GrannySampleModelAnimationsAccelerated on the weapon skeleton). Identity when
|
// model. This may contain a large translation which cancels an authored vertex
|
||||||
// the weapon has no skeleton or a ~identity bone.
|
// 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 };
|
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_gr2;
|
||||||
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
|
godot::String shield_bone = "Bip01 L Hand"; // PC skeletons have no equip_left_hand
|
||||||
@@ -220,7 +229,6 @@ private:
|
|||||||
godot::MeshInstance3D *mi = nullptr;
|
godot::MeshInstance3D *mi = nullptr;
|
||||||
godot::String last_info;
|
godot::String last_info;
|
||||||
double ground_offset = 0.0;
|
double ground_offset = 0.0;
|
||||||
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
|
|
||||||
|
|
||||||
// §2.10 LOD
|
// §2.10 LOD
|
||||||
bool lod_enabled = true;
|
bool lod_enabled = true;
|
||||||
|
|||||||
@@ -153,8 +153,65 @@ bool add_attribute_collision(const fmt::AttributeCollision &collision,
|
|||||||
return true;
|
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<Metin2World::HeightTriangle> &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,
|
int add_attribute_data(const std::string &path, const Transform3D &place,
|
||||||
Node3D *root, MeshInstance3D *mesh) {
|
Node3D *root, MeshInstance3D *mesh,
|
||||||
|
std::vector<Metin2World::HeightTriangle> *out_heights = nullptr) {
|
||||||
fmt::AttributeData data;
|
fmt::AttributeData data;
|
||||||
std::string error;
|
std::string error;
|
||||||
if (!fmt::parse_attribute_data_file(path, data, &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() + ")");
|
" (" + error.c_str() + ")");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (out_heights) {
|
||||||
|
extract_height_triangles(data, place, *out_heights);
|
||||||
|
}
|
||||||
StaticBody3D *body = memnew(StaticBody3D);
|
StaticBody3D *body = memnew(StaticBody3D);
|
||||||
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
|
body->set_name(String("AttributeBody_") + String(path.c_str()).get_file().get_basename());
|
||||||
body->set_collision_layer(2);
|
body->set_collision_layer(2);
|
||||||
@@ -196,6 +256,15 @@ int add_attribute_data(const std::string &path, const Transform3D &place,
|
|||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void load_attribute_heights(const std::string &path, const Transform3D &place,
|
||||||
|
std::vector<Metin2World::HeightTriangle> &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
|
} // namespace
|
||||||
|
|
||||||
Metin2World::Metin2World() {}
|
Metin2World::Metin2World() {}
|
||||||
@@ -468,7 +537,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
|
|||||||
ck.am = am;
|
ck.am = am;
|
||||||
ck.root = croot;
|
ck.root = croot;
|
||||||
if (objects_enabled && registry_ok && resolver)
|
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)
|
if (registry_ok)
|
||||||
place_chunk_ambience(tx, ty);
|
place_chunk_ambience(tx, ty);
|
||||||
objects_placed += ck.objects;
|
objects_placed += ck.objects;
|
||||||
@@ -477,7 +546,8 @@ bool Metin2World::build_chunk(int tx, int ty) {
|
|||||||
return true;
|
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<HeightTriangle> &out_heights) {
|
||||||
n_obj = 0;
|
n_obj = 0;
|
||||||
n_tree = 0;
|
n_tree = 0;
|
||||||
const std::string mdir = std::string(map_dir().utf8().get_data());
|
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),
|
// 盒尺寸/中心直接算到「米 / Y-up」,body 只带 place(旋转+平移,无 conv),
|
||||||
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
|
// 避免把碰撞形状放在 0.01 缩放节点下(Godot 缩放 shape 不稳)。
|
||||||
bool mdatr_built = false;
|
bool mdatr_built = false;
|
||||||
if (collision_enabled) {
|
|
||||||
String md = String(rp.c_str()).get_basename() + ".mdatr";
|
String md = String(rp.c_str()).get_basename() + ".mdatr";
|
||||||
if (FileAccess::file_exists(md)) {
|
if (FileAccess::file_exists(md)) {
|
||||||
const int built = add_attribute_data(std::string(md.utf8().get_data()), place, root, mi);
|
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;
|
mdatr_built = built > 0;
|
||||||
if (mdatr_built)
|
if (mdatr_built)
|
||||||
++objects_mdatr_built;
|
++objects_mdatr_built;
|
||||||
|
} else {
|
||||||
|
load_attribute_heights(md_str, place, out_heights);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A malformed/missing attribute resource still gets a conservative
|
// 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 tx = int(mx_cm / fmt::m2coord::CHUNK_CM);
|
||||||
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
|
const int ty = int(my_abs_cm / fmt::m2coord::CHUNK_CM);
|
||||||
const Chunk *c = chunk_at(tx, ty);
|
const Chunk *c = chunk_at(tx, ty);
|
||||||
if (!c || !c->hm)
|
double terrain_h = 0.0;
|
||||||
return 0.0;
|
if (c && c->hm) {
|
||||||
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
|
const double lx = mx_cm - double(tx) * fmt::m2coord::CHUNK_CM;
|
||||||
const double ly = my_abs_cm - double(ty) * 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;
|
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<godot::Image> Metin2World::load_dds(const String &path) const {
|
Ref<godot::Image> Metin2World::load_dds(const String &path) const {
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ class Metin2World : public godot::Node3D {
|
|||||||
GDCLASS(Metin2World, godot::Node3D)
|
GDCLASS(Metin2World, godot::Node3D)
|
||||||
|
|
||||||
public:
|
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();
|
||||||
~Metin2World() override;
|
~Metin2World() override;
|
||||||
|
|
||||||
@@ -154,6 +164,7 @@ private:
|
|||||||
std::shared_ptr<fmt::AttrMap> am;
|
std::shared_ptr<fmt::AttrMap> am;
|
||||||
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
|
godot::Node3D *root = nullptr; // 该区块的全部场景节点(terrain + water + 对象 + 树)
|
||||||
int objects = 0, trees = 0;
|
int objects = 0, trees = 0;
|
||||||
|
std::vector<HeightTriangle> height_triangles;
|
||||||
};
|
};
|
||||||
struct AmbienceSource {
|
struct AmbienceSource {
|
||||||
int tile_x = 0, tile_y = 0;
|
int tile_x = 0, tile_y = 0;
|
||||||
@@ -173,7 +184,8 @@ private:
|
|||||||
|
|
||||||
godot::String map_dir() const;
|
godot::String map_dir() const;
|
||||||
bool build_chunk(int tx, int ty);
|
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<HeightTriangle> &out_heights);
|
||||||
void place_chunk_ambience(int tx, int ty);
|
void place_chunk_ambience(int tx, int ty);
|
||||||
void unload_chunk(int idx);
|
void unload_chunk(int idx);
|
||||||
void stream_update();
|
void stream_update();
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ private:
|
|||||||
if (header == HDR_GC_MARK_BLOCK) {
|
if (header == HDR_GC_MARK_BLOCK) {
|
||||||
return receive_block(packet, available);
|
return receive_block(packet, available);
|
||||||
}
|
}
|
||||||
|
if (header == HDR_GC_MARK_DIFF_DATA) {
|
||||||
|
// GuildMarkDownloader.cpp: sizeof(BYTE), dispatch returns true.
|
||||||
|
return raw(ClassicStream::RawPacketStatus::Consumed, 1);
|
||||||
|
}
|
||||||
return raw(ClassicStream::RawPacketStatus::NotHandled);
|
return raw(ClassicStream::RawPacketStatus::NotHandled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,25 @@
|
|||||||
|
|
||||||
namespace mtnet::classic {
|
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) {
|
void ClassicParser::set_npclist_path(std::string path) {
|
||||||
if (path == m_npclist_path) {
|
if (path == m_npclist_path) {
|
||||||
return;
|
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)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
m_empire = p.empire;
|
||||||
m_world.mut_spawn_main(p.vid, p.race,
|
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,
|
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||||
(float)p.z);
|
(float)p.z);
|
||||||
|
m_world.mut_skill_group(p.skill_group);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HDR_GC_MAIN_CHARACTER3_BGM: {
|
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)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
m_empire = p.empire;
|
||||||
m_world.mut_spawn_main(p.vid, p.race,
|
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,
|
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||||
(float)p.z);
|
(float)p.z);
|
||||||
|
m_world.mut_skill_group(p.skill_group);
|
||||||
// §9.1: route the map's background-music track; volume unspecified.
|
// §9.1: route the map's background-music track; volume unspecified.
|
||||||
m_world.mut_map_bgm(
|
m_world.mut_map_bgm(
|
||||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
|
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)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
m_empire = p.empire;
|
||||||
m_world.mut_spawn_main(p.vid, p.race,
|
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,
|
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||||
(float)p.z);
|
(float)p.z);
|
||||||
|
m_world.mut_skill_group(p.skill_group);
|
||||||
// §9.1: track + server-authored volume (0..1).
|
// §9.1: track + server-authored volume (0..1).
|
||||||
m_world.mut_map_bgm(
|
m_world.mut_map_bgm(
|
||||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
|
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)) {
|
if (is_invisible_race(p.race)) {
|
||||||
return true;
|
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 /
|
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
|
||||||
// parts, so stash a bare record and wait for
|
// parts, so stash a bare record and wait for
|
||||||
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
|
// 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 = Entity{};
|
||||||
e.vid = p.vid;
|
e.vid = p.vid;
|
||||||
e.race = p.race;
|
e.race = p.race;
|
||||||
e.ch_type = p.type;
|
e.ch_type = ch_type;
|
||||||
e.x = (float)p.x;
|
e.x = (float)p.x;
|
||||||
e.y = (float)p.y;
|
e.y = (float)p.y;
|
||||||
e.z = (float)p.z;
|
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
|
// Monster / stone / door / object: appended immediately, name from
|
||||||
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
|
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
|
||||||
// PC-specific field stays 0.
|
// 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);
|
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
||||||
return true;
|
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)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
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,
|
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);
|
(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,
|
m_world.mut_char_info(p.vid, {}, p.parts, p.empire, (int32_t)p.guild, 0,
|
||||||
@@ -326,12 +352,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
p.anti_flags, p.sockets, a);
|
p.anti_flags, p.sockets, a);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HDR_GC_ITEM_DEL: { // 20 — inventory pos only
|
case HDR_GC_ITEM_DEL: { // 20 — TPacketGCItemDelDeprecated, read as ITEM_SET by 40250
|
||||||
GCItemDel p;
|
GCItemDelDeprecated p;
|
||||||
if (!fill(p, header, body, len)) {
|
if (!fill(p, header, body, len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
m_world.mut_item_del(/*WINDOW_INVENTORY*/ 1, p.pos);
|
if (p.vnum == 0) {
|
||||||
|
m_world.mut_item_del(p.cell.window_type, p.cell.cell);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
mtnet::ItemAttr a[ITEM_ATTRIBUTE_MAX_NUM];
|
||||||
|
for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) {
|
||||||
|
a[i] = {p.attrs[i].type, p.attrs[i].value};
|
||||||
|
}
|
||||||
|
m_world.mut_item_set(p.cell.window_type, p.cell.cell, p.vnum, p.count, 0, 0, p.sockets, a);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HDR_GC_ITEM_UPDATE: { // 25
|
case HDR_GC_ITEM_UPDATE: { // 25
|
||||||
@@ -900,7 +934,19 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
std::memcpy(&si, cur, sizeof(si));
|
std::memcpy(&si, cur, sizeof(si));
|
||||||
cur += sizeof(si);
|
cur += sizeof(si);
|
||||||
if (si.vnum != 0) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -965,8 +1011,13 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
}
|
}
|
||||||
ShopUpdateItem update;
|
ShopUpdateItem update;
|
||||||
std::memcpy(&update, cur, sizeof(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,
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
case SHOP_SUB_UPDATE_PRICE: {
|
case SHOP_SUB_UPDATE_PRICE: {
|
||||||
@@ -1331,7 +1382,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
|||||||
m_world.mut_observer(ObserverEvent::Remove, packet.vid, 0, 0);
|
m_world.mut_observer(ObserverEvent::Remove, packet.vid, 0, 0);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case HDR_GC_REFINE_INFORMATION_OLD:
|
case HDR_GC_REFINE_INFORMATION_OLD: { // 95 — RecvRefineInformationPacket, no type
|
||||||
|
GCRefineInfoOld packet;
|
||||||
|
if (!fill(packet, header, body, len)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
RefineCue::Mat materials[5] = {};
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
materials[i].vnum = packet.materials[i].vnum;
|
||||||
|
materials[i].count = packet.materials[i].count;
|
||||||
|
}
|
||||||
|
m_world.mut_refine(0, packet.pos, packet.src_vnum, packet.result_vnum,
|
||||||
|
packet.material_count, packet.cost, packet.prob, materials);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
case HDR_GC_REFINE_INFORMATION: {
|
case HDR_GC_REFINE_INFORMATION: {
|
||||||
GCRefineInfo packet;
|
GCRefineInfo packet;
|
||||||
if (!fill(packet, header, body, len)) {
|
if (!fill(packet, header, body, len)) {
|
||||||
|
|||||||
@@ -796,12 +796,8 @@ bool ClassicSession::send_use_skill(uint32_t skill_vnum, uint32_t target_vid) {
|
|||||||
if (m_stage != Stage::InGame) {
|
if (m_stage != Stage::InGame) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (target_vid) {
|
// PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket: only TPacketCGUseSkill.
|
||||||
CGFlyTargeting ft{};
|
// Fly targets are sent by the skill caller (CG_ADD_FLY_TARGETING / OnSetFlyTarget).
|
||||||
ft.header = HDR_CG_FLY_TARGETING;
|
|
||||||
ft.target_vid = target_vid;
|
|
||||||
m_stream.send_fixed(&ft, sizeof(ft));
|
|
||||||
}
|
|
||||||
CGUseSkill p{};
|
CGUseSkill p{};
|
||||||
p.header = HDR_CG_USE_SKILL;
|
p.header = HDR_CG_USE_SKILL;
|
||||||
p.vnum = skill_vnum;
|
p.vnum = skill_vnum;
|
||||||
|
|||||||
@@ -518,6 +518,16 @@ void ClassicStream::dispatch() {
|
|||||||
}
|
}
|
||||||
framed = static_cast<uint32_t>(
|
framed = static_cast<uint32_t>(
|
||||||
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1]));
|
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1]));
|
||||||
|
} else if (header == HDR_GC_GUILD) {
|
||||||
|
// GUILD_SUBHEADER_GC_SKILL_INFO reports one byte more than the
|
||||||
|
// server writes (see GUILD_SKILL_INFO_PACKET_SIZE).
|
||||||
|
uint8_t head[sizeof(DynHead) + 1];
|
||||||
|
if (!m_recv.peek(head, sizeof(head))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (head[sizeof(DynHead)] == GUILD_SUBHEADER_GC_SKILL_INFO) {
|
||||||
|
framed = GUILD_SKILL_INFO_PACKET_SIZE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (m_recv.readable() < framed) {
|
if (m_recv.readable() < framed) {
|
||||||
return; // whole packet not here yet
|
return; // whole packet not here yet
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
|
|||||||
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
||||||
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
||||||
|
|
||||||
// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
|
// CHRTYPE_* is the protocol-neutral Entity::ch_type domain used by the Godot
|
||||||
// (client GameType.h / server char.h). Values line up with the Entity comment.
|
// 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 {
|
enum : uint8_t {
|
||||||
CHRTYPE_PC = 0,
|
CHRTYPE_PC = 0,
|
||||||
CHRTYPE_NPC = 1,
|
CHRTYPE_NPC = 1,
|
||||||
@@ -72,6 +74,22 @@ enum : uint8_t {
|
|||||||
CHRTYPE_GOTO = 9,
|
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/
|
// Races the reference client never spawns — REF/UserInterface/
|
||||||
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
|
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
|
||||||
// for one of these is dropped before any pending/stash step.
|
// for one of these is dropped before any pending/stash step.
|
||||||
@@ -282,6 +300,7 @@ enum : uint8_t {
|
|||||||
HDR_GC_OBSERVER_MOVE = 98,
|
HDR_GC_OBSERVER_MOVE = 98,
|
||||||
HDR_GC_VIEW_EQUIP = 99,
|
HDR_GC_VIEW_EQUIP = 99,
|
||||||
HDR_GC_MARK_BLOCK = 100,
|
HDR_GC_MARK_BLOCK = 100,
|
||||||
|
HDR_GC_MARK_DIFF_DATA = 101, // mark connection only: bare header, ignored
|
||||||
HDR_GC_MARK_IDXLIST = 102,
|
HDR_GC_MARK_IDXLIST = 102,
|
||||||
HDR_GC_TIME = 106,
|
HDR_GC_TIME = 106,
|
||||||
HDR_GC_CHANGE_NAME = 107,
|
HDR_GC_CHANGE_NAME = 107,
|
||||||
@@ -944,6 +963,19 @@ struct GCViewEquip {
|
|||||||
};
|
};
|
||||||
struct GCChangeName { uint8_t header; uint32_t pid; char name[CHARACTER_NAME_MAX_LEN + 1]; };
|
struct GCChangeName { uint8_t header; uint32_t pid; char name[CHARACTER_NAME_MAX_LEN + 1]; };
|
||||||
struct GCRefineMaterial { uint32_t vnum; int32_t count; };
|
struct GCRefineMaterial { uint32_t vnum; int32_t count; };
|
||||||
|
// Header 95: client TPacketGCRefineInformation = [hdr][pos][TRefineTable], no type.
|
||||||
|
struct GCRefineInfoOld {
|
||||||
|
uint8_t header;
|
||||||
|
uint8_t pos;
|
||||||
|
uint32_t src_vnum;
|
||||||
|
uint32_t result_vnum;
|
||||||
|
uint8_t material_count;
|
||||||
|
int32_t cost;
|
||||||
|
int32_t prob;
|
||||||
|
GCRefineMaterial materials[5];
|
||||||
|
};
|
||||||
|
static_assert(sizeof(GCRefineInfoOld) == 59);
|
||||||
|
// Header 119: server TPacketGCRefineInformation == client TPacketGCRefineInformationNew.
|
||||||
struct GCRefineInfo {
|
struct GCRefineInfo {
|
||||||
uint8_t header;
|
uint8_t header;
|
||||||
uint8_t type;
|
uint8_t type;
|
||||||
@@ -1148,9 +1180,22 @@ struct GCItemSet { // packet_item_set (server packet.h:1134, header 21)
|
|||||||
};
|
};
|
||||||
static_assert(sizeof(GCItemSet) == 1 + 3 + 4 + 1 + 4 + 4 + 1 + 12 + 21); // 51
|
static_assert(sizeof(GCItemSet) == 1 + 3 + 4 + 1 + 4 + 4 + 1 + 12 + 21); // 51
|
||||||
|
|
||||||
struct GCItemDel { uint8_t header; uint8_t pos; }; // header 20 — inventory pos only
|
struct GCItemDel { uint8_t header; uint8_t pos; }; // TPacketGCItemDel — SAFEBOX_DEL / MALL_DEL
|
||||||
static_assert(sizeof(GCItemDel) == 2);
|
static_assert(sizeof(GCItemDel) == 2);
|
||||||
|
|
||||||
|
// Header 20: the 40250 server sends TPacketGCItemDelDeprecated (packet.h:1124,
|
||||||
|
// char_item.cpp:424) when an inventory cell empties; the 40250 client reads the
|
||||||
|
// same 42 bytes as its HEADER_GC_ITEM_SET (no flags/anti_flags/highlight).
|
||||||
|
struct GCItemDelDeprecated {
|
||||||
|
uint8_t header;
|
||||||
|
ItemPos cell;
|
||||||
|
uint32_t vnum;
|
||||||
|
uint8_t count;
|
||||||
|
int32_t sockets[ITEM_SOCKET_MAX_NUM];
|
||||||
|
ItemAttr3 attrs[ITEM_ATTRIBUTE_MAX_NUM];
|
||||||
|
};
|
||||||
|
static_assert(sizeof(GCItemDelDeprecated) == 1 + 3 + 4 + 1 + 12 + 21); // 42
|
||||||
|
|
||||||
// GC_MESSENGER is dynamic: after [header][u16 size], body starts with the
|
// GC_MESSENGER is dynamic: after [header][u16 size], body starts with the
|
||||||
// subheader and then the subheader-specific records below.
|
// subheader and then the subheader-specific records below.
|
||||||
struct GCMessengerHead { uint8_t header; uint16_t size; uint8_t subheader; };
|
struct GCMessengerHead { uint8_t header; uint16_t size; uint8_t subheader; };
|
||||||
@@ -1249,6 +1294,14 @@ static_assert(sizeof(GuildMember38) == 38 && sizeof(GuildInfo35) == 35 &&
|
|||||||
sizeof(GuildName16) == 16 && sizeof(GuildSkill17) == 17 &&
|
sizeof(GuildName16) == 16 && sizeof(GuildSkill17) == 17 &&
|
||||||
sizeof(GuildComment80) == 80 && sizeof(GuildInvite17) == 17);
|
sizeof(GuildComment80) == 80 && sizeof(GuildInvite17) == 17);
|
||||||
|
|
||||||
|
// CGuild::SendSkillInfoPacket() (guild.cpp) announces
|
||||||
|
// `size = sizeof(pack) + 6 + GUILD_SKILL_COUNT` (22) but writes only
|
||||||
|
// skill_point, abySkill[12], power(2) and max_power(2) after the head — 21
|
||||||
|
// bytes. The original client's RecvGuild() reads those fields one by one, so
|
||||||
|
// framing off the size field would swallow the next packet's header byte.
|
||||||
|
inline constexpr uint8_t GUILD_SUBHEADER_GC_SKILL_INFO = 12;
|
||||||
|
inline constexpr int GUILD_SKILL_INFO_PACKET_SIZE = 4 + sizeof(GuildSkill17); // 21
|
||||||
|
|
||||||
// --- chat (dynamic) ---
|
// --- chat (dynamic) ---
|
||||||
struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[]
|
struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[]
|
||||||
static_assert(sizeof(CGChatHead) == 4);
|
static_assert(sizeof(CGChatHead) == 4);
|
||||||
@@ -1382,7 +1435,7 @@ constexpr int packet_size_gc(uint8_t h) {
|
|||||||
case HDR_GC_CHARACTER_POINTS: return sizeof(GCPoints);
|
case HDR_GC_CHARACTER_POINTS: return sizeof(GCPoints);
|
||||||
case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange);
|
case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange);
|
||||||
case HDR_GC_ITEM_SET: return sizeof(GCItemSet);
|
case HDR_GC_ITEM_SET: return sizeof(GCItemSet);
|
||||||
case HDR_GC_ITEM_DEL: return sizeof(GCItemDel);
|
case HDR_GC_ITEM_DEL: return sizeof(GCItemDelDeprecated);
|
||||||
case HDR_GC_ITEM_USE: return sizeof(GCItemUse);
|
case HDR_GC_ITEM_USE: return sizeof(GCItemUse);
|
||||||
case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate);
|
case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate);
|
||||||
case HDR_GC_ITEM_GROUND_ADD: return sizeof(GCItemGroundAdd);
|
case HDR_GC_ITEM_GROUND_ADD: return sizeof(GCItemGroundAdd);
|
||||||
@@ -1447,7 +1500,7 @@ constexpr int packet_size_gc(uint8_t h) {
|
|||||||
case HDR_GC_LOVE_POINT_UPDATE: return sizeof(GCLovePointUpdate);
|
case HDR_GC_LOVE_POINT_UPDATE: return sizeof(GCLovePointUpdate);
|
||||||
case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion);
|
case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion);
|
||||||
case HDR_GC_VIEW_EQUIP: return sizeof(GCViewEquip);
|
case HDR_GC_VIEW_EQUIP: return sizeof(GCViewEquip);
|
||||||
case HDR_GC_REFINE_INFORMATION_OLD:
|
case HDR_GC_REFINE_INFORMATION_OLD: return sizeof(GCRefineInfoOld);
|
||||||
case HDR_GC_REFINE_INFORMATION: return sizeof(GCRefineInfo);
|
case HDR_GC_REFINE_INFORMATION: return sizeof(GCRefineInfo);
|
||||||
case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine);
|
case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine);
|
||||||
case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm);
|
case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm);
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ void dbg_ignored(const char *what, uint32_t id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server GetDegreeFromPosition (game/src/vector.cpp): compass heading of a
|
||||||
|
// server-frame direction, 0 = +Y, clockwise toward +X, in [0, 360). Same frame
|
||||||
|
// as the decoded GC_MOVE bRot, i.e. Entity::angle.
|
||||||
|
float heading_deg(float dx, float dy) {
|
||||||
|
float deg = std::atan2(dx, dy) * (180.0f / 3.14159265f);
|
||||||
|
if (deg < 0.0f) {
|
||||||
|
deg += 360.0f;
|
||||||
|
}
|
||||||
|
return deg;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Entity &EntityStore::touch(uint32_t vid, bool &created) {
|
Entity &EntityStore::touch(uint32_t vid, bool &created) {
|
||||||
@@ -52,6 +63,9 @@ Entity &EntityStore::touch(uint32_t vid, bool &created) {
|
|||||||
if (created) {
|
if (created) {
|
||||||
Entity e;
|
Entity e;
|
||||||
e.vid = vid;
|
e.vid = vid;
|
||||||
|
if (vid == m_main_vid) {
|
||||||
|
e.is_main = true;
|
||||||
|
}
|
||||||
it = m_ents.emplace(vid, e).first;
|
it = m_ents.emplace(vid, e).first;
|
||||||
}
|
}
|
||||||
return it->second;
|
return it->second;
|
||||||
@@ -102,6 +116,7 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
|
|||||||
e.z = z;
|
e.z = z;
|
||||||
e.is_main = true;
|
e.is_main = true;
|
||||||
e.moving = false;
|
e.moving = false;
|
||||||
|
e.state_queue.clear();
|
||||||
m_main_vid = vid;
|
m_main_vid = vid;
|
||||||
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, vid});
|
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, vid});
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -129,6 +144,10 @@ void EntityStore::mut_spawn_full(const Entity &src) {
|
|||||||
if (e.vid == m_main_vid) {
|
if (e.vid == m_main_vid) {
|
||||||
e.is_main = true;
|
e.is_main = true;
|
||||||
}
|
}
|
||||||
|
if (e.is_main) {
|
||||||
|
e.moving = false;
|
||||||
|
e.state_queue.clear();
|
||||||
|
}
|
||||||
m_ents[src.vid] = e;
|
m_ents[src.vid] = e;
|
||||||
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
||||||
if (e.is_main) {
|
if (e.is_main) {
|
||||||
@@ -199,12 +218,26 @@ void EntityStore::mut_despawn(uint32_t vid) {
|
|||||||
// past (or 0) still applies synchronously.
|
// past (or 0) still applies synchronously.
|
||||||
void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
|
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) {
|
uint32_t dur_ms, uint8_t arg, uint32_t chk_time) {
|
||||||
|
if (vid == m_main_vid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
auto it = m_ents.find(vid);
|
auto it = m_ents.find(vid);
|
||||||
if (it == m_ents.end()) {
|
if (it == m_ents.end() || it->second.is_main) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Entity &e = it->second;
|
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);
|
drain_state_queue(e);
|
||||||
m_changes.push_back({ChangeKind::Move, vid});
|
m_changes.push_back({ChangeKind::Move, vid});
|
||||||
}
|
}
|
||||||
@@ -240,7 +273,11 @@ void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_f
|
|||||||
if (dur == 0) {
|
if (dur == 0) {
|
||||||
dur = 1; // never a zero-length walk; tick() finishes it next frame
|
dur = 1; // never a zero-length walk; tick() finishes it next frame
|
||||||
}
|
}
|
||||||
e.angle = c.rot;
|
// Reference MovementProcess faces a walking remote actor along Src->Dst
|
||||||
|
// (SetAdvancingRotation from the pixel positions); the packet's bRot is only
|
||||||
|
// applied once it stops (m_fDstRot). Server Follow() can send a bRot that
|
||||||
|
// faces the victim while Dst is a flank point, so using bRot here slides.
|
||||||
|
e.angle = dist > 0.0f ? heading_deg(dx, dy) : c.rot;
|
||||||
e.moving = true;
|
e.moving = true;
|
||||||
e.func = FUNC_MOVE;
|
e.func = FUNC_MOVE;
|
||||||
e.sx = e.x;
|
e.sx = e.x;
|
||||||
@@ -336,6 +373,9 @@ void EntityStore::drain_state_queue(Entity &e) {
|
|||||||
|
|
||||||
void EntityStore::process_states() {
|
void EntityStore::process_states() {
|
||||||
for (auto &kv : m_ents) {
|
for (auto &kv : m_ents) {
|
||||||
|
if (kv.second.is_main) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
drain_state_queue(kv.second);
|
drain_state_queue(kv.second);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +437,14 @@ void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
|
|||||||
}
|
}
|
||||||
Entity &e = it->second;
|
Entity &e = it->second;
|
||||||
switch (type) {
|
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_MAX_HP: e.max_hp = value; break;
|
||||||
case POINT_SP: e.sp = value; break;
|
case POINT_SP: e.sp = value; break;
|
||||||
case POINT_MAX_SP: e.max_sp = value; break;
|
case POINT_MAX_SP: e.max_sp = value; break;
|
||||||
@@ -844,7 +891,8 @@ void EntityStore::mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs)
|
|||||||
m_shop_dirty = true;
|
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) {
|
if (!m_shop_open || pos >= SHOP_HOST_ITEM_MAX_NUM) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -856,18 +904,47 @@ void EntityStore::mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t pric
|
|||||||
if (vnum == 0) {
|
if (vnum == 0) {
|
||||||
items.erase(items.begin() + (long)i);
|
items.erase(items.begin() + (long)i);
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
if (vnum != 0) {
|
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);
|
update(m_shop_items);
|
||||||
if (!m_shop_tabs.empty()) {
|
if (!m_shop_tabs.empty()) {
|
||||||
update(m_shop_tabs[0].items);
|
update(m_shop_tabs[0].items);
|
||||||
}
|
}
|
||||||
|
m_shop_slot_updates.push_back(pos);
|
||||||
m_shop_dirty = true;
|
m_shop_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,6 +958,7 @@ void EntityStore::mut_shop_close() {
|
|||||||
m_shop_vid = 0;
|
m_shop_vid = 0;
|
||||||
m_shop_items.clear();
|
m_shop_items.clear();
|
||||||
m_shop_tabs.clear();
|
m_shop_tabs.clear();
|
||||||
|
m_shop_slot_updates.clear();
|
||||||
m_shop_selling_price = 0;
|
m_shop_selling_price = 0;
|
||||||
m_shop_dirty = true;
|
m_shop_dirty = true;
|
||||||
}
|
}
|
||||||
@@ -1566,6 +1644,7 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
e.z = (float)p.z;
|
e.z = (float)p.z;
|
||||||
e.is_main = true;
|
e.is_main = true;
|
||||||
e.moving = false;
|
e.moving = false;
|
||||||
|
e.state_queue.clear();
|
||||||
m_main_vid = p.vid;
|
m_main_vid = p.vid;
|
||||||
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, p.vid});
|
m_changes.push_back({created ? ChangeKind::Spawn : ChangeKind::MainSet, p.vid});
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -1581,6 +1660,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
std::memcpy(&p, body, sizeof(p));
|
std::memcpy(&p, body, sizeof(p));
|
||||||
bool created;
|
bool created;
|
||||||
Entity &e = touch(p.vid, 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.race = p.race;
|
||||||
e.ch_type = p.type;
|
e.ch_type = p.type;
|
||||||
e.angle = p.angle;
|
e.angle = p.angle;
|
||||||
@@ -1601,6 +1685,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
std::memcpy(&p, body, sizeof(p));
|
std::memcpy(&p, body, sizeof(p));
|
||||||
bool created;
|
bool created;
|
||||||
Entity &e = touch(p.vid, 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.race = p.race;
|
||||||
e.ch_type = p.type;
|
e.ch_type = p.type;
|
||||||
e.angle = p.angle;
|
e.angle = p.angle;
|
||||||
@@ -1686,9 +1775,12 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
}
|
}
|
||||||
GCMove p;
|
GCMove p;
|
||||||
std::memcpy(&p, body, sizeof(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.
|
// CLIENT-GAP §3.2: same TCP state queue as the classic backend.
|
||||||
// m2dev heading is rot*360/256.
|
// Server sends (int)GetRotation() / 5 (char.cpp SendMovePacket), client decodes rot * 5.0f.
|
||||||
mut_move(p.vid, (float)p.rot * (360.0f / 256.0f), p.func, (float)p.x, (float)p.y,
|
mut_move(p.vid, (float)p.rot * 5.0f, p.func, (float)p.x, (float)p.y,
|
||||||
p.duration, p.arg, p.time);
|
p.duration, p.arg, p.time);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2041,7 +2133,14 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
|
|||||||
if (it != m_ents.end()) {
|
if (it != m_ents.end()) {
|
||||||
Entity &e = it->second;
|
Entity &e = it->second;
|
||||||
switch (p.type) {
|
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_MAX_HP: e.max_hp = p.value; break;
|
||||||
case POINT_SP: e.sp = p.value; break;
|
case POINT_SP: e.sp = p.value; break;
|
||||||
case POINT_MAX_SP: e.max_sp = p.value; break;
|
case POINT_MAX_SP: e.max_sp = p.value; break;
|
||||||
@@ -3555,11 +3654,147 @@ void EntityStore::tick() {
|
|||||||
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
|
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
|
||||||
// advance the walk. A queued command released here starts its walk this tick.
|
// advance the walk. A queued command released here starts its walk this tick.
|
||||||
process_states();
|
process_states();
|
||||||
|
uint32_t dt_ms = m_ticked && m_now > m_last_tick_ms ? m_now - m_last_tick_ms : 0;
|
||||||
|
if (dt_ms > MOVE_MAX_TICK_MS) {
|
||||||
|
dt_ms = MOVE_MAX_TICK_MS;
|
||||||
|
}
|
||||||
|
m_last_tick_ms = m_now;
|
||||||
|
m_ticked = true;
|
||||||
for (auto &kv : m_ents) {
|
for (auto &kv : m_ents) {
|
||||||
Entity &e = kv.second;
|
Entity &e = kv.second;
|
||||||
if (!e.moving) {
|
if (!e.moving || e.is_main) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const float speed = motion_move_speed(e);
|
||||||
|
if (speed > 0.0f) {
|
||||||
|
advance_walk_by_motion(e, speed, dt_ms);
|
||||||
|
} else {
|
||||||
|
advance_walk_by_duration(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s) {
|
||||||
|
auto it = m_ents.find(vid);
|
||||||
|
if (it == m_ents.end()) {
|
||||||
|
dbg_ignored("set_motion_speed", vid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
// m_fMovSpd) and CInstanceBase::SetMoveSpeed: m_fMovSpd = movSpd / 100 (> 1100 ->
|
||||||
|
// 0). The walk then advances by that motion's accumulation. Deviations: movSpd 0
|
||||||
|
// (not sent yet) counts as 100, and a zero factor (> 1100) falls back to the
|
||||||
|
// dwDuration lerp instead of freezing the actor mid-walk. The pushed speeds are
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const float factor = e.moving_speed == 0 ? 1.0f : (float)e.moving_speed / 100.0f;
|
||||||
|
return base * factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference m_kMovAfterFunc switch on arrival: COMBO / ATTACK / MOB_SKILL snap
|
||||||
|
// to Dst and act; everything else snaps to Dst, faces m_fDstRot and stops.
|
||||||
|
void EntityStore::finish_walk(Entity &e, bool snap_to_dst) {
|
||||||
|
if (snap_to_dst) {
|
||||||
|
e.x = e.tx;
|
||||||
|
e.y = e.ty;
|
||||||
|
}
|
||||||
|
e.moving = false;
|
||||||
|
e.skip_collision = false;
|
||||||
|
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
|
||||||
|
e.mov_after_func == FUNC_MOB_SKILL) {
|
||||||
|
e.func = e.mov_after_func;
|
||||||
|
} else {
|
||||||
|
e.func = FUNC_WAIT;
|
||||||
|
}
|
||||||
|
e.angle = e.mov_after_rot;
|
||||||
|
e.mov_after_func = FUNC_WAIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference CInstanceBase::MovementProcess (non-main branch). Src/Dst are the
|
||||||
|
// server-frame points from the state command; Cur advances along Src->Dst by
|
||||||
|
// the motion speed and "arrives" on the frame whose step crosses Dst.
|
||||||
|
void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms) {
|
||||||
|
float dir_x = e.tx - e.sx;
|
||||||
|
float dir_y = e.ty - e.sy;
|
||||||
|
float total = std::sqrt(dir_x * dir_x + dir_y * dir_y);
|
||||||
|
if (total > 0.0f) {
|
||||||
|
e.angle = heading_deg(dir_x, dir_y);
|
||||||
|
dir_x /= total;
|
||||||
|
dir_y /= total;
|
||||||
|
}
|
||||||
|
const float step = speed * (float)dt_ms / 1000.0f;
|
||||||
|
const float next_x = e.x + dir_x * step;
|
||||||
|
const float next_y = e.y + dir_y * step;
|
||||||
|
const float cx = e.x - e.sx, cy = e.y - e.sy;
|
||||||
|
const float nx = next_x - e.sx, ny = next_y - e.sy;
|
||||||
|
const float cur_len = std::sqrt(cx * cx + cy * cy);
|
||||||
|
const float next_len = std::sqrt(nx * nx + ny * ny);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.x = next_x;
|
||||||
|
e.y = next_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when the actor's motion speed is unknown: lerp Src->Dst over the
|
||||||
|
// server-supplied dwDuration and stop on time.
|
||||||
|
void EntityStore::advance_walk_by_duration(Entity &e) {
|
||||||
float t = e.move_dur_ms == 0
|
float t = e.move_dur_ms == 0
|
||||||
? 1.0f
|
? 1.0f
|
||||||
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms;
|
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms;
|
||||||
@@ -3567,27 +3802,12 @@ void EntityStore::tick() {
|
|||||||
t = 0.0f;
|
t = 0.0f;
|
||||||
}
|
}
|
||||||
if (t >= 1.0f) {
|
if (t >= 1.0f) {
|
||||||
e.x = e.tx;
|
finish_walk(e, true);
|
||||||
e.y = e.ty;
|
|
||||||
e.moving = false;
|
|
||||||
e.skip_collision = false;
|
|
||||||
// reference m_kMovAfterFunc: once the walk reaches Dst, run the
|
|
||||||
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
|
|
||||||
// otherwise settle to WAIT.
|
|
||||||
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
|
|
||||||
e.mov_after_func == FUNC_MOB_SKILL) {
|
|
||||||
e.func = e.mov_after_func;
|
|
||||||
e.angle = e.mov_after_rot;
|
|
||||||
} else {
|
|
||||||
e.func = FUNC_WAIT;
|
|
||||||
}
|
|
||||||
e.mov_after_func = FUNC_WAIT;
|
|
||||||
} else {
|
} else {
|
||||||
e.x = e.sx + (e.tx - e.sx) * t;
|
e.x = e.sx + (e.tx - e.sx) * t;
|
||||||
e.y = e.sy + (e.ty - e.sy) * t;
|
e.y = e.sy + (e.ty - e.sy) * t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const Entity *EntityStore::get(uint32_t vid) const {
|
const Entity *EntityStore::get(uint32_t vid) const {
|
||||||
auto it = m_ents.find(vid);
|
auto it = m_ents.find(vid);
|
||||||
|
|||||||
@@ -51,10 +51,24 @@ struct StateCmd {
|
|||||||
// CLIENT-GAP-FIX.md §3.2.
|
// CLIENT-GAP-FIX.md §3.2.
|
||||||
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
|
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
|
||||||
|
|
||||||
|
// GC_WALK_MODE / TPacketGCWalkMode::mode (40250 Packet.h:2327, server packet.h:1946).
|
||||||
|
enum : uint8_t {
|
||||||
|
WALKMODE_RUN = 0,
|
||||||
|
WALKMODE_WALK = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reference CInstanceBase::MovementProcess (non-main branch): a remote actor that
|
||||||
|
// has walked more than this far past its Dst re-sources the walk at its current
|
||||||
|
// position, turns back toward Dst and downgrades a pending FUNC_MOVE to FUNC_WAIT.
|
||||||
|
static constexpr float MOVE_OVERSHOOT_CM = 100.0f;
|
||||||
|
// Cap on one tick's walk step so a hitch (or the first tick after a long pause)
|
||||||
|
// cannot teleport an actor far past its Dst in a single frame.
|
||||||
|
static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
|
||||||
|
|
||||||
struct Entity {
|
struct Entity {
|
||||||
uint32_t vid = 0;
|
uint32_t vid = 0;
|
||||||
uint16_t race = 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 name;
|
||||||
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
|
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
|
||||||
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
|
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
|
||||||
@@ -65,7 +79,7 @@ struct Entity {
|
|||||||
|
|
||||||
uint8_t func = FUNC_WAIT;
|
uint8_t func = FUNC_WAIT;
|
||||||
uint8_t position = 0;
|
uint8_t position = 0;
|
||||||
uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*)
|
uint8_t walk_mode = WALKMODE_RUN; // server WALKMODE_* (0 run, 1 walk)
|
||||||
uint32_t fly_target_vid = 0;
|
uint32_t fly_target_vid = 0;
|
||||||
int32_t fly_target_x = 0, fly_target_y = 0;
|
int32_t fly_target_x = 0, fly_target_y = 0;
|
||||||
bool fly_target_set = false;
|
bool fly_target_set = false;
|
||||||
@@ -74,12 +88,21 @@ struct Entity {
|
|||||||
float sx = 0, sy = 0, tx = 0, ty = 0;
|
float sx = 0, sy = 0, tx = 0, ty = 0;
|
||||||
uint32_t move_start_ms = 0;
|
uint32_t move_start_ms = 0;
|
||||||
uint32_t move_dur_ms = 0;
|
uint32_t move_dur_ms = 0;
|
||||||
|
// Root-motion speed of the race's WALK / RUN loop at movSpd 100, in cm/s
|
||||||
|
// (.msa Accumulation length / MotionDuration). Pushed by the presentation
|
||||||
|
// layer once the model is built (set_motion_speed). While known, a remote
|
||||||
|
// walk advances like the reference CActorInstance::AccumulationMovement
|
||||||
|
// (motion speed * movSpd/100) and arrives by crossing Dst; while 0 (no model /
|
||||||
|
// headless) it falls back to lerping over the server dwDuration.
|
||||||
|
float walk_motion_speed = 0;
|
||||||
|
float run_motion_speed = 0;
|
||||||
|
|
||||||
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
|
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
|
||||||
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
|
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
|
||||||
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
|
// __EnableSkipCollision(): a walk kicked off by a network state packet ignores
|
||||||
// actor-vs-actor collision until it arrives.
|
// actor-vs-actor collision until it arrives.
|
||||||
std::deque<StateCmd> state_queue;
|
std::deque<StateCmd> 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_func = FUNC_WAIT;
|
||||||
uint8_t mov_after_arg = 0;
|
uint8_t mov_after_arg = 0;
|
||||||
float mov_after_rot = 0;
|
float mov_after_rot = 0;
|
||||||
@@ -283,6 +306,8 @@ struct ShopEntry {
|
|||||||
uint32_t price = 0;
|
uint32_t price = 0;
|
||||||
uint8_t count = 0;
|
uint8_t count = 0;
|
||||||
uint8_t pos = 0; // slot index within its tab (0..SHOP_HOST_ITEM_MAX_NUM-1)
|
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
|
// One shelf/tab of a SHOP_GC_START_EX shop. A plain SHOP_GC_START shop is
|
||||||
@@ -516,6 +541,9 @@ public:
|
|||||||
// and again right after mut_move() enqueues, so an already-due command applies
|
// and again right after mut_move() enqueues, so an already-due command applies
|
||||||
// synchronously.
|
// synchronously.
|
||||||
void process_states();
|
void process_states();
|
||||||
|
// Root-motion speeds (cm/s at movSpd 100) of this actor's WALK / RUN loops;
|
||||||
|
// 0 = unknown. See Entity::walk_motion_speed. No-op for an unknown VID.
|
||||||
|
void set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s);
|
||||||
|
|
||||||
// Feed one complete game-phase packet. `body` points at the packet start
|
// Feed one complete game-phase packet. `body` points at the packet start
|
||||||
// (header/length included); `len` == that length. Unknown headers ignored.
|
// (header/length included); `len` == that length. Unknown headers ignored.
|
||||||
@@ -628,7 +656,8 @@ public:
|
|||||||
|
|
||||||
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
|
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
|
||||||
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs);
|
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &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_update_price(int32_t price);
|
||||||
void mut_shop_close();
|
void mut_shop_close();
|
||||||
void mut_shop_error(const std::string &code);
|
void mut_shop_error(const std::string &code);
|
||||||
@@ -833,6 +862,11 @@ public:
|
|||||||
m_shop_errors.clear();
|
m_shop_errors.clear();
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
std::vector<uint8_t> drain_shop_slot_updates() {
|
||||||
|
auto v = std::move(m_shop_slot_updates);
|
||||||
|
m_shop_slot_updates.clear();
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
// --- P8 exchange ---
|
// --- P8 exchange ---
|
||||||
const ExchangeState &exchange() const { return m_exchange; }
|
const ExchangeState &exchange() const { return m_exchange; }
|
||||||
@@ -1176,11 +1210,18 @@ private:
|
|||||||
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
|
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
|
||||||
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
|
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
|
||||||
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
|
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
|
||||||
|
// Remote-walk advance (reference CInstanceBase::MovementProcess, non-main).
|
||||||
|
static float motion_move_speed(const Entity &e); // cm/s, 0 = use dwDuration lerp
|
||||||
|
static void finish_walk(Entity &e, bool snap_to_dst); // m_kMovAfterFunc on arrival
|
||||||
|
void advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms);
|
||||||
|
void advance_walk_by_duration(Entity &e);
|
||||||
|
|
||||||
std::unordered_map<uint32_t, Entity> m_ents;
|
std::unordered_map<uint32_t, Entity> m_ents;
|
||||||
uint32_t m_main_vid = 0;
|
uint32_t m_main_vid = 0;
|
||||||
uint32_t m_now = 0;
|
uint32_t m_now = 0;
|
||||||
uint32_t m_server_frame_ms = 0;
|
uint32_t m_server_frame_ms = 0;
|
||||||
|
uint32_t m_last_tick_ms = 0; // m_now at the previous tick() (motion-driven walk dt)
|
||||||
|
bool m_ticked = false;
|
||||||
std::vector<Change> m_changes;
|
std::vector<Change> m_changes;
|
||||||
std::vector<uint32_t> m_dirty;
|
std::vector<uint32_t> m_dirty;
|
||||||
std::string m_bgm_name;
|
std::string m_bgm_name;
|
||||||
@@ -1261,6 +1302,7 @@ private:
|
|||||||
bool m_shop_dirty = false;
|
bool m_shop_dirty = false;
|
||||||
int32_t m_shop_selling_price = 0;
|
int32_t m_shop_selling_price = 0;
|
||||||
std::vector<std::string> m_shop_errors;
|
std::vector<std::string> m_shop_errors;
|
||||||
|
std::vector<uint8_t> m_shop_slot_updates;
|
||||||
ExchangeState m_exchange;
|
ExchangeState m_exchange;
|
||||||
bool m_exchange_dirty = false;
|
bool m_exchange_dirty = false;
|
||||||
Item m_safebox[SAFEBOX_MAX_NUM];
|
Item m_safebox[SAFEBOX_MAX_NUM];
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ void M2Client::_bind_methods() {
|
|||||||
ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel);
|
ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel);
|
||||||
ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests);
|
ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests);
|
||||||
ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity);
|
ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity);
|
||||||
|
ClassDB::bind_method(D_METHOD("set_entity_motion_speed", "vid", "walk_cm_s", "run_cm_s"),
|
||||||
|
&M2Client::set_entity_motion_speed);
|
||||||
ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities);
|
ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities);
|
||||||
ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid);
|
ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid);
|
||||||
ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid);
|
ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid);
|
||||||
@@ -282,12 +284,16 @@ void M2Client::_bind_methods() {
|
|||||||
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
|
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
|
||||||
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
|
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
|
||||||
ADD_SIGNAL(MethodInfo("shop_closed"));
|
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("shop_error", PropertyInfo(Variant::STRING, "kind")));
|
||||||
ADD_SIGNAL(MethodInfo("exchange_changed"));
|
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_changed"));
|
||||||
ADD_SIGNAL(MethodInfo("safebox_password_required"));
|
ADD_SIGNAL(MethodInfo("safebox_password_required"));
|
||||||
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
|
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
|
||||||
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
|
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
|
||||||
|
ADD_SIGNAL(MethodInfo("mall_closed"));
|
||||||
ADD_SIGNAL(MethodInfo("mall_changed"));
|
ADD_SIGNAL(MethodInfo("mall_changed"));
|
||||||
ADD_SIGNAL(MethodInfo("mall_password_required"));
|
ADD_SIGNAL(MethodInfo("mall_password_required"));
|
||||||
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
|
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
|
||||||
@@ -528,6 +534,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
|
|||||||
classic_last_empire = -1;
|
classic_last_empire = -1;
|
||||||
shop_open_seen = false;
|
shop_open_seen = false;
|
||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
|
safebox_open_seen = false;
|
||||||
last_game_phase = -1;
|
last_game_phase = -1;
|
||||||
if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port,
|
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,
|
std::string(g_host.utf8().get_data()), (uint16_t)g_port,
|
||||||
@@ -578,6 +585,7 @@ void M2Client::disconnect_from_server() {
|
|||||||
}
|
}
|
||||||
shop_open_seen = false;
|
shop_open_seen = false;
|
||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
|
safebox_open_seen = false;
|
||||||
selected_pid = 0;
|
selected_pid = 0;
|
||||||
if (stage != Stage::Idle) {
|
if (stage != Stage::Idle) {
|
||||||
set_stage(Stage::Idle);
|
set_stage(Stage::Idle);
|
||||||
@@ -1275,6 +1283,19 @@ static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) {
|
|||||||
d["vnum"] = (int)s.vnum;
|
d["vnum"] = (int)s.vnum;
|
||||||
d["price"] = (int)s.price;
|
d["price"] = (int)s.price;
|
||||||
d["count"] = (int)s.count;
|
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;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,6 +1485,31 @@ int M2Client::get_safebox_gold() const {
|
|||||||
const mtnet::EntityStore *world = active_world();
|
const mtnet::EntityStore *world = active_world();
|
||||||
return world ? (int)world->safebox_gold() : 0;
|
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 M2Client::get_safebox_items() const {
|
||||||
Array out;
|
Array out;
|
||||||
const mtnet::EntityStore *world = active_world();
|
const mtnet::EntityStore *world = active_world();
|
||||||
@@ -1475,10 +1521,8 @@ Array M2Client::get_safebox_items() const {
|
|||||||
if (it.empty()) {
|
if (it.empty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Dictionary d;
|
Dictionary d = item_dict(it);
|
||||||
d["cell"] = c;
|
d["cell"] = c;
|
||||||
d["vnum"] = (int)it.vnum;
|
|
||||||
d["count"] = (int)it.count;
|
|
||||||
out.push_back(d);
|
out.push_back(d);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -1505,10 +1549,8 @@ Array M2Client::get_mall_items() const {
|
|||||||
if (it.empty()) {
|
if (it.empty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Dictionary d;
|
Dictionary d = item_dict(it);
|
||||||
d["cell"] = c;
|
d["cell"] = c;
|
||||||
d["vnum"] = (int)it.vnum;
|
|
||||||
d["count"] = (int)it.count;
|
|
||||||
out.push_back(d);
|
out.push_back(d);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -2474,7 +2516,7 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
|
|||||||
Dictionary d;
|
Dictionary d;
|
||||||
d["vid"] = (int)e.vid;
|
d["vid"] = (int)e.vid;
|
||||||
d["race"] = (int)e.race;
|
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["name"] = String::utf8(e.name.c_str());
|
||||||
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
|
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
|
||||||
d["is_main"] = e.is_main;
|
d["is_main"] = e.is_main;
|
||||||
@@ -2523,6 +2565,13 @@ const mtnet::EntityStore *M2Client::active_world() const {
|
|||||||
return game ? &game->world() : nullptr;
|
return game ? &game->world() : nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void M2Client::set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s) {
|
||||||
|
mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr);
|
||||||
|
if (w) {
|
||||||
|
w->set_motion_speed((uint32_t)vid, (float)walk_cm_s, (float)run_cm_s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Dictionary M2Client::get_entity(int vid) const {
|
Dictionary M2Client::get_entity(int vid) const {
|
||||||
const mtnet::EntityStore *w = active_world();
|
const mtnet::EntityStore *w = active_world();
|
||||||
if (!w) {
|
if (!w) {
|
||||||
@@ -2692,30 +2741,6 @@ bool M2Client::pickup_item(int ground_vid) {
|
|||||||
return game && game->send_item_pickup((uint32_t)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 M2Client::get_inventory() const {
|
||||||
Array out;
|
Array out;
|
||||||
@@ -2921,6 +2946,7 @@ void M2Client::warp_to_game_server(const String &host, int port) {
|
|||||||
classic_last_empire = -1;
|
classic_last_empire = -1;
|
||||||
shop_open_seen = false;
|
shop_open_seen = false;
|
||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
|
safebox_open_seen = false;
|
||||||
dead_seen.clear();
|
dead_seen.clear();
|
||||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
||||||
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
||||||
@@ -3290,6 +3316,9 @@ void M2Client::pump_classic() {
|
|||||||
emit_signal("shop_closed");
|
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()) {
|
for (const auto &err : w.drain_shop_errors()) {
|
||||||
emit_signal("shop_error", String::utf8(err.c_str()));
|
emit_signal("shop_error", String::utf8(err.c_str()));
|
||||||
}
|
}
|
||||||
@@ -3316,12 +3345,21 @@ void M2Client::pump_classic() {
|
|||||||
emit_signal("exchange_changed");
|
emit_signal("exchange_changed");
|
||||||
}
|
}
|
||||||
if (w.safebox_dirty()) {
|
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");
|
emit_signal("safebox_changed");
|
||||||
}
|
}
|
||||||
if (w.mall_dirty()) {
|
if (w.mall_dirty()) {
|
||||||
bool mall_now = w.mall_open();
|
bool mall_now = w.mall_open();
|
||||||
if (mall_now && !mall_open_seen) {
|
if (mall_now && !mall_open_seen) {
|
||||||
emit_signal("mall_opened", w.mall_size());
|
emit_signal("mall_opened", w.mall_size());
|
||||||
|
} else if (!mall_now && mall_open_seen) {
|
||||||
|
emit_signal("mall_closed");
|
||||||
}
|
}
|
||||||
mall_open_seen = mall_now;
|
mall_open_seen = mall_now;
|
||||||
emit_signal("mall_changed");
|
emit_signal("mall_changed");
|
||||||
@@ -3424,6 +3462,7 @@ void M2Client::pump_classic() {
|
|||||||
classic_last_stage = -1;
|
classic_last_stage = -1;
|
||||||
shop_open_seen = false;
|
shop_open_seen = false;
|
||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
|
safebox_open_seen = false;
|
||||||
dead_seen.clear();
|
dead_seen.clear();
|
||||||
set_stage(Stage::GameConnect);
|
set_stage(Stage::GameConnect);
|
||||||
return;
|
return;
|
||||||
@@ -3817,6 +3856,9 @@ void M2Client::pump_game() {
|
|||||||
emit_signal("shop_closed");
|
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()) {
|
for (const auto &err : w.drain_shop_errors()) {
|
||||||
emit_signal("shop_error", String::utf8(err.c_str()));
|
emit_signal("shop_error", String::utf8(err.c_str()));
|
||||||
}
|
}
|
||||||
@@ -3824,12 +3866,21 @@ void M2Client::pump_game() {
|
|||||||
emit_signal("exchange_changed");
|
emit_signal("exchange_changed");
|
||||||
}
|
}
|
||||||
if (w.safebox_dirty()) {
|
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");
|
emit_signal("safebox_changed");
|
||||||
}
|
}
|
||||||
if (w.mall_dirty()) {
|
if (w.mall_dirty()) {
|
||||||
bool mall_now = w.mall_open();
|
bool mall_now = w.mall_open();
|
||||||
if (mall_now && !mall_open_seen) {
|
if (mall_now && !mall_open_seen) {
|
||||||
emit_signal("mall_opened", w.mall_size());
|
emit_signal("mall_opened", w.mall_size());
|
||||||
|
} else if (!mall_now && mall_open_seen) {
|
||||||
|
emit_signal("mall_closed");
|
||||||
}
|
}
|
||||||
mall_open_seen = mall_now;
|
mall_open_seen = mall_now;
|
||||||
emit_signal("mall_changed");
|
emit_signal("mall_changed");
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ public:
|
|||||||
|
|
||||||
// --- networked world snapshot (positions already Godot-space, metres) ---
|
// --- networked world snapshot (positions already Godot-space, metres) ---
|
||||||
godot::Dictionary get_entity(int vid) const;
|
godot::Dictionary get_entity(int vid) const;
|
||||||
|
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
|
||||||
|
// measured by the view from its .msa; drives the reference-style remote walk.
|
||||||
|
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
|
||||||
godot::Array get_entities() const;
|
godot::Array get_entities() const;
|
||||||
int get_main_vid() const;
|
int get_main_vid() const;
|
||||||
int get_main_pid() const;
|
int get_main_pid() const;
|
||||||
@@ -367,6 +370,7 @@ private:
|
|||||||
bool enter_game_sent = false;
|
bool enter_game_sent = false;
|
||||||
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
bool shop_open_seen = false; // last shop_open() state we emitted a signal for
|
||||||
bool mall_open_seen = false;
|
bool mall_open_seen = false;
|
||||||
|
bool safebox_open_seen = false;
|
||||||
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
uint32_t last_login_key = 0; // for a cross-server GC_WARP reconnect
|
||||||
void warp_to_game_server(const godot::String &host, int port);
|
void warp_to_game_server(const godot::String &host, int port);
|
||||||
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
std::unordered_set<uint32_t> dead_seen; // vids we've already fired entity_dead for
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
|
|||||||
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
|
mat->set_albedo(Color(0.7f, 0.7f, 0.7f));
|
||||||
mat->set_roughness(1.0f);
|
mat->set_roughness(1.0f);
|
||||||
mat->set_texture_filter(StandardMaterial3D::TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC);
|
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();
|
String t = String(tex_name.c_str()).to_lower();
|
||||||
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
|
bool kw_alpha = t.find("leaf") != -1 || t.find("grass") != -1 || t.find("fence") != -1 ||
|
||||||
@@ -78,6 +82,10 @@ Ref<StandardMaterial3D> material_for(const std::string &tex_name, bool alpha_ble
|
|||||||
if (tex.is_valid()) {
|
if (tex.is_valid()) {
|
||||||
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
|
mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, tex);
|
||||||
mat->set_albedo(Color(1, 1, 1));
|
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;
|
return mat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ static void test_mark_download() {
|
|||||||
assert(request.size() == sizeof(CGMarkIDXList));
|
assert(request.size() == sizeof(CGMarkIDXList));
|
||||||
assert(request[0] == HDR_CG_MARK_IDXLIST);
|
assert(request[0] == HDR_CG_MARK_IDXLIST);
|
||||||
|
|
||||||
|
// GuildMarkDownloader.cpp:172/201 frames HEADER_GC_MARK_DIFF_DATA (101) as a
|
||||||
|
// bare header byte and ignores it; the next mark frame must still parse.
|
||||||
|
const uint8_t diff = 101;
|
||||||
|
client.stream().feed(&diff, 1);
|
||||||
|
assert(client.stream().last_error().empty());
|
||||||
|
|
||||||
// One guild maps to image zero, position one. Feed the whole-size frame in
|
// One guild maps to image zero, position one. Feed the whole-size frame in
|
||||||
// two chunks to exercise the custom raw framing path.
|
// two chunks to exercise the custom raw framing path.
|
||||||
std::vector<uint8_t> idx(sizeof(GCMarkIDXList) + 4);
|
std::vector<uint8_t> idx(sizeof(GCMarkIDXList) + 4);
|
||||||
|
|||||||
@@ -183,6 +183,26 @@ int main() {
|
|||||||
"on_char_list fired with renamed slot");
|
"on_char_list fired with renamed slot");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GC 9 create failure: input_login.cpp:442-465 sends a zeroed 10-byte
|
||||||
|
// TPacketGCLoginFailure under header 9 (blocked creation / bad name). The 40250
|
||||||
|
// client reads TPacketGCCreateFailure (2 bytes) and PythonNetworkStream.cpp:509
|
||||||
|
// skips the trailing zero bytes as blank headers. input_db.cpp:196 sends 2 bytes. ---
|
||||||
|
{
|
||||||
|
s.parser().drain_char_events();
|
||||||
|
std::vector<uint8_t> cf(10, 0);
|
||||||
|
cf[0] = HDR_GC_CREATE_FAILURE;
|
||||||
|
GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1};
|
||||||
|
std::vector<uint8_t> db = raw(dup);
|
||||||
|
cf.insert(cf.end(), db.begin(), db.end());
|
||||||
|
feed(s, cf);
|
||||||
|
auto ev = s.parser().drain_char_events();
|
||||||
|
CHECK(ev.size() == 2 && ev[0].kind == ClassicParser::CharEvent::CreateFail &&
|
||||||
|
ev[0].fail_type == 0 && ev[1].kind == ClassicParser::CharEvent::CreateFail &&
|
||||||
|
ev[1].fail_type == 1,
|
||||||
|
"10-byte and 2-byte GC 9 create failures both decode like 40250");
|
||||||
|
CHECK(s.last_error().empty(), "no desync after 10-byte GC 9");
|
||||||
|
}
|
||||||
|
|
||||||
// --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] ---
|
// --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] ---
|
||||||
{
|
{
|
||||||
CHECK(s.select_char(0), "select_char(0) ok");
|
CHECK(s.select_char(0), "select_char(0) ok");
|
||||||
@@ -208,10 +228,13 @@ int main() {
|
|||||||
mc.y = 200000;
|
mc.y = 200000;
|
||||||
mc.z = 3;
|
mc.z = 3;
|
||||||
mc.empire = 1;
|
mc.empire = 1;
|
||||||
|
mc.skill_group = 2;
|
||||||
feed(s, raw(mc));
|
feed(s, raw(mc));
|
||||||
CHECK(s.world().main_vid() == 7777, "main vid set");
|
CHECK(s.world().main_vid() == 7777, "main vid set");
|
||||||
const auto *me = s.world().get(7777);
|
const auto *me = s.world().get(7777);
|
||||||
CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity");
|
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);
|
auto version = drain(s);
|
||||||
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
|
// §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the
|
||||||
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
|
// fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION.
|
||||||
@@ -228,7 +251,7 @@ int main() {
|
|||||||
add.header = HDR_GC_CHARACTER_ADD;
|
add.header = HDR_GC_CHARACTER_ADD;
|
||||||
add.vid = 8888;
|
add.vid = 8888;
|
||||||
add.race = 101;
|
add.race = 101;
|
||||||
add.type = CHRTYPE_NPC;
|
add.type = WIRE_CHRTYPE_NPC;
|
||||||
add.x = 100500;
|
add.x = 100500;
|
||||||
add.y = 200500;
|
add.y = 200500;
|
||||||
add.moving_speed = 150;
|
add.moving_speed = 150;
|
||||||
@@ -259,7 +282,7 @@ int main() {
|
|||||||
mob.header = HDR_GC_CHARACTER_ADD;
|
mob.header = HDR_GC_CHARACTER_ADD;
|
||||||
mob.vid = 8890;
|
mob.vid = 8890;
|
||||||
mob.race = 101;
|
mob.race = 101;
|
||||||
mob.type = CHRTYPE_MONSTER;
|
mob.type = WIRE_CHRTYPE_MONSTER;
|
||||||
mob.x = 101000;
|
mob.x = 101000;
|
||||||
mob.y = 201000;
|
mob.y = 201000;
|
||||||
feed(s, raw(mob));
|
feed(s, raw(mob));
|
||||||
@@ -272,7 +295,7 @@ int main() {
|
|||||||
ghost.header = HDR_GC_CHARACTER_ADD;
|
ghost.header = HDR_GC_CHARACTER_ADD;
|
||||||
ghost.vid = 8891;
|
ghost.vid = 8891;
|
||||||
ghost.race = 20025;
|
ghost.race = 20025;
|
||||||
ghost.type = CHRTYPE_MONSTER;
|
ghost.type = WIRE_CHRTYPE_MONSTER;
|
||||||
feed(s, raw(ghost));
|
feed(s, raw(ghost));
|
||||||
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
|
CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 &&
|
||||||
s.world().size() == 3, "invisible race dropped");
|
s.world().size() == 3, "invisible race dropped");
|
||||||
@@ -523,9 +546,84 @@ int main() {
|
|||||||
feed(s, raw(iu));
|
feed(s, raw(iu));
|
||||||
CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3");
|
CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3");
|
||||||
|
|
||||||
GCItemDel id{HDR_GC_ITEM_DEL, 4};
|
// GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424,
|
||||||
feed(s, raw(id));
|
// packet.h:1124): header, TItemPos, vnum, count, alSockets[3], aAttr[7] = 42
|
||||||
|
// bytes; the 40250 client reads it as ITEM_SET. Sent on inventory load, so feed
|
||||||
|
// it glued to the next packet: a short frame desyncs the loading stream.
|
||||||
|
std::vector<uint8_t> del(42, 0);
|
||||||
|
del[0] = HDR_GC_ITEM_DEL;
|
||||||
|
del[1] = 1; // WINDOW_INVENTORY
|
||||||
|
del[2] = 4; // cell 4 (u16 LE)
|
||||||
|
GCItemSet next{};
|
||||||
|
next.header = HDR_GC_ITEM_SET;
|
||||||
|
next.cell = {1, 7};
|
||||||
|
next.vnum = 11200;
|
||||||
|
next.count = 2;
|
||||||
|
std::vector<uint8_t> glued = del;
|
||||||
|
std::vector<uint8_t> nb = raw(next);
|
||||||
|
glued.insert(glued.end(), nb.begin(), nb.end());
|
||||||
|
feed(s, glued);
|
||||||
CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4");
|
CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4");
|
||||||
|
CHECK(s.world().item_slot(1, 7).vnum == 11200 && s.world().item_slot(1, 7).count == 2,
|
||||||
|
"packet after 42-byte GC 20 framed correctly");
|
||||||
|
CHECK(s.last_error().empty(), "no desync after GC 20");
|
||||||
|
|
||||||
|
// Same wire layout with a vnum: the 40250 client applies it as an item set.
|
||||||
|
std::vector<uint8_t> set20(42, 0);
|
||||||
|
set20[0] = HDR_GC_ITEM_DEL;
|
||||||
|
set20[1] = 1;
|
||||||
|
set20[2] = 9;
|
||||||
|
const uint32_t vnum20 = 27001;
|
||||||
|
std::memcpy(&set20[4], &vnum20, 4);
|
||||||
|
set20[8] = 5; // count
|
||||||
|
const int32_t sock0 = 28030;
|
||||||
|
std::memcpy(&set20[9], &sock0, 4);
|
||||||
|
set20[21] = 7; // aAttr[0].bType
|
||||||
|
const int16_t av = 15;
|
||||||
|
std::memcpy(&set20[22], &av, 2);
|
||||||
|
feed(s, set20);
|
||||||
|
const mtnet::Item &it20 = s.world().item_slot(1, 9);
|
||||||
|
CHECK(it20.vnum == 27001 && it20.count == 5 && it20.sockets[0] == 28030 &&
|
||||||
|
it20.attrs[0].type == 7 && it20.attrs[0].value == 15,
|
||||||
|
"GC 20 with vnum sets the cell like the 40250 client");
|
||||||
|
s.world().mut_item_del(1, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GC 95 HEADER_GC_REFINE_INFORMATION: the 40250 client registers it with
|
||||||
|
// TPacketGCRefineInformation = [hdr][pos][TRefineTable] = 59 bytes (no type byte;
|
||||||
|
// only 119 _NEW carries type). Glue a packet after it to prove the frame length. ---
|
||||||
|
{
|
||||||
|
s.world().drain_refine_cues();
|
||||||
|
std::vector<uint8_t> rf(59, 0);
|
||||||
|
rf[0] = 95;
|
||||||
|
rf[1] = 6; // pos
|
||||||
|
const uint32_t src = 11209, dst = 11210, mat_vnum = 30053;
|
||||||
|
const int32_t cost = 5000, prob = 90, mat_count = 2;
|
||||||
|
std::memcpy(&rf[2], &src, 4);
|
||||||
|
std::memcpy(&rf[6], &dst, 4);
|
||||||
|
rf[10] = 1; // material_count
|
||||||
|
std::memcpy(&rf[11], &cost, 4);
|
||||||
|
std::memcpy(&rf[15], &prob, 4);
|
||||||
|
std::memcpy(&rf[19], &mat_vnum, 4);
|
||||||
|
std::memcpy(&rf[23], &mat_count, 4);
|
||||||
|
GCItemSet after{};
|
||||||
|
after.header = HDR_GC_ITEM_SET;
|
||||||
|
after.cell = {1, 11};
|
||||||
|
after.vnum = 11210;
|
||||||
|
after.count = 1;
|
||||||
|
std::vector<uint8_t> ab = raw(after);
|
||||||
|
rf.insert(rf.end(), ab.begin(), ab.end());
|
||||||
|
feed(s, rf);
|
||||||
|
auto cues = s.world().drain_refine_cues();
|
||||||
|
CHECK(cues.size() == 1 && cues[0].type == 0 && cues[0].pos == 6 &&
|
||||||
|
cues[0].src_vnum == 11209 && cues[0].result_vnum == 11210 &&
|
||||||
|
cues[0].material_count == 1 && cues[0].cost == 5000 &&
|
||||||
|
cues[0].prob == 90 && cues[0].materials[0].vnum == 30053 &&
|
||||||
|
cues[0].materials[0].count == 2,
|
||||||
|
"GC 95 refine information uses the 59-byte 40250 layout");
|
||||||
|
CHECK(s.world().item_slot(1, 11).vnum == 11210, "packet after GC 95 framed correctly");
|
||||||
|
CHECK(s.last_error().empty(), "no desync after GC 95");
|
||||||
|
s.world().mut_item_del(1, 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) /
|
// --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) /
|
||||||
@@ -743,22 +841,26 @@ int main() {
|
|||||||
cf[0].request_pid == 555,
|
cf[0].request_pid == 555,
|
||||||
"quest confirm cue");
|
"quest confirm cue");
|
||||||
|
|
||||||
// GC_QUEST_INFO dynamic: [hdr 81][u16 size][u16 index][u8 flag][TITLE\0][COUNTER_NAME\0][i32]
|
// GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index]
|
||||||
std::vector<uint8_t> qb;
|
// [u8 flag] then fixed-width optional fields (title 31, counter name 17, i32). The
|
||||||
|
// head is written before size is bumped, so the wire size stays 6.
|
||||||
|
std::vector<uint8_t> qpkt(QUEST_INFO_HEAD_SIZE, 0);
|
||||||
|
qpkt[0] = HDR_GC_QUEST_INFO;
|
||||||
|
uint16_t qsz = QUEST_INFO_HEAD_SIZE;
|
||||||
|
std::memcpy(&qpkt[1], &qsz, 2);
|
||||||
uint16_t qidx = 3;
|
uint16_t qidx = 3;
|
||||||
qb.insert(qb.end(), (uint8_t *)&qidx, (uint8_t *)&qidx + 2);
|
std::memcpy(&qpkt[3], &qidx, 2);
|
||||||
uint8_t qflag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
|
qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
|
||||||
qb.push_back(qflag);
|
std::vector<uint8_t> qt(QUEST_INFO_TITLE_SIZE, 0);
|
||||||
const char *qt = "Kill 10 wolves";
|
std::memcpy(qt.data(), "Kill 10 wolves", 14);
|
||||||
qb.insert(qb.end(), qt, qt + std::strlen(qt) + 1);
|
qpkt.insert(qpkt.end(), qt.begin(), qt.end());
|
||||||
const char *cn = "Wolves";
|
std::vector<uint8_t> cn(QUEST_INFO_COUNTER_NAME_SIZE, 0);
|
||||||
qb.insert(qb.end(), cn, cn + std::strlen(cn) + 1);
|
std::memcpy(cn.data(), "Wolves", 6);
|
||||||
|
qpkt.insert(qpkt.end(), cn.begin(), cn.end());
|
||||||
int32_t cv = 4;
|
int32_t cv = 4;
|
||||||
qb.insert(qb.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
|
qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
|
||||||
uint16_t qsz = (uint16_t)(3 + qb.size());
|
CHECK(qpkt.size() == static_cast<size_t>(quest_info_packet_size(qpkt[5])),
|
||||||
std::vector<uint8_t> qpkt = {HDR_GC_QUEST_INFO};
|
"quest info test packet matches 40250 flag-driven length");
|
||||||
qpkt.insert(qpkt.end(), (uint8_t *)&qsz, (uint8_t *)&qsz + 2);
|
|
||||||
qpkt.insert(qpkt.end(), qb.begin(), qb.end());
|
|
||||||
feed(s, qpkt);
|
feed(s, qpkt);
|
||||||
const mtnet::QuestInfo *q = s.world().quest(3);
|
const mtnet::QuestInfo *q = s.world().quest(3);
|
||||||
CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" &&
|
CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" &&
|
||||||
@@ -1185,6 +1287,16 @@ int main() {
|
|||||||
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
|
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CG_USE_SKILL: PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket sends
|
||||||
|
// only the use-skill packet; fly-targeting is a separate caller decision ---
|
||||||
|
{
|
||||||
|
uint32_t seq = s.stream().sequence_index();
|
||||||
|
CHECK(s.send_use_skill(3, 8888), "use skill send");
|
||||||
|
auto out = drain(s);
|
||||||
|
CHECK(out.size() == sizeof(CGUseSkill) + 1 && out[0] == HDR_CG_USE_SKILL &&
|
||||||
|
out.back() == SEQUENCE_TABLE[seq], "use skill wire: no extra fly targeting");
|
||||||
|
}
|
||||||
|
|
||||||
// --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD
|
// --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD
|
||||||
// is dropped and leaves the live entity untouched (§2.2) ---
|
// is dropped and leaves the live entity untouched (§2.2) ---
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
using namespace mtnet::classic;
|
using namespace mtnet::classic;
|
||||||
@@ -248,6 +249,46 @@ int main() {
|
|||||||
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes");
|
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------- GC_GUILD SKILL_INFO short write
|
||||||
|
{
|
||||||
|
// guild.cpp CGuild::SendSkillInfoPacket: size = 4 + 6 + GUILD_SKILL_COUNT (22)
|
||||||
|
// but only skill_point + abySkill[12] + power(2) + max_power(2) follow the
|
||||||
|
// head (21 bytes). LoginMember sends it right before SendEnemyGuild's
|
||||||
|
// TPacketGCGuildName (size 20 = 0x14), which is how a real login desynced
|
||||||
|
// into "unknown GC header 200 (last: 75,20)".
|
||||||
|
ClassicStream s;
|
||||||
|
std::string err;
|
||||||
|
s.on_error = [&](const std::string &e) { err = e; };
|
||||||
|
std::vector<std::pair<uint8_t, std::vector<uint8_t>>> got;
|
||||||
|
s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) {
|
||||||
|
got.push_back({h, std::vector<uint8_t>(body, body + len)});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
std::vector<uint8_t> skill = {HDR_GC_GUILD, 22, 0, 12, 3};
|
||||||
|
for (uint8_t i = 0; i < 12; ++i) {
|
||||||
|
skill.push_back(static_cast<uint8_t>(i + 1));
|
||||||
|
}
|
||||||
|
skill.insert(skill.end(), {0x10, 0x00, 0x20, 0x00}); // power 16, max_power 32
|
||||||
|
CHECK(skill.size() == 21, "server SKILL_INFO writes 21 bytes");
|
||||||
|
std::vector<uint8_t> name = {HDR_GC_GUILD, 20, 0, 16, 0x2A, 0, 0, 0};
|
||||||
|
const char gname[12] = "Wolves";
|
||||||
|
name.insert(name.end(), gname, gname + sizeof(gname));
|
||||||
|
CHECK(name.size() == 20, "TPacketGCGuildName is 20 bytes");
|
||||||
|
std::vector<uint8_t> wire = skill;
|
||||||
|
wire.insert(wire.end(), name.begin(), name.end());
|
||||||
|
s.feed(wire.data(), wire.size());
|
||||||
|
CHECK(err.empty(), "guild skill info + guild name: no framing error");
|
||||||
|
CHECK(got.size() == 2, "guild skill info + guild name: two packets delivered");
|
||||||
|
if (got.size() == 2) {
|
||||||
|
CHECK(got[0].second.size() == 1 + 17 && got[0].second[0] == 12 &&
|
||||||
|
got[0].second[1] == 3 && got[0].second[16] == 0x20,
|
||||||
|
"skill info body = subheader + 17 bytes");
|
||||||
|
CHECK(got[1].first == HDR_GC_GUILD && got[1].second.size() == 17 &&
|
||||||
|
got[1].second[0] == 16 && got[1].second[1] == 0x2A,
|
||||||
|
"guild name packet framed intact after skill info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------- rejected handler
|
// ---------------------------------------------------------- rejected handler
|
||||||
{
|
{
|
||||||
ClassicStream s;
|
ClassicStream s;
|
||||||
|
|||||||
@@ -152,6 +152,89 @@ int main() {
|
|||||||
"chk_time past cap: released immediately (staleness valve)");
|
"chk_time past cap: released immediately (staleness valve)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 7) motion-driven remote walk (reference MovementProcess) --------------
|
||||||
|
// Speed comes from the WALK/RUN root motion * movSpd/100, not dwDuration;
|
||||||
|
// heading follows Src->Dst while walking and takes bRot on arrival.
|
||||||
|
{
|
||||||
|
EntityStore es;
|
||||||
|
es.set_now(0);
|
||||||
|
es.mut_spawn(70, 101, 2, "Wolf", 0, 0, 0, 0, 100 /*moving_speed*/, 0);
|
||||||
|
es.set_motion_speed(70, 87.5f, 425.0f);
|
||||||
|
es.tick();
|
||||||
|
// server says 100 ms (it prices NPC walks at RUN speed); ignored here.
|
||||||
|
es.mut_move(70, 45.0f, FUNC_WAIT, 1000.0f, 0.0f, 100, 0, 0);
|
||||||
|
CHECK(es.get(70)->moving && std::abs(es.get(70)->angle - 90.0f) < 1e-3f,
|
||||||
|
"motion walk: faces Src->Dst, not bRot");
|
||||||
|
uint32_t now = 0;
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
now += 100;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick();
|
||||||
|
}
|
||||||
|
CHECK(es.get(70)->moving && std::abs(es.get(70)->x - 425.0f) < 1.0f,
|
||||||
|
"motion walk: run mode advances at RUN motion speed, ignores dwDuration");
|
||||||
|
es.mut_walk_mode(70, WALKMODE_WALK);
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
now += 100;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick();
|
||||||
|
}
|
||||||
|
CHECK(std::abs(es.get(70)->x - 512.5f) < 1.0f, "motion walk: walk mode uses WALK motion speed");
|
||||||
|
es.mut_change_speed(70, 200); // movSpd 200 doubles the step
|
||||||
|
now += 1000;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick(); // capped at MOVE_MAX_TICK_MS
|
||||||
|
CHECK(std::abs(es.get(70)->x - (512.5f + 175.0f * 0.25f)) < 1.0f,
|
||||||
|
"motion walk: movSpd scales speed; tick step capped");
|
||||||
|
for (int i = 0; i < 100 && es.get(70)->moving; ++i) {
|
||||||
|
now += 100;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick();
|
||||||
|
}
|
||||||
|
CHECK(!es.get(70)->moving && es.get(70)->func == FUNC_WAIT, "motion walk: stops on arrival");
|
||||||
|
CHECK(es.get(70)->x == 1000.0f && es.get(70)->y == 0.0f, "motion walk: snaps to Dst");
|
||||||
|
CHECK(std::abs(es.get(70)->angle - 45.0f) < 1e-3f, "motion walk: bRot applied on arrival");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8) FUNC_MOVE walks past Dst, overshoot guard turns back and stops ------
|
||||||
|
{
|
||||||
|
EntityStore es;
|
||||||
|
es.set_now(0);
|
||||||
|
es.mut_spawn(80, 0, 0, "Pc", 0, 0, 0, 0, 100, 0);
|
||||||
|
es.set_motion_speed(80, 0.0f, 1000.0f);
|
||||||
|
es.tick();
|
||||||
|
es.mut_move(80, 90.0f, FUNC_MOVE, 100.0f, 0.0f, 100, 0, 0);
|
||||||
|
uint32_t now = 0;
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
now += 50;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick();
|
||||||
|
}
|
||||||
|
CHECK(es.get(80)->moving && es.get(80)->x > 100.0f, "FUNC_MOVE: keeps walking past Dst");
|
||||||
|
bool turned = false;
|
||||||
|
for (int i = 0; i < 40 && es.get(80)->moving; ++i) {
|
||||||
|
now += 50;
|
||||||
|
es.set_now(now);
|
||||||
|
es.tick();
|
||||||
|
turned = turned || std::abs(es.get(80)->angle - 270.0f) < 1e-3f;
|
||||||
|
}
|
||||||
|
CHECK(turned, "FUNC_MOVE overshoot: turns back toward Dst");
|
||||||
|
CHECK(!es.get(80)->moving && es.get(80)->x == 100.0f && es.get(80)->func == FUNC_WAIT,
|
||||||
|
"FUNC_MOVE overshoot: returns to Dst and stops");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9) unknown motion speed keeps the dwDuration lerp, heading Src->Dst -----
|
||||||
|
{
|
||||||
|
EntityStore es;
|
||||||
|
es.set_now(0);
|
||||||
|
spawn(es, 90, 0, 0);
|
||||||
|
es.mut_move(90, 0.0f, FUNC_WAIT, 0.0f, -500.0f, 1000, 0, 0);
|
||||||
|
CHECK(std::abs(es.get(90)->angle - 180.0f) < 1e-3f, "duration walk: faces Src->Dst");
|
||||||
|
es.set_now(500);
|
||||||
|
es.tick();
|
||||||
|
CHECK(std::abs(es.get(90)->y + 250.0f) < 1e-3f, "duration walk: lerps over dwDuration");
|
||||||
|
}
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
|
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "../src/net/classic/classic_session.h"
|
#include "../src/net/classic/classic_session.h"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <array>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -74,7 +75,8 @@ int main(int argc, char **argv) {
|
|||||||
const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ?
|
const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ?
|
||||||
std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000;
|
std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000;
|
||||||
session.set_auto_entergame_delay(enter_delay_ms);
|
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_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr;
|
||||||
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
|
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
|
||||||
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
|
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) {
|
if (session.stage() == mtnet::INetSession::Stage::InGame) {
|
||||||
const auto &world = session.world();
|
const auto &world = session.world();
|
||||||
|
std::array<int, 10> 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;
|
int inventory_count = 0;
|
||||||
for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) {
|
for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) {
|
||||||
if (!world.inv_slot(cell).empty()) ++inventory_count;
|
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",
|
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());
|
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())) {
|
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",
|
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],
|
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);
|
(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();
|
session.disconnect();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cvfimpfsp5t6b
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://vg3mkqiy31mq
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dhtltbx5sioow
|
||||||
+38
-5
@@ -51,6 +51,12 @@ func _init() -> void:
|
|||||||
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||||
_assets = assets_root
|
_assets = assets_root
|
||||||
serverinfo = ServerInfoRes.new()
|
serverinfo = ServerInfoRes.new()
|
||||||
|
# 联网内测的地址覆盖只在 MT_TEST_MODE=playable 生效;父运行器把同一路径交给
|
||||||
|
# 预检,保证预检目标与客户端真实连接目标来自同一个 ServerInfo 解析。
|
||||||
|
var test_serverlist := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
|
||||||
|
if OS.get_environment("MT_TEST_MODE") == "playable" and not test_serverlist.is_empty():
|
||||||
|
serverinfo.load_file(test_serverlist)
|
||||||
|
else:
|
||||||
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
|
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
|
||||||
if injected_client != null:
|
if injected_client != null:
|
||||||
client = injected_client
|
client = injected_client
|
||||||
@@ -84,6 +90,17 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
|
|||||||
func state() -> int:
|
func state() -> int:
|
||||||
return _state
|
return _state
|
||||||
|
|
||||||
|
# Playable-test only: choose the server/channel before the deferred autologin
|
||||||
|
# connect runs, and return the exact address _do_connect() will use.
|
||||||
|
func select_test_server(server_index: int, channel: int) -> Dictionary:
|
||||||
|
if OS.get_environment("MT_TEST_MODE") != "playable" or serverinfo == null:
|
||||||
|
return {}
|
||||||
|
if serverinfo.server(server_index).is_empty():
|
||||||
|
return {}
|
||||||
|
_sel_server = server_index
|
||||||
|
_sel_channel = channel
|
||||||
|
return serverinfo.address(_sel_server, _sel_channel)
|
||||||
|
|
||||||
# Read-only adapter for the packaged playable test. It deliberately returns
|
# Read-only adapter for the packaged playable test. It deliberately returns
|
||||||
# summaries rather than the private UI/game nodes so a report cannot retain a
|
# summaries rather than the private UI/game nodes so a report cannot retain a
|
||||||
# stale scene reference across reconnect or map reload.
|
# stale scene reference across reconnect or map reload.
|
||||||
@@ -147,14 +164,16 @@ func _on_char_list(list: Array) -> void:
|
|||||||
if auto_login and not _chars.is_empty():
|
if auto_login and not _chars.is_empty():
|
||||||
var idx := int(_chars[0].get("index", 0))
|
var idx := int(_chars[0].get("index", 0))
|
||||||
if _auto_char_slot >= 0:
|
if _auto_char_slot >= 0:
|
||||||
|
# An explicit MT_CHAR_SLOT that the account lacks must not silently enter
|
||||||
|
# another character; stay on the select screen instead.
|
||||||
|
idx = -1
|
||||||
for character in _chars:
|
for character in _chars:
|
||||||
if int(character.get("index", -1)) == _auto_char_slot:
|
if int(character.get("index", -1)) == _auto_char_slot:
|
||||||
idx = _auto_char_slot
|
idx = _auto_char_slot
|
||||||
break
|
break
|
||||||
if client.has_method("enter_game"):
|
if idx >= 0:
|
||||||
client.enter_game(idx)
|
# Remember the slot so a later in-game reconnect re-enters the same character.
|
||||||
elif client.has_method("select_character"):
|
_enter_character(idx)
|
||||||
client.select_character(idx)
|
|
||||||
|
|
||||||
func _on_char_name_changed(pid: int, name: String) -> void:
|
func _on_char_name_changed(pid: int, name: String) -> void:
|
||||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
||||||
@@ -255,6 +274,18 @@ func _goto_game() -> void:
|
|||||||
add_child(_game)
|
add_child(_game)
|
||||||
if _game.has_method("setup"):
|
if _game.has_method("setup"):
|
||||||
await _game.setup(client, _assets) # 协程:分帧,不阻塞 socket
|
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"):
|
if _lifecycle and is_instance_valid(_game) and _game.has_method("lifecycle_audio"):
|
||||||
_lifecycle.bind(client, _game.lifecycle_audio())
|
_lifecycle.bind(client, _game.lifecycle_audio())
|
||||||
if _loading:
|
if _loading:
|
||||||
@@ -379,9 +410,11 @@ func _probe_tcp(host: String, port: int, timeout_s: float) -> bool:
|
|||||||
peer.poll()
|
peer.poll()
|
||||||
match peer.get_status():
|
match peer.get_status():
|
||||||
StreamPeerTCP.STATUS_CONNECTED:
|
StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
peer.poll()
|
||||||
|
if peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
peer.disconnect_from_host()
|
peer.disconnect_from_host()
|
||||||
return true
|
return true
|
||||||
StreamPeerTCP.STATUS_ERROR:
|
StreamPeerTCP.STATUS_ERROR, StreamPeerTCP.STATUS_NONE:
|
||||||
return false
|
return false
|
||||||
if is_inside_tree():
|
if is_inside_tree():
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
|
|||||||
@@ -74,6 +74,20 @@ func _init() -> void:
|
|||||||
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
|
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
|
||||||
"playable snapshot reports GAME after reconnect")
|
"playable snapshot reports GAME after reconnect")
|
||||||
|
|
||||||
|
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
|
||||||
|
# character, and the chosen slot must be remembered for a later reconnect.
|
||||||
|
flow._state = AppFlow.LOGIN
|
||||||
|
flow.auto_login = true
|
||||||
|
flow._auto_char_slot = 5
|
||||||
|
flow._selected_char_slot = -1
|
||||||
|
client.selects.clear()
|
||||||
|
client.char_list.emit([{"index": 0, "name": "Other"}])
|
||||||
|
_ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]")
|
||||||
|
flow._state = AppFlow.LOGIN
|
||||||
|
client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}])
|
||||||
|
_ck(client.selects == [5] and flow._selected_char_slot == 5,
|
||||||
|
"explicit slot is entered and remembered for reconnect")
|
||||||
|
|
||||||
flow.queue_free()
|
flow.queue_free()
|
||||||
await process_frame
|
await process_frame
|
||||||
if _fail == 0:
|
if _fail == 0:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dras8n6cjudtn
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bkw5vjkd4yqgq
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d2i1ud7k3o0l5
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bsbmx3e0l0jgs
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://nde6dfrdg7vg
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://co4sc1bl3n0sj
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dvues2wv3bqb1
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cqcq40dpplogo
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xjrr61n0rkui
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ngrh5ra7vqus
|
||||||
@@ -63,6 +63,9 @@ func _run() -> void:
|
|||||||
if hair_image != null:
|
if hair_image != null:
|
||||||
hair_image.save_png(output.path_join("hair-texture.png"))
|
hair_image.save_png(output.path_join("hair-texture.png"))
|
||||||
var mesh_bounds: Array = []
|
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):
|
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
||||||
var local_aabb: AABB = mi.get_aabb()
|
var local_aabb: AABB = mi.get_aabb()
|
||||||
var world_aabb: AABB = mi.get_global_transform() * local_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)
|
var mat: Material = mi.get_active_material(surface)
|
||||||
if mat is ShaderMaterial:
|
if mat is ShaderMaterial:
|
||||||
print("MATERIAL mode=", mat.get_shader_parameter("mode"), " alpha_write=", mat.shader.code.contains("ALPHA ="))
|
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():
|
if "--solid-body" in OS.get_cmdline_user_args():
|
||||||
var solid := StandardMaterial3D.new()
|
var solid := StandardMaterial3D.new()
|
||||||
solid.albedo_texture = mat.get_shader_parameter("albedo_tex")
|
solid.albedo_texture = mat.get_shader_parameter("albedo_tex")
|
||||||
@@ -87,12 +101,23 @@ func _run() -> void:
|
|||||||
printerr("FAIL: server armor was not applied")
|
printerr("FAIL: server armor was not applied")
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
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"):
|
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")
|
printerr("FAIL: standard HairIndex 1001 did not replace the default hair model")
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
if DisplayServer.get_name() != "headless":
|
if DisplayServer.get_name() != "headless":
|
||||||
await RenderingServer.frame_post_draw
|
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:
|
if root.get_texture().get_image().save_png(output.path_join("selection.png")) != OK:
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ class FakeClient extends Node:
|
|||||||
return "銀河聯盟" if gid == 9 else ""
|
return "銀河聯盟" if gid == 9 else ""
|
||||||
func say(type: int, text: String) -> bool:
|
func say(type: int, text: String) -> bool:
|
||||||
says.append([type, text]); return true
|
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
|
var _fail := 0
|
||||||
func _ck(c: bool, m: String) -> void:
|
func _ck(c: bool, m: String) -> void:
|
||||||
@@ -148,6 +156,112 @@ func _run() -> void:
|
|||||||
cs._set_state("STATUS")
|
cs._set_state("STATUS")
|
||||||
_ck(char_page.visible, "Character_Page visible again after SetState(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()
|
cs.close()
|
||||||
_ck(not cs.is_open(), "character window closed")
|
_ck(not cs.is_open(), "character window closed")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bnktthkdod33w
|
||||||
@@ -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_"),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://8amkno8ks12i
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxlbtf4m6ejc4
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://brl1ecflh4ugi
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://n55fydu6qdrj
|
||||||
+304
-34
@@ -19,7 +19,9 @@ class FakeClient extends Node:
|
|||||||
signal affect_added(affect: Dictionary)
|
signal affect_added(affect: Dictionary)
|
||||||
signal affect_removed(type: int)
|
signal affect_removed(type: int)
|
||||||
signal phase_changed(phase: String)
|
signal phase_changed(phase: String)
|
||||||
|
signal combo_changed(enabled: bool)
|
||||||
var main := 1000
|
var main := 1000
|
||||||
|
var skills := []
|
||||||
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
|
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
|
||||||
var attacks := []
|
var attacks := []
|
||||||
var moves := []
|
var moves := []
|
||||||
@@ -40,10 +42,38 @@ class FakeClient extends Node:
|
|||||||
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
|
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
|
||||||
func sync_positions(arr) -> bool: synced.append(arr); return true
|
func sync_positions(arr) -> bool: synced.append(arr); return true
|
||||||
func say(t, s) -> bool: said.append([t, s]); return true
|
func say(t, s) -> bool: said.append([t, s]); return true
|
||||||
|
func get_skills() -> Array: return skills
|
||||||
|
|
||||||
class FakeNetWorld extends Node:
|
class FakeNetWorld extends Node:
|
||||||
var nodes := {}
|
var nodes := {}
|
||||||
|
var pushes := [] # [vid, dir(actor-world xy), force]
|
||||||
|
var pushing := {}
|
||||||
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
|
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
|
||||||
|
func push_victim(vid: int, dir: Vector2, force: float) -> void:
|
||||||
|
pushes.append([vid, dir, force])
|
||||||
|
pushing[vid] = true
|
||||||
|
func is_pushing(vid: int) -> bool: return bool(pushing.get(vid, false))
|
||||||
|
func blending_position(vid: int) -> Vector3: return nodes[vid].global_position + Vector3(0.1, 0, 0)
|
||||||
|
|
||||||
|
# 受击方视图桩:.msm 防御球 + InsertDelay + __Hit* 反应
|
||||||
|
class FakeVictim extends Node3D:
|
||||||
|
var spheres := [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||||
|
var delays := []
|
||||||
|
var reactions := []
|
||||||
|
func get_defending_spheres() -> Array: return spheres
|
||||||
|
func insert_delay(t: float) -> void: delays.append(t)
|
||||||
|
func hit_good(scalar: float, _stunned: bool) -> void: reactions.append(["good", scalar])
|
||||||
|
func hit_greate(scalar: float, _stunned: bool) -> void: reactions.append(["greate", scalar])
|
||||||
|
func hit_stone(_stunned: bool) -> void: reactions.append(["stone", 0.0])
|
||||||
|
|
||||||
|
class FakeFx extends Node:
|
||||||
|
var spawned := [] # [name, global_pos, node]
|
||||||
|
func spawn_at(n: String, parent: Node3D, pos: Vector3, _one_shot := true) -> Node3D:
|
||||||
|
var e := Node3D.new()
|
||||||
|
parent.add_child(e)
|
||||||
|
e.global_position = pos
|
||||||
|
spawned.append([n, pos, e])
|
||||||
|
return e
|
||||||
|
|
||||||
class FakeProto extends Node:
|
class FakeProto extends Node:
|
||||||
var items := {}
|
var items := {}
|
||||||
@@ -61,11 +91,18 @@ class FakeAnim extends Node:
|
|||||||
func get_motion_data() -> Dictionary: return md
|
func get_motion_data() -> Dictionary: return md
|
||||||
|
|
||||||
class FakeView extends Node3D:
|
class FakeView extends Node3D:
|
||||||
|
signal motion_bound(state: String)
|
||||||
var states := []
|
var states := []
|
||||||
|
var attack_motions := [] # [motion_mode, motion_index, speed_ratio]
|
||||||
|
var delays := []
|
||||||
var anim := FakeAnim.new()
|
var anim := FakeAnim.new()
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
add_child(anim)
|
add_child(anim)
|
||||||
func set_anim_state(s): states.append(s)
|
func set_anim_state(s): states.append(s)
|
||||||
|
func play_attack_motion(mode: int, index: int, speed: float) -> void:
|
||||||
|
attack_motions.append([mode, index, speed])
|
||||||
|
motion_bound.emit("attack")
|
||||||
|
func insert_delay(t: float) -> void: delays.append(t)
|
||||||
|
|
||||||
class FakeHud extends Node:
|
class FakeHud extends Node:
|
||||||
var affects := []
|
var affects := []
|
||||||
@@ -77,6 +114,19 @@ func _ck(c: bool, m: String) -> void:
|
|||||||
_fail += 1
|
_fail += 1
|
||||||
printerr("FAIL: " + m)
|
printerr("FAIL: " + m)
|
||||||
|
|
||||||
|
# 命中小节:每个场景前清掉命中表 / 无敌时间 / 防御球上一帧缓存 / 硬直与各桩记录
|
||||||
|
func _reset_hit(np: Node, fc: Node, nw: Node, fx: Node) -> void:
|
||||||
|
np._hit_dedup.clear()
|
||||||
|
np._victim_invisible_until.clear()
|
||||||
|
np._def_last.clear()
|
||||||
|
np._delay = 0.0
|
||||||
|
np._victim_flush.clear()
|
||||||
|
fc.attacks.clear()
|
||||||
|
fc.synced.clear()
|
||||||
|
nw.pushes.clear()
|
||||||
|
nw.pushing.clear()
|
||||||
|
fx.spawned.clear()
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
await _run()
|
await _run()
|
||||||
if _fail == 0:
|
if _fail == 0:
|
||||||
@@ -156,8 +206,49 @@ func _run() -> void:
|
|||||||
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
|
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
|
||||||
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
|
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
|
||||||
"attack speed scales the resolved period")
|
"attack speed scales the resolved period")
|
||||||
|
# CInstanceBase::SetAttackSpeed:uAtkSpd > 1100 -> 0;否则 /100,无上下限
|
||||||
|
np._apply_attack_speed(400)
|
||||||
|
_ck(is_equal_approx(np._atk_speed_factor, 4.0), "attack_speed 400 -> factor 4.0 (no clamp)")
|
||||||
|
np._apply_attack_speed(1200)
|
||||||
|
_ck(np._atk_speed_factor == 0.0, "attack_speed > 1100 -> factor 0")
|
||||||
|
_ck(is_finite(np._current_attack_period()), "factor 0 -> attack period stays finite")
|
||||||
np._apply_attack_speed(100)
|
np._apply_attack_speed(100)
|
||||||
|
|
||||||
|
# --- CGraphicThingInstance::InsertDelay / UpdateTime:本地时间 ---
|
||||||
|
np._local_time = 5.0
|
||||||
|
np._delay = 0.05
|
||||||
|
np._advance_local_time(0.02)
|
||||||
|
_ck(is_equal_approx(np._local_time, 5.0) and is_equal_approx(np._delay, 0.03),
|
||||||
|
"delay > elapsed -> local time frozen, delay shrinks")
|
||||||
|
np._advance_local_time(0.05)
|
||||||
|
_ck(is_equal_approx(np._local_time, 5.02) and np._delay == 0.0,
|
||||||
|
"delay < elapsed -> local time advances by the remainder")
|
||||||
|
np._apply_attack_speed(200)
|
||||||
|
np._motion_start_t = np._local_time - 0.1
|
||||||
|
_ck(is_equal_approx(np._attacking_elapsed(), 0.2),
|
||||||
|
"GetAttackingElapsedTime = (local - start) * fSpeedRatio")
|
||||||
|
np._apply_attack_speed(100)
|
||||||
|
var lt0: float = np._local_time
|
||||||
|
np._process(0.016)
|
||||||
|
_ck(np._local_time > lt0, "_process advances the local time")
|
||||||
|
|
||||||
|
# --- SetComboSkillFlag(PythonPlayerSkill.cpp):GC 连击开关 -> SetComboType(MIN(lv,2)) ---
|
||||||
|
fc.skills = [{"id": 122, "level": 3, "master": 0}]
|
||||||
|
fc.combo_changed.emit(true)
|
||||||
|
_ck(np._combo_type == 2, "combo on + skill 122 lv3 -> combo type MIN(3,2)=2, got %d" % np._combo_type)
|
||||||
|
fc.combo_changed.emit(false)
|
||||||
|
_ck(np._combo_type == 0, "combo off -> combo type 0")
|
||||||
|
fc.skills = [{"id": 122, "level": 1, "master": 0}]
|
||||||
|
fc.combo_changed.emit(true)
|
||||||
|
_ck(np._combo_type == 1, "combo on + skill 122 lv1 -> combo type 1")
|
||||||
|
fc.skills = [{"id": 122, "level": 0, "master": 0}]
|
||||||
|
fc.combo_changed.emit(false)
|
||||||
|
_ck(np._combo_type == 1, "skill 122 level 0 -> return before SetComboType")
|
||||||
|
fc.skills = []
|
||||||
|
fc.combo_changed.emit(false)
|
||||||
|
_ck(np._combo_type == 1, "no skill 122 slot -> return before SetComboType")
|
||||||
|
np._combo_type = 0
|
||||||
|
|
||||||
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
|
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
|
||||||
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
|
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
|
||||||
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
|
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
|
||||||
@@ -181,93 +272,272 @@ func _run() -> void:
|
|||||||
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
|
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
|
||||||
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
|
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
|
||||||
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
|
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
|
||||||
|
_ck(view.attack_motions.size() == 1 and view.attack_motions[0] == [np.MOTION_MODE_GENERAL, 14, 1.0],
|
||||||
|
"swing binds the seg motion (mode, index 14, speed ratio 1.0), got %s" % [view.attack_motions])
|
||||||
|
|
||||||
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
|
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
|
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
|
||||||
|
|
||||||
# elapsed 落在 [start, next):置 m_isPreInput,不推进
|
# elapsed 落在 [start, next):置 m_isPreInput,不推进(GetAttackingElapsedTime 基于本地时间)
|
||||||
np._combo_started_t = np._now() - 0.15
|
np._motion_start_t = np._local_time - 0.15
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
|
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
|
||||||
|
|
||||||
# ComboProcess:elapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
|
# ComboProcess:elapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
|
||||||
np._combo_started_t = np._now() - 0.25
|
np._motion_start_t = np._local_time - 0.25
|
||||||
np._combo_process()
|
np._combo_process()
|
||||||
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
|
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
|
||||||
|
|
||||||
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
|
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
|
||||||
np._combo_started_t = np._now() - 0.30
|
np._motion_start_t = np._local_time - 0.30
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
|
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
|
||||||
np._combo_started_t = np._now() - 0.30
|
np._motion_start_t = np._local_time - 0.30
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 4, "advance to last seg 4")
|
_ck(np._combo_index == 4, "advance to last seg 4")
|
||||||
|
|
||||||
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
|
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
|
||||||
np._combo_started_t = np._now() - 0.30
|
np._motion_start_t = np._local_time - 0.30
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
|
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
|
||||||
|
|
||||||
# 动作回到 Wait(elapsed > duration)-> __ClearCombo
|
# 参考端 ComboProcess 没有「超过整段时长就清连击」:elapsed > duration 段号保持
|
||||||
np._combo_started_t = np._now() - 1.5
|
np._motion_start_t = np._local_time - 1.5
|
||||||
np._combo_process()
|
np._combo_process()
|
||||||
_ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo")
|
_ck(np._combo_index == 4, "ComboProcess has no duration timeout -> combo index kept, got %d" % np._combo_index)
|
||||||
|
# __SetMotion 尾:新绑定的动作没有 ComboInputData(回 Wait)-> m_dwcurComboIndex = 0
|
||||||
|
view.anim.md = {"duration": 2.0}
|
||||||
|
view.motion_bound.emit("wait")
|
||||||
|
_ck(np._combo_index == 0, "bound motion without ComboInputData -> combo index 0")
|
||||||
|
# __SetMotion 尾:绑定带 MotionAttackData 的动作 -> m_HitDataMap.clear()
|
||||||
|
np._hit_dedup = {0: {2000: 1.0}}
|
||||||
|
view.anim.md = {"has_attacking_data": true, "has_combo_input": true, "duration": 1.0}
|
||||||
|
view.motion_bound.emit("attack")
|
||||||
|
_ck(np._hit_dedup.is_empty(), "bound attacking motion -> hit data map cleared")
|
||||||
|
|
||||||
# 没有段表(缺资源)-> 退化为单段普攻(段号 13)
|
# 没有段表(缺资源)-> 退化为单段普攻(段号 13);攻速系数原样传给动作播放
|
||||||
np._combo_tables = {}
|
np._combo_tables = {}
|
||||||
fc.moves.clear()
|
fc.moves.clear()
|
||||||
|
np._apply_attack_speed(150)
|
||||||
np._do_attack_swing(pcn, {})
|
np._do_attack_swing(pcn, {})
|
||||||
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
|
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
|
||||||
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
|
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
|
||||||
"no combo table -> single NORMAL_ATTACK swing")
|
"no combo table -> single NORMAL_ATTACK swing")
|
||||||
|
_ck(view.attack_motions.back() == [np.MOTION_MODE_GENERAL, np.NAME_NORMAL_ATTACK, 1.5],
|
||||||
|
"attack motion plays at fSpeedRatio 1.5, got %s" % [view.attack_motions.back()])
|
||||||
|
np._apply_attack_speed(100)
|
||||||
|
|
||||||
# --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList ---
|
# --- __NormalAttackProcess 的 m_HitDataMap:新窗 insert;旧窗 map::insert 不覆盖 + 每窗上限 ---
|
||||||
|
np._hit_dedup.clear()
|
||||||
|
np._hit_motion_type = np.MOTION_TYPE_SKILL
|
||||||
|
np._hit_limit_count = 2
|
||||||
|
np._hit_invisible_time = 0.5
|
||||||
|
np._local_time = 10.0
|
||||||
|
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
|
||||||
|
"first hit of a window -> insert local + fInvisibleTime, process")
|
||||||
|
np._local_time = 11.0
|
||||||
|
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
|
||||||
|
"victim already in the window -> map::insert keeps the old time, count 1 <= limit")
|
||||||
|
_ck(np._register_hit(0, 2), "second victim -> count 2 <= iHitLimitCount 2")
|
||||||
|
_ck(not np._register_hit(0, 3), "third victim -> count 3 > iHitLimitCount -> FALSE")
|
||||||
|
np._hit_limit_count = 0
|
||||||
|
np._hit_dedup.clear()
|
||||||
|
_ck(np._register_hit(0, 1), "SKILL: first hit of a window skips the limit check")
|
||||||
|
_ck(not np._register_hit(0, 1), "SKILL iHitLimitCount 0 -> next hit in that window 1 > 0 -> FALSE")
|
||||||
|
np._hit_motion_type = np.MOTION_TYPE_COMBO
|
||||||
|
for i in 16:
|
||||||
|
np._register_hit(1, 100 + i)
|
||||||
|
_ck(not np._register_hit(1, 200), "COMBO/NORMAL: 17th victim in one window -> > 16 -> FALSE")
|
||||||
|
np._hit_dedup.clear()
|
||||||
|
|
||||||
|
# --- §3.5 修改 4 / §3.7:__NormalAttackProcess 扫掠球 vs .msm 防御球 -> __ProcessDataAttackSuccess ---
|
||||||
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree(),
|
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree(),
|
||||||
# 几何判定读 global_position,所以这一小节先 await 一帧。
|
# 几何判定读 global_position,所以这一小节先 await 一帧。
|
||||||
var nw := FakeNetWorld.new(); get_root().add_child(nw)
|
var nw := FakeNetWorld.new(); get_root().add_child(nw)
|
||||||
np.net_world = nw
|
np.net_world = nw
|
||||||
var vnode := Node3D.new(); get_root().add_child(vnode)
|
var fx := FakeFx.new(); get_root().add_child(fx)
|
||||||
|
var fx_parent := Node3D.new(); get_root().add_child(fx_parent)
|
||||||
|
np.fx = fx
|
||||||
|
np.fx_parent = fx_parent
|
||||||
|
var vnode := FakeVictim.new(); get_root().add_child(vnode)
|
||||||
nw.nodes[2000] = vnode
|
nw.nodes[2000] = vnode
|
||||||
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true}
|
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "race": 101, "hp": 50, "dead": false, "owner_vid": 1000}
|
||||||
fc.ents[1000]["dead"] = false
|
fc.ents[1000]["dead"] = false
|
||||||
await process_frame
|
await process_frame
|
||||||
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 m(yaw 0 -> 前向 +Z)
|
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 m(yaw 0:模型 -Y = Godot +Z)
|
||||||
|
vnode.rotation.y = 0.0
|
||||||
pcn.global_position = Vector3.ZERO
|
pcn.global_position = Vector3.ZERO
|
||||||
pcn.rotation.y = 0.0
|
pcn.rotation.y = 0.0
|
||||||
|
# 一个采样:t=0.1 时刀尖球从模型本地 (-60,-80) 横扫到 (60,-80),高 1 m
|
||||||
view.anim.md = {
|
view.anim.md = {
|
||||||
|
"has_attacking_data": true,
|
||||||
"hit_windows": [{
|
"hit_windows": [{
|
||||||
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand",
|
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", "weapon_length": 120.0,
|
||||||
"weapon_length": 120.0, "samples": [],
|
"samples": [{"time": 0.1, "last_pos": Vector3(-60, -80, 100), "pos": Vector3(60, -80, 100)}],
|
||||||
}],
|
}],
|
||||||
"motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1,
|
"motion_type": np.MOTION_TYPE_COMBO, "hitting_type": np.HIT_TYPE_GOOD, "hit_limit_count": 0,
|
||||||
"next_combo": 0.2,
|
"invisible_time": 0.1, "stiffen_time": 0.05, "external_force": 3.0, "next_combo": 0.2,
|
||||||
}
|
}
|
||||||
np._combo_tables = {}
|
np._combo_tables = {}
|
||||||
fc.attacks.clear()
|
np._target_vid = 0
|
||||||
fc.synced.clear()
|
view.delays.clear()
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
|
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
|
||||||
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
|
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
|
||||||
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
|
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
|
||||||
np._swing_start_t = np._now() - 0.1 # 推进到命中窗内
|
# 采样时刻 0.1 不在 [motiontime - elapsed, motiontime] 内 -> 不判定
|
||||||
np._process(0.016)
|
np._motion_start_t = np._local_time - 0.2
|
||||||
_ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000],
|
np._attack_process(0.016)
|
||||||
"in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)")
|
_ck(fc.attacks.is_empty(), "no .msa sample inside [t-dt, t] -> no hit")
|
||||||
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000,
|
np._motion_start_t = np._local_time - 0.105
|
||||||
"pushed victim -> frame-end CG_SYNC_POSITION")
|
np._attack_process(0.016)
|
||||||
np._swing_start_t = np._now() - 0.2
|
_ck(fc.attacks == [[0, 2000]], "sweep sphere crosses the defending sphere -> OnHit CG_ATTACK(0, 2000), got %s" % [fc.attacks])
|
||||||
np._process(0.016)
|
_ck(np._target_vid == 2000, "OnHit -> SetTarget(victim)")
|
||||||
|
_ck(is_equal_approx(np._delay, 0.05) and view.delays == [0.05], "attacker InsertDelay(fStiffenTime), got %s" % [view.delays])
|
||||||
|
_ck(vnode.delays == [0.05], "victim InsertDelay(fStiffenTime), got %s" % [vnode.delays])
|
||||||
|
_ck(float(np._victim_invisible_until.get(2000, 0.0)) > np._now(), "victim m_fInvisibleTime = now + fInvisibleTime")
|
||||||
|
_ck(vnode.reactions == [["good", 1.0]], "HIT_TYPE_GOOD -> __HitGood(scalar cos(0) = 1), got %s" % [vnode.reactions])
|
||||||
|
_ck(fx.spawned.size() == 1 and fx.spawned[0][0] == np.EFFECT_HIT \
|
||||||
|
and Vector3(fx.spawned[0][1]).is_equal_approx(vnode.global_position),
|
||||||
|
"blow_1_low.mse spawned at the victim position, got %s" % [fx.spawned])
|
||||||
|
if fx.spawned.size() == 1:
|
||||||
|
_ck(absf(absf((fx.spawned[0][2] as Node3D).rotation.y) - PI) < 0.01, "hit effect yaw = atan2(-dx, -dz) = ±π")
|
||||||
|
_ck(nw.pushes.size() == 1 and nw.pushes[0][0] == 2000 and Vector2(nw.pushes[0][1]).is_equal_approx(Vector2(0, -1)) \
|
||||||
|
and is_equal_approx(float(nw.pushes[0][2]), 3.0),
|
||||||
|
"__PushCircle + IncreaseExternalForce(3) along victim - attacker (actor-world (0,-1)), got %s" % [nw.pushes])
|
||||||
|
np._flush_victim_list()
|
||||||
|
var bxy := MapCoord.to_server_cm(vnode.global_position + Vector3(0.1, 0, 0))
|
||||||
|
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000 and fc.synced[0][0]["x"] == int(bxy.x) \
|
||||||
|
and fc.synced[0][0]["y"] == int(bxy.y),
|
||||||
|
"pushing victim -> frame-end CG_SYNC_POSITION at its blending position, got %s" % [fc.synced])
|
||||||
|
# COMBO:同窗同目标只一次(先把受击方无敌时间去掉,单独验 m_HitDataMap)
|
||||||
|
np._victim_invisible_until.clear()
|
||||||
|
np._attack_process(0.016)
|
||||||
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
|
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
|
||||||
vnode.global_position = Vector3(0, 0, -1.0) # 转到身后
|
# AttackingProcess:rVictim.__isInvisible() -> FALSE
|
||||||
np._hit_dedup.clear()
|
np._hit_dedup.clear()
|
||||||
np._swing_start_t = np._now() - 0.2
|
np._def_last.clear()
|
||||||
np._process(0.016)
|
np._victim_invisible_until[2000] = np._now() + 5.0
|
||||||
_ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit")
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.size() == 1, "victim still inside m_fInvisibleTime -> no hit")
|
||||||
|
|
||||||
|
# __CanPushDestActor:owner 不是攻击者 / owner 超 3 秒 -> 不推;晕眩 -> 推
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
fc.ents[2000]["owner_vid"] = 0
|
||||||
|
np._attack_process(0.016)
|
||||||
|
np._flush_victim_list()
|
||||||
|
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
|
||||||
|
"victim not owned by the attacker -> hit, no push, no CG_SYNC_POSITION")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
fc.ents[2000]["owner_vid"] = 1000
|
||||||
|
np._owner_seen[2000] = [1000, np._now() - 3.5]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty(), "owner time > 3 s -> no push")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
fc.ents[2000]["stunned"] = true
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(nw.pushes.size() == 1, "stunned victim -> always pushable")
|
||||||
|
fc.ents[2000]["stunned"] = false
|
||||||
|
np._owner_seen.erase(2000)
|
||||||
|
|
||||||
|
# HIT_TYPE_NONE:__ProcessDataAttackSuccess 直接 return(不硬直、不发包);GREAT -> __HitGreate
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
view.delays.clear()
|
||||||
|
vnode.delays.clear()
|
||||||
|
vnode.reactions.clear()
|
||||||
|
np._hit_type = np.HIT_TYPE_NONE
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty() and view.delays.is_empty() and vnode.delays.is_empty() and fx.spawned.is_empty(),
|
||||||
|
"HIT_TYPE_NONE -> no delay / effect / OnHit")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
np._hit_type = np.HIT_TYPE_GREAT
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(vnode.reactions.size() == 1 and vnode.reactions[0][0] == "greate", "HIT_TYPE_GREAT -> __HitGreate, got %s" % [vnode.reactions])
|
||||||
|
np._hit_type = np.HIT_TYPE_GOOD
|
||||||
|
|
||||||
|
# 距离怪癖:v3Distance = (dX, dZ, dZ) —— 平面 Y 不参与,高度差算两次
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.global_position = Vector3(3.5, 0, 1.0)
|
||||||
|
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-350, 0, 100), "bone": ""}]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty(), "|dX| 350 >= 300 -> distance reject although the spheres touch")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.global_position = Vector3(0, 0, 3.5)
|
||||||
|
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 270, 100), "bone": ""}]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.size() == 1, "|dY| 350 is not part of the distance check -> hit")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.global_position = Vector3(0, 2.2, 1.0)
|
||||||
|
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty(), "height 2.2 m -> 2 * 220² >= 300² -> reject")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.global_position = Vector3(0, 2.0, 1.0)
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.size() == 1, "height 2.0 m -> 2 * 200² < 300², Z cylinder ignores height -> hit")
|
||||||
|
|
||||||
|
# IS_HUGE_RACE:500 cm 距离、不推、特效在 (攻击者 + 防御球) * 0.5
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
fc.ents[2000]["race"] = 2493
|
||||||
|
vnode.global_position = Vector3(4.0, 0, 1.0)
|
||||||
|
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-400, 0, 100), "bone": ""}]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
np._flush_victim_list()
|
||||||
|
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
|
||||||
|
"IS_HUGE_RACE: 400 cm < 500 -> hit, no push, no sync")
|
||||||
|
_ck(fx.spawned.size() == 1 and Vector3(fx.spawned[0][1]).is_equal_approx(Vector3(0, 0.5, 0.5)),
|
||||||
|
"IS_HUGE_RACE: effect at (attacker + defending sphere) * 0.5, got %s" % [fx.spawned])
|
||||||
|
fc.ents[2000]["race"] = 101
|
||||||
|
|
||||||
|
# 防御球随受击方朝向旋转;__HitGood 的 scalar = cos(攻击者朝向 - 受击方朝向)
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.reactions.clear()
|
||||||
|
vnode.global_position = Vector3(0, 0, 1.6)
|
||||||
|
vnode.spheres = [{"radius": 25.0, "pos": Vector3(0, -60, 100), "bone": ""}]
|
||||||
|
vnode.rotation.y = 0.0
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty(), "victim yaw 0 -> defending sphere 220 cm ahead -> miss")
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.rotation.y = PI
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.size() == 1, "victim yaw π -> sphere swings to 100 cm -> hit")
|
||||||
|
_ck(vnode.reactions.size() == 1 and is_equal_approx(float(vnode.reactions[0][1]), -1.0),
|
||||||
|
"__HitGood scalar = cos(0 - π) = -1, got %s" % [vnode.reactions])
|
||||||
|
vnode.rotation.y = 0.0
|
||||||
|
|
||||||
|
# 身后:扫掠球够不到
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
vnode.global_position = Vector3(0, 0, -1.0)
|
||||||
|
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty(), "victim behind the attacker -> sweep misses")
|
||||||
|
|
||||||
|
# 占位胶囊(无 .msm):退化防御球
|
||||||
|
var ph := Node3D.new(); get_root().add_child(ph)
|
||||||
|
await process_frame
|
||||||
|
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
|
||||||
vnode.global_position = Vector3(0, 0, 1.0)
|
vnode.global_position = Vector3(0, 0, 1.0)
|
||||||
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
|
|
||||||
np._process(0.016)
|
np._motion_start_t = np._local_time - 2.0 # 动作越过所有命中窗
|
||||||
|
np._attack_process(0.016)
|
||||||
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
|
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
|
||||||
|
# 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中)
|
||||||
|
fc.ents[2000]["dead"] = true
|
||||||
|
np._do_attack_swing(pcn, {})
|
||||||
|
_reset_hit(np, fc, nw, fx)
|
||||||
|
np._motion_start_t = np._local_time - 0.105
|
||||||
|
np._attack_process(0.016)
|
||||||
|
_ck(fc.attacks.is_empty(), "dead victim inside the hit window -> no CG_ATTACK")
|
||||||
|
fc.ents[2000]["dead"] = false
|
||||||
np.net_world = null
|
np.net_world = null
|
||||||
|
np.fx = null
|
||||||
|
|
||||||
# --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)---
|
# --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)---
|
||||||
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
|
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7cxwyvtq02xd
|
||||||
@@ -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, {})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxu88rt8qo1o5
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bo2ad4sxgu07r
|
||||||
@@ -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": "清脆的开锁声响起!开启宝箱,获得珍稀秘宝!"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bau7tkdeg2823
|
||||||
@@ -12,6 +12,7 @@ const ExchangeUI = preload("res://ui/exchange_ui.gd")
|
|||||||
|
|
||||||
class FakeClient extends Node:
|
class FakeClient extends Node:
|
||||||
signal safebox_changed()
|
signal safebox_changed()
|
||||||
|
signal inventory_changed()
|
||||||
signal mall_opened(size: int)
|
signal mall_opened(size: int)
|
||||||
signal mall_changed()
|
signal mall_changed()
|
||||||
signal shop_opened(vid: int)
|
signal shop_opened(vid: int)
|
||||||
@@ -39,6 +40,7 @@ class FakeClient extends Node:
|
|||||||
calls.append(["safe_move", src, dst, count]); return true
|
calls.append(["safe_move", src, dst, count]); return true
|
||||||
func safebox_checkout(src, window, cell) -> bool:
|
func safebox_checkout(src, window, cell) -> bool:
|
||||||
calls.append(["safe_checkout", src, window, cell]); return true
|
calls.append(["safe_checkout", src, window, cell]); return true
|
||||||
|
func get_inventory() -> Array: return []
|
||||||
|
|
||||||
func is_mall_open() -> bool: return mall_open
|
func is_mall_open() -> bool: return mall_open
|
||||||
func get_mall_size() -> int: return mall_size
|
func get_mall_size() -> int: return mall_size
|
||||||
@@ -91,11 +93,11 @@ func _init() -> void:
|
|||||||
"inventory item drops into empty safebox slot")
|
"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),
|
_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")
|
"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()
|
var safe_event := InputEventMouseButton.new()
|
||||||
safe_event.button_index = MOUSE_BUTTON_LEFT
|
safe_event.button_index = MOUSE_BUTTON_LEFT
|
||||||
safe_event.pressed = true
|
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,
|
_ck(mouse.is_attached() and int(mouse.attached().get("window", -1)) == 3,
|
||||||
"safebox item enters global item state")
|
"safebox item enters global item state")
|
||||||
mouse.cancel()
|
mouse.cancel()
|
||||||
|
|||||||
@@ -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": "未知的传送卷轴"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ucwyc04610e3
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cd5g767aqt3ui
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bas2h6u0g5rsg
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxkngqnfky42a
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://mklhwerocdlu
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://berjrd53seoek
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dyqcl4bpdcalf
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bp1qvx4apa2no
|
||||||
@@ -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": "净度提升失败,辅助材料碎裂。"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ia8t4ldbjqhp
|
||||||
@@ -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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user