arena_editor.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
-- Driver: the interactive battle-arena editor. -- -- data/battle_arenas.lua holds one authored spot per area -- where that map's -- 3D battles are staged. tests/arena_pick.lua AUTHORS that file in a batch: -- it takes the search's answer for every map, photographs each one, and -- leaves a pile of PNGs and PICK lines to be read afterwards. This is the -- other way round. It is one window you sit in front of: pick a map, slide -- the arena around on a plan of it, watch the fit and the sightlines answer -- as you move, stage a REAL battle on the spot to see what the shot actually -- looks like, and commit. Committing rewrites the config. -- -- The plan is the MAP'S OWN ART -- the tiles extracted from the cartridge, -- drawn from the game's own atlas through the game's own palette -- with the -- walk grid washed over it, because "where may an arena stand" and "what is -- it standing in" are two different questions and a spot has to answer both. -- -- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua \ -- "/c/Program Files/LOVE/lovec.exe" . -- -- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so -- the console is attached: every commit prints its line there too, and the -- console transcript alone is enough to redo a session by hand. -- -- ------- the keys -- -- arrows move the arena's north-west corner (hold shift for 5 cells) -- [ ] previous / next map -- s cycle the SHAPE -- wide (3x6, with the apron) or narrow (1x4) -- r TURN the whole staging a quarter (shift: the other way) -- -- footprint, both mons and the camera together, so the picture -- is unchanged and only the ground under it moves. What it is -- for: a long arena down a corridor that runs east-west, and -- swapping what stands behind the pair -- c cycle the LENS -- the long default, or cam = "wide" -- h cycle the HOST map: stage this map's fight on a sibling floor -- w water counts as ground for the search (the surf routes) -- g what the plan shows: the map's own tiles, the walk grid over -- them, or the walk grid alone -- f scan this map for every arena that fits, clear ones first -- n p step through that scan -- a jump to the automatic pick (what arena_pick would have said) -- u back to whatever the config file holds for this map -- t TEST: stage a real battle here and look at it -- y screenshot -- enter COMMIT this map (shift+enter: commit and go to the next map) -- backspace commit a REFUSAL: nowhere on this map a fight can be seen -- delete commit a REMOVAL: no entry, so battle time falls back to the -- nearest-clear search -- e export now (a commit exports on its own; this is for after a -- revert, or just to be sure) -- F1 the key legend on screen -- escape quit (in TEST, back to the editor) -- -- In TEST every key except t / escape / y goes to the GAME, so the fight is -- driven with the game's own buttons and the camera steered with the mod's -- own Q/E and mouse -- what you are judging is the shot a player gets. -- -- ------- what it writes -- -- The export is data/battle_arenas.lua REWRITTEN LINE BY LINE rather than -- regenerated: an entry that changed has its lines replaced in place, a new -- one is appended, and every other line of that file -- which is mostly the -- prose explaining why each spot is the spot -- comes through untouched. A -- comment sitting directly above a replaced entry describes the OLD spot, so -- each of those is reported by id at export time and is yours to re-word. -- -- It lands in .scratchpad/arena_editor/battle_arenas.lua by default, so a -- session is a file to diff and copy over rather than an edit to the mod -- under a running game. ARENA_WRITE=1 writes to data/battle_arenas.lua -- itself, keeping a .bak of what was there. Either way the written text is -- parsed back and checked against what was committed BEFORE it replaces -- anything -- a tool that writes a config file must never be the reason one -- stops loading. -- -- Re-run and the session resumes: the export is a complete config, so it is -- read back as the base when it exists (ARENA_FRESH=1 starts from the mod's -- own file instead). -- -- ------- environment -- -- ARENA_MAPS=ID,ID work this list instead of every map a fight happens on -- ARENA_OUT=path where the config is written -- ARENA_WRITE=1 write to data/battle_arenas.lua (keeps a .bak) -- ARENA_FRESH=1 ignore an existing export and start from the mod's file -- ARENA_MON=SPECIES what to fight in TEST (default NIDORINO) -- ARENA_LEVEL=N its level (default 20) -- ARENA_RUNG=N the voxel angle rung to sit on (default 3) -- ARENA_MARGIN=N keep the scan this far off the map's edge (default: 2 -- outdoors, where the outermost cells are the connection -- border, and 0 indoors) -- SHOT_DIR=path screenshots and the default export directory return function(game) local U = dofile("tests/drivers/util.lua") -- the config's line surgery, kept in its own file so it can be tested -- without a game (see the header there) local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua") local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end local DIR = os.getenv("SHOT_DIR") if not DIR or DIR == "" then DIR = ".scratchpad/arena_editor" end local DATA = "mods/DramaticShapeVoxelMod/data/battle_arenas.lua" local IN_PLACE = truthy(os.getenv("ARENA_WRITE")) local OUT = os.getenv("ARENA_OUT") if not OUT or OUT == "" then OUT = IN_PLACE and DATA or (DIR .. "/battle_arenas.lua") end local FRESH = truthy(os.getenv("ARENA_FRESH")) local SELFTEST = truthy(os.getenv("ARENA_SELFTEST")) if SELFTEST then -- never the mod's own file, whatever the environment says: the check runs -- unattended and its whole point is that it can be thrown away IN_PLACE = false OUT = DIR .. "/selftest_battle_arenas.lua" FRESH = true -- and always from the mod's own file, so a rerun end -- checks the same surgery on the same text local SPECIES = os.getenv("ARENA_MON") if not SPECIES or SPECIES == "" then SPECIES = "NIDORINO" end local LEVEL = tonumber(os.getenv("ARENA_LEVEL") or "") or 20 local RUNG = tonumber(os.getenv("ARENA_RUNG") or "") or 3 local MARGIN = tonumber(os.getenv("ARENA_MARGIN") or "") -- SHOT_DIR is created with both spellings: U.shot's own mkdir is -- Unix-flavoured and silently does nothing on Windows, which is where this -- tool is driven from, and a screenshot that never reaches disk is the one -- failure a screenshot tool must not have. -- U.shot's own mkdir is Unix-flavoured, and this tool is driven from -- Windows, where `2>/dev/null` is cmd.exe being asked to redirect into a -- directory that does not exist -- which prints "the system cannot find the -- path specified" for every call and creates nothing. So the shell is asked -- in its own language. local WINDOWS = love.system and love.system.getOS() == "Windows" local function mkdirp(dir) if not dir or dir == "" then return end if WINDOWS then os.execute('if not exist "' .. dir:gsub("/", "\\") .. '" mkdir "' .. dir:gsub("/", "\\") .. '"') else os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end end mkdirp(DIR) local function readFile(path) local f = io.open(path, "rb") if not f then return nil end local text = f:read("*a") f:close() return text end local function writeFile(path, text) mkdirp(path:match("^(.*)[/\\][^/\\]+$")) local f = io.open(path, "wb") if not f then return false end f:write(text) f:close() return true end -- ------- the mod, and the modes this tool needs it in local exports = game.mods and game.mods.exports local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib if not lib then U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") return end local Arena = lib.require("BattleArena") local Battles = lib.require("OverworldBattle") local BattleCam = lib.require("BattleCam") local DayNight = lib.require("DayNight") local ChunkMesher = lib.require("ChunkMesher") local Voxel3D = lib.require("Voxel3D") local Pipelines = require("src.render.Pipelines") local PaletteFX = require("src.render.PaletteFX") local BattleState = require("src.battle.BattleState") local Pokemon = require("src.pokemon.Pokemon") local MapLoader = require("src.world.MapLoader") local Map = require("src.world.Map") game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } game.save.player.name = "RED" -- Every mod setting is set WITHOUT a game argument, deliberately: passing -- one persists the value into the player's own options file, and a tool -- that is borrowing their install for an hour should not come back out -- having changed what they play with. ModSetting caches the index it is -- given, which is what every reader asks, so the modes are live for this -- process and stored nowhere. Battles.setting:setValue(true) -- 2D-3D A: the fight on the map Battles.backSetting:setValue(false) -- both mons out there on it DayNight.setting:setValue("day") -- one light to judge them all in -- the rung IS persisted by the engine's own registry, so it is noted here -- and put back on the way out local wasRung = Pipelines.level("voxel") Pipelines.setLevel("voxel", RUNG) if not Voxel3D.available() then U.log("no 3D pass available here -- the editor still picks and exports, " .. "but TEST will draw the flat battle screen") end -- ------- which maps get a spot -- -- The same filter arena_pick uses, and for the same reason: authoring a -- spot for a shop floor is authoring a spot for a fight that never happens -- there. The encounter tables are their own registry keyed by map id, NOT a -- field on the map record -- reading def.encounters answers nil for every -- map in the game and silently narrows this to trainer maps alone. local function fights(id, def) local enc = game.data.encounters and game.data.encounters[id] if enc then for _, kind in ipairs({ "grass", "water" }) do local t = enc[kind] if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then return true end end end for _, obj in ipairs(def.objects or {}) do if obj.trainer or obj.trainerClass or obj.species then return true end end return false end local ids = {} local explicit = os.getenv("ARENA_MAPS") if explicit and explicit ~= "" then for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end else for id, def in pairs(game.data.maps) do if type(id) == "string" and type(def) == "table" and fights(id, def) then ids[#ids + 1] = id end end table.sort(ids) end if #ids == 0 then U.log("no maps to work on") return end -- ------- the config this session starts from -- -- The export is a complete config file, so when one is already there it is -- what we read back: a session that ran out of evening resumes exactly -- where it stopped, and the file being rewritten is the same one being -- read, which keeps the prose in it accumulating rather than reverting. local baseFrom = OUT local baseText = (not FRESH) and readFile(OUT) or nil if not baseText then baseFrom = DATA baseText = readFile(DATA) end local base = {} if baseText then local chunk = load(baseText, "@" .. baseFrom) local ok, list = pcall(chunk) if ok and type(list) == "table" then base = list else U.log(("could not read %s (%s) -- starting from an empty config") :format(baseFrom, tostring(list))) baseText = "return {\n}\n" baseFrom = "(empty)" end else U.log("no config file found -- starting from an empty one") baseText = "return {\n}\n" baseFrom = "(empty)" end U.log(("base config: %s (%d maps)"):format(baseFrom, (function() local n = 0 for _ in pairs(base) do n = n + 1 end return n end)())) -- What this session has decided, per map id: an entry table, `false` for a -- refusal, or REMOVE for "take the entry out and let the search answer". -- Kept apart from `base` so the export knows which lines it may touch. local REMOVE = Config.REMOVE local edits = {} local sameEntry = Config.same local backedUp = false -- Apply every committed edit to the base text and write it out. Returns -- ok, message. local function export() local n = 0 for _ in pairs(edits) do n = n + 1 end local text, report = Config.apply(baseText, edits) -- Parsed back and checked against what was committed BEFORE it replaces -- anything. A config file this tool has made unloadable would take the -- authored spot away from every map in the game, and the failure would -- turn up as battles quietly happening somewhere else. local list, why = Config.verify(text, edits, OUT) if not list then return false, why end if IN_PLACE and not backedUp then if writeFile(DATA .. ".bak", baseText) then U.log("kept the original at " .. DATA .. ".bak") end backedUp = true end if not writeFile(OUT, text) then return false, "could not open " .. OUT .. " for writing" end -- the written file becomes the base, so the next export edits the text it -- just produced and every edit is applied exactly once baseText, base, edits = text, list, {} if #report.added > 0 then U.log("ADDED " .. table.concat(report.added, ", ")) end if #report.removed > 0 then U.log("REMOVED " .. table.concat(report.removed, ", ")) end if #report.stale > 0 then U.log("NOTE these entries had comments above them that describe the " .. "spot they USED to be: " .. table.concat(report.stale, ", ")) end return true, ("wrote %s (%d %s)"):format(OUT, n, n == 1 and "map" or "maps") end -- ------- the editor's state local S = { i = 1, -- which map, into ids map = nil, -- the live Map for it pick = nil, -- the working spot: a table, false, or nil fits = false, -- does its footprint lie on open ground clear = false, -- would both mons be seen from the battle camera arena = nil, -- the arena record the two above were judged on host = nil, -- the map that arena stands on (a sibling floor) cands = nil, -- the last scan ci = 0, surf = false, -- water counts as ground for the search mode = "edit", msg = "", legend = true, planMode = 1, -- the map's own tiles, with the walk grid over tiles = nil, -- that map, baked from its own tileset shooting = false, -- the overlay stands aside for a screenshot quit = false, done = {}, -- ids committed at some point this session doneN = 0, overrideOn = nil, -- the map whose entry we have forced, if any } local queue = {} local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end local function say(fmt, ...) S.msg = select("#", ...) > 0 and fmt:format(...) or fmt end local function mapId() return ids[S.i] end local hosts = {} local function hostMap(id) if id == nil or (S.map and id == S.map.id) then return S.map end if hosts[id] ~= nil then return hosts[id] or nil end local ok, m = pcall(MapLoader.load, game.data, id) hosts[id] = (ok and m) or false return hosts[id] or nil end local function shapeFor(id) for _, s in ipairs(Arena.SHAPES) do if s.id == (id or "wide") then return s end end return Arena.SHAPES[1] end -- Does this spot's whole footprint lie on ground an arena may be laid on? -- -- Water counts, whatever the player is doing, because that is what -- BattleArena.find does for an AUTHORED entry: the surfing test exists to -- stop the automatic search staging a walker's fight out at sea, and an -- entry chosen by a person who is looking at it is not that. -- the footprint a shape covers at this turn: a quarter turn swaps how far -- it reaches each way, which is the whole reason a corridor takes one -- orientation and refuses the other local function extentOf(shapeId, turn) return Arena.extent(shapeFor(shapeId), turn) end local function footprintFits(host, x, y, shapeId, turn) local w, h = extentOf(shapeId, turn) for cy = y, y + h - 1 do for cx = x, x + w - 1 do if not Arena.openCell(host, cx, cy, true) then return false end end end return true end -- Re-judge the working pick and hand the same answer to the mod, so a TEST -- stages exactly what is on screen rather than whatever the search would -- have found from the player's cell. local function evaluate() S.fits, S.clear, S.arena, S.host = false, false, nil, nil -- The working spot is handed to the mod as this map's authored entry, so -- a TEST stages what is on screen. Any map we forced earlier is let go -- first: an override outlives the map it was set on, and one left behind -- would quietly stage some other area's fight on a spot that was never -- committed. if S.overrideOn and S.overrideOn ~= mapId() then Arena.setOverride(S.overrideOn, nil) end S.overrideOn = mapId() Arena.setOverride(mapId(), S.pick) local p = S.pick if type(p) ~= "table" or not S.map then return end local host = hostMap(p.map) if not host then say("host map %s could not be loaded", tostring(p.map)) return end S.host = host if not footprintFits(host, p.x, p.y, p.shape, p.turn) then return end S.fits = true local arena = Arena.at(p.x, p.y, p.shape, p.turn) if not arena then return end arena.map, arena.cam = host, p.cam S.arena = arena local ok, clear = pcall(Arena.clearance, host, arena) S.clear = (ok and clear) and true or false end local function setPick(entry, why) S.pick = entry evaluate() if why then say(why) end end -- Put the player on the near mon's cell, so the free-roam camera is looking -- at the spot being edited and the meshes around it are the ones built. -- Written the way OverworldController's own placement writes it -- cell, -- pixel and the half-finished step alike -- because setting the cell alone -- leaves the body mid-stride toward wherever it was walking. local function standOnPick() local p, ow = S.pick, game.overworld if type(p) ~= "table" or not (ow and ow.player) then return end if S.host and S.map and S.host.id ~= S.map.id then return end -- through the placed record rather than the shape's own offsets, so the -- near mon's cell is the turned one local arena = Arena.at(p.x, p.y, p.shape, p.turn) if not arena then return end local cx, cy = arena.playerCell[1], arena.playerCell[2] if not S.map:inBounds(cx, cy) then return end local player = ow.player player.cellX, player.cellY = cx, cy player.px, player.py = cx * 16, cy * 16 player.moving = false player.targetX, player.targetY = nil, nil player.facing = "up" end -- ------- what the map looks like from above -- -- THE MAP'S OWN ART, first: the plan is the surface a spot is chosen on, -- and a grid of coloured squares is a map of the walk grid rather than of -- the place. What tells you the fight will be staged in the clearing north -- of the ledge, or on the jetty rather than the sand, is the tiles -- the -- game's own, extracted from the cartridge, drawn from the same atlas and -- through the same shade-remap shader the flat 2D world uses. -- -- Baked ONCE per map into a canvas rather than drawn per frame: the tile -- layer is a static SpriteBatch over the whole body, and asking the engine -- for it sixty times a second would refill that batch against a window the -- game is also using. Baked during UPDATE, never inside a draw, because it -- binds a canvas of its own -- the same reason the battle's scene pass -- lives on the update hook. local PLAN_MAX = 2048 -- the largest bake, in pixels on the long side -- The four colours this map's ground is drawn in: its own SGB palette, -- looked up exactly as the overworld looks it up, so a cave is a cave's -- grey and Viridian Forest is its own green. Falls back to the boot ROM's -- background palette, which is what the engine falls back to. local function paletteFor(m) local ow = game.overworld if ow and ow.paletteNameFor then local okName, name = pcall(ow.paletteNameFor, ow, m) if okName and name then local okPal, colours = pcall(PaletteFX.pal, game.data, name) if okPal and colours then return colours end end end local okOg, og = pcall(PaletteFX.ogBg) return okOg and og or nil end local function bakeTiles() if S.tiles then pcall(S.tiles.release, S.tiles) end S.tiles = nil local m = S.map if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then return end local w, h = m.widthCells * 16, m.heightCells * 16 local scale = math.min(1, PLAN_MAX / math.max(w, h)) local okNew, canvas = pcall(love.graphics.newCanvas, math.max(1, math.floor(w * scale)), math.max(1, math.floor(h * scale))) if not (okNew and canvas) then return end -- linear, deliberately, and the one place in this mod where that is -- right: the plan is this art MINIFIED to a panel, where nearest -- sampling throws away most of the tiles and keeps a moire of whichever -- texels the ratio happens to land on pcall(canvas.setFilter, canvas, "linear", "linear") local g = love.graphics local prev = g.getCanvas() -- the true-colour marks a RED++ atlas registers are for the frame the -- engine is composing, not for a canvas baked outside one local wasTrue = m.renderer.trueColor m.renderer.trueColor = false local ok, err = pcall(function() g.push("all") g.origin() g.setCanvas(canvas) g.clear(0, 0, 0, 0) g.setBlendMode("alpha") g.setColor(1, 1, 1, 1) g.scale(scale, scale) -- the atlas is the Game Boy's four greys; this is the pass that turns -- them into the map's colours, and it is the engine's own local shader = PaletteFX.shader() local colours = paletteFor(m) if shader and colours then g.setShader(shader) PaletteFX.sendColors(shader, colours) end m.renderer:drawMapOnly(0, 0, w, h) g.pop() end) m.renderer.trueColor = wasTrue if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end if not ok then pcall(canvas.release, canvas) U.log("could not bake the map's tiles: " .. tostring(err)) return end S.tiles = canvas end -- And the walk grid over it, one class per cell, built once with it. The -- art says what the place looks like; this says where an arena may legally -- go, which is not visible in the art -- a ledge lip and the path beside it -- are the same three pixels of grass. -- -- How much of it is laid over the art is the `g` key. Declared up here -- rather than beside the drawing that reads it: a local declared further -- down the chunk would leave the key handler above reading a GLOBAL of the -- same name, which is nil forever and takes the key silently with it. local PLAN_MODES = { "both", "tiles", "grid" } local CELL_COLOUR = { open = { 0.78, 0.74, 0.63 }, grass = { 0.33, 0.60, 0.30 }, water = { 0.24, 0.44, 0.78 }, warp = { 0.85, 0.68, 0.20 }, solid = { 0.17, 0.17, 0.20 }, } local function buildPlan() local m = S.map if not m then S.plan = nil return end local w, h = m.widthCells, m.heightCells local plan = { w = w, h = h } for cy = 0, h - 1 do for cx = 0, w - 1 do local kind if not m:isWalkableCell(cx, cy) then kind = m:isWaterCell(cx, cy) and "water" or "solid" elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then kind = "warp" elseif m.isGrassCell and m:isGrassCell(cx, cy) then kind = "grass" else kind = "open" end plan[cy * w + cx] = kind end end S.plan = plan end -- ------- moving between maps local function autoPick(quiet) if not S.map then return nil end local def = game.data.maps[mapId()] -- def.width/height are in BLOCKS and a block is two cells, so the map's -- middle in cells is the width itself local ox, oy = math.floor(def.width), math.floor(def.height) if S.surf then -- A surf route's land is a rim of beach around the edge of the map, so -- a search from the middle always lands on the rim and the fight is -- staged on sand at the corner of a sea. Starting from the middle of -- the BIGGEST body of water puts it out in the open instead -- biggest, -- not all of it at once, because a map with a lake and an ocean has a -- centroid between them that is on neither. local m = S.map local w, h = m.widthCells, m.heightCells local seen, best = {}, nil for y0 = 0, h - 1 do for x0 = 0, w - 1 do if not seen[y0 * w + x0] and m:isWaterCell(x0, y0) then local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0 seen[y0 * w + x0] = true while #stack > 0 do local cell = table.remove(stack) local cx, cy = cell[1], cell[2] n, sx, sy = n + 1, sx + cx, sy + cy for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do local nx, ny = cx + d[1], cy + d[2] local key = ny * w + nx if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key] and m:isWaterCell(nx, ny) then seen[key] = true stack[#stack + 1] = { nx, ny } end end end if not best or n > best.n then best = { n = n, x = sx / n, y = sy / n } end end end end if best then ox, oy = best.x, best.y end end local found = Arena.search(S.map, ox, oy, S.surf, true) local why = "clear" if not found then found = Arena.search(S.map, ox, oy, S.surf) -- The shipped search asks about the DEFAULT lens only, which is what -- makes it say this about rooms it cannot stand back from. `f` asks -- about both, so it is the thing to reach for next rather than a -- conclusion that the map has nowhere to fight. why = "OBSTRUCTED on the long lens -- try f, which also tries the wide one" end if not found then if not quiet then say("no arena of either shape fits on this map") end return nil end if not quiet then say("automatic pick, %s", why) end return { x = found.x, y = found.y, shape = found.shape } end local function enterMap(index) S.i = ((index - 1) % #ids) + 1 local id = mapId() S.cands, S.ci = nil, 0 local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end) if not ok then S.map, S.plan = nil, nil say("could not enter %s (%s)", id, tostring(err)) U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err))) return end S.map = game.overworld.map buildPlan() bakeTiles() -- the entry this map already has wins; a map with none gets the search's -- answer as a starting point, uncommitted local entry = edits[id] if entry == REMOVE then entry = nil end if entry == nil then entry = base[id] end if entry == nil then setPick(autoPick(true), nil) say("%s: no entry yet -- this is the automatic pick", id) else if entry == false then setPick(false, nil) say("%s: the config refuses this map", id) else setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide", turn = entry.turn, cam = entry.cam, map = entry.map }, nil) say("%s: from the config", id) end end standOnPick() end -- ------- the frame -- -- The driver harness turns the frame cap off (main.lua: a scripted run is -- not paced), so each step is held to a 60th of a second by hand. Without -- it the game's logic runs at the refresh rate of whatever monitor this is -- on, and a battle intro on a 165Hz panel is over before it can be read. local lastT = love.timer.getTime() local function step(n) for _ = 1, (n or 1) do local rest = (1 / 60) - (love.timer.getTime() - lastT) if rest > 0 then love.timer.sleep(rest) end lastT = love.timer.getTime() U.wait(1) end end -- `withHud` keeps the overlay in the shot, which is how a spot is filed -- with its own plan and verdict beside it; the plain shot is the picture a -- player would get and is the one to judge by. local function shoot(name, withHud) local path = ("%s/%s.png"):format(DIR, name) S.shooting = not withHud game.capturePath = path for _ = 1, 120 do if not game.capturePath then break end step(1) end step(2) S.shooting = false local f = io.open(path, "rb") if f then f:close() -- the panel gets the file name and the console the whole path: one is -- read at a glance and the other is pasted into something say("shot %s.png", name) U.log("SHOT " .. path) else say("screenshot did not reach disk: %s", path) end end -- The spot IS the filename, so a directory of these is a contact sheet of -- decisions rather than a pile of numbered frames -- and the mode is in it -- too, or the shot of the staged fight lands on top of the shot of the -- ground it was staged on. local function shotName(kind) local p = S.pick local where = mapId():lower() if type(p) == "table" then where = ("%s_x%d_y%d_%s"):format(where, p.x, p.y, p.shape or "wide") -- the turn is part of the spot's identity, or the same corner shot two -- ways round writes one file twice and the comparison is impossible if (p.turn or 0) ~= 0 then where = ("%s_t%d"):format(where, p.turn) end end return where .. "_" .. (kind or (S.mode == "test" and "battle" or "plan")) end -- ------- TEST: a real fight on the spot -- -- Staged through the mod's own path -- the override is already installed by -- evaluate(), the overworld's own pushBattle is what starts it -- so what -- appears is a battle, not a mock of one. Judging a spot against anything -- less is how a spot that reads on a plan and hides a Pokemon in the game -- gets committed. local function beginTest() if type(S.pick) ~= "table" then say("nothing to stage here") return end if not S.fits then say("that footprint is not on open ground -- nothing would stage") return end standOnPick() say("staging...") -- let the terrain around the arena finish building before the shot needs -- it, so the first battle frame is not the flat fallback for _ = 1, 120 do if (ChunkMesher.pending and ChunkMesher.pending() or 0) == 0 then break end step(1) end step(20) local battle = BattleState.newWild(game, SPECIES, LEVEL) -- the fight is a fixture, so it must not run the real one's aftermath battle.onFinish = function() end game.overworld:pushBattle(battle) S.mode = "test" -- Drive it to the MENU. Anything earlier is the intro, where the player's -- side legitimately shows the trainer's back and there is no Pokemon on -- the field at all -- which looks exactly like a broken arena and is not. for _ = 1, 200 do if battle.phase == "menu" then break end U.tap(game, "a") step(6) end local staged = Battles.arena() if staged then say("TEST at %d,%d %s -- t or escape to come back", staged.x, staged.y, staged.shape) else say("TEST: nothing staged, this is the flat battle screen") end U.log(("TEST %s phase=%s staged=%s"):format(mapId(), tostring(battle.phase), staged and "yes" or "no")) end local function endTest() U.log("TEST left") while game.stack:top() and game.stack:top() ~= game.overworld do game.stack:pop() end step(8) S.mode = "edit" standOnPick() say("back in the editor") end -- ------- the scan -- -- Every arena that fits anywhere on this map, clear ones first. The -- clearance test is what makes the list worth stepping through: an -- obstructed spot is not a candidate however good the ground under it -- looks, and half the ground on a route is obstructed from a camera this -- low. local function scan() local m = S.map if not m then return end local margin = MARGIN if not margin then -- outdoors the outermost cells are the CONNECTION BORDER, the strip the -- neighbouring map is drawn into: ground there passes every test and is -- still the wrong answer, because a fight staged on it happens at the -- edge of the world. Indoors there is no such strip and the walls are -- already off the table, so nothing is kept back. margin = Map.isOutdoor(m.def) and 2 or 0 end local list = {} local mx = (m.widthCells - 1) / 2 local my = (m.heightCells - 1) / 2 -- Both footprints, because a quarter turn is what lets the 3x6 shape into -- an east-west corridor at all: turns 0 and 2 cover the same ground and -- so do 1 and 3, so the GROUND is walked twice and the four ways round -- are asked about each patch that takes one. for _, shape in ipairs(Arena.SHAPES) do for _, lie in ipairs({ 0, 90 }) do local fw, fh = Arena.extent(shape, lie) -- the square footprint the narrow shape never has, and the wide one -- never has either -- so nothing is scanned twice local twice = (fw == shape.w and fh == shape.h and lie ~= 0) for y = margin, m.heightCells - fh - margin do for x = margin, m.widthCells - fw - margin do local fits = not twice if fits then for cy = y, y + fh - 1 do for cx = x, x + fw - 1 do if not Arena.openCell(m, cx, cy, S.surf) then fits = false break end end if not fits then break end end end if fits then -- Judged on both TURNS that share this footprint and on both -- LENSES, because which way round a fight stands and what it is -- shot on are part of the answer, not separate questions asked -- afterwards. -- -- The lens matters most indoors: the long default cannot stand -- back from a small room -- its eye lands outside the walls, and -- on an indoor map that is inside the border ring this mode -- extrudes into a cliff -- so a gym where nothing is clear on -- the tele lens can be entirely clear on the 44-degree one. That -- is what `cam = "wide"` is for on the three gyms in the shipped -- file, and an editor that only asked about the default lens -- would report those maps as having nowhere to fight. -- -- The turn matters most against a wall: the two ends of the same -- axis put the camera on opposite sides of the pair, and one of -- them is often looking into the rock the other has behind it. -- -- First combination that reads wins, in preference order, so the -- common case -- clear, as it lies, on the default lens -- costs -- exactly one clearance test. local clear, cam, turn = false, nil, lie for _, t in ipairs({ lie, lie + 180 }) do for _, lens in ipairs({ false, "wide" }) do local a = Arena.at(x, y, shape.id, t) a.cam = lens or nil local ok, got = pcall(Arena.clearance, m, a) if ok and got then clear, cam, turn = true, lens or nil, t break end end if clear then break end end local dx = x + (fw - 1) / 2 - mx local dy = y + (fh - 1) / 2 - my list[#list + 1] = { x = x, y = y, shape = shape.id, turn = turn, clear = clear, cam = cam, d = dx * dx + dy * dy } end end say("scanning %s%s... %d found", shape.id, lie ~= 0 and " (turned)" or "", #list) step(1) end end end -- Clear before obstructed; among the clear, the default lens before the -- one that had to be widened to get there; then the wide arena shape -- before the narrow one, since that is the shot this mode is framed for; -- and within all that, nearest to the middle of the map -- which is the -- order arena_pick's own answer comes first in, so `f` then `n` walks -- outward from the spot the batch tool would have chosen. table.sort(list, function(a, b) if a.clear ~= b.clear then return a.clear end if (a.cam ~= nil) ~= (b.cam ~= nil) then return a.cam == nil end if a.shape ~= b.shape then return a.shape == "wide" end -- and the way round the mode was drawn for before the three that had -- to be turned to get there if (a.turn or 0) ~= (b.turn or 0) then return (a.turn or 0) < (b.turn or 0) end return a.d < b.d end) local clearN, wideN = 0, 0 for _, c in ipairs(list) do if c.clear then clearN = clearN + 1 if c.cam then wideN = wideN + 1 end end end S.cands, S.ci = list, 0 S.candClear, S.candWide = clearN, wideN if #list == 0 then say("nothing fits anywhere on this map") else say("%d spots fit, %d of them clear%s -- n and p step through them", #list, clearN, wideN > 0 and (" (%d only on the wide lens)"):format(wideN) or "") end end local function stepCandidate(dir) if not S.cands or #S.cands == 0 then say("scan the map first (f)") return end S.ci = ((S.ci + dir - 1) % #S.cands + #S.cands) % #S.cands + 1 local c = S.cands[S.ci] local keep = type(S.pick) == "table" and S.pick or {} -- the turn and the lens come WITH the candidate: the scan reached its -- verdict about one particular way round on one particular lens, and -- landing on the spot with either of the others selected would show a -- verdict that was never about this shot setPick({ x = c.x, y = c.y, shape = c.shape, turn = c.turn, cam = c.clear and c.cam or keep.cam, map = keep.map }) standOnPick() say("candidate %d/%d %s%s%s", S.ci, #S.cands, c.clear and "clear" or "OBSTRUCTED", (c.turn or 0) ~= 0 and (" turned %d"):format(c.turn) or "", c.cam and " on the wide lens" or "") end -- ------- committing local function commit(entry, advance) local id = mapId() local was = base[id] if entry == nil then -- there is no spot to commit. Saying so is the honest answer; a map -- with nowhere a fight can be seen is committed with backspace, which -- writes a refusal the mod reads rather than silence it has to guess at say("no spot here to commit -- backspace commits a refusal instead") return end if not S.done[id] then S.done[id] = true S.doneN = S.doneN + 1 end if entry == REMOVE then if was == nil then edits[id] = nil say("%s has no entry to remove", id) return end edits[id] = REMOVE U.log(("REMOVE %s -- battle time falls back to the search"):format(id)) elseif entry == false then edits[id] = false U.log(("PICK [%q] = false,"):format(id)) else if sameEntry(entry, was) then edits[id] = nil say("%s already says exactly that", id) if advance then post("map", 1) end return end edits[id] = { x = entry.x, y = entry.y, shape = entry.shape or "wide", turn = ((entry.turn or 0) ~= 0) and entry.turn or nil, cam = entry.cam, map = entry.map } U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s%s },%s") :format(id, entry.map and ("map = %q, "):format(entry.map) or "", entry.x, entry.y, entry.shape or "wide", ((entry.turn or 0) ~= 0) and (", turn = %d"):format(entry.turn) or "", entry.cam and (", cam = %q"):format(entry.cam) or "", S.clear and "" or " -- OBSTRUCTED")) end local ok, msg = export() if ok then say("committed %s -- %s", id, msg) else say("COMMITTED BUT NOT WRITTEN: %s", msg) U.log("EXPORT FAILED: " .. msg) end if advance then post("map", 1) end end -- ------- the keys -- -- Wrapped around love.keypressed rather than polled, so a press is taken -- INSTEAD of reaching the game rather than as well as -- an arrow key that -- moved the arena and walked the player would fight itself. Only the keys -- below are claimed; everything else falls through, which is what leaves -- the mod's own hotkeys (the rung, the water, the wireframe) working while -- the editor is up, and the whole keyboard working inside a TEST. local function shifted() return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") end -- Keep the corner on the map, measured against the footprint as it -- currently lies -- a shape that has just been turned or swapped reaches a -- different distance each way from the same corner. local function clampPick() local p = S.pick if type(p) ~= "table" then return end local fw, fh = extentOf(p.shape, p.turn) local w = S.host and S.host.widthCells or (S.map and S.map.widthCells) or 0 local h = S.host and S.host.heightCells or (S.map and S.map.heightCells) or 0 p.x = math.max(0, math.min(w - fw, p.x)) p.y = math.max(0, math.min(h - fh, p.y)) end local function nudge(dx, dy) local p = S.pick if type(p) ~= "table" then setPick(autoPick(true), "no spot here yet -- took the automatic one") return end local n = shifted() and 5 or 1 p.x, p.y = p.x + dx * n, p.y + dy * n clampPick() evaluate() standOnPick() say("%d,%d %s", p.x, p.y, p.shape or "wide") end -- A quarter turn of the whole staging: the footprint, both mons and the -- camera together (see BattleArena's `turn`). The picture does not change -- -- the pair land on the same screen anchors at the same size -- so what this -- is for is the GROUND: fitting a long arena down a corridor that runs the -- other way, and swapping what is standing behind the pair. local function rotate(dir) local p = S.pick if type(p) ~= "table" then return end p.turn = (((p.turn or 0) + dir * 90) % 360 + 360) % 360 clampPick() evaluate() standOnPick() local fw, fh = extentOf(p.shape, p.turn) say("turned %d degrees -- %dx%d cells, foe to the %s", p.turn, fw, fh, ({ [0] = "north", [90] = "east", [180] = "south", [270] = "west" })[p.turn]) end local function cycleShape() local p = S.pick if type(p) ~= "table" then return end p.shape = (p.shape == "narrow") and "wide" or "narrow" -- the wide shape is 3x6 and the narrow one 1x4, so a corner that was -- legal for the smaller one can hang off the map with the bigger clampPick() evaluate() standOnPick() say("shape %s", p.shape) end local function cycleCam() local p = S.pick if type(p) ~= "table" then return end p.cam = (p.cam == nil) and "wide" or nil evaluate() say("lens %s", p.cam or "tele (the long default)") end -- The floors of the same cave or building, for an entry that stages its -- fight on one of them. Family is the id with its floor suffix taken off, -- which is how these are actually named: POKEMON_TOWER_4F borrows -- POKEMON_TOWER_3F, CERULEAN_CAVE_2F borrows CERULEAN_CAVE_B1F. local function family(id) return (id:gsub("_B?%d+F", "")) end local function cycleHost() local p = S.pick if type(p) ~= "table" then return end local id = mapId() local kin = { id } for other in pairs(game.data.maps) do if type(other) == "string" and other ~= id and family(other) == family(id) then kin[#kin + 1] = other end end table.sort(kin) if #kin == 1 then say("%s has no sibling floors to stage on", id) return end local at = 1 for k, other in ipairs(kin) do if other == (p.map or id) then at = k end end local nextId = kin[(at % #kin) + 1] p.map = (nextId ~= id) and nextId or nil -- the corner is a coordinate on the HOST, so a floor change is a fresh -- question about where on it -- clamp it in, then let the fit test speak evaluate() if S.host then clampPick() evaluate() end standOnPick() say("staging on %s", p.map or "this map") end local function handle(key) if S.mode == "test" then if key == "t" or key == "escape" then post("endtest") return true end if key == "y" then post("shot", shifted() and "hud" or nil) return true end if key == "f1" then S.legend = not S.legend return true end return false end if key == "up" then nudge(0, -1) return true end if key == "down" then nudge(0, 1) return true end if key == "left" then nudge(-1, 0) return true end if key == "right" then nudge(1, 0) return true end if key == "[" then post("map", -1) return true end if key == "]" then post("map", 1) return true end if key == "s" then cycleShape() return true end if key == "r" then rotate(shifted() and -1 or 1) return true end if key == "c" then cycleCam() return true end if key == "h" then cycleHost() return true end if key == "w" then S.surf = not S.surf say("the search %s water as ground", S.surf and "counts" or "does not count") return true end if key == "g" then S.planMode = (S.planMode % #PLAN_MODES) + 1 say("plan: %s", PLAN_MODES[S.planMode]) return true end if key == "f" then post("scan") return true end if key == "n" then stepCandidate(1) return true end if key == "p" then stepCandidate(-1) return true end if key == "a" then local found = autoPick() if found then setPick(found) standOnPick() end return true end -- `u` for undo, because `r` is rotate: the strongest convention on each -- wins, and this one is only ever wanted after a change you regret if key == "u" then local id = mapId() local entry = base[id] edits[id] = nil if entry == nil then setPick(autoPick(true), "no entry in the config -- automatic pick") elseif entry == false then setPick(false, "the config refuses this map") else setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide", turn = entry.turn, cam = entry.cam, map = entry.map }, "back to the config") end standOnPick() return true end if key == "t" then post("test") return true end if key == "y" then post("shot", shifted() and "hud" or nil) return true end if key == "return" or key == "kpenter" then post("commit", shifted() and "advance" or "stay") return true end if key == "backspace" then post("refuse") return true end if key == "delete" then post("drop") return true end if key == "e" then post("export") return true end if key == "f1" then S.legend = not S.legend return true end if key == "escape" then S.quit = true return true end return false end local innerKey = love.keypressed function love.keypressed(key, scancode, isrepeat) -- the unattended check drives handle() directly, so anything arriving -- HERE during one is a real key from the window and worth naming if SELFTEST then U.log("KEY " .. tostring(key)) end local ok, claimed = pcall(handle, key) if ok and claimed then return end if not ok then U.log("key error: " .. tostring(claimed)) end if innerKey then return innerKey(key, scancode, isrepeat) end end -- ------- the overlay -- -- Drawn after the game's own frame, over it. The plan on the right is what -- the spot is CHOSEN on -- open ground, grass, water, the walls -- and the -- two lines drawn across it from the camera's eye are why a spot that looks -- open gets rejected: they are the sightlines the clearance test walks, and -- seeing them cross a building is the whole explanation. local font = love.graphics.newFont(13) local smallFont = love.graphics.newFont(11) local function drawPlan(x, y, w, h) local plan = S.plan if not plan then return end local g = love.graphics local cell = math.min(w / plan.w, h / plan.h) if cell < 1 then cell = 1 end local pw, ph = plan.w * cell, plan.h * cell local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2 g.setColor(0, 0, 0, 0.55) g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8) local mode = PLAN_MODES[S.planMode or 1] -- the map itself, from its own tiles if S.tiles and mode ~= "grid" then g.setColor(1, 1, 1, 1) g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(), ph / S.tiles:getHeight()) end -- and the walk grid over it: opaque with no art under it, and a wash -- when there is -- enough to read where an arena may go without hiding -- what it would be standing on. Open ground is never washed: it is what -- the art is being read for. if mode ~= "tiles" then local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22 for cy = 0, plan.h - 1 do for cx = 0, plan.w - 1 do local kind = plan[cy * plan.w + cx] local c = CELL_COLOUR[kind] if c and not (alpha < 1 and kind == "open") then g.setColor(c[1], c[2], c[3], alpha) g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell) end end end end -- The candidates, so a scan is a picture of where the map will take a -- fight at all rather than a number to press n against. -- -- One mark at each one's middle rather than its footprint filled in: on a -- route most open ground is a candidate, and painting all of them lays a -- sheet of colour over the very art the spot is being chosen from. A dot -- says "here, and here" without hiding what is there. if S.cands then local r = math.max(1.5, cell * 0.35) for _, c in ipairs(S.cands) do local fw, fh = extentOf(c.shape, c.turn) if c.clear then -- green for the default lens, amber for one that only reads on the -- wide one -- the difference is a decision, not a detail if c.cam then g.setColor(1, 0.8, 0.2, 0.85) else g.setColor(0.3, 1, 0.4, 0.85) end else g.setColor(1, 0.35, 0.2, 0.35) end g.circle("fill", ox + (c.x + fw / 2) * cell, oy + (c.y + fh / 2) * cell, r) end end local ow = game.overworld if ow and ow.player then g.setColor(1, 1, 1, 0.8) g.circle("fill", ox + (ow.player.cellX + 0.5) * cell, oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4)) end -- the spot itself, on whichever map it is measured against local p = S.pick local onHost = not (S.host and S.map and S.host.id ~= S.map.id) -- laid out through the placed record rather than from the shape's own -- offsets, so a turned arena is drawn the way it will actually stand. -- Built even when it does not fit -- a footprint half off the map is -- exactly the thing you need to see to move it back on. local shot = type(p) == "table" and Arena.at(p.x, p.y, p.shape, p.turn) if shot and onHost then g.setColor(S.fits and (S.clear and 0.2 or 1) or 1, S.fits and 1 or 0.3, S.fits and (S.clear and 1 or 0.2) or 0.3, 0.9) g.setLineWidth(2) g.rectangle("line", ox + shot.x * cell, oy + shot.y * cell, shot.w * cell, shot.h * cell) g.setLineWidth(1) local ex, ey = shot.enemyCell[1], shot.enemyCell[2] local px, py = shot.playerCell[1], shot.playerCell[2] g.setColor(0.95, 0.35, 0.35, 1) g.rectangle("fill", ox + ex * cell, oy + ey * cell, cell, cell) g.setColor(0.4, 0.65, 1, 1) g.rectangle("fill", ox + px * cell, oy + py * cell, cell, cell) -- the camera, and what it is looking through if S.arena then local ok, rig = pcall(BattleCam.rig, S.arena, 0, true) if ok and rig and rig.eye then local eyeX = ox + (rig.eye[1] / 16) * cell local eyeY = oy + (rig.eye[3] / 16) * cell if S.clear then g.setColor(0.3, 1, 0.5, 0.85) else g.setColor(1, 0.35, 0.3, 0.85) end g.line(eyeX, eyeY, ox + (ex + 0.5) * cell, oy + (ey + 0.5) * cell) g.line(eyeX, eyeY, ox + (px + 0.5) * cell, oy + (py + 0.5) * cell) g.circle("fill", eyeX, eyeY, 4) end end end g.setColor(1, 1, 1, 1) end local LEGEND = { "arrows move the corner (shift x5)", "[ ] previous / next map", "s shape: wide / narrow", "r turn it 90 (shift: the other way)", "c lens: tele / wide", "h stage on a sibling floor", "w search over water", "g plan: tiles / walk grid / both", "f scan the map n p step", "a automatic pick u revert", "t TEST a real battle here", "y screenshot (shift: with this panel)", "enter commit (shift: and next map)", "bksp commit a refusal", "del commit a removal", "e export F1 this list", "escape quit", } local function drawOverlay() local g = love.graphics local W, H = g.getDimensions() if S.mode == "test" then g.setFont(font) g.setColor(0, 0, 0, 0.65) g.rectangle("fill", 0, 0, W, 26) g.setColor(1, 0.85, 0.3, 1) g.print(("TEST %s %s [t] back [y] shot") :format(mapId(), S.msg), 8, 5) g.setColor(1, 1, 1, 1) return end local panel = math.min(340, W * 0.34) g.setFont(font) g.setColor(0, 0, 0, 0.68) g.rectangle("fill", 0, 0, panel, H) local p = S.pick local id = mapId() local mark = S.done[id] and " *" or "" local lines = {} local function add(fmt, ...) lines[#lines + 1] = select("#", ...) > 0 and fmt:format(...) or fmt end add("ARENA EDITOR %d/%d%s", S.i, #ids, mark) add("%s", id) if S.map then add("%d x %d cells", S.map.widthCells, S.map.heightCells) end add("") if p == false then add("REFUSAL -- no fight is staged here") elseif type(p) ~= "table" then add("no spot") else local fw, fh = extentOf(p.shape, p.turn) add("x %d y %d %s (%dx%d)", p.x, p.y, p.shape or "wide", fw, fh) add("turn %d foe to the %s", p.turn or 0, ({ [0] = "north", [90] = "east", [180] = "south", [270] = "west" })[p.turn or 0] or "?") add("lens %s", p.cam or "tele") add("host %s", p.map or "this map") add("") add("fits %s", S.fits and "YES" or "NO -- not open ground") add("clear %s", S.clear and "YES" or (S.fits and "NO -- a mon is hidden" or "-")) end add("") -- what the CONFIG says right now, which after a commit is what was just -- written to it -- an export rebases on its own output, so "pending" is -- never a state this can be left in local held = edits[id] if held == nil then held = base[id] end if held == REMOVE or held == nil then add("config no entry (the search answers)") elseif held == false then add("config refuses this map") else add("config %d,%d %s%s%s%s", held.x, held.y, held.shape or "wide", ((held.turn or 0) ~= 0) and (" turn=" .. held.turn) or "", held.cam and (" cam=" .. held.cam) or "", held.map and (" on " .. held.map) or "") end if not sameEntry(p, held) then add("NOT COMMITTED -- enter writes it") elseif S.done[id] then add("committed this session") else add("unchanged") end if S.cands then add("scan %d fit, %d clear (%d need the wide lens)%s", #S.cands, S.candClear or 0, S.candWide or 0, S.ci > 0 and (" at %d"):format(S.ci) or "") end add("water %s", S.surf and "counts as ground" or "is not searched") add("done %d of %d maps this session", S.doneN, #ids) add("out %s", OUT) g.setColor(1, 1, 1, 1) local y = 10 for _, l in ipairs(lines) do g.print(l, 10, y) y = y + 16 end y = y + 6 g.setColor(1, 0.85, 0.35, 1) g.setFont(smallFont) local msg = S.msg or "" local _, wrapped = smallFont:getWrap(msg, panel - 20) g.printf(msg, 10, y, panel - 20) y = y + 13 * math.max(1, #wrapped) -- what the marks on the plan mean, in the marks themselves if S.cands then y = y + 8 local key = { { { 0.3, 1, 0.4 }, "clear" }, { { 1, 0.8, 0.2 }, "wide lens only" }, { { 1, 0.35, 0.2 }, "obstructed" } } local kx = 14 for _, entry in ipairs(key) do g.setColor(entry[1][1], entry[1][2], entry[1][3], 0.9) g.circle("fill", kx, y + 5, 3.5) g.setColor(0.8, 0.8, 0.85, 1) g.print(entry[2], kx + 9, y - 1) kx = kx + 12 + smallFont:getWidth(entry[2]) + 10 end y = y + 16 end if S.legend then y = y + 10 g.setColor(0.75, 0.8, 0.9, 1) for _, l in ipairs(LEGEND) do g.print(l, 10, y) y = y + 14 end end -- the plan, in whatever is left of the window local px = panel + 12 drawPlan(px, 12, W - px - 12, H - 24) g.setColor(1, 1, 1, 1) end local innerDraw = love.draw function love.draw() if innerDraw then innerDraw() end if S.shooting then return end local g = love.graphics g.push("all") g.origin() local ok, err = pcall(drawOverlay) g.pop() if not ok and not S.drawWarned then S.drawWarned = true U.log("overlay draw failed: " .. tostring(err)) end end -- ------- the session U.log("arena editor -- " .. #ids .. " maps") for _, l in ipairs(LEGEND) do U.log(" " .. l) end U.log("exports to " .. OUT) enterMap(1) local function dispatch(job) if job.cmd == "map" then enterMap(S.i + job.arg) elseif job.cmd == "scan" then scan() elseif job.cmd == "test" then beginTest() elseif job.cmd == "endtest" then endTest() elseif job.cmd == "shot" then local hud = job.arg == "hud" shoot(shotName(hud and "sheet" or nil), hud) elseif job.cmd == "commit" then commit(S.pick, job.arg == "advance") elseif job.cmd == "refuse" then commit(false, false) elseif job.cmd == "drop" then commit(REMOVE, false) elseif job.cmd == "export" then local ok, msg = export() say("%s", msg) U.log((ok and "EXPORT " or "EXPORT FAILED ") .. msg) end end -- One frame of the session: the next thing a key asked for, or a frame of -- standing still while the game runs underneath. local function pump(frames) for _ = 1, (frames or 1) do local job = table.remove(queue, 1) if job then dispatch(job) else step(1) end if S.quit then return end end end -- ------- ARENA_SELFTEST=1: the same session, with the keys pressed for you -- -- Every command this tool has, driven through the SAME entry point the -- keyboard drives -- so it exercises the real handler, the real staging and -- the real export rather than a parallel script that can drift from them. -- It is how the tool is checked after a change without a person sitting in -- front of it, and it writes to its own file, never to the mod's. if SELFTEST then local function press(key, frames) local ok, err = pcall(handle, key) if not ok then U.log("SELFTEST key failed: " .. tostring(err)) end pump(frames or 20) U.log(("SELFTEST %-9s %s"):format(key, S.msg or "")) end press("f", 900) -- scan this map press("n") -- and step through what it found press("n") press("s") -- both shapes press("s") press("r") -- all four ways round, and back to where it was press("r") press("r") press("r") press("right") -- nudge, with and without shift press("down") press("c") -- both lenses press("c") press("h") -- a sibling floor, and back off it press("h", 40) press("a", 60) -- the automatic pick press("n", 60) -- back onto the scan's best answer -- and the rest of the run happens TURNED, so what gets photographed and -- committed is a turned staging: the projection maths is proved in the -- suite, but whether the whole pipeline behind it -- the shadow cast, the -- billboards, the pinned pics -- still composes at a turn is the kind of -- thing only a picture settles. -- -- HALF a turn, twice, rather than a quarter: the candidate under us fits -- the ground it was found on, and 180 keeps that footprint exactly while -- moving the camera to the other end of it. A quarter turn swaps the -- footprint's reach, so on a lane that is six cells one way and three the -- other it lands on a wall -- which the tool rightly refuses to stage, and -- which would leave this check photographing nothing. press("r") press("r", 60) post("shot", "hud") -- and one with the panel in it, which is the pump(240) -- only thing that proves the overlay draws press("y", 240) -- a screenshot of the free-roam view press("g") -- each way of drawing the plan press("g") press("g") press("t", 1200) -- a real battle on the spot press("y", 240) -- and one of that press("t", 90) -- out of the test, by the key that is not also -- the quit key -- escape is checked at the end press("return", 120) -- commit it, which exports press("]", 200) -- the next map press("backspace", 120) -- commit a refusal there press("delete", 120) -- and take the entry out again press("e", 60) -- an explicit export press("escape", 5) -- and the way out U.log("SELFTEST done -- " .. tostring(S.msg)) S.quit = true end while not S.quit do pump(1) end -- Hand the install back the way it was found: the forced entry let go, the -- baked plan released, the two wraps unwound and the camera rung put back. -- The mod settings this tool moved were never persisted (see the top), so -- there is nothing to undo there. if S.overrideOn then Arena.setOverride(S.overrideOn, nil) end if S.tiles then pcall(S.tiles.release, S.tiles) end love.draw = innerDraw love.keypressed = innerKey pcall(Pipelines.setLevel, "voxel", wasRung) U.log("done -- " .. OUT) end