sabrina 0.5.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ module Sabrina
2
+ class Plugin
3
+ # Allows to load plugins. Add +load_plugins+ at the end of your class's
4
+ # +initialize+ call.
5
+ module Load
6
+ class << self
7
+ # Exposes +#append_features+.
8
+ def include_in(obj)
9
+ append_features(obj) unless obj.include?(self)
10
+ end
11
+ end
12
+
13
+ attr_reader :plugins
14
+
15
+ private
16
+
17
+ # Generates plugin instances. This should be called from
18
+ # the target class's init.
19
+ #
20
+ # @return [0]
21
+ def load_plugins
22
+ self.class.plugins.each do |plugin|
23
+ n = plugin.short_name
24
+
25
+ plugin.features.each do |f|
26
+ feature_all_sym = f.to_sym
27
+ feature_this_sym = "#{f}_#{n}"
28
+ unless respond_to?(feature_all_sym)
29
+ define_singleton_method(feature_all_sym, plugin.feature_all(f))
30
+ end
31
+ unless respond_to?(feature_this_sym)
32
+ define_singleton_method(feature_this_sym, plugin.feature_this(f, n))
33
+ end
34
+ end
35
+
36
+ define_singleton_method(n, -> { @plugins[n] }) unless respond_to?(n)
37
+ @plugins[n] = plugin.new(self)
38
+ end
39
+ 0
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ module Sabrina
2
+ class Plugin
3
+ # Allows to register plugins.
4
+ module Register
5
+ # Lists all currently registered plugins.
6
+ #
7
+ # @return [Set]
8
+ def plugins
9
+ @plugins.to_a
10
+ end
11
+
12
+ # Registers a new plugin for handling a specific subset of monster
13
+ # data.
14
+ #
15
+ # @return [0]
16
+ # @see Plugin
17
+ def register_plugin(plugin)
18
+ @plugins ||= Set.new
19
+ @plugins << plugin
20
+ end
21
+
22
+ # Lists all currently available features.
23
+ #
24
+ # @return [Set]
25
+ def features
26
+ s = Set.new
27
+ @plugins.each { |x| s += x.features }
28
+ s
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,196 @@
1
+ module Sabrina
2
+ module Plugins
3
+ # This {Plugin} provides an abstraction for manipulating all
4
+ # {Sprite Sprites} and {Palette Palettes} associated with a {Monster}.
5
+ #
6
+ # @see Plugin
7
+ class Spritesheet < Plugin
8
+ include ChildrenManager
9
+
10
+ # @see Plugin::ENHANCES
11
+ ENHANCES = Monster
12
+
13
+ # @see Plugin::PLUGIN_NAME
14
+ PLUGIN_NAME = 'Spritesheet'
15
+
16
+ # @see Plugin::SHORT_NAME
17
+ SHORT_NAME = 'spritesheet'
18
+
19
+ # @see Plugin::FEATURES
20
+ FEATURES = Set.new [:reread, :write, :save, :load]
21
+
22
+ # @!attribute rom
23
+ # The current working ROM file.
24
+ # @return [Rom]
25
+ # @!method rom=(r)
26
+ # The current working ROM file.
27
+ # @param [Rom] r
28
+ # @return [Rom]
29
+ # @!attribute index
30
+ # The real index of the monster.
31
+ # @return [Integer]
32
+ # @!method index=(i)
33
+ # The real index of the monster.
34
+ # @param [Integer] i
35
+ # @return [Integer]
36
+ attr_children :rom, :index
37
+
38
+ # The palettes used by the sprites.
39
+ #
40
+ # @return [Array]
41
+ attr_reader :palettes
42
+
43
+ # The sprites.
44
+ #
45
+ # @return [Array]
46
+ attr_reader :sprites
47
+
48
+ # Generates a new Spritesheet object.
49
+ #
50
+ # @return [Spritesheet]
51
+ def initialize(monster)
52
+ @monster = monster
53
+ @rom = monster.rom
54
+ @index = monster.index
55
+
56
+ @palettes = [
57
+ Palette.from_table(@monster.rom, :palette_table, @monster.index),
58
+ Palette.from_table(@monster.rom, :shinypal_table, @monster.index)
59
+ ]
60
+
61
+ @sprites = [
62
+ Sprite.from_table(@monster.rom, :front_table, @monster.index),
63
+ Sprite.from_table(@monster.rom, :back_table, @monster.index)
64
+ ]
65
+ end
66
+
67
+ # @return [Array]
68
+ # @see ChildrenManager
69
+ def children
70
+ @sprites + @palettes
71
+ end
72
+
73
+ # Justify sprites to the target ROM count as per ROM {Config}, assuming
74
+ # 64x64 frame size.
75
+ def justify
76
+ @sprites.map(&:justify)
77
+ end
78
+
79
+ # Load data from a file.
80
+ #
81
+ # @return [Array] Any return data from child methods.
82
+ def load(file = @monster.filename, dir = @monster.work_dir, *_args)
83
+ path = get_path(file, dir)
84
+ a = split_canvas(ChunkyPNG::Canvas.from_file(path))
85
+ h = { rom: @rom, index: @index }
86
+
87
+ normal_rgb = a[0].to_rgb_stream + a[2].to_rgb_stream
88
+ shiny_rgb = a[1].to_rgb_stream + a[3].to_rgb_stream
89
+
90
+ @palettes = Palette.create_synced_palettes(
91
+ normal_rgb,
92
+ shiny_rgb,
93
+ h.merge(table: :palette_table),
94
+ h.merge(table: :shinypal_table)
95
+ )
96
+
97
+ frames = @rom.special_frames.fetch(@index, @rom.frames)
98
+ front = crop_canvas(a[0], frames.first * 64)
99
+ back = crop_canvas(a[2], frames.last * 64)
100
+
101
+ @sprites = [
102
+ Sprite.from_canvas(
103
+ front,
104
+ @palettes.first,
105
+ h.merge(table: :front_table)
106
+ ),
107
+ Sprite.from_canvas(
108
+ back,
109
+ @palettes.first,
110
+ h.merge(table: :back_table)
111
+ )
112
+ ]
113
+
114
+ justify
115
+ children
116
+ end
117
+
118
+ # Save data to a file.
119
+ #
120
+ # @return [Array] Any return data from child methods.
121
+ def save(file = @monster.filename, dir = @monster.work_dir, *_args)
122
+ a = []
123
+ @sprites.product(@palettes).each do |x|
124
+ a << x.first.to_canvas(x.last)
125
+ end
126
+
127
+ path = get_path(file, dir, mkdir: true)
128
+
129
+ combine_canvas(a).save(path)
130
+ end
131
+
132
+ private
133
+
134
+ # Crops the canvas to the specified height if taller.
135
+ def crop_canvas(c, h)
136
+ return c.crop(0, 0, c.width, h) if c.height > h
137
+ c
138
+ end
139
+
140
+ # Horizontally combine a one-dimensional array of
141
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
142
+ # objects into a single, wide canvas.
143
+ #
144
+ # @param [Array] a an array of Canvas objects.
145
+ # @return [Canvas] the combined canvas.
146
+ def combine_canvas(a)
147
+ out_c = ChunkyPNG::Canvas.new(a.first.width * a.length, a.first.height)
148
+ a.each_index { |i| out_c.replace!(a[i], a.first.width * i) }
149
+ out_c
150
+ end
151
+
152
+ # Vertically combine a one-dimensional array of
153
+ # {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
154
+ # objects into a single, tall canvas.
155
+ #
156
+ # @param [Array] a an array of Canvas objects.
157
+ # @return [Canvas] the combined canvas.
158
+ def combine_canvas_vert(a)
159
+ out_c = ChunkyPNG::Canvas.new(a.first.width, a.first.height * a.length)
160
+ a.each_index { |i| out_c.replace!(a[i], 0, a.first.height * i) }
161
+ out_c
162
+ end
163
+
164
+ # Split a {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
165
+ # into an array of canvases horizontally.
166
+ #
167
+ # @param [Canvas] c
168
+ # @param [Integer] w tile width.
169
+ # @return [Array] an array of Canvas objects.
170
+ def split_canvas(c, w = 64)
171
+ out_a = []
172
+ (c.width / w).times { |i| out_a << c.crop(i * w, 0, w, c.height) }
173
+ out_a
174
+ end
175
+
176
+ # Concatenate the file name and directory into a full path, optionally
177
+ # creating the directory if it doesn't exist.
178
+ #
179
+ # @param [String] file
180
+ # @param [String] dir
181
+ # @param [Hash] h
182
+ # @option h [Boolean] :mkdir If +true+, create +dir+ if it doesn't
183
+ # exist.
184
+ # @return [String]
185
+ def get_path(file, dir, h = {})
186
+ f, d = file.dup, dir.dup
187
+ d << '/' unless d.empty? || d.end_with?('/')
188
+
189
+ FileUtils.mkpath(d) if h.fetch(:mkdir, false) && !Dir.exist?(d)
190
+
191
+ path = d << f
192
+ path << '.png' unless path.downcase.end_with?('.png')
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,257 @@
1
+ module Sabrina
2
+ module Plugins
3
+ # @todo Finish this class.
4
+ # This {Plugin} aids in manipulating the basic stats of any {Monster}.
5
+ #
6
+ # @see Plugin
7
+ class Stats < Plugin
8
+ # @see Plugin::ENHANCES
9
+ ENHANCES = Monster
10
+
11
+ # @see Plugin::PLUGIN_NAME
12
+ PLUGIN_NAME = 'Stats'
13
+
14
+ # @see Plugin::SHORT_NAME
15
+ SHORT_NAME = 'stats'
16
+
17
+ # @see Plugin::FEATURES
18
+ FEATURES = Set.new [:reread, :write]
19
+
20
+ # Describes the order in which various stats appear in the byte data.
21
+ # All of these will also be converted into attributes on runtime and
22
+ # can be modified directly.
23
+ STRUCTURE = [
24
+ :hp, :attack, :defense, :speed, :sp_atk, :sp_def, :type_1, :type_2,
25
+ :catch_rate, :exp_yield, :ev_yield, :item_1, :item_2, :gender,
26
+ :egg_cycles, :friendship, :level_curve, :egg_group_1, :egg_group_2,
27
+ :ability_1, :ability_2, :safari_rate, :color_flip
28
+ ]
29
+
30
+ # Code names for level up types.
31
+ LEVEL_CURVES = [
32
+ 'Medium-Fast', 'Erratic', 'Fluctuating', 'Medium-Slow', 'Fast', 'Slow'
33
+ ]
34
+
35
+ # Code names for egg groups.
36
+ EGG_GROUPS = [
37
+ 'None', 'Monster', 'Water 1', 'Bug', 'Flying', 'Field', 'Fairy',
38
+ 'Grass', 'Human-Like', 'Water 3', 'Mineral', 'Amorphous', 'Water 2',
39
+ 'Ditto', 'Dragon', 'Undiscovered'
40
+ ]
41
+
42
+ # Where to pull descriptive names from if applicable, these can be arrays
43
+ # or ROM tables.
44
+ NAMES = {
45
+ type_1: :type_table,
46
+ type_2: :type_table,
47
+ item_1: :item_table,
48
+ item_2: :item_table,
49
+ level_curve: LEVEL_CURVES,
50
+ egg_group_1: EGG_GROUPS,
51
+ egg_group_2: EGG_GROUPS,
52
+ ability_1: :ability_table,
53
+ ability_2: :ability_table
54
+ }
55
+
56
+ # @!attribute [rw] rom
57
+ # The current working ROM file.
58
+ # @return [Rom]
59
+ # @!attribute [rw] index
60
+ # The real index of the monster.
61
+ # @return [Integer]
62
+ attr_accessor(:rom, :index, *STRUCTURE)
63
+
64
+ # Generates a new Stats object.
65
+ #
66
+ # @return [Stats]
67
+ def initialize(monster)
68
+ @monster = monster
69
+ @rom = monster.rom
70
+ @index = monster.index
71
+
72
+ @stream = Bytestream.from_table(
73
+ @rom,
74
+ :stats_table,
75
+ @monster.index,
76
+ @rom.stats_length
77
+ )
78
+
79
+ parse_stats
80
+ end
81
+
82
+ # Returns the base stats total.
83
+ #
84
+ # @return [Integer]
85
+ def total
86
+ @hp + @attack + @defense + @speed + @sp_atk + @sp_def
87
+ end
88
+
89
+ # Reloads the data from a ROM, dropping any changes.
90
+ #
91
+ # @return [self]
92
+ def reread
93
+ parse_stats
94
+ self
95
+ end
96
+
97
+ # Write data to the ROM.
98
+ def write
99
+ stream.write_to_rom
100
+ end
101
+
102
+ # Returns a {Bytestream} object representing the stats, ready to be
103
+ # written to the ROM.
104
+ #
105
+ # @return [Bytestream]
106
+ def stream
107
+ Bytestream.from_bytes(
108
+ unparse_stats,
109
+ rom: @rom,
110
+ table: :stats_table,
111
+ index: @index
112
+ )
113
+ end
114
+
115
+ # Returns a hash representation of the stats.
116
+ #
117
+ # @return [Hash]
118
+ def to_h
119
+ h = {}
120
+
121
+ STRUCTURE.each do |entry|
122
+ h[entry] = instance_variable_get('@' << entry.to_s)
123
+ end
124
+
125
+ { @index => { stats: h } }
126
+ end
127
+
128
+ # Returns a pretty hexadecimal representation of the stats byte data.
129
+ #
130
+ # @return [String]
131
+ def to_hex
132
+ stream.to_hex(true)
133
+ end
134
+
135
+ # Returns a pretty JSON representation of the stats.
136
+ #
137
+ # @return [String]
138
+ def to_json
139
+ JSON.pretty_generate(to_h)
140
+ end
141
+
142
+ # Returns a blurb containing the base stats total.
143
+ #
144
+ # @return [String]
145
+ def to_s
146
+ "<Stats (#{total})>"
147
+ end
148
+
149
+ private
150
+
151
+ # Reads stats data from the ROM.
152
+ def parse_stats
153
+ b = @stream.to_bytes.dup
154
+
155
+ STRUCTURE.each do |entry|
156
+ value =
157
+ case entry
158
+ when :ev_yield
159
+ parse_evs(b.slice!(0, 2))
160
+ when :item_1, :item_2
161
+ b.slice!(0, 2).unpack('S').first
162
+ when :color_flip
163
+ parse_color_flip(b.slice!(0))
164
+ else
165
+ b.slice!(0).unpack('C').first
166
+ end
167
+
168
+ pretty_value = prettify_stat(entry, value)
169
+ instance_variable_set('@' << entry.to_s, pretty_value)
170
+ end
171
+ end
172
+
173
+ # Converts stats data to a GBA-compatible byte string.
174
+ def unparse_stats
175
+ b = ''
176
+ STRUCTURE.each do |entry|
177
+ val = instance_variable_get('@' << entry.to_s)
178
+ case entry
179
+ when :ev_yield
180
+ b << unparse_evs(val)
181
+ when :item_1, :item_2
182
+ b << [val.to_i].pack('S')
183
+ when :color_flip
184
+ b << unparse_color_flip(val)
185
+ else
186
+ b << [val.to_i].pack('C')
187
+ end
188
+ end
189
+
190
+ b.ljust(28, "\x00")
191
+ end
192
+
193
+ # Attempts to annotate a numeric value with useful information.
194
+ def prettify_stat(entry, value)
195
+ return "#{value} (#{gender_info(value)})" if entry == :gender
196
+
197
+ return value unless NAMES.key?(entry) && value.is_a?(Numeric)
198
+
199
+ zero_is_valid = [:type_1, :type_2, :level_curve]
200
+ return value if value == 0 && !zero_is_valid.include?(entry)
201
+
202
+ return "#{value} (#{NAMES[entry][value]})" if NAMES[entry].is_a?(Array)
203
+
204
+ "#{value} (#{ @rom.read_string_from_table(NAMES[entry], value) })"
205
+ end
206
+
207
+ # Displays readable info about the gender distribution defined by the
208
+ # provided gender value.
209
+ def gender_info(i)
210
+ return 'Genderless' if i > 254
211
+ return 'Always Female' if i == 254
212
+ return 'Always Male' if i == 0
213
+ "#{(100.0 * i / 255).round}% Female"
214
+ end
215
+
216
+ # Converts a word (two bytes) into a hash of EV yield data.
217
+ def parse_evs(b)
218
+ a = b.unpack('b*').first.scan(/../).map { |x| x.reverse.to_i(2) }
219
+ h = {}
220
+
221
+ a.take(6).each_index do |i|
222
+ next if a[i] < 1
223
+ h[STRUCTURE[i]] = a[i]
224
+ end
225
+
226
+ h
227
+ end
228
+
229
+ # Converts a hash of EV yield data into a word (two bytes).
230
+ def unparse_evs(h)
231
+ a = []
232
+
233
+ STRUCTURE.take(6).each do |stat|
234
+ ev = h.fetch(stat, 0)
235
+ a << ev.to_s(2).rjust(2, '0').reverse
236
+ end
237
+
238
+ [a.join.ljust(16, '0')].pack 'b*'
239
+ end
240
+
241
+ # Converts a byte into an array of dex color and flip.
242
+ def parse_color_flip(b)
243
+ s = b.unpack('C').first.to_s(16).rjust(2, '0')
244
+
245
+ [
246
+ s[1].to_i,
247
+ s[0] == '8' ? true : false
248
+ ]
249
+ end
250
+
251
+ # Converts an array of dex color and flip into a byte.
252
+ def unparse_color_flip(a)
253
+ ((a.last ? '8' : '0') << a.first.to_s).hex.chr
254
+ end
255
+ end
256
+ end
257
+ end