diff --git a/.gitignore b/.gitignore index ab7f3a2c..81d47bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ __pycache__/ # Written by 40250 uitaskbar.py at runtime. /mouse.cfg +# Written at runtime by the mt_* option windows (mt_pickup, mt_display, ...). +/mt_*.cfg +/Client/mt_*.cfg + # native Android client (./android/build.sh): SDL3 download, NDK builds, Gradle output /android/.gradle/ /android/app/build/ diff --git a/docs/AVA2-DISPLAY.md b/docs/AVA2-DISPLAY.md new file mode 100644 index 00000000..921317d1 --- /dev/null +++ b/docs/AVA2-DISPLAY.md @@ -0,0 +1,54 @@ +# AVA2 显示开关适配进度(v1) + +2026-10-15。只改了 metin2-client,服务端不需要改动。参考了 AVA2 的 `ENABLE_GRAPHIC_ON_OFF`(`InstanceBase.cpp` 中 `__CanRender` 的 effect_level 0–4)和 `HIDE_OBJECTS`(`background.SetVisiblePart`)。 + +## 与 AVA2 的差异 + +| 项目 | AVA2 | 本实现 | +| --- | --- | --- | +| 特效等级 | 0–4,在渲染判断里跳过 | 相同的 5 档;在 `CInstanceBase::Update` 中对角色挂载的特效调用 Active/Deactive,不改渲染路径 | +| 摆摊、宠物、坐骑的显示范围 | 有 | 不移植:经典版没有离线摆摊和宠物 | +| 树木、云 | `background.SetVisiblePart` | 相同,使用原有接口 | +| 名字、伤害数字 | 原有选项 | 窗口内加了快捷开关,仍然写原来的 systemSetting,退出时由原配置保存 | + +## 特效等级 + +| 等级 | 显示的特效 | +| --- | --- | +| 0 全部特效 | 全部 | +| 1 只看怪物特效 | 自己、怪物、灵石 | +| 2 只看玩家特效 | 自己、其他玩家 | +| 3 只看自己特效 | 只有自己 | +| 4 关闭全部特效 | 无,包括自己 | + +## 客户端 + +- C++: + - `CEffectManager::ActiveEffectInstance`。 + - `CActorInstance::SetAttachingEffectShown`:对已挂载的特效调用 Active/Deactive;关闭期间新挂载的特效也会立即 Deactive。使用 Deactive 而不是 Hide,避免和刺客隐身的 Show/Hide 冲突。 + - `CPythonSystem::Get/SetEffectLevel`,越界值按 0 处理;不写入 metin2.cfg。 + - `systemSetting.SetEffectLevel/GetEffectLevel`。 + - `CInstanceBase::__UpdateEffectShown`,每帧在 `Update` 开头按上表判断。 +- 模块 `mt_display`(`src/platform/ScriptLib/DisplayScript.inc`),在 `PythonBoot.cpp` 中安装: + - 入口是小地图旁“拾取”下方的“显示”按钮。 + - 窗口“显示设置”包含 5 个特效等级、显示树木、显示云、总是显示名字、显示伤害数字,`【】` 表示当前选中。 + - 设置保存在客户端目录的 `mt_display.cfg`,格式为“特效等级 树木 云”,默认 `0 1 1`,损坏时回到默认值。 + - 特效等级在启动时生效;树木和云在进入游戏(`GameWindow.Open`)时生效。 + +## 证据 + +- `tests/wiki/test_display.py`(CTest:`port.display`)共 3 项通过,覆盖: + - 默认值、配置文件读写、损坏的配置; + - 进入游戏时生效,窗口按钮、配置保存、名字和伤害开关; + - 5 档和 C++ 规则一致。 +- `test_pickup.py`(7 项)和 `test_switchbot.py`(6 项)仍然通过。 +- 2026-10-15 联机测试(203 测试核心 channel5,macOS 原生客户端,账号 admin): + - 切到等级 4 并关闭树木后,配置为 `4 0 1`;恢复后为 `0 1 1`。 + - 截图确认窗口布局正常。等级 4 的截图里角色被窗口挡住,特效隐藏没有截图对比,只有日志确认等级已设置。 + +## 限制和未完成 + +- 只控制挂载在角色上的特效(装备光效、buff、种族特效)。单独播放的特效和飞行特效(技能弹道、命中特效)不受影响。 +- macOS 桌面客户端没有中文字体,窗口文字显示为 `?`(原版界面同样乱码);Android 使用 CFF FreeType,不受影响。 +- 测试点附近没有树木,树木开关只通过日志和配置确认,没有截图对比。 +- Android 真机测试未做。 diff --git a/docs/AVA2-FEATURE-MIGRATION-PLAN.md b/docs/AVA2-FEATURE-MIGRATION-PLAN.md index 4567edc3..038db3f3 100644 --- a/docs/AVA2-FEATURE-MIGRATION-PLAN.md +++ b/docs/AVA2-FEATURE-MIGRATION-PLAN.md @@ -120,3 +120,5 @@ 2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。 2026-10-15 批量拾取(计划外追加,参考 AVA2 do_pickup_all):服务端 mt_pickup 对范围内自己的物品按分类逐件调用原 PickupItem,客户端在拾取键(Z)上接入并提供分类设置窗口;macOS 客户端已在 203 测试核心联机验收,未部署 203 正式环境、未做 Android 真机测试和触屏按钮。见 AVA2-PICKUP.md。 + +2026-10-15 显示开关(计划外追加,参考 AVA2 ENABLE_GRAPHIC_ON_OFF / HIDE_OBJECTS):仅客户端,5 档特效等级(挂载特效 Active/Deactive)、树木和云开关、名字和伤害数字快捷开关;macOS 客户端已在 203 测试核心联机验收,未做 Android 真机测试。不移植摆摊/宠物/坐骑显示范围。见 AVA2-DISPLAY.md。 diff --git a/src/platform/ScriptLib/DisplayScript.inc b/src/platform/ScriptLib/DisplayScript.inc new file mode 100644 index 00000000..b2641cce --- /dev/null +++ b/src/platform/ScriptLib/DisplayScript.inc @@ -0,0 +1,141 @@ +// PORT: AVA2-style display options (ENABLE_GRAPHIC_ON_OFF effect level, HIDE_OBJECTS tree/cloud parts), +// client-only. The effect level is systemSetting.SetEffectLevel (CInstanceBase::__UpdateEffectShown deactivates +// attached effects); trees and clouds use the original background.SetVisiblePart; names and damage numbers +// are shortcuts to the original game options. See docs/AVA2-DISPLAY.md. +static const char c_szDisplayScript[] = R"PY(# -*- coding: utf-8 -*- +import background +import game +import grp +import systemSetting +import ui +import weakref +from mt_biolog import Action, Text, L +from mt_wiki import B + +g_window=None +g_entry=None +CONFIG='mt_display.cfg' +LEVELS=(u'全部特效',u'只看怪物特效',u'只看玩家特效',u'只看自己特效',u'关闭全部特效') +LEVEL_HINTS=('显示所有角色的特效','隐藏其他玩家和NPC的特效,保留自己和怪物的','隐藏怪物和NPC的特效,保留玩家的', + '只保留自己的特效','连自己的特效也隐藏') + + +class Options(object): + def __init__(self):self.effect=0;self.tree=True;self.cloud=True + + def Load(self,text): + words=text.split() + if len(words)!=3 or not all(w.isdigit() and len(w)==1 for w in words):return + effect,tree,cloud=[int(w) for w in words] + if effectSetDeactive(); } +// PORT: AVA2 ENABLE_GRAPHIC_ON_OFF (display options effect level). +void CEffectManager::ActiveEffectInstance(DWORD dwInstanceIndex) +{ + TEffectInstanceMap::iterator itor = m_kEftInstMap.find(dwInstanceIndex); + + if (itor == m_kEftInstMap.end()) + return; + + itor->second->SetActive(); +} + void CEffectManager::CreateUnsafeEffectInstance(DWORD dwEffectDataID, CEffectInstance ** ppEffectInstance) { CEffectData * pEffect; diff --git a/src/port/EffectLib/EffectManager.h b/src/port/EffectLib/EffectManager.h index a395a85f..e84cd6f9 100644 --- a/src/port/EffectLib/EffectManager.h +++ b/src/port/EffectLib/EffectManager.h @@ -47,6 +47,7 @@ class CEffectManager : public CScreen, public CSingleton BOOL SelectEffectInstance(DWORD dwInstanceIndex); bool DestroyEffectInstance(DWORD dwInstanceIndex); void DeactiveEffectInstance(DWORD dwInstanceIndex); + void ActiveEffectInstance(DWORD dwInstanceIndex); // PORT: AVA2 ENABLE_GRAPHIC_ON_OFF void SetEffectTextures(DWORD dwID, std::vector textures); void SetEffectInstancePosition(const D3DXVECTOR3 & c_rv3Position); diff --git a/src/port/GameLib/ActorInstance.h b/src/port/GameLib/ActorInstance.h index 7ff67ad3..9b5a857e 100644 --- a/src/port/GameLib/ActorInstance.h +++ b/src/port/GameLib/ActorInstance.h @@ -496,6 +496,9 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject float GetHeight(); void ShowAllAttachingEffect(); void HideAllAttachingEffect(); + // PORT: AVA2 ENABLE_GRAPHIC_ON_OFF. Deactivates (not hides) attached effects, so it does not fight + // the assassin invisibility Show/Hide; effects attached while off start deactivated. + void SetAttachingEffectShown(bool bShown); void ClearAttachingEffect(); // Fishing @@ -793,6 +796,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject // Attached Effect std::list m_AttachingEffectList; bool m_bEffectInitialized; + bool m_bAttachingEffectShown = true; // material color DWORD m_dwMtrlColor; diff --git a/src/port/GameLib/ActorInstanceAttach.cpp b/src/port/GameLib/ActorInstanceAttach.cpp index c5851229..0d8b5c81 100644 --- a/src/port/GameLib/ActorInstanceAttach.cpp +++ b/src/port/GameLib/ActorInstanceAttach.cpp @@ -294,6 +294,8 @@ DWORD CActorInstance::AttachEffectByID(DWORD dwParentPartIndex, const char * c_p ae.iBoneIndex = -1; } + if (!m_bAttachingEffectShown) + rkEftMgr.DeactiveEffectInstance(ae.dwEffectIndex); m_AttachingEffectList.push_back(ae); return ae.dwEffectIndex; @@ -472,6 +474,8 @@ void CActorInstance::RefreshActorInstance() ae.iBoneIndex = iBoneIndex; + if (!m_bAttachingEffectShown) + CEffectManager::Instance().DeactiveEffectInstance(ae.dwEffectIndex); m_AttachingEffectList.push_back(ae); } break; @@ -593,6 +597,22 @@ void CActorInstance::HideAllAttachingEffect() } } +void CActorInstance::SetAttachingEffectShown(bool bShown) +{ + if (m_bAttachingEffectShown == bShown) + return; + + m_bAttachingEffectShown = bShown; + CEffectManager& rkEftMgr = CEffectManager::Instance(); + for (const TAttachingEffect& ae : m_AttachingEffectList) + { + if (bShown) + rkEftMgr.ActiveEffectInstance(ae.dwEffectIndex); + else + rkEftMgr.DeactiveEffectInstance(ae.dwEffectIndex); + } +} + void CActorInstance::__ClearAttachingEffect() { m_bEffectInitialized = false; diff --git a/src/port/UserInterface/InstanceBase.cpp b/src/port/UserInterface/InstanceBase.cpp index 5eb75c42..e05944fc 100644 --- a/src/port/UserInterface/InstanceBase.cpp +++ b/src/port/UserInterface/InstanceBase.cpp @@ -4,6 +4,7 @@ #include "PythonNonPlayer.h" #include "PythonPlayer.h" #include "PythonCharacterManager.h" +#include "PythonSystem.h" #include "AbstractPlayer.h" #include "AbstractApplication.h" #include "Packet.h" @@ -1868,6 +1869,7 @@ void CInstanceBase::Update() { ++ms_dwUpdateCounter; + __UpdateEffectShown(); StateProcess(); m_GraphicThingInstance.PhysicsProcess(); m_GraphicThingInstance.RotationProcess(); @@ -2454,6 +2456,25 @@ bool CInstanceBase::__IsInViewFrustum() return m_GraphicThingInstance.isShow(); } +// PORT: AVA2 ENABLE_GRAPHIC_ON_OFF effect level, applied to attached effects (skills, buffs, refine glow). +void CInstanceBase::__UpdateEffectShown() +{ + const int iLevel = CPythonSystem::Instance().GetEffectLevel(); + bool isShown = true; + if (iLevel == 4) + isShown = false; + else if (iLevel != 0 && !__IsMainInstance()) + { + if (iLevel == 1) + isShown = IsEnemy() || IsStone(); + else if (iLevel == 2) + isShown = IsPC(); + else + isShown = false; + } + m_GraphicThingInstance.SetAttachingEffectShown(isShown); +} + bool CInstanceBase::__CanRender() { if (!__IsInViewFrustum()) diff --git a/src/port/UserInterface/InstanceBase.h b/src/port/UserInterface/InstanceBase.h index cc08a914..c7a15498 100644 --- a/src/port/UserInterface/InstanceBase.h +++ b/src/port/UserInterface/InstanceBase.h @@ -407,6 +407,7 @@ class CInstanceBase protected: bool __IsExistMainInstance(); bool __IsMainInstance(); + void __UpdateEffectShown(); bool __MainCanSeeHiddenThing(); float __GetBowRange(); diff --git a/src/port/UserInterface/PythonSystem.h b/src/port/UserInterface/PythonSystem.h index f70d2b30..d2c45d42 100644 --- a/src/port/UserInterface/PythonSystem.h +++ b/src/port/UserInterface/PythonSystem.h @@ -110,6 +110,9 @@ class CPythonSystem : public CSingleton bool IsAlwaysShowName(); bool IsShowDamage(); bool IsShowSalesText(); + // PORT: AVA2 effect_level (0 all, 1 hide players/NPCs, 2 hide monsters/NPCs, 3 hide others, 4 hide all). + int GetEffectLevel() { return m_iPortEffectLevel; } + void SetEffectLevel(int iLevel) { m_iPortEffectLevel = iLevel < 0 || iLevel > 4 ? 0 : iLevel; } bool IsUseDefaultIME(); bool IsNoSoundCard(); bool IsAutoTiling(); @@ -159,4 +162,5 @@ class CPythonSystem : public CSingleton bool m_isInterfaceConfig; PyObject * m_poInterfaceHandler; TWindowStatus m_WindowStatus[WINDOW_MAX_NUM]; + int m_iPortEffectLevel = 0; }; \ No newline at end of file diff --git a/src/port/UserInterface/PythonSystemModule.cpp b/src/port/UserInterface/PythonSystemModule.cpp index bc4f356d..e060351d 100644 --- a/src/port/UserInterface/PythonSystemModule.cpp +++ b/src/port/UserInterface/PythonSystemModule.cpp @@ -211,6 +211,22 @@ PyObject * systemSetShowSalesTextFlag(PyObject * poSelf, PyObject * poArgs) return Py_BuildNone(); } +PyObject * systemSetEffectLevel(PyObject * poSelf, PyObject * poArgs) +{ + int iLevel; + if (!PyTuple_GetInteger(poArgs, 0, &iLevel)) + return Py_BuildException(); + + CPythonSystem::Instance().SetEffectLevel(iLevel); + + return Py_BuildNone(); +} + +PyObject * systemGetEffectLevel(PyObject * poSelf, PyObject * poArgs) +{ + return Py_BuildValue("i", CPythonSystem::Instance().GetEffectLevel()); +} + PyObject * systemIsAlwaysShowName(PyObject * poSelf, PyObject * poArgs) { return Py_BuildValue("i", CPythonSystem::Instance().IsAlwaysShowName()); @@ -422,6 +438,8 @@ void initsystem() { "SetShowSalesTextFlag", systemSetShowSalesTextFlag, METH_VARARGS }, { "IsShowSalesText", systemIsShowSalesText, METH_VARARGS }, + { "SetEffectLevel", systemSetEffectLevel, METH_VARARGS }, // PORT: AVA2 + { "GetEffectLevel", systemGetEffectLevel, METH_VARARGS }, { "GetShadowLevel", systemGetShadowLevel, METH_VARARGS }, { "SetShadowLevel", systemSetShadowLevel, METH_VARARGS }, diff --git a/tests/wiki/test_display.py b/tests/wiki/test_display.py new file mode 100644 index 00000000..f52609f4 --- /dev/null +++ b/tests/wiki/test_display.py @@ -0,0 +1,91 @@ +import os +import re +import sys +import tempfile +import types +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0] + + +class Widget(object): + def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0) + def __getattr__(self,name):return lambda *a,**k:None + def Show(self):self.shown=True + def Hide(self):self.shown=False + def IsShow(self):return self.shown + def SetText(self,text):self.text=text + def SetPosition(self,x,y):self.pos=(x,y) + def GetGlobalPosition(self):return self.pos + def SetSize(self,w,h):self.size=(w,h) + def GetWidth(self):return self.size[0] + def GetHeight(self):return self.size[1] + + +class Action(Widget): + def __init__(self,parent,x,y,width,text,event): + Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True + + +class DisplayTests(unittest.TestCase): + def setUp(self): + self.cwd=os.getcwd();self.tmp=tempfile.TemporaryDirectory();os.chdir(self.tmp.name) + self.state=dict(effect=None,name=True,damage=True,parts={});self.opened=[] + mods={n:types.ModuleType(n) for n in ('background','game','grp','systemSetting','ui','mt_biolog','mt_wiki')} + bg=mods['background'];bg.PART_TREE=3;bg.PART_CLOUD=2;bg.SetVisiblePart=lambda part,on:self.state['parts'].__setitem__(part,on) + ss=mods['systemSetting'];ss.SetEffectLevel=lambda v:self.state.__setitem__('effect',v) + ss.IsAlwaysShowName=lambda:self.state['name'];ss.SetAlwaysShowNameFlag=lambda v:self.state.__setitem__('name',bool(v)) + ss.IsShowDamage=lambda:self.state['damage'];ss.SetShowDamageFlag=lambda v:self.state.__setitem__('damage',bool(v)) + mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:self.opened.append(1),Close=lambda s:None)) + grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None + mods['ui'].Window=Widget + mods['mt_biolog'].Action=Action;mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:Widget();mods['mt_biolog'].L=lambda t:t if isinstance(t,str) else t.decode('utf-8') + mods['mt_wiki'].B=lambda t,limit=0:t + self.previous={n:sys.modules.get(n) for n in mods} + sys.modules.update(mods) + + def load(self): + self.ns={'__name__':'mt_display'};exec(script('DisplayScript.inc'),self.ns);return self.ns + + def tearDown(self): + os.chdir(self.cwd);self.tmp.cleanup() + for name,value in self.previous.items(): + if value is None:sys.modules.pop(name,None) + else:sys.modules[name]=value + + def test_defaults_and_config(self): + ns=self.load();self.assertEqual(self.state['effect'],0) + o=ns['Options']();self.assertEqual(o.Dump(),'0 1 1') + for bad in ('5 1 1','1 2 1','a 1 1','1 1','10 1 1','-1 1 1'): + o=ns['Options']();o.Load(bad);self.assertEqual(o.Dump(),'0 1 1') + Path('mt_display.cfg').write_text('3 0 1') + self.load();self.assertEqual(self.state['effect'],3) # applied at boot, before any map + + def test_game_window_applies_and_saves(self): + Path('mt_display.cfg').write_text('2 0 1') + ns=self.load();game=sys.modules['game'];gw=game.GameWindow();gw.interface=types.SimpleNamespace(wndMiniMap=Widget()) + game.GameWindow.Open(gw);self.assertEqual(self.opened,[1]) + self.assertEqual(self.state['parts'],{3:0,2:1});self.assertEqual(ns['g_entry'].pos,(4,252)) + w=ns['g_window'];w.Open() + self.assertIn('【',w.levelButtons[2].text);self.assertNotIn('【',w.levelButtons[0].text) + self.assertNotIn('【',w.treeButton.text);self.assertIn('【',w.cloudButton.text) + w.levelButtons[4].event();self.assertEqual(self.state['effect'],4) + w.ToggleTree();w.ToggleCloud();self.assertEqual(self.state['parts'],{3:1,2:0}) + self.assertEqual(Path('mt_display.cfg').read_text(),'4 1 0') + w.ToggleName();w.ToggleDamage();self.assertEqual((self.state['name'],self.state['damage']),(False,False)) + self.assertNotIn('【',w.nameButton.text) + game.GameWindow.Close(gw);self.assertIsNone(ns['g_window']);self.assertIsNone(ns['g_entry']) + + def test_levels_match_native_rule(self): + ns=self.load();text=(ROOT/'src/port/UserInterface/InstanceBase.cpp').read_text(encoding='latin1') + body=text.split('void CInstanceBase::__UpdateEffectShown()',1)[1].split('\n}\n',1)[0] + self.assertEqual(len(ns['LEVELS']),5);self.assertIn('iLevel == 4',body) + self.assertIn('IsEnemy() || IsStone()',body);self.assertIn('IsPC()',body) + system=(ROOT/'src/port/UserInterface/PythonSystem.h').read_text(encoding='latin1') + self.assertIn('iLevel > 4',system) + + +if __name__=='__main__':unittest.main()