diff --git a/src/platform/ScriptLib/MountScript.inc b/src/platform/ScriptLib/MountScript.inc new file mode 100644 index 00000000..017cc7b0 --- /dev/null +++ b/src/platform/ScriptLib/MountScript.inc @@ -0,0 +1,79 @@ +// PORT: AVA2 mount costume + mount skin (ENABLE_MOUNT_COSTUME_SYSTEM / ENABLE_MOUNT_SKIN), as module mt_mount. +// The server swaps the horse race for the worn costume's value0 (metin2-server port_mount.cpp); this script +// registers the AVA2 npclist rows of the skin races (npclist.txt is read from the root pack only, so a patch +// pack cannot add them; RaceManager's __IsNewMountRace adds npc_mount to their search path), adds the mount +// and skin slots to the costume window under the sash slot, and renders the "MTMOUNT " server +// messages that replace AVA2's locale strings 334 / 1097 / 1098. Models ship in the ava2items pack +// (tools/server/ava2-items, MOUNT_MOBS). +static const char c_szMountScript[] = R"PY(# -*- coding: utf-8 -*- +import chat +import chrmgr +import game +import item +import player +import ui +import uiInventory + +L=player.AutoHuntLocalize +# AVA2 root/npclist.txt rows of the races MOUNT_MOBS ships. +RACES=((32012,"christmas_2017_sleddog_02"),(32028,"christmas_2020_alpaca")) +SRC_NAMES=(("christmas_2017_sleddog_02","christmas_2017_sleddog"),) +MESSAGES={'riding':'骑乘中无法更换坐骑时装。','need_mount':'需要先装备坐骑时装。','has_skin':'请先卸下坐骑外观。', + 'need_horse':'需要先拥有马匹(马匹等级 1 以上)才能召唤坐骑。'} +# Costume window: left column under the sash slot (CostumeSlot coordinates; body is at 61,45). +SLOTS=((item.COSTUME_SLOT_MOUNT,(13,45)),(item.COSTUME_SLOT_MOUNT_SKIN,(13,82))) + +for race,name in RACES:chrmgr.RegisterRaceName(race,name) +for name,src in SRC_NAMES:chrmgr.RegisterRaceSrcName(name,src) + + +def FindChild(node,name): + if node.get("name")==name:return node + for child in node.get("children",()): + found=FindChild(child,name) + if found:return found + return None + + +def PatchCostumeWindow(window): + base=FindChild(window,"Costume_Base");slot=FindChild(window,"CostumeSlot") + if not base or not slot:return + frames=[];slots=[] + for index,(x,y) in SLOTS: + frames.append({"name":"MountSlotBase%d"%index,"type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"}) + slots.append({"index":index,"x":x,"y":y,"width":32,"height":32}) + base["children"]=tuple(frames)+tuple(base.get("children",())) + slot["slot"]=tuple(slot["slot"])+tuple(slots) + + +_execfile=ui.execfile + +def ExecFile(fileName,dict): + _execfile(fileName,dict) + name=fileName.replace("\\","/").split("/")[-1].lower() + if name=="costumewindow.py" and "window" in dict:PatchCostumeWindow(dict["window"]) + +ui.execfile=ExecFile + +_refreshCostume=uiInventory.CostumeWindow.RefreshCostumeSlot + +def RefreshCostumeSlot(self): + _refreshCostume(self) + for index,_ in SLOTS:self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0) + self.wndEquip.RefreshSlot() + +uiInventory.CostumeWindow.RefreshCostumeSlot=RefreshCostumeSlot + + +_command=game.GameWindow.BINARY_ServerCommand_Run + +def Command(self,line): + if line.startswith('MTMOUNT '): + words=line.split() + text=MESSAGES.get(words[1] if len(words)>1 else '') + if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text)) + return 1 + return _command(self,line) + +game.GameWindow.BINARY_ServerCommand_Run=Command +)PY"; diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index 4c2fdc93..1e260ac4 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -1017,6 +1017,7 @@ namespace PythonBoot #include "AppliesScript.inc" #include "ArmorShapesScript.inc" #include "SashScript.inc" +#include "MountScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -1102,6 +1103,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_applies", c_szAppliesScript); install_script_module("mt_shapes", c_szArmorShapesScript); install_script_module("mt_sash", c_szSashScript); + install_script_module("mt_mount", c_szMountScript); if (g_touch_input) install_script_module("mt_mobile_hud", c_szMobileHUDScript); } diff --git a/src/port/GameLib/ItemData.h b/src/port/GameLib/ItemData.h index 935581ad..c8e59692 100644 --- a/src/port/GameLib/ItemData.h +++ b/src/port/GameLib/ItemData.h @@ -102,7 +102,9 @@ class CItemData { COSTUME_BODY, //0 갑옷(main look) COSTUME_HAIR, //1 헤어(탈착가능) - COSTUME_SASH = 3, // PORT: AVA2 sash (2 is AVA2 COSTUME_MOUNT) + COSTUME_MOUNT = 2, // PORT: AVA2 mount costume (value0 = horse race) + COSTUME_SASH = 3, // PORT: AVA2 sash + COSTUME_MOUNT_SKIN = 4, // PORT: AVA2 mount skin (needs a worn COSTUME_MOUNT) COSTUME_NUM_TYPES, }; diff --git a/src/port/GameLib/RaceManager.cpp b/src/port/GameLib/RaceManager.cpp index 58601698..996571b1 100644 --- a/src/port/GameLib/RaceManager.cpp +++ b/src/port/GameLib/RaceManager.cpp @@ -22,6 +22,15 @@ bool __IsNPCRace(unsigned race) return false; } +// PORT: AVA2 __IsNewMountRace — mount costume / skin races (christmas_2017_sleddog, ...) live in npc_mount. +bool __IsNewMountRace(unsigned race) +{ + if (race >= 32000 && race <= 32100) + return true; + + return false; +} + void __GetRaceResourcePathes(unsigned race, std::vector & vec_stPathes) { if (__IsGuildRace(race)) @@ -37,6 +46,8 @@ void __GetRaceResourcePathes(unsigned race, std::vector & vec_stPat if (race >= 30000) { vec_stPathes.push_back ("d:/ymir work/npc2/"); + if (__IsNewMountRace(race)) // PORT: AVA2 + vec_stPathes.push_back ("d:/ymir work/npc_mount/"); vec_stPathes.push_back ("d:/ymir work/npc/"); vec_stPathes.push_back ("d:/ymir work/monster/"); vec_stPathes.push_back ("d:/ymir work/monster2/"); diff --git a/src/port/UserInterface/GameType.h b/src/port/UserInterface/GameType.h index 76a9ca83..91b87f96 100644 --- a/src/port/UserInterface/GameType.h +++ b/src/port/UserInterface/GameType.h @@ -105,6 +105,8 @@ enum EDragonSoulStepTypes const DWORD c_Costume_Slot_Count = 2; const DWORD c_Costume_Slot_End = c_Costume_Slot_Start + c_Costume_Slot_Count; const DWORD c_Costume_Slot_Sash = c_Equipment_Start + 24; // PORT: AVA2 sash, server WEAR_COSTUME_SASH (after WEAR_BELT) + const DWORD c_Costume_Slot_Mount = c_Equipment_Start + 25; // PORT: AVA2 mount costume, server WEAR_COSTUME_MOUNT + const DWORD c_Costume_Slot_MountSkin = c_Equipment_Start + 26; // PORT: AVA2 mount skin, server WEAR_COSTUME_MOUNT_SKIN #endif diff --git a/src/port/UserInterface/PythonItemModule.cpp b/src/port/UserInterface/PythonItemModule.cpp index 48f6897e..15a62559 100644 --- a/src/port/UserInterface/PythonItemModule.cpp +++ b/src/port/UserInterface/PythonItemModule.cpp @@ -642,6 +642,10 @@ void initItem() PyModule_AddIntConstant(poModule, "COSTUME_SLOT_END", c_Costume_Slot_End); PyModule_AddIntConstant(poModule, "COSTUME_TYPE_SASH", CItemData::COSTUME_SASH); // PORT: AVA2 sash PyModule_AddIntConstant(poModule, "COSTUME_SLOT_SASH", c_Costume_Slot_Sash); + PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT", CItemData::COSTUME_MOUNT); // PORT: AVA2 mount costume + PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT_SKIN", CItemData::COSTUME_MOUNT_SKIN); + PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT", c_Costume_Slot_Mount); + PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT_SKIN", c_Costume_Slot_MountSkin); #endif #ifdef ENABLE_NEW_EQUIPMENT_SYSTEM diff --git a/tools/server/ava2-items/install_items.py b/tools/server/ava2-items/install_items.py index d141956b..bab4f6e6 100644 --- a/tools/server/ava2-items/install_items.py +++ b/tools/server/ava2-items/install_items.py @@ -1,18 +1,27 @@ #!/usr/bin/env python3 """Install items-server.tsv (from make_items.py) into share/conf/item_proto.txt + item_names.txt. Rows with the same vnum are replaced, so a rerun is idempotent. The db reads the tables only at boot: -restart db and every core afterwards. Usage: install_items.py +restart db and every core afterwards. mobs-server.tsv (optional 4th arg) goes into mob_proto.txt + mob_names.txt +the same way. Usage: install_items.py [] """ import shutil,sys from pathlib import Path server,rows,backup=map(Path,sys.argv[1:4]) +mobrows=Path(sys.argv[4]) if len(sys.argv)>4 else None if backup.exists():raise SystemExit('use a fresh backup directory') -items={int(l.split(b'\t',1)[0]):l for l in rows.read_bytes().splitlines() if l} +load=lambda p:{int(l.split(b'\t',1)[0]):l for l in p.read_bytes().splitlines() if l} backup.mkdir(parents=True) conf=server/'share/conf' -for filename,row in (('item_proto.txt',lambda v:items[v]),('item_names.txt',lambda v:str(v).encode()+b'\t'+items[v].split(b'\t')[1])): - path=conf/filename;raw=path.read_bytes();shutil.copy2(path,backup/filename) - newline=b'\r\n' if b'\r\n' in raw else b'\n' - kept=[l for l in raw.replace(b'\r\n',b'\n').split(b'\n') if l and not (l.split(b'\t',1)[0].isdigit() and int(l.split(b'\t',1)[0]) in items)] - tmp=path.with_name(filename+'.tmp');tmp.write_bytes(newline.join(kept+[row(v) for v in sorted(items)])+newline);tmp.replace(path) + +def install(proto,names,rows): + for filename,row in ((proto,lambda v:rows[v]),(names,lambda v:str(v).encode()+b'\t'+rows[v].split(b'\t')[1])): + path=conf/filename;raw=path.read_bytes();shutil.copy2(path,backup/filename) + newline=b'\r\n' if b'\r\n' in raw else b'\n' + kept=[l for l in raw.replace(b'\r\n',b'\n').split(b'\n') if l and not (l.split(b'\t',1)[0].isdigit() and int(l.split(b'\t',1)[0]) in rows)] + tmp=path.with_name(filename+'.tmp');tmp.write_bytes(newline.join(kept+[row(v) for v in sorted(rows)])+newline);tmp.replace(path) + +items=load(rows);install('item_proto.txt','item_names.txt',items) print('AVA2 items installed:',len(items),'backup:',backup) +if mobrows: + mobs=load(mobrows);install('mob_proto.txt','mob_names.txt',mobs) + print('AVA2 mobs installed:',len(mobs)) diff --git a/tools/server/ava2-items/make_items.py b/tools/server/ava2-items/make_items.py index 83d8216a..1a5370b2 100644 --- a/tools/server/ava2-items/make_items.py +++ b/tools/server/ava2-items/make_items.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Stage the AVA2 drop items that the 203 server lacks as ordinary 40250 items (server rows + client patch pack). Only types/subtypes/flags the 40250 server understands survive; items of AVA2-only systems (pets, pendants, -new rings, mounts, costume enchants, talisman bonus items, new skill books) become plain ITEM_NONE -items until those systems are ported. Usage: see build_zh_locale.build_ava2_items and --help. +new rings, costume enchants, talisman bonus items, new skill books) become plain ITEM_NONE items until those +systems are ported. Also writes mobs-server.tsv (MOUNT_MOBS) for install_items.py. Usage: see build_zh_locale.build_ava2_items and --help. """ import argparse,json,re,shutil,subprocess from pathlib import Path @@ -17,7 +17,7 @@ ARMOR_SHAPES={32:'%s_7_1'} RACE_PATHS={'warrior':('pc','pc2'),'assassin':('pc2','pc'),'sura':('pc','pc2'),'shaman':('pc2','pc')} # (male, female) # Kept with their 40250 meaning; everything else becomes ITEM_NONE (see module doc). KEEP_TYPES={'ITEM_MATERIAL','ITEM_QUEST','ITEM_GIFTBOX','ITEM_NONE','ITEM_TREASURE_BOX','ITEM_TREASURE_KEY'} -KEEP_SUBTYPES={('ITEM_WEAPON',s) for s in ('WEAPON_SWORD','WEAPON_DAGGER','WEAPON_BOW','WEAPON_TWO_HANDED','WEAPON_BELL','WEAPON_FAN')}|{('ITEM_ARMOR','ARMOR_FOOTS'),('ITEM_ARMOR','ARMOR_BODY'),('ITEM_METIN','METIN_NORMAL')}|{('ITEM_COSTUME','COSTUME_SASH')} +KEEP_SUBTYPES={('ITEM_WEAPON',s) for s in ('WEAPON_SWORD','WEAPON_DAGGER','WEAPON_BOW','WEAPON_TWO_HANDED','WEAPON_BELL','WEAPON_FAN')}|{('ITEM_ARMOR','ARMOR_FOOTS'),('ITEM_ARMOR','ARMOR_BODY'),('ITEM_METIN','METIN_NORMAL')}|{('ITEM_COSTUME',s) for s in ('COSTUME_SASH','COSTUME_MOUNT','COSTUME_MOUNT_SKIN')} DESC={'ITEM_METIN':'可镶嵌到武器的空孔中。','ITEM_MATERIAL':'制作与升级材料。','ITEM_QUEST':'任务物品。','ITEM_GIFTBOX':'打开后可获得物品。','ITEM_TREASURE_BOX':'需要对应的钥匙才能打开。','ITEM_TREASURE_KEY':'可以打开对应的宝箱。','ITEM_WEAPON':'','ITEM_ARMOR':'','ITEM_COSTUME':'披风。可在披风工匠处合成升级,或吸收武器、铠甲的部分属性。'} # Sash system (metin2-server docs/FEATURE-AVA2-UNLOCK.md P4): effects registered by the client's mt_sash script # (files they reference are staged too), the window images, and item_scale.txt (model scale per job/sex; a row @@ -26,6 +26,13 @@ SASH_EFFECTS=('d:/ymir work/effect/etc/buff/buff_item6.mse','d:/ymir work/effect 'd:/ymir work/pc/common/effect/armor/acc_01.mse') SASH_LOCALE={'locale/en/ui/acce/acce_combine.tga':'locale/zh/ui/acce/acce_combine.tga', 'locale/en/ui/acce/acce_absorb.tga':'locale/zh/ui/acce/acce_absorb.tga'} +# Per-subtype descriptions (override DESC). +DESC_SUB={('ITEM_COSTUME','COSTUME_MOUNT'):'坐骑时装。装备后召唤该坐骑(需要已拥有马匹),装备期间属性生效。', + ('ITEM_COSTUME','COSTUME_MOUNT_SKIN'):'坐骑外观。需先装备坐骑时装,装备后改变坐骑外观。'} +# Mount system (P5): races of the mount skins that 203 and the 40250 client lack -> (zh name, npc_mount folder). +# Their AVA2 mob_proto rows go to mobs-server.tsv, the whole model folder into the pack; the client's mt_mount +# script registers the race names (npclist.txt is read from the root pack only). +MOUNT_MOBS={32012:('蓝色巴里','christmas_2017_sleddog'),32028:('白色冬日羊驼','christmas_2020_alpaca')} LOCKED='该物品所属的系统尚未开放,暂时无法使用。' def enums(proto_reader): @@ -156,12 +163,21 @@ def prepare(ava,proto_reader,locale,output,tool,packer,wrap=lambda text:text): if q not in files:raise SystemExit('missing AVA2 asset %s for %d'%(q,v)) target=assets/q;target.parent.mkdir(parents=True,exist_ok=True) if not target.exists():shutil.copy2(files[q],target);copied+=1 - for q in shape_models()+effect_files(files): + mount_files=[q for q in files for _,d in MOUNT_MOBS.values() if q.startswith('d:/ymir work/npc_mount/%s/'%d)] + if len({q.split('/')[3] for q in mount_files})!=len(MOUNT_MOBS):raise SystemExit('missing AVA2 mount models') + for q in shape_models()+effect_files(files)+mount_files: if q not in files:raise SystemExit('missing AVA2 asset %s'%q) target=assets/q;target.parent.mkdir(parents=True,exist_ok=True) if not target.exists():shutil.copy2(files[q],target);copied+=1 (output/'items-server.tsv').write_bytes(b'\n'.join(server)+b'\n') (output/'items-client.tsv').write_bytes(b'\n'.join(client)+b'\n') + mobs={} + for line in (db/'mob_proto.txt').read_bytes().splitlines(): + cols=line.split(b'\t') + if cols[0].strip().isdigit() and int(cols[0]) in MOUNT_MOBS: + v=int(cols[0]);cols[1]=MOUNT_MOBS[v][0].encode('gbk');mobs[v]=b'\t'.join(cols) + assert mobs.keys()==MOUNT_MOBS.keys(),sorted(MOUNT_MOBS.keys()-mobs.keys()) + (output/'mobs-server.tsv').write_bytes(b'\n'.join(mobs[v] for v in sorted(mobs))+b'\n') target=assets/'locale/zh';target.mkdir(parents=True,exist_ok=True) subprocess.run([str(tool),str(locale/'item_proto'),str(output/'items-client.tsv'),str(target/'item_proto')],check=True) for source,dest in SASH_LOCALE.items(): @@ -175,7 +191,7 @@ def prepare(ava,proto_reader,locale,output,tool,packer,wrap=lambda text:text): if filename=='item_list.txt':result.append('\t'.join(listrows[v]).encode('gbk')) else: t=rows[v][2].strip() - desc=DESC.get(t,'') if v in report['kept'] else LOCKED + desc=DESC_SUB.get((t,rows[v][3].strip()),DESC.get(t,'')) if v in report['kept'] else LOCKED result.append(('%d\t%s\t%s'%(v,zh[v],wrap(desc) if desc else '')).encode('gbk')) (target/filename).write_bytes(b'\n'.join(result)+b'\n') subprocess.run([str(packer),str(output/'ava2items'),str(assets),''],check=True) diff --git a/tools/server/ava2-items/tests/test_items.py b/tools/server/ava2-items/tests/test_items.py index 8d301ae0..3044e3af 100644 --- a/tools/server/ava2-items/tests/test_items.py +++ b/tools/server/ava2-items/tests/test_items.py @@ -51,6 +51,26 @@ class ConvertTests(unittest.TestCase): values=('4','0','0','0','0','0')),self.E,{85004}) self.assertTrue(kept);self.assertEqual(s[2:4],['ITEM_COSTUME','COSTUME_SASH']);self.assertEqual(s[24],'4') # value0 = grade self.assertEqual(c[2],3) # COSTUME_SASH, after BODY/HAIR/MOUNT + def test_mount_and_skin_kept(self): + s,c,kept=m.convert(row(71502,'ITEM_COSTUME','COSTUME_MOUNT','ANTI_STACK',applies=('APPLY_ATTBONUS_MONSTER','30','APPLY_NONE','0','APPLY_NONE','0'), + values=('20113','0','0','0','0','0')),self.E,{71502}) + self.assertTrue(kept);self.assertEqual(s[3],'COSTUME_MOUNT');self.assertEqual(s[18:20],['APPLY_ATTBONUS_MONSTER','30']) + self.assertEqual(s[24],'20113');self.assertEqual(c[2],2) # value0 = horse race + s,c,kept=m.convert(row(71234,'ITEM_COSTUME','COSTUME_MOUNT_SKIN',values=('32012','0','0','0','0','0')),self.E,{71234}) + self.assertTrue(kept);self.assertEqual(s[3],'COSTUME_MOUNT_SKIN');self.assertEqual(c[2],4) + self.assertEqual(set(m.MOUNT_MOBS),{32012,32028}) + def test_install_items_with_mobs(self): + import subprocess,tempfile + with tempfile.TemporaryDirectory() as d: + d=Path(d);conf=d/'share/conf';conf.mkdir(parents=True) + (conf/'item_proto.txt').write_bytes(b'VNUM\tNAME\r\n71502\told\r\n');(conf/'item_names.txt').write_bytes(b'VNUM\tNAME\r\n') + (conf/'mob_proto.txt').write_bytes(b'VNUM\tNAME\n20110\tboar\n');(conf/'mob_names.txt').write_bytes(b'20110\tboar\n') + (d/'i.tsv').write_bytes(b'71502\tnew\n');(d/'m.tsv').write_bytes(b'32012\tbari\tKING\n') + subprocess.run([sys.executable,str(HERE/'install_items.py'),str(d),str(d/'i.tsv'),str(d/'bk'),str(d/'m.tsv')],check=True,capture_output=True) + self.assertEqual((conf/'item_proto.txt').read_bytes(),b'VNUM\tNAME\r\n71502\tnew\r\n') + self.assertEqual((conf/'mob_proto.txt').read_bytes(),b'VNUM\tNAME\n20110\tboar\n32012\tbari\tKING\n') + self.assertEqual((conf/'mob_names.txt').read_bytes(),b'20110\tboar\n32012\tbari\n') + self.assertTrue((d/'bk/mob_proto.txt').is_file()) def test_sash_effect_closure(self): import tempfile with tempfile.TemporaryDirectory() as d: