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 pathOverworldBattle.lua
More file actions
1524 lines (1428 loc) · 71 KB
/
Copy pathOverworldBattle.lua
File metadata and controls
1524 lines (1428 loc) · 71 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
-- Overworld battles: fights that happen on the map you were standing on.
--
-- The engine's battle is a screen: a white field with two pics on it, pushed
-- over a frozen overworld that stops drawing. This turns that white field
-- into the world -- the same terrain the free-roam mode extrudes, shot from
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
-- animation, faint slide and text box is the engine's own, drawn in the
-- engine's own order. What changes is what is behind them, and where the two
-- pics stand.
--
-- The sequence, from the moment something picks a fight:
--
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
-- plays over an empty map and no bystander is left standing in the
-- arena shot
-- 2. the engine's own transition wipes the screen (untouched: it is the
-- right wipe, picked by the right three bits)
-- 3. the battle draws over a live, window-resolution render of the arena,
-- with each mon PINNED to the cell it is standing on, the camera
-- drifting slowly enough to read as parallax, and a depth-of-field pass
-- holding the slab of world the two of them occupy sharp
-- 4. the battle ends, the cast comes back, and the player is exactly
-- where they were standing
--
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
-- real warp would have to survive trainer sight-lines, post-battle
-- dialogue, the blackout path and every script that assumes the player is
-- where it left them -- and it would have to put them back afterwards.
-- Moving the camera buys the whole shot and owes nothing back.
--
-- The feature declines cleanly rather than half-working: no depth support,
-- no open ground on the map, the row switched off, or a mesh still building
-- all end at the same place, which is the battle screen the engine has
-- always drawn.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local BattleArena = V.require("BattleArena")
local BattleCam = V.require("BattleCam")
local BattleScene = V.require("BattleScene")
local BattleDOF = V.require("BattleDOF")
local BattleHud = V.require("BattleHud")
local BattlePics = V.require("BattlePics")
local Voxel3D = V.require("Voxel3D")
local ChunkMesher = V.require("ChunkMesher")
local OverworldBattle = {}
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
-- second, which is how the glyph flip is checked from a shot run. Read
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
-- diagnostic must never be the reason the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end
OverworldBattle.KEY = "battles"
OverworldBattle.LABEL = "3D-BTL"
-- Five rungs. Two independent choices, laid out as one ladder because they
-- are one question to the player -- WHAT is standing there, and WHERE:
--
-- on the MAP on two DISCS
-- pics 2D-3D A 2D-3D B
-- models STADIUM A STADIUM B
--
-- 2D-3D A the mode this file was written for: the fight is staged on
-- the map and the two Pokemon are the GB's OWN PICS, stood up
-- on their tiles as quads (BattleBillboard).
-- 2D-3D B those same pics on a pair of DISCS against the sky, with no
-- map at all (see lib/StadiumStage.lua). The Game Boy's own
-- framing with the Game Boy's own art, in three dimensions --
-- and, like every B rung, it works everywhere, including the
-- caves and shop floors that have nowhere to stage a fight.
-- STADIUM A the staged fight with the Pokemon Stadium battle models in
-- place of those quads -- skinned, animated, and playing the
-- animation the move being used actually calls for (see
-- lib/Stadium.lua). The world is still the world: the fight
-- happens on real ground, in the map's own weather and light.
-- STADIUM B the models on the discs: both halves swapped at once.
-- OFF the engine's own white battle screen.
--
-- A and B is the STAGE and it is the same stage either way -- the discs do
-- not know what is standing on them and BattleScene draws them off
-- `arena.discs` alone, which is why the second column cost a value in this
-- table and nothing else. The four combinations are all reachable rather
-- than only the diagonal, because a player who cannot use the STADIUM rungs
-- -- no ROM, or a ROM they would rather not go and find -- should still be
-- able to have the disc framing, and because the discs are the answer to
-- "this map has nowhere to fight" whichever art is standing on them.
--
-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and
-- what an unrecognised stored value falls back to, and the stored value for
-- this row has been `true` since the row existed. Keeping `true` at the head
-- means every save written before the later rungs existed reads back as the
-- 2D-3D it was written for, and a mod whose headline is "the world in 3D"
-- still does not need the player to go and find the switch.
--
-- Every other stored value is likewise the one it has always been --
-- "stadium" from before there was a B, "stadiumB" from before there was a
-- flat one -- so no save loses the mode it chose.
--
-- Both STADIUM rungs are GATED on the models existing: the mod ships no
-- Pokemon Stadium data, and until the player's own ROM has been found and
-- built from (StadiumInstall) the row simply has two fewer stops. See
-- ModSetting.setGate for why they are skipped rather than shown and refused.
-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are
-- the game's own art, so it needs nothing the base game did not ship.
OverworldBattle.FLAT_B = "flatB"
OverworldBattle.setting =
ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
{ true, "flatB", "stadium", "stadiumB", false },
{ "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })
:setGate(function(value)
if value ~= "stadium" and value ~= "stadiumB" then return true end
local ok, install = pcall(V.require, "StadiumInstall")
return ok and install and install.available()
end)
-- Whether the fight stands on the two carried DISCS rather than on the map
-- -- the B column above, whichever row of it. Asked by stageFor (what to
-- stage on), wantsFront (whether this map needs an arena at all) and, once
-- the arena carries the answer as `arena.discs`, by BattleScene and
-- VoxelScene for what to draw.
--
-- Read straight off the row rather than through Stadium, because it is a
-- question about the STAGE and half the rungs that answer yes have no
-- Stadium models on them at all.
function OverworldBattle.discs()
local value = OverworldBattle.setting:get()
return (value == OverworldBattle.FLAT_B or value == "stadiumB")
end
-- Whether the VR row is ON -- read lazily, because VR requires modules
-- that sit above this one. While it is, this mode stops being optional:
-- the headset's battle seat, the pokedex screen and the effects plane
-- all assume a fight standing on the world, and a white-field battle
-- inside a headset is exactly the flat screen VR exists to replace.
local function vrOn()
local ok, vr = pcall(V.require, "VR")
return ok and vr and vr.enabled and vr.enabled() or false
end
function OverworldBattle.enabled()
if vrOn() then return true end
return OverworldBattle.setting:get() and true or false
end
-- Whether the STADIUM rung is the one selected -- read through Stadium so
-- there is one answer to that question and it lives with the mode it
-- describes. Required lazily: Stadium sits above this file and requires it
-- back (for the row), which a load-time require would deadlock.
function OverworldBattle.stadium()
local ok, stadium = pcall(V.require, "Stadium")
return (ok and stadium and stadium.enabled()) and true or false
end
-- ------- BACK SPRITES: the player's own mon stays on the menu
--
-- The staged shot stands BOTH mons on the map, which is the mode's whole
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
-- with its feet on the box. That silhouette is the series' shot.
--
-- So BACK SPRITES is offered as a middle setting rather than a compromise
-- imposed on everyone. With it on the foe is still geometry standing on its
-- tile at the far end of the arena, and the player's side goes back to being
-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
-- feet on row 96.
-- Nothing else about the shot moves -- the arena, the camera and the drift are
-- solved exactly as they were, so the foe stands where it always stood and the
-- player's cell is simply empty ground in the foreground.
--
-- OFF by default: what the mode advertises is the pair of them out there.
OverworldBattle.BACK_KEY = "battleBack"
OverworldBattle.BACK_LABEL = "BACK SPRITES"
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
OverworldBattle.BACK_LABEL,
{ false, true }, { "OFF", "ON" })
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
-- battle screen already draws exactly this. And held OFF under VR: the
-- headset stands both mons on the world -- a flat back pic pinned to the
-- 2D frame would keep your own mon off the arena the battle seat looks at.
function OverworldBattle.backPinned()
if not OverworldBattle.enabled() then return false end
if vrOn() then return false end
return OverworldBattle.backSetting:get() and true or false
end
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
-- text box, rather than geometry standing out on the map.
--
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
-- that holds the slot until "Go!" -- because that is the only pic this mod
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
-- tile whichever mode is on, and with the mode off the player's side is one
-- too, so both of those keep the open bottom that lets the arena through a
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
-- behind its lowest row, so its bottom edge seals.
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
-- it that way for the whole render -- during which the pic layer runs, and
-- picImage asks this. A nil test passes a `false` straight through to the
-- index below, and the error comes out of sideTexture into the pcall that
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
-- simply is not there.
function OverworldBattle.pinnedPic(battle, img)
if not (battle and img) then return false end
if not OverworldBattle.backPinned() then return false end
if img == battle.playerBackPic then return true end
local player = battle.player
return (player and img == player.sprite) and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
-- wrong twice over: it is turned away from the camera that is looking at it,
-- and the back pics are a different, smaller drawing made for a slot the
-- player never really sees. So the player's side asks for the FRONT pic too,
-- through the engine's own pokemon.sprite hook -- the seam that exists for
-- exactly this, so no battle code has to be touched to get it.
--
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
-- that mon is drawn in its own slot on the menu, seen from behind, and the
-- front art would be it turned round to face the player it belongs to.
--
-- Answered BEFORE a battle exists, because the battler is built before the
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
-- whether one on this map WOULD be -- the row is on, the 3D pass is
-- available, and the map has an arena -- which is the same question with the
-- same answer a moment later. Cached per map, because the arena search walks
-- the whole grid and this runs once per battler.
local staged = { mapId = nil, ok = false }
function OverworldBattle.wantsFront()
if not OverworldBattle.enabled() then return false end
if OverworldBattle.backPinned() then return false end
if not Voxel3D.available() then return false end
-- required here rather than through the file's own helper: this runs
-- while a battler is being built, which is before that helper is defined
local g = require("src.core.Game")
local ow = g and g.overworld
if not (ow and ow.map and ow.player) then return false end
-- a B rung carries its own stage, so the answer is yes on every map and
-- there is nothing to search or to cache
if OverworldBattle.discs() then return true end
if staged.mapId ~= ow.map.id then
local ok, arena = pcall(BattleArena.find, ow.map,
ow.player.cellX, ow.player.cellY,
ow.player.surfing)
staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
end
return staged.ok
end
-- ------- where the engine's own pics stand
--
-- The GB draws the player's back pic with its feet on the text box at row 96
-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
-- points are the pics' FEET, they hold for every species at every scale (the
-- engine's placement helpers pin the bottom edge and the centre), and they
-- are what BattleCam is solved to put the two arena cells under.
--
-- Which makes the pin a subtraction: whatever the drift has done to the
-- camera this frame, each pic moves by its own cell's projected position
-- minus its anchor. At the middle of the drift that is zero.
OverworldBattle.ANCHOR = {
player = { 26, 96 },
enemy = { 124, 56 },
}
-- ------- how big a mon is
--
-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
-- keeps every pixel the artist drew, and the CAMERA is solved so that one
-- overworld square is that big on screen (see BattleCam). The mon fits its
-- tile because the tile was sized to the mon, not the other way round.
OverworldBattle.SLOT_W = { front = 56, back = 32 }
-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
-- the anchors above.
OverworldBattle.HUD_RECT = {
enemy = { 8, 0, 80, 32 },
player = { 72, 56, 88, 40 },
}
-- ------- the box at the bottom, on the same glass
--
-- The HUDs got frosted panels because black glyphs on grass are not readable.
-- The battle's text box and its menu had the opposite problem and the same
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
-- the field's own colour when the field was white and is a sheet of paper laid
-- over the bottom third of the diorama now that it is not.
--
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
-- frosted glass and laid back down translucent, with the border and the text
-- drawn over it unchanged, and the same brightness verdict flipping the ink
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
-- the engine draws inside the box is still the engine's own, in its own place.
--
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
-- READ-ONLY duplicates of that function's own branches, the same kind of
-- mirror hudLive is and for the same reason: there is no seam that reports "a
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
-- The worst a future engine change can do is frost a rectangle nothing lands
-- on, or leave a box unfrosted -- never break a battle.
--
-- Each rect stops where the next one starts rather than overlapping it: two
-- panels over the same pixels would frost it twice and leave a visible step
-- along the seam.
OverworldBattle.TEXT_RECT = {
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
-- above the box above -- its last tile row sits inside that one
moves = { 0, 64, 88, 32 },
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
mimic = { 0, 56, 128, 40 },
}
-- How far apart the two anchors are: the spacing every move animation was
-- authored against, and so the yardstick the live pair is measured with.
OverworldBattle.ANCHOR_SPAN = math.sqrt(
(OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2
+ (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)
-- The effects layer's scale for this shot: how far apart the two mons
-- actually are on screen, over how far apart the slots they were authored
-- for were. Clamped hard at both ends -- an effect is pixel art and a wild
-- factor is worse than a slightly wrong one -- and held at exactly 1 when
-- the marks coincide, which is a projection about to degenerate rather
-- than a pair that has genuinely closed up.
OverworldBattle.ANIM_SCALE_MIN = 0.5
OverworldBattle.ANIM_SCALE_MAX = 2.0
function OverworldBattle.animScale(shot, px, py)
if not (shot and shot.enemy and px and py) then return 1 end
local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py
local span = math.sqrt(dx * dx + dy * dy)
if not (span > 1) then return 1 end
local k = span / OverworldBattle.ANCHOR_SPAN
return math.max(OverworldBattle.ANIM_SCALE_MIN,
math.min(OverworldBattle.ANIM_SCALE_MAX, k))
end
function OverworldBattle.textRects(battle)
if not battle or battle.blankForAskName then return {} end
if battle.bottomUIVisible and not battle:bottomUIVisible() then return {} end
local r = OverworldBattle.TEXT_RECT
local out = { box = r.box }
if battle.phase == "moveSelect" then
out.moves = r.moves
elseif battle.phase == "mimicSelect" then
out.mimic = r.mimic
end
return out
end
-- ------- the HUDs, out at the window's own edges
--
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
-- whole of it. That left both HUD blocks huddled together in the middle of the
-- frame with map showing on either side of them, which reads as a Game Boy
-- screenshot pasted over a diorama rather than as the diorama's own furniture.
--
-- So each block is snapped to its own side: the foe's to the left edge of the
-- window, the player's to the right. Nothing about either block changes -- same
-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
-- of the GB screen there is nowhere to go and the snap is a no-op.
--
-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
-- canvas and everything outside it is clipped away. So the layer is rendered to
-- a texture and composited into the WORLD image instead, which is the one
-- surface in this mode that covers the whole window.
-- The rows each block is cut out of, full width. Generous on purpose:
-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
-- further than the panel does, and the pokeball rows and the safari ball count
-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
-- outside rows 0-96, and the two bands split that between them.
OverworldBattle.HUD_BAND = {
enemy = { 0, 0, 160, 48 },
player = { 0, 48, 160, 48 },
}
-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
-- glass is cut to, plus the x its band is blitted at.
--
-- The foe's panel starts at the window's left edge and the player's ends at the
-- right one. The vertical is untouched, so both stay on the rows the GB put
-- them on. A band's own origin sits outside the window by the panel's inset --
-- the couple of pixels a HUD shake can push past the edge are clipped there,
-- which is the whole cost of the snap and is invisible.
function OverworldBattle.snapRects(shot)
local s = shot.scale
local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
local ex = -e[1] * s -- foe: panel's left edge to 0
local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side
local rects = {
enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
}
return rects, { enemy = ex, player = px }
end
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
-- where it always was -- but its glass is laid into the world image alongside
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
local function toWorld(rect, shot)
local s = shot.scale
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
rect[3] * s, rect[4] * s }
end
-- ------- the live battle
--
-- nil when no overworld battle is running. Never more than one: battles do
-- not nest.
local session = nil
local function isIOS()
return love.system and love.system.getOS and love.system.getOS() == "iOS"
end
local function game()
return require("src.core.Game")
end
-- Whether this frame's HUDs went out to the window's edges instead of being
-- drawn in the GB frame. False whenever the composite could not be made, which
-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
local function snapped()
return (session and session.snapped) and true or false
end
-- Put the map's cast back. Both lists are handed back by identity, so
-- anything that captured one before the battle still sees the same table.
local function restoreCast()
if not (session and session.state) then return end
if session.entities then session.state.entities = session.entities end
if session.ghosts then session.state.ghosts = session.ghosts end
session.entities, session.ghosts = nil, nil
end
-- Cull them. The player stays -- they are not an NPC, they are who the
-- battle belongs to, and Fly/surf animations and the save's own capture read
-- state.player through this list.
--
-- Only the DRAW lists are touched, and only while the overworld is frozen
-- underneath a battle: StateStack updates the top state alone, so nothing
-- walks, wanders, triggers or collides against a list that is short for
-- these frames. The originals go back at battle.ended.
local function cullCast(state)
session.entities = state.entities
session.ghosts = state.ghosts
state.entities = { state.player }
state.ghosts = {}
end
-- ------- one right battle layout
--
-- Everything this file composes is measured in the GB's own 160x144 frame: the
-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
-- the frosted panels are cut to, and the full-frame white intercepted to let
-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
-- -- the mons would stand where no camera was solved for them, and the panels
-- would land beside the HUDs they are supposed to be under.
--
-- So while a fight can be staged on the map there is one right answer, and it
-- is SET rather than worked around. The engine reads the option live
-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
-- top state for its surface the same way), so writing it here lands on the
-- battle being pushed as well as every one after it.
--
-- This is the last line rather than the first: the OPTIONS menu takes the row
-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
-- player is never offered a switch that gets reverted under them. What reaches
-- here is a value that arrived some other way -- a save written before the mod
-- was installed, the mod manager's own page, another mod.
function OverworldBattle.forceOG(g)
g = g or game()
local opts = g and g.save and g.save.options
if not opts or opts.battleLayout ~= "wide" then return false end
opts.battleLayout = "og"
if g.writeOptions then pcall(g.writeOptions, g) end
return true
end
-- Where THIS fight stands, on whichever rung is running: the map's own
-- ground, or the pair of discs a B rung carries with it.
--
-- The one place the two columns actually diverge, and it is worth stating
-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a
-- map whose authored entry is a refusal -- and the battle then plays exactly
-- as the vanilla game does. A B rung cannot fail: its stage is not something
-- the map has to have room for, so a fight in the tightest cave in Kanto is
-- staged as readily as one on Route 1.
function OverworldBattle.stageFor(state)
if OverworldBattle.discs() and Voxel3D.available() then
local okStage, arena = pcall(function()
return V.require("StadiumStage").arena(state.map)
end)
if okStage and arena then return arena end
-- the discs could not be built; fall through to the map, which is a
-- worse picture but a real one
end
local okFind, arena = pcall(BattleArena.find, state.map,
state.player.cellX, state.player.cellY,
state.player.surfing)
return (okFind and arena) or nil
end
-- Stage a battle triggered from `state`, if this mode can. Returns true when
-- a session started -- which is also the only case where anything visible
-- changes, so a map with no room for an arena plays exactly the vanilla
-- battle it always did, cast and all.
function OverworldBattle.begin(state, battle)
OverworldBattle.finish()
if not OverworldBattle.enabled() then return false end
if not (state and state.map and state.player) then return false end
if not Voxel3D.available() then return false end
local arena = OverworldBattle.stageFor(state)
if not arena then return false end
-- the fight is staged from here on, so the layout it is composed for is not
-- optional any more (see forceOG)
OverworldBattle.forceOG()
session = { state = state, arena = arena, battle = battle, shot = nil,
armed = false, token = 0 }
cullCast(state)
BattleCam.reset()
-- and, on the STADIUM rung, the pair of models that will stand on this
-- arena's two cells. Declines quietly on any other rung.
pcall(function() V.require("Stadium").begin(arena) end)
return true
end
-- The fallback entry point: a battle that arrived without going through the
-- overworld's own pushBattle (a link battle, a script pushing a BattleState
-- directly). Nothing visible depends on the cull for those -- the wipe has
-- already been and gone -- but the arena still has to be picked.
function OverworldBattle.ensure(battle)
if session then
-- a battle pushed through the overworld reaches begin() before it is
-- built far enough to draw; battle.started is where it is finished
if battle and not session.battle then session.battle = battle end
return
end
local g = game()
local ow = g and g.overworld
if ow and ow.map then OverworldBattle.begin(ow, battle) end
end
-- The arena this battle is staged on, or nil. Read by the shot driver so a
-- screenshot can be labelled with the ground it was taken on.
function OverworldBattle.arena()
return session and session.arena or nil
end
function OverworldBattle.finish()
if not session then return end
restoreCast()
session = nil
Voxel3D.camera = nil
pcall(function() V.require("Stadium").finish() end)
end
-- ------- per-frame
--
-- Driven from the voxel pipeline's update hook, which the engine ticks every
-- frame regardless of which state is on top -- including the frames the
-- transition wipe covers, which is what gets the arena's meshes built before
-- the first battle frame needs them.
--
-- The scene is rendered HERE rather than inside the battle's draw, because
-- update runs with no canvas bound: a 3D pass that binds a depth target and
-- unbinds to the screen when it is done cannot do that in the middle of
-- someone else's frame without putting the frame back itself.
function OverworldBattle.update(dt)
if not session then return end
local g = game()
local top = g and g.stack and g.stack:top()
local ow = g and g.overworld
-- A battle that ended without saying so (a script tearing the state down,
-- a path that never emits battle.ended) would otherwise leave the cast
-- culled for good. Armed only once something has actually covered the
-- overworld, because begin() runs while the overworld is still on top.
if top ~= nil and top ~= ow then
session.armed = true
elseif session.armed then
OverworldBattle.finish()
return
end
-- Whether the shot is the player's to steer at all. BACK SPRITES pins
-- their own mon to the GB's slot on the menu while the foe stands out on
-- the map, and there is no angle that half-framed, half-solid
-- composition survives -- so under it the camera holds the shot the rig
-- was solved for (the slow drift aside, which was always there). Polled
-- per frame rather than latched at battle start: the row is reachable
-- from the mod manager's page mid-session.
BattleCam.steerable = not OverworldBattle.backPinned()
-- the right stick, read as a rate before the rig is built from it: the
-- wheel, the keys, the mouse and a drag all arrive as events and have
-- already landed, but a stick is a HELD position and only a tick can
-- turn it into travel (CamControl, which owns every one of those inputs)
pcall(V.require("CamControl").tick, dt)
BattleCam.update(dt)
-- the battle only exists once it has been pushed; a session opened at
-- pushBattle time has it, one opened from battle.started was handed it
session.battle = session.battle or (top ~= ow and top or nil)
-- the world pass is hidden behind the battle, so mesh builds get the wide
-- slice: nothing visible can hitch on them
ChunkMesher.pump(true)
-- The STADIUM models, ahead of the pics, because what they decide is
-- WHICH pics are needed: a side a model is standing on gets no billboard
-- texture rendered for it at all (see Stadium.covers). Posed and skinned
-- here too, once for the frame -- the sun pass, the camera and, in a
-- headset, both eyes all draw the same skinned meshes.
pcall(function()
local host = (session.arena and session.arena.map) or session.state.map
V.require("Stadium").update(dt, session.battle,
BattleScene.groundY(host, session.arena))
end)
-- The mons' textures are rendered HERE, with no canvas bound, for the same
-- reason the scene is: the pics layer binds its own targets, and doing that
-- inside somebody else's frame means putting the frame back afterwards.
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
if not okTex then textures = nil end
-- stashed for the VR eye pass, which stands these same pics on the map
-- in ITS view of the world (VoxelScene's eyes path). Stashed HERE
-- because rendering them binds canvases, which the eye pass -- mid-scene
-- when it wants them -- must never do; reading a stashed canvas is free.
session.textures = textures
-- and the move-animation layer, for the same eyes -- rendered only
-- while a headset is actually watching, because only the VR world
-- pass draws it (the flat screen has the animations in-frame already)
session.animTex = nil
local okVR, vrOn = pcall(function()
local vr = V.require("VR")
return vr.active and vr.active() or false
end)
if okVR and vrOn and session.battle then
local okA, anim = pcall(OverworldBattle.animTexture, session.battle)
if okA then session.animTex = anim end
end
session.token = (session.token or 0) + 1
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
textures, session.token)
if not ok then
-- One failure retires the arena for THIS battle and nothing else: the
-- battle screen carries on as the engine's own, the free-roam pipeline
-- this runs inside keeps rendering the overworld, and the next battle
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
-- guard, which retires a pipeline for the session.
session.shot = nil
session.snapped = false
session.broken = true
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
.. "on the plain battle background", tostring(shot))
return
end
session.snapped = false
if shot and shot.canvas then
-- the depth of field is measured off the two marks: the slab in focus is
-- the one the mons are standing in, at whatever the drift has done to
-- where that lands
local y1 = shot.ly + shot.player[2] * shot.scale
local y2 = shot.ly + shot.enemy[2] * shot.scale
local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
focusY, band, range)
if okDof and blurred then shot.canvas = blurred end
-- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
-- so a panel over a blurred far field is frosted from what is actually
-- behind it
pcall(BattleHud.build, shot.canvas)
-- and then the HUDs go ON that backdrop, snapped out to the window's own
-- edges (snapHUDs). Here rather than in the battle's draw for the same
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local ios = isIOS()
local okHud, up = false, false
if not ios then
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
end
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not ios and not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
end
end
session.shot = shot
end
-- The finished shot for this frame, or nil when there is none and the battle
-- should draw the way it always did.
function OverworldBattle.shot()
if not session or session.broken then return nil end
local s = session.shot
if s and s.canvas then return s end
return nil
end
-- The staged fight's WORLD-side pieces, for a pass that stands the mons in
-- its own view of the map rather than in the arena's composed shot -- the
-- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed
-- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets
-- per-eye cards), the live textures table (for the hit-flash flag), and the
-- token the shadow signature keys on. nil while nothing is staged, the
-- arena is broken, or the pics have not been rendered yet.
function OverworldBattle.worldCards()
if not (session and session.arena and not session.broken) then return nil end
local tex = session.textures
if not tex then return nil end
local host = (session.state and session.state.map) or nil
if not host then return nil end
local groundY = BattleScene.groundY(host, session.arena)
return BattleScene.monCards(session.arena, groundY, tex), tex, session.token
end
-- The live session's BATTLE STATE, once the pushed battle has been met
-- (session.battle fills in from the stack in update). The VR quad reads
-- it to tell "the battle screen is on top" from "a menu is over the
-- battle" -- the UI-only panel is right for the first and wrong for the
-- second. nil with no session, a broken one, or a battle not yet pushed.
function OverworldBattle.battle()
if not (session and not session.broken) then return nil end
return session.battle
end
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
-- transparent canvas of its own. This is what stands the effects up in
-- the VR eyes' world -- see worldAnim below -- the same move the pics
-- made through sideTexture: let the engine draw what it always draws,
-- catch it on a canvas, stand the canvas in the scene.
local animLayer = nil
-- the engine's own drawAnimLayer, captured by install(). Declared HERE,
-- above the function that reads it: a local declared further down the
-- chunk would leave this function reading a global of the same name --
-- nil forever, and the effects silently absent from the eyes (the bug
-- this comment is the tombstone of).
local innerAnim = nil
function OverworldBattle.animTexture(battle)
if not (innerAnim and battle) then return nil end
if not (love.graphics and love.graphics.newCanvas) then return nil end
if not animLayer then
local ok, c = pcall(love.graphics.newCanvas,
BattleScene.GB_W, BattleScene.GB_H)
if not (ok and c) then return nil end
pcall(c.setFilter, c, "nearest", "nearest")
animLayer = c
end
local g = love.graphics
local prevCanvas = g.getCanvas()
local ok = pcall(function()
g.push("all")
g.origin()
g.setCanvas(animLayer)
g.clear(0, 0, 0, 0)
g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1)
innerAnim(battle, false)
g.pop()
end)
if not ok then pcall(g.pop, g) end
if prevCanvas then pcall(g.setCanvas, g, prevCanvas)
else pcall(g.setCanvas, g) end
return ok and animLayer or nil
end
-- The staged fight's effects, for the VR eyes: the animation layer plus
-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit
-- authored at a slot lands on the mon standing in for that slot). nil
-- while nothing is staged or no layer was rendered this frame.
function OverworldBattle.worldAnim()
if not (session and session.arena and not session.broken) then return nil end
local tex = session.animTex
if not tex then return nil end
local host = (session.state and session.state.map) or nil
if not host then return nil end
local groundY = BattleScene.groundY(host, session.arena)
local model = BattleScene.fxCard(session.arena, groundY,
OverworldBattle.ANCHOR)
if not model then return nil end
return tex, model
end
-- Where the staged fight STANDS -- the arena and its floor height -- for a
-- camera that wants to look at it rather than draw it (the VR battle
-- mount). Answered as soon as the stage exists, textures or not: the
-- camera should be seated behind the fade before the first pic lands.
-- nil whenever no fight is staged on the world.
function OverworldBattle.stage()
if not (session and session.arena and not session.broken) then return nil end
local host = (session.state and session.state.map) or nil
if not host then return nil end
return session.arena, BattleScene.groundY(host, session.arena)
end
function OverworldBattle.invalidate()
BattleDOF.invalidate()
BattleHud.invalidate()
BattlePics.invalidate()
-- the STADIUM models hold meshes and textures of this graphics context
-- like everything else here does
pcall(function() V.require("Stadium").invalidate() end)
end
-- ------- the battle screen's background
--
-- BattleState opens by filling 160x144 white -- that fill IS the battle's
-- background, and in the colorized pipeline it is also the BG canvas's clear
-- (nothing else clears it, so skipping it outright would ghost last frame).
-- So for the length of one draw, that one call is intercepted: on the two
-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
-- composites the HUD and the text box over the arena and leaves the empty
-- field showing it; on the screen it is simply dropped, because the UI canvas
-- has already been cleared transparent for the world to show through.
--
-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
-- move-animation flash (which is white at 0.85) all pass through untouched.
--
-- This is a shim over love.graphics and it is the one invasive thing here,
-- so it is scoped as tightly as it can be: installed around a single call,
-- removed on the way out including on error, and never live outside a battle
-- frame this mode is drawing.
local function withoutBackgroundFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, x, y, w, h, ...)
if mode == "fill" and x == 0 and y == 0
and w == BattleScene.GB_W and h == BattleScene.GB_H then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 then
-- Two different full-frame whites, both replaced rather than drawn.
--
-- OPAQUE is the battle's background, and on the offscreen canvases it
-- doubles as their clear, so there it becomes a transparent one.
--
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
-- flash; over a world it whites out the map, the HUD and the text box
-- together. BattleScene puts it back on the mons alone.
if a > 0.99 then
local target = g.getCanvas()
if target ~= nil
and (target == battle.bgCanvas or target == battle.waveCanvas) then
g.clear(0, 0, 0, 0)
end
end
return
end
end
return rectangle(mode, x, y, w, h, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the box, without its paper
--
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
-- opaque slab the frosted panel underneath is there to replace. So for the
-- length of one drawTextArea the white fills are dropped and everything else
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
-- always did, over the glass instead of over paper.
--
-- Every fill drawTextArea issues is one of those: the box's own, and the two
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
-- the border glyphs that hardware would have overwritten. Both are opaque
-- white, both are paper, and both go.
--
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
-- installed around a single call, removed on the way out including on error,
-- never live outside a battle frame this mode is drawing.
local function withoutBoxFill(battle, fn)
local g = love.graphics
local rectangle = g.rectangle
g.rectangle = function(mode, ...)
if mode == "fill" then
local r, gr, b, a = g.getColor()
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
end
return rectangle(mode, ...)
end
local ok, err = pcall(fn, battle)
g.rectangle = rectangle
if not ok then error(err, 0) end
end
-- ------- the hour's light, on a pic that is not geometry
--
-- Everything standing in the arena goes through the voxel shader, and that
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
-- night it goes blue, and the two mons' cards go with it because they are
-- drawn in the same pass as the ground they stand on.
--
-- A back pic pinned to the menu is not in that pass. It is the engine's own
-- flat blit over the finished shot, so it arrived at noon while the world
-- behind it was at midnight -- a mon lit by nothing in the frame.
--
-- So the tint is applied by hand, to that one draw. Every colour the pics
-- layer sets is multiplied on its way past, which is the whole of it: the
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
-- slide's fade and the blink's own colour all compose with it rather than
-- being overwritten.
--
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
-- standing under a tree is darker than the tint alone, and this pic has no
-- position in the scene to be shadowed at. It carries the hour and not the
-- weather, which is the part the eye reads.
local function withTint(tint, fn, ...)
if not tint then return fn(...) end
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
local gfx = love.graphics
local setColor = gfx.setColor
gfx.setColor = function(cr, cg, cb, ca, ...)
if type(cr) == "table" then
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
cr[4] }, cg, ...)
end
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
end
local ok, err = pcall(fn, ...)
gfx.setColor = setColor
-- the layer leaves whatever colour it last set, and that one is tinted;
-- hand the next caller plain white rather than a dimmed one
setColor(1, 1, 1, 1)
if not ok then error(err, 0) end
end
-- ------- the mons, as textures for the 3D pass
--
-- The two Pokemon are not composited over the world any more: they are quads
-- standing in it (see BattleBillboard). What that needs from the battle
-- screen is a TEXTURE per side -- and the honest way to get one is to let the
-- engine draw its own pics layer, unchanged, into a canvas.
--
-- So the layer is rendered twice, once per side, with the other side
-- falsified out of existence by nulling exactly the fields its branches
-- test. Everything the engine does to a pic comes along for free that way:
-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
-- faint slide, the damage blink, the squish, every SE displacement. None of
-- it is reimplemented and none of it can drift.
--
-- Two things are forced during that render. The scale, to 1, so the texture
-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
-- placement, so the pic lands centred on a known column with its feet on a
-- known row. That known point is what the quad is then hung from.
local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline
local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
-- Which side is being rendered, or nil. The placement wrappers read it.
local texturing = nil
local texCanvas = {}
local innerPics = nil -- captured by install()
local innerHUDs = nil -- likewise, for the snapped HUD layer
-- (innerAnim, their sibling, is declared up beside animTexture, which
-- sits earlier in the chunk than this group and must see the local)
local function texCanvasFor(side)
local c = texCanvas[side]
if c then return c end
local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
BattleScene.GB_H, { dpiscale = 1 })
if not ok then return nil end
made:setFilter("nearest", "nearest")