sabrina 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|