update all battle locations

This commit is contained in:
DramaticShape
2026-08-05 20:36:46 -04:00
parent 77e0f93315
commit 6240b50cec
9 changed files with 2260 additions and 75 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## Unreleased
## 1.6.1
### Fixed
+88 -64
View File
@@ -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" },
}
+72 -8
View File
@@ -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
+21 -2
View File
@@ -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
+4
View File
@@ -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
+197
View File
@@ -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
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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")
+157
View File
@@ -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