Files
metin2-client/tests/wiki/test_model_preview.py
T
shenleiandClaude Opus 5.5 84404cf1f1 Model preview: AVA2 render-target window on a Vulkan colour target
Port of AVA2's CRenderTarget / GrpRenderTargetTexture / renderTarget module.
The host gains an RGBA8 offscreen colour pass for D3D colour render targets;
mt_preview shows wearable items on every allowed race and monsters with
motions, reached by X+click in the inventory and shop and from the wiki.
Player races are created as TYPE_PC so their parts exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:48:07 +09:00

191 lines
11 KiB
Python

import sys
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.Hide() # like ui.Window, before the subclass sets anything
self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True
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
def SetEnabled(self,enabled):self.enabled=enabled
# vnum -> (type, subtype, antiflags)
ITEMS={11299:(1,0,0),11499:(1,0,0x08|0x10|0x20),12049:(2,0,0x04|0x10|0x20|0x40),8000:(1,6,0),
41001:(28,0,0x01),45001:(28,1,0x02),27001:(3,0,0),11259:(2,1,0)}
class Targets(object):
"""renderTarget stub: what exists, and every call in order."""
def __init__(self):self.live=set();self.calls=[];self.models={};self.missing=set();self.motionless=set()
def __getattr__(self,name):
def call(*a):self.calls.append((name,)+a)
return call
def Create(self,i,w,h):self.calls.append(('Create',i,w,h));self.live.add(i);return 1
def Remove(self,i):self.calls.append(('Remove',i));self.live.discard(i);self.models.pop(i,None)
def Destroy(self):self.calls.append(('Destroy',));self.live.clear();self.models.clear()
def SelectModel(self,i,race):
self.calls.append(('SelectModel',i,race))
if race in self.missing:return 0
self.models[i]=race;return 1
def SetMotionIndex(self,i,m):self.calls.append(('SetMotionIndex',i,m));return 0 if m in self.motionless else 1
def named(self,name):return [c for c in self.calls if c[0]==name]
class PreviewTests(unittest.TestCase):
def setUp(self):
self.chat=[];self.keys=set();self.mouse=(0,0);self.race=0;self.selected=None
self.rt=Targets()
names=('app','chat','game','grp','item','player','renderTarget','ui','wndMgr','mt_biolog','uiInventory','uiShop','shop')
mods={n:types.ModuleType(n) for n in names}
mods['renderTarget']=self.rt
app=mods['app'];app.DIK_X=45;app.IsPressed=lambda k:k in self.keys
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None))
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
it=mods['item']
for k,v in dict(ITEM_TYPE_WEAPON=1,ITEM_TYPE_ARMOR=2,ITEM_TYPE_COSTUME=28,WEAPON_ARROW=6,ARMOR_BODY=0,
COSTUME_TYPE_BODY=0,COSTUME_TYPE_HAIR=1,ITEM_ANTIFLAG_FEMALE=0x01,ITEM_ANTIFLAG_MALE=0x02,
ITEM_ANTIFLAG_WARRIOR=0x04,ITEM_ANTIFLAG_ASSASSIN=0x08,ITEM_ANTIFLAG_SURA=0x10,
ITEM_ANTIFLAG_SHAMAN=0x20).items():setattr(it,k,v)
def select(v):self.selected=ITEMS.get(v,ITEMS[27001])
it.SelectItem=select;it.GetItemType=lambda:self.selected[0];it.GetItemSubType=lambda:self.selected[1]
it.IsAntiFlag=lambda f:bool(self.selected[2]&f)
mods['player'].GetRace=lambda:self.race
self.bag={3:11299,4:27001}
mods['player'].GetItemIndex=lambda cell:self.bag.get(cell,0)
mods['ui'].Window=Widget
mods['wndMgr'].GetMousePosition=lambda:self.mouse
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t)
self.inventoryClicks=[];self.shopClicks=[]
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(
SelectItemSlot=lambda s,slot:self.inventoryClicks.append(slot),
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+0))
mods['uiShop'].ShopDialog=type('ShopDialog',(),dict(SelectItemSlot=lambda s,slot:self.shopClicks.append(slot),
_ShopDialog__GetRealIndex=lambda s,slot:40+slot))
mods['shop'].GetItemID=lambda i:{41:12049,42:27001}.get(i,0)
self.previous={n:sys.modules.get(n) for n in mods}
sys.modules.update(mods)
self.ns={'__name__':'mt_preview'};exec(script('ModelPreviewScript.inc'),self.ns)
self.game=mods['game'];self.gw=self.game.GameWindow();self.game.GameWindow.Open(self.gw)
self.w=self.ns['g_window']
def text(self,t):
w=Widget();w.text=t;return w
def tearDown(self):
for name,value in self.previous.items():
if value is None:sys.modules.pop(name,None)
else:sys.modules[name]=value
def test_item_spec(self):
spec=self.ns['ItemSpec']
self.assertEqual(spec(11299),('weapon',[0,4,5,1,2,6,7,3]))
self.assertEqual(spec(11499),('weapon',[0,4])) # warrior only
self.assertEqual(spec(12049),('armor',[5,1])) # assassin only
self.assertEqual(spec(41001),('armor',[0,5,2,7])) # anti-female costume
self.assertEqual(spec(45001),('hair',[4,1,6,3]))
for vnum in (8000,27001,11259):self.assertIsNone(spec(vnum)) # arrow, potion, helmet
self.assertTrue(self.ns['Previewable']('mob',101));self.assertFalse(self.ns['Previewable']('item',27001))
def test_item_preview_races_and_reset(self):
self.race=2
self.assertTrue(self.ns['Preview']('item',11299));self.assertTrue(self.w.IsShow())
self.assertEqual(self.rt.live,{1});self.assertIn(('Create',1,300,330),self.rt.calls)
self.assertEqual(self.rt.models[1],2);self.assertEqual(self.rt.named('SetWeapon')[-1],('SetWeapon',1,11299))
self.assertEqual(self.w.raceName.text,'修罗(男)');self.assertEqual(self.rt.named('Zoom'),[('Zoom',1,1)]*2)
self.w.nextRace.event();self.assertEqual(self.rt.models[1],6);self.assertEqual(self.w.raceName.text,'修罗(女)')
self.w.prevRace.event();self.w.prevRace.event();self.assertEqual(self.rt.models[1],1)
# The player's own race is not allowed: the first allowed race is shown, and race buttons need two races.
self.ns['Preview']('item',41001);self.assertEqual(self.rt.models[1],2)
self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,41001))
self.ns['Preview']('item',12049);self.assertEqual(self.rt.models[1],5)
self.ns['Preview']('item',45001);self.assertEqual(self.rt.models[1],4)
self.assertEqual(self.rt.named('SetHair')[-1],('SetHair',1,45001))
self.assertTrue(self.w.nextRace.enabled)
self.ns['Preview']('item',11499);self.race=0;self.ns['Preview']('item',11499)
self.assertEqual(self.rt.models[1],0)
self.assertFalse(self.ns['Preview']('item',27001));self.assertEqual(self.chat,['该物品没有可预览的模型'])
self.assertEqual(self.rt.models[1],0) # the window keeps what it showed
def test_mob_motions_zoom_turn(self):
self.rt.motionless.add(34)
self.assertTrue(self.ns['Preview']('mob',101));self.assertEqual(self.rt.models[1],101)
self.assertFalse(self.w.prevRace.IsShow());self.assertTrue(all(b.IsShow() for b in self.w.motions))
self.w.motions[1].event();self.assertEqual(self.rt.named('SetMotionIndex')[-1],('SetMotionIndex',1,13))
self.w.motions[5].event();self.assertEqual(self.w.note.text,'该怪物没有此动作')
self.w.motions[0].event();self.assertEqual(self.w.note.text,'')
zoom_in,zoom_out,spin,reset=[b.event for b in self.w.controls]
for _ in range(20):zoom_in()
self.assertEqual(self.w.zoom,-8);self.assertEqual(len(self.rt.named('Zoom')),8)
for _ in range(30):zoom_out()
self.assertEqual(self.w.zoom,12);self.assertEqual(len(self.rt.named('Zoom')),28)
spin();self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True));self.assertEqual(self.w.controls[2].text,'停止旋转')
# Drag: absolute yaw/pitch from where the press started, pitch clamped.
view=self.w.view;self.mouse=(100,100);view.OnMouseLeftButtonDown()
self.mouse=(160,1100);view.OnUpdate()
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
view.OnMouseLeftButtonUp();self.mouse=(0,0);view.OnUpdate()
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
reset();self.assertEqual((self.w.yaw,self.w.pitch,self.w.zoom),(0.,0.,0))
self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True)) # spin survives the reset
self.rt.missing.add(999);self.ns['Preview']('mob',999);self.assertEqual(self.w.note.text,'该怪物没有模型')
zoom_in();self.assertEqual(self.w.zoom,0)
def test_view_draws_target(self):
self.ns['Preview']('mob',101);self.w.view.pos=(20,70);self.w.view.OnRender()
self.assertEqual(self.rt.named('RenderTexture')[-1],('RenderTexture',1,20,70,320,400))
def test_no_leak_on_reopen_and_close(self):
for _ in range(5):
self.ns['Preview']('item',11299);self.assertEqual(self.rt.live,{1})
self.w.closeButton.event();self.assertEqual(self.rt.live,set());self.assertFalse(self.w.IsShow())
self.ns['Preview']('mob',101);self.w.OnPressEscapeKey();self.assertEqual(self.rt.live,set())
self.ns['Preview']('mob',101)
self.game.GameWindow.Close(self.gw)
self.assertIsNone(self.ns['g_window']);self.assertEqual(self.rt.live,set());self.assertEqual(self.rt.calls[-1],('Destroy',))
self.assertFalse(self.ns['Preview']('mob',101))
def test_inventory_and_shop_entries(self):
inv=sys.modules['uiInventory'].InventoryWindow()
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertFalse(self.w.IsShow())
self.keys.add(45)
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertTrue(self.w.IsShow())
inv.SelectItemSlot(4);inv.SelectItemSlot(9);self.assertEqual(self.inventoryClicks,[3,4,9]) # not wearable, empty
dlg=sys.modules['uiShop'].ShopDialog()
dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[]);self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,12049))
dlg.SelectItemSlot(2);self.assertEqual(self.shopClicks,[2])
self.keys.clear();dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[2,1])
def test_wiki_links(self):
wiki=(ROOT/'src/platform/ScriptLib/WikiScript.inc').read_text()
self.assertIn("(u'模型预览',('preview','item',vnum))",wiki);self.assertIn("(u'模型预览',('preview','mob',vnum))",wiki)
self.assertIn("mt_preview.Preview(vnum[1],vnum[2])",wiki)
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
self.assertLess(boot.index('install_script_module("mt_mobile_inventory"'),boot.index('install_script_module("mt_preview"'))
self.assertLess(boot.index('install_script_module("mt_biolog"'),boot.index('install_script_module("mt_preview"'))
if __name__=='__main__':unittest.main()