ShinyFlash.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
-- The arrival sparkle, on the FLAT battle screen. -- -- ------- why ShinyFx could not be reused -- -- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it -- is about the 3D arena: it is armed from Stadium.update on the frame a -- side's model changes, it is sized from the model's own world height and -- radius, and it draws additive quads into the voxel scene through -- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no -- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards -- rather than building a model to measure. -- -- So the effect was Stadium-only, and had been since it was written: ShinyFx -- .arm is called from exactly one file. On every other rung a shiny simply -- appeared, with no announcement. This is the announcement, in the one -- coordinate space those rungs share -- the Game Boy's own 160x144 grid, -- where the pic itself is drawn. -- -- ------- the two slots -- -- Both are the engine's, and neither moves: the enemy's front pic lives in -- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's -- back pic stands at x=8 with its feet on the text box at y=96, two-times -- scaled, so it fills y 32..96. The burst springs from a point inside each, -- a little above centre, which is roughly where a Pokemon's chest is in art -- drawn to fill its box. -- -- Deliberately NOT measured off the drawn image. resolveBattleScale can -- rescale a pic per species, the send-out grow animates the scale from zero, -- and following either would make the burst jump around during exactly the -- moment it is playing. The slot is fixed; the sparkle uses the slot. -- -- ------- black AND white, both -- -- Each spark is drawn twice: a wider near-black cross, then a white one -- inside it. One colour alone would be invisible half the time -- the battle -- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D -- rungs composite the same pic over a sky or a map, where a black one does. -- The pair reads on both, and costs ten extra rectangles. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Shiny = V.require("Shiny") local ShinyFlash = {} ShinyFlash.LIFE = 0.75 -- seconds, matching ShinyFx ShinyFlash.SPARKS = 9 -- The two slots, in GB pixels: where the burst starts and how far it travels. ShinyFlash.SLOTS = { enemy = { x = 124, y = 24, rx = 34, ry = 26 }, player = { x = 40, y = 62, rx = 34, ry = 30 }, } -- Where each spark sits on the ring, as a fraction of a turn. Spread by hand -- rather than randomly: nine sparks on an even ring reads as a ring, and nine -- random ones read as a mess at this size. The half-step offset on alternate -- sparks keeps it from looking like a clock face. local ANGLES = {} for i = 1, ShinyFlash.SPARKS do ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0) / ShinyFlash.SPARKS end -- ------- why the clock is the WALL clock -- -- Everything here happens on the DRAW side (see install), and a draw is -- handed no dt. Rather than accumulate one nobody offers, a burst records -- the time it started and its age is read back off love.timer. -- -- That also makes it immune to being asked to draw more than once in a -- frame, which the wide layout does -- once per side -- and which a -- per-call dt accumulator would age at double speed. local function now() return (love.timer and love.timer.getTime and love.timer.getTime()) or 0 end -- live bursts: side -> the time it started local live = {} -- what each side's pic was showing last frame, so an arrival is an EDGE local showing = {} -- for the tests and the shot drivers, the way ShinyFx.debug is ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0, armed = 0, draws = 0, sparks = 0, err = "" } function ShinyFlash.arm(side) live[side] = now() ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1 end function ShinyFlash.clear(side) live[side] = nil end function ShinyFlash.reset() live, showing = {}, {} end -- How far through its life this side's burst is, 0..1, or nil when there -- isn't one (or it has finished, which retires it on the way past). function ShinyFlash.age(side) local started = live[side] if not started then return nil end local u = (now() - started) / ShinyFlash.LIFE if u >= 1 then live[side] = nil return nil end return u end function ShinyFlash.active(side) return ShinyFlash.age(side) ~= nil end -- ------- is a Pokemon's own pic on screen for this side -- -- The conditions are the engine's, read off drawPicsLayer rather than -- guessed: a side showing a TRAINER is showing a person and not a Pokemon, -- and the send-out, the faint fade and the safari/demo cases each have their -- own reason for the slot to be empty. -- -- Returns the mon whose pic is up, or nil. function ShinyFlash.occupant(battle, side) if type(battle) ~= "table" then return nil end if side == "enemy" then if battle.showEnemyTrainer and battle.trainerPic then return nil end local b = battle.enemy if not (b and b.sprite) then return nil end if battle.enemyHidden or battle.enemySendingOut then return nil end if battle.fxHidden and battle:fxHidden(b) then return nil end return b.mon end if battle.showPlayerBack and battle.playerBackPic then return nil end if battle.safari or battle.demo then return nil end local b = battle.player if not (b and b.sprite) then return nil end if battle.sendingOut then return nil end if battle.fxHidden and battle:fxHidden(b) then return nil end return b.mon end -- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a -- switch and a wild foe's first appearance alike, which is the same edge -- ShinyFx picks for the models. function ShinyFlash.follow(battle) ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1 for _, side in ipairs({ "enemy", "player" }) do local mon = ShinyFlash.occupant(battle, side) if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end if mon ~= showing[side] then showing[side] = mon if mon and Shiny.isShiny(mon) then ShinyFlash.arm(side) else ShinyFlash.clear(side) end end end end -- ------- drawing -- -- Whole pixels. The screen this lands on is 160x144 and everything else in -- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing -- on a hard-edged frame. local function spark(px, py, arm) local g = love.graphics px, py = math.floor(px + 0.5), math.floor(py + 0.5) -- the dark cross first, one pixel proud of the light one on every side g.setColor(0, 0, 0, 1) g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3) g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3) g.setColor(1, 1, 1, 1) g.rectangle("fill", px - arm, py, arm * 2 + 1, 1) g.rectangle("fill", px, py - arm, 1, arm * 2 + 1) end -- One side's burst, if it has one. function ShinyFlash.draw(side, sx, sy) local u = ShinyFlash.age(side) if not u then return end local slot = ShinyFlash.SLOTS[side] if not slot then return end local g = love.graphics local r, gg, b, a = g.getColor() -- Out and fading. The ring eases OUT rather than travelling at a constant -- speed -- fast off the mark, slow at the edge -- because a burst that -- decelerates reads as thrown and one that does not reads as a wipe. local ease = 1 - (1 - u) * (1 - u) local fade = 1 - u g.setColor(1, 1, 1, 1) ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1 for i = 1, ShinyFlash.SPARKS do -- every third spark is held back a little, so the ring has some depth -- rather than nine points on one circle local lag = (i % 3 == 0) and 0.78 or 1 local ang = ANGLES[i] * math.pi * 2 local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag -- arms shrink as the spark fades, so it goes out rather than vanishing local arm = 1 + math.floor(fade * 2.5) spark(px, py, arm) ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1 end g.setColor(r, gg, b, a) end -- ------- BEHIND the Pokemon, not over it -- -- The burst springs from inside the mon and flies outward, so the frames that -- matter most are the ones where the ring is still small and sitting ON the -- body. Drawn from the overlay hook -- the end of the battle draw -- every one -- of those lands in FRONT of the pic, and the sparkle reads as stuck to the -- glass rather than as coming from the Pokemon. -- -- So it is drawn from the PICS LAYER instead, before the engine's own pics go -- down. That is the only place in the frame that is behind the mon and in -- front of the field. -- -- The overlay hook stays, and is still the only seam the 3D rungs have: -- OverworldBattle captured drawPicsLayer at install time and its battle draw -- calls the captured copy, so the wrap below never runs there. Whichever seam -- fires first draws; the other one sees the side already spent and leaves it -- alone. `spent` is cleared by the overlay, which is the one call guaranteed -- to happen exactly once per battle draw. local spent = {} -- One side, unless it has already been drawn this frame. local function once(side, sx, sy) if spent[side] then return end spent[side] = true ShinyFlash.draw(side, sx, sy) end -- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout -- drawing one side per call, and is honoured so the burst lands in the same -- pass its Pokemon does. -- -- Skipped while the layer is SLIDING (the intro walks the whole battle in -- from the side): the slot this draws to is fixed, so a burst during the -- slide would sit still while the mon travelled past it. Nothing is lost -- -- the arrival edge that arms it is after the slide is over. function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide) ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1 ShinyFlash.follow(battle) if (slide or 0) ~= 0 then return end if onlySide ~= "player" then once("enemy", sx, sy) end if onlySide ~= "enemy" then once("player", sx, sy) end end -- Follow the occupants and draw whatever the pics layer did not, in one call. function ShinyFlash.render(battle) ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1 ShinyFlash.follow(battle) once("enemy", 0, 0) once("player", 0, 0) spent = {} -- one battle draw ends here; the next is new end -- ------- install -- -- Through the engine's own `battle.overlay` hook, whose comment at the call -- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires -- at the very end of BattleState:draw, in the Game Boy's own 160x144 space, -- with the battle as its argument -- which is all this needs. -- -- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth -- recording so it is not tried again: BattleState:update never fires during -- the intro, because the battle is not the top of the stack there and -- StateStack:update only calls the top. Measured -- installed, confirmed live -- on the class, zero calls -- rather than reasoned about. -- -- The hook has no shake offset to give, and does not need one: it is called -- after the screen-shake translate has been popped, so nominal coordinates -- are the right ones. -- -- ------- and the second seam, for depth -- -- The overlay alone draws the burst OVER the Pokemon. The pics layer is -- wrapped as well so it can go down BEHIND it (see renderBehind), on every -- rung where the engine's own method is the one called. On the 3D rungs it is -- not -- OverworldBattle captured drawPicsLayer at install time and calls the -- captured copy -- and there the overlay is still the seam, which is why both -- are installed rather than one replacing the other. function ShinyFlash.install() local mod = V.mod if not (mod and mod.hooks and mod.hooks.wrap) then return false end if ShinyFlash.installed then return true end mod.hooks:wrap("battle.overlay", function(next, battle) local out = next(battle) local ok, err = pcall(ShinyFlash.render, battle) if not ok then ShinyFlash.debug.err = tostring(err) end return out end) local okBS, BattleState = pcall(require, "src.battle.BattleState") if okBS and type(BattleState) == "table" and type(BattleState.drawPicsLayer) == "function" and not BattleState.dramaticShapeShinyFlash then local inner = BattleState.drawPicsLayer function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...) ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1 local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy, onlySide) if not ok then ShinyFlash.debug.err = tostring(err) end return inner(self, slide, sx, sy, onlySide, ...) end BattleState.dramaticShapeShinyFlash = true end ShinyFlash.installed = true return true end return ShinyFlash