mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 18:41:09 +02:00
1618 lines
63 KiB
Lua
1618 lines
63 KiB
Lua
-- 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
|