update all battle locations
DramaticShape · 2026-08-05 ·
parent 77e0f93315
update all battle locations
Changed files
CHANGELOG.md | 2 +-
data/battle_arenas.lua | 152 +++---
lib/BattleArena.lua | 80 ++-
lib/BattleCam.lua | 23 +-
lib/BattleScene.lua | 4 +
tests/arena_config.lua | 197 ++++++++
tests/arena_editor.lua | 1617 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
tests/dramatic_shape_test.lua | 103 ++++
tests/stadium_all_shots.lua | 157 ++++++
9 files changed, 2260 insertions(+), 75 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5e35b6..b6d4873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## Unreleased
+## 1.6.1
### Fixed
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index 52714a1..33f7164 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -23,6 +23,20 @@
--
-- SHOT_DIR=.scratchpad/arenas \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
+--
+-- To change ONE map's spot, or to go over these by eye, run the editor
+-- instead -- it is the same choice made in front of the map rather than in a
+-- batch. It slides the arena around on a plan of the map with the fit, the
+-- clearance and the camera's own sightlines answering live, stages a real
+-- battle on the spot when asked, and writes this file back a map at a time,
+-- replacing only the lines that changed and leaving every comment here alone:
+--
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
+--
+-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
+-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
+-- moved describes where that spot USED to be, and it says so by name at
+-- export time -- those are the lines to re-word by hand.
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
@@ -49,14 +63,14 @@ return {
-- height along the sightline and a hedge in the apron row is not terrain.
-- So the choice is where in the clearing, and this is its west end: tree
-- line square behind the pair, nothing crossing either of them.
- ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
+ ["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
- ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
- ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
- ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
- ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
- ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
- ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
+ ["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
+ ["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
+ ["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
+ ["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
+ ["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
+ ["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
-- the whole route admits six bare wide arenas, all in the west cliff
-- corridor; this is the best of them. A flower cluster crosses the far
-- mon's hind legs, which the brief allows -- every alternative put a
@@ -64,13 +78,13 @@ return {
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
- ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
- ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
- ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
- ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
- ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
+ ["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
+ ["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
+ ["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
+ ["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
+ ["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
- ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
+ ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
-- ------- buildings and caves
--
@@ -78,10 +92,10 @@ return {
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
-- also SEEN, and giving up the apron is usually the difference between a
-- fight in the open and one behind a console.
- ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
- ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
- ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
- ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
+ ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
+ ["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
+ ["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
+ ["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
-- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -89,73 +103,79 @@ return {
-- is shot there
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
shape = "wide" },
- ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
- ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
+ ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
+ ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
- ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
- ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
- ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
- ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
+ ["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
+ ["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
+ ["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
- ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
- ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
- ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
+ ["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
+ ["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
- ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
- ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
- ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
- ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
+ ["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
+ ["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
+ ["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
-- Silph Co is office floors partitioned into small rooms, so the long lens
-- often lands outside the walls it is meant to be looking between; the
-- floors that could not be framed any other way ask for the wide one.
- ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
- ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
+ ["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
+ ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
- ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
+ ["SILPH_CO_6F"] = { x = 19, y = 2,
+ shape = "narrow", turn = 90, cam = "wide" },
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
- ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
+ ["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
-- aboard and the long lens always lands outside the hull
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
-- these two decks are byte-identical geometry, so they take the same spot
- ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
- ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+ ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
+ ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
- ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
- ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
+ ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
+ ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
-- ------- towns and the last interiors
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
- ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
+ ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
-- the lab is ten cells by twelve, so the long lens is always off-map, and
-- on it a desk clipped one mon and a pillar the other
- ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
+ ["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
-- Saffron's gym is a grid of small walled cells: no wide shape exists
-- anywhere in it, and the long lens sits inside a divider
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
- ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
- ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+ ["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
+ ["SILPH_CO_11F"] = { x = 10, y = 6,
+ shape = "wide", turn = 180, cam = "wide" },
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
-- gym's barrier, so arenas there silently fail the fit test
- ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
- ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
+ ["VERMILION_GYM"] = { x = 3, y = 2,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
-- ------- the remaining routes
- ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
+ ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
-- than on the rim of beach the land search would otherwise find
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
- ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
- ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
- ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
+ ["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
+ ["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
+ ["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
-- ------- caves, gyms and the Elite Four
@@ -163,25 +183,29 @@ return {
-- None of these tilesets has a grass tile at all, so the no-grass rule
-- constrained nothing here; what constrains them is furniture, rock
-- pillars and how small the rooms are.
- ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
- ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
- ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
- ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
+ ["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
+ ["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
+ ["CELADON_GYM"] = { x = 3, y = 4,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
-- hide a mon, so it borrows the floor below -- the same cave
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
shape = "wide" },
- ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
- ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
+ ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
+ ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
- ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
- ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
- ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
- ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
- ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
- ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
- ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
- ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
+ ["CINNABAR_GYM"] = { x = 9, y = 16,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["DIGLETTS_CAVE"] = { x = 14, y = 26,
+ shape = "wide", turn = 90, cam = "wide" },
+ ["FIGHTING_DOJO"] = { x = 3, y = 5,
+ shape = "narrow", turn = 90, cam = "wide" },
+ ["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
+ ["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
+ ["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
+ ["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
+ ["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
-- The three gyms the default rig cannot stand back from. Five blocks is
-- further than these rooms are wide, so the eye landed outside the map and
@@ -190,7 +214,7 @@ return {
-- BattleCam), which fits inside the room; the mons come out smaller and all
-- three became stageable. It is asked for HERE, per map, so every area that
-- does not ask keeps the long lens it was framed for.
- ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
- ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
+ ["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
+ ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
}
diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua
index 025fe8f..ccc124b 100644
--- a/lib/BattleArena.lua
+++ b/lib/BattleArena.lua
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
}
+-- ------- which way round the fight stands
+--
+-- Both shapes above are drawn north-south, with the foe at the top and the
+-- player below it, and the camera is solved for that: it sits off the
+-- player's shoulder, low and back down the arena's own axis. `turn` swings
+-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
+-- the camera with them -- so the composition on screen is identical and only
+-- the ground under it is different.
+--
+-- It buys two things.
+--
+-- A footprint that FITS. The wide shape is three cells by six; an east-west
+-- corridor two cells deep has no room for it standing up and all the room in
+-- the world for it lying down. Half the maps in Kanto run the other way from
+-- the one shape this mode was drawn in.
+--
+-- And a BACKDROP. A quarter turn moves the camera to a different side of the
+-- same patch of ground, so the wall behind the pair becomes the window
+-- behind them, or the cliff becomes the valley. Nothing about the shot's
+-- geometry changes -- the mons land on the same two screen anchors at the
+-- same size -- so this is purely a choice about what is behind them, made
+-- per map by somebody looking at it.
+--
+-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
+-- what it is; handled as quarter turns everywhere below.
+local function quarters(turn)
+ local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
+ return ((q % 4) + 4) % 4
+end
+
+BattleArena.quarters = quarters
+
+-- The footprint a shape covers once turned: a quarter or three of a turn
+-- swaps how far it reaches in each direction, which is the whole reason a
+-- corridor takes one and not the other.
+function BattleArena.extent(shape, turn)
+ if quarters(turn) % 2 == 1 then return shape.h, shape.w end
+ return shape.w, shape.h
+end
+
+-- Where a cell offset inside the shape ends up under the same turn, measured
+-- from the turned footprint's own north-west corner -- so the corner an entry
+-- names stays the corner, whichever way the fight faces from it.
+local function spin(shape, turn, ox, oy)
+ local q = quarters(turn)
+ if q == 1 then return shape.h - 1 - oy, ox end
+ if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
+ if q == 3 then return oy, shape.w - 1 - ox end
+ return ox, oy
+end
+
-- Whether a cell is open ground for the purpose above.
--
-- "Open" is the walk test the player themselves answer to, so an arena can
@@ -174,12 +225,19 @@ end
-- Build the record the renderer reads: the two mons' cells and, in world
-- pixels, the centre of each and of the pair.
-local function place(shape, x, y)
- local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
- local px, py = x + shape.player[1], y + shape.player[2]
+local function place(shape, x, y, turn)
+ local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
+ local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
+ local ex, ey = x + eox, y + eoy
+ local px, py = x + pox, y + poy
+ local w, h = BattleArena.extent(shape, turn)
local arena = {
shape = shape.id,
- x = x, y = y, w = shape.w, h = shape.h,
+ -- carried in degrees, so everything downstream that reasons about the
+ -- shot -- the camera's base yaw above all -- reads the same number the
+ -- data file was written with
+ turn = quarters(turn) * 90,
+ x = x, y = y, w = w, h = h,
enemyCell = { ex, ey },
playerCell = { px, py },
-- world-pixel centres of the two cells a mon stands on
@@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
-- ocean rather than on a scrap of beach at the edge of the map. Land
-- entries are unaffected: land passes the test either way.
local grid, gw = openGrid(host, true)
- if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
- local arena = place(shape, pick.x, pick.y)
+ -- measured against the TURNED footprint: an entry that lies the arena
+ -- down an east-west corridor covers different ground from the one that
+ -- stands it up, and the fit test is the thing that has to know
+ local fw, fh = BattleArena.extent(shape, pick.turn)
+ if fits(grid, gw, pick.x, pick.y, fw, fh) then
+ local arena = place(shape, pick.x, pick.y, pick.turn)
arena.map = host
-- which camera rig this spot is framed for; nil is the default long
-- lens, "close" the short one small rooms need (see BattleCam)
@@ -321,9 +383,11 @@ end
-- The arena at a given north-west corner, whatever the map says about it.
-- The authoring tool's manual override: a spot chosen by eye rather than by
-- the search, so it can be photographed and judged before it is written down.
-function BattleArena.at(x, y, shapeId)
+function BattleArena.at(x, y, shapeId, turn)
for _, shape in ipairs(BattleArena.SHAPES) do
- if shape.id == (shapeId or "wide") then return place(shape, x, y) end
+ if shape.id == (shapeId or "wide") then
+ return place(shape, x, y, turn)
+ end
end
return nil
end
diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua
index 3bd3170..160f4fd 100644
--- a/lib/BattleCam.lua
+++ b/lib/BattleCam.lua
@@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical)
-- arena's own axis, and the room the player has is all on the far side of
-- that -- out toward square-on. (orbitRange measures exactly that room.)
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
- local yaw = steer + (fixed and 0
+ -- ------- and the quarter turn the arena itself is standing at
+ --
+ -- An arena may be laid down any of the four ways (BattleArena's `turn`),
+ -- and the rig is solved for ONE of them: eye off the player's shoulder,
+ -- back down an axis that runs north-south. So the whole offset is turned
+ -- with the ground under it, which leaves the camera in exactly the same
+ -- place RELATIVE to the two mons -- same distance, same height, same
+ -- angle -- and therefore lands them on the same two screen anchors at the
+ -- same size. A turn is a fact about the map, never about the shot.
+ --
+ -- It goes in with the drift and the steer rather than beside them because
+ -- it is the same rotation about the same point; the player's own orbit is
+ -- then measured from wherever the arena starts, so both stops travel with
+ -- it and side-on stays side-on.
+ local base = math.rad(arena.turn or 0)
+ local yaw = base + steer + (fixed and 0
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
local c, s = math.cos(yaw), math.sin(yaw)
-- the breath scales the whole offset, height included, so the eye moves
@@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical)
local dz = (R.side * s + R.back * c) * k
local eye = { mx + dx, groundY + R.height * k, mz + dz }
- local focus = { mx + R.lookX, groundY + R.lookY, mz }
+ -- the aim's own offset turns with the arena too, and with the BASE alone --
+ -- the drift and the steer swing the eye about the focus, so a focus that
+ -- followed them would take the thing being orbited around with it
+ local bc, bs = math.cos(base), math.sin(base)
+ local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
-- and the climb: the eye swung UP about the focus, at a constant radius.
-- About the focus so the aim stays nailed to the two mons and only the
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 008d84b..52ad143 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -303,7 +303,11 @@ end
-- first drawn in.
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
+ -- `turn` is in the signature with the corner and the shape: the same corner
+ -- turned a quarter is a different footprint standing on different ground,
+ -- and a cast kept from the other one freezes the shadows across it
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
+ tostring(arena.turn or 0),
tostring(terrain), tostring(token or 0),
-- the cycle keeps running through a fight, and an arena lit
-- from somewhere new must be re-cast from there
diff --git a/tests/arena_config.lua b/tests/arena_config.lua
new file mode 100644
index 0000000..c72a258
--- /dev/null
+++ b/tests/arena_config.lua
@@ -0,0 +1,197 @@
+-- data/battle_arenas.lua, edited in place by line.
+--
+-- The arena editor (tests/arena_editor.lua) writes this file back every time
+-- a map is committed, and most of what is in it is PROSE: which alternatives
+-- were rejected for each area and what was wrong with them. That argument is
+-- the part a tool cannot reconstruct, so nothing here regenerates the file --
+-- an entry that changed has its own lines replaced, a new one is appended,
+-- and every other byte comes through exactly as it was.
+--
+-- Kept out of the driver so it can be tested without a game: a bug in here
+-- writes a config file, and the failure mode of a bad one is silent -- every
+-- map loses its authored spot and battles quietly start happening somewhere
+-- else. `apply` therefore never decides on its own that its output is good;
+-- it hands back the text and the caller parses it back and checks it says
+-- what was asked for before anything is overwritten.
+--
+-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
+-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
+-- shape = "narrow" } })
+--
+-- An edit's value is an entry table, `false` for an authored refusal, or
+-- Config.REMOVE to take the entry out altogether.
+local Config = {}
+
+Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
+
+-- The heading new maps are collected under, so a file worked over several
+-- sessions grows one list rather than one heading per session.
+Config.SECTION = " -- ------- added by tests/arena_editor"
+
+-- Lossless both ways: a trailing newline becomes a trailing empty line, and
+-- concat puts it back.
+function Config.splitLines(text)
+ local out, pos = {}, 1
+ while true do
+ local a, b = text:find("\n", pos, true)
+ if not a then
+ out[#out + 1] = text:sub(pos)
+ break
+ end
+ out[#out + 1] = text:sub(pos, a - 1)
+ pos = b + 1
+ end
+ return out
+end
+
+-- One entry, laid out the way the file already lays them out: on one line
+-- where it fits inside the file's own margin, and wrapped under the brace
+-- where it does not (which is how the cross-floor entries are written).
+function Config.entryLines(id, entry)
+ if entry == false then
+ return { (" [%q] = false,"):format(id) }
+ end
+ local head = (" [%q] = { "):format(id)
+ local parts = {}
+ if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
+ parts[#parts + 1] = ("x = %d"):format(entry.x)
+ parts[#parts + 1] = ("y = %d"):format(entry.y)
+ parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
+ if (entry.turn or 0) ~= 0 then
+ parts[#parts + 1] = ("turn = %d"):format(entry.turn)
+ end
+ if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
+ local one = head .. table.concat(parts, ", ") .. " },"
+ if #one <= 79 then return { one } end
+ local first = entry.map and 3 or 2
+ return {
+ head .. table.concat(parts, ", ", 1, first) .. ",",
+ (" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
+ }
+end
+
+-- Where an id's entry starts and ends, plus how many comment lines sit
+-- directly above it. The end is the first line that closes the table, so a
+-- wrapped entry is found whole rather than half-replaced.
+function Config.findEntry(lines, id)
+ local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
+ for i = 1, #lines do
+ if lines[i]:find(pat) then
+ local note = 0
+ while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
+ note = note + 1
+ end
+ if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
+ for j = i, #lines do
+ if lines[j]:find("}%s*,?%s*$") then return i, j, note end
+ end
+ return i, i, note
+ end
+ end
+ return nil
+end
+
+-- The `}` that closes the returned table: new entries go in above it.
+function Config.closingLine(lines)
+ for i = #lines, 1, -1 do
+ if lines[i]:find("^}") then return i end
+ end
+ return #lines + 1
+end
+
+-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
+--
+-- Returns the new text and a report: `changed`, `added`, `removed` and
+-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
+-- entries had comment lines directly above them, and a comment above an
+-- entry that has just moved describes where it USED to be.
+function Config.apply(text, edits)
+ local lines = Config.splitLines(text)
+ local report = { changed = {}, added = {}, removed = {}, stale = {} }
+
+ local ordered = {}
+ for id in pairs(edits) do ordered[#ordered + 1] = id end
+ table.sort(ordered)
+
+ local appended = {}
+ for _, id in ipairs(ordered) do
+ local entry = edits[id]
+ -- found again per edit rather than indexed once up front: each
+ -- replacement changes the line count under every index after it
+ local from, to, note = Config.findEntry(lines, id)
+ if entry == Config.REMOVE then
+ if from then
+ for _ = from, to do table.remove(lines, from) end
+ report.removed[#report.removed + 1] = id
+ if note > 0 then report.stale[#report.stale + 1] = id end
+ end
+ elseif from then
+ local new = Config.entryLines(id, entry)
+ for _ = from, to do table.remove(lines, from) end
+ for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
+ report.changed[#report.changed + 1] = id
+ if note > 0 then report.stale[#report.stale + 1] = id end
+ else
+ appended[#appended + 1] = id
+ report.added[#report.added + 1] = id
+ end
+ end
+
+ if #appended > 0 then
+ local at, block = nil, {}
+ for i = 1, #lines do
+ if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
+ at = i + 1
+ end
+ end
+ if not at then
+ at = Config.closingLine(lines)
+ block[#block + 1] = ""
+ block[#block + 1] = Config.SECTION
+ end
+ for _, id in ipairs(appended) do
+ for _, l in ipairs(Config.entryLines(id, edits[id])) do
+ block[#block + 1] = l
+ end
+ end
+ for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
+ end
+
+ return table.concat(lines, "\n"), report
+end
+
+-- Whether two entries say the same thing. `false` and nil are values here --
+-- a refusal is not the absence of one -- so this is an equality test and not
+-- a truthiness one.
+function Config.same(a, b)
+ if a == b then return true end
+ if type(a) ~= "table" or type(b) ~= "table" then return false end
+ return a.x == b.x and a.y == b.y
+ and (a.shape or "wide") == (b.shape or "wide")
+ and (a.turn or 0) == (b.turn or 0)
+ and a.cam == b.cam and a.map == b.map
+end
+
+-- Parse a config back and check it says what was committed. This is what
+-- stands between a bug in the surgery above and a data file that no longer
+-- loads, so it is run on the TEXT before that text replaces anything.
+-- Returns the parsed table, or nil and why not.
+function Config.verify(text, edits, chunkName)
+ local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
+ if not chunk then return nil, "would not compile: " .. tostring(err) end
+ local ok, list = pcall(chunk)
+ if not (ok and type(list) == "table") then
+ return nil, "did not return a table: " .. tostring(list)
+ end
+ for id, want in pairs(edits) do
+ local got = list[id]
+ if want == Config.REMOVE then
+ if got ~= nil then return nil, id .. " is still in the file" end
+ elseif not Config.same(want, got) then
+ return nil, id .. " did not read back as it was committed"
+ end
+ end
+ return list
+end
+
+return Config
diff --git a/tests/arena_editor.lua b/tests/arena_editor.lua
new file mode 100644
index 0000000..b540b11
--- /dev/null
+++ b/tests/arena_editor.lua
@@ -0,0 +1,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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index a1d46e7..ee96136 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -2909,6 +2909,109 @@ T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
T.check(span(closeRig, shot.player) < span(rig, shot.player),
"the mons render smaller on it, which is what it trades for fitting")
+-- ------- the quarter turns
+--
+-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
+-- the whole claim of the feature is that this is a fact about the GROUND and
+-- never about the shot: the footprint, the two mons and the camera turn
+-- together, so the pair land on the same two anchors at the same size and
+-- only what is behind them changes.
+--
+-- Asserted by REPROJECTING each turn through the real rig, exactly the way
+-- the unturned shot is checked above -- so a rig retune, or a sign error in
+-- the rotation, says so here rather than in a screenshot nobody takes.
+--
+-- In its own scope: the suite's main chunk sits at LuaJIT's 200-active-local
+-- ceiling, and every local below would be one more of them. The leading
+-- semicolon is the file's own convention -- without it the previous
+-- statement's `)` and this `(` parse as one call.
+;(function()
+ local corner = { shot.x, shot.y }
+ -- How wide a mon's own square comes out, measured ACROSS the arena's axis.
+ -- `span` above offsets along world X, which is across the axis only while
+ -- the arena is standing the way the mode was drawn; on the odd quarters the
+ -- axis IS X, so offsetting along it would measure the square's depth --
+ -- foreshortened by a camera that is looking almost straight down it -- and
+ -- report a shrinking mon that is nothing of the kind.
+ local function acrossSpan(cam, point, turn)
+ local d = (turn % 180 == 0) and { 8, 0 } or { 0, 8 }
+ local a = project(cam, { point[1] - d[1], point[2] - d[2] })
+ local b = project(cam, { point[1] + d[1], point[2] + d[2] })
+ return math.abs(b - a)
+ end
+ for _, turn in ipairs({ 0, 90, 180, 270 }) do
+ BattleCam.reset()
+ local a = BattleArena.at(corner[1], corner[2], "wide", turn)
+ T.check(a ~= nil, ("the wide arena places at turn %d"):format(turn))
+ T.eq(a.turn, turn, ("and carries the turn it was placed at (%d)"):format(turn))
+
+ -- the footprint swaps its reach on the odd quarters, which is the whole
+ -- reason a corridor takes one way round and refuses the other
+ local wantW = (turn % 180 == 0) and 3 or 6
+ local wantH = (turn % 180 == 0) and 6 or 3
+ T.eq(a.w, wantW, ("turn %d is %d cells across"):format(turn, wantW))
+ T.eq(a.h, wantH, ("and %d deep"):format(wantH))
+
+ -- both mons stay inside the footprint they were turned within, and stay
+ -- three cells apart -- the gap the move animations are scaled against
+ T.check(a.enemyCell[1] >= a.x and a.enemyCell[1] < a.x + a.w
+ and a.enemyCell[2] >= a.y and a.enemyCell[2] < a.y + a.h,
+ ("turn %d keeps the foe inside the footprint"):format(turn))
+ T.check(a.playerCell[1] >= a.x and a.playerCell[1] < a.x + a.w
+ and a.playerCell[2] >= a.y and a.playerCell[2] < a.y + a.h,
+ ("turn %d keeps the player inside it too"):format(turn))
+ local gap = math.abs(a.enemyCell[1] - a.playerCell[1])
+ + math.abs(a.enemyCell[2] - a.playerCell[2])
+ T.eq(gap, 3, ("turn %d still stands them three cells apart"):format(turn))
+
+ -- and which way the foe lies from the player, which is what `turn` NAMES
+ local dx = a.enemyCell[1] - a.playerCell[1]
+ local dy = a.enemyCell[2] - a.playerCell[2]
+ local want = ({ [0] = { 0, -3 }, [90] = { 3, 0 },
+ [180] = { 0, 3 }, [270] = { -3, 0 } })[turn]
+ T.check(dx == want[1] and dy == want[2],
+ ("turn %d puts the foe %d,%d from the player: got %d,%d")
+ :format(turn, want[1], want[2], dx, dy))
+
+ -- THE INVARIANT: the same composition, whichever way round it stands
+ local r = BattleCam.rig(a, 0)
+ local ppx, ppy = project(r, a.player)
+ local eex, eey = project(r, a.enemy)
+ T.check(ppx ~= nil and eex ~= nil,
+ ("turn %d keeps both marks in front of the camera"):format(turn))
+ T.check(math.abs(ppx - 26) < 1 and math.abs(ppy - 96) < 1,
+ ("turn %d lands the player's mark on its anchor: (%.2f, %.2f)")
+ :format(turn, ppx, ppy))
+ T.check(math.abs(eex - 124) < 1 and math.abs(eey - 56) < 1,
+ ("turn %d lands the enemy's on its own: (%.2f, %.2f)")
+ :format(turn, eex, eey))
+ T.check(math.abs(acrossSpan(r, a.player, turn) - 64) < 4,
+ ("turn %d still makes the player's square a back pic wide (64px): got "
+ .. "%.2f"):format(turn, acrossSpan(r, a.player, turn)))
+ T.check(math.abs(acrossSpan(r, a.enemy, turn) - 56) < 4,
+ ("turn %d still makes the foe's square a front pic wide (56px): got "
+ .. "%.2f"):format(turn, acrossSpan(r, a.enemy, turn)))
+
+ -- the eye really did move: a turn that left the camera where it was would
+ -- pass every anchor test above by looking at the pair from the side
+ if turn ~= 0 then
+ local base = BattleArena.at(corner[1], corner[2], "wide", 0)
+ BattleCam.reset()
+ local r0 = BattleCam.rig(base, 0)
+ local moved = math.abs(r.eye[1] - r0.eye[1])
+ + math.abs(r.eye[3] - r0.eye[3])
+ T.check(moved > 16,
+ ("turn %d actually moves the camera (%.1f world px)"):format(turn, moved))
+ end
+ end
+
+ -- degrees in, degrees out, and anything else folded onto the four
+ T.eq(BattleArena.quarters(360), 0, "a full turn is no turn")
+ T.eq(BattleArena.quarters(-90), 3, "and a negative one comes round the back")
+ T.eq(BattleArena.at(1, 1, "wide").turn, 0,
+ "an arena placed without a turn is the way the mode was drawn")
+end)()
+
-- an arena picks its rig by name, and anything unnamed gets the default
T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide,
"an arena that asks for the wide lens gets it")
diff --git a/tests/stadium_all_shots.lua b/tests/stadium_all_shots.lua
new file mode 100644
index 0000000..1c09cc9
--- /dev/null
+++ b/tests/stadium_all_shots.lua
@@ -0,0 +1,157 @@
+-- Driver: one STADIUM screenshot per species, every one of the 151 standing
+-- on the ENEMY mark in its standby loop.
+--
+-- stadium_shots.lua photographs a handful of deliberately awkward cases; this
+-- is the exhaustive sweep behind it. What it exists to catch is the class of
+-- fault that is per-MODEL and invisible to the headless QA pass -- a texture
+-- that comes out magenta, a mon standing in the floor or floating over it, a
+-- silhouette that is subtly the wrong shape -- none of which is a number
+-- stadium_anim_qa.lua can measure.
+--
+-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \
+-- POKEPORT_SPEED=4 \
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \
+-- lovec.exe .
+--
+-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT.
+--
+-- ------- everything but the foe is nailed down
+--
+-- The whole value of 151 shots is that they are comparable, so the staging is
+-- identical in every one: same map, same cells, same hour (the day/night
+-- cycle would otherwise relight every shot), same rung, and the same small
+-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest
+-- model in the set and so hides the least of the arena behind it. Whatever
+-- differs between two of these shots is the foe.
+--
+-- ------- the model/sprite verdict is the other half
+--
+-- A species whose pack will not load, or whose animation data the packer
+-- declined, does not error: StadiumMon.setSpecies answers false and the mode
+-- quietly stands the Game Boy's flat pic on the tile instead. That is the
+-- correct behaviour and it is nearly invisible in a screenshot at a glance --
+-- which is exactly why it needs counting rather than looking. Stadium.showing
+-- is asked for every species and the ones that decline are listed at the end.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify"
+ local Pokemon = require("src.pokemon.Pokemon")
+ local BattleState = require("src.battle.BattleState")
+
+ 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 Battles = lib.require("OverworldBattle")
+ local Stadium = lib.require("Stadium")
+ local Pack = lib.require("StadiumPack")
+ local Install = lib.require("StadiumInstall")
+
+ -- every real species the merged data carries, in dex order
+ local species = {}
+ for id, def in pairs(game.data.pokemon) do
+ if type(id) == "string" and type(def) == "table"
+ and def.dex and def.dex >= 1 and def.dex <= 151 then
+ species[#species + 1] = { id = id, dex = def.dex }
+ end
+ end
+ table.sort(species, function(a, b) return a.dex < b.dex end)
+
+ game.save.player.name = "RED"
+
+ -- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the
+ -- mod's update hook on the first frame the overworld is the top state, so
+ -- waiting for it before teleporting waits forever.
+ U.teleport(game, "ROUTE_1", 5, 8, "down")
+
+ local waited = 0
+ while (Install.pending() or Install.status.state == "building")
+ and waited < 5400 do
+ U.wait(10)
+ waited = waited + 10
+ end
+ if waited > 0 then
+ U.log(("waited %.1fs for the model build -- %s")
+ :format(waited / 60, tostring(Install.status.state)))
+ end
+
+ Battles.setting:setValue("stadium", game)
+ Battles.backSetting:setValue(false, game)
+ lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
+
+ local have = 0
+ for dex = 1, 151 do
+ if Pack.available(dex) then have = have + 1 end
+ end
+ U.log(("%d species, %d/151 packs on disk, rung = %s")
+ :format(#species, have, tostring(Battles.setting:get())))
+
+ -- let the neighbourhood's meshes land, so the first fight opens on the
+ -- real arena rather than the flat fallback
+ U.wait(90)
+
+ local asPic, stuck, missed = {}, {}, {}
+
+ for _, s in ipairs(species) do
+ -- the player's side is a constant (see the header), so the only thing
+ -- that changes between two shots is the Pokemon being verified
+ game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) }
+
+ local battle = BattleState.newWild(game, s.id, 30)
+ battle.onFinish = function() end
+ game.overworld:pushBattle(battle)
+
+ -- the wipe and the intro chatter, then far enough past it that the
+ -- player's own Pokemon has been sent out -- before that the player's
+ -- side is legitimately the trainer's back sprite and the foe is still
+ -- sliding in
+ U.wait(70)
+ for _ = 1, 40 do
+ if battle.phase == "menu" then break end
+ U.tap(game, "a")
+ U.wait(10)
+ end
+ if battle.phase ~= "menu" then
+ stuck[#stuck + 1] = s.id
+ U.log(("STUCK before menu: %s (phase %s)")
+ :format(s.id, tostring(battle.phase)))
+ end
+ -- and let the send-out beat finish so the mon is standing still in its
+ -- standby loop rather than mid-arrival
+ U.wait(45)
+
+ -- model or flat pic? asked here, with the fight actually on screen
+ if not Stadium.showing("enemy") then
+ asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id)
+ end
+
+ local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())
+ if not U.shot(game, path) then
+ missed[#missed + 1] = s.id
+ end
+
+ while game.stack:top() and game.stack:top() ~= game.overworld do
+ game.stack:pop()
+ end
+ U.wait(10)
+ end
+
+ U.log(("---- %d shots -> %s"):format(#species - #missed, DIR))
+ if #asPic > 0 then
+ U.log(("%d species declined to a model and were shot as FLAT PICS: %s")
+ :format(#asPic, table.concat(asPic, ", ")))
+ else
+ U.log("all species stood as MODELS -- none fell back to a flat pic")
+ end
+ if #stuck > 0 then
+ U.log(("%d never reached the battle menu: %s")
+ :format(#stuck, table.concat(stuck, ", ")))
+ end
+ if #missed > 0 then
+ U.log(("%d screenshots did not reach disk: %s")
+ :format(#missed, table.concat(missed, ", ")))
+ end
+ U.log("done -- " .. DIR)
+end