This repository was archived by the owner on Aug 14, 2026. It is now read-only.
forked from TeJota1337/DramaticShapeVoxelMod
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStadiumStage.lua
More file actions
340 lines (310 loc) · 13.8 KB
/
Copy pathStadiumStage.lua
File metadata and controls
340 lines (310 loc) · 13.8 KB
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
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
-- The B rungs: the two discs the fight is staged on.
--
-- Where an A rung puts the fight on the MAP -- real ground, whatever the
-- route happens to look like -- a B rung puts it on two platforms against
-- the sky and draws no map at all.
--
-- ------- one stage, two rungs
--
-- The discs do not know what is standing on them. 2D-3D B stands the Game
-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium
-- models, and this file is identical for both: it draws two platforms at two
-- cells and sizes each to whatever footprint it is given. That is why the
-- flat disc rung cost a value in the 3D-BTL ladder and nothing else.
--
-- ------- why this is a rung and not a fix
--
-- Staging on the map is the better picture when the map cooperates, and it
-- often does not. Half of Kanto's interiors are furniture; a cave floor can
-- be nothing but two-cell corridors; some maps have nowhere a fight can be
-- SEEN from a low camera and are declined outright (see BattleArena), which
-- drops the player back to the flat battle screen with no warning. And even
-- where a spot exists, the ground behind the foe is a hedge or a shop counter
-- rather than anything a battle wants behind it.
--
-- Discs have none of those problems, because the stage is CARRIED rather than
-- found: it works on every map, in every building, at every step, and the
-- framing is the same every time. What it gives up is the thing STADIUM A is
-- for -- fighting somewhere real.
--
-- ------- what stays
--
-- The sky, and the light. A battle outdoors is under the hour's own sky, with
-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a
-- dressed one); a battle in a cave or a room is under that place's own void
-- and its own neutral light, exactly as the map itself would be. So the mode
-- is abstracted from the GROUND, not from the world -- walk into a cave at
-- midnight and the fight looks like a cave at midnight.
--
-- And the framing. The camera, the pins, the HUDs, the text box, the move
-- animations and the depth of field are all identical, because every one of
-- them is hung off the arena's CELLS rather than off what is under them. That
-- is the same reason STADIUM A could be an option on the mode rather than a
-- second mode, and it is why this file is a few hundred lines and not a few
-- thousand.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local StadiumStage = {}
local floor = math.floor
local sin, cos = math.sin, math.cos
local pi = math.pi
-- ------- the shape of a disc
--
-- Radius in world pixels, where a map cell is 16 and the two mons stand three
-- cells apart.
--
-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size,
-- because the set's footprints run nearly tenfold: a Caterpie is under four
-- world pixels across and Moltres, wings out, is twenty-six. One radius for
-- both is either a dinner plate under the caterpillar or a doily under the
-- bird.
--
-- RADIUS is the floor, and it is the number most species land on -- it is a
-- little over the map cell a Pokemon is sized to cover, which is the
-- proportion the Game Boy's own battle platforms have. PAD is the margin
-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from
-- being handed something the frame cannot hold.
--
-- These are the FULL radius, out to where the fade has finished; the solid
-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a
-- mon's own footprint fits inside that centre rather than out over the
-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has
-- solid ground under its edges.
StadiumStage.RADIUS = 18
StadiumStage.MAX_RADIUS = 34
StadiumStage.PAD = 1.8
-- The platform for a mon of this footprint. `r` may be nil -- nothing is
-- standing there yet, which is every frame of the send-out before the model
-- appears, and it is also the whole of the flat 2D-3D B rung, where a
-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is
-- already a little over that. Either way the platform is the plain one, and
-- it has to be there BEFORE the Pokemon lands on it.
function StadiumStage.radiusFor(r)
local want = (r or 0) * StadiumStage.PAD
if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end
if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end
return want
end
-- Per-vertex shading, in the same terms StadiumRig lights the models with, so
-- a disc and the Pokemon standing on it agree about where the sun is. Fitted
-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each
-- axis term is half the spread between that axis's two faces.
local SHADE_BASE = 0.7725
local SHADE_X = 0.06
local SHADE_Y = 0.225
local SHADE_Z = 0.11
local function shadeFor(nx, ny, nz)
local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z
if s < 0.30 then return 0.30 end
if s > 1.00 then return 1.00 end
return s
end
-- ------- the texture
--
-- The platform is a FLAT painted disc that fades out at its rim -- no rim
-- wall, no thickness, the whole thing carried in one texture on one quad
-- lying on the ground plane. That is what the Game Boy's own battle
-- platforms are, and it is what keeps the stage from competing with the
-- Pokemon standing on it.
--
-- ------- why the fade is DITHERED
--
-- The scene shader discards any texel under half alpha outright (it has to:
-- that is what keeps a sprite's transparent corners out of the depth buffer).
-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut
-- at wherever the ramp crosses 0.5, which is the one thing this must not be.
--
-- The fade is therefore an ORDERED DITHER baked into the texture's alpha:
-- every texel is fully on or fully off, and the proportion that are on falls
-- away toward the rim. It is the same trick the sky already uses for its
-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any
-- other pass, and on a mode built out of visible texels it reads as intended
-- rather than as a limitation.
--
-- The COLOUR is deliberately neutral. Everything the shader does to it after
-- this is the environment's: Voxel3D.tint carries the hour outdoors and the
-- room's own flat light indoors, and the shadow pass darkens whatever the
-- Pokemon standing on it occludes. So one texture is a sunlit platform, a
-- dusk platform and a cave platform, without a variant for each.
StadiumStage.TEX = 128
-- Where the solid centre ends, as a fraction of the disc's radius. Inside
-- this everything is opaque; from here to the rim the dither thins out.
StadiumStage.SOLID = 0.76
local TOP = { 0.74, 0.71, 0.63 }
local TOP_ALT = { 0.67, 0.64, 0.57 }
local texture = nil
-- A small deterministic scatter, for the surface itself. Not a random one: an
-- authored constant that happens to look unpatterned is worth more here than
-- a seed, because it can never change under a different Lua.
--
-- Quantised into blocks, so the surface reads as TEXELS rather than as noise.
-- Per-pixel it came out as a fine mottle that fought the dithered rim for
-- attention -- and the rim is the thing worth looking at. At this size the
-- grain is roughly the size of the voxels everywhere else in the mode.
StadiumStage.GRAIN = 4
local function grain(x, y)
local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN
local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN
local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100
return v < 34
end
-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than
-- random because a random dither crawls: this pattern is fixed in the
-- texture, so the fade holds still while the camera drifts across it.
local BAYER = {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 },
}
function StadiumStage.texture()
if texture ~= nil then return texture or nil end
local ok, img = pcall(function()
local n = StadiumStage.TEX
local data = love.image.newImageData(n, n)
local half = (n - 1) / 2
local solid = StadiumStage.SOLID
for y = 0, n - 1 do
local dy = (y - half) / half
for x = 0, n - 1 do
local dx = (x - half) / half
local d = (dx * dx + dy * dy) ^ 0.5
-- how much of this texel's neighbourhood should survive: everything
-- inside the solid core, nothing past the rim, and a smooth ramp
-- between the two that the dither turns into a stipple
local cover
if d <= solid then
cover = 1.0
elseif d >= 1.0 then
cover = 0.0
else
local t = (d - solid) / (1.0 - solid)
cover = 1.0 - t * t * (3 - 2 * t) -- smoothstep, falling
end
local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64
local a = (cover > threshold) and 1 or 0
local c = grain(x, y) and TOP_ALT or TOP
data:setPixel(x, y, c[1], c[2], c[3], a)
end
end
local image = love.graphics.newImage(data)
-- nearest, like every other texture in this mode: the grain and the
-- stipple are both meant to read as texels. Clamped rather than
-- repeating now that one texture covers the whole disc.
image:setFilter("nearest", "nearest")
image:setWrap("clampzero", "clampzero")
return image
end)
texture = (ok and img) or false
return texture or nil
end
-- ------- the mesh
--
-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with
-- the whole texture stretched across it. The DISC is the texture's business,
-- not the geometry's -- everything outside the painted circle is alpha the
-- shader discards -- which is what "a flat texture that fades out at the
-- edges" means and what makes this four vertices rather than a hundred and
-- fifty.
--
-- Shaded as a face pointing straight up, because it is one.
local mesh = nil
local function build()
local s = shadeFor(0, 1, 0)
local verts = {
{ -1, 0, -1, 0, 0, s },
{ 1, 0, -1, 1, 0, s },
{ 1, 0, 1, 1, 1, s },
{ -1, 0, 1, 0, 1, s },
}
return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 })
end
function StadiumStage.mesh()
if mesh == nil then mesh = build() or false end
return mesh or nil
end
function StadiumStage.invalidate()
if texture and texture.release then pcall(texture.release, texture) end
if mesh and mesh.release then pcall(mesh.release, mesh) end
texture, mesh = nil, nil
end
-- How far under the ground plane the disc actually sits. A hair, and only so
-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not
-- coplanar with it and left to the depth buffer's mercy.
StadiumStage.SINK = 0.06
-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon
-- placed at that same height stands ON it rather than in it.
function StadiumStage.matrix(x, groundY, z, radius)
radius = radius or StadiumStage.RADIUS
return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z),
Mat4.scale(radius, 1, radius))
end
-- The two platforms this frame, as (side, matrix) -- shared by the camera's
-- pass and the sun's, so the two can never disagree about where they are.
local function each(arena, groundY, fn)
local ok, Stadium = pcall(V.require, "Stadium")
for _, side in ipairs({ "enemy", "player" }) do
local cell = arena[side]
if cell then
local footprint = ok and Stadium and Stadium.footprint(side) or nil
fn(StadiumStage.matrix(cell[1], groundY, cell[2],
StadiumStage.radiusFor(footprint)))
end
end
end
-- ------- the synthetic arena
--
-- A B rung does not search the map, because it does not stand on it. The
-- arena is the same WIDE shape every other staged fight uses -- so the two
-- cells are three apart down the middle and BattleCam frames them exactly as
-- it always has -- just placed at a fixed spot rather than a found one.
--
-- Away from the origin on purpose. The coordinates run through the camera
-- solve, the sun's frustum fit and the projection to Game Boy pixels, and
-- putting a stage at (0, 0) is the kind of thing that hides a sign error for
-- months.
StadiumStage.ORIGIN = { 16, 16 }
function StadiumStage.arena(map)
local BattleArena = V.require("BattleArena")
local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2],
"wide")
if not arena then return nil end
-- the map is carried for its SKY and its palette only -- what kind of place
-- the fight is happening in -- never for its geometry
arena.map = map
arena.discs = true
return arena
end
-- ------- the draws
-- The discs, in the main pass. No wireframe: everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that, and a
-- disc is a turned solid with no grid to draw.
function StadiumStage.draw(arena, groundY)
if not (arena and arena.discs) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
Voxel3D.seams(false)
Voxel3D.glass(false)
each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end)
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- And into the sun, so the two Pokemon put real shadows on the platforms they
-- are standing on. Without this the shadow map is empty where the discs are
-- and a mon casts onto nothing at all -- which, with no ground behind it
-- either, reads as the pair floating.
function StadiumStage.cast(shadowMap, arena, groundY)
if not (arena and arena.discs and shadowMap) then return end
local m = StadiumStage.mesh()
local tex = StadiumStage.texture()
if not (m and tex) then return end
each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end)
end
return StadiumStage