Устройство движка для квестов Trickster Games

Всем привет! В результате недавних трудов у меня появились исходники движка для квестов компании Trickster Games. Выкладывать я их не буду, потому что не знаю насколько он лицензионно чистый. Я не юрист, в таких вопросах не разбираюсь. Поэтому расскажу своими словами — как устроен движок для квестов Trickster Games.

Поколений движков было два — первый использовался для игр Танита. Морское Приключение, Приключения Барона Мюнхгаузена на Луне и использовал lua в качестве скриптового языка.

Танита. Морское приключение и Приключения Барона Мюнхгаузена на Луне

Начиная с игры «Петрович и все, все, все» движок переходит на использование Python 2.xx вместо lua. Я пришёл в Trickster Games, когда начали делать Петрович, так что про предыдущие движки и игры практически ничего не знаю.

Итак, приступим!

Описание редактора игрового движка Trickster Games

Небольшое отступление: движок казался гораздо проще. Однако, он содержит в себе множество разных python классов, буду заново разбираться по ходу написания статьи.

Итак, у нас есть редактор (это первый уровень игры «Петрович и все все все»):

Редактор Trickster Games

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

Почти все объекты наследуются от ItemBase и рисуются относительно координат родителя. При перемещении родительского элемента на сцене, его дети перемещаются вместе с ним. В дереве объектов элементы можно перетаскивать с помощью drag-n-drop или добавлять новые правой кнопкой мыши. У каждого элемента есть замочек и глазик — блокировать перемещение/выделение при клике на сцену и показать/спрятать элемент при отрисовке сцены. Отрисовка элементов идёт по дереву от корневого узла вниз.

Уровень (Location)

Собственно уровень игры, рутовый элемент нашей сцены, содержит параметры width, height — ширина локации, высота локации. Вот её панель свойств:

Свойства локации в игровом редакторе Trickster Games

Может содержать в качестве детей только слои.

Слой (Layer)

Слой локации, имеет параметр parallax — это отставание при прокрутке. С помощью него создаётся эффект глубины при скроллинге локации по горизонтали или вертикали. Вот её панель свойств:
Свойства слоя в игровом редакторе Trickster Games Может содержать в качестве детей статические изображения, регионы, пути, анимированные объекты и точки.

Статическое изображение (LayerImage)

Фоновое изображение, ничего не делает, просто рисуется с соответствующим свойствами (параллакс) слоя. Имеет довольно скудный набор свойств:

Свойства статического изображения в игровом редакторе Trickster Games

Анимированный объект (AnimatedObject)

Можно сказать, что это наш главный элемент, базовый кирпичик нашего движка. У него есть позиция, набор состояний, к нему привязываются звуки, регионы, а также скрипт.

Свойства игрового объекта в игровом редакторе Trickster Games

Он может наследоваться от особенных классов, например, Playable Character — это наш главный герой, который умеет ходить по локации, или Takeable Item — это вещь, которую можно забрать себе в инвентарь. Он может загружаться согласно определённым условиям из скрипта, если поставить флажок «Conditional object loading». Пример условной загрузки элемента «Item_parfume» (условия описываются в родительском элементе):

class FloorLayer:
    def should_load_Item_parfume(self):
        return not world.parfume_taken

Также у него есть pivot — начало его координат и позиция на локации абсолютная и относительная. К нему могут привязываться дочерние AnimatedObject, Region и StateSequence. Содержит список состояний. Представляет собой конечный автомат, который меняет своё состояние в зависимости от внешних условий. Может загружать дочерние элементы типа AnimatedObject по некоторым условиям.

Состояние объекта (AnimatedSequence)

Анимация и состояние AnimatedObject. Анимация неотделима от состояния объекта. Пока AnimatedObject находится в определённом состоянии, проигрывается анимация этого состояния из одного или нескольких кадров. К любому кадру можно привязать автоматическое проигрывание звука.
Свойства состояния объекта в игровом редакторе Trickster Games

У состояния есть свойства on_enter, on_exit, link, on_update — для привязки пользовательских функций. on_enter выполнятся при переходе в это состояние, on_exit — при выходе из него, link — выполняется каждый кадр, и если возвращает строку, то состояние меняется на указанное, on_update вызывается каждым кадр с неким dt (количество миллисекунд между кадрами).

Регион (Region)

Некий замкнутый набор точек. Определяет область для хождения главного героя на сцене (на скриншоте редактора это R_Floor) и области для клика мышкой. Имеет свойство
«.click» или «.click_with_» для проверки клика на этот регион, второе свойство динамическое и проверяется клик с предметом из трея. Предоставляет метод для поиска кратчайшего пути между точками для хождения главного героя. Может иметь несколько типов (не помню, кто за что отвечает) и менять курсор мыши при наведении.
Свойства региона в игровом редакторе Trickster Games

Путь (Path)

Путь состоящий из нескольких точек. Можно привязать элемент к этому пути, и объект будет плавно по нему перемещаться.
Свойства пути в игровом редакторе Trickster Games

Точка (Point)

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

Описание игрового движка Trickster Games

Движок написан на C++, для рендера используется DirectX 9.0, для связки Python 2.xx и C++ используется Boost.Python. Для сборки и компиляции исходников движка применяется (БУДЬТЕ ПРОКЛЯТЫ ЕГО СОЗДАТЕЛИ!) Boost Jam и Visual Studio 2007.

Базовый элемент в игровом движке — это GameObject, который имеет матрицу трансформации себя относительно родителя, список детей типа GameObject, и используется для создания дерева сцены. Простыми словами, для отрисовки дерева сцены вызывается GameObject::update(dt), который последовательно вызывает обновления всех детей, начиная от корня дерева вниз по иерархии. От него торчат уши в Python. Python-часть движка инициализируется следующим кодом:

std::string setup(
"sys.path = [r'Data\\Scripts1.pak', r'Data\\Scripts2.pak', "
                    "r'Data\\Scripts3.pak', r'Data\\Scripts3.pak\\CommonClasses', "
                    "r'Data\\Scripts4.pak', r'Data\\Scripts4.pak\\World', '.', 'Lib/std']\n");
run_string(setup);

Затем вызываем метод on_init из файла Lib/engine.py:

boost::python::call<void>(bp::object(py["Lib"].attr("engine").attr("on_init")).ptr(), long(application::Application::window()));
py_on_frame = py["Lib"].attr("engine").attr("on_frame");

И после делаем on_frame из того же файла каждый кадр:

boost::python::call<void>(py_on_frame.ptr(), dt / 1000.0f, just_redraw, cursor_position, mouse_button_state);

Всё, никакой магии.

Форматы файлов

При подготовке релиза происходит преобразование .py файлов в .pyc, PNG в DDS, и всё это запаковывается в .zip файл.

Анимация

Анимация делалась в Adobe Flash и экспортировалась в набор PNG файлов, которые при запаковке релиза превращаются в DDS — это родной формат для DirectX SDK.

Звуки

Звуки — это .wav файлы

Видео

Для видео использовался формат OGV (Theora Vorbis).

Вроде всё, что мог, описал. Спрашивайте, если что-то осталось непонятным.

Установка Python в режиме portable под Windows

Установка Python

  1. Скачать инсталлятор в формате MSI отсюда: https://python.org/downloads/
  2. Установить полученный дистрибутив <python.msi> в папку <INSTALLDIR> командой:
    msiexec /a <python.msi> /qn TARGETDIR="<INSTALLDIR>"

    , где /a — команда на установку с админскими правами, /qn — не задавать лишних вопросов, TARGETDIR — конечная папка

Установка Python Package Index

  1. Скачать файл getpip.py: https://bootstrap.pypa.io/get-pip.py
  2. Установить pip командой:
    <INSTALLDIR>\python.exe getpip.py
  3. Теперь любые пакеты можно устанавливать командой:
    <INSTALLDIR>\Scripts\pip.exe install <package name>

В итоге у вас есть обособленный Python интерпретатор любой версии с любыми пакетами в комплекте.

Также у вас есть возможность запускать *.py файлы с помощью установленного дистрибутива по клику мыши. Для этого вам понадобится PyLauncher.

Установка и настройка PyLauncher

  1. Скачать и установить pylauncer отсюда: https://bitbucket.org/vinay.sajip/pylauncher/downloads
  2. Открыть/создать файл %LOCALAPPDATA%\py.ini и добавить строку:
    [commands]
    mypython=<INSTALLDIR>\python.exe
  3. Добавить/поменять первую строку запускаемых *.py файлов на такую:
    #!mypython

    Теперь при запуске этих файлов их будет обрабатывать ваш интерпретатор (<INSTALLDIR>\python.exe)

Как это работает?

Первая строчка в формате #!<program name> используется в *nix подобных системах для автоматического выбора интерпретатора во время запуска и называется она, кстати, «shebang». В Windows системах эта строка игнорируется. PyLauncher просто перехватывает обработку *.py файлов и вызывает правильный Python интерпретатор.

Ускорение работы Python

Я рекомендую отключать создание и обновление *.pyc и *.pyo файлов в процессе работы Python. Аргументы могу привести следующие: оперативной памяти сейчас у всех хватает, процессор достаточно быстрый. Но устройство хранения данных (в народе флешка, жёсткий диск, SSD) по-прежнему слабовато.

Поэтому не выпендривайтесь и запускайте python с параметром -B или добавьте параметр PYTHONDONTWRITEBYTECODE в переменную окружения с любым непустым значением. Например, PYTHONDONTWRITEBYTECODE=Non_empty_string.

Формат файлов в папке .git/objects

- А не запилить ли мне свой .git с преферансом и блудницами, — подумал я. И запилил! По крайней мере, малюсенькую часть, а именно папку .git/objects. Формат хранения обычных файлов простой — берем sha1 от данных этого файла, создаем папку из первых двух символов этого хеша, а внутрь заливаем пакованные данные с именем оставшегося хеша.

Вот такой скрипт получился на питоне. Зачем делал — не спрашивайте, не знаю. :)

import os
import hashlib
import zlib
 
# Какие папки сканируем
directories = ( "D:\\resources", )
# Какие файлы учитываем
extensions = ( '.png', '.wav', '.jpg', '.dds' )
 
# Количество повторов
numDuplicates = 0
# Общее количество обработанных файлов
numFiles = 0
 
for directory in directories:
    for root, dirs, files in os.walk(directory):
        for filename in files:
            basename, ext = os.path.splitext(filename)
            full = os.path.join(root, filename)
            # Пропустим неизвестный файл
            if ext not in extensions:
                print 'Skip %s' % full
                continue
 
            # Понеслась!
            print 'Processing %s' % full,
 
            # Возьмем хеш от содержимого файлов
            data = open(full, 'rb').read()
            hash = hashlib.sha1(data).hexdigest()
            print '.. done'
 
            # Название папки - первые два символа от хеша
            prefix = hash[:2]
            # Название файла - остальные символы хеша
            postfix = hash[2:]
 
            # Создадим подпапку в папочке objects
            dirname = os.path.join('objects', prefix)
            if not os.path.isdir(dirname):
                os.makedirs(dirname)
 
            # Выходной файл с данными
            filename = os.path.join(dirname, postfix)
            if not os.path.isfile(filename):
                # Пишем данные, предварительно запаковав их с помощью zlib
                open(filename, 'wb').write(zlib.compress(data))
            else:
                # Такой файл уже есть, найден дубликат, куда смотрел дизайнер?!
                numDuplicates += 1
            numFiles += 1
 
# Краткий отчет
print 'Total: %d, duplicates: %d' % (numFiles, numDuplicates)
Python модуль для создания древовидной структуры даных на лету

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

Пример использования

# -*- coding: utf-8 -*-
import cPickle as pickle
from Dictionary import Dictionary
 
# Создаем пустой объект "animalsDict"
animalsDict = Dictionary()
 
# Добавляем ветку "cats" и ноду "wild" со значением "Tiger"
animalsDict.cats.wild = 'Tiger'
# К ветке "cats" добавляем ноду "home" со значение "House"
animalsDict.cats.home = 'House'
# Добавляем ветку "dogs" одной строкой с нодами "wild" и "home"
animalsDict.dogs = Dictionary(wild='wolf', home='House')
# Выводим данные с префиксом "animals"
print "before dump:"
animalsDict.printf('animals')
 
# Сохраняем объект "animalsDict" в строку
data = pickle.dumps(animalsDict)
# Восстанавливаем данные в объект "animalsRestored"
animalsRestored = pickle.loads(data)
 
# Выводим данные с префиксом "animals2"
print
print "after dump:"
animalsRestored.printf("animals2")
 
# Удаляем ветку cats
del animalsRestored.cats
 
# Повторно выводим данные
print
print "after remove:"
animalsRestored.printf("animals2")
 
# Создаем объект только для чтения
p = Dictionary(read_only=True, x=2, y=3.5, z=1.0)
 
# Выводим объект "p" с префиксом "point"
print
print "read only:"
p.printf("point")
 
# Если это раскомментировать, будет исключение типа KeyError
# del p.x

Вывод

before dump:
animals.cats.home = House
animals.cats.wild = Tiger
animals.dogs.home = House
animals.dogs.wild = wolf
 
after dump:
animals2.cats.home = House
animals2.cats.wild = Tiger
animals2.dogs.home = House
animals2.dogs.wild = wolf
 
after remove:
animals2.dogs.home = House
animals2.dogs.wild = wolf
 
read only:
point.x = 2
point.y = 3.5
point.z = 1.0

Модуль Dictionary.py

Исходники программы на Python

import cPickle as pickle
 
 
class Dictionary:
    def __init__(self, read_only=False, **params):
        self.__dict__['__children__'] = {}
        self.__dict__['__readonly__'] = read_only
        for key, value in params.iteritems():
            self.__dict__['__children__'][key] = value
 
 
    def clear(self):
        for key, value in self.__dict__['__children__'].iteritems():
            if isinstance(value, Dictionary):
                value.clear()
        self.__dict__['__children__'].clear()
 
 
    def printf(self, prefix=None):
        for key in sorted(self.__dict__['__children__'].iterkeys()):
            value = self.__dict__['__children__'][key]
            if value is None:
                continue
            if isinstance(value, Dictionary):
                if prefix:
                    value.printf('%s.%s' % (prefix, key))
                else:
                    value.printf('%s' % key)
            else:
                if prefix:
                    print '%s.%s = %s' % (prefix, key, str(value))
                else:
                    print '%s = %s' % (key, str(value))
 
 
    def __getattr__(self, name):
        try:
            return self.__dict__['__children__'][name]
        except KeyError:
            if not self.__dict__['__readonly__']:
                value = self.__dict__['__children__'][name] = Dictionary()
                return value
            raise
 
 
    def __delattr__(self, name):
        if self.__dict__['__readonly__']:
            raise KeyError, "Read only structure."
        del self.__dict__['__children__'][name]
 
 
    def __setattr__(self, name, value):
        children = self.__dict__['__children__']
        old_value = children.get(name)
 
        if name not in children:
            if self.__dict__['__readonly__']:
                raise KeyError, "Read only structure."
        elif isinstance(old_value, Dictionary):
            if old_value:
                raise AttributeError, "Attribute %s has children: %s" % (repr(name), old_value)
 
        children[name] = value
 
 
    def __getitem__(self, name):
        return self.__getattr__(name)
 
 
    def __setitem__(self, name, value):
        self.__setattr__(name, value)
 
 
    def __delitem__(self, name):
        self.__delattr__(name)
 
 
    def __eq__(self, value):
        if isinstance(value, Dictionary):
            return repr(self) == repr(value)
        if isinstance(value, dict):
            return self.__dict__['__children__'] == value
        return value is None
 
 
    def __ne__(self, value):
        return not self.__eq__(value)
 
 
    def __nonzero__(self):
        return bool(self.__dict__['__children__'])
 
 
    def __str__(self):
        return str(repr(self))
 
 
    def __repr__(self):
        params = [('read_only=%s' % self.__dict__['__readonly__'])]
        for key in sorted(self.__dict__['__children__'].iterkeys()):
            value = self.__dict__['__children__'][key]
            params.append('%s=%s' % (key, repr(value)))
        return 'Dictionary(%s)' % ', '.join(params)
 
 
    def __len__(self):
        return len(self.__dict__['__children__'])
 
 
    def iterkeys(self):
        return self.__dict__['__children__'].iterkeys()
 
 
    def itervalues(self):
        return self.__dict__['__children__'].itervalues()
 
 
    def iteritems(self):
        return self.__dict__['__children__'].iteritems()
 
 
    def __iter__(self):
        return self.__dict__['__children__'].__iter__()
 
 
    def __setstate__(self, state):
        for key, name, value in state:
            if key == 'param':
                self.__dict__[name] = value
            elif key == 'value':
                self.__dict__['__children__'][name] = pickle.loads(value)
 
 
    def __getstate__(self):
        result = []
        result.append(('param', '__readonly__', self.__dict__['__readonly__']))
 
        for name, value in self.__dict__['__children__'].iteritems():
            result.append(('value', name, pickle.dumps(value)))
        return tuple(result)
 
 
    def __getinitargs__(self):
        return ()
 
 
    @staticmethod
    def parse(kwargs):
        kwargs = dict(kwargs)
        params = {}
 
        while kwargs:
            key, value = kwargs.popitem()
            uri = key.split(".")
            params_ptr = params
 
            if len(uri) == 1:
                params[key] = value
                continue
 
            for i in xrange(len(uri)):
                k = uri[i]
                if i == len(uri) - 1:
                    params_ptr[k] = value
                elif k not in params_ptr:
                    params_ptr[k] = {}
                params_ptr = params_ptr[k]
 
        def create(dictionary):
            result = {}
            for key, value in dictionary.iteritems():
                if isinstance(value, dict):
                    result[key] = create(value)
                else:
                    result[key] = value
            return Dictionary(read_only=True, **result)
 
        return create(params)

Создание модуля расширения для Python на языке Си

Простой, а главное — рабочий пример модуля расширения на языке C для питона. Модуль называется example, а реализовывает одну функцию hello, которая вызывается с параметром who и возвращает «Hello %s» % who.

Листинг модуля example.c

Python 2.xx

#include <Python.h>
 
PyObject *hello( PyObject *self, PyObject *args, PyObject *kwargs )
{
    char *who = 0;
    static char *keywords[] = {"who", NULL};
    PyObject *result = 0;
 
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "s", keywords, &who))
    {
        return NULL;
    }
 
    result = PyString_FromString("Hello ");
    PyString_Concat(&result, PyString_FromString(who));
    return result;
}
 
 
static PyMethodDef example_methods[] = 
{
    { "hello", (PyCFunction) hello, METH_KEYWORDS, "hello(who) -- return \"Hello who\"" },
    { NULL, 0, 0, NULL }
};
 
 
PyMODINIT_FUNC initexample()
{
    (void) Py_InitModule("example", example_methods);
 
    if (PyErr_Occurred())
    {
        PyErr_SetString(PyExc_ImportError, "example module init failed");
    }
}

Python 3.xx

#include <Python.h>
 
PyObject *hello( PyObject *self, PyObject *args, PyObject *kwargs )
{
    char *who = 0;
    static char *keywords[] = {"who", NULL};
 
    if (PyArg_ParseTupleAndKeywords(args, kwargs, "s", keywords, &who))
    {
        return PyUnicode_FromFormat("Hello %s", who);
    }
    return NULL;
}
 
 
static PyMethodDef example_methods[] = 
{
    { "hello", (PyCFunction) hello, METH_KEYWORDS, "hello(who) -- return \"Hello who\"" },
    { NULL, 0, 0, NULL }
};
 
 
static struct PyModuleDef example_module = 
{
    PyModuleDef_HEAD_INIT,
    "example",
    NULL,
    -1,
    example_methods
};
 
 
PyMODINIT_FUNC PyInit_example(void)
{
    return PyModule_Create(&example_module);
}

Листинг setup.py

setup.py

from distutils.core import setup
from distutils.extension import Extension
examplemodule = Extension(name="example", sources=['example.c', ])
setup(name="example", ext_modules=[examplemodule])

Компилируем:

python setup.py build

Инсталлируем:

python setup.py install

Выполняем простенький тест:

from example import hello
print hello(who="world!")

Радуемся :)

Hello world!

Полезные ссылки (на английском):

Цикл статей про Boost.Python на хабре:

P.S. По своему опыту могу сказать, что Boost.Python — редкостное барахло, возьмите лучше библиотеку PyCXX.

Блог Евгения Жирнова