这篇文章上次修改于 199 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

1月下旬,我在推完常轨脱离Creative中妃爱和锦亚澄的两条线后,开始整理艺术集时,发现这部Galgame的立绘合成方式和krkr引擎不一样,而且经过近三天的网上查阅后,发现全网没有可以很好胜任立绘合成的工具以及方式,只能用最基本的方式——手搓,也就是使用PS或者其它软件,来拼出立绘,这样会大大降低立绘合成的效率以及准确率,而且无法进行批量合成。于是,就有了接下来的artemis引擎立绘批量合成工具制作过程。

注意:本篇文章涉及到部分Galgame解包操作,请不要将解包文件进行任意的出售和未声明的二次创作!

解包毕竟是一种侵权行为,建议资源分享与使用注意场合与时机,确实地保护游戏公司的利益。所有解包过程仅供学习使用!!

再次,该文章面向群众为:对Artemis引擎的立绘合成原理和Python编程感兴趣的人士,以及对某些Galgame人物立绘有收藏纪念意向的玩家。

立绘差分的坐标读取:

常轨脱离Creative使用的是Artemis引擎。因为Artemis引擎解包后里面的立绘文件是以差分形式出现的,所以相应的,我们要找到引擎中合成立绘的各个坐标。这是实现批量合成的最关键的一步。不过经过翻阅解包文件后发现,并没有像krkr引擎那样把坐标参数按照角色分类单独存放在多个.txt或者.json的文件。后来,我在解包文件的system里的image文件夹中,发现了image_fg.lua这个脚本文件,并且是用lua语言编写的。在该脚本的第225至第256行间,有着这样的代码:

-- 立ち絵ファイルから座標を取得
function getfgfilepos(px)
    local r = { x=0, y=0 }
    if isFile(px) then
        local p = e:loadPngComments(px)
        local a = p and explode(",", p.comment)
        if p and a[1] == "pos" then
            r.x = tn(a[2])
            r.y = tn(a[3])

            -- anime
            if a[6] then
                local w = a[4]
                local h = a[5]
                local m = a[6]
                local c = 0
                local d = {}
                for i=1, m do
                    local cl = "0,"..c..","..w..","..h
                    d[i] = cl
                    c = c + h + 1
                end

                -- 保存
                r.com  = a[7] or "ani"    -- アニメ動作
                r.clip = d[1]            -- 先頭clip
                r.ani  = d                -- アニメ全clip
            end
        end
    end
    return r
end

猛一看,这段代码给我们的感觉是什么都看不懂,无从下手。但我将这段代码的前面八行交给ChatGPT后,它给了我这样的结果:

This Lua function named getfgfilepos seems to be designed to retrieve the position data stored within a PNG file’s comments. Let’s break down the function step by step:

  1. Function Signature:

    function getfgfilepos(px)
    • px: This seems to be a parameter representing the path to the PNG file.
  2. Local Variable Initialization:

    local r = { x=0, y=0 }
    • This initializes a table r with keys x and y, both set to 0. This table likely holds the position data.
  3. Checking if the File Exists:

    if isFile(px) then
    • It appears that isFile(px) is a function used to check if the file exists at the given path.
  4. Loading PNG Comments:

    local p = e:loadPngComments(px)
    • e:loadPngComments(px) seems to be a method used to load comments embedded within the PNG file specified by the path px. The result is stored in variable p.
  5. Parsing Comments:

    local a = p and explode(",", p.comment)
    • This line checks if p is not nil (which means loading comments was successful) and then splits the comment string by commas into an array a. The explode function is commonly used to split strings in Lua.
  6. Extracting Position Data:

    if p and a[1] == "pos" then
        r.x = tn(a[2])
        r.y = tn(a[3])
    • If p exists and the first element of array a is “pos”, it means the comment contains position data. The position data, assumed to be in the format “pos,x,y”, is then converted to numbers and stored in r.x and r.y.

However, there are some undefined functions and objects (isFile, e, explode, tn) used in this code snippet which seem to be part of a larger program or framework. Without knowing the definitions of these functions and objects, it’s difficult to fully understand the behavior and purpose of this function.

从ChatGPT给出的结果我们可以看到,前八行代码段是执行读取立绘文件的操作,并且从第四个步骤我们可以看到,该行代码是读取.png文件中特定的元数据,第五个步骤是将读取到的元数据p进行字符串拆分,第六个步骤则是进行分配字符串给xy,而且有关键字”pos”。根据以上分析,我们可以大胆猜测,坐标数据就藏在.png文件里所谓的元数据中。

接下来,我用十六进制编辑器打开了其中的一个立绘差分图像文件,然后惊奇地发现,关键字”pos”后果然有着四组数据。很显然,坐标数据就在这里。

关于Galgame引擎——Artemis批量合成人物立绘问题1.jpg

通过对多个图像文件的元数据分析后,这四组数据中,前两个为我们想获取的坐标数据xy,后两个则是图像的分辨率,用不到。

到了这里,看样子我们已经可以进行下一步的程序编写了。但我们真的找到了合成立绘的正确坐标了吗?

这是常轨脱离Creative里锦亚澄的立绘文件组的一部分:

关于Galgame引擎——Artemis批量合成人物立绘问题2.jpg

通常,根据立绘差分合成的规则来看,在只有表情差分和身体差分(包含服装和动作)的时候,一般是一个表情差分和一个身体差分组成的一个完整的立绘对应一个坐标,也就是用来控制表情差分在身体差分底板上的合成位置。但是,这里不光表情差分文件里有坐标,身体差分文件里也有坐标,哪个文件里的坐标才是正确的?很显然,这两个坐标数据都不能正确合成立绘,但将这两组坐标的xy分别对应互相进行减法运算后,在保证结果没有负数的情况下,得到的坐标正是合成立绘的正确坐标。这里又是关键的一步。到这里,我们这才找到了真正的坐标数据。

用Python实现坐标的读取以及运算:

首先,先写一个标准的UI界面,这里采用的Wxpython第三方模块编写UI:

import wx

立绘合成工具,当然少不了输入图片和输出图片。因为要输入的图片有两种,表情差分和身体差分,所以这里预留了三个选择输入输出文件夹的按钮,让用户自行选择:

class EncryptFrame(wx.Frame):
    def __init__(self):
        super().__init__(parent=None, id=wx.ID_ANY, title="artemis引擎立绘批量合成", pos=wx.DefaultPosition,
        size=(600,600) , style=wx.SYSTEM_MENU | wx.CLOSE_BOX | wx.MINIMIZE_BOX | wx.CAPTION ) #窗口不可拉伸且顶置
        self.SetIcon(wx.Icon("ico.ico", wx.BITMAP_TYPE_ICO))
        self.Bind(wx.EVT_CLOSE,self.OnClose)
        self.Centre() 
        panel = wx.Panel(parent=self)

        global txt1
        self.st1 = wx.StaticText(parent=panel, label='请选择表情差分图片放置文件夹:')
        self.txt1 = wx.TextCtrl(panel)
        self.choicedirbtn1 = wx.Button(panel, label='选择文件夹')
        self.Bind(wx.EVT_BUTTON, self.choicedirclick1, self.choicedirbtn1)
        txt1 = self.txt1

        global txt2
        self.st2 = wx.StaticText(parent=panel, label='请选择身体差分图片放置文件夹:')
        self.txt2 = wx.TextCtrl(panel)
        self.choicedirbtn2 = wx.Button(panel, label='选择文件夹')
        self.Bind(wx.EVT_BUTTON, self.choicedirclick2, self.choicedirbtn2)
        txt2 = self.txt2

        global txt3
        self.st3 = wx.StaticText(parent=panel, label='请选择立绘输出文件夹:')
        self.txt3 = wx.TextCtrl(panel)
        self.choicedirbtn3 = wx.Button(panel, label='选择文件夹')
        self.Bind(wx.EVT_BUTTON, self.choicedirclick3, self.choicedirbtn3)
        txt3 = self.txt3

        startbtn = wx.Button(panel, label='开始合成')
        self.Bind(wx.EVT_BUTTON, self.startcombine, startbtn)

        global txt4
        downSizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, u"状态"), wx.VERTICAL)
        txt4 = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
        downSizer.Add(txt4, 1, wx.EXPAND | wx.ALL, 5)
        infortxt = "欢迎使用artemis引擎立绘批量合成工具!\n作者:岱中鹏\n注意事项:\n1.因为立绘文件的特殊性,本程序目前只能合成大部分用artemis作为gal引擎的立绘,请勿用于合成其它立绘。\n2.一定要注意各文件夹下表情和身体的相对位置,避免因为基本位置错误导致合成错误。"
        txt4.SetValue(infortxt)


        hbox1 = wx.BoxSizer(wx.HORIZONTAL)
        hbox1.Add(self.st1,0, wx.CENTER | wx.ALL , border=5)
        hbox1.Add(self.txt1,1, wx.CENTER | wx.ALL , border=5)
        hbox1.Add(self.choicedirbtn1,0,wx.CENTER | wx.BOTTOM , border=0)

        hbox2 = wx.BoxSizer(wx.HORIZONTAL)
        hbox2.Add(self.st2,0, wx.CENTER | wx.ALL , border=5)
        hbox2.Add(self.txt2,1, wx.CENTER | wx.ALL , border=5)
        hbox2.Add(self.choicedirbtn2,0,wx.CENTER | wx.BOTTOM , border=0)

        hbox3 = wx.BoxSizer(wx.HORIZONTAL)
        hbox3.Add(self.st3,0, wx.CENTER | wx.ALL , border=5)
        hbox3.Add(self.txt3,1, wx.CENTER | wx.ALL , border=5)
        hbox3.Add(self.choicedirbtn3,0,wx.CENTER | wx.BOTTOM , border=0)
        infortxt = ""
        txt3.SetValue(infortxt)

        hbox4 = wx.BoxSizer(wx.HORIZONTAL)
        hbox4.Add(startbtn,0, wx.CENTER | wx.ALL , border=5)

        vbox1 = wx.BoxSizer(wx.VERTICAL)
        vbox1.Add(hbox1,0,wx.ALL | wx.EXPAND,border=5)
        vbox1.Add(hbox2,0,wx.ALL | wx.EXPAND,border=5)
        vbox1.Add(hbox3,0,wx.ALL | wx.EXPAND,border=5)
        vbox1.Add(hbox4,0,wx.ALL | wx.CENTER,border=5)
        vbox1.Add(downSizer,1,wx.ALL | wx.EXPAND,border=5)
        panel.SetSizer(vbox1)

    def choicedirclick1(self,event):
        dialog = wx.DirDialog(self, "选择文件夹", Desktop_path, wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST)
        if dialog.ShowModal() == wx.ID_OK: #打开资源管理器选择文件夹,下同。
            self.txt1.SetValue(dialog.GetPath())
            dialog.Destroy()

    def choicedirclick2(self,event):
        dialog = wx.DirDialog(self, "选择文件夹", Desktop_path, wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST)
        if dialog.ShowModal() == wx.ID_OK:
            self.txt2.SetValue(dialog.GetPath())
            dialog.Destroy()

    def choicedirclick3(self,event):
        dialog = wx.DirDialog(self, "选择文件夹", Desktop_path, wx.DD_DEFAULT_STYLE | wx.DD_DIR_MUST_EXIST)
        if dialog.ShowModal() == wx.ID_OK:
            self.txt3.SetValue(dialog.GetPath())
            dialog.Destroy()

接下来,就要开始写读取坐标的代码了。

身体差分:

list2 = os.listdir(txt2.GetValue())
for file1 in list2:
    if ".png" in file1: #监测读取的文件是不是非png文件,防止程序因报错停止。
        txt4.AppendText("\n正在合成{0}配套表情--------".format(file1))
        dataw1 = ""
        datas = ""
        data1 = ""
        data2 = ""
        datastop = False
        with open(txt2.GetValue() + "\\" + file1,"rb") as f:
            databody = str(f.read(100))[100:150]
            f.close() #读取100到150字节之间的元数据。
        for w in databody:
            if w == ",":
                datamove = dataw1
                    break
                dataw1 += w
        for i in databody.replace(datamove + ",",""):
            if i == "," and datastop == False:
                data1 = datas
                datastop = True
                continue
            if i == "," and datastop:
                data2 = datas    
                    break
                datas += i
        datax1 = int(data1)
        datay1 = int(data2.replace(data1,"")) #通过循环得到身体差分的x和y坐标。
    else:
        list2.remove(file1)
        continue

表情差分:

list1 = os.listdir(txt1.GetValue())

后面读取代码和身体差分的差不多,不在展示。

用Python实现图像合成:

接下来就是两组坐标之间的简单运算以及图像合成:

from PIL import Image
x = datax2 - datax1
y = datay2 - datay1
image1 = Image.open(txt1.GetValue() + "\\" + file2)
image2 = Image.open(txt2.GetValue() + "\\" + file1)
image2.alpha_composite(image1,(x,y))
image2.save(txt3.GetValue() + "\\" +  file1 + file2,"PNG")

这里要注意,使用Image模块合成.png图像时,不要以ARGB或者RGB形式读取或者合成图像,以及创建蒙版合成图像,这样会导致透明通道不正确等许多问题。这里使用了alpha_composite函数将表情差分通过坐标粘贴到身体差分上。

关于Galgame引擎——Artemis批量合成人物立绘问题3.jpg

最后,将表情差分的读取坐标过程嵌套进身体差分读取坐标的循环里,然后在执行操作过于频繁的关键位置写上几段wx.Yield()代码,保证UI不会因为批量合成图片导致卡死。

至此,artemis引擎立绘批量合成工具就编写完成了。

关于Galgame引擎——Artemis批量合成人物立绘问题4.png

根据后期多个Galgame的立绘合成测试,该工具可以应对大部分以Artemis作为引擎的Galgame的立绘合成。不过遇到表情和身体差分再分成多种差分的情况时,需要再留出多个输入图片的地方,而且要一次性合成,二次合成会因为前一次合成后把.png文件里的元数据破坏掉导致无法再次合成。


本篇文章中的工具已经在GitHub上开源: artemis引擎立绘批量合成工具