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

от автора

Вступление
Думаю многие программисты создали, или пробовали создать свою игру. Обычно процесс доходит до момента, когда основная часть всего написана, и нужно начинать строить уровни, игровые сцены, и т.д. Если использовать готовые решения, «из коробки» — например 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 окошечка плагина, входные параметры данных, типы кнопок, и т.д. На скриншоте ниже показано «моё детище».

Файл 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

ссылка на оригинал статьи http://habrahabr.ru/post/208094/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *