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 pathVoxelScene.lua
More file actions
1426 lines (1345 loc) · 64.3 KB
/
Copy pathVoxelScene.lua
File metadata and controls
1426 lines (1345 loc) · 64.3 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
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Voxel world mode: assemble and draw one frame of the 3D scene.
--
-- World space is world pixels and shares its origin with the 2D paths, so
-- the terrain mesh needs no transform at all and a connected map just
-- translates by the same (ox, oy) the flat renderer already offsets it by.
--
-- Order is: the sun's shadow pass, then terrain, then characters, then a 2D
-- overlay for the field FX. There is no y-sort anywhere -- the depth buffer
-- resolves occlusion, which is the whole point of the mode. Walk behind a
-- building and the building is simply in front.
-- 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 ShadowMap = V.require("ShadowMap")
local ChunkMesher = V.require("ChunkMesher")
local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local FirstPerson = V.require("FirstPerson")
local BattleBillboard = V.require("BattleBillboard")
local Pokedex = V.require("Pokedex")
local Perf = V.require("Perf")
local Diorama = V.require("Diorama")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local VoxelScene = {}
-- What the active display mode actually paints with.
--
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
-- any of the non-colour modes draw. The flat path runs it through
-- PaletteFX.effectiveColors on the way to the shade-remap shader, and that
-- call IS where GRAY, INVERTED and CLASSIC happen -- OG / OG INV replace
-- the palette with the DMG greys (inverted for the latter), CLASSIC
-- replaces it with the green DMG set, and GBC INV permutes the zone's own
-- shades. GBC and RED++ pass through untouched.
--
-- This pass has no shader to apply that in: colour is baked into the atlas
-- and into the sprite sheets ahead of the draw, so it has to run the same
-- transform itself. Without it every mode that is not already a colour mode
-- comes through wearing the SGB palette -- grey and inverted both rendering
-- as plain SGB blue.
local function modeColors(paletteFor, map)
local c = paletteFor and paletteFor(map) or nil
return PaletteFX.effectiveColors(c)
end
VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
-- air. Map.isOutdoor is the same test the engine uses for door SFX and the
-- town map, and the same one Structures already asks to decide whether a
-- map rings with trees.
--
-- The colour is a four-shade ramp shaped like a world palette so the
-- display mode can transform it exactly like one: GRAY gets a grey sky,
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
-- mismatch the terrain bake had.
--
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
-- same way by the same seam; they are separate because the flat one also has to
-- serve an indoor void and a battle's arena, which want a colour rather than a
-- sky.
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
{ 64, 120, 192 }, { 16, 40, 80 } }
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
-- the ramp as the display mode has it, which is the only form anything here
-- should be reading it in
local function skyRamp()
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
end
-- Full strength at every rung: the sky is painted wherever the diorama is.
--
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
-- camera up from flat, and the sky comes up with it over the first few degrees
-- rather than appearing whole on the keypress -- which is also what keeps a
-- top-down camera, where there is no void worth speaking of, from painting one.
local SKY_FADE_DEG = 8
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
if deg <= 0 then return 0 end
local t = deg / SKY_FADE_DEG
return t < 1 and t or 1
end
-- One shade off the sky ramp, transformed by the display mode, as an
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
-- proper; 4 is its darkest, which is what an indoor void wants).
function VoxelScene.skyShade(shade, alpha)
local shades = skyRamp()
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
end
-- The sky `map` stands under at strength `t`, or nil where there is no sky
-- to paint: indoors, or with the horizon out of frame.
--
-- One flat colour, which is what a caller that only needs something to clear the
-- void to wants -- the overworld battle's arena shot is one of those. The
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
local sky = VoxelScene.skyShade(SKY_SHADE, t)
-- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
-- gold at dusk, navy at night -- so a battle staged on the map at
-- midnight is under a midnight void, not a noon one. Free-roam is
-- unchanged by this: Sky.dress overwrites the fill with the same value.
local haze = Sky.haze()
if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
return sky
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
-- (lib/Sky.lua).
--
-- Only here, and deliberately. This is the sky the walking camera stands under,
-- where the horizon is a quarter of the way down the frame at the top rung and
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
-- placed camera whose horizon sits above the frame entirely, so it keeps the
-- flat fill it has always had -- there is no gradient to see from down there,
-- and the arena's look is not this rung's to change.
local function skyFor(map)
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
if not sky then return nil end
return Sky.dress(sky)
end
VoxelScene._skyFor = skyFor -- named for the suite
VoxelScene._skyStrength = skyStrength
-- A facing as a yaw about +Y, kept for callers that reason about which way
-- an entity points (the mod exports it). The character cards themselves
-- never yaw -- they face south and lean, like the flat game.
local YAW = {
down = 0,
up = math.pi,
right = math.pi / 2,
left = -math.pi / 2,
}
-- The ground height a cell stands at, so a character on a ledge stands on
-- top of it rather than sunk into it. Uses the same bottom-left collision
-- tile the engine walks on (Map:cellTile).
local function groundAt(map, cellX, cellY)
-- Off the map, cellTile border-extends into the map's borderBlock --
-- which on maps ringed with trees is a RAISED tile. The only entity
-- ever standing off-map is the player mid seam-step (placed one cell
-- before the connection entry), and the ground actually rendered
-- there is the departed neighbour's flat walkway: height 0. Without
-- this, crossing into such a map hoisted the walker tree-high for
-- exactly one step -- the "hops like a ledge" seam bug.
if not map:inBounds(cellX, cellY) then return 0 end
local shapes = TileShape.forMap(map)
local s = shapes[map:cellTile(cellX, cellY)]
if not s then return 0 end
-- a recessed class (water) still supports whatever stands on it; only
-- raised ground lifts the model. Stairs never do: the class height is
-- the flight's TALL end, but the player enters at floor level and the
-- warp fires as they step in -- lifting them onto the geometry read as
-- climbing an invisible block
if s.art == "stair" then return 0 end
return s.h > 0 and s.h or 0
end
VoxelScene.YAW = YAW
-- shared with the overworld battle, which stands its mons on map cells and
-- needs the same answer about what height "the floor" is there
VoxelScene.groundAt = groundAt
-- Camera-ward pull distance for billboards (and the grass rows, which
-- must keep their relative depth to feet): just enough that a leaned-back
-- slab clears the wall it leans over. The lean flattens toward top-down,
-- so the needed pull grows exactly as real occlusion stops mattering.
function VoxelScene.pull(a)
return 6 + math.max(0, 16 * math.cos(a) - 8) / math.max(math.sin(a), 0.2)
end
-- The sheet frame and mirror flag the 2D path would draw for this pose
-- (same tables as SpriteRenderer). Shared by the billboard pass and the
-- shadow pass so a walking character's shadow swings its legs too.
local function frameFor(def, facing, phase, flip)
local SR = require("src.render.SpriteRenderer")
local frame, mirror = 0, false
if (def.frames or 1) > 1 then
frame = (def.walker and phase == 1) and SR.WALK[facing]
or SR.STAND[facing]
mirror = facing == "right"
or ((facing == "down" or facing == "up") and phase == 1 and flip)
end
return frame, mirror
end
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
-- looks from the south", which is where the orbit always stands; a
-- first-person eye stands anywhere, so deep enough into the blend the
-- facing is remapped to how the pose looks from THERE -- walk behind an
-- NPC and their card wears the back sprite. Used by the camera draw and
-- the sun pass BOTH: the card the sun stored and the transform a lit card
-- reads its own shadowing with must describe the same frame, or the
-- mirror-flip half of the pair asks the map about texels the sun filed
-- under the other cheek.
-- The player's own card asks a different function for the same answer:
-- their body's bearing is what the camera is derived FROM, so it is known
-- continuously rather than as one of four directions, and measuring
-- against the compass point instead flicks the card to a profile for a
-- frame or two when the camera is spun fast (see playerFacing).
local function viewFacing(p)
if FirstPerson.cardBlend() > 0.5 then
if p.isPlayer then
return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
end
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
end
return p.facing
end
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
-- a decal: its current sprite frame as a single quad, flattened onto the
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
-- beginShadows, which supplies the translucent black; the texture is only
-- consulted for its alpha, so no palette work is needed.
local function drawShadow(sprite, px, py, facing, phase, flip, gh, lift)
local def = sprite.def
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
Voxel3D.draw(mesh, sprite:resolveImage(),
Voxel3D.shadowMatrix(px, py, gh, lift, mirror))
end
-- Where a billboard character's card stands: on the middle of its cell at
-- height `y`, pivoted at the feet and tipped back by exactly the camera's
-- pitch. The slab is built centred on its sprite plane (z = 0), so only the
-- x anchor shifts; the relief bulges symmetrically front and back of it.
--
-- Shared by the solid draw and the silhouette below, so the two can never
-- drift apart -- a silhouette standing anywhere but exactly behind the
-- figure would read as a second character.
--
-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed
-- about its feet to face the eye (cylindrical billboarding). A south-facing
-- card is invisible edge-on to an eye standing east of it, which no orbit
-- camera could ever do and a first-person one does constantly. The blend
-- carries one pose into the other -- the lean eases out as the yaw eases in
-- -- and cardBlend is zero for every camera that is not the first-person
-- rig, the battle's placed shot included, so nothing else moves.
-- The pitch the sprite cards lean back by -- normally the rung's own
-- camera angle, overridable in radians. VR sets the override to the top
-- rung's 75 degrees for every diorama and battle frame: a table watched
-- from a freely moving head has no one camera pitch for the cards to
-- match, and the near-upright top-rung lean is the pose that reads as
-- "standing" from anywhere around it. nil (the default, and the flat
-- screen always) leans with the rung as ever.
VoxelScene.spriteLean = nil
local function leanAngle()
return VoxelScene.spriteLean or V.require("VoxelState").angle
end
local function billboardMatrix(px, py, y, mirror)
local b = FirstPerson.cardBlend()
local m = Mat4.translate(px + 8, y, py + 8)
if b > 0 then
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
end
m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
end
local function billboardPull()
return VoxelScene.pull(math.max(leanAngle(), 0.05))
end
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
-- local space with its feet on y = 0, and this stands it at its drawn
-- position and tips it back by exactly the camera's pitch -- the same
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
-- around him. No cell centring: unlike a character he is not standing on a
-- cell, he is standing where he was drawn, which may straddle two.
--
-- First person turns him at the eye like the walkers (see billboardMatrix)
-- -- about his own middle, because unlike a character card his local space
-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about
-- his edge would swing him off his seat. The width rode in on the record
-- for exactly this (ChunkMesher.buildFigureMeshes).
local function figureMatrix(f, offX, offZ)
local b = FirstPerson.cardBlend()
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
local m = Mat4.translate(wx, f.y, wz)
if b > 0 and f.w and f.w > 0 then
local half = f.w / 2
m = Mat4.mul(m, Mat4.translate(half, 0, 0))
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
end
return Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
-- Voxel3D.casterMatrix does it for a character.
local function figureCaster(f, offX, offZ)
return Mat4.mul(
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.scale(1, 1, 0))
end
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
local function eachFigure(map, offX, offZ, draw)
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
end
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
-- modes (nil under RED++/trueColor): the 2D path colorizes sprites with a
-- screen-space shader the voxel canvas never runs through, so the model's
-- texture gets the palette baked in instead (TerrainAtlas.forSprite).
-- `lift` raises the figure off the ground plane (ledge hops arc UP in 3D,
-- where the 2D path could only slide the sprite north).
local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
lift)
local def = sprite.def
local tex = sprite:resolveImage()
if colors and not def.trueColor then
tex = TerrainAtlas.forSprite(def.image, colors) or tex
end
local y = gh + (lift or 0)
-- pick the very frame the 2D path would draw (same tables). The card
-- always faces SOUTH -- the direction the 2D game implies -- and only
-- LEANS BACK, pivoting at its feet, by exactly the camera's pitch, so
-- at every tilt level the sprite reads face-on like the flat game.
-- No camera-tracking yaw: every sprite leans in parallel.
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.mesh(def, frame)
if not mesh then return false end
-- Camera-ward pull (applied per vertex in the shader, along each
-- vertex's own eye ray, so it is a PURE depth bias with zero screen
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED -- and SNUGGED, exactly as the sun stored it
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
-- vertex asks whether the light reached it; see ShadowMap.snug for why
-- the lookup must match the stored transform to the letter
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)))
return true
end
VoxelScene.drawEntity = drawEntity
-- The player's silhouette, for wherever the scenery is standing in front of
-- them (Voxel3D.beginGhost inverts the depth test around this call).
--
-- The same flat card the solid pass and the sun pass draw. That it has no
-- self-overlap is what makes it safe here: with the depth test inverted, a
-- mesh carrying both front and back faces would read its own back faces as
-- "behind something" and repaint the figure on open ground, occluded or
-- not. One quad cannot do that, and cannot double-blend into a mottled
-- patch either. A silhouette is an outline, so an outline is the right
-- mesh for it.
local function drawGhost(p)
local def = p.sprite.def
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
local tex = p.sprite:resolveImage()
if p.colors and not def.trueColor then
tex = TerrainAtlas.forSprite(def.image, p.colors) or tex
end
local y = p.gh + (p.lift or 0)
Voxel3D.draw(mesh, tex, billboardMatrix(p.px, p.py, y, mirror),
billboardPull())
end
-- Render the world. `state` is the OverworldState; `vw`/`vh` the world view
-- size in world pixels; `w`/`h` the pixel size of the canvas to render
-- into; `paletteFor(map)` yields a map's 4-color world palette (nil in the
-- color modes whose atlas is already true color). Returns the finished
-- canvas, or nil if the 3D pass could not run (headless, no depth support)
-- so the caller can fall back to 2D.
-- The last live-set key, so eviction only runs when the neighbourhood
-- actually changes (a map crossing), not every frame.
local lastLiveKey = nil
-- The flat renderer keeps two connection hops for its survey zoom. In 3D that
-- can expose and build a large map beyond an intervening route (Viridian can
-- see Route 23 through Route 22), only for it to vanish at the next seam.
-- Direct connections cover every real map edge without that distant work.
local neighborSource, neighborRoot, directNeighbors, directMapIds, neighborMasks
local function neighborsOf(state)
if neighborSource == state.neighbors and neighborRoot == state.map.id then
return directNeighbors
end
local connected = {}
for _, conn in pairs(state.map.def.connections or {}) do
connected[conn.map] = true
end
directNeighbors = {}
directMapIds = { [state.map.id] = true }
neighborMasks = {}
for _, nb in ipairs(state.neighbors or {}) do
if connected[nb.map.id] then
directNeighbors[#directNeighbors + 1] = nb
directMapIds[nb.map.id] = true
end
end
neighborSource, neighborRoot = state.neighbors, state.map.id
return directNeighbors
end
local function mapIdsOf(state)
neighborsOf(state)
return directMapIds
end
local facingConnection = {
up = "north", down = "south", left = "west", right = "east",
}
local function preferredMapId(state)
local edge = state.player and facingConnection[state.player.facing]
local conn = edge and (state.map.def.connections or {})[edge]
return conn and conn.map or nil
end
-- Full meshes bake their border fill, so a neighbour promoted to the current
-- map must use masks expressed from its own origin. The engine's two-hop list
-- already contains every body needed to carve those rings correctly.
local function masksFor(state, map, ox, oy)
neighborsOf(state)
local masks = neighborMasks[map.id]
if masks then return masks end
masks = {}
local function add(other, x, y)
if other.id == map.id then return end
masks[#masks + 1] = { x - ox, y - oy,
x - ox + other.def.width * 32,
y - oy + other.def.height * 32 }
end
add(state.map, 0, 0)
for _, nb in ipairs(state.neighbors or {}) do
add(nb.map, nb.ox, nb.oy)
end
neighborMasks[map.id] = masks
return masks
end
VoxelScene._neighborsOf = neighborsOf
VoxelScene._masksFor = masksFor
VoxelScene._preferredMapId = preferredMapId
-- Request everything `state`'s frame wants and evict what it no longer
-- does; returns the current map's terrain mesh (or nil while it builds)
-- and the neighbour meshes ready to draw. render() calls this for the
-- frame it is drawing, and the pipeline's update hook calls it EVERY
-- frame -- including the frames a warp's Transition covers, when the
-- world pass is off. That update-side call is what lets a door fade hide
-- the destination's build: the map swaps behind the fade, and waiting
-- for the first visible frame to request meshes would show the flat
-- fallback while the first slices run.
function VoxelScene.prefetch(state)
local Voxel = V.require("VoxelState")
-- The live set is the current map plus its rendered neighbours. When
-- it changes, everything outside it (and the previous set, which
-- ChunkMesher retains so stepping into a house keeps the town warm)
-- is evicted -- meshes released, analysis dropped -- so memory stays
-- bounded by the neighbourhood instead of growing with every area
-- ever visited.
local liveKey = state.map.id
local live = { [state.map.id] = true }
for _, nb in ipairs(neighborsOf(state)) do
live[nb.map.id] = true
liveKey = liveKey .. "|" .. nb.map.id
end
if liveKey ~= lastLiveKey then
lastLiveKey = liveKey
ChunkMesher.setLive(live)
-- RED++ bakes one atlas per map, so its animated copy is per map too
-- and is bounded by the same neighbourhood
TerrainAtlas.setLive(live)
end
-- masks: where connected neighbour BODIES sit, so the border ring is
-- suppressed under them (see runGeometry)
local masks = masksFor(state, state.map, 0, 0)
-- Builds are asynchronous (ChunkMesher.pump runs in the pipeline's
-- update): request what this frame wants and draw what is ready.
-- A neighbour starts with its body mesh, then prebuilds and prefers the
-- masked FULL variant. If a fast crossing beats that build, the body stays
-- playable as the seamless fallback until its ring lands.
-- The water surface rides along with whichever variant answers: it was
-- cut out of that build's own geometry (ChunkMesher.pair), so the two
-- always come from the same slot and a lake is never drawn twice or left
-- as a hole.
ChunkMesher.request(state.map, false, masks, true)
local terrain, water = ChunkMesher.pair(state.map, false)
if not terrain then
terrain, water = ChunkMesher.pair(state.map, true)
end
local nbMesh, nbWater = {}, {}
local preferred = preferredMapId(state)
local preferredMap, preferredBodyOnly
for i, nb in ipairs(neighborsOf(state)) do
ChunkMesher.request(nb.map, true)
local body, bodyWater = ChunkMesher.pair(nb.map, true)
if body then
ChunkMesher.request(nb.map, false,
masksFor(state, nb.map, nb.ox, nb.oy))
end
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false)
if not nbMesh[i] then
nbMesh[i], nbWater[i] = body, bodyWater
end
if nb.map.id == preferred and not ChunkMesher.peek(nb.map, false) then
preferredMap, preferredBodyOnly = nb.map, not body
end
end
ChunkMesher.prefer(preferredMap, preferredBodyOnly)
local pending = ChunkMesher.pending()
if not terrain and pending > 0 then
-- Only cover a genuinely cold map. Cached body meshes remain playable
-- while their border fill finishes, even if that makes the ring pop in.
Voxel.beginLoading(state.map.id)
elseif Voxel.loading and Voxel.loadingMap == state.map.id and pending == 0 then
-- A failed build reaches pending=0 and releases the cover too, preserving
-- the vanilla fallback instead of an infinite loading screen.
Voxel.finishLoading(state.map.id)
end
Voxel.ready = terrain ~= nil and not Voxel.loading
return terrain, nbMesh, water, nbWater
end
-- Capture every entity's pose for this frame. pose() advances the hop /
-- surf bob / spinner timers, so it must be called EXACTLY once per entity
-- per frame -- the sun pass and the character pass then read the same
-- answer instead of disagreeing by a tick. Ghost NPCs live on a neighbour
-- map, so their position, ground lookup and palette all belong to that
-- map. pose() returns the VISUAL y (ledge hops arc it, surfing bobs it);
-- the difference from the entity's base y becomes vertical LIFT in 3D, so
-- a hop rises off the ground instead of sliding north.
-- Returns the pose list and, separately, the PLAYER's entry in it (nil
-- during a Fly animation, which draws the player itself and is skipped
-- below). Only that one entry gets the see-through treatment: NPCs and the
-- ghosts standing on a neighbour map are left to honest occlusion, because
-- it is only your own character you cannot afford to lose behind a roof.
local function posesOf(state, spriteColors)
local colors = spriteColors(state.map)
local posed = {}
local me = nil
local mapIds = mapIdsOf(state)
for _, g in ipairs(state.ghosts or {}) do
local map = g.map or state.map
if mapIds[map.id] then
local sprite, vx, vy, facing, phase, flip = g.npc:pose()
posed[#posed + 1] = {
sprite = sprite, px = vx + g.ox, py = g.npc.py + g.oy,
facing = facing, phase = phase, flip = flip,
gh = groundAt(map, g.npc.cellX, g.npc.cellY),
lift = g.npc.py - vy, colors = spriteColors(map),
}
end
end
for _, e in ipairs(state.entities or {}) do
if not (state.flyAnim and e == state.player) then
local sprite, vx, vy, facing, phase, flip = e:pose()
posed[#posed + 1] = {
sprite = sprite, px = vx, py = e.py,
facing = facing, phase = phase, flip = flip,
gh = groundAt(state.map, e.cellX, e.cellY),
lift = e.py - vy, colors = colors,
}
if e == state.player then
me = posed[#posed]
-- marked so the camera draw can leave the card out in first
-- person, where it would fill the lens from inside; the SUN pass
-- reads the same list and deliberately does not check the mark
me.isPlayer = true
end
end
end
return posed, me
end
-- ------- the glint's drive
--
-- A reflection is something the VIEWPOINT does, so the window glint is fed
-- by the camera's own travel rather than by a clock: its phase advances
-- with distance covered and its strength fades in over a few steps of
-- walking and back out within a beat of standing still. Stand still and
-- the glass is still; move and the light crosses it.
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
-- texels (see the scene shader), so this is a FRACTION of a texel per world
-- pixel walked -- one full pass of the glint across a pane per eight or so
-- cells of travel, with no frame ever jumping it far enough to strobe.
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
function VoxelScene.glintStep(g, cx, cy)
local dist = 0
if g.x then
dist = math.abs(cx - g.x) + math.abs(cy - g.y)
end
g.x, g.y = cx, cy
g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi)
if dist > 0.05 then
g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN)
else
g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT)
end
return g
end
local glint = {}
-- ------- the cast
--
-- Everybody standing on the map: the walkers, and the authored FIGURES the
-- tileset draws into its own furniture (they ARE characters as far as the
-- artwork is concerned, just ones drawn by the tileset instead of by a
-- sprite sheet, so they get the same lean and the same camera-ward pull).
--
-- One function because it is drawn TWICE and the two must be identical: once
-- into the frame, and once into the water's reflection copy (see drawWater --
-- Gen 1 draws people over the world, and water is world, so the cast cannot
-- be composited before the water it has to appear in).
--
-- Characters carry no wireframe out here, whatever the V-GRID row says. The
-- seams are what makes the WORLD read as built out of voxels, and the people
-- walking around in it are the one thing that should read as drawn instead --
-- a grid over a 16x16 sprite lands a line every couple of display pixels and
-- turns a face into a mesh. (The battle pass makes the opposite call for its
-- own combatants, deliberately -- see BattleBillboard.)
--
-- Sprite sheets until the figure pass: their texture coordinates mean
-- nothing to the tileset-shaped glass mask, so the glass is off or the
-- panes' atlas positions stripe the cast with lamplight at night.
local function drawCast(state, posed, atlasFor)
Voxel3D.glass(false)
Voxel3D.seams(false)
-- Characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
--
-- In first person two of them change: the player's own card is left out
-- (the eye is standing in it), and every other card wears the frame its
-- pose SHOWS this eye (viewFacing) rather than the one it shows the
-- south. Both run through here, so the water's reflection copy -- drawn
-- by this same function -- agrees with the frame to the pixel.
local hideMe = FirstPerson.hidePlayer()
for _, p in ipairs(posed) do
if not (p.isPlayer and hideMe) then
drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
p.colors, p.lift)
end
end
-- back on for everything textured from the atlas again -- figures, grass
-- and flowers all sample it, where the mask's coordinates are honest
Voxel3D.glass(true)
-- Figures after the walkers, so a player standing in front of the couch
-- wins the overlap -- the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull,
ShadowMap.snug(caster))
end)
for _, nb in ipairs(neighborsOf(state)) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
ShadowMap.snug(caster))
end)
end
-- and the seams are back on for the terrain art that follows: grass and
-- flowers are the world's own drawing, not people
Voxel3D.seams(true)
end
-- ------- the water pass
--
-- Between the terrain and everything that stands on it, because water is a
-- MIRROR and a mirror can only reflect what is already down: the ground, the
-- shoreline, the trees and buildings behind it, and the sky the frame opened
-- with.
--
-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1
-- draws people over the world and water is world, so a surfing player has to
-- composite OVER the water they are sitting on -- which puts them after it,
-- and a reflection can only hold what came before it. So `cast` is painted
-- into the reflection copy alone (Voxel3D.beginWater), where it is in the
-- picture the water reflects and not yet in the picture the water is drawn
-- into. Both draws go through drawCast, so they cannot come out different.
--
-- The ray march finds them the honest way round: a sprite is not in the
-- DEPTH buffer at that point, so a ray aimed at one passes through to the
-- terrain standing behind it and reads the copy there -- where the sprite is
-- already painted. The reflection lands a hair off the sprite's own depth
-- and exactly on its colour, which at a lake's worth of ripple is the same
-- picture.
--
-- `draws` is a list of { mesh, texture, model }. Nothing is a special case:
-- with the row OFF, no depth texture to read, or a shader that would not
-- build, the same meshes go through the ordinary scene shader and come out
-- as the flat animated water this mode always drew.
-- The overworld's alone: the staged battle draws its water plain, always --
-- its placed camera reads this pass wrong, and a stage set wants painted
-- water anyway (see BattleScene, where the choice is argued).
-- ------- and why the flat draw happens FIRST while the world is curved
--
-- The reflective pass writes no depth -- it cannot, the depth canvas is
-- detached for the length of it so the shader can READ it -- and it does its
-- own depth test against that texture instead. That test asks whether
-- something opaque is in front, and it answers correctly for every case but
-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so
-- no lake can hide another, and the pass simply paints them in mesh order.
--
-- On a flat world that never matters: every surface lies in the one plane
-- at its own recessed height, and a farther sheet always lands farther down
-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the
-- square of its distance, so the far side of the map swings down and back
-- up into the near field of view -- and a sheet of sea a hundred and fifty
-- tiles away, drawn later in the same mesh, paints straight over the pond
-- at the player's feet. Not a reflection of the far shore: the far shore
-- itself, rasterised on top of the water in front of you.
--
-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the
-- ordinary scene shader with depth writes on, and the reflective pass draws
-- over the top of what survived: the depth buffer now holds the water
-- surface, so the pass's own test throws the far sheet away, and the
-- reflection COPY holds it too, so a ray grazing another part of the lake
-- reads water rather than the void behind it.
--
-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY,
-- and it stays off -- the reflective pass tests only against terrain, as it
-- always did. Painting the surface into the depth texture turns the pass's
-- test into a comparison of the surface against ITSELF, which asks the two
-- rasterisations to agree to within interpolation error -- and on mobile
-- GPUs they don't reliably (that fight is what put the Android port back on
-- flat water). Confined to the curve there is no regression to reach: the
-- flat world never had the far-shore bug in the first place.
function VoxelScene.drawWater(draws, cast)
-- prepass only under the bend; see the header
local curved = (Voxel3D.curveK or 0) > 0
if curved then
for _, d in ipairs(draws) do
Voxel3D.draw(d[1], d[2], d[3])
end
end
local plain = not curved
if Water.enabled() and Voxel3D.depthReadable() then
local mirror, depth = Voxel3D.beginWater(cast)
local w, h = Voxel3D.size()
local ok = mirror and depth and Water.begin({
reflect = mirror, depth = depth,
vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0,
Voxel3D.curveZ or 0,
Voxel3D.curveK or 0 },
screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY,
skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(),
lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent,
})
if ok then
for _, d in ipairs(draws) do
Water.draw(d[1], d[2], d[3])
end
Water.finish()
plain = false
end
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
-- the shader and the depth mode BEFORE it can discover it cannot go on,
-- so a frame that bails halfway through has to be put back together
-- exactly like one that succeeded -- otherwise every pass after it runs
-- with no shader and no depth test.
Voxel3D.endWater()
end
-- the fallback flat draw -- unless the curve's prepass already put the
-- same meshes down, in which case a bailed frame is already whole
if plain then
for _, d in ipairs(draws) do
Voxel3D.draw(d[1], d[2], d[3])
end
end
end
-- Two stamps rather than one. Structural changes (meshes, view shape,
-- pitch, sun) must refresh immediately. Camera and character motion may use
-- the V-SRATE cadence: the map is world-anchored, so reusing it does not make
-- a shadow slide with the screen; it only delays newly moved silhouettes.
--
-- Pose records are sorted by VALUE before they enter the motion stamp. The
-- engine y-sorts entities every frame with no tie-breaker, so equal-y NPCs
-- can swap list positions even though the set of casters is unchanged.
-- Serialising raw list order made that harmless swap invalidate the entire
-- world shadow map.
local staticSigBuf, dynamicSigBuf, poseSigBuf = {}, {}, {}
local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
local sn, dn = 0, 0
local function static(v)
sn = sn + 1
staticSigBuf[sn] = v
end
local function dynamic(v)
dn = dn + 1
dynamicSigBuf[dn] = v
end
-- the view size and the camera PITCH are both what the light frustum is
-- fitted to (a lower camera sees further north, so the box grows), so a
-- zoom step, a window resize or a rung change invalidates the map even
-- standing perfectly still
static(vw); static(vh)
static(math.floor((V.require("VoxelState").angle or 0) * 512))
-- the sun itself: the cycle swings the shear as the clock runs, and a map
-- lit from somewhere new must be redrawn from there too. Quantised by the
-- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
-- few times a minute rather than every frame.
static(math.floor(ShadowMap.KX * 128))
static(math.floor(ShadowMap.KZ * 128))
-- and the first-person head: the box is fitted around wherever it looks
-- and the sprite cards swap frames as it circles them, so a turn on the
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
static(FirstPerson.signature())
static(tostring(terrain))
for i = 1, #nbMesh do
static(tostring(nbMesh[i]))
end
for i, p in ipairs(posed) do
poseSigBuf[i] = table.concat({
tostring(p.sprite.def.image),
tostring(p.px), tostring(p.py), tostring(p.gh),
tostring(p.lift or 0), tostring(p.facing), tostring(p.phase),
p.flip and "1" or "0",
}, "/")
end
for i = #posed + 1, #poseSigBuf do poseSigBuf[i] = nil end
table.sort(poseSigBuf)
for i = 1, #poseSigBuf do dynamic(poseSigBuf[i]) end
for i = sn + 1, #staticSigBuf do staticSigBuf[i] = nil end
for i = dn + 1, #dynamicSigBuf do dynamicSigBuf[i] = nil end
return table.concat(staticSigBuf, ","),
table.concat(dynamicSigBuf, ",")
end
-- Draw only spatial chunks intersecting the camera that beginScene installed.
-- The test uses the exact per-frame view-projection matrix, so this helper is
-- valid for every zoom, aspect ratio and placed camera. If chunk construction
-- was unavailable, the complete mesh remains the correctness fallback.
function VoxelScene.drawSpatial(fallback, chunks, texture, ox, oz, pull,
sunModel)
ox, oz = ox or 0, oz or 0
local model = (ox ~= 0 or oz ~= 0) and Mat4.translate(ox, 0, oz) or nil
if type(chunks) == "table" and #chunks > 0 then
for _, chunk in ipairs(chunks) do
if chunk.mesh and Voxel3D.visible(chunk, ox, oz) then
Voxel3D.draw(chunk.mesh, texture, model, pull, sunModel)
end
end
return
end
if ChunkMesher.isChunkToken(fallback) then return end
Voxel3D.draw(fallback, texture, model, pull, sunModel)
end
-- The sun pass: render the scene once from the light, so the main pass can
-- ask any fragment whether the sun reached it. Every caster the main pass
-- draws goes in -- the terrain mesh, which is where buildings, trees,
-- ledges, signs and every prop live, plus one UPRIGHT card per character
-- (Voxel3D.casterMatrix; the leaning slab is a trick for the camera, not
-- for the sun) -- so shadows land on walls, roofs, ledges and passing NPCs
-- as readily as on the floor.
--
-- Runs BEFORE Voxel3D.beginScene, because canvases do not nest. Grass is
-- left out on purpose: thousands of tufts would cast a speckle no bigger
-- than the pixels it lands on, at the cost of the mesh being drawn twice.
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
atlasFor, water, nbWater, battleCards, battleToken)
if not ShadowMap.available() then return end
local staticSig, dynamicSig =
shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- VR battle cards move with their animation, so refresh only the cheap
-- actor layer when their token advances.
if battleToken then
dynamicSig = dynamicSig .. "|btl" .. tostring(battleToken)
end
if ShadowMap.staticStale(staticSig, cx, cy) then
if not ShadowMap.begin(cx, cy, vw, vh) then return end
local function drawTerrain(map, mesh, ox, oz)
local chunks = ChunkMesher.chunksForMesh(map, mesh)
local model = (ox ~= 0 or oz ~= 0) and Mat4.translate(ox, 0, oz) or nil
if chunks and #chunks > 0 then
for _, chunk in ipairs(chunks) do
if ShadowMap.visible(chunk, ox, oz) then
ShadowMap.draw(chunk.mesh, nil, model, true)
end
end
return
end
if ChunkMesher.isChunkToken(mesh) then return end
local bounds = {
x0 = -96, x1 = map.def.width * 32 + 96,
y0 = -32, y1 = ShadowMap.HEIGHT,
z0 = -96, z1 = map.def.height * 32 + 96,
}
if ShadowMap.visible(bounds, ox, oz) then
ShadowMap.draw(mesh, nil, model, true)
end
end
local function drawFlowers(map, ox, oz)
local chunks = ChunkMesher.flowerChunks(map)
local model = (ox ~= 0 or oz ~= 0) and Mat4.translate(ox, 0, oz) or nil
local sunModel = ShadowMap.snug(model)
if chunks and #chunks > 0 then
for _, chunk in ipairs(chunks) do
if ShadowMap.visible(chunk, ox, oz) then
ShadowMap.draw(chunk.mesh, atlasFor(map), sunModel)
end
end
return
end
ShadowMap.draw(ChunkMesher.flowers(map), atlasFor(map), sunModel)
end
drawTerrain(state.map, terrain, 0, 0)
for i, nb in ipairs(neighborsOf(state)) do
drawTerrain(nb.map, nbMesh[i], nb.ox, nb.oy)
end
-- Water is a separate reflective mesh in the main pass, but still part
-- of the static world the sun sees. It is opaque to the depth-only pass.
ShadowMap.draw(water, nil, nil, true)
for i, nb in ipairs(neighborsOf(state)) do
ShadowMap.draw(nbWater and nbWater[i], nil,
Mat4.translate(nb.ox, 0, nb.oy), true)
end
-- Flowers and authored figures do not move in world space, so they live
-- with terrain. Their alpha cutout remains in this otherwise opaque pass.
drawFlowers(state.map, 0, 0)
for _, nb in ipairs(neighborsOf(state)) do
drawFlowers(nb.map, nb.ox, nb.oy)
end
-- Figures are static enough to cache with terrain, but remain people:
-- water ignores their classifier while ordinary surfaces receive them.
ShadowMap.sprites(true)
eachFigure(state.map, 0, 0, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster))
end)
for _, nb in ipairs(neighborsOf(state)) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster))
end)
end
ShadowMap.sprites(false)
-- The optional disc stage does not move, so it belongs to the cached
-- terrain layer rather than the per-frame actor layer.
pcall(function()
local stageArena, stageY = V.require("OverworldBattle").stage()
if stageArena and stageArena.discs then
V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0)
end