Сегодня я начну разговор о очень обширной и интересной теме - о метапрограммировании (у этого термина есть синоним - отражение, давайте будем использовать его, как более краткий). Так как тема большая, я не могу описать все в одной статье. Их будет несколько, и все они будут направлены на решение одной проблемы - проблемы описания данных в мейкере. Начнем со скиллов, возможно добавим туда еще врагов. Во время разработки своей боевой системы (которая пока еще совсем не готова), я понял, насколько же мне неудобно создавать навыки через интерфейс мейкера. Одни и те же действия мне приходилось делать по несколько раз, иногда для каждого навыка (а это меня просто бесит). Так же я успел добавить кучу кастомных методов, которых нельзя поменять из интерфейса базы данных. Я бы применил известный подход описания этих данных в графе notes, но в мейкерском руби нет YAML, нет JSON, нет XML, в общем пришлось бы самому писать парсер. А еще я бы хотел чтобы эти скиллы можно было легко передавать между проектами (я понимаю, что это можно сделать несколькими путями). В итоге я написал свой DSL для навыков, и на этом примере очень удобно будет рассмотреть многие аспекты отражения.
Эта статья будет вводной, дальше я буду привязывать статьи к этому оглавлению (оно будет расти).
1 Введение
1.1 Что же такое DSL
1.2 Что нужно знать и что понадобится перед началом работы
2 Исследование
2.1 RPG::Skill
2.2 Работа с instance переменными
3. SkillDsl и SkillBuilder
1.1 Что же такое DSL
Для начала разберемся, что же мы будем писать - что же такое DSL? Вот пример того, что у нас в итоге получится (у меня уже есть на руках рабочий код (который впрочем я меняю чуть ли не каждый день), иначе я бы наверное и не взялся бы за статьи
)
SkillDsl.define 'archer' do
skill 'Shot' do
tp_gain 5
id 147
shared 'shot' do
message 'shoots'
hit_type physical
shared 'range'
scope enemy
required_weapon bow
required_weapon gun
damage do
critical true
type to_hp
element common
formula 'a.atk * 2 + a.agi * 2 - b.def * 2'
end
end
end
skill 'Armor-piercing Shot' do
id 149
tp_cost 10
shared 'shot'
damage do
formula 'a.atk * 2 + a.agi * 2'
end
end
end
Это - DSL (Domain Specific Language) , и для начала нужно понять, что это
валидный руби код. Никакой магии тут нет. Тут все те же методы, блоки, классы и т.п. DSL предназначен для упрощения написания кода в какой-то специфической области (именно поэтому и Domain Specific). В нашем случае - для упрощения описания скилов. C хорошо написанным и документированным DSL может работать человек, далекий от руби. В принципе в этом может быть одна из целей написания DSL.
1.2 Что нужно знать и что понадобится перед началом работы
a) Нам понадобится
дебаггер, теоретически можно и без него, но только теоретически.
б) Прочитайте
статью о массивах
в) Так же обязательно прочитайте
статью о блоках, их будет много, они будут разными
г) Обязательно понимание ООП (про это я пока не писал, но уже планирую). Я не буду отвлекаться на объяснения таких понятий как “объект”, “класс” или “instance переменная”. Иначе это растянется на год.
2 Исследование
Для начала нужно разобраться в чем проблема - мы хотим создавать при помощи нашей DSL объекты навыков. Начнем попорядку, для начала посмотрим что же из себя представляют эти самые навыки.
2.1 RPG::Skill
Сделаем новый проект, в нем сделаем папку lib, в ней будет жить наш код.
Скачаем с гитхаба код дебаггера и распакуем его в эту папку. Переименуем папку rpg_maker_debugger-master в просто debugger, нам будет так проще. Нам нужно подгрузить скрипты дебаггера, для этого используем загрузчик (и заодно еще раз скажем спасибо Эльфу). Добавьте этот скрипт в скрипты проекта.
class RequireLoader
module ToInclude
def require(path)
super
rescue Exception => e
RequireLoader.new(path).load
rescue
raise e
end
end
class << self
attr_accessor :binding
def pathes
@pathes ||= []
end
def enabled?
Dir.pwd.encode 'utf-8'
false
rescue Encoding::UndefinedConversionError
true
end
end
def initialize(path)
@path = path.sub(/\.rb\z/, '')
end
def load
File.open founded_path do |file|
eval file.lines.to_a.join, self.class.binding
end
end
def founded_path
all_pathes.find { |file_name| File.exist? file_name } || raise(LoadError)
end
def all_pathes
["#{@path}.rb"] + self.class.pathes.map do |path|
File.join path, "#{@path}.rb"
end
end
end
if RequireLoader.enabled?
include RequireLoader::ToInclude
RequireLoader.binding = binding
end
class SideScriptsLoader
class << self
def load(dir)
new(dir).load
end
def add_to_path(dir)
new(dir).add_to_path
end
def add_gems_to_path
if Dir.exist? 'gems'
Dir.entries('gems').each do |entry|
if Dir.exist? File.join('gems', entry)
new(File.join 'gems', entry, 'lib').add_to_path
end
end
end
end
end
def initialize(dir)
@dir = dir
end
def dirname
RequireLoader.enabled? ? @dir : File.expand_path(@dir, Dir.pwd)
end
def load
load_entries if Dir.exist? dirname
end
def add_to_path
if RequireLoader.enabled?
RequireLoader.pathes
else
$LOAD_PATH
end << dirname if Dir.exist? dirname
end
private
def load_entries
Dir.entries(dirname).each do |entry|
require filname(entry) if entry =~ /\.rb\Z/
end
end
def filname(entry)
if RequireLoader.enabled?
entry
else
File.expand_path File.join(dirname, entry), Dir.pwd
end
end
end
SideScriptsLoader.add_to_path 'lib/debugger'
SideScriptsLoader.load 'lib/debugger'
Запускаем наш проект (вместе с консолью), нажимаем прямо на фоне меню F5. Если все ок - активируется наша консоль.
Мейкер хранит все скиллы в массиве $data_skills. Набираем в консоли
> $data_skills #Первый элемент этого массива пустой, остальные - объекты скиллов
Сперва нам нужно узнать класс скиллов
> $data_skills[1].class
=> RPG::Skill
Ясно, наш DSL будет работать с этим классом. Давайте взглянем на публичные методы скилла
> $data_skills[1].public_methods
На приватные
> $data_skills[1].private_methods
На protected
> $data_skills[1].protected_methods
Так как в руби классы - тоже объекты, можно посмотреть на методы класса RPG::Skill
> $data_skills[1].class.private_methods
> $data_skills[1].class.public_methods
#а вот это доступно только для классов
> $data_skills[1].class.instance_methods
2.2 Работа с instance переменными
Стоит сказать, что в мейкере широко используется довольно удачный подход - поведение объектов одого класса управляется instance переменными, при этом метод initialize обычно не принимает каких либо аргументов, или принимает, но очень мало. В initialize устанавливаются значения переменных по умолчанию, в итоге получается дефолтный объект, который затем можно изменить при помощи сеттеров, меняя значения instance переменных. При этом гетеров для этих переменных обычно нет.
Может это и звучит запутано, но это означает, что нас должны больше интересовать instance переменные, нежели методы. Получить их список можно так:
> $data_skills[1].instance_variables # Вернет массив имен переменных
Получить значение instance переменной можно так
> $data_skills[1].instance_variable_get :@description # Принимает строку или символ с именем переменной
Установить значение instance переменной можно так
> $data_skills[1].instance_variable_set :@description, ‘New’
Обратите внимание, что все имена instance переменных начинаются с @, это очень важно, этот символ нельзя опускать в методах instance_variable_get и instance_variable_set
Маленькая ремарка о том что мы только что сделали.
Конечно же instance переменную
внутри объекта проще (да и нужно, когда это возможно) вызывать просто по имени. Методы instance_variable_get и instance_variable_set используются обычно когда
а) Не известно точно имени переменной и \ или оно задается динамически.
б) Когда нужно работать с instance переменными снаружи объекта, а гетеров \ сетеров нет.
в) Во время исследования конечно же.
Так же к instance переменным можно достучаться к примеру при помощи eval и instance_eval
object.eval “@a = 1”
object.instance_eval { @a }
но это почти тоже самое, что и “забивать гвозди микроскопом”
Немного расскажу о динамической работе с константами, мы, скорее всего, не будем это использовать, но знать крайне полезно.
class Outer
ARRAY = [1, 2]
class Inner
end
end
Outer.constants #список констант
=> [:ARRAY, :Inner]
Outer.const_get :ARRAY
=> [1, 2]
Outer.const_get :Inner
Outer::Inner
Outer.const_set :HELLO, 'hello'
=> "hello"
Outer.const_get :HELLO
=> "hello
Выводы:
1 Объекты могут рассказать о себе все что угодно, какие у них есть методы, какие у них есть переменные, какие константы и т.п. Именно поэтому я скептически отношусь к попыткам скрыть руби скрипты, закодировать их код и т.п.
2 Теперь мы можем сформулировать, что же мы будем делать дальше - мы будем инициализировать объекты RPG::Skill, а потом устанавливать его instance variables (коих я насчитал аж 23 штуки) в нужные значения.
Думаю пока что это все, увидимся в следующей статье