sabrina 0.5.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/sabrina.rb +46 -0
- data/lib/sabrina/bytestream.rb +266 -0
- data/lib/sabrina/bytestream/byte_input.rb +126 -0
- data/lib/sabrina/bytestream/byte_output.rb +112 -0
- data/lib/sabrina/bytestream/rom_operations.rb +138 -0
- data/lib/sabrina/children_manager.rb +60 -0
- data/lib/sabrina/config.rb +112 -0
- data/lib/sabrina/config/charmap_in.rb +81 -0
- data/lib/sabrina/config/charmap_out.rb +144 -0
- data/lib/sabrina/config/charmap_out_special.rb +28 -0
- data/lib/sabrina/config/main.rb +105 -0
- data/lib/sabrina/gba_string.rb +156 -0
- data/lib/sabrina/lz77.rb +161 -0
- data/lib/sabrina/meta.rb +33 -0
- data/lib/sabrina/monster.rb +147 -0
- data/lib/sabrina/palette.rb +216 -0
- data/lib/sabrina/plugin.rb +145 -0
- data/lib/sabrina/plugin/load.rb +43 -0
- data/lib/sabrina/plugin/register.rb +32 -0
- data/lib/sabrina/plugins/spritesheet.rb +196 -0
- data/lib/sabrina/plugins/stats.rb +257 -0
- data/lib/sabrina/rom.rb +302 -0
- data/lib/sabrina/sprite.rb +312 -0
- metadata +113 -0
data/lib/sabrina/meta.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# Current software version.
|
3
|
+
#
|
4
|
+
# @return [String]
|
5
|
+
VERSION = '0.5.5'
|
6
|
+
|
7
|
+
# Date of the current version.
|
8
|
+
#
|
9
|
+
# @return [String]
|
10
|
+
DATE = '2014-10-12'
|
11
|
+
|
12
|
+
# A short description.
|
13
|
+
#
|
14
|
+
# @return [String]
|
15
|
+
ABOUT = 'Hack GBA ROMs of a popular monster collection RPG series.'
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Gets the PNG handler info.
|
19
|
+
def png
|
20
|
+
PNG
|
21
|
+
end
|
22
|
+
|
23
|
+
# @see VERSION
|
24
|
+
def version
|
25
|
+
"#{VERSION}/#{PNG}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# @see ABOUT
|
29
|
+
def about
|
30
|
+
ABOUT
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# An abstract class for dealing with further abstractions of data related
|
3
|
+
# to a specific, numbered monster.
|
4
|
+
#
|
5
|
+
# See {Sabrina::Plugins} for extensions that might enhance this class
|
6
|
+
# with added functionality.
|
7
|
+
class Monster
|
8
|
+
include ChildrenManager
|
9
|
+
|
10
|
+
# @!attribute rom
|
11
|
+
# The current working ROM file.
|
12
|
+
# @return [Rom]
|
13
|
+
# @!method rom=(r)
|
14
|
+
# The current working ROM file.
|
15
|
+
# @param [Rom] r
|
16
|
+
# @return [Rom]
|
17
|
+
# @!attribute index
|
18
|
+
# The real index of the monster.
|
19
|
+
# @return [Integer]
|
20
|
+
# @!method index=(i)
|
21
|
+
# The real index of the monster.
|
22
|
+
# @param [Integer] i
|
23
|
+
# @return [Integer]
|
24
|
+
attr_children :rom, :index
|
25
|
+
|
26
|
+
# The filename for saving data to a file, sans the path and extension.
|
27
|
+
# Refer to {#work_dir=} for setting the path, and child +#save+
|
28
|
+
# methods should take care of adding the right extension.
|
29
|
+
#
|
30
|
+
# @return [String]
|
31
|
+
attr_reader :filename
|
32
|
+
|
33
|
+
# The directory for reading and saving file data.
|
34
|
+
# Defaults to the current ROM's file name affixed with +_files+.
|
35
|
+
#
|
36
|
+
# @return [String]
|
37
|
+
# @see #work_dir=
|
38
|
+
attr_reader :work_dir
|
39
|
+
|
40
|
+
# The effective dex number of the monster, depending on any blanks
|
41
|
+
# in the ROM file.
|
42
|
+
attr_reader :dex_number
|
43
|
+
|
44
|
+
class << self
|
45
|
+
# Calculates the real index by parsing the provided index either
|
46
|
+
# as a dex number (when integer) or as a string. A number string
|
47
|
+
# prefixed with an exclamation mark +"!"+ will be interpreted as
|
48
|
+
# the real index.
|
49
|
+
#
|
50
|
+
# @param [Integer, String] index either the dex number, or the
|
51
|
+
# real index prepended with "!".
|
52
|
+
# @param [Rom] rom the rom file to pull the dex data from.
|
53
|
+
# @return [Integer] the real index of the monster in the ROM file,
|
54
|
+
# after accounting for blank spaces between dex numbers 251 and 252.
|
55
|
+
def parse_index(index, rom)
|
56
|
+
in_index = index.to_s
|
57
|
+
if /[^0-9\!]/ =~ in_index
|
58
|
+
fail "\'#{in_index}\' does not look like a valid index."
|
59
|
+
end
|
60
|
+
|
61
|
+
out_index =
|
62
|
+
if in_index['!']
|
63
|
+
in_index.rpartition('!').last.to_i
|
64
|
+
else
|
65
|
+
i = in_index.to_i
|
66
|
+
i < rom.dex_blank_start ? i : i + rom.dex_blank_length
|
67
|
+
end
|
68
|
+
|
69
|
+
unless out_index < rom.dex_length
|
70
|
+
fail "Real index #{out_index} out of bounds;" \
|
71
|
+
" #{rom.id} has #{rom.dex_length} monsters."
|
72
|
+
end
|
73
|
+
|
74
|
+
out_index
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Generates a new instance of Monster.
|
79
|
+
#
|
80
|
+
# @param [Rom] rom the ROM to be associated with the monster.
|
81
|
+
# @param [Integer] index either the dex number of the monster,
|
82
|
+
# or the real (ROM) index prefixed with "!". See {parse_index}
|
83
|
+
# for details.
|
84
|
+
# @param [String] dir the working directory.
|
85
|
+
# @return [Monster]
|
86
|
+
def initialize(rom, index, dir = nil)
|
87
|
+
@plugins = {}
|
88
|
+
|
89
|
+
@rom = rom
|
90
|
+
@index = self.class.parse_index(index, @rom)
|
91
|
+
@dex_number = index
|
92
|
+
|
93
|
+
@filename = format('%03d', @index)
|
94
|
+
@work_dir = dir || @rom.filename.dup << '_files/'
|
95
|
+
|
96
|
+
load_plugins
|
97
|
+
end
|
98
|
+
|
99
|
+
# @return [Array]
|
100
|
+
# @see ChildrenManager
|
101
|
+
def children
|
102
|
+
@plugins.values
|
103
|
+
end
|
104
|
+
|
105
|
+
# Sets the path for saving data to a file.
|
106
|
+
#
|
107
|
+
# @param [String] path
|
108
|
+
# @return [String]
|
109
|
+
def work_dir=(path)
|
110
|
+
path << '/' unless path.end_with?('/')
|
111
|
+
@work_dir = path
|
112
|
+
end
|
113
|
+
|
114
|
+
alias_method :dir=, :work_dir=
|
115
|
+
|
116
|
+
# Sets the dex number of the monster, dependent on any blanks in the ROM.
|
117
|
+
#
|
118
|
+
# @return [Integer]
|
119
|
+
# @see parse_index
|
120
|
+
def dex_number=(n)
|
121
|
+
@dex_number = n
|
122
|
+
self.index = self.class.parse_index(n, @rom)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Reads the monster name from the ROM.
|
126
|
+
#
|
127
|
+
# @return [String]
|
128
|
+
def name
|
129
|
+
@rom.monster_name(@index)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Closes the ROM file.
|
133
|
+
#
|
134
|
+
# @return [0]
|
135
|
+
def close
|
136
|
+
@rom.close
|
137
|
+
0
|
138
|
+
end
|
139
|
+
|
140
|
+
# Prints a blurb consisting of the monster's dex number and name.
|
141
|
+
#
|
142
|
+
# @return [String]
|
143
|
+
def to_s
|
144
|
+
"#{@index}. #{name}"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# A class dedicated to handling color palette data inside a ROM file.
|
3
|
+
# This must be used alongside sprites to display the correct colors
|
4
|
+
# in game or when exported to files.
|
5
|
+
#
|
6
|
+
# While a palette will function in this and some other programs even if
|
7
|
+
# smaller than 16 colors, it must have exactly 16 colors to work in-game.
|
8
|
+
# To ensure this, use the {#pad} method to fill the remaining slots with
|
9
|
+
# a default color. This will, however, make it impossible to add further
|
10
|
+
# colors.
|
11
|
+
#
|
12
|
+
# Parts adapted from
|
13
|
+
# {https://github.com/thekaratekid552/Secret-Tool Gen III Hacking Suite}
|
14
|
+
# by thekaratekid552.
|
15
|
+
#
|
16
|
+
# @see Sprite#set_palette
|
17
|
+
class Palette < Bytestream
|
18
|
+
class << self
|
19
|
+
# Generates an array of two palettes from two +0xRRGGBB+-format streams:
|
20
|
+
# One containing every color from +rgb1+, and another where each color is
|
21
|
+
# replaced with its spatial equivalent from +rgb2+. This assumes
|
22
|
+
# palette 1 does not contain duplicate entries, but palette 2 might.
|
23
|
+
#
|
24
|
+
# @param [String] rgb1 a string of +0xRRGGBB+ values.
|
25
|
+
# @param [String] rgb2 a string of +0xRRGGBB+ values.
|
26
|
+
# @param [Hash] h1 see {Bytestream#initialize}
|
27
|
+
# @param [Hash] h2 see {Bytestream#initialize}
|
28
|
+
# @return [Array] an array of the two resulting palettes.
|
29
|
+
def create_synced_palettes(rgb1, rgb2, h1 = {}, h2 = {})
|
30
|
+
unless rgb1.length % 3 == 0 && rgb2.length % 3 == 0
|
31
|
+
fail 'RGB stream length must divide by 3.'
|
32
|
+
end
|
33
|
+
|
34
|
+
a1, a2 = rgb1.scan(/.../), rgb2.scan(/.../)
|
35
|
+
pal1, pal2 = Palette.empty(h1), Palette.empty(h2)
|
36
|
+
|
37
|
+
a1.each_index do |i|
|
38
|
+
pix1 = a1[i].unpack('CCC')
|
39
|
+
next if pal1.index_of(pix1)
|
40
|
+
|
41
|
+
pix2 = a2[i].unpack('CCC')
|
42
|
+
pal1.add_color(pix1)
|
43
|
+
pal2.add_color(pix2, force: true)
|
44
|
+
end
|
45
|
+
|
46
|
+
[pal1.pad, pal2.pad]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Same as {ByteInput#from_table_as_pointer}.
|
50
|
+
#
|
51
|
+
# @param [Hash] h see {Bytestream#initialize}
|
52
|
+
# @return [Palette]
|
53
|
+
# @see ByteInput#from_table_as_pointer
|
54
|
+
def from_table(rom, table, index, h = {})
|
55
|
+
from_table_as_pointer(rom, table, index, h)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Generates a palette from a stream of bytes following the +0xRRGGBB+
|
59
|
+
# format, failing if the total number of colors in the palette exceeds 16.
|
60
|
+
#
|
61
|
+
# @param [String] rgb
|
62
|
+
# @param [Hash] h see {Bytestream#initialize}
|
63
|
+
# @return [Sprite]
|
64
|
+
def from_rgb(rgb, h = {})
|
65
|
+
fail 'RGB stream length must divide by 3.' unless rgb.length % 3 == 0
|
66
|
+
out_pal = empty(h)
|
67
|
+
|
68
|
+
until rgb.empty?
|
69
|
+
pixel = rgb.slice!(0, 3).unpack('CCC')
|
70
|
+
out_pal.add(pixel) unless out_pal.index_of(pixel)
|
71
|
+
end
|
72
|
+
|
73
|
+
out_pal
|
74
|
+
end
|
75
|
+
|
76
|
+
# Returns an object representing an empty palette.
|
77
|
+
#
|
78
|
+
# @param [Hash] h see {Bytestream#initialize}
|
79
|
+
# @return [Palette]
|
80
|
+
def empty(h = {})
|
81
|
+
h.merge!(representation: [])
|
82
|
+
new(h)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns a palette object represented by the given array
|
86
|
+
# of [R,G,B] values. Caution is advised as there is no
|
87
|
+
# validation.
|
88
|
+
#
|
89
|
+
# @param [Array] a
|
90
|
+
# @return [Palette]
|
91
|
+
def from_array(a = [], h = {})
|
92
|
+
h.merge!(representation: a)
|
93
|
+
new(h)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Same as {Bytestream#initialize}, but with +:lz77+
|
98
|
+
# and +:pointer_mode+ set to true by default.
|
99
|
+
#
|
100
|
+
# @see Bytestream#initialize
|
101
|
+
def initialize(h = {})
|
102
|
+
@lz77 = true
|
103
|
+
@pointer_mode = true
|
104
|
+
|
105
|
+
super
|
106
|
+
end
|
107
|
+
|
108
|
+
# Pads the palette until it has the specified number of colors.
|
109
|
+
# This is a mandatory step for the palette to actually work in-game.
|
110
|
+
#
|
111
|
+
# @param [Integer] l target size.
|
112
|
+
# @param [Array] c the color to pad with, following the [R, G, B] format.
|
113
|
+
# @return [self]
|
114
|
+
def pad(l = 16, c = [16, 16, 16])
|
115
|
+
add_color(c, force: true) until present.length >= l
|
116
|
+
clear_cache
|
117
|
+
end
|
118
|
+
|
119
|
+
# Adds a color to the array. The color must be a [R, G, B] array.
|
120
|
+
# Will fail on malformed color or 16 coors exceeded.
|
121
|
+
#
|
122
|
+
# This will clear the internal cache.
|
123
|
+
#
|
124
|
+
# @param [Array] color the color in [255, 255, 255] format.
|
125
|
+
# @param [Hash] h
|
126
|
+
# @option h [Boolean] :force if +true+, add colors even if already
|
127
|
+
# present in the palette.
|
128
|
+
# @return [self]
|
129
|
+
def add_color(color, h = {})
|
130
|
+
unless color.is_a?(Array) && color.length == 3
|
131
|
+
fail "Color must be [R, G, B]. (#{color})"
|
132
|
+
end
|
133
|
+
|
134
|
+
color.each do |i|
|
135
|
+
next if i.between?(0, 255)
|
136
|
+
fail "Color component out of bounds. (#{color})"
|
137
|
+
end
|
138
|
+
|
139
|
+
@representation ||= []
|
140
|
+
|
141
|
+
if @representation.index(color) && !h.fetch(:force, false)
|
142
|
+
return clear_cache
|
143
|
+
end
|
144
|
+
@representation << color
|
145
|
+
|
146
|
+
if present.length > 16
|
147
|
+
fail "Palette must be smaller than 16. (#{present.length}, #{color})"
|
148
|
+
end
|
149
|
+
|
150
|
+
clear_cache
|
151
|
+
end
|
152
|
+
|
153
|
+
alias_method :add, :add_color
|
154
|
+
|
155
|
+
# Returns the index of the [R, G, B] color in the palette,
|
156
|
+
# or +nil+ if absent.
|
157
|
+
#
|
158
|
+
# @param [Array] color the color in [255, 255, 255] format.
|
159
|
+
# @return [Integer]
|
160
|
+
def index_of(color)
|
161
|
+
present.index(color)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns the palette as an array of [R, G, B] values.
|
165
|
+
#
|
166
|
+
# @return [Array]
|
167
|
+
def present
|
168
|
+
return @representation if @representation
|
169
|
+
|
170
|
+
red_mask, green_mask, blue_mask = 0x1f, 0x3e0, 0x7c00
|
171
|
+
|
172
|
+
in_bytes = to_bytes.dup
|
173
|
+
out_array = []
|
174
|
+
|
175
|
+
until in_bytes.empty?
|
176
|
+
color = Bytestream.from_bytes(in_bytes.slice!(0, 2).reverse).to_i
|
177
|
+
|
178
|
+
out_array << [
|
179
|
+
(color & red_mask) << 3,
|
180
|
+
(color & green_mask) >> 5 << 3,
|
181
|
+
(color & blue_mask) >> 10 << 3
|
182
|
+
]
|
183
|
+
end
|
184
|
+
|
185
|
+
@representation = out_array
|
186
|
+
end
|
187
|
+
|
188
|
+
alias_method :to_a, :present
|
189
|
+
|
190
|
+
# Converts the internal representation to a GBA-compatible
|
191
|
+
# stream of bytes.
|
192
|
+
#
|
193
|
+
# @return [String]
|
194
|
+
# @see ByteOutput#generate_bytes
|
195
|
+
def generate_bytes
|
196
|
+
pal = ''
|
197
|
+
@representation.each do |c|
|
198
|
+
red = c[0] >> 3
|
199
|
+
green = c[1] >> 3 << 5
|
200
|
+
blue = c[2] >> 3 << 10
|
201
|
+
|
202
|
+
pal <<
|
203
|
+
Bytestream.from_hex(format('%04X', (blue | green | red))).to_b.reverse
|
204
|
+
end
|
205
|
+
|
206
|
+
pal.rjust(2 * @representation.length, "\x00")
|
207
|
+
end
|
208
|
+
|
209
|
+
# A blurb showing the color count of the palette.
|
210
|
+
#
|
211
|
+
# @return [String]
|
212
|
+
def to_s
|
213
|
+
"Palette (#{present.length})"
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# This is the recommended wrapper namespace for {Plugin Plugins} that enhance
|
3
|
+
# {Sabrina} with new functionality.
|
4
|
+
#
|
5
|
+
# @see Plugin
|
6
|
+
module Plugins; end
|
7
|
+
|
8
|
+
# The base class for plugins that improve classes with the ability
|
9
|
+
# to handle additional data. The +#initialize+ method for every
|
10
|
+
# plugin should accept a target class instance as the parameter.
|
11
|
+
# The +#initialize+ method of the target class should contain
|
12
|
+
# a call to +load_plugins+.
|
13
|
+
#
|
14
|
+
# When a class has been enhanced by a plugin, it will gain a read-only
|
15
|
+
# attribute +#plugins+ that will contain a set of all plugins registered
|
16
|
+
# with that class. Class instances will gain a read-only attribute
|
17
|
+
# +#plugins+ that will contain a hash of SHORT_NAME => (plugin instance)
|
18
|
+
# pairs.
|
19
|
+
#
|
20
|
+
# Each plugin will also expose a set of {FEATURES}. For every +feature+, two
|
21
|
+
# instance methods will be added to the target object: +#feature+, which
|
22
|
+
# will call +#feature+ on every plugin instance that supports it, and
|
23
|
+
# +#feature_shortname+, which will call +#feature+ on the instance of the
|
24
|
+
# plugin identified by {SHORT_NAME} (assuming it supports that feature).
|
25
|
+
#
|
26
|
+
# For clarity, plugins should be contained within the {Plugins}
|
27
|
+
# namespace.
|
28
|
+
class Plugin
|
29
|
+
# What class this plugin enhances. This should be required before
|
30
|
+
# the plugin.
|
31
|
+
#
|
32
|
+
# The target class should have +load_plugins+ at the end of the init.
|
33
|
+
ENHANCES = Monster
|
34
|
+
|
35
|
+
# Plugin name goes here.
|
36
|
+
PLUGIN_NAME = 'Generic Plugin'
|
37
|
+
|
38
|
+
# Short name should contain only a-z, 0-9, and underscores.
|
39
|
+
# This will be the suffix for target instance methods.
|
40
|
+
SHORT_NAME = 'genericplugin'
|
41
|
+
|
42
|
+
# Tells the enhanced class what public instance methods the plugin
|
43
|
+
# should expose.
|
44
|
+
#
|
45
|
+
# This should be a set of symbols.
|
46
|
+
#
|
47
|
+
# Common features include +:reread+, +:write+, +:save+, +:load+.
|
48
|
+
FEATURES = Set.new [:reread, :write, :save, :load]
|
49
|
+
|
50
|
+
class << self
|
51
|
+
# @see PLUGIN_NAME
|
52
|
+
def plugin_name
|
53
|
+
self::PLUGIN_NAME
|
54
|
+
end
|
55
|
+
|
56
|
+
# @see ENHANCES
|
57
|
+
def target
|
58
|
+
self::ENHANCES
|
59
|
+
end
|
60
|
+
|
61
|
+
# @see SHORT_NAME
|
62
|
+
def short_name
|
63
|
+
self::SHORT_NAME.downcase.gsub(/[^a-z0-9_]/, '')
|
64
|
+
end
|
65
|
+
|
66
|
+
# @see FEATURES
|
67
|
+
def features
|
68
|
+
self::FEATURES
|
69
|
+
end
|
70
|
+
|
71
|
+
# Automagically registers new plugins with target.
|
72
|
+
def inherited(subclass)
|
73
|
+
target.extend(Plugin::Register)
|
74
|
+
Plugin::Load.include_in(target)
|
75
|
+
|
76
|
+
target.register_plugin(subclass)
|
77
|
+
super
|
78
|
+
end
|
79
|
+
|
80
|
+
# Generate a +#feature+ method.
|
81
|
+
#
|
82
|
+
# @param [Symbol] f feature.
|
83
|
+
# @return [Proc]
|
84
|
+
def feature_all(f)
|
85
|
+
proc do |*args|
|
86
|
+
targets = @plugins.select { |_key, val| val.feature?(f) }
|
87
|
+
targets.values.map { |val| val.method(f).call(*args) }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Generate a +#feature_shortname+ method.
|
92
|
+
#
|
93
|
+
# @param [Symbol] f feature.
|
94
|
+
# @return [Proc]
|
95
|
+
def feature_this(f, n)
|
96
|
+
proc do |*args|
|
97
|
+
@plugins.fetch(n).method(f).call(*args)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Provides a short description of the plugin's functionality.
|
102
|
+
#
|
103
|
+
# @return [String]
|
104
|
+
def to_s
|
105
|
+
"\'#{short_name}\': enhances #{target.name}, supports #{features.to_a}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Generates a new {Plugin} object.
|
110
|
+
#
|
111
|
+
# @param [Object] parent
|
112
|
+
# @return [Plugin]
|
113
|
+
def initialize(parent, *_args)
|
114
|
+
@parent = parent
|
115
|
+
end
|
116
|
+
|
117
|
+
# Drop internal data and force reloading from ROM.
|
118
|
+
#
|
119
|
+
# @return [Array] Any return data from child methods.
|
120
|
+
def reread(*_args)
|
121
|
+
children.map(&:reload_from_rom)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Write data to ROM.
|
125
|
+
#
|
126
|
+
# @return [Array] Any return data from child methods.
|
127
|
+
def write(*_args)
|
128
|
+
children.map(&:write_to_rom)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Whether a plugin instance supports a feature.
|
132
|
+
#
|
133
|
+
# @return [Boolean]
|
134
|
+
def feature?(f)
|
135
|
+
self.class.features.include?(f)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Subclasses should override this to provide a useful textual
|
139
|
+
# representation of instance data.
|
140
|
+
# @return [String]
|
141
|
+
def to_s
|
142
|
+
"<#{self.class.plugin_name}>"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|