Ren'Py引擎从入门到放弃(支线9)——使用CDD播放序列帧动画

Ren'Py引擎从入门到放弃(支线9)——使用CDD播放序列帧动画

世上无难事,只要肯放弃。

支线系列是独立于基础之外的内容,感觉有困难的同学可以暂时不(fang)看(qi)。


第一个问题:什么是序列帧动画?

答:序列帧动画的本质就是……动画:通过快速切换画面(帧),使观众产生画面内容在运动的感觉。

序列帧动画是大多数游戏引擎都具备的功能,Ren'Py也不例外。Ren'Py中最基础的序列帧播放方式,是使用ATL定义图像(Image)对象。例如,我们有一个8帧的动画序列,文件名分别为 seq_1.png ~ seq_8.png。那么我们使用这8张图片定义一个每秒8帧的动画往往这样写:

image SomeAnima:
    "seq_1.png"
    pause 0.12
    "seq_2.png"
    pause 0.12
    "seq_3.png"
    pause 0.12
    "seq_4.png"
    pause 0.12
    "seq_5.png"
    pause 0.12
    "seq_6.png"
    pause 0.12
    "seq_7.png"
    pause 0.12
    "seq_8.png"
    pause 0.12

    repeat

这样写的优点是清晰易懂,缺点是……非常蛋疼,不对,冗余信息过多。当序列数量不多时,直接列出所有序列图片还可以接受,如果序列数量有几十甚至上百,这种写法效率就很低下。

绝大多数序列帧动画使用的图片文件,往往具有相同的前缀和相同的播放时间(帧率),只需要根据某个递增的序列号顺序显示对应图片即可。所以我们可以使用CDD更高效地实现序列帧动画。


第二个问题:什么是CDD?

答:CDD全称是Creator-Defined Displayable,创作者定义的可视组件,本质是Ren'Py提供的一个基类(超类)和一组API。

详情可参考文档:

renpy.Displayable 是CDD是核心基类,所有CDD都必须是它的派生类。创建的CDD中必须包含 render 方法,返回类型是 renpy.Render (或其派生)类的实例。当然,如果没有 render 方法在语法上不会报错,只是不显示任何图像而已……event方法用于处理pygame事件,visit方法访问CDD子组件时返回子组件列表,播放序列帧动画时不需要。至此,我们可以实现一个空的CDD了:

init python:

    class SquenceAnimator(renpy.Displayable):

        def __init__(self, **kwargs):
            super(SquenceAnimator, self).__init__(**kwargs)

        def render(self, width, height, st, at):
            render = renpy.Render(0, st, at)
            renpy.redraw(self, 0)

            return render

我们可以定义这个CDD的一个实例,并使用 show 语句显示(尽管实际不会显示任何内容):

image null_seq = SquenceAnimator()

label start:
    show null_seq

SquenceAnimator 实现具体功能之前,还需要先设计支持的“序列帧命名规范”,也就是图片文件名的规则。我们这里采用最简单的一种规则:

完整文件名 = 前缀 + 分隔符 +序列号 + 文件扩展名

举例来说,在第一个问题中提到的“seq_1.png~seq_8.png”,“seq”是前缀,下划线“_”是分隔符,数字1到8是序列号,“png”是文件扩展名。除了文件扩展名由Ren'Py预处理外,其他3项内容就是 SquenceAnimator 的3种必备入参。序列帧应是两个整型入参,或者一个二元元组,分别表示起始帧序列号和结束帧序列号(此处我使用的是2个整型入参)。我们有了这4个入参就可以找到符合规范的一组图片:

init python:
    class SquenceAnimator(renpy.Displayable):

        def __init__(self, prefix, separator, begin_index, end_index, **kwargs):
            super(SquenceAnimator, self).__init__(**kwargs)
            # 前缀
            self.prefix = prefix
            # 分隔符
            self.separator = separator
            # 起始帧序列号
            self.begin_index = begin_index
            # 结束帧序列号
            self.end_index = end_index
            # 序列帧长度
            self.length = end_index - begin_index + 1

            # 可视组件列表
            self.sequence = []
            for i in range(begin_index, end_index+1):
                # 将前缀、分隔符和序列号拼接,对应名称的可视组件顺序添加到列表中
                self.sequence.append(renpy.displayable(self.prefix + self.separator + str(i)))

            # 当前帧在可视组件列表中的索引
            self.current_index = 0
            # 关键帧时间轴
            self.show_timebase = 0

找到图片并放置到可视组件列表中后,我们接着就可以在 render 方法中根据时间渲染对应的可视组件了:

        def render(self, width, height, st, at):
            # 暂时默认每帧持续时间0.12秒
            interval = 0.12
            if (st >= (self.show_timebase + interval)):
                self.show_timebase = st
                self.current_index += 1
                if self.current_index >= self.length:
                    # 默认循环播放,将可视组件列表索引归零
                    self.current_index = 0                        

            # 此处默认所有序列帧图片都具有相同尺寸
            render = renpy.render(self.sequence[self.current_index], width, height, st, at)
            renpy.redraw(self, 0)

            return render

此处可以先测试一下,是否可以播放序列帧动画。

回到上面的代码,每帧持续时间定死了0.12秒,并且是循环播放。如果需要自己定义每帧持续时间并控制是否循环,就需要给 SquenceAnimator 的构造函数添加两个入参,同时修改一下可视组件列表索引达到最大值的逻辑:

init python:

    class SquenceAnimator(renpy.Displayable):

        def __init__(self, prefix, separator, begin_index, end_index, interval, loop=True, **kwargs):
            super(SquenceAnimator, self).__init__(**kwargs)
            self.prefix = prefix
            self.separator = separator
            self.begin_index = begin_index
            self.end_index = end_index
            self.length = end_index - begin_index + 1


            self.sequence = []
            for i in range(begin_index, end_index+1):
                self.sequence.append(renpy.displayable(self.prefix + self.separator + str(i)))

            self.current_index = 0
            self.show_timebase = 0

            self.interval = interval
            self.loop = loop

        def render(self, width, height, st, at):
            if (st >= (self.show_timebase + self.interval)):
                self.show_timebase = st
                self.current_index += 1
                if self.current_index >= self.length:
                    if self.loop:
                        self.current_index = 0                        
                    else:
                        self.current_index = self.length - 1

            render = renpy.render(self.sequence[self.current_index], width, height, st, at)
            renpy.redraw(self, 0)

            return render

实例化并使用:

image SeqAnima = SquenceAnimator("seq", "_", 1, 8, 0.12)

label start:
    show SeqAnima at truecenter


第三个问题:有些序列帧动画会合并为一张图片,该如何处理?

答:单张图片的序列帧动画,本质是一个网格,序列索引通常是按行读取,从左往右顺序排列。比如一个8帧动画,图片可能是一个2×4的网格,顺序示意如下(开头的数字表示序列号,括号里的数字表示行、列的索引号):

┌────────┬────────┐
│ 1(0,0) │ 2(0,1) │
├────────┼────────┤
│ 3(1,0) │ 4(1,1) │
├────────┼────────┤
│ 5(2,0) │ 6(2,1) │
├────────┼────────┤
│ 7(3,0) │ 8(3,1) │
└────────┴────────┘

因此,CDD的构造函数入参需要做修改,不再是图片索引编号,而是行数和列数。接着根据图片大小和行数、列数,计算单帧图像的宽、高,最后用Crop切割并顺序加入到图像列表中。render方法不需要做修改,可以沿用 SquenceAnimator 的:

init python:

    class SquenceAnimator2(renpy.Displayable):

        def __init__(self, im, row, column, interval, loop=True, **kwargs):
            super(SquenceAnimator2, self).__init__(**kwargs)
            # im入参是字符串,需要转为Image对象,获取尺寸信息
            self.im = Image(im)
            self.size = renpy.image_size(self.im)
            # 行数
            self.row = row
            # 列数
            self.column = column
            # 单帧宽度
            self.frame_width = self.size[0] / column
            # 单帧高度
            self.frame_height = self.size[1] / row
            # 序列帧长度
            self.length = row * column

            self.sequence = []
            # 循环嵌套切割单帧图像
            for i in range(row):
                for j in range(column):
                    self.sequence.append(Crop((self.frame_width*j, self.frame_height*i, self.frame_width, self.frame_height), self.im))

            self.current_index = 0
            self.show_timebase = 0

            self.interval = interval
            self.loop = loop

        #省略render()

可以使用下图测试(知乎直接上传带透明通道的png会转成纯白的jpg,所以加了黑色背景):

测试代码:

image stars2 = SquenceAnimator2("sequence_stars.png", 5, 6, 0.07)

label start:
    show stars2 at truecenter

SquenceAnimator2 还有点缺陷。有些序列图的最后一行可能会有空白帧,也就是说“序列总数 < 行数 × 列数”。这就需要添加一个构造函数入参,指定序列总数。render方法依然不用修改:

    class SquenceAnimator2(renpy.Displayable):

        def __init__(self, im, row, column, interval, seq=0, loop=True, **kwargs):
            super(SquenceAnimator2, self).__init__(**kwargs)
            self.im = Image(im)
            self.size = renpy.image_size(self.im)
            self.row = row
            self.column = column
            self.frame_width = self.size[0] / column
            self.frame_height = self.size[1] / row
            # 如果seq为默认值,则序列总数依然是行数×列数
            if(seq == 0):
                self.length = row * column
            # 根据seq入参决定序列总数
            else:
                self.length = seq

            self.sequence = []
            for i in range(row):
                for j in range(column):
                    # 如果超过序列总数则不再向列表中添加元素
                    if((i * column + j + 1) <= self.length):
                        self.sequence.append(Crop((self.frame_width*j, self.frame_height*i, self.frame_width, self.frame_height), self.im))
                    else:
                        break

            self.current_index = 0
            self.show_timebase = 0

            self.interval = interval
            self.loop = loop
        #省略render()

测试代码:

image stars2 = SquenceAnimator2("sequence_stars.png", 5, 6, 0.07, seq=28)

label start:
    show stars2 at truecenter

至此,设计CDD播放序列帧动画的目标基本实现了。在少数应用场景下,存在图片四周有留白,或者各帧之间还有空隙的情况,我们在SquenceAnimator2的基础上做一点点修改就可以。


补充部分:

在遇到某些经常使用的特效,但引擎实时绘制特别困难的情况,序列帧动画是非常有效的实现方案。需要注意序列帧动画会占用Ren'Py的缓存,不适合尺寸较大的图像。角色和UI等画面元素比较合适。

编辑于 2022-03-18 22:37