Делаем простой редактор уровней на базе плагина к Inkscape

в 0:59, , рубрики: Без рубрики

Вступление
Думаю многие программисты создали, или пробовали создать свою игру. Обычно процесс доходит до момента, когда основная часть всего написана, и нужно начинать строить уровни, игровые сцены, и т.д. Если использовать готовые решения, «из коробки» — например Unity, тогда проблем не возникает. Но могут появиться проблемы с лицензированием, поддержкой разных платформ — может кто-то хочет попробовать что-то поделать под Linux / Mac, где не всегда можно найти нужное решение. Да и начинающим игроделам интересней использовать что-то своё, лёгкое в разработке и наращивании функционала, адаптированное под себя. Для себя я нашёл решение в виде написания собственного небольшого плагина к Inkscape.
В свободное время мне интересно поковыряться в своей же библиотеке написанной на AS3 — да, да, флеш :D. Библиотека существует в виде обёртки на физический движок Box2D, использует кучу всего полезного — собственную state-machine, небольшие обёртки на твиннеры для программной анимации и систему частиц. В принципе, что-то играбельное, небольшое и на свой вкус стильное сделать можно. Так как я люблю OpenSource и удобство, то программирую во FlashDevelop. Естественно, что редактора графики там нет. Да и он вряд ли бы сильно помог в создании объектов со собственными параметрами. Вспомнил за Inkscape, его модульность, плагины, и за сам SVG — собственно XML, лёгок для парсинга. Решил писать плагин для Inkscape.

Проблемы
Начав искать информацию «по поводу», нашёл очень мало, один целый пример «Hello World plugin», а всё остальное плохо структурировано, и на английском. И python в качестве скрипт-языка для плагинов. Функциональный и не типизированный, ужас. Можно вроде писать плагины на руби, не пробовал. Но разобрав пример и посмотрев готовые модули, установленные вместе с Inkscape, понял что не всё так плохо. Нужно просто найти правильные методы работы со слоями и фигурами, определить что именно хочешь сделать, и запрограммировать. Далее уже в приложении парсим готовый SVG / XML — благо все языки имеют отличные инструменты для таких целей, подаём куда нужно — у меня специальный конструктор, и готово.

Структура готового SVG
Я решил каждый уровень задавать как отдельный слой в SVG, это важно продумать при создании плагина. Объекты физ. мира могут иметь форму круглую, квадратную и комплексную (выпуклый многоугольник или же несколько любых многоугольников через несколько шейпов) — требования Box2D. И естественно кучу параметров — как и физических, так и своих. Объекты идут как обычные для SVG, нормально отображаются в редакторе и имеют кучу кастомных тегов и параметров. Удобно для выделения разных типов тел — динамических, статических, подсвечивать их разным цветом. Пока только реализовал поддержку круглых тел и квадратных.
Важно: при операциях в Inkscape по перемещению и вращению в конечном SVG не изменяются параметры тел напрямую. Всё идёт посредством матриц трансформаций matrix для вращения и свойства translate для перемещения тела. Естественно уже при парсинге таких данных нужно применить немножко матричной математики.

Структура плагина
Плагин в Inkscape состоит из двух частей, двух файлов — например my_super_plugin.py и my_super_plugin.inx. Файл my_super_plugin.inx существует в виде набора специальных XML — тегов, что-то похожее на бины в Java. Он задаёт GUI окошечка плагина, входные параметры данных, типы кнопок, и т.д. На скриншоте ниже показано «моё детище».

Делаем простой редактор уровней на базе плагина к Inkscape

Файл my_super_plugin.py задаёт собственно сам скрипт работы с SVG — файлом. Скрипт берёт его на вход, делает нужные действия и подаёт на выход, Inkscape всё отрисовывает. Быстро и красиво. Насколько я понял, в скрипте код и Inkscape связаны через модуль inkex.py. На официальных страницах документации к редактору объяснены нужные типы данных для my_super_plugin.py и my_super_plugin.inx (ссылки внизу).

INX
Выкладываю код моего .inx файла:

<inkscape-extension>
	<_name>PF Editor</_name>
	<id>org.pf.inkscape.plugins.pf_plugin</id>
	<dependency type="executable" location="extensions">pf_plugin.py</dependency>
	<dependency type="executable" location="extensions">inkex.py</dependency>
	<param name="layer_name" type="string" _gui_text="Layer name">Game objects</param>
	<param name="obj_name" type="string" _gui_text="Object name">Object1</param>
	<param name="obj_width" type="int" _gui-text="Width" min="10" max="12000">30</param>
	<param name="obj_height" type="int" _gui-text="Height" min="10" max="12000">30</param>
	<param name="obj_radius" type="int" _gui-text="Radius" min="10" max="12000">30</param>
	<param name="obj_posX" type="int" _gui-text="PosX" min="0" max="12000">30</param>
	<param name="obj_posY" type="int" _gui-text="PosY" min="0" max="12000">30</param>
	<param name="obj_density" type="float" _gui-text="Density" min="0" max="1">0.5</param>
	<param name="obj_friction" type="float" _gui-text="Friction" min="0" max="1">0.5</param>
	<param name="obj_restitution" type="float" _gui-text="Restitution" min="0" max="1">0.5</param>
	<param name="obj_isSensor" type="boolean" _gui-text="Sensor body">false</param>
	<param name="obj_isRotable" type="boolean" _gui-text="Rotable body">false</param>
	<param name="obj_type" type="enum" _gui-text="Object type">
		<_item value="SQUARE">Square</_item>
		<_item value="CIRCLE">Circle</_item>
	</param>
	<param name="obj_d_type" type="enum" _gui-text="Static/Dynamic">
		<_item value="STATIC">Static</_item>
		<_item value="DYNAMIC">Dynamic</_item>
	</param>
	<param name="obj_hasImage" type="boolean" _gui-text="Has image">false</param>
	<effect>
		<object-type>all</object-type>
		<effects-menu>
			<submenu _name="PF Plugins"/>
		</effects-menu>
	</effect>
	<script>
		<command reldir="extensions" interpreter="python">pf_plugin.py</command>
	</script>
</inkscape-extension>

Думаю тут всё понятно. Строки <dependency type="executable" location="extensions">pf_plugin.py</dependency>, <dependency type="executable" location="extensions">inkex.py</dependency> задают зависимости для модуля — собственно, что будет подгружаться. Тег <param name="obj_d_type" type="enum" _gui-text="Static/Dynamic"> имеет внутри свойство enum — внешне задаётся выпадающий список. При нажатии на ОК все параметры идут на вход к питоновскому скрипту, текущее значение на выпадающем списке тоже есть параметром. Значение param name должно совпадать с параметрами, объявленными как входящие в питоновском скрипте. Ах да, визуально в плагине можно создавать вкладки — попробовал, мне не подошло.

PY
Теперь я покажу свой скрипт, который делает всю работу по наполнению тегами файла с уровнями:

import sys
sys.path.append('/usr/share/inkscape/extensions')
import inkex

class PFEditor(inkex.Effect):
    def __init__(self):
        inkex.Effect.__init__(self)

        self.OptionParser.add_option('--layer_name', action='store',
          type='string', dest='layer_name', default='Game objects',
          help='Layer name which objects append to')
        
        self.OptionParser.add_option('--obj_name', action='store',
          type='string', dest='obj_name', default='Object',
          help='Object name')
        
        self.OptionParser.add_option('--obj_width', action='store',
          type='int', dest='obj_width', default=30,
          help='Object width')

        self.OptionParser.add_option('--obj_height', action='store',
          type='int', dest='obj_height', default=30,
          help='Object height')
        
        self.OptionParser.add_option('--obj_radius', action='store',
          type='int', dest='obj_radius', default=30,
          help='Object radius')
        
        self.OptionParser.add_option('--obj_posX', action='store',
          type='int', dest='obj_posX', default=30,
          help='PosX')

        self.OptionParser.add_option('--obj_posY', action='store',
          type='int', dest='obj_posY', default=30,
          help='PosY')
        
        self.OptionParser.add_option('--obj_type', action='store',
          type='string', dest='obj_type', default='SQUARE',
          help='Object type')
        
        self.OptionParser.add_option('--obj_d_type', action='store',
          type='string', dest='obj_d_type', default='STATIC',
          help='Static/Dynamic')
        
        self.OptionParser.add_option('--obj_density', action='store',
          type='float', dest='obj_density', default=0.5,
          help='Density')
        
        self.OptionParser.add_option('--obj_friction', action='store',
          type='float', dest='obj_friction', default=0.5,
          help='Friction')
        
        self.OptionParser.add_option('--obj_restitution', action='store',
          type='float', dest='obj_restitution', default=0.5,
          help='Restitution')
        
        self.OptionParser.add_option('--obj_isSensor', action='store',
          type='inkbool', dest='obj_isSensor', default=False,
          help='Sensor body')
        
        self.OptionParser.add_option('--obj_isRotable', action='store',
          type='inkbool', dest='obj_isRotable', default=True,
          help='Rotable body')
        
        self.OptionParser.add_option('--obj_hasImage', action='store',
          type='inkbool', dest='obj_hasImage', default=False,
          help='Body has image')

    def pfbTypes(self, x):
        return {
            'STATIC'   : '#00ff00',
            'DYNAMIC'  : '#ff0000',
            'SQUARE'   : 'SQUARE',
            'CIRCLE'   : 'CIRCLE' }.get(x, 0)
            
    def pfbType_SVG(self, x):
        return {
            'SQUARE'   : 'rect',
            'CIRCLE'   : 'circle' }.get(x, 'rect')
    
    def concat_style(self, style):  # @NoSelf
        style_str = ''
        
        for stl in style:
            style_str += stl + ':' + style[stl] + ';'
        style_str = style_str[:-1]
        
        return style_str

    def generate_object(self, w, h, r, x, y, density, friction, restitution, isSensor, isRotable, parent, type, d_type, name, hasImage):  # @NoSelf
        
        style = {        
        'fill'            : self.pfbTypes(d_type),
        'fill-rule'       :'evenodd',
        'stroke'          :'000000',
        'stroke-width'    :'0px',
        'stroke-linecap'  :'butt',
        'stroke-linejoin' :'miter',
        'stroke-opacity'  :'0'
        }
        
        attribs = {
        'type'            : type,
        'd_type'          : d_type,
        'height'          : str(h),
        'width'           : str(w),
        'density'         : str(density),
        'friction'        : str(friction),
        'restitution'     : str(restitution),
        'isSensor'        : str(isSensor).lower(),
        'isRotable'       : str(isRotable).lower(),
        'hasImage'        : str(hasImage).lower(),
        'name'            : name,
        'style'           : self.concat_style(style),
        }
        
        if d_type == 'DYNAMIC':
            attribs['isDynamic'] = 'true'
        else:
           attribs['isDynamic'] = 'false'
        
        if type == 'SQUARE' :
            attribs['x'] = str(x);
            attribs['y'] = str(y);
            
        if type == 'CIRCLE' :
            attribs['cx'] = str(x);
            attribs['cy'] = str(y);
            attribs['r'] = str(r);
            
        obj = inkex.etree.SubElement(parent, inkex.addNS(self.pfbType_SVG(type), 'svg'), attribs)
    
    def effect(self) :
        
        layer_name = self.options.layer_name
        obj_name = self.options.obj_name
        obj_width = self.options.obj_width
        obj_height = self.options.obj_height
        obj_radius = self.options.obj_radius
        obj_posX = self.options.obj_posX
        obj_posY = self.options.obj_posY
        obj_type = self.options.obj_type
        obj_d_type = self.options.obj_d_type
        obj_density = self.options.obj_density
        obj_friction = self.options.obj_friction
        obj_restitution = self.options.obj_restitution
        obj_isSensor = self.options.obj_isSensor
        obj_isRotable = self.options.obj_isRotable
        obj_hasImage = self.options.obj_hasImage
        
        svg = self.document.getroot()
      
        d_root = self.document.getroot()
        layer = None
        iter = 0
        for item in d_root:
            if (item.attrib.get('id') == 'pf_go_id' and item.attrib.get('level_name') == layer_name):
                layer = item
                iter += 1
                break
        
        if(iter == 0):
            layer = inkex.etree.SubElement(svg, 'g')
            layer.set(inkex.addNS('id'), 'pf_go_id')
            layer.set(inkex.addNS('level_name'), layer_name)
            layer.set(inkex.addNS('label', 'inkscape'), layer_name)
            layer.set(inkex.addNS('groupmode', 'inkscape'), 'layer')
        
        self.generate_object(obj_width, obj_height, obj_radius, obj_posX, obj_posY, obj_density, obj_friction, obj_restitution, obj_isSensor, obj_isRotable, layer, obj_type, obj_d_type, obj_name, obj_hasImage)
           
effect = PFEditor()
effect.affect()

Строки вида self.OptionParser.add_option('--layer_name', action='store',
type='string', dest='layer_name', default='Game objects',
help='Layer name which objects append to')
задают входящие параметры, их тип (имена параметров с именами из .inx совпадают). Далее в функции effect данные, входные для скрипта, запихиваются в переменные. Потом я ищу что-то типа for item in d_root:
if (item.attrib.get('id') == 'pf_go_id' and item.attrib.get('level_name') == layer_name)
: в SVG слои имеют свойство id, и туда я запихиваю именно 'pf_go_id', для простоты идентификации «своих слоёв» с уровнями. Если слой уже существует, мы будем добавлять новые объекты в него, если нет — создаём новый слой, «уровень», и работаем с ним. Следующие строки создают слой, и уже внутри функции generate_object я создаю объекты. Думаю, что там всё понятно.

SVG
И, собственно, пример сгенерированного SVG файла:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->

<svg
   xmlns:dc="http://purl.org/dc/elements/1.1/"
   xmlns:cc="http://creativecommons.org/ns#"
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns:svg="http://www.w3.org/2000/svg"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   width="630"
   height="480"
   id="svg2"
   version="1.1"
   inkscape:version="0.48.4 r9939"
   sodipodi:docname="levels_tmp.svg">
  <sodipodi:namedview
     id="base"
     pagecolor="#ffffff"
     bordercolor="#666666"
     borderopacity="1.0"
     inkscape:pageopacity="0.0"
     inkscape:pageshadow="2"
     inkscape:zoom="0.98994949"
     inkscape:cx="60.920287"
     inkscape:cy="223.06442"
     inkscape:document-units="px"
     inkscape:current-layer="pf_go_id"
     showgrid="false"
     inkscape:window-width="1366"
     inkscape:window-height="716"
     inkscape:window-x="-8"
     inkscape:window-y="-8"
     inkscape:window-maximized="1" />
  <defs
     id="defs4" />
  <metadata
     id="metadata7">
    <rdf:RDF>
      <cc:Work
         rdf:about="">
        <dc:format>image/svg+xml</dc:format>
        <dc:type
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
        <dc:title />
      </cc:Work>
    </rdf:RDF>
  </metadata>
  <g
     id="pf_go_id"
     level_name="Menu"
     inkscape:label="Menu"
     inkscape:groupmode="layer"
     style="display:inline">
    <circle
       transform="translate(194.95944,151.52288)"
       sodipodi:ry="40"
       sodipodi:rx="40"
       sodipodi:cy="40"
       sodipodi:cx="40"
       isSensor="false"
       isRotable="true"
       height="10"
       cy="40"
       cx="40"
       friction="0.5"
       restitution="0.5"
       style="fill:#00ff00;fill-rule:evenodd;stroke-width:0px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0"
       name="Object1"
       density="0.5"
       isDynamic="false"
       width="100"
       r="40"
       type="CIRCLE"
       d_type="STATIC"
       hasImage="false"
       id="circle3294" />
    <rect
       id="rect2997"
       hasImage="false"
       d_type="DYNAMIC"
       type="SQUARE"
       x="4.7976952"
       y="405.67188"
       width="100"
       isDynamic="true"
       density="0.5"
       name="Object2"
       style="fill:#ff0000;fill-rule:evenodd;stroke-width:0px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0"
       restitution="0.5"
       friction="0.5"
       height="10"
       isRotable="true"
       isSensor="false"
       transform="matrix(0.88912747,-0.45765964,0.45765964,0.88912747,0,0)" />
    <rect
       isSensor="false"
       isRotable="true"
       height="10"
       friction="0.5"
       restitution="0.5"
       style="fill:#00ff00;fill-rule:evenodd;stroke-width:0px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:0"
       name="Object2"
       density="0.5"
       isDynamic="false"
       width="300"
       y="391.62952"
       x="173.53809"
       type="SQUARE"
       d_type="STATIC"
       hasImage="false"
       id="rect3011" />
  </g>
  <g
     inkscape:groupmode="layer"
     inkscape:label="Level_1"
     level_name="Level_1"
     id="g3027">
    <circle
       id="circle3029"
       hasImage="false"
       d_type="DYNAMIC"
       type="CIRCLE"
       r="30"
       width="300"
       isDynamic="true"
       density="0.5"
       name="Object1"
       style="stroke-linejoin:miter;stroke-opacity:0;fill-rule:evenodd;stroke:000000;stroke-linecap:butt;stroke-width:0px;fill:#ff0000"
       restitution="0.5"
       friction="0.5"
       cx="120"
       cy="130"
       height="10"
       isRotable="true"
       isSensor="false" />
  </g>
</svg>

Если загрузить в редактор, увидим несколько объектов и два слоя — уровни Menu и Level_1. Зелёные фигуры будут неподвижными, красные подвижными. У меня есть параметр тел isSensor, я его не выделял цветом, хотя можно визуально добавить прозрачность. На слое Menu прямоугольники повёрнуты и передвинуты — поэтому появились свойства matrix и translate внутри тегов rect. Как известно, они отвечают за поворот (и не только), и за перемещение соответственно. Уже в целевом приложении всё считываем и обрабатываем. Пишем классы для матриц и решаем матричное уравнение вида Ax=B (:D). Оттуда достаём настоящие координаты тел и угол поворота. Если будет интересно — расскажу как такое сделать на AS3, так как сейчас пост вышел довольно большим.

Ссылки
Поискав в интернете, можно полностью самому разобраться что и куда. Ещё обязательно нужно посмотреть разницу между системами координат приложения, для которого пишем редактор уровней и Inkscape — оси, центры объектов, углы вращения. Ключевые ссылки:
wiki.inkscape.org/wiki/index.php/Script_extensions
wiki.inkscape.org/wiki/index.php/PythonEffectTutorial
wiki.inkscape.org/wiki/index.php/Generating_objects_from_extensions
wiki.inkscape.org/wiki/index.php/INX_extension_descriptor_format
docs.python.org/2/library/xml.etree.elementtree.html
wiki.inkscape.org/wiki/index.php/INX_Parameters
www.w3schools.com/svg/svg_rect.asp
developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform

Автор: zeksa

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js