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>
191 lines
11 KiB
Python
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()
|