Ren'Py学习笔记(一)使用layeredimage构建多图层立绘

这是Ren'Py视觉小说引擎学习系列的第一篇文章。这个系列主要记录笔者在学习该引擎时的一些总结和感受。

本文的主题是层叠式图像layeredimage

前言

放一些杂七杂八的东西。

Ren'Py官网:https://www.renpy.org/

官方文档(英文):https://www.renpy.org/doc/html/

简中文档并不是很全,不太推荐。虽然英文文档也没有涵盖所有的内容 (ノ`Д)ノ

本文参考了下面这些资料:

https://gruntsteel.itch.io/distant-travels/devlog/452114/how-to-set-up-layeredimages-in-renpy-new-sprite-teaser

https://www.renpy.org/doc/html/layeredimage.html

由于Ren'Py的基础比较简单,因此这个系列的文章不会涵盖入门内容,也不会按部就班地讲解Ren'Py,我会仅仅选择我认为有趣的主题来写。我在接触Ren'Py之前已经有一定的Python基础。

我正在尝试将基于KRKR引擎的柚子社第七作《天色幻想岛》(部分地)移植到Ren'Py,从而起到学习了解Ren'Py的作用。因此接下来的一些文章可能都会以这部作品为例,并且也可能包含一些KRKR引擎的内容。

作为Ren'Py初学者,我的脚本可能会存在许多不专业的操作,请见谅。

为什么要引入layeredimage

layeredimage(层叠式图像)是Ren'Py在7.0版本引入的一种的可视组件(参见此处),主要用于解决同一个角色的多级图层的立绘合成问题。

考虑这样一种情况,某个角色的立绘由4个图层组成:

  • 第一层是主体,即角色的身体、头部和脸部。角色可能有多种着装,因此这一层会有多种变化。
  • 第二层是一个可选的脸红图层,这层可以是空(全透明),也可以带有一个脸红的图层,脸红图层可能有多种变化。
  • 第三层是角色的五官,包括眉毛、眼睛、嘴等。角色可能有多种表情,因此这一层也会有多种变化。
  • 第四层是角色的前发,主要起到部分遮罩角色眉眼的作用,使立绘看起来更加立体,并消除眼睛完全暴露的违和感。这层的种类不会太多。

那么这个角色的立绘的理论数量是所有图层各自可能变化数量的乘积。对于业界大厂产出的游戏来说,一个角色可能有多套服装和几十种表情(据我了解,柚子社的角色以表情丰富著称),虽然我们可以使用某些工具(比如这个)来合成所有可能的立绘,但会出现如下问题:

  • 图像的数量将非常惊人,且会占用相当大的存储空间。
  • 最重要的是,为了使用这数量巨大的组合立绘,我们必须在脚本中使用大量的定义语句来引用这些图片。这严重耽误了开发效率。

值得庆幸的是,Ren'Py有专门用于解决这个问题的工具,即layeredimage

layeredimage的官方文档:https://www.renpy.org/doc/html/layeredimage.html

中文:https://www.renpy.cn/doc/layeredimage.html#LayeredImage

layeredimage的使用

结构简介

一个layeredimage由多个图层(group)组成。每个图层有多个可选项(attribute)。我们知道,show语句显示图像时,第一个字段是图像名,之后的字段都是attribute。当我们尝试显示layeredimage时,引擎会根据我们指定的attribute到每个group中匹配,从而组合出我们想要的立绘。

直接看一个例子:

layeredimage augustina:

    always:
        "augustina_base"

    group outfit:

        attribute dress:
            "augustina_outfit_dress"

        attribute jeans:
            "augustina_outfit_jeans"

    group eyes:

        attribute open default:
            "augustina_eyes_open"
            default True

        attribute wink:
            "augustina_eyes_wink"

    group eyebrows:

        attribute normal default:
            "augustina_eyebrows_normal"

        attribute oneup:
            "augustina_eyebrows_oneup"

    group mouth:

        pos (100, 100)

        attribute smile default:
            "augustina_mouth_smile"

        attribute happy:
            "augustina_mouth_happy"

    if evil:
        "augustina_glasses_evil"
    else:
        "augustina_glasses"

这段来自官方文档的示例代码定义了一个名叫augustina的layeredimage对象,它包含outfit、eyes、eyebrow、mouth几个group,此外还使用always语句绘制了一个总是会显示的图层,以及通过条件判断分别绘制了两种可能的眼镜图层。计算可以得出,这位augustina共有

\[2\times 2\times 2\times 2\times 2=2^5=32 \]

种不同的立绘组合。

attribute可以使用任何Unicode字符,意味着你可以使用中文字符来描述备选的图层。

default关键词

每个group都可以包含至多一个带有default关键词的attribute,当某个attribute带有default关键词时,其所属的group在显示图像时可以不指定,Ren'Py将自动使用带有default关键词的attribute。对于上面的例子,假如我们使用如下命令显示立绘:

show augustina dress

那么Ren'Py将按顺序绘制"augustina_base"、"augustina_outfit_dress"、"augustina_eyes_open"、"augustina_eyebrows_normal"、"augustina_mouth_smile",最后根据环境变量evil来绘制眼镜图层。

multiple关键词

group后面可以跟关键词 multiple 。出现时,可以同时选中某个组的多个成员。这个功能可以用于某个自动定义多个属性的组,以便同时对组内成员同时设置相同的特性(property)或属性(attribute)。但是与关键词 default 定义的属性会有冲突。

图层顺序问题

layeredimage的图层绘制是有顺序的。更靠前的group,在绘制时会处于更靠底部(离屏幕前的观众更远)的位置。为了使立绘被正确绘制而不产生相互遮挡的问题,必须谨慎地排列group的定义顺序。

以上面的例子为例,引擎在绘制立绘时将会首先绘制"augustina_base"这张图,然后根据指定的attribute依次从outfit、eyes、eyebrow、mouth中选取对应的图层并叠加在base上。最后绘制条件语句中的glasses图层。

尽管图层绘制存在顺序,但你在使用show语句显示整个layeredimage时不必太在意attribute的顺序。图层绘制的顺序完全取决于各个group定义的顺序。

图像的定位问题

可以使用offsetposanchor等语句对layeredimage整体及其各个组件进行定位。我这里选用比较熟悉的offset来说明。

layeredimage声明行之后紧接着一条offset语句,可以指定整个layeredimage对象的偏移。之后绘制的所有上层图层都将继承这个偏移。

layeredimage example:

	offset (100, 100)
	
	group xxx:
		...

每个attribute也可以指定offset,在后面我们将会看到,通过使用这种独立的offset,可以确保从KRKR移植过来的立绘图层不发生错位。

group xxx:

	attribute aaa:
		"path/to/image"
		offset(10, 20)
    attribute bbb:
    	"path/to/image"
		offset(20, 30)

注意Ren'Py默认的图片位置是水平居中,图片底部和窗口底部相接。

  • 正的xoffset将产生向右的偏移
  • 正的yoffset将产生向下的偏移

不合适的offset可能导致图像被部分或完全移出画面,在使用中请务必注意。

根据其他attribute自动推导某些attribute

想象我们有这样一位角色,当她穿私服时,需要搭配一种前发;当她穿其他服饰时,需要搭配另一种前发。前发的样式完全由服饰图层决定。(后面我们将会看到这位宛若天仙的女孩的立绘实例

虽然我们可以为前发定义一个group,然后指定两种attribute来对应不同的前发,但这种写法要求我们在绘制立绘时必须显式指定前发的样式,显然不符合我们希望根据服饰自动推导前发样式的需求。

为了处理这种问题,layeredimage添加了三种标记:if_anyif_allif_not

  • if_any表示仅当其参数列表中的多个attribute中有至少一个被命中时,才启用当前的group
  • if_all表示仅当其参数列表中的多个attribute全部被命中时,才启用当前的group
  • if_not表示仅当其参数列表中的多个attribute都未被命中时,才启用当前的group

有点抽象!请看下文实战中“前发图层”一节的例子。

实战

KRKR立绘解包

将《天色幻想岛》的游戏资源解包出来。KRKR引擎的解包技术已经比较普及,此处不详细介绍了。由于我这里的资源是KRKR模拟器版,没有exe文件,因此无法使用强大的KrkrExtract工具解包,于是我使用KRKR Android模拟器直接解压.xp3文件(在模拟器的文件浏览中长按即可选择解压),并使用GARbro将tlg图像文件转换为png。

我知道解包游戏是很不好的行为,所以我可能会删文跑路的,请不要举办我〒▽〒

文件重命名和组织

由于本文的主题是立绘,因此我们只关注fgimage这个文件夹(对应fgimage.xp3)。该文件夹内按照人物分门别类地存储了立绘图层。每个角色文件夹中包含该角色的所有立绘图层和若干个说明文件。观察可以发现:

  • 本作4位女主的立绘有两套基础动作(朝向不同的方向),一套为主动作(a),一套为副动作(b)。
  • 主动作的每套着装都有标准姿势和“腕差分”两种姿势。
  • 每套基础动作图层都对应有各自的表情图层、一套或多套脸红图层和前发图层。
  • 所有素材都有两个不同分辨率的版本(带有0标记的是较低分辨率的版本,不带标记的则是较高分辨率的版本)

fgimage文件夹结构示意

以第三女主白鹿爱莉为例,该角色的文件夹中文件列表为:

愛莉a_0_314.tlg
愛莉a_0_315.tlg
愛莉a_0_467.tlg
...
愛莉a_0.txt

愛莉a_314.tlg
愛莉a_315.tlg
愛莉a_467.tlg
...
愛莉a.txt

愛莉b_0_47.tlg
愛莉b_0_191.tlg
愛莉b_0_192.tlg
...
愛莉b_0.txt

愛莉b_47.tlg
愛莉b_191.tlg
愛莉b_192.tlg
...
愛莉b.txt

其中a是主动作,角色面部朝左,每套着装都有默认姿势和腕差分。b是副动作,角色面部朝右,只有一种动作。ab都有制服、私服1、私服2、睡衣、泳装、果体6种服装差分。因此a有12种基础图层,b有6种基础图层。

ab都各自有一个可选的脸红图层。

ab都各自有若干个表情图层,其中的部分表情是流泪的差分。

ab都各自有一个固定的前发图层。

Ren'Py对非ASCII文件名的支持情况

总的来说,Ren'Py支持非ASCII文件名,但不推荐使用。建议的做法是将所有文件用ASCII字符重命名。

如果你确实希望使用非ASCII文件名(如包含中文、日文字符的文件名),按照这个贴子中PyTom本人(Ren'Py作者)的回复,你可以在字符串之前添加一个u来支持Unicode字符串:

    image splash logo1 = u"cg/sys/title/注意事項.jpg"

但这会导致以下问题:

  • 常见的打包文件格式(zip、tar等)不包含文件名的编码信息,因此包含Unicode字符的文件名在使用不同编码的系统上解压出来可能会乱码。假设你使用中文Windows(使用GBK编码)或日文Windows(使用Shift-JIS编码)打包了文件,那么在英文Windows(使用ASCII编码)或Mac OS(使用UTF-8编码)解压后,带有非ASCII字符的文件名很可能会乱码。
  • 如果你使用了Unicode字符串,为了支持这种编码,Ren'Py在打包时会加入额外的组件,主要是大量的CJK(Chinese、Japanese、Korean)字符的对照表,体积可能会膨胀1MB以上。

为了避免这些麻烦,我在移植《天色幻想岛》时将所有包含日文的文件重命名为纯ASCII文件名。

使用多个文件夹归类并重命名文件

为了移植到Ren'Py,我将ab的高分辨率版本保留,并分别放入两个文件夹。低分辨率图片则舍去不用。立绘文件结构如下:

game
	images
		tachie
			airi
				a
					a_314.png
					a_315.png
					...
                                b
                	                b_47.png
                	                b_191.png
                        yune
            	                a
            		                ...
           		        b
           			        ...
           	        ...
                bg
                ...
        scripts
        audio
        ...
    ...
                	

为了移除图片文件名的非ASCII部分,使用一个简单的Python脚本来完成批量重命名:

import os

path = './'
if os.path.exists(path):
    files = os.listdir(path)
    print(files)
files.remove('rename.py')
for file_name in files:
    new_name = file_name.replace('愛莉', '')
    # print(new_name)
    os.rename(path + file_name, path + new_name)

将脚本放在图片的同级目录下执行即可。

KRKR立绘清单文件

注意到上一节展示的文件列表中,除了图像的.tlg文件,还有几个.txt文件。这些文件中描述了对应的立绘各图层的位置关系、图层名称和文件名的对应关系等。如“愛莉a.txt”描述了所有面部朝左较高分辨率版本的立绘组件;“愛莉b_0.txt”描述了所有面部朝右较低分辨率版本的立绘组件。

这些说明文件均使用Shift-JIS编码,“愛莉a.txt”的文件内容大概是这样的:

愛莉a.txt的文件内容(部分)

我们仅关注其中的name(图层名称)、left(左边距)、top(右边距)、opacity(不透明度)、images(图片编号)几项。width(宽度)、height(高度)对我们的移植没有实际意义。

lefttop我还没看出来是怎么计算的,不过不要紧,我们关注的主要是不同图层相对于基础图层的偏移量,而layeredimage整体的偏移量将会使用我们自拟的数据。

使用layeredimage

下面我们以白鹿爱莉这个角色为例说明她的layeredimage的编写过程。为了节省篇幅,此处只解析面朝左侧的主动作a的编写。为了支持两种动作,可以定义两个layeredimage

layeredimage 爱莉 左向:
	...
layeredimage 爱莉 右向:
	...

我们只对第一个进行说明。

全局变换

首先对图像全局施加一个offset并适当缩放一下,使整个layeredimage被定位到合适的位置,并具有合适的尺寸:

layeredimage 爱莉 左向:

    offset (0, 1050)
    zoom 0.8

注意,缩放应该只写在全局位置,如果针对不同图层施加不同的缩放,那么图层的比例将会失调。

基础图层

定义group outfit,即基础图层。前面已经提到,这个图层应该具有12个attribute

需要注意的是,不同的基础图层,尺寸会有所不同,而Ren'Py绘图是居中的,这意味着图像左上顶点的位置是不固定的。为了使之后各种图层的组合都能正确地定位,我们需要选定一个基准attribute,让其他attribute都向它对齐,保证所有基础图层的左上顶点都在同一个点。我这里选取的基准是“私服1”。

group outfit:

        # 每个立绘的宽度有所不同,此处使用私服1作为基准,其他立绘向私服1对齐,以避免表情图层错位
        attribute 制服:
            "tachie/airi/a/a_884.png"
            xoffset -83
        attribute 制服手腕差分 default:
            "tachie/airi/a/a_885.png"
            xoffset -102
        attribute 私服1:
            "tachie/airi/a/a_611.png"
        attribute 私服1手腕差分:
            "tachie/airi/a/a_469.png"
        attribute 私服2:
            "tachie/airi/a/a_468.png"
            xoffset -78
        attribute 私服2手腕差分:
            "tachie/airi/a/a_467.png"
            xoffset -102
        ...

写在attribute里面的偏移量是建立在全局偏移的基础上的,也可以认为这些偏移量的原点是基准图层(此处为私服1)左上角的坐标。偏移量可以通过前面的清单文件中的数据计算得出。设基准attribute(私服1)的left值和top值分别是\(x_0, y_0\),任意其他基础图层的的left值和top值分别是\(x_1,y_1\),则这个图层应该施加的offset是:

\[\text{offset}_1=(x_1-x_0,y_1-y_0) \]

将所有12个attribute写完后,在脚本文件中引用layeredimage来测试:

scene airi_room_day	# 简单加个背景

show 爱莉 左向 私服2

重新加载脚本,观察图像是否处在我们满意的位置:

仅基础图层

如果对位置和尺寸不满意,可以多次调整全局偏移量和缩放倍率,同时观察游戏窗口中的效果,最终达到满意的效果。

在游戏窗口中,按Shift+R可以重新加载脚本。实际上保存脚本文件之后引擎应该会自动重载脚本。

可选的脸红图层

脸红的效果是由一个专门的脸红图层来实现的,需要注意脸红图层应该位于表情图层的下面,这样脸红才不会遮挡眼睛。

# 注意脸红应该排在face前面,这样脸红图层才不会遮挡眼睛
    group blush:

        attribute 无脸红 default:
            null
        
        attribute 脸红:
            "tachie/airi/a/a_634.png"
            offset (105, 199)

无脸红应该是默认的情况,且不添加任何图层,我们简单地使用一个null来跳过这种情况。这样当角色无脸红时我们可以简单地省略这个attribute,而不是显式地写一个“无脸红”。

offset的计算方法上文已经给出,要记住除基准图层外的所有其他图层的偏移量都是相对于基准图层而言的。下面的各种图层均使用同样的方法,不再一一赘述。

现在绘制脸红图层来测试效果,我同时更改了基础图层,来展示腕差分的效果:

show 爱莉 左向 私服2手腕差分 脸红

私服2手腕差分 脸红

表情图层

这是数量最多的一个图层了。没有太多可以解释的。

group face:

        # 以私服1为对齐对象,调整offset
        attribute 笑脸:
            "tachie/airi/a/a_809.png"
            offset (135, 156)
        attribute 懊恼:
            "tachie/airi/a/a_798.png"
            offset (130, 156)
        attribute 失落:
            "tachie/airi/a/a_782.png"
            offset (138, 162)
        ...

几张流泪的差分表情也应该写在这个group里面:

	...
	attribute 流泪垂眼:
            "tachie/airi/a/a_894.png"
            offset (134, 162)
        attribute 流泪抬眼:
            "tachie/airi/a/a_893.png"
            offset (138, 162)
        ...

测试以下效果,为了暴露没有前发的问题,我这里选用了一个睁眼的表情(这个表情叫“没错” ƪ(˘⌣˘)ʃ):

show 爱莉 左向 私服2 没错

没错

前发图层

前发的主要作用是遮挡一下眼睛,显得不那么突兀。

注意前发图层应该是略微透明的,表现出一种碎发的效果。查询清单文件可知,不透明度应为166,而Ren'Py中用于控制图片透明度的alpha变换的参数范围是0-1的小数,因此我们用255去除一下。

同时,前发应该是始终绘制的,因此不需要再设置group,而是直接使用always

# 前发。爱莉同一个朝向的所有着装共用同一套前发。
    always:
        "tachie/airi/a/a_644.png"
        offset (129, 184)
        alpha 166 / 255

前发图层的offset是最敏感的,哪怕差了一个像素效果就惨不忍睹,所以请仔细核对KRKR清单文件的数据。

现在查看同一个表情的效果,注意眼角处的遮挡效果,而且这个效果应该是半透明的。

加上前发之后的效果

爱莉的前发问题很好地解决了,但某些角色的同一个动作可能有不同的前发样式,我们来看第二女主天雾夕音的立绘。

夕音的立绘清单文件指出,当基础图层为私服1(包括对应的腕差分)时,应该使用一种“私服专用前发”,ID是1454;基础图层是其他时,应该使用另一种通用前发,ID是1455。

根据前面介绍的if_anyif_allif_not,不难写出如下脚本:

group hair1 if_any["私服1", "私服1手腕差分"]:

        attribute 私服前发 default:
            "tachie/yune/a/a_1454.png"
            offset (107, 195)
            alpha 166 / 255

group hair2 if_not["私服1", "私服1手腕差分"]:
        
        attribute 其他前发 default:
            "tachie/yune/a/a_1455.png"
            offset (107, 195)
            alpha 166 / 255

注意这段脚本应该放在夕音的layeredimage的最后,表示前发在最上层被绘制。

这里放一张制服的立绘,读者可以和文末的私服1立绘对比,注意左眼旁边垂下来的头发,留意两种前发的区别。

夕音 制服

效果展示

这篇文章到这里就全部结束了。layeredimage实际上还支持更多较为冷门的复杂功能,可以查阅官方文档。如果我发现本文的描述存在问题或者需要补充,我会随时进行修改。

最后我们来看一下总体的效果:

show 夕音 左向 私服1 淑女 at right

show 爱莉 右向 私服2 流泪垂眼 脸红1 at left

总体效果

posted @ 2023-03-15 16:51  Eslzzyl  阅读(769)  评论(0编辑  收藏  举报