DramaticShapeVoxelMod archival mirror · read-only
tests/arena_pick.lua

arena_pick.lua

tests/arena_pick.lua9.7 KB10064406bd92aeef
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
-- Driver: choose and photograph one battle arena per map.
--
-- data/battle_arenas.lua holds one authored spot per area. This is what
-- authors it: for every map a battle can happen on, it asks BattleArena for
-- the arena nearest the map's middle that its clearance test says both mons
-- can be SEEN in, stages a real battle there, and takes one screenshot.
--
-- Two outputs. A `PICK` line per map, ready to paste into the data file, and
-- a PNG per map to look at -- because the clearance test answers a geometry
-- question ("nothing tall on the sightline") and the actual question is
-- whether the picture reads. Grass and flowers around a mon's feet are fine
-- and wanted; a body cut in half by a wall is not, and only an eye catches
-- the difference.
--
--   SHOT_DIR=.scratchpad/arenas ARENA_FROM=1 ARENA_COUNT=20 \
--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
--
-- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
-- centred in the map's biggest body of it, which is what the surf routes
-- want. SHOT_DIR must already exist -- the capture writes with io.open,
-- which does not create directories.
return function(game)
  local U = dofile("tests/drivers/util.lua")
  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
  local Pokemon = require("src.pokemon.Pokemon")
  local BattleState = require("src.battle.BattleState")

  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
  game.save.player.name = "RED"

  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")

  -- ------- staging out on the water
  --
  -- A surf route's land is a rim of beach round the edge of the map, so the
  -- ordinary search always picks the rim and the fight happens on sand at
  -- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
  -- counts as ground, and the search starts from the middle of the map's
  -- BIGGEST body of water rather than the middle of the map, so the arena
  -- lands out in the open sea instead of against the first shoreline it
  -- finds.
  --
  -- Biggest body, not all water at once: a map with a lake and an ocean has
  -- a centroid between them that is on neither, and the arena would be
  -- pinned to whichever shore that landed nearest.
  local function waterCentre(map)
    local w, h = map.widthCells, map.heightCells
    local seen, best = {}, nil
    for y0 = 0, h - 1 do
      for x0 = 0, w - 1 do
        if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
          -- one connected body, flooded from this cell
          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 map: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
    return best
  end

  -- Which maps a battle can actually happen on: anything with a wild
  -- encounter table or an object that fights. Everything else -- a shop
  -- floor, a stairwell, a bedroom -- would be 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. An earlier cut of this read `def.encounters`, which is
  -- always nil -- so the list silently narrowed to trainer maps only and
  -- every route, cave and Safari zone fell out of it. Nothing failed; the
  -- tool just quietly stopped offering to author the places wild battles
  -- actually happen.
  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

  local from = tonumber(os.getenv("ARENA_FROM") or "") or 1
  local count = tonumber(os.getenv("ARENA_COUNT") or "") or #ids
  U.log(("%d battle maps; doing %d..%d"):format(#ids, from,
                                                math.min(#ids, from + count - 1)))
  -- ARENA_COUNT=0 lists what WOULD be done and stops. The set this filter
  -- picks is the authoritative answer to "which areas need an arena", so it
  -- has to be readable without sitting through a capture -- that is how the
  -- bug above went unnoticed.
  if count <= 0 then
    for i = 1, #ids, 8 do
      U.log("LIST  " .. table.concat(ids, ",", i, math.min(#ids, i + 7)))
    end
    U.log("done -- listing only")
    return
  end

  for i = from, math.min(#ids, from + count - 1) do
    local id = ids[i]
    local def = game.data.maps[id]
    local cx = math.floor(def.width)          -- the middle, in cells
    local cy = math.floor(def.height)

    -- the pick is made against the MAP, before anything is staged, so a map
    -- with no clear arena at all is reported rather than quietly given a bad
    -- one
    local ok, err = pcall(function()
      U.teleport(game, id, 1, 1, "down")
    end)
    if not ok then
      U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err)))
    else
      local map = game.overworld.map
      local clear, any
      -- ARENA_AT=x,y[,shape] photographs one specific spot instead of the
      -- picked one, which is how a map the automatic choice got wrong is
      -- re-aimed by hand and checked in the same loop.
      -- ARENA_MAP=OTHER_ID stages on another floor of the same cave or
      -- building, for a map that has nowhere of its own to put a fight
      local at = os.getenv("ARENA_AT")
      local onMap = os.getenv("ARENA_MAP")
      if at and at ~= "" and (explicit and explicit ~= "") then
        local ax, ay, ashape = at:match("^(%-?%d+)%s*,%s*(%-?%d+)%s*,?%s*(%a*)$")
        if ax then
          -- forced through the authored-entry seam, so what gets staged is
          -- exactly what was asked for rather than whatever the search
          -- would have picked from the player's cell
          local onCam = os.getenv("ARENA_CAM")
          Arena.setOverride(id, {
            x = tonumber(ax), y = tonumber(ay),
            shape = (ashape ~= "" and ashape) or "wide",
            map = (onMap ~= "" and onMap) or nil,
            cam = (onCam and onCam ~= "" and onCam) or nil,
          })
          any = Arena.find(map, 0, 0, false)
          clear = any and Arena.clearance(any.map or map, any) or false
        end
      end
      local surf = os.getenv("ARENA_SURF")
      surf = surf ~= nil and surf ~= "" and surf ~= "0"
      if not any then
        local ox, oy = cx, cy
        if surf then
          local sea = waterCentre(map)
          if sea then
            ox, oy = sea.x, sea.y
            U.log(("SEA    %s: biggest body is %d cells, centre %.1f,%.1f")
                  :format(id, sea.n, sea.x, sea.y))
          else
            U.log("SEA    " .. id .. ": no water on this map")
          end
        end
        clear = Arena.search(map, ox, oy, surf, true)
        any = clear or Arena.search(map, ox, oy, surf)
      end
      if not any then
        U.log(("NONE %s -- no arena of either shape"):format(id))
      else
        local onOther = any.map and any.map.id ~= id and any.map.id or nil
        U.log(("PICK   [%q] = { %sx = %d, y = %d, shape = %q%s },%s")
              :format(id, onOther and ("map = %q, "):format(onOther) or "",
                      any.x, any.y, any.shape,
                      any.cam and (", cam = %q"):format(any.cam) or "",
                      clear and "" or "   -- OBSTRUCTED: no clear spot"))
        -- stand the player on the arena so the staged battle uses it, then
        -- fight something there and photograph the result
        game.overworld.player.cellX = any.playerCell[1]
        game.overworld.player.cellY = any.playerCell[2]
        U.wait(90)                            -- let the meshes land
        local battle = BattleState.newWild(game, "NIDORINO", 20)
        battle.onFinish = function() end
        game.overworld:pushBattle(battle)
        U.wait(70)
        for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
        local got = Battles.arena()
        U.log(("SHOT   %s at %s,%s"):format(id,
              tostring(got and got.x), tostring(got and got.y)))
        U.shot(game, ("%s/%s.png"):format(DIR, id:lower()))
        while game.stack:top() and game.stack:top() ~= game.overworld do
          game.stack:pop()
        end
        U.wait(6)
      end
    end
  end

  U.log("done -- " .. DIR)
end