Ссылка на репозиторий с кодом.

Если вы когда-нибудь работали с игровым движком Ren'Py, то знаете, что вывод спрайта на экран осуществляется следующим образом:

image character outfit emotion = ...

label ...:
    show character outfit emotion

character outfit emotion это имя заранее объявленного изображения. Попытка вывести спрайт character emotion без outfit, не приведёт к тому, что спрайт окажется голым. Вместо этого движок будет кидаться в нас traceback'ами.

Если принять высокие моральные принципы движка ещё можно, то вот мириться с тем, что для смены эмоции/одежды/аксессуара нам снова нужно прописывать полное имя спрайта, как-то не хочется.

show girl dress smile
player 'Хватит лыбиться.'
show girl sad # это не пройдёт
'Трейсбеки появляются настолько быстро, что никто никогда не узнает, что я - [secret_info].'

Но не стоит отчаиваться, ведь нас выручит layeredimage.

Слоённое изображение

layeredimage это мощный инструмент, который позволят декларативно описать изображение, которое состоит из нескольких слоёв и управляется атрибутами.

# Пример layeredimage

layeredimage girl:
    always "body" 
    group outfit:
        attribute swim
        attribute dress
        attribute sleep
    group emotion:
        attribute smile
        attribute cry
        attribute sad
  • always — изображение которое будет отображаться всегда;

  • group — атрибуты из одной группы, не могут повторяться;

  • attribute — собственно, атрибут.

В чём отличие от ручного определения каждого варианта спрайта? Во первых, в удобстве. Во вторых, layeredimage при отображении через show запоминает атрибуты, что позволяет не прописывать полное имя спрайта заново.

Само собой, у layeredimage есть ещё очень много крутых фич, но они хороши освещены в документации, так что я не буду здесь подробно останавливаться.

Предыдущий пример теперь отработает без ошибок.

show girl dress smile
player 'Хватит лыбиться.'
show girl sad # girl останется в платье и погрустнеет
'Трейсбеки появляются настолько быстро, что никто никогда не узнает, что я - [secret_info].'
'А где трейсбек? О нет, теперь все знаю, что я - [secret_info]!'

Теперь встаёт вопрос, а собственно как это всё работает? Система отображения изображений знает о существовании layeredimage и использует для него отдельную логику? Как бы не так. layeredimage(а также модуль live2d для Ren'Py) использует недокументированную фичу, с помощью которой изображение может само обрабатывать атрибуты.

Что за атрибуты? Атрибуты это всё то, что идёт после тега. В большинстве случаев, тег это первая часть имени изображения. Но также тег может быть задан и во время показа спрайта через show.

show girl smile dress # girl - тег, smile и dress - атрибуты
show girl swim sad as another_girl # another_girl - тег, остальное атрибуты

От слов к делу

И так, что нужно для минимального обработчика атрибутов? Наверное, нужно создать класс, который реализует какой-то интерфейс и реализовывать логику управления атрибутов в нём.

Нет. Это вам не Java или C#. Никаких интерфейсов. Да здравствует утиная типизация!

Утиная типизация

Если что-то выглядит как утка, плавает как утка и крякает как утка, это, вероятно, и есть утка.

Для того, чтобы отобразить изображение используя переданные пользователем атрибутами, достаточно определить у класса метод _duplicate. В него передаётся специальный объект, который как раз и содержит наши атрибуты. Вот так это выглядит.

class SimpleAttributeImage(python_object):
    def __init__(self, *images):
        self.images = images
    
    def _duplicate(self, args):
        attributes = set(args.args)
        return Fixed(
            *(
                renpy.easy.displayable(image_like) 
                for attr, image_like in self.images
                if attr in attributes
            ),
            xfit=True, yfit=True
        )

В методе _duplicate мы взяли атрибуты, а затем отфильтровали слои по ним. Отфильтрованные слои обернули в виджет, который отображает изображения наслаивая их друг на друга.

Использовать это можно так:

image example1 = SimpleAttributeImage(
    ('base', 'images/simple/base.png'),
    ('smile', 'images/simple/smile.png'),
    ('serious', 'images/simple/serious.png'),
    ('normal', 'images/simple/normal.png')
)

label start:
    show example1 base smile as first at left
    show example1 base serious as second at center
    show example1 base normal as third at right
Славяны. Они повсюду
Славяны. Они повсюду

Выглядит классно, но это всё ещё не пригодно к использованию. Доказать это достаточно просто.

show example1 serious as first
show example1 base smile serious normal as third
Я бы тоже злился, если бы от меня остались только глаза и рот
Я бы тоже злился, если бы от меня остались только глаза и рот

Тело пропало, а эмоции наслаиваются друг на друга.
Первая проблема вызвана тем, что по умолчанию, Ren'Py не запоминает установленные в прошлом атрибуты.
Вторая проблема является следствием того, что мы никак не фильтруем атрибуты перед показом.

И так, давайте напишем фикс для первой проблемы.

class SimpleAttributeImageFix1(python_object):
    def __init__(self, *images):
        self.images = images
    
    def _duplicate(self, args):
        attributes = set(args.args)
        return Fixed(
            *(
                renpy.easy.displayable(image_like) 
                for attr, image_like in self.images
                if attr in attributes
            ),
            xfit=True, yfit=True
        )

    def _choose_attributes(self, tag, required, optional):
        return tuple(required) + tuple(optional or [])

Отличие от прошлой реализации одно - появился метод _choose_attributes. Метод _choose_attributes вызывается с новыми атрибутами(required) и атрибутами которые были до вызова(optional).

Наша реализация просто складывает эти атрибуты, что решает проблему пропавшего тела.

Проверим.

scene
show example2 base smile
'Улыбается.'
show example2 serious
'№#&@$@$Y@#@#@?'
Ситуация с бровями осталась прежней
Ситуация с бровями осталась прежней

Хотя первая проблема решена, но вот вторая никуда не делась. Для её решения, придётся изменить структуру объявления изображения. Теперь каждый слой будет содержать несколько вариантов изображения связанным с атрибутом.

class SimpleAttributeImageFix2:
    def __init__(self, *layers):
        self.layers = layers

    def _duplicate(self, args):
        selected = set(args.args)
        fixed = Fixed(xfit=True, yfit=True)
        for layer in self.layers:
            for attr, image_like in layer:
                if attr in selected:
                    fixed.add(renpy.easy.displayable(image_like))
                    break
        
        return fixed

    def _choose_attributes(self, tag, required, optional):
        required_set = set(required)
        optional_set = set(optional) if optional is not None else set()
        conflicts = []

        def process_layer(layer):
            layer_selected = [
                attr for attr, _ in layer if attr in required_set
            ]
            if len(layer_selected) > 1:
                conflicts.extend(layer_selected)
            elif len(layer_selected) == 1:
                for attr, _ in layer:
                    optional_set.discard(attr)

        for layer in self.layers:
            process_layer(layer)
        
        if conflicts:
            raise Exception('Attribute conflict: {0}'.format(conflicts))

        return tuple(required_set | optional_set)

Кода много, но мы попытаемся разобраться, что происходит.

Метод _choose_attributes теперь проходит по слоям и выясняет, какие из вариантов изображения в каждом слое активны.

  • Если активны несколько вариантов, то это означает, что произошёл конфликт атрибутов и нам необходимо выбросить исключения.

  • Если активен только один вариант, то исключаем из существовавших ранее атрибутов все атрибуты слоя. Это гарантирует нам то, что для одного слоя будет использовано одно изображение.

В то же время, логика метода _duplicate меняется не сильно. Проходим по всем слоям и выбираем первый вариант изображения используя атрибуты. В этом методе конфликтов быть не может, так как мы их уже успешно решили в методе _choose_attributes.

Настало время тестирования.

image example3 = SimpleAttributeImageFix2(
    [('base', 'images/simple/base.png')], # каждый слой это набор элементов (attribute, image)
    [('smile', 'images/simple/smile.png'), ('serious', 'images/simple/serious.png'), ('normal', 'images/simple/normal.png')],
    [('pioneer', 'images/simple/pioneer.png'), ('dress', 'images/simple/dress.png')]
)

label start:
    scene
    show example3 base normal
    'База.'
    show example3 dress
    'Смена наряда.'
    show example3 smile
    'Смена эмоции.'
    python:
        try:
            renpy.show('example3 dress pioneer')
        except Exception as e:
            renpy.say(None, '[e!q]', what_color='#a00')
Пускай конфликты будут только между атрибутами
Пускай конфликты будут только между атрибутами

Все работает, как и ожидалось. Все атрибуты использованные ранее остаются на месте, если они не конфликтуют между собой. Конфликт новых атрибутов выбрасывает исключение.

Конечно, текущая реализация очень далека от универсальной. Например, давайте попробуем добавить для нашей Славяны новую позу.

image example4 = SimpleAttributeImageFix2(
    [('base1', 'images/simple/base.png'), ('base2', 'images/simple/base2.png')],
    [('smile', 'images/simple/smile.png'), ('serious', 'images/simple/serious.png'), ('normal', 'images/simple/normal.png'), ('happy', 'images/simple/happy.png')],
    [('pioneer', 'images/simple/pioneer.png'), ('dress', 'images/simple/dress.png')]
)

label start:
    scene
    show example4 base2 happy
    'Пока всё нормально.'
    show example4 base1 dress
    'Сукуна пытается отобрать тело у Слави?'
Славяне не здоровится
Славяне не здоровится

Чудо не случилось. Эмоции и позы чётко соотносятся друг с другом и изменив позу, эмоция остаётся прежней.

Написать универсальное решение, конечно, можно, но на это уйдёт довольно много времени. Вместо этого, давайте напишем реализацию конкретно для спрайта Слави, а вернее для всех спрайтов из игры "Бесконечное Лето".

И так, каждая части спрайта персонажа делятся на 4 типа:

  • Тело(body)

  • Одежда(pioneer, dress, swim)

  • Эмоция(smile, cry, sad)

  • Аксессуар(panama, glasses)

Также, каждая часть привязана к позе. Поза определяется при помощи эмоции.

Формат имени файла для каждой части имеет следующий вид - {tag}_{body_number}_{image}.png, где:

  • tag - тег персонажа

  • body_number - номер позы

  • image - имя изображения(body, pioneer, smile, glasses)

Файлы для спрайта Славяны
Файлы для спрайта Славяны

Используя всю вышеперечисленную информацию, мы можем перейти к реализации.

class ESSprite(python_object):
    def __init__(self, template, emotions, outfits):
        """
        :param str template: Шаблон вида "path/to/char/char_{body_number}_{image}.png"
        :emotions list[list[str]]: Двумерный список с набором атрибутов. Из этого списка будет составлена карта для определения номера позы
        :outfits list[str]: Список доступных нарядов
        """
        self.template = template
        self.emotion_to_body_index = {
            emotion: i for i, emotion_layer in enumerate(emotions) for emotion in emotion_layer
        }
        self.outfits = set(outfits)
    
    def _duplicate(self, args):
        emotion, body_index = None, 0
        outfit = None
        for attr in args.args:
            # Если атрибут эмоция, то находим индекс позы и запоминаем эмоцию
            if attr in self.emotion_to_body_index:
                emotion = attr
                body_index = self.emotion_to_body_index[emotion]
            # Запоминаем наряд
            elif attr in self.outfits:
                outfit = attr

        images = []
        
        def format(image):
            return self.template.format(image=image, body_number=body_index + 1)
        
        def add(image):
            if image is None:
                return
            images.append(format(image))
        
        add('body')
        add(outfit)
        add(emotion)

        return Fixed(*images, xfit=True, yfit=True)

    def _choose_attributes(self, tag, required, optional):
        optional = list(optional) if optional else []

        outfit = None
        emotion = None
        conflicts = set()
        for attr in required:
            if attr in self.emotion_to_body_index:
                # Если эмоция уже определена, то значит у нас конфликт атрибутов
                if emotion:
                    conflicts.add(emotion)
                    conflicts.add(attr)
                emotion = attr
            elif attr in self.outfits:
                # Если наряд уже определён, то значит у нас конфликт атрибутов
                if outfit:
                    conflicts.add(outfit)
                    conflicts.add(attr)
                outfit = attr
        
        if conflicts:
            raise Exception('Attribute conflict: %s' % conflicts)
        
        # Выкидываем уже существующие атрибуты, если они конфликтуют с новыми
        if emotion:
            optional = [attr for attr in optional if attr not in self.emotion_to_body_index]
        if outfit:
            optional = [attr for attr in optional if attr not in self.outfits]
        
        return tuple(required) + tuple(optional)

Кода много, но он достаточно примитивный. Все неоднозначные моменты, я постарался осветить в комментариях и документации к методам.

image example5 = ESSprite(
    template='images/sprites/sl/sl_{body_number}_{image}.png',
    emotions=[
        ['normal', 'serious', 'smile'],
        ['happy', 'laugh', 'smile2', 'shy'],
        ['angry', 'sad', 'surprise'],
        ['scared', 'tender']
    ],
    outfits=['swim', 'dress', 'pioneer', 'sport']
)

label start:
    scene
    show example5 dress smile
    'dress smile'
    show example5 laugh
    'dress laugh'
    show example5 sport
    'sport laugh' 
    show example5 -sport
    'laugh' 
    show example5 -laugh
    ''
    python:
        try:
            renpy.show('example5 dress pioneer sport smile laugh happy tender')
        except Exception as e:
            renpy.say(None, '[e!q]', what_color='#a00')

Здоровье в порядке - спасибо зарядке
Здоровье в порядке - спасибо зарядке

Результат радует. Поза меняется в зависимости от эмоции. Старые атрибуты сохраняются и заменяются новыми при конфликте. Конфликт новых атрибутов приводит к ошибке.

Заключение

Я постарался рассказать в общих чертах об устройстве того, как Ren'Py работает с атрибутами. Некоторые вещи были мной опущены, так они не предоставляют большой ценности(в частности, речь идёт об методе _list_attributes.

Вся информация для статьи была почерпана из исходников Ren'Py, которые находятся в открытом доступе. Стоит помнить, что _duplicate и _choose_attributes не являются документированными фичами, что наводит мысль о том, что эти вещи вполне могут измениться в будущих релизах.

Ссылка на репозиторий с кодом.

Ren'Py | СНГ — русскоязычное сообщество Ren'Py в Discord.

Комментарии (2)


  1. Richday
    22.11.2023 04:49

    Спасибо за статью! Этот материал будет полезен всем новичкам и тем, кто еще не работал с layeredimage, но хочет научится. Продолжай в том же духе. Будем ждать новые качественные статьи по Ren'Py!


  1. ZeroCold1981
    22.11.2023 04:49

    Спасибо, полезно.